Skip to content

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

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions