Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
pit performance — state of the world & the next part
Written 2026-07-10 at the close of the first perf arc, on the fully merged
tree (perf arc + distribution/platforms line, make check ALL GREEN). This is
the handoff document: the chart, the remaining gaps with their proposed
solutions — including proposals the designer has explicitly rejected or
deferred, kept here so the reasoning isn’t re-litigated — and the working
criteria this effort runs under.
The chart (2026-07-10, merged tree, ms, same machine, all match=Y)
| bench | pit-mach | pit-native | Lua | LuaJIT | qjs | node | bun | Ruby | mruby | OCaml | C# | s7 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| arith_int | 28.8 | 10.4 | 28.2 | 15.6 | 42.4 | 9.4 | 10.0 | 60.5 | 70.2 | 10.0 | 8.4 | 11.9 |
| arith_poly | 24.0 | 7.7 | 30.7 | 10.9 | 37.7 | 6.7 | 7.2 | 60.8 | 80.0 | 7.1 | 6.3 | 55.2 |
| loop_nested | 26.0 | 12.0 | 36.0 | 19.3 | 47.1 | 11.7 | 12.6 | 69.1 | 85.2 | 11.5 | 9.5 | 15.1 |
| float_math | 20.1 | 7.1 | 9.1 | 1.2 | 11.9 | 1.2 | 1.3 | 26.0 | 24.4 | 1.3 | 1.2 | 18.0 |
| fib | 78.2 | 28.9 | 22.4 | 3.3 | 34.2 | 4.1 | 2.6 | 37.8 | 45.0 | 1.9 | 1.1 | 4.8 |
| call_hot | 16.5 | 7.8 | 31.5 | 9.6 | 32.3 | 5.8 | 6.3 | 49.0 | 73.4 | 6.6 | 5.4 | 39.1 |
| closure | 60.1 | 75.4 | 28.2 | 0.5 | 26.6 | 2.7 | 1.0 | 35.7 | 45.6 | 3.2 | 0.5 | 43.1 |
| array_read | 30.5 | 12.6 | 20.4 | 14.0 | 35.9 | 8.8 | 9.4 | 50.0 | 65.8 | 10.0 | 7.7 | 28.4 |
| array_grow | 3.0 | 2.2 | 2.6 | 1.0 | 2.8 | 0.9 | 0.5 | 4.5 | — | 2.5 | 0.5 | 15.1 |
| record_new | 34.1 | 18.7 | 22.2 | 1.5 | 22.4 | 1.0 | 1.1 | 17.3 | 27.1 | 1.1 | 1.2 | 24.3 |
| string_concat | 0.37 | 0.29 | 11.2 | 9.1 | 0.5 | 0.04 | 0.04 | 9.8 | 8.5 | 13.3 | 23.2 | 29.2 |
| gc_churn | 10.5 | 8.2 | 11.8 | 2.0 | 8.9 | 0.09 | 0.25 | 5.7 | 8.8 | 0.18 | 0.6 | 16.4 |
| d_field | 20.8 | 6.5 | 8.2 | 1.5 | 5.9 | 0.9 | 1.1 | 19.9 | 28.7 | 1.4 | 0.8 | 10.2 |
| record_field | 113.3 | 36.8 | 39.1 | 18.6 | 47.9 | 12.5 | 11.7 | 95.1 | 123.8 | 18.3 | 13.1 | 39.4 |
Reading: pit-native is LuaJIT-or-better on integer compute, arrays, and field
access, Lua-parity on record_field, hardware-tier-ish on floats (residual
below). The bold-bad rows: fib and closure are slowest-in-field (behind
mruby), and closure’s native is slower than mach (every call takes the
trampoline). Allocation-heavy rows (gc_churn, record_new vs OCaml/C#) are
gated on the GC design conversation. Cross-language harnesses:
aot_bench/cross/bench.{lua,js,rb,ml,cs,scm}; pit driver aot_bench/vs.ce;
dynamic op census aot_bench/census.ce; IL/mcode dump aot_bench/dumpil.ce.
THE INTEGRATED CHART (2026-07-12, work/perf-integration @ 468273d8)
All seven campaign lines merged; make check ALL GREEN + lint; every row
match=Y both lanes (fannkuch after the splice-guard fix below). Same machine,
sequential runs, fresh daemon per fixture; cross-language columns re-measured
in the same session window. Values identical across all runtimes per bench.
Micro (ms; nursery OFF = default)
| bench | pit-mach | pit-native | Lua | LuaJIT | qjs | node | bun | Ruby | mruby | OCaml | C# |
|---|---|---|---|---|---|---|---|---|---|---|---|
| arith_int | 19.6 | 9.9 | 24.1 | 14.8 | 41.3 | 9.4 | 10.0 | 57.0 | 70.3 | 9.9 | 8.3 |
| arith_poly | 18.3 | 7.7 | 28.6 | 10.3 | 36.6 | 6.7 | 7.2 | 57.6 | 79.2 | 7.1 | 5.8 |
| loop_nested | 21.4 | 11.8 | 35.6 | 18.9 | 46.8 | 11.6 | 12.4 | 66.2 | 83.6 | 11.5 | 8.9 |
| float_math | 17.8 | 7.1 | 6.9 | 1.2 | 11.7 | 1.2 | 1.3 | 25.4 | 26.0 | 1.3 | 1.2 |
| fib | 50.8 | 28.1 | 20.9 | 2.7 | 33.2 | 4.0 | 2.5 | 37.4 | 44.4 | 1.6 | 1.2 |
| call_hot | 13.1 | 7.8 | 30.6 | 9.0 | 31.5 | 5.8 | 6.2 | 45.9 | 72.3 | 6.6 | 4.9 |
| closure | 52.9 | 28.8 | 50.1 | 10.9 | 62.2 | 7.3 | 6.8 | 80.2 | 102.7 | 8.1 | 6.3 |
| array_read | 25.1 | 11.9 | 20.5 | 12.7 | 35.1 | 8.7 | 9.3 | 47.9 | 64.9 | 10.0 | 7.4 |
| array_grow | 2.8 | 2.8 | 2.5 | 0.8 | 2.7 | 0.9 | 0.5 | 4.2 | — | 2.6 | 0.5 |
| record_new | 30.9 | 22.0 | 30.0 | 8.0 | 23.8 | 1.1 | 1.2 | 29.2 | 38.4 | 20.9 | 9.6 |
| string_concat | 0.27 | 0.58 | 13.3 | 8.6 | 0.5 | 0.04 | 0.04 | 9.7 | 8.5 | 18.7 | 27.0 |
| gc_churn | 10.4 | 9.6 | 16.2 | 4.7 | 9.8 | 0.35 | 0.5 | 8.1 | 11.9 | 4.7 | 3.0 |
| d_field | 24.9 | 10.9 | 12.7 | 3.1 | 14.6 | 4.1 | 2.6 | 31.0 | 44.5 | 17.5 | 7.8 |
| record_field | 120.8 | 62.6 | 76.3 | 20.5 | 76.1 | 20.6 | 14.5 | 161.8 | 213.2 | 96.9 | 41.8 |
Nursery-ON supplement (PIT_GC_NURSERY=1): gc_churn mach 7.4 (−29% vs off, beats every simple-GC runtime), record_new mach 25.1, closure unchanged.
Shootout (composed; ms)
| bench | pit-mach | pit-native | Lua | LuaJIT | qjs | node | bun | Ruby | mruby | OCaml | C# |
|---|---|---|---|---|---|---|---|---|---|---|---|
| mandelbrot | 101.9 | 27.0 | 50.8 | 39.0 | 59.1 | 2.0 | 2.8 | 134.8 | 164.9 | 3.7 | 2.7 |
| fannkuch | 321.9 | 284.9 | 142.4 | 16.4 | 185.6 | 11.3 | 9.3 | 440.3 | 383.9 | 12.9 | 10.2 |
| spectralnorm | 289.2 | 161.2 | 172.3 | 7.0 | 269.8 | 6.8 | 3.7 | 410.1 | 369.0 | 10.0 | 3.4 |
| binarytrees | 93.2 | 57.0 | 57.2 | 20.0 | 42.1 | 2.7 | 3.1 | 33.8 | 30.8 | 1.1 | 2.9 |
| nbody | blocked | — | 62.9 | 3.0 | 72.7 | 1.8 | 2.2 | 135.6 | 238.7 | 1.5 | 1.6 |
(nbody: vs.ce lanes don’t link C modules — math.sqrt; filed gap.)
Campaign deltas (integrated vs the honest baseline)
mach: fib −33%, closure −31%, record_field −30%, arith_int −28%, d_field −22%, record_new −23%, call_hot −20%, spectralnorm −40%, fannkuch −39% (from 530/pre-campaign). native: closure 71.8→28.8 (−60%), spectralnorm −50%, mandelbrot and binarytrees now CORRECT (were miscompiled) with mandelbrot native 27.0 beating Lua and LuaJIT. mach ≈ Lua-or-better on 9 of 14 micro rows (was 7); the honest remaining mach gaps: float (rep), fib/deep recursion, array_read locality, record rows vs Lua’s fused interpreter ops.
Composition bug caught by this re-run (criterion 6 receipts)
fannkuch went match=N on the first integrated run: a fusion-w2 typed handler (add_iip) trusted a slot write-type fact that is not splice-sound on inline-spliced code — mach-only wrong (native was saved by its float-taint defense). The wave-2 debug contracts pinpointed it (fannkuch.ce:71, pc=455) exactly as designed. Interim guard shipped: hosts with _has_inline_splice skip fact-typed handler selection; root cause (splice-time fact merge) filed for the fact-soundness follow-up.
ADDENDUM 2026-07-10 late: the honest chart (corrected benches)
Designer directive: benchmarks must measure real work — records received as messages (shape unknown at the access site), honest dynamic calls, allocations that actually escape. Audit of the old suite found five rows measuring something else:
- closure / record_new / gc_churn were fully or partially optimized away by JIT runtimes (allocation sinking, single-target inlining). LuaJIT’s closure “0.5” was the loop reduced to a register increment; node’s gc_churn “0.09” was V8 sinking every allocation.
- d_field / record_field used one local literal record, so shape/offset runtimes ran static offset access while pit ran its generic path — apples-to-oranges for the records-as-messages scenario.
- Even after adding 8 distinct shapes, a cyclic
i % 8rotation was still trace-unrolled by LuaJIT (d_field 1.18ms — each unroll slot got its own specialized lookup). Real message streams are not cyclic.
Corrections (same iteration counts; identical results verified across all
runtimes): every allocation escapes into a ring (record_new 1024-slot,
gc_churn 512-slot); closure calls 8 counters through one site; d_field /
record_field RMW 8 records of distinct key-sets/orders through one site;
target selection is a small int32-safe LCG (x = (x*1103+12345) % 100003,
x % 8) — data-dependent, aperiodic, uniform across languages. OCaml and C#
record benches now use their generic paths (Hashtbl / Dictionary<string,long>)
— the static-record numbers remain in pit-native-benchmarks.md as the
offset-access ceiling. s7 not installed at re-run time (—).
| bench | pit-mach | pit-native | Lua | LuaJIT | qjs | node | bun | Ruby | mruby | OCaml | C# |
|---|---|---|---|---|---|---|---|---|---|---|---|
| arith_int | 27.2 | 10.3 | 25.1 | 15.2 | 44.4 | 9.5 | 10.1 | 59.1 | 70.6 | 10.1 | 8.5 |
| arith_poly | 23.4 | 7.5 | 28.6 | 10.4 | 38.7 | 6.8 | 7.3 | 59.3 | 79.1 | 7.2 | 5.9 |
| loop_nested | 24.8 | 11.6 | 37.2 | 19.4 | 47.6 | 11.7 | 12.6 | 68.5 | 85.7 | 11.7 | 9.1 |
| float_math | 19.7 | 7.2 | 8.0 | 1.2 | 11.9 | 1.2 | 1.3 | 26.1 | 25.8 | 1.3 | 1.2 |
| fib | 76.3 | 27.2 | 21.3 | 2.7 | 34.7 | 4.1 | 2.6 | 38.5 | 45.7 | 2.0 | 1.1 |
| call_hot | 16.4 | 7.5 | 32.3 | 8.9 | 31.9 | 6.0 | 6.3 | 48.8 | 76.1 | 7.0 | 5.0 |
| closure | 76.8 | 71.8 | 50.2 | 10.8 | 63.5 | 7.6 | 7.0 | 82.2 | 105.7 | 8.2 | 6.4 |
| array_read | 29.4 | 11.8 | 22.3 | 12.8 | 36.0 | 8.9 | 9.5 | 49.8 | 66.5 | 10.1 | 7.5 |
| array_grow | 3.1 | 3.1 | 2.5 | 0.9 | 2.8 | 1.0 | 0.5 | 4.4 | — | 2.7 | 0.5 |
| record_new | 39.9 | 20.6 | 31.8 | 8.4 | 24.4 | 1.1 | 1.2 | 29.8 | 40.6 | 21.3 | 9.5 |
| string_concat | 0.33 | 0.38 | 14.0 | 8.7 | 0.5 | 0.04 | 0.04 | 10.9 | 25.3 | 17.8 | 27.9 |
| gc_churn | 11.5 | 8.4 | 16.6 | 5.0 | 10.2 | 0.4 | 0.6 | 8.4 | 12.9 | 4.8 | 3.1 |
| d_field | 31.8 | 13.2 | 12.8 | 3.3 | 15.2 | 4.3 | 2.7 | 32.5 | 45.5 | 17.9 | 8.0 |
| record_field | 172.4 | 63.7 | 76.8 | 20.3 | 78.3 | 21.3 | 15.0 | 165.7 | 217.4 | 98.2 | 42.8 |
(pit lanes: fresh daemon per fixture, -d detached start, all match=Y;
rows recovered from .pit/log/observe.jsonl. Every runtime computes
identical result values per bench — closure 245453, record_new/d_field
290006, gc_churn 1000, record_field 974489.)
Honest reading. (1) Generic records — the product path — pit-native leads every non-JIT runtime: record_field 63.7 beats Lua 76.8, qjs 78.3, OCaml-Hashtbl 98.2, Ruby 165.7; d_field 13.2 ≈ Lua 12.8, beats OCaml-Hashtbl 17.9. The remaining record gap is to megamorphic-IC JITs (bun 15.0 / LuaJIT 20.3 / node 21.3, ~3×) and C# Dictionary (42.8, ~1.5×) — probe/layout/sidecar territory, no shapes needed. (2) Honest dynamic-call tier is 6–11ms (bun 7.0, C# 6.4, node 7.6, OCaml 8.2, LuaJIT 10.8), not the old inlined 0.5: pit’s 71.8 is ~9× off, and call_hot native 7.5 is ALREADY at that tier — the gap is specifically the dynamic-callee sequence (trampoline/arity/env), not calls in general. fib (27.2 vs OCaml 2.0) remains the recursion outlier. (3) Allocation: with sinking defeated, record_new pit-native 20.6 sits at the top of the non-generational pack (Lua 31.8, qjs 24.4, OCaml-Hashtbl 21.3); the nursery tier (node/bun ~1.1, LuaJIT 8.4, C# 9.5) is the remaining distance. gc_churn 8.4 already beats every simple-GC runtime. (4) float_math and array_grow unchanged (emitter residual / growth policy).
Bench-protocol regressions found during the re-run (merged-tree daemon lane; each cost a debugging session and is worth fixing deliberately):
- Resolved in the CLI one-shot redesign. A bare script receives
{}and the terminal waits for its reply. Nonempty argv is sent as a canonical{type:"cli", args, cwd, context?}envelope and adapted by the actor. There is no implicit help probe or two-second courtesy kill. - Policy watchdog defaults are now fast 0.1s (pause tick) / slow 1s / 3 strikes (internal/policy.ce PARAMS); the pause fired mid-turn on a 1.7s bench turn (“pausing (turn exceeded 1000ms)”) — the pause machinery works (relevant to §6/§1), but the defaults strike long compute turns.
- The shop-level 2-arg raises (
actor.set_actor_slow_timer(id, ms)etc.) silently refuse non-underling targets — vs.ce’s raises were placebos. The self-targeted 1-arg forms (actor.set_fast_timer(ms),set_slow_timer,set_slow_strikes) work; vs.ce now uses those. $unneeded’s reap wrapper callsstop_endowment()with no argument → “Can only stop an underling actor.” — an actor cannot reap itself through the endowment (engine_lite.cm ~1941).
Decision-bank updates 2026-07-11 late (designer rulings):
- Barrier audit: GO — DELIVERED (work/gc-nursery-productize @ 33bcb395: verifier silent, card-only default, big-old regression fixed, 8KB tiny-actor floor proven; 4 C sites + 4 emitted-IL stores barriered).
- Promotion aging: DECIDED — Erlang watermark scheme (2026-07-11): promote on SECOND survival, age encoded by address vs a nursery watermark, ZERO per-object header bits (the designer’s constraint). In flight on the same branch; expected to convert the churn parity into the measured −22-40% win by keeping the medium-lifetime population out of old space.
- Static arity: stamped — resolved call sites get compile-time arity checking (error on mismatch, matching runtime semantics) + arity-proven in the callee-identity fact; added to the inliner agent. LTO later widens resolution to nearly all sites; apply/dynamic dispatch keeps the runtime check.
- Uniform-4 arg region: BANKED as an exploitable idea; schedule inside the next call arc alongside the mach direct-call fast path (same code region, A/B both).
- Tranch re-census: DONE (2026-07-11) — post-elision residual on record_field: is_record 8M (7.1% of all ops) + is_num 6M (~5%), is_func 2M, is_stone ~0. The residual is structurally un-elidable (the read fallback’s null-merge blocks predicate forwarding), so tranch-family type-test branch ops (branch_not_record / branch_not_num, folding guard+branch 2→1 in mcode, both lanes) retain a real ~10-12% dispatch target on record-heavy code. DESIGNER RULING (2026-07-11): NOT mcode ops — the win is mach-dominant (native emits the same test+branch IL either way), so per the mcode-must-serve-both-lanes criterion they are BANKED as a MACH FUSION candidate (wave-3: fold is_X + jump pairs at load time into mach-internal opcodes, alongside the immediate-key compare forms). Guard principle satisfied: elision (guard-CSE) ran first and the census proved the residual structurally irreducible — fusing it hides no compiler deficiency. tranch_true/false themselves stay parked with the wary decision.
- Switch lowering: GO (compare-tree phase, existing ops) — queued as the next pit-compiler slot after the inliner agent, BUNDLED with per-pass disable infrastructure (env-controlled pass registry: every streamline pass individually switchable for A/B benching, miscompile bisection, and pass-subset fuzzing).
- wary semantics: revisit later (designer: “our booleans have always been a bit odd”).
- SROA budget: clarified as administrative — SROA stays on; the compiler- budget GATE’s slot metric predates slots-for-instructions trades; refresh the baseline slot rows on a quiet machine at integration.
- Immediate-ASCII keys: reframed per-rep (see §5 discussion) — the seam makes PIT_ASCII_MAX_LEN a per-rep capability (32-bit reps naturally set it ~0 = all-interned). DESIGNER: run the PIT_ASCII_MAX_LEN=0 measurement build AFTER the current compiler wave lands (queued post-integration: matrix + msg_bench, immediates-off vs on).
- Arity semantics (designer ruling, 2026-07-11): under-application is LEGAL (null-filled); over-application DISRUPTS. Consequences: resolved-site over-application = compile error (in flight on work/inliner-resolution); the callee-identity fact carries fid/argc/paramc/fill-count; and null-fill-to-paramc being REQUIRED work makes the uniform-4 arg region strictly more attractive (its unconditional 4-slot write IS the fill).
- Dynamic-call arg-passing options compared (designer question): (A) load-time arity-specialized handlers call0..call4 (qjs-style, but at mach lowering — no new mcode); (B) uniform-4 arg region with null-padding; (C) resolved-callee exact specialization via the identity fact. C wins outright where resolution succeeds (most calls, ~all post-LTO). For the dynamic remainder: B subsumes A — the 4-slot write is branchless, covers any paramc≤4, and implements null-fill for free; A still needs a dynamic fill loop. B’s frame cost is ~nil: the arg region overlaps the local region and its writes REPLACE existing null-init writes (degenerate cost: ≤4 null stores on 0-arg calls to 0-local leaves). Plan: next call arc lands C’s mach consumption, prototypes B, keeps A as the measured fallback comparison.
Parked / filed 2026-07-11 (designer: “write down, revisit”):
- Constant-chain switch lowering — compare-tree (O(log n), existing ops)
now; a
branch_tablecomputed-branch mcode op only if census shows hot big-N chains (likely customer: the tokenizer). - tranch-family type-test branches — pit’s
ifalready lowers to ONE op, butwary_true/falseare toBool-COERCING (mach.c ~238), not fail-on-non-logical like misty’s updatedtranch:if (5)coerces rather than disrupting. Semantics decision for the designer. The internaljump_true/falseare exact-value branches (= misty branch_true/false) used only on by-construction logical slots — sound. A tranch-STYLE generalization (branch_not_record etc.) could fold the guard+branch PAIRS the fusion census found (~16M in record_field) — revisit AFTER streamline’s guard elision lands, with a fresh census. - Finalizable-object registry — kills gc_finalize_walk’s whole-from-space walk (runtime.c ~3252; sole finalizer class: regexp). Small runtime change, parked.
- Immediate-key compare-branch forms (mach wave-2 candidate) — eq/neq against an immediate-ASCII constant as one dispatch (single word compare, no generic-equality call). Awaiting designer read.
- Stone double-check — store path checks stone in the mcode guard AND in the C helper. Intended architecture (criterion 5): helper check becomes a debug ASSERT (public C API keeps always-on). Verify at integration whether work/stone-use-asserts already converted this site.
- PGO design ruling (2026-07-11): ZERO new C. Collection = a compiler instrumentation pass that injects counter increments into the mcode itself (counters are plain pit arrays; dump via existing file APIs); the shop realizes an INSTRUMENTED VARIANT of an executable when it wants a profile (instrumentation = one more realization-key salt), plain realizations otherwise. Consumption = site IDs (unit content hash + op index, already stable) + one optional hints input threaded like _write_types. Queued in the pit-compiler lane behind the post-streamline batch.
- CALL-site facts (updated 2026-07-11 late): (a) callee-identity fact — DESIGNER-STAMPED; emission in flight on work/inliner-resolution (rides the shared resolution helper); mach direct-call fast path consumes it in a future lowering wave (fib residual 52.5→target ≤40). (b) exact-arity fact — DEMOTED pending the uniform-arity experiment below.
- Uniform-4 argument region (designer proposal, 2026-07-11): pit arity is ≤4 by language rule; make every frame’s arg region exactly 4 slots and the arg copy a branchless unrolled 4-word move (+≤24B transient stack per frame, zero heap). Likely cheaper than the variable-length copy for argc 1-2 and kills arg-region variability. Does NOT remove the semantic arity check (mismatch behavior is language semantics — the check is one compare either way, which is why the arity fact is demoted). Worth a measured experiment in the next call arc: implement, compare call0..call4 attribution spread; keep if the spread collapses.
- Whole-program / link-time optimization (future arc, designer-flagged): the linker already assembles executables from module sets; def-stone imports make cross-module callees compile-time constants, so cross-module inlining + global escape analysis + dead-module-code elimination are SOUND and cheap in pit’s model (far easier than C LTO). Natural scope = app bundles (fixed module sets); keyed as a whole-program realization. Also the eventual home for profile-guided cross-module layout. Not scheduled; think in this frame when designing inliner/PGO seams.
Campaign branch inventory (2026-07-11, for the integrator session)
All work is branch tips, handoff-not-merge. Suggested order (dependencies in parentheses; each tip was green at handoff):
claude/perf-roadmap-review-3680bf— honest benches + harnesses + vs.ce watchdog fixes + this doc’s addendum/decision bank.work/seam-phase2@ c75afc97 — pluggable numeric rep; f32 + fixed end-to-end (mach); fold-in-target-semantics; qbe_rt.c desync eliminated.work/calling-v2@ 153dec1e — native stack-safe dynamic calls (closure 69→33ms).work/calling-restructure@ 19556c99 (on 3) — shared frame floor both lanes; mach fib −32%; composed benches −13..20% mach.work/compiler-batch@ 2c48a83e (on 7) — LICM reset-hoist FIX, the two streamline fact-soundness FIXES, TCO, guard-CSE (record_field −18% mach, guards −50%); inliner root cause designed (resolution, not size).work/mach-fusion@ 60670455 — wave-1 superinstructions (records 1.16–1.41× mach; lever PIT_MACH_NO_FUSE).work/streamline-escape-forward@ 321b0707 — SROA + field forwarding (d_rec* 6–7.5×). Pending designer SLOTS-budget ruling.work/mach-fusion-w2@ f25e74ce (on 6) — 18 fact-typed handlers (71% checkless arith). MUST integrate WITH or AFTER item 5’s fact fixes (wave-2 makes mach a facts consumer; the fact bugs 5 fixes would miscompile mach release otherwise).work/native-miscompile-fixes@ 7c843ee9 — emitter defenses + regress fixtures (composes with 5’s source fixes: defense in depth).work/shootout-benches@ 3002aa9f — composed tier + 9-runtime harnesses. After 5 integrates, revert its commented LICM workarounds in fannkuch/binarytrees.work/gc-nursery@ d8bc768a — the experiment (superseded by 13).work/inliner-resolution@ 0247a414 (on 5) — shared callee resolver (callresolve.cm), cross-closure inlining (spectralnorm native −49%), callee-identity CALL fact, static arity (over-application = compile error), TCO refactored onto the resolver.work/gc-nursery-productize@ 4c531faf (on 11) — barrier audit complete (card-only default), 8KB tiny-actor nursery-as-initial-heap, watermark aging (majors 150→14, promotions −99.4%; daemon gc_churn −18% vs off; functions pin old — shared-cpool hazard). Lever-gated, off by default.work/switch-passflags@ abb9549f (on 12) — per-pass disable registry (PIT_STREAMLINE_DISABLE; byte-identical default; flags fold into the toolchain stamp) + compare-tree switch lowering (MIN_CHAIN=8; mach wins n≥8, native prefers linear ≤64 — per-profile threshold candidate). ALSO FIXES a pre-existing staleness gap: MCODE_SALT_FILES never included the sibling pass modules (sroa/tco/licm/…) — editing them did not invalidate the realization cache; audit other salt lists at integration.
After integration: re-run the composed tier + full micro matrix on the merged tree (criterion 6 — waves verified separately must be re-benched composed); refresh compiler_budget baseline on a quiet machine per the pending SLOTS ruling; revert shootout workarounds; re-verify the stone-double-check site against work/stone-use-asserts.
The gaps, and the proposed (or rejected) solutions
1. Calls — the one remaining structurally-slow thing (PROPOSED, awaiting stamp)
Nothing in pit’s design requires slow calls (BEAM has reified suspendable frames at 2-5ns; Lua has dynamic first-class callees at ~16ns; pit: ~30ns). The cost is the calling sequence: separate frame allocation + frame-size negotiation (max of proto/arg-bases, several branches) + per-call arity validation call + CALLARGS unpack + copying every argument from caller frame to callee frame. Every fast implementation instead uses overlapping frames: arguments are evaluated directly into the memory that becomes the callee frame; the call is “bump by a prototype-precomputed size, jump.”
The fix (“calling sequence v2” — general, both lanes, no eligibility rules):
- overlapping/contiguous frames — the arg copy disappears;
- prototype-precomputed frame sizes — the negotiation disappears;
- arity resolved by compile-time facts where the callee is known (def-bound/direct), dynamic check retained otherwise — the standard guard-elision architecture, not a special case;
- initialize only what the GC’s frame scan requires (the one careful design interaction: uninitialized slots vs the scanner — watermark or write-type-driven init). Estimate: mach call 30 → 10-15ns (fib 78→~45 mach), native helpers inherit (fixes closure-native 75ms). REJECTED alternative: “convention v2” = args-in-registers for provably-simple leaf callees — rejected as a special case with eligibility rules; reconsider only after the general sequence is fixed, if ever. Related compiler candidate: closure-body inlining for resolved single-target closures (extends the existing inliner; kills the call entirely for the closure-counter pattern).
2. Allocation/GC — gc_churn 8-10ms vs OCaml 0.18 / C# 0.6 (DEFERRED by design ruling)
The solution everywhere else is a generational nursery: young objects bump-allocated in a per-actor nursery, survivors evacuated on minor collect. Evidence that no cleverness beyond that is needed: C# heap-allocates every record in record_new (no escape analysis) and still runs it at 1.2ms — the entire 20-70× churn gap is generational collection. A per-actor nursery preserves pit’s independent-GC architecture (each actor’s nursery is private). Designer’s standing position: the GC is simple and that simplicity is a design feature; per-actor independence must be preserved; revisit later — this is the one remaining runtime-design conversation, to be had deliberately, not slipped in. The distribution line’s GC work (merged) already improved churn ~15-30% without design change.
3. Records to the C#/OCaml tier — d_field 6.5 vs 0.8 (SEQUENCED)
Hash records with canonical keys have a floor around Lua’s numbers (achieved). Below that requires offset access:
- def-stone offsets (designer: “crucial — get every corner”) —
usereturns stone bound to def, so module-record field offsets are compile-time constants. Depends on stone-at-use (in flight). Deserves its own thorough arc, not a bolt-on. - Record templates (explained, awaiting go): literal key-sets get their
full table layout precomputed at compile time; construction = memcpy
template + offset stores; result is still a plain hash record. Constraint:
the template reference rides the existing
recordop as a fact — no new mcode ops (standing rule). Est. record_new 18.7 → ~8-12. - Shapes/hidden classes: REJECTED — Lua-parity was reached without them; they contradict the bigger-records-for-fast-hash design intent. Only reconsider if offsets+templates leave a demonstrated, needed gap.
- Fused mach field opcodes: REJECTED for now (“no new mcode, no new opcodes”) — the mach LOADK+LOAD double dispatch is a mach-lowering artifact; mach optimization is deliberately deferred (see criteria).
4. Small filed items
- float_math residual (7.1 vs its 2.13 pre-LICM best): exactly one
qbe_new_float64encode left in the hot loop — the multiply’s scratch dest stops engaging the FP fast path post-LICM (see commit af3ff74a’s message; dumpil IL ~line 1109). Small emitter follow-up. - int-literal LICM deferral: hoisting int/null/true/false literals
miscompiled core modules only via the realization cache (fuzzer and
benches blind to it) — signature documented in licm.cm (
licm_const_sig). Investigate the cache blind spot, then enable. - string_concat vs JS (0.29 vs 0.04): V8/JSC cons-strings make concat an O(1) rope node. pit already beats every non-JS runtime by 20-30×. Runtime-representation change; LOW priority.
- mach dispatch tier: interpreter floor ~1.5ns/op means mach gains come from executing fewer ops (mcode work) — deliberately deferred until mcode is exhausted, so mach optimization doesn’t hide mcode deficiencies.
5. Numeric representation seam (IN FLIGHT, phases 2-3 queued)
Phase 1 (audit + emitter descriptor extraction, zero-behavior-change) is running. Then: f32 pilot build (Playdate: hardware float32, software double), per-target NAMED PROFILES (arch+os+numrep bundled — axes never exposed), rep in the realization/native cache keys, firmware rep-tagged; mcode/wota stays rep-portable (verify as a gate). Then the 4-build benchmark matrix: nan64 / int-f32 / dec64 / fixed-Q47.16 (mach lane for all; native where descriptors exist; per-build expected values — results differ by design). Dec64: opt-in exactness profile ONLY, never a platform default (reference asm measured: int add/sub/mul parity, but division ~140×, divmod ~240×; the repo’s asm has two fixable bugs — inc/dec register typo, dead integer-divide fast path — and dec64_math is a placeholder that infinite-loops on sqrt(1036); a pit dec64 build is fork-and-improve, plus an added integer-modulo fast path). Immediate-ASCII removal: REJECTED (181 consumer sites, measured zero perf upside; immediates are value-canonical and free across $receive). PS1-class fixed-point runs as Q47.16 on 64-bit hosts; real PS1 additionally means 32-bit words — separate question, flagged.
6. Scheduler/preemption (DEFERRED to the actor startup/suspension arc)
pit today: turns run to completion; slow-turn watchdog flags/strikes/kills; a hogged turn holds one worker thread. Erlang’s “preempt anywhere” is reduction polling at calls (loops are calls). Options when the arc opens: (a) status quo; (b) yield-flag polling at back-edges+calls (~2 instrs, ~free when unset, build-flag for consoles) — orchestrator’s recommendation; (c) full reduction counting. The leaf-call sites already carry yield-ready seams (stable segment ids, single sentinel-check helper) so no call rework is needed later.
Post-integration audit ledger (2026-07-12)
Correctness landed
- Closure writes now invalidate copy-propagation aliases and guard-CSE facts
across every synchronous invocation form, including
apply. A nested closure can rewrite a captured caller slot without a local def in that caller; both optimizations previously retained stale facts.applyalso invalidates field forwarding and stone facts because it can run arbitrary user code. - Overflow-promoting arithmetic no longer establishes an invariant int write
type merely because its operands are ints.
30 * 100000000promotes to a float, so a following add must not selectadd_iip. Optimizer cycles now use conservative types; a preliminary range run supplies candidates, and only per-instruction_no_overflowproofs restore int facts before backend lowering. Debug Mach identified the exact false claim at inline fannkuch’s final multiply/add. - The not-stone pass now deletes a guarded error block only when the stone
guard is that block’s sole predecessor. The push skeleton shares its error
block with an array-type guard; deleting it left a dangling inlining-prefixed
label in
internal/shop_build.cm, visible as a cold-bootmcode_lower: unknown labeldiagnostic. A structural regression scans every optimized function for missing branch targets, and the shop-build bisection now reports zero missing labels under every registered pass arm. - The natural inline fannkuch is restored: exact result
3000008629, Mach median 362.66 -> 313.33 ms versus the helper workaround (13.6% faster). The post-fix pass bisection returns that exact result with every individual pass disabled as well as with the composed optimizer.
Compiler-budget and tooling findings
- The overflow-proof reconciliation is gated before both extra inference passes: functions over 128 instructions, and functions with no potentially promoting arithmetic, keep their existing conservative facts. The first gate improved the measured compiler corpus by 12–16% versus the ungated correctness patch. The branch still fails the older compiler-time baseline by roughly 2–2.9x, so that broader regression remains real and must not be hidden by refreshing the baseline.
- Runtime rows remain much faster than the saved gate baseline (typically 16–62%, with 27–46% fewer Mach instructions), but several rows retain an extra fixed ~800 allocation bytes. This is consistent with the deferred per-actor diagnostics/facts footprint and is not caused by the overflow fix.
pit downstops the node but exits 1 without a useful result; a first test run charges realization time to the test deadline and can time out just asvm_suitefinishes building; and a daemon spawned withPIT_ALLOW_NATIVE_LOAD=1can breed the shop actor without ever opening its socket. Required tooling fixes: synchronous successful down, deadlines that begin after realization/actor launch, and a bounded daemon-ready wait that reports the actor/boot failure. In-process one-shot execution also tears down before an asynchronous benchmark actor finishes, so it is not a usable native-benchmark fallback.- The per-compile pass-disable path now gates the coupled type/range
reconciliation as well as the final named pass. Before this correction,
disabling
typesrebuilt_write_types, while disablingint_rangescould leave pre-compression instruction-index facts attached to the final stream. Structural regressions require disabled passes to leave their facts absent. - This tooling defect is resolved: bare one-shots now wait for a reply without
probing help, and machine-shell
/logs quiet|off|verbosechanges live log subscriptions without replay.
Call experiments — measured and rejected
- Consuming
_resolved_callsin a specialized Mach call opcode worked mechanically (2,692,537recursive fib calls selected it), but skipping the function/arity checks moved fib only 60.79 -> 60.50 ms (~0.5%, noise-size). No opcode retained. The existing resolver also does not prove the commonvar f=null; f=function...recursive form because the parent slot is multi-def; a declaration form does resolve. Future work must fix resolution coverage before assuming “nearly all calls”. - A uniform four-slot argument region regressed every measured Mach row and was deleted: call0 50.52 -> 54.92, call1 60.57 -> 72.10, call2 76.73 -> 81.67, call4 103.25 -> 111.01, recursive fib 60.47 -> 68.00 ms. Extra stores/frame footprint outweigh the removed copy loop variability. Do not revisit without a different activation layout.
- Conclusion: the next call work must target activation/return itself: direct known-code entry while preserving the closure environment, reusable or caller-provided frame storage, and a thinner return convention. Validation checks and argc branching are not the cost center.
Deferred runtime and target-profile items
- Opcode instrumentation: revisit lazy allocation of the 256-counter table and a diagnostics-free compact profile. Counting is already off by default; its embedded per-actor storage is not.
- POSIX stacks: revisit a worker-specific stack API. The 8 MiB reservation is per OS thread (actors still share a small worker pool), but the generic thread creator also applies it to timer/I/O helper threads.
- Nursery default: retain the nursery design, but measure a 1 KiB initial default with growth driven by survivor pressure. This anticipates compact 32-bit values; do not confuse the 512 KiB cap with initial allocation.
- 32-bit Playdate profile: add a reduced-payload NaN representation behind the
PitValue seam. The public C API stays representation-neutral:
Pit_ToFloat64/“get double” still returns a Cdouble; only the internal encode/decode changes per named target profile. A real 32-bit value word also requires removing the remaining 64-bit ABI/slot/QBE assumptions. A 16-bit profile would additionally need handles or heap-relative offsets.
Mach facts disposition
Mach facts exist today because final typed/fused handlers are chosen at load
time: _write_types/_no_overflow are serialized as an instruction sidecar,
mach_fuse_stream consumes them, and the loaded code retains the sidecar. That
was a compatibility staging design, not the desired endpoint. Mach is already
the target artifact; mcode should carry semantic facts, and mcode->Mach lowering
should emit the final fused/typed Mach stream. Then discard the facts. This
removes load-time optimization, retained sidecar memory, and the conceptual
split between “Mach bytecode” and “the bytecode actually executed”.
LICM constant spans (2026-07-12 correctness port)
LICM constants now cross physical-slot compression as compact tuples
[physical_slot, first_instruction, last_instruction, value], one tuple per
logical constant. The bounds are the conservative non-overlapping intervals
already computed by the allocator. int_ranges and QBE accept a fact only when
both the physical slot and the current final instruction index match that exact
span. The old post-compression _const_slots map is destroyed; there is no
slot-global fallback. Final nop compaction establishes instruction numbering
before spans are built, and no later mutation pass renumbers the stream.
Re-streamlining clears both logical producers and transported spans before its
first mutation, so indexed facts from an earlier run cannot survive.
The canonical mcode serializer (mcode_without_locations) intentionally emits
only executable function fields and therefore strips _const_spans, just as it
strips the other optimizer side facts. The full optimized IR returned by
compile_object_result, and consequently full seed construction, retains the
spans for backend consumption. Mach does not consume this fact; this change adds
no Mach opcode or constant operand. QBE consumes it for its existing float and
constant-divisor paths.
Measured against d560e544 on two collision fixtures, 40 compiles per sample, three samples on the same machine (medians): compiler time 319.49 ms -> 324.12 ms (+1.45%); allocation 92,217,040 -> 92,111,120 bytes (-0.11%); retained actor heap delta 8,448,248 -> 8,427,496 bytes (-0.25%); summed frame slots 360 -> 360. Full optimized-IR JSON grew 45,520 -> 46,980 bytes (+1,460, +3.21%) for 180 retained span tuples, while canonical serialized code stayed byte-identical at 24,800 bytes. These are focused compiler microbench numbers, not whole-suite artifact totals.
A fresh full seed (measured, then restored rather than committed) changed
boot.qop 60,958,304 -> 60,998,133 bytes (+39,829, +0.065%) and
boot/firmware 54,463,808 -> 54,498,144 bytes (+34,336, +0.063%);
boot/boot remained 1,223,773 bytes. The growth is attributable to the full
seed IR retaining optimizer span facts; ordinary canonical mcode remained
unchanged as measured above.
Correctness gates cover a constant plus later runtime value sharing a physical
slot, two distinct constants sharing one physical slot, re-streamlining fact
lifecycle, and canonical/full-IR fact disposition. QBE IL for the runtime
divisor contains the runtime-safe divisor/remainder path rather than treating
the reused slot as 1000. pit --aot native execution produced exact goldens
1000000 and 352; the full make check gate (VM, language, baseline, fuzz)
was green after one cold-cache VM-suite timeout was rerun successfully.
Structured mid-end proposal
Add one shared structured optimizer between semantic mcode and the two target lowerings:
mcode -> CFG + SSA/value identities -> proof/effect/range passes -> Mach | QBE
The representation needs explicit blocks, phi/value identities, dominators and loops; a type/number-representation/range lattice; effect and alias summaries (including captured-slot writes); and stable source/provenance IDs. Preserve mcode as the language/portable-machine contract. Do not make target-specific operations the optimizer’s source of truth.
The language facts are clear enough to build this now: maximum arity, legal under-application/null fill, disruptive over-application, closure capture and mutation, actor isolation, stone immutability, numeric promotion, and explicit effectful intrinsics are all enumerable. Unknown dynamic calls remain a conservative “may write captured state / allocate / disrupt” effect. The missing piece in prior attempts was not semantics; it was composing analyses through mutable slot numbers and instruction-index side maps. Stable values and block edges solve that class of bug.
PGO must fit without a redesign: carry stable origin IDs through inlining; separate static proofs from profile hints; key profiles by unit content + target profile; and let guarded specialization consume hints without treating them as truth. Instrumented realizations remain separate artifacts. PGO stays deferred, but this seam is mandatory in the mid-end design.
The working criteria (the designer’s standing rules)
- AOT always. No JIT, ever. Anything achievable ahead-of-time is fair game.
- mcode is the ISA of a hypothetical pit CPU. mach and pit-native are two implementations of that CPU. Guards live in mcode as program intent and as fact sources; the compiler elides/hoists them; facts (write-types, ranges, const-slots, element types) ride the unit and drive each implementation’s lowering. Implementations stay close to macro substitution + facts — nothing per-architecture clever. For new targets, mcode is the truth.
- Optimize mcode and the runtime internals first; mach later. Optimizing mach early hides mcode deficiencies. The mach lowering tier (fused ops, fact-aware dispatch) is a later pass.
- Runtime changes require explicit sign-off. Keep the runtime simple. The GC’s simplicity and per-actor independence are design features, not accidents. Behavior-preserving internal fixes (needless rooting, dead defensive layers) carry standing approval — just do them and report.
- Enforcement architecture: user-level laws are enforced by compiler-emitted guards (and compile errors where statically certain); internal primitives carry loud debug ASSERTS (compiled out in release — mach in debug is the oracle that stress-tests the compiler); the public C API boundary keeps always-on checks (C modules have no mcode guards).
- Measure first, then design. Dynamic census (
census.ce) over static counts (final mcode is dominated by cold error-path scaffolding); dumpil + disassembly for evidence; the interp-vs-native differential (vs.cematch=Y) is the correctness oracle — the opt/noopt fuzzer is blind to emitter and realization-cache bugs. Waves verified separately must be re-benched composed (LICM × emitter-constants taught this). - No new mcode ops or MACH opcodes without explicit need and approval. New capabilities ride existing ops as facts/operands where possible.
- Old processors are targets (no branch-predictor assumptions): prefer statically checkless code via compile-time facts over “cheap” runtime checks.
- Per-node numeric representations via named target profiles; nota is the numeric-agnostic interchange; cross-node precision divergence is accepted and intentional.
- Integration discipline: the distribution/platforms session owns
cross-branch merges; perf-line work is handed off as branch tips +
conflict notes, never merged across from here. Bench protocol gotchas:
fresh daemon per fixture (env reaches the daemon only at spawn);
observe.jsonlis ground truth when client stdout drops rows; the native dylib cache does not key on the emitter — nuke.pit/build .pit/cache .pit/catalog .pit/objectsafter emitter edits; verifygit logshows a merge/pick actually landed before benching it.
In flight at handoff
- DONE —
work/stone-use-asserts(based on 4ec91f28; commits 71fcc6fe stone-at-use / 48def6c8 compile-rejection / 8b3f7287 asserts / 27adecf6 reseed;make check+make check-cliALL GREEN, full dist-vm coverage; awaiting integrator merge):use()exports stoned at the single engine_lite.load_module chokepoint (C-module and script exports both); exactly one internal violator found+removed (dead_verify_irwrite in internal/test.ce); direct writes through a use-binding are now COMPILE errors (“cannot write to ‘j’ — use(…) imports are stone (frozen)”, parser semantic pass, scope-accurate, aliases still runtime-disrupt); internal S1 stone checks are loud debug ASSERTS (PIT_MACH_STONE_ASSERT, abort-with-site; nm-verified present in debug / absent in release; fork SIGABRT test proves both directions); public C API checks untouched. This unblocks the def-stone-offsets arc (§3). - DONE —
work/seam-phase1(commit 4a1707bd on base 4ec91f28; rebases cleanly over the platforms tip’s later doc commit; all gates green, IL byte-identical, awaiting integrator merge): audit committed atdocs/plans/pitvalue-seam-audit.md. Headline: the C runtime is ALREADY comprehensively fenced (~4272 family call sites, zero stray bit-peeks in core); 7 mach.c overflow-promotion sites routed through Pit_NewInt64. The nan64 representation “header” = pit.h (31 defs) + pit_internal.h (69) + src/qbe_rt.c — a HAND-MAINTAINED MIRROR of the tag constants and float layout; the critical C sync point for every later representation phase. Descriptor: 5 nan64 IL snippets extracted into the injected qbe.cm (e_num_to_double, e_truthy_w, e_is_text_w, e_is_num_w, e_pack_bool); discovered the descriptor’s pre-existing type/arith functions were dead code. Next extraction targets inventoried: untag/retag int (~30 sites), the int32 overflow-check shape, emit_helpers’ objhdr-chase templates. Two latent semantic bugs found, documented, deliberately untouched (zero-behavior-change phase):pit_number_from_doublediverges fromPit_NewFloat64on -0.0;MACH_DIVhas an INT32_MIN/-1 latent overflow. Phase 2 (f32 pilot build + target profiles + cache keys) is unblocked. - Awaiting stamps: calling sequence v2 (§1), record templates (§3), nursery conversation (§2), float residual + LICM-int-literal follow-ups (§4).
Tooling lifecycle findings (2026-07-12)
- A POSIX
pit downrequest is successful only after the shop endpoint and daemon process are both gone. The client now waits up to five seconds and reports whether the endpoint or process remained; the terminal couple’s intermediate exit code is not sufficient evidence of shutdown. - Daemon startup has a bounded 120-second readiness window. If the process
exits before its listener answers, or the listener never appears, the CLI
reports the state and includes the tail of
.pit/log/boot.log. The longer window is intentional: a fresh shop can spend more than 30 seconds materializing boot services. - Worktree isolation is conditional on invoking
pitfrom that worktree (or passing--shopexplicitly). Shop resolution walks upward from the current directory and otherwise falls back to the binary-adjacent or home shop; concurrent benchmark tasks must therefore use their own worktree-local.pitor an explicit unique--shoppath. The runtime refuses to spawn a second daemon when a shop’s pid is alive but its socket is absent. - Bare-script one-shot semantics and machine-shell
/logscommands are implemented; their focused CLI regressions remain open semantic/documentation decisions; no behavior change is included in this lifecycle patch.
Source: plans/archive/perf-2026-07/perf-next.md