kit

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

commit 3516b3d341e45839a621d05bf189000302a461f0
parent 2cd7230758eac2d58870f15f88b9a3b389961c6d
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 19:08:00 -0700

perf(cg): reuse transient temp frame slots at -O0 (sqlite 2.42x->1.75x tcc)

The single-pass -O0 path minted a fresh, never-freed frame slot for every
subexpression temp (api_alloc_temp_local), so frames ballooned (sqlite max
~39.5KB) and most slot accesses needed sub x17,x29,#N (+movz/movk) address
computation past the aa64 unscaled +/-256 window.

Recycle a temp's home when it dies. A temp is provably dead at a statement
boundary, where the CG value stack is empty (g->sp == 0): no value references it.
New kit_cg_reclaim_temps(), called from the C frontend's parse_compound_stmt
loop, returns such temps' frame slots to a per-(size,align) free list in
native_frame; the next statement's temps reuse them. This bounds the frame to
max-simultaneous-live temps instead of a per-subexpression sum.

The reclaim only fires at g->sp == 0, sidestepping the lazy-alias hazard that
sank a prior per-op reuse attempt (s.a++ + ++s.b must stay 3): the -O0 value
stack holds results as lazy references to temp locals, so a temp is NOT dead
just because "its op finished" — only when nothing is on the stack. The NDT
drops (does not spill) any cached+dirty transient before recycling its home, so
a later flush can't resurrect a dead value into a reused slot. Declared
locals/params (never CG_LOCAL_TRANSIENT) are untouched and live across
statements. address-taken transients are excluded conservatively.

Mechanism is shared (native_frame free list + release_frame_slot NativeTarget
hook on aa64/x64/rv64); policy is NDT-only (reclaim_temps CgTarget hook, NULL on
the recording/C-source targets, so the optimizer/-O1 path is unaffected).

sqlite3.c -c, arm64-macOS: 831,078 -> 599,488 insns (-27.9%); movk 86,992 ->
4,450 (-95%), sub -51%, movz -50%; object 3.72MB -> 2.79MB; max frame 39.5KB ->
6.6KB. Now 1.75x tcc (was 2.42x). Compile is also ~9.5% faster (2.20B vs 2.43B
instructions) from emitting far fewer instructions.

test-toy 1392/0, test-parse-ok 3920/0 (O0/O1 x D/R/J/E), test-parse-err 129/0,
test-smoke-x64/rv64 3/0, test-cg-api/opt 0 fail; sqlite shell links+runs (84|2).

Diffstat:
Minclude/kit/cg.h | 9+++++++++
Mlang/c/parse/cg_adapter.c | 10++++++++++
Mlang/c/parse/cg_adapter.h | 3+++
Mlang/c/parse/parse_stmt.c | 4++++
Msrc/arch/aa64/native.c | 5+++++
Msrc/arch/native_target.h | 12++++++++++++
Msrc/arch/riscv/native.c | 5+++++
Msrc/arch/x64/native.c | 5+++++
Msrc/cg/cgir.h | 7+++++++
Msrc/cg/cgtarget.h | 8++++++++
Msrc/cg/native_direct_target.c | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/cg/native_direct_target.h | 10++++++++++
Msrc/cg/native_frame.c | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/cg/native_frame.h | 26++++++++++++++++++++++++--
Msrc/cg/session.c | 10++++++++++
Msrc/cg/value.c | 4++++
16 files changed, 220 insertions(+), 3 deletions(-)

