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
perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back #7929
Found while measuring #7916/#7714 (PR #7928, INLINE_SLOT_FLOOR 4 → 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:
minor
bytes, 72 B objects → 56 B objects
objects, 72 B → 56 B
1 (copy)
17 694 064 → 17 694 216
245 752 → 315 969
2 (promote)
18 742 672 → 18 742 816
260 316 → 334 694
3 (promote)
34 601 760 → 34 602 008
480 580 → 617 893
The object ratio is 1.286 = 72/56, exactly. deeplist reproduces 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_bytes here).
Caveat that keeps this honest
Total promotion work is set by the surviving object count, which does not change — 1 M either
way. retain1 promotes 740 896 of 1 M objects with 72-byte objects and 952 587 with 56-byte
ones, 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_retain1 on either side of PR #7928 and
compare the [gc-copy-minor] ran … lines. Full write-up: gc-handoff/REPR-NOTES.md §3d.
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.
★ retain is a live example of the counter trap: copied_objects = 245 752
against promoted_objects = 2 109 820. Reading copied_objects alone 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_due
from_space_bytes >= cap_bytes
NO — this is the mismatch
tenuring.rs:243desired_survivor_bytes
cap / 16
n/a (it sizes a space)
tenuring.rs:247compute_target_survivals
1 + desired_bytes / eden_live_bytes
YES, by cancellation
tenuring.rs:334retune_nursery_cap_scale
eden_live_bytes ≷ cap/25, cap/100
YES, by cancellation
Both tenuring.rs ratios compare a byte quantity against another byte quantity
derived from the same cap, so uniformly scaling every object cancels out: (cap/16)/eden_bytes does 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 on asyncpipe, 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.
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, or push_num trips it continuously.
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 at 14e291f3a:
bench
before
after
delta
deeplist
0.057
0.063
+10.5%
retain1
0.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 and retain_wide to 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.rs ratios are invariant by cancellation and only young_scavenge_cap_due is 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 cut asyncpipe's young survival 770‰ → 24‰, which changes that calculus.
⚠️ 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, so deeplist/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 retain1 and retain.
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. On retain1:
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's all array — a large object born straight
into old-gen, whose growth never passes through promotion and so never credits
the baseline — then pushes old_in_use past 48 MB and the latch fires. The full
reports freed_bytes = 8 257 632, i.e. the array's dead backing stores: a
whole-heap collection to reclaim 8 MB.
deeplist has 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.
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 measured young_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.rs ratios 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 in REPR-NOTES.md §3d are entangled with cycle-kind
changes and should not be quoted as per-object work.
#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 (main was carrying spurious old_gen_bytes full 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, only nursery_cap_object_scale_permille forced
to 1000 in the off arm. 19/19 byte-exact + exit 0 in both. Cycle counts and
instructions retired are load-independent.
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 reported retain −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.
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_miss 47.1 → 61.3 ms, interp 36.3 → 48.0 ms, cycles 6.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.
#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.
Activity
proggeramlug commentedon 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] ranas
(copied_bytes + promoted_bytes) / (copied_objects + promoted_objects),origin/main@b8c1f103e, all 19 programs byte-exact and exit 0: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 752against
promoted_objects = 2 109 820. Readingcopied_objectsalone reportsthat 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:
policy.rs:107young_scavenge_cap_duefrom_space_bytes >= cap_bytestenuring.rs:243desired_survivor_bytescap / 16tenuring.rs:247compute_target_survivals1 + desired_bytes / eden_live_bytestenuring.rs:334retune_nursery_cap_scaleeden_live_bytes ≷ cap/25, cap/100Both
tenuring.rsratios compare a byte quantity against another byte quantityderived from the same cap, so uniformly scaling every object cancels out:
(cap/16)/eden_bytesdoes not change. Only the trigger genuinely converts afixed 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 collectorearlier 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 on
asyncpipe, and the #7926re-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:
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 hasto be defined over the same population the collector charges per-item work
for, or
push_numtrips it continuously.Full working:
gc-handoff/PACING-NOTES.md§4.proggeramlug commentedon Aug 12, 2026
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:deeplistretain1Both 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.fix(gc): denominate the nursery constant band in objects (#7929) (#7961)
proggeramlug commentedon Aug 12, 2026
#7961 merged (
109de930f) while its validation was still running. The validationfinished 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 inthat issue to #7944 / #7902 — untraced promotion stopped crediting the
old-reclaim baseline, so
deeplist/retain*each gained a futile fullmark-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:
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:baseline(GC_LAST_OLD_RECLAIM_IN_USE_BYTES) is a running sum of promotiondeltas, so whether it ends up above or below T is decided by promotion step
size, not by the heap. On
retain1: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 straightinto old-gen, whose growth never passes through promotion and so never credits
the baseline — then pushes
old_in_usepast 48 MB and the latch fires. The fullreports
freed_bytes = 8 257 632, i.e. the array's dead backing stores: awhole-heap collection to reclaim 8 MB.
deeplisthas no such old-born array, which is why it is the one program in thetable 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
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 as
retain1−10.9%,retain−63.1%,
deeplist−72.3% and is a net win today. Reverting now would makemain worse.
baseline is credited correctly,
retain1/retainflip from −11 %/−63 % to+158 %/+78 %. That is a landmine, and it is armed.
credit old-gen growth to the baseline regardless of how it arrived, or refuse
to schedule a reclaim when the last cycle measured
young_survival_permille = 1000(it was 1000 on every cycle in this table, i.e. the collector had justfinished proving the reclaim cannot free anything).
What stands from the PR
tenuring.rsratios and the tenured-proportional arm are invariant bycancellation).
push_num's 3600 B array-dominatedmean is neutralised, and the 5 factor-1.000 programs calibrate the A/B's
link-layout noise floor at −0.3 %…−3.5 %.
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.
~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 % figuresin the PR body and in
REPR-NOTES.md§3d are entangled with cycle-kindchanges and should not be quoted as per-object work.
Full working:
gc-handoff/BUDGET-NOTES.md§8.proggeramlug commentedon Aug 12, 2026
#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 spuriousold_gen_bytesfull mark-sweeps on the retain cluster at the time), so itsheadline 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, only
nursery_cap_object_scale_permilleforcedto
1000in the off arm. 19/19 byte-exact + exit 0 in both. Cycle counts andinstructions retired are load-independent.
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 reported
retain−63.1% /retain1−10.9% were the spurious fullsmoving, 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%),interp31 → 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 thepause 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 ingc-handoff/REGRESS-NOTES.md.