Skip to content

[JSC] Heap::evacuateSparseAuxiliaryBlocks: move butterflies out of sparse blocks of a program at rest (prototype, nothing calls it by default) - #635

Open
Jarred-Sumner wants to merge 1 commit into
claude/gc-sweep-skips-decommitted-pagesfrom
claude/gc-evacuate-sparse-butterflies
Open

[JSC] Heap::evacuateSparseAuxiliaryBlocks: move butterflies out of sparse blocks of a program at rest (prototype, nothing calls it by default)#635
Jarred-Sumner wants to merge 1 commit into
claude/gc-sweep-skips-decommitted-pagesfrom
claude/gc-evacuate-sparse-butterflies

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

One commit, stacked on #632 (its tests use $vm.markedBlockStatistics() from there, and both add $vm functions at the same place). No dependency on #628. Nothing here runs unless the testing option or the $vm function asks for it.

claude/gc-sweep-skips-decommitted-pages** (2 small commits, its own PR) because the tests count MarkedBlocks with
$vm.markedBlockStatistics() from there and both add $vm functions at the same place. It does not depend on #628. This
PR's own diff is the last commit. It adds the mechanism, $vm.evacuateAuxiliaryBlocks(), and a testing mode
(Options::evacuateAuxiliaryBlocksAfterEveryFullCollection, default off); in production nothing calls it. The two-line
hook into VM::shrinkFootprintNow (which only exists on #628's branch; main only has upstream's
shrinkFootprintWhenIdle()) is a separate follow-up,
claude/gc-evacuate-on-shrink-footprint, described at the end.

Why

After an interactive session of a large bundled CLI application the Auxiliary subspace (butterflies) holds 2.8 MB of live
cells in 2,270 pages (8.9 MB): what the session's peak left behind is spread thinly over 900 blocks, and page-granular
decommit cannot help because nearly every page has a survivor. Allocation policy does not help either (resident-first
picking, sealing blocks below 25 % / 50 % / all: every variant measured neutral or worse); the fragmentation is created by
the first session's peak and then stays flat.

JSC does not move cells, but a butterfly is special: exactly one pointer to it is known to the collector, its object's
m_butterfly.

Measured with the follow-up hook, i.e. the embedder's deep-idle footprint shrink calling this (20 inputs / idle 265 s /
10 inputs / idle 265 s, five interleaved pairs, off -> on):

off on
anonymous memory after the shrink, first idle 126.8 MB 119.9 MB
anonymous memory after the shrink, second idle 130.2 MB 123.4 MB (-6.8 MB)
pages holding a live cell 61.3 MB 54.8 MB
candidate blocks / evacuated 640-730 / 600-690
butterflies moved 13.6-15.3 k, 1.3-1.4 MB copied, none pinned
cost two heap walks, 18-22 ms on a quiet machine (up to 116 ms under load); 90 M instructions for 292 k live objects and 41 k moves in the jsc shell
user instructions, whole process 34.95 G 35.05 G (inside the +-0.6 G spread between identical runs)

At a fresh prompt (no session yet) it is worth 0.7-1 MB.

What

Heap::evacuateSparseAuxiliaryBlocks(maximumOccupancy), for a program at rest:

  1. Candidates: MarkedBlocks of the Auxiliary subspace whose live bytes (mark count, once per block) are at most
    maximumOccupancy of a block, except blocks an allocator is in the middle of, and except size classes where moving
    would not free a block (a single candidate, or survivors that would fill as many blocks as they came from). That rule
    is what gives repeated calls a fixed point: without it each call finds the block the previous one half filled sparse
    again. Candidates are taken out of the allocatable set while cells move, so copies never land in them; the ones that
    are not evacuated in the end are put back.
  2. Owners: one walk over every live JSObject (mark bits per block, cell kind decided per block; precise allocations
    separately) finds, for each live cell of a candidate, whether it is the (non copy-on-write) butterfly of exactly one
    object.
  3. Pins: a conservative scan of everything the collector scans conservatively (below).
  4. A block is evacuated only if every live cell in it can move. Compiler threads are parked for the move loop only. Each
    cell is copied whole (pre-capacity, out-of-line properties, IndexingHeader, vector or ArrayStorage move together) into a
    cell of the same size class allocated the ordinary way, and installed with JSObject::setButterfly.
  5. The old copy stays marked and untouched; the next full collection finds it unreferenced and frees it, and with it the
    block. The caller follows the evacuation with a full collection; a second call before that collection returns at once.

