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:
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'