commit 175b6648929c92b677c5afed25e450da1a9cb2e0
parent af44b31a755fcb5fe512d1554b5157163a38fb48
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 08:19:26 -0700
perf(cg): -O0 lazy transient frame homes — home only on spill, reuse slots
Stage 4 of the value-stack residency work (PERF-TCC-SLIM §10c deferred): a
plain transient temp (CG_LOCAL_TRANSIENT && !address_taken && !memory_required)
no longer reserves a frame slot at creation. nd_alloc_local leaves home=NONE; a
single ensure-accessor nd_home() mints the slot lazily on first demand, so a
value that stays register-resident never costs frame space.
Every NDT read of a transient's home routes through nd_home — the loc/addr
builders (nd_loc_frame, nd_loc_operand, nd_addr_storage, nd_addr_pointer), the
spill in nd_flush_local, the scratch->home store in nd_store_operand_from_reg,
and the nd_call capture-then-flush (capture allocates the slot id, the later
flush writes the value into that same id). The va_arg / va_* / inline-asm
boundaries call nd_home_operand on each operand before crossing into arch code
that reads home directly: the va_arg destination and asm reg outputs are fresh
store-target transients with no prior spill, which would otherwise read
NATIVE_FRAME_SLOT_NONE -> "bad frame slot".
Reuse needs no new mechanism: lazily-minted slots carry
NATIVE_FRAME_SLOT_TRANSIENT, so they feed the existing statement-boundary
free-list (nd_reclaim_temps + native_frame_slot_alloc bins). Declared
locals/params and address-taken/wide8 temps keep eager homes (debug-loc +
stable-address; they also spill at the first barrier anyway).
sqlite3.c -c -O0, arm64-mac, RELEASE, vs the copy/convert-coalescing baseline:
emitted 457,115 -> 455,546 (-0.34%); object 2,222,960 -> 2,216,680 B (-0.28%);
mean frame 136 -> 112 B (-17.6%); frames >256B 236 -> 181 (-23.3%); sub xNN,x29
frame-addressing 27,403 -> 25,954 (-5.3%); 25 functions become frameless. As
§10c predicted: small emitted-instruction win (coalescing already prevents most
spills), real frame-size win.
Gate (run-correctness + determinism, not byte-identity): byte-deterministic
(two compiles cmp-identical); toy 1392/0, parse-ok 3920/0, parse-err 129/0,
cg-api, opt (-O1 unaffected), smoke-x64/rv64 3/0, debug+dwarf OK, libc musl
18/0 + glibc 9/0; clang-differential probe (va_arg mixed int/double lanes,
inline-asm reg output, struct return, shared-arg-live-across) matches clang.
Diffstat:
3 files changed, 116 insertions(+), 21 deletions(-)
diff --git a/doc/plan/PERF-TCC-SLIM.md b/doc/plan/PERF-TCC-SLIM.md
@@ -415,12 +415,44 @@ Two lessons:
home, and reloaded into the ABI arg register, behind a flush-everything that
also spilled non-live cached locals.
-**Deferred:** lazy transient frame homes (allocate a transient's home only on
-its first spill). With coalescing now preventing most spills the eager homes are
-benign — they cost frame space + a per-transient `frame_slot`/reclaim call, but
-not emitted instructions — and the lazy-home surface (every address builder /
-tail-call projection / spill / reclaim / debug-loc reads `->home`) is wide. Low
-metric payoff for the risk; revisit only if a re-profile shows `frame_slot` hot.
+## 10d. Measured results — lazy transient frame homes (Stage 4)
+
+The §10c-deferred follow-up landed: a plain transient temp
+(`CG_LOCAL_TRANSIENT && !address_taken && !memory_required`) reserves **no** frame
+slot at creation; `nd_alloc_local` leaves `home = NONE` and a single
+ensure-accessor `nd_home()` mints the slot on first demand. Every NDT read of a
+transient's `home` (loc/addr builders, the spill in `nd_flush_local`, the
+scratch→home store, the `nd_call` capture-then-flush, `nd_ret`) routes through
+`nd_home`; the va_arg/va_*/inline-asm boundaries call `nd_home_operand` on each
+operand before crossing into arch code that reads `home` directly (the va_arg
+destination and asm reg outputs are fresh store-target transients with no prior
+spill — without this they hit `NATIVE_FRAME_SLOT_NONE` → "bad frame slot"). The
+lazily-minted slots carry `NATIVE_FRAME_SLOT_TRANSIENT`, so they feed the
+existing statement-boundary free-list reuse (`nd_reclaim_temps`) with no new
+mechanism. Declared locals/params and address-taken/wide8 temps keep eager homes
+(debug-loc + stable-address needs; they also spill at the first barrier anyway).
+
+**sqlite3.c -c -O0, arm64-mac, RELEASE, vs the copy+convert-coalescing baseline:**
+
+| metric | base | lazy | Δ |
+|---|--:|--:|--:|
+| emitted instructions (text/4) | 457,115 | 455,546 | −0.34% |
+| object bytes | 2,222,960 | 2,216,680 | −0.28% |
+| **mean frame size** | 136 B | 112 B | **−17.6%** |
+| frames > 256 B (need x29-relative addr) | 236 | 181 | −23.3% |
+| `sub xNN, x29` (frame-address building) | 27,403 | 25,954 | −5.3% |
+| functions with a stack frame | 2,630 | 2,605 | 25 frameless |
+
+Exactly as §10c predicted: the **emitted-instruction win is small** (coalescing
+already prevents most spills, so eager homes were near-benign for the *instruction
+count*), but the **frame shrinks materially** — mean −17.6 %, a quarter fewer
+frames past the ±256 unscaled window, and 25 functions become frameless. That
+chips the Fix-B `sub xNN,x29` residual (§ in PERF-TCC-GAP) by 5.3 % without
+touching addressing. Byte-deterministic (two compiles `cmp`-identical);
+compile-time within wall-clock noise. Green across
+toy(1392)/parse-ok(3920)/parse-err(129)/cg-api/opt/smoke-x64/smoke-rv64/debug/
+dwarf/libc(musl 18+glibc 9), plus a clang-differential probe (va_arg mixed
+int/double lanes, inline-asm reg output, struct return, shared-arg-live-across).
## 11. What this is not
diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md
@@ -45,9 +45,11 @@ re-read).
kit beats clang on compile speed and is the fastest *general* backend here, but
**tcc is the bar**: ~3.22× instructions ahead. Closing that is the whole game.
(The 2.22 B / 4.26 MB figures earlier in this table's history predate the
-codesize track and the -O0 value-stack residency work; current is 2.13 B /
-2.29 MB — object now 1.09× tcc, emitted-instruction count 1.39× tcc. See
-PERF-TCC-SLIM.md §10c.)
+codesize track and the -O0 value-stack residency work; object is now ~2.22 MB
+(1.09× tcc) after value-stack residency + copy/convert coalescing + lazy
+transient frame homes. The last of these is frame-focused: mean -O0 frame
+-17.6 %, 25 sqlite functions frameless, `sub xNN,x29` frame-addressing -5.3 %.
+See PERF-TCC-SLIM.md §10c and §10d.)
### Reproducing the detailed measurements
diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c
@@ -228,6 +228,45 @@ static NativeFrameSlot nd_alloc_frame_slot(NativeDirectTarget* d,
return slot;
}
+/* Ensure LOCAL has a frame home, allocating one lazily on first demand.
+ * Transient temps are created without a home (nd_alloc_local) so a value that
+ * stays register-resident never reserves frame space; the slot is minted here
+ * the first time the value must live in memory (a spill, or a reader that needs
+ * the home address). The TRANSIENT flag wires the lazy slot into the same
+ * free-list reuse as eager transient homes (native_frame_slot_alloc pops a free
+ * bin first; nd_reclaim_temps recycles it at the next statement boundary).
+ * Non-transient locals and address-taken/memory-required temps are homed
+ * eagerly, so for them this is a pure pass-through. */
+static NativeFrameSlot nd_home(NativeDirectTarget* d, CGLocal local) {
+ NativeDirectLocal* l = nd_local(d, local);
+ if (l->home == NATIVE_FRAME_SLOT_NONE) {
+ NativeFrameSlotDesc fsd = {.type = l->type,
+ .size = l->size,
+ .align = l->align,
+ .kind = NATIVE_FRAME_SLOT_LOCAL,
+ .flags = NATIVE_FRAME_SLOT_TRANSIENT};
+ l->home = nd_alloc_frame_slot(d, &fsd);
+ }
+ return l->home;
+}
+
+/* Ensure the home(s) of every local an operand references are allocated before
+ * handing the operand to arch code that reads `home` directly (va_arg / asm
+ * helpers). A no-op for IMM/GLOBAL operands. */
+static void nd_home_operand(NativeDirectTarget* d, Operand op) {
+ switch ((OpKind)op.kind) {
+ case OPK_LOCAL:
+ nd_home(d, op.v.local);
+ break;
+ case OPK_INDIRECT:
+ nd_home(d, op.v.ind.base);
+ if (op.v.ind.index != CG_LOCAL_NONE) nd_home(d, op.v.ind.index);
+ break;
+ default:
+ break;
+ }
+}
+
static NativeFrameSlotDesc nd_slot_desc_local(const CGLocalDesc* in) {
NativeFrameSlotDesc out = {.type = in->type,
.name = in->name,
@@ -275,8 +314,16 @@ static CGLocal nd_alloc_local(NativeDirectTarget* d, const CGLocalDesc* desc) {
l->memory_required = (desc->flags & CG_LOCAL_MEMORY_REQUIRED) != 0;
l->transient = (desc->flags & CG_LOCAL_TRANSIENT) != 0;
l->cls = (u8)nd_class_for_type(d, desc->type);
- fsd = nd_slot_desc_local(desc);
- l->home = nd_alloc_frame_slot(d, &fsd);
+ if (l->transient && !l->address_taken && !l->memory_required) {
+ /* Lazy home: a plain transient temp reserves no frame slot until it must
+ * actually live in memory (nd_home, on first spill / home read). Address-
+ * taken and memory-required (wide8) temps need a stable home, so they stay
+ * eager. */
+ l->home = NATIVE_FRAME_SLOT_NONE;
+ } else {
+ fsd = nd_slot_desc_local(desc);
+ l->home = nd_alloc_frame_slot(d, &fsd);
+ }
if (l->transient) {
nd_grow_transient(d, d->ntransient + 1u);
d->transient_locals[d->ntransient++] = id;
@@ -311,7 +358,7 @@ static NativeLoc nd_loc_frame(NativeDirectTarget* d, CGLocal local,
return (NativeLoc){.kind = NATIVE_LOC_FRAME,
.cls = l->cls,
.type = type ? type : l->type,
- .v.frame = l->home};
+ .v.frame = nd_home(d, local)};
}
static NativeLoc nd_loc_imm(i64 imm, KitCgTypeId type) {
@@ -338,7 +385,7 @@ static NativeLoc nd_loc_operand(NativeDirectTarget* d, Operand op) {
.kind = NATIVE_LOC_ADDR,
.type = op.type,
.v.addr = {.base_kind = NATIVE_ADDR_BASE_FRAME_VALUE,
- .base.frame = bl->home,
+ .base.frame = nd_home(d, op.v.ind.base),
.cls = bl->cls,
.base_type = bl->type,
.log2_scale = op.v.ind.log2_scale,
@@ -346,7 +393,7 @@ static NativeLoc nd_loc_operand(NativeDirectTarget* d, Operand op) {
if (op.v.ind.index != CG_LOCAL_NONE) {
NativeDirectLocal* il = nd_local(d, op.v.ind.index);
out.v.addr.index_kind = NATIVE_ADDR_INDEX_FRAME_VALUE;
- out.v.addr.index.frame = il->home;
+ out.v.addr.index.frame = nd_home(d, op.v.ind.index);
out.v.addr.index_cls = il->cls;
out.v.addr.index_type = il->type;
}
@@ -368,7 +415,7 @@ static NativeAddr nd_addr_storage(NativeDirectTarget* d, Operand op) {
nd_flush_local(d, op.v.local);
l = nd_local(d, op.v.local);
return (NativeAddr){.base_kind = NATIVE_ADDR_BASE_FRAME,
- .base.frame = l->home,
+ .base.frame = nd_home(d, op.v.local),
.cls = l->cls,
.base_type = l->type};
}
@@ -391,7 +438,7 @@ static NativeAddr nd_addr_storage(NativeDirectTarget* d, Operand op) {
<< br; /* pin; unpinned at temps release */
} else {
out.base_kind = NATIVE_ADDR_BASE_FRAME_VALUE;
- out.base.frame = bl->home;
+ out.base.frame = nd_home(d, op.v.ind.base);
}
if (op.v.ind.index != CG_LOCAL_NONE) {
NativeDirectLocal* il = nd_local(d, op.v.ind.index);
@@ -404,7 +451,7 @@ static NativeAddr nd_addr_storage(NativeDirectTarget* d, Operand op) {
d->scratch_used[il->cls] |= 1u << ir;
} else {
out.index_kind = NATIVE_ADDR_INDEX_FRAME_VALUE;
- out.index.frame = il->home;
+ out.index.frame = nd_home(d, op.v.ind.index);
}
}
return out;
@@ -429,14 +476,14 @@ static NativeAddr nd_addr_pointer(NativeDirectTarget* d, Operand op) {
d->scratch_used[l->cls] |= 1u << r;
} else {
out.base_kind = NATIVE_ADDR_BASE_FRAME_VALUE;
- out.base.frame = l->home;
+ out.base.frame = nd_home(d, op.v.local);
}
} else {
/* The local's home is addressed directly; make it current first (see
* nd_addr_storage OPK_LOCAL). */
nd_flush_local(d, op.v.local);
out.base_kind = NATIVE_ADDR_BASE_FRAME;
- out.base.frame = l->home;
+ out.base.frame = nd_home(d, op.v.local);
}
return out;
}
@@ -641,7 +688,7 @@ static void nd_flush_local(NativeDirectTarget* d, CGLocal local) {
if (l->reg == REG_NONE) return;
if (l->dirty)
nd_store_reg_to_frame(
- d, l->home, l->type,
+ d, nd_home(d, local), l->type,
native_loc_reg(l->type, (NativeAllocClass)l->cls, l->reg));
nd_cache_unlink(d, local);
d->reg_owner[l->cls][l->reg] = CG_LOCAL_NONE;
@@ -1025,7 +1072,7 @@ static void nd_store_operand_from_reg(NativeDirectTarget* d, Operand dst,
{
NativeDirectLocal* l = nd_local(d, dst.v.local);
if (l->reg != REG_NONE) nd_invalidate_local(d, dst.v.local);
- nd_store_reg_to_frame(d, l->home, dst.type, src);
+ nd_store_reg_to_frame(d, nd_home(d, dst.v.local), dst.type, src);
}
}
@@ -1949,6 +1996,7 @@ static void nd_ret(CgTarget* t, CGLocal value) {
* spilled first (no production arch installs it today). */
if (d->ops && d->ops->emit_ret) {
nd_flush_all(d);
+ if (value != CG_LOCAL_NONE) nd_home(d, value); /* arch reads value from home */
d->ops->emit_ret(d, value);
return;
}
@@ -1997,6 +2045,10 @@ static void nd_alloca(CgTarget* t, Operand dst, Operand size, u32 align) {
static void nd_va_start(CgTarget* t, Operand ap_addr) {
NativeDirectTarget* d = nd_of(t);
nd_flush_all(d);
+ /* The arch va_* / asm helpers read operand locals' homes directly; with lazy
+ * homing a never-spilled transient operand (or a fresh store-target temp) has
+ * no home yet, so materialize one before crossing into arch code. */
+ nd_home_operand(d, ap_addr);
if (!d->ops || !d->ops->va_start_)
nd_panic(d, "target does not emit va_start");
d->ops->va_start_(d, ap_addr);
@@ -2006,6 +2058,8 @@ static void nd_va_arg(CgTarget* t, Operand dst, Operand ap_addr,
KitCgTypeId type) {
NativeDirectTarget* d = nd_of(t);
nd_flush_all(d);
+ nd_home_operand(d, dst); /* fresh store-target temp: home it before arch reads it */
+ nd_home_operand(d, ap_addr);
if (!d->ops || !d->ops->va_arg_) nd_panic(d, "target does not emit va_arg");
d->ops->va_arg_(d, dst, ap_addr, type);
}
@@ -2013,6 +2067,7 @@ static void nd_va_arg(CgTarget* t, Operand dst, Operand ap_addr,
static void nd_va_end(CgTarget* t, Operand ap_addr) {
NativeDirectTarget* d = nd_of(t);
nd_flush_all(d);
+ nd_home_operand(d, ap_addr);
if (!d->ops || !d->ops->va_end_) nd_panic(d, "target does not emit va_end");
d->ops->va_end_(d, ap_addr);
}
@@ -2020,6 +2075,8 @@ static void nd_va_end(CgTarget* t, Operand ap_addr) {
static void nd_va_copy(CgTarget* t, Operand dst_ap_addr, Operand src_ap_addr) {
NativeDirectTarget* d = nd_of(t);
nd_flush_all(d);
+ nd_home_operand(d, dst_ap_addr);
+ nd_home_operand(d, src_ap_addr);
if (!d->ops || !d->ops->va_copy_) nd_panic(d, "target does not emit va_copy");
d->ops->va_copy_(d, dst_ap_addr, src_ap_addr);
}
@@ -2136,6 +2193,10 @@ static void nd_asm_block(CgTarget* t, const char* tmpl,
nd_flush_all(d);
nd_barrier(d,
NATIVE_DIRECT_BARRIER_INLINE_ASM | NATIVE_DIRECT_BARRIER_MEMORY);
+ /* Outputs (incl. fresh reg-constraint store-targets) and inputs are bound by
+ * the arch helper reading their homes directly; materialize lazy homes now. */
+ for (u32 i = 0; i < nout; ++i) nd_home_operand(d, out_ops[i]);
+ for (u32 i = 0; i < nin; ++i) nd_home_operand(d, in_ops[i]);
if (d->ops && d->ops->asm_block) {
d->ops->asm_block(d, tmpl, outs, nout, out_ops, ins, nin, in_ops, clobbers,
nclob, clobber_abi_sets);