kit

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

commit 6e8a65407c77b86bff1ee2de56fe11b6b4ad119e
parent 4947d29f9526863273e0456517a6ec8ca8247c3e
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 11:58:31 -0700

fix(cg): gate the NDT caller-saved-only invariant behind a per-arch capability

The prior commit's "single-pass (-O0) path never uses callee-saved registers"
assumption holds for aa64 (rich caller-saved scratch x8..x15) but NOT for x64
(scratch is only {R8,R9}; allocable is the callee-saved {R13,R14,R15}) or riscv
(scratch {t4,t5,t6}; allocable the callee-saved s-registers). Those backends
historically used callee-saved registers as *extra* -O0 scratch under pressure
(saving them in the prologue). Forbidding that broke -O0 on both: x64 failed to
compile even add3 ("out of scratch registers"), and riscv failed on
high-pressure soft-float (rt/lib/fp/fp.c). The aa64-only validation missed it
because the host test lanes run aa64 natively.

Make "the -O0 path is caller-saved-only" an explicit per-arch capability,
NativeRegInfo.ndt_caller_saved_only:
- Set: nd_scratch_acquire skips callee-saved regs and nd_func_end asserts none
  were used — enabling the tiny tcc-style deferred-`sub` prologue. aa64 sets it.
- Clear (default): NDT keeps the historical callee-saved-as-scratch fallback and
  nd_func_end reserves save slots for any used. x64 and riscv leave it clear for
  now, restoring exact pre-regression behavior, until their scratch pools are
  reworked to be caller-saved-rich enough to opt in.

The optimizer/known-frame path is unaffected either way (it never routes through
NDT and always has full callee-save freedom).

Verified: x64 -O0 (assert.c, add3 compile+run, toy cross lane via Rosetta) and
rv64 -O0 (fp.c) work again; aa64 stays on the tcc prologue (3 NOPs/add3).

Diffstat:
Msrc/arch/aa64/native.c | 4++++
Msrc/arch/native_target.h | 12++++++++++++
Msrc/cg/native_direct_target.c | 53++++++++++++++++++++++++++++++++++++-----------------
3 files changed, 52 insertions(+), 17 deletions(-)

diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c @@ -4003,6 +4003,10 @@ static int aa_asm_constraint_reg(const NativeRegInfo* ri, const char* body, static const NativeRegInfo aa_reg_info = { .classes = aa_classes, .nclasses = sizeof aa_classes / sizeof aa_classes[0], + /* x8..x15 give NDT a rich caller-saved scratch pool, so the -O0 path never + * needs a callee-saved register — enabling the tcc-style deferred-`sub` + * prologue (see aa_func_begin / AA_NDT_SUB_WORDS). */ + .ndt_caller_saved_only = 1u, .resolve_name = aa_resolve_name, .asm_operand_reg_ok = aa_asm_operand_reg_ok, .asm_constraint_reg = aa_asm_constraint_reg, diff --git a/src/arch/native_target.h b/src/arch/native_target.h @@ -157,6 +157,18 @@ struct NativeRegInfo { const NativeAllocClassInfo* classes; u32 nclasses; + /* True when this register model's scratch + caller-saved-allocable pool is + * rich enough that the single-pass (-O0) NativeDirectTarget never needs to + * fall back to a callee-saved register under pressure. When set, NDT is + * enforced caller-saved-only (nd_scratch_acquire skips callee-saved regs; + * nd_func_end asserts none were used), which lets the backend reserve only a + * tiny tcc-style deferred-`sub` prologue region instead of a worst-case + * callee-save area. When clear (the default), NDT keeps its historical + * behavior: it may use callee-saved registers as extra scratch and the + * prologue preserves them. Independent of the optimizer/-O1 path, which always + * has full callee-save freedom. */ + u8 ndt_caller_saved_only; + /* Map a register name to its (Reg, class). `name` is the raw spelling * ("rax", "x8", "a7"); the caller resolves any Sym to its bytes first so this * stays pool-free. Returns 0 on success, non-zero for a non-register name. */ diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -163,12 +163,17 @@ static Reg nd_scratch_acquire(NativeDirectTarget* d, NativeAllocClass cls) { for (u32 i = 0; i < nregs; ++i) { Reg r = regs[i]; if (r >= 32u) continue; - /* The single-pass (-O0) path is caller-saved only: never take a - * callee-saved register as scratch (the `allocable` pass would otherwise - * offer them under pressure). Pressure is absorbed by evicting a cached - * local below instead — keeping callee_saved_used empty so each backend - * can reserve only a tiny deferred-prologue region. See nd_func_end. */ - if (nd_callee_saved_mask(d, cls) & (1u << r)) continue; + /* On targets whose register model declares ndt_caller_saved_only, the + * single-pass (-O0) path never takes a callee-saved register as scratch + * (the `allocable` pass would otherwise offer them under pressure); + * pressure is absorbed by evicting a cached local instead. This keeps + * callee_saved_used empty so the backend can reserve only a tiny + * deferred-prologue region (see nd_func_end). Targets without a rich + * enough caller-saved pool (x64, riscv today) leave it clear and keep the + * historical callee-saved-as-scratch fallback. */ + if (d->reg_info && d->reg_info->ndt_caller_saved_only && + (nd_callee_saved_mask(d, cls) & (1u << r))) + continue; if ((d->scratch_used[cls] & (1u << r)) == 0 && d->reg_owner[cls][r] == CG_LOCAL_NONE) { d->scratch_used[cls] |= 1u << r; @@ -946,17 +951,31 @@ static void nd_func_end(CgTarget* t) { NativeFramePatchState frame; memset(&frame, 0, sizeof frame); frame.max_outgoing = d->max_outgoing; - /* Invariant: the single-pass (-O0) path never allocates a callee-saved - * register — nd_scratch_acquire and the local cache restrict themselves to - * caller-saved regs (see nd_scratch_acquire / nd_cache_alloc). The prologue - * therefore never saves/restores any callee-save, which is exactly what lets - * each backend reserve only a small fixed deferred-prologue region (just the - * frame `sub`, plus a Windows probe) instead of a worst-case callee-save - * area. The optimizer/known-frame path keeps full callee-save freedom: it - * drives the NativeTarget directly and never routes through here. */ - for (u32 cls = 0; cls < 3u; ++cls) { - if (d->callee_saved_used[cls]) - nd_panic(d, "single-pass (-O0) path must not use callee-saved registers"); + if (d->reg_info && d->reg_info->ndt_caller_saved_only) { + /* Caller-saved-only target (e.g. aa64): the -O0 path never allocates a + * callee-saved register — nd_scratch_acquire and the local cache restrict + * themselves to caller-saved regs. The prologue therefore never + * saves/restores any callee-save, which is what lets the backend reserve + * only a tiny tcc-style deferred-`sub` prologue region. Assert it so a + * future regression can't silently overflow that region. */ + for (u32 cls = 0; cls < 3u; ++cls) { + if (d->callee_saved_used[cls]) + nd_panic(d, "ndt_caller_saved_only target used a callee-saved register"); + } + } else { + /* Default: NDT may use callee-saved registers as extra scratch under + * pressure; have the backend reserve save slots for them. The + * optimizer/known-frame path is separate and always callee-save-capable. */ + u32 ncallee_classes = 0; + for (u32 cls = 0; cls < 3u; ++cls) { + if (d->callee_saved_used[cls]) ncallee_classes = cls + 1u; + } + if (ncallee_classes) { + if (!d->native || !d->native->reserve_callee_saves) + nd_panic(d, "target cannot preserve callee-saved scratch registers"); + d->native->reserve_callee_saves(d->native, d->callee_saved_used, + ncallee_classes); + } } if (d->native && d->native->note_frame_state) d->native->note_frame_state(d->native, &frame);