Skip to content

gc: zeal + compiled loop polls performs zero copying minors on compute-only benches — green instrument runs may be vacuous #7604

Closed

Description

@proggeramlug

Observation from auditing #7602, needs a diagnosis

The prescribed recipe for exercising a barrier/rooting change on a compute-only
workload — compile with PERRY_GC_MOVING_LOOP_POLLS=1, run with
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_DIAG=1 — performed
zero copying minors for me on current main-family binaries (v0.5.1340/41,
perry-dev profile, macOS arm64, default PERRY_RS4GC):

  • Workloads: gc-handoff/bench/push_cls.ts, churn_alloc.ts, retain.ts.
  • Polls verified present in the emitted IR (js_gc_loop_safepoint × 3 in
    push_cls), so the compile-time half was live.
  • Zero [gc-fromspace-protect] … retired_set= lines and zero [gc-copy-minor]
    lines across all three; outputs matched the unzealed runs.

Per CLAUDE.md, zeal bypasses the pending-flag and the budgeted-due test but not
the entry guards — so either an entry guard (in-alloc, suppressed, FFI zone,
root-lock depth) is persistently true at every back-edge poll on these
workloads, or copying-minor eligibility fails under the statepoint lowering
here, or something else. I did not diagnose further; the #7602 audit had
stronger direct evidence (bit-identical remembered-set counters), so nothing
rests on this.

Why it deserves a look anyway: #7602's own validation cites "110
mode=ProtectPages retired_set=#N sets" — presumably on a workload shape with
event-loop boundaries — and CLAUDE.md's instrument table says to check
retired_set=#N is printed and that zeal_forced_collections() is nonzero.
If the recipe silently arms-but-never-fires on compute-only benches, every
green "ran clean under zeal" claim on such a bench is vacuous — the exact
instrument-liveness failure (#7154 family, CLAUDE.md gate hazard 4) these knobs
exist to prevent. The fix may be as small as a loud
[gc-zeal] forced_collections=0 — this run exercised NOTHING line at exit when
zeal is on and the counter is zero, which converts the vacuous green into a
visible one.

Repro:

PERRY_NO_AUTO_OPTIMIZE=1 PERRY_GC_MOVING_LOOP_POLLS=1 target/…/perry compile gc-handoff/bench/push_cls.ts -o /tmp/p
PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_DIAG=1 /tmp/p
# expected: retired_set lines; observed: none, and no [gc-copy-minor] lines

Activity

  1. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Measured. The recipe is NOT inert on current main — but your suspicion was right, and there is a second cause you did not name. PR #7684.

    What does not reproduce (v0.5.1384). benchmarks/gc_ratchet/probes/01_nursery_churn.ts, compiled with PERRY_GC_MOVING_LOOP_POLLS=1, run under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_DIAG=1: 741,630 [gc-copy-minor] ran lines with real copied_objects=12 per cycle, and [gc-fromspace-protect] mode=ProtectPages retired_set=#N throughout. Your own benches (gc-handoff/bench/*.ts) are not in the repo, so the exact workload could not be re-run; something between v0.5.1341 and now changed the outcome for that shape.

    What is real, and is worse than "the recipe sometimes doesn't fire".

    1. The documented liveness check could not be performed. CLAUDE.md says "check crate::gc::zeal_forced_collections() is nonzero". There was no JS API, no diagnostic line and no exit report through which to read it from a compiled program. And the only alternative, PERRY_GC_DIAG=1 + grep, wrote 212 MB of stderr in ten minutes on that same 400k-iteration probe — so that is not a usable check either. A liveness counter nobody can read is the same as no liveness counter.
    2. A second "arms but never fires" cause. A compute-only loop compiled with the flag contains zero js_gc_loop_safepoint call sites — codegen deliberately omits the poll for provably alloc-free bodies (loop_purity::loop_may_allocate) and incidentally omits it for the specialized for / for-of / for-in lowerings (emit_gc_loop_safepoint's own COVERAGE note). Under zeal that binary collected 5 times, at event-loop boundaries only, and nothing said so.
    3. The obvious external check is wrong. nm / objdump -d BIN | grep -c js_gc_loop_safepoint reports 0 on a binary whose polls then fire 20,064 times.

    The fix is the loud line you proposed, plus the counter that makes it correct. Three process-global counters (copying_minor_cycles, moved_objects_total, loop_polls_reached) and a verdict at the process-exit boundary that exits 70:

    # ALLOCATING loop, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1
    [gc-zeal] forced_collections=20069 copying_minors=20069 moved_objects=95506 loop_polls=20064
    exit 0
    
    # COMPUTE-ONLY loop, same flags
    [gc-zeal] forced_collections=5 copying_minors=5 moved_objects=4 loop_polls=0
    [gc-zeal] THIS RUN EXERCISED NOTHING WORTH TRUSTING. … NOT ONE back-edge poll was
    reached, so every collection came from an event-loop boundary and no loop body was
    covered. …
    exit 70
    

    Note what a forced_collections-only line — the fix as originally proposed — would have done with the second run: forced=5, so it prints "5" and passes. copying_minors and moved_objects also say live. Only loop_polls says the loop was never covered. That is why there are three counters and not one.

    Limitations stated rather than hidden: process.exit() uses libc::_exit and never reaches the exit boundary, as does an uncaught throw.

    Shown able to fail: five unit tests over the pure verdict function (including the measured compute-only counters as a fixture), plus end-to-end on real binaries — compute-only exits 70 where it previously exited 0 silently, the allocating probe exits 0 with its numbers, and an unzealed run of the same binary prints no [gc-zeal] line at all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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