commit fb2cb4c3a776e1ec0dd98270eb41140bdb991565
parent a1ccdbcb9b22194c1ff989e6747755409db4efc3
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 16 Jun 2026 00:42:00 -0700
opt: cmp-imm + addr-mode folds and same-block stack DSE at O1 (O1.md W6+W8)
Diffstat:
7 files changed, 515 insertions(+), 49 deletions(-)
diff --git a/mk/test.mk b/mk/test.mk
@@ -859,7 +859,7 @@ test-macho: lib $(TEST_RT_DEP) $(ROUNDTRIP_BIN_MACHO) $(LINK_EXE_RUNNER) $(JIT_R
OPT_TEST_BIN = build/test/cg_ir_lower_test
TINY_INLINE_TEST_BIN = build/test/tiny_inline_test
-test-opt: bin $(OPT_TEST_BIN) test-opt-tiny-inline test-opt-inline test-opt-zero-arg test-opt-static-prune-aa64 test-opt-aa64-tail test-opt-x64-win-tail-sret test-opt-prologue-tier test-opt-whole-program-inline test-opt-lto-phase1 test-opt-redundant-copy-ext test-opt-redundant-frame-sub test-opt-o1-branch-cleanup test-opt-hot-slot-order test-opt-o1-remat test-opt-aa64-x29-bottom test-opt-o1-coalesce test-opt-o1-switch-imm test-opt-o1-inline-cap test-opt-rv64-far-slot
+test-opt: bin $(OPT_TEST_BIN) test-opt-tiny-inline test-opt-inline test-opt-zero-arg test-opt-static-prune-aa64 test-opt-aa64-tail test-opt-x64-win-tail-sret test-opt-prologue-tier test-opt-whole-program-inline test-opt-lto-phase1 test-opt-redundant-copy-ext test-opt-redundant-frame-sub test-opt-o1-branch-cleanup test-opt-hot-slot-order test-opt-o1-remat test-opt-aa64-x29-bottom test-opt-o1-coalesce test-opt-o1-switch-imm test-opt-o1-inline-cap test-opt-rv64-far-slot test-opt-o1-cmp-imm test-opt-o1-stack-dse
$(OPT_TEST_BIN)
@@ -924,6 +924,19 @@ test-opt-o1-inline-cap: bin
test-opt-rv64-far-slot: bin
@KIT=$(abspath $(BIN)) bash test/opt/rv64_far_slot.sh
+# Structural disasm check: O1 folds a load_imm feeding a cmp into cmp #imm, and
+# merges add #k; ldr [#j] into ldr [#k+j] when the offset is legal (O1.md W6).
+.PHONY: test-opt-o1-cmp-imm
+test-opt-o1-cmp-imm: bin
+ @KIT=$(abspath $(BIN)) bash test/opt/o1_cmp_imm.sh
+
+# Behavioral check: same-block stack dead-store elimination is correct (deletes
+# a fully-overwritten spill store, preserves stores across reads/barriers,
+# never touches volatile/atomic) (O1.md W8).
+.PHONY: test-opt-o1-stack-dse
+test-opt-o1-stack-dse: bin
+ @KIT=$(abspath $(BIN)) bash test/opt/o1_stack_dse.sh
+
test-opt-tiny-inline: bin $(TINY_INLINE_TEST_BIN)
$(TINY_INLINE_TEST_BIN)
diff --git a/src/opt/opt.c b/src/opt/opt.c
@@ -278,7 +278,7 @@ static void opt_o1_native_finish(OptImpl* o, Func* f, int cfg_dirty) {
opt_mir_verify(f, "lower-mir");
metrics_scope_end(o->c, "opt.lower_mir.verify");
metrics_scope_begin(o->c, "opt.combine");
- opt_mir_combine(f);
+ opt_mir_combine(f, o->native);
metrics_scope_end(o->c, "opt.combine");
metrics_scope_begin(o->c, "opt.combine.verify");
opt_mir_verify(f, "post-mir-combine");
diff --git a/src/opt/opt.h b/src/opt/opt.h
@@ -152,12 +152,14 @@ void opt_rewrite_dump(Func*, Writer*);
void opt_coalesce(Func*);
void opt_regalloc_locations(Func*, OptLiveInfo* live_out);
void opt_lower_to_mir(Func*, const OptLiveInfo*);
-void opt_mir_combine(Func*);
+void opt_mir_combine(Func*, NativeTarget* target);
void opt_mir_dce(Func*);
void opt_mir_jump_cleanup(Func*, OptJumpCleanupStage);
void opt_mir_build_cfg(Func*);
void opt_mir_verify(Func*, const char* stage);
-void opt_combine(Func*); /* code selection: merge dependent insns */
+/* code selection: merge dependent insns. `target` (may be NULL) supplies the
+ * immediate-legality oracle for the W6 cmp-imm / address-offset folds. */
+void opt_combine(Func*, NativeTarget* target);
void opt_dce(Func*); /* post-RA DCE */
void opt_dead_def_elim(Func*); /* pre-RA dead-definition elimination */
void opt_dead_def_elim_with_live(Func*, const OptLiveInfo*);
diff --git a/src/opt/pass_combine.c b/src/opt/pass_combine.c
@@ -37,6 +37,18 @@ static int frame_slot_is_spill(Func* f, FrameSlot fs) {
return f->frame_slots[fs - 1u].kind == FS_SPILL;
}
+/* W6 address-offset profitability gate: is `ofs` an offset the target can keep
+ * inline in a load/store (so folding it into an indirect whose base producer
+ * survives does not force an emit-time address build)? Conservatively keyed on
+ * the target's NATIVE_IMM_ADDR_OFFSET legality. With no target oracle, treat
+ * only a zero offset as safe. */
+static int combine_offset_fold_ok(NativeTarget* target, KitCgTypeId type,
+ i64 ofs) {
+ if (ofs == 0) return 1;
+ if (!target || !target->imm_legal) return 0;
+ return target->imm_legal(target, NATIVE_IMM_ADDR_OFFSET, 0, type, ofs);
+}
+
static int spill_local_slot(Func* f, const Operand* addr, const MemAccess* mem,
FrameSlot* out) {
if (!addr || addr->kind != OPK_LOCAL) return 0;
@@ -396,6 +408,7 @@ typedef struct AddrCseEntry {
typedef struct CombineCtx {
Func* f;
Block* bl;
+ NativeTarget* target; /* W6 immediate-legality oracle (may be NULL) */
const OptHardBlockLive* hard_live;
/* Index of the most recent inst in this BB that defined (cls, reg);
* -1 means no definition seen this BB. */
@@ -404,6 +417,15 @@ typedef struct CombineCtx {
/* W1a addr-of CSE ring (most-recent-wins). */
AddrCseEntry addr_cse[COMBINE_ADDR_CSE_SLOTS];
u32 addr_cse_next;
+ /* W6 cmp-immediate tracking: the constant currently held by each integer
+ * hard register, if known. const_valid is a per-RC_INT-reg bit set when a
+ * reaching IR_LOAD_IMM defined that register and nothing since has redefined
+ * or clobbered it (incl. call/asm/intrinsic clobbers, tracked precisely via
+ * opt_hard_inst_use_def). Lets a `cmp wN, wM` whose wM holds a small constant
+ * fold to `cmp wN, #k`, even across a call that preserves wM (a callee-saved
+ * reg). Integer-only: cmp immediates are integer. */
+ i64 const_val[OPT_MAX_HARD_REGS];
+ u32 const_valid;
int block_change_p;
} CombineCtx;
@@ -413,6 +435,7 @@ static void ctx_reset(CombineCtx* ctx) {
for (u32 k = 0; k < COMBINE_ADDR_CSE_SLOTS; ++k)
ctx->addr_cse[k].inst_idx = -1;
ctx->addr_cse_next = 0;
+ ctx->const_valid = 0;
ctx->block_change_p = 0;
}
@@ -1006,49 +1029,67 @@ static int try_addr_synth_one_op(CombineCtx* ctx, Inst* in, i32 i,
int killed = 0;
int uses_after = count_uses_in_live_range(ctx->f, ctx->bl, prod_idx,
&prod_def, &killed);
- if (uses_after == 1 &&
+ int single_use =
+ uses_after == 1 &&
(killed || !opt_block_live_out_has_phys_reg(
- ctx->f, ctx->hard_live, ctx->bl->id, &prod_def))) {
- Operand lhs = prod->opnds[1];
- Operand rhs = prod->opnds[2];
- int has_no_index = (op->v.ind.index == (Reg)REG_NONE);
- /* reg + reg: base = lhs, index = rhs, scale=0. Needs an empty
- * index slot — cannot stack two indices. */
- if (has_no_index && lhs.kind == OPK_REG && rhs.kind == OPK_REG &&
- lhs.cls == RC_INT && rhs.cls == RC_INT &&
- !producer_def_aliases_source(&prod_def, &lhs) &&
- !producer_def_aliases_source(&prod_def, &rhs) &&
- !ctx_def_changed_since(ctx, RC_INT, lhs.v.reg, prod_idx) &&
- !ctx_def_changed_since(ctx, RC_INT, rhs.v.reg, prod_idx)) {
+ ctx->f, ctx->hard_live, ctx->bl->id, &prod_def));
+ Operand lhs = prod->opnds[1];
+ Operand rhs = prod->opnds[2];
+ int has_no_index = (op->v.ind.index == (Reg)REG_NONE);
+ /* reg + reg: base = lhs, index = rhs, scale=0. Needs an empty index
+ * slot — cannot stack two indices. This synthesizes a new index, a
+ * structural change, so keep the single-use guard: with other live
+ * uses the add must stay, and we'd add an index without retiring it. */
+ if (single_use && has_no_index && lhs.kind == OPK_REG &&
+ rhs.kind == OPK_REG && lhs.cls == RC_INT && rhs.cls == RC_INT &&
+ !producer_def_aliases_source(&prod_def, &lhs) &&
+ !producer_def_aliases_source(&prod_def, &rhs) &&
+ !ctx_def_changed_since(ctx, RC_INT, lhs.v.reg, prod_idx) &&
+ !ctx_def_changed_since(ctx, RC_INT, rhs.v.reg, prod_idx)) {
+ op->v.ind.base = lhs.v.reg;
+ op->v.ind.index = rhs.v.reg;
+ op->v.ind.log2_scale = 0;
+ any = 1;
+ }
+ /* (W6) reg + imm: base = lhs, fold imm k into ofs (`add xN,xM,#k;
+ * ldr [xN,#j]` -> `ldr [xM,#k+j]`). This rewrites only this indirect's
+ * base+offset; the producer's def of xN is untouched and stays valid
+ * for any other uses, so the rewrite is value-correct regardless of use
+ * count. The aliasing guards (lhs unchanged since the add) still apply
+ * and are independent of use count.
+ *
+ * Use count gates *profitability*, not correctness: when the add is
+ * single-use it becomes dead (DCE retires it) and folding any in-range
+ * offset is a clear win. When the add survives (other live uses), we
+ * only fold an offset the target can keep inline in the access
+ * (combine_offset_fold_ok) — otherwise the surviving add plus an
+ * out-of-reach offset would cost an extra emit-time address build. */
+ else if (lhs.kind == OPK_REG && rhs.kind == OPK_IMM &&
+ lhs.cls == RC_INT &&
+ !producer_def_aliases_source(&prod_def, &lhs) &&
+ !ctx_def_changed_since(ctx, RC_INT, lhs.v.reg, prod_idx)) {
+ i64 sum = (i64)op->v.ind.ofs + rhs.v.imm;
+ if (sum >= INT32_MIN && sum <= INT32_MAX &&
+ (single_use ||
+ combine_offset_fold_ok(ctx->target, op->type, sum))) {
op->v.ind.base = lhs.v.reg;
- op->v.ind.index = rhs.v.reg;
- op->v.ind.log2_scale = 0;
+ op->v.ind.ofs = (i32)sum;
any = 1;
}
- /* reg + imm: base = lhs, fold imm into ofs. Safe with or without
- * an existing index — only the offset is mutated. */
- else if (lhs.kind == OPK_REG && rhs.kind == OPK_IMM &&
- lhs.cls == RC_INT &&
- !producer_def_aliases_source(&prod_def, &lhs) &&
- !ctx_def_changed_since(ctx, RC_INT, lhs.v.reg, prod_idx)) {
- i64 sum = (i64)op->v.ind.ofs + rhs.v.imm;
- if (sum >= INT32_MIN && sum <= INT32_MAX) {
- op->v.ind.base = lhs.v.reg;
- op->v.ind.ofs = (i32)sum;
- any = 1;
- }
- }
- /* imm + reg: base = rhs, fold imm into ofs (commutative IADD). */
- else if (lhs.kind == OPK_IMM && rhs.kind == OPK_REG &&
- rhs.cls == RC_INT &&
- !producer_def_aliases_source(&prod_def, &rhs) &&
- !ctx_def_changed_since(ctx, RC_INT, rhs.v.reg, prod_idx)) {
- i64 sum = (i64)op->v.ind.ofs + lhs.v.imm;
- if (sum >= INT32_MIN && sum <= INT32_MAX) {
- op->v.ind.base = rhs.v.reg;
- op->v.ind.ofs = (i32)sum;
- any = 1;
- }
+ }
+ /* (W6) imm + reg: base = rhs, fold imm into ofs (commutative IADD).
+ * Same reasoning as the reg+imm case — pure base/offset rewrite. */
+ else if (lhs.kind == OPK_IMM && rhs.kind == OPK_REG &&
+ rhs.cls == RC_INT &&
+ !producer_def_aliases_source(&prod_def, &rhs) &&
+ !ctx_def_changed_since(ctx, RC_INT, rhs.v.reg, prod_idx)) {
+ i64 sum = (i64)op->v.ind.ofs + lhs.v.imm;
+ if (sum >= INT32_MIN && sum <= INT32_MAX &&
+ (single_use ||
+ combine_offset_fold_ok(ctx->target, op->type, sum))) {
+ op->v.ind.base = rhs.v.reg;
+ op->v.ind.ofs = (i32)sum;
+ any = 1;
}
}
}
@@ -1436,15 +1477,92 @@ static int try_fold_const_convert(CombineCtx* ctx, Inst* in, i32 i) {
return 1;
}
+/* ---- Rewrite 7 (W6): cmp-immediate folding ----
+ *
+ * O1.md W6. A small constant that feeds a compare can be materialized into a
+ * register too late for the emitter's inline-immediate path — e.g. when the
+ * `load_imm` of the constant is separated from the `cmp` by a call barrier, the
+ * constant survives in a callee-saved register and the compare reads it as a
+ * register (`movz w20,#7 ; ... ; cmp w19,w20`). The emitter already routes the
+ * compare's immediate slot through `operand_imm_or_reg(..., NATIVE_IMM_CMP)`, so
+ * presenting the value as an OPK_IMM there collapses it to `cmp w19,#7`.
+ *
+ * We track, per integer hard register, the constant a reaching IR_LOAD_IMM put
+ * there (const_val / const_valid). On a cmp/cmp_branch we replace an immediate-
+ * slot register operand holding a target-legal constant with that immediate.
+ * Tracking is invalidated precisely (opt_hard_inst_use_def gives each inst's
+ * def + clobber set), so a constant in a callee-saved register survives calls
+ * that preserve it. Linear: O(1) bookkeeping per inst. */
+
+/* Record / invalidate the per-register known-constant state after visiting the
+ * inst at index `i`. Must run for EVERY inst (called from the forward driver),
+ * so the tracking stays sound across barriers and redefinitions. */
+static void ctx_track_const(CombineCtx* ctx, const Inst* in) {
+ OptHardRegSet use, def;
+ opt_hard_inst_use_def(ctx->f, in, &use, &def);
+ /* Any register this inst defines or clobbers no longer holds its old known
+ * constant. (RC_INT only — cmp immediates are integer.) */
+ ctx->const_valid &= ~def.cls[RC_INT];
+ /* A load_imm of an integer hard register records the new constant. */
+ if ((IROp)in->op == IR_LOAD_IMM && in->nopnds >= 1 &&
+ in->opnds[0].kind == OPK_REG && in->opnds[0].cls == RC_INT &&
+ in->opnds[0].v.reg < OPT_MAX_HARD_REGS) {
+ Reg r = in->opnds[0].v.reg;
+ ctx->const_val[r] = in->extra.imm;
+ ctx->const_valid |= 1u << r;
+ }
+}
+
+static int reg_known_const(const CombineCtx* ctx, const Operand* op, i64* out) {
+ if (!op || op->kind != OPK_REG || op->cls != RC_INT) return 0;
+ if (op->v.reg >= OPT_MAX_HARD_REGS) return 0;
+ if (!(ctx->const_valid & (1u << op->v.reg))) return 0;
+ *out = ctx->const_val[op->v.reg];
+ return 1;
+}
+
+/* Fold the immediate-slot operand of a cmp/cmp_branch from a register holding a
+ * known constant into an inline OPK_IMM, when the target can encode it. The
+ * immediate slot is opnds[2] for IR_CMP and opnds[1] for IR_CMP_BRANCH (opnds
+ * before it are the materialized LHS). */
+static int try_cmp_imm_fold(CombineCtx* ctx, Inst* in) {
+ if (!ctx->target || !ctx->target->imm_legal) return 0;
+ u32 slot;
+ if ((IROp)in->op == IR_CMP)
+ slot = 2u;
+ else if ((IROp)in->op == IR_CMP_BRANCH)
+ slot = 1u;
+ else
+ return 0;
+ if (slot >= in->nopnds) return 0;
+ Operand* op = &in->opnds[slot];
+ i64 k;
+ if (!reg_known_const(ctx, op, &k)) return 0;
+ u32 cmpop = (u32)in->extra.imm;
+ if (!ctx->target->imm_legal(ctx->target, NATIVE_IMM_CMP, cmpop, op->type, k))
+ return 0;
+ KitCgTypeId type = op->type;
+ u8 cls = op->cls;
+ memset(op, 0, sizeof *op);
+ op->kind = OPK_IMM;
+ op->cls = cls;
+ op->type = type;
+ op->v.imm = k;
+ ctx->block_change_p = 1;
+ return 1;
+}
+
/* ---- per-BB driver ---- */
static int opt_combine_fold_block(Func* f, Block* bl,
- const OptHardBlockLive* hard_live) {
+ const OptHardBlockLive* hard_live,
+ NativeTarget* target) {
enum { enable_o1_combine_rewrites = 1 };
enum { enable_o1_sink_rewrites = 1 };
CombineCtx ctx;
ctx.f = f;
ctx.bl = bl;
+ ctx.target = target;
ctx.hard_live = hard_live;
ctx_reset(&ctx);
@@ -1486,12 +1604,18 @@ static int opt_combine_fold_block(Func* f, Block* bl,
try_combine_exts(&ctx, in, i);
try_substitute(&ctx, in, i);
try_addr_synth(&ctx, in, i);
+ /* W6: fold a register holding a known constant into a compare's inline
+ * immediate slot (reads the constant tracker maintained below). */
+ try_cmp_imm_fold(&ctx, in);
}
/* Track this inst as an addr-of producer for later W1a CSE (only when it is
* still an addr-of: a prior rewrite may have turned it into a copy). */
addr_cse_record(&ctx, in, i);
ctx_record(&ctx, in, i);
+ /* W6: update the per-register known-constant tracker for this inst (after
+ * rewrites, so a load_imm produced/rewritten here is recorded). */
+ ctx_track_const(&ctx, in);
}
return ctx.block_change_p;
}
@@ -1548,10 +1672,136 @@ static int opt_combine_compact_block(Func* f, Block* bl) {
return changed;
}
-void opt_combine(Func* f) {
+/* ---- W8: same-block stack dead-store elimination ----
+ *
+ * O1.md W8. A forward MIR scan deletes a spill store when a later store in the
+ * same block fully overwrites the same stack slot before any possible read.
+ * Deliberately narrower than the parked O2 DSE: V1 handles exact direct frame
+ * spill stores only (IR_STORE of OPK_LOCAL(slot) with ALIAS_LOCAL == slot,
+ * !observable, no bit-field rider, exact same {slot,size,addr_space}).
+ *
+ * Fast design (no per-block hash table, no O(nslots) clear): dense per-FrameSlot
+ * side arrays plus a `gen` counter that is bumped at block start AND on any
+ * memory barrier instead of clearing the arrays. A slot's pending store is
+ * "live" iff seen_gen[slot] == gen. The correctness boundary is aliasing and
+ * partial overlap: any unknown memory op, call, asm, intrinsic, atomic,
+ * volatile, aggregate, or non-direct local access bumps gen (forgetting all
+ * pending stores in O(1)); a different size/addr-space/bit-field shape on the
+ * same slot clears just that slot; a direct read of the slot clears it. A
+ * deleted store is left as IR_NOP for the existing compaction + mir_dce. */
+
+typedef struct StackDseState {
+ u32* seen_gen; /* per slot (1-based): the gen at which last_store_idx was set */
+ i32* last_idx; /* per slot: index of the pending (not-yet-killed) store */
+ u32* last_size; /* per slot: byte size of the pending store */
+ u16* last_aspace; /* per slot: addr_space of the pending store */
+ u32 nslots; /* f->nframe_slots */
+ u32 gen;
+} StackDseState;
+
+/* A direct, DSE-eligible spill store: IR_STORE of a frame-local spill slot,
+ * not observable (volatile/atomic), aliasing exactly that local, no bit-field
+ * rider. Fills *slot. */
+static int dse_direct_spill_store(Func* f, const Inst* in, FrameSlot* slot) {
+ if (!store_spill_slot(f, in, slot)) return 0;
+ if (in->extra.mem.bf_width != 0) return 0; /* bit-field rider: bail */
+ return 1;
+}
+
+/* A direct read of a spill slot (the read side that must preserve a pending
+ * store). Mirrors load_spill_slot but also covers bit-field/atomic loads of the
+ * slot defensively; those route through the barrier path anyway, but a plain
+ * IR_LOAD of the slot is the common case to clear. */
+static int dse_direct_spill_load(Func* f, const Inst* in, FrameSlot* slot) {
+ return load_spill_slot(f, in, slot);
+}
+
+static void stack_dse_block(Func* f, Block* bl, StackDseState* st,
+ int* changed) {
+ if (st->nslots == 0) return;
+ ++st->gen; /* block start: forget all pending stores */
+ for (u32 i = 0; i < bl->ninsts; ++i) {
+ Inst* in = &bl->insts[i];
+ if ((IROp)in->op == IR_NOP) continue;
+
+ FrameSlot slot = FRAME_SLOT_NONE;
+
+ /* Accepted direct spill store: try to kill a prior pending store of the
+ * exact same key, then record this one. */
+ if (dse_direct_spill_store(f, in, &slot) && slot >= 1u &&
+ slot <= st->nslots) {
+ u32 sz = in->extra.mem.size;
+ u16 as = in->extra.mem.addr_space;
+ u32 si = slot - 1u;
+ if (st->seen_gen[si] == st->gen) {
+ if (st->last_size[si] == sz && st->last_aspace[si] == as) {
+ /* Same {slot,size,addr_space}: the prior store is fully overwritten
+ * before any read -> dead. NOP it; this store becomes the pending
+ * one. */
+ Inst* dead = &bl->insts[st->last_idx[si]];
+ dead->op = (u16)IR_NOP;
+ dead->def = VAL_NONE;
+ dead->ndefs = 0;
+ dead->defs = NULL;
+ dead->nopnds = 0;
+ dead->opnds = NULL;
+ *changed = 1;
+ }
+ /* else: different size/addr-space shape on this slot -> the entry is
+ * about to be replaced by this store's shape anyway; fall through. */
+ }
+ st->seen_gen[si] = st->gen;
+ st->last_idx[si] = (i32)i;
+ st->last_size[si] = sz;
+ st->last_aspace[si] = as;
+ continue;
+ }
+
+ /* Accepted direct spill load: clears the pending store for that slot (it is
+ * read, so not dead). */
+ if (dse_direct_spill_load(f, in, &slot) && slot >= 1u &&
+ slot <= st->nslots) {
+ u32 si = slot - 1u;
+ if (st->seen_gen[si] == st->gen) st->seen_gen[si] = st->gen - 1u;
+ continue;
+ }
+
+ /* A plain memory read that is NOT a direct spill load cannot alias any
+ * pending spill slot: FS_SPILL slots are allocator-private and never
+ * address-taken, so no computed pointer can reach them. Such a load (a
+ * struct field, a global, a heap deref) is therefore transparent to this
+ * spill-only DSE — do not bump gen for it. (A direct spill load was already
+ * handled above.) */
+ if (inst_reads_memory(in)) continue;
+
+ /* Any other memory writer is a full barrier: a non-spill / non-direct store
+ * (handled per-slot above only for direct spill stores), an aggregate op, a
+ * call/asm/intrinsic, or an atomic/volatile access. These could, in
+ * principle, touch state this narrow pass does not model, so forget every
+ * pending store in O(1) by bumping gen. (Plain register-only insts —
+ * binop/copy/convert/load_imm/etc. — are transparent.) */
+ if (inst_writes_memory(in)) ++st->gen;
+ }
+}
+
+void opt_combine(Func* f, NativeTarget* target) {
if (!f) return;
opt_analysis_invalidate(f, OPT_ANALYSIS_DEF_USE);
OptHardBlockLive* hard_live = opt_maybe_build_hard_live(f);
+
+ /* W8 side arrays, allocated once per function (frame slots are dense). */
+ StackDseState dse;
+ memset(&dse, 0, sizeof dse);
+ dse.nslots = f->nframe_slots;
+ if (dse.nslots) {
+ dse.seen_gen = arena_array(f->arena, u32, dse.nslots);
+ dse.last_idx = arena_array(f->arena, i32, dse.nslots);
+ dse.last_size = arena_array(f->arena, u32, dse.nslots);
+ dse.last_aspace = arena_array(f->arena, u16, dse.nslots);
+ memset(dse.seen_gen, 0, sizeof(u32) * dse.nslots);
+ dse.gen = 0;
+ }
+
/* Per-BB fixpoint, matching MIR's combine loop (mir-gen.c:9037-9038). */
/* Per-BB fixpoint with a defensive iteration cap. Each rewrite is supposed
* to be monotonic (it only fires when it strictly improves the IR), so we
@@ -1561,9 +1811,14 @@ void opt_combine(Func* f) {
for (u32 b = 0; b < f->nblocks; ++b) {
Block* bl = &f->blocks[b];
for (int iter = 0; iter < MAX_COMBINE_ITERS; ++iter) {
- int folded = opt_combine_fold_block(f, bl, hard_live);
+ int folded = opt_combine_fold_block(f, bl, hard_live, target);
+ /* W8 same-block stack DSE right before compaction; NOP'd stores are
+ * dropped by opt_combine_compact_block (and their dead source producers
+ * by the following mir_dce). */
+ int dse_changed = 0;
+ stack_dse_block(f, bl, &dse, &dse_changed);
int compacted = opt_combine_compact_block(f, bl);
- if (!folded && !compacted) break;
+ if (!folded && !dse_changed && !compacted) break;
}
}
}
diff --git a/src/opt/pass_mir.c b/src/opt/pass_mir.c
@@ -73,10 +73,10 @@ void opt_mir_verify(Func* f, const char* stage) {
}
}
-void opt_mir_combine(Func* f) {
+void opt_mir_combine(Func* f, NativeTarget* target) {
Func v;
if (!mir_view(f, &v)) return;
- opt_combine(&v);
+ opt_combine(&v, target);
mir_commit(f, &v);
}
diff --git a/test/opt/o1_cmp_imm.sh b/test/opt/o1_cmp_imm.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Structural checks for the -O1 W6 peephole tightening (O1.md W6), in
+# src/opt/pass_combine.c (mir_combine):
+#
+# A. cmp-immediate folding: a small constant that reaches a compare through a
+# register (because its load_imm was separated from the cmp by a call
+# barrier, so it sits in a callee-saved reg) folds into the cmp's inline
+# immediate. The constant tracker is invalidated precisely (only the regs a
+# call clobbers), so a constant in a callee-saved register survives the
+# call and the compare becomes `cmp wN,#k` with no `movz` of that constant.
+#
+# B. address-mode constant-add + offset fold: `add xN,xM,#k; ldr [xN,#j]`
+# collapses to `ldr [xM,#k+j]` so the explicit address-build add disappears
+# when it is otherwise dead.
+#
+# Both are local, target-agnostic, and keep -O1 linear. The checks pin the
+# resulting disassembly on aarch64 (the reference backend), where the patterns
+# have stable mnemonics.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+KIT="${KIT:-$ROOT/build/kit}"
+WORK="$ROOT/build/test/opt/o1_cmp_imm"
+mkdir -p "$WORK"
+
+SRC="$WORK/case.c"
+cat > "$SRC" <<'EOF'
+extern int side(void);
+/* A: a small constant K materialized before a call (it survives in a callee-
+ * saved register across the call), then compared. The constant must fold into
+ * the cmp's immediate -- `cmp wN,#42` -- with no surviving `movz wM,#42`. */
+int cmp_after_call(int x) {
+ const int K = 42;
+ side();
+ return x > K;
+}
+/* B: a base+constant add feeding a plain (no-index) load. The add should fold
+ * into the load offset so no `add xN,xM,#16` address build survives -- just a
+ * single `ldr xD,[xM,#...]`. */
+struct S { long a, b, c; };
+long off_fold(struct S *p) {
+ long *q = (long *)((char *)p + 8);
+ return *q;
+}
+EOF
+
+OBJ="$WORK/case.o"
+"$KIT" cc -target aarch64-linux-gnu -O1 -std=c11 -c "$SRC" \
+ -o "$OBJ" > "$WORK/cc.out" 2>&1
+"$KIT" objdump -d "$OBJ" > "$WORK/dis.out" 2>&1
+
+fn_body() { # $1 = symbol name -> stdout body
+ awk -v want="$1" '
+ $0 ~ ("^[0-9a-f]+ <" want ">:") { in_fn = 1; next }
+ /^[0-9a-f]+ </ { in_fn = 0 }
+ in_fn { print }
+ ' "$WORK/dis.out"
+}
+
+fail() {
+ printf 'o1_cmp_imm FAILED: %s\n' "$1" >&2
+ printf ' --- disassembly ---\n' >&2
+ sed 's/^/ | /' "$WORK/dis.out" >&2
+ exit 1
+}
+
+# A: cmp_after_call must compare against the immediate (`cmp wN, #42`) and must
+# NOT keep a `movz wM, #42` (the constant materialization the fold removes).
+A="$(fn_body _cmp_after_call)"
+[ -n "$A" ] || A="$(fn_body cmp_after_call)"
+[ -n "$A" ] || fail "cmp_after_call not found"
+printf '%s\n' "$A" | grep -Eq 'cmp[[:space:]]+w[0-9]+, #42' \
+ || fail "expected 'cmp wN, #42' (cmp-immediate fold did not fire)"
+if printf '%s\n' "$A" | grep -Eq 'movz[[:space:]]+w[0-9]+, 0x2a\b'; then
+ fail "constant 42 still materialized with movz (cmp-imm fold incomplete)"
+fi
+
+# B: off_fold must load with a single ldr and a folded offset; no `add xN,xM,#8`
+# address build should survive for the field offset.
+B="$(fn_body _off_fold)"
+[ -n "$B" ] || B="$(fn_body off_fold)"
+[ -n "$B" ] || fail "off_fold not found"
+printf '%s\n' "$B" | grep -Eq '\bldr\b' || fail "expected a ldr in off_fold"
+if printf '%s\n' "$B" | grep -Eq 'add[[:space:]]+x[0-9]+, x[0-9]+, #8\b'; then
+ fail "explicit 'add xN,xM,#8' address build survived (offset fold did not fire)"
+fi
+
+printf 'o1_cmp_imm: OK (cmp wN,#k fold across barrier; add+offset fold)\n'
diff --git a/test/opt/o1_stack_dse.sh b/test/opt/o1_stack_dse.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# Checks for the -O1 W8 same-block stack dead-store elimination (O1.md W8), in
+# src/opt/pass_combine.c (stack_dse_block).
+#
+# W8 deletes a spill store when a LATER store to the same spill slot fully
+# overwrites it in the same block before any possible read or barrier. It is
+# deliberately exact and spill-only; the correctness boundary is aliasing /
+# partial overlap, so the pass treats every uncertain case (a read of the slot,
+# a different size/addr-space, a call/asm/intrinsic/atomic/volatile, an
+# aggregate or non-direct access) as a barrier that preserves the prior store.
+#
+# Important: in kit's current -O1 pipeline this pass is correct but rarely finds
+# work -- upstream rematerialization (W2) + dead-definition-elimination + the
+# adjacent store/load coalesce already remove redundant spill traffic, so two
+# stores to one slot almost always have a reload or a barrier between them. This
+# guard therefore validates the two properties that matter for correctness:
+#
+# A. PRESERVATION: two stores to the same spill slot that are separated by a
+# call (a barrier) AND whose value is reloaded between them must keep BOTH
+# stores -- W8 must NOT delete a store that may be read.
+# B. RUNTIME CORRECTNESS: a high-register-pressure function (forcing real
+# spills) computes the identical, correct result at -O0 and -O1.
+#
+# (When a future pass exposes a barrier-free double store, W8 will retire the
+# first store; that path is exercised by the differential -O0/-O1 run here.)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+KIT="${KIT:-$ROOT/build/kit}"
+WORK="$ROOT/build/test/opt/o1_stack_dse"
+mkdir -p "$WORK"
+
+fail() {
+ printf 'o1_stack_dse FAILED: %s\n' "$1" >&2
+ shift || true
+ for f in "$@"; do
+ printf ' --- %s ---\n' "$f" >&2
+ sed 's/^/ | /' "$f" >&2
+ done
+ exit 1
+}
+
+# ---- A. preservation: barrier + reload between two stores keeps both ----
+SRC_A="$WORK/preserve.c"
+cat > "$SRC_A" <<'EOF'
+extern long c1(void);
+extern long c2(void);
+extern long c3(void);
+extern void use2(long, long, long, long, long, long);
+/* The call results are not rematerializable, so they spill across the later
+ * calls; each spilled value is stored, then reloaded (for use2 and the return),
+ * then its slot is reused -- two stores to a slot with a barrier+read between.
+ * W8 must keep both stores. */
+long preserve(void) {
+ long a = c1(), b = c2(), c = c3(), d = c1(), e = c2(), g = c3();
+ long h = c1(), i = c2(), j = c3(), k = c1(), l = c2(), m = c3();
+ use2(a, b, c, d, e, g);
+ use2(h, i, j, k, l, m);
+ return a + b + c + d + e + g + h + i + j + k + l + m;
+}
+EOF
+OBJ_A="$WORK/preserve.o"
+"$KIT" cc -target aarch64-linux-gnu -O1 -std=c11 -c "$SRC_A" \
+ -o "$OBJ_A" > "$WORK/preserve.cc.out" 2>&1
+"$KIT" objdump -d "$OBJ_A" > "$WORK/preserve.dis" 2>&1
+# There must be at least one spill store (str to a frame/stack base) AND the
+# function must still load values back (ldr) -- proving stores were not stripped.
+STR_N="$(grep -cE '\b(str|stur)\b' "$WORK/preserve.dis" || true)"
+LDR_N="$(grep -cE '\b(ldr|ldur|ldp)\b' "$WORK/preserve.dis" || true)"
+[ "$STR_N" -ge 1 ] || fail "expected spill stores in preserve()" "$WORK/preserve.dis"
+[ "$LDR_N" -ge 1 ] || fail "expected reloads in preserve()" "$WORK/preserve.dis"
+
+# ---- B. runtime correctness at -O0 vs -O1 under heavy spill pressure ----
+SRC_B="$WORK/run.c"
+cat > "$SRC_B" <<'EOF'
+/* Heavy live-range pressure: many simultaneously-live longs feeding several
+ * dependent expressions, forcing the allocator to spill and reload. The result
+ * is a fixed deterministic value; -O0 and -O1 must agree. */
+static long mix(long s) {
+ long a=s+1,b=s+2,c=s+3,d=s+4,e=s+5,g=s+6,h=s+7,i=s+8;
+ long j=s+9,k=s+10,m=s+11,n=s+12,o=s+13,p=s+14,q=s+15,r=s+16;
+ long t = a*b + c*d + e*g + h*i + j*k + m*n + o*p + q*r;
+ long u = a-b + c-d + e-g + h-i + j-k + m-n + o-p + q-r;
+ long v = a^b ^ c^d ^ e^g ^ h^i ^ j^k ^ m^n ^ o^p ^ q^r;
+ return t + u + v + a + r;
+}
+int main(void) {
+ long acc = 0;
+ for (long s = 0; s < 50; ++s) acc += mix(s);
+ /* Reduce to a small exit code so the JIT entry's return value is comparable. */
+ return (int)(acc % 251);
+}
+EOF
+run_rc() { # $1 = -O level -> echoes exit code
+ if "$KIT" run "$1" -e main "$SRC_B" > "$WORK/run.$1.out" 2>&1; then
+ echo 0
+ else
+ echo $?
+ fi
+}
+RC0="$(run_rc -O0)"
+RC1="$(run_rc -O1)"
+if [ "$RC0" != "$RC1" ]; then
+ fail "spill-heavy run mismatch: -O0=$RC0 -O1=$RC1" \
+ "$WORK/run.-O0.out" "$WORK/run.-O1.out"
+fi
+
+printf 'o1_stack_dse: OK (preservation across barrier+reload; -O0==-O1=%s on spill-heavy run)\n' "$RC1"