[JSC] Heap::evacuateSparseAuxiliaryBlocks: move butterflies out of sparse blocks of a program at rest (prototype, nothing calls it by default) - #635
Conversation
Preview Builds
|
…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.
6a9b828 to
b0d7468
Compare
There was a problem hiding this comment.
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
preciseObjectsbinary search innoteCellOnStack: bounds and interior-pointer arithmetic matchPreciseAllocation's cell/size layout. evacuateAuxiliaryBlocksIfDue()now guards viareasonNotToEvacuateAuxiliaryBlocksNow(); its only caller (LocalAllocator::allocateSlowCase) requires!deferralContext, and the re-entry guard ism_isCollectionPrevented.VM::forEachActiveScratchBufferrefactor:gatherScratchBufferRootsbehavior is unchanged;ScopedLambdabinds the local functor by reference, no lifetime issue.restoreCandidatesscope-exit runs on the OOMbreakpath, so partially-evacuated candidate blocks regaincanAllocate.
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.
One commit, stacked on #632 (its tests use
$vm.markedBlockStatistics()from there, and both add$vmfunctions at the same place). No dependency on #628. Nothing here runs unless the testing option or the$vmfunction 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$vmfunctions at the same place. It does not depend on #628. ThisPR'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-linehook into
VM::shrinkFootprintNow(which only exists on #628's branch; main only has upstream'sshrinkFootprintWhenIdle()) 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):
At a fresh prompt (no session yet) it is worth 0.7-1 MB.
What
Heap::evacuateSparseAuxiliaryBlocks(maximumOccupancy), for a program at rest:maximumOccupancyof a block, except blocks an allocator is in the middle of, and except size classes where movingwould 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.
separately) finds, for each live cell of a candidate, whether it is the (non copy-on-write) butterfly of exactly one
object.
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.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
skippedfield says why. The header comment spells out what it asks of anembedder that keeps raw pointers into out-of-line storage.
$vm.evacuateAuxiliaryBlocks(occupancy = 1, holder)runs it on demand and returns the counters,skippedand the time.With a
holder, the native frame keeps a raw pointer to the storage ofholder.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:
m_butterflyeach time it needs it and bakes no storage pointer for ordinary objects:ConstantStoragePointeris only made for typed-array vectors (DFGConstantFoldingPhase), which live in another subspaceand are never touched here; the butterfly constants that DFG/FTL do embed are JSCellButterfly (copy-on-write) cells;
hoisted the load; C++ that keeps a
Butterfly*local across a call that can allocate), active DFG scratch buffers andOSR 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 throughcallWithCurrentThreadState(so the wholeof this function's own frame is above the scanned stack top), the buffers through one
VMfunction thatgatherScratchBufferRootsnow 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,pandp - sizeof(IndexingHeader) - 1: the butterfly pointer of anobject 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 ispinned, 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 abutterfly 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 asafepoint, where a plan holds cells only.
Barrier. The copy is installed with
JSObject::setButterfly, i.e. the ordinary write barrier: the owner goes into theremembered 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::evacuateAuxiliaryBlocksAfterEveryFullCollectionevacuates every Auxiliary block (occupancy 1, no fixed-pointrule) 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)
isAtomAligned/ block-set test).RELEASE_ASSERTon caller state: one predicate, shared with the testing hook, and askippedreason in the result.$vm.evacuateAuxiliaryBlocks: exception check aftertoNumber, occupancy clamped to [0, 1] (NaN = 0).#if USE(BUN_JSC_ADDITIONS)like the declarations;memcpySpan; include order.collectNowhook and its comment are gone.m_isEvacuatingAuxiliaryBlocks,lastAuxiliaryEvacuation()and its member are gone;durationis set before thereturn and exposed;
m_isCollectionPreventedstays as a skip condition becausepreventCollection()does not nest andan allocation slow path can run inside a caller's
PreventCollectionScope(Heap::deleteAllCodeBlocks).gatherScratchBufferRoots(aScopedLambda, no allocation);callWithCurrentThreadState; the redundantp - 1probe isgone. Not done: factoring the pointer lookup out of
ConservativeRoots::genericAddPointerand driving the scan fromgatherStackRoots/gatherVMRootswith a custom sink (touches the hot conservative-scan path; see "Not done").gc(); evacuateround on.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.
Tests
jsc shells, release and debug + ASan (
ASAN_OPTIONS=detect_stack_use_after_return=0), of this branch (b0d746820eb7) and ofmain(cf1b36ec8703); base lists retaken in the same session. "Testing mode" =--evacuateAuxiliaryBlocksAfterEveryFullCollection=1.check-webkit-styleon 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 thatthe collector stays generational):
auxiliary-evacuation-moves-butterflies.jsauxiliary-evacuation-pins-storage-in-use.jssizeof(IndexingHeader)past an object's out-of-line propertiesauxiliary-evacuation-under-jit-frames.jsgc(), takes the slow path and evacuates: element loads, out-of-line loads, out-of-line stores on plain objects and on functionsauxiliary-evacuation-under-allocating-natives.jsslowPathAllocsBetweenGCs21 (LLInt) and 11 (eager JIT) and with collectContinuously: slice, concat, spread, push, splice, unshift, Object.assign, JSON, fill + growauxiliary-evacuation-leaves-unevacuated-blocks-usable.jsauxiliary-evacuation-with-concurrent-compiles.jsTeeth. Release builds with one mechanism taken out at a time, the six tests above run against each:
pushresult in the eager-JIT line; its collectContinuously line is not deterministic and does not count)heldStorageStayedfalse)pprobe onlyp - sizeof(IndexingHeader) - 1probe onlysetButterfly(plain store){}where an array was)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:
main, same mode without the testing optionrecursive-try-catch.js(intermittent "Used too much heap", fails onmainin 2 of its 3 other runs)--useGenerationalGC=0auxiliary-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)--useGenerationalGC=0 --collectContinuously=1auxiliary-evacuation-moves-butterflies.js--slowPathAllocsBetweenGCs=20 --useConcurrentJIT=0async-from-sync-throw-closes-and-rejects.js(85 s onmainin this mode),marked-block-decommitted-pages-are-not-read.js: the mode is ten times slower by constructionJSTests/stress, debug + ASan, every fourth file (1,458):
main, same mode without the testing option--useGenerationalGC=0--useGenerationalGC=0 --collectContinuously=1array-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.jsandsampling-profiler-bound-function-name.js; with the cheaper walk they finish.)On top of #628's branch with the
shrinkFootprintNowhook (the follow-up below): all 18 run lines of the seven tests pass;release default 35 / 5624 (base 36 / 5615), testing mode +
useGenerationalGC=036 / 5623 (35 / 5616: onlymoves-butterflies), + collectContinuously 38 / 5621 (37 / 5614: moves-butterflies and
codeblock-aging-ftl-idle.js, a timingtest that fails 5/20 on the base shell in that mode).
Not done / open questions
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.
(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.
ConservativeRoots::genericAddPointer(twoprobes and the owner table), and the set of scanned areas is assembled by hand to mirror
gatherStackRoots/gatherVMRoots/ the PinballCompletion constraint. Factoring oneforEachCellPossiblyReferencedBy(pointer)out ofgenericAddPointerand driving both from the same gatherers with a custom sink would make it impossible for a futureroot to be missed; it means restructuring the hot conservative-scan path (its asserts on
worldIsStopped()andisMarking(), its mark hooks, its per-collection precise-allocation snapshot) and was left for a follow-up.what moves (about 10 ns per live cell before this round; the mark-bit iteration removes the per-cell
isLive()that wasover 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.
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).
$vm.evacuateAuxiliaryBlocksthemselves assume that nothing else evacuates in between, sothey fail when the whole suite is run with the testing option on.
VM::shrinkFootprintNowand 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 forVM::shrinkFootprintNowand this PR for themechanism): 2 lines in
shrinkFootprintNow,Options::evacuateSparseAuxiliaryBlocksWhenShrinkingFootprint(default off) andauxiliaryEvacuationMaximumOccupancy(0.5), and the fourth testauxiliary-evacuation-on-shrink-footprint.js. Text:wk-evacuate-on-shrink-footprint.md. That is the configuration the -6.8 MB was measured in.