diff --git a/include/kit/cg.h b/include/kit/cg.h @@ -491,6 +491,15 @@ KIT_API void kit_cg_func_begin_attrs(KitCg*, KitCgSym sym, KitCgFuncAttrs attrs); KIT_API void kit_cg_func_end(KitCg*); +/* Reclaim the storage of dead compiler temporaries. A frontend calls this at a + * full-expression / statement boundary where it has consumed all values it + * pushed (the value stack is empty); the single-pass -O0 backend then recycles + * the frame homes of its per-subexpression temps, bounding the frame to + * max-simultaneous-live temps. A no-op when the value stack is non-empty or the + * backend does not recycle (optimizer / C-source targets). Purely an + * optimization: omitting it only enlarges the frame. */ +KIT_API void kit_cg_reclaim_temps(KitCg*); + typedef enum KitCgLocalFlag { KIT_CG_LOCALFLAG_NONE = 0, KIT_CG_LOCAL_ARTIFICIAL = 1u << 1, diff --git a/lang/c/parse/cg_adapter.c b/lang/c/parse/cg_adapter.c @@ -127,6 +127,16 @@ void pcg_drop(Parser* p) { pcg_drop_type(p); } +/* Reclaim dead compiler-temp slots at a statement boundary. The authoritative + * "no value is live" witness is the CG value stack depth, which + * kit_cg_reclaim_temps checks (g->sp == 0): a stray non-empty call is a safe + * no-op rather than a miscompile. (The parser's typed shadow stack cg_type_sp is + * NOT a reliable witness — it is not drained in lockstep with the value stack.) */ +void pcg_reclaim_temps(Parser* p) { + if (!pcg_emit_enabled(p)) return; + kit_cg_reclaim_temps(p->cg); +} + PcgLvAux* pcg_top_lv_aux(Parser* p) { return p->cg_type_sp ? &p->cg_slot_stack[p->cg_type_sp - 1u].aux : NULL; } diff --git a/lang/c/parse/cg_adapter.h b/lang/c/parse/cg_adapter.h @@ -255,6 +255,9 @@ void pcg_swap_type(Parser*); void pcg_dup(Parser*); void pcg_swap(Parser*); void pcg_drop(Parser*); +/* Reclaim dead compiler-temp frame slots at a statement boundary; safe because + * kit_cg_reclaim_temps acts only when the CG value stack is empty (g->sp == 0). */ +void pcg_reclaim_temps(Parser*); int pcg_top_is_bitfield(Parser*); void pcg_set_top_bitfield(Parser*); int pcg_top_is_register(Parser*); diff --git a/lang/c/parse/parse_stmt.c b/lang/c/parse/parse_stmt.c @@ -871,6 +871,10 @@ void parse_compound_stmt(Parser* p) { parse_stmt(p); } } + /* Statement boundary: the value stack is back to empty, so every transient + * compiler temp minted by this statement is dead. Recycle their frame homes + * for the next statement, bounding the -O0 frame. */ + pcg_reclaim_temps(p); } expect_punct(p, '}', "'}'"); scope_pop(p); diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c @@ -1695,6 +1695,10 @@ static NativeFrameSlot aa_frame_slot(NativeTarget* t, return native_frame_slot_alloc(&aa_of(t)->frame, d); } +static void aa_release_frame_slot(NativeTarget* t, NativeFrameSlot slot) { + native_frame_release_slot(&aa_of(t)->frame, slot); +} + static int aa_frame_slot_debug_loc(NativeTarget* t, NativeFrameSlot slot, CGDebugLoc* out) { AANativeTarget* a = aa_of(t); @@ -4055,6 +4059,7 @@ NativeTarget* aa64_native_target_new(Compiler* c, ObjBuilder* obj, t->store_zero_reg = 31u; /* wzr/xzr in the Rt position of a store */ t->func_end = aa_func_end; t->frame_slot = aa_frame_slot; + t->release_frame_slot = aa_release_frame_slot; t->frame_slot_debug_loc = aa_frame_slot_debug_loc; t->bind_param = aa_bind_native_param; t->label_new = aa_label_new; diff --git a/src/arch/native_target.h b/src/arch/native_target.h @@ -31,6 +31,11 @@ typedef enum NativeFrameSlotFlag { NATIVE_FRAME_SLOT_ADDR_TAKEN = 1u << 0, NATIVE_FRAME_SLOT_MEMORY_REQUIRED = 1u << 1, NATIVE_FRAME_SLOT_FIXED_OFFSET = 1u << 2, + /* A recyclable single-pass temp slot. native_frame_slot_alloc serves such a + * request from the per-(size,align) free list first, before bumping cum_off; + * native_frame_release_slot returns the slot there when its temp dies at a + * statement boundary. Bounds the -O0 frame to max-simultaneous-live temps. */ + NATIVE_FRAME_SLOT_TRANSIENT = 1u << 3, } NativeFrameSlotFlag; typedef struct NativeFrameSlotDesc { @@ -439,6 +444,13 @@ struct NativeTarget { void (*func_end)(NativeTarget*); NativeFrameSlot (*frame_slot)(NativeTarget*, const NativeFrameSlotDesc*); + /* Optional. Return a recyclable single-pass temp slot (one previously handed + * out for a NATIVE_FRAME_SLOT_TRANSIENT desc) to the frame's free list so a + * later transient allocation of the same (size,align) reuses it instead of + * growing the frame. Called by NativeDirectTarget's reclaim_temps at statement + * boundaries. NULL means the backend does not recycle (frame still correct, + * just larger). */ + void (*release_frame_slot)(NativeTarget*, NativeFrameSlot); /* Optional post-finalization query for a native frame slot's debug location. * Each arch owns the frame layout math and returns the coordinate system its * debugger/unwinder path can materialize. */ diff --git a/src/arch/riscv/native.c b/src/arch/riscv/native.c @@ -1462,6 +1462,10 @@ static NativeFrameSlot rv_frame_slot(NativeTarget* t, return native_frame_slot_alloc(&rv_of(t)->frame, d); } +static void rv_release_frame_slot(NativeTarget* t, NativeFrameSlot slot) { + native_frame_release_slot(&rv_of(t)->frame, slot); +} + static int rv_frame_slot_debug_loc(NativeTarget* t, NativeFrameSlot slot, CGDebugLoc* out) { RvNativeTarget* a = rv_of(t); @@ -4024,6 +4028,7 @@ NativeTarget* rv64_native_target_new(Compiler* c, ObjBuilder* obj, t->store_zero_reg = RV_ZERO; t->func_end = rv_func_end; t->frame_slot = rv_frame_slot; + t->release_frame_slot = rv_release_frame_slot; t->frame_slot_debug_loc = rv_frame_slot_debug_loc; t->bind_param = rv_bind_native_param; t->label_new = rv_label_new; diff --git a/src/arch/x64/native.c b/src/arch/x64/native.c @@ -1565,6 +1565,10 @@ static NativeFrameSlot x64_frame_slot(NativeTarget* t, return native_frame_slot_alloc(&x64_of(t)->frame, d); } +static void x64_release_frame_slot(NativeTarget* t, NativeFrameSlot slot) { + native_frame_release_slot(&x64_of(t)->frame, slot); +} + static int x64_frame_slot_debug_loc(NativeTarget* t, NativeFrameSlot slot, CGDebugLoc* out) { X64NativeTarget* a = x64_of(t); @@ -4292,6 +4296,7 @@ NativeTarget* x64_native_target_new(Compiler* c, ObjBuilder* obj, t->has_store_zero_reg = 0; t->func_end = x64_func_end; t->frame_slot = x64_frame_slot; + t->release_frame_slot = x64_release_frame_slot; t->frame_slot_debug_loc = x64_frame_slot_debug_loc; t->bind_param = x64_bind_native_param; t->label_new = x64_label_new; diff --git a/src/cg/cgir.h b/src/cg/cgir.h @@ -206,6 +206,13 @@ typedef enum CGLocalFlag { CG_LOCAL_FLAG_NONE = 0, CG_LOCAL_ADDR_TAKEN = 1u << 0, CG_LOCAL_MEMORY_REQUIRED = 1u << 1, + /* A transient compiler temporary minted per-subexpression (api_alloc_temp_local + * is its sole producer). Its frame home is recyclable: at a statement boundary + * the value stack holds no live reference to it, so the single-pass backend + * returns its slot to a free list and reuses it for the next statement's temps + * (kit_cg_reclaim_temps). Declared locals/params never carry this flag and keep + * their home for the whole function. */ + CG_LOCAL_TRANSIENT = 1u << 2, } CGLocalFlag; typedef struct CGLocalDesc { diff --git a/src/cg/cgtarget.h b/src/cg/cgtarget.h @@ -70,6 +70,14 @@ struct CgTarget { * target-neutral CGDebugLoc into the debug producer API. */ int (*local_debug_loc)(CgTarget*, CGLocal, CGDebugLoc*); + /* Optional. Reclaim the frame homes of transient compiler temporaries + * (CG_LOCAL_TRANSIENT) that are now dead. cg calls this only at a statement + * boundary where the value stack is empty (kit_cg_reclaim_temps), so no value + * references any temp; the single-pass backend drops their cache entries and + * recycles their slots, bounding the -O0 frame. NULL on the recording/C-source + * targets, which own frame layout differently. */ + void (*reclaim_temps)(CgTarget*); + /* ---- labels and control flow ---- */ Label (*label_new)(CgTarget*); void (*label_place)(CgTarget*, Label); diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -86,6 +86,19 @@ static void nd_grow_locals(NativeDirectTarget* d, u32 want) { d->locals_cap = cap; } +static void nd_grow_transient(NativeDirectTarget* d, u32 want) { + CGLocal* next; + u32 cap; + if (d->transient_cap >= want) return; + cap = d->transient_cap ? d->transient_cap : 32u; + while (cap < want) cap *= 2u; + next = nd_arena(d, sizeof(*next) * cap, _Alignof(CGLocal)); + if (d->transient_locals) + memcpy(next, d->transient_locals, sizeof(*next) * d->ntransient); + d->transient_locals = next; + d->transient_cap = cap; +} + static void nd_grow_labels(NativeDirectTarget* d, u32 want) { MCLabel* next; u32 cap; @@ -226,6 +239,8 @@ static NativeFrameSlotDesc nd_slot_desc_local(const CGLocalDesc* in) { out.flags |= NATIVE_FRAME_SLOT_ADDR_TAKEN; if (in->flags & CG_LOCAL_MEMORY_REQUIRED) out.flags |= NATIVE_FRAME_SLOT_MEMORY_REQUIRED; + if (in->flags & CG_LOCAL_TRANSIENT) + out.flags |= NATIVE_FRAME_SLOT_TRANSIENT; return out; } @@ -258,9 +273,14 @@ static CGLocal nd_alloc_local(NativeDirectTarget* d, const CGLocalDesc* desc) { l->reg = REG_NONE; l->address_taken = (desc->flags & CG_LOCAL_ADDR_TAKEN) != 0; 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) { + nd_grow_transient(d, d->ntransient + 1u); + d->transient_locals[d->ntransient++] = id; + } return id; } @@ -933,6 +953,7 @@ static void nd_func_begin(CgTarget* t, const CGFuncDesc* fd) { NativeDirectTarget* d = nd_of(t); d->func = fd; d->nlocals = 0; + d->ntransient = 0; d->nlabels = 0; d->nscopes = 0; d->max_outgoing = 0; @@ -984,6 +1005,36 @@ static void nd_func_end(CgTarget* t) { d->func = NULL; } +/* Reclaim dead transient temp slots at a statement boundary. cg calls this only + * when its value stack is empty, so every transient minted since the last + * reclaim is provably dead: nothing on the stack references it. + * + * For each such temp we DROP (not spill) any live cache entry — a pure-compute + * expression statement can leave a transient cached+dirty, and spilling it would + * write a dead value into a home we are about to recycle, then a later + * nd_flush_all could resurrect it into a different temp now sharing that slot. + * Then we return its frame home to the free list for the next statement's temps. + * + * An address-taken transient is excluded conservatively: nd_local_addr already + * flushed it and marked it uncacheable, and a pointer to its home may have + * escaped, so we keep its slot live (it is never cached here). At -O0 no C + * construct takes the address of a compiler temp, so this branch is belt-and- + * suspenders. Non-transient locals (declared locals/params) are untouched — + * their cached/dirty values legitimately live across statements. */ +static void nd_reclaim_temps(CgTarget* t) { + NativeDirectTarget* d = nd_of(t); + for (u32 i = 0; i < d->ntransient; ++i) { + CGLocal local = d->transient_locals[i]; + NativeDirectLocal* l = nd_local(d, local); + if (l->address_taken) continue; + nd_invalidate_local(d, local); /* drop cache entry, no write-back */ + if (d->native && d->native->release_frame_slot && + l->home != NATIVE_FRAME_SLOT_NONE) + d->native->release_frame_slot(d->native, l->home); + } + d->ntransient = 0; +} + static void nd_alias(CgTarget* t, ObjSymId alias_sym, ObjSymId target_sym, KitCgTypeId type) { (void)t; @@ -1988,6 +2039,7 @@ CgTarget* native_direct_target_new(Compiler* c, ObjBuilder* obj, d->base.local_addr = nd_local_addr; d->base.param = nd_param; d->base.local_debug_loc = nd_local_debug_loc; + d->base.reclaim_temps = nd_reclaim_temps; d->base.label_new = nd_label_new; d->base.label_place = nd_label_place; d->base.jump = nd_jump; diff --git a/src/cg/native_direct_target.h b/src/cg/native_direct_target.h @@ -39,6 +39,8 @@ typedef struct NativeDirectLocal { u8 dirty; u8 address_taken; u8 memory_required; + u8 transient; /* a per-subexpression temp; its home is reclaimable at a + * statement boundary (see nd_reclaim_temps) */ u32 last_use; /* d->use_tick at the most recent cache touch (LRU victim key) */ /* Intrusive doubly-linked list of currently-cached locals, in insertion @@ -142,6 +144,14 @@ struct NativeDirectTarget { u32 nlocals; u32 locals_cap; + /* Transient temp locals minted since the last reclaim (statement boundary), in + * allocation order. nd_reclaim_temps drains this to invalidate their cache + * entries and return their frame homes to the free list; bounded by one + * statement's temp depth. */ + CGLocal* transient_locals; + u32 ntransient; + u32 transient_cap; + MCLabel* labels; u32 nlabels; u32 labels_cap; diff --git a/src/cg/native_frame.c b/src/cg/native_frame.c @@ -20,22 +20,50 @@ void native_frame_init(NativeFrame* f, Compiler* c) { void native_frame_reset(NativeFrame* f) { /* Keep the slots buffer (slots/slots_cap) for reuse across functions in the - * translation unit; nslots = 0 logically clears it. */ + * translation unit; nslots = 0 logically clears it. The transient free list is + * per-function: dropping nfree_bins discards any stale buckets, and each fresh + * slot_alloc reinitializes its entry's free_next/in_free, so no stale handle + * from the previous function can be served. */ f->nslots = 0; f->cum_off = 0; f->max_outgoing = 0; f->ncallee_saves = 0; + f->nfree_bins = 0; f->frame_final = 0; f->known_frame = 0; f->has_alloca = 0; } +/* Find the free bucket for (size,align), or NULL. */ +static u32* nf_free_bin_find(NativeFrame* f, u32 size, u32 align) { + for (u32 i = 0; i < f->nfree_bins; ++i) + if (f->free_bins[i].size == size && f->free_bins[i].align == align) + return &f->free_bins[i].head; + return NULL; +} + NativeFrameSlot native_frame_slot_alloc(NativeFrame* f, const NativeFrameSlotDesc* d) { NativeFrameSlotEntry* s; u32 size = d->size ? d->size : 8u; u32 align = d->align ? d->align : 1u; if (f->frame_final) nf_panic(f, "frame slot requested after prologue"); + /* A transient temp first tries to reuse a dead slot of the exact same + * (size,align): its home offset already satisfies the alignment, so handing + * the same handle back is safe and leaves cum_off (the frame high-water mark) + * unchanged. */ + if (d->flags & NATIVE_FRAME_SLOT_TRANSIENT) { + u32* head = nf_free_bin_find(f, size, align); + if (head && *head != NATIVE_FRAME_SLOT_NONE) { + NativeFrameSlot slot = *head; + s = &f->slots[slot - 1u]; + *head = s->free_next; + s->free_next = NATIVE_FRAME_SLOT_NONE; + s->in_free = 0; + s->kind = d->kind; + return slot; + } + } if (f->nslots == f->slots_cap) { u32 cap = f->slots_cap ? f->slots_cap * 2u : 16u; NativeFrameSlotEntry* nb = @@ -49,10 +77,33 @@ NativeFrameSlot native_frame_slot_alloc(NativeFrame* f, s->off = f->cum_off; s->size = size; s->align = align; + s->free_next = NATIVE_FRAME_SLOT_NONE; s->kind = d->kind; + s->in_free = 0; return (NativeFrameSlot)f->nslots; } +void native_frame_release_slot(NativeFrame* f, NativeFrameSlot slot) { + NativeFrameSlotEntry* s; + u32* head; + if (slot == NATIVE_FRAME_SLOT_NONE || slot > f->nslots) return; + s = &f->slots[slot - 1u]; + if (s->in_free) return; /* already released */ + head = nf_free_bin_find(f, s->size, s->align); + if (!head) { + if (f->nfree_bins >= (u32)(sizeof f->free_bins / sizeof f->free_bins[0])) + return; /* bucket table full: don't recycle this shape (still correct) */ + f->free_bins[f->nfree_bins].size = s->size; + f->free_bins[f->nfree_bins].align = s->align; + f->free_bins[f->nfree_bins].head = NATIVE_FRAME_SLOT_NONE; + head = &f->free_bins[f->nfree_bins].head; + f->nfree_bins++; + } + s->free_next = *head; + *head = slot; + s->in_free = 1; +} + NativeFrameSlotEntry* native_frame_slot_at(NativeFrame* f, NativeFrameSlot slot) { if (slot == NATIVE_FRAME_SLOT_NONE || slot > f->nslots) diff --git a/src/cg/native_frame.h b/src/cg/native_frame.h @@ -36,8 +36,10 @@ typedef struct NativeFrameSlotEntry { u32 off; u32 size; u32 align; - u8 kind; /* NativeFrameSlotKind */ - u8 pad[3]; + NativeFrameSlot free_next; /* next free slot in its size/align bin, when in_free */ + u8 kind; /* NativeFrameSlotKind */ + u8 in_free; /* on the free list (guards double-release) */ + u8 pad[2]; } NativeFrameSlotEntry; /* A callee-saved register the function body used and must preserve. `slot` is @@ -76,6 +78,20 @@ typedef struct NativeFrame { NativeFrameCalleeSave callee_saves[NATIVE_FRAME_MAX_CALLEE_SAVES]; u32 ncallee_saves; + /* Free list of recyclable transient temp slots, bucketed by exact + * (size,align). A NATIVE_FRAME_SLOT_TRANSIENT allocation pops a same-bucket + * slot before bumping cum_off; native_frame_release_slot pushes a dead temp's + * slot back. Buckets are LIFO stacks threaded through + * NativeFrameSlotEntry.free_next; the per-function distinct (size,align) set is + * tiny (scalar temps: 4/4, 8/8, 16/16, ...), so a linear bucket scan is O(1) in + * practice. Overflow past NATIVE_FRAME_FREE_BINS simply doesn't recycle. */ + struct { + u32 size; + u32 align; + NativeFrameSlot head; + } free_bins[16]; + u32 nfree_bins; + u8 frame_final; /* set once the prologue is emitted; bars further slots */ u8 known_frame; /* optimizer (known-frame) path vs single-pass path */ u8 has_alloca; /* body contains a dynamic alloca */ @@ -96,6 +112,12 @@ void native_frame_reset(NativeFrame* f); NativeFrameSlot native_frame_slot_alloc(NativeFrame* f, const NativeFrameSlotDesc* d); +/* Return a previously-allocated transient slot to the free list so a later + * NATIVE_FRAME_SLOT_TRANSIENT allocation of the same (size,align) reuses it. + * Idempotent (a slot already on the free list is ignored). The slot's home + * offset is preserved; only the next-temp ownership is recycled. */ +void native_frame_release_slot(NativeFrame* f, NativeFrameSlot slot); + /* Resolve a 1-indexed handle to its entry. Panics on an out-of-range handle. */ NativeFrameSlotEntry* native_frame_slot_at(NativeFrame* f, NativeFrameSlot slot); diff --git a/src/cg/session.c b/src/cg/session.c @@ -490,5 +490,15 @@ void kit_cg_func_end(KitCg* g) { if (g->scopes) memset(g->scopes, 0, sizeof(*g->scopes) * g->scopes_cap); } +void kit_cg_reclaim_temps(KitCg* g) { + if (!g) return; + /* Only safe when the value stack is empty: every transient temp is then + * provably dead. The guard also makes this a no-op inside any future construct + * (e.g. a statement-expression) that leaves a value live across a boundary. */ + if (g->sp != 0) return; + if (g->target && g->target->reclaim_temps) + g->target->reclaim_temps(g->target); +} + void api_call_symbol_common(KitCg* g, KitCgSym sym, uint32_t nargs, KitCgCallAttrs attrs); diff --git a/src/cg/value.c b/src/cg/value.c @@ -217,6 +217,10 @@ CGLocal api_alloc_temp_local(KitCg* g, KitCgTypeId ty) { CGLocal local; memset(&d, 0, sizeof d); d.type = ty; + /* A compiler temporary: its home is reclaimable once the value stack drops it + * at a statement boundary (kit_cg_reclaim_temps), so the single-pass frame is + * bounded by max-simultaneous-live temps rather than a per-subexpression sum. */ + d.flags |= CG_LOCAL_TRANSIENT; if (ty) { d.size = abi_cg_sizeof(g->c->abi, ty); d.align = abi_cg_alignof(g->c->abi, ty);