commit 0cac2e5ced0729fe4cfe3359319142e8358a615e
parent 99899453fcf7fad2a7ba0eed9e341c78298e86cf
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 16:50:32 -0700
perf(cg): -O0 switch-selector residency + indirect-branch reorder (§4.6)
Diffstat:
1 file changed, 52 insertions(+), 3 deletions(-)
diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c
@@ -1334,9 +1334,52 @@ static void nd_cmp_branch(CgTarget* t, CmpOp op, Operand a, Operand b,
nd_release_materialized(d, ar);
}
+/* Cmp-and-branch chain with the selector pinned in ONE register across every
+ * case. This emits the same shape as cg_lower_switch_default (one CMP_EQ per
+ * case against cases[i].value at selector_type, then a jump to the default if
+ * any), but materializes the selector ONCE — before the flush — instead of
+ * letting each per-case cmp_branch reload it from its home (the flush empties
+ * the cache, so the shared lowering would re-materialize + re-flush per case).
+ *
+ * Soundness: after the initial flush the cache is empty. The chain falls through
+ * case-to-case with that same empty state — sel is a pinned scratch reg, not a
+ * cache entry, so nothing is re-cached between cases. Each case body and the
+ * default are reached through nd_label_place / nd_jump, which flush an
+ * already-empty cache. So the cache is empty along every out-edge: merge-correct
+ * exactly as the shared lowering is. The selector's value survives the flush in
+ * its pinned register (a spill store does not clobber it). Other backends and
+ * opt's IR replay still use cg_lower_switch_default unchanged. */
static void nd_switch(CgTarget* t, const CGSwitchDesc* desc) {
- nd_flush_all(nd_of(t));
- cg_lower_switch_default(t, desc);
+ NativeDirectTarget* d = nd_of(t);
+ NativeLoc sel;
+ if (desc->ncases == 0) {
+ nd_flush_all(d);
+ if (desc->default_label != LABEL_NONE) {
+ ND_REQUIRE_NATIVE(d, jump, "target does not emit jumps");
+ d->native->jump(d->native, nd_mc_label(d, desc->default_label));
+ }
+ return;
+ }
+ sel = nd_materialize_operand(d, desc->selector);
+ nd_flush_all(d);
+ ND_REQUIRE_NATIVE(d, cmp_branch, "target does not emit compare branches");
+ for (u32 i = 0; i < desc->ncases; ++i) {
+ /* Same per-case immediate cg_lower_switch_default builds with api_op_imm:
+ * cases[i].value interpreted at selector_type. Constructed inline (the imm
+ * helper is not declared in this TU). */
+ Operand imm = {.kind = OPK_IMM,
+ .type = desc->selector_type,
+ .v.imm = (i64)desc->cases[i].value};
+ NativeLoc br = nd_rhs_imm_or_reg(d, NATIVE_IMM_CMP, (u32)CMP_EQ, imm);
+ d->native->cmp_branch(d->native, CMP_EQ, sel, br,
+ nd_mc_label(d, desc->cases[i].label));
+ nd_release_materialized(d, br);
+ }
+ if (desc->default_label != LABEL_NONE) {
+ ND_REQUIRE_NATIVE(d, jump, "target does not emit jumps");
+ d->native->jump(d->native, nd_mc_label(d, desc->default_label));
+ }
+ nd_release_materialized(d, sel);
}
static void nd_indirect_branch(CgTarget* t, Operand addr,
@@ -1344,8 +1387,14 @@ static void nd_indirect_branch(CgTarget* t, Operand addr,
NativeDirectTarget* d = nd_of(t);
MCLabel* native_targets;
NativeLoc addr_reg;
- nd_flush_all(d);
+ /* Materialize the target address BEFORE the flush (mirrors nd_cmp_branch): a
+ * cache-resident address is read from its register instead of being spilled
+ * by the flush and immediately reloaded. The reg stays pinned across the
+ * flush — its value survives (a spill store does not clobber it) — and the
+ * post-branch cache is still empty, so merge correctness is unchanged. */
addr_reg = nd_materialize_operand(d, addr);
+ nd_drop_killed_operand(d, addr);
+ nd_flush_all(d);
ND_REQUIRE_NATIVE(d, indirect_branch,
"target does not emit indirect branches");
native_targets =