commit 942c3090611c0ac05b3c024bb3a5b8c380863d86
parent 3dbbfcf1bb2cc2e23eee8a8f147c107b5d3d84bc
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 16:23:55 -0700
doc(plan): PERF-O0-CODESIZE.md — record landed Lever 2 + re-rank §4
Lever 2 (eager dead-operand drop + nd_cmp_branch materialize-before-flush
reorder + 2c) landed: 1.16x -> 1.091x tcc, +55,698 -> +31,117 excess
insns. Refresh §1 standings, §2 landed table, §3 diagnosis (mov +44,877
is now #1 by far; spill mnemonics dropped ~9 K each), re-rank §4 (Lever 1
is now the single biggest remaining win; add §4.6 reorder-the-other-
barriers and §4.7 in-place-binop-into-killed-reg follow-ons), and mark §6
landed.
Diffstat:
1 file changed, 94 insertions(+), 44 deletions(-)
diff --git a/doc/plan/PERF-O0-CODESIZE.md b/doc/plan/PERF-O0-CODESIZE.md
@@ -25,12 +25,13 @@ follow once the design is proven.
| metric | kit | tcc 0.9.28 | ratio |
|---|--:|--:|--:|
-| **machine code (`.text`)** | **1,593,732 B / 398,433 insns** | **1,370,940 B / 342,735 insns** | **1.16×** |
+| **machine code (`.text`)** | **1,495,408 B / 373,852 insns** | **1,370,940 B / 342,735 insns** | **1.091×** |
**The honest size metric is the `.text` machine code**, and there kit is now
-**1.16× tcc, +55,698 excess instructions** — down from 1.33× / +112,811 at the
-start of this campaign (half the excess removed; §2). The object file (Mach-O vs
-tcc's ELF) is not comparable, and compile-speed lives in `PERF.md`.
+**1.091× tcc, +31,117 excess instructions** — down from 1.16× / +55,698 after
+Lever 2 landed (§2; ~44% of that excess removed), and 1.33× / +112,811 at the
+start of this campaign. The object file (Mach-O vs tcc's ELF) is not comparable,
+and compile-speed lives in `PERF.md`.
**Reproduce:**
@@ -60,6 +61,14 @@ clang-differential probes). Design rationale lives in each commit message.
| **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 |
+
¹ 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
@@ -77,19 +86,21 @@ ratio 3.45×→1.33× before this campaign.
---
-## 3. Diagnosis — where the remaining 55,698 excess lives
+## 3. Diagnosis — where the remaining 31,117 excess lives
-Per-mnemonic signed diff (kit − tcc) on the current `.text`:
+Per-mnemonic signed diff (kit − tcc) on the current `.text` (post-Lever-2):
| mnemonic | kit | tcc | **excess** | what it is |
|---|--:|--:|--:|---|
-| `mov` | 48,561 | 3,701 | **+44,860** | reg-reg copies — **#1 remaining**: arg setup into x0–x7 (~30 K) + residual materialize/value-stack copies |
-| `ldur` | 71,934 | 37,994 | **+33,940** | frame reloads — register pressure |
-| `stur` | 48,913 | 15,899 | **+33,014** | frame spills (≈3× tcc) — register pressure |
-| `str` | 22,668 | 8,211 | **+14,457** | "" (more spills) |
-| `sub` | 11,484 | 6,713 | +4,771 | residual `sub xN,x29` (byte/half far slots + &local) |
-| `movk` | 2,371 | 357 | +2,014 | far-offset builds in the residual `sub` paths |
-| `sxtb`+`sxth` | 1,341 | 99 | +1,242 | sign-extend after a narrow load (L3 did the unsigned half only) |
+| `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):
@@ -112,34 +123,49 @@ was how each reached a *far* local (past `stur`'s ±256): kit built the address
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 is register pressure, not addressing.** kit emits ~72 K
-stores vs tcc's ~25 K (3×) and 102 K loads vs 87 K: it keeps fewer live values in
-registers across statements than tcc's value stack, so it stores-and-reloads the
-frame far more. That excess (`mov` arg-setup + `stur`/`ldur`/`str`) is the
-compute-into-place / value-residency frontier (§4).
+**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)
+## 4. Remaining levers (ranked, post-Lever-2)
| # | Lever | Est. saving | Risk | §|
|---|---|--:|---|---|
| **1** | Args materialized into arg regs (L4 Phase 3) | ~30 K `mov` | high (frontend/CG-API + arg-shuffle correctness) | §4.1 |
-| **2** | Reduce frame spilling / extend value residency | up to ~50 K (stores+reloads) | high (register-allocation reach) | §4.2 |
-| **3** | byte/half far slots → positive-scaled (extend L1) | ~1,940 `sub` + some `movk` | med (overflow fallback) | §4.3 |
+| **3** | byte/half far slots → positive-scaled (extend L1) | ~4.4 K `sub` + ~2 K `movk` + narrow spills | med (overflow fallback) | §4.3 |
+| **6** | extend the §6 reorder to other flush-first barriers (switch-selector residency, indirect-branch) | ~few K (per-case reloads) | med (selector pinning) | §4.6 |
+| **7** | in-place binop/cmp into a killed operand's register (reuse the freed reg as dst) | moderate (eviction spills) | med | §4.7 |
| **4** | Signed load-with-extend (`ldrsb`/`ldrsh`) | ~1,242 `sxtb`/`sxth` | med (frontend widening load) | §4.4 |
| **5** | rv64 L4-P1 (flip `ndt_result_reg_stable`) | rv64 only | low (on-target check) | §4.5 |
-Levers 1–2 are the bulk of what's left and both reduce to one structural change:
-**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 (below). Re-profile after each
-landing — the highest lever moves as work is removed.
+**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 (L4 Phase 3)
@@ -161,22 +187,14 @@ remaining win; most plumbing (frontend + CG API + backend); highest correctness
surface (arg marshalling). The general destination-hint mechanism it introduces
also subsumes the residual materialize/value-stack `mov`s.
-### 4.2 Lever 2 — reduce frame spilling / extend value residency
+### 4.2 Lever 2 — reduce frame spilling (LANDED, §6)
-The deepest structural gap: kit spills ~3× as much as tcc (§3). The NDT register
-cache flushes the live-across set at every call and holds a bounded set across
-statements; tcc's value stack keeps more operands unmaterialized and threads them
-through. Closing this is the path to ~1.0× parity, and it overlaps Lever 1 (both
-are "place the value where it's used, don't relocate"). It is the highest-reach
-change to the -O0 register model.
-
-**→ §6 is the grounded hand-off plan for this lever** — a code-traced root-cause
-(the 3× spill is dead transients flush-stored at mid-statement barriers) plus a
-concrete, mostly-already-built fix (eager dead-operand drop via the existing
-`OPK_FLAG_KILL`/`api_temp_dead` machinery). The deep "extend residency across
-barriers" reach (register allocation at joins) stays out of -O0 scope; §6 carves
-out the part that does *not* need new analysis and can land independently of
-Lever 1.
+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)
@@ -213,6 +231,31 @@ operands), so result-in-result-register caching is sound there — flip
(build the p1-x64 probe for rv64 and run it through the emulator / a rv64 host).
Small, low-risk, rv64-only win.
+### 4.6 Lever 6 — extend the §6 reorder to the other flush-first barriers
+
+§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_switch` flushes, then `cg_lower_switch_default`
+ emits one `cmp_branch` per case — each reloads the selector (the cache was just
+ emptied). Pin the selector across the case chain (or don't flush at `nd_switch`
+ and let it stay cached) so it is read once. Helps the big `VdbeExec`-style
+ dispatch chains; bounded by the chain-vs-jumptable threshold in `cg_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
+
+§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
@@ -236,7 +279,14 @@ Small, low-risk, rv64-only win.
---
-## 6. Lever 2 in depth — spill reduction via eager dead-operand drop (hand-off plan)
+## 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