Repository navigation
gc: automatic (allocation-driven) collections in compiled programs are always full mark-sweeps under a forced conservative scan — PERRY_GC_FORCE_EVACUATE is inert there too #6950
Description
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.
Activity
test(gc): GC × representation-selection stress matrix — harness, live…
proggeramlug commentedon Jul 29, 2026
Dependency note: #6951 is a prerequisite for this.
The reason
gc_check_triggertakesforce_full_scan()on the automatic arms looks load-bearing rather than incidental: precise (shadow-stack) roots are currently incomplete, and the conservative native-stack scan is what covers the gap. #6951 is the concrete evidence — with the scan off, a plain non-moving mark-sweep dropsconsole.log's string-literal argument, no evacuation required, reproducing with all six representation gates off.So removing or bypassing the forced scan to make an evacuating minor reachable would, today, expose real unrooted values rather than merely enable verification.
Suggested order:
scripts/gc_repsel_matrix.sh— the 333 UNVERIFIED cells and the twocons_scan_offXFAILs are the measure of progress.Worth noting the production stakes independent of testing:
conservative_stack_scan_mode()resolvesAuto → SkipDisabledin release (gc/roots.rs), so the conservative cover is not guaranteed in shipped builds — which makes #6951 a correctness issue in its own right, not only a test-harness blocker.docs(changelog): don't present the FORCE_EVACUATE arm as evacuation-s…
perf(codegen): repsel Phase 5a — Ptr<Shape> proven `this` in methods (#…
fix(gc): layout_transfer clears a valid intact bit on shape-keyed obj…
fix(gc): a compiled program can now actually reach an evacuating minor (
proggeramlug commentedon Jul 30, 2026
Closing — resolved by #6977 and #7019.
This issue said
gc_check_triggertakesManualGcScanGuard::force_full_scan()on both automatic arms, so no reachable configuration in a compiled program evacuates. That was measured at the time: 45 cycles underPERRY_GC_FORCE_EVACUATE=1, allmanualfull mark-sweeps,total_moved_objects: 0.Both halves are now fixed:
GcDebtSnapshot::currentmeasured debt against the rawGC_NEXT_TRIGGER_BYTESwhile arming usedeffective_next_arena_trigger(), so debt read zero and the assist budget never left its 256-unit floor).Current evidence, matrix on
e279b2d54,--arms all --pressure 8, 440 cells: the evacuating arms reportcopy-minor 21/22withcopied_objectsin the hundreds of thousands to 1.5 M. Un-pressuredshipped_defaultcopied 40,087,037 objects. The premise of this issue — that nothing evacuates — no longer holds.Two related things stayed open deliberately and are not closed by this:
--pressureknob disables the path gc: default-on evacuating young-gen scavenge (moving minor at precise safepoints) #7019 added, so thedefaultarm runs zero copying minors. That is a harness defect, not a collector one, and it is being fixed separately.gc_safepoint_moving_minorvia itsgc_budgeted_cycle_active()early-return. gc: default-on evacuating young-gen scavenge (moving minor at precise safepoints) #7019 removed the nursery-armed route into that state; a non-nursery-armed one is unproven and untested.test(gc): corpus row for array truncate-then-append; PR-key the chang…