From 2d83365f299ce08ea0cbacba77e8a6c334ad62af Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo Date: Thu, 18 Jun 2026 03:59:04 +0000 Subject: [PATCH 1/2] Collect parse-boundary GC pressure --- crates/perry-runtime/src/gc/policy.rs | 39 +++++++++++-- crates/perry-runtime/src/gc/tests/triggers.rs | 57 +++++++++++++++++++ crates/perry-runtime/src/json/parse_api.rs | 7 ++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 6978729ae7..80af45ef0f 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -265,6 +265,7 @@ thread_local! { pub(super) const GC_SUPPRESSED_TINY_PARSE_BYTES: usize = 1024 * 1024; pub(super) const GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES: usize = 48 * 1024 * 1024; pub(super) const GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES: usize = 24 * 1024 * 1024; +const GC_SUPPRESSED_PARSE_BOUNDARY_DRAIN_STEPS: usize = 128; pub(super) fn gc_suppressed_parse_is_tiny(parse_growth: usize) -> bool { parse_growth <= GC_SUPPRESSED_TINY_PARSE_BYTES @@ -626,7 +627,6 @@ pub fn gc_bump_malloc_trigger() { trigger.set(bytes_now); } }); - gc_check_trigger(); } else { crate::arena::arena_start_fresh_general_block(); GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true)); @@ -634,12 +634,12 @@ pub fn gc_bump_malloc_trigger() { } } -/// Run a full collection that was armed by tiny JSON parse churn. +/// Run a collection that was armed by tiny JSON parse churn. /// -/// This is separate from the raise-only post-parse trigger bump. Full -/// mark-sweep needs the collection to happen before the next suppressed parse, -/// not immediately after the previous one, otherwise the parse result is still -/// rooted and every churn block looks partially live. +/// This is separate from the raise-only post-parse trigger bump. The collection +/// needs to happen before the next suppressed parse, not immediately after the +/// previous one, otherwise the parse result is still rooted and every churn +/// block looks partially live. pub fn gc_collect_pending_suppressed_parse() { let pending = GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| { let was_pending = pending.get(); @@ -656,6 +656,33 @@ pub fn gc_collect_pending_suppressed_parse() { return; } + if gc_budgeted_cycle_active() { + for _ in 0..GC_SUPPRESSED_PARSE_BOUNDARY_DRAIN_STEPS { + let result = gc_runtime_safepoint(); + match result.status { + JS_GC_STEP_STATUS_ACTIVE => continue, + JS_GC_STEP_STATUS_COMPLETED | JS_GC_STEP_STATUS_IDLE => break, + JS_GC_STEP_STATUS_SKIPPED => { + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true)); + return; + } + _ => break, + } + } + if gc_budgeted_cycle_active() { + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true)); + return; + } + } + + let in_use = crate::arena::arena_in_use_bytes(); + if gen_gc_enabled() && in_use >= GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES { + let outcome = + gc_collect_inner_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::ArenaBytes)); + gc_finish_arena_trigger_collection(in_use, outcome); + return; + } + let total = crate::arena::arena_total_bytes(); GC_NEXT_TRIGGER_BYTES.with(|trigger| { if trigger.get() > total { diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 2c3ca6a14e..8240aad4e5 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -9,6 +9,7 @@ struct GcBumpTriggerTestGuard { malloc_step: usize, trigger_bumped: bool, pre_suppress_bytes: usize, + suppressed_parse_pending: bool, } impl GcBumpTriggerTestGuard { @@ -36,6 +37,11 @@ impl GcBumpTriggerTestGuard { previous }), pre_suppress_bytes: GC_PRE_SUPPRESS_BYTES.with(|bytes| bytes.get()), + suppressed_parse_pending: GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| { + let previous = pending.get(); + pending.set(false); + previous + }), }; GC_PRE_SUPPRESS_BYTES.with(|bytes| bytes.set(0)); previous @@ -66,9 +72,17 @@ impl Drop for GcBumpTriggerTestGuard { GC_MALLOC_COUNT_STEP.with(|step| step.set(self.malloc_step)); GC_TRIGGER_BUMPED.with(|bumped| bumped.set(self.trigger_bumped)); GC_PRE_SUPPRESS_BYTES.with(|bytes| bytes.set(self.pre_suppress_bytes)); + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING + .with(|pending| pending.set(self.suppressed_parse_pending)); } } +fn reset_old_reclaim_pressure() { + let old_in_use = crate::arena::old_gen_in_use_bytes(); + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use)); + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); +} + #[test] fn test_gc_bump_tiny_parse_caps_arena_trigger_at_collector_ceiling() { let _guard = GcBumpTriggerTestGuard::new(0, GC_THRESHOLD_INITIAL_BYTES); @@ -172,6 +186,49 @@ fn test_gc_bump_never_lowers_existing_arena_trigger() { assert!(!GcBumpTriggerTestGuard::trigger_bumped()); } +#[test] +fn test_pending_tiny_parse_boundary_collection_completes_and_rebaselines() { + let _trace_guard = TestGcTraceCaptureGuard::force_enabled(); + let _copying_guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _bump_guard = GcBumpTriggerTestGuard::new(usize::MAX, GC_THRESHOLD_INITIAL_BYTES); + reset_old_reclaim_pressure(); + + let live = crate::string::js_string_from_bytes(b"parse-boundary-live".as_ptr(), 19) as usize; + js_shadow_slot_set(0, string_bits(live)); + + while crate::arena::arena_in_use_bytes() + < GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES + (2 * 1024 * 1024) + { + let _ = crate::arena::arena_alloc_gc(8 * 1024, 8, GC_TYPE_STRING); + } + let pre_in_use = crate::arena::arena_in_use_bytes(); + let before = gc_collection_count(); + + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true)); + gc_collect_pending_suppressed_parse(); + + assert_eq!(gc_collection_count(), before + 1); + assert!( + crate::arena::arena_in_use_bytes() < pre_in_use / 8, + "parse-boundary collection should reclaim dead tiny-parse churn" + ); + assert!( + GC_NEXT_TRIGGER_BYTES.with(|trigger| trigger.get()) > crate::arena::arena_total_bytes(), + "completed boundary collection should rebaseline the arena trigger" + ); + + let event = + take_test_last_gc_trace_json().expect("parse-boundary collection should emit trace JSON"); + assert_eq!(event["collection_kind"].as_str(), Some("minor")); + assert_eq!(event["trigger"]["kind"].as_str(), Some("arena_bytes")); + + let live_after = (js_shadow_slot_get(0) & POINTER_MASK) as *const crate::StringHeader; + unsafe { + assert_string_bytes(live_after, b"parse-boundary-live"); + } +} + #[test] fn test_old_reclaim_pressure_uses_threshold_and_growth() { assert!(!old_reclaim_pressure_due( diff --git a/crates/perry-runtime/src/json/parse_api.rs b/crates/perry-runtime/src/json/parse_api.rs index e56a9cfdac..1a7d3891c7 100644 --- a/crates/perry-runtime/src/json/parse_api.rs +++ b/crates/perry-runtime/src/json/parse_api.rs @@ -325,8 +325,9 @@ fn should_use_tape_parse(len: usize, bytes: &[u8]) -> bool { /// /// Wraps the tape path in the same GC-safety contract as the direct /// parser (pending parse-boundary collection → gc_check_trigger → -/// suppress → parse → unsuppress → bump malloc trigger + cache trim) so -/// it's a drop-in replacement behind the feature flag. +/// suppress → parse → unsuppress → bump malloc trigger + parse-boundary +/// scheduling + cache trim) so it's a drop-in replacement behind the feature +/// flag. pub(crate) unsafe fn try_parse_via_tape( text_ptr: *const StringHeader, bytes: &[u8], @@ -357,6 +358,7 @@ pub(crate) unsafe fn try_parse_via_tape( crate::gc::gc_unsuppress(); crate::gc::gc_bump_malloc_trigger(); + crate::gc::gc_schedule_parse_boundary_collection_if_pressure(); parse_root_restore(text_root); PARSE_KEY_CACHE.with(|c| { @@ -448,6 +450,7 @@ pub unsafe extern "C" fn js_json_parse_typed_array( crate::gc::gc_unsuppress(); crate::gc::gc_bump_malloc_trigger(); + crate::gc::gc_schedule_parse_boundary_collection_if_pressure(); parse_root_restore(text_root); PARSE_KEY_CACHE.with(|c| { From 7169ab76654becd56ebe7f0570e454dd3718ed75 Mon Sep 17 00:00:00 2001 From: Andrew DiZenzo <59515127+andrewtdiz@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:15:32 -0600 Subject: [PATCH 2/2] Prioritize old reclaim at parse boundaries (#5380) --- crates/perry-runtime/src/gc/policy.rs | 14 ++++++ crates/perry-runtime/src/gc/tests/triggers.rs | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 80af45ef0f..bc85bb221c 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -676,6 +676,20 @@ pub fn gc_collect_pending_suppressed_parse() { } let in_use = crate::arena::arena_in_use_bytes(); + if gen_gc_enabled() { + let old_pending = GC_OLD_RECLAIM_PENDING.with(|pending| pending.get()); + let old_in_use = crate::arena::old_gen_in_use_bytes(); + let old_baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()); + if old_pending || old_reclaim_pressure_due(old_in_use, old_baseline) { + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture( + GcTriggerKind::OldGenBytes, + )) + .emit_after_current(); + return; + } + } + if gen_gc_enabled() && in_use >= GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES { let outcome = gc_collect_inner_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::ArenaBytes)); diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 8240aad4e5..0fa1080e3b 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -229,6 +229,53 @@ fn test_pending_tiny_parse_boundary_collection_completes_and_rebaselines() { } } +#[test] +fn test_pending_parse_boundary_prioritizes_old_reclaim_pressure() { + let _trace_guard = TestGcTraceCaptureGuard::force_enabled(); + let _copying_guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _bump_guard = GcBumpTriggerTestGuard::new(usize::MAX, GC_THRESHOLD_INITIAL_BYTES); + + let live_old = crate::arena::arena_alloc_gc_old(40, 8, GC_TYPE_STRING) as usize; + js_shadow_slot_set(0, ptr_bits(live_old)); + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(0)); + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(true)); + + let mut dead_bytes = 0usize; + let dead_chunks = (GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES / (1024 * 1024)) + 2; + for _ in 0..dead_chunks { + let dead = crate::arena::arena_alloc_gc_old(1024 * 1024, 8, GC_TYPE_STRING) as usize; + let (_header, total) = old_test_header_and_size(dead); + dead_bytes = dead_bytes.saturating_add(total); + } + let old_before = crate::arena::old_gen_in_use_bytes(); + let before = gc_collection_count(); + + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true)); + gc_collect_pending_suppressed_parse(); + + assert_eq!(gc_collection_count(), before + 1); + assert!( + crate::arena::old_gen_in_use_bytes() < old_before / 4, + "parse-boundary old reclaim should reclaim dead old JSON pressure" + ); + assert!( + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()) + <= crate::arena::old_gen_in_use_bytes(), + "completed old reclaim should rebaseline old-generation pressure" + ); + assert!(dead_bytes >= GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES); + + let event = + take_test_last_gc_trace_json().expect("parse-boundary old reclaim should emit trace JSON"); + assert_eq!(event["collection_kind"].as_str(), Some("full")); + assert_eq!(event["trigger"]["kind"].as_str(), Some("old_gen_bytes")); + + let live_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_eq!(live_after, live_old); + assert!(crate::arena::pointer_in_old_gen(live_after)); +} + #[test] fn test_old_reclaim_pressure_uses_threshold_and_growth() { assert!(!old_reclaim_pressure_due(