It never asserts on the caller's state. If this is not a moment at which it can run (no API lock, heap access released, a
collection in progress, collection deferred or prevented by the caller, heap iteration, a second thread registered with the
VM, the CLoop) it returns an empty result whose skipped field says why. The header comment spells out what it asks of an
embedder that keeps raw pointers into out-of-line storage.

$vm.evacuateAuxiliaryBlocks(occupancy = 1, holder) runs it on demand and returns the counters, skipped and the time.
With a holder, the native frame keeps a raw pointer to the storage of holder.target (and nothing else of the target)
across the call and reports heldStorageStayed: that is how the tests reach the pointer rule without the owner rule.

Safety argument

Exactly one owner. A cell of the Auxiliary subspace moves only if the heap walk found exactly one live JSObject whose
butterfly()->base(structure) is that cell and whose indexing mode is not copy-on-write. Everything else in the subspace
(JSPropertyNameEnumerator buffers, ScopedArguments overflow storage, StructureChain vectors: 1 % of the bytes) has no such
owner, stays, and keeps its whole block in place. Copy-on-write butterflies are cells of another subspace
(JSCellButterfly) and are not candidates; neither are precise allocations. So after step 2, for every cell that may move,
the only heap reference is one known m_butterfly.

No other reference anywhere else. Raw pointers to an ordinary butterfly exist only transiently:

  • generated code loads m_butterfly each time it needs it and bakes no storage pointer for ordinary objects:
    ConstantStoragePointer is only made for typed-array vectors (DFGConstantFoldingPhase), which live in another subspace
    and are never touched here; the butterfly constants that DFG/FTL do embed are JSCellButterfly (copy-on-write) cells;
  • inline caches and profiles hold offsets, structure IDs and cells; suspended generators and async frames hold JSValues;
  • what remains is what the collector itself scans conservatively: machine registers and the machine stack (JIT code that
    hoisted the load; C++ that keeps a Butterfly* local across a call that can allocate), active DFG scratch buffers and
    OSR exit side state, and the copies of machine stack and callee-saves that a suspended JSPI computation keeps
    (PinballCompletion). All of these are scanned: registers and stack through callWithCurrentThreadState (so the whole
    of this function's own frame is above the scanned stack top), the buffers through one VM function that
    gatherScratchBufferRoots now shares.

The conservative pin rule. A candidate cell is pinned if a scanned word points into it or up to
sizeof(IndexingHeader) past its end (two probes, p and p - sizeof(IndexingHeader) - 1: the butterfly pointer of an
object without indexed storage sits that far beyond its out-of-line properties); this is the arithmetic of
ConservativeRoots::genericAddPointer. In addition, the storage of every object that a scanned word refers to is
pinned, whether the object is a cell of a MarkedBlock (interior pointers included) or a precise allocation (large cells and
the first cells of every IsoSubspace, found in a table sorted by address): code that has the object at hand may hold a
pointer derived from its butterfly that no longer points into the allocation. A pinned cell stays, and so does its block.
If another thread is registered with the VM (its stack is not scanned) nothing is done at all.

Collector and compiler threads. The evacuation runs under PreventCollectionScope (no marker is looking at a
butterfly while it is replaced) with allocation-triggered collection deferred. Compiler threads read the butterflies of
constant objects, so they are parked with Heap::suspendCompilerThreads() while cells move: that waits until each is at a
safepoint, where a plan holds cells only.

Barrier. The copy is installed with JSObject::setButterfly, i.e. the ordinary write barrier: the owner goes into the
remembered set and the next collection (eden or full) marks the new storage through it, exactly as when an old array's
storage is reallocated on growth. Concurrent-marking races do not arise because no collection is in progress.

Old copy. Nothing is freed by hand. The old cell stays marked and intact until the next full collection clears the mark
bits and finds it unreferenced, so even a missed reader would read stale but valid data until then; the testing mode below
removes that slack on purpose.

The testing mode

Options::evacuateAuxiliaryBlocksAfterEveryFullCollection evacuates every Auxiliary block (occupancy 1, no fixed-point
rule) from the first allocation that takes the slow path after each full collection, i.e. under arbitrary JS, JIT and C++
frames; scribbles the old copies with 0xbadbeef0 (as a JSValue: a cell at an address that is never mapped; as a length:
out of bounds); then walks the heap again with the generic iterator and release-asserts that no butterfly points into an
evacuated cell and that every butterfly is live.

Changes since the first version of this PR (review round)

  1. The owner rule now covers owners in precise allocations (it silently skipped them: isAtomAligned / block-set test).
  2. No page-size gate any more: evacuation frees whole blocks and works where an OS page is as large as a block.
  3. Candidates that are not evacuated get their allocatable bit back (they used to stay frozen until the next sweep).
  4. No RELEASE_ASSERT on caller state: one predicate, shared with the testing hook, and a skipped reason in the result.
    $vm.evacuateAuxiliaryBlocks: exception check after toNumber, occupancy clamped to [0, 1] (NaN = 0).
  5. Every use is under #if USE(BUN_JSC_ADDITIONS) like the declarations; memcpySpan; include order.
  6. Tests rewritten so that each mechanism has teeth (table below); the dead collectNow hook and its comment are gone.
  7. m_isEvacuatingAuxiliaryBlocks, lastAuxiliaryEvacuation() and its member are gone; duration is set before the
    return and exposed; m_isCollectionPrevented stays as a skip condition because preventCollection() does not nest and
    an allocation slow path can run inside a caller's PreventCollectionScope (Heap::deleteAllCodeBlocks).
  8. (partly) Scans OSR exit side state and PinballCompletion slices too; one scratch-buffer walker shared with
    gatherScratchBufferRoots (a ScopedLambda, no allocation); callWithCurrentThreadState; the redundant p - 1 probe is
    gone. Not done: factoring the pointer lookup out of ConservativeRoots::genericAddPointer and driving the scan from
    gatherStackRoots/gatherVMRoots with a custom sink (touches the hot conservative-scan path; see "Not done").
  9. Fixed point and repeat cost: see step 1 and step 5 above. A dense heap now reports 0 candidates from the second
    gc(); evacuate round on.
  10. (partly) Candidate selection by mark count; the owner walk iterates mark bits with the cell kind decided per block;
    compiler threads are parked around the move loop only; the allocator is fetched once per block. 8,937 cells out of 94
    blocks with 60 k live objects: 2.4 ms in the release shell.
  11. Refuses to run without heap access; the embedder contract is in the header comment.

Tests

jsc shells, release and debug + ASan (ASAN_OPTIONS=detect_stack_use_after_return=0), of this branch (b0d746820eb7) and of
main (cf1b36ec8703); base lists retaken in the same session. "Testing mode" = --evacuateAuxiliaryBlocksAfterEveryFullCollection=1.
check-webkit-style on the commit: 0 errors in 13 files.

New tests, 13 run lines, 13/13 on release and on ASan (every line 20-130 ms on release except the two compile-thread lines
at ~200 ms; loop counts derive from testLoopCount; LLInt-only lines use --useBaselineJIT=0, not --useJIT=0, so that
the collector stays generational):

test what it does
auxiliary-evacuation-moves-butterflies.js out-of-line properties and int32 / double / contiguous / ArrayStorage arrays, sparse survivors; explicit evacuation; contents checked after the move, after an eden collection, after 16,000 new objects and arrays took whatever that collection freed, after a full collection; the evacuated blocks are freed; objects then grow, transition and shrink; a second evacuation finds nothing to do
auxiliary-evacuation-pins-storage-in-use.js explicit evacuation with, on the stack: an array that is a precise allocation, one that is a block cell, a plain array, an object with out-of-line properties only (their storage must stay, everything else must move); then a native frame holding only a raw pointer into an array's storage, then only a pointer sizeof(IndexingHeader) past an object's out-of-line properties
auxiliary-evacuation-under-jit-frames.js testing mode; compiled loops (default tiering, eager DFG+FTL, eager DFG only) that load an array's or object's storage once and keep using it across an allocation that, after a gc(), takes the slow path and evacuates: element loads, out-of-line loads, out-of-line stores on plain objects and on functions
auxiliary-evacuation-under-allocating-natives.js testing mode with slowPathAllocsBetweenGCs 21 (LLInt) and 11 (eager JIT) and with collectContinuously: slice, concat, spread, push, splice, unshift, Object.assign, JSON, fill + grow
auxiliary-evacuation-leaves-unevacuated-blocks-usable.js 150 sparse blocks that each hold a for-in enumerator's buffer cannot be emptied; 20,000 arrays allocated afterwards must go into their free cells (block count +25, not +125)
auxiliary-evacuation-with-concurrent-compiles.js concurrent compiles folding loads from constant objects during explicit evacuations, with and without the testing mode

Teeth. Release builds with one mechanism taken out at a time, the six tests above run against each:

removed caught by
the whole scan of stack, registers and buffers 7 run lines: pins-storage-in-use (2), under-jit-frames (3: wrong sums), under-allocating-natives (2: SIGSEGV in the LLInt line, a wrong push result in the eager-JIT line; its collectContinuously line is not deterministic and does not count)
the pointer rule (both probes), owner rule kept pins-storage-in-use, both lines (heldStorageStayed false)
the p probe only pins-storage-in-use, both lines (array held by the native frame)
the p - sizeof(IndexingHeader) - 1 probe only pins-storage-in-use, both lines (object with out-of-line properties only)
the owner rule, pointer rule kept pins-storage-in-use, both lines
the precise-allocation lookup of the owner rule pins-storage-in-use, both lines (3 pinned instead of 4)
the barrier after setButterfly (plain store) moves-butterflies, both lines ({} where an array was)
restoring the allocatable bit leaves-unevacuated-blocks-usable (198 -> 323 blocks)
parking the compiler threads nothing (see "Not done")

After the last variant the source was restored and the rebuilt binary is byte-identical to the tested one.

JSTests/stress, release, all 5,838 files (197 skipped by their own directives), failures / passes:

mode this branch main, same mode without the testing option only failing here
default options 35 / 5606 37 / 5597 recursive-try-catch.js (intermittent "Used too much heap", fails on main in 2 of its 3 other runs)
testing mode + --useGenerationalGC=0 37 / 5604 35 / 5599 auxiliary-evacuation-moves-butterflies.js (it evacuates explicitly and finds that the testing mode already did), int8-repeat-in-then-out-of-bounds.js (flaky everywhere)
testing mode + --useGenerationalGC=0 --collectContinuously=1 37 / 5604 36 / 5598 auxiliary-evacuation-moves-butterflies.js
testing mode + --slowPathAllocsBetweenGCs=20 --useConcurrentJIT=0 35 + 2 timeouts / 5604 34 / 5600 the three tests that evacuate explicitly (moves-butterflies, pins-storage-in-use, leaves-unevacuated-blocks-usable: a collection + evacuation lands between their steps); timeouts (300 s): async-from-sync-throw-closes-and-rejects.js (85 s on main in this mode), marked-block-decommitted-pages-are-not-read.js: the mode is ten times slower by construction

JSTests/stress, debug + ASan, every fourth file (1,458):

mode this branch main, same mode without the testing option only failing here
testing mode + --useGenerationalGC=0 12 / 1393 12 / 1393 none (same 12)
testing mode + --useGenerationalGC=0 --collectContinuously=1 11 + 2 timeouts (1,200 s) / 1392 11 + 1 timeout / 1393 no failure; one more timeout, array-shift-intrinsic.js (466 s under ASan + collectContinuously without the option; release 2.7 s -> 26 s, passes)

(The first version of this commit had two more tests at that limit in this mode, regress-158411.js and
sampling-profiler-bound-function-name.js; with the cheaper walk they finish.)

On top of #628's branch with the shrinkFootprintNow hook (the follow-up below): all 18 run lines of the seven tests pass;
release default 35 / 5624 (base 36 / 5615), testing mode + useGenerationalGC=0 36 / 5623 (35 / 5616: only
moves-butterflies), + collectContinuously 38 / 5621 (37 / 5614: moves-butterflies and codeblock-aging-ftl-idle.js, a timing
test that fails 5/20 on the base shell in that mode).

Not done / open questions

  • Prototype: nothing calls it by default, single-threaded VMs only (a second registered thread disables it, and the result
    says so), Auxiliary subspace only. The other large holes in live pages after two sessions (JSCell 12.0 MB, JSArray 2.4,
    JSFunction 2.4, JSRopeString 1.4, ImmutableButterfly 1.2 of ~33 MB) are cells with arbitrarily many referrers and are out
    of reach of this technique.
  • Typed-array vectors (Gigacage / primitive subspace) are never moved: DFG bakes their address.
  • Blocking is per block, not per byte. The three kinds of non-butterfly cells that share the Auxiliary subspace
    (JSPropertyNameEnumerator buffers, ScopedArguments overflow storage, StructureChain vectors) are 1 % of its bytes on the
    measured application, but each one keeps its whole block in place: a for-in over many shapes leaves 60 % of the candidates
    unevacuated (464 candidates, 185 evacuated in the reviewer's measurement). Giving them their own CompleteSubspace is a
    separate small change and not in this PR.
  • The pointer lookup is still this function's own copy of the arithmetic in ConservativeRoots::genericAddPointer (two
    probes and the owner table), and the set of scanned areas is assembled by hand to mirror gatherStackRoots /
    gatherVMRoots / the PinballCompletion constraint. Factoring one forEachCellPossiblyReferencedBy(pointer) out of
    genericAddPointer and driving both from the same gatherers with a custom sink would make it impossible for a future
    root to be missed; it means restructuring the hot conservative-scan path (its asserts on worldIsStopped() and
    isMarking(), its mark hooks, its per-collection precise-allocation snapshot) and was left for a follow-up.
  • Cost: one pass over block headers, one walk over live objects, one over the stack; proportional to the live heap, not to
    what moves (about 10 ns per live cell before this round; the mark-bit iteration removes the per-cell isLive() that was
    over half of it). Still not grouped: the directory bit-vector lock is taken per candidate block. There is no explicit
    cost/benefit threshold beyond "a size class must free at least one block". Acceptable once per deep idle, not per
    collection.
  • Parking the compiler threads is the one mechanism without a test that fails when it is removed (the detector runs
    compiles of functions that fold loads from constant objects during 40-odd evacuations and has not caught it; without the
    testing option a stale read from the intact old copy is harmless by construction).
  • The three tests that call $vm.evacuateAuxiliaryBlocks themselves assume that nothing else evacuates in between, so
    they fail when the whole suite is run with the testing option on.
  • The hook into VM::shrinkFootprintNow and the embedder glue (calling the shrink at deep idle) are not part of this PR.

Follow-up: the hook into VM::shrinkFootprintNow

Separate branch claude/gc-evacuate-on-shrink-footprint (needs #628 for VM::shrinkFootprintNow and this PR for the
mechanism): 2 lines in shrinkFootprintNow, Options::evacuateSparseAuxiliaryBlocksWhenShrinkingFootprint (default off) and
auxiliaryEvacuationMaximumOccupancy (0.5), and the fourth test auxiliary-evacuation-on-shrink-footprint.js. Text:
wk-evacuate-on-shrink-footprint.md. That is the configuration the -6.8 MB was measured in.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread Source/JavaScriptCore/heap/Heap.cpp Outdated
Comment thread Source/JavaScriptCore/heap/Heap.cpp Outdated
Comment thread Source/JavaScriptCore/heap/Heap.cpp
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
b0d74682 autobuild-preview-pr-635-b0d74682 2026-09-12 11:53:39 UTC
6a9b828f autobuild-preview-pr-635-6a9b828f 2026-09-12 09:32:04 UTC

@Jarred-Sumner
Jarred-Sumner marked this pull request as draft September 12, 2026 10:29
…arse blocks of a program at rest (prototype)

After an interactive session of a large bundled CLI application the Auxiliary subspace (butterflies) holds 2.8 MB of
live cells in 2,270 pages (8.9 MB): what the session's peak left behind is spread thinly over 900 blocks, and page-
granular decommit cannot help because nearly every page has a survivor. JSC does not move cells, but a butterfly is
special: exactly one pointer to it is known to the collector, its object's m_butterfly.

Heap::evacuateSparseAuxiliaryBlocks(maximumOccupancy), for a program at rest:
- candidates: MarkedBlocks of the Auxiliary subspace whose live bytes are at most maximumOccupancy of a block, except
  blocks an allocator is in the middle of, and except size classes where moving would not free a block (one candidate,
  or the survivors would fill as many blocks as they came from: otherwise every call would find the block that the
  previous one half filled sparse again). They are taken out of the allocatable set while cells move, so copies never
  land in them; those that are not evacuated in the end are put back;
- a walk over every live JSObject (mark bits per block, not isLive() per cell) finds, for each live cell of a
  candidate, whether it is the (non copy-on-write) butterfly of exactly one object. Cells that are not
  (JSPropertyNameEnumerator buffers, ScopedArguments storage, StructureChain vectors: 1 % of the bytes, but each keeps
  its whole block in place) stay, and so does their block;
- a conservative scan of everything the collector scans conservatively (this thread's registers and stack through
  callWithCurrentThreadState, active DFG scratch buffers and OSR exit side state, the stack copies of suspended JSPI
  computations) pins every candidate cell that a word points into or up to sizeof(IndexingHeader) past (where the
  butterfly pointer of an object without indexed storage sits), the arithmetic of
  ConservativeRoots::genericAddPointer. It also pins the storage of every object that such a word refers to, in a
  MarkedBlock or a precise allocation, since code that has the object at hand may hold a pointer derived from its
  butterfly. Raw pointers to a butterfly exist nowhere else: generated code loads m_butterfly each time and bakes no
  storage pointer for ordinary objects (ConstantStoragePointer is only made for typed-array vectors, which live in
  another subspace), inline caches and profiles hold offsets, structure IDs and cells, suspended generators hold
  JSValues. The collector is not running (PreventCollectionScope), compiler threads are parked at a safepoint while
  cells move (Heap::suspendCompilerThreads), where they hold cells only; if another thread is registered with the VM
  nothing is done;
- a block is evacuated only if every live cell in it can move: each is copied, whole cell (pre-capacity, out-of-line
  properties, IndexingHeader, vector or ArrayStorage move together), into a cell of the same size class allocated the
  ordinary way, and installed with JSObject::setButterfly. That barrier puts the owner into the remembered set, so the
  next collection marks the copy through it, exactly as when an old array's storage is reallocated. The old copy stays
  marked and untouched; the next full collection frees it and with it the block. A second call before that collection
  does nothing.

The function never asserts on its caller's state. Where it cannot run (no API lock or heap access, a collection in
progress, deferred or prevented collection, heap iteration, a second thread, the CLoop) it returns an empty result
whose `skipped` field says why; the header comment states what it asks of an embedder that keeps raw storage pointers.
Nothing calls it yet except $vm.evacuateAuxiliaryBlocks(occupancy) and the testing mode below; an embedder (or
VM::shrinkFootprintNow, in a separate change) follows it with a full collection.

Testing: Options::evacuateAuxiliaryBlocksAfterEveryFullCollection evacuates every Auxiliary block (occupancy 1) from
the first allocation that takes the slow path after each full collection, i.e. under arbitrary JS, JIT and C++ frames,
scribbles the old copies (0xbadbeef0: an unmapped cell as a JSValue, out of bounds as a length) and then re-walks the
heap asserting that no butterfly points into an evacuated cell and every butterfly is live.
$vm.evacuateAuxiliaryBlocks(occupancy, holder) runs it on demand; with a holder its native frame keeps a raw pointer
to the storage of holder.target, and nothing else of it, across the call. New tests: contents and array kinds survive,
eden and full collections and new allocations after a move, freed blocks (moves-butterflies); each pin rule on its
own: owner on the stack in a block or a precise allocation, a native frame's pointer into the storage and
sizeof(IndexingHeader) past it, and everything else moving (pins-storage-in-use); compiled loops that hoisted a
storage pointer over an allocation (under-jit-frames); natives that allocate while holding storage (under-allocating-
natives); blocks that could not be emptied stay usable (leaves-unevacuated-blocks-usable); compiler threads folding
loads from constant objects meanwhile (with-concurrent-compiles). Taking out, one at a time, the whole scan, the
pointer rule, either of its two probes, the owner rule, the precise-allocation lookup, the barrier or the restoring of
the allocatable bit makes at least one deterministic run line fail each (two for all but the last); parking the
compiler threads is the one mechanism no test catches (without the testing option a stale read from the intact old
copy is harmless). JSTests/stress with the testing option (5.8 k tests): release with useGenerationalGC=0, with
useGenerationalGC=0 + collectContinuously, and with slowPathAllocsBetweenGCs=20 + non-concurrent JIT fails what the
same modes fail without the option, plus the tests here that call $vm.evacuateAuxiliaryBlocks themselves (they assume
that nothing else evacuates in between) and, in the last mode, two timeouts (a whole-space evacuation after every
twentieth slow-path allocation makes it ten times slower). Debug + ASan, every fourth test, the first two modes: the
same failures as without the option, and one more test runs into the 20 minute limit under collectContinuously.

