kit

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

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:
Mmk/test.mk | 15++++++++++++++-
Msrc/opt/opt.c | 2+-
Msrc/opt/opt.h | 6++++--
Msrc/opt/pass_combine.c | 341+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/opt/pass_mir.c | 4++--
Atest/opt/o1_cmp_imm.sh | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/opt/o1_stack_dse.sh | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"