Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.

Final nursery sizing sweep protocol

Status: sweep executed on campaign checkpoint da2a46b5; results and disposition recorded below. The runner/protocol remain here for reproduction.

What is being selected

The experiment selects two policies, if the evidence supports both:

  • a compact-target policy minimizing actor floor and peak reserved memory;
  • an unconstrained-host policy, but only when a larger cap buys a material language-runtime win.

The nursery stays off as the control. Card-only collection and second-survival aging are fixed for the performance sweep. The verifier is a separate correctness gate because its O(old) scan is intentionally not representative.

Environment and process ownership

Nursery controls are read when each PitContext is created and are inherited from the shop daemon. Applying PIT_GC_NURSERY=1 to a client command while an old daemon is running does not change the measured actors. Every configuration must therefore:

  1. run ./pit down;
  2. prove that no exact-worktree $PWD/pit process remains;
  3. spawn the daemon through ./pit ps with the complete environment;
  4. run the benchmark through that same daemon;
  5. verify the reported cap_B before accepting the log.

scripts/run_nursery_config.sh ../../scripts/run_nursery_config.sh performs those steps and refuses both stale processes and overwritten logs. It does not kill processes. A survivor is a lifecycle failure to inspect, not something a measurement script should hide.

The AOT artifact cache is deliberately retained between configurations: the nursery environment does not alter generated code, and clearing it would make one arm pay unrelated compilation work. After the final make seed, run one discarded nursery-off prewarm over all selected rows. Do not edit sources, reseed, or clear .pit caches until the matrix is complete. Logs record the HEAD; the sweep should preferably begin from a checkpoint commit so untracked or dirty source cannot drift between arms.

Exact telemetry

With PIT_BENCH_GC_DETAIL=1, the benchmark now prints a lossless GC_EXACT line for each Mach/native row. Delta fields cover exactly the instrumented thunk: allocation bytes/count, collection/minor/major counts and time, promotion, and nursery-to-nursery copying. Current/peak nursery, old block, survivor, card, and minor-pause fields are explicitly labeled current, peak, or lifetime; these include frontend/AOT work performed earlier by the same benchmark actor and must not be mistaken for workload-only deltas.

The harness does not expose total C-side PitContext, scheduler, native code, or buddy-pool fragmentation per actor. Consequently this sweep can select nursery/old-space policies but cannot by itself prove a complete 4 KiB actor. That requires the allocator/category high-water instrumentation already noted in the Playdate plan.

Non-exhaustive adaptive matrix

First run the raw nursery verifier fixtures at the two extremes:

meson test -C build nursery_barrier nursery_barrier_1k --print-errorlogs

Then prewarm and discard one control run:

scripts/run_nursery_config.sh prewarm_off off - work/nursery-sweep \
  gc_churn record_new binarytrees closure arith_int

Stage A: choose the initial block

Hold the cap at 128 KiB and screen 1/2/4/8 KiB initial blocks. gc_churn detects repeated early growth cost; record_new detects survivor pressure; closure catches function-first-promotion regressions; arith_int is the non-allocating control.

for initial in 1 2 4 8; do
  scripts/run_nursery_config.sh "initial_${initial}_cap_128" \
    "$initial" 128 work/nursery-sweep \
    gc_churn record_new closure arith_int
done

Choose the smallest initial size that is within 3% of the best candidate on all three language rows and does not regress arith_int by more than 2%. Treat a hot actor reaching 128 KiB as expected; the initial-size decision is about the light-actor floor and early-growth overhead, not its final hot cap.

Stage B: choose the cap

Using the Stage A initial size, run 8/32/128/512 KiB caps over the complete allocation set:

for cap in 8 32 128 512; do
  scripts/run_nursery_config.sh "best_initial_cap_${cap}" \
    BEST_INITIAL "$cap" work/nursery-sweep \
    gc_churn record_new binarytrees closure arith_int
done

This is four cap points plus four inexpensive initial-size screens, not a 16-cell cross product. Keep every point in the report; a cap can be dominated even if it is locally fastest on one row.

Stage C: alternating confirmation

Select at most two finalists: the compact Pareto point and, only if different, the fastest justified host point. Confirm each twice with interleaved nursery-off controls (off_A, finalist A, finalist B, off_B, finalist A2, finalist B2). Each harness result is already a median of seven timed runs, but the repeated configuration guards against machine drift and daemon history.

Decision rules

  • Correctness: exact results must match across Mach/native, verifier fixtures must pass, and no fallback-to-zero cap_B is accepted.
  • Timing: reject a policy with a repeatable >2% regression on arith_int or a

    3% regression on closure; require at least 5% on an allocation-heavy row before spending materially more peak nursery memory.

  • Memory: compare nursery_B_lifetime + old_B_peak + cards_B_current, plus the transient replacement nursery (up to another current nursery block). Report this bound rather than nursery bytes alone.
  • Collection: favor lower major count/promotion only when total GC time and wall time agree. A reduced major count that copies much more survivor data is not automatically a win.
  • Compact target: 512 KiB is categorically unavailable for GBA-class memory. Prefer 8 or 32 KiB unless 128 KiB has a large, repeatable game-relevant win.
  • Default policy: do not turn the nursery on globally from microbenchmarks alone. A retained policy still needs the full suite, fuzz, and representative long-lived game actor under the selected cap.

2026-07-14 executed sweep

All completed rows returned the exact expected result in Mach and native. The control is the completed warmed nursery-off arm prewarm_off_escalated.log; despite its name it contains the complete five-row, seven-sample benchmark and exact telemetry. Timings are Mach/native milliseconds:

configurationgc_churnrecord_newbinarytreesclosurearith_int
nursery off7.27 / 5.7621.09 / 14.2578.15 / 48.1355.84 / 26.3426.69 / 9.90
1 KiB initial, 32 KiB cap31.53 / 36.2358.11 / 61.00181.87 / 172.9160.29 / 27.1426.89 / 9.58
1 KiB initial, 128 KiB cap9.66 / 9.6027.63 / 21.61not run57.59 / 26.8526.25 / 9.33
1 KiB initial, 512 KiB cap5.53 / 4.4919.21 / 14.3972.72 / 48.5757.69 / 26.9526.29 / 9.34

Relative to nursery-off, 32 KiB regressed gc_churn by 334%/529%, record_new by 176%/328%, and binarytrees by 133%/259%. The 128 KiB arm still regressed gc_churn by 33%/67% and record_new by 31%/52%. The 512 KiB arm improved gc_churn by 23.9%/22.0%, record_new by 8.9% Mach but regressed it 1.0% native, and improved binarytrees 6.9% Mach while regressing it 0.9% native. Its controls were closure +3.3%/+2.3% and arith_int -1.5%/-5.7%; without alternating confirmation, the latter is treated as host noise rather than a nursery claim.

Initial-size result

At a fixed 128 KiB cap every 1/2/4/8 KiB initial arm reached exactly the same steady state and collection traffic. The timing spread was small relative to the cap effect:

initial KiBgc_churnrecord_newclosurearith_int
19.66 / 9.6027.63 / 21.6157.59 / 26.8526.25 / 9.33
29.97 / 8.9327.35 / 21.7056.49 / 26.5726.09 / 9.37
49.97 / 9.1727.59 / 21.1858.90 / 27.2326.31 / 9.97
89.60 / 9.4528.67 / 21.0156.70 / 27.0726.27 / 9.34

Thus 1 KiB is the selected initial size for any future nursery experiment. It has no detected hot-actor penalty and reduces the never-grown per-actor block by 7,168 bytes relative to the old 8 KiB initial policy. This is a sizing decision for the opt-in mechanism, not approval to enable the nursery by default, and the current telemetry still does not measure the complete actor footprint.

