commit 70031256ffce48755c4e8aa0b17a3d4c110fa107
parent 1ff134b534b2d8d026e108221b72abf889db7ab5
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 19:51:51 -0700
doc(perf): consolidate PERF*.md into one forward-looking doc/plan/PERF.md
Fold PERF-O0-CODESIZE.md, PERF-TCC-GAP.md, PERF-TCC-SLIM.md, and PERF-IDEAL.md
into a single PERF.md covering both axes (compile speed + code size). Drop all the
campaign history / measured-results / landed-lever narratives; keep only:
1. Current standings — compile speed ~2.9× tcc (instructions), code size 1.044×
tcc (.text), with where the remaining cost lives.
2. Where the cost is — phase decomposition + Linux callgrind distribution
(frontend-bound; types are the #2 cluster) + the spill-bound code-size view.
3. Reproducing the metrics — tcc build, macOS best-of-7 instructions, phase
decomposition, macOS sample, Linux callgrind (scripts/perf_callgrind.sh),
.text histogram diff, e2e correctness, the bench-cc scaling guard.
4. Ideas for improvement — ranked forward-looking levers per axis (compile:
symbol-binding cache, type-subsystem decode, macro pointer-swap, …; code
size: spill reduction, positive-offset addressing, arg dest-hint, …) +
measured dead ends + the out-of-scope seam line.
5. Methodology notes — RELEASE/gate/measurement discipline.
Repoint the few cross-references (README index, riscv/native.c comment,
perf_callgrind.sh header) to the consolidated doc and section numbers.
Diffstat:
8 files changed, 303 insertions(+), 2347 deletions(-)
diff --git a/doc/plan/PERF-IDEAL.md b/doc/plan/PERF-IDEAL.md
@@ -1,682 +0,0 @@
-# Performance by ideal flow — rewrite to the conservation law, don't patch around it
-
-Companion to `PERF.md` (standings + methodology), `PERF-TCC-GAP.md` (structural
-diagnosis), and `PERF-TCC-SLIM.md` (the patch-oriented slimming campaign). This
-doc reframes the whole effort around a single discipline:
-
-> **For each entity the compiler handles, name the work that should happen
-> _exactly once_ per entity — the conservation law / ideal flow — then rewrite the
-> subsystem to obey it, instead of memoizing or short-cutting around a structure
-> that re-does the work N times.**
-
-This is tcc's secret stated as invariants: *each source byte is touched ~once,
-each token is an `int`, each operator is a `vtop` adjustment plus ≤1 four-byte
-store, and there is no second copy of the program anywhere.* kit is ~3.1× tcc in
-**retired instructions** (the metric — load-independent, Linux callgrind `Ir`);
-kit's IPC already exceeds tcc's, so the gap is **instructions, not cache**. Each
-violated conservation law below _is_ a slice of that 3×.
-
-## One refinement up front: the objective is minimum *instructions*, not minimum *passes*
-
-"Touch each byte exactly once" is a proxy for "do the minimum work," and usually
-the two agree — but not always. The splice pre-scan (§A) is the case that
-separates them: the literal one-pass ideal (fuse `\<newline>` handling into the
-tokenizer, tcc-style) would reintroduce a per-byte splice test into the hot
-tokenizer loops that the raw-cursor rewrite deliberately made splice-free —
-**taxing every token to save a pass.** A separate SIMD-cheap pre-sweep (`memchr`,
-~1 instruction per 16–32 bytes) does the same job for fewer total instructions.
-So the law we optimize is **minimum retired instructions per entity**; "touch
-once" is how we reason about it, not a goal in itself. Where the two diverge,
-instructions win — and we say so explicitly.
-
-## The fresh profile (the evidence base)
-
-`kit cc -c sqlite3.c` (the real benchmark), callgrind at HEAD `de8c3b62`, **2.04 B
-`Ir` total**. Self-time buckets (trust self/exclusive — inclusive is garbage here
-because lex↔pp↔parse form one mutual-recursion cycle callgrind double-counts):
-
-| area | self % | conservation law it violates |
-|---|--:|---|
-| **types / CG-drive** | **20.7%** | each type decoded/classified once; all ancillary info cached on the id |
-| lexer scan (`lex_next`+`finish_ident`) | 16.3% | each byte classified once into one token |
-| pp pump + relay | 11.6% | a token lives in one place, never copied frame-to-frame |
-| **splice pre-scan** (`lex_open_mem`) | 10.6% | bytes not destined for a token are not walked by a scalar pass |
-| intern (`pool_intern_slice`) | 4.2% | each lexeme hashed once, then it is an int forever |
-| native emit (NDT path) | 3.9% | each machine byte written once into the final image |
-| other (parse/sema/objwrite long tail) | 26.3% | — |
-
-Artifacts: `build/linux-prof/cg.annot.txt` (flat self-`Ir`),
-`build/linux-prof/cg.tree.txt` (caller/callee + call counts). Recipe + gotchas:
-`PERF.md` §6. Every rewrite gates byte-identical
-(`scripts/perf_identity_gate.sh`) unless it deliberately changes emitted bytes
-(then: run-correctness + determinism).
-
----
-
-## §A — Source bytes: *classify once; never scalar-walk a byte that isn't becoming a token*
-
-**Ideal flow.** A source byte is loaded, classified via `cclass[256]`, and
-consumed into exactly one token body — once. Bytes that exist only to be skipped
-(whitespace, the rare line-continuation) cost a table test, not a separate scalar
-pass.
-
-**Violation (10.6%, `lex_open_mem`).** Every opened buffer is walked **twice**.
-`lex_fold_splices` (`lang/cpp/lex/lex.c:149`, inlined into `lex_open_mem:281`)
-runs a scalar, 2×-unrolled byte loop (`lex.c:156-157`,
-`for(i;i+1<len;++i) if(src[i]=='\\' && src[i+1]=='\n') ++nspl;`) over the *entire*
-buffer to count `\<newline>` splices — which are almost always zero — and then
-`lex_next` walks every byte again to tokenize. Confirmed by disassembly of the
-profiled binary: the count loop is `ldurb;cmp #0x5c;ldrb;cmp #0xa;cset;...`,
-~4–5 instr/byte, **no NEON**. Across the ~3,140 file opens of the sqlite
-amalgamation's transitive header closure (`file_register → lex_open_mem`,
-`cg.tree.txt:56`) this is tens of millions of bytes scalar-walked for nothing.
-(`PERF.md:278-279`'s claim that the splice-free path "pays nothing" is true of
-`lex_next` during tokenization but **false for the open-time pre-scan** — the doc
-overlooked this counting pass.)
-
-**The rewrite.**
-1. **`memchr`-driven splice sweep** (the instruction-ideal — see the refinement
- above). Replace the scalar count loop with `memchr(p,'\\',...)` to skip
- non-backslash spans at glibc-NEON speed; backslash is rare in C, so the sweep
- consumes the buffer at ~1 instruction per 16–32 bytes. Byte-identical (same
- `nspl`, same fold table; the fold path is entered only when `nspl>0`).
- **215M → ~20–40M `Ir` ≈ −8.5…9.5% of the whole compile.** This is the single
- biggest lever in the profile and near-zero risk. The full tcc-style fuse is
- explicitly **rejected**: it taxes the splice-free hot loops (`lex_next`,
- `skip_ws_fast`, `scan_ident_run`, `scan_pp_number`, header-name scan) with a
- per-byte test and would force spelling reconstruction for spliced tokens.
-2. **Delete the per-lexer `Pool`.** `lex_open_mem` mints `l->pool = c_pool_new(c)`
- (`lex.c:288`) — a 64 B `Pool` + a `kit_arena_new` — on each of ~6,000 opens,
- but the lexer touches `l->pool` only as `l->pool->c` (the Compiler) at three
- sites, and `l->c` already holds it. Drop the field; replace `l->pool->c` →
- `l->c`. **~20–30M `Ir`**, byte-identical, clean cutover. (Identifier interning
- is unaffected — it already targets the shared `c->global` symtab via
- `kit_sym_intern`, _not_ the per-lexer pool; the pool was dead weight.)
-3. **Hoist the punctuator-spelling cache to per-compile.** `struct Lexer` is
- 1216 B, ~1184 of it `Sym punct_sym[]`, `memset`-zeroed per open and re-interned
- cold each time — so the same ~60 punctuators are re-interned across ~6,000
- lexers (the "classify once" law: a punctuator spelling should be interned
- **once per compilation**, not once per lexer). Move `punct_sym[]` to a
- per-compile `LexShared`; `Lexer` shrinks to ~32 B, its per-open `memset`
- collapses, and punctuators are interned ~60 times total. **~5–15M `Ir`** +
- relief on the 2% `memset`. More invasive (threads a shared ctx into
- `lex_open_mem`); preserve the digraph-bypass and the "entry 0 = not-yet-seen"
- sentinel. Do third.
-
-**Floor.** The irreducible work is one classified pass over each distinct
-buffer's bytes (the tokenize pass) + a near-free SIMD splice sweep. The rewrite
-reaches it. **Area est.: ~10–11% of the whole compile**, all byte-identical
-(gate + the line-splice battery: mid-token / mid-string / leading / trailing /
-consecutive continuations + a diagnostic line across a splice).
-
----
-
-## §B — Identifiers: *each lexeme hashed once, then it is an integer forever*
-
-**Ideal flow.** The first time a spelling is scanned it is hashed → `Sym` (a small
-int); every later occurrence is *already* that int. Macro/keyword/symbol
-resolution is a load off the interned token, never a re-hash. (tcc: the token
-leaves the scanner as an int.)
-
-**Violation (4.2%, `pool_intern_slice`).** Every occurrence of every
-identifier/number re-runs FNV-1a + an open-addressed probe — ~628K calls ×
-~135 `Ir` — on a token stream with **22.4× reuse** (measured on real sqlite3.c:
-440,942 id+num occurrences, 19,700 distinct). The probe's `sym_eq`
-(`src/core/pool.h`) loads a hash-scattered `ents[sym]` line per occupied slot
-tested. *(Note: the interner is already per-compile-shared — `c->global`,
-`api/core.c:222`. The per-lexer `Pool` from `c_pool_new` is the **frontend** pool
-(arena + type/abi cache), a different type, not a string interner — so there is no
-per-buffer intern fragmentation to recover; the cache below belongs on
-`c->global`.)*
-
-**The rewrite — a direct-mapped, byte-verified intern cache inside
-`pool_intern_slice`.** An array of `{u32 keyhash; Sym sym}`, N a power of two,
-keyed on a *cheap* fold of `len` + the first ≤4 bytes (**not** full FNV). On
-lookup: index `= fold & (N-1)`; if the slot is occupied, **byte-verify** the
-candidate via `pool_slice(sym)` (the same compare the probe's `sym_eq` does) — on
-match return, having skipped FNV *and* the probe. On miss, fall through to the
-existing FNV+probe and populate the slot. Placing it inside `pool_intern_slice`
-(not the lexer) serves **frontend and codegen** interns alike — e.g. `aa_plan_call`
-re-interns ABI helper names (`__floatditf`, …) 19,455× — which a lexer-local cache
-would miss. Pure accelerator: every hit byte-verifies, so it can never return a
-wrong `Sym` (digraph-safe by construction — `<:` and `[` differ in bytes and fail
-verify); store only `{keyhash, sym}`, never a borrowed `in.s` pointer (it aims at
-transient lexer buffers).
-
-Measured hit rates on real sqlite3.c: **256-slot ≤4-byte-fold = 78.7%**, 1024-slot
-= 85.3%. This is explicitly **not** PERF-TCC-SLIM's A1 single "last-spelling" slot
-— that measured **3.4%** (real C alternates: `sqlite3 . pVfs -> xOpen (`), so a
-1-slot cache never hits. It is also distinct from the documented dead ends (the
-*entry-side* inline-prefix cache and the word-at-a-time hash, which don't remove
-the per-occurrence hash+probe): this removes both the hash and the probe for ~79%
-of calls.
-
-**Floor.** Steady state = one cache load + a ~6-byte verify per occurrence (~20–25
-`Ir`); one FNV+probe per distinct lexeme + collisions. At 78.7% × ~564K × ~110 `Ir`
-saved minus the cache's own overhead: **−30…45M (1.5–2.2%)** — the single biggest
-lever in this area. Byte-identical (gate + a digraph spelling check:
-`<: :> <% %> %: %:%:` must stay distinct).
-
-**Deferred — _not_ an `Ir` lever:** the self-sufficient `{hash, sym}` probe slot
-(PERF-TCC-SLIM A2 / `PERF-TCC-GAP` Track 3). Measured ~0 on instructions — it
-trades an `ents[sym].hash` load for a `slot.hash` load (same instruction, better
-locality), so it is a **D1-miss / wall-clock** play the `Ir` metric cannot see,
-and the cache above already absorbs ~79% of probes. Revisit only under a dedicated
-cache-stall investigation. *(This corrects PERF-TCC-SLIM's framing of A2 as an
-instruction lever.)*
-
----
-
-## §C — Tokens: *produce only tokens the parser consumes — never build one that's always dropped*
-
-**Ideal flow.** The pump emits, into a fixed out-pointer slot, only tokens the
-parser will read; a token that is always discarded downstream is never built or
-relayed. The "lives in one place, never copied" half of the ideal is **already
-met** — and the work to do here is *removing a token class*, not restructuring the
-relay.
-
-**The copy half is already at the ideal (measured — relay rewrite is a confirmed
-dead end).** The lex→pp→parse relay already uses out-pointer (`_into`) forms
-(`pp.c:72`, `pp_expand.c:935`) precisely to kill inter-frame sret copies. The
-~206M `Ir` in `src_next_raw_into` / `pp_next_raw_into` / `fetch_tok` / `advance` is
-**per-token macro / directive / include-guard / `#line` logic, not copy
-bandwidth** — it survives any relay restructuring (`PERF.md`'s "fused pull pipeline
-= ~5% dead end", reconfirmed). Likewise **`Tok` 24→16 B is a dead end**: `loc.line`
-is read/written on the *hot* PP path (`#line` delta `pp.c:123`, `__LINE__`
-`pp_expand.c:1021`), so an inline-packed position forces a per-token decode that
-costs more than the copy it saves — the `LocId` side-table regression,
-reconfirmed.
-
-**The real violation — 51% of produced tokens are discarded.** `lex_next` is
-called **1,424,699×** but only **684,790** tokens reach the parser (`pp_next`):
-~740K (51%) are dropped inside PP, overwhelmingly `TOK_NEWLINE` — each built by
-`lex_next` (24 B), sret-relayed through `src_next_raw_into` + `pp_next_raw_into`,
-then `continue`'d in `pp_next`. `TOK_NEWLINE` cannot be removed outright (the
-directive scanner needs line boundaries: `#define` / `#if` / `read_directive_line`
-scan to it), but the non-directive ~95% need not be materialized as parser-facing
-`Tok`s.
-
-**The rewrite.**
-- **§C.3a (low risk):** absorb the newline-drop one frame lower so a non-directive
- newline never pays the `pp_next` sret round-trip (`pp.c:191`,
- `pp_expand.c:935`). **−5…12M.** Byte-identical (-E newline placement is the
- critical gate).
-- **§C.3b (structural follow-up):** don't materialize non-directive newlines as
- `Tok`s at all — the lexer sets `TF_AT_BOL` on the next content token (already
- tracked) and emits an explicit end-of-line marker only *inside* directive
- context. **−15…25M**, med-high risk: must prove every `TOK_NEWLINE` consumer is
- directive-internal or expressible via `TF_AT_BOL` (`#if`/`#define`
- line-termination is the hazard). Map the directive-scanner contract first.
-
-**Floor.** The pump's irreducible cost is the per-token macro/directive/guard logic
-over the *content* tokens only; newlines collapse to a flag on the next token.
-**Area est.: −5…12M now (3a), −15…25M follow-up (3b).** Gate: byte-identical -E +
-the directive corpus (`test/pp/`) + the line-splice battery.
-
----
-
-## §D — Types: *decode/classify once; all ancillary info cached on the id*
-
-**Ideal flow.** At construction, a type is classified once and carries — on a
-stable id — its size, align, regclass, predicate bitset
-(`is_int/float/ptr/void/record/aggregate/i128`), unaliased id, lowered `CgId`, and
-record layout. Every later query is **one load**. There is one type
-representation the whole pipeline reads, not a `Type*`→`CgId`→`CgType` re-crossing
-per operand.
-
-**Violation (20.7% — the single largest area).** Predicates re-derive recursively
-through `cg_type_get` ↔ `api_unalias_type` ↔ `abi_cg_type_info`: `cg_type_get`
-recurses on itself **610K×**, `api_unalias_type` is called **~660K×**,
-`cg_type_is_aggregate` 31M self over ~550K calls — each re-decoding the id through
-a two-level segvec index. Root causes the prior "F1 memo" missed (it memoized
-`abi_cg_type_info`, which sat on already-flat field reads — hence its measured
-−2.1M):
-- **`c_abi_record_layout` is _not_ memoized and is rebuilt O(M·N)** (M member
- accesses × N fields). `lang/c/abi/c_abi.c:85` `arena_znew`s a fresh layout and
- loops every field calling `kit_cg_type_record_field` + `kit_cg_type_size` per
- field — on every call. Its exact CG twin `abi_cg_record_layout`
- (`src/abi/abi.c:150`) **is** memoized via `AbiRecLayoutMap`. *(Both verified.)*
- This is the direct source of the `kit_cg_type_record_field` (1.39M calls) /
- `kit_cg_type_size` (648K) cluster.
-- **The parser's value stack carries `const Type*`** (`cg_adapter.h:65`), so every
- CG-driving op re-crosses the bridge via `type_cg_id_in_pool` (>1.2M calls), and
- `type_cg_lower` runs a `t->kind` switch *before* checking its own `t->cg_id`
- cache (`type.c:840-846`).
-- **Two parallel type-info memos** (`c_abi_type_info` keyed on `Type*`,
- `abi_cg_type_info` keyed on `CgId`) wrap the same facts; a `sizeof` probes one,
- crosses the bridge, then reads the other.
-
-**The rewrite.**
-1. **Memoize `c_abi_record_layout`** (highest leverage, lowest risk) — mirror the
- `AbiInfoMap` pattern (`c_abi.c:22`) or, better, cache the layout pointer on the
- `Type` node itself (it already carries a mutable `cg_id` slot, `type.h:94`).
- The O(M·N) rebuild collapses to one build per distinct record. **−40…80M.**
- Byte-identical.
-2. **A flat per-id descriptor** — extend the proven `cached_class` byte
- (`type.c:33`, with its `pad[3]`) to a packed `{unalias_is_self, pred_bitset,
- unaliased_id}` stamped once (eagerly at construction for builtins/records).
- Predicates become builtin-fast-path OR one `api_type_from_id` + a byte test;
- `api_unalias_type` returns `e->unaliased`. **−15…30M.** Byte-identical.
-3. **Carry the lowered `CgId` on the `PcgSlot`** alongside `const Type*`, filled
- once at push — operands stop re-crossing the bridge. Plus hoist the `t->cg_id`
- cache check above the `type_cg_lower` switch. **−15…30M** (P3a byte-identical;
- P3b re-stamp `cg_id` whenever `slot->type` is written; couples with the
- `PcgSlot`/`ApiSValue` shrink so net size is neutral).
-4. **Collapse `c_abi_type_info` into a thin reader** off the descriptor once (2)
- and (3) land — delete the `AbiInfoMap` probe. **−8…14M.** Byte-identical.
-
-**Floor.** The irreducible work is one decode + classify + layout per *distinct*
-type, then O(1) loads. The rewrite reaches it. **Area est.: ~80…140M (−4…7%)** —
-the first credible double-digit-M win in this campaign, because (1) attacks genuine
-recomputation, not a constant factor. Sequence: (1) first and independent; (2) and
-(3a) independent; (4) after (2)+(3b). Re-profile after (1).
-
----
-
-## §E — Operands & emit (the -O0 NDT path): *pop, pop, emit — one real dispatch, one byte-store*
-
-**Ideal flow.** An operand is a small fixed stack record; an operator is
-`pop,pop,emit` with exactly **one** indirect call (the real per-arch
-`NativeTarget` dispatch) and ≤1 four-byte store *into the final section image*. No
-marshalling round-trip, no fake vtable, no mini register-allocator on the hot
-path. The fold/strength-reduce ladder stays (it removes emitted bytes — the
-multiplicative codesize lever); only its *decision cost* is slimmed.
-
-**Already at the ideal (verified — do not re-propose).** `F2` (MCEmitter
-devirtualized to direct extern calls; the `static inline` form regressed +21M and
-is documented out-of-line), `D6` (relocations indexed by section — the
-O(n_sections × n_relocs) rescan is gone, the latent superlinear axis closed), and
-the value-stack residency + coalescing + lazy-home work (object now 1.09× tcc) are
-**done**.
-
-**Violation that remains.**
-- **A per-instruction libc `memcpy` in the byte sink.** `aa_emit32`
- (`aa64/native.c:329`) stages a word into `u8 b[4]` then calls the out-of-line
- `mc_emit_bytes(mc,b,4)` → `buf_write` → `memcpy(t->data+t->used, data, n)`
- (`buf.h:55`). Because the call boundary blocks const-propagating `n=4`, the
- memcpy lowers to a **libc call: 6.94M `Ir` over 434K calls** — one per emitted
- word, the dominant memcpy in the whole compile. *(Verified.)*
-- **The arch re-queries types the NDT already knew.** `loc_is_64` /`type_size32`
- (`aa64/native.c:374`) call `cg_type_size` + `cg_type_is_ptr` on `loc.type` for
- every binop/move/convert/cmp — ~6.9M of `cg_type_size`'s callers are backend
- re-derivation of a width the NDT had when it built the `NativeLoc`.
-- **A 48 B `NativeLoc` marshalling round-trip** — `nd_binop` passes 3×48 B by
- value; the arch immediately unwraps each to a register number.
-
-**The rewrite.**
-1. **Typed-store byte sink** (the "F2 second half," never built): add direct
- `mc_emit32(MCEmitter*, u32)` / `mc_emit_word(…, u64, n)` against a `{cur,end}`
- cursor cached on the base — `if (cur+4<=end){ wr_u32_le(cur,word); cur+=4; }
- else slow()`. `aa_emit32`/rv64 call it directly: **one 4-byte store, no
- staging, no libc call.** **~5–6M**, compounds with codesize (per emitted word).
- Risk: cursor↔`Buf::used` coherence across the slow path / `buf_patch` /
- `mc_pos` (sync before any random-access read). Byte-identical + a `-g` check.
-2. **Stamp `is64`/`size_log2` on the `NativeLoc` pad** (`native_target.h:261` has
- free `pad[2]`) at the `native_loc_reg` choke point; the arch reads the bits
- instead of `loc_is_64`/`type_size32`. **~4–6M**, byte-identical, low risk
- (falls back to `loc.type` where unset). The arch-side elimination is
- independent of §D's descriptor.
-3. **Narrow register-only `NativeTarget` hooks** (`binop_rr` etc., ~16–24 B with
- the imm rider vs 144 B/op) for the post-materialization ops; keep the fat
- vtable for the opt replay path, gate on `opt_level==0`. **~3–5M**, additive
- across all arches; synergistic with (2) (the hook carries exactly what the
- arch reads). Byte-identical for the rr refactor; defer the x64 fused
- mem-operand sub-case (changes selection → run-correctness gate).
-
-**Explicitly dropped** (measured/judged not worth it): D2 round-robin victim (the
-LRU is inlined, invisible to `Ir`, and coarser eviction risks the codesize track —
-instead a policy-neutral free-register bitmap for the `nd_cache_alloc` free-pool
-scan is ~1–2M, byte-identical); C3 `ApiSValue` shrink (Round-7-neutral;
-`api_sv_adjust_refs` at 0.67% is the load-bearing liveness refcount that *drives*
-coalescing, not copy bandwidth); D5 zero-copy section write (`buf_flatten` is only
-0.25M `Ir` — invisible to the metric).
-
-**Floor.** This subsystem's micro-levers each land single-digit-M (as
-`PERF-TCC-SLIM` §10b warned); items 1–3 + the bitmap total **~13–19M (−0.6…0.9%)**.
-The real emit-phase lever is the **codesize track** (fewer emitted bytes →
-multiplicatively less byte-sink/objwrite/assemble), already most-realized by the
-value-stack work. Item 1 leads because it compounds with codesize. **Do not touch
-the fold ladder** (`arith.c:48-99`) — `PERF-TCC-SLIM` §8 holds.
-
----
-
-## §F — Emitted bytes / objwrite: *each machine byte written once into the final image*
-
-**Ideal flow.** Machine code is built once in the final section buffer; symbols,
-strings, relocations are each bucketed once. No flatten-to-temp second copy, no
-O(n²) rescan.
-
-**Status.** The two historical O(n²) violations are **closed**: `obj_strtab_add`
-(shared content-hashed `ObjStrtab`) and the reloc-by-section rescan (`D6`,
-`obj_reloc_index_ensure`, a counting sort — verified). The residual `buf_flatten`
-second copy of all machine code at ELF/Mach-O write is real but only ~0.25M `Ir`
-(one big 2.2 MB copy, cheap per byte) — **below the metric's noise; not worth the
-Writer-API surface change** unless a future wall-clock/cache pass resurfaces it.
-The full tcc-shape ideal (`mc_emit32` stores into one contiguous geometric section
-buffer = the final image, no chunked `Buf` at all) is the §E-item-1 cursor taken
-to its conclusion; revisit only if §E-1's chunked cursor still shows the spill
-path hot.
-
----
-
-## Sequencing by leverage (re-profile after each — the frontier moves)
-
-```
-WAVE 1 (biggest, cleanest, all byte-identical):
- §A.1 memchr splice sweep ~ -8.5…9.5% (largest single lever)
- §D.1 memoize c_abi_record_layout ~ -40…80M (the F1-missed recomputation)
- §B.1 direct-mapped intern cache ~ -30…45M (79% of interns are redundant)
-
-WAVE 2 (high-leverage, byte-identical):
- §A.2 delete per-lexer (frontend) Pool ~ -20…30M
- §D.2 flat per-id type descriptor ~ -15…30M
- §D.3 CgId on PcgSlot + cg_id hoist ~ -15…30M
- §E.1 typed-store byte sink ~ -5…6M
- §E.2 is64/size_log2 on NativeLoc ~ -4…6M
- §C.3a newline drop one frame lower ~ -5…12M
-
-WAVE 3 (more invasive / structural follow-ups):
- §A.3 per-compile punct table ~ -5…15M
- §D.4 collapse c_abi_type_info ~ -8…14M
- §E.3 narrow register-only NT hooks ~ -3…5M
- §C.3b non-directive newlines never Tok ~ -15…25M
-
-DROPPED / not Ir levers (measured — do not pursue):
- §B self-sufficient {hash,sym} slot (~0 Ir, D1-only) · relay-copy rewrite (dead) ·
- Tok 24→16B (hot-path line decode) · §E D2 round-robin / C3 ApiSValue / D5 zero-copy
-```
-
-**WAVE 1 alone is ~13–15% off the 2.04 B compile** (moving kit from ~3.1× toward
-~2.7× tcc) for three low-risk, byte-identical rewrites. All three attack **genuine
-recomputation** — an unmemoized O(M·N) layout rebuild (§D.1), a redundant scalar
-pass over every byte (§A.1), and 79%-redundant per-occurrence hashing (§B.1) — not
-constant factors. Those are the rewrites-to-the-ideal; WAVE 2–3 compound. Re-profile
-after each landing — the frontier moves, and several WAVE-2 items shrink once their
-WAVE-1 neighbor lands.
-
-## Measured results — WAVE 1–2 landing (2026-06-13, 9 commits `325a68b5`..`bcdbeba3`)
-
-All of WAVE 1 plus most of WAVE 2 landed as 9 byte-identical commits (5 parallel
-in-tree agents on disjoint file sets). **Cumulative, Linux callgrind, sqlite3.c
-`-c`:**
-
-| | Ir | vs tcc (0.663 B) |
-|---|--:|--:|
-| baseline (`de8c3b62`) | 2,038,845,542 | 3.08× |
-| **after WAVE 1–2 (`bcdbeba3`)** | **1,796,553,239** | **2.71×** |
-| **delta** | **−242,292,303 (−11.88%)** | |
-
-Gate: the full 60-category byte-identity gate PASSes and the sqlite `-O0` object is
-bit-for-bit identical to golden (2,216,680 B) — i.e. **−11.88% instructions for
-zero output change**. Green: toy / parse / pp / cg-api / opt / smoke-x64 /
-smoke-rv64.
-
-**Per-change, measured vs. estimated** (estimates ran high, as the docs warned —
-trust measured):
-
-| change | est. | measured (self-Ir move) |
-|---|--:|---|
-| §A.1 memchr splice sweep | 215M→20–40M | `lex_open_mem` **215.0M → 78.9M** (−136M; residual is the per-open `memset`) |
-| §D.1 memoize `c_abi_record_layout` | −40…80M | `kit_cg_type_record_field` **48.0M → out of top**, `kit_cg_type_size` 35.7M→13.2M |
-| §B.1 intern cache | −30…45M | `pool_intern_slice` **84.7M → 71.6M** (−13M; under estimate) |
-| §E.1 typed-store byte sink | −5…6M | `mc_emit_bytes` **16.3M → below threshold** |
-| §C.3a newline drop | −5…12M | old relay → `pp_pull_into` (consolidated) |
-| §D.2/§D.3a descriptor + hoist | −15…30M | predicates collapsed into `api_type_pred` |
-| §A.2 / §E.2 | −20…30M / −4…6M | folded into the cumulative total |
-
-**The re-profile surfaced the next frontier** (re-rank after every wave — the
-discipline holds):
-- `api_type_pred` is a **new 66.8M hub** — §D.2 made predicates O(1) but the
- per-call `id → entry` segvec decode is now the cost → **§D.3b** (carry `CgId` on
- the value stack) or inline `api_type_pred`.
-- `lex_open_mem` residual **78.9M is the per-open `memset`** → **§A.3** (hoist the
- ~1184 B `punct_sym[]` to a per-compile table) now has clear payoff.
-- `pool_intern_slice` 71.6M — the cache took the hash side; the probe side remains.
-- `pp_pull_into` 73.8M — **§C.3b** (don't materialize non-directive newlines) is
- the structural follow-up.
-
-## Measured results — WAVE 3 (2026-06-13, 5 commits `fb17391c`..`bfa30f77`)
-
-The second-order levers, landed by 3 more parallel in-tree agents (type-hub /
-lexer-path / emit-hooks). **Diminishing returns, exactly as the docs warn
-second-order levers do:**
-
-| | Ir | vs tcc |
-|---|--:|--:|
-| after WAVE 1–2 | 1,796,553,239 | 2.71× |
-| **after WAVE 3** | **1,785,778,881** | **2.69×** |
-| WAVE 3 delta | −10,774,358 (−0.60%) | |
-| **cumulative (from `de8c3b62`)** | **−253,066,661 (−12.41%)** | **3.08× → 2.69×** |
-
-Gate: full 60-category byte-identity PASS, sqlite object still 2,216,680 B.
-
-| change | commit | measured |
-|---|---|---|
-| builtin pred_bits memo + §D.3b CgId-on-slot | `fb17391c`,`486575cf` | `api_type_pred` 66.8M → 61.9M (−4.9M) |
-| §A.3 punct cache (`struct Lexer` 1208→288 B) | `3e0e8146` | `__GI_memset` 28.7M → 26.5M (−2.2M) |
-| §C.3b drain non-directive newlines | `bfa30f77` | shows in `kit_arena_reset` (per-newline reset removed) |
-| §E.3 narrow register-only NT hooks | `d953be12` | ~3–5M in the `nd_binop`/cmp/convert crossing |
-
-**Honest correction (the re-profile earned it): §A.3 was mis-diagnosed.** The
-`lex_open_mem` 78.9M residual was assumed to be the per-open `memset` of the
-1208 B `struct Lexer`. It is **not** — `memset` is an out-of-line libc call, so
-shrinking the struct to 288 B only moved `__GI_memset` (−2.2M); `lex_open_mem`
-self stayed at **79.2M**. That 79M is genuine per-open work *other than* the
-memset (field init, the splice-fold loop control, `kit_source_add_memory`,
-`lex_catchup_splices`) and needs a fresh diagnosis before it can be cut. §A.3 was
-still a real −2.2M and shrank the struct 4.2×, but it did not touch the residual
-it targeted. Likewise the §D.2/§D.3b hub work moved `api_type_pred` only −5M — the
-per-call `id → entry` decode is more stubborn than the builtin-reclassify was.
-
-**The frontier is now genuinely hard.** The remaining top costs are the
-irreducible scan (`lex_next` 16.5%), `lex_open_mem` 79M (needs re-diagnosis —
-*not* the memset), `pool_intern_slice` 71.6M (probe side, measured ~0-Ir
-improvable), `src_next_raw_into` 86M + `pp_pull_into` 74M (per-token PP logic), and
-`api_type_pred`/`cg_type_get`/`api_unalias_type` decode (~115M, the next type
-lever is the id→entry decode itself). No remaining single structural lever of the
-WAVE-1 class (the O(M×N) rebuild, the redundant scalar pass) is visible — closing
-the last 2.69× → ~2× needs the **codesize track** (fewer emitted bytes, the
-multiplicative lever) and/or Track 4 (tcc's shared-token single-pass shape), not
-more per-op slimming.
-
-## Measured results — §R-A/§R-B/§R-C implemented (2026-06-13, 8 commits `209596b8`..`fe54567a`)
-
-The seam-preserving backlog below was worked **in full**: 8 byte-identical items
-landed (4 parallel isolated-worktree tracks PP/PARSE/TYPE/OBJ, each per-item
-byte-identity-gated against a shared golden) and 5 items were **skip-justified with
-the diagnosis verified against current source** (they are not byte-identical-
-achievable, are ~0 `Ir`, are already done, or — for one — the doc's literal design
-was a latent miscompile). The "no per-op lever left" pessimism above was too strong:
-the seam-preserving per-op work still found **−1.86%** before the codesize track.
-
-**Cumulative, Linux callgrind, sqlite3.c `-c`:**
-
-| | Ir | vs tcc (0.663 B) |
-|---|--:|--:|
-| after WAVE 3 (`b50e883e`) | 1,785,778,688 | 2.69× |
-| **after §R landing (`fe54567a`)** | **1,752,564,544** | **2.64×** |
-| §R delta | **−33,214,144 (−1.86%)** | |
-| **cumulative (from `de8c3b62`)** | **−286,280,998 (−14.04%)** | **3.08× → 2.64×** |
-
-Gate: full 60-category byte-identity **PASS**; sqlite `-O0` object still bit-identical
-(2,216,680 B). Green: pp / parse / toy (1392/0/35skip) / cg-api / opt / elf / macho /
-link / ar / debug / dwarf / smoke-x64 / smoke-rv64.
-
-**Landed (8 items, all byte-identical):**
-
-| item | commit | mechanism |
-|---|---|---|
-| A1 Sym-keyed binding cache | `5ff33ea3` | `scope_lookup` → one `BindingTab_get`; shadow saved on `SymEntry.shadowed`, head-first unwind in `scope_pop` |
-| A3 macro body pointer-replay | `7ada3f62` | no-`##` object bodies replayed by pointer (`has_paste` cache + `TokSrc` loc/flag override + `push_buf_replay`); 2 copies/expansion removed |
-| A2 paste-path slimming | `39c384b1` | reuse one per-`Pp` paste lexer (`lex_reset_mem`) + cache the `<paste>` `Sym`; **file_id ordering preserved** (DWARF gate is the proof) |
-| A5 skip no-op `arena_reset` | `209596b8` | `arena_is_empty` pristine predicate + early-out + `pp_pull_into` guard |
-| B1 one decode per id | `9a060c6c` | CG-internal `api_type_pred_bits` (one decode → full bitset) at multi-predicate sites + hoist non-CSE-able `api_unalias_type` |
-| B2 fold dual ABI memo | `9bd45ea5` | delete `c_abi_type_info` + its `Type*`-keyed `AbiInfoMap`; size/align via the per-id memo; `signed_` → `type_is_signed_integer` |
-| B3 type-lower micro | `407e8ac9` | field-init `TypeCgLower` (drop `memset`) + stamp the builtin `cg_id` on the `Type` node |
-| C4 objwrite mop-up | `fe54567a` | single-pass Mach-O/ELF symtab counts + direct Mach-O strtab write (no flatten copy) |
-
-**Per-item attribution caveat (PERF.md §6).** The naive per-function self-`Ir` diff
-between the two builds **over-counts** wildly (its group-sum is −125M vs the real
-−33.2M `PROGRAM TOTALS`) because each item moves work into a *new* function the diff
-double-counts: `api_type_pred` 61.85M → `api_type_pred_bits` 48.83M (B1), `lex_open_mem`
-self collapses into `lex_reset_mem`+callees (A2), `arena_reset` 11.81M→0.44M but a new
-`arena_is_empty` +7.3M (A5), `subst_phase2` −4.07M but a new `push_buf_replay` +2.09M
-(A3), `c_abi_type_info` 19.13M removed but redistributed to the cheaper per-id memo (B2).
-**Trust the −33.2M total.** The one clean isolation is **A1 = net −6.8M** (`scope_lookup`
-8.91M→0.99M, bookkeeping +1.11M — its work stays inside the four scope functions), which
-**refutes the doc's worry** that the per-scope `emap` indexes already amortized it:
-`scope_lookup` was a real 8.9M chain-walk, and tcc's binding-on-the-symbol technique cut it.
-
-**Skip-justified — verified against current source, NOT implemented:**
-
-- **§R-A A4** (stop materializing non-directive newlines) — **not byte-identical-
- achievable.** `lex_next` is the single source funnel feeding the directive scanner,
- the macro-arg scanner, the `-E` emitter, and the cc content path on ONE shared
- `Lexer`, interleaved dynamically; the "is this newline directive-internal / whitespace"
- decision lives in PP, invisible to the lexer. `-E` requires 1:1 `TOK_NEWLINE`→`\n`
- (forecloses collapse); directive-line termination and multi-line-macro accounting need
- the materialized newline *in cc mode*. The §C.3a/b in-place drain (`bfa30f77`) already
- captured the only safe win — the parser already never sees a newline `Tok`, so there is
- nothing further to delete.
-- **§R-A A6** (intern probe/insert side) — **~0 `Ir`.** The 1024-slot direct-mapped intern
- cache already absorbs ~85% of probes; the only candidate (the self-sufficient `{hash,sym}`
- fat-table slot) trades one `ents[sym].hash` load for one `slot.hash` load — same
- instruction, better D1 locality — a cache-stall play the `Ir` metric cannot see, and it
- doubles the table. Revisit only under a dedicated cachegrind (D1mr/DLmr) study.
-- **§R-C C1** (free-register bitmap) — **the doc's literal design is a latent rv64-fp
- miscompile.** `nd_cache_alloc`'s scan is `(reg_owner==NONE) && !pinned` over `cache_pool`
- in **priority** order, not `caller_saved & ~owned` in register-number order; rv64-fp's
- pool `{4,5,6,7,28,29,30,31,10,…}` is non-ascending, so register-number find-first-set
- picks a *different* register than the linear scan (reg10 vs reg28) → different emitted
- bytes, invisible to the default host gate. The only byte-identical variant (a priority-
- position-indexed bitmap) lands its maintenance on hotter paths than the scan it removes →
- a wash; and the scan already exits at index 0–1 (the live cached set is tiny after every
- barrier flush).
-- **§R-C C2** (typed-store for memory-op emit) — **already subsumed by §E.1.** `aa_emit_mem`
- already routes every word through `aa_emit32`→`mc_emit32` (the typed `{cur,end}` cursor);
- there is no residual stage-bytes + `mc_emit_bytes` fixed-width path left to convert.
-- **§R-C C3** (contiguous section image) — **too invasive for the payoff.** `buf_flatten`
- is ~0.25M `Ir` (one big cheap copy, below the metric's noise); replacing the chunked
- geometric `Buf` with a single image `mc_emit32` writes into is a Writer-API surface change
- for a sub-noise win. Revisit only under a wall-clock/cache pass.
-
-## Remaining opportunities — modularity-preserving (the backend seam STAYS)
-
-The `CgTarget` → `NativeTarget` polymorphism is the architecture's asset (one
-frontend, seven backends: aa64/rv64/rv32/x64 native + wasm + c_target + check, plus
-the opt-IR recorder at -O1) and is **out of scope** — we are not collapsing the
-value-stack→direct-emit seam, not monomorphizing it, not adding a parallel
--O0 fast path that bypasses the public `kit_cg_*` API. Everything below respects
-that line. It lives in one of four places that are **not** the swappable seam:
-(a) the front half (lex/pp/intern/parse), (b) *inside* the concrete
-`NativeDirectTarget` and the monomorphic `MCEmitter` (the -O0 register machinery
-and byte sink — these are not the polymorphic vtable), (c) the type subsystem, or
-(d) the codesize track (which *bytes* the NDT chooses to emit, not how it dispatches).
-
-> **Reframe that governs this list (verified):** kit is *already* AST-free and
-> IR-free at -O0 (`session.c:148` gates the opt IR behind `opt_level > 0`; the
-> parser emits as it parses; the `p->replay[]` buffer is bounded local lookahead
-> for incomplete-array brace-init sizing only, `parse_type.c:1746`). tcc's "no
-> second copy of the program" property is already true here. So none of the items
-> below is "delete a second representation" — they are **thin-the-unit /
-> classify-once / emit-fewer-bytes**, the same conservation laws as the rest of
-> this doc.
-
-The re-profile shows no remaining single function of the WAVE-1 class. The biggest
-remaining *lever* is the codesize track (§R-D, multiplicative); the highest-value
-single *brick* is the symbol-binding cache (§R-A1).
-
-### §R-A — Front half: lex / pp / intern / parse (~50% of self-Ir)
-
-| # | opportunity | where | character | gate |
-|---|---|---|---|---|
-| **A1** | **Symbol-binding cache on the interned `Sym`** (the front-half "single-pass brick"; SLIM §B5). `scope_lookup` is an N-scope chain walk per identifier (`parse.c:378`, 12 call sites); tcc caches `sym_identifier` on the interned token. A `Sym`-keyed binding stack with push/restore on scope enter/exit makes resolution a pointer load. | `parse.c:301-380` (scope), interner | **structural, high value** (depth-dependent, ~5-10% of parse phase) | byte-identical; **medium-high risk** — save/restore + shadow/redef/typedef-vs-ident discipline; full parse corpus |
-| A2 | **Re-diagnose `lex_open_mem`'s 79M** (WAVE-3 proved it is NOT the memset). It is genuine per-open work over ~6,000 opens (3,140 files + 2,796 macro-paste buffers): field init, the splice-fold loop control, `kit_source_add_memory`, `lex_catchup_splices`. Profile *what*, then cut per-open setup — esp. whether macro-paste buffers (`<paste>`) need a full `lex_open_mem` at all. | `lex.c:281-305`, `pp_expand.c` paste sites | unknown until re-profiled; potentially structural | byte-identical |
-| A3 | **Macro body replay by pointer-swap** (gap #3 / SLIM §A5). `subst_phase2` copies the object-macro body even with no `##`; function-macro args take 3-4 copies/token. A `has_paste` flag set at definition → replay the immutable body directly (tcc's model). | `pp_expand.c` (`subst_phase2`, arg subst) | structural, PP-contained | byte-identical (macro corpus) |
-| A4 | **Stop materializing non-directive newlines** (deeper §C.3b). Today they are drained in place (`bfa30f77`); the full version never builds them as `Tok`s on the cc path (lexer sets `TF_AT_BOL`; `-E` keeps a newline-emitting variant). ~51% of lexer outputs are newlines. | `lex.c:644`, `pp.c`/`pp_directive.c` | structural, **med-high risk** (cc-vs-`-E` split; directive-line contract) | byte-identical, gate the `-E` categories hard |
-| A5 | **PP per-token micro-levers**: gate the `kit_arena_reset` reset-check on "xarena actually grew" (fires ~every 5 tokens); tighten the `#line`-delta + include-guard state machine on the `src_next_raw_into`/`pp_pull_into` hot path. | `pp.c:949`, `pp_expand.c:935` | cleanup (~1-3M) | byte-identical |
-| A6 | **`pool_intern_slice` probe/insert side** (71.6M; the WAVE-1 cache took the hash side). Diminishing — the self-sufficient `{hash,sym}` slot is measured **~0 Ir** (a D1/wall-clock play, not instructions). Only revisit under a cache-stall study. | `pool.c` | **near-dead-end for Ir** | — |
-| — | `lex_next` (16.5%, the dominant fn) and the `Tok` relay (gap #1) are **already optimal** — raw-cursor scanner; `_into` out-pointer relay; `Tok`-16B was a measured dead end. No structural lever left. | — | done | — |
-
-### §R-B — Type subsystem (~14% of self-Ir)
-
-| # | opportunity | where | character | gate |
-|---|---|---|---|---|
-| B1 | **Kill the per-call `id → entry` decode** that is now the residual hub cost (`api_type_pred` 61.9M, `cg_type_get` 26.6M, `api_unalias_type` 53.7M). §D.2 made the *result* O(1); §D.3b carried the `CgId` on the parser slot — go one further and carry the **decoded `CgApiType*` entry** (or inline `api_type_from_id`'s segvec double-index) so a predicate is a direct field read, not a decode. | `src/cg/type.c` (`api_type_from_id`, `api_type_pred`), `cg_adapter` | structural-ish (the next type lever) | byte-identical |
-| B2 | **Collapse `c_abi_type_info` into a descriptor reader** (§D.4, deferred). Two memos compute the same facts on two keys (`Type*` vs `CgId`). Blocked by a layer boundary (frontend has no `KitCompiler*→TargetABI*` accessor) + the `signed_` divergence + a `scalar_kind` ALIAS edge — needs a small public accessor first. | `lang/c/abi/c_abi.c:34`, `src/abi/abi.c` | cleanup (~8M), boundary work | byte-identical |
-| B3 | **`resolve_type` 26.7M / `type_cg_lower` 16M / `type_cg_id_in_pool` 13.4M** — the `Type*`→`CgId` bridge. §D.3b cut the *operand* re-crossings; `resolve_type` (frontend type resolution) is separate and still hot. Audit for a re-resolve that a per-`Type` cache would remove. | `lang/c/type/type.c`, `cg_adapter` | cleanup | byte-identical |
-
-### §R-C — Inside the concrete NDT + the monomorphic MCEmitter (NOT the vtable, ~7%)
-
-These are the -O0 register machinery and byte sink *within* `NativeDirectTarget` /
-`MCEmitter` — concrete, not the swappable dispatch. Fair game.
-
-| # | opportunity | where | character | gate |
-|---|---|---|---|---|
-| C1 | **Free-register bitmap** for `nd_cache_alloc`'s free-pool scan (the policy-neutral half — find-first-set on `caller_saved & ~owned` instead of the linear scan). The LRU victim policy (D2 round-robin) was **dropped** — it is inlined/invisible to Ir and risks the codesize track. | `native_direct_target.c` (`nd_cache_alloc` 0.75%, `nd_dst_reg`, `nd_dst_writeback`) | cleanup (~1-2M) | byte-identical |
-| C2 | **Typed-store for memory-op emit** — §E.1's `mc_emit32` covered the fixed-width path; `aa_emit_mem` (0.81%) and the other multi-byte idioms still stage+`mc_emit_bytes`. Extend the cursor store to them. | `aa64/native.c` (`aa_emit_mem`), `mc.c` | cleanup (~few M) | byte-identical |
-| C3 | **Contiguous section image** (gap #5 / D5-stretch) — a single geometric section buffer so `mc_emit32` stores into the *final* image (the full tcc shape for emit), eliminating `buf_flatten` at objwrite. MCEmitter is monomorphic, so this never touches the seam. | `mc.c` (cursor), `obj/{elf,macho}/emit.c` | structural (the emit brick); Ir win small (~0.25M flatten) but it is the last "emit into final image" piece | byte-identical (`test-elf/macho/link`) |
-| C4 | **objwrite mop-up** (D7): single-pass symtab emission, chunk-walk the strtab. | `obj/macho/emit.c`, `obj/elf/emit.c` | cleanup | byte-identical |
-| — | **`ApiSValue` 56→24 B shrink (C3)** — the value node is CG-layer infra (not the vtable, so technically in scope) but the shrink was **measured neutral** (Round 7) and `api_sv_adjust_refs` (0.77%) is the load-bearing liveness refcount that drives coalescing, not copy bandwidth. Leave it. | `src/cg/internal.h`, `value.c` | measured-neutral | — |
-
-### §R-D — The codesize track (the BIGGEST remaining lever — multiplicative, seam-independent)
-
-kit's -O0 `__TEXT` for sqlite is **1.88 MB, ~2.4× tcc's** (`o0-codesize-vs-tcc`).
-This is the *what-bytes-NDT-emits* axis — it does **not** touch the dispatch seam,
-and it is **multiplicative**: every byte removed cuts emit + objwrite + assemble
-together. The value-stack residency + coalescing + lazy-homes work already took the
-object 4.26 MB → 2.22 MB (~1.0× tcc object); the remaining ~2.4× *text* bloat is
-the single largest lever left after the front half. Open items (from
-`PERF-TCC-SLIM` Track B / the `o0-codesize` track):
-
-- **Signed-load fold** `ldrb;sxtb` → `ldrsb` (and the zero-extend cases) —
- structurally **blocked**: needs a load-with-extend rider on `MemAccess`, not a
- register rename (the copy/convert-coalescing work flipped the register cases but
- could not reach the load itself).
-- **Call-argument spill** reduction (fewer stack stores around calls).
-- **Indexed-addressing fold** (collapse base+index into one addressing mode).
-- **NOP / alignment pad** trimming.
-- Each is **run-correctness + determinism**-gated (it deliberately changes emitted
- bytes), not byte-identical — and each compounds with the per-op emit costs in §R-C.
-
-This is the track to open next: it is the largest remaining lever, it is entirely
-within the NDT's byte choices (seam untouched), and it directly shrinks the worst-
-ratio phase.
-
-### §R-E — Memset / arena churn (~2-3%)
-
-| # | opportunity | where | character |
-|---|---|---|---|
-| E1 | Right-size the per-expression / per-emit struct zeroing (`__GI_memset` ~26.5M); designated-init the per-op clears (the idiom `api_op_imm`/the B2 `PcgLvAux` work already use). | `cg_adapter.c`, `value.c`, `native_direct_target.c` | cleanup |
-| E2 | Arena churn (`arena_alloc` 20.4M, `arena_reset` 14.2M) — audit per-statement/per-temp allocation vs reuse. | `src/core/arena.c` consumers | cleanup |
-
-### Explicitly OUT of scope (the seam stays modular)
-
-The gap-#4 value-stack→direct-emit collapse — removing/monomorphizing the
-`CgTarget`/`NativeTarget` vtable indirection, or adding a parallel -O0 native path
-that bypasses the public `kit_cg_*` API. The two real indirect calls per primitive
-(the `CgTarget` seam + the per-arch `NativeTarget` dispatch) are the price of the
-seven-backend modularity and are **kept**. The ~2× instruction headroom that a full
-tcc-shape collapse might reach is **not** pursued; the seam-preserving items above
-plus the codesize track are the campaign from here.
-
-## Appendix — claims verified against source (not docs)
-
-- §A scalar splice loop + no NEON: `lex.c:156-157`, disasm of the profiled binary. ✓
-- §A per-lexer pool dead except `->c`: `lex.c:288,340,597,784`; ident intern hits
- shared `c->global` via `kit_sym_intern` (`src/api/core.c:220`). ✓
-- §B two distinct `Pool` types — `c_pool_new` makes the **frontend** pool (arena +
- type/abi cache, `cpp_support.h:33`), *not* the string interner (`c->global`,
- `src/core/pool.h`); intern is already per-compile-shared. ✓
-- §B/§C measured on real sqlite3.c (re-tokenized): 440,942 id+num occurrences /
- 19,700 distinct (22.4× reuse); 256-slot intern cache 78.7% hit, single-slot 3.4%;
- `lex_next` 1,424,699 calls vs 684,790 tokens to the parser (51% dropped in PP). ✓
-- §D `c_abi_record_layout` unmemoized vs `abi_cg_record_layout` memoized:
- `lang/c/abi/c_abi.c:85-118` (fresh `arena_znew` + per-field loop, no cache) vs
- `src/abi/abi.c:150-161` (`AbiRecLayoutMap_get/set`). ✓
-- §D parser stack carries `Type*`: `lang/c/parse/cg_adapter.h:65`. ✓
-- §E byte-sink libc memcpy: `aa64/native.c:329` (`b[4]`+`mc_emit_bytes`) →
- `mc.c:242` (`buf_write`) → `buf.h:55` (`memcpy(...,n)`), n=4 not const-propagated
- across the out-of-line call. ✓
-- §E F2/D6 already landed: `mc.h:48-82` (no fn-ptrs), `obj.c:1140-1196`
- (`obj_reloc_index_ensure` counting sort). ✓
diff --git a/doc/plan/PERF-O0-CODESIZE.md b/doc/plan/PERF-O0-CODESIZE.md
@@ -1,541 +0,0 @@
-# Shrinking kit's -O0 emitted code toward tcc — the code-size track
-
-Companion to `PERF.md` (compile-speed standings), `PERF-TCC-GAP.md` (structural
-diagnosis), and `PERF-TCC-SLIM.md` (the vtable-slimming compile-speed campaign).
-Those chase **fewer instructions to compile**. This doc chases **fewer
-instructions emitted** — making kit's `-O0` machine code as dense as tcc's.
-
-The two tracks compound. Every emitted instruction removed is also one fewer to
-emit, relocate, and write to the object — so codesize wins shrink the
-emit/objwrite/assemble phases *multiplicatively* (`PERF-TCC-SLIM.md` §2.1). The
-goal is **smaller and faster output while holding -O0 compile speed** — and
-because un-emitted instructions cost nothing to emit, the well-chosen wins here
-*improve* compile speed rather than trading it away.
-
-The arch focus is **aarch64** (the reference backend); x64/rv64 carry analogous
-taxes — the shared-NDT levers (below) already help them, the aa64-specific ones
-follow once the design is proven.
-
----
-
-## 1. Current state (measured 2026-06-13, branch `o0-codesize`)
-
-`build/release/kit cc -c sqlite3.c` vs `tcc -c sqlite3.c`, arm64-macOS, on the
-3.50.2 amalgamation (`tmp/projects/sqlite-amalg/sqlite3.c`, 9.28 MB).
-
-| metric | kit | tcc 0.9.28 | ratio |
-|---|--:|--:|--:|
-| **machine code (`.text`)** | **1,430,828 B / 357,707 insns** | **1,370,940 B / 342,735 insns** | **1.044×** |
-
-**The honest size metric is the `.text` machine code**, and there kit is now
-**1.044× tcc, +14,972 excess instructions** — down from 1.084× / +28,802 after the
-single-pass follow-ons (§2: Lever 3/4 + §4.6), 1.16× / +55,698 after Lever 2, and
-1.33× / +112,811 at the start of this campaign. **Lever 1 (args into the ABI arg
-registers, §2/§4.1) closed roughly half the remaining excess** (357,707 vs 371,537,
-−13,830 insns / −3.72% `.text`; `mov` 48,578→36,188, −25.5%). The object file
-(Mach-O vs tcc's ELF) is not comparable, and compile-speed lives in `PERF.md`.
-
-**Reproduce:**
-
-```sh
-KIT=build/release/kit; TCC=tmp/tinycc/tcc
-SRC=tmp/projects/sqlite-amalg/sqlite3.c; SDK=$(xcrun --sdk macosx --show-sdk-path)
-"$KIT" cc -c "$SRC" --sysroot "$SDK" -o /tmp/kit.o # text size: kit size /tmp/kit.o
-"$TCC" -c "$SRC" -o /tmp/tcc.o # kit size /tmp/tcc.o
-# opcode histogram diff:
-"$KIT" objdump -d /tmp/kit.o | grep -E '^[[:space:]]+[0-9a-f]+:' \
- | sed -E 's/.*\t([a-z][a-z0-9._]*).*/\1/' | sort | uniq -c | sort -rn
-```
-
----
-
-## 2. Landed
-
-Five levers landed and gated (determinism + sqlite e2e O0/O1 golden+vs-clang +
-toy/parse/smoke-x64/smoke-rv64/dwarf/debug + alloca/far-slot/narrow/p1-x64
-clang-differential probes). Design rationale lives in each commit message.
-
-| lever | change | aa64 insns | where |
-|---|---|--:|---|
-| **L2** | 2-insn top-record epilogue (`mov sp,x29; ldp [sp],#16`, was a 3-insn x16 dance) | −2,633 | aa64 |
-| **L1** | far fixed slots addressed positive-scaled off sp (`ldr/str [sp,#+ofs]`, was `sub xN,x29,#off; ldur`); alloca anchors a callee-saved x28 frame base | −25,579 | aa64 |
-| **L4-P1** | scalar call result stays in the ABI result register (was `bl; mov cachereg,x0`) | −8,969 `mov` | shared¹ |
-| **L4-P2** | copy source materialized straight into the destination register (was scratch-load + `mov`) | −16,151 | shared |
-| **L3** | elide the zero-extend after a narrow zero-extending load (`uxtb`/`uxth`/`ubfx`) | −3,587 | shared |
-
-**Lever 2 — spill reduction (§6), landed as one commit:** −24,581 (−6.17%).
-
-| sub-change | what | aa64 insns | where |
-|---|---|--:|---|
-| **2a** | eager dead-operand drop: `api_op_kill_if_dead` sets `OPK_FLAG_KILL` on dead-transient binop/cmp/unop/store operands; `nd_drop_killed_operand` drops them after the op with no write-back (so they are not flush-stored at the next barrier) | −2,533 | shared |
-| **2a-branch** | `nd_cmp_branch` materializes the compare operands **before** the flush (a cached operand is read from its register instead of spilled-then-reloaded; a dead one is dropped), and `control.c` flags dead branch operands | −21,987 (mostly `ldur`/`stur`) | shared |
-| **2c** | skip the value-cache flush at pure memory barriers (volatile/atomic/fence) — the cache holds only non-escaped locals, which they cannot alias | ~0 on sqlite | shared |
-
-**Single-pass follow-ons (§4.3/§4.4/§4.6), three of four landed:** −2,315 combined.
-
-| lever | what | aa64 insns | where |
-|---|---|--:|---|
-| **§4.6** | switch-selector residency (materialize the selector once, reuse across the cmp chain instead of reloading per case) + indirect-branch reorder | −1,293 (`ldur`) | shared |
-| **Lever 4** | signed load-with-extend: a frontend widening signed load (`MF_SEXT_LOAD` rider + `ndt_load_sext` capability, aa64 only) emits `ldrsb`/`ldrsh` so the narrow load+`sxt` collapse to one insn | −958 (`sxtb`/`sxth`) | aa64¹ |
-| **Lever 3** | byte/half far frame slots positive-scaled off sp (extends L1) with a 2-word overflow fallback for offsets past the scaled byte/half reach | −192 net | aa64 |
-| ~~§4.7~~ | ~~in-place binop/cmp into a killed operand's register~~ — **built, measured −21, dropped**: the cg waist (`api_ensure_local`) already reuses an owned operand as the binop/cmp destination, so the NDT-level rename is redundant | — | — |
-
-¹ Lever 4 is capability-gated (`NativeRegInfo.ndt_load_sext`): **on** for aa64
-(`ldrsb`/`ldrsh` sign-extend), **off** for x86-64/rv64 (the frontend flag is a
-no-op; the backend emits a plain zero-extending load and the `CV_SEXT` convert
-runs normally). **Lever 3/4 interaction (caught at integration):** Lever 3's
-far-slot fast path emits a plain zero-extending `ldr`, so a *far signed* narrow
-load must skip it (the `!sext_far` guard) and take the general `ldrsb`/`ldrsh`
-path — else it would zero-extend while the cg layer dropped the `CV_SEXT`. A
-dedicated far-signed-slot differential probe guards this.
-
-¹ L4-P1 is gated to a new `NativeRegInfo.ndt_result_reg_stable` capability —
-**on** for aa64 (x0 is clobbered only at calls) and sound for rv64 (a0), **off**
-for x86-64 (RAX is an implicit div/mul operand: a result left there is clobbered
-before its consumer; the p1-x64 probe catches it).
-
-**Cross-arch:** the three shared-NDT levers (L4-P1/P2, L3) apply to every backend
-for free — **x86-64 sqlite `.text` −3.18%**, rv64 likewise (smoke-green). L1/L2
-are aa64-specific (the *concept* ports — positive-scaled / fold the epilogue —
-the *mechanism* is per-arch).
-
-**Lever 1 — args materialized into the ABI arg registers (§4.1), landed.** The
-former #1 open lever. Two halves, both required:
-
-| sub-change | what | where |
-|---|---|---|
-| **pool fronting** | front the ABI arg/ret registers in each backend's -O0 value-cache pool so `nd_cache_alloc` (in-order scan) prefers them — arg producers land directly in the arg regs (tcc's `get_reg(0..N)`). aa64 `aa_int_allocable` x0..x7 + `aa_fp_allocable` v0..v7 (+ dropped the double-listed x11 scratch from the int pool); x64 `x64_fp_allocable` xmm0..3 (int rsi/rdi already fronted; rax/rdx/rcx stay excluded — implicit operands); rv64/rv32 already pool int a0..a7 and the rv leg fronts fp fa0..7 **and** flips `ndt_result_reg_stable` (the result stays in a0/fa0 across the next ops — sound since rv div/rem/mul are R-type; this is Lever 5) | per-arch |
-| **forward-order arg materialization** | `api_pack_call_args_in_order` (`src/cg/call.c`) reverses the on-stack arg order so the LIFO pop materializes **arg0-first**, matching tcc's eager left-to-right emission. Without it, kit's lazy right-to-left materialization landed args in the *reversed* arg registers and the parallel-copy had to permute them (a 4-computed-arg call went 4→6 movs); with it the shuffle is a no-op (→0). Preserves `api_temp_dead` semantics (not-yet-materialized args stay on the stack, so a temp shared between args is coalesced at its last use) | shared |
-
-Result (sqlite, aa64): `.text` −3.72% / −13,830 insns, `mov` −12,390 (−25.5%);
-1.084×→1.044× tcc. **Cross-arch** (shared forward-materialization + per-arch pool
-fronting): x64 fp-heavy source −13.8% `.text` (movsd −14%); rv64 call-heavy corpus
-−4.9% `.text` (fp moves −43%, int moves −12%). All gated per-arch: x86_64-macos
-run vs clang, rv64 run on real riscv64 via qemu-user (both byte-identical /
-exit-0). The §4.1 frontend "destination hint" plan was **dropped** — pool
-front-loading + forward production gets the win with no frontend/CG-API plumbing.
-
-**Part B — `nd_gv` read-side unification (clean redesign).** Collapsed
-`nd_materialize_operand` + `nd_materialize_operand_into` into one
-`nd_gv(d, op, want)` primitive — kit's analog of tcc's `gv(rc)` ("generalize a
-value into a register; no code if already there"). `want == NULL` is the
-generic-class case, `want != NULL` targets a specific register. Pure refactor
-(byte-identical sqlite object). The two old names remain as thin wrappers.
-
-Prior partial work this built on: value-stack residency, lazy transient homes,
-lazy-dup + copy/convert coalescing (`[[o0-value-stack-residency]]`,
-`[[o0-lazy-transient-homes]]`, `[[o0-copy-convert-coalescing]]`), which took the
-ratio 3.45×→1.33× before this campaign.
-
----
-
-## 3. Diagnosis — where the remaining 31,117 excess lives
-
-Per-mnemonic signed diff (kit − tcc) on the current `.text` (post-Lever-2):
-
-| mnemonic | kit | tcc | **excess** | what it is |
-|---|--:|--:|--:|---|
-| `mov` | 48,578 | 3,701 | **+44,877** | reg-reg copies — **now #1 by far**: arg setup into x0–x7 (~30 K) + residual materialize/value-stack copies. Untouched by Lever 2 (spill-focused); this is **Lever 1** (§4.1) |
-| `ldur` | 62,851 | 37,994 | **+24,857** | frame reloads — register pressure (was +33,940; Lever 2's branch reorder cut ~9 K) |
-| `stur` | 39,867 | 15,899 | **+23,968** | frame spills (was +33,014; Lever 2's dead-operand drop cut ~9 K) |
-| `str` | 20,123 | 8,211 | **+11,912** | "" (more spills; was +14,457) |
-| `cbz` + `b.eq`/`b.ne`/… | — | — | (not excess) | kit fuses `if(x)`/compares into `cbz`/`b.cc` (tcc materializes a bool then tests) — *fewer* total insns; ignore the per-mnemonic split |
-| `sub` | 11,116 | 6,713 | +4,403 | residual `sub xN,x29` (byte/half far slots + &local) — **Lever 3** |
-| `movk` | 2,365 | 357 | +2,008 | far-offset builds in the residual `sub` paths — **Lever 3** |
-| `sturb`+`sturh` | 2,990 | 738 | +2,252 | narrow-type frame spills — register pressure on byte/half locals |
-| `sxtb`+`sxth` | 1,341 | 99 | +1,242 | sign-extend after a narrow load — **Lever 4** (L3 did the unsigned half only) |
-
-Where **kit already beats tcc** (do not touch — structural wins):
-
-| mnemonic | excess | note |
-|---|--:|---|
-| `add` | −21,709 | kit folds offsets into the displacement |
-| `ldr` | −19,360 | far slots now fold into `[sp,#scaled]`; tcc materializes more addresses |
-| `cset` | −17,226 | tcc lowers every compare to a materialized bool |
-| `cbnz` | −10,817 | "" |
-| `cmp` | −9,421 | kit fuses compare into the branch |
-| **`movn`** | **−6,720** | **far-local addressing: kit now emits 0 (positive-scaled), tcc still builds the offset** |
-| `nop` | −5,852 | kit's prologue pad is smaller |
-| `stp` | −3,722 | tcc pairs more aggressively |
-
-**fp-vs-sp is resolved.** Both compilers home locals at **fp-relative** offsets
-(`[x29]`: kit 124 K mem-ops, tcc 87 K — tcc is *not* sp-relative). The old gap
-was how each reached a *far* local (past `stur`'s ±256): kit built the address
-(`sub xN,x29,#off; ldur`), tcc used register-indexed `movn x30,#off; ldr
-[x29,x30]`. L1 moved far slots to **positive-scaled off sp** (`ldr/str [sp,#+ofs]`,
-one insn, no address build) — so kit now does far addressing *better* than tcc
-(`movn` 0 vs 6,720; 28 K `[sp]` accesses are the migrated slots).
-
-**The remaining gap split, post-Lever-2.** Lever 2 closed most of the *barrier*
-spill/reload excess (stores 72 K→63 K, loads 102 K→90 K; the `nd_cmp_branch`
-reorder alone removed ~9 K reloads by not spilling-then-reloading every compare
-operand). What is left is two distinct frontiers:
-
-1. **`mov` (+44,877) — now #1 by a wide margin.** This is `mov`-into-place, not
- spilling: ~30 K is arg setup into x0–x7, the rest residual value-stack copies.
- Lever 2 (spill-focused) does not touch it; it is **Lever 1** (§4.1,
- destination-hint placement).
-2. **Residual spills (`stur`+24 K, `str`+12 K, `ldur`+25 K).** kit still spills
- more than tcc's value stack keeps resident across *statements* and across
- *control-flow joins*. Closing that is register allocation at joins (`§4.2`
- note 2b), explicitly **out of single-pass -O0 scope** — the dead-operand part
- that *was* in scope is now landed.
-
-**Concentration.** The excess still clusters in the giant functions
-(`_sqlite3VdbeExec` etc.); per-function spill counts dominate there.
-
----
-
-## 4. Remaining levers (ranked, post-Lever-2)
-
-| # | Lever | Saving | Risk | §| status |
-|---|---|--:|---|---|---|
-| **1** | Args materialized into arg regs | −13,830 insns / −3.72% `.text` | — | §4.1 | **LANDED** |
-| **3** | byte/half far slots → positive-scaled (extend L1) | −192 net (nop tax) | med | §4.3 | LANDED |
-| **6** | extend the §6 reorder to other flush-first barriers (switch-selector residency, indirect-branch) | −1,293 | med | §4.6 | LANDED |
-| **4** | Signed load-with-extend (`ldrsb`/`ldrsh`) | −958 | med | §4.4 | LANDED |
-| **7** | in-place binop/cmp into a killed operand's register | −21 | low | §4.7 | dropped (cg-waist redundant) |
-| **5** | rv64/rv32 L4-P1 (flip `ndt_result_reg_stable`) | rv only | low (on-target check) | §4.5 | folded into Lever 1 (rv leg) |
-
-After Lever 1, the in-scope single-pass levers are all landed or dropped. The
-remaining `.text` gap to tcc (+14,972 insns, 1.044×) is dominated by residual
-arg/value `mov`s that forward-order production does not place (arguments that are
-nested-call results, or spilled under register pressure) — capturing those needs
-the per-arg destination hint (§4.1) or register allocation, both out of the
-simple single-pass scope.
-
-**Lever 2 (§4.2 / §6) is landed** — the in-scope, no-new-analysis part of "reduce
-frame spilling." The deep residency reach (keep a *live* value resident across a
-control-flow join) is register allocation and stays out of single-pass -O0 scope
-(§6.4 2b). **Lever 1 is now the single biggest remaining win** (`mov` +44,877) and,
-like Lever 2, reduces to **demand-driven value placement** — keep a value where
-its consumer wants it, materialized at the point of use, instead of producing into
-a cache-allocator register and relocating. tcc gets this from its lazy
-`SValue`/`gv(rc)` model (`tcc.h:479`, `tccgen.c:1844`); kit's NDT cache is already
-a lazy-location model but is missing the destination-hint handoff (§4.1). The
-landed dead-operand drop is the *foundation* for it: a killed operand's freed
-register is the natural destination for the next producer (§6.5). Re-profile after
-each landing — the highest lever moves as work is removed.
-
-### 4.1 Lever 1 — args into arg-regs (LANDED)
-
-The `mov` excess was dominated by **arg setup**: `mov xK, reg` moving an argument
-from a general cache register into x0–x7 at the call, because aa64 *excluded* the
-arg/ret registers from the -O0 value-cache pool. tcc emits ~13× fewer `mov`s
-because its `get_reg` scans `reg_classes[]` from register 0 — the arg/ret
-registers are in the pool and **preferred first**, so arg expressions compute
-straight into x0,x1,… and are already in place at the call.
-
-**What landed (two halves, both required):**
-
-1. **Pool fronting.** Add the ABI arg/ret registers to the front of each
- backend's `allocable[]` (the array read *only* by the NDT — the -O1 RA uses
- the phys-table `NATIVE_REG_ALLOCABLE` flags, so -O0 and -O1 are isolated).
- `nd_cache_alloc` scans the pool in order, so producers prefer the arg regs.
-2. **Forward-order arg materialization** (`api_pack_call_args_in_order`,
- `src/cg/call.c`). Pool-fronting *alone* slightly regressed: kit materializes
- computed args lazily, **right-to-left** (the LIFO pack pop), so they landed in
- the *reversed* arg registers and `native_arg_shuffle` had to permute them
- (`f(a,b,c)`→ arg cycles; a 4-computed-arg call went 4→6 movs). Reversing the
- on-stack arg order makes the pop materialize **arg0-first**, matching tcc's
- eager left-to-right emission, so each arg lands in its own slot and the shuffle
- is a no-op (→0). It preserves `api_temp_dead` exactly: the not-yet-materialized
- args stay on the value stack (which `api_temp_scan_refs` walks as ground
- truth), so a temp shared between args (`f(t*2,t*3)`) is coalesced at its last
- use, never killed early.
-
-**The §4.1 frontend "destination hint" was investigated and dropped.** Measuring
-pool-fronting alone proved it does *not* subsume the hint (it traded
-load-from-home for permute-moves; arg-reg-targeting movs rose 30,774→38,299).
-Forward-order production — not a hint — is what aligns args with their slots, with
-zero frontend/CG-API plumbing. A per-arg hint would still help the *residual*
-cases forward order cannot place (a nested-call result already in x0; an arg
-spilled under pressure), but that is a separate future lever.
-
-Result: aa64 `.text` −3.72% / −13,830 insns, `mov` −25.5%. Cross-arch via the
-shared forward-materialization + per-arch pool fronting (aa64 x0-7/v0-7; x64
-xmm0-3, rsi/rdi already fronted; rv a0-7 already pooled + fa0-7 fronted). Gated:
-determinism, test-toy/cg-api/opt/smoke-x64/smoke-rv64/parse/dwarf/debug, sqlite
-e2e at -O0 and -O1 vs clang, and shared-operand/nested-call/eval-order functional
-checks vs clang.
-
-**Part B — `nd_gv`.** This lever also unified the -O0 read-side
-(`nd_materialize_operand` + `…_into`) into one `nd_gv(d, op, want)` primitive, the
-`gv(rc)` analog (`want == NULL` = any register, else a specific one). Pure
-refactor, byte-identical.
-
-### 4.2 Lever 2 — reduce frame spilling (LANDED, §6)
-
-The in-scope part of this lever is **landed** (§2, §6): eager dead-operand drop +
-the `nd_cmp_branch` materialize-before-flush reorder + 2c. Result −24,581 insns;
-stores 72 K→63 K, loads 102 K→90 K. The deep residency reach (keep a *live* value
-in a register across a control-flow join) is register allocation and stays out of
-single-pass -O0 scope (§6.4 2b) — pursue it only as a separate -O1-style pass, not
-within the NDT. §6 remains the code-traced record of the landed change.
-
-### 4.3 Lever 3 — byte/half far slots (extend L1) — LANDED (−192 net)
-
-Landed (§2). Net is modest: the single-pass deferred patch must reserve a 2nd
-word (a `nop`) for every byte/half far slot since the final frame offset is
-unknown at emit time, and only the ~51 sqlite slots that actually overflow the
-4 KB/8 KB scaled reach truly win (`sub −1,213`/`movk −144` vs `nop +1,210`).
-Correct (overflow fallback `add x17,base,#hi; ldrb [x17,#lo]`), but close to
-break-even — the nop-reservation tax is inherent to single-pass. Original plan:
-
-L1 moved only **4/8-byte** far slots to positive-scaled `[sp,#ofs]`. **1/2-byte**
-far slots (~1,940 `sub xN,x29` + `movk`, see §3) still build the address, because
-scaled byte/half reach (4 KB/8 KB) is below the largest frame (`VdbeExec`
-6,544 B) so a one-word patch is not *guaranteed* to fit. Extend the
-`AA_PATCH_SLOT` resolver to byte/half with an **overflow fallback** (when the
-scaled offset exceeds imm12 for that size, keep the `sub`/indexed form) and lift
-the `sz ∈ {2,3}` gate in `aa_emit_mem`. Self-contained aa64 change; the fallback
-is the only new code. (Also in this bucket but low-value: the ~4 K
-address-of-local `sub xN,x29` from `aa_load_addr`, which would become `add
-xN,sp,#ofs` — count-neutral for the common ≤4095 frame, only saving the `movk`
-in big frames.)
-
-### 4.4 Lever 4 — signed load-with-extend — LANDED (−958)
-
-Landed (§2) exactly as planned below: `MF_SEXT_LOAD` rider + `ndt_load_sext`
-capability (aa64 only). See §2 for the Lever 3/4 interaction guard. Original plan:
-
-A signed narrow load is still `ldrb/ldrh ; sxtb/sxth` (two insns) where `ldrsb`/
-`ldrsh` would do one. L3 captured only the *unsigned* half: a zero-extending load
-already fills the register, so the following `uxtb`/`uxth` is provably redundant
-and is elided. The signed case can't be done the same way — **kit's CG integer
-types are sign-agnostic** (signedness lives in the convert op, not the type), so
-`nd_load` can't know the value feeds a sign-extend and must emit the
-zero-extending `ldrb`. The fix is a **frontend widening signed load**: emit the
-load at the promoted width with a signedness rider on `MemAccess`, so `aa_emit_mem`
-picks `ldrsb`/`ldrsh` and no separate convert is generated. Frontend + CG-API +
-encoder; ~1,242 insns.
-
-### 4.5 Lever 5 — rv64/rv32 L4-P1 — LANDED (with Lever 1's rv leg)
-
-`ndt_result_reg_stable` is now flipped on for riscv (rv64 + rv32). riscv's `a0`
-is clobbered only at call boundaries (div/rem/mul are R-type with explicit
-operands), so result-in-result-register caching is sound — verified by an
-**on-target run** of a result-stable-then-division probe (`r = foo(a); return
-r / bar(b);`) on real riscv64 via qemu-user (the rv64 smoke substrate; `kit emu
--arch riscv64` is a separate pre-existing translator hang, not codegen). Landed
-alongside the rv fp-arg pool fronting in the Lever 1 rv leg.
-
-### 4.6 Lever 6 — extend the §6 reorder to the other flush-first barriers — LANDED (−1,293)
-
-Both parts landed (§2): `nd_switch` now emits the cmp chain itself, materializing
-the selector once into a pinned register before the flush and reusing it per case
-(the entire −1,293 is the eliminated per-case `ldur` reloads); `nd_indirect_branch`
-got the same materialize-before-flush reorder (near-zero on sqlite). Original plan:
-
-§6's `nd_cmp_branch` win was *materialize-before-flush* (read the operand from its
-register instead of spill-then-reload). The same pattern is still un-applied at the
-other ops that flush *before* reading an operand, all single-pass-local:
-- **switch selector residency.** `nd_switch` flushes, then `cg_lower_switch_default`
- emits one `cmp_branch` per case — each reloads the selector (the cache was just
- emptied). Pin the selector across the case chain (or don't flush at `nd_switch`
- and let it stay cached) so it is read once. Helps the big `VdbeExec`-style
- dispatch chains; bounded by the chain-vs-jumptable threshold in `cg_plan_switch`.
-- **indirect branch** (`nd_indirect_branch`, computed goto) flushes then
- materializes the target address — same reorder applies. Rare; low value.
-
-### 4.7 Lever 7 — in-place binop/cmp into a killed operand's register — DROPPED (redundant)
-
-Built and measured (−21 insns), then **dropped**: the cg waist already does this
-in-place coalescing one level up. `api_ensure_local` (`src/cg/value.c`) reuses an
-*owned* binop/cmp operand as the destination directly (so `dst == a` and no NDT
-rename is needed), and `api_op_kill_if_dead` deliberately does not flag an operand
-that equals the destination — so the NDT-level rename only fires for the residual
-width-mismatch cases (−21 insns), not worth the code. Useful finding: binop/cmp
-in-place is already captured upstream; the NDT rename adds nothing material.
-Original idea:
-
-§6's dead-operand drop *frees* a killed operand's register after the op; the
-natural next step is to **reuse that register as the op's destination** in the same
-instruction — the binop/cmp analog of `nd_rename_killed_to_dst` (already done for
-copy/convert). aa64 binops are 3-register (`add dst,a,b`), so when operand `a`
-carries `OPK_FLAG_KILL` and `dst` is a fresh cacheable transient, rename `a`'s reg
-to `dst` instead of allocating a fresh `dst` reg — avoiding the `nd_dst_reg`
-allocation (and its eviction spill under pressure). Soundness mirrors the existing
-rename (the op reads both operands before writing `dst`, so a self-referential
-`dst==a` is fine). Composes with Lever 1's destination hint.
-
----
-
-## 5. Gate & measurement methodology
-
-- **Measure on RELEASE** (`make bin RELEASE=1`); the ASan default inverts costs.
-- **Size metric = `.text` machine code**, not object bytes (format-skewed). Use
- `kit size` / the §1 histogram diff. The instruction metric is deterministic on
- a fixed binary — small deltas are real.
-- **Codesize changes alter emitted bytes**, so the gate is **run-correctness +
- determinism** (compile sqlite twice + `cmp` for self-identity; `make test-toy
- test-parse-ok test-parse-err test-smoke-x64 test-smoke-rv64 test-dwarf
- test-debug`; the sqlite ecosystem e2e at O0+O1, golden + vs-clang; and
- clang-differential probes), *not* the byte-identity gate used for
- compile-speed-only refactors.
-- **Verify every shared-NDT change on x64 and rv64, not just aa64** — the L4-P1
- x86-64 RAX miscompile (now gated) is the cautionary tale: a shared change can
- be a win on the reference arch and a silent miscompile elsewhere. Keep a
- clang-differential probe per arch for any register-residency change.
-- Re-profile after each landing and re-rank §4 — the highest lever moves as work
- is removed.
-
----
-
-## 6. Lever 2 in depth — spill reduction via eager dead-operand drop (LANDED)
-
-**Status: landed** (§2; commit on `main`). 2a + the `nd_cmp_branch`
-materialize-before-flush reorder + branch-operand kill + 2c, −24,581 insns
-(−6.17%), gated (§5) on aa64/x64/rv64. The branch reorder — *not* in the original
-plan below — turned out to be the bulk of the win (the flush-first `nd_cmp_branch`
-was spilling-then-reloading every compare operand; ~21 K of the 24.6 K). The
-sections below are the code-traced design that led there, kept as the record.
-
-A code-traced plan for §4.2's "reduce frame spilling" lever, scoped to the part
-that needs **no new dataflow analysis** because the liveness it relies on is
-already computed today. All line numbers are from `src/cg/native_direct_target.c`
-(the NDT) unless noted; **re-verify them before editing — the file moves.**
-
-### 6.1 The cache model, as it actually is
-
-The authoritative summary is the comment at `native_direct_target.c:567–576`, and
-the code matches it:
-
-- **Only scalar, non-address-taken locals are cached**, in caller-saved registers
- (`reg_owner[cls][reg]` → `CGLocal`; per-local state in
- `NativeDirectLocal.{reg,dirty,transient}`, `native_direct_target.h:30–59`). The
- caller-saved-only pool (`cache_pool`, `.h:132–145`) is why values live across a
- call must reach memory on aa64 — those registers are clobbered.
-- **Every cache entry is a dirty compute result.** The only sites that set
- `dirty = 1` are `nd_dst_writeback:1047` (binop/unop/cmp/convert destination),
- `nd_rename_killed_to_dst:1096`, and the call-result cache (`:2014`/`:2082`). A
- plain *read* never creates an entry (`:570`). So at any flush, **100% of cached
- entries are dirty and every flush stores them all** (`nd_flush_local:695` stores
- iff dirty — and they always are).
-- **Cache lifetime = one straight-line run of compute ops.** `nd_flush_all:723`
- empties it at ~20 barriers: control-flow joins (`nd_label_place:1282`,
- `nd_jump:1289`, `nd_cmp_branch:1298`, `nd_switch:1308`,
- `nd_indirect_branch:1317`), calls (`nd_flush_all_except_kept_args` at
- `nd_call`, `:1992`), and memory/other barriers (volatile `:1576`/`:1633`, atomics
- `:2205…:2279`, asm `:2289`, va/alloca/ret).
-- **Drop-without-store already exists.** `nd_invalidate_local:711` drops an entry
- with no write-back; `nd_drop_all:732` does it for the whole cache at `ret`
- (frame about to die); `nd_reclaim_temps:1191` drops dead transients at the
- *statement boundary* (`:1197`, no store) and recycles their lazy homes
- (`nd_home:249`, lazy-home gate in `nd_alloc_local:326`).
-
-### 6.2 Root cause of the 3× spill (code-traced)
-
-Because every cache entry is a dirty compute result and a flush stores them all:
-**a compute result whose value dies before the next barrier is still stored at
-that barrier.** Two excess sources:
-
-1. **Dead transients flush-stored at *mid-statement* barriers.** `t = b*c; …
- f(t) …`: `t` is a dirty entry, is consumed, becomes dead, but the call inside
- the expression flushes it to a (lazily-minted) home. tcc keeps `t` in a
- register and never stores it. In a *call-free* expression there is **no**
- excess — `nd_reclaim_temps` drops dead transients at statement end with no
- store. The excess is specifically expressions containing **calls / `&&` /
- `||` / `?:` / comma** — i.e. most of sqlite.
-2. **Eviction spills under pressure.** When `cache_pool` fills, `nd_cache_alloc:636`
- → `nd_pick_cache_victim:613` → `nd_flush_local` **stores** the dirty victim.
- A dead transient still holding a register inflates pressure → more eviction
- stores.
-
-Named-local assignment stores are **not** excess — the cache merely defers the
-one store tcc also does. The waste is entirely **dead transients lingering
-between their last use and statement-boundary reclaim / eviction.**
-
-### 6.3 The lever already exists — it is just under-deployed
-
-kit already computes exactly the needed liveness:
-
-- `api_temp_dead` (`src/cg/value.c:569`) — sound dead-transient predicate
- (refcount `local_refs` fast-reject + confirming stack scan; fields at
- `src/cg/internal.h:208–223`). Reseat gaps cost at most a missed optimization,
- never a miscompile (by construction).
-- `OPK_FLAG_KILL` (`src/cg/cgir.h:216`) — the operand-level "dead last use" bit.
-
-But `OPK_FLAG_KILL` is **set in only two producers** — `src/cg/memory.c:534`
-(copy source) and `src/cg/arith.c:338` (convert source) — and **consumed in only
-one place**, `nd_rename_killed_to_dst:1075` (used at copy `:1539`, convert
-`:1922`). Every other consumer ignores deadness: `nd_binop:1791` materializes its
-operands, computes, then `nd_release_materialized:942` — which only *unpins
-scratch*, never drops a dead operand's cache entry (`:1827–1828`). So a dead
-transient binop/cmp/store/call-arg operand survives to be flush-stored.
-
-### 6.4 The change — 2a (primary) + 2c (secondary)
-
-**2a — eager dead-operand drop. The core lever.**
-- *Producer (CG layer):* generalize the `memory.c:534` / `arith.c:338` pattern —
- set `OPK_FLAG_KILL` on any operand `api_temp_dead` confirms is a dead transient,
- at the remaining op emitters: binop/cmp operands (`src/cg/arith.c`), store value
- (`src/cg/memory.c`), and call args (`src/cg/call.c`). A doubly-used temp (`a*a`,
- `f(t,t)`) must **not** be killed on the non-final use — `api_temp_dead`'s
- refcount already guards this; verify per call site.
-- *Consumer (NDT):* after an op materializes-and-consumes an operand carrying
- `OPK_FLAG_KILL`, `nd_invalidate_local` it (drop, **no store**) instead of
- leaving it cached. Apply in `nd_binop` (after the op, at `:1827–1828`), the
- cmp path, the store-value path, and the call-arg marshalling. The
- drop-without-store primitive and the soundness argument already exist
- (`nd_rename_killed_to_dst`'s comment, `:1059–1074`).
-- *Effect:* the dead transient is never flush-stored (removes excess `stur`/`str`)
- **and** its register frees immediately (lower pressure → fewer eviction spills,
- hence fewer `ldur` reloads too).
-
-**2c — don't flush the cache at pure memory barriers. Secondary, sound, smaller.**
-Because **only non-address-taken locals are cached** (`:568`), no pointer can
-alias a cached value, so a `volatile`/`atomic`/`fence` barrier (`:1576`, `:1633`,
-`:2205…:2279`) cannot touch one — yet each currently `nd_flush_all`s. Those
-flushes are unnecessary: keep the cache across pure memory barriers. **Still
-flush at calls** (clobber caller-saved regs) **and at inline-asm with clobbers**
-(`:2289`). Rarer ops, so a smaller win, but free and correct.
-
-**2b — explicitly out of -O0 scope.** Keeping a *live* value in a register across
-a control-flow join (label/branch merge) needs the same register on every
-incoming edge = register allocation, which -O0 does not do; and live values
-across a call must spill on aa64's caller-saved-only pool (tcc spills there too).
-So the residency reach reduces to "make sure dead transients are gone *before* the
-barrier," which 2a delivers. The existing `nd_flush_all_except_kept_args:752` /
-`nd_call_arg_kept:740` is the call-site special case (it keeps only this call's
-*dead args* resident to feed marshalling); 2a generalizes "dead ⇒ don't spill" to
-all dead transients at all barriers.
-
-### 6.5 Why 2a is independent of Lever 1 (§4.1)
-
-Lever 1 (args into arg-regs) reduces **`mov`s** via a new *destination-hint*
-surface (frontend → CG-API → backend). 2a reduces **spills** by acting on the
-*existing* `OPK_FLAG_KILL` surface; it needs no destination hint and no new
-analysis, so it can land first and independently. (The two compose: once Lever 1
-exists, a killed operand's freed register is a natural destination for the next
-producer — but that is additive, not a prerequisite.)
-
-### 6.6 Gate & risk
-
-- **Not byte-identical** — it removes stores and frees registers, so allocation
- shifts (deterministically). Gate = **run-correctness + determinism +
- clang-differential**, the §5 codesize-track gate, **on aa64, x64, and rv64**.
-- **Correctness rests entirely on `api_temp_dead`** being a true last-use; that
- is already the proven predicate. Targeted differential probes to add:
- dead-operand reuse, doubly-used temps (`a*a`, `f(t,t)`), a transient live across
- a nested call (`f(t) + g(t*2)`), and a killed operand that is also the
- destination's address base.
-- **Cautionary precedent:** L4-P1's x86-64 RAX miscompile (now gated by
- `ndt_result_reg_stable`, §2 footnote) — a shared-NDT change can be a win on aa64
- and a silent miscompile elsewhere. 2a has no implicit-register trap, but run the
- per-arch differential probes regardless.
-
-### 6.7 Implementation checklist (for the picking-up agent)
-
-1. Re-verify §6.1/§6.3 line anchors against current source (the file moves).
-2. Snapshot a golden + a baseline `.text` histogram (§1 reproduce block) and a
- Linux-callgrind `Ir` baseline (`scripts/perf_callgrind.sh run pre2a`).
-3. **Consumer first, behind a temporary always-false guard** is not possible
- (KILL isn't set broadly yet); instead do producer+consumer for **one op
- (binop)** end-to-end, gate, measure — prove the loop before fanning out.
-4. Extend the producer (`OPK_FLAG_KILL` from `api_temp_dead`) to cmp, store-value,
- call-arg; extend the consumer drop to each; gate after each op class.
-5. Land 2c (skip flush at volatile/atomic/fence; keep at call/asm-clobber)
- separately — it is orthogonal and individually gateable.
-6. Re-profile: report the `.text` `stur`/`str`/`ldur` deltas (codesize) **and** the
- compile-`Ir` delta (both should improve — fewer stores to emit). Re-rank §4.
diff --git a/doc/plan/PERF-TCC-GAP.md b/doc/plan/PERF-TCC-GAP.md
@@ -1,247 +0,0 @@
-# Closing the tcc gap — a structural map
-
-Companion to `PERF.md`. That doc tracks the running standings and the
-constant-factor sprints; this one is the **structural diagnosis**: where the
-2.22 B vs 0.66 B (3.35×) instruction gap to tcc actually lives, *why* (grounded
-in code paths), and the redesign tracks that can close it. Written after a
-measured phase decomposition + a four-strand code-path audit (kit token
-pipeline, kit codegen, tcc baseline, kit memory density).
-
-## The headline finding: it is instructions, not cache
-
-Measured on the sqlite amalgamation (`sqlite3.c`, arm64 macOS, best-of-7,
-`/usr/bin/time -l`), low load:
-
-| compiler | instructions | cycles | **IPC** | peak RSS |
-|---|--:|--:|--:|--:|
-| kit `-c` | 2.216 B | 0.598 B | **3.70** | 70.6 MB |
-| tcc `-c` | 0.663 B | 0.190 B | **3.48** | 13.7 MB |
-
-**kit's IPC (3.70) is *higher* than tcc's (3.48).** Despite 5× the RSS and
-pointer-chasing data structures, kit is **not** cache/stall-bound relative to
-tcc — both are compute-bound on an 8-wide core. The gap is **purely instruction
-count**: kit executes 3.35× more instructions and retires them slightly *more*
-efficiently. The "denser, cache-friendlier design" intuition is the right
-*direction*, but the payoff mechanism is **fewer instructions** (fewer copies,
-fewer indirect calls, fewer redundant recomputations) — not recovering memory
-stalls. There is no large hidden cache-miss penalty to claw back; do not chase
-that ghost. Smaller/denser structures help because they cost fewer
-load/store/move *instructions*, and they keep IPC high.
-
-## The phase decomposition (instruction-grounded)
-
-The wall-clock `sample` profile in PERF.md is **single-peaked on `lex_next`
-(62 %)** — but that is a *wall-clock* artifact of one huge inlined leaf function
-on a single-peaked workload. The **instruction** truth (load-independent, the
-metric to trust) is very different and is derived by subtraction:
-
-- `emit+objwrite = (-c) − (-fsyntax-only)` = 2.216 − 1.846 = **0.372 B** (exact).
-- Compile the *already-preprocessed* source as a `.c` (re-preprocesses only
- trivial `# line` markers, so PP work ≈ 0; kit produces the **same 3.7 MB
- object**, `rc=0`): `-c`(exp) 1.589 B, `-fsyntax`(exp) 1.217 B, `-E`(exp)
- 0.343 B. So `parse+sema+types+CG-drive = -fsyntax(exp) − -E(exp)` ≈ **0.874 B**
- and `emit+objwrite(exp) = 1.589 − 1.217 = 0.372 B` (matches — same program).
-- `lex+pp` = remainder = 2.216 − 0.372 − 0.874 = **~0.97 B** (≈ kit `-E` raw
- 0.974 B — independently consistent).
-
-| phase | kit `-c` instr | share | tcc (est. within 0.663 B) | ratio |
-|---|--:|--:|--:|--:|
-| **lex + pp** (scan, include, macro expand) | ~0.97 B | ~44 % | ~0.35 B | ~2.8× |
-| **parse + sema + types + CG value-stack drive** | ~0.87 B | ~39 % | ~0.25 B | ~3.5× |
-| **native emit + obj write** | 0.37 B | ~17 % | ~0.05 B | **~7×** |
-| **total** | **2.22 B** | 100 % | **0.66 B** | **3.35×** |
-
-(tcc's phases are fused; its per-phase figures are estimates from `tcc -E`
-1.152 B and its structure. The point is the *shape*, not the third digit.)
-
-**Two facts reframe the whole effort:**
-
-1. **The gap is roughly uniform (~3×) across all three phases** — there is no
- single hot function to crush. Closing 3.35× is a *campaign across the
- pipeline*, not one patch.
-2. **Post-PP work alone (≥ `-c` − `-E` = 1.24 B) is already ~1.87× tcc's entire
- compile.** Even a *free* lexer+pp leaves kit at ≥1.87× tcc. **The lexer is
- not the sole frontier** — the prior "scanner is THE lever" framing (true for
- wall-clock self-time, and the recent raw-cursor rewrite was a real −84 M win)
- does **not** close the tcc gap. The post-PP pipeline must be attacked too.
-
-## Why each phase is ~3× tcc (structural, file:line-grounded)
-
-### tcc's target model — one pass, no second representation
-
-tcc fuses lex+preprocess+parse+codegen into one recursive-descent pass with **no
-IR and no token objects** (`tmp/tinycc/tccpp.c`, `tccgen.c`, `arm64-gen.c`):
-
-- The current token is a **single global `int tok` + `CValue tokc`** (16 B
- union). `next()` returns `void` and mutates globals — no struct returned or
- copied per token. Identifiers are interned to small ints **while being
- scanned** (`hash_ident`, one inline hash fold), and the `TokenSym` caches
- `sym_define/_struct/_identifier/_label` pointers directly — so macro/symbol
- resolution is a **pointer load off the interned token**, not a per-occurrence
- hash lookup.
-- The value stack is a fixed `SValue _vstack[513]` (48 B entries); push is
- `vtop++` + four field writes, pop is `vtop--`. `gen_op('+')` constant-folds /
- strength-reduces *in place* and otherwise calls the backend `gen_opi`
- **directly** (a `ST_FUNC`, no vtable). The backend's `o()` writes one 4-byte
- instruction straight into `cur_text_section->data` — the final image.
-- Macro bodies are pre-tokenized `int[]` streams replayed by **pointer-swap**
- (`begin_macro`/`end_macro` push/pop `macro_ptr`), no per-expansion copy.
-
-Net: each source byte is touched ~once, each token is an `int`, each operator is
-a `vtop` adjustment plus ≤1 four-byte store. **There is no second copy of the
-program anywhere.**
-
-### kit lex+pp (~0.97 B, ~2.8×) — `lang/cpp/lex/lex.c`, `lang/cpp/pp/*`, `lang/c/parse/parse.c`
-
-- **24 B `Tok` returned by value across a 4-frame relay**: `lex_next` →
- `pp_next` (`pp.c:182`) → `fetch_tok` (`parse.c:165`) → `advance`
- (`parse.c:185`). The same Tok is **copied ~3 extra times** through frames that
- never mutate it (~72 B of redundant motion/token). *Caveat:* PERF.md records
- that collapsing these *pull wrappers* alone measured ~5 % (a near-dead-end) —
- the copies are real but a bounded constant factor; weight this below interning
- and Tok-size.
-- **Per-occurrence re-interning**: every content token re-runs FNV-1a + a pool
- probe (`pool_intern_slice`, `pool.c:120`), even the millionth `int`/`;`.
- Punctuators are cached (`punct_sym[]`); identifiers and numbers are **not**.
- tcc hashes once during the scan, then it's an int. **This is the single
- biggest lex+pp lever** (~14 % of total self-time).
-- **`Tok` is 24 B, half of it `SrcLoc {file,line,col}` (12 B)** copied through
- every layer (`lex.h:67`, `core.h:64`). Packing it to a 32-bit position (lazy
- decode on diagnostics) shrinks `Tok` to 16 B → 33 % less token-copy bandwidth.
-- **`TOK_NEWLINE` is materialized, copied up 3 layers, then dropped** in
- `pp_next` (`pp.c:194`) — a full Tok produced+copied+discarded per source line.
-- **Object-macro expansion double-copies the body** (`tmp[i]=m->body[i]` +
- `subst_phase2`, `pp_expand.c:374`) even with no `##`; function-macro args take
- 3–4 copies/token (`pp_expand.c:620`).
-- **Interner table/entry split**: the open-addressed table slot holds only a
- 4 B `Sym`; the `hash`+`len` guard lives in a *separate* `entries[]` array at a
- hash-scattered index, so every occupied probe slot pays a random
- `entries[sym]` line (`pool.c:140`). Dense at the table, pointer-chasing at the
- entry. (At IPC 3.7 this is extra *loads*, not stalls.)
-
-### kit parse+sema+types+CG-drive (~0.87 B, ~3.5×) — `src/cg/*`, `lang/c/parse/cg_adapter.c`
-
-At `-O0` **no IR is recorded** — the bare `NativeDirectTarget` is installed and
-`opt_cgtarget_new` is skipped (`src/cg/session.c:147`). So this 0.87 B is the
-value-stack + type machinery itself, run to drive emission:
-
-- **56 B `ApiSValue` per operand** (`internal.h:99`) carrying delayed-arith,
- bitfield (12 B), and source-local riders the common `a+b` never uses — vs
- tcc's 48 B register-resident `SValue`. Every push/pop moves 56 B.
-- **A fold / delay / strength-reduce / identity ladder runs on the hot path even
- at -O0** (`arith.c:48-99`), plus 4 type-class probes before any work in
- `kit_cg_int_binop` (`arith.c:1105`). -O0 wants none of this value-quality work.
-- **Type re-decoded ~8–12× per statement**, and `abi_cg_type_info` (size/align)
- is **not memoized** — a recursive `cg_type_get` + alias chase **every call**
- (`abi.c:90`), hit from `alloc_temp_local`, `nd_type_mem`, `class_for_type`, …
- Only the packed `api_type_class` *byte* is cached (`type.c:289`).
-- The CgTarget seam sits **above** all this (per PERF Round 7): even routed to
- the no-op check backend, the value-stack drive pays the full cost.
-
-### kit native emit + objwrite (0.37 B, ~7× — worst ratio) — `native_direct_target.c`, `src/arch/aa64/native.c`, `src/arch/mc.c`
-
-- **Three stacked vtable layers**: `CgTarget` → `NativeTarget` → `MCEmitter`,
- each an **indirect call per primitive**. `x = a + b;` costs ~7–9 indirect
- calls (`nd_binop` 1542 → `aa_binop` → `m_emit_bytes` 298, ×{add, store,
- operand materializations}) where tcc does a couple of *direct* `o()` byte
- appends. Three indirections where tcc has zero.
-- **The NDT runs an LRU register-cache** — `reg_owner`/`cache_head`/`touch_local`
- /`flush`/`writeback`/`pick_victim` (`native_direct_target.c:516-930`) — a
- *mini register allocator at -O0*, where tcc keeps top-of-stack in a fixed reg
- and spills on demand.
-- kit emits **~2× tcc's machine-code bytes** (unoptimized -O0 codegen: 4.26 MB
- vs 2.11 MB object) → ~2× the emit + objwrite volume. Track B
- (`o0-codesize-vs-tcc`) is already shrinking this (3.45×→2.42× text) and
- **directly reduces this phase**.
-
-## The roadmap — tracks to close the gap
-
-No single change closes 3.35×; this is a campaign. Ranked by
-instruction-payoff × structural leverage. Every track gates on
-`scripts/perf_identity_gate.sh` byte-identical output (or, where it changes
-codegen, run-correctness + determinism) and is measured on RELEASE best-of-7
-instructions.
-
-### Track 1 — Collapse the codegen vtable stack + the -O0 value machinery (biggest lever: ~0.37 B emit at 7× + much of the 0.87 B middle)
-
-The worst ratio and the densest cluster of redundant work. Structural moves:
-
-1. **Fuse `CgTarget`+`NativeTarget` into one direct -O0 emit path** — remove 2
- of 3 indirect calls per primitive and the `NativeLoc` re-marshalling between
- the layers (`native_direct_target.c:1542-1648` ↔ `aa64/native.c`). The
- CgTarget seam exists to share the frontend with the opt-IR / C-source / Wasm
- backends; at -O0 it is pure overhead. Consider a compile-time-selected direct
- emitter for the native -O0 path.
-2. **Memoize size/align/regclass on the type id** (extend the `api_type_class`
- memo, `type.c:289`) → kills the uncached recursive `abi_cg_type_info`
- (`abi.c:90`) and the per-operand `class_for_type` vtable hops.
-3. **Gate the fold/delay/strength-reduce ladder OFF at -O0** (`arith.c:48-99`,
- `1105-1114`) → binop becomes pop,pop,emit.
-4. **Replace the NDT LRU register-cache with a tcc-style fixed TOS-register
- discipline at -O0** (`native_direct_target.c:516-930`).
-5. **Shrink `ApiSValue` 56→~24 B** — move bitfield/delayed/source-local fully
- off-node (`internal.h:99`).
-6. **Couples with Track B** (`o0-codesize-vs-tcc`): fewer emitted bytes → less
- emit + objwrite + assembler work.
-
-### Track 2 — Densify the token relay (lex+pp, 0.97 B)
-
-1. **Per-occurrence intern cache / deferred intern** (highest lex+pp payoff,
- ~14 %): stop re-hashing hot identifiers. Cache the last-interned Sym by
- spelling start+len, or intern lazily only when the parser needs the Sym.
- (`lex.c` intern sites, `pool.c:120`.)
-2. **Pack `SrcLoc` → 32-bit position; `Tok` 24→16 B** — 33 % less token-copy
- bandwidth through PP/macro-expansion/parser; lazy file:line:col decode on the
- diagnostic path only (`lex.h:67`, `core.h:64`).
-3. **Collapse the 4-frame pull into one inlined `pp_pull` writing straight into
- `p->cur`** — eliminate ~3 Tok copies/token and fold newline filtering into the
- scanner so newlines never become parser-facing Toks. *(Bounded: pull-wrapper
- fusion alone measured ~5 % before — do it for the copy elimination, not as
- the main lever.)*
-
-### Track 3 — Interner self-sufficiency (part of lex+pp; ~14 %)
-
-Put `hash`+`len` (and an inline small-string prefix, SSO for ≤14 B identifiers)
-**in the probe slot** so a probe rejects without the random `entries[sym]` line
-(`pool.c:31-45,139`). Converts a ~2–3 cache-line intern into ~1–2 lines and
-removes the scattered-entry loads — fewer instructions and a smaller LLC
-footprint (shrinks the ~2–3 MB `entries[]`+arena).
-
-### Track 4 — The end-state bet: adopt tcc's single-pass data-flow shape
-
-Tracks 1–3 are the incremental path; the destination is tcc's *shape*: a shared
-mutable token slot fed by both the scanner and the macro replayer (no
-per-stage Tok structs), identifiers resolved through symbol pointers cached on
-the interned token, and a thin `SValue[]` value stack the parser drives by
-calling the backend's emit directly (no vtable), writing bytes into the final
-section image. This is the "clean structural redesign" the project prizes; it
-subsumes Tracks 1–3 and is the larger lift. Treat Tracks 1–3 as independently
-shippable, byte-identical-gated steps *toward* it.
-
-## What this corrects / supersedes
-
-- PERF.md's "the scanner is THE lever (58–62 %)" is **wall-clock**; the
- instruction-grounded gap is ~uniform across phases and **codegen-density is
- the highest-leverage single track**. The raw-cursor scanner rewrite was still
- a real −84 M win and `kit -E` now *beats* `tcc -E` — but it is not the path to
- tcc parity.
-- The "fused lex→pp→parse pull pipeline = dead end (~5 %)" note stands for the
- *pull wrappers*; it does **not** cover interning, Tok-size, or the codegen
- vtable stack, which are the actual levers.
-- This is an **instruction-reduction** program (kit IPC ≥ tcc IPC). Density is
- the means; fewer retired instructions is the metric.
-
-### Reproduce the decomposition
-
-```sh
-SDK=$(xcrun --sdk macosx --show-sdk-path); cd tmp/projects/sqlite-amalg
-K=build/release/kit
-# best-of-7 instr+cycles helper `mc` as in PERF.md, plus IPC
-$K cc -E sqlite3.c -o /tmp/e.c --sysroot "$SDK"; cp /tmp/e.c /tmp/exp.c # preprocessed -> .c
-mc $K cc -c sqlite3.c -o /tmp/k.o --sysroot "$SDK" # 2.216 B (full)
-mc $K cc -fsyntax-only sqlite3.c --sysroot "$SDK" # 1.846 B (− emit/objwrite)
-mc $K cc -c /tmp/exp.c -o /tmp/ke.o --sysroot "$SDK" # 1.589 B (no PP work)
-mc $K cc -fsyntax-only /tmp/exp.c --sysroot "$SDK" # 1.217 B
-mc $K cc -E /tmp/exp.c -o /dev/null --sysroot "$SDK" # 0.343 B (lex+trivpp+detok)
-mc tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o # 0.663 B (tcc, all phases)
-```
diff --git a/doc/plan/PERF-TCC-SLIM.md b/doc/plan/PERF-TCC-SLIM.md
@@ -1,467 +0,0 @@
-# Slimming the vtable stack to close the tcc gap — a redesign plan
-
-Companion to `PERF.md` (running standings) and `PERF-TCC-GAP.md` (structural
-diagnosis). Those establish *where* the 2.22 B vs 0.66 B (3.35×) instruction gap
-lives and *why*. This doc is the **redesign**: a concrete, ranked, dependency-ordered
-campaign to slim every layer of the pipeline so each does tcc-spare work per
-operation — **keeping the layered architecture, not deleting it.**
-
-The brief that scoped this plan: *keep the vtable stack, but slim it down
-significantly to match tcc's more spare construction; structural wins, 3× fewer
-instructions.* Written after a six-strand code-path audit (lex+pp+intern, parse+
-sema+cg_adapter, the CG value-stack/type machinery, NativeDirectTarget, the
-CgTarget seam, MCEmitter+objwrite).
-
----
-
-## 1. The shape we drive toward — keep the layers, remove the redundancy
-
-The architecture is the asset; the per-operation **work** is the liability. tcc is
-fast because each source byte is touched ~once, each token is an `int`, each
-operator is a `vtop` adjustment plus ≤1 four-byte store, and there is **no second
-copy of the program anywhere**. kit pays for pluggability (multiple backends share
-one frontend through the `CgTarget` seam) and for a typed value model — and those
-are worth keeping. What is *not* worth keeping is the redundant work piled on top:
-re-interning every token, re-decoding every type 8–12× per statement, copying a
-56 B value node and a 48 B location descriptor per operand, and crossing **three**
-indirect-call layers per emitted primitive where one would do.
-
-The single most important structural finding — and where an early framing had to
-be corrected: **not all three "layers" are real vtables.** Two are genuinely
-polymorphic and earn their indirection; one is monomorphic and the indirection is
-pure overhead.
-
-- **CgTarget** is polymorphic — `NativeDirectTarget`, the opt IR recorder,
- `c_target`, `wasm`, and `check` each install one (`NativeDirectTarget` *is-a*
- `CgTarget`, `native_direct_target.c:112`). One indirect call at the seam. **Keep.**
-- **NativeTarget** is polymorphic **per arch** — separate impls in
- `src/arch/{aa64,x64,riscv}/native.c` (+ `check_target.c`). NDT is the
- arch-*neutral* -O0 driver and holds `NativeTarget* native`
- (`native_direct_target.h:114`); `nd_binop → d->native->binop` is a **real arch
- dispatch**, not redundant. It is not removable without compile-time
- monomorphizing NDT per arch (a much larger change, out of scope). **Keep the
- vtable.** What *is* removable here is the **48 B `NativeLoc` marshalling
- round-trip** (NDT already resolved the operand to a `Reg`; it wraps it in a
- NativeLoc, the arch immediately unwraps it back to a register number) and the
- **number of crossings** per statement — copies and counts, **not** the indirect
- call.
-- **MCEmitter** is **monomorphic** — `mc.h:10` states it outright (*"One generic
- implementation in `src/arch/mc.c` serves every machine-code arch"*), and only
- `mc.c` ever assigns the slots; `mc_new` is the sole constructor. All 16 function
- pointers point at the single `m_*` function. **386 call sites** pay an indirect
- call for zero polymorphism. **This vtable should not exist** — devirtualize it.
-
-So "keep the vtable stack, slim it" resolves to:
-
-| Layer | Polymorphic? | Decision |
-|---|---|---|
-| **CgTarget** | yes (NDT / opt-recorder / c_target / wasm / check) | **KEEP** — one vtable, untouched signatures |
-| **NativeTarget** | yes — **per arch** (aa64 / x64 / rv64 / check) | **KEEP** — NDT correctly holds it; slim the `NativeLoc` marshalling + crossing count, not the dispatch |
-| **MCEmitter** | **no — one impl (`mc.c`)** | **DEVIRTUALIZE** — `static inline` the hot base-only ops, direct-call the rest, delete the 16 pointers |
-
-At -O0 the descent becomes **one indirect call (CgTarget seam) + one indirect arch
-dispatch (NativeTarget, real) + one inline byte store (MCEmitter, devirtualized)**,
-instead of three indirect calls + a `NativeLoc` marshalling round-trip. Two real
-vtables survive; the fake one and the copies do not.
-
----
-
-## 2. The honest arithmetic — what this gets us, and what it does not
-
-Summing the strands' own (deliberately conservative) per-track estimates, with
-overlaps de-duplicated:
-
-| phase | now | est. after campaign | residual ratio vs tcc |
-|---|--:|--:|--:|
-| lex + pp | 0.97 B | ~0.74 B | ~2.1× |
-| parse + sema + CG-drive | 0.87 B | ~0.54 B | ~2.2× |
-| native emit + objwrite | 0.37 B | ~0.24 B | ~4.8× |
-| **total** | **2.22 B** | **~1.52 B** | **~2.3×** |
-
-The emit-phase number is deliberately conservative: keeping the `NativeTarget`
-per-arch dispatch (it is real, §1) means the indirection savings there come only
-from devirtualizing the monomorphic MCEmitter (F2) and slimming marshalling (F3) —
-not from collapsing the whole descent. The bulk of the remaining emit headroom is
-the **o0-codesize track** (fewer emitted bytes), which is out of this plan's scope
-but compounds with it (below).
-
-**This campaign, fully landed, credibly takes kit from 3.35× to ~2.3× tcc — it
-does not reach parity by itself.** That is the truth the plan must own. Closing
-the last ~2× requires two things this plan *sets up* but does not *finish*:
-
-1. **The o0-codesize track (Track B) compounding.** kit emits ~2× tcc's machine
- bytes; that inflates emit, objwrite, and assemble *multiplicatively*. Track B
- has already taken the object 3.45×→1.71× tcc; every further byte removed
- shrinks the 0.37 B emit phase below the per-op wins counted above. The two
- tracks multiply, they don't add.
-2. **Re-profiling after each landing.** The phase shares shift as work is removed;
- the highest lever moves. Treat the estimates as a starting order, not a budget
- — re-measure on RELEASE best-of-7 after every shippable step and re-rank.
-
-The destination beyond this plan is `PERF-TCC-GAP.md` Track 4 (tcc's single-pass
-data-flow shape: a shared mutable token slot, symbols resolved through pointers on
-the interned token, a thin value stack the parser drives by calling emit directly
-into the final section image). The moves below are independently shippable,
-byte-identical-gated steps **toward** that shape — several of them (the contiguous
-section buffer, the binding-on-Sym cache, the intern-once cache) are literally the
-first bricks of Track 4.
-
----
-
-## 3. Foundational shared changes — do these first, they unblock multiple tracks
-
-Three changes are depended on by more than one strand. Land them first so the
-per-phase tracks build on them and nobody double-implements.
-
-### F1. One packed `{size, align, regclass}` memo on the type id
-
-**The highest shared-leverage change in the whole plan.** Today `abi_cg_type_info`
-(`src/abi/abi.c:27`) is an *uncached* recursive switch with alias/enum chasing;
-`abi_cg_sizeof`/`abi_cg_alignof` (`abi.c:90-95`) each call it independently, and it
-is hit several times per statement from **all three** downstream layers:
-
-- parser: `pcg_mem`/`pcg_sizeof`/`pcg_alignof` on every load/store/convert
- (`cg_adapter.c:9-39`, 17 sites) — and `pcg_mem` calls size+align *twice*;
-- cg: `api_alloc_temp_local` calls sizeof **and** alignof per temp
- (`value.c:225-226`), `api_mem_for_lvalue` per memop (`value.c:246-247`);
-- NDT: `nd_type_mem` calls size+align per native frame load/store
- (`native_direct_target.c:477-479`), plus the arch's own `loc_is_64`/`type_size32`
- re-query yet again (`aa64/native.c:349,374`).
-
-Only the 1-byte `api_type_class` is memoized today (`type.c:289-307`, with a CACHED
-bit + builtin fast path). **Extend exactly that proven mechanism** to a packed
-`{u32 size, u16 align, u8 regclass}` word on `CgApiType`, primed for builtins in
-`cg_api_init_builtins` and filled lazily for user types. Route `abi_cg_sizeof`/
-`abi_cg_alignof`/`abi_cg_type_info` and the parser's `pcg_*` through it; collapse
-the double-call sites to one read.
-
-- **Payoff:** ~0.08–0.12 B across the parse+sema phase **and** part of the emit
- phase (it serves NDT too). Single biggest middle-phase lever.
-- **Gate:** byte-identical (pure memoization of a deterministic function).
-- **Owner:** cg strand (lives in `type.c`/`abi.c`). Consumed by parser + NDT —
- they must *not* keep their own parallel size/align paths after this lands.
-
-### F2. Devirtualize MCEmitter (the fake vtable) — direct calls + `static inline` hot path
-
-**MCEmitter is monomorphic** (`mc.h:10`; only `mc.c` assigns slots; `mc_new` is the
-sole constructor) — its 16 function pointers are pure indirection across **386 call
-sites** with zero polymorphism. Convert:
-
-- **`static inline` in mc.h** the 3 hot, base-only ops — they touch only the
- *public* `MCEmitter` base struct (`cur_bytes`, `loc`, `obj`; `mc.h:49-65`):
- `emit_bytes` (**155 sites** — `mc.c:298`, just `buf_write`/`obj_write`, this is
- tcc's `o()`), `pos` (70 sites), `set_loc` (3 sites). These three cover 228/386
- sites. While here, cache a `{u8* cur; u8* end;}` cursor on the base so
- `emit_bytes`/a new `mc_emit32`/`mc_emit_words` do `if (cur<end){ store; cur+=n; }
- else slow()` — bounds-test + store + bump, no staging, no memcpy.
-- **Direct extern call** the other 13 — they need `MCImpl` private state
- (labels/fdes) or are cold: un-`static` the `m_*` bodies, declare `mc_*` in mc.h,
- rewrite `mc->X(mc,…)` → `mc_X(mc,…)`. Sites: `emit_reloc_at` 55, `label_new`/
- `label_place` 27/27, `emit_label_ref` 20, `cfi_*` 32, `set_section` 6,
- `emit_align`/`fill`/`label_data_reloc`/`reloc`/`destroy` ~8.
-- **Delete the 16 pointers** from `struct MCEmitter` (−128 B).
-
-This removes an indirect call **plus its caller-save churn** from every emitted
-instruction. It is the one genuinely-redundant indirection of the three layers.
-
-- **Payoff:** ~5–10 M from the call-shape change + the inline cursor; larger in
- practice because `emit_bytes` is the single highest-frequency call in the backend
- (one+ per emitted word). It is also the **sink F3 lands in.**
-- **Gate:** byte-identical (same functions, same order; only call shape changes).
-- **Owner:** MCEmitter strand. The cursor is shared with D5's section-buffer
- decision — settle the storage type once.
-
-### F3. Slim the NDT ↔ NativeTarget seam — kill the `NativeLoc` round-trip, batch crossings (NOT the dispatch)
-
-`NativeTarget` is a **real per-arch vtable** — keep it. The waste is the
-marshalling and the crossing *count*, not the indirect call. NDT already resolves
-operands to a `Reg` (`nd_dst_reg`/`nd_materialize_operand`), then wraps each in a
-48 B `NativeLoc`, passes it by value, and the arch immediately unwraps it to a
-register number (`aa64/native.c:2288`). Two moves, both keeping `d->native->X`:
-
-1. **Narrow register-only entry points** — `binop_rr(NativeTarget*, BinOp, RegLoc
- dst, RegLoc a, RegLoc b)` with a 12 B `RegLoc`, for the post-materialization
- ops (the contract already guarantees they receive only `NATIVE_LOC_REG`,
- `native_target.h:484-488`). Keep the fat `binop` for the opt replay path
- (`opt.c:990-1018`, unchanged).
-2. **Batch** the cold frame-operand case into one fused NT call (`binop_mem`)
- instead of `load`+`load`+`binop`, so a cold `a+b` is one crossing not three.
-
-- **Payoff:** ~3–5 % of the emit phase from the marshalling (48 B→12 B × 3/op,
- retired stores at IPC 3.7), more on the batched cold path.
-- **Gate:** byte-identical for the narrow-hook refactor; run-correctness +
- determinism for the x64 mem-operand fused form (different instruction selection).
-- **Owner:** NDT strand + per-arch backends (one additive `NativeTarget` change).
-- **Multi-backend safety:** additive hooks only; opt/-O1 keeps the fat vtable, gate
- the -O0 fast paths on `opt_level == 0`. C-source/Wasm never touch NDT.
-
----
-
-## 4. Track A — lex + pp + intern (0.97 B)
-
-The binding/resolution model is **already tcc-shaped** — macros (`MacroTab`) and
-keywords (`KwTab`) are O(1) `Sym`-indexed loads via `KIT_SYMTAB` (the Round-8
-sym-centric work). Do **not** rebuild resolution. The gap is *production-side*
-intern-once and copy density.
-
-| # | Change | Payoff | Gate | Notes |
-|---|---|--:|---|---|
-| A1 | **Per-lexer last-spelling intern cache** + promote the punct cache to the PP layer so includes don't re-intern. Real C is enormously repetitive at the token level; a 16–64 entry direct-mapped cache keyed on `(first_byte,len)` catches the dominant fraction before `pool_intern_slice` (`lex.c:594,573,784,679,1029`; `pool.c:120`). | −0.06…0.10 B | byte-identical (full byte-compare on hit; never cache digraphs) | biggest lex+pp lever; `pool_intern_slice` is 14% of self-time |
-| A2 | **Self-sufficient interner probe slot** — put `{hash, sym}` (optionally `+len+SSO prefix`) in the table slot so a probe rejects without the random `entries[sym]` load (`pool.c:139-142`). `{hash,sym}` is the safe floor; SSO width needs an A/B (PERF.md's "inline-prefix" dead-end was *entry-side*, not slot-side — the distinction matters). | −0.02…0.05 B | byte-identical | complements A1 (A1 = hits, A2 = misses/probes) |
-| A3 | **`pp_next_into(Pp*, Tok*)` out-pointer** mirroring `pp_next_raw_into`; collapse the 3 sret copies in `pp_next`→`fetch_tok`→`advance`. | −0.02…0.04 B | byte-identical | bounded (pull-fusion was ~5%); co-owned with the parser strand (it owns `fetch_tok`/`advance`/`p->cur`) |
-| A4 | **`Tok` 24→16 B** via an inline-packed 32-bit position (NOT a side table — the side-table LocId attempt was the documented regression), lazy `(file,line,col)` decode only at diagnostics + `pcg_set_loc`. | −0.03…0.06 B **or neutral** | **byte-identical, gate HARD** (DWARF, `-E` line markers, `__LINE__`/`#line`, diag-across-splice) | **highest-risk item.** Needs a genuinely O(1) decode helper at the warm `pcg_set_loc` site (34 expr sites) or it repeats history. Do **after** A1–A3 + jointly with the parser strand |
-| A5 | **Kill the manufactured `TOK_NEWLINE`** on the cc path (fold BOL/space into the next token's flags + a line counter; `-E` keeps a newline-emitting variant) and **skip the macro-body pre-copy** for paste-free bodies (`has_paste` flag set at definition; replay immutable body directly — `pp_expand.c:374,633`). | −0.03…0.07 B | byte-identical (`-E` newline placement; macro corpus) | lower priority; BOL/directive/invocation semantics are subtle |
-
-**Track A subtotal: ~0.16–0.32 B.**
-
----
-
-## 5. Track B — parse + sema + cg_adapter (0.87 B)
-
-No re-introduced superlinear axis was found (scope_lookup, declare_function,
-typedef/global intern, make_local_aligned are all O(1)-amortized). The gap is pure
-constant factor: redundant type queries, struct zeroing, and stack-copy bandwidth.
-
-| # | Change | Payoff | Gate | Notes |
-|---|---|--:|---|---|
-| B1 | **Route `pcg_mem`/`pcg_sizeof`/`pcg_alignof` through F1's memo** instead of raw `kit_cg_type_size`/`align` cross-API calls; build `KitCgMemAccess` from one `c_abi_type_info` call (`cg_adapter.c:9-39`). Halves the per-memop type work. | ~3–6% of phase | byte-identical | depends on **F1**; keep volatile read inline |
-| B2 | **Lazy/right-sized `PcgLvAux` clear** — most pushes are rvalues that never read aux; clear the 32 B aux only when a slot is tagged lvalue, drop the 5 explicit `pad[]` writes, use designated-init (`cg_adapter.c:41-51,497,678,993`). Attacks the 8% `memset`. | ~4–7% of phase | byte-identical (audit aux readers behind `was_lvalue`/lvalue tag) | parser-side, independent |
-| B3 | **Shrink `PcgSlot` 48→~24 B** (bit-field/subobject riders off-node) **in lockstep with cg's `ApiSValue` shrink (C3)**; memoize `{rank,signed,bits}` in a 24-entry `TypeKind` table so integer usual-arithmetic-conversions read array slots, not 6 hashmap probes (`cg_adapter.h:65`, `cint_common_type` 613-627). | ~3–5% of phase | low (table) / medium (slot split — bitfield+member corpus) | **co-own with cg strand** — the two stacks are kept symmetric (`cg_adapter.h:8`) |
-| B4 | **Classify the leading keyword once** into a `CKw` then `switch`, instead of a ~13-deep `is_kw` chain in `parse_stmt`/`parse_unary` (`parse_stmt.c:885-959`). tcc's `switch(tok)`. | ~1–3% of phase | very low (pure control-flow) | independent |
-| B5 | **Cache the resolved binding on the interned `Sym`** (parser-side `KIT_SYMTAB(BindingTab, SymEntry*)` with push/restore on scope enter/exit) so `scope_lookup` becomes one pointer load instead of an N-scope hash walk (`parse.c:369-380`). tcc's `sym_identifier`. | ~5–10% of phase (depth-dependent) | medium-high (save/restore + shadow/redef discipline; full corpus) | parser-side, **no pool change needed** — coordinate with intern strand only if you prefer the on-Sym layout |
-| B6 | **Hoist `pcg_convert`'s `src==dst` early-out** above its size/tid queries (`cg_adapter.c:882-892`). | ~1–2% of phase | very low | stacks with B1 |
-
-**Track B subtotal: ~0.17–0.33 B** (some also visible in `-fsyntax-only`).
-
----
-
-## 6. Track C — the CG value-stack + type machinery (part of 0.87 B)
-
-The CgTarget seam sits **above** all this, so even `-fsyntax-only` pays it. Note
-the **load-bearing caveat** resolved in §8: the fold ladder is a deliberate -O0
-peephole, not dead weight — slim the *decision*, keep the *effect*.
-
-| # | Change | Payoff | Gate | Notes |
-|---|---|--:|---|---|
-| C1 | **F1 applied inside CG** — collapse `api_alloc_temp_local`'s double size+align decode and `api_mem_for_lvalue` to single memo reads. | ~0.05–0.10 B (counted in F1) | byte-identical | this *is* F1's CG-side consumption |
-| C2 | **Single type-classify per binop** — stamp `{foldable-width, is_int, is_float}` on the value node at `api_push` (alongside the existing cached `wide_kind`), then read once in `api_cg_binop`; pass the decoded width into `api_can_delay_int_arith`/`api_try_strength_reduce`/`api_try_collapse_binop_identity` so they skip re-deriving it. Removes 6–9 alias-chases per integer binop (`arith.c:29-107`). Also drop the 4 i128/wide64 stack probes per op in `kit_cg_int_binop` (`arith.c:1103-1121`) for the i32/i64-native common case. | ~0.04–0.08 B | low (cached width must match `api_int_like_width`'s int-vs-int-like distinction exactly) | **the correct form of "slim the fold ladder"** — see §8 |
-| C3 | **Shrink `ApiSValue` 56→~24 B** — move `bitfield` (12 B) + `source_local` off-node behind a sparse rider keyed by slot (PLACEs are the minority); make `pinned` a transient local in `dup` (its only reader). Lockstep with B3. | ~0.02–0.06 B | medium (riders must survive dup/swap/rot3/pop; bitfield + source-local corpus) | PERF Round 7 found the *delayed*-payload shrink neutral — do this for **copy bandwidth**, measure before committing; most invasive, land after C1/C2 |
-| C4 | **Designated-init the per-op `memset`s** in `api_alloc_temp_local` / `api_mem_for_lvalue` (the idiom `api_op_imm` already uses, `value.c:22-25`). | ~0.01–0.025 B | very low | stacks with C1 |
-
-**Track C subtotal: ~0.07–0.18 B net new** (excl. the F1 portion already counted).
-
----
-
-## 7. Track D — NDT register discipline + descriptor + emit/objwrite
-
-| # | Change | Payoff | Gate | Notes |
-|---|---|--:|---|---|
-| D1 | **F2** (MCEmitter devirtualization — direct/inline the monomorphic byte sink). | counted in F2 | byte-identical | the genuine indirection win; highest-frequency call in the backend |
-| D2 | **Replace the LRU register-cache with a fixed round-robin write-back cache** — drop `use_tick`/`last_use`/`reg_last_use`/`nd_touch_local` and the linear `nd_pick_cache_victim` scan; keep the consumer-resident write-back mechanism the recent commits (f75874cb/3516b3d3) built — swap only the *victim policy* (`native_direct_target.c:516-930`). | ~5–10% of emit phase | **run-correctness + determinism** (changes reg selection → different bytes; compile sqlite twice + `cmp`, run `84|2`, toy/parse/smoke) | coarser eviction may cost a few emitted bytes — measure both ways |
-| D3 | **F3** (slim the NDT↔NativeTarget marshalling: `NativeLoc` 48 B → 12 B `RegLoc` register-only hooks + batch the cold frame-operand crossings; `native_target.h:234,258,484-488`). Keep the per-arch dispatch — it is real. | counted in F3 | byte-identical (narrow hooks) / run-correctness (x64 fused mem-operand) | coordinate the `NativeTarget` struct change once across archs + the opt path |
-| D4 | **NDT reads F1's memo** for `nd_type_mem`, and carries `is64`/`size_log2` on the `NativeLoc` pad so the arch's `loc_is_64`/`type_size32` stop re-querying (`aa64/native.c:349,374`). | ~0.02 B | byte-identical | depends on **F1** |
-| D5 | **Zero-copy section write** — `buf_write_to_writer(Buf*, Writer*)` walks chunks straight to the Writer, eliminating the `buf_flatten` temp-buffer second copy of all machine code (`macho/emit.c:814-829`, `elf/emit.c:730-742`). Stretch: a single contiguous geometric section buffer so `mc_emit32` stores into the final image (full tcc shape) — larger lift, shared cursor with F3. | ~4–8 M (chunk-walk) up to ~8 M (contiguous) | byte-identical (`test-elf test-macho test-link`) | decide the buffer type once with F3 |
-| D6 | **Index relocations by section** at `obj_finalize` (stable bucketing) to kill the O(n_sections × n_relocs) rescan in object write (`obj.c:1121-1128`, `macho/emit.c:538-550`, `elf/emit.c:468-479`). **A latent superlinear axis** — grows with `-ffunction-sections`/literal fan-out. | tens of M on section-heavy objects | byte-identical (stable buckets preserve order) | independent; a "bug" by PERF.md's rule |
-| D7 | **Single-pass symbol-table emission** + chunk-walk the string table (drop the recount pass + the strtab flatten, `macho/emit.c:392-534,854-857`); add the rv64 `if (mc->debug)` guard `aa_emit32` already has (`riscv/native.c:81`); `mc_emit_words` for multi-word idioms (F3). | ~0.01–0.02 B | byte-identical | mop-up, all in the 7× phase |
-
-**Track D subtotal: ~0.16–0.27 B** (incl. the F2 portion).
-
----
-
-## 8. The one resolved conflict — do NOT gate the fold ladder "off"
-
-The CgTarget strand proposed gating the -O0 fold/strength-reduce/identity ladder
-off; the CG strand flagged this as a **trap**, and it is right:
-
-> The ladder at `arith.c:48-99` is **not a no-op at -O0.** It const-folds
-> immediates, strength-reduces, and collapses identities — which *reduces emitted
-> machine bytes*. The o0-codesize track (Track B in the standings, 3.45×→1.71×
-> object) **depends on it.** Gating it off would change emitted bytes, fail the
-> byte-identity gate, and **regress the worst-ratio emit phase** — moving backward
-> on the exact phase with the most headroom.
-
-**Resolution:** keep the ladder's *byte-producing effects*; slim only the
-*decision cost*. That is **C2** (classify once, drop the delay-node bookkeeping
-whose product is never read at -O0), not "turn it off." Any future "fuse the seam"
-work must preserve the ladder's output and the `untyped_values` bitcast-elision
-flag (`arith.c:276`). This is the one place a naive implementation shoots itself in
-the foot — flagged here so it doesn't.
-
----
-
-## 9. Sequencing & dependency graph
-
-```
-WAVE 0 (foundations, byte-identical, unblock everything):
- F1 (type memo) ──> B1, C1, C4, D4
- F2 (MCEmitter devirt + inline cursor) [the fake-vtable removal; F3's sink]
- D6 (reloc index, independent superlinear fix)
-
-WAVE 1 (high-leverage, mostly byte-identical):
- F3/D3 (NDT↔NativeTarget marshalling slim) [lands in F2's inline emit]
- C2 (classify-once binop) [independent of F1, but co-measure]
- A1 (intern last-spelling cache)
- B2 (PcgLvAux right-size), B4 (kw switch), B6 (convert early-out)
- D5 (zero-copy section write), D7 (symtab/emit mop-up)
-
-WAVE 2 (medium risk, lockstep pairs):
- B3 + C3 (PcgSlot + ApiSValue shrink — MUST land together, symmetric stacks)
- A2 (probe slot), A3 (pp_next_into — co-own fetch_tok with parser)
- D2 (round-robin cache — switches to run-correctness gate)
-
-WAVE 3 (highest risk, gate hard, do last & jointly):
- A4 (Tok 16B — needs O(1) decode at pcg_set_loc, joint lex+parser)
- B5 (binding-on-Sym cache)
- A5 (TOK_NEWLINE / macro pre-copy)
- D5-stretch (contiguous section buffer → emit into final image)
-```
-
-Re-profile (RELEASE best-of-7, `tmp/projects/sqlite-amalg/sqlite3.c`) after **each
-wave** and re-rank — the phase shares shift as work is removed.
-
----
-
-## 10. Gate strategy
-
-- **Byte-identical (the default):** every change that restructures *how* work is
- done without changing emitted bytes — F1, F2, F3, A1–A5, B1–B6, C1–C4, D1, D3,
- D4, D5, D6, D7. Use `scripts/perf_identity_gate.sh` (60-category golden-vs-candidate;
- `-E` text, `-g` DWARF, objects, diagnostics). Lexer changes (A1–A5) additionally
- need the line-splice battery + a diagnostic line across a splice.
-- **Run-correctness + determinism (only where bytes legitimately change):** D2
- (round-robin register selection) and the x64 fused mem-operand sub-case of F3/D3
- (the register-only narrow hooks stay byte-identical). Compile sqlite twice and `cmp` the objects
- (self-identical across runs), plus `make test-toy test-parse test-smoke-x64
- test-smoke-rv64` and the sqlite end-to-end (`84|2`).
-- **A/B before committing the uncertain ones:** A2 (SSO width), A4 (decode cost),
- C3 (Round 7 found the analogous shrink neutral). Build them, measure on RELEASE,
- keep only if positive — the same discipline that caught the de-intern and
- 16 B-Tok dead ends.
-- **Measure on RELEASE** (`make bin RELEASE=1`, `PROFILE=1` for sampling), never
- the ASan default. Instructions via `/usr/bin/time -l` best-of-7; trust
- instructions over wall/cycles.
-
----
-
-## 10b. Measured results (first implementation pass)
-
-Wave 0 + the reloc fix were implemented and measured on `sqlite3.c -c`
-(best-of-7, instruction metric is deterministic to ±0.2M on a fixed binary;
-each change byte-identical via the 60-category gate). **The actuals came in 1–2
-orders of magnitude below §2's estimates** — the estimates assumed the hot paths
-were fat, but they were already tight:
-
-| change | estimate | **measured** | note |
-|---|--:|--:|---|
-| D6 (reloc index) | tens of M | **−5.6M** | also a real superlinear fix; grows with section count |
-| F1 (type memo) | ~80–120M | **−2.1M** | `cg_type_get` already memoized size/align on the `CgType`; only the alias/enum recursion + double-calls remained |
-| F2 (MCEmitter devirt) | ~5–10M | **−1.2M** | the indirect calls were single-target → branch-predicted to near-free; the win is removing the fn-ptr load |
-| **cumulative D6+F1+F2** | | **−8.9M (−0.41%)** | 2160.7M → 2151.8M |
-
-Two findings worth keeping:
-
-- **F2 must be a direct call, not `static inline`.** Inlining the hot
- `emit_bytes`/`pos` into the per-arch leaf emitters bloats them enough to break
- the inliner's own cascade (e.g. `aa_emit32` stops being inlined), a measured
- **net +21M** regression. Direct extern call removes the indirection without
- that effect.
-- **The metric is deterministic, not noisy** — so these small deltas are real,
- and a per-operand tracking pass added elsewhere shows up immediately (a
- concurrent in-tree `-O0` transient-liveness/coalescing WIP was measured adding
- ~+26M to `-fsyntax-only` while disabled, which is the actual high-value lever
- in this subsystem).
-
-**Implication for the remaining micro-levers (F3, the rest of Track A/B/C):**
-they will each land in the single-digit-M range for substantial churn. The
-instruction gap to tcc (~1500M) is **not** closable by per-operation slimming;
-it needs the **codesize track** (fewer emitted bytes → less emit/objwrite/
-assemble, the multiplicative lever) and the value-stack/coalescing redesign.
-Treat the micro-levers as cleanups that compound, not as the path to parity.
-
-## 10c. Measured results — -O0 value-stack residency (the high-value lever)
-
-§10b flagged the real lever as *"the value-stack/coalescing redesign,"* not the
-per-op micro-slimming. That redesign landed as four staged, run-correctness +
-determinism-gated commits on the brief *"stop homing intermediates in frame
-slots: keep them register-resident on the value stack and spill only the live
-set at calls."* The diagnosis (from emitted `-O0` asm) was three round-trip
-classes, all instances of *frame-home every intermediate + flush-everything at
-every barrier*:
-
-| commit | change | sqlite3.c -c -O0 effect |
-|---|---|--:|
-| `93aafda3` | **register-resident return** — `nd_ret` sources the value from its live register and drops the cache without spilling (every cached local is dead at a return); was `stur w8,[home]; ldur x0,[home]` | emitted 526,681 → 520,854; obj −0.9% |
-| `2b1b03c2` | **assignment coalescing** — `pcg_store_void` (no dup of a discarded assignment result) + `kit_cg_store` materializes a delayed arith/cmp straight into the destination local; kills the temp→local routing `mov` for `x = <expr>` | compile −5.9M; emitted → 518,757 |
-| `98b1f010` | **register-resident call args + spill-only-live-set** — a provably-dead arg (`CGCallDesc.arg_dead_mask` from `api_temp_dead`) flows reg→arg-reg via the existing `native_arg_shuffle`; only the live-across set is spilled, not the whole cache | compile −10.4M; emitted → 479,263; obj −6.4% |
-| `f61d252b` | **register-resident call result** — the scalar result is cached (claimed reg, written by the post-call ret move) instead of stored to its home + reloaded | emitted → 474,752; obj → 2.29 MB |
-
-**Cumulative: compile 2,153M → 2,134M (−18.8M, −0.87%); emitted instructions
-526,681 → 474,752 (−9.9%); object 2,501,224 → 2,293,504 B (−8.3%, now 1.09×
-tcc); emitted-instruction count 1.54× → 1.39× tcc.** All four byte-deterministic;
-green across toy/parse/smoke-x64/smoke-rv64/cg-api/libc(musl+glibc)/opt, sqlite
-e2e `84|2` at -O0 and -O1, and a clang-differential call probe (shared-arg-
-live-across, >8-arg stack calls, varargs, struct sret/byval, recursion,
-fn-pointer dispatch).
-
-Two lessons:
-
-- **The emitted-code win (−9.9%) dwarfs the compile-time win (−0.87%).** Removing
- a store+reload round-trip is mostly fewer *emitted* bytes; the compile-time
- saving is one fewer op emitted. This is squarely the codesize track (§2.1) —
- it compounds with emit/objwrite/assemble — and it moved the object to 1.09×
- tcc, far more than the per-op levers did.
-- **The arg round-trip was the single biggest class** (−10.4M compile, −39.5K
- emitted): every call argument was computed into a register, spilled to its
- home, and reloaded into the ABI arg register, behind a flush-everything that
- also spilled non-live cached locals.
-
-## 10d. Measured results — lazy transient frame homes (Stage 4)
-
-The §10c-deferred follow-up landed: a plain transient temp
-(`CG_LOCAL_TRANSIENT && !address_taken && !memory_required`) reserves **no** frame
-slot at creation; `nd_alloc_local` leaves `home = NONE` and a single
-ensure-accessor `nd_home()` mints the slot on first demand. Every NDT read of a
-transient's `home` (loc/addr builders, the spill in `nd_flush_local`, the
-scratch→home store, the `nd_call` capture-then-flush, `nd_ret`) routes through
-`nd_home`; the va_arg/va_*/inline-asm boundaries call `nd_home_operand` on each
-operand before crossing into arch code that reads `home` directly (the va_arg
-destination and asm reg outputs are fresh store-target transients with no prior
-spill — without this they hit `NATIVE_FRAME_SLOT_NONE` → "bad frame slot"). The
-lazily-minted slots carry `NATIVE_FRAME_SLOT_TRANSIENT`, so they feed the
-existing statement-boundary free-list reuse (`nd_reclaim_temps`) with no new
-mechanism. Declared locals/params and address-taken/wide8 temps keep eager homes
-(debug-loc + stable-address needs; they also spill at the first barrier anyway).
-
-**sqlite3.c -c -O0, arm64-mac, RELEASE, vs the copy+convert-coalescing baseline:**
-
-| metric | base | lazy | Δ |
-|---|--:|--:|--:|
-| emitted instructions (text/4) | 457,115 | 455,546 | −0.34% |
-| object bytes | 2,222,960 | 2,216,680 | −0.28% |
-| **mean frame size** | 136 B | 112 B | **−17.6%** |
-| frames > 256 B (need x29-relative addr) | 236 | 181 | −23.3% |
-| `sub xNN, x29` (frame-address building) | 27,403 | 25,954 | −5.3% |
-| functions with a stack frame | 2,630 | 2,605 | 25 frameless |
-
-Exactly as §10c predicted: the **emitted-instruction win is small** (coalescing
-already prevents most spills, so eager homes were near-benign for the *instruction
-count*), but the **frame shrinks materially** — mean −17.6 %, a quarter fewer
-frames past the ±256 unscaled window, and 25 functions become frameless. That
-chips the Fix-B `sub xNN,x29` residual (§ in PERF-TCC-GAP) by 5.3 % without
-touching addressing. Byte-deterministic (two compiles `cmp`-identical);
-compile-time within wall-clock noise. Green across
-toy(1392)/parse-ok(3920)/parse-err(129)/cg-api/opt/smoke-x64/smoke-rv64/debug/
-dwarf/libc(musl 18+glibc 9), plus a clang-differential probe (va_arg mixed
-int/double lanes, inline-asm reg output, struct return, shared-arg-live-across).
-
-## 11. What this is not
-
-- **Not Track 4.** This keeps the typed value model and the seam; it does not adopt
- tcc's shared-mutable-token-slot single pass. It builds toward it (A1/A4/B5/D5 are
- the first bricks) but stops short.
-- **Not the codesize track.** Track B (o0-codesize) shrinks emitted *bytes* and
- must run in parallel — it compounds with §3–§7 multiplicatively and is most of
- the remaining gap to parity after this plan (§2).
-- **Not parity by itself.** ~3.35× → ~2.3× is the credible reach of this campaign;
- parity needs codesize compounding + iteration. Stated plainly so the goal stays
- honest.
diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md
@@ -1,94 +1,146 @@
# Performance: the fastest C compiler
-**Goal.** kit should be *the fastest* C compiler at `-O0`. Generated code quality
-is irrelevant; only correctness and **compile+link throughput** matter. The bar
-is **tcc** — the fastest mainstream C compiler. The structural bet is already in
-place (a single-pass, no-AST C frontend, single-pass code emission with
+**Goal.** kit should be *the fastest* C compiler at `-O0`, and emit code at least
+as dense as the bar. Generated-code *quality* (runtime speed) is irrelevant; only
+correctness, **compile+link throughput**, and **emitted code size** matter. The
+bar on both axes is **tcc** — the fastest mainstream C compiler. The structural
+bet is already in place (single-pass no-AST C frontend, single-pass emission with
patch-ups, a format-neutral linker), so the work is measurement-driven: profile a
-real workload, find where the time goes, and remove it.
+real workload, find where the instructions/bytes go, remove them.
-Two rules hold everywhere below:
+Two rules hold everywhere:
-- **A superlinear axis is a bug.** It means some inner step is O(n²)-ish — a
- linear scan run once per item, a table that rebuilds, a list re-walked. The
- type-cache work in "Current state" was exactly this class.
-- **Gate on byte-identical output.** A perf change must produce a bit-for-bit
- identical object (and `-E` / `-g` / diagnostics) vs a snapshot of the
- pre-change binary, unless it deliberately changes output (then the gate is
- run-correctness + determinism). ASan can't see a premature arena reuse or an
- uninit read; the output diff can.
-
-## The bar: tcc on sqlite
+- **A superlinear axis is a bug.** It means an inner step is O(n²)-ish — a linear
+ scan run per item, a table that rebuilds, a list re-walked. Catch these with the
+ synthetic scaling benchmark (below).
+- **Gate every change.** A perf change must produce a **bit-for-bit identical**
+ object (and `-E` / `-g` / diagnostics) vs a snapshot of the pre-change binary —
+ *unless* it deliberately changes emitted bytes (a code-size lever), in which case
+ the gate is **run-correctness + determinism** (compile twice + `cmp`, the test
+ suites, sqlite e2e at `-O0`+`-O1` vs clang, per-arch clang-differential probes).
+ ASan can't see a premature arena reuse or an uninit read; the output diff can.
The real workload is the **sqlite amalgamation** — one ~9 MB / 263 K-line C file
-(`tmp/projects/sqlite-amalg/sqlite3.c`, v3.50.2). It is the right benchmark
-because it is real, huge, declaration/macro/type-heavy (not a synthetic shape),
-and doubles as a correctness test (compile + link the shell, run a query).
+(`tmp/projects/sqlite-amalg/sqlite3.c`, v3.50.2): real, huge, declaration/macro/
+type-heavy, and doubling as a correctness test (compile + link the shell, run a
+query). All commands assume `SDK=$(xcrun --sdk macosx --show-sdk-path)` and a
+release kit at `build/release/kit` (`make bin RELEASE=1`).
+
+---
+
+## 1. Current standings
-### Current standings (compile to object, `-c`, default `-O0`, best-of-N)
+### Compile speed — the open frontier (~2.9× tcc)
-Apple-silicon arm64 / macOS. **`instructions` is the metric to trust** — it is
-load-independent (from `/usr/bin/time -l`, best-of-7); wall and cycles are only
-meaningful on a quiet machine (`sysctl -n vm.loadavg`) and are shown from a
-recent low-load reading.
+Apple-silicon arm64 / macOS. **`instructions` is the metric to trust** —
+load-independent (`/usr/bin/time -l`, best-of-7); cycles/wall are low-load
+readings (`sysctl -n vm.loadavg`) shown only for context.
| compiler | instructions | cycles † | wall † | object |
|---|--:|--:|--:|--:|
| **tcc 0.9.28** | 0.66 B | 0.19 B | 0.06 s | 2.11 MB |
-| **kit (current)** | 1.91 B | — | — | **1.83 MB** |
+| **kit** | 1.91 B | 0.52 B | 0.16 s | 1.83 MB |
| clang 22 | 8.73 B | 2.57 B | 0.81 s | 1.50 MB |
-† low-load reading; re-confirm on a quiet machine (`instructions` is
-load-independent and the figure to trust; cycles/wall are pending a quiet-machine
-re-read).
-
-kit beats clang on compile speed and is the fastest *general* backend here, but
-**tcc is the bar**: ~2.88× instructions ahead. Closing that is the whole game.
-The gap was 3.23× when this table was last read; two campaigns since closed it to
-~2.88×: the frontend instruction-slimming work on main (Sym-keyed binding cache,
-per-id type decode, single-pass symtab/strtab — `-fsyntax-only` 1.84 → 1.63 B)
-and the **`o0-codesize` track** (next paragraph). kit's **object is now 1.83 MB
-— below tcc's 2.11 MB** (1.88 MB before Lever 1; 1.99, 2.29 MB earlier), because
-emitting less machine code also writes a smaller object.
-
-The `o0-codesize` track cut emitted `-O0` `.text` from **1.33× → 1.044× tcc**
-(455,546 → 357,707 insns): five early levers (far-slot positive-scaled
-addressing, a 2-insn epilogue, call-result-in-result-register, copy-into-dest
-materialization, narrow-load zero-extend elision), then **Lever 2** (spill
-reduction — eager dead-operand drop + a branch materialize-before-flush reorder,
-−24,581), three single-pass follow-ons (switch-selector residency, signed
-load-with-extend, byte/half far slots; −2,315), and **Lever 1** (call args
-materialized straight into the ABI arg registers — front the arg/ret regs in the
--O0 cache pool + arg0-first materialization so producers land args in their slot;
-−13,830, the biggest single lever, all four arches). Several are arch-neutral (the
-shared-NDT levers also help x86-64/rv64). Smaller output is fewer instructions to
-emit and write, so it shrinks the emit+objwrite slice — but each lever's
-producer-side CG cost (Lever 2's `api_temp_dead` checks, Lever 1's arg-order
-reversal + value-stack ref bookkeeping) offsets it, so the `-c`
-compile-instruction total is **~flat (1.91 B, callgrind 1.746 B Ir unchanged)** —
-the win shows in the smaller object and emitted `.text`. Full standings,
-diagnosis, and the remaining levers: **`doc/plan/PERF-O0-CODESIZE.md`**.
-
-### Reproducing the detailed measurements
-
-All commands assume `SDK=$(xcrun --sdk macosx --show-sdk-path)` and a release
-kit at `build/release/kit` (`make bin RELEASE=1`).
-
-**1. Build tcc** (the bar) from the mob mirror, as an optimized binary:
+† low-load; instructions is the figure to trust.
+
+kit beats clang and is the fastest *general* backend, but **tcc is ~2.9×
+(instructions) ahead** — this is the whole game. **The gap is instructions, not
+cache:** kit's IPC (~3.7) is *higher* than tcc's (~3.5), so both are compute-bound
+on a wide core and there is no hidden cache-miss penalty to claw back. The payoff
+mechanism is **fewer retired instructions** (fewer copies, indirect calls,
+redundant recomputations) — denser structures help only because they cost fewer
+load/store/move *instructions*.
+
+### Code size — ≈ parity (1.044× tcc)
+
+The honest metric is **`.text` machine code** (the object file is format-skewed —
+Mach-O vs tcc's ELF — and not comparable; kit's *object* is actually smaller).
+
+| metric | kit | tcc | ratio |
+|---|--:|--:|--:|
+| **`.text`** | **1,430,828 B / 357,707 insns** | **1,370,940 B / 342,735 insns** | **1.044×** (+14,972 insns) |
+
+Where the remaining `.text` excess lives (per-mnemonic, kit − tcc on sqlite):
+
+| heavier in kit | Δ | what it is |
+|---|--:|---|
+| `stur`+`ldur`+`str` (spills) | **+58,567** | **#1: register pressure** — the single-pass NDT spills more than tcc keeps resident across statements / control-flow joins |
+| `mov` | **+32,487** | residual arg / value-stack copies the arg0-first placement can't reach (nested-call results already in x0, pressure spills) |
+| `sub`+`movk` | +5,048 | residual far-frame addressing (byte/half slots, `&local` in big frames) |
+
+…offset by where **kit already beats tcc** (structural wins — do not touch):
+`add` −21,713 (folds offsets into displacements), `ldr` −21,814 (far slots fold
+into `[sp,#scaled]`), `cset` −17,226 / `cbnz` −10,817 / `cmp` −9,421 (kit fuses
+compares into `cbz`/`b.cc`; tcc materializes a bool then tests), `nop` −4,704.
+Net +14,972.
+
+---
+
+## 2. Where the cost is (current profile)
+
+### Compile is frontend-bound, ~uniform ~3× across phases
+
+Phase decomposition by subtraction (instructions; `-fsyntax-only` drives the full
+CG value-stack + type lowering routed to the no-op check backend, so `(-c) −
+(-fsyntax-only)` isolates native-emit + object-write):
+
+| phase | kit `-c` | share | ratio to tcc |
+|---|--:|--:|--:|
+| lex + pp | ~0.93 B | ~49 % | ~2.8× |
+| parse + sema + types + CG-drive | ~0.71 B | ~37 % | ~3.5× |
+| native emit + object write | ~0.27 B | ~14 % | **~7×** (worst ratio, smallest slice) |
+| **total** | **1.91 B** | 100 % | **~2.9×** |
+
+Two facts govern strategy: (1) the gap is **roughly uniform across phases** — no
+single hot function to crush; closing it is a campaign across the pipeline. (2)
+**Post-PP work alone (`-c` − `-E` ≈ 0.98 B) is already ~1.5× tcc's entire
+compile** — a *free* lexer would still leave kit above tcc, so the lexer is not
+the sole frontier.
+
+**Linux callgrind** (inclusive, instruction-grounded — the tool that sees what
+wall-clock `sample` hides; total **1.746 B `Ir`**, glibc/ELF):
+
+| self % | function(s) | subsystem |
+|--:|---|---|
+| **16.9** | `lex_next` | scanner |
+| 5.0 / 4.5 / 2.0 | `src_next_raw_into` / `lex_point_at` / `finish_ident` | scanner+pp |
+| 4.4 | `pp_pull_into` | preprocessor |
+| 4.1 | `pool_intern_slice` | interning |
+| 3.1 / 2.8 | `api_unalias_type` / `api_type_pred_bits` | types |
+| 1.5×4 / 1.3 / 1.2 | `resolve_type`,`cg_type_get`,`type_cg_lower`,`api_type_class` / `abi_cg_type_info` / `cg_type_size` | types |
+| 1.0 | `aa_emit_mem` | codegen |
+
+**Subsystem rollup:** scanner ~24 %, **types ~14 %**, preprocessor ~12 %,
+interning ~4 %, codegen/emit ~3 %. The macOS wall-clock `sample` makes the scanner
+*look* like the lone frontier (`lex_next` ~72 %) and renders the type subsystem
+nearly invisible — callgrind reveals **types as the #2 self-`Ir` cluster** (a
+dozen O(1)-but-very-frequent per-query helpers). Trust callgrind for *where*;
+trust macOS instructions for *how much*.
+
+### Code size is spill-bound
+
+After arg-into-arg-register placement, the `.text` excess is dominated by
+**spills** (register pressure in the single-pass cache, +58 K) then residual
+**`mov`s** (+32 K). Both concentrate in the giant functions (`_sqlite3VdbeExec`
+etc.) where per-function pressure is highest.
+
+---
+
+## 3. Reproducing the metrics
+
+**Build tcc** (the bar) from the mob mirror as an optimized binary:
```sh
-git clone --depth 1 https://github.com/tinycc/tinycc tmp/tinycc
-cd tmp/tinycc
-export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) # REQUIRED: Homebrew
- # clang guesses a non-existent SDK; without this, "stdio.h not found"
+git clone --depth 1 https://github.com/tinycc/tinycc tmp/tinycc && cd tmp/tinycc
+export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) # else "stdio.h not found"
sed -i 's|^CFLAGS=.*|CFLAGS=-Wall -O3 -DNDEBUG -Wdeclaration-after-statement|' config.mak
-./configure ; make clean ; make CC=cc # CC=cc = Apple clang (xcrun-aware)
+./configure ; make clean ; make CC=cc # CC=cc = Apple clang (xcrun-aware)
# binary at tmp/tinycc/tcc ; compile with: tcc -c sqlite3.c -o x.o
```
-**2. Instructions / cycles / wall** — `/usr/bin/time -l` is the macOS analog of
-`perf stat`; it reports `instructions retired` and `cycles elapsed`. Best-of-N
-filters scheduling noise. Helper:
+**Instructions / cycles / wall** — `/usr/bin/time -l` is the macOS `perf stat`;
+best-of-N filters scheduling noise:
```sh
m(){ local bi=9e18 bc=9e18 br=9e18; for i in $(seq 1 7); do
@@ -97,49 +149,28 @@ m(){ local bi=9e18 bc=9e18 br=9e18; for i in $(seq 1 7); do
local C=$(awk '/cycles elapsed/{print $1}' /tmp/m.txt)
local R=$(awk '/real/{print $1}' /tmp/m.txt)
awk "BEGIN{exit !($I<$bi)}"&&bi=$I; awk "BEGIN{exit !($C<$bc)}"&&bc=$C
- awk "BEGIN{exit !($R<$br)}"&&br=$R; done
- echo "instr=$bi cycles=$bc real=${br}s"; }
+ awk "BEGIN{exit !($R<$br)}"&&br=$R; done; echo "instr=$bi cycles=$bc real=${br}s"; }
cd tmp/projects/sqlite-amalg
m build/release/kit cc -c sqlite3.c -o /tmp/k.o --sysroot "$SDK"
m tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o
```
-Caveat: Apple clang spawns a separate `cc1` that `/usr/bin/time` does **not**
-count — measure it with `cc -fintegrated-cc1 -c …`. Homebrew clang and kit are
-single-process, so their counters are exact. Check `sysctl -n vm.loadavg` before
-trusting wall time; instructions/cycles are fine under load.
+Caveat: Apple clang spawns a `cc1` that `/usr/bin/time` does **not** count —
+measure it with `cc -fintegrated-cc1 -c …`. kit and tcc are single-process.
-**3. Phase decomposition** — where the frontend vs codegen time goes, by
-subtraction (instruction counts are cleanest):
+**Phase decomposition** (subtraction; instruction counts are cleanest):
```sh
-m build/release/kit cc -E -o /dev/null sqlite3.c --sysroot "$SDK" # pp + lex
-m build/release/kit cc -fsyntax-only sqlite3.c --sysroot "$SDK" # + parse/sema/types
-m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # + codegen + emit
+m build/release/kit cc -E -o /dev/null sqlite3.c --sysroot "$SDK" # lex+pp
+m build/release/kit cc -fsyntax-only sqlite3.c --sysroot "$SDK" # +parse/sema/types/CG-drive
+m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # +emit+objwrite
```
-`-fsyntax-only` still drives the full CG value-stack and type lowering (routed to
-the no-op check backend), so `(-c) − (-fsyntax-only)` isolates **native emit +
-object write** only. Current binary: `-E` 0.93 B, `-fsyntax-only` 1.64 B, `-c`
-1.91 B → that delta is ~0.27 B / ~14 % — i.e. **codegen+emit is a small slice;
-the frontend is the rest.** (The `o0-codesize` spill-reduction levers nudged
-`-fsyntax-only` up ~0.01 B via their per-op `api_temp_dead` checks while shrinking
-the emit slice, so the `-c` total held ~flat as the object shrank 10 %.) Within that frontend, `-E` alone (0.93 B) is just
-under half, and the scanner (`lex_next`) is the bulk of it — see *Where the time
-goes*. (Landings that shrank these: the scanner rewrite cut a flat ~84 M off
-every phase — `-E` 1.06 → 0.93 B — the -O0 value-stack residency work took
-emit+objwrite 0.37 → 0.29 B by killing the call/return/assignment home
-round-trips, the `o0-codesize` track shaved it again by emitting fewer
-instructions, and the frontend slimming on main cut `-fsyntax-only` 1.84 →
-1.63 B. See PERF-TCC-SLIM.md §10c and PERF-O0-CODESIZE.md.)
-
-**4. Hotspot profile** (self-time per function). One run is far too fast for
-`sample` (a `-c` is ~0.2 s now), so merge the `Sort by top of stack` sections
-across many runs — use **~80** to get a stable distribution (~1800 samples; 24
-runs yields only a few hundred and the percentages wobble):
+**macOS hotspot sample** (self-time leaves only — see the callgrind caveat). One
+`-c` is too fast for `sample`; merge ~80 runs:
```sh
-make RELEASE=1 PROFILE=1 CC=clang BUILD_DIR=build/bench bin # profileable kit
+make RELEASE=1 PROFILE=1 CC=clang BUILD_DIR=build/bench bin # -g + frame ptrs, no strip
cd tmp/projects/sqlite-amalg ; KIT=build/bench/kit ; rm -f /tmp/p_*.txt
for i in $(seq 1 80); do
$KIT cc -c sqlite3.c -o /tmp/kk.o --sysroot "$SDK" 2>/dev/null & pid=$!
@@ -149,12 +180,38 @@ for f in /tmp/p_*.txt; do awk '/Sort by top of stack/{f=1;next}/Binary Images/{f
| awk '{c[$1]+=$2;t+=$2} END{for(k in c) printf "%.1f%% %s\n",100*c[k]/t,k}' | sort -rn | head -25
```
-`PROFILE=1` keeps `-g` + frame pointers and skips the strip, so codegen matches
-the shipped release and arm64 stacks are reliable. **Verify the build actually
-completes a full `-c`** (`rc=0`, non-empty object) before trusting a sample — a
-build that errors partway through inflates the frontend's apparent share.
+**Linux callgrind** — the **inclusive, instruction-grounded** profiler (no
+valgrind on macOS). `scripts/perf_callgrind.sh` codifies the recipe; one-time
+`scripts/perf_callgrind.sh image` bakes the container, then:
-**5. End-to-end correctness** (sqlite as a correctness test):
+```sh
+scripts/perf_callgrind.sh run <tag> # builds kit in-container, runs callgrind, prints total Ir
+ # → build/linux-prof/cg.<tag>.annot.txt (self Ir + callers)
+```
+
+Gotchas baked into the script (do not relearn): **(a)** pass `-lc` so kit finds
+the glibc sysroot even for `-c`; use **bookworm** (glibc 2.36), not ubuntu 24.04
+(glibc 2.39's `bits/math-vector.h` uses a vector-typedef attribute the C frontend
+rejects). **(b)** `strip --strip-debug` the kit binary in place before profiling
+(valgrind 3.19 chokes on clang DWARF5; callgrind needs only `.symtab`) — strip *in
+place*, kit resolves `support/rt` relative to its own path. **(c)** trust
+self/exclusive `Ir`, not inclusive % (recursion in the recursive-descent parser
+makes `--inclusive=yes` double-count to absurd numbers). The Linux/ELF total is
+not comparable to the macOS/Mach-O hardware figure (different libc, sysroot,
+format; counts glibc + loader + the `-lc` probe) — the *distribution* is the point.
+
+**Code size** (`.text` machine code, the honest metric):
+
+```sh
+build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o ; build/release/kit size /tmp/k.o
+tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o ; build/release/kit size /tmp/t.o
+# per-mnemonic excess:
+hist(){ build/release/kit objdump -d "$1" | grep -E '^[[:space:]]+[0-9a-f]+:' \
+ | sed -E 's/.*\t([a-z][a-z0-9._]*).*/\1/' | grep -E '^[a-z]' | sort | uniq -c | sort -rn; }
+diff <(hist /tmp/k.o) <(hist /tmp/t.o)
+```
+
+**End-to-end correctness** (sqlite as a correctness test, both opt levels):
```sh
build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc
@@ -162,307 +219,144 @@ build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc
select sum(a)+sum(b), count(*) from t;" # -> 84|2
```
-**6. Inclusive instruction profile — Linux + `callgrind` (the "where do the
-instructions *go*" tool).** macOS `sample` (step 4) gives only **wall-clock
-self-time leaves** — it shows the single fat leaf (`lex_next`) and hides
-everything inclusive, so a router like `parse_stmt` or a per-symbol object-write
-loop is invisible. For inclusive, **instruction-grounded** attribution use
-**`valgrind --tool=callgrind`** on Linux: it counts every instruction (`Ir`,
-the same metric we trust) exactly and deterministically, and attributes it up
-the call tree. (This is what surfaced the `strtab_add` O(n²) — 31% of `-c`
-instructions — that `sample` could not see, because it never appears on the
-macOS Mach-O path.) **Linux is the profiling/comparison platform** for this
-reason; backport fixes to the Mach-O path where they apply.
-
-That O(n²) is **fixed**: the per-add `buf_flatten` + linear substring scan in
-each writer's `strtab_add` is now a shared `ObjStrtab` (obj.h/obj.c) — one
-contiguous buffer + an open-addressed hash, used by ELF/COFF/Mach-O alike (the
-dedup policy is identical because a string table is just bytes; sharing is a
-size optimization, not a format requirement). `obj_strtab_add` dropped from
-31.05% to **0.07%** self; the full sqlite `-c` to ELF fell **3.30 B → 2.04 B
-`Ir` (−38%)**. (Mach-O previously didn't dedupe at all and so had no O(n²);
-adopting the shared deduping builder shrank its object too.)
-
-Run it in the same arm64 container family as the hosted suite (no valgrind on
-macOS). The recipe, with its non-obvious gotchas baked in:
+**Synthetic scaling guard** (`make bench-cc`): sweeps each input dimension
+(`fn-count`, `body-size`, `global-decl`, `type-decl`, `locals-per-fn`, `pp-macro`,
+`pp-include`, `ref-density`, `obj-count`, `symbol-count`) over a geometric series,
+fits a scaling exponent (`p ≈ 1.0` goal, `p ≥ 1.4` flagged `SUPERLINEAR` — treat
+as a bug), samples hotspots, and records a clang reference. Output in
+`build/bench/cc/` (`scaling.md`, `hotspots.md`). Use it to catch a re-introduced
+O(n²); use the sqlite profile to decide *where* to optimize (the synthetic axes
+over-weight codegen vs real code). Sources of truth:
+`scripts/cc_bench_gen.py` (axis catalog), `scripts/cc_bench.sh` (harness),
+`scripts/cc_bench_report.py` (fit/verdicts).
+
+---
+
+## 4. Ideas for improvement (forward-looking, ranked)
+
+Ordered by instruction/byte-payoff × structural leverage. The architecture's asset
+— the `CgTarget`→`NativeTarget` polymorphism (one frontend; aa64/x64/rv64/rv32 +
+wasm + c_target + check backends, plus the opt-IR recorder at `-O1`) — **stays**.
+Everything below lives in one of: the front half, the type subsystem, *inside* the
+concrete `NativeDirectTarget`/`MCEmitter` (the -O0 register machinery + byte sink,
+not the swappable vtable), or the code-size track (which *bytes* the NDT emits).
+
+### 4.1 Compile speed (frontend-bound)
+
+1. **Symbol-binding cache on the interned `Sym`** — the highest-value front-half
+ brick. `scope_lookup` is an N-scope chain walk per identifier (`lang/c/parse/
+ parse.c`, ~12 call sites); tcc caches the binding on the interned token. A
+ `Sym`-keyed binding stack with push/restore on scope enter/exit makes resolution
+ a pointer load. Medium-high risk (save/restore + shadow/redef/typedef-vs-ident
+ discipline); byte-identical gate, full parse corpus.
+2. **Type subsystem (#2 self-`Ir` cluster, ~14 %).** Carry the *decoded* type
+ entry on the parser/operand slot so a predicate is a field read, not an
+ `id→entry` decode (`api_type_pred`, `cg_type_get`, `api_unalias_type` are the
+ residual hub). Collapse the duplicate `c_abi_type_info` (frontend) vs
+ `abi_cg_type_info` (backend) memos into one descriptor reader (needs a small
+ public `KitCompiler*→TargetABI*` accessor first). Audit `resolve_type` /
+ `type_cg_lower` for a re-resolve a per-`Type` cache would remove.
+3. **Token relay (lex+pp).** Replay macro bodies by **pointer-swap** when there's
+ no `##` (today `subst_phase2` copies the body; function-macro args take 3–4
+ copies/token) — set a `has_paste` flag at definition, replay the immutable body.
+ **Stop materializing non-directive newlines** as `Tok`s on the cc path (~51 % of
+ lexer outputs are newlines, drained downstream today; keep an `-E` variant that
+ emits them). Both byte-identical-gated; the newline one is medium-high risk
+ (cc-vs-`-E` split + directive-line contract).
+4. **`lex_open_mem` per-open work** (~6,000 opens: 3,140 files + 2,796 macro-paste
+ buffers). Re-profile *what* (field init, splice-fold control, `lex_catchup_
+ splices`) and cut per-open setup — especially whether `<paste>` buffers need a
+ full `lex_open_mem` at all.
+5. **`memset` / arena churn (~2–3 %).** Right-size per-expression / per-emit struct
+ zeroing (designated-init the per-op clears); audit per-statement/per-temp arena
+ allocation vs reuse. (`memset` here is explicit zero-init, **not**
+ `-ftrivial-auto-var-init` — proven by rebuilding with the flag off; attack call
+ sites, keep the hardening flag.)
+
+Near-dead-ends for *instructions* (revisit only under a cache-stall study, not
+expected to pay): the `pool_intern_slice` probe/insert side (the self-sufficient
+`{hash,sym}` slot measured ~0 `Ir`); `lex_next` itself (raw-cursor scanner is
+optimal); `aa_emit_mem` typed-store micro-levers.
+
+### 4.2 Code size (run-correctness-gated; multiplicative — each byte cut shrinks emit + objwrite + assemble together)
+
+1. **Spill reduction — the #1 remaining `.text` excess (+58 K).** The single-pass
+ NDT spills more than tcc keeps resident across statements and control-flow
+ joins. The no-new-analysis wins (dead-operand drop, materialize-before-flush)
+ are done; deeper residency = keeping a *live* value in a register across a join,
+ which is register allocation and out of simple single-pass scope. Pursue as a
+ separate `-O1`-style pass over the NDT, or a cheaper "pin the hot local across a
+ straight-line run" heuristic.
+2. **Positive-offset frame addressing** (kills the residual `sub xN,x29`+`movk`,
+ ~5 K). The machinery already exists — aa64 has a bottom-record layout
+ (`fp_at_bottom`, `frame_size−off`) + an `AAPatch` deferred-patch list, currently
+ gated to `-O1` known-frame. Extend to `-O0` by deferring per-slot offsets (emit
+ scaled `str`/`ldr` with an imm12 placeholder, patch `(N−off)>>scale` at
+ `func_end`; scaled imm12 reaches 32,760 B so the placeholder is always one
+ word). alloca-safe via a stable base anchor (the fp-at-bottom / a callee-saved
+ frame base plays tcc's stable-x29 role). Compile-speed risk (per-access
+ bookkeeping) — gate it.
+3. **Residual arg/value `mov`s (+32 K).** Forward-order materialization places
+ straightforward args directly in their ABI register; what's left is args it
+ can't place — a nested-call result already in x0, or a value spilled under
+ pressure. A per-arg **destination hint** (abstract arg-slot threaded
+ frontend→CG-API→backend, resolved to the physical reg) would let the producer
+ target the exact register in those cases. High plumbing, high correctness
+ surface; the win is the residual only.
+4. **Signed load-with-extend** `ldrb;sxtb`→`ldrsb` (the unsigned zero-extend cases
+ are done). Structurally **blocked**: needs a load-with-extend rider on
+ `MemAccess` (kit's CG integer types are sign-agnostic, so `nd_load` can't know
+ the value feeds a sign-extend) — a frontend widening signed load, not a register
+ rename.
+5. Smaller, self-contained: **indexed-addressing fold** (collapse base+index+disp
+ into one EA where a member offset currently forces an extra `add`),
+ **NOP/alignment-pad trimming**, **call-argument stack-spill** reduction.
+
+### 4.3 The end-state bet (large lift, optional)
+
+tcc's *shape*: a shared mutable token slot fed by both scanner and macro replayer
+(no per-stage `Tok` structs), identifiers resolved through symbol pointers cached
+on the interned token, a thin `SValue[]` the parser drives by calling emit
+directly. This is the "clean structural redesign" the project prizes and subsumes
+§4.1. **But** the value-stack→direct-emit vtable collapse is **explicitly out of
+scope** here: the two real indirect calls per primitive are the price of the
+seven-backend modularity and are kept. The seam-preserving items above plus the
+code-size track are the campaign from here; the ~2× headroom a full tcc-shape
+collapse might reach is not pursued.
+
+### Measured dead ends — do not retry
+
+- **16-byte `Tok`** (LocId side-table / register return): byte-identical but a net
+ `-c` regression (every token pays a LocId store+resolve that outweighs the
+ smaller copy; `-c` dominates and is loc-heavy).
+- **Fused lex→pp→parse pull pipeline**: the pull wrappers are only ~5 % of frontend
+ self-time; `pp_next`'s real cost (macro/directive logic) survives the rewrite.
+- **`nd_grow_*` non-zeroing**: uninit-read risk no sanitizer catches.
+- **Word-at-a-time intern hash · lower hash load factor · inline-prefix entry
+ cache**: all slower or negligible for the short identifiers real code uses.
-```sh
-podman run --rm --platform linux/arm64 -v "$PWD":/work:Z \
- docker.io/arm64v8/debian:bookworm-slim sh -c '
- set -eu; export DEBIAN_FRONTEND=noninteractive
- apt-get update -qq && apt-get install -y -qq clang lld make libc6-dev binutils valgrind perl
- cd /work
- make bin RELEASE=1 PROFILE=1 CC=clang AR=ar BUILD_DIR=build/linux-prof
- strip --strip-debug build/linux-prof/kit # see (b)
- cd tmp/projects/sqlite-amalg
- valgrind --tool=callgrind --cache-sim=no --branch-sim=no --dump-instr=no \
- --collect-jumps=no --callgrind-out-file=/work/build/linux-prof/cg.out \
- /work/build/linux-prof/kit cc -c sqlite3.c -lc -o /tmp/k.o # see (a)
- callgrind_annotate --threshold=95 /work/build/linux-prof/cg.out # self Ir + callers
-'
-```
+---
-Gotchas (each cost a round trip — do not relearn them):
-- **(a) Headers via `-lc`.** kit on Linux discovers the libc sysroot through its
- hosted profile, which `-lc` triggers even for `-c`; without it, `time.h not
- found`. **Use `bookworm` (glibc 2.36), not `ubuntu:24.04`** — glibc 2.39's
- `bits/math-vector.h` uses a vector-typedef attribute the C frontend rejects
- (`expected ';' after typedef`), aborting the compile mid-profile.
-- **(b) `strip --strip-debug` before profiling.** clang-14 emits DWARF5
- (`DW_FORM_addrx`/`rnglistx`) that bookworm's valgrind 3.19 chokes on
- ("unhandled dwarf2 abbrev form 0x25" -> fatal). callgrind builds its call graph
- from executed `CALL`s, not DWARF, and needs only the ELF **`.symtab`** to name
- functions — `--strip-debug` drops `.debug_*` but keeps `.symtab`. (A newer
- valgrind, e.g. ubuntu's 3.22, reads the DWARF — but then hits (a). Stripping is
- the portable fix.) **Strip in place**, do not copy the binary elsewhere: kit
- resolves its `support/rt` dir relative to its own path, so a copied binary
- fails `cc: support dir not found`.
-- **(c) Trust self/exclusive, not inclusive %.** `callgrind_annotate
- --inclusive=yes` double-counts around recursion cycles (the recursive-descent
- parser), printing absurd percentages (`lex_open_mem` at 14,000,000%). The
- self/exclusive ranking sums cleanly to 100% — use it; read inclusive only as
- "function X's subtree is hot," not a number.
-- callgrind is ~10x slower; the sqlite `-c` total (**1.75 B `Ir`**, Linux/ELF) is
- not directly comparable to the 1.91 B macOS/Mach-O hardware figure — different
- libc, sysroot, and object format, and callgrind also counts glibc + the loader
- + the `-lc` probe. The *distribution* is the point, not the absolute total.
-
-### Current callgrind distribution (self `Ir`, refreshed)
-
-`scripts/perf_callgrind.sh run` (after the frontend slimming on main + the
-`o0-codesize` track). Total **1.75 B `Ir`** (was 3.30 B before the strtab fix,
-2.04 B after). **Re-confirmed unchanged after the `o0-codesize` Lever 2 +
-single-pass follow-ons + Lever 1** (total 1.746 B; the same distribution, with
-`aa_emit_mem` now 1.0 % and a new ~0.8 % `api_sv_adjust_refs` from Lever 1's
-value-stack ref bookkeeping): the codesize levers shrink emitted *code* but not
-codegen's *compile-time* `Ir` — that is dominated by per-statement vtable dispatch
-(`nd_dst_reg`/`nd_dst_writeback`/`aa_emit_mem`), and each lever's producer-side CG
-cost (Lever 2's `api_temp_dead` checks, Lever 1's arg reversal + ref counting)
-adds back roughly what the smaller emit removed. Per-function self `Ir`
-(callgrind's `'N` inlined contexts merged):
-
-| self % | function | subsystem |
-|--:|---|---|
-| **16.9** | `lex_next` | scanner |
-| 5.0 | `src_next_raw_into` | preprocessor |
-| 4.5 | `lex_point_at` | scanner |
-| 4.4 | `pp_pull_into` | preprocessor |
-| 4.1 | `pool_intern_slice` | interning |
-| 3.1 | `api_unalias_type` | types |
-| 2.8 | `api_type_pred_bits` | types |
-| 2.0 | `finish_ident` | scanner |
-| 1.5 | `resolve_type` / `cg_type_get` (each) | types |
-| 1.3 | `cg_type_size` / `abi_cg_type_info` (each) | types |
-| 1.0 | `aa_emit_mem` | codegen |
-| 1.2 | `__GI_memset` | libc |
-
-Subsystem rollup (kit self `Ir`): **scanner ~24 %, types ~14 %, preprocessor
-~12 %, interning ~4 %, codegen/emit ~3 %.** This is the inclusive-instruction
-view's payoff: the macOS wall-clock `sample` (next section) shows `lex_next` at
-~72 % and the **type subsystem nearly invisible**, but callgrind reveals types as
-the **#2 self-`Ir` cluster** — a dozen O(1)-but-frequent per-query functions
-(`api_unalias_type`, `api_type_pred_bits`, `cg_type_get`/`cg_type_size`,
-`abi_cg_type_info`, `resolve_type`, `api_type_class`, `type_cg_lower`,
-`api_type_layout_get`). Codegen/emit is now only ~3 % self (the value-stack
-residency + `o0-codesize` work moved it off the hot path), consistent with the
-~15 % `-c` − `-fsyntax-only` slice (the rest of that slice is object-write).
-
-## Current state
-
-**Real-world compilation is frontend-bound, not codegen-bound.** The phase split
-(instructions: `-E` 0.93 B, `-c` 1.91 B) puts native codegen + emit + object-write
-at ~15 %; the frontend — preprocessor, lexer, interner, parser, semantic analysis,
-and the CG value-stack/type-lowering it drives — is the rest. (The synthetic
-`bench-cc` axes below over-weight codegen by construction; trust the sqlite profile
-for where to spend effort.)
-
-### Where the time goes (self-time, real sqlite `-c`)
-
-80-run merged `sample` on a `PROFILE=1` build, **after the raw-cursor scanner
-rewrite and the -O0 value-stack residency work**. The profile is **single-peaked**:
-the scanner dominates and everything downstream of it is flat. (`sample` is
-wall-clock self-time, which tracks the instruction metric for CPU-bound code
-but *not* for blocking I/O.)
-
-| function | self % | stage | note |
-|---|--:|---|---|
-| `lex_next` | **71.9** | lexer (the scanner) | dispatch + ws/comment skip + number/string/punct scan + the per-token intern calls (`scan_pp_number`/`scan_quoted`/`skip_ws_fast` inlined in) |
-| `pool_intern_slice` | 9.7 | identifier interning | one per ident/number; puncts cached |
-| `finish_ident` | 6.1 | lexer (identifier scan) | part of the scanner lever (`scan_ident_run` inlined in) |
-| `_platform_memset` | 6.1 | zeroing | callers are **parse + codegen**, not the lexer |
-| `src_next_raw_into` | 2.6 | preprocessor source stack | |
-| `pp_next_raw_into` | 2.6 | preprocessor token pump | |
-| `api_unalias_type` | 0.5 | types | the only codegen-side row still visible |
-| `_platform_memmove` | 0.5 | buffer moves | callers parse/codegen |
-
-The scanner (`lex_next` + `finish_ident`) is now **~78 %** of *wall-clock*
-self-time — up from ~66 %, because the value-stack residency work removed the
-codegen/emit self-time, not because the scanner got slower. **Codegen has dropped
-off this profile entirely** (the former ~1.6 % cluster — `nd_dst_reg`/
-`cg_type_is_aggregate`/… — is now <0.5 %), confirming the emit-phase shrink the
-instruction decomposition shows (0.37 → 0.28 B, the `o0-codesize` track shaving
-it below the value-stack-residency 0.29 B). The callgrind self-`Ir` view (§6)
-agrees: `aa_emit_mem` is ~1.1 %, codegen/emit ~3 % total.
-
-Three things that used to be hot remain absent: the **type system**
-(derived-type + ABI/record dedup are O(1)), the **preprocessor hideset** (O(1)
-content-addressed dedup), and **guarded-header re-lexing** (multiple-include
-optimization).
-
-> **⚠ Wall-clock self-time ≠ instruction attribution — read `PERF-TCC-GAP.md`
-> before deciding where to optimize for the tcc gap.** This single-peaked
-> `sample` profile makes the scanner *look* like the lone frontier, but it is a
-> wall-clock artifact of one big inlined leaf on a single-peaked workload. The
-> **instruction-grounded** decomposition (load-independent, the metric to trust)
-> tells a different story: `-E` 0.97 B, post-PP 1.16 B, with the gap to tcc
-> spread **~uniformly ~3× across lex+pp / parse+sema+CG / emit** — and kit `-E`
-> already *beats* `tcc -E`. The highest-leverage single track is **codegen
-> density** (the CgTarget→NativeTarget→MCEmitter vtable stack + the -O0
-> value/RA machinery; the home-round-trip half is now done, ~7× → ~5.9× tcc),
-> not the scanner. kit's IPC (3.70) is *higher*
-> than tcc's (3.48): the gap is **instructions, not cache stalls**. Full map,
-> per-phase diagnosis, and the redesign tracks: **`doc/plan/PERF-TCC-GAP.md`**.
-
-### Resolved — already optimal, don't re-propose
-
-- **Multiple-include optimization** — a controlling-`#ifndef` guard + `#pragma
- once` memo skips re-lexing a guarded header while its macro is defined. (Was
- ~37 % of scanned bytes: `sys/cdefs.h`-style headers re-lexed dozens of times.)
-- **Punctuator interning** — the spelling `Sym` is cached per lexer
- (`Lexer.punct_sym[]`, `punct_spelling()`); digraphs intern verbatim.
-- **CG-type lowering** — the lowered `KitCgTypeId` + the complete-record
- `type_unqual` are memoized on the pool.
-- **Preprocessor hideset** — O(1) content-addressed dedup; per-buffer scalar id
- (no per-token `HidesetId` array on the uniform macro-body path).
-- **Sym-centric macro + keyword tables** — flat `Sym`-indexed array loads, no hash.
-- **Raw-cursor scanner** — `lex_next` walks `cur`/`end` pointers (no per-byte
- `pos`/`len`/`col` struct round-trip), classifies via one `cclass[256]` load
- (ident-start/cont, digit, space), fuses the whitespace+comment skip into the
- table walk (a token with no leading space pays only a failed `CC_SPACE` test),
- and **defers the column** to `col = cur - line_start + 1`, computed once per
- built `SrcLoc` instead of per byte. Splices stay bit-exact: the splice-free
- common path pays nothing (no per-byte splice test); the splice-present path
- reconciles fold points with a `lex_catchup_splices` after each token body and a
- per-byte slow path for the one place `\n` and folds interleave (block
- comments). Flat ~84 M off every phase (`-E` −8.0 %), byte-identical (60/60 gate
- + block-comment×splice). The dispatch/classification is now table-driven; the
- residual `lex_next` self-time is the raw byte loads themselves + the interning
- handoff, so the remaining lever is (2).
-
-### Next levers (ranked) — see `PERF-TCC-GAP.md` for the structural roadmap
-
-The instruction-grounded decomposition (above callout) puts the tcc gap
-~uniformly across phases; the tracks below are ordered by instruction-payoff ×
-structural leverage. Full per-phase diagnosis + redesign plan in
-**`doc/plan/PERF-TCC-GAP.md`**.
-
-1. **Codegen density — collapse the -O0 emit stack** (Track 1, now ~5.9× tcc
- after the value-stack residency landing, still the worst ratio). The
- round-trip half of this track is **done** — the call/return/assignment home
- round-trips are gone (register-resident values + spill-only-live-set; see
- PERF-TCC-SLIM.md §10c), which is why codegen has dropped off the hotspot
- profile. What remains: the three stacked vtables (`CgTarget`→`NativeTarget`→
- `MCEmitter`, ~7–9 indirects per statement), the NDT LRU register-cache victim
- policy (a mini-RA at -O0), the fold/strength-reduce ladder on the -O0 hot
- path, the uncached recursive `abi_cg_type_info`, and the 56 B `ApiSValue`.
- Fuse the two native vtables into one direct emit path, memoize
- size/align/regclass on the type id, slim the fold-ladder *decision* (keep its
- byte-producing effect — see SLIM §8), shrink the value node. Couples with
- Track B (`o0-codesize`: fewer emitted bytes → less emit/objwrite).
-2. **Identifier interning** (`pool_intern_slice`, ~14 %). Per-occurrence
- re-hash+probe of hot identifiers; cache/defer the intern, and make the probe
- slot self-sufficient (`hash`+`len`+SSO inline) so it rejects without the
- random `entries[sym]` line. Word-at-a-time hashing was a measured dead end —
- this is a *layout* change, not a hash change.
-3. **Token relay density** (lex+pp): pack `SrcLoc`→32-bit so `Tok` is 16 B (33 %
- less copy bandwidth), collapse the 4-frame by-value relay into one inlined
- pull. (Pull-wrapper fusion alone was ~5 % — do it for the copy elimination.)
-4. **`memset`/`memmove` in parse + codegen** (~8 %, callers `cg_adapter` /
- `native` / `m_emit_bytes` / `parse_*`, **not** the lexer). Right-size the struct
- zeroing and buffer copies on the per-expression / per-emit path.
-
-## The synthetic scaling benchmark (`make bench-cc`)
-
-A complement to the sqlite profile: it isolates *one input dimension at a time*
-and proves each scales **linearly**. Use it to catch a re-introduced O(n²); use
-the sqlite profile to decide where to optimize (the synthetic axes over-weight
-codegen relative to real code).
-
-`make bench-cc` builds a `PROFILE=1` kit and runs `scripts/cc_bench.sh`, which:
-
-1. **Sweeps each axis** over a geometric size series, timing `kit cc -O0 -c` /
- `-E` and `kit ld` best-of-N (link axes pre-build objects untimed).
-2. **Fits a scaling exponent** (`cc_bench_report.py`): least-squares
- `log t = log a + p·log n` on overhead-subtracted times; `p ≈ 1.0` is the goal,
- `p ≥ 1.4` is flagged `SUPERLINEAR` (treat as a bug).
-3. **Samples hotspots** on the largest input of each axis.
-4. Records a **clang `-O0` reference** for each axis.
-
-### Components (single source of truth)
-
-| File | Role |
-|------|------|
-| `scripts/cc_bench_gen.py` | Axis catalog + synthetic source/object generator (`--list` prints axes). **All axis definitions live here.** |
-| `scripts/cc_bench.sh` | Harness: build/locate kit, measure overhead, sweep, time kit + clang, correctness-check, sample. Writes `scaling.csv`. |
-| `scripts/cc_bench_hot.sh` | Hotspot sampler: drives one axis at a size tuned for a ~1.5–3 s run so `sample` captures a real call graph. |
-| `scripts/cc_bench_report.py` | Exponent fit + verdicts + clang ratios → `scaling.md`; parses `sample` call-trees → `hotspots.md`. |
-| `scripts/cc_bench_stages.py` | Buckets each function's self-time into a pipeline **stage** (lexer/pp/intern/parser/types-abi/codegen/emit/arena/libc). |
-| `mk/maint.mk: bench-cc` | Builds the `PROFILE=1` kit and runs the harness. |
-
-### Axes
-
-Compile (`kit cc -O0 -c`, or `-E` for the pp axes): `fn-count` · `body-size` ·
-`global-decl` · `type-decl` · `locals-per-fn` · `pp-macro` · `pp-include` ·
-`ref-density`. Link (`kit ld`, objects pre-built untimed): `obj-count` ·
-`symbol-count`.
-
-### Output + env knobs
-
-Output in `build/bench/cc/`: `scaling.md` (linearity summary + kit/clang ratios),
-`hotspots.md` (top self-time per axis), `scaling.csv`, `raw/<axis>.sample.txt`.
-
-Env: `KIT` · `CLANG` · `KIT_CC_BENCH_AXES` · `KIT_CC_BENCH_SIZES` ·
-`KIT_CC_BENCH_REPEATS` (3) · `KIT_CC_BENCH_MAX_MS` (4000) ·
-`KIT_CC_BENCH_SKIP_CLANG` · `KIT_CC_BENCH_SAMPLE` (1) · `KIT_CC_BENCH_DTRACE`
-(needs sudo) · `KIT_CC_BENCH_OUT`. Quick wire-check:
-`KIT_CC_BENCH_SIZES='8 16 32' KIT_CC_BENCH_SAMPLE=0 make bench-cc`.
-
-Every axis currently scales **linearly** and kit is faster than clang `-O0` on
-all of them; that guarantee is the floor this benchmark defends.
-
-## Methodology notes
+## 5. Methodology notes
- **Measure on a RELEASE build** (`make bin RELEASE=1`, or `PROFILE=1` for
- sampling), never the default `make bin` (`RELEASE=0` → ASan/UBSan). ASan
- amplifies *memory*-op savings and dilutes *compute* savings, which can invert
- the apparent ranking. The byte-identity gate is build-mode-independent; the
- timer is not.
-- **`memset` is explicit zero-init, not `-ftrivial-auto-var-init`.** Proven by
- rebuilding with the auto-init flag off and re-sampling (self-time unchanged).
- Attack `memset` at call sites (designated initializers, right-sized
- allocations) — the hardening flag stays.
+ sampling), never the default `make bin` (`RELEASE=0` → ASan/UBSan): ASan
+ amplifies *memory*-op savings and dilutes *compute* savings, inverting the
+ ranking. Gates are build-mode-independent; timers are not.
- **Byte-identity gate tooling.** `scripts/perf_identity_gate.sh` (60-category
- byte-identical gate) and `scripts/perf_axis_time.py` (focused A/B axis timer).
- For lexer changes the diff must include a line-splice battery (mid-token /
- mid-string / leading / trailing / consecutive continuations) plus a diagnostic
- whose line number falls across a splice.
- - **A/B flow:** `make perf-golden` (snapshot the pre-change RELEASE build as a
- self-contained golden at `build/release/golden/kit`) → edit → `make bin
- RELEASE=1` (the candidate at `build/release/kit`) → `make perf-gate` (runs
- the gate golden-vs-candidate). The golden carries its own `support/rt`
- sibling (via the `%/support/rt` Makefile pattern rule), so both binaries
- resolve their runtime from any cwd — no copy into the checkout needed, and
- no "support dir not found" when measuring from `tmp/projects/sqlite-amalg`.
-
-## Dead ends (measured — don't retry)
-
-- **16-byte `Tok` (LocId side-table, register return).** Byte-identical but a net
- `-c` regression: the compile path is loc-heavy, so each token pays a `LocId`
- store (lexer) + resolve (parser) that costs more than the smaller copy saves.
- `Tok` is shared by `-c` and `-E`, and `-c` dominates. Do not revive.
-- **Fused lex→pp→parse pull pipeline.** The pull-wrapper layers are only ~5 % of
- frontend self-time; `pp_next_raw`'s real cost (macro/directive logic) survives
- the rewrite. Not worth it.
-- **`nd_grow_*` non-zeroing** — uninit-read risk no available sanitizer catches.
-- **Word-at-a-time intern hash · lower hash load factor · inline-prefix entry
- cache** — all slower or negligible for the short identifiers real code uses.
+ byte-identical gate, golden-vs-candidate) + `scripts/perf_axis_time.py` (focused
+ A/B axis timer). For lexer changes the diff must include a line-splice battery
+ (mid-token / mid-string / leading / trailing / consecutive continuations) plus a
+ diagnostic whose line number falls across a splice. Flow: `make perf-golden`
+ (snapshot pre-change RELEASE build) → edit → `make bin RELEASE=1` → `make
+ perf-gate`. The golden carries its own `support/rt` sibling, so both binaries
+ resolve their runtime from any cwd.
+- **Code-size gate** (for emitted-byte changes): determinism (compile twice +
+ `cmp`) + `make test-toy test-cg-api test-opt test-smoke-x64 test-smoke-rv64
+ test-parse-ok test-parse-err test-dwarf test-debug` + sqlite e2e at `-O0`+`-O1`
+ vs clang + per-arch clang-differential probes. **Verify shared-NDT changes on
+ x64 and rv64, not just aa64** — a register-residency change can be a win on the
+ reference arch and a silent miscompile elsewhere (x86-64 RAX is an implicit
+ div/mul operand; the result-stable capability is gated per-arch for this reason).
+- **Re-profile after each landing and re-rank** — the highest lever moves as work
+ is removed.
diff --git a/doc/plan/README.md b/doc/plan/README.md
@@ -10,8 +10,7 @@ shrinks to whatever remains open.
|---------|-------|------------|
| [RELEASE.md](RELEASE.md) | Cross-cutting initial-release punchlist: release scope, deferred features, and per-subsystem completion/validation items. | — |
| [OPTIMIZER.md](OPTIMIZER.md) | Completing the O2 SSA mid-end, expanded inlining, -O0/-O1 performance work, machine register-constraint improvements. | [../OPT.md](../OPT.md) |
-| [PERF.md](PERF.md) | Making kit the fastest `-O0` C compiler: the compile+link scaling benchmark (`make bench-cc`), per-axis linearity goals, hotspot sampling, and the open superlinear-axis fixes. | — |
-| [PERF-O0-CODESIZE.md](PERF-O0-CODESIZE.md) | Shrinking kit's `-O0` emitted machine code toward tcc (1.33× → parity): the opcode-histogram diagnosis, the frame-addressing tax, and the ranked levers — positive-offset frame addressing (Fix B), epilogue slim, load-with-extend, and the deferred `mov`/spill track. Companion to PERF-TCC-{GAP,SLIM}.md. | [../ARCH.md](../ARCH.md) |
+| [PERF.md](PERF.md) | Making kit the fastest `-O0` C compiler with code as dense as tcc: current compile-speed + code-size standings, how to reproduce them (macOS instruction counts, Linux callgrind, the `make bench-cc` scaling guard), and the ranked forward-looking levers on both axes. | [../ARCH.md](../ARCH.md) |
| [LINKER.md](LINKER.md) | Incremental linking: the file-based object-link redesign and remaining non-ELF format coverage. | [../LINK.md](../LINK.md) |
| [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) |
| [DEBUG.md](DEBUG.md) | The Windows debugger host adapter, x64/rv64 displaced single-step, profiling, and DWARF gaps. | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) |
diff --git a/scripts/perf_callgrind.sh b/scripts/perf_callgrind.sh
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# perf_callgrind.sh — measure kit's retired-instruction (`Ir`) cost compiling
-# sqlite3.c under Linux callgrind, the campaign's instruction metric (PERF.md §6).
+# sqlite3.c under Linux callgrind, the campaign's instruction metric (PERF.md §3).
# macOS has no valgrind, so this runs inside an arm64 Linux container (podman).
#
-# Codifies the recipe documented in doc/plan/PERF.md §6, including its gotchas:
+# Codifies the recipe documented in doc/plan/PERF.md §3, including its gotchas:
# (a) -lc so kit finds the glibc sysroot; bookworm (glibc 2.36) not ubuntu.
# (b) strip --strip-debug in place before profiling (valgrind 3.19 vs DWARF5).
# (c) trust self/exclusive Ir, not inclusive (recursion double-counts).
diff --git a/src/arch/riscv/native.c b/src/arch/riscv/native.c
@@ -639,7 +639,7 @@ static const NativeRegInfo rv_reg_info = {
* explicit operands (unlike x86-64's implicit-RAX div/mul), so a scalar
* call result can stay cached in a0/fa0 across the following straight-line
* ops and feed the next consumer with no mov — exactly as on aa64 (cf. the
- * aa64 ndt_result_reg_stable comment; doc/plan/PERF-O0-CODESIZE.md Lever 5).
+ * aa64 ndt_result_reg_stable comment; doc/plan/PERF.md §4.2).
* The NDT places the result directly in the ABI result reg post-call. */
.ndt_result_reg_stable = 1u,
.resolve_name = rv_resolve_name,