Skip to main content

rlx_cpu/
executor.rs

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