kit

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

commit 492813f8d4bc58c72af70327f281a08e832ca959
parent 2e236aa0196bbee9a2c39a5e4c4f9786fd963b99
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 13:49:07 -0700

perf(cg,arch): light up the -O0 NDT value cache on x64/rv, widen aa64 fp

The single-pass NativeDirectTarget value cache only allocates from a
per-class register pool, and on x64 and riscv that pool was empty: their
NDT allocable lists were callee-saved-only while the cache restricted
itself to caller-saved registers. Every integer local therefore
round-tripped through its stack slot after each op, with only 2-3
transient scratch registers.

Core (native_direct_target.{c,h}): the cache pool is now the caller-saved
subset of allocable on ndt_caller_saved_only targets (aa64, keeping the
deferred prologue free of callee-save spills), else the full allocable
list, with any callee-save covered by the existing reserve_callee_saves
path. Rename caller_alloc -> cache_pool; drop the now-unneeded
caller-saved victim guard.

x64: NDT allocable -> {rsi, rdi, r13, r14, r15}, caller-first (rcx/rax/rdx
excluded -- shift/divide clobber them implicitly; r13-r15 fit the
fixed-size save region).
riscv: NDT allocable -> caller-saved only (int a0-a7; fp ft4-7/ft8-11/
fa0-7) so the cache needs no callee-save spills and the fixed 32-word
single-pass prologue can't overflow on large/variadic frames. Covers
rv64 and rv32 (shared backend).
aa64: widen the fp pool from {v18,v19} with v22-v31 (v16 is the fp emit
scratch, v20/v21 the per-op scratch -- both excluded).

allocable[] is read only by the NDT (the optimizer allocates over phys[]
flags), so each arch's -O0 pool is tunable without touching -O1.

Static -O0 stack mem ops on a header-free compute file:
  x86_64   241 -> 159 (-34%),  insns 676 -> 551
  riscv64  240 -> 138 (-43%),  insns 455 -> 353
  aarch64  int unchanged; fp bench mem ops 59 -> 51
Verified by execution on all four arches (aa64 native, x64 Rosetta, rv64
podman/qemu) plus toy/cg-api/libc/debug/dwarf/isa suites.

Diffstat:
Msrc/arch/aa64/native.c | 10++++++++--
Msrc/arch/riscv/native.c | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/arch/x64/native.c | 15+++++++++++----
Msrc/cg/native_direct_target.c | 60+++++++++++++++++++++++++++++++++---------------------------
Msrc/cg/native_direct_target.h | 22++++++++++++++--------
5 files changed, 218 insertions(+), 65 deletions(-)

diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c @@ -3790,8 +3790,14 @@ static const Reg aa_int_allocable[] = {8u, 11u, 12u, 13u, 14u, 15u, 19u, 20u, * land. x9/x10/x11 are all caller-saved temporaries reserved out of the * allocable set below. */ static const Reg aa_int_scratch[] = {9u, 10u, 11u}; -static const Reg aa_fp_allocable[] = {18u, 19u, 8u, 9u, 10u, - 11u, 12u, 13u, 14u, 15u}; +/* 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}; static const Reg aa_fp_scratch[] = {20u, 21u}; #define AA_PHYS_INT_ALLOC(r) \ diff --git a/src/arch/riscv/native.c b/src/arch/riscv/native.c @@ -44,9 +44,22 @@ enum { RV_FTMP1 = 1u, /* ft1: emit-internal FP scratch */ RV_FA0 = 10u, /* fa0..fa7 = f10..f17 (FP arg/return registers) */ RV_FA7 = 17u, - /* Single-pass (-O0) worst-case prologue: sp adjust (3) + far save pair (7) - * + sret spill (1) + variadic GP spills (8). No callee-saves at -O0. */ - RV_PROLOGUE_WORDS = 32u, + /* Single-pass (-O0) tcc-style prologue. The frame-independent entry + * `addi sp,sp,-(frame_save_size+va_save) ; sd s0,0(sp) ; sd ra,ptr(sp) ; + * addi s0,sp,0` + * (RV_NDT_FIXED_ENTRY_WORDS) is emitted live in rv_func_begin: it pre-decrements + * sp by just the saved-pair + variadic-GP-save area, stores the s0/ra pair at + * the top, and anchors s0 there. The pre-decrement is always small + * (<= 16 + 64 = 80 on rv64, 8 + 32 = 40 on rv32), so it always fits imm12 and + * needs no lui+add far form. Only the second `sub sp` that grows the rest of + * the frame below the pair is deferred (patched once frame_size is final); the + * single-pass path uses no callee-saved registers (enforced in the NDT scratch + * acquire) and RISC-V has no Windows-style stack probe, so nothing else is + * deferred and the reserved region is tiny: worst-case sub = lui(1)+addi(1)+ + * add(1) = 3 words. The sret + variadic GP spills are s0-relative and emitted + * live after the region (rv_emit_entry_save_stores). */ + RV_NDT_FIXED_ENTRY_WORDS = 4u, + RV_NDT_SUB_WORDS = 4u, /* region buffer: worst-case far `sub sp` is 3 (+1 pad) */ /* Known-frame (-O1) prologues are emitted directly, not into the fixed -O0 * NOP region, and additionally save callee-saved registers (up to 11 int + 12 * fp, each up to 4 words for a far s0-relative offset) on top of the header, @@ -270,6 +283,7 @@ typedef struct RvNativeTarget { u32 func_start; u32 prologue_pos; + u32 prologue_region_words; /* single-pass: reserved deferred-`sub` region */ MCLabel epilogue_label; } RvNativeTarget; @@ -385,11 +399,21 @@ static u32 loc_reg(NativeLoc loc) { return loc.v.reg & 0x1fu; } * `s->arr[i].field`), and the worst case — a 3-operand binop, or such an access * — has three native-emit scratches live at once. aarch64 reserves three for * the same reason; x64 needs only two because it folds the index into its - * addressing mode. s1..s11 (callee-saved) are the allocable set, saved by the - * optimizer prologue at -O1; rv64 keeps no caller-saved allocable int reg. */ -static const Reg rv_int_allocable[] = {9u, 18u, 19u, 20u, 21u, 22u, - 23u, 24u, 25u, 26u, 27u}; -static const Reg rv_int_scratch[] = {29u, 30u, 31u}; /* t4, t5, t6 */ + * addressing mode. + * + * rv_int_allocable is the NDT (-O0) value-cache / scratch pool and is read only + * by NativeDirectTarget; the optimizer allocates over the phys[] ALLOCABLE flags + * (s1..s11, callee-saved, saved by its -O1 prologue) instead. The NDT pool is + * the caller-saved argument registers a0..a7: the -O0 cache flushes to frame + * homes at every call/branch/return, so nothing needs to survive in a register + * across a call, and caller-saved regs keep the prologue free of callee-save + * spills — which also keeps the single-pass prologue placeholder (a fixed + * RV_PROLOGUE_WORDS region) from ever overflowing on large/variadic frames the + * way callee-save offsets would. Incoming args are spilled to homes at entry + * before any body op, so caching in a0..a7 cannot clobber a live parameter. */ +static const Reg rv_int_allocable[] = {10u, 11u, 12u, 13u, 14u, + 15u, 16u, 17u}; /* a0..a7 */ +static const Reg rv_int_scratch[] = {29u, 30u, 31u}; /* t4, t5, t6 */ static const NativePhysRegInfo rv_int_phys[] = { RV_PHYS_INT_RESERVED(0u), /* zero */ @@ -447,12 +471,18 @@ static const NativePhysRegInfo rv_int_phys[] = { .spill_cost = 0u, \ .copy_cost = 0u} -/* Caller-saved allocable first (ft4..ft7, ft8..ft11), then callee (fs0..fs11). - * ft0/ft1 reserved as emit-internal scratch; ft2/ft3 driver scratch. */ -static const Reg rv_fp_allocable[] = {4u, 5u, 6u, 7u, 28u, 29u, 30u, - 31u, 8u, 9u, 18u, 19u, 20u, 21u, - 22u, 23u, 24u, 25u, 26u, 27u}; -static const Reg rv_fp_scratch[] = {2u, 3u}; /* ft2, ft3 */ +/* NDT (-O0) fp value-cache / scratch pool (read only by NativeDirectTarget; the + * optimizer uses the phys[] ALLOCABLE flags, which also cover the callee-saved + * fs0..fs11). Caller-saved only — ft4..ft7, ft8..ft11, and the fa0..fa7 arg + * registers — for the same reasons as the int pool: the -O0 cache flushes at + * every call/branch/return, so caller-saved suffices, and keeping callee-saves + * out of the pool keeps the fixed single-pass prologue placeholder from + * overflowing. ft0/ft1 reserved as emit-internal scratch; ft2/ft3 driver + * scratch. */ +static const Reg rv_fp_allocable[] = {4u, 5u, 6u, 7u, 28u, 29u, + 30u, 31u, 10u, 11u, 12u, 13u, + 14u, 15u, 16u, 17u}; /* ft4-7,ft8-11,fa0-7 */ +static const Reg rv_fp_scratch[] = {2u, 3u}; /* ft2, ft3 */ static const NativePhysRegInfo rv_fp_phys[] = { RV_PHYS_FP_RESERVED(0u), /* ft0 = FTMP0 */ @@ -1489,6 +1519,12 @@ static void rv_reserve_entry_saves(RvNativeTarget* a) { } } +/* Emit the s0-relative (frame-independent) entry stores live: the sret a0 spill + * and, for variadics, the unconsumed-GP register save area. Both are anchored at + * s0 (set by the fixed entry in rv_func_begin) and sit at non-negative offsets in + * the saved-pair + variadic region the fixed entry already pre-decremented sp + * for, so they run correctly after the deferred `sub sp`. Single-pass path only; + * the known-frame path emits these inline in rv_build_prologue. */ static void rv_emit_entry_save_stores(RvNativeTarget* a) { NativeTarget* t = &a->base; if (a->has_sret && a->sret_ptr_slot != NATIVE_FRAME_SLOT_NONE) { @@ -1502,6 +1538,22 @@ static void rv_emit_entry_save_stores(RvNativeTarget* a) { rv_emit_mem(a, 0, native_loc_reg(i64t, NATIVE_REG_INT, RV_A0), addr, native_mem_for_type(t, i64t, ptr)); } + /* variadic GP save area: home a0..a7 at [s0 + frame_save_size + i*gp_slot_bytes] + * (positive, always within imm12). Emitted live in rv_func_begin, before the + * params are bound, so next_param_int is not yet final — home ALL eight GP + * registers (the named leading ones are homed too; va_start skips past them via + * its next_param_int cursor, so the extra named-slot stores are dead-but- + * harmless, mirroring aa64). The save-area offsets are byte-identical to the + * historical rv_build_prologue layout. */ + if (a->is_variadic) { + const RiscvVariant* v = a->variant; + u32 fsz = v->frame_save_size; + u32 gp_slot = v->gp_slot_bytes; + u32 i; + for (i = 0; i < 8u; ++i) + rv64_emit32(t->mc, rv_sd_ptr(v, RV_A0 + i, RV_S0, + (i32)fsz + (i32)i * (i32)gp_slot)); + } } /* Collect the callee-saves the body used (none at -O0). */ @@ -1639,13 +1691,74 @@ static u32 rv_build_prologue(RvNativeTarget* a, u32* words, u32 cap, return wi; } +/* Build the deferred portion of the single-pass (-O0) prologue: the `sub sp` + * that grows the frame below the saved s0/ra pair the live entry (rv_func_begin) + * already saved and anchored s0 at. The fixed entry pre-decremented sp by the + * saved-pair + variadic-GP-save area (frame_save_size + va_save_sz), so only + * `frame_size - (frame_save_size + va_save_sz)` remains to subtract. RISC-V has + * no stack probe, so this is the whole deferred region. Returns the word count + * (0 when nothing remains to subtract; <= 3 for the far form). */ +static u32 rv_build_ndt_sub(RvNativeTarget* a, u32 frame_size, u32* words, + u32 cap) { + const RiscvVariant* v = a->variant; + u32 pair_area = v->frame_save_size + rv_va_save_sz(a); + u32 sub_bytes = frame_size - pair_area; + u32 wi = 0; +#define ADDI_LO(rd, lo) \ + (v->has_w_forms ? rv_addiw((rd), (rd), (lo)) : rv_addi((rd), (rd), (lo))) +#define PUSH(w) \ + do { \ + if (wi >= cap) rv_panic(a, "ndt sub placeholder overflow"); \ + words[wi++] = (w); \ + } while (0) + if (!sub_bytes) return 0; + if (fits_i12(-(i32)sub_bytes)) { + PUSH(rv_addi(RV_SP, RV_SP, -(i32)sub_bytes)); + } else { + i32 neg = -(i32)sub_bytes; + i32 hi = (i32)(((i64)neg + 0x800) >> 12); + i32 lo = neg - (i32)((u32)hi << 12); + PUSH(rv_lui(RV_TMP0, (u32)hi & 0xfffffu)); + if (lo) PUSH(ADDI_LO(RV_TMP0, lo)); + PUSH(rv_add(RV_SP, RV_SP, RV_TMP0)); + } +#undef PUSH +#undef ADDI_LO + return wi; +} + static void rv_func_begin(NativeTarget* t, const CGFuncDesc* fd) { RvNativeTarget* a = rv_of(t); MCEmitter* mc = t->mc; - u32 i; + const RiscvVariant* v; + u32 pair_area, region, i; rv_func_begin_common(t, fd); + v = a->variant; + /* tcc-style prologue. Emit the frame-independent entry live: pre-decrement sp + * by just the saved-pair + variadic-GP-save area, store the s0/ra pair at the + * top of it, and anchor s0 there. The pair and s0 land at exactly the + * addresses every s0-relative offset already assumes (s0 == old_sp - + * frame_save_size - va_save_sz, identical to the old `sub frame_size` then + * `addi s0,sp,fp_pair_off`), so the frame is byte-identical — only the + * instruction sequence changes. pair_area is small (<=80 rv64 / <=40 rv32) and + * always fits imm12. */ + pair_area = v->frame_save_size + rv_va_save_sz(a); + rv64_emit32(mc, rv_addi(RV_SP, RV_SP, -(i32)pair_area)); + rv64_emit32(mc, rv_sd_ptr(v, RV_S0, RV_SP, 0)); + rv64_emit32(mc, rv_sd_ptr(v, RV_RA, RV_SP, (i32)v->ptr_bytes)); + rv64_emit32(mc, rv_addi(RV_S0, RV_SP, 0)); /* s0 = sp = saved-pair address */ + /* Reserve only the deferred `sub sp` region (patched in rv_func_end); record + * it for the patch and CFI. RISC-V has no stack probe, so the region is just + * the worst-case far `sub`. */ + region = RV_NDT_SUB_WORDS; a->prologue_pos = mc->pos(mc); - for (i = 0; i < RV_PROLOGUE_WORDS; ++i) rv64_emit32(mc, RV_NOP); + /* The live fixed entry is exactly RV_NDT_FIXED_ENTRY_WORDS words; the CFI + * advance in rv_func_end relies on prologue_pos sitting just past it. */ + if (a->prologue_pos - a->func_start != RV_NDT_FIXED_ENTRY_WORDS * 4u) + rv_panic(a, "fixed prologue entry size drifted"); + a->prologue_region_words = region; + for (i = 0; i < region; ++i) rv64_emit32(mc, RV_NOP); + /* sret + variadic GP spills are s0-relative and run live after the region. */ rv_reserve_entry_saves(a); rv_emit_entry_save_stores(a); } @@ -1692,15 +1805,24 @@ static void rv_func_end(NativeTarget* t) { rv64_emit32(mc, rv_jalr(RV_ZERO, RV_RA, 0)); } - /* patch prologue */ + /* patch the deferred-`sub` region. The frame-independent entry (saved pair + + * s0 anchor) was already emitted live in rv_func_begin; only the `sub sp` that + * grows the frame below the pair is deferred here. Single-pass path only — the + * known-frame path emits its prologue final and never patches. */ if (!a->frame.known_frame) { - u32 words[RV_PROLOGUE_WORDS]; + u32 region = a->prologue_region_words; + u32 words[RV_NDT_SUB_WORDS]; u32 nwords, k; - for (k = 0; k < RV_PROLOGUE_WORDS; ++k) words[k] = RV_NOP; - nwords = rv_build_prologue(a, words, RV_PROLOGUE_WORDS, frame_size, - fp_pair_off, int_regs, n_int, fp_regs, n_fp); - (void)nwords; - for (k = 0; k < RV_PROLOGUE_WORDS; ++k) + if (region > RV_NDT_SUB_WORDS) rv_panic(a, "prologue region too large"); + for (k = 0; k < region; ++k) words[k] = RV_NOP; + nwords = rv_build_ndt_sub(a, frame_size, words, region); + /* If the deferred `sub` is shorter than the reserved region, branch straight + * to the entry saves rather than executing the trailing NOPs. */ + if (nwords < region) { + words[nwords] = rv_jal(RV_ZERO, (i32)((region - nwords) * 4u)); + for (k = nwords + 1u; k < region; ++k) words[k] = RV_NOP; + } + for (k = 0; k < region; ++k) rv_patch32(obj, sec, a->prologue_pos + k * 4u, words[k]); } /* patch alloca sites: addi dst, sp, max_outgoing */ @@ -1723,9 +1845,15 @@ static void rv_func_end(NativeTarget* t) { mc->cfi_def_cfa(mc, RV_SP, 0); } else { i32 cfa = (i32)frame_size - (i32)fp_pair_off; + /* CFI advance to the post-prologue PC. known-frame: prologue_pos + + * minimal_prologue_words. single-pass: prologue_pos is past the live fixed + * entry, so add the reserved deferred-`sub` region (the saved pair + s0 + * anchor are already established by the fixed entry; the deferred `sub` is + * what completes the frame). The sret/variadic spills after the region do + * not move sp/s0, so the CFA state holds from here. */ u32 post = a->prologue_pos + (a->frame.known_frame ? a->minimal_prologue_words * 4u - : RV_PROLOGUE_WORDS * 4u); + : a->prologue_region_words * 4u); u32 k; mc->cfi_set_next_pc_offset(mc, post - a->func_start); mc->cfi_def_cfa(mc, RV_S0, cfa); diff --git a/src/arch/x64/native.c b/src/arch/x64/native.c @@ -211,10 +211,17 @@ static void emit_jcc_rel32(MCEmitter* mc, u32 cc, MCLabel l); .spill_cost = 0u, \ .copy_cost = 0u} -/* Allocable int pool, opt's spill/reload set. R8/R9 are the driver scratch - * pool; R10/R11 are emit scratch (reserved); RAX is reserved (return / div-mul, - * asm-pinnable). */ -static const Reg x64_int_allocable[] = {X64_R13, X64_R14, X64_R15}; +/* The NDT (-O0) value-cache / scratch pool (read only by NativeDirectTarget; + * the optimizer allocates over the phys[] ALLOCABLE flags instead, which also + * include rcx/rdx/rsi/rdi). Caller-saved RSI/RDI lead so the cache prefers them + * (no prologue save) and only spills into callee-saved R13-R15 under pressure + * (the fixed-size save region covers them). RSI/RDI are safe to cache: they have + * no implicit x86 use — unlike RCX (shift count) and RAX/RDX (div/mul), which + * are therefore excluded — and incoming args are spilled to frame homes at entry + * before any body op runs. R8/R9 are the driver scratch pool; R10/R11 are emit + * scratch (reserved); RAX is reserved (return / div-mul, asm-pinnable). */ +static const Reg x64_int_allocable[] = {X64_RSI, X64_RDI, X64_R13, X64_R14, + X64_R15}; static const Reg x64_int_scratch[] = {X64_R8, X64_R9}; static const NativePhysRegInfo x64_int_phys[] = { diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -554,28 +554,28 @@ static Reg nd_pick_cache_victim(NativeDirectTarget* d, NativeAllocClass cls) { return best; } -/* Pick a caller-saved allocable register to cache a local in: a free one, else - * evict the LRU non-pinned cached local. REG_NONE means use the frame-only - * path. */ +/* Pick an allocable register to cache a local in: a free one from the cache + * pool, else evict the LRU non-pinned cached local. REG_NONE means use the + * frame-only path. */ static Reg nd_cache_alloc(NativeDirectTarget* d, NativeAllocClass cls) { - u32 caller = d->caller_saved[cls]; - const Reg* sub = d->caller_alloc[cls]; - u32 nsub = d->ncaller_alloc[cls]; + const Reg* pool = d->cache_pool[cls]; + u32 npool = d->ncache_pool[cls]; Reg victim; - /* The cache only ever allocates caller-saved registers, so scan the - * precomputed caller-saved-allocable sublist (allocable order preserved) and - * skip the always-failing callee-saved candidates. First match is identical - * to the former full-allocable scan. */ - for (u32 i = 0; i < nsub; ++i) { - Reg r = sub[i]; + /* Scan the precomputed cache pool (allocable order preserved; on + * ndt_caller_saved_only targets it is the caller-saved subset). */ + for (u32 i = 0; i < npool; ++i) { + Reg r = pool[i]; if (d->reg_owner[cls][r] == CG_LOCAL_NONE && (d->scratch_used[cls] & (1u << r)) == 0) { nd_note_reg_used(d, cls, r); return r; } } + /* Under pressure, evict the LRU non-pinned cached local and reuse its + * register. Every cached local was allocated from cache_pool, so the victim + * is always a valid pool register — no caller/callee guard needed. */ victim = nd_pick_cache_victim(d, cls); - if (victim != REG_NONE && (caller & (1u << victim))) { + if (victim != REG_NONE) { nd_flush_local(d, d->reg_owner[cls][victim]); nd_note_reg_used(d, cls, victim); return victim; @@ -1954,21 +1954,27 @@ CgTarget* native_direct_target_new(Compiler* c, ObjBuilder* obj, } } - /* Precompute, per class, the caller-saved-allocable sublist (in allocable - * order) and the live-ABI caller-saved mask, both constant for the program. - * The local register cache only allocates caller-saved registers, so the - * free-reg scan iterates this short list and avoids re-querying the mask. */ - for (u32 cls = 0; cls < 3u; ++cls) { - const NativeAllocClassInfo* ci = d->class_info[cls]; - u32 mask, n = 0; - if (!ci) continue; - mask = nd_caller_saved_mask(d, (NativeAllocClass)cls); - d->caller_saved[cls] = mask; - for (u32 i = 0; i < ci->nallocable; ++i) { - Reg r = ci->allocable[i]; - if (r < 32u && (mask & (1u << r))) d->caller_alloc[cls][n++] = r; + /* Precompute, per class, the -O0 value-cache register pool (in allocable + * order), constant for the program. On ndt_caller_saved_only targets the pool + * is the caller-saved subset of allocable (the cache then never forces a + * callee-save spill into the deferred prologue); otherwise it is the full + * allocable list, with any callee-save in it reported to the backend on use + * (nd_note_reg_used) and reserved by reserve_callee_saves. */ + { + int caller_only = d->reg_info && d->reg_info->ndt_caller_saved_only; + for (u32 cls = 0; cls < 3u; ++cls) { + const NativeAllocClassInfo* ci = d->class_info[cls]; + u32 mask, n = 0; + if (!ci) continue; + mask = nd_caller_saved_mask(d, (NativeAllocClass)cls); + for (u32 i = 0; i < ci->nallocable; ++i) { + Reg r = ci->allocable[i]; + if (r >= 32u) continue; + if (caller_only && !(mask & (1u << r))) continue; + d->cache_pool[cls][n++] = r; + } + d->ncache_pool[cls] = n; } - d->ncaller_alloc[cls] = n; } d->base.func_begin = nd_func_begin; diff --git a/src/cg/native_direct_target.h b/src/cg/native_direct_target.h @@ -120,14 +120,20 @@ struct NativeDirectTarget { const NativeRegInfo* reg_info; const NativeAllocClassInfo* class_info[3]; - /* Per-class caller-saved-allocable sublist, precomputed at construction in - * `allocable[]` order (the cache only ever allocates caller-saved registers, - * so the free-reg scan iterates this short list instead of all allocable - * registers, and avoids re-querying the live-ABI caller-saved mask per call). - * caller_saved[cls] is that same mask cached. */ - Reg caller_alloc[3][32]; - u32 ncaller_alloc[3]; - u32 caller_saved[3]; + /* Per-class -O0 value-cache register pool, precomputed at construction in + * `allocable[]` order. On ndt_caller_saved_only targets (aa64) it is the + * caller-saved subset of allocable, so the deferred prologue never has to + * spill a callee-save. Other targets take the whole allocable list: their NDT + * allocable sets are deliberately hazard-free (no implicit-clobber registers + * such as the x86 shift/divide regs) and any callee-save among them is either + * covered by the reserve_callee_saves prologue path (x64 r13-r15, whose save + * region is fixed-size) or is itself caller-saved (riscv a-regs), so caching + * in them is both safe and free of a single-pass prologue-budget blowup. + * Note: `allocable[]` is read only by the NDT, never the optimizer (which + * allocates over the phys[] ALLOCABLE flags), so each arch tunes it for -O0 + * register caching independently of -O1. */ + Reg cache_pool[3][32]; + u32 ncache_pool[3]; const CGFuncDesc* func; SrcLoc loc;