Skip to content

perf(gc): predict the futile full mark-sweep instead of pricing it afterwards (retain 3.99x -> 2.54x node, retain_wide 7.01x -> 2.89x) - #7799

Draft
proggeramlug wants to merge 8 commits into
mainfrom
perf/retain-survival-adaptive-pacing
Draft

perf(gc): predict the futile full mark-sweep instead of pricing it afterwards (retain 3.99x -> 2.54x node, retain_wide 7.01x -> 2.89x)#7799
proggeramlug wants to merge 8 commits into
mainfrom
perf/retain-survival-adaptive-pacing

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes the largest remaining deficit on the retain family. On the quiet M1
mini, best-of-5, against origin/main @ 0a2bf15bd:

bench main this PR node was P/node now P/node
retain 0.542 0.345 0.136 3.99 2.54
retain1 0.299 0.134 0.084 3.54 1.60
retain_wide 1.099 0.454 0.157 7.01 2.89
retain_wide1 0.276 0.157 0.090 3.08 1.74
deeplist (not targeted) 0.245 0.123 0.098 2.51 1.26

Peak RSS fell on all of them (retain 332.4 → 331.3 MB, retain_wide
466.4 → 453.9 MB, deeplist 103.4 → 99.2 MB), because the full collections this
removes were reclaiming 0–12%.

The finding

retain.ts builds a 3M-element array of records and keeps every one alive.
Nothing is ever garbage. 79% of its runtime was GC pause (88% for
retain_wide), and the pause was dominated by full mark-sweeps that found the
heap fully live: retain one full at 161 ms reclaiming 11.9%, retain_wide two
at 98 ms and 512 ms reclaiming 6.8% and 9.6%, deeplist one at 127 ms
reclaiming 0.0%. Against tree / tree_wide, whose 40 fulls each reclaim
87.8% / 92.3% and which this PR leaves untouched.

The escalation rule was "run a full once the arena grows past 2× the last full's
live set". That is right for a heap accumulating garbage and wrong for one that
is not — when everything allocated stays alive, doubling is the program working.
#7726/#7733's retrospective yield backoff cannot repair it: it prices a full
after paying for it, and on a monotonically growing live heap deferring a full
only makes the next one bigger. The futile full has to be predicted.

The change

The prediction is a measurement the collector already takes.
young_survival_permille separates the two populations by two orders of
magnitude with nothing in between — churn/churn_alloc/push_cls 0–4,
cycles 0, shapes 713–920, retain/retain_wide/deeplist 999–1000. A
copying minor measuring ≥ 900 permille marks the heap RETAINING, which widens the
escalation growth band 4× and re-baselines arena-growth pacing on the occupancy
that survived. The same signal and multiplier are applied to
old_reclaim_pressure_due's band, which became the binding constraint once the
arena-growth escalation correctly declined (a 452 ms full reclaiming 7.6% — the
identical shape, one trigger over).

Two properties bound the blast radius, both asserted by tests:

  • It can only make fulls rarer, never more frequent — the baseline only
    ratchets up and the multiplier is ≥ 1, so the boundary is never lower than
    before. The exposure is deferred reclamation, and measured RSS went down.
  • One non-retaining minor disarms it, no decay window.

Plus two independent wins found while profiling: an all-pointer array's dirty-card
scan was O(live array) rather than O(dirty pages) (worth 9% on retain alone,
and dirty_slot_ranges_scanned == 0 in every trace was recording it), and two
hot-path readings in the copying minor that were paid for and not used.

Full writeup, including the hypothesis that measured as exactly zero and was
therefore not shipped, in the changelog fragment.

Validation

  • All 19 corpus programs byte-identical to node --experimental-strip-types, exit 0.
  • gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0, including under
    PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 and
    PERRY_GC_VERIFY_EVACUATION=1.
  • Protected set unchanged within noise; tree/tree_wide still run their 40
    fulls each at the same wall time.
  • Full perry-runtime lib suite green (2,054 tests, RUST_TEST_THREADS=1).
  • gc-ratchet is already red on main. 11_collect_at_depth (six gating rows)
    and 04_dead_after_deep_stack's copied_objects fail identically for the
    origin/main @ 0a2bf15bd reference build measured on the same host. Comparing
    the two artifacts cell by cell, every gating metric across all 13 probes is
    identical
    between main and this branch; only wall_ms / rss_bytes /
    peak_rss_bytes differ, which is exactly what the shared_ci profile does not
    gate. 12_large_live_set holds and improves (heap_used_bytes −2.39%) with
    copied_objects 61,851, so its subject was live. Those rows want their own
    investigation and are not from this PR.

…les futile full mark-sweeps

retain.ts 0.542 -> 0.345 s, retain_wide.ts 1.099 -> 0.454 s, deeplist.ts
0.245 -> 0.123 s, with peak RSS DOWN on all three. Quiet M1 mini, best-of-5.

