Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Pit language performance campaign — 2026-07
Restart note: the authoritative branch disposition, later experiments, rejected-arm index, memory ledger, and next-work sequence are in
compiler-optimizing-handoff.md. This file remains the detailed audit ledger for the experiments it covers.
This is the audit ledger for the contained performance experiment based at
81685c37. Nothing in this campaign is merged upstream or pushed. A change is
only a candidate for later human review after it passes correctness, language
semantics, runtime-memory, and repeatable micro/macro benchmark gates.
Objective and priorities
- Make AOT native programs as fast as the language contract permits.
- Improve Mach where the same compiler proof or a Mach-specific lowering can remove executed work.
- Keep runtime memory viable for older consoles. Measure every persistent table, frame, code-size, and heap tradeoff; treat 4 MiB (Nintendo 64 class) as a real design constraint and 288 KiB (Game Boy Advance class) as an extreme profile that may require a deliberately smaller runtime fleet.
- Compiler time is observational only during this campaign. Large temporary compiler memory is acceptable for experiments because compilation can be ahead of time, but it must be recorded and must not leak into generated artifacts or runtime state accidentally.
- Only language execution is in scope. Actor message latency, Nota/Wota, package/build latency, and unrelated system services are excluded.
Language and runtime invariants
- Function arity is at most four. Missing arguments are null-filled. Excess dynamic arguments disrupt; statically certain excess arguments may be a compile error.
- Parameters and
defbindings are immutable. - Stoned records/arrays/text cannot be mutated. Compiler optimizations must preserve the required stone checks at every mutation boundary.
- Pit-to-Pit calls must retain a reifiable, GC-walkable continuation/frame at every actor suspension or safepoint boundary. A native C module may use its native stack internally, but general compiled Pit execution may not depend on an unfreezable native call stack.
- Numeric overflow, division, modulo/remainder, null, disruption, closure capture, and preemption behavior remain part of the observable contract.
- No new semantic mcode operation is introduced silently. Target-internal Mach fusion or native lowering may change without changing the portable mcode contract, but is still documented here.
Measurement rules
- Pit Mach is compared primarily with interpreter/bytecode tiers.
- Pit native is compared primarily with LuaJIT and native/JIT/AOT tiers such as BEAM JIT, OCaml, C#, Node/Bun, and C where comparable.
- Timings use warmups plus at least seven samples and report the median. A claimed win should survive repeated fresh-process runs and exceed normal noise. Small wins need generated-code evidence.
- Every benchmark has an independent expected result where practical. Cross- lane equality alone is insufficient because both backends may consume the same poisoned compiler fact.
- Microbenchmarks identify mechanisms. Shootouts decide whether the mechanism matters in composed programs.
- Record/allocation benchmarks must retain their anti-sinking, aperiodic target selection, and unknown-shape properties unless the experiment explicitly measures a static-record ceiling.
- Record executed Mach instruction counts, native helper calls/code shape, allocation bytes/count, GC count/time, peak live heap when available, frame slots, generated code size, and persistent metadata added by an experiment.
Baseline
Git baseline: 81685c37 (codex/perf-next-baseline). It includes:
- exact-use-span transport for LICM constants, a correctness repair that does not itself claim a runtime win;
- Playdate nan32 design documents and a standalone representation fixture, not a converted runtime.
The previous integrated chart in docs/plans/perf-next.md is historical input,
not the fresh campaign baseline. This ledger will record a newly measured table
before accepting performance deltas.
Acceptance gates
For each experiment:
- State the compiler/runtime hypothesis and the expected generated-code change.
- Capture before/after focused timings and at least one affected shootout.
- Inspect optimized mcode and/or QBE/native assembly to verify the intended change actually occurred.
- Run targeted semantic regressions, Mach/native result oracles, and the full default suite. Run fuzz-sensitive gates for optimizer/lowering changes.
- Record runtime memory and code-size deltas. Persistent cost must be justified against constrained targets; a host-only speed profile must be explicit.
- Keep failed experiments in this ledger, but delete their implementation.
Experiment ledger
| ID | Hypothesis | Status | Runtime result | Memory/code tradeoff | Disposition |
|---|---|---|---|---|---|
| B0 | Fresh baseline is reproducible and result-correct | complete | 1,918/1,918 default tests; fresh rows below | no campaign runtime cost beyond T2 tooling | accepted campaign baseline |
| N1 | Loop-carried numeric values can remain unboxed between observable boundaries | accepted locally | composed raw-on/off: mandelbrot -87.7%, float_math -84.4%, spectralnorm neutral (-0.24%), fannkuch byte-identical | no runtime state; composed assembly mandelbrot -29.0%, float_math -4.8%, spectralnorm -2.7%, fannkuch identical | keep; exact parameter inference removed the old spectral code-shape penalty |
| N2 | Point-specific constants can remove integer carrier loads/stores | accepted locally; full/fuzz green | fannkuch native 199.78 -> 191.03 ms (-4.4% matrix); spectralnorm 119.26 -> 116.63/118.12 ms (-2.2%/-1.0%) | fannkuch IL -1.09%/asm -5.48%; spectralnorm IL -0.46%/asm -2.16%; no runtime state | keep; exact-span substitution with conservative use audit |
| N3 | Analyze integer ranges in large functions instead of enforcing a compiler-throughput cutoff | accepted locally | root d_field 14.21 -> 12.05 ms (-15.2%); record_new 23.9 -> 21.02 ms (-12.1%); fannkuch restored to 200.36 ms | d_field/record_new/fannkuch assembly smaller; compiler time ~4-5x on affected bodies; no target metadata/state | keep; compile speed explicitly out of scope |
| N4 | Reuse exact block-local is_num fallthrough facts in native raw arithmetic | accepted locally as a correctness-preserving refinement | fannkuch 209.57 -> 200.36 ms and spectralnorm 192.83 -> 182.27 ms; exact results | fannkuch assembly 124,967 -> 118,423 B; spectralnorm 269,674 -> 255,965 B; compiler maps only | keep; N1 still gated on spectral profitability |
| N5 | Treat hoisted function declarations as callable bindings in closed-world parameter inference | accepted locally; full/fuzz green | spectralnorm Mach 280.11 -> 202.70 ms (-27.6%), native 181.96 -> 132.92 ms (-26.9%); composed matrix 202.67/130.65 ms | spectralnorm optimized instructions 1,432 -> 444, IL -52.0%, assembly -52.3%, frame slots smaller; no added target state | keep; pure compiler proof repair |
| N6 | Carry closed-world immutable parameter facts into final range/guard cleanup | accepted locally; full/fuzz green | spectralnorm Mach 201.99 -> 199.04 ms (-1.5%), native 131.21 -> 128.74 ms (-1.9%) | IL -8.2%, assembly -7.5%, exact-fact bytes -22.9%; at most four compiler-only fact words/function, stripped from canonical mcode | keep; pure compiler proof consumption |
| N7 | Decode a dynamic numeric value and its validity in one representation-specific branch tree | accepted locally; full/fuzz green | current spectralnorm native 128.74 -> 124.65 ms (-3.2%); old-context controlled spectral about 182.5 -> 178.5 ms | current spectral IL -2.3%, assembly -1.6%; no metadata/frame/heap state; shared emitter code only | keep; exact sticky-null semantics in nan64/f32/fixed |
| N8 | Give self-TCO loop phis structured entry/backedge facts and choose raw lanes per value | keep; composed and validated | tco_self 30.561 -> 5.312 ms (-82.6%); exact result; nonselected shootouts unchanged; forced resume 49/49; full 1,918/1,918; fuzz 3,733/3,733 | generated IL -11.1%, asm -6.5%; no target state; 32-byte host compiler fact array for the two-arg fixture | raw-int guarded decrement plus raw-double overflow accumulator |
| N9 | Build natural-loop value identities and retain exact pointer-free phis across cold inline-call regions | accepted isolated; full/fuzz green | spectralnorm 120.032 -> 78.544/78.001 ms (-34.6%/-35.0%); fannkuch and loop_nested byte-identical; forced resume/GC 48/48; full 1,918/1,918; fuzz 3,733/3,733 | spectral IL -3.67%, asm -3.06%, linked __text -1,140 B; no frame/heap/persistent target state; compiler-temporary CFG only | keep narrow; >6 eligible phis reject to avoid demonstrated spill/code cliff |
| A1 | Raise inline size limits and admit immediate-child calls through level-1 captures | rejected; implementation deleted | spectralnorm native 172.10 -> 172.20 ms; Mach 305.97 -> 295.53 ms | IL 13,355 -> 15,578 lines (+16.6%); native allocation traffic 2,656 -> 26 KiB | not worth static growth without speed |
| A2 | Inline exact-type array reads instead of calling the dynamic load helper | rejected twice; implementation deleted | current-tree retry: array_read -0.49%; spectralnorm ~0.3% average and not reproducible | array_read IL +2.70%/asm +1.42%; spectralnorm IL +3.47%/asm +2.38% | micro win does not justify replicated control flow/ROM |
| A3 | Share one exact-array arm in the native index helper | accepted locally; strengthened proof gate | composed spectralnorm on/off/on 119.44/121.18/118.41 ms; array_read 25.01/25.12 ms | versus N2 alone spectralnorm IL +888 B/asm +577 B; no dynamic memory/state | keep for fastest AOT profile; bounded ROM cost buys a repeatable macro win |
| A6 | Keep a proven exact-array numeric element raw through its guard and immediate multiply/divide | accepted locally; full/fuzz green | spectralnorm 122.08 -> 114.51/114.92 ms (-6%); final array_read artifact byte-identical to disabled arm | spectralnorm IL -1,164 B (-0.68%), assembly +215 B (+0.19%); no target heap/frame/metadata state | keep narrow; broader add/sub arm regressed array_read ~31% and was deleted |
| A7 | Fold the compiler-generated checked-array-load/null region in native output | accepted in isolated experiment; full/fuzz green | 31 interleaved fannkuch pairs 188.89 -> 184.17 ms (-2.5%); final standalone 183.75 ms exact | fannkuch IL -1,021 B, asm -548 B, linked __text -136 B; no target state; transient emitter maps only | keep; strict shape, exact receiver, private labels, and dead-scratch proof |
| A8 | Fold the complete compiler-generated checked-array-store diamond in native output | accepted in isolated experiment; full/fuzz green | three interleaved fannkuch processes 185.11 -> 113.58 ms (-38.6%); final dual-lane matrix 117.2 ms exact | fannkuch IL -6.3%, asm -2.8%, linked __text -1,124 B; no target heap/frame/metadata state | keep; strict final-index shape and full append/error/barrier semantics; report |
| C1 | Freeze-safe direct calls can remove dynamic call overhead on top of the accepted continuation model | queued | pending | continuation metadata is now bounded; direct-call design still must preserve it | pending |
| C2 | A resolved native call can skip dynamic identity/code lookup and call its symbol directly | rejected; implementation deleted | no-inline call_hot 27.06 -> 28.67 ms (+6.0%); polymorphic calls intentionally unchanged | IL +179 B/asm +118 B on call_hot; helper added shared text but no state | existing dynamic helper is already cheaper than a second specialized prep seam |
| M1 | Fuse residual pure type-test + exact boolean branch pairs in Mach | accepted locally | record_field -12.0%; fannkuch -4.5%; spectralnorm -6.7% controlled A/B | no actor/per-code/blob bytes; about +984 B shared text | keep; exact report linked below |
| M2 | Allocate AOT side tables in console-sized increments | accepted locally | no speed credit; native recursion results unchanged through 200 live frames and GC | roots 17,408 -> 544 B; first key cache 1,024 -> 128 B; first code cache 3,072 -> 384 B | keep |
| M3 | Extend target-preserving Mach guard fusion to residual text predicates | accepted locally | spectralnorm -2.21%; record_field -1.24% controlled A/B | identical Mach blobs; no dynamic/program state; aligned runtime segments unchanged | keep; exact report linked below |
| M4 | Size known-field records against the hash table’s real 75% load limit | accepted locally | binarytrees Mach -12.1%, native -12.9%; dispatch and emitted code unchanged | allocation traffic -44.4%, collections 62 -> 33; no metadata or code-size cost | keep |
| M5 | Consume exact type spans to lower guarded native array stores directly | accepted locally | fannkuch native -17.5%; 2M numeric stores -28.3%; root reproduced 206.26/15.97 ms | +3.70%/+1.97% assembly; 1-19 KiB compiler-only spans; no runtime heap/frame cost | keep; correctness barriers separate |
| M6 | Decode Mach numeric null as a NaN poison inside the arithmetic fast path | accepted locally | float_math -22.4%; mandelbrot -12.8%; spectralnorm -8.2% in controlled pairs | +484 B shared runtime text; no dispatch, bytecode, frame, actor, or per-code memory | keep; native companion remains separate |
| M7 | Let pre-compression range analysis consume fresh LICM integer constants | accepted locally | loop_nested native 29.41 -> 12.17 ms (-58.6% controlled); root 12.17 ms exact | IL -15.3%, assembly -9.1%; compiler facts only, no target memory | keep |
| M8 | Consume exact per-use integer spans in inline-spliced Mach hosts | accepted locally | call_hot Mach -5.2% repeated-process / -7.6% controlled; root median 13.59 ms, exact result; fannkuch/spectralnorm unchanged | about +1.2 KiB shared runtime text and +200 B serialized facts on call_hot; load-time fact copy is immediately freed | keep; experiment-only disable switch removed |
| M9 | Fuse generic Mach arithmetic with its forward jump over cold error code | rejected; implementation deleted | spectralnorm dispatches 224.4M -> 192.4M, but 277.30 ms fused vs 276.48 ms disabled | about 95 lines/four larger shared handlers; serialized Mach and program state unchanged | dispatch reduction did not translate to speed |
| M10 | Reorder guarded numeric diamonds so success falls through and cold disruption code is out of line | prototype rejected before timing; implementation deleted | compiler self-realization stalled/failed even under benchmark-only and small-function gates | no target artifact produced; poisoned generated cache required worktree-local cache clear | retry only through verified CFG block layout |
| M11 | Keep Mach numeric null absorbing through every numeric intrinsic and materialize canonical null/bool constants | accepted locally; full/fuzz green | exact Mach/native raw oracle restored; intrinsic_ops 23.725 -> 23.770 ms (+0.2%, noise) | no heap/frame/per-code state; release __text 1,030,980 -> 1,024,712 B and identical mapped __TEXT segment | keep; correctness repair with no measured runtime or mapped-memory penalty |
| M12 | Lower proven array-receiver/integer-index accesses to compact typed Mach handlers | accepted locally; full/fuzz green | array_read -6.8%/-9.3%; fannkuch -3.3%/-3.5%; spectralnorm -1.0%/-2.9% in bracketed A/B runs | same one-word instructions and blob sizes; no fact sidecar/runtime state; mapped runtime text unchanged | keep; strong Mach win at effectively zero target-memory cost |
| M13 | Consume the immediate is_int fallthrough fact at exact-array Mach loads | accepted locally; full/fuzz green | incremental fannkuch 276.23/275.46 vs 294.61 ms (-6.2%/-6.5%); typed loads 6.16M -> 16.22M | +600 B linked __text (+608 B total section content), mapped segment unchanged; no program/runtime state | keep; 600 shared bytes buy another material shootout win |
| M14 | Extend the local integer proof through the full checked-array-store diamond | rejected; implementation deleted | all 14.83M stores typed, but 280.79/275.05 vs 272.06 ms generic (+3.2%/+1.1%) | prototype added 1,272 B __text plus 38 B string/unwind content; no program state | a predictable receiver branch was cheaper than the larger proof/handler layout |
| M15 | Keep existing-capacity Mach array stores inside the VM and reload frames only after growth | accepted locally; full/fuzz green | fannkuch 264.73/267.01 vs 304.50 ms (-13.1%/-12.3%); final no-toggle 261.61 ms | +1,480 B shared __text; mapped segment unchanged; no bytecode/program/actor state | keep; one shared fast path beats both generic and typed call-out handlers |
| M16 | Inline the pure Mach array-read helper at its three VM call sites | accepted locally; full/fuzz green | array_read normally -6% to -8%; fannkuch approximately -2% to -6%; spectralnorm neutral | linked __text -856 B on Darwin; no bytecode/heap/frame/actor/program state | keep; pure shared fast path, 5,623-case differential gate |
| M17 | Delete the dead self-callee lookup left behind by self-tail-call elimination | accepted locally; focused compiler green | tco_self Mach 52.13 -> 46.21 ms (-11.3%); native timing neutral | 5.0M dispatches removed; Mach blob -64 B; native IL -395 B/assembly -232 B; no runtime or compiler metadata | keep; pure post-transform dead-value cleanup |
| M18 | Fuse the complete compiler-proven checked-array-store region in Mach | accepted locally; full/fuzz green | fannkuch dispatches 225.86M -> 168.03M (-25.6%); exclusive median 265.29 -> 227.44 ms (-14.3%) | +3,792 B shared __text vs no fusion; mapped segment/blob/heap/frame unchanged; one-time load +2.06 us/code object | keep; exact sparse fact replaces duplicate runtime matching |
| T1 | Exact daemon identity and endpoint ordering make benchmark processes reliable | verified, incomplete cold-boot cure | not a language speed change | small CLI-only code/test increase | tooling-only |
| T2 | Open native benchmark loading only after a normal daemon boot | verified | not a language speed change | one process-global atomic flag | tooling-only campaign capability |
| T3 | Make the benchmark driver a typed CLI actor instead of relying on environment selection and a two-second probe | verified | not a language speed change; enables complete shootout measurement | no generated-program/runtime cost | tooling-only |
| T4 | Expose point-specific integer proof facts in the native IL dump | verified | not a language speed change; made the loop_nested boxing cycle directly auditable | diagnostic output only | tooling-only |
| T5 | Make the IR dump tolerate absent optional facts and show exact constant spans | verified | not a language speed change; unblocks constant-carrier and raw-region audits | diagnostic output only | tooling-only |
| T6 | Keep the contributor fuzz command aligned with the dedicated fuzz actor | verified | not a language speed change; removes a guaranteed validation dead end | documentation only | tooling-only |
| T7 | Print both backend results in focused timing output | verified | not a language speed change; makes mismatches immediately actionable | diagnostic text only | tooling-only |
| T8 | Return the raw/optimized statistics that streamline --mode stats already computes | verified | not a language speed change; exposes instruction and slot deltas per function | response data only | tooling-only |
| T9 | Tiny edits to the large QBE emitter must not crash compiler self-realization | assigned | reproducible mcode_lower_func_node/mach_release_frame segfaults before benchmark execution | unknown | diagnose underlying bound/lifecycle defect |
| T10 | Test execution deadlines must begin after realization, not before compilation | verified locally | cold vm_suite previously timed out with 0 tests; now compiles past the old boundary and passes 1,086/1,086 | two timestamps/one optional duration field per live test entry; test-runner actor only | keep |
| T11 | Print a compact per-function fact/guard/load/store/constant-span census on request | verified | not a language speed change; exposed the all-unknown spectral parameter seam and N2 candidates without megabytes of IR | diagnostic text only and disabled by default | tooling-only |
| T12 | Outer one-shot replies must survive a bounded cold test realization | accepted locally; focused/full/fuzz green | 139-second fuzz returned 3,733/3,733 and cold full suite returned 1,918/1,918 instead of dying at 60 seconds | one optional numeric request field and descriptor constant; no target-program cost | keep bounded typed descriptor/command policy; separate from semantic test deadlines |
| T13 | A transient daemon info failure must not destroy a live endpoint | accepted locally; native test green | repeated client failure preserved socket/PID and the next retry succeeded instead of breeding or requiring cleanup | no Pit/program memory; five-second client-only retry bound and a few hundred bytes host CLI text | keep; cold listener omission remains separate |
| T14 | A native cache entry must identify the exact emitted IL, not only its source | accepted locally; focused rebuild/hit verified | same source now changes dylib path when compiler output changes and reuses it only for byte-identical IL | one compiler-time IL hash and more truthful cache entries; no target/runtime cost | keep; prevents benchmarking stale machine code |
| T15 | A long differential-fuzz validation must show bounded human progress without corrupting its final reply | accepted locally; 500-seed/full-suite green | seed 20260713 completes 3,733/3,733, exit 0, with progress every 25 programs; JSON remains one object | log text and one actor-local interval number only; no generated-program/runtime cost | keep; tooling-only, interval is configurable |
| T16 | Machine-shell/daemon fuzz attempts that never create a fuzz actor are a shop lifecycle failure, not typed argument packing | reproduced; not fixed here | fresh daemon times out after 120 s with no endpoint; a machine shell prints help but its top-level request is never serviced | unknown; separate from language execution and T15 | assign to shop/listener lifecycle work; do not blame optimizer or fuzz |
| T17 | A terminal-owned foreground actor must not be reaped while awaiting cold realization | accepted locally; focused/full green | captured test controller died at 60.0239 s while the builder continued; owned starts now rely on the bounded terminal deadline/coupling; forwarding1/1 and full1918/1918 | no new actor field or target state; detached actors retain idle reap | keep; $start forwards explicit ar_timer, foreground terminal uses -1 |
| T18 | A failed terminal session must not unlink a live daemon listener | preservation accepted locally; accept stall remains | command failure used to remove pit.sock from a live listener; rebuilt client preserved path/PID/listening FD and native predicate passed, though later info still stalled | one PID-state check on failure; no Pit or generated-program state | keep; separately diagnose the now-visible accept-loop stall |
| P1 | Native execution needs real reduction/suspension safepoints | accepted composed candidate | shootout tax normally about 1-2%; recursive native fib about +10%; forced mixed-engine/GC matrix passes | +64 B native state/context; lazy 136 B four-entry host chunk; no per-frame word; generated text grows | keep for correctness; optimize call accounting and outlined slow paths next |
M8: exact Mach facts survive inlining
The earlier emergency response to a composed fannkuch miscompile disabled all
Mach type facts in any host that had received an inline splice. Final-index
_type_spans now let lowering prove the two operands at each exact use instead.
Spliced hosts require exact covering spans; non-spliced hosts may retain the
older conservative function-wide fallback. A compact overflow-chain regression
returns 3000008629 in both lanes, and the targeted compiler suite passes
75/75.
The accepted implementation has no runtime toggle. The temporary A/B switch
used on the experiment branch was removed before integration. Type spans exist
only while lowering; the one-byte-per-Mach-instruction selection table remains
part of serialized identity but is freed as soon as its choices have been
rewritten into opcode bytes. The covered call_hot row improved 5.2% across
independent processes (7.6% in the tight controlled pair) without changing its
dispatch count. Root integration measured 13.59 ms Mach and 6.75 ms native,
with exact results. Fannkuch and spectralnorm gain no typed operations from this
fact set, so this change correctly leaves their dynamic behavior unchanged.
Rejected M9: arithmetic plus cold-arm jump fusion
Spectralnorm’s dynamic census attributes 57.7 million of 224.4 million Mach dispatches to unconditional jumps. Most follow successful generic arithmetic and skip the corresponding cold error formatter. A target-private prototype fused add/subtract/multiply/divide with a following forward jump while leaving the second word independently targetable and leaving backedge pause checks untouched. It changed no semantic mcode, serialized Mach word, frame, or heap state.
The prototype worked mechanically: 25.6 million fuse_add_jmp and 6.4 million
fuse_mul_jmp executions reduced the census to 192.4 million dispatches. It did
not work economically. Eleven-sample controlled medians were 277.30 ms fused
and 276.48 ms disabled. The duplicated numeric handlers and resulting runtime
layout erased the computed-goto savings. All 95 lines, four target-private
opcodes, and the temporary A/B switch were deleted. This is strong evidence
that reducing Mach dispatch count without also reducing numeric decode/encode
work is not sufficient for this workload.
Rejected M10 prototype: instruction-pattern cold layout
The more promising formulation is to invert the last numeric guard and move its
single success operation after a non-fallthrough disruption block. That removes
the unconditional jump without enlarging a VM handler, and can improve native
hot/cold layout too. A first instruction-array prototype required an exact
conditional/success/jump diamond, label-only prefix, and a cold interval whose
last executable instruction was disrupt; it transported disruption_pc and
ran before final-index facts.
It was still not trustworthy. Compiler self-realization stalled and ultimately
reported compile failed, including with the transformation limited to four
small functions and then to the benchmark filename. No executable benchmark
was produced. Restoring streamline.cm was not enough because the failed
realization left generated cache state poisoned; clearing only this worktree’s
.pit/build, .pit/cache, .pit/lib, and .pit/tmp restored normal focused
compilation and the 13.56 ms exact call_hot result. The entire 177-line
prototype was deleted. A retry must operate on verified basic-block identities,
re-run CFG verification after layout, and round-trip control-flow destinations;
local instruction-pattern proof is not adequate for this transformation.
Tooling findings
T8: paired streamline statistics
Both compiler inspection actors fetched raw mcode for stats mode, then discarded
it and returned optimized-only counters despite the documented before/after
contract. They now return paired detailed records plus scalar before/after/delta
instruction and slot counts. The scalar fields are intentional: the generic CLI
renderer otherwise collapses an array of nested records to function names only.
pit compile streamline core/internal/heap.ce --mode stats now prints useful
per-function rows (for example its main body reports 633 -> 240 instructions
and 381 -> 44 slots). The inspect actor also realizes successfully with the
same response shape.
T10: separate realization and execution deadlines
Removing the arbitrary large-function range-analysis cutoff made a cold
vm_suite compilation exceed the test actor’s 30-second deadline. The runner
started entry.start_time before $start, so realization, linking, actor
startup, and test execution were all charged to one semantic timeout. Both the
full suite and an isolated retry failed with zero VM assertions executed while
the builder was still compiling.
The runner now gives realization its own bounded ten-minute deadline and resets
the ordinary 30-second (or named slow-test) execution clock on the actor’s
greet event. It reports Test realization timed out distinctly from Test timed out, and records setup duration separately. This does not make a hung
builder unbounded and does not relax a running test’s deadline. The exact cold
case then compiled beyond the former boundary and passed all 1,086 VM checks.
The only retained cost is two timestamps and one optional duration number in
the test-runner actor’s entry record; generated programs and production target
runtime state are unchanged.
The first outer-lifetime experiment put reply_timeout in HELP metadata. The
final CLI redesign rejected that layering: daemon and in-process terminal
creation now apply one fixed 600-second transport bound, while actor
descriptors contain only vocabulary. See docs/plans/perf-cli-reply-timeout.md.
T15/T16: differential-fuzz CLI reliability split
The original report combined two different behaviors. On a working in-process
node, typed descriptor packing was correct: fuzz 500 --seed 20260713 reached
internal/fuzz.ce, ran all 500 generated programs, and returned 3,733 passed
checks, zero failures, seed 20260713, and process exit 0. The actor intentionally
sent only its final reply, however, so the warm run emitted nothing for roughly
one minute. It was running rather than stalled. A misleading exit-1 observation
came from wrapping the child in macOS /usr/bin/time -lp: time itself could
not query kern.clockrate in the sandbox. Running the child directly proved
that Pit propagated the successful summary as exit 0.
Human fuzz commands now log an immediate start line and a progress line every
25 completed programs. --progress-every N changes that interval. Progress is
log traffic, not additional replies, so the command still has exactly one final
summary and --json remains one clean JSON object with no progress mixed into
stdout. The accepted 500-program run printed progress from 25/500 through
475/500 and then returned the same exact 3,733/3,733 final result with exit 0.
There is no generated code, target heap, frame, bytecode, or persistent runtime
state cost; only the fuzz actor’s local interval value and diagnostic log text.
The standalone deterministic CLI regression uses two programs and
--progress-every 1, keeping the expensive differential work small. It checks
the start line, the 1/2 progress line, the final successful summary, and exit 0.
A separate typed fixture returns a normal {passed:2, failed:1, total:3} reply;
the same regression requires the details to be rendered and the process to
exit nonzero. The cold standalone check passed 2/2 in 23.3 seconds, within its
30-second execution bound. The composed default suite passed 1,918/1,918 when
run outside the socket-bind sandbox. The first sandboxed run’s six failures
were all denied local socket/bind operations, not semantic failures.
The other behavior is independent and remains unfixed. A fresh daemon attempt
timed out after the full 120-second readiness window with a live process and no
endpoint. Its boot trace reached the shop actor and its observation trace built
internal/builder_worker, but no listener-ready event followed. After one
successful ps, the next client could likewise find the preserved socket but
an unavailable endpoint. An in-process machine shell printed its initial help
event, then its top-level fuzz request remained unserviced; the observation
trace contained no fuzz actor creation and stopped in shop bootstrap after the
builder-worker cache hit. Thus this is not fuzz duration, CLI packing, a fuzz
timeout, or an optimizer result. It is a separate shop/listener forward-
progress defect. T15 deliberately does not mask it or claim to fix it.
N5: hoisted declarations participate in parameter inference
Pit already had a conservative whole-program parameter inference pass. It accepts a function binding only when exactly one function value reaches it and every reference is a visible direct call; an omitted argument, mixed argument types, or any escape widens the affected parameter back to unknown. Parameters are immutable, so a successful proof is valid for every use in the callee.
The pass handled def f = function(...) but its AST walker did not record an
ordinary hoisted function f(...) declaration as the value written to f in
the enclosing scope. Later call resolution still knew the exact callees, which
made this especially wasteful: every spectralnorm helper was statically called,
but all of its array and numeric parameters were emitted as dynamic. The fix is
one producer edge in resolve.cm; it changes no language rule, mcode operation,
target ABI, runtime structure, or call convention. A regression proves direct
hoisted calls lose redundant parameter guards, while a second regression takes
the function value through an alias and proves the dynamic guards remain.
On the same root immediately before and after the fix, spectralnorm changed:
| Metric | Before | After | Delta |
|---|---|---|---|
| Mach median | 280.11 ms | 202.70 ms | -27.6% |
| native median | 181.96 ms | 132.92 ms | -26.9% |
| optimized instructions, all functions | 1,432 | 444 | -69.0% |
| QBE IL bytes | 396,266 | 190,279 | -52.0% |
| native assembly bytes | 258,683 | 123,373 | -52.3% |
| exact type-span live bytes | 18,672 | 8,944 | -52.1% |
| summed compressed frame slots | 99 | 76 | -23.2% |
The selected shootout matrix then measured spectralnorm at 202.67 ms Mach and
130.65 ms native with exact result 1623647009; executed Mach instructions
fell from the prior 224,432,202 census to 134,704,082 (-40.0%). Fannkuch,
which has no helper function, remained neutral at 297.00/196.31 ms. The same
matrix produced mandelbrot 91.49/9.21 ms and binarytrees 82.32/56.86 ms with
exact results. Binarytrees’ recursive parameter proof makes it a smaller win
than spectralnorm because allocation and record traversal still dominate.
This is strictly favorable for constrained targets: generated code and frames
shrink, no target metadata is added, and the inference map exists only while
compiling. The optional PERF_FACTS=1 census added to perf_one.ce reports
per-function instruction, guard, load/store, type-span, and resolved-call counts
to make future missed proofs visible without affecting normal benchmark runs.
PERF_INSTRS=1 optionally prints optimized instructions, and
PERF_FUNCTION=<name> restricts that dump to one function. This made the final
two hot-loop guards readable directly instead of requiring a full IR dump.
N6: preserve parameter facts through final range cleanup
N5 used the resolver facts while generating semantic mcode, but then discarded
them. Streamline therefore still saw every parameter as unknown. In
spectralnorm it could not prove that j = j + 1 stayed in the int32 lane under
the immutable integer bound j < n, leaving an is_int(j) check before every
array access even though all visible callers passed integer n values.
Raw mcode now carries _param_types, an array of at most four closed-world
facts. Streamline consumes it in write-type, array, and range analysis; exact
integer parameters seed the range lattice with the conservative full int32
interval. After no-overflow reconciliation, type-check cleanup runs once more
before final compaction and regenerates instruction-indexed facts afterward.
Canonical mcode deliberately strips _param_types; it is neither portable
mcode ABI nor target runtime metadata. Regressions verify that the fact is
present in full compiler IR, absent from canonical serialization, removes a
bounded index guard, and remains absent when a function escapes.
The immediate spectralnorm pair changed 201.99 -> 199.04 ms Mach (-1.5%) and 131.21 -> 128.74 ms native (-1.9%). QBE IL fell 190,279 -> 174,589 bytes (-8.2%), assembly 123,373 -> 114,160 bytes (-7.5%), optimized instructions 444 -> 428, and exact type-span live bytes 8,944 -> 6,896. The code and fact reductions are strictly favorable on constrained targets. Compiler-temporary cost is one at-most-four-element fact array only for functions with a proven parameter; it disappears from canonical artifacts.
T7: explicit mismatch values
aot_bench/perf_one.ce used to print only the native value after reporting a
Mach/native mismatch. It now prints both values on every row. The dynamic-null
oracle consequently identifies the current boundary directly as
mach_result=15 native_result=0, rather than requiring a second custom driver.
T6: executable fuzz instructions
AGENTS.md instructed compiler/runtime work to run ./pit test fuzz, but the
CLI rejects that form because fuzz is a separate actor. The instruction now
uses the deterministic campaign form ./pit fuzz 500 --seed 20260713 and says
explicitly that fuzz is not a pit test category.
T5: fact-safe IR diagnostics
aot_bench/dumpil.ce disrupted before printing an otherwise valid optimized
function whenever _int_bounded, _no_overflow, or another optional fact was
absent: json.encode(null) produced null and the diagnostic concatenated that
as text. The dump now renders missing facts explicitly as null and includes
_const_spans beside write/range facts. A fresh in-process spectralnorm dump
completed and exposed the exact physical-slot/instruction intervals needed for
the constant-carrier audit. This changes neither compilation nor execution;
it is only developer tooling.
T1: POSIX daemon lifecycle
The first fresh benchmark attempt exposed a pre-existing lifecycle race:
successive pit commands observed a disappearing socket before the old process
had terminated and spawned replacement daemons. Detached benchmark actors then
vanished without a result. The in-process fallback also halts the logger on
some long fixtures, so it is not a valid general replacement.
The experiment branch therefore merged the already isolated
codex/perf-lifecycle repair (70e135a8) as tooling-only work. It:
- returns the exact detached child PID from the platform spawn API;
- follows that identity through listener readiness and shutdown instead of treating a pid-file or socket transition alone as authoritative;
- waits for both the captured process and endpoint to disappear on
pit down; - refuses to start a replacement while a known process remains alive;
- recomputes endpoint/process state after stale endpoint cleanup;
- makes an already-down
pit downidempotently successful; - bounds readiness at 120 seconds and shutdown at five seconds, reports progress, detects early child death and boot-log errors, and truncates stale boot diagnostics before spawn;
- adds deterministic native lifecycle state tests.
This code is outside compiled Pit program execution and receives no benchmark credit. Its code-size cost is in the host CLI/runtime binary, not actor heaps or generated program state. It must pass its native test, repeated start/down integration, rebuild, and the default Pit suite before T1 is considered usable.
At campaign commit 2601786e, the deterministic lifecycle executable and
Meson test both pass. A cold normal boot nevertheless remained alive without
opening its listener for the entire bounded 120-second wait; its boot trace
contained only process start and shop birth. After terminating only that exact
worktree daemon, one in-process realization warm-up completed in 0.39 seconds
and the following normal daemon opened its listener in 0.37 seconds. Thus T1
correctly diagnoses and bounds the failure, but does not claim to fix the
underlying cold realization/listener omission.
The same unresolved omission recurred while removing C2. A seed build had
correctly regenerated its boot payload, but the next binary restart left the
exact pit internal/bootstrap --mcode process alive for 120 seconds with no
listener. The lifecycle code produced the expected bounded diagnostic; it did
not make progress. The process was verified by exact path/PID and stopped, and
the generated payload from the rejected experiment was restored from the
committed checkpoint. This remains costly iteration friction for large
compiler experiments.
A later real command-path failure isolated the destructive half of the
remaining listener problem. pit ps completed, but an immediately following
client could not obtain an info reply within 200 ms. The client treated that
transient probe failure as proof that pit.sock was stale, unlinked it, then
noticed the captured daemon was still alive and correctly refused to breed a
replacement. The result was a healthy-enough process made permanently
unreachable by its own next client.
T13 makes cleanup conservative: an endpoint may be unlinked only when the captured process is positively known dead. A live process and a missing or invalid identity both preserve the endpoint and fail actionably. The info probe now spans five seconds, long enough to cross ordinary slow-turn pauses; if it still fails, a later retry can recover because the socket remains. The native policy test covers dead/live/unknown identity decisions. Direct and Meson lifecycle tests pass, and the reproduced failure retained both socket and PID; its immediate later retry succeeded. This does not cure a daemon that never opens a listener or a listener that remains unavailable indefinitely. These C functions allocate no Pit values, so the GC-rooting checklist is not applicable. There is no target actor/program memory cost.
T14: emitted IL participates in native cache identity
The focused runner exposed a correctness problem in its own evidence. After a
QBE emitter change it printed the new IL and assembly sizes, but
compile_native_ir returned the exact dylib path created before the change.
The native cache key contained source, target, sanitizer flags, and numeric
representation, but not the optimized IR or emitted IL. A benchmark could
therefore report new compiler facts beside old machine code until .pit/build
was manually deleted.
Native cache identity now includes a BLAKE2 hash of the complete QBE program:
data, lazily selected helpers, and functions. The same helper constructs both
the hashed text and the text passed to QBE, preventing formatting drift between
identity and compilation. The obsolete source-only cache-probe API had no
consumers and was deleted. On the composed TCO fixture, the pre-fix source key
was 74f65e...; the exact-IL key became d7987e..., and an immediate repeat
reused d7987e.... Compiler time gains one linear hash of material it already
holds. Target code, runtime memory, actor memory, and executable behavior are
unchanged; disk cache usage can temporarily grow when compiler output changes,
which is the required behavior rather than a performance trade.
T2: post-boot native benchmark capability
PIT_ALLOW_NATIVE_LOAD=1 is not a sound benchmark bootstrap mode on this
machine: the daemon can breed its shop actor but never open the listener. A
normal daemon boot is reliable. A second attempt with a distinct opt-in
environment marker reproduced the same stall, showing that the worktree’s
realization/bootstrap context is sensitive to an altered PIT_* environment.
The replacement keeps native symbol loading sealed throughout a normal,
unmodified daemon boot and lets the benchmark actor explicitly open the
capability only after the listener is ready. The capability is process-wide
because dylib handles and their code already have process lifetime; it costs
one atomic integer and no actor-heap state.
This is campaign tooling, not a proposed public language API or production security policy. It is intentionally callable without an environment gate in this contained branch so no pre-boot context changes. It must be removed or replaced by a reviewed development capability before any upstream merge.
The toggle was validated by compiling, loading, and timing native modules from a normally booted daemon. Fresh matching rows before the CLI probe ended the multi-row actor were: arith_int Mach 18.39 ms/native 10.69 ms, arith_poly 19.47/7.88 ms, and loop_nested 22.77/28.19 ms. The loop_nested regression relative to historical data must be reproduced before it is interpreted.
After the same explicit warm-up/normal-daemon sequence, the complete default suite passed 1,918/1,918 with the post-boot native-load capability present. This verifies the campaign baseline, not the safety of making the capability a public API.
T3: typed benchmark command
aot_bench/vs.ce now publishes a normal CLI command descriptor and does no
work until it receives {type: "run", benches: [...]}. Consequently
./pit aot_bench/vs.ce spectralnorm fannkuch selects rows explicitly, answers
the terminal help exchange immediately, and keeps the terminal coupled until
the asynchronous native builds and timings reply. It no longer needs a
BENCH_ONLY variable in the daemon environment and is no longer killed by the
two-second courtesy probe. BENCH_ONLY remains only as an in-process fallback.
The one-row, two-row, and unmatched-name paths were exercised directly. This is a benchmark-tool usability change with no effect on emitted Mach/native programs and receives no performance credit.
T4: integer-proof IL diagnostics
aot_bench/dumpil.ce now prints _int_bounded and instruction-indexed
_no_overflow facts alongside _write_types. This is diagnostic output only.
It exposed the exact loop_nested native failure: both loop indices and their
product are bounded integers, but the loop-carried s slot is globally typed
num; QBE converts i*j to a double, adds it to decoded s, calls
qbe_new_float64, decodes the result again, performs floating divide/truncate
remainder, calls qbe_new_float64 a second time, and decodes once more on every
inner iteration. Since % 7 proves s is in [0, 6], the recurrence can stay
as a raw integer if range facts flow around the loop phi. This is the focused
integer arm of N1 rather than a reason to special-case the benchmark.
Remaining lifecycle/CLI defects
- A cold daemon can require an in-process warm realization before its listener starts. The bounded lifecycle wait reports this, but does not cure it.
- The listener can still become unresponsive after a small number of client sessions. T13 prevents the next client from destroying the live endpoint and permits later recovery, but does not yet explain or cure the listener stall.
- POSIX
kill(pid, 0)reports an unreaped zombie as alive. Readiness/shutdown identity checks need a zombie-aware child-state path and a regression. - Resolved: bare scripts receive
{}and wait for a reply; nonempty argv uses the canonical typed CLI envelope. compile streamline --mode checkdoes not check facts/types; it only lists functions over 200 slots. Generated-code auditing still requires direct mcode/QBE/assembly inspection.- Compiler inspection treats a
.cefile as a complete actor and therefore rejects the benchmark fragments’ deliberate top-levelreturn. The campaign usesaot_bench/dumpil.cefor those fragments; a future--fragmentinput mode would make the public compiler tool cover the same workflow.
Fresh baseline evidence
Current-tree rows with matching Mach/native results:
| benchmark | Mach ms | native ms | Mach instructions | allocation | GC |
|---|---|---|---|---|---|
| arith_int | 18.39 | 10.69 | 21,000,049 | 1 KiB | 0 |
| arith_poly | 19.47 | 7.88 | 20,000,052 | 1 KiB | 0 |
| loop_nested | 22.77 | 28.19 | 24,010,049 | 1 KiB | 0 |
| mandelbrot | 108.30 | 28.01 | 36,900,654 | 1 KiB | 0 |
| fannkuch | 297.85 | 241.13 | 268,248,821 | 2 KiB | 0 |
| spectralnorm | 305.97 | 172.10 | 266,116,651 | 26 KiB | 0 |
| binarytrees | 97.66 | 60.71 | 27,626,436 | 94,869 KiB traffic | 62 |
Fresh comparison shootouts (same checksums):
| benchmark | LuaJIT ms | OCaml ms | .NET ms |
|---|---|---|---|
| mandelbrot | 43.53 | 3.88 | 2.91 |
| binarytrees | 20.95 | 0.94 | 3.27 |
| spectralnorm | 7.34 | 10.58 | 3.54 |
| fannkuch | 17.33 | 13.76 | 10.55 |
The typed benchmark command made the previously blocked spectralnorm and fannkuch rows reproducible. A repeated loop_nested row was 22.67/28.39 ms, confirming that native currently loses to Mach on this fixture rather than the earlier result being a single noisy sample.
Fresh call-path rows:
| benchmark | Mach ms | native ms | Mach instructions | native allocation |
|---|---|---|---|---|
| call_hot (currently inlined) | 13.58 | 7.39 | 18,000,051 | 1 KiB |
| closure (dynamic) | 58.07 | 30.43 | 44,000,097 | 2 KiB |
| fib (recursive) | 54.24 | 28.79 | 29,617,946 | 1 KiB |
| tco_self | 89.13 | 30.30 | 80,000,055 | 1 KiB |
Erlang/OTP 29 JIT and Elixir 1.20.2 ports use the same counts/results for
call_hot, fib, and tco_self, with two warmups and median-of-seven timing:
| benchmark | Erlang ms | Elixir ms |
|---|---|---|
| call_hot | 8.06 | 8.03 |
| dynamic_call0 | 25.07 | 34.43 |
| fib | 2.12 | 2.10 |
| tco_self | 3.43 | 3.43 |
dynamic_call0 isolates indirect immutable-fun dispatch and has its own
checksum; it is not presented as equivalent to Pit’s mutable captured-cell
closure benchmark. The useful interpretation is narrower: Pit’s already
inlined call_hot is at the BEAM known-call floor, but recursive native calls
remain roughly 13.6x slower and Pit’s tail-recursive loop 8.8x slower. A thinner
freeze-safe continuation is therefore necessary for recursion, while raw
integer loop values and backedge lowering matter at least as much for TCO.
Rejected experiment A1: broad composed-helper inlining
Raising the ordinary hot-body limit from 64 to the existing 800-instruction hard cap, and the per-function inline count from 20 to 100, changed neither spectralnorm’s 266,116,651 executed Mach instructions nor its 13,355-line native IL. Size was not the blocker.
The next arm allowed an immediate child to inline even when it calls a function
read from its level-1 parent. The existing splice already rewrites that capture
to the parent’s exact slot; true siblings remained rejected. This flattened
more of sn_atav -> sn_av/sn_atv -> sn_row_*, cutting native allocation traffic
from 2,656 KiB to 26 KiB. It did not move native runtime: 172.10 ms became
172.20 ms. Mach measured 295.53 ms versus 305.97 ms, while its instruction count
only fell to 266,052,432 (-0.024%), so most of the apparent timing change is not
explained by removed work. Native IL grew from 13,355 to 15,578 lines (+16.6%).
The compiler/shop realization also grew from roughly 11 MiB live to 28 MiB in
this development fleet.
This is a useful negative result: row-call frame traffic is not spectralnorm’s runtime bottleneck. Broad flattening spends scarce static memory without fixing generic array access and repeated numeric encode/decode. Both source changes were deleted. A later inliner should be driven by the raw-value/array facts it actually unlocks and should outline duplicated cold diagnostics, rather than raising global limits alone.
Accepted experiment M1: Mach guard/branch fusion
The isolated implementation and measurements are recorded in
docs/plans/perf-campaign-mach-guard-fusion.md. The root campaign integration
reproduced the mechanism and results: mach_guard_fusion returned the golden
290 in both lanes; record_field ran 106.98 ms at 86,000,384 dispatches;
fannkuch returned 3000008629 at 232,394,887 dispatches; spectralnorm returned
1623647009 at 227,632,203 dispatches and 282.78 ms. The latter is 7.6% below
this root’s 305.97 ms pre-fusion baseline. Normal-daemon fannkuch timing was
noisier (298.39 versus 297.85 ms) than the agent’s controlled fresh-process A/B,
but its 13.4% dispatch removal reproduced exactly.
The fused handler preserves the predicate’s boolean result slot, leaves the
second branch word independently targetable, and performs the same pause check
when it takes a backward branch. It changes no semantic mcode, serialized Mach
bytes, actor state, or per-code state. The approximately 984-byte shared text
cost is justified by 4.5%-12.0% controlled improvements on affected composed
programs. _has_inline_splice still disables typed fact handlers because the
historical stale fact violation has not been disproven.
Root validation after integration: deterministic lifecycle test passed; the first cold full suite reproduced only the known realization-charged vm_suite timeout (832/833), isolated vm_suite passed 1086/1086, and the warmed full suite passed 1918/1918. The benchmark driver was also fixed to print a null native result without disrupting itself after a backend failure; the terminal still ends the coupled run on fatal native logs, so known-failing rows remain best run in isolation.
Accepted experiment M2: small AOT side tables
The native runtime previously allocated 1,024 persistent PitGCRef entries
the first time an actor entered native code. On a 64-bit build the root array
plus initialization bitmap is 17,408 bytes, even for a one-frame actor. This
is C-side state and was absent from the actor heap counters, so ordinary heap
benchmarks concealed it. The chunk size is now 32: 544 bytes for a shallow
native actor, a 16,864-byte (96.9%) reduction. Capacity remains unbounded and
grows in 32-frame increments; a 120-frame recursion uses four chunks (2,176
bytes) rather than one 17,408-byte chunk.
This changes neither the number of active GC roots nor generated code. It only
allocates their backing storage in smaller increments. gc_recur passed at 120
native frames, and gc_recur_live passed at 200 frames while retaining arrays
and forcing real collections. The closure, fib, and TCO timing samples remained
within the host’s run-to-run noise, so M2 claims no execution-speed win. The
runtime dylib file and aligned segments were unchanged; __text was eight
bytes smaller. The native stress harness now schedules its non-disrupting deep
GC cases before the deliberate panic case, because the current terminal ends a
coupled harness after a caught native panic log.
The same allocation policy applies to two other per-actor native side tables. The fallback C-string key cache now starts at eight entries instead of 64 (128 versus 1,024 bytes on 64-bit), and the native function-code cache starts at 16 entries instead of 128 (384 versus 3,072 bytes). Both still double geometrically, so large native programs reach the same capacity; small actors no longer prepay for it. These changes affect closure/module initialization only, not hot lookup structure or generated code. Fresh closure/fib/TCO native medians were 30.26/28.95/29.52 ms versus 30.12/28.92/29.27 before M2, i.e. no measurable change. The additional constants did not change runtime binary size or section sizes. The required default suite passed 1,918/1,918 after the C rebuild. A sandboxed attempt reported six local bind/fd failures; the identical suite with normal local-socket permission was fully green, so those six were environment denials rather than language/runtime regressions.
That reordered gate exposed a pre-existing native correctness failure in
gc_closure_alloc: it disrupts not a function with both the old 1,024-entry
chunk and the new 32-entry chunk. It is not caused or hidden by M2 and remains a
separate compiler/runtime task.
Accepted experiment M3: Mach text-guard fusion
The separately measured implementation and rejected alternatives are recorded
in docs/plans/perf-campaign-mach-text-guard.md. is_text followed by an exact
boolean branch now uses the same load-time, target-preserving fusion as M1.
Controlled spectralnorm pairs removed 3,200,001 dispatches (-1.41%) and improved
the median 284.90 -> 278.61 ms (-2.21%); record_field removed 2,000,001
dispatches and improved 104.05 -> 102.76 ms (-1.24%). Fannkuch and mandelbrot
had no coverage.
The root integration reproduced the exact spectralnorm count (224,432,202 in the harness observation interval), checksum, and a 276.93 ms sample. The extended immediate/heap/GC regression returned 674 in both lanes. Serialized Mach blobs remain identical; there is no per-actor, frame, or loaded-code allocation, and aligned runtime segments did not grow. The isolated branch passed 1,918/1,918 default tests and 3,733/3,733 fuzz cases. Root’s native record_field lane still hits its pre-existing QBE type-fact error; the Mach count/result are valid and the same native failure predates M3. The combined root branch (M1+M2+M3) subsequently passed 1,918/1,918 default tests and 3,733/3,733 fuzz cases with seed 20260713.
Accepted experiment M4: exact record-literal capacity
Pit_NewRecordCap(n) used to approximate a known-field record as twice
n + 1, then round that number up to a power-of-two hash-table capacity. The
record implementation actually reserves slot zero and resizes only when the
usable 1..mask slots would exceed 75% load. The approximation therefore put
the overwhelmingly common two-field literal in a mask-7 table even though two
fields fit the existing hash representation at its valid mask-3 limit. On the
64-bit host that is 144 bytes versus 80 bytes per record.
The constructor now chooses the smallest power-of-two-minus-one mask satisfying
the exact invariant n * 4 <= mask * 3. This changes no record layout, lookup,
mutation, stone, key, or GC semantics. It adds no shapes, templates, side
metadata, or generated instructions. A native regression fills capacities at
the 0/1/2/3/4/5/6-field boundaries and verifies that none resize; a 20,000-record
stress case forces collection while keeping the current record rooted. It
passes both the normal collector and a fresh-context nursery run with
PIT_GC_NURSERY_VERIFY=1.
Controlled fresh-process binarytrees measurements, three samples per arm:
- old Mach median 92.71 ms, native median 59.19 ms;
- new Mach median 81.45 ms (-12.1%), native median 51.54 ms (-12.9%);
- allocation traffic 94,869 -> 52,703 KiB (-44.4%);
- collections 62 -> 33, with collection time about 20.9 -> 12.0 ms;
- Mach dispatch remained 22,910,542, and QBE IL/native assembly sizes were byte-for-byte unchanged (160,457/110,413 bytes).
The checksum remained 674478 in both lanes. The default suite passed 1,918/1,918 and fuzz passed 3,733/3,733 with seed 20260713. Full-shop nursery verification is currently blocked before benchmarks by a known pre-existing old-record-to-nursery missed barrier in shop boot; the isolated fresh-context test verifies this sizing change itself under nursery movement. That separate barrier defect is being handled by the native-store experiment rather than hidden here.
Accepted experiment M5: exact-type guarded native stores
The implementation and full A/B evidence are recorded in
docs/plans/perf-native-array-store.md. Slot compression now retains compact
[physical slot, first final instruction, last final instruction, type] spans
for compiler consumers. QBE uses an exact array fact at a contractually guarded
store to take its existing inline indexed-store path, retaining capacity and
growth handling but avoiding repeated dynamic receiver/key/stone dispatch. It
also omits the generational barrier only for representation-defined immediate
RHS classes (int, num, float, bool, and null).
The isolated paired median was 249.690 -> 206.072 ms for native fannkuch (-17.5%) and 22.351 -> 16.034 ms for two million numeric stores (-28.3%). The cost is 4,312 bytes (+3.70%) and 573 bytes (+1.97%) of assembly respectively. Spectralnorm has no qualifying stores and emitted byte-identical code. Exact type-span storage is compiler-only: 1,040 bytes for the store micro, 8,208 for fannkuch, 18,672 for spectralnorm, and 2,064-5,168 for the other shootouts. It is stripped from canonical mcode and adds no target heap, frame, or artifact metadata.
Root integration reproduced fannkuch at 206.261 ms with 120,825 bytes of assembly and the store micro at 15.974 ms with 29,592 bytes, both result-exact. The compiler tests passed 72/72, the default suite 1,918/1,918, and the differential fuzzer 3,733/3,733 with seed 20260713.
The same audit found two pre-existing native pointer paths missing a generational barrier: spare-capacity array push and writes into a captured old frame. Those fixes are a separate commit and retain barriers for pointer or unknown values. The fresh raw-context fixture passes an 8 KiB verified nursery with result 261633 across 17 minor collections. They receive correctness, not performance, credit.
Accepted experiment M6: absorbing numeric null in Mach
The language maps division by zero and other non-representable numeric results
to null. A focused oracle found that Mach and native subsequently laundered
that null through arithmetic: null + x, null - x, null * x, and
null / x produced ordinary non-null values. The correct result is null for
all four operations. The regression first creates null dynamically with
1 / 0, so constant folding cannot conceal the backend error.
A naive Mach repair that tested null around every numeric operation was correct but made spectralnorm 7.7% slower and was rejected. The accepted version changes only the existing arithmetic fast-path decoder: tagged integers become doubles, tagged null becomes a NaN poison, and floats decode directly. The existing non-finite result check then maps that poison back to null. It adds no Mach instruction, dispatch, bytecode word, frame slot, or dynamic state.
Controlled paired medians from the isolated experiment were:
- float_math 18.82 -> 14.60 ms (-22.4%);
- spectralnorm 286.55 -> 263.16 ms (-8.2%);
- mandelbrot 105.03 -> 91.62 ms (-12.8%);
- fannkuch had no meaningful change.
The runtime text grows by 484 bytes. Root integration reproduced float_math at 14.82 ms, spectralnorm at 267.53 ms, and mandelbrot at 91.22 ms, with exact checksums against native. Native remains unchanged by this Mach-only commit and still requires an independently measured correctness repair for the same dynamic-null oracle. Compiler tests passed 73/73 after integration. After M7, the combined root passed 1,918/1,918 default tests and 3,733/3,733 differential fuzz checks with seed 20260713.
Accepted experiment M7: LICM constants in integer range proofs
The final range pass could consume LICM constants only after slot compression,
through exact _const_spans. An earlier reconciliation pass decides whether
loop-carried arithmetic is provably integer before compression; at that point
the hoisted divisor exists only in LICM’s _const_slots map. Because the proof
could not see the constant 7, it conservatively typed (s + i * j) % 7 as
generic num, and QBE repeatedly boxed, decoded, divided, and truncated the
inner-loop recurrence.
LICM allocates each hoisted constant in a fresh logical slot with one producer.
The reconciliation range pass now consumes that exact pre-compression fact.
Compression then transports it to an instruction-bounded physical-slot span
and destroys _const_slots; there is no global or stale physical-slot fallback.
A regression verifies the divisor proof, no-overflow instruction fact, bounded
accumulator slots, and retained integer write type.
In a controlled fresh pair, loop_nested native improved 29.41 -> 12.17 ms
(-58.6%), while QBE IL shrank 15.3% and assembly shrank 9.1%. Root integration
reproduced 12.17 ms and exact result 2; its current 24,673-byte IL and
22,749-byte assembly are 18.0% and 13.3% below the immediately preceding root
artifact. Mach remains unchanged because this is native integer-region
lowering. There is no target metadata, heap, frame, or runtime-code cost.
The same investigation fixed an independent native correctness seam:
eq_tol/ne_tol previously read their tolerance directly from a frame slot,
which is stale when the operand is promoted, raw, or elided. All three operands
now use the backend’s value seam. This receives correctness rather than speed
credit. Root validation passed 74/74 compiler tests, 1,918/1,918 default tests,
and 3,733/3,733 fuzz checks with seed 20260713. The sandboxed default suite’s
six local-bind failures disappeared in the required unrestricted run.
Accepted experiment N1: native raw-double regions
The implementation and its semantic boundary matrix are recorded in
docs/plans/perf-raw-numeric.md. On nan64, promoted numeric locals and confined
scratch values may remain QBE doubles. A NaN poison represents Pit null inside
the raw region; canonical materialization restores tagged int, short float, or
null at frame, call, capture, GC, and generic-use seams. F32 and fixed retain
their representation-specific tagged lowering. No raw value is stored in a GC
slot, and the experiment adds no runtime metadata, frame field, or heap state.
The isolated experiment cleared 1,918 default tests, 7,510 fuzz checks, f32 and fixed representation fixtures, and dedicated arithmetic/null/call/capture/GC oracles. Root reproduced the intended wins more strongly: mandelbrot 27.86 -> 9.21 ms (-67.0%) and float_math 6.38 -> 1.39 ms (-78.2%), with exact results. The exchange is static code: mandelbrot assembly 33,968 -> 40,052 bytes (+17.9%); float_math grows only 541 bytes (+2.0%).
The first root macro matrix found a release-blocking regression. Spectralnorm
grew 256,300 -> 269,674 assembly bytes (+5.2%)
and regressed 173.22 -> 192.83 ms (+11.3%); fannkuch grew 120,825 -> 124,967
bytes (+3.4%) and regressed 200.74 -> 209.57 ms (+4.4%). The backend currently
knows that a slot is broadly num, but not whether this exact value was proven
non-null by a preceding guard or was produced by nullable arithmetic. It
therefore emits repeated number tests on generic paths that cannot retain a raw
value. That checkpoint was not accepted at the time.
Point-local guard provenance (N4), structural raw-island eligibility, and the closed-world parameter repair (N5) removed the bad code shape. A controlled raw-on/raw-off comparison on the composed tree produced:
| benchmark | raw on | raw off | runtime | IL / assembly effect |
|---|---|---|---|---|
| mandelbrot | 9.108 ms | 73.838 ms | -87.7% | -16.7% / -29.0% |
| float_math | 1.853 ms | 11.904 ms | -84.4% | -6.3% / -4.8% |
| spectralnorm | 125.630 ms | 125.938 ms | -0.24% (neutral) | -1.8% / -2.7% |
| fannkuch | 239.19 ms | 245.50 ms | timing noise | byte-identical |
The temporary raw-disable switch was deleted. Every lane returned the exact golden. Spectralnorm showed host drift in an earlier same-session pair, so it receives only a neutral claim; the static artifact is nevertheless smaller and the old macro penalty is gone. N1 is therefore accepted: it is transformative for float-heavy raw regions and structurally declines to engage in fannkuch.
An independent prerequisite fixes qbe_new_float64: it called the force-float
internal encoder instead of public canonical Pit_NewFloat64, so exact integer
results from generic native arithmetic lost their integer lane. The focused
LCG/remainder oracle now returns 15 and d_field returns 290006 in both lanes.
N7 removes one remaining duplicate dynamic-number dispatch. The old generic
path first tested is_num and then separately branched again to decode int or
float. Each representation now supplies one value_to_double branch tree that
decodes valid numbers and maps all other values directly to the NaN poison.
Sticky-null semantics and canonical materialization are unchanged in nan64,
f32, and fixed. On the current composed spectralnorm tree this moved native
128.74 -> 124.65 ms (-3.2%), IL 174,589 -> 170,554 bytes (-2.3%), and assembly
114,160 -> 112,376 bytes (-1.6%). It adds no target data or dynamic memory.
The composed root passed 79/79 compiler tests, 1,918/1,918 unrestricted default
tests, and 3,733/3,733 deterministic fuzz checks with seed 20260713. All seven
native canonical/raw boundary rows retained their independent goldens.
M11 then repaired the corresponding Mach oracle. Numeric mcode may legitimately
carry null after divide-by-zero or a representation overflow, but modulo,
remainder, and the unary numeric handlers decoded that sentinel with the
non-null helper and could turn it back into zero. They now use the same
null-absorbing decoder as arithmetic. Separately, out-of-range source literals
are canonicalized to null before Mach lowering; an access constant containing
that value previously emitted no instruction and exposed the zeroed frame slot.
Mach now emits LOADNULL (and handles boolean constants completely). The full
8,388,607-bit boundary oracle and all six companion raw/canonical rows agree in
both lanes. The focused compiler suite is 80/80. A paired one-million-iteration
intrinsic_ops run was neutral (23.725 -> 23.770 ms Mach). There is no dynamic,
per-code, frame, or heap cost. On the release Darwin build, __text measured
1,030,980 -> 1,024,712 bytes, the mapped __TEXT segment stayed 1,196,032
bytes, and the file grew by 32 bytes; in other words, no mapped-page increase.
Validation completed with 80/80 focused compiler tests, 1,918/1,918 default
tests, and 3,733/3,733 deterministic fuzz checks at seed 20260713. The first
sandboxed default-suite attempt had six expected loopback-bind denials; the
normal-permission rerun was fully green.
Accepted experiment N2: direct integer constants, dead carriers
The exact _const_spans repair initially used LICM constants only for range
proofs, raw numeric reads, and constant division. Other native handlers still
loaded the physical frame slot, so functions that could not registerize all
slots paid for an integer carrier store and repeated carrier loads even though
the final instruction index identified the value exactly.
The QBE emitter now reconstructs an int32 constant’s tagged value directly at
s_read. It deletes the corresponding access initialization only after a
conservative scan of the constant’s exact final-index span proves every read
goes through s_read or read_double. Any helper that consumes a physical
slot number directly blocks the deletion. Promoted slots are also excluded:
QBE already folds those carrier registers, and eliding their seed would leave
an uninitialized SSA value. Physical-slot reuse outside the span remains a
normal frame/register read. This changes neither semantic mcode nor runtime
layouts, and canonical mcode still strips the compiler-only span facts.
Controlled source-toggle measurements on the composed tree gave:
- fannkuch: 199.78 -> 191.03 ms in the selected matrix (-4.4%); an independent 11-sample focused run measured 190.04 ms. IL fell 172,344 -> 170,463 bytes (-1.09%) and assembly 118,136 -> 111,662 bytes (-5.48%);
- spectralnorm: an off median of 119.26 ms bracketed on medians of 116.63 and 118.12 ms (-2.2% and -1.0%). IL fell 170,554 -> 169,771 bytes (-0.46%) and focused assembly 112,376 -> 109,948 bytes (-2.16%);
- binarytrees assembly fell 106,598 -> 104,860 bytes (-1.63%) with identical 57.61 ms matrix medians;
- loop_nested IL fell by only 28 bytes and assembly was identical, confirming that already-promoted constants receive no artificial speed credit;
- mandelbrot and float_math artifacts were unchanged.
Both distinct-constant/one-physical-slot and constant-then-runtime-value
collision oracles remain exact (352 and 1,000,000 in Mach and native). A
new IL regression uses an uninlined call to disable registerization, proves the
constant frame store is absent, and proves the tagged literal is substituted at
its use. The focused compiler suite is 81/81. There is no heap, frame, side
table, or runtime-text cost; affected AOT programs are smaller.
Final validation passed 81/81 focused compiler tests, 1,918/1,918 default tests,
and 3,733/3,733 deterministic fuzz checks at seed 20260713.
Rejected experiment A2: direct exact-array reads
QBE can use an exact array span to inline forwarding chase, index bounds, and element load instead of calling the dynamic property helper. The prototype was semantically exact and changed no mcode/runtime state, but the existing helper is already compact: array_read improved only 26.04 -> 25.30 ms (-2.8%) while IL grew 2.9% and assembly 1.5%. Spectralnorm grew from 390,232/256,300 to 393,832/257,477 IL/assembly bytes and measured 173.22 -> 174.55 ms. The implementation was deleted. A second retry after N5/N6 reached only 25.348 -> 25.225 ms (-0.49%) on array_read while growing IL 2.70% and assembly 1.42%. Spectralnorm grew IL 3.47% and assembly 2.38%; alternating timing pairs crossed, for only about 0.3% by average and no reproducible win. Future array work should remove surrounding representation/guard traffic as a region, not paste a larger helper body into every access site.
Accepted experiment A3: shared exact-array helper arm
A2’s useful part can be expressed once rather than copied at every read site.
The existing private __load_index_ss QBE helper now has an exact mode selected
by the complement of its array-slot argument. Its ordinary non-negative ABI is
unchanged. The exact arm follows forwarding headers, checks negative and upper
bounds, loads the element, and returns null out of bounds; it omits only the
receiver and integer-key checks already established by compiler facts.
Composition review found and fixed an important first-version oversight. The
initial selector proved only the receiver and therefore could have treated an
unknown key as a tagged integer. The accepted selector independently requires
both an exact array receiver and exact int key, using immutable _param_types
for parameters or final-index _type_spans for locals. A regression exposes an
exact array through a function whose key remains externally unknown. A3 keeps
that site dynamic; A7 below later validates the key inline before selecting this
same exact helper. The dual-lane execution oracle covers an in-bounds read plus
negative and exact-upper-bound null results.
On the composed N2 tree, a controlled source toggle gave spectralnorm native medians of 119.44 ms exact, 121.18 ms dynamic, and 118.41 ms exact again. The array_read micro moved 25.12 -> 25.01 ms. At exact sites the spectral artifact shrinks 170,904 -> 170,659 bytes IL and 110,668 -> 110,525 bytes assembly. Including the one shared arm, however, the complete artifact is 888 bytes IL and 577 bytes assembly larger than N2 alone (about 0.52% each). This is the real tradeoff: no heap, frame, actor, side-table, or semantic-mcode change, but a small fixed amount of native text per generated unit. It is worth retaining in the fastest profile for the repeatable spectralnorm gain; a future ROM-minimal profile may omit it. Final validation passed 84/84 focused compiler tests, 1,086/1,086 isolated VM checks, 1,918/1,918 warmed default tests, and 752/752 deterministic differential-fuzzer checks at seed 20260713.
Accepted experiment A7: native checked-array-load regions
Fannkuch still carried three compiler-generated regions of this exact form after A3:
is_int scratch, key
jump_false scratch, load_null
load scratch, exact_array, key
jump load_done
load_null:
null scratch
load_done:
move result, scratch // optional
The native emitter used to materialize the predicate as a tagged Pit boolean in the frame, reload it for the branch, call a helper into the scratch slot, cross two labels, then copy the element to its semantic destination. The new native-only region recognizer emits one key-tag test, calls A3’s existing exact array helper directly into the semantic result slot, and writes null there on the non-integer edge. The helper still follows forwarding headers and performs negative/upper-bound checks, and the loaded element remains an unchanged tagged PitValue. There is no element-type assumption, raw array storage, mcode change, new helper ABI, or runtime-layout change.
The recognizer is deliberately strict. The receiver must be exact at the load’s final instruction index. Both generated labels must have exactly one explicit predecessor. A trailing scratch move is deleted only if a small CFG liveness walk proves the scratch dead on every successor; otherwise the scratch remains the result. Functions with disruption handlers decline the fold. Promoted keys are spilled before the helper reads the frame. These gates preserve dynamic keys, mixed elements, mutation/aliasing, stoned reads, GC forwarding, null on a non-integer key, and null for negative or out-of-bounds integer keys. A manual IR regression adds a second predecessor to the null label and proves the region stays unfused.
A smaller first arm merely transported the adjacent integer fallthrough fact to the load selector. It raised exact-helper sites from four to seven and reduced fannkuch from 171,432 to 171,309 bytes of QBE IL and from 111,768 to 111,693 bytes of assembly text, but timing was only about 0.3% and crossed noise. It was deleted. Folding the surrounding region is the part that matters.
Two separately compiled dylibs were loaded together and timed in alternating
order for 31 pairs. Guard-only fannkuch measured 188.89 ms median; the region
fold measured 184.17 ms (-2.5%), with exact result 3000008629 in every run. The
final ungated source measured 183.75 ms standalone. Emitted fannkuch shrank
171,432 -> 170,411 bytes of IL, 111,768 -> 111,220 bytes of assembly text, and
30,484 -> 30,348 bytes of linked __text; the 98,304-byte dylib file and mapped
segments remain unchanged because of page alignment. There is no target heap,
frame, actor, side table, or per-code metadata. The only memory cost is transient
compiler maps proportional to labels plus three tiny selected-site records.
The recognizer selects zero sites in array_read, mandelbrot, spectralnorm, and binarytrees, so their emitted programs are unchanged. Final observed medians were array_read 24.65 ms, mandelbrot 9.11 ms, fannkuch 183.36 ms, spectralnorm 120.11 ms, and binarytrees 60.27 ms; all Mach/native results agreed. The widened dual-lane oracle returns 127 while exercising an escaped callable, local exact array, heap text/float/record elements, mutation through an alias, stoning, a text key, a negative key, and the exact upper bound. Focused compiler tests pass 88/88, the warmed default suite passes 1,918/1,918, and deterministic fuzzing passes 7,525/7,525 checks at seed 20260713.
Tooling note: one self-host recompilation of the enlarged qbe_emit.cm crashed
in the pre-existing mcode_lower_func_node failure before any benchmark ran.
make seed completed successfully and subsequent compiler/full runs were clean.
This is not attributed to generated program code, but it materially slowed the
experiment and remains worth fixing.
Rejected experiment C2: direct symbols at resolved calls
_resolved_calls already proves a local callee body and arity at an exact call
site. A native-only prototype used that fact to prepare a normal GC-scanned
stack frame holding the real closure, call $pit_fn_N directly, and leave via
the existing LIFO frame path. This retained captured outer-frame behavior,
under-application null fill, stack limits, disruption propagation, and the
same eventual suspension seam; unknown and polymorphic sites stayed on
pit_rt_call_dynamic.
The extra specialization was a loss. With inlining disabled so two million
known calls remained, call_hot moved from 27.06 to 28.67 ms (+6.0%). IL grew
36,749 -> 36,928 bytes and assembly 31,627 -> 31,745 bytes. The direct branch
saved function-kind/code lookup and an indirect function-pointer call, but
added another prep ABI and control merge; the tuned dynamic helper already
does its no-GC frame bump, initialization, push, call, and pop compactly.
Polymorphic call0_plain and closure did not select the path, as intended. The
runtime helper and emitter lowering were deleted. The retained benchmark and
PERF_DISABLE_PASSES option make future call experiments falsifiable without
confusing inlining with call mechanics.
Accepted experiment M12: typed Mach array indexes
Generic Mach load and store handlers must distinguish arrays from records
and validate the key representation before reaching the compact array helper.
Streamline already proves both facts at many hot sites. Mach lowering now
consumes the same two independent proofs used by native A3: immutable
_param_types or an exact final-instruction _type_spans interval must prove
the receiver is an array and the key is an integer. Inline-spliced hosts may
use exact intervals but may not fall back to function-global write types.
An exact site is emitted directly as the target-internal load_ai or
store_ai opcode. This does not add a semantic mcode operation. Each remains
one 32-bit Mach instruction, so the measured Mach blobs are unchanged in size
(array_read 4,472 bytes, fannkuch 34,032, spectralnorm 30,176). Direct lowering
also deliberately avoids the serialized one-byte-per-instruction fact sidecar:
programs that had no arithmetic facts do not acquire a fact section merely for
array access. The loaded program gains no table, frame slot, heap allocation,
or actor state. Lowering temporarily allocates at most three additional
one-byte-per-slot type maps plus 12 bytes per retained array span, all freed
before the Mach artifact is returned. On the release Darwin build the linked
__text happened to move from 1,024,712 to 1,024,628 bytes and the mapped
__TEXT segment stayed 1,196,032 bytes; this is treated as zero mapped-ROM/RAM
growth, not as a claimed size optimization.
The specialized handlers preserve the generic array helper’s negative and upper-bound behavior, stone check, forwarding repair, resize/GC reload, and write barrier. Debug contracts disrupt if compiler metadata ever selects the handler for a non-array or non-integer. Executable regressions count dispatched opcode names: optimized source must execute both typed load and store, while bare semantic mcode without proof metadata must execute the generic handler.
A temporary compile-time A/B switch, deleted before acceptance, produced these fresh-process, three-run medians with exact independent results and identical dispatch totals:
- array_read: 23.44 / 25.15 / 22.81 ms (typed/generic/typed), a 6.8-9.3% win;
- fannkuch: 270.87 / 280.84 / 271.51 ms, a 3.3-3.5% win. Of 17.47 million loads and 14.83 million stores, current proofs select 6.16 million loads and 3.27 million stores;
- spectralnorm: 194.27 / 200.09 / 198.06 ms, a 1.0-2.9% win; all 6.40 million array loads and 16,000 stores select the typed handlers;
- binarytrees has no selected array access and is behaviorally unchanged.
The final no-toggle build passed make seed, 86/86 compiler-specific tests,
1,918/1,918 default tests, and 752/752 deterministic differential-fuzz checks
at seed 20260713. Generated boot artifacts were restored after proving the seed
path so this experimental checkpoint contains only source, tests, and its
audit record.
Accepted experiment M13: local integer-guard consumption
M12 exposed the next missing fact without requiring a general dataflow engine.
Fannkuch’s array elements are semantically numbers, so an index loaded from an
array correctly has type num, not globally int. The generated mcode guards
each use with the exact local sequence is_int predicate, key followed by
jump_false predicate, error; the following load executes only on the true
fallthrough. That short-lived fact was not represented in _type_spans, leaving
10.06 million hot reads on the generic Mach handler.
Mach lowering now recognizes only that exact adjacent three-instruction shape.
It requires matching predicate and key slots, jump_false direction, and an
independently exact array receiver. A label, intervening instruction, opposite
branch, mismatched slot, or unproved receiver declines the optimization. The
resulting load uses M12’s existing one-word load_ai; no opcode, mcode, blob
byte, fact sidecar, frame slot, or runtime table is added. An executable
regression loads an index from an array (therefore only num globally) and
proves that both the constant-index read and the locally guarded read dispatch
through load_ai with the exact result.
A deleted A/B environment gate gave fresh-process fannkuch medians of 276.23 /
294.61 / 275.46 ms (local proof on/off/on), an incremental 6.2-6.5% win over
M12. Typed loads rise from 6,163,834 to 16,223,440; generic loads fall from
11,306,664 to 1,247,058. Total dispatches (225,863,029), the 34,032-byte blob,
and result 3000008629 are identical. Array_read and spectralnorm already had
global/exact facts, so their opcode counts and artifacts are unchanged.
The release Darwin build grows linked __text from 1,024,628 to 1,025,228
bytes and unwind information by 8 bytes; the mapped __TEXT segment remains
1,196,032 bytes. This approximately 600-byte shared-runtime cost is worthwhile
for the additional fannkuch shootout gain, even for constrained targets. The
final no-toggle build passed make seed, 87/87 compiler-specific tests,
1,918/1,918 default tests, and 752/752 deterministic differential-fuzz checks
at seed 20260713. Generated boot artifacts were restored after the seed gate.
M14 tried to carry the same local integer proof across the compiler’s complete
checked-store diamond (is_int, length, lower bound, upper bound, store). A
strict recognizer converted all 14.83 million fannkuch stores to store_ai, but
the controlled result regressed: 280.79 and 275.05 ms specialized versus
272.06 ms with the local store proof disabled. The prototype also added 1,272
bytes of linked __text plus 38 bytes of string/unwind section content beyond
M13. The array receiver branch is evidently predictable enough that the larger
lowering recognizer/handler layout has negative value. The recognizer and its
measurement switch were deleted; the rejected result is retained here to
prevent dispatch-count enthusiasm from reviving it without new evidence.
Accepted experiment M15: allocation-free Mach array stores
M14 showed that deleting only the receiver branch was not useful. The larger cost was shared by both generic and typed handlers: every array store called a C helper, reloaded the current frame, and chased forwarding state even when the array already had capacity and no allocation could occur.
Mach now handles that common case in one shared inline helper. It resolves an
old forwarding pointer, preserves the stone check, fills any legal gap with
null, updates length, stores the value, and runs the generational write barrier
inside a GC-forbidden region. Because that path cannot allocate, the handler
writes a resolved receiver back to its frame slot and continues without a
frame reload. Capacity exhaustion delegates to the old helper, which retains
its rooted value, grow/GC behavior, and post-GC forwarding repair. A stone
disruption still reloads the frame before unwinding. The same helper serves
generic store, exact store_ai, and the field-fused store’s defensive array
arm; there is no compiler fact, bytecode, frame, heap, or actor-state change.
A deleted runtime A/B gate produced fresh-process fannkuch medians of 264.73 /
304.50 / 267.01 ms (fast path on/off/on), a 12.3-13.1% win. The final no-toggle
build measured 261.61 ms. The result (3000008629), 225,863,029 dispatches,
34,032-byte Mach blob, allocation, and GC counts are unchanged. Focused
dual-lane checks cover existing and growing arrays, old-to-young pointer write
barriers/self-reference, null stores, and stoned-array disruption. The release
Darwin __text grows from 1,025,228 to 1,026,708 bytes (+1,480); the mapped
__TEXT segment remains 1,196,032 bytes. That shared-code cost is a strong
trade even for constrained targets because it removes work from every hot
existing-capacity array store without adding per-program memory.
The final no-toggle build passed make seed, 55/55 focused Mach tests,
1,918/1,918 default tests, and 752/752 deterministic differential-fuzz checks
at seed 20260713. Generated boot artifacts were restored after the seed gate.
Accepted experiment M16: inline pure Mach array reads
M15 made the common array store allocation-free but every array read still
crossed a C helper boundary. That helper takes a context it does not use and
performs only forwarding chase, bounds checks, and an element load. Mach now
uses an internal static inline form of those same operations in generic,
typed, and field-fused array-read handlers. Text and record reads retain their
existing helpers. There is no allocation, write barrier, semantic mcode,
bytecode, frame, heap, actor-state, or per-program metadata change.
The first timing attempt used a runtime switch inside each hot read and was discarded because the switch itself changed the result. A fair compile-time binary comparison on the complete M15 tree measured:
- array_read candidate 21.77/23.29/23.63 ms, baseline 25.35/25.43/25.69 ms, then candidate 24.15/23.78 ms: normally a 6-8% win, with the best cold pair larger;
- fannkuch candidate 256.37/253.87 ms, baseline 261.83/273.92 ms, then candidate 255.66/260.62 ms: approximately 2-6% on this noisier shootout;
- spectralnorm candidate 207.40/201.80/202.56 ms, which is treated as neutral rather than claimed as a win.
Results and dispatch counts remained exact. The release Darwin linked
__text unexpectedly shrinks from 1,026,708 to 1,025,852 bytes (-856), likely
because the compiler can now eliminate or lay out the old helper/call sequence
more compactly; mapped __TEXT remains 1,196,032 bytes. The important memory
claim is only that this adds no data or per-program memory. The final build
passed make seed, 87/87 compiler tests, 1,918/1,918 default tests, and
5,623/5,623 generated differential cases from 752 iterations at seed 20260713.
Accepted experiment A6: raw exact-array numeric results
A3 still returned a tagged PitValue, after which the generated code stored it
to the frame, reloaded it, tested whether it was numeric, and decoded it for the
immediate arithmetic operation. The new nan64-only arm is selected only when
the receiver is independently proven to be an exact array, the key is
independently proven to be an exact integer, and the compiler’s exact control
shape is load; jump join; null alternate; join; is_num; jump_false; followed
by an immediate multiply or divide. The other arithmetic operand must also be
an exact numeric value. If the loaded value remains live after either the
success or disruption path, the ordinary tagged path is retained.
One shared QBE helper follows forwarding headers, checks both bounds, loads the
element, and decodes it to a raw double. It also writes the original tagged
element into its semantic destination frame slot, so GC scans, disruption
diagnostics, and frame inspection see the same state as before. Invalid,
out-of-bounds, or nonnumeric values produce a quiet-NaN poison in the temporary
raw lane; the existing is_num branch rejects that poison and follows the
unchanged error edge. The null alternate also writes canonical null to the
frame. The helper is emitted lazily only in generated units that contain a
proven site. No semantic mcode operation, runtime object layout, actor state,
frame slot, heap allocation, or persistent side table was added.
The composed spectralnorm source toggle measured 122.082 ms with the arm off,
then 114.509 and 114.919 ms with it on; a final repeated on run measured
114.232 ms. The controlled artifact changed from 170,659 to 169,495 QBE IL
bytes (-1,164, -0.68%) and from 110,525 to 110,740 assembly bytes (+215,
+0.19%). The small assembly cost is shared helper/control text, not replicated
per element access, and is justified by the repeatable approximately 6% macro
win. raw_array_numeric independently checks int and float array elements and
returns exactly 3.75 in both Mach and native lanes.
A broader first arm allowed addition and subtraction. It made array_read
about 31% slower (25.15 -> 33.03 ms), because preserving the tagged result is
cheaper for an integer element that immediately takes the integer arithmetic
path. That arm was rejected. The final multiply/divide and overflow-proof gate
makes array_read byte-for-byte identical to the disabled build: 37,112 IL
bytes and 31,725 assembly bytes, with a selected native median of 23.66 ms.
Mandelbrot, fannkuch, and binarytrees retain exact results and do not acquire a
helper unless their final proof/use shape qualifies.
The liveness audit also found that the shared fixed-shape use table omitted the
source operand of get(dest, source, depth). Treating get as unknown made the
analysis reject a safe spectral site after an unrelated inlined helper. The
table now records source position 2. The dedicated
test_qbe_raw_array_liveness_tracks_inlined_get_source fixture contains that
shape; deleting the fact makes exactly that test fail because the raw helper is
not selected. This correction can only make liveness more accurate for a known
read operand; unknown and variable-shape instructions remain conservative.
Final validation passed 87/87 focused compiler tests, 1,918/1,918 default tests, and 3,733/3,733 deterministic fuzz checks at seed 20260713. A broader invalid/OOB native disruption oracle exposed an existing nested-native disruption halt both with A6 enabled and disabled, so it is not attributed to this experiment; the helper’s error semantics are instead covered structurally and by the unchanged generic error edge.
Accepted experiment M17: remove TCO’s consumed callee lookup
Self-tail-call elimination runs after the ordinary optimization cycles. It
replaces the resolved call with parameter rebinds and a jump, but it previously
left the pure level-one get that loaded the self closure for that call. No
later dead-value pass removed it. tco_self therefore chased the outer frame
and loaded the same unused function value on every one of five million loop
iterations.
The TCO pass now performs one narrow post-rewrite use scan and replaces only an
unused, non-captured get destination with a compiler nop. It does not attempt
general dead-code elimination and cannot remove allocating, disrupting, or
otherwise observable operations. Final nop compaction transports the existing
instruction-indexed facts in the usual way.
On the current composed tree the exact Mach result remained
12500002500000; the dynamic census changed from 35,000,025 to 30,000,025
instructions and getup from 5,000,002 to 2. The Mach artifact shrank from
2,240 to 2,176 bytes. An eleven-sample focused run moved 52.125 to 46.212 ms
(-11.3%). Native QBE IL shrank 32,756 to 32,361 bytes and assembly 28,139 to
27,907 bytes; native timing remained about 30 ms, so it receives size but no
speed credit. There is no runtime code, frame, heap, actor state, bytecode
metadata, or compiler side table. A focused compiler regression requires both
the TCO loop label and absence of the consumed get; the compiler suite passed
91/91. Full and fuzz gates remain pending composition with the suspension work.
Accepted experiment M18: fuse checked Mach array stores
Fannkuch still executed the compiler’s complete checked-store diamond for every
array write: integer guard, length load, lower and upper bounds, store, and the
branches around the cold failure arm. After final instruction numbering,
streamline now recognizes only that exact region and emits sparse
_mach_checked_stores pairs containing its head and nine- or ten-word length.
It also proves that the lower bound is exactly zero at that use. Mach lowering
validates the compact metadata, preserves load-time incoming-edge and disruption
checks, and stamps a target-private fused handler. This adds no semantic mcode
operation. The compiler fact is stripped from canonical mcode, and the runtime
no longer carries a second full instruction-pattern matcher.
All six fannkuch regions fuse. The exact result remains 3000008629; dynamic
Mach instructions fall from 225,863,029 to 168,026,476 (-25.61%). Exclusive
fresh-process outer medians moved from 265.290 to 227.443 ms (-14.27%). The
cleanup is also faster than the first fusion implementation’s noisy 239.585 ms
median, while recovering 3,120 bytes of its linked __text. Native artifacts
are unchanged, so M18 claims no native speed improvement.
Against the no-fusion binary, Darwin linked __text grows 1,034,136 to
1,037,928 bytes (+3,792); total __TEXT section content grows 4,227 bytes. The
mapped __TEXT segment remains 1,212,416 bytes, the dylib file grows only 32
bytes after padding, and the serialized fannkuch Mach file remains 4,254 bytes.
There is no persistent program, heap, frame, or actor-state cost. Parsing the
fact and checking incoming edges adds about 2.06 microseconds once when a new
code object is loaded; loaded code is cached. This is a strong default trade,
but a future extreme sub-288 KiB build-profile audit may compile the feature
out. There is deliberately no runtime toggle.
Final validation passed 93/93 focused compiler tests, 1,086/1,086 VM tests, 1,918/1,918 default tests, and 3,733/3,733 deterministic fuzz checks at seed 20260713. Malformed, overlapping, and invalid-head metadata arrays fail closed; the executable regression also covers append-at-length and canonical fact stripping.
Correctness prerequisite: actor reductions and native suspension
The implemented architecture, reduction policy, memory limits, cross-engine
matrix, and measured tax are recorded in
docs/plans/perf-native-suspension-unified.md.
The composed candidate now amortizes checks at backedges and call boundaries, spills promoted/raw values before slow paths, persists resume segments in Pit frames, unwinds the host C stack, and resumes mixed Mach/native chains under a moving GC. It adds no semantic mcode operation or per-frame word. C modules remain the explicit non-freezable boundary.
The main remaining performance issue is call-only recursion: native fib is about 10% slower because it cannot rely on a loop poll. Slow poll/resume code also grows generated text, notably spectralnorm assembly by about 10%. Future work may change accounting placement or outline cold code, but must retain one bounded observation path around every cycle and across Pit call chains.
Source: plans/archive/perf-2026-07/perf-campaign-2026-07.md