You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
gc: zeal + compiled loop polls performs zero copying minors on compute-only benches — green instrument runs may be vacuous #7604
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):
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.
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".
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.
A second "arms but never fires" cause. A compute-only loop compiled with the flag contains zerojs_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.
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.
Activity
gc: zeal reports and fails on its own liveness; docs (#7604)
gc: the evacuation knobs stop lying about what they gate (#7611, #6946,
gc: zeal reports and fails on its own liveness; docs (#7604)
gc: zeal reports and fails on its own liveness; docs (#7604)
proggeramlug commentedon Aug 9, 2026
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 withPERRY_GC_MOVING_LOOP_POLLS=1, run underPERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_DIAG=1: 741,630[gc-copy-minor] ranlines with realcopied_objects=12per cycle, and[gc-fromspace-protect] mode=ProtectPages retired_set=#Nthroughout. 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".
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.js_gc_loop_safepointcall sites — codegen deliberately omits the poll for provably alloc-free bodies (loop_purity::loop_may_allocate) and incidentally omits it for the specializedfor/for-of/for-inlowerings (emit_gc_loop_safepoint's own COVERAGE note). Under zeal that binary collected 5 times, at event-loop boundaries only, and nothing said so.nm/objdump -d BIN | grep -c js_gc_loop_safepointreports 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: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_minorsandmoved_objectsalso say live. Onlyloop_pollssays the loop was never covered. That is why there are three counters and not one.Limitations stated rather than hidden:
process.exit()useslibc::_exitand 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.gc: the evacuation knobs stop lying about what they gate (#7611, #6946,
gc: zeal reports and fails on its own liveness; docs (#7604)
gc: the evacuation knobs stop lying about what they gate (#7611, #6946,
feat(gc): seeded GC-schedule fuzzing (PERRY_GC_SCHEDULE_SEED)
gc: retire PERRY_GC_ZEAL for the seeded schedule
gc: retire PERRY_GC_ZEAL for the seeded schedule
gc: retire PERRY_GC_ZEAL for the seeded schedule
Remove the older collector stress-test setting, now that the newer on…