kit

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

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:
Mdoc/plan/PERF-O0-CODESIZE.md | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
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