Repository navigation
gc(): the forced conservative stack scan makes every retained-heap reading nondeterministic, and 16% too large on a 50 MB live set #7558
Description
Split out of #7554, which asked whether 12_large_live_set's retention
non-determinism is benign or a real finding. It is a real finding, and it is
not about the probe.
The metric is measured after a deliberately conservative collection
Every GC-ratchet probe reads process.memoryUsage() after an explicit gc().
An explicit gc() runs a full mark-sweep with a forced conservative stack
scan — PERRY_GC_DIAG=1 prints this on every single run:
[gc-scan-fallback] site=manual_collect automatic=false count=1
A conservative scan retains whatever the native stack happens to look like a
pointer to. Stack residue at that moment differs between runs, so the number
the probe reports differs between runs.
The evidence
Diffing two full PERRY_GC_DIAG traces of the same binary that disagree: the
minors, the tenuring decisions, the step cycles and every copy/promote counter
match exactly. The only difference is in the last collection:
< [gc] blocks: … freed_bytes=87675232 … < [gc-old-free] reusable_bytes=45397168
> [gc] blocks: … freed_bytes=87677896 … > [gc-old-free] reusable_bytes=45399760
< #gcmetric heap_used_bytes=59945816 > #gcmetric heap_used_bytes=59943224
And turning the scan off removes the variance completely:
| arm | heap_used_bytes |
|---|---|
| default | 59,943,080 … 59,952,824 over 22 runs, 5+ distinct values |
PERRY_CONSERVATIVE_STACK_SCAN=off |
51,668,688 × 8 runs, bit-identical |
Two conclusions:
- The run-to-run variance is entirely false roots. A few kilobytes —
bounded by how much a handful of stale stack words can pin. - The conservative scan retains 8,275,208 bytes systematically — 16% of what
this probe reports as its retained heap. That is not noise, it is a
constant tax on everygc()-then-measure reading.
The eleven other probes stay bit-identical because their live sets are one to
two orders of magnitude smaller, so a stale stack word is far less likely to
alias a plausible heap address at all. This is a size effect, not a property of
what 12_large_live_set does.
Reproduced three times independently on 2026-08-06/07 at be1fc80f8
(0.5.1315): pinned quiet host perry-macos spread 2,304 over 7 harness repeats
(all eleven other probes spread 0); MacBook Pro under load 21 spread 9,072 over
7; 22 ad-hoc runs of the same binary spanning 9,744 bytes.
Why it matters beyond the ratchet
gc() is the measurement instrument for every retained-heap number this project
quotes, and it reports precise retention plus a stack-residue tax. On a ~50 MB
live set that tax was 16%. Any conclusion of the form "this change reduced
retained heap by X%" taken through gc() on a large heap has that term in it.
Worth deciding deliberately:
- Should a manual
gc()force a conservative scan at all? The probes' roots
are precise; the fallback is presumably defensive. - If it must, should the ratchet measure with
PERRY_CONSERVATIVE_STACK_SCAN=off
soheap_used_byteson the large-live-set probe becomes gateable again? That
would let theprobe_overridesentry added in test(gc-ratchet): per-probe gating overrides so the probes run again (#7554) #7557 be deleted, which the
override machinery already forces to be a deliberate act (an entry that
matches nothing fails).
Current state
#7557 takes 12_large_live_set.heap_used_bytes out of the gating family with
this reason recorded next to it, so the ratchet measures again. Every other
metric on that probe — heap_total_bytes, minor_cycles, copied_objects,
copied_bytes, promoted_*, freed_bytes — is unaffected by the scan and
stays gating, so a real over-retention regression on that probe still goes red.
Activity
docs(gc-ratchet): point the override's evidence at the cause issue
test(gc-ratchet): per-probe gating overrides so the probes run again (#…
chore(gc-ratchet): re-pin 01_nursery_churn.heap_used_bytes for #7645
chore(gc-ratchet): re-pin 01_nursery_churn.heap_used_bytes for #7645
perf(gc): stop traversing the young graph twice — skip the copying mi…
test(gc-ratchet): re-arm the 12_large_live_set retention cell and mak…
test(gc-ratchet): re-pin the baseline on the pinned quiet host (#7558)
docs(gc): quote the measured residue instead of the issue's single-pr…
docs(gc): name js_gc_collect as a collection point in the rooting inv…
wip(gc): explicit gc() runs on precise roots (#7558)
test(gc-ratchet): re-arm the 12_large_live_set retention cell and mak…
test(gc-ratchet): re-pin the baseline on the pinned quiet host (#7558)
docs(gc): quote the measured residue instead of the issue's single-pr…
docs(gc): name js_gc_collect as a collection point in the rooting inv…
fix(gc): explicit gc() runs on precise roots — remove the forced cons…
fix(gc): stop two passes retaining unreachable objects (#9628, #9629)