Skip to content

perf(gc): live-proportional collection budgets at both generations (#7592) - #7596

Merged
proggeramlug merged 3 commits into
mainfrom
perf/7592-tenured-live-budget
Aug 7, 2026
Merged

perf(gc): live-proportional collection budgets at both generations (#7592)#7596
proggeramlug merged 3 commits into
mainfrom
perf/7592-tenured-live-budget

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Second half of #7592, stacked on #7594. Three changes, one principle: no constant band may pace a collector whose per-cycle cost is O(live) — total work goes quadratic in the live set, and a bigger constant only moves the cliff.

What was left after #7594

With the livelock latched, the 200k/500k trace still spent ~100 % of build_out in GC pause, and two of the six remaining cycles were guaranteed futile:

 #  kind                    trigger      old_before  promoted   freed   pause
 4  full   survivor_promotion_bytes         4.2 MB       0       0.0   1,015 ms   <- handoff over a near-empty old-gen
 6  full              old_gen_bytes       274.5 MB       0       0.0   2,100 ms   <- reclaim of just-promoted live bytes

The changes

1. The survivor-promotion handoff fires on CURRENT old-gen pressure only. It used to fire on old + promotable — a prediction of where old-gen would land after the promotion. But promotable bytes sit in the survivor space, where a full mark-sweep can neither reclaim them (they are live) nor reclaim the old-gen space they have not yet occupied. That is the #7594 mistake in another coat: scheduling a non-moving collection for bytes it cannot affect. The only useful work a handoff can do is clear current old garbage so the promotion lands in reused holes; over 4.2 MB of old-gen it is a 1,015 ms no-op.

2. A copying minor's promoted bytes credit the old-reclaim baseline. Promoted bytes are live by construction — only marked-live objects get copied — so a reclaim fired because promotion crossed a threshold finds them all live and frees nothing. The credit is exactly the promoted delta, never a resync: pre-existing old garbage still counts. The trade is the standard GOGC one — promoted-then-dead bytes now wait for the growth band — and it is the one visible ratchet cost (below).

3. Both pacing bands become live-proportional.

Measurements

Three arms, identically built and linked against a pinned PERRY_RUNTIME_DIR, interleaved, output hash checked every row:

records main #7594 latch this PR vs main RSS (this PR)
25,000 42 ms 42 ms 42 ms 1.0× 81 MB
50,000 546 ms 534 ms 501 ms 1.1× 163 MB
100,000 2,257 ms 1,499 ms 863 ms 2.6× 302 MB
200,000 8,889 ms 3,505 ms 2,617 ms 3.4× 581 MB
500,000 61,589 ms 12,139 ms 5,784 ms 10.6× 1,348 MB

Output hash identical on every row. RSS is +1.7 % over the latch arm at 500k (and −27 % of main's regression budget: main was 1,064 MB but 57 s slower). ns/record is flat within ~30 % across a 20× size range; on main it grows 70×.

The 500k trace after: 4 cycles, none futile — one parse-garbage reclaim (1,045 ms, frees 111 MB), one Eden scavenge, one promotion pass.

GC ratchet — one gated cell moves, and I am flagging it, not hiding it

Both arms measured back to back on one host (the pinned baseline is 0.5.1315 on another machine and cannot separate this change from drift). 144 metric medians across all 12 probes: every semantic counter identical except:

  • 12_large_live_set.heap_total_bytes: 95.4 MB → 130.0 MB (+36 %) — gated, band 2 %, so the official check goes red on this cell.

I attribution-tested it: with only the promoted-bytes credit disabled, the probe is byte-identical to the latch arm — the growth is 100 % the deferred post-promotion reclaim, i.e. the intended GOGC trade. On the same probe, same run: heap_used_bytes +0.02 % (nothing extra retained), peak_rss_bytes −0.7 %, wall_ms −7.4 %, and every copy/promote/cycle counter identical. The growth is reserved-block high-water from collecting less often, not resident memory and not retention.

This PR therefore needs a maintainer decision on that one baseline cell (--update-baseline scoped to it, per the ratchet's own flow). If the reserved high-water is judged unacceptable, the credit (change 2) can be dropped independently — it is one call site — at the cost of reinstating the 2,100 ms futile full at 500k.

Tests

What this does not fix

build_out at 500k is ~23,000 ns/record — flat, but still ~30× off the 764 ns/record a collection-free run shows. The remaining structural cost is the two-hop promotion (Eden→survivor→old copies 268 MB twice; 3.9 s of the 5.1 s). Collapsing it needs promote-on-first-copy within the first copying minor, which is a tenuring-policy design with #7432's determinism constraint — follow-up on #7592.

Summary by CodeRabbit

  • Performance

    • Improved garbage collection pacing by adapting reclaim thresholds to current memory pressure.
    • Adjusted nursery sizing and promotion handling to better balance throughput and memory usage.
    • Delays unnecessary old-generation reclamation after minor collections when appropriate.
  • Bug Fixes

    • Improved reclaim debt calculations and promotion handoff behavior across collection cycles.
  • Tests

    • Added coverage for proportional reclaim thresholds, promotion accounting, boundary conditions, and low-memory scenarios.
  • Documentation

    • Documented performance results, including benchmark improvements and memory impact.

Ralph Küpper added 2 commits August 7, 2026 22:26
…7592)

Three changes, one principle: no constant band may pace a collector whose
per-cycle cost is O(live) -- total work goes quadratic in the live set.

1. The scavenge nursery cap is now also proportional to the TENURED live
   set (old-gen reclaimable pressure / 2), with the influx-driven product
   as the floor. Keyed on old-gen occupancy, NOT total arena in-use: the
   cap gates young_scavenge_cap_due() against from-space occupancy, and a
   cap defined by a total that includes the young generation is a fixed
   point from-space can never cross (measured: scavenging stopped
   entirely, 0 copying minors at 200k records).

2. The old-reclaim growth band is now max(constant, baseline/2) -- Go's
   GOGC shape, shared by old_reclaim_pressure_due and
   gc_old_reclaim_debt_bytes so dueness and debt cannot diverge.

3. Two guaranteed-futile fulls eliminated:
   - The survivor-promotion handoff now fires on CURRENT old-gen pressure
     only. It used to fire on old + promotable -- but promotable bytes sit
     in the survivor space, where a full mark-sweep can neither reclaim
     them nor the old-gen space they have not yet occupied (measured:
     1,015 ms over 4.2 MB of old-gen, 0 freed).
   - A copying minor's promoted bytes are credited to the old-reclaim
     baseline: they are live by construction, so a reclaim fired because
     promotion crossed a threshold finds them all live and frees nothing
     (measured: 2,100 ms over 274 MB just-promoted, 0.0 MB freed).

json_pipeline build_out, 500k records: 57.9 s (main) / 10.6 s (#7594
latch alone) -> 5.1 s, 11.3x vs main, and ns/record is flat within 21%
across a 5x size range (main: 11x growth). Output hash identical on
every row; RSS +1.7% over the latch arm.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c521a953-57bf-4b3a-80ad-90fb3d8f95f0

📥 Commits

Reviewing files that changed from the base of the PR and between 22dd8ea and e29945b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

📝 Walkthrough

Walkthrough

The GC now uses proportional old-generation pressure for reclaim pacing, promotion handoff, and nursery sizing. Copying minor collections credit promoted bytes to the old-reclaim baseline. Trigger tests cover thresholds, debt boundaries, and promotion crediting. The package version is updated to 0.5.1339.

Changes

GC reclaim pacing

Layer / File(s) Summary
Proportional nursery cap
crates/perry-runtime/src/gc/tenuring.rs
The effective nursery cap uses the larger of influx-scaled capacity and reclaimable old-generation pressure divided by TENURED_EDEN_DIVISOR.
Proportional reclaim decisions
crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/tests/triggers.rs, changelog.d/7596-live-proportional-gc-budgets.md, Cargo.toml, CLAUDE.md
Old-generation reclaim checks and debt calculations use a shared proportional growth band. Promotion handoff checks use current old-generation pressure. Tests and the changelog cover the updated behavior. The package version changes from 0.5.1338 to 0.5.1339.
Promotion baseline accounting
crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/copying.rs
Copied-minor promotions increase the old-reclaim baseline before reclaim scheduling, without resynchronizing it to current usage.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

  • PerryTS/perry issue 6181 — The issue concerns promotion-handoff logic in gc/policy.rs, which this change also modifies.

Possibly related PRs

  • PerryTS/perry#7443 — Both modify old-generation reclaim-pressure logic in gc/policy.rs.
  • PerryTS/perry#7594 — Both modify survivor-promotion handoff behavior and related GC trigger tests.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: performance improvements from live-proportional GC budgets at both generations.
Description check ✅ Passed The description covers the purpose, concrete changes, benchmarks, tests, known baseline impact, and follow-up, although it omits some template headings and checklist items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7592-tenured-live-budget

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/tenuring.rs`:
- Around line 131-135: Add PR-run tests that directly exercise
scavenge_nursery_cap_effective_bytes, covering both outcomes: influx_driven
being the maximum and old_gen_reclaimable_pressure_bytes() /
TENURED_EDEN_DIVISOR being the maximum. Configure the relevant scale and
reclaimable-pressure inputs for each case, then assert the effective cap matches
the winning branch.
- Around line 138-142: Correct the documentation comment above the tenuring
nursery cap divisor to describe `tenured / 2` only as the tenured-proportional
term. Remove the claim that young-generation RSS is bounded by that value, and
avoid asserting logarithmic collection behavior as a guaranteed consequence when
`scavenge_nursery_cap_effective_bytes` may select the larger `influx_driven`
cap.
🪄 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: 2ba981cb-538b-4f4e-9f15-fa3cd0eb2fa6

📥 Commits

Reviewing files that changed from the base of the PR and between 08940c8 and 22dd8ea.

📒 Files selected for processing (5)
  • changelog.d/7596-live-proportional-gc-budgets.md
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tenuring.rs
  • crates/perry-runtime/src/gc/tests/triggers.rs

Comment on lines 131 to +135
pub(super) fn scavenge_nursery_cap_effective_bytes() -> usize {
gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize)
let influx_driven =
gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize);
let tenured_proportional = old_gen_reclaimable_pressure_bytes() / TENURED_EDEN_DIVISOR;
influx_driven.max(tenured_proportional)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add direct coverage for both effective-cap branches.

The supplied trigger tests do not call scavenge_nursery_cap_effective_bytes. Add PR-run tests where influx_driven wins and where old_gen_reclaimable_pressure_bytes() / 2 wins. This protects the new aggregation and pressure source.

As per coding guidelines, “Put acceptance coverage in tests that run on PRs when possible.”

🤖 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 131 - 135, Add PR-run
tests that directly exercise scavenge_nursery_cap_effective_bytes, covering both
outcomes: influx_driven being the maximum and
old_gen_reclaimable_pressure_bytes() / TENURED_EDEN_DIVISOR being the maximum.
Configure the relevant scale and reclaimable-pressure inputs for each case, then
assert the effective cap matches the winning branch.

Source: Coding guidelines

Comment on lines +138 to +142
/// #7592: divisor for the tenured-proportional nursery cap — Eden may grow to
/// half the tenured live set before a scavenge is forced. Peak young-gen RSS
/// contribution is therefore bounded at `tenured / 2`; the young collection
/// count is logarithmic in heap growth on promote-heavy workloads
/// (`old_{n+1} ≈ old_n × (1 + 1/2)`) instead of linear in bytes allocated.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the RSS-bound claim.

scavenge_nursery_cap_effective_bytes returns max(influx_driven, tenured_proportional). If influx_driven is larger, the cap can exceed tenured / 2. Describe tenured / 2 as the proportional term, not as an upper bound on young-generation RSS.

🤖 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 138 - 142, Correct the
documentation comment above the tenuring nursery cap divisor to describe
`tenured / 2` only as the tenured-proportional term. Remove the claim that
young-generation RSS is bounded by that value, and avoid asserting logarithmic
collection behavior as a guaranteed consequence when
`scavenge_nursery_cap_effective_bytes` may select the larger `influx_driven`
cap.

@proggeramlug
proggeramlug merged commit 72fa5b4 into main Aug 7, 2026
@proggeramlug
proggeramlug deleted the perf/7592-tenured-live-budget branch August 7, 2026 20:41
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified end to end, merged as v0.5.1339

Three-arm census reproduced on my own builds (same fixture, same trace
parser, interleaved, hash-checked — 200k records, dev profile):

arm cycles total pause futile fulls wall RSS
main (pre-#7594) 23 10,159 ms 20 × survivor_promotion ~12 s 476 MB
#7594 latch 6 3,270 ms 1 × survivor_promotion (391 ms) + post-promotion reclaim 5 s 572 MB
#7596 4 1,854 ms 0 — the old_gen_bytes cycle is 1 ms 4 s 582 MB

Both futile classes are gone: the handoff-over-empty-old-gen AND the
just-promoted reclaim. Output hash identical across all three arms. RSS +1.7 %
over the latch arm — exactly as disclosed.

Both tested changes survive my sabotage:

The design reasoning holds up under adversarial reading. The credit is a
delta, never a resync, so pre-existing old garbage keeps counting; a baseline
left above in-use can only persist until the next reclaim, which resets it —
in-use only drops at a reclaim, so there is no stuck state. The proportional
band's #7437 interaction is genuinely better than the constant band (a futile
reclaim inflates the next band instead of re-firing every constant step). And
the nursery cap being keyed off old-gen rather than total arena is the
load-bearing detail — the PR measured the self-referential variant failing (0
copying minors) rather than reasoning about it, which is the standard this
repo's gate history demands.

Maintainer decision on the gated ratchet cell, recorded here:
12_large_live_set.heap_total_bytes +36 % (95.4 → 130.0 MB) is accepted.
The attribution test is what earns it: with only the credit disabled the probe
is byte-identical to the latch arm, so the growth is 100 % the intended GOGC
trade — reserved-block high-water from collecting less often. On the same probe
heap_used is +0.02 % (no extra retention), peak RSS −0.7 % and wall
−7.4 %. Resident memory is the plan's goal; reserved high-water is the price of
paying O(live) less often, and the credit is one call site to drop if this
proves wrong in practice. The pinned baseline (0.5.1315, other host) should be
regenerated on the mini as part of the #7554 repair rather than patched
per-cell from a dev-Mac run.

Two follow-ups, both already assigned

  1. Change 3 has no test that can see it. The existing
    scavenge_nursery_cap_effective_bytes tests (tenuring.rs:418–432) do pin
    the influx floor — so perf(gc): nursery cap + scavenge on by default — peak RSS -69% #7377's small-live-set guarantee is regression-tested
    and passes on this arm, which is the safety-critical half — but with an
    empty test heap max(influx, old_reclaimable/2) degenerates and the
    proportional term is invisible to them. I sabotaged the term out and nothing
    asserts its presence. Assigned to a running agent along with the crossover
    case.
  2. The PR body promised the gap-suite result as a comment and none was posted.
    My own targeted verification (three-arm hash-identical output + full runtime
    suite 1,844/0 + all four lint gates) stands in for it; if the author's sweep
    surfaces anything, it lands on perf: json_pipeline at 500k records is 97.6x bun (60.4s vs 618ms) while the same workload at 100 records BEATS bun — a scaling cliff, not a constant factor #7592.

#7592 stays open — 5.8 s vs bun's 618 ms is still ~9×. The named remainder
is the two-hop promotion (Eden→survivor→old copies 268 MB twice, 3.9 s of the
5.1 s), and the promote-on-first-copy design note is assigned. The stringify
(1,451 ms) and fnv1a (1,250 ms) phases are also now first-order and have an
agent on them.

proggeramlug added a commit that referenced this pull request Aug 7, 2026
…mic bitwise helper (#7592) (#7601)

Two defects: charCodeAt was not statically Number (is_numeric_expr had no String-method arm, so integer xor went through the BigInt-aware dynamic helper), and there was no inline path (four opaque FFI calls per character). Adds the String-method arm (charCodeAt/indexOf/lastIndexOf/search/localeCompare; codePointAt deliberately excluded) and an inline ASCII fast path riding PERRY_STATIC_STRING_LOWERING, falling back to the same calls otherwise. fnv1a 11.2x (17.72 -> 1.58 ns/char), RSS unchanged. Also adds the missing test for #7596's tenured-proportional nursery cap. #7592 stays open.
proggeramlug pushed a commit that referenced this pull request Aug 7, 2026
…gate (#7554)

An artifact defect used to abort validation on the first problem, and the
artifact-validation step runs BEFORE the measurement step. So one cell —
12_large_live_set.heap_used_bytes, spread 6,768 bytes — meant none of the
twelve probes executed on any branch for three days, while two GC pacing
changes (#7594, #7596) merged with hand-run both-arms A/Bs standing in for
the gate.

The defect was a claim about ONE cell. Nothing about it voided the other 143,
and nothing about it made the probes unrunnable.

Defects now carry a scope. `artifact` (unreadable, tampered, missing metric)
stays fatal and stays in preflight. `probe` (pinned without an oracle diff, or
with no collection) and `cell` (contradicts the bit-identity premise of its own
band) demote their subject out of the gating family and are reported as
failures — so `check` still measures everything, still evaluates the other
cells, and still names a regression elsewhere in the matrix, while the defect
itself keeps the job red.

`validate --scope structural` (what CI preflight now runs) fails only on the
fatal kind. It cannot suppress: `check` re-derives the same list and fails on
it, and a test asserts that coupling per planted defect shape.

`assemble` is unchanged — pin time still refuses any defect outright, so this
cannot be used to freeze a new unfit artifact.
proggeramlug pushed a commit that referenced this pull request Aug 7, 2026
…ost (#7554)

gc-ratchet had not been green on main since 2026-08-01T05:39Z — 179 consecutive
red main runs. The 2026-08-05 window where it could not reach its probes at all
(#7554, fixed by #7557) was an episode inside that, not the whole of it: after
#7557 restored measurement the job stayed red against a 0.5.1280 artifact that
no longer described the collector.

Re-pinned at origin/main 26b9c9d (0.5.1346) on perry-macos — the same Mac mini
and the same rustc/cargo/clang the 2026-08-05 pin used, so this is like-for-like.
All 12 probes oracle-pass; heap_used_bytes spread 0 on eleven and 864 B on
12_large_live_set. Full per-cell attribution is in the artifact's own `notes`.

Three of the four moved groups are explained:
 - 03/04's copy and promote counters collapsing 40–99.8% is #7594 + #7596 doing
   what they said (less futile promotion). Recorded caveat: 03's promoted_* now
   pin at 0, where the allowance floor and the liveness assert both go quiet.
 - 02 +2.77% and 05 +16.44% retention are conservative-scan false roots, not
   retention. `classify` on this host gives 05 precise 5,329,880 — byte-identical
   to what #7571 measured at both ends of its window — and 02 precise 9,416,632,
   BELOW the number this baseline previously recorded. That is #7559's answer,
   reproduced rather than assumed.

The fourth is flagged, not explained: 12_large_live_set.wall_ms 3,056 -> 3,471 ms
(+13.58%), two non-overlapping 7-sample clusters on one host, while 06 and 11 got
9.6% and 28.4% faster. #7596 reported -7.4% on that cell, so by its own evidence
this is not #7596. It is gated on pinned_host only.

#7596's accepted 12_large_live_set.heap_total_bytes +36% did NOT reproduce here
(110,100,480 -> 110,100,480, +0.00%), so nothing was re-pinned for it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant