fix(gc): denominate the nursery constant band in objects (#7929) - #7961
Conversation
The scavenge nursery trigger compares from-space BYTES against a constant 16 MB band, while the copying minor's cost is per OBJECT. Shrinking a representation therefore silently buys the collector more work per cycle: #7928 took a two-field object literal 72 B -> 56 B and every minor then moved 1.286x (= 72/56) as many objects for the same bytes. Scale the constant band by the mean size of the objects the last copying minor actually moved, so the band buys a constant OBJECT budget. The mean comes from the census the collector already produces, so nothing is added to the allocation fast path. The scaling is one-sided (clamped at 1.0): a mean above the reference keeps today's band. That is what neutralises an array-dominated mean, and it leaves every program at or above the reference bit-identical. The two tenuring ratios are representation-invariant by cancellation, so only the constant band is re-denominated.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe copying minor now reports moved-object census data. Tenuring retains the measured mean object size and scales only the constant nursery-cap band against a 72-byte reference. Unit and integration tests validate scaling, clamping, carry-forward, and proportional-cap isolation. ChangesGC object-denominated nursery capacity
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant CopyingMinor
participant Tenuring
participant NurseryCapacity
CopyingMinor->>Tenuring: report moved bytes and moved objects
Tenuring->>NurseryCapacity: apply measured object-size scale
NurseryCapacity-->>Tenuring: return adjusted nursery capacity
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-runtime/src/gc/copying.rs`:
- Around line 1859-1873: Guard the note_surviving_object_census call in the
surrounding collection flow with !untraced. Do not update the tenuring census
when PromotionLiveness::AssumeAllLive supplies whole promoted-block counts;
preserve the existing measured mean until a traced copying minor runs.
In `@crates/perry-runtime/src/gc/tenuring.rs`:
- Around line 223-283: Make both calculations overflow-safe: update the
constant-band scaling logic to avoid multiplying saturated values directly,
using quotient/remainder arithmetic or an equivalent bounded configuration path;
update nursery_cap_object_scale_permille to return 1000 before multiplying
whenever mean_surviving_object_bytes is at least
NURSERY_CAP_REFERENCE_OBJECT_BYTES. Add boundary tests covering saturated
nursery capacity and mean sizes at and above NURSERY_CAP_REFERENCE_OBJECT_BYTES.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 15bb3754-9a16-4033-80e9-181e2f65524b
📒 Files selected for processing (4)
changelog.d/7961-gc-object-denominated-nursery-band.mdcrates/perry-runtime/src/gc/copying.rscrates/perry-runtime/src/gc/tenuring.rscrates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs
| // #7929: the object denomination of the nursery constant band, fed BEFORE | ||
| // the tenuring loop so every number `retune_after_scavenge` derives from | ||
| // the effective cap (desired survivor occupancy, the cap-scale band) reads | ||
| // one consistent factor. Both tenuring ratios are representation-invariant | ||
| // by cancellation, so this only re-denominates the constant band itself. | ||
| super::tenuring::note_surviving_object_census( | ||
| collector | ||
| .stats | ||
| .copied_bytes | ||
| .saturating_add(collector.stats.promoted_bytes), | ||
| collector | ||
| .stats | ||
| .copied_objects | ||
| .saturating_add(collector.stats.promoted_objects), | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not record an untraced promotion as a moved-object census.
This call runs when untraced is true. That path uses PromotionLiveness::AssumeAllLive and assigns whole promoted-block counts to promoted_objects and promoted_bytes. It does not measure surviving moved objects. The call overwrites the previous measured mean with dead-object-inclusive data and can distort the next nursery cap.
Gate this census update on !untraced so the existing mean carries forward until a traced copying minor measures it.
Proposed fix
- super::tenuring::note_surviving_object_census(
- collector
- .stats
- .copied_bytes
- .saturating_add(collector.stats.promoted_bytes),
- collector
- .stats
- .copied_objects
- .saturating_add(collector.stats.promoted_objects),
- );
+ if !untraced {
+ super::tenuring::note_surviving_object_census(
+ collector
+ .stats
+ .copied_bytes
+ .saturating_add(collector.stats.promoted_bytes),
+ collector
+ .stats
+ .copied_objects
+ .saturating_add(collector.stats.promoted_objects),
+ );
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // #7929: the object denomination of the nursery constant band, fed BEFORE | |
| // the tenuring loop so every number `retune_after_scavenge` derives from | |
| // the effective cap (desired survivor occupancy, the cap-scale band) reads | |
| // one consistent factor. Both tenuring ratios are representation-invariant | |
| // by cancellation, so this only re-denominates the constant band itself. | |
| super::tenuring::note_surviving_object_census( | |
| collector | |
| .stats | |
| .copied_bytes | |
| .saturating_add(collector.stats.promoted_bytes), | |
| collector | |
| .stats | |
| .copied_objects | |
| .saturating_add(collector.stats.promoted_objects), | |
| ); | |
| // #7929: the object denomination of the nursery constant band, fed BEFORE | |
| // the tenuring loop so every number `retune_after_scavenge` derives from | |
| // the effective cap (desired survivor occupancy, the cap-scale band) reads | |
| // one consistent factor. Both tenuring ratios are representation-invariant | |
| // by cancellation, so this only re-denominates the constant band itself. | |
| if !untraced { | |
| super::tenuring::note_surviving_object_census( | |
| collector | |
| .stats | |
| .copied_bytes | |
| .saturating_add(collector.stats.promoted_bytes), | |
| collector | |
| .stats | |
| .copied_objects | |
| .saturating_add(collector.stats.promoted_objects), | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-runtime/src/gc/copying.rs` around lines 1859 - 1873, Guard the
note_surviving_object_census call in the surrounding collection flow with
!untraced. Do not update the tenuring census when
PromotionLiveness::AssumeAllLive supplies whole promoted-block counts; preserve
the existing measured mean until a traced copying minor runs.
| let constant_band = | ||
| gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize); | ||
| constant_band.saturating_mul(nursery_cap_object_scale_permille( | ||
| mean_surviving_object_bytes(), | ||
| )) / 1000 | ||
| } | ||
|
|
||
| /// #7929: the mean size of the objects the last copying minor actually moved, | ||
| /// in bytes — `(copied + promoted) bytes / (copied + promoted) objects`. | ||
| /// | ||
| /// Seeded at [`NURSERY_CAP_REFERENCE_OBJECT_BYTES`] so a process that has never | ||
| /// completed a copying minor paces bit-identically to the pre-#7929 collector. | ||
| pub(super) fn mean_surviving_object_bytes() -> usize { | ||
| MEAN_SURVIVING_OBJECT_BYTES.with(Cell::get) | ||
| } | ||
|
|
||
| /// Mean surviving object size the 16 MB constant band was calibrated against. | ||
| /// | ||
| /// #7056 (the cap) and #7377/#7592 (its scale ladder) were measured on a heap | ||
| /// whose two-field object literal was **72 bytes**; #7928 right-sized that to | ||
| /// 56. The constant is the *calibration anchor*, not a claim about any current | ||
| /// representation — moving it re-tunes the cap for every program at once. | ||
| pub(super) const NURSERY_CAP_REFERENCE_OBJECT_BYTES: usize = 72; | ||
| /// Floor for the object-denomination factor (per mille). At the corpus's | ||
| /// smallest measured mean (32.8 B on `tree_wide`) the unclamped factor is 456; | ||
| /// the floor bounds how far a shrinking representation may pull the cap down, | ||
| /// so the collection *count* cannot run away on a workload whose survivors are | ||
| /// atypically small. | ||
| const NURSERY_CAP_OBJECT_SCALE_MIN_PERMILLE: usize = 500; | ||
|
|
||
| /// #7929: how much of the byte-denominated constant band this representation | ||
| /// should get, in per mille, so the band buys a **constant number of objects**. | ||
| /// | ||
| /// The collector's trigger is denominated in bytes and its per-cycle cost is | ||
| /// per object, so a fixed byte band silently buys more collector work as | ||
| /// objects shrink: #7928 took a two-field object 72 B → 56 B and every minor | ||
| /// then moved 1.286× (= 72/56) as many objects for the same bytes, costing | ||
| /// `deeplist` +10.5% and `retain1` +8.8% wall. Scaling the band by | ||
| /// `mean / reference` restores `band / mean` — the object budget — to what the | ||
| /// band was calibrated to buy. | ||
| /// | ||
| /// **Deliberately one-sided.** The factor is clamped at 1000, so a | ||
| /// representation *larger* than the reference gets the unchanged band rather | ||
| /// than a proportionally larger one. Two reasons, both measured: | ||
| /// | ||
| /// * The byte-weighted mean is not a representative object size on a workload | ||
| /// whose survivors are dominated by large allocations — `push_num`'s mean is | ||
| /// **3600 B** because it survives arrays, not objects (the documented blocker | ||
| /// on #7929). One-sided clamping turns that from a ×50 cap explosion into | ||
| /// exactly today's behaviour. | ||
| /// * The corpus response to the cap is threshold-dominated (which cycle *kinds* | ||
| /// fire), so *raising* a cap is the risky direction: it is how a program | ||
| /// crosses `GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES` or lands in a #7909 budgeted | ||
| /// stall. Every program at or above the reference — `retain_wide`, | ||
| /// `retain_wide1`, `push_num`, `shapes` — is left bit-identical. | ||
| pub(super) fn nursery_cap_object_scale_permille(mean_surviving_object_bytes: usize) -> usize { | ||
| if mean_surviving_object_bytes == 0 { | ||
| return 1000; | ||
| } | ||
| (mean_surviving_object_bytes * 1000 / NURSERY_CAP_REFERENCE_OBJECT_BYTES) | ||
| .clamp(NURSERY_CAP_OBJECT_SCALE_MIN_PERMILLE, 1000) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Identify repository-declared 32-bit targets or CI jobs where this arithmetic
# can overflow at practical configured nursery sizes.
rg -n --hidden \
-g '!target/**' \
-g 'Cargo.toml' \
-g '*.yml' \
-g '*.yaml' \
-g '*.toml' \
'i686|i586|armv7|32-bit|--target' . || trueRepository: PerryTS/perry
Length of output: 7175
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- tenuring.rs relevant definitions and tests ---'
sed -n '140,330p' crates/perry-runtime/src/gc/tenuring.rs
sed -n '540,720p' crates/perry-runtime/src/gc/tenuring.rs
printf '%s\n' '--- nursery-cap configuration and arithmetic call sites ---'
rg -n -C 5 \
'gc_scavenge_nursery_cap_bytes|PERRY_GC_SCAVENGE_NURSERY_MB|NURSERY_CAP_SCALE|MEAN_SURVIVING_OBJECT_BYTES|mean_surviving_object_bytes|nursery_cap_object_scale_permille' \
crates/perry-runtime
printf '%s\n' '--- target and platform assumptions ---'
rg -n --hidden \
-g '!target/**' \
-g '*.rs' \
-g 'Cargo.toml' \
'usize::MAX|target_pointer_width|PERRY_GC_SCAVENGE_NURSERY_MB|SCAVENGE_NURSERY' \
crates/perry-runtimeRepository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- exact nursery-cap parser ---'
sed -n '200,228p' crates/perry-runtime/src/gc/policy.rs
printf '%s\n' '--- target pointer-width declarations ---'
rg -n --hidden \
-g '!target/**' \
-g '*.json' \
-g '*.toml' \
-g '*.yml' \
-g '*.yaml' \
'arm64_32|watchos|visionos|target_pointer_width|32-bit pointers|ILP32' \
. | head -120
printf '%s\n' '--- tracked diff summary ---'
git diff --stat -- crates/perry-runtime/src/gc/tenuring.rs crates/perry-runtime/src/gc/policy.rs
printf '%s\n' '--- standalone arithmetic probe ---'
python3 - <<'PY'
def scale(mean, bits):
max_usize = (1 << bits) - 1
if mean == 0:
return 1000
# Rust usize multiplication in release mode wraps.
wrapped = (mean * 1000) & max_usize
return max(500, min(1000, wrapped // 72))
def scale_intended(mean):
if mean == 0 or mean >= 72:
return 1000
return max(500, (mean * 1000) // 72)
for bits in (32, 64):
limit = (1 << bits) - 1
print(f'{bits}-bit:')
for mean in (0, 8, 56, 71, 72, 104, 3_600,
limit // 1000, limit // 1000 + 1, limit):
print(mean, scale(mean, bits), scale_intended(mean))
PYRepository: PerryTS/perry
Length of output: 7669
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- exact nursery-cap parser ---'
sed -n '200,228p' crates/perry-runtime/src/gc/policy.rs
printf '%s\n' '--- target pointer-width declarations ---'
rg -n --hidden \
-g '!target/**' \
-g '*.json' \
-g '*.toml' \
-g '*.yml' \
-g '*.yaml' \
'arm64_32|watchos|visionos|target_pointer_width|32-bit pointers|ILP32' \
. | head -120
printf '%s\n' '--- tracked diff summary ---'
git diff --stat -- crates/perry-runtime/src/gc/tenuring.rs crates/perry-runtime/src/gc/policy.rs
printf '%s\n' '--- standalone arithmetic probe ---'
python3 - <<'PY'
def scale(mean, bits):
max_usize = (1 << bits) - 1
if mean == 0:
return 1000
wrapped = (mean * 1000) & max_usize
return max(500, min(1000, wrapped // 72))
def scale_intended(mean):
if mean == 0 or mean >= 72:
return 1000
return max(500, (mean * 1000) // 72)
for bits in (32, 64):
limit = (1 << bits) - 1
print(f'{bits}-bit:')
for mean in (0, 8, 56, 71, 72, 104, 3_600,
limit // 1000, limit // 1000 + 1, limit):
print(mean, scale(mean, bits), scale_intended(mean))
PYRepository: PerryTS/perry
Length of output: 7669
Make both permille calculations overflow-safe.
PERRY_GC_SCAVENGE_NURSERY_MB accepts any positive usize. On 64-bit targets, a saturated constant_band can overflow the current u64 multiplication, causing a debug panic or an incorrect release cap. Use quotient/remainder arithmetic or bound the configuration.
nursery_cap_object_scale_permille also multiplies mean_surviving_object_bytes before checking its upper range. Check mean_surviving_object_bytes >= NURSERY_CAP_REFERENCE_OBJECT_BYTES first. Add boundary tests for both cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-runtime/src/gc/tenuring.rs` around lines 223 - 283, Make both
calculations overflow-safe: update the constant-band scaling logic to avoid
multiplying saturated values directly, using quotient/remainder arithmetic or an
equivalent bounded configuration path; update nursery_cap_object_scale_permille
to return 1000 before multiplying whenever mean_surviving_object_bytes is at
least NURSERY_CAP_REFERENCE_OBJECT_BYTES. Add boundary tests covering saturated
nursery capacity and mean sizes at and above NURSERY_CAP_REFERENCE_OBJECT_BYTES.
|
| 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 MBbaseline (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.
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.
Closes #7929.
The mismatch
The scavenge nursery trigger compares from-space bytes against a constant
16 MB band (
young_scavenge_cap_due), while the copying minor's cost is perobject. Shrinking a representation therefore silently buys the collector more
work per cycle. #7928 took a two-field object literal 72 B → 56 B and every minor
then moved 1.286× (= 72/56) as many objects for the same bytes, costing
deeplist+10.5% andretain1+8.8% wall on the quiet mini.This scales the constant band by the mean size of the objects the last copying
minor actually moved, so the band buys a constant object budget.
Why this is a single comparison
Of the four byte-denominated valves, the two
tenuring.rsratios arerepresentation-invariant by cancellation —
desired_survivor_bytesiscap/16and
retune_nursery_cap_scalecompares eden againstcap/25, both of which scalewith the object size on both sides. The tenured-proportional arm of the cap is
invariant for the same reason:
tenured_bytes / 2istenured_objects / 2objects. Only the constant band converts a fixed byte budget into a variable
object count, so only the constant band is re-denominated here.
The measurement that unblocked it
#7929's recorded blocker was that an object term fires the collector earlier and
every extra cycle pays a fixed root scan. Both sides are now priced, on the shipped
corpus, by sweeping the existing
PERRY_GC_SCAVENGE_NURSERY_MBdial (instructionsretired; the dev box ran at load 77–92, so no wall clock is quoted):
deeplistcellswith an identical cycle-kind profile (2 minors, 0 fulls) — 805 137 objects at
1203 M, 954 929 at 1366 M, 1 254 513 at 1669 M — give slopes of 1088 and 1011
instr/object.
churnat caps8/16/32 MB runs 172/86/43 minors for 3205.7/3093.3/3078.0 M instructions;
netting out the object term leaves ~0.49 M per cycle. That is consistent with
gc: asyncpipe collects at 1200-1650 ns/object, including a 122 ms minor that handled zero objects #7915's own number (218 455 pointer roots at ~2 instructions each).
So an extra minor pays for itself if it avoids ≥ ~500 object-moves, and on the
taxed programs the trade is ~500:1 in favour. On
cyclesandpipelinemorecycles is outright cheaper (1763.6 M at 23 minors vs 1837.6 M at 6).
One-sided by design
The factor is clamped at 1.0, so a representation larger than the reference gets
today's band rather than a proportionally larger one. Two measured reasons:
dominated by large allocations —
push_num's mean is 3600 B because itsurvives arrays. That was the documented blocker on perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back #7929; one-sided clamping
turns a ×50 cap explosion into exactly today's behaviour.
GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES(48 MB) or lands in a gc: incremental old-gen work costs 14% on a program that never collects (asyncpipe, zero GC cycles) #7909 budgeted stall.Every program at or above the reference is left bit-identical, which is also what
makes the A/B below self-calibrating.
A/B — per program, both arms, cycle kinds included
origin/main@ac52a5c38vs this branch, same target dir, same-pset,PERRY_RUNTIME_DIRpinned,.amtimes verified to have moved. All 19 programsbyte-identical to the base arm and to
node --experimental-strip-types26.5.1,exit 0.
Italic rows are the control set: their factor is exactly 1.000 (clamped, or no
copying minor ever ran), so their policy is bit-identical and their spread —
−0.3% to −3.5% — is this A/B's link-layout noise floor, not an effect.
interp+0.2% andiso_miss+0.4% sit inside it; the three large deltas do not.retain1is the clean cell: identical cycle kinds in both arms, −11.8% objectwork for −11.3% instructions, against the +12.0% instructions #7928 cost it.
deeplist's andretain's larger wins are a threshold crossing on top of theobject effect: both were running an
old_gen_bytes-triggeredfullwhosesweep.freed_bytesis 0 — a futile mark-sweep over a heap that is live byconstruction — and the smaller band keeps old-gen under the 48 MB one-shot arm of
old_reclaim_pressure_due. Isolating that (interpolatingdeeplist's 3-minorcells) leaves the object-denomination effect alone at −10.5%, which is exactly
the +10.5% regression #7928 caused on that program.
Gate
Four tests. The two pure-function tests still pass with the
copying.rscall sitedeleted — the #7024 shape — so the wiring is covered separately by a band-level
test and by
copying_minor_feeds_the_object_denomination_census, which drives areal copying minor.
★ The discriminating quantity in that test is "the recorded mean equals THIS
cycle's measured mean AND differs from the seed". A "the mean is nonzero"
assertion is satisfied by the seed itself, so an unwired build would pass it — a
presence check, not a proof. The test also refuses to run vacuously: it asserts
the fixture's mean is strictly below the reference, so a future representation
change that made the fixture 72 B fails the test rather than silently exercising
the clamp instead of the scaling arm.
Both sabotage arms were run, not argued:
note_surviving_object_censuscall incopying.rsthe census must carry THIS cycle's measured mean (19040 B over 340 objects))influx_driven_nursery_cap_bytesconstant_band_buys_a_constant_object_countadditionally carries an inlinesabotage arm: it asserts the un-denominated band inflates the object budget by
≥1250 permille, so if that ever reads 1000 the test declares its own main
assertion meaningless instead of passing quietly.
Validation
again under
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1.iso_misscanary:checksum 437840 misses 0.cargo test --release -p perry-runtime: 2216 passed, 0 failed.cargo fmt --all -- --check,scripts/check_file_size.sh,scripts/check_gc_doc_claims.py(17 facts re-derived),scripts/gc_runtime_root_holders.py: all clean.PERRY_GC_DIAGpath. Nothing is added to the allocation fast path — the meancomes from the census the collector already produces.
Full working:
gc-handoff/BUDGET-NOTES.md.Summary by CodeRabbit
New Features
Bug Fixes
Tests
Documentation