fix(runtime): explicit gc() runs a full collection so dead old-gen objects are reclaimed - #5714
Merged
TheHypnoo merged 1 commit intoJun 26, 2026
Conversation
…jects are reclaimed With generational GC on (the default), `js_gc_collect()` dispatched a MINOR cycle. The minor sweep intentionally skips dead-old-block reclamation (`reclaim_dead_old_blocks = false` in `step_sweep`), and large objects (>16KB) plus minor-GC survivors live in the OLD arena. So dead large/tenured objects were never returned to the OS by an explicit `gc()`: a large-object allocation loop grew unboundedly (RSS climbed ~175MB/round with `gc()` between rounds) instead of plateauing. Route `manual_gc_collect_now` (the single chokepoint for both the synchronous `js_gc_collect` path and the deferred flush) through `gc_collect_full_mark_sweep_with_trigger`, so an explicit `gc()` performs a full mark-sweep that reclaims dead old-generation blocks. The same allocation loop now plateaus. Automatic/threshold-driven minor collections are untouched. Notes: - V8's bare `gc()` is a scavenge (minor); this makes Perry's explicit, user-initiated `gc()` a thorough (full) collection. Gating the full sweep on `gc(true)` to mirror V8's minor/major split is a reasonable follow-up. - A single lone dead large object whose block is the allocator's *current* old-gen target is kept mapped + reusable (not returned to the OS until reused) — a deliberate allocator trade-off, separate from this change.
|
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)
📝 WalkthroughWalkthroughExplicit ChangesManual GC semantics
🎯 2 (Simple) | ⏱️ ~10 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
8 tasks
proggeramlug
added a commit
that referenced
this pull request
Jun 27, 2026
…n compute-only workloads (#5734) * fix(gc): #5476 — reclaim dead old-gen blocks under reclaim pressure on compute-only workloads A workload that churns large temporaries (>16 KB, born directly in the old arena because they exceed LARGE_OBJECT_THRESHOLD_BYTES) grows the old generation without ever exercising the nursery. Old-gen reclaim pressure schedules a budgeted full cycle that *would* return the dead old blocks to the OS, but: 1. the budgeted stepper is blocked whenever synchronous-only root scanners are registered (the common case in a compiled program), and 2. even when it runs it only advances through bounded mutator-assist steps that a compute-only loop never drives to completion — no event-loop safepoint ever runs. Either way no collection completes, so dead old blocks are never reclaimed and RSS climbs unbounded (the #5476 benchmark reached 1.8 GB RSS / 164 MB live). Fix: when old-gen reclaim pressure is what's due in gc_check_trigger — a rare event gated by the ~32 MB growth / 48 MB absolute baseline, so it never fires on the common nursery-churn path — run a direct full mark-sweep to completion, the same non-budgeted collection an explicit gc() performs (#5714). The conservative native-stack scan keeps it safe: anything still referenced from the stack or registers at the allocation point is retained; only genuinely unreachable old blocks are returned. A re-entrancy guard prevents a nested trigger from recursing, and the full cycle's completion rebaselines the reclaim watermark. Repro (50× new Array(100k) → filter→map→reduce, no gc() calls): before: RSS 58→103→149→191 MB, monotonic and unbounded after: RSS 51→66→67→67 MB, stabilizes (matches Node's bounded RSS) Adds a regression test asserting gc_check_trigger alone drives the old-gen reclaim cycle to completion (collection runs, dead old bytes freed, live root preserved) without any host GC step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(gc): isolate #5476 reclaim test's dead-old allocation in a non-inlined helper Address CodeRabbit feedback on #5734: hold the unreachable old-arena pointer entirely within an `#[inline(never)]` helper that returns only its size, so the raw pointer never lands on the test's stack frame where a conservative scan could pin it. The GC test guard already pins `Auto` scan mode (which skips the native-stack scan), so the reclaim was real before this change too, but the isolation makes the assertion robust regardless of scan mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Ralph <ralph@skelpo.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An explicit
gc()did not reclaim dead old-generation / large objects, so alarge-object allocation loop grew unboundedly (RSS climbed ~175 MB/round with
gc()between rounds) instead of plateauing.With generational GC on (the default),
js_gc_collect()dispatched a minorcycle. The minor sweep intentionally skips dead-old-block reclamation
(
reclaim_dead_old_blocks = falseinstep_sweep), and large objects (>16 KB)plus minor-GC survivors live in the old arena — so an explicit
gc()neverreturned that memory to the OS.
Changes
crates/perry-runtime/src/gc/policy.rs— routemanual_gc_collect_now(thesingle chokepoint for both the synchronous
js_gc_collectpath and thedeferred flush) through
gc_collect_full_mark_sweep_with_trigger, so anexplicit
gc()performs a full mark-sweep that reclaims dead old-generationblocks. Automatic/threshold-driven minor collections are untouched.
Related issue
n/a — explicit-
gc()reclamation of old-generation/large garbage.Test plan
Scope / caveats (intentional)
V8 semantics: V8's bare
gc()is a scavenge (minor);gc(true)is themajor one. This makes Perry's explicit, user-initiated
gc()a thorough(full) collection. Gating the full sweep on
gc(true)to mirror V8'sminor/major split is a reasonable follow-up (Perry currently ignores the
forceargument).Pause: an explicit
gc()now costs a full collection rather than a minor.Since
gc()is user-initiated, a thorough collection is the expected trade.Lone large object: a single dead large object whose block is the
allocator's current old-gen target is kept mapped + reusable (not returned
to the OS until reused) — a deliberate allocator trade-off, separate from this
change. This fix addresses the accumulation (unbounded-growth) case.
cargo build --releaseclean — built-p perry; full-workspace build needs the GTK/gdk-pixbuflibs forperry-ui-*, which CI provides.cargo test --workspace --exclude perry-ui-ios --exclude perry-ui-tvos --exclude perry-ui-watchos --exclude perry-ui-gtk4 --exclude perry-ui-android --exclude perry-ui-windowspasses — rancargo test -p perry-runtime --lib gc::(379/379) plus the end-to-end large-loop check; full workspace deferred to CI.(if user-facing) Added or updated a test —
gc::unit tests cover the collection paths; behavior verified end to end above.(if CLI / stdlib / runtime API changed) Updated
docs/src/— n/a.(if touching a platform UI backend) Built
-p perry-ui-<backend>— n/a.Screenshots / output
n/a — see the bounded plateau in the test plan.
Checklist
feat:/fix:/docs:/chore:prefix convention —fix(runtime): …Summary by CodeRabbit