kit

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

commit 8674712d569b75d1bf334314acada9f59eb40ada
parent d3493a9dc57891958726cc00c1bb57d6a76f0c58
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 18:00:06 -0700

doc(plan): PERF-O0-CODESIZE.md — record Lever 1 landed (args→arg-regs, all 4 arches) + nd_gv

Lever 1 (the former #1 open lever) landed across aa64/x64/rv64/rv32: front the
ABI arg/ret registers in each backend's -O0 value-cache pool + materialize call
args arg0-first (api_pack_call_args_in_order) so producers land them directly in
the ABI registers and the parallel-copy is a no-op (tcc's get_reg(0..N) + eager
left-to-right model). Records the key finding — pool-fronting alone regressed
(reversed-arg-reg permutation cycles); forward-order production is what aligns
args with slots, so the §4.1 frontend destination-hint plan was dropped. Also
records Part B (nd_gv read-side unification, the gv(rc) analog) and folds Lever 5
(riscv result-in-a0) into the rv leg. aa64 sqlite 1.084×→1.044× tcc (-3.72%).

Diffstat:
Mdoc/plan/PERF-O0-CODESIZE.md | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
1 file changed, 92 insertions(+), 35 deletions(-)

diff --git a/doc/plan/PERF-O0-CODESIZE.md b/doc/plan/PERF-O0-CODESIZE.md @@ -25,13 +25,15 @@ follow once the design is proven. | metric | kit | tcc 0.9.28 | ratio | |---|--:|--:|--:| -| **machine code (`.text`)** | **1,486,148 B / 371,537 insns** | **1,370,940 B / 342,735 insns** | **1.084×** | +| **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.084× tcc, +28,802 excess instructions** — down from 1.091× / +31,117 after the +**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. The object file (Mach-O vs tcc's -ELF) is not comparable, and compile-speed lives in `PERF.md`. +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:** @@ -97,6 +99,29 @@ for free — **x86-64 sqlite `.text` −3.18%**, rv64 likewise (smoke-green). L1 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 @@ -163,18 +188,21 @@ operand). What is left is two distinct frontiers: ## 4. Remaining levers (ranked, post-Lever-2) -| # | Lever | Est. saving | Risk | §| status | +| # | Lever | 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** | +| **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 L4-P1 (flip `ndt_result_reg_stable`) | rv64 only | low (on-target check) | §4.5 | open | +| **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 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). +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 `mov`s 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 @@ -189,25 +217,52 @@ 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) - -The `mov` excess is now dominated by **arg setup**: ~30 K `mov xK, reg` moving an -argument from a general cache register into x0–x7 at the call. The arg's producer -ran *before* the call, so it wrote a cache-allocator register; `native_arg_shuffle` -then moves it into the ABI arg register. There is **no backend-only fix** — a -cached arg costs exactly one `mov`, and forcing it through the home instead -(load-into-xK) trades the `mov` for a spill+reload (worse). - -The fix is to **produce the arg directly in xK**, which needs the call's -arg-register target threaded *back* to the arg-expression producer. The frontend -knows, when generating arg-expr *k* of `f(a0,a1,…)`, that its result feeds arg -slot *k*; the CG API has no way to express "destined for arg *k*" today. Add that -hint (an abstract arg-slot the arch resolves to xK), have the NDT allocate xK as -the producer's destination when free, and let `native_arg_shuffle` resolve the -rare cross-arg cycles (tcc spills conflicts to the stack first). Biggest single -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.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 `mov`s +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) @@ -255,13 +310,15 @@ load at the promoted width with a signedness rider on `MemAccess`, so `aa_emit_m picks `ldrsb`/`ldrsh` and no separate convert is generated. Frontend + CG-API + encoder; ~1,242 insns. -### 4.5 Lever 5 — rv64 L4-P1 +### 4.5 Lever 5 — rv64/rv32 L4-P1 — LANDED (with Lever 1's rv leg) -riscv64's `a0` is clobbered only at call boundaries (div/rem/mul take explicit -operands), so result-in-result-register caching is sound there — flip -`ndt_result_reg_stable` for rv64 after an **on-target div-after-result check** -(build the p1-x64 probe for rv64 and run it through the emulator / a rv64 host). -Small, low-risk, rv64-only win. +`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)