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