Skip to content

perf(gc): promote a fully-live young generation without tracing it — retain −33.6%, deeplist −43% - #7888

Merged
proggeramlug merged 6 commits into
mainfrom
perf/7880-untraced-whole-block-promotion
Aug 11, 2026
Merged

perf(gc): promote a fully-live young generation without tracing it — retain −33.6%, deeplist −43%#7888
proggeramlug merged 6 commits into
mainfrom
perf/7880-untraced-whole-block-promotion

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

A copying minor that promotes the whole young generation in place (#7742) still ran the
full trace. It does not any more, when the last measured young-survival ratio says the
nursery is fully live.

Measured per phase with PERRY_GC_TRACE=1 on gc-handoff/bench/retain.ts, per promoting
cycle:

cycle promoted pause_us copying_nursery (the trace) in_place_promotion (the promotion)
1 261 910 16 582 13 762 2 187
2 480 580 34 171 27 773 4 758
3 611 647 42 819 33 991 6 480
4 757 277 64 814 50 690 7 461

The promotion is ~9 ns/object. The trace alongside it is ~55–67 ns/object. The per-object
cost of "whole-block in-place promotion" was never the promotion.

Why it is safe to drop

retag_young_for_in_place_promotion takes every in-use Eden and survivor block, so after
it no address in the heap classifies as Nursery. Therefore, on such a cycle:

  • move_young is unreachable — nothing moves — so every root walk, slot rewrite and
    forwarding repair is a provable no-op rather than an approximation;
  • no remembered-set entry can be created (that is perf(gc): whole-block in-place promotion of a fully-live young generation (#7742) #7744's skip_remembering proof, reused
    verbatim as a precondition here), so clearing the whole remembered set is exact;
  • the address-keyed death-pruning passes (dead_owner::owner_is_dead, the map/set/error
    finalizers) all require the owner to classify as Nursery on a minor, so they already
    prune nothing.

That leaves the marks with two real consumers: the old-gen page index's live filter, and the
survival ratio itself.

  • The first becomes PromotionLiveness::AssumeAllLive — register every parseable object
    on the promoted block. Registering a dead object is safe and is closer to what the index is
    for (it answers "which objects are on this page"); the next full mark-sweep frees and
    unregisters it. The cost is accounting precision, not correctness.
  • The second is bounded rather than assumed away. UNTRACED_PROMOTED_BYTES is charged per
    untraced cycle and capped at max(128 MB, old-gen at the last measurement); exceeding it
    forces the next cycle to trace, which re-arms or disarms. A full collection resets it. The
    dead bytes the last measurement implies are charged to the existing 32 MB
    PROMOTED_DEAD_BUDGET_BYTES — charging zero is not "no garbage", it is "no answer", and it
    would disarm that cap for the whole run.

The path refuses to run when any assumption is not free: a registered weak-target holder
(weak tombstoning reads marks), a non-empty malloc registry (the malloc sweep reads marks),
an incremental mark in progress (an allocate-black birth puts GC_FLAG_MARKED on a nursery
object, and a cycle that neither reads nor clears marks would carry it into old-gen where a
stale mark reads as live), or PERRY_GC_VERIFY_EVACUATION / PERRY_GC_FROMSPACE_SCAN /
PERRY_GC_VERIFY_MARK — each of those instruments takes the trace as its subject, and a
cycle producing no marks would let all three report success having examined nothing.
PERRY_GC_PROMOTE_IN_PLACE=0 still turns the whole family off.

Measured — quiet M1 mini, best-of-5, exit-checked, window quiet at both ends

Both arms built by me from 9ca8b4f71; both corpora 19/19 byte-identical to node + exit 0
before timing (including shapes, which is what caught the last round's silent wrong answer).

bench main this PR delta node
retain 0.2700 0.1792 −33.6% 0.132
retain_wide 0.3744 0.2666 −28.8% 0.157
retain1 0.1104 0.0770 −30.3% 0.085 (beats node)
retain_wide1 0.1338 0.0945 −29.4% 0.089
deeplist 0.1032 0.0588 −43.0% 0.098 (beats node 1.7×)
pipeline 0.2647 0.2734 +3.3%
everything else (13 cells) ±0.6%

ns per promoted objectpause_us ÷ Σpromoted+copied, second token-guarded window).
Handled-object counts are identical to the object in both arms on every benchmark — the
collector does the same amount of promotion, it just stops tracing to decide it:

bench main this PR handled GC pause
retain 67.7 21.9 2 358 760 159.6 ms → 51.7 ms
retain_wide 69.2 26.6 2 943 649 203.6 → 78.2
retain1 75.1 36.7 989 836 74.3 → 36.3
retain_wide1 74.5 30.5 1 028 065 76.6 → 31.3
deeplist 88.8 33.7 990 290 87.9 → 33.4

Peak RSS: no regressionretain −5.1 MB, retain_wide −4.2 MB, retain1 −11.8 MB,
retain_wide1 −12.6 MB, shapes −1.2 MB; deeplist +0.9% and churn +0.4%.

pipeline +3.3% is real (re-measured in the second window) but it runs zero untraced
cycles and has identical handled counts, so the mechanism is not this path — most likely
runtime code layout. It is the only cell outside ±0.6%.

The risk, measured rather than argued

gc-handoff/bench/phase_flip.ts: 1.5 M records retained (which arms the path), then 6 M
churned. Output byte-identical to node on both arms. Peak RSS 263 MB → 370 MB.

That is the designed exposure and its bound working: main disarms on the flip cycle itself
(it measures 174‰); this branch promotes one more nursery untraced and the budget then forces
the measuring cycle that disarms it (measuring 0‰). The cost is that the flip is noticed
one cycle later
, bounded by the floor constant. The floor is a real trade, not a free
parameter: retain's untraced run reaches 97.4 MB, so any floor below ~100 MB costs it a
measuring cycle on its largest nursery and puts retain back at ~0.19.

Tests

  • 11 tests in gc/tests/promote_in_place.rs (4 new). The load-bearing one,
    an_untraced_promotion_indexes_the_objects_it_could_not_prove_live, stores a young child
    into an untraced-promoted parent and forces the NEXT cycle to evacuate — on a promoting
    cycle every young object survives whether or not anything reached it, so without that the
    test would pass against a promotion that indexed nothing. It is sabotage-verified:
    flipping AssumeAllLive => false fails it with copied_objects 0 != 1.
  • cargo test --release -p perry-runtime --lib (RUST_TEST_THREADS=1): 2129 passed, 0 failed.
  • Stress sweep on the programs that actually take the path (retain / retain_wide / deeplist /
    shapes) under PERRY_GC_SCHEDULE_RATE=1 and PERRY_GC_PROTECT_FROMSPACE=1 +DEPTH=800
    neither vetoes it, and 4 untraced cycles still fire under both — plus
    PERRY_GC_VERIFY_EVACUATION=1 and PERRY_GC_FORCE_EVACUATE=1, which DO veto it by design.
    All outputs correct. iso_miss canary checksum 437840 misses 0 with the instrument
    confirmed live (50 [gc-fromspace-protect] lines) — though iso_miss runs zero untraced
    cycles, so it is evidence about the rest of the collector, not about this change.

What this does NOT reach

The brief's targets (retain ≤ 0.16, retain_wide ≤ 0.20) are not met. What is left is
two structural items:

  1. The first copying minor of a process is now the single largest GC cost in retain
    (an evacuation of 247 346 objects, ~24 ms of the remaining 51.7 ms). It cannot promote in
    place because nothing has been measured yet — LAST_YOUNG_SURVIVAL_PERMILLE is None,
    which is a different state from a measured 0 and is asserted as such by
    an_unmeasured_thread_never_promotes. Making it bet would cost every churn-shaped
    program one nursery of retained garbage at startup.
  2. The stamp walk itself, ~10 ns/object (~22 ms of the remaining 51.7 ms). It exists to
    put GC_FLAG_TENURED on every promoted header, which the generated write barrier's fast
    path reads (perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511). Removing it means giving the barrier a page-based generation test
    instead of a header bit — which is what lets V8's page promotion be genuinely O(1).

retain_wide additionally has a mutator half of ~172 ms against node's 157 ms for the whole
program, so retain_wide ≤ 0.20 requires GC ≤ 28 ms — below what any design touching one
header line per object over ~390 MB can reach.

Summary by CodeRabbit

  • New Features

    • Added untraced in-place promotion for eligible whole young generations, reducing unnecessary tracing work.
    • Added safeguards that automatically disable this optimization when runtime conditions require tracing.
    • Added diagnostic counters for untraced promotion cycles and promoted objects.
  • Bug Fixes

    • Preserved object reachability and stable addresses during untraced promotion.
    • Added budget tracking and reset behavior to prevent excessive untraced promotions.
  • Tests

    • Added coverage for thresholds, budgets, statistics, indexing, and end-to-end object retention.

Ralph Küpper added 5 commits August 11, 2026 21:08
A copying minor that promotes the WHOLE young generation in place still ran
the full trace: the remembered-set dirty scan marked every survivor, the drain
re-touched every marked header, and clear_marks touched them a third time —
three passes over a cohort far larger than any cache, on a cycle where nothing
moves and nothing can be freed.

After retag_young_for_in_place_promotion no address in the heap classifies as
Nursery, so move_young is unreachable, no remembered-set entry can be created
(#7744's skip_remembering proof), and the address-keyed death-pruning passes
find nothing by construction. That leaves the marks with two real consumers:
the old-gen page index's live filter, and the survival ratio itself.

So skip the trace when the last MEASURED ratio is in the fully-live regime
(>= 999 permille), registering every object on the promoted block instead of
only the marked ones, and charge the promoted bytes against a budget that
forces a measuring cycle before the assumption can run away.
…n progress

An allocate-black birth puts GC_FLAG_MARKED on a nursery object. A cycle that
neither reads nor clears marks would carry that bit into old-gen, where a stale
mark reads as live to the next full sweep.
…the last measurement

Comparing against the CURRENT figure compares a quantity with itself — the
untraced bytes are old-gen bytes — so the relative half could never fire.
…ment implies

Charging zero is not "no garbage", it is "no answer" — and it disarmed the
32 MB footprint cap for the whole untraced run. Both paths now feed the same
cap; they differ only in whether the dead figure is measured or extrapolated.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 57696cc7-839b-443a-80ac-593909447a3d

📥 Commits

Reviewing files that changed from the base of the PR and between 9ca8b4f and de4b27c.

📒 Files selected for processing (8)
  • changelog.d/7888-untraced-whole-block-promotion.md
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/promote.rs
  • crates/perry-runtime/src/gc/barrier/mod.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/promote_in_place.rs
  • crates/perry-runtime/src/gc/tests/promote_in_place.rs
📝 Walkthrough

Walkthrough

Adds untraced whole-young-generation promotion when the last survival measurement is fully live. The runtime adds budget controls, safety vetoes, all-object indexing, promotion metrics, diagnostic fields, and tests for thresholds, resets, and later evacuation.

Changes

Untraced Promotion

Layer / File(s) Summary
Promotion policy and accounting
crates/perry-runtime/src/gc/promote_in_place.rs
Adds untraced-promotion thresholds, byte budgets, counters, reset behavior, and test helpers.
Promotion liveness and indexing
crates/perry-runtime/src/arena/promote.rs, crates/perry-runtime/src/arena/mod.rs
Adds PromotionLiveness modes. AssumeAllLive indexes every parseable promoted object.
Copying collection integration
crates/perry-runtime/src/gc/barrier/mod.rs, crates/perry-runtime/src/gc/copying.rs, crates/perry-runtime/src/gc/mod.rs
Adds instrumentation vetoes, skips tracing work for eligible cycles, records promotion results, and exports untraced counters.
Policy validation and changelog
crates/perry-runtime/src/gc/tests/promote_in_place.rs, changelog.d/7888-untraced-whole-block-promotion.md
Tests thresholds, budget exhaustion, reset behavior, indexing, and later evacuation. Documents the new behavior and diagnostics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CopyingCollection
  participant PromoteInPlace
  participant PromotionIndex
  participant GCDiagnostics
  CopyingCollection->>PromoteInPlace: check policy and instrumentation
  PromoteInPlace-->>CopyingCollection: select traced or untraced cycle
  CopyingCollection->>PromotionIndex: finalize promotion with liveness mode
  PromotionIndex-->>CopyingCollection: register promoted objects
  CopyingCollection->>PromoteInPlace: record bytes and counters
  CopyingCollection->>GCDiagnostics: emit cycle diagnostics
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7744 — Extends the same whole-block promotion flow with untraced liveness, budgeting, and GC integration.
  • PerryTS/perry#7432 — Modifies promotion behavior through adaptive survivor-based tenuring in the same copying collector.
  • PerryTS/perry#7019 — Modifies young-generation copying and promotion address/index handling.

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main performance change: skipping tracing for fully live young-generation in-place promotion.
Description check ✅ Passed The description thoroughly explains the change, safety conditions, performance results, risks, and test coverage, despite using different section headings than the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7880-untraced-whole-block-promotion

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: 1

🤖 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/tests/promote_in_place.rs`:
- Around line 280-376: Extend
an_untraced_promotion_indexes_the_objects_it_could_not_prove_live with a second
allocation on the promoted block that is not rooted before the first collection.
Verify during the subsequent evacuating collection that this unrooted object was
indexed and remains discoverable, preferably by storing a young child through it
and asserting the child is copied and relocated; keep the existing rooted parent
assertions intact.
🪄 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: 607400c9-074f-4912-9034-0d2e60ee7da4

📥 Commits

Reviewing files that changed from the base of the PR and between 9ca8b4f and de4b27c.

📒 Files selected for processing (8)
  • changelog.d/7888-untraced-whole-block-promotion.md
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/promote.rs
  • crates/perry-runtime/src/gc/barrier/mod.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/promote_in_place.rs
  • crates/perry-runtime/src/gc/tests/promote_in_place.rs

Comment thread crates/perry-runtime/src/gc/tests/promote_in_place.rs
@proggeramlug
proggeramlug merged commit 39771ad into main Aug 11, 2026
11 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/7880-untraced-whole-block-promotion branch August 11, 2026 20:51
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