Why the caps diverge

The exact counted-thunk collection evidence is:

row/configGC count Mach/nativeGC ms Mach/nativecopied B Mach/nativepromoted B Mach/native
gc_churn, off10 / 62.453 / 2.7720 / 00 / 0
gc_churn, 32 KiB585 / 58728.217 / 46.7919,296,912 / 9,457,0889,297,864 / 9,453,904
gc_churn, 128 KiB118 / 1174.562 / 5.3795,739,144 / 5,762,01643,656 / 15,032
gc_churn, 512 KiB20 / 201.136 / 1.224933,888 / 937,1440 / 2,104
record_new, off20 / 114.406 / 3.4640 / 00 / 0
record_new, 32 KiB1,024 / 1,02643.992 / 54.68416,211,560 / 16,472,39216,270,824 / 16,526,712
record_new, 128 KiB227 / 2278.441 / 10.61113,018,760 / 13,022,3361,752 / 9,056
record_new, 512 KiB35 / 371.717 / 2.7292,008,712 / 2,102,7681,752 / 4,608
binarytrees, off27 / 1511.988 / 9.9910 / 00 / 0
binarytrees, 32 KiB1,684 / 1,682112.449 / 139.53911,667,472 / 11,704,92811,298,976 / 11,361,520
binarytrees, 512 KiB86 / 845.477 / 8.8451,123,000 / 767,5521,039,704 / 666,192

The 32 KiB survivor valve repeatedly copies and then promotes approximately the whole allocation stream. At 128 KiB, promotion is controlled but repeated survivor copying still dominates. Only 512 KiB provides enough fresh runway for this aging policy to make minor collection cheaper than the legacy major collector. Fewer collections is not itself the mechanism: the 512 KiB arms often have more collections than nursery-off, but their total GC time is lower.

The 8 KiB cap did not reach the benchmark. After more than 60 seconds, the shop had performed 22,102 collections without reaching the builder actor. This is collector thrash, not an accepted timing result. It rejects 8 KiB as a process-wide policy for the current self-hosted fleet; it does not prove that a trimmed, precompiled console fleet could never use an 8 KiB nursery with a different first-survival policy.

Memory bound and disposition

The cap is not reserved at actor creation, but a hot actor grows to it and does not shrink. A survivor minor can temporarily hold an equal-size replacement block. Using the harness actor’s maximum sampled 16,777,216-byte old block and maximum observed cards, the lifetime sampled bounds were:

policyresident nurseryreplacement transientmax cardssampled old+nursery bound
off00016,777,216 B
32 KiB32,768 B32,768 B16,384 B16,859,136 B
128 KiB131,072 B131,072 B8,192 B17,047,552 B
512 KiB524,288 B524,288 B16,384 B17,842,176 B

The old-block peak is actor-lifetime and includes frontend/AOT compilation, so this table is a conservative harness bound rather than workload-only live memory. The nursery-specific incremental bound is exact: up to two blocks at the selected size plus cards. A 512 KiB hot actor may therefore need just over 1 MiB of nursery-specific memory during collection. That is unsuitable as a general N64 policy and categorically unsuitable for GBA-class memory.

Disposition:

  • keep nursery-off as the runtime default and as the campaign’s native performance baseline;
  • keep the verified nursery machinery and telemetry opt-in; use a 1 KiB initial block in any future profile experiment;
  • reject 8/32/128 KiB caps as a general current-policy performance win;
  • classify 512 KiB as a host-only Mach/GC-throughput option, not a retained cross-target language optimization: it helps Mach allocation rows and gc_churn native, but does not improve native record_new or binarytrees and lacks the planned alternating confirmation;
  • do not spend another campaign pass tuning the same aging policy. A compact revisit needs a representative precompiled game fleet and a different survival/cap policy, not another point in this host-only cap sweep.

Source: plans/archive/perf-2026-07/perf-nursery-final-sweep.md