kit

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

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
§4.7 in-place binop/cmp into a killed operand's registerbuilt, measured −21, dropped: the cg waist (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:

  1. mov (+44,877) — now #1 by a wide margin. This is mov-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).
  2. 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.2 note 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):

  1. 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-table NATIVE_REG_ALLOCABLE flags, so -O0 and -O1 are isolated). nd_cache_alloc scans the pool in order, so producers prefer the arg regs.
  2. 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 and native_arg_shuffle had 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 preserves api_temp_dead exactly: the not-yet-materialized args stay on the value stack (which api_temp_scan_refs walks 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:

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


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:

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:

  1. Dead transients flush-stored at mid-statement barriers. t = b*c; … f(t) …: t is a dirty entry, is consumed, becomes dead, but the call inside the expression flushes it to a (lazily-minted) home. tcc keeps t in a register and never stores it. In a call-free expression there is no excess — nd_reclaim_temps drops dead transients at statement end with no store. The excess is specifically expressions containing calls / && / || / ?: / comma — i.e. most of sqlite.
  2. Eviction spills under pressure. When cache_pool fills, nd_cache_alloc:636nd_pick_cache_victim:613nd_flush_local stores 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:

But OPK_FLAG_KILL is set in only two producerssrc/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.

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

6.7 Implementation checklist (for the picking-up agent)

  1. Re-verify §6.1/§6.3 line anchors against current source (the file moves).
  2. Snapshot a golden + a baseline .text histogram (§1 reproduce block) and a Linux-callgrind Ir baseline (scripts/perf_callgrind.sh run pre2a).
  3. 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.
  4. Extend the producer (OPK_FLAG_KILL from api_temp_dead) to cmp, store-value, call-arg; extend the consumer drop to each; gate after each op class.
  5. Land 2c (skip flush at volatile/atomic/fence; keep at call/asm-clobber) separately — it is orthogonal and individually gateable.
  6. Re-profile: report the .text stur/str/ldur deltas (codesize) and the compile-Ir delta (both should improve — fewer stores to emit). Re-rank §4.