Skip to main content

rlx_cpu/
executor.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Graph executor — runs a fused IR graph on CPU using the arena + kernels.
17//!
18//! The executor is the runtime hot path. For a 6-layer BERT, it makes
19//! ~24 kernel calls total (one per fused node). Everything else is
20//! inside the kernels — SIMD, BLAS, pre-allocated arena buffers.
21
22use crate::arena::Arena;
23use crate::kernels;
24use rlx_ir::op::{Activation, BinaryOp, ReduceOp};
25use rlx_ir::{Graph, NodeId, Op};
26use std::collections::HashMap;
27
28/// External data provided at runtime (model weights + inputs).
29pub struct ExternalBuffers<'a> {
30    /// Map from node ID (Input/Param nodes) to external f32 data.
31    pub buffers: HashMap<NodeId, &'a [f32]>,
32}
33
34/// Execute a compiled graph on CPU.
35///
36/// The graph should already be fused and memory-planned.
37/// `arena` holds all intermediate buffers.
38/// `external` provides input data and model weights.
39///
40/// Returns the output node IDs (data is in the arena).
41pub fn execute(graph: &Graph, arena: &mut Arena, external: &ExternalBuffers) {
42    let schedule: Vec<NodeId> = arena.schedule().to_vec();
43    // NaN/Inf localization epilogue. Off unless `RLX_DEBUG_NANS` is set;
44    // `RLX_DEBUG_NANS=abort` additionally fails fast. The env is read once here
45    // so the hot loop pays only a bool test.
46    let scanner = rlx_ir::numeric_check::DebugScanner::from_env("cpu");
47    for &node_id in &schedule {
48        let node = graph.node(node_id);
49
50        match &node.op {
51            // External data — skip (data provided via get_data which reads
52            // from external buffers directly without copying to arena)
53            Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => {}
54
55            // ── Fused matmul + bias + optional activation ───────────
56            Op::FusedMatMulBiasAct { activation } => {
57                let input_id = node.inputs[0];
58                let weight_id = node.inputs[1];
59                let bias_id = node.inputs[2];
60
61                let input = get_data(arena, external, input_id);
62                let weight = get_data(arena, external, weight_id);
63                let bias = get_data(arena, external, bias_id);
64                let output = get_output(arena, node_id);
65
66                // Compute output shape for sgemm
67                let shape = &node.shape;
68                let n = shape.dim(shape.rank() - 1).unwrap_static();
69                let m = shape.num_elements().unwrap() / n;
70                let k = input.len() / m;
71
72                // sgemm: output = input @ weight
73                // TODO: call cblas_sgemm via FFI
74                // For now, naive matmul as placeholder
75                matmul(input, weight, output, m, k, n);
76
77                // Fused bias + activation (parallel NEON kernels)
78                match activation {
79                    Some(Activation::Gelu) => kernels::par_bias_gelu(output, bias, m, n),
80                    Some(Activation::Silu) => {
81                        crate::blas::bias_add(output, bias, m, n);
82                        kernels::silu_inplace(output);
83                    }
84                    _ => crate::blas::bias_add(output, bias, m, n),
85                }
86            }
87
88            // ── Fused residual + LayerNorm (parallel NEON) ──────────
89            Op::FusedResidualLN { has_bias, eps } => {
90                let x_id = node.inputs[0];
91                let residual_id = node.inputs[1];
92                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
93                let zero_bias = vec![0f32; h];
94                let (gamma_id, beta_id, bias_slice) = if *has_bias {
95                    let b = get_data(arena, external, node.inputs[2]);
96                    (node.inputs[3], node.inputs[4], b)
97                } else {
98                    (node.inputs[2], node.inputs[3], zero_bias.as_slice())
99                };
100
101                let x = get_data(arena, external, x_id);
102                let residual = get_data(arena, external, residual_id);
103                let gamma = get_data(arena, external, gamma_id);
104                let beta = get_data(arena, external, beta_id);
105                let output = get_output(arena, node_id);
106
107                let n = x.len() / h;
108
109                // Parallel: each thread processes a chunk of rows
110                let x_ptr = x.as_ptr() as usize;
111                let r_ptr = residual.as_ptr() as usize;
112                let o_ptr = output.as_mut_ptr() as usize;
113                let bi_ptr = bias_slice.as_ptr() as usize;
114                let g_ptr = gamma.as_ptr() as usize;
115                let b_ptr = beta.as_ptr() as usize;
116                let e = *eps;
117                crate::pool::par_for(n, 4, &|off, cnt| unsafe {
118                    let x_s =
119                        std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
120                    let r_s =
121                        std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
122                    let o_s =
123                        std::slice::from_raw_parts_mut((o_ptr as *mut f32).add(off * h), cnt * h);
124                    let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
125                    let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
126                    let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
127                    kernels::residual_bias_layer_norm(x_s, r_s, bi, g, b, o_s, cnt, h, e);
128                });
129            }
130
131            // ── Fused residual + RMSNorm (parallel) ─────────────────
132            Op::FusedResidualRmsNorm { has_bias, eps } => {
133                let x_id = node.inputs[0];
134                let residual_id = node.inputs[1];
135                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
136                let zero_bias = vec![0f32; h];
137                let (gamma_id, beta_id, bias_slice) = if *has_bias {
138                    let b = get_data(arena, external, node.inputs[2]);
139                    (node.inputs[3], node.inputs[4], b)
140                } else {
141                    (node.inputs[2], node.inputs[3], zero_bias.as_slice())
142                };
143
144                let x = get_data(arena, external, x_id);
145                let residual = get_data(arena, external, residual_id);
146                let gamma = get_data(arena, external, gamma_id);
147                let beta = get_data(arena, external, beta_id);
148                let output = get_output(arena, node_id);
149
150                let n = x.len() / h;
151
152                let x_ptr = x.as_ptr() as usize;
153                let r_ptr = residual.as_ptr() as usize;
154                let o_ptr = output.as_mut_ptr() as usize;
155                let bi_ptr = bias_slice.as_ptr() as usize;
156                let g_ptr = gamma.as_ptr() as usize;
157                let b_ptr = beta.as_ptr() as usize;
158                let e = *eps;
159                crate::pool::par_for(n, 4, &|off, cnt| unsafe {
160                    let x_s =
161                        std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
162                    let r_s =
163                        std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
164                    let o_s =
165                        std::slice::from_raw_parts_mut((o_ptr as *mut f32).add(off * h), cnt * h);
166                    let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
167                    let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
168                    let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
169                    kernels::residual_bias_rms_norm(x_s, r_s, bi, g, b, o_s, cnt, h, e);
170                });
171            }
172
173            // ── Plain matmul ────────────────────────────────────────
174            Op::MatMul => {
175                let lhs = get_data(arena, external, node.inputs[0]);
176                let rhs = get_data(arena, external, node.inputs[1]);
177                let output = get_output(arena, node_id);
178
179                let shape = &node.shape;
180                let lhs_shape = &graph.node(node.inputs[0]).shape;
181                let rhs_shape = &graph.node(node.inputs[1]).shape;
182                let n = shape.dim(shape.rank() - 1).unwrap_static();
183                let out_m_inner = shape.dim(shape.rank() - 2).unwrap_static();
184                let k = lhs_shape.dim(lhs_shape.rank() - 1).unwrap_static();
185
186                // Outer batch dims — present when either input has rank > 2.
187                // Compute total batch as output.num_elements / (M * N).
188                let total = shape.num_elements().unwrap();
189                let per_batch_out = out_m_inner * n;
190                let batches = total / per_batch_out;
191
192                if batches == 1 {
193                    matmul(lhs, rhs, output, out_m_inner, k, n);
194                } else {
195                    let lhs_batched =
196                        lhs_shape.num_elements().unwrap_or(0) == batches * out_m_inner * k;
197                    let rhs_batched = rhs_shape.num_elements().unwrap_or(0) == batches * k * n;
198                    for b in 0..batches {
199                        let l_off = if lhs_batched { b * out_m_inner * k } else { 0 };
200                        let r_off = if rhs_batched { b * k * n } else { 0 };
201                        let o_off = b * out_m_inner * n;
202                        let l_slice = &lhs[l_off..l_off + out_m_inner * k];
203                        let r_slice = &rhs[r_off..r_off + k * n];
204                        let o_slice = &mut output[o_off..o_off + out_m_inner * n];
205                        matmul(l_slice, r_slice, o_slice, out_m_inner, k, n);
206                    }
207                }
208            }
209
210            // ── Element-wise binary ─────────────────────────────────
211            Op::Binary(op) => {
212                let lhs = get_data(arena, external, node.inputs[0]);
213                let rhs = get_data(arena, external, node.inputs[1]);
214                let output = get_output(arena, node_id);
215                let len = output.len();
216                let rhs_len = rhs.len();
217
218                // Fast path: Add with broadcast bias → NEON bias_add
219                if matches!(op, BinaryOp::Add) && rhs_len < len && len.is_multiple_of(rhs_len) {
220                    output.copy_from_slice(lhs);
221                    crate::blas::bias_add(output, rhs, len / rhs_len, rhs_len);
222                } else if rhs_len == len {
223                    for i in 0..len {
224                        output[i] = binary_op(*op, lhs[i], rhs[i]);
225                    }
226                } else {
227                    for i in 0..len {
228                        output[i] = binary_op(*op, lhs[i], rhs[i % rhs_len]);
229                    }
230                }
231            }
232
233            // ── Unary activation ────────────────────────────────────
234            Op::Activation(act) => {
235                let input = get_data(arena, external, node.inputs[0]);
236                let output = get_output(arena, node_id);
237                output.copy_from_slice(input);
238                let zeros = vec![0f32; node.shape.dim(node.shape.rank() - 1).unwrap_static()];
239                let m = output.len() / zeros.len();
240                let n = zeros.len();
241                match act {
242                    Activation::Gelu => kernels::par_bias_gelu(output, &zeros, m, n),
243                    Activation::Silu => kernels::silu_inplace(output),
244                    Activation::Relu => {
245                        for v in output.iter_mut() {
246                            *v = v.max(0.0);
247                        }
248                    }
249                    Activation::Exp => {
250                        for v in output.iter_mut() {
251                            *v = v.exp();
252                        }
253                    }
254                    Activation::Sqrt => {
255                        for v in output.iter_mut() {
256                            *v = v.sqrt();
257                        }
258                    }
259                    Activation::Neg => {
260                        for v in output.iter_mut() {
261                            *v = -*v;
262                        }
263                    }
264                    Activation::Tanh => {
265                        for v in output.iter_mut() {
266                            *v = v.tanh();
267                        }
268                    }
269                    Activation::Sigmoid => {
270                        for v in output.iter_mut() {
271                            *v = 1.0 / (1.0 + (-*v).exp());
272                        }
273                    }
274                    _ => {}
275                }
276            }
277
278            // ── Gather (embedding lookup) ───────────────────────────
279            Op::Gather { axis } => {
280                let table = get_data(arena, external, node.inputs[0]);
281                let indices = get_data(arena, external, node.inputs[1]);
282                let output = get_output(arena, node_id);
283
284                let table_shape = &graph.node(node.inputs[0]).shape;
285                let _out_shape = &node.shape;
286
287                // For axis=0 (embedding): table[V, D...], indices[B, S] → [B, S, D...]
288                if *axis == 0 {
289                    let trailing: usize = (1..table_shape.rank())
290                        .map(|i| table_shape.dim(i).unwrap_static())
291                        .product();
292                    for (i, &idx_f32) in indices.iter().enumerate() {
293                        let idx = idx_f32 as usize;
294                        let src = idx * trailing;
295                        let dst = i * trailing;
296                        output[dst..dst + trailing].copy_from_slice(&table[src..src + trailing]);
297                    }
298                } else {
299                    // General gather along `axis`: the index tensor is applied
300                    // uniformly across the leading (`outer`) dims and copies a
301                    // contiguous `inner` block per gathered element.
302                    let rank = table_shape.rank();
303                    let outer: usize = (0..*axis)
304                        .map(|i| table_shape.dim(i).unwrap_static())
305                        .product();
306                    let axis_size = table_shape.dim(*axis).unwrap_static();
307                    let inner: usize = (*axis + 1..rank)
308                        .map(|i| table_shape.dim(i).unwrap_static())
309                        .product();
310                    let n_idx = indices.len();
311                    for o in 0..outer {
312                        for (k, &idx_f32) in indices.iter().enumerate() {
313                            let idx = (idx_f32 as usize).min(axis_size.saturating_sub(1));
314                            let src = (o * axis_size + idx) * inner;
315                            let dst = (o * n_idx + k) * inner;
316                            output[dst..dst + inner].copy_from_slice(&table[src..src + inner]);
317                        }
318                    }
319                }
320            }
321
322            // ── Narrow (slice along axis) ───────────────────────────
323            Op::Narrow { axis, start, len } => {
324                let input = get_data(arena, external, node.inputs[0]);
325                let output = get_output(arena, node_id);
326                let in_shape = &graph.node(node.inputs[0]).shape;
327
328                let rank = in_shape.rank();
329                let outer: usize = (0..*axis)
330                    .map(|i| in_shape.dim(i).unwrap_static())
331                    .product::<usize>()
332                    .max(1);
333                let inner: usize = (*axis + 1..rank)
334                    .map(|i| in_shape.dim(i).unwrap_static())
335                    .product::<usize>()
336                    .max(1);
337                let in_axis_size = in_shape.dim(*axis).unwrap_static();
338
339                for o in 0..outer {
340                    for s in 0..*len {
341                        let src_off = o * in_axis_size * inner + (*start + s) * inner;
342                        let dst_off = o * len * inner + s * inner;
343                        output[dst_off..dst_off + inner]
344                            .copy_from_slice(&input[src_off..src_off + inner]);
345                    }
346                }
347            }
348
349            // ── Transpose ───────────────────────────────────────────
350            Op::Transpose { perm } => {
351                let input = get_data(arena, external, node.inputs[0]);
352                let output = get_output(arena, node_id);
353                let in_shape = &graph.node(node.inputs[0]).shape;
354                let rank = in_shape.rank();
355
356                let in_dims: Vec<usize> =
357                    (0..rank).map(|i| in_shape.dim(i).unwrap_static()).collect();
358                let out_dims: Vec<usize> = perm.iter().map(|&i| in_dims[i]).collect();
359
360                // Row-major strides for input and output spaces.
361                // For a shape [d0, d1, ..., d_{r-1}], stride[i] = product(d_{i+1..r}).
362                let mut in_strides = vec![1usize; rank];
363                for i in (0..rank - 1).rev() {
364                    in_strides[i] = in_strides[i + 1] * in_dims[i + 1];
365                }
366                let mut out_strides = vec![1usize; rank];
367                for i in (0..rank - 1).rev() {
368                    out_strides[i] = out_strides[i + 1] * out_dims[i + 1];
369                }
370
371                let total = output.len();
372                for flat_out in 0..total {
373                    let mut in_flat = 0;
374                    for d in 0..rank {
375                        // out_coord[d] decoded from flat_out via output strides.
376                        let coord = (flat_out / out_strides[d]) % out_dims[d];
377                        // Output dim d came from input dim perm[d].
378                        in_flat += coord * in_strides[perm[d]];
379                    }
380                    output[flat_out] = input[in_flat];
381                }
382            }
383
384            // ── Concat ──────────────────────────────────────────────
385            Op::Concat { axis } => {
386                let output = get_output(arena, node_id);
387                let out_shape = &node.shape;
388                let rank = out_shape.rank();
389
390                let outer: usize = (0..*axis)
391                    .map(|i| out_shape.dim(i).unwrap_static())
392                    .product::<usize>()
393                    .max(1);
394                let inner: usize = (*axis + 1..rank)
395                    .map(|i| out_shape.dim(i).unwrap_static())
396                    .product::<usize>()
397                    .max(1);
398
399                let mut dst_off = 0;
400                for o in 0..outer {
401                    for &inp_id in &node.inputs {
402                        let inp = get_data(arena, external, inp_id);
403                        let inp_shape = &graph.node(inp_id).shape;
404                        let inp_axis = inp_shape.dim(*axis).unwrap_static();
405                        let chunk = inp_axis * inner;
406                        let src_off = o * chunk;
407                        output[dst_off..dst_off + chunk]
408                            .copy_from_slice(&inp[src_off..src_off + chunk]);
409                        dst_off += chunk;
410                    }
411                }
412            }
413
414            // ── Reshape (zero-copy: same data, different shape) ─────
415            Op::Reshape { .. } => {
416                let input = get_data(arena, external, node.inputs[0]);
417                let output = get_output(arena, node_id);
418                output[..input.len()].copy_from_slice(input);
419            }
420            // Broadcast the input up to the output shape (size-1 / missing leading
421            // dims replicate). A plain copy would leave a stride-0 axis unfilled —
422            // e.g. `[3,1] → [3,3]` in a `torch.eye` (arange/eq) construction.
423            Op::Expand { .. } => {
424                let input = get_data(arena, external, node.inputs[0]);
425                let in_shape = &graph.node(node.inputs[0]).shape;
426                let out_shape = &node.shape;
427                let out_rank = out_shape.rank();
428                let pad = out_rank - in_shape.rank();
429                let out_dims: Vec<usize> = (0..out_rank)
430                    .map(|i| out_shape.dim(i).unwrap_static())
431                    .collect();
432                let in_dims: Vec<usize> = (0..out_rank)
433                    .map(|i| {
434                        if i < pad {
435                            1
436                        } else {
437                            in_shape.dim(i - pad).unwrap_static()
438                        }
439                    })
440                    .collect();
441                // Row-major input strides over the padded shape; 0 on broadcast axes.
442                let mut in_strides = vec![0usize; out_rank];
443                let mut acc = 1usize;
444                for i in (0..out_rank).rev() {
445                    in_strides[i] = if in_dims[i] == 1 { 0 } else { acc };
446                    acc *= in_dims[i];
447                }
448                let output = get_output(arena, node_id);
449                let total: usize = out_dims.iter().product();
450                let mut coords = vec![0usize; out_rank];
451                for out_idx in 0..total {
452                    let mut in_idx = 0usize;
453                    for i in 0..out_rank {
454                        in_idx += coords[i] * in_strides[i];
455                    }
456                    output[out_idx] = input[in_idx];
457                    for i in (0..out_rank).rev() {
458                        coords[i] += 1;
459                        if coords[i] < out_dims[i] {
460                            break;
461                        }
462                        coords[i] = 0;
463                    }
464                }
465            }
466
467            // ── LayerNorm (parallel NEON) ────────────────────────────
468            Op::LayerNorm { eps, .. } => {
469                let input = get_data(arena, external, node.inputs[0]);
470                let gamma = get_data(arena, external, node.inputs[1]);
471                let beta = get_data(arena, external, node.inputs[2]);
472                let output = get_output(arena, node_id);
473                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
474                let n = input.len() / h;
475                for row in 0..n {
476                    let base = row * h;
477                    kernels::layer_norm_row(
478                        &input[base..base + h],
479                        gamma,
480                        beta,
481                        &mut output[base..base + h],
482                        h,
483                        *eps,
484                    );
485                }
486            }
487
488            Op::GroupNorm { num_groups, eps } => {
489                let input = get_data(arena, external, node.inputs[0]);
490                let gamma = get_data(arena, external, node.inputs[1]);
491                let beta = get_data(arena, external, node.inputs[2]);
492                let output = get_output(arena, node_id);
493                let n = node.shape.dim(0).unwrap_static();
494                let c = node.shape.dim(1).unwrap_static();
495                let h = node.shape.dim(2).unwrap_static();
496                let w = node.shape.dim(3).unwrap_static();
497                kernels::group_norm_nchw(input, gamma, beta, output, n, c, h, w, *num_groups, *eps);
498            }
499
500            Op::ResizeNearest2x => {
501                let input = get_data(arena, external, node.inputs[0]);
502                let output = get_output(arena, node_id);
503                let n = node.shape.dim(0).unwrap_static();
504                let c = node.shape.dim(1).unwrap_static();
505                let h = node.shape.dim(2).unwrap_static() / 2;
506                let w = node.shape.dim(3).unwrap_static() / 2;
507                let in_plane = c * h * w;
508                let out_plane = c * h * 2 * w * 2;
509                for ni in 0..n {
510                    kernels::resize_nearest_2x_nchw(
511                        &input[ni * in_plane..(ni + 1) * in_plane],
512                        &mut output[ni * out_plane..(ni + 1) * out_plane],
513                        c,
514                        h,
515                        w,
516                    );
517                }
518            }
519
520            Op::AxialRope2d {
521                end_x,
522                end_y,
523                head_dim,
524                num_heads,
525                theta,
526                repeat_factor,
527            } => {
528                let input = get_data(arena, external, node.inputs[0]);
529                let output = get_output(arena, node_id);
530                let batch = node.shape.dim(0).unwrap_static();
531                let seq = node.shape.dim(1).unwrap_static();
532                let plane = seq * node.shape.dim(2).unwrap_static();
533                for bi in 0..batch {
534                    let rotated = rlx_ir::ops::axial_rope2d::apply_axial_rope2d(
535                        &input[bi * plane..(bi + 1) * plane],
536                        *num_heads,
537                        seq,
538                        *head_dim,
539                        *end_x,
540                        *end_y,
541                        *theta,
542                        *repeat_factor,
543                    );
544                    output[bi * plane..(bi + 1) * plane].copy_from_slice(&rotated);
545                }
546            }
547
548            // ── Softmax ─────────────────────────────────────────────
549            Op::Softmax { axis } => {
550                let input = get_data(arena, external, node.inputs[0]);
551                let output = get_output(arena, node_id);
552                output.copy_from_slice(input);
553                let rank = node.shape.rank();
554                let ax = if *axis < 0 {
555                    (rank as i32 + axis) as usize
556                } else {
557                    *axis as usize
558                };
559                let cols = node.shape.dim(ax).unwrap_static();
560                let rows = output.len() / cols;
561                crate::naive::softmax(output, rows, cols);
562            }
563
564            // ── Attention (SDPA) — BLAS-accelerated ─────────────────
565            Op::Attention {
566                num_heads,
567                head_dim,
568                mask_kind,
569                score_scale,
570                attn_logit_softcap,
571            } => {
572                let q = get_data(arena, external, node.inputs[0]);
573                let k = get_data(arena, external, node.inputs[1]);
574                let v = get_data(arena, external, node.inputs[2]);
575                // For non-Custom mask kinds the IR emits no mask input —
576                // synthesize an empty slice so the masking branch below
577                // sees `mask.len() < ...` and skips.
578                let mask: &[f32] = if matches!(
579                    mask_kind,
580                    rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias
581                ) {
582                    get_data(arena, external, node.inputs[3])
583                } else {
584                    &[]
585                };
586                let output = get_output(arena, node_id);
587
588                let q_shape = &graph.node(node.inputs[0]).shape;
589                let k_shape = &graph.node(node.inputs[1]).shape;
590                let hs = num_heads * head_dim;
591                let scale = score_scale.unwrap_or((*head_dim as f32).powf(-0.5));
592                let (batch_size, s_q) = if q_shape.rank() >= 3 {
593                    (
594                        q_shape.dim(0).unwrap_static(),
595                        q_shape.dim(1).unwrap_static(),
596                    )
597                } else {
598                    (1, q_shape.dim(0).unwrap_static())
599                };
600                // K and V share Lk. In decode mode Lk = past+1 and Lq = 1;
601                // in prefill Lq = Lk. Causal/SlidingWindow masking is
602                // expressed in absolute positions: Q-row qi is at absolute
603                // position (Lk - Lq) + qi, so masking shifts accordingly.
604                let s_k = if k_shape.rank() >= 3 {
605                    k_shape.dim(1).unwrap_static()
606                } else {
607                    k_shape.dim(0).unwrap_static()
608                };
609                let q_offset = s_k.saturating_sub(s_q);
610
611                // Pre-allocate buffers ONCE (reused across heads)
612                let q_buf_len = s_q * head_dim;
613                let k_buf_len = s_k * head_dim;
614                let mut q_head = vec![0f32; q_buf_len];
615                let mut k_head = vec![0f32; k_buf_len];
616                let mut v_head = vec![0f32; k_buf_len];
617                let mut scores = vec![0f32; s_q * s_k];
618                let mut out_head = vec![0f32; q_buf_len];
619
620                for bi in 0..batch_size {
621                    for hi in 0..*num_heads {
622                        // Gather per-head Q (Lq rows).
623                        for si in 0..s_q {
624                            let off = bi * s_q * hs + si * hs + hi * head_dim;
625                            q_head[si * head_dim..(si + 1) * head_dim]
626                                .copy_from_slice(&q[off..off + head_dim]);
627                        }
628                        // Gather per-head K, V (Lk rows).
629                        for si in 0..s_k {
630                            let off = bi * s_k * hs + si * hs + hi * head_dim;
631                            k_head[si * head_dim..(si + 1) * head_dim]
632                                .copy_from_slice(&k[off..off + head_dim]);
633                            v_head[si * head_dim..(si + 1) * head_dim]
634                                .copy_from_slice(&v[off..off + head_dim]);
635                        }
636                        // Q@K^T: scores[Lq, Lk]. Use NEON dots when the
637                        // larger of Lq/Lk is small; BLAS otherwise.
638                        if s_q.max(s_k) <= 32 {
639                            for qi in 0..s_q {
640                                for ki in 0..s_k {
641                                    let q_off = qi * head_dim;
642                                    let k_off = ki * head_dim;
643                                    #[cfg(target_arch = "aarch64")]
644                                    let mut dot;
645                                    #[cfg(not(target_arch = "aarch64"))]
646                                    let mut dot = 0f32;
647                                    #[cfg(target_arch = "aarch64")]
648                                    unsafe {
649                                        use std::arch::aarch64::*;
650                                        let chunks = head_dim / 4;
651                                        let mut acc = vdupq_n_f32(0.0);
652                                        for c in 0..chunks {
653                                            let vq = vld1q_f32(q_head.as_ptr().add(q_off + c * 4));
654                                            let vk = vld1q_f32(k_head.as_ptr().add(k_off + c * 4));
655                                            acc = vfmaq_f32(acc, vq, vk);
656                                        }
657                                        dot = vaddvq_f32(acc);
658                                        for d in (chunks * 4)..*head_dim {
659                                            dot += q_head[q_off + d] * k_head[k_off + d];
660                                        }
661                                    }
662                                    #[cfg(not(target_arch = "aarch64"))]
663                                    {
664                                        for d in 0..*head_dim {
665                                            dot += q_head[q_off + d] * k_head[k_off + d];
666                                        }
667                                    }
668                                    scores[qi * s_k + ki] = dot * scale;
669                                }
670                            }
671                        } else {
672                            crate::blas::sgemm_bt(
673                                &q_head,
674                                &k_head,
675                                &mut scores,
676                                s_q,
677                                *head_dim,
678                                s_k,
679                                scale,
680                            );
681                        }
682                        // Mask: branch on kind so None / Causal skip the
683                        // mask load entirely. Causal/SlidingWindow use
684                        // absolute positions so they handle Lq != Lk
685                        // (decode-mode with cached K/V).
686                        match mask_kind {
687                            rlx_ir::op::MaskKind::None => {}
688                            rlx_ir::op::MaskKind::Causal => {
689                                for qi in 0..s_q {
690                                    let abs_q = q_offset + qi;
691                                    for ki in (abs_q + 1)..s_k {
692                                        scores[qi * s_k + ki] = -1e9;
693                                    }
694                                }
695                            }
696                            rlx_ir::op::MaskKind::SlidingWindow(w) => {
697                                for qi in 0..s_q {
698                                    let abs_q = q_offset + qi;
699                                    let lo = abs_q.saturating_sub(*w);
700                                    for ki in 0..s_k {
701                                        if ki < lo || ki > abs_q {
702                                            scores[qi * s_k + ki] = -1e9;
703                                        }
704                                    }
705                                }
706                            }
707                            rlx_ir::op::MaskKind::Custom => {
708                                if mask.len() >= (bi + 1) * s_k {
709                                    let m = &mask[bi * s_k..(bi + 1) * s_k];
710                                    for qi in 0..s_q {
711                                        for ki in 0..s_k {
712                                            if m[ki] < 0.5 {
713                                                scores[qi * s_k + ki] = -1e9;
714                                            }
715                                        }
716                                    }
717                                }
718                            }
719                            rlx_ir::op::MaskKind::Bias => {
720                                // Bias is [batch, num_heads, s_q, s_k]
721                                // (additive, pre-softmax). Skip if the
722                                // buffer wasn't supplied.
723                                let per_bh = s_q * s_k;
724                                let need = (bi * *num_heads + hi + 1) * per_bh;
725                                if mask.len() >= need {
726                                    let bias_off = (bi * *num_heads + hi) * per_bh;
727                                    let b = &mask[bias_off..bias_off + per_bh];
728                                    for i in 0..per_bh {
729                                        scores[i] += b[i];
730                                    }
731                                }
732                            }
733                        }
734                        if let Some(cap) = attn_logit_softcap {
735                            if *cap > 0.0 {
736                                for s in scores.iter_mut() {
737                                    *s = cap * (*s / cap).tanh();
738                                }
739                            }
740                        }
741                        crate::naive::softmax(&mut scores, s_q, s_k);
742                        // scores[Lq, Lk] @ V[Lk, head_dim] → out_head[Lq, head_dim]
743                        if s_q.max(s_k) <= 32 {
744                            out_head.fill(0.0);
745                            for qi in 0..s_q {
746                                for ki in 0..s_k {
747                                    let sc = scores[qi * s_k + ki];
748                                    if sc > 1e-8 {
749                                        let v_off = ki * head_dim;
750                                        let o_off = qi * head_dim;
751                                        #[cfg(target_arch = "aarch64")]
752                                        unsafe {
753                                            use std::arch::aarch64::*;
754                                            let vsc = vdupq_n_f32(sc);
755                                            let chunks = head_dim / 4;
756                                            for c in 0..chunks {
757                                                let off = c * 4;
758                                                let vo =
759                                                    vld1q_f32(out_head.as_ptr().add(o_off + off));
760                                                let vv =
761                                                    vld1q_f32(v_head.as_ptr().add(v_off + off));
762                                                vst1q_f32(
763                                                    out_head.as_mut_ptr().add(o_off + off),
764                                                    vfmaq_f32(vo, vsc, vv),
765                                                );
766                                            }
767                                        }
768                                        #[cfg(not(target_arch = "aarch64"))]
769                                        for d in 0..*head_dim {
770                                            out_head[o_off + d] += sc * v_head[v_off + d];
771                                        }
772                                    }
773                                }
774                            }
775                        } else {
776                            crate::blas::sgemm(
777                                &scores,
778                                &v_head,
779                                &mut out_head,
780                                s_q,
781                                s_k,
782                                *head_dim,
783                            );
784                        }
785                        // Scatter back into [B, Lq, hs].
786                        for si in 0..s_q {
787                            let off = bi * s_q * hs + si * hs + hi * head_dim;
788                            output[off..off + head_dim]
789                                .copy_from_slice(&out_head[si * head_dim..(si + 1) * head_dim]);
790                        }
791                    }
792                }
793            }
794
795            // ── Rotary position embedding ────────────────────────────
796            //
797            // Layout-aware position derivation:
798            //   - Packed rank-3 input `[B, S, H*D]` (heads in the last dim):
799            //     a head_dim-sized chunk index `c` maps to `(b, s, h)` with
800            //     `s = (c / H) % S`. We compute `s` explicitly and ignore
801            //     `cos.len()` for position derivation. Mirrors the MLX
802            //     `multi_head_packed` path in `rlx-mlx/src/lower.rs` so all
803            //     backends agree on per-chunk position.
804            //   - Rank-4 input `[B, H, S, D]` (heads as their own axis):
805            //     chunks per head are contiguous in `S`, so `s = c % S`.
806            //   - Single-head input `[B, S, D]`: same as rank-4 with H=1.
807            //   - Decode slice `cos.len() == half` (single position table):
808            //     `cos_len / tab_half = 1`, so `s = c % 1 = 0` is the right
809            //     value and matches the runtime-computed slice for absolute
810            //     position `past_seq`.
811            Op::Rope {
812                head_dim, n_rot, ..
813            } => {
814                let head_dim = *head_dim;
815                let n_rot = *n_rot;
816                let x = get_data(arena, external, node.inputs[0]);
817                let cos_cache = get_data(arena, external, node.inputs[1]);
818                let sin_cache = get_data(arena, external, node.inputs[2]);
819                let x_shape = &graph.node(node.inputs[0]).shape;
820                let output = get_output(arena, node_id);
821                output.copy_from_slice(x);
822
823                let rot_half = n_rot / 2;
824                let tab_half = head_dim / 2;
825                let total = output.len();
826                let num_chunks = total / head_dim;
827
828                // Derive (s_dim, heads_per_seq) from the input shape so we can
829                // map chunk index → seq position without assuming layout.
830                let cos_rows = cos_cache.len() / tab_half.max(1);
831                let (s_dim, heads_per_seq): (usize, usize) = {
832                    let rank = x_shape.rank();
833                    if rank == 0 {
834                        (1, 1)
835                    } else {
836                        let last = if x_shape.dim(rank - 1).is_static() {
837                            x_shape.dim(rank - 1).unwrap_static()
838                        } else {
839                            head_dim
840                        };
841                        if rank >= 3 && last > head_dim && last.is_multiple_of(head_dim) {
842                            // Packed multi-head [..., S, H*D].
843                            let s = if x_shape.dim(rank - 2).is_static() {
844                                x_shape.dim(rank - 2).unwrap_static()
845                            } else {
846                                1
847                            };
848                            (s, last / head_dim)
849                        } else if rank >= 4 && last == head_dim {
850                            // [B, H, S, D] — heads on outer axis.
851                            let s = if x_shape.dim(rank - 2).is_static() {
852                                x_shape.dim(rank - 2).unwrap_static()
853                            } else {
854                                1
855                            };
856                            (s, 1)
857                        } else if rank >= 3 && last == head_dim {
858                            // [..., S, D] single head.
859                            let s = if x_shape.dim(rank - 2).is_static() {
860                                x_shape.dim(rank - 2).unwrap_static()
861                            } else {
862                                1
863                            };
864                            (s, 1)
865                        } else {
866                            // Fallback: rely on the cos-table-length heuristic.
867                            (cos_rows.max(1), 1)
868                        }
869                    }
870                };
871
872                if std::env::var("RLX_ROPE_DEBUG").is_ok() {
873                    eprintln!(
874                        "[rope] shape={:?} num_chunks={num_chunks} cos_rows={cos_rows} s_dim={s_dim} heads_per_seq={heads_per_seq}",
875                        x_shape.dims()
876                    );
877                }
878                // Total tokens across the flattened (batch · seq) grid: each
879                // token contributes `heads_per_seq` chunks.
880                let total_tokens = num_chunks / heads_per_seq.max(1);
881                for chunk in 0..num_chunks {
882                    let off = chunk * head_dim;
883                    // Global token index of this chunk.
884                    //   - Packed [B, S, H*D]: chunk = ((b*S)+s)*H + h ⇒ token = chunk / H.
885                    //   - [B, H, S, D] / [B, S, D]: chunks per seq run contig ⇒ token = chunk.
886                    let token = if heads_per_seq > 1 {
887                        chunk / heads_per_seq
888                    } else {
889                        chunk
890                    };
891                    let pos = if cos_rows == 1 {
892                        // Single supplied RoPE row (uniform decode slice): always row 0.
893                        0
894                    } else if cos_rows == total_tokens && total_tokens != s_dim {
895                        // Per-token RoPE table — one row per (batch · seq) token.
896                        // Used by *ragged* batched decode, where each sequence in
897                        // the batch sits at a different absolute position. Index
898                        // by the global token, not the (collapsed) seq position.
899                        token.min(cos_rows - 1)
900                    } else {
901                        // Per-seq-position table shared across the batch.
902                        (token % s_dim).min(cos_rows.saturating_sub(1))
903                    };
904                    if std::env::var("RLX_ROPE_DEBUG").is_ok() && chunk < 4 {
905                        eprintln!("[rope]   chunk={chunk} pos={pos}");
906                    }
907                    let cos_off = pos * tab_half;
908
909                    for i in 0..rot_half {
910                        let cos_v = cos_cache[cos_off + i];
911                        let sin_v = sin_cache[cos_off + i];
912                        let x1 = output[off + i];
913                        let x2 = output[off + rot_half + i];
914                        output[off + i] = x1 * cos_v - x2 * sin_v;
915                        output[off + rot_half + i] = x2 * cos_v + x1 * sin_v;
916                    }
917                    output[(n_rot + off)..(head_dim + off)]
918                        .copy_from_slice(&x[(n_rot + off)..(head_dim + off)]);
919                }
920            }
921
922            // ── Compare ─────────────────────────────────────────────
923            Op::Compare(cmp) => {
924                let lhs = get_data(arena, external, node.inputs[0]);
925                let rhs = get_data(arena, external, node.inputs[1]);
926                let output = get_output(arena, node_id);
927                let rhs_len = rhs.len();
928                for i in 0..output.len() {
929                    let a = lhs[i];
930                    let b = rhs[i % rhs_len];
931                    output[i] = if compare_op(*cmp, a, b) { 1.0 } else { 0.0 };
932                }
933            }
934
935            // ── Where (conditional select) ──────────────────────────
936            Op::Where => {
937                let cond = get_data(arena, external, node.inputs[0]);
938                let on_true = get_data(arena, external, node.inputs[1]);
939                let on_false = get_data(arena, external, node.inputs[2]);
940                let output = get_output(arena, node_id);
941                for i in 0..output.len() {
942                    output[i] = if cond[i] > 0.5 {
943                        on_true[i]
944                    } else {
945                        on_false[i]
946                    };
947                }
948            }
949
950            // ── Reduce ──────────────────────────────────────────────
951            Op::Reduce {
952                op: reduce_op,
953                axes,
954                keep_dim: _,
955            } => {
956                let input = get_data(arena, external, node.inputs[0]);
957                let output = get_output(arena, node_id);
958                output.fill(0.0);
959                // Simple: only handle single-axis reduction for now
960                if axes.len() == 1 {
961                    let in_shape = &graph.node(node.inputs[0]).shape;
962                    let axis = axes[0];
963                    let rank = in_shape.rank();
964                    let outer: usize = (0..axis)
965                        .map(|i| in_shape.dim(i).unwrap_static())
966                        .product::<usize>()
967                        .max(1);
968                    let axis_size = in_shape.dim(axis).unwrap_static();
969                    let inner: usize = (axis + 1..rank)
970                        .map(|i| in_shape.dim(i).unwrap_static())
971                        .product::<usize>()
972                        .max(1);
973
974                    match reduce_op {
975                        ReduceOp::Sum | ReduceOp::Mean => {
976                            for o in 0..outer {
977                                for i in 0..inner {
978                                    let mut acc = 0f32;
979                                    for a in 0..axis_size {
980                                        acc += input[o * axis_size * inner + a * inner + i];
981                                    }
982                                    if matches!(reduce_op, ReduceOp::Mean) {
983                                        acc /= axis_size as f32;
984                                    }
985                                    output[o * inner + i] = acc;
986                                }
987                            }
988                        }
989                        ReduceOp::Max => {
990                            output.fill(f32::NEG_INFINITY);
991                            for o in 0..outer {
992                                for i in 0..inner {
993                                    for a in 0..axis_size {
994                                        let v = input[o * axis_size * inner + a * inner + i];
995                                        let idx = o * inner + i;
996                                        if v > output[idx] {
997                                            output[idx] = v;
998                                        }
999                                    }
1000                                }
1001                            }
1002                        }
1003                        _ => {} // Min, Prod — TODO
1004                    }
1005                }
1006            }
1007
1008            // ── Cast ────────────────────────────────────────────────
1009            Op::Cast { .. } => {
1010                let input = get_data(arena, external, node.inputs[0]);
1011                let output = get_output(arena, node_id);
1012                output[..input.len()].copy_from_slice(input);
1013            }
1014
1015            // ── Fused SwiGLU ────────────────────────────────────────
1016            // Input layout: concatenated [..., 2N] tensor where the first
1017            // N elements per row are the "up" projection and the next N
1018            // are the "gate" projection. Output: [..., N] = up * silu(gate).
1019            // `cast_to` is currently advisory: rlx-cpu always operates in
1020            // f32, so backends that distinguish dtypes apply the cast; the
1021            // CPU executor stores the f32 result regardless.
1022            Op::FusedSwiGLU { cast_to: _, .. } => {
1023                let input = get_data(arena, external, node.inputs[0]);
1024                let output = get_output(arena, node_id);
1025                // n = last-dim half (read from the node's own shape, NOT
1026                // derived from buffer lengths — those count total elements
1027                // including all leading dims).
1028                let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
1029                let outer = output.len() / n;
1030                debug_assert_eq!(
1031                    outer * 2 * n,
1032                    input.len(),
1033                    "FusedSwiGLU: input/output shape mismatch"
1034                );
1035                for o in 0..outer {
1036                    let in_row = &input[o * 2 * n..(o + 1) * 2 * n];
1037                    let out_row = &mut output[o * n..(o + 1) * n];
1038                    for i in 0..n {
1039                        let up = in_row[i];
1040                        let gate = in_row[n + i];
1041                        let silu_gate = gate / (1.0 + (-gate).exp());
1042                        out_row[i] = up * silu_gate;
1043                    }
1044                }
1045            }
1046
1047            // ── DenseSolve: x = A⁻¹ b (F32 / F64 via LAPACK) ────────
1048            Op::DenseSolve => {
1049                let a_shape = &graph.node(node.inputs[0]).shape;
1050                let n = a_shape.dim(0).unwrap_static();
1051                let b_elems = node.shape.num_elements().unwrap();
1052                let nrhs = b_elems / n.max(1);
1053                match node.shape.dtype() {
1054                    rlx_ir::DType::F32 => {
1055                        let a = get_data(arena, external, node.inputs[0]);
1056                        let b = get_data(arena, external, node.inputs[1]);
1057                        let x = get_output(arena, node_id);
1058                        let mut a_scratch = a.to_vec();
1059                        let mut x_buf = b.to_vec();
1060                        let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n, nrhs);
1061                        if info != 0 {
1062                            panic!("DenseSolve: singular matrix (info={info})");
1063                        }
1064                        x[..x_buf.len()].copy_from_slice(&x_buf);
1065                    }
1066                    rlx_ir::DType::F64 => {
1067                        let (a_ptr, a_len) = arena.raw_ptr(node.inputs[0]);
1068                        let (b_ptr, b_len) = arena.raw_ptr(node.inputs[1]);
1069                        let (x_ptr, x_len) = arena.raw_ptr(node_id);
1070                        unsafe {
1071                            let a_src = std::slice::from_raw_parts(a_ptr as *const f64, a_len / 8);
1072                            let b_src = std::slice::from_raw_parts(b_ptr as *const f64, b_len / 8);
1073                            let mut a_scratch = a_src.to_vec();
1074                            let mut x_buf = b_src.to_vec();
1075                            let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n, nrhs);
1076                            if info != 0 {
1077                                panic!("DenseSolve: singular matrix (info={info})");
1078                            }
1079                            std::slice::from_raw_parts_mut(x_ptr as *mut f64, x_len / 8)
1080                                .copy_from_slice(&x_buf);
1081                        }
1082                    }
1083                    other => panic!("DenseSolve executor: unsupported dtype {other:?}"),
1084                }
1085            }
1086
1087            // ── Passthrough for unimplemented ops ───────────────────
1088            _ => {
1089                if !node.inputs.is_empty() && arena.has_buffer(node_id) {
1090                    let input = get_data(arena, external, node.inputs[0]);
1091                    let output = get_output(arena, node_id);
1092                    let len = output.len().min(input.len());
1093                    output[..len].copy_from_slice(&input[..len]);
1094                }
1095            }
1096        }
1097
1098        // ── NaN/Inf debug epilogue ───────────────────────────────
1099        // Runs in schedule (topological) order, so the FIRST node that
1100        // trips is the origin — not the downstream ops that inherit it.
1101        if scanner.enabled() {
1102            scan_node_for_nans(&scanner, graph, node_id, arena, external);
1103        }
1104    }
1105}
1106
1107/// Localize a NaN/Inf on a single computed node using the same buffer
1108/// accessors as the executor, applying the [`DebugScanner`] policy (print /
1109/// abort). Returns the report on a hit for tests. No-op when the node has no
1110/// scannable `f32` buffer. Split out from [`execute`] so the gather logic is
1111/// unit-testable without toggling process env.
1112///
1113/// F32 buffers only — the executor also stores raw f64 for DenseSolve, which
1114/// we skip.
1115pub(crate) fn scan_node_for_nans(
1116    scanner: &rlx_ir::numeric_check::DebugScanner,
1117    graph: &Graph,
1118    node_id: NodeId,
1119    arena: &Arena,
1120    external: &ExternalBuffers,
1121) -> Option<rlx_ir::numeric_check::NanReport> {
1122    let node = graph.node(node_id);
1123    if node.shape.dtype() != rlx_ir::DType::F32 || !arena.has_buffer(node_id) {
1124        return None;
1125    }
1126    let out = get_data(arena, external, node_id);
1127    let mut inbufs: Vec<(NodeId, &[f32])> = Vec::with_capacity(node.inputs.len());
1128    for &inp in &node.inputs {
1129        if graph.node(inp).shape.dtype() == rlx_ir::DType::F32
1130            && (external.buffers.contains_key(&inp) || arena.has_buffer(inp))
1131        {
1132            inbufs.push((inp, get_data(arena, external, inp)));
1133        }
1134    }
1135    scanner.check(graph, node_id, out, &inbufs)
1136}
1137
1138/// Get read-only data for a node — from external or arena via raw pointer.
1139/// SAFETY: the memory planner guarantees that input buffers don't overlap with
1140/// the output buffer being written, so concurrent read+write is safe.
1141fn get_data<'a>(arena: &'a Arena, external: &'a ExternalBuffers, id: NodeId) -> &'a [f32] {
1142    // Check external first (test mode, or runtime inputs copied via run())
1143    // Then arena (params pre-stored by set_param, computed intermediates)
1144    if let Some(&ext) = external.buffers.get(&id) {
1145        ext
1146    } else if arena.has_buffer(id) {
1147        let (ptr, len) = arena.raw_ptr(id);
1148        unsafe { std::slice::from_raw_parts(ptr, len) }
1149    } else {
1150        panic!("no data for node {id}")
1151    }
1152}
1153
1154/// Get mutable output buffer via raw pointer (doesn't borrow arena).
1155/// Takes `&Arena` (not `&mut Arena`) on purpose — the executor walks
1156/// the schedule with multiple node-buffer references live at once;
1157/// the arena allocator already partitioned them into non-overlapping
1158/// regions at compile time.
1159#[allow(clippy::mut_from_ref)]
1160fn get_output(arena: &Arena, id: NodeId) -> &mut [f32] {
1161    let (ptr, len) = arena.raw_ptr(id);
1162    unsafe { std::slice::from_raw_parts_mut(ptr, len) }
1163}
1164
1165/// Matrix multiply — uses BLAS when linked, naive fallback otherwise.
1166#[inline]
1167fn matmul(a: &[f32], b: &[f32], c: &mut [f32], m: usize, k: usize, n: usize) {
1168    // Use BLAS sgemm (Accelerate/MKL/OpenBLAS) — linked via build.rs
1169    crate::blas::sgemm(a, b, c, m, k, n);
1170}
1171
1172fn binary_op(op: rlx_ir::op::BinaryOp, a: f32, b: f32) -> f32 {
1173    use rlx_ir::op::BinaryOp::*;
1174    match op {
1175        Add => a + b,
1176        Sub => a - b,
1177        Mul => a * b,
1178        Div => a / b,
1179        Max => a.max(b),
1180        Min => a.min(b),
1181        Pow => a.powf(b),
1182    }
1183}
1184
1185fn compare_op(op: rlx_ir::op::CmpOp, a: f32, b: f32) -> bool {
1186    use rlx_ir::op::CmpOp::*;
1187    match op {
1188        Eq => a == b,
1189        Ne => a != b,
1190        Lt => a < b,
1191        Le => a <= b,
1192        Gt => a > b,
1193        Ge => a >= b,
1194    }
1195}
1196
1197// Reference scalar GELU — kept as a parity oracle for SIMD paths.
1198#[allow(dead_code)]
1199fn scalar_gelu(x: f32) -> f32 {
1200    let sign = if x >= 0.0 { 1.0f32 } else { -1.0 };
1201    let xa = x.abs();
1202    let t = 1.0 / (1.0 + 0.3275911 * xa);
1203    let y = t
1204        * (0.254_829_6
1205            + t * (-0.284_496_72 + t * (1.421_413_8 + t * (-1.453_152_1 + t * 1.061_405_4))));
1206    let erf = sign * (1.0 - y * (-xa * xa).exp());
1207    x * 0.5 * (1.0 + erf)
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213    use rlx_ir::*;
1214
1215    use rlx_opt::fusion::FuseMatMulBiasAct;
1216    use rlx_opt::memory;
1217    use rlx_opt::pass::Pass;
1218
1219    /// End-to-end test: build graph → fuse → plan memory → execute.
1220    #[test]
1221    fn execute_fused_matmul_bias_gelu() {
1222        // Build graph: x @ w + b → gelu
1223        let mut g = Graph::new("test");
1224        let x_id = g.input("x", Shape::new(&[2, 4], DType::F32));
1225        let w_id = g.param("w", Shape::new(&[4, 3], DType::F32));
1226        let b_id = g.param("b", Shape::new(&[3], DType::F32));
1227        let mm = g.matmul(x_id, w_id, Shape::new(&[2, 3], DType::F32));
1228        let add = g.binary(BinaryOp::Add, mm, b_id, Shape::new(&[2, 3], DType::F32));
1229        let out = g.activation(Activation::Gelu, add, Shape::new(&[2, 3], DType::F32));
1230        g.set_outputs(vec![out]);
1231
1232        // Fuse
1233        let fused = FuseMatMulBiasAct.run(g);
1234        println!("{fused}");
1235
1236        // Plan memory
1237        let plan = memory::plan_memory(&fused);
1238        println!("Arena: {} bytes", plan.arena_size);
1239
1240        // Prepare data
1241        let x_data = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // [2, 4] identity-ish
1242        let w_data = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]; // [4, 3]
1243        let b_data = vec![0.5, -0.5, 0.0]; // [3]
1244
1245        let mut ext = ExternalBuffers {
1246            buffers: HashMap::new(),
1247        };
1248        ext.buffers.insert(fused.outputs[0], &[]); // placeholder
1249        // Find input/param node IDs in fused graph
1250        for node in fused.nodes() {
1251            match &node.op {
1252                Op::Input { name } if name == "x" => {
1253                    ext.buffers.insert(node.id, &x_data);
1254                }
1255                Op::Param { name } if name == "w" => {
1256                    ext.buffers.insert(node.id, &w_data);
1257                }
1258                Op::Param { name } if name == "b" => {
1259                    ext.buffers.insert(node.id, &b_data);
1260                }
1261                _ => {}
1262            }
1263        }
1264
1265        // Execute
1266        let mut arena = Arena::from_plan(plan);
1267        execute(&fused, &mut arena, &ext);
1268
1269        // Check output
1270        let output_id = fused.outputs[0];
1271        let result = arena.slice(output_id);
1272        println!("Result: {result:?}");
1273
1274        // x @ w = [[1,0,0], [0,1,0]]; + bias = [[1.5,-0.5,0], [0.5,0.5,0]]
1275        // gelu(1.5) ≈ 1.399, gelu(-0.5) ≈ -0.154, gelu(0) = 0
1276        // gelu(0.5) ≈ 0.346
1277        assert!((result[0] - 1.399).abs() < 0.01, "got {}", result[0]);
1278        assert!((result[1] - -0.154).abs() < 0.01, "got {}", result[1]);
1279        assert!((result[2] - 0.0).abs() < 0.01, "got {}", result[2]);
1280        assert!((result[3] - 0.346).abs() < 0.01, "got {}", result[3]);
1281    }
1282
1283    /// Test Gather (embedding lookup).
1284    #[test]
1285    fn execute_gather() {
1286        use rlx_ir::infer::GraphExt;
1287        let mut g = Graph::new("gather_test");
1288        // Embedding table [4, 3] and indices [2] → output [2, 3]
1289        let table = g.param("table", Shape::new(&[4, 3], DType::F32));
1290        let indices = g.input("ids", Shape::new(&[2], DType::F32)); // f32 indices
1291        let out = g.gather_(table, indices, 0);
1292        g.set_outputs(vec![out]);
1293
1294        let plan = memory::plan_memory(&g);
1295        let mut arena = Arena::from_plan(plan);
1296
1297        let table_data = vec![
1298            10.0, 11.0, 12.0, // row 0
1299            20.0, 21.0, 22.0, // row 1
1300            30.0, 31.0, 32.0, // row 2
1301            40.0, 41.0, 42.0, // row 3
1302        ];
1303        let ids_data = vec![2.0, 0.0]; // gather rows 2 and 0
1304
1305        let mut ext = ExternalBuffers {
1306            buffers: HashMap::new(),
1307        };
1308        for node in g.nodes() {
1309            match &node.op {
1310                Op::Param { name } if name == "table" => {
1311                    ext.buffers.insert(node.id, &table_data);
1312                }
1313                Op::Input { name } if name == "ids" => {
1314                    ext.buffers.insert(node.id, &ids_data);
1315                }
1316                _ => {}
1317            }
1318        }
1319
1320        execute(&g, &mut arena, &ext);
1321        let result = arena.slice(g.outputs[0]);
1322        assert_eq!(&result[..3], &[30.0, 31.0, 32.0]); // row 2
1323        assert_eq!(&result[3..6], &[10.0, 11.0, 12.0]); // row 0
1324    }
1325
1326    /// Test Narrow (slice).
1327    #[test]
1328    fn execute_narrow() {
1329        use rlx_ir::infer::GraphExt;
1330        let mut g = Graph::new("narrow_test");
1331        let x = g.input("x", Shape::new(&[2, 6], DType::F32));
1332        let sliced = g.narrow_(x, 1, 2, 3); // take cols 2..5
1333        g.set_outputs(vec![sliced]);
1334
1335        let plan = memory::plan_memory(&g);
1336        let mut arena = Arena::from_plan(plan);
1337
1338        let data = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0];
1339        let mut ext = ExternalBuffers {
1340            buffers: HashMap::new(),
1341        };
1342        for node in g.nodes() {
1343            if let Op::Input { .. } = &node.op {
1344                ext.buffers.insert(node.id, &data);
1345            }
1346        }
1347
1348        execute(&g, &mut arena, &ext);
1349        let result = arena.slice(g.outputs[0]);
1350        assert_eq!(result, &[2.0, 3.0, 4.0, 8.0, 9.0, 10.0]);
1351    }
1352
1353    /// Test Softmax.
1354    #[test]
1355    fn execute_softmax() {
1356        use rlx_ir::infer::GraphExt;
1357        let mut g = Graph::new("softmax_test");
1358        let x = g.input("x", Shape::new(&[1, 4], DType::F32));
1359        let sm = g.sm(x, -1);
1360        g.set_outputs(vec![sm]);
1361
1362        let plan = memory::plan_memory(&g);
1363        let mut arena = Arena::from_plan(plan);
1364
1365        let data = vec![1.0, 2.0, 3.0, 4.0];
1366        let mut ext = ExternalBuffers {
1367            buffers: HashMap::new(),
1368        };
1369        for node in g.nodes() {
1370            if let Op::Input { .. } = &node.op {
1371                ext.buffers.insert(node.id, &data);
1372            }
1373        }
1374
1375        execute(&g, &mut arena, &ext);
1376        let result = arena.slice(g.outputs[0]);
1377        let sum: f32 = result.iter().sum();
1378        assert!(
1379            (sum - 1.0).abs() < 1e-5,
1380            "softmax should sum to 1, got {sum}"
1381        );
1382        // Values should be monotonically increasing
1383        assert!(result[0] < result[1]);
1384        assert!(result[1] < result[2]);
1385        assert!(result[2] < result[3]);
1386    }
1387
1388    /// Test RoPE (rotary position embedding).
1389    #[test]
1390    fn execute_rope() {
1391        use rlx_ir::infer::GraphExt;
1392        let head_dim = 4;
1393        let half = head_dim / 2;
1394        let seq = 2;
1395
1396        let mut g = Graph::new("rope_test");
1397        // x: [seq, head_dim], cos: [seq, half], sin: [seq, half]
1398        let x = g.input("x", Shape::new(&[seq, head_dim], DType::F32));
1399        let cos = g.param("cos", Shape::new(&[seq, half], DType::F32));
1400        let sin = g.param("sin", Shape::new(&[seq, half], DType::F32));
1401        let rotated = g.rope(x, cos, sin, head_dim);
1402        g.set_outputs(vec![rotated]);
1403
1404        let plan = memory::plan_memory(&g);
1405        let mut arena = Arena::from_plan(plan);
1406
1407        // x = [[1, 0, 0, 1], [1, 1, 0, 0]] (2 positions, head_dim=4)
1408        let x_data = vec![1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0f32];
1409        // cos = [[1, 0], [0, 1]], sin = [[0, 1], [1, 0]] (identity-ish rotation)
1410        let cos_data = vec![1.0, 0.0, 0.0, 1.0f32];
1411        let sin_data = vec![0.0, 1.0, 1.0, 0.0f32];
1412
1413        let mut ext = ExternalBuffers {
1414            buffers: HashMap::new(),
1415        };
1416        for node in g.nodes() {
1417            match &node.op {
1418                Op::Input { name } if name == "x" => {
1419                    ext.buffers.insert(node.id, &x_data);
1420                }
1421                Op::Param { name } if name == "cos" => {
1422                    ext.buffers.insert(node.id, &cos_data);
1423                }
1424                Op::Param { name } if name == "sin" => {
1425                    ext.buffers.insert(node.id, &sin_data);
1426                }
1427                _ => {}
1428            }
1429        }
1430
1431        execute(&g, &mut arena, &ext);
1432        let result = arena.slice(g.outputs[0]);
1433
1434        // Position 0: cos=[1,0], sin=[0,1]
1435        //   x1=1, x2=0 → x1*cos[0]-x2*sin[0] = 1*1-0*0 = 1
1436        //   x1=0, x2=1 → same half: x2*cos[0]+x1*sin[0] = 0*1+1*0 → wait
1437        // Actually: for i=0: x[0]=1, x[half+0]=0 → out[0]=1*1-0*0=1, out[2]=0*1+1*0=0
1438        //           for i=1: x[1]=0, x[half+1]=1 → out[1]=0*0-1*1=-1, out[3]=1*0+0*1=0
1439        assert!((result[0] - 1.0).abs() < 1e-5, "pos0[0]={}", result[0]);
1440        assert!((result[1] - -1.0).abs() < 1e-5, "pos0[1]={}", result[1]);
1441        assert!((result[2] - 0.0).abs() < 1e-5, "pos0[2]={}", result[2]);
1442        assert!((result[3] - 0.0).abs() < 1e-5, "pos0[3]={}", result[3]);
1443
1444        // Position 1: cos=[0,1], sin=[1,0]
1445        //   x=[1,1,0,0]: for i=0: 1*0-0*1=-0=0, out[half+0]=0*0+1*1=1
1446        //                 for i=1: 1*1-0*0=1, out[half+1]=0*1+1*0=0
1447        assert!((result[4] - 0.0).abs() < 1e-5, "pos1[0]={}", result[4]);
1448        assert!((result[5] - 1.0).abs() < 1e-5, "pos1[1]={}", result[5]);
1449        assert!((result[6] - 1.0).abs() < 1e-5, "pos1[2]={}", result[6]);
1450        assert!((result[7] - 0.0).abs() < 1e-5, "pos1[3]={}", result[7]);
1451    }
1452
1453    /// NaN localization epilogue: sqrt(-1) → NaN should be pinned to the
1454    /// Sqrt node as the *culprit* (finite input, non-finite output), with a
1455    /// fix hint. Exercises the exact gather/accessor path `execute` uses.
1456    #[test]
1457    fn epilogue_localizes_sqrt_of_negative() {
1458        use rlx_ir::op::Activation;
1459        let mut g = Graph::new("nan_localize");
1460        let x = g.input("x", Shape::new(&[2], DType::F32));
1461        let s = g.activation(Activation::Sqrt, x, Shape::new(&[2], DType::F32));
1462        g.set_outputs(vec![s]);
1463
1464        let plan = memory::plan_memory(&g);
1465        let mut arena = Arena::from_plan(plan);
1466        let data = vec![-1.0f32, 4.0]; // sqrt(-1) = NaN, sqrt(4) = 2
1467        let mut ext = ExternalBuffers {
1468            buffers: HashMap::new(),
1469        };
1470        for node in g.nodes() {
1471            if let Op::Input { .. } = &node.op {
1472                ext.buffers.insert(node.id, &data);
1473            }
1474        }
1475
1476        execute(&g, &mut arena, &ext);
1477
1478        // Output is now [NaN, 2.0]; the epilogue helper localizes it.
1479        let scanner = rlx_ir::numeric_check::DebugScanner::with_mode(
1480            rlx_ir::numeric_check::DebugMode::Warn,
1481            "cpu",
1482        );
1483        let report = super::scan_node_for_nans(&scanner, &g, g.outputs[0], &arena, &ext)
1484            .expect("sqrt(-1) must be localized as a NaN");
1485        assert_eq!(report.node, s);
1486        assert!(report.op.contains("Sqrt"), "op tag was {}", report.op);
1487        assert!(
1488            report.source_input.is_none(),
1489            "finite input → Sqrt is the culprit, not a propagator"
1490        );
1491        assert!(report.fix.is_some(), "culprit should carry a fix hint");
1492
1493        // Sanity: a clean node (the input) reports nothing.
1494        assert!(super::scan_node_for_nans(&scanner, &g, x, &arena, &ext).is_none());
1495    }
1496
1497    /// Test LayerNorm standalone.
1498    #[test]
1499    fn execute_layer_norm() {
1500        use rlx_ir::infer::GraphExt;
1501        let mut g = Graph::new("ln_test");
1502        let x = g.input("x", Shape::new(&[1, 4], DType::F32));
1503        let gamma = g.param("g", Shape::new(&[4], DType::F32));
1504        let beta = g.param("b", Shape::new(&[4], DType::F32));
1505        let ln = g.ln(x, gamma, beta, 1e-5);
1506        g.set_outputs(vec![ln]);
1507
1508        let plan = memory::plan_memory(&g);
1509        let mut arena = Arena::from_plan(plan);
1510
1511        let x_data = vec![1.0, 2.0, 3.0, 4.0];
1512        let g_data = vec![1.0, 1.0, 1.0, 1.0];
1513        let b_data = vec![0.0, 0.0, 0.0, 0.0];
1514
1515        let mut ext = ExternalBuffers {
1516            buffers: HashMap::new(),
1517        };
1518        for node in g.nodes() {
1519            match &node.op {
1520                Op::Input { name } if name == "x" => {
1521                    ext.buffers.insert(node.id, &x_data);
1522                }
1523                Op::Param { name } if name == "g" => {
1524                    ext.buffers.insert(node.id, &g_data);
1525                }
1526                Op::Param { name } if name == "b" => {
1527                    ext.buffers.insert(node.id, &b_data);
1528                }
1529                _ => {}
1530            }
1531        }
1532
1533        execute(&g, &mut arena, &ext);
1534        let result = arena.slice(g.outputs[0]);
1535        let sum: f32 = result.iter().sum();
1536        assert!(
1537            sum.abs() < 1e-3,
1538            "LN output should be zero-centered, sum={sum}"
1539        );
1540    }
1541}