kit

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

commit 1812cce424a193d2044059895dde09565734344c
parent cd46fd6eb67337c8e1c94cd0ac0adc42f9897d23
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 17:38:54 -0700

perf(cg): -O0 args into ABI arg regs (PERF-O0-CODESIZE Lever 1, aa64) — -3.8% .text

Front the ABI argument/return registers in the aa64 -O0 NDT value-cache pool
(x0..x7 in aa_int_allocable, v0..v7 in aa_fp_allocable) AND materialize call
arguments arg0-first, so an argument expression computes straight into its ABI
register and the per-call parallel-copy collapses to no-ops — the tcc
get_reg(0..N) + eager-left-to-right model. rv64 already pools a0..a7; aa64 was
the lone holdout.

The two halves are both required. The pool fronting alone slightly regressed:
kit materializes computed args lazily, right-to-left, so with arg regs in the
pool they landed in the *reversed* arg registers and the shuffle had to permute
them (f(c,a,b)-shaped cycles). api_pack_call_args_in_order reverses the on-stack
arg order so the LIFO pop yields arg0 first, then pops+materializes forward —
preserving api_temp_dead semantics exactly (not-yet-materialized args stay on the
stack, so a temp shared between args is coalesced at its last use, never killed
early). Matches tcc; on a 4-computed-arg call, 4/6 movs -> 0.

Also drop x11 from aa_int_allocable (it is int emit scratch, was double-listed)
and refresh the now-stale "ret reg excluded from the cache pool" comment in
nd_call (x0 is an ordinary -O0 pool member now; the evict-prior-owner guard is
the real backstop).

sqlite3.c -c, arm64-macOS, vs HEAD baseline (same SDK):
  .text  1,486,660 -> 1,430,316 B   (-3.79%)
  insns    371,537 -> 357,707       (-13,830)
  mov       48,578 -> 36,188        (-12,390, -25.5%)

Gate (codesize-track, not byte-identity): determinism (compile x2 + cmp);
test-toy 1392/0, test-cg-api, test-opt, test-smoke-x64/-rv64, test-parse-ok/-err,
test-dwarf, test-debug all green; sqlite e2e at -O0 and -O1 matches clang;
shared-operand / nested-call / arg-eval-order functional check matches clang.

Diffstat:
Msrc/arch/aa64/native.c | 40++++++++++++++++++++++++++--------------
Msrc/cg/call.c | 39+++++++++++++++++++++++++++++++--------
Msrc/cg/native_direct_target.c | 10++++++----
3 files changed, 63 insertions(+), 26 deletions(-)

diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c @@ -4069,27 +4069,39 @@ static void aa_set_loc(NativeTarget* t, SrcLoc loc) { static void aa_bind_native_param(NativeTarget* t, const CGParamDesc* p, NativeLoc dst); -/* Caller-saved allocables come first so the allocator prefers them (lower - * spill_cost); callee-saved x19..x28 / v8..v15 are appended and only chosen - * under register pressure, after which the prologue saves/restores them. */ -static const Reg aa_int_allocable[] = {8u, 11u, 12u, 13u, 14u, 15u, 19u, 20u, - 21u, 22u, 23u, 24u, 25u, 26u, 27u, 28u}; +/* ABI argument/return registers (x0..x7) come FIRST so the -O0 value cache + * prefers them: an argument expression then computes straight into its ABI arg + * register and the per-call parallel-copy (native_arg_shuffle) collapses to + * no-ops — the tcc get_reg(0..N) trick (mirrors rv_int_allocable a0..a7 and + * x64's leading rsi/rdi). They are caller-saved, so the deferred prologue stays + * callee-save-free. Incoming params are spilled to frame homes at entry before + * any body op (aa_bind_native_param), so caching in x0..x7 cannot clobber a + * live parameter. Next come the remaining caller-saved temps x8/x12..x15; + * callee-saved x19..x28 are appended and only chosen under register pressure, + * after which the prologue saves/restores them. x9/x10/x11 are the int emit + * scratch (aa_int_scratch) and stay out of this pool. */ +static const Reg aa_int_allocable[] = {0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, + 8u, 12u, 13u, 14u, 15u, 19u, 20u, 21u, + 22u, 23u, 24u, 25u, 26u, 27u, 28u}; /* Three int scratch registers, not two: a 3-operand op (e.g. `binop dst, a, b` * or `store [base+index], value`) whose dst/sources all spill needs three * distinct scratch regs at emit time — the IR spill-rewrite round-robins * operands across this pool and the native emitter materializes each into one. * With only two, an immediate operand of an all-spilled binop had nowhere to * land. x9/x10/x11 are all caller-saved temporaries reserved out of the - * allocable set below. */ + * allocable set above. */ static const Reg aa_int_scratch[] = {9u, 10u, 11u}; -/* Caller-saved fp temps first (v18/v19, v22..v31) — these form the -O0 value - * cache under ndt_caller_saved_only. v16 is the fp emit scratch (the fp AA_TMP0, - * used per-op) and v20/v21 are the NDT per-op fp scratch, so all three stay out - * of the pool. Callee-saved v8..v15 (AAPCS64) are appended like the int set; - * the caller-saved-only NDT never selects them. */ -static const Reg aa_fp_allocable[] = {18u, 19u, 22u, 23u, 24u, 25u, 26u, - 27u, 28u, 29u, 30u, 31u, 8u, 9u, - 10u, 11u, 12u, 13u, 14u, 15u}; +/* ABI fp argument/return registers (v0..v7) come FIRST (same rationale as the + * int pool: fp args land in place, the shuffle is a no-op). Then the caller- + * saved fp temps v18/v19, v22..v31 — the rest of the -O0 value cache under + * ndt_caller_saved_only. v16 is the fp emit scratch (the fp AA_TMP0, also the + * arg-shuffle fp cycle scratch) and v20/v21 are the NDT per-op fp scratch, so + * all three stay out of the pool. Callee-saved v8..v15 (AAPCS64) are appended + * like the int set; the caller-saved-only NDT never selects them. */ +static const Reg aa_fp_allocable[] = {0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, + 18u, 19u, 22u, 23u, 24u, 25u, 26u, 27u, + 28u, 29u, 30u, 31u, 8u, 9u, 10u, 11u, + 12u, 13u, 14u, 15u}; static const Reg aa_fp_scratch[] = {20u, 21u}; #define AA_PHYS_INT_ALLOC(r) \ diff --git a/src/cg/call.c b/src/cg/call.c @@ -82,6 +82,35 @@ void api_pack_call_arg(KitCg* g, CGLocal* out, KitCgTypeId fty, u32 idx) { *out = api_materialize_call_local(g, &arg, aty); } +/* Pack the NARGS top-of-stack call arguments into ARGS[0..nargs) IN ARG ORDER, + * materializing each (emitting any pending producer) arg0-first. The arguments + * sit on the value stack as `arg0 .. arg(n-1)` (arg(n-1) on top); reverse that + * sub-array so the LIFO pop yields arg0 first, then pop+materialize forward. + * + * Why forward: an argument materialized into a register lands in the first free + * -O0 cache register, and the cache pool front-loads the ABI argument registers + * (x0,x1,.. — see aa_int_allocable). Producing arg0 first therefore lands it in + * x0, arg1 in x1, .. so the per-call parallel-copy collapses to no-ops, instead + * of the reverse order leaving each value in the *wrong* arg register and + * forcing a permutation shuffle. Matches tcc's eager left-to-right argument + * emission. + * + * Reversing the on-stack order (rather than popping all up front) preserves the + * api_temp_dead semantics exactly: the not-yet-materialized arguments stay on + * the stack, so a temp shared between arguments (e.g. f(t*2, t*3)) is scanned as + * live until its last (highest-index) use and is coalesced there, never killed + * prematurely. */ +static void api_pack_call_args_in_order(KitCg* g, CGLocal* args, u32 nargs, + KitCgTypeId fty) { + u32 base = g->sp - nargs; /* arg0's stack slot (callee, if any, sits below) */ + for (u32 i = 0; i < nargs / 2u; ++i) { + ApiSValue tmp = g->stack[base + i]; + g->stack[base + i] = g->stack[base + nargs - 1u - i]; + g->stack[base + nargs - 1u - i] = tmp; + } + for (u32 i = 0; i < nargs; ++i) api_pack_call_arg(g, &args[i], fty, i); +} + CGLocal api_alloc_call_result(KitCg* g, KitCgTypeId ret_ty) { return api_alloc_temp_local(g, ret_ty); } @@ -178,10 +207,7 @@ void kit_cg_call(KitCg* g, uint32_t nargs, KitCgTypeId fn_type, } args = api_alloc_call_args(g, nargs); - for (u32 i = 0; i < nargs; ++i) { - u32 idx = nargs - 1u - i; - api_pack_call_arg(g, &args[idx], fty, idx); - } + api_pack_call_args_in_order(g, args, nargs, fty); callee = api_pop(g); api_ensure_local(g, &callee); @@ -236,10 +262,7 @@ void api_call_symbol_common(KitCg* g, KitCgSym sym, uint32_t nargs, return; } args = api_alloc_call_args(g, nargs); - for (u32 i = 0; i < nargs; ++i) { - u32 idx = nargs - 1u - i; - api_pack_call_arg(g, &args[idx], fty, idx); - } + api_pack_call_args_in_order(g, args, nargs, fty); callee_op = api_op_global((ObjSymId)sym, 0, cg_type_ptr_to(g->c, fty)); inline_policy = attrs.inline_policy; if (inline_policy == KIT_CG_INLINE_DEFAULT) { diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -2160,10 +2160,12 @@ static void nd_call(CgTarget* t, const CGCallDesc* desc) { nd_invalidate_local(d, desc->args[i]); } /* Stable-result-register path: cache the result directly in the ABI result - * register the call left it in (plan.rets[0].src) and skip its home store — - * that register is excluded from the general cache pool, so it is free here - * (the dead args were just invalidated); the next call's flush spills it if - * still live, or drops it if dead. */ + * register the call left it in (plan.rets[0].src) and skip its home store. + * That register may now be an ordinary cache-pool member (x0 on aa64 is -O0 + * allocable), but it is free here regardless: the live-across flush + the + * dead-arg invalidation above cleared it, and the guard below evicts any + * residual owner before claiming it. The next call's flush spills it if still + * live, or drops it if dead. */ u32 ret_start = 0; if (result_in_abi_reg) { NativeDirectLocal* rl = nd_local(d, desc->result);