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