Skip to content

Commit fdd3c7c

Browse files
author
metrics
committed
fix: prevent hoisting of Assign instructions from inner loops in LICM
1 parent 9622cb5 commit fdd3c7c

1 file changed

Lines changed: 117 additions & 3 deletions

File tree

  • crates/herkos-core/src/optimizer

crates/herkos-core/src/optimizer/licm.rs

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
//! the source without going through the header
1212
//! 4. For each loop, identify invariant instructions in the header (fixpoint):
1313
//! - `Const` — trivially invariant
14-
//! - `BinOp`, `UnOp`, `Assign`, `Select` — invariant if all operands are
14+
//! - `BinOp`, `UnOp`, `Select` — invariant if all operands are
1515
//! defined outside the loop or by other invariant instructions
16-
//! - Skip: `Load`, `Store`, `Call*`, `Global*`, `Memory*`
16+
//! - Skip: `Assign`, `Load`, `Store`, `Call*`, `Global*`, `Memory*`
1717
//! 5. Create or reuse a preheader block and move invariant instructions there
1818
//!
1919
//! **V1 simplification:** only hoists from the loop header block (which
@@ -92,17 +92,25 @@ fn find_natural_loops(
9292
back_edges: &[(BlockId, BlockId)],
9393
preds: &HashMap<BlockId, HashSet<BlockId>>,
9494
) -> Vec<(BlockId, HashSet<BlockId>)> {
95+
// Map from loop header → set of all blocks in that loop.
9596
let mut loops: HashMap<BlockId, HashSet<BlockId>> = HashMap::new();
9697

9798
for &(src, header) in back_edges {
99+
// Seed the loop with its header. Multiple back edges to the same header
100+
// share one entry, so their bodies are merged into a single loop.
98101
let loop_blocks = loops.entry(header).or_insert_with(|| {
99102
let mut set = HashSet::new();
100103
set.insert(header);
101104
set
102105
});
103106

107+
// Walk backwards from `src` through predecessors, collecting every block
108+
// that can reach `src` without leaving the loop. The header acts as the
109+
// boundary: it is already in the set, so `insert` returns false and we
110+
// stop propagating past it.
104111
let mut worklist = vec![src];
105112
while let Some(n) = worklist.pop() {
113+
// `insert` returns true only for newly-seen blocks, avoiding cycles.
106114
if loop_blocks.insert(n) {
107115
if let Some(n_preds) = preds.get(&n) {
108116
for &p in n_preds {
@@ -129,7 +137,6 @@ fn is_licm_hoistable(instr: &IrInstr) -> bool {
129137
IrInstr::Const { .. }
130138
| IrInstr::BinOp { .. }
131139
| IrInstr::UnOp { .. }
132-
| IrInstr::Assign { .. }
133140
| IrInstr::Select { .. }
134141
)
135142
}
@@ -1252,4 +1259,111 @@ mod tests {
12521259
HashSet::from([BlockId(1), BlockId(2), BlockId(3)])
12531260
);
12541261
}
1262+
1263+
// ── Assign (counter reset) must NOT be hoisted from inner loop ────────
1264+
//
1265+
// Regression test for: `IrInstr::Assign` appearing in `is_licm_hoistable`
1266+
// caused inner-loop counter resets to be hoisted to the outer preheader,
1267+
// executing only once per function call rather than once per outer iteration.
1268+
// This produced an effectively infinite loop in nested-loop programs such as
1269+
// a bubble sort (fill_sort_sum benchmark).
1270+
//
1271+
// CFG (outer ← B0→B1 back-edge; inner ← B3→B2 back-edge):
1272+
//
1273+
// B0 (outer-preheader): Jump → B1
1274+
// B1 (outer-header): BranchIf(cond_outer, B2, B5)
1275+
// B2 (inner-header): v_init = Const(0)
1276+
// j = Assign(v_init) ← counter reset
1277+
// BranchIf(cond_inner, B3, B4)
1278+
// B3 (inner-body): Jump → B2 ← inner back-edge
1279+
// B4 (inner-exit): Jump → B1 ← outer back-edge
1280+
// B5 (outer-exit): Return
1281+
//
1282+
// Before the fix: LICM on the inner loop (header = B2) would see v_init as a
1283+
// Const (trivially invariant) and then, via the fixpoint, classify
1284+
// `j = Assign(v_init)` as invariant too (because v_init is in
1285+
// invariant_dests). Both get hoisted to the outer preheader B0. On every
1286+
// outer iteration B2's instructions are already gone, so `j` is never
1287+
// reset — the inner loop runs with a stale, ever-growing `j`, hanging.
1288+
//
1289+
// After the fix: Assign is excluded from is_licm_hoistable, so only v_init
1290+
// is hoisted (to B0), while `j = Assign(v_init)` stays in B2 and resets the
1291+
// counter correctly on every outer iteration.
1292+
#[test]
1293+
fn assign_counter_reset_not_hoisted_from_inner_loop() {
1294+
// Variable map:
1295+
// VarId(0) = cond_outer (defined externally, used in B1)
1296+
// VarId(1) = cond_inner (defined externally, used in B2)
1297+
// VarId(2) = v_init (Const 0, defined in inner header B2)
1298+
// VarId(3) = j (Assign(v_init), inner loop counter, in B2)
1299+
let mut func = make_func(vec![
1300+
// B0 outer preheader
1301+
IrBlock {
1302+
id: BlockId(0),
1303+
instructions: vec![],
1304+
terminator: IrTerminator::Jump { target: BlockId(1) },
1305+
},
1306+
// B1 outer header
1307+
IrBlock {
1308+
id: BlockId(1),
1309+
instructions: vec![],
1310+
terminator: IrTerminator::BranchIf {
1311+
condition: VarId(0),
1312+
if_true: BlockId(2),
1313+
if_false: BlockId(5),
1314+
},
1315+
},
1316+
// B2 inner header: v_init = Const(0); j = Assign(v_init)
1317+
IrBlock {
1318+
id: BlockId(2),
1319+
instructions: vec![
1320+
IrInstr::Const {
1321+
dest: VarId(2),
1322+
value: IrValue::I32(0),
1323+
},
1324+
IrInstr::Assign {
1325+
dest: VarId(3),
1326+
src: VarId(2),
1327+
},
1328+
],
1329+
terminator: IrTerminator::BranchIf {
1330+
condition: VarId(1),
1331+
if_true: BlockId(3),
1332+
if_false: BlockId(4),
1333+
},
1334+
},
1335+
// B3 inner body → back-edge to inner header B2
1336+
IrBlock {
1337+
id: BlockId(3),
1338+
instructions: vec![],
1339+
terminator: IrTerminator::Jump { target: BlockId(2) },
1340+
},
1341+
// B4 inner exit → back-edge to outer header B1
1342+
IrBlock {
1343+
id: BlockId(4),
1344+
instructions: vec![],
1345+
terminator: IrTerminator::Jump { target: BlockId(1) },
1346+
},
1347+
// B5 outer exit
1348+
IrBlock {
1349+
id: BlockId(5),
1350+
instructions: vec![],
1351+
terminator: IrTerminator::Return { value: None },
1352+
},
1353+
]);
1354+
1355+
eliminate(&mut func);
1356+
1357+
// The Assign (j = Assign(v_init)) MUST remain in the inner header B2.
1358+
// If it were hoisted to the outer preheader the counter reset would only
1359+
// happen once per function call, not once per outer iteration.
1360+
let inner_header = func.blocks.iter().find(|b| b.id == BlockId(2)).unwrap();
1361+
assert!(
1362+
inner_header
1363+
.instructions
1364+
.iter()
1365+
.any(|i| matches!(i, IrInstr::Assign { dest: VarId(3), .. })),
1366+
"j = Assign(v_init) must stay in the inner header B2, not be hoisted"
1367+
);
1368+
}
12551369
}

0 commit comments

Comments
 (0)