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.
Summary
Under
PERRY_GC_INCREMENTAL=1, allocation-side mutator assists do a fixedGC_MUTATOR_ASSIST_WORK_UNITS = 256units 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):PERRY_GC_INCREMENTAL=1* "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=1confirms 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).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::stepstops 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.