Measured with the footprint shrink of a large bundled CLI application calling it at deep idle (20 inputs / idle 265 s
/ 10 inputs / idle 265 s, five interleaved pairs): anonymous memory after the shrink 126.8 -> 119.9 MB (first idle)
and 130.2 -> 123.4 MB (second); pages holding a live cell 61.3 -> 54.8 MB; 600-690 of 640-730 candidate blocks
evacuated, 13.6-15.3 k butterflies, 1.3-1.4 MB copied, none pinned. Cost: two heap walks, 18-22 ms (up to 116 ms on a
loaded machine; 90 M instructions for 292 k live objects and 41 k moves in the jsc shell); whole-process user
instructions 34.95 G -> 35.05 G, inside the +-0.6 G spread between identical runs.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/gc-evacuate-sparse-butterflies branch from 6a9b828 to b0d7468 Compare September 12, 2026 11:25
@Jarred-Sumner
Jarred-Sumner marked this pull request as ready for review September 12, 2026 11:25

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — all three points from the earlier pass are addressed (precise-allocation objects now pin their storage via the sorted preciseObjects lookup, the dead collectNow hook is gone in favor of the allocator-slow-path trigger alone, and every new definition/call site is behind USE(BUN_JSC_ADDITIONS)). I re-reviewed the updated commit and didn't find further bugs; given this is a novel cell-moving path in a non-moving collector, a human pass on the safety argument is still worthwhile.

