commit b50e883e3b179503f8c2340695356869e6b7e602
parent f1221981c527ac1c956d1f182a658f7153dd3255
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 11:35:07 -0700
doc(plan): O0 code-size reduction roadmap vs tcc (PERF-O0-CODESIZE.md)
Refreshed measurement: kit -O0 .text is 1.33x tcc on sqlite3.c (455,546 vs
342,735 insns), down from 1.57x after the value-stack-residency work — and the
#1 excess is now 'mov' (+70K), not 'sub'. Documents the opcode-histogram
diagnosis, the frame-addressing tax, and four ranked levers:
1. Fix B positive-offset frame addressing (~-33K/-7.3% -> ~1.24x) — extends
the existing bottom-record (fp_at_bottom) + AAPatch defer-patch infra to
the -O0 single-pass path; alloca-safe via a stable anchor (tcc's x29 role).
2. epilogue slim (-2.6K), 3. load-with-extend ldrsb/ldrsh (-1.3K),
4. compute-into-place (mov/spill): lazy dest placement is the *absence* of
eager relocation, not an added pass — compile-neutral-to-faster; phased
plan (x0 result-adoption, dest-hint surface, args-into-arg-regs).
Added to doc/plan/README index.
Diffstat:
2 files changed, 401 insertions(+), 0 deletions(-)
diff --git a/doc/plan/PERF-O0-CODESIZE.md b/doc/plan/PERF-O0-CODESIZE.md
@@ -0,0 +1,400 @@
+# 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). This
+track is most of the remaining gap to parity after the per-operation slimming.
+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 and follow once the aa64 design is proven.
+
+---
+
+## 1. Current state (measured 2026-06-13)
+
+`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`/`.text`)** | **1,822,184 B / 455,546 insns** | **1,370,940 B / 342,735 insns** | **1.33×** |
+| object file | 2,216,680 B (Mach-O) | 2,108,134 B (ELF) | *not comparable¹* |
+| compile instructions | 1,968 M | 667 M | 2.95× |
+| compile wall | 0.18 s | 0.06 s | 3.0× |
+
+¹ tcc emits ELF, kit Mach-O — different format/symbol/string-table overhead.
+**The honest size metric is the `.text` machine code**, and there kit is **1.33×
+tcc, +112,811 excess instructions.** (This is well down from the 1.57× recorded
+when §6's history was written — the value-stack-residency + lazy-homes +
+coalescing commits landed since and reshaped the picture; `mov`, not `sub`, is
+now the #1 excess.)
+
+**Reproduce:**
+
+```sh
+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. Diagnosis — where the 112,811 excess instructions live
+
+Per-mnemonic signed diff (kit − tcc), from the opcode histograms:
+
+| mnemonic | kit | tcc | **excess** | what it is |
+|---|--:|--:|--:|---|
+| `mov` | 73,824 | 3,701 | **+70,123** | reg-reg copies — **#1 lever** |
+| `stur` | 62,819 | 15,899 | **+46,920** | frame spills (negative-offset, unscaled) |
+| `ldur` | 80,853 | 37,994 | **+42,859** | frame reloads |
+| `sub` | 33,758 | 6,713 | **+27,045** | **29,379 are `sub xN,x29,#imm`** — far-local address building |
+| `cbz` | 8,942 | 2,648 | +6,294 | compare-branch lowering |
+| `movk` | 4,360 | 357 | +4,003 | building frame offsets > 4095 |
+| `uxtb`/`uxth`/`sxtb`/`sxth` | 5,434 | 333 | +5,101 | sub-word extends after narrow loads |
+
+By value class the spill story is starker than the per-mnemonic split: **kit
+stores ~78 K vs tcc ~26 K (3× the spilling)**; loads are closer (107 K vs 92 K).
+
+Where **kit already beats tcc** (do not touch — these are structural wins, mostly
+kit's fused `cmp;b.cond` vs tcc's `cset;cbnz`, plus folded offsets):
+
+| mnemonic | excess | note |
+|---|--:|---|
+| `ldr` | −28,365 | tcc materializes more addresses; kit folds offsets into displacement |
+| `add` | −18,422 | "" |
+| `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 | tcc's far-local offset build (see §3) |
+| `nop` | −5,834 | kit's prologue pad is *smaller* than tcc's (§3) |
+| `stp` | −3,722 | tcc pairs more aggressively |
+
+**Concentration.** The excess lives in the giant functions. Top 12 by size hold
+~80 K insns; `_sqlite3VdbeExec` alone is **22,991** (5% of all text), of which
+**5,162 (22%) are pure far-local address arithmetic** (2,757 `sub xN,x29` +
+2,405 `movk`). Frame-size distribution: kit mean **112 B**, max 3,520, 181
+functions > 256 B; tcc mean **53 B**, max 3,392, 58 functions > 256 B.
+
+---
+
+## 3. The two structural realities
+
+**(a) The frame-addressing tax** (`sub` +27 K, `movk` +4 K, and part of
+`stur`/`ldur`). Both kit and tcc home fixed locals at **negative x29 offsets** —
+this is *not* an addressing-mode difference. aarch64 reaches negative offsets
+only with unscaled `stur`/`ldur` (±256). Past that, an address must be built. The
+difference is *how*:
+
+- **kit** emits `sub x17, x29, #off` **per access** (+ a `movk` when off > 4095),
+ then `stur`/`ldur [x17]`. Cited at `aa_fp_off_slot = -off` (negative), the
+ single-pass TOP-RECORD layout (`src/arch/aa64/native.c:112`).
+- **tcc** falls through `arm64_ldrx` (`arm64-gen.c:317`) to **register-indexed**
+ addressing: `movn/movz x30, #off ; ldr/str [x29, x30]`, reusing x30. One `movn`
+ covers any frame < 64 KB, and the index reg is shared across nearby accesses.
+
+In `VdbeExec`: kit 2,757 `sub` + 2,405 `movk`; tcc **66** `sub`, 1,668 indexed
+accesses, 1,719 `movn`. kit pays ~2–3 insns per far access where tcc pays ~1, and
+**kit makes more far accesses** because it touches the frame more (reality (b)).
+
+**(b) kit computes-into-a-register-then-moves/spills; tcc computes-into-place**
+(`mov` +70 K, stores +52 K). 8,511 movs just capture a return value out of x0;
+~19 K cluster around `bl` (arg setup `mov mov bl` / retval `bl mov`); the rest are
+materialization chains (`ldur;mov`) and value-stack copies. The value-stack
+residency work (§6) moved partway — it traded frame round-trips for register
+moves (net fewer insns, but `mov` count *rose*). Closing the rest is
+destination-driven (lazy) value placement — which is the *absence* of eager
+relocation, **not** an added pass, so it is compile-time-neutral-to-faster (§8).
+The cost is correctness/invasiveness, not throughput. Treated as a separate
+track (§4, Lever 4; mechanism + phased plan in §8).
+
+**Non-levers (ruled out empirically):**
+- *Prologue nop pad.* kit reserves a `b <body>; nop×3` patch region per function;
+ tcc reserves `ARM64_FUNC_STACK_SETUP_SLOTS` nops too, patched in `gfunc_epilog`
+ (`arm64-gen.c:1417`). tcc has **more** nops (13,757 vs kit 7,923). Not a
+ differentiator.
+- *Branch lowering.* kit's fused `cmp;b.cond` already beats tcc's `cset;cbnz`
+ (the −17 K `cset`, −10 K `cbnz`, −9 K `cmp` deficits). Leave it.
+
+---
+
+## 4. Ranked open levers
+
+| # | Lever | Est. saving | Risk | Compile-speed | §|
+|---|---|--:|---|---|---|
+| **1** | **Fix B — positive-offset -O0 frame addressing** | **~33 K (−7.3%) → ~1.24× tcc** | med (alloca layout, CFI, base-reg) | neutral/faster | §5 |
+| 2 | Epilogue slim (drop the x16 dance) | ~2,633 (−0.6%) | low | neutral | §6 |
+| 3 | Load-with-extend (`ldrsb/ldrsh` vs `ldrb;sxtb`) | ~1,300 | low-med | neutral | §7 |
+| 4 | Compute-into-place (`mov`/spill; lazy dest placement) | up to ~50–70 K | high (correctness/invasiveness) | neutral/faster | §8 |
+
+Levers 1–3 are smaller-and-faster with bounded risk; Lever 4 is the deep,
+high-count frontier — high *correctness* risk and invasiveness, but
+compile-time-neutral-to-faster (it removes work, §8), so it is *not* gated by the
+compile-speed budget. Re-profile after each landing — the phase shares shift.
+
+---
+
+## 5. Lever 1 — positive-offset -O0 frame addressing (Fix B)
+
+**The idea:** address fixed locals as **positive *scaled* offsets** from a stable
+frame-base anchor. A scaled `ldr/str [base, #+ofs]` reaches **32,760 B** for
+64-bit (4095×8), covering any realistic frame in **one instruction, zero address
+building** — strictly better than both kit's current `sub`-per-access *and* tcc's
+`movn`+indexed scheme. This deletes essentially all 29,379 `sub xN,x29` and the
+~4,000 frame-offset `movk`.
+
+### 5.1 The infrastructure already exists
+
+The aa64 backend already has a positive-offset layout and a deferred-patch list:
+
+- **BOTTOM-RECORD layout** (`fp_at_bottom`): the saved pair sits at the *bottom*,
+ `fp = sp`, slots stack **above** at positive offsets, `aa_fp_off_slot(a,off) =
+ frame_size − off` (`src/arch/aa64/native.c:291`, layout diagram :120-136). It is
+ currently **gated to -O1 known-frame** small frames with `out_stack == 0`
+ (`native.c:1835`), because the positive offset `N − off` needs `frame_size` (N),
+ which the single-pass path does not know before the body (`native.c:217`).
+- **`AAPatch` deferred-patch list** (`native.c:185-202`, resolved in
+ `aa_func_end`): the single-pass path **already defers the prologue `sub sp`** and
+ patches it once `frame_size` is final (`native.c:72-82`). The same mechanism
+ extends to slot offsets.
+
+So Fix B is **not a new subsystem** — it is *extending BOTTOM-RECORD to the -O0
+single-pass path* by deferring the per-slot offset, reusing `AAPatch` and the
+deferred-prologue machinery.
+
+### 5.2 The single-pass mechanic
+
+1. Emit each slot access as a scaled `str/ldr [base, #placeholder]` (imm12) and
+ record an `AAPatch{insn_pos, slot_off, scale}`.
+2. In `aa_func_end`, once `frame_size` (N) is final, patch `imm12 = (N − slot_off)
+ >> scale`. Because positive scaled imm12 reaches 32,760 B, the placeholder is
+ **always one word** — no 1-word→2-word expansion (the old worry, which was a
+ *negative*-offset artifact, is gone).
+3. The base anchor is set once in the prologue and never clobbered. Two viable
+ anchors (decide during implementation):
+ - **`fp` at the bottom** (true BOTTOM-RECORD): `mov x29, sp` *after* the
+ deferred frame `sub`. fp becomes the stable bottom anchor. CFA becomes
+ `fp + N` (frame-size-dependent — patch the CFI in `aa_func_end`, see §5.4).
+ - **A dedicated callee-saved base reg `xFB`**: keep fp at the top (top-record
+ CFA `fp+16`, unwind unchanged) and add `mov xFB, sp` after the frame `sub`.
+ Costs one reserved callee-saved register + its save/restore, but leaves
+ unwind/CFI untouched. Note the single-pass path currently *uses no
+ callee-saved registers* (`native.c:77`, enforced in `nd_scratch_acquire`) —
+ reserving `xFB` relaxes that invariant.
+
+The setup insn lands in the existing reserved prologue region, so **only
+functions that overflow ±256 pay it** — small functions keep today's
+`ldur [x29,#-n]` and emit nothing extra.
+
+### 5.3 alloca / VLA — why this is *not* a fallback problem
+
+How **tcc** handles alloca (`arm64-gen.c:2305-2334`): `gen_vla_alloc` does
+`sub sp, sp, xN` at runtime; `gen_vla_sp_save/restore` spill/reload sp to a frame
+slot at scope boundaries. **Fixed locals stay x29-relative; sp floats.** x29 is a
+stable anchor that alloca cannot disturb.
+
+Fix B's base anchor (fp-at-bottom or `xFB`) **plays exactly tcc's x29 role** — it
+is set once and is unaffected when alloca lowers sp. The VLA memory is sp-relative
+(below the anchor); fixed-local addressing through the anchor is untouched. So
+alloca is **safe by construction, with no `CG_FUNC_HAS_ALLOCA` flag and no
+fallback path** — that worry was specific to a *bare-sp-relative* scheme.
+
+Caveat to settle: today's BOTTOM-RECORD excludes `has_alloca` *and* requires
+`out_stack == 0` (`native.c:1835`). The -O0 variant must support `out_stack > 0`
+(outgoing stack args coexist with positive slot addressing) and alloca. Cleanest
+first cut: choose the layout **at `aa_func_end`**, where `has_alloca` is finally
+known — patch slots positive (Fix B) when no alloca was seen, or fall back to the
+current negative top-record `sub` form when it was. The deferred-patch list makes
+this a per-function decision at resolve time, not an up-front guess.
+
+### 5.4 Open design points
+
+- **CFI.** fp-at-bottom makes CFA frame-size-dependent (`fp + N`); emit/patch the
+ CFA rule in `aa_func_end`. The `xFB` variant avoids this entirely (fp stays at
+ top). Gate on `test-dwarf`/`test-debug`.
+- **Sub-word reach.** Scaled imm12 reaches only 4095×size — 16 KB for 4-byte,
+ 8 KB for 2-byte, 4 KB for 1-byte. Frames past those for *narrow* accesses keep
+ the indexed/`sub` fallback. Rare; the 64-bit/32-bit common case covers nearly
+ everything.
+- **Index registers (`[base, xN, lsl #s]`) and q-regs.** Verify the scaled forms
+ for 128-bit (reaches 65,520 B) and the few indexed loads.
+- **`frame_size_final` plumbing.** It is "set in `aa_func_begin_known_frame` …
+ unread on the single-pass path" (`native.c:214-217`). The single-pass path must
+ now compute and store it before the patch loop in `aa_func_end`.
+
+### 5.5 Expected payoff & gate
+
+**−29,379 `sub` − ~4,000 frame `movk` ≈ −33 K insns (−7.3% text) → ~1.24× tcc**,
+concentrated in the giant functions (`VdbeExec` alone −5,162, ~22% of the
+function). Setup cost is ~1–3 insns only in big-frame functions, vastly
+out-weighed. Compile speed is **neutral-to-faster** (bounded O(far-accesses) patch
+list; ~33 K fewer instructions to emit).
+
+**Gate: run-correctness + determinism** (it changes emitted bytes): compile
+sqlite twice and `cmp` the objects (self-identical), `make test-toy test-parse-ok
+test-parse-err test-smoke-x64 test-smoke-rv64 test-dwarf test-debug`, the sqlite
+e2e (`84|2`), and a clang-differential probe over alloca/VLA + large-frame +
+&local-address-taken + outgoing-stack-arg functions.
+
+---
+
+## 6. Lever 2 — epilogue slim (quick win)
+
+kit emits a **3-insn** epilogue using x16 as scratch:
+
+```
+add x16, x29, #0 ; copy fp to scratch
+ldp x29, x30, [x16] ; restore fp/lr
+add sp, x16, #16 ; sp = fp + 16
+ret
+```
+
+tcc emits **2 insns** (`arm64-gen.c:1668`):
+
+```
+mov sp, x29 ; sp = fp (recovers from any alloca too)
+ldp x29, x30, [sp], #16 ; restore fp/lr, post-increment sp
+ret
+```
+
+`mov sp, x29` then a post-indexed `ldp [sp],#16` needs no scratch and is correct
+under alloca (it restores sp from fp). **−2,633 insns (−0.6%).** Byte-safe modulo
+the intended change; investigate why the x16 form was chosen (likely incidental).
+Gate: run-correctness + determinism + the alloca probe above.
+
+---
+
+## 7. Lever 3 — load-with-extend (quick win)
+
+kit emits **0** `ldrsb`/`ldrsh`; tcc emits 1,313. kit lowers a signed narrow load
+as `ldrb; sxtb` (two insns) where one `ldrsb` suffices. This was flagged blocked
+in `[[o0-copy-convert-coalescing]]` (needs a load-with-extend rider on the
+`MemAccess`, not a register rename). **~−1,300 insns.** Gate: run-correctness +
+determinism; signed/unsigned char/short load corpus.
+
+---
+
+## 8. Lever 4 — compute-into-place (the `mov`/spill frontier)
+
+The biggest *raw* counts — `mov` +70 K and stores +52 K — are reality (b): kit
+computes into a register, then moves/spills to place. This is the largest lever,
+and the one most worth understanding correctly, because the obvious framing
+("add a register allocator / a cleanup pass") is wrong and would indeed cost
+compile time. **The correct framing is the opposite: compute-into-place is the
+*absence* of eager relocation, not extra analysis — it emits fewer instructions
+and runs no extra pass. tcc is faster *because* of it.** So this lever is
+compile-time-neutral-to-*positive*; its real cost is correctness/invasiveness,
+not throughput.
+
+### 8.1 How tcc does it — lazy location + demand-driven registers
+
+tcc's `SValue` separates *what a value is* from *where it lives* (`tcc.h:479`). A
+value-stack entry stays unmaterialized — a constant, a memory ref
+(`VT_LOCAL|VT_LVAL`), CPU flags (`VT_CMP`), or a register — until something
+*forces* it. When forced, `gv(rc)` materializes it into a register **of the
+class the consumer asks for** (`tccgen.c:1844`); `get_reg(rc)` picks that
+destination *at the point of use* (`tccgen.c:1487`); the result lands there and
+`vtop->r` is updated to point at it. There is no IR, CFG, or second pass — the
+"analysis" is the recursion shape: the parent passes down the register it wants.
+Laziness is therefore *cheaper*, not costlier.
+
+### 8.2 The three mov classes — tcc vs kit (with emit sites)
+
+| class | excess | tcc | kit today |
+|---|--:|---|---|
+| **retval capture** | ~8,511 | result SValue records `r = x0`, emits nothing; next forcing use materializes it (`tccgen.c:6334`) | pre-allocates a cache reg, emits `mov cachereg,x0` after `bl` (`native_direct_target.c:1952,2010`) |
+| **arg setup** | ~19,000 | `gv(RC_R(n))` materializes each arg **directly into x0–x7** at the call (`arm64-gen.c:1187`) | args already sit in general cache regs; `native_arg_shuffle` moves them into x0–x7 |
+| **materialize chains** (`ldur;mov`) | ~18,000 | `gv` loads straight into the consumer's demanded reg | `nd_materialize_operand` loads into an *arbitrary* scratch, then `mov`s to the consumer's reg (`native_direct_target.c:832,954`) |
+
+### 8.3 Why kit pays — three missing handoffs, not missing analysis
+
+kit's NDT cache is *already* a lazy-location model, and its bookkeeping is
+already cheap (`nd_cache_alloc` O(≤8), `nd_touch` O(1), flush O(ncached)≤8). The
+movs come from three gaps:
+
+1. **ABI registers are excluded from the cache.** x0–x7 are non-allocable
+ (`aa64/native.c:3843`), so a result can't *stay* in x0 and an arg can't be
+ *produced into* x_k — both route through a general cache reg + a mov.
+2. **No destination-hint surface.** A producer always writes to a
+ cache-allocator-chosen register; no consumer can say "produce this in
+ register R." The only operand hint today is `OPK_FLAG_KILL` (`cgir.h:210`),
+ which transfers ownership of a *dead source* but does not request a
+ *destination*.
+3. **kit materializes args eagerly, before the call.** By the time `nd_call`
+ runs, args are CGLocals already in cache regs → they must be shuffled. tcc
+ materializes args *at* the call, while still unmaterialized, straight into
+ arg regs.
+
+### 8.4 Phased plan — cheapest-first, each compile-neutral-or-faster
+
+Each phase adds a *preferred-register field*, not a pass; every mov removed is
+also one fewer instruction to emit (faster compile). Uniform gate:
+**run-correctness + determinism** (compile twice + `cmp`; full
+toy/parse/smoke/dwarf/debug; sqlite e2e `84|2`; clang-differential probes around
+calls/varargs/sret). The one real correctness surface is the **eviction
+discipline for ABI-register holders**.
+
+- **Phase 1 — adopt x0 as the result holder (~8,511 movs, the clean win).**
+ Record "this result local *is in* x0" as a short-lived cache entry instead of
+ allocating a cache reg + emitting `mov cachereg,x0`. The next call already
+ evicts caller-saved cache regs — reuse that path to spill/move x0 only if it
+ is still live at the next clobber. For `x = f()`, `f()+1`, `g(f())` (result
+ consumed before the next call) it is **zero movs**. Self-contained, reuses the
+ flush-at-call machinery, exactly tcc's "retval stays in x0." Correctness rule:
+ evict the x0-holder before *any* op that clobbers x0 (calls, and x0-using
+ lowering like div/varargs).
+- **Phase 2 — a destination-hint field on the operand (~18 K materialize
+ chains).** Add an optional "preferred destination register" beside
+ `OPK_FLAG_KILL`; when the consumer's wanted reg is known and free,
+ `nd_materialize_operand` loads straight into it. The cache already supports
+ "write into reg R" — only the hint needs to flow. This is the general form of
+ tcc's `gv(rc)`.
+- **Phase 3 — args into arg-regs (~19 K, the structural one).** Thread the hint
+ from the call *back* to the arg producers (the frontend knows, when generating
+ arg-expr k, that its result feeds arg slot k — pass an abstract "destined for
+ arg k" the arch resolves to x_k), or keep args unmaterialized until `nd_call`.
+ Non-conflicting args (the majority) then produce directly into x_k;
+ `native_arg_shuffle` still resolves the rare cross-arg cycles (tcc handles the
+ same case by spilling conflicts to stack first). Biggest payoff, most
+ plumbing — do last.
+
+Prior partial work this builds on: value-stack residency (register-resident
+return/args/result), lazy transient homes, lazy-dup + copy/convert coalescing
+(`[[o0-value-stack-residency]]`, `[[o0-copy-convert-coalescing]]`).
+
+History of landed codesize work (context for §1's 1.33×), newest last:
+
+- temp-slot reuse `3516b3d3`, addr-result caching `f75874cb`, subscript fusion
+ `442fdc55`, transient-liveness + lazy-dup `ecbd00dc`/`73e128a2` — took the
+ object 3.45×→1.57× tcc (`[[o0-codesize-vs-tcc]]`).
+- value-stack residency (register-resident return/args/result + spill-only-live-
+ set), lazy transient frame homes, copy+convert coalescing — took emitted insns
+ ~539 K→455 K and the ratio 1.57×→1.33× (`PERF-TCC-SLIM.md` §10c/§10d,
+ `[[o0-value-stack-residency]]`, `[[o0-lazy-transient-homes]]`).
+
+---
+
+## 9. Gate & measurement methodology
+
+- **Measure on RELEASE** (`make bin RELEASE=1`); the ASan default inverts costs.
+- **Size metric = `.text` machine code**, not object bytes (format-skewed). Use
+ `kit size` / the §1 histogram diff. The instruction metric is deterministic to
+ ±0.2 M on a fixed binary — small deltas are real.
+- **Codesize changes alter emitted bytes**, so the gate is **run-correctness +
+ determinism** (compile twice + `cmp`; full toy/parse/smoke/dwarf/debug; sqlite
+ e2e `84|2`; clang-differential probes), *not* the byte-identity gate used for
+ compile-speed-only refactors.
+- Re-profile after each landing and re-rank §4 — the highest lever moves as work
+ is removed.
diff --git a/doc/plan/README.md b/doc/plan/README.md
@@ -11,6 +11,7 @@ shrinks to whatever remains open.
| [RELEASE.md](RELEASE.md) | Cross-cutting initial-release punchlist: release scope, deferred features, and per-subsystem completion/validation items. | — |
| [OPTIMIZER.md](OPTIMIZER.md) | Completing the O2 SSA mid-end, expanded inlining, -O0/-O1 performance work, machine register-constraint improvements. | [../OPT.md](../OPT.md) |
| [PERF.md](PERF.md) | Making kit the fastest `-O0` C compiler: the compile+link scaling benchmark (`make bench-cc`), per-axis linearity goals, hotspot sampling, and the open superlinear-axis fixes. | — |
+| [PERF-O0-CODESIZE.md](PERF-O0-CODESIZE.md) | Shrinking kit's `-O0` emitted machine code toward tcc (1.33× → parity): the opcode-histogram diagnosis, the frame-addressing tax, and the ranked levers — positive-offset frame addressing (Fix B), epilogue slim, load-with-extend, and the deferred `mov`/spill track. Companion to PERF-TCC-{GAP,SLIM}.md. | [../ARCH.md](../ARCH.md) |
| [LINKER.md](LINKER.md) | Incremental linking: the file-based object-link redesign and remaining non-ELF format coverage. | [../LINK.md](../LINK.md) |
| [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) |
| [DEBUG.md](DEBUG.md) | The Windows debugger host adapter, x64/rv64 displaced single-step, profiling, and DWARF gaps. | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) |