kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

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:
Ddoc/plan/PERF-IDEAL.md | 682-------------------------------------------------------------------------------
Ddoc/plan/PERF-O0-CODESIZE.md | 541-------------------------------------------------------------------------------
Ddoc/plan/PERF-TCC-GAP.md | 247-------------------------------------------------------------------------------
Ddoc/plan/PERF-TCC-SLIM.md | 467-------------------------------------------------------------------------------
Mdoc/plan/PERF.md | 704++++++++++++++++++++++++++++++++++---------------------------------------------
Mdoc/plan/README.md | 3+--
Mscripts/perf_callgrind.sh | 4++--
Msrc/arch/riscv/native.c | 2+-
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,