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