kit

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

commit 551c873fe9683e912226ead7bd5b14465accfa7e
parent 852e89287976ad5a2b10a2a6ac57ec7d7938b41a
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 15:39:41 -0700

doc(plan): PERF-O0-CODESIZE.md §6 — Lever 2 spill-reduction hand-off plan

Grounded code-trace of the 3x -O0 spill gap and a mostly-already-built fix,
written so a new agent can pick it up. Root cause: every NDT cache entry is a
dirty compute result and a flush stores them all, so a dead transient consumed
mid-statement is flush-stored at the next call/branch barrier (and inflates
eviction pressure). The liveness to avoid it already exists (api_temp_dead ->
OPK_FLAG_KILL) but is wired into only 2 producers (copy/convert src) and 1
consumer (nd_rename_killed_to_dst). Plan: 2a generalize KILL to all dead-transient
operands + have the NDT drop (not flush) killed operands on consumption; 2c skip
the cache flush at non-aliasing memory barriers (cached locals are non-address-
taken). 2b (residency across joins) stays out of -O0 scope (needs RA). Independent
of Lever 1; gated run-correctness+determinism+clang-differential per arch. Adds a
pointer from the §4.2 stub.

Diffstat:
Mdoc/plan/PERF-O0-CODESIZE.md | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 162 insertions(+), 2 deletions(-)

