Repository navigation
perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back #7929
Description
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performance
on Aug 12, 2026 The corpus census: the standing spread is 110×, not 1.29×
The issue's evidence is one PR's 72 B → 56 B shift (a 1.29× object-count change
inside a fixed byte budget). Measuring the whole corpus says that is the small
end of the effect. Mean surviving object size, derived from[gc-copy-minor] ran
as(copied_bytes + promoted_bytes) / (copied_objects + promoted_objects),
origin/main@b8c1f103e, all 19 programs byte-exact and exit 0:program minors copied_ob promoted_ob mean surviving object B objects per 16 MB cap tree_wide 40 30 10 32.8 ~512 000 cycles / tree 16 / 40 6 2 36.0 ~466 000 interp 38 14 037 4 804 53.7 ~312 000 iso_miss 50 14 634 4 812 53.8 ~312 000 pipeline 6 97 30 57.6 ~291 000 deeplist 3 247 573 742 717 72.0 ~233 000 retain / retain1 5 / 3 245 752 2 109 820 / 740 896 72.0 ~233 000 shapes 1 7 416 0 72.2 ~232 000 asyncpipe 1 172 387 0 79.7 ~210 000 churn / churn_alloc / push_cls 105 47 705 2 83.3 ~201 000 retain_wide / retain_wide1 8 / 4 170 140 2 771 303 / 855 719 104.0 ~161 000 push_num 18 18 0 3 600 ~4 660 One 16 MB byte budget buys between ~4 660 and ~512 000 objects of collector
work, decided only by the program's object shape.★
retainis a live example of the counter trap:copied_objects = 245 752
againstpromoted_objects = 2 109 820. Readingcopied_objectsalone reports
that cycle as doing ~10 % of its actual per-object work.Narrowing where the byte denomination actually lives
Four byte-denominated valves — and they are not equally wrong:
site expression representation-invariant? policy.rs:107young_scavenge_cap_duefrom_space_bytes >= cap_bytesNO — this is the mismatch tenuring.rs:243desired_survivor_bytescap / 16n/a (it sizes a space) tenuring.rs:247compute_target_survivals1 + desired_bytes / eden_live_bytesYES, by cancellation tenuring.rs:334retune_nursery_cap_scaleeden_live_bytes ≷ cap/25, cap/100YES, by cancellation Both
tenuring.rsratios compare a byte quantity against another byte quantity
derived from the same cap, so uniformly scaling every object cancels out:
(cap/16)/eden_bytesdoes not change. Only the trigger genuinely converts a
fixed byte budget into a variable amount of per-object work.So this is one comparison, not a subsystem in the wrong units — which is good
news for the fix's size and bad news for its cost.Why I did not ship a pacing change
Adding an object term alongside the byte term
(due = bytes >= cap_bytes || objects >= cap_objects) fires the collector
earlier on small-object programs: a smaller per-cycle pause bought with
more cycles, each paying the fixed root scan. #7915 measured that fixed cost
at 82 scanners over 218 455 pointer roots onasyncpipe, and the #7926
re-derivation just showed that on that program a single extra minor is the
entire observable delta. On this corpus the trade looks net-negative on exactly
the programs the campaign is trying to fix.Two further blockers for whoever picks this up:
- The arena tracks no nursery object count.
copying_from_space_in_use_bytes(arena/reset.rs:78) sumsblock.offset.
An object term needs either a counter on the allocation fast path — which
perf(runtime): allocation path spends 34% of self time in _tlv_get_addr — 24× behind Node on object churn with the collector already idle #7469 spent a campaign making cheap — or an estimate from the previous cycle's
measured mean. The table above shows that mean is stable per program but
spans 110× across programs, so a single constant will not do. push_num's 3 600 B mean is arrays, not objects. An object-count cap has
to be defined over the same population the collector charges per-item work
for, orpush_numtrips it continuously.
Full working:
gc-handoff/PACING-NOTES.md§4.- The arena tracks no nursery object count.
Now measured end-to-end on the quiet mini, not a projection.
Sweep at
8039fa798(VERDICT: CLEAN, load 1.24, best-of-5) vs the previous clean sweep at14e291f3a:bench before after delta deeplist0.057 0.063 +10.5% retain10.068 0.074 +8.8% Both are perry-side (node moved <2% on each). This is the wall-clock realisation of exactly what #7928's author predicted: right-sizing objects 72 → 56 B means every minor fires at the same byte mark but processes 1.286× as many objects, taking
retain1's GC pause 39.60 → 50.10 ms — more than the program's entire cycle delta.The tax is real, reproduced at the predicted magnitude on a quiet machine, and currently costs two benchmarks ~10% each.
This issue now blocks the compaction direction rather than annotating it. #7916's follow-up (the ≥4-field header work,
gc-handoff/REPR-NOTES.md§4) would take{a,b}to 32 B andretain_wideto 80 B — a much larger object-count multiplier and therefore a much larger tax, unless the budgets carry an object-count term first.The prior analysis stands as the starting point: of four byte-denominated valves, both
tenuring.rsratios are invariant by cancellation and onlyyoung_scavenge_cap_dueis genuinely mis-denominated; the corpus census spans 32.8 B → 3600 B mean surviving object size (110×). The recorded blocker — an object term fires the collector earlier, and each added cycle pays #7915's fixed root scan — is still the thing to answer, but #7933/#7939 has since cutasyncpipe's young survival 770‰ → 24‰, which changes that calculus.- added a commit that references this issue
on Aug 12, 2026 ⚠️ Post-merge correction: on a healthy collector the shipped term is net-NEGATIVE, and #7965 is a hard prerequisite#7961 merged (
109de930f) while its validation was still running. The validation
finished and contradicts the A/B in the PR body. Recording it here rather
than leaving the PR's numbers standing.What the PR measured, and why it was misleading
The PR's A/B was taken against
ac52a5c38, which contains #7965 (bisected in
that issue to #7944 / #7902 — untraced promotion stopped crediting the
old-reclaim baseline, sodeeplist/retain*each gained a futile full
mark-sweep). Against that regressed base the term reads as a large win, because
it happens to keep old-gen under the 48 MB one-shot threshold.Re-run against the pre-regression base (
e886b56dd, the commit before
#7902), with the same two commits cherry-picked on top so the only variable is
the term itself:program base fulls / instr +term fulls / instr Δobjects Δinstr ΔRSS deeplist 0 / 1107.2 M 0 / 1093.5 M −11.8% −1.2% −0.0% retain1 0 / 1293.7 M 1 / 3342.4 M −11.8% +158.4% +25.4% retain 0 / 2596.4 M 1 / 4635.8 M −5.1% +78.5% +2.6% retain_wide 0 / 3029.3 M 0 / 3027.7 M 0 −0.1% 0.0% The term itself introduces the futile full on
retain1andretain.Mechanism — the same defect as #7965, reached from the other side
old_reclaim_pressure_due's absolute arm is a one-shot latch:(old_in_use >= T && baseline < T) || ... // T = 48 MB
baseline(GC_LAST_OLD_RECLAIM_IN_USE_BYTES) is a running sum of promotion
deltas, so whether it ends up above or below T is decided by promotion step
size, not by the heap. Onretain1:promotion steps final baseline latch base 18.74 + 34.60 MB 53.34 MB > T disarmed by overshoot +term 18.74 + 26.21 MB 44.95 MB < T still armed The smaller band makes the second step smaller, the baseline lands 3 MB short of
T, and the latch survives.retain1'sallarray — a large object born straight
into old-gen, whose growth never passes through promotion and so never credits
the baseline — then pushesold_in_usepast 48 MB and the latch fires. The full
reportsfreed_bytes = 8 257 632, i.e. the array's dead backing stores: a
whole-heap collection to reclaim 8 MB.deeplisthas no such old-born array, which is why it is the one program in the
table that stays clean — and its −1.2% is therefore the honest value of the
object denomination on a healthy collector, not the −72.3% the PR quotes.So #7965 and this are one defect: a valve whose firing is decided by
granularity rather than by the quantity it claims to measure. #7902 zeroed the
baseline; this term lowers it. Either way the latch is left armed and a
100 %-live old-gen gets a futile full.What I recommend
- Do not revert fix(gc): denominate the nursery constant band in objects (#7929) #7961 yet. On current main both arms already carry the
perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965 full, so the term is measured there asretain1−10.9%,retain
−63.1%,deeplist−72.3% and is a net win today. Reverting now would make
main worse. - Fixing perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965 must re-measure this term in the same change. The moment the
baseline is credited correctly,retain1/retainflip from −11 %/−63 % to
+158 %/+78 %. That is a landmine, and it is armed. - The durable fix is to stop the latch depending on step size at all — either
credit old-gen growth to the baseline regardless of how it arrived, or refuse
to schedule a reclaim when the last cycle measuredyoung_survival_permille = 1000(it was 1000 on every cycle in this table, i.e. the collector had just
finished proving the reclaim cannot free anything).
What stands from the PR
- The corpus census, and that only the constant band is mis-denominated (both
tenuring.rsratios and the tenured-proportional arm are invariant by
cancellation). - The one-sided clamp and its control set —
push_num's 3600 B array-dominated
mean is neutralised, and the 5 factor-1.000 programs calibrate the A/B's
link-layout noise floor at −0.3 %…−3.5 %. - The recorded blocker is still refuted: an extra copying minor costs ~0.5 M
instructions (churn: 43→172 minors for +4 % instructions and −60 % RSS), so
"more cycles pay the fixed root scan" was never the reason not to do this. - What is not established, and what the PR over-claimed: that the term is worth
~10 % on the taxed programs. On a healthy collector it is worth ~1 %
(deeplist, clean cell, 3 minors and 0 fulls in both arms). The ~10 % figures
in the PR body and inREPR-NOTES.md§3d are entangled with cycle-kind
changes and should not be quoted as per-object work.
Full working:
gc-handoff/BUDGET-NOTES.md§8.- Do not revert fix(gc): denominate the nursery constant band in objects (#7929) #7961 yet. On current main both arms already carry the
#7929's band re-measured on top of the #7965 fix — the reported win does not survive, and the honest picture is a cost on the churn cluster
#7961 merged on top of the #7965 regression (
mainwas carrying spurious
old_gen_bytesfull mark-sweeps on the retain cluster at the time), so its
headline numbers were measured against a baseline whose fulls this band change
happened to move.Re-measured with #7965 fixed (PR #7968) in both arms — same compiler, same
runtime archives, same corpus, onlynursery_cap_object_scale_permilleforced
to1000in the off arm. 19/19 byte-exact + exit 0 in both. Cycle counts and
instructions retired are load-independent.program cycles with #7929 cycles without instr M with instr M without retain 4 4 2 176 2 178 retain1 2 2 956 958 retain_wide 6 6 2 859 2 860 retain_wide1 3 3 911 911 deeplist 2 2 961 968 cycles 22 12 1 836 1 808 interp 44 31 11 708 11 619 iso_miss 61 43 14 322 14 203 churn / churn_alloc / push_cls 88 86 +0.1% — pipeline 7 6 2 599 2 598 shapes, tree, tree_wide, push_num, asyncpipe, fib40 identical identical ±0.05% — 1. On its stated target the denomination is worth ±0.2% and zero cycles
The retain cluster is where the 72 B → 56 B representation shrink was measured
and where the band was expected to pay. With #7965 fixed it does not respond at
all: same cycle count, same full count, instruction counts within 0.2%.
#7961's reportedretain−63.1% /retain1−10.9% were the spurious fulls
moving, not the band.It is also not the large regression it was predicted to become once #7965 was
fixed. On the two load-independent measures the retain cluster is simply
insensitive to it.2. Where it does respond, it costs
cycles12 → 22 collections (+83%),iso_miss43 → 61 (+42%),interp
31 → 44 (+42%),churn/churn_alloc/push_cls86 → 88,pipeline6 → 7.These are cheap minors over a dying nursery, so instructions retired move only
+0.8…+1.5% — but the collection count is a load-independent 40–80% increase,
and total GC pause moved with it on this host (iso_miss47.1 → 61.3 ms,
interp36.3 → 48.0 ms,cycles6.5 → 8.2 ms; host was loaded, so treat the
pause figures as shape and the counts as fact).The direction is the one #7929's own doc comment flags as the risky one, just
mirrored: the doc argues that raising a cap is risky because it changes which
cycle kinds fire. Lowering it on the churn cluster does not change cycle kinds,
it changes cycle frequency, and the corpus rows above are the price.Not folded into PR #7968
#7965's fix does not depend on this, and the two are separable at the census
level — every retain row is identical across the #7929 arm. Re-tuning or
reverting the band belongs here, with this table in hand. Reproduce with
gc-handoff/rg/build_arm.sh+gc-handoff/c0/cycles.py; full working in
gc-handoff/REGRESS-NOTES.md.
Found while measuring #7916/#7714 (PR #7928,
INLINE_SLOT_FLOOR4 → 2).The mismatch
The nursery/promotion budget is denominated in bytes. The collector's per-cycle cost is
dominated by per-object work (copy, forwarding, layout transfer, page registration —
~50–100 ns each). Those two are not the same quantity, and the gap opens exactly when object
representation gets more compact.
The measurement
Same program (
gc-handoff/bench/retain1.ts, 1 M surviving 2-field literals), same commit,one arm with 72-byte objects and one with 56-byte objects. Every minor fires at the same
byte mark and processes the same bytes:
The object ratio is 1.286 = 72/56, exactly.
deeplistreproduces it to three decimals.Consequences on those two rows: GC pause 39.60 → 50.10 ms (+26.5%), and that +10.5 ms
exceeds the whole program's cycle delta (25.2 M cycles ≈ 7.9 ms) — the mutator got faster
and the collector got slower. Cost per extra promoted object: 10.5 ms / 211 691 = 49.6 ns,
i.e. the per-object price did not change at all. Only the population inside a fixed byte
budget did.
Why it matters beyond one PR
Object representation is the current perf direction (#7916 established that the collector is
no longer the binding constraint on the retain cluster; the mutator is). But every byte
removed from an object increases the number of objects the collector handles per unit of its
own budget. A byte-denominated trigger in front of a per-object cost model makes "make
objects smaller" and "make the collector do more work per cycle" the same action, so the
footprint work partially pays for itself in GC time until this is fixed.
What "fixed" would mean
An object-count term alongside the byte term in the nursery/promotion budget, so a cycle's
work — not its bytes — is what is being paced. Adjacent to #7715 (B4 generation bit) and
#7432 (adaptive tenuring young cap, which is the valve reading
eden_live_byteshere).Caveat that keeps this honest
Total promotion work is set by the surviving object count, which does not change — 1 M either
way.
retain1promotes 740 896 of 1 M objects with 72-byte objects and 952 587 with 56-byteones, so the smaller representation pulls ~211 k promotions forward into the measurement
window rather than creating them. A program that runs to completion pays the same total.
That makes the effect largest on exit-bounded microbenchmarks — which is most of the perf
corpus, so it is still worth pacing correctly.
Reproduce:
PERRY_GC_TRACE=1 PERRY_GC_DIAG=1 ./p_retain1on either side of PR #7928 andcompare the
[gc-copy-minor] ran …lines. Full write-up:gc-handoff/REPR-NOTES.md§3d.