Skip to content

[perf] Minor GC does O(old-gen) work: full-region sweep walk, whole-heap RS rebuild, per-cycle page-meta iterations #6181

Closed

Description

@proggeramlug

Problem

Minor GC cost includes several O(old-gen) components even when nothing old is touched:

  1. Sweep visits/rewrites every old-gen header on every minor (gc/oldgen.rs:1181-1225 cursor over all regions; flags/accounting rewrites gc/oldgen.rs:1291-1311,1338,1357; minors set reclaim_dead_old_blocks=false but still walk, gc/cycle.rs:1390-1416).
  2. The remembered-set finalize rebuild walks the ENTIRE heap (all arena objects + full malloc registry) every cycle (gc/cycle.rs:1244-1264, gc/verify.rs:279-349), and with require_marked=false for minors it re-dirties pages from unreachable-but-unswept old parents.
  3. Old-page bookkeeping iterates every old 4 KB page meta up to 3× per minor (arena/page_meta.rs:555-571, gc/oldgen.rs:181-184,1199), plus the promotion-handoff pre-walk over all arena objects once survivor in-use ≥ 24 MB (gc/policy.rs:864-902).

Minor pause grows monotonically with process lifetime on servers.

Proposed fix

  • Restrict the minor sweep cursor to nursery+survivor regions; maintain old-gen accounting incrementally from promotion/reclaim events; per-block live counts so fully-dead block reset needs no per-object walk.
  • Maintain the RS incrementally (the barriers already do) and restrict the finalize rebuild to evacuated/moved parents — rebuild_evacuated_old_to_young_remembered_set (gc/verify.rs:243-253) plus the CI: gc_write_barrier_stress red on main (missing old→young remembered-set edges → segfault) #5029 snapshot repair already exist for exactly this.
  • Lazy epoch-counter resets for page metas; keep defrag candidates on a small heap updated at promotion time.

Context

2026-07-09 GC audit (pause/scalability + barrier dimensions).

Activity

  1. proggeramlug commented on Jul 10, 2026

    @proggeramlug
    ContributorAuthor

    Status: the whole-heap old→young RS rebuild for minors was eliminated in #6213 (minors jump AtomicFinalize→Sweep; barriers + evacuation-sticky + #5029 snapshot repair carry coverage). Remaining from this issue: the minor sweep's full-region walk and per-cycle census cost — the census half just landed for budgeted cycles in #6239 (no exact set built at all); the sweep-walk restriction to young regions remains open and is queued in the #6180 flip campaign.

  2. proggeramlug commented on Jul 10, 2026

    @proggeramlug
    ContributorAuthor

    Per-sub-cost status on current main (post the July 9-10 GC campaign):

    • (b) whole-heap remembered-set rebuild on every minor: FIXED by perf(gc): minor collections don't walk/rebuild over old-gen (#6181) #6213 (gc/cycle.rs:1405-1429, // Fix 2 (#6181)) — minors early-exit past RememberedSetRebuild; regression tests test_minor_skips_whole_heap_old_to_young_rebuild + test_minor_preserves_old_to_young_edge_across_minors cover it. Don't re-touch.
    • (a) full old-gen sweep walk on every minor: still present, deliberately deferred. ArenaSweepObjectsState::new always builds a BlockIndex cursor over all 5 regions incl. Old (gc/cycle.rs:1337, arena/walk.rs). PR perf(gc): minor collections don't walk/rebuild over old-gen (#6181) #6213 scoped this out with a real correctness blocker: a minor's mark phase marks root/young-reachable old objects, and the minor sweep's full old-gen walk is the only place those MARKED bits get cleared before the next full GC (which assumes clean marks at entry). Skipping it naively → stale marks → next full GC skips tracing → frees live old children → UAF. Needs a design choice (minor-scoped mark suppression vs. a full-GC mark-clear pass + delta accounting) — not a quick patch.
    • (c) per-cycle page-meta iteration: partly present. old_pages_begin_gc_cycle/old_pages_reset_sweep_accounting/old_page_summary walk all page metas every cycle (the reset-accounting one is coupled to (a)). One clean, decoupled win exists: copied_minor_promotable_active_survivor_bytes (gc/policy.rs:765-786) calls the unfiltered arena_walk_objects_with_block_index just to filter by active block range — a filtered helper arena_walk_objects_filtered(block_filter, cb) (arena/walk.rs:686-775) already exists and skips out-of-range blocks in O(n_blocks). It's a pure measurement pass, no mark-bit coupling → safe S swap. (This was not addressed by perf(gc): census-free budgeted cycles + RSS floor (#6179, #6180 Stage 2) #6239, whose "census" is the mark-phase ValidPointerSet, a different structure — verified by diff.)

    Recommendation: land the policy.rs:765 filtered-walk swap as an isolated S/low-risk PR (essentially conflict-free with the #6180/#6243 churn); keep (a) + the coupled reset-accounting as a separate design-gated follow-up once the mark-clear approach is chosen. The fix/6083-nursery-scoped-minor branch (#6129) is closed/superseded by #6239 and not reusable here.

  3. proggeramlug commented on Jul 11, 2026

    @proggeramlug
    ContributorAuthor

    Landed: #6277 — the promotable-survivor census (gc/policy.rs) now uses the block-filtered arena walk instead of the unfiltered one, so sub-cost (c)'s one cleanly-decoupled piece is done: the pure measurement pass drops from O(all arena objects) to O(active-survivor objects) + O(blocks). Regression test asserts the filtered census equals the old unfiltered-walk value byte-for-byte (with a guard that the equality isn't vacuously 0 == 0).

    Status of this issue's three sub-costs:

    • (b) whole-heap RS rebuild on every minor — fixed earlier by perf(gc): minor collections don't walk/rebuild over old-gen (#6181) #6213.
    • (c) per-cycle page-meta iteration — the policy.rs:765 unfiltered-walk half is now fixed (perf(gc): use the filtered arena walk for the minor promotable-survivor census (#6181) #6277). The old_pages_* reset-accounting walks remain, coupled to (a).
    • (a) full old-gen sweep walk on every minor — still open, still design-gated. A minor's mark phase marks root/young-reachable old objects, and the minor sweep's full old-gen walk is the only place those MARKED bits get cleared before the next full GC (which assumes clean marks at entry). Skipping it naively → stale marks → next full GC skips tracing → frees live old children (UAF). Needs the design choice (minor-scoped mark suppression vs. a full-GC mark-clear pass + delta accounting) before any patch.

    Leaving open for (a) + the coupled reset-accounting.

  4. added
    performanceRuntime, compile-time, build-size, or memory performance
    triagedMaintainer reviewed; type, scope, and next step are clear
    on Jul 26, 2026
  5. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Closing — two of the three sub-costs are fixed, and the third is refiled so it is visible as its own work.

    Fixed: (b) whole-heap remembered-set rebuild and (c) per-cycle page-meta iterations, via #6213 / #6277 / #6568.

    Refiled: #7716 — sub-cost (a), the minor's full-region sweep walk over the old generation (gc/oldgen.rs::sweep, still on the minor path at HEAD), which makes minor cost scale with old-gen size rather than nursery survivors.

    That issue carries the two traps that make it less mechanical than it looks: the malloc sweep is a separate, trigger-gated path that must not be conflated with it, and #7682/#7690's precedent that removing work from the minor path can livelock on its own, because the nursery cap was keyed on bytes the changed collector no longer frees.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performancetriagedMaintainer reviewed; type, scope, and next step are clear

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions