From e12742375c968a790ae559fec511121ffcdc4260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 10:45:28 +0200 Subject: [PATCH 1/3] gc: measure the moving-defer allowance from the deferral point (#7024) The alloc-point deferral that hands a nursery trigger to the precise-root safepoint -- and therefore to the copying minor -- was guarded by an absolute arena cap derived from budget_scaled(128 MB, 1, 4, 2 MB), the same formula as gc_trigger_absolute_ceiling_bytes(). Under any heap budget small enough for the ceiling to reach the nursery cap the two collapse to one number, making 'a trigger is due' (arena_total >= trigger) and 'the deferral is allowed' (arena_total < cap) exact complements: the deferral was unreachable and the copying minor never ran. Make the allowance a slack measured from the deferral point instead. The first deferral of a cycle is unconditional; the safety valve fires once the arena has grown a slack past it, and retires the pending request so the baseline cannot go stale. --- crates/perry-runtime/src/gc/heap_budget.rs | 23 +++- crates/perry-runtime/src/gc/policy.rs | 94 ++++++++++--- crates/perry-runtime/src/gc/tests/triggers.rs | 123 ++++++++++++++++++ scripts/gc_repsel_matrix.sh | 10 ++ 4 files changed, 230 insertions(+), 20 deletions(-) diff --git a/crates/perry-runtime/src/gc/heap_budget.rs b/crates/perry-runtime/src/gc/heap_budget.rs index 5ea48bcc29..3279266a3c 100644 --- a/crates/perry-runtime/src/gc/heap_budget.rs +++ b/crates/perry-runtime/src/gc/heap_budget.rs @@ -6,7 +6,7 @@ use std::sync::OnceLock; use super::policy::{ - GC_COPY_PROMOTION_HANDOFF_MIN_BYTES, GC_MOVING_DEFER_HARD_CAP_BYTES, + GC_COPY_PROMOTION_HANDOFF_MIN_BYTES, GC_MOVING_DEFER_SLACK_BYTES, GC_OLD_GEN_RECLAIM_GROWTH_BYTES, GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES, GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES, GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES, GC_TRIGGER_ABSOLUTE_CEILING, @@ -152,11 +152,24 @@ budget_scaled_accessor!( 2 * 1024 * 1024 ); budget_scaled_accessor!( - gc_moving_defer_hard_cap_dyn_bytes, - GC_MOVING_DEFER_HARD_CAP_BYTES, + /// Growth allowance for a nursery trigger that has been deferred to a + /// precise-root safepoint, measured **from the deferral point** (#7024). + /// + /// ★ Do not give this the `1, 4, 2 MB` shape of + /// `gc_trigger_absolute_ceiling_bytes` again, and do not turn it back into + /// an absolute arena cap. While it was both, the two collapsed to the same + /// number under every explicit `PERRY_GC_HEAP_LIMIT`, which made "a trigger + /// is due" (`arena_total >= trigger`) and "the deferral is allowed" + /// (`arena_total < cap`) exact complements: the copying minor became + /// unreachable at precisely the pressure settings that provoke it. A delta + /// is immune to that by construction; the denominator is a third rather + /// than a quarter so the deferral point (≈ a quarter of the budget) plus + /// its slack still leaves the budget headroom. + gc_moving_defer_slack_dyn_bytes, + GC_MOVING_DEFER_SLACK_BYTES, 1, - 4, - 2 * 1024 * 1024 + 3, + 1024 * 1024 ); budget_scaled_accessor!( gc_tiny_parse_in_use_trigger_dyn_bytes, diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 0c411d0fca..9656a817ee 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -641,17 +641,56 @@ thread_local! { /// back-edge poll) so the copying minor can MOVE survivors instead of the /// conservative non-moving minor running mid-expression. pub(super) static GC_SAFEPOINT_PENDING: Cell = const { Cell::new(false) }; + /// `arena_total_bytes()` sampled at the moment `GC_SAFEPOINT_PENDING` was + /// last set — the baseline the deferral slack is measured from (#7024). + /// Meaningless while `GC_SAFEPOINT_PENDING` is false. + pub(super) static GC_SAFEPOINT_DEFER_ARENA_BASE: Cell = const { Cell::new(0) }; } -/// Hard cap on committed arena bytes before which a nursery trigger may be -/// deferred to a safepoint (Phase 2/3). Loop back-edge polls drain the pending +/// Committed arena bytes a deferred nursery trigger may allocate **past the +/// point at which it was deferred** before the alloc-point non-moving minor +/// runs as the safety valve (Phase 2/3). Loop back-edge polls drain the pending /// flag every iteration, so the arena never grows near this in normal code; the -/// cap bounds RSS for code that reaches no safepoint before the next trigger — +/// slack bounds RSS for code that reaches no safepoint before the next trigger — /// a synchronous loop on a specialized lowering path that doesn't yet emit the -/// poll, or a single mega-expression — where the alloc-point non-moving minor -/// runs as the safety valve. Kept modest so those cases don't balloon under the -/// default-on moving GC (raise once poll coverage is complete). -pub(super) const GC_MOVING_DEFER_HARD_CAP_BYTES: usize = 128 * 1024 * 1024; +/// poll, or a single mega-expression. +/// +/// ★ #7024: this is a SLACK (a delta from the deferral point), not an absolute +/// arena size, and that is the whole point. It was an absolute cap derived by +/// `budget_scaled(_, 1, 4, 2 MB)` — **the same formula as +/// `gc_trigger_absolute_ceiling_bytes()`**. `gc_budgeted_due_trigger()` reports +/// `ArenaBytes` due exactly when `arena_total_bytes() >= trigger`, and the +/// deferral required `arena_total_bytes() < cap`; under any explicit +/// `PERRY_GC_HEAP_LIMIT` the two collapsed to the same number, so the two +/// predicates became exact complements and the deferral was *unreachable* — the +/// copying minor could never run under the very pressure setting the stress +/// matrix used to provoke it (`default` arm: 0 copying minors on all 22 corpus +/// rows). A delta cannot collapse into the trigger, at any heap budget: the +/// first deferral of a cycle is always taken and the arena is allowed a bounded +/// amount of growth to reach a poll. +pub(super) const GC_MOVING_DEFER_SLACK_BYTES: usize = 64 * 1024 * 1024; + +/// Whether an alloc-point nursery trigger may (still) be deferred to the next +/// precise-root safepoint. +/// +/// `deferred_at` is `Some(arena_total_at_the_first_deferral)` while a deferral +/// is outstanding, `None` when none is. The first deferral of a cycle is +/// unconditional — deferring is the *sound* path (it collects with precise, +/// rewritable roots at a real safepoint) and the alloc-point fallback exists +/// only to bound growth when nothing drains the deferral. See +/// `GC_MOVING_DEFER_SLACK_BYTES` for why this is a delta and not an absolute +/// cap (#7024). +#[inline] +pub(super) fn moving_defer_within_slack( + arena_total: usize, + deferred_at: Option, + slack: usize, +) -> bool { + match deferred_at { + None => true, + Some(base) => arena_total < base.saturating_add(slack), + } +} /// RAII guard that marks a #5476 direct old-gen reclaim in progress so a nested /// `gc_check_trigger` can't re-enter it. See `GC_OLD_RECLAIM_IN_PROGRESS`. @@ -1317,14 +1356,39 @@ pub fn gc_check_trigger() { // to the next precise-root safepoint (event-loop boundary or a // codegen loop back-edge poll) so the copying minor MOVES survivors // instead of the conservative non-moving minor running here at a - // register-imprecise point. Safety valve: once committed arena bytes - // pass the hard cap (a mega-expression that reached no poll), fall - // through and collect non-moving here so growth stays bounded. - if gc_moving_loop_polls_enabled() - && crate::arena::arena_total_bytes() < gc_moving_defer_hard_cap_dyn_bytes() - { - GC_SAFEPOINT_PENDING.with(|p| p.set(true)); - return; + // register-imprecise point. Safety valve: once the arena has grown + // `gc_moving_defer_slack_dyn_bytes()` PAST the point at which the + // collection was deferred (a mega-expression that reached no poll), + // fall through and collect non-moving here so growth stays bounded. + // + // #7024: the allowance is measured from the deferral point, not + // against an absolute arena size. The absolute cap shared + // `budget_scaled(_, 1, 4, 2 MB)` with the trigger ceiling, so under + // an explicit PERRY_GC_HEAP_LIMIT "a trigger is due" and "the + // deferral is allowed" became exact complements and this branch was + // dead — see `GC_MOVING_DEFER_SLACK_BYTES`. + if gc_moving_loop_polls_enabled() { + let arena_total = crate::arena::arena_total_bytes(); + let already_deferred = GC_SAFEPOINT_PENDING.with(Cell::get); + let deferred_at = + already_deferred.then(|| GC_SAFEPOINT_DEFER_ARENA_BASE.with(Cell::get)); + if moving_defer_within_slack( + arena_total, + deferred_at, + gc_moving_defer_slack_dyn_bytes(), + ) { + if !already_deferred { + GC_SAFEPOINT_DEFER_ARENA_BASE.with(|base| base.set(arena_total)); + GC_SAFEPOINT_PENDING.with(|p| p.set(true)); + } + return; + } + // The deferral never drained. The direct minor below IS the + // collection that was owed, so retire the request — leaving it + // pending would pin `GC_SAFEPOINT_DEFER_ARENA_BASE` at a stale, + // already-exceeded baseline and disable deferral for the rest of + // the process (the same "the branch is dead" shape as #7024). + GC_SAFEPOINT_PENDING.with(|p| p.set(false)); } let pre_in_use = crate::arena::arena_in_use_bytes(); let pre_malloc_count = malloc_object_count(); diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 1a52c3b8ae..62c13b4be8 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -250,6 +250,129 @@ fn test_budget_scaled_clamps_only_under_budget() { assert_eq!(budget_scaled_with(Some(MB), 128 * MB, 1, 4, 2 * MB), 2 * MB); } +// ─────────────────────────────────────────────────────────────────────────── +// #7024: the alloc-point deferral must be REACHABLE at the moment a nursery +// trigger becomes due, at every heap budget. +// +// `gc_budgeted_due_trigger()` reports `ArenaBytes` due exactly when +// `arena_total_bytes() >= effective_next_arena_trigger()`. The deferral that +// hands the collection to the precise-root safepoint (and therefore to the +// COPYING minor) used to be guarded by `arena_total_bytes() < `, +// where the cap came from `budget_scaled(128 MB, 1, 4, 2 MB)` — byte-for-byte +// the trigger-ceiling formula. Under any budget small enough for the ceiling to +// sit at or below the nursery cap the two are the same number, so the two +// predicates are exact complements: the deferral is refused at precisely the +// arena size that makes the trigger due, and the copying minor never runs. +// Measured consequence: the stress matrix's `default` arm ran zero copying +// minors on all 22 corpus rows. +// +// These tests fail against the pre-#7024 predicate (`arena_total < cap`) and +// pass against the slack-from-the-deferral-point predicate. +// ─────────────────────────────────────────────────────────────────────────── +#[test] +fn test_moving_defer_reachable_when_the_arena_trigger_is_due() { + use super::super::heap_budget::budget_scaled_with; + use super::super::policy::{ + moving_defer_within_slack, GC_MOVING_DEFER_SLACK_BYTES, GC_TRIGGER_ABSOLUTE_CEILING, + }; + const MB: usize = 1024 * 1024; + // `gc_scavenge_nursery_cap_bytes()`'s default; moving mode clamps the + // effective trigger to it (`effective_next_arena_trigger`). + const NURSERY_CAP: usize = 16 * MB; + + for budget in [ + None, + Some(2 * MB), + Some(8 * MB), // the stress matrix's `--pressure 8` + Some(16 * MB), + Some(32 * MB), + Some(64 * MB), + Some(128 * MB), + Some(512 * MB), + ] { + let ceiling = budget_scaled_with(budget, GC_TRIGGER_ABSOLUTE_CEILING, 1, 4, 2 * MB); + let slack = budget_scaled_with(budget, GC_MOVING_DEFER_SLACK_BYTES, 1, 3, MB); + // The smallest `arena_total` at which `gc_budgeted_due_trigger()` + // reports ArenaBytes, in moving mode. + let due_at = ceiling.min(NURSERY_CAP); + + // The collapse premise, asserted rather than assumed: whenever the + // budget pulls the ceiling to or below the nursery cap — every device + // budget ≤ 64 MB, and every `--pressure` setting the matrix uses — the + // pre-#7024 absolute cap is already reached at `due_at`, so the old + // guard `arena_total < cap` was FALSE exactly when the trigger fired. + let legacy_cap = budget_scaled_with(budget, 128 * MB, 1, 4, 2 * MB); + if ceiling <= NURSERY_CAP { + assert!( + due_at >= legacy_cap, + "budget {budget:?}: expected the pre-#7024 absolute cap ({legacy_cap}) to be \ + unreachable at the due point ({due_at})" + ); + } + + // The fix: the first deferral of a cycle is unconditional, so the + // copying minor is reachable at every budget. + assert!( + moving_defer_within_slack(due_at, None, slack), + "budget {budget:?}: a nursery trigger due at {due_at} bytes must be deferrable" + ); + // …and it stays deferrable for a whole slack of further growth, so a + // loop back-edge poll has room to drain it. + assert!( + moving_defer_within_slack(due_at + slack - 1, Some(due_at), slack), + "budget {budget:?}: deferral must survive until the slack is spent" + ); + } +} + +#[test] +fn test_moving_defer_slack_still_has_a_safety_valve() { + use super::super::policy::moving_defer_within_slack; + const MB: usize = 1024 * 1024; + let slack = 4 * MB; + let base = 2 * MB; + + // No deferral outstanding: always allowed, however large the arena. This is + // the sound path (precise, rewritable roots at a real safepoint); the + // alloc-point fallback exists only to bound growth when nothing drains it. + assert!(moving_defer_within_slack(0, None, slack)); + assert!(moving_defer_within_slack(4 * 1024 * MB, None, slack)); + + // Deferral outstanding: bounded overshoot, measured from the deferral point. + assert!(moving_defer_within_slack(base, Some(base), slack)); + assert!(moving_defer_within_slack( + base + slack - 1, + Some(base), + slack + )); + assert!(!moving_defer_within_slack(base + slack, Some(base), slack)); + assert!(!moving_defer_within_slack( + base + slack + MB, + Some(base), + slack + )); + + // The valve is relative, not absolute: a program whose live set already + // sits far above any fixed cap still gets its slack (and therefore still + // gets copying minors) instead of being pinned on the non-moving path. + let big = 900 * MB; + assert!(moving_defer_within_slack(big, None, slack)); + assert!(moving_defer_within_slack(big + slack - 1, Some(big), slack)); + assert!(!moving_defer_within_slack(big + slack, Some(big), slack)); + + // Overflow-safe. + assert!(moving_defer_within_slack( + usize::MAX - 1, + Some(usize::MAX), + slack + )); + assert!(!moving_defer_within_slack( + usize::MAX, + Some(usize::MAX), + slack + )); +} + // The un-armed trigger cell (desktop-default const initializer) reads as // the device ceiling; an armed trigger above the ceiling is legitimate // (headroom floor over a big live set) and must NOT be clamped. diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh index 1340085a47..623b6ff935 100755 --- a/scripts/gc_repsel_matrix.sh +++ b/scripts/gc_repsel_matrix.sh @@ -91,6 +91,12 @@ RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m' # Arms. Format: id | compile-env | run-env | liveness-requirement | note # # liveness requirement: +# scavenge the arm claims the COPYING MINOR runs -> require +# `[gc-copy-minor] ran copied_objects=` > 0. Strictly stronger than +# `move`, which the C4b mark-sweep evacuation satisfies on its own +# (#7025) -- `default` reported `moved=7 610 512` while running zero +# copying minors. Any arm whose subject is the relocating young-gen +# minor #7019 shipped must use THIS, not `move`. # move the arm claims to evacuate -> require moved/copied objects > 0 # collect the arm claims to collect -> require at least one GC cycle # none no GC claim of its own (an explicit control) @@ -400,6 +406,10 @@ while [ "$ai" -lt "$NARMS" ]; do result="FAIL"; ev="output-mismatch $ev" else case "$live" in + # #7024/#7025: the copying minor's OWN counter, never the + # sum. An arm that certifies the relocating young-gen minor + # must not go green on a C4b mark-sweep evacuation. + scavenge) [ "$scavenged" -gt 0 ] && result="PASS" || result="UNVER" ;; move) [ "$moved" -gt 0 ] && result="PASS" || result="UNVER" ;; collect) [ "$cycles" -gt 0 ] && result="PASS" || result="UNVER" ;; *) result="PASS" ;; From 4536e06ce0f99a59868cfe70dbf78cd1f6b7a63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 11:32:30 +0200 Subject: [PATCH 2/3] gc-matrix: assert the copying minor ran, and record that the PR subset now reaches it (#7024, #6993) Adds a `scavenge` liveness requirement -- the copying minor's own `[gc-copy-minor] ran copied_objects=` counter, never the sum that also counts the C4b mark-sweep evacuation (#7025) -- and gives it to the four arms whose subject is the relocating young-gen minor: default, verify_evac, cons_scan_off, cons_scan_off_force. Adds a compiled-program regression test for the observable that distinguishes the two worlds under an explicit PERRY_GC_HEAP_LIMIT: copied_objects > 0. Asserting 'a cycle happened' does not; the broken build collected on 13 of 22 corpus rows while relocating nothing. --- .../tests/gc_copy_minor_under_heap_limit.rs | 130 ++++++++++++++++++ scripts/gc_repsel_matrix.sh | 54 ++++++-- 2 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 crates/perry/tests/gc_copy_minor_under_heap_limit.rs diff --git a/crates/perry/tests/gc_copy_minor_under_heap_limit.rs b/crates/perry/tests/gc_copy_minor_under_heap_limit.rs new file mode 100644 index 0000000000..0d97549233 --- /dev/null +++ b/crates/perry/tests/gc_copy_minor_under_heap_limit.rs @@ -0,0 +1,130 @@ +//! Regression test for #7024: under an explicit `PERRY_GC_HEAP_LIMIT`, a +//! compiled program must still run the **copying** minor. +//! +//! `gc_check_trigger`'s deferral arm hands an allocation-point nursery trigger +//! to the next precise-root safepoint, which is the only way the copying +//! (relocating) young-gen minor #7019 shipped ever runs on the automatic path. +//! That deferral used to be guarded by an absolute committed-arena cap derived +//! from `budget_scaled(128 MB, 1, 4, 2 MB)` — byte-for-byte the formula behind +//! `gc_trigger_absolute_ceiling_bytes()`. Under any heap budget small enough +//! for the ceiling to reach the 16 MB nursery cap, the two collapse to one +//! number, and since a nursery trigger is due exactly when +//! `arena_total_bytes() >= trigger` while the deferral required +//! `arena_total_bytes() < cap`, the two predicates became exact complements: +//! the deferral was unreachable, control fell through to the alloc-point minor +//! under `ManualGcScanGuard::force_full_scan()`, and the collector reported +//! +//! ```text +//! [gc-copy-minor] eligible=false fallback=conservative_stack +//! ``` +//! +//! for the whole run. Measured on the representation corpus at +//! `PERRY_GC_HEAP_LIMIT=8` before the fix: **0 of 22 files ran a single copying +//! minor**, while the same 22 files collected on 13. So a heap-limited +//! deployment — every small container, every watch-class device, and every arm +//! of the GC stress matrix that uses the pressure knob — silently ran the +//! *pre*-#7019 non-moving collector. +//! +//! What this test pins is the observable that distinguishes those two worlds: +//! `copied_objects > 0`. Asserting "a GC cycle happened" does not — the broken +//! build collected too. Exit 0 does not either; the broken build exited 0. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// Sum of every `[gc-copy-minor] ran copied_objects=N` in the collector's +/// `PERRY_GC_DIAG` output. This is the copying minor's OWN counter: the +/// `moved_objects=` counter that also appears there belongs to the C4b +/// evacuation policy inside the mark-sweep collector, a different collector +/// entirely, and summing the two is how a green result was once reported for a +/// run that scavenged nothing (#7025). +fn copied_objects(stderr: &str) -> u64 { + stderr + .lines() + .filter_map(|line| line.strip_prefix("[gc-copy-minor] ran copied_objects=")) + .filter_map(|rest| { + rest.split_whitespace() + .next() + .and_then(|n| n.parse::().ok()) + }) + .sum() +} + +#[test] +fn copying_minor_runs_under_an_explicit_heap_limit() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + // Escaping allocation churn in a plain `for` loop: the body allocates, so + // codegen emits the loop back-edge poll (`js_gc_loop_safepoint`) that + // drains the deferral, and the sink keeps the objects genuinely live for a + // while so survivors exist for the copying minor to relocate. + std::fs::write( + &entry, + r#" +let sink: any[] = []; +let checksum = 0; +for (let i = 0; i < 400000; i++) { + sink.push({ i, s: "x" + (i & 255), pair: { a: i, b: i + 1 } }); + if (sink.length > 2048) { + checksum = (checksum + sink.length) | 0; + sink = []; + } +} +console.log("checksum:", checksum); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + // 8 MB is the pressure setting `scripts/gc_repsel_matrix.sh` uses, and + // the one on which the copying minor was measured to never run. + .env("PERRY_GC_HEAP_LIMIT", "8") + .env("PERRY_GC_DIAG", "1") + .output() + .expect("run compiled binary"); + let stderr = String::from_utf8_lossy(&run.stderr).into_owned(); + assert!( + run.status.success(), + "compiled binary failed (exit {:?})\nstderr:\n{stderr}", + run.status.code(), + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + // Verified against the pinned Node oracle (`.node-version`). + "checksum: 399555\n", + "the workload must still produce its result under a heap limit" + ); + + let copied = copied_objects(&stderr); + assert!( + copied > 0, + "no copying minor ran under PERRY_GC_HEAP_LIMIT=8 (#7024). The \ + allocation-point deferral to the precise-root safepoint is \ + unreachable again — check that the moving-defer allowance is still a \ + SLACK measured from the deferral point and has not been turned back \ + into an absolute arena cap sharing a formula with \ + gc_trigger_absolute_ceiling_bytes().\ncollector diagnostics:\n{stderr}" + ); +} diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh index 623b6ff935..f0af2f96be 100755 --- a/scripts/gc_repsel_matrix.sh +++ b/scripts/gc_repsel_matrix.sh @@ -137,7 +137,22 @@ RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m' # # NOTE this is a MEASUREMENT configuration, not the shipped one. It says the # collector's evacuating path is exercised; it does not say the shipped default -# reaches that path. It does not -- see #6978. +# reaches that path. +# +# ***AS OF #7024 THE SHIPPED DEFAULT DOES REACH IT*** -- by the sound route, +# which is not this one. `default` (pressure knob only, no GC env) now defers +# the alloc-point trigger to a precise-root safepoint and runs the copying +# minor there. Measured on this corpus at `--pressure 8`: +# +# arm copy-minor before #7024 after +# default 0/22 12/22 +# verify_evac 0/22 12/22 +# +# The difference between `default` and %E% is now WHERE the relocation happens: +# `default` relocates at a real safepoint (the JS stack has unwound, roots are +# precise by construction), %E% forces it at the register-imprecise allocation +# point. Both belong in the matrix; only the first is a configuration anyone +# ships. # # ***AND IT IS RED.*** The first `--arms all` run in which anything actually # moved failed 14 of the 20 corpus files: 5 crashes and 9 output mismatches @@ -149,18 +164,18 @@ RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m' # arms stay configured to keep producing it. Do not quiet them down. # --------------------------------------------------------------------------- ARMS=( -"default||%P%|collect|as-shipped GC configuration under allocation pressure" +"default||%P%|scavenge|as-shipped GC configuration under allocation pressure. Since #7024 this is a RELOCATING arm with no env override at all beyond the pressure knob: the alloc-point trigger defers to js_gc_loop_safepoint -> gc_safepoint_moving_minor, which runs the copying minor on precise, rewritable roots. requires=scavenge, not collect: before #7024 it collected on 13/22 rows while running ZERO copying minors, so a collect requirement certified the pre-#7019 non-moving path under a name that says otherwise." "evac_minor||%P% %E%|move|THE evacuating arm: the automatic alloc-point collection as a precise-rooted COPYING minor that relocates survivors. No stress knob -- this is the collector's own moving path." "force_evac||%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|stress-copy every marked non-pinned nursery object" -"verify_evac||%P% PERRY_GC_VERIFY_EVACUATION=1|collect|panic if a live slot still points at a forwarded object" +"verify_evac||%P% PERRY_GC_VERIFY_EVACUATION=1|scavenge|panic if a live slot still points at a forwarded object. requires=scavenge: a verifier that runs over zero relocations verifies nothing, which is what it did on all 22 rows before #7024." "force_verify||%P% %E% PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|move|force + verify" "gen_gc_off||%P% PERRY_GEN_GC=0|collect|full mark-sweep only; no nursery => no evacuation by construction" "wb_off|PERRY_WRITE_BARRIERS=0|%P% PERRY_WRITE_BARRIERS=0|collect|no codegen write barriers => copying nursery ineligible by construction" "gen_off_verify||%P% PERRY_GEN_GC=0 PERRY_GC_VERIFY_EVACUATION=1|collect|full mark-sweep + evacuation verifier" "wb_off_force|PERRY_WRITE_BARRIERS=0|%P% PERRY_WRITE_BARRIERS=0 PERRY_GC_FORCE_EVACUATE=1|collect|force-evacuate is a documented no-op without barriers (barriers_inactive)" "all_four|PERRY_WRITE_BARRIERS=0|%P% PERRY_GEN_GC=0 PERRY_WRITE_BARRIERS=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|collect|every escape hatch at once" -"cons_scan_off||%P% PERRY_CONSERVATIVE_STACK_SCAN=off|collect|PRECISE ROOTS ONLY -- removes the conservative-stack pinning that every automatic collection otherwise forces (ManualGcScanGuard::force_full_scan). The only arm that can observe a missing shadow-slot binding." -"cons_scan_off_force||%P% PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|collect|precise roots + force/verify evacuation" +"cons_scan_off||%P% PERRY_CONSERVATIVE_STACK_SCAN=off|scavenge|PRECISE ROOTS ONLY -- removes the conservative-stack pinning that the alloc-point fallback otherwise forces (ManualGcScanGuard::force_full_scan). An arm that can observe a missing shadow-slot binding." +"cons_scan_off_force||%P% PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|scavenge|precise roots + force/verify evacuation" "loop_polls|PERRY_GC_MOVING_LOOP_POLLS=1|%P% %E% PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_FORCE_EVACUATE=1|move|defer the alloc-point collection to a loop back-edge precise-root safepoint, where the copying minor may MOVE survivors" "rep_i32_off|PERRY_CANONICAL_I32_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 1 OFF x evacuation" "rep_str_off|PERRY_CANONICAL_STR_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3a OFF x evacuation" @@ -175,17 +190,26 @@ ARMS=( # as-shipped under pressure, the evacuation verifier, precise-roots-only, and # the untouched shipped configuration as a control. # -# ***THE EVACUATING ARMS ARE DELIBERATELY NOT IN THIS SUBSET, AND THAT IS A -# TEMPORARY STATE WITH AN EXPIRY.*** They are not omitted because they are -# noisy: they are omitted because they are RED, and they are red for a real -# reason that is filed, reproduced and minimised in #6981 -- a relocating minor -# with precise roots breaks 14 of the 20 corpus files (5 crashes, 9 output -# mismatches), while the SAME relocation with the conservative stack scan on -# passes 19/20. Putting them in the per-PR gate today would paint every -# unrelated PR red from the first commit, which is how a gate stops being read. +# ***THIS SUBSET CAN NOW REPRODUCE THE RELOCATING-MINOR DEFECT CLASS (#6993).*** +# Until #7024 it could not, and that was the hole: `default` and `verify_evac` +# ran the NON-moving alloc-point minor (the deferral to the precise-root +# safepoint was unreachable whenever the pressure knob was set, because the +# deferral cap and the trigger ceiling shared a formula), and `cons_scan_off` +# relocated only with incremental mode still on. So the whole "raw reference +# held across a relocating collection" class -- #6951, #6972, #6982, #6991, +# #6992 -- was invisible per PR and could only go red after merge, on push. +# +# The proof that the hole is closed is a cell that changed colour, not an +# argument: `default x test_gap_repsel_p4a3_numarray_barriers` was PASS +# (`cycles=1 scavenged=0` -- it collected, and relocated nothing) and is now +# FAIL (`exit=139 scavenged=3594`) -- the same SIGSEGV that only `cons_scan_off` +# and the %E% arms could produce before. #6981's redness now reaches the arm +# named after the shipped configuration. # -# They ARE in `--arms all`, which is what push / workflow_dispatch runs, so the -# failures are visible and measured on every push to main -- not hidden. +# `evac_minor` / `force_verify` stay out for the original reason, unchanged: +# they are RED for a real, filed reason (#6981), and per-PR redness on an +# unrelated PR is how a gate stops being read. They ARE in `--arms all`, which +# push / workflow_dispatch runs. # # WHEN #6981 CLOSES, PUT `evac_minor` AND `force_verify` BACK IN THIS LIST. # That is the point at which "a representation regressed GC correctness under From 40d849af0b77fc8e768b92e75801a37061f15758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 11:35:11 +0200 Subject: [PATCH 3/3] docs(changelog): fragment for #7057 --- changelog.d/7057-gc-moving-defer-slack.md | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 changelog.d/7057-gc-moving-defer-slack.md diff --git a/changelog.d/7057-gc-moving-defer-slack.md b/changelog.d/7057-gc-moving-defer-slack.md new file mode 100644 index 0000000000..193ac7e6c7 --- /dev/null +++ b/changelog.d/7057-gc-moving-defer-slack.md @@ -0,0 +1,54 @@ +Fixed the moving-GC deferral so the copying minor is reachable under an explicit +heap budget, and made the per-PR GC matrix arms assert that it actually ran. + +`gc_check_trigger`'s deferral arm — the one that hands an allocation-point +nursery trigger to the next precise-root safepoint, and therefore to the copying +minor #7019 shipped — was guarded by an absolute committed-arena cap derived from +`budget_scaled(128 MB, 1, 4, 2 MB)`. That is byte-for-byte the formula behind +`gc_trigger_absolute_ceiling_bytes()`, so under any heap budget small enough for +the ceiling to reach the 16 MB nursery cap (every `PERRY_GC_HEAP_LIMIT` ≤ 64, and +every device budget a small container or watch-class device derives) the two +collapse to one number. A nursery trigger is due exactly when +`arena_total_bytes() >= trigger` while the deferral required +`arena_total_bytes() < cap`: same number, so the two predicates were exact +complements and the deferral was unreachable. Control fell through to the +alloc-point minor under `ManualGcScanGuard::force_full_scan()`, the collector +reported `[gc-copy-minor] eligible=false fallback=conservative_stack`, and a +heap-limited deployment silently ran the pre-#7019 non-moving collector. Measured +on the representation corpus at `--pressure 8`: the `default` arm collected on 13 +of 22 rows and ran **zero** copying minors on all 22. + +The allowance is now a slack measured **from the deferral point** +(`GC_MOVING_DEFER_SLACK_BYTES`, `gc_moving_defer_slack_dyn_bytes()`): the first +deferral of a cycle is unconditional, and the safety valve fires once the arena +has grown one slack past it, retiring the pending request so the baseline cannot +go stale and pin the deferral off for the rest of the process. A delta cannot +collapse into an absolute trigger at any heap budget. No env knob was added. + +The `default` arm now runs a real copying minor — `[gc-copy-minor] ran +copied_objects=3576 … eligible=true fallback=none` on +`test_gap_repsel_canonical_i32`, 8 604 215 objects over 154 copying minors on +`test_gap_repsel_gc_stress` — 12 of 22 corpus rows, up from 0. Full matrix +(`--arms all --pressure 8`, 440 cells) moves `PASS=324 UNVER=91 XFAIL=1 FAIL=24` +to `PASS=325 UNVER=100 XFAIL=1 FAIL=14`; all 14 residual failures are the single +pre-existing #6981 cell `test_gap_repsel_p4a3_numarray_barriers`, and no corpus +row broke that was not already red. + +That also closes the per-PR gate hole in #6993: the relocating-minor defect class +was previously invisible to `--arms pr`, and the proof it no longer is, is a cell +that changed colour — `default × test_gap_repsel_p4a3_numarray_barriers` went +from PASS (`cycles=1 scavenged=0`) to FAIL (`exit=139 scavenged=3594`), the same +SIGSEGV only the push-only evacuating arms could produce before. + +Gate mechanics hardened alongside: a `scavenge` liveness requirement reads the +copying minor's own `[gc-copy-minor] ran copied_objects=` counter rather than the +sum that also counts the C4b mark-sweep evacuation (#7025), and is given to the +four arms whose subject is the relocating young-gen minor. A compiled-program +regression test (`crates/perry/tests/gc_copy_minor_under_heap_limit.rs`) pins the +observable that distinguishes the two worlds under `PERRY_GC_HEAP_LIMIT=8`: +`copied_objects > 0`. Exit 0 does not — the broken build exited 0 and collected. + +One measured consequence is recorded rather than smoothed over: +`test_gap_specabi_reassign`, a 20-line program with no loop and therefore no +back-edge poll to drain the deferral, now exits before collecting under the +pressure knob and is reported UNVER instead of PASS.