kit

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

commit 98b1f010ea090a8d18360d194880ea658b26296c
parent 2b1b03c21d1b949107cf692febb5a709969608f0
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 07:07:19 -0700

perf(cg): -O0 register-resident call args — spill only the live set

nd_call flushed the entire value cache before every call and sourced each
argument from its frame home, emitting `<compute>; stur <arg>,[home]; ldur
x0,[home]; bl` — a spill+reload round-trip per argument plus dead writebacks of
every cached local.

Now an argument provably dead after the call (new CGCallDesc.arg_dead_mask, set
from the sound api_temp_dead in api_finish_call) is sourced straight from its
live register into the ABI arg register; the backends' existing parallel-copy
scheduler (native_arg_shuffle) already resolves reg<->arg-reg conflicts. Only
the live-across set — non-arg cached locals plus any live/shared arg — is
spilled (nd_flush_all_except_kept_args). Kept args are pinned across the callee
materialize and dropped without write-back afterward (their registers are
call-clobbered and they are dead).

  viacall: add w8,w9,w10; stur w8,[home]; ldur w0,[home]; bl
        -> add w8,w9,w10; mov w0,w8; bl

A live or shared arg (`y = g(t) + t`) stays clear in the mask and is spilled, so
its value survives the call (verified against clang).

sqlite3.c -c -O0 (arm64-macOS): compile instrs 2,145.0M -> 2,134.5M (-10.4M);
emitted insns 518,757 -> 479,263 (-39,494); object 2,469,528 -> 2,311,552 B
(-6.4%). Byte-deterministic (compile x2 identical).

Green: test-toy 1392/0, test-parse 3920+129/0, test-smoke-x64 3/0,
test-smoke-rv64 3/0, test-cg-api 0 fail, test-libc 9/0; sqlite e2e 84|2;
call-correctness probe matches clang (shared-arg-live-across, swap, indirect,
nested).

Diffstat:
Msrc/cg/call.c | 8++++++++
Msrc/cg/cgir.h | 7+++++++
Msrc/cg/native_direct_target.c | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 74 insertions(+), 5 deletions(-)

