kit

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

commit 51681b4548f8b4046ecb863e175c4ab64179aa5f
parent e98cc403a4f37c11bd4e087cccf9cefdc5b428e9
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Wed, 17 Jun 2026 13:09:23 -0700

arm32: fix O1 many-int variadic call (spilled call args aliased to ip/lr)

A call with more register arguments than fit, under enough register pressure to
spill, miscompiled at -O1: variadic_02_many_ints (sum(12, 1..)) returned 0
instead of 42. The 13 arg values exhaust arm32's tiny allocable file (r0-r6,
r8-r11 = 11), so the allocator spills two; the rewrite then rematerialized each
spilled constant call-arg into the round-robin opt-scratch registers (ip/lr).
But ALL of a call's args are live simultaneously at the call, so four register
args round-robined onto just {ip, lr} and clobbered each other (r0..r3 sourced
ip,lr,ip,lr). arm_plan_call's per-stack-arg staging through ip made it worse.

Two fixes:
- opt/pass_lower.c: opt_mark_remat now excludes any value used as a call
  argument from rematerialization (remat_exclude_call_args). Remat-into-scratch
  is only sound for a use consumed immediately; call args are all live at once,
  and there are fewer scratch registers (2 on arm32) than a call can have
  simultaneously-live spilled args. Keeping them non-remat preserves their spill
  store, so rewrite_call_arg_operand reads each from its own slot. Targets with
  ample registers never spill call args, so this is a no-op there (aa64 parse
  2988/0, arm32 toy 423/0/2 unchanged).
- arch/arm32/native.c: a register-resident outgoing stack arg now stores
  directly (str rN,[sp,#off]) instead of bouncing through the ip arg-stage
  scratch, so it cannot clobber a live value the optimizer parked in ip.

variadic_02_many_ints now passes O0+O1; many-int sums (9/11/13 args) verified.

Diffstat:
Msrc/arch/arm32/native.c | 17++++++++++++++---
Msrc/opt/pass_lower.c | 42++++++++++++++++++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 3 deletions(-)

diff --git a/src/arch/arm32/native.c b/src/arch/arm32/native.c @@ -2020,11 +2020,22 @@ static void arm_plan_call(NativeTarget* t, const NativeCallDesc* desc, m->size = part->size; m->is_addr = 0; } else { - NativeLoc tmp = native_loc_reg(lty, NATIVE_REG_INT, arg_stage); - arm_load_part(t, tmp, desc->args[i], part->src_offset, part->size); if (p == 0 && even) stack = align_up_u32(stack, 8u); else stack = align_up_u32(stack, 4u); - arm_store_outgoing(t, stack, tmp, part->size, tail); + if (desc->args[i].kind == NATIVE_LOC_REG) { + /* Source already in a register: store it straight to the outgoing + * slot. Staging through the arg scratch (ip) would clobber a live + * value the optimizer parked there — under register pressure it + * spills/rematerializes arg values into the opt scratch regs (ip/lr, + * see pass_lower scratch_for), so a register arg whose source is ip + * was lost when an earlier stack arg's staging overwrote it (the + * many-int variadic O1 miscompile). */ + arm_store_outgoing(t, stack, desc->args[i], part->size, tail); + } else { + NativeLoc tmp = native_loc_reg(lty, NATIVE_REG_INT, arg_stage); + arm_load_part(t, tmp, desc->args[i], part->src_offset, part->size); + arm_store_outgoing(t, stack, tmp, part->size, tail); + } stack += 4u; } } diff --git a/src/opt/pass_lower.c b/src/opt/pass_lower.c @@ -1522,6 +1522,47 @@ static void remat_count_def(Func* f, Inst* in, Operand* op, int is_def, /* Linear pre-pass: record remat_def[v] iff v has exactly one def and that def * is in the v1 set. One walk to count defs + snapshot candidate defs, one PReg * sweep to drop multiply-defined entries. */ +/* Drop `op`'s PReg from the remat set (used to force a spill store + slot reload + * instead of a recompute-into-scratch). */ +static void remat_exclude_operand(RematInfo* ri, const Operand* op) { + if (!op || op->kind != OPK_REG) return; + PReg v = (PReg)op->v.reg; + if (v != PREG_NONE && v != 0 && v < ri->nregs) ri->remat_def[v] = NULL; +} + +/* Exclude every value used as a call argument from rematerialization. ALL of a + * call's arguments are live simultaneously at the call, but a spilled remat arg + * recomputes into the round-robin opt-scratch registers + * (rewrite_call_arg_operand). With more simultaneously-live spilled args than + * scratch registers — arm32 has only ip/lr yet up to four register args — the + * round-robin aliases them and they clobber each other (the many-int variadic + * O1 miscompile). Keeping these out of the remat set preserves their spill + * store, so the arg rewrite reads each one from its own slot. Other targets have + * enough registers that call args rarely spill, so this is a no-op there. */ +static void remat_exclude_call_args(Func* f, RematInfo* ri) { + for (u32 b = 0; b < f->nblocks; ++b) { + Block* bl = &f->blocks[b]; + for (u32 i = 0; i < bl->ninsts; ++i) { + Inst* in = &bl->insts[i]; + IRCallAux* aux; + if ((IROp)in->op != IR_CALL) continue; + aux = (IRCallAux*)in->extra.aux; + if (!aux) continue; + if (aux->use_plan_replay) { + for (u32 k = 0; k < aux->plan.nargs; ++k) + remat_exclude_operand(ri, &aux->plan.args[k].src); + } else { + for (u32 k = 0; k < aux->desc.nargs; ++k) { + CGABIValue* av = (CGABIValue*)&aux->desc.args[k]; + remat_exclude_operand(ri, &av->storage); + for (u32 p = 0; p < av->nparts; ++p) + remat_exclude_operand(ri, (Operand*)&av->parts[p].op); + } + } + } + } +} + static void opt_mark_remat(Func* f, RematInfo* ri) { u32 nregs = opt_reg_count(f); memset(ri, 0, sizeof *ri); @@ -1552,6 +1593,7 @@ static void opt_mark_remat(Func* f, RematInfo* ri) { } for (u32 v = 0; v < nregs; ++v) if (def_count[v] != 1u) ri->remat_def[v] = NULL; + remat_exclude_call_args(f, ri); } static Inst* remat_def_for(const RematInfo* ri, PReg v) {