What was reviewed

  • The new preciseObjects binary search in noteCellOnStack: bounds and interior-pointer arithmetic match PreciseAllocation's cell/size layout.
  • evacuateAuxiliaryBlocksIfDue() now guards via reasonNotToEvacuateAuxiliaryBlocksNow(); its only caller (LocalAllocator::allocateSlowCase) requires !deferralContext, and the re-entry guard is m_isCollectionPrevented.
  • VM::forEachActiveScratchBuffer refactor: gatherScratchBufferRoots behavior is unchanged; ScopedLambda binds the local functor by reference, no lifetime issue.
  • restoreCandidates scope-exit runs on the OOM break path, so partially-evacuated candidate blocks regain canAllocate.
Extended reasoning...

Overview

The follow-up commit reworks Heap::evacuateSparseAuxiliaryBlocks and its plumbing in response to the three inline findings from the first review. noteCellOnStack now consults a sorted table of every live PreciseAllocation whose cell kind is a JSCell, so a stack word pointing at (or into) a precisely-allocated JSObject pins that object's butterfly via the object-on-stack rule. The collectNow(Sync, …) hook that was dead under DeferGCForAWhile was removed; the testing option now arms m_auxiliaryEvacuationIsDue from runCollectionEpilogue and fires only from LocalAllocator::allocateSlowCase (with !deferralContext), and evacuateAuxiliaryBlocksIfDue() re-checks reasonNotToEvacuateAuxiliaryBlocksNow() before running. Every new definition, member, and call site — in Heap.cpp, Heap.h, LocalAllocator.cpp, JSDollarVM.cpp, and the forEachConservativelyScannedBuffer helper in VM.cpp/VM.h — is now inside #if USE(BUN_JSC_ADDITIONS); the unguarded forEachActiveScratchBuffer is a behavior-preserving refactor of pre-existing code under ENABLE(DFG_JIT).