These programs retain every record they allocate, so nothing is ever garbage,
yet 79% of retain and 88% of retain_wide was GC pause -- dominated by full
mark-sweeps that found the heap fully live (retain 161 ms for 11.9%,
retain_wide 98 + 512 ms for 6.8% and 9.6%, deeplist 127 ms for 0.0%; against
tree/tree_wide's 40 fulls each at 87.8%/92.3%, which this leaves untouched).

The escalation rule was "run a full once the arena grows past 2x the last
full's live set". That is right for a heap accumulating garbage and wrong for
one that is not: when everything allocated stays alive, doubling is the program
working. #7726/#7733's retrospective backoff cannot repair it -- it prices a
full after paying for it, and on a monotonically growing live heap deferring a
full only makes the next one bigger. The futile full has to be predicted.

The prediction is a measurement the collector already takes.
young_survival_permille separates the populations by two orders of magnitude
with nothing between: churn/churn_alloc/push_cls 0-4, cycles 0, shapes 713-920,
retain/retain_wide/deeplist 999-1000. A copying minor at or above 900 permille
marks the heap RETAINING, which widens the escalation growth band 4x and
re-baselines arena-growth pacing on the occupancy that survived -- the latter is
what makes the former reachable, since before the first full the baseline is 0
and the boundary degenerates to the absolute floor, so ANY program retaining
more than 32 MB paid a whole-heap mark-sweep for doing so.

The same signal and multiplier apply to old_reclaim_pressure_due's growth band.
credit_promoted_bytes_to_old_baseline (#7592) already exempts old-gen growth a
minor proved live, but a large object is allocated straight into old-gen and
never passes through promotion, so its bytes are uncredited growth even when
they are the program's live data -- on retain.ts, the element array itself. With
the arena-growth escalation correctly declining, that band became the binding
constraint and fired a 452 ms full reclaiming 7.6%.

Two bounding properties, both asserted by tests: the baseline only ratchets up
and the multiplier is >= 1, so the boundary is never lower than before and this
can only make fulls rarer, never more frequent; and one non-retaining minor
disarms the band with no decay window. `retaining` is emitted in the
major_pacing GC trace so a run that never armed it is distinguishable from one
that did and had nothing to skip.

Two independent wins found while profiling:

* An all-pointer array's dirty-card scan was O(live array), not O(dirty pages).
  scan_dirty_object_slots's Slot arm answers "is this slot dirty?" with a
  hash-set probe per slot; its Range arm intersects with the dirty-page set
  directly. LayoutSlotMask::AllPointers reported itself as Masked and so emitted
  one Slot per element -- 3M probes per minor to find a few hundred known-dirty
  pages. dirty_slot_ranges_scanned == 0 in every retain.ts trace was recording
  exactly that. Worth 9% on retain before any pacing change.
  dirty_slot_ranges_for now also walks whichever of the two sets is smaller.
* classify_heap_space_in_range is split into an inline(always) cache-hit arm and
  an inline(never) miss arm, as #7469 did for classify_heap_generation and for
  the same reason; and classify_arena no longer reads both survivor-space
  thread-locals (two _tlv_get_addr calls on Darwin) before a match whose common
  arms cannot use them.

Refuted and not shipped: batching the per-slot old_page_account_dirty_slot map
probe into one update per 4 KB page measured as exactly zero (0.344 vs 0.345 s).
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a697a48-aed5-4578-80de-5f36723d379a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

Ralph Küpper added 7 commits August 10, 2026 23:11
…t survivor placement

gc-ratchet 11_collect_at_depth turned 6,150 promoted objects into 6,139 copied
ones: copied_minor_promotion_handoff_pressure_due shares old_reclaim_pressure_due
with the OldReclaim escalation, so widening the shared band also stopped the
survivor-promotion handoff from firing on a retaining heap. Placement and
collection are different questions and only the second one was paying for a
futile full, so the multiplier moves to old_reclaim_full_due and the shared band
goes back to what it was. Pinned by a test that asserts both directions from one
reading.
…sion, not survivor placement"

This reverts 882be57. Both of its justifications were refuted by measurement.

The premise was that widening the shared band flipped gc-ratchet's
`11_collect_at_depth` from 6,150 promoted objects to 6,139 copied ones. It did
not: a gc_ratchet run of the `origin/main` @ 0a2bf15 reference build on the
same host produces that flip too, along with `04_dead_after_deep_stack`'s
copied_objects row -- six identical gating rows, byte for byte. Comparing the two
artifacts cell by cell, EVERY gating metric across all 13 probes is identical
between main and this branch; only wall_ms/rss_bytes/peak_rss_bytes differ, and
those are the three the shared_ci profile deliberately does not gate. Those rows
are red on main on this host, not something this PR did.

The principle behind it was wrong too. It carved
`copied_minor_promotion_handoff_pressure_due` out as a survivor-PLACEMENT
decision that should not read a band derived from full-GC-yield evidence. Its own
doc says otherwise -- "whether an imminent promotion justifies a full old reclaim
FIRST" -- and `gc::mod.rs` responds to it with `note_survivor_promotion_handoff_full`.
It is a full-collection decision like the other two, so one signal and one
multiplier across all three callers of `old_reclaim_pressure_due` is the coherent
shape, not a carve-out.
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