logicaffeine_jit/lib.rs
1// Native tier is available only where the forge JIT is (x86_64 System V — Linux +
2// macOS). On Windows/aarch64/wasm this crate is empty and the VM runs bytecode.
3#![cfg(all(target_arch = "x86_64", not(target_os = "windows")))]
4#![doc = include_str!("../README.md")]
5
6use std::sync::atomic::{AtomicU32, Ordering};
7
8use logicaffeine_compile::vm::{
9 install_native_tier, ArrayPin, CalleeSig, Constant, FnTable, HoistGuard, NativeCtx, NativeFn,
10 NativeFrame, NativeOutcome, NativeRet, NativeTier, ObservedKind, Op, ParamKind, PinElem,
11 RegBox, RegionFn, RegionOutcome, SlotKind,
12};
13use logicaffeine_forge::jit::{
14 compile_straightline_coded, compile_straightline_pinned_float, compile_straightline_with,
15 ChainOutcome, Cmp, CompiledChain, FOp, IOp, MicroOp, RmwOp,
16};
17
18// The precise/self call stencils BAKE the depth limit (their holes are all
19// spoken for), so the kernel's `MAX_CALL_DEPTH` and the forge's baked value
20// MUST agree — otherwise native recursion would side-exit at a different depth
21// than the kernel raises its error, diverging from the tree-walker. Pinned at
22// compile time so any future change to either constant fails the BUILD here
23// rather than silently miscompiling deep recursion.
24const _: () = assert!(
25 logicaffeine_compile::semantics::MAX_CALL_DEPTH as i64
26 == logicaffeine_forge::jit::BAKED_CALL_DEPTH,
27 "MAX_CALL_DEPTH and the forge's baked call-depth limit have drifted; \
28 update stencils/int_stencils.rs and jit.rs BAKED_CALL_DEPTH to match",
29);
30
31/// Runtime push helpers the push stencil calls indirectly (their addresses
32/// are baked as constants — no relocations). Each pushes the value with the
33/// kernel's representation and refreshes the pinned pointer/length slots
34/// after a possible realloc.
35///
36/// # Safety
37/// The vec-handle slot must hold a live `*mut Vec<…>` of the matching
38/// element type, pinned (borrowed) by the VM for the whole region run.
39/// The NATIVE ALLOCATION REGISTRY: every list a chain allocates lives
40/// here until its boundary. On success the returned list detaches;
41/// everything else drops. On deopt everything drops — fresh lists never
42/// escape mid-run, so replay is leak-free and double-build-free.
43std::thread_local! {
44 static ALLOC_REGISTRY: std::cell::RefCell<Vec<*mut Vec<i64>>> =
45 const { std::cell::RefCell::new(Vec::new()) };
46}
47
48/// Registry depth — drained-to-zero is part of every boundary's contract
49/// (asserted by the test gates).
50pub fn native_alloc_registry_len() -> usize {
51 ALLOC_REGISTRY.with(|r| r.borrow().len())
52}
53
54fn alloc_registry_drain() {
55 ALLOC_REGISTRY.with(|r| {
56 for ptr in r.borrow_mut().drain(..) {
57 drop(unsafe { Box::from_raw(ptr) });
58 }
59 });
60}
61
62/// Detach one allocation by handle (the boundary's success path for a
63/// returned fresh list). `None` when the handle is not registry-owned
64/// (a param passthrough).
65fn alloc_registry_detach(handle: i64) -> Option<Vec<i64>> {
66 ALLOC_REGISTRY.with(|r| {
67 let mut reg = r.borrow_mut();
68 let pos = reg.iter().position(|&p| p as i64 == handle)?;
69 let ptr = reg.swap_remove(pos);
70 Some(*unsafe { Box::from_raw(ptr) })
71 })
72}
73
74/// Allocate a fresh Int list and plant its pin triple.
75///
76/// # Safety
77/// `frame` slots must be this chain's live frame.
78pub unsafe extern "C" fn logos_rt_alloc_list_i64(
79 frame: *mut i64,
80 vec_slot: i64,
81 ptr_slot: i64,
82 len_slot: i64,
83) {
84 let boxed: Box<Vec<i64>> = Box::new(Vec::new());
85 let raw = Box::into_raw(boxed);
86 ALLOC_REGISTRY.with(|r| r.borrow_mut().push(raw));
87 *frame.add(vec_slot as usize) = raw as i64;
88 *frame.add(ptr_slot as usize) = (*raw).as_mut_ptr() as i64;
89 *frame.add(len_slot as usize) = 0;
90}
91
92/// Plant the pin triple for a list handle RETURNED by a self-call (the
93/// caller's view of the callee's result list).
94///
95/// # Safety
96/// `frame[handle_slot]` must be a live `*mut Vec<i64>` (registry-owned or
97/// a pinned param's vec).
98pub unsafe extern "C" fn logos_rt_list_triple(
99 frame: *mut i64,
100 handle_slot: i64,
101 vec_slot: i64,
102 ptr_slot: i64,
103 len_slot: i64,
104) {
105 let raw = *frame.add(handle_slot as usize) as *mut Vec<i64>;
106 *frame.add(vec_slot as usize) = raw as i64;
107 *frame.add(ptr_slot as usize) = (*raw).as_mut_ptr() as i64;
108 *frame.add(len_slot as usize) = (*raw).len() as i64;
109}
110
111pub unsafe extern "C" fn logos_rt_map_get_ii(
112 map: i64,
113 key: i64,
114 frame: *mut i64,
115 dst_slot: i64,
116) -> i64 {
117 use logicaffeine_compile::interpreter::{MapStorage, RuntimeValue};
118 let storage = &*(map as *const MapStorage);
119 match storage.get(&RuntimeValue::Int(key)) {
120 Some(RuntimeValue::Int(v)) => {
121 *frame.add(dst_slot as usize) = *v;
122 1
123 }
124 // Miss or non-Int value: the side exit replays on bytecode, where
125 // the kernel raises its exact error or hands back the boxed value.
126 _ => 0,
127 }
128}
129
130/// Map insert on the kernel's own storage — identical hashing, identical
131/// iteration order.
132///
133/// # Safety
134/// `map` must be a live `*mut MapStorage` pinned for the run.
135pub unsafe extern "C" fn logos_rt_map_set_ii(map: i64, key: i64, value: i64) {
136 use logicaffeine_compile::interpreter::{MapStorage, RuntimeValue};
137 let storage = &mut *(map as *mut MapStorage);
138 storage.insert(RuntimeValue::Int(key), RuntimeValue::Int(value));
139}
140
141/// Map membership for an Int key.
142///
143/// # Safety
144/// As for [`logos_rt_map_set_ii`].
145pub unsafe extern "C" fn logos_rt_map_has_i(map: i64, key: i64) -> i64 {
146 use logicaffeine_compile::interpreter::{MapStorage, RuntimeValue};
147 let storage = &*(map as *const MapStorage);
148 storage.contains_key(&RuntimeValue::Int(key)) as i64
149}
150
151/// Push helper for pinned Int lists.
152///
153/// # Safety
154/// `frame` slots must be the caller's live pins.
155pub unsafe extern "C" fn logos_rt_push_i64(
156 frame: *mut i64,
157 vec_slot: i64,
158 ptr_slot: i64,
159 len_slot: i64,
160 value: i64,
161) {
162 let vec = *frame.add(vec_slot as usize) as *mut Vec<i64>;
163 (*vec).push(value);
164 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
165 *frame.add(len_slot as usize) = (*vec).len() as i64;
166}
167
168/// Push helper for pinned half-width Int lists (`ListRepr::IntsI32` =
169/// `Vec<i32>`): the value TRUNCATES to its low 4 bytes. The narrowing proof
170/// guarantees every pushed value fits `i32`, so the truncation is lossless;
171/// `debug_assert` catches a proof violation in test builds. The refreshed
172/// `ptr_slot` is the `Vec<i32>` element buffer (4-byte stride — the stencil and
173/// regalloc loads `movsxd` from it).
174///
175/// # Safety
176/// See [`logos_rt_push_i64`]; `vec_slot` must be a live `*mut Vec<i32>`.
177pub unsafe extern "C" fn logos_rt_push_i32(
178 frame: *mut i64,
179 vec_slot: i64,
180 ptr_slot: i64,
181 len_slot: i64,
182 value: i64,
183) {
184 let vec = *frame.add(vec_slot as usize) as *mut Vec<i32>;
185 debug_assert!(
186 i32::try_from(value).is_ok(),
187 "LOGOS_NARROW_VM: pushed value {value} out of i32 range on a narrowed buffer"
188 );
189 (*vec).push(value as i32);
190 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
191 *frame.add(len_slot as usize) = (*vec).len() as i64;
192}
193
194/// See [`logos_rt_push_i64`] — f64 value travels as bits.
195///
196/// # Safety
197/// See [`logos_rt_push_i64`].
198pub unsafe extern "C" fn logos_rt_push_f64(
199 frame: *mut i64,
200 vec_slot: i64,
201 ptr_slot: i64,
202 len_slot: i64,
203 value: i64,
204) {
205 let vec = *frame.add(vec_slot as usize) as *mut Vec<f64>;
206 (*vec).push(f64::from_bits(value as u64));
207 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
208 *frame.add(len_slot as usize) = (*vec).len() as i64;
209}
210
211/// See [`logos_rt_push_i64`] — nonzero stores `true`.
212///
213/// # Safety
214/// See [`logos_rt_push_i64`].
215pub unsafe extern "C" fn logos_rt_push_bool(
216 frame: *mut i64,
217 vec_slot: i64,
218 ptr_slot: i64,
219 len_slot: i64,
220 value: i64,
221) {
222 let vec = *frame.add(vec_slot as usize) as *mut Vec<bool>;
223 (*vec).push(value != 0);
224 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
225 *frame.add(len_slot as usize) = (*vec).len() as i64;
226}
227
228/// In-place clear of a pinned list: `vec.clear()` (truncate to empty, KEEP the
229/// buffer/capacity) and refresh the pinned ptr/len slots (ptr unchanged — clear
230/// never reallocs — but refreshed for parity with push). Lowers an in-region
231/// `NewEmptyList` on a pinned array: an in-place mutation (like SetIndex), so the
232/// PRECISE region resumes over it soundly. See [`logos_rt_push_i64`] for the
233/// frame-slot convention.
234///
235/// # Safety
236/// `frame` slots must be the caller's live pins; `vec_slot` a live `*mut Vec<i64>`.
237pub unsafe extern "C" fn logos_rt_clear_i64(
238 frame: *mut i64,
239 vec_slot: i64,
240 ptr_slot: i64,
241 len_slot: i64,
242) {
243 let vec = *frame.add(vec_slot as usize) as *mut Vec<i64>;
244 (*vec).clear();
245 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
246 *frame.add(len_slot as usize) = 0;
247}
248
249/// See [`logos_rt_clear_i64`] — half-width `Vec<i32>` buffer.
250///
251/// # Safety
252/// See [`logos_rt_clear_i64`]; `vec_slot` must be a live `*mut Vec<i32>`.
253pub unsafe extern "C" fn logos_rt_clear_i32(
254 frame: *mut i64,
255 vec_slot: i64,
256 ptr_slot: i64,
257 len_slot: i64,
258) {
259 let vec = *frame.add(vec_slot as usize) as *mut Vec<i32>;
260 (*vec).clear();
261 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
262 *frame.add(len_slot as usize) = 0;
263}
264
265/// See [`logos_rt_clear_i64`] — `Vec<f64>` buffer.
266///
267/// # Safety
268/// See [`logos_rt_clear_i64`].
269pub unsafe extern "C" fn logos_rt_clear_f64(
270 frame: *mut i64,
271 vec_slot: i64,
272 ptr_slot: i64,
273 len_slot: i64,
274) {
275 let vec = *frame.add(vec_slot as usize) as *mut Vec<f64>;
276 (*vec).clear();
277 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
278 *frame.add(len_slot as usize) = 0;
279}
280
281/// See [`logos_rt_clear_i64`] — `Vec<bool>` buffer.
282///
283/// # Safety
284/// See [`logos_rt_clear_i64`].
285pub unsafe extern "C" fn logos_rt_clear_bool(
286 frame: *mut i64,
287 vec_slot: i64,
288 ptr_slot: i64,
289 len_slot: i64,
290) {
291 let vec = *frame.add(vec_slot as usize) as *mut Vec<bool>;
292 (*vec).clear();
293 *frame.add(ptr_slot as usize) = (*vec).as_mut_ptr() as i64;
294 *frame.add(len_slot as usize) = 0;
295}
296
297/// MUTABLE-Text append helper for the `MicroOp::StrAppend` regalloc lowering of a
298/// `Set text to text + <s>` build loop. `frame[handle_slot]` holds a `*mut Value`
299/// pointing AT THE VM REGISTER CELL holding the accumulator (planted at region
300/// entry). `src`/`src_len` carry the appended operand:
301/// * `src_len < 0` — BYTE form: `src` is a single ASCII byte VALUE (0..=127); the
302/// appended text is that one character.
303/// * `src_len >= 0` — CONST form: `src` is a `*const u8` to `src_len` bytes (a
304/// baked ASCII literal).
305///
306/// The grow reproduces the VM's `Vm::add_assign` semantics EXACTLY so the
307/// tiered run is BIT-IDENTICAL to the tree-walker for every alias case:
308/// * if the accumulator is a sole-owned `Rc<String>` (`Rc::get_mut` succeeds), it
309/// is extended IN PLACE (`String::push_str`);
310/// * otherwise (the `Rc` is aliased — a captured `Set saved to text`) it is grown
311/// COPY-ON-WRITE through the kernel's `arith::add` (`Rc::new(format!("{a}{b}"))`,
312/// the SAME path `add_assign` falls through to), and the FRESH `Rc` replaces the
313/// accumulator's cell — the alias keeps its own (unchanged) `Rc`;
314/// * a non-`Text` accumulator (never planted — the entry pin declines a non-Text)
315/// still falls to the kernel `add`, matching the bytecode.
316///
317/// # Safety
318/// `frame[handle_slot]` must be the live `*mut Value` planted by the region-entry
319/// pin loop; for the CONST form `src` must point to `src_len` readable ASCII bytes
320/// valid for the call. The single accumulator cell is exclusively reachable for
321/// the native run (no other code touches that register), so the `&mut Value` is
322/// non-aliasing.
323pub unsafe extern "C" fn logos_rt_str_append(
324 frame: *mut i64,
325 handle_slot: i64,
326 src: i64,
327 src_len: i64,
328) {
329 use logicaffeine_compile::interpreter::RuntimeValue;
330 use logicaffeine_compile::vm::Value;
331 use std::rc::Rc;
332
333 let cell = *frame.add(handle_slot as usize) as *mut Value;
334 let acc = &mut *cell;
335
336 // The appended bytes as a &str (ASCII — both the byte form and the baked
337 // const are guaranteed ASCII by the region's `TextBytes`/`TextByte` gate).
338 let byte_buf: [u8; 1];
339 let bytes: &[u8] = if src_len < 0 {
340 byte_buf = [src as u8];
341 &byte_buf
342 } else {
343 std::slice::from_raw_parts(src as *const u8, src_len as usize)
344 };
345 let appended: &str = std::str::from_utf8_unchecked(bytes);
346
347 // Mirror `add_assign`: sole-owned Text grows in place; everything else (a
348 // shared Rc, or a non-Text) takes the kernel `add` (COW for Text+Text).
349 if matches!(acc.as_runtime_ref(), Some(RuntimeValue::Text(_))) {
350 if let RuntimeValue::Text(rc) = acc.as_runtime_mut() {
351 if let Some(s) = Rc::get_mut(rc) {
352 s.push_str(appended);
353 return;
354 }
355 }
356 }
357 // COW / non-Text fallthrough — bit-identical to `add_assign`'s
358 // `self.reg(dst).add(self.reg(src))` for `(Text, Text)`.
359 let rhs = Value::text(appended.to_string());
360 match acc.add(&rhs) {
361 Ok(v) => *acc = v,
362 // `add` over two Texts never errors; an Err would mean a non-Text
363 // accumulator the pin should have declined. Leave the cell untouched
364 // (the region's differential gate would catch any divergence).
365 Err(_) => {}
366 }
367}
368
369/// The deopt sentinel `logos_rt_memmem` returns when the recognized search nest
370/// would, on bytecode, take an access the helper cannot reproduce in-bounds — a
371/// checked needle index past the needle buffer (`needleLen > len(needle)`). The
372/// caller's stencil routes this to a side exit so the VM replays from the region
373/// head on bytecode and raises the EXACT same error at the EXACT same point.
374/// `i64::MIN` is never a legitimate count (a count is in `[0, haystack_len]`).
375pub const LOGOS_MEMMEM_DEOPT: i64 = i64::MIN;
376
377/// Count OVERLAPPING occurrences of an ASCII needle in an ASCII haystack,
378/// reproducing the LOGOS naive-search nest BIT-IDENTICALLY (the string_search
379/// benchmark idiom). The recognizer in `adapt_region` collapses the per-byte
380/// nested-loop region to a single call to this helper.
381///
382/// Parameters mirror the nest's live values at region entry:
383/// - `haystack_ptr` / `haystack_len`: the pinned `text` byte buffer (char index
384/// == byte index for the ASCII pin) and `length of text`.
385/// - `needle_ptr` / `needle_buf_len`: the pinned `needle` byte buffer and its
386/// actual length (the pinned len slot).
387/// - `needle_len`: the PROGRAM's `needleLen` value (the inner loop bound), which
388/// may differ from `needle_buf_len`.
389/// - `start`: the 1-based outer index `i` at region entry.
390///
391/// Semantics, matching the nest `while i <= textLen - needleLen + 1 { … }`:
392/// - the outer bound is `haystack_len - needle_len + 1` (recomputed here from
393/// the SAME inputs the nest used, so it agrees exactly);
394/// - a position `i` (1-based) counts iff `haystack[i-1 + j] == needle[j]` for
395/// all `j` in `[0, needle_len)` (0-based byte compare);
396/// - an empty needle (`needle_len == 0`) matches at EVERY outer position;
397/// - a needle longer than the haystack (bound `< start`) yields zero;
398/// - if a needle index `j` would read past `needle_buf_len` (i.e.
399/// `needle_len > needle_buf_len`, the nest's CHECKED `Index` on `needle`),
400/// the helper returns [`LOGOS_MEMMEM_DEOPT`] WITHOUT scanning — the caller
401/// side-exits and bytecode reproduces the exact `index out of bounds` error.
402/// (Text accesses are guaranteed in-bounds by the bound, exactly as the nest's
403/// `IndexUnchecked` on `text` relies on.)
404///
405/// Counting is via a first-byte scan + window verify (`memchr`-style) which is
406/// bit-identical to the nested loop: it visits the same positions and applies
407/// the same byte equality, just without per-op bytecode dispatch.
408///
409/// # Safety
410/// `haystack_ptr` must point to `haystack_len` readable bytes and `needle_ptr`
411/// to `needle_buf_len` readable bytes, both pinned (borrowed) for the call.
412pub unsafe extern "C" fn logos_rt_memmem(
413 haystack_ptr: i64,
414 haystack_len: i64,
415 needle_ptr: i64,
416 needle_buf_len: i64,
417 needle_len: i64,
418 start: i64,
419 _bound_unused: i64,
420) -> i64 {
421 // Lengths arrive as raw `i64` from the JIT frame. A negative (or larger than
422 // `isize::MAX`) value would make `len as usize` a ~2^64 slice length and turn
423 // the `from_raw_parts` calls below into immediate undefined behavior
424 // (`from_raw_parts` requires `len <= isize::MAX`). Treat any out-of-range length
425 // as the recoverable bail path — bytecode then reproduces the exact semantics —
426 // exactly as for an over-long needle access. Without this, a negative
427 // `needle_len`/`haystack_len` is a reachable OOB / arbitrary-length read.
428 if haystack_len < 0
429 || needle_buf_len < 0
430 || needle_len < 0
431 || haystack_len as u64 > isize::MAX as u64
432 || needle_len as u64 > isize::MAX as u64
433 {
434 return LOGOS_MEMMEM_DEOPT;
435 }
436 // A checked needle access past its buffer is the nest's recoverable error
437 // path: bail to bytecode rather than read OOB or guess the count.
438 if needle_len > needle_buf_len {
439 return LOGOS_MEMMEM_DEOPT;
440 }
441 // The nest recomputes `bound = textLen - needleLen + 1` every outer
442 // iteration from loop-invariant inputs; recompute it here from the same
443 // values so the range agrees exactly (the passed `_bound_unused` is the
444 // VM's live copy, kept in the ABI for parity but never trusted).
445 let bound = haystack_len - needle_len + 1;
446 if start > bound {
447 return 0;
448 }
449 let haystack = std::slice::from_raw_parts(haystack_ptr as *const u8, haystack_len as usize);
450 let needle = std::slice::from_raw_parts(needle_ptr as *const u8, needle_len as usize);
451
452 // Empty needle: matches at every outer position in `[start, bound]`.
453 if needle_len == 0 {
454 return bound - start + 1;
455 }
456
457 // 1-based positions `[start, bound]` correspond to 0-based haystack offsets
458 // `[start - 1, bound - 1]`. Each offset `p` matches iff
459 // `haystack[p .. p + needle_len] == needle`.
460 let first = needle[0];
461 let nlen = needle_len as usize;
462 let lo = (start - 1) as usize;
463 // `bound - 1` is the last 0-based start offset; the window end is
464 // `bound - 1 + nlen == haystack_len`, in bounds by construction.
465 let hi = (bound - 1) as usize; // inclusive last start offset
466 let mut count = 0i64;
467 let mut p = lo;
468 while p <= hi {
469 // First-byte scan to the next candidate (memchr semantics).
470 match memchr(first, &haystack[p..=hi]) {
471 Some(off) => {
472 let cand = p + off;
473 if haystack[cand..cand + nlen] == *needle {
474 count += 1;
475 }
476 p = cand + 1;
477 }
478 None => break,
479 }
480 }
481 count
482}
483
484/// First-byte scan helper (a tiny `memchr`): the offset of the first `needle`
485/// byte in `hay`, or `None`. A plain byte loop — bit-identical to the nest's
486/// position-by-position visit; a SIMD (SSE2/AVX2) replacement can drop in here
487/// once the scalar path is proven against the differential gate.
488#[inline]
489fn memchr(needle: u8, hay: &[u8]) -> Option<usize> {
490 hay.iter().position(|&b| b == needle)
491}
492
493/// Frame-driven entry the [`MicroOp::MemMem`] stencil calls: reads the search
494/// nest's live values out of the JIT frame, runs [`logos_rt_memmem`], and on
495/// success ADDS the count into `count_slot` and advances `i_slot` to the loop's
496/// exit value (`bound + 1`). Returns `1` on success and `0` on the deopt
497/// sentinel — the stencil routes `0` to its side-exit continuation, untouched.
498///
499/// # Safety
500/// `frame` must be the running chain's live frame; the named slots must hold the
501/// pinned haystack/needle pointer+length pairs, the `needleLen` value, the
502/// 1-based `i`, and the `count` accumulator.
503#[allow(clippy::too_many_arguments)]
504pub unsafe extern "C" fn logos_rt_memmem_frame(
505 frame: *mut i64,
506 h_ptr_slot: i64,
507 h_len_slot: i64,
508 n_ptr_slot: i64,
509 n_len_slot: i64,
510 needle_len_slot: i64,
511 i_slot: i64,
512 count_slot: i64,
513) -> i64 {
514 let h_ptr = *frame.add(h_ptr_slot as usize);
515 let h_len = *frame.add(h_len_slot as usize);
516 let n_ptr = *frame.add(n_ptr_slot as usize);
517 let n_buf_len = *frame.add(n_len_slot as usize);
518 let needle_len = *frame.add(needle_len_slot as usize);
519 let start = *frame.add(i_slot as usize);
520 let result = logos_rt_memmem(h_ptr, h_len, n_ptr, n_buf_len, needle_len, start, 0);
521 if result == LOGOS_MEMMEM_DEOPT {
522 return 0;
523 }
524 *frame.add(count_slot as usize) += result;
525 // Advance `i` to the nest's natural exit value: the first index for which
526 // `i > bound` (`bound = h_len - needle_len + 1`). If the loop never ran
527 // (`start > bound`), `i` keeps its entry value — exactly the bytecode's
528 // `while i <= bound { … i = i + 1 }` post-state.
529 let bound = h_len - needle_len + 1;
530 let exit_i = core::cmp::max(start, bound + 1);
531 *frame.add(i_slot as usize) = exit_i;
532 1
533}
534
535/// Register kinds for the adapter's sound dataflow.
536#[derive(Clone, Copy, PartialEq, Debug)]
537enum Kind {
538 Unknown,
539 Int,
540 Bool,
541 Float,
542 /// A pinned unboxed Int list (the register itself never rides a slot —
543 /// its buffer pointer/length live in dedicated pin slots).
544 IntList,
545 /// A pinned half-width (`Vec<i32>`) Int list (`ListRepr::IntsI32`): the
546 /// element access width is 4 bytes (sign-extending loads, truncating
547 /// stores). Behaves exactly like [`Kind::IntList`] for register/pin gating;
548 /// only the array op width differs.
549 IntListI32,
550 /// A pinned unboxed Float list.
551 FloatList,
552 /// A pinned unboxed Bool list.
553 BoolList,
554 /// A pinned MAP (boxed kernel storage behind a pointer pin; int fast
555 /// lane verified per helper call).
556 IntMap,
557 /// A pinned ASCII `Text` carried AS BYTES (char index == byte index, char
558 /// count == byte length): `item i of text` is a 1-byte pinned load and the
559 /// extracted char rides the [`Kind::TextByte`] lane.
560 TextBytes,
561 /// A single ASCII character extracted from a [`Kind::TextBytes`] (or a
562 /// 1-char ASCII text constant): the integer byte value 0..=127. Equality
563 /// between two `TextByte`s is an exact integer compare — which matches the
564 /// tree-walker's `Text == Text` string compare for single ASCII chars.
565 TextByte,
566 /// A pinned MUTABLE-Text accumulator (the `dst` of a `Set text to text + …`
567 /// build loop): it rides the [`PinElem::TextMut`] channel (a `*mut Value`
568 /// handle), not a scalar frame slot. The only op that consumes it is the
569 /// append (`AddAssign`), lowered to [`MicroOp::StrAppend`]; any other use
570 /// (a `Move`, an `Index`, …) is rejected so the region bails — keeping the
571 /// accumulator's growth confined to the proven-sound append path.
572 TextMut,
573 /// A MULTI-character ASCII text CONSTANT (`text + "XXXXX"`): a baked byte
574 /// slice. Valid ONLY as the source of a `StrAppend`; every other use gate
575 /// rejects it (so the region bails).
576 TextConst,
577 Mixed,
578}
579
580impl Kind {
581 fn slot_kind(self) -> Option<SlotKind> {
582 match self {
583 Kind::Int => Some(SlotKind::Int),
584 Kind::Bool => Some(SlotKind::Bool),
585 Kind::Float => Some(SlotKind::Float),
586 _ => None,
587 }
588 }
589}
590
591/// The single ASCII byte of a 1-character ASCII text constant, or `None` if the
592/// constant is not exactly one ASCII char. A `Kind::TextByte` comparison loads
593/// one byte from the pinned text, so a constant on the other side must be that
594/// same single byte; the tree-walker compares the two 1-char Texts as strings,
595/// which agrees with the byte compare exactly for single ASCII chars.
596fn text_const_byte(s: &str) -> Option<u8> {
597 let b = s.as_bytes();
598 (b.len() == 1 && b[0] < 128).then(|| b[0])
599}
600
601/// Recover the constant ASCII byte slice of a `StrAppend`'s `TextConst` source
602/// (`text + "XXXXX"`) and bake it into a `'static` allocation so the regalloc
603/// stencil can hold a stable `(ptr, len)`. The source register `src` is loaded by
604/// a `LoadConst` in the same region; scan BACKWARD from the append for the nearest
605/// def of `src` and require it to be a `LoadConst` of a `Constant::Text`. Returns
606/// `None` (declining the region) if the source is not a direct constant load — a
607/// conservative bail to bytecode. The leak is one-time per compiled region (there
608/// are a handful of such constants) and the bytes outlive the chain.
609fn const_text_slice(
610 ops: &[Op],
611 i: usize,
612 base_pc: usize,
613 src: u16,
614 constants: &[Constant],
615) -> Option<(i64, i64)> {
616 let _ = base_pc;
617 let mut j = i;
618 while j > 0 {
619 j -= 1;
620 let (_, defs) = region_use_def(&ops[j])?;
621 if defs.contains(&src) {
622 if let Op::LoadConst { idx, .. } = ops[j] {
623 if let Some(Constant::Text(s)) = constants.get(idx as usize) {
624 if s.is_ascii() && !s.is_empty() {
625 let leaked: &'static [u8] = s.clone().into_bytes().leak();
626 return Some((leaked.as_ptr() as i64, leaked.len() as i64));
627 }
628 }
629 }
630 // The nearest def of `src` is not a constant Text load — bail.
631 return None;
632 }
633 }
634 None
635}
636
637fn observed_kind(o: ObservedKind) -> Kind {
638 match o {
639 ObservedKind::Int => Kind::Int,
640 ObservedKind::Bool => Kind::Bool,
641 ObservedKind::Float => Kind::Float,
642 ObservedKind::IntList => Kind::IntList,
643 ObservedKind::IntListI32 => Kind::IntListI32,
644 ObservedKind::FloatList => Kind::FloatList,
645 ObservedKind::BoolList => Kind::BoolList,
646 ObservedKind::Map => Kind::IntMap,
647 ObservedKind::TextBytes => Kind::TextBytes,
648 ObservedKind::Other => Kind::Mixed,
649 }
650}
651
652/// Per-pinned-register slot assignments for translation.
653#[derive(Clone, Copy)]
654struct PinSlots {
655 vec_slot: u16,
656 ptr_slot: u16,
657 len_slot: u16,
658 elem: PinElem,
659}
660
661/// Everything a SELF-call lowering needs baked into its stencil.
662struct CallCtx<'t> {
663 table_addr: i64,
664 depth_addr: i64,
665 status_addr: i64,
666 limit_slot: u16,
667 depth_limit: i64,
668 self_fi: u16,
669 /// The program's function table — non-self calls bake the CALLEE's
670 /// slot address for table dispatch.
671 table: &'t FnTable,
672}
673
674/// The result kind of an arithmetic op over two operand kinds, mirroring the
675/// kernel's promotion: Int×Int→Int, any Float→Float. None = not arithmetic
676/// (the use gate bails).
677fn arith_result(l: Kind, r: Kind) -> Option<Kind> {
678 match (l, r) {
679 (Kind::Int, Kind::Int) => Some(Kind::Int),
680 (Kind::Float, Kind::Float) | (Kind::Int, Kind::Float) | (Kind::Float, Kind::Int) => {
681 Some(Kind::Float)
682 }
683 _ => None,
684 }
685}
686
687fn join(a: Kind, b: Kind) -> Kind {
688 match (a, b) {
689 (Kind::Unknown, x) | (x, Kind::Unknown) => x,
690 (x, y) if x == y => x,
691 _ => Kind::Mixed,
692 }
693}
694
695/// Forward, flow-SENSITIVE kind dataflow over a bytecode slice (a function
696/// body or a loop region). Each reachable op gets the kind vector holding
697/// BEFORE it executes; merge points join per slot (`Unknown ⊔ k = k`,
698/// `k ⊔ k = k`, else `Mixed`). Constants carry their pool type (Int, Float,
699/// Bool); arithmetic promotes like the kernel (any Float operand → Float).
700/// Jump targets outside the slice are exits. Returns per-op kind-in vectors
701/// (None = unreachable), or None when any reachable use violates the gates.
702fn kind_flow(
703 ops: &[Op],
704 base_pc: usize,
705 constants: &[Constant],
706 entry: Vec<Kind>,
707 calls: Option<&dyn Fn(u16) -> Option<(Kind, Vec<Kind>)>>,
708) -> Option<Vec<Option<Vec<Kind>>>> {
709 let n = ops.len();
710 let in_slice = |t: usize| t >= base_pc && t < base_pc + n;
711 let const_kind = |idx: u32| -> Kind {
712 match constants.get(idx as usize) {
713 Some(Constant::Int(_)) => Kind::Int,
714 Some(Constant::Float(_)) => Kind::Float,
715 Some(Constant::Bool(_)) => Kind::Bool,
716 // A 1-char ASCII text/char literal compares against an extracted
717 // `item i of text` as an integer byte (the strings benchmark's
718 // `item i of result equals " "`). A multi-char or non-ASCII text is
719 // not a single byte — it stays Mixed and bails.
720 Some(Constant::Char(c)) if c.is_ascii() => Kind::TextByte,
721 Some(Constant::Text(s)) if text_const_byte(s).is_some() => Kind::TextByte,
722 // A MULTI-char ASCII text constant is valid only as a `StrAppend`
723 // source (`text + "XXXXX"`); every other gate rejects `TextConst`.
724 Some(Constant::Text(s)) if !s.is_empty() && s.is_ascii() => Kind::TextConst,
725 _ => Kind::Mixed,
726 }
727 };
728 let mut kin: Vec<Option<Vec<Kind>>> = vec![None; n];
729 kin[0] = Some(entry);
730 let mut work: Vec<usize> = vec![0];
731 while let Some(i) = work.pop() {
732 let Some(cur) = kin[i].clone() else { continue };
733 let mut out = cur;
734 match ops[i] {
735 Op::LoadConst { dst, idx } => out[dst as usize] = const_kind(idx),
736 Op::Add { dst, lhs, rhs }
737 | Op::Sub { dst, lhs, rhs }
738 | Op::Mul { dst, lhs, rhs }
739 | Op::Div { dst, lhs, rhs } => {
740 out[dst as usize] =
741 arith_result(out[lhs as usize], out[rhs as usize]).unwrap_or(Kind::Mixed)
742 }
743 Op::AddAssign { dst, src } => {
744 out[dst as usize] = if out[dst as usize] == Kind::TextMut {
745 // A pinned mutable-Text accumulator stays the accumulator —
746 // it grows in place / COW through `StrAppend`.
747 Kind::TextMut
748 } else {
749 arith_result(out[dst as usize], out[src as usize]).unwrap_or(Kind::Mixed)
750 }
751 }
752 Op::Mod { dst, .. }
753 | Op::DivPow2 { dst, .. }
754 | Op::MagicDivU { dst, .. }
755 | Op::BitXor { dst, .. }
756 | Op::Shl { dst, .. }
757 | Op::Shr { dst, .. } => out[dst as usize] = Kind::Int,
758 Op::BitAnd { dst, lhs, rhs } | Op::BitOr { dst, lhs, rhs } => {
759 out[dst as usize] = match (out[lhs as usize], out[rhs as usize]) {
760 (Kind::Int, Kind::Int) => Kind::Int,
761 (Kind::Bool, Kind::Bool) => Kind::Bool,
762 _ => Kind::Mixed,
763 }
764 }
765 Op::Not { dst, src } => {
766 out[dst as usize] = match out[src as usize] {
767 Kind::Bool => Kind::Bool,
768 _ => Kind::Mixed,
769 }
770 }
771 Op::Lt { dst, .. } | Op::Gt { dst, .. } | Op::LtEq { dst, .. }
772 | Op::GtEq { dst, .. } | Op::Eq { dst, .. } | Op::NotEq { dst, .. } => {
773 out[dst as usize] = Kind::Bool
774 }
775 Op::Move { dst, src } => out[dst as usize] = out[src as usize],
776 Op::Index { dst, collection, .. }
777 | Op::IndexUnchecked { dst, collection, .. } => {
778 out[dst as usize] = match out[collection as usize] {
779 Kind::IntList | Kind::IntListI32 => Kind::Int,
780 Kind::FloatList => Kind::Float,
781 Kind::BoolList => Kind::Bool,
782 // Map int fast lane: a non-Int value is a helper miss
783 // (side exit), so the in-region kind is Int.
784 Kind::IntMap => Kind::Int,
785 // `item i of text` on an ASCII text: a single ASCII byte.
786 Kind::TextBytes => Kind::TextByte,
787 _ => Kind::Mixed,
788 }
789 }
790 Op::Length { dst, .. } => out[dst as usize] = Kind::Int,
791 Op::Contains { dst, .. } => out[dst as usize] = Kind::Bool,
792 // Fresh allocations ride the Int lane (mode B sites).
793 Op::NewEmptyList { dst } => out[dst as usize] = Kind::IntList,
794 Op::Call { dst, func, .. } => {
795 let Some((ret, _)) = calls.and_then(|c| c(func)) else { return None };
796 out[dst as usize] = ret;
797 }
798 Op::CallBuiltin {
799 dst,
800 builtin: logicaffeine_compile::semantics::builtins::BuiltinId::Sqrt,
801 ..
802 } => out[dst as usize] = Kind::Float,
803 _ => {}
804 }
805 let mut succs: Vec<usize> = Vec::with_capacity(2);
806 match ops[i] {
807 Op::Jump { target } => {
808 if in_slice(target) {
809 succs.push(target - base_pc);
810 }
811 }
812 Op::JumpIfFalse { target, .. } | Op::JumpIfTrue { target, .. } => {
813 if i + 1 < n {
814 succs.push(i + 1);
815 }
816 if in_slice(target) {
817 succs.push(target - base_pc);
818 }
819 }
820 Op::Return { .. } | Op::ReturnNothing => {}
821 _ => {
822 if i + 1 < n {
823 succs.push(i + 1);
824 }
825 }
826 }
827 for s in succs {
828 match &mut kin[s] {
829 slot @ None => {
830 *slot = Some(out.clone());
831 work.push(s);
832 }
833 Some(prev) => {
834 let mut changed = false;
835 for (a, b) in prev.iter_mut().zip(&out) {
836 let j = join(*a, *b);
837 if j != *a {
838 *a = j;
839 changed = true;
840 }
841 }
842 if changed {
843 work.push(s);
844 }
845 }
846 }
847 }
848 }
849 // Gates on every reachable use, at that use's program point.
850 for (i, op) in ops.iter().enumerate() {
851 let Some(k) = &kin[i] else { continue };
852 let int_at = |r: u16| k[r as usize] == Kind::Int;
853 let num_at = |r: u16| matches!(k[r as usize], Kind::Int | Kind::Float);
854 match *op {
855 // Arithmetic and ordered comparisons: numeric operands (mixed
856 // Int/Float promotes via an inserted conversion).
857 Op::Add { lhs, rhs, .. } | Op::Sub { lhs, rhs, .. } | Op::Mul { lhs, rhs, .. }
858 | Op::Div { lhs, rhs, .. }
859 | Op::Lt { lhs, rhs, .. } | Op::Gt { lhs, rhs, .. } | Op::LtEq { lhs, rhs, .. }
860 | Op::GtEq { lhs, rhs, .. } => {
861 if !num_at(lhs) || !num_at(rhs) {
862 return None;
863 }
864 }
865 Op::AddAssign { dst, src } => {
866 if k[dst as usize] == Kind::TextMut {
867 // A pinned mutable-Text append: the source must be an
868 // appendable ASCII text — a 1-char runtime byte (`TextByte`)
869 // or a multi-char constant (`TextConst`). `StrAppend` lowers it.
870 if !matches!(k[src as usize], Kind::TextByte | Kind::TextConst) {
871 return None;
872 }
873 } else if !num_at(dst) || !num_at(src) {
874 return None;
875 }
876 }
877 // Equality: numeric pairs (promoting), Bool×Bool, or TextByte×
878 // TextByte (two extracted ASCII chars, or an extracted char vs a
879 // 1-char ASCII literal — both sides are integer bytes, the compare
880 // is exact, matching the tree-walker's single-char Text compare).
881 Op::Eq { lhs, rhs, .. } | Op::NotEq { lhs, rhs, .. } => {
882 let bools = k[lhs as usize] == Kind::Bool && k[rhs as usize] == Kind::Bool;
883 let bytes = k[lhs as usize] == Kind::TextByte && k[rhs as usize] == Kind::TextByte;
884 if !bools && !bytes && (!num_at(lhs) || !num_at(rhs)) {
885 return None;
886 }
887 }
888 // Int-only ops.
889 Op::Mod { lhs, rhs, .. } | Op::BitXor { lhs, rhs, .. }
890 | Op::Shl { lhs, rhs, .. } | Op::Shr { lhs, rhs, .. } => {
891 if !int_at(lhs) || !int_at(rhs) {
892 return None;
893 }
894 }
895 // `& |`: both-Int (bitwise) or both-Bool (logical 0/1).
896 Op::BitAnd { lhs, rhs, .. } | Op::BitOr { lhs, rhs, .. } => {
897 let ints = int_at(lhs) && int_at(rhs);
898 let bools = k[lhs as usize] == Kind::Bool && k[rhs as usize] == Kind::Bool;
899 if !ints && !bools {
900 return None;
901 }
902 }
903 // `not` is LOGICAL (truthiness → Bool); only a Bool operand has a
904 // 0/1 register form the stencils can negate. `not <Int>` (rare —
905 // an emptiness idiom) stays on the VM.
906 Op::Not { src, .. } => {
907 if !matches!(k[src as usize], Kind::Bool) {
908 return None;
909 }
910 }
911 Op::Move { src, .. } => {
912 // List buffers never ride frame slots (they are pinned
913 // separately) — but a list-kind Move is legal as STAGING:
914 // the slot value is a placeholder and pin identity travels
915 // statically. Any real USE of an unpinned destination still
916 // fails closed at translate (pins lookup).
917 if !matches!(
918 k[src as usize],
919 Kind::Int
920 | Kind::Bool
921 | Kind::Float
922 | Kind::IntList
923 | Kind::IntListI32
924 | Kind::FloatList
925 | Kind::BoolList
926 // A 1-char ASCII byte (`text + ch`'s `ch`) rides a plain
927 // frame slot as its integer byte value — a raw-copy Move.
928 | Kind::TextByte
929 ) {
930 return None;
931 }
932 }
933 Op::JumpIfFalse { cond, .. } | Op::JumpIfTrue { cond, .. } => {
934 if k[cond as usize] != Kind::Bool {
935 return None;
936 }
937 }
938 Op::Index { collection, index, .. }
939 | Op::IndexUnchecked { collection, index, .. } => {
940 if !matches!(
941 k[collection as usize],
942 Kind::IntList | Kind::IntListI32 | Kind::FloatList | Kind::BoolList | Kind::IntMap | Kind::TextBytes
943 ) || !int_at(index)
944 {
945 return None;
946 }
947 }
948 Op::SetIndex { collection, index, value }
949 | Op::SetIndexUnchecked { collection, index, value } => {
950 let elem_ok = match k[collection as usize] {
951 Kind::IntList | Kind::IntListI32 => k[value as usize] == Kind::Int,
952 Kind::FloatList => k[value as usize] == Kind::Float,
953 Kind::BoolList => k[value as usize] == Kind::Bool,
954 Kind::IntMap => k[value as usize] == Kind::Int,
955 _ => false,
956 };
957 if !elem_ok || !int_at(index) {
958 return None;
959 }
960 }
961 Op::Contains { collection, value, .. } => {
962 if k[collection as usize] != Kind::IntMap || !int_at(value) {
963 return None;
964 }
965 }
966 Op::Length { collection, .. } => {
967 if !matches!(
968 k[collection as usize],
969 Kind::IntList | Kind::IntListI32 | Kind::FloatList | Kind::BoolList | Kind::TextBytes
970 ) {
971 return None;
972 }
973 }
974 // SELF-calls only (the callee's entry contract is its own: Int
975 // params, known return kind); every argument must be Int at the
976 // call point — exactly the per-call guard the bytecode boundary
977 // enforces.
978 Op::Call { func, args_start, arg_count, .. } => {
979 let Some((_, params)) = calls.and_then(|c| c(func)) else { return None };
980 if arg_count as usize != params.len() {
981 return None;
982 }
983 for (j, a) in (args_start..args_start + arg_count).enumerate() {
984 if k[a as usize] != params[j] {
985 return None;
986 }
987 }
988 }
989 Op::CallBuiltin {
990 builtin: logicaffeine_compile::semantics::builtins::BuiltinId::Sqrt,
991 args_start,
992 arg_count,
993 ..
994 } => {
995 if arg_count != 1
996 || !matches!(k[args_start as usize], Kind::Int | Kind::Float)
997 {
998 return None;
999 }
1000 }
1001 Op::ListPush { list, value } => {
1002 let elem_ok = match k[list as usize] {
1003 Kind::IntList | Kind::IntListI32 => k[value as usize] == Kind::Int,
1004 Kind::FloatList => k[value as usize] == Kind::Float,
1005 Kind::BoolList => k[value as usize] == Kind::Bool,
1006 _ => false,
1007 };
1008 if !elem_ok {
1009 return None;
1010 }
1011 }
1012 Op::Return { src } => {
1013 // Regions return scalars (re-boxed by SlotKind) or pinned
1014 // lists (by register); the function adapter's own ret-kind
1015 // check still restricts FUNCTION returns to Int/Bool.
1016 if !matches!(
1017 k[src as usize],
1018 Kind::Int
1019 | Kind::Bool
1020 | Kind::Float
1021 | Kind::IntList
1022 | Kind::IntListI32
1023 | Kind::FloatList
1024 | Kind::BoolList
1025 ) {
1026 return None;
1027 }
1028 }
1029 _ => {}
1030 }
1031 }
1032 Some(kin)
1033}
1034
1035/// Translate WHOLE-PROGRAM Main bytecode into the J2 micro-op subset.
1036///
1037/// Requirements (else `None`):
1038/// - ops drawn from {LoadConst(Int), Move, Add, Sub, Mul, Lt, Gt, Eq,
1039/// Jump, JumpIfFalse, JumpIfTrue, Show};
1040/// - exactly ONE Show, and it is the final op (it becomes Return);
1041/// - no comparison-produced value is the shown register (bool-taint).
1042///
1043/// Returns the micro program and the frame size it needs.
1044#[allow(clippy::type_complexity)]
1045pub fn adapt(
1046 ops: &[Op],
1047 constants: &[Constant],
1048 register_count: usize,
1049) -> Option<(Vec<MicroOp>, usize)> {
1050 // The shown register must not be a comparison result anywhere.
1051 let mut tainted: Vec<u16> = Vec::new();
1052 let mut shows = 0usize;
1053 for op in ops {
1054 match *op {
1055 Op::Lt { dst, .. } | Op::Gt { dst, .. } | Op::LtEq { dst, .. }
1056 | Op::GtEq { dst, .. } | Op::Eq { dst, .. } | Op::NotEq { dst, .. } => {
1057 tainted.push(dst)
1058 }
1059 Op::Show { .. } => shows += 1,
1060 _ => {}
1061 }
1062 }
1063 // Terminal shape: `Show; Halt` (or a bare trailing Show). Anything that
1064 // jumps PAST the Show (to the Halt) would print nothing on the VM while
1065 // the JIT still returns a value — bail on those.
1066 let n = ops.len();
1067 let (show_pc, halt_pc) = match (n.checked_sub(2).map(|i| &ops[i]), ops.last()) {
1068 (Some(Op::Show { .. }), Some(Op::Halt)) => (n - 2, Some(n - 1)),
1069 (_, Some(Op::Show { .. })) => (n - 1, None),
1070 _ => return None,
1071 };
1072 if shows != 1 {
1073 return None;
1074 }
1075 for op in ops {
1076 if let Op::Jump { target } | Op::JumpIfFalse { target, .. } | Op::JumpIfTrue { target, .. } = *op {
1077 if target > show_pc {
1078 return None;
1079 }
1080 }
1081 }
1082 let _ = halt_pc;
1083
1084 let frame_size = register_count;
1085
1086 // Pass 1: translate, recording vm-pc → micro-index. Jump targets hold VM
1087 // pcs until pass 2 remaps them.
1088 let mut micro: Vec<MicroOp> = Vec::with_capacity(ops.len());
1089 let mut pc_to_micro: Vec<usize> = Vec::with_capacity(ops.len());
1090 // (micro index, vm target) needing remap.
1091 let mut fixups: Vec<usize> = Vec::new();
1092
1093 for op in ops {
1094 pc_to_micro.push(micro.len());
1095 match *op {
1096 Op::LoadConst { dst, idx } => match constants.get(idx as usize)? {
1097 Constant::Int(v) => micro.push(MicroOp::LoadConst { dst, value: *v }),
1098 _ => return None,
1099 },
1100 Op::Move { dst, src } => micro.push(MicroOp::Move { dst, src }),
1101 Op::EnsureOwned { .. } => {} // interpreter-only COW barrier — no-op in a native region
1102 Op::Add { dst, lhs, rhs } => micro.push(MicroOp::Add { dst, lhs, rhs }),
1103 Op::AddAssign { dst, src } => micro.push(MicroOp::Add { dst, lhs: dst, rhs: src }),
1104 Op::Div { dst, lhs, rhs } => match const_pow2_div(ops, rhs, constants) {
1105 Some(k) => micro.push(MicroOp::DivPow2 { dst, lhs, k }),
1106 None => micro.push(MicroOp::Div { dst, lhs, rhs }),
1107 },
1108 Op::DivPow2 { dst, lhs, k } => micro.push(MicroOp::DivPow2 { dst, lhs, k: k as u32 }),
1109 Op::MagicDivU { dst, lhs, magic, more, mul_back } => {
1110 micro.push(MicroOp::MagicDivU { dst, lhs, magic, more, mul_back })
1111 }
1112 Op::Mod { dst, lhs, rhs } => micro.push(MicroOp::Mod { dst, lhs, rhs }),
1113 Op::Sub { dst, lhs, rhs } => micro.push(MicroOp::Sub { dst, lhs, rhs }),
1114 Op::Mul { dst, lhs, rhs } => micro.push(MicroOp::Mul { dst, lhs, rhs }),
1115 Op::Lt { dst, lhs, rhs } => micro.push(MicroOp::Lt { dst, lhs, rhs }),
1116 Op::Gt { dst, lhs, rhs } => micro.push(MicroOp::Gt { dst, lhs, rhs }),
1117 Op::Eq { dst, lhs, rhs } => micro.push(MicroOp::Eq { dst, lhs, rhs }),
1118 Op::LtEq { dst, lhs, rhs } => micro.push(MicroOp::LtEq { dst, lhs, rhs }),
1119 Op::GtEq { dst, lhs, rhs } => micro.push(MicroOp::GtEq { dst, lhs, rhs }),
1120 Op::NotEq { dst, lhs, rhs } => micro.push(MicroOp::Neq { dst, lhs, rhs }),
1121 Op::Jump { target } => {
1122 fixups.push(micro.len());
1123 micro.push(MicroOp::Jump { target });
1124 }
1125 Op::JumpIfFalse { cond, target } => {
1126 fixups.push(micro.len());
1127 micro.push(MicroOp::JumpIfFalse { cond, target });
1128 }
1129 Op::JumpIfTrue { cond, target } => {
1130 fixups.push(micro.len());
1131 micro.push(MicroOp::JumpIfTrue { cond, target });
1132 }
1133 Op::Show { src } => {
1134 if tainted.contains(&src) {
1135 return None;
1136 }
1137 micro.push(MicroOp::Return { src });
1138 }
1139 // The trailing Halt after the Show: unreachable past Return.
1140 Op::Halt => {}
1141 _ => return None,
1142 }
1143 }
1144
1145 // Pass 2: remap jump targets from VM pcs to micro indices.
1146 for &i in &fixups {
1147 match &mut micro[i] {
1148 MicroOp::Jump { target }
1149 | MicroOp::JumpIfFalse { target, .. }
1150 | MicroOp::JumpIfTrue { target, .. } => {
1151 *target = *pc_to_micro.get(*target)?;
1152 }
1153 _ => unreachable!(),
1154 }
1155 }
1156 Some((micro, frame_size))
1157}
1158
1159/// Translate a FUNCTION body (terminal = Op::Return, absolute pcs rebased by
1160/// `entry_pc`) into the J2 subset.
1161///
1162/// J4 kind dataflow (sound fixpoint): params are Int (the entry guard),
1163/// comparisons produce Bool, arithmetic requires Int operands (a Bool
1164/// flowing into `+` errors on the VM — native code must NOT compute it),
1165/// Move propagates, conflicting writes go Mixed (bail on use). All reachable
1166/// returns must agree on Int or Bool; the bool flag re-boxes the result.
1167/// Everything the precise-deopt boundary needs at runtime, built once at
1168/// adapt time (mode B — functions with list parameters).
1169struct PreciseInfo {
1170 register_count: usize,
1171 pins_start: usize,
1172 /// Per-register re-box kinds at every potential pause point: each
1173 /// deoptable op's pc and each self-call's pc.
1174 kinds_by_pc: std::collections::HashMap<usize, Vec<RegBox>>,
1175 /// NATIVE-OWNED list registers per pause point: (register, the frame
1176 /// slot of its pin's vec handle). At materialization the handle either
1177 /// detaches from the allocation registry (fresh list — re-boxed here)
1178 /// or matches a boundary pin (param passthrough — rewritten to
1179 /// ListParam for the VM side).
1180 owned_by_pc: std::collections::HashMap<usize, Vec<(u16, u16)>>,
1181 /// Where each boundary pin triple (param order) lands in the frame:
1182 /// the vec slot of THAT PARAM REGISTER's pin (per-register pins are
1183 /// not in param order).
1184 param_pin_scatter: Vec<u16>,
1185}
1186
1187/// What adapt_function hands back: micro code, frame size, return shape,
1188/// per-micro deopt codes (mode B) and the precise-walk tables (mode B).
1189struct AdaptedFn {
1190 micro: Vec<MicroOp>,
1191 frame_size: usize,
1192 ret: NativeRet,
1193 deopt_codes: Option<Vec<i64>>,
1194 precise: Option<PreciseInfo>,
1195}
1196
1197/// The precise-deopt tables for a REGION (push+SetIndex worklist). `deopt_codes`
1198/// tag every micro with its bytecode op's encoded resume pc; `kinds_by_pc` maps
1199/// each resume pc to the per-register re-box kind used when materializing the
1200/// frame's scalars back into the VM registers (`None` = keep the VM register's
1201/// live value — a pinned array, or a read-only/unknown slot).
1202struct RegionPrecise {
1203 deopt_codes: Vec<i64>,
1204 kinds_by_pc: std::collections::HashMap<usize, Vec<Option<SlotKind>>>,
1205}
1206
1207/// LIST-REGISTER discovery (shared by the adapter and the frame-size
1208/// pre-computation): every register that ever holds a list — params,
1209/// allocation sites, Move aliases, list-returning self-call results.
1210fn discover_list_regs(
1211 ops: &[Op],
1212 list_param_regs: &[u16],
1213 fn_returns_lists: bool,
1214 self_fi: u16,
1215 nregs: usize,
1216) -> Vec<bool> {
1217 let mut is_list = vec![false; nregs];
1218 for &r in list_param_regs {
1219 is_list[r as usize] = true;
1220 }
1221 loop {
1222 let mut changed = false;
1223 for op in ops {
1224 match *op {
1225 Op::NewEmptyList { dst } => {
1226 if !is_list[dst as usize] {
1227 is_list[dst as usize] = true;
1228 changed = true;
1229 }
1230 }
1231 Op::Move { dst, src } => {
1232 if is_list[src as usize] && !is_list[dst as usize] {
1233 is_list[dst as usize] = true;
1234 changed = true;
1235 }
1236 }
1237 Op::Call { dst, func, .. } if func == self_fi && fn_returns_lists => {
1238 if !is_list[dst as usize] {
1239 is_list[dst as usize] = true;
1240 changed = true;
1241 }
1242 }
1243 _ => {}
1244 }
1245 }
1246 if !changed {
1247 break;
1248 }
1249 }
1250 is_list
1251}
1252
1253fn jt(which: &str) {
1254 if std::env::var_os("LOGOS_JIT_TRACE").is_some() {
1255 eprintln!("jit-trace: mode-B bail at {which}");
1256 }
1257}
1258
1259fn adapt_function(
1260 ops: &[Op],
1261 entry_pc: usize,
1262 constants: &[Constant],
1263 param_count: u16,
1264 register_count: u16,
1265 self_fi: u16,
1266 param_kinds: &[Option<ParamKind>],
1267 declared_ret: Option<SlotKind>,
1268 call_ctx_in: &CallCtx,
1269 callees: &[CalleeSig],
1270) -> Option<AdaptedFn> {
1271 // Every jump must stay inside the body slice — a function is self-contained.
1272 for op in ops {
1273 if let Op::Jump { target }
1274 | Op::JumpIfFalse { target, .. }
1275 | Op::JumpIfTrue { target, .. } = *op
1276 {
1277 if target < entry_pc || target >= entry_pc + ops.len() {
1278 return None;
1279 }
1280 }
1281 }
1282
1283 // SOUNDNESS STOPGAP (BUG-002, NOT the root-cause fix): a function that both
1284 // (a) RETURNS/recurses on a list it MUTATES in place AND (b) loads an array
1285 // element that survives a conditional branch into an in-place `SetIndex`
1286 // miscompiles in the native tier (segfault) — the quicksort `Let vj … set
1287 // item i to vj` shape. Until the codegen is fixed, refuse to tier such a
1288 // function (it runs on the correct VM). Scoped to recursion + in-place store
1289 // so non-recursive swap loops (bubble_sort/fannkuch) still tier.
1290 // `LOGOS_BUG002_BAIL=0` disables the stopgap (to reproduce the raw crash).
1291 if std::env::var("LOGOS_BUG002_BAIL").as_deref() != Ok("0")
1292 && ops.iter().any(|op| matches!(op, Op::Call { .. }))
1293 && has_crossbranch_array_value_store_hazard(ops)
1294 {
1295 return None;
1296 }
1297
1298 // Flow-sensitive kinds: every parameter starts at its DECLARED kind
1299 // (the per-call boundary guard checks the matching discriminant before
1300 // entry, so the seed is sound, not speculative); everything else starts
1301 // Unknown and the gates inside kind_flow enforce soundness at every
1302 // reachable use.
1303 // Return kind: a declared return type pins it in one pass; otherwise
1304 // the self-recursive Int/Bool inference (assume, verify, retry) stands.
1305 if param_kinds.len() != param_count as usize {
1306 return None;
1307 }
1308 let mut param_entry: Vec<Kind> = Vec::with_capacity(param_count as usize);
1309 let mut list_params: Vec<(u16, PinElem)> = Vec::new();
1310 for (i, pk) in param_kinds.iter().enumerate() {
1311 param_entry.push(match (*pk)? {
1312 ParamKind::Scalar(SlotKind::Int) => Kind::Int,
1313 ParamKind::Scalar(SlotKind::Bool) => Kind::Bool,
1314 ParamKind::Scalar(SlotKind::Float) => Kind::Float,
1315 ParamKind::List(elem) => {
1316 list_params.push((i as u16, elem));
1317 match elem {
1318 PinElem::Int => Kind::IntList,
1319 PinElem::Float => Kind::FloatList,
1320 PinElem::Bool => Kind::BoolList,
1321 // A half-width buffer is a VM-local storage decision, never a
1322 // declared list-param type — bail rather than speculate it.
1323 PinElem::IntI32 => return None,
1324 // Declared kinds never produce a Map or Text list-param.
1325 PinElem::Map | PinElem::TextBytes | PinElem::TextMut => return None,
1326 }
1327 }
1328 });
1329 }
1330 // ALLOCATION SITES make a function mode B even without list params.
1331 let has_sites = ops.iter().any(|op| matches!(op, Op::NewEmptyList { .. }));
1332 let mode_b = !list_params.is_empty() || has_sites;
1333 // Mode B leans on the PRECISE call stencil, whose depth limit is baked
1334 // at the kernel's locked MAX_CALL_DEPTH.
1335 if mode_b && call_ctx_in.depth_limit != logicaffeine_compile::semantics::MAX_CALL_DEPTH as i64 {
1336 return None;
1337 }
1338 // Does this function RETURN lists? Declared scalar returns say no;
1339 // otherwise list params or sites make it possible — the fixpoint
1340 // verifies every return site agrees.
1341 let fn_returns_lists = declared_ret.is_none() && mode_b;
1342
1343 // LIST REGISTERS (mode B): one pin triple PER REGISTER that ever
1344 // holds a list — every list assignment (param entry, allocation,
1345 // Move alias, self-call result) REFRESHES that register's own triple,
1346 // so reassignment patterns (quicksort's `result`) cannot conflict.
1347 // Push eligibility is static: a register every one of whose list
1348 // sources is an allocation site is definitely registry-owned — a push
1349 // anywhere else could stale another holder's pinned slots.
1350 let nregs_pin = register_count as usize + 2;
1351 let list_param_regs: Vec<u16> = list_params.iter().map(|(r, _)| *r).collect();
1352 let is_list = discover_list_regs(ops, &list_param_regs, fn_returns_lists, self_fi, nregs_pin);
1353 // Provenance TAINT, FLOW-SENSITIVE: at each program point, which
1354 // registers may hold a list some OTHER holder pins (params; call
1355 // results, which may be param passthroughs). Moves propagate taint;
1356 // a NewEmptyList CLEANSES its destination (fresh, registry-owned).
1357 // Pushes are legal only into point-wise clean registers — mergesort's
1358 // `Set left to mergeSort(left)` taints `left` only AFTER the pushes.
1359 let mut ret_any_list = false;
1360 if mode_b {
1361 let in_body = |t: usize| t >= entry_pc && t < entry_pc + ops.len();
1362 let mut taint_in: Vec<Option<Vec<bool>>> = vec![None; ops.len()];
1363 let mut entry_taint = vec![false; nregs_pin];
1364 for (preg, _) in &list_params {
1365 entry_taint[*preg as usize] = true;
1366 }
1367 taint_in[0] = Some(entry_taint);
1368 let mut work: Vec<usize> = vec![0];
1369 while let Some(i) = work.pop() {
1370 let Some(cur) = taint_in[i].clone() else { continue };
1371 let mut out = cur;
1372 match ops[i] {
1373 Op::NewEmptyList { dst } => out[dst as usize] = false,
1374 Op::Move { dst, src } => out[dst as usize] = out[src as usize],
1375 Op::Call { dst, func, .. } if func == self_fi && fn_returns_lists => {
1376 out[dst as usize] = true;
1377 }
1378 _ => {}
1379 }
1380 let mut succs: Vec<usize> = Vec::with_capacity(2);
1381 match ops[i] {
1382 Op::Jump { target } => {
1383 if in_body(target) {
1384 succs.push(target - entry_pc);
1385 }
1386 }
1387 Op::JumpIfFalse { target, .. }
1388 | Op::JumpIfTrue { target, .. }
1389 => {
1390 if i + 1 < ops.len() {
1391 succs.push(i + 1);
1392 }
1393 if in_body(target) {
1394 succs.push(target - entry_pc);
1395 }
1396 }
1397 Op::Return { .. } | Op::ReturnNothing => {}
1398 _ => {
1399 if i + 1 < ops.len() {
1400 succs.push(i + 1);
1401 }
1402 }
1403 }
1404 for sidx in succs {
1405 match &mut taint_in[sidx] {
1406 slot @ None => {
1407 *slot = Some(out.clone());
1408 work.push(sidx);
1409 }
1410 Some(prev) => {
1411 let mut changed = false;
1412 for (p, &o) in prev.iter_mut().zip(&out) {
1413 if !*p && o {
1414 *p = true;
1415 changed = true;
1416 }
1417 }
1418 if changed {
1419 work.push(sidx);
1420 }
1421 }
1422 }
1423 }
1424 }
1425 for (i, op) in ops.iter().enumerate() {
1426 match *op {
1427 Op::Return { src } => {
1428 if is_list[src as usize] {
1429 ret_any_list = true;
1430 }
1431 }
1432 Op::ListPush { list, .. } => {
1433 let clean = taint_in[i]
1434 .as_ref()
1435 .map(|t| is_list[list as usize] && !t[list as usize])
1436 // Unreachable pushes never run.
1437 .unwrap_or(true);
1438 if !clean {
1439 jt("push-outside-site");
1440 return None;
1441 }
1442 }
1443 _ => {}
1444 }
1445 }
1446 }
1447 let list_regs: Vec<u16> = (0..nregs_pin as u16).filter(|&r| is_list[r as usize]).collect();
1448 let total_pins = list_regs.len();
1449 if total_pins > 24 {
1450 return None;
1451 }
1452 let n_params = list_params.len();
1453 let _ = n_params;
1454 // pin id = index into list_regs; pin_of mirrors it per register.
1455 let mut pin_of: Vec<Option<u8>> = vec![None; nregs_pin];
1456 for (id, &r) in list_regs.iter().enumerate() {
1457 pin_of[r as usize] = Some(id as u8);
1458 }
1459 // Any list-returning site makes the self-call result an Int list
1460 // (sites are Int-lane; param pins must agree). Otherwise the declared
1461 // scalar pins one pass and undeclared falls back to Int/Bool inference.
1462 let assumed_set: Vec<Kind> = if ret_any_list {
1463 // Sites are Int-lane; list params must agree.
1464 if list_params.iter().any(|(_, e)| *e != PinElem::Int) {
1465 return None;
1466 }
1467 vec![Kind::IntList]
1468 } else {
1469 match declared_ret {
1470 Some(SlotKind::Int) => vec![Kind::Int],
1471 Some(SlotKind::Bool) => vec![Kind::Bool],
1472 Some(SlotKind::Float) => vec![Kind::Float],
1473 None => vec![Kind::Int, Kind::Bool],
1474 }
1475 };
1476 let mut chosen: Option<(Vec<Option<Vec<Kind>>>, Kind)> = None;
1477 for assumed in assumed_set {
1478 let mut entry = vec![Kind::Unknown; register_count as usize + 2];
1479 entry[..param_count as usize].copy_from_slice(¶m_entry);
1480 // Self-calls resolve against the assumed signature; calls to OTHER
1481 // functions against their DECLARED all-scalar signatures (the call
1482 // stencil dispatches through the published table; an uncompiled
1483 // callee deopts at the call until it tiers up on its own). Mode B
1484 // keeps self-calls only — its pin staging is self-shaped.
1485 let resolver = |fi: u16| -> Option<(Kind, Vec<Kind>)> {
1486 if fi == self_fi {
1487 return Some((assumed, param_entry.clone()));
1488 }
1489 if mode_b {
1490 return None;
1491 }
1492 let sig = callees.get(fi as usize)?;
1493 let ret = match sig.ret? {
1494 SlotKind::Int => Kind::Int,
1495 SlotKind::Bool => Kind::Bool,
1496 SlotKind::Float => Kind::Float,
1497 };
1498 let params = sig
1499 .param_kinds
1500 .iter()
1501 .map(|k| match k {
1502 Some(ParamKind::Scalar(SlotKind::Int)) => Some(Kind::Int),
1503 Some(ParamKind::Scalar(SlotKind::Bool)) => Some(Kind::Bool),
1504 Some(ParamKind::Scalar(SlotKind::Float)) => Some(Kind::Float),
1505 _ => None,
1506 })
1507 .collect::<Option<Vec<Kind>>>()?;
1508 Some((ret, params))
1509 };
1510 let Some(kin) = kind_flow(ops, entry_pc, constants, entry, Some(&resolver))
1511 else {
1512 continue;
1513 };
1514 // The trailing implicit ReturnNothing must be dead code — a
1515 // reachable one would return Nothing, which native code cannot
1516 // represent. All reachable returns must agree on Int or Bool.
1517 let mut ret_kind: Option<Kind> = None;
1518 let mut ok = true;
1519 for (i, op) in ops.iter().enumerate() {
1520 let Some(k) = &kin[i] else { continue };
1521 match *op {
1522 Op::ReturnNothing => {
1523 ok = false;
1524 break;
1525 }
1526 Op::Return { src } => {
1527 let rk = k[src as usize];
1528 match ret_kind {
1529 None => ret_kind = Some(rk),
1530 Some(prev) if prev == rk => {}
1531 _ => {
1532 ok = false;
1533 break;
1534 }
1535 }
1536 }
1537 _ => {}
1538 }
1539 }
1540 if !ok {
1541 continue;
1542 }
1543 let Some(rk) = ret_kind else { continue };
1544 if rk == assumed {
1545 chosen = Some((kin, rk));
1546 break;
1547 }
1548 }
1549 if chosen.is_none() {
1550 jt("chosen");
1551 }
1552 let (kin, ret_kind) = chosen?;
1553 let ret = if ret_any_list {
1554 NativeRet::ListByHandle
1555 } else {
1556 NativeRet::Scalar(ret_kind.slot_kind()?)
1557 };
1558
1559 // Liveness for the cmp+branch fusion (functions have no write-back, so
1560 // deadness inside the body is the whole story).
1561 let live = liveness(ops, entry_pc, register_count as usize + 2);
1562 if live.is_none() {
1563 jt("liveness");
1564 }
1565 let live = live?;
1566 let jump_targets: std::collections::HashSet<usize> = ops
1567 .iter()
1568 .filter_map(|op| match *op {
1569 Op::Jump { target }
1570 | Op::JumpIfFalse { target, .. }
1571 | Op::JumpIfTrue { target, .. }
1572 => Some(target),
1573 _ => None,
1574 })
1575 .collect();
1576 let scratch_ok = |_: u16| true;
1577
1578 let conv = (register_count, register_count + 1);
1579 let rc = register_count as usize;
1580 // Frame layout: mode A keeps the classic [regs | conv ×2 | limit]
1581 // (size rc+3). Mode B inserts the plant slots and pin triples BEFORE
1582 // the limit: [regs | conv ×2 | window | resume | dst | pins ×3p |
1583 // limit] — the published table value is frame_size − 3 either way, so
1584 // the call stencil's bound math and limit planting hold unchanged.
1585 let npins = total_pins;
1586 let frame_size = if mode_b { rc + 6 + 3 * npins } else { rc + 3 };
1587 let plant_window = (rc + 2) as u16;
1588 let plant_resume = (rc + 3) as u16;
1589 let plant_dst = (rc + 4) as u16;
1590 let pins_start = rc + 5;
1591 let mut micro: Vec<MicroOp> = Vec::with_capacity(ops.len());
1592 let mut pc_to_micro: Vec<usize> = Vec::with_capacity(ops.len());
1593 let mut fixups: Vec<usize> = Vec::new();
1594
1595 // Every pin-aliased register resolves to its pin's slot triple.
1596 let pin_elem = |j: usize| -> PinElem {
1597 let reg = list_regs[j];
1598 list_params
1599 .iter()
1600 .find(|(preg, _)| *preg == reg)
1601 .map(|(_, e)| *e)
1602 .unwrap_or(PinElem::Int)
1603 };
1604 let pin_slots_of = |j: usize| -> PinSlots {
1605 let base = pins_start + 3 * j;
1606 PinSlots {
1607 vec_slot: base as u16,
1608 ptr_slot: (base + 1) as u16,
1609 len_slot: (base + 2) as u16,
1610 elem: pin_elem(j),
1611 }
1612 };
1613 let mut pins: std::collections::HashMap<u16, PinSlots> = std::collections::HashMap::new();
1614 if mode_b {
1615 for (r, p) in pin_of.iter().enumerate() {
1616 if let Some(j) = p {
1617 pins.insert(r as u16, pin_slots_of(*j as usize));
1618 }
1619 }
1620 // Prologue: my plant is invalid until I make a call (the precise
1621 // walk stops at the first invalid plant).
1622 micro.push(MicroOp::LoadConst { dst: plant_window, value: -1 });
1623 }
1624
1625 // Per-pause-point register re-box kinds (deopt sites + call sites).
1626 // Param-pinned list registers re-box as their argument; NATIVE-OWNED
1627 // pins (sites, call results) record their vec-handle slot so the
1628 // deopt walk can detach-or-match them.
1629 let mut kinds_by_pc: std::collections::HashMap<usize, Vec<RegBox>> =
1630 std::collections::HashMap::new();
1631 let mut owned_by_pc: std::collections::HashMap<usize, Vec<(u16, u16)>> =
1632 std::collections::HashMap::new();
1633 let mut capture = |i: usize,
1634 kin: &Vec<Option<Vec<Kind>>>,
1635 pin_of: &Vec<Option<u8>>|
1636 -> Option<()> {
1637 let k = kin[i].as_ref()?;
1638 let mut row = Vec::with_capacity(rc);
1639 let mut owned: Vec<(u16, u16)> = Vec::new();
1640 for r in 0..rc {
1641 row.push(match k[r] {
1642 Kind::Int => RegBox::Int,
1643 Kind::Bool => RegBox::Bool,
1644 Kind::Float => RegBox::Float,
1645 Kind::IntList | Kind::IntListI32 | Kind::FloatList | Kind::BoolList => {
1646 let j = pin_of[r]?;
1647 if let Some(pidx) =
1648 list_params.iter().position(|(preg, _)| *preg as usize == r)
1649 {
1650 RegBox::ListParam(pidx as u8)
1651 } else {
1652 owned.push((r as u16, pin_slots_of(j as usize).vec_slot));
1653 RegBox::Resolved
1654 }
1655 }
1656 _ => RegBox::Dead,
1657 });
1658 }
1659 kinds_by_pc.insert(entry_pc + i, row);
1660 owned_by_pc.insert(entry_pc + i, owned);
1661 Some(())
1662 };
1663
1664 let mut i = 0usize;
1665 while i < ops.len() {
1666 if mode_b {
1667 let deoptable = matches!(
1668 ops[i],
1669 Op::Div { .. }
1670 | Op::Mod { .. }
1671 | Op::Index { .. }
1672 | Op::IndexUnchecked { .. }
1673 | Op::SetIndex { .. }
1674 | Op::SetIndexUnchecked { .. }
1675 | Op::Call { .. }
1676 );
1677 if deoptable && kin[i].is_some() {
1678 if capture(i, &kin, &pin_of).is_none() {
1679 jt("capture");
1680 return None;
1681 }
1682 }
1683 // Self-call, mode B: the callee windows at base + FRAME_SIZE —
1684 // fully DISJOINT from this frame, because the interpreter's
1685 // compact windowing (base + args_start) would let the callee's
1686 // registers overlap this frame's plant and pin slots and
1687 // clobber them. Arguments are copied into the fresh window,
1688 // pins pass through, the plant records the linkage for the
1689 // precise walk, and the post-return store invalidates it.
1690 if let Op::Call { dst, args_start, arg_count, func } = ops[i] {
1691 // Mode B stages pins for OUR OWN parameter shape — a call
1692 // to any other function cannot use this seam.
1693 if func != self_fi {
1694 jt("non-self-call-mode-b");
1695 return None;
1696 }
1697 pc_to_micro.push(micro.len());
1698 let window = frame_size as u16;
1699 micro.push(MicroOp::LoadConst { dst: plant_window, value: window as i64 });
1700 micro.push(MicroOp::LoadConst {
1701 dst: plant_resume,
1702 value: (entry_pc + i + 1) as i64,
1703 });
1704 micro.push(MicroOp::LoadConst { dst: plant_dst, value: dst as i64 });
1705 for j in 0..arg_count {
1706 micro.push(MicroOp::Move { dst: window + j, src: args_start + j });
1707 }
1708 // Each callee list-param pin receives the triple of WHATEVER
1709 // pin the staged argument carries (a param passthrough, a
1710 // fresh site, or a previous call's result).
1711 for (slot, (preg, pelem)) in list_params.iter().enumerate() {
1712 if *pelem != PinElem::Int && *pelem != PinElem::Float && *pelem != PinElem::Bool
1713 {
1714 return None;
1715 }
1716 let Some(arg_pin) = pin_of[(args_start + preg) as usize] else {
1717 jt("staged-arg-unpinned");
1718 return None;
1719 };
1720 let src_slots = pin_slots_of(arg_pin as usize);
1721 let dst_base = window + (pins_start + 3 * slot) as u16;
1722 micro.push(MicroOp::Move { dst: dst_base, src: src_slots.vec_slot });
1723 micro.push(MicroOp::Move { dst: dst_base + 1, src: src_slots.ptr_slot });
1724 micro.push(MicroOp::Move { dst: dst_base + 2, src: src_slots.len_slot });
1725 }
1726 let c = call_ctx_in;
1727 micro.push(MicroOp::Call {
1728 dst,
1729 args_start: window,
1730 table_addr: c.table_addr,
1731 depth_addr: c.depth_addr,
1732 status_addr: c.status_addr,
1733 limit_slot: c.limit_slot,
1734 depth_limit: c.depth_limit,
1735 });
1736 micro.push(MicroOp::LoadConst { dst: plant_window, value: -1 });
1737 // A list-returning self-call: plant the result's triple
1738 // from the returned handle.
1739 if fn_returns_lists {
1740 if let Some(j) = pin_of[dst as usize] {
1741 let slots = pin_slots_of(j as usize);
1742 micro.push(MicroOp::ListTriple {
1743 handle_slot: dst,
1744 vec_slot: slots.vec_slot,
1745 ptr_slot: slots.ptr_slot,
1746 len_slot: slots.len_slot,
1747 helper_addr: logos_rt_list_triple as usize as i64,
1748 });
1749 }
1750 }
1751 i += 1;
1752 continue;
1753 }
1754 // Fresh-list allocation: registry-owned, triple planted.
1755 if let Op::NewEmptyList { dst } = ops[i] {
1756 pc_to_micro.push(micro.len());
1757 let j = pin_of[dst as usize].expect("site pins were assigned");
1758 let slots = pin_slots_of(j as usize);
1759 micro.push(MicroOp::NewList {
1760 vec_slot: slots.vec_slot,
1761 ptr_slot: slots.ptr_slot,
1762 len_slot: slots.len_slot,
1763 helper_addr: logos_rt_alloc_list_i64 as usize as i64,
1764 });
1765 // INVARIANT: a list register's frame slot mirrors its vec
1766 // handle (Moves, staging and returns all ride it).
1767 micro.push(MicroOp::Move { dst, src: slots.vec_slot });
1768 i += 1;
1769 continue;
1770 }
1771 // A Move BETWEEN list registers transfers the handle and
1772 // refreshes the destination's own pin triple from the live
1773 // vector (per-register pins make reassignment conflict-free).
1774 if let Op::Move { dst, src } = ops[i] {
1775 if is_list[src as usize] {
1776 pc_to_micro.push(micro.len());
1777 let j = pin_of[dst as usize].expect("list regs are pinned");
1778 let slots = pin_slots_of(j as usize);
1779 micro.push(MicroOp::Move { dst, src });
1780 micro.push(MicroOp::ListTriple {
1781 handle_slot: dst,
1782 vec_slot: slots.vec_slot,
1783 ptr_slot: slots.ptr_slot,
1784 len_slot: slots.len_slot,
1785 helper_addr: logos_rt_list_triple as usize as i64,
1786 });
1787 i += 1;
1788 continue;
1789 }
1790 }
1791 // List return: the vec HANDLE rides the return value (the
1792 // boundary detaches registry-owned lists or matches a param).
1793 if let Op::Return { src } = ops[i] {
1794 if let Some(j) = pin_of[src as usize] {
1795 pc_to_micro.push(micro.len());
1796 let slots = pin_slots_of(j as usize);
1797 micro.push(MicroOp::Move { dst: conv.0, src: slots.vec_slot });
1798 micro.push(MicroOp::Return { src: conv.0 });
1799 i += 1;
1800 continue;
1801 }
1802 }
1803 // OPERATION FUSION (nbody's hot lever): collapse
1804 // `t1 = item I of A; t2 = item J of A; D = t1 <op> t2`
1805 // — two loads from the SAME pinned 8-byte float array feeding one
1806 // float add/sub/mul — into a single ArrLoad2F so the two loaded
1807 // f64s never round-trip through the frame. Both scratch loads must
1808 // be single-use (dead after the binop, read only by it), the two
1809 // collapsed ops must not be jump targets (no entry lands between),
1810 // and a bounds failure side-exits to the FIRST Index's bytecode pc
1811 // (captured above, since Index is deoptable) — resuming there
1812 // re-runs all three pure ops, every effect still confined.
1813 if let Some(fused) = fuse_index2_fbinop(
1814 ops, i, entry_pc, &kin, &live, &jump_targets, &pins,
1815 ) {
1816 if std::env::var_os("LOGOS_JIT_TRACE").is_some() {
1817 eprintln!("jit-trace: FUSED arrld2 at pc {} -> {:?}", entry_pc + i, fused);
1818 }
1819 pc_to_micro.push(micro.len());
1820 micro.push(fused);
1821 // The j-Index (i+1) and the binop (i+2) map PAST the fused
1822 // micro, so the first Index's deopt range covers exactly it.
1823 pc_to_micro.push(micro.len());
1824 pc_to_micro.push(micro.len());
1825 i += 3;
1826 continue;
1827 }
1828 }
1829 let step = translate_op(
1830 ops,
1831 i,
1832 entry_pc,
1833 constants,
1834 &kin,
1835 &live,
1836 &jump_targets,
1837 &scratch_ok,
1838 conv,
1839 &pins,
1840 Some(call_ctx_in),
1841 None,
1842 true,
1843 false,
1844 &mut micro,
1845 &mut pc_to_micro,
1846 &mut fixups,
1847 );
1848 if step.is_none() {
1849 jt("translate");
1850 if std::env::var_os("LOGOS_JIT_TRACE").is_some() {
1851 eprintln!("jit-trace: op {:?}", ops[i]);
1852 }
1853 }
1854 i += step?;
1855 }
1856 for &fi in &fixups {
1857 match &mut micro[fi] {
1858 MicroOp::Jump { target }
1859 | MicroOp::JumpIfFalse { target, .. }
1860 | MicroOp::JumpIfTrue { target, .. }
1861 | MicroOp::Branch { target, .. }
1862 | MicroOp::BranchF { target, .. } => {
1863 let rebased = target.checked_sub(entry_pc)?;
1864 *target = *pc_to_micro.get(rebased)?;
1865 }
1866 _ => unreachable!(),
1867 }
1868 }
1869 // Function bodies may end on a Return-less path only if the VM never
1870 // reaches it; the chain compiler requires structural termination.
1871 if !matches!(micro.last(), Some(MicroOp::Return { .. }) | Some(MicroOp::Jump { .. })) {
1872 return None;
1873 }
1874 // Mode B: every micro of bytecode op i carries that op's encoded pc
1875 // so its side exit resumes precisely.
1876 let deopt_codes = if mode_b {
1877 let mut codes = vec![1i64; micro.len()];
1878 for opi in 0..ops.len() {
1879 let lo = pc_to_micro[opi];
1880 let hi = pc_to_micro.get(opi + 1).copied().unwrap_or(micro.len());
1881 let code = (((entry_pc + opi) as i64) << 2) | 3;
1882 for c in codes.iter_mut().take(hi).skip(lo) {
1883 *c = code;
1884 }
1885 }
1886 Some(codes)
1887 } else {
1888 None
1889 };
1890 let param_pin_scatter: Vec<u16> = list_params
1891 .iter()
1892 .map(|(preg, _)| pin_slots_of(pin_of[*preg as usize].unwrap() as usize).vec_slot)
1893 .collect();
1894 let precise = if mode_b {
1895 Some(PreciseInfo {
1896 register_count: rc,
1897 pins_start,
1898 kinds_by_pc,
1899 owned_by_pc,
1900 param_pin_scatter,
1901 })
1902 } else {
1903 None
1904 };
1905 Some(AdaptedFn { micro, frame_size, ret, deopt_codes, precise })
1906}
1907
1908/// If slot `rhs` is a region-constant power of two — its ONLY writer in `ops`
1909/// is a `LoadConst` of `2^k` with `1 <= k <= 62` — return `k`, so an integer
1910/// `x / rhs` can lower to the side-exit-free [`MicroOp::DivPow2`] shift instead
1911/// of an `idiv`. Sound: a single constant writer means `rhs` holds exactly
1912/// `2^k` at every `Div`, in every loop iteration. Any other writer (or an op
1913/// whose defs we cannot determine) disqualifies it.
1914fn const_pow2_div(ops: &[Op], rhs: u16, constants: &[Constant]) -> Option<u32> {
1915 let mut k_found: Option<u32> = None;
1916 for op in ops {
1917 let defs = region_use_def(op)?.1;
1918 if !defs.contains(&rhs) {
1919 continue;
1920 }
1921 match op {
1922 Op::LoadConst { dst, idx } if *dst == rhs => match constants.get(*idx as usize) {
1923 Some(Constant::Int(d)) if *d >= 2 && (*d & (*d - 1)) == 0 => {
1924 let k = d.trailing_zeros();
1925 if !(1..=62).contains(&k) || k_found.is_some() {
1926 return None;
1927 }
1928 k_found = Some(k);
1929 }
1930 _ => return None,
1931 },
1932 _ => return None,
1933 }
1934 }
1935 k_found
1936}
1937
1938/// The use/def sets of one op from the adaptable subset, or None when the op
1939/// is outside it. `AddAssign` reads AND writes its dst.
1940fn region_use_def(op: &Op) -> Option<(Vec<u16>, Vec<u16>)> {
1941 Some(match *op {
1942 Op::Return { src } => (vec![src], vec![]),
1943 Op::ReturnNothing => (vec![], vec![]),
1944 Op::LoadConst { dst, .. } => (vec![], vec![dst]),
1945 Op::DivPow2 { dst, lhs, .. } => (vec![lhs], vec![dst]),
1946 Op::MagicDivU { dst, lhs, .. } => (vec![lhs], vec![dst]),
1947 Op::Move { dst, src } => (vec![src], vec![dst]),
1948 // Interpreter-only call-site COW barrier: a no-op in a native region (the
1949 // pinned-buffer / list-param mechanism already governs value semantics here,
1950 // exactly as it did before the barrier existed). Reads `reg` to keep it live.
1951 Op::EnsureOwned { reg } => (vec![reg], vec![]),
1952 Op::BitXor { dst, lhs, rhs }
1953 | Op::BitAnd { dst, lhs, rhs }
1954 | Op::BitOr { dst, lhs, rhs }
1955 | Op::Shl { dst, lhs, rhs }
1956 | Op::Shr { dst, lhs, rhs } => (vec![lhs, rhs], vec![dst]),
1957 Op::Not { dst, src } => (vec![src], vec![dst]),
1958 Op::Index { dst, collection, index }
1959 | Op::IndexUnchecked { dst, collection, index } => (vec![collection, index], vec![dst]),
1960 Op::Contains { dst, collection, value } => (vec![collection, value], vec![dst]),
1961 Op::NewEmptyList { dst } => (vec![], vec![dst]),
1962 Op::SetIndex { collection, index, value }
1963 | Op::SetIndexUnchecked { collection, index, value } => {
1964 (vec![collection, index, value], vec![])
1965 }
1966 Op::Length { dst, collection } => (vec![collection], vec![dst]),
1967 Op::ListPush { list, value } => (vec![list, value], vec![]),
1968 Op::Call { dst, args_start, arg_count, .. } => {
1969 ((args_start..args_start + arg_count).collect(), vec![dst])
1970 }
1971 Op::CallBuiltin {
1972 dst,
1973 builtin: logicaffeine_compile::semantics::builtins::BuiltinId::Sqrt,
1974 args_start,
1975 arg_count,
1976 } if arg_count == 1 => (vec![args_start], vec![dst]),
1977 Op::Add { dst, lhs, rhs } | Op::Sub { dst, lhs, rhs } | Op::Mul { dst, lhs, rhs }
1978 | Op::Div { dst, lhs, rhs } | Op::Mod { dst, lhs, rhs }
1979 | Op::Lt { dst, lhs, rhs } | Op::Gt { dst, lhs, rhs } | Op::LtEq { dst, lhs, rhs }
1980 | Op::GtEq { dst, lhs, rhs } | Op::Eq { dst, lhs, rhs } | Op::NotEq { dst, lhs, rhs } => {
1981 (vec![lhs, rhs], vec![dst])
1982 }
1983 Op::AddAssign { dst, src } => (vec![dst, src], vec![dst]),
1984 Op::JumpIfFalse { cond, .. } | Op::JumpIfTrue { cond, .. } => (vec![cond], vec![]),
1985 Op::Jump { .. } => (vec![], vec![]),
1986 // Region-entry metadata: emits no native code (the VM verifies it at
1987 // entry from its own registers + the pinned length). No frame use/def.
1988 Op::RegionBoundsGuard { .. } => (vec![], vec![]),
1989 _ => return None,
1990 })
1991}
1992
1993/// Backward liveness over a bytecode slice to a fixpoint: per-op LIVE-IN
1994/// vectors. Successors are the fall-through and the in-slice jump target;
1995/// out-of-slice targets are exits with empty live-out (exit-observable slots
1996/// flow through the caller's write-back, never the native frame), and
1997/// `Return`/`ReturnNothing` are terminals. None when the slice contains an
1998/// op outside the adaptable subset.
1999fn liveness(ops: &[Op], base_pc: usize, nregs: usize) -> Option<Vec<Vec<bool>>> {
2000 let in_slice = |t: usize| (base_pc..base_pc + ops.len()).contains(&t);
2001 fn merge_into(out: &mut [bool], row: &[bool]) {
2002 for (o, &l) in out.iter_mut().zip(row) {
2003 *o |= l;
2004 }
2005 }
2006 let mut live: Vec<Vec<bool>> = vec![vec![false; nregs]; ops.len()];
2007 loop {
2008 let mut changed = false;
2009 for i in (0..ops.len()).rev() {
2010 let Some((uses, defs)) = region_use_def(&ops[i]) else {
2011 if std::env::var_os("LOGOS_RDIAG").is_some() {
2012 eprintln!("RDIAG-BAIL liveness use_def-unsupported op={:?}", ops[i]);
2013 }
2014 return None;
2015 };
2016 let mut out = vec![false; nregs];
2017 match ops[i] {
2018 Op::Jump { target } => {
2019 if in_slice(target) {
2020 merge_into(&mut out, &live[target - base_pc]);
2021 }
2022 }
2023 Op::JumpIfFalse { target, .. }
2024 | Op::JumpIfTrue { target, .. }
2025 => {
2026 if i + 1 < ops.len() {
2027 merge_into(&mut out, &live[i + 1]);
2028 }
2029 if in_slice(target) {
2030 merge_into(&mut out, &live[target - base_pc]);
2031 }
2032 }
2033 Op::Return { .. } | Op::ReturnNothing => {}
2034 _ => {
2035 if i + 1 < ops.len() {
2036 merge_into(&mut out, &live[i + 1]);
2037 }
2038 }
2039 }
2040 for &d in &defs {
2041 out[d as usize] = false;
2042 }
2043 for &u in &uses {
2044 out[u as usize] = true;
2045 }
2046 if out != live[i] {
2047 live[i] = out;
2048 changed = true;
2049 }
2050 }
2051 if !changed {
2052 break;
2053 }
2054 }
2055 Some(live)
2056}
2057
2058/// The [`Cmp`] a comparison op computes, if it is one.
2059fn cmp_of(op: &Op) -> Option<(Cmp, u16, u16, u16)> {
2060 Some(match *op {
2061 Op::Lt { dst, lhs, rhs } => (Cmp::Lt, dst, lhs, rhs),
2062 Op::Gt { dst, lhs, rhs } => (Cmp::Gt, dst, lhs, rhs),
2063 Op::LtEq { dst, lhs, rhs } => (Cmp::LtEq, dst, lhs, rhs),
2064 Op::GtEq { dst, lhs, rhs } => (Cmp::GtEq, dst, lhs, rhs),
2065 Op::Eq { dst, lhs, rhs } => (Cmp::Eq, dst, lhs, rhs),
2066 Op::NotEq { dst, lhs, rhs } => (Cmp::NotEq, dst, lhs, rhs),
2067 _ => return None,
2068 })
2069}
2070
2071/// When `ops[i]` is a comparison whose dst feeds ONLY the immediately
2072/// following conditional jump, return the fused [`MicroOp::Branch`]
2073/// parameters `(cmp, lhs, rhs, vm_target)` — `cmp` already negated for the
2074/// JumpIfTrue polarity (Branch jumps when its cmp is FALSE). Conditions:
2075/// the dst is dead after the jump (per `live`), no jump lands BETWEEN the
2076/// pair, and `scratch_ok(dst)` (regions: the dst must be an unobservable
2077/// scratch — its value write disappears under fusion).
2078fn fuse_cmp_branch(
2079 ops: &[Op],
2080 i: usize,
2081 base_pc: usize,
2082 live: &[Vec<bool>],
2083 jump_targets: &std::collections::HashSet<usize>,
2084 scratch_ok: &dyn Fn(u16) -> bool,
2085) -> Option<(Cmp, u16, u16, usize)> {
2086 let (cmp, dst, lhs, rhs) = cmp_of(&ops[i])?;
2087 if i + 1 >= ops.len() || jump_targets.contains(&(base_pc + i + 1)) {
2088 return None;
2089 }
2090 let (fused_cmp, target) = match ops[i + 1] {
2091 Op::JumpIfFalse { cond, target } if cond == dst => (cmp, target),
2092 Op::JumpIfTrue { cond, target } if cond == dst => (cmp.negated(), target),
2093 _ => return None,
2094 };
2095 if !scratch_ok(dst) {
2096 return None;
2097 }
2098 // Dead after the jump: not live-in at the fall-through nor at an
2099 // in-slice target (out-of-slice exits are covered by scratch_ok).
2100 let in_slice = |t: usize| (base_pc..base_pc + ops.len()).contains(&t);
2101 if i + 2 < ops.len() && live[i + 2][dst as usize] {
2102 return None;
2103 }
2104 if in_slice(target) && live[target - base_pc][dst as usize] {
2105 return None;
2106 }
2107 Some((fused_cmp, lhs, rhs, target))
2108}
2109
2110/// Detect the fusable triple at `ops[i]`:
2111/// `t1 = item I of A; t2 = item J of A; D = t1 <op> t2`
2112/// — two loads from the SAME pinned 8-byte float array (`A`), both feeding a
2113/// single float add/sub/mul. Returns the collapsed [`MicroOp::ArrLoad2F`].
2114///
2115/// Conditions (all required for soundness/no-loss):
2116/// - the two `item` reads target the SAME pinned collection, which is a
2117/// list of 8-byte FLOAT elements (not a map, not a byte/Bool buffer);
2118/// - both loaded values are FLOATS at the binop (no Int→Float promotion is
2119/// folded — the fused stencil reads raw f64 bits straight from the buffer);
2120/// - the binop's operands are exactly the two scratch loads (for the
2121/// commutative Add/Mul either order; for Sub only `t1 - t2`);
2122/// - both scratches are SINGLE-USE: distinct, dead after the binop, and read
2123/// by nothing between (the three ops are adjacent and i+1/i+2 are not jump
2124/// targets, so no other reader can sneak in);
2125/// - the destination is not one of the scratch slots (the binop overwrites
2126/// its inputs only after both are read — the fused stencil does the same,
2127/// but keeping them distinct avoids any aliasing surprise).
2128fn fuse_index2_fbinop(
2129 ops: &[Op],
2130 i: usize,
2131 base_pc: usize,
2132 kin: &[Option<Vec<Kind>>],
2133 live: &[Vec<bool>],
2134 jump_targets: &std::collections::HashSet<usize>,
2135 pins: &std::collections::HashMap<u16, PinSlots>,
2136) -> Option<MicroOp> {
2137 if i + 3 > ops.len() {
2138 return None;
2139 }
2140 // No fresh entry may land on the second load or the binop, or a jump
2141 // target would skip the first load's effects.
2142 if jump_targets.contains(&(base_pc + i + 1)) || jump_targets.contains(&(base_pc + i + 2)) {
2143 return None;
2144 }
2145 // The first load is the deopt resume point; its kind capture must exist
2146 // (the mode-B loop captures every deoptable op with a known kind row).
2147 if kin.get(i)?.is_none() {
2148 return None;
2149 }
2150 let (t1, coll_a, ix) = match ops[i] {
2151 Op::Index { dst, collection, index }
2152 | Op::IndexUnchecked { dst, collection, index } => (dst, collection, index),
2153 _ => return None,
2154 };
2155 let (t2, coll_b, jx) = match ops[i + 1] {
2156 Op::Index { dst, collection, index }
2157 | Op::IndexUnchecked { dst, collection, index } => (dst, collection, index),
2158 _ => return None,
2159 };
2160 if coll_a != coll_b || t1 == t2 {
2161 return None;
2162 }
2163 let (op, dst, lhs, rhs) = match ops[i + 2] {
2164 Op::Add { dst, lhs, rhs } => (FOp::Add, dst, lhs, rhs),
2165 Op::Sub { dst, lhs, rhs } => (FOp::Sub, dst, lhs, rhs),
2166 Op::Mul { dst, lhs, rhs } => (FOp::Mul, dst, lhs, rhs),
2167 _ => return None,
2168 };
2169 // The binop must consume exactly the two scratch loads, mapping i→j so
2170 // the result equals `f(A[I]) <op> f(A[J])`. Add/Mul commute; Sub does not.
2171 let (load_i, load_j) = if lhs == t1 && rhs == t2 {
2172 (ix, jx)
2173 } else if (op == FOp::Add || op == FOp::Mul) && lhs == t2 && rhs == t1 {
2174 (jx, ix)
2175 } else {
2176 return None;
2177 };
2178 if dst == t1 || dst == t2 {
2179 return None;
2180 }
2181 // The collection is a pinned list of 8-byte float elements.
2182 let p = pins.get(&coll_a)?;
2183 if p.elem != PinElem::Float {
2184 return None;
2185 }
2186 // Both loaded values are floats at the binop (the fused stencil reads raw
2187 // f64 bits — an Int element would need a promotion the stencil omits).
2188 let k = kin.get(i + 2)?.as_ref()?;
2189 if k[t1 as usize] != Kind::Float || k[t2 as usize] != Kind::Float {
2190 return None;
2191 }
2192 // Single-use: both scratches dead entering the op after the binop, so the
2193 // collapse drops their frame writes with no observable loss.
2194 let after = live.get(i + 3)?;
2195 if after[t1 as usize] || after[t2 as usize] {
2196 return None;
2197 }
2198 Some(MicroOp::ArrLoad2F {
2199 dst,
2200 i: load_i,
2201 j: load_j,
2202 ptr_slot: p.ptr_slot,
2203 len_slot: p.len_slot,
2204 op,
2205 })
2206}
2207
2208/// Translate the VM op at `i` (or a fused cmp+branch pair) into micro-ops,
2209/// choosing Int or Float forms from the kinds holding at that point and
2210/// inserting the kernel's Int→Float promotion through the two conversion
2211/// scratch slots. Jump-family micro targets keep their RAW VM pcs — the
2212/// caller remaps them (and routes out-of-slice exits). Pushes the pc→micro
2213/// map entries for every VM op it consumes; returns how many it consumed,
2214/// or None when the op (or its kind shape) is outside the subset.
2215#[allow(clippy::too_many_arguments)]
2216fn translate_op(
2217 ops: &[Op],
2218 i: usize,
2219 base_pc: usize,
2220 constants: &[Constant],
2221 kin: &[Option<Vec<Kind>>],
2222 live: &[Vec<bool>],
2223 jump_targets: &std::collections::HashSet<usize>,
2224 scratch_ok: &dyn Fn(u16) -> bool,
2225 conv: (u16, u16),
2226 pins: &std::collections::HashMap<u16, PinSlots>,
2227 call_ctx: Option<&CallCtx>,
2228 region_return: Option<(u16, u16)>,
2229 allow_return: bool,
2230 region: bool,
2231 micro: &mut Vec<MicroOp>,
2232 pc_to_micro: &mut Vec<usize>,
2233 fixups: &mut Vec<usize>,
2234) -> Option<usize> {
2235 pc_to_micro.push(micro.len());
2236 let k = kin[i].as_ref();
2237 // Unreachable ops never execute — emit a placeholder of valid shape so
2238 // pc mapping and structural termination hold.
2239 let kind_of = |r: u16| k.map(|kk| kk[r as usize]).unwrap_or(Kind::Int);
2240 // Resolve a numeric operand to (slot, is_float), inserting a conversion
2241 // into `cslot` when the other side forces a Float promotion.
2242 let promote = |micro: &mut Vec<MicroOp>, slot: u16, kind: Kind, want_float: bool, cslot: u16| -> u16 {
2243 if want_float && kind == Kind::Int {
2244 micro.push(MicroOp::IntToFloat { dst: cslot, src: slot });
2245 cslot
2246 } else {
2247 slot
2248 }
2249 };
2250
2251 // Fused compare-and-branch first.
2252 if let Some((cmp, lhs, rhs, target)) =
2253 fuse_cmp_branch(ops, i, base_pc, live, jump_targets, scratch_ok)
2254 {
2255 let (lk, rk) = (kind_of(lhs), kind_of(rhs));
2256 let bools = lk == Kind::Bool && rk == Kind::Bool;
2257 if bools && matches!(cmp, Cmp::Eq | Cmp::NotEq) {
2258 pc_to_micro.push(micro.len());
2259 fixups.push(micro.len());
2260 micro.push(MicroOp::Branch { cmp, lhs, rhs, target });
2261 return Some(2);
2262 }
2263 let want_float = lk == Kind::Float || rk == Kind::Float;
2264 if want_float {
2265 let l = promote(micro, lhs, lk, true, conv.0);
2266 let r = promote(micro, rhs, rk, true, conv.1);
2267 pc_to_micro.push(micro.len());
2268 fixups.push(micro.len());
2269 micro.push(MicroOp::BranchF { cmp, lhs: l, rhs: r, target });
2270 } else {
2271 pc_to_micro.push(micro.len());
2272 fixups.push(micro.len());
2273 micro.push(MicroOp::Branch { cmp, lhs, rhs, target });
2274 }
2275 return Some(2);
2276 }
2277
2278 match ops[i] {
2279 Op::LoadConst { dst, idx } => match constants.get(idx as usize)? {
2280 Constant::Int(v) => micro.push(MicroOp::LoadConst { dst, value: *v }),
2281 Constant::Float(f) => {
2282 micro.push(MicroOp::LoadConst { dst, value: f.to_bits() as i64 })
2283 }
2284 Constant::Bool(b) => micro.push(MicroOp::LoadConst { dst, value: *b as i64 }),
2285 // A 1-char ASCII text/char literal lowers to its single byte so a
2286 // `Kind::TextByte` comparison against it is an integer compare (the
2287 // strings benchmark's `item i of result equals " "`).
2288 Constant::Char(c) if c.is_ascii() => {
2289 micro.push(MicroOp::LoadConst { dst, value: *c as i64 })
2290 }
2291 Constant::Text(s) if text_const_byte(s).is_some() => {
2292 micro.push(MicroOp::LoadConst { dst, value: text_const_byte(s).unwrap() as i64 })
2293 }
2294 // A MULTI-char ASCII text constant (`text + "XXXXX"`) is consumed ONLY
2295 // as a `StrAppend` source (`Kind::TextConst`, rejected by every other
2296 // gate), which bakes the literal's bytes directly — its frame slot is
2297 // never read as a value. Emit a harmless placeholder so the pc→micro
2298 // mapping stays 1:1 without materializing the (non-scalar) string.
2299 Constant::Text(s) if s.is_ascii() && !s.is_empty() => {
2300 micro.push(MicroOp::LoadConst { dst, value: 0 })
2301 }
2302 _ => return None,
2303 },
2304 Op::Move { dst, src } => micro.push(MicroOp::Move { dst, src }),
2305 Op::EnsureOwned { .. } => {} // interpreter-only COW barrier — no-op in a native region
2306 Op::DivPow2 { dst, lhs, k } => micro.push(MicroOp::DivPow2 { dst, lhs, k: k as u32 }),
2307 Op::MagicDivU { dst, lhs, magic, more, mul_back } => {
2308 micro.push(MicroOp::MagicDivU { dst, lhs, magic, more, mul_back })
2309 }
2310 Op::Add { dst, lhs, rhs }
2311 | Op::Sub { dst, lhs, rhs }
2312 | Op::Mul { dst, lhs, rhs }
2313 | Op::Div { dst, lhs, rhs } => {
2314 let (lk, rk) = (kind_of(lhs), kind_of(rhs));
2315 if lk == Kind::Float || rk == Kind::Float {
2316 let l = promote(micro, lhs, lk, true, conv.0);
2317 let r = promote(micro, rhs, rk, true, conv.1);
2318 micro.push(match ops[i] {
2319 Op::Add { .. } => MicroOp::AddF { dst, lhs: l, rhs: r },
2320 Op::Sub { .. } => MicroOp::SubF { dst, lhs: l, rhs: r },
2321 Op::Mul { .. } => MicroOp::MulF { dst, lhs: l, rhs: r },
2322 _ => MicroOp::DivF { dst, lhs: l, rhs: r },
2323 });
2324 } else {
2325 micro.push(match ops[i] {
2326 Op::Add { .. } => MicroOp::Add { dst, lhs, rhs },
2327 Op::Sub { .. } => MicroOp::Sub { dst, lhs, rhs },
2328 Op::Mul { .. } => MicroOp::Mul { dst, lhs, rhs },
2329 // integer `x / 2^k` → side-exit-free shift when the divisor
2330 // is a region-constant power of two (collatz, heap_sort i/2,
2331 // mergesort (lo+hi)/2).
2332 _ => match const_pow2_div(ops, rhs, constants) {
2333 Some(k) => MicroOp::DivPow2 { dst, lhs, k },
2334 None => MicroOp::Div { dst, lhs, rhs },
2335 },
2336 });
2337 }
2338 }
2339 Op::AddAssign { dst, src } => {
2340 let (dk, sk) = (kind_of(dst), kind_of(src));
2341 if dk == Kind::TextMut {
2342 // A pinned mutable-Text accumulator grows through the `StrAppend`
2343 // helper. The handle is the pin's vec slot (a `*mut Value`). The
2344 // source is a 1-char runtime byte (`TextByte` — the byte VALUE
2345 // rides its frame slot) or a multi-char ASCII constant
2346 // (`TextConst` — recover the literal's bytes and bake a `'static`
2347 // slice). The handle slot is force-frame-resident so a non-frame
2348 // (register-resident) handle is impossible here.
2349 let p = pins.get(&dst)?;
2350 let strsrc = match sk {
2351 Kind::TextByte => logicaffeine_forge::jit::StrSrc::Byte(src),
2352 Kind::TextConst => {
2353 let (ptr, len) = const_text_slice(ops, i, base_pc, src, constants)?;
2354 logicaffeine_forge::jit::StrSrc::Const { ptr, len }
2355 }
2356 _ => return None,
2357 };
2358 micro.push(MicroOp::StrAppend {
2359 text_handle_slot: p.vec_slot,
2360 src: strsrc,
2361 helper_addr: crate::logos_rt_str_append as usize as i64,
2362 });
2363 } else if dk == Kind::Float || sk == Kind::Float {
2364 let l = promote(micro, dst, dk, true, conv.0);
2365 let r = promote(micro, src, sk, true, conv.1);
2366 micro.push(MicroOp::AddF { dst, lhs: l, rhs: r });
2367 } else {
2368 micro.push(MicroOp::Add { dst, lhs: dst, rhs: src });
2369 }
2370 }
2371 Op::Mod { dst, lhs, rhs } => micro.push(MicroOp::Mod { dst, lhs, rhs }),
2372 Op::BitXor { dst, lhs, rhs } => micro.push(MicroOp::BitXor { dst, lhs, rhs }),
2373 Op::Shl { dst, lhs, rhs } => micro.push(MicroOp::Shl { dst, lhs, rhs }),
2374 Op::Shr { dst, lhs, rhs } => micro.push(MicroOp::Shr { dst, lhs, rhs }),
2375 Op::BitAnd { dst, lhs, rhs } => micro.push(MicroOp::BitAnd { dst, lhs, rhs }),
2376 Op::BitOr { dst, lhs, rhs } => micro.push(MicroOp::BitOr { dst, lhs, rhs }),
2377 Op::Not { dst, src } => match kind_of(src) {
2378 Kind::Bool => micro.push(MicroOp::NotBool { dst, src }),
2379 // The region guard admits `Not` only on Bool; a bitwise NotInt
2380 // here would silently miscompile the logical `not` — fail closed.
2381 _ => return None,
2382 },
2383 Op::Lt { dst, lhs, rhs }
2384 | Op::Gt { dst, lhs, rhs }
2385 | Op::LtEq { dst, lhs, rhs }
2386 | Op::GtEq { dst, lhs, rhs }
2387 | Op::Eq { dst, lhs, rhs }
2388 | Op::NotEq { dst, lhs, rhs } => {
2389 let (lk, rk) = (kind_of(lhs), kind_of(rhs));
2390 if lk == Kind::Float || rk == Kind::Float {
2391 let l = promote(micro, lhs, lk, true, conv.0);
2392 let r = promote(micro, rhs, rk, true, conv.1);
2393 micro.push(match ops[i] {
2394 Op::Lt { .. } => MicroOp::LtF { dst, lhs: l, rhs: r },
2395 Op::Gt { .. } => MicroOp::GtF { dst, lhs: l, rhs: r },
2396 Op::LtEq { .. } => MicroOp::LtEqF { dst, lhs: l, rhs: r },
2397 Op::GtEq { .. } => MicroOp::GtEqF { dst, lhs: l, rhs: r },
2398 Op::Eq { .. } => MicroOp::EqF { dst, lhs: l, rhs: r },
2399 _ => MicroOp::NeqF { dst, lhs: l, rhs: r },
2400 });
2401 } else {
2402 micro.push(match ops[i] {
2403 Op::Lt { .. } => MicroOp::Lt { dst, lhs, rhs },
2404 Op::Gt { .. } => MicroOp::Gt { dst, lhs, rhs },
2405 Op::LtEq { .. } => MicroOp::LtEq { dst, lhs, rhs },
2406 Op::GtEq { .. } => MicroOp::GtEq { dst, lhs, rhs },
2407 Op::Eq { .. } => MicroOp::Eq { dst, lhs, rhs },
2408 _ => MicroOp::Neq { dst, lhs, rhs },
2409 });
2410 }
2411 }
2412 Op::Jump { target } => {
2413 fixups.push(micro.len());
2414 micro.push(MicroOp::Jump { target });
2415 }
2416 Op::JumpIfFalse { cond, target } => {
2417 fixups.push(micro.len());
2418 micro.push(MicroOp::JumpIfFalse { cond, target });
2419 }
2420 Op::JumpIfTrue { cond, target } => {
2421 fixups.push(micro.len());
2422 micro.push(MicroOp::JumpIfTrue { cond, target });
2423 }
2424 // `IndexUnchecked` carries the Oracle's in-bounds proof (M9 range
2425 // analysis): its array load drops the bounds branch entirely
2426 // (V8/LLVM-style bounds-check elimination). Maps always check
2427 // through their helper, so the proof only affects the list lane.
2428 Op::Index { dst, collection, index }
2429 | Op::IndexUnchecked { dst, collection, index } => {
2430 // Elision is region-only: the function tier has no per-loop entry
2431 // guard, so it keeps every access checked (a speculative
2432 // `IndexUnchecked` there would be unguarded). Static proofs lose
2433 // nothing measurable in functions.
2434 let p = pins.get(&collection)?;
2435 if p.elem == PinElem::Map {
2436 micro.push(MicroOp::MapGet {
2437 dst,
2438 key: index,
2439 map_slot: p.vec_slot,
2440 helper_addr: logos_rt_map_get_ii as usize as i64,
2441 });
2442 } else {
2443 // A Text-as-bytes pin loads ONE byte (the ASCII char codepoint)
2444 // and is ALWAYS bounds-checked: indexing past an ASCII text must
2445 // raise the same error at the same point as the tree-walker's
2446 // per-char path (`IndexUnchecked` elision is never applied to a
2447 // text — no RegionBoundsGuard is emitted for it). Bool buffers
2448 // are 1-byte too; Int/Float are 8-byte.
2449 let text = p.elem == PinElem::TextBytes;
2450 let byte = text || p.elem == PinElem::Bool;
2451 let narrow32 = p.elem == PinElem::IntI32;
2452 let checked =
2453 text || !region || matches!(ops[i], Op::Index { .. });
2454 micro.push(MicroOp::ArrLoad {
2455 dst,
2456 idx: index,
2457 ptr_slot: p.ptr_slot,
2458 len_slot: p.len_slot,
2459 byte,
2460 narrow32,
2461 checked,
2462 });
2463 }
2464 }
2465 Op::SetIndex { collection, index, value }
2466 | Op::SetIndexUnchecked { collection, index, value } => {
2467 // Region-only elision (see `Index`); maps always check.
2468 let checked = !region || matches!(ops[i], Op::SetIndex { .. });
2469 let p = pins.get(&collection)?;
2470 // A Text pin is read-only — `Set item i of text` never tiers (the
2471 // kind gate already rejects it; bail defensively too).
2472 if p.elem == PinElem::TextBytes {
2473 return None;
2474 }
2475 if p.elem == PinElem::Map {
2476 micro.push(MicroOp::MapSet {
2477 src: value,
2478 key: index,
2479 map_slot: p.vec_slot,
2480 helper_addr: logos_rt_map_set_ii as usize as i64,
2481 });
2482 } else {
2483 let byte = p.elem == PinElem::Bool;
2484 let narrow32 = p.elem == PinElem::IntI32;
2485 micro.push(MicroOp::ArrStore {
2486 src: value,
2487 idx: index,
2488 ptr_slot: p.ptr_slot,
2489 len_slot: p.len_slot,
2490 byte,
2491 narrow32,
2492 checked,
2493 });
2494 }
2495 }
2496 Op::Contains { dst, collection, value } => {
2497 let p = pins.get(&collection)?;
2498 if p.elem != PinElem::Map {
2499 return None;
2500 }
2501 micro.push(MicroOp::MapHas {
2502 dst,
2503 key: value,
2504 map_slot: p.vec_slot,
2505 helper_addr: logos_rt_map_has_i as usize as i64,
2506 });
2507 }
2508 Op::Length { dst, collection } => {
2509 let p = pins.get(&collection)?;
2510 micro.push(MicroOp::Move { dst, src: p.len_slot });
2511 }
2512 Op::ListPush { list, value } => {
2513 let p = pins.get(&list)?;
2514 let helper_addr = match p.elem {
2515 PinElem::Int => crate::logos_rt_push_i64 as usize as i64,
2516 PinElem::IntI32 => crate::logos_rt_push_i32 as usize as i64,
2517 PinElem::Float => crate::logos_rt_push_f64 as usize as i64,
2518 PinElem::Bool => crate::logos_rt_push_bool as usize as i64,
2519 // No push onto a map or a text accumulator.
2520 PinElem::Map | PinElem::TextBytes | PinElem::TextMut => return None,
2521 };
2522 micro.push(MicroOp::ArrPush {
2523 src: value,
2524 vec_slot: p.vec_slot,
2525 ptr_slot: p.ptr_slot,
2526 len_slot: p.len_slot,
2527 helper_addr,
2528 // A `Seq of Bool` buffer is 1-byte; the inline fast-path store
2529 // writes the boolean normalization. Int/Float are 8-byte raw;
2530 // a narrowed `IntsI32` buffer is 4-byte (truncating push).
2531 byte: p.elem == PinElem::Bool,
2532 narrow32: p.elem == PinElem::IntI32,
2533 });
2534 }
2535 Op::NewEmptyList { dst } => {
2536 // In-region `Let x be a new Seq` on a PINNED array: clear it in place
2537 // (truncate + reset the pinned length, keep the buffer) and reuse it,
2538 // rather than allocating fresh. An in-place mutation — the precise
2539 // region resumes over it soundly. Bails (`?`) for a non-pinned dst:
2540 // such a fresh list has no pin channel to clear here.
2541 let p = pins.get(&dst)?;
2542 // SOUNDNESS: reusing the buffer is only valid if the old contents
2543 // are dead. A `Move { src: dst }` that copies THIS list's handle to
2544 // another register ALIASES the buffer past the clear — reusing it
2545 // would wipe the alias's live data (knapsack's `Set prev to curr`,
2546 // where the cleared row is the one `prev` now points at). But the
2547 // compiler reuses register slots, so `dst` may also be a Move source
2548 // while it transiently holds an UNRELATED scalar (fannkuch's `perm`
2549 // slot is a `Sub` result moved out one op before the `NewEmptyList`
2550 // re-binds it to the list). Such a move reads the scalar, not the
2551 // list, and is irrelevant to buffer reuse. The kind flow already
2552 // resolves which value `dst` holds at each op: a move aliases the
2553 // list iff `dst` holds a LIST kind there (Mixed = a list joined with
2554 // a scalar across paths ⇒ conservatively an alias). A scalar kind
2555 // (or an unreachable/`Unknown` op) provably does not observe the
2556 // list, so clear-reuse stays sound. This is the same alias test the
2557 // VM makes dynamically (sole-ownership), lifted to the static kind
2558 // flow.
2559 let list_handle_escapes = ops.iter().enumerate().any(|(j, o)| {
2560 matches!(o, Op::Move { src, .. } if *src == dst)
2561 && matches!(
2562 kin[j].as_ref().map(|kk| kk[dst as usize]),
2563 Some(
2564 Kind::IntList
2565 | Kind::IntListI32
2566 | Kind::FloatList
2567 | Kind::BoolList
2568 | Kind::IntMap
2569 | Kind::Mixed
2570 )
2571 )
2572 });
2573 if list_handle_escapes {
2574 return None;
2575 }
2576 let helper_addr = match p.elem {
2577 PinElem::Int => crate::logos_rt_clear_i64 as usize as i64,
2578 PinElem::IntI32 => crate::logos_rt_clear_i32 as usize as i64,
2579 PinElem::Float => crate::logos_rt_clear_f64 as usize as i64,
2580 PinElem::Bool => crate::logos_rt_clear_bool as usize as i64,
2581 // No empty-list clear for a map or text pin.
2582 PinElem::Map | PinElem::TextBytes | PinElem::TextMut => return None,
2583 };
2584 micro.push(MicroOp::ListClear {
2585 vec_slot: p.vec_slot,
2586 ptr_slot: p.ptr_slot,
2587 len_slot: p.len_slot,
2588 helper_addr,
2589 });
2590 }
2591 Op::CallBuiltin {
2592 dst,
2593 builtin: logicaffeine_compile::semantics::builtins::BuiltinId::Sqrt,
2594 args_start,
2595 arg_count,
2596 } => {
2597 if arg_count != 1 {
2598 return None;
2599 }
2600 let src = match kind_of(args_start) {
2601 Kind::Float => args_start,
2602 Kind::Int => {
2603 micro.push(MicroOp::IntToFloat { dst: conv.0, src: args_start });
2604 conv.0
2605 }
2606 _ => return None,
2607 };
2608 micro.push(MicroOp::SqrtF { dst, src });
2609 }
2610 Op::Call { dst, args_start, arg_count, func } => {
2611 let c = call_ctx?;
2612 // The callee windows at a DISJOINT offset past the limit slot —
2613 // the interpreter's overlapping windowing (callee base = caller
2614 // base + args_start) would let the callee's registers land on
2615 // the caller's conversion scratches and arena-limit slot, so
2616 // the caller's NEXT call would read a clobbered bound and
2617 // deopt. Args are staged across with explicit moves.
2618 let window = c.limit_slot + 1;
2619 // LEVER A — the pinned-argument self-call ABI. A DIRECT self-call
2620 // whose arguments are a contiguous all-scalar block lying wholly
2621 // below the callee window stages them inside ONE fused stencil
2622 // (`CallSelfCopy`) instead of `arg_count` separate frame-to-frame
2623 // `Move` pieces — the dispatch-reduction win on this piece-bound
2624 // engine. The copy is bit-identical to the Moves: the same callee
2625 // slots receive the same 8-byte values (Int/Bool/Float alike),
2626 // and the depth/deopt machinery is untouched. The disjointness
2627 // guard (`args_start + arg_count <= window`) is always true for
2628 // the function tier (`window = limit_slot + 1` sits past every
2629 // register), but is checked so the fallback is provably safe; any
2630 // list/map-kinded argument (which the all-scalar mode-A signature
2631 // never produces, but is guarded for soundness) keeps the Moves.
2632 let scalar_args = (0..arg_count).all(|j| {
2633 matches!(kind_of(args_start + j), Kind::Int | Kind::Bool | Kind::Float)
2634 });
2635 let fuse_self = func == c.self_fi
2636 && arg_count >= 1
2637 && args_start + arg_count <= window
2638 && scalar_args
2639 && std::env::var_os("LOGOS_NO_PINNED_ARGS").is_none();
2640 if fuse_self {
2641 // DIRECT self-call with the fused argument copy: the entry
2642 // pool word is patched with this chain's own base after
2643 // layout; the frame bound is static (self-recursion: the
2644 // callee frame is OUR OWN size).
2645 micro.push(MicroOp::CallSelfCopy {
2646 dst,
2647 args_start: window,
2648 src_start: args_start,
2649 arg_count,
2650 depth_addr: c.depth_addr,
2651 status_addr: c.status_addr,
2652 limit_slot: c.limit_slot,
2653 frame_size: (c.limit_slot as i64) + 1,
2654 });
2655 return Some(1);
2656 }
2657 for j in 0..arg_count {
2658 micro.push(MicroOp::Move { dst: window + j, src: args_start + j });
2659 }
2660 if func == c.self_fi {
2661 // DIRECT self-call: the entry pool word is patched with
2662 // this chain's own base after layout; the frame bound is
2663 // static (self-recursion: the callee frame is OUR OWN
2664 // size).
2665 micro.push(MicroOp::CallSelf {
2666 dst,
2667 args_start: window,
2668 depth_addr: c.depth_addr,
2669 status_addr: c.status_addr,
2670 limit_slot: c.limit_slot,
2671 frame_size: (c.limit_slot as i64) + 1,
2672 });
2673 } else {
2674 // Cross-function call: table dispatch through the CALLEE's
2675 // slot pair (the kind fixpoint already validated its
2676 // all-scalar signature); an unpublished entry deopts and
2677 // the boundary replay stays exact.
2678 micro.push(MicroOp::Call {
2679 dst,
2680 args_start: window,
2681 table_addr: c.table.slot_addr(func as usize),
2682 depth_addr: c.depth_addr,
2683 status_addr: c.status_addr,
2684 limit_slot: c.limit_slot,
2685 depth_limit: c.depth_limit,
2686 });
2687 }
2688 }
2689 // In-region Return: set the flag, stash the value, leave via the
2690 // synthetic exit (usize::MAX is the out-of-region marker the remap
2691 // routes there).
2692 Op::Return { src } if region_return.is_some() => {
2693 let (flag_slot, value_slot) = region_return.unwrap();
2694 micro.push(MicroOp::LoadConst { dst: flag_slot, value: 1 });
2695 if matches!(kind_of(src), Kind::IntList | Kind::FloatList | Kind::BoolList) {
2696 // Lists return BY REGISTER: stash the register number; the
2697 // VM returns that register's current value.
2698 micro.push(MicroOp::LoadConst { dst: value_slot, value: src as i64 });
2699 } else {
2700 micro.push(MicroOp::Move { dst: value_slot, src });
2701 }
2702 fixups.push(micro.len());
2703 micro.push(MicroOp::Jump { target: usize::MAX });
2704 }
2705 Op::Return { src } if allow_return => micro.push(MicroOp::Return { src }),
2706 // Statically dead (gated by the caller); valid-shape placeholder.
2707 Op::ReturnNothing if allow_return => micro.push(MicroOp::Return { src: 0 }),
2708 // Region-entry length hoist: pure metadata, no native code. The VM
2709 // verifies it once at region entry; `adapt_region` collects it.
2710 Op::RegionBoundsGuard { .. } => {}
2711 _ => return None,
2712 }
2713 Some(1)
2714}
2715
2716/// Translate a MAIN-LOOP REGION into the J2 subset.
2717///
2718/// The register roles of a recognized naive substring-search nest (the
2719/// string_search benchmark idiom): the count of OVERLAPPING `needle`
2720/// occurrences in `text` over the outer index range.
2721struct MemMemShape {
2722 text_reg: u16,
2723 needle_reg: u16,
2724 needle_len_reg: u16,
2725 i_reg: u16,
2726 count_reg: u16,
2727}
2728
2729/// Recognize the EXACT naive substring-search nest the LOGOS compiler lowers
2730/// for `string_search`'s count loop, so the JIT can collapse the per-byte
2731/// nested-loop region to one [`MicroOp::MemMem`] call.
2732///
2733/// The matched outer-loop region (head at `ops[0]`) is, in bytecode:
2734///
2735/// ```text
2736/// Sub(t1 = textLen - needleLen); LoadConst(c=1); Add(bound = t1 + c)
2737/// LtEq(cmp = i <= bound); JumpIfFalse(cmp -> EXIT)
2738/// LoadConst(c=1); Move(match = c); LoadConst(c=0); Move(j = c)
2739/// Lt(cj = j < needleLen); JumpIfFalse(cj -> after_inner)
2740/// Add(t3 = i + j); IndexUnchecked(ch1 = text[t3])
2741/// LoadConst(c=1); Add(t4 = j + c); Index(ch2 = needle[t4]) // checked
2742/// NotEq(ne = ch1 != ch2); JumpIfFalse(ne -> cont)
2743/// LoadConst(c=0); Move(match = c); Move(t5 = needleLen); Move(j = t5)
2744/// cont: LoadConst(c=1); AddAssign(j += c); Jump(-> inner head)
2745/// after_inner: LoadConst(c=1); Eq(eq = match == c); JumpIfFalse(eq -> skip)
2746/// LoadConst(c=1); AddAssign(count += c)
2747/// skip: LoadConst(c=1); AddAssign(i += c); Jump(-> outer head)
2748/// ```
2749///
2750/// Recognition is STRICT — the shape, the operand wiring (`i`/`j`/`needleLen`/
2751/// `count`/`text`/`needle` register threading), the two `Index` collections, the
2752/// jump topology, and the `1`/`0` constants are all checked — so the helper is
2753/// only ever substituted when it is bit-identical to this nest. Any deviation
2754/// returns `None` and the region falls back to the per-byte tiered loop.
2755/// `LOGOS_MEMMEM=0` disables the recognizer entirely.
2756fn match_memmem_nest(
2757 ops: &[Op],
2758 head_pc: usize,
2759 exit_pc: usize,
2760 constants: &[Constant],
2761) -> Option<MemMemShape> {
2762 if std::env::var("LOGOS_MEMMEM").as_deref() == Ok("0") {
2763 return None;
2764 }
2765 // The template is exactly 33 ops (the outer back-edge is the last).
2766 if ops.len() != 33 {
2767 return None;
2768 }
2769 let is_const = |i: usize, want: i64| -> bool {
2770 matches!(ops.get(i), Some(Op::LoadConst { idx, .. })
2771 if matches!(constants.get(*idx as usize), Some(Constant::Int(v)) if *v == want))
2772 };
2773 let const_dst = |i: usize| -> Option<u16> {
2774 match ops.get(i) {
2775 Some(Op::LoadConst { dst, .. }) => Some(*dst),
2776 _ => None,
2777 }
2778 };
2779 // The region-relative targets the jumps must hit.
2780 let inner_head = head_pc + 9; // op [38] Lt(j < needleLen)
2781 let after_inner = head_pc + 25; // op [54]
2782 let cont = head_pc + 22; // op [51]
2783 let skip = head_pc + 30; // op [59]
2784
2785 // [0] Sub(t1 = textLen - needleLen)
2786 let Op::Sub { dst: t1, lhs: text_len, rhs: needle_len } = ops[0] else { return None };
2787 // [1] LoadConst(c1 = 1); [2] Add(bound = t1 + c1)
2788 if !is_const(1, 1) {
2789 return None;
2790 }
2791 let c1 = const_dst(1)?;
2792 let Op::Add { dst: bound, lhs, rhs } = ops[2] else { return None };
2793 if lhs != t1 || rhs != c1 {
2794 return None;
2795 }
2796 // [3] LtEq(cmp = i <= bound); [4] JumpIfFalse(cmp -> EXIT)
2797 let Op::LtEq { dst: cmp, lhs: i_reg, rhs } = ops[3] else { return None };
2798 if rhs != bound {
2799 return None;
2800 }
2801 let Op::JumpIfFalse { cond, target } = ops[4] else { return None };
2802 if cond != cmp || target != exit_pc {
2803 return None;
2804 }
2805 // [5] LoadConst(=1); [6] Move(match = it)
2806 if !is_const(5, 1) {
2807 return None;
2808 }
2809 let c5 = const_dst(5)?;
2810 let Op::Move { dst: match_reg, src } = ops[6] else { return None };
2811 if src != c5 {
2812 return None;
2813 }
2814 // [7] LoadConst(=0); [8] Move(j = it)
2815 if !is_const(7, 0) {
2816 return None;
2817 }
2818 let c7 = const_dst(7)?;
2819 let Op::Move { dst: j_reg, src } = ops[8] else { return None };
2820 if src != c7 {
2821 return None;
2822 }
2823 // [9] Lt(cj = j < needleLen); [10] JumpIfFalse(cj -> after_inner)
2824 let Op::Lt { dst: cj, lhs, rhs } = ops[9] else { return None };
2825 if lhs != j_reg || rhs != needle_len {
2826 return None;
2827 }
2828 let Op::JumpIfFalse { cond, target } = ops[10] else { return None };
2829 if cond != cj || target != after_inner {
2830 return None;
2831 }
2832 // [11] Add(t3 = i + j); [12] IndexUnchecked(ch1 = text[t3])
2833 let Op::Add { dst: t3, lhs, rhs } = ops[11] else { return None };
2834 if lhs != i_reg || rhs != j_reg {
2835 return None;
2836 }
2837 let Op::IndexUnchecked { dst: ch1, collection: text_reg, index } = ops[12] else {
2838 return None;
2839 };
2840 if index != t3 {
2841 return None;
2842 }
2843 // [13] LoadConst(=1); [14] Add(t4 = j + it); [15] Index(ch2 = needle[t4])
2844 if !is_const(13, 1) {
2845 return None;
2846 }
2847 let c13 = const_dst(13)?;
2848 let Op::Add { dst: t4, lhs, rhs } = ops[14] else { return None };
2849 if lhs != j_reg || rhs != c13 {
2850 return None;
2851 }
2852 let Op::Index { dst: ch2, collection: needle_reg, index } = ops[15] else { return None };
2853 if index != t4 {
2854 return None;
2855 }
2856 // [16] NotEq(ne = ch1 != ch2); [17] JumpIfFalse(ne -> cont)
2857 let Op::NotEq { dst: ne, lhs, rhs } = ops[16] else { return None };
2858 if !((lhs == ch1 && rhs == ch2) || (lhs == ch2 && rhs == ch1)) {
2859 return None;
2860 }
2861 let Op::JumpIfFalse { cond, target } = ops[17] else { return None };
2862 if cond != ne || target != cont {
2863 return None;
2864 }
2865 // [18] LoadConst(=0); [19] Move(match = it)
2866 if !is_const(18, 0) {
2867 return None;
2868 }
2869 let c18 = const_dst(18)?;
2870 let Op::Move { dst, src } = ops[19] else { return None };
2871 if dst != match_reg || src != c18 {
2872 return None;
2873 }
2874 // [20] Move(t5 = needleLen); [21] Move(j = t5)
2875 let Op::Move { dst: t5, src } = ops[20] else { return None };
2876 if src != needle_len {
2877 return None;
2878 }
2879 let Op::Move { dst, src } = ops[21] else { return None };
2880 if dst != j_reg || src != t5 {
2881 return None;
2882 }
2883 // [22] cont: LoadConst(=1); [23] AddAssign(j += it); [24] Jump(-> inner head)
2884 if !is_const(22, 1) {
2885 return None;
2886 }
2887 let c22 = const_dst(22)?;
2888 let Op::AddAssign { dst, src } = ops[23] else { return None };
2889 if dst != j_reg || src != c22 {
2890 return None;
2891 }
2892 let Op::Jump { target } = ops[24] else { return None };
2893 if target != inner_head {
2894 return None;
2895 }
2896 // [25] after_inner: LoadConst(=1); [26] Eq(eq = match == it); [27] JumpIfFalse(eq -> skip)
2897 if !is_const(25, 1) {
2898 return None;
2899 }
2900 let c25 = const_dst(25)?;
2901 let Op::Eq { dst: eq, lhs, rhs } = ops[26] else { return None };
2902 if !((lhs == match_reg && rhs == c25) || (lhs == c25 && rhs == match_reg)) {
2903 return None;
2904 }
2905 let Op::JumpIfFalse { cond, target } = ops[27] else { return None };
2906 if cond != eq || target != skip {
2907 return None;
2908 }
2909 // [28] LoadConst(=1); [29] AddAssign(count += it)
2910 if !is_const(28, 1) {
2911 return None;
2912 }
2913 let c28 = const_dst(28)?;
2914 let Op::AddAssign { dst: count_reg, src } = ops[29] else { return None };
2915 if src != c28 {
2916 return None;
2917 }
2918 // [30] skip: LoadConst(=1); [31] AddAssign(i += it); [32] Jump(-> outer head)
2919 if !is_const(30, 1) {
2920 return None;
2921 }
2922 let c30 = const_dst(30)?;
2923 let Op::AddAssign { dst, src } = ops[31] else { return None };
2924 if dst != i_reg || src != c30 {
2925 return None;
2926 }
2927 let Op::Jump { target } = ops[32] else { return None };
2928 if target != head_pc {
2929 return None;
2930 }
2931 // text and needle must be DISTINCT collections; the count accumulator must
2932 // be distinct from the loop induction `i`.
2933 let _ = text_len;
2934 if text_reg == needle_reg || count_reg == i_reg {
2935 return None;
2936 }
2937 Some(MemMemShape {
2938 text_reg,
2939 needle_reg,
2940 needle_len_reg: needle_len,
2941 i_reg,
2942 count_reg,
2943 })
2944}
2945
2946/// Region contract: `ops[0]` is the loop head; the slice ends at the
2947/// back-edge; every jump out targets `exit_pc` (mapped to a synthetic
2948/// terminal). The guard set is the LIVE-IN set at the head — computed by
2949/// textbook backward liveness over the region's CFG — so any slot whose
2950/// incoming value can be observed is Int-guarded and copied in, while
2951/// incoming-dead slots (loop scratches, comparison temporaries anywhere in
2952/// the body) need no guard. Writes are re-boxed by inferred kind.
2953#[allow(clippy::type_complexity)]
2954/// BUG-002 detector: does an array-loaded value survive a conditional branch and
2955/// reach an in-place `SetIndex`? A forward scan tracks, per register, whether it
2956/// currently holds a value loaded from an array and whether a branch has occurred
2957/// since that load (propagating through `Move`s). A `SetIndex`/`SetIndexUnchecked`
2958/// whose stored value is such a branch-surviving array-loaded register is the
2959/// miscompiling shape. Conservative (a branch marks ALL live array-loaded
2960/// registers), but the safe in-branch-load shape never trips it because the load
2961/// happens AFTER the guard branch.
2962fn has_crossbranch_array_value_store_hazard(ops: &[Op]) -> bool {
2963 use std::collections::HashMap;
2964 // reg -> has a branch occurred since this reg was loaded from an array?
2965 let mut loaded: HashMap<u16, bool> = HashMap::new();
2966 for op in ops {
2967 match op {
2968 Op::Index { dst, .. } | Op::IndexUnchecked { dst, .. } => {
2969 loaded.insert(*dst, false);
2970 }
2971 Op::Move { dst, src } => match loaded.get(src).copied() {
2972 Some(b) => {
2973 loaded.insert(*dst, b);
2974 }
2975 None => {
2976 loaded.remove(dst);
2977 }
2978 },
2979 Op::JumpIfFalse { .. } | Op::JumpIfTrue { .. } | Op::Jump { .. } => {
2980 for v in loaded.values_mut() {
2981 *v = true;
2982 }
2983 }
2984 Op::SetIndex { value, .. } | Op::SetIndexUnchecked { value, .. } => {
2985 if loaded.get(value) == Some(&true) {
2986 return true;
2987 }
2988 }
2989 other => {
2990 // Any other op that DEFINES a register clears its array-loaded
2991 // status (the value is no longer a raw array element).
2992 if let Some((_, defs)) = region_use_def(other) {
2993 for d in defs {
2994 loaded.remove(&d);
2995 }
2996 }
2997 }
2998 }
2999 }
3000 false
3001}
3002
3003fn adapt_region(
3004 ops: &[Op],
3005 head_pc: usize,
3006 exit_pc: usize,
3007 constants: &[Constant],
3008 register_count: u16,
3009 named: &[bool],
3010 observed: &[ObservedKind],
3011 ctx: &NativeCtx,
3012 callees: &[logicaffeine_compile::vm::CalleeSig],
3013) -> Option<(
3014 Vec<MicroOp>,
3015 usize,
3016 Vec<(u16, SlotKind)>,
3017 Vec<u16>,
3018 Vec<(u16, SlotKind)>,
3019 Vec<ArrayPin>,
3020 Option<logicaffeine_compile::vm::RegionReturn>,
3021 Vec<HoistGuard>,
3022 // Precise-deopt tables (Some ⇒ this region resumes AT the faulting op on a
3023 // side exit, re-boxing each register by kind, instead of replaying from the
3024 // head — required for a sound push+SetIndex region).
3025 Option<RegionPrecise>,
3026)> {
3027 // SOUNDNESS (Bug Report #1, BUG-001): a region runs under a
3028 // discard-and-replay-from-head deopt contract that is sound only for
3029 // replay-idempotent effects. `ListPush` APPENDS — not idempotent on its own
3030 // — but the VM's region deopt path now rolls every pinned buffer back to its
3031 // entry length before replaying (see machine.rs), so a push followed by a
3032 // PURE side-exit (Div/Mod div-by-zero, Index/Contains out-of-bounds or
3033 // fast-lane miss — all read-only or arithmetic) replays cleanly. What stays
3034 // unsound is push coexisting with another NON-idempotent effect whose replay
3035 // the truncate cannot undo: an in-place `SetIndex`/`SetIndexUnchecked` write
3036 // (a read-modify-write would double-apply) or a `Call` (arbitrary effects).
3037 // Those still disqualify the region; it falls back to the always-correct
3038 // bytecode interpreter.
3039 if std::env::var("LOGOS_DUMP_REGION").ok().and_then(|v| v.parse::<usize>().ok()) == Some(head_pc) {
3040 eprintln!("=== REGION head_pc={head_pc} ops ({}) ===", ops.len());
3041 for (i, op) in ops.iter().enumerate() {
3042 eprintln!(" [{}] {op:?}", head_pc + i);
3043 }
3044 }
3045 let has_list_push = ops.iter().any(|op| matches!(op, Op::ListPush { .. }));
3046 let has_setindex = ops
3047 .iter()
3048 .any(|op| matches!(op, Op::SetIndex { .. } | Op::SetIndexUnchecked { .. }));
3049 let has_call_op = ops.iter().any(|op| matches!(op, Op::Call { .. }));
3050 // A `ListPush` coexisting with an in-place `SetIndex` or a `Call` is NOT
3051 // replay-safe under the classic discard-replay-from-head deopt (truncate
3052 // rolls the pushes back but the in-place write persists, so a self-gated
3053 // re-push is skipped — the BFS lost-node bug). It can still tier up with
3054 // PRECISE region deopt (resume AT the faulting op, no replay): sound for any
3055 // pattern. Precise materialization re-boxes the frame's scalars, so it is
3056 // admitted only for an ALL-INT region with NO call (a call frame would need
3057 // the full function-precise walk). A push beside a call is therefore always
3058 // disqualified; a push beside a SetIndex is admitted here and re-checked for
3059 // all-int after lowering (where the precise `deopt_codes` are built).
3060 let needs_precise = has_list_push && (has_setindex || has_call_op);
3061 if needs_precise && has_call_op {
3062 return None;
3063 }
3064
3065 // Backward liveness: out-of-region exits contribute empty live-out —
3066 // exit-observable slots flow through the WRITE-BACK, never the frame.
3067 let nregs = register_count as usize + 2;
3068 let in_region = |t: usize| (head_pc..head_pc + ops.len()).contains(&t);
3069 let Some(live) = liveness(ops, head_pc, nregs) else {
3070 if std::env::var_os("LOGOS_RDIAG").is_some() {
3071 eprintln!("RDIAG-BAIL head_pc={head_pc} liveness");
3072 }
3073 return None;
3074 };
3075
3076 // Guard = live-in at the head; touched = every use or def; free =
3077 // touched − guard; writes = every def (re-boxed by kind on completion).
3078 let mut guard: Vec<u16> = Vec::new();
3079 let mut free: Vec<u16> = Vec::new();
3080 let mut writes: Vec<u16> = Vec::new();
3081 let mut touched: Vec<u16> = Vec::new();
3082 for op in ops {
3083 let Some((uses, defs)) = region_use_def(op) else {
3084 if std::env::var_os("LOGOS_RDIAG").is_some() {
3085 eprintln!("RDIAG-BAIL head_pc={head_pc} use_def-unsupported op={op:?}");
3086 }
3087 return None;
3088 };
3089 for d in defs {
3090 if !writes.contains(&d) {
3091 writes.push(d);
3092 }
3093 if !touched.contains(&d) {
3094 touched.push(d);
3095 }
3096 }
3097 for u in uses {
3098 if !touched.contains(&u) {
3099 touched.push(u);
3100 }
3101 }
3102 }
3103 // Array pins: every register used as an Index/SetIndex/Length collection
3104 // must hold an UNBOXED list right now (the speculation the entry guard
3105 // re-checks); each gets a pinned (pointer, length) slot pair past the
3106 // conversion scratches.
3107 let mut pin_regs: Vec<(u16, PinElem)> = Vec::new();
3108 for op in ops {
3109 let coll = match *op {
3110 Op::Index { collection, .. }
3111 | Op::IndexUnchecked { collection, .. }
3112 | Op::SetIndex { collection, .. }
3113 | Op::SetIndexUnchecked { collection, .. }
3114 | Op::Length { collection, .. }
3115 | Op::Contains { collection, .. } => Some(collection),
3116 Op::ListPush { list, .. } => Some(list),
3117 _ => None,
3118 };
3119 if let Some(c) = coll {
3120 let elem = match observed.get(c as usize)? {
3121 ObservedKind::IntList => PinElem::Int,
3122 ObservedKind::IntListI32 => PinElem::IntI32,
3123 ObservedKind::FloatList => PinElem::Float,
3124 ObservedKind::BoolList => PinElem::Bool,
3125 ObservedKind::Map => PinElem::Map,
3126 ObservedKind::TextBytes => {
3127 // A Text-as-bytes pin is read-only: the byte buffer pointer
3128 // is captured at entry and the `Rc<String>` is never written
3129 // in place. A region that REASSIGNS the pinned register (a
3130 // `Move`/rebind into `c`, or string growth via `Op::Concat`
3131 // — already out of `region_use_def` so it bails on its own)
3132 // would leave a stale buffer pointer. Bail if the pinned
3133 // register is ever a def in this region.
3134 if writes.contains(&c) {
3135 if std::env::var_os("LOGOS_RDIAG").is_some() {
3136 eprintln!("RDIAG-BAIL head_pc={head_pc} textbytes-pin-reassigned reg={c}");
3137 }
3138 return None;
3139 }
3140 PinElem::TextBytes
3141 }
3142 _ => {
3143 if std::env::var_os("LOGOS_RDIAG").is_some() {
3144 eprintln!("RDIAG-BAIL head_pc={head_pc} non-list-collection reg={c}");
3145 }
3146 return None;
3147 }
3148 };
3149 if !pin_regs.iter().any(|&(r, _)| r == c) {
3150 pin_regs.push((c, elem));
3151 }
3152 }
3153 }
3154 // MUTABLE-TEXT accumulator (`Set text to text + ch`): a register that is the
3155 // `dst` of an `AddAssign` and is OBSERVED as an ASCII Text rides the
3156 // `TextMut` pin channel (a `*mut Value` handle to its VM register cell), so
3157 // the append lowers to a `StrAppend` helper call instead of bailing the
3158 // region on a non-numeric `AddAssign`. Emitted ONLY for the contiguous
3159 // regalloc backend (the per-piece stencil tier has no `StrAppend` lowering
3160 // and declines it); when regalloc is off, a `text + ch` region falls back to
3161 // bytecode exactly as before. A register that is ALSO used as a collection
3162 // (already pinned above) is not re-pinned as TextMut.
3163 let strappend_on = logicaffeine_forge::regalloc::regalloc_enabled();
3164 if strappend_on {
3165 for op in ops {
3166 if let Op::AddAssign { dst, .. } = *op {
3167 if matches!(observed.get(dst as usize), Some(ObservedKind::TextBytes))
3168 && !pin_regs.iter().any(|&(r, _)| r == dst)
3169 {
3170 pin_regs.push((dst, PinElem::TextMut));
3171 }
3172 }
3173 }
3174 }
3175 // LEVER B (ON by default; kill-switch `LOGOS_LEVERB=0`): a list that flows
3176 // into a list-parameter CALL but is never directly Indexed here (e.g.
3177 // `arr = siftDown(arr, …)`) is still a pinnable array — pin every touched
3178 // slot observed as a list, so it rides the array channel rather than the
3179 // scalar guard set (which has no `slot_kind` for a list and would bail).
3180 // Validated across ~2180 tests (878 JIT + 1301 e2e) + the deopt-rollback
3181 // RED/GREEN; regions calling list-param functions now tier (heap_sort,
3182 // mergesort, any worklist-via-helper) with discard-replay soundness from
3183 // the array snapshot/rollback.
3184 let leverb_on = std::env::var("LOGOS_LEVERB").as_deref() != Ok("0");
3185 // Does this region CALL a list-parameter function? Only then must a list
3186 // that's merely passed through (not Indexed here) be pinned. Pinning EVERY
3187 // touched list would spuriously re-pin lists in SCALAR-call regions and
3188 // reintroduce the float-pin / mem-form clobber (spectral_norm), so this is
3189 // strictly scoped to list-param-call regions.
3190 let has_list_param_call = leverb_on
3191 && ops.iter().any(|op| match *op {
3192 Op::Call { func, .. } => callees.get(func as usize).is_some_and(|s| {
3193 s.param_kinds.iter().any(|p| matches!(p, Some(ParamKind::List(_))))
3194 }),
3195 _ => false,
3196 });
3197 if has_list_param_call {
3198 for &r in &touched {
3199 if pin_regs.iter().any(|&(p, _)| p == r) {
3200 continue;
3201 }
3202 let elem = match observed.get(r as usize) {
3203 Some(ObservedKind::IntList) => Some(PinElem::Int),
3204 Some(ObservedKind::IntListI32) => Some(PinElem::IntI32),
3205 Some(ObservedKind::FloatList) => Some(PinElem::Float),
3206 Some(ObservedKind::BoolList) => Some(PinElem::Bool),
3207 _ => None,
3208 };
3209 if let Some(e) = elem {
3210 pin_regs.push((r, e));
3211 }
3212 }
3213 }
3214 // In-region Returns: the value's kind must agree across every return
3215 // site (re-boxed by SlotKind at the boundary).
3216 let has_return = ops.iter().any(|op| matches!(op, Op::Return { .. }));
3217 if ops.iter().any(|op| matches!(op, Op::ReturnNothing)) {
3218 if std::env::var_os("LOGOS_RDIAG").is_some() {
3219 eprintln!("RDIAG-BAIL head_pc={head_pc} return-nothing");
3220 }
3221 return None;
3222 }
3223 // An in-place `SetIndex` write is NOT replay-idempotent (a read-modify-write
3224 // or swap double-applies), so a buffer it targets must be snapshotted on
3225 // region entry and restored on a classic replay-from-head `Deopt`. That cost
3226 // is only owed when the region can ACTUALLY take a recoverable side exit:
3227 // a checked list `Index` (bounds), ANY map `Index`/`Contains` (int-lane
3228 // miss), `Div`/`Mod` (div-by-zero), or a `Call` (callee deopt). Unchecked
3229 // list reads, pure arithmetic, and control flow never side-exit — so a
3230 // deopt-free swap loop (bubble/quick sort) pays nothing. A PRECISE region
3231 // resumes AT the faulting op (no replay), so it never needs the snapshot.
3232 let is_map = |c: u16| matches!(observed.get(c as usize), Some(ObservedKind::Map));
3233 let region_has_deopt_source = ops.iter().any(|op| match *op {
3234 Op::Div { .. } | Op::Mod { .. } | Op::Call { .. } | Op::Index { .. } => true,
3235 Op::IndexUnchecked { collection, .. } | Op::Contains { collection, .. } => is_map(collection),
3236 _ => false,
3237 });
3238 let is_setindexed = |reg: u16| {
3239 ops.iter().any(|op| {
3240 matches!(*op,
3241 Op::SetIndex { collection, .. } | Op::SetIndexUnchecked { collection, .. }
3242 if collection == reg)
3243 })
3244 };
3245 // A pinned mutable-Text accumulator is grown directly in its VM register cell
3246 // by the `StrAppend` helper — like an in-place `SetIndex`, that growth is NOT
3247 // replay-idempotent (a classic replay-from-head would double-append), so its
3248 // entry `Value` must be snapshotted and restored on a classic deopt.
3249 let is_text_mut = |reg: u16| pin_regs.iter().any(|&(r, e)| r == reg && e == PinElem::TextMut);
3250 // LEVER B: a region that CALLS a list-parameter function (`has_list_param_call`,
3251 // computed above) lets the callee mutate the passed buffer IN PLACE. The
3252 // region can't see which pinned array flows into the call (it's staged through
3253 // Moves into the arg window), so conservatively snapshot EVERY pinned array in
3254 // such a region — its contents must roll back on a classic deopt like a SetIndex.
3255 let needs_snapshot = !needs_precise && region_has_deopt_source;
3256 let pin_base = register_count + 2;
3257 let array_pins: Vec<ArrayPin> = pin_regs
3258 .iter()
3259 .enumerate()
3260 .map(|(k, &(reg, elem))| ArrayPin {
3261 reg,
3262 vec_slot: pin_base + 3 * k as u16,
3263 ptr_slot: pin_base + 3 * k as u16 + 1,
3264 len_slot: pin_base + 3 * k as u16 + 2,
3265 elem,
3266 mutated: needs_snapshot && (is_setindexed(reg) || has_list_param_call || is_text_mut(reg)),
3267 })
3268 .collect();
3269 // Collect the loop's region-entry bounds hoists, resolving each guarded
3270 // array to its pinned length slot. The guarded array MUST be pinned (it is
3271 // — the compiler only emits a guard for an array it also accesses); a
3272 // guard whose array is not pinned is dropped (its accesses then stay
3273 // checked, which is safe).
3274 let mut hoist_guards: Vec<HoistGuard> = Vec::new();
3275 for op in ops {
3276 if let Op::RegionBoundsGuard { array, bound, iv, add_max, add_min } = *op {
3277 // The guarded array must be pinned (so its length is available to
3278 // check). If not, bail the region entirely — never run the
3279 // speculative accesses without their guard.
3280 let Some(pin) = array_pins.iter().find(|p| p.reg == array) else {
3281 if std::env::var_os("LOGOS_RDIAG").is_some() {
3282 eprintln!("RDIAG-BAIL head_pc={head_pc} bounds-guard-array-not-pinned");
3283 }
3284 return None;
3285 };
3286 hoist_guards.push(HoistGuard {
3287 len_slot: pin.len_slot,
3288 bound_reg: bound,
3289 iv_reg: iv,
3290 add_max,
3291 add_min,
3292 });
3293 }
3294 }
3295 let pins: std::collections::HashMap<u16, PinSlots> = array_pins
3296 .iter()
3297 .map(|p| {
3298 (
3299 p.reg,
3300 PinSlots {
3301 vec_slot: p.vec_slot,
3302 ptr_slot: p.ptr_slot,
3303 len_slot: p.len_slot,
3304 elem: p.elem,
3305 },
3306 )
3307 })
3308 .collect();
3309
3310 // Must-write: slots DEFINITELY written on every path from the head to
3311 // op i (state holding before i executes). Forward dataflow with
3312 // intersection at merges. A written slot that is not must-written at
3313 // some exit edge is only written on SOME completing paths — bytecode
3314 // would leave its old value on the others, so the region must copy the
3315 // old value IN (guard it) for write-back to restore when the taken path
3316 // skipped the write. (The primes `isPrime`-flag shape.)
3317 let mut mw: Vec<Option<Vec<bool>>> = vec![None; ops.len()];
3318 mw[0] = Some(vec![false; nregs]);
3319 let mut work: Vec<usize> = vec![0];
3320 while let Some(i) = work.pop() {
3321 let Some(cur) = mw[i].clone() else { continue };
3322 let mut out = cur;
3323 let Some((_, defs)) = region_use_def(&ops[i]) else {
3324 if std::env::var_os("LOGOS_RDIAG").is_some() {
3325 eprintln!("RDIAG-BAIL head_pc={head_pc} mw-use_def-unsupported op={:?}", ops[i]);
3326 }
3327 return None;
3328 };
3329 for d in defs {
3330 out[d as usize] = true;
3331 }
3332 let mut succs: Vec<usize> = Vec::with_capacity(2);
3333 match ops[i] {
3334 Op::Jump { target } => {
3335 if in_region(target) {
3336 succs.push(target - head_pc);
3337 }
3338 }
3339 Op::JumpIfFalse { target, .. }
3340 | Op::JumpIfTrue { target, .. }
3341 => {
3342 if i + 1 < ops.len() {
3343 succs.push(i + 1);
3344 }
3345 if in_region(target) {
3346 succs.push(target - head_pc);
3347 }
3348 }
3349 _ => {
3350 if i + 1 < ops.len() {
3351 succs.push(i + 1);
3352 }
3353 }
3354 }
3355 for s in succs {
3356 match &mut mw[s] {
3357 slot @ None => {
3358 *slot = Some(out.clone());
3359 work.push(s);
3360 }
3361 Some(prev) => {
3362 let mut changed = false;
3363 for (a, &b) in prev.iter_mut().zip(&out) {
3364 if *a && !b {
3365 *a = false;
3366 changed = true;
3367 }
3368 }
3369 if changed {
3370 work.push(s);
3371 }
3372 }
3373 }
3374 }
3375 }
3376 // Intersection of must-write across every reachable exit edge.
3377 let mut exit_must: Vec<bool> = vec![true; nregs];
3378 let mut saw_exit = false;
3379 for (i, op) in ops.iter().enumerate() {
3380 let Some(m) = &mw[i] else { continue };
3381 let exits_here = match *op {
3382 Op::Jump { target }
3383 | Op::JumpIfFalse { target, .. }
3384 | Op::JumpIfTrue { target, .. }
3385 => !in_region(target),
3386 Op::Return { .. } => true,
3387 _ => false,
3388 };
3389 if exits_here {
3390 saw_exit = true;
3391 for (e, &b) in exit_must.iter_mut().zip(m) {
3392 *e &= b;
3393 }
3394 }
3395 }
3396 if !saw_exit {
3397 exit_must.iter_mut().for_each(|e| *e = false);
3398 }
3399
3400 // Observability policy: only NAMED slots can be read after the region
3401 // (scratches are statement-local and dead at every statement boundary by
3402 // the compiler's allocation discipline). A named slot written only on
3403 // SOME completing paths is also copied IN, so write-back restores the
3404 // pre-region value when the taken path skipped the write.
3405 let is_named = |r: u16| named.get(r as usize).copied().unwrap_or(false);
3406 for &r in &touched {
3407 if pins.contains_key(&r) {
3408 continue; // pinned arrays ride their own channel
3409 }
3410 let conditionally_written =
3411 is_named(r) && writes.contains(&r) && !exit_must[r as usize];
3412 if live[0][r as usize] || conditionally_written {
3413 guard.push(r);
3414 } else {
3415 free.push(r);
3416 }
3417 }
3418
3419 // Flow-sensitive kinds: guarded (live-in or conditionally-written) slots
3420 // enter with the kind the VM OBSERVED at this hot crossing — exactly what
3421 // the runtime guard will re-check on every entry — free slots as Unknown.
3422 // A guard slot holding something the frame can't carry kills the region.
3423 let mut entry = vec![Kind::Unknown; nregs];
3424 let mut guard_kinds: Vec<(u16, SlotKind)> = Vec::with_capacity(guard.len());
3425 for &g in &guard {
3426 let k = observed_kind(*observed.get(g as usize)?);
3427 let Some(sk) = k.slot_kind() else {
3428 if std::env::var_os("LOGOS_RDIAG").is_some() {
3429 eprintln!("RDIAG-BAIL head_pc={head_pc} guard-slot-no-slotkind g={g} kind={k:?}");
3430 }
3431 return None;
3432 };
3433 entry[g as usize] = k;
3434 guard_kinds.push((g, sk));
3435 }
3436 for &(reg, elem) in &pin_regs {
3437 entry[reg as usize] = match elem {
3438 PinElem::Int => Kind::IntList,
3439 PinElem::IntI32 => Kind::IntListI32,
3440 PinElem::Float => Kind::FloatList,
3441 PinElem::Bool => Kind::BoolList,
3442 PinElem::Map => Kind::IntMap,
3443 PinElem::TextBytes => Kind::TextBytes,
3444 PinElem::TextMut => Kind::TextMut,
3445 };
3446 }
3447 // LEVER B: admit a region that CALLS a list-parameter function. Sound only
3448 // for a region-safe callee (never reallocs a list param) whose list args are
3449 // pinned arrays the region snapshots; the region also releases its array
3450 // borrows around the call so the callee can borrow them. Gated ON by
3451 // `LOGOS_LEVERB=1` while the call path is being hardened (default rejects,
3452 // exactly as before). `leverb_on` is bound above (at the pin-regs stage).
3453 let resolve = |fi: u16| -> Option<(Kind, Vec<Kind>)> {
3454 let sig = callees.get(fi as usize)?;
3455 let list_kind = |e: PinElem| -> Option<Kind> {
3456 match e {
3457 PinElem::Int => Some(Kind::IntList),
3458 PinElem::Float => Some(Kind::FloatList),
3459 PinElem::Bool => Some(Kind::BoolList),
3460 // A half-width buffer is never a declared list-param.
3461 PinElem::IntI32 => None,
3462 PinElem::Map | PinElem::TextBytes | PinElem::TextMut => None,
3463 }
3464 };
3465 let ret = match sig.ret {
3466 Some(SlotKind::Int) => Kind::Int,
3467 Some(SlotKind::Bool) => Kind::Bool,
3468 Some(SlotKind::Float) => Kind::Float,
3469 // A LIST (or void) return: admitted only when it aliases a list
3470 // parameter (no fresh buffer ⇒ no stale pin) of a stable callee.
3471 None => {
3472 if !leverb_on || !sig.list_params_stable || !sig.returns_list_param {
3473 if std::env::var_os("LOGOS_RDIAG").is_some() {
3474 eprintln!("RDIAG-BAIL head_pc={head_pc} list-return-call");
3475 }
3476 return None;
3477 }
3478 let elem = sig.param_kinds.iter().find_map(|pk| match pk {
3479 Some(ParamKind::List(e)) => Some(*e),
3480 _ => None,
3481 })?;
3482 list_kind(elem)?
3483 }
3484 };
3485 let mut ps = Vec::with_capacity(sig.param_kinds.len());
3486 for pk in &sig.param_kinds {
3487 match (*pk)? {
3488 ParamKind::Scalar(SlotKind::Int) => ps.push(Kind::Int),
3489 ParamKind::Scalar(SlotKind::Bool) => ps.push(Kind::Bool),
3490 ParamKind::Scalar(SlotKind::Float) => ps.push(Kind::Float),
3491 // A list param: the callee mutates the shared buffer in place.
3492 // Sound iff the callee is stable (no realloc) — the pinned arg's
3493 // contents ride the deopt snapshot/rollback, and the region
3494 // releases its borrow so the callee can access it.
3495 ParamKind::List(elem) => {
3496 if !leverb_on || !sig.list_params_stable {
3497 if std::env::var_os("LOGOS_RDIAG").is_some() {
3498 eprintln!("RDIAG-BAIL head_pc={head_pc} list-param-call");
3499 }
3500 return None;
3501 }
3502 ps.push(list_kind(elem)?);
3503 }
3504 }
3505 }
3506 Some((ret, ps))
3507 };
3508 let Some(kin) = kind_flow(ops, head_pc, constants, entry, Some(&resolve)) else {
3509 if std::env::var_os("LOGOS_RDIAG").is_some() {
3510 eprintln!("RDIAG-BAIL head_pc={head_pc} kind_flow");
3511 }
3512 return None;
3513 };
3514
3515 // Write-back kinds come from the EXIT EDGES: for each written slot, join
3516 // its kind at every reachable out-of-region jump. Mixed (exits disagree)
3517 // bails; Unknown means no completing path writes it — leave the VM
3518 // register untouched (exactly what bytecode reaching the exit would do).
3519 let mut exit_kind: Vec<Kind> = vec![Kind::Unknown; nregs];
3520 for (i, op) in ops.iter().enumerate() {
3521 let Some(k) = &kin[i] else { continue };
3522 let exits_here = match *op {
3523 Op::Jump { target } => !in_region(target),
3524 Op::JumpIfFalse { target, .. }
3525 | Op::JumpIfTrue { target, .. }
3526 => !in_region(target),
3527 // An in-region Return leaves the loop too — its named writes
3528 // flow through the same write-back.
3529 Op::Return { .. } => true,
3530 _ => false,
3531 };
3532 if exits_here {
3533 for (e, kk) in exit_kind.iter_mut().zip(k) {
3534 *e = join(*e, *kk);
3535 }
3536 }
3537 }
3538 let mut write_set: Vec<(u16, SlotKind)> = Vec::new();
3539 for &r in &writes {
3540 // Scratches are unobservable after the region — never written back,
3541 // and their exit kind (often Mixed: the compiler reuses them across
3542 // Int and Bool statements) is irrelevant.
3543 if !is_named(r) {
3544 continue;
3545 }
3546 // LEVER B: a pinned array REBOUND to a return-aliased call result
3547 // (`arr = siftDown(arr, …)`) carries a list exit-kind, but it rides the
3548 // pin channel, not the scalar write-back: the callee returns its own
3549 // argument (`returns_list_param`), so the VM register already holds the
3550 // correct Rc and no write-back is owed. Skip it (else `slot_kind()` on a
3551 // list kind below would bail the region).
3552 if leverb_on && array_pins.iter().any(|a| a.reg == r) {
3553 continue;
3554 }
3555 match exit_kind[r as usize] {
3556 Kind::Unknown => {}
3557 // A Mixed exit-kind means the compiler REUSED this register across
3558 // an Int and a Float on different paths — it is never a single
3559 // typed observable variable. If it is NOT loop-carried (not live-in
3560 // at the region head, per the region's own backward liveness) it is
3561 // a per-iteration scratch, dead at the region boundary: skip its
3562 // write-back rather than decline the whole region (the VM's stale
3563 // register value is unobservable — the scratch is recomputed every
3564 // iteration). A genuinely loop-carried Mixed slot still bails.
3565 Kind::Mixed => {
3566 if live[0][r as usize] {
3567 if std::env::var_os("LOGOS_RDIAG").is_some() {
3568 eprintln!("RDIAG-BAIL head_pc={head_pc} mixed-write-set slot={r}");
3569 }
3570 return None;
3571 }
3572 }
3573 // A text kind has no scalar write-back representation. The pinned
3574 // `TextMut` accumulator already rode the `array_pins` skip above; a
3575 // `TextByte`/`TextConst` here is a per-iteration char/const scratch
3576 // (the `ch` selection, a literal load) — dead at the region boundary
3577 // when NOT loop-carried, so skip its write-back exactly like a
3578 // non-loop-carried Mixed scratch. A genuinely loop-carried text
3579 // (live-in at the head, e.g. an unpinned Text variable) still bails.
3580 Kind::TextByte | Kind::TextConst | Kind::TextMut => {
3581 if live[0][r as usize] {
3582 if std::env::var_os("LOGOS_RDIAG").is_some() {
3583 eprintln!("RDIAG-BAIL head_pc={head_pc} text-write-set-loop-carried slot={r} kind={:?}", exit_kind[r as usize]);
3584 }
3585 return None;
3586 }
3587 }
3588 k => {
3589 let Some(sk) = k.slot_kind() else {
3590 if std::env::var_os("LOGOS_RDIAG").is_some() {
3591 eprintln!("RDIAG-BAIL head_pc={head_pc} write-set-no-slotkind r={r} kind={k:?}");
3592 }
3593 return None;
3594 };
3595 write_set.push((r, sk));
3596 }
3597 }
3598 }
3599
3600 let ret_base = pin_base + 3 * array_pins.len() as u16;
3601 let region_return_slots = has_return.then_some((ret_base, ret_base + 1));
3602
3603 // The agreed return kind across every reachable Return site.
3604 use logicaffeine_compile::vm::RegionReturnKind;
3605 let mut ret_kind: Option<RegionReturnKind> = None;
3606 if has_return {
3607 for (i, op) in ops.iter().enumerate() {
3608 let Some(k) = &kin[i] else { continue };
3609 if let Op::Return { src } = *op {
3610 let rk = match k[src as usize] {
3611 Kind::IntList | Kind::FloatList | Kind::BoolList => {
3612 RegionReturnKind::Register
3613 }
3614 other => RegionReturnKind::Slot(other.slot_kind()?),
3615 };
3616 match ret_kind {
3617 None => ret_kind = Some(rk),
3618 Some(prev) if prev == rk => {}
3619 _ => return None,
3620 }
3621 }
3622 }
3623 // A region whose every Return is unreachable: treat as returnless.
3624 if ret_kind.is_none() {
3625 return None;
3626 }
3627 }
3628 let region_return = match (region_return_slots, ret_kind) {
3629 (Some((flag, val)), Some(kind)) => {
3630 Some(logicaffeine_compile::vm::RegionReturn { flag_slot: flag, value_slot: val, kind })
3631 }
3632 _ => None,
3633 };
3634
3635 // Emit: out-of-region jumps go to the synthetic exit terminal. Adjacent
3636 // cmp + conditional-jump pairs whose scratch is dead FUSE into one
3637 // Branch micro-op (both VM pcs map to it). Two scratch slots past the
3638 // register file carry Int→Float promotions; two more per pinned array
3639 // carry its buffer pointer and length.
3640 let has_calls = ops.iter().any(|op| matches!(op, Op::Call { .. }));
3641 let frame_size = register_count as usize
3642 + 2
3643 + 3 * array_pins.len()
3644 + if has_return { 2 } else { 0 }
3645 + if has_calls { 1 } else { 0 };
3646 // The arena-limit slot is LAST; callees window immediately past the
3647 // frame (region frames get real arena headroom when they call).
3648 let limit_slot = (frame_size - 1) as u16;
3649 let jump_targets: std::collections::HashSet<usize> = ops
3650 .iter()
3651 .filter_map(|op| match *op {
3652 Op::Jump { target }
3653 | Op::JumpIfFalse { target, .. }
3654 | Op::JumpIfTrue { target, .. }
3655 => Some(target),
3656 _ => None,
3657 })
3658 .collect();
3659 let scratch_ok = |r: u16| !is_named(r);
3660 let conv = (register_count, register_count + 1);
3661 let mut micro: Vec<MicroOp> = Vec::with_capacity(ops.len());
3662 let mut pc_to_micro: Vec<usize> = Vec::with_capacity(ops.len());
3663 let mut fixups: Vec<usize> = Vec::new();
3664
3665 // NAIVE SUBSTRING-SEARCH COLLAPSE (string_search): if the WHOLE outer-loop
3666 // region is the exact naive-search nest over two pinned ASCII `TextBytes`
3667 // buffers (text + needle), replace the per-byte nested loop with ONE
3668 // `MemMem` helper call. The helper counts overlapping matches, adds them to
3669 // `count`, and advances `i` to the loop's exit value — bit-identical to the
3670 // nest, with a deopt exit for the one recoverable disagreement (a checked
3671 // needle index past the needle buffer). Gated on: no return, no precise
3672 // requirement, both `text` and `needle` pinned as `TextBytes`, and `count`/
3673 // `i`/`needleLen` carried as plain Int write-back/guard slots.
3674 if !has_return && !needs_precise {
3675 if let Some(shape) = match_memmem_nest(ops, head_pc, exit_pc, constants) {
3676 let text_pin = pins.get(&shape.text_reg);
3677 let needle_pin = pins.get(&shape.needle_reg);
3678 if let (Some(tp), Some(np)) = (text_pin, needle_pin) {
3679 let both_text = tp.elem == PinElem::TextBytes && np.elem == PinElem::TextBytes;
3680 // The accumulators must be plain Int write-back slots (the nest's
3681 // `count`/`i`); `needleLen` must be a guarded Int input. If the
3682 // shape's registers don't line up as Int the recognizer declines.
3683 let count_int = write_set
3684 .iter()
3685 .any(|&(r, k)| r == shape.count_reg && k == SlotKind::Int);
3686 let i_int = write_set
3687 .iter()
3688 .any(|&(r, k)| r == shape.i_reg && k == SlotKind::Int);
3689 let needle_len_int = guard_kinds
3690 .iter()
3691 .any(|&(r, k)| r == shape.needle_len_reg && k == SlotKind::Int);
3692 if both_text && count_int && i_int && needle_len_int {
3693 let mut mm: Vec<MicroOp> = Vec::with_capacity(2);
3694 mm.push(MicroOp::MemMem {
3695 h_ptr_slot: tp.ptr_slot,
3696 h_len_slot: tp.len_slot,
3697 n_ptr_slot: np.ptr_slot,
3698 n_len_slot: np.len_slot,
3699 needle_len_slot: shape.needle_len_reg,
3700 i_slot: shape.i_reg,
3701 count_slot: shape.count_reg,
3702 helper_addr: logos_rt_memmem_frame as usize as i64,
3703 });
3704 mm.push(MicroOp::Return { src: 0 });
3705 // Only `count` and `i` are observable after the loop (they are
3706 // the nest's loop-carried write-back); restrict the write-set
3707 // to them so nothing stale is copied back.
3708 let mm_write_set: Vec<(u16, SlotKind)> = write_set
3709 .iter()
3710 .copied()
3711 .filter(|&(r, _)| r == shape.count_reg || r == shape.i_reg)
3712 .collect();
3713 return Some((
3714 mm,
3715 frame_size,
3716 guard_kinds,
3717 free,
3718 mm_write_set,
3719 array_pins,
3720 None,
3721 hoist_guards,
3722 None,
3723 ));
3724 }
3725 }
3726 }
3727 }
3728
3729 // The frame buffer is reused across entries — the return flag must be
3730 // explicitly cleared at the head of every run.
3731 if let Some((flag, _)) = region_return_slots {
3732 micro.push(MicroOp::LoadConst { dst: flag, value: 0 });
3733 }
3734
3735 let mut i = 0usize;
3736 while i < ops.len() {
3737 // Region call: the callee windows at base + frame_size (disjoint
3738 // from registers, pins, return and limit slots), arguments copied
3739 // into the fresh window. Entry deopt (callee not yet compiled,
3740 // depth, arena) writes the PLAIN marker — region replay is sound
3741 // because the resolver admitted only scalar-pure callees.
3742 if let Op::Call { dst, args_start, arg_count, func } = ops[i] {
3743 if kin[i].is_some() {
3744 if resolve(func).is_none() {
3745 if std::env::var_os("LOGOS_RDIAG").is_some() {
3746 let s = callees.get(func as usize);
3747 eprintln!(
3748 "RDIAG-BAIL head_pc={head_pc} lowering-resolve func={func} stable={:?} retparam={:?} ret={:?}",
3749 s.map(|x| x.list_params_stable),
3750 s.map(|x| x.returns_list_param),
3751 s.map(|x| x.ret),
3752 );
3753 }
3754 return None;
3755 }
3756 pc_to_micro.push(micro.len());
3757 let window = frame_size as u16;
3758 for j in 0..arg_count {
3759 micro.push(MicroOp::Move { dst: window + j, src: args_start + j });
3760 }
3761 micro.push(MicroOp::Call {
3762 dst,
3763 args_start: window,
3764 table_addr: ctx.table.slot_addr(func as usize),
3765 depth_addr: std::sync::Arc::as_ptr(&ctx.depth) as i64,
3766 status_addr: std::sync::Arc::as_ptr(&ctx.status) as i64,
3767 limit_slot,
3768 depth_limit: logicaffeine_compile::semantics::MAX_CALL_DEPTH as i64,
3769 });
3770 } else {
3771 pc_to_micro.push(micro.len());
3772 }
3773 i += 1;
3774 continue;
3775 }
3776 let Some(advance) = translate_op(
3777 ops,
3778 i,
3779 head_pc,
3780 constants,
3781 &kin,
3782 &live,
3783 &jump_targets,
3784 &scratch_ok,
3785 conv,
3786 &pins,
3787 None,
3788 region_return_slots,
3789 false,
3790 true,
3791 &mut micro,
3792 &mut pc_to_micro,
3793 &mut fixups,
3794 ) else {
3795 if std::env::var_os("LOGOS_RDIAG").is_some() {
3796 eprintln!("RDIAG-BAIL head_pc={head_pc} translate_op-failed op={:?}", ops[i]);
3797 }
3798 return None;
3799 };
3800 i += advance;
3801 }
3802 // Out-of-region jumps route to the synthetic exit terminal.
3803 let exit_micro = micro.len();
3804 micro.push(MicroOp::Return { src: 0 });
3805 let _ = exit_pc;
3806 for &fi in &fixups {
3807 match &mut micro[fi] {
3808 MicroOp::Jump { target }
3809 | MicroOp::JumpIfFalse { target, .. }
3810 | MicroOp::JumpIfTrue { target, .. }
3811 | MicroOp::Branch { target, .. }
3812 | MicroOp::BranchF { target, .. } => {
3813 *target = if in_region(*target) {
3814 *pc_to_micro.get(*target - head_pc)?
3815 } else {
3816 exit_micro
3817 };
3818 }
3819 _ => unreachable!(),
3820 }
3821 }
3822 // A push+SetIndex region is admitted ONLY with PRECISE deopt: on a side
3823 // exit the VM materializes the frame's scalars back into the VM registers
3824 // and resumes AT the faulting op. Materialization re-boxes each register by
3825 // its kind at that op (from the kind flow) — Int/Bool/Float by value;
3826 // LIST/MAP and read-only/unknown registers are KEPT (the VM register holds
3827 // the live value: a pinned array is mutated in place through its pins, and a
3828 // read-only slot still holds its entry value). The ONE thing that breaks
3829 // "keep" is a list/map REBIND — a def whose value is a collection, which
3830 // would leave the VM register holding the stale Rc. In-place array
3831 // mutations (`ListPush`/`SetIndex`) are USES of the handle, never defs, so
3832 // they never appear in `writes`; a genuine rebind does, and disqualifies.
3833 let array_reg_set: std::collections::HashSet<u16> =
3834 array_pins.iter().map(|a| a.reg).collect();
3835 let rebinds_collection = writes.iter().any(|&r| {
3836 !array_reg_set.contains(&r)
3837 && matches!(
3838 exit_kind[r as usize],
3839 Kind::IntList | Kind::FloatList | Kind::BoolList | Kind::IntMap
3840 )
3841 });
3842 let precise_region = needs_precise && !rebinds_collection;
3843 if needs_precise && !precise_region {
3844 if std::env::var_os("LOGOS_RDIAG").is_some() {
3845 eprintln!("RDIAG-BAIL head_pc={head_pc} needs-precise-but-rebinds-collection");
3846 }
3847 return None;
3848 }
3849 // Per-op resume codes + per-op materialization kinds. Each micro of bytecode
3850 // op `opi` carries `((head_pc+opi) << 2) | 3`; the matching `kinds_by_pc`
3851 // row says how to re-box every register when the VM resumes there.
3852 let precise = if precise_region {
3853 let mut codes = vec![1i64; micro.len()];
3854 let mut kinds_by_pc: std::collections::HashMap<usize, Vec<Option<SlotKind>>> =
3855 std::collections::HashMap::new();
3856 for opi in 0..ops.len() {
3857 let lo = pc_to_micro[opi];
3858 let hi = pc_to_micro.get(opi + 1).copied().unwrap_or(micro.len());
3859 let resume_pc = head_pc + opi;
3860 let code = ((resume_pc as i64) << 2) | 3;
3861 for c in codes.iter_mut().take(hi).skip(lo) {
3862 *c = code;
3863 }
3864 if let Some(k) = kin.get(opi).and_then(|x| x.as_ref()) {
3865 let row: Vec<Option<SlotKind>> = (0..register_count as usize)
3866 .map(|r| {
3867 if array_reg_set.contains(&(r as u16)) {
3868 return None; // pinned array — keep its live Rc
3869 }
3870 match k.get(r) {
3871 Some(Kind::Int) => Some(SlotKind::Int),
3872 Some(Kind::Bool) => Some(SlotKind::Bool),
3873 Some(Kind::Float) => Some(SlotKind::Float),
3874 // list/map/unknown/mixed read-only → keep VM value
3875 _ => None,
3876 }
3877 })
3878 .collect();
3879 kinds_by_pc.insert(resume_pc, row);
3880 }
3881 }
3882 Some(RegionPrecise { deopt_codes: codes, kinds_by_pc })
3883 } else {
3884 None
3885 };
3886 Some((
3887 micro,
3888 frame_size,
3889 guard_kinds,
3890 free,
3891 write_set,
3892 array_pins,
3893 region_return,
3894 hoist_guards,
3895 precise,
3896 ))
3897}
3898
3899/// Slots in the per-thread native call arena: deep self-recursion windows
3900/// callee frames upward through this one buffer (16 MiB); the call stencil's
3901/// arena-limit check deopts before any overflow.
3902const ARENA_SLOTS: usize = 1 << 21;
3903
3904struct ChainFn {
3905 chain: CompiledChain,
3906 limit_slot: usize,
3907 depth: std::sync::Arc<std::sync::atomic::AtomicI64>,
3908 ret: NativeRet,
3909 /// frame_size − 3: what the call stencil's bound math expects in the
3910 /// table's regcount slot (mode A: the plain register count).
3911 published_regc: i64,
3912 precise: Option<PreciseInfo>,
3913 stats: std::sync::Arc<RuntimeStats>,
3914}
3915
3916impl NativeFn for ChainFn {
3917 fn call(&self, args: &[i64], pins: &[i64], depth: usize) -> NativeOutcome {
3918 // One arena per thread: the outermost native call owns it for the
3919 // whole (possibly deeply recursive) run. Kind gates prove no slot is
3920 // read before it is written, so stale contents are unobservable.
3921 thread_local! {
3922 static ARENA: std::cell::RefCell<Vec<i64>> = const { std::cell::RefCell::new(Vec::new()) };
3923 }
3924 self.stats.entries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3925 ARENA.with(|f| {
3926 let mut frame = f.borrow_mut();
3927 if frame.len() < ARENA_SLOTS {
3928 frame.resize(ARENA_SLOTS, 0);
3929 }
3930 frame[..args.len()].copy_from_slice(args);
3931 if let Some(p) = &self.precise {
3932 for (k, chunk) in pins.chunks(3).enumerate() {
3933 let base = p.param_pin_scatter[k] as usize;
3934 frame[base..base + 3].copy_from_slice(chunk);
3935 }
3936 }
3937 let arena_end = unsafe { frame.as_ptr().add(ARENA_SLOTS) } as i64;
3938 frame[self.limit_slot] = arena_end;
3939 self.depth.store(depth as i64, std::sync::atomic::Ordering::Relaxed);
3940 match self.chain.run_with_frame(&mut frame) {
3941 ChainOutcome::Return(v) => {
3942 self.stats
3943 .completions
3944 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3945 let out = if matches!(self.ret, NativeRet::ListByHandle) {
3946 match alloc_registry_detach(v) {
3947 // A fresh, registry-owned list: re-box here.
3948 Some(vec) => NativeOutcome::ReturnValue(
3949 logicaffeine_compile::vm::Value::int_list(vec),
3950 ),
3951 // A param passthrough: the VM matches the
3952 // handle against its pin arguments.
3953 None => NativeOutcome::Return(v),
3954 }
3955 } else {
3956 NativeOutcome::Return(v)
3957 };
3958 alloc_registry_drain();
3959 out
3960 }
3961 ChainOutcome::Deopt(raw) => {
3962 if std::env::var_os("LOGOS_JIT_TRACE").is_some() {
3963 let arena_end = unsafe { frame.as_ptr().add(ARENA_SLOTS) } as i64;
3964 eprintln!(
3965 "jit-trace: fn deopt raw={raw:#x} depth={depth} limit_slot={} \
3966 frame[limit]={:#x} arena_end={:#x} delta={}",
3967 self.limit_slot,
3968 frame[self.limit_slot],
3969 arena_end,
3970 arena_end - frame[self.limit_slot],
3971 );
3972 }
3973 let out = if raw & 3 == 3 {
3974 self.stats
3975 .deopt_ats
3976 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3977 let pc = ((raw & 0xFFFF_FFFF) >> 2) as usize;
3978 let frame_count = ((raw >> 32) - depth as i64 + 1) as usize;
3979 self.materialize(pc, frame_count, &frame, pins)
3980 } else {
3981 self.stats
3982 .deopts
3983 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3984 NativeOutcome::Deopt
3985 };
3986 alloc_registry_drain();
3987 out
3988 }
3989 }
3990 })
3991 }
3992 fn ret(&self) -> NativeRet {
3993 self.ret
3994 }
3995 fn entry_ptr(&self) -> i64 {
3996 self.chain.base() as i64
3997 }
3998 fn published_regc(&self) -> i64 {
3999 self.published_regc
4000 }
4001}
4002
4003impl ChainFn {
4004 /// Precise deopt: walk the active plant chain (every frame's window
4005 /// plant is −1 unless a call is in flight) and package each native
4006 /// frame with its pause-point re-box kinds — the innermost pauses at
4007 /// the faulting pc, every ancestor at its own Call op.
4008 fn materialize(
4009 &self,
4010 pc: usize,
4011 frame_count: usize,
4012 frame: &[i64],
4013 pins: &[i64],
4014 ) -> NativeOutcome {
4015 let p = self
4016 .precise
4017 .as_ref()
4018 .expect("precise deopt status from a mode-A chain");
4019 let rc = p.register_count;
4020 // Exactly `frame_count` frames are live (the depth rode the status
4021 // value): a call-entry deopt leaves the innermost frame's plant
4022 // freshly written with a callee that never materialized, so the
4023 // plant chain alone cannot terminate the walk.
4024 let mut raw: Vec<(usize, i64, usize, u16)> = Vec::with_capacity(frame_count);
4025 let mut base = 0usize;
4026 for k in 0..frame_count {
4027 let window = frame[base + rc + 2];
4028 raw.push((
4029 base,
4030 window,
4031 frame[base + rc + 3] as usize,
4032 frame[base + rc + 4] as u16,
4033 ));
4034 if k + 1 < frame_count {
4035 assert!(
4036 window > 0,
4037 "corrupt plant chain: window {window} at depth {k} (pc {pc})"
4038 );
4039 base += window as usize;
4040 }
4041 }
4042 let last = raw.len() - 1;
4043 let mut frames = Vec::with_capacity(raw.len());
4044 for (k, &(b, _, my_resume, _)) in raw.iter().enumerate() {
4045 let pause_pc = if k == last { pc } else { my_resume - 1 };
4046 let mut kinds = p
4047 .kinds_by_pc
4048 .get(&pause_pc)
4049 .unwrap_or_else(|| panic!("missing kind capture for pc {pause_pc}"))
4050 .clone();
4051 // Native-owned lists survive the deopt: detach fresh ones into
4052 // real values; param passthroughs rewrite to their argument.
4053 let mut resolved: Vec<(u16, logicaffeine_compile::vm::Value)> = Vec::new();
4054 if let Some(owned) = p.owned_by_pc.get(&pause_pc) {
4055 for &(reg, vslot) in owned {
4056 let handle = frame[b + vslot as usize];
4057 if let Some(vec) = alloc_registry_detach(handle) {
4058 kinds[reg as usize] = RegBox::Resolved;
4059 resolved.push((reg, logicaffeine_compile::vm::Value::int_list(vec)));
4060 } else {
4061 // A param's vec: match the boundary pin handles.
4062 let mut hit = false;
4063 for (j, chunk) in pins.chunks(3).enumerate() {
4064 if chunk.first() == Some(&handle) {
4065 kinds[reg as usize] = RegBox::ListParam(j as u8);
4066 hit = true;
4067 break;
4068 }
4069 }
4070 if !hit {
4071 kinds[reg as usize] = RegBox::Dead;
4072 }
4073 }
4074 }
4075 }
4076 let (offset, return_pc, return_reg) = if k == 0 {
4077 (0, 0, 0)
4078 } else {
4079 let prev = raw[k - 1];
4080 (prev.1 as usize, prev.2, prev.3)
4081 };
4082 frames.push(NativeFrame {
4083 offset,
4084 return_pc,
4085 return_reg,
4086 regs: frame[b..b + rc].to_vec(),
4087 kinds,
4088 resolved,
4089 });
4090 }
4091 NativeOutcome::DeoptAt { resume_pc: pc, frames }
4092 }
4093}
4094
4095pub static REGION_RUNS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
4096pub static REGION_DEOPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
4097
4098struct RegionChain {
4099 chain: CompiledChain,
4100 frame_size: usize,
4101 /// Set when the region CALLS functions: arena headroom for callee
4102 /// windows, the limit slot to plant, and the live depth cell.
4103 call_support: Option<(usize, u16, std::sync::Arc<std::sync::atomic::AtomicI64>)>,
4104 guard: Vec<(u16, SlotKind)>,
4105 free: Vec<u16>,
4106 writes: Vec<(u16, SlotKind)>,
4107 arrays: Vec<ArrayPin>,
4108 region_return: Option<logicaffeine_compile::vm::RegionReturn>,
4109 hoist_guards: Vec<HoistGuard>,
4110 /// PRECISE region deopt: when `Some`, the chain was compiled with per-op
4111 /// `deopt_codes`, so a side exit stores `(resume_pc << 2) | 3` (low 32 bits)
4112 /// in the status cell — decode it to a precise `DeoptAt` and look the
4113 /// per-register re-box kinds up here by resume pc. `None` ⇒ the classic
4114 /// plain-replay region.
4115 precise_kinds: Option<std::collections::HashMap<usize, Vec<Option<SlotKind>>>>,
4116}
4117
4118impl RegionFn for RegionChain {
4119 fn guard_set(&self) -> &[(u16, SlotKind)] {
4120 &self.guard
4121 }
4122 fn free_set(&self) -> &[u16] {
4123 &self.free
4124 }
4125 fn write_set(&self) -> &[(u16, SlotKind)] {
4126 &self.writes
4127 }
4128 fn array_set(&self) -> &[ArrayPin] {
4129 &self.arrays
4130 }
4131 fn region_return(&self) -> Option<logicaffeine_compile::vm::RegionReturn> {
4132 self.region_return
4133 }
4134 fn hoist_guards(&self) -> &[HoistGuard] {
4135 &self.hoist_guards
4136 }
4137 fn frame_size(&self) -> usize {
4138 self.frame_size
4139 }
4140 fn arena_slots(&self) -> usize {
4141 self.call_support.as_ref().map(|(a, _, _)| *a).unwrap_or(0)
4142 }
4143 fn precise_kinds(&self, resume_pc: usize) -> Option<&[Option<SlotKind>]> {
4144 self.precise_kinds
4145 .as_ref()
4146 .and_then(|m| m.get(&resume_pc))
4147 .map(|v| v.as_slice())
4148 }
4149 fn run(&self, frame: &mut [i64], depth: usize) -> RegionOutcome {
4150 if let Some((_, limit_slot, depth_cell)) = &self.call_support {
4151 let arena_end = unsafe { frame.as_ptr().add(frame.len()) } as i64;
4152 frame[*limit_slot as usize] = arena_end;
4153 depth_cell.store(depth as i64, std::sync::atomic::Ordering::Relaxed);
4154 }
4155 match self.chain.run_with_frame(frame) {
4156 ChainOutcome::Return(_) => {
4157 REGION_RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4158 RegionOutcome::Completed
4159 }
4160 ChainOutcome::Deopt(raw) => {
4161 REGION_DEOPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4162 // Precise chains tag every side exit with `(resume_pc << 2) | 3`
4163 // in the low 32 bits; a plain chain's terminal stores a value
4164 // whose low 2 bits are not 3. Resume precisely when tagged.
4165 if self.precise_kinds.is_some() && (raw & 0b11) == 0b11 {
4166 let resume_pc = ((raw & 0xFFFF_FFFF) >> 2) as usize;
4167 RegionOutcome::DeoptAt { resume_pc }
4168 } else {
4169 RegionOutcome::Deopt
4170 }
4171 }
4172 }
4173 }
4174}
4175
4176/// Runtime boundary counters shared by every ChainFn a tier produces.
4177/// Compilation is not execution: these count what the native code actually
4178/// DID — entries across the VM boundary, completed returns, and side exits.
4179#[derive(Default)]
4180struct RuntimeStats {
4181 entries: std::sync::atomic::AtomicU64,
4182 completions: std::sync::atomic::AtomicU64,
4183 deopts: std::sync::atomic::AtomicU64,
4184 deopt_ats: std::sync::atomic::AtomicU64,
4185}
4186
4187/// The forge-backed native tier, with compile observability.
4188#[derive(Default)]
4189pub struct ForgeTier {
4190 compiles: AtomicU32,
4191 successes: AtomicU32,
4192 region_compiles: AtomicU32,
4193 region_successes: AtomicU32,
4194 /// Region compiles whose chain came from the CONTIGUOUS register-allocating
4195 /// backend (`compile_region_regalloc`) rather than a per-piece stencil tier
4196 /// — the observable that WS-G's backend actually fired on a region.
4197 regalloc_regions: AtomicU32,
4198 /// PRECISE region compiles whose chain came from the contiguous register-
4199 /// allocating backend (`compile_region_regalloc_precise`) rather than the
4200 /// per-piece precise stencil tier (`compile_straightline_coded`). A precise
4201 /// region is the in-place-mutation + reallocating-push shape (the fannkuch /
4202 /// graph_bfs worklist). Nonzero proves the Wave 21 precise-region regalloc
4203 /// path fired — distinct from `regalloc_regions`, which counts a program's
4204 /// non-precise loops too.
4205 regalloc_precise_regions: AtomicU32,
4206 /// FUNCTION compiles whose chain came from the contiguous register-allocating
4207 /// backend (`compile_function_regalloc`) — the observable that the recursion
4208 /// cluster (self-calls) now goes through the regalloc backend instead of the
4209 /// per-piece stencil tier.
4210 regalloc_functions: AtomicU32,
4211 /// Fused pinned-argument self-calls (`CallSelfCopy`) emitted across all
4212 /// function compiles — the observable that Lever A fired.
4213 pinned_self_calls: AtomicU32,
4214 runtime: std::sync::Arc<RuntimeStats>,
4215}
4216
4217impl ForgeTier {
4218 /// A fresh tier with zeroed counters.
4219 pub fn new() -> Self {
4220 ForgeTier::default()
4221 }
4222
4223 /// (attempts, successes) for FUNCTION compiles.
4224 pub fn function_counts(&self) -> (u32, u32) {
4225 (self.compiles.load(Ordering::SeqCst), self.successes.load(Ordering::SeqCst))
4226 }
4227
4228 /// The number of fused pinned-argument self-calls (`CallSelfCopy`)
4229 /// emitted across every function compile — Lever A's observable. A
4230 /// nonzero value proves a scalar self-call took the fused ABI rather
4231 /// than per-argument frame `Move` staging.
4232 pub fn pinned_self_call_count(&self) -> u32 {
4233 self.pinned_self_calls.load(Ordering::SeqCst)
4234 }
4235
4236 /// (attempts, successes) for Main-loop REGION compiles.
4237 pub fn region_counts(&self) -> (u32, u32) {
4238 (
4239 self.region_compiles.load(Ordering::SeqCst),
4240 self.region_successes.load(Ordering::SeqCst),
4241 )
4242 }
4243
4244 /// How many region compiles used the CONTIGUOUS register-allocating backend
4245 /// (WS-G `compile_region_regalloc`) rather than a per-piece stencil tier.
4246 /// Nonzero proves the regalloc backend fired on a real program's hot loop.
4247 pub fn regalloc_region_count(&self) -> u32 {
4248 self.regalloc_regions.load(Ordering::SeqCst)
4249 }
4250
4251 /// How many PRECISE region compiles used the contiguous register-allocating
4252 /// backend (`compile_region_regalloc_precise`) rather than the per-piece
4253 /// precise stencil tier. Nonzero proves the in-place-mutation + reallocating-
4254 /// push shape (fannkuch / graph_bfs worklist) tiered through the regalloc
4255 /// backend — Wave 21's lever.
4256 pub fn regalloc_precise_region_count(&self) -> u32 {
4257 self.regalloc_precise_regions.load(Ordering::SeqCst)
4258 }
4259
4260 /// How many FUNCTION compiles used the contiguous register-allocating backend
4261 /// (`compile_function_regalloc`) rather than the per-piece stencil tier.
4262 /// Nonzero proves a recursive (self-calling) function tiered through the
4263 /// regalloc backend — the recursion cluster's lever.
4264 pub fn regalloc_function_count(&self) -> u32 {
4265 self.regalloc_functions.load(Ordering::SeqCst)
4266 }
4267
4268 /// Runtime FUNCTION-boundary truth: (entries, completions, plain
4269 /// deopts, precise deopts). Entries count VM→native crossings — native
4270 /// self-recursion inside one entry is invisible here, which is exactly
4271 /// the point: a hot recursive function shows FEW entries.
4272 pub fn runtime_stats(&self) -> (u64, u64, u64, u64) {
4273 (
4274 self.runtime.entries.load(Ordering::SeqCst),
4275 self.runtime.completions.load(Ordering::SeqCst),
4276 self.runtime.deopts.load(Ordering::SeqCst),
4277 self.runtime.deopt_ats.load(Ordering::SeqCst),
4278 )
4279 }
4280}
4281
4282/// Loop-nesting depth of each micro op, from its enclosing back-edges (a
4283/// jump/branch whose target is at or before it opens a loop over
4284/// `[target, here]`). Depth is how many such loops contain an op — the
4285/// frequency proxy for region pin selection.
4286fn loop_depths(ops: &[MicroOp]) -> Vec<u32> {
4287 let n = ops.len();
4288 let mut depth = vec![0u32; n];
4289 for (i, op) in ops.iter().enumerate() {
4290 let target = match *op {
4291 MicroOp::Jump { target }
4292 | MicroOp::JumpIfFalse { target, .. }
4293 | MicroOp::JumpIfTrue { target, .. }
4294 | MicroOp::Branch { target, .. }
4295 | MicroOp::BranchF { target, .. } => Some(target),
4296 _ => None,
4297 };
4298 if let Some(t) = target {
4299 if t <= i && t < n {
4300 for d in depth.iter_mut().take(i + 1).skip(t) {
4301 *d += 1;
4302 }
4303 }
4304 }
4305 }
4306 depth
4307}
4308
4309/// Linear-scan pin selection. Each slot's profit sums its per-op deltas —
4310/// variant-family uses save a memory access (positive), mem-form uses force
4311/// a frame spill (negative).
4312///
4313/// `region` switches the FREQUENCY MODEL. A FUNCTION body is itself the hot
4314/// loop (recursion is a Call, not a back-edge), so every op counts equally
4315/// (the historical flat model — preserved exactly). A REGION's depth-0 ops
4316/// run once per entry while its INNER loops dominate, so each op is weighted
4317/// by `BASE^loop_depth` — a slot wins only when its register-resident
4318/// variant uses outweigh its spill costs AT THE FREQUENCY THEY EXECUTE.
4319/// (Without this, a counter feeding an inner-loop array INDEX — mem-form,
4320/// spills every iteration — looks neutral statically but is a net loss; see
4321/// histogram vs sieve.) Conversion scratches and beyond-register slots
4322/// never pin; the four most profitable net-positive slots win.
4323///
4324/// The slots a micro-op READS, by value (for read-counting). Call/CallSelf are
4325/// handled by the caller (copy-prop bails on any region containing one), so
4326/// their frame-window arg reads need no accounting here.
4327fn micro_read_slots(op: &MicroOp) -> Vec<u16> {
4328 use MicroOp::*;
4329 match *op {
4330 LoadConst { .. } | Jump { .. } | NewList { .. } => vec![],
4331 Move { src, .. } | DivPow2 { lhs: src, .. } | MagicDivU { lhs: src, .. } | NotInt { src, .. } | NotBool { src, .. }
4332 | IntToFloat { src, .. } | SqrtF { src, .. } | Return { src } | ListTriple { handle_slot: src, .. } => vec![src],
4333 JumpIfFalse { cond, .. } | JumpIfTrue { cond, .. } => vec![cond],
4334 Add { lhs, rhs, .. } | Sub { lhs, rhs, .. } | Mul { lhs, rhs, .. } | Div { lhs, rhs, .. }
4335 | Mod { lhs, rhs, .. } | Lt { lhs, rhs, .. } | Gt { lhs, rhs, .. } | Eq { lhs, rhs, .. }
4336 | LtEq { lhs, rhs, .. } | GtEq { lhs, rhs, .. } | Neq { lhs, rhs, .. } | BitAnd { lhs, rhs, .. }
4337 | BitOr { lhs, rhs, .. } | BitXor { lhs, rhs, .. } | Shl { lhs, rhs, .. } | Shr { lhs, rhs, .. }
4338 | Branch { lhs, rhs, .. } | AddF { lhs, rhs, .. } | SubF { lhs, rhs, .. } | MulF { lhs, rhs, .. }
4339 | DivF { lhs, rhs, .. } | LtF { lhs, rhs, .. } | GtF { lhs, rhs, .. } | LtEqF { lhs, rhs, .. }
4340 | GtEqF { lhs, rhs, .. } | EqF { lhs, rhs, .. } | NeqF { lhs, rhs, .. }
4341 | BranchF { lhs, rhs, .. } => vec![lhs, rhs],
4342 MapGet { key, map_slot, .. } | MapHas { key, map_slot, .. } => vec![key, map_slot],
4343 MapSet { src, key, map_slot, .. } => vec![src, key, map_slot],
4344 ArrLoad { idx, ptr_slot, len_slot, .. } => vec![idx, ptr_slot, len_slot],
4345 ArrLoadAffine { a, op, b, ptr_slot, len_slot, .. } => {
4346 if op == logicaffeine_forge::jit::AffOp::None {
4347 vec![a, ptr_slot, len_slot]
4348 } else {
4349 vec![a, b, ptr_slot, len_slot]
4350 }
4351 }
4352 ArrLoad2F { i, j, ptr_slot, len_slot, .. } => vec![i, j, ptr_slot, len_slot],
4353 ArrLoad2 { i, j, ptr_a, len_a, ptr_b, len_b, .. } => vec![i, j, ptr_a, len_a, ptr_b, len_b],
4354 ArrStore { src, idx, ptr_slot, len_slot, .. } => vec![src, idx, ptr_slot, len_slot],
4355 ArrRMW { idx, operand, ptr_slot, len_slot, .. } => vec![idx, operand, ptr_slot, len_slot],
4356 ArrCondSwap { idx1, idx2, ptr_slot, len_slot, .. } => vec![idx1, idx2, ptr_slot, len_slot],
4357 ArrSwap { idx1, idx2, ptr_slot, len_slot, .. } => vec![idx1, idx2, ptr_slot, len_slot],
4358 FmaF { a, b, c, .. } => vec![a, b, c],
4359 ArrPush { src, vec_slot, ptr_slot, len_slot, .. } => vec![src, vec_slot, ptr_slot, len_slot],
4360 ListClear { vec_slot, ptr_slot, len_slot, .. } => vec![vec_slot, ptr_slot, len_slot],
4361 StrAppend { text_handle_slot, src, .. } => match src {
4362 logicaffeine_forge::jit::StrSrc::Byte(s) => vec![text_handle_slot, s],
4363 logicaffeine_forge::jit::StrSrc::Const { .. } => vec![text_handle_slot],
4364 },
4365 MemMem {
4366 h_ptr_slot,
4367 h_len_slot,
4368 n_ptr_slot,
4369 n_len_slot,
4370 needle_len_slot,
4371 i_slot,
4372 count_slot,
4373 ..
4374 } => vec![
4375 h_ptr_slot,
4376 h_len_slot,
4377 n_ptr_slot,
4378 n_len_slot,
4379 needle_len_slot,
4380 i_slot,
4381 count_slot,
4382 ],
4383 Call { .. } | CallSelf { .. } | CallSelfCopy { .. } => vec![],
4384 }
4385}
4386
4387/// Mutable references to a micro-op's READ operands (for in-place copy
4388/// substitution). Mirrors [`micro_read_slots`].
4389fn micro_reads_mut(op: &mut MicroOp) -> Vec<&mut u16> {
4390 use MicroOp::*;
4391 match op {
4392 LoadConst { .. } | Jump { .. } | NewList { .. } | Call { .. } | CallSelf { .. }
4393 | CallSelfCopy { .. } => vec![],
4394 Move { src, .. } | DivPow2 { lhs: src, .. } | MagicDivU { lhs: src, .. } | NotInt { src, .. } | NotBool { src, .. }
4395 | IntToFloat { src, .. } | SqrtF { src, .. } | Return { src } | ListTriple { handle_slot: src, .. } => vec![src],
4396 JumpIfFalse { cond, .. } | JumpIfTrue { cond, .. } => vec![cond],
4397 Add { lhs, rhs, .. } | Sub { lhs, rhs, .. } | Mul { lhs, rhs, .. } | Div { lhs, rhs, .. }
4398 | Mod { lhs, rhs, .. } | Lt { lhs, rhs, .. } | Gt { lhs, rhs, .. } | Eq { lhs, rhs, .. }
4399 | LtEq { lhs, rhs, .. } | GtEq { lhs, rhs, .. } | Neq { lhs, rhs, .. } | BitAnd { lhs, rhs, .. }
4400 | BitOr { lhs, rhs, .. } | BitXor { lhs, rhs, .. } | Shl { lhs, rhs, .. } | Shr { lhs, rhs, .. }
4401 | Branch { lhs, rhs, .. } | AddF { lhs, rhs, .. } | SubF { lhs, rhs, .. } | MulF { lhs, rhs, .. }
4402 | DivF { lhs, rhs, .. } | LtF { lhs, rhs, .. } | GtF { lhs, rhs, .. } | LtEqF { lhs, rhs, .. }
4403 | GtEqF { lhs, rhs, .. } | EqF { lhs, rhs, .. } | NeqF { lhs, rhs, .. }
4404 | BranchF { lhs, rhs, .. } => vec![lhs, rhs],
4405 MapGet { key, map_slot, .. } | MapHas { key, map_slot, .. } => vec![key, map_slot],
4406 MapSet { src, key, map_slot, .. } => vec![src, key, map_slot],
4407 ArrLoad { idx, ptr_slot, len_slot, .. } => vec![idx, ptr_slot, len_slot],
4408 ArrLoadAffine { a, b, ptr_slot, len_slot, .. } => vec![a, b, ptr_slot, len_slot],
4409 ArrLoad2F { i, j, ptr_slot, len_slot, .. } => vec![i, j, ptr_slot, len_slot],
4410 ArrLoad2 { i, j, ptr_a, len_a, ptr_b, len_b, .. } => vec![i, j, ptr_a, len_a, ptr_b, len_b],
4411 ArrStore { src, idx, ptr_slot, len_slot, .. } => vec![src, idx, ptr_slot, len_slot],
4412 ArrRMW { idx, operand, ptr_slot, len_slot, .. } => vec![idx, operand, ptr_slot, len_slot],
4413 ArrCondSwap { idx1, idx2, ptr_slot, len_slot, .. } => vec![idx1, idx2, ptr_slot, len_slot],
4414 ArrSwap { idx1, idx2, ptr_slot, len_slot, .. } => vec![idx1, idx2, ptr_slot, len_slot],
4415 FmaF { a, b, c, .. } => vec![a, b, c],
4416 ArrPush { src, vec_slot, ptr_slot, len_slot, .. } => vec![src, vec_slot, ptr_slot, len_slot],
4417 ListClear { vec_slot, ptr_slot, len_slot, .. } => vec![vec_slot, ptr_slot, len_slot],
4418 StrAppend { text_handle_slot, src, .. } => match src {
4419 logicaffeine_forge::jit::StrSrc::Byte(s) => vec![text_handle_slot, s],
4420 logicaffeine_forge::jit::StrSrc::Const { .. } => vec![text_handle_slot],
4421 },
4422 MemMem {
4423 h_ptr_slot,
4424 h_len_slot,
4425 n_ptr_slot,
4426 n_len_slot,
4427 needle_len_slot,
4428 i_slot,
4429 count_slot,
4430 ..
4431 } => vec![
4432 h_ptr_slot,
4433 h_len_slot,
4434 n_ptr_slot,
4435 n_len_slot,
4436 needle_len_slot,
4437 i_slot,
4438 count_slot,
4439 ],
4440 }
4441}
4442
4443/// The slots a micro-op DEFINES (writes).
4444fn micro_defs(op: &MicroOp) -> Vec<u16> {
4445 use MicroOp::*;
4446 match *op {
4447 LoadConst { dst, .. } | Move { dst, .. } | Add { dst, .. } | Sub { dst, .. } | Mul { dst, .. }
4448 | Div { dst, .. } | DivPow2 { dst, .. } | MagicDivU { dst, .. } | Mod { dst, .. } | Lt { dst, .. } | Gt { dst, .. }
4449 | Eq { dst, .. } | LtEq { dst, .. } | GtEq { dst, .. } | Neq { dst, .. } | BitAnd { dst, .. }
4450 | BitOr { dst, .. } | BitXor { dst, .. } | Shl { dst, .. } | Shr { dst, .. } | NotInt { dst, .. }
4451 | NotBool { dst, .. } | AddF { dst, .. } | SubF { dst, .. } | MulF { dst, .. } | DivF { dst, .. }
4452 | LtF { dst, .. } | GtF { dst, .. } | LtEqF { dst, .. } | GtEqF { dst, .. } | EqF { dst, .. }
4453 | NeqF { dst, .. } | IntToFloat { dst, .. } | SqrtF { dst, .. } | ArrLoad { dst, .. }
4454 | ArrLoadAffine { dst, .. }
4455 | ArrLoad2F { dst, .. } | ArrLoad2 { dst, .. } | FmaF { dst, .. } | MapGet { dst, .. } | MapHas { dst, .. }
4456 | Call { dst, .. } | CallSelf { dst, .. } | CallSelfCopy { dst, .. } => vec![dst],
4457 ArrPush { ptr_slot, len_slot, .. } => vec![ptr_slot, len_slot],
4458 ListClear { ptr_slot, len_slot, .. } => vec![ptr_slot, len_slot],
4459 NewList { vec_slot, ptr_slot, len_slot, .. } => vec![vec_slot, ptr_slot, len_slot],
4460 ListTriple { vec_slot, ptr_slot, len_slot, .. } => vec![vec_slot, ptr_slot, len_slot],
4461 MemMem { i_slot, count_slot, .. } => vec![i_slot, count_slot],
4462 Branch { .. } | BranchF { .. } | Jump { .. } | JumpIfFalse { .. } | JumpIfTrue { .. }
4463 | MapSet { .. } | ArrStore { .. } | ArrRMW { .. } | ArrCondSwap { .. } | ArrSwap { .. } | Return { .. }
4464 // The mutable-Text append writes the off-frame VM register cell (through
4465 // the planted `*mut Value`), not a frame slot — it defines NO frame slot.
4466 | StrAppend { .. } => vec![],
4467 }
4468}
4469
4470/// A mutable reference to a micro-op's branch/jump TARGET (a micro index), if any.
4471fn micro_target_mut(op: &mut MicroOp) -> Option<&mut usize> {
4472 use MicroOp::*;
4473 match op {
4474 Jump { target } | Branch { target, .. } | BranchF { target, .. }
4475 | JumpIfFalse { target, .. } | JumpIfTrue { target, .. } => Some(target),
4476 _ => None,
4477 }
4478}
4479
4480/// COPY PROPAGATION + dead-Move elimination over a region's micro stream — kills
4481/// the redundant copy chains the bytecode lowering leaves in hot loops (e.g.
4482/// mandelbrot's `t40←t36; t46←t40; zr←t46`, three Moves to land one value),
4483/// each of which is a wasted stencil dispatch every iteration. SOUNDNESS: it
4484/// only ever (1) rewrites a READ to a value provably equal to it within the
4485/// same basic block (copies are cleared at every jump target and across every
4486/// branch/jump), and (2) removes a `Move` whose destination is an UN-NAMED
4487/// scratch temp (never written back to a VM register — `named[d]` is false) and
4488/// has zero remaining reads. Named slots (loop-carried / observable) are never
4489/// touched. The caller restricts this to NON-PRECISE, CALL-FREE regions, so the
4490/// only index-bearing references that survive removal are jump targets, which
4491/// are remapped here; `deopt` is replay-from-head (no per-micro resume).
4492fn copy_propagate(
4493 micro: &mut Vec<MicroOp>,
4494 named: &[bool],
4495 register_count: u16,
4496 pinned: &std::collections::HashSet<u16>,
4497) {
4498 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
4499 return;
4500 }
4501 // A slot is REMOVABLE only if it is an un-named, un-PINNED scratch temp: its
4502 // value is never written back to a VM register (not named) and it is not
4503 // held live in a register by the pin allocator. This lets the pass run AFTER
4504 // pin selection without disturbing the chosen pins (named/pinned defining ops
4505 // and their reads are left intact).
4506 let removable = |slot: u16| -> bool {
4507 slot < register_count
4508 && !named.get(slot as usize).copied().unwrap_or(true)
4509 && !pinned.contains(&slot)
4510 };
4511 let n = micro.len();
4512 let mut leader = vec![false; n];
4513 for op in micro.iter() {
4514 if let Some(t) = micro_target_const(op) {
4515 if t < n {
4516 leader[t] = true;
4517 }
4518 }
4519 }
4520 // Phase 1: forward block-local value propagation. `root[d] = s` means slot d
4521 // currently holds an unmodified copy of slot s; reads of d are rewritten to
4522 // s. `const_holder[v] = slot` tracks the live slot holding integer-bits
4523 // constant `v`, so a re-`LoadConst` of the same value aliases the existing
4524 // holder (CONSTANT CSE — hot loops re-load the same small constants every
4525 // iteration). Both maps clear at block boundaries and invalidate when a
4526 // tracked slot is redefined.
4527 let mut root: std::collections::HashMap<u16, u16> = std::collections::HashMap::new();
4528 let mut const_holder: std::collections::HashMap<i64, u16> = std::collections::HashMap::new();
4529 // `value_holder[(tag, a, b)] = slot` — the live slot already holding the
4530 // result of that pure op over those operands. A redundant recomputation
4531 // (e.g. `v + 1` evaluated for both the load index and the store index of an
4532 // array RMW) aliases the existing holder, which (a) cuts a stencil dispatch
4533 // and (b) makes the two indices the SAME slot, letting `fuse_array_rmw`
4534 // collapse the load/store. Local value numbering: cleared at block
4535 // boundaries, invalidated the instant any operand or the holder is redefined.
4536 let mut value_holder: std::collections::HashMap<(u8, u16, u16), u16> =
4537 std::collections::HashMap::new();
4538 for i in 0..n {
4539 if leader[i] {
4540 root.clear();
4541 const_holder.clear();
4542 value_holder.clear();
4543 }
4544 for r in micro_reads_mut(&mut micro[i]) {
4545 if let Some(&s) = root.get(r) {
4546 *r = s;
4547 }
4548 }
4549 // Value key + prior holder, captured from the operands AS SUBSTITUTED
4550 // above and BEFORE this op's own def invalidates the table.
4551 let value_key = micro_pure_value_key(µ[i]);
4552 let cse_hit = value_key.and_then(|(k, _)| value_holder.get(&k).copied());
4553 let defs = micro_defs(µ[i]);
4554 for &d in &defs {
4555 root.retain(|k, v| *k != d && *v != d);
4556 const_holder.retain(|_, s| *s != d);
4557 value_holder.retain(|k, s| k.1 != d && k.2 != d && *s != d);
4558 }
4559 match micro[i] {
4560 MicroOp::Move { dst, src } if dst != src => {
4561 let sroot = root.get(&src).copied().unwrap_or(src);
4562 if sroot != dst {
4563 root.insert(dst, sroot);
4564 }
4565 }
4566 MicroOp::LoadConst { dst, value } => match const_holder.get(&value) {
4567 Some(&d2) if d2 != dst => {
4568 root.insert(dst, d2);
4569 }
4570 Some(_) => {}
4571 None => {
4572 const_holder.insert(value, dst);
4573 }
4574 },
4575 _ => {}
4576 }
4577 if let Some((k, dst)) = value_key {
4578 match cse_hit {
4579 Some(h) if h != dst => {
4580 root.insert(dst, h);
4581 }
4582 _ => {
4583 value_holder.insert(k, dst);
4584 }
4585 }
4586 }
4587 // NB: copies/CSE are NOT cleared after a branch. A branch defines no
4588 // slot, so its fall-through (a single-predecessor block) validly inherits
4589 // them; the only multi-predecessor blocks are JUMP TARGETS, which the
4590 // leader-clear at the top of the loop already resets. (Clearing here too
4591 // was overly conservative — it dropped copies that hold across the
4592 // compare-branch into a loop's conditional body, e.g. a sort's swap.)
4593 }
4594 // Phase 2: remove dead scratch Moves / LoadConsts / pure binops (0 remaining
4595 // reads after propagation), remapping jump targets after each removal.
4596 loop {
4597 let mut reads: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
4598 for op in micro.iter() {
4599 for s in micro_read_slots(op) {
4600 *reads.entry(s).or_insert(0) += 1;
4601 }
4602 }
4603 let dead = |dst: u16| removable(dst) && reads.get(&dst).copied().unwrap_or(0) == 0;
4604 let victim = micro.iter().position(|op| match *op {
4605 MicroOp::Move { dst, src } => dst != src && dead(dst),
4606 MicroOp::LoadConst { dst, .. } => dead(dst),
4607 _ => micro_pure_value_key(op).is_some_and(|(_, dst)| dead(dst)),
4608 });
4609 let Some(idx) = victim else { break };
4610 micro.remove(idx);
4611 for op in micro.iter_mut() {
4612 if let Some(t) = micro_target_mut(op) {
4613 if *t > idx {
4614 *t -= 1;
4615 }
4616 }
4617 }
4618 }
4619 // Phase 3: DEAD-STORE elimination — a pure def whose dst is REDEFINED before
4620 // it is ever read is dead even if the dst is read LATER (register reuse, e.g.
4621 // a sort's recomputed `j+1` index whose slot is reused for the loop-increment
4622 // constant). Global read-counting (phase 2) keeps these; a forward straight-
4623 // line scan removes them. Conservative: stop the scan at any control-flow op
4624 // or jump target (the value could be live on another path → keep). Only
4625 // un-named/un-pinned scratch (the `removable` slots) is eligible.
4626 loop {
4627 let mut victim = None;
4628 'scan: for i in 0..micro.len() {
4629 let d = match micro[i] {
4630 MicroOp::Move { dst, src } if dst != src => dst,
4631 MicroOp::LoadConst { dst, .. } => dst,
4632 ref other => match micro_pure_value_key(other) {
4633 Some((_, dst)) => dst,
4634 None => continue,
4635 },
4636 };
4637 if !removable(d) {
4638 continue;
4639 }
4640 // Walk forward: `d` is dead at `i` iff it is REDEFINED before any
4641 // read. We stop at a control-flow SOURCE op (a branch/jump could
4642 // carry `d`'s value to a taken path that reads it) — but NOT at a
4643 // mere jump TARGET: a leader that redefines `d` as its first action
4644 // kills every incoming `d`, so checking it is sound.
4645 for op in micro.iter().skip(i + 1) {
4646 if micro_read_slots(op).contains(&d) {
4647 break; // read before redef — live; keep.
4648 }
4649 if micro_defs(op).contains(&d) {
4650 victim = Some(i); // redefined before any read — op `i` is dead.
4651 break 'scan;
4652 }
4653 if micro_target_const(op).is_some() {
4654 break; // control transfers away — be conservative; keep.
4655 }
4656 }
4657 }
4658 let Some(idx) = victim else { break };
4659 micro.remove(idx);
4660 for op in micro.iter_mut() {
4661 if let Some(t) = micro_target_mut(op) {
4662 if *t > idx {
4663 *t -= 1;
4664 }
4665 }
4666 }
4667 }
4668}
4669
4670/// ARRAY READ-MODIFY-WRITE FUSION — collapses the `ArrLoad(t = arr[i]) ;
4671/// <int ALU>(t2 = t OP operand) ; ArrStore(arr[i] = t2)` idiom (histogram's
4672/// `counts[x] += 1`, counting sort, bitset marks) into ONE [`MicroOp::ArrRMW`]:
4673/// one stencil dispatch + one bounds check instead of three, and the element
4674/// never round-trips the frame. The JIT is per-op DISPATCH-bound, so collapsing
4675/// three hot ops into one is a direct win on the indexed-RMW loops that lose
4676/// worst to V8.
4677///
4678/// SOUNDNESS: the load and store hit the SAME pinned 8-byte array and the SAME
4679/// index slot (unchanged between them — the ALU only writes the scratch `t2`),
4680/// so the read cell and the written cell coincide. A single pre-write bounds
4681/// check (`checked = c1 || c2`) is bit-identical to the original pair because
4682/// both tested that same index. The fused op side-exits BEFORE any effect and,
4683/// occupying the ArrLoad's micro slot, replays all three ops from that bytecode
4684/// pc (region deopt is replay-from-head). The loaded value `t` and the result
4685/// `t2` must be single-use, un-named, un-pinned scratch (read only by the ALU /
4686/// the store) — exactly the staleness contract `copy_propagate` already relies
4687/// on, so a dead frame cell is never observably written back. None of the three
4688/// ops may be a jump target (no entry lands mid-idiom). Caller restricts this to
4689/// NON-PRECISE regions (removing micro ops renumbers jump targets — remapped
4690/// here — but would break per-op precise resume codes).
4691fn fuse_array_rmw(
4692 micro: &mut Vec<MicroOp>,
4693 named: &[bool],
4694 register_count: u16,
4695 pinned: &std::collections::HashSet<u16>,
4696) {
4697 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
4698 return;
4699 }
4700 let removable = |slot: u16, reads: &std::collections::HashMap<u16, usize>| -> bool {
4701 slot < register_count
4702 && !named.get(slot as usize).copied().unwrap_or(true)
4703 && !pinned.contains(&slot)
4704 && reads.get(&slot).copied().unwrap_or(0) == 1
4705 };
4706 loop {
4707 let n = micro.len();
4708 // Block leaders: an op any branch/jump lands on. Recomputed each round
4709 // (a fusion renumbers the stream).
4710 let mut is_target = vec![false; n];
4711 for op in micro.iter() {
4712 if let Some(t) = micro_target_const(op) {
4713 if t < n {
4714 is_target[t] = true;
4715 }
4716 }
4717 }
4718 let mut reads: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
4719 for op in micro.iter() {
4720 for s in micro_read_slots(op) {
4721 *reads.entry(s).or_insert(0) += 1;
4722 }
4723 }
4724 let mut hit: Option<(usize, MicroOp)> = None;
4725 for k in 0..n.saturating_sub(2) {
4726 // The ALU and the store must not be block leaders.
4727 if is_target[k + 1] || is_target[k + 2] {
4728 continue;
4729 }
4730 let MicroOp::ArrLoad { dst: t, idx: i, ptr_slot: p, len_slot: l, byte: false, narrow32: false, checked: c1 } =
4731 micro[k]
4732 else {
4733 continue;
4734 };
4735 // The ALU op: the loaded value `t` is one operand; the OTHER is the
4736 // RMW operand. Sub is non-commutative — fuse only when `t` is on the
4737 // left (`buf[i] - operand`).
4738 let (t2, op, operand) = match micro[k + 1] {
4739 MicroOp::Add { dst, lhs, rhs } if lhs == t => (dst, RmwOp::Add, rhs),
4740 MicroOp::Add { dst, lhs, rhs } if rhs == t => (dst, RmwOp::Add, lhs),
4741 MicroOp::Sub { dst, lhs, rhs } if lhs == t => (dst, RmwOp::Sub, rhs),
4742 MicroOp::Mul { dst, lhs, rhs } if lhs == t => (dst, RmwOp::Mul, rhs),
4743 MicroOp::Mul { dst, lhs, rhs } if rhs == t => (dst, RmwOp::Mul, lhs),
4744 MicroOp::BitAnd { dst, lhs, rhs } if lhs == t => (dst, RmwOp::And, rhs),
4745 MicroOp::BitAnd { dst, lhs, rhs } if rhs == t => (dst, RmwOp::And, lhs),
4746 MicroOp::BitOr { dst, lhs, rhs } if lhs == t => (dst, RmwOp::Or, rhs),
4747 MicroOp::BitOr { dst, lhs, rhs } if rhs == t => (dst, RmwOp::Or, lhs),
4748 MicroOp::BitXor { dst, lhs, rhs } if lhs == t => (dst, RmwOp::Xor, rhs),
4749 MicroOp::BitXor { dst, lhs, rhs } if rhs == t => (dst, RmwOp::Xor, lhs),
4750 // Float RMW (nbody's `v[i] = v[i] + dx*mag`): the array holds
4751 // f64 bits; the fused op reinterprets. Same commutativity rules.
4752 MicroOp::AddF { dst, lhs, rhs } if lhs == t => (dst, RmwOp::AddF, rhs),
4753 MicroOp::AddF { dst, lhs, rhs } if rhs == t => (dst, RmwOp::AddF, lhs),
4754 MicroOp::SubF { dst, lhs, rhs } if lhs == t => (dst, RmwOp::SubF, rhs),
4755 MicroOp::MulF { dst, lhs, rhs } if lhs == t => (dst, RmwOp::MulF, rhs),
4756 MicroOp::MulF { dst, lhs, rhs } if rhs == t => (dst, RmwOp::MulF, lhs),
4757 _ => continue,
4758 };
4759 let MicroOp::ArrStore { src, idx: i2, ptr_slot: p2, len_slot: l2, byte: false, narrow32: false, checked: c2 } =
4760 micro[k + 2]
4761 else {
4762 continue;
4763 };
4764 // Same array + index, and the store consumes exactly the ALU result.
4765 if src != t2 || i2 != i || p2 != p || l2 != l {
4766 continue;
4767 }
4768 // `t` and `t2` are single-use scratch we can erase; `operand` is
4769 // re-read by the fused op (so it must NOT be one of them), and the
4770 // index `i` likewise stays live.
4771 if !removable(t, &reads) || !removable(t2, &reads) || operand == t || operand == t2 {
4772 continue;
4773 }
4774 hit = Some((
4775 k,
4776 MicroOp::ArrRMW { idx: i, operand, ptr_slot: p, len_slot: l, op, checked: c1 || c2 },
4777 ));
4778 break;
4779 }
4780 let Some((k, rmw)) = hit else { break };
4781 if std::env::var_os("LOGOS_FUSE_TRACE").is_some() {
4782 eprintln!("fuse-trace: ARRRMW {rmw:?}");
4783 }
4784 micro[k] = rmw;
4785 micro.remove(k + 2);
4786 micro.remove(k + 1);
4787 // Two ops vanished at k+1, k+2 (neither a jump target). Shift targets
4788 // above them down; a jump to the fused op (k) stays put.
4789 for op in micro.iter_mut() {
4790 if let Some(t) = micro_target_mut(op) {
4791 if *t > k + 2 {
4792 *t -= 2;
4793 } else if *t > k {
4794 *t -= 1;
4795 }
4796 }
4797 }
4798 }
4799}
4800
4801/// TWO-BUFFER INTEGER LOAD+BINOP FUSION — collapses `ArrLoad(t1 = a[i]); …;
4802/// ArrLoad(t2 = b[j]); …; {Add,Sub,Mul}(dst = t1 OP t2)` (the matrix-multiply /
4803/// dot-product idiom `c += a[..] * b[..]`) into one [`MicroOp::ArrLoad2`] placed
4804/// at the binop: ONE stencil dispatch + ONE combined bounds gate instead of
4805/// three ops, and the two loaded i64s never round-trip the frame. The JIT is
4806/// per-op DISPATCH-bound, so folding three hot inner-loop ops into one is a
4807/// direct win on the indexed arithmetic loops (matrix_mult) that lose worst to
4808/// V8. The two loads need NOT be adjacent (matmul computes `b`'s index between
4809/// them) — only their values, indices, and buffers must reach the binop
4810/// unchanged. The two buffers may be distinct (`a`, `b`) or the same (a self dot
4811/// product); each must be a pinned 8-byte INT buffer.
4812///
4813/// SOUNDNESS — the fused op evaluates `a[i-1] OP b[j-1]` with the kernel's
4814/// wrapping i64 semantics, bit-identical to the original ArrLoad+ArrLoad+ALU,
4815/// provided the two loaded values are exactly the ones the binop consumed:
4816/// - both loaded scratch values `t1`/`t2` are SINGLE-USE (read only by the
4817/// binop), distinct, un-named, un-pinned — so erasing their frame writes
4818/// loses no observable value (the `copy_propagate` staleness contract);
4819/// - between the EARLIER load and the binop, NOTHING redefines either index
4820/// (`i`/`j`) or either pointer/length slot, and NOTHING writes memory (no
4821/// array store/RMW/swap/push, map set, or list rebuild) — so neither buffer
4822/// nor index can change under the loads being hoisted to the binop site;
4823/// - no block leader (jump target) lands strictly AFTER the earlier load up to
4824/// and including the binop — otherwise an entry would skip a load.
4825/// The combined `checked = c1 || c2` is at least as safe as the pair (an
4826/// unchecked load proven in-bounds is in range by hypothesis, so re-checking it
4827/// never fires). The fused op side-exits BEFORE any effect on out-of-bounds and,
4828/// in a replay-from-head region, the deopt re-runs the whole region from its
4829/// entry — so moving the bounds gates to the binop site changes no observable
4830/// result (the only intervening ops are pure arithmetic on dead scratch).
4831/// Add/Mul commute (`t1`/`t2` in either order); Sub fuses only `a[i] - b[j]`
4832/// (the first load on the left). BYTE (Bool) buffers never fuse — the fused
4833/// stencil reads raw i64s. Caller restricts this to NON-PRECISE regions (it
4834/// renumbers micro indices).
4835fn fuse_array_ld2(
4836 micro: &mut Vec<MicroOp>,
4837 named: &[bool],
4838 register_count: u16,
4839 pinned: &std::collections::HashSet<u16>,
4840) {
4841 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
4842 return;
4843 }
4844 let removable = |slot: u16, reads: &std::collections::HashMap<u16, usize>| -> bool {
4845 slot < register_count
4846 && !named.get(slot as usize).copied().unwrap_or(true)
4847 && !pinned.contains(&slot)
4848 && reads.get(&slot).copied().unwrap_or(0) == 1
4849 };
4850 // An op that writes anywhere in memory (any pinned array / map / list) —
4851 // we cannot statically tell which buffer it aliases, so any such op between
4852 // a load and the binop blocks the hoist conservatively.
4853 let writes_memory = |op: &MicroOp| -> bool {
4854 matches!(
4855 op,
4856 MicroOp::ArrStore { .. }
4857 | MicroOp::ArrRMW { .. }
4858 | MicroOp::ArrSwap { .. }
4859 | MicroOp::ArrCondSwap { .. }
4860 | MicroOp::ArrPush { .. }
4861 | MicroOp::MapSet { .. }
4862 | MicroOp::NewList { .. }
4863 | MicroOp::ListClear { .. }
4864 | MicroOp::ListTriple { .. }
4865 )
4866 };
4867 loop {
4868 let n = micro.len();
4869 let mut is_target = vec![false; n];
4870 for op in micro.iter() {
4871 if let Some(t) = micro_target_const(op) {
4872 if t < n {
4873 is_target[t] = true;
4874 }
4875 }
4876 }
4877 let mut reads: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
4878 for op in micro.iter() {
4879 for s in micro_read_slots(op) {
4880 *reads.entry(s).or_insert(0) += 1;
4881 }
4882 }
4883 // Anchor on the binop; locate the two ArrLoads that define its operands.
4884 let mut hit: Option<(usize, usize, usize, MicroOp)> = None;
4885 'anchor: for m in 0..n {
4886 let (binop_kind, dst, lhs, rhs) = match micro[m] {
4887 MicroOp::Add { dst, lhs, rhs } => (IOp::Add, dst, lhs, rhs),
4888 MicroOp::Sub { dst, lhs, rhs } => (IOp::Sub, dst, lhs, rhs),
4889 MicroOp::Mul { dst, lhs, rhs } => (IOp::Mul, dst, lhs, rhs),
4890 _ => continue,
4891 };
4892 if lhs == rhs || dst == lhs || dst == rhs {
4893 continue;
4894 }
4895 // The most-recent ArrLoad defining a slot strictly before m, if its
4896 // value provably still holds at m (single-use scratch, and the load
4897 // is not itself re-defined in between — guaranteed by single-use of
4898 // its dst plus the no-rebind interval check below).
4899 let find_load = |slot: u16| -> Option<usize> {
4900 (0..m).rev().find(|&k| micro_defs(µ[k]).contains(&slot))
4901 };
4902 let (Some(ka), Some(kb)) = (find_load(lhs), find_load(rhs)) else { continue };
4903 if ka == kb {
4904 continue;
4905 }
4906 let MicroOp::ArrLoad { dst: _, idx: ia, ptr_slot: pa, len_slot: lena, byte: false, narrow32: false, checked: ca } =
4907 micro[ka]
4908 else {
4909 continue;
4910 };
4911 let MicroOp::ArrLoad { dst: _, idx: ib, ptr_slot: pb, len_slot: lenb, byte: false, narrow32: false, checked: cb } =
4912 micro[kb]
4913 else {
4914 continue;
4915 };
4916 // For Sub the order is fixed: lhs must be `a[ia]` (the load at ka).
4917 // Add/Mul commute. We always store the operand that came from ka as
4918 // `a`/`i` and from kb as `b`/`j` so `eval` reproduces lhs OP rhs.
4919 // (Sub: lhs == a[ia], rhs == b[ib] ⇒ a - b. Correct by construction.)
4920 // Both scratch loads must be single-use removable; dst already
4921 // checked distinct from both above.
4922 if !removable(lhs, &reads) || !removable(rhs, &reads) {
4923 continue;
4924 }
4925 // Interval soundness. The fused op sits at the binop and reads each
4926 // index + pointer/length from the frame at THAT point, so each
4927 // load's index/buffer must reach the binop unchanged FROM ITS OWN
4928 // LOAD onward (the index is naturally produced just before the load
4929 // — that write is fine). Concretely:
4930 // - NO jump target in `[lo, m]` (the loads are removed and the fused
4931 // op moves to the binop, so any entry there would skip a load);
4932 // - `ia`,`pa`,`lena` not redefined in `(ka, m)`; `ib`,`pb`,`lenb`
4933 // not redefined in `(kb, m)`;
4934 // - NO memory write in `[lo, m)` (could mutate either buffer).
4935 let lo = ka.min(kb);
4936 let guard_a: [u16; 3] = [ia, pa, lena];
4937 let guard_b: [u16; 3] = [ib, pb, lenb];
4938 for p in lo..m {
4939 if p < n && is_target[p] {
4940 continue 'anchor;
4941 }
4942 if p == ka || p == kb {
4943 continue;
4944 }
4945 if writes_memory(µ[p]) {
4946 continue 'anchor;
4947 }
4948 let defs = micro_defs(µ[p]);
4949 if p > ka && defs.iter().any(|d| guard_a.contains(d)) {
4950 continue 'anchor;
4951 }
4952 if p > kb && defs.iter().any(|d| guard_b.contains(d)) {
4953 continue 'anchor;
4954 }
4955 }
4956 // A jump target landing exactly on the binop would skip both loads.
4957 if m < n && is_target[m] {
4958 continue 'anchor;
4959 }
4960 hit = Some((
4961 ka,
4962 kb,
4963 m,
4964 MicroOp::ArrLoad2 {
4965 dst,
4966 i: ia,
4967 j: ib,
4968 ptr_a: pa,
4969 len_a: lena,
4970 ptr_b: pb,
4971 len_b: lenb,
4972 op: binop_kind,
4973 checked: ca || cb,
4974 },
4975 ));
4976 break;
4977 }
4978 let Some((ka, kb, m, ld2)) = hit else { break };
4979 if std::env::var_os("LOGOS_FUSE_TRACE").is_some() {
4980 eprintln!("fuse-trace: ARRLD2 {ld2:?}");
4981 }
4982 // Place the fused op at the binop, then remove the two loads (higher
4983 // index first so the lower index stays valid).
4984 micro[m] = ld2;
4985 let (hi, low) = if ka > kb { (ka, kb) } else { (kb, ka) };
4986 micro.remove(hi);
4987 micro.remove(low);
4988 // Two ops vanished at `low` and `hi` (neither a jump target — the
4989 // interval check forbids targets after the earlier load). Shift later
4990 // targets down by however many removed ops precede them. The fused op
4991 // at `m` is itself shifted by the removals below it.
4992 for op in micro.iter_mut() {
4993 if let Some(t) = micro_target_mut(op) {
4994 let shift = (*t > low) as usize + (*t > hi) as usize;
4995 *t -= shift;
4996 }
4997 }
4998 }
4999}
5000
5001/// AFFINE-INDEX LOAD FUSION — folds the index-arithmetic chain that builds a
5002/// read-only array index INTO the load, so `<binop>(t = a OP b); Add(t2 = t +
5003/// Kc); ArrLoad(arr[t2])` (the `w - wi + 1`, `i*n + j + 1` shape) — or the
5004/// shorter `Add(t = a + Kc); ArrLoad(arr[t])` (`w + 1`) and the bare
5005/// `<binop>(t = a OP b); ArrLoad(arr[t])` (`i*n + j`) — collapse into one
5006/// [`MicroOp::ArrLoadAffine`] stencil that computes the 1-based index and loads
5007/// in a single dispatch. The JIT is per-op DISPATCH-bound, so removing the 1-2
5008/// index-arithmetic ops per access is a direct win on the indexed inner loops
5009/// (knapsack `prev[w - wi + 1]`, the heap-sift read region, graph adjacency
5010/// reads) that lose worst to V8.
5011///
5012/// SOUNDNESS — the fused stencil recomputes `idx = (frame[a] OP frame[b])
5013/// .wrapping_add(const_offset)` (or `frame[a] + const_offset` for the no-`b`
5014/// shape) with the kernel's EXACT i64 wrapping semantics, bit-identical to the
5015/// chain it replaces, PROVIDED the surviving operands reach the load unchanged:
5016/// - the load's index slot is SINGLE-USE scratch (read only by this load), so
5017/// erasing the index-producing op loses no observable value;
5018/// - the trailing-const `Add`'s product `t` (the two-slot binop's dst) is also
5019/// single-use scratch, so the inner binop is removable too;
5020/// - the constant offset is a LoadConst VALUE whose source slot is NOT redefined
5021/// between its load and the index `Add` (the LoadConst itself is KEPT — it is
5022/// usually reused — only its value is folded);
5023/// - between each removed op and the load NOTHING redefines a surviving operand
5024/// (`a`, `b`) and NO jump target lands in the removed span (an entry there
5025/// would skip a removed op).
5026/// The fused op SIDE-EXITS before any effect on out-of-bounds, identical to the
5027/// plain [`MicroOp::ArrLoad`] it replaces (replay-from-head recomputes the same
5028/// index). BYTE (Bool) buffers never fuse (the affine stencils read raw i64s).
5029/// Runs AFTER `fuse_array_ld2` so the matmul `a[..] * b[..]` two-buffer loads
5030/// keep fusing into `ArrLoad2` (which consumes the raw `ArrLoad`s) — this pass
5031/// only claims the plain read-only `ArrLoad`s that `ArrLoad2`/swap fusion left
5032/// behind. Caller restricts to NON-PRECISE regions (it renumbers micro indices).
5033/// `LOGOS_AFFINE=0` is the kill-switch.
5034fn fuse_index_affine(
5035 micro: &mut Vec<MicroOp>,
5036 named: &[bool],
5037 register_count: u16,
5038 pinned: &std::collections::HashSet<u16>,
5039) {
5040 use logicaffeine_forge::jit::AffOp;
5041 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
5042 return;
5043 }
5044 // A scratch temp removable iff it is in-range, un-named, un-pinned, and
5045 // read EXACTLY once (its only reader is the op we erase or replace).
5046 let single_use_scratch = |slot: u16, reads: &std::collections::HashMap<u16, usize>| -> bool {
5047 (slot as usize) < register_count as usize
5048 && !named.get(slot as usize).copied().unwrap_or(true)
5049 && !pinned.contains(&slot)
5050 && reads.get(&slot).copied().unwrap_or(0) == 1
5051 };
5052 // The integer binop family that can build an affine index.
5053 let as_iop = |op: &MicroOp| -> Option<(IOp, u16, u16, u16)> {
5054 match *op {
5055 MicroOp::Add { dst, lhs, rhs } => Some((IOp::Add, dst, lhs, rhs)),
5056 MicroOp::Sub { dst, lhs, rhs } => Some((IOp::Sub, dst, lhs, rhs)),
5057 MicroOp::Mul { dst, lhs, rhs } => Some((IOp::Mul, dst, lhs, rhs)),
5058 _ => None,
5059 }
5060 };
5061 let iop_to_aff = |op: IOp| match op {
5062 IOp::Add => AffOp::Add,
5063 IOp::Sub => AffOp::Sub,
5064 IOp::Mul => AffOp::Mul,
5065 };
5066 loop {
5067 let n = micro.len();
5068 let mut is_target = vec![false; n];
5069 for op in micro.iter() {
5070 if let Some(t) = micro_target_const(op) {
5071 if t < n {
5072 is_target[t] = true;
5073 }
5074 }
5075 }
5076 let mut reads: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
5077 for op in micro.iter() {
5078 for s in micro_read_slots(op) {
5079 *reads.entry(s).or_insert(0) += 1;
5080 }
5081 }
5082 // The reaching constant value of a slot at position `m`, if it is a
5083 // LoadConst not redefined in `(def, m)`. Returns (value, def_index).
5084 let reaching_const = |slot: u16, m: usize| -> Option<(i64, usize)> {
5085 let k = (0..m).rev().find(|&p| micro_defs(µ[p]).contains(&slot))?;
5086 if let MicroOp::LoadConst { value, .. } = micro[k] {
5087 Some((value, k))
5088 } else {
5089 None
5090 }
5091 };
5092 // The reaching def index of a slot strictly before `m`.
5093 let reaching_def = |slot: u16, m: usize| -> Option<usize> {
5094 (0..m).rev().find(|&p| micro_defs(µ[p]).contains(&slot))
5095 };
5096 // No jump target lands in [lo, m]; no surviving operand redefined in the
5097 // open spans after each removed op up to the load.
5098 let mut hit: Option<(Vec<usize>, usize, MicroOp)> = None;
5099 'anchor: for m in 0..n {
5100 let MicroOp::ArrLoad { dst, idx, ptr_slot, len_slot, byte: false, narrow32: false, checked } = micro[m]
5101 else {
5102 continue;
5103 };
5104 // The index must be single-use scratch defined by an integer binop.
5105 if !single_use_scratch(idx, &reads) {
5106 continue;
5107 }
5108 let Some(kidx) = reaching_def(idx, m) else { continue };
5109 let Some((idx_op, _idst, ilhs, irhs)) = as_iop(µ[kidx]) else { continue };
5110
5111 // Decompose into (a, op, b, const_offset) + the chain ops to remove.
5112 // Three shapes, tried in order of specificity.
5113 let mut removed: Vec<usize> = Vec::new();
5114 let (a, aff_op, b, coff): (u16, AffOp, u16, i64);
5115
5116 // Does either operand of the index binop come from an inner two-slot
5117 // binop whose dst is single-use (the `t` of `t + Kc`)? If the OTHER
5118 // operand is a reaching const, this is the trailing-const shape.
5119 let inner_of = |slot: u16| -> Option<(usize, IOp, u16, u16)> {
5120 if !single_use_scratch(slot, &reads) {
5121 return None;
5122 }
5123 let k = reaching_def(slot, kidx)?;
5124 let (op, d, l, r) = as_iop(µ[k])?;
5125 Some((k, op, l, r)).filter(|_| d == slot)
5126 };
5127
5128 if idx_op == IOp::Add {
5129 // `Add(idx = LHS + RHS)`: classify each side.
5130 let lconst = reaching_const(ilhs, kidx);
5131 let rconst = reaching_const(irhs, kidx);
5132 let linner = inner_of(ilhs);
5133 let rinner = inner_of(irhs);
5134 if let (Some((kc, _)), Some((kin, iop, l, r))) = (rconst, linner) {
5135 // `(l OP r) + Kc` — Kc on the right.
5136 let _ = kc;
5137 a = l; b = r; aff_op = iop_to_aff(iop); coff = rconst.unwrap().0;
5138 removed.push(kin);
5139 removed.push(kidx);
5140 } else if let (Some((kc, _)), Some((kin, iop, l, r))) = (lconst, rinner) {
5141 // `Kc + (l OP r)` — Kc on the left.
5142 let _ = kc;
5143 a = l; b = r; aff_op = iop_to_aff(iop); coff = lconst.unwrap().0;
5144 removed.push(kin);
5145 removed.push(kidx);
5146 } else if let Some((_, _)) = rconst {
5147 // `a + Kc` (the `w + 1` shape): single slot + const.
5148 a = ilhs; b = ilhs; aff_op = AffOp::None; coff = rconst.unwrap().0;
5149 removed.push(kidx);
5150 } else if let Some((_, _)) = lconst {
5151 a = irhs; b = irhs; aff_op = AffOp::None; coff = lconst.unwrap().0;
5152 removed.push(kidx);
5153 } else {
5154 // `a + b` (no const): a bare two-slot add.
5155 a = ilhs; b = irhs; aff_op = AffOp::Add; coff = 0;
5156 removed.push(kidx);
5157 }
5158 } else {
5159 // `Sub`/`Mul(idx = a OP b)` with no trailing const (the const,
5160 // when present, is always the OUTER `+ Kc` Add handled above; a
5161 // bare Sub/Mul index folds with const_offset = 0).
5162 a = ilhs; b = irhs; aff_op = iop_to_aff(idx_op); coff = 0;
5163 removed.push(kidx);
5164 }
5165
5166 // Interval soundness over the union of removed ops and the load.
5167 let lo = *removed.iter().min().unwrap();
5168 let survivors: [u16; 2] = [a, b];
5169 for p in lo..m {
5170 if p < n && is_target[p] {
5171 continue 'anchor;
5172 }
5173 if removed.contains(&p) {
5174 continue;
5175 }
5176 let defs = micro_defs(µ[p]);
5177 if defs.iter().any(|d| survivors.contains(d)) {
5178 continue 'anchor;
5179 }
5180 }
5181 // A jump target landing exactly on the load would skip the removed
5182 // index ops.
5183 if m < n && is_target[m] {
5184 continue 'anchor;
5185 }
5186 removed.sort_unstable();
5187 hit = Some((
5188 removed,
5189 m,
5190 MicroOp::ArrLoadAffine {
5191 dst,
5192 a,
5193 op: aff_op,
5194 b,
5195 const_offset: coff,
5196 ptr_slot,
5197 len_slot,
5198 checked,
5199 },
5200 ));
5201 break;
5202 }
5203 let Some((removed, m, affine)) = hit else { break };
5204 if std::env::var_os("LOGOS_FUSE_TRACE").is_some() {
5205 eprintln!("fuse-trace: AFFINE {affine:?}");
5206 }
5207 // Place the fused op at the load, then remove the index-chain ops
5208 // (highest index first so lower indices stay valid). None of the removed
5209 // ops is a jump target (the interval check forbids targets in [lo, m]).
5210 micro[m] = affine;
5211 for &r in removed.iter().rev() {
5212 micro.remove(r);
5213 }
5214 for op in micro.iter_mut() {
5215 if let Some(t) = micro_target_mut(op) {
5216 let shift = removed.iter().filter(|&&r| r < *t).count();
5217 *t -= shift;
5218 }
5219 }
5220 }
5221}
5222
5223/// FLOAT MULTIPLY-ADD FUSION — collapses `MulF(t = a*b); AddF(d = t + c)` (the
5224/// product `t` single-use, AddF commutative so `t` may be either operand) into
5225/// one [`MicroOp::FmaF`] computing `(a*b) + c`. The JIT is per-op DISPATCH-bound,
5226/// and the float arithmetic chains that dominate the float cluster (nbody's
5227/// `dz*dz + sum`, dot products) are a stream of two-input AddF/MulF; merging a
5228/// product into its consuming add removes a stencil dispatch per occurrence.
5229///
5230/// SOUNDNESS: FmaF rounds the product and the add SEPARATELY (`(a*b)+c`, two
5231/// roundings — NOT a hardware single-rounding FMA), bit-identical to MulF+AddF.
5232/// It is mem-form (all operands read from the frame), so it never disturbs the
5233/// threaded XMM pins. To keep it a SPILL-FREE win on its INPUTS, fuse only when
5234/// the surviving inputs (`a`, `b`, the addend `c`) are FRAME-RESIDENT (not
5235/// pinned) — a pinned input would be spilled BEFORE the mem-form op, possibly
5236/// costing more than the saved dispatch. The RESULT `d` MAY be pinned (Lever
5237/// 1a): the pinned compiler's mem-form path reloads a pinned `d` from the frame
5238/// after the FmaF, exactly as the unfused register-form `AddF` would settle it,
5239/// so a pinned `d` adds no input spill (the nbody distance-sum's `dz*dz +
5240/// partial`, whose `d` is the float-pinned running sum). The product `t` must be
5241/// single-use, un-named, un-pinned scratch (its only reader, the AddF, is
5242/// removed). Caller restricts to NON-PRECISE regions (renumbering).
5243fn fuse_fma(
5244 micro: &mut Vec<MicroOp>,
5245 named: &[bool],
5246 register_count: u16,
5247 pinned: &std::collections::HashSet<u16>,
5248) {
5249 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
5250 return;
5251 }
5252 let frame_resident = |slot: u16| -> bool { !pinned.contains(&slot) };
5253 loop {
5254 let n = micro.len();
5255 let mut is_target = vec![false; n];
5256 for op in micro.iter() {
5257 if let Some(t) = micro_target_const(op) {
5258 if t < n {
5259 is_target[t] = true;
5260 }
5261 }
5262 }
5263 let mut reads: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
5264 for op in micro.iter() {
5265 for s in micro_read_slots(op) {
5266 *reads.entry(s).or_insert(0) += 1;
5267 }
5268 }
5269 let mut hit: Option<(usize, MicroOp)> = None;
5270 for k in 0..n.saturating_sub(1) {
5271 if is_target[k + 1] {
5272 continue;
5273 }
5274 let MicroOp::MulF { dst: t, lhs: a, rhs: b } = micro[k] else { continue };
5275 // The AddF consumes the product `t` (either operand) plus the addend.
5276 let (d, c) = match micro[k + 1] {
5277 MicroOp::AddF { dst, lhs, rhs } if lhs == t => (dst, rhs),
5278 MicroOp::AddF { dst, lhs, rhs } if rhs == t => (dst, lhs),
5279 _ => continue,
5280 };
5281 // `t` is single-use, un-named, un-pinned scratch we can erase.
5282 let t_dead = (t as usize) < register_count as usize
5283 && !named.get(t as usize).copied().unwrap_or(true)
5284 && !pinned.contains(&t)
5285 && reads.get(&t).copied().unwrap_or(0) == 1;
5286 // INPUT-spill-free (Lever 1a): require only the surviving INPUTS
5287 // (`a`, `b`, the addend `c`) frame-resident. `FmaF` is mem-form, so a
5288 // pinned RESULT `d` is spilled/reloaded by the pinned compiler's
5289 // mem-form path exactly as the unfused `AddF`'s register-form write
5290 // would settle it — fusing a pinned `d` adds no INPUT spill, removing
5291 // the `MulF` dispatch (the nbody distance-sum's `dz*dz + partial`,
5292 // whose `d` is the float-pinned sum). The addend `c` must NOT be
5293 // pinned: in the `s = s + a*b` accumulator shape `c == d == s`, a
5294 // pinned `c` would be spilled BEFORE the FmaF (a real extra spill),
5295 // costing more pieces than the register-form `AddF` it replaces.
5296 if !t_dead || !frame_resident(a) || !frame_resident(b) || !frame_resident(c) {
5297 continue;
5298 }
5299 hit = Some((k, MicroOp::FmaF { dst: d, a, b, c }));
5300 break;
5301 }
5302 let Some((k, fma)) = hit else { break };
5303 if std::env::var_os("LOGOS_FUSE_TRACE").is_some() {
5304 eprintln!("fuse-trace: FMAF {fma:?}");
5305 }
5306 micro[k] = fma;
5307 micro.remove(k + 1);
5308 for op in micro.iter_mut() {
5309 if let Some(t) = micro_target_mut(op) {
5310 if *t > k {
5311 *t -= 1;
5312 }
5313 }
5314 }
5315 }
5316}
5317
5318/// CONDITIONAL-SWAP FUSION — collapses the sort inner-loop idiom
5319/// `ArrLoad(a, i1); [pure index ops]; ArrLoad(b, i2); Branch(cmp, a, b, skip);
5320/// ArrStore(i1, b); ArrStore(i2, a)` into one [`MicroOp::ArrCondSwap`] — the
5321/// biggest per-iteration dispatch sink for bubble/insertion/quick sort. Unlocked
5322/// by the precise-region-live-out keystone: the loaded values flow through
5323/// loop-local `Let a`/`Let b`, which are now dropped from write-back, so
5324/// copy_propagate erases their Moves and the loaded temps become single-use.
5325///
5326/// SOUNDNESS: `ArrCondSwap` re-reads `buf[i1]`/`buf[i2]` and swaps atomically
5327/// (both writes or neither) — bit-identical to the load/compare/cross-store it
5328/// replaces because NOTHING writes the array between the original loads and the
5329/// stores (only the pure index ops and the branch sit there). One pre-write
5330/// bounds check covers both indices. The loaded `a`, `b` must be single-use
5331/// scratch (read only by the branch + their cross-store — exactly two reads
5332/// each — un-named, un-pinned), the branch must target precisely past both
5333/// stores, the comparison must be an ordering (Gt/Lt/GtEq/LtEq), and up to two
5334/// PURE ops (a const + the `i+1` add) may sit between the loads. No fused op may
5335/// be a jump target. Non-precise regions only.
5336fn fuse_cond_swap(
5337 micro: &mut Vec<MicroOp>,
5338 named: &[bool],
5339 register_count: u16,
5340 pinned: &std::collections::HashSet<u16>,
5341) {
5342 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
5343 return;
5344 }
5345 let dead_scratch = |slot: u16, reads: &std::collections::HashMap<u16, usize>| -> bool {
5346 (slot as usize) < register_count as usize
5347 && !named.get(slot as usize).copied().unwrap_or(true)
5348 && !pinned.contains(&slot)
5349 && reads.get(&slot).copied().unwrap_or(0) == 2 // the branch + its cross-store
5350 };
5351 loop {
5352 let n = micro.len();
5353 let mut is_target = vec![false; n];
5354 for op in micro.iter() {
5355 if let Some(t) = micro_target_const(op) {
5356 if t < n {
5357 is_target[t] = true;
5358 }
5359 }
5360 }
5361 let mut reads: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
5362 for op in micro.iter() {
5363 for s in micro_read_slots(op) {
5364 *reads.entry(s).or_insert(0) += 1;
5365 }
5366 }
5367 let mut hit: Option<(usize, usize, MicroOp)> = None; // (k, gap, condswap)
5368 // The compare's SECOND index can be a multi-op affine computation
5369 // (`2*root+2` = LoadConst + Mul + Add, or `i*n + j + 1`) sitting between
5370 // the two loads. The `between_ok` purity guard below — not this window —
5371 // is what proves the gap ops cannot perturb the swap (pure, and they
5372 // redefine none of the first loaded value / first index / pointer /
5373 // length); the window only bounds the linear search, so it must be wide
5374 // enough to span a realistic index expression.
5375 const MAXGAP: usize = 5;
5376 'outer: for k in 0..n {
5377 let MicroOp::ArrLoad { dst: a, idx: i1, ptr_slot: p, len_slot: l, byte: false, narrow32: false, checked: c1 } =
5378 micro[k]
5379 else {
5380 continue;
5381 };
5382 for gap in 0..=MAXGAP {
5383 let lb = k + 1 + gap;
5384 if lb + 3 >= n {
5385 break;
5386 }
5387 let between_ok = (k + 1..lb).all(|x| {
5388 let pure = micro_pure_value_key(µ[x]).is_some()
5389 || matches!(micro[x], MicroOp::LoadConst { .. });
5390 let defs = micro_defs(µ[x]);
5391 pure && !defs.contains(&a) && !defs.contains(&i1) && !defs.contains(&p)
5392 && !defs.contains(&l)
5393 });
5394 if !between_ok {
5395 continue;
5396 }
5397 let MicroOp::ArrLoad { dst: b, idx: i2, ptr_slot: p2, len_slot: l2, byte: false, narrow32: false, checked: c2 } =
5398 micro[lb]
5399 else {
5400 continue;
5401 };
5402 if p2 != p || l2 != l {
5403 continue;
5404 }
5405 let MicroOp::Branch { cmp, lhs, rhs, target } = micro[lb + 1] else { continue };
5406 if !matches!(cmp, Cmp::Lt | Cmp::Gt | Cmp::LtEq | Cmp::GtEq) || lhs != a || rhs != b {
5407 continue;
5408 }
5409 let MicroOp::ArrStore { src: s1, idx: si1, ptr_slot: sp1, len_slot: sl1, byte: false, narrow32: false, checked: c3 } =
5410 micro[lb + 2]
5411 else {
5412 continue;
5413 };
5414 let MicroOp::ArrStore { src: s2, idx: si2, ptr_slot: sp2, len_slot: sl2, byte: false, narrow32: false, checked: c4 } =
5415 micro[lb + 3]
5416 else {
5417 continue;
5418 };
5419 if si1 != i1 || si2 != i2 || s1 != b || s2 != a || sp1 != p || sp2 != p || sl1 != l || sl2 != l {
5420 continue;
5421 }
5422 if target != lb + 4 {
5423 continue;
5424 }
5425 if (k..=lb + 3).any(|x| is_target.get(x).copied().unwrap_or(false)) {
5426 continue;
5427 }
5428 if !dead_scratch(a, &reads) || !dead_scratch(b, &reads) {
5429 continue;
5430 }
5431 hit = Some((
5432 k,
5433 gap,
5434 MicroOp::ArrCondSwap {
5435 idx1: i1,
5436 idx2: i2,
5437 ptr_slot: p,
5438 len_slot: l,
5439 cmp,
5440 checked: c1 || c2 || c3 || c4,
5441 },
5442 ));
5443 break 'outer;
5444 }
5445 }
5446 let Some((k, gap, swap)) = hit else { break };
5447 if std::env::var_os("LOGOS_FUSE_TRACE").is_some() {
5448 eprintln!("fuse-trace: CONDSWAP {swap:?}");
5449 }
5450 // Span k ..= k+4+gap. Keep the between ops, turn the b-load slot into the
5451 // swap, drop a-load + branch + both stores (4 removed). First op after the
5452 // span (old k+gap+5) lands at k+gap+1.
5453 micro[k + gap + 1] = swap;
5454 micro.drain(k + gap + 2..=k + gap + 4);
5455 micro.remove(k);
5456 for op in micro.iter_mut() {
5457 if let Some(t) = micro_target_mut(op) {
5458 if *t >= k + gap + 5 {
5459 *t -= 4;
5460 }
5461 }
5462 }
5463 }
5464}
5465
5466/// UNCONDITIONAL-SWAP FUSION — collapses the `Let tmp = arr[i]; arr[i] = arr[j];
5467/// arr[j] = tmp` exchange (quicksort/heap_sort/mergesort) — micro
5468/// `ArrLoad(a, i1); ArrLoad(b, i2); ArrStore(i1, b); ArrStore(i2, a)` — into one
5469/// [`MicroOp::ArrSwap`] (4 ops → 1). The conditionality (`if arr[j] <= pivot`)
5470/// is a SEPARATE branch before the loads and stays.
5471///
5472/// SOUNDNESS: `ArrSwap` re-reads `buf[i1]`/`buf[i2]` and swaps atomically —
5473/// bit-identical because nothing writes the array between the loads and stores.
5474/// `a`, `b` must be LOCALLY single-use (each read only by its cross-store before
5475/// its next redefinition — a slot may be REUSED later, so global read-counting
5476/// is too coarse here), un-named, un-pinned. No control flow between the four
5477/// ops; none a jump target. Non-precise regions only.
5478fn fuse_swap(
5479 micro: &mut Vec<MicroOp>,
5480 named: &[bool],
5481 register_count: u16,
5482 pinned: &std::collections::HashSet<u16>,
5483) {
5484 if micro.iter().any(|op| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. })) {
5485 return;
5486 }
5487 let erasable = |slot: u16| -> bool {
5488 (slot as usize) < register_count as usize
5489 && !named.get(slot as usize).copied().unwrap_or(true)
5490 && !pinned.contains(&slot)
5491 };
5492 loop {
5493 let n = micro.len();
5494 let mut is_target = vec![false; n];
5495 for op in micro.iter() {
5496 if let Some(t) = micro_target_const(op) {
5497 if t < n {
5498 is_target[t] = true;
5499 }
5500 }
5501 }
5502 // `slot` (defined at def_i) is read ONLY at `want` before its next redef.
5503 let local_single_use = |slot: u16, def_i: usize, want: usize| -> bool {
5504 let mut seen = false;
5505 for j in def_i + 1..micro.len() {
5506 if micro_read_slots(µ[j]).contains(&slot) {
5507 if j != want {
5508 return false;
5509 }
5510 seen = true;
5511 }
5512 if micro_defs(µ[j]).contains(&slot) {
5513 break; // next redefinition — a separate live range
5514 }
5515 }
5516 seen
5517 };
5518 let mut hit: Option<(usize, MicroOp)> = None;
5519 for k in 0..n.saturating_sub(3) {
5520 let MicroOp::ArrLoad { dst: a, idx: i1, ptr_slot: p, len_slot: l, byte: false, narrow32: false, checked: c1 } =
5521 micro[k]
5522 else {
5523 continue;
5524 };
5525 let MicroOp::ArrLoad { dst: b, idx: i2, ptr_slot: p2, len_slot: l2, byte: false, narrow32: false, checked: c2 } =
5526 micro[k + 1]
5527 else {
5528 continue;
5529 };
5530 if p2 != p || l2 != l || a == b {
5531 continue;
5532 }
5533 let MicroOp::ArrStore { src: s1, idx: si1, ptr_slot: sp1, len_slot: sl1, byte: false, narrow32: false, checked: c3 } =
5534 micro[k + 2]
5535 else {
5536 continue;
5537 };
5538 let MicroOp::ArrStore { src: s2, idx: si2, ptr_slot: sp2, len_slot: sl2, byte: false, narrow32: false, checked: c4 } =
5539 micro[k + 3]
5540 else {
5541 continue;
5542 };
5543 // Cross-store swap to the SAME array+indices: buf[i1]=b, buf[i2]=a.
5544 if si1 != i1 || si2 != i2 || s1 != b || s2 != a || sp1 != p || sp2 != p || sl1 != l || sl2 != l {
5545 continue;
5546 }
5547 if is_target[k + 1] || is_target[k + 2] || is_target[k + 3] {
5548 continue;
5549 }
5550 // `a` consumed only by the store at k+3, `b` only by the store at k+2.
5551 if !erasable(a) || !erasable(b) || !local_single_use(a, k, k + 3) || !local_single_use(b, k + 1, k + 2) {
5552 continue;
5553 }
5554 hit = Some((
5555 k,
5556 MicroOp::ArrSwap {
5557 idx1: i1,
5558 idx2: i2,
5559 ptr_slot: p,
5560 len_slot: l,
5561 checked: c1 || c2 || c3 || c4,
5562 },
5563 ));
5564 break;
5565 }
5566 let Some((k, swap)) = hit else { break };
5567 if std::env::var_os("LOGOS_FUSE_TRACE").is_some() {
5568 eprintln!("fuse-trace: ARRSWAP {swap:?}");
5569 }
5570 micro[k] = swap;
5571 micro.drain(k + 1..=k + 3); // remove the 2nd load + both stores
5572 for op in micro.iter_mut() {
5573 if let Some(t) = micro_target_mut(op) {
5574 if *t >= k + 4 {
5575 *t -= 3;
5576 }
5577 }
5578 }
5579 }
5580}
5581
5582
5583/// A canonical value key for a PURE, side-effect-free, deterministic micro-op —
5584/// the basis for local value numbering (CSE) in [`copy_propagate`]. Returns
5585/// `((tag, a, b), dst)`: a unique op tag, the two operand slots (commutative ops
5586/// sort them so `v + 1` and `1 + v` share a key), and the result slot. Returns
5587/// None for anything that loads memory, calls, side-exits (Div/Mod, whose
5588/// duplicate could diverge on a zero divisor), branches, or carries hidden
5589/// state — those must never be deduplicated. Every operand position is a real
5590/// frame slot (never an immediate), so the invalidation in `copy_propagate` can
5591/// drop a key the moment any slot it names is redefined.
5592fn micro_pure_value_key(op: &MicroOp) -> Option<((u8, u16, u16), u16)> {
5593 use MicroOp::*;
5594 let comm = |t: u8, a: u16, b: u16, d: u16| Some(((t, a.min(b), a.max(b)), d));
5595 let ord = |t: u8, a: u16, b: u16, d: u16| Some(((t, a, b), d));
5596 match *op {
5597 Add { dst, lhs, rhs } => comm(1, lhs, rhs, dst),
5598 Mul { dst, lhs, rhs } => comm(2, lhs, rhs, dst),
5599 BitAnd { dst, lhs, rhs } => comm(3, lhs, rhs, dst),
5600 BitOr { dst, lhs, rhs } => comm(4, lhs, rhs, dst),
5601 BitXor { dst, lhs, rhs } => comm(5, lhs, rhs, dst),
5602 Eq { dst, lhs, rhs } => comm(6, lhs, rhs, dst),
5603 Neq { dst, lhs, rhs } => comm(7, lhs, rhs, dst),
5604 Sub { dst, lhs, rhs } => ord(8, lhs, rhs, dst),
5605 Shl { dst, lhs, rhs } => ord(9, lhs, rhs, dst),
5606 Shr { dst, lhs, rhs } => ord(10, lhs, rhs, dst),
5607 Lt { dst, lhs, rhs } => ord(11, lhs, rhs, dst),
5608 Gt { dst, lhs, rhs } => ord(12, lhs, rhs, dst),
5609 LtEq { dst, lhs, rhs } => ord(13, lhs, rhs, dst),
5610 GtEq { dst, lhs, rhs } => ord(14, lhs, rhs, dst),
5611 NotInt { dst, src } => ord(15, src, src, dst),
5612 NotBool { dst, src } => ord(16, src, src, dst),
5613 _ => None,
5614 }
5615}
5616
5617/// The branch/jump TARGET of a micro-op (a micro index), if any — by value.
5618fn micro_target_const(op: &MicroOp) -> Option<usize> {
5619 use MicroOp::*;
5620 match *op {
5621 Jump { target } | Branch { target, .. } | BranchF { target, .. }
5622 | JumpIfFalse { target, .. } | JumpIfTrue { target, .. } => Some(target),
5623 _ => None,
5624 }
5625}
5626
5627/// Returns TWO disjoint pin sets: `(gp_pins, float_pins)`. A slot that ever
5628/// holds an f64 VALUE (used by any float-arithmetic / float-compare op) is a
5629/// FLOAT slot — it can only ride an XMM register (f0..f3), so it goes into
5630/// `float_pins` and is NEVER put in the GP set, which threads i64s through
5631/// r0..r3. The two budgets are independent (the threaded ABI passes both
5632/// r0..r3 and f0..f3), so each is ranked and capped at four on its own.
5633fn select_pins(ops: &[MicroOp], register_count: u16, region: bool) -> (Vec<u16>, Vec<u16>) {
5634 const BASE: i64 = 16;
5635 const CAP: u32 = 4;
5636 let depth = if region { loop_depths(ops) } else { Vec::new() };
5637 let weight = |i: usize| -> i64 {
5638 if region {
5639 BASE.pow(depth[i].min(CAP))
5640 } else {
5641 1
5642 }
5643 };
5644 // Every REAL call saves/restores each live pinned register (SysV makes
5645 // them caller-saved): call-heavy bodies (fib) lose to pinning while
5646 // loop-heavy bodies (gcd, nqueens' solver) win — charge each slot a
5647 // per-call penalty, frequency-weighted in regions (a call inside an
5648 // inner loop is far costlier than one at top level).
5649 let call_penalty: i64 = ops
5650 .iter()
5651 .enumerate()
5652 .filter(|(_, op)| matches!(op, MicroOp::Call { .. } | MicroOp::CallSelf { .. } | MicroOp::CallSelfCopy { .. }))
5653 .map(|(i, _)| 3 * weight(i))
5654 .sum();
5655 // FLOAT-slot set: every slot that ever holds an f64 VALUE. The float
5656 // arithmetic ops (AddF/SubF/MulF/DivF) carry floats in all three slots;
5657 // the float comparisons/branch carry floats only in the OPERANDS (their
5658 // dst is the Int 0/1 result); IntToFloat produces a float in dst from an
5659 // Int src; SqrtF and the fused float load produce/consume floats. A float
5660 // slot can only live in an XMM register, NEVER a GP one (the GP stencils
5661 // store raw i64 bits and the float arith reads `fN` directly), so EVERY
5662 // float slot is excluded from the GP budget below.
5663 //
5664 // A float slot is XMM-PINNABLE only when every op that touches it as a
5665 // float is XMM-aware: the V_FBINOP arith threads it through `fN`, and the
5666 // mem-form float ops (DivF, the float compares/branch, IntToFloat, SqrtF,
5667 // the fused load) spill/reload it around their frame-form stencils. The
5668 // register-form ops that resolve operands through the GP location only —
5669 // `LoadConst`, `Move`, and the function `Return` value — have NO XMM form,
5670 // so a float slot they touch must stay FRAME-resident (blocked from the
5671 // float-pin budget); the float arith then reads it from the frame (floc 0)
5672 // correctly. (`main_float_loop`: the `0.25` constant slot is a LoadConst
5673 // dst AND an AddF operand — float-valued but pin-blocked.)
5674 let mut float_slots: std::collections::HashSet<u16> = std::collections::HashSet::new();
5675 let mut float_blocked: std::collections::HashSet<u16> = std::collections::HashSet::new();
5676 let mut mark_float = |slot: u16, set: &mut std::collections::HashSet<u16>| {
5677 if slot < register_count {
5678 set.insert(slot);
5679 }
5680 };
5681 for op in ops {
5682 match *op {
5683 MicroOp::AddF { dst, lhs, rhs }
5684 | MicroOp::SubF { dst, lhs, rhs }
5685 | MicroOp::MulF { dst, lhs, rhs }
5686 | MicroOp::DivF { dst, lhs, rhs } => {
5687 for s in [dst, lhs, rhs] {
5688 mark_float(s, &mut float_slots);
5689 }
5690 }
5691 MicroOp::LtF { lhs, rhs, .. }
5692 | MicroOp::GtF { lhs, rhs, .. }
5693 | MicroOp::LtEqF { lhs, rhs, .. }
5694 | MicroOp::GtEqF { lhs, rhs, .. }
5695 | MicroOp::EqF { lhs, rhs, .. }
5696 | MicroOp::NeqF { lhs, rhs, .. }
5697 | MicroOp::BranchF { lhs, rhs, .. } => {
5698 for s in [lhs, rhs] {
5699 mark_float(s, &mut float_slots);
5700 }
5701 }
5702 MicroOp::IntToFloat { dst, .. } => mark_float(dst, &mut float_slots),
5703 MicroOp::SqrtF { dst, src } => {
5704 for s in [dst, src] {
5705 mark_float(s, &mut float_slots);
5706 }
5707 }
5708 MicroOp::ArrLoad2F { dst, .. } => mark_float(dst, &mut float_slots),
5709 // A float value loaded from / stored to an array stays FRAME-RESIDENT
5710 // (block it from the XMM pin budget): the array access itself is
5711 // mem-form, so a pinned value would round-trip the frame anyway, and
5712 // the float-pin + mem-form-array interaction is not yet correct
5713 // (spectral_norm SIGSEGV). The array load/store still works mem-form.
5714 MicroOp::ArrLoad { dst, .. } => mark_float(dst, &mut float_blocked),
5715 MicroOp::ArrStore { src, .. } => mark_float(src, &mut float_blocked),
5716 // Register-form ops with no XMM lowering: a float slot they touch
5717 // is pin-blocked (it must read/write through the frame). `Move` is
5718 // NOT here — it threads XMM pins through V_FMOV (f→f, or frame↔f when
5719 // one end is unpinned), so a loop-carried float reassigned via `Set
5720 // acc to next` keeps its register (mandelbrot's `Set zr to zr2`).
5721 MicroOp::LoadConst { dst, .. } => mark_float(dst, &mut float_blocked),
5722 MicroOp::Return { src } => mark_float(src, &mut float_blocked),
5723 _ => {}
5724 }
5725 }
5726 // INTEGER-role slots: any slot read/written as an i64 by a non-float op —
5727 // int arithmetic, a branch/jump condition, an array index/pointer/length/
5728 // handle, an `IntToFloat` SOURCE, a map key/handle. A slot that is BOTH
5729 // float-valued (above) AND integer-role is a TYPE-REUSED scratch cell, and
5730 // must stay FRAME-resident (never float-pinned): the int stencil writes raw
5731 // i64 bits to its frame cell while a float pin would keep an f64 in an XMM
5732 // register, and the mem-form spill of the float pin clobbers the integer
5733 // the int op wrote. (spectral_norm SIGSEGV: a `MulF` result slot was reused
5734 // as an `ArrStore` index; the float-pin spill wrote f64 bits over the
5735 // integer index, so the store indexed through a garbage pointer.) Marking
5736 // the integer role into `float_blocked` blocks the pin; a pure-integer slot
5737 // is never in `float_slots`, so this is a no-op for it — only genuinely
5738 // mixed slots are forced frame-resident.
5739 for op in ops {
5740 match *op {
5741 MicroOp::Add { dst, lhs, rhs }
5742 | MicroOp::Sub { dst, lhs, rhs }
5743 | MicroOp::Mul { dst, lhs, rhs }
5744 | MicroOp::BitAnd { dst, lhs, rhs }
5745 | MicroOp::BitOr { dst, lhs, rhs }
5746 | MicroOp::BitXor { dst, lhs, rhs }
5747 | MicroOp::Shl { dst, lhs, rhs }
5748 | MicroOp::Shr { dst, lhs, rhs }
5749 | MicroOp::Lt { dst, lhs, rhs }
5750 | MicroOp::Gt { dst, lhs, rhs }
5751 | MicroOp::LtEq { dst, lhs, rhs }
5752 | MicroOp::GtEq { dst, lhs, rhs }
5753 | MicroOp::Eq { dst, lhs, rhs }
5754 | MicroOp::Neq { dst, lhs, rhs }
5755 | MicroOp::Div { dst, lhs, rhs }
5756 | MicroOp::Mod { dst, lhs, rhs } => {
5757 for s in [dst, lhs, rhs] {
5758 mark_float(s, &mut float_blocked);
5759 }
5760 }
5761 MicroOp::DivPow2 { dst, lhs, .. } | MicroOp::MagicDivU { dst, lhs, .. } => {
5762 for s in [dst, lhs] {
5763 mark_float(s, &mut float_blocked);
5764 }
5765 }
5766 MicroOp::NotInt { dst, src } | MicroOp::NotBool { dst, src } => {
5767 for s in [dst, src] {
5768 mark_float(s, &mut float_blocked);
5769 }
5770 }
5771 MicroOp::Branch { lhs, rhs, .. } => {
5772 for s in [lhs, rhs] {
5773 mark_float(s, &mut float_blocked);
5774 }
5775 }
5776 MicroOp::JumpIfFalse { cond, .. } | MicroOp::JumpIfTrue { cond, .. } => {
5777 mark_float(cond, &mut float_blocked);
5778 }
5779 MicroOp::IntToFloat { src, .. } => mark_float(src, &mut float_blocked),
5780 MicroOp::ArrLoad { idx, ptr_slot, len_slot, .. } => {
5781 for s in [idx, ptr_slot, len_slot] {
5782 mark_float(s, &mut float_blocked);
5783 }
5784 }
5785 MicroOp::ArrStore { idx, ptr_slot, len_slot, .. } => {
5786 for s in [idx, ptr_slot, len_slot] {
5787 mark_float(s, &mut float_blocked);
5788 }
5789 }
5790 MicroOp::ArrRMW { idx, operand, ptr_slot, len_slot, .. } => {
5791 for s in [idx, operand, ptr_slot, len_slot] {
5792 mark_float(s, &mut float_blocked);
5793 }
5794 }
5795 MicroOp::ArrCondSwap { idx1, idx2, ptr_slot, len_slot, .. } => {
5796 for s in [idx1, idx2, ptr_slot, len_slot] {
5797 mark_float(s, &mut float_blocked);
5798 }
5799 }
5800 MicroOp::ArrSwap { idx1, idx2, ptr_slot, len_slot, .. } => {
5801 for s in [idx1, idx2, ptr_slot, len_slot] {
5802 mark_float(s, &mut float_blocked);
5803 }
5804 }
5805 MicroOp::ArrLoad2F { i: ix, j: jx, ptr_slot, len_slot, .. } => {
5806 for s in [ix, jx, ptr_slot, len_slot] {
5807 mark_float(s, &mut float_blocked);
5808 }
5809 }
5810 // The fused int two-load result is an i64; every operand and slot
5811 // is integer-role (block them from the float-pin budget, exactly
5812 // like the mem-form array ops).
5813 MicroOp::ArrLoad2 { dst, i: ix, j: jx, ptr_a, len_a, ptr_b, len_b, .. } => {
5814 for s in [dst, ix, jx, ptr_a, len_a, ptr_b, len_b] {
5815 mark_float(s, &mut float_blocked);
5816 }
5817 }
5818 MicroOp::ArrPush { vec_slot, ptr_slot, len_slot, .. } => {
5819 for s in [vec_slot, ptr_slot, len_slot] {
5820 mark_float(s, &mut float_blocked);
5821 }
5822 }
5823 MicroOp::NewList { vec_slot, ptr_slot, len_slot, .. } => {
5824 for s in [vec_slot, ptr_slot, len_slot] {
5825 mark_float(s, &mut float_blocked);
5826 }
5827 }
5828 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
5829 for s in [handle_slot, vec_slot, ptr_slot, len_slot] {
5830 mark_float(s, &mut float_blocked);
5831 }
5832 }
5833 MicroOp::MapGet { key, map_slot, .. } => {
5834 for s in [key, map_slot] {
5835 mark_float(s, &mut float_blocked);
5836 }
5837 }
5838 MicroOp::MapSet { key, map_slot, .. } => {
5839 for s in [key, map_slot] {
5840 mark_float(s, &mut float_blocked);
5841 }
5842 }
5843 MicroOp::MapHas { dst, key, map_slot, .. } => {
5844 for s in [dst, key, map_slot] {
5845 mark_float(s, &mut float_blocked);
5846 }
5847 }
5848 _ => {}
5849 }
5850 }
5851 let mut profit: std::collections::HashMap<u16, i64> = std::collections::HashMap::new();
5852 let bump = |slot: u16, delta: i64, profit: &mut std::collections::HashMap<u16, i64>| {
5853 if slot < register_count {
5854 *profit.entry(slot).or_insert(0) += delta;
5855 }
5856 };
5857 for (i, op) in ops.iter().enumerate() {
5858 // Variant uses save a memory access (+); mem-form uses force a
5859 // spill (−). Each scaled by execution frequency (loop depth).
5860 let v = 2 * weight(i);
5861 let mv = 1 * weight(i);
5862 let m = -4 * weight(i);
5863 match *op {
5864 MicroOp::Add { dst, lhs, rhs }
5865 | MicroOp::Sub { dst, lhs, rhs }
5866 | MicroOp::Mul { dst, lhs, rhs }
5867 | MicroOp::BitAnd { dst, lhs, rhs }
5868 | MicroOp::BitOr { dst, lhs, rhs }
5869 | MicroOp::BitXor { dst, lhs, rhs }
5870 | MicroOp::Shl { dst, lhs, rhs }
5871 | MicroOp::Shr { dst, lhs, rhs }
5872 | MicroOp::Lt { dst, lhs, rhs }
5873 | MicroOp::Gt { dst, lhs, rhs }
5874 | MicroOp::LtEq { dst, lhs, rhs }
5875 | MicroOp::GtEq { dst, lhs, rhs }
5876 | MicroOp::Eq { dst, lhs, rhs }
5877 | MicroOp::Neq { dst, lhs, rhs } => {
5878 for s in [dst, lhs, rhs] {
5879 bump(s, v, &mut profit);
5880 }
5881 }
5882 MicroOp::Branch { lhs, rhs, .. } => {
5883 for s in [lhs, rhs] {
5884 bump(s, v, &mut profit);
5885 }
5886 }
5887 MicroOp::Move { dst, src } => {
5888 for s in [dst, src] {
5889 bump(s, mv, &mut profit);
5890 }
5891 }
5892 MicroOp::LoadConst { dst, .. } => bump(dst, mv, &mut profit),
5893 MicroOp::Return { src } => bump(src, mv, &mut profit),
5894 MicroOp::JumpIfFalse { cond, .. } | MicroOp::JumpIfTrue { cond, .. } => {
5895 bump(cond, -3 * weight(i), &mut profit);
5896 }
5897 MicroOp::Div { dst, lhs, rhs } | MicroOp::Mod { dst, lhs, rhs } => {
5898 for s in [dst, lhs, rhs] {
5899 bump(s, m, &mut profit);
5900 }
5901 }
5902 // DivPow2 is a single mem-form stencil (reads lhs, writes dst).
5903 // MagicDivU bails the pinned/function tier (no stencil), but its
5904 // operands still profit from residency on the regalloc path.
5905 MicroOp::DivPow2 { dst, lhs, .. } | MicroOp::MagicDivU { dst, lhs, .. } => {
5906 for s in [dst, lhs] {
5907 bump(s, m, &mut profit);
5908 }
5909 }
5910 // Float add/sub/mul are register-threaded now (V_FBINOP) → variant.
5911 MicroOp::AddF { dst, lhs, rhs }
5912 | MicroOp::SubF { dst, lhs, rhs }
5913 | MicroOp::MulF { dst, lhs, rhs } => {
5914 for s in [dst, lhs, rhs] {
5915 bump(s, v, &mut profit);
5916 }
5917 }
5918 // DivF is register-threaded now (V_DIVF) → variant. The float
5919 // comparisons have no XMM variant yet → mem-form.
5920 MicroOp::DivF { dst, lhs, rhs } => {
5921 for s in [dst, lhs, rhs] {
5922 bump(s, v, &mut profit);
5923 }
5924 }
5925 MicroOp::LtF { dst, lhs, rhs }
5926 | MicroOp::GtF { dst, lhs, rhs }
5927 | MicroOp::LtEqF { dst, lhs, rhs }
5928 | MicroOp::GtEqF { dst, lhs, rhs }
5929 | MicroOp::EqF { dst, lhs, rhs }
5930 | MicroOp::NeqF { dst, lhs, rhs } => {
5931 for s in [dst, lhs, rhs] {
5932 bump(s, m, &mut profit);
5933 }
5934 }
5935 // BranchF is register-threaded now (V_BRANCHF) → variant.
5936 MicroOp::BranchF { lhs, rhs, .. } => {
5937 for s in [lhs, rhs] {
5938 bump(s, v, &mut profit);
5939 }
5940 }
5941 // SqrtF is register-threaded now (V_SQRTF) → variant.
5942 MicroOp::SqrtF { dst, src } => {
5943 for s in [dst, src] {
5944 bump(s, v, &mut profit);
5945 }
5946 }
5947 // IntToFloat is register-threaded now (V_I2F) → variant.
5948 MicroOp::IntToFloat { dst, src } => {
5949 for s in [dst, src] {
5950 bump(s, v, &mut profit);
5951 }
5952 }
5953 MicroOp::NotInt { dst, src } | MicroOp::NotBool { dst, src } => {
5954 for s in [dst, src] {
5955 bump(s, m, &mut profit);
5956 }
5957 }
5958 MicroOp::ArrLoad { dst, idx, ptr_slot, len_slot, .. } => {
5959 for s in [dst, idx, ptr_slot, len_slot] {
5960 bump(s, m, &mut profit);
5961 }
5962 }
5963 MicroOp::ArrLoadAffine { dst, a, b, ptr_slot, len_slot, .. } => {
5964 for s in [dst, a, b, ptr_slot, len_slot] {
5965 bump(s, m, &mut profit);
5966 }
5967 }
5968 MicroOp::ArrLoad2F { dst, i: ix, j: jx, ptr_slot, len_slot, .. } => {
5969 for s in [dst, ix, jx, ptr_slot, len_slot] {
5970 bump(s, m, &mut profit);
5971 }
5972 }
5973 MicroOp::ArrLoad2 { dst, i: ix, j: jx, ptr_a, len_a, ptr_b, len_b, .. } => {
5974 for s in [dst, ix, jx, ptr_a, len_a, ptr_b, len_b] {
5975 bump(s, m, &mut profit);
5976 }
5977 }
5978 MicroOp::ArrStore { src, idx, ptr_slot, len_slot, .. } => {
5979 for s in [src, idx, ptr_slot, len_slot] {
5980 bump(s, m, &mut profit);
5981 }
5982 }
5983 MicroOp::ArrRMW { idx, operand, ptr_slot, len_slot, .. } => {
5984 for s in [idx, operand, ptr_slot, len_slot] {
5985 bump(s, m, &mut profit);
5986 }
5987 }
5988 MicroOp::ArrCondSwap { idx1, idx2, ptr_slot, len_slot, .. } => {
5989 for s in [idx1, idx2, ptr_slot, len_slot] {
5990 bump(s, m, &mut profit);
5991 }
5992 }
5993 MicroOp::ArrSwap { idx1, idx2, ptr_slot, len_slot, .. } => {
5994 for s in [idx1, idx2, ptr_slot, len_slot] {
5995 bump(s, m, &mut profit);
5996 }
5997 }
5998 // FmaF is introduced AFTER pin selection, so this arm never executes
5999 // at runtime — it exists only to keep the match exhaustive. Treat it
6000 // as a mem-form op for completeness.
6001 MicroOp::FmaF { dst, a, b, c } => {
6002 for s in [dst, a, b, c] {
6003 bump(s, m, &mut profit);
6004 }
6005 }
6006 MicroOp::ArrPush { src, vec_slot, ptr_slot, len_slot, .. } => {
6007 for s in [src, vec_slot, ptr_slot, len_slot] {
6008 bump(s, m, &mut profit);
6009 }
6010 }
6011 MicroOp::ListClear { vec_slot, ptr_slot, len_slot, .. } => {
6012 for s in [vec_slot, ptr_slot, len_slot] {
6013 bump(s, m, &mut profit);
6014 }
6015 }
6016 // The str-append's byte source is read across a SysV call (mem-form);
6017 // the handle slot is forced frame-resident, so count both as mem-form.
6018 MicroOp::StrAppend { text_handle_slot, src, .. } => {
6019 bump(text_handle_slot, m, &mut profit);
6020 if let logicaffeine_forge::jit::StrSrc::Byte(s) = src {
6021 bump(s, m, &mut profit);
6022 }
6023 }
6024 MicroOp::Call { dst, .. } | MicroOp::CallSelf { dst, .. } => {
6025 bump(dst, m, &mut profit)
6026 }
6027 MicroOp::CallSelfCopy { dst, src_start, arg_count, .. } => {
6028 bump(dst, m, &mut profit);
6029 for s in src_start..src_start + arg_count {
6030 bump(s, m, &mut profit);
6031 }
6032 }
6033 MicroOp::MapGet { dst, key, map_slot, .. } => {
6034 for s in [dst, key, map_slot] {
6035 bump(s, m, &mut profit);
6036 }
6037 }
6038 MicroOp::MapSet { src, key, map_slot, .. } => {
6039 for s in [src, key, map_slot] {
6040 bump(s, m, &mut profit);
6041 }
6042 }
6043 MicroOp::MapHas { dst, key, map_slot, .. } => {
6044 for s in [dst, key, map_slot] {
6045 bump(s, m, &mut profit);
6046 }
6047 }
6048 MicroOp::NewList { vec_slot, ptr_slot, len_slot, .. } => {
6049 for s in [vec_slot, ptr_slot, len_slot] {
6050 bump(s, m, &mut profit);
6051 }
6052 }
6053 MicroOp::ListTriple { handle_slot, vec_slot, ptr_slot, len_slot, .. } => {
6054 for s in [handle_slot, vec_slot, ptr_slot, len_slot] {
6055 bump(s, m, &mut profit);
6056 }
6057 }
6058 // A MemMem region runs UNPINNED (it reads/writes every slot through
6059 // the frame in the helper); count every touched slot as mem-form so
6060 // none would be selected even if the no-pin gate were ever lifted.
6061 MicroOp::MemMem {
6062 h_ptr_slot,
6063 h_len_slot,
6064 n_ptr_slot,
6065 n_len_slot,
6066 needle_len_slot,
6067 i_slot,
6068 count_slot,
6069 ..
6070 } => {
6071 for s in [
6072 h_ptr_slot,
6073 h_len_slot,
6074 n_ptr_slot,
6075 n_len_slot,
6076 needle_len_slot,
6077 i_slot,
6078 count_slot,
6079 ] {
6080 bump(s, m, &mut profit);
6081 }
6082 }
6083 MicroOp::Jump { .. } => {}
6084 }
6085 }
6086 // Threshold: flat model wants any net win (>2, historical); the region
6087 // model wants at least one depth-1 net benefit (>BASE) so cold depth-0
6088 // slots never pin.
6089 let threshold = if region { BASE } else { 2 };
6090 let ranked: Vec<(u16, i64)> = {
6091 let mut r: Vec<(u16, i64)> = profit
6092 .into_iter()
6093 .map(|(s, p)| (s, p - call_penalty))
6094 .filter(|&(_, p)| p > threshold)
6095 .collect();
6096 r.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
6097 r
6098 };
6099 // Split by budget: a float slot can only ride an XMM register, an int/bool
6100 // slot only a GP register; each budget independently takes its four best. A
6101 // float slot that is pin-blocked (touched by a register-form op with no XMM
6102 // lowering) gets NO pin — it stays frame-resident — but is still kept out
6103 // of the GP budget (its value is f64 bits the GP stencils would mishandle).
6104 let mut gp_pins: Vec<u16> = Vec::new();
6105 let mut float_pins: Vec<u16> = Vec::new();
6106 // A float pin lives in a caller-saved XMM register, threaded through the
6107 // stencil chain by the register-form float ops (AddF/SubF/MulF). It is only
6108 // sound in a REGION when EVERY op on its live range provably leaves the
6109 // other live XMM pins alone behind the chain's back — otherwise a pin is
6110 // clobbered and memory corrupts (spectral_norm SIGSEGV'd at tier-up: its
6111 // inner loop kept the float product in a pinned XMM across both the `aVal`
6112 // CALL, when un-inlined, and the inlined `1.0/denom` DivF + IntToFloat).
6113 // Rather than blocklist the clobbering ops — which silently misses any new
6114 // one — WHITELIST the ops proven NOT to disturb a live float pin:
6115 // register-form float arith (AddF/SubF/MulF and the fused float load), the
6116 // array load/store stencils, the mem-form float stencils that spill/reload
6117 // only the slot THEY touch (DivF/SqrtF/IntToFloat/BranchF and — Lever 2a —
6118 // the float ORDERING compares LtF/LtEqF/GtF/GtEqF), and the pure integer/
6119 // pointer/control ops (no XMM at all). ANYTHING else blocks: a call or
6120 // helper-call (Call/CallSelf/Map*/NewList/ListTriple/ArrPush — all wipe the
6121 // caller-saved xmm0–15). Float EQUALITY (EqF/NeqF) is NOT whitelisted here —
6122 // it stays on its (epsilon-correct) mem-form path but a region carrying it
6123 // still drops its float pins, a pre-existing limitation outside Lever 2a's
6124 // ordering-only scope. A MicroOp variant added later is unmatched and so
6125 // blocks by default: sound until it is classified. GP pins and the function
6126 // tier are unaffected.
6127 let xmm_pin_safe = |op: &MicroOp| {
6128 matches!(
6129 op,
6130 MicroOp::LoadConst { .. }
6131 | MicroOp::Move { .. }
6132 | MicroOp::Add { .. }
6133 | MicroOp::Sub { .. }
6134 | MicroOp::Mul { .. }
6135 | MicroOp::Div { .. }
6136 | MicroOp::DivPow2 { .. }
6137 | MicroOp::MagicDivU { .. }
6138 | MicroOp::Mod { .. }
6139 | MicroOp::Lt { .. }
6140 | MicroOp::Gt { .. }
6141 | MicroOp::Eq { .. }
6142 | MicroOp::LtEq { .. }
6143 | MicroOp::GtEq { .. }
6144 | MicroOp::Neq { .. }
6145 | MicroOp::Branch { .. }
6146 | MicroOp::BitAnd { .. }
6147 | MicroOp::BitOr { .. }
6148 | MicroOp::BitXor { .. }
6149 | MicroOp::Shl { .. }
6150 | MicroOp::Shr { .. }
6151 | MicroOp::NotInt { .. }
6152 | MicroOp::NotBool { .. }
6153 | MicroOp::AddF { .. }
6154 | MicroOp::SubF { .. }
6155 | MicroOp::MulF { .. }
6156 | MicroOp::DivF { .. }
6157 | MicroOp::SqrtF { .. }
6158 | MicroOp::IntToFloat { .. }
6159 | MicroOp::BranchF { .. }
6160 | MicroOp::LtF { .. }
6161 | MicroOp::LtEqF { .. }
6162 | MicroOp::GtF { .. }
6163 | MicroOp::GtEqF { .. }
6164 | MicroOp::ArrLoad { .. }
6165 | MicroOp::ArrLoad2F { .. }
6166 | MicroOp::ArrLoad2 { .. }
6167 | MicroOp::ArrStore { .. }
6168 | MicroOp::ArrRMW { .. }
6169 | MicroOp::ArrCondSwap { .. }
6170 | MicroOp::ArrSwap { .. }
6171 | MicroOp::FmaF { .. }
6172 | MicroOp::Jump { .. }
6173 | MicroOp::JumpIfFalse { .. }
6174 | MicroOp::JumpIfTrue { .. }
6175 | MicroOp::Return { .. }
6176 )
6177 };
6178 // LEVER 3 (fusion-aware pin selection): a float slot that is the RESULT `d`
6179 // of an `AddF` which `fuse_fma` will collapse into an `FmaF` — its product
6180 // operand is the single-use, un-named result of an immediately-preceding
6181 // `MulF` — is KEPT OUT of the float-pin budget. `fuse_fma` runs after pin
6182 // selection; a mem-form `FmaF` writing a PINNED `d` must reload that pin from
6183 // the frame afterwards (one extra piece), which exactly cancels the dispatch
6184 // the fusion saved. Leaving `d` frame-resident lets the `FmaF` write the
6185 // frame directly with NO reload — a true 2-pieces-to-1 win (the nbody
6186 // distance-sum's `dz*dz + partial`, whose single-use partial-sum was
6187 // wastefully pinned). Frame traffic on this temp is L1-free next to the
6188 // saved dispatch (the engine is per-op DISPATCH-bound). Excluding a slot
6189 // that turns out NOT to fuse is at worst neutral (one un-pinned float temp).
6190 // The product `t` is read by the AddF being removed; if `t` were pinned the
6191 // fusion would not fire, so it is left to its own pin eligibility.
6192 let mut fma_result_temps: std::collections::HashSet<u16> = std::collections::HashSet::new();
6193 {
6194 let mut read_counts: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
6195 for op in ops {
6196 for s in micro_read_slots(op) {
6197 *read_counts.entry(s).or_insert(0) += 1;
6198 }
6199 }
6200 for k in 0..ops.len().saturating_sub(1) {
6201 let MicroOp::MulF { dst: t, .. } = ops[k] else { continue };
6202 // The addend `c` is the AddF operand that is NOT the product `t`.
6203 let (d, c) = match ops[k + 1] {
6204 MicroOp::AddF { dst, lhs, rhs } if lhs == t => (dst, rhs),
6205 MicroOp::AddF { dst, lhs, rhs } if rhs == t => (dst, lhs),
6206 _ => continue,
6207 };
6208 // The ACCUMULATOR shape `s = s + a*b` (`c == d`) is NOT a Lever-3
6209 // candidate: there `d` is a loop-carried float that must stay pinned,
6210 // and Lever 1a deliberately refuses to fuse it (a pinned addend `c`
6211 // would have to be spilled). Only a result `d` DISTINCT from its
6212 // addend is the genuine throwaway partial-sum the FmaF absorbs.
6213 if d == c {
6214 continue;
6215 }
6216 // `t` is single-use scratch (only the AddF reads it) — `fuse_fma`'s
6217 // removability precondition for the product (named `t`s are rejected
6218 // there too, but excluding their `d` here is at worst neutral: one
6219 // un-pinned float temp, free next to the per-op dispatch).
6220 let t_dead = (t as usize) < register_count as usize
6221 && read_counts.get(&t).copied().unwrap_or(0) == 1;
6222 if t_dead {
6223 fma_result_temps.insert(d);
6224 }
6225 }
6226 }
6227 // XMM float pinning is enabled for Main-loop REGIONS whose ops are all
6228 // XMM-aware. Array regions are now admitted: the earlier spectral_norm
6229 // SIGSEGV was a TYPE-REUSED slot (a `MulF` result reused as an `ArrStore`
6230 // index) being float-pinned — fixed above by blocking every integer-role
6231 // slot from the float-pin budget, so a mem-form array op never spills an
6232 // f64 over an integer index. FUNCTIONS still keep their floats frame-
6233 // resident (the mode-B call boundary round-trips them anyway).
6234 let block_float_pins = !region || !ops.iter().all(xmm_pin_safe);
6235 for (slot, _) in ranked {
6236 if float_slots.contains(&slot) {
6237 if !block_float_pins
6238 && !float_blocked.contains(&slot)
6239 && !fma_result_temps.contains(&slot)
6240 && float_pins.len() < 6
6241 {
6242 float_pins.push(slot);
6243 }
6244 } else if gp_pins.len() < 4 {
6245 gp_pins.push(slot);
6246 }
6247 }
6248 (gp_pins, float_pins)
6249}
6250
6251impl NativeTier for ForgeTier {
6252 fn compile_function(
6253 &self,
6254 code: &[Op],
6255 entry_pc: usize,
6256 constants: &[Constant],
6257 param_count: u16,
6258 register_count: u16,
6259 self_fi: u16,
6260 param_kinds: &[Option<ParamKind>],
6261 ret_kind: Option<SlotKind>,
6262 ctx: &NativeCtx,
6263 callees: &[CalleeSig],
6264 ) -> Option<Box<dyn NativeFn>> {
6265 self.compiles.fetch_add(1, Ordering::SeqCst);
6266 // The frame layout (and therefore the limit slot the Call micros
6267 // bake) depends on the mode — compute it before adapting, with
6268 // EXACTLY the adapter's pin accounting (params + allocation sites
6269 // + list-returning self-call results); the adapter asserts the
6270 // agreement.
6271 let n_list_params = param_kinds
6272 .iter()
6273 .filter(|k| matches!(k, Some(ParamKind::List(_))))
6274 .count();
6275 let has_sites = code.iter().any(|op| matches!(op, Op::NewEmptyList { .. }));
6276 let mode_b = n_list_params > 0 || has_sites;
6277 let fn_returns_lists = ret_kind.is_none() && mode_b;
6278 let list_param_regs: Vec<u16> = param_kinds
6279 .iter()
6280 .enumerate()
6281 .filter_map(|(i, k)| {
6282 matches!(k, Some(ParamKind::List(_))).then_some(i as u16)
6283 })
6284 .collect();
6285 let npins = discover_list_regs(
6286 code,
6287 &list_param_regs,
6288 fn_returns_lists,
6289 self_fi,
6290 register_count as usize + 2,
6291 )
6292 .iter()
6293 .filter(|&&b| b)
6294 .count();
6295 let frame_size = if mode_b {
6296 register_count as usize + 6 + 3 * npins
6297 } else {
6298 register_count as usize + 3
6299 };
6300 // Self-recursion advances one full frame per level and stages the
6301 // callee window BEFORE the depth check runs — the arena must hold
6302 // MAX_CALL_DEPTH + 1 frames so staging writes can never cross the
6303 // buffer even on the deopt path.
6304 if frame_size * (logicaffeine_compile::semantics::MAX_CALL_DEPTH + 1) > ARENA_SLOTS {
6305 return None;
6306 }
6307 let call_ctx = CallCtx {
6308 table_addr: ctx.table.slot_addr(self_fi as usize),
6309 depth_addr: std::sync::Arc::as_ptr(&ctx.depth) as i64,
6310 status_addr: std::sync::Arc::as_ptr(&ctx.status) as i64,
6311 limit_slot: (frame_size - 1) as u16,
6312 depth_limit: logicaffeine_compile::semantics::MAX_CALL_DEPTH as i64,
6313 self_fi,
6314 table: ctx.table.as_ref(),
6315 };
6316 let adapted = adapt_function(
6317 code,
6318 entry_pc,
6319 constants,
6320 param_count,
6321 register_count,
6322 self_fi,
6323 param_kinds,
6324 ret_kind,
6325 &call_ctx,
6326 callees,
6327 )?;
6328 debug_assert_eq!(adapted.frame_size, frame_size);
6329 // LINEAR SCAN (EXODIA 3.1), mode A only: mode B's precise deopt
6330 // reads the frame mid-run, which the pinning contract forbids.
6331 // Profit = variant-family uses − spill/reload churn at memory-form
6332 // ops − the prologue reload; the four best positive slots pin.
6333 // The value-form float `>`/`>=` compares (`GtF`/`GtEqF`) now have a
6334 // mem-form lowering (Lever 2a: swap operands, reuse the LtF/LeF stencil —
6335 // IEEE-exact, NaN-false), so they spill/reload their pinned operands like
6336 // any mem-form op and no longer force the whole function unpinned.
6337 // `ListClear` is still excluded: the mem-form emitter cannot lower it
6338 // without leaving a pinned ptr/len register stale (it would miscompile —
6339 // observed on knapsack).
6340 let pinnable = !adapted.micro.iter().any(|op| {
6341 matches!(op, MicroOp::ListClear { .. })
6342 });
6343 // WS-G WAVE 12: the CONTIGUOUS regalloc FUNCTION backend. For a
6344 // non-precise mode-A function whose every op (the self-calls included)
6345 // is in the backend's supported subset, emit ONE register-allocated
6346 // x86-64 function with a real SysV self-call — 4-6x faster than the
6347 // per-piece tier on the recursion cluster. Returns None on any
6348 // unsupported op (a cross-function Call, list/map/byte ops, …), in which
6349 // case we fall through to the pinned per-piece tier, byte-identical to
6350 // today. The self-entry patch is INTERNAL (an entry cell), so the
6351 // stencil patch path below is skipped for this chain. `LOGOS_REGALLOC=0`
6352 // is the kill-switch.
6353 #[cfg(target_arch = "x86_64")]
6354 let regalloc_enabled = logicaffeine_forge::regalloc::regalloc_enabled()
6355 && !std::env::var("LOGOS_JIT_REGALLOC").is_ok_and(|v| v == "0");
6356 #[cfg(target_arch = "x86_64")]
6357 let regalloc_chain: Option<CompiledChain> = if !regalloc_enabled {
6358 None
6359 } else if adapted.precise.is_none() {
6360 // MODE A (scalar, classic replay deopt): the original contiguous
6361 // FUNCTION backend.
6362 logicaffeine_forge::regalloc::compile_function_regalloc(
6363 &adapted.micro,
6364 Some(ctx.status.clone()),
6365 call_ctx.depth_addr,
6366 )
6367 } else if std::env::var("LOGOS_REGALLOC_PRECISE").as_deref() != Ok("0") {
6368 // MODE B (list-param, in-place array mutation): the PRECISE
6369 // contiguous backend — a side exit materializes the native frame and
6370 // resumes AT the faulting op (no replay-from-head double-apply). Only
6371 // when the adapter built the per-op deopt-code table. `None` on any
6372 // unsupported op (e.g. a list-RETURN allocation/Map shape) → fall back
6373 // to the per-piece precise stencil tier, byte-identical to today.
6374 // `LOGOS_REGALLOC_PRECISE=0` is the targeted kill-switch.
6375 adapted.deopt_codes.as_deref().and_then(|codes| {
6376 logicaffeine_forge::regalloc::compile_function_regalloc_precise(
6377 &adapted.micro,
6378 Some(ctx.status.clone()),
6379 call_ctx.depth_addr,
6380 codes,
6381 )
6382 })
6383 } else {
6384 None
6385 };
6386 #[cfg(not(target_arch = "x86_64"))]
6387 let regalloc_chain: Option<CompiledChain> = None;
6388
6389 let chain = if let Some(c) = regalloc_chain {
6390 self.regalloc_functions.fetch_add(1, Ordering::SeqCst);
6391 // A `CallSelfCopy` in the stream is a FUSED contiguous-arg self-call
6392 // (Lever A's fusion, decided by the adapter) — the regalloc backend
6393 // stages the contiguous arg block in one go just like the stencil
6394 // tier did, so it remains the same observable. Count it (the
6395 // kill-switch path emits per-`Move` `CallSelf` instead, leaving this
6396 // at zero).
6397 let fused = adapted
6398 .micro
6399 .iter()
6400 .filter(|op| matches!(op, MicroOp::CallSelfCopy { .. }))
6401 .count() as u32;
6402 if fused != 0 {
6403 self.pinned_self_calls.fetch_add(fused, Ordering::SeqCst);
6404 }
6405 c
6406 } else {
6407 let (gp_pins, float_pins): (Vec<u16>, Vec<u16>) = if pinnable
6408 && adapted.precise.is_none()
6409 && !std::env::var("LOGOS_JIT_REGALLOC").is_ok_and(|v| v == "0")
6410 {
6411 select_pins(&adapted.micro, register_count, false)
6412 } else {
6413 (Vec::new(), Vec::new())
6414 };
6415 let chain = if gp_pins.is_empty() && float_pins.is_empty() {
6416 compile_straightline_coded(
6417 &adapted.micro,
6418 Some(ctx.status.clone()),
6419 adapted.deopt_codes.as_deref(),
6420 call_ctx.depth_addr,
6421 )
6422 .ok()?
6423 } else {
6424 compile_straightline_pinned_float(
6425 &adapted.micro,
6426 &gp_pins,
6427 &float_pins,
6428 Some(ctx.status.clone()),
6429 )
6430 .ok()?
6431 };
6432 // Self-entry patch: write this chain's base into every direct
6433 // self-call site. Targets without post-seal patching (MAP_JIT)
6434 // fail here — fall back to bytecode rather than run unpatched.
6435 if chain.has_patch_marks() && chain.patch_marked(chain.base()).is_err() {
6436 return None;
6437 }
6438 let fused = adapted
6439 .micro
6440 .iter()
6441 .filter(|op| matches!(op, MicroOp::CallSelfCopy { .. }))
6442 .count() as u32;
6443 if fused != 0 {
6444 self.pinned_self_calls.fetch_add(fused, Ordering::SeqCst);
6445 }
6446 chain
6447 };
6448 self.successes.fetch_add(1, Ordering::SeqCst);
6449 Some(Box::new(ChainFn {
6450 chain,
6451 limit_slot: call_ctx.limit_slot as usize,
6452 depth: ctx.depth.clone(),
6453 ret: adapted.ret,
6454 published_regc: (frame_size - 3) as i64,
6455 precise: adapted.precise,
6456 stats: self.runtime.clone(),
6457 }))
6458 }
6459
6460 fn compile_region(
6461 &self,
6462 code: &[Op],
6463 head_pc: usize,
6464 exit_pc: usize,
6465 constants: &[Constant],
6466 register_count: u16,
6467 named: &[bool],
6468 observed: &[ObservedKind],
6469 ctx: &NativeCtx,
6470 callees: &[logicaffeine_compile::vm::CalleeSig],
6471 ) -> Option<Box<dyn RegionFn>> {
6472 self.region_compiles.fetch_add(1, Ordering::SeqCst);
6473 let dbg = std::env::var_os("LOGOS_RDIAG").is_some();
6474 let Some((mut micro, frame_size, guard, free, writes, arrays, region_return, hoist_guards, precise)) =
6475 adapt_region(
6476 code,
6477 head_pc,
6478 exit_pc,
6479 constants,
6480 register_count,
6481 named,
6482 observed,
6483 ctx,
6484 callees,
6485 ) else {
6486 if dbg { eprintln!("RDIAG head_pc={head_pc}: adapt_region -> None"); }
6487 return None;
6488 };
6489 let has_calls = code.iter().any(|op| matches!(op, Op::Call { .. }));
6490 // A calling region's chain must check the SHARED status cell — the
6491 // call stencil writes its deopt marker there. A PRECISE region needs it
6492 // too: every side exit stores its encoded resume pc through it.
6493 let status =
6494 (has_calls || precise.is_some()).then(|| ctx.status.clone());
6495 // REGION REGISTER ALLOCATION (EXODIA 3.1, sprint 13's deferred half).
6496 // Pin the hottest INTEGER slots that are not in the write-set or the
6497 // array-handle set: the write-set must stay frame-resident for the
6498 // VM's success copy-back, and dead-at-exit slots (loop counters,
6499 // read-only bounds, scratch) need only the prologue reload — so NO
6500 // exit-spill machinery is required, and a mid-loop side-exit keeps
6501 // the discard-and-replay contract untouched (the VM replays from the
6502 // region head on its OWN registers; the native frame's scalars are
6503 // never read back). The value-form float `>`/`>=` compares (`GtF`/
6504 // `GtEqF`) now have a mem-form lowering (Lever 2a: swap operands, reuse
6505 // the LtF/LeF stencil — IEEE-exact, NaN-false), so a region carrying a
6506 // value-form float `>`/`>=` keeps its XMM pins instead of dropping all
6507 // of them. (A float `>` USED AS A BRANCH already fuses into the register-
6508 // form `BranchF`; only the VALUE form produces a surviving `GtF`/`GtEqF`.)
6509 //
6510 // `ListClear` is still excluded: the mem-form emitter has no arm for it
6511 // (it resets a list's vec/ptr/len through a helper, which would leave a
6512 // pinned ptr/len register stale), so a pinned region containing one
6513 // would miscompile (observed: knapsack's DP-row reuse). Such a region
6514 // stays unpinned and runs the correct unpinned path.
6515 let pinnable = !micro.iter().any(|op| {
6516 // A `MemMem` region reads/writes every operand through the frame in
6517 // its helper, so it must run UNPINNED (no register-resident slot the
6518 // helper would miss); `ListClear`'s mem-form emitter has no pinned arm.
6519 matches!(op, MicroOp::ListClear { .. } | MicroOp::MemMem { .. })
6520 });
6521 let (gp_pins, float_pins): (Vec<u16>, Vec<u16>) = if !pinnable
6522 // Precise regions materialize the frame's scalars on a side exit, so
6523 // every scalar must stay frame-resident — no GP register pinning
6524 // (mirrors the function tier's `adapted.precise.is_none()` gate).
6525 || precise.is_some()
6526 || std::env::var("LOGOS_JIT_REGALLOC").is_ok_and(|v| v == "0")
6527 {
6528 (Vec::new(), Vec::new())
6529 } else {
6530 // Phase 2 (write-set pinning): the WRITE-SET is now eligible —
6531 // the pinned compiler's success-exit epilogue spills each pinned
6532 // slot back to its frame cell, so the VM's write-back still sees
6533 // loop-carried values. Only ARRAY handle registers stay excluded
6534 // (they are pinned through the dedicated ptr/len triple, not GP
6535 // register threading).
6536 let array_regs: std::collections::HashSet<u16> =
6537 arrays.iter().map(|a| a.reg).collect();
6538 let (gp, fp) = select_pins(µ, register_count, true);
6539 let mut gp: Vec<u16> =
6540 gp.into_iter().filter(|r| !array_regs.contains(r)).collect();
6541 let fp: Vec<u16> = fp.into_iter().filter(|r| !array_regs.contains(r)).collect();
6542 // REGISTER-FORM array access: hand the hottest INT array whose base
6543 // pointer is STABLE (never pushed-to in this region → no realloc) a
6544 // free GP register, so its word ArrLoad/ArrStore read the pointer
6545 // from a register instead of re-loading the frame cell every access.
6546 // Parked default-OFF: measured ~0% (the interpreter is per-op
6547 // DISPATCH-bound, not frame-traffic-bound — re-reading the array
6548 // pointer from its L1-hot frame cell is free next to the stencil
6549 // dispatch). `LOGOS_ARRPTR=1` re-enables for experiments.
6550 if gp.len() < 4 && std::env::var("LOGOS_ARRPTR").is_ok() {
6551 let pushed: std::collections::HashSet<u16> = micro
6552 .iter()
6553 .filter_map(|op| match op {
6554 MicroOp::ArrPush { vec_slot, .. } => Some(*vec_slot),
6555 _ => None,
6556 })
6557 .collect();
6558 let mut best: Option<(u16, usize)> = None;
6559 for a in &arrays {
6560 if a.elem != PinElem::Int || pushed.contains(&a.vec_slot) {
6561 continue;
6562 }
6563 let cnt = micro
6564 .iter()
6565 .filter(|op| match op {
6566 MicroOp::ArrLoad { ptr_slot, byte: false, .. }
6567 | MicroOp::ArrStore { ptr_slot, byte: false, .. } => {
6568 *ptr_slot == a.ptr_slot
6569 }
6570 _ => false,
6571 })
6572 .count();
6573 if cnt > 0 && best.map_or(true, |(_, b)| cnt > b) {
6574 best = Some((a.ptr_slot, cnt));
6575 }
6576 }
6577 if let Some((ptr_slot, _)) = best {
6578 gp.push(ptr_slot);
6579 }
6580 }
6581 (gp, fp)
6582 };
6583 // COPY-PROPAGATION + CONSTANT CSE — kills the redundant scratch-Move
6584 // chains and re-loaded constants the bytecode lowering leaves in hot
6585 // loops (the per-op DISPATCH is the floor, so fewer ops = faster). Run
6586 // AFTER pin selection, protecting the chosen pins, so it never perturbs
6587 // the greedy allocator (the reason the earlier pre-selection version had
6588 // to be array-free-gated) — now it applies in array regions too. Sound
6589 // only for replay-from-head (non-precise) regions: removing micro ops
6590 // renumbers jump targets (remapped) but would break per-op precise
6591 // resume codes. `LOGOS_COPYPROP=0` is the kill-switch.
6592 if precise.is_none() && std::env::var("LOGOS_COPYPROP").as_deref() != Ok("0") {
6593 let pinset: std::collections::HashSet<u16> =
6594 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6595 copy_propagate(&mut micro, named, register_count, &pinset);
6596 }
6597 // WS-G PRE-FUSION SNAPSHOT (approach a). The array-fusion passes below
6598 // (`ArrRMW`/`ArrLoad2`/`ArrLoadAffine`/`ArrCondSwap`/`ArrSwap`) exist to
6599 // cut PIECE count in the per-piece stencil tier. The CONTIGUOUS regalloc
6600 // backend has no per-piece overhead and handles RAW `ArrLoad`/`ArrStore`
6601 // directly, so it must see the UN-fused stream. Snapshot the cleaned
6602 // (copy-propagated) micro here, BEFORE the fusions, and try regalloc on
6603 // it at the dispatch below; only if regalloc declines do the fusions and
6604 // the per-piece stencil tier take over the (mutated) `micro`. The
6605 // snapshot is taken only for a non-precise region (the only kind
6606 // regalloc compiles); precise regions never reach it.
6607 #[cfg(target_arch = "x86_64")]
6608 let prefusion_micro: Option<Vec<MicroOp>> =
6609 (precise.is_none() && logicaffeine_forge::regalloc::regalloc_enabled())
6610 .then(|| micro.clone());
6611 // ARRAY READ-MODIFY-WRITE FUSION: after copy-prop/CSE has cleaned the
6612 // stream and deduped the operand constant, collapse `arr[i] = arr[i] OP
6613 // operand` (ArrLoad + int ALU + ArrStore) into one ArrRMW stencil — the
6614 // dispatch-reduction lever for the indexed-RMW loops (histogram,
6615 // counting sort). Non-precise only (it renumbers micro indices).
6616 // `LOGOS_RMW=0` is the kill-switch.
6617 if precise.is_none() && std::env::var("LOGOS_RMW").as_deref() != Ok("0") {
6618 let pinset: std::collections::HashSet<u16> =
6619 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6620 fuse_array_rmw(&mut micro, named, register_count, &pinset);
6621 }
6622 // TWO-BUFFER INTEGER LOAD+BINOP FUSION: collapse `a[i] OP b[j]`
6623 // (ArrLoad + ArrLoad + int Add/Sub/Mul) into one ArrLoad2 stencil — the
6624 // dispatch-reduction lever for the integer dot-product / matrix-multiply
6625 // inner loop, where two distinct pinned buffers feed a binop. Runs after
6626 // RMW (the c-accumulator's load+add+mod+store is not a clean RMW, so the
6627 // two passes never contend for the same ops). Non-precise only (it
6628 // renumbers micro indices). `LOGOS_LD2=0` is the kill-switch.
6629 if precise.is_none() && std::env::var("LOGOS_LD2").as_deref() != Ok("0") {
6630 let pinset: std::collections::HashSet<u16> =
6631 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6632 fuse_array_ld2(&mut micro, named, register_count, &pinset);
6633 }
6634 // FLOAT MULTIPLY-ADD FUSION: merge `a*b; +c` (MulF feeding a single-use
6635 // AddF) into one FmaF stencil — the dispatch-reduction lever for the
6636 // float arithmetic chains in the float cluster (nbody). Only fuses
6637 // frame-resident operands (spill-free). `LOGOS_FMA=0` is the kill-switch.
6638 if precise.is_none() && std::env::var("LOGOS_FMA").as_deref() != Ok("0") {
6639 let pinset: std::collections::HashSet<u16> =
6640 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6641 fuse_fma(&mut micro, named, register_count, &pinset);
6642 }
6643 // CONDITIONAL-SWAP FUSION: collapse the sort inner-loop compare-and-swap
6644 // (ArrLoad×2 + Branch + ArrStore×2 → 1) — now fires thanks to the
6645 // precise-region-live-out keystone freeing the loop-local a/b.
6646 // `LOGOS_CONDSWAP=0` kills it.
6647 if std::env::var("LOGOS_DUMP_MICRO").ok().and_then(|v| v.parse::<usize>().ok()) == Some(head_pc) {
6648 eprintln!("=== MICRO head_pc={head_pc} ({} ops) ===", micro.len());
6649 for (i, m) in micro.iter().enumerate() {
6650 eprintln!(" [{i}] {m:?}");
6651 }
6652 }
6653 if precise.is_none() && std::env::var("LOGOS_CONDSWAP").as_deref() != Ok("0") {
6654 let pinset: std::collections::HashSet<u16> =
6655 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6656 fuse_cond_swap(&mut micro, named, register_count, &pinset);
6657 }
6658 // UNCONDITIONAL-SWAP FUSION: collapse the `tmp` exchange (quick/heap/merge
6659 // sort: load,load,store,store → 1). `LOGOS_SWAP=0` kills it.
6660 if precise.is_none() && std::env::var("LOGOS_SWAP").as_deref() != Ok("0") {
6661 let pinset: std::collections::HashSet<u16> =
6662 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6663 fuse_swap(&mut micro, named, register_count, &pinset);
6664 }
6665 // AFFINE-INDEX LOAD FUSION: fold the index arithmetic of a read-only
6666 // array load (`prev[w - wi + 1]`, the heap-sift reads, graph adjacency
6667 // reads) into the load stencil. Runs LAST among the array fusions so the
6668 // two-buffer (`ArrLoad2`), RMW, and swap fusions claim their raw loads
6669 // first; this pass only takes the plain `ArrLoad`s they leave behind.
6670 // `LOGOS_AFFINE=0` is the kill-switch.
6671 if precise.is_none() && std::env::var("LOGOS_AFFINE").as_deref() != Ok("0") {
6672 let pinset: std::collections::HashSet<u16> =
6673 gp_pins.iter().chain(float_pins.iter()).copied().collect();
6674 fuse_index_affine(&mut micro, named, register_count, &pinset);
6675 }
6676 if dbg {
6677 eprintln!(
6678 "RDIAG head_pc={head_pc}: adapt OK frame={frame_size} pinnable={pinnable} gp_pins={gp_pins:?} float_pins={float_pins:?} arrays={}",
6679 arrays.len()
6680 );
6681 }
6682 // WS-G CONTIGUOUS REGALLOC BACKEND (LOGOS_REGALLOC default ON).
6683 // For a non-precise integer region whose every op is in the contiguous
6684 // backend's supported subset, emit ONE register-allocated function (no
6685 // per-piece stencil boundaries). Returns None on any unsupported op →
6686 // we fall through to the existing per-piece tiers, so behavior with the
6687 // flag off — or on an unsupported region — is byte-identical to today.
6688 //
6689 // It is tried on the PRE-FUSION snapshot (approach a): the backend has
6690 // no per-piece cost, so it wants the RAW `ArrLoad`/`ArrStore` stream,
6691 // not the fused `ArrRMW`/`ArrLoad2`/… the array fusions produced for the
6692 // stencil tier. If regalloc declines (an unsupported op — float arrays,
6693 // byte loads, calls, globals, list ops, …) we fall through to the
6694 // already-fused `micro` and the per-piece tier, byte-identical to today.
6695 #[cfg(target_arch = "x86_64")]
6696 let regalloc_chain: Option<CompiledChain> = prefusion_micro
6697 .as_ref()
6698 .and_then(|m| logicaffeine_forge::regalloc::compile_region_regalloc(m, status.clone()));
6699 #[cfg(not(target_arch = "x86_64"))]
6700 let regalloc_chain: Option<CompiledChain> = None;
6701
6702 let chain = if let Some(c) = regalloc_chain {
6703 self.regalloc_regions.fetch_add(1, Ordering::SeqCst);
6704 if dbg {
6705 eprintln!("RDIAG head_pc={head_pc}: CONTIGUOUS REGALLOC backend (arrays={})", arrays.len());
6706 }
6707 c
6708 } else if let Some(p) = precise.as_ref() {
6709 // PRECISE region (the fannkuch / graph_bfs worklist shape: an
6710 // in-place `SetIndex` beside a reallocating `ArrPush`). The depth
6711 // cell value rides the resume tag's high 32 bits; the VM masks it out
6712 // of the region resume (only the low-32 resume code is read back).
6713 let depth_addr = std::sync::Arc::as_ptr(&ctx.depth) as i64;
6714 // WS-G WAVE 21: try the CONTIGUOUS regalloc backend FIRST, with the
6715 // per-op precise deopt codes. It register-allocates the region's
6716 // scalars (the per-piece precise stencil tier kept every scalar
6717 // frame-resident and paid the 4-6× per-piece dispatch overhead) while
6718 // flushing every resident-written scalar to its frame slot at each
6719 // precise side exit, so the VM's resume reads correct values. No
6720 // fusions / copy-prop ran (all gated `precise.is_none()`), so `micro`
6721 // is the raw lowered stream `p.deopt_codes` is parallel to. On any
6722 // unsupported op the backend declines and the per-piece precise tier
6723 // takes over — byte-identical to before this wave.
6724 #[cfg(target_arch = "x86_64")]
6725 let precise_regalloc: Option<CompiledChain> = (logicaffeine_forge::regalloc::regalloc_enabled()
6726 && std::env::var("LOGOS_REGALLOC_PRECISE").as_deref() != Ok("0"))
6727 .then(|| {
6728 logicaffeine_forge::regalloc::compile_region_regalloc_precise(
6729 µ,
6730 status.clone(),
6731 depth_addr,
6732 &p.deopt_codes,
6733 )
6734 })
6735 .flatten();
6736 #[cfg(not(target_arch = "x86_64"))]
6737 let precise_regalloc: Option<CompiledChain> = None;
6738 if let Some(c) = precise_regalloc {
6739 self.regalloc_regions.fetch_add(1, Ordering::SeqCst);
6740 self.regalloc_precise_regions.fetch_add(1, Ordering::SeqCst);
6741 if dbg {
6742 eprintln!("RDIAG head_pc={head_pc}: CONTIGUOUS REGALLOC backend (PRECISE, arrays={})", arrays.len());
6743 }
6744 c
6745 } else {
6746 match compile_straightline_coded(µ, status, Some(&p.deopt_codes), depth_addr) {
6747 Ok(c) => c,
6748 Err(e) => {
6749 if dbg { eprintln!("RDIAG head_pc={head_pc}: compile_straightline_coded(precise) -> Err({e:?})"); }
6750 return None;
6751 }
6752 }
6753 }
6754 } else if gp_pins.is_empty() && float_pins.is_empty() {
6755 match compile_straightline_with(µ, status) {
6756 Ok(c) => c,
6757 Err(e) => {
6758 if dbg { eprintln!("RDIAG head_pc={head_pc}: compile_straightline_with -> Err({e:?})"); }
6759 return None;
6760 }
6761 }
6762 } else {
6763 match compile_straightline_pinned_float(µ, &gp_pins, &float_pins, status) {
6764 Ok(c) => c,
6765 Err(e) => {
6766 if dbg { eprintln!("RDIAG head_pc={head_pc}: compile_straightline_pinned_float -> Err({e:?})"); }
6767 return None;
6768 }
6769 }
6770 };
6771 self.region_successes.fetch_add(1, Ordering::SeqCst);
6772 let call_support = has_calls.then(|| {
6773 (ARENA_SLOTS, (frame_size - 1) as u16, ctx.depth.clone())
6774 });
6775 Some(Box::new(RegionChain {
6776 chain,
6777 frame_size,
6778 call_support,
6779 guard,
6780 free,
6781 writes,
6782 arrays,
6783 region_return,
6784 hoist_guards,
6785 precise_kinds: precise.map(|p| p.kinds_by_pc),
6786 }))
6787 }
6788}
6789
6790/// Install the forge tier as the process-wide native tier. Idempotent —
6791/// the first call wins; later calls return the already-installed tier.
6792///
6793/// Call once at binary startup (CLI, server). The live VM constructors in
6794/// `logicaffeine-compile` pick it up for every program they run.
6795///
6796/// `LOGOS_NATIVE_TIER=0` skips installation entirely — the diagnostic
6797/// kill-switch that isolates pure-bytecode wall time from tiered runs.
6798/// Install the diagnostic SIGSEGV/SIGBUS tracer (no-op unless `LOGOS_SEGV_TRAP`
6799/// is set). Used to localize faults inside JIT'd code while root-causing.
6800pub fn segv_trace_install() {
6801 logicaffeine_forge::segv_trace::install();
6802}
6803
6804pub fn install() -> &'static ForgeTier {
6805 static TIER: std::sync::OnceLock<ForgeTier> = std::sync::OnceLock::new();
6806 let tier = TIER.get_or_init(ForgeTier::new);
6807 if !std::env::var("LOGOS_NATIVE_TIER").is_ok_and(|v| v == "0") {
6808 install_native_tier(tier);
6809 }
6810 tier
6811}
6812
6813#[cfg(test)]
6814mod memmem_safety_tests {
6815 use super::*;
6816
6817 /// SECURITY/UB regression (CRITIQUE #6). `logos_rt_memmem` cast its `i64` lengths
6818 /// straight to `usize` for `from_raw_parts`, so a negative `needle_len` or
6819 /// `haystack_len` became a ~2^64 slice length — immediate undefined behavior
6820 /// (`from_raw_parts` requires `len <= isize::MAX`), reachable on program-controlled
6821 /// input. The guard must bail to the recoverable deopt sentinel before constructing
6822 /// any slice. (The pre-fix failure is UB, so it is established by inspection of the
6823 /// `from_raw_parts` calls rather than executed as a crashing test.)
6824 #[test]
6825 fn out_of_range_lengths_bail_safely_without_constructing_an_invalid_slice() {
6826 let haystack = b"abcabc";
6827 let needle = b"bc";
6828 let hp = haystack.as_ptr() as i64;
6829 let np = needle.as_ptr() as i64;
6830 unsafe {
6831 // negative needle_len → ~2^64 slice length pre-fix; must bail.
6832 assert_eq!(
6833 logos_rt_memmem(hp, 6, np, 2, -1, 1, 0),
6834 LOGOS_MEMMEM_DEOPT,
6835 "negative needle_len must bail to deopt, never reach from_raw_parts"
6836 );
6837 assert_eq!(logos_rt_memmem(hp, -1, np, 2, 2, 1, 0), LOGOS_MEMMEM_DEOPT, "negative haystack_len must bail");
6838 assert_eq!(logos_rt_memmem(hp, 6, np, -1, 2, 1, 0), LOGOS_MEMMEM_DEOPT, "negative needle_buf_len must bail");
6839 }
6840 }
6841
6842 /// Regression: valid inputs still search correctly after the guard is added.
6843 /// `"bc"` occurs twice in `"abcabc"` (0-based offsets 1 and 4).
6844 #[test]
6845 fn valid_search_still_counts_matches() {
6846 let haystack = b"abcabc";
6847 let needle = b"bc";
6848 let count =
6849 unsafe { logos_rt_memmem(haystack.as_ptr() as i64, 6, needle.as_ptr() as i64, 2, 2, 1, 0) };
6850 assert_eq!(count, 2, "the guard must not change correct search results");
6851 }
6852}
6853
6854#[cfg(test)]
6855mod select_pins_tests {
6856 use super::*;
6857
6858 /// A slot used as BOTH an f64 (a `MulF` result, float-valued) AND an i64
6859 /// (an `ArrStore` index, integer-valued) is type-reused scratch. It must
6860 /// NEVER be float-pinned: the mem-form `ArrStore` spills the float pin to
6861 /// the frame cell, overwriting the integer index the `Add` wrote, so the
6862 /// store indexes through an f64 bit-pattern → a wild write. This was the
6863 /// spectral_norm SIGSEGV. The integer-role classification in `select_pins`
6864 /// must keep the mixed slot out of the float-pin budget (frame-resident).
6865 #[test]
6866 fn float_result_reused_as_array_index_is_not_float_pinned() {
6867 // register_count = 8 → slots 0..7 are registers; 8/9 stand in for the
6868 // pinned array's ptr/len cells. Slot 5 is the type-reused cell.
6869 let ops = vec![
6870 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 6 },
6871 MicroOp::MulF { dst: 5, lhs: 2, rhs: 3 }, // slot 5 holds an f64 …
6872 MicroOp::AddF { dst: 4, lhs: 4, rhs: 5 }, // … consumed as a float
6873 MicroOp::Add { dst: 5, lhs: 0, rhs: 1 }, // slot 5 reused as an i64 index
6874 MicroOp::ArrStore {
6875 src: 4,
6876 idx: 5,
6877 ptr_slot: 8,
6878 len_slot: 9,
6879 byte: false,
6880 narrow32: false,
6881 checked: false,
6882 },
6883 MicroOp::Jump { target: 0 },
6884 MicroOp::Return { src: 0 },
6885 ];
6886 let (_gp, fpins) = select_pins(&ops, 8, true);
6887 assert!(
6888 !fpins.contains(&5),
6889 "slot 5 (an f64 result reused as an integer array index) must NOT be \
6890 float-pinned — pinning it spills f64 bits over the integer index; got fpins={fpins:?}"
6891 );
6892 }
6893
6894 /// LEVER 3 — a single-use float partial-sum that is the DESTINATION of an
6895 /// `AddF` consuming an immediately-preceding single-use `MulF` product (the
6896 /// nbody distance-sum's `partial = a*a + (b*b)` shape, `d` distinct from its
6897 /// addend) is the result an FmaF will absorb. It must be KEPT OUT of the
6898 /// float-pin budget so the (later) `fuse_fma` writes the frame directly with
6899 /// NO reload — a 2-pieces-to-1 win — instead of pinning it and paying a reload
6900 /// after the mem-form FmaF.
6901 #[test]
6902 fn fma_result_temp_is_not_float_pinned() {
6903 // partial = aa + b*b, where aa is a frame temp and b*b is single-use.
6904 // slot 4 = partial (the FmaF dst); slot 6 = b*b product (single-use);
6905 // slot 5 = aa (the addend, distinct from 4); slot 7 reads partial after.
6906 let ops = vec![
6907 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 6 },
6908 MicroOp::MulF { dst: 6, lhs: 2, rhs: 2 }, // b*b → single-use product
6909 MicroOp::AddF { dst: 4, lhs: 5, rhs: 6 }, // partial = aa + (b*b), 4 != 5
6910 MicroOp::SqrtF { dst: 7, src: 4 }, // partial's only reader
6911 MicroOp::Jump { target: 0 },
6912 MicroOp::Return { src: 0 },
6913 ];
6914 let (_gp, fpins) = select_pins(&ops, 8, true);
6915 assert!(
6916 !fpins.contains(&4),
6917 "slot 4 (a single-use FmaF-candidate partial-sum, distinct from its \
6918 addend) must NOT be float-pinned (Lever 3); got fpins={fpins:?}"
6919 );
6920 }
6921
6922 /// LEVER 3 boundary — a LOOP-CARRIED accumulator `s = s + a*b` (`c == d == s`)
6923 /// is NOT an FmaF candidate (Lever 1a refuses to fuse a pinned addend), so
6924 /// Lever 3 must NOT exclude it from the float-pin budget. (The accumulator is
6925 /// read+written by the AddF as a float; the region returns an unrelated int so
6926 /// the `Return`-blocks-float rule does not interfere with the assertion.)
6927 #[test]
6928 fn loop_carried_float_accumulator_not_excluded_by_lever3() {
6929 // s = s + a*b: slot 4 = s (read + written by the AddF), 6 = a*b product.
6930 let ops = vec![
6931 MicroOp::Branch { cmp: Cmp::Lt, lhs: 0, rhs: 1, target: 4 },
6932 MicroOp::MulF { dst: 6, lhs: 2, rhs: 3 }, // a*b → product
6933 MicroOp::AddF { dst: 4, lhs: 4, rhs: 6 }, // s = s + product (c == d == 4)
6934 MicroOp::Jump { target: 0 },
6935 MicroOp::Return { src: 0 }, // returns an int, not the accumulator
6936 ];
6937 let (_gp, fpins) = select_pins(&ops, 8, true);
6938 assert!(
6939 fpins.contains(&4),
6940 "slot 4 (a loop-carried float accumulator, c == d) must STILL be \
6941 float-pin-eligible — it is not an FmaF candidate, so Lever 3 must not \
6942 exclude it; got fpins={fpins:?}"
6943 );
6944 }
6945}
6946
6947#[cfg(test)]
6948mod fuse_rmw_tests {
6949 use super::*;
6950 use std::collections::HashSet;
6951
6952 // register_count = 8 (slots 0..7 are VM registers); slots 6/7 stand in for
6953 // the pinned array's ptr/len cells. `named` marks slot 5 (the index) live;
6954 // the scratch temps 3 (loaded value) and 4 (sum) are un-named.
6955 fn named8(live: &[u16]) -> Vec<bool> {
6956 let mut n = vec![false; 8];
6957 for &s in live {
6958 n[s as usize] = true;
6959 }
6960 n
6961 }
6962
6963 /// The canonical histogram idiom `arr[i] = arr[i] + operand` — ArrLoad into
6964 /// a single-use scratch, Add with the operand, ArrStore the result back to
6965 /// the SAME array+index — collapses to one ArrRMW.
6966 #[test]
6967 fn load_add_store_to_same_cell_fuses_to_rmw() {
6968 let mut micro = vec![
6969 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
6970 MicroOp::Add { dst: 4, lhs: 3, rhs: 2 },
6971 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
6972 MicroOp::Return { src: 0 },
6973 ];
6974 fuse_array_rmw(&mut micro, &named8(&[2, 5]), 8, &HashSet::new());
6975 assert_eq!(micro.len(), 2, "the 3-op idiom must collapse to 1 op + Return");
6976 match micro[0] {
6977 MicroOp::ArrRMW { idx, operand, ptr_slot, len_slot, op, checked } => {
6978 assert_eq!((idx, operand, ptr_slot, len_slot), (5, 2, 6, 7));
6979 assert_eq!(op, RmwOp::Add);
6980 assert!(checked);
6981 }
6982 ref other => panic!("expected ArrRMW, got {other:?}"),
6983 }
6984 }
6985
6986 /// Subtraction is non-commutative: it fuses ONLY when the loaded element is
6987 /// the left operand (`buf[i] - operand`), and the operand is the right.
6988 #[test]
6989 fn sub_fuses_only_with_loaded_value_on_the_left() {
6990 let mut micro = vec![
6991 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: false },
6992 MicroOp::Sub { dst: 4, lhs: 3, rhs: 2 },
6993 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: false },
6994 MicroOp::Return { src: 0 },
6995 ];
6996 fuse_array_rmw(&mut micro, &named8(&[2, 5]), 8, &HashSet::new());
6997 match micro[0] {
6998 MicroOp::ArrRMW { op: RmwOp::Sub, operand: 2, checked: false, .. } => {}
6999 ref other => panic!("expected unchecked ArrRMW Sub operand=2, got {other:?}"),
7000 }
7001
7002 // operand - buf[i] is NOT an RMW (the loaded value is on the right).
7003 let mut rev = vec![
7004 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: false },
7005 MicroOp::Sub { dst: 4, lhs: 2, rhs: 3 },
7006 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: false },
7007 MicroOp::Return { src: 0 },
7008 ];
7009 fuse_array_rmw(&mut rev, &named8(&[2, 5]), 8, &HashSet::new());
7010 assert!(
7011 matches!(rev[0], MicroOp::ArrLoad { .. }),
7012 "operand - buf[i] must NOT fuse: {rev:?}"
7013 );
7014 }
7015
7016 /// A bounds-checked load with an unchecked store (or vice versa) fuses to a
7017 /// CHECKED rmw — a single pre-write check covers both (same index).
7018 #[test]
7019 fn mixed_checked_load_unchecked_store_fuses_checked() {
7020 let mut micro = vec![
7021 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7022 MicroOp::BitOr { dst: 4, lhs: 3, rhs: 2 },
7023 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: false },
7024 MicroOp::Return { src: 0 },
7025 ];
7026 fuse_array_rmw(&mut micro, &named8(&[2, 5]), 8, &HashSet::new());
7027 match micro[0] {
7028 MicroOp::ArrRMW { op: RmwOp::Or, checked: true, .. } => {}
7029 ref other => panic!("expected checked ArrRMW Or, got {other:?}"),
7030 }
7031 }
7032
7033 /// The loaded value must be single-use: if `t` is read again (here also
7034 /// stored elsewhere), folding it away would lose that read — no fusion.
7035 #[test]
7036 fn multi_use_loaded_value_blocks_fusion() {
7037 let mut micro = vec![
7038 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7039 MicroOp::Add { dst: 4, lhs: 3, rhs: 2 },
7040 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7041 MicroOp::Move { dst: 1, src: 3 }, // second use of the loaded value
7042 MicroOp::Return { src: 1 },
7043 ];
7044 fuse_array_rmw(&mut micro, &named8(&[1, 2, 5]), 8, &HashSet::new());
7045 assert!(matches!(micro[0], MicroOp::ArrLoad { .. }), "multi-use t must block fusion");
7046 }
7047
7048 /// Load and store to DIFFERENT arrays (or different indices) is not an
7049 /// in-place RMW and must never fuse.
7050 #[test]
7051 fn different_array_or_index_blocks_fusion() {
7052 // different ptr_slot
7053 let mut a = vec![
7054 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7055 MicroOp::Add { dst: 4, lhs: 3, rhs: 2 },
7056 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 1, len_slot: 7, byte: false, narrow32: false, checked: true },
7057 MicroOp::Return { src: 0 },
7058 ];
7059 fuse_array_rmw(&mut a, &named8(&[1, 2, 5]), 8, &HashSet::new());
7060 assert!(matches!(a[0], MicroOp::ArrLoad { .. }), "different array must block fusion");
7061 // different index
7062 let mut b = vec![
7063 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7064 MicroOp::Add { dst: 4, lhs: 3, rhs: 2 },
7065 MicroOp::ArrStore { src: 4, idx: 1, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7066 MicroOp::Return { src: 0 },
7067 ];
7068 fuse_array_rmw(&mut b, &named8(&[1, 2, 5]), 8, &HashSet::new());
7069 assert!(matches!(b[0], MicroOp::ArrLoad { .. }), "different index must block fusion");
7070 }
7071
7072 /// A jump landing ON the store (or the ALU) means another path enters
7073 /// mid-idiom — fusing would skip the load on that entry. No fusion.
7074 #[test]
7075 fn jump_target_inside_idiom_blocks_fusion() {
7076 let mut micro = vec![
7077 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7078 MicroOp::Add { dst: 4, lhs: 3, rhs: 2 },
7079 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7080 MicroOp::JumpIfTrue { cond: 2, target: 2 }, // jumps onto the store
7081 MicroOp::Return { src: 0 },
7082 ];
7083 fuse_array_rmw(&mut micro, &named8(&[2, 5]), 8, &HashSet::new());
7084 assert!(
7085 matches!(micro[0], MicroOp::ArrLoad { .. }),
7086 "a jump target inside the idiom must block fusion"
7087 );
7088 }
7089
7090 /// THE HISTOGRAM SHAPE: the index `v + 1` is computed TWICE (separate
7091 /// slots, as the bytecode lowers `Set item (v+1) of A to (item (v+1) of A)
7092 /// + 1`). `copy_propagate`'s value-numbering must merge the two `v + 1` into
7093 /// one slot so the load and store share an index, and only then does
7094 /// `fuse_array_rmw` collapse the idiom. This pins the CSE→fusion handoff.
7095 #[test]
7096 fn duplicated_index_expr_is_cse_d_then_fused() {
7097 // v=1 (named loop var); c=2 the `1`; 3/5/6 scratch; 8/9 ptr/len.
7098 let mut micro = vec![
7099 MicroOp::LoadConst { dst: 2, value: 1 },
7100 MicroOp::Add { dst: 3, lhs: 1, rhs: 2 }, // idx_a = v + 1
7101 MicroOp::ArrLoad { dst: 4, idx: 3, ptr_slot: 8, len_slot: 9, byte: false, narrow32: false, checked: true },
7102 MicroOp::Add { dst: 5, lhs: 4, rhs: 2 }, // t2 = arr[idx_a] + 1
7103 MicroOp::Add { dst: 6, lhs: 1, rhs: 2 }, // idx_b = v + 1 (recomputed)
7104 MicroOp::ArrStore { src: 5, idx: 6, ptr_slot: 8, len_slot: 9, byte: false, narrow32: false, checked: true },
7105 MicroOp::Return { src: 1 },
7106 ];
7107 let named = named8_n(&[1], 10);
7108 copy_propagate(&mut micro, &named, 10, &HashSet::new());
7109 fuse_array_rmw(&mut micro, &named, 10, &HashSet::new());
7110 // The dead recomputed index is gone; the idiom is one ArrRMW.
7111 let rmw = micro.iter().find(|op| matches!(op, MicroOp::ArrRMW { .. }));
7112 match rmw {
7113 Some(&MicroOp::ArrRMW { idx, operand, op, .. }) => {
7114 assert_eq!(op, RmwOp::Add);
7115 assert_eq!(operand, 2, "operand is the deduped `1`");
7116 // idx is whichever surviving slot holds v+1 (the load's index).
7117 assert!(idx == 3, "load+store must share the CSE'd index slot; got {idx}");
7118 }
7119 _ => panic!("CSE+fusion failed to collapse the duplicated-index idiom: {micro:?}"),
7120 }
7121 assert!(
7122 !micro.iter().any(|op| matches!(op, MicroOp::ArrStore { .. })),
7123 "the store must be folded into the ArrRMW"
7124 );
7125 }
7126
7127 fn named8_n(live: &[u16], n: usize) -> Vec<bool> {
7128 let mut v = vec![false; n];
7129 for &s in live {
7130 v[s as usize] = true;
7131 }
7132 v
7133 }
7134
7135 /// FLOAT RMW (nbody `v[i] = v[i] + dx*mag`): an ArrLoad feeding an AddF whose
7136 /// result stores back to the same cell collapses to a float ArrRMW. The
7137 /// operand (the product) stays live; commutative AddF takes `t` on either
7138 /// side, SubF only on the left.
7139 #[test]
7140 fn float_load_addf_store_fuses_to_float_rmw() {
7141 let mut micro = vec![
7142 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 8, len_slot: 9, byte: false, narrow32: false, checked: false },
7143 MicroOp::AddF { dst: 4, lhs: 3, rhs: 2 }, // v[i] + product
7144 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 8, len_slot: 9, byte: false, narrow32: false, checked: false },
7145 MicroOp::Return { src: 0 },
7146 ];
7147 fuse_array_rmw(&mut micro, &named8(&[2, 5]), 10, &HashSet::new());
7148 match micro[0] {
7149 MicroOp::ArrRMW { op: RmwOp::AddF, operand: 2, idx: 5, .. } => {}
7150 ref other => panic!("expected float ArrRMW AddF operand=2, got {other:?}"),
7151 }
7152
7153 // operand - v[i] must NOT fuse (loaded value on the right of SubF).
7154 let mut rev = vec![
7155 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 8, len_slot: 9, byte: false, narrow32: false, checked: true },
7156 MicroOp::SubF { dst: 4, lhs: 2, rhs: 3 },
7157 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 8, len_slot: 9, byte: false, narrow32: false, checked: true },
7158 MicroOp::Return { src: 0 },
7159 ];
7160 fuse_array_rmw(&mut rev, &named8(&[2, 5]), 10, &HashSet::new());
7161 assert!(matches!(rev[0], MicroOp::ArrLoad { .. }), "operand - v[i] must not fuse: {rev:?}");
7162 }
7163
7164 /// FLOAT MULTIPLY-ADD: `MulF(t = a*b); AddF(d = t + c)` with `t` single-use
7165 /// collapses to one FmaF. Commutative — the product may be either AddF
7166 /// operand; the addend `c` stays live.
7167 #[test]
7168 fn mulf_addf_fuses_to_fma() {
7169 for swap in [false, true] {
7170 let add = if swap {
7171 MicroOp::AddF { dst: 4, lhs: 2, rhs: 3 } // c + t
7172 } else {
7173 MicroOp::AddF { dst: 4, lhs: 3, rhs: 2 } // t + c
7174 };
7175 let mut micro = vec![
7176 MicroOp::MulF { dst: 3, lhs: 0, rhs: 1 }, // t = a*b
7177 add,
7178 MicroOp::Return { src: 4 },
7179 ];
7180 fuse_fma(&mut micro, &named8(&[0, 1, 2, 4]), 8, &HashSet::new());
7181 match micro[0] {
7182 MicroOp::FmaF { dst: 4, a: 0, b: 1, c: 2 } => {}
7183 ref other => panic!("expected FmaF(dst4,a0,b1,c2), got {other:?} (swap={swap})"),
7184 }
7185 assert!(!micro.iter().any(|op| matches!(op, MicroOp::AddF { .. })), "AddF folded away");
7186 }
7187 }
7188
7189 /// A multi-use product must NOT fuse (the second reader would lose it), and a
7190 /// pinned operand blocks fusion (mem-form FmaF would need a spill).
7191 #[test]
7192 fn fma_blocks_multiuse_product_and_pinned_operands() {
7193 // product read twice
7194 let mut multi = vec![
7195 MicroOp::MulF { dst: 3, lhs: 0, rhs: 1 },
7196 MicroOp::AddF { dst: 4, lhs: 3, rhs: 2 },
7197 MicroOp::AddF { dst: 5, lhs: 3, rhs: 2 }, // second use of the product
7198 MicroOp::Return { src: 4 },
7199 ];
7200 fuse_fma(&mut multi, &named8(&[0, 1, 2, 4, 5]), 8, &HashSet::new());
7201 assert!(matches!(multi[0], MicroOp::MulF { .. }), "multi-use product must block FMA");
7202
7203 // a pinned operand (slot 0) blocks the spill-free fusion
7204 let mut pinned_in = vec![
7205 MicroOp::MulF { dst: 3, lhs: 0, rhs: 1 },
7206 MicroOp::AddF { dst: 4, lhs: 3, rhs: 2 },
7207 MicroOp::Return { src: 4 },
7208 ];
7209 let pins: HashSet<u16> = [0u16].into_iter().collect();
7210 fuse_fma(&mut pinned_in, &named8(&[0, 1, 2, 4]), 8, &pins);
7211 assert!(matches!(pinned_in[0], MicroOp::MulF { .. }), "pinned operand must block FMA");
7212 }
7213
7214 /// LEVER 1a — a pinned DESTINATION `d` (the accumulator/result XMM pin) must
7215 /// NOT block fusion: `FmaF` is mem-form, so a pinned `d` is spilled/reloaded
7216 /// by `compile_straightline_pinned_float`'s mem-form path exactly as it would
7217 /// be by the unfused `AddF`'s register-form result — fusing adds no extra
7218 /// spill on the INPUTS (which stay frame-resident). The nbody distance-sum's
7219 /// `MulF(t = dz*dz); AddF(d = sum + t)` with `d` float-pinned is the case the
7220 /// guard previously refused. Inputs `a`, `b`, the addend `c` stay frame-resident.
7221 #[test]
7222 fn fma_fuses_with_pinned_destination() {
7223 for swap in [false, true] {
7224 let add = if swap {
7225 MicroOp::AddF { dst: 4, lhs: 2, rhs: 3 } // c + t
7226 } else {
7227 MicroOp::AddF { dst: 4, lhs: 3, rhs: 2 } // t + c
7228 };
7229 let mut micro = vec![
7230 MicroOp::MulF { dst: 3, lhs: 0, rhs: 1 }, // t = a*b (a,b,t frame-resident)
7231 add, // d = t + c, d pinned
7232 MicroOp::Return { src: 4 },
7233 ];
7234 let pins: HashSet<u16> = [4u16].into_iter().collect(); // ONLY the dst is pinned
7235 fuse_fma(&mut micro, &named8(&[0, 1, 2, 4]), 8, &pins);
7236 match micro[0] {
7237 MicroOp::FmaF { dst: 4, a: 0, b: 1, c: 2 } => {}
7238 ref other => panic!(
7239 "expected FmaF(dst4,a0,b1,c2) with pinned dst, got {other:?} (swap={swap})"
7240 ),
7241 }
7242 assert!(
7243 !micro.iter().any(|op| matches!(op, MicroOp::AddF { .. })),
7244 "AddF must fold into the FmaF even when the dst is pinned"
7245 );
7246 }
7247 }
7248
7249 /// LEVER 1a soundness boundary — a pinned ADDEND `c` (the loop-carried
7250 /// accumulator read on the FmaF's frame-input side, the `s = s + a*b` shape
7251 /// where `c == d == s`) must STILL block: spilling the pinned accumulator
7252 /// around the mem-form FmaF would cost more pieces than the register-form
7253 /// `AddF` it replaces. Only a pinned `d` that is NOT also an input is relaxed.
7254 #[test]
7255 fn fma_pinned_addend_still_blocks() {
7256 // accumulator shape: t = a*b ; s = s + t (c == d == slot 4, pinned)
7257 let mut micro = vec![
7258 MicroOp::MulF { dst: 3, lhs: 0, rhs: 1 },
7259 MicroOp::AddF { dst: 4, lhs: 4, rhs: 3 }, // s = s + t, s is both c and d
7260 MicroOp::Return { src: 4 },
7261 ];
7262 let pins: HashSet<u16> = [4u16].into_iter().collect();
7263 fuse_fma(&mut micro, &named8(&[0, 1, 4]), 8, &pins);
7264 assert!(
7265 matches!(micro[0], MicroOp::MulF { .. }),
7266 "a pinned addend (loop-carried accumulator) must block FMA"
7267 );
7268 }
7269
7270 /// Jump targets AFTER the idiom are remapped down by the two removed ops.
7271 #[test]
7272 fn fusion_remaps_later_jump_targets() {
7273 let mut micro = vec![
7274 MicroOp::ArrLoad { dst: 3, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7275 MicroOp::Add { dst: 4, lhs: 3, rhs: 2 },
7276 MicroOp::ArrStore { src: 4, idx: 5, ptr_slot: 6, len_slot: 7, byte: false, narrow32: false, checked: true },
7277 MicroOp::JumpIfTrue { cond: 2, target: 5 }, // -> the Return
7278 MicroOp::Add { dst: 1, lhs: 1, rhs: 2 },
7279 MicroOp::Return { src: 1 },
7280 ];
7281 fuse_array_rmw(&mut micro, &named8(&[1, 2, 5]), 8, &HashSet::new());
7282 // [ArrRMW, JumpIfTrue->3, Add, Return] — the target slid 5 -> 3.
7283 assert!(matches!(micro[0], MicroOp::ArrRMW { .. }));
7284 match micro[1] {
7285 MicroOp::JumpIfTrue { target, .. } => assert_eq!(target, 3, "target must remap 5->3"),
7286 ref other => panic!("expected JumpIfTrue, got {other:?}"),
7287 }
7288 assert!(matches!(micro[3], MicroOp::Return { .. }));
7289 }
7290}
7291
7292#[cfg(test)]
7293mod fuse_ld2_tests {
7294 use super::*;
7295 use logicaffeine_forge::jit::IOp;
7296 use std::collections::HashSet;
7297
7298 fn named_n(live: &[u16], n: usize) -> Vec<bool> {
7299 let mut v = vec![false; n];
7300 for &s in live {
7301 v[s as usize] = true;
7302 }
7303 v
7304 }
7305
7306 /// THE MATMUL DOT-PRODUCT SHAPE: `ArrLoad(t1 = a[ix]); ArrLoad(t2 = b[jx]);
7307 /// Mul(d = t1 * t2)` — the two elements come from TWO DISTINCT pinned int
7308 /// buffers (a at ptr 10/len 11, b at ptr 12/len 13). The two single-use
7309 /// scratch loads collapse into ONE fused ArrLoad2 (two loads + multiply,
7310 /// three dispatches → one), so the loaded i64s never round-trip the frame.
7311 #[test]
7312 fn two_buffer_load_mul_fuses_to_arrld2() {
7313 let mut micro = vec![
7314 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7315 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: true },
7316 MicroOp::Mul { dst: 5, lhs: 3, rhs: 4 },
7317 MicroOp::Return { src: 5 },
7318 ];
7319 fuse_array_ld2(&mut micro, &named_n(&[1, 2, 5], 14), 14, &HashSet::new());
7320 assert_eq!(micro.len(), 2, "the 3-op dot-product idiom must collapse to 1 op + Return");
7321 match micro[0] {
7322 MicroOp::ArrLoad2 { dst, i, j, ptr_a, len_a, ptr_b, len_b, op, checked } => {
7323 assert_eq!((dst, i, j), (5, 1, 2));
7324 assert_eq!((ptr_a, len_a, ptr_b, len_b), (10, 11, 12, 13));
7325 assert_eq!(op, IOp::Mul);
7326 assert!(checked);
7327 }
7328 ref other => panic!("expected ArrLoad2, got {other:?}"),
7329 }
7330 }
7331
7332 /// Add commutes — the loads may feed the binop in either order, and the
7333 /// fused op records the index order that reproduces `a[i] OP b[j]`.
7334 #[test]
7335 fn add_fuses_in_either_operand_order() {
7336 for swap in [false, true] {
7337 let add = if swap {
7338 MicroOp::Add { dst: 5, lhs: 4, rhs: 3 } // b[j] + a[i]
7339 } else {
7340 MicroOp::Add { dst: 5, lhs: 3, rhs: 4 } // a[i] + b[j]
7341 };
7342 let mut micro = vec![
7343 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: false },
7344 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: false },
7345 add,
7346 MicroOp::Return { src: 5 },
7347 ];
7348 fuse_array_ld2(&mut micro, &named_n(&[1, 2, 5], 14), 14, &HashSet::new());
7349 match micro[0] {
7350 MicroOp::ArrLoad2 { op: IOp::Add, checked: false, .. } => {}
7351 ref other => panic!("expected unchecked ArrLoad2 Add (swap={swap}), got {other:?}"),
7352 }
7353 }
7354 }
7355
7356 /// Subtraction is non-commutative, but the fusion is anchored on the binop
7357 /// and tracks WHICH load feeds the left vs. right operand, so it fuses BOTH
7358 /// orderings correctly by recording the left load as `a`/`i` and the right
7359 /// load as `b`/`j` (the stencil computes `a[i] - b[j]`).
7360 #[test]
7361 fn sub_fuses_in_binop_order() {
7362 // `X[1] - Y[2]`: left load = X (ptr 10), right load = Y (ptr 12).
7363 let mut fwd = vec![
7364 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7365 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: true },
7366 MicroOp::Sub { dst: 5, lhs: 3, rhs: 4 }, // X[1] - Y[2]
7367 MicroOp::Return { src: 5 },
7368 ];
7369 fuse_array_ld2(&mut fwd, &named_n(&[1, 2, 5], 14), 14, &HashSet::new());
7370 match fwd[0] {
7371 MicroOp::ArrLoad2 { op: IOp::Sub, i: 1, j: 2, ptr_a: 10, ptr_b: 12, .. } => {}
7372 ref other => panic!("expected ArrLoad2 Sub (X-Y): i=1 j=2 ptr_a=10 ptr_b=12, got {other:?}"),
7373 }
7374
7375 // `Y[2] - X[1]`: left load = Y (ptr 12), right load = X (ptr 10). The
7376 // fused op must SWAP which buffer is `a`/`b` so it still computes Y - X.
7377 let mut rev = vec![
7378 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7379 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: true },
7380 MicroOp::Sub { dst: 5, lhs: 4, rhs: 3 }, // Y[2] - X[1]
7381 MicroOp::Return { src: 5 },
7382 ];
7383 fuse_array_ld2(&mut rev, &named_n(&[1, 2, 5], 14), 14, &HashSet::new());
7384 match rev[0] {
7385 MicroOp::ArrLoad2 { op: IOp::Sub, i: 2, j: 1, ptr_a: 12, ptr_b: 10, .. } => {}
7386 ref other => panic!("expected ArrLoad2 Sub (Y-X): i=2 j=1 ptr_a=12 ptr_b=10, got {other:?}"),
7387 }
7388 }
7389
7390 /// A byte (Bool) buffer is not an 8-byte int load — the fused int stencil
7391 /// reads raw i64s, so a byte load must never fuse.
7392 #[test]
7393 fn byte_buffer_blocks_fusion() {
7394 let mut micro = vec![
7395 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: true, narrow32: false, checked: true },
7396 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: true },
7397 MicroOp::Mul { dst: 5, lhs: 3, rhs: 4 },
7398 MicroOp::Return { src: 5 },
7399 ];
7400 fuse_array_ld2(&mut micro, &named_n(&[1, 2, 5], 14), 14, &HashSet::new());
7401 assert!(matches!(micro[0], MicroOp::ArrLoad { byte: true, .. }), "byte load must block fusion");
7402 }
7403
7404 /// A multi-use loaded value must NOT be folded away (a later reader would
7405 /// lose it), and a jump landing on the second load or the binop blocks it.
7406 #[test]
7407 fn multiuse_load_and_inner_jump_target_block_fusion() {
7408 let mut multi = vec![
7409 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7410 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: true },
7411 MicroOp::Mul { dst: 5, lhs: 3, rhs: 4 },
7412 MicroOp::Move { dst: 6, src: 3 }, // second use of a[i]
7413 MicroOp::Return { src: 5 },
7414 ];
7415 fuse_array_ld2(&mut multi, &named_n(&[1, 2, 5, 6], 14), 14, &HashSet::new());
7416 assert!(matches!(multi[0], MicroOp::ArrLoad { .. }), "multi-use load must block fusion");
7417
7418 let mut jt = vec![
7419 MicroOp::ArrLoad { dst: 3, idx: 1, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7420 MicroOp::ArrLoad { dst: 4, idx: 2, ptr_slot: 12, len_slot: 13, byte: false, narrow32: false, checked: true },
7421 MicroOp::Mul { dst: 5, lhs: 3, rhs: 4 },
7422 MicroOp::JumpIfTrue { cond: 0, target: 1 }, // lands on the 2nd load
7423 MicroOp::Return { src: 5 },
7424 ];
7425 fuse_array_ld2(&mut jt, &named_n(&[0, 1, 2, 5], 14), 14, &HashSet::new());
7426 assert!(matches!(jt[0], MicroOp::ArrLoad { .. }), "inner jump target must block fusion");
7427 }
7428}
7429
7430#[cfg(test)]
7431mod fuse_affine_tests {
7432 use super::*;
7433 use logicaffeine_forge::jit::AffOp;
7434 use std::collections::HashSet;
7435
7436 // slots 0..n are registers; named marks the live ones. The const-holding
7437 // slot is left live (it is reused), the index temps are scratch (un-named).
7438 fn named_n(live: &[u16], n: usize) -> Vec<bool> {
7439 let mut v = vec![false; n];
7440 for &s in live {
7441 v[s as usize] = true;
7442 }
7443 v
7444 }
7445
7446 /// THE `w + 1` SHAPE — `LoadConst(kc = 1); Add(idx = w + kc); ArrLoad(arr[idx])`.
7447 /// The index `idx` is single-use scratch, the const slot is reused; folds to
7448 /// one `ArrLoadAffine{op: None, a: w, const_offset: 1}` and the `Add` vanishes
7449 /// while the `LoadConst` survives.
7450 #[test]
7451 fn slot_plus_const_folds_to_affine_none() {
7452 // slot 4 = w (live index var), 8 = kc (live const), 5 = idx (scratch),
7453 // 6 = loaded value, 10/11 = ptr/len.
7454 let mut micro = vec![
7455 MicroOp::LoadConst { dst: 8, value: 1 },
7456 MicroOp::Add { dst: 5, lhs: 4, rhs: 8 },
7457 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7458 MicroOp::Return { src: 6 },
7459 ];
7460 fuse_index_affine(&mut micro, &named_n(&[4, 8, 6], 12), 12, &HashSet::new());
7461 assert_eq!(micro.len(), 3, "Add folds into the load; LoadConst kept");
7462 match micro[1] {
7463 MicroOp::ArrLoadAffine { dst, a, op, const_offset, ptr_slot, len_slot, checked, .. } => {
7464 assert_eq!((dst, a, op, const_offset), (6, 4, AffOp::None, 1));
7465 assert_eq!((ptr_slot, len_slot, checked), (10, 11, true));
7466 }
7467 ref other => panic!("expected ArrLoadAffine None, got {other:?}"),
7468 }
7469 assert!(matches!(micro[0], MicroOp::LoadConst { dst: 8, value: 1 }), "shared const kept");
7470 }
7471
7472 /// THE `w - wi + 1` SHAPE — `Sub(t = w - wi); LoadConst(kc = 1); Add(idx = t + kc);
7473 /// ArrLoad(arr[idx])`. Both `t` and `idx` are single-use scratch; folds to one
7474 /// `ArrLoadAffine{op: Sub, a: w, b: wi, const_offset: 1}` (the inner Sub AND the
7475 /// trailing Add vanish, the LoadConst survives).
7476 #[test]
7477 fn sub_plus_const_folds_to_affine_sub() {
7478 // 4 = w, 3 = wi, 8 = kc (live), 7 = t (scratch), 5 = idx (scratch).
7479 let mut micro = vec![
7480 MicroOp::Sub { dst: 7, lhs: 4, rhs: 3 },
7481 MicroOp::LoadConst { dst: 8, value: 1 },
7482 MicroOp::Add { dst: 5, lhs: 7, rhs: 8 },
7483 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: false },
7484 MicroOp::Return { src: 6 },
7485 ];
7486 fuse_index_affine(&mut micro, &named_n(&[4, 3, 8, 6], 12), 12, &HashSet::new());
7487 assert_eq!(micro.len(), 3, "Sub + Add fold into the load; LoadConst kept");
7488 match micro[1] {
7489 MicroOp::ArrLoadAffine { dst, a, op, b, const_offset, checked, .. } => {
7490 assert_eq!((dst, a, op, b, const_offset, checked), (6, 4, AffOp::Sub, 3, 1, false));
7491 }
7492 ref other => panic!("expected ArrLoadAffine Sub, got {other:?}"),
7493 }
7494 }
7495
7496 /// THE `i*n + j + 1` MATMUL READ SHAPE — `Mul(t1 = i*n)` is MULTI-USE (the
7497 /// matmul reuses `i*n`), so it must NOT be removed, but the trailing
7498 /// `Add(t2 = t1 + j); Add(idx = t2 + kc); ArrLoad` still folds: the inner
7499 /// add `t2` is single-use scratch over the slot `t1`, giving
7500 /// `ArrLoadAffine{op: Add, a: t1, b: j, const_offset: 1}`.
7501 #[test]
7502 fn matmul_read_folds_keeping_shared_mul() {
7503 // 6 = i, 1 = n, 14 = j, 8 = kc (live), 18 = t1 = i*n (MULTI-USE: read by
7504 // the inner add AND a later op), 17 = t2 (scratch), 16 = idx (scratch).
7505 let mut micro = vec![
7506 MicroOp::Mul { dst: 18, lhs: 6, rhs: 1 }, // i*n, reused
7507 MicroOp::Add { dst: 17, lhs: 18, rhs: 14 }, // i*n + j (scratch)
7508 MicroOp::LoadConst { dst: 8, value: 1 },
7509 MicroOp::Add { dst: 16, lhs: 17, rhs: 8 }, // + 1 (scratch)
7510 MicroOp::ArrLoad { dst: 23, idx: 16, ptr_slot: 40, len_slot: 41, byte: false, narrow32: false, checked: true },
7511 MicroOp::Move { dst: 99, src: 18 }, // second use of i*n
7512 MicroOp::Return { src: 23 },
7513 ];
7514 fuse_index_affine(&mut micro, &named_n(&[6, 1, 14, 8, 23, 99], 100), 100, &HashSet::new());
7515 // The two inner Adds collapse into the load; the Mul and LoadConst stay.
7516 match micro.iter().find(|o| matches!(o, MicroOp::ArrLoadAffine { .. })) {
7517 Some(MicroOp::ArrLoadAffine { dst, a, op, b, const_offset, .. }) => {
7518 assert_eq!((*dst, *a, *op, *b, *const_offset), (23, 18, AffOp::Add, 14, 1));
7519 }
7520 other => panic!("expected ArrLoadAffine, got {other:?}"),
7521 }
7522 assert!(micro.iter().any(|o| matches!(o, MicroOp::Mul { dst: 18, .. })), "shared i*n Mul kept");
7523 }
7524
7525 /// THE BARE `a + b` SHAPE (no const) — `Add(idx = a + b); ArrLoad` folds to
7526 /// `ArrLoadAffine{op: Add, const_offset: 0}`.
7527 #[test]
7528 fn bare_two_slot_add_folds_with_zero_offset() {
7529 let mut micro = vec![
7530 MicroOp::Add { dst: 5, lhs: 4, rhs: 3 },
7531 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7532 MicroOp::Return { src: 6 },
7533 ];
7534 fuse_index_affine(&mut micro, &named_n(&[4, 3, 6], 12), 12, &HashSet::new());
7535 assert_eq!(micro.len(), 2, "the Add folds into the load");
7536 match micro[0] {
7537 MicroOp::ArrLoadAffine { a, op, b, const_offset, .. } => {
7538 assert_eq!((a, op, b, const_offset), (4, AffOp::Add, 3, 0));
7539 }
7540 ref other => panic!("expected ArrLoadAffine Add, got {other:?}"),
7541 }
7542 }
7543
7544 /// A MULTI-USE INDEX (the slot is read by a store too, as matmul's `c[idx]`)
7545 /// must NOT fold — folding would drop the index the store still needs.
7546 #[test]
7547 fn multiuse_index_blocks_fusion() {
7548 let mut micro = vec![
7549 MicroOp::LoadConst { dst: 8, value: 1 },
7550 MicroOp::Add { dst: 5, lhs: 4, rhs: 8 },
7551 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7552 MicroOp::ArrStore { src: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7553 MicroOp::Return { src: 6 },
7554 ];
7555 fuse_index_affine(&mut micro, &named_n(&[4, 8, 6], 12), 12, &HashSet::new());
7556 assert!(
7557 matches!(micro[2], MicroOp::ArrLoad { .. }),
7558 "an index reused by a store must block the fold: {micro:?}"
7559 );
7560 }
7561
7562 /// A BYTE (Bool) load never folds — the affine stencils read raw i64s.
7563 #[test]
7564 fn byte_load_blocks_fusion() {
7565 let mut micro = vec![
7566 MicroOp::LoadConst { dst: 8, value: 1 },
7567 MicroOp::Add { dst: 5, lhs: 4, rhs: 8 },
7568 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: true, narrow32: false, checked: true },
7569 MicroOp::Return { src: 6 },
7570 ];
7571 fuse_index_affine(&mut micro, &named_n(&[4, 8, 6], 12), 12, &HashSet::new());
7572 assert!(matches!(micro[2], MicroOp::ArrLoad { byte: true, .. }), "byte load must not fold");
7573 }
7574
7575 /// A PINNED index temp must not fold (the fused op reads it from the frame;
7576 /// folding past a pin would lose the pinned value's settling).
7577 #[test]
7578 fn pinned_index_temp_blocks_fusion() {
7579 let mut micro = vec![
7580 MicroOp::Add { dst: 5, lhs: 4, rhs: 3 },
7581 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7582 MicroOp::Return { src: 6 },
7583 ];
7584 let mut pins = HashSet::new();
7585 pins.insert(5u16);
7586 fuse_index_affine(&mut micro, &named_n(&[4, 3, 6], 12), 12, &pins);
7587 assert!(matches!(micro[0], MicroOp::Add { .. }), "a pinned index must block the fold");
7588 }
7589
7590 /// A jump landing on the load (or between the index op and the load) must
7591 /// block the fold — an entry there would skip the index computation.
7592 #[test]
7593 fn jump_target_on_load_blocks_fusion() {
7594 let mut micro = vec![
7595 MicroOp::Add { dst: 5, lhs: 4, rhs: 3 },
7596 MicroOp::ArrLoad { dst: 6, idx: 5, ptr_slot: 10, len_slot: 11, byte: false, narrow32: false, checked: true },
7597 MicroOp::JumpIfTrue { cond: 4, target: 1 }, // lands on the load
7598 MicroOp::Return { src: 6 },
7599 ];
7600 fuse_index_affine(&mut micro, &named_n(&[4, 3, 6], 12), 12, &HashSet::new());
7601 assert!(matches!(micro[0], MicroOp::Add { .. }), "jump onto the load blocks the fold");
7602 }
7603
7604 /// COND-SWAP WITH A COMPUTED SECOND INDEX (Priority 2) — the sort inner-loop
7605 /// compare-and-swap idiom `ArrLoad(a, i1); <index arith for i2>; ArrLoad(b, i2);
7606 /// Branch(cmp, a, b); ArrStore(i1, b); ArrStore(i2, a)` must still fuse into one
7607 /// `ArrCondSwap` when `Mul`/`Add`/`LoadConst` (the `2*root+2`-style index
7608 /// computation of the SECOND access) sit BETWEEN the two loads. The
7609 /// between-ops are pure and redefine neither the first loaded value, the
7610 /// first index, nor the pointer/length, so they do not block the fusion.
7611 #[test]
7612 fn cond_swap_tolerates_computed_index_between_loads() {
7613 // i1 = slot 5 (live), root = slot 4 (live). i2 = 2*root + 2 computed
7614 // between the loads (slots 8 = kc2, 7 = 2*root, 6 = i2; all scratch).
7615 // a = slot 9, b = slot 10 (the two loaded values, dead after the swap).
7616 let mut micro = vec![
7617 MicroOp::ArrLoad { dst: 9, idx: 5, ptr_slot: 20, len_slot: 21, byte: false, narrow32: false, checked: true },
7618 MicroOp::LoadConst { dst: 8, value: 2 },
7619 MicroOp::Mul { dst: 7, lhs: 4, rhs: 8 }, // 2*root
7620 MicroOp::Add { dst: 6, lhs: 7, rhs: 8 }, // 2*root + 2 (the i2 index)
7621 MicroOp::ArrLoad { dst: 10, idx: 6, ptr_slot: 20, len_slot: 21, byte: false, narrow32: false, checked: true },
7622 MicroOp::Branch { cmp: Cmp::Lt, lhs: 9, rhs: 10, target: 8 },
7623 MicroOp::ArrStore { src: 10, idx: 5, ptr_slot: 20, len_slot: 21, byte: false, narrow32: false, checked: true },
7624 MicroOp::ArrStore { src: 9, idx: 6, ptr_slot: 20, len_slot: 21, byte: false, narrow32: false, checked: true },
7625 MicroOp::Return { src: 0 },
7626 ];
7627 // gap from load1 to load2 is 3 (LoadConst, Mul, Add) — raise the search
7628 // window by running with the same named/pin config the pipeline uses.
7629 fuse_cond_swap(&mut micro, &named_n(&[4, 5], 22), 22, &HashSet::new());
7630 assert!(
7631 micro.iter().any(|o| matches!(o, MicroOp::ArrCondSwap { idx1: 5, idx2: 6, .. })),
7632 "the compare-and-swap must fuse despite the 2*root+2 index arith between \
7633 the loads: {micro:?}"
7634 );
7635 }
7636
7637 /// EVAL PARITY — `AffOp::eval` reproduces the kernel's wrapping i64 index
7638 /// arithmetic exactly (the differential gate's bit-for-bit contract).
7639 #[test]
7640 fn affop_eval_matches_wrapping_kernel_semantics() {
7641 assert_eq!(AffOp::None.eval(7, 999, 1), 8);
7642 assert_eq!(AffOp::Add.eval(40, 3, 1), 44);
7643 assert_eq!(AffOp::Sub.eval(10, 4, 1), 7);
7644 assert_eq!(AffOp::Mul.eval(6, 7, 1), 43);
7645 // Wrapping at the i64 edges, identical to the kernel.
7646 assert_eq!(AffOp::Add.eval(i64::MAX, 1, 0), i64::MAX.wrapping_add(1));
7647 assert_eq!(AffOp::Mul.eval(i64::MIN, -1, 0), i64::MIN.wrapping_mul(-1));
7648 assert_eq!(AffOp::Sub.eval(i64::MIN, 1, 0), i64::MIN.wrapping_sub(1));
7649 }
7650}
7651
7652#[cfg(test)]
7653mod bug002_hazard_tests {
7654 use super::*;
7655 use logicaffeine_compile::vm::Op;
7656
7657 fn load(dst: u16, idx: u16) -> Op {
7658 Op::IndexUnchecked { dst, collection: 7, index: idx }
7659 }
7660 fn store(idx: u16, value: u16) -> Op {
7661 Op::SetIndex { collection: 7, index: idx, value }
7662 }
7663
7664 #[test]
7665 fn detects_crossbranch_array_value_store() {
7666 // load a[j]->v ; branch ; store a[i] = v (v survives the branch) -> HAZARD
7667 let ops = vec![
7668 load(13, 11),
7669 Op::Move { dst: 14, src: 13 },
7670 Op::JumpIfFalse { cond: 15, target: 0 },
7671 Op::Index { dst: 16, collection: 7, index: 9 },
7672 store(9, 14),
7673 store(11, 16),
7674 ];
7675 assert!(has_crossbranch_array_value_store_hazard(&ops));
7676 }
7677
7678 #[test]
7679 fn safe_when_load_is_inside_branch() {
7680 // branch ; load a[j]->v ; store a[i] = v (no branch between load and store) -> SAFE
7681 let ops = vec![
7682 Op::JumpIfFalse { cond: 15, target: 0 },
7683 Op::Index { dst: 16, collection: 7, index: 9 },
7684 load(17, 11),
7685 store(9, 17),
7686 store(11, 16),
7687 ];
7688 assert!(!has_crossbranch_array_value_store_hazard(&ops));
7689 }
7690}