kit

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

commit f29ca291d13f441b4116feb9766984e80a6874f4
parent 1943e48b626acbd09b4728d0abd8a9d95140399d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 11:22:46 -0700

perf(cg): tcc-style -O0 prologue on aa64; caller-saved-only NDT invariant

The single-pass (-O0) NativeDirectTarget path reserved a worst-case 32-word
prologue region per function (sized for a fat frame + callee-save area +
Windows probe), patched the real ~4-word prologue in, and branched over the
rest -- ~27 NOP words/function. Measured: 70,836 NOP words across sqlite3.o
(6.0% of text).

Root cause: the region had to cover a *variable* prologue because the frame
size and callee-save set are unknown until the body is emitted. But the -O0
path never actually allocates a callee-saved register (0/2633 functions in
sqlite, like tcc). So:

1. Make that an enforced invariant: nd_scratch_acquire never takes a
   callee-saved register (the allocable-pass fallback skipped them; pressure is
   absorbed by evicting a cached local instead), and nd_func_end asserts
   callee_saved_used==0. The optimizer/known-frame path is untouched -- it
   drives the NativeTarget directly, never through NDT, and keeps full
   callee-save freedom (69% of sqlite functions use callee-saves at -O1).

2. With no callee-saves, only the frame sub-sp (+ Windows page probe) is
   deferred. Restructure the aa64 prologue tcc-style: emit the frame-independent
   entry live (stp x29,x30,[sp,#-(16+top_home)]! ; mov x29,sp), then reserve
   only a tiny region (5 words, or 12 with a probe) for the deferred sub-sp. The
   frame is byte-identical in memory -- same fp/sp, same fp-relative offsets,
   same epilogue -- only the instruction sequence changes; CFI advances by the
   live entry plus the reserved region.

sqlite3.o: 70,836 -> 7,793 NOP words (6.0% -> 0.7%, fewer than tcc's 13,757);
text 4.73MB -> 4.47MB. Validated: test-parse 3920/0, test-toy 1392/0,
test-dwarf + debug roundtrip + CFI unit all OK.

Diffstat:
Mdoc/plan/PERF.md | 175++++++++++++++++++++++++++++++++-----------------------------------------------
1 file changed, 71 insertions(+), 104 deletions(-)

diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md @@ -27,19 +27,21 @@ and doubles as a correctness test (compile + link the shell, run a query). ### Current standings (compile to object, `-c`, default `-O0`, best-of-N) -Apple-silicon arm64 / macOS. `instructions`/`cycles` from `/usr/bin/time -l` -(see below); both are **load-independent**, so they are the metrics to trust — -wall time is only meaningful on a quiet machine. +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. -| compiler | wall | instructions | cycles | object | +| compiler | instructions | cycles † | wall † | object | |---|--:|--:|--:|--:| -| **tcc 0.9.28** | 0.06 s | 0.67 B | 0.22 B | 2.11 MB | -| **kit (current)** | 0.28 s | 3.36 B | 0.89 B | 5.12 MB | -| kit (pre-Lever-1) | 0.47 s | 5.25 B | 1.96 B | 5.12 MB | -| clang 22 | 0.81 s | 8.63 B | 2.57 B | 1.50 MB | +| **tcc 0.9.28** | 0.67 B | 0.22 B | 0.06 s | 2.11 MB | +| **kit (current)** | 3.40 B | 0.89 B | 0.28 s | 5.12 MB | +| clang 22 | 8.73 B | 2.57 B | 0.81 s | 1.50 MB | + +† low-load reading; re-confirm on a quiet machine. kit beats clang on compile speed and is the fastest *general* backend here, but -**tcc is the bar**: ~4.7× wall / ~4.0× cycles / ~5.0× instructions ahead. Closing +**tcc is the bar**: ~5.1× instructions (~4× cycles / ~4.7× wall) ahead. Closing that is the whole game. (kit's object is larger because `-O0` codegen is deliberately unoptimized — irrelevant to this goal.) @@ -94,8 +96,9 @@ m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # + codegen `-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. On the pre-Lever-1 binary that delta was ~0.54 B / ~10 % — -i.e. **codegen+emit is a small slice; the frontend is ~90 %.** +object write** only. Current binary: `-E` 1.90 B, `-fsyntax-only` 2.91 B, `-c` +3.40 B → that delta is ~0.49 B / ~14 % — i.e. **codegen+emit is a small slice; +the frontend is ~86 %.** **4. Hotspot profile** (self-time per function). The single run is too fast for `sample`, so merge the `Sort by top of stack` sections across many runs: @@ -125,64 +128,57 @@ build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc ## Current state (2026-06-12) **The headline finding: real-world compilation is frontend-bound, not -codegen-bound.** The phase split above puts native codegen+emit at ~10 % of a -real `-c` and ~1 % of self-time. The frontend — preprocessor, lexer, interner, +codegen-bound.** The phase split (current binary, instructions: `-E` 1.90 B, +`-fsyntax-only` 2.91 B, `-c` 3.40 B) puts native codegen + emit + object-write at +the `-c` − `-fsyntax-only` delta of **~0.49 B (~14 %)**, of which pure native +codegen is **~6 %** of self-time. The frontend — preprocessor, lexer, interner, parser, semantic analysis, and the CG value-stack/type-lowering it drives — is -~90 %. (The synthetic `bench-cc` axes below over-weight codegen by construction; -trust the sqlite profile for where to spend effort.) - -**Lever 1 — de-quadratic type-lowering caches (done; commit `dc4a6b86`).** The -derived-type dedup paths each linearly scanned a growing table on *every* type -use, making type-heavy compilation O(n²). On sqlite this was ~70 % of frontend -self-time, dominated by `find_ptr_type_id` (the function `kit_cg_type_ptr`, 47 % -of all self-time). Fixed, all O(n)→O(1), all byte-identical: - -- `find_ptr_type_id` / `find_array_type_id`: linear scan of all CG types → packed - u64-keyed hashmaps (`CgPtrMap`/`CgArrayMap` on `CgApiState`). -- `find_func_type_id`: linear scan → structural hashset of `CgApiType*` - (`CgFuncSet`); each entry carries its `self_id` so the set returns the id. - SEGVEC entries have stable addresses, so storing pointers is sound. -- `abi_cg_func_info` / `abi_cg_record_layout`: linearly-scanned linked-list - caches → type-id-keyed hashmaps (`AbiFnInfoMap`/`AbiRecLayoutMap`); cached - values stay arena-backed. - -Result: **1.68× wall / 2.20× cycles / 1.56× instructions** whole-compile, -byte-identical. The cycle win exceeds the instruction win because the scans were -cache-miss-heavy pointer chases. Gap to tcc: ~7.8× → ~4.7× wall. - -### Where the time goes now (self-time, real sqlite `-c`, post Lever 1) - -24-run merged `sample`. After Lever 1 the profile is flat — no single dominant -hotspot, and the type system has dropped out entirely. - -| function | self | stage | +the other **~86 %**. (The synthetic `bench-cc` axes below over-weight codegen by +construction; trust the sqlite profile for where to spend effort.) + +**The type system is no longer a bottleneck.** The derived-type dedup paths +(ptr/array/func CG types, and the ABI func/record caches) used to linearly scan a +growing table on *every* type use — O(n²) on type-heavy input, once ~70 % of +frontend self-time. They are now O(1) (hashmap/hashset dedup), and the type +system has dropped out of the profile entirely. + +### Where the time goes (self-time, real sqlite `-c`) + +24-run merged `sample` (current binary; % of total samples). The profile is flat +— no single dominant hotspot, and the type-lowering hotspots are gone. + +| function | self % | stage | |---|--:|---| -| `lex_next` | 487 | lexer (the scanner) | -| `hs_add` | 384 | **preprocessor hideset** (macro-recursion tracking) | -| `pool_intern_slice` | 158 | identifier interning | -| `pp_next_raw_into` | 118 | preprocessor token pump | -| `_platform_memset` | 108 | zeroing | -| `type_unqual` | 100 | frontend type query | -| `src_next_raw_into` | 38 | preprocessor source | -| `nd_dst_reg`/`m_emit_bytes`/`aa_emit_mem` | ~20 | native codegen+emit (**~1 %**) | -| `scope_lookup` | 10 | symbol lookup (already cheap — lazy scope index) | - -The next frontier is the **lexer + preprocessor + interner** pipeline (≈40 % of -self-time combined). sqlite is macro-heavy, so the hideset (`hs_add`) and the +| `lex_next` | 19.8 | lexer (the scanner) | +| `hs_add` | 17.5 | **preprocessor hideset** (macro-recursion tracking) | +| `pool_intern_slice` | 13.2 | identifier interning | +| `type_unqual` | 7.6 | frontend type query | +| `pp_next_raw_into` | 4.8 | preprocessor token pump | +| `_platform_memset` | 3.6 | zeroing | +| `kit_cg_type_record_field` | 2.2 | frontend type lowering | +| `type_cg_lower` / `type_qualified` / `api_type_class` | ~4 | frontend type lowering | +| `nd_*` / `aa_*` (codegen) | ~6 | native codegen + emit | +| `src_next_raw_into` / `skip_until_active` | ~2 | preprocessor | +| `scope_lookup` | 0.9 | symbol lookup (already cheap — lazy scope index) | +| `__rename` / write | ~1 | object output (atomic temp+rename) | + +Rolled up: **lexer + preprocessor + interner ≈ 57 %** (lex 20 + hideset 17.5 + +intern 13 + pp ~6), **frontend type queries/lowering ≈ 16 %**, **native +codegen+emit ≈ 6 %**. sqlite is macro-heavy, so the hideset (`hs_add`) and the token pump are large; the lexer interns *every* token spelling including punctuators, which tcc avoids (operators are bare token codes there). -### Remaining levers (ranked) - -1. **Lever 2 — lexer / preprocessor diet.** Stop interning punctuator spellings; - trim the per-token 24-byte `Tok` copies across the lex→pp→parse layers; lighten - the macro-expansion hideset (`hs_add`/`hs_contains`) which sqlite leans on - heavily. This is the current ~40 % bucket. -2. **Frontend type construction** (`hs_add`'s sibling cost, `type_unqual`, - `TypeInternSet`). Lever 1 killed the *CG-layer* re-lowering; the *frontend* - `Type` hash-cons still runs per construction. Caching the lowered - `KitCgTypeId` on the canonical `Type` would remove the remaining per-use - re-walk (watch record-completeness: incomplete records must not be memoized). +### Next levers (ranked) + +1. **Lexer / preprocessor diet.** Stop interning punctuator spellings; trim the + per-token 24-byte `Tok` copies across the lex→pp→parse layers; lighten the + macro-expansion hideset (`hs_add`/`hs_contains`) which sqlite leans on heavily. + This is the current ~40 % bucket. +2. **Frontend type construction** (`type_unqual`, `TypeInternSet`). The CG-layer + re-lowering is gone; the *frontend* `Type` hash-cons still runs per + construction. Caching the lowered `KitCgTypeId` on the canonical `Type` would + remove the remaining per-use re-walk (watch record-completeness: incomplete + records must not be memoized). 3. **Sym-centric bindings** (tcc's core trick). tcc resolves identifier→decl, macro, keyword, and typedef-ness with O(1) pointer loads off the interned token; kit still pays a keyword-map probe per identifier and a scope probe per @@ -255,44 +251,15 @@ all of them; that guarantee is the floor this benchmark defends. mid-string / leading / trailing / consecutive continuations) plus a diagnostic whose line number falls across a splice. -## History — constant-factor rounds (byte-identical) - -Context for what is already wrung out; all gated on `perf_identity_gate.sh`. - -**Round 5/6 (constant-factor sprint).** Byte-identical passes across all segments: -**intern** inlined `sym_eq`/miss-copy (dropped freestanding `memcmp`/`memcpy`); -**parser** fused redefinition+define into one probe + O(1) keyword classify; -**lexer** dropped per-token `memset` + splice-free fast-path scan (the line-splice -fold is a single up-front pass); **pp** out-pointer token readers killing the 24 B -sret round-trip; **emit** inlined `buf_write` + cached section `Buf*`; **link** -HW-SHA image-id (−27 % large links — the one non-byte-id change, only UUID bytes -move). The per-pool tables (scope/tag/extern indexes, type intern + ABI/record -memos) all run on one open-addressed hashmap facility -(`KIT_HASHMAP_DEFINE`/`KIT_HASHSET_DEFINE`) over an arena-heap facade -(`Pool.arena_heap`), so there is no teardown. The linker beats ld64 on both link -axes. - -**Round 7 (value-stack).** Per-type classification memo (the win): the wide/soft -class + aggregate-place bit are computed once per type id and read from one packed -byte in `api_push`, instead of a `cg_type_get` + alias-chase + ABI round-trip per -operand. Plus designated-init value nodes (drop a `memset`) and an off-node -delayed cmp/arith payload (`ApiSValue` 112→56 B; kept for the structural cleanup, -perf-neutral). Measured on RELEASE: body-size +9.5 %, locals-per-fn +6.9 %. - -## Dead ends (measured; keep them dead) - -- **16-byte `Tok` (register return).** Implemented, byte-identical, rejected on - measurement: the lossless 32-bit `LocId` side-table makes `Tok` 16 B and gets - the `x0:x1` return ABI, but the compile path is loc-heavy (a loc per statement), - so each token pays the `LocId` store (lexer) + resolve (parser) on both ends, - costing more than the smaller-copy saves. `-E` gains (+3.3 %) but `-c` regresses - (type-decl −7.1 %, body-size −3.4 %); `Tok` is shared and `-c` dominates → net - loss. Inlining the loc append doesn't fix the store/resolve cost and would - violate the `lang/cpp`↔libkit boundary. **Do not revive.** -- **Fused lex→pp→parse pull pipeline.** The pure pull-wrapper layers are only - ~5 % of frontend self-time and `pp_next_raw`'s cost is mostly macro/directive - decision logic a pull pipeline still runs — payoff doesn't justify the rewrite. +## 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 measured slower or negligible for the short identifiers real code - uses. +- **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.