Status: CLOSED — not viable for a non-moving collector. Phase 0 measured GO and Phase 1 (the mature→mature remembered set) was built + fully validated; then a careful read of the Phase 2 marking mechanism found a fundamental obstruction, and the effort was reverted. See Outcome immediately below — the rest of the doc is the historical record of how we got there. Owner: the GC track. Prerequisite reading: handbook/gc.md (sticky mark bits, card marking, incremental major mark, lazy sweep — all shipped).
Building Phase 2 surfaced a fundamental obstruction: a non-moving mark-sweep collector cannot reclaim old-generation garbage without re-tracing the old set. The generational-incremental major — "skip re-tracing the unchanged old set, keep its sticky marks" — does not deliver a cheap reclaiming major. Concretely, the three realizations all fail:
- Keep all sticky marks (no colour flip) ⇒ a no-op. Between full majors
every live mature object is already
live_color(the last full major marked it; promotion marks survivors; allocate-black marks new objects). A dead mature object — a removedsplaypayload — keeps its sticky mark too. A major that only adds marks from (dirtied-old ∪ roots) marks nothing new and frees nothing; it is provably identical to skipping the major and floating the garbage. - Flip only the dirtied set ⇒ reclaims the wrong objects.
splay's actual garbage is a removed node, whose own pointers never changed — only its parent's did. The removed node is therefore clean, never flipped, never reclaimed. The dirtied set is the rotated/relinked nodes, which mostly stay live. - Flip everything, prune the trace at clean subgraphs ⇒ use-after-free. A clean old object C can point to D whose only path is through C (the C→D edge predates the last full major, so C is clean). Stopping the trace at C leaves D unmarked → swept while reachable. Marking a clean subgraph live without visiting each object needs region/page contiguity — a moving/region heap, which Cynic's per-object pools are not.
The root cause is information-theoretic, not implementation-specific: deciding an object is unreachable requires either tracing every path to it (a full old-gen trace) or reference-counting it. Sticky marks supply neither for the clean old set, so a major that skips the clean old trace cannot observe old garbage dead.
Why Phase 0's GO was wrong. Phase 0 measured the dirtied-old fraction
correctly (0.6% on splay), but a small dirtied set does not enable cheap
reclaim. "Skip the 99.4% re-trace" is "float the garbage in it" — the
marking-CPU saved is traded for RSS, which Phase 0 never measured — and the 0.6%
re-scan is itself the no-op above. The data was right; the inference ("⇒ a
~100–250× reclaiming major") was not.
Prior art (the focused survey Phase 0 owed). The state of the art for prompt
old-object reclamation without full-trace cost is LXR (Zhao, Blackburn &
McKinley, Low-Latency, High-Throughput Garbage Collection, 2022,
arXiv:2210.17175): it reclaims old objects
with reference counting, lays the heap out as Immix regions (a region
reclaims without per-object tracing), and uses concurrent tracing only for
cyclic garbage. Its own framing — "they depend on tracing, which in the limit
and in practice does not scale" — is exactly the markValue wall here. The
three escapes it uses are the only known ones: region/line structure
(Immix), reference counting (immediacy), or concurrency (hide the trace
off-thread). A non-moving, per-object-pool, single-threaded mark-sweep has none.
Textbook generational mark-sweep (Jones & Hosking, The GC Handbook) agrees:
cards make the minor cheap; the major is a full old-gen trace.
What this means for Cynic. The major's mark CPU on a large stable retained set is a near-fixed cost for the current heap. The incremental major already bounds the pause (~800 ms → ~1 ms); the CPU floor is inherent to a non-moving full trace. The real levers — each a major architecture change, none needing this remembered set — are an Immix-style region heap (region-granularity reclaim), reference counting for the old gen (immediacy + a cycle collector), or concurrent/background marking (move the trace off the mutator thread — the route the multi-threaded production engines took, which Phase 0 flagged as a poor fit for a single-threaded mutator). Revisit only behind one of those.
Two of the three were subsequently scoped, measured, and closed. An
Immix-first region heap was killed by a sample breakdown showing the
per-object pools are not the bottleneck (allocation ~2–5%; the mark is ~62% on
splay) — gc-immix-rearchitecture.md. Reference
counting — the one lever that breakdown justified, since RC cuts the mark the
others can't — was then prototyped: its coalescing store barrier taxes the
common operation set (+7–26% on object construction / property writes /
allocation) for a narrow, retained-set-only win — a measured no-go,
gc-reference-counting.md. Net across all three: the
GC's broad win is banked (incremental marking + lazy sweep bound the pause
~800 ms → ~1 ms); the mark-CPU floor stands on a non-moving collector, and the
broad perf frontier is the JIT, not the GC.
Phase 1 disposition. The mature→mature remembered set — the dirty_old bit
on the four container types, old_dirty_list, rememberOldStore, the 7 routed
barrier sites + the minor carry-over + the major-termination reset — was
implemented and fully validated (byte-identical by construction, test-fast
green, gc-stress --gc-threshold=1 clean across
Map/class/Promise/Set/WeakRef/FinalizationRegistry), then reverted: it is
machinery for a major that cannot pay off. The barrier-site inventory in this
sentence is the reconstruction guide if a future region/RC effort wants it.
The major cycle re-traces the entire live set, O(live), from the roots,
every time. For a workload with a large stable retained set — the Octane
splay tree, a long-lived cache, an app's module graph — most of that
re-tracing re-marks objects that did not change since the previous major.
A sample profile of splay at the bench iteration count puts markValue
at ~34% of CPU and GC+alloc together at ~60% — dominated by the major
re-walking the unchanged tree. The pause is already ~1 ms (incremental mark)
and the sweep is sliced (lazy sweep), so the residual cost is marking
work, not pause: the GC does the same O(live) traversal each cycle.
Sticky mark bits already eliminated the minor re-trace (mature marks
persist across minor cycles; collectYoung is O(young + dirty)). The
major still re-walks all mature on every cycle.
Make the major incremental in the generational sense: re-mark only the old objects that changed since the previous major (plus the roots), and keep the sticky marks on the unchanged old subgraphs. The unchanged old set is never re-walked.
The minor's win was "don't re-trace mature for a young collection." This is the same move one level up: "don't re-trace unchanged mature for a major."
The card-marking dirty list records mature→young edges only (it exists to
let collectYoung find young referents held by mature objects without an
O(mature) scan). It does not record mature→mature edges. splay's
tree rotations rewrite node.left / .right — mature→mature pointer changes
— which the current barrier ignores (correctly, for the generational
purpose; a mature→mature store creates no old→young edge). A generational
major needs exactly those edges: an old object whose pointers changed is the
only old object that can make a previously-unmarked object newly reachable.
Sketch from memory; deepen before committing to a mechanism:
- V8 (Orinoco) — incremental + concurrent old-space marking with a marking write barrier and a remembered set; majors are sliced and do not restart the trace from scratch within an incremental cycle.
- JavaScriptCore (Riptide) — concurrent generational mark-sweep; the Eden (young) is scavenged, the old space marked; a store barrier feeds the remembered set.
- SpiderMonkey — generational + incremental; a store buffer tracks tenured→nursery, the major is sliced.
- The textbook mechanism — a card table (or per-object dirty bit) over the old generation; at a major, re-scan only the dirty cards plus the roots, leaving clean old objects' marks intact. Jones & Hosking, The Garbage Collection Handbook, ch. on generational + incremental.
Open question the survey must answer: do the production engines actually skip re-marking the clean old set per major, or do they rely on concurrency (marking off-thread so the re-trace cost is hidden, not removed)? If the latter, the lever for a single-threaded mutator is different (concurrency, not generational-incremental), and #3 should pivot.
-
The generational-major invariant. Every object reachable after the major must be marked. A clean (un-re-scanned) old object O keeps its mark; its referents were marked by the previous major and are still marked (sticky). The only way O can reach a newly live object is through a pointer that changed — and a changed pointer dirties O (the new mature→mature barrier), so O is re-scanned. So the invariant holds iff the mature→mature barrier is complete — the same completeness contract the card-marking verifier already enforces for mature→young, extended to mature→mature. The verifier-first gate (Debug/ReleaseSafe full re-scan + assert) carries straight over.
-
Floating garbage. An old object that became unreachable keeps its sticky mark (it's never re-traced, so it's never observed dead) and is not swept. A generational major therefore leaks until a periodic full major (re-trace everything, clear the sticky old marks) reclaims it. The interleave — N generational majors between full majors — is a tunable, exactly like the minor/major backstop (
gc_threshold) today.
- A mature→mature write barrier on every old-object pointer store —
strictly more barrier work than today (which skips mature→mature). For
splay(rotation-heavy mature→mature) that's a real per-store cost; it could erase the marking win. Measure the mature→mature store rate before building — if it's high relative to the marking saved, the trade is a loss. - Floating garbage between full majors → higher peak RSS; bounded by the full-major interval.
- Real complexity + correctness risk in the most safety-critical engine path.
- Phase 0 — measure, decide go/no-go. Instrument two counters: (a) the
mature→mature pointer-store rate per major (the new barrier's cost), and
(b) the dirtied-old fraction per major (live set that actually changed —
the marking saved). The change only pays if (b) ≫ (a)-equivalent. Run on
splay/richardsand a retained-cache micro. Also do the prior-art survey above. If Phase 0 says no, stop here and write that up. - Phase 1 — mature→mature card marking. Extend the dirty-list barrier to record mature→mature stores (reuse the dirty list + the existing verifier, re-keyed to assert "a clean old object holds no edge to a newly-marked object"). No marking-policy change yet — just the remembered set, validated byte-identical.
- Phase 2 — the generational major. Re-mark from (dirtied-old ∪ roots); keep sticky old marks on clean objects; add the periodic full major for floating garbage. Differential-gate it: a generational major's surviving set must equal a full major's on the same heap.
- Gate throughout: gc-stress
--gc-threshold=1+ the differential (generational vs full major) + conformance byte-identical + thesplay/richardsA/B.
Instrumented the mature→mature pointer-store path (writeBarrierRemember's
non-young early-return: at that point the container is already known mature
and the value a heap pointer, so a non-young value is exactly a
mature→mature edge) with two per-major counters — raw m2m stores, and
distinct m2m-dirtied containers (the set a generational major would
re-scan). Ran splay (40 iters, to clear the one-time setup and reach the
retained-set plateau) and richards (canonical 100) under --no-jit --unhardened --allow=eval --gc-stats, ReleaseFast. The instrumentation is
throwaway — measured, then reverted; not merged.
splay — the retained-set case (decisive win). At steady state (live set plateaued at ~913k objects plus ~600k immutable payload strings):
| metric | per major | vs live set |
|---|---|---|
| live objects a full major re-walks | ~913,000 | 100% |
| m2m pointer stores | ~107,000 | — |
| distinct m2m-dirtied objects | ~5,700 | 0.6% |
Only ~5,700 distinct objects change between majors — the actively-rotated
splay-tree nodes (the tree is ~8k nodes, each rotated ~19× per major;
stores/distinct ≈ 19). The other 99.4% of the old object set, plus every
immutable payload string, is unchanged. A generational major re-scans ~5,700
nodes + roots instead of ~913k objects + ~600k strings: a ~100–250×
reduction in marking work at steady state. This is precisely the cost the
splay profile flagged (~34% markValue) — almost all of it re-marking the
unchanged retained tree.
richards — the churn / low-retention case (no benefit, no harm). Live set
is only ~612 objects; m2m stores are huge (~195k/major) but land on just 288
distinct objects (~690 stores each — the scheduler rewriting task/queue
links). There is nothing to save (612 objects mark in microseconds). The
barrier sees 195k stores, but those stores already run the expensive
isYoungHeapValue discrimination today (that early-return is where the
counter sits), so the incremental barrier cost over today is one dirty-bit
load per store + 288 dirty-list appends — negligible against richards'
interpreter work, and the marking it can't help was never a problem.
Cost/benefit gate: passes decisively. Marking saved (splay ~100–250×) ≫ barrier cost (a dirty-bit load that is ~90% already paid, plus ≤distinct appends, repeats collapsed by the dirty bit). The mature→mature barrier reuses the existing dirty-list + card-marking + verifier substrate, so it does not erase the win (the lead risk below).
Prior-art open question — answered: do not pivot to concurrency. V8 (Orinoco), JavaScriptCore (Riptide), and SpiderMonkey hide the major's old-space re-trace with concurrent (off-thread) marking, not by skipping the clean old set — because they are multi-threaded runtimes where a GC thread is available and concurrency hides the whole re-trace, not just the clean-old portion. The "sticky marks across majors + card-mark the old gen + periodic full major" mechanism here is the classic generational mark-sweep of the literature (Jones & Hosking) that those engines passed over in favour of concurrency. For Cynic the calculus inverts: the mutator is single-threaded by design (one safe-point at the interpreter back-edge), so concurrency would mean a new GC thread + concurrent-barrier races on every field — a large, safety-critical departure. Generational-incremental-major needs none of that; it extends the card-marking barrier and keeps sticky old marks, reusing the incremental-mark + lazy-sweep substrate already shipped. Concurrency is the wrong fit for a single-threaded engine; the generational path the multi-threaded engines skipped is exactly Cynic's lever — and the measurement says it pays. Proceed to Phase 1.
- The mature→mature barrier erases the win — Phase 0 cleared this: the
dirtied-old fraction is ~0.6% on
splayand the barrier reuses the existing dirty-list path (the costlyisYoungHeapValuediscrimination is already paid today). Re-check if a future barrier change adds per-store cost. - The invariant is subtler than the mature→young one — the verifier-first discipline is mandatory, not optional.
- The macros may not all benefit — confirmed by Phase 0:
splayis the clear win (retained tree, 0.6% dirtied);richards/navier/cryptoretain little, so they see nothing — but also pay no meaningful barrier cost. Scope the claim to retained-set workloads. - Concurrency might be the real answer — Phase 0 answered no for Cynic: the production engines went concurrent because they are multi-threaded; Cynic's single-threaded mutator makes generational-incremental (which reuses the shipped substrate) the right lever, not off-thread marking. See the Phase 0 result above.