kit

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

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:
Mdoc/plan/PERF-TCC-SLIM.md | 44++++++++++++++++++++++++++++++++++++++------
Mdoc/plan/PERF.md | 8+++++---
Msrc/cg/native_direct_target.c | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
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);