diff --git a/doc/plan/PERF-O0-CODESIZE.md b/doc/plan/PERF-O0-CODESIZE.md @@ -168,8 +168,15 @@ 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 and should follow Lever 1's destination-hint -surface. Measure the spill sites in the giant functions first to scope it. +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. ### 4.3 Lever 3 — byte/half far slots (extend L1) @@ -226,3 +233,156 @@ Small, low-risk, rv64-only win. clang-differential probe per arch for any register-residency change. - Re-profile after each landing and re-rank §4 — the highest lever moves as work is removed. + +--- + +## 6. Lever 2 in depth — spill reduction via eager dead-operand drop (hand-off plan) + +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 +already computed today. All line numbers are from `src/cg/native_direct_target.c` +(the NDT) unless noted; **re-verify them before editing — the file moves.** + +### 6.1 The cache model, as it actually is + +The authoritative summary is the comment at `native_direct_target.c:567–576`, and +the code matches it: + +- **Only scalar, non-address-taken locals are cached**, in caller-saved registers + (`reg_owner[cls][reg]` → `CGLocal`; per-local state in + `NativeDirectLocal.{reg,dirty,transient}`, `native_direct_target.h:30–59`). The + caller-saved-only pool (`cache_pool`, `.h:132–145`) is why values live across a + call must reach memory on aa64 — those registers are clobbered. +- **Every cache entry is a dirty compute result.** The only sites that set + `dirty = 1` are `nd_dst_writeback:1047` (binop/unop/cmp/convert destination), + `nd_rename_killed_to_dst:1096`, and the call-result cache (`:2014`/`:2082`). A + plain *read* never creates an entry (`:570`). So at any flush, **100% of cached + entries are dirty and every flush stores them all** (`nd_flush_local:695` stores + iff dirty — and they always are). +- **Cache lifetime = one straight-line run of compute ops.** `nd_flush_all:723` + empties it at ~20 barriers: control-flow joins (`nd_label_place:1282`, + `nd_jump:1289`, `nd_cmp_branch:1298`, `nd_switch:1308`, + `nd_indirect_branch:1317`), calls (`nd_flush_all_except_kept_args` at + `nd_call`, `:1992`), and memory/other barriers (volatile `:1576`/`:1633`, atomics + `:2205…:2279`, asm `:2289`, va/alloca/ret). +- **Drop-without-store already exists.** `nd_invalidate_local:711` drops an entry + with no write-back; `nd_drop_all:732` does it for the whole cache at `ret` + (frame about to die); `nd_reclaim_temps:1191` drops dead transients at the + *statement boundary* (`:1197`, no store) and recycles their lazy homes + (`nd_home:249`, lazy-home gate in `nd_alloc_local:326`). + +### 6.2 Root cause of the 3× spill (code-traced) + +Because every cache entry is a dirty compute result and a flush stores them all: +**a compute result whose value dies before the next barrier is still stored at +that barrier.** Two excess sources: + +1. **Dead transients flush-stored at *mid-statement* barriers.** `t = b*c; … + f(t) …`: `t` is a dirty entry, is consumed, becomes dead, but the call inside + the expression flushes it to a (lazily-minted) home. tcc keeps `t` in a + register and never stores it. In a *call-free* expression there is **no** + excess — `nd_reclaim_temps` drops dead transients at statement end with no + store. The excess is specifically expressions containing **calls / `&&` / + `||` / `?:` / comma** — i.e. most of sqlite. +2. **Eviction spills under pressure.** When `cache_pool` fills, `nd_cache_alloc:636` + → `nd_pick_cache_victim:613` → `nd_flush_local` **stores** the dirty victim. + A dead transient still holding a register inflates pressure → more eviction + stores. + +Named-local assignment stores are **not** excess — the cache merely defers the +one store tcc also does. The waste is entirely **dead transients lingering +between their last use and statement-boundary reclaim / eviction.** + +### 6.3 The lever already exists — it is just under-deployed + +kit already computes exactly the needed liveness: + +- `api_temp_dead` (`src/cg/value.c:569`) — sound dead-transient predicate + (refcount `local_refs` fast-reject + confirming stack scan; fields at + `src/cg/internal.h:208–223`). Reseat gaps cost at most a missed optimization, + never a miscompile (by construction). +- `OPK_FLAG_KILL` (`src/cg/cgir.h:216`) — the operand-level "dead last use" bit. + +But `OPK_FLAG_KILL` is **set in only two producers** — `src/cg/memory.c:534` +(copy source) and `src/cg/arith.c:338` (convert source) — and **consumed in only +one place**, `nd_rename_killed_to_dst:1075` (used at copy `:1539`, convert +`:1922`). Every other consumer ignores deadness: `nd_binop:1791` materializes its +operands, computes, then `nd_release_materialized:942` — which only *unpins +scratch*, never drops a dead operand's cache entry (`:1827–1828`). So a dead +transient binop/cmp/store/call-arg operand survives to be flush-stored. + +### 6.4 The change — 2a (primary) + 2c (secondary) + +**2a — eager dead-operand drop. The core lever.** +- *Producer (CG layer):* generalize the `memory.c:534` / `arith.c:338` pattern — + set `OPK_FLAG_KILL` on any operand `api_temp_dead` confirms is a dead transient, + at the remaining op emitters: binop/cmp operands (`src/cg/arith.c`), store value + (`src/cg/memory.c`), and call args (`src/cg/call.c`). A doubly-used temp (`a*a`, + `f(t,t)`) must **not** be killed on the non-final use — `api_temp_dead`'s + refcount already guards this; verify per call site. +- *Consumer (NDT):* after an op materializes-and-consumes an operand carrying + `OPK_FLAG_KILL`, `nd_invalidate_local` it (drop, **no store**) instead of + leaving it cached. Apply in `nd_binop` (after the op, at `:1827–1828`), the + cmp path, the store-value path, and the call-arg marshalling. The + drop-without-store primitive and the soundness argument already exist + (`nd_rename_killed_to_dst`'s comment, `:1059–1074`). +- *Effect:* the dead transient is never flush-stored (removes excess `stur`/`str`) + **and** its register frees immediately (lower pressure → fewer eviction spills, + hence fewer `ldur` reloads too). + +**2c — don't flush the cache at pure memory barriers. Secondary, sound, smaller.** +Because **only non-address-taken locals are cached** (`:568`), no pointer can +alias a cached value, so a `volatile`/`atomic`/`fence` barrier (`:1576`, `:1633`, +`:2205…:2279`) cannot touch one — yet each currently `nd_flush_all`s. Those +flushes are unnecessary: keep the cache across pure memory barriers. **Still +flush at calls** (clobber caller-saved regs) **and at inline-asm with clobbers** +(`:2289`). Rarer ops, so a smaller win, but free and correct. + +**2b — explicitly out of -O0 scope.** Keeping a *live* value in a register across +a control-flow join (label/branch merge) needs the same register on every +incoming edge = register allocation, which -O0 does not do; and live values +across a call must spill on aa64's caller-saved-only pool (tcc spills there too). +So the residency reach reduces to "make sure dead transients are gone *before* the +barrier," which 2a delivers. The existing `nd_flush_all_except_kept_args:752` / +`nd_call_arg_kept:740` is the call-site special case (it keeps only this call's +*dead args* resident to feed marshalling); 2a generalizes "dead ⇒ don't spill" to +all dead transients at all barriers. + +### 6.5 Why 2a is independent of Lever 1 (§4.1) + +Lever 1 (args into arg-regs) reduces **`mov`s** via a new *destination-hint* +surface (frontend → CG-API → backend). 2a reduces **spills** by acting on the +*existing* `OPK_FLAG_KILL` surface; it needs no destination hint and no new +analysis, so it can land first and independently. (The two compose: once Lever 1 +exists, a killed operand's freed register is a natural destination for the next +producer — but that is additive, not a prerequisite.) + +### 6.6 Gate & risk + +- **Not byte-identical** — it removes stores and frees registers, so allocation + shifts (deterministically). Gate = **run-correctness + determinism + + clang-differential**, the §5 codesize-track gate, **on aa64, x64, and rv64**. +- **Correctness rests entirely on `api_temp_dead`** being a true last-use; that + is already the proven predicate. Targeted differential probes to add: + dead-operand reuse, doubly-used temps (`a*a`, `f(t,t)`), a transient live across + a nested call (`f(t) + g(t*2)`), and a killed operand that is also the + destination's address base. +- **Cautionary precedent:** L4-P1's x86-64 RAX miscompile (now gated by + `ndt_result_reg_stable`, §2 footnote) — a shared-NDT change can be a win on aa64 + and a silent miscompile elsewhere. 2a has no implicit-register trap, but run the + per-arch differential probes regardless. + +### 6.7 Implementation checklist (for the picking-up agent) + +1. Re-verify §6.1/§6.3 line anchors against current source (the file moves). +2. Snapshot a golden + a baseline `.text` histogram (§1 reproduce block) and a + Linux-callgrind `Ir` baseline (`scripts/perf_callgrind.sh run pre2a`). +3. **Consumer first, behind a temporary always-false guard** is not possible + (KILL isn't set broadly yet); instead do producer+consumer for **one op + (binop)** end-to-end, gate, measure — prove the loop before fanning out. +4. Extend the producer (`OPK_FLAG_KILL` from `api_temp_dead`) to cmp, store-value, + call-arg; extend the consumer drop to each; gate after each op class. +5. Land 2c (skip flush at volatile/atomic/fence; keep at call/asm-clobber) + separately — it is orthogonal and individually gateable. +6. Re-profile: report the `.text` `stur`/`str`/`ldur` deltas (codesize) **and** the + compile-`Ir` delta (both should improve — fewer stores to emit). Re-rank §4.