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

ChangeKindIntended effect / cost
Barrier all fast paths in pit_mach_record_set_hashedcorrectnesscloses 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 KiBtestverifies insert, replacement, canonical heap key, movement, and contiguous card storage; test-only
Contiguous primary/scratch cardsmemoryremoves one C allocation/header per nursery actor; logical card bytes and scan work unchanged
Safe card-reallocation OOM fallbackcorrectnessprevents stale-card out-of-bounds writes; only runs after allocation failure at a major
PIT_GC_NURSERY_INITIAL_KBmeasurement leverenables 1/2/4/8 KiB sweep; default remains 8 KiB, no steady-state field cost
Oversize-for-current-nursery direct pretenureperformanceavoids 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 fieldsmeasurementexposes 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 resulttooling correctness/memoryremoves telemetry record growth and the resulting cross-interval allocation/GC contamination; same final result capacity
PIT_BENCH_GC_DETAIL=1 outputtoolingopt-in exact GC deltas and scoped footprint data; no default benchmark output change
Expanded verifier failure detaildiagnosticsfailure-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 4c531faf repaired 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 put and native emitted put now 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, and PIT_GC_NURSERY_VERIFY=1 performs 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_churn 11.3 / 11.9 / 21.1 ms and record_new 42.3 / 46.6 / 62.3 ms (off / card-only / scan-all);
  • second-survival aging at the 512 KiB cap measured gc_churn 11.3 -> 9.3 ms (-18%) and record_new 41.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 then and list both contain pointers into the collected nursery;
  • stack: analysis.collect / symbol_id while 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:

  1. survives a record through two minors so it is old;
  2. stores a young array through the hashed Mach setter’s insert path;
  3. repeats through its existing-slot replacement path;
  4. inserts a canonical young heap-text key and young value;
  5. after each store, performs a verified card-only minor and proves roots plus old-record fields were rewritten to the same moved children/keys;
  6. 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 served ps after the shop actor completed 967 collections, passing the former analysis.cm old-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;
  • closure plus 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