Skip to content

GC: incremental mutator assists use a fixed budget — allocation outruns the collector, RSS grows unbounded (6-22x measured) #6224

Description

@proggeramlug

Summary

Under PERRY_GC_INCREMENTAL=1, allocation-side mutator assists do a fixed GC_MUTATOR_ASSIST_WORK_UNITS = 256 units of GC work per trigger, regardless of how far behind the collector has fallen. A tight allocation loop therefore outruns the collector: the budgeted cycle crawls and never completes, nothing is ever reclaimed, and RSS grows without bound. This is disqualifying for the low-RAM/default-on goal (#6180 Stage 2) — the mode currently increases memory footprint by multiples on allocation-heavy workloads.

Measured (2026-07-10, N=3 medians, main @ 6309eb7)

Ring-buffer churn benchmark (10M allocations, ~1k live objects, /usr/bin/time -l):

STW (default) PERRY_GC_INCREMENTAL=1
collections completed 7 0
peak RSS 501 MB 3113 MB (6.2×)
peak RSS @ 40M allocations 501 MB 11 GB (22×)
wall-clock 31.9 s 30.6 s*

* "faster" only because it skipped collecting entirely.

Large-live-heap latency benchmark showed the same pattern in milder form (RSS 497 MB → 1948 MB), even though cycles do complete there.

PERRY_GC_DIAG=1 confirms the mechanism: STW emits 7×[gc-copy-minor]/[gc-step]; incremental emits zero GC lines for the whole run.

Root cause

  • gc_check_trigger() fires an assist per arena-block allocation (~1 MB), each doing a fixed 256 work units (policy.rs).
  • A growing heap needs work proportional to its size per cycle (valid-pointer build + mark + sweep — tens of millions of units at multi-GB scale); ~256 units per MB allocated can never converge.
  • GcDebtSnapshot (telemetry.rs) already measures exactly this shortfall — allocation past the armed triggers — but is wired to telemetry/FFI reporting only. Nothing scales assist work by it.

Fix shape (proportional pacing)

Scale each assist's budget linearly with measured debt (e.g. 1 unit per KB of arena debt + 1 unit per outstanding malloc-registry object). Between two block-alloc assists the mutator allocates ~one block while the budget grows with total debt, so the controller self-stabilizes at a bounded equilibrium instead of falling behind forever.

No explicit cap is needed: the budget is a ceiling on work, not a pause floor — GcCycleState::step stops the moment the cycle completes, and remaining cycle work is bounded by the heap. Worst case under extreme allocation pressure is finishing the cycle in one assist — i.e. exactly the pause the synchronous collector takes today. Incremental then degrades gracefully toward STW behavior rather than toward unbounded memory.

Part of the #6180 Stage-2 (default-on graduation) prerequisite list, alongside slicing AtomicFinalizeSubphase::WeakProcessing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions