From a30d6bd3c28815c06a8ff4b3967d946fe56d615f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 13:58:30 +0200 Subject: [PATCH 1/6] perf(gc): pace parse-boundary collection on JSON side-allocation bytes A lazily-parsed document's memory is not in the arena: a 13 KB records_array_16k parse puts ~1.1 KB (header + sparse cache + bitmap) in the nursery and ~24 KB of tape in a json_tape_store side allocation. Both arms of the parse-boundary dueness predicate are denominated in arena bytes, so the young generation read 1/24th of what the process held and the loop reached its nursery cap ~24x later than the memory said. Add a third arm keyed on external_side_live_bytes(), with the max(floor, baseline) growth band the old-gen reclaim already uses, based at the reading the last collection left behind. --- crates/perry-runtime/src/gc/policy.rs | 81 ++++++- .../src/gc/tests/tiny_parse_pressure.rs | 229 ++++++++++++++++++ 2 files changed, 307 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 1c33d1e9d4..548ac16850 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -478,8 +478,15 @@ pub(super) fn tiny_parse_pressure_due_with( /// is safe to consult where the absolute in-use guard was not: a minor lowers /// the quantity it tests to the survivors, so it cannot fire again until the /// cap has been refilled. +/// +/// #10262 adds the third arm for the same reason the second one exists, one +/// currency over: both of the first two are denominated in ARENA bytes, and a +/// lazily-parsed document's bytes are not in the arena at all. See +/// [`external_side_parse_pressure_due`]. pub(super) fn tiny_parse_generational_collection_due(in_use: usize, in_use_trigger: usize) -> bool { - tiny_parse_pressure_due(in_use, in_use_trigger) || young_scavenge_cap_due() + tiny_parse_pressure_due(in_use, in_use_trigger) + || young_scavenge_cap_due() + || external_side_parse_pressure_due() } /// The live [`tiny_parse_pressure_due_with`]: current base and step. @@ -522,13 +529,22 @@ fn diag_tiny_parse_forced_collection(site: &str, in_use: usize) { } let base = GC_TINY_PARSE_PRESSURE_BASE_BYTES.with(Cell::get); let step = GC_STEP_BYTES.with(Cell::get); + // #10262: the side-allocation arm's own inputs, so a diag reader can tell + // which of the three arms priced this collection rather than re-deriving + // it — the same "a gate must assert its subject was live" rule. + let external = external_side_live_bytes(); + let external_base = GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(Cell::get); eprintln!( - "[gc-tiny-parse] forced collection site={} in_use={} base={} headroom={} step={}", + "[gc-tiny-parse] forced collection site={} in_use={} base={} headroom={} step={} \ + external_side={} external_base={} external_band={}", site, in_use, base, tiny_parse_pressure_headroom_bytes(step), - step + step, + external, + external_base, + external_side_parse_band_bytes(external_base) ); } @@ -631,6 +647,12 @@ const GC_EXTERNAL_SIDE_ALLOC_STEP: usize = 16 * 1024 * 1024; crate::perry_thread_local! { static GC_EXTERNAL_SIDE_ALLOC_PENDING: std::cell::Cell = const { std::cell::Cell::new(0) }; static GC_EXTERNAL_SIDE_LIVE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// #10262: [`external_side_live_bytes`] as the last collection ended — the + /// base of the parse-boundary growth band + /// ([`external_side_parse_pressure_due_with`]). A byte COUNT, never an + /// address; written only from `note_collection_finished_arena_occupancy`. + pub(super) static GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES: std::cell::Cell = + const { std::cell::Cell::new(0) }; } /// Live bytes currently held by external Map/Set side buffers on this thread. @@ -639,6 +661,53 @@ pub(super) fn external_side_live_bytes() -> usize { GC_EXTERNAL_SIDE_LIVE_BYTES.with(Cell::get) } +/// #10262: how many bytes of external side allocation may accumulate past the +/// last collection before a `JSON.parse` boundary is due. +/// +/// Deliberately the `max(floor, proportional)` shape of +/// [`gc_old_reclaim_growth_band_bytes`], for the two reasons that shape exists: +/// +/// * a program whose live side set is genuinely large (a retained multi-MB +/// `Map`) must not collect once per parse, so the band grows with it; and +/// * a collection that CANNOT lower the number this band watches re-baselines +/// it at the surviving value, so futile repeats space out geometrically +/// instead of firing at a constant step. That is what keeps this arm off the +/// #7437/#7592 livelock: a lazy array whose cluster was born OLD +/// (`json_tape::lazy_cluster_is_old`) keeps its tape through the nursery +/// collection this arm schedules, and the next band is then twice as far +/// away rather than due again at the next parse. +pub(super) fn external_side_parse_band_bytes(baseline: usize) -> usize { + gc_trigger_headroom_floor_bytes().max(baseline) +} + +/// [`external_side_parse_pressure_due`] with both readings supplied. +pub(super) fn external_side_parse_pressure_due_with(live: usize, baseline: usize) -> bool { + live >= baseline.saturating_add(external_side_parse_band_bytes(baseline)) +} + +/// #10262: has external side-allocation churn earned a parse-boundary +/// collection? +/// +/// Every other pacing input a parse boundary reads is denominated in ARENA +/// bytes, and a lazily-parsed document's memory is not in the arena: a 13 KB +/// `records_array_16k` parse puts ~1.1 KB (header + sparse cache + bitmap) in +/// the nursery and ~24 KB of tape in a `json_tape_store` side allocation. So +/// the young generation reads 1/24th of what the process is actually holding, +/// and a parse loop reaches its nursery cap 24x later than the memory says it +/// should. Measured on `records_array_16k:parse` at `origin/main` +/// (`PERRY_GC_DIAG=1`, 11 284 iterations): EIGHT collections, every one of them +/// a full mark-sweep from `alloc_point_old_reclaim`, each firing at +/// `external_side=33.6 MB` with `arena_total` between 3 and 8 MB, +/// `old_in_use=0` and `from_space` never above 7 MB against a 16 MB nursery +/// cap. The only pacing this workload had was the old-reclaim growth band +/// reading those side bytes, i.e. 32 MB of dead tape per cycle. +pub(super) fn external_side_parse_pressure_due() -> bool { + external_side_parse_pressure_due_with( + external_side_live_bytes(), + GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(Cell::get), + ) +} + /// Record `bytes` of fresh external side-buffer allocation (Map entries / /// Set elements — creation or growth delta) and poke the trigger check when /// the accumulated churn window fills. Callers must invoke this only when @@ -2235,6 +2304,12 @@ pub(super) fn note_collection_finished_arena_occupancy(full: bool) { GC_LAST_COLLECTION_POST_IN_USE_BYTES.with(|cell| cell.set(bytes)); // #9831: the same moment, in the units the tiny-parse guard reads. GC_TINY_PARSE_PRESSURE_BASE_BYTES.with(|cell| cell.set(crate::arena::arena_in_use_bytes())); + // #10262: and in the units the parse-boundary side-allocation band reads. + // This is the site that makes the band self-correcting: whatever the sweep + // and the from-space pass just released has already been subtracted from + // `external_side_live_bytes`, so a collection that freed the tapes + // re-bases at ~0 and one that could not re-bases at the surviving value. + GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(|cell| cell.set(external_side_live_bytes())); super::arena_right_size::note_collection_finished(bytes, full); } diff --git a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs index e6eedd9dbf..41f7805c1b 100644 --- a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs +++ b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs @@ -20,6 +20,7 @@ use super::super::heap_budget::{ gc_trigger_absolute_ceiling_bytes, gc_trigger_headroom_floor_bytes, }; use super::super::policy::{ + external_side_parse_band_bytes, external_side_parse_pressure_due_with, tiny_parse_boundary_poll_next, tiny_parse_pressure_due, tiny_parse_pressure_due_with, tiny_parse_pressure_headroom_bytes, GC_STEP_BYTES, GC_THRESHOLD_INITIAL_BYTES, GC_THRESHOLD_MAX_BYTES, GC_TINY_PARSE_BOUNDARY_POLL_INTERVAL, @@ -253,6 +254,16 @@ fn a_due_nursery_cap_schedules_the_boundary_collection_below_the_in_use_guard() !tiny_parse_pressure_due(in_use, 48 * MB), "fixture: the priced in-use guard must not be due, or this proves nothing" ); + // #10262 added a third arm to the same predicate. Pin it quiet for the + // duration, so a side-allocation band that happened to be due could not + // make this test's negative half pass for the wrong reason. + let _external_base = ExternalBaseGuard::set( + crate::gc::policy::external_side_live_bytes() + gc_trigger_headroom_floor_bytes(), + ); + assert!( + !crate::gc::policy::external_side_parse_pressure_due(), + "fixture: the side-allocation arm must not be due, or this proves nothing" + ); { let _cap = ScavengeNurseryCapTestGuard::due_at_bytes(usize::MAX); @@ -272,3 +283,221 @@ fn a_due_nursery_cap_schedules_the_boundary_collection_below_the_in_use_guard() } GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|p| p.set(false)); } + +// ─────────────────────────────────────────────────────────────────────────── +// #10262: the side-allocation arm. +// +// Both pre-existing arms are denominated in ARENA bytes, and a lazily-parsed +// document's bytes are not in the arena. The readings below are the measured +// ones from `records_array_16k:parse` at `origin/main` (`PERRY_GC_DIAG=1`, +// 11 284 iterations of a 13 197-byte fixture): every one of the eight +// collections the row ran was a full mark-sweep from `alloc_point_old_reclaim`, +// fired at `external_side=33 570 480` with `arena_total` 3–8 MB, `old_in_use=0` +// and `from_space` never above 6 857 064 against a 16 MB nursery cap. +// +// Sabotage-proved: deleting the `|| external_side_parse_pressure_due()` arm +// from `tiny_parse_generational_collection_due` fails +// `side_allocation_pressure_schedules_the_boundary_collection_below_both_arena_arms` +// while `the_measured_defect_is_invisible_to_both_arena_denominated_arms` — the +// twin that says what the harm IS — keeps passing, which is the point: the two +// together say "nothing else was going to collect this". Pricing the band at a +// bare floor (`gc_trigger_headroom_floor_bytes()`, dropping the `.max(baseline)`) +// fails `a_collection_that_cannot_free_the_side_bytes_doubles_the_band` and +// `a_genuinely_large_live_side_set_is_not_due_at_the_floor`. +// ─────────────────────────────────────────────────────────────────────────── + +/// The measured `records_array_16k:parse` readings at the moment `origin/main` +/// finally collected. +const MEASURED_EXTERNAL_SIDE_BYTES: usize = 33_570_480; +const MEASURED_ARENA_IN_USE_BYTES: usize = 3 * MB; + +/// Restores the side-allocation band's base cell, which the live predicate and +/// every finished collection write. +struct ExternalBaseGuard(usize); + +impl ExternalBaseGuard { + fn set(base: usize) -> Self { + Self( + super::super::policy::GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES + .with(|cell| cell.replace(base)), + ) + } +} + +impl Drop for ExternalBaseGuard { + fn drop(&mut self) { + super::super::policy::GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(|cell| cell.set(self.0)); + } +} + +/// The twin that says what the harm is. Nothing here asserts the new arm — it +/// asserts that WITHOUT it the measured workload has no arm at all, which is +/// what made 32 MB of dead tape per cycle the row's steady state. +#[test] +fn the_measured_defect_is_invisible_to_both_arena_denominated_arms() { + use super::super::policy::ScavengeNurseryCapTestGuard; + let _cells = LiveCellsGuard::set(GC_THRESHOLD_INITIAL_BYTES, 0); + // The priced in-use guard reads `arena_in_use_bytes()`: 3 MB against a + // 48 MB trigger. + assert!( + !tiny_parse_pressure_due(MEASURED_ARENA_IN_USE_BYTES, 48 * MB), + "3 MB of arena is far below the in-use guard — it can never fire here" + ); + // The nursery cap reads `copying_from_space_in_use_bytes()`: the row's + // high-water was 6.9 MB against the 16 MB cap, because a lazy array puts + // ~1.1 KB in the nursery per parse and ~24 KB in a side allocation. + let _cap = ScavengeNurseryCapTestGuard::due_at_bytes(usize::MAX); + assert!( + !super::super::policy::young_scavenge_cap_due(), + "the nursery cap cannot see a byte of the 32 MB the process is holding" + ); +} + +#[test] +fn the_side_allocation_arm_is_due_at_the_measured_readings() { + assert!( + external_side_parse_pressure_due_with(MEASURED_EXTERNAL_SIDE_BYTES, 0), + "32 MB of side allocation past an empty base must be due" + ); +} + +#[test] +fn an_empty_base_is_due_exactly_at_the_headroom_floor() { + let floor = gc_trigger_headroom_floor_bytes(); + assert!( + external_side_parse_pressure_due_with(floor, 0), + "the floor's worth of side allocation past an empty base is due" + ); + assert!( + !external_side_parse_pressure_due_with(floor - 1, 0), + "one byte short of the floor is not" + ); + assert!( + !external_side_parse_pressure_due_with(0, 0), + "a program that has allocated no side buffers is never due" + ); +} + +#[test] +fn a_collection_that_cannot_free_the_side_bytes_doubles_the_band() { + // The livelock shape this band is built against: a lazy array whose sparse + // cache is at or past `LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES` is born + // OLD (`json_tape::lazy_cluster_is_old`), so the nursery collection this arm + // schedules cannot prove its owner dead and its tape survives. The base then + // re-bases at the surviving value and the next band is twice as far away, + // rather than being due again at the very next parse. + let survived = 24 * MB; + assert!( + !external_side_parse_pressure_due_with(survived, survived), + "a collection that freed nothing must not be immediately due again" + ); + assert!( + external_side_parse_pressure_due_with(2 * survived, survived), + "doubling past the surviving value is due" + ); + assert!( + !external_side_parse_pressure_due_with(2 * survived - 1, survived), + "one byte short of doubling is not" + ); +} + +#[test] +fn a_genuinely_large_live_side_set_is_not_due_at_the_floor() { + // A retained multi-MB `Map` contributes to the same counter. Its bytes must + // not force a collection at every parse boundary for the rest of the run. + let live_map = 64 * MB; + let floor = gc_trigger_headroom_floor_bytes(); + assert!( + !external_side_parse_pressure_due_with(live_map + floor, live_map), + "the band must grow with a genuinely large live side set, not stay at the floor" + ); + assert_eq!( + external_side_parse_band_bytes(live_map), + live_map, + "past the floor the band is the baseline itself (100% growth)" + ); + assert_eq!( + external_side_parse_band_bytes(0), + floor, + "an empty baseline buys exactly the headroom floor" + ); +} + +/// The wiring test: the arm reaches the boundary-collection scheduler, and does +/// so below both arena-denominated arms. +#[test] +fn side_allocation_pressure_schedules_the_boundary_collection_below_both_arena_arms() { + use super::super::policy::{ + gc_schedule_parse_boundary_collection_if_pressure, ScavengeNurseryCapTestGuard, + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING, + }; + use super::support::*; + let _isolation = GcTestIsolationGuard::new(); + let _pacing = crate::gc::policy::force_moving_gc_pacing(); + let _cells = LiveCellsGuard::set(GC_THRESHOLD_INITIAL_BYTES, 0); + let pending = || GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(std::cell::Cell::get); + let _cap = ScavengeNurseryCapTestGuard::due_at_bytes(usize::MAX); + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|p| p.set(false)); + + let in_use = crate::arena::arena_in_use_bytes(); + assert!( + !tiny_parse_pressure_due(in_use, 48 * MB), + "fixture: the priced in-use guard must not be due, or this proves nothing" + ); + { + // A base one floor above the live reading keeps the side arm off too, + // so all three arms are quiet: the scheduler must do nothing. + let _base = ExternalBaseGuard::set( + super::super::policy::external_side_live_bytes() + gc_trigger_headroom_floor_bytes(), + ); + assert!( + !super::super::policy::external_side_parse_pressure_due(), + "fixture: the side arm must start quiet, or the negative half is vacuous" + ); + gc_schedule_parse_boundary_collection_if_pressure(); + assert!(!pending(), "no arm is due: nothing scheduled"); + } + { + // Now only the side arm is due — an empty base with side bytes already + // a floor past it, which is the `records_array_16k:parse` shape. + let _base = ExternalBaseGuard::set(0); + crate::gc::gc_note_external_side_alloc(gc_trigger_headroom_floor_bytes()); + // The alloc notice itself pokes `gc_check_trigger`, whose collection + // would re-base the cell it just crossed; re-pin the base being tested. + let _repin = ExternalBaseGuard::set(0); + assert!( + super::super::policy::external_side_parse_pressure_due(), + "fixture: the side arm must be due, or the positive half is vacuous" + ); + gc_schedule_parse_boundary_collection_if_pressure(); + let scheduled = pending(); + crate::gc::gc_note_external_side_free(gc_trigger_headroom_floor_bytes()); + assert!( + scheduled, + "a due side-allocation band schedules the boundary collection" + ); + } + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|p| p.set(false)); +} + +/// The band is self-correcting only if the base is recorded AFTER the sweep and +/// the from-space pass have released what they can. Assert the identity of the +/// two readings, not merely that the cell moved. +#[test] +fn a_finished_collection_moves_the_external_base_to_the_post_collection_reading() { + use super::super::js_gc_collect; + use super::super::policy::{external_side_live_bytes, GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES}; + let _base = ExternalBaseGuard::set(usize::MAX); + js_gc_collect(); + let base = GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(|cell| cell.get()); + assert_ne!( + base, + usize::MAX, + "a finished collection must record the side-allocation base" + ); + assert_eq!( + base, + external_side_live_bytes(), + "the base must be the POST-collection `external_side_live_bytes()` reading" + ); +} From 76a1b44b45e94d141f9e01ae1da4d3ea60f90ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 14:02:51 +0200 Subject: [PATCH 2/6] tooling(gc): register the side-allocation band's base cell and re-pin the census snapshot The parse-boundary band adds one Cell byte counter to gc/policy.rs. Record its not_a_gc_pointer verdict, and re-audit + re-pin PASS1_MARKED's non_moving_snapshot window, whose source list hashes gc/policy.rs. --- crates/perry-runtime/src/gc/policy.rs | 30 ++++++++++--------- .../src/gc/tests/tiny_parse_pressure.rs | 9 +++--- scripts/gc_runtime_root_holders.json | 10 +++++-- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 548ac16850..07a70f7c68 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -479,9 +479,10 @@ pub(super) fn tiny_parse_pressure_due_with( /// the quantity it tests to the survivors, so it cannot fire again until the /// cap has been refilled. /// -/// #10262 adds the third arm for the same reason the second one exists, one -/// currency over: both of the first two are denominated in ARENA bytes, and a -/// lazily-parsed document's bytes are not in the arena at all. See +/// Medium-parse pacing (2026-09-14) adds the third arm for the same reason the +/// second one exists, one currency over: both of the first two are denominated +/// in ARENA bytes, and a lazily-parsed document's bytes are not in the arena at +/// all. See /// [`external_side_parse_pressure_due`]. pub(super) fn tiny_parse_generational_collection_due(in_use: usize, in_use_trigger: usize) -> bool { tiny_parse_pressure_due(in_use, in_use_trigger) @@ -529,9 +530,9 @@ fn diag_tiny_parse_forced_collection(site: &str, in_use: usize) { } let base = GC_TINY_PARSE_PRESSURE_BASE_BYTES.with(Cell::get); let step = GC_STEP_BYTES.with(Cell::get); - // #10262: the side-allocation arm's own inputs, so a diag reader can tell - // which of the three arms priced this collection rather than re-deriving - // it — the same "a gate must assert its subject was live" rule. + // Medium-parse pacing (2026-09-14): the side-allocation arm's own inputs, + // so a diag reader can tell which of the three arms priced this collection + // rather than re-deriving it — the same "assert the subject was live" rule. let external = external_side_live_bytes(); let external_base = GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(Cell::get); eprintln!( @@ -647,8 +648,8 @@ const GC_EXTERNAL_SIDE_ALLOC_STEP: usize = 16 * 1024 * 1024; crate::perry_thread_local! { static GC_EXTERNAL_SIDE_ALLOC_PENDING: std::cell::Cell = const { std::cell::Cell::new(0) }; static GC_EXTERNAL_SIDE_LIVE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; - /// #10262: [`external_side_live_bytes`] as the last collection ended — the - /// base of the parse-boundary growth band + /// Medium-parse pacing (2026-09-14): [`external_side_live_bytes`] as the + /// last collection ended — the base of the parse-boundary growth band /// ([`external_side_parse_pressure_due_with`]). A byte COUNT, never an /// address; written only from `note_collection_finished_arena_occupancy`. pub(super) static GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES: std::cell::Cell = @@ -661,8 +662,9 @@ pub(super) fn external_side_live_bytes() -> usize { GC_EXTERNAL_SIDE_LIVE_BYTES.with(Cell::get) } -/// #10262: how many bytes of external side allocation may accumulate past the -/// last collection before a `JSON.parse` boundary is due. +/// Medium-parse pacing (2026-09-14): how many bytes of external side allocation +/// may accumulate past the last collection before a `JSON.parse` boundary is +/// due. /// /// Deliberately the `max(floor, proportional)` shape of /// [`gc_old_reclaim_growth_band_bytes`], for the two reasons that shape exists: @@ -685,8 +687,8 @@ pub(super) fn external_side_parse_pressure_due_with(live: usize, baseline: usize live >= baseline.saturating_add(external_side_parse_band_bytes(baseline)) } -/// #10262: has external side-allocation churn earned a parse-boundary -/// collection? +/// Medium-parse pacing (2026-09-14): has external side-allocation churn earned +/// a parse-boundary collection? /// /// Every other pacing input a parse boundary reads is denominated in ARENA /// bytes, and a lazily-parsed document's memory is not in the arena: a 13 KB @@ -2304,8 +2306,8 @@ pub(super) fn note_collection_finished_arena_occupancy(full: bool) { GC_LAST_COLLECTION_POST_IN_USE_BYTES.with(|cell| cell.set(bytes)); // #9831: the same moment, in the units the tiny-parse guard reads. GC_TINY_PARSE_PRESSURE_BASE_BYTES.with(|cell| cell.set(crate::arena::arena_in_use_bytes())); - // #10262: and in the units the parse-boundary side-allocation band reads. - // This is the site that makes the band self-correcting: whatever the sweep + // Medium-parse pacing (2026-09-14): and in the units the parse-boundary + // side-allocation band reads. This is the site that makes the band self-correcting: whatever the sweep // and the from-space pass just released has already been subtracted from // `external_side_live_bytes`, so a collection that freed the tapes // re-bases at ~0 and one that could not re-bases at the surviving value. diff --git a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs index 41f7805c1b..31354d5882 100644 --- a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs +++ b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs @@ -254,9 +254,10 @@ fn a_due_nursery_cap_schedules_the_boundary_collection_below_the_in_use_guard() !tiny_parse_pressure_due(in_use, 48 * MB), "fixture: the priced in-use guard must not be due, or this proves nothing" ); - // #10262 added a third arm to the same predicate. Pin it quiet for the - // duration, so a side-allocation band that happened to be due could not - // make this test's negative half pass for the wrong reason. + // Medium-parse pacing (2026-09-14) added a third arm to the same + // predicate. Pin it quiet for the duration, so a side-allocation band that + // happened to be due could not make this test's negative half pass for the + // wrong reason. let _external_base = ExternalBaseGuard::set( crate::gc::policy::external_side_live_bytes() + gc_trigger_headroom_floor_bytes(), ); @@ -285,7 +286,7 @@ fn a_due_nursery_cap_schedules_the_boundary_collection_below_the_in_use_guard() } // ─────────────────────────────────────────────────────────────────────────── -// #10262: the side-allocation arm. +// Medium-parse pacing (2026-09-14): the side-allocation arm. // // Both pre-existing arms are denominated in ARENA bytes, and a lazily-parsed // document's bytes are not in the arena. The readings below are the measured diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 6ce5171275..3831f636c9 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -331,7 +331,7 @@ "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "fdef083301463ad8cec8142ca86cc4354e289c67e97bbc6caea2e8d16d4bf089", "crates/perry-runtime/src/gc/mod.rs": "9dbdde7594af06d08f45f82e426b220e586429da985e38f1e11e7f01e98a2527", - "crates/perry-runtime/src/gc/policy.rs": "abd08472fe71002a55a32f7a17021ab2c029d34ff2b26eb78340d1983ae939c4", + "crates/perry-runtime/src/gc/policy.rs": "2418a7edfbbb611683aa1ecea206a4ac40cdf6e7ccbf528fc52f7c6cd27ac2dc", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } @@ -402,6 +402,12 @@ "verdict": "not_a_gc_pointer", "why": "#9772: releasable block BYTES the last idle selection promised — a size, not an address. A `Cell` compared against what the collection actually released." }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES", + "verdict": "not_a_gc_pointer", + "why": "Medium-parse pacing (2026-09-14): `external_side_live_bytes()` (a running sum of Map/Set entry-buffer and JSON tape side-allocation byte counts) recorded as each collection ends, read back by the parse-boundary side-allocation band to price growth since then. A byte COUNT in a `Cell`, never an address or a NaN-boxed value: written only from `note_collection_finished_arena_occupancy` and the test guard, read only by `external_side_parse_pressure_due` and `diag_tiny_parse_forced_collection`. Same shape and same verdict as its sibling `GC_TINY_PARSE_PRESSURE_BASE_BYTES` two declarations over." + }, { "file": "crates/perry-runtime/src/gc/policy.rs", "name": "GC_TINY_PARSE_BOUNDARY_POLL_REMAINING", From 97d5d0ec22fc38736b9d2381f85022d51013ab62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 14:48:27 +0200 Subject: [PATCH 3/6] perf(gc): keep drained side bytes in old-reclaim pressure Only a full collection returns arena capacity, and on a lazily-parsed record loop the external side term was what pushed old-reclaim over its band. Draining that term with the new parse-boundary minors removed those fulls: records_array_1m:sparse went from 7 fulls to 1, arena dirty pages 29 -> 55 MB, peak RSS 63.5 -> 73.6 MiB with live external bytes HALVED. Count what a non-full collection released since the last full into the old-reclaim pressure term, so the full cadence is pinned to main's while the band holds the live reading down. --- crates/perry-runtime/src/gc/diag_sites.rs | 4 +- crates/perry-runtime/src/gc/policy.rs | 53 ++++++++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/perry-runtime/src/gc/diag_sites.rs b/crates/perry-runtime/src/gc/diag_sites.rs index 5d7776bfab..cacd4e8926 100644 --- a/crates/perry-runtime/src/gc/diag_sites.rs +++ b/crates/perry-runtime/src/gc/diag_sites.rs @@ -45,6 +45,7 @@ pub(super) fn trigger_decision(site: &'static str, kind: &'static str) { let nursery_cap = tenuring::scavenge_nursery_cap_effective_bytes(); let old_reclaimable = policy::old_gen_reclaimable_pressure_bytes(); let external = policy::external_side_live_bytes(); + let external_drained = policy::GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(Cell::get); let old_baseline = policy::GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(Cell::get); let old_band = policy::gc_old_reclaim_growth_band_bytes(old_baseline); let old_threshold = gc_old_gen_reclaim_threshold_dyn_bytes(); @@ -57,7 +58,8 @@ pub(super) fn trigger_decision(site: &'static str, kind: &'static str) { eprintln!( "[gc-trigger] site={site} kind={kind} arena_total={arena_total} next_base={next_base} armed={armed} \ from_space={from_space} nursery_cap={nursery_cap} old_in_use={old_in_use} old_free={old_free} \ - old_reclaimable={old_reclaimable} external_side={external} old_baseline={old_baseline} \ + old_reclaimable={old_reclaimable} external_side={external} \ + external_drained={external_drained} old_baseline={old_baseline} \ old_band={old_band} old_threshold={old_threshold} old_pending={old_pending} retaining={retaining} \ malloc={malloc} next_malloc={next_malloc}" ); diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 07a70f7c68..bfd7fcf39f 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -654,6 +654,12 @@ crate::perry_thread_local! { /// address; written only from `note_collection_finished_arena_occupancy`. pub(super) static GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Medium-parse pacing (2026-09-14): external side bytes that a + /// NON-full collection has released since the last full — see + /// [`external_side_old_reclaim_pressure_bytes`]. A byte COUNT, never an + /// address. + pub(super) static GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL: std::cell::Cell = + const { std::cell::Cell::new(0) }; } /// Live bytes currently held by external Map/Set side buffers on this thread. @@ -737,6 +743,34 @@ pub(crate) fn gc_note_external_side_alloc(bytes: usize) { /// Record that a Map/Set side buffer of `bytes` was freed (GC finalizer). pub(crate) fn gc_note_external_side_free(bytes: usize) { GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_sub(bytes))); + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|c| c.set(c.get().saturating_add(bytes))); +} + +/// The external side-buffer term of OLD-RECLAIM pressure. +/// +/// Live bytes PLUS whatever a non-full collection has already released since +/// the last full. The sum is deliberately what `external_side_live_bytes()` +/// alone read before the parse-boundary band existed, so old-reclaim keeps +/// firing at exactly the program point it always did. +/// +/// It has to. Only a FULL collection returns arena capacity — general blocks +/// are released after two full observations (`gc::arena_right_size`) — and on a +/// lazily-parsed record loop the external term was what pushed old-reclaim over +/// its band, i.e. the side allocations were paying for the arena's block +/// release as well as their own. Draining that term with cheap nursery +/// collections and leaving the pressure test on the live reading removed those +/// fulls: measured on `records_array_1m:sparse` (161 parses of a 7 600-record +/// document), 12 minors and ONE full against `origin/main`'s seven, the arena's +/// dirty pages 29 MB -> 55 MB, and peak RSS 63.5 -> 73.6 MiB even though live +/// external bytes had HALVED. Keeping the drained bytes in the pressure term +/// pins the full cadence to main's while the band holds the live reading down. +/// +/// It can never make old-reclaim fire EARLIER than main: every drained byte is +/// a byte main would still have been counting as live at the same point, so the +/// sum is bounded above by main's reading and equals it when the same objects +/// die. +pub(super) fn external_side_old_reclaim_pressure_bytes() -> usize { + external_side_live_bytes().saturating_add(GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(Cell::get)) } #[inline] @@ -2002,8 +2036,8 @@ pub(super) fn copied_minor_promotion_handoff_due(trigger_kind: GcTriggerKind) -> return false; } let promotable = copied_minor_promotable_active_survivor_bytes(); - let old_in_use = - old_gen_reclaimable_pressure_bytes().saturating_add(external_side_live_bytes()); + let old_in_use = old_gen_reclaimable_pressure_bytes() + .saturating_add(external_side_old_reclaim_pressure_bytes()); let baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()); copied_minor_promotion_handoff_pressure_due(promotable, old_in_use, baseline) } @@ -2111,8 +2145,8 @@ pub(super) fn maybe_schedule_old_reclaim_after_copied_minor() { // a tenured-then-dead Map holds its multi-MB buffer until a full // reclaim's old-gen sweep finalizes it, so the buffer bytes must be // able to escalate that reclaim. - let old_in_use = - old_gen_reclaimable_pressure_bytes().saturating_add(external_side_live_bytes()); + let old_in_use = old_gen_reclaimable_pressure_bytes() + .saturating_add(external_side_old_reclaim_pressure_bytes()); let baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()); if old_reclaim_pressure_due(old_in_use, baseline) { GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(true)); @@ -2136,10 +2170,15 @@ pub(super) fn request_old_reclaim_for_untraced_promotions(bytes: usize) { } pub(super) fn finish_full_old_reclaim_baseline() { + // Medium-parse pacing (2026-09-14): the full this baseline records is the + // collection the drained bytes were being held for, so the debt is paid + // here — before the baseline is read, or the baseline would carry it into + // the next band and the following full would fire a band too early. + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|c| c.set(0)); // Baseline includes external side-buffer bytes (#6010) so the growth // delta in `old_reclaim_pressure_due` stays unit-consistent. - let old_in_use = - old_gen_reclaimable_pressure_bytes().saturating_add(external_side_live_bytes()); + let old_in_use = old_gen_reclaimable_pressure_bytes() + .saturating_add(external_side_old_reclaim_pressure_bytes()); GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use)); // Record the TOTAL post-full live set for major-GC pacing (young+old): the // full sweep is the only collection that frees forwarding stubs, so this is @@ -3450,7 +3489,7 @@ pub(super) fn gc_budgeted_due_trigger_eval() -> (Option, bool let old_pending = GC_OLD_RECLAIM_PENDING.with(Cell::get); // #6010: external Map/Set side-buffer bytes escalate to OldReclaim too. let old_reclaimable = old_gen_reclaimable_pressure_bytes(); - let old_in_use = old_reclaimable.saturating_add(external_side_live_bytes()); + let old_in_use = old_reclaimable.saturating_add(external_side_old_reclaim_pressure_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) { return (Some(BudgetedGcTrigger::OldReclaim), true); From eb9327e47086d455594389f1e7c9f6834e0f2be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 15:25:21 +0200 Subject: [PATCH 4/6] test(gc): plant the side-allocation band and its drained-bytes counterweight Ten tests over the new predicates, each with its sabotage recorded and run: dropping the third arm, pricing the band at a bare floor, reverting the old-reclaim term to the live read, and dropping the drain reset each fail exactly the named tests. --- .../src/gc/tests/tiny_parse_pressure.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs index 31354d5882..8586f54f30 100644 --- a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs +++ b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs @@ -502,3 +502,75 @@ fn a_finished_collection_moves_the_external_base_to_the_post_collection_reading( "the base must be the POST-collection `external_side_live_bytes()` reading" ); } + +/// The band's counterweight: the cheap collections it schedules must not hide +/// pressure from the arm that pays for arena-capacity release. +/// +/// Sabotage-proved (run 2026-09-14, each against the whole +/// `tiny_parse_pressure` filter): reverting +/// `external_side_old_reclaim_pressure_bytes` to the bare +/// `external_side_live_bytes()` read fails BOTH tests below; deleting the reset +/// from `finish_full_old_reclaim_baseline` fails +/// `a_full_collection_clears_the_drained_debt` alone. +#[test] +fn a_drained_side_byte_still_pays_old_reclaim_until_the_next_full() { + use super::super::policy::{ + external_side_live_bytes, external_side_old_reclaim_pressure_bytes, + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL, + }; + use super::support::*; + let _isolation = GcTestIsolationGuard::new(); + let restore = GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.replace(0)); + let live_before = external_side_live_bytes(); + assert_eq!( + external_side_old_reclaim_pressure_bytes(), + live_before, + "fixture: with no drained debt the term is the live reading" + ); + + const BYTES: usize = 3 * MB; + crate::gc::gc_note_external_side_alloc(BYTES); + let charged = external_side_old_reclaim_pressure_bytes(); + assert_eq!(charged, live_before + BYTES); + // A NON-full collection releasing the buffer lowers the live reading but + // must leave old-reclaim pressure exactly where main would have read it. + crate::gc::gc_note_external_side_free(BYTES); + assert_eq!( + external_side_live_bytes(), + live_before, + "the live reading must fall by what was released" + ); + assert_eq!( + external_side_old_reclaim_pressure_bytes(), + charged, + "old-reclaim must still be charged for a byte a full has not yet paid for" + ); + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.set(restore)); +} + +#[test] +fn a_full_collection_clears_the_drained_debt() { + use super::super::js_gc_collect; + use super::super::policy::{ + external_side_live_bytes, external_side_old_reclaim_pressure_bytes, + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL, + }; + let restore = GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.replace(7 * MB)); + assert_ne!( + external_side_old_reclaim_pressure_bytes(), + external_side_live_bytes(), + "fixture: the debt must be non-zero, or this proves nothing" + ); + js_gc_collect(); + assert_eq!( + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(std::cell::Cell::get), + 0, + "the full that the debt was held for pays it" + ); + assert_eq!( + external_side_old_reclaim_pressure_bytes(), + external_side_live_bytes(), + "with the debt paid the term is the live reading again" + ); + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.set(restore)); +} From ea2392e29c59d423d776b36ea4e55d3aa354cd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 15:28:15 +0200 Subject: [PATCH 5/6] changelog: medium-parse side-allocation pacing --- .../gc-medium-parse-side-allocation-pacing.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 changelog.d/gc-medium-parse-side-allocation-pacing.md diff --git a/changelog.d/gc-medium-parse-side-allocation-pacing.md b/changelog.d/gc-medium-parse-side-allocation-pacing.md new file mode 100644 index 0000000000..675a4c001e --- /dev/null +++ b/changelog.d/gc-medium-parse-side-allocation-pacing.md @@ -0,0 +1,68 @@ +### Fixed + +**GC: a `JSON.parse` loop over medium documents retained ~32 MB of dead tape per cycle.** + +`records_array_16k:parse` was the one row of the 50-cell JSON matrix still losing +on peak RSS: **74.98 MiB against Node 26.5.1's 65 and Bun's 71** on the bench +mini. It now peaks at **48.86 MiB** — 25 % under Node — with CPU 2.8 % *better* +than before. + +**Where the bytes were.** A lazily-parsed document's memory is not in the arena. +Parsing the 13 197-byte `records_array_16k` fixture puts ~1.1 KB in the nursery +(the `LazyArrayHeader`, its sparse cache and bitmap) and ~24 KB of tape in a +`json_tape_store` side allocation. Every pacing input a parse boundary reads is +denominated in *arena* bytes, so the young generation saw 1/24th of what the +process was holding. `PERRY_GC_DIAG=1` over the row's 11 284 iterations at +`origin/main`: **eight collections, every one a full mark-sweep from +`alloc_point_old_reclaim`**, each firing at `external_side=33.6 MB` with +`arena_total` between 3 and 8 MB, `old_in_use=0`, and `from_space` never above +6.9 MB against a 16 MB nursery cap. The row's only pacing was the old-reclaim +growth band happening to read those side bytes — i.e. 32 MB of dead tape per +cycle. + +**The fix, in two halves.** + +* A third arm on `tiny_parse_generational_collection_due`, keyed on + `external_side_live_bytes()` with the `max(floor, baseline)` growth band + old-gen reclaim already uses, based at the reading the last collection left + behind. A futile collection (an old-owned cluster whose tape survives) + re-bases the band at the surviving value, so repeats space out geometrically + instead of livelocking. +* The band's counterweight: what a *non-full* collection releases stays in the + old-reclaim pressure term until the next full + (`external_side_old_reclaim_pressure_bytes`). Only a full returns arena + capacity, and on these rows the external term was paying for that too; + draining it with cheap minors alone took `records_array_1m:sparse` from seven + fulls to one, the arena's dirty pages from 29 MB to 55 MB, and peak RSS from + 63.5 to 73.6 MiB *even though live external bytes had halved*. The sum is + bounded above by what `main` read at the same point, so it can never fire + old-reclaim earlier. + +**Measured** on the bench mini, 9 interleaved rounds, best of each, all 50 cells +of the JSON matrix (peak RSS, MiB / CPU, ms): + +| row | main | this | ΔRSS | ΔCPU | +|---|---|---|---|---| +| `records_array_16k:parse` | 74.98 / 155.2 | 48.86 / 150.8 | **−34.8 %** | −2.8 % | +| `records_array_16k:sparse` | 65.17 / 149.5 | 44.48 / 142.5 | −31.8 % | −4.7 % | +| `records_array_8m:roundtrip` | 129.00 / 146.7 | 99.16 / 139.6 | −23.1 % | −4.8 % | +| `records_array_16k:roundtrip` | 63.72 / 143.5 | 51.12 / 152.0 | −19.8 % | +5.9 % | +| `records_array_8m:parse` | 97.84 / 134.2 | 85.44 / 131.7 | −12.7 % | −1.9 % | +| `records_array_8m:sparse` | 98.39 / 132.7 | 86.00 / 129.2 | −12.6 % | −2.6 % | +| `records_array_1m:roundtrip` | 61.36 / 162.9 | 58.23 / 165.5 | −5.1 % | +1.6 % | +| `numbers_1m:parse` | 62.81 / 160.7 | 60.28 / 164.8 | −4.0 % | +2.6 % | +| `records_array_1m:parse` | 65.78 / 157.7 | 64.75 / 158.9 | −1.6 % | +0.8 % | +| `heterogeneous_1m:parse` | 59.05 / 160.3 | 62.52 / 159.4 | **+5.9 %** | −0.6 % | + +The remaining 40 rows move by less than 1 % on both axes. `heterogeneous_1m:parse` +is the one row that grows: its arena capacity goes 5.24 → 8.39 MiB (one extra +live non-general block plus one in-place promoted block) for a live-external +reading that falls — the standing cost of running copying minors on a row that +previously ran none — while its nine full collections are preserved exactly. + +`gc_ratchet` (14 probes, 7 repeats each, `main` vs this): correctness passes on +all 14, and **`heap_used_bytes` and `heap_total_bytes` are bit-identical on every +probe** — the gated retention counters do not move. Peak RSS (min of 7) stays +within ±0.5 %, the largest being `03_cross_gen_writes` 25.25 → 25.38 MiB +(+0.13 MiB) and `01_nursery_churn` 27.17 → 27.30 MiB; wall clock runs from +`08_map_set_sidetables` −5.1 % to `06_string_retention` +1.2 %. From 8ef17f61e5b92b5de3974a237a5e34aca736b601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 15:40:53 +0200 Subject: [PATCH 6/6] tooling(gc): register the drained-bytes counter and re-pin the census snapshot --- scripts/gc_runtime_root_holders.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 3831f636c9..e2b61742a5 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is reached only from Map/Set and lazy-tape finalizers, which run inside the SWEEP body — after `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local — and from the copying minor's from-space pass, which a full cycle never executes. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -331,7 +331,7 @@ "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "fdef083301463ad8cec8142ca86cc4354e289c67e97bbc6caea2e8d16d4bf089", "crates/perry-runtime/src/gc/mod.rs": "9dbdde7594af06d08f45f82e426b220e586429da985e38f1e11e7f01e98a2527", - "crates/perry-runtime/src/gc/policy.rs": "2418a7edfbbb611683aa1ecea206a4ac40cdf6e7ccbf528fc52f7c6cd27ac2dc", + "crates/perry-runtime/src/gc/policy.rs": "5bac96bc4f0d2192647225c841d6f070bf665e1e242fee9e498f08371e743cfe", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } @@ -402,6 +402,12 @@ "verdict": "not_a_gc_pointer", "why": "#9772: releasable block BYTES the last idle selection promised — a size, not an address. A `Cell` compared against what the collection actually released." }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL", + "verdict": "not_a_gc_pointer", + "why": "Medium-parse pacing (2026-09-14): the external side-buffer bytes a NON-full collection has released since the last full, kept in the old-reclaim pressure term so cheap collections cannot starve the arm that returns arena capacity. A byte COUNT in a `Cell`, never an address or a NaN-boxed value: incremented only by `gc_note_external_side_free`, zeroed only by `finish_full_old_reclaim_baseline` and the test guards, read only by `external_side_old_reclaim_pressure_bytes` and `diag_sites::trigger_decision`. Same shape and same verdict as its siblings `GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES` and `GC_TINY_PARSE_PRESSURE_BASE_BYTES`." + }, { "file": "crates/perry-runtime/src/gc/policy.rs", "name": "GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES",