The "card-marking remembered set" + "drop the per-cycle typed-slot scan" this note describes as future work shipped (the scan-drop half — not aging). Every typed-slot write into a mature object now routes through the card-marking barrier (
JSObject.noteInternalSlotWrite/JSObject.anchorKey/ the value-awareHeap.writeBarrier), so the holder's young referents ride the dirty list (Root source 1,markAllPointerFields). Thefor (objects_mature) markObjectInternalSlotsloop — the O(mature) per-minor scan — is compiled out in ReleaseFast; Debug / ReleaseSafe keep it plus a verifier assertingobjectYoungTypedSlot(o) != null ⇒ o.dirty, so a missed barrier is a gc-stress assert, never a use-after-free. Splay (no-jit) ~1.38 → ~0.69 s laptop (~2×) on top of the sticky-bit win; conformance byte-identical (45335); test-fast + gc-stress clean. Seedocs/handbook/gc.md. Aging itself remains deferred — the analysis below still holds (~0% on this non-moving GC); only the scan-drop half of this note's plan shipped.
This note is about aging (keep survivors young for N cycles). The cost it set out to attack — the alloc/Splay gap — turned out to be a different mechanism: the minor cycle re-traced the entire mature set every nursery cycle, because
beginMinorCycleflippedlive_colorand made every mature object look unmarked. That was fixed by sticky mark bits — a minor cycle no longer flips; the mature generation keeps its marks and is never re-traced; the minor clears only the young generation. Seedocs/handbook/gc.md. Result: Octane Splay 5631 → 1417 ms (~4×), the macro alloc cluster trended down with it, conformance byte-identical, gc-stress clean (single + multi-threaded). The aging analysis below still stands (aging gives ~0% on this non-moving collector); the win came from the minor-mark fix, not aging or card marking.
The rooting blocker (below) was solved — a conservative native-stack scan (
origin/wip/gc-conservative-scan) makes the rooting complete-by-construction and passes the full gc-stress gate (all the finally crashers, Object/Array/Promise/class/expressions, single + multi, leak-clean, 45166 baseline). With rooting solved, aging (promote_age = 1, survive two minor cycles) was measured directly, isolated from the scan cost (build withconservative_scan = false). Result, interleaved min-of-15 A/B vsmain:
fixture main aging-only aging's win object_alloc 30.0 30.2 +1% ctor_array_build 489.7 531.7 +9% (slower) string_concat 41.7 40.9 −2% json_stringify 39.2 39.4 +1% promise_chain 16.0 15.7 −2% Aging delivers no speedup (−2% to +9%). This note predicted aging would close the ~5×
ctor_array_buildgap; instead it is slower. A win that large would be unmistakable even under load.Why the premise was wrong: the "Root cause" section below assumes mature garbage is expensive ("only an
O(mature)full cycle reclaims it"). But Cynic's GC is non-moving with promote-by-relink — tenuring a survivor isO(1)(a list move, address unchanged), mature garbage costs RSS, not CPU, and full cycles are rare (the minor cycle absorbs the churn). So keeping temps young longer (aging) only adds young-marking work without saving meaningful reclamation — net ≈ neutral. Premature promotion is cheap on this collector; the big-engine analogy (JSC-Riptide / SpiderMonkey, where promotion copies) does not transfer.Consequences:
- The whole "generational aging → alloc-churn medals" thesis does not pan out for Cynic. Do not re-chase aging for perf.
- The conservative-stack-scan work (branch
gc-conservative-membership) is a real correctness/robustness asset — it eliminates the native-rooting UAF class and adds alloc-provenance diagnostics — but it costs +12–31% on alloc-heavy fixtures and buys no perf, so it is not worth merging for a perf-focused engine as-is. Keep it parked; revisit only if the rooting-UAF safety (not speed) is wanted.
- The no-hashmap membership was tried — it did not remove the tax.
isLiveHeapPointerwas rewritten from a per-GClive_ptr_sethashmap rebuild to a chunk-range + per-slot allocated-bit test (commit0f046b4): correct (compiles; the finally crashers + Object/Promise gc-stress stay green single + multi), but the bench A/B is unchanged (object_alloc +30%, string_concat +21%, ctor_array_build +14%, promise_chain +12%). So the cost was never the data structure — it is per-allocation liveness tracking itself (the rebuild and the allocated-bit are bothO(allocs)on alloc-heavy code) plus the scan. Near-neutral would require a zero-per-alloc design: no liveness tracking at allocation time, validating each candidate per stack word during the scan (few words, cheap) via chunk-range + alignment + a safe structural check distinguishing a real object from a free-listNode— a different, subtler implementation (per-kind sentinel) and the only open path to a mergeable rooting model.- The alloc-fixture gaps (
object_alloc,ctor_array_build,string_concat,promise_chain) are bottlenecked on per-allocation cost and GC marking cost, NOT premature promotion. A medal effort should profile and attack those (cheaper object allocation, less per-minor-cycle marking), which is a different investigation.The historical analysis below is retained for context, but its central premise is superseded by this measurement.
Status: barrier landed; aging still blocked — on rooting, not the
barrier. The card-marking design below shipped as the dirty-container
write barrier (commit 4ce56ff): a complete-by-construction barrier +
generic marking that replaced the per-edge-class remembered set. That
solved the barrier whack-a-mole. Aging on top is still blocked, and a
later investigation (below) pinned why: a second, deeper whack-a-mole
in native young-object rooting that the barrier does not address.
With the complete barrier in place, the remaining aging blocker is a
class of unrooted young objects in native re-entrant code — a young
heap value (a Promise reaction handler, a capability promise, a
value-thunk) held in a native local across a GC-triggering allocation
or a .then / cap.resolve re-entry, swept by the next minor cycle and
its slab slot reused (the crash surfaces later as a stale reaction
calling a reused slot — e.g. a runPromiseReaction → reused
ShadowRealm-trampoline segfault, which is a symptom of slot reuse, not
a ShadowRealm bug).
The decisive observation: incremental HandleScope patches diverge.
Adding a scope to chainFinallyResult (rooting wrapped / thunk_ctx
/ thunk_fn + the subclass cap) fixed Promise/prototype/finally/ subclass-reject-count but its extra allocations shifted GC timing and
broke species-constructor + subclass-resolve-count (both green on
main). Adding a second scope to promiseThen's subclass path then
re-broke subclass-reject-count. Each correct rooting fix shifts timing
and exposes (or re-exposes) more swept-handler windows faster than it
closes them. Per-site rooting cannot be proven complete by inspection —
exactly the property that made the per-edge-class barrier unworkable.
The correct fix is complete-by-construction rooting, not per-site
scopes — the rooting analogue of card marking. The principled option
is conservative native-stack scanning (JSC-style): during GC, treat
any aligned, pool-resident pointer found in the native call stack /
registers as a root. Cynic's pooled heap makes the "is this a live heap
pointer" membership test cheap, and it can layer under the existing
precise HandleScopes as a completeness backstop — a missing scope then
costs a retained-too-long object, never a use-after-free. With that
backstop the aging recipe (below) becomes safe; without it, aging (or
any GC-timing shift) re-opens the divergent rooting cascade.
This blocker is out of scope for an incremental rooting fix and is the real prerequisite for aging + the alloc-churn medals.
This note's lower half remains the card-marking diagnosis (now shipped) so the next effort starts from the root cause, not from scratch.
The completeness backstop above is now implemented (collectYoung
"root source 3" in src/runtime/heap.zig): each minor cycle builds an
exact young-pointer membership map (young_ptr_set) from the per-kind
young lists, then scans the current thread's native call stack
(@frameAddress() → pthread_get_stackaddr_np), marking any aligned
word — bare *T or NaN-boxed Value — that exactly matches a live
young pointer. Exact-pointer matching means it can only ADD a root
(retain a real object), never trace garbage, so it cannot introduce a
use-after-free; a missing precise HandleScope degrades to
retained-too-long. It is gated on Heap.scan_native_stack, which
Realm.collectGarbageYoung sets from active_native_fn != null — the
only window an unrooted young pointer can sit in a native local — so
pure-JS minor cycles (every young pointer reachable via the frame
stack) pay nothing. macOS today; other hosts fall through (the precise
scopes remain primary). With this backstop in place the aging recipe
below is now safe to attempt — it's the next step, separately gated.
Cynic's interpreter-tier cross-engine compass
(bench-cross-results.md) shows allocation
churn is the engine's clearest relative weakness against the JIT-off
big-engine interpreters (JSC JSC_useJIT=0, SpiderMonkey
--no-baseline --no-ion, Hermes):
| fixture | cynic | best peer | gap |
|---|---|---|---|
ctor_array_build |
~600 | ~110 | ~5× |
object_alloc |
~34 | ~16 | ~2× |
class_instantiate |
~36 | ~22 | ~1.6× |
promise_chain |
~19 | ~10 | ~1.9× |
(Cynic is competitive-to-leading on the compute / IC-cached fixtures —
arith_loop, prop_access, method_call, tail_recursion. The gap
is specifically alloc-bound work.)
Root cause: premature promotion. promoteYoungList
(src/runtime/heap.zig) tenures every
young survivor into the mature list on its first collectYoung.
A per-iteration temp (the new Point(...) and the [p.x, p.y] in
ctor_array_build) survives the one minor cycle it's live for, gets
promoted, then dies — but as mature garbage that only an
O(mature) full cycle reclaims. The big-engine interpreters keep that
churn in cheap young space via a real generational nursery with
aging: an object must survive N young cycles before tenuring, so
short-lived churn is swept by the cheap minor cycle and never pollutes
mature space.
Aging itself is the textbook-correct fix. The blocker is that Cynic's remembered-set / write-barrier coverage is a hand-maintained patchwork that is only sound because survivors promote immediately.
The minor cycle (collectYoung) finds mature→young edges from two
sources:
- the remembered set — populated by the barriered store helpers
(
storeProperty/storePropertyWithFlags/ the typed-setter funnels) for named property edges; and - an unconditional per-cycle scan of every mature container's
typed internal slots (
markObjectInternalSlots,markFunctionInternalSlots, the environment-slot loop, generator internal slots, promise reactions).
This split works today only because a young object never persists
across a cycle: at end of collectYoung the remembered set is
consumed and cleared (for (remembered) setInRememberedSet(false); clearRetainingCapacity()), and the comment there spells out the load-
bearing assumption — "an edge created before this cycle and not
re-stored is still safe because the referent was promoted to mature
this cycle."
Aging removes that crutch (a survivor stays young), which exposes that the "every mature→young edge is tracked" invariant is enforced per edge class, by hand — and the set of edge classes is large and keeps growing. The two attempts hit them one crash at a time:
- Attempt 1 added aging + remembered-set retention (keep a
remembered entry whose referent stayed young).
verifyRememberedSettripped: a container tenured this cycle while its referent aged forms a fresh mature→young edge the barrier never recorded (young→young at store time). Fix = promotion-time remembering (mechanism 3): after promotion, scan the newly-tenured tail of each mature list and remember any with a young referent. With all three mechanisms the verifier passed and Object / Array / class / language / deep-graph gc-stress were clean. - Attempt 2 (with mechanism 3) then crashed
built-ins/Promisegc-stress single-threaded in thefinallyreaction path (finallyThenReaction → chainFinallyResult → promiseThen, swept referent). The finally / capability /promise_storereaction machinery is another edge class the per-class predicates didn't cover.
The pattern is whack-a-mole — each fix surfaces the next uncovered
edge class (typed slots → named slots → promotion-time → promise
reactions → capability records → finally fields → the realm
value_stack → …). Whack-a-mole is evidence the design is wrong,
not the implementation. Per-edge-class enumeration cannot be made
complete by inspection.
Make "find every mature→young edge" complete by construction with a card-marking remembered set, then layer aging on top.
- Divide the mature heap (or the whole address space the pooled object
/ function / environment / string slabs live in) into fixed-size
cards (commonly 512 B or one OS page). A
card_table: []u1(or[]u8for cheaper stores) has one dirty bit per card. - A card is dirty if any pointer-bearing field of an object in that card may have been written since the last minor cycle.
Replace the per-edge-class in_remembered_set bookkeeping with one
uniform barrier at every store of a heap value into a heap object:
store(container_field, value):
*container_field = value
if isMature(container) and isYoungHeapValue(value):
card_table[cardOf(container)] = dirty
Crucially this is edge-class-agnostic — it fires on any write
into a mature object regardless of whether the field is a named slot,
a typed internal slot, a promise reaction, a capability record, or a
finally field. The funnel already exists in spirit (the typed-setter
helpers / storeProperty*); card marking unifies them and lets the
hand-maintained in_remembered_set flag + the four-arm rebuild in
collectYoung be deleted.
collectYoung():
beginMinorCycle() // live_color flip
markRoots() // realm roots, handle scopes, etc.
for card in dirty_cards:
for obj in objectsInCard(card):
markAllPointerFields(obj) // generic, every field
drainMarkWorklist()
sweepYoung(); promoteOrAge()
clearDirtyCards() // re-armed by the barrier next cycle
markAllPointerFields walks every outgoing pointer of the object
(the union of what markValue's per-type arm + markObjectInternalSlots
already enumerate). Because the dirty-card scan is generic, a young
referent reached through any field of a dirty mature object is found
— no per-edge-class predicate, so no whack-a-mole and no
verifyRememberedSet arm to keep in lockstep.
With a complete remembered set the aging policy is trivial and was already proven correct in the spike:
JSObject.age: u8(start 0).promote_age = 1("survive two minor cycles": age 0→1 on the first survival, tenure on the second).promoteYoungList(..., age_survivors: bool): in a minor cycle a live survivor withage < promote_ageincrementsageand stays in the young list; otherwise it tenures (resetage = 0). A full cycle passesage_survivors = falseand tenures outright (no aged- young objects exist after a full cycle, so card state is moot there).- Only
JSObjectages initially (it dominates churn); functions / environments tenure on first survival. Extending aging to them later is free — card marking already covers their edges.
This is exactly the JSC-Riptide / SpiderMonkey generational shape and is why their interpreters win the alloc-churn fixtures above.
Complete the per-edge-class barrier instead of card marking. Keep the remembered set + the three aging mechanisms, and add a predicate arm for every remaining edge class (promise reactions, capability records, finally fields, value_stack, …). Rejected: it's the whack-a- mole the two attempts demonstrated. You can never prove by inspection that the enumeration is complete, and every new pointer-bearing field added to a heap type silently re-opens a use-after-free under aging. Card marking is complete-by-construction; that property is the whole point.
Escape analysis / scalar replacement is a complementary, not
substitute, technique: it eliminates the non-escaping allocation
subset (ctor_array_build's array) entirely, but does nothing for
escaping churn, and is a larger compiler analysis. Production engines
do both; card-marking aging is the foundational half and should land
first.
- Card table + barrier, no aging. Add the card table, route the
existing store funnels through the dirty-mark, and switch
collectYoung's mature-root discovery from the remembered set + per-cycle typed-slot scan to the dirty-card scan. This is behaviour-preserving (still promote-on-first) and must keepgc-threshold=1green single- and multi-threaded before proceeding. Deletein_remembered_setand the consume-and-clear / retention machinery. - Aging. Add
JSObject.age+ theage_survivorsparameter; age in minor cycles only. Re-run the full gate. - Extend aging to functions / environments once (1)+(2) are stable, if the churn data justifies it.
zig build test-fast— including updated promote-timing unit tests (aging shifts tenure from one cycle to two; ~4 Heap tests need the two-cycle update, and add the promotion-time / retention regression tests from the spike).gc-threshold=1ReleaseSafe (cynic-test262-safe) acrosslanguage/expressions,built-ins/{Object,Array,Promise},language/statements/class, both--threads=1and the default multi-threaded harness — the multi-threaded path matters because a remembered-set/barrier change interacts with the per-worker realm pool.verifyRememberedSetis retired by card marking, but the0xaafree-poison still catches a swept-but-referenced object.--leak-check, fullzig build test262(≥ baseline pass count), and the bench A/B onctor_array_build/object_alloc/class_instantiate/promise_chain.- Revert on any gc-stress crash — the discipline that caught both prior attempts.
The whole alloc-churn cluster at once: ctor_array_build (the ~5×
outlier), object_alloc, class_instantiate, promise_chain. The
card table costs one bit per card (negligible) plus a barrier branch
already paid by the current in_remembered_set check.
- Promotion + the consume-and-clear / aging hooks:
promoteYoungList,collectYoung,collectFullinsrc/runtime/heap.zig. - The invariant a card table replaces:
verifyRememberedSet(the exact per-type edge classes it checks — objects' slots/bag/elements, function properties, environment slots). - Store funnels to route through the barrier: the typed-setter helpers
storeProperty/storePropertyWithFlags(seehandbook/gc.md"Typed-setter helpers").
- Per-cycle typed-slot scan the card scan subsumes:
markObjectInternalSlots/markFunctionInternalSlotsand the environment/generator loops incollectYoung.