kit

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

commit cac0d499663f47820d827dba6a68084912dc4b38
parent 5fe95f6d07bbf02f77cf5801af0aff3ee7f01e45
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 12:56:47 -0700

perf(cg): -O0 keep scalar call result in the ABI result register (L4 Phase 1)

A cacheable scalar call result was moved out of the ABI result register into a
general cache register right after the call (bl ; mov cachereg,x0), then read
from there. Instead cache the result directly in the ABI result register the
call already left it in (x0 / v0; a0 on rv64, ...): feed the plan a placeholder
result dst so no home slot is allocated, then after the call record the result
as cached in plan.rets[0].src and skip its home store. The mov disappears for
the common 'x = f(); use(x)' / 'f()+1' / 'g(f())' shapes. The result register is
excluded from the general cache pool, so it is free post-call (dead args were
just invalidated); the next call's flush spills it if still live, drops it if
dead. Arch-neutral — uses the ABI result register the call plan computes, not a
hardcoded register.

sqlite3.c -O0 .text: -8,969 mov (1.2468x -> 1.2201x tcc). Gate: determinism +
sqlite e2e O0/O1 (golden+vs-clang) + toy/parse/smoke-x64/smoke-rv64/dwarf/debug
all green; alloca + far-slot clang-differential probes match clang.

Diffstat:
Msrc/cg/native_direct_target.c | 62+++++++++++++++++++++++++++++++++++---------------------------
1 file changed, 35 insertions(+), 27 deletions(-)

diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -1941,29 +1941,17 @@ static void nd_call(CgTarget* t, const CGCallDesc* desc) { 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); - /* Keep a scalar result register-resident: claim a cache register for it now - * (the cache holds only the pinned kept args) and have the post-call ret move - * write the ABI result register into it, instead of storing the result to its - * home and reloading it at the consumer. The claimed register holds garbage - * until that ret move, but nothing reads the result before then. A - * non-cacheable result (aggregate / multi-part / sret) keeps the home path. */ + /* Place the scalar result. A cacheable result is cached in the ABI result + * register the call leaves it in — done after the call below, with no mov and + * no home store. Feed the plan a placeholder register dst here (never applied; + * the post-call step always skips rets[0] for cacheable results) so no home + * slot is allocated for a result consumed before the next flush. Non-cacheable + * results (aggregate / multi-part / sret) keep the home path. */ if (nresults) { NativeDirectLocal* rl = nd_local(d, desc->result); - Reg r = nd_local_cacheable(d, rl) - ? nd_cache_alloc(d, (NativeAllocClass)rl->cls) - : REG_NONE; - if (r != REG_NONE) { - d->reg_owner[rl->cls][r] = desc->result; - rl->reg = r; - rl->dirty = 1; - nd_cache_link(d, desc->result); - /* Pin until the post-call ret move so the callee materialize cannot evict - * this not-yet-valid entry as a scratch victim. */ - d->scratch_used[rl->cls] |= 1u << r; - results[0] = nd_loc_reg(d, rl->type, (NativeAllocClass)rl->cls, r); - } else { - results[0] = nd_loc_frame(d, desc->result, 0); - } + results[0] = nd_local_cacheable(d, rl) + ? nd_loc_reg(d, rl->type, (NativeAllocClass)rl->cls, 0) + : nd_loc_frame(d, desc->result, 0); } nd_barrier(d, NATIVE_DIRECT_BARRIER_CALL | NATIVE_DIRECT_BARRIER_MEMORY); nd.fn_type = desc->fn_type; @@ -2006,13 +1994,33 @@ static void nd_call(CgTarget* t, const CGCallDesc* desc) { 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) + /* Cache a cacheable scalar result directly in the ABI result register the call + * left it in (x0 / v0 — reg 0 of its class on aa64, a0 on rv64, etc.), instead + * of moving it to a general cache register or storing it home. 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 it is still live, + * or it is dropped if it turns out dead. The home store for rets[0] is then + * skipped — the value lives in its cache register. */ + u32 ret_start = 0; + if (nresults) { + NativeDirectLocal* rl = nd_local(d, desc->result); + if (nd_local_cacheable(d, rl)) { + NativeLoc src = plan.rets[0].src; + if (plan.nrets != 1u || src.kind != NATIVE_LOC_REG || + (NativeAllocClass)src.cls != (NativeAllocClass)rl->cls) + nd_panic(d, "cacheable scalar result not returned in one register"); + if (d->reg_owner[rl->cls][src.v.reg] != CG_LOCAL_NONE) + nd_invalidate_local(d, d->reg_owner[rl->cls][src.v.reg]); + d->reg_owner[rl->cls][src.v.reg] = desc->result; + rl->reg = src.v.reg; + rl->dirty = 1; + nd_cache_link(d, desc->result); + nd_touch_local(d, rl); + ret_start = 1u; + } + } + for (u32 i = ret_start; i < plan.nrets; ++i) nd_write_loc(d, plan.rets[i].dst, plan.rets[i].src, plan.rets[i].mem); - /* The result's cache register is now valid; unpin it so it behaves as an - * ordinary write-back cache entry (its value reaches the home on the next - * flush, or is dropped if the result turns out dead). */ - if (nresults && results[0].kind == NATIVE_LOC_REG) - d->scratch_used[results[0].cls] &= ~(1u << results[0].v.reg); if (release_callee_tmp) nd_scratch_release(d, (NativeAllocClass)callee_tmp.cls, callee_tmp.v.reg); }