Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Generational nursery revisit (2026-07-13)
Branch: codex/perf-nursery-revisit
Base: dc34dbf6
Status: focused correctness repair and measurement instrumentation complete;
current-tree performance measurement is blocked by the one-shot terminal
reply/lifecycle path. The nursery remains opt-in and no default policy has
changed.
Why revisit it
The current performance tree already contains the complete
work/gc-nursery-productize ancestry through 4c531faf. The nursery is not an
unmerged prototype. PIT_GC_NURSERY=1 enables it; leaving the variable unset
keeps the legacy collector path. The old report showed useful allocation-heavy
Mach wins, but full-shop verification was known to fail. Before using its old
benchmark claims, this revisit treats verified full-shop startup as a hard
correctness gate and re-audits compiler/runtime stores added after the original
barrier audit.
The goal remains a per-actor generational collector with no per-object age bits, no shared young heap, and no loss of moving-GC or suspension semantics. Default-on policy is explicitly out of scope for this experiment.
Change inventory in this revisit
| Change | Kind | Intended effect / cost |
|---|---|---|
Barrier all fast paths in pit_mach_record_set_hashed | correctness | closes the reproduced old-record -> young key/value hole; one predictable nursery-enabled check on the fused Mach store path when the nursery is off |
| Focused hashed-store fixture at 8 KiB and 1 KiB | test | verifies insert, replacement, canonical heap key, movement, and contiguous card storage; test-only |
| Contiguous primary/scratch cards | memory | removes one C allocation/header per nursery actor; logical card bytes and scan work unchanged |
| Safe card-reallocation OOM fallback | correctness | prevents stale-card out-of-bounds writes; only runs after allocation failure at a major |
PIT_GC_NURSERY_INITIAL_KB | measurement lever | enables 1/2/4/8 KiB sweep; default remains 8 KiB, no steady-state field cost |
| Oversize-for-current-nursery direct pretenure | performance | avoids invoking an empty minor for a <4 KiB object that cannot fit the current 1/2 KiB nursery; placement remains old until growth makes it fit |
Five additional vm_stats fields | measurement | exposes actual/peak old block, logical card bytes, survivor peak/copy traffic; three new context counters cost 24 bytes per actor in this experiment |
Pre-sized vm_stats result | tooling correctness/memory | removes telemetry record growth and the resulting cross-interval allocation/GC contamination; same final result capacity |
PIT_BENCH_GC_DETAIL=1 output | tooling | opt-in exact GC deltas and scoped footprint data; no default benchmark output change |
| Expanded verifier failure detail | diagnostics | failure-only old-record/key/slot evidence; no successful-path allocation |
Nothing in this table changes the default collector policy. Runtime-performance acceptance remains pending correctness gates and fresh matched measurements.
Existing design and invariants
- Every actor owns an independent nursery block. Small objects below 4 KiB bump-allocate there; large objects and resize/growth destinations pretenure into the actor’s existing old/frame buddy block.
- A minor leaves old objects in place. Roots, active/pending frames, dirty old cards, heap frames, and the recently-settled old scan floor are scanned.
- Aging is address-encoded. Objects above the nursery watermark are fresh and can copy into the front half of a replacement nursery. Objects below the watermark have survived once and promote at their next survival. This adds zero object-header bits.
- The survivor valve reserves at least half of every nursery for new allocation. Overflow survivors promote rather than consuming the runway.
- Functions promote on first survival. A register code constant pool is C-side
state shared by closure instances; leaving a function young allowed a dying
closure to strand survivor pointers in that shared pool. Commit
4c531fafrepaired that historical aging failure. - A major is a union collection over old objects, frame-stack objects, and the non-empty nursery. It tenures every surviving young object, resets the watermark, resizes/clears the cards, and preserves actor-local collection.
- Frame-stack slots need no card because every minor scans them. Captured heap
frames do need a card and both Mach
putand native emittedputnow apply one. Grown records/arrays are pinned old and conservatively card their copied ranges. Allocation-free ordinary record/array mutations use the point write barrier. Builders with no intervening allocation are covered by the old scan floor; builders that invoke user code barrier each produced value. - Card-only collection is the nursery-on default. Scan-all remains available
through
PIT_GC_NURSERY_CARDONLY=0, andPIT_GC_NURSERY_VERIFY=1performs an O(old) post-minor check.
The later M5 native-store work repaired two additional emitted paths: pointer pushes into spare-capacity arrays and pointer writes into captured frames. Its fresh-context oracle passed 17 verified minors. It deliberately did not clear the independent full-shop blocker.
Historical performance evidence (not yet reaccepted)
The nursery commits recorded matched daemon-lane Mach measurements before the later compiler work exposed the missing fused-store barrier:
- first-survival card-only collection was near parity with nursery-off while
scan-all was roughly twice as slow:
gc_churn11.3 / 11.9 / 21.1 ms andrecord_new42.3 / 46.6 / 62.3 ms (off / card-only / scan-all); - second-survival aging at the 512 KiB cap measured
gc_churn11.3 -> 9.3 ms (-18%) andrecord_new41.0 -> 38.0 ms (-7%); - the retained churn oracle reduced promotion traffic 22,671 -> 143 KiB (-99.4%) and majors 150 -> 14; recorded minor pauses were p50/p95 16 us and max 39 us before aging.
Those numbers explain why the design is worth repairing, but they are not current-tree acceptance data. The current compiler emits additional fused and native store forms, benchmark code has changed substantially, and the old full-shop verifier claim is now known to have had a coverage hole. This branch will report fresh matched measurements rather than carrying the historical numbers forward as conclusions.
Current-tree blocker and localized repair
An untouched dc34dbf6 cold startup with
PIT_GC_NURSERY=1 PIT_GC_NURSERY_VERIFY=1 ./pit ps
still aborts. The daemon crash occurs about 2.7 seconds after the shop actor is
bred, during pit-compiler/analysis.cm, after roughly 265 collections. Running
in-process exposed the verifier evidence:
- offending old object: ordinary mask-15 record;
- object lies below
gc_old_scan_floor, so it requires a card; - fields
thenandlistboth contain pointers into the collected nursery; - stack:
analysis.collect/symbol_idwhile compiling the shop fleet.
The source audit identified the precise omission. Load-time Mach literal-field
fusion, added after the original nursery barrier audit, routes stores through
pit_mach_record_set_hashed. Its allocation-free hit and insert paths copied
the ordinary setter but omitted all three barriers (replacement value, inserted
key, inserted value). The unfused Mach setter, native setters, and generic
record setter already had them.
This branch adds those barriers and a fresh raw-context regression, registered at both the default 8 KiB initial size and the proposed 1 KiB measurement point, that:
- survives a record through two minors so it is old;
- stores a young array through the hashed Mach setter’s insert path;
- repeats through its existing-slot replacement path;
- inserts a canonical young heap-text key and young value;
- after each store, performs a verified card-only minor and proves roots plus old-record fields were rewritten to the same moved children/keys;
- in the 1 KiB arm, proves a roughly 1.6 KiB object pretenures without charging an empty minor.
The verifier diagnostic now prints the bad record’s keys/values and scan-floor position on failure. This is failure-only code and allocates no Pit values.
With the repair, a supported cold daemon startup under card-only collection and
the O(old) verifier passed the former abort, reached listener readiness, and
served ps. The shop actor had completed 967 collections without a verifier
failure. The two raw-context fixtures also passed at both initial sizes. This
is direct evidence that the three missing hashed-store barriers close the
reproduced full-shop hole.
An unsupported PIT_INPROCESS=1 attempt passed the old verifier failure and
later halted after ten cooperative suspensions with current frame has no function. The supported daemon did not reproduce that failure, so this report
classifies it as the existing in-process/suspension lifecycle limitation, not
as a nursery collector failure. It is not used as a correctness or timing
lane.
No performance result is accepted from this revisit. The supported focused self-host and benchmark paths were blocked before a measured row completed, as recorded below.
What 8 KiB and 512 KiB actually mean
The 512 KiB value is a growth cap, not an initial reservation. Each nursery-on actor currently allocates one 8 KiB buddy block. The block grows monotonically by powers of two when either survivor pressure exceeds one quarter of the block or eight minors occur at the current size. Light actors that do not fill 8 KiB never grow. Churning actors can reach 512 KiB.
The 512 KiB cap was chosen after second-survival aging reserved up to half the
block for survivors. At the cap it restores approximately 256 KiB of fresh
allocation runway. The earlier 256 KiB cap left only about 128 KiB and made the
medium-lived record_new ring cross minors too frequently.
Memory costs on a 64-bit build are therefore:
- current nursery buddy block: 8 KiB initially, at most 512 KiB by default;
- remembered-set tables: two bytes per 512-byte old region, or old-block / 256 (0.39% of old capacity). This branch places the primary and scratch halves in one contiguous C allocation, removing one allocator header/allocation per nursery actor (the previous two-allocation layout was especially wasteful when each logical table was only a few bytes);
- replacement-nursery transient: when survivors exist, a minor temporarily holds both the old and new nursery blocks, up to 1 MiB at the default cap;
- no new per-object header or slot bits;
- the cap itself reserves no memory.
The same audit found an OOM edge in the old layout. If a major changed the old block size and replacement card-table allocation failed, the runtime retained the stale smaller table; a later write barrier could index beyond it. A major has already tenured and emptied the nursery, so this branch disables/frees the nursery at that safe point and continues on the legacy collector. Initial nursery/card allocation failure likewise reports a zero cap. This is a low-memory correctness repair, not a benchmark optimization.
The old/frame block remains separate and already exists without the nursery.
old_block_bytes reports its current usable soft capacity, while the new
old_alloc_bytes reports the actual buddy allocation (the two can differ
during delayed shrink). old_alloc_peak_bytes records its high water.
The 512 KiB host default is not a plausible GBA policy: it exceeds the entire 288 KiB machine even before old space, frames, runtime state, code, and game memory. It is only the current unconstrained-host cap. A compact target needs a named, bounded profile whose cap is justified by the 8/32/128 KiB sweep (and may need first-survival promotion rather than a half-block aging valve). No target should inherit the host cap accidentally.
For compact targets, 8 KiB is too large as a universal light-actor floor. The
buddy allocator already supports 1 KiB blocks on 64-bit and 512-byte blocks on
32-bit. This branch adds an experiment-only
PIT_GC_NURSERY_INITIAL_KB override while retaining the 8 KiB default and the
independent cap. A 1 KiB nursery has only a 512-byte survivor valve, so larger
objects will promote on first survival; it is expected to grow quickly for
compiler/game-loop actors but reduce the never-churned actor floor by 7 KiB.
Objects that are below the general 4 KiB pretenure threshold but larger than
the nursery’s current block now allocate directly in old space; previously a
1/2 KiB experiment would run an empty minor before making that same placement.
Measurement fields
$runtime.vm_stats() already exposes allocation traffic, GC time/count,
nursery_bytes, nursery_cap_bytes, old_block_bytes, minor/major counts,
minor pause percentiles, current survivor bytes, and cumulative promoted bytes.
This branch adds:
old_alloc_bytes: actual current old/frame buddy block;old_alloc_peak_bytes: old/frame block high water;nursery_card_bytes: both current card tables combined;nursery_survivor_peak_bytes: survivor-region high water;minor_survivor_copied_bytes: cumulative nursery-to-nursery copying.
nursery_bytes is also the nursery block high water because the current growth
policy never shrinks it.
The experimental counters add 24 bytes to every PitContext in this branch
(two size_t fields and one uint64_t). This is acceptable for measurement,
not automatically for a GBA profile; the final recommendation must say whether
to retain, conditionalize, or remove them.
The benchmark driver gains an opt-in PIT_BENCH_GC_DETAIL=1 line per Mach and
native measured run. Delta fields (minor/major count and time, promotion, and
nursery-to-nursery copying) cover exactly the one counted thunk. Current block
sizes and lifetime peaks necessarily include frontend/native compilation done
earlier by the same benchmark actor; the output labels that scope and will not
be used as a clean workload-only actor footprint. Standalone one-shot actors
remain the authority for the 1 KiB light-actor floor.
While adding those fields, the telemetry path itself proved misleading:
vm_stats() created a minimum record, read its counters, then grew the record
several times while filling properties. Those post-snapshot allocations (and a
possible collection) appeared in the next measured delta. It now pre-sizes the
known 21-field result. The single allocation happens before counters are read,
and property insertion needs no resize, so consecutive snapshots no longer
charge telemetry growth to the benchmark interval.
Executed gates and blocked performance matrix
Completed on this exact branch:
make: passed; the C runtime and fixture built cleanly;meson test -C build nursery_barrier nursery_barrier_1k --print-errorlogs: 2/2 passed, verifier enabled in both arms;- supported cold daemon with
PIT_GC_NURSERY=1 PIT_GC_NURSERY_VERIFY=1: reached listener readiness and servedpsafter the shop actor completed 967 collections, passing the formeranalysis.cmold-record abort point.
The focused compiler suite did not return a test result. Three one-shot forms
all reached realization/compilation, but the terminal wrapper’s fixed 60-second
reply timeout killed the coupled target before its own 600-second test
realization budget could apply. A final ordinary nursery-on attempt reported
plan: 30 unit(s), compiling 7 and then exited at the wrapper timeout. This is
tooling blocker T12: the typed terminal request does not propagate a
realization-aware reply timeout. It is not evidence of either compiler-test
success or nursery failure.
The requested matched matrix was then attempted only through the supported
daemon CLI. After exact-worktree daemon cleanup, a fresh
PIT_GC_NURSERY=1 PIT_BENCH_GC_DETAIL=1 ./pit ps remained silent for more than
70 seconds and ended with daemon endpoint is temporarily unavailable; preserving it because its process is not confirmed dead; no matching process,
PID file, or endpoint remained. Per the experiment stop rule, no in-process or
custom benchmark workaround was substituted. Consequently:
- no current-tree off/on timing is reported;
- no 1/2/4/8 KiB initial-size or 8/32/128/512 KiB cap result is reported;
- the historical results above remain motivation only, not acceptance data;
- nursery default-on and compact-target sizing remain explicitly unapproved.
After T12/lifecycle is repaired centrally, run matched nursery-off/on arms and sweep initial sizes 1/2/4/8 KiB with caps 8/32/128/512 KiB. Target language rows:
gc_churn: die-young allocation throughput;record_new: medium-lived retained ring;binarytrees: macro allocation/record pressure;closureplus a control row: function-first-promotion and non-allocation regressions.
For every row record exact result, Mach/native time, allocation bytes/count, minor/major counts, promoted bytes, nursery-survivor copied/current/peak bytes, current nursery block, actual/peak old block, card bytes, and GC time/pause. The final decision should optimize the low-memory Pareto frontier, not simply choose the fastest host cap.
Source: plans/archive/perf-2026-07/perf-nursery-revisit.md