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:
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 |
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:
mov(+44,877) — now #1 by a wide margin. This ismov-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).- 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.2note 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 movs 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 movs
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):
- 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-tableNATIVE_REG_ALLOCABLEflags, so -O0 and -O1 are isolated).nd_cache_allocscans the pool in order, so producers prefer the arg regs. - 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 andnative_arg_shufflehad 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 preservesapi_temp_deadexactly: the not-yet-materialized args stay on the value stack (whichapi_temp_scan_refswalks 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_switchflushes, thencg_lower_switch_defaultemits onecmp_branchper case — each reloads the selector (the cache was just emptied). Pin the selector across the case chain (or don't flush atnd_switchand let it stay cached) so it is read once. Helps the bigVdbeExec-style dispatch chains; bounded by the chain-vs-jumptable threshold incg_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 =
.textmachine code, not object bytes (format-skewed). Usekit 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 +
cmpfor 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 inNativeDirectLocal.{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 = 1arend_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:695stores iff dirty — and they always are). - Cache lifetime = one straight-line run of compute ops.
nd_flush_all:723empties 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_argsatnd_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:711drops an entry with no write-back;nd_drop_all:732does it for the whole cache atret(frame about to die);nd_reclaim_temps:1191drops dead transients at the statement boundary (:1197, no store) and recycles their lazy homes (nd_home:249, lazy-home gate innd_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:
- Dead transients flush-stored at mid-statement barriers.
t = b*c; … f(t) …:tis a dirty entry, is consumed, becomes dead, but the call inside the expression flushes it to a (lazily-minted) home. tcc keepstin a register and never stores it. In a call-free expression there is no excess —nd_reclaim_tempsdrops dead transients at statement end with no store. The excess is specifically expressions containing calls /&&/||/?:/ comma — i.e. most of sqlite. - Eviction spills under pressure. When
cache_poolfills,nd_cache_alloc:636→nd_pick_cache_victim:613→nd_flush_localstores 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 (refcountlocal_refsfast-reject + confirming stack scan; fields atsrc/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:338pattern — setOPK_FLAG_KILLon any operandapi_temp_deadconfirms 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_localit (drop, no store) instead of leaving it cached. Apply innd_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 fewerldurreloads 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_alls. 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 movs 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_deadbeing 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)
- Re-verify §6.1/§6.3 line anchors against current source (the file moves).
- Snapshot a golden + a baseline
.texthistogram (§1 reproduce block) and a Linux-callgrindIrbaseline (scripts/perf_callgrind.sh run pre2a). - 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.
- Extend the producer (
OPK_FLAG_KILLfromapi_temp_dead) to cmp, store-value, call-arg; extend the consumer drop to each; gate after each op class. - Land 2c (skip flush at volatile/atomic/fence; keep at call/asm-clobber) separately — it is orthogonal and individually gateable.
- Re-profile: report the
.textstur/str/ldurdeltas (codesize) and the compile-Irdelta (both should improve — fewer stores to emit). Re-rank §4.