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:
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.