Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/6977-reachable-evacuating-minor.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 13 additions & 1 deletion crates/perry-runtime/src/gc/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
64 changes: 64 additions & 0 deletions crates/perry-runtime/src/gc/tests/debt_pacer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
88 changes: 76 additions & 12 deletions scripts/gc_repsel_matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,33 +100,94 @@ 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"
"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"
"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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions test-parity/gc_repsel_corpus.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Shape> 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
Loading