…sue #5537)
A deep game-tree search that #5563 saved from an EXC_RESOURCE kill came back
frozen instead: GC pauses growing longer and more frequent until they were
effectively continuous, with the simulator's footprint climbing to gigabytes
while the app retained nothing. Both readings are the same defect, and it sits
under the one #5563 fixed rather than beside it.
Every cycle the grace pass walks the BiBOP page registry and marks every object
allocated since the last cycle -- a fresh object may already be linked into the
live graph, so it and its subtree have to survive. How many that is depends on
the mutator's ALLOCATION RATE, not on the live set, and the pass pushed all of
them onto a fixed 65536-entry worklist before draining any of it. A worker
churning small short-lived objects produces several times that per cycle, so the
worklist overflowed as a matter of course.
Overflow is survivable -- the dropped entries are already marked and the belt
re-discovers their children -- but the belt is a full O(heap) rescan. It makes
the cycle several times longer, the mutator leaves proportionally more fresh
objects for the next one, and that one overflows for certain. The collector never
returns to its fast path. Every symptom on the issue follows from that single
loop: the original kill by the iOS per-process ceiling, the frozen app once
#5563's pacing held the process under that ceiling and had to park the mutator on
nearly every allocation instead, and the simulator's climbing footprint where no
ceiling exists at all.
Both grace passes -- the page registry and the legacy table -- now drain when the
worklist reaches half capacity. That costs nothing the end-of-pass drain would
not have cost anyway, since the same objects are scanned, only sooner; what it
buys is a cursor that cannot run away. The drain runs outside the trusted window
(CN1_GC_TRUSTED_SUSPEND/RESUME, added because BEGIN/END save and restore a
block-scoped local and so cannot express a hole inside a walk): a drain follows
child words out of arbitrary mark functions, which is precisely what the resolve
guard exists for. A _Static_assert pins the remaining assumption -- that a whole
page of slots fits above the drain threshold -- so raising CN1_BIBOP_PAGE_SIZE
fails the build rather than quietly restoring the spiral.
Measured on a repro of the reporter's shape (worker thread, tree search, live set
of one path). Realistic version, no ceiling: peak footprint 6.2GB -> 231MB, cycle
time 6ms->750ms -> a flat 6ms, and 30% more nodes searched. Heavier version under
a 512MB simulated ceiling, which is the device case: 77 of 150 cycles overflowed
and the mutator parked 72 times -> 0 of 440 and no parks, 10.2s -> 6.8s, with
174MB of headroom left instead of 64MB.
GcOverflowSpiralIntegrationTest guards it, asserting zero overflow cycles under a
simulated ceiling and that the pass actually reached its drain threshold (else the
first assertion would pass on a run that never allocated). Ablating the drain and
leaving everything else in place fails it with 77 overflows. Overflow cycles are
counted through a new env-gated [GC-OVERFLOW] tracer, and the count is taken with
an exchange on the existing flag so it reads once per cycle rather than once per
dropped push.
Not addressed here, and separate: off a per-process ceiling the pacing cap is
still a fraction of the HOST's free RAM, so on a RAM-rich Mac a sufficiently
extreme allocator can build gigabytes of garbage before anything stalls it. A
live-set-relative cap and an absolute cap were both measured and rejected -- each
cost 2-4x throughput, because a volume-cap park waits out a whole collection while
the footprint-based admission used under a real ceiling is both bounded and free.
Extending that admission to hosts with a footprint probe but no ceiling is the
right fix and needs its own benchmarking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A deep game-tree search that #5563 saved from an
EXC_RESOURCEkill came back frozen instead -- GC pauses growing longer and more frequent until they were effectively continuous, with the simulator's footprint climbing to gigabytes while the app retained nothing. Both readings are the same defect, and it sits under the one #5563 fixed rather than beside it.Root cause
Every cycle the grace pass walks the BiBOP page registry and marks every object allocated since the last cycle -- a fresh object may already be linked into the live graph, so it and its subtree have to survive. How many that is depends on the mutator's allocation rate, not on the live set, and the pass pushed all of them onto a fixed 65536-entry worklist before draining any of it. A worker churning small short-lived objects produces several times that per cycle, so the worklist overflowed as a matter of course.
Overflow is survivable -- the dropped entries are already marked and the belt re-discovers their children -- but the belt is a full O(heap) rescan. It makes the cycle several times longer, the mutator leaves proportionally more fresh objects for the next one, and that one overflows for certain. The collector never returns to its fast path.
Every symptom on the issue follows from that one loop:
The change
Both grace passes -- the page registry and the legacy table -- drain when the worklist reaches half capacity. That costs nothing the end-of-pass drain would not have cost anyway (the same objects are scanned, only sooner); what it buys is a cursor that cannot run away.
The drain runs outside the trusted window, via a new
CN1_GC_TRUSTED_SUSPEND/RESUMEpair (BEGIN/ENDsave and restore a block-scoped local and so cannot express a hole inside a walk): a drain follows child words out of arbitrary mark functions, which is precisely what the resolve guard exists for. A_Static_assertpins the remaining assumption -- that a whole page of slots fits above the drain threshold -- so raisingCN1_BIBOP_PAGE_SIZEfails the build rather than quietly restoring the spiral.Measured
Repro of the reporter's shape: worker thread, tree search, live set of one path through the tree.
Realistic version, no ceiling:
Heavier version under a 512MB simulated ceiling (the device case):
Test
GcOverflowSpiralIntegrationTestasserts zero overflow cycles under a simulated ceiling, and that the pass actually reached its drain threshold -- otherwise the first assertion would pass on a run that never allocated. Ablating the drain and leaving everything else in place fails it with 77 overflows. Overflow cycles are counted through a new env-gated[GC-OVERFLOW]tracer, taken with an exchange on the existing flag so it reads once per cycle rather than once per dropped push.Green locally: 519 non-benchmark ParparVM tests, and all 8 benchmark tests including
GcHeapIntegrity,LargeArrayGc,BibopPageFloorandProcessBudgetPacing.Not addressed here
Off a per-process ceiling the pacing cap is still a fraction of the host's free RAM, so on a RAM-rich Mac a sufficiently extreme allocator can build gigabytes of garbage before anything stalls it. A live-set-relative cap and an absolute cap were both measured and rejected -- each cost 2-4x throughput, because a volume-cap park waits out a whole collection while the footprint-based admission used under a real ceiling is both bounded and free. Extending that admission to hosts that have a footprint probe but no ceiling is the right fix and needs its own benchmarking.
🤖 Generated with Claude Code