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:
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);