diff --git a/src/cg/call.c b/src/cg/call.c @@ -133,6 +133,14 @@ static int api_tail_decide(KitCg* g, const CGCallDesc* desc, static void api_finish_call(KitCg* g, CGCallDesc* desc, int want_tail, int emit_tail) { if (!emit_tail) api_call_clobber_boundary(g, desc); + /* Flag arguments that are dead after the call (no live value-stack reference) + * so the -O0 backend can keep them register-resident across the call instead + * of round-tripping through their homes. api_temp_dead is sound — true only + * when the transient is provably dead — so a live or shared arg stays clear + * and is spilled like any other live-across value. */ + desc->arg_dead_mask = 0; + for (u32 i = 0; i < desc->nargs && i < 64u; ++i) + if (api_temp_dead(g, desc->args[i])) desc->arg_dead_mask |= (u64)1u << i; g->target->call(g->target, desc); /* Push the single result (if any) onto the stack. */ diff --git a/src/cg/cgir.h b/src/cg/cgir.h @@ -404,6 +404,13 @@ typedef struct CGCallDesc { * call+ret (ALLOWED), or diagnose (MUST). */ u8 pad; KitCgInlinePolicy inline_policy; + /* Bit i set => args[i] is a transient that is provably dead after this call + * (no live value-stack reference). The -O0 NativeDirectTarget may then source + * such an arg from its live register and drop it without writing back, + * instead of the spill-to-home + reload round-trip; a live (clear-bit) arg is + * spilled like any other live-across value. Bits >= 64 are 0 (treated as + * live: correct, just unoptimized). Other backends ignore it. */ + u64 arg_dead_mask; } CGCallDesc; typedef u32 Label; diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -676,6 +676,32 @@ static void nd_drop_all(NativeDirectTarget* d) { nd_invalidate_local(d, (CGLocal)(d->cache_head + 1)); } +/* A "kept" argument is one nd_call leaves register-resident across the call: it + * is named in desc->args and flagged dead in arg_dead_mask (so dropping it after + * the call loses nothing). A live or shared arg is not kept and is spilled. */ +static int nd_call_arg_kept(const CGCallDesc* desc, CGLocal local) { + for (u32 i = 0; i < desc->nargs; ++i) + if (desc->args[i] == local) + return i < 64u && ((desc->arg_dead_mask >> i) & 1u); + return 0; +} + +/* Spill every cached local EXCEPT the kept (dead) argument locals to its home + * and empty those entries. Kept args stay cached so the marshalling can source + * them from their live registers; everything else is potentially live across + * the call and, on the -O0 caller-saved-only cache, sits in a call-clobbered + * register, so it must reach memory now. */ +static void nd_flush_all_except_kept_args(NativeDirectTarget* d, + const CGCallDesc* desc) { + i32 idx = d->cache_head; + while (idx >= 0) { + i32 next = d->locals[idx].cache_next; /* capture before flush unlinks idx */ + CGLocal local = (CGLocal)(idx + 1); + if (!nd_call_arg_kept(desc, local)) nd_flush_local(d, local); + idx = next; + } +} + static NativeAddr nd_addr_materialize(NativeDirectTarget* d, NativeAddr in, NdAddrTemps* temps, MemAccess mem) { NativeAddr out = in; @@ -1723,17 +1749,37 @@ static void nd_call(CgTarget* t, const CGCallDesc* desc) { NativeLoc* results; NativeLoc callee_tmp; int release_callee_tmp = 0; - nd_flush_all(d); - nd_barrier(d, NATIVE_DIRECT_BARRIER_CALL | NATIVE_DIRECT_BARRIER_MEMORY); + u32 nresults = desc->result != CG_LOCAL_NONE ? 1u : 0u; memset(&plan, 0, sizeof plan); memset(&nd, 0, sizeof nd); memset(&callee_tmp, 0, sizeof callee_tmp); - u32 nresults = desc->result != CG_LOCAL_NONE ? 1u : 0u; args = nd_loc_buf(d, d->argbuf, ND_ARG_BUF, desc->nargs); results = nd_loc_buf(d, d->retbuf, ND_RET_BUF, nresults); - for (u32 i = 0; i < desc->nargs; ++i) - args[i] = nd_loc_frame(d, desc->args[i], 0); + /* Source each argument cache-aware BEFORE spilling. A cached arg that is dead + * after the call (arg_dead_mask) flows from its live register straight into + * the ABI arg register — the backend's parallel-copy scheduler + * (native_arg_shuffle) resolves reg<->arg-reg conflicts — avoiding the + * spill-to-home + reload round-trip the old flush-all forced. Pin its register + * so the callee materialize below cannot evict it as a scratch victim. A live + * (or uncached) arg keeps the home source and is spilled by the flush below. */ + for (u32 i = 0; i < desc->nargs; ++i) { + NativeDirectLocal* l = nd_local(d, desc->args[i]); + int dead = i < 64u && ((desc->arg_dead_mask >> i) & 1u); + if (dead && l->reg != REG_NONE) { + args[i] = native_loc_reg(l->type, (NativeAllocClass)l->cls, l->reg); + d->scratch_used[l->cls] |= 1u << l->reg; + } else { + args[i] = nd_loc_frame(d, desc->args[i], 0); + } + } if (nresults) results[0] = nd_loc_frame(d, desc->result, 0); + /* An indirect callee is read from its home; make it authoritative before the + * selective flush (the cg only ever produces an OPK_LOCAL or OPK_GLOBAL + * callee). */ + if (desc->callee.kind == OPK_LOCAL) nd_flush_local(d, desc->callee.v.local); + /* Spill the live-across set: everything cached except the kept (dead) args. */ + nd_flush_all_except_kept_args(d, desc); + nd_barrier(d, NATIVE_DIRECT_BARRIER_CALL | NATIVE_DIRECT_BARRIER_MEMORY); nd.fn_type = desc->fn_type; nd.callee = nd_loc_operand(d, desc->callee); if (nd.callee.kind == NATIVE_LOC_FRAME) { @@ -1766,6 +1812,14 @@ static void nd_call(CgTarget* t, const CGCallDesc* desc) { ND_REQUIRE_NATIVE(d, emit_call, "target does not emit calls"); d->native->emit_call(d->native, &plan); } + /* The call clobbered the caller-saved registers, so the (dead-after) args' + * cached values are stale: unpin and drop their entries without writing back. + * args[i] is a reg only for args that were sourced from the cache. */ + for (u32 i = 0; i < desc->nargs; ++i) { + if (args[i].kind == NATIVE_LOC_REG) + d->scratch_used[args[i].cls] &= ~(1u << args[i].v.reg); + nd_invalidate_local(d, desc->args[i]); + } for (u32 i = 0; i < plan.nrets; ++i) nd_write_loc(d, plan.rets[i].dst, plan.rets[i].src, plan.rets[i].mem); if (release_callee_tmp)