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: 05_closure_capture retains +16.44% with every collector counter at +0.00% — surfaced when the ratchet started measuring again #7559
05_closure_capture retains +16.44% with every collector counter unchanged
Surfaced the moment the GC ratchet started measuring again (#7557 — its probes had been skipped entirely since 2026-08-05 because a harness-validation step ran before them).
Of the 10 gating breaches that became visible, most are improvements wearing a two-sided band. This one is not. Reported by the agent that restored the ratchet, on two machine classes (the pinned quiet mini and a MacBook), with retention and counters reproducing bit-for-bit across hosts:
probe
retention
collector counters
05_closure_capture
+16.44%
+0.00% on every one
02_survivor_promotion
+2.77%
—
Same minor_cycles, copied_objects, copied_bytes, promoted_objects, promoted_bytes, freed_bytes — identical work, more retained. That combination is the interesting one: it rules out "the collector ran differently" and points at something being kept alive that previously was not. A closure-capture probe holding 16% more is exactly the shape of an over-broad root or a capture cell that stopped being released.
Why it was invisible
The ratchet did not run between 2026-08-05 and #7557. In that window roughly fifteen GC-affecting changes landed: #7532 (typed-shape layout at allocation), #7535 (shape-install memo), #7536 (write-barrier gating), #7546 (spill-path value rooting), #7553 (tape out of the old generation), #7550/#7552 (for-init type propagation), plus the rooting stack #7495/#7516/#7527/#7529.
Several of those add rooting.#7516 alone rooted 14 sites, #7527 more, #7529 three more — and a root that is broader or longer-lived than necessary retains. That is the first place I would look, and it is worth saying plainly: these fixes were correct and must not be reverted (they closed real use-after-frees), but "more correct rooting" and "more retention" are the same lever pulled in opposite directions.
Suggested method
Bisect the probe's retention across that window with the ratchet itself now that it works — gc_ratchet.py measure --probe 05_closure_capture at each candidate commit on the pinned host. The counters being flat means a PERRY_GC_DIAG trace diff between the endpoints should show the difference as a retained-object delta rather than a cycle-behaviour one.
Note the measurement caveat from #7558: every probe reads process.memoryUsage() after an explicit gc(), which forces a conservative stack scan that systematically over-retains (measured at 8.28 MB / 16% on 12_large_live_set). Run the comparison with PERRY_CONSERVATIVE_STACK_SCAN=off as well, so a false-root artifact is not mistaken for a real regression — or confirmed as one.
Acceptance
Either the +16.44% is attributed to a specific commit and fixed, or it is shown to be a measurement artifact of the conservative scan and the probe's methodology is corrected. Do not re-pin it away without an answer — that is the decision the ratchet exists to force.
Answer: nothing extra is being retained. It is a measurement artifact of the conservative scan, and it is exactly one nursery block.
Investigated in #7571 (instrument + docs; no band widened, nothing re-pinned — this issue's decision is still open, see the bottom).
The finding
05_closure_capture's retention measured with PERRY_CONSERVATIVE_STACK_SCAN=off is 5,329,880 bytes at both endpoints, to the byte. The +16.44% is false-root residue going from one 1 MiB nursery block to two.
PERRY_GC_DIAG counts it directly at the probe's measurement collection:
arm
general blocks marked live
heap_used_bytes
5e236e6e2 (the pin), default scan
6
6,378,392
5e236e6e2 (the pin), scan off
5
5,329,880
b5a2954ec (v0.5.1321), default scan
7
7,426,960
b5a2954ec (v0.5.1321), scan off
5
5,329,880
Both minors are bit-identical across the arms (copied_objects 1422 + 1614 = 3036, exactly the pinned value), and with the scan off both arms free exactly 8,124,192 bytes at the final mark-sweep and retain exactly 425 forwarded stubs. The precise root set marks the same five blocks at both endpoints.
So the issue's inference was half right. "Identical work, more retained" does rule out a change in collector behaviour — but the thing kept alive is not the program's data. It is one more stale word on the native Rust stack at the instant the probe calls gc().
Why the measurement does that
Two properties of the measurement point, neither about the collector's retention:
The measurement forces the conservative scan. Every probe reads process.memoryUsage() immediately after an explicit gc(), and an explicit gc() is the one site in Perry that forces the conservative native-stack scan (ManualGcScanGuard, GC: explicit gc() (full collect, default auto stack-scan) reclaims live top-level locals — string fields read back as garbage #4977 — production resolves to SkipDisabled). PERRY_GC_DIAG shows the suite's entire conservative-scan census is the probes' own gc() (site=manual_collect automatic=false on all twelve), plus one automatic old_reclaim_alloc_point on 12_large_live_set. The gated number is taken under a root set nothing else in the language uses.
Both arms built --release -p perry -p perry-runtime-static -p perry-stdlib-static with a cold object cache. The baseline arm reproduces the pinned artifact byte-for-byte on all twelve probes, which is what makes the head arm comparable.
probe
heap_used_bytes base → head
scan-off (precise) base → head
01_nursery_churn
7,325,584 → 6,277,048 (−14.3%)
5,228,512 → 5,228,512 (0)
02_survivor_promotion
9,418,232 → 9,678,792 (+2.77%)
9,418,232 → 9,416,632 (−1,600)
03_cross_gen_writes
2,793,472 → 1,427,664 (−48.9%)
2,465,776 → 1,394,880 (−1,070,896)
04_dead_after_deep_stack
6,257,040 → 4,897,320 (−21.7%)
5,208,472 → 4,891,968 (−316,504)
05_closure_capture
6,378,392 → 7,426,960 (+16.44%)
5,329,880 → 5,329,880 (0)
06_string_retention
7,058,896 → 7,058,896
4,961,800 → 4,961,800 (0)
07_array_grow_evacuate
15,649,104 → 15,649,104
unchanged (0)
08_map_set_sidetables
1,548,960 → 1,548,960
unchanged (0)
09_try_catch_roots
7,068,864 → 6,020,320 (−14.8%)
4,982,528 → 4,982,528 (0)
10_store_receiver_across_alloc
4,666,248 → 4,666,248
unchanged (0)
11_collect_at_depth
7,390,400 → 7,390,400
unchanged (0)
12_large_live_set
59,943,824 → 59,943,896
51,668,568 → 51,668,568 (0)
heap_used_bytes moved on five probes, always by whole blocks, three down and two up. Precise retention moved on two, and both went down.
That cuts both ways, which is the part worth keeping: 01_nursery_churn's −14.3% and 09_try_catch_roots' −14.8% are also false-root residue, not wins. Their precise retention is byte-identical.
02_survivor_promotion (+2.77%) is the same shape one survivor block down: excess 0 → 262,160 B (≈256 KiB), and its precise retention fell by 1,600 bytes. Same cause, and it needs no separate investigation.
And the metric is not reporting the live set at all
05_closure_capture drops everything before it measures, so its live set is ~zero for every workload size. Sweeping BATCHES with one compiler:
A 1 MiB sawtooth over a constant zero live set. What is left over is the un-reset tail blocks' bump pointers — arena_reset_empty_blocks never resets the current block or the four before it. Two batches of workload (123 KB) move the gated number by 925,696 bytes.
Which commit?
None, meaningfully. Bisecting further would attribute a coincidence: any codegen or allocation change perturbs the native stack image at the gc() callsite, and the metric quantises the result to whole blocks. The rooting stack (#7495/#7516/#7527/#7529) is exonerated by the scan-off column — a broader or longer-lived root would raise the precise number, and it did not move by one byte on ten of twelve probes.
gc_ratchet.py classify — runs every probe under both scan modes, prints conservative / spread / precise / excess and the scan-site census. A row whose excess moved and whose precise did not is a false-root artifact; a row whose precise moved is a real retention change. It refuses to tabulate a probe whose stdout changes when the scan is disabled (then the scan was load-bearing for that probe's correctness and its precise number is not evidence), and refuses to report a non-bit-identical precise reading — while allowing the conservative reading to vary and reporting its spread, because that spread on 12_large_live_set is why #7554 had to stop gating the cell. Plus the finding written into the module docstring, the README, and tolerances.json, and six tests.
The decision that is still yours
Per this issue's own instruction, #7571 does not re-pin and does not widen a band. 05_closure_capture and 02_survivor_promotion stay red. The options, with what I think is wrong with each:
Re-pin those two cells, recording "false-root residue moved by one block; precise retention 5,329,880 → 5,329,880 / −1,600" in --notes. Cheapest, honest, and needs the pinned host. It does not stop the next unrelated codegen change from moving another probe by a block.
Change what the retention family measures — record retention from a second untraced pass with PERRY_CONSERVATIVE_STACK_SCAN=off and gate that, keeping the conservative reading as an informational row. This makes the number semantic, keeps the band tight (a genuinely rooted extra block still costs 1 MiB and still goes red), and lets gc-ratchet has measured nothing since 2026-08-05: harness validation fails before the probes run (12_large_live_set spread 6768) #7554's 12_large_live_set exclusion be deleted — its scan-off reading is bit-identical at 51,668,568. Costs a full re-pin. The objection to weigh is that the ratchet was built partly to catch the campaign's move toward a conservative scan; my answer is that the campaign's scan would be the automatic production path, not this forced-at-gc() one, but that is a call for whoever owns the campaign.
Widening the band is not on the list.heap_used_bytes's churn quantum is one whole block, which is also the unit test_one_falsely_retained_nursery_block_fails asserts must go red. A quantity whose noise quantum equals its signal quantum cannot be gated; the fix is to measure a different quantity, not to loosen the band on this one.
Measured on a MacBook Pro (10 cores); every number above is retention or a collector counter, both of which are load-independent and were verified bit-identical across repeats. The baseline arm reproducing the pinned quiet-host artifact byte-for-byte on all twelve probes is the cross-host check.
Reproduced independently on the pinned quiet host at current main, as part of
the #7554 repair (#7609). This closes the measurement question this issue asked;
the acceptance said "either the +16.44% is attributed to a specific commit and
fixed, or it is shown to be a measurement artifact of the conservative scan".
05's precise retention is 5,329,880 — byte-identical to the figure test(gc-ratchet): classify a retention breach instead of guessing at it (#7559) #7571
measured at both ends of its 74-commit window. So across everything that
landed since, the collector's own roots account for exactly the same retained
bytes. The entire +16.44% is false-root residue going 1 block → 2 (2,097,080 B
≈ 2 MiB), which is the quantum heap_used_bytes moves in because js_arena_stats sums block offsets and a block cannot be reset while one
marked object sits in it.
02's precise retention is 9,416,632, which is below the 9,418,232
the old baseline recorded as that probe's retention. Since precise ≤
conservative always, real retention at the new commit is bounded above by a
number smaller than the old reported one. The +2.77% cannot be a retention
increase.
So both breaches are the measurement, not the collector — consistent with every
counter having been at +0.00%, which is what made this issue interesting in the
first place. No rooting change from #7516/#7527/#7529 is implicated.
Worth being blunt about the consequence, though, because it is not "nothing to
do here": heap_used_bytes's noise quantum is one whole 1 MiB block, while
its band on 05 is 127,568 B. A quantity whose noise quantum is 8× its band
cannot carry a gate, and re-pinning it — which #7609 does, to get the other 143
cells gating again — only resets the clock until the next build shifts a stack
word. #7571 reached the same conclusion from the other direction ("the fix is to
measure a different quantity, not to loosen the band on this one").
The follow-up is therefore on #7558, not here: gate the classifyprecise
reading instead of the conservative one, so retention stops being measured under
a root set nothing else in the language uses. That is a metric-family redesign
and a maintainer call, deliberately not smuggled into the repair PR.
Closed by #7657 (v0.5.1374) — it was measurement, and the cause is gone.
This issue's shape ("+16.44% retained with every collector counter at +0.00%") was exactly right as an observation, and the diagnosis it implied was right too: the counters said the collector did the same work, so the extra bytes were not retention. They were conservative-stack-scan false roots, forced by the probe's own explicit gc().
#7657 removed the cause rather than the symptom: gc() now runs on precise roots. The forced scan turned out to be a workaround for a precise-rooting hole (#4977 → #4998) that the 2026-06→08 rooting campaign had since closed from the other end — grandfathered, then quoted for two months as though it were a requirement. test_issue_4977_gc_toplevel_locals.ts prints the right answer with the scan off, including under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1.
Result: excess 0.00% and spread 0 on all twelve probes, with the site=manual_collect census line gone from every one (it fired on all 12 before — that is the liveness assertion, not its absence).
Two corrections to this issue's own numbers, from the re-measurement:
The residue was non-zero on 9 of 12 probes, up to 31.03% — far broader than the one probe here suggested. 12_large_live_set's 13.80% was the smallest of the four large ones; it stood out only because it was the only one with non-zero spread.
This probe's +16.44% is not its residue — that figure is drift between two commits. Its actual excess was 28.24%.
Activity
proggeramlug commentedon Aug 7, 2026
Answer: nothing extra is being retained. It is a measurement artifact of the conservative scan, and it is exactly one nursery block.
Investigated in #7571 (instrument + docs; no band widened, nothing re-pinned — this issue's decision is still open, see the bottom).
The finding
05_closure_capture's retention measured withPERRY_CONSERVATIVE_STACK_SCAN=offis 5,329,880 bytes at both endpoints, to the byte. The+16.44%is false-root residue going from one 1 MiB nursery block to two.PERRY_GC_DIAGcounts it directly at the probe's measurement collection:heap_used_bytes5e236e6e2(the pin), default scan5e236e6e2(the pin), scan offb5a2954ec(v0.5.1321), default scanb5a2954ec(v0.5.1321), scan offBoth minors are bit-identical across the arms (
copied_objects1422 + 1614 = 3036, exactly the pinned value), and with the scan off both arms free exactly 8,124,192 bytes at the final mark-sweep and retain exactly 425 forwarded stubs. The precise root set marks the same five blocks at both endpoints.So the issue's inference was half right. "Identical work, more retained" does rule out a change in collector behaviour — but the thing kept alive is not the program's data. It is one more stale word on the native Rust stack at the instant the probe calls
gc().Why the measurement does that
Two properties of the measurement point, neither about the collector's retention:
process.memoryUsage()immediately after an explicitgc(), and an explicitgc()is the one site in Perry that forces the conservative native-stack scan (ManualGcScanGuard, GC: explicit gc() (full collect, default auto stack-scan) reclaims live top-level locals — string fields read back as garbage #4977 — production resolves toSkipDisabled).PERRY_GC_DIAGshows the suite's entire conservative-scan census is the probes' owngc()(site=manual_collect automatic=falseon all twelve), plus one automaticold_reclaim_alloc_pointon12_large_live_set. The gated number is taken under a root set nothing else in the language uses.js_arena_statssums block offsets, not live bytes. A bump pointer never moves backwards and a block holding one marked object cannot be reset, so a single stale stack word costs a whole 1 MiB block — ~26,000x amplification. This is the nursery's version of the old-generation accounting problem GC: old-gen fragmentation — scattered survivors pin 105 MB of blocks for a ~1 MB live set #7437/gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443 fixed forOLD_ARENAby subtracting swept holes.The whole window, all twelve probes
Both arms built
--release -p perry -p perry-runtime-static -p perry-stdlib-staticwith a cold object cache. The baseline arm reproduces the pinned artifact byte-for-byte on all twelve probes, which is what makes the head arm comparable.heap_used_bytesbase → head01_nursery_churn02_survivor_promotion03_cross_gen_writes04_dead_after_deep_stack05_closure_capture06_string_retention07_array_grow_evacuate08_map_set_sidetables09_try_catch_roots10_store_receiver_across_alloc11_collect_at_depth12_large_live_setheap_used_bytesmoved on five probes, always by whole blocks, three down and two up. Precise retention moved on two, and both went down.That cuts both ways, which is the part worth keeping:
01_nursery_churn's −14.3% and09_try_catch_roots' −14.8% are also false-root residue, not wins. Their precise retention is byte-identical.02_survivor_promotion(+2.77%) is the same shape one survivor block down: excess 0 → 262,160 B (≈256 KiB), and its precise retention fell by 1,600 bytes. Same cause, and it needs no separate investigation.And the metric is not reporting the live set at all
05_closure_capturedrops everything before it measures, so its live set is ~zero for every workload size. SweepingBATCHESwith one compiler:A 1 MiB sawtooth over a constant zero live set. What is left over is the un-reset tail blocks' bump pointers —
arena_reset_empty_blocksnever resets the current block or the four before it. Two batches of workload (123 KB) move the gated number by 925,696 bytes.Which commit?
None, meaningfully. Bisecting further would attribute a coincidence: any codegen or allocation change perturbs the native stack image at the
gc()callsite, and the metric quantises the result to whole blocks. The rooting stack (#7495/#7516/#7527/#7529) is exonerated by the scan-off column — a broader or longer-lived root would raise the precise number, and it did not move by one byte on ten of twelve probes.What #7571 lands
gc_ratchet.py classify— runs every probe under both scan modes, printsconservative/spread/precise/excessand the scan-site census. A row whoseexcessmoved and whoseprecisedid not is a false-root artifact; a row whoseprecisemoved is a real retention change. It refuses to tabulate a probe whose stdout changes when the scan is disabled (then the scan was load-bearing for that probe's correctness and its precise number is not evidence), and refuses to report a non-bit-identical precise reading — while allowing the conservative reading to vary and reporting its spread, because that spread on12_large_live_setis why #7554 had to stop gating the cell. Plus the finding written into the module docstring, the README, andtolerances.json, and six tests.The decision that is still yours
Per this issue's own instruction, #7571 does not re-pin and does not widen a band.
05_closure_captureand02_survivor_promotionstay red. The options, with what I think is wrong with each:--notes. Cheapest, honest, and needs the pinned host. It does not stop the next unrelated codegen change from moving another probe by a block.PERRY_CONSERVATIVE_STACK_SCAN=offand gate that, keeping the conservative reading as an informational row. This makes the number semantic, keeps the band tight (a genuinely rooted extra block still costs 1 MiB and still goes red), and lets gc-ratchet has measured nothing since 2026-08-05: harness validation fails before the probes run (12_large_live_set spread 6768) #7554's12_large_live_setexclusion be deleted — its scan-off reading is bit-identical at 51,668,568. Costs a full re-pin. The objection to weigh is that the ratchet was built partly to catch the campaign's move toward a conservative scan; my answer is that the campaign's scan would be the automatic production path, not this forced-at-gc()one, but that is a call for whoever owns the campaign.js_arena_statssubtract swept nursery free space the way GC: old-gen fragmentation — scattered survivors pin 105 MB of blocks for a ~1 MB live set #7437/gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443 made it subtract old-gen holes. Then one falsely-retained object costs ~40 bytes instead of 1 MiB and the metric becomes a live-set measurement. Biggest change, user-visible inprocess.memoryUsage().heapUsed, and the nursery has no free-list to count from today — but it is the only option that fixes the 26,000x amplification rather than working around it.Widening the band is not on the list.
heap_used_bytes's churn quantum is one whole block, which is also the unittest_one_falsely_retained_nursery_block_failsasserts must go red. A quantity whose noise quantum equals its signal quantum cannot be gated; the fix is to measure a different quantity, not to loosen the band on this one.Measured on a MacBook Pro (10 cores); every number above is retention or a collector counter, both of which are load-independent and were verified bit-identical across repeats. The baseline arm reproducing the pinned quiet-host artifact byte-for-byte on all twelve probes is the cross-host check.
test(gc-ratchet): classify a retention breach instead of guessing at …
test(gc-ratchet): classify a retention breach instead of guessing at …
proggeramlug commentedon Aug 7, 2026
Reproduced independently on the pinned quiet host at current
main, as part ofthe #7554 repair (#7609). This closes the measurement question this issue asked;
the acceptance said "either the +16.44% is attributed to a specific commit and
fixed, or it is shown to be a measurement artifact of the conservative scan".
It is the artifact, and the numbers are exact.
gc_ratchet.py classify,perry-macos(M1 mini),main26b9c9d59(0.5.1346),3 repeats per scan mode:
05_closure_capture02_survivor_promotionTwo things fall out:
05's precise retention is5,329,880— byte-identical to the figure test(gc-ratchet): classify a retention breach instead of guessing at it (#7559) #7571measured at both ends of its 74-commit window. So across everything that
landed since, the collector's own roots account for exactly the same retained
bytes. The entire +16.44% is false-root residue going 1 block → 2 (2,097,080 B
≈ 2 MiB), which is the quantum
heap_used_bytesmoves in becausejs_arena_statssums block offsets and a block cannot be reset while onemarked object sits in it.
02's precise retention is9,416,632, which is below the9,418,232the old baseline recorded as that probe's retention. Since precise ≤
conservative always, real retention at the new commit is bounded above by a
number smaller than the old reported one. The +2.77% cannot be a retention
increase.
So both breaches are the measurement, not the collector — consistent with every
counter having been at +0.00%, which is what made this issue interesting in the
first place. No rooting change from #7516/#7527/#7529 is implicated.
Worth being blunt about the consequence, though, because it is not "nothing to
do here":
heap_used_bytes's noise quantum is one whole 1 MiB block, whileits band on
05is 127,568 B. A quantity whose noise quantum is 8× its bandcannot carry a gate, and re-pinning it — which #7609 does, to get the other 143
cells gating again — only resets the clock until the next build shifts a stack
word. #7571 reached the same conclusion from the other direction ("the fix is to
measure a different quantity, not to loosen the band on this one").
The follow-up is therefore on #7558, not here: gate the
classifyprecisereading instead of the conservative one, so retention stops being measured under
a root set nothing else in the language uses. That is a metric-family redesign
and a maintainer call, deliberately not smuggled into the repair PR.
test(gc-ratchet): re-pin the baseline at main 26b9c9d on the pinned h…
proggeramlug commentedon Aug 9, 2026
Closed by #7657 (v0.5.1374) — it was measurement, and the cause is gone.
This issue's shape ("+16.44% retained with every collector counter at +0.00%") was exactly right as an observation, and the diagnosis it implied was right too: the counters said the collector did the same work, so the extra bytes were not retention. They were conservative-stack-scan false roots, forced by the probe's own explicit
gc().#7657 removed the cause rather than the symptom:
gc()now runs on precise roots. The forced scan turned out to be a workaround for a precise-rooting hole (#4977 → #4998) that the 2026-06→08 rooting campaign had since closed from the other end — grandfathered, then quoted for two months as though it were a requirement.test_issue_4977_gc_toplevel_locals.tsprints the right answer with the scan off, including underPERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1.Result: excess 0.00% and spread 0 on all twelve probes, with the
site=manual_collectcensus line gone from every one (it fired on all 12 before — that is the liveness assertion, not its absence).Two corrections to this issue's own numbers, from the re-measurement:
12_large_live_set's 13.80% was the smallest of the four large ones; it stood out only because it was the only one with non-zero spread.