commit ab9b9d875fe5380e9f888ac9a6b6d60d5bc21941
parent c0b3c960cc1669197efecd77359fd923f4cb12b1
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 9 Jun 2026 22:12:28 -0700
cleanup wave E: tail-call projection dedup (B.2) + optimizer-asm drift verdict (A.2)
B.2 — extracted the byte-identical CGCallDesc->NativeCallDesc tail-call
projection (build NativeCallDesc args/results from the frame homes) out of
aa_no_tail / x64_no_tail / rv_no_tail into
native_direct_project_tail_call_desc (cg/native_direct_target.{c,h}). Each
backend's _no_tail keeps its arch-divergent tail-eligibility logic inline (aa64
has no callee-saves guard; x64/rv64 do; x64's stack threshold adds shadow_space;
each uses its own *_call_stack_size) — those are real differences, not dedup
targets. Behavior-identical; verified by test-aa64-inline / test-rv64-inline /
test-x64-inline / test-opt / test-cg-api / test-toy / test-smoke-x64/rv64.
A.2 — investigated the optimizer-path *_asm_block_native triplication that A.1
surfaced; dedup deliberately DEFERRED. Verdict: rv64's extra output-staging
branch (staged_outs) is NEEDED, not a bug — rv64 has dedicated stage registers
(RV_TMP2/3 + RV_FTMP0/1) distinct from its mem-scratch, so it can stage a
register-constrained operand that regalloc couldn't pin, where aa64/x64 reuse
their 2 mem-scratch regs and can't. The three hooks differ by *policy* (staging
coverage + scratch-register model), not just naming, so a hooks dedup would
relocate rather than unify behavior and add miscompile risk in a hot path.
A.3 (NEW finding, surfaced by the A.2 investigation; tracked in the doc, not
fixed here) — aa64/x64 inline-asm operand staging is narrower than rv64: a
register-constrained output / FP input / x64 'q' input that regalloc spilled
(e.g. address-taken local) PANICS on aa64/x64 where rv64 succeeds. Needs a
dedicated, test-backed fix (it changes which programs compile vs panic); once
the three staging policies match, A.2's dedup becomes safe.
Diffstat:
6 files changed, 64 insertions(+), 78 deletions(-)
diff --git a/doc/plan/CLEANUP-2026-06-09.md b/doc/plan/CLEANUP-2026-06-09.md
@@ -107,12 +107,24 @@ confirmed finding.
is now a thin wrapper supplying scratch masks / OPK constants / a panic + a few adapters
(−222 LOC × 3 native.c). The direct copies turned out **not** drifted (only the scratch-mask
seed differed); behavior preserved, verified by inline-asm × 3 / toy / smoke.
-- [ ] **A.2 (surfaced by A.1)** the *optimizer-path* hooks `aa_/x64_/rv_asm_block_native`
- (`aa64/native.c:~4869`, `x64/native.c:~4000`, `riscv/native.c:~3753`) are the *genuinely*
- drifted triplication: rv64's `rv_asm_block_native` carries an extra `staged_outs[i]=1` branch
- (`riscv/native.c:~3789`) and the input loops have differing frame-slot scratch-load
- conditions. Left verbatim by A.1. *Fix:* a second shared driver (or extend the direct one)
- for the optimizer path — and reconcile/flag the rv64 staging drift (possible latent bug).
+- [~] **A.2 (surfaced by A.1)** the *optimizer-path* hooks `aa_/x64_/rv_asm_block_native`.
+ **INVESTIGATED; dedup DEFERRED (deliberately).** Verdict: rv64's extra `staged_outs[i]=1`
+ output-staging branch is **NOT a bug** — it's a needed rv64 difference (rv64 has dedicated
+ stage regs `RV_TMP2/3`+`RV_FTMP0/1` distinct from its mem-scratch, so it can safely stage
+ register-constrained operands that didn't get a hard reg from regalloc). The optimizer-path
+ differences are *policy* (different staging coverage + structurally different scratch
+ models), not just naming — a hooks-based dedup would relocate, not unify, behavior and add
+ miscompile risk in a hot path. Correct sequencing: land **A.3** first, then dedup.
+- [ ] **A.3 (NEW — latent robustness gap, surfaced by A.2's investigation)** aa64/x64 inline-asm
+ operand staging is *narrower* than rv64: for a register-constrained operand that regalloc
+ could not keep in a hard register (address-taken/spilled local), rv64 stages it to a scratch
+ reg and succeeds, but **aa64/x64 panic** (`"register asm operand not in a register"`). Gaps:
+ (a) register-constrained **outputs** in a non-reg loc — no fallback on aa64 (`native.c:4883`)
+ or x64 (`:4021`), only rv64 (`riscv/native.c:3780`); (b) **FP-constrained inputs** (`"w"` aa64,
+ `"x"/"v"` x64) in a non-reg loc; (c) x64 **`"q"`** inputs (the input-stage test is raw
+ `body[0]=='r'`, so `q`→INT,allowed=0 skips staging then panics). *Fix:* give aa64/x64 the
+ equivalent fallback staging, with test cases — changes which programs compile vs panic, so a
+ dedicated reviewed change (NOT a dedup). After this, A.2's dedup becomes safe.
### B. Per-arch codegen plumbing duplicated (med, 7)
- [x] **B.1** x64 callee-save offset formula open-coded 5+ times; collect+reverse-restore
@@ -120,7 +132,7 @@ confirmed finding.
`src/arch/x64/native.c:1946-1953, 2655-2666`. aa64 already factored
`aa_emit_callee_restores`. *Fix:* `x64_cs_int_off/x64_cs_fp_off` helpers + extract
`x64_emit_callee_restores`, mirroring aa64.
-- [ ] **B.2** `CGCallDesc→NativeCallDesc` tail-call projection (`rv_no_tail`/`aa_no_tail`/
+- [x] **B.2** `CGCallDesc→NativeCallDesc` tail-call projection (`rv_no_tail`/`aa_no_tail`/
`x64_no_tail`) duplicated. *Fix:* shared projection in `cg/native_direct_target`.
- [x] **B.3** PC-relative `auipc`+anchor reloc pair emitted 3× in riscv. *Fix:* one helper.
- [x] **B.4** Indirect `jmp`/`call r/m64` encoding 3× in x64. *Fix:* one encoder.
diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c
@@ -4097,30 +4097,8 @@ static void aa_bind_param(NativeDirectTarget* d, const CGParamDesc* p,
static const char* aa_no_tail(NativeDirectTarget* d, const CGCallDesc* call) {
NativeCallDesc nd;
- NativeLoc* args = NULL;
- NativeLoc* results = NULL;
u32 stack;
- memset(&nd, 0, sizeof nd);
- u32 nresults = call->result != CG_LOCAL_NONE ? 1u : 0u;
- if (call->nargs) args = arena_zarray(d->base.c->tu, NativeLoc, call->nargs);
- if (nresults) results = arena_zarray(d->base.c->tu, NativeLoc, nresults);
- for (u32 i = 0; i < call->nargs; ++i) {
- args[i].kind = NATIVE_LOC_FRAME;
- args[i].type = d->locals[call->args[i] - 1u].type;
- args[i].cls = d->locals[call->args[i] - 1u].cls;
- args[i].v.frame = d->locals[call->args[i] - 1u].home;
- }
- if (nresults) {
- results[0].kind = NATIVE_LOC_FRAME;
- results[0].type = d->locals[call->result - 1u].type;
- results[0].cls = d->locals[call->result - 1u].cls;
- results[0].v.frame = d->locals[call->result - 1u].home;
- }
- nd.fn_type = call->fn_type;
- nd.args = args;
- nd.results = results;
- nd.nargs = call->nargs;
- nd.nresults = nresults;
+ native_direct_project_tail_call_desc(d, call, &nd);
stack = aa_call_stack_size(d->native, &nd);
if (stack > aa_of(d->native)->incoming_stack_size)
return "aarch64 tail call: stack argument area too small";
diff --git a/src/arch/riscv/native.c b/src/arch/riscv/native.c
@@ -3950,32 +3950,10 @@ static void rv_bind_param(NativeDirectTarget* d, const CGParamDesc* p,
static const char* rv_no_tail(NativeDirectTarget* d, const CGCallDesc* call) {
RvNativeTarget* a = rv_of(d->native);
NativeCallDesc nd;
- NativeLoc* args = NULL;
- NativeLoc* results = NULL;
- u32 i, stack;
+ u32 stack;
if (a->frame.ncallee_saves)
return "rv64 tail call: callee-saved registers in use";
- memset(&nd, 0, sizeof nd);
- u32 nresults = call->result != CG_LOCAL_NONE ? 1u : 0u;
- if (call->nargs) args = arena_zarray(d->base.c->tu, NativeLoc, call->nargs);
- if (nresults) results = arena_zarray(d->base.c->tu, NativeLoc, nresults);
- for (i = 0; i < call->nargs; ++i) {
- args[i].kind = NATIVE_LOC_FRAME;
- args[i].type = d->locals[call->args[i] - 1u].type;
- args[i].cls = d->locals[call->args[i] - 1u].cls;
- args[i].v.frame = d->locals[call->args[i] - 1u].home;
- }
- if (nresults) {
- results[0].kind = NATIVE_LOC_FRAME;
- results[0].type = d->locals[call->result - 1u].type;
- results[0].cls = d->locals[call->result - 1u].cls;
- results[0].v.frame = d->locals[call->result - 1u].home;
- }
- nd.fn_type = call->fn_type;
- nd.args = args;
- nd.results = results;
- nd.nargs = call->nargs;
- nd.nresults = nresults;
+ native_direct_project_tail_call_desc(d, call, &nd);
stack = rv_call_stack_size(d->native, &nd);
if (stack > a->incoming_stack_size)
return "rv64 tail call: stack argument area too small";
diff --git a/src/arch/x64/native.c b/src/arch/x64/native.c
@@ -4274,32 +4274,10 @@ static void x64_bind_param(NativeDirectTarget* d, const CGParamDesc* p,
static const char* x64_no_tail(NativeDirectTarget* d, const CGCallDesc* call) {
X64NativeTarget* a = x64_of(d->native);
NativeCallDesc nd;
- NativeLoc* args = NULL;
- NativeLoc* results = NULL;
- u32 i, stack;
+ u32 stack;
if (a->frame.ncallee_saves)
return "x64 tail call: callee-saved registers in use";
- memset(&nd, 0, sizeof nd);
- u32 nresults = call->result != CG_LOCAL_NONE ? 1u : 0u;
- if (call->nargs) args = arena_zarray(d->base.c->tu, NativeLoc, call->nargs);
- if (nresults) results = arena_zarray(d->base.c->tu, NativeLoc, nresults);
- for (i = 0; i < call->nargs; ++i) {
- args[i].kind = NATIVE_LOC_FRAME;
- args[i].type = d->locals[call->args[i] - 1u].type;
- args[i].cls = d->locals[call->args[i] - 1u].cls;
- args[i].v.frame = d->locals[call->args[i] - 1u].home;
- }
- if (nresults) {
- results[0].kind = NATIVE_LOC_FRAME;
- results[0].type = d->locals[call->result - 1u].type;
- results[0].cls = d->locals[call->result - 1u].cls;
- results[0].v.frame = d->locals[call->result - 1u].home;
- }
- nd.fn_type = call->fn_type;
- nd.args = args;
- nd.results = results;
- nd.nargs = call->nargs;
- nd.nresults = nresults;
+ native_direct_project_tail_call_desc(d, call, &nd);
stack = x64_call_stack_size(d->native, &nd);
/* x64_call_stack_size includes the shadow-space prefix; the caller's incoming
* window has the same prefix, so compare against incoming_stack_size + it. */
diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c
@@ -45,6 +45,35 @@ static NativeLoc* nd_loc_buf(NativeDirectTarget* d, NativeLoc* buf, u32 cap,
return nd_arena(d, sizeof(NativeLoc) * n, _Alignof(NativeLoc));
}
+void native_direct_project_tail_call_desc(NativeDirectTarget* d,
+ const CGCallDesc* call,
+ NativeCallDesc* nd) {
+ NativeLoc* args = NULL;
+ NativeLoc* results = NULL;
+ u32 nresults = call->result != CG_LOCAL_NONE ? 1u : 0u;
+ u32 i;
+ memset(nd, 0, sizeof *nd);
+ if (call->nargs) args = arena_zarray(d->base.c->tu, NativeLoc, call->nargs);
+ if (nresults) results = arena_zarray(d->base.c->tu, NativeLoc, nresults);
+ for (i = 0; i < call->nargs; ++i) {
+ args[i].kind = NATIVE_LOC_FRAME;
+ args[i].type = d->locals[call->args[i] - 1u].type;
+ args[i].cls = d->locals[call->args[i] - 1u].cls;
+ args[i].v.frame = d->locals[call->args[i] - 1u].home;
+ }
+ if (nresults) {
+ results[0].kind = NATIVE_LOC_FRAME;
+ results[0].type = d->locals[call->result - 1u].type;
+ results[0].cls = d->locals[call->result - 1u].cls;
+ results[0].v.frame = d->locals[call->result - 1u].home;
+ }
+ nd->fn_type = call->fn_type;
+ nd->args = args;
+ nd->results = results;
+ nd->nargs = call->nargs;
+ nd->nresults = nresults;
+}
+
static void nd_grow_locals(NativeDirectTarget* d, u32 want) {
NativeDirectLocal* next;
u32 cap;
diff --git a/src/cg/native_direct_target.h b/src/cg/native_direct_target.h
@@ -192,4 +192,15 @@ CgTarget* native_direct_semantic_target_new(Compiler*, ObjBuilder*, MCEmitter*,
NativeTargetCtor ctor,
const NativeOps* ops);
+/* Project a semantic CGCallDesc into a NativeCallDesc whose args/results are the
+ * callee's incoming frame slots (the layout a sibling/tail call must land in).
+ * `nd` is fully overwritten (memset to zero, then its args/results buffers are
+ * arena-allocated from d->locals[*].home). This is the arch-neutral core the
+ * per-arch tail_call_unrealizable_reason hooks share; each backend wraps it with
+ * its own eligibility guards and call_stack_size / incoming-window comparison.
+ * Leaves nd.callee / flags / tail_policy / inline_policy zeroed. */
+void native_direct_project_tail_call_desc(NativeDirectTarget* d,
+ const CGCallDesc* call,
+ NativeCallDesc* nd);
+
#endif