kit

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

commit 6f4cd59a6b440abffd17633fbccf372962005789
parent 9a99a0aae81d593f98884d5b2a8a2b94b3e1aecf
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Mon, 15 Jun 2026 23:49:44 -0700

opt: linear move coalescing at O1 (O1.md W3)

Diffstat:
Mmk/test.mk | 8+++++++-
Msrc/opt/ir.h | 7+++++++
Msrc/opt/opt_internal.h | 4++++
Msrc/opt/pass_coalesce.c | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/opt/pass_combine.c | 3++-
Msrc/opt/pass_lower.c | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Atest/opt/o1_coalesce.sh | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 371 insertions(+), 11 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: 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 $(OPT_TEST_BIN) @@ -899,6 +899,12 @@ test-opt-o1-remat: bin test-opt-aa64-x29-bottom: bin @KIT=$(abspath $(BIN)) bash test/opt/aa64_x29_bottom.sh +# Behavioral disasm check: linear O1 move coalescing removes copy-chain movs and +# preserves the imm-through-copy fold (the explicit opt_o1_coalescing flag). +.PHONY: test-opt-o1-coalesce +test-opt-o1-coalesce: bin + @KIT=$(abspath $(BIN)) bash test/opt/o1_coalesce.sh + test-opt-tiny-inline: bin $(TINY_INLINE_TEST_BIN) $(TINY_INLINE_TEST_BIN) diff --git a/src/opt/ir.h b/src/opt/ir.h @@ -724,6 +724,13 @@ typedef struct Func { MFunc* mir; /* physical post-allocation IR; HIR stays virtual */ u32* opt_coalesce_parent; u32* opt_coalesce_size; + /* Set when the linear O1 move-coalescing pass (pass_coalesce.c, + * opt_coalesce_linear) has populated opt_coalesce_parent in the no-SSA O1 + * regalloc path. Distinguishes "O1 with a union-find" from the parked O2 + * matrix coalescer (which also sets opt_coalesce_parent): the O1 immediate + * fold-through-copy in pass_combine.c keys on this, not on the mere presence + * of opt_coalesce_parent. */ + int opt_o1_coalescing; OptUse* opt_uses; u32 opt_nuses, opt_uses_cap; diff --git a/src/opt/opt_internal.h b/src/opt/opt_internal.h @@ -184,6 +184,10 @@ OptHardRegSet opt_hard_live_out_for_block(const OptHardBlockLive*); int opt_block_live_out_has_phys_reg(Func*, const OptHardBlockLive*, u32 block, const Operand*); void opt_coalesce_ranges(Func*, const OptLiveRangeSet*); +/* Linear (no O(n^2) conflict matrix) move coalescer for the no-SSA O1 regalloc + * path. Populates f->opt_coalesce_parent before opt_assign_ranges and sets + * f->opt_o1_coalescing. */ +void opt_coalesce_linear(Func*, const OptLiveRangeSet*); /* Return 0 (no overlap), 1 (a single unit-length overlap), or 2 (real * conflict: an overlap longer than one point, or two or more disjoint * unit-length overlaps). A unit overlap is the safe swap-friendly case diff --git a/src/opt/pass_coalesce.c b/src/opt/pass_coalesce.c @@ -346,3 +346,209 @@ void opt_coalesce(Func* f) { opt_live_ranges_build(f, &live, &ranges); opt_coalesce_ranges(f, &ranges); } + +/* ---- Linear move coalescing for the no-SSA O1 regalloc path (O1.md W3) ---- + * + * The O2 coalescer above (opt_coalesce_ranges) builds an O(n^2) conflict matrix + * over the *related set* (every PReg touched by any move) and then merges. W3 + * needs the same union-find result for the O1 allocator without that matrix. + * + * We keep, per coalesce root: a member-PReg list (capped at K) and the + * aggregated register constraints. A merge tests the two roots with the proven + * raw-range overlap predicate (opt_ranges_overlap_kind) over the bounded K^2 + * member cross-product — exactly the rule the O2 group_conflicts uses: the only + * permitted overlap in the whole cross-product is the move's single unit + * overlap (dst defined where src dies); any other overlap (unit or wide) blocks + * the merge. Per-PReg ranges are NOT globally sorted (a non-SSA value redefined + * in a block produces overlapping sub-ranges), so a sorted-list sweep is wrong + * here; the nested raw-range test in opt_ranges_overlap_kind is order-agnostic + * and correct. With K bounded and per-PReg range counts bounded, each merge is + * O(1) and the pass is linear in moves. Classes that would exceed K simply stop + * coalescing further (the conservative fallback in O1.md W3) — correct, just + * leaving a few copies. No nrelated^2 work anywhere. + */ +#define LIN_COAL_MAX_MEMBERS 64u + +typedef struct LinCoalState { + PReg* members; /* per root: PReg members of the coalesce group (capped) */ + u32 nmembers; + u32 forbidden; /* OR of forbidden_hard_regs over the group */ + u32 allowed; /* intersection of nonzero allowed masks; 0 if none */ + u8 has_allowed; /* a member contributed a positive allowed mask */ + u8 init; /* state has been populated for this root */ + i32 tied; /* single tied hard reg, or -1 */ + u8 cls; + KitCgTypeId type; +} LinCoalState; + +/* Conflict test for merging roots ra/rb via move endpoints (md,ms). Returns 1 + * if any member pair (a in ra, b in rb) overlaps, except the single benign unit + * overlap of the move endpoints themselves. Mirrors O2 group_conflicts. */ +static int lin_group_conflicts(const OptLiveRangeSet* ranges, + const LinCoalState* sa, const LinCoalState* sb, + PReg md, PReg ms) { + for (u32 i = 0; i < sa->nmembers; ++i) { + PReg a = sa->members[i]; + for (u32 j = 0; j < sb->nmembers; ++j) { + PReg b = sb->members[j]; + int kind = opt_ranges_overlap_kind(ranges, a, b); + if (!kind) continue; + /* The one excusable overlap: the move's endpoints, unit-length only. */ + if (kind == 1 && ((a == md && b == ms) || (a == ms && b == md))) + continue; + return 1; + } + } + return 0; +} + +/* Constraint compatibility on the precomputed group masks (mirrors + * group_constraints_compatible's mask math, but incremental rather than + * re-scanning every PReg). Requires at least one plausible hard register so a + * merge does not over-constrain the class onto the stack. */ +static int lin_constraints_ok(Func* f, const LinCoalState* sa, + const LinCoalState* sb) { + if (sa->cls != sb->cls || sa->type != sb->type) return 0; + u32 forbidden = sa->forbidden | sb->forbidden; + u32 allowed = 0; + u8 has_allowed = 0; + if (sa->has_allowed && sb->has_allowed) { + allowed = sa->allowed & sb->allowed; + has_allowed = 1; + if (!allowed) return 0; + } else if (sa->has_allowed) { + allowed = sa->allowed; + has_allowed = 1; + } else if (sb->has_allowed) { + allowed = sb->allowed; + has_allowed = 1; + } + i32 tied = sa->tied; + if (sb->tied >= 0) { + if (tied >= 0 && tied != sb->tied) return 0; + tied = sb->tied; + } + if (tied >= 0 && tied < 32 && (forbidden & (1u << (Reg)tied))) return 0; + if (tied >= 0 && tied < 32 && has_allowed && + (allowed & (1u << (Reg)tied)) == 0) + return 0; + return hard_reg_possible(f, sa->cls, forbidden, has_allowed ? allowed : 0u); +} + +void opt_coalesce_linear(Func* f, const OptLiveRangeSet* ranges) { + if (!f || !ranges || !f->preg_info) return; + u32 nregs = opt_reg_count(f); + f->opt_coalesce_moves_seen = 0; + f->opt_coalesce_candidates = 0; + f->opt_coalesce_conflicts = 0; + f->opt_coalesce_merge_attempts = 0; + f->opt_coalesce_merges = 0; + f->opt_coalesce_parent = arena_array(f->arena, u32, nregs ? nregs : 1u); + f->opt_coalesce_size = arena_array(f->arena, u32, nregs ? nregs : 1u); + for (PReg v = 0; v < nregs; ++v) { + f->opt_coalesce_parent[v] = v; + f->opt_coalesce_size[v] = 1; + } + f->opt_o1_coalescing = 1; + + /* Collect eligible moves (same predicate the O2 path uses). */ + CoalesceMove* moves = NULL; + u32 nmoves = 0; + u32 move_cap = 0; + for (u32 b = 0; b < f->nblocks; ++b) { + Block* bl = &f->blocks[b]; + for (u32 i = 0; i < bl->ninsts; ++i) { + if ((IROp)bl->insts[i].op == IR_COPY) ++f->opt_coalesce_moves_seen; + CoalesceMove m; + if (!collect_move(f, ranges, &bl->insts[i], b, &m)) continue; + if (nmoves == move_cap) { + u32 ncap = move_cap ? move_cap * 2u : 32u; + CoalesceMove* nv = arena_array(f->arena, CoalesceMove, ncap); + if (moves) memcpy(nv, moves, sizeof(moves[0]) * nmoves); + moves = nv; + move_cap = ncap; + } + moves[nmoves++] = m; + } + } + f->opt_coalesce_candidates = nmoves; + if (!nmoves) goto metrics; + + /* Per-root state, indexed by PReg (root is its own index). Only roots that + * appear as a move endpoint are ever consulted, but a flat array keeps the + * union-find indexing trivial. */ + LinCoalState* st = arena_array(f->arena, LinCoalState, nregs ? nregs : 1u); + for (PReg v = 0; v < nregs; ++v) { + st[v].members = NULL; + st[v].nmembers = 0; + st[v].forbidden = 0; + st[v].allowed = 0; + st[v].has_allowed = 0; + st[v].init = 0; + st[v].tied = -1; + st[v].cls = 0; + st[v].type = 0; + } + for (u32 i = 0; i < nmoves; ++i) { + PReg ends[2] = {moves[i].dst, moves[i].src}; + for (int e = 0; e < 2; ++e) { + PReg v = ends[e]; + if (st[v].init) continue; + const OptPRegInfo* vi = &f->preg_info[v]; + st[v].init = 1; + st[v].members = arena_array(f->arena, PReg, LIN_COAL_MAX_MEMBERS); + st[v].members[0] = v; + st[v].nmembers = 1; + st[v].forbidden = vi->forbidden_hard_regs; + if (vi->allowed_hard_regs) { + st[v].allowed = vi->allowed_hard_regs; + st[v].has_allowed = 1; + } + st[v].tied = vi->tied_hard_reg; + st[v].cls = opt_reg_cls(f, v); + st[v].type = opt_reg_type(f, v); + } + } + + qsort(moves, nmoves, sizeof(moves[0]), move_cmp); + for (u32 i = 0; i < nmoves; ++i) { + PReg ra = coalesce_find(f, moves[i].dst); + PReg rb = coalesce_find(f, moves[i].src); + if (ra == rb) continue; + ++f->opt_coalesce_merge_attempts; + /* Conservative size cap (O1.md W3 fallback): stop growing a class past K. */ + if (st[ra].nmembers + st[rb].nmembers > LIN_COAL_MAX_MEMBERS) continue; + if (lin_group_conflicts(ranges, &st[ra], &st[rb], moves[i].dst, + moves[i].src)) { + ++f->opt_coalesce_conflicts; + continue; + } + if (!lin_constraints_ok(f, &st[ra], &st[rb])) continue; + coalesce_union(f, ra, rb); + PReg nr = coalesce_find(f, ra); + PReg orr = (nr == ra) ? rb : ra; + /* Fold `orr` group state into surviving root `nr`. */ + for (u32 m = 0; m < st[orr].nmembers; ++m) + st[nr].members[st[nr].nmembers++] = st[orr].members[m]; + st[nr].forbidden |= st[orr].forbidden; + if (st[orr].has_allowed) { + if (st[nr].has_allowed) + st[nr].allowed &= st[orr].allowed; + else { + st[nr].allowed = st[orr].allowed; + st[nr].has_allowed = 1; + } + } + if (st[orr].tied >= 0) st[nr].tied = st[orr].tied; + st[orr].nmembers = 0; /* folded into nr */ + ++f->opt_coalesce_merges; + } + +metrics: + metrics_count(f->c, "opt.coalesce.moves_seen", f->opt_coalesce_moves_seen); + metrics_count(f->c, "opt.coalesce.candidates", f->opt_coalesce_candidates); + metrics_count(f->c, "opt.coalesce.conflicts", f->opt_coalesce_conflicts); + metrics_count(f->c, "opt.coalesce.merge_attempts", + f->opt_coalesce_merge_attempts); + metrics_count(f->c, "opt.coalesce.merges", f->opt_coalesce_merges); +} diff --git a/src/opt/pass_combine.c b/src/opt/pass_combine.c @@ -904,7 +904,8 @@ static int try_substitute_for_reg(CombineCtx* ctx, Inst* in, i32 i, u8 cls, /* O1 (no coalescing) folds immediates into IR_COPY; O2 leaves the copy * register-to-register so coalescing + self-copy removal handles it. */ - int copy_imm_ok = ctx->f && !ctx->f->opt_coalesce_parent; + int copy_imm_ok = + ctx->f && (ctx->f->opt_o1_coalescing || !ctx->f->opt_coalesce_parent); int n = subst_consumer_operands(in, &def, &src_op, kind, copy_imm_ok); if (n > 0) { ctx->block_change_p = 1; diff --git a/src/opt/pass_lower.c b/src/opt/pass_lower.c @@ -609,6 +609,7 @@ typedef struct OptAllocGroupInfo { PReg root; u32 spill_cost; u32 live_length; + u32 live_across_call_freq; /* sum over group members (W3-coalesce aware) */ u32 first; u32 last; i32 tied_hard_reg; @@ -650,6 +651,14 @@ typedef struct OptAllocator { /* Scratch bitmap of loc_words. Reused per candidate. */ u64* conflict_locs; + /* Per-coalesce-root member lists, built once (O(nregs)) so the hint-path + * precise interference check can enumerate a candidate group's members in + * O(group size) instead of rescanning all PRegs. member_head[root] is the + * first member (or 0), member_next[m] links the rest (0 terminates). Indexed + * by PReg. NULL when there is no coalescing (single-member groups). */ + u32* member_head; + u32* member_next; + /* Metrics. */ u64 hard_point_visits; /* points scanned during hard-reg conflict probe */ u64 stack_point_visits; /* points scanned during stack-slot probe */ @@ -784,6 +793,7 @@ static void alloc_group_info(Func* f, const OptLiveRangeSet* ranges, PReg root, OptPRegInfo* vi = &f->preg_info[v]; out->spill_cost += vi->frequency ? vi->frequency : vi->spill_cost; out->live_length += vi->live_length; + out->live_across_call_freq += vi->live_across_call_freq; u32 first = vi->first_pos ? vi->first_pos - 1u : 0; if (first < out->first) out->first = first; if (vi->last_pos > out->last) out->last = vi->last_pos; @@ -1100,6 +1110,21 @@ static void opt_assign_ranges(Func* f, const OptLiveRangeSet* ranges, } alloc_sort_candidates(cands, n); + /* Build per-root member lists once (only meaningful with W3 coalescing). The + * hint-path precise interference check uses these to enumerate a candidate + * group's members cheaply instead of rescanning all PRegs per probe. */ + if (f->opt_coalesce_parent) { + u32 nr = opt_reg_count(f); + a->member_head = arena_zarray(f->arena, u32, nr ? nr : 1u); + a->member_next = arena_zarray(f->arena, u32, nr ? nr : 1u); + for (PReg v = 1; v < nr; ++v) { + if (ranges->first_range_by_preg[v] == OPT_RANGE_NONE) continue; + PReg root = alloc_coalesce_root(f, v); + a->member_next[v] = a->member_head[root]; + a->member_head[root] = v; + } + } + for (u32 i = 0; i < n; ++i) { PReg v = cands[i].v; OptAllocGroupInfo gi = cands[i].gi; @@ -1194,7 +1219,7 @@ static void opt_assign_ranges(Func* f, const OptLiveRangeSet* ranges, * below would otherwise take the hint reg regardless of score, parking a * cross-call value in x0 where it collides with the next call's result. */ if (vi->preferred_hard_reg >= 0 && - !(vi->live_across_call_freq && + !(gi.live_across_call_freq && is_caller_saved(f, cls, (Reg)vi->preferred_hard_reg))) { Reg hint = (Reg)vi->preferred_hard_reg; int already_tried = 0; @@ -1215,17 +1240,32 @@ static void opt_assign_ranges(Func* f, const OptLiveRangeSet* ranges, * call's result occupies x0 and the sub both reads it and writes * the new value. Fall back to a precise per-PReg interference check * that allows the unit-length overlap (same rule used by - * opt_coalesce_ranges for moves). */ + * opt_coalesce_ranges for moves). The check must be group-aware on + * both sides: with W3 coalescing the candidate root v stands for a + * whole coalesce group, and the conflict may involve a non-root + * member m (a value coalesced into v) overlapping an already-assigned + * PReg u. Checking only v would miss it and place two raw-overlapping + * groups in the same hint register. */ if (!hint_safe) { int real_conflict = 0; - for (PReg u = 1; u < opt_reg_count(f); ++u) { - if (u == v) continue; + for (PReg u = 1; u < opt_reg_count(f) && !real_conflict; ++u) { const OptPRegInfo* ui = &f->preg_info[u]; if (ui->alloc_kind != OPT_ALLOC_HARD) continue; if (ui->hard_reg != hint) continue; - if (opt_ranges_overlap_kind(ranges, u, v) >= 2) { + if (alloc_group_member(f, v, u)) continue; /* same group: benign */ + /* Group-aware: the conflict may be between u and a non-root + * member m coalesced into v (W3). Enumerate v's members via the + * precomputed list (O(group size)); fall back to the root alone + * when there is no coalescing. */ + if (a->member_head) { + for (u32 m = a->member_head[v]; m; m = a->member_next[m]) { + if (opt_ranges_overlap_kind(ranges, u, (PReg)m) >= 2) { + real_conflict = 1; + break; + } + } + } else if (opt_ranges_overlap_kind(ranges, u, v) >= 2) { real_conflict = 1; - break; } } if (!real_conflict) hint_safe = 1; @@ -1996,6 +2036,11 @@ static void opt_verify_alloc(Func* f, const OptLiveInfo* live) { for (PReg p = 1; p < nregs; ++p) { u8 p_kind; if (!cur[p] || p == d) continue; + /* W3: PRegs in the same coalesce root deliberately share one location + * (the allocator assigns per root). They are one value, not an + * interference — the move-coalescer only merges roots whose live + * ranges do not truly overlap, so the shared location is sound. */ + if (alloc_coalesce_root(f, p) == alloc_coalesce_root(f, d)) continue; p_kind = opt_preg_alloc_kind(f, p); if (p_kind == OPT_ALLOC_HARD && d_kind == OPT_ALLOC_HARD && opt_preg_loc_cls(f, p) == opt_preg_loc_cls(f, d) && @@ -2046,9 +2091,11 @@ static void opt_regalloc_place(Func* f, OptLiveInfo* live_out) { for (PReg v = 1; v < opt_reg_count(f); ++v) f->preg_info[v].forbidden_hard_regs |= f->preg_info[v].clobbered_hard_regs; - /* This O1 point-bitmap allocator does not coalesce or split live ranges; it - * emits copies through the natural conflict-free path. MIR coalesces at -O2 - * (mir-gen.c). */ + /* Linear move coalescing (O1.md W3): populate the union-find that + * alloc_coalesce_root/alloc_group_member consult so the allocator merges + * copy-related values onto one location. No O(n^2) conflict matrix — see + * opt_coalesce_linear. opt_verify_alloc treats same-root PRegs as one value. */ + opt_coalesce_linear(f, &ranges); metrics_count(f->c, "opt.live_words", f->opt_live_words); metrics_count(f->c, "opt.ranges", ranges.nranges); metrics_count(f->c, "opt.range_points", ranges.point_count); diff --git a/test/opt/o1_coalesce.sh b/test/opt/o1_coalesce.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Structural checks for -O1 linear move coalescing (doc/plan/O1.md W3). +# +# A. copy_chain: a `b = a; c = b; d = c;` copy chain whose values feed a +# computation. The per-block combiner (mir_combine) leaves these as +# register-to-register moves; the linear move coalescer merges the +# copy-related values onto one register, so the `mov wD, wS` copies +# disappear. Baseline (no coalescing) emits three; coalesced emits none. +# +# B. imm_fold: `int k = 1234; int t = k; return a + t;` -- O1 may still fold a +# materialized immediate through a copy into its consumer (`add w0,w0,#1234`) +# even with the union-find now populated. Regression guard for the +# pass_combine.c proxy fix: coalescing keys on f->opt_o1_coalescing, not on +# the mere presence of f->opt_coalesce_parent, so enabling W3 must NOT +# silently disable the O1 immediate fold (which would re-introduce a +# separate movz of the constant). +# +# Pinned on aarch64 (the reference backend) where the mnemonics are stable. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +KIT="${KIT:-$ROOT/build/kit}" +WORK="$ROOT/build/test/opt/o1_coalesce" +mkdir -p "$WORK" + +SRC="$WORK/case.c" +cat > "$SRC" <<'EOF' +/* A: a copy chain b=a; c=b; d=c whose values flow through several uses, so the + * frontend records IR_COPY moves the per-block combiner cannot retire. */ +int copies(int a) { + int b = a; + int c = b; + int d = c; + int r = b; + r = r + c; + r = r + d; + r = r + b * c; + return r; +} +/* B: an immediate assigned, copied, then used -- the O1 immediate-through-copy + * fold must survive the W3 union-find (no separate movz of 1234). */ +int imm(int a) { + int k = 1234; + int t = k; + return a + t; +} +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_coalesce FAILED: %s\n' "$1" >&2 + printf ' --- disassembly ---\n' >&2 + sed 's/^/ | /' "$WORK/dis.out" >&2 + exit 1 +} + +# A: the copy chain must leave no register-to-register `mov wD, wS` in `copies`. +A="$(fn_body copies)" +[ -n "$A" ] || A="$(fn_body _copies)" +[ -n "$A" ] || fail "copies not found in disassembly" +movs="$(printf '%s\n' "$A" | grep -cE 'mov[[:space:]]+w[0-9]+, w[0-9]+' || true)" +if [ "$movs" -ne 0 ]; then + fail "copies still has $movs reg-to-reg mov(s) (coalescing did not fire)" +fi + +# B: the immediate fold must survive -- expect an `add wD, wS, #1234` with the +# constant folded into the use, and NO movz/mov materializing 1234 separately. +B="$(fn_body imm)" +[ -n "$B" ] || B="$(fn_body _imm)" +[ -n "$B" ] || fail "imm not found in disassembly" +printf '%s\n' "$B" | grep -Eq 'add[[:space:]]+w[0-9]+, w[0-9]+, #1234' \ + || fail "imm: immediate 1234 not folded into the add (O1 imm fold disabled)" +if printf '%s\n' "$B" | grep -Eq '(movz|movn|mov)[[:space:]]+w[0-9]+, #?(0x)?(1234|0x4d2)'; then + fail "imm: a separate materialization of 1234 survived (proxy fix regressed)" +fi + +printf 'o1_coalesce: OK (copy chain coalesced, imm-through-copy fold intact)\n'