Skip to main content

shape_jit/
loop_analysis.rs

1//! Loop Analysis for JIT Optimization
2//!
3//! Analyzes bytecode loops to identify:
4//! - Loop-invariant locals (for LICM — Loop-Invariant Code Motion)
5//! - Induction variables (for bounds check hoisting and strength reduction)
6//! - Simple loops amenable to unrolling
7//!
8//! This analysis runs before code generation and produces LoopInfo
9//! structs that the compiler consults during IR emission.
10
11use std::collections::{HashMap, HashSet};
12
13use cranelift::prelude::IntCC;
14use shape_vm::bytecode::{BytecodeProgram, Instruction, OpCode, Operand};
15
16/// Information about a single loop in the bytecode.
17#[derive(Debug, Clone)]
18pub struct LoopInfo {
19    /// Bytecode index of LoopStart
20    pub header_idx: usize,
21    /// Bytecode index of LoopEnd
22    pub end_idx: usize,
23    /// Local variables written inside the loop body
24    pub body_locals_written: HashSet<u16>,
25    /// Local variables read inside the loop body
26    pub body_locals_read: HashSet<u16>,
27    /// Module bindings written inside the loop body
28    pub body_module_bindings_written: HashSet<u16>,
29    /// Module bindings read inside the loop body
30    pub body_module_bindings_read: HashSet<u16>,
31    /// Identified induction variables
32    pub induction_vars: Vec<InductionVar>,
33    /// Locals that are loop-invariant (read but not written in loop body)
34    pub invariant_locals: HashSet<u16>,
35    /// Module bindings that are loop-invariant
36    pub invariant_module_bindings: HashSet<u16>,
37    /// Whether the loop body contains opcodes that may trigger heap allocation.
38    /// When false, the GC safepoint poll at the loop header can be skipped,
39    /// eliminating a load + compare + branch per iteration (~3 cycles saved).
40    pub body_can_allocate: bool,
41    /// Bytecode indices of calls that the LICM pass identified as hoistable.
42    /// Populated by the optimizer's LICM analysis after loop detection.
43    /// The translator consults this to emit hoisted calls in the loop pre-header.
44    pub hoistable_calls: Vec<usize>,
45}
46
47/// An induction variable: a local or module binding that follows the pattern
48/// `var = var + step` each iteration.
49#[derive(Debug, Clone)]
50pub struct InductionVar {
51    /// The local variable slot used as induction variable
52    pub local_slot: u16,
53    /// Whether this is a module binding (true) or local variable (false)
54    pub is_module_binding: bool,
55    /// Comparison condition used in loop test
56    pub bound_cmp: IntCC,
57    /// The local slot that the induction var is compared against (bound)
58    pub bound_slot: Option<u16>,
59    /// The constant step value (e.g. 1 for `i = i + 1`), if detected
60    pub step_value: Option<i64>,
61}
62
63/// Analyze all loops in a bytecode program.
64///
65/// Scans for LoopStart/LoopEnd pairs and collects read/write sets,
66/// induction variables, and invariant locals for each loop.
67pub fn analyze_loops(program: &BytecodeProgram) -> HashMap<usize, LoopInfo> {
68    let mut result = HashMap::new();
69
70    // First pass: find LoopStart/LoopEnd pairs
71    let mut loop_starts: Vec<usize> = Vec::new();
72    let mut loop_pairs: Vec<(usize, usize)> = Vec::new();
73
74    for (i, instr) in program.instructions.iter().enumerate() {
75        match instr.opcode {
76            OpCode::LoopStart => loop_starts.push(i),
77            OpCode::LoopEnd => {
78                if let Some(start) = loop_starts.pop() {
79                    loop_pairs.push((start, i));
80                }
81            }
82            _ => {}
83        }
84    }
85
86    // Second pass: analyze each loop
87    for (start_idx, end_idx) in loop_pairs {
88        // Check if any opcode in the loop body can trigger allocation
89        let body_can_allocate = program.instructions[start_idx + 1..end_idx]
90            .iter()
91            .any(|instr| !opcode_is_non_allocating(instr.opcode));
92
93        let mut info = LoopInfo {
94            header_idx: start_idx,
95            end_idx,
96            body_locals_written: HashSet::new(),
97            body_locals_read: HashSet::new(),
98            body_module_bindings_written: HashSet::new(),
99            body_module_bindings_read: HashSet::new(),
100            induction_vars: Vec::new(),
101            invariant_locals: HashSet::new(),
102            invariant_module_bindings: HashSet::new(),
103            hoistable_calls: Vec::new(),
104            body_can_allocate,
105        };
106
107        // Scan loop body for local and module binding reads and writes.
108        // For an outer loop, ignore instructions inside nested loops so we don't
109        // accidentally classify inner-loop locals/IVs as outer-loop candidates.
110        let mut nested_depth = 0usize;
111        for i in (start_idx + 1)..end_idx {
112            let instr = &program.instructions[i];
113            match instr.opcode {
114                OpCode::LoopStart => {
115                    nested_depth += 1;
116                    continue;
117                }
118                OpCode::LoopEnd => {
119                    nested_depth = nested_depth.saturating_sub(1);
120                    continue;
121                }
122                _ => {}
123            }
124            if nested_depth > 0 {
125                continue;
126            }
127            match instr.opcode {
128                OpCode::StoreLocal => {
129                    if let Some(Operand::Local(idx)) = &instr.operand {
130                        info.body_locals_written.insert(*idx);
131                    }
132                }
133                OpCode::StoreLocalTyped => {
134                    if let Some(Operand::TypedLocal(idx, _)) = &instr.operand {
135                        info.body_locals_written.insert(*idx);
136                    }
137                }
138                OpCode::LoadLocal | OpCode::LoadLocalTrusted => {
139                    if let Some(Operand::Local(idx)) = &instr.operand {
140                        info.body_locals_read.insert(*idx);
141                    }
142                }
143                OpCode::StoreModuleBinding => {
144                    if let Some(Operand::ModuleBinding(idx)) = &instr.operand {
145                        info.body_module_bindings_written.insert(*idx);
146                    }
147                }
148                OpCode::LoadModuleBinding => {
149                    if let Some(Operand::ModuleBinding(idx)) = &instr.operand {
150                        info.body_module_bindings_read.insert(*idx);
151                    }
152                }
153                _ => {}
154            }
155        }
156
157        // Invariant locals: read but not written
158        for &local in &info.body_locals_read {
159            if !info.body_locals_written.contains(&local) {
160                info.invariant_locals.insert(local);
161            }
162        }
163
164        // Invariant module bindings: read but not written
165        for &mb in &info.body_module_bindings_read {
166            if !info.body_module_bindings_written.contains(&mb) {
167                info.invariant_module_bindings.insert(mb);
168            }
169        }
170
171        // Detect induction variables:
172        // Pattern: LoadLocal(X) → PushConst(1) → AddInt → StoreLocal(X)
173        // This is the canonical `x = x + 1` pattern
174        detect_induction_vars(
175            &program.instructions,
176            start_idx,
177            end_idx,
178            &mut info,
179            &program.constants,
180        );
181
182        result.insert(start_idx, info);
183    }
184
185    result
186}
187
188/// Detect induction variable patterns in a loop body.
189///
190/// Looks for patterns like:
191///   LoadLocal(X), PushConst(step), AddInt/SubInt, StoreLocal(X)
192///   LoadModuleBinding(X), PushConst(step), Add, StoreModuleBinding(X)
193///
194/// Also detects the bound comparison:
195///   LoadLocal(X), LoadLocal(Y), LtInt/GtInt/etc. → JumpIfFalse
196fn detect_induction_vars(
197    instrs: &[Instruction],
198    start_idx: usize,
199    end_idx: usize,
200    info: &mut LoopInfo,
201    constants: &[shape_vm::bytecode::Constant],
202) {
203    // Precompute loop nesting depth for each instruction in this loop body.
204    // Depth 0 = instruction belongs to this loop directly (not a nested loop).
205    let mut depth_by_idx = vec![0usize; instrs.len()];
206    let mut depth = 0usize;
207    for i in (start_idx + 1)..end_idx {
208        depth_by_idx[i] = depth;
209        match instrs[i].opcode {
210            OpCode::LoopStart => depth += 1,
211            OpCode::LoopEnd => depth = depth.saturating_sub(1),
212            _ => {}
213        }
214    }
215
216    // Look for increment patterns at depth 0 only.
217    for i in (start_idx + 1)..end_idx.saturating_sub(3) {
218        if depth_by_idx[i] != 0
219            || depth_by_idx[i + 1] != 0
220            || depth_by_idx[i + 2] != 0
221            || depth_by_idx[i + 3] != 0
222        {
223            continue;
224        }
225        let (load, step_src, arith, store) =
226            (&instrs[i], &instrs[i + 1], &instrs[i + 2], &instrs[i + 3]);
227
228        let is_arith = matches!(
229            arith.opcode,
230            OpCode::AddInt | OpCode::SubInt
231        );
232        let is_supported_step_src = matches!(
233            step_src.opcode,
234            OpCode::PushConst
235                | OpCode::LoadLocal
236                | OpCode::LoadLocalTrusted
237                | OpCode::LoadModuleBinding
238        );
239        if !is_arith || !is_supported_step_src {
240            continue;
241        }
242
243        // Extract constant step value when available; variable-step IVs are
244        // represented with `None` and validated later in bounds analysis.
245        let step_value = if step_src.opcode == OpCode::PushConst {
246            if let Some(Operand::Const(const_idx)) = &step_src.operand {
247                match constants.get(*const_idx as usize) {
248                    Some(shape_vm::bytecode::Constant::Int(n)) => {
249                        let step = if matches!(arith.opcode, OpCode::SubInt) {
250                            -n
251                        } else {
252                            *n
253                        };
254                        Some(step)
255                    }
256                    Some(shape_vm::bytecode::Constant::UInt(n)) => {
257                        let step = *n as i64;
258                        let step = if matches!(arith.opcode, OpCode::SubInt) {
259                            -step
260                        } else {
261                            step
262                        };
263                        Some(step)
264                    }
265                    Some(shape_vm::bytecode::Constant::Number(n)) if *n == (*n as i64) as f64 => {
266                        let step = if matches!(arith.opcode, OpCode::SubInt) {
267                            -(*n as i64)
268                        } else {
269                            *n as i64
270                        };
271                        Some(step)
272                    }
273                    _ => None,
274                }
275            } else {
276                None
277            }
278        } else {
279            None
280        };
281
282        // Check: LoadLocal(X) ... Add/AddInt ... StoreLocal(X)
283        if matches!(load.opcode, OpCode::LoadLocal | OpCode::LoadLocalTrusted)
284            && matches!(store.opcode, OpCode::StoreLocal | OpCode::StoreLocalTyped)
285        {
286            let store_local_idx = match &store.operand {
287                Some(Operand::Local(idx)) => Some(*idx),
288                Some(Operand::TypedLocal(idx, _)) => Some(*idx),
289                _ => None,
290            };
291            if let (Some(Operand::Local(load_idx)), Some(store_idx)) =
292                (&load.operand, store_local_idx)
293            {
294                if *load_idx == store_idx {
295                    let bound_info =
296                        detect_bound_comparison(instrs, start_idx, end_idx, *load_idx, false);
297                    if bound_info.1.is_none() {
298                        continue;
299                    }
300
301                    info.induction_vars.push(InductionVar {
302                        local_slot: *load_idx,
303                        is_module_binding: false,
304                        bound_cmp: bound_info.0,
305                        bound_slot: bound_info.1,
306                        step_value,
307                    });
308                }
309            }
310        }
311
312        // Check: LoadModuleBinding(X) ... Add/AddInt ... StoreModuleBinding(X)
313        if load.opcode == OpCode::LoadModuleBinding && store.opcode == OpCode::StoreModuleBinding {
314            if let (
315                Some(Operand::ModuleBinding(load_idx)),
316                Some(Operand::ModuleBinding(store_idx)),
317            ) = (&load.operand, &store.operand)
318            {
319                if load_idx == store_idx {
320                    let bound_info =
321                        detect_bound_comparison(instrs, start_idx, end_idx, *load_idx, true);
322                    if bound_info.1.is_none() {
323                        continue;
324                    }
325
326                    info.induction_vars.push(InductionVar {
327                        local_slot: *load_idx,
328                        is_module_binding: true,
329                        bound_cmp: bound_info.0,
330                        bound_slot: bound_info.1,
331                        step_value,
332                    });
333                }
334            }
335        }
336    }
337}
338
339/// Detect the bound comparison for an induction variable.
340///
341/// Looks for: Load(indvar), Load(bound), Lt/Gt/etc. → JumpIfFalse
342/// Handles both LoadLocal and LoadModuleBinding patterns.
343fn detect_bound_comparison(
344    instrs: &[Instruction],
345    start_idx: usize,
346    end_idx: usize,
347    indvar_slot: u16,
348    is_module_binding: bool,
349) -> (IntCC, Option<u16>) {
350    let (load_op, bound_load_op) = if is_module_binding {
351        (OpCode::LoadModuleBinding, OpCode::LoadModuleBinding)
352    } else {
353        (OpCode::LoadLocal, OpCode::LoadLocal)
354    };
355
356    // Scan the first few instructions after LoopStart for the comparison
357    let scan_end = (start_idx + 10).min(end_idx);
358    for window in instrs[start_idx + 1..scan_end].windows(3) {
359        let (load1, load2, cmp) = (&window[0], &window[1], &window[2]);
360
361        let load1_matches = load1.opcode == load_op
362            || (!is_module_binding && load1.opcode == OpCode::LoadLocalTrusted);
363        let load2_matches = load2.opcode == bound_load_op
364            || (!is_module_binding && load2.opcode == OpCode::LoadLocalTrusted);
365        if load1_matches && load2_matches {
366            let l1 = match &load1.operand {
367                Some(Operand::Local(idx)) if !is_module_binding => Some(*idx),
368                Some(Operand::ModuleBinding(idx)) if is_module_binding => Some(*idx),
369                _ => None,
370            };
371            let l2 = match &load2.operand {
372                Some(Operand::Local(idx)) if !is_module_binding => Some(*idx),
373                Some(Operand::ModuleBinding(idx)) if is_module_binding => Some(*idx),
374                _ => None,
375            };
376
377            if let (Some(l1), Some(l2)) = (l1, l2) {
378                if l1 == indvar_slot {
379                    let cc = match cmp.opcode {
380                        OpCode::LtInt => IntCC::SignedLessThan,
381                        OpCode::LteInt => IntCC::SignedLessThanOrEqual,
382                        OpCode::GtInt => IntCC::SignedGreaterThan,
383                        OpCode::GteInt => IntCC::SignedGreaterThanOrEqual,
384                        _ => continue,
385                    };
386                    return (cc, Some(l2));
387                }
388            }
389        }
390    }
391
392    (IntCC::SignedLessThan, None) // Default
393}
394
395/// Returns true if the opcode is definitively non-allocating.
396///
397/// Non-allocating opcodes never trigger heap allocation, so loops containing
398/// only these opcodes don't need GC safepoint polling at the loop header.
399///
400/// Conservative: returns false for any opcode that might allocate, including
401/// function calls, array/object creation, and operations with known allocating
402/// FFI slow paths.
403///
404/// Note: generic numeric arithmetic/comparison opcodes are non-allocating in the
405/// current JIT lowering (they do not dispatch through allocating string/object
406/// paths), so they are treated as safe here.
407fn opcode_is_non_allocating(opcode: OpCode) -> bool {
408    matches!(
409        opcode,
410        // Stack manipulation (pure register/variable ops)
411        OpCode::PushConst
412            | OpCode::PushNull
413            | OpCode::Pop
414            | OpCode::Dup
415            | OpCode::Swap
416            // Typed arithmetic (inline f64/i64 ops, no FFI)
417            | OpCode::AddInt
418            | OpCode::SubInt
419            | OpCode::MulInt
420            | OpCode::DivInt
421            | OpCode::ModInt
422            | OpCode::AddNumber
423            | OpCode::SubNumber
424            | OpCode::MulNumber
425            | OpCode::DivNumber
426            | OpCode::ModNumber
427            | OpCode::AddDecimal
428            | OpCode::SubDecimal
429            | OpCode::MulDecimal
430            | OpCode::DivDecimal
431            | OpCode::ModDecimal
432            | OpCode::NegInt
433            | OpCode::NegNumber
434            // Typed comparisons (inline fcmp/icmp, no FFI)
435            | OpCode::GtInt
436            | OpCode::LtInt
437            | OpCode::GteInt
438            | OpCode::LteInt
439            | OpCode::GtNumber
440            | OpCode::LtNumber
441            | OpCode::GteNumber
442            | OpCode::LteNumber
443            | OpCode::EqInt
444            | OpCode::EqNumber
445            | OpCode::NeqInt
446            | OpCode::NeqNumber
447            | OpCode::EqString
448            | OpCode::GtString
449            | OpCode::LtString
450            | OpCode::GteString
451            | OpCode::LteString
452            | OpCode::EqDecimal
453            | OpCode::IsNull
454            | OpCode::GtDecimal
455            | OpCode::LtDecimal
456            | OpCode::GteDecimal
457            | OpCode::LteDecimal
458            // Logical (inline)
459            | OpCode::And
460            | OpCode::Or
461            | OpCode::Not
462            // Variable access (Cranelift Variables / memory loads, no allocation)
463            | OpCode::LoadLocal
464            | OpCode::LoadLocalTrusted
465            | OpCode::StoreLocal
466            | OpCode::StoreLocalTyped
467            | OpCode::LoadModuleBinding
468            | OpCode::StoreModuleBinding
469            | OpCode::StoreModuleBindingTyped
470            | OpCode::LoadClosure
471            | OpCode::StoreClosure
472            // Track A.1B mutable-cell capture ops. OwnedMutable is a
473            // single Box-pointer deref (no allocation). Shared takes the
474            // parking_lot mutex, which does not allocate — the uncontended
475            // fast path is a single atomic compare-exchange. Neither op
476            // enters the GC safepoint poll path. A.1D / A.1E provide the
477            // Cranelift lowering; until then these opcodes bail to the
478            // interpreter outside hot loops, but they are semantically
479            // non-allocating so they belong on the whitelist.
480            | OpCode::LoadOwnedMutableCapture
481            | OpCode::StoreOwnedMutableCapture
482            | OpCode::LoadSharedCapture
483            | OpCode::StoreSharedCapture
484            // Track A.1C.1 outer-scope Shared-cell ops. AllocSharedLocal
485            // does allocate (single `Arc::new` for a `parking_lot::Mutex`
486            // wrapper); that allocation is an ordinary refcounted heap
487            // object and does not enter the GC safepoint poll path, so
488            // treating it as non-allocation-sensitive is consistent with
489            // the A.1B Shared-capture ops above. LoadSharedLocal /
490            // StoreSharedLocal are pure mutex-guarded reads/writes;
491            // DropSharedLocal decrements a single Arc strong count. All
492            // four bail to the interpreter outside of hot loops via the
493            // JIT preflight gate.
494            | OpCode::AllocSharedLocal
495            | OpCode::LoadSharedLocal
496            | OpCode::StoreSharedLocal
497            | OpCode::DropSharedLocal
498            // Track A.1C.3 outer-scope Shared module-binding ops.
499            // Parallel to `AllocSharedLocal` / `LoadSharedLocal` /
500            // `StoreSharedLocal` but addressed by module-binding index.
501            // Same allocation / locking semantics — bail to interpreter
502            // until A.1D/A.1E ship a JIT lowering.
503            | OpCode::AllocSharedModuleBinding
504            | OpCode::LoadSharedModuleBinding
505            | OpCode::StoreSharedModuleBinding
506            // Type casting (inline, no allocation)
507            | OpCode::CastWidth
508            // Control flow (inline jumps/branches)
509            | OpCode::Jump
510            | OpCode::JumpIfFalse
511            | OpCode::JumpIfFalseTrusted
512            | OpCode::JumpIfTrue
513            | OpCode::LoopStart
514            | OpCode::LoopEnd
515            | OpCode::Break
516            | OpCode::Continue
517            | OpCode::Return
518            | OpCode::ReturnValue
519            // Bitwise (inline i64 ops)
520            | OpCode::BitAnd
521            | OpCode::BitOr
522            | OpCode::BitXor
523            | OpCode::BitShl
524            | OpCode::BitShr
525            | OpCode::BitNot
526            // Type coercion (inline, no allocation)
527            | OpCode::IntToNumber
528            | OpCode::NumberToInt
529            // Typed object field access (reads/writes existing object memory)
530            | OpCode::GetFieldTyped
531            | OpCode::SetFieldTyped
532            // Reference ops (access existing memory, no allocation)
533            | OpCode::MakeRef
534            | OpCode::DerefLoad
535            | OpCode::DerefStore
536            | OpCode::SetIndexRef
537            // In-place array index writes (no growth path in these opcodes)
538            | OpCode::GetProp
539            | OpCode::SetLocalIndex
540            | OpCode::SetModuleBindingIndex
541            | OpCode::Length
542            // No-ops in JIT
543            | OpCode::Halt
544            | OpCode::Nop
545            | OpCode::PushTimeframe
546            | OpCode::PopTimeframe
547            | OpCode::WrapTypeAnnotation
548            | OpCode::BindSchema
549            | OpCode::ErrorContext
550            | OpCode::CloseUpvalue
551            // Async/task no-ops in JIT
552            | OpCode::Yield
553            | OpCode::Suspend
554            | OpCode::Resume
555            | OpCode::Poll
556            | OpCode::AwaitBar
557            | OpCode::AwaitTick
558            | OpCode::Await
559            // Drop/box no-ops in JIT
560            | OpCode::DropCall
561            | OpCode::DropCallAsync
562            // Event/async scope no-ops in JIT
563            | OpCode::EmitAlert
564            | OpCode::EmitEvent
565            | OpCode::AsyncScopeEnter
566            | OpCode::AsyncScopeExit
567            // Trait object no-ops in JIT
568            | OpCode::BoxTraitObject
569            | OpCode::DynMethodCall
570    )
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use shape_vm::bytecode::*;
577
578    fn make_instr(opcode: OpCode, operand: Option<Operand>) -> Instruction {
579        Instruction { opcode, operand }
580    }
581
582    fn make_program(instrs: Vec<Instruction>, constants: Vec<Constant>) -> BytecodeProgram {
583        BytecodeProgram {
584            instructions: instrs,
585            constants,
586            strings: vec![],
587            functions: vec![],
588            debug_info: DebugInfo::default(),
589            data_schema: None,
590            module_binding_names: vec![],
591            top_level_locals_count: 0,
592            top_level_local_storage_hints: vec![],
593            type_schema_registry: Default::default(),
594            module_binding_storage_hints: vec![],
595            function_local_storage_hints: vec![],
596            compiled_annotations: Default::default(),
597            trait_method_symbols: Default::default(),
598            expanded_function_defs: Default::default(),
599            string_index: Default::default(),
600            foreign_functions: Vec::new(),
601            native_struct_layouts: vec![],
602            content_addressed: None,
603            function_blob_hashes: vec![],
604            top_level_frame: None,
605            ..Default::default()
606        }
607    }
608
609    #[test]
610    fn test_detect_invariant_locals() {
611        // Simulate: loop { x = x + y } where y is invariant
612        let instrs = vec![
613            make_instr(OpCode::LoopStart, None),
614            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))), // load x
615            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))), // load y (invariant)
616            make_instr(OpCode::AddInt, None),
617            make_instr(OpCode::StoreLocal, Some(Operand::Local(0))), // store x
618            make_instr(OpCode::LoopEnd, None),
619        ];
620
621        let loops = analyze_loops(&make_program(instrs, vec![]));
622        assert_eq!(loops.len(), 1);
623
624        let info = loops.get(&0).unwrap();
625        assert!(info.body_locals_written.contains(&0)); // x is written
626        assert!(!info.body_locals_written.contains(&1)); // y is NOT written
627        assert!(info.invariant_locals.contains(&1)); // y is invariant
628        assert!(!info.invariant_locals.contains(&0)); // x is NOT invariant
629    }
630
631    #[test]
632    fn test_detect_induction_variable() {
633        // Simulate: for (i = 0; i < n; i++) { ... }
634        let instrs = vec![
635            make_instr(OpCode::LoopStart, None),
636            // Loop condition: i < n
637            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))), // load i
638            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))), // load n
639            make_instr(OpCode::LtInt, None),
640            make_instr(OpCode::JumpIfFalse, Some(Operand::Offset(5))),
641            // Loop body (empty for this test)
642            // Increment: i = i + 1
643            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))), // load i
644            make_instr(OpCode::PushConst, Some(Operand::Const(0))), // push 1
645            make_instr(OpCode::AddInt, None),
646            make_instr(OpCode::StoreLocal, Some(Operand::Local(0))), // store i
647            make_instr(OpCode::LoopEnd, None),
648        ];
649
650        let loops = analyze_loops(&make_program(instrs, vec![Constant::Int(1)]));
651        let info = loops.get(&0).unwrap();
652
653        assert_eq!(info.induction_vars.len(), 1);
654        assert_eq!(info.induction_vars[0].local_slot, 0);
655        assert_eq!(info.induction_vars[0].bound_slot, Some(1));
656        assert_eq!(info.induction_vars[0].bound_cmp, IntCC::SignedLessThan);
657    }
658
659    #[test]
660    fn test_non_allocating_loop() {
661        // Pure arithmetic loop: s = s + i; i = i + 1
662        // Should NOT need GC safepoint
663        let instrs = vec![
664            make_instr(OpCode::LoopStart, None),
665            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))),
666            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))),
667            make_instr(OpCode::LtInt, None),
668            make_instr(OpCode::JumpIfFalse, Some(Operand::Offset(7))),
669            make_instr(OpCode::LoadLocal, Some(Operand::Local(2))),
670            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))),
671            make_instr(OpCode::AddInt, None),
672            make_instr(OpCode::StoreLocal, Some(Operand::Local(2))),
673            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))),
674            make_instr(OpCode::PushConst, Some(Operand::Const(0))),
675            make_instr(OpCode::AddInt, None),
676            make_instr(OpCode::StoreLocal, Some(Operand::Local(0))),
677            make_instr(OpCode::LoopEnd, None),
678        ];
679
680        let loops = analyze_loops(&make_program(instrs, vec![Constant::Int(1)]));
681        let info = loops.get(&0).unwrap();
682        assert!(
683            !info.body_can_allocate,
684            "Pure arithmetic loop should not need GC safepoint"
685        );
686    }
687
688    #[test]
689    fn test_allocating_loop() {
690        // Loop with array push: arr.push(i)
691        // SHOULD need GC safepoint (ArrayPush may reallocate)
692        let instrs = vec![
693            make_instr(OpCode::LoopStart, None),
694            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))),
695            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))),
696            make_instr(OpCode::ArrayPush, None),
697            make_instr(OpCode::LoopEnd, None),
698        ];
699
700        let loops = analyze_loops(&make_program(instrs, vec![]));
701        let info = loops.get(&0).unwrap();
702        assert!(
703            info.body_can_allocate,
704            "Loop with ArrayPush should need GC safepoint"
705        );
706    }
707
708    #[test]
709    fn test_loop_with_function_call_allocates() {
710        // Loop with function call: f(x)
711        // SHOULD need GC safepoint (calls can allocate anything)
712        let instrs = vec![
713            make_instr(OpCode::LoopStart, None),
714            make_instr(OpCode::LoadLocal, Some(Operand::Local(0))),
715            make_instr(
716                OpCode::Call,
717                Some(Operand::Function(shape_value::FunctionId(0))),
718            ),
719            make_instr(OpCode::Pop, None),
720            make_instr(OpCode::LoopEnd, None),
721        ];
722
723        let loops = analyze_loops(&make_program(instrs, vec![]));
724        let info = loops.get(&0).unwrap();
725        assert!(
726            info.body_can_allocate,
727            "Loop with Call should need GC safepoint"
728        );
729    }
730
731    #[test]
732    fn test_loop_with_set_index_ref_is_non_allocating() {
733        // In-place array mutation through references should not force
734        // per-iteration safepoint checks.
735        let instrs = vec![
736            make_instr(OpCode::MakeRef, Some(Operand::Local(0))),
737            make_instr(OpCode::StoreLocal, Some(Operand::Local(3))), // r = &arr
738            make_instr(OpCode::LoopStart, None),
739            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))), // i
740            make_instr(OpCode::LoadLocal, Some(Operand::Local(2))), // n
741            make_instr(OpCode::LtInt, None),
742            make_instr(OpCode::JumpIfFalse, Some(Operand::Offset(8))),
743            make_instr(OpCode::PushConst, Some(Operand::Const(0))), // value
744            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))), // i
745            make_instr(OpCode::SetIndexRef, Some(Operand::Local(3))),
746            make_instr(OpCode::LoadLocal, Some(Operand::Local(1))),
747            make_instr(OpCode::PushConst, Some(Operand::Const(1))),
748            make_instr(OpCode::AddInt, None),
749            make_instr(OpCode::StoreLocal, Some(Operand::Local(1))),
750            make_instr(OpCode::LoopEnd, None),
751        ];
752
753        let loops = analyze_loops(&make_program(
754            instrs,
755            vec![Constant::Bool(false), Constant::Int(1)],
756        ));
757        let info = loops.get(&2).unwrap();
758        assert!(
759            !info.body_can_allocate,
760            "Loop with SetIndexRef should be treated as non-allocating"
761        );
762    }
763}