kit

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

commit 3dbbfcf1bb2cc2e23eee8a8f147c107b5d3d84bc
parent 551c873fe9683e912226ead7bd5b14465accfa7e
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 16:20:04 -0700

perf(cg): -O0 spill reduction via eager dead-operand drop + branch reorder (PERF-O0-CODESIZE §6, Lever 2)

Implements doc/plan/PERF-O0-CODESIZE.md §6 (Lever 2: reduce -O0 frame
spilling). sqlite3.c .text on arm64-macOS: 398,433 -> 373,852 insns
(-24,581, -6.17%); 1.16x -> 1.091x tcc (excess +55,698 -> +31,117, ~44%
of the remaining excess removed). Deterministic; not byte-identical (it
removes spills/reloads, so allocation shifts).

Three sub-changes, all on the shared NativeDirectTarget (-O0):

2a  Eager dead-operand drop. The NDT cache holds dirty compute results
    and flushes the whole live set at every barrier (call/branch/...), so
    a transient whose value dies before the next barrier is still
    flush-stored there. New api_op_kill_if_dead (cg layer) sets the
    existing OPK_FLAG_KILL on any operand api_temp_dead confirms is a dead
    transient (distinct from the op's destination); new
    nd_drop_killed_operand (NDT) drops such an operand's cache entry
    WITHOUT a write-back after the op consumes it. Wired into binop, cmp,
    unop, store-value (cg: arith.c/fold.c/memory.c; NDT consumers). This
    generalizes the copy/convert OPK_FLAG_KILL pattern that already
    existed; call-args were already handled via arg_dead_mask. Safe-set
    alone: -2,533 insns (-1,927 stur, -419 str).

    Branch barriers (the &&/||/?:/if case §6.2 names as the main excess):
    nd_cmp_branch flushed FIRST, then materialized the compare operands —
    spilling a cached operand and immediately reloading it for the
    compare. Reorder to materialize-then-drop-killed-then-flush: a
    cache-resident operand is read from its register (no reload), a dead
    one is dropped (no store), and the flush still spills everything live
    so the post-branch cache is empty (merge correctness unchanged). The
    flush's stores precede the compare, so they cannot clobber its flags;
    rv64's fused compare-branch is equally fine. control.c flags the
    SV_CMP and truthiness operands dead. Reorder: -11,908 (mostly ldur);
    branch kill: -10,079 (-7,095 stur, -2,125 str).

2c  Skip the value-cache flush at pure memory barriers (volatile
    load/store, atomic load/store/rmw/cas, fence). The cache holds only
    non-escaped (non-address-taken, non-memory-required, hence
    non-volatile) locals, which no pointer/other-agent can alias, so these
    barriers can neither observe a cached value's stale home nor require
    its write-back. The arch barrier hook / fence instruction still fires.
    ~0 on sqlite (few such ops) but sound and removes wasted work.

Gate (§5/§6.6 codesize-track, all green): determinism (self-cmp x2);
toy aa64 1392/0; toy cross-arch exec on x64+rv64 753/0; parse-ok (D/R/E/J)
/parse-err; dwarf; debug; cg-api; opt; smoke-x64/rv64; libc-glibc
(atomics/volatile, for 2c); aa64-inline; isa; link; ecosystem sqlite O0+O1
golden + vs-clang (real queries run); and clang-differential probes at
O0+O1 (dead operands across call/branch barriers, a*a / f(t,t)
doubly-used temps, transients live across nested calls, store-then-call,
volatile+atomic interleaved with cached locals).

Diffstat:
Msrc/cg/arith.c | 6++++++
Msrc/cg/control.c | 11++++++++++-
Msrc/cg/fold.c | 15++++++++++++---
Msrc/cg/internal.h | 9+++++++++
Msrc/cg/memory.c | 6++++++
Msrc/cg/native_direct_target.c | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/cg/value.c | 8++++++++
7 files changed, 113 insertions(+), 27 deletions(-)

diff --git a/src/cg/arith.c b/src/cg/arith.c @@ -100,6 +100,10 @@ void api_cg_binop(KitCg* g, BinOp iop, u32 flags) { rr = api_alloc_temp_local(g, ty); dst = api_op_local(rr, ty); + /* Flag dead-transient operands so the -O0 backend drops them after the op + * instead of spilling them at the next barrier (eager dead-operand drop). */ + ra = api_op_kill_if_dead(g, ra, dst); + rb = api_op_kill_if_dead(g, rb, dst); T->binop(T, iop, dst, ra, rb); api_release(g, &a); api_release(g, &b); @@ -132,6 +136,7 @@ void api_cg_unop(KitCg* g, UnOp iop, u32 flags) { ra = api_force_local(g, &a, ty); rr = api_alloc_temp_local(g, ty); dst = api_op_local(rr, ty); + ra = api_op_kill_if_dead(g, ra, dst); T->unop(T, iop, dst, ra); api_release(g, &a); api_push(g, api_make_sv(dst, ty)); @@ -171,6 +176,7 @@ void api_cg_unop(KitCg* g, UnOp iop, u32 flags) { } rr = api_alloc_temp_local(g, ty); dst = api_op_local(rr, ty); + ra = api_op_kill_if_dead(g, ra, dst); T->unop(T, iop, dst, ra); api_release(g, &a); api_push(g, api_make_sv(dst, ty)); diff --git a/src/cg/control.c b/src/cg/control.c @@ -33,7 +33,13 @@ void api_branch_if(KitCg* g, ApiSValue* v, int branch_when_true, Label label) { if (v->kind == SV_CMP) { CmpOp op = branch_when_true ? v->delayed->cmp.op : api_invert_cmp(v->delayed->cmp.op); - T->cmp_branch(T, op, v->delayed->cmp.a, v->delayed->cmp.b, label); + /* Flag dead-transient operands so the -O0 backend drops them at the branch + * instead of spilling them (eager dead-operand drop). The compare has no + * local destination, so pass an immediate as the dst sentinel. */ + Operand none = api_op_imm(0, builtin_id(KIT_CG_BUILTIN_I32)); + Operand ca = api_op_kill_if_dead(g, v->delayed->cmp.a, none); + Operand cb = api_op_kill_if_dead(g, v->delayed->cmp.b, none); + T->cmp_branch(T, op, ca, cb, label); api_release(g, v); return; } @@ -64,6 +70,9 @@ void api_branch_if(KitCg* g, ApiSValue* v, int branch_when_true, Label label) { { Operand a = api_force_local(g, v, ty); Operand zero = api_op_imm(0, ty); + /* The truthiness test is `v`'s last use; drop it at the branch if it is a + * dead transient (zero is the non-local dst sentinel). */ + a = api_op_kill_if_dead(g, a, zero); T->cmp_branch(T, branch_when_true ? CMP_NE : CMP_EQ, a, zero, label); api_release(g, v); } diff --git a/src/cg/fold.c b/src/cg/fold.c @@ -209,7 +209,11 @@ void api_release_cmp(KitCg* g, ApiSValue* sv) { void api_materialize_cmp_to(KitCg* g, ApiSValue* sv, Operand dst) { ApiDelayed* d = sv->delayed; - g->target->cmp(g->target, d->cmp.op, dst, d->cmp.a, d->cmp.b); + /* Flag dead-transient operands so the -O0 backend drops them after the compare + * instead of spilling them at the next barrier (eager dead-operand drop). */ + Operand a = api_op_kill_if_dead(g, d->cmp.a, dst); + Operand b = api_op_kill_if_dead(g, d->cmp.b, dst); + g->target->cmp(g->target, d->cmp.op, dst, a, b); api_delayed_free(g, d); sv->delayed = NULL; sv->kind = SV_OPERAND; @@ -269,9 +273,14 @@ void api_release_arith(KitCg* g, ApiSValue* sv) { void api_materialize_arith_to(KitCg* g, ApiSValue* sv, Operand dst) { ApiDelayed* d = sv->delayed; if (d->arith.kind == API_DELAYED_UNOP) { - g->target->unop(g->target, d->arith.un_op, dst, d->arith.a); + Operand a = api_op_kill_if_dead(g, d->arith.a, dst); + g->target->unop(g->target, d->arith.un_op, dst, a); } else { - g->target->binop(g->target, d->arith.bin_op, dst, d->arith.a, d->arith.b); + /* Flag dead-transient operands so the -O0 backend drops them after the op + * instead of spilling them at the next barrier (eager dead-operand drop). */ + Operand a = api_op_kill_if_dead(g, d->arith.a, dst); + Operand b = api_op_kill_if_dead(g, d->arith.b, dst); + g->target->binop(g->target, d->arith.bin_op, dst, a, b); } api_delayed_free(g, d); sv->delayed = NULL; diff --git a/src/cg/internal.h b/src/cg/internal.h @@ -535,6 +535,15 @@ void api_release(KitCg* g, ApiSValue* sv); * its references are re-accounted. */ int api_coalesce_on(KitCg* g); int api_temp_dead(KitCg* g, CGLocal local); +/* Flag OP for the -O0 backend's eager dead-operand drop: when coalescing is on and + * OP names a transient that api_temp_dead confirms is dead after the consuming op, + * AND OP is distinct from that op's destination DST (so it is not the result being + * written), return OP with OPK_FLAG_KILL set; otherwise return OP unchanged. The + * NativeDirectTarget then drops OP's live cache register after the op (no spill, no + * reload) instead of leaving it resident to be flush-stored at the next barrier. + * Pass a non-OPK_LOCAL operand for DST (e.g. the store's memory place) when the op + * has no local destination — the self-guard is then a no-op. */ +Operand api_op_kill_if_dead(KitCg* g, Operand op, Operand dst); void api_reseat_begin(KitCg* g, const ApiSValue* sv); void api_reseat_end(KitCg* g, const ApiSValue* sv); diff --git a/src/cg/memory.c b/src/cg/memory.c @@ -564,6 +564,12 @@ void kit_cg_store(KitCg* g, KitCgMemAccess access) { api_local_const_memory_boundary(g); } + /* Flag a dead-transient store value so the -O0 backend drops it after the store + * instead of spilling it at the next barrier (eager dead-operand drop). mem_op + * is the memory place (OPK_INDIRECT/GLOBAL, never a scalar local — that is the + * scalar_local_place fast path above), so the dst self-guard is a no-op and the + * store has already read the address before the drop. */ + src = api_op_kill_if_dead(g, src, mem_op); if (is_bitfield) { /* A bit-field store rides the generic `store` with a bit-field MemAccess; * the CgTarget impl does the read-modify-write insert. */ diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -1100,6 +1100,22 @@ static Reg nd_rename_killed_to_dst(NativeDirectTarget* d, Operand dst, return r; } +/* After an op has materialized and consumed OP, drop OP's cache entry WITHOUT a + * write-back when it carries OPK_FLAG_KILL — a transient the cg layer proved dead + * after this op (api_temp_dead). The op already read OP's value, so storing it at + * the next barrier would be wasted and holding its register inflates pressure; + * dropping it now both removes that dead spill and frees the register immediately + * (fewer eviction spills + reloads downstream). Sound for the same reason + * nd_rename_killed_to_dst is: OPK_FLAG_KILL is set only from api_temp_dead, which + * confirms no live value-stack reference to OP remains. A non-local or non-cached + * operand is a no-op (nd_invalidate_local early-returns on REG_NONE). The caller + * must have already unpinned OP's materialized register (nd_release_materialized); + * the cg layer never flags an operand that is also the op's destination. */ +static void nd_drop_killed_operand(NativeDirectTarget* d, Operand op) { + if ((op.flags & OPK_FLAG_KILL) && op.kind == OPK_LOCAL) + nd_invalidate_local(d, op.v.local); +} + static void nd_store_operand_from_reg(NativeDirectTarget* d, Operand dst, NativeLoc src) { if (dst.kind != OPK_LOCAL) nd_panic(d, "destination is not a semantic local"); @@ -1295,9 +1311,20 @@ static void nd_cmp_branch(CgTarget* t, CmpOp op, Operand a, Operand b, Label label) { NativeDirectTarget* d = nd_of(t); NativeLoc ar, br; - nd_flush_all(d); + /* Materialize the compare operands BEFORE spilling the live-across set. A + * cache-resident operand is then read straight from its register instead of + * being spilled by the flush and immediately reloaded for the compare; a dead + * one (OPK_FLAG_KILL) is dropped without a write-back so the flush skips its + * store entirely. The operands stay pinned across the flush — their values + * survive in their registers (a spill store does not clobber them) — and the + * flush's stores are emitted before the compare, so they cannot clobber its + * condition flags. The post-branch cache state is still empty (the flush spills + * everything live), so control-flow-merge correctness is unchanged. */ ar = nd_materialize_operand(d, a); br = nd_rhs_imm_or_reg(d, NATIVE_IMM_CMP, (u32)op, b); + nd_drop_killed_operand(d, a); + nd_drop_killed_operand(d, b); + nd_flush_all(d); ND_REQUIRE_NATIVE(d, cmp_branch, "target does not emit compare branches"); d->native->cmp_branch(d->native, op, ar, br, nd_mc_label(d, label)); nd_release_materialized(d, br); @@ -1569,14 +1596,12 @@ static void nd_load(CgTarget* t, Operand dst, Operand addr, MemAccess mem) { return; } size = mem.size ? mem.size : (mem.type ? cg_type_size(t->c, mem.type) : 0); - /* No value-cache flush: only escaped (address-taken / memory-required) locals - * can be aliased through a pointer, and those are never cached. A volatile - * access may observe memory and needs the cache made authoritative first. */ - if (mem.flags & MF_VOLATILE) { - nd_flush_all(d); - nd_barrier(d, - NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_VOLATILE); - } + /* No value-cache flush (2c): the cache holds only non-escaped locals, which no + * pointer can alias, so a volatile access — foreign memory — can neither observe + * a cached value's stale home nor overwrite it. The arch barrier hook still + * fires so an instrumentation seam can order the access. */ + if (mem.flags & MF_VOLATILE) + nd_barrier(d, NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_VOLATILE); NativeAddr naddr = nd_addr_materialize(d, nd_addr_storage(d, addr), &temps, mem); if (size > (u64)t->c->target.ptr_size) { @@ -1625,15 +1650,12 @@ static void nd_store(CgTarget* t, Operand addr, Operand src, MemAccess mem) { return; } size = mem.size ? mem.size : (mem.type ? cg_type_size(t->c, mem.type) : 0); - /* No value-cache flush (see nd_load): a store through a pointer cannot alias - * a cached non-escaped local. The store target is foreign memory, so there is - * no dst local entry to invalidate; SRC is read via nd_materialize_operand. - */ - if (mem.flags & MF_VOLATILE) { - nd_flush_all(d); - nd_barrier(d, - NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_VOLATILE); - } + /* No value-cache flush (2c, see nd_load): a store through a pointer cannot + * alias a cached non-escaped local. The store target is foreign memory, so + * there is no dst local entry to invalidate; SRC is read via + * nd_materialize_operand. The arch barrier hook still fires. */ + if (mem.flags & MF_VOLATILE) + nd_barrier(d, NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_VOLATILE); NativeAddr naddr = nd_addr_materialize(d, nd_addr_storage(d, addr), &temps, mem); if (size > (u64)t->c->target.ptr_size) { @@ -1656,6 +1678,9 @@ static void nd_store(CgTarget* t, Operand addr, Operand src, MemAccess mem) { d->native->store(d->native, naddr, val, mem); nd_release_materialized(d, val); nd_addr_temps_release(d, &temps); + /* The store consumed SRC; if it was a dead transient, drop it (no write-back) + * so it is not flush-stored at the next barrier (eager dead-operand drop). */ + nd_drop_killed_operand(d, src); } static void nd_addr_of(CgTarget* t, Operand dst, Operand lv) { @@ -1826,6 +1851,11 @@ static void nd_binop(CgTarget* t, BinOp op, Operand dst, Operand a, Operand b) { nd_dst_writeback(d, dst, dr); nd_release_materialized(d, br); nd_release_materialized(d, ar); + /* Eager dead-operand drop: a/b flagged dead by the cg layer (OPK_FLAG_KILL) are + * consumed now — drop their cache entries without a write-back so they are not + * flush-stored at the next barrier and their registers free immediately. */ + nd_drop_killed_operand(d, b); + nd_drop_killed_operand(d, a); } static void nd_unop(CgTarget* t, UnOp op, Operand dst, Operand a) { @@ -1850,6 +1880,7 @@ static void nd_unop(CgTarget* t, UnOp op, Operand dst, Operand a) { d->native->unop(d->native, op, dr, ar); nd_dst_writeback(d, dst, dr); nd_release_materialized(d, ar); + nd_drop_killed_operand(d, a); } static void nd_cmp(CgTarget* t, CmpOp op, Operand dst, Operand a, Operand b) { @@ -1883,6 +1914,8 @@ static void nd_cmp(CgTarget* t, CmpOp op, Operand dst, Operand a, Operand b) { nd_dst_writeback(d, dst, dr); nd_release_materialized(d, br); nd_release_materialized(d, ar); + nd_drop_killed_operand(d, b); + nd_drop_killed_operand(d, a); } static void nd_convert(CgTarget* t, ConvKind op, Operand dst, Operand src) { @@ -2202,7 +2235,8 @@ static void nd_atomic_load(CgTarget* t, Operand dst, Operand addr, MemAccess mem, KitCgMemOrder order) { NativeDirectTarget* d = nd_of(t); NdAddrTemps temps; - nd_flush_all(d); + /* No value-cache flush (2c): the atomic accesses foreign memory, which cannot + * alias a cached non-escaped local; the arch barrier hook still orders it. */ nd_barrier(d, NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_ATOMIC); NativeAddr naddr = nd_addr_materialize(d, nd_addr_pointer(d, addr), &temps, mem); @@ -2218,7 +2252,8 @@ static void nd_atomic_store(CgTarget* t, Operand addr, Operand src, MemAccess mem, KitCgMemOrder order) { NativeDirectTarget* d = nd_of(t); NdAddrTemps temps; - nd_flush_all(d); + /* No value-cache flush (2c): foreign atomic memory cannot alias a cached + * non-escaped local. */ nd_barrier(d, NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_ATOMIC); NativeAddr naddr = nd_addr_materialize(d, nd_addr_pointer(d, addr), &temps, mem); @@ -2234,7 +2269,8 @@ static void nd_atomic_rmw(CgTarget* t, KitCgAtomicOp op, Operand dst, KitCgMemOrder order) { NativeDirectTarget* d = nd_of(t); NdAddrTemps temps; - nd_flush_all(d); + /* No value-cache flush (2c): foreign atomic memory cannot alias a cached + * non-escaped local. */ nd_barrier(d, NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_ATOMIC); NativeAddr naddr = nd_addr_materialize(d, nd_addr_pointer(d, addr), &temps, mem); @@ -2253,7 +2289,8 @@ static void nd_atomic_cas(CgTarget* t, Operand prior, Operand ok, Operand addr, KitCgMemOrder success, KitCgMemOrder failure) { NativeDirectTarget* d = nd_of(t); NdAddrTemps temps; - nd_flush_all(d); + /* No value-cache flush (2c): foreign atomic memory cannot alias a cached + * non-escaped local. */ nd_barrier(d, NATIVE_DIRECT_BARRIER_MEMORY | NATIVE_DIRECT_BARRIER_ATOMIC); NativeAddr naddr = nd_addr_materialize(d, nd_addr_pointer(d, addr), &temps, mem); @@ -2276,7 +2313,9 @@ static void nd_atomic_cas(CgTarget* t, Operand prior, Operand ok, Operand addr, static void nd_fence(CgTarget* t, KitCgMemOrder order) { NativeDirectTarget* d = nd_of(t); - nd_flush_all(d); + /* No value-cache flush (2c): a fence orders memory visible to other agents; a + * cached local is non-escaped, so no other agent can observe its deferred home + * write. The arch fence instruction is still emitted. */ ND_REQUIRE_NATIVE(d, fence, "target does not emit fences"); d->native->fence(d->native, order); } diff --git a/src/cg/value.c b/src/cg/value.c @@ -572,6 +572,14 @@ int api_temp_dead(KitCg* g, CGLocal local) { return api_temp_scan_refs(g, local) == 0; /* confirm before acting */ } +Operand api_op_kill_if_dead(KitCg* g, Operand op, Operand dst) { + if (api_coalesce_on(g) && op.kind == OPK_LOCAL && + (dst.kind != OPK_LOCAL || op.v.local != dst.v.local) && + api_temp_dead(g, op.v.local)) + op.flags |= OPK_FLAG_KILL; + return op; +} + /* ---- BinOp / UnOp / CmpOp mapping ---- */ BinOp api_map_int_binop(KitCgIntBinOp op) {