Status: Phase 0 (a) done — Immix-first is NOT supported by the data. The alloc-vs-sweep-vs-mark breakdown (the Phase 0 (a) result immediately below) shows the per-object pools are not the bottleneck anywhere; the only GC rearchitecture the measured costs justify is reference counting — which was then prototyped and measured as a no-go (its coalescing store barrier taxes the common operation set, +7–26% on construction / property writes / allocation, for a narrow retained-set-only win; gc-reference-counting.md), closing the GC rearchitecture investigation: the broad win is already banked (incremental marking + lazy sweep), and the broad perf frontier is the JIT, not the GC. The Immix heap (Steps 1/3/4) is shelved. The rest of the doc is the design that the measurement redirected. Owner: the GC track (user-directed). Prerequisite reading: handbook/gc.md (the shipped collector) and gc-generational-major.md (why the in-place, non-moving major hit an architectural floor).
sample self-time breakdown of the two macros (ReleaseFast, JIT on,
--unhardened --allow=eval, ~15–20 s steady-state windows):
splay (large retained set) — ~4,700 leaf samples:
| bucket | ~share | top functions |
|---|---|---|
| mark | ~62% | markValue 2165, beginMajorCycle 312, markSymbolKeys 214, markString 166, drains ~50 |
| sweep / free | ~10% | sweepObjectsMatureBudget 200, deinitFields 140, finishIncrementalMajor 99 |
| alloc | ~2–5% | allocateObject 52, cons/flat string 25, makeDenseArray 23 |
| mutator / JIT | ~23% | runFrames, Shape.lookup, arith, property sets |
richards (low retention, compute / call-heavy):
| bucket | ~share | top functions |
|---|---|---|
| mutator / JIT / frames | ~85% | runFrames 1530, Bistromath ~360, CallFrame/FramePool ~295, JITted code (???) ~200 |
| mark | ~1–2% | markValue 20 |
| alloc | ~1% | allocateEnvironment 16 |
Three findings, all against the Immix scoping below:
- The per-object pools are not the bottleneck anywhere. Allocation is ~2–5%
on
splayand ~1% onrichards. Immix's headline win — bump allocation — addresses a sliver. Step 1 (the Immix heap) does not pay for itself, and its bulk-reclaim only touches the ~5% sweep-walk (deinitFields— half the sweep — frees per-object external resources and survives any heap layout). - Where GC dominates (
splay), it is the mark (~62%), not alloc or sweep — and the mark is reduced only by RC, not Immix (Immix still traces every live object). RC reclaimssplay's acyclic churn on refcount→0 with no trace and never re-examines the stable retained tree, cutting the ~62% to near-zero. RC, not Immix, is the GC lever. - Where GC does not dominate (
richards, ~85% mutator), no GC rearchitecture helps — the bottleneck is interpreter / JIT / call-frame overhead, the JIT track's domain, not the GC's.
Verdict: do not do Immix-first. The measured costs do not support a
block/line heap rewrite. The only GC rearchitecture the data justifies is
reference counting (the splay mark) — itself a large, separate change (a
coalescing inc/dec barrier on the interpreter's hot store path + a cycle
collector) that deserves its own scoping + measure-first Phase 0 (the barrier's
hot-path cost vs the mark saved), not the Immix foundation this doc led with.
And the scope of even that win is narrow: it helps retained-set workloads
(splay); compute-bound workloads (richards) are bottlenecked on the
interpreter/JIT, where GC changes do nothing. Cheap measurement, expensive
mistake avoided — the same discipline that closed the generational major.
The cross-engine RSS capture (bench-cross --macros) makes splay a ~5×
peak-RSS outlier — Cynic ~357 MB vs jsc 55 / v8 71 / hermes 68. It reads
like a GC retention bug (removed nodes kept alive by stale roots); it
measurably is not. Three checks, ReleaseFast --no-jit --unhardened --allow=eval:
- The live count plateaus exactly at the tree size. A fixed 2000-node
tree driven through 40 rounds of 80 insert+remove (size held constant,
__collectGarbage()each round) holds the post-GC object count flat at 192,192 =2000 × 96objects/node, round after round. Removed nodes' payloads are reclaimed; a false-root leak would grow the count ~304K over the run. It grows by zero. The card-marking dirty list is clean. - RSS scales linearly with node count — 2000→102 MB, 4000→195 MB, 8000→371 MB. A per-object cost with no fixed retained offset, not an accumulating set.
- The cost is the header.
@sizeOf(JSObject) = 408 B; splay's live set is genuinely ~768K objects (8000 × 96: oneSplayTree.Node+ 63 payload nodes + 32 array-exotics per tree node) → 313 MB of headers alone, +25 MB JSString headers +~20 MB string bytes ≈ the 357 MB observed. V8/JSC hold the identical 768K-object tree in 55–71 MB because their object is ~48 B: a fixed header plus out-of-line property/element stores reached by one pointer each.
So splay's RSS is bounded by the inlined-everything JSObject layout —
two StringArrayHashMaps (empty in shape mode) + inline_slots[4] +
elements + ~110 B of null-on-every-plain-object type-specific slots
(Promise / RegExp / Proxy / iterator state) — not by anything the collector
retains. The lever is shrinking the header: object-layout / IC-substrate
work, not GC. Same shape as the CPU finding above — the collector is no more
the bottleneck for splay's memory than the pools are for its CPU. Reaching
the ~70 MB field needs the V8-style out-of-line redesign of property/element
storage (the uniform-header + storage-substrate change Step 1 already
contemplates — challenges #2/#3); the cold type-specific slots are a smaller,
independent ~112 B win via the existing extension pointer. Neither is a
collector change, so the generational/RC scoping below does not move this
number.
The ~112 B win is now realized. Four cold per-kind clusters moved off the
base JSObject into JSObjectExtension (reached by the existing one
pointer), each read only when a get*/set* helper or a cheap inline
brand says the object is of that kind — a plain {key,left,right,value}
data object (splay's Node) has no extension and pays nothing:
- Iterator state (40 B) —
array_like_iter/map_set_iter/regexp_string_iter/iter_record/iter_helper. - RegExp state (24 B) —
regexp_source/regexp_flags(GC-marked, snapshot-serialized) +regex_perlex(recomputed on demand). - Key anchors (24 B) — the borrowed-key
JSStringroots; moving them behind the extension also let the minor-cycle skip predicate (objectScanSkippable) drop its directkey_anchorscheck, since every anchor write now setsneeds_internal_scan. - Proxy pointers (24 B) —
proxy_target/proxy_handler/proxy_target_fn, gated by a new cheap inlineis_proxybool brand so the property-op hot path (a proxy is brand-checked on every get/set) stays a single load rather than three pointer reads.
Result: @sizeOf(JSObject) 408 B → 296 B (−112 B, −27.5%). Measured
same-laptop, splay (8000-node tree) peak RSS 391 MB → 303 MB (−88 MB,
−22.5 %) — and the −88 MB drop equals the per-object header saving times
splay's live-object count (768K × 112 B ≈ 86 MB), confirming the RSS is the
header, exactly as diagnosed. test262 is byte-identical (0 regressions
across the built-ins + language trees, ~45 k fixtures) and the
--gc-threshold=1 verifiers stay green. Reaching the ~70 MB field still
needs the out-of-line property/element redesign (Step 1); Stage A is the
self-contained down payment.
Stage A moved the cold clusters (fields null on a plain object). The rest
of the header is the storage fields — empty on a shape-mode object but
still resident: properties/property_flags (40 B each), own_key_order
(24 B), elements (24 B), sparse_elements+is_sparse+sparse_length
(~28 B), inline_slots[4] (32 B), the then-separate overflow_slots
(24 B; now the overflow role of secondary_values), and shape (8 B).
A survey of the property/element substrate splits these into two moves along
one hard constraint: Bistromath (the JIT) bakes header offsets into
emitted machine code.
B1 — the dict-mode representation (JIT-safe, object-layout lane). SHIPPED.
properties, property_flags, and own_key_order are the dictionary
representation. They are empty on every shape-mode object (splay's Nodes,
all plain literals — a shaped object stores values in inline_slots, never
the bag), and the JIT deopts to the interpreter for dict-mode objects and
never reads these fields. So they moved into a lazily-allocated
DictStore reached by one pointer (originally dict_store, now the tagged
aux_store), allocated only on
demoteFromShape / first bag write, read through propsConst/propsMut
(and the flags/order pair). Shape-mode objects never allocate it, so the
is_pristine fast-death and the shape/IC hot path are undisturbed — the
same pattern as the extension sidecar. The global object is the one
always-dict-backed exception: GlobalBindings.bindToObject eager-allocates
its store so the allocator-free map() accessor stays valid.
Result: header 296 → 200 B (−96 B; 408 → 200 overall, −51%); measured
same-laptop, splay peak RSS 303 → 229.5 MB (−73.5 MB, matching 768K ×
96 B). test262 byte-identical (0 regressions across the built-ins +
language trees, ~45 k fixtures); test-fast + --gc-threshold=1 verifiers
green (no panic / poison / pristine violation). The gates held identically
to Stage A: both GC markers (markValue's iterOwnNamedKeys walk;
objectHoldsYoungSymbolKey / markSymbolKeys reach the bag/shape keys),
verifyRememberedSet (the write-barrier dirty contract on every bag
write), deinitFields, the pristine assert, and the snapshot comptime
classification + serialize.
B1b — the sparse indexed store (SHIPPED). sparse_elements (the
u32 → Value dictionary-mode indexed map, populated only on the rare
sparse array exotic) moved into the existing extension sidecar — reusing
that pointer, so it saves the full 24 B with no new field. The hot
is_sparse brand + sparse_length stay inline (they gate the dense/sparse
split on the element hot path and pack into padding), so the dense element
path never touches the extension. Sparse values ride the value write
barrier (setIndexed) exactly as before. Header 200 → 176 B; test262
byte-identical, --gc-threshold=1 verifiers green. The dense elements
vector is deliberately NOT moved — it is the array hot path (every dense
index read/write) and belongs to a perf-sensitive "packed JSArray"
redesign, not a cold move. So header now stands at 176 B (408 → 176 overall,
−57 %); the remaining big fields — inline_slots[4], the then-separate
overflow_slots (now secondary_values), and shape — are all JIT-baked
and only move under B2.
B1c — overlay the two secondary value-vector headers (SHIPPED).
Shape-mode named properties need an overflow vector only after the four
inline slots fill; dense indexed objects need an elements vector. The
common objects measured in splay use one role or the other, yet every
object currently pays for both 24-byte ArrayListUnmanaged(Value) headers.
secondary_values overlays those headers without moving either vector's
payload. It starts as the dense-elements header. On the first named-slot
spill, the elements header moves to an exact-sized cold auxiliary record
only if that same object actually has indexed storage, and
secondary_values becomes the overflow-slot header. The
secondary_is_overflow transition is monotonic, so
layout.object.overflow_items_ptr remains one direct JIT load even after a
later shape demotion. A tagged auxiliary store composes the rare mixed
elements-plus-dictionary case from stable child pointers; ordinary shaped
objects and ordinary dense arrays allocate no new sidecar.
This is a deliberately smaller step than JavaScriptCore's Butterfly, which lays property and indexed storage around one out-of-line allocation. SpiderMonkey's NativeObject and Hermes' JSObject instead keep direct slots plus separate out-of-line storage. The overlay keeps Cynic's existing element and compiled-slot access shapes, trading a cold allocation only for the uncommon object that uses both roles.
The representation is not ECMAScript-observable: §10.1 ordinary-object property operations and §10.4.2 array-exotic indexed semantics must remain identical. The implementation gate therefore includes both construction orders for mixed named/indexed objects, deletion and shape demotion, snapshot round trips, GC marking/deinit under allocation pressure, and the JIT layout proof. test262's property/array buckets and the SES hardened- primordial suite remain unchanged.
Result: the later packed-brand baseline falls from 168 → 144 B per
JSObject (−24 B, −14.3%). On the pinned remote box, interpreter Splay
falls from 201,856 → 183,664 KiB peak RSS (−18,192 KiB / −17.77 MiB,
−9.01%), matching the 768,000-live-object prediction (18,000 KiB) within
allocator/page noise. It also gets slightly faster: absolute p50
688.05 → 660.14 ms (−4.1%), while the interleaved five-pair comparison
reports 0.968× with 4.9% ratio spread. The adjacent interpreter micro
smokes (array_iter, property read/write, object allocation, constructor
array build) show no stable regression; their small ±3–8% movements carry
11.7–26.0% spread except Splay's low-noise result. ReleaseSafe unit tests,
the SES suite, allocation-pressure Array/Object/object-expression buckets,
and the JIT layout/Bistromath/Ohaimark proofs stay green.
B2 — the hot-field / uniform-header move (JIT lane, the actual ~75 MB
target). shape + inline_slots + the overlaid secondary_values
header are still resident hot-path storage. Moving them fully out-of-line
to reach ~90 B is where the 4× RSS outlier actually closes, but it is
machine-code-coupled: src/runtime/jit/layout.zig is the single source
of truth for object.shape / object.inline_slots /
object.overflow_items_ptr / inline_slot_cap, and emitLoadSlot /
emitStoreSlot plus four shape-guard emit sites in bistromath.zig read
them raw, guarded by an executable proof test (layout.zig "machine loads
match Zig reads"). B2 must: (1) redefine layout.object.* relative to the
out-of-line store, (2) rewrite the two slot-emit helpers + the shape-guard
sites to dereference the store pointer first (one extra indirection on the
hottest compiled read), (3) update the proof test, and (4) resolve the
pristine-death tension — a lazily-allocated slot store makes every
shape-mode object allocate on first property write, which would regress
exactly the profiled hot workloads unless the "empty baseline store" is
special-cased in assertPristineFieldsClean / deinitFields (mirroring how
elements_pooled returns a pooled buffer rather than freeing). That
JIT-substrate coupling plus the pristine redesign puts B2 in the JIT/IC
lane, not the object-layout lane; B1 is the self-contained increment that
ships first.
The shipped collector is a generational, non-moving, per-object-pool
mark-sweep: young/mature ArrayList(*Kind) lists per kind,
std.heap.MemoryPool slabs for three kinds (object / env / string-header) and
the bare allocator for four more, sticky-bit minors, card marking, incremental
major marking, lazy sweep. Two architectural weak points remain — and
gc-generational-major.md proved the in-place fix for
the second is foreclosed for a non-moving collector:
- Per-object pools. Allocation is a pool free-list pop; freeing is a
per-object pool destroy; objects of a kind are scattered (no contiguity);
freed slots are never returned to the OS. Allocation is on the hot path of
every
new, every closure, every array growth. - The mark is O(live), every major.
markValuere-traces the whole live set; on a large stable retained set (splay) that is ~34% of CPU re-marking an unchanged tree. The generational-major attempt to skip it is information-theoretically foreclosed non-moving (you cannot establish unreachability without a full trace or reference counting).
The modern answer to both — and the current state of the art — is LXR (Zhao, Blackburn & McKinley, Low-Latency, High-Throughput Garbage Collection, 2022, arXiv:2210.17175): an Immix region/line heap (fixes #1; adds locality + bulk reclaim + optional defrag) plus reference counting (fixes #2 — reclaim without tracing; "they depend on tracing, which in the limit and in practice does not scale") with concurrent tracing only for cycles. This doc scopes adopting that design, in two steps, measure-first.
The storage-survey is encouraging: the mark substrate is interface-stable and carries over nearly unchanged, because it operates at the Value/pointer level, not the storage level. KEEP:
- mark colour (
mark_color/live_color),marking_phase, the mark worklistsdrainMarkWorklist[Budget], the Dijkstra write barrier, the card-marking dirty list remembered set, weak-aware marking + the ephemeron fixpoint, the conservative native-stack scan, handle scopes, and the incremental safe-point hooks.
REPLACE — purely the storage substrate: the 7 per-kind young/mature
ArrayList(*Kind) pairs, the 3 QuarantinedPool/MemoryPool slabs + the 4
direct-allocator kinds, the per-kind allocate* functions, and the
sweepList/promoteYoungList type-dispatch + swapRemove walk. The mark phase
touches storage through a narrow interface — iterate live objects;
read/write a per-object mark_color/generation/dirty/pinned; free dead —
so the swap is contained in principle (the friction is below).
Replace the per-kind pools + lists with a single block/line heap:
- Blocks (e.g. 32 KiB) carved into lines (e.g. 128–256 B). Allocation bump-points into the current line's hole; an object that doesn't fit skips to the next hole/line; a fresh block is grabbed when the current fills.
- Line marks fall out of object marks (a line is live iff it holds a live object). Sweep reclaims empty lines/blocks wholesale (bulk, vs per-object pool destroy) and recycles partially-free blocks' holes.
- Large-object space (LOS) for objects above a line/block threshold (big
JSFunctions, largeJSBigIntlimb arrays, ArrayBuffer payloads) — separately managed, not bump-allocated. - Generational, non-moving. Keep the young/mature distinction at block granularity; the sticky-bit minors + card-marking remembered set carry over (they're mark substrate). No evacuation in Step 1 — only fully-empty lines/blocks reclaim; fragmentation is accepted and addressed later (Step 3). This keeps the non-moving contract (FFI, handle scopes, raw native pointers held across safe-points) intact.
Standalone wins: bump allocation (vs pool pop), bulk empty-region reclaim,
allocation locality. The mark is still a trace — Step 1 does not reduce
markValue; it fixes allocation, reclaim, and locality. splay's mark CPU is
Step 2's job.
LXR's core: reclaim most objects by reference counting (a coalescing inc/dec
barrier on pointer stores), reclaiming acyclic garbage immediately (no trace),
and run tracing only to collect cycles (infrequently, eventually
concurrently). For splay — an acyclic tree with acyclic payloads — RC reclaims
the churn directly and the trace all but disappears. This is the piece that
reduces the mark CPU rather than hiding it, and it is what makes the heap
scale. It is not optional here — see challenge #1.
This is not a clean port; Cynic's object model adds real friction:
- Objects own external heap resources. A
JSObjectowns a property map + accessor maps + element buffers;JSStringowns its byte payload;JSGeneratora register file;JSBigInta limb array. These need explicitdeiniton object death. Immix reclaims at line granularity — a dead object sharing a line with a live one is not individually reclaimed, so its external resources would leak until the line frees. Mark-sweep Immix must therefore still per-object-sweep-for-deinit (eroding the bulk-reclaim win), or pull those resources into the managed heap, or lean on RC for prompt per-object deinit (refcount→0). This is the strongest reason Step 2 (RC) is what makes the structure pay — and a reason Step 1 alone may underwhelm. - No uniform object header. Line sweep must identify an object's type +
size from its address. Today only four kinds share a
HeapKindtag (function/object/symbol/bigint);JSString/Environment/JSGeneratorare distinguished by Value tag / context, not a header tag. Immix needs a uniform header (type + size) on every managed object — a layout change to every heap struct. - Wide size variance.
JSObject~408 B, several headers ~48 B, bigJSFunctions / BigInts / ArrayBuffers far larger → the LOS split + the line-fit policy must be tuned to the real distribution (Phase 0 measures it). - The RC barrier cost. A coalescing inc/dec barrier runs on the interpreter's hot store path; it must not erase the win. Measure-first.
- Untrusted-input robustness. Every change is in the never-abort-the-host
path; a botched line-sweep or RC barrier is a UAF on adversarial JS. The
verifier-first discipline + gc-stress
--gc-threshold=1carry over, and (for the eventual concurrent cycle-tracer) extend to data races.
Before any rewrite, establish that the foundation pays and size the design:
- (a) Cost breakdown. Instrument the macros (
splay,richards,ctor_array_build) to split GC+alloc CPU into alloc (pool draw) vs sweep/free (per-object deinit + pool destroy) vs mark (markValue). If alloc+sweep is a meaningful slice, the Immix foundation pays directly; if it's almost all mark, Step 1 buys little and the value is concentrated in Step 2 (RC) — which reframes the sequencing (maybe RC-first on the existing pools). - (b) Object-size + external-resource census. Per kind: count, size distribution, and the fraction of objects owning external heap resources (the deinit-promptness exposure, challenge #1). Sizes the LOS threshold, the line size, and how badly #1 bites.
- (c) Bump-allocator microprototype. A standalone block/line bump allocator
vs the current
MemoryPool, on the measured size distribution — confirm the alloc-throughput delta in isolation before committing to the rewrite. - (d) Prior-art deep read. LXR + the Immix paper (Blackburn & McKinley 2008) — block/line sizing, the hole-finding allocator, line marking, the RC coalescing barrier, cycle collection — pulled into the design before building.
If Phase 0 shows the foundation doesn't pay (mark-dominated, or the external-resource friction outweighs the bump/locality win), stop and write that up — exactly the discipline the generational-major Phase 0 owed (it priced the marking saved but not the RSS traded).
- Immix heap, non-moving — uniform header + block/line bump allocator + LOS
- line-sweep, generational (block-level young/mature, card marking kept).
Gate: conformance byte-identical, gc-stress
--gc-threshold=1, an alloc/locality A/B on the macros.
- line-sweep, generational (block-level young/mature, card marking kept).
Gate: conformance byte-identical, gc-stress
- Reference counting on Immix — coalescing inc/dec barrier + a cycle
collector (backup trace). Gate: a differential against the tracing collector
(identical surviving set), gc-stress, the macro A/B (the splay
markValuefix). - (optional) Opportunistic evacuation — defrag fragmented blocks via limited copying; needs pointer-updating, so it breaks non-moving — gated separately, behind a full audit of raw pointers held across safe-points.
- (optional) Concurrent cycle tracing — move the backup trace off-thread (the LXR design), with the race-robustness gate (ThreadSanitizer + concurrent interleaving stress + the verifier extended to the concurrent barrier).
- Biggest blast radius in the engine — a heap-storage and collector
rewrite; months, not weeks. The keep/replace boundary (mark substrate stays)
bounds it, but Step 1 alone touches every
allocate*, the sweep, and every heap struct's header. - Value may be back-loaded. External-resource ownership (challenge #1) can make Step 1 (mark-sweep Immix) underperform until Step 2 (RC) lands; the win may not show until the second phase.
- The RC barrier may tax the interpreter's hot path (Phase 0 (c)/(d) sizes it).
- Non-moving contract is preserved through Steps 1–2; Step 3 (evacuation) breaks it and must be gated behind the raw-pointer audit.
- It may not pay at all. Phase 0 is the gate. A well-supported "the per-object pools are not the bottleneck — the mark is, and only RC moves it, so do RC-first / don't bother with Immix" is a valid and cheaper-to-reach outcome, and writing that up is a real result.