Extends #6946 (which is scoped to the gc() path) and #6942 with measurements from the allocation-driven path.
#6946 established that PERRY_GC_FORCE_EVACUATE=1 is inert for a gc()-driven test, and noted that the representation-selection corpus does not call gc() — it relies on allocation pressure — leaving open "whether their churn actually crosses the threshold". This issue settles that: it does not, and even when allocation pressure does cross the threshold, the resulting cycle is never a minor and never evacuates.
Measured on origin/main @ 83a6767ff, macOS arm64, --release, pinned Node 26.5.0. Liveness signal is PERRY_GC_TRACE=1, which prints exactly one [gc] cycle … marker per completed collection (the runtime staticlib is built without the diagnostics feature, so the marker is the "diagnostics feature disabled" line — still a sound per-cycle counter), cross-checked against PERRY_GC_DIAG=1.
1. The representation corpus performs zero collections
All 18 test_gap_repsel_* / test_gap_specabi_* / typed-array-param gap files on main: 0 [gc] cycle markers, under default settings and under every GC env arm. Every GC arm applied to that corpus is therefore inert, and "byte-exact under PERRY_GC_FORCE_EVACUATE=1" asserts nothing about any of them.
2. Ordinary allocation pressure does not reach the collector until ~1M escaping allocations
Program shape: keep.push({a: i, b: "str-"+i, c: [i, i+1]}), all live.
| escaping allocations |
cycles (default) |
cycles (PERRY_GC_HEAP_LIMIT=8) |
| 100 000 |
0 |
0 |
| 300 000 |
0 |
0 |
| 600 000 |
0 |
0 |
| 900 000 |
0 |
0 |
| 1 200 000 |
2 |
7 |
Note the second column: PERRY_GC_HEAP_LIMIT does not lower the first-collection point. It is documented as a device/deployer budget that scales gc_trigger_absolute_ceiling_bytes() to budget/4 (floor 2 MB), i.e. a 2 MB first trigger at =8. A program with ~50 MB of live arena still performs zero collections under it. Once the first collection has happened the knob does take effect (it re-baselines the trigger to new_total + headroom_floor, which is why 1.2M allocations give 7 cycles instead of 2) — so the knob is a cycle-frequency multiplier, not a first-trigger control. Either the ceiling is not consulted on the path that matters, or arena_total_bytes() does not track these allocations; either way the knob cannot be used to make a small program collect.
3. Every automatic collection is a FULL mark-sweep under a forced conservative scan — no minor, no evacuation
For a churning program that does collect (3M iterations, 23 cycles), PERRY_GC_DIAG=1 shows only [gc] blocks: … / [gc-step] … — i.e. old-gen-reclaim-driven full mark-sweeps. Zero [gc-copy-minor], zero [gc-evac-policy] lines. This is structural, and gc/policy.rs::gc_check_trigger says so in-code: both the OldReclaim arm and the nursery ArenaBytes/MallocCount arm take ManualGcScanGuard::force_full_scan() before collecting, and the comment on the latter is explicit — "which also makes copied-minor ineligible for THIS cycle, so the non-moving minor runs (no relocation hazards at alloc points)".
Consequence: on the allocation-driven path, exactly as on the gc() path, (a) PERRY_GC_FORCE_EVACUATE / PERRY_GC_VERIFY_EVACUATION are no-ops, and (b) the conservative stack scan pins raw Rust locals, so the #6655/#6935 bug class is masked.
Swept for any reachable exception — none found (all on the 3M-iteration churn program, copy = [gc-copy-minor] lines, evac = [gc-evac-policy] lines):
| configuration |
cycles |
copy |
evac |
PERRY_GC_HEAP_LIMIT=8 |
24 |
0 |
0 |
+ PERRY_GC_INCREMENTAL=0 |
7 |
0 |
0 |
+ PERRY_GC_MOVING_SAFEPOINT=1 PERRY_GC_FORCE_EVACUATE=1 |
7 |
0 |
0 |
+ PERRY_GEN_GC_EVACUATE=1 PERRY_GC_FORCE_EVACUATE=1 |
7 |
0 |
0 |
+ PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1 |
7 |
0 |
0 |
PERRY_GC_MOVING_LOOP_POLLS=1 (compile and run) + FORCE_EVACUATE |
7 |
0 |
0 |
PERRY_GC_MOVING_LOOP_POLLS=1 is the documented route to "defer the alloc-point collection to a precise-root safepoint where the copying minor MOVES survivors" (gc/policy.rs), and it changes nothing here — plausibly because the back-edge poll is only wired into the generic while / do..while / for lowerings and not the specialized/versioned ones (see the COVERAGE note in perry-codegen/src/stmt/loops.rs), but the deferred cycle never becomes a copying minor even for a plain while loop.
The only configuration observed to move objects remains the manual one from #6942: perry/gc's minor() + PERRY_GC_FORCE_EVACUATE=1 → [gc-evac-policy] … moved_objects=104831, with [gc-copy-minor] eligible=false fallback=conservative_stack — i.e. it moves, and it pins the raw locals the bug class is about.
Why this matters
"Green under PERRY_GC_FORCE_EVACUATE=1" has been the standard validation sentence across the GC and representation-selection series. On both reachable paths — manual gc() and ordinary allocation pressure — that sentence is unsupported. The GC × representation matrix (scripts/gc_repsel_matrix.sh) reports those cells as UNVERIFIED rather than green and will flip them to green automatically once an evacuating, unpinned configuration exists; that configuration is what #6942 asks for.
Related: #6942, #6946, #6655, #6935, #6910.
Extends #6946 (which is scoped to the
gc()path) and #6942 with measurements from the allocation-driven path.#6946 established that
PERRY_GC_FORCE_EVACUATE=1is inert for agc()-driven test, and noted that the representation-selection corpus does not callgc()— it relies on allocation pressure — leaving open "whether their churn actually crosses the threshold". This issue settles that: it does not, and even when allocation pressure does cross the threshold, the resulting cycle is never a minor and never evacuates.Measured on
origin/main@83a6767ff, macOS arm64,--release, pinned Node 26.5.0. Liveness signal isPERRY_GC_TRACE=1, which prints exactly one[gc] cycle …marker per completed collection (the runtime staticlib is built without thediagnosticsfeature, so the marker is the "diagnostics feature disabled" line — still a sound per-cycle counter), cross-checked againstPERRY_GC_DIAG=1.1. The representation corpus performs zero collections
All 18
test_gap_repsel_*/test_gap_specabi_*/ typed-array-param gap files on main: 0[gc] cyclemarkers, under default settings and under every GC env arm. Every GC arm applied to that corpus is therefore inert, and "byte-exact underPERRY_GC_FORCE_EVACUATE=1" asserts nothing about any of them.2. Ordinary allocation pressure does not reach the collector until ~1M escaping allocations
Program shape:
keep.push({a: i, b: "str-"+i, c: [i, i+1]}), all live.PERRY_GC_HEAP_LIMIT=8)Note the second column:
PERRY_GC_HEAP_LIMITdoes not lower the first-collection point. It is documented as a device/deployer budget that scalesgc_trigger_absolute_ceiling_bytes()tobudget/4(floor 2 MB), i.e. a 2 MB first trigger at=8. A program with ~50 MB of live arena still performs zero collections under it. Once the first collection has happened the knob does take effect (it re-baselines the trigger tonew_total + headroom_floor, which is why 1.2M allocations give 7 cycles instead of 2) — so the knob is a cycle-frequency multiplier, not a first-trigger control. Either the ceiling is not consulted on the path that matters, orarena_total_bytes()does not track these allocations; either way the knob cannot be used to make a small program collect.3. Every automatic collection is a FULL mark-sweep under a forced conservative scan — no minor, no evacuation
For a churning program that does collect (3M iterations, 23 cycles),
PERRY_GC_DIAG=1shows only[gc] blocks: …/[gc-step] …— i.e. old-gen-reclaim-driven full mark-sweeps. Zero[gc-copy-minor], zero[gc-evac-policy]lines. This is structural, andgc/policy.rs::gc_check_triggersays so in-code: both theOldReclaimarm and the nurseryArenaBytes/MallocCountarm takeManualGcScanGuard::force_full_scan()before collecting, and the comment on the latter is explicit — "which also makes copied-minor ineligible for THIS cycle, so the non-moving minor runs (no relocation hazards at alloc points)".Consequence: on the allocation-driven path, exactly as on the
gc()path, (a)PERRY_GC_FORCE_EVACUATE/PERRY_GC_VERIFY_EVACUATIONare no-ops, and (b) the conservative stack scan pins raw Rust locals, so the #6655/#6935 bug class is masked.Swept for any reachable exception — none found (all on the 3M-iteration churn program,
copy=[gc-copy-minor]lines,evac=[gc-evac-policy]lines):PERRY_GC_HEAP_LIMIT=8+ PERRY_GC_INCREMENTAL=0+ PERRY_GC_MOVING_SAFEPOINT=1 PERRY_GC_FORCE_EVACUATE=1+ PERRY_GEN_GC_EVACUATE=1 PERRY_GC_FORCE_EVACUATE=1+ PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1PERRY_GC_MOVING_LOOP_POLLS=1(compile and run)+ FORCE_EVACUATEPERRY_GC_MOVING_LOOP_POLLS=1is the documented route to "defer the alloc-point collection to a precise-root safepoint where the copying minor MOVES survivors" (gc/policy.rs), and it changes nothing here — plausibly because the back-edge poll is only wired into the genericwhile/do..while/forlowerings and not the specialized/versioned ones (see the COVERAGE note inperry-codegen/src/stmt/loops.rs), but the deferred cycle never becomes a copying minor even for a plainwhileloop.The only configuration observed to move objects remains the manual one from #6942:
perry/gc'sminor()+PERRY_GC_FORCE_EVACUATE=1→[gc-evac-policy] … moved_objects=104831, with[gc-copy-minor] eligible=false fallback=conservative_stack— i.e. it moves, and it pins the raw locals the bug class is about.Why this matters
"Green under
PERRY_GC_FORCE_EVACUATE=1" has been the standard validation sentence across the GC and representation-selection series. On both reachable paths — manualgc()and ordinary allocation pressure — that sentence is unsupported. The GC × representation matrix (scripts/gc_repsel_matrix.sh) reports those cells as UNVERIFIED rather than green and will flip them to green automatically once an evacuating, unpinned configuration exists; that configuration is what #6942 asks for.Related: #6942, #6946, #6655, #6935, #6910.