Skip to content

perf(gc): break the survivor-promotion handoff livelock (#7592) - #7594

Merged
proggeramlug merged 4 commits into
mainfrom
perf/7592-live-proportional-gc-pacing
Aug 7, 2026
Merged

perf(gc): break the survivor-promotion handoff livelock (#7592)#7594
proggeramlug merged 4 commits into
mainfrom
perf/7592-live-proportional-gc-pacing

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes the build_out half of #7592.

The bug is a livelock, and it is not in JSON

#7592 reported json_pipeline at 500k records being 97.6× bun. Splitting the phases first, as the issue insists:

phase time share
readFileSync 89 ms 0.1%
JSON.parse 742 ms 1.2%
build_out 57,409 ms 94.1%
JSON.stringify 1,451 ms 2.4%
fnv1a 1,250 ms 2.0%

JSON.parse handles 107 MB in 742 ms and scales linearly. build_out — the out.push({...}) loop — is ~100 % GC pause (8,840 ms of traced pause against 8,633 ms of phase at 100k) and grows quadratically.

The GC trace says exactly what is happening. At 200k records, of 22 collections:

 19 x full/survivor_promotion_bytes     each freeing 0.0 MB at ~400 ms
  1 x full/old_gen_bytes
  1 x full/arena_bytes
  1 x minor/arena_bytes                 promoted: 0.0 MB

copied_minor_promotion_handoff_due replaces a minor with a full mark-sweep to make room in old-gen for survivors that are about to be promoted. But a full mark-sweep is non-moving — it promotes nothing. So it cannot relieve the pressure it was scheduled for: the survivor space still holds the same 108 MB, the reclaim baseline it resets does not count those bytes, and the predicate is true again at the very next minor. The copying minor that would have done the promotion never gets to run.

That is 7.6 s of an 8.6 s phase spent on collections that free nothing. Peak RSS is the same whether those 19 collections run or not.

Fix

Latch it: one handoff per copying minor. The handoff makes room; the copying minor performs the promotion that consumes it. The latch clears only on a copying minor, because a non-moving minor fallback promotes nothing and would reinstate the livelock at half rate.

The guard sits before the copied_minor_promotable_active_survivor_bytes() walk, so a suppressed handoff also skips that O(n) survivor pass.

Same workload, after:

cycles pause survivor_promotion fulls promoted
main 22 8,782 ms 19 0.0 MB
this PR 6 2,828 ms 1 110.0 MB

Measurements

Both arms built from the same package set and linked against a pinned PERRY_RUNTIME_DIR, interleaved, output hash checked every row:

records main this PR speedup main RSS PR RSS
25,000 48 ms 48 ms 1.0× 81 MB 81 MB
50,000 499 ms 459 ms 1.1× 163 MB 163 MB
100,000 2,095 ms 1,437 ms 1.5× 245 MB 318 MB
200,000 8,654 ms 2,858 ms 3.0× 484 MB 572 MB
500,000 57,242 ms 10,551 ms 5.4× 1,064 MB 1,325 MB

Output hash identical on every row.

The RSS cost is real and I am not hiding it: +17–24 % at the large sizes, because the run now does 6 collections instead of 22. It is a genuine trade, not a free win. It is confined to workloads that actually hit this livelock — see below.

GC ratchet

Checking against the pinned baseline reports 29 regressions, but that baseline is from 0.5.1315 on a different host, so it cannot separate this change from drift. I measured both arms back to back on one host with an identical package set instead — 144 metric medians across all 12 probes:

  • Every semantic counter is identical, except 12_large_live_set.heap_used_bytes at −0.01 % (2,232 B). That cell is the one the harness itself documents as ungated and sample-dependent (conservative-scan residue, observed spread 9,072 B over 36 runs), so 2,232 B is inside its own noise.
  • Everything else that moved is RSS/wall — ungated, and ≤ 0.12 % on RSS.

That is the expected shape: the latch can only fire after a survivor-promotion handoff has occurred, so a workload that never hits the livelock is unaffected.

cargo test -p perry-runtime: 1,843 passed, 0 failed. cargo fmt --check and scripts/check_file_size.sh clean.

Test

test_survivor_promotion_handoff_waits_for_the_copying_minor.

The obvious version of this test cannot fail: with an empty heap copied_minor_promotion_handoff_due returns false at the survivor-occupancy check regardless, so asserting the verdict passes with the latch deleted. The test therefore counts suppressions and asserts the latch branch is what rejected it — and checks that a trigger kind the handoff never applies to is not counted, so the counter cannot pass by incrementing everywhere.

Verified by deleting the latch and watching it go red:

assertion `left == right` failed: the latch branch must be what rejected it (ArenaBytes)

What this does not fix

build_out is still ~28,500 ns/record and not yet flat, so #7592 stays open. With a nursery large enough to avoid collecting during the loop the same phase runs at 764 ns/record, so there is roughly another order of magnitude available. That remainder is the collection-budget question — the cap is a constant (16 MB × NURSERY_CAP_SCALE_MAX), so cadence is independent of live-set size. I tried a live-proportional cap and it is structurally wrong as written: the cap gates from-space occupancy, and here from-space is nearly all of live, so cap = live is a fixed point that stops scavenging entirely. Details are in the issue.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed repeated garbage-collection cycles caused by survivor-promotion pressure.
    • Suppressed redundant full collections until a copying minor collection completes.
    • Preserved expected behavior for direct collection triggers.
  • Performance

    • Reduced unnecessary full collections and related overhead.
    • Improved affected workloads, with a measured RSS tradeoff.
  • Tests

    • Added regression coverage for suppression, counting, and reset behavior.
  • Documentation

    • Documented the fix, performance impact, RSS tradeoff, and validation results.

Ralph Küpper added 3 commits August 7, 2026 15:22
The handoff replaces a minor with a full mark-sweep to make room in
old-gen for survivors about to be promoted. But a full mark-sweep is
non-moving and promotes nothing, so it cannot relieve the pressure it
was scheduled for: the survivor space still holds the same bytes and the
predicate is true again at the next minor.

Measured on json_pipeline at 200k records: 19 consecutive full
collections triggered by survivor_promotion_bytes, each freeing 0.0 MB
at ~400 ms -- 7.6 s of an 8.6 s phase, with peak RSS unchanged whether
those collections ran or not.

Latch it: one handoff per copying minor. The handoff makes room, the
copying minor performs the promotion that consumes it. The latch clears
only on a copying minor, since a non-moving minor fallback promotes
nothing and would reinstate the livelock at half rate.
With an empty heap `copied_minor_promotion_handoff_due` returns false at
the survivor-occupancy check anyway, so a bare assertion on the verdict
passes with the latch deleted -- a test that cannot fail. Count the
suppressions so the test observes the latch branch itself; verified by
removing the latch and watching the test go red.
@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: 574b093d-c08e-4c27-8086-45bc55d49bb7

📥 Commits

Reviewing files that changed from the base of the PR and between 0641e7c and 859695d.

⛔ 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 records survivor-promotion handoffs, suppresses repeated arena- and malloc-triggered handoffs, counts suppressions, and clears the latch after copying-minor completion. A regression test validates the trigger behavior.

Changes

Survivor promotion handoff

Layer / File(s) Summary
Handoff latch and suppression decision
crates/perry-runtime/src/gc/policy.rs
The GC policy tracks whether a handoff full collection awaits a copying minor, counts suppressed handoffs, and suppresses repeated arena- and malloc-triggered handoffs.
Collector handoff and completion wiring
crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/copying.rs, crates/perry-runtime/src/gc/tests/triggers.rs, changelog.d/..., Cargo.toml, CLAUDE.md
The collector arms the latch before a handoff full collection. Copying-minor completion clears it. The regression test validates trigger-specific suppression and latch transitions. The changelog records the fix, and the project version changes to 0.5.1338.

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

Sequence Diagram(s)

sequenceDiagram
  participant GCTriggerPolicy
  participant FullCollection
  participant CopyingMinor
  GCTriggerPolicy->>FullCollection: record survivor-promotion handoff
  FullCollection-->>GCTriggerPolicy: await copying minor
  GCTriggerPolicy-->>GCTriggerPolicy: suppress ArenaBytes and MallocCount handoffs
  CopyingMinor->>GCTriggerPolicy: note_copying_minor_completed()
  GCTriggerPolicy-->>GCTriggerPolicy: clear handoff latch
Loading

Possibly related PRs

  • PerryTS/perry#7020: Uses related GC policy and trigger-test code, but implements a distinct GC policy mechanism.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the GC performance fix for the survivor-promotion handoff livelock.
Description check ✅ Passed The description covers the issue, implementation, measurements, tests, tradeoffs, related issue, and remaining scope, despite omitting some template headings.
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-live-proportional-gc-pacing

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.

@proggeramlug
proggeramlug merged commit 08940c8 into main Aug 7, 2026
@proggeramlug
proggeramlug deleted the perf/7592-live-proportional-gc-pacing branch August 7, 2026 19:12
@proggeramlug

Copy link
Copy Markdown
Contributor Author

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

Reproduced the livelock and the fix independently, own builds of both arms
(same package set, same PERRY_RUNTIME_DIR, PERRY_NO_AUTO_OPTIMIZE=1 both
sides), PERRY_GC_TRACE=1 census on the 200k workload:

main (my run) PR (my run) PR's claim
total cycles 23 6 22 → 6
survivor_promotion fulls 20 1 19 → 1
total pause 10,159 ms 3,271 ms 8,782 → 2,828
promoted by the copying minor 110.0 MB 110.0 MB
wall (perry-dev profile) ~11–12 s 5 s
peak RSS 476 MB 572 MB (+20 %) 484 → 572 (+18 %)
output hash 0a883457 0a883457 identical

The census is the mechanism and it reproduces to the digit — including the
110.0 MB promoted, exactly. The RSS trade is real and matches the disclosed
figure; disclosing it rather than burying it is noted and appreciated.

The diagnosis is structurally sound, verified in the code, not just the
trace.
On main, the handoff branch runs the full and returns early
the minor it displaced never executes (gc/mod.rs:178-184). The full mark-sweep
is non-moving (long-established here: it is why PERRY_GC_FORCE_EVACUATE was
inert for every gc()-driven test, #6942/#6946), and the predicate keys on
copying_active_survivor_in_use_bytes(), which only a copying minor reduces
while the survivors are live. Predicate true → full instead of minor → survivor
bytes unchanged → predicate true again. A genuine livelock.

The wiring is right in the two places that decide correctness:

  • gc_collect_minor_copying_fast_path is a thin wrapper over
    _with_eligibility, so the latch-clear covers both entry points.
  • The single ineligibility bail (return None, copying.rs:1045) sits before
    the latch-clear at the completion tail — so a copying attempt that bails does
    not clear the latch. That is the correct semantics; clearing on a bailed
    attempt would reinstate the livelock at half rate.
  • The predicate's trigger arm (ArenaBytes | MallocCount) confirms the test's
    choice of applicable kinds, and Direct is rejected ahead of the latch —
    which the test asserts via the counter not moving.

Sabotage re-verified myself: deleting the latch branch turns the test red at
the counter assertion with exactly the quoted message. The test's design — count
suppressions rather than assert the verdict, because an empty heap returns
false regardless — is the "assert the subject was live" discipline this repo's
gate history demands, applied correctly.

Gates re-run here: runtime suite 1,843 / 0, addr_class_inventory,
class_id_collisions, raw_handle_debt (998/998), check_file_size.sh,
cargo fmt --check all clean.

One coverage gap, noted not blocking

The test exercises the latch via direct calls to note_copying_minor_completed()
— nothing asserts the wiring, i.e. that the completion tail of the copying
minor actually calls it. If a future refactor drops that call, every handoff
after the first is silently suppressed forever, and no test goes red. The blast
radius is bounded (handoffs only serve copying-minor promotion, and the other
full triggers — old_gen_bytes, arena_bytes — still fire, as my PR-arm census
shows), which is why this is a note rather than a blocker. Same shape as the
coverage gap I flagged on #7584: the helper is tested, the call site is not.

Bookkeeping

Merged with a plain (#7592) reference — the issue stays open. Post-fix the
500k row is ~10.6 s against bun's 618 ms, still ~17×, and the PR's own analysis
points at the remaining order of magnitude: with a nursery large enough to avoid
collecting during the loop the phase runs at 764 ns/record vs ~28,500 now. The
failed live-proportional-cap attempt (cap gates from-space occupancy; here
from-space ≈ live, so cap = live is a fixed point that stops scavenging) is
recorded on the issue, which is exactly where it needs to be so the next attempt
starts past it.

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