Security risks

This is memory-safety-critical: it copies live GC storage and repoints owners in a collector that otherwise never moves cells. The correctness argument rests on (a) exactly-one-owner detection via a full heap walk, (b) conservative pinning of every buffer the GC itself scans conservatively (thread state, DFG scratch buffers, checkpoint side state, PinballCompletion stacks), and (c) parking compiler threads at a safepoint during the copy loop. A miss in any of those is a use-after-move. The pin arithmetic mirrors ConservativeRoots::genericAddPointer (two probes, p and p - sizeof(IndexingHeader) - 1), and the precise-allocation lookup now closes the gap the first review flagged. Nothing runs by default: only $vm.evacuateAuxiliaryBlocks() or the off-by-default evacuateAuxiliaryBlocksAfterEveryFullCollection option reach it.

Level of scrutiny

High. Even though it is prototype-gated and off by default, the mechanism is subtle enough (interaction with concurrent compilers, HeapIterationScope, BlockDirectory canAllocate bit manipulation, and the embedder contract spelled out in the header comment) that a maintainer familiar with JSC's GC should sign off on the safety argument and on whether the enumerated conservative-scan sources are exhaustive for Bun's embedding.

Other factors

Six new stress tests exercise butterfly moves, native/JIT-frame pinning, unevacuated-block reuse, and concurrent compilation; the testing option additionally scribbles old cells and re-walks the heap to RELEASE_ASSERT no live object still points at an evacuated cell. The restoreCandidates makeScopeExit correctly restores canAllocate for candidates that were not evacuated, including on the ranOutOfMemory early break. The ScopedLambda refactor binds lvalue functors by reference and passes the existing const ScopedLambda& straight through, so no copy of the nonmovable type is attempted.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant