kit

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

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:
Mdoc/plan/CLEANUP-2026-06-09.md | 26+++++++++++++++++++-------
Msrc/arch/aa64/native.c | 24+-----------------------
Msrc/arch/riscv/native.c | 26++------------------------
Msrc/arch/x64/native.c | 26++------------------------
Msrc/cg/native_direct_target.c | 29+++++++++++++++++++++++++++++
Msrc/cg/native_direct_target.h | 11+++++++++++
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