diff --git a/changelog.d/6977-reachable-evacuating-minor.md b/changelog.d/6977-reachable-evacuating-minor.md new file mode 100644 index 0000000000..e539df78b0 --- /dev/null +++ b/changelog.d/6977-reachable-evacuating-minor.md @@ -0,0 +1 @@ +**GC / pacing:** the budgeted (incremental) collector measured its own allocation debt against the raw `GC_NEXT_TRIGGER_BYTES` cell while the arming path (`gc_budgeted_due_trigger`) compares against `effective_next_arena_trigger()`, which substitutes the device/`PERRY_GC_HEAP_LIMIT`-derived ceiling before the first collection. A cycle armed at a 2 MB effective trigger therefore reported ZERO debt, `gc_mutator_assist_scaled_work_units` never left its 256-unit floor, and the cycle crawled without ever completing — 300 000 escaping allocations under `PERRY_GC_HEAP_LIMIT=8` produced 0 collections (1.2 M produced 330 MB RSS and still 0), the exact unbounded-growth failure the debt-proportional pacing exists to prevent. Now 4 collections on the same program. No-op for the desktop default, where the ceiling *is* the raw cell's initializer. Also: `scripts/gc_repsel_matrix.sh`'s evacuating arms were inert — they claimed `move` and nothing ever moved — so they now carry the measured evacuating base (`PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off`), which turns the automatic alloc-point collection into a precise-rooted copying minor (`eligible=true fallback=none`, 1 556 543 objects copied on `test_gap_repsel_gc_stress`), plus a new `evac_minor` arm. Registers `test_gap_repsel_proven_this_frozen`, which #6925 shipped unregistered — that made `--arms all` abort before evaluating a single cell (#6950). diff --git a/crates/perry-runtime/src/gc/telemetry.rs b/crates/perry-runtime/src/gc/telemetry.rs index 9e3a8fa04d..a88fbedead 100644 --- a/crates/perry-runtime/src/gc/telemetry.rs +++ b/crates/perry-runtime/src/gc/telemetry.rs @@ -526,7 +526,19 @@ impl GcDebtSnapshot { #[inline] pub(super) fn current() -> Self { let total = crate::arena::arena_total_bytes(); - let next_arena_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); + // #6950: read the SAME trigger the arming path compares against. + // `gc_budgeted_due_trigger` uses `effective_next_arena_trigger()`, which + // substitutes the device/`PERRY_GC_HEAP_LIMIT`-derived ceiling while the + // raw cell still holds its 128 MB desktop-default const initializer + // (`GC_TRIGGER_ARMED == false`). Reading the raw cell here made the two + // disagree: a cycle armed at a 2 MB effective trigger measured its own + // debt against 128 MB and therefore reported ZERO debt, so + // `gc_mutator_assist_scaled_work_units` never scaled past its 256-unit + // floor and the budgeted cycle crawled without ever completing — + // 300k escaping allocations / 330 MB RSS with ZERO collections. That is + // exactly the unbounded-growth failure the debt-proportional pacing was + // introduced to prevent. + let next_arena_trigger = effective_next_arena_trigger(); let malloc_count = malloc_object_count(); let next_malloc_trigger = GC_NEXT_MALLOC_TRIGGER.with(|c| c.get()); let old_in_use = crate::arena::old_gen_in_use_bytes(); diff --git a/crates/perry-runtime/src/gc/tests/debt_pacer.rs b/crates/perry-runtime/src/gc/tests/debt_pacer.rs index 46195a3614..2f38dbcf64 100644 --- a/crates/perry-runtime/src/gc/tests/debt_pacer.rs +++ b/crates/perry-runtime/src/gc/tests/debt_pacer.rs @@ -724,3 +724,67 @@ fn minor_sweep_retains_window_expired_growth_stub() { assert_eq!(crate::array::js_array_length(live), 64); assert_eq!(crate::array::js_array_get_f64_unchecked(live, 63), 63.0); } + +/// #6950: the pacing path must measure allocation debt against the SAME +/// trigger the arming path compares against. +/// +/// `gc_budgeted_due_trigger` arms a cycle when +/// `arena_total_bytes() >= effective_next_arena_trigger()`, which substitutes +/// the device / `PERRY_GC_HEAP_LIMIT`-derived ceiling while the raw +/// `GC_NEXT_TRIGGER_BYTES` cell still holds its desktop-default const +/// initializer (`GC_TRIGGER_ARMED == false`). `GcDebtSnapshot::current` read +/// the RAW cell, so a cycle armed at the (lower) effective trigger measured its +/// own debt against the (higher) raw one and reported ZERO debt. +/// +/// `gc_mutator_assist_scaled_work_units` scales the assist budget by exactly +/// that number, so it never left its 256-unit floor and the budgeted cycle +/// crawled without ever completing. Measured on a compiled program: 300 000 +/// escaping allocations, 330 MB RSS, ZERO collections — the unbounded-growth +/// failure the debt-proportional pacing exists to prevent. +#[test] +fn test_arena_debt_measured_against_effective_trigger_not_raw_cell() { + use super::super::heap_budget::gc_trigger_absolute_ceiling_bytes; + use super::super::policy::{ + effective_next_arena_trigger, GC_NEXT_TRIGGER_BYTES, GC_TRIGGER_ARMED, + }; + + let prev_total = crate::arena::ARENA_TOTAL_BYTES.with(|c| c.get()); + let prev_trigger = GC_NEXT_TRIGGER_BYTES.with(|c| c.get()); + let prev_armed = GC_TRIGGER_ARMED.with(|c| c.get()); + + // Un-armed with a raw cell far above the ceiling: exactly the state a + // process is in before its FIRST collection. + GC_TRIGGER_ARMED.with(|c| c.set(false)); + GC_NEXT_TRIGGER_BYTES.with(|c| c.set(usize::MAX / 2)); + let ceiling = gc_trigger_absolute_ceiling_bytes(); + let overshoot = 64 * 1024 * 1024; + crate::arena::ARENA_TOTAL_BYTES.with(|c| c.set(ceiling.saturating_add(overshoot))); + + let effective = effective_next_arena_trigger(); + let due = crate::arena::arena_total_bytes() >= effective; + let debt = GcDebtSnapshot::current().arena_debt_bytes; + let units = gc_mutator_assist_scaled_work_units(); + + crate::arena::ARENA_TOTAL_BYTES.with(|c| c.set(prev_total)); + GC_NEXT_TRIGGER_BYTES.with(|c| c.set(prev_trigger)); + GC_TRIGGER_ARMED.with(|c| c.set(prev_armed)); + + assert_eq!( + effective, ceiling, + "an un-armed trigger cell must read as the device ceiling" + ); + assert!( + due, + "the arming path must consider this arena total past the effective trigger" + ); + assert_eq!( + debt, overshoot as u64, + "debt must be measured against the EFFECTIVE trigger the cycle was armed on, \ + not the raw cell — reading the raw cell reports 0 and freezes the assist \ + budget at its floor" + ); + assert!( + units > GC_MUTATOR_ASSIST_WORK_UNITS, + "a cycle with real debt must scale its assist budget past the fixed floor" + ); +} diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh index c65bd0420c..0c56bb522b 100755 --- a/scripts/gc_repsel_matrix.sh +++ b/scripts/gc_repsel_matrix.sh @@ -100,12 +100,54 @@ RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m' # IR; all of them are keyed into the object cache # (perry/src/commands/compile/object_cache.rs), so arms never silently share # cached objects. +# +# %E% expands to THE EVACUATING BASE (#6950): +# +# PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off +# +# Every arm whose requirement is `move` carries it, because without it those +# arms are INERT and were reported UNVER across the whole corpus. Two +# independent blockers, both measured: +# +# 1. `PERRY_GC_INCREMENTAL=0`. With incremental mode on (the default), +# `registered_root_scanners_block_budgeted_gc()` reduces to "any copy-only +# scanner", which a compiled program has none of. So `gc_check_trigger` +# skips the direct-collection arm and hands the trigger to the budgeted +# stepper, whose mutator assists never drive the cycle to completion. +# Turning incremental off restores the direct synchronous minor. +# 2. `PERRY_CONSERVATIVE_STACK_SCAN=off`. The direct arm takes +# `ManualGcScanGuard::force_full_scan()`, and a forced conservative scan +# makes the copying minor ineligible (`fallback=conservative_stack`) -- +# the exact sentence #6950 quotes from `gc/policy.rs`. An explicit env +# value BEATS that guard in `conservative_stack_scan_mode()`, so this is +# what turns the automatic collection into a precise-rooted copying minor +# that actually relocates survivors. +# +# Measured on this pair (`--pressure 8`, arm `evac_minor`): +# test_gap_repsel_canonical_i32 0 cycles -> 1 cycle, 4 579 objects copied +# test_gap_repsel_ptr_shape_locals 0 cycles -> 1 cycle, 4 640 objects copied +# test_gap_repsel_gc_stress 24 cycles -> 31 cycles, 1 556 543 copied +# every one with `[gc-copy-minor] eligible=true fallback=none`. +# +# 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. +# +# ***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 +# (#6981), plus one intermittent SIGSEGV that does not even need precise roots +# (#6982). The discriminator is NOT relocation -- with the conservative stack +# scan still on, the same evacuating cycles pass 19/20 while copying thousands +# of objects. It is precise roots: the values only the conservative scan was +# keeping alive. That is the finding this gate was built to produce, and the +# arms stay configured to keep producing it. Do not quiet them down. # --------------------------------------------------------------------------- ARMS=( "default||%P%|collect|as-shipped GC configuration under allocation pressure" -"force_evac||%P% PERRY_GC_FORCE_EVACUATE=1|move|stress-copy every marked non-pinned nursery object" +"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" -"force_verify||%P% PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|move|force + verify" +"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" @@ -113,20 +155,39 @@ ARMS=( "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" -"loop_polls|PERRY_GC_MOVING_LOOP_POLLS=1|%P% 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% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 1 OFF x evacuation" -"rep_str_off|PERRY_CANONICAL_STR_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3a OFF x evacuation" -"rep_ptr_shape_off|PERRY_PTR_SHAPE_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3b OFF x evacuation" -"rep_ptr_numarray_off|PERRY_PTR_NUMARRAY_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 4a.3 OFF x evacuation" -"rep_spec_abi_off|PERRY_SPECIALIZED_ABI=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 2 OFF x evacuation" -"rep_int_valued_off|PERRY_INT_VALUED_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|native-i32 residency (#6898) OFF x 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" +"rep_ptr_shape_off|PERRY_PTR_SHAPE_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3b OFF x evacuation" +"rep_ptr_numarray_off|PERRY_PTR_NUMARRAY_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 4a.3 OFF x evacuation" +"rep_spec_abi_off|PERRY_SPECIALIZED_ABI=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 2 OFF x evacuation" +"rep_int_valued_off|PERRY_INT_VALUED_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|native-i32 residency (#6898) OFF x evacuation" "shipped_default||-|none|control: exactly the as-shipped configuration -- no pressure knob, no GC env at all" ) # PR-gating subset: the arms with the most detection power per second -- -# as-shipped under pressure, force+verify evacuation, precise-roots-only, and +# as-shipped under pressure, the evacuation verifier, precise-roots-only, and # the untouched shipped configuration as a control. -PR_ARMS="default,force_verify,cons_scan_off,shipped_default" +# +# ***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. +# +# 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. +# +# 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 +# relocation" becomes a per-PR signal, which is the whole reason this matrix +# exists. Do not instead add triage entries for those cells: +# test-parity/gc_repsel_triage.txt is for redness that is provably NOT a +# representation defect, and #6981's redness may well be exactly that. +PR_ARMS="default,verify_evac,cons_scan_off,shipped_default" arm_field() { # $1 = arm record, $2 = 1..5 printf '%s' "$1" | cut -d'|' -f"$2" @@ -273,6 +334,9 @@ done # --------------------------------------------------------------------------- PRESSURE_ENV="" [ "$PRESSURE_MB" != "0" ] && PRESSURE_ENV="PERRY_GC_HEAP_LIMIT=$PRESSURE_MB" +# The evacuating base -- see the %E% note above the arm table. Both halves are +# required and neither is sufficient alone. +EVAC_ENV="PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off" triage_reason() { # $1 test, $2 arm [ -f "$TRIAGE" ] || return 1 @@ -288,7 +352,7 @@ n_pass=0; n_unver=0; n_fail=0; n_xfail=0 ai=0 while [ "$ai" -lt "$NARMS" ]; do id="${ARM_IDS[$ai]}"; slug="${ARM_SLUGS[$ai]}"; live="${ARM_LIVES[$ai]}" - renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed "s/%P%/$PRESSURE_ENV/")" + renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed -e "s/%P%/$PRESSURE_ENV/" -e "s/%E%/$EVAC_ENV/")" [ "$renv" = "-" ] && renv="" echo "==> arm $id" ti=0 diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index cce8252d95..c58ef65bd0 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -47,3 +47,10 @@ test_gap_repsel_p4b_field_store_elision # across escaping allocation churn heavy enough to reach the collector, so the # "collect" arms actually bite. Keep it registered and keep it collecting. test_gap_repsel_gc_stress + +# --- Phase 5a: Ptr proven `this` in methods (#6925) ------------------- +# Landed in 1a533a3a8 WITHOUT registering here, which made +# `gc_repsel_matrix.sh --arms all` refuse to run at all (the UNREGISTERED gate +# fires before any cell is evaluated). Registering it is the action that gate +# asks for. +test_gap_repsel_proven_this_frozen