Performance: the fastest C compiler
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 instructions/bytes go, remove them.
Two rules hold everywhere:
- 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+-O1vs 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): 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
Compile speed — the open frontier (~2.6× tcc)
Apple-silicon arm64 / Darwin 25.3, refreshed 2026-06-15. instructions is the
metric to trust — load-independent (/usr/bin/time -l, best-of-7);
cycles/wall are low-load readings shown only for context. The kit row is after
the CG-type-interaction pass + the arena geometric/retain rework (see §3). Each
perf step left the sqlite object byte-identical, but the object has grown to
1.75 MB (from ~1.70 MB) because aa64 codegen correctness fixes landed in the
same window — the far-slot address-build fallback and the aggregate-return place
fix — which add a fixed per-function reserved patch region (see Code size below).
| compiler | instructions | cycles † | wall † | object |
|---|---|---|---|---|
| tcc 0.9.28rc | 0.663 B | 0.190 B | 0.06 s | 2.11 MB |
| kit | 1.744 B | 0.522 B | 0.16 s | 1.75 MB |
| Apple clang 21 | 8.824 B | 2.775 B | 0.88 s | 1.50 MB |
† low-load; instructions is the figure to trust. kit instructions over the
CG-type-interaction pass: 1.939 B → 1.864 B (slot-query dedup + memory-align
contract + builtin ABI-layout cache + resolve/predicate fast paths) → 1.760 B
(abi_cg_type_info inline builtin fast path + by-pointer layout cache) =
−179 M total (−9.2 %); then the arena geometric-block/retain-on-reset rework
- the delayed-arena block-size fix shaved another −16 M → 1.744 B, all byte-identical (each perf step left the sqlite object bit-for-bit unchanged — the object's growth to 1.75 MB is the separate codegen correctness fixes noted above).
kit beats clang and is the fastest general backend, but tcc is ~2.6×
(instructions) ahead — this is the whole game. The gap is instructions, not
cache: kit's IPC (3.3) is on par with 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.
Lean-token lexer/PP rewrite (landed, byte-identical, net win). The lexer/preprocessor boundary was rewritten to the
doc/plan/LEX-PP-API.md"lean token" design (compactLocRefbyte-offset locations materialized on demand;TextRefspellings; slot-based pulls). sqlite-E,-cobject,-g,-S, and diagnostics are bit-for-bit identical to the pre-rewrite snapshot (perf-gategreen incl. the splice/#line/diagnostic battery), so code size is unchanged. The boundary-only landing was initially a −3.35 %-cregression — lazy line/col was a loss because line numbers are needed in the happy path anyway (sqlite expands__LINE__pervasively, and the parser stamps a CG loc per statement) and the first cut built the line index with a scalar\nrescan + a per-loc binary search. Fixing the implementation of laziness (build the index with memchr; cursor the materializer — §4.1) turned it into a campaign-local −24 M-cwin (1.865 B → 1.841 B), since the scanner no longer builds aSrcLocper token. The remaining ~+19 M parse+CG residual (per-statement loc lookups the old eager-on-token loc got for free) is the optional Stage-2 target in §4.1.
Code size — below tcc (0.986×)
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,352,356 B / 338,089 4-byte slots | 1,370,940 B / 342,735 4-byte slots | 0.986× (−4,646 slots) |
kit still clears the code-size bar overall, but the margin shrank from 0.951×:
the recent aa64 far-slot/large-frame fix reserves a branched-over 5-instruction
patch region in every qualifying function's prologue (a b over five nops
when the long address-build path is unused), and 2,587 functions pay it. That
alone adds **+12,897 52 KB)** — i.e. the entire nops (.text growth is this
padding; net of nops, real instructions actually shrank slightly. The genuine
excess remains the same instruction families as before (per-mnemonic, kit − tcc):
| heavier in kit | Δ | what it is |
|---|---|---|
stur+ldur+str (spills) |
+47,095 | #1: register pressure — the single-pass NDT spills more than tcc keeps resident across statements / control-flow joins |
mov |
+13,666 | residual arg / value-stack copies the arg0-first placement can't reach (nested-call results already in x0, pressure spills) |
nop |
+12,897 | new — branched-over 5-nop patch region reserved per qualifying function by the far-slot/large-frame fallback; likely over-reserved (see §4.2) |
sub+movk |
+3,474 | residual far-frame addressing (byte/half slots, &local in big frames) |
…offset by where kit already beats tcc (structural wins — do not touch):
ldr −24,214 (far slots fold into [sp,#scaled]), add −20,899 (folds offsets
into displacements), cset −17,226 / cbnz −10,817 / cmp −9,421 (kit fuses
compares straight into cbz/b.cc; tcc materializes a bool then tests — the cost
side is cbz +6,294 and the b.eq/b.ne/b.ge/b.le/b.lt family +6,936,
still a large net win), movn −6,720 / movz −4,644 / stp −3,722. The mnemonic
histogram nets −5,058 decoded instructions; kit also has 412 4-byte padding
slots, so the byte metric nets −4,646 slots — the +12,897 reserved-patch
nops are what eroded the old −16,897.
2. Where the cost is (current profile)
Compile is frontend-bound — lex+pp ~2.0× tcc, post-PP ~3.6× tcc
Phase split measured directly (instructions, best-of-7), not estimated from -E.
-E is a bad lex+pp proxy: it re-serializes the token stream to text, work
neither compiler does at -c, and the two serialize at wildly different cost
(tcc's -E 1.17 B is ~0.79 B serialization via get_tok_str re-stringify +
whitespace logic, so tcc -E even exceeds tcc -c; kit's -E serializes for
~0.06 B since tokens carry TEXT_SRC spans → emit is a memcpy). To isolate
lex+pp we drain the token stream to EOF with no parse and no output in both
compilers (tcc: -bench hook, patched to use -c parse_flags; kit:
KIT_PP_DRAIN, see §3):
| phase | kit -c |
tcc -c |
kit / tcc |
|---|---|---|---|
| lex + pp | 0.80 B | 0.40 B | ~2.0× |
| parse + sema + types + CG-drive | 0.71 B | ┐ 0.26 B | — |
| native emit + object write | 0.24 B | ┘ (post-PP) | — |
| post-PP total | 0.94 B | 0.26 B | ~3.6× |
| total | 1.74 B | 0.66 B | ~2.6× |
So both halves are real frontiers — lex+pp is not at parity (an earlier
claim from the misleading -E proxy was wrong). The post-PP ratio is larger, but
lex+pp is ~46 % of kit's -c and a clean 2.0× behind. Note kit's lex+pp drain
does less than tcc's — kit defers number/string decode to the parser (tcc decodes
in the lexer; bare tcc scan+expand is 0.38 B, +decode 0.40 B) — yet is still ~2.0×
heavier, so the gap is pure per-token engine overhead, not extra work:
- tcc: one mutable global token (
int tok+CValue tokc+tok_flags) — the lexer and macro replayer write it in place; no per-token struct is built or copied. kit writes a 32-byteTok(kind/flags/aux/LocRef/TextRef) per token and copies it lex→pp→parser through slots. - tcc: no per-token location (diagnostics read a
file->line_numcounter); kit stores aLocRefper token (cheap, but ≠ free) and the pp checks it. - tcc: macro bodies are
int[]token streams replayed via amacro_ptrcursor with no hidesets (a cheap nested-macro guard); kit replaysTok[]with a Prosser hideset side-channel (hash-consed ids, union/dedup per expansion). - tcc:
TokenSymcaches directSym*pointers (define/ident/struct/label) on the interned token, so define-lookup is a pointer-follow; kit re-probes (pool_intern_slice3.9 %, the Sym-indexed macro table 1 load). - tcc: one
next()does lex + pp + feeds the parser — no inter-stage boundary; kit layers lex → pp (source stack,src_next_raw_into→pp_pull_into) →pp_next_parse→ parser, and the pp re-checks directive/defined/dynamic-macro state per token. This is the [[frontend-instruction-halving-pathb]] lever: fewer per-token ops, not less work.
The post-PP 3.6× is the bigger slice: tcc drives a thin SValue[] straight into a
one-pass emitter, while kit routes through the CgTarget→NativeTarget→MCEmitter
seam and a richer type/ABI layer (§4). Closing either is a campaign, not one hot
function.
In the lean-token rewrite + loc-materialization fix A/B (golden→candidate,
best-of-7): lex+pp −44 M (the scanner no longer builds a SrcLoc per token —
loc is a byte offset), parse+sema+CG +19 M (the residual: a per-statement
cursor lookup at pcg_set_loc the old eager-on-token loc got for free), emit
flat → −24 M total (−1.3 %).
Linux callgrind (inclusive, instruction-grounded — the tool that sees what
wall-clock sample hides; total 1.547 B Ir, glibc/ELF; self-Ir summed
across callgrind's '2 symbol splits). This is after the CG-type-interaction
pass (slot-query dedup + memory-align contract + builtin ABI-layout cache +
resolve/predicate fast paths + the api_type_layout_get follow-up) and the
arena geometric/retain rework: the type cluster went 1.741 B → 1.608 B →
1.566 B (−175 M, −10.1 %), then the arena change shaved heap/memset churn to
1.547 B (−19 M), all byte-identical for the perf steps (perf-identity gate
green on every non--g category; the Linux/ELF total is not comparable to the
macOS hardware count — different libc/format — but the distribution is the point).
| self % | function(s) | subsystem |
|---|---|---|
| 12.3 | lex_next |
scanner |
| 6.4 | malloc |
hosted heap |
| 4.5 / 4.4 | src_next_raw_into / pool_intern_slice |
pp + interning |
| 3.9 | pp_pull_into |
preprocessor |
| 2.8 | finish_ident |
scanner |
| 2.0 / 1.6 / 0.3 | __GI_memset / __GI_memchr / __GI_memcpy |
libc memory |
| 1.9 / 1.8 | abi_cg_type_info / api_type_pred_bits |
types/ABI |
| 1.6 / 1.3 / 1.2 | pp_materialize_loc / api_const_from_sv / api_sv_adjust_refs |
lazy-loc / value-stack |
| 1.2 | arena_alloc |
arena bump path |
| 1.1 / 1.0 / 1.0 / 0.4 | cg_type_get / api_type_layout_ref / api_type_class / resolve_type |
types |
| 1.1 | aa_emit_mem |
codegen |
| 0.78 (Σ) | kit_cg_slot_lang_type 0.53 / kit_cg_slot_lang_flags 0.16 / kit_cg_slot_cg_type 0.09 |
CG slot queries (zero-copy accessors) |
What moved (vs the pre-pass profile above each arrow):
kit_cg_slot_info 3.2 % → gone (the by-value struct copy is retired; the C
frontend now reads single facts through the narrow accessors above, Σ 0.75 %),
cg_type_get 3.0 → 1.1, resolve_type 1.8 → 0.4, __GI_memset 3.0 → 1.9. The
ABI-layout lookup was first consolidated into api_type_layout_get (1.3 →
3.9 in the intermediate profile) — one memoized indexed load per type for
builtins and user types instead of the abi_cg_type_info_compute →
cg_type_get → per-call resolve_type spread — then the follow-up retired
that 5-field out-param marshalling: api_type_layout_ref returns a borrowed
pointer to the cached ABITypeInfo, and abi_cg_type_info resolves a builtin
with one inline indexed load off Compiler.cg_builtin_layout (no cross-TU
call). Combined abi_cg_type_info + layout cache: 5.5 % → 2.9 % (−43 M, the
whole program-total drop this step); api_type_layout_get 3.9 → _ref 1.0,
abi_cg_type_info 1.6 → 1.9 (it absorbed the inline load). Net of the targeted
leaves: 15.0 % → ~9 %.
Subsystem rollup: scanner ~15 % (lex_next+finish_ident), pp+interning
~13 % (src_next_raw_into+pp_pull_into+pool_intern_slice), CG/types/ABI
metadata ~9 % (down from ~15 %), libc allocation/memory helpers ~10 %, lazy-loc
(pp_materialize_loc) ~1.6 %, arena bump path (arena_alloc) ~1.2 %, direct
codegen emit ~1 %. The type layer is no longer a top-tier frontier — the remaining
leaves (abi_cg_type_info register-struct returns, api_type_pred_bits,
cg_type_get) are already at/under ~2 % and were explicitly not micro-optimized.
The next frontiers are the scanner (lex_next ~15 %) and the hosted heap (malloc
~6 %, call frequency already addressed by the arena rework — see below). Trust
callgrind for where; trust macOS instructions for how much.
Allocator (hosted heap). malloc is 6 % of 4,278 calls**
for the whole sqlite TU (Ir but only **KIT_METRICS=1 heap counters): the cost is in large,
churned blocks, not call frequency. Most allocation never reaches the heap —
arenas bump-allocate, and the variable-count structures (interner table/entries,
vectors, segvecs) already grow geometrically (351 reallocs total, all grows).
The one gap was fixed-size 64 KiB arena blocks. The arena now (a) grows
blocks geometrically (64 KiB doubling to a 1 MiB cap, so a large arena needs
O(log n) heap calls not O(n)) and (b) arena_reset retains the high-water
blocks — it rewinds the bump cursor and frees nothing; only arena_fini
returns memory, so reset/refill cycles (per-statement fold, per-function MC,
per-expansion pp scratch) reuse their blocks with zero heap traffic. Effect on
the sqlite compile (KIT_METRICS): large (≥32 KiB) block allocs 440 → 124
(−72 %), total allocs 4,278 → 3,967, frees 4,571 → 4,260; macOS instructions
−0.22 %, byte-identical (isolated golden-vs-candidate). Small on the
instruction metric (Apple malloc is cheap) but it corrects the reset-vs-free
semantics and cuts heap pressure/fragmentation. Heap counters reuse the
KitProfiler machinery (embedder counter range), opt-in via KIT_METRICS=1.
SQLite -O1 optimizer profile (Linux callgrind, self Ir,
optimizer-finalize only). Full sqlite -O1 callgrind collection on the default
2 GiB Podman VM overflows Valgrind's brk segment and dies with rc 137, so the
useful profile toggles collection at opt_on_finalize and forces glibc malloc
onto mmap (MALLOC_MMAP_THRESHOLD_=1 MALLOC_ARENA_MAX=1). That captures the
whole-program O1 sweep (reachability/internalization, CGIR lowering, inlining,
per-function O1 pipeline, native emit) but excludes frontend recording, so it
is a distribution profile, not a total comparable to the -O0 full-compile
profile above. Command and raw output are in
build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.*.
Profiled command:
kit cc -O1 -c sqlite3.c -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -lc.
Total collected inside opt_on_finalize: 15.496 B Ir.
| self % | function(s) | subsystem |
|---|---|---|
| 24.5 | opt_regalloc_place |
physical register placement |
| 18.7 | opt_func_from_cg_ir |
CGIR -> optimizer Func lowering |
| 7.8 | alloc_assign_group_hard |
hard-register assignment grouping |
| 7.4 | ir_note_emit |
IR/lowering record construction |
| 4.5 | opt_build_loop_tree |
loop analysis |
| 3.2 / 1.7 / 1.6 / 1.5 | opt_bitset_has / opt_bitset_union / opt_bitset_copy / opt_bitset_union_and_not |
liveness/range bitsets |
| 2.4 | opt_live_ranges_build |
live-range construction |
| 2.1 / 0.8 | msort_with_tmp / u32_cmp |
sorting in optimizer hot paths |
| 1.6 / 0.3 | __GI_memset / __GI_memcpy |
libc memory |
| 1.5 / 0.2 | inline_call_site / opt_inline |
whole-program inliner |
| 1.2 / 1.0 / 0.3 / 0.2 | metrics_count / metrics_scope_from_name / metrics_scope_from_name'2 / metrics_count'2 |
profiling string-name lookup |
| 1.1 | arena_alloc |
arena bump path |
| 1.0 | opt_addr_xform_pregs |
address transform |
| 0.1 | opt_emit_native |
native emit self-time |
Interpretation: sqlite -O1 compile time is dominated by the optimizer's
middle/back half, not native emission. Register allocation alone is at least
32 % (opt_regalloc_place + alloc_assign_group_hard, excluding the shared
bitset/range helpers), while CGIR lowering plus record construction is another
26 %. Built-in metrics on the same TU report 2,522 functions,
121,075 blocks, 98,516 PRegs, 1,049,079 ranges, and 29.1 M live
bitset words touched, which explains why the liveness/range bitset helpers are
visible. A cheap cleanup from this profile was the disabled metrics fast path:
metrics_scope_from_name and metrics_count burned ~2.7 % of O1 finalization
with KIT_METRICS unset because each hot scope/counter call string-decoded the
name before discovering the profiler sink was null. metrics_* now checks the
sink before name decoding; enum-specific wrappers would still make metrics-on
collection cheaper.
Follow-up cleanup on the same sqlite -O1 workload made the hot shapes cheaper:
CGIR lowering precomputes local address-use, param lookup, emit order, and
fallthrough successors; regalloc builds coalesce-group member lists before
candidate scoring and uses them in group scans; loop-tree construction
precomputes backedges and uses generation-marked loop bodies. Bounded macOS
release timing for the single amalgamation compile is now 0.93 s best / 0.96 s
mean (7 runs, 5 s per-run timeout); the ecosystem-style two-TU sqlite O1 build
is 1.20 s best / 1.22 s mean (5 runs, 8 s timeout), down from the previous
2.78 s best row. KIT_METRICS=1 ticks on the same single-amalgamation
command: opt.o1.total 27.09 M → 16.78 M (−38 %), opt.regalloc
13.81 M → 7.83 M (−43 %), opt.o1.cg_ir_lower 4.94 M → 1.16 M (−77 %),
and opt.build_loop_tree 1.41 M → 0.20 M (−86 %). Linux callgrind rerun was
blocked in this pass because podman machine started then immediately stopped
at 3 GiB, 2.5 GiB, and the restored 2 GiB setting; the VM was left configured at
2 GiB/stopped.
Code size is still locally spill-bound
Even with aggregate .text now smaller than tcc, the remaining per-mnemonic
excess is dominated by spills (register pressure in the single-pass cache,
+47 K) then residual movs (+14 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:
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)
# binary at tmp/tinycc/tcc ; compile with: tcc -c sqlite3.c -o x.o
Instructions / cycles / wall — /usr/bin/time -l is the macOS perf stat;
best-of-N filters scheduling noise:
m(){ local bi=9e18 bc=9e18 br=9e18; for i in $(seq 1 7); do
/usr/bin/time -l "$@" >/dev/null 2>/tmp/m.txt
local I=$(awk '/instructions retired/{print $1}' /tmp/m.txt)
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"; }
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 cc1 that /usr/bin/time does not count —
measure it with cc -fintegrated-cc1 -c …. kit and tcc are single-process.
Phase decomposition. Do not use -E as the lex+pp number — it adds
token→text serialization neither compiler does at -c, and tcc/kit serialize at
very different cost (§2). Instead drain the token stream to EOF (lex+pp only,
no parse, no output) in each compiler:
# kit: KIT_PP_DRAIN runs pp_next_parse to EOF then returns (lang/c/c.c)
m env KIT_PP_DRAIN=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /dev/null # lex+pp
m build/release/kit cc -fsyntax-only sqlite3.c --sysroot "$SDK" # lex+pp +parse/sema/types/CG
m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # +emit+objwrite
# tcc: the -bench hook drains next() to EOF; patched (tccpp.c tcc_preprocess)
# to use -c parse_flags. TCC_DRAIN_FLAGS=c also decodes literals in the lexer.
m env TCC_DRAIN_FLAGS=c tmp/tinycc/tcc -E -bench sqlite3.c -o /dev/null # tcc lex+pp
m tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o # tcc full -c
macOS hotspot sample (self-time leaves only — see the callgrind caveat). One
-c is too fast for sample; merge ~80 runs:
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=$!
sample $pid 5 1 -file /tmp/p_$i.txt -mayDie >/dev/null 2>&1 ; wait $pid; done
for f in /tmp/p_*.txt; do awk '/Sort by top of stack/{f=1;next}/Binary Images/{f=0}f' "$f"; done \
| sed -E 's/\(in [^)]*\)//' | grep -oE '[A-Za-z_][A-Za-z0-9_]*[[:space:]]+[0-9]+' \
| awk '{c[$1]+=$2;t+=$2} END{for(k in c) printf "%.1f%% %s\n",100*c[k]/t,k}' | sort -rn | head -25
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:
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)
For the sqlite -O1 optimizer-finalize profile, use podman machine explicitly
to give the VM enough memory for Valgrind, and force malloc away from brk:
podman machine stop || true
podman machine set --memory 3072
podman machine start
podman run --rm --platform linux/arm64 -v "$PWD":/work:Z kit-prof sh -c '
set -eu
cd /work/tmp/projects/sqlite-amalg
env MALLOC_MMAP_THRESHOLD_=1 MALLOC_ARENA_MAX=1 \
valgrind --tool=callgrind --cache-sim=no --branch-sim=no --dump-instr=no \
--collect-jumps=no --collect-atstart=no --toggle-collect=opt_on_finalize \
--callgrind-out-file=/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.out \
/work/build/linux-prof/kit cc -O1 -c sqlite3.c \
-DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -lc -o /tmp/sqlite_o1.o \
2>/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.vg.log
callgrind_annotate --threshold=99.5 \
/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.out \
>/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.annot.txt
'
# restore the default local VM shape after the run
podman machine stop
podman machine set --memory 2048
podman machine start
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.
Heap allocation counts — KIT_METRICS=1 prints the hosted-heap counters
(allocs / large allocs ≥32 KiB / reallocs / frees) at exit; the counts are
host-independent (kit issues the same h->alloc calls everywhere):
KIT_METRICS=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o
# -> kit heap metrics: heap.allocs=… heap.large_allocs=… heap.reallocs=… heap.frees=…
Code size (.text machine code, the honest metric):
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):
build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc
/tmp/sq :memory: "create table t(a,b); insert into t values(2,40),(10,32);
select sum(a)+sum(b), count(*) from t;" # -> 84|2
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)
Lean-token loc-materialization fix — Stage 1 DONE (-c −24 M net win). The
boundary rewrite's initial +62 M -c regression was two lazy-loc implementation
costs, both now fixed byte-identically (frontend-only, no CG-API change):
- ✅ memchr the line-index build.
srcinfo_build_linesbuilt the line-start index with a scalar\nbyte loop; replaced with a memchr sweep (5.3 % → 0.6 % self-Ir). The build is genuinely needed in the happy path —__LINE__(which sqlite expands pervasively) and diagnostics need line numbers even without-g— so the lever was to make it cheap, not to skip it. - ✅ cursor
pp_materialize_loc. Per-loc lookup was a fresh binary search; now aSrcInfo.line_cursorcaches the last line index, so the near-monotonic access pattern (parser advances forward) is O(1), binary search only on a backward jump (2.6 % → 0.9 %).
Result: lex+pp −44 M (the scanner no longer builds a SrcLoc per token),
parse+CG +19 M residual, net −24 M (§1, §2), perf-gate byte-identical.
Stage 2 (optional, ~+19 M parse+CG residual). The residual is the
per-statement loc lookup at pcg_set_loc (CG source-loc) that the old
eager-on-token loc got for free. g->cur_loc is consumed only by DWARF (gated on
g->debug), descriptor locs, and cold CG compiler_panics — so with -g off and
no error it is discarded. Carrying it as a LocRef (a kit_cg_set_locref +
a "resolve LocRef→SrcLoc" callback the frontend registers, so cold CG
diagnostics still materialize exactly) would skip those per-statement lookups
entirely. Smaller payoff than Stage 1, needs a contained CG-API addition + the
parser loc-plumbing; gate: byte-identical (-g DWARF + diag battery unchanged).
Status (prior campaign). Most of §4.1 has now landed, byte-identical, for a
combined −43.7 M -c instructions (−2.29 %) on sqlite vs the pre-campaign
snapshot (sqlite .o + -E bit-for-bit identical; 60/60 gate; full parse/pp/cg
/toy/smoke-x64/rv64/debug/dwarf suites green). Item-by-item below: #1 ✅ (landed
earlier, 5ff33ea3); #2 ◐ (a+c landed d07e43a3+c8ee9133; b deferred —
near-zero payoff, see note); #3 ✅ (object-macro replay 7ada3f62, function-macro
replay d59dff08, lexer-level newline elision 811351c2); #4 ◐ (Cut A
f368c7f8 + Cut B 94e642e3 landed; the dead paste-file-id register, Cut C, left
as a small follow-on); #5 ○ open.
- ✅ [DONE —
5ff33ea3] Symbol-binding cache on the internedSym— the highest-value front-half brick.scope_lookupis an N-scope chain walk per identifier (lang/c/parse/ parse.c, ~12 call sites); tcc caches the binding on the interned token. ASym-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. - ◐ [a+c DONE; b deferred] Type subsystem (#2 self-
Ircluster, ~14 %). Done: single-decode the per-op predicate gauntlet — hoist oneapi_type_pred_bitsper id and testAPI_PRED_*masks locally instead of re-callingcg_type_is_*(each a freshid→entry+unalias decode) on the load/store/convert hot paths (d07e43a3); fuse the redundantresolve_typeinto the followingapi_unalias_type, and route the hotcg_adapterhelpers through the slot's cachedcg_idinstead of the uncachedpcg_tidbridge (c8ee9133). Deferred: collapsing the duplicatec_abi_record_layout(frontend) vsabi_cg_record_layout(backend) memos — both are already memoized at O(#distinct records), so the collapse saves only the duplicate build (< 0.01 % of total) against real boundary/byte-identity risk; not worth it now. Optional remaining: cachepred_bitson theApiSValueslot itself (freeApiBitField.padbytes) — measure first, retype-invalidation surface. - ✅ [DONE] Token relay (lex+pp). Macro bodies with no
##now replay by pointer (object-like7ada3f62; function-like skips thesubst_phase2copy,d59dff08). Non-directive newlines are no longer materialized by the lexer on the cc path (811351c2, −32 M-c): anemit_newlineslexer mode suppresses them while still emitting the one newline that terminates a directive line (Option B2 — no cross-frame coupling);-E/cpp keep them. Line numbers safe by construction (counter advances on byte-consume,locrides every token); verified bit-identical on-E,-c, and-c -gDWARF. - ◐ [Cut A+B DONE; Cut C open]
lex_open_memper-open work (~6,000 opens: 3,140 files + 2,796 macro-paste buffers). Done: paste buffers skip thelex_fold_splicesmemchr scan via a guarded splice-free fast path (f368c7f8, they are interned already-folded spellings — provably splice-free); the per-file-open full-structmemsetis right-sized to just thepunct_symsentinel array (94e642e3). Open (Cut C): the dead paste file-id registration could become a barenfiles++after auditing no diagnostic ever queries a paste file-id. - ◑ [arena churn LANDED; memset OPEN]
memset/ arena churn (~2–3 %). Arena churn done: blocks now grow geometrically andarena_resetretains the high-water capacity instead of freeing all-but-head (large block allocs −72 %, byte-identical) — see §2 Allocator. Still open: right-size per-expression / per-emit struct zeroing (designated-init the per-op clears). (memsethere 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)
- Trim the over-reserved far-slot/large-frame patch region (new, +12,897
nop/ ~52 KB — the whole 0.951×→0.986× erosion). The aa64 far-slot address-build / large-frame fallback now reserves a fixed branched-over 5-instruction patch region in ~2,587 functions' prologues (abover fivenops when the long path is unused), so every qualifying function pays 24 B of padding even when the short path suffices. This is correctness-first and probably over-reserved: reserve only when the frame/slot actually needs the long form, or shrink the reservation to the real worst case (and patch the branch away when unused). Investigate before touching spills — it's likely the cheapest byte win on the board. Gate: run-correctness + determinism (this is the codegen path the recent correctness fixes added). - Spill reduction — the #1 remaining local code-size excess (+47 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. - Positive-offset frame addressing (kills the residual
sub xN,x29+movk, ~3.5 K). The machinery already exists — aa64 has a bottom-record layout (fp_at_bottom,frame_size−off) + anAAPatchdeferred-patch list, currently gated to-O1known-frame. Extend to-O0by deferring per-slot offsets (emit scaledstr/ldrwith an imm12 placeholder, patch(N−off)>>scaleatfunc_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. - Residual arg/value
movs (+13.7 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. - Signed load-with-extend
ldrb;sxtb→ldrsb(the unsigned zero-extend cases are done). Structurally blocked: needs a load-with-extend rider onMemAccess(kit's CG integer types are sign-agnostic, sond_loadcan't know the value feeds a sign-extend) — a frontend widening signed load, not a register rename. - 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-cregression (every token pays a LocId store+resolve that outweighs the smaller copy;-cdominates 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.
5. Methodology notes
- Measure on a RELEASE build (
make bin RELEASE=1, orPROFILE=1for sampling), never the defaultmake 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, 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 ownsupport/rtsibling, 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+-O1vs 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.