kit

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

commit 2cd7230758eac2d58870f15f88b9a3b389961c6d
parent 15ebd192facfde7db02a13c2a01fbe86a9bfa913
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 18:50:27 -0700

doc(perf): structural map to close the tcc gap (PERF-TCC-GAP.md)

Measurement-grounded investigation of the 2.22B vs 0.66B (3.35x) instruction gap
to tcc on sqlite, with a four-strand code-path audit (kit token pipeline, kit
codegen, tcc baseline, kit memory density).

Headline findings (overturn the wall-clock "scanner is THE frontier" framing):
- kit IPC 3.70 > tcc IPC 3.48: the gap is INSTRUCTION COUNT, not cache stalls.
  Density helps by cutting instructions (copies/indirections/recompute), not by
  recovering memory stalls. RSS 70MB vs 14MB but no IPC penalty.
- Instruction-grounded phase split (by subtraction + preprocessed-.c isolation):
  lex+pp ~0.97B (~2.8x tcc), parse+sema+types+CG-drive ~0.87B (~3.5x), native
  emit+objwrite 0.37B (~7x). The gap is ~uniform ~3x across phases.
- Post-PP work alone (>= -c minus -E = 1.24B) is already ~1.87x tcc's ENTIRE
  compile, and kit -E already beats tcc -E. So the lexer is NOT the sole
  frontier; the post-PP pipeline must be attacked. The wall-clock 62% lex_next
  is a single-peaked-leaf artifact.

Per-phase structural diagnosis (file:line grounded) + tcc's single-pass model
(token=int+globals, SValue[] vtop++, direct backend calls, bytes into the final
section, int[] macro replay), then a ranked roadmap:
- Track 1 (biggest): collapse the CgTarget->NativeTarget->MCEmitter vtable stack
  at -O0, drop the NDT mini-RA, gate the fold ladder off, memoize size/align on
  the type id, shrink ApiSValue 56->24B.
- Track 2: token relay density (defer/cache interning, Tok 24->16B via packed
  SrcLoc, collapse the 4-frame pull).
- Track 3: interner slot self-sufficiency (hash+len+SSO inline).
- Track 4: end-state = adopt tcc's single-pass data-flow shape.

Updates PERF.md: adds a wall-vs-instruction warning callout under the self-time
table and re-ranks Next levers (codegen density #1), pointing to the new doc.

Diffstat:
Adoc/plan/PERF-TCC-GAP.md | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/plan/PERF.md | 58+++++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 290 insertions(+), 15 deletions(-)

diff --git a/doc/plan/PERF-TCC-GAP.md b/doc/plan/PERF-TCC-GAP.md @@ -0,0 +1,247 @@ +# 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.md b/doc/plan/PERF.md @@ -163,14 +163,24 @@ but *not* for blocking I/O — see the `fsync` row.) | codegen (`nd_dst_reg`/`cg_type_is_aggregate`/`api_unalias_type`/…) | ~1.6 total | native emit | each <1 % | | `_platform_memmove` | 0.3 | buffer moves | callers parse/codegen | -The scanner (`lex_next` + `finish_ident`) is **~66 %** of self-time — still THE -lever by a wide margin, and *more* concentrated than before the rewrite (the -dispatch/classification is now one table load, so the cost is the irreducible -per-byte cursor walk plus the per-token interning handoff). The next lever is -`pool_intern_slice` (interning), then the parse/codegen `memset`. 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). +The scanner (`lex_next` + `finish_ident`) is **~66 %** of *wall-clock* self-time. +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.24 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, ~7× 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 @@ -198,13 +208,31 @@ dedup are O(1)), the **preprocessor hideset** (O(1) content-addressed dedup), an residual `lex_next` self-time is the raw byte loads themselves + the interning handoff, so the remaining lever is (2). -### Next levers (ranked) - -1. **Identifier interning** (`pool_intern_slice`, ~15 %). Mostly fundamental - (identifiers must be interned); word-at-a-time hashing was a measured dead end - for short ids (see below). Now the top frontend lever after the scanner - rewrite, but a low ceiling. -2. **`memset`/`memmove` in parse + codegen** (~10 %, callers `cg_adapter` / +### 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, ~7× tcc, the + worst ratio). Three stacked vtables (`CgTarget`→`NativeTarget`→`MCEmitter`, + ~7–9 indirects per statement), the NDT LRU register-cache running as 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, gate the fold ladder off at -O0, shrink the value node. Highest + single lever; 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.