kit

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

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:

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):

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

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:

kit native emit + objwrite (0.37 B, ~7× — worst ratio) — native_direct_target.c, src/arch/aa64/native.c, src/arch/mc.c

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-1648aa64/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

Reproduce the decomposition

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)