logicaffeine_forge/regalloc.rs
1//! Contiguous register-allocated region codegen — the EXODIA 3.1 "closer"
2//! foundation (WS-G).
3//!
4//! Where [`crate::jit::compile_straightline_coded`] lowers ONE stencil PIECE
5//! per [`MicroOp`] (each piece re-establishing operands from the frame, doing
6//! its op, storing the result, and tail-calling the next), this backend emits
7//! the WHOLE supported region as ONE contiguous x86-64 function. A global
8//! register assignment keeps the hottest slots resident in physical registers
9//! across every op, so a reg-resident operand is read with a register move (or
10//! used directly) rather than a frame round-trip — eliminating the per-piece
11//! ABI/operand overhead that holds the tiered cluster at 3-6× V8.
12//!
13//! ## Soundness contract (the differential gate is sacred)
14//!
15//! The emitted function is BIT-IDENTICAL to [`crate::jit::reference_eval`] and
16//! thus to the tree-walker:
17//! - i64 wrapping arithmetic (x86 `add`/`sub`/`imul` wrap natively);
18//! - the kernel's exact shift spec (count truncates to the low 6 bits via `cl`);
19//! - `Div`/`Mod` side-EXIT on a zero divisor BEFORE any effect (a
20//! `ChainOutcome::Deopt` through the shared status cell, replayed on
21//! bytecode where the kernel raises the precise error), and reproduce
22//! `MIN / -1 = MIN` / `MIN % -1 = 0` without the `#DE` overflow trap.
23//!
24//! On exit (every `Return`) ALL reg-resident slots are flushed back to the
25//! frame, so the frame the caller observes is consistent with the tree-walker's
26//! full frame state — the region tier can resume / deopt from frame slots.
27//!
28//! ## Assignment model
29//!
30//! A slot is either RESIDENT (lives in one fixed physical register for the
31//! whole function) or SPILLED (lives in its frame slot). Slots are ranked by
32//! reference count; the top ones that fit get the allocatable callee/caller
33//! saved GPRs. This is a *global* per-slot assignment (no per-point churn), so
34//! it is trivially correct across loops and back-edges: a slot is always in the
35//! same place. `rax`/`rdx`/`rcx` are reserved scratch (arithmetic, division,
36//! shift count); `r15` holds the frame base pointer.
37
38#![cfg(target_arch = "x86_64")]
39
40use std::sync::atomic::AtomicI64;
41
42use crate::buffer::JitChain;
43use crate::jit::{Cmp, CompiledChain, MicroOp, Slot};
44use crate::x64asm::{Asm, Cond, LabelId, Reg, Xmm};
45
46/// Where a slot's value lives for the whole function.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48enum Loc {
49 /// Resident in a physical general-purpose register (an INT-class slot).
50 Reg(Reg),
51 /// Resident in a physical XMM register (a FLOAT-class slot). The slot's f64
52 /// value lives here for the whole function; never simultaneously GP-resident.
53 Xmm(Xmm),
54 /// Spilled to `frame[slot]` (byte displacement = `slot * 8`). Both classes
55 /// load/store the SAME 8 raw bytes here, so a slot used by both an int and a
56 /// float op (or whose class is ambiguous) is kept in the frame, where bits
57 /// are reinterpreted freely.
58 Frame,
59}
60
61/// The frame base register (`*mut i64`, the chain ABI's first argument).
62const BASE: Reg = Reg::R15;
63/// Primary arithmetic scratch / division quotient / return value.
64const S0: Reg = Reg::Rax;
65/// Secondary arithmetic scratch / division remainder.
66const S1: Reg = Reg::Rdx;
67/// Shift-count scratch (the only register `shl`/`sar` by `cl` can use).
68const SC: Reg = Reg::Rcx;
69
70/// Primary XMM scratch (float arithmetic accumulator / load target).
71const FS0: Xmm = Xmm::Xmm14;
72/// Secondary XMM scratch (float second operand / abs-mask staging).
73const FS1: Xmm = Xmm::Xmm15;
74
75/// The BYTE offset of the `capacity` and `len` fields WITHIN a `Vec<T>`,
76/// discovered by PROBING the running binary's actual `Vec` layout — NEVER a
77/// hardcoded assumption.
78///
79/// `Vec<T>`'s field order (`ptr`/`cap`/`len`) is an implementation detail the
80/// Rust language does NOT guarantee (it differs across `std` builds — on the
81/// current toolchain the order is `cap, ptr, len`). The inline [`ArrPush`] fast
82/// path needs `cap` (the realloc test) and must keep the real `Vec::len` field
83/// coherent (so a later helper push / drop / deopt sees the appended element), so
84/// it reads/writes those two fields at these offsets. Probing the LIVE layout —
85/// constructing a real `Vec` and matching each word against the known
86/// `capacity()`/`len()` — makes the inline path bit-identical to `Vec::push` on
87/// EXACTLY this binary, with no cross-version layout assumption. The probe is
88/// computed once. The `ptr` field is never read inline (the mirrored frame
89/// `ptr_slot` is the source of truth and never moves on the fast path), so only
90/// these two offsets are needed.
91///
92/// The offsets are T-INDEPENDENT: `Vec<T>` is `RawVec<T> { ptr: NonNull<T>, cap }`
93/// + `len`, all word-sized regardless of `T`, so one `Vec<i64>` probe serves the
94/// Int / Float / Bool element buffers alike.
95struct VecLayout {
96 cap_off: i32,
97 len_off: i32,
98}
99
100fn vec_layout() -> &'static VecLayout {
101 use std::sync::OnceLock;
102 static LAYOUT: OnceLock<VecLayout> = OnceLock::new();
103 LAYOUT.get_or_init(|| {
104 // Distinct, recognizable cap and len so each maps to exactly one word.
105 let mut v: Vec<i64> = Vec::with_capacity(8);
106 // SAFETY: capacity is 8 ≥ 3, so setting len to 3 is in-bounds; the
107 // backing memory is uninitialized but never read (we forget `v`).
108 unsafe { v.set_len(3) };
109 let cap = v.capacity(); // 8
110 let len = v.len(); // 3
111 debug_assert_eq!(std::mem::size_of::<Vec<i64>>(), 24, "Vec is a 3-word triple");
112 let base = &v as *const Vec<i64> as *const usize;
113 // SAFETY: a `Vec<i64>` is exactly three `usize`-sized words.
114 let words = unsafe { std::slice::from_raw_parts(base, 3) };
115 let cap_off = words
116 .iter()
117 .position(|&w| w == cap)
118 .expect("Vec capacity field located") as i32
119 * 8;
120 let len_off = words
121 .iter()
122 .position(|&w| w == len)
123 .expect("Vec len field located") as i32
124 * 8;
125 // Restore len so the (forgotten) Vec drops its real (empty) contents
126 // without touching the uninitialized tail.
127 unsafe { v.set_len(0) };
128 VecLayout { cap_off, len_off }
129 })
130}
131
132/// XMM registers we may assign to FLOAT-class slots. All are caller-saved under
133/// SysV (no prologue save/restore); `xmm14`/`xmm15` are reserved as scratch
134/// (`FS0`/`FS1`). Ordered low→high so the assignment is deterministic.
135const FLOAT_REGS: [Xmm; 14] = [
136 Xmm::Xmm0, Xmm::Xmm1, Xmm::Xmm2, Xmm::Xmm3, Xmm::Xmm4, Xmm::Xmm5, Xmm::Xmm6,
137 Xmm::Xmm7, Xmm::Xmm8, Xmm::Xmm9, Xmm::Xmm10, Xmm::Xmm11, Xmm::Xmm12, Xmm::Xmm13,
138];
139
140/// Callee-saved registers we may assign (must be saved/restored).
141const CALLEE_SAVED: [Reg; 4] = [Reg::Rbx, Reg::R12, Reg::R13, Reg::R14];
142/// Caller-saved registers we may assign freely (no save/restore needed).
143/// `rdi`/`rsi` are the incoming `base`/`sp` args — `base` is copied to r15 in
144/// the prologue and `sp` is unused by the int subset, so both are free after
145/// entry.
146const CALLER_SAVED: [Reg; 6] = [Reg::R8, Reg::R9, Reg::R10, Reg::R11, Reg::Rdi, Reg::Rsi];
147
148/// Whether the contiguous register-allocating backend is selected. Default ON:
149/// it is bit-identical to the stencil tier (proven by the full corpus
150/// differential with the flag on) and 4-6x faster on the regions it supports,
151/// falling back to the per-piece tier on any unsupported op. `LOGOS_REGALLOC=0`
152/// is the kill-switch. Read once.
153pub fn regalloc_enabled() -> bool {
154 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
155 *ON.get_or_init(|| std::env::var("LOGOS_REGALLOC").map_or(true, |v| v != "0"))
156}
157
158/// `LOGOS_SIMD=1` opts a recognized PURE ELEMENT-WISE MAP region (Oracle-proven
159/// in-bounds, unit-stride, no loop-carried scalar — see
160/// [`crate::vectorize::recognize_elementwise_map`]) into a 2-wide packed-double
161/// loop. DEFAULT OFF: the bit-identical differential gate must certify it before
162/// promotion, and no current benchmark exercises a float map. Read once.
163pub fn simd_enabled() -> bool {
164 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
165 *ON.get_or_init(|| std::env::var("LOGOS_SIMD").is_ok_and(|v| v == "1"))
166}
167
168/// Whether the CALL-LOCALITY ranking bonus is applied (Fix 1). Default ON: it is
169/// a pure register-assignment reorder (bit-identical, proven by the corpus
170/// differential) that lands call-surviving values in callee-saved registers so a
171/// call-heavy loop pays no per-call spill/reload. `LOGOS_CALL_WEIGHT=0` is the
172/// kill-switch (also used to A/B the lever's effect on a quiet box). Read once.
173fn call_weight_enabled() -> bool {
174 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
175 *ON.get_or_init(|| std::env::var("LOGOS_CALL_WEIGHT").map_or(true, |v| v != "0"))
176}
177
178/// Whether the INLINE `ArrPush` fast path is emitted (Wave 25). Default ON: it is
179/// bit-identical to the helper-call lowering (proven by the corpus differential
180/// and the dedicated `inline_push_*` tests), eliminating the per-push spill+call
181/// in the common (`len < cap`) case. `LOGOS_NO_INLINE_PUSH=1` falls back to the
182/// always-call lowering — the kill-switch AND the A/B toggle for the relative
183/// on/off timing on a noisy box. Read once.
184fn inline_push_enabled() -> bool {
185 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
186 *ON.get_or_init(|| std::env::var("LOGOS_NO_INLINE_PUSH").map_or(true, |v| v == "0"))
187}
188
189/// Whether the SCALED-INDEX CSE is emitted (Wave 26). A straight-line run of
190/// array accesses sharing one index slot recomputes the 0-based index `im1 =
191/// idx - 1` (and its scaled byte offset `im1 * 8`) once into reserved
192/// caller-saved register(s), then reuses it for each `base + off` address — the
193/// LLVM/V8 "compute the scaled offset once, reuse across base pointers" idiom.
194/// nbody's inner force loop reads/writes 7 co-indexed arrays at the same `i`
195/// (and another set at `j`); matrix_mult / spectral_norm / prefix_sum likewise
196/// touch several arrays at a shared index. Default ON: it is bit-identical to
197/// the per-access recompute (the cache is recomputed whenever the index slot is
198/// reassigned, at every jump target, and across any SysV call), proven by the
199/// corpus differential and the dedicated `off_cse_*` tests. `LOGOS_NO_OFF_CSE=1`
200/// falls back to the per-access recompute — the kill-switch AND the A/B toggle
201/// for the relative on/off timing on a noisy box. Read once.
202fn off_cse_enabled() -> bool {
203 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
204 *ON.get_or_init(|| std::env::var("LOGOS_NO_OFF_CSE").map_or(true, |v| v != "1"))
205}
206
207/// Whether the LOOP-INVARIANT ARRAY PTR/LEN HOIST is emitted (Wave 27a). When an
208/// array's ptr/len handle slots are FRAME-resident and are never written inside a
209/// loop (only in-place `ArrLoad`/`ArrStore` of that array — no reallocating
210/// `ArrPush`/`ListClear`/`NewList`/`ListTriple`, no scalar write to the slot),
211/// the per-access `emit_arr_addr` reload of those slots from the frame is
212/// loop-invariant. The hoist loads them ONCE into loop-persistent CALLEE-SAVED
213/// registers at the loop pre-header, so the inner accesses read the register
214/// instead of re-touching the frame every iteration (the knapsack inner `w`-loop
215/// reads `prev`'s ptr/len every iteration though `prev` only changes on the OUTER
216/// loop's `Set prev to curr`). Default ON: bit-identical to the per-access reload
217/// (the hoisted register holds exactly `frame[slot]`, which is invariant over the
218/// span, and the bounds check still runs per access against the hoisted length),
219/// proven by the corpus differential and the dedicated `ptr_hoist_*` tests.
220/// `LOGOS_NO_PTR_HOIST=1` falls back to the per-access reload — the kill-switch
221/// AND the A/B toggle for the relative on/off timing on a noisy box. Read once.
222fn ptr_hoist_enabled() -> bool {
223 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
224 *ON.get_or_init(|| std::env::var("LOGOS_NO_PTR_HOIST").map_or(true, |v| v != "1"))
225}
226
227/// Whether the LOOP-INVARIANT CONSTANT HOIST is emitted (Wave 28). A
228/// `LoadConst { dst, value }` INSIDE a natural loop, whose `dst` is written by no
229/// other op in the span (so the value is invariant across iterations) and lands
230/// in a GP register, has its `mov reg, imm` re-executed every iteration in the
231/// body. The hoist emits it ONCE in the loop pre-header instead, leaving the
232/// register resident across the back-edge — the V8/LLVM loop-invariant-code-
233/// motion idiom (collatz −7%, loop_sum, every pure-GP scalar loop with an
234/// invariant constant).
235///
236/// FLOAT (XMM-resident) consts are EXCLUDED — the investigation began with
237/// mandelbrot's `2.0`/`4.0` GP→XMM reloads, the obvious target, but hoisting them
238/// into the SATURATED XMM file is bit-identical yet measured ~8% SLOWER (the
239/// per-iteration `movabs;movq` is FREE out-of-order work that overlaps the float
240/// chain; pinning the invariant in a read-heavy XMM register serializes worse).
241/// See the `const_hoist_*` tests and the call site's exclusion comment.
242///
243/// Default ON: bit-identical to the per-iteration `LoadConst` (the register holds
244/// exactly the same constant bits, the body simply stops re-loading them, and the
245/// exit flush of a written slot is unchanged), proven by the corpus differential
246/// and the dedicated `const_hoist_*` tests. `LOGOS_NO_CONST_HOIST=1` falls back to
247/// the per-iteration materialization — the kill-switch AND the A/B toggle for the
248/// relative on/off timing on a noisy box. Read LIVE (not cached) so a single
249/// process can A/B the two emissions for the structural gate.
250fn const_hoist_enabled() -> bool {
251 std::env::var("LOGOS_NO_CONST_HOIST").map_or(true, |v| v != "1")
252}
253
254/// Diagnostic: when `LOGOS_DUMP_CLASS=1`, [`compile_impl`] prints a one-line
255/// slot-class + placement histogram per compiled region that touches any float
256/// (Int/Float/Mixed counts, how many Float slots won an XMM register vs spilled
257/// to the frame under pressure, and the Mixed-slot indices). Pure observability,
258/// no behavior change — it is how the campaign tells a Mixed-frame wall apart
259/// from an XMM-pressure wall before choosing a lever.
260fn dump_class_enabled() -> bool {
261 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
262 *ON.get_or_init(|| std::env::var("LOGOS_DUMP_CLASS").map_or(false, |v| v != "0"))
263}
264
265/// LS1 linear-scan register allocation (reuse a register across disjoint live
266/// ranges, float slots only). MEASURED LOSS — parked default-OFF. It is sound
267/// (forge 93/93 with `=1`, all benchmarks bit-identical) but a net SLOWDOWN on
268/// the float-pressured regions (nbody +74%, mandelbrot +104%): the floats the
269/// rank-based per-slot model leaves in the frame are COLD, so frame residency is
270/// already the cheap, correct choice — forcing them into reused registers adds a
271/// per-iteration spill-at-end store that exceeds their cold frame cost. `maxLive
272/// <= 14` measured liveness, not HOTNESS, so it overstated the win. Do NOT promote;
273/// kept behind the flag with the `LOGOS_DUMP_CLASS` diagnostic as a documented
274/// negative result. The float-cluster lever is op-count (fusion), not allocation.
275fn linear_scan_enabled() -> bool {
276 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
277 *ON.get_or_init(|| std::env::var("LOGOS_LINEAR_SCAN").map_or(false, |v| v == "1"))
278}
279
280/// The maximum LOGOS call depth, baked into the self-call codegen exactly as it
281/// is baked into `logos_stencil_call_self` — the kernel's locked
282/// `MAX_CALL_DEPTH`. Reusing the single forge constant keeps the contiguous
283/// FUNCTION backend's depth guard in lockstep with the stencil tier and the
284/// kernel (a drift would diverge the depth-exceeded error). See the
285/// `max-call-depth-sync-jit-forge` invariant.
286const SELF_CALL_DEPTH_LIMIT: i64 = crate::jit::BAKED_CALL_DEPTH;
287
288/// Is this op in the supported subset of the contiguous backend? An op outside
289/// it makes [`compile_region_regalloc`] return `None`, so the caller falls back
290/// to the per-piece tier and behavior is unchanged.
291fn supported(op: &MicroOp) -> bool {
292 matches!(
293 op,
294 MicroOp::Move { .. }
295 | MicroOp::LoadConst { .. }
296 | MicroOp::Add { .. }
297 | MicroOp::Sub { .. }
298 | MicroOp::Mul { .. }
299 | MicroOp::Lt { .. }
300 | MicroOp::Gt { .. }
301 | MicroOp::LtEq { .. }
302 | MicroOp::GtEq { .. }
303 | MicroOp::Eq { .. }
304 | MicroOp::Neq { .. }
305 | MicroOp::BitAnd { .. }
306 | MicroOp::BitOr { .. }
307 | MicroOp::BitXor { .. }
308 | MicroOp::Shl { .. }
309 | MicroOp::Shr { .. }
310 | MicroOp::NotInt { .. }
311 | MicroOp::NotBool { .. }
312 | MicroOp::Div { .. }
313 | MicroOp::Mod { .. }
314 | MicroOp::DivPow2 { .. }
315 | MicroOp::MagicDivU { .. }
316 | MicroOp::Branch { .. }
317 | MicroOp::Jump { .. }
318 | MicroOp::JumpIfFalse { .. }
319 | MicroOp::JumpIfTrue { .. }
320 | MicroOp::Return { .. }
321 // Integer (8-byte element) array load/store. A FLOAT array element
322 // is also an 8-byte (`byte: false`) slot: the raw bits load into /
323 // store from a frame slot, and the float arithmetic reinterprets
324 // them — no separate "float array" op is needed.
325 | MicroOp::ArrLoad { byte: false, .. }
326 | MicroOp::ArrStore { byte: false, .. }
327 // BYTE (1-byte element) array load/store — the `Seq of Bool` buffer
328 // (sieve's `flags`, graph_bfs would-be `visited`). A `byte: true`
329 // load is a zero-extended `movzx` (`u8 as i64`); a `byte: true`
330 // store writes the BOOLEAN NORMALIZATION `(v != 0) as u8`. The
331 // address machinery and OOB side-exit are the 8-byte path with a
332 // unit stride — bit-identical to `ST_ARRLDB`/`ST_ARRSTB`.
333 | MicroOp::ArrLoad { byte: true, .. }
334 | MicroOp::ArrStore { byte: true, .. }
335 // FLOAT (f64) ops — the XMM register class (wave 11). Arithmetic is
336 // IEEE; `FmaF` is TWO roundings (mulsd then addsd, NOT a fused
337 // `vfmadd`); ordering compares are exact IEEE (NaN → false); `EqF`/
338 // `NeqF` use the kernel's `|a-b| < EPSILON` rule; `DivF` side-exits
339 // on a `0.0` divisor like the integer `Div`.
340 | MicroOp::AddF { .. }
341 | MicroOp::SubF { .. }
342 | MicroOp::MulF { .. }
343 | MicroOp::DivF { .. }
344 | MicroOp::SqrtF { .. }
345 | MicroOp::IntToFloat { .. }
346 | MicroOp::FmaF { .. }
347 | MicroOp::LtF { .. }
348 | MicroOp::GtF { .. }
349 | MicroOp::LtEqF { .. }
350 | MicroOp::GtEqF { .. }
351 | MicroOp::EqF { .. }
352 | MicroOp::NeqF { .. }
353 | MicroOp::BranchF { .. }
354 // LIST MUTATION (wave 13) — helper calls into the JIT runtime.
355 // `ArrPush` appends through `logos_rt_push_*` (the call MAY
356 // reallocate, so the pinned ptr/len are refreshed in the frame
357 // after); `ListClear` truncates a SOLE-OWNED buffer in place
358 // through `logos_rt_clear_*` (the alias-safety of the reuse is
359 // proven UPSTREAM in the micro-op lowering — a `ListClear` reaching
360 // here is unaliased). Both keep their vec/ptr/len handle slots
361 // FRAME-resident (the helper reads/writes those frame cells) and
362 // spill the caller-saved residents across the SysV call.
363 | MicroOp::ArrPush { .. }
364 | MicroOp::ListClear { .. }
365 // MUTABLE-TEXT append (`Set text to text + ch`). A helper call into
366 // the JIT runtime that grows the accumulator THROUGH the pinned
367 // `*mut Value` cell handle with the VM's exact `add_assign`
368 // (in-place / copy-on-write) semantics; the handle slot is forced
369 // frame-resident and the caller-saved residents are spilled across
370 // the SysV call, exactly like `ArrPush`.
371 | MicroOp::StrAppend { .. }
372 )
373}
374
375/// Is this op in the supported subset of the contiguous FUNCTION backend
376/// ([`compile_function_regalloc`])? It is the region subset PLUS the DIRECT
377/// self-call ops (`CallSelf`/`CallSelfCopy`). A cross-function `Call`, list/map
378/// ops, byte arrays, etc. remain unsupported → the function falls back to the
379/// per-piece stencil tier, byte-identical to today.
380fn supported_function(op: &MicroOp) -> bool {
381 supported(op) || matches!(op, MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })
382}
383
384/// Is this op in the supported subset of the contiguous PRECISE (mode-B)
385/// FUNCTION backend ([`compile_function_regalloc_precise`])? This spans two
386/// recursion shapes:
387///
388/// - the IN-PLACE-ARRAY shape (heap_sort's `siftDown`, quicksort's `qs`):
389/// scalar arithmetic, checked int/byte array load/store (the in-place
390/// mutation), and the precise self-`Call` (windowed disjointly, resuming AT
391/// the faulting op on a side exit); and
392/// - the fresh-list-RETURN shape (mergesort: allocate fresh `left`/`right`/
393/// `result`, push into them, and return a new list): the registry-owned
394/// allocation `NewList`, the reallocating `ArrPush`, the in-place `ListClear`,
395/// and the `ListTriple` pin refresh from a live handle (a self-call's returned
396/// list, seen from the caller).
397///
398/// SOUNDNESS of the list-return shape: the regalloc body shares the SAME
399/// `adapt_function` micro stream, per-op `deopt_codes` table, runtime helpers
400/// (`logos_rt_alloc_list_i64` / push / triple), and `ChainFn::call`+`materialize`
401/// boundary as the per-piece precise stencil tier — only the body codegen
402/// changes. `NewList`/`ArrPush`/`ListTriple` each force their vec/ptr/len triple
403/// FRAME-resident (the helper's frame writes are the single source of truth, so a
404/// reallocating push's refreshed pointer/length is always read by the next
405/// access), every mode-B machinery slot (`>= rc`) stays frame-resident, and every
406/// precise side exit flushes the resident-written scalars to the frame. So a
407/// registry-owned fresh list materializes (detaches) on a precise resume exactly
408/// as in the stencil tier; the differential gate proves it bit-identical.
409///
410/// It still does NOT admit the classic `CallSelf`/`CallSelfCopy` (those replay
411/// from head — sound only for the scalar mode-A path), nor maps/cross-function
412/// calls.
413fn supported_function_precise(op: &MicroOp) -> bool {
414 supported(op)
415 || matches!(
416 op,
417 MicroOp::Call { .. } | MicroOp::ListTriple { .. } | MicroOp::NewList { .. }
418 )
419}
420
421/// Whether `op` is a CHECKED operation needing the deopt side-exit channel (the
422/// status cell): a divisor-check on `Div`/`Mod`, or a bounds-check on a checked
423/// integer array load/store. A self-call also writes the status cell (its depth/
424/// arena/entry guards and the propagation of an in-callee deopt), so it too
425/// requires the channel.
426fn needs_deopt(op: &MicroOp) -> bool {
427 matches!(
428 op,
429 MicroOp::Div { .. }
430 | MicroOp::Mod { .. }
431 | MicroOp::DivF { .. }
432 | MicroOp::ArrLoad { checked: true, .. }
433 | MicroOp::ArrStore { checked: true, .. }
434 // Integer add/sub/mul side-exit on signed overflow (`jo`) so the exact
435 // tier recomputes and promotes to BigInt — overflow is no longer a
436 // silent wrap. (A later perf pass elides the guard where the prover
437 // proves no overflow; for now every integer arith op is checked.)
438 | MicroOp::Add { .. }
439 | MicroOp::Sub { .. }
440 | MicroOp::Mul { .. }
441 | MicroOp::CallSelf { .. }
442 | MicroOp::CallSelfCopy { .. }
443 // The mode-B precise self-call writes the status cell (its depth/
444 // arena/entry guards and the propagation of an in-callee deopt).
445 | MicroOp::Call { .. }
446 )
447}
448
449/// Every slot index an op reads or writes (for reference-count ranking and the
450/// max-slot frame bound).
451fn slots_of(op: &MicroOp, out: &mut Vec<Slot>) {
452 match *op {
453 MicroOp::Move { dst, src } | MicroOp::NotInt { dst, src } | MicroOp::NotBool { dst, src } => {
454 out.extend([dst, src])
455 }
456 MicroOp::LoadConst { dst, .. } => out.push(dst),
457 MicroOp::Add { dst, lhs, rhs }
458 | MicroOp::Sub { dst, lhs, rhs }
459 | MicroOp::Mul { dst, lhs, rhs }
460 | MicroOp::Lt { dst, lhs, rhs }
461 | MicroOp::Gt { dst, lhs, rhs }
462 | MicroOp::LtEq { dst, lhs, rhs }
463 | MicroOp::GtEq { dst, lhs, rhs }
464 | MicroOp::Eq { dst, lhs, rhs }
465 | MicroOp::Neq { dst, lhs, rhs }
466 | MicroOp::BitAnd { dst, lhs, rhs }
467 | MicroOp::BitOr { dst, lhs, rhs }
468 | MicroOp::BitXor { dst, lhs, rhs }
469 | MicroOp::Shl { dst, lhs, rhs }
470 | MicroOp::Shr { dst, lhs, rhs }
471 | MicroOp::Div { dst, lhs, rhs }
472 | MicroOp::Mod { dst, lhs, rhs }
473 | MicroOp::AddF { dst, lhs, rhs }
474 | MicroOp::SubF { dst, lhs, rhs }
475 | MicroOp::MulF { dst, lhs, rhs }
476 | MicroOp::DivF { dst, lhs, rhs }
477 | MicroOp::LtF { dst, lhs, rhs }
478 | MicroOp::GtF { dst, lhs, rhs }
479 | MicroOp::LtEqF { dst, lhs, rhs }
480 | MicroOp::GtEqF { dst, lhs, rhs }
481 | MicroOp::EqF { dst, lhs, rhs }
482 | MicroOp::NeqF { dst, lhs, rhs } => out.extend([dst, lhs, rhs]),
483 MicroOp::FmaF { dst, a, b, c } => out.extend([dst, a, b, c]),
484 MicroOp::IntToFloat { dst, src } | MicroOp::SqrtF { dst, src } => out.extend([dst, src]),
485 MicroOp::DivPow2 { dst, lhs, .. } => out.extend([dst, lhs]),
486 MicroOp::MagicDivU { dst, lhs, .. } => out.extend([dst, lhs]),
487 MicroOp::Branch { lhs, rhs, .. } | MicroOp::BranchF { lhs, rhs, .. } => out.extend([lhs, rhs]),
488 MicroOp::JumpIfFalse { cond, .. } | MicroOp::JumpIfTrue { cond, .. } => out.push(cond),
489 MicroOp::Return { src } => out.push(src),
490 MicroOp::ArrLoad { dst, idx, ptr_slot, len_slot, checked, .. } => {
491 out.extend([dst, idx, ptr_slot]);
492 if checked {
493 out.push(len_slot);
494 }
495 }
496 MicroOp::ArrStore { src, idx, ptr_slot, len_slot, checked, .. } => {
497 out.extend([src, idx, ptr_slot]);
498 if checked {
499 out.push(len_slot);
500 }
501 }
502 // A push reads its `src` value (rank it — it may be register/XMM
503 // resident) and the vec/ptr/len handle TRIPLE, which is forced
504 // frame-resident below (the helper reads/writes those frame cells), so
505 // we list the triple only to extend `max_slot`, never to rank it.
506 MicroOp::ArrPush { src, vec_slot, ptr_slot, len_slot, .. } => {
507 out.extend([src, vec_slot, ptr_slot, len_slot])
508 }
509 MicroOp::ListClear { vec_slot, ptr_slot, len_slot, .. } => {
510 out.extend([vec_slot, ptr_slot, len_slot])
511 }
512 // A fresh-list allocation writes the vec/ptr/len handle TRIPLE (forced
513 // frame-resident below — the helper writes those frame cells); list it
514 // only to extend `max_slot`, never to rank it (the triple stays in the
515 // frame, so it competes for no register).
516 MicroOp::NewList { vec_slot, ptr_slot, len_slot, .. } => {
517 out.extend([vec_slot, ptr_slot, len_slot])
518 }
519 // A str-append reads its handle slot (forced frame-resident below — the
520 // helper indexes it) and, for the byte form, the value slot (ranked: it
521 // may be register-resident). The const form has no extra slot.
522 MicroOp::StrAppend { text_handle_slot, src, .. } => {
523 out.push(text_handle_slot);
524 if let crate::jit::StrSrc::Byte(s) = src {
525 out.push(s);
526 }
527 }
528 // The mode-B precise self-call writes `dst` and reads `limit_slot` (the
529 // arena bound). The callee window (args_start..) is forced frame-
530 // resident below; the staging Moves into it account those slots already,
531 // and `extend_self_call_slots` is not used (the disjoint window's extent
532 // is covered by the Move ops the adapter emits into it).
533 MicroOp::Call { dst, limit_slot, .. } => out.extend([dst, limit_slot]),
534 // A triple-plant reads `handle_slot` and writes the vec/ptr/len triple
535 // (all forced frame-resident); `handle_slot` is ranked so a register-
536 // resident handle is still read correctly (the codegen spills it to its
537 // forced frame cell so the helper sees it).
538 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
539 out.extend([handle_slot, vec_slot, ptr_slot, len_slot])
540 }
541 // A self-call writes `dst` and reads `limit_slot` (the arena bound). The
542 // callee window (args_start..) and a `CallSelfCopy`'s source block
543 // (src_start..) are accounted by `extend_self_call_slots` so `max_slot`
544 // covers them; the staging slots stay FRAME-resident (the callee reads
545 // them from the frame) and so are NOT ranked for a register here.
546 MicroOp::CallSelf { dst, limit_slot, .. } => out.extend([dst, limit_slot]),
547 MicroOp::CallSelfCopy { dst, src_start, arg_count, limit_slot, .. } => {
548 out.extend([dst, limit_slot]);
549 // The source args are READ; rank them (they may be register-resident
550 // — the codegen reads them from wherever they live, then stages).
551 for j in 0..arg_count {
552 out.push(src_start + j);
553 }
554 }
555 MicroOp::Jump { .. } => {}
556 _ => {}
557 }
558}
559
560/// The control-flow TARGET of a transfer op (`Jump`/`JumpIf*`/`Branch*`), if
561/// any. A target whose index is `<` the op's own index is a loop BACK-EDGE.
562fn op_target(op: &MicroOp) -> Option<usize> {
563 match *op {
564 MicroOp::Jump { target }
565 | MicroOp::JumpIfFalse { target, .. }
566 | MicroOp::JumpIfTrue { target, .. }
567 | MicroOp::Branch { target, .. }
568 | MicroOp::BranchF { target, .. } => Some(target),
569 _ => None,
570 }
571}
572
573/// The LOOP NESTING DEPTH of every op in the region. A loop is the span
574/// `[target, idx]` of a BACK-EDGE — a transfer op at `idx` whose `target <= idx`
575/// (so its body runs repeatedly). The depth of op `j` is the number of such
576/// spans that contain `j`; ops inside a doubly-nested loop have depth 2, etc.
577/// This is a structural over-approximation (it counts every back-edge span, not
578/// just reducible natural loops), which is exactly what the spill heuristic
579/// wants: a value referenced under a back-edge is hot and should win a register.
580fn loop_depths(ops: &[MicroOp]) -> Vec<u32> {
581 let n = ops.len();
582 let mut depth = vec![0u32; n];
583 for (idx, op) in ops.iter().enumerate() {
584 if let Some(target) = op_target(op) {
585 if target <= idx {
586 for d in depth.iter_mut().take(idx + 1).skip(target) {
587 *d += 1;
588 }
589 }
590 }
591 }
592 depth
593}
594
595/// Does the region contain a SCALED-INDEX CSE opportunity — two or more array
596/// accesses (`ArrLoad`/`ArrStore`) sharing one index slot AND element stride
597/// across a straight-line run, with NO intervening invalidation? This is the
598/// gate for reserving a cache register: without a reusable run the CSE never
599/// fires, so the reservation (which spills the coldest int scalar to the frame)
600/// would be a pure cost. The invalidation here MIRRORS the runtime exactly —
601/// the cache is dropped at a jump TARGET (control join), when the cached index
602/// slot is reassigned, and across a SysV call — so this returns `true` iff at
603/// least one access would HIT the cache the codegen builds.
604fn has_shared_index_run(ops: &[MicroOp]) -> bool {
605 // Precompute jump-target indices (control joins drop the cache).
606 let mut is_target = vec![false; ops.len()];
607 for op in ops {
608 if let Some(t) = op_target(op) {
609 if let Some(slot) = is_target.get_mut(t) {
610 *slot = true;
611 }
612 }
613 }
614 let mut cache: Option<(Slot, bool)> = None; // (idx, byte)
615 for (i, op) in ops.iter().enumerate() {
616 if is_target[i] {
617 cache = None;
618 }
619 match *op {
620 MicroOp::ArrLoad { idx, byte, .. } | MicroOp::ArrStore { idx, byte, .. } => {
621 if cache == Some((idx, byte)) {
622 return true; // a reuse hit — the run pays off.
623 }
624 cache = Some((idx, byte));
625 }
626 _ => {}
627 }
628 // Post-op invalidation, mirroring the codegen: a SysV call clobbers the
629 // reserved caller-saved register; a write to the cached index slot makes
630 // the held `im1` stale.
631 if is_sysv_call(op) {
632 cache = None;
633 } else if let Some((idx, _)) = cache {
634 if dest_of(op) == Some(idx) {
635 cache = None;
636 }
637 }
638 }
639 false
640}
641
642/// Whether `op` is a REAL SysV `call` (it clobbers the caller-saved registers per
643/// the ABI). This is the universe of ops the call-weight ranking and the
644/// per-call-site spill machinery key off: the self-call families (`CallSelf`,
645/// `CallSelfCopy`, the mode-B precise `Call`) and the list/string-mutation
646/// helpers (`NewList`, `ArrPush`, `ListClear`, `ListTriple`, `StrAppend`), each
647/// of which enters JIT-runtime code through a SysV `call`. Mirrors the
648/// `has_self_call || has_list_call || has_precise_call` disjunction that defines
649/// `has_call`.
650fn is_sysv_call(op: &MicroOp) -> bool {
651 matches!(
652 op,
653 MicroOp::CallSelf { .. }
654 | MicroOp::CallSelfCopy { .. }
655 | MicroOp::Call { .. }
656 | MicroOp::NewList { .. }
657 | MicroOp::ArrPush { .. }
658 | MicroOp::ListClear { .. }
659 | MicroOp::ListTriple { .. }
660 | MicroOp::StrAppend { .. }
661 )
662}
663
664/// A loop-invariant array ptr/len hoist (Wave 27a): one array, invariant over one
665/// loop span, whose handle slots are loaded ONCE at the loop pre-header into
666/// persistent callee-saved registers and reused for every in-span access.
667#[derive(Clone, Copy)]
668struct HoistPlan {
669 /// The loop head op index (the back-edge target). The hoist loads fire at this
670 /// op's PRE-HEADER (just before binding the head's label), reached only by the
671 /// entry fall-through — the back-edge jumps to the bound label and skips them.
672 head: usize,
673 /// The last op index INSIDE the loop (the back-edge op). The hoist registers
674 /// are live (used by `emit_arr_addr`) for ops in `head..=back`; outside this
675 /// span the array falls back to the per-access frame reload.
676 back: usize,
677 /// The frame-resident ptr slot loaded once and reused for every in-span access.
678 ptr_slot: Slot,
679 /// The len slot, loaded once and reused for every CHECKED in-span access; only
680 /// loaded when `needs_len` (some in-span access of this array is checked).
681 len_slot: Slot,
682 /// Whether any in-span access of this array is CHECKED (so `len` must hoist).
683 needs_len: bool,
684 /// How many `ArrLoad`/`ArrStore` of this array occur in the span — the reuse
685 /// count the hoist amortizes (one pre-header load serves all of them).
686 accesses: u32,
687}
688
689/// Whether op `op` WRITES the frame cell of slot `s` — either it is the op's
690/// destination (a scalar def, an `ArrLoad` dst, a self-call result), OR a
691/// list-mutation helper REFRESHES it through the frame (`ArrPush`/`NewList`/
692/// `ListClear`/`ListTriple` write `frame[vec/ptr/len]` after a possible realloc).
693/// An array whose ptr/len slot is written this way INSIDE a loop is NOT invariant
694/// there and must not be hoisted (its hoisted register would go stale).
695fn op_writes_slot(op: &MicroOp, s: Slot) -> bool {
696 if dest_of(op) == Some(s) {
697 return true;
698 }
699 match *op {
700 MicroOp::NewList { vec_slot, ptr_slot, len_slot, .. }
701 | MicroOp::ArrPush { vec_slot, ptr_slot, len_slot, .. }
702 | MicroOp::ListClear { vec_slot, ptr_slot, len_slot, .. } => {
703 s == vec_slot || s == ptr_slot || s == len_slot
704 }
705 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
706 s == handle_slot || s == vec_slot || s == ptr_slot || s == len_slot
707 }
708 _ => false,
709 }
710}
711
712/// Identify every loop-invariant array ptr/len hoist available in `ops`. A
713/// candidate is one (`ptr_slot`, `len_slot`) pair that:
714///
715/// 1. is accessed by an `ArrLoad`/`ArrStore` INSIDE a natural loop span
716/// `[head, back]` (a back-edge whose `target == head <= back`);
717/// 2. has NEITHER slot written anywhere inside `[head, back]` (no scalar def, no
718/// reallocating push/clear/triple of that array) — so `frame[ptr]`/`frame[len]`
719/// are constant across the whole span;
720/// 3. enters the loop ONLY via the pre-header fall-through — every transfer that
721/// targets `head` originates INSIDE `[head, back]` (a back-edge). A forward
722/// jump into `head` from outside would skip the pre-header load and read a
723/// stale register, so such a loop is rejected (no hoist).
724///
725/// Frame-residency is NOT decided here (the analysis is purely structural over the
726/// op stream); the caller filters to slots that are actually `Loc::Frame` before
727/// reserving a register (a slot the linear scan already keeps in a register has
728/// no per-access reload to hoist).
729///
730/// For nested loops the INNERMOST span containing an access is preferred: its
731/// pre-header runs once per outer iteration (the highest reuse), and a slot
732/// invariant in the inner loop may be written between inner loops at the outer
733/// level (knapsack's `Set prev to curr`), so the outer span would not be
734/// invariant. We therefore emit one plan per (innermost-loop, array) pair.
735fn loop_invariant_array_hoists(ops: &[MicroOp]) -> Vec<HoistPlan> {
736 // Every natural-loop span: a back-edge at `back` with `target == head <= back`.
737 let mut loops: Vec<(usize, usize)> = Vec::new();
738 for (back, op) in ops.iter().enumerate() {
739 if let Some(head) = op_target(op) {
740 if head <= back {
741 loops.push((head, back));
742 }
743 }
744 }
745 let mut plans: Vec<HoistPlan> = Vec::new();
746 for &(head, back) in &loops {
747 // Entry-discipline guard: every transfer targeting `head` must originate
748 // inside `[head, back]` (a back-edge). A forward jump into `head` from
749 // outside the span would skip the pre-header load → reject this loop.
750 let entry_ok = ops.iter().enumerate().all(|(i, op)| match op_target(op) {
751 Some(t) if t == head => (head..=back).contains(&i),
752 _ => true,
753 });
754 if !entry_ok {
755 continue;
756 }
757 // The array (ptr, len) pairs accessed inside the span, and whether any
758 // access of each is checked. A pair is keyed by ptr_slot (the address
759 // base); the len_slot rides along (a checked access of a given array
760 // always names the same len_slot — the lowering pins one handle triple
761 // per array).
762 let mut accessed: std::collections::BTreeMap<Slot, (Slot, bool, u32)> =
763 std::collections::BTreeMap::new();
764 for op in &ops[head..=back] {
765 match *op {
766 MicroOp::ArrLoad { ptr_slot, len_slot, checked, .. }
767 | MicroOp::ArrStore { ptr_slot, len_slot, checked, .. } => {
768 let e = accessed.entry(ptr_slot).or_insert((len_slot, false, 0));
769 e.1 |= checked;
770 e.2 += 1;
771 }
772 _ => {}
773 }
774 }
775 for (ptr_slot, (len_slot, any_checked, accesses)) in accessed {
776 // Invariance: neither handle slot is written anywhere in the span.
777 let invariant = !ops[head..=back]
778 .iter()
779 .any(|op| op_writes_slot(op, ptr_slot) || op_writes_slot(op, len_slot));
780 if invariant {
781 plans.push(HoistPlan {
782 head,
783 back,
784 ptr_slot,
785 len_slot,
786 needs_len: any_checked,
787 accesses,
788 });
789 }
790 }
791 }
792 // For an array accessed in several NESTED spans, keep only the innermost (the
793 // smallest span) so the pre-header sits at the tightest loop (highest reuse)
794 // and no two plans claim the same array with overlapping spans. Two disjoint
795 // loops touching the same array each keep their own plan.
796 plans.sort_by_key(|p| (p.ptr_slot, p.back - p.head));
797 let mut chosen: Vec<HoistPlan> = Vec::new();
798 for p in plans {
799 let overlaps_kept = chosen.iter().any(|c| {
800 c.ptr_slot == p.ptr_slot && c.head <= p.back && p.head <= c.back
801 });
802 if !overlaps_kept {
803 chosen.push(p);
804 }
805 }
806 chosen
807}
808
809/// A loop-invariant constant hoist (Wave 28): one `LoadConst` op inside a loop
810/// whose `dst` is invariant across the span, materialized ONCE at the loop
811/// pre-header instead of every iteration.
812#[derive(Clone, Copy)]
813struct ConstHoist {
814 /// The op index of the `LoadConst` being hoisted. The emit loop SKIPS this op
815 /// inside the span (the pre-header already loaded its register).
816 op: usize,
817 /// The loop head op index (the back-edge target). The hoist materialization
818 /// fires at this op's PRE-HEADER, reached only by the entry fall-through.
819 head: usize,
820 /// The destination slot the constant lands in (register-resident).
821 dst: Slot,
822 /// The raw 64-bit constant value (`MicroOp::LoadConst::value`).
823 value: i64,
824}
825
826/// Identify every loop-invariant `LoadConst` hoist available in `ops`. A
827/// `LoadConst { dst, value }` at op index `i` is a candidate when:
828///
829/// 1. it sits INSIDE a natural loop span `[head, back]` (a back-edge whose
830/// `target == head <= back`) — the INNERMOST such span containing `i` (the
831/// tightest pre-header, the highest reuse: mandelbrot's `2.0`/`4.0` sit in
832/// the 50-iteration inner loop);
833/// 2. the loop is entered ONLY via the pre-header fall-through (every transfer
834/// targeting `head` originates inside `[head, back]`) — otherwise a forward
835/// jump into `head` would skip the pre-header materialization and read a
836/// stale register;
837/// 3. `dst` is written by NO OTHER op anywhere in `[head, back]` (the ONLY
838/// writer in the span is this `LoadConst`) — so the register holds the same
839/// constant bits on every iteration. A slot reused for two different
840/// constants (a scratch `LoadConst {dst,0}` then `LoadConst {dst,1}`) has two
841/// writers and is therefore NOT hoisted.
842///
843/// Register-residency is NOT decided here (the analysis is purely structural over
844/// the op stream); the caller filters to `dst`s that are actually `Loc::Reg`/
845/// `Loc::Xmm` before materializing (a frame-resident const has no per-iteration
846/// register reload to amortize).
847fn loop_invariant_const_hoists(ops: &[MicroOp]) -> Vec<ConstHoist> {
848 // Every natural-loop span: a back-edge at `back` with `target == head <= back`.
849 let mut loops: Vec<(usize, usize)> = Vec::new();
850 for (back, op) in ops.iter().enumerate() {
851 if let Some(head) = op_target(op) {
852 if head <= back {
853 loops.push((head, back));
854 }
855 }
856 }
857 let mut chosen: Vec<ConstHoist> = Vec::new();
858 for (i, op) in ops.iter().enumerate() {
859 let MicroOp::LoadConst { dst, value } = *op else { continue };
860 // The INNERMOST loop span that contains this op (smallest span). A const is
861 // invariant in every enclosing loop, but the innermost pre-header runs the
862 // most often, and an enclosing loop might write `dst` between inner loops.
863 let inner = loops
864 .iter()
865 .copied()
866 .filter(|&(head, back)| (head..=back).contains(&i))
867 .min_by_key(|&(head, back)| back - head);
868 let Some((head, back)) = inner else { continue };
869 // Entry-discipline: every transfer targeting `head` must originate inside
870 // `[head, back]`. A forward jump into `head` from outside would skip the
871 // pre-header materialization.
872 let entry_ok = ops.iter().enumerate().all(|(j, o)| match op_target(o) {
873 Some(t) if t == head => (head..=back).contains(&j),
874 _ => true,
875 });
876 if !entry_ok {
877 continue;
878 }
879 // Invariance: NO other op in the span writes `dst` (this LoadConst is the
880 // sole writer). A slot reused for two constants has a second writer here.
881 let sole_writer = ops[head..=back]
882 .iter()
883 .enumerate()
884 .all(|(off, o)| head + off == i || dest_of(o) != Some(dst));
885 if sole_writer {
886 chosen.push(ConstHoist { op: i, head, dst, value });
887 }
888 }
889 chosen
890}
891
892/// The largest slot index the region touches, INCLUDING each self-call window's
893/// full extent (`args_start + frame_size - 1`). The frame indexing allocates
894/// `loc`/`written`/liveness bitvectors up to this bound, so it must cover the
895/// callee window staging slots even though those stay frame-resident.
896fn max_slot_of(ops: &[MicroOp]) -> usize {
897 let mut max_slot: usize = 0;
898 let mut buf = Vec::new();
899 for op in ops {
900 buf.clear();
901 slots_of(op, &mut buf);
902 for &s in &buf {
903 max_slot = max_slot.max(s as usize);
904 }
905 if let MicroOp::CallSelf { args_start, frame_size, .. }
906 | MicroOp::CallSelfCopy { args_start, frame_size, .. } = *op
907 {
908 let last = (args_start as i64) + frame_size - 1;
909 if last >= 0 {
910 max_slot = max_slot.max(last as usize);
911 }
912 }
913 }
914 max_slot
915}
916
917/// Loop-weighted reference ranking with a CALL-LOCALITY tie-break.
918///
919/// The PRIMARY key is the loop-weighted reference count: each reference at loop
920/// depth `d` is worth `base^depth`, so a loop-carried value out-ranks a colder
921/// slot referenced more times outside any loop. This key — and therefore the
922/// RESIDENT-vs-SPILLED boundary it induces (the first `pool.len()` slots are
923/// resident) — is EXACTLY the pre-Fix-1 ranking. The call bonus is a SECONDARY
924/// key only, so it can NEVER evict a hotter loop slot from a register: it merely
925/// reorders slots that would be resident anyway.
926///
927/// The SECONDARY key prices CALL-LOCALITY. A slot LIVE ACROSS a SysV call (in
928/// `live_after[call_idx]`) pays a caller-saved spill+reload pair on EVERY
929/// execution of that call unless it lives in a callee-saved register. The bonus
930/// is `call_weight * base^depth(call)` per surviving call (a call inside a hot
931/// loop pays the pair per iteration). Because the `has_call` pool puts the four
932/// CALLEE-SAVED registers FIRST, and a slot's callee-vs-caller placement is
933/// decided by its rank position, lifting call-survivors above call-dead slots OF
934/// EQUAL LOOP WEIGHT steers exactly the survivors into the callee-saved regs —
935/// eliminating their per-call reload for a one-time prologue push/pop — without
936/// disturbing which slots are resident at all.
937///
938/// This ONLY changes which physical register a resident slot occupies (and, on a
939/// primary-key tie, which equally-cold slot spills). A slot still lives in
940/// exactly one place for the whole function and its value never changes, so the
941/// assignment is bit-identical to any other ranking. `live_after` is `None` for a
942/// call-free region (no bonus). Returns the order (descending by the composite
943/// key, then ascending by slot for determinism) and the observed `max_slot`. The
944/// returned per-slot `u64` is the PRIMARY loop-weight (the secondary key is
945/// internal to the sort) — downstream consumes only the slot ORDER.
946fn rank_slots(
947 ops: &[MicroOp],
948 loop_weight_base: u64,
949 loop_depth_cap: u32,
950 call_weight: u64,
951 live_after: Option<&[Vec<bool>]>,
952) -> (Vec<(Slot, u64)>, usize) {
953 let depths = loop_depths(ops);
954 let max_slot = max_slot_of(ops);
955 // PRIMARY: loop-weighted reference count.
956 let mut refs: std::collections::HashMap<Slot, u64> = std::collections::HashMap::new();
957 // SECONDARY: call-survival weight (a separate key, never folded into refs).
958 let mut call_surv: std::collections::HashMap<Slot, u64> = std::collections::HashMap::new();
959 let mut buf = Vec::new();
960 for (idx, op) in ops.iter().enumerate() {
961 let weight = loop_weight_base.saturating_pow(depths[idx].min(loop_depth_cap));
962 buf.clear();
963 slots_of(op, &mut buf);
964 for &s in &buf {
965 let e = refs.entry(s).or_insert(0);
966 *e = e.saturating_add(weight);
967 }
968 }
969 if let Some(la) = live_after {
970 for (idx, op) in ops.iter().enumerate() {
971 if !is_sysv_call(op) {
972 continue;
973 }
974 let call_depth_weight =
975 loop_weight_base.saturating_pow(depths[idx].min(loop_depth_cap));
976 let bonus = call_weight.saturating_mul(call_depth_weight);
977 for (s, &live) in la[idx].iter().enumerate() {
978 if live {
979 // Only slots that ALSO carry loop/ref weight can ever be
980 // resident; recording survival for a slot with no refs is
981 // harmless (it sorts last on the primary key regardless).
982 let e = call_surv.entry(s as Slot).or_insert(0);
983 *e = e.saturating_add(bonus);
984 }
985 }
986 }
987 }
988 let mut order: Vec<(Slot, u64)> = refs.iter().map(|(&s, &c)| (s, c)).collect();
989 order.sort_by(|a, b| {
990 // PRIMARY desc, then call-survival desc (the tie-break that picks
991 // callee-saved for the survivor), then slot asc for determinism.
992 b.1.cmp(&a.1)
993 .then_with(|| call_surv.get(&b.0).cmp(&call_surv.get(&a.0)))
994 .then(a.0.cmp(&b.0))
995 });
996 (order, max_slot)
997}
998
999/// Every slot an op READS (its use set), for backward liveness. This enumerates
1000/// the READ operands by POSITION (not by value), so a self-referential op like
1001/// `Add { dst: i, lhs: i, rhs: 1 }` (`i = i + 1`) correctly reports `i` as a USE
1002/// even though it is also the destination — a value-based "all-slots minus dst"
1003/// would wrongly drop it. Every op in the supported subset has a pure write-only
1004/// destination (the operands `lhs`/`rhs`/`src`/`a`/`b`/`c`/`idx`/`cond`/… are the
1005/// reads); `dst` is never itself read by the op. A self-call READS `limit_slot`
1006/// (and a `CallSelfCopy`'s source args) and WRITES `dst`. An
1007/// `ArrStore`/`ArrPush`/`ListClear`/`ListTriple` has no `dst`, so all its slots
1008/// are reads. Used ONLY by the per-call-site spill-liveness lever (a pure
1009/// scheduling optimization): a missed read here would be UNSOUND, so this is the
1010/// authoritative read set — derived as `slots_of` (read ∪ write) minus the
1011/// POSITIONAL destination, by removing exactly the dst's first occurrence is
1012/// wrong; instead we list reads directly.
1013fn read_slots_of(op: &MicroOp, out: &mut Vec<Slot>) {
1014 match *op {
1015 MicroOp::Move { src, .. } | MicroOp::NotInt { src, .. } | MicroOp::NotBool { src, .. } => {
1016 out.push(src)
1017 }
1018 MicroOp::LoadConst { .. } => {}
1019 MicroOp::Add { lhs, rhs, .. }
1020 | MicroOp::Sub { lhs, rhs, .. }
1021 | MicroOp::Mul { lhs, rhs, .. }
1022 | MicroOp::Lt { lhs, rhs, .. }
1023 | MicroOp::Gt { lhs, rhs, .. }
1024 | MicroOp::LtEq { lhs, rhs, .. }
1025 | MicroOp::GtEq { lhs, rhs, .. }
1026 | MicroOp::Eq { lhs, rhs, .. }
1027 | MicroOp::Neq { lhs, rhs, .. }
1028 | MicroOp::BitAnd { lhs, rhs, .. }
1029 | MicroOp::BitOr { lhs, rhs, .. }
1030 | MicroOp::BitXor { lhs, rhs, .. }
1031 | MicroOp::Shl { lhs, rhs, .. }
1032 | MicroOp::Shr { lhs, rhs, .. }
1033 | MicroOp::Div { lhs, rhs, .. }
1034 | MicroOp::Mod { lhs, rhs, .. }
1035 | MicroOp::AddF { lhs, rhs, .. }
1036 | MicroOp::SubF { lhs, rhs, .. }
1037 | MicroOp::MulF { lhs, rhs, .. }
1038 | MicroOp::DivF { lhs, rhs, .. }
1039 | MicroOp::LtF { lhs, rhs, .. }
1040 | MicroOp::GtF { lhs, rhs, .. }
1041 | MicroOp::LtEqF { lhs, rhs, .. }
1042 | MicroOp::GtEqF { lhs, rhs, .. }
1043 | MicroOp::EqF { lhs, rhs, .. }
1044 | MicroOp::NeqF { lhs, rhs, .. } => out.extend([lhs, rhs]),
1045 MicroOp::FmaF { a, b, c, .. } => out.extend([a, b, c]),
1046 MicroOp::IntToFloat { src, .. } | MicroOp::SqrtF { src, .. } => out.push(src),
1047 MicroOp::DivPow2 { lhs, .. } => out.push(lhs),
1048 MicroOp::MagicDivU { lhs, .. } => out.push(lhs),
1049 MicroOp::Branch { lhs, rhs, .. } | MicroOp::BranchF { lhs, rhs, .. } => out.extend([lhs, rhs]),
1050 MicroOp::JumpIfFalse { cond, .. } | MicroOp::JumpIfTrue { cond, .. } => out.push(cond),
1051 MicroOp::Return { src } => out.push(src),
1052 MicroOp::ArrLoad { idx, ptr_slot, len_slot, checked, .. } => {
1053 out.extend([idx, ptr_slot]);
1054 if checked {
1055 out.push(len_slot);
1056 }
1057 }
1058 MicroOp::ArrStore { src, idx, ptr_slot, len_slot, checked, .. } => {
1059 out.extend([src, idx, ptr_slot]);
1060 if checked {
1061 out.push(len_slot);
1062 }
1063 }
1064 MicroOp::ArrPush { src, vec_slot, ptr_slot, len_slot, .. } => {
1065 out.extend([src, vec_slot, ptr_slot, len_slot])
1066 }
1067 MicroOp::ListClear { vec_slot, ptr_slot, len_slot, .. } => {
1068 out.extend([vec_slot, ptr_slot, len_slot])
1069 }
1070 MicroOp::StrAppend { text_handle_slot, src, .. } => {
1071 out.push(text_handle_slot);
1072 if let crate::jit::StrSrc::Byte(s) = src {
1073 out.push(s);
1074 }
1075 }
1076 MicroOp::Call { limit_slot, .. } => out.push(limit_slot),
1077 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
1078 out.extend([handle_slot, vec_slot, ptr_slot, len_slot])
1079 }
1080 MicroOp::CallSelf { limit_slot, .. } => out.push(limit_slot),
1081 MicroOp::CallSelfCopy { src_start, arg_count, limit_slot, .. } => {
1082 out.push(limit_slot);
1083 for j in 0..arg_count {
1084 out.push(src_start + j);
1085 }
1086 }
1087 MicroOp::Jump { .. } => {}
1088 _ => {}
1089 }
1090}
1091
1092/// Backward LIVENESS: `live_after[i]` is the set of slots that may be READ on
1093/// some control-flow path AFTER op `i` finishes (before being overwritten). A
1094/// standard iterative backward dataflow to a fixpoint over the op stream's CFG
1095/// (fall-through `i+1` plus the branch/jump TARGET of `i`):
1096///
1097/// live_in[i] = uses(i) ∪ (live_out[i] \ {def(i)})
1098/// live_out[i] = ⋃ live_in[succ] over the successors of i
1099///
1100/// This is a SOUND OVER-approximation of "definitely dead after the call": every
1101/// slot a later op (on any path, including a loop back-edge) reads is marked
1102/// live, and an unconditional `Jump`/`Return` has no fall-through successor. The
1103/// result drives the spill-liveness lever, which spills/reloads only the
1104/// caller-saved residents that are live AFTER a call site; a slot NOT in
1105/// `live_after[call]` is read by no later op on any path (so the success-path
1106/// reload is dead) and is never observed by a deopt resume either (classic
1107/// replay recomputes mid-call temps from the boundary args; a precise resume at
1108/// a later op re-boxes only its own live slots — a subset of these). Eliding its
1109/// spill is therefore bit-identical. Returns one bitset (`Vec<bool>` over
1110/// `0..=max_slot`) per op.
1111fn liveness_after(ops: &[MicroOp], max_slot: usize) -> Vec<Vec<bool>> {
1112 let n = ops.len();
1113 let width = max_slot + 1;
1114 let mut live_in: Vec<Vec<bool>> = vec![vec![false; width]; n];
1115 // Precompute each op's use set and def (stable across iterations).
1116 let mut uses: Vec<Vec<Slot>> = Vec::with_capacity(n);
1117 let mut defs: Vec<Option<Slot>> = Vec::with_capacity(n);
1118 for op in ops {
1119 let mut u = Vec::new();
1120 read_slots_of(op, &mut u);
1121 uses.push(u);
1122 defs.push(dest_of(op));
1123 }
1124
1125 let mut changed = true;
1126 while changed {
1127 changed = false;
1128 // Process in reverse for faster convergence (data flows backward).
1129 for i in (0..n).rev() {
1130 // live_out[i] = union of live_in over successors.
1131 let mut out = vec![false; width];
1132 // Fall-through successor (i+1) unless this op is an unconditional
1133 // transfer (Jump has no fall-through; Return terminates).
1134 let unconditional_transfer = matches!(ops[i], MicroOp::Jump { .. } | MicroOp::Return { .. });
1135 if !unconditional_transfer {
1136 if let Some(succ) = live_in.get(i + 1) {
1137 for (o, &s) in out.iter_mut().zip(succ.iter()) {
1138 *o |= s;
1139 }
1140 }
1141 }
1142 // Branch/jump target successor.
1143 if let Some(t) = op_target(&ops[i]) {
1144 if let Some(succ) = live_in.get(t) {
1145 for (o, &s) in out.iter_mut().zip(succ.iter()) {
1146 *o |= s;
1147 }
1148 }
1149 }
1150 // live_in[i] = uses ∪ (live_out \ def).
1151 let mut new_in = out.clone();
1152 if let Some(d) = defs[i] {
1153 if (d as usize) < width {
1154 new_in[d as usize] = false;
1155 }
1156 }
1157 for &u in &uses[i] {
1158 if (u as usize) < width {
1159 new_in[u as usize] = true;
1160 }
1161 }
1162 if new_in != live_in[i] {
1163 live_in[i] = new_in;
1164 changed = true;
1165 }
1166 }
1167 }
1168
1169 // live_after[i] == live_out[i]: rebuild from the converged live_in.
1170 let mut live_after: Vec<Vec<bool>> = vec![vec![false; width]; n];
1171 for i in 0..n {
1172 let unconditional_transfer = matches!(ops[i], MicroOp::Jump { .. } | MicroOp::Return { .. });
1173 if !unconditional_transfer {
1174 if let Some(succ) = live_in.get(i + 1) {
1175 for (o, &s) in live_after[i].iter_mut().zip(succ.iter()) {
1176 *o |= s;
1177 }
1178 }
1179 }
1180 if let Some(t) = op_target(&ops[i]) {
1181 if let Some(succ) = live_in.get(t) {
1182 for (o, &s) in live_after[i].iter_mut().zip(succ.iter()) {
1183 *o |= s;
1184 }
1185 }
1186 }
1187 }
1188 live_after
1189}
1190
1191/// The condition for a `Cmp`.
1192fn cond_of(cmp: Cmp) -> Cond {
1193 match cmp {
1194 Cmp::Lt => Cond::Lt,
1195 Cmp::Gt => Cond::Gt,
1196 Cmp::LtEq => Cond::Le,
1197 Cmp::GtEq => Cond::Ge,
1198 Cmp::Eq => Cond::Eq,
1199 Cmp::NotEq => Cond::Ne,
1200 }
1201}
1202
1203/// The register CLASS a slot may be resident in. A slot that appears in BOTH an
1204/// integer role and a float role (or only ever as a raw-bits/neutral operand) is
1205/// kept frame-resident, where the two classes share the same 8 raw bytes.
1206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1207enum Class {
1208 /// Used only in integer roles → eligible for a GP register.
1209 Int,
1210 /// Used only in float (f64) roles → eligible for an XMM register.
1211 Float,
1212 /// Used in both classes (or class-ambiguous): frame-resident only.
1213 Mixed,
1214}
1215
1216/// Per-slot role tally for [`Class`] assignment.
1217#[derive(Clone, Copy, Default)]
1218struct Roles {
1219 int: bool,
1220 float: bool,
1221}
1222
1223/// Mark every slot an op touches with its INT and/or FLOAT roles. Type-NEUTRAL
1224/// roles (a `Move`/`LoadConst`/`Return` operand, an `ArrLoad` dst / `ArrStore`
1225/// src — all raw-bits transfers) impose NO class; the slot's class comes from
1226/// the strongly-typed ops it also appears in. A slot with no typed role at all
1227/// defaults to INT (the historical behavior).
1228fn tally_roles(op: &MicroOp, roles: &mut [Roles]) {
1229 macro_rules! mark_int {
1230 ($s:expr) => {
1231 roles[$s as usize].int = true
1232 };
1233 }
1234 match *op {
1235 // Integer arithmetic / compare / bitwise / shift: all operands + dst int.
1236 MicroOp::Add { dst, lhs, rhs }
1237 | MicroOp::Sub { dst, lhs, rhs }
1238 | MicroOp::Mul { dst, lhs, rhs }
1239 | MicroOp::Div { dst, lhs, rhs }
1240 | MicroOp::Mod { dst, lhs, rhs }
1241 | MicroOp::Lt { dst, lhs, rhs }
1242 | MicroOp::Gt { dst, lhs, rhs }
1243 | MicroOp::LtEq { dst, lhs, rhs }
1244 | MicroOp::GtEq { dst, lhs, rhs }
1245 | MicroOp::Eq { dst, lhs, rhs }
1246 | MicroOp::Neq { dst, lhs, rhs }
1247 | MicroOp::BitAnd { dst, lhs, rhs }
1248 | MicroOp::BitOr { dst, lhs, rhs }
1249 | MicroOp::BitXor { dst, lhs, rhs }
1250 | MicroOp::Shl { dst, lhs, rhs }
1251 | MicroOp::Shr { dst, lhs, rhs } => {
1252 mark_int!(dst);
1253 mark_int!(lhs);
1254 mark_int!(rhs);
1255 }
1256 MicroOp::DivPow2 { dst, lhs, .. } | MicroOp::MagicDivU { dst, lhs, .. } => {
1257 mark_int!(dst);
1258 mark_int!(lhs);
1259 }
1260 MicroOp::NotInt { dst, src } | MicroOp::NotBool { dst, src } => {
1261 mark_int!(dst);
1262 mark_int!(src);
1263 }
1264 MicroOp::Branch { lhs, rhs, .. } => {
1265 mark_int!(lhs);
1266 mark_int!(rhs);
1267 }
1268 MicroOp::JumpIfFalse { cond, .. } | MicroOp::JumpIfTrue { cond, .. } => mark_int!(cond),
1269 // Array address machinery is INT; the loaded/stored value is NEUTRAL.
1270 MicroOp::ArrLoad { idx, ptr_slot, len_slot, checked, .. } => {
1271 mark_int!(idx);
1272 mark_int!(ptr_slot);
1273 if checked {
1274 mark_int!(len_slot);
1275 }
1276 }
1277 MicroOp::ArrStore { idx, ptr_slot, len_slot, checked, .. } => {
1278 mark_int!(idx);
1279 mark_int!(ptr_slot);
1280 if checked {
1281 mark_int!(len_slot);
1282 }
1283 }
1284 // The list-mutation handle slots (vec/ptr/len) are INT machinery; the
1285 // pushed VALUE is NEUTRAL (its raw bits travel to the buffer regardless
1286 // of class — a float-list push bit-copies through a GP reg). The handle
1287 // slots are also forced frame-resident, so their class is moot, but
1288 // marking them INT keeps the role tally accurate.
1289 MicroOp::ArrPush { vec_slot, ptr_slot, len_slot, .. } => {
1290 mark_int!(vec_slot);
1291 mark_int!(ptr_slot);
1292 mark_int!(len_slot);
1293 }
1294 MicroOp::ListClear { vec_slot, ptr_slot, len_slot, .. } => {
1295 mark_int!(vec_slot);
1296 mark_int!(ptr_slot);
1297 mark_int!(len_slot);
1298 }
1299 // Float arithmetic: all operands + dst float.
1300 MicroOp::AddF { dst, lhs, rhs }
1301 | MicroOp::SubF { dst, lhs, rhs }
1302 | MicroOp::MulF { dst, lhs, rhs }
1303 | MicroOp::DivF { dst, lhs, rhs } => {
1304 roles[dst as usize].float = true;
1305 roles[lhs as usize].float = true;
1306 roles[rhs as usize].float = true;
1307 }
1308 // Float compares: operands float, RESULT (dst) is an int 0/1.
1309 MicroOp::LtF { dst, lhs, rhs }
1310 | MicroOp::GtF { dst, lhs, rhs }
1311 | MicroOp::LtEqF { dst, lhs, rhs }
1312 | MicroOp::GtEqF { dst, lhs, rhs }
1313 | MicroOp::EqF { dst, lhs, rhs }
1314 | MicroOp::NeqF { dst, lhs, rhs } => {
1315 roles[dst as usize].int = true;
1316 roles[lhs as usize].float = true;
1317 roles[rhs as usize].float = true;
1318 }
1319 MicroOp::BranchF { lhs, rhs, .. } => {
1320 roles[lhs as usize].float = true;
1321 roles[rhs as usize].float = true;
1322 }
1323 MicroOp::FmaF { dst, a, b, c } => {
1324 roles[dst as usize].float = true;
1325 roles[a as usize].float = true;
1326 roles[b as usize].float = true;
1327 roles[c as usize].float = true;
1328 }
1329 MicroOp::SqrtF { dst, src } => {
1330 roles[dst as usize].float = true;
1331 roles[src as usize].float = true;
1332 }
1333 // IntToFloat: src is INT, dst is FLOAT.
1334 MicroOp::IntToFloat { dst, src } => {
1335 roles[dst as usize].float = true;
1336 mark_int!(src);
1337 }
1338 // A self-call: `dst` (the result) and `limit_slot` (the arena bound) are
1339 // INT. The staged scalar args are 8-byte raw copies (NEUTRAL — the copy
1340 // preserves their bits regardless of class), so they impose no class
1341 // here; their class comes from the ops that produced them.
1342 MicroOp::CallSelf { dst, limit_slot, .. } => {
1343 mark_int!(dst);
1344 mark_int!(limit_slot);
1345 }
1346 MicroOp::CallSelfCopy { dst, limit_slot, .. } => {
1347 mark_int!(dst);
1348 mark_int!(limit_slot);
1349 }
1350 // The mode-B precise self-call: `dst` (the result handle, an i64) and
1351 // `limit_slot` are INT. The staged args are NEUTRAL raw copies (their
1352 // class comes from the producing ops).
1353 MicroOp::Call { dst, limit_slot, .. } => {
1354 mark_int!(dst);
1355 mark_int!(limit_slot);
1356 }
1357 // List-machinery handle/triple slots are INT (raw `*mut Vec` words);
1358 // they are also forced frame-resident, so the class is moot, but the
1359 // tally stays accurate.
1360 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
1361 mark_int!(handle_slot);
1362 mark_int!(vec_slot);
1363 mark_int!(ptr_slot);
1364 mark_int!(len_slot);
1365 }
1366 // The str-append handle (a raw `*mut Value` word) and a byte-form value
1367 // (an ASCII byte) are INT. The handle is forced frame-resident, so its
1368 // class is moot, but the tally stays accurate.
1369 MicroOp::StrAppend { text_handle_slot, src, .. } => {
1370 mark_int!(text_handle_slot);
1371 if let crate::jit::StrSrc::Byte(s) = src {
1372 mark_int!(s);
1373 }
1374 }
1375 // Move / LoadConst / Return are NEUTRAL — no class imposed.
1376 MicroOp::Move { .. }
1377 | MicroOp::LoadConst { .. }
1378 | MicroOp::Return { .. }
1379 | MicroOp::Jump { .. } => {}
1380 _ => {}
1381 }
1382}
1383
1384/// Classify every slot up to `max_slot` into its register [`Class`].
1385fn classify_slots(ops: &[MicroOp], max_slot: usize) -> Vec<Class> {
1386 let mut roles = vec![Roles::default(); max_slot + 1];
1387 for op in ops {
1388 tally_roles(op, &mut roles);
1389 }
1390 roles
1391 .iter()
1392 .map(|r| match (r.int, r.float) {
1393 (_, true) if r.int => Class::Mixed,
1394 (false, true) => Class::Float,
1395 // int-only OR neutral-only (default int).
1396 _ => Class::Int,
1397 })
1398 .collect()
1399}
1400
1401/// A slot's coarse LIVE INTERVAL as a single `[start, end]` span over op indices:
1402/// `start` = the first op that defines or uses it, `end` = the last op that uses
1403/// or defines it. This omits holes (a slot dead in the middle of its span still
1404/// occupies it), so the max overlap of these intervals is an UPPER BOUND on true
1405/// register pressure — the pessimistic input a classic linear-scan allocator
1406/// consumes. Used by the linear-scan SIMULATION the class diagnostic reports, to
1407/// confirm a simple interval allocator would reclaim the spills the whole-region
1408/// per-slot model leaves on the table.
1409#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1410struct LiveInterval {
1411 slot: u16,
1412 start: usize,
1413 end: usize,
1414}
1415
1416/// One coarse `[first-touch, last-touch]` interval per slot the op stream reads
1417/// or writes. A slot never touched gets no interval (it needs no register).
1418fn live_intervals(ops: &[MicroOp], max_slot: usize) -> Vec<LiveInterval> {
1419 let mut first = vec![usize::MAX; max_slot + 1];
1420 let mut last = vec![0usize; max_slot + 1];
1421 let mut reads = Vec::new();
1422 for (i, op) in ops.iter().enumerate() {
1423 reads.clear();
1424 read_slots_of(op, &mut reads);
1425 let mut touch = |s: Slot| {
1426 let s = s as usize;
1427 if s <= max_slot {
1428 if first[s] == usize::MAX {
1429 first[s] = i;
1430 }
1431 last[s] = i;
1432 }
1433 };
1434 for &s in &reads {
1435 touch(s);
1436 }
1437 if let Some(d) = dest_of(op) {
1438 touch(d);
1439 }
1440 }
1441 (0..=max_slot)
1442 .filter(|&s| first[s] != usize::MAX)
1443 .map(|s| LiveInterval { slot: s as u16, start: first[s], end: last[s] })
1444 .collect()
1445}
1446
1447/// Classic linear-scan over the coarse intervals of ONE register class: how many
1448/// of that class's intervals would SPILL given `regs` physical registers,
1449/// reusing a register the instant an interval expires. The spill victim on
1450/// overflow is the active interval whose end is farthest (the textbook
1451/// heuristic) — but the COUNT (what we report) is heuristic-independent: it is
1452/// `intervals − (intervals placeable in `regs` colors)`, i.e. one spill per
1453/// point where more than `regs` intervals overlap. This is the number the
1454/// whole-region per-slot model fails to achieve (it spills every slot beyond the
1455/// top-`regs` ranked, even when their ranges never overlap).
1456fn linscan_spills(intervals: &[LiveInterval], class: &[Class], want: Class, regs: usize) -> usize {
1457 let mut iv: Vec<&LiveInterval> =
1458 intervals.iter().filter(|i| class[i.slot as usize] == want).collect();
1459 iv.sort_by_key(|i| (i.start, i.end));
1460 // `active` ends, kept sorted ascending so the farthest end is last.
1461 let mut active: Vec<usize> = Vec::new();
1462 let mut spills = 0usize;
1463 for cur in iv {
1464 active.retain(|&e| e >= cur.start); // expire intervals that ended before cur starts
1465 if regs == 0 {
1466 spills += 1;
1467 continue;
1468 }
1469 if active.len() < regs {
1470 let pos = active.partition_point(|&e| e <= cur.end);
1471 active.insert(pos, cur.end);
1472 } else {
1473 // Pool full: spill the farthest-ending of {actives ∪ cur}.
1474 let far = *active.last().unwrap();
1475 if far > cur.end {
1476 active.pop();
1477 let pos = active.partition_point(|&e| e <= cur.end);
1478 active.insert(pos, cur.end);
1479 }
1480 spills += 1;
1481 }
1482 }
1483 spills
1484}
1485
1486/// Per-slot loop-aware live RANGE `[start, end]` (op indices), or `None` if the
1487/// slot is never touched. Built from the `liveness_after` backward fixpoint, so a
1488/// loop-carried slot — live on the back-edge path at every body op — spans the
1489/// whole loop automatically (min..max over its live points), with NO explicit
1490/// loop-extension step. This is the SOUND interval the real allocator consumes
1491/// (unlike `live_intervals`, naive first/last touch, used only by the diagnostic).
1492fn live_ranges(ops: &[MicroOp], max_slot: usize) -> Vec<Option<(usize, usize)>> {
1493 let la = liveness_after(ops, max_slot);
1494 let mut start = vec![usize::MAX; max_slot + 1];
1495 let mut end = vec![0usize; max_slot + 1];
1496 let mut reads = Vec::new();
1497 for i in 0..ops.len() {
1498 // A slot OCCUPIES op i if it is live out of i, used at i, or defined at i
1499 // (live_in is uses ∪ (live_after \ def), all subsumed by these three).
1500 for s in 0..=max_slot {
1501 if la[i][s] {
1502 if start[s] == usize::MAX {
1503 start[s] = i;
1504 }
1505 end[s] = i;
1506 }
1507 }
1508 reads.clear();
1509 read_slots_of(&ops[i], &mut reads);
1510 let mut touch = |s: usize| {
1511 if s <= max_slot {
1512 if start[s] == usize::MAX {
1513 start[s] = i;
1514 }
1515 if i > end[s] {
1516 end[s] = i;
1517 }
1518 }
1519 };
1520 for &u in &reads {
1521 touch(u as usize);
1522 }
1523 if let Some(d) = dest_of(&ops[i]) {
1524 touch(d as usize);
1525 }
1526 }
1527 (0..=max_slot)
1528 .map(|s| (start[s] != usize::MAX).then_some((start[s], end[s])))
1529 .collect()
1530}
1531
1532/// LINEAR-SCAN ASSIGNMENT (the LS1 keystone). Assign each slot a physical
1533/// register of its class for its whole loop-aware live range, REUSING a register
1534/// the instant its previous holder's range expires — so disjoint temporaries
1535/// share registers instead of each consuming a whole-region frame home. Returns a
1536/// per-slot [`Loc`]; a slot that finds no register stays `Loc::Frame` (exactly the
1537/// current behavior, so the fallback is bit-identical).
1538///
1539/// SOUNDNESS — register REUSE is permitted only across a STRAIGHT-LINE gap: a
1540/// register freed at op `e` may host a new range starting at `s` only when no
1541/// control point (a branch/jump OR a jump target) lies in `(e, s]`. That confines
1542/// every register reassignment to a single-predecessor, single-successor stretch,
1543/// so the binding is identical on every path reaching it — no inconsistent state
1544/// at a control join. Overlapping ranges never share (a holder is freed only once
1545/// its end is strictly before the newcomer's start). Loop-carried slots span the
1546/// whole loop (their range covers every body op), so their register is never
1547/// reused mid-loop. The caller gates this to no-deopt / no-call regions so there
1548/// is no mid-region side-exit whose flush would observe a transient binding.
1549fn linscan_assign(ops: &[MicroOp], max_slot: usize, class: &[Class], gp: &[Reg], xmm: &[Xmm]) -> Vec<Loc> {
1550 let n = ops.len();
1551 let ranges = live_ranges(ops, max_slot);
1552 // Control-point prefix sum: ctrl[j] = 1 if op j transfers OR is a jump target.
1553 let mut is_jt = vec![false; n];
1554 for op in ops {
1555 if let Some(t) = op_target(op) {
1556 if t < n {
1557 is_jt[t] = true;
1558 }
1559 }
1560 }
1561 let mut pref = vec![0u32; n + 1];
1562 for j in 0..n {
1563 pref[j + 1] = pref[j] + u32::from(op_target(&ops[j]).is_some() || is_jt[j]);
1564 }
1565 // Any control point in the half-open gap (e, s]?
1566 let ctrl_between = |e: usize, s: usize| -> bool {
1567 let lo = (e + 1).min(n);
1568 let hi = (s + 1).min(n);
1569 pref[hi].saturating_sub(pref[lo]) > 0
1570 };
1571 // Slots with a range, in start order (then end, then slot — deterministic).
1572 let mut order: Vec<usize> = (0..=max_slot).filter(|&s| ranges[s].is_some()).collect();
1573 order.sort_by_key(|&s| {
1574 let (a, b) = ranges[s].unwrap();
1575 (a, b, s)
1576 });
1577 // Fresh pools (never used) popped from the front; freed pools carry the op
1578 // index at which each register became free, for the gap test.
1579 let mut gp_fresh: Vec<Reg> = gp.iter().rev().copied().collect();
1580 let mut xmm_fresh: Vec<Xmm> = xmm.iter().rev().copied().collect();
1581 let mut gp_freed: Vec<(Reg, usize)> = Vec::new();
1582 let mut xmm_freed: Vec<(Xmm, usize)> = Vec::new();
1583 let mut active: Vec<(usize, usize)> = Vec::new(); // (end, slot)
1584 let mut out = vec![Loc::Frame; max_slot + 1];
1585 for &s in &order {
1586 let (st, en) = ranges[s].unwrap();
1587 // Expire holders whose range ended strictly before this one starts.
1588 let mut still = Vec::with_capacity(active.len());
1589 for &(ae, asl) in &active {
1590 if ae < st {
1591 match out[asl] {
1592 Loc::Reg(r) => gp_freed.push((r, ae)),
1593 Loc::Xmm(x) => xmm_freed.push((x, ae)),
1594 Loc::Frame => {}
1595 }
1596 } else {
1597 still.push((ae, asl));
1598 }
1599 }
1600 active = still;
1601 // PREFER A FRESH register over reusing a freed one: a slot in a register
1602 // all to itself needs no spill-at-end, so as long as a never-used register
1603 // remains we pay no reuse overhead — reuse (and its one end-spill) kicks in
1604 // ONLY under real pressure, once the pool is exhausted. This makes a region
1605 // that already fits (<= pool size) bit-identical in COST to the classic
1606 // per-slot assignment, and reclaims frame spills only where they exist.
1607 match class[s] {
1608 Class::Int => {
1609 let pick = gp_fresh.pop().or_else(|| {
1610 gp_freed
1611 .iter()
1612 .position(|&(_, e)| !ctrl_between(e, st))
1613 .map(|i| gp_freed.swap_remove(i).0)
1614 });
1615 if let Some(r) = pick {
1616 out[s] = Loc::Reg(r);
1617 active.push((en, s));
1618 }
1619 }
1620 Class::Float => {
1621 let pick = xmm_fresh.pop().or_else(|| {
1622 xmm_freed
1623 .iter()
1624 .position(|&(_, e)| !ctrl_between(e, st))
1625 .map(|i| xmm_freed.swap_remove(i).0)
1626 });
1627 if let Some(x) = pick {
1628 out[s] = Loc::Xmm(x);
1629 active.push((en, s));
1630 }
1631 }
1632 Class::Mixed => {}
1633 }
1634 }
1635 out
1636}
1637
1638/// Per-self-call support: the label of the "bare return" epilogue that a
1639/// PROPAGATED in-callee deopt jumps to — flush the frame and return WITHOUT
1640/// touching the status cell (which already holds the inner exit code). The
1641/// per-call-site caller-saved spill/reload lists live on [`Gen`]
1642/// (`call_gp`/`call_xmm`), shared with the list-mutation helper-call path.
1643struct SelfCall {
1644 /// The bare flush+return epilogue (no status write) for deopt PROPAGATION.
1645 propagate_label: LabelId,
1646}
1647
1648/// PRECISE (mode-B) deopt support. Every checked op and the precise self-call's
1649/// guards side-exit with the op's ENCODED resume tag (`(pc << 2) | 3`) OR'd with
1650/// the live call depth in the high 32 bits, mirroring `logos_stencil_deopt_at` /
1651/// `logos_stencil_call_precise`. A checked op routes its bounds/divisor failure
1652/// to the out-of-line block for its code (`code_blocks[code]`), which stages the
1653/// tag in `S1` and jumps to the shared `epilogue`. The epilogue reads the live
1654/// depth, ORs it into the high bits, writes the status cell, flushes every
1655/// resident-written slot (so the precise-deopt walk reads the right frame), and
1656/// returns.
1657struct Precise<'a> {
1658 /// The per-op resume codes (parallel to the op stream).
1659 codes: &'a [i64],
1660 /// Distinct non-plain code → its out-of-line tag-staging block label (each
1661 /// block stages the tag in `S1` and jumps to the shared precise epilogue).
1662 code_blocks: &'a std::collections::HashMap<i64, LabelId>,
1663}
1664
1665/// The codegen context: the slot→location map plus the assembler.
1666struct Gen<'a> {
1667 asm: Asm,
1668 /// Per-slot location (GP reg / XMM reg / frame). The location already
1669 /// encodes the slot's register class, so Move/LoadConst/array transfers read
1670 /// `loc` directly to decide whether to bridge between the GP and XMM classes.
1671 /// OWNED (not borrowed) so the linear-scan path can MUTATE a slot's home as
1672 /// the emission walks the ops — a register cache reused across disjoint live
1673 /// ranges (a reused slot is spilled to its frame home and set `Frame` at its
1674 /// interval end). The classic per-slot path sets it once and never mutates.
1675 loc: Vec<Loc>,
1676 /// Op-index → label, for jump targets.
1677 op_labels: &'a [LabelId],
1678 /// The label of the deopt epilogue (set status = 1, flush, return), if any.
1679 deopt_label: Option<LabelId>,
1680 /// Stack-pointer realignment padding applied in the prologue (so RSP is
1681 /// 16-aligned at every `call`) and undone in every epilogue. 0 or 8.
1682 stack_pad: i32,
1683 /// Self-call support (present only for the FUNCTION backend with self-calls).
1684 self_call: Option<SelfCall>,
1685 /// PER-CALL-SITE caller-saved GP residents to spill/reload around the SysV
1686 /// call at op index `i` — `call_gp[i]` is the global caller-saved-GP-resident
1687 /// universe INTERSECTED with the slots LIVE AFTER op `i` (so a resident dead
1688 /// after the call is not spilled/reloaded). Empty for a non-call op. The XMM
1689 /// counterpart lives in `call_xmm`.
1690 call_gp: &'a [Vec<Slot>],
1691 /// PER-CALL-SITE XMM residents to spill/reload around the call at op `i` (all
1692 /// XMM are caller-saved). The live-after subset, parallel to `call_gp`.
1693 call_xmm: &'a [Vec<Slot>],
1694 /// PRECISE (mode-B) deopt support; `None` for the classic region/function
1695 /// paths (which replay from head). When `Some`, every checked op's side exit
1696 /// and the precise self-call's guards route through it.
1697 precise: Option<Precise<'a>>,
1698 /// SCALED-INDEX CSE (Wave 26). Reserved caller-saved register(s) that hold a
1699 /// run's shared 0-based index `im1 = idx - 1` (`im1_reg`) and, when a second
1700 /// register is free, its scaled byte offset `im1 * 8` (`scaled_reg`). Both
1701 /// are `None` when no caller-saved register is free (CSE disabled for the
1702 /// region) or the kill-switch is off. The reserved registers are never
1703 /// assigned to a resident slot and are touched by NOTHING but [`emit_arr_addr`],
1704 /// so they stay live across intervening arithmetic — invalidated only when the
1705 /// index slot is reassigned, at a jump target, or across a SysV call.
1706 off_im1_reg: Option<Reg>,
1707 /// The second reserved register holding the scaled byte offset, when free.
1708 off_scaled_reg: Option<Reg>,
1709 /// The currently-cached run, or `None` when the cache is invalid. `byte`
1710 /// records the element stride the offset was scaled for (1 vs 8); a different
1711 /// stride for the same index slot is a cache miss (recompute). `verified`
1712 /// records whether the FIRST access of the run bounds-checked `im1` (so a
1713 /// later checked access may reuse the cached scaled offset soundly).
1714 off_cache: Option<OffCacheState>,
1715 /// LOOP-INVARIANT PTR/LEN HOIST (Wave 27a). The per-plan reserved registers
1716 /// holding the invariant `frame[ptr_slot]` (and `frame[len_slot]`) for a loop
1717 /// span. Each entry is one [`HoistPlan`] plus the callee-saved registers it
1718 /// claimed. `emit_arr_addr` consults [`Gen::hoisted`] to read the register
1719 /// instead of reloading the frame, but ONLY while codegen is inside the plan's
1720 /// span — the active-set is keyed per op index in the emit loop.
1721 hoists: &'a [HoistEntry],
1722 /// The ptr/len slots whose hoist registers are ACTIVE at the current op (the
1723 /// codegen is inside their span). Reset/recomputed per op in the emit loop;
1724 /// `emit_arr_addr` reads it to decide whether a given ptr/len slot is hoisted
1725 /// here. Indexed lookups are over a tiny vec (at most a few hoists per loop).
1726 active_hoists: Vec<usize>,
1727}
1728
1729/// A resolved [`HoistPlan`]: the plan plus the callee-saved registers reserved to
1730/// hold its invariant `frame[ptr_slot]` (`ptr_reg`) and, when `needs_len`, its
1731/// `frame[len_slot]` (`len_reg`). A plan that could not claim a register is not
1732/// materialized into a `HoistEntry` (it falls back to the per-access reload).
1733#[derive(Clone, Copy)]
1734struct HoistEntry {
1735 plan: HoistPlan,
1736 ptr_reg: Reg,
1737 len_reg: Option<Reg>,
1738}
1739
1740/// The live scaled-index cache: the reserved registers hold `im1`/`im1*8` for
1741/// this `idx` slot at this element stride.
1742#[derive(Clone, Copy)]
1743struct OffCacheState {
1744 idx: Slot,
1745 byte: bool,
1746}
1747
1748impl Gen<'_> {
1749 /// The label an op at index `idx` jumps to on a CHECKED side exit, or `None`
1750 /// when no deopt channel exists (an UNCHECKED op in a region with no checked
1751 /// op — the label is never referenced). In the classic paths this is the
1752 /// shared `deopt_label` (writes status = 1); in the PRECISE path it is the
1753 /// out-of-line tag-staging block for this op's code (which stages the precise
1754 /// tag and jumps to the precise epilogue). A precise op whose code is the
1755 /// plain marker `1` still uses `deopt_label`. The CHECKED-op emitters
1756 /// `.expect()` this where a missing label would be a real bug; the unchecked
1757 /// path never reads it.
1758 fn checked_exit(&self, idx: usize) -> Option<LabelId> {
1759 if let Some(p) = &self.precise {
1760 let code = p.codes[idx];
1761 if code != 1 {
1762 return Some(p.code_blocks[&code]);
1763 }
1764 }
1765 self.deopt_label
1766 }
1767
1768 /// Drop the scaled-index cache: the reserved register(s) no longer hold a
1769 /// usable `im1`/offset for any index. Called at a jump target (control could
1770 /// enter here with a stale register), across a SysV call (which clobbers the
1771 /// caller-saved reserved register), and whenever the cached index slot is
1772 /// reassigned (so the next access recomputes from the fresh index value).
1773 fn invalidate_off_cache(&mut self) {
1774 self.off_cache = None;
1775 }
1776
1777 /// Drop the cache if `op` writes the cached index slot. A write to the slot
1778 /// the cache keys off makes the held `im1` stale; the next access must
1779 /// recompute from the new index value. (A write to ANY other slot leaves the
1780 /// cached `im1` valid, so cross-arithmetic reuse is preserved.)
1781 fn invalidate_off_cache_if_writes_idx(&mut self, op: &MicroOp) {
1782 if let Some(state) = self.off_cache {
1783 if dest_of(op) == Some(state.idx) {
1784 self.off_cache = None;
1785 }
1786 }
1787 }
1788
1789 /// The hoisted register holding `frame[ptr_slot]` for an ACTIVE hoist of this
1790 /// ptr slot, or `None` (use the per-access frame reload). A ptr slot is the
1791 /// address base, so we key on it; the entry's `len_reg` is consulted via
1792 /// [`Gen::hoisted_len`]. Only entries whose span is active at the current op
1793 /// (recorded in `active_hoists`) are eligible — outside the span the register
1794 /// may hold a stale value (a different loop's array, or the slot was rewritten
1795 /// at the outer level), so it must not be read.
1796 fn hoisted_ptr(&self, ptr_slot: Slot) -> Option<Reg> {
1797 self.active_hoists
1798 .iter()
1799 .map(|&k| self.hoists[k])
1800 .find(|e| e.plan.ptr_slot == ptr_slot)
1801 .map(|e| e.ptr_reg)
1802 }
1803
1804 /// The hoisted register holding `frame[len_slot]` for an ACTIVE hoist whose
1805 /// `ptr_slot` matches (the array is identified by its ptr base), or `None`.
1806 /// `None` when the access is unchecked-only-hoisted (no `len_reg` reserved) —
1807 /// the checked path then reloads `len` from the frame as before.
1808 fn hoisted_len(&self, ptr_slot: Slot) -> Option<Reg> {
1809 self.active_hoists
1810 .iter()
1811 .map(|&k| self.hoists[k])
1812 .find(|e| e.plan.ptr_slot == ptr_slot)
1813 .and_then(|e| e.len_reg)
1814 }
1815}
1816
1817impl Gen<'_> {
1818 /// Spill the per-call-site live-after caller-saved residents (GP + XMM) for
1819 /// the call op at index `idx` to their frame slots before a SysV call: the
1820 /// callee clobbers all caller-saved registers. Reloaded by
1821 /// [`Gen::reload_volatiles_at`] after the call so loop-carried values survive.
1822 /// The `call_gp`/`call_xmm` lists are the LIVE-AFTER subset (a resident dead
1823 /// after the call is read by no later op and not observed by a deopt resume,
1824 /// so its spill is elided — bit-identical). The slices live on a separate
1825 /// borrow than `asm`, so this resolves the spill list before mutating the
1826 /// assembler.
1827 fn spill_volatiles_at(&mut self, idx: usize) {
1828 let gp: &[Slot] = self.call_gp.get(idx).map_or(&[], Vec::as_slice);
1829 for &s in gp {
1830 if let Loc::Reg(r) = self.loc[s as usize] {
1831 self.asm.mov_mr(BASE, (s as i32) * 8, r);
1832 }
1833 }
1834 let xmm: &[Slot] = self.call_xmm.get(idx).map_or(&[], Vec::as_slice);
1835 for &s in xmm {
1836 if let Loc::Xmm(x) = self.loc[s as usize] {
1837 self.asm.movsd_mr(BASE, (s as i32) * 8, x);
1838 }
1839 }
1840 }
1841
1842 /// Reload the per-call-site live-after residents for the call op at `idx`.
1843 fn reload_volatiles_at(&mut self, idx: usize) {
1844 let gp: &[Slot] = self.call_gp.get(idx).map_or(&[], Vec::as_slice);
1845 for &s in gp {
1846 if let Loc::Reg(r) = self.loc[s as usize] {
1847 self.asm.mov_rm(r, BASE, (s as i32) * 8);
1848 }
1849 }
1850 let xmm: &[Slot] = self.call_xmm.get(idx).map_or(&[], Vec::as_slice);
1851 for &s in xmm {
1852 if let Loc::Xmm(x) = self.loc[s as usize] {
1853 self.asm.movsd_rm(x, BASE, (s as i32) * 8);
1854 }
1855 }
1856 }
1857}
1858
1859impl<'a> Gen<'a> {
1860 /// Load slot `s` into GP register `r`. An XMM-resident slot is bit-copied
1861 /// via `movq` (defensive — INT-class slots never land in XMM).
1862 fn load(&mut self, r: Reg, s: Slot) {
1863 match self.loc[s as usize] {
1864 Loc::Reg(src) => self.asm.mov_rr(r, src),
1865 Loc::Frame => self.asm.mov_rm(r, BASE, (s as i32) * 8),
1866 Loc::Xmm(x) => self.asm.movq_rx(r, x),
1867 }
1868 }
1869
1870 /// Store GP register `r` into slot `s`. An XMM-resident slot is bit-copied
1871 /// via `movq` (defensive — INT-class slots never land in XMM).
1872 fn store(&mut self, s: Slot, r: Reg) {
1873 match self.loc[s as usize] {
1874 Loc::Xmm(x) => self.asm.movq_xr(x, r),
1875 Loc::Reg(dst) => self.asm.mov_rr(dst, r),
1876 Loc::Frame => self.asm.mov_mr(BASE, (s as i32) * 8, r),
1877 }
1878 }
1879
1880 /// Materialize slot `s` into a register usable as an operand, preferring its
1881 /// resident register (no move) and falling back to scratch `scratch`. Slot
1882 /// must be INT-class or frame-resident (an XMM-resident slot would be a
1883 /// classification bug — caught by `debug_assert`).
1884 fn operand(&mut self, s: Slot, scratch: Reg) -> Reg {
1885 match self.loc[s as usize] {
1886 Loc::Reg(r) => r,
1887 Loc::Frame => {
1888 self.asm.mov_rm(scratch, BASE, (s as i32) * 8);
1889 scratch
1890 }
1891 Loc::Xmm(_) => {
1892 debug_assert!(false, "int operand on an XMM-resident slot {s}");
1893 self.asm.mov_rm(scratch, BASE, (s as i32) * 8);
1894 scratch
1895 }
1896 }
1897 }
1898
1899 /// Load f64 slot `s` into XMM register `x`.
1900 fn fload(&mut self, x: Xmm, s: Slot) {
1901 match self.loc[s as usize] {
1902 Loc::Xmm(src) => self.asm.movsd_rr(x, src),
1903 Loc::Frame => self.asm.movsd_rm(x, BASE, (s as i32) * 8),
1904 Loc::Reg(r) => self.asm.movq_xr(x, r), // a Mixed slot can never be here; defensive bit-copy.
1905 }
1906 }
1907
1908 /// Store XMM register `x` into f64 slot `s`.
1909 fn fstore(&mut self, s: Slot, x: Xmm) {
1910 match self.loc[s as usize] {
1911 Loc::Xmm(dst) => self.asm.movsd_rr(dst, x),
1912 Loc::Frame => self.asm.movsd_mr(BASE, (s as i32) * 8, x),
1913 Loc::Reg(r) => self.asm.movq_rx(r, x), // defensive (Mixed never lands here).
1914 }
1915 }
1916
1917 /// Materialize f64 slot `s` into an XMM operand, preferring its resident XMM
1918 /// register and falling back to scratch `scratch`.
1919 fn foperand(&mut self, s: Slot, scratch: Xmm) -> Xmm {
1920 match self.loc[s as usize] {
1921 Loc::Xmm(x) => x,
1922 Loc::Frame => {
1923 self.asm.movsd_rm(scratch, BASE, (s as i32) * 8);
1924 scratch
1925 }
1926 Loc::Reg(r) => {
1927 self.asm.movq_xr(scratch, r);
1928 scratch
1929 }
1930 }
1931 }
1932}
1933
1934/// Compile a region of micro-ops into ONE contiguous register-allocated x86-64
1935/// function, callable through the [`crate::buffer::JitChain`] ABI. Returns
1936/// `None` (caller falls back to the stencil tier) when the region contains an
1937/// unsupported op or has no terminating `Return`/`Jump`.
1938///
1939/// A REGION never contains a call, so this gates on the call-free [`supported`]
1940/// subset and emits no self-call machinery.
1941pub fn compile_region_regalloc(
1942 ops: &[MicroOp],
1943 shared_status: Option<std::sync::Arc<AtomicI64>>,
1944) -> Option<CompiledChain> {
1945 compile_impl(ops, shared_status, false, 0, None)
1946}
1947
1948/// Compile a PRECISE REGION — the in-place-array-mutation shape that ALSO does a
1949/// reallocating `ArrPush` (the fannkuch permutation rebuild, the graph_bfs BFS
1950/// frontier) — into ONE contiguous register-allocated x86-64 region with PRECISE
1951/// deopt. Returns `None` (caller falls back to the per-piece precise stencil
1952/// tier) on any unsupported op or a missing terminator.
1953///
1954/// A precise region is the keystone Wave 13 (`ArrPush` in NON-precise regions)
1955/// and Wave 15 (PRECISE deopt for in-place-mutating recursion) each handled HALF
1956/// of: a `ListPush` that REALLOCS the pinned buffer coexisting with an in-place
1957/// `SetIndex` whose replay-from-head would double-apply. Under the classic
1958/// discard-replay deopt the truncate rolls the pushes back but the in-place
1959/// write persists, so the region must instead resume AT the faulting op (no
1960/// replay). [`compile_region_regalloc`] handles the reallocating push (the
1961/// helper refreshes the pinned ptr/len in the frame after a possible realloc),
1962/// but only with the CLASSIC deopt; this entry adds the per-op PRECISE deopt
1963/// codes so the push+SetIndex region is sound.
1964///
1965/// SOUNDNESS of the deopt resume (no double-apply, grown-array materialization):
1966/// every checked op's side exit stores its encoded resume pc `(pc << 2) | 3`
1967/// (depth in the high bits) through the shared status cell and the precise
1968/// epilogue FLUSHES every resident-written scalar to its frame slot — so the
1969/// VM's region precise resume reads each scalar from the frame and re-boxes it
1970/// by kind. The grown array needs NO materialization step: the push helper grew
1971/// the `Vec` IN PLACE inside the same `Rc<RefCell<…>>` the VM register still
1972/// holds, so the VM keeps that register's live value (the precise re-box kind is
1973/// `None` for a pinned array) and it already reflects the post-push buffer +
1974/// length. The resume pc is AFTER the push, so the bytecode never re-runs it —
1975/// no double-apply, no lost appended element. (The VM's per-array entry snapshot
1976/// / truncate rollback is gated on a CLASSIC `Deopt`, never a precise `DeoptAt`,
1977/// so completed pushes stand.)
1978///
1979/// `deopt_codes` is the per-op resume table the region adapter built (parallel
1980/// to `ops`): a plain `1` keeps the op on the ordinary deopt terminal; any other
1981/// value is the precise tag emitted on that op's side exit. `depth_addr` is the
1982/// live-depth cell whose value rides the resume tag's high 32 bits.
1983pub fn compile_region_regalloc_precise(
1984 ops: &[MicroOp],
1985 shared_status: Option<std::sync::Arc<AtomicI64>>,
1986 depth_addr: i64,
1987 deopt_codes: &[i64],
1988) -> Option<CompiledChain> {
1989 if deopt_codes.len() != ops.len() {
1990 return None;
1991 }
1992 // A precise region always needs the status channel — every checked op's side
1993 // exit stores its tagged resume value through it.
1994 if shared_status.is_none() {
1995 return None;
1996 }
1997 compile_impl(ops, shared_status, false, depth_addr, Some(deopt_codes))
1998}
1999
2000/// Compile a recursive FUNCTION's micro-op stream into ONE contiguous
2001/// register-allocated x86-64 function — including its DIRECT self-calls
2002/// (`CallSelf`/`CallSelfCopy`) — callable through the [`crate::buffer::JitChain`]
2003/// ABI. Returns `None` (caller falls back to the per-piece stencil tier) on any
2004/// unsupported op (a cross-function `Call`, list/map ops, byte arrays, …) or a
2005/// missing terminator.
2006///
2007/// The self-call is a REAL SysV `call` to this same chain's entry. The entry
2008/// address is unknown until the code is mapped, so it rides an `Arc<AtomicI64>`
2009/// ENTRY CELL whose address is baked into every call site (`mov rax,[cell];
2010/// call rax`) and which is written with `chain.base()` AFTER mapping — mirroring
2011/// `logos_stencil_call_self`'s patched-entry word (an unpatched `0` deopts). The
2012/// cell is kept alive by the returned [`CompiledChain`].
2013///
2014/// `depth_addr` is the live-depth cell's address (the caller's `ctx.depth`),
2015/// matching the function tier's wiring; the self-call increments/decrements it
2016/// and side-exits (status = 5) at [`SELF_CALL_DEPTH_LIMIT`].
2017pub fn compile_function_regalloc(
2018 ops: &[MicroOp],
2019 shared_status: Option<std::sync::Arc<AtomicI64>>,
2020 depth_addr: i64,
2021) -> Option<CompiledChain> {
2022 // A function with no self-call is just a region; route it through the
2023 // call-free path (no entry cell, no spill machinery) for uniformity.
2024 let has_self_call = ops
2025 .iter()
2026 .any(|op| matches!(op, MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. }));
2027 if !has_self_call {
2028 return compile_impl(ops, shared_status, true, depth_addr, None);
2029 }
2030 // A self-call always needs the status channel (its guards write it).
2031 if shared_status.is_none() {
2032 return None;
2033 }
2034 compile_impl(ops, shared_status, true, depth_addr, None)
2035}
2036
2037/// Compile a LIST-PARAMETER (mode-B) recursive FUNCTION — one that mutates a
2038/// SHARED pinned array in place and may self-call — into ONE contiguous
2039/// register-allocated x86-64 function with PRECISE deopt. Returns `None`
2040/// (caller falls back to the per-piece stencil tier) on any unsupported op.
2041///
2042/// Where [`compile_function_regalloc`] uses CLASSIC deopt (a side exit replays
2043/// the whole function from its boundary args — sound only when every effect is
2044/// confined to the private frame, the scalar mode-A case), a list-param
2045/// function's in-place array writes land in SHARED state, so a replay would
2046/// DOUBLE-APPLY them. This entry instead emits PRECISE deopt: every checked op
2047/// and the self-call's guards side-exit through the shared status cell with the
2048/// op's ENCODED resume value (`(bytecode_pc << 2) | 3 | (depth << 32)`), exactly
2049/// as `logos_stencil_deopt_at` / `logos_stencil_call_precise` do, so the VM
2050/// materializes the native call chain and resumes interpreting AT the faulting
2051/// op — every prior in-place mutation intact, never re-applied.
2052///
2053/// `deopt_codes` is the per-op resume table the function adapter built (parallel
2054/// to `ops`): a plain `1` keeps the op on the ordinary deopt terminal; any other
2055/// value is the precise tag emitted on that op's side exit.
2056///
2057/// SOUNDNESS: the precise contract requires that on a side exit the native
2058/// frame mirror the tree-walker's full register state. The backend therefore
2059/// keeps every mode-B machinery slot (the plant window/resume/dst, the pin
2060/// triples, the disjoint callee window) FRAME-RESIDENT, every `ListTriple`
2061/// HANDLE slot frame-resident (the helper reads/writes the frame), and FLUSHES
2062/// every resident-written VM register to its frame slot at every precise side
2063/// exit (so `materialize` reads the correct value). All slots at or above the
2064/// function's register count `rc` are mode-B machinery and stay frame-resident.
2065pub fn compile_function_regalloc_precise(
2066 ops: &[MicroOp],
2067 shared_status: Option<std::sync::Arc<AtomicI64>>,
2068 depth_addr: i64,
2069 deopt_codes: &[i64],
2070) -> Option<CompiledChain> {
2071 if deopt_codes.len() != ops.len() {
2072 return None;
2073 }
2074 // A precise function always needs the status channel (its checked ops and
2075 // self-call guards write the tagged value through it).
2076 if shared_status.is_none() {
2077 return None;
2078 }
2079 compile_impl(ops, shared_status, true, depth_addr, Some(deopt_codes))
2080}
2081
2082/// The VM register count `rc` of a mode-B precise function, INFERRED from the
2083/// op stream: the prologue's plant-window invalidation is `LoadConst { dst:
2084/// rc + 2, value: -1 }` (the adapter's first micro). Every slot `>= rc` is
2085/// mode-B machinery (plant slots, pin triples, the disjoint callee window) and
2086/// must stay frame-resident. Returns `None` if the stream does not open with
2087/// that recognizable prologue (then the precise path declines and the caller
2088/// falls back).
2089fn infer_mode_b_rc(ops: &[MicroOp]) -> Option<u16> {
2090 match ops.first() {
2091 Some(MicroOp::LoadConst { dst, value: -1 }) if *dst >= 2 => Some(dst - 2),
2092 _ => None,
2093 }
2094}
2095
2096/// The shared codegen core for both the region and the function backends.
2097/// `is_function` selects the op-support gate ([`supported_function`] adds the
2098/// self-call ops); `depth_addr` is the live-depth cell (function backend only).
2099fn compile_impl(
2100 ops: &[MicroOp],
2101 shared_status: Option<std::sync::Arc<AtomicI64>>,
2102 is_function: bool,
2103 depth_addr: i64,
2104 precise: Option<&[i64]>,
2105) -> Option<CompiledChain> {
2106 if ops.is_empty() {
2107 return None;
2108 }
2109 // SIMD: a recognized pure element-wise map region compiles to a 2-wide packed
2110 // loop. It has NO deopt paths (the recognizer requires `checked:false` Oracle-
2111 // proven-in-bounds accesses and pure float arithmetic), so it runs to
2112 // Completed and the VM resumes at the stored exit_pc — independent of the
2113 // `precise` mode-B machinery. Bit-identical to the scalar region (each lane is
2114 // the scalar op on that index); the differential gate certifies it. Default
2115 // OFF (`LOGOS_SIMD=1`).
2116 if !is_function && simd_enabled() {
2117 if let Some(plan) = crate::vectorize::recognize_elementwise_map(ops) {
2118 if let Some(code) =
2119 crate::vectorize::emit_map_kernel(&ops[plan.body_start..plan.body_end], &plan)
2120 {
2121 let chain = JitChain::from_code(&code, ops.len()).ok()?;
2122 return Some(CompiledChain::from_chain(chain, None));
2123 }
2124 }
2125 }
2126 // The PRECISE mode-B FUNCTION path needs the function's VM register count
2127 // `rc` to know which slots are machinery (the plant window/resume/dst, pin
2128 // triples, the disjoint callee window — every slot `>= rc`). Infer it from
2129 // the adapter's prologue micro; decline (fall back) if the stream does not
2130 // open with it. A precise REGION has NO mode-B machinery (no self-call plant
2131 // window — its in-place mutation + reallocating push flow through the
2132 // enclosing frame's pinned arrays, and the precise epilogue flushes every
2133 // resident-written scalar back to the frame for materialization), so it
2134 // leaves `mode_b_rc = None` and its scalars are register-allocated freely.
2135 // The classic paths also leave `mode_b_rc = None`.
2136 let mode_b_rc: Option<u16> = if precise.is_some() && is_function {
2137 Some(infer_mode_b_rc(ops)?)
2138 } else {
2139 None
2140 };
2141 let gate: fn(&MicroOp) -> bool = match (is_function, precise.is_some()) {
2142 (true, true) => supported_function_precise,
2143 (true, false) => supported_function,
2144 // A precise REGION uses the region op-support gate (`supported`, which
2145 // already admits the reallocating `ArrPush` and `ListClear` — W13); the
2146 // precise deopt codes ride the same per-op side-exit machinery as the
2147 // function path.
2148 _ => supported,
2149 };
2150 if !ops.iter().all(gate) {
2151 return None;
2152 }
2153 if !matches!(ops.last(), Some(MicroOp::Return { .. }) | Some(MicroOp::Jump { .. })) {
2154 return None;
2155 }
2156
2157 let has_self_call = ops
2158 .iter()
2159 .any(|op| matches!(op, MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. }));
2160 // The mode-B precise self-call is a REAL SysV call through the entry table.
2161 let has_precise_call = ops.iter().any(|op| matches!(op, MicroOp::Call { .. }));
2162
2163 // A list-mutation / list-machinery op (`ArrPush`/`ListClear`/`NewList`/
2164 // `ListTriple`) is a HELPER CALL into the JIT runtime — a real SysV `call`,
2165 // so it clobbers the caller-saved registers exactly like a self-call and
2166 // needs the same spill/reload machinery (the `gp_volatile`/`xmm_volatile`
2167 // lists + the stack-alignment pad). Treat ANY such helper call, not just a
2168 // self-call, as requiring the call ABI.
2169 let has_list_call = ops.iter().any(|op| {
2170 matches!(
2171 op,
2172 MicroOp::NewList { .. }
2173 | MicroOp::ArrPush { .. }
2174 | MicroOp::ListClear { .. }
2175 | MicroOp::ListTriple { .. }
2176 | MicroOp::StrAppend { .. }
2177 )
2178 });
2179 let has_call = has_self_call || has_list_call || has_precise_call;
2180
2181 // The vec/ptr/len handle TRIPLE of every list-mutation op MUST stay
2182 // FRAME-resident: the helper reads `frame[vec_slot]` and WRITES
2183 // `frame[ptr_slot]`/`frame[len_slot]` (refreshing them after a possible
2184 // realloc). A register-resident handle slot would be stale after the call
2185 // (the helper updates the frame, not the register) and would feed a wrong
2186 // pointer/length into the next access — the exact realloc-coherence trap.
2187 // Forcing the triple frame-resident makes the helper's frame writes the
2188 // single source of truth, so the next access reads the fresh pointer.
2189 let mut force_frame_set: std::collections::HashSet<Slot> = std::collections::HashSet::new();
2190 for op in ops {
2191 match *op {
2192 // `NewList` allocates a registry-owned fresh buffer and WRITES its
2193 // vec/ptr/len triple through the frame (the helper indexes the frame);
2194 // the same realloc-coherence rule as `ArrPush` requires the triple be
2195 // frame-resident so the helper's writes are the single source of
2196 // truth and every later push/load reads the fresh pointer/length.
2197 MicroOp::NewList { vec_slot, ptr_slot, len_slot, .. }
2198 | MicroOp::ArrPush { vec_slot, ptr_slot, len_slot, .. }
2199 | MicroOp::ListClear { vec_slot, ptr_slot, len_slot, .. } => {
2200 force_frame_set.insert(vec_slot);
2201 force_frame_set.insert(ptr_slot);
2202 force_frame_set.insert(len_slot);
2203 }
2204 // A triple-plant's HANDLE slot is read by the helper FROM THE FRAME,
2205 // and its vec/ptr/len triple is WRITTEN to the frame — all four must
2206 // be frame-resident so the helper's view and the next access agree.
2207 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
2208 force_frame_set.insert(handle_slot);
2209 force_frame_set.insert(vec_slot);
2210 force_frame_set.insert(ptr_slot);
2211 force_frame_set.insert(len_slot);
2212 }
2213 // The str-append's handle slot (a `*mut Value`) is read by the helper
2214 // FROM THE FRAME, so it must stay frame-resident (a register-resident
2215 // handle would not be seen by the SysV call). The accumulator itself
2216 // lives in the VM register file, off-frame — nothing else here needs
2217 // forcing.
2218 MicroOp::StrAppend { text_handle_slot, .. } => {
2219 force_frame_set.insert(text_handle_slot);
2220 }
2221 _ => {}
2222 }
2223 }
2224
2225 // The callee-window staging slots (`args_start..`) of a self-call MUST stay
2226 // FRAME-resident: the callee reads its parameters from its own frame window,
2227 // and the codegen stages them by writing those frame cells. A `CallSelfCopy`
2228 // also carries `frame_size` (the callee's full frame, = limit_slot + 1); the
2229 // window therefore spans `args_start .. args_start + frame_size`. Force every
2230 // slot at or above the lowest such window base to the frame.
2231 let mut force_frame_above: Option<u16> = None;
2232 for op in ops {
2233 let args_start = match *op {
2234 MicroOp::CallSelf { args_start, .. }
2235 | MicroOp::CallSelfCopy { args_start, .. }
2236 | MicroOp::Call { args_start, .. } => args_start,
2237 _ => continue,
2238 };
2239 // The callee window starts at args_start; everything at/above it stages
2240 // a callee frame slot and must stay frame-resident.
2241 force_frame_above = Some(force_frame_above.map_or(args_start, |a| a.min(args_start)));
2242 }
2243 // PRECISE (mode-B): EVERY slot at or above the VM register count `rc` is
2244 // machinery — the plant window/resume/dst, the pin triples and the disjoint
2245 // callee window. All must stay FRAME-resident: the precise-deopt walk
2246 // (`ChainFn::materialize`) reads each ancestor frame's plant slots and pin
2247 // handles straight from the frame, so a register copy would be stale. This
2248 // subsumes (and is stricter than) the per-call window forcing above.
2249 if let Some(rc) = mode_b_rc {
2250 force_frame_above = Some(force_frame_above.map_or(rc, |a| a.min(rc)));
2251 }
2252
2253 // Whether the program has any checked op needing a deopt channel (divisor
2254 // check, a checked array bounds check, or a self-call guard). When one is
2255 // present but the caller passed no shared cell, allocate a private one —
2256 // exactly what the stencil compilers do (`has_checked` ⇒ `unwrap_or_else`).
2257 let needs_status = ops.iter().any(needs_deopt);
2258 let status: Option<std::sync::Arc<AtomicI64>> = if needs_status {
2259 Some(shared_status.unwrap_or_else(|| std::sync::Arc::new(AtomicI64::new(0))))
2260 } else {
2261 shared_status
2262 };
2263
2264 // The largest slot index — sizes every per-slot vector below (loc / written
2265 // / liveness). It covers each self-call window's full extent because the
2266 // frame indexing allocates against it.
2267 let max_slot = max_slot_of(ops);
2268
2269 // LS1 — linear-scan register allocation gate. Enabled for NO-CALL classic
2270 // regions (the precise mode-B path is excluded). A CHECKED op (bounds/divisor)
2271 // is allowed: its classic side-exit replays from the head with prefix-
2272 // idempotence (native_tier.rs), so the deopt flush must hold each slot's value
2273 // AT the faulting op. Linear scan keeps that true by WRITING THROUGH every
2274 // REUSED slot to its frame home on each write (so the shared epilogue, which
2275 // skips it, finds the current value in the frame); NON-reused slots keep a
2276 // fixed register for their whole range and are flushed by the epilogue from
2277 // that register. Register reuse is confined to straight-line gaps
2278 // (`linscan_assign`), so no reassignment crosses a control join.
2279 let linscan_active = linear_scan_enabled() && !has_call && precise.is_none();
2280
2281 // Backward liveness, computed ONCE when the region has a SysV call and reused
2282 // by both the call-weight ranking (below) and the per-call-site spill-elision
2283 // pass (further down). `None` for a call-free region: there are no calls to
2284 // survive, so no bonus and no spill machinery. Computing it before the
2285 // ranking is what lets the ranking SEE which slots are live across each call.
2286 let live_after: Option<Vec<Vec<bool>>> = has_call.then(|| liveness_after(ops, max_slot));
2287
2288 // Rank slots by LOOP-WEIGHTED reference count, with CALL-LOCALITY as a
2289 // SECONDARY tie-break; assign the hottest to physical registers. A raw count
2290 // under-prices loop-carried values: a slot touched once per iteration of a
2291 // hot loop costs a memory round-trip on EVERY iteration, whereas a slot
2292 // touched many times in straight-line setup code costs a round-trip only that
2293 // once. The linear-scan loop weight (each reference at loop depth `d` worth
2294 // `LOOP_WEIGHT_BASE^d`) closes this so a back-edge value (nbody's dx/dy/dist,
2295 // mandelbrot's zr/zi) outranks colder slots and STAYS RESIDENT across
2296 // iterations. This PRIMARY key — and the resident-vs-spilled boundary it
2297 // induces — is identical to the pre-Fix-1 ranking.
2298 //
2299 // The call-weight breaks TIES among equal-primary-weight slots toward those
2300 // LIVE ACROSS a SysV call. Combined with the `has_call` pool order
2301 // (callee-saved first), a call-survivor wins a callee-saved register over a
2302 // call-dead slot of equal weight and pays NO per-call spill/reload — only a
2303 // one-time prologue push/pop, the dominant cost in a recursion-heavy loop
2304 // (fib / binary_trees / coins). Because it is only a tie-break it can never
2305 // evict a hotter loop slot, so it adds the steering without the spill-boundary
2306 // perturbation an additive bonus would cause.
2307 //
2308 // Ranking is semantically inert: it changes only WHICH register a resident
2309 // slot occupies (and, on a primary-key tie, which equally-cold slot spills). A
2310 // slot still lives in exactly one place for the whole function and its value
2311 // never changes, so the result is bit-identical to any other ranking.
2312 //
2313 // The base/cap mirror the per-piece tier's `select_pins` loop weight
2314 // (`BASE=16`, depth capped at 4) so both backends rank loop-carried slots on
2315 // the SAME frequency model. Every weight is `u64` and saturating, so even a
2316 // pathologically deep/large region cannot overflow the ranking key (a
2317 // saturated tie falls back to the call-survival then deterministic slot-index
2318 // order).
2319 const LOOP_WEIGHT_BASE: u64 = 16;
2320 const LOOP_DEPTH_CAP: u32 = 4;
2321 const CALL_WEIGHT: u64 = 4;
2322 // A zero call-weight zeroes the secondary key, reducing `rank_slots` to the
2323 // pure loop-weighted ranking — the kill-switch is exactly pre-Fix-1 behavior.
2324 let call_weight = if call_weight_enabled() { CALL_WEIGHT } else { 0 };
2325 let (order, _) = rank_slots(
2326 ops,
2327 LOOP_WEIGHT_BASE,
2328 LOOP_DEPTH_CAP,
2329 call_weight,
2330 live_after.as_deref(),
2331 );
2332
2333 // Classify each slot. INT-class slots compete for GP registers, FLOAT-class
2334 // slots for XMM registers; MIXED slots (used by both classes) stay frame-
2335 // resident. The two pools are disjoint, so the assignment is sound: a slot
2336 // is always in exactly one place, of the right class, for the whole function.
2337 let class = classify_slots(ops, max_slot);
2338
2339 // Register-pool ORDER. Pool order is semantically inert — it only decides
2340 // WHICH physical register a slot occupies, never where (a slot is still in
2341 // exactly one place for the whole function), so every ordering is
2342 // bit-identical. But it changes the prologue/epilogue and per-call spill
2343 // bill, so we order by what is cheapest for THIS region's call shape:
2344 //
2345 // - CALL-FREE region: a caller-saved register costs NOTHING (no prologue
2346 // push/pop, no spill — nothing clobbers it). A callee-saved register
2347 // costs a prologue `push` + epilogue `pop` (and a stack-alignment pad
2348 // risk). So prefer CALLER-SAVED first: a hot scalar loop (primes,
2349 // pi_leibniz, fib_iterative, the matrix/histogram inner loops) pays no
2350 // callee-save prologue/epilogue at all when it fits the 6 caller-saved
2351 // GP regs.
2352 // - region/function WITH a SysV call (self-call or list-mutation helper):
2353 // a caller-saved resident is clobbered by every call, so it is
2354 // spilled+reloaded around each one (gp_volatile below) — a pair of
2355 // memory ops per call. A callee-saved resident survives the call for the
2356 // one-time prologue/epilogue push/pop. In a call-heavy loop (fib /
2357 // binary_trees / coins recursion) the per-call spill pair dominates the
2358 // one-time save, so prefer CALLEE-SAVED first.
2359 let mut pool: Vec<Reg> = Vec::new();
2360 if has_call {
2361 pool.extend_from_slice(&CALLEE_SAVED);
2362 pool.extend_from_slice(&CALLER_SAVED);
2363 } else {
2364 pool.extend_from_slice(&CALLER_SAVED);
2365 pool.extend_from_slice(&CALLEE_SAVED);
2366 }
2367
2368 // SCALED-INDEX CSE register reservation (Wave 26). A region with array
2369 // accesses RESERVES up to two CALLER-SAVED registers OUT of the resident
2370 // pool — held for the run-shared `im1 = idx - 1` (and, with the second, its
2371 // scaled byte offset `im1 * 8`). Reserving up front (rather than scavenging a
2372 // register the scan happened to leave free) is what makes the CSE fire in the
2373 // HIGH-PRESSURE loops it targets — nbody/matrix touch many co-indexed
2374 // arrays, whose ptr/len handle slots would otherwise consume every GP
2375 // register and leave none free. The cost is that the one or two
2376 // LOWEST-ranked int slots spill to the frame instead of a register — a good
2377 // trade in an array-heavy loop, where eliminating the per-access index
2378 // recompute across N arrays dominates one extra frame round-trip on a cold
2379 // scalar. CALLER-SAVED registers need no prologue save, and the cache is
2380 // dropped across every SysV call, so a call's clobber of them is harmless.
2381 // The reserved registers are removed from `pool`, so the linear scan never
2382 // assigns them to a resident — they are exclusively the CSE cache's.
2383 // Reserve ONLY when the region actually has a reusable run — two or more
2384 // array accesses sharing one index slot with no intervening invalidation.
2385 // Without a shared-index run the CSE never fires, so reserving a register
2386 // (spilling a hot scalar) would be a pure cost — exactly the matrix_mult
2387 // inner loop, whose three accesses use THREE different indices (`i*n+k`,
2388 // `k*n+j`, `i*n+j`) and so must NOT pay the reservation. The static check
2389 // mirrors the runtime cache invalidation precisely.
2390 let (off_im1_reg, off_scaled_reg) = if off_cse_enabled() && has_shared_index_run(ops) {
2391 // The reservable caller-saved registers still in the pool, in pool order;
2392 // take from the END so residents keep the higher-priority (earlier)
2393 // registers and only the coldest int slots lose a register.
2394 let mut reservable: Vec<Reg> =
2395 pool.iter().copied().filter(|r| CALLER_SAVED.contains(r)).collect();
2396 // Leave at least one register for residents when the pool can spare it;
2397 // reserve TWO only when ≥ 2 caller-saved remain beyond that margin (so
2398 // the loop's index/counter still gets a register). With a tiny pool a
2399 // single reservation (a pure array-copy loop with no other live int) is
2400 // still sound — the reserved register is the cache's alone.
2401 let want = if reservable.len() >= 3 { 2 } else { usize::from(!reservable.is_empty()) };
2402 let mut reserved = Vec::new();
2403 for _ in 0..want {
2404 if let Some(r) = reservable.pop() {
2405 reserved.push(r);
2406 }
2407 }
2408 // Remove the reserved registers from the resident pool.
2409 pool.retain(|r| !reserved.contains(r));
2410 (reserved.first().copied(), reserved.get(1).copied())
2411 } else {
2412 (None, None)
2413 };
2414
2415 // LOOP-INVARIANT PTR/LEN HOIST register reservation (Wave 27a). Reserve
2416 // CALLEE-SAVED registers for each invariant-array plan and load the handle(s)
2417 // ONCE at the loop pre-header (`g.load(reg, slot)` copies whatever the slot's
2418 // current home holds — a register or a frame cell — so the pre-header captures
2419 // the value the OUTER context just set). Callee-saved registers SURVIVE the
2420 // SysV calls (the `ArrPush`/`ListClear` helpers, a self-call) inside the
2421 // loop, so a handle that the linear scan would otherwise put in a CALLER-saved
2422 // register — spilled + reloaded around EVERY in-loop call — instead stays live
2423 // for a one-time prologue push/pop; and a handle that would land in the FRAME
2424 // (forced by `force_frame_set`, or out of registers under pressure) avoids its
2425 // per-access reload. The reserved registers are removed from the resident pool
2426 // (so the scan never assigns them) and added to `used_callee` (prologue
2427 // save/restore), always leaving at least one callee-saved register for the
2428 // scan's own call-surviving residents.
2429 let mut used_callee: Vec<Reg> = Vec::new();
2430 let mut hoists: Vec<HoistEntry> = Vec::new();
2431 if ptr_hoist_enabled() {
2432 // The callee-saved registers still in the pool, reservable for hoisting.
2433 // We take from the FRONT (callee-saved lead the pool in a call-heavy
2434 // region) but always leave AT LEAST ONE callee-saved register for the
2435 // scan's call-surviving residents — a loop-carried scalar live across the
2436 // push wants a callee-saved home too. With fewer than two callee-saved
2437 // available, no hoist is reserved (the trade would starve the loop).
2438 let mut reservable_callee: Vec<Reg> =
2439 pool.iter().copied().filter(|r| CALLEE_SAVED.contains(r)).collect();
2440 let mut reserved_for_hoist: Vec<Reg> = Vec::new();
2441 let plans = loop_invariant_array_hoists(ops);
2442 for plan in plans {
2443 // Hoist only when it amortizes: either the array is accessed at least
2444 // twice in the span (each access otherwise reloads/uses the handle), or
2445 // the span contains a SysV call (a register-resident handle is then
2446 // spilled+reloaded around every call — a callee-saved hoist survives
2447 // it). A single un-called access has nothing to amortize → skip.
2448 let span_calls = ops[plan.head..=plan.back].iter().any(is_sysv_call);
2449 if plan.accesses < 2 && !span_calls {
2450 continue;
2451 }
2452 let need = 1 + usize::from(plan.needs_len);
2453 // Reserve from the available callee-saved, leaving one for the scan.
2454 if reservable_callee.len().saturating_sub(need) < 1 {
2455 continue;
2456 }
2457 let ptr_reg = reservable_callee.remove(0);
2458 reserved_for_hoist.push(ptr_reg);
2459 let len_reg = if plan.needs_len {
2460 let r = reservable_callee.remove(0);
2461 reserved_for_hoist.push(r);
2462 Some(r)
2463 } else {
2464 None
2465 };
2466 hoists.push(HoistEntry { plan, ptr_reg, len_reg });
2467 }
2468 // Remove the reserved registers from the resident pool and record them for
2469 // the prologue/epilogue callee-saved save (deterministic order).
2470 pool.retain(|r| !reserved_for_hoist.contains(r));
2471 for r in reserved_for_hoist {
2472 used_callee.push(r);
2473 }
2474 }
2475
2476 let mut loc = vec![Loc::Frame; max_slot + 1];
2477 {
2478 let mut pi = 0usize; // GP pool cursor
2479 let mut fi = 0usize; // XMM pool cursor
2480 for (s, _) in &order {
2481 // Force callee-window staging slots to the frame (the callee reads them
2482 // from its frame; a register-resident window slot would never reach it).
2483 if let Some(thresh) = force_frame_above {
2484 if *s >= thresh {
2485 continue;
2486 }
2487 }
2488 // Force list-mutation handle slots (vec/ptr/len) to the frame — the
2489 // helper reads/writes them there, so a register copy would go stale.
2490 if force_frame_set.contains(s) {
2491 continue;
2492 }
2493 match class[*s as usize] {
2494 Class::Int => {
2495 if pi >= pool.len() {
2496 continue;
2497 }
2498 let r = pool[pi];
2499 pi += 1;
2500 loc[*s as usize] = Loc::Reg(r);
2501 if CALLEE_SAVED.contains(&r) {
2502 used_callee.push(r);
2503 }
2504 }
2505 Class::Float => {
2506 if fi >= FLOAT_REGS.len() {
2507 continue;
2508 }
2509 loc[*s as usize] = Loc::Xmm(FLOAT_REGS[fi]);
2510 fi += 1;
2511 }
2512 Class::Mixed => {} // frame-resident only.
2513 }
2514 }
2515 }
2516
2517 // LS1 — linear-scan register REUSE for FLOAT slots only. The integer side
2518 // keeps the classic per-slot assignment above plus its GP-only optimizations
2519 // (scaled-index CSE, ptr/const hoists), which the XMM file never touches — so
2520 // the two compose. This OVERRIDES the float locs with a live-range allocation
2521 // that reuses an XMM register across disjoint float temporaries, reclaiming
2522 // the frame spills the whole-region model leaves under XMM pressure (nbody's
2523 // 47 floats / 14 registers / 33 spilled — but only ~10 live at once).
2524 if linscan_active {
2525 let gp_all: Vec<Reg> = CALLER_SAVED.iter().chain(CALLEE_SAVED.iter()).copied().collect();
2526 let scan = linscan_assign(ops, max_slot, &class, &gp_all, &FLOAT_REGS);
2527 for s in 0..=max_slot {
2528 if class[s] == Class::Float {
2529 let forced = force_frame_set.contains(&(s as u16))
2530 || force_frame_above.is_some_and(|t| s as u16 >= t);
2531 loc[s] = if forced { Loc::Frame } else { scan[s] };
2532 }
2533 }
2534 }
2535
2536 // LS1 per-point schedule: each slot's loop-aware [start,end] and whether its
2537 // register is SHARED with another slot. A shared-register slot is WRITTEN
2538 // THROUGH to its frame home on each write and set `Frame` at its range end —
2539 // because at any region exit (or mid-region deopt) the physical register may
2540 // hold a DIFFERENT member of the share-group, so the slot's authoritative
2541 // value must live in the frame, not be read back from the register by the
2542 // flush. A slot in a register all to itself (the common case) needs neither:
2543 // the register always holds exactly its value, so the exit flush is correct.
2544 // Empty unless `linscan_active`.
2545 let (ls_range, ls_shared): (Vec<Option<(usize, usize)>>, Vec<bool>) = if linscan_active {
2546 let ranges = live_ranges(ops, max_slot);
2547 let mut shared = vec![false; max_slot + 1];
2548 for a in 0..=max_slot {
2549 if matches!(loc[a], Loc::Frame) || ranges[a].is_none() {
2550 continue;
2551 }
2552 for b in 0..=max_slot {
2553 if a != b && loc[b] == loc[a] && ranges[b].is_some() {
2554 shared[a] = true;
2555 break;
2556 }
2557 }
2558 }
2559 (ranges, shared)
2560 } else {
2561 (Vec::new(), Vec::new())
2562 };
2563
2564 if linscan_active && std::env::var_os("LOGOS_DUMP_LS").is_some() {
2565 eprintln!("LS-REGION ops={} max_slot={}", ops.len(), max_slot);
2566 for (i, op) in ops.iter().enumerate() {
2567 eprintln!(" op{i:>3}: {op:?}");
2568 }
2569 for s in 0..=max_slot {
2570 if let Some((a, b)) = ls_range[s] {
2571 eprintln!(" slot{s:>3} [{a:>3},{b:>3}] {:?} shared={}", loc[s], ls_shared[s]);
2572 }
2573 }
2574 }
2575
2576 // Diagnostic class+placement histogram (LOGOS_DUMP_CLASS=1). Only for regions
2577 // that touch a float, so the float-cluster investigation isn't drowned in
2578 // scalar regions. Reports how many Float-class slots actually won an XMM
2579 // register vs spilled to the frame under pressure, and which slots are Mixed
2580 // (frame-resident class-ambiguous) — the empirical signal that decides whether
2581 // the float wall is a Mixed-class problem (K1) or an XMM-pressure problem (K2).
2582 if dump_class_enabled() {
2583 let (mut n_int, mut n_float, mut n_mixed) = (0usize, 0usize, 0usize);
2584 let (mut f_xmm, mut f_frame) = (0usize, 0usize);
2585 let mut mixed_slots: Vec<u16> = Vec::new();
2586 for s in 0..=max_slot {
2587 match class[s] {
2588 Class::Int => n_int += 1,
2589 Class::Float => {
2590 n_float += 1;
2591 match loc[s] {
2592 Loc::Xmm(_) => f_xmm += 1,
2593 _ => f_frame += 1,
2594 }
2595 }
2596 Class::Mixed => {
2597 n_mixed += 1;
2598 mixed_slots.push(s as u16);
2599 }
2600 }
2601 }
2602 // Max SIMULTANEOUSLY-LIVE float/int slots = the true register pressure in
2603 // the live-interval sense. If max-live-float <= 14 (the XMM pool), a
2604 // linear-scan allocator that reuses a register once a slot's range ends
2605 // fits every float with ZERO spill — even though the whole-region per-slot
2606 // model spills `frame` of them. This is the number that decides whether
2607 // interval allocation is the lever.
2608 let la = liveness_after(ops, max_slot);
2609 let (mut max_live_f, mut max_live_i) = (0usize, 0usize);
2610 for row in &la {
2611 let (mut lf, mut li) = (0usize, 0usize);
2612 for s in 0..=max_slot {
2613 if s < row.len() && row[s] {
2614 match class[s] {
2615 Class::Float => lf += 1,
2616 Class::Int => li += 1,
2617 Class::Mixed => {}
2618 }
2619 }
2620 }
2621 max_live_f = max_live_f.max(lf);
2622 max_live_i = max_live_i.max(li);
2623 }
2624 // Linear-scan SIMULATION: how many float/int slots a simple interval
2625 // allocator (the lever we'd build) would spill, given the same XMM pool
2626 // (14) and the actual GP `pool` this region has after CSE/hoist
2627 // reservations. Compare `lsF`/`lsI` against `frame=`: the gap is the spill
2628 // the whole-region per-slot model leaves on the table.
2629 let intervals = live_intervals(ops, max_slot);
2630 let ls_float = linscan_spills(&intervals, &class, Class::Float, FLOAT_REGS.len());
2631 let ls_int = linscan_spills(&intervals, &class, Class::Int, pool.len());
2632 if n_float > 0 || n_mixed > 0 {
2633 eprintln!(
2634 "DUMP-CLASS region ops={} max_slot={} int={} float={} (xmm={} frame={}) mixed={} maxLiveF={} maxLiveI={} lsF={} lsI={} mixed_slots={:?}",
2635 ops.len(), max_slot, n_int, n_float, f_xmm, f_frame, n_mixed, max_live_f, max_live_i, ls_float, ls_int, mixed_slots
2636 );
2637 }
2638 }
2639
2640 // The caller-saved residents (GP + XMM) that a SysV call CLOBBERS — a
2641 // self-call OR a list-mutation helper call both trash the caller-saved
2642 // registers per the ABI. This is the UNIVERSE; the per-call-site lists below
2643 // intersect it with each call's live-after set. Callee-saved residents
2644 // (rbx/r12/r13/r14, BASE=r15) survive a call automatically and need no spill.
2645 let (gp_volatile, xmm_volatile): (Vec<Slot>, Vec<Slot>) = if has_call {
2646 let mut gp = Vec::new();
2647 let mut xmm = Vec::new();
2648 for s in 0..=max_slot as u16 {
2649 match loc[s as usize] {
2650 Loc::Reg(r) if CALLER_SAVED.contains(&r) => gp.push(s),
2651 Loc::Xmm(_) => xmm.push(s),
2652 _ => {}
2653 }
2654 }
2655 (gp, xmm)
2656 } else {
2657 (Vec::new(), Vec::new())
2658 };
2659
2660 // Slots that are resident AND written somewhere must be flushed to the
2661 // frame on exit. (A resident read-only slot was loaded from the frame at
2662 // entry, never changed, so the frame is already correct.)
2663 let mut written: Vec<bool> = vec![false; max_slot + 1];
2664 for op in ops {
2665 if let Some(d) = dest_of(op) {
2666 written[d as usize] = true;
2667 }
2668 }
2669
2670 // PER-CALL-SITE spill liveness. A caller-saved resident is spilled+reloaded
2671 // around a call iff its value must survive the call's register clobber. That
2672 // is true exactly when the slot is either:
2673 // - WRITTEN somewhere (so it is flushed to the frame at EVERY region exit —
2674 // `Return` and every deopt side-exit — which the broader VM may read; its
2675 // last-written value MUST be preserved through the clobbering call), OR
2676 // - LIVE AFTER this call (read by some later op in the region).
2677 // A caller-saved resident that is READ-ONLY (never written) AND dead after
2678 // the call is the ONLY case we elide: its frame cell already holds the value
2679 // loaded at entry (it never changed), it is read by no later op, and being
2680 // unwritten it is never flushed at exit — so eliding its spill/reload is
2681 // bit-identical to the full-frame stencil tier (verified by the
2682 // `*_matches_stencil` post-frame differentials). This removes the redundant
2683 // spill/reload of cold read-only operands (e.g. a constant or bound consulted
2684 // only before the call) from call-heavy loops. For a non-call op the lists
2685 // are empty. When `has_call` is false the liveness pass is skipped entirely.
2686 let (call_gp, call_xmm): (Vec<Vec<Slot>>, Vec<Vec<Slot>>) = if let Some(live_after) =
2687 live_after.as_deref()
2688 {
2689 let keep = |s: Slot, la: &[bool]| -> bool {
2690 written.get(s as usize).copied().unwrap_or(true)
2691 || la.get(s as usize).copied().unwrap_or(true)
2692 };
2693 let mut cgp: Vec<Vec<Slot>> = vec![Vec::new(); ops.len()];
2694 let mut cxmm: Vec<Vec<Slot>> = vec![Vec::new(); ops.len()];
2695 for idx in 0..ops.len() {
2696 let la = &live_after[idx];
2697 cgp[idx] = gp_volatile.iter().copied().filter(|&s| keep(s, la)).collect();
2698 cxmm[idx] = xmm_volatile.iter().copied().filter(|&s| keep(s, la)).collect();
2699 }
2700 (cgp, cxmm)
2701 } else {
2702 (Vec::new(), Vec::new())
2703 };
2704
2705 // One label per op (jump targets) plus the deopt epilogue label and the
2706 // self-call deopt-PROPAGATION (bare flush+return, no status write) label.
2707 let mut asm = Asm::new();
2708 let op_labels: Vec<LabelId> = (0..ops.len()).map(|_| asm.new_label()).collect();
2709 // The plain (classic) deopt epilogue is still needed for any precise op whose
2710 // code is the plain marker `1` (a deoptable op the adapter chose not to make
2711 // precise) and for every classic checked op.
2712 let deopt_label = if needs_status { Some(asm.new_label()) } else { None };
2713 // A PROPAGATION epilogue (bare flush+return) is needed when ANY real SysV
2714 // self-call is present — the classic self-call OR the mode-B precise call —
2715 // because the inner exit code (already in the status cell) must not be
2716 // overwritten on the way out.
2717 let propagate_label = (has_self_call || has_precise_call).then(|| asm.new_label());
2718
2719 // PRECISE (mode-B) deopt machinery: a shared precise epilogue plus one
2720 // out-of-line tag-staging block per DISTINCT non-plain code (deterministic,
2721 // first-use order). Each block stages its tag in `S1` and jumps to the
2722 // epilogue, which reads the live depth, ORs it into the high 32 bits, writes
2723 // the status cell, flushes every resident-written slot, and returns. This is
2724 // the contiguous-codegen analog of the stencil tier's `ST_DEOPT_AT` family.
2725 let mut code_blocks: std::collections::HashMap<i64, LabelId> = std::collections::HashMap::new();
2726 let precise_epilogue = if precise.is_some() { Some(asm.new_label()) } else { None };
2727 if let Some(codes) = precise {
2728 for &c in codes {
2729 if c != 1 {
2730 code_blocks.entry(c).or_insert_with(|| asm.new_label());
2731 }
2732 }
2733 }
2734
2735 // Stack-pointer alignment: at function entry RSP ≡ 8 (mod 16) (the caller's
2736 // `call` pushed the return address). After `push BASE` + K callee-saved
2737 // pushes, RSP ≡ 8 + 8*(1+K) (mod 16). A SysV `call` requires RSP ≡ 0 (mod
2738 // 16) at the call instruction. Pad with one 8-byte slot when (1+K) is even
2739 // so RSP is 16-aligned at every call (self-call OR list-mutation helper);
2740 // undo it in every epilogue. (No calls ⇒ no padding needed, none emitted.)
2741 let pushes = 1 + used_callee.len();
2742 let stack_pad: i32 = if has_call && pushes % 2 == 0 { 8 } else { 0 };
2743
2744 // Prologue: save callee-saved regs, move base (rdi) into r15, apply the
2745 // alignment pad, then load every resident slot from the frame.
2746 asm.push(BASE);
2747 for &r in &used_callee {
2748 asm.push(r);
2749 }
2750 asm.mov_rr(BASE, Reg::Rdi);
2751 if stack_pad != 0 {
2752 asm.sub_ri(Reg::Rsp, stack_pad);
2753 }
2754 {
2755 // Load resident slots in slot order for determinism. GP-resident slots
2756 // load via `mov`; XMM-resident (float) slots via `movsd`.
2757 for s in 0..=max_slot as u16 {
2758 // LS1: under linear scan, load a resident slot at entry iff it is
2759 // ENTRY-LIVE (live range starts at op 0 — an input/loop-carried value
2760 // read before written) OR it has a register to ITSELF (non-shared, so
2761 // the load can't clobber a co-tenant; loading a write-first temp is
2762 // harmless — its def overwrites it, and the entry value preserves a
2763 // conditionally-written slot on the paths it isn't written). Skip only
2764 // a SHARED write-first temp: loading it would clobber the entry-live or
2765 // earlier member sharing its one register.
2766 if linscan_active {
2767 let shared = ls_shared.get(s as usize).copied().unwrap_or(false);
2768 let entry_live = matches!(ls_range.get(s as usize).copied().flatten(), Some((0, _)));
2769 if shared && !entry_live {
2770 continue;
2771 }
2772 }
2773 match loc.get(s as usize) {
2774 Some(Loc::Reg(r)) => asm.mov_rm(*r, BASE, (s as i32) * 8),
2775 Some(Loc::Xmm(x)) => asm.movsd_rm(*x, BASE, (s as i32) * 8),
2776 _ => {}
2777 }
2778 }
2779 }
2780
2781 let status_addr = status
2782 .as_ref()
2783 .map(|c| c.as_ref() as *const AtomicI64 as i64)
2784 .unwrap_or(0);
2785
2786 // The self-call ENTRY CELL: an `Arc<AtomicI64>` whose address is baked into
2787 // every call site and which is written with `chain.base()` after mapping.
2788 // Needed for BOTH the classic direct self-call and the mode-B precise call
2789 // (both are REAL SysV calls through this very chain's entry).
2790 let entry_cell: Option<std::sync::Arc<AtomicI64>> =
2791 (has_self_call || has_precise_call).then(|| std::sync::Arc::new(AtomicI64::new(0)));
2792 let entry_addr = entry_cell
2793 .as_ref()
2794 .map(|c| c.as_ref() as *const AtomicI64 as i64)
2795 .unwrap_or(0);
2796
2797 let self_call = propagate_label.map(|pl| SelfCall { propagate_label: pl });
2798 let precise_ctx = precise.map(|codes| Precise { codes, code_blocks: &code_blocks });
2799
2800 let mut g = Gen {
2801 asm,
2802 loc: loc.clone(),
2803 op_labels: &op_labels,
2804 deopt_label,
2805 stack_pad,
2806 self_call,
2807 call_gp: &call_gp,
2808 call_xmm: &call_xmm,
2809 precise: precise_ctx,
2810 off_im1_reg,
2811 off_scaled_reg,
2812 off_cache: None,
2813 hoists: &hoists,
2814 active_hoists: Vec::new(),
2815 };
2816
2817 // Jump-target indices: an op reached by some transfer's `target` is a control
2818 // join, so the scaled-index cache must be DROPPED on entry to it (a stale
2819 // reserved register from the fall-through predecessor must not be reused when
2820 // control arrives from the branch). Computed once; consulted in the loop.
2821 let mut is_jump_target = vec![false; ops.len()];
2822 for op in ops {
2823 if let Some(t) = op_target(op) {
2824 if let Some(slot) = is_jump_target.get_mut(t) {
2825 *slot = true;
2826 }
2827 }
2828 }
2829
2830 // LOOP-INVARIANT CONSTANT HOIST (Wave 28). A `LoadConst` whose `dst` is the
2831 // span's sole writer and lands in a GP REGISTER is materialized ONCE in the
2832 // loop pre-header instead of every iteration. `const_hoist_by_op[i] =
2833 // Some(head)` marks the op `i` to (a) SKIP in the body and (b) materialize at
2834 // `head`'s pre-header.
2835 //
2836 // FLOAT (XMM-resident) consts are EXCLUDED, and the exclusion is empirical,
2837 // not incidental. mandelbrot's hot inner loop re-materializes `2.0`/`4.0`
2838 // every iteration through a GP→XMM bridge; hoisting them into resident XMM
2839 // registers held across the loop is bit-identical but measured ~8% SLOWER (n
2840 // =2000: 480ms vs 443ms, reproducible across independent processes; GP-only
2841 // hoisting is at parity). The XMM register file is SATURATED (14/14 resident
2842 // in mandelbrot), and holding an invariant float in an XMM register that the
2843 // float dependency chain repeatedly reads serializes worse than the
2844 // per-iteration re-materialization, whose independent `movabs;movq` the
2845 // out-of-order engine overlaps with the float chain — the copy-and-patch /
2846 // contiguous backend is throughput-bound here, so removing the "redundant"
2847 // float loads loses free ILP. Frame-resident dsts are also filtered (no
2848 // register to keep resident — the body would still touch the frame). The
2849 // structural detection is purely over the op stream; residency is decided
2850 // here against `loc`.
2851 let mut const_hoist_by_op: Vec<Option<usize>> = vec![None; ops.len()];
2852 let mut const_hoist_at_head: Vec<Vec<usize>> = vec![Vec::new(); ops.len()];
2853 if const_hoist_enabled() {
2854 for h in loop_invariant_const_hoists(ops) {
2855 if matches!(loc[h.dst as usize], Loc::Reg(_)) {
2856 const_hoist_by_op[h.op] = Some(h.head);
2857 const_hoist_at_head[h.head].push(h.op);
2858 }
2859 }
2860 }
2861
2862 for (idx, op) in ops.iter().enumerate() {
2863 // LOOP PRE-HEADER for any hoist whose loop head is THIS op: load the
2864 // invariant ptr (and len) into the reserved callee-saved register(s) ONCE,
2865 // BEFORE binding the head's label. The loop's back-edge targets the bound
2866 // label, so it SKIPS this load (the register stays live across iterations);
2867 // only the entry fall-through executes it. The entry-discipline guard in
2868 // `loop_invariant_array_hoists` ensures the head is entered ONLY via this
2869 // fall-through, so the register is always loaded before the first access.
2870 for k in 0..g.hoists.len() {
2871 let e = g.hoists[k];
2872 if e.plan.head == idx {
2873 g.load(e.ptr_reg, e.plan.ptr_slot);
2874 if let Some(lr) = e.len_reg {
2875 g.load(lr, e.plan.len_slot);
2876 }
2877 }
2878 }
2879 // LOOP PRE-HEADER for any loop-invariant CONSTANT whose head is THIS op:
2880 // materialize the raw bits ONCE into the dst register, BEFORE binding the
2881 // head's label, so the back-edge (which jumps to the bound label) skips it
2882 // and the register stays resident across iterations. Mirrors the body
2883 // `LoadConst` emit exactly (a float dst bridges GP→XMM via `movq`), so the
2884 // register holds the same bits the per-iteration load would have.
2885 for ci in 0..const_hoist_at_head[idx].len() {
2886 let op_i = const_hoist_at_head[idx][ci];
2887 if let MicroOp::LoadConst { dst, value } = ops[op_i] {
2888 g.asm.mov_ri(S0, value);
2889 if let Loc::Xmm(_) = g.loc[dst as usize] {
2890 g.asm.movq_xr(FS0, S0);
2891 g.fstore(dst, FS0);
2892 } else {
2893 g.store(dst, S0);
2894 }
2895 }
2896 }
2897 g.asm.bind(op_labels[idx]);
2898 // The hoists ACTIVE at this op: those whose span `[head, back]` contains
2899 // `idx`. Inside the span `emit_arr_addr` reads the hoisted register;
2900 // outside it falls back to the per-access frame reload (the register may
2901 // hold a stale value from a different loop or a since-rewritten slot).
2902 g.active_hoists.clear();
2903 for k in 0..g.hoists.len() {
2904 let p = g.hoists[k].plan;
2905 if (p.head..=p.back).contains(&idx) {
2906 g.active_hoists.push(k);
2907 }
2908 }
2909 // Drop the scaled-index cache at a control join: control could reach this
2910 // op from a branch where the reserved register holds a different (or no)
2911 // index, so the next array access here must recompute.
2912 if is_jump_target[idx] {
2913 g.invalidate_off_cache();
2914 }
2915 // A hoisted loop-invariant `LoadConst`: the pre-header already
2916 // materialized its register, which stays resident across the back-edge —
2917 // SKIP the per-iteration re-materialization (the body label is still bound
2918 // above so any transfer to it lands here correctly). The post-match
2919 // off-cache invalidation below is still honored: a `LoadConst` is never a
2920 // SysV call, and `invalidate_off_cache_if_writes_idx` recomputes a cache
2921 // keyed off this slot — harmless here (the invariant value is unchanged).
2922 let hoisted_const = const_hoist_by_op[idx].is_some();
2923 if !hoisted_const {
2924 match op {
2925 MicroOp::CallSelf { dst, args_start, limit_slot, frame_size, .. } => {
2926 emit_self_call(
2927 &mut g, idx, *dst, *args_start, None, *limit_slot, *frame_size, status_addr,
2928 depth_addr, entry_addr,
2929 );
2930 }
2931 MicroOp::CallSelfCopy {
2932 dst, args_start, src_start, arg_count, limit_slot, frame_size, ..
2933 } => {
2934 emit_self_call(
2935 &mut g, idx, *dst, *args_start, Some((*src_start, *arg_count)), *limit_slot,
2936 *frame_size, status_addr, depth_addr, entry_addr,
2937 );
2938 }
2939 // The mode-B precise self-`Call`: the disjoint callee window is
2940 // placed at `args_start == frame_size` (the adapter's invariant), so
2941 // the callee's frame size — for the arena bound and the limit plant —
2942 // is exactly `args_start`. The per-op code (precise) drives the
2943 // guard exits.
2944 MicroOp::Call { dst, args_start, limit_slot, .. } => {
2945 let code = g
2946 .precise
2947 .as_ref()
2948 .map(|p| p.codes[idx])
2949 .expect("a precise Call requires precise codes");
2950 emit_precise_call(
2951 &mut g, idx, *dst, *args_start, *limit_slot, *args_start as i64, status_addr,
2952 depth_addr, entry_addr, code,
2953 );
2954 }
2955 _ => emit_op(&mut g, op, idx, &written, &used_callee)?,
2956 }
2957 }
2958 // A SysV call (self-call, precise call, or a list/string-mutation helper)
2959 // clobbers the caller-saved reserved register(s) during the call, so the
2960 // cache cannot survive it. A write to the cached index slot makes the held
2961 // `im1` stale. Either drops the cache so a later access recomputes.
2962 if is_sysv_call(op) {
2963 g.invalidate_off_cache();
2964 } else {
2965 g.invalidate_off_cache_if_writes_idx(op);
2966 }
2967 // LS1 SPILL-AT-END: when a SHARED-register slot's range ends, store it to
2968 // its frame home and set it `Frame`, freeing the register for the next
2969 // member. Only the Completed exit flush reads these cells (a classic Deopt
2970 // DISCARDS the private frame and replays from the VM's own registers —
2971 // machine.rs — so no per-write write-through is needed). A slot in a
2972 // register all to itself needs nothing: the register always holds exactly
2973 // its value, so the exit flush reads it correctly. The reuse start is
2974 // strictly after this end (`linscan_assign`), so the register still holds
2975 // THIS slot now — never a successor's value.
2976 if linscan_active {
2977 for s in 0..=max_slot {
2978 if ls_shared[s] && matches!(ls_range[s], Some((_, e)) if e == idx) {
2979 match g.loc[s] {
2980 Loc::Reg(r) => g.asm.mov_mr(BASE, (s as i32) * 8, r),
2981 Loc::Xmm(x) => g.asm.movsd_mr(BASE, (s as i32) * 8, x),
2982 Loc::Frame => {}
2983 }
2984 g.loc[s] = Loc::Frame;
2985 }
2986 }
2987 }
2988 }
2989
2990 // Deopt epilogue (only if some op can side-exit): store 1 to the status
2991 // cell, flush resident-written slots, restore callee-saved regs, return 0.
2992 if let Some(dl) = deopt_label {
2993 g.asm.bind(dl);
2994 // mov rax, status_addr; mov qword [rax], 1
2995 g.asm.mov_ri(S0, status_addr);
2996 g.asm.mov_ri(S1, 1);
2997 g.asm.mov_mr(S0, 0, S1);
2998 emit_flush_and_return(&mut g, &written, &used_callee, None);
2999 }
3000
3001 // Self-call deopt-PROPAGATION epilogue: the status cell ALREADY holds the
3002 // inner exit code (5/9/precise/1), so flush and return WITHOUT touching it.
3003 if let Some(pl) = propagate_label {
3004 g.asm.bind(pl);
3005 emit_flush_and_return(&mut g, &written, &used_callee, None);
3006 }
3007
3008 // PRECISE (mode-B) deopt epilogue + per-code tag-staging blocks. Each block
3009 // stages its tag in S1, then falls through (or jumps) to the shared epilogue,
3010 // which combines the live depth into the high 32 bits, writes the status
3011 // cell, flushes every resident-written slot, and returns — bit-identical to
3012 // `logos_stencil_deopt_at` (`status = (pc<<2 | 3) | (depth << 32)`).
3013 if let Some(ep) = precise_epilogue {
3014 // The blocks (deterministic order by code value for a stable layout),
3015 // each `mov S1, code; jmp epilogue`.
3016 let mut codes_sorted: Vec<(i64, LabelId)> =
3017 code_blocks.iter().map(|(&c, &l)| (c, l)).collect();
3018 codes_sorted.sort_by_key(|(c, _)| *c);
3019 for (c, lbl) in codes_sorted {
3020 g.asm.bind(lbl);
3021 g.asm.mov_ri(S1, c);
3022 g.asm.jmp(ep);
3023 }
3024 // The shared epilogue: S1 = tag; rax = *depth; rax <<= 32; rax |= S1;
3025 // *status = rax; flush; return.
3026 g.asm.bind(ep);
3027 g.asm.mov_ri(S0, depth_addr);
3028 g.asm.mov_rm(S0, S0, 0); // S0 = *depth
3029 g.asm.shl_ri(S0, 32);
3030 g.asm.or_rr(S0, S1); // S0 = (depth << 32) | tag
3031 g.asm.mov_ri(SC, status_addr);
3032 g.asm.mov_mr(SC, 0, S0); // *status = S0
3033 emit_flush_and_return(&mut g, &written, &used_callee, None);
3034 }
3035
3036 let code = g.asm.resolve();
3037 let chain = JitChain::from_code(&code, ops.len()).ok()?;
3038
3039 // Patch the entry cell with the mapped base, so every self-call reaches this
3040 // very function. An unpatched (0) cell would deopt (matching the stencil's
3041 // unpatched-entry guard), but we patch it here, before the chain ever runs.
3042 if let Some(cell) = entry_cell {
3043 cell.store(chain.base() as i64, std::sync::atomic::Ordering::SeqCst);
3044 return Some(CompiledChain::from_chain_keepalive(chain, status, vec![cell]));
3045 }
3046 Some(CompiledChain::from_chain(chain, status))
3047}
3048
3049/// The destination slot an op writes, if any.
3050fn dest_of(op: &MicroOp) -> Option<Slot> {
3051 match *op {
3052 MicroOp::Move { dst, .. }
3053 | MicroOp::LoadConst { dst, .. }
3054 | MicroOp::Add { dst, .. }
3055 | MicroOp::Sub { dst, .. }
3056 | MicroOp::Mul { dst, .. }
3057 | MicroOp::Lt { dst, .. }
3058 | MicroOp::Gt { dst, .. }
3059 | MicroOp::LtEq { dst, .. }
3060 | MicroOp::GtEq { dst, .. }
3061 | MicroOp::Eq { dst, .. }
3062 | MicroOp::Neq { dst, .. }
3063 | MicroOp::BitAnd { dst, .. }
3064 | MicroOp::BitOr { dst, .. }
3065 | MicroOp::BitXor { dst, .. }
3066 | MicroOp::Shl { dst, .. }
3067 | MicroOp::Shr { dst, .. }
3068 | MicroOp::NotInt { dst, .. }
3069 | MicroOp::NotBool { dst, .. }
3070 | MicroOp::Div { dst, .. }
3071 | MicroOp::Mod { dst, .. }
3072 | MicroOp::ArrLoad { dst, .. }
3073 | MicroOp::DivPow2 { dst, .. }
3074 | MicroOp::MagicDivU { dst, .. }
3075 | MicroOp::AddF { dst, .. }
3076 | MicroOp::SubF { dst, .. }
3077 | MicroOp::MulF { dst, .. }
3078 | MicroOp::DivF { dst, .. }
3079 | MicroOp::SqrtF { dst, .. }
3080 | MicroOp::IntToFloat { dst, .. }
3081 | MicroOp::FmaF { dst, .. }
3082 | MicroOp::LtF { dst, .. }
3083 | MicroOp::GtF { dst, .. }
3084 | MicroOp::LtEqF { dst, .. }
3085 | MicroOp::GtEqF { dst, .. }
3086 | MicroOp::EqF { dst, .. }
3087 | MicroOp::NeqF { dst, .. }
3088 // A self-call writes its result into `dst` (after a successful return).
3089 | MicroOp::CallSelf { dst, .. }
3090 | MicroOp::CallSelfCopy { dst, .. }
3091 // The mode-B precise self-call also stores its result handle into `dst`.
3092 | MicroOp::Call { dst, .. } => Some(dst),
3093 // `ArrStore` writes the BUFFER (through the pinned pointer), not a frame
3094 // slot, so it has no `dest_of` — its effect is outside the frame.
3095 _ => None,
3096 }
3097}
3098
3099/// Flush every resident-written slot back to the frame, restore callee-saved
3100/// registers, and `ret`. When `ret_slot` is `Some`, the return value (slot's
3101/// value) is loaded into `rax` AFTER flushing; when `None` (deopt), `rax` is
3102/// already the status-write scratch and the returned value is irrelevant (the
3103/// status cell signals the side-exit).
3104fn emit_flush_and_return(
3105 g: &mut Gen,
3106 written: &[bool],
3107 used_callee: &[Reg],
3108 ret_slot: Option<Slot>,
3109) {
3110 // Flush resident-written slots in slot order. GP slots flush via `mov`,
3111 // XMM (float) slots via `movsd` — both leave the frame consistent with the
3112 // tree-walker's full frame state (so the region can resume / deopt).
3113 for s in 0..g.loc.len() {
3114 if written[s] {
3115 match g.loc[s] {
3116 Loc::Reg(r) => g.asm.mov_mr(BASE, (s as i32) * 8, r),
3117 Loc::Xmm(x) => g.asm.movsd_mr(BASE, (s as i32) * 8, x),
3118 Loc::Frame => {}
3119 }
3120 }
3121 }
3122 if let Some(rs) = ret_slot {
3123 // Load the return value bits into rax from its (now frame-consistent)
3124 // home. A float return slot lives in an XMM reg — bit-copy via `movq`;
3125 // the chain ABI returns the raw i64 bits either way.
3126 match g.loc[rs as usize] {
3127 Loc::Reg(r) => g.asm.mov_rr(S0, r),
3128 Loc::Xmm(x) => g.asm.movq_rx(S0, x),
3129 Loc::Frame => g.asm.mov_rm(S0, BASE, (rs as i32) * 8),
3130 }
3131 }
3132 // Undo the prologue's stack-alignment pad (if any) before restoring regs.
3133 if g.stack_pad != 0 {
3134 g.asm.add_ri(Reg::Rsp, g.stack_pad);
3135 }
3136 // Restore callee-saved regs in reverse push order, then base.
3137 for &r in used_callee.iter().rev() {
3138 g.asm.pop(r);
3139 }
3140 g.asm.pop(BASE);
3141 g.asm.ret();
3142}
3143
3144/// Lower a DIRECT self-call (`CallSelf` / `CallSelfCopy`) — a REAL SysV call to
3145/// THIS chain's own entry. Bit-identical to `logos_stencil_call_self`(`_copy`):
3146///
3147/// 1. entry-cell load + zero guard (status = 1, the unpatched-entry marker);
3148/// 2. live-depth guard `depth >= MAX` (status = 5);
3149/// 3. arena-bound guard `callee_base + frame_size*8 > arena_end` (status = 9),
3150/// where `arena_end = frame[limit_slot]` and `callee_base = BASE + args*8`;
3151/// 4. (`CallSelfCopy`) stage `arg_count` scalar args from `src_start..` into the
3152/// callee window `callee_base[0..]` — read from wherever the source slots
3153/// live (register or frame), so a register-resident arg is staged correctly;
3154/// 5. plant `arena_end` into the callee's own limit slot
3155/// (`callee_base[frame_size-1]`);
3156/// 6. `*depth += 1`; spill caller-saved residents; `call [entry]`;
3157/// 7. `*depth -= 1`; if `*status != 0` → PROPAGATE (bare flush+return, the inner
3158/// exit code is already in the cell); else reload residents and store the
3159/// result into `dst`.
3160///
3161/// Every side exit writes its marker (or leaves the propagated code) and jumps
3162/// to the self-call PROPAGATION epilogue, which flushes the frame and returns
3163/// WITHOUT overwriting the status cell.
3164#[allow(clippy::too_many_arguments)]
3165fn emit_self_call(
3166 g: &mut Gen,
3167 idx: usize,
3168 dst: Slot,
3169 args_start: Slot,
3170 copy: Option<(Slot, u16)>,
3171 limit_slot: Slot,
3172 frame_size: i64,
3173 status_addr: i64,
3174 depth_addr: i64,
3175 entry_addr: i64,
3176) {
3177 let propagate = g.self_call.as_ref().expect("self-call without SelfCall context").propagate_label;
3178
3179 // A small helper: on a failed guard, write `marker` to the status cell, then
3180 // jump to the propagation epilogue (which flushes + returns, no further
3181 // status write). Emitted as an out-of-line block reached by a jcc.
3182 let entry_zero = g.asm.new_label();
3183 let depth_bad = g.asm.new_label();
3184 let arena_bad = g.asm.new_label();
3185
3186 // ---- 1. entry-cell load + zero guard ----
3187 // S0 = *entry_cell (the patched base, or 0 if somehow unpatched).
3188 g.asm.mov_ri(S0, entry_addr);
3189 g.asm.mov_rm(S0, S0, 0);
3190 g.asm.test_rr(S0, S0);
3191 g.asm.jcc(Cond::Eq, entry_zero);
3192
3193 // ---- 2. depth guard: *depth >= MAX ----
3194 // S1 = *depth.
3195 g.asm.mov_ri(S1, depth_addr);
3196 g.asm.mov_rm(S1, S1, 0);
3197 g.asm.cmp_ri(S1, SELF_CALL_DEPTH_LIMIT as i32);
3198 g.asm.jcc(Cond::Ge, depth_bad);
3199
3200 // ---- 3. arena-bound guard ----
3201 // SC = callee_base = BASE + args_start*8.
3202 g.asm.mov_rr(SC, BASE);
3203 g.asm.add_ri(SC, (args_start as i32) * 8);
3204 // S0 = arena_end = frame[limit_slot].
3205 g.asm.mov_rm(S0, BASE, (limit_slot as i32) * 8);
3206 // S1 = callee_base + frame_size*8.
3207 g.asm.mov_rr(S1, SC);
3208 g.asm.add_ri(S1, (frame_size as i32) * 8);
3209 g.asm.cmp_rr(S1, S0); // (callee_base + fs*8) - arena_end
3210 g.asm.jcc(Cond::Gt, arena_bad); // > arena_end → OOB
3211
3212 // ---- 4. argument staging (CallSelfCopy) ----
3213 // SC holds callee_base; S0 holds arena_end (kept for step 5). Stage each
3214 // source arg through S1, reading the source slot from wherever it lives.
3215 if let Some((src_start, arg_count)) = copy {
3216 for j in 0..arg_count {
3217 g.load(S1, src_start + j); // S1 = frame-or-reg[src_start+j]
3218 g.asm.mov_mr(SC, (j as i32) * 8, S1); // callee_base[j] = arg
3219 }
3220 }
3221
3222 // ---- 5. plant arena_end into the callee's own limit slot ----
3223 g.asm.mov_mr(SC, ((frame_size - 1) as i32) * 8, S0); // callee_base[fs-1] = arena_end
3224
3225 // ---- 6a. *depth += 1 ----
3226 g.asm.mov_ri(SC, depth_addr);
3227 g.asm.mov_rm(S0, SC, 0);
3228 g.asm.add_ri(S0, 1);
3229 g.asm.mov_mr(SC, 0, S0);
3230
3231 // ---- 6b. spill caller-saved residents to their frame slots ----
3232 g.spill_volatiles_at(idx);
3233
3234 // ---- 6c. set up the SysV call args and CALL ----
3235 // rdi = callee_base = BASE + args_start*8 (recompute — SC was reused above).
3236 g.asm.mov_rr(Reg::Rdi, BASE);
3237 g.asm.add_ri(Reg::Rdi, (args_start as i32) * 8);
3238 // rsi = sp = 0 (the contiguous backend never reads the operand stack).
3239 g.asm.mov_ri(Reg::Rsi, 0);
3240 // rdx/rcx/r8/r9 = 0 (threaded register args enter zeroed; the callee's
3241 // prologue reloads its pinned registers from its frame).
3242 g.asm.mov_ri(Reg::Rdx, 0);
3243 g.asm.mov_ri(Reg::Rcx, 0);
3244 g.asm.mov_ri(Reg::R8, 0);
3245 g.asm.mov_ri(Reg::R9, 0);
3246 // call [entry_cell]: rax = *entry_cell; call rax.
3247 g.asm.mov_ri(S0, entry_addr);
3248 g.asm.mov_rm(S0, S0, 0);
3249 g.asm.call_r(S0); // result -> rax (S0)
3250
3251 // ---- 7a. *depth -= 1 (uses rcx; rax holds the result) ----
3252 g.asm.mov_ri(SC, depth_addr);
3253 g.asm.mov_rm(S1, SC, 0);
3254 g.asm.sub_ri(S1, 1);
3255 g.asm.mov_mr(SC, 0, S1);
3256
3257 // ---- 7b. status check: if *status != 0 propagate (rax irrelevant) ----
3258 g.asm.mov_ri(S1, status_addr);
3259 g.asm.mov_rm(S1, S1, 0);
3260 g.asm.test_rr(S1, S1);
3261 g.asm.jcc(Cond::Ne, propagate);
3262
3263 // ---- 7c. reload caller-saved residents (their pre-call loop-carried
3264 // values, spilled in 6b — the callee never touches the caller's frame
3265 // slots). Reloads write the resident register directly (no scratch), so
3266 // rax (the result) survives. Only the LIVE-AFTER subset is reloaded (a
3267 // resident dead after the call is read by no later op). ----
3268 g.reload_volatiles_at(idx);
3269
3270 // ---- 7d. store the result into dst (AFTER reloading, so a volatile-
3271 // resident dst gets the result, not its stale reloaded value). ----
3272 g.store(dst, S0);
3273
3274 // ---- out-of-line guard-failure blocks (write marker, then propagate) ----
3275 let after = g.asm.new_label();
3276 g.asm.jmp(after);
3277
3278 g.asm.bind(entry_zero);
3279 emit_marker_then_propagate(g, status_addr, 1, propagate);
3280 g.asm.bind(depth_bad);
3281 emit_marker_then_propagate(g, status_addr, 5, propagate);
3282 g.asm.bind(arena_bad);
3283 emit_marker_then_propagate(g, status_addr, 9, propagate);
3284
3285 g.asm.bind(after);
3286}
3287
3288/// Write `marker` to the status cell, then jump to the self-call propagation
3289/// epilogue (bare flush + return). Used by the pre-call self-call guards
3290/// (unpatched entry = 1, depth limit = 5, arena bound = 9) — the same distinct
3291/// markers `logos_stencil_call_self` writes.
3292fn emit_marker_then_propagate(g: &mut Gen, status_addr: i64, marker: i64, propagate: LabelId) {
3293 g.asm.mov_ri(S0, status_addr);
3294 g.asm.mov_ri(S1, marker);
3295 g.asm.mov_mr(S0, 0, S1);
3296 g.asm.jmp(propagate);
3297}
3298
3299/// Lower a mode-B PRECISE self-`Call` (a REAL SysV call through the program's
3300/// entry table) — bit-identical to `logos_stencil_call_precise`:
3301///
3302/// 1. entry-cell load + zero guard (precise-tag exit);
3303/// 2. live-depth guard `depth >= MAX` (precise-tag exit);
3304/// 3. arena-bound guard `callee_base + (regc+3)*8 > arena_end` (precise-tag exit),
3305/// where `arena_end = frame[limit_slot]`, `callee_base = BASE + args_start*8`,
3306/// `regc = frame_size - 3` (the published table regcount);
3307/// 4. plant `arena_end` into the callee's own limit slot
3308/// (`callee_base[regc+2] = callee_base[frame_size-1]`);
3309/// 5. `*depth += 1`; spill caller-saved residents; `call [entry]`;
3310/// 6. `*depth -= 1`; if `*status != 0` → PRECISE PROPAGATE (bare flush+return,
3311/// the inner exit code — already a precise tag — stays in the cell); else
3312/// reload residents and store the result handle into `dst`.
3313///
3314/// Unlike the classic self-call, the args are PRE-STAGED by `Move` micros into
3315/// the disjoint callee window (`args_start = frame_size`) before this op, and the
3316/// plant window/resume/dst slots were written by the preceding `LoadConst`
3317/// micros (all frame-resident), so the precise-deopt walk can chain frames. On
3318/// ANY guard failure the precise epilogue writes `code | (*depth << 32)`.
3319#[allow(clippy::too_many_arguments)]
3320fn emit_precise_call(
3321 g: &mut Gen,
3322 idx: usize,
3323 dst: Slot,
3324 args_start: Slot,
3325 limit_slot: Slot,
3326 frame_size: i64,
3327 status_addr: i64,
3328 depth_addr: i64,
3329 entry_addr: i64,
3330 code: i64,
3331) {
3332 let propagate = g
3333 .self_call
3334 .as_ref()
3335 .expect("precise call without a propagate epilogue")
3336 .propagate_label;
3337 let fail = g
3338 .precise
3339 .as_ref()
3340 .map(|p| p.code_blocks[&code])
3341 .expect("precise call without a code block");
3342
3343 // The published table regcount is frame_size - 3; the callee frame spans
3344 // (regc + 3) slots, exactly the stencil's `regc.wrapping_add(3)`.
3345 let bound_slots = frame_size; // == (frame_size - 3) + 3.
3346
3347 // ---- 1. entry-cell load + zero guard ----
3348 g.asm.mov_ri(S0, entry_addr);
3349 g.asm.mov_rm(S0, S0, 0);
3350 g.asm.test_rr(S0, S0);
3351 g.asm.jcc(Cond::Eq, fail);
3352
3353 // ---- 2. depth guard: *depth >= MAX ----
3354 g.asm.mov_ri(S1, depth_addr);
3355 g.asm.mov_rm(S1, S1, 0);
3356 g.asm.cmp_ri(S1, SELF_CALL_DEPTH_LIMIT as i32);
3357 g.asm.jcc(Cond::Ge, fail);
3358
3359 // ---- 3. arena-bound guard ----
3360 // SC = callee_base = BASE + args_start*8.
3361 g.asm.mov_rr(SC, BASE);
3362 g.asm.add_ri(SC, (args_start as i32) * 8);
3363 // S0 = arena_end = frame[limit_slot].
3364 g.asm.mov_rm(S0, BASE, (limit_slot as i32) * 8);
3365 // S1 = callee_base + bound_slots*8.
3366 g.asm.mov_rr(S1, SC);
3367 g.asm.add_ri(S1, (bound_slots as i32) * 8);
3368 g.asm.cmp_rr(S1, S0);
3369 g.asm.jcc(Cond::Gt, fail);
3370
3371 // ---- 4. plant arena_end into the callee's own limit slot ----
3372 g.asm.mov_mr(SC, ((frame_size - 1) as i32) * 8, S0); // callee_base[fs-1] = arena_end
3373
3374 // ---- 5a. *depth += 1 ----
3375 g.asm.mov_ri(SC, depth_addr);
3376 g.asm.mov_rm(S0, SC, 0);
3377 g.asm.add_ri(S0, 1);
3378 g.asm.mov_mr(SC, 0, S0);
3379
3380 // ---- 5b. spill the LIVE-AFTER caller-saved residents ----
3381 // A resident dead after this precise call is read by no later op AND is not
3382 // observed by a precise resume at this call's PC (the resume reads live-IN
3383 // slots, a subset of these live-after residents plus the frame-resident
3384 // args/limit) — so eliding its spill/reload is bit-identical.
3385 g.spill_volatiles_at(idx);
3386
3387 // ---- 5c. SysV call args + CALL ----
3388 g.asm.mov_rr(Reg::Rdi, BASE);
3389 g.asm.add_ri(Reg::Rdi, (args_start as i32) * 8);
3390 g.asm.mov_ri(Reg::Rsi, 0);
3391 g.asm.mov_ri(Reg::Rdx, 0);
3392 g.asm.mov_ri(Reg::Rcx, 0);
3393 g.asm.mov_ri(Reg::R8, 0);
3394 g.asm.mov_ri(Reg::R9, 0);
3395 g.asm.mov_ri(S0, entry_addr);
3396 g.asm.mov_rm(S0, S0, 0);
3397 g.asm.call_r(S0); // result -> rax (S0)
3398
3399 // ---- 6a. *depth -= 1 (rcx scratch; rax holds the result) ----
3400 g.asm.mov_ri(SC, depth_addr);
3401 g.asm.mov_rm(S1, SC, 0);
3402 g.asm.sub_ri(S1, 1);
3403 g.asm.mov_mr(SC, 0, S1);
3404
3405 // ---- 6b. status check: if *status != 0 propagate (rax irrelevant) ----
3406 g.asm.mov_ri(S1, status_addr);
3407 g.asm.mov_rm(S1, S1, 0);
3408 g.asm.test_rr(S1, S1);
3409 g.asm.jcc(Cond::Ne, propagate);
3410
3411 // ---- 6c. reload the LIVE-AFTER caller-saved residents ----
3412 g.reload_volatiles_at(idx);
3413
3414 // ---- 6d. store the result handle into dst ----
3415 g.store(dst, S0);
3416}
3417
3418/// Lower one supported op.
3419fn emit_op(
3420 g: &mut Gen,
3421 op: &MicroOp,
3422 idx: usize,
3423 written: &[bool],
3424 used_callee: &[Reg],
3425) -> Option<()> {
3426 match *op {
3427 MicroOp::LoadConst { dst, value } => {
3428 // Materialize the raw bits in a GP scratch; if the dst is XMM-
3429 // resident (a float const like `0.0`), bridge through `movq`.
3430 g.asm.mov_ri(S0, value);
3431 if let Loc::Xmm(_) = g.loc[dst as usize] {
3432 g.asm.movq_xr(FS0, S0);
3433 g.fstore(dst, FS0);
3434 } else {
3435 g.store(dst, S0);
3436 }
3437 }
3438 MicroOp::Move { dst, src } => {
3439 // A raw-bits copy. If EITHER end is XMM-resident, route through an
3440 // XMM scratch (fload/fstore bit-preserve across all locations);
3441 // otherwise the GP path (a direct reg move when both resident).
3442 if matches!(g.loc[dst as usize], Loc::Xmm(_))
3443 || matches!(g.loc[src as usize], Loc::Xmm(_))
3444 {
3445 let x = g.foperand(src, FS0);
3446 g.fstore(dst, x);
3447 } else {
3448 let r = g.operand(src, S0);
3449 g.store(dst, r);
3450 }
3451 }
3452 MicroOp::Add { dst, lhs, rhs } => emit_commutative(g, dst, lhs, rhs, AsmBinop::Add, idx),
3453 MicroOp::Sub { dst, lhs, rhs } => emit_sub(g, dst, lhs, rhs, idx),
3454 MicroOp::Mul { dst, lhs, rhs } => emit_commutative(g, dst, lhs, rhs, AsmBinop::Mul, idx),
3455 MicroOp::BitAnd { dst, lhs, rhs } => emit_commutative(g, dst, lhs, rhs, AsmBinop::And, idx),
3456 MicroOp::BitOr { dst, lhs, rhs } => emit_commutative(g, dst, lhs, rhs, AsmBinop::Or, idx),
3457 MicroOp::BitXor { dst, lhs, rhs } => emit_commutative(g, dst, lhs, rhs, AsmBinop::Xor, idx),
3458 MicroOp::Lt { dst, lhs, rhs } => emit_compare(g, dst, lhs, rhs, Cond::Lt),
3459 MicroOp::Gt { dst, lhs, rhs } => emit_compare(g, dst, lhs, rhs, Cond::Gt),
3460 MicroOp::LtEq { dst, lhs, rhs } => emit_compare(g, dst, lhs, rhs, Cond::Le),
3461 MicroOp::GtEq { dst, lhs, rhs } => emit_compare(g, dst, lhs, rhs, Cond::Ge),
3462 MicroOp::Eq { dst, lhs, rhs } => emit_compare(g, dst, lhs, rhs, Cond::Eq),
3463 MicroOp::Neq { dst, lhs, rhs } => emit_compare(g, dst, lhs, rhs, Cond::Ne),
3464 MicroOp::NotInt { dst, src } => {
3465 g.load(S0, src);
3466 g.asm.not_r(S0);
3467 g.store(dst, S0);
3468 }
3469 MicroOp::NotBool { dst, src } => {
3470 // dst = src ^ 1
3471 g.load(S0, src);
3472 g.asm.mov_ri(S1, 1);
3473 g.asm.xor_rr(S0, S1);
3474 g.store(dst, S0);
3475 }
3476 MicroOp::Shl { dst, lhs, rhs } => emit_shift(g, dst, lhs, rhs, ShiftKind::Left),
3477 MicroOp::Shr { dst, lhs, rhs } => emit_shift(g, dst, lhs, rhs, ShiftKind::Right),
3478 MicroOp::DivPow2 { dst, lhs, k } => emit_div_pow2(g, dst, lhs, k),
3479 MicroOp::MagicDivU { dst, lhs, magic, more, mul_back } => {
3480 emit_magic_div(g, dst, lhs, magic, more, mul_back)
3481 }
3482 MicroOp::Div { dst, lhs, rhs } => {
3483 let dl = g.checked_exit(idx).expect("Div without a deopt label");
3484 emit_div_mod(g, dst, lhs, rhs, true, dl);
3485 }
3486 MicroOp::Mod { dst, lhs, rhs } => {
3487 let dl = g.checked_exit(idx).expect("Mod without a deopt label");
3488 emit_div_mod(g, dst, lhs, rhs, false, dl);
3489 }
3490 MicroOp::Branch { cmp, lhs, rhs, target } => {
3491 // Transfer to `target` when cmp(lhs, rhs) is FALSE; fall through
3492 // when TRUE. So jump on the NEGATED condition.
3493 let a = g.operand(lhs, S0);
3494 let b = g.operand(rhs, S1);
3495 g.asm.cmp_rr(a, b);
3496 let neg = cond_of(cmp.negated());
3497 g.asm.jcc(neg, g.op_labels[target]);
3498 }
3499 MicroOp::Jump { target } => {
3500 if target != idx + 1 {
3501 g.asm.jmp(g.op_labels[target]);
3502 }
3503 }
3504 MicroOp::JumpIfFalse { cond, target } => {
3505 let r = g.operand(cond, S0);
3506 g.asm.test_rr(r, r);
3507 g.asm.jcc(Cond::Eq, g.op_labels[target]); // ZF set => value == 0
3508 }
3509 MicroOp::JumpIfTrue { cond, target } => {
3510 let r = g.operand(cond, S0);
3511 g.asm.test_rr(r, r);
3512 g.asm.jcc(Cond::Ne, g.op_labels[target]); // ZF clear => value != 0
3513 }
3514 MicroOp::ArrLoad { dst, idx: idx_slot, ptr_slot, len_slot, byte: false, narrow32: true, checked } => {
3515 let dl = g.checked_exit(idx);
3516 emit_arr_load_i32(g, dst, idx_slot, ptr_slot, len_slot, checked, dl);
3517 }
3518 MicroOp::ArrStore { src, idx: idx_slot, ptr_slot, len_slot, byte: false, narrow32: true, checked } => {
3519 let dl = g.checked_exit(idx);
3520 emit_arr_store_i32(g, src, idx_slot, ptr_slot, len_slot, checked, dl);
3521 }
3522 MicroOp::ArrLoad { dst, idx: idx_slot, ptr_slot, len_slot, byte: false, narrow32: false, checked } => {
3523 // The exit label is only consulted for a CHECKED access; an unchecked
3524 // load needs none (and a region with no checked op has no label).
3525 let dl = g.checked_exit(idx);
3526 emit_arr_load(g, dst, idx_slot, ptr_slot, len_slot, checked, dl);
3527 }
3528 MicroOp::ArrStore { src, idx: idx_slot, ptr_slot, len_slot, byte: false, narrow32: false, checked } => {
3529 let dl = g.checked_exit(idx);
3530 emit_arr_store(g, src, idx_slot, ptr_slot, len_slot, checked, dl);
3531 }
3532 MicroOp::ArrLoad { dst, idx: idx_slot, ptr_slot, len_slot, byte: true, narrow32: _, checked } => {
3533 let dl = g.checked_exit(idx);
3534 emit_arr_load_byte(g, dst, idx_slot, ptr_slot, len_slot, checked, dl);
3535 }
3536 MicroOp::ArrStore { src, idx: idx_slot, ptr_slot, len_slot, byte: true, narrow32: _, checked } => {
3537 let dl = g.checked_exit(idx);
3538 emit_arr_store_byte(g, src, idx_slot, ptr_slot, len_slot, checked, dl);
3539 }
3540 // ---- LIST MUTATION (wave 13) — helper calls into the JIT runtime. ----
3541 MicroOp::ArrPush { src, vec_slot, ptr_slot, len_slot, helper_addr, byte, narrow32 } => {
3542 emit_list_push(g, idx, src, vec_slot, ptr_slot, len_slot, helper_addr, byte, narrow32);
3543 }
3544 MicroOp::ListClear { vec_slot, ptr_slot, len_slot, helper_addr } => {
3545 emit_list_clear(g, idx, vec_slot, ptr_slot, len_slot, helper_addr);
3546 }
3547 MicroOp::StrAppend { text_handle_slot, src, helper_addr } => {
3548 emit_str_append(g, idx, text_handle_slot, src, helper_addr);
3549 }
3550 // ---- MODE-B list machinery (precise path). The fresh-list-RETURN
3551 // recursion shape (mergesort): `NewList` allocates a registry-owned fresh
3552 // buffer and plants its pin triple, `ArrPush` (above) appends with a
3553 // realloc refresh, and `ListTriple` refreshes a triple from a live handle
3554 // (a self-call's returned list). ----
3555 MicroOp::NewList { vec_slot, ptr_slot, len_slot, helper_addr } => {
3556 emit_new_list(g, idx, vec_slot, ptr_slot, len_slot, helper_addr);
3557 }
3558 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, helper_addr } => {
3559 emit_list_triple(g, idx, handle_slot, vec_slot, ptr_slot, len_slot, helper_addr);
3560 }
3561 // ---- FLOAT (f64 / XMM) ops. IEEE; no FMA; no reassociation. ----
3562 MicroOp::AddF { dst, lhs, rhs } => emit_fbinop(g, dst, lhs, rhs, FBinop::Add),
3563 MicroOp::SubF { dst, lhs, rhs } => emit_fbinop(g, dst, lhs, rhs, FBinop::Sub),
3564 MicroOp::MulF { dst, lhs, rhs } => emit_fbinop(g, dst, lhs, rhs, FBinop::Mul),
3565 MicroOp::DivF { dst, lhs, rhs } => {
3566 let dl = g.checked_exit(idx).expect("DivF without a deopt label");
3567 emit_fdiv(g, dst, lhs, rhs, dl);
3568 }
3569 MicroOp::SqrtF { dst, src } => {
3570 let s = g.foperand(src, FS1);
3571 g.asm.sqrtsd_rr(FS0, s);
3572 g.fstore(dst, FS0);
3573 }
3574 MicroOp::IntToFloat { dst, src } => {
3575 // cvtsi2sd needs a GP source; materialize the int into S0.
3576 let r = g.operand(src, S0);
3577 g.asm.cvtsi2sd(FS0, r);
3578 g.fstore(dst, FS0);
3579 }
3580 MicroOp::FmaF { dst, a, b, c } => {
3581 // TWO IEEE roundings (mulsd THEN addsd), bit-identical to the
3582 // reference `(a*b) + c`. NEVER a fused `vfmadd` (one rounding).
3583 g.fload(FS0, a); // FS0 = a
3584 let bv = g.foperand(b, FS1);
3585 g.asm.mulsd_rr(FS0, bv); // FS0 = a*b (rounded once)
3586 let cv = g.foperand(c, FS1);
3587 g.asm.addsd_rr(FS0, cv); // FS0 = (a*b) + c (rounded again)
3588 g.fstore(dst, FS0);
3589 }
3590 MicroOp::LtF { dst, lhs, rhs } => emit_fcompare(g, dst, lhs, rhs, FCmp::Lt),
3591 MicroOp::GtF { dst, lhs, rhs } => emit_fcompare(g, dst, lhs, rhs, FCmp::Gt),
3592 MicroOp::LtEqF { dst, lhs, rhs } => emit_fcompare(g, dst, lhs, rhs, FCmp::Le),
3593 MicroOp::GtEqF { dst, lhs, rhs } => emit_fcompare(g, dst, lhs, rhs, FCmp::Ge),
3594 MicroOp::EqF { dst, lhs, rhs } => emit_feq(g, dst, lhs, rhs, false),
3595 MicroOp::NeqF { dst, lhs, rhs } => emit_feq(g, dst, lhs, rhs, true),
3596 MicroOp::BranchF { cmp, lhs, rhs, target } => {
3597 emit_branchf(g, cmp, lhs, rhs, g.op_labels[target]);
3598 }
3599 MicroOp::Return { src } => {
3600 emit_flush_and_return(g, written, used_callee, Some(src));
3601 }
3602 _ => return None,
3603 }
3604 Some(())
3605}
3606
3607/// Compute the 1-based array element address into [`S0`], side-exiting to the
3608/// deopt epilogue on a CHECKED out-of-bounds index — bit-identical to the
3609/// `ST_ARRLD`/`ST_ARRLDB`/`ST_ARRST`/`ST_ARRSTB` stencils: `im1 = idx - 1`
3610/// (wrapping); a checked access exits when `(im1 as u64) >= (len as u64)` (the
3611/// unsigned compare catches both the 0/negative index — `im1` wraps huge — and
3612/// the over-length case); `addr = ptr + im1 * stride`, where `stride` is 8 for
3613/// 8-byte int/float elements (`byte == false`) and 1 for 1-byte `Seq of Bool`
3614/// elements (`byte == true`). After this returns the address is in `S0` and the
3615/// length/pointer scratch (`S1`) is dead.
3616fn emit_arr_addr(g: &mut Gen, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, byte: bool, dl: Option<LabelId>) {
3617 // SCALED-INDEX CSE FAST PATH. When the reserved register already holds the
3618 // 0-based index `im1 = frame[idx] - 1` for THIS index slot at THIS element
3619 // stride (a hit established by an earlier access in this straight-line run),
3620 // skip the index reload + decrement. The bounds check still runs PER ACCESS
3621 // (each array has its own length) against the cached TRUE `im1`, so an
3622 // out-of-bounds index — including the wrapped huge value a 0/negative index
3623 // produces — side-exits identically; only the `idx - 1` (and, with the second
3624 // reserved register, the `* 8` scaling) arithmetic is shared.
3625 if let (Some(im1_reg), Some(state)) = (g.off_im1_reg, g.off_cache) {
3626 if state.idx == idx && state.byte == byte {
3627 if checked {
3628 let dl = dl.expect("checked array op without a deopt label");
3629 // The invariant length: a hoisted len register (loop pre-header
3630 // load) when active, else the per-access frame reload.
3631 let len = g.hoisted_len(ptr_slot).unwrap_or_else(|| g.operand(len_slot, S1));
3632 g.asm.cmp_rr(im1_reg, len); // im1 - len
3633 g.asm.jcc(Cond::AeU, dl); // (im1 as u64) >= (len as u64) → OOB
3634 }
3635 // addr = ptr + im1 * stride, reusing the cached index. A byte stride
3636 // is `im1` itself; an 8-byte stride reuses the cached scaled offset
3637 // (second register) or re-shifts the cached `im1` (one register).
3638 if byte {
3639 g.asm.mov_rr(S0, im1_reg);
3640 } else if let Some(scaled_reg) = g.off_scaled_reg {
3641 g.asm.mov_rr(S0, scaled_reg); // S0 = im1 * 8 (cached)
3642 } else {
3643 g.asm.mov_rr(S0, im1_reg);
3644 g.asm.shl_ri(S0, 3); // im1 * 8
3645 }
3646 // The invariant base pointer: the hoisted ptr register when active,
3647 // else the per-access frame reload.
3648 let ptr = g.hoisted_ptr(ptr_slot).unwrap_or_else(|| g.operand(ptr_slot, S1));
3649 g.asm.add_rr(S0, ptr); // S0 = ptr + im1*stride
3650 return;
3651 }
3652 }
3653
3654 // COMPUTE PATH (cache miss or CSE disabled): the literal per-access lowering,
3655 // additionally populating the reserved register(s) so the next access in the
3656 // run hits the fast path above. Bit-identical to the stencil tier.
3657 // S0 = idx; S0 -= 1 → im1.
3658 g.load(S0, idx);
3659 g.asm.sub_ri(S0, 1);
3660 // Cache the freshly computed `im1` (the 0-based index) for reuse.
3661 if let Some(im1_reg) = g.off_im1_reg {
3662 g.asm.mov_rr(im1_reg, S0);
3663 g.off_cache = Some(OffCacheState { idx, byte });
3664 }
3665 if checked {
3666 let dl = dl.expect("checked array op without a deopt label");
3667 let len = g.hoisted_len(ptr_slot).unwrap_or_else(|| g.operand(len_slot, S1));
3668 g.asm.cmp_rr(S0, len); // im1 - len
3669 g.asm.jcc(Cond::AeU, dl); // (im1 as u64) >= (len as u64) → OOB
3670 }
3671 // addr = ptr + im1 * stride. Byte (Bool) elements are 1-byte: no scaling.
3672 if !byte {
3673 g.asm.shl_ri(S0, 3); // im1 * 8 (8-byte i64/f64 elements)
3674 // Cache the scaled offset too (second register), so a reused 8-byte
3675 // access skips the shift as well.
3676 if g.off_im1_reg.is_some() {
3677 if let Some(scaled_reg) = g.off_scaled_reg {
3678 g.asm.mov_rr(scaled_reg, S0);
3679 }
3680 }
3681 }
3682 let ptr = g.hoisted_ptr(ptr_slot).unwrap_or_else(|| g.operand(ptr_slot, S1));
3683 g.asm.add_rr(S0, ptr); // S0 = ptr + im1*stride
3684}
3685
3686/// `frame[dst] = buffer[frame[idx] - 1]` (8-byte int element). The loaded value
3687/// goes through scratch `S1` so a resident `dst` is written with a register move.
3688/// A CHECKED out-of-bounds index jumps to `dl` (the classic deopt epilogue or, in
3689/// the precise path, this op's tag-staging block).
3690fn emit_arr_load(g: &mut Gen, dst: Slot, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3691 emit_arr_addr(g, idx, ptr_slot, len_slot, checked, false, dl);
3692 g.asm.mov_rm(S1, S0, 0); // S1 = *addr
3693 g.store(dst, S1);
3694}
3695
3696/// `frame[dst] = buffer[frame[idx] - 1] as i64` over 1-BYTE (`Seq of Bool`)
3697/// elements — bit-identical to `logos_stencil_arrldb`: a zero-extended 1-byte
3698/// load (`movzx`), so the loaded `u8` (0..=255, in practice 0/1) widens to a
3699/// NON-NEGATIVE i64. The loaded value goes through scratch `S1` so a resident
3700/// `dst` is written with a register move; OOB side-exit is identical to the
3701/// 8-byte path (same `im1`/length unsigned guard).
3702fn emit_arr_load_byte(g: &mut Gen, dst: Slot, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3703 emit_arr_addr(g, idx, ptr_slot, len_slot, checked, true, dl);
3704 g.asm.movzx_rm8(S1, S0, 0); // S1 = *(u8*)addr (zero-extended)
3705 g.store(dst, S1);
3706}
3707
3708/// `buffer[frame[idx] - 1] = frame[src]` (8 raw bytes). The stored value is the
3709/// slot's raw bits, so a FLOAT-resident (XMM) source is bit-copied to a GP
3710/// register via `movq` first — the buffer holds the same 8 bytes either way. A
3711/// CHECKED store side-exits BEFORE the write (to `dl`), so an out-of-bounds store
3712/// leaves the buffer untouched (the reference and stencil contract).
3713fn emit_arr_store(g: &mut Gen, src: Slot, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3714 emit_arr_addr(g, idx, ptr_slot, len_slot, checked, false, dl);
3715 // The address is in S0 and the pointer scratch S1 is dead. Materialize the
3716 // source bits into a GP reg that cannot collide with the address: SC (rcx)
3717 // for a GP/frame source, or S1 (the dead pointer scratch) for an XMM source.
3718 let v = match g.loc[src as usize] {
3719 Loc::Xmm(x) => {
3720 g.asm.movq_rx(S1, x);
3721 S1
3722 }
3723 _ => g.operand(src, SC),
3724 };
3725 g.asm.mov_mr(S0, 0, v); // *addr = value
3726}
3727
3728/// `buffer[frame[idx] - 1] = (frame[src] != 0) as u8` over 1-BYTE (`Seq of Bool`)
3729/// elements — bit-identical to `logos_stencil_arrstb`: the stored byte is the
3730/// BOOLEAN NORMALIZATION of the value (1 when nonzero, else 0), NOT its raw low
3731/// byte. A CHECKED store side-exits BEFORE the write (to `dl`), so an
3732/// out-of-bounds store leaves the buffer untouched.
3733///
3734/// The normalization is `test v, v; setne S1l` — `S1` (rdx) is the dead pointer
3735/// scratch after the address is in `S0`, and its low byte `dl` is REX-free
3736/// addressable. Only that low byte is then stored. An XMM-resident source is
3737/// bit-copied to `SC` (rcx) first so its full 64 bits feed the zero test (a
3738/// nonzero f64 bit pattern — incl. `-0.0` and NaN — stores 1, matching `!= 0`).
3739fn emit_arr_store_byte(g: &mut Gen, src: Slot, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3740 emit_arr_addr(g, idx, ptr_slot, len_slot, checked, true, dl);
3741 // Materialize the source value into a GP reg distinct from the address (S0).
3742 // SC (rcx) for a GP/frame source; for an XMM source, bit-copy to SC.
3743 let v = match g.loc[src as usize] {
3744 Loc::Xmm(x) => {
3745 g.asm.movq_rx(SC, x);
3746 SC
3747 }
3748 _ => g.operand(src, SC),
3749 };
3750 // (v != 0) → S1's low byte via test + setne; store that one byte.
3751 g.asm.test_rr(v, v);
3752 g.asm.setcc8(Cond::Ne, S1);
3753 g.asm.mov_mr8(S0, 0, S1); // *(u8*)addr = (v != 0) as u8
3754}
3755
3756/// Address machinery for a 4-byte (`IntsI32`) element: `S0 = ptr + (idx-1)*4`,
3757/// with the same unsigned over-length OOB guard as [`emit_arr_addr`]. A literal
3758/// per-access lowering (no scaled-index CSE — the half-width lane is rare and
3759/// the index reuse is the dominant cost, already covered by the loop's other
3760/// accesses); after this the address is in `S0` and the scratch `S1` is dead.
3761fn emit_arr_addr_i32(g: &mut Gen, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3762 g.load(S0, idx);
3763 g.asm.sub_ri(S0, 1); // im1 = idx - 1
3764 if checked {
3765 let dl = dl.expect("checked array op without a deopt label");
3766 let len = g.hoisted_len(ptr_slot).unwrap_or_else(|| g.operand(len_slot, S1));
3767 g.asm.cmp_rr(S0, len); // im1 - len
3768 g.asm.jcc(Cond::AeU, dl); // (im1 as u64) >= (len as u64) → OOB
3769 }
3770 g.asm.shl_ri(S0, 2); // im1 * 4 (4-byte i32 elements)
3771 let ptr = g.hoisted_ptr(ptr_slot).unwrap_or_else(|| g.operand(ptr_slot, S1));
3772 g.asm.add_rr(S0, ptr); // S0 = ptr + im1*4
3773}
3774
3775/// `frame[dst] = buffer[frame[idx] - 1] as i64` over 4-byte SIGN-EXTENDED
3776/// (`IntsI32`) elements — bit-identical to `logos_stencil_arrld_i32`: a
3777/// `movsxd` widening the stored `i32` to a full i64. Through scratch `S1` so a
3778/// resident `dst` is written with a register move; OOB side-exit identical to
3779/// the 8-byte path. A half-width access drops the scaled-index CSE cache (its
3780/// stride differs from any cached 8-byte run).
3781fn emit_arr_load_i32(g: &mut Gen, dst: Slot, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3782 g.invalidate_off_cache();
3783 emit_arr_addr_i32(g, idx, ptr_slot, len_slot, checked, dl);
3784 g.asm.movsxd_rm(S1, S0, 0); // S1 = *(i32*)addr (sign-extended)
3785 g.store(dst, S1);
3786}
3787
3788/// `buffer[frame[idx] - 1] = frame[src] as i32` over 4-byte (`IntsI32`)
3789/// elements — bit-identical to `logos_stencil_arrst_i32`: a truncating 4-byte
3790/// store (lossless under the narrowing proof). A CHECKED store side-exits
3791/// BEFORE the write. An XMM-resident source is bit-copied to a GP reg first.
3792fn emit_arr_store_i32(g: &mut Gen, src: Slot, idx: Slot, ptr_slot: Slot, len_slot: Slot, checked: bool, dl: Option<LabelId>) {
3793 g.invalidate_off_cache();
3794 emit_arr_addr_i32(g, idx, ptr_slot, len_slot, checked, dl);
3795 let v = match g.loc[src as usize] {
3796 Loc::Xmm(x) => {
3797 g.asm.movq_rx(S1, x);
3798 S1
3799 }
3800 _ => g.operand(src, SC),
3801 };
3802 g.asm.mov_mr32(S0, 0, v); // *(i32*)addr = value as i32 (low 4 bytes)
3803}
3804
3805/// Lower a pinned-list PUSH (`ArrPush`) with an INLINE FAST PATH plus a cold
3806/// realloc helper call — the V8-style array-push lowering.
3807///
3808/// The common case (the appended element fits the current capacity) runs ENTIRELY
3809/// in scratch registers, with NO SysV call and NO caller-saved spill/reload:
3810///
3811/// ```text
3812/// vec = frame[vec_slot] // the live *mut Vec
3813/// len = frame[len_slot] // the mirrored length
3814/// if (len as u64) < (*(vec+cap_off) as u64): // spare capacity?
3815/// ptr = frame[ptr_slot]
3816/// ptr[len] = value // store (8-byte raw, or (v!=0) byte)
3817/// len += 1
3818/// frame[len_slot] = len // refresh the mirror
3819/// *(vec+len_off) = len // AND the real Vec::len field
3820/// goto join
3821/// // else fall through to the cold realloc path:
3822/// <spill caller-saved>; helper(frame, vec, ptr, len, value); <reload>
3823/// join:
3824/// ```
3825///
3826/// SOUNDNESS — bit-identical to `logos_rt_push_*` (and thus to `Vec::push` and the
3827/// tree-walker) in every case:
3828/// * The capacity test uses the SAME `cap`/`len` the helper would (`(*vec).len() <
3829/// (*vec).capacity()` is exactly `Vec::push`'s "no grow" condition), read from
3830/// the LIVE `Vec` at PROBED field offsets ([`vec_layout`]) — never a hardcoded
3831/// layout — so it is correct on this exact binary.
3832/// * On the fast path the buffer does NOT move, so `frame[ptr_slot]` stays valid
3833/// (the helper would not refresh it either) and the store lands at the identical
3834/// address `ptr + len*stride` the helper's `(*vec).push` writes.
3835/// * BOTH the mirrored `frame[len_slot]` AND the real `Vec::len` field are bumped,
3836/// so a later non-fast access (a subsequent realloc helper push, a deopt that
3837/// materializes/keeps the live `Rc<Vec>`, or the `Vec`'s eventual drop) observes
3838/// the appended element exactly as after `Vec::push`.
3839/// * The cold path is the ORIGINAL helper-call lowering verbatim: on a realloc
3840/// (`len == cap`) the helper reallocates and refreshes `frame[ptr_slot]` /
3841/// `frame[len_slot]` (and the real `Vec` fields), so the next access reads the
3842/// fresh pointer — the realloc-coherence contract is unchanged.
3843/// * The fast path touches ONLY the reserved scratch registers `S0`/`S1`/`SC`
3844/// (`rax`/`rdx`/`rcx`), which are NEVER assigned to a resident slot, so it
3845/// clobbers no loop-carried value and needs no spill/reload. The cold path keeps
3846/// the full caller-saved spill discipline.
3847/// * The vec/ptr/len triple is forced FRAME-resident (`force_frame_set`), so both
3848/// paths read/write the same frame cells and the helper's view agrees.
3849fn emit_list_push(
3850 g: &mut Gen,
3851 idx: usize,
3852 src: Slot,
3853 vec_slot: Slot,
3854 ptr_slot: Slot,
3855 len_slot: Slot,
3856 helper_addr: i64,
3857 byte: bool,
3858 narrow32: bool,
3859) {
3860 // Kill-switch / A-B toggle: fall back to the always-call helper lowering.
3861 if !inline_push_enabled() {
3862 g.spill_volatiles_at(idx);
3863 g.load(Reg::R8, src);
3864 g.asm.mov_rr(Reg::Rdi, BASE);
3865 g.asm.mov_ri(Reg::Rsi, vec_slot as i64);
3866 g.asm.mov_ri(Reg::Rdx, ptr_slot as i64);
3867 g.asm.mov_ri(Reg::Rcx, len_slot as i64);
3868 g.asm.mov_ri(S0, helper_addr);
3869 g.asm.call_r(S0);
3870 g.reload_volatiles_at(idx);
3871 return;
3872 }
3873
3874 let layout = vec_layout();
3875 let cold = g.asm.new_label();
3876 let join = g.asm.new_label();
3877
3878 // ---- FAST PATH ---- (scratch-only; no spill, no call when len < cap)
3879 // S0 = vec handle (kept live for the real-len writeback); S1 = len; SC = cap.
3880 g.asm.mov_rm(S0, BASE, (vec_slot as i32) * 8);
3881 g.asm.mov_rm(S1, BASE, (len_slot as i32) * 8);
3882 g.asm.mov_rm(SC, S0, layout.cap_off); // SC = (*vec).capacity
3883 g.asm.cmp_rr(S1, SC); // len - cap
3884 g.asm.jcc(Cond::AeU, cold); // (len as u64) >= (cap as u64) -> realloc (cold)
3885
3886 // Materialize the pushed value into SC (cap is dead). An XMM-resident
3887 // float-list value is bit-copied via movq — the raw 8 bytes travel to the
3888 // buffer exactly as the helper's `f64::from_bits(...).push(...)` round-trip.
3889 match g.loc[src as usize] {
3890 Loc::Xmm(x) => g.asm.movq_rx(SC, x),
3891 _ => {
3892 let v = g.operand(src, SC);
3893 if v != SC {
3894 g.asm.mov_rr(SC, v);
3895 }
3896 }
3897 }
3898 // addr = ptr + len*stride, computed into S0 (vec dead now; reloaded below for
3899 // the real-len writeback). S1 still holds `len`.
3900 g.asm.mov_rm(S0, BASE, (ptr_slot as i32) * 8); // S0 = ptr
3901 if narrow32 {
3902 g.asm.shl_ri(S1, 2); // len * 4 (4-byte i32 elements)
3903 } else if !byte {
3904 g.asm.shl_ri(S1, 3); // len * 8 (8-byte i64/f64 elements)
3905 }
3906 g.asm.add_rr(S0, S1); // S0 = ptr + len*stride (S1 now dead)
3907 if byte {
3908 // 1-byte (`Seq of Bool`) store: the BOOLEAN NORMALIZATION (v != 0) as u8,
3909 // matching `logos_rt_push_bool`'s `(*vec).push(value != 0)`.
3910 g.asm.test_rr(SC, SC);
3911 g.asm.setcc8(Cond::Ne, S1);
3912 g.asm.mov_mr8(S0, 0, S1); // *(u8*)addr = (value != 0) as u8
3913 } else if narrow32 {
3914 // 4-byte (`IntsI32`) store: TRUNCATE the value to its low 4 bytes
3915 // (lossless under the narrowing proof), matching `logos_rt_push_i32`.
3916 g.asm.mov_mr32(S0, 0, SC); // *(i32*)addr = value as i32
3917 } else {
3918 g.asm.mov_mr(S0, 0, SC); // *addr = value (8 raw bytes)
3919 }
3920 // Bump the length: new_len = old_len + 1. Refresh BOTH the mirrored frame
3921 // slot AND the real `Vec::len` field so every later view is coherent.
3922 g.asm.mov_rm(S1, BASE, (len_slot as i32) * 8); // reload old len
3923 g.asm.add_ri(S1, 1); // new_len
3924 g.asm.mov_mr(BASE, (len_slot as i32) * 8, S1); // frame[len_slot] = new_len
3925 g.asm.mov_rm(S0, BASE, (vec_slot as i32) * 8); // reload vec handle
3926 g.asm.mov_mr(S0, layout.len_off, S1); // (*vec).len = new_len
3927 g.asm.jmp(join);
3928
3929 // ---- COLD PATH ---- (realloc: the original helper-call lowering verbatim).
3930 g.asm.bind(cold);
3931 // Spill the LIVE-AFTER caller-saved residents to their frame slots FIRST (the
3932 // helper clobbers them). This must precede touching any arg register: the arg
3933 // registers (rdi/rsi/rdx/rcx/r8) are themselves in the caller-saved pool, so a
3934 // live resident may sit in one of them. Spilling reads each resident's
3935 // register (still intact) into the frame; only AFTER that is it safe to
3936 // overwrite those registers with call args.
3937 g.spill_volatiles_at(idx);
3938 // Materialize the pushed value into the value-arg register (r8) AFTER the
3939 // spill (so no resident's value is lost). XMM-resident floats bit-copy.
3940 g.load(Reg::R8, src);
3941 g.asm.mov_rr(Reg::Rdi, BASE);
3942 g.asm.mov_ri(Reg::Rsi, vec_slot as i64);
3943 g.asm.mov_ri(Reg::Rdx, ptr_slot as i64);
3944 g.asm.mov_ri(Reg::Rcx, len_slot as i64);
3945 g.asm.mov_ri(S0, helper_addr);
3946 g.asm.call_r(S0);
3947 g.reload_volatiles_at(idx);
3948
3949 g.asm.bind(join);
3950}
3951
3952/// Lower a pinned-list in-place CLEAR (`ListClear`) as a SysV call to
3953/// `logos_rt_clear_*(frame, vec_slot, ptr_slot, len_slot)`: truncate the
3954/// SOLE-OWNED buffer to empty (keep its capacity) and refresh
3955/// `frame[ptr_slot]`/`frame[len_slot]` (length → 0). The buffer-reuse
3956/// alias-safety is established UPSTREAM in the micro-op lowering (a
3957/// `Op::NewEmptyList` whose handle escapes via a live `Move` declines to emit
3958/// `ListClear`), so a `ListClear` reaching here is provably unaliased. Same
3959/// frame-resident-triple + caller-saved spill discipline as the push, minus the
3960/// value argument.
3961fn emit_list_clear(g: &mut Gen, idx: usize, vec_slot: Slot, ptr_slot: Slot, len_slot: Slot, helper_addr: i64) {
3962 g.spill_volatiles_at(idx);
3963 g.asm.mov_rr(Reg::Rdi, BASE);
3964 g.asm.mov_ri(Reg::Rsi, vec_slot as i64);
3965 g.asm.mov_ri(Reg::Rdx, ptr_slot as i64);
3966 g.asm.mov_ri(Reg::Rcx, len_slot as i64);
3967 g.asm.mov_ri(S0, helper_addr);
3968 g.asm.call_r(S0);
3969 g.reload_volatiles_at(idx);
3970}
3971
3972/// Lower a fresh-list ALLOCATION (`NewList`, mode B) as a SysV call to
3973/// `logos_rt_alloc_list_i64(frame, vec_slot, ptr_slot, len_slot)`: the helper
3974/// `Box`-allocates a fresh empty `Vec`, REGISTERS it in the thread-local
3975/// allocation registry (so a deopt drains it and a `Return` detaches it), and
3976/// plants the pin triple — writing `frame[vec_slot]` (the live `*mut Vec`),
3977/// `frame[ptr_slot]` (its buffer pointer) and `frame[len_slot]` (0). The triple
3978/// is forced FRAME-resident (so the helper's frame writes are the single source
3979/// of truth and every later push/load reads the fresh pointer/length); the call
3980/// takes no value argument. Same caller-saved spill discipline as the push/clear
3981/// helpers — bit-identical to the per-piece tier's `ST_ALLOCLIST` stencil.
3982fn emit_new_list(g: &mut Gen, idx: usize, vec_slot: Slot, ptr_slot: Slot, len_slot: Slot, helper_addr: i64) {
3983 g.spill_volatiles_at(idx);
3984 g.asm.mov_rr(Reg::Rdi, BASE);
3985 g.asm.mov_ri(Reg::Rsi, vec_slot as i64);
3986 g.asm.mov_ri(Reg::Rdx, ptr_slot as i64);
3987 g.asm.mov_ri(Reg::Rcx, len_slot as i64);
3988 g.asm.mov_ri(S0, helper_addr);
3989 g.asm.call_r(S0);
3990 g.reload_volatiles_at(idx);
3991}
3992
3993/// Lower a triple-plant (`ListTriple`, mode B) as a SysV call to
3994/// `logos_rt_list_triple(frame, handle_slot, vec_slot, ptr_slot, len_slot)`: read
3995/// `frame[handle_slot]` (a live `*mut Vec`) and refresh the pin triple. The
3996/// handle and triple slots are all forced FRAME-resident (so the helper's frame
3997/// view is the source of truth). Same caller-saved spill discipline; the handle
3998/// slot rides the fifth SysV arg (`r8`).
3999fn emit_list_triple(
4000 g: &mut Gen,
4001 idx: usize,
4002 handle_slot: Slot,
4003 vec_slot: Slot,
4004 ptr_slot: Slot,
4005 len_slot: Slot,
4006 helper_addr: i64,
4007) {
4008 g.spill_volatiles_at(idx);
4009 g.asm.mov_rr(Reg::Rdi, BASE);
4010 g.asm.mov_ri(Reg::Rsi, handle_slot as i64);
4011 g.asm.mov_ri(Reg::Rdx, vec_slot as i64);
4012 g.asm.mov_ri(Reg::Rcx, ptr_slot as i64);
4013 g.asm.mov_ri(Reg::R8, len_slot as i64);
4014 g.asm.mov_ri(S0, helper_addr);
4015 g.asm.call_r(S0);
4016 g.reload_volatiles_at(idx);
4017}
4018
4019/// Lower a pinned MUTABLE-Text append (`StrAppend`) as a SysV call to the runtime
4020/// helper `logos_rt_str_append(frame, handle_slot, src, src_len)`: the helper
4021/// reads `frame[handle_slot]` (a live `*mut Value` to the VM accumulator cell)
4022/// and grows the accumulator with EXACTLY the VM's `add_assign` (in-place when
4023/// sole-owned, copy-on-write otherwise) semantics. The handle slot is forced
4024/// FRAME-resident (the helper indexes it). The source is either a 1-char frame
4025/// BYTE — read AFTER the spill into the value-arg register `rdx`, with `rcx` =
4026/// `-1` flagging the byte form — or a baked constant byte slice (`rdx` = the
4027/// `'static` pointer, `rcx` = its length). Same caller-saved spill discipline as
4028/// the list-push helper.
4029fn emit_str_append(
4030 g: &mut Gen,
4031 idx: usize,
4032 handle_slot: Slot,
4033 src: crate::jit::StrSrc,
4034 helper_addr: i64,
4035) {
4036 // Spill the LIVE-AFTER caller-saved residents FIRST (the helper clobbers
4037 // them; the arg registers are themselves caller-saved). The byte-form value
4038 // is read AFTER the spill (so no resident is lost) but BEFORE the arg
4039 // registers overwrite the register the source might still occupy.
4040 g.spill_volatiles_at(idx);
4041 match src {
4042 crate::jit::StrSrc::Byte(s) => {
4043 g.load(Reg::Rdx, s);
4044 g.asm.mov_ri(Reg::Rcx, -1);
4045 }
4046 crate::jit::StrSrc::Const { ptr, len } => {
4047 g.asm.mov_ri(Reg::Rdx, ptr);
4048 g.asm.mov_ri(Reg::Rcx, len);
4049 }
4050 }
4051 g.asm.mov_rr(Reg::Rdi, BASE);
4052 g.asm.mov_ri(Reg::Rsi, handle_slot as i64);
4053 g.asm.mov_ri(S0, helper_addr);
4054 g.asm.call_r(S0);
4055 g.reload_volatiles_at(idx);
4056}
4057
4058#[derive(Clone, Copy)]
4059enum AsmBinop {
4060 Add,
4061 Mul,
4062 And,
4063 Or,
4064 Xor,
4065}
4066
4067/// `dst = lhs OP rhs` for a commutative op. Compute in S0 to avoid clobbering a
4068/// resident operand that may be read again later. Integer `add`/`imul` SIDE-EXIT
4069/// on signed overflow (`jo` → this op's deopt label) BEFORE the store, so the
4070/// exact tier recomputes and promotes to BigInt; bitwise ops cannot overflow.
4071fn emit_commutative(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, op: AsmBinop, idx: usize) {
4072 g.load(S0, lhs);
4073 let b = g.operand(rhs, S1);
4074 match op {
4075 AsmBinop::Add => g.asm.add_rr(S0, b),
4076 AsmBinop::Mul => g.asm.imul_rr(S0, b),
4077 AsmBinop::And => g.asm.and_rr(S0, b),
4078 AsmBinop::Or => g.asm.or_rr(S0, b),
4079 AsmBinop::Xor => g.asm.xor_rr(S0, b),
4080 }
4081 if matches!(op, AsmBinop::Add | AsmBinop::Mul) {
4082 if let Some(dl) = g.checked_exit(idx) {
4083 g.asm.jcc(Cond::Overflow, dl);
4084 }
4085 }
4086 g.store(dst, S0);
4087}
4088
4089/// `dst = lhs - rhs` (non-commutative). lhs into S0, then `sub S0, rhs`; `jo` →
4090/// deopt on signed overflow (before the store) so the exact tier promotes.
4091fn emit_sub(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, idx: usize) {
4092 g.load(S0, lhs);
4093 let b = g.operand(rhs, S1);
4094 g.asm.sub_rr(S0, b);
4095 if let Some(dl) = g.checked_exit(idx) {
4096 g.asm.jcc(Cond::Overflow, dl);
4097 }
4098 g.store(dst, S0);
4099}
4100
4101/// `dst = (lhs CMP rhs) as i64` — `cmp` then `setcc`/`movzx`.
4102fn emit_compare(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, cond: Cond) {
4103 let a = g.operand(lhs, S0);
4104 let b = g.operand(rhs, S1);
4105 g.asm.cmp_rr(a, b);
4106 g.asm.setcc_movzx(cond, S0);
4107 g.store(dst, S0);
4108}
4109
4110#[derive(Clone, Copy)]
4111enum FBinop {
4112 Add,
4113 Sub,
4114 Mul,
4115}
4116
4117/// `dst = lhs OP rhs` for an f64 arithmetic op (`addsd`/`subsd`/`mulsd`).
4118///
4119/// The instruction order is ALWAYS the source order — `OP target, second`
4120/// computes `target OP second` — so a non-commutative `subsd` keeps `lhs - rhs`
4121/// and the result is bit-identical to the reference regardless of which fast
4122/// path fires. No reassociation, no FMA.
4123///
4124/// When `dst` is XMM-resident we compute IN PLACE in `dst`, avoiding the
4125/// `fload`→FS0 + `fstore`→dst scratch round-trip (two extra `movsd`s) that the
4126/// scratch path pays every iteration on a loop-carried accumulator (nbody's
4127/// dx/dy/dz/dist, mandelbrot's zr/zi):
4128/// - `dst == lhs` (in-place `x = x OP rhs`): just `OP dst, rhs` — no moves;
4129/// - `dst != lhs` and `dst != rhs`: `movsd dst, lhs; OP dst, rhs` — the result
4130/// lands in `dst` directly (one move, no store).
4131/// The remaining case (`dst` resident, `dst == rhs`, `dst != lhs`) cannot write
4132/// `dst` first without clobbering `rhs`, so it falls through to FS0 staging.
4133fn emit_fbinop(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, op: FBinop) {
4134 let apply = |asm: &mut Asm, target: Xmm, second: Xmm| match op {
4135 FBinop::Add => asm.addsd_rr(target, second),
4136 FBinop::Sub => asm.subsd_rr(target, second),
4137 FBinop::Mul => asm.mulsd_rr(target, second),
4138 };
4139 if let Loc::Xmm(d) = g.loc[dst as usize] {
4140 // Resolve rhs to a concrete register first (its own resident XMM, or
4141 // FS1 if frame-resident). FS1 is scratch and never a slot's residence,
4142 // so a frame-resident rhs never collides with `d`.
4143 let b = g.foperand(rhs, FS1);
4144 if g.loc[lhs as usize] == Loc::Xmm(d) {
4145 // In-place accumulate: dst and lhs ARE the same register.
4146 apply(&mut g.asm, d, b);
4147 return;
4148 }
4149 if d != b {
4150 // Materialize lhs into dst, then op against rhs — result in dst.
4151 g.fload(d, lhs);
4152 apply(&mut g.asm, d, b);
4153 return;
4154 }
4155 // d == b (dst aliases rhs, dst != lhs): writing dst first would lose
4156 // rhs; fall through to the scratch path, which reads rhs (b == d) before
4157 // overwriting dst.
4158 g.fload(FS0, lhs);
4159 apply(&mut g.asm, FS0, b);
4160 g.fstore(dst, FS0);
4161 return;
4162 }
4163 g.fload(FS0, lhs);
4164 let b = g.foperand(rhs, FS1);
4165 apply(&mut g.asm, FS0, b);
4166 g.fstore(dst, FS0);
4167}
4168
4169/// `dst = lhs / rhs` (f64), side-EXITING to the deopt epilogue when the divisor
4170/// is `0.0` — bit-identical to the reference (`b == 0.0 -> None`). The IEEE
4171/// `0.0 == -0.0` makes BOTH zeros trip it: the divisor bits are compared to 0.0
4172/// via `ucomisd`, and `ucomisd -0.0, 0.0` sets ZF (equal), so `je deopt` fires
4173/// for `-0.0` too. NaN divisor is NOT zero (ucomisd sets PF, ZF — but `je` on a
4174/// NaN-vs-0 compare: NaN is unordered → ZF=1 → would wrongly deopt). Guard the
4175/// NaN case: only deopt when the unordered flag is CLEAR (an ordered equality).
4176fn emit_fdiv(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, dl: LabelId) {
4177 // Zero-divisor side-exit: compare rhs to 0.0. Build 0.0 in FS1 (xorps would
4178 // need a new encoder; instead movq from a zeroed GP reg).
4179 let b = g.foperand(rhs, FS1);
4180 // FS0 = 0.0 via movq from a zeroed GP scratch.
4181 g.asm.mov_ri(S0, 0);
4182 g.asm.movq_xr(FS0, S0);
4183 g.asm.ucomisd_rr(b, FS0); // compare rhs, 0.0
4184 // An ORDERED equality (rhs == 0.0, incl -0.0) sets ZF=1, PF=0. A NaN divisor
4185 // is unordered: ZF=1, PF=1 — must NOT deopt (NaN/x is a valid IEEE result).
4186 // So skip the deopt when PF=1 (unordered), then `je` on the ordered equal.
4187 let not_zero = g.asm.new_label();
4188 g.asm.jcc(Cond::ParityEven, not_zero); // unordered (NaN) -> not a zero divisor
4189 g.asm.jcc(Cond::Eq, dl); // ordered equal to 0.0 -> side-exit
4190 g.asm.bind(not_zero);
4191 // Normal divide: FS0 = lhs; FS0 /= rhs.
4192 g.fload(FS0, lhs);
4193 let b2 = g.foperand(rhs, FS1);
4194 g.asm.divsd_rr(FS0, b2);
4195 g.fstore(dst, FS0);
4196}
4197
4198#[derive(Clone, Copy)]
4199enum FCmp {
4200 Lt,
4201 Gt,
4202 Le,
4203 Ge,
4204}
4205
4206/// `dst = (lhs CMP rhs) as i64` for an f64 ORDERING compare, exact IEEE: a NaN
4207/// operand makes the result FALSE (0). Implemented via `ucomisd` + an UNSIGNED
4208/// setcc — for `>`/`>=` directly (`a` then `b`), for `<`/`<=` by SWAPPING the
4209/// operands (so `a < b` ≡ `b > a`). The unordered (NaN) case sets ZF=CF=1, which
4210/// the `seta`/`setae` family reads as FALSE, matching the reference.
4211fn emit_fcompare(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, cmp: FCmp) {
4212 // Decide operand order + the unsigned condition.
4213 let (a, b, cond) = match cmp {
4214 FCmp::Gt => (lhs, rhs, Cond::AU), // a > b : ucomisd a,b ; seta
4215 FCmp::Ge => (lhs, rhs, Cond::AeU), // a >= b : ucomisd a,b ; setae
4216 FCmp::Lt => (rhs, lhs, Cond::AU), // a < b ≡ b > a : ucomisd b,a ; seta
4217 FCmp::Le => (rhs, lhs, Cond::AeU), // a <= b ≡ b >= a : ucomisd b,a ; setae
4218 };
4219 let xa = g.foperand(a, FS0);
4220 let xb = g.foperand(b, FS1);
4221 g.asm.ucomisd_rr(xa, xb);
4222 g.asm.setcc_movzx(cond, S0);
4223 g.store(dst, S0);
4224}
4225
4226/// `dst = (lhs == rhs) as i64` (when `neg` is false) or `(lhs != rhs)` (`neg`
4227/// true) — IEEE float equality via `ucomisd`: ordered-equal ⟺ ZF=1 && PF=0
4228/// (PF=1 ⟺ unordered, i.e. a NaN operand, which must yield false for `==`
4229/// and true for `!=`) — bit-identical to the reference's `a == b`.
4230fn emit_feq(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, neg: bool) {
4231 let xa = g.foperand(lhs, FS0);
4232 let xb = g.foperand(rhs, FS1);
4233 g.asm.ucomisd_rr(xa, xb);
4234 if neg {
4235 // neq = ZF==0 || PF==1 (`setne` | `setp`).
4236 g.asm.setcc_movzx(Cond::Ne, S0);
4237 g.asm.setcc_movzx(Cond::ParityEven, S1);
4238 g.asm.or_rr(S0, S1);
4239 } else {
4240 // eq = ZF==1 && PF==0 (`sete` & `setnp`).
4241 g.asm.setcc_movzx(Cond::Eq, S0);
4242 g.asm.setcc_movzx(Cond::ParityOdd, S1);
4243 g.asm.and_rr(S0, S1);
4244 }
4245 g.store(dst, S0);
4246}
4247
4248/// Fused f64 compare-and-branch: transfer to `target` when `cmp(lhs, rhs)` is
4249/// FALSE — which, under IEEE, includes every NaN-unordered comparison (the
4250/// reference's `BranchF`). Ordering compares use `ucomisd` + the negated
4251/// unsigned jcc; epsilon (Eq/NotEq) reuses the `emit_feq` value then tests it.
4252fn emit_branchf(g: &mut Gen, cmp: Cmp, lhs: Slot, rhs: Slot, target: LabelId) {
4253 match cmp {
4254 Cmp::Lt | Cmp::Gt | Cmp::LtEq | Cmp::GtEq => {
4255 // For each, pick (a, b) and the "FALSE" unsigned jcc. The branch is
4256 // taken when the comparison is FALSE; NaN (unordered: CF=ZF=1) must
4257 // TAKE the false branch.
4258 // a > b true = seta(CF=0&&ZF=0) ; false = jbe(CF=1||ZF=1)
4259 // a >= b true = setae(CF=0) ; false = jb (CF=1)
4260 let (a, b, false_cond) = match cmp {
4261 Cmp::Gt => (lhs, rhs, Cond::BeU), // !(a>b)
4262 Cmp::GtEq => (lhs, rhs, Cond::BU), // !(a>=b)
4263 Cmp::Lt => (rhs, lhs, Cond::BeU), // a<b ≡ b>a ; !(b>a)
4264 Cmp::LtEq => (rhs, lhs, Cond::BU), // a<=b ≡ b>=a ; !(b>=a)
4265 _ => unreachable!(),
4266 };
4267 let xa = g.foperand(a, FS0);
4268 let xb = g.foperand(b, FS1);
4269 g.asm.ucomisd_rr(xa, xb);
4270 g.asm.jcc(false_cond, target);
4271 }
4272 Cmp::Eq | Cmp::NotEq => {
4273 // IEEE (in)equality; branch when the comparison is FALSE.
4274 let xa = g.foperand(lhs, FS0);
4275 let xb = g.foperand(rhs, FS1);
4276 g.asm.ucomisd_rr(xa, xb);
4277 if matches!(cmp, Cmp::Eq) {
4278 // truth = ordered-equal; FALSE = ZF==0 || PF==1 — two direct
4279 // jumps, and a NaN (PF=1) takes the false branch.
4280 g.asm.jcc(Cond::Ne, target);
4281 g.asm.jcc(Cond::ParityEven, target);
4282 return;
4283 }
4284 // truth = (a != b); FALSE = ordered-equal (ZF==1 && PF==0).
4285 // Materialize the IEEE neq exactly like `emit_feq`, then branch
4286 // on zero.
4287 g.asm.setcc_movzx(Cond::Ne, S0);
4288 g.asm.setcc_movzx(Cond::ParityEven, S1);
4289 g.asm.or_rr(S0, S1);
4290 g.asm.test_rr(S0, S0);
4291 g.asm.jcc(Cond::Eq, target); // truth == 0 → jump (false branch)
4292 }
4293 }
4294}
4295
4296#[derive(Clone, Copy)]
4297enum ShiftKind {
4298 Left,
4299 Right,
4300}
4301
4302/// `dst = lhs <</>> rhs` with the kernel's shift spec: the count is the low
4303/// bits of `rhs` (x86 masks the count mod 64 for 64-bit operands, matching
4304/// `wrapping_shl`/`wrapping_shr`'s `as u32 % 64`). The value goes in S0, the
4305/// count in `cl` (rcx).
4306fn emit_shift(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, kind: ShiftKind) {
4307 g.load(S0, lhs);
4308 g.load(SC, rhs);
4309 match kind {
4310 ShiftKind::Left => g.asm.shl_cl(S0),
4311 ShiftKind::Right => g.asm.sar_cl(S0),
4312 }
4313 g.store(dst, S0);
4314}
4315
4316/// `dst = lhs / 2^k` (signed, round toward zero) via the sign-correcting shift,
4317/// bit-exact with `reference_eval`'s `DivPow2`:
4318/// `(x + ((x >> 63) & ((1<<k)-1))) >> k`.
4319fn emit_div_pow2(g: &mut Gen, dst: Slot, lhs: Slot, k: u32) {
4320 g.load(S0, lhs); // x
4321 // S1 = x >> 63 (sign mask, arithmetic).
4322 g.asm.mov_rr(S1, S0);
4323 g.asm.mov_ri(SC, 63);
4324 g.asm.sar_cl(S1);
4325 // S1 &= (1<<k) - 1
4326 let mask = (1i64 << k) - 1;
4327 g.asm.mov_ri(SC, mask);
4328 g.asm.and_rr(S1, SC);
4329 // S0 = x + S1
4330 g.asm.add_rr(S0, S1);
4331 // S0 >>= k (arithmetic)
4332 g.asm.mov_ri(SC, k as i64);
4333 g.asm.sar_cl(S0);
4334 g.store(dst, S0);
4335}
4336
4337/// `dst = lhs / c` (`mul_back == 0`) or `dst = lhs % c` (`mul_back == c`) via the
4338/// Granlund–Montgomery / libdivide UNSIGNED magic reciprocal — bit-exact with
4339/// `reference_eval`'s `MagicDivU` and the VM's `magic_eval`. Emitted only for a
4340/// proven non-negative dividend, so the unsigned arithmetic equals the signed
4341/// truncating result.
4342///
4343/// Register choreography: RAX/RDX/RCX (S0/S1/SC) are reserved scratch — no slot
4344/// is ever resident there, so `mul`'s implicit RDX:RAX clobber is safe. The
4345/// dividend's home slot (`lhs`) is never written, so it is RELOADED whenever the
4346/// add-fixup or the remainder needs `x` again.
4347///
4348/// Quotient: `q_hi = mulhi_u(magic, x)`; with the add-marker path
4349/// `q = ((x - q_hi) >> 1 + q_hi) >> shift`, else `q = q_hi >> shift`. The
4350/// quotient lands in S0 (RAX). Remainder: `r = x - q*c` (S0 ← x - q*c).
4351fn emit_magic_div(g: &mut Gen, dst: Slot, lhs: Slot, magic: u64, more: u8, mul_back: i64) {
4352 const SHIFT_MASK: u8 = 0x3F;
4353 const ADD_MARKER: u8 = 0x40;
4354 const SHIFT_PATH: u8 = 0x80;
4355 let shift = (more & SHIFT_MASK) as u8;
4356
4357 if more & SHIFT_PATH != 0 {
4358 // Pure power-of-two path (never emitted by the compiler for the non-pow2
4359 // gate, but executed identically here for completeness): q = x >> shift,
4360 // LOGICAL — sound because x is non-negative.
4361 g.load(S0, lhs);
4362 if shift != 0 {
4363 g.asm.shr_ri(S0, shift);
4364 }
4365 finish_magic(g, dst, lhs, mul_back);
4366 return;
4367 }
4368
4369 // q_hi = high 64 bits of the UNSIGNED product magic * x.
4370 g.asm.mov_ri(SC, magic as i64); // RCX = magic
4371 g.load(S0, lhs); // RAX = x
4372 g.asm.mul_r(SC); // RDX:RAX = x * magic; RDX = q_hi
4373
4374 if more & ADD_MARKER != 0 {
4375 // q = ((x - q_hi) >> 1).wrapping_add(q_hi) >> shift.
4376 g.load(SC, lhs); // RCX = x (reload; lhs home is untouched)
4377 g.asm.sub_rr(SC, S1); // RCX = x - q_hi
4378 g.asm.shr_ri(SC, 1); // RCX = (x - q_hi) >> 1 (logical)
4379 g.asm.add_rr(SC, S1); // RCX = t
4380 if shift != 0 {
4381 g.asm.shr_ri(SC, shift); // RCX = q
4382 }
4383 g.asm.mov_rr(S0, SC); // RAX = q
4384 } else {
4385 // q = q_hi >> shift.
4386 if shift != 0 {
4387 g.asm.shr_ri(S1, shift); // RDX = q
4388 }
4389 g.asm.mov_rr(S0, S1); // RAX = q
4390 }
4391 finish_magic(g, dst, lhs, mul_back);
4392}
4393
4394/// Common tail: the quotient is in S0 (RAX). For division store it; for modulo
4395/// compute `r = x - q*c` (`mul_back == c`) and store that.
4396fn finish_magic(g: &mut Gen, dst: Slot, lhs: Slot, mul_back: i64) {
4397 if mul_back == 0 {
4398 g.store(dst, S0); // quotient
4399 } else {
4400 // r = x - q*c. RAX holds q.
4401 g.asm.mov_ri(SC, mul_back); // RCX = c
4402 g.asm.imul_rr(S0, SC); // RAX = q * c (low 64 bits suffice)
4403 g.load(SC, lhs); // RCX = x (reload)
4404 g.asm.sub_rr(SC, S0); // RCX = x - q*c
4405 g.store(dst, SC); // remainder
4406 }
4407}
4408
4409/// `dst = lhs / rhs` (quotient) or `lhs % rhs` (remainder), with a zero-divisor
4410/// SIDE-EXIT before any effect and the kernel's `MIN op -1` overflow handling
4411/// (no `#DE` trap): if `rhs == -1` then quotient = `0 - lhs` (== MIN for MIN)
4412/// and remainder = 0.
4413fn emit_div_mod(g: &mut Gen, dst: Slot, lhs: Slot, rhs: Slot, is_div: bool, dl: LabelId) {
4414 // Load divisor into S1 (rdx is overwritten by cqo, so keep divisor in a
4415 // register that survives; use SC=rcx for the divisor instead).
4416 g.load(SC, rhs); // divisor in rcx
4417 // Zero check: side-exit if rcx == 0.
4418 g.asm.test_rr(SC, SC);
4419 g.asm.jcc(Cond::Eq, dl);
4420 // Overflow guard: if divisor == -1, avoid #DE. quotient = -lhs, rem = 0.
4421 let neg_one = g.asm.new_label();
4422 let done = g.asm.new_label();
4423 g.asm.mov_ri(S1, -1);
4424 g.asm.cmp_rr(SC, S1); // compare divisor with -1
4425 g.asm.jcc(Cond::Eq, neg_one);
4426 // Normal path: rax = lhs; cqo; idiv rcx.
4427 g.load(S0, lhs);
4428 g.asm.cqo();
4429 g.asm.idiv_r(SC); // quotient -> rax, remainder -> rdx
4430 if is_div {
4431 // result already in rax (S0)
4432 } else {
4433 g.asm.mov_rr(S0, S1); // remainder rdx -> rax
4434 }
4435 g.asm.jmp(done);
4436 // divisor == -1 path.
4437 g.asm.bind(neg_one);
4438 if is_div {
4439 // quotient = -lhs — EXCEPT `i64::MIN`, whose negation overflows:
4440 // side-exit so the exact tier recomputes (and promotes to BigInt).
4441 g.load(S0, lhs);
4442 g.asm.mov_ri(S1, i64::MIN);
4443 g.asm.cmp_rr(S0, S1);
4444 g.asm.jcc(Cond::Eq, dl);
4445 g.asm.neg_r(S0);
4446 } else {
4447 // remainder = 0
4448 g.asm.mov_ri(S0, 0);
4449 }
4450 g.asm.bind(done);
4451 g.store(dst, S0);
4452}
4453
4454#[cfg(test)]
4455mod tests {
4456 use super::*;
4457 use crate::jit::{reference_eval, ChainOutcome};
4458 use std::sync::atomic::Ordering;
4459 use std::sync::Arc;
4460
4461 fn run(ops: &[MicroOp], frame: &[i64]) -> (ChainOutcome, Vec<i64>) {
4462 let status = Arc::new(AtomicI64::new(0));
4463 let chain = compile_region_regalloc(ops, Some(status)).expect("supported region compiles");
4464 let mut f = frame.to_vec();
4465 let out = chain.run_with_frame(&mut f);
4466 (out, f)
4467 }
4468
4469 #[test]
4470 fn live_intervals_span_first_to_last_touch() {
4471 // LoadConst s0; Move s1<-s0; Return s1.
4472 let ops = vec![
4473 MicroOp::LoadConst { dst: 0, value: 7 },
4474 MicroOp::Move { dst: 1, src: 0 },
4475 MicroOp::Return { src: 1 },
4476 ];
4477 let iv = live_intervals(&ops, 1);
4478 assert_eq!(iv, vec![
4479 LiveInterval { slot: 0, start: 0, end: 1 }, // defined @0, last read @1
4480 LiveInterval { slot: 1, start: 1, end: 2 }, // defined @1, returned @2
4481 ]);
4482 }
4483
4484 #[test]
4485 fn linscan_reuses_a_register_across_disjoint_ranges() {
4486 // Five float temporaries with strictly DISJOINT ranges. The whole-region
4487 // per-slot model would give each its own home (spilling 4 of 5 with a
4488 // single register); linear scan reuses the one register for all → 0 spill.
4489 let class = vec![Class::Float; 5];
4490 let iv: Vec<LiveInterval> = (0..5)
4491 .map(|s| LiveInterval { slot: s, start: (s as usize) * 2, end: (s as usize) * 2 + 1 })
4492 .collect();
4493 assert_eq!(linscan_spills(&iv, &class, Class::Float, 1), 0, "disjoint ranges share 1 reg");
4494 assert_eq!(linscan_spills(&iv, &class, Class::Float, 0), 5, "0 regs spills all");
4495 }
4496
4497 #[test]
4498 fn linscan_spills_only_the_overlap_excess() {
4499 // Three fully-overlapping float intervals need 3 registers; with 2 exactly
4500 // one spills, with 3 none. This is the pressure the per-slot model can't
4501 // see — it would spill by rank, not by overlap.
4502 let class = vec![Class::Float; 3];
4503 let iv: Vec<LiveInterval> = (0..3).map(|s| LiveInterval { slot: s, start: 0, end: 9 }).collect();
4504 assert_eq!(linscan_spills(&iv, &class, Class::Float, 3), 0);
4505 assert_eq!(linscan_spills(&iv, &class, Class::Float, 2), 1);
4506 assert_eq!(linscan_spills(&iv, &class, Class::Float, 1), 2);
4507 }
4508
4509 /// A float accumulation loop: `acc` is loop-carried; `x` and `y` are disjoint
4510 /// within-iteration float temps. Returns (ops, max_slot). slots: 0=i 1=N
4511 /// 2=acc(f) 3=x(f) 4=y(f) 5=one.
4512 fn float_loop_two_temps() -> (Vec<MicroOp>, usize) {
4513 let ops = vec![
4514 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 8 }, // loop guard (ctrl + target@8)
4515 MicroOp::IntToFloat { dst: 3, src: 0 }, // x = (f)i
4516 MicroOp::AddF { dst: 2, lhs: 2, rhs: 3 }, // acc += x (x dead after)
4517 MicroOp::IntToFloat { dst: 4, src: 0 }, // y = (f)i
4518 MicroOp::AddF { dst: 2, lhs: 2, rhs: 4 }, // acc += y (y dead after)
4519 MicroOp::LoadConst { dst: 5, value: 1 },
4520 MicroOp::Add { dst: 0, lhs: 0, rhs: 5 }, // i += 1
4521 MicroOp::Jump { target: 0 }, // back-edge (ctrl + target@0)
4522 MicroOp::Return { src: 2 },
4523 ];
4524 (ops, 5)
4525 }
4526
4527 #[test]
4528 fn live_ranges_are_loop_aware() {
4529 let (ops, max_slot) = float_loop_two_temps();
4530 let r = live_ranges(&ops, max_slot);
4531 // acc (loop-carried) spans the whole loop, returned at op 8.
4532 assert_eq!(r[2], Some((0, 8)), "carried acc must span the loop");
4533 // x / y are within-iteration temps with disjoint ranges.
4534 assert_eq!(r[3], Some((1, 2)));
4535 assert_eq!(r[4], Some((3, 4)));
4536 }
4537
4538 #[test]
4539 fn linscan_prefers_fresh_then_reuses_under_pressure() {
4540 let (ops, max_slot) = float_loop_two_temps();
4541 let class = classify_slots(&ops, max_slot);
4542 let ranges = live_ranges(&ops, max_slot);
4543 // AMPLE XMM pool: disjoint temps x and y each get their OWN register — no
4544 // reuse, hence no spill-at-end overhead when the region already fits.
4545 let ample = linscan_assign(&ops, max_slot, &class, &CALLER_SAVED, &FLOAT_REGS);
4546 assert_ne!(ample[3], Loc::Frame, "temp must get a register");
4547 assert_ne!(ample[3], ample[4], "with registers to spare, disjoint temps do NOT share");
4548 // PRESSURE: only acc + one scratch XMM. acc (loop-carried) overlaps both
4549 // temps and keeps its register; x and y must then SHARE the single scratch.
4550 let tight = linscan_assign(&ops, max_slot, &class, &CALLER_SAVED, &[Xmm::Xmm0, Xmm::Xmm1]);
4551 assert_eq!(tight[3], tight[4], "under pressure, disjoint gap-clear temps reuse one register");
4552 assert_ne!(tight[2], tight[3], "carried acc cannot share a live temp's register");
4553 // INVARIANT (both regimes): no two slots with OVERLAPPING ranges share a register.
4554 for assign in [&le, &tight] {
4555 for a in 0..=max_slot {
4556 for b in (a + 1)..=max_slot {
4557 if let (Some((sa, ea)), Some((sb, eb))) = (ranges[a], ranges[b]) {
4558 let overlap = sa <= eb && sb <= ea;
4559 if overlap && assign[a] != Loc::Frame {
4560 assert_ne!(assign[a], assign[b], "overlapping slots {a},{b} share a register");
4561 }
4562 }
4563 }
4564 }
4565 }
4566 }
4567
4568 #[test]
4569 fn linscan_no_reuse_across_a_control_point() {
4570 // Two disjoint int temps separated by a jump TARGET: reuse would cross a
4571 // control join, so they must NOT share a register.
4572 // 0:LoadConst t0 1:Add a=t0+t0 (t0 dead@1) 2:Jump 4 3:(unreached) 4:(target) LoadConst t1 5:Add b=t1+t1 6:Return b
4573 let ops = vec![
4574 MicroOp::LoadConst { dst: 0, value: 1 }, // t0 @0
4575 MicroOp::Add { dst: 1, lhs: 0, rhs: 0 }, // a=t0+t0 ; t0 dead after @1
4576 MicroOp::Jump { target: 4 }, // ctrl; target 4 is a jump target
4577 MicroOp::LoadConst { dst: 9, value: 0 }, // filler (unreached) keeps idx 3 occupied
4578 MicroOp::LoadConst { dst: 2, value: 2 }, // t1 @4 (a jump target — control join)
4579 MicroOp::Add { dst: 3, lhs: 2, rhs: 2 }, // b=t1+t1
4580 MicroOp::Return { src: 3 },
4581 ];
4582 let max_slot = 9;
4583 let class = classify_slots(&ops, max_slot);
4584 let assign = linscan_assign(&ops, max_slot, &class, &CALLER_SAVED, &FLOAT_REGS);
4585 // t0 (range ~[0,1]) freed before t1 (range ~[4,5]) but op 4 is a jump
4586 // target in the gap → t1 must take a FRESH register, not t0's.
4587 assert_ne!(assign[0], assign[2], "reuse must not cross a control point");
4588 }
4589
4590 #[test]
4591 fn straightline_arith_matches_reference() {
4592 let ops = vec![
4593 MicroOp::Add { dst: 3, lhs: 0, rhs: 1 },
4594 MicroOp::Mul { dst: 4, lhs: 3, rhs: 2 },
4595 MicroOp::Sub { dst: 5, lhs: 4, rhs: 0 },
4596 MicroOp::Return { src: 5 },
4597 ];
4598 let frame = vec![7i64, 11, 13, 0, 0, 0];
4599 let expected = reference_eval(&ops, &mut frame.clone(), 1000).unwrap();
4600 let (out, _) = run(&ops, &frame);
4601 assert_eq!(out, ChainOutcome::Return(expected));
4602 }
4603
4604 #[test]
4605 fn counting_loop_matches_reference() {
4606 // sum 0..N via a branch-back loop.
4607 let ops = vec![
4608 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 2, target: 5 }, // while i<N
4609 MicroOp::Add { dst: 1, lhs: 1, rhs: 0 }, // acc += i
4610 MicroOp::LoadConst { dst: 3, value: 1 },
4611 MicroOp::Add { dst: 0, lhs: 0, rhs: 3 }, // i += 1
4612 MicroOp::Jump { target: 0 },
4613 MicroOp::Return { src: 1 },
4614 ];
4615 let frame = vec![0i64, 0, 1000, 0];
4616 let expected = reference_eval(&ops, &mut frame.clone(), 10_000_000).unwrap();
4617 let (out, _) = run(&ops, &frame);
4618 assert_eq!(out, ChainOutcome::Return(expected));
4619 assert_eq!(out, ChainOutcome::Return(499_500));
4620 }
4621
4622 #[test]
4623 fn div_by_zero_side_exits() {
4624 let status = Arc::new(AtomicI64::new(0));
4625 let ops = vec![
4626 MicroOp::Div { dst: 2, lhs: 0, rhs: 1 },
4627 MicroOp::Return { src: 2 },
4628 ];
4629 let chain = compile_region_regalloc(&ops, Some(status)).unwrap();
4630 let mut frame = vec![100i64, 0, 0];
4631 let out = chain.run_with_frame(&mut frame);
4632 assert!(out.is_deopt(), "div by zero must side-exit, got {out:?}");
4633 }
4634
4635 #[test]
4636 fn div_min_neg_one_side_exits() {
4637 // Exact arithmetic: the overflowing quotient i64::MIN / -1 side-exits
4638 // in BOTH the reference and the machine code (deopt → promotion),
4639 // never wraps — and it must not trap the process.
4640 let ops = vec![
4641 MicroOp::Div { dst: 2, lhs: 0, rhs: 1 },
4642 MicroOp::Return { src: 2 },
4643 ];
4644 let frame = vec![i64::MIN, -1, 0];
4645 assert!(
4646 reference_eval(&ops, &mut frame.clone(), 1000).is_none(),
4647 "reference must side-exit MIN / -1"
4648 );
4649 let (out, _) = run(&ops, &frame);
4650 assert!(out.is_deopt(), "machine code must side-exit MIN / -1, got {out:?}");
4651 }
4652
4653 #[test]
4654 fn mod_min_neg_one_zero() {
4655 let ops = vec![
4656 MicroOp::Mod { dst: 2, lhs: 0, rhs: 1 },
4657 MicroOp::Return { src: 2 },
4658 ];
4659 let frame = vec![i64::MIN, -1, 0];
4660 let expected = reference_eval(&ops, &mut frame.clone(), 1000).unwrap();
4661 let (out, _) = run(&ops, &frame);
4662 assert_eq!(out, ChainOutcome::Return(expected)); // MIN % -1 == 0
4663 assert_eq!(out, ChainOutcome::Return(0));
4664 }
4665
4666 #[test]
4667 fn unsupported_op_returns_none() {
4668 // A `MapGet` (hash-map probe via a runtime helper) is outside the
4669 // supported subset, so the region declines and the caller falls back to
4670 // the per-piece stencil tier. (Byte / `Seq of Bool` array loads/stores
4671 // ARE now supported — see `byte_array_*` — so they no longer decline.)
4672 let ops = vec![
4673 MicroOp::MapGet { dst: 1, key: 0, map_slot: 2, helper_addr: 0 },
4674 MicroOp::Return { src: 1 },
4675 ];
4676 assert!(compile_region_regalloc(&ops, None).is_none());
4677 }
4678
4679 // =================================================================
4680 // WAVE 25: the INLINED `ArrPush` fast path. Each push lowers to an
4681 // INLINE `len < cap ? buffer[len++] = v` test that calls the runtime
4682 // helper ONLY on the (cold) realloc boundary. These forge-level tests
4683 // drive a REAL `Vec` through the compiled region with an instrumented
4684 // helper, proving (1) bit-identical contents to `Vec::push` across the
4685 // fast path AND the realloc cold path, and (2) that the per-iteration
4686 // call vanished on the fast path (the helper's call counter).
4687 // =================================================================
4688
4689 /// Call counter for the instrumented test push helper: a fast-path push
4690 /// (`len < cap`) must NOT touch the helper, so this counts only the realloc
4691 /// cold-path entries.
4692 static TEST_PUSH_CALLS: AtomicI64 = AtomicI64::new(0);
4693
4694 /// A drop-in stand-in for `logos_rt_push_i64` with the SAME SysV ABI and the
4695 /// SAME `Vec::push` + ptr/len-refresh semantics, plus a call counter. The
4696 /// inline fast path must reproduce its effect EXACTLY when `len < cap`.
4697 ///
4698 /// # Safety
4699 /// `frame` slots must hold a live `*mut Vec<i64>` (vec_slot) and the mirrored
4700 /// ptr/len cells, exactly as the region pin convention requires.
4701 unsafe extern "C" fn test_push_i64(
4702 frame: *mut i64,
4703 vec_slot: i64,
4704 ptr_slot: i64,
4705 len_slot: i64,
4706 value: i64,
4707 ) {
4708 TEST_PUSH_CALLS.fetch_add(1, Ordering::Relaxed);
4709 let vec = *frame.add(vec_slot as usize) as *mut Vec<i64>;
4710 (*vec).push(value);
4711 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
4712 *frame.add(len_slot as usize) = (*vec).len() as i64;
4713 }
4714
4715 /// Build a region that pushes `i` for `i` in `[0, n)` into a pinned Int list,
4716 /// then returns the final length. Frame layout:
4717 /// 0 = i (induction), 1 = n (bound), 2 = const 1,
4718 /// 8 = vec handle, 9 = buffer ptr, 10 = length.
4719 fn push_loop_ops(helper_addr: i64) -> Vec<MicroOp> {
4720 vec![
4721 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 5 }, // while i < n
4722 MicroOp::ArrPush {
4723 src: 0,
4724 vec_slot: 8,
4725 ptr_slot: 9,
4726 len_slot: 10,
4727 helper_addr,
4728 byte: false,
4729 narrow32: false,
4730 },
4731 MicroOp::LoadConst { dst: 2, value: 1 },
4732 MicroOp::Add { dst: 0, lhs: 0, rhs: 2 }, // i += 1
4733 MicroOp::Jump { target: 0 },
4734 MicroOp::Return { src: 10 }, // final length
4735 ]
4736 }
4737
4738 /// RED: pushes that ALL fit the pre-allocated capacity touch the runtime
4739 /// helper ZERO times — every push took the inline fast path — and the buffer
4740 /// holds exactly `Vec::push`'s result (same contents, same final pointer,
4741 /// since no realloc moved it).
4742 #[test]
4743 fn inline_push_fast_path_never_calls_helper_within_capacity() {
4744 let n = 5000i64;
4745 let mut vec: Vec<i64> = Vec::with_capacity(n as usize);
4746 let vec_ptr = &mut vec as *mut Vec<i64>;
4747 let buf_before = vec.as_ptr() as i64;
4748
4749 let ops = push_loop_ops(test_push_i64 as usize as i64);
4750 let status = Arc::new(AtomicI64::new(0));
4751 let chain = compile_region_regalloc(&ops, Some(status)).expect("push region compiles");
4752
4753 // frame[8] = vec handle, [9] = buffer ptr, [10] = length (all 0 initially).
4754 let mut frame = vec![0i64; 11];
4755 frame[1] = n;
4756 frame[8] = vec_ptr as i64;
4757 frame[9] = buf_before;
4758 frame[10] = 0;
4759
4760 TEST_PUSH_CALLS.store(0, Ordering::Relaxed);
4761 let out = chain.run_with_frame(&mut frame);
4762
4763 assert_eq!(out, ChainOutcome::Return(n), "final length must be n");
4764 assert_eq!(
4765 TEST_PUSH_CALLS.load(Ordering::Relaxed),
4766 0,
4767 "every push fit within capacity — the inline fast path must call the helper ZERO times"
4768 );
4769 // Bit-identical to Vec::push: contents and (no-realloc) pointer.
4770 assert_eq!(vec.len(), n as usize, "real Vec length");
4771 assert_eq!(vec.as_ptr() as i64, buf_before, "no realloc moved the buffer");
4772 for (i, &v) in vec.iter().enumerate() {
4773 assert_eq!(v, i as i64, "buffer[{i}] must be the pushed value");
4774 }
4775 // The frame's mirrored len/ptr stay coherent with the real Vec.
4776 assert_eq!(frame[10], n, "mirrored length");
4777 assert_eq!(frame[9], buf_before, "mirrored pointer unchanged (no realloc)");
4778 }
4779
4780 /// RED: pushes that GROW past the initial capacity stay bit-identical to
4781 /// `Vec::push` — the cold helper path reallocates and refreshes ptr/len, and
4782 /// the inline path bumps len in place between reallocs. The helper is called
4783 /// only on the realloc boundaries (far fewer than `n` times), proving the
4784 /// per-iteration call is gone.
4785 #[test]
4786 fn inline_push_realloc_cold_path_matches_vec_push() {
4787 let n = 4000i64;
4788 // Start with a TINY capacity so the cold realloc path fires repeatedly.
4789 let mut vec: Vec<i64> = Vec::with_capacity(1);
4790 let vec_ptr = &mut vec as *mut Vec<i64>;
4791
4792 // The reference: a plain Vec::push of the same sequence from cap 1.
4793 let mut reference: Vec<i64> = Vec::with_capacity(1);
4794 for i in 0..n {
4795 reference.push(i);
4796 }
4797
4798 let ops = push_loop_ops(test_push_i64 as usize as i64);
4799 let status = Arc::new(AtomicI64::new(0));
4800 let chain = compile_region_regalloc(&ops, Some(status)).expect("push region compiles");
4801
4802 let mut frame = vec![0i64; 11];
4803 frame[1] = n;
4804 frame[8] = vec_ptr as i64;
4805 frame[9] = vec.as_ptr() as i64;
4806 frame[10] = 0;
4807
4808 TEST_PUSH_CALLS.store(0, Ordering::Relaxed);
4809 let out = chain.run_with_frame(&mut frame);
4810
4811 assert_eq!(out, ChainOutcome::Return(n), "final length must be n");
4812 assert_eq!(vec, reference, "compiled push must be bit-identical to Vec::push");
4813 assert_eq!(frame[10], n, "mirrored length matches");
4814 assert_eq!(
4815 frame[9],
4816 vec.as_ptr() as i64,
4817 "mirrored pointer matches the (post-realloc) buffer"
4818 );
4819 let calls = TEST_PUSH_CALLS.load(Ordering::Relaxed);
4820 // Geometric growth from cap 1: O(log n) reallocs, vastly fewer than n.
4821 assert!(
4822 calls < n / 2,
4823 "the helper must fire only on realloc boundaries (got {calls} of {n} pushes)"
4824 );
4825 assert!(calls >= 1, "at least one realloc must have occurred");
4826 }
4827
4828 /// RED: a `Seq of Bool` (1-byte element) push stores the BOOLEAN
4829 /// NORMALIZATION `(v != 0) as u8` inline — bit-identical to
4830 /// `logos_rt_push_bool`'s `(*vec).push(value != 0)`.
4831 #[test]
4832 fn inline_push_byte_normalizes_like_helper() {
4833 unsafe extern "C" fn test_push_bool(
4834 frame: *mut i64,
4835 vec_slot: i64,
4836 ptr_slot: i64,
4837 len_slot: i64,
4838 value: i64,
4839 ) {
4840 let vec = *frame.add(vec_slot as usize) as *mut Vec<bool>;
4841 (*vec).push(value != 0);
4842 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
4843 *frame.add(len_slot as usize) = (*vec).len() as i64;
4844 }
4845
4846 let n = 300i64;
4847 let mut vec: Vec<bool> = Vec::with_capacity(n as usize);
4848 let vec_ptr = &mut vec as *mut Vec<bool>;
4849 let buf_before = vec.as_ptr() as i64;
4850
4851 // Push `i % 3` (0, 1, 2, 0, 1, 2, …): zero stores false, nonzero true.
4852 let ops = vec![
4853 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 7 }, // while i < n
4854 MicroOp::LoadConst { dst: 3, value: 3 },
4855 MicroOp::Mod { dst: 4, lhs: 0, rhs: 3 }, // v = i % 3
4856 MicroOp::ArrPush {
4857 src: 4,
4858 vec_slot: 8,
4859 ptr_slot: 9,
4860 len_slot: 10,
4861 helper_addr: test_push_bool as usize as i64,
4862 byte: true,
4863 narrow32: false,
4864 },
4865 MicroOp::LoadConst { dst: 2, value: 1 },
4866 MicroOp::Add { dst: 0, lhs: 0, rhs: 2 }, // i += 1
4867 MicroOp::Jump { target: 0 },
4868 MicroOp::Return { src: 10 },
4869 ];
4870 let status = Arc::new(AtomicI64::new(0));
4871 let chain = compile_region_regalloc(&ops, Some(status)).expect("byte push region compiles");
4872
4873 let mut frame = vec![0i64; 11];
4874 frame[1] = n;
4875 frame[8] = vec_ptr as i64;
4876 frame[9] = buf_before;
4877 frame[10] = 0;
4878 let out = chain.run_with_frame(&mut frame);
4879
4880 assert_eq!(out, ChainOutcome::Return(n));
4881 let reference: Vec<bool> = (0..n).map(|i| (i % 3) != 0).collect();
4882 assert_eq!(vec, reference, "byte push must normalize (v != 0) like the helper");
4883 assert_eq!(vec.as_ptr() as i64, buf_before, "no realloc within capacity");
4884 }
4885
4886 /// RED: an XMM-resident (float-list) push bit-copies the value to the buffer
4887 /// inline — the raw 8 bytes travel identically to the helper's
4888 /// `f64::from_bits` round-trip.
4889 #[test]
4890 fn inline_push_float_value_bitcopies() {
4891 unsafe extern "C" fn test_push_f64(
4892 frame: *mut i64,
4893 vec_slot: i64,
4894 ptr_slot: i64,
4895 len_slot: i64,
4896 value: i64,
4897 ) {
4898 let vec = *frame.add(vec_slot as usize) as *mut Vec<f64>;
4899 (*vec).push(f64::from_bits(value as u64));
4900 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
4901 *frame.add(len_slot as usize) = (*vec).len() as i64;
4902 }
4903
4904 let n = 200i64;
4905 let mut vec: Vec<f64> = Vec::with_capacity(n as usize);
4906 let vec_ptr = &mut vec as *mut Vec<f64>;
4907 let buf_before = vec.as_ptr() as i64;
4908
4909 // src is a FLOAT slot produced by IntToFloat (lands in XMM): push i as f64.
4910 let ops = vec![
4911 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 6 }, // while i < n
4912 MicroOp::IntToFloat { dst: 4, src: 0 }, // v = i as f64 (XMM-resident)
4913 MicroOp::ArrPush {
4914 src: 4,
4915 vec_slot: 8,
4916 ptr_slot: 9,
4917 len_slot: 10,
4918 helper_addr: test_push_f64 as usize as i64,
4919 byte: false,
4920 narrow32: false,
4921 },
4922 MicroOp::LoadConst { dst: 2, value: 1 },
4923 MicroOp::Add { dst: 0, lhs: 0, rhs: 2 }, // i += 1
4924 MicroOp::Jump { target: 0 },
4925 MicroOp::Return { src: 10 },
4926 ];
4927 let status = Arc::new(AtomicI64::new(0));
4928 let chain = compile_region_regalloc(&ops, Some(status)).expect("float push region compiles");
4929
4930 let mut frame = vec![0i64; 11];
4931 frame[1] = n;
4932 frame[8] = vec_ptr as i64;
4933 frame[9] = buf_before;
4934 frame[10] = 0;
4935 let out = chain.run_with_frame(&mut frame);
4936
4937 assert_eq!(out, ChainOutcome::Return(n));
4938 let reference: Vec<f64> = (0..n).map(|i| i as f64).collect();
4939 assert_eq!(vec, reference, "float push must bit-copy the value like the helper");
4940 assert_eq!(vec.as_ptr() as i64, buf_before, "no realloc within capacity");
4941 }
4942
4943 fn lcg(state: &mut u64) -> u64 {
4944 *state = state
4945 .wrapping_mul(6364136223846793005)
4946 .wrapping_add(1442695040888963407);
4947 *state >> 33
4948 }
4949
4950 /// Exhaustive differential: every random straight-line program over the
4951 /// supported families must equal the (location-independent) reference, at
4952 /// EVERY slot count — small (all resident) and large (forces spills).
4953 #[test]
4954 fn random_straightline_matches_reference_all_pressures() {
4955 for slots in [3u16, 6, 10, 20] {
4956 for seed in 1..=80u64 {
4957 let mut s = seed.wrapping_mul(2654435761);
4958 let len = 8 + (lcg(&mut s) % 24) as usize;
4959 let mut ops = Vec::with_capacity(len + 1);
4960 for _ in 0..len {
4961 let dst = (lcg(&mut s) % slots as u64) as u16;
4962 let lhs = (lcg(&mut s) % slots as u64) as u16;
4963 let rhs = (lcg(&mut s) % slots as u64) as u16;
4964 let op = match lcg(&mut s) % 13 {
4965 0 => MicroOp::Add { dst, lhs, rhs },
4966 1 => MicroOp::Sub { dst, lhs, rhs },
4967 2 => MicroOp::Mul { dst, lhs, rhs },
4968 3 => MicroOp::BitAnd { dst, lhs, rhs },
4969 4 => MicroOp::BitOr { dst, lhs, rhs },
4970 5 => MicroOp::BitXor { dst, lhs, rhs },
4971 6 => MicroOp::Lt { dst, lhs, rhs },
4972 7 => MicroOp::Gt { dst, lhs, rhs },
4973 8 => MicroOp::LtEq { dst, lhs, rhs },
4974 9 => MicroOp::Eq { dst, lhs, rhs },
4975 10 => MicroOp::Neq { dst, lhs, rhs },
4976 11 => MicroOp::Move { dst, src: lhs },
4977 _ => MicroOp::LoadConst {
4978 dst,
4979 value: (lcg(&mut s) as i64).wrapping_sub(1 << 40),
4980 },
4981 };
4982 ops.push(op);
4983 }
4984 let ret = (lcg(&mut s) % slots as u64) as u16;
4985 ops.push(MicroOp::Return { src: ret });
4986
4987 let mut frame = vec![0i64; slots as usize];
4988 for (i, f) in frame.iter_mut().enumerate() {
4989 *f = (i as i64 + 1).wrapping_mul(1_000_003) - 7;
4990 }
4991 // Exact integer math: `reference_eval` returns None on i64 overflow,
4992 // which the native chain matches with a side-exit (deopt).
4993 let expected = reference_eval(&ops, &mut frame.clone(), 100_000);
4994 let (out, post) = run(&ops, &frame);
4995 match expected {
4996 Some(e) => {
4997 assert_eq!(
4998 out,
4999 ChainOutcome::Return(e),
5000 "slots={slots} seed={seed}: return diverged"
5001 );
5002 // The full frame must also match the reference's full frame.
5003 let mut ref_frame = frame.clone();
5004 reference_eval(&ops, &mut ref_frame, 100_000);
5005 assert_eq!(post, ref_frame, "slots={slots} seed={seed}: frame diverged");
5006 }
5007 None => assert!(
5008 out.is_deopt(),
5009 "slots={slots} seed={seed}: overflow must deopt, got {out:?}"
5010 ),
5011 }
5012 }
5013 }
5014 }
5015
5016 /// Shifts and unary ops bit-exact against the reference, at spill pressure.
5017 #[test]
5018 fn shifts_and_unary_match_reference() {
5019 let ops = vec![
5020 MicroOp::Shl { dst: 3, lhs: 0, rhs: 1 },
5021 MicroOp::Shr { dst: 4, lhs: 3, rhs: 2 },
5022 MicroOp::NotInt { dst: 5, src: 4 },
5023 MicroOp::NotBool { dst: 6, src: 2 },
5024 MicroOp::DivPow2 { dst: 7, lhs: 0, k: 3 },
5025 MicroOp::Add { dst: 8, lhs: 5, rhs: 7 },
5026 MicroOp::Return { src: 8 },
5027 ];
5028 for &a in &[1i64, -1, 1024, i64::MIN, -255] {
5029 let frame = vec![a, 5, 2, 0, 0, 0, 0, 0, 0];
5030 let expected = reference_eval(&ops, &mut frame.clone(), 1000).unwrap();
5031 let (out, _) = run(&ops, &frame);
5032 assert_eq!(out, ChainOutcome::Return(expected), "a={a}");
5033 }
5034 }
5035
5036 /// The Granlund–Montgomery magic constants for an unsigned divisor `d`
5037 /// (test-local; mirrors the canonical `LogosDivU64::new` the compiler ships).
5038 fn magic_parts(d: u64) -> (u64, u8) {
5039 if d & (d - 1) == 0 {
5040 return (0, (d.trailing_zeros() as u8) | 0x80);
5041 }
5042 let l = 63 - d.leading_zeros();
5043 let numer = (1u128 << l) << 64;
5044 let m = (numer / d as u128) as u64;
5045 let rem = (numer % d as u128) as u64;
5046 if d - rem < (1u64 << l) {
5047 (m + 1, l as u8)
5048 } else {
5049 let twice = rem.wrapping_mul(2);
5050 let bump = (twice >= d || twice < rem) as u64;
5051 (m.wrapping_mul(2).wrapping_add(bump) + 1, (l as u8) | 0x40)
5052 }
5053 }
5054
5055 /// `MagicDivU` (the W24 constant-divisor magic reciprocal) is bit-exact with
5056 /// `reference_eval` — which itself is bit-exact with the kernel's
5057 /// `wrapping_div`/`wrapping_rem` for the non-negative dividend it gates on —
5058 /// for both the quotient and the remainder, across several divisors (the
5059 /// 64-bit-magic and 65-bit add-marker paths) and a boundary value grid,
5060 /// emitted into a frame with enough live slots to force spill pressure on
5061 /// the dst/lhs.
5062 #[test]
5063 fn magic_div_matches_reference_div_and_mod() {
5064 for &c in &[3i64, 7, 100, 1000, 1_000_000_007, 65521] {
5065 let (magic, more) = magic_parts(c as u64);
5066 // Two ops (div into 1, mod into 2) plus padding slots 3..16 so the
5067 // allocator must spill — exercises both register- and frame-resident
5068 // dst/lhs in emit_magic_div.
5069 let ops = vec![
5070 MicroOp::MagicDivU { dst: 1, lhs: 0, magic, more, mul_back: 0 },
5071 MicroOp::MagicDivU { dst: 2, lhs: 0, magic, more, mul_back: c },
5072 MicroOp::Add { dst: 3, lhs: 1, rhs: 2 },
5073 MicroOp::Return { src: 3 },
5074 ];
5075 for n in [
5076 0i64, 1, c - 1, c, c + 1, 2 * c + 3, 123_456_789, i64::MAX, i64::MAX - 1,
5077 (i64::MAX / c) * c, (i64::MAX / c).saturating_sub(1) * c,
5078 ] {
5079 assert!(n >= 0);
5080 let frame = vec![n, 0, 0, 0];
5081 let expected = reference_eval(&ops, &mut frame.clone(), 1000).unwrap();
5082 let (out, _) = run(&ops, &frame);
5083 assert_eq!(out, ChainOutcome::Return(expected), "c={c} n={n}");
5084 // And the reference itself equals the kernel for these.
5085 assert_eq!(
5086 expected,
5087 n.wrapping_div(c).wrapping_add(n.wrapping_rem(c)),
5088 "reference vs kernel c={c} n={n}"
5089 );
5090 }
5091 }
5092 }
5093
5094 /// A nested loop with many live slots (forces some spills) — the polynomial
5095 /// accumulation the ceiling harness times — matches the reference exactly.
5096 #[test]
5097 fn poly_loop_matches_reference_under_spill() {
5098 // s = s*A + i*B - C across i in 0..N, with A,B,C,N,i,s in distinct slots.
5099 let ops = vec![
5100 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 11 }, // i<N
5101 MicroOp::Mul { dst: 7, lhs: 2, rhs: 3 }, // t = s*A
5102 MicroOp::Mul { dst: 8, lhs: 0, rhs: 4 }, // u = i*B
5103 MicroOp::Add { dst: 7, lhs: 7, rhs: 8 }, // t += u
5104 MicroOp::Sub { dst: 2, lhs: 7, rhs: 5 }, // s = t - C
5105 MicroOp::BitXor { dst: 2, lhs: 2, rhs: 6 }, // s ^= MASK
5106 MicroOp::Add { dst: 9, lhs: 2, rhs: 0 }, // (extra live)
5107 MicroOp::Mul { dst: 2, lhs: 2, rhs: 3 }, // s *= A again
5108 MicroOp::LoadConst { dst: 10, value: 1 },
5109 MicroOp::Add { dst: 0, lhs: 0, rhs: 10 }, // i += 1
5110 MicroOp::Jump { target: 0 },
5111 MicroOp::Return { src: 2 },
5112 ];
5113 // slots: 0=i 1=N 2=s 3=A 4=B 5=C 6=MASK 7=t 8=u 9=extra 10=one
5114 let frame = vec![0i64, 5000, 1, 6364136223846793005u64 as i64, 1442695040888963407u64 as i64, 12345, 0x5DEECE66D, 0, 0, 0, 0];
5115 // These LCG-sized multipliers overflow i64 on the first `s*A`. Under exact
5116 // integer math the reference returns None and the native chain side-exits
5117 // (deopt) — the two must agree that this leaves the i64 fast path.
5118 let expected = reference_eval(&ops, &mut frame.clone(), 100_000_000);
5119 let (out, _) = run(&ops, &frame);
5120 match expected {
5121 Some(e) => assert_eq!(out, ChainOutcome::Return(e)),
5122 None => assert!(out.is_deopt(), "overflow must deopt, got {out:?}"),
5123 }
5124 }
5125
5126 /// `loop_depths` (the spill-weight heuristic's loop detector): a back-edge
5127 /// `[target, idx]` raises the depth of every op in its span by one, and
5128 /// nesting accumulates. Straight-line code stays depth 0.
5129 #[test]
5130 fn loop_depths_counts_back_edge_nesting() {
5131 // No back-edge: all depth 0.
5132 let flat = vec![
5133 MicroOp::Add { dst: 1, lhs: 0, rhs: 0 },
5134 MicroOp::Return { src: 1 },
5135 ];
5136 assert_eq!(loop_depths(&flat), vec![0, 0]);
5137
5138 // One loop: a Jump at idx 3 back to 1 wraps ops 1..=3 (depth 1); the
5139 // guard at 0 and the tail at 4 stay depth 0.
5140 let single = vec![
5141 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 4 }, // 0
5142 MicroOp::Add { dst: 2, lhs: 2, rhs: 0 }, // 1
5143 MicroOp::Add { dst: 0, lhs: 0, rhs: 3 }, // 2
5144 MicroOp::Jump { target: 1 }, // 3
5145 MicroOp::Return { src: 2 }, // 4
5146 ];
5147 assert_eq!(loop_depths(&single), vec![0, 1, 1, 1, 0]);
5148
5149 // Nested: inner back-edge 3->2 (depth 2 over ops 2..=3) sits inside the
5150 // outer back-edge 5->1 (depth 1 over ops 1..=5).
5151 let nested = vec![
5152 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 6 }, // 0
5153 MicroOp::Move { dst: 4, src: 0 }, // 1 outer
5154 MicroOp::Add { dst: 2, lhs: 2, rhs: 4 }, // 2 inner
5155 MicroOp::Jump { target: 2 }, // 3 inner back-edge
5156 MicroOp::Add { dst: 0, lhs: 0, rhs: 3 }, // 4 outer
5157 MicroOp::Jump { target: 1 }, // 5 outer back-edge
5158 MicroOp::Return { src: 2 }, // 6
5159 ];
5160 assert_eq!(loop_depths(&nested), vec![0, 1, 2, 2, 1, 1, 0]);
5161 }
5162
5163 /// A loop-carried slot referenced ONCE per iteration but living inside a
5164 /// loop must out-rank a colder slot referenced MANY times only in
5165 /// straight-line setup, so it wins a register. We assert this through the
5166 /// public `loc` assignment indirectly: build a region where slot `H` (hot,
5167 /// loop-carried, few raw refs) and slot `C` (cold, setup-only, many raw refs)
5168 /// compete for the SOLE remaining int register, and confirm the loop weight
5169 /// flips the ranking so the result is still bit-identical to the reference
5170 /// (the heuristic only moves residency, never correctness).
5171 #[test]
5172 fn loop_weight_keeps_carried_slot_resident_and_exact() {
5173 // Setup touches `C` (slot 5) four times in straight-line code; the loop
5174 // touches `H` (slot 2, the accumulator) once per iteration. Raw counts
5175 // would rank C above H; the loop weight (10^1 per loop ref) flips it.
5176 let ops = vec![
5177 MicroOp::Add { dst: 5, lhs: 0, rhs: 1 }, // 0 C = a+b (setup)
5178 MicroOp::Add { dst: 5, lhs: 5, rhs: 0 }, // 1 C += a (setup)
5179 MicroOp::Add { dst: 5, lhs: 5, rhs: 1 }, // 2 C += b (setup)
5180 MicroOp::Add { dst: 2, lhs: 2, rhs: 5 }, // 3 seed acc with C (setup)
5181 MicroOp::Branch { cmp: Cmp::Lt, lhs: 3, rhs: 4, target: 8 }, // 4 while i<N
5182 MicroOp::Add { dst: 2, lhs: 2, rhs: 3 }, // 5 acc += i (HOT, depth 1)
5183 MicroOp::Add { dst: 3, lhs: 3, rhs: 6 }, // 6 i += 1 (HOT, depth 1)
5184 MicroOp::Jump { target: 4 }, // 7 back-edge
5185 MicroOp::Return { src: 2 }, // 8
5186 ];
5187 // slots: 0=a 1=b 2=acc(H) 3=i 4=N 5=C(cold) 6=one
5188 let frame = vec![3i64, 4, 0, 0, 100_000, 0, 1];
5189 let depths = loop_depths(&ops);
5190 assert_eq!(depths[5], 1, "the accumulator update is inside the loop");
5191 let expected = reference_eval(&ops, &mut frame.clone(), 10_000_000).unwrap();
5192 let (out, post) = run(&ops, &frame);
5193 assert_eq!(out, ChainOutcome::Return(expected), "loop-weighted ranking must stay bit-identical");
5194 let mut ref_frame = frame.clone();
5195 reference_eval(&ops, &mut ref_frame, 10_000_000).unwrap();
5196 assert_eq!(post, ref_frame, "frame diverged under loop-weighted ranking");
5197 }
5198
5199 /// Fix 1 (call-weight tie-break): two slots with the SAME primary loop-weight
5200 /// must be ordered by call-survival — the slot LIVE ACROSS a SysV call ranks
5201 /// first, so it wins a callee-saved register (paying no per-call spill/reload)
5202 /// while the call-dead slot of equal weight takes a caller-saved one. The
5203 /// call-weight is a SECONDARY key: it breaks the tie WITHOUT changing the
5204 /// primary weights (so it can never evict a hotter loop slot).
5205 #[test]
5206 fn call_weight_breaks_tie_toward_live_across_call_slot() {
5207 // `survivor` (slot 1) and `transient` (slot 2) are each defined once and
5208 // read once — identical primary loop-weight. `survivor` is read AFTER the
5209 // call (op 3) → live across it; `transient` is consumed BEFORE the call
5210 // and never read after → dead across it. Only the secondary key differs.
5211 // 0: survivor = a + one (def slot 1)
5212 // 1: transient = a + one (def slot 2)
5213 // 2: sink = transient + a (last read of transient, def slot 8 — BEFORE call)
5214 // 3: ListClear (the SysV call, op 3)
5215 // 4: r = survivor + one (reads slot 1 AFTER the call, def slot 4)
5216 // 5: Return r
5217 let ops = vec![
5218 MicroOp::Add { dst: 1, lhs: 0, rhs: 3 },
5219 MicroOp::Add { dst: 2, lhs: 0, rhs: 3 },
5220 MicroOp::Add { dst: 8, lhs: 2, rhs: 0 },
5221 MicroOp::ListClear { vec_slot: 5, ptr_slot: 6, len_slot: 7, helper_addr: 0 },
5222 MicroOp::Add { dst: 4, lhs: 1, rhs: 3 },
5223 MicroOp::Return { src: 4 },
5224 ];
5225 let max_slot = max_slot_of(&ops);
5226 let la = liveness_after(&ops, max_slot);
5227 assert!(la[3][1], "survivor must be live across the call");
5228 assert!(!la[3][2], "transient must be dead across the call");
5229 // PRIMARY weights must be equal (both: 1 def-ref + 1 use-ref outside any
5230 // loop). Slot 1: def@0 + use@4 = 2 refs. Slot 2: def@1 + use@2 = 2 refs.
5231 let (order, _) = rank_slots(&ops, 16, 4, 4, Some(&la));
5232 let pos = |slot: Slot| order.iter().position(|&(s, _)| s == slot).unwrap();
5233 let key = |slot: Slot| order.iter().find(|&&(s, _)| s == slot).unwrap().1;
5234 assert_eq!(
5235 key(1),
5236 key(2),
5237 "survivor and transient must share the SAME primary weight (got {} vs {})",
5238 key(1),
5239 key(2)
5240 );
5241 assert!(
5242 pos(1) < pos(2),
5243 "on equal primary weight the call-survivor must rank ahead of the call-dead slot"
5244 );
5245 }
5246
5247 /// Fix 1 soundness: the call-survival key NEVER changes the primary
5248 /// loop-weighted ordering of two slots with DIFFERENT primary weights, so it
5249 /// cannot evict a hotter loop slot. A loop-hot, call-DEAD slot must still
5250 /// out-rank a cold, call-SURVIVING slot.
5251 #[test]
5252 fn call_weight_never_outranks_a_hotter_loop_slot() {
5253 // `hot` (slot 2) is referenced every iteration of a loop (high primary
5254 // weight) but is consumed before the call → dead across it. `cold` (slot
5255 // 1) is set once outside the loop and read once after the call → live
5256 // across the call but cold. The loop-hot slot must still win.
5257 let ops = vec![
5258 MicroOp::Add { dst: 1, lhs: 0, rhs: 3 }, // 0 cold = a+one (setup)
5259 MicroOp::Branch { cmp: Cmp::Lt, lhs: 4, rhs: 5, target: 6 }, // 1 while i<N
5260 MicroOp::Add { dst: 2, lhs: 2, rhs: 4 }, // 2 hot += i (HOT)
5261 MicroOp::Add { dst: 4, lhs: 4, rhs: 3 }, // 3 i += one (HOT)
5262 MicroOp::ListClear { vec_slot: 7, ptr_slot: 8, len_slot: 9, helper_addr: 0 }, // 4 call
5263 MicroOp::Jump { target: 1 }, // 5 back-edge
5264 MicroOp::Add { dst: 10, lhs: 1, rhs: 2 }, // 6 use cold (live across) + hot
5265 ];
5266 // Append a Return so the region is well-formed.
5267 let mut ops = ops;
5268 ops.push(MicroOp::Return { src: 10 });
5269 let max_slot = max_slot_of(&ops);
5270 let la = liveness_after(&ops, max_slot);
5271 let (order, _) = rank_slots(&ops, 16, 4, 4, Some(&la));
5272 let pos = |slot: Slot| order.iter().position(|&(s, _)| s == slot).unwrap();
5273 assert!(
5274 pos(2) < pos(1),
5275 "the loop-hot slot must out-rank the cold call-survivor (primary key dominates)"
5276 );
5277 }
5278
5279 /// Fix 1 soundness: the call bonus is INERT for a call-free region. With
5280 /// `live_after = None` the ranking is exactly the pre-Fix-1 loop-weighted
5281 /// ranking — no bonus exists because there is no call to survive.
5282 #[test]
5283 fn call_weight_is_inert_without_a_call() {
5284 let ops = vec![
5285 MicroOp::Add { dst: 3, lhs: 0, rhs: 1 },
5286 MicroOp::Mul { dst: 4, lhs: 3, rhs: 2 },
5287 MicroOp::Sub { dst: 5, lhs: 4, rhs: 0 },
5288 MicroOp::Return { src: 5 },
5289 ];
5290 // With no liveness (call-free) and with an EMPTY all-dead liveness, the
5291 // bonus contributes nothing, so the two rankings are identical.
5292 let (no_call, _) = rank_slots(&ops, 16, 4, 4, None);
5293 let max_slot = max_slot_of(&ops);
5294 let dead = vec![vec![false; max_slot + 1]; ops.len()];
5295 let (dead_la, _) = rank_slots(&ops, 16, 4, 4, Some(&dead));
5296 assert_eq!(no_call, dead_la, "the call bonus must vanish with no live-across-call slot");
5297 }
5298
5299 /// Fix 1 (call-weight) must not perturb the SELF-CALL window forcing or the
5300 /// `max_slot` extent: a region with a `CallSelf` still compiles, and the
5301 /// ranking ranks the call-surviving non-window slot ahead of a transient. The
5302 /// runnable end-to-end (bit-identical + tiers) lives in the integration suite
5303 /// (`jit_regalloc::call_weight_*`) where a real recursive program is built.
5304 #[test]
5305 fn call_weight_self_call_region_compiles_and_ranks_survivor() {
5306 // A function-shaped region: guard, a self-call, an accumulate that reads a
5307 // value live across the call, return. (Window slots are forced frame-
5308 // resident regardless; this only checks the ranking + that it compiles.)
5309 let ops = vec![
5310 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 5 }, // 0 if !(i<N) -> ret
5311 MicroOp::CallSelf { dst: 3, args_start: 8, depth_addr: 0, status_addr: 0, limit_slot: 2, frame_size: 4 }, // 1 call
5312 MicroOp::Add { dst: 4, lhs: 4, rhs: 3 }, // 2 acc += call result (acc live across? no — defined here)
5313 MicroOp::Add { dst: 0, lhs: 0, rhs: 4 }, // 3 i += acc (reads acc 4 → live after the call)
5314 MicroOp::Jump { target: 0 }, // 4 back-edge
5315 MicroOp::Return { src: 4 }, // 5
5316 ];
5317 let max_slot = max_slot_of(&ops);
5318 assert!(max_slot >= 11, "self-call window extent (8+4-1=11) must be covered, got {max_slot}");
5319 let la = liveness_after(&ops, max_slot);
5320 // slot 4 (acc) is read by op 3 after the call at op 1 → live across it.
5321 assert!(la[1][4], "acc must be live across the self-call");
5322 let (order, _) = rank_slots(&ops, 16, 4, 4, Some(&la));
5323 let key = |slot: Slot| order.iter().find(|&&(s, _)| s == slot).map(|&(_, k)| k).unwrap_or(0);
5324 // slot 4 (call-survivor, loop-carried) must out-rank the bound slot 1
5325 // (referenced only at the cold guard, not across the call).
5326 assert!(key(4) > key(1), "call-surviving loop slot must out-rank a cold bound");
5327 }
5328
5329 /// Wave 16 (call-site spill liveness): `liveness_after` is a sound backward
5330 /// dataflow. A slot WRITTEN before an op and READ only before it (never on any
5331 /// path AFTER) is DEAD after that op; a slot read by a LATER op (including a
5332 /// loop back-edge) is LIVE after every op on the path to that read.
5333 #[test]
5334 fn liveness_after_is_a_sound_backward_dataflow() {
5335 // 0: t = a + b (defines t=2)
5336 // 1: dead = a * a (defines dead=3, never read again)
5337 // 2: r = t - dead (reads t and dead, defines r=4)
5338 // 3: Return r
5339 let ops = vec![
5340 MicroOp::Add { dst: 2, lhs: 0, rhs: 1 },
5341 MicroOp::Mul { dst: 3, lhs: 0, rhs: 0 },
5342 MicroOp::Sub { dst: 4, lhs: 2, rhs: 3 },
5343 MicroOp::Return { src: 4 },
5344 ];
5345 let la = liveness_after(&ops, 4);
5346 // After op 1 (`dead = a*a`), `dead` (slot 3) is LIVE (read by op 2) and so
5347 // is `t` (slot 2, read by op 2).
5348 assert!(la[1][3], "dead must be live right after it is defined (read by op 2)");
5349 assert!(la[1][2], "t must still be live after op 1 (read by op 2)");
5350 // After op 2 (`r = t - dead`), NEITHER `t` nor `dead` is read again — both DEAD.
5351 assert!(!la[2][2], "t is dead after its last read (op 2)");
5352 assert!(!la[2][3], "dead is dead after its last read (op 2)");
5353 // `r` (slot 4) is live after op 2 (the Return reads it).
5354 assert!(la[2][4], "r is live after op 2 (Return reads it)");
5355 // After the Return, nothing is live.
5356 assert!(la[3].iter().all(|&b| !b), "no slot is live after Return");
5357 }
5358
5359 /// A loop-carried slot read across a back-edge stays LIVE after every op in
5360 /// the loop body (the backward dataflow must reach a fixpoint over the
5361 /// back-edge, not just the forward pass).
5362 #[test]
5363 fn liveness_after_propagates_across_loop_back_edge() {
5364 // counting loop: 0=i 1=acc 2=N 3=one
5365 let ops = vec![
5366 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 2, target: 5 }, // 0 while i<N
5367 MicroOp::Add { dst: 1, lhs: 1, rhs: 0 }, // 1 acc += i
5368 MicroOp::LoadConst { dst: 3, value: 1 }, // 2 one = 1
5369 MicroOp::Add { dst: 0, lhs: 0, rhs: 3 }, // 3 i += 1
5370 MicroOp::Jump { target: 0 }, // 4 back-edge
5371 MicroOp::Return { src: 1 }, // 5
5372 ];
5373 let la = liveness_after(&ops, 3);
5374 // `i` (slot 0) is read by the guard (op 0), `acc += i` (op 1), and `i += 1`
5375 // (op 3); via the back-edge it must be live after op 3 (the increment),
5376 // after op 1, and after the guard's true edge — i.e. live around the loop.
5377 assert!(la[3][0], "i must be live after the increment (back-edge re-reads it)");
5378 assert!(la[1][0], "i must be live after acc += i (used again at op 3)");
5379 // `acc` (slot 1) is read by op 1 and the final Return → live around the loop.
5380 assert!(la[3][1], "acc must be live after op 3 (read next iteration / return)");
5381 assert!(la[1][1], "acc must be live after its own update");
5382 }
5383
5384 /// The spill-elision keep-rule: a caller-saved resident is elided around a
5385 /// call ONLY when it is READ-ONLY (never written → never flushed at exit) AND
5386 /// dead after the call. A WRITTEN slot is kept even when dead-after-call
5387 /// (its last value is flushed at every region exit), and a read-only slot
5388 /// read after the call is kept (the reload feeds that read). This mirrors the
5389 /// `keep` closure in `compile_impl` and is the soundness contract the
5390 /// `*_matches_stencil` post-frame differentials enforce.
5391 #[test]
5392 fn spill_elision_keeps_written_and_live_drops_readonly_dead() {
5393 // Slot 5 = `cold`: a frame input READ only at op 0 (never written, never
5394 // read after the call) → read-only + dead-after-call → ELIDED.
5395 // Slot 8 = `hot`: WRITTEN at op 1, dead after the call (op 3 re-reads it,
5396 // so it is actually live-after too — but the WRITTEN rule alone keeps
5397 // it; flushed at every exit). Slot 1 = the literal-`1` operand: read
5398 // only (never written) but READ after the call (op 3) → kept via live.
5399 // 0: t0 = cold + 1 (reads slot 5 `cold`, defines slot 7)
5400 // 1: hot = hot + 1 (WRITTEN slot 8)
5401 // 2: ListClear (the call op, index 2)
5402 // 3: keep = hot + 1 (reads slot 8 `hot` and slot 1 `1`, defines slot 9)
5403 // 4: Return keep
5404 let ops = vec![
5405 MicroOp::Add { dst: 7, lhs: 5, rhs: 1 }, // 0 t0 = cold + 1
5406 MicroOp::Add { dst: 8, lhs: 8, rhs: 1 }, // 1 hot += 1 (written)
5407 MicroOp::ListClear { vec_slot: 2, ptr_slot: 3, len_slot: 4, helper_addr: 0 }, // 2 call
5408 MicroOp::Add { dst: 9, lhs: 8, rhs: 1 }, // 3 keep = hot + 1
5409 MicroOp::Return { src: 9 }, // 4
5410 ];
5411 let max_slot = 9;
5412 let la = liveness_after(&ops, max_slot);
5413 let mut written = vec![false; max_slot + 1];
5414 for op in &ops {
5415 if let Some(d) = dest_of(op) {
5416 written[d as usize] = true;
5417 }
5418 }
5419 let keep = |s: Slot| {
5420 written.get(s as usize).copied().unwrap_or(true)
5421 || la[2].get(s as usize).copied().unwrap_or(true)
5422 };
5423 // `cold` (slot 5): read-only (only ever an operand) and NOT read after the
5424 // call (op 3 reads `hot`/`1`, not `cold`) → ELIDED.
5425 assert!(!written[5], "cold is read-only");
5426 assert!(!la[2][5], "cold is dead after the call");
5427 assert!(!keep(5), "a read-only, dead-after-call resident must be elided");
5428 // `hot` (slot 8): WRITTEN → kept regardless (flushed at every exit). It is
5429 // also live-after here, but the WRITTEN rule alone must keep it.
5430 assert!(written[8], "hot is written");
5431 assert!(keep(8), "a written resident must be kept (flushed at exit)");
5432 // `frame[1]` (the literal `1` operand): read-only but read AFTER the call
5433 // (op 3 reads it) → kept via the live-after rule.
5434 assert!(!written[1], "the constant operand is read-only");
5435 assert!(la[2][1], "the constant operand is read after the call");
5436 assert!(keep(1), "a read-only resident read after the call must be kept");
5437 }
5438
5439 // =================================================================
5440 // WAVE 21: precise REGION backend (in-place SetIndex + reallocating
5441 // ArrPush). The fannkuch / graph_bfs worklist shape — a precise region
5442 // that does NOT have the mode-B function prologue, so `mode_b_rc` is
5443 // inferred only for FUNCTIONS, never regions.
5444 // =================================================================
5445
5446 /// The precise REGION path compiles a push+SetIndex stream (a reallocating
5447 /// `ArrPush` beside an in-place checked `ArrStore`) into ONE contiguous
5448 /// register-allocated chain — even though the stream has NO mode-B function
5449 /// prologue (`LoadConst {-1}`). The per-piece precise stencil tier no longer
5450 /// monopolizes this shape.
5451 #[test]
5452 fn precise_region_with_push_and_setindex_compiles() {
5453 // 0: i < n? -> exit (op 6)
5454 // 1: ArrStore arr[i] = v (in-place, CHECKED — the precise side exit)
5455 // 2: ArrPush v -> q (reallocates q; helper refreshes ptr/len)
5456 // 3: i += 1
5457 // 4: Jump head
5458 // 5: (exit) Return i
5459 let ops = vec![
5460 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 5 },
5461 MicroOp::ArrStore { src: 2, idx: 0, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
5462 MicroOp::ArrPush { src: 2, vec_slot: 8, ptr_slot: 9, len_slot: 10, helper_addr: 0, byte: false, narrow32: false },
5463 MicroOp::Add { dst: 0, lhs: 0, rhs: 3 },
5464 MicroOp::Jump { target: 0 },
5465 MicroOp::Return { src: 0 },
5466 ];
5467 // Per-op precise resume codes (parallel to `ops`): the checked store and
5468 // the push carry a precise tag `(pc << 2) | 3`; the rest stay plain `1`.
5469 let codes: Vec<i64> = ops
5470 .iter()
5471 .enumerate()
5472 .map(|(i, op)| match op {
5473 MicroOp::ArrStore { checked: true, .. } | MicroOp::ArrPush { .. } => {
5474 ((i as i64) << 2) | 3
5475 }
5476 _ => 1,
5477 })
5478 .collect();
5479 let status = Arc::new(AtomicI64::new(0));
5480 let chain = compile_region_regalloc_precise(&ops, Some(status), 0, &codes);
5481 assert!(
5482 chain.is_some(),
5483 "a precise push+SetIndex REGION (no mode-B prologue) must compile through \
5484 the contiguous regalloc backend"
5485 );
5486 }
5487
5488 /// The precise REGION path DECLINES when the per-op `deopt_codes` length does
5489 /// not match the op stream — a malformed call falls back to the per-piece tier
5490 /// rather than mis-indexing the resume table.
5491 #[test]
5492 fn precise_region_declines_on_codes_length_mismatch() {
5493 let ops = vec![
5494 MicroOp::ArrStore { src: 2, idx: 0, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
5495 MicroOp::Return { src: 0 },
5496 ];
5497 let codes = vec![1i64]; // one short
5498 let status = Arc::new(AtomicI64::new(0));
5499 assert!(
5500 compile_region_regalloc_precise(&ops, Some(status), 0, &codes).is_none(),
5501 "a deopt-codes length mismatch must decline"
5502 );
5503 }
5504
5505 /// The precise REGION path requires the shared status channel (every checked
5506 /// op's side exit stores its tagged resume value through it); a `None` status
5507 /// declines.
5508 #[test]
5509 fn precise_region_declines_without_status() {
5510 let ops = vec![
5511 MicroOp::ArrStore { src: 2, idx: 0, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
5512 MicroOp::Return { src: 0 },
5513 ];
5514 let codes = vec![3i64, 1];
5515 assert!(
5516 compile_region_regalloc_precise(&ops, None, 0, &codes).is_none(),
5517 "a precise region with no status channel must decline"
5518 );
5519 }
5520
5521 /// A precise region carrying a CROSS-FUNCTION `Call` is unsupported by the
5522 /// region gate (`supported`) and must decline — the JIT adapter already
5523 /// disqualifies push-beside-call regions, and the backend is the second line
5524 /// of defense (a call frame would need the full function-precise walk).
5525 #[test]
5526 fn precise_region_declines_on_cross_call() {
5527 let ops = vec![
5528 MicroOp::Call {
5529 dst: 1,
5530 args_start: 4,
5531 table_addr: 0,
5532 depth_addr: 0,
5533 status_addr: 0,
5534 limit_slot: 3,
5535 depth_limit: SELF_CALL_DEPTH_LIMIT,
5536 },
5537 MicroOp::Return { src: 1 },
5538 ];
5539 let codes = vec![3i64, 1];
5540 let status = Arc::new(AtomicI64::new(0));
5541 assert!(
5542 compile_region_regalloc_precise(&ops, Some(status), 0, &codes).is_none(),
5543 "a precise REGION with a cross-function Call must decline (region gate)"
5544 );
5545 }
5546}