commit e8f7a220cb0fa548b419b4264542ba463949616f
parent b57f569929399c788ffc2078abd167689f73ba5d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 17:15:00 -0700
doc(plan): PERF-O0-CODESIZE.md — record landed single-pass follow-ons
Three of four follow-ons landed (§4.6 switch-selector residency −1,293,
Lever 4 signed load-with-extend −958, Lever 3 byte/half far slots −192
net); §4.7 in-place binop/cmp built+measured (−21) then dropped as
redundant with the cg-waist coalescing. Combined −2,315; cumulative
1.091x -> 1.084x tcc (+31,117 -> +28,802 excess). Refresh §1/§2/§4, note
the Lever 3/4 far-signed-slot interaction guard. Only Lever 1 (args->arg-
regs) and the rv64-only Lever 5 remain open within single-pass scope.
Diffstat:
1 file changed, 63 insertions(+), 17 deletions(-)
diff --git a/doc/plan/PERF-O0-CODESIZE.md b/doc/plan/PERF-O0-CODESIZE.md
@@ -25,13 +25,13 @@ follow once the design is proven.
| metric | kit | tcc 0.9.28 | ratio |
|---|--:|--:|--:|
-| **machine code (`.text`)** | **1,495,408 B / 373,852 insns** | **1,370,940 B / 342,735 insns** | **1.091×** |
+| **machine code (`.text`)** | **1,486,148 B / 371,537 insns** | **1,370,940 B / 342,735 insns** | **1.084×** |
**The honest size metric is the `.text` machine code**, and there kit is now
-**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`.
+**1.084× tcc, +28,802 excess instructions** — down from 1.091× / +31,117 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. The object file (Mach-O vs tcc's
+ELF) is not comparable, and compile-speed lives in `PERF.md`.
**Reproduce:**
@@ -69,6 +69,24 @@ clang-differential probes). Design rationale lives in each commit message.
| **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 register~~ — **built, 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
@@ -145,14 +163,18 @@ operand). What is left is two distinct frontiers:
## 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 |
-| **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 |
+| # | Lever | Est. saving | Risk | §| status |
+|---|---|--:|---|---|---|
+| **1** | Args materialized into arg regs (L4 Phase 3) | ~30 K `mov` | high (frontend/CG-API + arg-shuffle correctness) | §4.1 | **open — #1 remaining** |
+| **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 L4-P1 (flip `ndt_result_reg_stable`) | rv64 only | low (on-target check) | §4.5 | open |
+
+After the single-pass follow-ons, **only Lever 1 (args→arg-regs) and the small
+rv64-only Lever 5 remain** within single-pass scope; everything else here is
+landed or dropped. Lever 1 is the dominant open win (`mov` +44,877).
**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
@@ -196,7 +218,14 @@ in a register across a control-flow join) is register allocation and stays out o
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)
+### 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
@@ -210,7 +239,10 @@ 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
+### 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
@@ -231,7 +263,12 @@ 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
+### 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
@@ -244,7 +281,16 @@ other ops that flush *before* reading an operand, all single-pass-local:
- **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
+### 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