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