Skip to main content

rlx_cpu/
thunk.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//! Thunks — pre-compiled kernel dispatch with zero per-call overhead.
17//!
18//! At compile time, the graph is lowered into a flat `Vec<Thunk>` where each
19//! thunk holds pre-computed arena offsets, dimensions, and kernel type.
20//! At runtime, the executor just iterates thunks and calls kernels directly.
21
22// Edition 2024: bodies of `unsafe fn` are safe by default; `sl`/`sl_mut` stay `unsafe fn`.
23#![allow(unsafe_op_in_unsafe_fn)]
24//! No match dispatch, no HashMap lookup, no dimension computation.
25
26use crate::arena::Arena;
27use crate::op_registry::CpuKernel;
28use rlx_ir::op::{Activation, BinaryOp, CmpOp, ReduceOp};
29use rlx_ir::{Graph, NodeId, Op, Shape};
30use std::collections::HashMap;
31use std::sync::Arc;
32
33/// Instrumentation: count of `FusedNomicLayer` thunks emitted by the full-layer
34/// fusion in this process. The fusion is otherwise invisible through the
35/// `Session` API, so out-of-tree model tests (rlx-models' Nomic parity test)
36/// load this to confirm the fusion actually fired (non-vacuous parity). Reset
37/// by storing 0 before a compile.
38pub static FUSED_NOMIC_LAYER_COUNT: std::sync::atomic::AtomicU64 =
39    std::sync::atomic::AtomicU64::new(0);
40
41/// A pre-compiled kernel call with all args resolved to arena offsets.
42#[derive(Clone)]
43pub enum Thunk {
44    /// Skip (Input/Param already in arena)
45    Nop,
46    /// Fused element-wise region: ONE pass applies the whole `chain` of scalar
47    /// steps per output element (bias-add → relu → … collapse into a single
48    /// kernel, no intermediate tensors). This is RLX's XLA-style auto-fusion
49    /// path on CPU — the GPU backends already run the same `ElementwiseRegion`
50    /// IR via a generated kernel; this is the scalar interpreter twin so any
51    /// fused chain runs without a hand-written per-op kernel.
52    /// `dst`/`input_offs` are byte offsets; `input_modulus[i]` is input i's
53    /// broadcast period (0 ⇒ read element `gid`; scalar inputs read element 0).
54    ElementwiseRegion {
55        dst: usize,
56        len: u32,
57        input_offs: Vec<usize>,
58        chain: Vec<rlx_ir::op::ChainStep>,
59        scalar_input_mask: u32,
60        input_modulus: [u32; 16],
61    },
62    /// C = A @ B (BLAS sgemm)
63    Sgemm {
64        a: usize,
65        b: usize,
66        c: usize,
67        m: u32,
68        k: u32,
69        n: u32,
70    },
71    /// `C[m,n] = op(A) @ op(B)` with optional transpose of each operand,
72    /// done via cblas trans flags (no materialized transpose). Emitted when
73    /// the thunk compiler folds a last-two-axis `Op::Transpose` feeding a
74    /// matmul (the dominant cost in matmul backprop). `a`/`b` are the
75    /// *pre-transpose* operands. lda/ldb are derived from the trans flags.
76    SgemmT {
77        a: usize,
78        b: usize,
79        c: usize,
80        m: u32,
81        k: u32,
82        n: u32,
83        ta: bool,
84        tb: bool,
85    },
86    /// Fused SGD-with-momentum parameter update, one pass per param.
87    /// Computes `v' = mom·v + g ; p' = p − lr·v'` reading `param`/`vel`/
88    /// `grad` and writing `p_out`/`v_out`. Emitted when the thunk compiler
89    /// folds the in-graph SGD chain `Sub(p, Mul(Add(Mul(v,mom),g), lr))`
90    /// (4 `BinaryFull` ops + 2 full-size constants per param) into a single
91    /// kernel — the dominant cost of the fully-fused training step. All
92    /// fields are arena byte offsets except the scalar hyperparameters.
93    SgdMomentum {
94        param: usize,
95        vel: usize,
96        grad: usize,
97        p_out: usize,
98        v_out: usize,
99        lr: f32,
100        mom: f32,
101        len: u32,
102    },
103    /// Complex (C64) dense GEMM `C = A·B`. Operands are interleaved
104    /// `[re, im]` f32; `a`/`b`/`c` are byte offsets, `m`/`k`/`n` are
105    /// complex-element matrix dims (`A` `[m,k]`, `B` `[k,n]`, `C` `[m,n]`).
106    CgemmC64 {
107        a: usize,
108        b: usize,
109        c: usize,
110        m: u32,
111        k: u32,
112        n: u32,
113    },
114    /// f64 dense solve `x = A⁻¹·b` via LAPACK dgesv.
115    /// `a`, `b`, `x` are byte-offsets into the arena. `n` is the matrix
116    /// dimension; `nrhs` is 1 for a vector RHS or >1 for multi-RHS.
117    /// The kernel materializes scratch copies of A and b internally
118    /// (LAPACK overwrites both with LU factors and solution).
119    DenseSolveF64 {
120        a: usize,
121        b: usize,
122        x: usize,
123        n: u32,
124        nrhs: u32,
125    },
126    /// f32 twin of `DenseSolveF64`. Calls LAPACK `sgesv` (or the
127    /// no-blas Rust fallback). Same arena byte-offset contract.
128    DenseSolveF32 {
129        a: usize,
130        b: usize,
131        x: usize,
132        n: u32,
133        nrhs: u32,
134    },
135    /// Batched f64 dense solve. `a`, `b`, `x` are byte-offsets to
136    /// the leading slice; `batch` is the number of independent
137    /// systems. Per slice the kernel calls `dgesv(A_i, b_i, n, nrhs)`
138    /// — LAPACK has no batched dgesv on Accelerate, so we loop.
139    BatchedDenseSolveF64 {
140        a: usize,
141        b: usize,
142        x: usize,
143        batch: u32,
144        n: u32,
145        nrhs: u32,
146    },
147    /// Batched f32 dense solve — loop of `sgesv` per batch slice.
148    BatchedDenseSolveF32 {
149        a: usize,
150        b: usize,
151        x: usize,
152        batch: u32,
153        n: u32,
154        nrhs: u32,
155    },
156    /// Batched f64 matmul. Both inputs and output have a leading
157    /// batch axis of size `batch`. Per-batch independent dgemm:
158    /// `C[i] = A[i] @ B[i]` for `i in 0..batch`. Used by VJP rules
159    /// that emit per-batch outer products (e.g., BatchedDenseSolve
160    /// VJP). The unbatched `Dgemm` thunk handles the rank-2 case.
161    BatchedDgemmF64 {
162        a: usize,
163        b: usize,
164        c: usize,
165        batch: u32,
166        m: u32,
167        k: u32,
168        n: u32,
169    },
170    /// Batched f32 matmul — same loop-per-batch shape as
171    /// `BatchedDgemmF64` but calling `sgemm`. Needed for attention
172    /// patterns where both operands carry a batch dim (e.g. q@k^T
173    /// and attn@v in decomposed self-attention). The 2-D `Sgemm`
174    /// flatten trick is wrong in that case because it treats `b` as
175    /// a single shared RHS across every batch.
176    BatchedSgemm {
177        a: usize,
178        b: usize,
179        c: usize,
180        batch: u32,
181        m: u32,
182        k: u32,
183        n: u32,
184    },
185    /// C = A @ B via Accelerate cblas_dgemm. Mirror of `Sgemm` at f64.
186    Dgemm {
187        a: usize,
188        b: usize,
189        c: usize,
190        m: u32,
191        k: u32,
192        n: u32,
193    },
194    /// f64 N-D index walk used for both `Op::Transpose` and `Op::Expand`.
195    /// `in_strides` carries 0s on broadcast axes (Expand) or permuted
196    /// strides (Transpose). Mirror of `Thunk::Transpose` at f64.
197    TransposeF64 {
198        src: usize,
199        dst: usize,
200        in_total: u32,
201        out_dims: Vec<u32>,
202        in_strides: Vec<u32>,
203    },
204    /// f64 element-wise activation. Single-input, single-output. The
205    /// kernel always reads from `src` and writes to `dst`, so it works
206    /// whether or not the planner aliased the two slots.
207    ActivationF64 {
208        src: usize,
209        dst: usize,
210        len: u32,
211        kind: Activation,
212    },
213    /// Element-wise complex squared-magnitude: `|z|² = re² + im²`.
214    /// Reads the C64 input at `src` as `2·len` f32 ([re,im] pairs),
215    /// writes `len` f32 to `dst`.
216    ComplexNormSqF32 {
217        src: usize,
218        dst: usize,
219        /// Logical element count (number of complex values).
220        len: u32,
221    },
222    /// Wirtinger backward for [`ComplexNormSqF32`]: `dz = g · z` as
223    /// C64. Reads `z` at `2·len` f32 + `g` at `len` f32; writes
224    /// `2·len` f32 to `dz`.
225    ComplexNormSqBackwardF32 {
226        z: usize,
227        g: usize,
228        dz: usize,
229        len: u32,
230    },
231    /// Element-wise C64 conjugate: writes `[re_i, -im_i]` per element.
232    /// Layout matches the rest of C64 here ([re,im] interleaved f32).
233    ConjugateC64 {
234        src: usize,
235        dst: usize,
236        len: u32,
237    },
238    /// C64 element-wise activation. Only kinds with well-defined
239    /// complex extensions are supported: Neg, Exp, Log, Sqrt.
240    /// Everything else (Sigmoid, Tanh, Relu, Abs, Sin/Cos/Tan/Atan,
241    /// Round, GeLU family) is rejected at lowering — those don't have
242    /// single natural complex definitions. `len` is the **complex
243    /// element count** (the f32 buffer holds `2·len` floats).
244    ActivationC64 {
245        src: usize,
246        dst: usize,
247        len: u32,
248        kind: Activation,
249    },
250    /// f64 contiguous reduction along a single axis range. Layout
251    /// `[outer, reduced, inner]` in memory; output is `[outer, inner]`.
252    /// Sum only for now (Mean composes via 1/N multiply post-pass).
253    ReduceSumF64 {
254        src: usize,
255        dst: usize,
256        outer: u32,
257        reduced: u32,
258        inner: u32,
259    },
260    /// f64 plain copy (Reshape / Cast at the same dtype). Mirrors `Copy`
261    /// but at 8 bytes per element.
262    CopyF64 {
263        src: usize,
264        dst: usize,
265        len: u32,
266    },
267    /// i64 element copy (Reshape/Cast on i64 tensors).
268    CopyI64 {
269        src: usize,
270        dst: usize,
271        len: u32,
272    },
273    /// Round f32 → i64 (ONNX Cast on duration scalar).
274    CastF32ToI64 {
275        src: usize,
276        dst: usize,
277        len: u32,
278    },
279    CastF32ToF64 {
280        src: usize,
281        dst: usize,
282        len: u32,
283    },
284    CastF32ToI32 {
285        src: usize,
286        dst: usize,
287        len: u32,
288    },
289    /// i64 → f32 (ONNX Cast on shape scalars, e.g. Albert head-dim).
290    CastI64ToF32 {
291        src: usize,
292        dst: usize,
293        len: u32,
294    },
295    /// bool → i32 (BERT attention mask grid).
296    CastBoolToI32 {
297        src: usize,
298        dst: usize,
299        len: u32,
300    },
301    CastBoolToF32 {
302        src: usize,
303        dst: usize,
304        len: u32,
305    },
306    /// i32 → f32 (BERT attention mask cast before subtract).
307    CastI32ToF32 {
308        src: usize,
309        dst: usize,
310        len: u32,
311    },
312    /// f64 element-wise binary with broadcast. `len`/`lhs_len`/`rhs_len`
313    /// are element counts; kernel does `out[i] = lhs[i % lhs_len] OP rhs[i % rhs_len]`.
314    /// Mirror of `BinaryFull` at 8 bytes per element.
315    BinaryFullF64 {
316        lhs: usize,
317        rhs: usize,
318        dst: usize,
319        len: u32,
320        lhs_len: u32,
321        rhs_len: u32,
322        op: BinaryOp,
323        /// Output shape dims (row-major). Empty in the fast path. See
324        /// `BinaryFull` doc for the broadcast convention.
325        out_dims_bcast: Vec<u32>,
326        bcast_lhs_strides: Vec<u32>,
327        bcast_rhs_strides: Vec<u32>,
328    },
329    /// f64 concat — byte-for-byte mirror of `Concat` but copies
330    /// 8 bytes per element. Element-counted offsets/strides match
331    /// the f32 variant; the executor scales by elem_size internally.
332    ConcatF64 {
333        dst: usize,
334        outer: u32,
335        inner: u32,
336        total_axis: u32,
337        inputs: Vec<(usize, u32, u32)>,
338    },
339    /// C64 element-wise binary with broadcast. Same `len` /
340    /// `lhs_len` / `rhs_len` semantics as `BinaryFull` but each
341    /// "element" is one complex value (8 bytes = `[re, im]` as two
342    /// f32s). The executor reads the underlying f32 buffer at
343    /// `2·len` floats and walks element pairs. Supports Add / Sub /
344    /// Mul / Div; Max / Min / Pow have no single natural complex
345    /// definition and panic at lowering.
346    BinaryFullC64 {
347        lhs: usize,
348        rhs: usize,
349        dst: usize,
350        /// Complex element count (NOT f32 count). f32 buffer length
351        /// is `2·len`.
352        len: u32,
353        lhs_len: u32,
354        rhs_len: u32,
355        op: BinaryOp,
356        out_dims_bcast: Vec<u32>,
357        bcast_lhs_strides: Vec<u32>,
358        bcast_rhs_strides: Vec<u32>,
359    },
360    /// Bounded scan. Holds a recursively-compiled body schedule + a
361    /// pre-initialized body arena snapshot (constants filled). Each
362    /// outer execution clones the snapshot, copies the carry-in slot
363    /// from the outer arena, runs the body schedule `length` times,
364    /// then writes the final carry to the outer arena.
365    ///
366    /// Single-carry MVP — body has exactly one Input and one output,
367    /// both same shape and dtype.
368    Scan {
369        body: Arc<ThunkSchedule>,
370        body_init: Arc<Vec<u8>>, // pristine body arena bytes
371        body_input_off: usize,   // byte offset of the body's carry-Input slot
372        body_output_off: usize,  // byte offset of the body's output slot
373        outer_init_off: usize,   // outer-arena offset of the initial carry
374        outer_final_off: usize,  // outer-arena offset of the final carry / trajectory base
375        length: u32,
376        carry_bytes: u32, // carry size in bytes
377        /// When true, write each step's carry to the outer arena at
378        /// offset `outer_final_off + t * carry_bytes`, producing a
379        /// `[length, *carry]` stacked trajectory. When false, only the
380        /// final carry lands at `outer_final_off`.
381        save_trajectory: bool,
382        /// Per-step `xs` inputs. For each: (body_x_input_off,
383        /// outer_xs_base_off, per_step_bytes). Per iteration `t`, the
384        /// executor copies `outer_xs_base_off + t * per_step_bytes`
385        /// into `body_x_input_off`. Empty when the scan has no xs.
386        xs_inputs: Arc<Vec<(usize, usize, u32)>>,
387        /// Broadcast inputs — values constant across iterations. For
388        /// each: (body_bcast_input_off, outer_bcast_off, total_bytes).
389        /// Filled into `body_buf` ONCE before the scan loop starts
390        /// (xs in contrast are re-filled every iteration). Empty when
391        /// the scan has no bcasts.
392        bcast_inputs: Arc<Vec<(usize, usize, u32)>>,
393        /// Number of trajectory checkpoints (when `save_trajectory`).
394        /// `0` or `length` ⇒ save every iteration. Otherwise save only
395        /// `K` rows at indices `floor((k+1) * length / K) - 1` for
396        /// `k in 0..K`. Last index is always `length-1` so the final
397        /// carry is always cached.
398        num_checkpoints: u32,
399    },
400
401    /// Reverse-mode AD companion to `Thunk::Scan`. Walks `t = length-1
402    /// .. 0`, threading `dcarry` through the body's VJP. Per iteration:
403    /// writes `carry_t` (from outer init or trajectory), each `xs_i[t]`
404    /// slice, and the current `dcarry` into the body_vjp's Input
405    /// slots, runs body_vjp, reads new `dcarry` from its single output.
406    /// f64 carry only — the upstream-accumulation step in trajectory
407    /// mode does an element-wise f64 add.
408    ScanBackward {
409        body_vjp: Arc<ThunkSchedule>,
410        body_init: Arc<Vec<u8>>,
411        body_carry_in_off: usize, // body_vjp's mirrored body-carry-input slot
412        body_x_offs: Arc<Vec<usize>>, // body_vjp's mirrored x_t_i Input slots, in xs order
413        body_d_output_off: usize, // body_vjp's "d_output" Input slot
414        body_dcarry_out_off: usize, // body_vjp's gradient output
415        outer_init_off: usize,    // original init carry
416        outer_traj_off: usize,    // [length-or-K, *carry] trajectory base
417        outer_upstream_off: usize, // upstream gradient (carry shape, or [length, *carry])
418        /// Per-xs entries: (outer_xs_base_off, per_step_bytes). Read
419        /// `xs_i[t]` from `outer_xs_base_off + t * per_step_bytes`.
420        outer_xs_offs: Arc<Vec<(usize, u32)>>,
421        outer_dinit_off: usize, // output: dinit
422        length: u32,
423        carry_bytes: u32,
424        /// Bytes per element in the carry tensor: 4 for f32, 8 for f64.
425        /// Used to dispatch the trajectory-mode upstream accumulation
426        /// kernel (the dcarry += upstream\[t\] add must use the right
427        /// floating-point type — a hard-coded f64 add silently does
428        /// nothing for an f32 carry whose `cb` isn't divisible by 8).
429        carry_elem_size: u32,
430        save_trajectory: bool, // true → upstream is per-step; false → just final
431        /// Recursive checkpointing config. `0` or `length` ⇒ full
432        /// trajectory cached, no recompute (existing behavior).
433        /// `0 < K < length` ⇒ trajectory has only K rows; the executor
434        /// recomputes intermediate carries via `forward_body` between
435        /// checkpoints. Memory: O(K · carry_bytes); time: O(length).
436        num_checkpoints: u32,
437        /// Forward body schedule (same compiled body as the forward
438        /// Op::Scan), used for recompute when `num_checkpoints` is
439        /// active. `None` for the All strategy.
440        forward_body: Option<Arc<ThunkSchedule>>,
441        /// Pristine forward body arena bytes (constants filled).
442        forward_body_init: Option<Arc<Vec<u8>>>,
443        /// Forward body's carry-Input and output slot offsets — needed
444        /// to seed/read the body during recompute.
445        forward_body_carry_in_off: usize,
446        forward_body_output_off: usize,
447        /// Forward body's per-step xs Input slots (one per outer xs).
448        /// Same indexing convention as `body_x_offs`.
449        forward_body_x_offs: Arc<Vec<usize>>,
450    },
451
452    /// Companion to `ScanBackward` that materializes one stacked
453    /// `dxs_i`. Same backward loop; per iteration, after running
454    /// body_vjp, copies its `body_dxs_out_off` slot into the outer
455    /// arena at `outer_dxs_off + t * per_step_bytes`. dcarry threading
456    /// is identical — we still need it for the body_vjp recurrence
457    /// even though we don't write it back to the outer arena.
458    ScanBackwardXs {
459        body_vjp: Arc<ThunkSchedule>,
460        body_init: Arc<Vec<u8>>,
461        body_carry_in_off: usize,
462        body_x_offs: Arc<Vec<usize>>,
463        body_d_output_off: usize,
464        body_dcarry_out_off: usize,
465        body_dxs_out_off: usize, // the body_vjp output we extract per step
466        outer_init_off: usize,
467        outer_traj_off: usize,
468        outer_upstream_off: usize,
469        outer_xs_offs: Arc<Vec<(usize, u32)>>,
470        outer_dxs_off: usize, // base of the stacked [length, *per_step] output
471        length: u32,
472        carry_bytes: u32,
473        /// Same role as `Thunk::ScanBackward::carry_elem_size`.
474        carry_elem_size: u32,
475        per_step_bytes: u32, // bytes per row of the dxs output
476        save_trajectory: bool,
477        /// Recursive checkpointing config. Same semantics as
478        /// `Thunk::ScanBackward::num_checkpoints` — `0` or `length`
479        /// means "save every step's carry"; `0 < K < length` means
480        /// the trajectory has only K rows and the executor recomputes
481        /// intermediate carries via `forward_body` (which must be
482        /// `Some`). Implemented via segment-cached recompute,
483        /// mirroring the `ScanBackward` path.
484        num_checkpoints: u32,
485        forward_body: Option<Arc<ThunkSchedule>>,
486        forward_body_init: Option<Arc<Vec<u8>>>,
487        forward_body_carry_in_off: usize,
488        forward_body_output_off: usize,
489        forward_body_x_offs: Arc<Vec<usize>>,
490    },
491    /// User-defined sub-graph (`Op::CustomFn`) — runs `fwd_body` once.
492    /// Per execution: clone `body_init`, copy each primal input from the
493    /// outer arena into its body Input slot, run the body schedule,
494    /// copy the body's single output back to the outer arena.
495    CustomFn {
496        body: Arc<ThunkSchedule>,
497        body_init: Arc<Vec<u8>>,
498        /// Per primal input: (body_input_off, outer_input_off, bytes).
499        inputs: Arc<Vec<(usize, usize, u32)>>,
500        body_output_off: usize,
501        outer_output_off: usize,
502        out_bytes: u32,
503    },
504    /// C = A @ B; C += bias; C = act(C)
505    FusedMmBiasAct {
506        a: usize,
507        w: usize,
508        bias: usize,
509        c: usize,
510        m: u32,
511        k: u32,
512        n: u32,
513        act: Option<Activation>,
514    },
515    /// out = LN(x + residual + bias, gamma, beta)
516    FusedResidualLN {
517        x: usize,
518        res: usize,
519        bias: usize,
520        g: usize,
521        b: usize,
522        out: usize,
523        rows: u32,
524        h: u32,
525        eps: f32,
526        has_bias: bool,
527    },
528    /// out = RmsNorm(x + residual + bias, gamma, beta)
529    FusedResidualRmsNorm {
530        x: usize,
531        res: usize,
532        bias: usize,
533        g: usize,
534        b: usize,
535        out: usize,
536        rows: u32,
537        h: u32,
538        eps: f32,
539        has_bias: bool,
540    },
541    /// out = bias_add(data, bias, m, n) for Binary::Add with broadcast
542    BiasAdd {
543        src: usize,
544        bias: usize,
545        dst: usize,
546        m: u32,
547        n: u32,
548    },
549    /// Element-wise binary op with NumPy-style broadcast.
550    ///
551    /// Fast path (`lhs_len == rhs_len == len`): plain element-wise loop,
552    /// SIMD-vectorized on aarch64 for `Add`/`Mul`. `bcast_*` fields
553    /// are unused.
554    ///
555    /// Broadcast path: uses `out_dims_bcast` + `bcast_lhs_strides` +
556    /// `bcast_rhs_strides` to compute per-cell indices into each
557    /// operand. The strides are precomputed at thunk-construction
558    /// time from the operands' true shapes (with stride 0 on any axis
559    /// where the operand has size 1). This is the only correct way
560    /// to handle bidirectional broadcasts like `[N, 1] op [1, S]
561    /// → [N, S]`, which simple `i % lhs_len` modulo indexing maps to
562    /// wrong cells.
563    BinaryFull {
564        lhs: usize,
565        rhs: usize,
566        dst: usize,
567        len: u32,
568        lhs_len: u32,
569        rhs_len: u32,
570        op: BinaryOp,
571        /// Output shape dims (row-major). Empty in the fast path.
572        out_dims_bcast: Vec<u32>,
573        /// Per-dim stride into `lhs` (0 where lhs broadcasts).
574        bcast_lhs_strides: Vec<u32>,
575        /// Per-dim stride into `rhs`.
576        bcast_rhs_strides: Vec<u32>,
577        /// Element size (4 = F32, 8 = I64).
578        elem_bytes: u8,
579    },
580    /// Activation in-place
581    ActivationInPlace {
582        data: usize,
583        len: u32,
584        act: Activation,
585    },
586    /// Gather axis=0: table\[idx\] → out
587    Gather {
588        table: usize,
589        table_len: u32,
590        idx: usize,
591        dst: usize,
592        num_idx: u32,
593        trailing: u32,
594        /// 1 when the index tensor is i64 (ONNX Gather indices).
595        idx_i64: u8,
596        /// Element size of table/output (4 = f32, 8 = i64).
597        table_bytes: u8,
598    },
599    /// Narrow: copy slice (`elem_bytes` = source element size: 4 for f32, 8 for f64).
600    Narrow {
601        src: usize,
602        dst: usize,
603        outer: u32,
604        src_stride: u32,
605        dst_stride: u32,
606        inner: u32,
607        elem_bytes: u8,
608    },
609    /// Reverse (flip) element order along the axes in `rev_mask`. `dims` is the
610    /// row-major input shape; output shape is identical. Dtype-agnostic
611    /// (byte-copy of `elem_bytes` per element).
612    Reverse {
613        src: usize,
614        dst: usize,
615        dims: Vec<u32>,
616        rev_mask: Vec<bool>,
617        elem_bytes: u8,
618    },
619    /// Copy (reshape, expand)
620    Copy {
621        src: usize,
622        dst: usize,
623        len: u32,
624    },
625    /// LayerNorm standalone
626    LayerNorm {
627        src: usize,
628        g: usize,
629        b: usize,
630        dst: usize,
631        rows: u32,
632        h: u32,
633        eps: f32,
634    },
635    /// GroupNorm on NCHW `[N,C,H,W]`.
636    GroupNorm {
637        src: usize,
638        g: usize,
639        b: usize,
640        dst: usize,
641        n: u32,
642        c: u32,
643        h: u32,
644        w: u32,
645        num_groups: u32,
646        eps: f32,
647    },
648    /// BatchNorm inference: frozen mean/var, feature axis last.
649    BatchNormInference {
650        src: usize,
651        g: usize,
652        b: usize,
653        mean: usize,
654        var: usize,
655        dst: usize,
656        count: u32,
657        channels: u32,
658        eps: f32,
659    },
660    BatchNormInferenceBackwardInput {
661        x: usize,
662        gamma: usize,
663        mean: usize,
664        var: usize,
665        dy: usize,
666        dx: usize,
667        count: u32,
668        channels: u32,
669        eps: f32,
670    },
671    BatchNormInferenceBackwardGamma {
672        x: usize,
673        mean: usize,
674        var: usize,
675        dy: usize,
676        dgamma: usize,
677        count: u32,
678        channels: u32,
679        eps: f32,
680    },
681    BatchNormInferenceBackwardBeta {
682        dy: usize,
683        dbeta: usize,
684        count: u32,
685        channels: u32,
686    },
687    /// LayerNorm2d on NCHW (SAM / candle semantics).
688    LayerNorm2d {
689        src: usize,
690        g: usize,
691        b: usize,
692        dst: usize,
693        n: u32,
694        c: u32,
695        h: u32,
696        w: u32,
697        eps: f32,
698    },
699    /// ConvTranspose2d on NCHW.
700    ConvTranspose2d {
701        src: usize,
702        weight: usize,
703        dst: usize,
704        n: u32,
705        c_in: u32,
706        h: u32,
707        w_in: u32,
708        c_out: u32,
709        h_out: u32,
710        w_out: u32,
711        kh: u32,
712        kw: u32,
713        sh: u32,
714        sw: u32,
715        ph: u32,
716        pw: u32,
717        dh: u32,
718        dw: u32,
719        groups: u32,
720    },
721    /// Nearest 2× upsample on NCHW (per-batch slice).
722    ResizeNearest2x {
723        src: usize,
724        dst: usize,
725        n: u32,
726        c: u32,
727        h: u32,
728        w: u32,
729    },
730    /// SAM2 axial 2-D RoPE on `[batch, seq, num_heads * head_dim]`.
731    AxialRope2d {
732        src: usize,
733        dst: usize,
734        batch: u32,
735        seq: u32,
736        hidden: u32,
737        end_x: u32,
738        end_y: u32,
739        head_dim: u32,
740        num_heads: u32,
741        theta: f32,
742        repeat_factor: u32,
743    },
744    /// RMSNorm: out = (x / sqrt(mean(x^2) + eps)) * gamma + beta. No mean
745    /// subtraction, hence cheaper than LayerNorm. Used by Llama-class models.
746    RmsNorm {
747        src: usize,
748        g: usize,
749        b: usize,
750        dst: usize,
751        rows: u32,
752        h: u32,
753        eps: f32,
754    },
755    /// Softmax
756    Softmax {
757        data: usize,
758        rows: u32,
759        cols: u32,
760    },
761    /// Inclusive (or exclusive) cumulative sum along the last axis
762    /// (callers pre-flatten higher-dim cumsums via reshape views).
763    Cumsum {
764        src: usize,
765        dst: usize,
766        rows: u32,
767        cols: u32,
768        exclusive: bool,
769    },
770    /// Mamba-style selective scan (plan #15).
771    /// Inputs: x, delta \[b,s,h\], a \[h,n\], b \[b,s,n\], c \[b,s,n\].
772    /// Output: y \[b,s,h\]. State h carries through the seq.
773    SelectiveScan {
774        x: usize,
775        delta: usize,
776        a: usize,
777        b: usize,
778        c: usize,
779        dst: usize,
780        batch: u32,
781        seq: u32,
782        hidden: u32,
783        state_size: u32,
784    },
785
786    /// Gated DeltaNet linear-attention scan (Qwen3.5/3.6 trunk).
787    /// Inputs: q, k, v `[b, s, h, n]`; g, beta `[b, s, h]`. Output:
788    /// `[b, s, h, n]`. See `Op::GatedDeltaNet` for math.
789    GatedDeltaNet {
790        q: usize,
791        k: usize,
792        v: usize,
793        g: usize,
794        beta: usize,
795        /// When non-zero, load initial `[b, h, n, n]` state and write
796        /// the final state back in place after the scan.
797        state: usize,
798        dst: usize,
799        batch: u32,
800        seq: u32,
801        heads: u32,
802        state_size: u32,
803    },
804
805    /// Multi-layer (optionally bidirectional, optional carry) LSTM with
806    /// packed weights. See `Op::Lstm`. `h0`/`c0` are valid only when
807    /// `carry`; `dst` is `[b, s, D*h]`.
808    Lstm {
809        x: usize,
810        w_ih: usize,
811        w_hh: usize,
812        bias: usize,
813        h0: usize,
814        c0: usize,
815        dst: usize,
816        batch: u32,
817        seq: u32,
818        input_size: u32,
819        hidden: u32,
820        num_layers: u32,
821        bidirectional: bool,
822        carry: bool,
823    },
824    /// GRU (gate order r, z, n) with separate `b_ih`/`b_hh`. See `Op::Gru`.
825    Gru {
826        x: usize,
827        w_ih: usize,
828        w_hh: usize,
829        b_ih: usize,
830        b_hh: usize,
831        h0: usize,
832        dst: usize,
833        batch: u32,
834        seq: u32,
835        input_size: u32,
836        hidden: u32,
837        num_layers: u32,
838        bidirectional: bool,
839        carry: bool,
840    },
841    /// Elman RNN (`tanh`/`relu`), single merged bias. See `Op::Rnn`.
842    Rnn {
843        x: usize,
844        w_ih: usize,
845        w_hh: usize,
846        bias: usize,
847        h0: usize,
848        dst: usize,
849        batch: u32,
850        seq: u32,
851        input_size: u32,
852        hidden: u32,
853        num_layers: u32,
854        bidirectional: bool,
855        carry: bool,
856        relu: bool,
857    },
858    /// Mamba-2 / SSD scalar-decay SSM scan. See `Op::Mamba2`.
859    Mamba2 {
860        x: usize,
861        dt: usize,
862        a: usize,
863        b: usize,
864        c: usize,
865        dst: usize,
866        batch: u32,
867        seq: u32,
868        heads: u32,
869        head_dim: u32,
870        state_size: u32,
871    },
872
873    /// 1×1 conv fast path (plan #26). The general Conv2D thunk
874    /// runs the textbook 7-deep loop; a 1×1 stride-1 padding-0
875    /// groups-1 conv is mathematically a per-batch matmul, and
876    /// dispatching it through BLAS is 3-10× faster than the
877    /// scalar nest. Common case: ViT patch-projection follow-on,
878    /// transformer "expert" reductions in some MoE designs.
879    ///
880    /// Per batch: weight `[c_out, c_in]` × input `[c_in, h*w]`
881    ///         = output `[c_out, h*w]`.
882    Conv2D1x1 {
883        src: usize,
884        weight: usize,
885        dst: usize,
886        n: u32,
887        c_in: u32,
888        c_out: u32,
889        hw: u32,
890    },
891
892    /// Fused dequant + matmul (plan #5). Today supports
893    /// `QuantScheme::Int8Block` (symmetric); other schemes panic
894    /// at lowering time with a clear message until kernels are added.
895    DequantMatMul {
896        x: usize,
897        w_q: usize,   // packed i8 bytes for Int8 schemes
898        scale: usize, // [k/block, n] f32 scale
899        zp: usize,    // [k/block, n] f32 zero-point (0 for sym)
900        dst: usize,
901        m: u32,
902        k: u32,
903        n: u32,
904        block_size: u32,
905        is_asymmetric: bool,
906    },
907
908    /// GGUF-format dequant + matmul. Weight is a packed byte tensor
909    /// in one of the K-quant super-block layouts (Q4_K, Q5_K, Q6_K,
910    /// Q8_K). Scales / mins live inside the packed bytes — no
911    /// side-channel scale tensor.
912    ///
913    /// Today this is a "dequant-to-scratch then sgemm" kernel — it
914    /// keeps the *arena* memory footprint down (weights stay packed)
915    /// but the dequant itself happens per matmul. A future fully
916    /// fused tile-streaming kernel would close the compute gap.
917    DequantMatMulGguf {
918        x: usize,   // f32 activations [m, k]
919        w_q: usize, // packed weight bytes (k*n elements packed)
920        dst: usize, // f32 output [m, n]
921        m: u32,
922        k: u32,
923        n: u32,
924        scheme: rlx_ir::quant::QuantScheme,
925    },
926
927    /// Int4 block dequant + matmul (packed nibbles, side scale/zp).
928    DequantMatMulInt4 {
929        x: usize,
930        w_q: usize,
931        scale: usize,
932        zp: usize,
933        dst: usize,
934        m: u32,
935        k: u32,
936        n: u32,
937        block_size: u32,
938        is_asymmetric: bool,
939    },
940
941    /// FP8 dequant + matmul (per-tensor or per-column scale).
942    DequantMatMulFp8 {
943        x: usize,
944        w_q: usize,
945        scale: usize,
946        dst: usize,
947        m: u32,
948        k: u32,
949        n: u32,
950        e5m2: bool,
951    },
952
953    /// NVFP4 (E2M1) block dequant + matmul — 16-wide groups, FP8 scales.
954    DequantMatMulNvfp4 {
955        x: usize,
956        w_q: usize,
957        scale: usize,
958        global_scale: usize,
959        dst: usize,
960        m: u32,
961        k: u32,
962        n: u32,
963    },
964
965    /// Native low-precision scaled GEMM (FP8/FP6/FP4) — CPU reference oracle.
966    /// TN layout: lhs [m,k], rhs [n,k] codes, out [m,n] f32.
967    ScaledMatMul {
968        lhs: usize,
969        rhs: usize,
970        lhs_scale: usize,
971        rhs_scale: usize,
972        bias: usize, // valid iff has_bias
973        dst: usize,
974        m: u32,
975        k: u32,
976        n: u32,
977        lhs_fmt: rlx_ir::ScaledFormat,
978        rhs_fmt: rlx_ir::ScaledFormat,
979        layout: rlx_ir::ScaleLayout,
980        has_bias: bool,
981    },
982
983    /// Quantize f32 → packed low-precision codes (reads the scale tensor).
984    ScaledQuantize {
985        x: usize,
986        scale: usize,
987        dst: usize,
988        rows: u32,
989        cols: u32,
990        fmt: rlx_ir::ScaledFormat,
991        layout: rlx_ir::ScaleLayout,
992    },
993
994    /// Compute the per-tensor / per-block scale tensor for `Op::ScaledQuantize`.
995    ScaledQuantScale {
996        x: usize,
997        dst: usize,
998        rows: u32,
999        cols: u32,
1000        fmt: rlx_ir::ScaledFormat,
1001        layout: rlx_ir::ScaleLayout,
1002    },
1003
1004    /// Reconstruct f32 from packed codes (`Op::ScaledDequantize`).
1005    ScaledDequantize {
1006        codes: usize,
1007        scale: usize,
1008        dst: usize,
1009        rows: u32,
1010        cols: u32,
1011        fmt: rlx_ir::ScaledFormat,
1012        layout: rlx_ir::ScaleLayout,
1013    },
1014
1015    /// Fused LoRA matmul (plan #9): out = x·W + scale * (x·A)·B.
1016    /// `r` is the LoRA rank (typically 4-64) — the rank-r
1017    /// intermediate `x·A` lives in scratch, never on the arena.
1018    LoraMatMul {
1019        x: usize,
1020        w: usize,
1021        a: usize,
1022        b: usize,
1023        dst: usize,
1024        m: u32,
1025        k: u32,
1026        n: u32,
1027        r: u32,
1028        scale: f32,
1029    },
1030    /// Fused sample: logits [batch, vocab] → token ids \[batch\].
1031    /// See Op::Sample. Output values are f32-encoded usize indices
1032    /// (matches the rest of the IR's "ids as f32" convention).
1033    Sample {
1034        logits: usize,
1035        dst: usize,
1036        batch: u32,
1037        vocab: u32,
1038        top_k: u32,       // 0 = disabled
1039        top_p: f32,       // 1.0 = disabled
1040        temperature: f32, // 1.0 = neutral
1041        seed: u64,
1042    },
1043    /// ONNX `RandomNormalLike` fill.
1044    RngNormal {
1045        dst: usize,
1046        len: u32,
1047        mean: f32,
1048        scale: f32,
1049        key: u64,
1050        op_seed: Option<f32>,
1051    },
1052    /// ONNX `RandomUniformLike` fill.
1053    RngUniform {
1054        dst: usize,
1055        len: u32,
1056        low: f32,
1057        high: f32,
1058        key: u64,
1059        op_seed: Option<f32>,
1060    },
1061    /// Attention SDPA. `mask` is the offset of the optional mask tensor
1062    /// (only meaningful when `mask_kind == MaskKind::Custom`); other
1063    /// kinds synthesize the mask in-kernel.
1064    ///
1065    /// Q/K/V each carry a `_row_stride` (elements per source row).
1066    /// Defaults to `heads * head_dim` — matches the standalone
1067    /// "Q/K/V are their own contiguous buffers" case. The Narrow→
1068    /// Attention fusion below rewrites these to the parent QKV stride
1069    /// (typically `3 * heads * head_dim`) so the kernel reads QKV
1070    /// directly without materializing the per-head buffers (plan #46).
1071    Attention {
1072        q: usize,
1073        k: usize,
1074        v: usize,
1075        mask: usize,
1076        out: usize,
1077        batch: u32,
1078        /// Query sequence length.
1079        seq: u32,
1080        /// Key/value sequence length. Differs from `seq` during cached decode.
1081        kv_seq: u32,
1082        heads: u32,
1083        head_dim: u32,
1084        mask_kind: rlx_ir::op::MaskKind,
1085        /// Softmax score scale (`Op::Attention::score_scale`). `head_dim^-0.5`
1086        /// when the op left it unset. Must be honored — Gemma 4 uses `1.0`
1087        /// (Q/K are per-head RMS-normed, so no `1/sqrt(d)` pre-scale).
1088        scale: f32,
1089        q_row_stride: u32,
1090        k_row_stride: u32,
1091        v_row_stride: u32,
1092        /// Memory layout flag. `false` (the historical default) →
1093        /// `[B, S, H, D]` row-major: per-head offset is
1094        /// `bi*S*H*D + si*H*D + hi*D`. `true` → `[B, H, S, D]`
1095        /// (head-major), matching the convention used by rlx-cuda /
1096        /// rlx-rocm / rlx-tpu: per-head offset is
1097        /// `bi*H*S*D + hi*S*D + si*D`. Detected at lowering time
1098        /// from the input shape vs `num_heads` / `head_dim`.
1099        bhsd: bool,
1100    },
1101    /// [`Op::AttentionBackward`] — emits dQ, dK, or dV (see `wrt`).
1102    AttentionBackward {
1103        q: usize,
1104        k: usize,
1105        v: usize,
1106        dy: usize,
1107        mask: usize,
1108        out: usize,
1109        batch: u32,
1110        seq: u32,
1111        kv_seq: u32,
1112        heads: u32,
1113        head_dim: u32,
1114        mask_kind: rlx_ir::op::MaskKind,
1115        wrt: rlx_ir::op::AttentionBwdWrt,
1116        bhsd: bool,
1117    },
1118    /// RoPE (rotary position embeddings).
1119    /// `src_row_stride` is elements per source row (defaults to `hidden`
1120    /// for the standalone case; set to `qkv_axis * inner` when the
1121    /// thunk fusion pass below rewires Rope to read directly from the
1122    /// fused QKV buffer — plan #45).
1123    Rope {
1124        src: usize,
1125        cos: usize,
1126        sin: usize,
1127        dst: usize,
1128        batch: u32,
1129        seq: u32,
1130        hidden: u32,
1131        head_dim: u32,
1132        n_rot: u32,
1133        cos_len: u32,
1134        src_row_stride: u32,
1135        /// `true` = GPT-J / llama.cpp-NORM interleaved pairs `(2i, 2i+1)`;
1136        /// `false` = HF / NeoX rotate-half pairs `(i, i+n_rot/2)`.
1137        interleaved: bool,
1138    },
1139    /// Fused attention block: QKV proj → split → \[RoPE\] → SDPA → output proj.
1140    /// All intermediates stay in L1 cache. Zero arena writes between ops.
1141    FusedAttnBlock {
1142        hidden: usize,
1143        qkv_w: usize,
1144        out_w: usize,
1145        mask: usize,
1146        /// How to mask attention scores. `Custom` reads the per-key `mask`
1147        /// buffer (BERT-style padding); `Causal` / `SlidingWindow` are
1148        /// synthesized in-kernel with no buffer; `None` applies no mask.
1149        /// Mirrors [`Thunk::Attention`]'s `mask_kind` — the attention-block
1150        /// fusion MUST carry it through, otherwise a causal decoder silently
1151        /// attends to future tokens (the fused kernel would only ever apply
1152        /// the per-key padding mask).
1153        mask_kind: rlx_ir::op::MaskKind,
1154        out: usize,
1155        qkv_b: usize,
1156        out_b: usize, // 0 = no bias
1157        cos: usize,
1158        sin: usize,
1159        cos_len: u32, // 0 = no RoPE
1160        batch: u32,
1161        seq: u32,
1162        hs: u32,
1163        nh: u32,
1164        dh: u32,
1165        has_bias: bool,
1166        has_rope: bool,
1167        /// RoPE pairing: `true` = GPT-J interleaved `(2i,2i+1)`, `false` = NeoX
1168        /// rotate-half `(i,i+d/2)`. Captured from the fused `Op::Rope` so the
1169        /// inline rope matches the standalone kernel (a GptJ model must not be
1170        /// silently rotated NeoX-style by the fused path).
1171        interleaved: bool,
1172    },
1173    /// Fused ENTIRE transformer layer: attention + residual + LN + FFN + residual + LN.
1174    /// Combines ~10 thunks into 1. All intermediates on stack. Zero arena traffic.
1175    FusedBertLayer {
1176        // attention
1177        hidden: usize,
1178        qkv_w: usize,
1179        qkv_b: usize,
1180        out_w: usize,
1181        out_b: usize,
1182        mask: usize,
1183        // LN1
1184        ln1_g: usize,
1185        ln1_b: usize,
1186        eps1: f32,
1187        // FFN (GELU)
1188        fc1_w: usize,
1189        fc1_b: usize,
1190        fc2_w: usize,
1191        fc2_b: usize,
1192        // LN2
1193        ln2_g: usize,
1194        ln2_b: usize,
1195        eps2: f32,
1196        // output
1197        out: usize,
1198        // dims
1199        batch: u32,
1200        seq: u32,
1201        hs: u32,
1202        nh: u32,
1203        dh: u32,
1204        int_dim: u32,
1205    },
1206    /// Fused Nomic transformer layer: attention+RoPE + residual + LN + SwiGLU FFN + residual + LN.
1207    FusedNomicLayer {
1208        hidden: usize,
1209        qkv_w: usize,
1210        out_w: usize,
1211        mask: usize,
1212        cos: usize,
1213        sin: usize,
1214        cos_len: u32,
1215        ln1_g: usize,
1216        ln1_b: usize,
1217        eps1: f32,
1218        fc11_w: usize,
1219        fc12_w: usize,
1220        fc2_w: usize,
1221        ln2_g: usize,
1222        ln2_b: usize,
1223        eps2: f32,
1224        out: usize,
1225        batch: u32,
1226        seq: u32,
1227        hs: u32,
1228        nh: u32,
1229        dh: u32,
1230        int_dim: u32,
1231        /// RoPE pairing, threaded from the consumed `FusedAttnBlock`
1232        /// (`true` = GPT-J interleaved, `false` = NeoX rotate-half).
1233        interleaved: bool,
1234    },
1235    /// Fused SwiGLU: out\[r,i\] = x\[r,i\] * silu(x[r, n_half+i]).
1236    /// Input: [outer, 2*n_half] — concatenated up||gate per row.
1237    /// Output: [outer, n_half].
1238    FusedSwiGLU {
1239        src: usize,
1240        dst: usize,
1241        n_half: u32,
1242        total: u32,
1243        gate_first: bool,
1244    },
1245    /// Concat along an axis: output[outer, axis, inner] = inputs concatenated.
1246    /// Each entry of `inputs` is (src_offset, axis_len_for_that_input) in u32
1247    /// elements. `outer`, `inner`, and `total_axis_len` are pre-computed
1248    /// at compile time to avoid per-run shape work.
1249    Concat {
1250        dst: usize,
1251        outer: u32,
1252        inner: u32,
1253        total_axis: u32,
1254        /// `(src_offset, axis_extent, input_numel)` — `input_numel` enables
1255        /// outer-dim broadcast when rank-deficient inputs are concatenated.
1256        inputs: Vec<(usize, u32, u32)>,
1257    },
1258    /// Element-wise comparison: out = (lhs CMP rhs) ? 1 : 0 (Bool u8 or F32 0/1).
1259    Compare {
1260        lhs: usize,
1261        rhs: usize,
1262        dst: usize,
1263        len: u32,
1264        op: CmpOp,
1265        /// Nonzero when lhs/rhs are i64 (mask/range ops).
1266        inputs_i64: u8,
1267        /// Input element size (1 = Bool, 4 = F32, 8 = I64).
1268        inputs_elem_bytes: u8,
1269        /// Output element size (1 = Bool, 4 = F32).
1270        dst_elem_bytes: u8,
1271    },
1272    /// Reduction along a contiguous range of axes. Input layout (after
1273    /// shape decomposition) is `[outer, reduced, inner]`; output is
1274    /// `[outer, inner]`. The single-axis cases (axis=0 → outer=1;
1275    /// axis=last → inner=1) and contiguous multi-axis (e.g. reduce over
1276    /// [0, 1] of an [N, C, H, W] tensor → outer=1, reduced=N*C, inner=H*W)
1277    /// all map onto this triplet. Non-contiguous axes are not supported
1278    /// and bail to Nop in the compile pass.
1279    Reduce {
1280        src: usize,
1281        dst: usize,
1282        outer: u32,
1283        reduced: u32,
1284        inner: u32,
1285        op: ReduceOp,
1286    },
1287    /// Index of the max (`is_max`) or min along the reduced axis; writes the
1288    /// winning index as an f32 into `dst`.
1289    ArgReduce {
1290        src: usize,
1291        dst: usize,
1292        outer: u32,
1293        reduced: u32,
1294        inner: u32,
1295        is_max: bool,
1296    },
1297    /// Top-K **indices** along the last axis. Input shape `[outer, axis_dim]`,
1298    /// output `[outer, k]` (f32 or i64 per `indices_i64`). Ties broken by
1299    /// smaller index. Used by MoE gating + beam search.
1300    TopK {
1301        src: usize,
1302        dst: usize,
1303        outer: u32,
1304        axis_dim: u32,
1305        k: u32,
1306        indices_i64: u8,
1307    },
1308    /// Indexed batched matmul: out\[i\] = input\[i\] @ weight[expert_idx\[i\]].
1309    /// Naive impl per token; for real MoE workloads, sort-by-expert + run
1310    /// segmented GEMM would amortize. Done when there's a workload.
1311    GroupedMatMul {
1312        input: usize,
1313        weight: usize,
1314        expert_idx: usize,
1315        dst: usize,
1316        m: u32,
1317        k_dim: u32,
1318        n: u32,
1319        num_experts: u32,
1320    },
1321    /// GGUF K-quant packed expert stack + grouped matmul (MoE FFN).
1322    DequantGroupedMatMulGguf {
1323        input: usize,
1324        w_q: usize,
1325        expert_idx: usize,
1326        dst: usize,
1327        m: u32,
1328        k_dim: u32,
1329        n: u32,
1330        num_experts: u32,
1331        scheme: rlx_ir::quant::QuantScheme,
1332    },
1333    /// Materialize packed MoE weights to F32 `[E, K, N]` (autodiff helper).
1334    DequantMoEWeightsGguf {
1335        w_q: usize,
1336        dst: usize,
1337        k_dim: u32,
1338        n: u32,
1339        num_experts: u32,
1340        scheme: rlx_ir::quant::QuantScheme,
1341    },
1342    /// Scatter-add: dst[indices\[i\] * trailing + j] += updates[i * trailing + j].
1343    /// Output is zeroed first; multiple updates to the same row accumulate.
1344    ScatterAdd {
1345        updates: usize,
1346        indices: usize,
1347        dst: usize,
1348        num_updates: u32,
1349        out_dim: u32,
1350        trailing: u32,
1351    },
1352    /// Ternary select: out = cond != 0 ? on_true : on_false
1353    Where {
1354        cond: usize,
1355        on_true: usize,
1356        on_false: usize,
1357        dst: usize,
1358        len: u32,
1359        elem_bytes: u8,
1360        /// Element size for cond (1 = Bool mask, 4 = F32 0/1).
1361        cond_elem_bytes: u8,
1362    },
1363    /// Single-rounded fused multiply-add: `dst = a*b + c` (one rounding —
1364    /// enables error-free transforms / compensated arithmetic).
1365    Fma {
1366        a: usize,
1367        b: usize,
1368        c: usize,
1369        dst: usize,
1370        len: u32,
1371        elem_bytes: u8,
1372    },
1373    /// General N-D transpose / broadcast. `out_dims[i]` is the output's dim
1374    /// i length; `in_strides[i]` is the input stride (in elements) used to
1375    /// index that dim — 0 for broadcast dims (Expand). `in_total` is the
1376    /// total element count in the source buffer (≤ output total when
1377    /// broadcasting). Strides are pre-computed at compile time.
1378    Transpose {
1379        src: usize,
1380        dst: usize,
1381        in_total: u32,
1382        out_dims: Vec<u32>,
1383        in_strides: Vec<u32>,
1384        elem_bytes: u8,
1385    },
1386    /// Gather along an arbitrary axis. `outer = product(dims[..axis])`,
1387    /// `trailing = product(dims[axis+1..])`, `axis_dim` = the dimension
1388    /// being indexed into. Output: outer × num_idx × trailing.
1389    /// (axis=0 still routes to the simpler Thunk::Gather fast path.)
1390    GatherAxis {
1391        table: usize,
1392        idx: usize,
1393        dst: usize,
1394        outer: u32,
1395        axis_dim: u32,
1396        num_idx: u32,
1397        trailing: u32,
1398        idx_i64: u8,
1399        table_bytes: u8,
1400    },
1401    /// 2D pooling (Max or Mean). Input layout [N, C, H, W], output
1402    /// [N, C, H_out, W_out]. Padding is implicit-zero; Mean divides by
1403    /// the full kernel area (matches torch's `count_include_pad=True`).
1404    Pool2D {
1405        src: usize,
1406        dst: usize,
1407        n: u32,
1408        c: u32,
1409        h: u32,
1410        w: u32,
1411        h_out: u32,
1412        w_out: u32,
1413        kh: u32,
1414        kw: u32,
1415        sh: u32,
1416        sw: u32,
1417        ph: u32,
1418        pw: u32,
1419        kind: ReduceOp,
1420    },
1421    /// 2D convolution. Input [N, C_in, H, W], weight [C_out, C_in_per_group, kH, kW],
1422    /// output [N, C_out, H_out, W_out]. Bias is a separate Op::Binary::Add
1423    /// after the conv (matching the IR's input layout — Op::Conv has 2 inputs).
1424    /// Naive direct convolution; sufficient for correctness, not optimised.
1425    Conv2D {
1426        src: usize,
1427        weight: usize,
1428        dst: usize,
1429        n: u32,
1430        c_in: u32,
1431        h: u32,
1432        w: u32,
1433        c_out: u32,
1434        h_out: u32,
1435        w_out: u32,
1436        kh: u32,
1437        kw: u32,
1438        sh: u32,
1439        sw: u32,
1440        ph: u32,
1441        pw: u32,
1442        dh: u32,
1443        dw: u32,
1444        groups: u32,
1445    },
1446
1447    // ── Backward / training kernels ─────────────────────────────
1448    /// Real INT8 matmul with i32 accumulation.
1449    ///   `out[m, n] = requantize(bias[n] + Σₖ (x[m,k]-x_zp)·(w[k,n]-w_zp), mult, out_zp)`
1450    /// Reads `x` and `w` as i8, `bias` as i32; writes `out` as i8.
1451    /// Same kernel shape as `rlx_cortexm::dense::dense_i8` — promoted
1452    /// to a desktop thunk so a quantized graph compiled here doesn't
1453    /// have to round-trip through fake-quant.
1454    QMatMul {
1455        x: usize,
1456        w: usize,
1457        bias: usize,
1458        out: usize,
1459        m: u32,
1460        k: u32,
1461        n: u32,
1462        x_zp: i32,
1463        w_zp: i32,
1464        out_zp: i32,
1465        mult: f32,
1466    },
1467
1468    /// Real INT8 conv2d, NCHW layout. Same loop shape as `Thunk::Conv2D`
1469    /// but with i8 reads, i32 accumulation, and per-output requantize
1470    /// to i8. Bias is i32 in the accumulator scale.
1471    QConv2d {
1472        x: usize,
1473        w: usize,
1474        bias: usize,
1475        out: usize,
1476        n: u32,
1477        c_in: u32,
1478        h: u32,
1479        w_in: u32,
1480        c_out: u32,
1481        h_out: u32,
1482        w_out: u32,
1483        kh: u32,
1484        kw: u32,
1485        sh: u32,
1486        sw: u32,
1487        ph: u32,
1488        pw: u32,
1489        dh: u32,
1490        dw: u32,
1491        groups: u32,
1492        x_zp: i32,
1493        w_zp: i32,
1494        out_zp: i32,
1495        mult: f32,
1496    },
1497
1498    /// INT8 quantize. Reads `x` as f32, writes `q` as i8.
1499    /// `chan = (i / inner) % chan_dim` selects the per-channel
1500    /// scale/zp; `chan_axis` is informational only (the kernel uses
1501    /// `chan_dim` and `inner` directly).
1502    /// For per-tensor, `chan_dim = 1` and `inner = len` so `chan` is
1503    /// always 0.
1504    Quantize {
1505        x: usize,
1506        q: usize,
1507        len: u32,
1508        chan_axis: u32,
1509        chan_dim: u32,
1510        inner: u32,
1511        scales: Vec<f32>,
1512        zero_points: Vec<i32>,
1513    },
1514
1515    /// INT8 dequantize — inverse of `Thunk::Quantize`.
1516    Dequantize {
1517        q: usize,
1518        x: usize,
1519        len: u32,
1520        chan_axis: u32,
1521        chan_dim: u32,
1522        inner: u32,
1523        scales: Vec<f32>,
1524        zero_points: Vec<i32>,
1525    },
1526
1527    /// QAT fake-quantize. Per-channel (or per-tensor) symmetric
1528    /// quantize-then-dequantize on the fly. Computes
1529    ///   `s[c] = max(|x[..., c, ...]|) / q_max`
1530    /// then
1531    ///   `out[i] = clamp(round(x[i]/s[c]), -q_max, q_max) * s[c]`
1532    /// with `q_max = {127, 7, 1}` for `bits = {8, 4, 2}`. Same
1533    /// channel-layout convention as `Thunk::Quantize`: every
1534    /// element's channel is `(i / inner) % chan_dim`. The kernel
1535    /// does two passes — one to scan max-abs per channel, one to
1536    /// quant-dequant per element.
1537    FakeQuantize {
1538        x: usize,
1539        out: usize,
1540        len: u32,
1541        chan_axis: u32,
1542        chan_dim: u32,
1543        inner: u32,
1544        bits: u8,
1545        /// STE variant — informational on the forward side (output is
1546        /// the same regardless), kernel-relevant in the matching
1547        /// `FakeQuantizeBackward` thunk.
1548        ste: rlx_ir::op::SteKind,
1549        /// Scale-tracking strategy. `PerBatch` recomputes
1550        /// `max_abs/q_max` every call (the original path). `EMA{decay}`
1551        /// blends per-batch max-abs into the `state_off` buffer; `Fixed`
1552        /// reads `state_off` and never updates it.
1553        scale_mode: rlx_ir::op::ScaleMode,
1554        /// `Some(off)` for `EMA` and `Fixed`; `None` for `PerBatch`.
1555        /// Points at a `[chan_dim]` f32 buffer holding the running scale
1556        /// per channel.
1557        state_off: Option<usize>,
1558    },
1559
1560    /// Backward pass for `Op::FakeQuantize` under one of four STE
1561    /// variants. Computes `dx[i]` from the f32 forward input `x` and
1562    /// the upstream gradient `dy`, using the same per-channel scale
1563    /// scheme as the forward.
1564    FakeQuantizeBackward {
1565        x: usize,
1566        dy: usize,
1567        dx: usize,
1568        len: u32,
1569        chan_axis: u32,
1570        chan_dim: u32,
1571        inner: u32,
1572        bits: u8,
1573        ste: rlx_ir::op::SteKind,
1574    },
1575
1576    /// LSQ forward — same kernel shape as `FakeQuantize` Fixed mode.
1577    /// Reads scale from `scale_off` (a `[chan_dim]` Param tensor).
1578    FakeQuantizeLSQ {
1579        x: usize,
1580        scale_off: usize,
1581        out: usize,
1582        len: u32,
1583        chan_axis: u32,
1584        chan_dim: u32,
1585        inner: u32,
1586        bits: u8,
1587    },
1588
1589    /// LSQ backward, x-gradient. STE-clipped: passes upstream
1590    /// through inside the quantization range, zeros outside.
1591    FakeQuantizeLSQBackwardX {
1592        x: usize,
1593        scale_off: usize,
1594        dy: usize,
1595        dx: usize,
1596        len: u32,
1597        chan_axis: u32,
1598        chan_dim: u32,
1599        inner: u32,
1600        bits: u8,
1601    },
1602
1603    /// LSQ backward, scale-gradient. Per-channel:
1604    ///   `dscale[c] = sum_i ψ(x[i]/s[c]) · upstream[i]`
1605    /// where `ψ(z) = -z + round(z)` if `|z| ≤ q_max` else
1606    /// `sign(z) · q_max`. Output shape: `[chan_dim]`.
1607    FakeQuantizeLSQBackwardScale {
1608        x: usize,
1609        scale_off: usize,
1610        dy: usize,
1611        dscale: usize,
1612        len: u32,
1613        chan_axis: u32,
1614        chan_dim: u32,
1615        inner: u32,
1616        bits: u8,
1617    },
1618
1619    /// ReLU backward: `dx[i] = dy[i] if x[i] > 0 else 0`.
1620    ReluBackward {
1621        x: usize,
1622        dy: usize,
1623        dx: usize,
1624        len: u32,
1625    },
1626    /// f64 sibling of `ReluBackward` — same shape as the f32 variant
1627    /// but reads/writes 8 bytes per element. Required because
1628    /// `ReluBackward`'s `&[f32]` slot view returns half of every f64
1629    /// otherwise → backward silently produces 0 gradients on an f64
1630    /// graph. Mirrors the `ActivationBackwardF64` split.
1631    ReluBackwardF64 {
1632        x: usize,
1633        dy: usize,
1634        dx: usize,
1635        len: u32,
1636    },
1637
1638    /// Generic element-wise activation backward.
1639    /// `dx[i] = (d/dx act(x))[i] · dy[i]`. The closure dispatch is
1640    /// per-element; expensive activations (Gelu) recompute internals
1641    /// inline rather than threading an extra "saved y" tensor through.
1642    ActivationBackward {
1643        x: usize,
1644        dy: usize,
1645        dx: usize,
1646        len: u32,
1647        kind: Activation,
1648    },
1649    /// f64 sibling of `ActivationBackward` — slot offsets, len in
1650    /// elements; kernel reads/writes 8 bytes per element. Required
1651    /// because `ActivationBackward`'s `&[f32]` slot view silently
1652    /// returns garbage on an f64 graph (cb % 4 still works but every
1653    /// loaded value is half of an f64 → wrong gradient).
1654    ActivationBackwardF64 {
1655        x: usize,
1656        dy: usize,
1657        dx: usize,
1658        len: u32,
1659        kind: Activation,
1660    },
1661
1662    /// LayerNorm backward — input gradient. Recomputes mean/var/x̂ from
1663    /// `x` and emits the closed-form `d_x` per row.
1664    LayerNormBackwardInput {
1665        x: usize,
1666        gamma: usize,
1667        dy: usize,
1668        dx: usize,
1669        rows: u32,
1670        h: u32,
1671        eps: f32,
1672    },
1673
1674    /// LayerNorm backward — gamma gradient. `d_gamma[d] = Σ_row dy·x̂`.
1675    LayerNormBackwardGamma {
1676        x: usize,
1677        dy: usize,
1678        dgamma: usize,
1679        rows: u32,
1680        h: u32,
1681        eps: f32,
1682    },
1683
1684    RmsNormBackwardInput {
1685        x: usize,
1686        gamma: usize,
1687        beta: usize,
1688        dy: usize,
1689        dx: usize,
1690        rows: u32,
1691        h: u32,
1692        eps: f32,
1693    },
1694    RmsNormBackwardGamma {
1695        x: usize,
1696        gamma: usize,
1697        beta: usize,
1698        dy: usize,
1699        dgamma: usize,
1700        rows: u32,
1701        h: u32,
1702        eps: f32,
1703    },
1704    RmsNormBackwardBeta {
1705        x: usize,
1706        gamma: usize,
1707        beta: usize,
1708        dy: usize,
1709        dbeta: usize,
1710        rows: u32,
1711        h: u32,
1712        eps: f32,
1713    },
1714    RopeBackward {
1715        dy: usize,
1716        cos: usize,
1717        sin: usize,
1718        dx: usize,
1719        batch: u32,
1720        seq: u32,
1721        hidden: u32,
1722        head_dim: u32,
1723        n_rot: u32,
1724        cos_len: u32,
1725    },
1726    CumsumBackward {
1727        dy: usize,
1728        dx: usize,
1729        rows: u32,
1730        cols: u32,
1731        exclusive: bool,
1732    },
1733    GatherBackward {
1734        dy: usize,
1735        indices: usize,
1736        dst: usize,
1737        outer: u32,
1738        axis_dim: u32,
1739        num_idx: u32,
1740        trailing: u32,
1741    },
1742
1743    GroupNormBackwardInput {
1744        x: usize,
1745        gamma: usize,
1746        beta: usize,
1747        dy: usize,
1748        dx: usize,
1749        n: u32,
1750        c: u32,
1751        h: u32,
1752        w: u32,
1753        num_groups: u32,
1754        eps: f32,
1755    },
1756    GroupNormBackwardGamma {
1757        x: usize,
1758        dy: usize,
1759        dgamma: usize,
1760        n: u32,
1761        c: u32,
1762        h: u32,
1763        w: u32,
1764        num_groups: u32,
1765        eps: f32,
1766    },
1767    GroupNormBackwardBeta {
1768        dy: usize,
1769        dbeta: usize,
1770        n: u32,
1771        c: u32,
1772        h: u32,
1773        w: u32,
1774    },
1775
1776    /// 2D max-pool backward (NCHW). Recomputes the argmax position
1777    /// inside each window and accumulates `dy` into `dx` at that
1778    /// position. Output is zeroed first; ties resolve to the first
1779    /// hit (lowest (kh,kw) index), matching what the forward kernel
1780    /// does with `acc.max(v)`.
1781    MaxPool2dBackward {
1782        x: usize,
1783        dy: usize,
1784        dx: usize,
1785        n: u32,
1786        c: u32,
1787        h: u32,
1788        w: u32,
1789        h_out: u32,
1790        w_out: u32,
1791        kh: u32,
1792        kw: u32,
1793        sh: u32,
1794        sw: u32,
1795        ph: u32,
1796        pw: u32,
1797    },
1798
1799    /// 2D conv backward w.r.t. input (`dx = conv_transpose(dy, w)`).
1800    /// `dy [N, C_out, H_out, W_out]`, `w [C_out, C_in_per_group, kH, kW]`,
1801    /// `dx [N, C_in, H, W]`.
1802    Conv2dBackwardInput {
1803        dy: usize,
1804        w: usize,
1805        dx: usize,
1806        n: u32,
1807        c_in: u32,
1808        h: u32,
1809        w_in: u32,
1810        c_out: u32,
1811        h_out: u32,
1812        w_out: u32,
1813        kh: u32,
1814        kw: u32,
1815        sh: u32,
1816        sw: u32,
1817        ph: u32,
1818        pw: u32,
1819        dh: u32,
1820        dw: u32,
1821        groups: u32,
1822    },
1823
1824    /// 2D conv backward w.r.t. weight. `x [N, C_in, H, W]`,
1825    /// `dy [N, C_out, H_out, W_out]`, `dw [C_out, C_in_per_group, kH, kW]`.
1826    /// `dw` is zeroed before accumulation.
1827    Conv2dBackwardWeight {
1828        x: usize,
1829        dy: usize,
1830        dw: usize,
1831        n: u32,
1832        c_in: u32,
1833        h: u32,
1834        w: u32,
1835        c_out: u32,
1836        h_out: u32,
1837        w_out: u32,
1838        kh: u32,
1839        kw: u32,
1840        sh: u32,
1841        sw: u32,
1842        ph: u32,
1843        pw: u32,
1844        dh: u32,
1845        dw_dil: u32,
1846        groups: u32,
1847    },
1848
1849    /// NCHW im2col for conv backward-weight matmul. Output `[M, C·kH·kW]`
1850    /// with `M = N · H_out · W_out`. `n == 0` means infer batch from `x`.
1851    Im2Col {
1852        x: usize,
1853        col: usize,
1854        n: u32,
1855        c_in: u32,
1856        h: u32,
1857        w: u32,
1858        h_out: u32,
1859        w_out: u32,
1860        kh: u32,
1861        kw: u32,
1862        sh: u32,
1863        sw: u32,
1864        ph: u32,
1865        pw: u32,
1866        dh: u32,
1867        dw_dil: u32,
1868    },
1869
1870    /// Fused softmax + cross-entropy loss against a dense target
1871    /// distribution. `logits [N, C]`, `targets [N, C]`, output `[N]`
1872    /// per-row loss `lse(logits[n]) - Σ_c targets[n,c]·logits[n,c]`.
1873    /// Numerically stable (max-subtract before exp).
1874    SoftmaxCrossEntropyDense {
1875        logits: usize,
1876        targets: usize,
1877        dst: usize,
1878        n: u32,
1879        c: u32,
1880    },
1881
1882    /// Fused softmax + cross-entropy loss with f32-encoded integer
1883    /// labels. `logits [N, C]`, `labels [N]`, output `[N]` per-row loss.
1884    /// Numerically stable (max-subtract before exp).
1885    SoftmaxCrossEntropy {
1886        logits: usize,
1887        labels: usize,
1888        dst: usize,
1889        n: u32,
1890        c: u32,
1891    },
1892
1893    /// Backward of the fused loss above.
1894    /// `dlogits[n, k] = (softmax(logits[n])[k] - one_hot(labels[n])[k]) * d_loss[n]`.
1895    SoftmaxCrossEntropyBackward {
1896        logits: usize,
1897        labels: usize,
1898        d_loss: usize,
1899        dlogits: usize,
1900        n: u32,
1901        c: u32,
1902    },
1903
1904    /// User-registered custom op (CPU side). Lowered from `Op::Custom`.
1905    /// `kernel` is resolved against the global CPU kernel registry at
1906    /// compile time and stored as `Arc<dyn CpuKernel>` so execution
1907    /// avoids per-call lookups. v1: f32 contiguous only — see
1908    /// `op_registry::CpuKernel::execute_f32`.
1909    CustomOp {
1910        kernel: Arc<dyn CpuKernel>,
1911        inputs: Vec<(usize, u32, Shape)>, // (offset, len_elements, shape)
1912        output: (usize, u32, Shape),      // (offset, len_elements, shape)
1913        attrs: Vec<u8>,
1914    },
1915
1916    /// 1D FFT along the last axis. Input/output are `[..., 2N]`
1917    /// real-block layout (first N real, second N imag along the
1918    /// transformed axis). `outer` is the product of all leading axes;
1919    /// `n_complex` is N (the number of complex points). Both halves
1920    /// of the real-block layout are read together by the kernel.
1921    /// `dtype` selects the f32 or f64 path; the two share structure
1922    /// but not buffers, so a flag at compile time avoids per-row
1923    /// dispatch.
1924    /// CPU reference 3D Gaussian splat render ([`rlx_ir::Op::GaussianSplatRender`]).
1925    GaussianSplatRender {
1926        positions_off: usize,
1927        positions_len: usize,
1928        scales_off: usize,
1929        scales_len: usize,
1930        rotations_off: usize,
1931        rotations_len: usize,
1932        opacities_off: usize,
1933        opacities_len: usize,
1934        colors_off: usize,
1935        colors_len: usize,
1936        sh_coeffs_off: usize,
1937        sh_coeffs_len: usize,
1938        meta_off: usize,
1939        dst_off: usize,
1940        dst_len: usize,
1941        width: u32,
1942        height: u32,
1943        tile_size: u32,
1944        radius_scale: f32,
1945        alpha_cutoff: f32,
1946        max_splat_steps: u32,
1947        transmittance_threshold: f32,
1948        max_list_entries: u32,
1949    },
1950    GaussianSplatRenderBackward {
1951        positions_off: usize,
1952        positions_len: usize,
1953        scales_off: usize,
1954        scales_len: usize,
1955        rotations_off: usize,
1956        rotations_len: usize,
1957        opacities_off: usize,
1958        opacities_len: usize,
1959        colors_off: usize,
1960        colors_len: usize,
1961        sh_coeffs_off: usize,
1962        sh_coeffs_len: usize,
1963        meta_off: usize,
1964        d_loss_off: usize,
1965        d_loss_len: usize,
1966        packed_off: usize,
1967        packed_len: usize,
1968        width: u32,
1969        height: u32,
1970        tile_size: u32,
1971        radius_scale: f32,
1972        alpha_cutoff: f32,
1973        max_splat_steps: u32,
1974        transmittance_threshold: f32,
1975        max_list_entries: u32,
1976        loss_grad_clip: f32,
1977        sh_band: u32,
1978        max_anisotropy: f32,
1979    },
1980    /// Strict IR stage 1 — project + bin + sort + rays ([`Op::GaussianSplatPrepare`]).
1981    GaussianSplatPrepare {
1982        positions_off: usize,
1983        positions_len: usize,
1984        scales_off: usize,
1985        scales_len: usize,
1986        rotations_off: usize,
1987        rotations_len: usize,
1988        opacities_off: usize,
1989        opacities_len: usize,
1990        colors_off: usize,
1991        colors_len: usize,
1992        sh_coeffs_off: usize,
1993        sh_coeffs_len: usize,
1994        meta_off: usize,
1995        meta_len: usize,
1996        prep_off: usize,
1997        prep_len: usize,
1998        width: u32,
1999        height: u32,
2000        tile_size: u32,
2001        radius_scale: f32,
2002        alpha_cutoff: f32,
2003        max_splat_steps: u32,
2004        transmittance_threshold: f32,
2005        max_list_entries: u32,
2006    },
2007    /// Strict IR stage 2 — tile raster from prepare buffer ([`Op::GaussianSplatRasterize`]).
2008    GaussianSplatRasterize {
2009        prep_off: usize,
2010        prep_len: usize,
2011        meta_off: usize,
2012        meta_len: usize,
2013        dst_off: usize,
2014        dst_len: usize,
2015        count: usize,
2016        width: u32,
2017        height: u32,
2018        tile_size: u32,
2019        alpha_cutoff: f32,
2020        max_splat_steps: u32,
2021        transmittance_threshold: f32,
2022        max_list_entries: u32,
2023    },
2024    Fft1d {
2025        src: usize,
2026        dst: usize,
2027        outer: u32,
2028        n_complex: u32,
2029        inverse: bool,
2030        norm_tag: u32,
2031        dtype: rlx_ir::DType,
2032    },
2033    FftButterflyStage {
2034        state_src: usize,
2035        state_dst: usize,
2036        gate_src: usize,
2037        rev_src: usize,
2038        tw_re_src: usize,
2039        tw_im_src: usize,
2040        batch: u32,
2041        n_fft: u32,
2042        stage: u32,
2043    },
2044    LogMel {
2045        spec: usize,
2046        filters: usize,
2047        dst: usize,
2048        outer: u32,
2049        n_fft: u32,
2050        n_bins: u32,
2051        n_mels: u32,
2052    },
2053    LogMelBackward {
2054        spec: usize,
2055        filters: usize,
2056        dy: usize,
2057        dst: usize,
2058        outer: u32,
2059        n_fft: u32,
2060        n_bins: u32,
2061        n_mels: u32,
2062    },
2063    WelchPeaks {
2064        spec: usize,
2065        dst: usize,
2066        welch_batch: u32,
2067        n_fft: u32,
2068        n_segments: u32,
2069        k: u32,
2070    },
2071}
2072
2073/// Compiled thunk schedule — the runtime hot path.
2074/// Nop thunks are filtered out at compile time for zero iteration overhead.
2075#[derive(Clone)]
2076pub struct ThunkSchedule {
2077    pub thunks: Vec<Thunk>,
2078    /// TIDE merged placement mask (union across layers).
2079    pub moe_resident: Option<std::sync::Arc<[bool]>>,
2080    /// Per MoE layer placement (`layer[e]`); preferred when set.
2081    pub moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
2082    /// MoE router TopK capture (per-layer refresh).
2083    pub moe_topk_capture: Option<std::sync::Arc<crate::moe_topk_capture::MoeTopkCapture>>,
2084    /// Cached config values.
2085    pub mask_threshold: f32,
2086    pub mask_neg_inf: f32,
2087    pub score_skip: f32,
2088    /// Pre-compiled closure dispatch (zero match overhead). `Arc` (not
2089    /// `Box`) so the schedule can be `Clone` — multiple parallel
2090    /// executors share the same compiled closures (they're read-only
2091    /// `Fn(*mut u8)` so concurrent dispatch is safe; the arena pointer
2092    /// they receive is the only mutable state and is per-executor).
2093    pub compiled_fns: Vec<Arc<dyn Fn(*mut u8) + Send + Sync>>,
2094    /// Runtime-mutable RNG policy for [`Thunk::RngNormal`] / [`Thunk::RngUniform`].
2095    pub rng: Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
2096}
2097
2098impl ThunkSchedule {
2099    pub fn strip_nops(&mut self) {
2100        self.thunks.retain(|t| !matches!(t, Thunk::Nop));
2101        // compiled_fns must be rebuilt after stripping — caller should
2102        // call strip_nops() before compile_closures().
2103        self.compiled_fns.clear();
2104    }
2105}
2106
2107/// Get the arena byte offset for a node.
2108fn node_offset(arena: &Arena, id: NodeId) -> usize {
2109    if arena.has_buffer(id) {
2110        arena.byte_offset(id)
2111    } else {
2112        usize::MAX
2113    }
2114}
2115
2116/// Every byte-offset that a thunk reads from. Used by the Narrow→Rope
2117/// fusion (#45) to verify a Narrow's dst has exactly one consumer
2118/// before eliding it. Conservative: when in doubt about reads (an op
2119/// not yet listed here), the fusion will skip — correctness over
2120/// completeness.
2121fn thunk_read_offsets(t: &Thunk) -> Vec<usize> {
2122    match t {
2123        Thunk::Sgemm { a, b, .. } => vec![*a, *b],
2124        Thunk::SgemmT { a, b, .. } => vec![*a, *b],
2125        Thunk::SgdMomentum {
2126            param, vel, grad, ..
2127        } => vec![*param, *vel, *grad],
2128        Thunk::DenseSolveF64 { a, b, .. } => vec![*a, *b],
2129        Thunk::DenseSolveF32 { a, b, .. } => vec![*a, *b],
2130        Thunk::BatchedDenseSolveF64 { a, b, .. } => vec![*a, *b],
2131        Thunk::BatchedDgemmF64 { a, b, .. } => vec![*a, *b],
2132        Thunk::BatchedSgemm { a, b, .. } => vec![*a, *b],
2133        Thunk::FusedMmBiasAct { a, w, bias, .. } => vec![*a, *w, *bias],
2134        Thunk::ElementwiseRegion { input_offs, .. } => input_offs.clone(),
2135        Thunk::BiasAdd { src, bias, .. } => vec![*src, *bias],
2136        Thunk::BinaryFull { lhs, rhs, .. } => vec![*lhs, *rhs],
2137        Thunk::BinaryFullF64 { lhs, rhs, .. } => vec![*lhs, *rhs],
2138        Thunk::BinaryFullC64 { lhs, rhs, .. } => vec![*lhs, *rhs],
2139        Thunk::ComplexNormSqF32 { src, .. } => vec![*src],
2140        Thunk::ComplexNormSqBackwardF32 { z, g, .. } => vec![*z, *g],
2141        Thunk::ConjugateC64 { src, .. } => vec![*src],
2142        Thunk::Scan {
2143            outer_init_off,
2144            xs_inputs,
2145            ..
2146        } => {
2147            let mut v = vec![*outer_init_off];
2148            for (_, outer_xs_off, _) in xs_inputs.iter() {
2149                v.push(*outer_xs_off);
2150            }
2151            v
2152        }
2153        Thunk::ScanBackward {
2154            outer_init_off,
2155            outer_traj_off,
2156            outer_upstream_off,
2157            outer_xs_offs,
2158            ..
2159        } => {
2160            let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
2161            for (off, _) in outer_xs_offs.iter() {
2162                v.push(*off);
2163            }
2164            v
2165        }
2166        Thunk::ScanBackwardXs {
2167            outer_init_off,
2168            outer_traj_off,
2169            outer_upstream_off,
2170            outer_xs_offs,
2171            ..
2172        } => {
2173            let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
2174            for (off, _) in outer_xs_offs.iter() {
2175                v.push(*off);
2176            }
2177            v
2178        }
2179        Thunk::CustomFn { inputs, .. } => {
2180            inputs.iter().map(|(_, outer_off, _)| *outer_off).collect()
2181        }
2182        Thunk::ActivationInPlace { data, .. } => vec![*data],
2183        Thunk::LayerNorm { src, g, b, .. } | Thunk::GroupNorm { src, g, b, .. } => {
2184            vec![*src, *g, *b]
2185        }
2186        Thunk::BatchNormInference {
2187            src,
2188            g,
2189            b,
2190            mean,
2191            var,
2192            ..
2193        } => vec![*src, *g, *b, *mean, *var],
2194        Thunk::ResizeNearest2x { src, .. } => vec![*src],
2195        Thunk::AxialRope2d { src, .. } => vec![*src],
2196        Thunk::FusedResidualLN {
2197            x, res, bias, g, b, ..
2198        } => vec![*x, *res, *bias, *g, *b],
2199        Thunk::FusedResidualRmsNorm {
2200            x, res, bias, g, b, ..
2201        } => vec![*x, *res, *bias, *g, *b],
2202        Thunk::RmsNorm { src, g, b, .. } => vec![*src, *g, *b],
2203        Thunk::Softmax { data, .. } => vec![*data],
2204        Thunk::Cumsum { src, .. } => vec![*src],
2205        Thunk::Sample { logits, .. } => vec![*logits],
2206        Thunk::RngNormal { .. } | Thunk::RngUniform { .. } => vec![],
2207        Thunk::LoraMatMul { x, w, a, b, .. } => vec![*x, *w, *a, *b],
2208        Thunk::DequantMatMul {
2209            x, w_q, scale, zp, ..
2210        } => vec![*x, *w_q, *scale, *zp],
2211        Thunk::DequantMatMulGguf { x, w_q, .. } => vec![*x, *w_q],
2212        Thunk::DequantMatMulInt4 {
2213            x, w_q, scale, zp, ..
2214        } => vec![*x, *w_q, *scale, *zp],
2215        Thunk::DequantMatMulFp8 { x, w_q, scale, .. } => vec![*x, *w_q, *scale],
2216        Thunk::DequantMatMulNvfp4 {
2217            x,
2218            w_q,
2219            scale,
2220            global_scale,
2221            ..
2222        } => vec![*x, *w_q, *scale, *global_scale],
2223        Thunk::ScaledMatMul {
2224            lhs,
2225            rhs,
2226            lhs_scale,
2227            rhs_scale,
2228            bias,
2229            has_bias,
2230            ..
2231        } => {
2232            let mut v = vec![*lhs, *rhs, *lhs_scale, *rhs_scale];
2233            if *has_bias {
2234                v.push(*bias);
2235            }
2236            v
2237        }
2238        Thunk::ScaledQuantize { x, scale, .. } => vec![*x, *scale],
2239        Thunk::ScaledQuantScale { x, .. } => vec![*x],
2240        Thunk::ScaledDequantize { codes, scale, .. } => vec![*codes, *scale],
2241        Thunk::Conv2D1x1 { src, weight, .. } => vec![*src, *weight],
2242        Thunk::SelectiveScan {
2243            x, delta, a, b, c, ..
2244        } => vec![*x, *delta, *a, *b, *c],
2245        Thunk::GatedDeltaNet {
2246            q,
2247            k,
2248            v,
2249            g,
2250            beta,
2251            state,
2252            ..
2253        } => {
2254            let mut v = vec![*q, *k, *v, *g, *beta];
2255            if *state != 0 {
2256                v.push(*state);
2257            }
2258            v
2259        }
2260        Thunk::Attention { q, k, v, mask, .. } => vec![*q, *k, *v, *mask],
2261        Thunk::AttentionBackward {
2262            q, k, v, dy, mask, ..
2263        } => {
2264            let mut v = vec![*q, *k, *v, *dy];
2265            if *mask != 0 {
2266                v.push(*mask);
2267            }
2268            v
2269        }
2270        Thunk::Rope { src, cos, sin, .. } => vec![*src, *cos, *sin],
2271        Thunk::FusedAttnBlock {
2272            hidden,
2273            qkv_w,
2274            out_w,
2275            mask,
2276            qkv_b,
2277            out_b,
2278            cos,
2279            sin,
2280            ..
2281        } => vec![*hidden, *qkv_w, *out_w, *mask, *qkv_b, *out_b, *cos, *sin],
2282        Thunk::FusedSwiGLU { src, .. } => vec![*src],
2283        Thunk::Concat { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
2284        Thunk::ConcatF64 { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
2285        Thunk::Narrow { src, .. } => vec![*src],
2286        Thunk::Copy { src, .. } => vec![*src],
2287        Thunk::Gather { table, idx, .. } => vec![*table, *idx],
2288        // Anything not enumerated → return the dst as a "read" too,
2289        // forcing the fusion to bail (read_count >= 2 → skip). Keeps
2290        // this list safe to be incomplete.
2291        _ => vec![],
2292    }
2293}
2294
2295/// Fused dequant + matmul (plan #5). Int8-blockwise weights: each
2296/// `block_size` consecutive elements of a column share one f32
2297/// scale (and optionally a zero-point). The dequant happens inside
2298/// the inner accumulate so the f32 weight is never materialized.
2299///
2300/// `w_bytes` is the row-major i8 weight matrix `[k, n]`. `scales`
2301/// and `zps` are `[k/block, n]`. When `asym=false`, `zps` may be
2302/// empty.
2303///
2304/// Today this is the reference scalar implementation — the win is
2305/// memory bandwidth, not flops, since LLM weights dominate the
2306/// working set. A NEON SIMD path that loads 16 i8 → splat-scale →
2307/// fused-multiply-add is the natural follow-on.
2308#[allow(clippy::too_many_arguments)]
2309pub fn dequant_matmul_int8(
2310    x: &[f32],       // [m, k]
2311    w_bytes: &[i8],  // [k, n]
2312    scales: &[f32],  // [k/block, n]
2313    zps: &[f32],     // [k/block, n] or empty
2314    out: &mut [f32], // [m, n]
2315    m: usize,
2316    k: usize,
2317    n: usize,
2318    block_size: usize,
2319    asym: bool,
2320) {
2321    let blocks_per_col = k.div_ceil(block_size);
2322    for i in 0..m {
2323        for j in 0..n {
2324            let mut acc = 0f32;
2325            for p in 0..k {
2326                let block = p / block_size;
2327                let s = scales[block * n + j];
2328                let z = if asym { zps[block * n + j] } else { 0.0 };
2329                let q = w_bytes[p * n + j] as f32;
2330                let dequantized = (q - z) * s;
2331                acc += x[i * k + p] * dequantized;
2332            }
2333            out[i * n + j] = acc;
2334        }
2335    }
2336    let _ = blocks_per_col;
2337}
2338
2339#[allow(clippy::too_many_arguments)]
2340fn dequant_matmul_int4(
2341    x: &[f32],
2342    w_bytes: &[u8],
2343    scales: &[f32],
2344    zps: &[f32],
2345    out: &mut [f32],
2346    m: usize,
2347    k: usize,
2348    n: usize,
2349    block_size: usize,
2350    asym: bool,
2351) {
2352    for i in 0..m {
2353        for j in 0..n {
2354            let mut acc = 0f32;
2355            for p in 0..k {
2356                let block = p / block_size;
2357                let s = scales[block * n + j];
2358                let z = if asym { zps[block * n + j] } else { 0.0 };
2359                let byte_idx = (p * n + j) / 2;
2360                let nibble = if (p * n + j) & 1 == 0 {
2361                    w_bytes[byte_idx] & 0x0F
2362                } else {
2363                    w_bytes[byte_idx] >> 4
2364                };
2365                let dequantized = (nibble as f32 - z) * s;
2366                acc += x[i * k + p] * dequantized;
2367            }
2368            out[i * n + j] = acc;
2369        }
2370    }
2371}
2372
2373fn fp8_e4m3_to_f32(b: u8) -> f32 {
2374    let sign = if b & 0x80 != 0 { -1.0 } else { 1.0 };
2375    let exp = (b >> 3) & 0x0F;
2376    let mant = b & 0x07;
2377    if exp == 0 {
2378        if mant == 0 {
2379            return 0.0;
2380        }
2381        return sign * (mant as f32) * 2f32.powi(-9);
2382    }
2383    if exp == 0x0F {
2384        return if mant == 0 {
2385            sign * f32::INFINITY
2386        } else {
2387            f32::NAN
2388        };
2389    }
2390    sign * (1.0 + mant as f32 / 8.0) * 2f32.powi(exp as i32 - 7)
2391}
2392
2393fn fp8_e5m2_to_f32(b: u8) -> f32 {
2394    let sign = if b & 0x80 != 0 { -1.0 } else { 1.0 };
2395    let exp = (b >> 2) & 0x1F;
2396    let mant = b & 0x03;
2397    if exp == 0 {
2398        if mant == 0 {
2399            return 0.0;
2400        }
2401        return sign * (mant as f32) * 2f32.powi(-16);
2402    }
2403    if exp == 0x1F {
2404        return if mant == 0 {
2405            sign * f32::INFINITY
2406        } else {
2407            f32::NAN
2408        };
2409    }
2410    sign * (1.0 + mant as f32 / 4.0) * 2f32.powi(exp as i32 - 15)
2411}
2412
2413#[allow(clippy::too_many_arguments)]
2414fn dequant_matmul_fp8(
2415    x: &[f32],
2416    w_bytes: &[u8],
2417    scales: &[f32],
2418    out: &mut [f32],
2419    m: usize,
2420    k: usize,
2421    n: usize,
2422    e5m2: bool,
2423) {
2424    let dequant = if e5m2 {
2425        fp8_e5m2_to_f32
2426    } else {
2427        fp8_e4m3_to_f32
2428    };
2429    for i in 0..m {
2430        for j in 0..n {
2431            let mut acc = 0f32;
2432            for p in 0..k {
2433                let w = dequant(w_bytes[p * n + j]);
2434                let s = scales.get(j).copied().unwrap_or(1.0);
2435                acc += x[i * k + p] * w * s;
2436            }
2437            out[i * n + j] = acc;
2438        }
2439    }
2440}
2441
2442#[allow(clippy::too_many_arguments)]
2443pub fn dequant_matmul_nvfp4(
2444    x: &[f32],
2445    w_bytes: &[u8],
2446    scale_bytes: &[u8],
2447    global_scale: f32,
2448    out: &mut [f32],
2449    m: usize,
2450    k: usize,
2451    n: usize,
2452) {
2453    use rlx_ir::{NVFP4_GROUP_SIZE, fp4_e2m1_to_f32, fp8_e4m3_scale_to_f32};
2454    let gs = NVFP4_GROUP_SIZE;
2455    for i in 0..m {
2456        for j in 0..n {
2457            let mut acc = 0f32;
2458            for p in 0..k {
2459                let byte_idx = (p * n + j) / 2;
2460                let nibble = if (p * n + j) & 1 == 0 {
2461                    w_bytes[byte_idx] & 0x0F
2462                } else {
2463                    w_bytes[byte_idx] >> 4
2464                };
2465                let block = p / gs;
2466                let scale = fp8_e4m3_scale_to_f32(scale_bytes[block * n + j]);
2467                let w = fp4_e2m1_to_f32(nibble) * scale * global_scale;
2468                acc += x[i * k + p] * w;
2469            }
2470            out[i * n + j] = acc;
2471        }
2472    }
2473}
2474
2475// ── Native low-precision (FP8/FP6/FP4) scaled GEMM — CPU reference oracle ──
2476//
2477// Decode-and-accumulate *reference* for `Op::ScaledMatMul` + its quantize
2478// producers. CPUs have no fp8 matrix units; correctness, not speed, is the
2479// point — every GPU backend's native tensor-core path is checked against these.
2480// Layout is TN: lhs [m,k], rhs [n,k] (K-last), out = lhs·rhsᵀ. Block scales
2481// (when any) run along the last/contraction axis of each operand.
2482
2483/// Blocks along a `len`-element axis (1 for per-tensor).
2484#[inline]
2485fn lowp_nblk(len: usize, layout: rlx_ir::ScaleLayout) -> usize {
2486    match layout {
2487        rlx_ir::ScaleLayout::PerTensor => 1,
2488        _ => len.div_ceil(layout.block() as usize),
2489    }
2490}
2491
2492/// Snap a raw f32 scale to the grid storable for `layout`, so quantizer and
2493/// matmul agree bit-for-bit on the reconstructed value.
2494#[inline]
2495fn lowp_snap_scale(layout: rlx_ir::ScaleLayout, s: f32) -> f32 {
2496    use rlx_ir::lowp_codec;
2497    match layout {
2498        rlx_ir::ScaleLayout::PerTensor => s,
2499        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
2500            lowp_codec::e8m0_to_f32(lowp_codec::f32_to_e8m0(s))
2501        }
2502        rlx_ir::ScaleLayout::Nvfp4 { .. } => lowp_codec::decode(
2503            rlx_ir::ScaledFormat::F8E4M3,
2504            lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s),
2505        ),
2506    }
2507}
2508
2509/// Scale for element (`free`, `contract`) given decoded raw scales.
2510#[inline]
2511fn lowp_scale_at(
2512    layout: rlx_ir::ScaleLayout,
2513    scales: &[f32],
2514    free: usize,
2515    contract: usize,
2516    nblk: usize,
2517) -> f32 {
2518    match layout {
2519        rlx_ir::ScaleLayout::PerTensor => scales.first().copied().unwrap_or(1.0),
2520        _ => scales[free * nblk + contract / layout.block() as usize],
2521    }
2522}
2523
2524/// Compute (snapped) raw f32 scales for `x` (`[rows, cols]`, blocks along
2525/// `cols` = contraction). PerTensor → 1 value; block → `rows*nblk` row-major.
2526fn lowp_compute_scales(
2527    x: &[f32],
2528    fmt: rlx_ir::ScaledFormat,
2529    layout: rlx_ir::ScaleLayout,
2530    rows: usize,
2531    cols: usize,
2532) -> Vec<f32> {
2533    let maxf = fmt.max_finite();
2534    let to_scale = |amax: f32| if amax > 0.0 { amax / maxf } else { 1.0 };
2535    match layout {
2536        rlx_ir::ScaleLayout::PerTensor => {
2537            let amax = x.iter().fold(0.0f32, |a, &v| a.max(v.abs()));
2538            vec![to_scale(amax)]
2539        }
2540        _ => {
2541            let block = layout.block() as usize;
2542            let nblk = cols.div_ceil(block);
2543            let mut out = vec![1.0f32; rows * nblk];
2544            for r in 0..rows {
2545                for b in 0..nblk {
2546                    let lo = b * block;
2547                    let hi = (lo + block).min(cols);
2548                    let mut amax = 0.0f32;
2549                    for c in lo..hi {
2550                        amax = amax.max(x[r * cols + c].abs());
2551                    }
2552                    out[r * nblk + b] = lowp_snap_scale(layout, to_scale(amax));
2553                }
2554            }
2555            out
2556        }
2557    }
2558}
2559
2560/// Quantize `x` (`[rows, cols]`, blocks along cols) to packed codes using the
2561/// already-snapped, decoded raw `scales`.
2562fn lowp_quantize(
2563    x: &[f32],
2564    scales: &[f32],
2565    fmt: rlx_ir::ScaledFormat,
2566    layout: rlx_ir::ScaleLayout,
2567    rows: usize,
2568    cols: usize,
2569    out: &mut [u8],
2570) {
2571    let nblk = lowp_nblk(cols, layout);
2572    for r in 0..rows {
2573        for c in 0..cols {
2574            let s = lowp_scale_at(layout, scales, r, c, nblk);
2575            let v = if s != 0.0 { x[r * cols + c] / s } else { 0.0 };
2576            out[r * cols + c] = rlx_ir::lowp_codec::encode(fmt, v);
2577        }
2578    }
2579}
2580
2581/// TN scaled GEMM: lhs [m,k] codes, rhs [n,k] codes, out [m,n] f32.
2582#[allow(clippy::too_many_arguments)]
2583fn lowp_scaled_matmul(
2584    lhs: &[u8],
2585    rhs: &[u8],
2586    lhs_scales: &[f32],
2587    rhs_scales: &[f32],
2588    bias: Option<&[f32]>,
2589    out: &mut [f32],
2590    m: usize,
2591    n: usize,
2592    k: usize,
2593    layout: rlx_ir::ScaleLayout,
2594    lhs_fmt: rlx_ir::ScaledFormat,
2595    rhs_fmt: rlx_ir::ScaledFormat,
2596) {
2597    use rlx_ir::lowp_codec::decode;
2598    let nblk = lowp_nblk(k, layout);
2599    for i in 0..m {
2600        for j in 0..n {
2601            let mut acc = 0f32;
2602            for p in 0..k {
2603                let a =
2604                    decode(lhs_fmt, lhs[i * k + p]) * lowp_scale_at(layout, lhs_scales, i, p, nblk);
2605                let b =
2606                    decode(rhs_fmt, rhs[j * k + p]) * lowp_scale_at(layout, rhs_scales, j, p, nblk);
2607                acc += a * b;
2608            }
2609            out[i * n + j] = acc + bias.map_or(0.0, |bb| bb[j]);
2610        }
2611    }
2612}
2613
2614/// Reconstruct f32 from packed codes: `out[i] = decode(code[i]) · scale(block)`.
2615/// `[rows, cols]`, blocks along cols. Inverse of [`lowp_quantize`].
2616fn lowp_dequantize(
2617    codes: &[u8],
2618    scales: &[f32],
2619    fmt: rlx_ir::ScaledFormat,
2620    layout: rlx_ir::ScaleLayout,
2621    rows: usize,
2622    cols: usize,
2623    out: &mut [f32],
2624) {
2625    use rlx_ir::lowp_codec::decode;
2626    let nblk = lowp_nblk(cols, layout);
2627    for r in 0..rows {
2628        for c in 0..cols {
2629            let s = lowp_scale_at(layout, scales, r, c, nblk);
2630            out[r * cols + c] = decode(fmt, codes[r * cols + c]) * s;
2631        }
2632    }
2633}
2634
2635/// Decode a stored scale tensor (f32 for per-tensor, E8M0/E4M3 bytes for block
2636/// layouts) into raw f32 scales. `n` is the scale-element count.
2637unsafe fn lowp_read_scales(
2638    layout: rlx_ir::ScaleLayout,
2639    base: *mut u8,
2640    offset: usize,
2641    n: usize,
2642) -> Vec<f32> {
2643    use rlx_ir::lowp_codec;
2644    match layout {
2645        rlx_ir::ScaleLayout::PerTensor => {
2646            unsafe { std::slice::from_raw_parts(base.add(offset) as *const f32, n) }.to_vec()
2647        }
2648        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
2649            let bytes = unsafe { std::slice::from_raw_parts(base.add(offset), n) };
2650            bytes.iter().map(|&b| lowp_codec::e8m0_to_f32(b)).collect()
2651        }
2652        rlx_ir::ScaleLayout::Nvfp4 { .. } => {
2653            let bytes = unsafe { std::slice::from_raw_parts(base.add(offset), n) };
2654            bytes
2655                .iter()
2656                .map(|&b| lowp_codec::decode(rlx_ir::ScaledFormat::F8E4M3, b))
2657                .collect()
2658        }
2659    }
2660}
2661
2662/// Fused sampling step: logits → top-k filter → top-p truncation
2663/// → softmax → multinomial sample. Operates on one row of length
2664/// `vocab` and returns the sampled index. Plan #42.
2665///
2666/// Internal scratch is on the stack via SmallVec-style fallback —
2667/// for `vocab > 8192` we heap-allocate a working buffer; below
2668/// that we keep things in a fixed array. (TODO: thread the
2669/// scratch through ThunkSchedule like sdpa_scores does.)
2670fn sample_row(
2671    logits: &[f32],
2672    top_k: usize,
2673    top_p: f32,
2674    temperature: f32,
2675    rng: &mut rlx_ir::Philox4x32,
2676) -> usize {
2677    let v = logits.len();
2678    if v == 0 {
2679        return 0;
2680    }
2681    let temp = temperature.max(1e-6);
2682    // Copy + temperature-scale into a working buffer.
2683    let mut scaled: Vec<f32> = logits.iter().map(|&x| x / temp).collect();
2684
2685    // Top-k: zero out everything but the k largest by setting to -inf.
2686    if top_k > 0 && top_k < v {
2687        // Partial selection: find k-th largest then mask below.
2688        let mut indexed: Vec<(usize, f32)> = scaled.iter().copied().enumerate().collect();
2689        // Sort descending; partial would be O(n log k), full sort is fine
2690        // for typical vocab sizes (32k-128k) — single-row work.
2691        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2692        let cutoff = indexed[top_k - 1].1;
2693        for x in scaled.iter_mut() {
2694            if *x < cutoff {
2695                *x = f32::NEG_INFINITY;
2696            }
2697        }
2698    }
2699
2700    // Stable softmax.
2701    let mut max_l = f32::NEG_INFINITY;
2702    for &x in &scaled {
2703        if x > max_l {
2704            max_l = x;
2705        }
2706    }
2707    let mut sum = 0.0f32;
2708    for x in scaled.iter_mut() {
2709        *x = (*x - max_l).exp();
2710        sum += *x;
2711    }
2712    let inv = 1.0 / sum.max(f32::MIN_POSITIVE);
2713    for x in scaled.iter_mut() {
2714        *x *= inv;
2715    }
2716
2717    // Top-p: keep the smallest set of tokens whose cumulative
2718    // probability exceeds top_p (after sorting descending).
2719    if top_p < 1.0 {
2720        let mut indexed: Vec<(usize, f32)> = scaled.iter().copied().enumerate().collect();
2721        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2722        let mut cum = 0.0f32;
2723        let mut keep = vec![false; v];
2724        for (idx, p) in indexed.iter() {
2725            keep[*idx] = true;
2726            cum += *p;
2727            if cum >= top_p {
2728                break;
2729            }
2730        }
2731        let mut new_sum = 0.0f32;
2732        for (i, x) in scaled.iter_mut().enumerate() {
2733            if !keep[i] {
2734                *x = 0.0;
2735            }
2736            new_sum += *x;
2737        }
2738        let inv = 1.0 / new_sum.max(f32::MIN_POSITIVE);
2739        for x in scaled.iter_mut() {
2740            *x *= inv;
2741        }
2742    }
2743
2744    // Multinomial sample via inverse-CDF.
2745    let r = rng.next_f32();
2746    let mut acc = 0.0f32;
2747    for (i, &p) in scaled.iter().enumerate() {
2748        acc += p;
2749        if r <= acc {
2750            return i;
2751        }
2752    }
2753    v - 1 // floating-point edge case fallback
2754}
2755
2756/// Apply a synthetic (kernel-generated) attention mask to a `[q_seq, k_seq]`
2757/// scores matrix. Custom masks are read from a tensor and not handled here.
2758/// `None` is a no-op so callers don't need to special-case it.
2759#[inline]
2760fn apply_synthetic_mask(
2761    scores: &mut [f32],
2762    q_seq: usize,
2763    k_seq: usize,
2764    kind: rlx_ir::op::MaskKind,
2765) {
2766    let neg = crate::config::RuntimeConfig::global().attn_mask_neg_inf;
2767    let q_offset = k_seq.saturating_sub(q_seq);
2768    match kind {
2769        rlx_ir::op::MaskKind::None | rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias => {}
2770        rlx_ir::op::MaskKind::Causal => {
2771            for qi in 0..q_seq {
2772                let abs_q = q_offset + qi;
2773                for ki in (abs_q + 1)..k_seq {
2774                    scores[qi * k_seq + ki] = neg;
2775                }
2776            }
2777        }
2778        rlx_ir::op::MaskKind::SlidingWindow(w) => {
2779            for qi in 0..q_seq {
2780                let abs_q = q_offset + qi;
2781                let lo = abs_q.saturating_sub(w);
2782                for ki in 0..k_seq {
2783                    if ki < lo || ki > abs_q {
2784                        scores[qi * k_seq + ki] = neg;
2785                    }
2786                }
2787            }
2788        }
2789    }
2790}
2791
2792/// NCL `[N,C,L]` or NCHW `[N,C,H,W]` → `(n, c, h, w)` for 2D conv/norm thunks.
2793fn conv_nchw_dims(shape: &Shape) -> (u32, u32, u32, u32) {
2794    match shape.rank() {
2795        3 => (
2796            shape.dim(0).unwrap_static() as u32,
2797            shape.dim(1).unwrap_static() as u32,
2798            1,
2799            shape.dim(2).unwrap_static() as u32,
2800        ),
2801        4 => (
2802            shape.dim(0).unwrap_static() as u32,
2803            shape.dim(1).unwrap_static() as u32,
2804            shape.dim(2).unwrap_static() as u32,
2805            shape.dim(3).unwrap_static() as u32,
2806        ),
2807        r => panic!("conv_nchw_dims: expected rank 3 or 4, got {r}"),
2808    }
2809}
2810
2811/// Compile graph into thunk schedule.
2812pub fn compile_thunks(graph: &Graph, arena: &Arena) -> ThunkSchedule {
2813    compile_thunks_with_rng(graph, arena, rlx_ir::RngOptions::default())
2814}
2815
2816/// Compile graph into thunk schedule with explicit RNG policy.
2817pub fn compile_thunks_with_rng(
2818    graph: &Graph,
2819    arena: &Arena,
2820    rng: rlx_ir::RngOptions,
2821) -> ThunkSchedule {
2822    let rng_shared = Arc::new(std::sync::RwLock::new(rng));
2823    let mut thunks = Vec::with_capacity(graph.len());
2824
2825    // ── Auto-fuse last-two-axis Transpose → MatMul into a trans-Sgemm ──
2826    // Matmul backprop emits `Transpose(operand) → MatMul` for `dA=g·Bᵀ` and
2827    // `dB=Aᵀ·g`; the transpose is a full copy (the dominant backward cost).
2828    // Fold it into cblas trans flags (no copy). Guarded to the safe 2-D F32
2829    // case where the transpose is used only by this matmul.
2830    let mut use_counts: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
2831    for n in graph.nodes() {
2832        for &i in &n.inputs {
2833            *use_counts.entry(i).or_insert(0) += 1;
2834        }
2835    }
2836    let is_t2 = |g: &Graph, id: NodeId| -> bool {
2837        matches!(&g.node(id).op, Op::Transpose { perm } if perm.as_slice() == [1, 0])
2838            && g.node(id).shape.rank() == 2
2839    };
2840    let mut folded_transpose: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
2841    let mut matmul_fold: std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)> =
2842        std::collections::HashMap::new();
2843    for n in graph.nodes() {
2844        if !matches!(n.op, Op::MatMul) {
2845            continue;
2846        }
2847        let (a_id, b_id) = (n.inputs[0], n.inputs[1]);
2848        if graph.node(a_id).shape.rank() != 2
2849            || graph.node(b_id).shape.rank() != 2
2850            || n.shape.dtype() != rlx_ir::DType::F32
2851        {
2852            continue;
2853        }
2854        let fold_a = is_t2(graph, a_id) && use_counts.get(&a_id) == Some(&1);
2855        let fold_b = is_t2(graph, b_id) && use_counts.get(&b_id) == Some(&1);
2856        if !fold_a && !fold_b {
2857            continue;
2858        }
2859        let (asrc, ta) = if fold_a {
2860            (graph.node(a_id).inputs[0], true)
2861        } else {
2862            (a_id, false)
2863        };
2864        let (bsrc, tb) = if fold_b {
2865            (graph.node(b_id).inputs[0], true)
2866        } else {
2867            (b_id, false)
2868        };
2869        matmul_fold.insert(n.id, (asrc, ta, bsrc, tb));
2870        if fold_a {
2871            folded_transpose.insert(a_id);
2872        }
2873        if fold_b {
2874            folded_transpose.insert(b_id);
2875        }
2876    }
2877
2878    // ── Auto-fuse the in-graph SGD-with-momentum update into one kernel ──
2879    // The fully-fused training step appends, per parameter, the chain
2880    //   v' = Add(Mul(vel, momᶜ), grad) ;  p' = Sub(param, Mul(v', lrᶜ))
2881    // where `momᶜ`/`lrᶜ` are full-size constant tensors. That lowers to 4
2882    // `BinaryFull` ops (+2 constants) per param and dominates the step
2883    // (~60% of CPU time on the MLP). Collapse the whole chain into a single
2884    // `SgdMomentum` thunk attached to the `Sub` (p'), which also writes v''s
2885    // slot. Guarded to exactly this shape: both p' and v' are graph outputs,
2886    // the inner nodes have a single in-graph use, and the scalars are
2887    // uniform full-size F32 constants.
2888    let out_set: std::collections::HashSet<NodeId> = graph.outputs.iter().copied().collect();
2889    let const_scalar = |g: &Graph, id: NodeId, n: usize| -> Option<f32> {
2890        if let Op::Constant { data } = &g.node(id).op {
2891            if data.len() == n * 4 {
2892                return Some(f32::from_le_bytes([data[0], data[1], data[2], data[3]]));
2893            }
2894        }
2895        None
2896    };
2897    // p' node → (param, vel, grad, v' node, lr, mom, len)
2898    let mut sgd_fold: std::collections::HashMap<
2899        NodeId,
2900        (NodeId, NodeId, NodeId, NodeId, f32, f32, usize),
2901    > = std::collections::HashMap::new();
2902    let mut sgd_elim: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
2903    for n in graph.nodes() {
2904        // p' = Sub(param, lr_v)
2905        if !matches!(n.op, Op::Binary(BinaryOp::Sub)) || n.shape.dtype() != rlx_ir::DType::F32 {
2906            continue;
2907        }
2908        if !out_set.contains(&n.id) {
2909            continue;
2910        }
2911        let len = match n.shape.num_elements() {
2912            Some(l) => l,
2913            None => continue,
2914        };
2915        let (param, lr_v) = (n.inputs[0], n.inputs[1]);
2916        // lr_v = Mul(v', lrᶜ), single in-graph use
2917        let lrv = graph.node(lr_v);
2918        if !matches!(lrv.op, Op::Binary(BinaryOp::Mul)) || use_counts.get(&lr_v) != Some(&1) {
2919            continue;
2920        }
2921        let (v_new, lr_c) = (lrv.inputs[0], lrv.inputs[1]);
2922        let lr = match const_scalar(graph, lr_c, len) {
2923            Some(v) => v,
2924            None => continue,
2925        };
2926        // v' = Add(v_scaled, grad), graph output, single in-graph use
2927        let vnew = graph.node(v_new);
2928        if !matches!(vnew.op, Op::Binary(BinaryOp::Add))
2929            || use_counts.get(&v_new) != Some(&1)
2930            || !out_set.contains(&v_new)
2931        {
2932            continue;
2933        }
2934        let (v_scaled, grad) = (vnew.inputs[0], vnew.inputs[1]);
2935        // v_scaled = Mul(vel, momᶜ), single in-graph use
2936        let vs = graph.node(v_scaled);
2937        if !matches!(vs.op, Op::Binary(BinaryOp::Mul)) || use_counts.get(&v_scaled) != Some(&1) {
2938            continue;
2939        }
2940        let (vel, mom_c) = (vs.inputs[0], vs.inputs[1]);
2941        let mom = match const_scalar(graph, mom_c, len) {
2942            Some(v) => v,
2943            None => continue,
2944        };
2945        sgd_fold.insert(n.id, (param, vel, grad, v_new, lr, mom, len));
2946        sgd_elim.insert(v_scaled);
2947        sgd_elim.insert(v_new);
2948        sgd_elim.insert(lr_v);
2949    }
2950
2951    for node in graph.nodes() {
2952        // View ops (Reshape / same-dtype Cast / axis-0 Narrow) are aliased
2953        // to their parent's slot by the memory planner — no copy needed.
2954        // Plan #46.
2955        if rlx_opt::is_pure_view(graph, node) {
2956            thunks.push(Thunk::Nop);
2957            continue;
2958        }
2959        // Transpose folded into a downstream matmul's trans flag — skip it.
2960        if folded_transpose.contains(&node.id) {
2961            thunks.push(Thunk::Nop);
2962            continue;
2963        }
2964        // Inner nodes of an SGD chain folded into one SgdMomentum thunk.
2965        if sgd_elim.contains(&node.id) {
2966            thunks.push(Thunk::Nop);
2967            continue;
2968        }
2969        let t = match &node.op {
2970            Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => Thunk::Nop,
2971
2972            Op::FusedMatMulBiasAct { activation } => {
2973                let shape = &node.shape;
2974                let n = shape.dim(shape.rank() - 1).unwrap_static();
2975                let total = shape.num_elements().unwrap();
2976                let m = total / n;
2977                let a_len = get_len(graph, node.inputs[0]);
2978                let k = a_len / m;
2979                Thunk::FusedMmBiasAct {
2980                    a: node_offset(arena, node.inputs[0]),
2981                    w: node_offset(arena, node.inputs[1]),
2982                    bias: node_offset(arena, node.inputs[2]),
2983                    c: node_offset(arena, node.id),
2984                    m: m as u32,
2985                    k: k as u32,
2986                    n: n as u32,
2987                    act: *activation,
2988                }
2989            }
2990
2991            Op::FusedResidualLN { has_bias, eps } => {
2992                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
2993                let total = node.shape.num_elements().unwrap();
2994                let rows = total / h;
2995                let (g_idx, b_idx) = if *has_bias { (3, 4) } else { (2, 3) };
2996                Thunk::FusedResidualLN {
2997                    x: node_offset(arena, node.inputs[0]),
2998                    res: node_offset(arena, node.inputs[1]),
2999                    bias: if *has_bias {
3000                        node_offset(arena, node.inputs[2])
3001                    } else {
3002                        0
3003                    },
3004                    g: node_offset(arena, node.inputs[g_idx]),
3005                    b: node_offset(arena, node.inputs[b_idx]),
3006                    out: node_offset(arena, node.id),
3007                    rows: rows as u32,
3008                    h: h as u32,
3009                    eps: *eps,
3010                    has_bias: *has_bias,
3011                }
3012            }
3013
3014            Op::FusedResidualRmsNorm { has_bias, eps } => {
3015                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
3016                let total = node.shape.num_elements().unwrap();
3017                let rows = total / h;
3018                let (g_idx, b_idx) = if *has_bias { (3, 4) } else { (2, 3) };
3019                Thunk::FusedResidualRmsNorm {
3020                    x: node_offset(arena, node.inputs[0]),
3021                    res: node_offset(arena, node.inputs[1]),
3022                    bias: if *has_bias {
3023                        node_offset(arena, node.inputs[2])
3024                    } else {
3025                        0
3026                    },
3027                    g: node_offset(arena, node.inputs[g_idx]),
3028                    b: node_offset(arena, node.inputs[b_idx]),
3029                    out: node_offset(arena, node.id),
3030                    rows: rows as u32,
3031                    h: h as u32,
3032                    eps: *eps,
3033                    has_bias: *has_bias,
3034                }
3035            }
3036
3037            Op::MatMul => {
3038                let shape = &node.shape;
3039                let a_shape = &graph.node(node.inputs[0]).shape;
3040                let b_shape = &graph.node(node.inputs[1]).shape;
3041                // Prefer inferred matmul shape from operands — ONNX bundle
3042                // meta often over-ranks outputs (e.g. [seq, seq, H]).
3043                let eff =
3044                    rlx_ir::shape::matmul_shape(a_shape, b_shape).unwrap_or_else(|_| shape.clone());
3045                let rank = eff.rank().max(2);
3046                let n = eff.dim(rank - 1).unwrap_static();
3047                let k_dim = a_shape.dim(a_shape.rank().max(2) - 1).unwrap_static();
3048                if shape.dtype() == rlx_ir::DType::C64 {
3049                    // Complex GEMM (interleaved re/im). Handles 2D and
3050                    // 3D×2D (flatten M); both-operand batched C64 is not
3051                    // yet wired.
3052                    let both = a_shape.rank() >= 3 && b_shape.rank() >= 3;
3053                    assert!(!both, "batched (both-operand) C64 matmul not yet supported");
3054                    let m: usize = if a_shape.rank() >= 3 {
3055                        (0..a_shape.rank() - 1)
3056                            .map(|d| a_shape.dim(d).unwrap_static())
3057                            .product()
3058                    } else {
3059                        a_shape.dim(a_shape.rank() - 2).unwrap_static()
3060                    };
3061                    Thunk::CgemmC64 {
3062                        a: node_offset(arena, node.inputs[0]),
3063                        b: node_offset(arena, node.inputs[1]),
3064                        c: node_offset(arena, node.id),
3065                        m: m as u32,
3066                        k: k_dim as u32,
3067                        n: n as u32,
3068                    }
3069                } else {
3070                    // Batched GEMM only when both operands carry batch dimensions.
3071                    // 3D×2D (activations × shared weight) must flatten to one Sgemm.
3072                    let both_batched = a_shape.rank() >= 3 && b_shape.rank() >= 3;
3073                    let batched_3d =
3074                        rank >= 3 && both_batched && a_shape.rank() + b_shape.rank() > 4;
3075                    if batched_3d && shape.dtype() == rlx_ir::DType::F64 {
3076                        let mut batch_prod = 1usize;
3077                        for d in 0..rank - 2 {
3078                            batch_prod *= eff.dim(d).unwrap_static();
3079                        }
3080                        let m_dim = eff.dim(rank - 2).unwrap_static();
3081                        Thunk::BatchedDgemmF64 {
3082                            a: node_offset(arena, node.inputs[0]),
3083                            b: node_offset(arena, node.inputs[1]),
3084                            c: node_offset(arena, node.id),
3085                            batch: batch_prod as u32,
3086                            m: m_dim as u32,
3087                            k: k_dim as u32,
3088                            n: n as u32,
3089                        }
3090                    } else if batched_3d && shape.dtype() == rlx_ir::DType::F32 {
3091                        let mut batch_prod = 1usize;
3092                        for d in 0..rank - 2 {
3093                            batch_prod *= eff.dim(d).unwrap_static();
3094                        }
3095                        let m_dim = eff.dim(rank - 2).unwrap_static();
3096                        Thunk::BatchedSgemm {
3097                            a: node_offset(arena, node.inputs[0]),
3098                            b: node_offset(arena, node.inputs[1]),
3099                            c: node_offset(arena, node.id),
3100                            batch: batch_prod as u32,
3101                            m: m_dim as u32,
3102                            k: k_dim as u32,
3103                            n: n as u32,
3104                        }
3105                    } else {
3106                        let m = if a_shape.rank() >= 3 && b_shape.rank() <= 2 {
3107                            let mut m_prod = 1usize;
3108                            for d in 0..a_shape.rank() - 1 {
3109                                m_prod *= a_shape.dim(d).unwrap_static();
3110                            }
3111                            m_prod
3112                        } else if a_shape.rank() >= 2 {
3113                            a_shape.dim(a_shape.rank() - 2).unwrap_static()
3114                        } else {
3115                            eff.num_elements().unwrap_or(1) / n.max(1)
3116                        };
3117                        match shape.dtype() {
3118                            rlx_ir::DType::F64 => Thunk::Dgemm {
3119                                a: node_offset(arena, node.inputs[0]),
3120                                b: node_offset(arena, node.inputs[1]),
3121                                c: node_offset(arena, node.id),
3122                                m: m as u32,
3123                                k: k_dim as u32,
3124                                n: n as u32,
3125                            },
3126                            _ => {
3127                                if let Some(&(asrc, ta, bsrc, tb)) = matmul_fold.get(&node.id) {
3128                                    // Folded Transpose→MatMul: read pre-transpose
3129                                    // operands, do the transpose via cblas flags.
3130                                    Thunk::SgemmT {
3131                                        a: node_offset(arena, asrc),
3132                                        b: node_offset(arena, bsrc),
3133                                        c: node_offset(arena, node.id),
3134                                        m: m as u32,
3135                                        k: k_dim as u32,
3136                                        n: n as u32,
3137                                        ta,
3138                                        tb,
3139                                    }
3140                                } else {
3141                                    Thunk::Sgemm {
3142                                        a: node_offset(arena, node.inputs[0]),
3143                                        b: node_offset(arena, node.inputs[1]),
3144                                        c: node_offset(arena, node.id),
3145                                        m: m as u32,
3146                                        k: k_dim as u32,
3147                                        n: n as u32,
3148                                    }
3149                                }
3150                            }
3151                        }
3152                    }
3153                }
3154            }
3155
3156            Op::Binary(op) => {
3157                if let Some(&(param, vel, grad, v_new, lr, mom, len)) = sgd_fold.get(&node.id) {
3158                    // Folded SGD-momentum: this `Sub` (p') drives a single
3159                    // SgdMomentum thunk that also writes v''s slot.
3160                    thunks.push(Thunk::SgdMomentum {
3161                        param: node_offset(arena, param),
3162                        vel: node_offset(arena, vel),
3163                        grad: node_offset(arena, grad),
3164                        p_out: node_offset(arena, node.id),
3165                        v_out: node_offset(arena, v_new),
3166                        lr,
3167                        mom,
3168                        len: len as u32,
3169                    });
3170                    continue;
3171                }
3172                let lhs_len = get_len(graph, node.inputs[0]);
3173                let rhs_len = get_len(graph, node.inputs[1]);
3174                let out_len = node.shape.num_elements().unwrap();
3175                if node.shape.dtype() == rlx_ir::DType::C64 {
3176                    // Native C64 element-wise. Add/Sub/Mul/Div lower
3177                    // to `BinaryFullC64`; the rest don't have a
3178                    // single natural complex definition.
3179                    match op {
3180                        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {}
3181                        BinaryOp::Max | BinaryOp::Min | BinaryOp::Pow => panic!(
3182                            "Op::Binary({op:?}) on DType::C64: complex \
3183                             max/min/pow have no single natural definition \
3184                             — caller should drop to 2N-real-block (see \
3185                             spike-ac) and pick a convention there"
3186                        ),
3187                    }
3188                }
3189                // Compute broadcast strides for the slow path. Empty
3190                // vectors when no broadcast is needed (the fast-path
3191                // kernel ignores them anyway).
3192                let (out_dims_bcast, bcast_lhs_strides, bcast_rhs_strides) =
3193                    if lhs_len == out_len && rhs_len == out_len {
3194                        (Vec::new(), Vec::new(), Vec::new())
3195                    } else {
3196                        let lhs_dims = get_static_dims(graph, node.inputs[0]);
3197                        let rhs_dims = get_static_dims(graph, node.inputs[1]);
3198                        let out_dims_v = get_static_dims(graph, node.id);
3199                        if lhs_dims.is_empty() || rhs_dims.is_empty() || out_dims_v.is_empty() {
3200                            // Dynamic shape — fall back to the legacy
3201                            // modulo path (correct for scalar / last-
3202                            // axis broadcast, which is the only
3203                            // dynamic case in practice).
3204                            (Vec::new(), Vec::new(), Vec::new())
3205                        } else {
3206                            let ls = broadcast_strides(&lhs_dims, &out_dims_v);
3207                            let rs = broadcast_strides(&rhs_dims, &out_dims_v);
3208                            let od: Vec<u32> = out_dims_v.iter().map(|x| *x as u32).collect();
3209                            (od, ls, rs)
3210                        }
3211                    };
3212                if node.shape.dtype() == rlx_ir::DType::C64 {
3213                    Thunk::BinaryFullC64 {
3214                        lhs: node_offset(arena, node.inputs[0]),
3215                        rhs: node_offset(arena, node.inputs[1]),
3216                        dst: node_offset(arena, node.id),
3217                        len: out_len as u32,
3218                        lhs_len: lhs_len as u32,
3219                        rhs_len: rhs_len as u32,
3220                        op: *op,
3221                        out_dims_bcast,
3222                        bcast_lhs_strides,
3223                        bcast_rhs_strides,
3224                    }
3225                } else if node.shape.dtype() == rlx_ir::DType::F64 {
3226                    // f64 path — no BiasAdd fast-path (yet); use the
3227                    // general binary-with-broadcast kernel.
3228                    Thunk::BinaryFullF64 {
3229                        lhs: node_offset(arena, node.inputs[0]),
3230                        rhs: node_offset(arena, node.inputs[1]),
3231                        dst: node_offset(arena, node.id),
3232                        len: out_len as u32,
3233                        lhs_len: lhs_len as u32,
3234                        rhs_len: rhs_len as u32,
3235                        op: *op,
3236                        out_dims_bcast,
3237                        bcast_lhs_strides,
3238                        bcast_rhs_strides,
3239                    }
3240                } else if matches!(op, BinaryOp::Add)
3241                    && rhs_len < out_len
3242                    && out_len % rhs_len == 0
3243                    && is_trailing_bias_broadcast(
3244                        graph.node(node.inputs[1]).shape.dims(),
3245                        graph.node(node.id).shape.dims(),
3246                    )
3247                {
3248                    // `BiasAdd` is only correct when the bias is a
3249                    // *trailing* broadcast — rhs dims match the right-
3250                    // hand side of the output dims (with size-1 only
3251                    // allowed in left-padded outer positions).
3252                    // SAM's rel-pos `[bh, h, w, 1, w] + [bh, h, w, h, w]`
3253                    // has rhs_len divide out_len cleanly but is a
3254                    // mid-shape singleton, NOT a trailing broadcast.
3255                    // Routing it through BiasAdd silently treats it as
3256                    // last-`rhs_len`-cols repeated — wrong values.
3257                    Thunk::BiasAdd {
3258                        src: node_offset(arena, node.inputs[0]),
3259                        bias: node_offset(arena, node.inputs[1]),
3260                        dst: node_offset(arena, node.id),
3261                        m: (out_len / rhs_len) as u32,
3262                        n: rhs_len as u32,
3263                    }
3264                } else {
3265                    let lhs_len = get_len(graph, node.inputs[0]);
3266                    Thunk::BinaryFull {
3267                        lhs: node_offset(arena, node.inputs[0]),
3268                        rhs: node_offset(arena, node.inputs[1]),
3269                        dst: node_offset(arena, node.id),
3270                        len: out_len as u32,
3271                        lhs_len: lhs_len as u32,
3272                        rhs_len: rhs_len as u32,
3273                        op: *op,
3274                        out_dims_bcast,
3275                        bcast_lhs_strides,
3276                        bcast_rhs_strides,
3277                        elem_bytes: node.shape.dtype().size_bytes() as u8,
3278                    }
3279                }
3280            }
3281
3282            Op::Activation(act) => {
3283                let len = node.shape.num_elements().unwrap();
3284                let in_off = node_offset(arena, node.inputs[0]);
3285                let out_off = node_offset(arena, node.id);
3286                if node.shape.dtype() == rlx_ir::DType::C64 {
3287                    // Only Neg/Exp/Log/Sqrt have natural complex
3288                    // extensions used in signal-processing graphs.
3289                    // Everything else (Sigmoid, Tanh, Relu, Abs,
3290                    // Sin/Cos/Tan/Atan, Round, GeLU family) is rejected.
3291                    match act {
3292                        Activation::Neg | Activation::Exp | Activation::Log | Activation::Sqrt => {}
3293                        other => panic!(
3294                            "Op::Activation({other:?}) on DType::C64: no \
3295                             natural complex extension — supported on C64: \
3296                             Neg, Exp, Log, Sqrt"
3297                        ),
3298                    }
3299                    Thunk::ActivationC64 {
3300                        src: in_off,
3301                        dst: out_off,
3302                        len: len as u32,
3303                        kind: *act,
3304                    }
3305                } else if node.shape.dtype() == rlx_ir::DType::F64 {
3306                    Thunk::ActivationF64 {
3307                        src: in_off,
3308                        dst: out_off,
3309                        len: len as u32,
3310                        kind: *act,
3311                    }
3312                } else if in_off == out_off {
3313                    // ActivationInPlace operates on a single buffer. When the
3314                    // planner has assigned input and output the same slot
3315                    // (typical post-fusion case), we just run on that slot.
3316                    Thunk::ActivationInPlace {
3317                        data: out_off,
3318                        len: len as u32,
3319                        act: *act,
3320                    }
3321                } else {
3322                    // Two-step: copy input → output, then activate output in place.
3323                    // The schedule executes them in this order; downstream
3324                    // thunks see the activated output at out_off.
3325                    thunks.push(Thunk::Copy {
3326                        src: in_off,
3327                        dst: out_off,
3328                        len: len as u32,
3329                    });
3330                    Thunk::ActivationInPlace {
3331                        data: out_off,
3332                        len: len as u32,
3333                        act: *act,
3334                    }
3335                }
3336            }
3337
3338            Op::Gather { axis } if *axis == 0 => {
3339                let table_shape = &graph.node(node.inputs[0]).shape;
3340                let table_total = table_shape.num_elements().unwrap();
3341                let trailing: usize = (1..table_shape.rank())
3342                    .map(|i| table_shape.dim(i).unwrap_static())
3343                    .product();
3344                let idx_len = get_len(graph, node.inputs[1]);
3345                let idx_i64 =
3346                    u8::from(graph.node(node.inputs[1]).shape.dtype() == rlx_ir::DType::I64);
3347                let table_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
3348                Thunk::Gather {
3349                    table: node_offset(arena, node.inputs[0]),
3350                    table_len: table_total as u32,
3351                    idx: node_offset(arena, node.inputs[1]),
3352                    dst: node_offset(arena, node.id),
3353                    num_idx: idx_len as u32,
3354                    trailing: trailing as u32,
3355                    idx_i64,
3356                    table_bytes,
3357                }
3358            }
3359
3360            Op::Gather { axis } => {
3361                // Non-zero axis: outer × num_idx × trailing layout.
3362                let table_shape = &graph.node(node.inputs[0]).shape;
3363                let rank = table_shape.rank();
3364                let outer: usize = (0..*axis)
3365                    .map(|i| table_shape.dim(i).unwrap_static())
3366                    .product::<usize>()
3367                    .max(1);
3368                let trailing: usize = (*axis + 1..rank)
3369                    .map(|i| table_shape.dim(i).unwrap_static())
3370                    .product::<usize>()
3371                    .max(1);
3372                let axis_dim = table_shape.dim(*axis).unwrap_static();
3373                let idx_len = get_len(graph, node.inputs[1]);
3374                let idx_i64 =
3375                    u8::from(graph.node(node.inputs[1]).shape.dtype() == rlx_ir::DType::I64);
3376                let table_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
3377                Thunk::GatherAxis {
3378                    table: node_offset(arena, node.inputs[0]),
3379                    idx: node_offset(arena, node.inputs[1]),
3380                    dst: node_offset(arena, node.id),
3381                    outer: outer as u32,
3382                    axis_dim: axis_dim as u32,
3383                    num_idx: idx_len as u32,
3384                    trailing: trailing as u32,
3385                    idx_i64,
3386                    table_bytes,
3387                }
3388            }
3389
3390            Op::Narrow { axis, start, len } => {
3391                let in_shape = &graph.node(node.inputs[0]).shape;
3392                let elem_bytes = in_shape.dtype().size_bytes() as u8;
3393                let rank = in_shape.rank();
3394                let outer: usize = (0..*axis)
3395                    .map(|i| in_shape.dim(i).unwrap_static())
3396                    .product::<usize>()
3397                    .max(1);
3398                let inner: usize = (*axis + 1..rank)
3399                    .map(|i| in_shape.dim(i).unwrap_static())
3400                    .product::<usize>()
3401                    .max(1);
3402                let in_axis = in_shape.dim(*axis).unwrap_static();
3403                let src_byte_offset =
3404                    node_offset(arena, node.inputs[0]) + start * inner * elem_bytes as usize;
3405                Thunk::Narrow {
3406                    src: src_byte_offset,
3407                    dst: node_offset(arena, node.id),
3408                    outer: outer as u32,
3409                    src_stride: (in_axis * inner) as u32, // elements per outer step in source
3410                    dst_stride: (*len * inner) as u32,    // elements per outer step in dest
3411                    inner: (*len * inner) as u32,         // elements to copy per outer step
3412                    elem_bytes,
3413                }
3414            }
3415
3416            Op::Reverse { axes } => {
3417                let in_shape = &graph.node(node.inputs[0]).shape;
3418                let rank = in_shape.rank();
3419                let dims: Vec<u32> = (0..rank)
3420                    .map(|i| in_shape.dim(i).unwrap_static() as u32)
3421                    .collect();
3422                let mut rev_mask = vec![false; rank];
3423                for &a in axes {
3424                    if a < rank {
3425                        rev_mask[a] = true;
3426                    }
3427                }
3428                Thunk::Reverse {
3429                    src: node_offset(arena, node.inputs[0]),
3430                    dst: node_offset(arena, node.id),
3431                    dims,
3432                    rev_mask,
3433                    elem_bytes: in_shape.dtype().size_bytes() as u8,
3434                }
3435            }
3436
3437            Op::Reshape { .. } | Op::StopGradient => {
3438                // Pure layout change: same total element count, plain copy.
3439                let len = node.shape.num_elements().unwrap();
3440                let src = node_offset(arena, node.inputs[0]);
3441                let dst = node_offset(arena, node.id);
3442                match node.shape.dtype() {
3443                    rlx_ir::DType::F64 => Thunk::CopyF64 {
3444                        src,
3445                        dst,
3446                        len: len as u32,
3447                    },
3448                    rlx_ir::DType::I64 => Thunk::CopyI64 {
3449                        src,
3450                        dst,
3451                        len: len as u32,
3452                    },
3453                    _ => Thunk::Copy {
3454                        src,
3455                        dst,
3456                        len: len as u32,
3457                    },
3458                }
3459            }
3460
3461            Op::Cast { to } => {
3462                let in_node = graph.node(node.inputs[0]);
3463                let in_dtype = in_node.shape.dtype();
3464                let out_dtype = *to;
3465                let len = node.shape.num_elements().unwrap();
3466                let src = node_offset(arena, node.inputs[0]);
3467                let dst = node_offset(arena, node.id);
3468                if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::I64 {
3469                    Thunk::CastF32ToI64 {
3470                        src,
3471                        dst,
3472                        len: len as u32,
3473                    }
3474                } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::F64 {
3475                    Thunk::CastF32ToF64 {
3476                        src,
3477                        dst,
3478                        len: len as u32,
3479                    }
3480                } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::I32 {
3481                    Thunk::CastF32ToI32 {
3482                        src,
3483                        dst,
3484                        len: len as u32,
3485                    }
3486                } else if in_dtype == rlx_ir::DType::I64 && out_dtype == rlx_ir::DType::F32 {
3487                    Thunk::CastI64ToF32 {
3488                        src,
3489                        dst,
3490                        len: len as u32,
3491                    }
3492                } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::I32 {
3493                    Thunk::CastBoolToI32 {
3494                        src,
3495                        dst,
3496                        len: len as u32,
3497                    }
3498                } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::F32 {
3499                    // Bool is 1 byte; the generic f32 Copy below would misread it as
3500                    // 4-byte f32. VITS sequence masks are `Cast(Less(...), f32)`.
3501                    Thunk::CastBoolToF32 {
3502                        src,
3503                        dst,
3504                        len: len as u32,
3505                    }
3506                } else if in_dtype == rlx_ir::DType::I32 && out_dtype == rlx_ir::DType::F32 {
3507                    Thunk::CastI32ToF32 {
3508                        src,
3509                        dst,
3510                        len: len as u32,
3511                    }
3512                } else if in_dtype == out_dtype {
3513                    match out_dtype {
3514                        rlx_ir::DType::F64 => Thunk::CopyF64 {
3515                            src,
3516                            dst,
3517                            len: len as u32,
3518                        },
3519                        rlx_ir::DType::I64 => Thunk::CopyI64 {
3520                            src,
3521                            dst,
3522                            len: len as u32,
3523                        },
3524                        _ => Thunk::Copy {
3525                            src,
3526                            dst,
3527                            len: len as u32,
3528                        },
3529                    }
3530                } else {
3531                    Thunk::Copy {
3532                        src,
3533                        dst,
3534                        len: len as u32,
3535                    }
3536                }
3537            }
3538
3539            Op::Quantize {
3540                axis,
3541                scales,
3542                zero_points,
3543            } => {
3544                let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
3545                Thunk::Quantize {
3546                    x: node_offset(arena, node.inputs[0]),
3547                    q: node_offset(arena, node.id),
3548                    len: node.shape.num_elements().unwrap() as u32,
3549                    chan_axis: chan_axis as u32,
3550                    chan_dim: chan_dim as u32,
3551                    inner: inner as u32,
3552                    scales: scales.clone(),
3553                    zero_points: zero_points.clone(),
3554                }
3555            }
3556
3557            Op::FakeQuantize {
3558                bits,
3559                axis,
3560                ste,
3561                scale_mode,
3562            } => {
3563                let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
3564                let state_off = match scale_mode {
3565                    rlx_ir::op::ScaleMode::PerBatch => None,
3566                    rlx_ir::op::ScaleMode::EMA { .. } | rlx_ir::op::ScaleMode::Fixed => {
3567                        // Second input carries the [chan_dim] scale state.
3568                        debug_assert_eq!(
3569                            node.inputs.len(),
3570                            2,
3571                            "EMA/Fixed FakeQuantize needs a state input"
3572                        );
3573                        Some(node_offset(arena, node.inputs[1]))
3574                    }
3575                };
3576                Thunk::FakeQuantize {
3577                    x: node_offset(arena, node.inputs[0]),
3578                    out: node_offset(arena, node.id),
3579                    len: node.shape.num_elements().unwrap() as u32,
3580                    chan_axis: chan_axis as u32,
3581                    chan_dim: chan_dim as u32,
3582                    inner: inner as u32,
3583                    bits: *bits,
3584                    ste: *ste,
3585                    scale_mode: *scale_mode,
3586                    state_off,
3587                }
3588            }
3589
3590            Op::FakeQuantizeLSQ { bits, axis } => {
3591                let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
3592                Thunk::FakeQuantizeLSQ {
3593                    x: node_offset(arena, node.inputs[0]),
3594                    scale_off: node_offset(arena, node.inputs[1]),
3595                    out: node_offset(arena, node.id),
3596                    len: node.shape.num_elements().unwrap() as u32,
3597                    chan_axis: chan_axis as u32,
3598                    chan_dim: chan_dim as u32,
3599                    inner: inner as u32,
3600                    bits: *bits,
3601                }
3602            }
3603
3604            Op::FakeQuantizeLSQBackwardX { bits, axis } => {
3605                let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
3606                Thunk::FakeQuantizeLSQBackwardX {
3607                    x: node_offset(arena, node.inputs[0]),
3608                    scale_off: node_offset(arena, node.inputs[1]),
3609                    dy: node_offset(arena, node.inputs[2]),
3610                    dx: node_offset(arena, node.id),
3611                    len: node.shape.num_elements().unwrap() as u32,
3612                    chan_axis: chan_axis as u32,
3613                    chan_dim: chan_dim as u32,
3614                    inner: inner as u32,
3615                    bits: *bits,
3616                }
3617            }
3618
3619            Op::FakeQuantizeLSQBackwardScale { bits, axis } => {
3620                // Output shape is [chan_dim] — node.shape doesn't
3621                // describe the input data layout, but inputs[0] does.
3622                let in_shape = &graph.node(node.inputs[0]).shape;
3623                let (chan_axis, chan_dim, inner) = quant_layout(in_shape, *axis);
3624                Thunk::FakeQuantizeLSQBackwardScale {
3625                    x: node_offset(arena, node.inputs[0]),
3626                    scale_off: node_offset(arena, node.inputs[1]),
3627                    dy: node_offset(arena, node.inputs[2]),
3628                    dscale: node_offset(arena, node.id),
3629                    len: in_shape.num_elements().unwrap() as u32,
3630                    chan_axis: chan_axis as u32,
3631                    chan_dim: chan_dim as u32,
3632                    inner: inner as u32,
3633                    bits: *bits,
3634                }
3635            }
3636
3637            Op::FakeQuantizeBackward { bits, axis, ste } => {
3638                let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
3639                Thunk::FakeQuantizeBackward {
3640                    x: node_offset(arena, node.inputs[0]),
3641                    dy: node_offset(arena, node.inputs[1]),
3642                    dx: node_offset(arena, node.id),
3643                    len: node.shape.num_elements().unwrap() as u32,
3644                    chan_axis: chan_axis as u32,
3645                    chan_dim: chan_dim as u32,
3646                    inner: inner as u32,
3647                    bits: *bits,
3648                    ste: *ste,
3649                }
3650            }
3651
3652            Op::Dequantize {
3653                axis,
3654                scales,
3655                zero_points,
3656            } => {
3657                let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
3658                Thunk::Dequantize {
3659                    q: node_offset(arena, node.inputs[0]),
3660                    x: node_offset(arena, node.id),
3661                    len: node.shape.num_elements().unwrap() as u32,
3662                    chan_axis: chan_axis as u32,
3663                    chan_dim: chan_dim as u32,
3664                    inner: inner as u32,
3665                    scales: scales.clone(),
3666                    zero_points: zero_points.clone(),
3667                }
3668            }
3669
3670            Op::Expand { .. } => {
3671                // Broadcast: build per-output-dim strides where any input dim
3672                // of size 1 has stride 0 (read the same element repeatedly).
3673                // Reuses the Thunk::Transpose runtime — N-D walk with strides
3674                // is identical; only the strides differ.
3675                let in_shape = &graph.node(node.inputs[0]).shape;
3676                let out_shape = &node.shape;
3677                let in_rank = in_shape.rank();
3678                let out_rank = out_shape.rank();
3679                // Implicit leading 1s if input has lower rank.
3680                let pad = out_rank.saturating_sub(in_rank);
3681                let in_dims: Vec<usize> = (0..out_rank)
3682                    .map(|i| {
3683                        if i < pad {
3684                            1
3685                        } else {
3686                            in_shape.dim(i - pad).unwrap_static()
3687                        }
3688                    })
3689                    .collect();
3690                // Row-major input strides (over the padded shape).
3691                let mut in_strides_full = vec![1usize; out_rank];
3692                for d in (0..out_rank.saturating_sub(1)).rev() {
3693                    in_strides_full[d] = in_strides_full[d + 1] * in_dims[d + 1];
3694                }
3695                let out_dims: Vec<u32> = (0..out_rank)
3696                    .map(|i| out_shape.dim(i).unwrap_static() as u32)
3697                    .collect();
3698                // Stride is 0 for broadcast dims (in_dim == 1 && out_dim > 1).
3699                let in_strides: Vec<u32> = (0..out_rank)
3700                    .map(|i| {
3701                        if in_dims[i] == 1 && (out_dims[i] as usize) > 1 {
3702                            0
3703                        } else {
3704                            in_strides_full[i] as u32
3705                        }
3706                    })
3707                    .collect();
3708                let in_total = in_dims.iter().product::<usize>() as u32;
3709                let src = node_offset(arena, node.inputs[0]);
3710                let dst = node_offset(arena, node.id);
3711                let elem_bytes = node.shape.dtype().size_bytes() as u8;
3712                match node.shape.dtype() {
3713                    rlx_ir::DType::F64 => Thunk::TransposeF64 {
3714                        src,
3715                        dst,
3716                        in_total,
3717                        out_dims,
3718                        in_strides,
3719                    },
3720                    _ => Thunk::Transpose {
3721                        src,
3722                        dst,
3723                        in_total,
3724                        out_dims,
3725                        in_strides,
3726                        elem_bytes,
3727                    },
3728                }
3729            }
3730
3731            Op::RmsNorm { eps, .. } => {
3732                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
3733                let total = node.shape.num_elements().unwrap();
3734                Thunk::RmsNorm {
3735                    src: node_offset(arena, node.inputs[0]),
3736                    g: node_offset(arena, node.inputs[1]),
3737                    b: node_offset(arena, node.inputs[2]),
3738                    dst: node_offset(arena, node.id),
3739                    rows: (total / h) as u32,
3740                    h: h as u32,
3741                    eps: *eps,
3742                }
3743            }
3744
3745            Op::LayerNorm { eps, .. } => {
3746                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
3747                let total = node.shape.num_elements().unwrap();
3748                Thunk::LayerNorm {
3749                    src: node_offset(arena, node.inputs[0]),
3750                    g: node_offset(arena, node.inputs[1]),
3751                    b: node_offset(arena, node.inputs[2]),
3752                    dst: node_offset(arena, node.id),
3753                    rows: (total / h) as u32,
3754                    h: h as u32,
3755                    eps: *eps,
3756                }
3757            }
3758
3759            Op::GroupNorm { num_groups, eps } => {
3760                let in_shape = &graph.node(node.inputs[0]).shape;
3761                let (n, c, h, w) = conv_nchw_dims(in_shape);
3762                Thunk::GroupNorm {
3763                    src: node_offset(arena, node.inputs[0]),
3764                    g: node_offset(arena, node.inputs[1]),
3765                    b: node_offset(arena, node.inputs[2]),
3766                    dst: node_offset(arena, node.id),
3767                    n,
3768                    c,
3769                    h,
3770                    w,
3771                    num_groups: *num_groups as u32,
3772                    eps: *eps,
3773                }
3774            }
3775
3776            Op::BatchNormInference { eps } => {
3777                let in_shape = &graph.node(node.inputs[0]).shape;
3778                let rank = in_shape.rank();
3779                let channels = in_shape.dim(rank - 1).unwrap_static();
3780                let total = in_shape.num_elements().unwrap_or(0);
3781                let count = (total / channels.max(1)) as u32;
3782                Thunk::BatchNormInference {
3783                    src: node_offset(arena, node.inputs[0]),
3784                    g: node_offset(arena, node.inputs[1]),
3785                    b: node_offset(arena, node.inputs[2]),
3786                    mean: node_offset(arena, node.inputs[3]),
3787                    var: node_offset(arena, node.inputs[4]),
3788                    dst: node_offset(arena, node.id),
3789                    count,
3790                    channels: channels as u32,
3791                    eps: *eps,
3792                }
3793            }
3794
3795            Op::BatchNormInferenceBackwardInput { eps } => {
3796                let x_shape = &graph.node(node.inputs[0]).shape;
3797                let rank = x_shape.rank();
3798                let channels = x_shape.dim(rank - 1).unwrap_static();
3799                let total = x_shape.num_elements().unwrap_or(0);
3800                Thunk::BatchNormInferenceBackwardInput {
3801                    x: node_offset(arena, node.inputs[0]),
3802                    gamma: node_offset(arena, node.inputs[1]),
3803                    mean: node_offset(arena, node.inputs[2]),
3804                    var: node_offset(arena, node.inputs[3]),
3805                    dy: node_offset(arena, node.inputs[4]),
3806                    dx: node_offset(arena, node.id),
3807                    count: (total / channels.max(1)) as u32,
3808                    channels: channels as u32,
3809                    eps: *eps,
3810                }
3811            }
3812
3813            Op::BatchNormInferenceBackwardGamma { eps } => {
3814                let x_shape = &graph.node(node.inputs[0]).shape;
3815                let rank = x_shape.rank();
3816                let channels = x_shape.dim(rank - 1).unwrap_static();
3817                let total = x_shape.num_elements().unwrap_or(0);
3818                let _gamma_shape = &graph.node(node.id).shape;
3819                Thunk::BatchNormInferenceBackwardGamma {
3820                    x: node_offset(arena, node.inputs[0]),
3821                    mean: node_offset(arena, node.inputs[1]),
3822                    var: node_offset(arena, node.inputs[2]),
3823                    dy: node_offset(arena, node.inputs[3]),
3824                    dgamma: node_offset(arena, node.id),
3825                    count: (total / channels.max(1)) as u32,
3826                    channels: channels as u32,
3827                    eps: *eps,
3828                }
3829            }
3830
3831            Op::BatchNormInferenceBackwardBeta => {
3832                let dy_shape = &graph.node(node.inputs[0]).shape;
3833                let rank = dy_shape.rank();
3834                let channels = dy_shape.dim(rank - 1).unwrap_static();
3835                let total = dy_shape.num_elements().unwrap_or(0);
3836                Thunk::BatchNormInferenceBackwardBeta {
3837                    dy: node_offset(arena, node.inputs[0]),
3838                    dbeta: node_offset(arena, node.id),
3839                    count: (total / channels.max(1)) as u32,
3840                    channels: channels as u32,
3841                }
3842            }
3843
3844            Op::LayerNorm2d { eps } => {
3845                let in_shape = &graph.node(node.inputs[0]).shape;
3846                let (n, c, h, w) = conv_nchw_dims(in_shape);
3847                Thunk::LayerNorm2d {
3848                    src: node_offset(arena, node.inputs[0]),
3849                    g: node_offset(arena, node.inputs[1]),
3850                    b: node_offset(arena, node.inputs[2]),
3851                    dst: node_offset(arena, node.id),
3852                    n,
3853                    c,
3854                    h,
3855                    w,
3856                    eps: *eps,
3857                }
3858            }
3859
3860            Op::ConvTranspose2d {
3861                kernel_size,
3862                stride,
3863                padding,
3864                dilation,
3865                output_padding: _,
3866                groups,
3867            } => {
3868                let in_shape = &graph.node(node.inputs[0]).shape;
3869                let out_shape = &node.shape;
3870                let (n, c_in, h, w_in) = conv_nchw_dims(in_shape);
3871                let (_, c_out, h_out, w_out) = conv_nchw_dims(out_shape);
3872                Thunk::ConvTranspose2d {
3873                    src: node_offset(arena, node.inputs[0]),
3874                    weight: node_offset(arena, node.inputs[1]),
3875                    dst: node_offset(arena, node.id),
3876                    n,
3877                    c_in,
3878                    h,
3879                    w_in,
3880                    c_out,
3881                    h_out,
3882                    w_out,
3883                    kh: kernel_size[0] as u32,
3884                    kw: kernel_size[1] as u32,
3885                    sh: stride.first().copied().unwrap_or(1) as u32,
3886                    sw: stride.get(1).copied().unwrap_or(1) as u32,
3887                    ph: padding.first().copied().unwrap_or(0) as u32,
3888                    pw: padding.get(1).copied().unwrap_or(0) as u32,
3889                    dh: dilation.first().copied().unwrap_or(1) as u32,
3890                    dw: dilation.get(1).copied().unwrap_or(1) as u32,
3891                    groups: *groups as u32,
3892                }
3893            }
3894
3895            Op::ResizeNearest2x => {
3896                let in_shape = &graph.node(node.inputs[0]).shape;
3897                let (n, c, h, w) = conv_nchw_dims(in_shape);
3898                Thunk::ResizeNearest2x {
3899                    src: node_offset(arena, node.inputs[0]),
3900                    dst: node_offset(arena, node.id),
3901                    n,
3902                    c,
3903                    h,
3904                    w,
3905                }
3906            }
3907
3908            Op::AxialRope2d {
3909                end_x,
3910                end_y,
3911                head_dim,
3912                num_heads,
3913                theta,
3914                repeat_factor,
3915            } => {
3916                let in_shape = &graph.node(node.inputs[0]).shape;
3917                let batch = in_shape.dim(0).unwrap_static() as u32;
3918                let seq = in_shape.dim(1).unwrap_static() as u32;
3919                let hidden = in_shape.dim(2).unwrap_static() as u32;
3920                Thunk::AxialRope2d {
3921                    src: node_offset(arena, node.inputs[0]),
3922                    dst: node_offset(arena, node.id),
3923                    batch,
3924                    seq,
3925                    hidden,
3926                    end_x: *end_x as u32,
3927                    end_y: *end_y as u32,
3928                    head_dim: *head_dim as u32,
3929                    num_heads: *num_heads as u32,
3930                    theta: *theta,
3931                    repeat_factor: *repeat_factor as u32,
3932                }
3933            }
3934
3935            Op::Softmax { axis } => {
3936                let rank = node.shape.rank();
3937                let ax = if *axis < 0 {
3938                    (rank as i32 + axis) as usize
3939                } else {
3940                    *axis as usize
3941                };
3942                let cols = node.shape.dim(ax).unwrap_static();
3943                let total = node.shape.num_elements().unwrap();
3944                let in_off = node_offset(arena, node.inputs[0]);
3945                let out_off = node_offset(arena, node.id);
3946                // Softmax kernel runs in-place on its data buffer. If the
3947                // planner gave input and output separate slots (their live
3948                // ranges overlap, so no aliasing), the output starts
3949                // uninitialized — emit a Copy first so the data is there.
3950                // Same pattern as Op::Activation.
3951                if in_off != out_off {
3952                    thunks.push(Thunk::Copy {
3953                        src: in_off,
3954                        dst: out_off,
3955                        len: total as u32,
3956                    });
3957                }
3958                Thunk::Softmax {
3959                    data: out_off,
3960                    rows: (total / cols) as u32,
3961                    cols: cols as u32,
3962                }
3963            }
3964
3965            Op::SelectiveScan { state_size } => {
3966                let in_shape = &graph.node(node.inputs[0]).shape;
3967                let (batch, seq, hidden) = (
3968                    in_shape.dim(0).unwrap_static(),
3969                    in_shape.dim(1).unwrap_static(),
3970                    in_shape.dim(2).unwrap_static(),
3971                );
3972                Thunk::SelectiveScan {
3973                    x: node_offset(arena, node.inputs[0]),
3974                    delta: node_offset(arena, node.inputs[1]),
3975                    a: node_offset(arena, node.inputs[2]),
3976                    b: node_offset(arena, node.inputs[3]),
3977                    c: node_offset(arena, node.inputs[4]),
3978                    dst: node_offset(arena, node.id),
3979                    batch: batch as u32,
3980                    seq: seq as u32,
3981                    hidden: hidden as u32,
3982                    state_size: *state_size as u32,
3983                }
3984            }
3985
3986            Op::GatedDeltaNet {
3987                state_size,
3988                carry_state,
3989            } => {
3990                let q_shape = &graph.node(node.inputs[0]).shape;
3991                let (batch, seq, heads) = (
3992                    q_shape.dim(0).unwrap_static(),
3993                    q_shape.dim(1).unwrap_static(),
3994                    q_shape.dim(2).unwrap_static(),
3995                );
3996                let state_off = if *carry_state {
3997                    node_offset(arena, node.inputs[5])
3998                } else {
3999                    0
4000                };
4001                Thunk::GatedDeltaNet {
4002                    q: node_offset(arena, node.inputs[0]),
4003                    k: node_offset(arena, node.inputs[1]),
4004                    v: node_offset(arena, node.inputs[2]),
4005                    g: node_offset(arena, node.inputs[3]),
4006                    beta: node_offset(arena, node.inputs[4]),
4007                    state: state_off,
4008                    dst: node_offset(arena, node.id),
4009                    batch: batch as u32,
4010                    seq: seq as u32,
4011                    heads: heads as u32,
4012                    state_size: *state_size as u32,
4013                }
4014            }
4015
4016            Op::Lstm {
4017                hidden_size,
4018                num_layers,
4019                bidirectional,
4020                carry,
4021            } => {
4022                let x_shape = &graph.node(node.inputs[0]).shape;
4023                let (batch, seq, input_size) = (
4024                    x_shape.dim(0).unwrap_static(),
4025                    x_shape.dim(1).unwrap_static(),
4026                    x_shape.dim(2).unwrap_static(),
4027                );
4028                let (h0, c0) = if *carry {
4029                    (
4030                        node_offset(arena, node.inputs[4]),
4031                        node_offset(arena, node.inputs[5]),
4032                    )
4033                } else {
4034                    (0, 0)
4035                };
4036                Thunk::Lstm {
4037                    x: node_offset(arena, node.inputs[0]),
4038                    w_ih: node_offset(arena, node.inputs[1]),
4039                    w_hh: node_offset(arena, node.inputs[2]),
4040                    bias: node_offset(arena, node.inputs[3]),
4041                    h0,
4042                    c0,
4043                    dst: node_offset(arena, node.id),
4044                    batch: batch as u32,
4045                    seq: seq as u32,
4046                    input_size: input_size as u32,
4047                    hidden: *hidden_size as u32,
4048                    num_layers: *num_layers as u32,
4049                    bidirectional: *bidirectional,
4050                    carry: *carry,
4051                }
4052            }
4053
4054            Op::Gru {
4055                hidden_size,
4056                num_layers,
4057                bidirectional,
4058                carry,
4059            } => {
4060                let x_shape = &graph.node(node.inputs[0]).shape;
4061                let (batch, seq, input_size) = (
4062                    x_shape.dim(0).unwrap_static(),
4063                    x_shape.dim(1).unwrap_static(),
4064                    x_shape.dim(2).unwrap_static(),
4065                );
4066                // Inputs: x, w_ih, w_hh, b_ih, b_hh (+ h0 when carry).
4067                let h0 = if *carry {
4068                    node_offset(arena, node.inputs[5])
4069                } else {
4070                    0
4071                };
4072                Thunk::Gru {
4073                    x: node_offset(arena, node.inputs[0]),
4074                    w_ih: node_offset(arena, node.inputs[1]),
4075                    w_hh: node_offset(arena, node.inputs[2]),
4076                    b_ih: node_offset(arena, node.inputs[3]),
4077                    b_hh: node_offset(arena, node.inputs[4]),
4078                    h0,
4079                    dst: node_offset(arena, node.id),
4080                    batch: batch as u32,
4081                    seq: seq as u32,
4082                    input_size: input_size as u32,
4083                    hidden: *hidden_size as u32,
4084                    num_layers: *num_layers as u32,
4085                    bidirectional: *bidirectional,
4086                    carry: *carry,
4087                }
4088            }
4089
4090            Op::Rnn {
4091                hidden_size,
4092                num_layers,
4093                bidirectional,
4094                carry,
4095                relu,
4096            } => {
4097                let x_shape = &graph.node(node.inputs[0]).shape;
4098                let (batch, seq, input_size) = (
4099                    x_shape.dim(0).unwrap_static(),
4100                    x_shape.dim(1).unwrap_static(),
4101                    x_shape.dim(2).unwrap_static(),
4102                );
4103                // Inputs: x, w_ih, w_hh, bias (+ h0 when carry).
4104                let h0 = if *carry {
4105                    node_offset(arena, node.inputs[4])
4106                } else {
4107                    0
4108                };
4109                Thunk::Rnn {
4110                    x: node_offset(arena, node.inputs[0]),
4111                    w_ih: node_offset(arena, node.inputs[1]),
4112                    w_hh: node_offset(arena, node.inputs[2]),
4113                    bias: node_offset(arena, node.inputs[3]),
4114                    h0,
4115                    dst: node_offset(arena, node.id),
4116                    batch: batch as u32,
4117                    seq: seq as u32,
4118                    input_size: input_size as u32,
4119                    hidden: *hidden_size as u32,
4120                    num_layers: *num_layers as u32,
4121                    bidirectional: *bidirectional,
4122                    carry: *carry,
4123                    relu: *relu,
4124                }
4125            }
4126
4127            Op::Mamba2 {
4128                head_dim,
4129                state_size,
4130            } => {
4131                // x [B,S,H,P]; dt [B,S,H]; a [H]; b,c [B,S,H,N].
4132                let x_shape = &graph.node(node.inputs[0]).shape;
4133                Thunk::Mamba2 {
4134                    x: node_offset(arena, node.inputs[0]),
4135                    dt: node_offset(arena, node.inputs[1]),
4136                    a: node_offset(arena, node.inputs[2]),
4137                    b: node_offset(arena, node.inputs[3]),
4138                    c: node_offset(arena, node.inputs[4]),
4139                    dst: node_offset(arena, node.id),
4140                    batch: x_shape.dim(0).unwrap_static() as u32,
4141                    seq: x_shape.dim(1).unwrap_static() as u32,
4142                    heads: x_shape.dim(2).unwrap_static() as u32,
4143                    head_dim: *head_dim as u32,
4144                    state_size: *state_size as u32,
4145                }
4146            }
4147
4148            Op::QMatMul {
4149                x_zp,
4150                w_zp,
4151                out_zp,
4152                mult,
4153            } => {
4154                let x_shape = &graph.node(node.inputs[0]).shape;
4155                let w_shape = &graph.node(node.inputs[1]).shape;
4156                let m = x_shape.dim(0).unwrap_static();
4157                let k = x_shape.dim(1).unwrap_static();
4158                let n = w_shape.dim(1).unwrap_static();
4159                Thunk::QMatMul {
4160                    x: node_offset(arena, node.inputs[0]),
4161                    w: node_offset(arena, node.inputs[1]),
4162                    bias: node_offset(arena, node.inputs[2]),
4163                    out: node_offset(arena, node.id),
4164                    m: m as u32,
4165                    k: k as u32,
4166                    n: n as u32,
4167                    x_zp: *x_zp,
4168                    w_zp: *w_zp,
4169                    out_zp: *out_zp,
4170                    mult: *mult,
4171                }
4172            }
4173
4174            Op::QConv2d {
4175                kernel_size,
4176                stride,
4177                padding,
4178                dilation,
4179                groups,
4180                x_zp,
4181                w_zp,
4182                out_zp,
4183                mult,
4184            } => {
4185                let in_shape = &graph.node(node.inputs[0]).shape;
4186                let w_shape = &graph.node(node.inputs[1]).shape;
4187                let out_shape = &node.shape;
4188                if kernel_size.len() == 2
4189                    && in_shape.rank() == 4
4190                    && w_shape.rank() == 4
4191                    && out_shape.rank() == 4
4192                {
4193                    Thunk::QConv2d {
4194                        x: node_offset(arena, node.inputs[0]),
4195                        w: node_offset(arena, node.inputs[1]),
4196                        bias: node_offset(arena, node.inputs[2]),
4197                        out: node_offset(arena, node.id),
4198                        n: in_shape.dim(0).unwrap_static() as u32,
4199                        c_in: in_shape.dim(1).unwrap_static() as u32,
4200                        h: in_shape.dim(2).unwrap_static() as u32,
4201                        w_in: in_shape.dim(3).unwrap_static() as u32,
4202                        c_out: out_shape.dim(1).unwrap_static() as u32,
4203                        h_out: out_shape.dim(2).unwrap_static() as u32,
4204                        w_out: out_shape.dim(3).unwrap_static() as u32,
4205                        kh: kernel_size[0] as u32,
4206                        kw: kernel_size[1] as u32,
4207                        sh: stride.first().copied().unwrap_or(1) as u32,
4208                        sw: stride.get(1).copied().unwrap_or(1) as u32,
4209                        ph: padding.first().copied().unwrap_or(0) as u32,
4210                        pw: padding.get(1).copied().unwrap_or(0) as u32,
4211                        dh: dilation.first().copied().unwrap_or(1) as u32,
4212                        dw: dilation.get(1).copied().unwrap_or(1) as u32,
4213                        groups: *groups as u32,
4214                        x_zp: *x_zp,
4215                        w_zp: *w_zp,
4216                        out_zp: *out_zp,
4217                        mult: *mult,
4218                    }
4219                } else {
4220                    Thunk::Nop
4221                }
4222            }
4223
4224            Op::DequantMatMul { scheme } => {
4225                use rlx_ir::quant::QuantScheme;
4226                let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
4227                let total = node.shape.num_elements().unwrap();
4228                let m = total / n.max(1);
4229                let x_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
4230                let k = x_total / m.max(1);
4231                if scheme.is_gguf() {
4232                    Thunk::DequantMatMulGguf {
4233                        x: node_offset(arena, node.inputs[0]),
4234                        w_q: node_offset(arena, node.inputs[1]),
4235                        dst: node_offset(arena, node.id),
4236                        m: m as u32,
4237                        k: k as u32,
4238                        n: n as u32,
4239                        scheme: *scheme,
4240                    }
4241                } else {
4242                    match scheme {
4243                        QuantScheme::Nvfp4Block => Thunk::DequantMatMulNvfp4 {
4244                            x: node_offset(arena, node.inputs[0]),
4245                            w_q: node_offset(arena, node.inputs[1]),
4246                            scale: node_offset(arena, node.inputs[2]),
4247                            global_scale: node_offset(arena, node.inputs[3]),
4248                            dst: node_offset(arena, node.id),
4249                            m: m as u32,
4250                            k: k as u32,
4251                            n: n as u32,
4252                        },
4253                        QuantScheme::Int4Block { block_size } => Thunk::DequantMatMulInt4 {
4254                            x: node_offset(arena, node.inputs[0]),
4255                            w_q: node_offset(arena, node.inputs[1]),
4256                            scale: node_offset(arena, node.inputs[2]),
4257                            zp: node_offset(arena, node.inputs[3]),
4258                            dst: node_offset(arena, node.id),
4259                            m: m as u32,
4260                            k: k as u32,
4261                            n: n as u32,
4262                            block_size: *block_size,
4263                            is_asymmetric: false,
4264                        },
4265                        QuantScheme::Fp8E4m3 => Thunk::DequantMatMulFp8 {
4266                            x: node_offset(arena, node.inputs[0]),
4267                            w_q: node_offset(arena, node.inputs[1]),
4268                            scale: node_offset(arena, node.inputs[2]),
4269                            dst: node_offset(arena, node.id),
4270                            m: m as u32,
4271                            k: k as u32,
4272                            n: n as u32,
4273                            e5m2: false,
4274                        },
4275                        QuantScheme::Fp8E5m2 => Thunk::DequantMatMulFp8 {
4276                            x: node_offset(arena, node.inputs[0]),
4277                            w_q: node_offset(arena, node.inputs[1]),
4278                            scale: node_offset(arena, node.inputs[2]),
4279                            dst: node_offset(arena, node.id),
4280                            m: m as u32,
4281                            k: k as u32,
4282                            n: n as u32,
4283                            e5m2: true,
4284                        },
4285                        QuantScheme::Int8Block { block_size } => Thunk::DequantMatMul {
4286                            x: node_offset(arena, node.inputs[0]),
4287                            w_q: node_offset(arena, node.inputs[1]),
4288                            scale: node_offset(arena, node.inputs[2]),
4289                            zp: node_offset(arena, node.inputs[3]),
4290                            dst: node_offset(arena, node.id),
4291                            m: m as u32,
4292                            k: k as u32,
4293                            n: n as u32,
4294                            block_size: *block_size,
4295                            is_asymmetric: false,
4296                        },
4297                        QuantScheme::Int8BlockAsym { block_size } => Thunk::DequantMatMul {
4298                            x: node_offset(arena, node.inputs[0]),
4299                            w_q: node_offset(arena, node.inputs[1]),
4300                            scale: node_offset(arena, node.inputs[2]),
4301                            zp: node_offset(arena, node.inputs[3]),
4302                            dst: node_offset(arena, node.id),
4303                            m: m as u32,
4304                            k: k as u32,
4305                            n: n as u32,
4306                            block_size: *block_size,
4307                            is_asymmetric: true,
4308                        },
4309                        other => panic!(
4310                            "DequantMatMul on CPU supports Int8/Int4/FP8/NVFP4 legacy or GGUF schemes; got {other}"
4311                        ),
4312                    }
4313                }
4314            }
4315
4316            Op::ScaledMatMul {
4317                lhs_format,
4318                rhs_format,
4319                scale_layout,
4320                has_bias,
4321            } => {
4322                // TN: lhs [m,k], rhs [n,k], out [m,n].
4323                let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
4324                let total = node.shape.num_elements().unwrap();
4325                let m = total / n.max(1);
4326                let lhs_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
4327                let k = lhs_total / m.max(1);
4328                Thunk::ScaledMatMul {
4329                    lhs: node_offset(arena, node.inputs[0]),
4330                    rhs: node_offset(arena, node.inputs[1]),
4331                    lhs_scale: node_offset(arena, node.inputs[2]),
4332                    rhs_scale: node_offset(arena, node.inputs[3]),
4333                    bias: if *has_bias {
4334                        node_offset(arena, node.inputs[4])
4335                    } else {
4336                        0
4337                    },
4338                    dst: node_offset(arena, node.id),
4339                    m: m as u32,
4340                    k: k as u32,
4341                    n: n as u32,
4342                    lhs_fmt: *lhs_format,
4343                    rhs_fmt: *rhs_format,
4344                    layout: *scale_layout,
4345                    has_bias: *has_bias,
4346                }
4347            }
4348
4349            Op::ScaledQuantize {
4350                format,
4351                scale_layout,
4352            } => {
4353                let xs = &graph.node(node.inputs[0]).shape;
4354                let cols = xs.dim(xs.rank() - 1).unwrap_static();
4355                let rows = xs.num_elements().unwrap() / cols.max(1);
4356                Thunk::ScaledQuantize {
4357                    x: node_offset(arena, node.inputs[0]),
4358                    scale: node_offset(arena, node.inputs[1]),
4359                    dst: node_offset(arena, node.id),
4360                    rows: rows as u32,
4361                    cols: cols as u32,
4362                    fmt: *format,
4363                    layout: *scale_layout,
4364                }
4365            }
4366
4367            Op::ScaledQuantScale {
4368                format,
4369                scale_layout,
4370            } => {
4371                let xs = &graph.node(node.inputs[0]).shape;
4372                let cols = xs.dim(xs.rank() - 1).unwrap_static();
4373                let rows = xs.num_elements().unwrap() / cols.max(1);
4374                Thunk::ScaledQuantScale {
4375                    x: node_offset(arena, node.inputs[0]),
4376                    dst: node_offset(arena, node.id),
4377                    rows: rows as u32,
4378                    cols: cols as u32,
4379                    fmt: *format,
4380                    layout: *scale_layout,
4381                }
4382            }
4383
4384            Op::ScaledDequantize {
4385                format,
4386                scale_layout,
4387            } => {
4388                let xs = &graph.node(node.inputs[0]).shape;
4389                let cols = xs.dim(xs.rank() - 1).unwrap_static();
4390                let rows = xs.num_elements().unwrap() / cols.max(1);
4391                Thunk::ScaledDequantize {
4392                    codes: node_offset(arena, node.inputs[0]),
4393                    scale: node_offset(arena, node.inputs[1]),
4394                    dst: node_offset(arena, node.id),
4395                    rows: rows as u32,
4396                    cols: cols as u32,
4397                    fmt: *format,
4398                    layout: *scale_layout,
4399                }
4400            }
4401
4402            Op::LoraMatMul { scale } => {
4403                // x [m, k], w [k, n], a [k, r], b [r, n].
4404                let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
4405                let total = node.shape.num_elements().unwrap();
4406                let m = total / n.max(1);
4407                let x_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
4408                let k = x_total / m.max(1);
4409                let a_total = graph.node(node.inputs[2]).shape.num_elements().unwrap();
4410                let r = a_total / k.max(1);
4411                Thunk::LoraMatMul {
4412                    x: node_offset(arena, node.inputs[0]),
4413                    w: node_offset(arena, node.inputs[1]),
4414                    a: node_offset(arena, node.inputs[2]),
4415                    b: node_offset(arena, node.inputs[3]),
4416                    dst: node_offset(arena, node.id),
4417                    m: m as u32,
4418                    k: k as u32,
4419                    n: n as u32,
4420                    r: r as u32,
4421                    scale: *scale,
4422                }
4423            }
4424
4425            Op::Sample {
4426                top_k,
4427                top_p,
4428                temperature,
4429                seed,
4430            } => {
4431                let in_shape = &graph.node(node.inputs[0]).shape;
4432                // Logits are [batch, vocab] (or [vocab] → batch=1).
4433                let (batch, vocab) = if in_shape.rank() >= 2 {
4434                    (
4435                        in_shape.dim(0).unwrap_static(),
4436                        in_shape.dim(in_shape.rank() - 1).unwrap_static(),
4437                    )
4438                } else {
4439                    (1, in_shape.num_elements().unwrap_or(0))
4440                };
4441                Thunk::Sample {
4442                    logits: node_offset(arena, node.inputs[0]),
4443                    dst: node_offset(arena, node.id),
4444                    batch: batch as u32,
4445                    vocab: vocab as u32,
4446                    top_k: *top_k as u32,
4447                    top_p: *top_p,
4448                    temperature: *temperature,
4449                    seed: *seed,
4450                }
4451            }
4452
4453            Op::RngNormal {
4454                mean,
4455                scale,
4456                key,
4457                op_seed,
4458            } => Thunk::RngNormal {
4459                dst: node_offset(arena, node.id),
4460                len: node.shape.num_elements().unwrap_or(0) as u32,
4461                mean: *mean,
4462                scale: *scale,
4463                key: *key,
4464                op_seed: *op_seed,
4465            },
4466
4467            Op::RngUniform {
4468                low,
4469                high,
4470                key,
4471                op_seed,
4472            } => Thunk::RngUniform {
4473                dst: node_offset(arena, node.id),
4474                len: node.shape.num_elements().unwrap_or(0) as u32,
4475                low: *low,
4476                high: *high,
4477                key: *key,
4478                op_seed: *op_seed,
4479            },
4480
4481            Op::Cumsum { axis, exclusive } => {
4482                // For now CPU only supports last-axis cumsum (the
4483                // common case for sampling / ragged offsets).
4484                // Other axes can lower via Transpose → Cumsum →
4485                // Transpose; not on the hot path today.
4486                let rank = node.shape.rank();
4487                let ax = if *axis < 0 {
4488                    (rank as i32 + axis) as usize
4489                } else {
4490                    *axis as usize
4491                };
4492                assert_eq!(
4493                    ax,
4494                    rank - 1,
4495                    "Cumsum only supports the last axis on CPU today"
4496                );
4497                let cols = node.shape.dim(ax).unwrap_static();
4498                let total = node.shape.num_elements().unwrap();
4499                Thunk::Cumsum {
4500                    src: node_offset(arena, node.inputs[0]),
4501                    dst: node_offset(arena, node.id),
4502                    rows: (total / cols) as u32,
4503                    cols: cols as u32,
4504                    exclusive: *exclusive,
4505                }
4506            }
4507
4508            Op::Attention {
4509                num_heads,
4510                head_dim,
4511                mask_kind,
4512                score_scale,
4513                attn_logit_softcap: _,
4514            } => {
4515                // Layout dispatch: rank-4 input could be either
4516                // `[B, S, H, D]` (CPU's historical convention) or
4517                // `[B, H, S, D]` (the convention the GPU/TPU backends
4518                // share). Disambiguate by which axis matches
4519                // `num_heads`. Rank-3 is always `[B, S, H*D]`.
4520                let q_shape = &graph.node(node.inputs[0]).shape;
4521                let k_shape = &graph.node(node.inputs[1]).shape;
4522                let rank = q_shape.rank();
4523                let (batch, seq, kv_seq, bhsd) = if rank == 4 {
4524                    let d1 = q_shape.dim(1).unwrap_static();
4525                    let d2 = q_shape.dim(2).unwrap_static();
4526                    if d1 == *num_heads {
4527                        // [B, H, S, D]
4528                        (
4529                            q_shape.dim(0).unwrap_static(),
4530                            d2,
4531                            k_shape.dim(2).unwrap_static(),
4532                            true,
4533                        )
4534                    } else {
4535                        // [B, S, H, D]
4536                        (
4537                            q_shape.dim(0).unwrap_static(),
4538                            d1,
4539                            k_shape.dim(1).unwrap_static(),
4540                            false,
4541                        )
4542                    }
4543                } else if rank >= 3 {
4544                    (
4545                        q_shape.dim(0).unwrap_static(),
4546                        q_shape.dim(1).unwrap_static(),
4547                        k_shape.dim(1).unwrap_static(),
4548                        false,
4549                    )
4550                } else {
4551                    (
4552                        1,
4553                        q_shape.dim(0).unwrap_static(),
4554                        k_shape.dim(0).unwrap_static(),
4555                        false,
4556                    )
4557                };
4558                let mask_off = if matches!(
4559                    mask_kind,
4560                    rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias
4561                ) {
4562                    node_offset(arena, node.inputs[3])
4563                } else {
4564                    0
4565                };
4566                let hs = (*num_heads * *head_dim) as u32;
4567                Thunk::Attention {
4568                    q: node_offset(arena, node.inputs[0]),
4569                    k: node_offset(arena, node.inputs[1]),
4570                    v: node_offset(arena, node.inputs[2]),
4571                    mask: mask_off,
4572                    out: node_offset(arena, node.id),
4573                    batch: batch as u32,
4574                    seq: seq as u32,
4575                    kv_seq: kv_seq as u32,
4576                    heads: *num_heads as u32,
4577                    head_dim: *head_dim as u32,
4578                    mask_kind: *mask_kind,
4579                    scale: score_scale.unwrap_or((*head_dim as f32).powf(-0.5)),
4580                    // Defaults: each input is its own contiguous buffer
4581                    // with row stride = hidden. Rewritten by the
4582                    // Narrow→Attention fusion when applicable.
4583                    q_row_stride: hs,
4584                    k_row_stride: hs,
4585                    v_row_stride: hs,
4586                    bhsd,
4587                }
4588            }
4589
4590            Op::AttentionBackward {
4591                num_heads,
4592                head_dim,
4593                mask_kind,
4594                wrt,
4595            } => {
4596                let q_shape = &graph.node(node.inputs[0]).shape;
4597                let k_shape = &graph.node(node.inputs[1]).shape;
4598                let rank = q_shape.rank();
4599                let (batch, seq, kv_seq, bhsd) = if rank == 4 {
4600                    let d1 = q_shape.dim(1).unwrap_static();
4601                    let d2 = q_shape.dim(2).unwrap_static();
4602                    if d1 == *num_heads {
4603                        (
4604                            q_shape.dim(0).unwrap_static(),
4605                            d2,
4606                            k_shape.dim(2).unwrap_static(),
4607                            true,
4608                        )
4609                    } else {
4610                        (
4611                            q_shape.dim(0).unwrap_static(),
4612                            d1,
4613                            k_shape.dim(1).unwrap_static(),
4614                            false,
4615                        )
4616                    }
4617                } else if rank >= 3 {
4618                    (
4619                        q_shape.dim(0).unwrap_static(),
4620                        q_shape.dim(1).unwrap_static(),
4621                        k_shape.dim(1).unwrap_static(),
4622                        false,
4623                    )
4624                } else {
4625                    (
4626                        1,
4627                        q_shape.dim(0).unwrap_static(),
4628                        k_shape.dim(0).unwrap_static(),
4629                        false,
4630                    )
4631                };
4632                let mask_off = if matches!(
4633                    mask_kind,
4634                    rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias
4635                ) {
4636                    node_offset(arena, node.inputs[4])
4637                } else {
4638                    0
4639                };
4640                Thunk::AttentionBackward {
4641                    q: node_offset(arena, node.inputs[0]),
4642                    k: node_offset(arena, node.inputs[1]),
4643                    v: node_offset(arena, node.inputs[2]),
4644                    dy: node_offset(arena, node.inputs[3]),
4645                    mask: mask_off,
4646                    out: node_offset(arena, node.id),
4647                    batch: batch as u32,
4648                    seq: seq as u32,
4649                    kv_seq: kv_seq as u32,
4650                    heads: *num_heads as u32,
4651                    head_dim: *head_dim as u32,
4652                    mask_kind: *mask_kind,
4653                    wrt: *wrt,
4654                    bhsd,
4655                }
4656            }
4657
4658            Op::FusedAttentionBlock {
4659                num_heads,
4660                head_dim,
4661                has_bias,
4662                has_rope,
4663            } => {
4664                let x_shape = &graph.node(node.inputs[0]).shape;
4665                let (batch, seq) = if x_shape.rank() >= 3 {
4666                    (
4667                        x_shape.dim(0).unwrap_static(),
4668                        x_shape.dim(1).unwrap_static(),
4669                    )
4670                } else {
4671                    let total = x_shape.num_elements().unwrap();
4672                    let s = x_shape.dim(x_shape.rank() - 2).unwrap_static();
4673                    (total / (s * num_heads * head_dim), s)
4674                };
4675                let hs = (*num_heads * *head_dim) as u32;
4676                // Inputs: hidden, qkv_w, out_w, mask, [qkv_b, out_b], [cos, sin]
4677                let mut idx = 4;
4678                let (qkv_b_off, out_b_off) = if *has_bias {
4679                    let qb = node_offset(arena, node.inputs[idx]);
4680                    let ob = node_offset(arena, node.inputs[idx + 1]);
4681                    idx += 2;
4682                    (qb, ob)
4683                } else {
4684                    (0, 0)
4685                };
4686                let (cos_off, sin_off, cl) = if *has_rope {
4687                    let c = node_offset(arena, node.inputs[idx]);
4688                    let s = node_offset(arena, node.inputs[idx + 1]);
4689                    let clen = get_len(graph, node.inputs[idx]);
4690                    (c, s, clen as u32)
4691                } else {
4692                    (0, 0, 0)
4693                };
4694
4695                Thunk::FusedAttnBlock {
4696                    hidden: node_offset(arena, node.inputs[0]),
4697                    qkv_w: node_offset(arena, node.inputs[1]),
4698                    out_w: node_offset(arena, node.inputs[2]),
4699                    mask: node_offset(arena, node.inputs[3]),
4700                    // The MIR `Op::FusedAttentionBlock` is emitted only for the
4701                    // BERT-style per-key padding mask (the MIR fusion pass is
4702                    // `Custom`-only), so the buffer mask is authoritative here.
4703                    mask_kind: rlx_ir::op::MaskKind::Custom,
4704                    out: node_offset(arena, node.id),
4705                    qkv_b: qkv_b_off,
4706                    out_b: out_b_off,
4707                    cos: cos_off,
4708                    sin: sin_off,
4709                    cos_len: cl,
4710                    batch: batch as u32,
4711                    seq: seq as u32,
4712                    hs,
4713                    nh: *num_heads as u32,
4714                    dh: *head_dim as u32,
4715                    has_bias: *has_bias,
4716                    has_rope: *has_rope,
4717                    // The MIR `Op::FusedAttentionBlock` is BERT-only (NeoX rope).
4718                    interleaved: false,
4719                }
4720            }
4721
4722            Op::Rope {
4723                head_dim,
4724                n_rot,
4725                style,
4726            } => {
4727                let x_shape = &graph.node(node.inputs[0]).shape;
4728                let (batch, seq, hidden) = if x_shape.rank() >= 3 {
4729                    (
4730                        x_shape.dim(0).unwrap_static(),
4731                        x_shape.dim(1).unwrap_static(),
4732                        x_shape.dim(2).unwrap_static(),
4733                    )
4734                } else {
4735                    let total = x_shape.num_elements().unwrap();
4736                    (
4737                        1,
4738                        x_shape.dim(0).unwrap_static(),
4739                        total / x_shape.dim(0).unwrap_static(),
4740                    )
4741                };
4742                let cos_len = get_len(graph, node.inputs[1]);
4743                Thunk::Rope {
4744                    src: node_offset(arena, node.inputs[0]),
4745                    cos: node_offset(arena, node.inputs[1]),
4746                    sin: node_offset(arena, node.inputs[2]),
4747                    dst: node_offset(arena, node.id),
4748                    batch: batch as u32,
4749                    seq: seq as u32,
4750                    hidden: hidden as u32,
4751                    head_dim: *head_dim as u32,
4752                    n_rot: *n_rot as u32,
4753                    cos_len: cos_len as u32,
4754                    // Default: source rows are tightly packed (rewritten
4755                    // by the Narrow→Rope fusion pass below if Rope ends
4756                    // up reading from a wider parent like QKV).
4757                    src_row_stride: hidden as u32,
4758                    interleaved: matches!(style, rlx_ir::op::RopeStyle::GptJ),
4759                }
4760            }
4761
4762            Op::FusedSwiGLU {
4763                cast_to: _,
4764                gate_first,
4765            } => {
4766                let n_half = node.shape.dim(node.shape.rank() - 1).unwrap_static();
4767                let total = node.shape.num_elements().unwrap();
4768                Thunk::FusedSwiGLU {
4769                    src: node_offset(arena, node.inputs[0]),
4770                    dst: node_offset(arena, node.id),
4771                    n_half: n_half as u32,
4772                    total: total as u32,
4773                    gate_first: *gate_first,
4774                }
4775            }
4776
4777            Op::Conv {
4778                kernel_size,
4779                stride,
4780                padding,
4781                dilation,
4782                groups,
4783            } => {
4784                let in_shape = &graph.node(node.inputs[0]).shape;
4785                let w_shape = &graph.node(node.inputs[1]).shape;
4786                let out_shape = &node.shape;
4787                // 1×1 fast path (plan #26): kH=kW=1, stride=1,
4788                // padding=0, dilation=1, groups=1. Emits a single
4789                // Conv2D1x1 thunk that BLAS-dispatches per batch.
4790                let is_1x1_simple = kernel_size.len() == 2
4791                    && kernel_size[0] == 1
4792                    && kernel_size[1] == 1
4793                    && stride.iter().all(|&s| s == 1)
4794                    && padding.iter().all(|&p| p == 0)
4795                    && dilation.iter().all(|&d| d == 1)
4796                    && *groups == 1;
4797                if is_1x1_simple
4798                    && in_shape.rank() >= 3
4799                    && out_shape.rank() >= 3
4800                    && w_shape.rank() >= 2
4801                {
4802                    let (n, c_in, h, w) = conv_nchw_dims(in_shape);
4803                    let (_, c_out, _, _) = conv_nchw_dims(out_shape);
4804                    Thunk::Conv2D1x1 {
4805                        src: node_offset(arena, node.inputs[0]),
4806                        weight: node_offset(arena, node.inputs[1]),
4807                        dst: node_offset(arena, node.id),
4808                        n,
4809                        c_in,
4810                        c_out,
4811                        hw: h.saturating_mul(w),
4812                    }
4813                } else if kernel_size.len() == 2
4814                    && in_shape.rank() >= 3
4815                    && w_shape.rank() >= 2
4816                    && out_shape.rank() >= 3
4817                {
4818                    let (n, c_in, h, w_in) = conv_nchw_dims(in_shape);
4819                    let (_, c_out, h_out, w_out) = conv_nchw_dims(out_shape);
4820                    // rlx lowers ONNX 1D convs as 2D NCHW with a unit H axis and the
4821                    // length in W (`[N,C,1,L]`), but keeps the length kernel/stride/pad/
4822                    // dilation at index 0 (`kernel=[k,1]`). A literal 2D conv would run
4823                    // the k-tap kernel over the singleton H axis and ignore the length.
4824                    // Since `[N,C,1,L]` and `[N,C,L,1]` share the same row-major layout,
4825                    // relabel the length onto the H axis (no data copy) so the kernel
4826                    // convolves it — matching the MLX 1D path and onnxruntime.
4827                    let one_d_w = h == 1
4828                        && w_in > 1
4829                        && kernel_size[0] > 1
4830                        && kernel_size.get(1).copied().unwrap_or(1) == 1;
4831                    let (h, w_in, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw) = if one_d_w {
4832                        (
4833                            w_in,
4834                            1,
4835                            w_out,
4836                            1,
4837                            kernel_size[0] as u32,
4838                            1,
4839                            stride.first().copied().unwrap_or(1) as u32,
4840                            1,
4841                            padding.first().copied().unwrap_or(0) as u32,
4842                            0,
4843                            dilation.first().copied().unwrap_or(1) as u32,
4844                            1,
4845                        )
4846                    } else {
4847                        (
4848                            h,
4849                            w_in,
4850                            h_out,
4851                            w_out,
4852                            kernel_size[0] as u32,
4853                            kernel_size[1] as u32,
4854                            stride.first().copied().unwrap_or(1) as u32,
4855                            stride.get(1).copied().unwrap_or(1) as u32,
4856                            padding.first().copied().unwrap_or(0) as u32,
4857                            padding.get(1).copied().unwrap_or(0) as u32,
4858                            dilation.first().copied().unwrap_or(1) as u32,
4859                            dilation.get(1).copied().unwrap_or(1) as u32,
4860                        )
4861                    };
4862                    Thunk::Conv2D {
4863                        src: node_offset(arena, node.inputs[0]),
4864                        weight: node_offset(arena, node.inputs[1]),
4865                        dst: node_offset(arena, node.id),
4866                        n,
4867                        c_in,
4868                        h,
4869                        w: w_in,
4870                        c_out,
4871                        h_out,
4872                        w_out,
4873                        kh,
4874                        kw,
4875                        sh,
4876                        sw,
4877                        ph,
4878                        pw,
4879                        dh,
4880                        dw,
4881                        groups: *groups as u32,
4882                    }
4883                } else {
4884                    Thunk::Nop
4885                }
4886            }
4887
4888            Op::Pool {
4889                kind,
4890                kernel_size,
4891                stride,
4892                padding,
4893            } => {
4894                // Currently support 2D pooling on rank-4 NCHW tensors.
4895                let in_shape = &graph.node(node.inputs[0]).shape;
4896                let out_shape = &node.shape;
4897                if kernel_size.len() == 2 && in_shape.rank() == 4 && out_shape.rank() == 4 {
4898                    Thunk::Pool2D {
4899                        src: node_offset(arena, node.inputs[0]),
4900                        dst: node_offset(arena, node.id),
4901                        n: in_shape.dim(0).unwrap_static() as u32,
4902                        c: in_shape.dim(1).unwrap_static() as u32,
4903                        h: in_shape.dim(2).unwrap_static() as u32,
4904                        w: in_shape.dim(3).unwrap_static() as u32,
4905                        h_out: out_shape.dim(2).unwrap_static() as u32,
4906                        w_out: out_shape.dim(3).unwrap_static() as u32,
4907                        kh: kernel_size[0] as u32,
4908                        kw: kernel_size[1] as u32,
4909                        sh: stride.first().copied().unwrap_or(1) as u32,
4910                        sw: stride.get(1).copied().unwrap_or(1) as u32,
4911                        ph: padding.first().copied().unwrap_or(0) as u32,
4912                        pw: padding.get(1).copied().unwrap_or(0) as u32,
4913                        kind: *kind,
4914                    }
4915                } else {
4916                    Thunk::Nop
4917                }
4918            }
4919
4920            Op::Transpose { perm } => {
4921                // Pre-compute (out_dims, in_strides_for_each_out_dim) so the
4922                // runtime loop is just an N-D index walk + scatter.
4923                let in_shape = &graph.node(node.inputs[0]).shape;
4924                let in_rank = in_shape.rank();
4925                if perm.iter().any(|&p| p >= in_rank) {
4926                    Thunk::Nop
4927                } else {
4928                    let in_dims: Vec<usize> = (0..in_rank)
4929                        .map(|i| in_shape.dim(i).unwrap_static())
4930                        .collect();
4931                    // Row-major input strides: stride[d] = product of dims[d+1..].
4932                    let mut in_strides_full = vec![1usize; in_rank];
4933                    for d in (0..in_rank.saturating_sub(1)).rev() {
4934                        in_strides_full[d] = in_strides_full[d + 1] * in_dims[d + 1];
4935                    }
4936                    let out_dims: Vec<u32> = perm.iter().map(|&p| in_dims[p] as u32).collect();
4937                    let in_strides: Vec<u32> =
4938                        perm.iter().map(|&p| in_strides_full[p] as u32).collect();
4939                    let in_total = in_dims.iter().product::<usize>() as u32;
4940                    let src = node_offset(arena, node.inputs[0]);
4941                    let dst = node_offset(arena, node.id);
4942                    let elem_bytes = node.shape.dtype().size_bytes() as u8;
4943                    match node.shape.dtype() {
4944                        rlx_ir::DType::F64 => Thunk::TransposeF64 {
4945                            src,
4946                            dst,
4947                            in_total,
4948                            out_dims,
4949                            in_strides,
4950                        },
4951                        _ => Thunk::Transpose {
4952                            src,
4953                            dst,
4954                            in_total,
4955                            out_dims,
4956                            in_strides,
4957                            elem_bytes,
4958                        },
4959                    }
4960                }
4961            }
4962
4963            Op::ScatterAdd => {
4964                // updates: [num_updates, ...trailing], indices: [num_updates],
4965                // output: [out_dim, ...trailing]
4966                let upd_shape = &graph.node(node.inputs[0]).shape;
4967                let out_shape = &node.shape;
4968                let num_updates = upd_shape.dim(0).unwrap_static();
4969                let out_dim = out_shape.dim(0).unwrap_static();
4970                let trailing: usize = (1..out_shape.rank())
4971                    .map(|i| out_shape.dim(i).unwrap_static())
4972                    .product::<usize>()
4973                    .max(1);
4974                Thunk::ScatterAdd {
4975                    updates: node_offset(arena, node.inputs[0]),
4976                    indices: node_offset(arena, node.inputs[1]),
4977                    dst: node_offset(arena, node.id),
4978                    num_updates: num_updates as u32,
4979                    out_dim: out_dim as u32,
4980                    trailing: trailing as u32,
4981                }
4982            }
4983
4984            Op::GroupedMatMul => {
4985                // Inputs: [input(M, K), weight(E, K, N), expert_idx(M)]
4986                let in_shape = &graph.node(node.inputs[0]).shape;
4987                let w_shape = &graph.node(node.inputs[1]).shape;
4988                let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
4989                let k_dim = in_shape.dim(in_shape.rank() - 1).unwrap_static();
4990                let num_experts = w_shape.dim(0).unwrap_static();
4991                let n = w_shape.dim(2).unwrap_static();
4992                Thunk::GroupedMatMul {
4993                    input: node_offset(arena, node.inputs[0]),
4994                    weight: node_offset(arena, node.inputs[1]),
4995                    expert_idx: node_offset(arena, node.inputs[2]),
4996                    dst: node_offset(arena, node.id),
4997                    m: m as u32,
4998                    k_dim: k_dim as u32,
4999                    n: n as u32,
5000                    num_experts: num_experts as u32,
5001                }
5002            }
5003
5004            Op::DequantGroupedMatMul { scheme } => {
5005                let in_shape = &graph.node(node.inputs[0]).shape;
5006                let w_shape = &graph.node(node.inputs[1]).shape;
5007                let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
5008                let k_dim = in_shape.dim(in_shape.rank() - 1).unwrap_static();
5009                let out_shape = &node.shape;
5010                let n = out_shape.dim(out_shape.rank() - 1).unwrap_static();
5011                let block_elems = scheme.gguf_block_size() as usize;
5012                let block_bytes = scheme.gguf_block_bytes() as usize;
5013                let slab_bytes = (k_dim * n) / block_elems * block_bytes;
5014                let total_bytes = w_shape.num_elements().unwrap();
5015                let num_experts = total_bytes / slab_bytes.max(1);
5016                Thunk::DequantGroupedMatMulGguf {
5017                    input: node_offset(arena, node.inputs[0]),
5018                    w_q: node_offset(arena, node.inputs[1]),
5019                    expert_idx: node_offset(arena, node.inputs[2]),
5020                    dst: node_offset(arena, node.id),
5021                    m: m as u32,
5022                    k_dim: k_dim as u32,
5023                    n: n as u32,
5024                    num_experts: num_experts as u32,
5025                    scheme: *scheme,
5026                }
5027            }
5028
5029            Op::DequantMoEWeights { scheme } => {
5030                let w_shape = &graph.node(node.inputs[0]).shape;
5031                let out_shape = &node.shape;
5032                let num_experts = out_shape.dim(0).unwrap_static();
5033                let k_dim = out_shape.dim(1).unwrap_static();
5034                let n = out_shape.dim(2).unwrap_static();
5035                let block_elems = scheme.gguf_block_size() as usize;
5036                let block_bytes = scheme.gguf_block_bytes() as usize;
5037                let slab_bytes = (k_dim * n) / block_elems * block_bytes;
5038                let total_bytes = w_shape.num_elements().unwrap();
5039                assert_eq!(
5040                    total_bytes,
5041                    num_experts * slab_bytes,
5042                    "DequantMoEWeights packed bytes mismatch"
5043                );
5044                Thunk::DequantMoEWeightsGguf {
5045                    w_q: node_offset(arena, node.inputs[0]),
5046                    dst: node_offset(arena, node.id),
5047                    k_dim: k_dim as u32,
5048                    n: n as u32,
5049                    num_experts: num_experts as u32,
5050                    scheme: *scheme,
5051                }
5052            }
5053
5054            Op::TopK { k } => {
5055                let in_shape = &graph.node(node.inputs[0]).shape;
5056                let rank = in_shape.rank();
5057                let axis_dim = in_shape.dim(rank - 1).unwrap_static();
5058                let outer = in_shape.num_elements().unwrap() / axis_dim;
5059                let indices_i64 = u8::from(graph.node(node.id).shape.dtype() == rlx_ir::DType::I64);
5060                Thunk::TopK {
5061                    src: node_offset(arena, node.inputs[0]),
5062                    dst: node_offset(arena, node.id),
5063                    outer: outer as u32,
5064                    axis_dim: axis_dim as u32,
5065                    k: *k as u32,
5066                    indices_i64,
5067                }
5068            }
5069
5070            Op::Reduce {
5071                op,
5072                axes,
5073                keep_dim: _,
5074            } => {
5075                // Decompose the input shape into [outer, reduced, inner]
5076                // around the reduced axis range. Non-contiguous reduced
5077                // axes aren't supported here — caller must transpose them
5078                // contiguous first (the coverage tool would surface the
5079                // gap if a model needs it).
5080                let in_shape = &graph.node(node.inputs[0]).shape;
5081                let rank = in_shape.rank();
5082                let mut sorted = axes.clone();
5083                sorted.sort();
5084                sorted.dedup();
5085                let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1)
5086                    && !sorted.is_empty()
5087                    && *sorted.last().unwrap() < rank;
5088                if !contiguous {
5089                    Thunk::Nop
5090                } else {
5091                    let first = sorted[0];
5092                    let last = *sorted.last().unwrap();
5093                    let outer: usize = (0..first)
5094                        .map(|i| in_shape.dim(i).unwrap_static())
5095                        .product::<usize>()
5096                        .max(1);
5097                    let reduced: usize = (first..=last)
5098                        .map(|i| in_shape.dim(i).unwrap_static())
5099                        .product();
5100                    let inner: usize = (last + 1..rank)
5101                        .map(|i| in_shape.dim(i).unwrap_static())
5102                        .product::<usize>()
5103                        .max(1);
5104                    let src = node_offset(arena, node.inputs[0]);
5105                    let dst = node_offset(arena, node.id);
5106                    if node.shape.dtype() == rlx_ir::DType::F64 && matches!(op, ReduceOp::Sum) {
5107                        Thunk::ReduceSumF64 {
5108                            src,
5109                            dst,
5110                            outer: outer as u32,
5111                            reduced: reduced as u32,
5112                            inner: inner as u32,
5113                        }
5114                    } else {
5115                        Thunk::Reduce {
5116                            src,
5117                            dst,
5118                            outer: outer as u32,
5119                            reduced: reduced as u32,
5120                            inner: inner as u32,
5121                            op: *op,
5122                        }
5123                    }
5124                }
5125            }
5126
5127            Op::ArgMax { axis, keep_dim: _ } | Op::ArgMin { axis, keep_dim: _ } => {
5128                let in_shape = &graph.node(node.inputs[0]).shape;
5129                let rank = in_shape.rank();
5130                let outer: usize = (0..*axis)
5131                    .map(|i| in_shape.dim(i).unwrap_static())
5132                    .product::<usize>()
5133                    .max(1);
5134                let reduced = in_shape.dim(*axis).unwrap_static();
5135                let inner: usize = (*axis + 1..rank)
5136                    .map(|i| in_shape.dim(i).unwrap_static())
5137                    .product::<usize>()
5138                    .max(1);
5139                Thunk::ArgReduce {
5140                    src: node_offset(arena, node.inputs[0]),
5141                    dst: node_offset(arena, node.id),
5142                    outer: outer as u32,
5143                    reduced: reduced as u32,
5144                    inner: inner as u32,
5145                    is_max: matches!(node.op, Op::ArgMax { .. }),
5146                }
5147            }
5148
5149            Op::Compare(cmp) => {
5150                let len = node.shape.num_elements().unwrap();
5151                let in_dtype = graph.node(node.inputs[0]).shape.dtype();
5152                let inputs_i64 = u8::from(in_dtype == rlx_ir::DType::I64);
5153                Thunk::Compare {
5154                    lhs: node_offset(arena, node.inputs[0]),
5155                    rhs: node_offset(arena, node.inputs[1]),
5156                    dst: node_offset(arena, node.id),
5157                    len: len as u32,
5158                    op: *cmp,
5159                    inputs_i64,
5160                    inputs_elem_bytes: in_dtype.size_bytes() as u8,
5161                    dst_elem_bytes: node.shape.dtype().size_bytes() as u8,
5162                }
5163            }
5164
5165            Op::Where => {
5166                let len = node.shape.num_elements().unwrap();
5167                let elem_bytes = node.shape.dtype().size_bytes() as u8;
5168                let cond_elem_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
5169                Thunk::Where {
5170                    cond: node_offset(arena, node.inputs[0]),
5171                    on_true: node_offset(arena, node.inputs[1]),
5172                    on_false: node_offset(arena, node.inputs[2]),
5173                    dst: node_offset(arena, node.id),
5174                    len: len as u32,
5175                    elem_bytes,
5176                    cond_elem_bytes,
5177                }
5178            }
5179
5180            Op::Fma => {
5181                let len = node.shape.num_elements().unwrap();
5182                Thunk::Fma {
5183                    a: node_offset(arena, node.inputs[0]),
5184                    b: node_offset(arena, node.inputs[1]),
5185                    c: node_offset(arena, node.inputs[2]),
5186                    dst: node_offset(arena, node.id),
5187                    len: len as u32,
5188                    elem_bytes: node.shape.dtype().size_bytes() as u8,
5189                }
5190            }
5191
5192            Op::ReluBackward => {
5193                let len: usize = (0..node.shape.rank())
5194                    .map(|i| node.shape.dim(i).unwrap_static())
5195                    .product();
5196                let x = node_offset(arena, node.inputs[0]);
5197                let dy = node_offset(arena, node.inputs[1]);
5198                let dx = node_offset(arena, node.id);
5199                match node.shape.dtype() {
5200                    rlx_ir::DType::F64 => Thunk::ReluBackwardF64 {
5201                        x,
5202                        dy,
5203                        dx,
5204                        len: len as u32,
5205                    },
5206                    _ => Thunk::ReluBackward {
5207                        x,
5208                        dy,
5209                        dx,
5210                        len: len as u32,
5211                    },
5212                }
5213            }
5214
5215            Op::ComplexNormSq => {
5216                let len: usize = (0..node.shape.rank())
5217                    .map(|i| node.shape.dim(i).unwrap_static())
5218                    .product();
5219                let src = node_offset(arena, node.inputs[0]);
5220                let dst = node_offset(arena, node.id);
5221                Thunk::ComplexNormSqF32 {
5222                    src,
5223                    dst,
5224                    len: len as u32,
5225                }
5226            }
5227
5228            Op::ComplexNormSqBackward => {
5229                let len: usize = (0..node.shape.rank())
5230                    .map(|i| node.shape.dim(i).unwrap_static())
5231                    .product();
5232                let z = node_offset(arena, node.inputs[0]);
5233                let g = node_offset(arena, node.inputs[1]);
5234                let dz = node_offset(arena, node.id);
5235                Thunk::ComplexNormSqBackwardF32 {
5236                    z,
5237                    g,
5238                    dz,
5239                    len: len as u32,
5240                }
5241            }
5242
5243            Op::Conjugate => {
5244                let len: usize = (0..node.shape.rank())
5245                    .map(|i| node.shape.dim(i).unwrap_static())
5246                    .product();
5247                Thunk::ConjugateC64 {
5248                    src: node_offset(arena, node.inputs[0]),
5249                    dst: node_offset(arena, node.id),
5250                    len: len as u32,
5251                }
5252            }
5253
5254            Op::ActivationBackward { kind } => {
5255                let len: usize = (0..node.shape.rank())
5256                    .map(|i| node.shape.dim(i).unwrap_static())
5257                    .product();
5258                let x = node_offset(arena, node.inputs[0]);
5259                let dy = node_offset(arena, node.inputs[1]);
5260                let dx = node_offset(arena, node.id);
5261                match node.shape.dtype() {
5262                    rlx_ir::DType::F64 => Thunk::ActivationBackwardF64 {
5263                        x,
5264                        dy,
5265                        dx,
5266                        len: len as u32,
5267                        kind: *kind,
5268                    },
5269                    _ => Thunk::ActivationBackward {
5270                        x,
5271                        dy,
5272                        dx,
5273                        len: len as u32,
5274                        kind: *kind,
5275                    },
5276                }
5277            }
5278
5279            Op::LayerNormBackwardInput { eps, .. } => {
5280                // axis = -1 only (matches forward LayerNorm thunk).
5281                let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
5282                let total = node.shape.num_elements().unwrap();
5283                Thunk::LayerNormBackwardInput {
5284                    x: node_offset(arena, node.inputs[0]),
5285                    gamma: node_offset(arena, node.inputs[1]),
5286                    dy: node_offset(arena, node.inputs[2]),
5287                    dx: node_offset(arena, node.id),
5288                    rows: (total / h) as u32,
5289                    h: h as u32,
5290                    eps: *eps,
5291                }
5292            }
5293
5294            Op::LayerNormBackwardGamma { eps, .. } => {
5295                let x_shape = &graph.node(node.inputs[0]).shape;
5296                let h = x_shape.dim(x_shape.rank() - 1).unwrap_static();
5297                let x_total = x_shape.num_elements().unwrap();
5298                Thunk::LayerNormBackwardGamma {
5299                    x: node_offset(arena, node.inputs[0]),
5300                    dy: node_offset(arena, node.inputs[1]),
5301                    dgamma: node_offset(arena, node.id),
5302                    rows: (x_total / h) as u32,
5303                    h: h as u32,
5304                    eps: *eps,
5305                }
5306            }
5307
5308            Op::RmsNormBackwardInput { eps, .. }
5309            | Op::RmsNormBackwardGamma { eps, .. }
5310            | Op::RmsNormBackwardBeta { eps, .. } => {
5311                let x_shape = &graph.node(node.inputs[0]).shape;
5312                let h = x_shape.dim(x_shape.rank() - 1).unwrap_static();
5313                let rows = (x_shape.num_elements().unwrap() / h) as u32;
5314                let off = |i: usize| node_offset(arena, node.inputs[i]);
5315                let common = (off(0), off(1), off(2), off(3), rows, h as u32, *eps);
5316                match &node.op {
5317                    Op::RmsNormBackwardInput { .. } => Thunk::RmsNormBackwardInput {
5318                        x: common.0,
5319                        gamma: common.1,
5320                        beta: common.2,
5321                        dy: common.3,
5322                        dx: node_offset(arena, node.id),
5323                        rows: common.4,
5324                        h: common.5,
5325                        eps: common.6,
5326                    },
5327                    Op::RmsNormBackwardGamma { .. } => Thunk::RmsNormBackwardGamma {
5328                        x: common.0,
5329                        gamma: common.1,
5330                        beta: common.2,
5331                        dy: common.3,
5332                        dgamma: node_offset(arena, node.id),
5333                        rows: common.4,
5334                        h: common.5,
5335                        eps: common.6,
5336                    },
5337                    Op::RmsNormBackwardBeta { .. } => Thunk::RmsNormBackwardBeta {
5338                        x: common.0,
5339                        gamma: common.1,
5340                        beta: common.2,
5341                        dy: common.3,
5342                        dbeta: node_offset(arena, node.id),
5343                        rows: common.4,
5344                        h: common.5,
5345                        eps: common.6,
5346                    },
5347                    _ => unreachable!(),
5348                }
5349            }
5350
5351            Op::RopeBackward { head_dim, n_rot } => {
5352                let dy_shape = &graph.node(node.inputs[0]).shape;
5353                let (batch, seq, hidden) = if dy_shape.rank() >= 3 {
5354                    (
5355                        dy_shape.dim(0).unwrap_static(),
5356                        dy_shape.dim(1).unwrap_static(),
5357                        dy_shape.dim(2).unwrap_static(),
5358                    )
5359                } else {
5360                    (
5361                        1,
5362                        dy_shape.dim(0).unwrap_static(),
5363                        dy_shape.dim(1).unwrap_static(),
5364                    )
5365                };
5366                let cos_shape = &graph.node(node.inputs[1]).shape;
5367                let cos_len = cos_shape.num_elements().unwrap();
5368                Thunk::RopeBackward {
5369                    dy: node_offset(arena, node.inputs[0]),
5370                    cos: node_offset(arena, node.inputs[1]),
5371                    sin: node_offset(arena, node.inputs[2]),
5372                    dx: node_offset(arena, node.id),
5373                    batch: batch as u32,
5374                    seq: seq as u32,
5375                    hidden: hidden as u32,
5376                    head_dim: *head_dim as u32,
5377                    n_rot: *n_rot as u32,
5378                    cos_len: cos_len as u32,
5379                }
5380            }
5381
5382            Op::CumsumBackward { exclusive, .. } => {
5383                let dy_shape = &graph.node(node.inputs[0]).shape;
5384                let rank = dy_shape.rank();
5385                let cols = dy_shape.dim(rank - 1).unwrap_static();
5386                let rows = dy_shape.num_elements().unwrap() / cols;
5387                Thunk::CumsumBackward {
5388                    dy: node_offset(arena, node.inputs[0]),
5389                    dx: node_offset(arena, node.id),
5390                    rows: rows as u32,
5391                    cols: cols as u32,
5392                    exclusive: *exclusive,
5393                }
5394            }
5395
5396            Op::GatherBackward { .. } => {
5397                let dy_shape = &graph.node(node.inputs[0]).shape;
5398                let idx_shape = &graph.node(node.inputs[1]).shape;
5399                let out_shape = &node.shape;
5400                let rank = out_shape.rank();
5401                let axis = match &node.op {
5402                    Op::GatherBackward { axis } => *axis,
5403                    _ => 0,
5404                };
5405                let axis_u = if axis < 0 {
5406                    (rank as i32 + axis) as usize
5407                } else {
5408                    axis as usize
5409                };
5410                let outer: usize = (0..axis_u)
5411                    .map(|i| dy_shape.dim(i).unwrap_static())
5412                    .product::<usize>()
5413                    .max(1);
5414                let num_idx = idx_shape.dim(axis_u).unwrap_static();
5415                let trailing: usize = (axis_u + 1..dy_shape.rank())
5416                    .map(|i| dy_shape.dim(i).unwrap_static())
5417                    .product::<usize>()
5418                    .max(1);
5419                let axis_dim = out_shape.dim(axis_u).unwrap_static();
5420                Thunk::GatherBackward {
5421                    dy: node_offset(arena, node.inputs[0]),
5422                    indices: node_offset(arena, node.inputs[1]),
5423                    dst: node_offset(arena, node.id),
5424                    outer: outer as u32,
5425                    axis_dim: axis_dim as u32,
5426                    num_idx: num_idx as u32,
5427                    trailing: trailing as u32,
5428                }
5429            }
5430
5431            Op::GroupNormBackwardInput { num_groups, eps }
5432            | Op::GroupNormBackwardGamma { num_groups, eps }
5433            | Op::GroupNormBackwardBeta { num_groups, eps } => {
5434                let x_shape = &graph.node(node.inputs[0]).shape;
5435                let n = x_shape.dim(0).unwrap_static() as u32;
5436                let c = x_shape.dim(1).unwrap_static() as u32;
5437                let h = x_shape.dim(2).unwrap_static() as u32;
5438                let w = x_shape.dim(3).unwrap_static() as u32;
5439                match &node.op {
5440                    Op::GroupNormBackwardInput { .. } => Thunk::GroupNormBackwardInput {
5441                        x: node_offset(arena, node.inputs[0]),
5442                        gamma: node_offset(arena, node.inputs[1]),
5443                        beta: node_offset(arena, node.inputs[2]),
5444                        dy: node_offset(arena, node.inputs[3]),
5445                        dx: node_offset(arena, node.id),
5446                        n,
5447                        c,
5448                        h,
5449                        w,
5450                        num_groups: *num_groups as u32,
5451                        eps: *eps,
5452                    },
5453                    Op::GroupNormBackwardGamma { .. } => Thunk::GroupNormBackwardGamma {
5454                        x: node_offset(arena, node.inputs[0]),
5455                        dy: node_offset(arena, node.inputs[1]),
5456                        dgamma: node_offset(arena, node.id),
5457                        n,
5458                        c,
5459                        h,
5460                        w,
5461                        num_groups: *num_groups as u32,
5462                        eps: *eps,
5463                    },
5464                    Op::GroupNormBackwardBeta { .. } => Thunk::GroupNormBackwardBeta {
5465                        dy: node_offset(arena, node.inputs[1]),
5466                        dbeta: node_offset(arena, node.id),
5467                        n,
5468                        c,
5469                        h,
5470                        w,
5471                    },
5472                    _ => unreachable!(),
5473                }
5474            }
5475
5476            Op::MaxPool2dBackward {
5477                kernel_size,
5478                stride,
5479                padding,
5480            } => {
5481                let x_shape = &graph.node(node.inputs[0]).shape;
5482                let dy_shape = &graph.node(node.inputs[1]).shape;
5483                if kernel_size.len() == 2 && x_shape.rank() == 4 && dy_shape.rank() == 4 {
5484                    Thunk::MaxPool2dBackward {
5485                        x: node_offset(arena, node.inputs[0]),
5486                        dy: node_offset(arena, node.inputs[1]),
5487                        dx: node_offset(arena, node.id),
5488                        n: x_shape.dim(0).unwrap_static() as u32,
5489                        c: x_shape.dim(1).unwrap_static() as u32,
5490                        h: x_shape.dim(2).unwrap_static() as u32,
5491                        w: x_shape.dim(3).unwrap_static() as u32,
5492                        h_out: dy_shape.dim(2).unwrap_static() as u32,
5493                        w_out: dy_shape.dim(3).unwrap_static() as u32,
5494                        kh: kernel_size[0] as u32,
5495                        kw: kernel_size[1] as u32,
5496                        sh: stride.first().copied().unwrap_or(1) as u32,
5497                        sw: stride.get(1).copied().unwrap_or(1) as u32,
5498                        ph: padding.first().copied().unwrap_or(0) as u32,
5499                        pw: padding.get(1).copied().unwrap_or(0) as u32,
5500                    }
5501                } else {
5502                    Thunk::Nop
5503                }
5504            }
5505
5506            Op::Conv2dBackwardInput {
5507                kernel_size,
5508                stride,
5509                padding,
5510                dilation,
5511                groups,
5512            } => {
5513                let dy_shape = &graph.node(node.inputs[0]).shape;
5514                let w_shape = &graph.node(node.inputs[1]).shape;
5515                let out_shape = &node.shape;
5516                if kernel_size.len() == 2
5517                    && dy_shape.rank() == 4
5518                    && w_shape.rank() == 4
5519                    && out_shape.rank() == 4
5520                {
5521                    Thunk::Conv2dBackwardInput {
5522                        dy: node_offset(arena, node.inputs[0]),
5523                        w: node_offset(arena, node.inputs[1]),
5524                        dx: node_offset(arena, node.id),
5525                        n: out_shape.dim(0).unwrap_static() as u32,
5526                        c_in: out_shape.dim(1).unwrap_static() as u32,
5527                        h: out_shape.dim(2).unwrap_static() as u32,
5528                        w_in: out_shape.dim(3).unwrap_static() as u32,
5529                        c_out: dy_shape.dim(1).unwrap_static() as u32,
5530                        h_out: dy_shape.dim(2).unwrap_static() as u32,
5531                        w_out: dy_shape.dim(3).unwrap_static() as u32,
5532                        kh: kernel_size[0] as u32,
5533                        kw: kernel_size[1] as u32,
5534                        sh: stride.first().copied().unwrap_or(1) as u32,
5535                        sw: stride.get(1).copied().unwrap_or(1) as u32,
5536                        ph: padding.first().copied().unwrap_or(0) as u32,
5537                        pw: padding.get(1).copied().unwrap_or(0) as u32,
5538                        dh: dilation.first().copied().unwrap_or(1) as u32,
5539                        dw: dilation.get(1).copied().unwrap_or(1) as u32,
5540                        groups: *groups as u32,
5541                    }
5542                } else {
5543                    Thunk::Nop
5544                }
5545            }
5546
5547            Op::Conv2dBackwardWeight {
5548                kernel_size,
5549                stride,
5550                padding,
5551                dilation,
5552                groups,
5553            } => {
5554                let x_shape = &graph.node(node.inputs[0]).shape;
5555                let dy_shape = &graph.node(node.inputs[1]).shape;
5556                let dw_shape = &node.shape;
5557                if kernel_size.len() == 2
5558                    && x_shape.rank() == 4
5559                    && dy_shape.rank() == 4
5560                    && dw_shape.rank() == 4
5561                {
5562                    Thunk::Conv2dBackwardWeight {
5563                        x: node_offset(arena, node.inputs[0]),
5564                        dy: node_offset(arena, node.inputs[1]),
5565                        dw: node_offset(arena, node.id),
5566                        n: x_shape.dim(0).unwrap_static() as u32,
5567                        c_in: x_shape.dim(1).unwrap_static() as u32,
5568                        h: x_shape.dim(2).unwrap_static() as u32,
5569                        w: x_shape.dim(3).unwrap_static() as u32,
5570                        c_out: dy_shape.dim(1).unwrap_static() as u32,
5571                        h_out: dy_shape.dim(2).unwrap_static() as u32,
5572                        w_out: dy_shape.dim(3).unwrap_static() as u32,
5573                        kh: kernel_size[0] as u32,
5574                        kw: kernel_size[1] as u32,
5575                        sh: stride.first().copied().unwrap_or(1) as u32,
5576                        sw: stride.get(1).copied().unwrap_or(1) as u32,
5577                        ph: padding.first().copied().unwrap_or(0) as u32,
5578                        pw: padding.get(1).copied().unwrap_or(0) as u32,
5579                        dh: dilation.first().copied().unwrap_or(1) as u32,
5580                        dw_dil: dilation.get(1).copied().unwrap_or(1) as u32,
5581                        groups: *groups as u32,
5582                    }
5583                } else {
5584                    Thunk::Nop
5585                }
5586            }
5587
5588            Op::Im2Col {
5589                kernel_size,
5590                stride,
5591                padding,
5592                dilation,
5593            } => {
5594                let x_shape = &graph.node(node.inputs[0]).shape;
5595                let out_shape = &node.shape;
5596                if kernel_size.len() == 2 && x_shape.rank() == 4 && out_shape.rank() == 2 {
5597                    let n = match x_shape.dim(0) {
5598                        rlx_ir::shape::Dim::Static(v) => v as u32,
5599                        _ => 0,
5600                    };
5601                    let c_in = x_shape.dim(1).unwrap_static() as u32;
5602                    let h = x_shape.dim(2).unwrap_static() as u32;
5603                    let w = x_shape.dim(3).unwrap_static() as u32;
5604                    let kh = kernel_size[0] as u32;
5605                    let kw = kernel_size[1] as u32;
5606                    let sh = stride.first().copied().unwrap_or(1) as u32;
5607                    let sw = stride.get(1).copied().unwrap_or(1) as u32;
5608                    let ph = padding.first().copied().unwrap_or(0) as u32;
5609                    let pw = padding.get(1).copied().unwrap_or(0) as u32;
5610                    let dh = dilation.first().copied().unwrap_or(1) as u32;
5611                    let dw_dil = dilation.get(1).copied().unwrap_or(1) as u32;
5612                    let h_out = rlx_ir::shape::conv2d_spatial_output(
5613                        h as usize,
5614                        kh as usize,
5615                        sh as usize,
5616                        ph as usize,
5617                        dh as usize,
5618                    ) as u32;
5619                    let w_out = rlx_ir::shape::conv2d_spatial_output(
5620                        w as usize,
5621                        kw as usize,
5622                        sw as usize,
5623                        pw as usize,
5624                        dw_dil as usize,
5625                    ) as u32;
5626                    Thunk::Im2Col {
5627                        x: node_offset(arena, node.inputs[0]),
5628                        col: node_offset(arena, node.id),
5629                        n,
5630                        c_in,
5631                        h,
5632                        w,
5633                        h_out,
5634                        w_out,
5635                        kh,
5636                        kw,
5637                        sh,
5638                        sw,
5639                        ph,
5640                        pw,
5641                        dh,
5642                        dw_dil,
5643                    }
5644                } else {
5645                    Thunk::Nop
5646                }
5647            }
5648
5649            Op::SoftmaxCrossEntropy => {
5650                let logits_shape = &graph.node(node.inputs[0]).shape;
5651                if logits_shape.rank() == 2 {
5652                    Thunk::SoftmaxCrossEntropyDense {
5653                        logits: node_offset(arena, node.inputs[0]),
5654                        targets: node_offset(arena, node.inputs[1]),
5655                        dst: node_offset(arena, node.id),
5656                        n: logits_shape.dim(0).unwrap_static() as u32,
5657                        c: logits_shape.dim(1).unwrap_static() as u32,
5658                    }
5659                } else {
5660                    Thunk::Nop
5661                }
5662            }
5663
5664            Op::SoftmaxCrossEntropyWithLogits => {
5665                let logits_shape = &graph.node(node.inputs[0]).shape;
5666                if logits_shape.rank() == 2 {
5667                    Thunk::SoftmaxCrossEntropy {
5668                        logits: node_offset(arena, node.inputs[0]),
5669                        labels: node_offset(arena, node.inputs[1]),
5670                        dst: node_offset(arena, node.id),
5671                        n: logits_shape.dim(0).unwrap_static() as u32,
5672                        c: logits_shape.dim(1).unwrap_static() as u32,
5673                    }
5674                } else {
5675                    Thunk::Nop
5676                }
5677            }
5678
5679            Op::SoftmaxCrossEntropyBackward => {
5680                let logits_shape = &graph.node(node.inputs[0]).shape;
5681                if logits_shape.rank() == 2 {
5682                    Thunk::SoftmaxCrossEntropyBackward {
5683                        logits: node_offset(arena, node.inputs[0]),
5684                        labels: node_offset(arena, node.inputs[1]),
5685                        d_loss: node_offset(arena, node.inputs[2]),
5686                        dlogits: node_offset(arena, node.id),
5687                        n: logits_shape.dim(0).unwrap_static() as u32,
5688                        c: logits_shape.dim(1).unwrap_static() as u32,
5689                    }
5690                } else {
5691                    Thunk::Nop
5692                }
5693            }
5694
5695            Op::DenseSolve => {
5696                // A: [n, n], b: [n] or [n, nrhs]. Output matches b.
5697                let a_shape = &graph.node(node.inputs[0]).shape;
5698                let n = a_shape.dim(0).unwrap_static();
5699                debug_assert_eq!(
5700                    n,
5701                    a_shape.dim(1).unwrap_static(),
5702                    "DenseSolve: A must be square"
5703                );
5704                let b_elems = node.shape.num_elements().unwrap();
5705                let nrhs = b_elems / n;
5706                match node.shape.dtype() {
5707                    rlx_ir::DType::F64 => Thunk::DenseSolveF64 {
5708                        a: node_offset(arena, node.inputs[0]),
5709                        b: node_offset(arena, node.inputs[1]),
5710                        x: node_offset(arena, node.id),
5711                        n: n as u32,
5712                        nrhs: nrhs as u32,
5713                    },
5714                    rlx_ir::DType::F32 => Thunk::DenseSolveF32 {
5715                        a: node_offset(arena, node.inputs[0]),
5716                        b: node_offset(arena, node.inputs[1]),
5717                        x: node_offset(arena, node.id),
5718                        n: n as u32,
5719                        nrhs: nrhs as u32,
5720                    },
5721                    other => panic!(
5722                        "DenseSolve: F32 + F64 lowered; got {other:?}. \
5723                         Add another variant when needed."
5724                    ),
5725                }
5726            }
5727
5728            Op::BatchedDenseSolve => {
5729                // A: [B, N, N], b: [B, N] or [B, N, K]. Output matches b.
5730                let a_shape = &graph.node(node.inputs[0]).shape;
5731                assert_eq!(a_shape.rank(), 3, "BatchedDenseSolve: A rank must be 3");
5732                let batch = a_shape.dim(0).unwrap_static();
5733                let n = a_shape.dim(1).unwrap_static();
5734                debug_assert_eq!(
5735                    n,
5736                    a_shape.dim(2).unwrap_static(),
5737                    "BatchedDenseSolve: A's last two dims must match"
5738                );
5739                let total = node.shape.num_elements().unwrap();
5740                let nrhs = total / (batch * n);
5741                match node.shape.dtype() {
5742                    rlx_ir::DType::F32 => Thunk::BatchedDenseSolveF32 {
5743                        a: node_offset(arena, node.inputs[0]),
5744                        b: node_offset(arena, node.inputs[1]),
5745                        x: node_offset(arena, node.id),
5746                        batch: batch as u32,
5747                        n: n as u32,
5748                        nrhs: nrhs as u32,
5749                    },
5750                    rlx_ir::DType::F64 => Thunk::BatchedDenseSolveF64 {
5751                        a: node_offset(arena, node.inputs[0]),
5752                        b: node_offset(arena, node.inputs[1]),
5753                        x: node_offset(arena, node.id),
5754                        batch: batch as u32,
5755                        n: n as u32,
5756                        nrhs: nrhs as u32,
5757                    },
5758                    other => panic!("BatchedDenseSolve: F32 + F64 only, got {other:?}"),
5759                }
5760            }
5761
5762            Op::Scan {
5763                body,
5764                length,
5765                save_trajectory,
5766                num_bcast,
5767                num_xs,
5768                num_checkpoints,
5769            } => {
5770                assert!(
5771                    *num_checkpoints == 0 || *num_checkpoints <= *length,
5772                    "Op::Scan: num_checkpoints={} must be 0 or ≤ length={}",
5773                    *num_checkpoints,
5774                    *length
5775                );
5776                if *num_checkpoints != 0 && *num_checkpoints != *length {
5777                    assert!(
5778                        *save_trajectory,
5779                        "Op::Scan: num_checkpoints<length only meaningful when save_trajectory=true"
5780                    );
5781                }
5782                // Plan + compile the body sub-graph standalone. The body
5783                // gets its own Arena; per execution we clone its
5784                // pristine bytes, copy the outer carry (and per-step xs
5785                // slices, if any) into the body's Input slots, run the
5786                // body schedule N times, then copy the body's output
5787                // back to the outer arena.
5788                //
5789                // Body invariants: 1 + num_xs Op::Inputs in NodeId order
5790                // — first declared is the carry, rest are x_t_i. Single
5791                // graph output (the next carry), same shape as carry.
5792                let body_plan = rlx_opt::memory::plan_memory(body);
5793                let _body_arena_size = body_plan.arena_size;
5794                // Snapshot per-input byte offsets before plan_memory
5795                // moves into the Arena below.
5796                let body_offsets: HashMap<NodeId, usize> = body_plan
5797                    .assignments
5798                    .iter()
5799                    .map(|(id, slot)| (*id, slot.offset))
5800                    .collect();
5801
5802                // Collect body Input nodes in NodeId order; first is
5803                // carry, rest are per-step xs in matching order.
5804                let mut body_inputs: Vec<NodeId> = body
5805                    .nodes()
5806                    .iter()
5807                    .filter(|n| matches!(n.op, Op::Input { .. }))
5808                    .map(|n| n.id)
5809                    .collect();
5810                body_inputs.sort();
5811                let n_body_inputs = body_inputs.len();
5812                let expected = 1 + *num_bcast as usize + *num_xs as usize;
5813                if n_body_inputs != expected {
5814                    let names: Vec<String> = body
5815                        .nodes()
5816                        .iter()
5817                        .filter_map(|n| match &n.op {
5818                            Op::Input { name } => Some(format!("{}={}", n.id, name)),
5819                            _ => None,
5820                        })
5821                        .collect();
5822                    panic!(
5823                        "Op::Scan body has {} Op::Input nodes; expected {} \
5824                            (1 carry + {} bcast + {} xs). Inputs by NodeId: [{}]",
5825                        n_body_inputs,
5826                        expected,
5827                        *num_bcast,
5828                        *num_xs,
5829                        names.join(", ")
5830                    );
5831                }
5832
5833                let body_input_id = body_inputs[0];
5834                let body_input_off = body_offsets[&body_input_id];
5835                let body_output_id = body
5836                    .outputs
5837                    .first()
5838                    .copied()
5839                    .expect("Op::Scan body must declare one output");
5840                let body_output_off = body_offsets[&body_output_id];
5841
5842                let mut body_arena = crate::arena::Arena::from_plan(body_plan);
5843                // Fill body Constant nodes — mirror the outer-graph logic
5844                // in rlx-runtime/src/backend.rs (dtype-aware).
5845                for n in body.nodes() {
5846                    if let Op::Constant { data } = &n.op
5847                        && body_arena.has_buffer(n.id)
5848                        && !data.is_empty()
5849                    {
5850                        match n.shape.dtype() {
5851                            rlx_ir::DType::F64 => {
5852                                let off = body_arena.byte_offset(n.id);
5853                                let buf = body_arena.raw_buf_mut();
5854                                let nbytes = (buf.len() - off).min(data.len());
5855                                buf[off..off + nbytes].copy_from_slice(&data[..nbytes]);
5856                            }
5857                            _ => {
5858                                let buf = body_arena.slice_mut(n.id);
5859                                let n_floats = data.len() / 4;
5860                                let n_lim = buf.len().min(n_floats);
5861                                for i in 0..n_lim {
5862                                    let bytes = [
5863                                        data[i * 4],
5864                                        data[i * 4 + 1],
5865                                        data[i * 4 + 2],
5866                                        data[i * 4 + 3],
5867                                    ];
5868                                    buf[i] = f32::from_le_bytes(bytes);
5869                                }
5870                            }
5871                        }
5872                    }
5873                }
5874                let body_init = body_arena.raw_buf().to_vec();
5875                let body_schedule = compile_thunks_with_rng(body, &body_arena, rng);
5876
5877                // Carry bytes — for trajectory mode, the outer node's
5878                // shape is [length, *carry_shape], so dividing by length
5879                // gives one row's bytes; the body's input slot still
5880                // holds carry_shape bytes.
5881                let carry_bytes = if *save_trajectory {
5882                    let total = node
5883                        .shape
5884                        .size_bytes()
5885                        .expect("Op::Scan trajectory output must have static shape");
5886                    total / *length as usize
5887                } else {
5888                    node.shape
5889                        .size_bytes()
5890                        .expect("Op::Scan carry must have static shape")
5891                };
5892
5893                // Bcast inputs occupy body_inputs[1..1+num_bcast] and
5894                // outer node.inputs[1..1+num_bcast]. They keep their
5895                // natural shape (no [length, ...] prefix) and are
5896                // copied into body_buf ONCE before the scan loop.
5897                let mut bcast_inputs: Vec<(usize, usize, u32)> =
5898                    Vec::with_capacity(*num_bcast as usize);
5899                for i in 0..*num_bcast as usize {
5900                    let body_b_id = body_inputs[1 + i];
5901                    let body_b_off = body_offsets[&body_b_id];
5902                    let outer_b_id = node.inputs[1 + i];
5903                    let outer_b_off = node_offset(arena, outer_b_id);
5904                    let outer_b_shape = &graph.node(outer_b_id).shape;
5905                    let total = outer_b_shape
5906                        .size_bytes()
5907                        .expect("Op::Scan bcast must have static shape");
5908                    bcast_inputs.push((body_b_off, outer_b_off, total as u32));
5909                }
5910
5911                // xs occupy body_inputs[1+num_bcast..] and node.inputs
5912                // [1+num_bcast..]. Each has shape [length, *per_step];
5913                // per-step bytes = total / length.
5914                let mut xs_inputs: Vec<(usize, usize, u32)> = Vec::with_capacity(*num_xs as usize);
5915                let xs_base = 1 + *num_bcast as usize;
5916                for i in 0..*num_xs as usize {
5917                    let body_x_id = body_inputs[xs_base + i];
5918                    let body_x_off = body_offsets[&body_x_id];
5919                    let outer_xs_id = node.inputs[xs_base + i];
5920                    let outer_xs_off = node_offset(arena, outer_xs_id);
5921                    let outer_xs_shape = &graph.node(outer_xs_id).shape;
5922                    let total = outer_xs_shape
5923                        .size_bytes()
5924                        .expect("Op::Scan xs must have static shape");
5925                    let per_step = total / *length as usize;
5926                    xs_inputs.push((body_x_off, outer_xs_off, per_step as u32));
5927                }
5928
5929                Thunk::Scan {
5930                    body: Arc::new(body_schedule),
5931                    body_init: Arc::new(body_init),
5932                    body_input_off,
5933                    body_output_off,
5934                    outer_init_off: node_offset(arena, node.inputs[0]),
5935                    outer_final_off: node_offset(arena, node.id),
5936                    length: *length,
5937                    carry_bytes: carry_bytes as u32,
5938                    save_trajectory: *save_trajectory,
5939                    xs_inputs: Arc::new(xs_inputs),
5940                    bcast_inputs: Arc::new(bcast_inputs),
5941                    num_checkpoints: *num_checkpoints,
5942                }
5943            }
5944
5945            Op::ScanBackward {
5946                body_vjp,
5947                length,
5948                save_trajectory,
5949                num_xs,
5950                num_checkpoints,
5951                forward_body,
5952            } => {
5953                let is_recursive = *num_checkpoints != 0 && *num_checkpoints != *length;
5954                if is_recursive {
5955                    assert!(
5956                        forward_body.is_some(),
5957                        "Op::ScanBackward with num_checkpoints<length requires forward_body"
5958                    );
5959                }
5960                // body_vjp has signature
5961                //   (carry, x_t_0, ..., x_t_{num_xs-1}, d_output) → dcarry
5962                // Identify slots:
5963                //   * "d_output" by exact name (AD-introduced seed Input).
5964                //   * Remaining Inputs sorted by NodeId — first is the
5965                //     carry mirror, rest are x_t_i mirrors in body's
5966                //     original Op::Input declaration order.
5967                let body_plan = rlx_opt::memory::plan_memory(body_vjp);
5968                let body_offsets: HashMap<NodeId, usize> = body_plan
5969                    .assignments
5970                    .iter()
5971                    .map(|(id, slot)| (*id, slot.offset))
5972                    .collect();
5973                let mut body_d_output_off: Option<usize> = None;
5974                let mut body_other_inputs: Vec<(NodeId, usize)> = Vec::new();
5975                for n in body_vjp.nodes() {
5976                    if let Op::Input { name } = &n.op {
5977                        let off = body_offsets[&n.id];
5978                        if name == "d_output" {
5979                            body_d_output_off = Some(off);
5980                        } else {
5981                            body_other_inputs.push((n.id, off));
5982                        }
5983                    }
5984                }
5985                body_other_inputs.sort_by_key(|(id, _)| *id);
5986                let body_d_output_off =
5987                    body_d_output_off.expect("ScanBackward body_vjp missing 'd_output' Input");
5988                let expected_others = 1 + *num_xs as usize;
5989                assert_eq!(
5990                    body_other_inputs.len(),
5991                    expected_others,
5992                    "ScanBackward body_vjp has {} non-d_output Inputs; \
5993                     expected {} (1 carry + {} xs)",
5994                    body_other_inputs.len(),
5995                    expected_others,
5996                    num_xs
5997                );
5998                let body_carry_in_off = body_other_inputs[0].1;
5999                let body_x_offs: Vec<usize> = body_other_inputs
6000                    .iter()
6001                    .skip(1)
6002                    .map(|(_, off)| *off)
6003                    .collect();
6004                let body_dcarry_out_off = body_offsets[&body_vjp.outputs[0]];
6005
6006                let mut body_arena = crate::arena::Arena::from_plan(body_plan);
6007                // Fill body_vjp's Constants (mirrors the Scan lowering).
6008                for n in body_vjp.nodes() {
6009                    if let Op::Constant { data } = &n.op
6010                        && body_arena.has_buffer(n.id)
6011                        && !data.is_empty()
6012                    {
6013                        match n.shape.dtype() {
6014                            rlx_ir::DType::F64 => {
6015                                let off = body_arena.byte_offset(n.id);
6016                                let buf = body_arena.raw_buf_mut();
6017                                let nb = (buf.len() - off).min(data.len());
6018                                buf[off..off + nb].copy_from_slice(&data[..nb]);
6019                            }
6020                            _ => {
6021                                let buf = body_arena.slice_mut(n.id);
6022                                let nf = data.len() / 4;
6023                                let nl = buf.len().min(nf);
6024                                for i in 0..nl {
6025                                    let bytes = [
6026                                        data[i * 4],
6027                                        data[i * 4 + 1],
6028                                        data[i * 4 + 2],
6029                                        data[i * 4 + 3],
6030                                    ];
6031                                    buf[i] = f32::from_le_bytes(bytes);
6032                                }
6033                            }
6034                        }
6035                    }
6036                }
6037                let body_init = body_arena.raw_buf().to_vec();
6038                let body_schedule = compile_thunks_with_rng(body_vjp, &body_arena, rng);
6039
6040                // Carry bytes from the dcarry output node (== carry shape).
6041                let carry_bytes = body_vjp
6042                    .node(body_vjp.outputs[0])
6043                    .shape
6044                    .size_bytes()
6045                    .expect("ScanBackward dcarry must be statically shaped");
6046                let carry_elem_size = body_vjp
6047                    .node(body_vjp.outputs[0])
6048                    .shape
6049                    .dtype()
6050                    .size_bytes() as u32;
6051
6052                // For each xs input on the outer node:
6053                // (outer_xs_base, per_step_bytes).
6054                let mut outer_xs_offs: Vec<(usize, u32)> = Vec::with_capacity(*num_xs as usize);
6055                for i in 0..*num_xs as usize {
6056                    let outer_xs_id = node.inputs[3 + i];
6057                    let outer_xs_off = node_offset(arena, outer_xs_id);
6058                    let outer_xs_shape = &graph.node(outer_xs_id).shape;
6059                    let total = outer_xs_shape
6060                        .size_bytes()
6061                        .expect("ScanBackward xs must have static shape");
6062                    let per_step = total / *length as usize;
6063                    outer_xs_offs.push((outer_xs_off, per_step as u32));
6064                }
6065
6066                // If recursive checkpointing is active, we also compile
6067                // the forward body so the executor can recompute
6068                // intermediate carries. The forward body is supplied
6069                // by the AD pass via `forward_body: Some(_)`.
6070                let (fb_schedule, fb_init, fb_carry_in_off, fb_output_off, fb_x_offs) =
6071                    if is_recursive {
6072                        let fb = forward_body.as_ref().unwrap();
6073                        let fb_plan = rlx_opt::memory::plan_memory(fb);
6074                        let fb_offsets: HashMap<NodeId, usize> = fb_plan
6075                            .assignments
6076                            .iter()
6077                            .map(|(id, slot)| (*id, slot.offset))
6078                            .collect();
6079                        let mut fb_inputs: Vec<NodeId> = fb
6080                            .nodes()
6081                            .iter()
6082                            .filter(|n| matches!(n.op, Op::Input { .. }))
6083                            .map(|n| n.id)
6084                            .collect();
6085                        fb_inputs.sort();
6086                        let fb_carry = fb_offsets[&fb_inputs[0]];
6087                        let fb_xs: Vec<usize> = (1..fb_inputs.len())
6088                            .map(|i| fb_offsets[&fb_inputs[i]])
6089                            .collect();
6090                        let fb_out = fb_offsets[&fb.outputs[0]];
6091                        let mut fb_arena = crate::arena::Arena::from_plan(fb_plan);
6092                        for n in fb.nodes() {
6093                            if let Op::Constant { data } = &n.op
6094                                && fb_arena.has_buffer(n.id)
6095                                && !data.is_empty()
6096                            {
6097                                // Byte-copy works for any
6098                                // numeric dtype as long as the
6099                                // arena slot is sized to hold
6100                                // it — the Constant's `data`
6101                                // already encodes the right
6102                                // bytes per element.
6103                                let off = fb_arena.byte_offset(n.id);
6104                                let buf = fb_arena.raw_buf_mut();
6105                                let nb = (buf.len() - off).min(data.len());
6106                                buf[off..off + nb].copy_from_slice(&data[..nb]);
6107                            }
6108                        }
6109                        let fb_init_bytes = fb_arena.raw_buf().to_vec();
6110                        let fb_sched = compile_thunks_with_rng(fb, &fb_arena, rng);
6111                        (
6112                            Some(Arc::new(fb_sched)),
6113                            Some(Arc::new(fb_init_bytes)),
6114                            fb_carry,
6115                            fb_out,
6116                            fb_xs,
6117                        )
6118                    } else {
6119                        (None, None, 0, 0, Vec::new())
6120                    };
6121
6122                Thunk::ScanBackward {
6123                    body_vjp: Arc::new(body_schedule),
6124                    body_init: Arc::new(body_init),
6125                    body_carry_in_off,
6126                    body_x_offs: Arc::new(body_x_offs),
6127                    body_d_output_off,
6128                    body_dcarry_out_off,
6129                    outer_init_off: node_offset(arena, node.inputs[0]),
6130                    outer_traj_off: node_offset(arena, node.inputs[1]),
6131                    outer_upstream_off: node_offset(arena, node.inputs[2]),
6132                    outer_xs_offs: Arc::new(outer_xs_offs),
6133                    outer_dinit_off: node_offset(arena, node.id),
6134                    length: *length,
6135                    carry_bytes: carry_bytes as u32,
6136                    carry_elem_size,
6137                    save_trajectory: *save_trajectory,
6138                    num_checkpoints: *num_checkpoints,
6139                    forward_body: fb_schedule,
6140                    forward_body_init: fb_init,
6141                    forward_body_carry_in_off: fb_carry_in_off,
6142                    forward_body_output_off: fb_output_off,
6143                    forward_body_x_offs: Arc::new(fb_x_offs),
6144                }
6145            }
6146
6147            Op::ScanBackwardXs {
6148                body_vjp,
6149                length,
6150                save_trajectory,
6151                num_xs,
6152                xs_idx,
6153                num_checkpoints,
6154                forward_body,
6155            } => {
6156                assert!(
6157                    *num_checkpoints == 0 || *num_checkpoints <= *length,
6158                    "Op::ScanBackwardXs: num_checkpoints={} must be 0 or ≤ length={}",
6159                    *num_checkpoints,
6160                    *length
6161                );
6162                let is_recursive = *num_checkpoints != 0 && *num_checkpoints != *length;
6163                if is_recursive {
6164                    assert!(
6165                        forward_body.is_some(),
6166                        "Op::ScanBackwardXs with num_checkpoints<length \
6167                         requires forward_body"
6168                    );
6169                }
6170                // Mirror ScanBackward's body_vjp slot identification +
6171                // arena prep, then add: per-iteration extraction of the
6172                // body_vjp output that corresponds to the chosen xs.
6173                //
6174                // body_vjp's outputs (from `grad(body, [carry, xs_0, ..., xs_{num_xs-1}])`):
6175                //   outputs[0]      = dcarry
6176                //   outputs[1 + i]  = dx_t_i
6177                let body_plan = rlx_opt::memory::plan_memory(body_vjp);
6178                let body_offsets: HashMap<NodeId, usize> = body_plan
6179                    .assignments
6180                    .iter()
6181                    .map(|(id, slot)| (*id, slot.offset))
6182                    .collect();
6183                let mut body_d_output_off: Option<usize> = None;
6184                let mut body_other_inputs: Vec<(NodeId, usize)> = Vec::new();
6185                for n in body_vjp.nodes() {
6186                    if let Op::Input { name } = &n.op {
6187                        let off = body_offsets[&n.id];
6188                        if name == "d_output" {
6189                            body_d_output_off = Some(off);
6190                        } else {
6191                            body_other_inputs.push((n.id, off));
6192                        }
6193                    }
6194                }
6195                body_other_inputs.sort_by_key(|(id, _)| *id);
6196                let body_d_output_off =
6197                    body_d_output_off.expect("ScanBackwardXs body_vjp missing 'd_output' Input");
6198                let expected_others = 1 + *num_xs as usize;
6199                assert_eq!(
6200                    body_other_inputs.len(),
6201                    expected_others,
6202                    "ScanBackwardXs body_vjp has {} non-d_output Inputs; expected {}",
6203                    body_other_inputs.len(),
6204                    expected_others
6205                );
6206                let body_carry_in_off = body_other_inputs[0].1;
6207                let body_x_offs: Vec<usize> = body_other_inputs
6208                    .iter()
6209                    .skip(1)
6210                    .map(|(_, off)| *off)
6211                    .collect();
6212                let body_dcarry_out_off = body_offsets[&body_vjp.outputs[0]];
6213                let dxs_out_node = body_vjp.outputs[1 + *xs_idx as usize];
6214                let body_dxs_out_off = body_offsets[&dxs_out_node];
6215
6216                let mut body_arena = crate::arena::Arena::from_plan(body_plan);
6217                for n in body_vjp.nodes() {
6218                    if let Op::Constant { data } = &n.op
6219                        && body_arena.has_buffer(n.id)
6220                        && !data.is_empty()
6221                    {
6222                        match n.shape.dtype() {
6223                            rlx_ir::DType::F64 => {
6224                                let off = body_arena.byte_offset(n.id);
6225                                let buf = body_arena.raw_buf_mut();
6226                                let nb = (buf.len() - off).min(data.len());
6227                                buf[off..off + nb].copy_from_slice(&data[..nb]);
6228                            }
6229                            _ => {
6230                                let buf = body_arena.slice_mut(n.id);
6231                                let nf = data.len() / 4;
6232                                let nl = buf.len().min(nf);
6233                                for i in 0..nl {
6234                                    let bytes = [
6235                                        data[i * 4],
6236                                        data[i * 4 + 1],
6237                                        data[i * 4 + 2],
6238                                        data[i * 4 + 3],
6239                                    ];
6240                                    buf[i] = f32::from_le_bytes(bytes);
6241                                }
6242                            }
6243                        }
6244                    }
6245                }
6246                let body_init = body_arena.raw_buf().to_vec();
6247                let body_schedule = compile_thunks_with_rng(body_vjp, &body_arena, rng);
6248
6249                let carry_bytes = body_vjp
6250                    .node(body_vjp.outputs[0])
6251                    .shape
6252                    .size_bytes()
6253                    .expect("ScanBackwardXs dcarry must be statically shaped");
6254                let carry_elem_size = body_vjp
6255                    .node(body_vjp.outputs[0])
6256                    .shape
6257                    .dtype()
6258                    .size_bytes() as u32;
6259                let per_step_bytes = body_vjp
6260                    .node(dxs_out_node)
6261                    .shape
6262                    .size_bytes()
6263                    .expect("ScanBackwardXs dxs body output must be statically shaped");
6264
6265                let mut outer_xs_offs: Vec<(usize, u32)> = Vec::with_capacity(*num_xs as usize);
6266                for i in 0..*num_xs as usize {
6267                    let outer_xs_id = node.inputs[3 + i];
6268                    let outer_xs_off = node_offset(arena, outer_xs_id);
6269                    let outer_xs_shape = &graph.node(outer_xs_id).shape;
6270                    let total = outer_xs_shape
6271                        .size_bytes()
6272                        .expect("ScanBackwardXs xs must have static shape");
6273                    let per_step = total / *length as usize;
6274                    outer_xs_offs.push((outer_xs_off, per_step as u32));
6275                }
6276
6277                // Compile forward_body for recompute when checkpointed.
6278                // Mirrors the same code path in the ScanBackward arm.
6279                let (fb_schedule, fb_init, fb_carry_in_off, fb_output_off, fb_x_offs) =
6280                    if is_recursive {
6281                        let fb = forward_body.as_ref().unwrap();
6282                        let fb_plan = rlx_opt::memory::plan_memory(fb);
6283                        let fb_offsets: HashMap<NodeId, usize> = fb_plan
6284                            .assignments
6285                            .iter()
6286                            .map(|(id, slot)| (*id, slot.offset))
6287                            .collect();
6288                        let mut fb_inputs: Vec<NodeId> = fb
6289                            .nodes()
6290                            .iter()
6291                            .filter(|n| matches!(n.op, Op::Input { .. }))
6292                            .map(|n| n.id)
6293                            .collect();
6294                        fb_inputs.sort();
6295                        let fb_carry = fb_offsets[&fb_inputs[0]];
6296                        let fb_xs: Vec<usize> = (1..fb_inputs.len())
6297                            .map(|i| fb_offsets[&fb_inputs[i]])
6298                            .collect();
6299                        let fb_out = fb_offsets[&fb.outputs[0]];
6300                        let mut fb_arena = crate::arena::Arena::from_plan(fb_plan);
6301                        for n in fb.nodes() {
6302                            if let Op::Constant { data } = &n.op
6303                                && fb_arena.has_buffer(n.id)
6304                                && !data.is_empty()
6305                            {
6306                                // Byte-copy works for any
6307                                // numeric dtype as long as the
6308                                // arena slot is sized to hold
6309                                // it — the Constant's `data`
6310                                // already encodes the right
6311                                // bytes per element.
6312                                let off = fb_arena.byte_offset(n.id);
6313                                let buf = fb_arena.raw_buf_mut();
6314                                let nb = (buf.len() - off).min(data.len());
6315                                buf[off..off + nb].copy_from_slice(&data[..nb]);
6316                            }
6317                        }
6318                        let fb_init_bytes = fb_arena.raw_buf().to_vec();
6319                        let fb_sched = compile_thunks_with_rng(fb, &fb_arena, rng);
6320                        (
6321                            Some(Arc::new(fb_sched)),
6322                            Some(Arc::new(fb_init_bytes)),
6323                            fb_carry,
6324                            fb_out,
6325                            fb_xs,
6326                        )
6327                    } else {
6328                        (None, None, 0, 0, Vec::new())
6329                    };
6330
6331                Thunk::ScanBackwardXs {
6332                    body_vjp: Arc::new(body_schedule),
6333                    body_init: Arc::new(body_init),
6334                    body_carry_in_off,
6335                    body_x_offs: Arc::new(body_x_offs),
6336                    body_d_output_off,
6337                    body_dcarry_out_off,
6338                    body_dxs_out_off,
6339                    outer_init_off: node_offset(arena, node.inputs[0]),
6340                    outer_traj_off: node_offset(arena, node.inputs[1]),
6341                    outer_upstream_off: node_offset(arena, node.inputs[2]),
6342                    outer_xs_offs: Arc::new(outer_xs_offs),
6343                    outer_dxs_off: node_offset(arena, node.id),
6344                    length: *length,
6345                    carry_bytes: carry_bytes as u32,
6346                    carry_elem_size,
6347                    per_step_bytes: per_step_bytes as u32,
6348                    save_trajectory: *save_trajectory,
6349                    num_checkpoints: *num_checkpoints,
6350                    forward_body: fb_schedule,
6351                    forward_body_init: fb_init,
6352                    forward_body_carry_in_off: fb_carry_in_off,
6353                    forward_body_output_off: fb_output_off,
6354                    forward_body_x_offs: Arc::new(fb_x_offs),
6355                }
6356            }
6357
6358            Op::Concat { axis } => {
6359                // Compute outer/inner from the OUTPUT shape: all inputs share
6360                // the same shape except along `axis`. The output's leading
6361                // and trailing dims match.
6362                let out_shape = &node.shape;
6363                let rank = out_shape.rank();
6364                let outer: usize = (0..*axis)
6365                    .map(|i| out_shape.dim(i).unwrap_static())
6366                    .product::<usize>()
6367                    .max(1);
6368                let inner: usize = (*axis + 1..rank)
6369                    .map(|i| out_shape.dim(i).unwrap_static())
6370                    .product::<usize>()
6371                    .max(1);
6372                let total_axis = out_shape.dim(*axis).unwrap_static();
6373                let inputs: Vec<(usize, u32, u32)> = node
6374                    .inputs
6375                    .iter()
6376                    .map(|&in_id| {
6377                        let in_shape = &graph.node(in_id).shape;
6378                        let in_axis = concat_axis_extent(in_shape, *axis, rank);
6379                        let in_numel = in_shape.num_elements().unwrap_or(0) as u32;
6380                        (node_offset(arena, in_id), in_axis as u32, in_numel)
6381                    })
6382                    .collect();
6383                let dst = node_offset(arena, node.id);
6384                match out_shape.dtype() {
6385                    rlx_ir::DType::F64 => Thunk::ConcatF64 {
6386                        dst,
6387                        outer: outer as u32,
6388                        inner: inner as u32,
6389                        total_axis: total_axis as u32,
6390                        inputs,
6391                    },
6392                    _ => Thunk::Concat {
6393                        dst,
6394                        outer: outer as u32,
6395                        inner: inner as u32,
6396                        total_axis: total_axis as u32,
6397                        inputs,
6398                    },
6399                }
6400            }
6401
6402            Op::GaussianSplatRender {
6403                width,
6404                height,
6405                tile_size,
6406                radius_scale,
6407                alpha_cutoff,
6408                max_splat_steps,
6409                transmittance_threshold,
6410                max_list_entries,
6411            } => {
6412                let elem_len =
6413                    |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
6414                Thunk::GaussianSplatRender {
6415                    positions_off: node_offset(arena, node.inputs[0]),
6416                    positions_len: elem_len(node.inputs[0]),
6417                    scales_off: node_offset(arena, node.inputs[1]),
6418                    scales_len: elem_len(node.inputs[1]),
6419                    rotations_off: node_offset(arena, node.inputs[2]),
6420                    rotations_len: elem_len(node.inputs[2]),
6421                    opacities_off: node_offset(arena, node.inputs[3]),
6422                    opacities_len: elem_len(node.inputs[3]),
6423                    colors_off: node_offset(arena, node.inputs[4]),
6424                    colors_len: elem_len(node.inputs[4]),
6425                    sh_coeffs_off: node_offset(arena, node.inputs[5]),
6426                    sh_coeffs_len: elem_len(node.inputs[5]),
6427                    meta_off: node_offset(arena, node.inputs[6]),
6428                    dst_off: node_offset(arena, node.id),
6429                    dst_len: node.shape.num_elements().unwrap_or(0),
6430                    width: *width,
6431                    height: *height,
6432                    tile_size: *tile_size,
6433                    radius_scale: *radius_scale,
6434                    alpha_cutoff: *alpha_cutoff,
6435                    max_splat_steps: *max_splat_steps,
6436                    transmittance_threshold: *transmittance_threshold,
6437                    max_list_entries: *max_list_entries,
6438                }
6439            }
6440
6441            Op::GaussianSplatRenderBackward {
6442                width,
6443                height,
6444                tile_size,
6445                radius_scale,
6446                alpha_cutoff,
6447                max_splat_steps,
6448                transmittance_threshold,
6449                max_list_entries,
6450                loss_grad_clip,
6451                sh_band,
6452                max_anisotropy,
6453            } => {
6454                let elem_len =
6455                    |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
6456                Thunk::GaussianSplatRenderBackward {
6457                    positions_off: node_offset(arena, node.inputs[0]),
6458                    positions_len: elem_len(node.inputs[0]),
6459                    scales_off: node_offset(arena, node.inputs[1]),
6460                    scales_len: elem_len(node.inputs[1]),
6461                    rotations_off: node_offset(arena, node.inputs[2]),
6462                    rotations_len: elem_len(node.inputs[2]),
6463                    opacities_off: node_offset(arena, node.inputs[3]),
6464                    opacities_len: elem_len(node.inputs[3]),
6465                    colors_off: node_offset(arena, node.inputs[4]),
6466                    colors_len: elem_len(node.inputs[4]),
6467                    sh_coeffs_off: node_offset(arena, node.inputs[5]),
6468                    sh_coeffs_len: elem_len(node.inputs[5]),
6469                    meta_off: node_offset(arena, node.inputs[6]),
6470                    d_loss_off: node_offset(arena, node.inputs[7]),
6471                    d_loss_len: elem_len(node.inputs[7]),
6472                    packed_off: node_offset(arena, node.id),
6473                    packed_len: node.shape.num_elements().unwrap_or(0),
6474                    width: *width,
6475                    height: *height,
6476                    tile_size: *tile_size,
6477                    radius_scale: *radius_scale,
6478                    alpha_cutoff: *alpha_cutoff,
6479                    max_splat_steps: *max_splat_steps,
6480                    transmittance_threshold: *transmittance_threshold,
6481                    max_list_entries: *max_list_entries,
6482                    loss_grad_clip: *loss_grad_clip,
6483                    sh_band: *sh_band,
6484                    max_anisotropy: *max_anisotropy,
6485                }
6486            }
6487
6488            Op::GaussianSplatPrepare {
6489                width,
6490                height,
6491                tile_size,
6492                radius_scale,
6493                alpha_cutoff,
6494                max_splat_steps,
6495                transmittance_threshold,
6496                max_list_entries,
6497            } => {
6498                let elem_len =
6499                    |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
6500                Thunk::GaussianSplatPrepare {
6501                    positions_off: node_offset(arena, node.inputs[0]),
6502                    positions_len: elem_len(node.inputs[0]),
6503                    scales_off: node_offset(arena, node.inputs[1]),
6504                    scales_len: elem_len(node.inputs[1]),
6505                    rotations_off: node_offset(arena, node.inputs[2]),
6506                    rotations_len: elem_len(node.inputs[2]),
6507                    opacities_off: node_offset(arena, node.inputs[3]),
6508                    opacities_len: elem_len(node.inputs[3]),
6509                    colors_off: node_offset(arena, node.inputs[4]),
6510                    colors_len: elem_len(node.inputs[4]),
6511                    sh_coeffs_off: node_offset(arena, node.inputs[5]),
6512                    sh_coeffs_len: elem_len(node.inputs[5]),
6513                    meta_off: node_offset(arena, node.inputs[6]),
6514                    meta_len: elem_len(node.inputs[6]),
6515                    prep_off: node_offset(arena, node.id),
6516                    prep_len: node.shape.num_elements().unwrap_or(0),
6517                    width: *width,
6518                    height: *height,
6519                    tile_size: *tile_size,
6520                    radius_scale: *radius_scale,
6521                    alpha_cutoff: *alpha_cutoff,
6522                    max_splat_steps: *max_splat_steps,
6523                    transmittance_threshold: *transmittance_threshold,
6524                    max_list_entries: *max_list_entries,
6525                }
6526            }
6527
6528            Op::GaussianSplatRasterize {
6529                width,
6530                height,
6531                tile_size,
6532                alpha_cutoff,
6533                max_splat_steps,
6534                transmittance_threshold,
6535                max_list_entries,
6536            } => {
6537                let elem_len =
6538                    |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
6539                let prep_id = node.inputs[0];
6540                let count = match &graph.node(prep_id).op {
6541                    rlx_ir::Op::GaussianSplatPrepare { .. } => {
6542                        elem_len(graph.node(prep_id).inputs[0]) / 3
6543                    }
6544                    _ => 1,
6545                };
6546                Thunk::GaussianSplatRasterize {
6547                    prep_off: node_offset(arena, prep_id),
6548                    prep_len: elem_len(prep_id),
6549                    meta_off: node_offset(arena, node.inputs[1]),
6550                    meta_len: elem_len(node.inputs[1]),
6551                    dst_off: node_offset(arena, node.id),
6552                    dst_len: node.shape.num_elements().unwrap_or(0),
6553                    count,
6554                    width: *width,
6555                    height: *height,
6556                    tile_size: *tile_size,
6557                    alpha_cutoff: *alpha_cutoff,
6558                    max_splat_steps: *max_splat_steps,
6559                    transmittance_threshold: *transmittance_threshold,
6560                    max_list_entries: *max_list_entries,
6561                }
6562            }
6563
6564            Op::Custom { name, attrs, .. } => {
6565                let kernel = crate::op_registry::lookup_cpu_kernel(name).unwrap_or_else(|| {
6566                    panic!(
6567                        "compile_thunks: no CPU kernel registered for \
6568                         Op::Custom('{name}'). Register one via \
6569                         rlx_cpu::op_registry::register_cpu_kernel \
6570                         before compiling on the CPU backend."
6571                    )
6572                });
6573                let inputs_v: Vec<(usize, u32, Shape)> = node
6574                    .inputs
6575                    .iter()
6576                    .map(|&in_id| {
6577                        let s = graph.node(in_id).shape.clone();
6578                        let len = s.num_elements().unwrap_or(0) as u32;
6579                        (node_offset(arena, in_id), len, s)
6580                    })
6581                    .collect();
6582                let out_len = node.shape.num_elements().unwrap_or(0) as u32;
6583                Thunk::CustomOp {
6584                    kernel,
6585                    inputs: inputs_v,
6586                    output: (node_offset(arena, node.id), out_len, node.shape.clone()),
6587                    attrs: attrs.clone(),
6588                }
6589            }
6590
6591            Op::Fft { inverse, norm } => {
6592                let shape = &node.shape;
6593                let meta = rlx_ir::fft::fft_meta(shape);
6594                let dtype = shape.dtype();
6595                assert!(
6596                    matches!(
6597                        dtype,
6598                        rlx_ir::DType::F32 | rlx_ir::DType::F64 | rlx_ir::DType::C64
6599                    ),
6600                    "Op::Fft on CPU requires F32, F64, or C64, got {dtype:?}"
6601                );
6602                Thunk::Fft1d {
6603                    src: node_offset(arena, node.inputs[0]),
6604                    dst: node_offset(arena, node.id),
6605                    outer: meta.outer as u32,
6606                    n_complex: meta.n_complex as u32,
6607                    inverse: *inverse,
6608                    norm_tag: norm.tag(),
6609                    dtype,
6610                }
6611            }
6612
6613            Op::FftButterflyStage { stage, n_fft } => {
6614                let state_shape = graph.node(node.inputs[0]).shape.clone();
6615                assert_eq!(
6616                    state_shape.dtype(),
6617                    rlx_ir::DType::F32,
6618                    "Op::FftButterflyStage requires F32 state"
6619                );
6620                let batch = state_shape.dim(0).unwrap_static() as u32;
6621                Thunk::FftButterflyStage {
6622                    state_src: node_offset(arena, node.inputs[0]),
6623                    state_dst: node_offset(arena, node.id),
6624                    gate_src: node_offset(arena, node.inputs[1]),
6625                    rev_src: node_offset(arena, node.inputs[2]),
6626                    tw_re_src: node_offset(arena, node.inputs[3]),
6627                    tw_im_src: node_offset(arena, node.inputs[4]),
6628                    batch,
6629                    n_fft: *n_fft,
6630                    stage: *stage,
6631                }
6632            }
6633
6634            Op::LogMel => {
6635                let spec_shape = graph.node(node.inputs[0]).shape.clone();
6636                let filt_shape = graph.node(node.inputs[1]).shape.clone();
6637                let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
6638                    .unwrap_or_else(|e| panic!("Op::LogMel: {e}"));
6639                Thunk::LogMel {
6640                    spec: node_offset(arena, node.inputs[0]),
6641                    filters: node_offset(arena, node.inputs[1]),
6642                    dst: node_offset(arena, node.id),
6643                    outer: meta.outer as u32,
6644                    n_fft: meta.n_fft as u32,
6645                    n_bins: meta.n_bins as u32,
6646                    n_mels: meta.n_mels as u32,
6647                }
6648            }
6649
6650            Op::LogMelBackward => {
6651                let spec_shape = graph.node(node.inputs[0]).shape.clone();
6652                let filt_shape = graph.node(node.inputs[1]).shape.clone();
6653                let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
6654                    .unwrap_or_else(|e| panic!("Op::LogMelBackward: {e}"));
6655                Thunk::LogMelBackward {
6656                    spec: node_offset(arena, node.inputs[0]),
6657                    filters: node_offset(arena, node.inputs[1]),
6658                    dy: node_offset(arena, node.inputs[2]),
6659                    dst: node_offset(arena, node.id),
6660                    outer: meta.outer as u32,
6661                    n_fft: meta.n_fft as u32,
6662                    n_bins: meta.n_bins as u32,
6663                    n_mels: meta.n_mels as u32,
6664                }
6665            }
6666
6667            Op::WelchPeaks { k, n_segments } => {
6668                let spec_shape = graph.node(node.inputs[0]).shape.clone();
6669                let meta = rlx_ir::audio::welch_peaks_meta(&spec_shape, *k, *n_segments)
6670                    .unwrap_or_else(|e| panic!("Op::WelchPeaks: {e}"));
6671                Thunk::WelchPeaks {
6672                    spec: node_offset(arena, node.inputs[0]),
6673                    dst: node_offset(arena, node.id),
6674                    welch_batch: meta.welch_batch as u32,
6675                    n_fft: meta.n_fft as u32,
6676                    n_segments: meta.n_segments as u32,
6677                    k: meta.k as u32,
6678                }
6679            }
6680
6681            Op::CustomFn {
6682                fwd_body,
6683                num_inputs,
6684                ..
6685            } => {
6686                // Plan + compile the body sub-graph standalone, fill its
6687                // Constants (mirrors the Op::Scan body lowering), then
6688                // capture per-input copy specs and the output spec.
6689                // Body Inputs in NodeId order match the outer node's
6690                // operand vector by position.
6691                let body_plan = rlx_opt::memory::plan_memory(fwd_body);
6692                let body_offsets: HashMap<NodeId, usize> = body_plan
6693                    .assignments
6694                    .iter()
6695                    .map(|(id, slot)| (*id, slot.offset))
6696                    .collect();
6697
6698                let mut body_input_ids: Vec<NodeId> = fwd_body
6699                    .nodes()
6700                    .iter()
6701                    .filter(|n| matches!(n.op, Op::Input { .. }))
6702                    .map(|n| n.id)
6703                    .collect();
6704                body_input_ids.sort();
6705                assert_eq!(
6706                    body_input_ids.len(),
6707                    *num_inputs as usize,
6708                    "Op::CustomFn fwd_body has {} Op::Input(s); declared num_inputs={}",
6709                    body_input_ids.len(),
6710                    *num_inputs,
6711                );
6712
6713                let mut body_arena = crate::arena::Arena::from_plan(body_plan);
6714                for n in fwd_body.nodes() {
6715                    if let Op::Constant { data } = &n.op
6716                        && body_arena.has_buffer(n.id)
6717                        && !data.is_empty()
6718                    {
6719                        match n.shape.dtype() {
6720                            rlx_ir::DType::F64 => {
6721                                let off = body_arena.byte_offset(n.id);
6722                                let buf = body_arena.raw_buf_mut();
6723                                let nb = (buf.len() - off).min(data.len());
6724                                buf[off..off + nb].copy_from_slice(&data[..nb]);
6725                            }
6726                            _ => {
6727                                let buf = body_arena.slice_mut(n.id);
6728                                let nf = data.len() / 4;
6729                                let nl = buf.len().min(nf);
6730                                for i in 0..nl {
6731                                    let bytes = [
6732                                        data[i * 4],
6733                                        data[i * 4 + 1],
6734                                        data[i * 4 + 2],
6735                                        data[i * 4 + 3],
6736                                    ];
6737                                    buf[i] = f32::from_le_bytes(bytes);
6738                                }
6739                            }
6740                        }
6741                    }
6742                }
6743                let body_init = body_arena.raw_buf().to_vec();
6744                let body_schedule = compile_thunks_with_rng(fwd_body, &body_arena, rng);
6745
6746                // Per primal input: (body_input_off, outer_input_off, bytes).
6747                let inputs_v: Vec<(usize, usize, u32)> = (0..*num_inputs as usize)
6748                    .map(|i| {
6749                        let body_in = body_input_ids[i];
6750                        let body_off = body_offsets[&body_in];
6751                        let outer_in = node.inputs[i];
6752                        let outer_off = node_offset(arena, outer_in);
6753                        let bytes = graph
6754                            .node(outer_in)
6755                            .shape
6756                            .size_bytes()
6757                            .expect("Op::CustomFn primal input must have static shape");
6758                        (body_off, outer_off, bytes as u32)
6759                    })
6760                    .collect();
6761
6762                let body_output_id = fwd_body
6763                    .outputs
6764                    .first()
6765                    .copied()
6766                    .expect("Op::CustomFn fwd_body must declare exactly one output");
6767                let body_output_off = body_offsets[&body_output_id];
6768                let out_bytes = node
6769                    .shape
6770                    .size_bytes()
6771                    .expect("Op::CustomFn output must have static shape");
6772
6773                Thunk::CustomFn {
6774                    body: Arc::new(body_schedule),
6775                    body_init: Arc::new(body_init),
6776                    inputs: Arc::new(inputs_v),
6777                    body_output_off,
6778                    outer_output_off: node_offset(arena, node.id),
6779                    out_bytes: out_bytes as u32,
6780                }
6781            }
6782
6783            Op::ElementwiseRegion {
6784                chain,
6785                scalar_input_mask,
6786                input_modulus,
6787                prologue,
6788                ..
6789            } => {
6790                // The scalar interpreter handles plain chains; prologue (resize)
6791                // regions are GPU-only and never reach here on the CPU path
6792                // (the graphfused fusion options disable prologue/FK fusion).
6793                if *prologue != rlx_ir::op::RegionPrologue::None {
6794                    Thunk::Nop
6795                } else {
6796                    let input_offs: Vec<usize> = node
6797                        .inputs
6798                        .iter()
6799                        .map(|&id| node_offset(arena, id))
6800                        .collect();
6801                    Thunk::ElementwiseRegion {
6802                        dst: node_offset(arena, node.id),
6803                        len: node.shape.num_elements().unwrap_or(0) as u32,
6804                        input_offs,
6805                        chain: chain.clone(),
6806                        scalar_input_mask: *scalar_input_mask,
6807                        input_modulus: *input_modulus,
6808                    }
6809                }
6810            }
6811            _ => Thunk::Nop,
6812        };
6813        thunks.push(t);
6814    }
6815
6816    let cfg = crate::config::RuntimeConfig::global();
6817    let mask_thr = cfg.mask_binary_threshold;
6818    let mask_neg = cfg.attn_mask_neg_inf;
6819    let score_skip = cfg.score_skip_threshold;
6820
6821    // Pre-compile closures (skip Nops — they're filtered out)
6822    let compiled_fns: Vec<Arc<dyn Fn(*mut u8) + Send + Sync>> = thunks
6823        .iter()
6824        .filter(|t| !matches!(t, Thunk::Nop))
6825        .map(|thunk| {
6826            match thunk.clone() {
6827                Thunk::Nop => Arc::new(|_: *mut u8| {}) as Arc<dyn Fn(*mut u8) + Send + Sync>,
6828
6829                Thunk::Sgemm { a, b, c, m, k, n } => {
6830                    let (m, k, n) = (m as usize, k as usize, n as usize);
6831                    Arc::new(move |base: *mut u8| unsafe {
6832                        crate::blas::sgemm(
6833                            sl(a, base, m * k),
6834                            sl(b, base, k * n),
6835                            sl_mut(c, base, m * n),
6836                            m,
6837                            k,
6838                            n,
6839                        );
6840                    })
6841                }
6842
6843                Thunk::CgemmC64 { a, b, c, m, k, n } => {
6844                    let (m, k, n) = (m as usize, k as usize, n as usize);
6845                    Arc::new(move |base: *mut u8| unsafe {
6846                        cgemm_c64(a, b, c, m, k, n, base);
6847                    })
6848                }
6849
6850                Thunk::DenseSolveF64 { a, b, x, n, nrhs } => {
6851                    let (n_, nrhs_) = (n as usize, nrhs as usize);
6852                    Arc::new(move |base: *mut u8| unsafe {
6853                        let a_src = sl_f64(a, base, n_ * n_);
6854                        let b_src = sl_f64(b, base, n_ * nrhs_);
6855                        let mut a_scratch: Vec<f64> = a_src.to_vec();
6856                        let mut x_buf: Vec<f64> = b_src.to_vec();
6857                        let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
6858                        if info != 0 {
6859                            panic!("DenseSolveF64: singular (info={info})");
6860                        }
6861                        sl_mut_f64(x, base, n_ * nrhs_).copy_from_slice(&x_buf);
6862                    })
6863                }
6864
6865                Thunk::DenseSolveF32 { a, b, x, n, nrhs } => {
6866                    let (n_, nrhs_) = (n as usize, nrhs as usize);
6867                    Arc::new(move |base: *mut u8| unsafe {
6868                        let a_src = sl(a, base, n_ * n_);
6869                        let b_src = sl(b, base, n_ * nrhs_);
6870                        let mut a_scratch: Vec<f32> = a_src.to_vec();
6871                        let mut x_buf: Vec<f32> = b_src.to_vec();
6872                        let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
6873                        if info != 0 {
6874                            panic!("DenseSolveF32: singular (info={info})");
6875                        }
6876                        sl_mut(x, base, n_ * nrhs_).copy_from_slice(&x_buf);
6877                    })
6878                }
6879
6880                Thunk::FusedMmBiasAct {
6881                    a,
6882                    w,
6883                    bias,
6884                    c,
6885                    m,
6886                    k,
6887                    n,
6888                    act,
6889                } => {
6890                    let (m, k, n) = (m as usize, k as usize, n as usize);
6891                    Arc::new(move |base: *mut u8| unsafe {
6892                        let out = sl_mut(c, base, m * n);
6893                        crate::blas::sgemm(sl(a, base, m * k), sl(w, base, k * n), out, m, k, n);
6894                        // Bias + activation epilogue. Gelu uses the fused
6895                        // `par_bias_gelu` kernel (bias add + Gelu in one
6896                        // pass). For everything else, do the bias add first
6897                        // and then apply the activation per-element. The
6898                        // pre-fix code dispatched `_ => bias_add` and dropped
6899                        // the activation entirely — silent correctness bug
6900                        // for Silu/Relu/Sigmoid/etc.
6901                        match act {
6902                            Some(Activation::Gelu) => {
6903                                crate::kernels::par_bias_gelu(out, sl(bias, base, n), m, n)
6904                            }
6905                            Some(other) => {
6906                                crate::blas::bias_add(out, sl(bias, base, n), m, n);
6907                                apply_activation_inplace(out, other);
6908                            }
6909                            None => crate::blas::bias_add(out, sl(bias, base, n), m, n),
6910                        }
6911                    })
6912                }
6913
6914                Thunk::FusedResidualLN {
6915                    x,
6916                    res,
6917                    bias,
6918                    g,
6919                    b,
6920                    out,
6921                    rows,
6922                    h,
6923                    eps,
6924                    has_bias,
6925                } => {
6926                    let (rows, h) = (rows as usize, h as usize);
6927                    Arc::new(move |base: *mut u8| unsafe {
6928                        let zero = vec![0f32; h]; // closure only — not hot path
6929                        let bi = if has_bias { sl(bias, base, h) } else { &zero };
6930                        let xp = sl(x, base, rows * h).as_ptr() as usize;
6931                        let rp = sl(res, base, rows * h).as_ptr() as usize;
6932                        let op = sl_mut(out, base, rows * h).as_mut_ptr() as usize;
6933                        let bp = bi.as_ptr() as usize;
6934                        let gp = sl(g, base, h).as_ptr() as usize;
6935                        let bbp = sl(b, base, h).as_ptr() as usize;
6936                        crate::pool::par_for(rows, 4, &|off, cnt| {
6937                            let xs = std::slice::from_raw_parts(
6938                                (xp as *const f32).add(off * h),
6939                                cnt * h,
6940                            );
6941                            let rs = std::slice::from_raw_parts(
6942                                (rp as *const f32).add(off * h),
6943                                cnt * h,
6944                            );
6945                            let os = std::slice::from_raw_parts_mut(
6946                                (op as *mut f32).add(off * h),
6947                                cnt * h,
6948                            );
6949                            let bi = std::slice::from_raw_parts(bp as *const f32, h);
6950                            let g = std::slice::from_raw_parts(gp as *const f32, h);
6951                            let b = std::slice::from_raw_parts(bbp as *const f32, h);
6952                            crate::kernels::residual_bias_layer_norm(
6953                                xs, rs, bi, g, b, os, cnt, h, eps,
6954                            );
6955                        });
6956                    })
6957                }
6958
6959                Thunk::BiasAdd {
6960                    src,
6961                    bias,
6962                    dst,
6963                    m,
6964                    n,
6965                } => {
6966                    let (m, n) = (m as usize, n as usize);
6967                    let len = m * n;
6968                    Arc::new(move |base: *mut u8| unsafe {
6969                        let out = sl_mut(dst, base, len);
6970                        if src != dst {
6971                            let src_ptr = base.add(src) as *const f32;
6972                            let dst_ptr = base.add(dst) as *mut f32;
6973                            if src_ptr != dst_ptr {
6974                                std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
6975                            }
6976                        }
6977                        crate::blas::bias_add(out, sl(bias, base, n), m, n);
6978                    })
6979                }
6980
6981                Thunk::Gather {
6982                    table,
6983                    table_len,
6984                    idx,
6985                    dst,
6986                    num_idx,
6987                    trailing,
6988                    idx_i64,
6989                    table_bytes,
6990                } => {
6991                    let (ni, tr, tl) = (num_idx as usize, trailing as usize, table_len as usize);
6992                    let rows = tl / tr.max(1);
6993                    let (idx_i64, table_bytes) = (idx_i64, table_bytes);
6994                    Arc::new(move |base: *mut u8| unsafe {
6995                        if table_bytes == 8 {
6996                            let tab = sl_i64(table, base, tl);
6997                            let out = sl_mut_i64(dst, base, ni * tr);
6998                            if idx_i64 != 0 {
6999                                let ids = sl_i64(idx, base, ni);
7000                                for i in 0..ni {
7001                                    let row = ids[i].max(0) as usize;
7002                                    if row < rows {
7003                                        out[i * tr..(i + 1) * tr]
7004                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
7005                                    }
7006                                }
7007                            } else {
7008                                let ids = sl(idx, base, ni);
7009                                for i in 0..ni {
7010                                    let row = ids[i] as usize;
7011                                    if row < rows {
7012                                        out[i * tr..(i + 1) * tr]
7013                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
7014                                    }
7015                                }
7016                            }
7017                        } else {
7018                            let tab = sl(table, base, tl);
7019                            let out = sl_mut(dst, base, ni * tr);
7020                            if idx_i64 != 0 {
7021                                let ids = sl_i64(idx, base, ni);
7022                                for i in 0..ni {
7023                                    let row = ids[i].max(0) as usize;
7024                                    if row < rows {
7025                                        out[i * tr..(i + 1) * tr]
7026                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
7027                                    }
7028                                }
7029                            } else {
7030                                let ids = sl(idx, base, ni);
7031                                for i in 0..ni {
7032                                    let row = ids[i] as usize;
7033                                    if row < rows {
7034                                        out[i * tr..(i + 1) * tr]
7035                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
7036                                    }
7037                                }
7038                            }
7039                        }
7040                    })
7041                }
7042
7043                Thunk::Narrow {
7044                    src,
7045                    dst,
7046                    outer,
7047                    src_stride,
7048                    dst_stride,
7049                    inner,
7050                    elem_bytes,
7051                } => {
7052                    narrow_thunk_closure(src, dst, outer, src_stride, dst_stride, inner, elem_bytes)
7053                }
7054
7055                Thunk::Reverse {
7056                    src,
7057                    dst,
7058                    dims,
7059                    rev_mask,
7060                    elem_bytes,
7061                } => {
7062                    let eb = elem_bytes as usize;
7063                    let rank = dims.len();
7064                    let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
7065                    // Row-major element strides.
7066                    let mut strides = vec![1usize; rank];
7067                    for i in (0..rank.saturating_sub(1)).rev() {
7068                        strides[i] = strides[i + 1] * dims[i + 1] as usize;
7069                    }
7070                    let dims_u: Vec<usize> = dims.iter().map(|&d| d as usize).collect();
7071                    Arc::new(move |base: *mut u8| unsafe {
7072                        let src_base = base.add(src);
7073                        let dst_base = base.add(dst);
7074                        for o in 0..total {
7075                            // Output flat index → multi-index → (axis-reversed)
7076                            // input flat index.
7077                            let mut rem = o;
7078                            let mut in_flat = 0usize;
7079                            for ax in 0..rank {
7080                                let idx = rem / strides[ax];
7081                                rem %= strides[ax];
7082                                let in_idx = if rev_mask[ax] {
7083                                    dims_u[ax] - 1 - idx
7084                                } else {
7085                                    idx
7086                                };
7087                                in_flat += in_idx * strides[ax];
7088                            }
7089                            std::ptr::copy_nonoverlapping(
7090                                src_base.add(in_flat * eb),
7091                                dst_base.add(o * eb),
7092                                eb,
7093                            );
7094                        }
7095                    })
7096                }
7097
7098                Thunk::Copy { src, dst, len } => {
7099                    let len = len as usize;
7100                    Arc::new(move |base: *mut u8| unsafe {
7101                        if src == dst || len == 0 {
7102                            return;
7103                        }
7104                        let src_ptr = base.add(src) as *const f32;
7105                        let dst_ptr = base.add(dst) as *mut f32;
7106                        if src_ptr == dst_ptr {
7107                            return;
7108                        }
7109                        std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
7110                    })
7111                }
7112
7113                Thunk::Softmax { data, rows, cols } => {
7114                    let (rows, cols) = (rows as usize, cols as usize);
7115                    Arc::new(move |base: *mut u8| unsafe {
7116                        crate::naive::softmax(sl_mut(data, base, rows * cols), rows, cols);
7117                    })
7118                }
7119
7120                Thunk::Cumsum {
7121                    src,
7122                    dst,
7123                    rows,
7124                    cols,
7125                    exclusive,
7126                } => {
7127                    let (rows, cols) = (rows as usize, cols as usize);
7128                    Arc::new(move |base: *mut u8| unsafe {
7129                        let s = sl(src, base, rows * cols);
7130                        let d = sl_mut(dst, base, rows * cols);
7131                        if exclusive {
7132                            for r in 0..rows {
7133                                let mut acc = 0.0f32;
7134                                for c in 0..cols {
7135                                    d[r * cols + c] = acc;
7136                                    acc += s[r * cols + c];
7137                                }
7138                            }
7139                        } else {
7140                            for r in 0..rows {
7141                                let mut acc = 0.0f32;
7142                                for c in 0..cols {
7143                                    acc += s[r * cols + c];
7144                                    d[r * cols + c] = acc;
7145                                }
7146                            }
7147                        }
7148                    })
7149                }
7150
7151                Thunk::Sample {
7152                    logits,
7153                    dst,
7154                    batch,
7155                    vocab,
7156                    top_k,
7157                    top_p,
7158                    temperature,
7159                    seed,
7160                } => {
7161                    let (b, v) = (batch as usize, vocab as usize);
7162                    let k = (top_k as usize).min(v);
7163                    Arc::new(move |base: *mut u8| unsafe {
7164                        let lg = sl(logits, base, b * v);
7165                        let out = sl_mut(dst, base, b);
7166                        let mut rng =
7167                            rlx_ir::Philox4x32::new(if seed == 0 { 0xDEADBEEF } else { seed });
7168                        for bi in 0..b {
7169                            let row = &lg[bi * v..(bi + 1) * v];
7170                            out[bi] = sample_row(row, k, top_p, temperature, &mut rng) as f32;
7171                        }
7172                    })
7173                }
7174
7175                Thunk::RngNormal {
7176                    dst,
7177                    len,
7178                    mean,
7179                    scale,
7180                    key,
7181                    op_seed,
7182                } => {
7183                    let n = len as usize;
7184                    let rng = rng_shared.clone();
7185                    Arc::new(move |base: *mut u8| unsafe {
7186                        let out = sl_mut(dst, base, n);
7187                        let opts = *rng.read().unwrap();
7188                        rlx_ir::fill_normal_like(out, mean, scale, opts, key, op_seed);
7189                    })
7190                }
7191
7192                Thunk::RngUniform {
7193                    dst,
7194                    len,
7195                    low,
7196                    high,
7197                    key,
7198                    op_seed,
7199                } => {
7200                    let n = len as usize;
7201                    let rng = rng_shared.clone();
7202                    Arc::new(move |base: *mut u8| unsafe {
7203                        let out = sl_mut(dst, base, n);
7204                        let opts = *rng.read().unwrap();
7205                        rlx_ir::fill_uniform_like(out, low, high, opts, key, op_seed);
7206                    })
7207                }
7208
7209                Thunk::DequantMatMul {
7210                    x,
7211                    w_q,
7212                    scale,
7213                    zp,
7214                    dst,
7215                    m,
7216                    k,
7217                    n,
7218                    block_size,
7219                    is_asymmetric,
7220                } => {
7221                    let (m, k, n, bs) = (m as usize, k as usize, n as usize, block_size as usize);
7222                    let n_blocks_per_col = k.div_ceil(bs);
7223                    Arc::new(move |base: *mut u8| unsafe {
7224                        let xs = sl(x, base, m * k);
7225                        // w_q is packed i8 — use raw byte slice + reinterpret.
7226                        let raw = base.add(w_q);
7227                        let w_bytes = std::slice::from_raw_parts(raw as *const i8, k * n);
7228                        let scales = sl(scale, base, n_blocks_per_col * n);
7229                        let zps = if is_asymmetric {
7230                            sl(zp, base, n_blocks_per_col * n)
7231                        } else {
7232                            &[][..]
7233                        };
7234                        let out = sl_mut(dst, base, m * n);
7235                        dequant_matmul_int8(
7236                            xs,
7237                            w_bytes,
7238                            scales,
7239                            zps,
7240                            out,
7241                            m,
7242                            k,
7243                            n,
7244                            bs,
7245                            is_asymmetric,
7246                        );
7247                    })
7248                }
7249
7250                Thunk::DequantMatMulGguf {
7251                    x,
7252                    w_q,
7253                    dst,
7254                    m,
7255                    k,
7256                    n,
7257                    scheme,
7258                } => {
7259                    let (m, k, n) = (m as usize, k as usize, n as usize);
7260                    let block_bytes = scheme.gguf_block_bytes() as usize;
7261                    let block_elems = scheme.gguf_block_size() as usize;
7262                    let total_bytes = (k * n) / block_elems * block_bytes;
7263                    Arc::new(move |base: *mut u8| unsafe {
7264                        let xs = sl(x, base, m * k);
7265                        let w_bytes =
7266                            std::slice::from_raw_parts(base.add(w_q) as *const u8, total_bytes);
7267                        let out = sl_mut(dst, base, m * n);
7268                        crate::gguf_matmul::gguf_matmul_bt(xs, w_bytes, out, m, k, n, scheme);
7269                    })
7270                }
7271
7272                Thunk::DequantMatMulInt4 {
7273                    x,
7274                    w_q,
7275                    scale,
7276                    zp,
7277                    dst,
7278                    m,
7279                    k,
7280                    n,
7281                    block_size,
7282                    is_asymmetric,
7283                } => {
7284                    let (m, k, n, bs) = (m as usize, k as usize, n as usize, block_size as usize);
7285                    let n_blocks = k.div_ceil(bs);
7286                    Arc::new(move |base: *mut u8| unsafe {
7287                        let xs = sl(x, base, m * k);
7288                        let w_bytes = std::slice::from_raw_parts(
7289                            base.add(w_q) as *const u8,
7290                            (k * n).div_ceil(2),
7291                        );
7292                        let scales = sl(scale, base, n_blocks * n);
7293                        let zps = if is_asymmetric {
7294                            sl(zp, base, n_blocks * n)
7295                        } else {
7296                            &[][..]
7297                        };
7298                        let out = sl_mut(dst, base, m * n);
7299                        dequant_matmul_int4(
7300                            xs,
7301                            w_bytes,
7302                            scales,
7303                            zps,
7304                            out,
7305                            m,
7306                            k,
7307                            n,
7308                            bs,
7309                            is_asymmetric,
7310                        );
7311                    })
7312                }
7313
7314                Thunk::DequantMatMulFp8 {
7315                    x,
7316                    w_q,
7317                    scale,
7318                    dst,
7319                    m,
7320                    k,
7321                    n,
7322                    e5m2,
7323                } => {
7324                    let (m, k, n) = (m as usize, k as usize, n as usize);
7325                    Arc::new(move |base: *mut u8| unsafe {
7326                        let xs = sl(x, base, m * k);
7327                        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, k * n);
7328                        let scales = sl(scale, base, n);
7329                        let out = sl_mut(dst, base, m * n);
7330                        dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, e5m2);
7331                    })
7332                }
7333
7334                Thunk::DequantMatMulNvfp4 {
7335                    x,
7336                    w_q,
7337                    scale,
7338                    global_scale,
7339                    dst,
7340                    m,
7341                    k,
7342                    n,
7343                } => {
7344                    let (m, k, n) = (m as usize, k as usize, n as usize);
7345                    let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
7346                    Arc::new(move |base: *mut u8| unsafe {
7347                        let xs = sl(x, base, m * k);
7348                        let w_bytes = std::slice::from_raw_parts(
7349                            base.add(w_q) as *const u8,
7350                            (k * n).div_ceil(2),
7351                        );
7352                        let scale_bytes =
7353                            std::slice::from_raw_parts(base.add(scale) as *const u8, n_scale);
7354                        let gs = sl(global_scale, base, 1)[0];
7355                        let out = sl_mut(dst, base, m * n);
7356                        dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
7357                    })
7358                }
7359
7360                Thunk::ScaledMatMul {
7361                    lhs,
7362                    rhs,
7363                    lhs_scale,
7364                    rhs_scale,
7365                    bias,
7366                    dst,
7367                    m,
7368                    k,
7369                    n,
7370                    lhs_fmt,
7371                    rhs_fmt,
7372                    layout,
7373                    has_bias,
7374                } => {
7375                    let (m, k, n) = (m as usize, k as usize, n as usize);
7376                    let nblk = lowp_nblk(k, layout);
7377                    let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
7378                    let n_lscale = if per_tensor { 1 } else { m * nblk };
7379                    let n_rscale = if per_tensor { 1 } else { n * nblk };
7380                    Arc::new(move |base: *mut u8| unsafe {
7381                        let lhs_b = std::slice::from_raw_parts(base.add(lhs) as *const u8, m * k);
7382                        let rhs_b = std::slice::from_raw_parts(base.add(rhs) as *const u8, n * k);
7383                        let ls = lowp_read_scales(layout, base, lhs_scale, n_lscale);
7384                        let rs = lowp_read_scales(layout, base, rhs_scale, n_rscale);
7385                        let bias_s = if has_bias { Some(sl(bias, base, n)) } else { None };
7386                        let out = sl_mut(dst, base, m * n);
7387                        lowp_scaled_matmul(
7388                            lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, lhs_fmt, rhs_fmt,
7389                        );
7390                    })
7391                }
7392
7393                Thunk::ScaledQuantize {
7394                    x,
7395                    scale,
7396                    dst,
7397                    rows,
7398                    cols,
7399                    fmt,
7400                    layout,
7401                } => {
7402                    let (rows, cols) = (rows as usize, cols as usize);
7403                    let nblk = lowp_nblk(cols, layout);
7404                    let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
7405                        1
7406                    } else {
7407                        rows * nblk
7408                    };
7409                    Arc::new(move |base: *mut u8| unsafe {
7410                        let xs = sl(x, base, rows * cols);
7411                        let scales = lowp_read_scales(layout, base, scale, n_scale);
7412                        let out =
7413                            std::slice::from_raw_parts_mut(base.add(dst), rows * cols);
7414                        lowp_quantize(xs, &scales, fmt, layout, rows, cols, out);
7415                    })
7416                }
7417
7418                Thunk::ScaledQuantScale {
7419                    x,
7420                    dst,
7421                    rows,
7422                    cols,
7423                    fmt,
7424                    layout,
7425                } => {
7426                    let (rows, cols) = (rows as usize, cols as usize);
7427                    let nblk = lowp_nblk(cols, layout);
7428                    Arc::new(move |base: *mut u8| unsafe {
7429                        let xs = sl(x, base, rows * cols);
7430                        let scales = lowp_compute_scales(xs, fmt, layout, rows, cols);
7431                        match layout {
7432                            rlx_ir::ScaleLayout::PerTensor => {
7433                                sl_mut(dst, base, 1)[0] = scales[0];
7434                            }
7435                            rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
7436                                let out = std::slice::from_raw_parts_mut(
7437                                    base.add(dst),
7438                                    rows * nblk,
7439                                );
7440                                for (o, &s) in out.iter_mut().zip(&scales) {
7441                                    *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
7442                                }
7443                            }
7444                            rlx_ir::ScaleLayout::Nvfp4 { .. } => {
7445                                let out = std::slice::from_raw_parts_mut(
7446                                    base.add(dst),
7447                                    rows * nblk,
7448                                );
7449                                for (o, &s) in out.iter_mut().zip(&scales) {
7450                                    *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
7451                                }
7452                            }
7453                        }
7454                    })
7455                }
7456
7457                Thunk::ScaledDequantize {
7458                    codes,
7459                    scale,
7460                    dst,
7461                    rows,
7462                    cols,
7463                    fmt,
7464                    layout,
7465                } => {
7466                    let (rows, cols) = (rows as usize, cols as usize);
7467                    let nblk = lowp_nblk(cols, layout);
7468                    let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
7469                        1
7470                    } else {
7471                        rows * nblk
7472                    };
7473                    Arc::new(move |base: *mut u8| unsafe {
7474                        let cs = std::slice::from_raw_parts(base.add(codes) as *const u8, rows * cols);
7475                        let scales = lowp_read_scales(layout, base, scale, n_scale);
7476                        let out = sl_mut(dst, base, rows * cols);
7477                        lowp_dequantize(cs, &scales, fmt, layout, rows, cols, out);
7478                    })
7479                }
7480
7481                Thunk::LoraMatMul {
7482                    x,
7483                    w,
7484                    a,
7485                    b,
7486                    dst,
7487                    m,
7488                    k,
7489                    n,
7490                    r,
7491                    scale,
7492                } => {
7493                    let (m, k, n, r) = (m as usize, k as usize, n as usize, r as usize);
7494                    Arc::new(move |base: *mut u8| unsafe {
7495                        let xs = sl(x, base, m * k);
7496                        let ws = sl(w, base, k * n);
7497                        let a_s = sl(a, base, k * r);
7498                        let bs = sl(b, base, r * n);
7499                        let out = sl_mut(dst, base, m * n);
7500                        // Step 1: out = x · W.
7501                        crate::blas::sgemm(xs, ws, out, m, k, n);
7502                        // Step 2: tmp = x · A (rank-r intermediate; tiny).
7503                        let mut tmp = vec![0f32; m * r];
7504                        crate::blas::sgemm(xs, a_s, &mut tmp, m, k, r);
7505                        // Step 3: out += scale * (tmp · B).
7506                        // sgemm_accumulate uses alpha=1.0 internally, so
7507                        // scale tmp first.
7508                        if scale != 1.0 {
7509                            for v in tmp.iter_mut() {
7510                                *v *= scale;
7511                            }
7512                        }
7513                        crate::blas::sgemm_accumulate(&tmp, bs, out, m, r, n);
7514                    })
7515                }
7516
7517                Thunk::LayerNorm {
7518                    src,
7519                    g,
7520                    b,
7521                    dst,
7522                    rows,
7523                    h,
7524                    eps,
7525                } => {
7526                    let (rows, h) = (rows as usize, h as usize);
7527                    Arc::new(move |base: *mut u8| unsafe {
7528                        let inp = sl(src, base, rows * h);
7529                        let gamma = sl(g, base, h);
7530                        let beta = sl(b, base, h);
7531                        let out = sl_mut(dst, base, rows * h);
7532                        for row in 0..rows {
7533                            crate::kernels::layer_norm_row(
7534                                &inp[row * h..(row + 1) * h],
7535                                gamma,
7536                                beta,
7537                                &mut out[row * h..(row + 1) * h],
7538                                h,
7539                                eps,
7540                            );
7541                        }
7542                    })
7543                }
7544
7545                Thunk::BatchNormInference {
7546                    src,
7547                    g,
7548                    b,
7549                    mean,
7550                    var,
7551                    dst,
7552                    count,
7553                    channels,
7554                    eps,
7555                } => {
7556                    let count = count as usize;
7557                    let c = channels as usize;
7558                    let n = count * c;
7559                    let (src, g, b, mean, var, dst) = (src, g, b, mean, var, dst);
7560                    Arc::new(move |base: *mut u8| unsafe {
7561                        crate::kernels::batch_norm_inference(
7562                            sl(src, base, n),
7563                            sl(g, base, c),
7564                            sl(b, base, c),
7565                            sl(mean, base, c),
7566                            sl(var, base, c),
7567                            sl_mut(dst, base, n),
7568                            c,
7569                            eps,
7570                        );
7571                    })
7572                }
7573
7574                Thunk::Attention {
7575                    q,
7576                    k,
7577                    v,
7578                    mask,
7579                    out,
7580                    batch,
7581                    seq,
7582                    kv_seq,
7583                    heads,
7584                    head_dim,
7585                    mask_kind,
7586                    scale,
7587                    q_row_stride,
7588                    k_row_stride,
7589                    v_row_stride,
7590                    bhsd,
7591                } => {
7592                    if std::env::var("RLX_ATTN_DEBUG").is_ok() {
7593                        eprintln!("[attn-compile] batch={batch} seq={seq} kv_seq={kv_seq} heads={heads} bhsd={bhsd}");
7594                    }
7595                    // Q seq length (`q_s`) and K/V seq length (`k_s`) differ
7596                    // during cached decode (`q_s=1`, `k_s=past_seq+1`). The
7597                    // earlier version of this kernel destructured
7598                    // `kv_seq: _` and used a single `s = seq` for both axes,
7599                    // so cached decode only scored 1×1 instead of 1×k_s —
7600                    // attention couldn't see the past K cache and decode
7601                    // collapsed into repetitive fragments
7602                    // (`Self-based on [1\nAnswer: Self-based on [1…`).
7603                    let (b, q_s, k_s, nh, dh) = (
7604                        batch as usize,
7605                        seq as usize,
7606                        kv_seq as usize,
7607                        heads as usize,
7608                        head_dim as usize,
7609                    );
7610                    let hs = nh * dh;
7611                    let qrs = q_row_stride as usize;
7612                    let krs = k_row_stride as usize;
7613                    let vrs = v_row_stride as usize;
7614                    // honor Op::Attention::score_scale (e.g. Gemma 4 = 1.0)
7615                    Arc::new(move |base: *mut u8| unsafe {
7616                        if std::env::var("RLX_ATTN_DEBUG").is_ok() {
7617                            eprintln!("[attn] b={b} q_s={q_s} k_s={k_s} nh={nh} dh={dh} bhsd={bhsd} mask_kind={:?}", mask_kind);
7618                        }
7619                        // Slice lengths use the source's row stride so the
7620                        // compiler-emitted bounds checks cover the whole
7621                        // strided span (the kernel walks with q/k/v_rs).
7622                        // For [B, H, S, D] the buffer is dense B*H*S*D.
7623                        let (q_len, k_len, v_len, o_len) = if bhsd {
7624                            let qn = b * nh * q_s * dh;
7625                            let kn = b * nh * k_s * dh;
7626                            (qn, kn, kn, qn)
7627                        } else {
7628                            (b * q_s * qrs, b * k_s * krs, b * k_s * vrs, b * q_s * hs)
7629                        };
7630                        let q_d = sl(q, base, q_len);
7631                        let k_d = sl(k, base, k_len);
7632                        let v_d = sl(v, base, v_len);
7633                        let m_d: &[f32] = match mask_kind {
7634                            rlx_ir::op::MaskKind::Custom => sl(mask, base, b * k_s),
7635                            rlx_ir::op::MaskKind::Bias => sl(mask, base, b * nh * q_s * k_s),
7636                            _ => &[],
7637                        };
7638                        let o_d = sl_mut(out, base, o_len);
7639                        let mut qh = vec![0f32; q_s * dh];
7640                        let mut kh = vec![0f32; k_s * dh];
7641                        let mut vh = vec![0f32; k_s * dh];
7642                        let mut sc = vec![0f32; q_s * k_s];
7643                        let mut oh = vec![0f32; q_s * dh];
7644                        for bi in 0..b {
7645                            for hi in 0..nh {
7646                                // Gather per-head Q.
7647                                for si in 0..q_s {
7648                                    let q_off = if bhsd {
7649                                        bi * nh * q_s * dh + hi * q_s * dh + si * dh
7650                                    } else {
7651                                        bi * q_s * qrs + si * qrs + hi * dh
7652                                    };
7653                                    qh[si * dh..(si + 1) * dh]
7654                                        .copy_from_slice(&q_d[q_off..q_off + dh]);
7655                                }
7656                                // Gather per-head K, V.
7657                                for si in 0..k_s {
7658                                    let (k_off, v_off) = if bhsd {
7659                                        (
7660                                            bi * nh * k_s * dh + hi * k_s * dh + si * dh,
7661                                            bi * nh * k_s * dh + hi * k_s * dh + si * dh,
7662                                        )
7663                                    } else {
7664                                        (
7665                                            bi * k_s * krs + si * krs + hi * dh,
7666                                            bi * k_s * vrs + si * vrs + hi * dh,
7667                                        )
7668                                    };
7669                                    kh[si * dh..(si + 1) * dh]
7670                                        .copy_from_slice(&k_d[k_off..k_off + dh]);
7671                                    vh[si * dh..(si + 1) * dh]
7672                                        .copy_from_slice(&v_d[v_off..v_off + dh]);
7673                                }
7674                                for qi in 0..q_s {
7675                                    for ki in 0..k_s {
7676                                        let mut dot = 0f32;
7677                                        for d in 0..dh {
7678                                            dot += qh[qi * dh + d] * kh[ki * dh + d];
7679                                        }
7680                                        sc[qi * k_s + ki] = dot * scale;
7681                                    }
7682                                }
7683                                // Apply mask. Causal/SlidingWindow use absolute
7684                                // positions so they handle Lq != Lk (decode mode
7685                                // with cached K/V): q_offset = k_s - q_s.
7686                                let q_offset = k_s.saturating_sub(q_s);
7687                                match mask_kind {
7688                                    rlx_ir::op::MaskKind::None => {}
7689                                    rlx_ir::op::MaskKind::Causal => {
7690                                        for qi in 0..q_s {
7691                                            let abs_q = q_offset + qi;
7692                                            for ki in (abs_q + 1)..k_s {
7693                                                sc[qi * k_s + ki] = mask_neg;
7694                                            }
7695                                        }
7696                                    }
7697                                    rlx_ir::op::MaskKind::SlidingWindow(w) => {
7698                                        for qi in 0..q_s {
7699                                            let abs_q = q_offset + qi;
7700                                            let lo = abs_q.saturating_sub(w);
7701                                            for ki in 0..k_s {
7702                                                if ki < lo || ki > abs_q {
7703                                                    sc[qi * k_s + ki] = mask_neg;
7704                                                }
7705                                            }
7706                                        }
7707                                    }
7708                                    rlx_ir::op::MaskKind::Custom => {
7709                                        for qi in 0..q_s {
7710                                            for ki in 0..k_s {
7711                                                if m_d[bi * k_s + ki] < mask_thr {
7712                                                    sc[qi * k_s + ki] = mask_neg;
7713                                                }
7714                                            }
7715                                        }
7716                                    }
7717                                    rlx_ir::op::MaskKind::Bias => {
7718                                        let per_bh = q_s * k_s;
7719                                        let off = (bi * nh + hi) * per_bh;
7720                                        for i in 0..per_bh {
7721                                            sc[i] += m_d[off + i];
7722                                        }
7723                                    }
7724                                }
7725                                crate::naive::softmax(&mut sc, q_s, k_s);
7726                                oh.fill(0.0);
7727                                for qi in 0..q_s {
7728                                    for ki in 0..k_s {
7729                                        let w = sc[qi * k_s + ki];
7730                                        if w > score_skip {
7731                                            for d in 0..dh {
7732                                                oh[qi * dh + d] += w * vh[ki * dh + d];
7733                                            }
7734                                        }
7735                                    }
7736                                }
7737                                for si in 0..q_s {
7738                                    let off = if bhsd {
7739                                        bi * nh * q_s * dh + hi * q_s * dh + si * dh
7740                                    } else {
7741                                        bi * q_s * hs + si * hs + hi * dh
7742                                    };
7743                                    o_d[off..off + dh].copy_from_slice(&oh[si * dh..(si + 1) * dh]);
7744                                }
7745                            }
7746                        }
7747                    })
7748                }
7749
7750                Thunk::FusedSwiGLU {
7751                    src,
7752                    dst,
7753                    n_half,
7754                    total,
7755                    gate_first,
7756                } => {
7757                    let n = n_half as usize;
7758                    let t = total as usize;
7759                    let outer = t / n;
7760                    let in_total = outer * 2 * n;
7761                    Arc::new(move |base: *mut u8| unsafe {
7762                        let inp = sl(src, base, in_total);
7763                        let out = sl_mut(dst, base, t);
7764                        for o in 0..outer {
7765                            let in_row = &inp[o * 2 * n..(o + 1) * 2 * n];
7766                            let out_row = &mut out[o * n..(o + 1) * n];
7767                            for i in 0..n {
7768                                let (up, gate) = if gate_first {
7769                                    (in_row[n + i], in_row[i])
7770                                } else {
7771                                    (in_row[i], in_row[n + i])
7772                                };
7773                                out_row[i] = up * (gate / (1.0 + (-gate).exp()));
7774                            }
7775                        }
7776                    })
7777                }
7778
7779                Thunk::Concat {
7780                    dst,
7781                    outer,
7782                    inner,
7783                    total_axis,
7784                    inputs,
7785                } => {
7786                    let outer = outer as usize;
7787                    let inner = inner as usize;
7788                    let total_axis = total_axis as usize;
7789                    let out_total = outer * total_axis * inner;
7790                    let mut layout: Vec<(usize, usize, usize, usize)> =
7791                        Vec::with_capacity(inputs.len());
7792                    let mut cum: usize = 0;
7793                    for (src_off, in_axis, in_numel) in &inputs {
7794                        let in_axis = *in_axis as usize;
7795                        layout.push((*src_off, cum * inner, in_axis * inner, *in_numel as usize));
7796                        cum += in_axis;
7797                    }
7798                    Arc::new(move |base: *mut u8| unsafe {
7799                        let out = sl_mut(dst, base, out_total);
7800                        let row_stride = total_axis * inner;
7801                        for (src_off, dst_col_off, copy_per_row, in_numel) in &layout {
7802                            let inp = sl(*src_off, base, (*in_numel).max(1));
7803                            concat_copy_rows_f32(
7804                                out,
7805                                inp,
7806                                outer,
7807                                *copy_per_row,
7808                                row_stride,
7809                                *dst_col_off,
7810                                *in_numel,
7811                            );
7812                        }
7813                    })
7814                }
7815
7816                Thunk::CustomOp {
7817                    kernel,
7818                    inputs,
7819                    output,
7820                    attrs,
7821                } => {
7822                    // Capture-by-move: clone the Arc and Vecs once into the
7823                    // closure. Dispatch by output dtype each call (the
7824                    // dtype is fixed at compile time but it's cheaper to
7825                    // branch once per execution than to monomorphize a
7826                    // dozen closure variants).
7827                    let kernel = kernel.clone();
7828                    let attrs = attrs.clone();
7829                    let inputs = inputs.clone();
7830                    let (out_off, out_len, out_shape) = output.clone();
7831                    Arc::new(move |base: *mut u8| unsafe {
7832                        dispatch_custom_op(
7833                            &*kernel, &inputs, out_off, out_len, &out_shape, &attrs, base,
7834                        );
7835                    })
7836                }
7837
7838                Thunk::GaussianSplatRender {
7839                    positions_off,
7840                    positions_len,
7841                    scales_off,
7842                    scales_len,
7843                    rotations_off,
7844                    rotations_len,
7845                    opacities_off,
7846                    opacities_len,
7847                    colors_off,
7848                    colors_len,
7849                    sh_coeffs_off,
7850                    sh_coeffs_len,
7851                    meta_off,
7852                    dst_off,
7853                    dst_len,
7854                    width,
7855                    height,
7856                    tile_size,
7857                    radius_scale,
7858                    alpha_cutoff,
7859                    max_splat_steps,
7860                    transmittance_threshold,
7861                    max_list_entries,
7862                } => Arc::new(move |base: *mut u8| unsafe {
7863                    crate::splat::execute_gaussian_splat_render(
7864                        positions_off,
7865                        positions_len,
7866                        scales_off,
7867                        scales_len,
7868                        rotations_off,
7869                        rotations_len,
7870                        opacities_off,
7871                        opacities_len,
7872                        colors_off,
7873                        colors_len,
7874                        sh_coeffs_off,
7875                        sh_coeffs_len,
7876                        meta_off,
7877                        dst_off,
7878                        dst_len,
7879                        width,
7880                        height,
7881                        tile_size,
7882                        radius_scale,
7883                        alpha_cutoff,
7884                        max_splat_steps,
7885                        transmittance_threshold,
7886                        max_list_entries,
7887                        base,
7888                    );
7889                }),
7890
7891                Thunk::GaussianSplatRenderBackward {
7892                    positions_off,
7893                    positions_len,
7894                    scales_off,
7895                    scales_len,
7896                    rotations_off,
7897                    rotations_len,
7898                    opacities_off,
7899                    opacities_len,
7900                    colors_off,
7901                    colors_len,
7902                    sh_coeffs_off,
7903                    sh_coeffs_len,
7904                    meta_off,
7905                    d_loss_off,
7906                    d_loss_len,
7907                    packed_off,
7908                    packed_len,
7909                    width,
7910                    height,
7911                    tile_size,
7912                    radius_scale,
7913                    alpha_cutoff,
7914                    max_splat_steps,
7915                    transmittance_threshold,
7916                    max_list_entries,
7917                    loss_grad_clip,
7918                    sh_band,
7919                    max_anisotropy,
7920                } => Arc::new(move |base: *mut u8| unsafe {
7921                    crate::splat::execute_gaussian_splat_render_backward(
7922                        positions_off,
7923                        positions_len,
7924                        scales_off,
7925                        scales_len,
7926                        rotations_off,
7927                        rotations_len,
7928                        opacities_off,
7929                        opacities_len,
7930                        colors_off,
7931                        colors_len,
7932                        sh_coeffs_off,
7933                        sh_coeffs_len,
7934                        meta_off,
7935                        d_loss_off,
7936                        d_loss_len,
7937                        packed_off,
7938                        packed_len,
7939                        width,
7940                        height,
7941                        tile_size,
7942                        radius_scale,
7943                        alpha_cutoff,
7944                        max_splat_steps,
7945                        transmittance_threshold,
7946                        max_list_entries,
7947                        loss_grad_clip,
7948                        sh_band,
7949                        max_anisotropy,
7950                        base,
7951                    );
7952                }),
7953
7954                Thunk::GaussianSplatPrepare {
7955                    positions_off,
7956                    positions_len,
7957                    scales_off,
7958                    scales_len,
7959                    rotations_off,
7960                    rotations_len,
7961                    opacities_off,
7962                    opacities_len,
7963                    colors_off,
7964                    colors_len,
7965                    sh_coeffs_off,
7966                    sh_coeffs_len,
7967                    meta_off,
7968                    meta_len,
7969                    prep_off,
7970                    prep_len,
7971                    width,
7972                    height,
7973                    tile_size,
7974                    radius_scale,
7975                    alpha_cutoff,
7976                    max_splat_steps,
7977                    transmittance_threshold,
7978                    max_list_entries,
7979                } => Arc::new(move |base: *mut u8| unsafe {
7980                    crate::splat::execute_gaussian_splat_prepare(
7981                        positions_off,
7982                        positions_len,
7983                        scales_off,
7984                        scales_len,
7985                        rotations_off,
7986                        rotations_len,
7987                        opacities_off,
7988                        opacities_len,
7989                        colors_off,
7990                        colors_len,
7991                        sh_coeffs_off,
7992                        sh_coeffs_len,
7993                        meta_off,
7994                        meta_len,
7995                        prep_off,
7996                        prep_len,
7997                        width,
7998                        height,
7999                        tile_size,
8000                        radius_scale,
8001                        alpha_cutoff,
8002                        max_splat_steps,
8003                        transmittance_threshold,
8004                        max_list_entries,
8005                        base,
8006                    );
8007                }),
8008
8009                Thunk::GaussianSplatRasterize {
8010                    prep_off,
8011                    prep_len,
8012                    meta_off,
8013                    meta_len,
8014                    dst_off,
8015                    dst_len,
8016                    count,
8017                    width,
8018                    height,
8019                    tile_size,
8020                    alpha_cutoff,
8021                    max_splat_steps,
8022                    transmittance_threshold,
8023                    max_list_entries,
8024                } => Arc::new(move |base: *mut u8| unsafe {
8025                    crate::splat::execute_gaussian_splat_rasterize(
8026                        prep_off,
8027                        prep_len,
8028                        meta_off,
8029                        meta_len,
8030                        dst_off,
8031                        dst_len,
8032                        count,
8033                        width,
8034                        height,
8035                        tile_size,
8036                        alpha_cutoff,
8037                        max_splat_steps,
8038                        transmittance_threshold,
8039                        max_list_entries,
8040                        base,
8041                    );
8042                }),
8043
8044                Thunk::Fft1d {
8045                    src,
8046                    dst,
8047                    outer,
8048                    n_complex,
8049                    inverse,
8050                    norm_tag,
8051                    dtype,
8052                } => {
8053                    let f: Arc<dyn Fn(*mut u8) + Send + Sync> = match dtype {
8054                        rlx_ir::DType::F64 => Arc::new(move |base: *mut u8| unsafe {
8055                            execute_fft1d_f64(
8056                                src,
8057                                dst,
8058                                outer as usize,
8059                                n_complex as usize,
8060                                inverse,
8061                                norm_tag,
8062                                base,
8063                            );
8064                        }),
8065                        rlx_ir::DType::F32 => Arc::new(move |base: *mut u8| unsafe {
8066                            execute_fft1d_f32(
8067                                src,
8068                                dst,
8069                                outer as usize,
8070                                n_complex as usize,
8071                                inverse,
8072                                norm_tag,
8073                                base,
8074                            );
8075                        }),
8076                        rlx_ir::DType::C64 => Arc::new(move |base: *mut u8| unsafe {
8077                            execute_fft1d_c64(
8078                                src,
8079                                dst,
8080                                outer as usize,
8081                                n_complex as usize,
8082                                inverse,
8083                                norm_tag,
8084                                base,
8085                            );
8086                        }),
8087                        other => panic!("Op::Fft on CPU requires F32/F64/C64, got {other:?}"),
8088                    };
8089                    f
8090                }
8091
8092                Thunk::FftButterflyStage {
8093                    state_src,
8094                    state_dst,
8095                    gate_src,
8096                    rev_src,
8097                    tw_re_src,
8098                    tw_im_src,
8099                    batch,
8100                    n_fft,
8101                    stage,
8102                } => Arc::new(move |base: *mut u8| unsafe {
8103                    execute_fft_butterfly_stage_f32(
8104                        state_src,
8105                        state_dst,
8106                        gate_src,
8107                        rev_src,
8108                        tw_re_src,
8109                        tw_im_src,
8110                        batch as usize,
8111                        n_fft as usize,
8112                        stage as usize,
8113                        base,
8114                    );
8115                }),
8116
8117                Thunk::LogMel {
8118                    spec,
8119                    filters,
8120                    dst,
8121                    outer,
8122                    n_fft,
8123                    n_bins,
8124                    n_mels,
8125                } => Arc::new(move |base: *mut u8| unsafe {
8126                    execute_log_mel_f32(
8127                        spec,
8128                        filters,
8129                        dst,
8130                        outer as usize,
8131                        n_fft as usize,
8132                        n_bins as usize,
8133                        n_mels as usize,
8134                        base,
8135                    );
8136                }),
8137
8138                Thunk::LogMelBackward {
8139                    spec,
8140                    filters,
8141                    dy,
8142                    dst,
8143                    outer,
8144                    n_fft,
8145                    n_bins,
8146                    n_mels,
8147                } => Arc::new(move |base: *mut u8| unsafe {
8148                    execute_log_mel_backward_f32(
8149                        spec,
8150                        filters,
8151                        dy,
8152                        dst,
8153                        outer as usize,
8154                        n_fft as usize,
8155                        n_bins as usize,
8156                        n_mels as usize,
8157                        base,
8158                    );
8159                }),
8160
8161                Thunk::WelchPeaks {
8162                    spec,
8163                    dst,
8164                    welch_batch,
8165                    n_fft,
8166                    n_segments,
8167                    k,
8168                } => Arc::new(move |base: *mut u8| unsafe {
8169                    execute_welch_peaks_f32(
8170                        spec,
8171                        dst,
8172                        welch_batch as usize,
8173                        n_fft as usize,
8174                        n_segments as usize,
8175                        k as usize,
8176                        base,
8177                    );
8178                }),
8179
8180                Thunk::SgdMomentum { param, vel, grad, p_out, v_out, lr, mom, len } => {
8181                    let len = len as usize;
8182                    Arc::new(move |base: *mut u8| unsafe {
8183                        let p = sl(param, base, len);
8184                        let v = sl(vel, base, len);
8185                        let g = sl(grad, base, len);
8186                        let po = sl_mut(p_out, base, len);
8187                        let vo = sl_mut(v_out, base, len);
8188                        for i in 0..len {
8189                            let vn = mom * v[i] + g[i];
8190                            vo[i] = vn;
8191                            po[i] = p[i] - lr * vn;
8192                        }
8193                    })
8194                }
8195
8196                _ => Arc::new(|_: *mut u8| {}),
8197            }
8198        })
8199        .collect();
8200
8201    // ── Thunk-level attention fusion ──────────────────────
8202    // For small batch*seq, fuse QKV→Narrow×3→[Rope×2]→Attention→OutProj
8203    // into a single FusedAttnBlock. Auto-detects from Attention thunks.
8204    let fuse_threshold: usize = rlx_ir::env::var("RLX_FUSE_ATTN_THRESHOLD")
8205        .and_then(|v| v.parse().ok())
8206        .unwrap_or(64);
8207    let should_fuse = thunks.iter().any(|t| match t {
8208        Thunk::Attention { batch, seq, .. } => {
8209            (*batch as usize) * (*seq as usize) <= fuse_threshold
8210        }
8211        _ => false,
8212    });
8213
8214    if should_fuse {
8215        // Build non-Nop index for pattern matching across Nop gaps
8216        let active: Vec<usize> = thunks
8217            .iter()
8218            .enumerate()
8219            .filter(|(_, t)| !matches!(t, Thunk::Nop))
8220            .map(|(i, _)| i)
8221            .collect();
8222
8223        let mut kill = vec![false; thunks.len()]; // mark thunks to remove
8224        let mut insertions: Vec<(usize, Thunk)> = Vec::new(); // (position, replacement)
8225
8226        let mut ai = 0;
8227        while ai < active.len() {
8228            // Helper: get active thunk at offset from current
8229            let a = |off: usize| -> Option<(usize, &Thunk)> {
8230                active.get(ai + off).map(|&idx| (idx, &thunks[idx]))
8231            };
8232
8233            // Try BERT pattern: FusedMmBiasAct(QKV) → Narrow×3 → Attention → FusedMmBiasAct(out)
8234            let matched = (|| {
8235                let (_i0, t0) = a(0)?;
8236                let (_, t1) = a(1)?;
8237                let (_, t2) = a(2)?;
8238                let (_, t3) = a(3)?;
8239
8240                // a[0] must be FusedMmBiasAct or Sgemm (QKV projection)
8241                let (hidden, qkv_w, qkv_b, has_b) = match t0 {
8242                    Thunk::FusedMmBiasAct {
8243                        a,
8244                        w,
8245                        bias,
8246                        n: _,
8247                        act: None,
8248                        ..
8249                    } => (*a, *w, *bias, true),
8250                    Thunk::Sgemm { a, b, n: _, .. } => (*a, *b, 0, false),
8251                    _ => return None,
8252                };
8253
8254                // a[1..3] must be Narrows
8255                if !matches!(t1, Thunk::Narrow { .. }) {
8256                    return None;
8257                }
8258                if !matches!(t2, Thunk::Narrow { .. }) {
8259                    return None;
8260                }
8261                if !matches!(t3, Thunk::Narrow { .. }) {
8262                    return None;
8263                }
8264
8265                // Look for optional Rope×2 then Attention. Capture the rope
8266                // pairing (`interleaved` = GPT-J) from the q-rope thunk and
8267                // require the k-rope to agree — the fused kernel applies one
8268                // pairing to both.
8269                let (has_rope, attn_ai, cos_off, sin_off, cl, rope_interleaved) = if let Some((
8270                    _,
8271                    Thunk::Rope {
8272                        cos,
8273                        sin,
8274                        cos_len,
8275                        interleaved,
8276                        ..
8277                    },
8278                )) = a(4)
8279                {
8280                    let q_il = *interleaved;
8281                    match a(5).map(|x| x.1) {
8282                        Some(Thunk::Rope {
8283                            interleaved: k_il, ..
8284                        }) if *k_il == q_il => {
8285                            if matches!(a(6).map(|x| x.1), Some(Thunk::Attention { .. })) {
8286                                (true, 6, *cos, *sin, *cos_len, q_il)
8287                            } else {
8288                                return None;
8289                            }
8290                        }
8291                        _ => return None,
8292                    }
8293                } else if matches!(a(4).map(|x| x.1), Some(Thunk::Attention { .. })) {
8294                    (false, 4, 0, 0, 0, false)
8295                } else {
8296                    return None;
8297                };
8298
8299                let (_attn_real_idx, attn_t) = a(attn_ai)?;
8300                let (batch, seq, heads, head_dim, mask, mask_kind, kv_seq) = match attn_t {
8301                    Thunk::Attention {
8302                        batch,
8303                        seq,
8304                        heads,
8305                        head_dim,
8306                        mask,
8307                        mask_kind,
8308                        kv_seq,
8309                        ..
8310                    } => (*batch, *seq, *heads, *head_dim, *mask, *mask_kind, *kv_seq),
8311                    _ => return None,
8312                };
8313                // The fused kernel synthesizes Causal / SlidingWindow in-kernel
8314                // and reads the buffer for Custom; it has no additive-`Bias`
8315                // path, and its in-kernel position math assumes prefill
8316                // (`q_seq == kv_seq`, i.e. no KV cache). Fall back to the
8317                // unfused Attention thunk for anything else.
8318                if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) || kv_seq != seq {
8319                    return None;
8320                }
8321
8322                // Next active must be out projection (FusedMmBiasAct or Sgemm)
8323                let (_out_real_idx, out_t) = a(attn_ai + 1)?;
8324                let (out_w, out_b, out_dst) = match out_t {
8325                    Thunk::FusedMmBiasAct {
8326                        w,
8327                        bias,
8328                        c,
8329                        act: None,
8330                        ..
8331                    } => (*w, *bias, *c),
8332                    Thunk::Sgemm { b: w, c, .. } => (*w, 0, *c),
8333                    _ => return None,
8334                };
8335
8336                let hs = heads * head_dim;
8337                let total_active = attn_ai + 2; // number of active thunks consumed
8338
8339                Some((
8340                    total_active,
8341                    Thunk::FusedAttnBlock {
8342                        hidden,
8343                        qkv_w,
8344                        out_w,
8345                        mask,
8346                        mask_kind,
8347                        out: out_dst,
8348                        qkv_b: if has_b { qkv_b } else { 0 },
8349                        out_b: if has_b { out_b } else { 0 },
8350                        cos: cos_off,
8351                        sin: sin_off,
8352                        cos_len: cl,
8353                        batch,
8354                        seq,
8355                        hs,
8356                        nh: heads,
8357                        dh: head_dim,
8358                        has_bias: has_b,
8359                        has_rope,
8360                        interleaved: rope_interleaved,
8361                    },
8362                ))
8363            })();
8364
8365            if let Some((count, fused_thunk)) = matched {
8366                // Mark consumed thunks for removal
8367                for off in 0..count {
8368                    if let Some(&idx) = active.get(ai + off) {
8369                        kill[idx] = true;
8370                    }
8371                }
8372                // Insert replacement at position of the QKV thunk
8373                insertions.push((active[ai], fused_thunk));
8374                ai += count;
8375            } else {
8376                ai += 1;
8377            }
8378        }
8379
8380        // Rebuild thunk list: keep non-killed, insert fused at right positions
8381        if !insertions.is_empty() {
8382            let mut new_thunks = Vec::with_capacity(thunks.len());
8383            let mut insert_idx = 0;
8384            for (i, t) in thunks.into_iter().enumerate() {
8385                if insert_idx < insertions.len() && insertions[insert_idx].0 == i {
8386                    new_thunks.push(insertions[insert_idx].1.clone());
8387                    insert_idx += 1;
8388                }
8389                if !kill[i] {
8390                    new_thunks.push(t);
8391                }
8392            }
8393            if cfg.verbose >= 1 {
8394                eprintln!(
8395                    "[rlx] fused_attention: {} attention blocks fused",
8396                    insertions.len()
8397                );
8398            }
8399            thunks = new_thunks;
8400        }
8401    }
8402
8403    // ── Full layer fusion ──────────────────────────────────
8404    // After attention blocks are fused, scan for full layer patterns:
8405    // BERT:  FusedAttnBlock → FusedResidualLN → FusedMmBiasAct(gelu) → Sgemm → BiasAdd → FusedResidualLN
8406    // Nomic: FusedAttnBlock → BinaryFull(add) → LayerNorm → Sgemm → [Narrow×2 → Silu → BinaryFull(mul)] → Sgemm → BinaryFull(add) → LayerNorm
8407    if should_fuse {
8408        let active: Vec<usize> = thunks
8409            .iter()
8410            .enumerate()
8411            .filter(|(_, t)| !matches!(t, Thunk::Nop))
8412            .map(|(i, _)| i)
8413            .collect();
8414
8415        let mut kill = vec![false; thunks.len()];
8416        let mut insertions: Vec<(usize, Thunk)> = Vec::new();
8417
8418        let a = |ai: usize| -> Option<&Thunk> { active.get(ai).map(|&i| &thunks[i]) };
8419
8420        let mut ai = 0;
8421        while ai < active.len() {
8422            // BERT pattern: FusedAttnBlock → FusedResidualLN → FusedMmBiasAct(gelu) → FusedMmBiasAct(none) → FusedResidualLN
8423            let bert_match = (|| -> Option<usize> {
8424                let fab = a(ai)?;
8425                let rln1 = a(ai + 1)?;
8426                let ffn1 = a(ai + 2)?;
8427                let ffn2 = a(ai + 3)?;
8428                let rln2 = a(ai + 4)?;
8429
8430                let (hidden, qkv_w, qkv_b, out_w, out_b, mask, batch, seq, hs, nh, dh) = match fab {
8431                    Thunk::FusedAttnBlock {
8432                        hidden,
8433                        qkv_w,
8434                        qkv_b,
8435                        out_w,
8436                        out_b,
8437                        mask,
8438                        // FusedBertLayer applies only the per-key padding mask;
8439                        // it has no synthesized causal/sliding path, so it must
8440                        // not swallow a non-`Custom` attention block.
8441                        mask_kind: rlx_ir::op::MaskKind::Custom,
8442                        batch,
8443                        seq,
8444                        hs,
8445                        nh,
8446                        dh,
8447                        has_bias: true,
8448                        has_rope: false,
8449                        ..
8450                    } => (
8451                        *hidden, *qkv_w, *qkv_b, *out_w, *out_b, *mask, *batch, *seq, *hs, *nh, *dh,
8452                    ),
8453                    _ => return None,
8454                };
8455                let (ln1_g, ln1_b, eps1) = match rln1 {
8456                    Thunk::FusedResidualLN { g, b, eps, .. } => (*g, *b, *eps),
8457                    _ => return None,
8458                };
8459                let (fc1_w, fc1_b, int_dim) = match ffn1 {
8460                    Thunk::FusedMmBiasAct {
8461                        w,
8462                        bias,
8463                        n,
8464                        act: Some(Activation::Gelu),
8465                        ..
8466                    } => (*w, *bias, *n),
8467                    _ => return None,
8468                };
8469                let (fc2_w, fc2_b) = match ffn2 {
8470                    Thunk::FusedMmBiasAct {
8471                        w, bias, act: None, ..
8472                    } => (*w, *bias),
8473                    _ => return None,
8474                };
8475                let (ln2_g, ln2_b, eps2, out) = match rln2 {
8476                    Thunk::FusedResidualLN { g, b, eps, out, .. } => (*g, *b, *eps, *out),
8477                    _ => return None,
8478                };
8479
8480                for off in 0..5 {
8481                    kill[active[ai + off]] = true;
8482                }
8483                insertions.push((
8484                    active[ai],
8485                    Thunk::FusedBertLayer {
8486                        hidden,
8487                        qkv_w,
8488                        qkv_b,
8489                        out_w,
8490                        out_b,
8491                        mask,
8492                        ln1_g,
8493                        ln1_b,
8494                        eps1,
8495                        fc1_w,
8496                        fc1_b,
8497                        fc2_w,
8498                        fc2_b,
8499                        ln2_g,
8500                        ln2_b,
8501                        eps2,
8502                        out,
8503                        batch,
8504                        seq,
8505                        hs,
8506                        nh,
8507                        dh,
8508                        int_dim,
8509                    },
8510                ));
8511                Some(5)
8512            })();
8513            if let Some(n) = bert_match {
8514                ai += n;
8515                continue;
8516            }
8517
8518            // Nomic full-layer fusion — DISABLED. The matcher below targets a
8519            // stale pipeline shape (`FusedAttnBlock → FusedResidualLN → Sgemm →
8520            // Narrow×2 → SiLU → Mul → Sgemm → FusedResidualLN`). The current CPU
8521            // pipeline collapses the SwiGLU itself (`FuseSwiGLU` → one
8522            // `Op::FusedSwiGLU`/`Thunk::FusedSwiGLU`) and emits a runtime weight
8523            // `Concat` (runtime weight concat) before the fused fc matmul. So the
8524            // current post-fusion shape is:
8525            //   FusedAttnBlock(rope,no-bias) → FusedResidualLN(LN1) → [Concat] →
8526            //   Sgemm(fc11‖fc12) → FusedSwiGLU → Sgemm(fc2) → FusedResidualLN(LN2)
8527            // which this matches (the weight `Concat`s are kept — they produce the
8528            // fused fc weight the kernel reads). The `FusedNomicLayer` exec only
8529            // implements the up‖gate SwiGLU (`gate_first == false`, what real
8530            // Nomic emits: `up=fc11`, `gate=silu(fc12)`) and a per-key (`Custom`)
8531            // mask, so the match bails otherwise. Escape hatch:
8532            // `RLX_DISABLE_NOMIC_FUSION`. Validated against the real model in
8533            // `../rlx-models/crates/rlx-nomic` (CPU fused-vs-unfused parity).
8534            let nomic_match = (|| -> Option<usize> {
8535                if rlx_ir::env::flag("RLX_DISABLE_NOMIC_FUSION") {
8536                    return None;
8537                }
8538                let (
8539                    hidden,
8540                    qkv_w,
8541                    out_w,
8542                    mask,
8543                    cos,
8544                    sin,
8545                    cos_len,
8546                    batch,
8547                    seq,
8548                    hs,
8549                    nh,
8550                    dh,
8551                    interleaved,
8552                ) = match a(ai)? {
8553                    Thunk::FusedAttnBlock {
8554                        hidden,
8555                        qkv_w,
8556                        out_w,
8557                        mask,
8558                        cos,
8559                        sin,
8560                        cos_len,
8561                        batch,
8562                        seq,
8563                        hs,
8564                        nh,
8565                        dh,
8566                        has_bias: false,
8567                        has_rope: true,
8568                        mask_kind: rlx_ir::op::MaskKind::Custom,
8569                        interleaved,
8570                        ..
8571                    } => (
8572                        *hidden,
8573                        *qkv_w,
8574                        *out_w,
8575                        *mask,
8576                        *cos,
8577                        *sin,
8578                        *cos_len,
8579                        *batch,
8580                        *seq,
8581                        *hs,
8582                        *nh,
8583                        *dh,
8584                        *interleaved,
8585                    ),
8586                    _ => return None,
8587                };
8588                // FusedResidualLN for LN1
8589                let (ln1_g, ln1_b, eps1) = match a(ai + 1)? {
8590                    Thunk::FusedResidualLN { g, b, eps, .. } => (*g, *b, *eps),
8591                    _ => return None,
8592                };
8593                // Consumed active thunks to remove (the kept weight `Concat`s are
8594                // NOT added here — the fused kernel still reads their output).
8595                let mut kills: Vec<usize> = vec![ai, ai + 1];
8596                // Optional runtime weight Concat (fc11‖fc12), then Sgemm.
8597                let mut o = 2;
8598                if matches!(a(ai + o)?, Thunk::Concat { .. }) {
8599                    o += 1;
8600                }
8601                let fused_fc_w = match a(ai + o)? {
8602                    Thunk::Sgemm { b: w, .. } => *w,
8603                    _ => return None,
8604                };
8605                kills.push(ai + o);
8606                o += 1;
8607                // FusedSwiGLU — int_dim is its half width; the exec only handles
8608                // up‖gate (gate is the SECOND half), so require !gate_first.
8609                let int_dim = match a(ai + o)? {
8610                    Thunk::FusedSwiGLU {
8611                        n_half,
8612                        gate_first: false,
8613                        ..
8614                    } => *n_half,
8615                    _ => return None,
8616                };
8617                kills.push(ai + o);
8618                o += 1;
8619                // Sgemm (fc2)
8620                let fc2_w = match a(ai + o)? {
8621                    Thunk::Sgemm { b: w, .. } => *w,
8622                    _ => return None,
8623                };
8624                kills.push(ai + o);
8625                o += 1;
8626                // FusedResidualLN for LN2
8627                let (ln2_g, ln2_b, eps2, out) = match a(ai + o)? {
8628                    Thunk::FusedResidualLN { g, b, eps, out, .. } => (*g, *b, *eps, *out),
8629                    _ => return None,
8630                };
8631                kills.push(ai + o);
8632                let consumed = o + 1;
8633
8634                for ki in kills {
8635                    kill[active[ki]] = true;
8636                }
8637                FUSED_NOMIC_LAYER_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8638                // Insert at the LAST consumed position (LN2), NOT the first
8639                // (FusedAttnBlock): the fused kernel reads the runtime weight
8640                // `Concat` (fc11‖fc12, and qkv for split-qkv models) that sits
8641                // between them, so it must execute AFTER those concats run.
8642                insertions.push((
8643                    active[ai + o],
8644                    Thunk::FusedNomicLayer {
8645                        hidden,
8646                        qkv_w,
8647                        out_w,
8648                        mask,
8649                        cos,
8650                        sin,
8651                        cos_len,
8652                        ln1_g,
8653                        ln1_b,
8654                        eps1,
8655                        fc11_w: fused_fc_w,
8656                        fc12_w: 0,
8657                        fc2_w,
8658                        ln2_g,
8659                        ln2_b,
8660                        eps2,
8661                        out,
8662                        batch,
8663                        seq,
8664                        hs,
8665                        nh,
8666                        dh,
8667                        int_dim,
8668                        interleaved,
8669                    },
8670                ));
8671                Some(consumed)
8672            })();
8673            if let Some(n) = nomic_match {
8674                ai += n;
8675                continue;
8676            }
8677
8678            ai += 1;
8679        }
8680
8681        if !insertions.is_empty() {
8682            let mut new_thunks = Vec::with_capacity(thunks.len());
8683            let mut ins_idx = 0;
8684            for (i, t) in thunks.into_iter().enumerate() {
8685                if ins_idx < insertions.len() && insertions[ins_idx].0 == i {
8686                    new_thunks.push(insertions[ins_idx].1.clone());
8687                    ins_idx += 1;
8688                }
8689                if !kill[i] {
8690                    new_thunks.push(t);
8691                }
8692            }
8693            if cfg.verbose >= 1 {
8694                eprintln!(
8695                    "[rlx] fused_layer: {} full transformer layers fused",
8696                    insertions.len()
8697                );
8698            }
8699            thunks = new_thunks;
8700        }
8701    }
8702
8703    // ── Narrow → Rope thunk fusion (plan #45) ──────────────
8704    // Runs *after* FusedAttnBlock fusion so it only catches the medium-
8705    // batch path (batch*seq > 64) where the bigger fusion didn't fire.
8706    // Pattern: a Rope thunk whose `src` is the dst of an immediately-
8707    // preceding Narrow whose dst has no other consumer in this schedule.
8708    // Rewrite Rope to read directly from the parent buffer with the
8709    // parent's row stride; the Narrow becomes a Nop.
8710    //
8711    // Skipping the Narrow's write saves one full pass over Q/K (B*S*hs
8712    // f32) per Rope. For Nomic h=768 / batch=8 / seq=15 / 12 layers
8713    // that's 2 ropes/layer × 369 KB = ~8.9 MB of write traffic gone.
8714    {
8715        // Collect every byte-offset that's read as a thunk's `src` so
8716        // we know whether a Narrow's dst has consumers other than Rope.
8717        let mut read_offsets: HashMap<usize, usize> = HashMap::new();
8718        for t in &thunks {
8719            for off in thunk_read_offsets(t) {
8720                *read_offsets.entry(off).or_insert(0) += 1;
8721            }
8722        }
8723
8724        let mut fused_count = 0usize;
8725        for i in 0..thunks.len().saturating_sub(1) {
8726            // Look for Rope at i+1 reading from Narrow at i (skip Nops
8727            // between them since the planner left them in place).
8728            let narrow = match &thunks[i] {
8729                Thunk::Narrow { .. } => i,
8730                _ => continue,
8731            };
8732            // Find the next non-Nop thunk
8733            let mut j = narrow + 1;
8734            while j < thunks.len() && matches!(thunks[j], Thunk::Nop) {
8735                j += 1;
8736            }
8737            if j >= thunks.len() {
8738                continue;
8739            }
8740            // Must be Rope reading Narrow's dst
8741            let (n_src, n_dst, n_src_stride) = match &thunks[narrow] {
8742                Thunk::Narrow {
8743                    src,
8744                    dst,
8745                    src_stride,
8746                    ..
8747                } => (*src, *dst, *src_stride),
8748                _ => continue,
8749            };
8750            let rope_reads_narrow = matches!(&thunks[j],
8751                Thunk::Rope { src, .. } if *src == n_dst);
8752            if !rope_reads_narrow {
8753                continue;
8754            }
8755            // Conservatively require that the Narrow's dst has exactly
8756            // one reader (the Rope). Anything else and rewriting would
8757            // skip a needed write.
8758            if read_offsets.get(&n_dst).copied().unwrap_or(0) != 1 {
8759                continue;
8760            }
8761
8762            // Rewire: Rope reads from Narrow's adjusted source with the
8763            // parent buffer's row stride.
8764            if let Thunk::Rope {
8765                src,
8766                src_row_stride,
8767                ..
8768            } = &mut thunks[j]
8769            {
8770                *src = n_src;
8771                *src_row_stride = n_src_stride;
8772            }
8773            thunks[narrow] = Thunk::Nop;
8774            fused_count += 1;
8775        }
8776
8777        if fused_count > 0 && cfg.verbose >= 1 {
8778            eprintln!(
8779                "[rlx] fused_qk_rope: {} Narrow→Rope pairs collapsed",
8780                fused_count
8781            );
8782        }
8783    }
8784
8785    // ── Narrow×3 → Attention thunk fusion (plan #46 deep) ────
8786    // For each Attention thunk in the schedule, look up the producers
8787    // of its q/k/v inputs. If each is a Narrow whose dst has exactly
8788    // one consumer (the Attention), rewire Attention to read directly
8789    // from the parent buffer with the parent's row stride. The three
8790    // Narrows become Nops.
8791    //
8792    // This catches the BERT/Nomic QKV split path that FusedAttnBlock
8793    // misses (batch*seq > 64) — eliminates Q/K/V copies entirely.
8794    // For minilm6 batch=32 seq=16 hs=384: 3 × 32*16*384*4 = 2.3 MB
8795    // per layer × 6 layers = ~14 MB of write traffic gone.
8796    {
8797        let mut read_counts: HashMap<usize, usize> = HashMap::new();
8798        for t in &thunks {
8799            for off in thunk_read_offsets(t) {
8800                *read_counts.entry(off).or_insert(0) += 1;
8801            }
8802        }
8803        // Build dst→index map for fast producer lookup.
8804        let mut dst_to_idx: HashMap<usize, usize> = HashMap::new();
8805        for (i, t) in thunks.iter().enumerate() {
8806            if let Thunk::Narrow { dst, .. } = t {
8807                dst_to_idx.insert(*dst, i);
8808            }
8809        }
8810
8811        let mut fused_count = 0usize;
8812        for i in 0..thunks.len() {
8813            let (q_off, k_off, v_off) = match &thunks[i] {
8814                Thunk::Attention { q, k, v, .. } => (*q, *k, *v),
8815                _ => continue,
8816            };
8817            // All three inputs must come from Narrows.
8818            let q_n = match dst_to_idx.get(&q_off).copied() {
8819                Some(x) => x,
8820                None => continue,
8821            };
8822            let k_n = match dst_to_idx.get(&k_off).copied() {
8823                Some(x) => x,
8824                None => continue,
8825            };
8826            let v_n = match dst_to_idx.get(&v_off).copied() {
8827                Some(x) => x,
8828                None => continue,
8829            };
8830            // Each Narrow's dst must have exactly one reader (this Attn).
8831            if read_counts.get(&q_off).copied().unwrap_or(0) != 1 {
8832                continue;
8833            }
8834            if read_counts.get(&k_off).copied().unwrap_or(0) != 1 {
8835                continue;
8836            }
8837            if read_counts.get(&v_off).copied().unwrap_or(0) != 1 {
8838                continue;
8839            }
8840
8841            let (q_src, q_stride) = match &thunks[q_n] {
8842                Thunk::Narrow {
8843                    src, src_stride, ..
8844                } => (*src, *src_stride),
8845                _ => continue,
8846            };
8847            let (k_src, k_stride) = match &thunks[k_n] {
8848                Thunk::Narrow {
8849                    src, src_stride, ..
8850                } => (*src, *src_stride),
8851                _ => continue,
8852            };
8853            let (v_src, v_stride) = match &thunks[v_n] {
8854                Thunk::Narrow {
8855                    src, src_stride, ..
8856                } => (*src, *src_stride),
8857                _ => continue,
8858            };
8859
8860            if let Thunk::Attention {
8861                q,
8862                k,
8863                v,
8864                q_row_stride,
8865                k_row_stride,
8866                v_row_stride,
8867                ..
8868            } = &mut thunks[i]
8869            {
8870                *q = q_src;
8871                *k = k_src;
8872                *v = v_src;
8873                *q_row_stride = q_stride;
8874                *k_row_stride = k_stride;
8875                *v_row_stride = v_stride;
8876            }
8877            thunks[q_n] = Thunk::Nop;
8878            thunks[k_n] = Thunk::Nop;
8879            thunks[v_n] = Thunk::Nop;
8880            fused_count += 1;
8881        }
8882
8883        if fused_count > 0 && cfg.verbose >= 1 {
8884            eprintln!(
8885                "[rlx] fused_strided_attn: {} Narrow×3→Attention rewrites",
8886                fused_count
8887            );
8888        }
8889    }
8890
8891    ThunkSchedule {
8892        thunks,
8893        moe_resident: None,
8894        moe_resident_layers: None,
8895        moe_topk_capture: None,
8896        mask_threshold: cfg.mask_binary_threshold,
8897        mask_neg_inf: cfg.attn_mask_neg_inf,
8898        score_skip: cfg.score_skip_threshold,
8899        compiled_fns,
8900        rng: rng_shared,
8901    }
8902}
8903
8904fn get_len(graph: &Graph, id: NodeId) -> usize {
8905    graph.node(id).shape.num_elements().unwrap_or(0)
8906}
8907
8908/// Static `usize` dims of a node's shape, or empty if any dim is dynamic.
8909fn get_static_dims(graph: &Graph, id: NodeId) -> Vec<usize> {
8910    let dims = graph.node(id).shape.dims();
8911    let mut out = Vec::with_capacity(dims.len());
8912    for d in dims {
8913        if let Some(s) = match d {
8914            rlx_ir::Dim::Static(s) => Some(*s),
8915            _ => None,
8916        } {
8917            out.push(s);
8918        } else {
8919            return Vec::new();
8920        }
8921    }
8922    out
8923}
8924
8925/// Extent along `axis` for a concat input, treating leading implicit 1s when
8926/// `input.rank() < output.rank()` (ONNX / numpy concat broadcast rules).
8927fn concat_axis_extent(input: &rlx_ir::Shape, axis: usize, out_rank: usize) -> usize {
8928    let in_rank = input.rank();
8929    if axis >= out_rank {
8930        return 1;
8931    }
8932    if axis < in_rank {
8933        input.dim(axis).unwrap_static()
8934    } else {
8935        1
8936    }
8937}
8938
8939fn broadcast_src_index(src_idx: usize, in_len: usize) -> usize {
8940    if in_len == 0 { 0 } else { src_idx % in_len }
8941}
8942
8943fn concat_copy_rows_f32(
8944    out: &mut [f32],
8945    inp: &[f32],
8946    outer: usize,
8947    copy_per_row: usize,
8948    row_stride: usize,
8949    dst_col_off: usize,
8950    in_numel: usize,
8951) {
8952    let need = outer.saturating_mul(copy_per_row.max(1));
8953    let broadcast_outer = in_numel < need;
8954    for o in 0..outer {
8955        let dst_row_start = o * row_stride + dst_col_off;
8956        if broadcast_outer {
8957            if in_numel == 1 {
8958                if copy_per_row == 1 {
8959                    out[dst_row_start] = inp[0];
8960                } else {
8961                    out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
8962                }
8963            } else if copy_per_row <= inp.len() {
8964                out[dst_row_start..dst_row_start + copy_per_row]
8965                    .copy_from_slice(&inp[..copy_per_row]);
8966            } else if !inp.is_empty() {
8967                out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
8968            }
8969        } else {
8970            let src_row_start = o * copy_per_row;
8971            out[dst_row_start..dst_row_start + copy_per_row]
8972                .copy_from_slice(&inp[src_row_start..src_row_start + copy_per_row]);
8973        }
8974    }
8975}
8976
8977fn concat_copy_rows_f64(
8978    out: &mut [f64],
8979    inp: &[f64],
8980    outer: usize,
8981    copy_per_row: usize,
8982    row_stride: usize,
8983    dst_col_off: usize,
8984    in_numel: usize,
8985) {
8986    let need = outer.saturating_mul(copy_per_row.max(1));
8987    let broadcast_outer = in_numel < need;
8988    for o in 0..outer {
8989        let dst_row_start = o * row_stride + dst_col_off;
8990        if broadcast_outer {
8991            if in_numel == 1 {
8992                if copy_per_row == 1 {
8993                    out[dst_row_start] = inp[0];
8994                } else {
8995                    out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
8996                }
8997            } else if copy_per_row <= inp.len() {
8998                out[dst_row_start..dst_row_start + copy_per_row]
8999                    .copy_from_slice(&inp[..copy_per_row]);
9000            } else if !inp.is_empty() {
9001                out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
9002            }
9003        } else {
9004            let src_row_start = o * copy_per_row;
9005            out[dst_row_start..dst_row_start + copy_per_row]
9006                .copy_from_slice(&inp[src_row_start..src_row_start + copy_per_row]);
9007        }
9008    }
9009}
9010
9011/// NumPy-style broadcast strides for one operand into the flat output
9012/// buffer. Returns a length-`out_dims.len()` `Vec<u32>` where entry
9013/// `d` is `0` if the input is size-1 (broadcast) at output dim `d`
9014/// (after left-padding with size-1 to match ranks), otherwise the
9015/// natural row-major stride into the *input* buffer.
9016///
9017/// Caller iterates output flat index `i` → output coords (row-major)
9018/// → input flat index = dot(coords, strides). The result is correct
9019/// for any broadcast pattern (scalar, last-axis, middle-axis,
9020/// bidirectional).
9021/// True when `rhs_dims` describes a *trailing* broadcast of `out_dims`
9022/// — i.e. every rhs dim either equals the corresponding output dim
9023/// (counting from the right) or rhs is shorter (left-padded with 1s).
9024/// Mid-shape singletons (e.g. rhs `[a, b, 1, d]` into out `[a, b, c, d]`
9025/// where `c > 1`) are NOT trailing broadcasts and require the
9026/// shape-aware `BinaryFull` slow path — `BiasAdd`'s linear bias-replicated
9027/// kernel silently miscomputes them.
9028fn is_trailing_bias_broadcast(rhs_dims: &[rlx_ir::Dim], out_dims: &[rlx_ir::Dim]) -> bool {
9029    if rhs_dims.len() > out_dims.len() {
9030        return false;
9031    }
9032    let off = out_dims.len() - rhs_dims.len();
9033    for i in 0..rhs_dims.len() {
9034        let r = match rhs_dims[i] {
9035            rlx_ir::Dim::Static(n) => n,
9036            _ => return false,
9037        };
9038        let o = match out_dims[off + i] {
9039            rlx_ir::Dim::Static(n) => n,
9040            _ => return false,
9041        };
9042        if r != o {
9043            return false;
9044        }
9045    }
9046    true
9047}
9048
9049fn broadcast_strides(in_dims: &[usize], out_dims: &[usize]) -> Vec<u32> {
9050    let r_out = out_dims.len();
9051    let r_in = in_dims.len();
9052    assert!(
9053        r_in <= r_out,
9054        "broadcast: input rank {r_in} > output rank {r_out}"
9055    );
9056    let pad = r_out - r_in;
9057    let mut strides = vec![0u32; r_out];
9058    let mut acc: usize = 1;
9059    for d in (0..r_out).rev() {
9060        let in_size = if d < pad { 1 } else { in_dims[d - pad] };
9061        if in_size == 1 {
9062            strides[d] = 0;
9063        } else {
9064            assert_eq!(
9065                in_size, out_dims[d],
9066                "broadcast: input dim {in_size} doesn't match output dim {} at axis {d}",
9067                out_dims[d]
9068            );
9069            strides[d] = acc as u32;
9070            acc *= in_size;
9071        }
9072    }
9073    strides
9074}
9075
9076/// Execute a thunk schedule on a raw arena buffer.
9077/// Fastest executor: call pre-compiled closures sequentially.
9078/// Zero match dispatch — each closure is a direct kernel call.
9079pub fn execute_compiled(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
9080    let base = arena_buf.as_mut_ptr();
9081    for f in &schedule.compiled_fns {
9082        f(base);
9083    }
9084}
9085
9086/// Active-extent execution stub. The runtime calls this when it has an
9087/// active-extent hint set. CPU doesn't implement per-thunk active-extent
9088/// scaling yet — return false so the caller falls back to the full
9089/// `execute_thunks` path.
9090pub fn execute_thunks_active(
9091    schedule: &ThunkSchedule,
9092    _arena_buf: &mut [u8],
9093    _actual: usize,
9094    _upper: usize,
9095) -> bool {
9096    let _ = schedule;
9097    false
9098}
9099
9100/// Match-based executor (fallback, used by tests).
9101struct MoeResidencyGuard;
9102impl Drop for MoeResidencyGuard {
9103    fn drop(&mut self) {
9104        if let Some(stats) = crate::moe_residency::take_stats() {
9105            crate::moe_residency::stash_last_forward_stats(stats);
9106        } else {
9107            crate::moe_residency::clear_mask();
9108        }
9109    }
9110}
9111
9112fn thunk_kind_name(t: &Thunk) -> &'static str {
9113    match t {
9114        Thunk::Nop => "Nop",
9115        Thunk::Gather { .. } => "Gather",
9116        Thunk::GatherAxis { .. } => "GatherAxis",
9117        Thunk::TopK { .. } => "TopK",
9118        Thunk::Copy { .. } => "Copy",
9119        Thunk::CopyF64 { .. } => "CopyF64",
9120        Thunk::CopyI64 { .. } => "CopyI64",
9121        Thunk::CastF32ToI64 { .. } => "CastF32ToI64",
9122        Thunk::CastI64ToF32 { .. } => "CastI64ToF32",
9123        Thunk::CastBoolToI32 { .. } => "CastBoolToI32",
9124        Thunk::CastBoolToF32 { .. } => "CastBoolToF32",
9125        Thunk::CastI32ToF32 { .. } => "CastI32ToF32",
9126        Thunk::Transpose { .. } => "Transpose",
9127        Thunk::TransposeF64 { .. } => "TransposeF64",
9128        Thunk::Where { .. } => "Where",
9129        Thunk::Fma { .. } => "Fma",
9130        Thunk::Compare { .. } => "Compare",
9131        Thunk::BinaryFull { .. } => "BinaryFull",
9132        Thunk::BinaryFullF64 { .. } => "BinaryFullF64",
9133        Thunk::Sgemm { .. } => "Sgemm",
9134        Thunk::SgemmT { .. } => "SgemmT",
9135        Thunk::SgdMomentum { .. } => "SgdMomentum",
9136        Thunk::Dgemm { .. } => "Dgemm",
9137        Thunk::FusedMmBiasAct { .. } => "FusedMmBiasAct",
9138        Thunk::BiasAdd { .. } => "BiasAdd",
9139        Thunk::LayerNorm { .. } => "LayerNorm",
9140        Thunk::Softmax { .. } => "Softmax",
9141        Thunk::Conv2D { .. } => "Conv2D",
9142        Thunk::Conv2D1x1 { .. } => "Conv2D1x1",
9143        Thunk::CustomOp { .. } => "CustomOp",
9144        Thunk::ActivationInPlace { .. } => "ActivationInPlace",
9145        Thunk::Narrow { .. } => "Narrow",
9146        Thunk::Cumsum { .. } => "Cumsum",
9147        Thunk::Reduce { .. } => "Reduce",
9148        Thunk::BatchedSgemm { .. } => "BatchedSgemm",
9149        Thunk::DequantMatMul { .. } => "DequantMatMul",
9150        Thunk::Quantize { .. } => "Quantize",
9151        Thunk::Dequantize { .. } => "Dequantize",
9152        Thunk::ConvTranspose2d { .. } => "ConvTranspose2d",
9153        Thunk::ResizeNearest2x { .. } => "ResizeNearest2x",
9154        Thunk::ElementwiseRegion { .. } => "ElementwiseRegion",
9155        Thunk::Conv2dBackwardInput { .. } => "Conv2dBackwardInput",
9156        Thunk::Conv2dBackwardWeight { .. } => "Conv2dBackwardWeight",
9157        Thunk::Pool2D { .. } => "Pool2D",
9158        Thunk::MaxPool2dBackward { .. } => "MaxPool2dBackward",
9159        Thunk::ReluBackward { .. } => "ReluBackward",
9160        Thunk::ActivationBackward { .. } => "ActivationBackward",
9161        Thunk::Im2Col { .. } => "Im2Col",
9162        Thunk::SoftmaxCrossEntropyDense { .. } => "SoftmaxCrossEntropyDense",
9163        Thunk::SoftmaxCrossEntropy { .. } => "SoftmaxCrossEntropy",
9164        Thunk::SoftmaxCrossEntropyBackward { .. } => "SoftmaxCrossEntropyBackward",
9165        _ => "Other",
9166    }
9167}
9168
9169/// Per-thunk-kind wall-time accumulator, populated only when the env var
9170/// `RLX_PROFILE_THUNKS` is set. Used to see which ops dominate a step so the
9171/// optimizer/kernels can target the real hotspots rather than guesses.
9172static THUNK_PROFILE: std::sync::Mutex<
9173    Option<std::collections::BTreeMap<&'static str, (u128, u64)>>,
9174> = std::sync::Mutex::new(None);
9175
9176#[inline]
9177fn profile_record(name: &'static str, d: std::time::Duration) {
9178    let mut g = THUNK_PROFILE.lock().unwrap();
9179    let map = g.get_or_insert_with(std::collections::BTreeMap::new);
9180    let e = map.entry(name).or_insert((0, 0));
9181    e.0 += d.as_nanos();
9182    e.1 += 1;
9183}
9184
9185/// Print and clear the per-thunk-kind time profile gathered under
9186/// `RLX_PROFILE_THUNKS`. Call after a run to see where the time went.
9187pub fn dump_thunk_profile() {
9188    let mut g = THUNK_PROFILE.lock().unwrap();
9189    if let Some(map) = g.take() {
9190        let mut v: Vec<_> = map.into_iter().collect();
9191        v.sort_by_key(|b| std::cmp::Reverse(b.1.0));
9192        let total: u128 = v.iter().map(|(_, (ns, _))| *ns).sum();
9193        eprintln!(
9194            "[thunk-profile] total {:.1}ms across kinds:",
9195            total as f64 / 1e6
9196        );
9197        for (name, (ns, c)) in v.iter().take(25) {
9198            eprintln!("  {name:<28} {:>8.1}ms  ({c} calls)", *ns as f64 / 1e6);
9199        }
9200    }
9201}
9202
9203pub fn execute_thunks(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
9204    crate::moe_residency::reset_gmm_counters();
9205    if let Some(layers) = schedule.moe_resident_layers.clone() {
9206        crate::moe_residency::set_per_layer_masks(Some(layers));
9207    } else {
9208        crate::moe_residency::set_mask(schedule.moe_resident.clone());
9209    }
9210    if let Some(cap) = schedule.moe_topk_capture.as_ref() {
9211        cap.clear();
9212    }
9213    let _moe_guard = MoeResidencyGuard;
9214    let base = arena_buf.as_mut_ptr();
9215    let mask_thr = schedule.mask_threshold;
9216    let mask_neg = schedule.mask_neg_inf;
9217    let score_thr = schedule.score_skip;
9218    let thunks = &schedule.thunks;
9219    let len = thunks.len();
9220
9221    // Pre-allocate ALL reusable buffers once (zero per-call allocation)
9222    let max_h = thunks
9223        .iter()
9224        .filter_map(|t| match t {
9225            Thunk::FusedResidualLN { h, .. }
9226            | Thunk::FusedResidualRmsNorm { h, .. }
9227            | Thunk::LayerNorm { h, .. } => Some(*h as usize),
9228            _ => None,
9229        })
9230        .max()
9231        .unwrap_or(0);
9232    let zero_bias = vec![0f32; max_h];
9233
9234    // Pre-allocate per-(batch,head) score buffers for parallel SDPA.
9235    // Q/K/V/out are accessed via strided BLAS — no deinterleave copy needed.
9236    let max_sdpa = thunks
9237        .iter()
9238        .filter_map(|t| match t {
9239            Thunk::Attention {
9240                batch,
9241                seq,
9242                kv_seq,
9243                heads,
9244                head_dim,
9245                ..
9246            } => Some((
9247                *batch as usize,
9248                (*seq as usize).max(*kv_seq as usize),
9249                *heads as usize,
9250                *head_dim as usize,
9251            )),
9252            _ => None,
9253        })
9254        .fold((0, 0, 0, 0), |(mb, ms, mh, md), (b, s, h, d)| {
9255            (mb.max(b), ms.max(s), mh.max(h), md.max(d))
9256        });
9257    let (max_batch, max_seq, max_heads, _max_dh) = max_sdpa;
9258    let max_units = max_batch * max_heads;
9259    let mut sdpa_scores = vec![0f32; max_units * max_seq * max_seq];
9260
9261    // Pre-allocate fused layer buffers (reused across all 12+ layers — zero malloc per layer)
9262    let fl = thunks
9263        .iter()
9264        .filter_map(|t| match t {
9265            Thunk::FusedBertLayer {
9266                batch,
9267                seq,
9268                hs,
9269                int_dim,
9270                ..
9271            } => {
9272                let m = (*batch as usize) * (*seq as usize);
9273                let h = *hs as usize;
9274                let id = *int_dim as usize;
9275                Some((m, h, id, m * (*seq as usize)))
9276            }
9277            Thunk::FusedNomicLayer {
9278                batch,
9279                seq,
9280                hs,
9281                int_dim,
9282                ..
9283            } => {
9284                let m = (*batch as usize) * (*seq as usize);
9285                let h = *hs as usize;
9286                let id = *int_dim as usize;
9287                Some((m, h, id, m * (*seq as usize)))
9288            }
9289            _ => None,
9290        })
9291        .fold((0, 0, 0, 0), |(mm, mh, mi, ms), (m, h, id, ss)| {
9292            (mm.max(m), mh.max(h), mi.max(id), ms.max(ss))
9293        });
9294    let (fl_m, fl_h, fl_int, fl_ss) = fl;
9295    let mut fl_qkv = vec![0f32; fl_m * 3 * fl_h];
9296    let mut fl_attn = vec![0f32; fl_m * fl_h];
9297    let mut fl_res = vec![0f32; fl_m * fl_h];
9298    let mut fl_normed = vec![0f32; fl_m * fl_h];
9299    let mut fl_ffn = vec![0f32; fl_m * fl_int.max(2 * fl_int)]; // Nomic needs 2×int for fused fc11+fc12
9300    let mut fl_sc = vec![0f32; fl_ss.max(1)];
9301
9302    let trace_thunks = std::env::var_os("RLX_TRACE_THUNK").is_some();
9303    if trace_thunks {
9304        eprintln!(
9305            "[thunk] prealloc max_h={max_h} sdpa={} fl_m={fl_m} fl_h={fl_h} fl_int={fl_int}",
9306            max_units * max_seq * max_seq
9307        );
9308    }
9309    let profile = std::env::var_os("RLX_PROFILE_THUNKS").is_some();
9310    // Time the previous thunk at the top of each iteration (avoids touching the
9311    // giant match's many arms). The last thunk's tail is folded into the next
9312    // step's first sample — negligible over a training run.
9313    let mut prof_prev: Option<(&'static str, std::time::Instant)> = None;
9314    for i in 0..len {
9315        if profile {
9316            if let Some((pn, pt)) = prof_prev.take() {
9317                profile_record(pn, pt.elapsed());
9318            }
9319        }
9320        let thunk = unsafe { thunks.get_unchecked(i) };
9321        if trace_thunks && (i < 120 || i % 200 == 0 || i + 1 == len) {
9322            eprintln!("[thunk {i}/{len}] {}", thunk_kind_name(thunk));
9323        }
9324        let trace_done = trace_thunks && i < 120;
9325        if profile {
9326            prof_prev = Some((thunk_kind_name(thunk), std::time::Instant::now()));
9327        }
9328        match thunk {
9329            Thunk::Nop => {}
9330
9331            Thunk::ElementwiseRegion {
9332                dst,
9333                len,
9334                input_offs,
9335                chain,
9336                scalar_input_mask,
9337                input_modulus,
9338            } => {
9339                let len = *len as usize;
9340                if !chain.is_empty() && len > 0 {
9341                    let base_addr = base as usize;
9342                    let dst = *dst;
9343                    let scalar_mask = *scalar_input_mask;
9344                    // Each output element is independent → fan over the range.
9345                    let eval = |gid: usize| {
9346                        let v = region_eval_elem(
9347                            gid,
9348                            base_addr as *const u8,
9349                            input_offs,
9350                            chain,
9351                            scalar_mask,
9352                            input_modulus,
9353                        );
9354                        unsafe {
9355                            *((base_addr as *mut u8).add(dst) as *mut f32).add(gid) = v;
9356                        }
9357                    };
9358                    if fast_conv_enabled() && crate::pool::should_parallelize(len) {
9359                        crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
9360                            for gid in off..off + cnt {
9361                                eval(gid);
9362                            }
9363                        });
9364                    } else {
9365                        for gid in 0..len {
9366                            eval(gid);
9367                        }
9368                    }
9369                }
9370            }
9371
9372            Thunk::GaussianSplatRender {
9373                positions_off,
9374                positions_len,
9375                scales_off,
9376                scales_len,
9377                rotations_off,
9378                rotations_len,
9379                opacities_off,
9380                opacities_len,
9381                colors_off,
9382                colors_len,
9383                sh_coeffs_off,
9384                sh_coeffs_len,
9385                meta_off,
9386                dst_off,
9387                dst_len,
9388                width,
9389                height,
9390                tile_size,
9391                radius_scale,
9392                alpha_cutoff,
9393                max_splat_steps,
9394                transmittance_threshold,
9395                max_list_entries,
9396            } => unsafe {
9397                crate::splat::execute_gaussian_splat_render(
9398                    *positions_off,
9399                    *positions_len,
9400                    *scales_off,
9401                    *scales_len,
9402                    *rotations_off,
9403                    *rotations_len,
9404                    *opacities_off,
9405                    *opacities_len,
9406                    *colors_off,
9407                    *colors_len,
9408                    *sh_coeffs_off,
9409                    *sh_coeffs_len,
9410                    *meta_off,
9411                    *dst_off,
9412                    *dst_len,
9413                    *width,
9414                    *height,
9415                    *tile_size,
9416                    *radius_scale,
9417                    *alpha_cutoff,
9418                    *max_splat_steps,
9419                    *transmittance_threshold,
9420                    *max_list_entries,
9421                    base,
9422                );
9423            },
9424
9425            Thunk::GaussianSplatRenderBackward {
9426                positions_off,
9427                positions_len,
9428                scales_off,
9429                scales_len,
9430                rotations_off,
9431                rotations_len,
9432                opacities_off,
9433                opacities_len,
9434                colors_off,
9435                colors_len,
9436                sh_coeffs_off,
9437                sh_coeffs_len,
9438                meta_off,
9439                d_loss_off,
9440                d_loss_len,
9441                packed_off,
9442                packed_len,
9443                width,
9444                height,
9445                tile_size,
9446                radius_scale,
9447                alpha_cutoff,
9448                max_splat_steps,
9449                transmittance_threshold,
9450                max_list_entries,
9451                loss_grad_clip,
9452                sh_band,
9453                max_anisotropy,
9454            } => unsafe {
9455                crate::splat::execute_gaussian_splat_render_backward(
9456                    *positions_off,
9457                    *positions_len,
9458                    *scales_off,
9459                    *scales_len,
9460                    *rotations_off,
9461                    *rotations_len,
9462                    *opacities_off,
9463                    *opacities_len,
9464                    *colors_off,
9465                    *colors_len,
9466                    *sh_coeffs_off,
9467                    *sh_coeffs_len,
9468                    *meta_off,
9469                    *d_loss_off,
9470                    *d_loss_len,
9471                    *packed_off,
9472                    *packed_len,
9473                    *width,
9474                    *height,
9475                    *tile_size,
9476                    *radius_scale,
9477                    *alpha_cutoff,
9478                    *max_splat_steps,
9479                    *transmittance_threshold,
9480                    *max_list_entries,
9481                    *loss_grad_clip,
9482                    *sh_band,
9483                    *max_anisotropy,
9484                    base,
9485                );
9486            },
9487
9488            Thunk::GaussianSplatPrepare {
9489                positions_off,
9490                positions_len,
9491                scales_off,
9492                scales_len,
9493                rotations_off,
9494                rotations_len,
9495                opacities_off,
9496                opacities_len,
9497                colors_off,
9498                colors_len,
9499                sh_coeffs_off,
9500                sh_coeffs_len,
9501                meta_off,
9502                meta_len,
9503                prep_off,
9504                prep_len,
9505                width,
9506                height,
9507                tile_size,
9508                radius_scale,
9509                alpha_cutoff,
9510                max_splat_steps,
9511                transmittance_threshold,
9512                max_list_entries,
9513            } => unsafe {
9514                crate::splat::execute_gaussian_splat_prepare(
9515                    *positions_off,
9516                    *positions_len,
9517                    *scales_off,
9518                    *scales_len,
9519                    *rotations_off,
9520                    *rotations_len,
9521                    *opacities_off,
9522                    *opacities_len,
9523                    *colors_off,
9524                    *colors_len,
9525                    *sh_coeffs_off,
9526                    *sh_coeffs_len,
9527                    *meta_off,
9528                    *meta_len,
9529                    *prep_off,
9530                    *prep_len,
9531                    *width,
9532                    *height,
9533                    *tile_size,
9534                    *radius_scale,
9535                    *alpha_cutoff,
9536                    *max_splat_steps,
9537                    *transmittance_threshold,
9538                    *max_list_entries,
9539                    base,
9540                );
9541            },
9542
9543            Thunk::GaussianSplatRasterize {
9544                prep_off,
9545                prep_len,
9546                meta_off,
9547                meta_len,
9548                dst_off,
9549                dst_len,
9550                count,
9551                width,
9552                height,
9553                tile_size,
9554                alpha_cutoff,
9555                max_splat_steps,
9556                transmittance_threshold,
9557                max_list_entries,
9558            } => unsafe {
9559                crate::splat::execute_gaussian_splat_rasterize(
9560                    *prep_off,
9561                    *prep_len,
9562                    *meta_off,
9563                    *meta_len,
9564                    *dst_off,
9565                    *dst_len,
9566                    *count,
9567                    *width,
9568                    *height,
9569                    *tile_size,
9570                    *alpha_cutoff,
9571                    *max_splat_steps,
9572                    *transmittance_threshold,
9573                    *max_list_entries,
9574                    base,
9575                );
9576            },
9577
9578            Thunk::Fft1d {
9579                src,
9580                dst,
9581                outer,
9582                n_complex,
9583                inverse,
9584                norm_tag,
9585                dtype,
9586            } => unsafe {
9587                match dtype {
9588                    rlx_ir::DType::F64 => execute_fft1d_f64(
9589                        *src,
9590                        *dst,
9591                        *outer as usize,
9592                        *n_complex as usize,
9593                        *inverse,
9594                        *norm_tag,
9595                        base,
9596                    ),
9597                    rlx_ir::DType::F32 => execute_fft1d_f32(
9598                        *src,
9599                        *dst,
9600                        *outer as usize,
9601                        *n_complex as usize,
9602                        *inverse,
9603                        *norm_tag,
9604                        base,
9605                    ),
9606                    rlx_ir::DType::C64 => execute_fft1d_c64(
9607                        *src,
9608                        *dst,
9609                        *outer as usize,
9610                        *n_complex as usize,
9611                        *inverse,
9612                        *norm_tag,
9613                        base,
9614                    ),
9615                    other => panic!("Op::Fft on CPU requires F32/F64/C64, got {other:?}"),
9616                }
9617            },
9618
9619            Thunk::FftButterflyStage {
9620                state_src,
9621                state_dst,
9622                gate_src,
9623                rev_src,
9624                tw_re_src,
9625                tw_im_src,
9626                batch,
9627                n_fft,
9628                stage,
9629            } => unsafe {
9630                execute_fft_butterfly_stage_f32(
9631                    *state_src,
9632                    *state_dst,
9633                    *gate_src,
9634                    *rev_src,
9635                    *tw_re_src,
9636                    *tw_im_src,
9637                    *batch as usize,
9638                    *n_fft as usize,
9639                    *stage as usize,
9640                    base,
9641                );
9642            },
9643
9644            Thunk::LogMel {
9645                spec,
9646                filters,
9647                dst,
9648                outer,
9649                n_fft,
9650                n_bins,
9651                n_mels,
9652            } => unsafe {
9653                execute_log_mel_f32(
9654                    *spec,
9655                    *filters,
9656                    *dst,
9657                    *outer as usize,
9658                    *n_fft as usize,
9659                    *n_bins as usize,
9660                    *n_mels as usize,
9661                    base,
9662                );
9663            },
9664
9665            Thunk::LogMelBackward {
9666                spec,
9667                filters,
9668                dy,
9669                dst,
9670                outer,
9671                n_fft,
9672                n_bins,
9673                n_mels,
9674            } => unsafe {
9675                execute_log_mel_backward_f32(
9676                    *spec,
9677                    *filters,
9678                    *dy,
9679                    *dst,
9680                    *outer as usize,
9681                    *n_fft as usize,
9682                    *n_bins as usize,
9683                    *n_mels as usize,
9684                    base,
9685                );
9686            },
9687
9688            Thunk::WelchPeaks {
9689                spec,
9690                dst,
9691                welch_batch,
9692                n_fft,
9693                n_segments,
9694                k,
9695            } => unsafe {
9696                execute_welch_peaks_f32(
9697                    *spec,
9698                    *dst,
9699                    *welch_batch as usize,
9700                    *n_fft as usize,
9701                    *n_segments as usize,
9702                    *k as usize,
9703                    base,
9704                );
9705            },
9706
9707            // CustomFn dispatch (interpreted path). Mirrors the
9708            // pre-compiled-closure variant elsewhere in this file.
9709            // Patched by rlx-eda.
9710            Thunk::CustomFn {
9711                body,
9712                body_init,
9713                inputs,
9714                body_output_off,
9715                outer_output_off,
9716                out_bytes,
9717            } => {
9718                let mut body_buf: Vec<u8> = (**body_init).clone();
9719                unsafe {
9720                    for (body_in_off, outer_in_off, n_bytes) in inputs.iter() {
9721                        let src = (base as *const u8).add(*outer_in_off);
9722                        let dst = body_buf.as_mut_ptr().add(*body_in_off);
9723                        std::ptr::copy_nonoverlapping(src, dst, *n_bytes as usize);
9724                    }
9725                }
9726                execute_thunks(body, &mut body_buf);
9727                unsafe {
9728                    let src = body_buf.as_ptr().add(*body_output_off);
9729                    let dst = base.add(*outer_output_off);
9730                    std::ptr::copy_nonoverlapping(src, dst, *out_bytes as usize);
9731                }
9732            }
9733
9734            Thunk::Sgemm { a, b, c, m, k, n } => {
9735                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
9736                if trace_thunks {
9737                    eprintln!("[sgemm] m={m} k={k} n={n} a={} b={} c={}", *a, *b, *c);
9738                }
9739                let c_len = m.saturating_mul(n);
9740                let a_len = m.saturating_mul(k);
9741                let b_len = k.saturating_mul(n);
9742                let arena_len = arena_buf.len();
9743                let max_a = (arena_len.saturating_sub(*a)) / 4;
9744                let max_b = (arena_len.saturating_sub(*b)) / 4;
9745                let max_c = (arena_len.saturating_sub(*c)) / 4;
9746                let a_len = a_len.min(max_a);
9747                let b_len = b_len.min(max_b);
9748                let c_len = c_len.min(max_c);
9749                unsafe {
9750                    let a_sl = sl(*a, base, a_len);
9751                    let b_sl = sl(*b, base, b_len);
9752                    let c_sl = sl_mut(*c, base, c_len);
9753                    if std::ptr::eq(a_sl.as_ptr(), c_sl.as_ptr())
9754                        || std::ptr::eq(b_sl.as_ptr(), c_sl.as_ptr())
9755                    {
9756                        let mut tmp = vec![0.0f32; c_len];
9757                        crate::blas::sgemm_auto(a_sl, b_sl, &mut tmp, m, k, n);
9758                        c_sl.copy_from_slice(&tmp);
9759                    } else {
9760                        crate::blas::sgemm_auto(a_sl, b_sl, c_sl, m, k, n);
9761                    }
9762                }
9763            }
9764
9765            Thunk::SgemmT {
9766                a,
9767                b,
9768                c,
9769                m,
9770                k,
9771                n,
9772                ta,
9773                tb,
9774            } => {
9775                // C[m,n] = op(A) @ op(B). RowMajor cblas: lda/ldb = stored
9776                // row-length of each operand → m if A is transposed else k;
9777                // k if B is transposed else n. Element counts are m*k / k*n
9778                // regardless of layout.
9779                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
9780                let lda = if *ta { m } else { k };
9781                let ldb = if *tb { k } else { n };
9782                let arena_len = arena_buf.len();
9783                let a_len = (m * k).min((arena_len.saturating_sub(*a)) / 4);
9784                let b_len = (k * n).min((arena_len.saturating_sub(*b)) / 4);
9785                let c_len = (m * n).min((arena_len.saturating_sub(*c)) / 4);
9786                unsafe {
9787                    let a_sl = sl(*a, base, a_len);
9788                    let b_sl = sl(*b, base, b_len);
9789                    let c_sl = sl_mut(*c, base, c_len);
9790                    let (ap, bp) = (a_sl.as_ptr(), b_sl.as_ptr());
9791                    if std::ptr::eq(ap, c_sl.as_ptr()) || std::ptr::eq(bp, c_sl.as_ptr()) {
9792                        let mut tmp = vec![0.0f32; c_len];
9793                        crate::blas::sgemm_general(
9794                            ap,
9795                            bp,
9796                            tmp.as_mut_ptr(),
9797                            m,
9798                            n,
9799                            k,
9800                            1.0,
9801                            0.0,
9802                            lda,
9803                            ldb,
9804                            n,
9805                            *ta,
9806                            *tb,
9807                        );
9808                        c_sl.copy_from_slice(&tmp);
9809                    } else {
9810                        crate::blas::sgemm_general(
9811                            ap,
9812                            bp,
9813                            c_sl.as_mut_ptr(),
9814                            m,
9815                            n,
9816                            k,
9817                            1.0,
9818                            0.0,
9819                            lda,
9820                            ldb,
9821                            n,
9822                            *ta,
9823                            *tb,
9824                        );
9825                    }
9826                }
9827            }
9828
9829            Thunk::SgdMomentum {
9830                param,
9831                vel,
9832                grad,
9833                p_out,
9834                v_out,
9835                lr,
9836                mom,
9837                len,
9838            } => {
9839                // v' = mom·v + g ;  p' = p − lr·v'  — one fused pass per param,
9840                // replacing the 4 BinaryFull ops the in-graph SGD chain lowers
9841                // to. Element-wise, so in-place aliasing (p_out==param etc.) is
9842                // safe: read index i, write index i.
9843                let len = *len as usize;
9844                let (lr, mom) = (*lr, *mom);
9845                unsafe {
9846                    let p = sl(*param, base, len);
9847                    let v = sl(*vel, base, len);
9848                    let g = sl(*grad, base, len);
9849                    let po = sl_mut(*p_out, base, len);
9850                    let vo = sl_mut(*v_out, base, len);
9851                    if fast_conv_enabled() && crate::pool::should_parallelize(len) {
9852                        let poa = po.as_mut_ptr() as usize;
9853                        let voa = vo.as_mut_ptr() as usize;
9854                        crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
9855                            for i in off..off + cnt {
9856                                let vn = mom * v[i] + g[i];
9857                                *((voa as *mut f32).add(i)) = vn;
9858                                *((poa as *mut f32).add(i)) = p[i] - lr * vn;
9859                            }
9860                        });
9861                    } else {
9862                        for i in 0..len {
9863                            let vn = mom * v[i] + g[i];
9864                            vo[i] = vn;
9865                            po[i] = p[i] - lr * vn;
9866                        }
9867                    }
9868                }
9869            }
9870
9871            Thunk::CgemmC64 { a, b, c, m, k, n } => unsafe {
9872                cgemm_c64(*a, *b, *c, *m as usize, *k as usize, *n as usize, base);
9873            },
9874
9875            Thunk::DenseSolveF64 { a, b, x, n, nrhs } => {
9876                let (n_, nrhs_) = (*n as usize, *nrhs as usize);
9877                // LAPACK overwrites both A and B; clone into scratch
9878                // each call. Caller's A and b must be preserved for
9879                // VJP recompute. (Eventually: swap to a factor-once /
9880                // solve-many scheme; that's the symbolic-reuse story
9881                // and lives with the sparse path.)
9882                unsafe {
9883                    let a_src = sl_f64(*a, base, n_ * n_);
9884                    let b_src = sl_f64(*b, base, n_ * nrhs_);
9885                    let mut a_scratch: Vec<f64> = a_src.to_vec();
9886                    let mut x_buf: Vec<f64> = b_src.to_vec();
9887                    let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
9888                    if info != 0 {
9889                        panic!(
9890                            "DenseSolveF64: dgesv reported singular matrix \
9891                                (info={info}, n={n_}, nrhs={nrhs_})"
9892                        );
9893                    }
9894                    let dst = sl_mut_f64(*x, base, n_ * nrhs_);
9895                    dst.copy_from_slice(&x_buf);
9896                }
9897            }
9898
9899            Thunk::DenseSolveF32 { a, b, x, n, nrhs } => {
9900                let (n_, nrhs_) = (*n as usize, *nrhs as usize);
9901                unsafe {
9902                    let a_src = sl(*a, base, n_ * n_);
9903                    let b_src = sl(*b, base, n_ * nrhs_);
9904                    let mut a_scratch: Vec<f32> = a_src.to_vec();
9905                    let mut x_buf: Vec<f32> = b_src.to_vec();
9906                    let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
9907                    if info != 0 {
9908                        panic!(
9909                            "DenseSolveF32: sgesv reported singular matrix \
9910                             (info={info}, n={n_}, nrhs={nrhs_})"
9911                        );
9912                    }
9913                    let dst = sl_mut(*x, base, n_ * nrhs_);
9914                    dst.copy_from_slice(&x_buf);
9915                }
9916            }
9917
9918            Thunk::BatchedDenseSolveF64 {
9919                a,
9920                b,
9921                x,
9922                batch,
9923                n,
9924                nrhs,
9925            } => {
9926                // Per slice: extract A_i and b_i, dgesv, write x_i.
9927                // LAPACK has no batched dgesv on Accelerate, so this
9928                // is a serial loop over the batch axis. cuSOLVER /
9929                // hipSOLVER expose `getrfBatched` / `getrsBatched` for
9930                // the GPU path — we'll wire that in rlx-cuda when
9931                // someone needs Linux+CUDA.
9932                let (b_, n_, nrhs_) = (*batch as usize, *n as usize, *nrhs as usize);
9933                let a_stride = n_ * n_;
9934                let b_stride = n_ * nrhs_;
9935                unsafe {
9936                    let a_full = sl_f64(*a, base, b_ * a_stride);
9937                    let b_full = sl_f64(*b, base, b_ * b_stride);
9938                    let x_full = sl_mut_f64(*x, base, b_ * b_stride);
9939                    for bi in 0..b_ {
9940                        let mut a_scratch: Vec<f64> =
9941                            a_full[bi * a_stride..(bi + 1) * a_stride].to_vec();
9942                        let mut x_buf: Vec<f64> =
9943                            b_full[bi * b_stride..(bi + 1) * b_stride].to_vec();
9944                        let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
9945                        if info != 0 {
9946                            panic!(
9947                                "BatchedDenseSolveF64: slice {bi} \
9948                                    singular (info={info}, n={n_}, nrhs={nrhs_})"
9949                            );
9950                        }
9951                        x_full[bi * b_stride..(bi + 1) * b_stride].copy_from_slice(&x_buf);
9952                    }
9953                }
9954            }
9955
9956            Thunk::BatchedDenseSolveF32 {
9957                a,
9958                b,
9959                x,
9960                batch,
9961                n,
9962                nrhs,
9963            } => {
9964                let (b_, n_, nrhs_) = (*batch as usize, *n as usize, *nrhs as usize);
9965                let a_stride = n_ * n_;
9966                let b_stride = n_ * nrhs_;
9967                unsafe {
9968                    let a_full = sl(*a, base, b_ * a_stride);
9969                    let b_full = sl(*b, base, b_ * b_stride);
9970                    let x_full = sl_mut(*x, base, b_ * b_stride);
9971                    for bi in 0..b_ {
9972                        let mut a_scratch = a_full[bi * a_stride..(bi + 1) * a_stride].to_vec();
9973                        let mut x_buf = b_full[bi * b_stride..(bi + 1) * b_stride].to_vec();
9974                        let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
9975                        if info != 0 {
9976                            panic!("BatchedDenseSolveF32: slice {bi} singular (info={info})");
9977                        }
9978                        x_full[bi * b_stride..(bi + 1) * b_stride].copy_from_slice(&x_buf);
9979                    }
9980                }
9981            }
9982
9983            Thunk::BatchedDgemmF64 {
9984                a,
9985                b,
9986                c,
9987                batch,
9988                m,
9989                k,
9990                n,
9991            } => {
9992                let (b_, m_, k_, n_) = (*batch as usize, *m as usize, *k as usize, *n as usize);
9993                let a_stride = m_ * k_;
9994                let b_stride = k_ * n_;
9995                let c_stride = m_ * n_;
9996                unsafe {
9997                    let a_full = sl_f64(*a, base, b_ * a_stride);
9998                    let b_full = sl_f64(*b, base, b_ * b_stride);
9999                    let c_full = sl_mut_f64(*c, base, b_ * c_stride);
10000                    for bi in 0..b_ {
10001                        let a_slice = &a_full[bi * a_stride..(bi + 1) * a_stride];
10002                        let b_slice = &b_full[bi * b_stride..(bi + 1) * b_stride];
10003                        let c_slice = &mut c_full[bi * c_stride..(bi + 1) * c_stride];
10004                        crate::blas::dgemm(a_slice, b_slice, c_slice, m_, k_, n_);
10005                    }
10006                }
10007            }
10008
10009            Thunk::BatchedSgemm {
10010                a,
10011                b,
10012                c,
10013                batch,
10014                m,
10015                k,
10016                n,
10017            } => {
10018                let (b_, m_, k_, n_) = (*batch as usize, *m as usize, *k as usize, *n as usize);
10019                if trace_thunks {
10020                    eprintln!(
10021                        "[batched-sgemm] batch={b_} m={m_} k={k_} n={n_} a={} b={} c={}",
10022                        *a, *b, *c
10023                    );
10024                }
10025                let a_stride = m_.saturating_mul(k_);
10026                let b_stride = k_.saturating_mul(n_);
10027                let c_stride = m_.saturating_mul(n_);
10028                let arena_len = arena_buf.len();
10029                let a_cap = (arena_len.saturating_sub(*a)) / 4;
10030                let b_cap = (arena_len.saturating_sub(*b)) / 4;
10031                let c_cap = (arena_len.saturating_sub(*c)) / 4;
10032                let a_elems = (b_ * a_stride).min(a_cap);
10033                let b_elems = (b_ * b_stride).min(b_cap);
10034                let c_elems = (b_ * c_stride).min(c_cap);
10035                let b_eff = b_
10036                    .min(a_elems.checked_div(a_stride).unwrap_or(0))
10037                    .min(b_elems.checked_div(b_stride).unwrap_or(0))
10038                    .min(c_elems.checked_div(c_stride).unwrap_or(0));
10039                unsafe {
10040                    let a_full = sl(*a, base, a_elems);
10041                    let b_full = sl(*b, base, b_elems);
10042                    let c_full = sl_mut(*c, base, c_elems);
10043                    for bi in 0..b_eff {
10044                        let a0 = bi * a_stride;
10045                        let b0 = bi * b_stride;
10046                        let c0 = bi * c_stride;
10047                        if a0 + a_stride > a_full.len()
10048                            || b0 + b_stride > b_full.len()
10049                            || c0 + c_stride > c_full.len()
10050                        {
10051                            break;
10052                        }
10053                        let a_slice = &a_full[a0..a0 + a_stride];
10054                        let b_slice = &b_full[b0..b0 + b_stride];
10055                        let c_slice = &mut c_full[c0..c0 + c_stride];
10056                        if std::ptr::eq(a_slice.as_ptr(), c_slice.as_mut_ptr())
10057                            || std::ptr::eq(b_slice.as_ptr(), c_slice.as_mut_ptr())
10058                        {
10059                            let mut tmp = vec![0.0f32; c_stride];
10060                            crate::blas::sgemm_auto(a_slice, b_slice, &mut tmp, m_, k_, n_);
10061                            c_slice.copy_from_slice(&tmp);
10062                        } else {
10063                            crate::blas::sgemm_auto(a_slice, b_slice, c_slice, m_, k_, n_);
10064                        }
10065                    }
10066                }
10067            }
10068
10069            Thunk::Dgemm { a, b, c, m, k, n } => {
10070                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
10071                unsafe {
10072                    crate::blas::dgemm(
10073                        sl_f64(*a, base, m * k),
10074                        sl_f64(*b, base, k * n),
10075                        sl_mut_f64(*c, base, m * n),
10076                        m,
10077                        k,
10078                        n,
10079                    );
10080                }
10081            }
10082
10083            Thunk::TransposeF64 {
10084                src,
10085                dst,
10086                in_total,
10087                out_dims,
10088                in_strides,
10089            } => unsafe {
10090                let inp = sl_f64(*src, base, *in_total as usize);
10091                let out_total: usize = out_dims.iter().map(|d| *d as usize).product();
10092                let out = sl_mut_f64(*dst, base, out_total);
10093                transpose_walk_f64(inp, out, out_dims, in_strides);
10094            },
10095
10096            Thunk::ActivationF64 {
10097                src,
10098                dst,
10099                len,
10100                kind,
10101            } => {
10102                let len = *len as usize;
10103                unsafe {
10104                    let inp = sl_f64(*src, base, len);
10105                    let out = sl_mut_f64(*dst, base, len);
10106                    apply_activation_f64(inp, out, *kind);
10107                }
10108            }
10109
10110            Thunk::ReduceSumF64 {
10111                src,
10112                dst,
10113                outer,
10114                reduced,
10115                inner,
10116            } => {
10117                let (o, r, n) = (*outer as usize, *reduced as usize, *inner as usize);
10118                unsafe {
10119                    let inp = sl_f64(*src, base, o * r * n);
10120                    let out = sl_mut_f64(*dst, base, o * n);
10121                    reduce_sum_f64(inp, out, o, r, n);
10122                }
10123            }
10124
10125            Thunk::CopyF64 { src, dst, len } => {
10126                let mut len = *len as usize;
10127                if *src == *dst || len == 0 {
10128                    continue;
10129                }
10130                let arena_len = arena_buf.len();
10131                let max_from_src = (arena_len.saturating_sub(*src)) / 8;
10132                let max_from_dst = (arena_len.saturating_sub(*dst)) / 8;
10133                len = len.min(max_from_src).min(max_from_dst);
10134                if len == 0 {
10135                    continue;
10136                }
10137                let byte_len = len.saturating_mul(8);
10138                unsafe {
10139                    std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
10140                }
10141            }
10142
10143            Thunk::CopyI64 { src, dst, len } => {
10144                let mut len = *len as usize;
10145                if *src == *dst || len == 0 {
10146                    continue;
10147                }
10148                let arena_len = arena_buf.len();
10149                let max_from_src = (arena_len.saturating_sub(*src)) / 8;
10150                let max_from_dst = (arena_len.saturating_sub(*dst)) / 8;
10151                len = len.min(max_from_src).min(max_from_dst);
10152                if len == 0 {
10153                    continue;
10154                }
10155                let byte_len = len.saturating_mul(8);
10156                unsafe {
10157                    std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
10158                }
10159            }
10160
10161            Thunk::CastF32ToI64 { src, dst, len } => {
10162                let len = *len as usize;
10163                if len == 0 {
10164                    continue;
10165                }
10166                unsafe {
10167                    let inp = sl(*src, base, len);
10168                    let out = sl_mut_i64(*dst, base, len);
10169                    for i in 0..len {
10170                        out[i] = inp[i].round() as i64;
10171                    }
10172                }
10173            }
10174
10175            Thunk::CastF32ToF64 { src, dst, len } => {
10176                let len = *len as usize;
10177                if len == 0 {
10178                    continue;
10179                }
10180                unsafe {
10181                    let inp = sl(*src, base, len);
10182                    let out = sl_mut_f64(*dst, base, len);
10183                    for i in 0..len {
10184                        out[i] = inp[i] as f64;
10185                    }
10186                }
10187            }
10188
10189            Thunk::CastF32ToI32 { src, dst, len } => {
10190                let len = *len as usize;
10191                if len == 0 {
10192                    continue;
10193                }
10194                unsafe {
10195                    let inp = sl(*src, base, len);
10196                    let out = sl_mut_i32(*dst, base, len);
10197                    for i in 0..len {
10198                        out[i] = inp[i].round() as i32;
10199                    }
10200                }
10201            }
10202
10203            Thunk::CastI64ToF32 { src, dst, len } => {
10204                let len = *len as usize;
10205                if len == 0 {
10206                    continue;
10207                }
10208                unsafe {
10209                    let inp = sl_i64(*src, base, len);
10210                    let out = sl_mut(*dst, base, len);
10211                    for i in 0..len {
10212                        out[i] = inp[i] as f32;
10213                    }
10214                }
10215            }
10216
10217            Thunk::CastBoolToI32 { src, dst, len } => {
10218                let len = *len as usize;
10219                if len == 0 {
10220                    continue;
10221                }
10222                unsafe {
10223                    let inp = &arena_buf[*src..*src + len];
10224                    let out = sl_mut_i32(*dst, base, len);
10225                    for i in 0..len {
10226                        out[i] = i32::from(inp[i] != 0);
10227                    }
10228                }
10229            }
10230
10231            Thunk::CastI32ToF32 { src, dst, len } => {
10232                let len = *len as usize;
10233                if len == 0 {
10234                    continue;
10235                }
10236                unsafe {
10237                    let inp = sl_i32(*src, base, len);
10238                    let out = sl_mut(*dst, base, len);
10239                    for i in 0..len {
10240                        out[i] = inp[i] as f32;
10241                    }
10242                }
10243            }
10244
10245            Thunk::CastBoolToF32 { src, dst, len } => {
10246                let len = *len as usize;
10247                if len == 0 {
10248                    continue;
10249                }
10250                unsafe {
10251                    let inp = &arena_buf[*src..*src + len];
10252                    let out = sl_mut(*dst, base, len);
10253                    for i in 0..len {
10254                        out[i] = if inp[i] != 0 { 1.0 } else { 0.0 };
10255                    }
10256                }
10257            }
10258
10259            Thunk::BinaryFullF64 {
10260                lhs,
10261                rhs,
10262                dst,
10263                len,
10264                lhs_len,
10265                rhs_len,
10266                op,
10267                out_dims_bcast,
10268                bcast_lhs_strides,
10269                bcast_rhs_strides,
10270            } => {
10271                let len = *len as usize;
10272                let lhs_len = *lhs_len as usize;
10273                let rhs_len = *rhs_len as usize;
10274                unsafe {
10275                    let l = sl_f64(*lhs, base, lhs_len);
10276                    let r = sl_f64(*rhs, base, rhs_len);
10277                    let d = sl_mut_f64(*dst, base, len);
10278                    if lhs_len == len && rhs_len == len {
10279                        for i in 0..len {
10280                            d[i] = binary_op_f64(*op, l[i], r[i]);
10281                        }
10282                    } else if !out_dims_bcast.is_empty() {
10283                        // Shape-aware broadcast path: correct for
10284                        // arbitrary NumPy-style broadcasts including
10285                        // bidirectional `[N,1] op [1,S]`.
10286                        let rank = out_dims_bcast.len();
10287                        let mut coords = vec![0u32; rank];
10288                        for i in 0..len {
10289                            let mut rem = i;
10290                            for ax in (0..rank).rev() {
10291                                let sz = out_dims_bcast[ax] as usize;
10292                                coords[ax] = (rem % sz) as u32;
10293                                rem /= sz;
10294                            }
10295                            let mut li: usize = 0;
10296                            let mut ri: usize = 0;
10297                            for ax in 0..rank {
10298                                li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
10299                                ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
10300                            }
10301                            d[i] = binary_op_f64(*op, l[li], r[ri]);
10302                        }
10303                    } else {
10304                        // Fallback: legacy modulo path (preserved for
10305                        // dynamic-shape graphs where strides can't be
10306                        // precomputed). Only correct for scalar /
10307                        // last-axis broadcast.
10308                        for i in 0..len {
10309                            d[i] = binary_op_f64(*op, l[i % lhs_len], r[i % rhs_len]);
10310                        }
10311                    }
10312                }
10313            }
10314
10315            Thunk::BinaryFullC64 {
10316                lhs,
10317                rhs,
10318                dst,
10319                len,
10320                lhs_len,
10321                rhs_len,
10322                op,
10323                out_dims_bcast,
10324                bcast_lhs_strides,
10325                bcast_rhs_strides,
10326            } => {
10327                // Complex element layout: [re_0, im_0, re_1, im_1, ...]
10328                // Underlying f32 buffer length is 2·N (N = complex
10329                // element count). All offsets are byte offsets; the
10330                // `sl` helper reads as f32 starting at the byte
10331                // offset, so f32-length = 2·complex-len.
10332                let n_out = *len as usize;
10333                let n_l = *lhs_len as usize;
10334                let n_r = *rhs_len as usize;
10335                unsafe {
10336                    let l = sl(*lhs, base, 2 * n_l);
10337                    let r = sl(*rhs, base, 2 * n_r);
10338                    let d = sl_mut(*dst, base, 2 * n_out);
10339                    let do_c64 = |a_re: f32, a_im: f32, b_re: f32, b_im: f32| -> (f32, f32) {
10340                        match op {
10341                            BinaryOp::Add => (a_re + b_re, a_im + b_im),
10342                            BinaryOp::Sub => (a_re - b_re, a_im - b_im),
10343                            BinaryOp::Mul => (a_re * b_re - a_im * b_im, a_re * b_im + a_im * b_re),
10344                            BinaryOp::Div => {
10345                                let denom = b_re * b_re + b_im * b_im;
10346                                (
10347                                    (a_re * b_re + a_im * b_im) / denom,
10348                                    (a_im * b_re - a_re * b_im) / denom,
10349                                )
10350                            }
10351                            BinaryOp::Max | BinaryOp::Min | BinaryOp::Pow => {
10352                                unreachable!("C64 max/min/pow rejected at lowering")
10353                            }
10354                        }
10355                    };
10356                    if n_l == n_out && n_r == n_out {
10357                        for i in 0..n_out {
10358                            let (re, im) = do_c64(l[2 * i], l[2 * i + 1], r[2 * i], r[2 * i + 1]);
10359                            d[2 * i] = re;
10360                            d[2 * i + 1] = im;
10361                        }
10362                    } else if !out_dims_bcast.is_empty() {
10363                        // Strided complex broadcast: strides are in
10364                        // *complex element* units; multiply by 2 when
10365                        // indexing into the f32 buffer.
10366                        let rank = out_dims_bcast.len();
10367                        let mut coords = vec![0u32; rank];
10368                        for i in 0..n_out {
10369                            let mut rem = i;
10370                            for ax in (0..rank).rev() {
10371                                let sz = out_dims_bcast[ax] as usize;
10372                                coords[ax] = (rem % sz) as u32;
10373                                rem /= sz;
10374                            }
10375                            let mut li: usize = 0;
10376                            let mut ri: usize = 0;
10377                            for ax in 0..rank {
10378                                li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
10379                                ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
10380                            }
10381                            let (re, im) =
10382                                do_c64(l[2 * li], l[2 * li + 1], r[2 * ri], r[2 * ri + 1]);
10383                            d[2 * i] = re;
10384                            d[2 * i + 1] = im;
10385                        }
10386                    } else {
10387                        // Modulo fallback (scalar / last-axis broadcast).
10388                        for i in 0..n_out {
10389                            let li = if n_l == 1 { 0 } else { i % n_l };
10390                            let ri = if n_r == 1 { 0 } else { i % n_r };
10391                            let (re, im) =
10392                                do_c64(l[2 * li], l[2 * li + 1], r[2 * ri], r[2 * ri + 1]);
10393                            d[2 * i] = re;
10394                            d[2 * i + 1] = im;
10395                        }
10396                    }
10397                }
10398            }
10399
10400            Thunk::ComplexNormSqF32 { src, dst, len } => {
10401                let n = *len as usize;
10402                unsafe {
10403                    let s = sl(*src, base, 2 * n);
10404                    let d = sl_mut(*dst, base, n);
10405                    for i in 0..n {
10406                        let re = s[2 * i];
10407                        let im = s[2 * i + 1];
10408                        d[i] = re * re + im * im;
10409                    }
10410                }
10411            }
10412
10413            Thunk::ComplexNormSqBackwardF32 { z, g, dz, len } => {
10414                // Wirtinger: dz = g · z, element-wise complex
10415                // (g is real, z is complex).
10416                let n = *len as usize;
10417                unsafe {
10418                    let zb = sl(*z, base, 2 * n);
10419                    let gb = sl(*g, base, n);
10420                    let db = sl_mut(*dz, base, 2 * n);
10421                    for i in 0..n {
10422                        let re = zb[2 * i];
10423                        let im = zb[2 * i + 1];
10424                        let gv = gb[i];
10425                        db[2 * i] = gv * re;
10426                        db[2 * i + 1] = gv * im;
10427                    }
10428                }
10429            }
10430
10431            Thunk::ConjugateC64 { src, dst, len } => {
10432                let n = *len as usize;
10433                unsafe {
10434                    let s = sl(*src, base, 2 * n);
10435                    let d = sl_mut(*dst, base, 2 * n);
10436                    for i in 0..n {
10437                        d[2 * i] = s[2 * i];
10438                        d[2 * i + 1] = -s[2 * i + 1];
10439                    }
10440                }
10441            }
10442
10443            Thunk::ActivationC64 {
10444                src,
10445                dst,
10446                len,
10447                kind,
10448            } => {
10449                let n = *len as usize;
10450                unsafe {
10451                    let s = sl(*src, base, 2 * n);
10452                    let d = sl_mut(*dst, base, 2 * n);
10453                    for i in 0..n {
10454                        let a = s[2 * i];
10455                        let b = s[2 * i + 1];
10456                        let (re, im) = match kind {
10457                            Activation::Neg => (-a, -b),
10458                            Activation::Exp => {
10459                                // exp(a + bi) = e^a · (cos b + i·sin b)
10460                                let ea = a.exp();
10461                                (ea * b.cos(), ea * b.sin())
10462                            }
10463                            Activation::Log => {
10464                                // log(z) = log|z| + i·arg(z), principal branch
10465                                let r = (a * a + b * b).sqrt();
10466                                (r.ln(), b.atan2(a))
10467                            }
10468                            Activation::Sqrt => {
10469                                // sqrt(a+bi) = sqrt((|z|+a)/2) + sign(b)·i·sqrt((|z|-a)/2)
10470                                // Principal branch; for b == 0 and a < 0 returns +i·sqrt(|a|).
10471                                let r = (a * a + b * b).sqrt();
10472                                let re = ((r + a) * 0.5).max(0.0).sqrt();
10473                                let im_mag = ((r - a) * 0.5).max(0.0).sqrt();
10474                                let im = if b >= 0.0 { im_mag } else { -im_mag };
10475                                (re, im)
10476                            }
10477                            _ => unreachable!("non-C64 activation kind survived lowering"),
10478                        };
10479                        d[2 * i] = re;
10480                        d[2 * i + 1] = im;
10481                    }
10482                }
10483            }
10484
10485            Thunk::Scan {
10486                body,
10487                body_init,
10488                body_input_off,
10489                body_output_off,
10490                outer_init_off,
10491                outer_final_off,
10492                length,
10493                carry_bytes,
10494                save_trajectory,
10495                xs_inputs,
10496                bcast_inputs,
10497                num_checkpoints,
10498            } => {
10499                let cb = *carry_bytes as usize;
10500                let n_steps = *length as usize;
10501                // Checkpoint mode: when 0 < K < length, save trajectory[k]
10502                // only when t == c_k = floor((k+1) * length / K) - 1.
10503                // The last index c_{K-1} = length - 1 always.
10504                let k_total = if *num_checkpoints == 0 || *num_checkpoints == *length {
10505                    n_steps // save every step
10506                } else {
10507                    *num_checkpoints as usize
10508                };
10509                let checkpoint_t_for_k = |k: usize| -> usize {
10510                    if k_total == n_steps {
10511                        k
10512                    } else {
10513                        ((k + 1) * n_steps)
10514                            .div_ceil(k_total)
10515                            .saturating_sub(1)
10516                            .min(n_steps - 1)
10517                    }
10518                };
10519                let mut next_k = 0usize;
10520
10521                let mut body_buf: Vec<u8> = (**body_init).clone();
10522                unsafe {
10523                    std::ptr::copy_nonoverlapping(
10524                        base.add(*outer_init_off),
10525                        body_buf.as_mut_ptr().add(*body_input_off),
10526                        cb,
10527                    );
10528                    // Broadcast inputs: copy each one into the body's
10529                    // input slot ONCE. They aren't touched in the
10530                    // iteration loop below (in contrast to xs).
10531                    for (body_b_off, outer_b_off, total_bytes) in bcast_inputs.iter() {
10532                        std::ptr::copy_nonoverlapping(
10533                            base.add(*outer_b_off),
10534                            body_buf.as_mut_ptr().add(*body_b_off),
10535                            *total_bytes as usize,
10536                        );
10537                    }
10538                }
10539                for t in 0..n_steps {
10540                    for (body_x_off, outer_xs_off, per_step_bytes) in xs_inputs.iter() {
10541                        let psb = *per_step_bytes as usize;
10542                        unsafe {
10543                            std::ptr::copy_nonoverlapping(
10544                                base.add(*outer_xs_off + t * psb),
10545                                body_buf.as_mut_ptr().add(*body_x_off),
10546                                psb,
10547                            );
10548                        }
10549                    }
10550
10551                    execute_thunks(body, &mut body_buf);
10552
10553                    if *save_trajectory && next_k < k_total && t == checkpoint_t_for_k(next_k) {
10554                        unsafe {
10555                            std::ptr::copy_nonoverlapping(
10556                                body_buf.as_ptr().add(*body_output_off),
10557                                base.add(*outer_final_off + next_k * cb),
10558                                cb,
10559                            );
10560                        }
10561                        next_k += 1;
10562                    }
10563
10564                    if *body_output_off != *body_input_off {
10565                        body_buf
10566                            .copy_within(*body_output_off..*body_output_off + cb, *body_input_off);
10567                    }
10568                }
10569
10570                if !*save_trajectory {
10571                    // Single final-carry write.
10572                    unsafe {
10573                        std::ptr::copy_nonoverlapping(
10574                            body_buf.as_ptr().add(*body_output_off),
10575                            base.add(*outer_final_off),
10576                            cb,
10577                        );
10578                    }
10579                }
10580            }
10581
10582            Thunk::ScanBackward {
10583                body_vjp,
10584                body_init,
10585                body_carry_in_off,
10586                body_x_offs,
10587                body_d_output_off,
10588                body_dcarry_out_off,
10589                outer_init_off,
10590                outer_traj_off,
10591                outer_upstream_off,
10592                outer_xs_offs,
10593                outer_dinit_off,
10594                length,
10595                carry_bytes,
10596                save_trajectory,
10597                num_checkpoints,
10598                forward_body,
10599                forward_body_init,
10600                forward_body_carry_in_off,
10601                forward_body_output_off,
10602                forward_body_x_offs,
10603                carry_elem_size,
10604            } => {
10605                // Two backward paths share the same per-iteration body
10606                // (body_vjp run + dcarry threading). The "All" path
10607                // reads the carry directly from the saved trajectory
10608                // each step. The "Recursive checkpointing" path stores
10609                // only K saved checkpoints and reconstructs intermediate
10610                // carries via Griewank-style recursive subdivision —
10611                // see [`griewank_process_segment`]. Auxiliary memory
10612                // is `O(log(segment_size) · carry_bytes)` for the
10613                // recursion stack, vs the old segment-cache scheme's
10614                // `O(segment_size · carry_bytes)`. Total recompute work
10615                // grows from `O(length)` to `O(length · log)`, which
10616                // is the canonical Griewank trade.
10617                let cb = *carry_bytes as usize;
10618                let n_steps = *length as usize;
10619                let k_total = *num_checkpoints as usize;
10620                let is_recursive = k_total != 0 && k_total != n_steps;
10621                let checkpoint_t_for_k = |k: usize| -> usize {
10622                    ((k + 1) * n_steps)
10623                        .div_ceil(k_total)
10624                        .saturating_sub(1)
10625                        .min(n_steps - 1)
10626                };
10627
10628                let mut fwd_buf: Vec<u8> = if is_recursive {
10629                    (**forward_body_init.as_ref().unwrap()).clone()
10630                } else {
10631                    Vec::new()
10632                };
10633
10634                let mut dcarry: Vec<u8> = vec![0u8; cb];
10635                if !*save_trajectory {
10636                    unsafe {
10637                        std::ptr::copy_nonoverlapping(
10638                            base.add(*outer_upstream_off),
10639                            dcarry.as_mut_ptr(),
10640                            cb,
10641                        );
10642                    }
10643                }
10644
10645                let mut body_buf: Vec<u8> = (**body_init).clone();
10646
10647                // Per-iteration backward action — shared between the
10648                // direct-trajectory (All) and Griewank (Recursive) paths.
10649                // Both feed the same body_vjp run with carry-at-t,
10650                // x_t_i, and d_output, then thread dcarry backward.
10651                let process_iter =
10652                    |t: usize, carry_in: &[u8], dcarry: &mut Vec<u8>, body_buf: &mut Vec<u8>| {
10653                        if *save_trajectory {
10654                            unsafe {
10655                                let up_off = *outer_upstream_off + t * cb;
10656                                match *carry_elem_size {
10657                                    4 => {
10658                                        let up_ptr = base.add(up_off) as *const f32;
10659                                        let dc_ptr = dcarry.as_mut_ptr() as *mut f32;
10660                                        let n_elems = cb / 4;
10661                                        for i in 0..n_elems {
10662                                            *dc_ptr.add(i) += *up_ptr.add(i);
10663                                        }
10664                                    }
10665                                    8 => {
10666                                        let up_ptr = base.add(up_off) as *const f64;
10667                                        let dc_ptr = dcarry.as_mut_ptr() as *mut f64;
10668                                        let n_elems = cb / 8;
10669                                        for i in 0..n_elems {
10670                                            *dc_ptr.add(i) += *up_ptr.add(i);
10671                                        }
10672                                    }
10673                                    other => panic!(
10674                                        "ScanBackward: unsupported carry elem size {other} \
10675                                     (only f32/f64 carries are supported today)"
10676                                    ),
10677                                }
10678                            }
10679                        }
10680                        body_buf[*body_carry_in_off..*body_carry_in_off + cb]
10681                            .copy_from_slice(carry_in);
10682                        unsafe {
10683                            for (i, body_x_off) in body_x_offs.iter().enumerate() {
10684                                let (outer_xs_off, per_step_bytes) = outer_xs_offs[i];
10685                                let psb = per_step_bytes as usize;
10686                                std::ptr::copy_nonoverlapping(
10687                                    base.add(outer_xs_off + t * psb),
10688                                    body_buf.as_mut_ptr().add(*body_x_off),
10689                                    psb,
10690                                );
10691                            }
10692                            std::ptr::copy_nonoverlapping(
10693                                dcarry.as_ptr(),
10694                                body_buf.as_mut_ptr().add(*body_d_output_off),
10695                                cb,
10696                            );
10697                        }
10698                        execute_thunks(body_vjp, body_buf);
10699                        unsafe {
10700                            std::ptr::copy_nonoverlapping(
10701                                body_buf.as_ptr().add(*body_dcarry_out_off),
10702                                dcarry.as_mut_ptr(),
10703                                cb,
10704                            );
10705                        }
10706                    };
10707
10708                if is_recursive {
10709                    // Griewank treeverse path. Process saved-checkpoint
10710                    // segments from highest-t to lowest-t; within each,
10711                    // recursive binary subdivision via
10712                    // `griewank_process_segment`. Auxiliary memory:
10713                    // O(log(seg_size) · cb) for the recursion stack
10714                    // (vs O(seg_size · cb) for the older segment-cache
10715                    // scheme); recompute work: O(seg_size · log).
10716                    let leaf_threshold = 4usize;
10717                    let fb_sched = forward_body.as_ref().unwrap();
10718                    let fb_init = forward_body_init.as_ref().unwrap().as_slice();
10719                    let mut segment_end = n_steps - 1;
10720                    for seg_k in (0..k_total).rev() {
10721                        let segment_start = if seg_k == 0 {
10722                            0
10723                        } else {
10724                            checkpoint_t_for_k(seg_k - 1) + 1
10725                        };
10726                        let mut anchor: Vec<u8> = vec![0u8; cb];
10727                        unsafe {
10728                            let src = if seg_k == 0 {
10729                                base.add(*outer_init_off)
10730                            } else {
10731                                base.add(*outer_traj_off + (seg_k - 1) * cb)
10732                            };
10733                            std::ptr::copy_nonoverlapping(src, anchor.as_mut_ptr(), cb);
10734                        }
10735                        // Closure adapter for the helper's signature
10736                        // (mutably re-borrows dcarry / body_buf each call).
10737                        let mut leaf_action = |t: usize, carry_in: &[u8]| {
10738                            process_iter(t, carry_in, &mut dcarry, &mut body_buf);
10739                        };
10740                        unsafe {
10741                            griewank_process_segment(
10742                                segment_start,
10743                                segment_end,
10744                                &anchor,
10745                                cb,
10746                                fb_sched,
10747                                fb_init,
10748                                *forward_body_carry_in_off,
10749                                *forward_body_output_off,
10750                                forward_body_x_offs,
10751                                base,
10752                                outer_xs_offs,
10753                                &mut fwd_buf,
10754                                leaf_threshold,
10755                                &mut leaf_action,
10756                            );
10757                        }
10758                        if seg_k == 0 {
10759                            break;
10760                        }
10761                        segment_end = segment_start - 1;
10762                    }
10763                } else {
10764                    // All-trajectory path: read each carry directly
10765                    // from the saved trajectory buffer.
10766                    let mut carry_buf: Vec<u8> = vec![0u8; cb];
10767                    for t in (0..n_steps).rev() {
10768                        unsafe {
10769                            let src = if t == 0 {
10770                                base.add(*outer_init_off)
10771                            } else {
10772                                base.add(*outer_traj_off + (t - 1) * cb)
10773                            };
10774                            std::ptr::copy_nonoverlapping(src, carry_buf.as_mut_ptr(), cb);
10775                        }
10776                        process_iter(t, &carry_buf, &mut dcarry, &mut body_buf);
10777                    }
10778                }
10779
10780                unsafe {
10781                    std::ptr::copy_nonoverlapping(dcarry.as_ptr(), base.add(*outer_dinit_off), cb);
10782                }
10783            }
10784
10785            Thunk::ScanBackwardXs {
10786                body_vjp,
10787                body_init,
10788                body_carry_in_off,
10789                body_x_offs,
10790                body_d_output_off,
10791                body_dcarry_out_off,
10792                body_dxs_out_off,
10793                outer_init_off,
10794                outer_traj_off,
10795                outer_upstream_off,
10796                outer_xs_offs,
10797                outer_dxs_off,
10798                length,
10799                carry_bytes,
10800                carry_elem_size,
10801                per_step_bytes,
10802                save_trajectory,
10803                num_checkpoints,
10804                forward_body,
10805                forward_body_init,
10806                forward_body_carry_in_off,
10807                forward_body_output_off,
10808                forward_body_x_offs,
10809            } => {
10810                let cb = *carry_bytes as usize;
10811                let psb = *per_step_bytes as usize;
10812                let n_steps = *length as usize;
10813                let k_total = *num_checkpoints as usize;
10814                let is_recursive = k_total != 0 && k_total != n_steps;
10815                let checkpoint_t_for_k = |k: usize| -> usize {
10816                    ((k + 1) * n_steps)
10817                        .div_ceil(k_total)
10818                        .saturating_sub(1)
10819                        .min(n_steps - 1)
10820                };
10821
10822                // Forward-body recompute scratch + segment cache —
10823                // exact mirror of the ScanBackward path. With ≈√length
10824                // checkpoints, total recompute work is O(length).
10825                let mut fwd_buf: Vec<u8> = if is_recursive {
10826                    (**forward_body_init.as_ref().unwrap()).clone()
10827                } else {
10828                    Vec::new()
10829                };
10830                let mut seg_cache: Vec<u8> = Vec::new();
10831                let mut seg_start_t: usize = usize::MAX;
10832                let mut seg_count: usize = 0;
10833                let recompute_carry_t =
10834                    |t: usize,
10835                     dst: &mut [u8],
10836                     fwd_buf: &mut Vec<u8>,
10837                     seg_cache: &mut Vec<u8>,
10838                     seg_start_t: &mut usize,
10839                     seg_count: &mut usize| {
10840                        if !is_recursive {
10841                            unsafe {
10842                                let src = if t == 0 {
10843                                    base.add(*outer_init_off)
10844                                } else {
10845                                    base.add(*outer_traj_off + (t - 1) * cb)
10846                                };
10847                                std::ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), cb);
10848                            }
10849                            return;
10850                        }
10851                        if *seg_start_t != usize::MAX
10852                            && t >= *seg_start_t
10853                            && t < *seg_start_t + *seg_count
10854                        {
10855                            let off = (t - *seg_start_t) * cb;
10856                            dst.copy_from_slice(&seg_cache[off..off + cb]);
10857                            return;
10858                        }
10859                        let seg_k = (0..k_total)
10860                            .find(|&k| t <= checkpoint_t_for_k(k))
10861                            .unwrap_or(k_total - 1);
10862                        let (anchor_t, anchor_ptr): (usize, *const u8) = if seg_k == 0 {
10863                            (0, unsafe { base.add(*outer_init_off) as *const u8 })
10864                        } else {
10865                            let prev_ck = checkpoint_t_for_k(seg_k - 1);
10866                            (prev_ck + 1, unsafe {
10867                                base.add(*outer_traj_off + (seg_k - 1) * cb) as *const u8
10868                            })
10869                        };
10870                        let seg_end_t = checkpoint_t_for_k(seg_k);
10871                        let seg_size = seg_end_t - anchor_t + 1;
10872
10873                        fwd_buf.copy_from_slice(forward_body_init.as_ref().unwrap());
10874                        unsafe {
10875                            std::ptr::copy_nonoverlapping(
10876                                anchor_ptr,
10877                                fwd_buf.as_mut_ptr().add(*forward_body_carry_in_off),
10878                                cb,
10879                            );
10880                        }
10881                        seg_cache.resize(seg_size * cb, 0u8);
10882                        seg_cache[0..cb].copy_from_slice(
10883                            &fwd_buf[*forward_body_carry_in_off..*forward_body_carry_in_off + cb],
10884                        );
10885                        let fb_sched = forward_body.as_ref().unwrap();
10886                        for i in 1..seg_size {
10887                            let cur_iter = anchor_t + i - 1;
10888                            for (idx, fb_x_off) in forward_body_x_offs.iter().enumerate() {
10889                                let (outer_xs_off, x_psb) = outer_xs_offs[idx];
10890                                let xb = x_psb as usize;
10891                                unsafe {
10892                                    std::ptr::copy_nonoverlapping(
10893                                        base.add(outer_xs_off + cur_iter * xb),
10894                                        fwd_buf.as_mut_ptr().add(*fb_x_off),
10895                                        xb,
10896                                    );
10897                                }
10898                            }
10899                            execute_thunks(fb_sched, fwd_buf);
10900                            if *forward_body_output_off != *forward_body_carry_in_off {
10901                                fwd_buf.copy_within(
10902                                    *forward_body_output_off..*forward_body_output_off + cb,
10903                                    *forward_body_carry_in_off,
10904                                );
10905                            }
10906                            let cache_off = i * cb;
10907                            seg_cache[cache_off..cache_off + cb].copy_from_slice(
10908                                &fwd_buf
10909                                    [*forward_body_carry_in_off..*forward_body_carry_in_off + cb],
10910                            );
10911                        }
10912                        *seg_start_t = anchor_t;
10913                        *seg_count = seg_size;
10914
10915                        let off = (t - anchor_t) * cb;
10916                        dst.copy_from_slice(&seg_cache[off..off + cb]);
10917                    };
10918
10919                let mut dcarry: Vec<u8> = vec![0u8; cb];
10920                if !*save_trajectory {
10921                    unsafe {
10922                        std::ptr::copy_nonoverlapping(
10923                            base.add(*outer_upstream_off),
10924                            dcarry.as_mut_ptr(),
10925                            cb,
10926                        );
10927                    }
10928                }
10929
10930                let mut body_buf: Vec<u8> = (**body_init).clone();
10931
10932                for t in (0..n_steps).rev() {
10933                    if *save_trajectory {
10934                        unsafe {
10935                            let up_off = *outer_upstream_off + t * cb;
10936                            match *carry_elem_size {
10937                                4 => {
10938                                    let up_ptr = base.add(up_off) as *const f32;
10939                                    let dc_ptr = dcarry.as_mut_ptr() as *mut f32;
10940                                    let n_elems = cb / 4;
10941                                    for i in 0..n_elems {
10942                                        *dc_ptr.add(i) += *up_ptr.add(i);
10943                                    }
10944                                }
10945                                8 => {
10946                                    let up_ptr = base.add(up_off) as *const f64;
10947                                    let dc_ptr = dcarry.as_mut_ptr() as *mut f64;
10948                                    let n_elems = cb / 8;
10949                                    for i in 0..n_elems {
10950                                        *dc_ptr.add(i) += *up_ptr.add(i);
10951                                    }
10952                                }
10953                                other => panic!(
10954                                    "ScanBackwardXs: unsupported carry elem size {other} \
10955                                     (only f32/f64 carries are supported today)"
10956                                ),
10957                            }
10958                        }
10959                    }
10960
10961                    // Seed body_vjp's carry input via the recompute
10962                    // helper (works for both All and Recursive modes),
10963                    // then x_t_i + d_output.
10964                    let carry_dst_start = *body_carry_in_off;
10965                    {
10966                        let carry_slice = &mut body_buf[carry_dst_start..carry_dst_start + cb];
10967                        recompute_carry_t(
10968                            t,
10969                            carry_slice,
10970                            &mut fwd_buf,
10971                            &mut seg_cache,
10972                            &mut seg_start_t,
10973                            &mut seg_count,
10974                        );
10975                    }
10976                    unsafe {
10977                        for (i, body_x_off) in body_x_offs.iter().enumerate() {
10978                            let (outer_xs_off, x_psb) = outer_xs_offs[i];
10979                            let xb = x_psb as usize;
10980                            std::ptr::copy_nonoverlapping(
10981                                base.add(outer_xs_off + t * xb),
10982                                body_buf.as_mut_ptr().add(*body_x_off),
10983                                xb,
10984                            );
10985                        }
10986                        std::ptr::copy_nonoverlapping(
10987                            dcarry.as_ptr(),
10988                            body_buf.as_mut_ptr().add(*body_d_output_off),
10989                            cb,
10990                        );
10991                    }
10992
10993                    execute_thunks(body_vjp, &mut body_buf);
10994
10995                    // Stash this step's dxs into row `t` of the outer
10996                    // [length, *per_step_xs] output.
10997                    unsafe {
10998                        std::ptr::copy_nonoverlapping(
10999                            body_buf.as_ptr().add(*body_dxs_out_off),
11000                            base.add(*outer_dxs_off + t * psb),
11001                            psb,
11002                        );
11003                    }
11004
11005                    // Update dcarry for next backward iteration.
11006                    unsafe {
11007                        std::ptr::copy_nonoverlapping(
11008                            body_buf.as_ptr().add(*body_dcarry_out_off),
11009                            dcarry.as_mut_ptr(),
11010                            cb,
11011                        );
11012                    }
11013                }
11014            }
11015
11016            Thunk::FusedMmBiasAct {
11017                a,
11018                w,
11019                bias,
11020                c,
11021                m,
11022                k,
11023                n,
11024                act,
11025            } => {
11026                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
11027                unsafe {
11028                    let out = sl_mut(*c, base, m * n);
11029                    crate::blas::sgemm_auto(sl(*a, base, m * k), sl(*w, base, k * n), out, m, k, n);
11030                    match act {
11031                        Some(Activation::Gelu) => {
11032                            crate::kernels::par_bias_gelu(out, sl(*bias, base, n), m, n)
11033                        }
11034                        Some(other) => {
11035                            crate::blas::bias_add(out, sl(*bias, base, n), m, n);
11036                            apply_activation_inplace(out, *other);
11037                        }
11038                        None => crate::blas::bias_add(out, sl(*bias, base, n), m, n),
11039                    }
11040                }
11041            }
11042
11043            Thunk::FusedResidualLN {
11044                x,
11045                res,
11046                bias,
11047                g,
11048                b,
11049                out,
11050                rows,
11051                h,
11052                eps,
11053                has_bias,
11054            } => {
11055                let (rows, h) = (*rows as usize, *h as usize);
11056                unsafe {
11057                    let zero = &zero_bias[..h];
11058                    let bi = if *has_bias { sl(*bias, base, h) } else { zero };
11059                    let x_ptr = sl(*x, base, rows * h).as_ptr() as usize;
11060                    let r_ptr = sl(*res, base, rows * h).as_ptr() as usize;
11061                    let o_ptr = sl_mut(*out, base, rows * h).as_mut_ptr() as usize;
11062                    let bi_ptr = bi.as_ptr() as usize;
11063                    let g_ptr = sl(*g, base, h).as_ptr() as usize;
11064                    let b_ptr = sl(*b, base, h).as_ptr() as usize;
11065                    let e = *eps;
11066                    crate::pool::par_for(rows, 4, &|off, cnt| {
11067                        let xs =
11068                            std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
11069                        let rs =
11070                            std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
11071                        let os = std::slice::from_raw_parts_mut(
11072                            (o_ptr as *mut f32).add(off * h),
11073                            cnt * h,
11074                        );
11075                        let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
11076                        let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
11077                        let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
11078                        crate::kernels::residual_bias_layer_norm(xs, rs, bi, g, b, os, cnt, h, e);
11079                    });
11080                }
11081            }
11082
11083            Thunk::FusedResidualRmsNorm {
11084                x,
11085                res,
11086                bias,
11087                g,
11088                b,
11089                out,
11090                rows,
11091                h,
11092                eps,
11093                has_bias,
11094            } => {
11095                let (rows, h) = (*rows as usize, *h as usize);
11096                unsafe {
11097                    let zero = &zero_bias[..h];
11098                    let bi = if *has_bias { sl(*bias, base, h) } else { zero };
11099                    let x_ptr = sl(*x, base, rows * h).as_ptr() as usize;
11100                    let r_ptr = sl(*res, base, rows * h).as_ptr() as usize;
11101                    let o_ptr = sl_mut(*out, base, rows * h).as_mut_ptr() as usize;
11102                    let bi_ptr = bi.as_ptr() as usize;
11103                    let g_ptr = sl(*g, base, h).as_ptr() as usize;
11104                    let b_ptr = sl(*b, base, h).as_ptr() as usize;
11105                    let e = *eps;
11106                    crate::pool::par_for(rows, 4, &|off, cnt| {
11107                        let xs =
11108                            std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
11109                        let rs =
11110                            std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
11111                        let os = std::slice::from_raw_parts_mut(
11112                            (o_ptr as *mut f32).add(off * h),
11113                            cnt * h,
11114                        );
11115                        let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
11116                        let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
11117                        let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
11118                        crate::kernels::residual_bias_rms_norm(xs, rs, bi, g, b, os, cnt, h, e);
11119                    });
11120                }
11121            }
11122
11123            Thunk::BiasAdd {
11124                src,
11125                bias,
11126                dst,
11127                m,
11128                n,
11129            } => {
11130                let (m, n) = (*m as usize, *n as usize);
11131                let len = m * n;
11132                unsafe {
11133                    let out = sl_mut(*dst, base, len);
11134                    if *src != *dst {
11135                        let src_ptr = base.add(*src) as *const f32;
11136                        let dst_ptr = base.add(*dst) as *mut f32;
11137                        if src_ptr != dst_ptr {
11138                            std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
11139                        }
11140                    }
11141                    crate::blas::bias_add(out, sl(*bias, base, n), m, n);
11142                }
11143            }
11144
11145            Thunk::BinaryFull {
11146                lhs,
11147                rhs,
11148                dst,
11149                len,
11150                lhs_len,
11151                rhs_len,
11152                op,
11153                out_dims_bcast,
11154                bcast_lhs_strides,
11155                bcast_rhs_strides,
11156                elem_bytes,
11157            } => {
11158                let len = *len as usize;
11159                let ll = (*lhs_len as usize).max(1);
11160                let rl = (*rhs_len as usize).max(1);
11161                let eb = (*elem_bytes).max(1) as usize;
11162                let arena_len = arena_buf.len();
11163                let ll = ll.min((arena_len.saturating_sub(*lhs)) / eb);
11164                let rl = rl.min((arena_len.saturating_sub(*rhs)) / eb);
11165                let len = len.min((arena_len.saturating_sub(*dst)) / eb);
11166                unsafe {
11167                    if eb == 8 {
11168                        let l = sl_i64(*lhs, base, ll);
11169                        let r = sl_i64(*rhs, base, rl);
11170                        let o = sl_mut_i64(*dst, base, len);
11171                        if !out_dims_bcast.is_empty() {
11172                            let rank = out_dims_bcast.len();
11173                            let mut coords = vec![0u32; rank];
11174                            for i in 0..len {
11175                                let mut rem = i;
11176                                for ax in (0..rank).rev() {
11177                                    let sz = out_dims_bcast[ax] as usize;
11178                                    coords[ax] = (rem % sz) as u32;
11179                                    rem /= sz;
11180                                }
11181                                let mut li = 0usize;
11182                                let mut ri = 0usize;
11183                                for ax in 0..rank {
11184                                    li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
11185                                    ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
11186                                }
11187                                o[i] = match op {
11188                                    BinaryOp::Add => l[li].wrapping_add(r[ri]),
11189                                    BinaryOp::Sub => l[li].wrapping_sub(r[ri]),
11190                                    BinaryOp::Mul => l[li].wrapping_mul(r[ri]),
11191                                    BinaryOp::Div => {
11192                                        if r[ri] == 0 {
11193                                            0
11194                                        } else {
11195                                            l[li] / r[ri]
11196                                        }
11197                                    }
11198                                    BinaryOp::Max => l[li].max(r[ri]),
11199                                    BinaryOp::Min => l[li].min(r[ri]),
11200                                    BinaryOp::Pow => l[li].pow(r[ri].max(0) as u32),
11201                                };
11202                            }
11203                        } else {
11204                            for i in 0..len {
11205                                let li = if ll == 1 { 0 } else { i % ll };
11206                                let ri = if rl == 1 { 0 } else { i % rl };
11207                                o[i] = match op {
11208                                    BinaryOp::Add => l[li].wrapping_add(r[ri]),
11209                                    BinaryOp::Sub => l[li].wrapping_sub(r[ri]),
11210                                    BinaryOp::Mul => l[li].wrapping_mul(r[ri]),
11211                                    BinaryOp::Div => {
11212                                        if r[ri] == 0 {
11213                                            0
11214                                        } else {
11215                                            l[li] / r[ri]
11216                                        }
11217                                    }
11218                                    BinaryOp::Max => l[li].max(r[ri]),
11219                                    BinaryOp::Min => l[li].min(r[ri]),
11220                                    BinaryOp::Pow => l[li].pow(r[ri].max(0) as u32),
11221                                };
11222                            }
11223                        }
11224                    } else {
11225                        let l = sl(*lhs, base, ll);
11226                        let r = sl(*rhs, base, rl);
11227                        let o = sl_mut(*dst, base, len);
11228                        if ll == len && rl == len {
11229                            #[cfg(target_arch = "aarch64")]
11230                            if matches!(op, BinaryOp::Add | BinaryOp::Mul) {
11231                                use std::arch::aarch64::*;
11232                                let chunks = len / 4;
11233                                for c in 0..chunks {
11234                                    let off = c * 4;
11235                                    let vl = vld1q_f32(l.as_ptr().add(off));
11236                                    let vr = vld1q_f32(r.as_ptr().add(off));
11237                                    let res = match op {
11238                                        BinaryOp::Add => vaddq_f32(vl, vr),
11239                                        BinaryOp::Mul => vmulq_f32(vl, vr),
11240                                        _ => unreachable!(),
11241                                    };
11242                                    vst1q_f32(o.as_mut_ptr().add(off), res);
11243                                }
11244                                for i in (chunks * 4)..len {
11245                                    o[i] = match op {
11246                                        BinaryOp::Add => l[i] + r[i],
11247                                        BinaryOp::Mul => l[i] * r[i],
11248                                        _ => unreachable!(),
11249                                    };
11250                                }
11251                                continue;
11252                            }
11253                        }
11254                        if !out_dims_bcast.is_empty() {
11255                            let rank = out_dims_bcast.len();
11256                            let mut coords = vec![0u32; rank];
11257                            for i in 0..len {
11258                                let mut rem = i;
11259                                for ax in (0..rank).rev() {
11260                                    let sz = out_dims_bcast[ax] as usize;
11261                                    coords[ax] = (rem % sz) as u32;
11262                                    rem /= sz;
11263                                }
11264                                let mut li = 0usize;
11265                                let mut ri = 0usize;
11266                                for ax in 0..rank {
11267                                    li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
11268                                    ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
11269                                }
11270                                o[i] = match op {
11271                                    BinaryOp::Add => l[li] + r[ri],
11272                                    BinaryOp::Sub => l[li] - r[ri],
11273                                    BinaryOp::Mul => l[li] * r[ri],
11274                                    BinaryOp::Div => l[li] / r[ri],
11275                                    BinaryOp::Max => l[li].max(r[ri]),
11276                                    BinaryOp::Min => l[li].min(r[ri]),
11277                                    BinaryOp::Pow => l[li].powf(r[ri]),
11278                                };
11279                            }
11280                        } else {
11281                            for i in 0..len {
11282                                let li = if ll == 1 { 0 } else { i % ll };
11283                                let ri = if rl == 1 { 0 } else { i % rl };
11284                                o[i] = match op {
11285                                    BinaryOp::Add => l[li] + r[ri],
11286                                    BinaryOp::Sub => l[li] - r[ri],
11287                                    BinaryOp::Mul => l[li] * r[ri],
11288                                    BinaryOp::Div => l[li] / r[ri],
11289                                    BinaryOp::Max => l[li].max(r[ri]),
11290                                    BinaryOp::Min => l[li].min(r[ri]),
11291                                    BinaryOp::Pow => l[li].powf(r[ri]),
11292                                };
11293                            }
11294                        }
11295                    }
11296                }
11297            }
11298
11299            Thunk::Gather {
11300                table,
11301                table_len,
11302                idx,
11303                dst,
11304                num_idx,
11305                trailing,
11306                idx_i64,
11307                table_bytes,
11308            } => {
11309                let (ni, tr) = (*num_idx as usize, *trailing as usize);
11310                let rows = *table_len as usize / tr.max(1);
11311                unsafe {
11312                    if *table_bytes == 8 {
11313                        let tab = sl_i64(*table, base, *table_len as usize);
11314                        let out = sl_mut_i64(*dst, base, ni * tr);
11315                        if *idx_i64 != 0 {
11316                            let ids = sl_i64(*idx, base, ni);
11317                            for i in 0..ni {
11318                                let row = ids[i].max(0) as usize;
11319                                if row < rows {
11320                                    out[i * tr..(i + 1) * tr]
11321                                        .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
11322                                }
11323                            }
11324                        } else {
11325                            let ids = sl(*idx, base, ni);
11326                            for i in 0..ni {
11327                                let row = ids[i] as usize;
11328                                if row < rows {
11329                                    out[i * tr..(i + 1) * tr]
11330                                        .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
11331                                }
11332                            }
11333                        }
11334                    } else {
11335                        let tab = sl(*table, base, *table_len as usize);
11336                        let out = sl_mut(*dst, base, ni * tr);
11337                        if *idx_i64 != 0 {
11338                            let ids = sl_i64(*idx, base, ni);
11339                            for i in 0..ni {
11340                                let row = ids[i].max(0) as usize;
11341                                if row < rows {
11342                                    out[i * tr..(i + 1) * tr]
11343                                        .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
11344                                }
11345                            }
11346                        } else {
11347                            let ids = sl(*idx, base, ni);
11348                            for i in 0..ni {
11349                                let row = ids[i] as usize;
11350                                if row < rows {
11351                                    out[i * tr..(i + 1) * tr]
11352                                        .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
11353                                }
11354                            }
11355                        }
11356                    }
11357                }
11358            }
11359
11360            Thunk::Narrow {
11361                src,
11362                dst,
11363                outer,
11364                src_stride,
11365                dst_stride,
11366                inner,
11367                elem_bytes,
11368            } => {
11369                let (outer, ss, ds, inner, eb) = (
11370                    *outer as usize,
11371                    *src_stride as usize,
11372                    *dst_stride as usize,
11373                    *inner as usize,
11374                    *elem_bytes as usize,
11375                );
11376                let row_bytes = inner.saturating_mul(eb);
11377                let src_row_stride = ss.saturating_mul(eb);
11378                let dst_row_stride = ds.saturating_mul(eb);
11379                if trace_thunks {
11380                    eprintln!(
11381                        "[narrow] src={} dst={} outer={outer} ss={ss} ds={ds} inner={inner} eb={eb} row={row_bytes} arena={}",
11382                        *src,
11383                        *dst,
11384                        arena_buf.len()
11385                    );
11386                }
11387                if row_bytes > 0 && *src != *dst {
11388                    let arena_len = arena_buf.len();
11389                    for o in 0..outer {
11390                        let s_off = *src + o * src_row_stride;
11391                        let d_off = *dst + o * dst_row_stride;
11392                        if s_off == d_off {
11393                            continue;
11394                        }
11395                        if s_off.saturating_add(row_bytes) > arena_len
11396                            || d_off.saturating_add(row_bytes) > arena_len
11397                        {
11398                            break;
11399                        }
11400                        unsafe {
11401                            std::ptr::copy_nonoverlapping(
11402                                base.add(s_off),
11403                                base.add(d_off),
11404                                row_bytes,
11405                            );
11406                        }
11407                    }
11408                }
11409            }
11410
11411            Thunk::Copy { src, dst, len } => {
11412                let mut len = *len as usize;
11413                if *src == *dst || len == 0 {
11414                    continue;
11415                }
11416                let arena_len = arena_buf.len();
11417                let max_from_src = (arena_len.saturating_sub(*src)) / 4;
11418                let max_from_dst = (arena_len.saturating_sub(*dst)) / 4;
11419                len = len.min(max_from_src).min(max_from_dst);
11420                if len == 0 {
11421                    continue;
11422                }
11423                let byte_len = len.saturating_mul(4);
11424                unsafe {
11425                    std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
11426                }
11427            }
11428
11429            Thunk::LayerNorm {
11430                src,
11431                g,
11432                b,
11433                dst,
11434                rows,
11435                h,
11436                eps,
11437            } => {
11438                let (rows, h) = (*rows as usize, *h as usize);
11439                unsafe {
11440                    let input = sl(*src, base, rows * h);
11441                    let gamma = sl(*g, base, h);
11442                    let beta = sl(*b, base, h);
11443                    let output = sl_mut(*dst, base, rows * h);
11444                    // Parallelize across rows (same pattern as FusedResidualLN)
11445                    if rows >= 4 && rows * h >= 30_000 {
11446                        let i_ptr = input.as_ptr() as usize;
11447                        let o_ptr = output.as_mut_ptr() as usize;
11448                        let g_ptr = gamma.as_ptr() as usize;
11449                        let b_ptr = beta.as_ptr() as usize;
11450                        let e = *eps;
11451                        crate::pool::par_for(rows, 4, &|off, cnt| {
11452                            let inp = std::slice::from_raw_parts(
11453                                (i_ptr as *const f32).add(off * h),
11454                                cnt * h,
11455                            );
11456                            let out = std::slice::from_raw_parts_mut(
11457                                (o_ptr as *mut f32).add(off * h),
11458                                cnt * h,
11459                            );
11460                            let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
11461                            let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
11462                            for row in 0..cnt {
11463                                crate::kernels::layer_norm_row(
11464                                    &inp[row * h..(row + 1) * h],
11465                                    g,
11466                                    b,
11467                                    &mut out[row * h..(row + 1) * h],
11468                                    h,
11469                                    e,
11470                                );
11471                            }
11472                        });
11473                    } else {
11474                        for row in 0..rows {
11475                            crate::kernels::layer_norm_row(
11476                                &input[row * h..(row + 1) * h],
11477                                gamma,
11478                                beta,
11479                                &mut output[row * h..(row + 1) * h],
11480                                h,
11481                                *eps,
11482                            );
11483                        }
11484                    }
11485                }
11486            }
11487
11488            Thunk::GroupNorm {
11489                src,
11490                g,
11491                b,
11492                dst,
11493                n,
11494                c,
11495                h,
11496                w,
11497                num_groups,
11498                eps,
11499            } => {
11500                let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
11501                let plane = c * h * w;
11502                unsafe {
11503                    // Per-batch plane stride is `plane` *f32 elements*; `base`
11504                    // is a byte pointer, so advance by `plane * size_of::<f32>()`.
11505                    // (Was `base.add(ni * plane)` — a 4× under-advance that read
11506                    // batch row 1+ from the wrong offset; only n=1 was tested.)
11507                    let stride = plane * std::mem::size_of::<f32>();
11508                    for ni in 0..n {
11509                        let input = sl(*src, base.add(ni * stride), plane);
11510                        let gamma = sl(*g, base, c);
11511                        let beta = sl(*b, base, c);
11512                        let output = sl_mut(*dst, base.add(ni * stride), plane);
11513                        crate::kernels::group_norm_nchw(
11514                            input,
11515                            gamma,
11516                            beta,
11517                            output,
11518                            1,
11519                            c,
11520                            h,
11521                            w,
11522                            *num_groups as usize,
11523                            *eps,
11524                        );
11525                    }
11526                }
11527            }
11528
11529            Thunk::BatchNormInference {
11530                src,
11531                g,
11532                b,
11533                mean,
11534                var,
11535                dst,
11536                count,
11537                channels,
11538                eps,
11539            } => {
11540                let count = *count as usize;
11541                let c = *channels as usize;
11542                let n = count * c;
11543                unsafe {
11544                    crate::kernels::batch_norm_inference(
11545                        sl(*src, base, n),
11546                        sl(*g, base, c),
11547                        sl(*b, base, c),
11548                        sl(*mean, base, c),
11549                        sl(*var, base, c),
11550                        sl_mut(*dst, base, n),
11551                        c,
11552                        *eps,
11553                    );
11554                }
11555            }
11556
11557            Thunk::LayerNorm2d {
11558                src,
11559                g,
11560                b,
11561                dst,
11562                n,
11563                c,
11564                h,
11565                w,
11566                eps,
11567            } => {
11568                let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
11569                let plane = c * h * w;
11570                unsafe {
11571                    let input = sl(*src, base, n * plane);
11572                    let gamma = sl(*g, base, c);
11573                    let beta = sl(*b, base, c);
11574                    let output = sl_mut(*dst, base, n * plane);
11575                    crate::kernels::layer_norm2d_nchw(input, gamma, beta, output, n, c, h, w, *eps);
11576                }
11577            }
11578
11579            Thunk::ConvTranspose2d {
11580                src,
11581                weight,
11582                dst,
11583                n,
11584                c_in,
11585                h,
11586                w_in,
11587                c_out,
11588                h_out,
11589                w_out,
11590                kh,
11591                kw,
11592                sh,
11593                sw,
11594                ph,
11595                pw,
11596                dh,
11597                dw,
11598                groups,
11599            } => {
11600                let n = *n as usize;
11601                let c_in = *c_in as usize;
11602                let h = *h as usize;
11603                let w_in = *w_in as usize;
11604                let c_out = *c_out as usize;
11605                let h_out = *h_out as usize;
11606                let w_out = *w_out as usize;
11607                unsafe {
11608                    let inp = sl(*src, base, n * c_in * h * w_in);
11609                    let wt = sl(
11610                        *weight,
11611                        base,
11612                        c_in * (c_out / *groups as usize) * (*kh as usize) * (*kw as usize),
11613                    );
11614                    let out = sl_mut(*dst, base, n * c_out * h_out * w_out);
11615                    crate::kernels::conv_transpose2d_nchw(
11616                        inp,
11617                        wt,
11618                        out,
11619                        n,
11620                        c_in,
11621                        h,
11622                        w_in,
11623                        c_out,
11624                        h_out,
11625                        w_out,
11626                        *kh as usize,
11627                        *kw as usize,
11628                        *sh as usize,
11629                        *sw as usize,
11630                        *ph as usize,
11631                        *pw as usize,
11632                        *dh as usize,
11633                        *dw as usize,
11634                        *groups as usize,
11635                    );
11636                }
11637            }
11638
11639            Thunk::ResizeNearest2x {
11640                src,
11641                dst,
11642                n,
11643                c,
11644                h,
11645                w,
11646            } => {
11647                let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
11648                let in_plane = c * h * w;
11649                let out_plane = c * h * 2 * w * 2;
11650                // `base` is a byte pointer; batch planes are f32 elements, so
11651                // advance by `plane * size_of::<f32>()`. (Was `base.add(ni *
11652                // plane)` — a 4× under-advance; only n=1 was ever tested.)
11653                let fsz = std::mem::size_of::<f32>();
11654                unsafe {
11655                    for ni in 0..n {
11656                        let input = sl(*src, base.add(ni * in_plane * fsz), in_plane);
11657                        let output = sl_mut(*dst, base.add(ni * out_plane * fsz), out_plane);
11658                        crate::kernels::resize_nearest_2x_nchw(input, output, c, h, w);
11659                    }
11660                }
11661            }
11662
11663            Thunk::AxialRope2d {
11664                src,
11665                dst,
11666                batch,
11667                seq,
11668                hidden,
11669                end_x,
11670                end_y,
11671                head_dim,
11672                num_heads,
11673                theta,
11674                repeat_factor,
11675            } => {
11676                let b = *batch as usize;
11677                let s = *seq as usize;
11678                let hdim = *head_dim as usize;
11679                let nh = *num_heads as usize;
11680                let plane = s * (*hidden as usize);
11681                // `base` is a byte pointer; advance per-batch by element-stride
11682                // bytes. (Was `base.add(bi * plane)` — 4× under-advance for
11683                // batch>1; matches the corrected `execute_axial_rope2d_f32`.)
11684                let plane_bytes = plane * std::mem::size_of::<f32>();
11685                unsafe {
11686                    for bi in 0..b {
11687                        let input = sl(*src, base.add(bi * plane_bytes), plane);
11688                        let output = sl_mut(*dst, base.add(bi * plane_bytes), plane);
11689                        let rotated = rlx_ir::ops::axial_rope2d::apply_axial_rope2d(
11690                            input,
11691                            nh,
11692                            s,
11693                            hdim,
11694                            *end_x as usize,
11695                            *end_y as usize,
11696                            *theta,
11697                            *repeat_factor as usize,
11698                        );
11699                        output.copy_from_slice(&rotated);
11700                    }
11701                }
11702            }
11703
11704            Thunk::RmsNorm {
11705                src,
11706                g,
11707                b,
11708                dst,
11709                rows,
11710                h,
11711                eps,
11712            } => {
11713                let (rows, h) = (*rows as usize, *h as usize);
11714                unsafe {
11715                    let input = sl(*src, base, rows * h);
11716                    let gamma = sl(*g, base, h);
11717                    let beta = sl(*b, base, h);
11718                    let output = sl_mut(*dst, base, rows * h);
11719                    let inv_h = 1.0 / h as f32;
11720                    for row in 0..rows {
11721                        let in_row = &input[row * h..(row + 1) * h];
11722                        let out_row = &mut output[row * h..(row + 1) * h];
11723                        // RMS = sqrt(mean(x^2) + eps); scale = 1/RMS.
11724                        let mut sumsq = 0f32;
11725                        for &v in in_row {
11726                            sumsq += v * v;
11727                        }
11728                        let inv_rms = (sumsq * inv_h + *eps).sqrt().recip();
11729                        for i in 0..h {
11730                            out_row[i] = in_row[i] * inv_rms * gamma[i] + beta[i];
11731                        }
11732                    }
11733                }
11734            }
11735
11736            Thunk::Softmax { data, rows, cols } => {
11737                let (rows, cols) = (*rows as usize, *cols as usize);
11738                unsafe {
11739                    crate::kernels::neon_softmax(sl_mut(*data, base, rows * cols), rows, cols);
11740                }
11741            }
11742
11743            Thunk::Cumsum {
11744                src,
11745                dst,
11746                rows,
11747                cols,
11748                exclusive,
11749            } => {
11750                let (rows, cols) = (*rows as usize, *cols as usize);
11751                unsafe {
11752                    let s = sl(*src, base, rows * cols);
11753                    let d = sl_mut(*dst, base, rows * cols);
11754                    if *exclusive {
11755                        for r in 0..rows {
11756                            let mut acc = 0.0f32;
11757                            for c in 0..cols {
11758                                d[r * cols + c] = acc;
11759                                acc += s[r * cols + c];
11760                            }
11761                        }
11762                    } else {
11763                        for r in 0..rows {
11764                            let mut acc = 0.0f32;
11765                            for c in 0..cols {
11766                                acc += s[r * cols + c];
11767                                d[r * cols + c] = acc;
11768                            }
11769                        }
11770                    }
11771                }
11772            }
11773
11774            Thunk::Sample {
11775                logits,
11776                dst,
11777                batch,
11778                vocab,
11779                top_k,
11780                top_p,
11781                temperature,
11782                seed,
11783            } => unsafe {
11784                execute_sample_f32(
11785                    *logits,
11786                    *dst,
11787                    *batch as usize,
11788                    *vocab as usize,
11789                    *top_k as usize,
11790                    *top_p,
11791                    *temperature,
11792                    *seed,
11793                    base,
11794                );
11795            },
11796
11797            Thunk::RngNormal {
11798                dst,
11799                len,
11800                mean,
11801                scale,
11802                key,
11803                op_seed,
11804            } => {
11805                let n = *len as usize;
11806                unsafe {
11807                    let out = sl_mut(*dst, base, n);
11808                    let opts = *schedule.rng.read().unwrap();
11809                    rlx_ir::fill_normal_like(out, *mean, *scale, opts, *key, *op_seed);
11810                }
11811            }
11812
11813            Thunk::RngUniform {
11814                dst,
11815                len,
11816                low,
11817                high,
11818                key,
11819                op_seed,
11820            } => {
11821                let n = *len as usize;
11822                unsafe {
11823                    let out = sl_mut(*dst, base, n);
11824                    let opts = *schedule.rng.read().unwrap();
11825                    rlx_ir::fill_uniform_like(out, *low, *high, opts, *key, *op_seed);
11826                }
11827            }
11828
11829            Thunk::GatedDeltaNet {
11830                q,
11831                k,
11832                v,
11833                g,
11834                beta,
11835                state,
11836                dst,
11837                batch,
11838                seq,
11839                heads,
11840                state_size,
11841            } => unsafe {
11842                execute_gated_delta_net_f32(
11843                    *q,
11844                    *k,
11845                    *v,
11846                    *g,
11847                    *beta,
11848                    *state,
11849                    *dst,
11850                    *batch as usize,
11851                    *seq as usize,
11852                    *heads as usize,
11853                    *state_size as usize,
11854                    base,
11855                );
11856            },
11857
11858            Thunk::Lstm {
11859                x,
11860                w_ih,
11861                w_hh,
11862                bias,
11863                h0,
11864                c0,
11865                dst,
11866                batch,
11867                seq,
11868                input_size,
11869                hidden,
11870                num_layers,
11871                bidirectional,
11872                carry,
11873            } => unsafe {
11874                execute_lstm_f32(
11875                    *x,
11876                    *w_ih,
11877                    *w_hh,
11878                    *bias,
11879                    *h0,
11880                    *c0,
11881                    *dst,
11882                    *batch as usize,
11883                    *seq as usize,
11884                    *input_size as usize,
11885                    *hidden as usize,
11886                    *num_layers as usize,
11887                    *bidirectional,
11888                    *carry,
11889                    base,
11890                );
11891            },
11892
11893            Thunk::Gru {
11894                x,
11895                w_ih,
11896                w_hh,
11897                b_ih,
11898                b_hh,
11899                h0,
11900                dst,
11901                batch,
11902                seq,
11903                input_size,
11904                hidden,
11905                num_layers,
11906                bidirectional,
11907                carry,
11908            } => unsafe {
11909                execute_gru_f32(
11910                    *x,
11911                    *w_ih,
11912                    *w_hh,
11913                    *b_ih,
11914                    *b_hh,
11915                    *h0,
11916                    *dst,
11917                    *batch as usize,
11918                    *seq as usize,
11919                    *input_size as usize,
11920                    *hidden as usize,
11921                    *num_layers as usize,
11922                    *bidirectional,
11923                    *carry,
11924                    base,
11925                );
11926            },
11927
11928            Thunk::Rnn {
11929                x,
11930                w_ih,
11931                w_hh,
11932                bias,
11933                h0,
11934                dst,
11935                batch,
11936                seq,
11937                input_size,
11938                hidden,
11939                num_layers,
11940                bidirectional,
11941                carry,
11942                relu,
11943            } => unsafe {
11944                execute_rnn_f32(
11945                    *x,
11946                    *w_ih,
11947                    *w_hh,
11948                    *bias,
11949                    *h0,
11950                    *dst,
11951                    *batch as usize,
11952                    *seq as usize,
11953                    *input_size as usize,
11954                    *hidden as usize,
11955                    *num_layers as usize,
11956                    *bidirectional,
11957                    *carry,
11958                    *relu,
11959                    base,
11960                );
11961            },
11962
11963            Thunk::Mamba2 {
11964                x,
11965                dt,
11966                a,
11967                b,
11968                c,
11969                dst,
11970                batch,
11971                seq,
11972                heads,
11973                head_dim,
11974                state_size,
11975            } => unsafe {
11976                execute_mamba2_f32(
11977                    *x,
11978                    *dt,
11979                    *a,
11980                    *b,
11981                    *c,
11982                    *dst,
11983                    *batch as usize,
11984                    *seq as usize,
11985                    *heads as usize,
11986                    *head_dim as usize,
11987                    *state_size as usize,
11988                    base,
11989                );
11990            },
11991
11992            Thunk::SelectiveScan {
11993                x,
11994                delta,
11995                a,
11996                b: bp,
11997                c: cp,
11998                dst,
11999                batch,
12000                seq,
12001                hidden,
12002                state_size,
12003            } => unsafe {
12004                execute_selective_scan_f32(
12005                    *x,
12006                    *delta,
12007                    *a,
12008                    *bp,
12009                    *cp,
12010                    *dst,
12011                    *batch as usize,
12012                    *seq as usize,
12013                    *hidden as usize,
12014                    *state_size as usize,
12015                    base,
12016                );
12017            },
12018
12019            Thunk::DequantMatMul {
12020                x,
12021                w_q,
12022                scale,
12023                zp,
12024                dst,
12025                m,
12026                k,
12027                n,
12028                block_size,
12029                is_asymmetric,
12030            } => {
12031                let (m, k, n, bs) = (*m as usize, *k as usize, *n as usize, *block_size as usize);
12032                let n_blocks = k.div_ceil(bs);
12033                unsafe {
12034                    let xs = sl(*x, base, m * k);
12035                    let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const i8, k * n);
12036                    let scales = sl(*scale, base, n_blocks * n);
12037                    let zps = if *is_asymmetric {
12038                        sl(*zp, base, n_blocks * n)
12039                    } else {
12040                        &[][..]
12041                    };
12042                    let out = sl_mut(*dst, base, m * n);
12043                    dequant_matmul_int8(xs, w_bytes, scales, zps, out, m, k, n, bs, *is_asymmetric);
12044                }
12045            }
12046
12047            Thunk::DequantMatMulGguf {
12048                x,
12049                w_q,
12050                dst,
12051                m,
12052                k,
12053                n,
12054                scheme,
12055            } => {
12056                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
12057                let block_bytes = scheme.gguf_block_bytes() as usize;
12058                let block_elems = scheme.gguf_block_size() as usize;
12059                debug_assert!(
12060                    block_bytes > 0 && block_elems > 0,
12061                    "non-GGUF scheme in GGUF arm"
12062                );
12063                debug_assert!(
12064                    (k * n).is_multiple_of(block_elems),
12065                    "k*n={} not aligned to GGUF block size {}",
12066                    k * n,
12067                    block_elems
12068                );
12069                let total_bytes = (k * n) / block_elems * block_bytes;
12070                unsafe {
12071                    let xs = sl(*x, base, m * k);
12072                    let w_bytes_ptr = base.add(*w_q) as *const u8;
12073                    let w_bytes = std::slice::from_raw_parts(w_bytes_ptr, total_bytes);
12074                    let out = sl_mut(*dst, base, m * n);
12075                    crate::gguf_matmul::gguf_matmul_bt(xs, w_bytes, out, m, k, n, *scheme);
12076                }
12077            }
12078
12079            Thunk::DequantMatMulInt4 {
12080                x,
12081                w_q,
12082                scale,
12083                zp,
12084                dst,
12085                m,
12086                k,
12087                n,
12088                block_size,
12089                is_asymmetric,
12090            } => {
12091                let (m, k, n, bs) = (*m as usize, *k as usize, *n as usize, *block_size as usize);
12092                let n_blocks = k.div_ceil(bs);
12093                unsafe {
12094                    let xs = sl(*x, base, m * k);
12095                    let w_bytes = std::slice::from_raw_parts(
12096                        base.add(*w_q) as *const u8,
12097                        (k * n).div_ceil(2),
12098                    );
12099                    let scales = sl(*scale, base, n_blocks * n);
12100                    let zps = if *is_asymmetric {
12101                        sl(*zp, base, n_blocks * n)
12102                    } else {
12103                        &[][..]
12104                    };
12105                    let out = sl_mut(*dst, base, m * n);
12106                    dequant_matmul_int4(xs, w_bytes, scales, zps, out, m, k, n, bs, *is_asymmetric);
12107                }
12108            }
12109
12110            Thunk::DequantMatMulFp8 {
12111                x,
12112                w_q,
12113                scale,
12114                dst,
12115                m,
12116                k,
12117                n,
12118                e5m2,
12119            } => {
12120                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
12121                unsafe {
12122                    let xs = sl(*x, base, m * k);
12123                    let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const u8, k * n);
12124                    let scales = sl(*scale, base, n);
12125                    let out = sl_mut(*dst, base, m * n);
12126                    dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, *e5m2);
12127                }
12128            }
12129
12130            Thunk::DequantMatMulNvfp4 {
12131                x,
12132                w_q,
12133                scale,
12134                global_scale,
12135                dst,
12136                m,
12137                k,
12138                n,
12139            } => {
12140                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
12141                let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
12142                unsafe {
12143                    let xs = sl(*x, base, m * k);
12144                    let w_bytes = std::slice::from_raw_parts(
12145                        base.add(*w_q) as *const u8,
12146                        (k * n).div_ceil(2),
12147                    );
12148                    let scale_bytes =
12149                        std::slice::from_raw_parts(base.add(*scale) as *const u8, n_scale);
12150                    let gs = sl(*global_scale, base, 1)[0];
12151                    let out = sl_mut(*dst, base, m * n);
12152                    dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
12153                }
12154            }
12155
12156            Thunk::ScaledMatMul {
12157                lhs,
12158                rhs,
12159                lhs_scale,
12160                rhs_scale,
12161                bias,
12162                dst,
12163                m,
12164                k,
12165                n,
12166                lhs_fmt,
12167                rhs_fmt,
12168                layout,
12169                has_bias,
12170            } => {
12171                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
12172                let layout = *layout;
12173                let nblk = lowp_nblk(k, layout);
12174                let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
12175                let n_lscale = if per_tensor { 1 } else { m * nblk };
12176                let n_rscale = if per_tensor { 1 } else { n * nblk };
12177                unsafe {
12178                    let lhs_b = std::slice::from_raw_parts(base.add(*lhs) as *const u8, m * k);
12179                    let rhs_b = std::slice::from_raw_parts(base.add(*rhs) as *const u8, n * k);
12180                    let ls = lowp_read_scales(layout, base, *lhs_scale, n_lscale);
12181                    let rs = lowp_read_scales(layout, base, *rhs_scale, n_rscale);
12182                    let bias_s = if *has_bias {
12183                        Some(sl(*bias, base, n))
12184                    } else {
12185                        None
12186                    };
12187                    let out = sl_mut(*dst, base, m * n);
12188                    lowp_scaled_matmul(
12189                        lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, *lhs_fmt, *rhs_fmt,
12190                    );
12191                }
12192            }
12193
12194            Thunk::ScaledQuantize {
12195                x,
12196                scale,
12197                dst,
12198                rows,
12199                cols,
12200                fmt,
12201                layout,
12202            } => {
12203                let (rows, cols) = (*rows as usize, *cols as usize);
12204                let layout = *layout;
12205                let nblk = lowp_nblk(cols, layout);
12206                let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
12207                    1
12208                } else {
12209                    rows * nblk
12210                };
12211                unsafe {
12212                    let xs = sl(*x, base, rows * cols);
12213                    let scales = lowp_read_scales(layout, base, *scale, n_scale);
12214                    let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * cols);
12215                    lowp_quantize(xs, &scales, *fmt, layout, rows, cols, out);
12216                }
12217            }
12218
12219            Thunk::ScaledQuantScale {
12220                x,
12221                dst,
12222                rows,
12223                cols,
12224                fmt,
12225                layout,
12226            } => {
12227                let (rows, cols) = (*rows as usize, *cols as usize);
12228                let layout = *layout;
12229                let nblk = lowp_nblk(cols, layout);
12230                unsafe {
12231                    let xs = sl(*x, base, rows * cols);
12232                    let scales = lowp_compute_scales(xs, *fmt, layout, rows, cols);
12233                    match layout {
12234                        rlx_ir::ScaleLayout::PerTensor => {
12235                            sl_mut(*dst, base, 1)[0] = scales[0];
12236                        }
12237                        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
12238                            let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * nblk);
12239                            for (o, &s) in out.iter_mut().zip(&scales) {
12240                                *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
12241                            }
12242                        }
12243                        rlx_ir::ScaleLayout::Nvfp4 { .. } => {
12244                            let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * nblk);
12245                            for (o, &s) in out.iter_mut().zip(&scales) {
12246                                *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
12247                            }
12248                        }
12249                    }
12250                }
12251            }
12252
12253            Thunk::ScaledDequantize {
12254                codes,
12255                scale,
12256                dst,
12257                rows,
12258                cols,
12259                fmt,
12260                layout,
12261            } => unsafe {
12262                execute_scaled_dequantize_f32(
12263                    *codes,
12264                    *scale,
12265                    *dst,
12266                    *rows as usize,
12267                    *cols as usize,
12268                    *fmt,
12269                    *layout,
12270                    base,
12271                );
12272            },
12273
12274            Thunk::LoraMatMul {
12275                x,
12276                w,
12277                a,
12278                b,
12279                dst,
12280                m,
12281                k,
12282                n,
12283                r,
12284                scale,
12285            } => {
12286                let (m, k, n, r) = (*m as usize, *k as usize, *n as usize, *r as usize);
12287                unsafe {
12288                    let xs = sl(*x, base, m * k);
12289                    let ws = sl(*w, base, k * n);
12290                    let a_s = sl(*a, base, k * r);
12291                    let bs = sl(*b, base, r * n);
12292                    let out = sl_mut(*dst, base, m * n);
12293                    crate::blas::sgemm(xs, ws, out, m, k, n);
12294                    let mut tmp = vec![0f32; m * r];
12295                    crate::blas::sgemm(xs, a_s, &mut tmp, m, k, r);
12296                    if *scale != 1.0 {
12297                        for v in tmp.iter_mut() {
12298                            *v *= *scale;
12299                        }
12300                    }
12301                    crate::blas::sgemm_accumulate(&tmp, bs, out, m, r, n);
12302                }
12303            }
12304
12305            Thunk::Attention {
12306                q,
12307                k,
12308                v,
12309                mask,
12310                out,
12311                batch,
12312                seq,
12313                kv_seq,
12314                heads,
12315                head_dim,
12316                mask_kind,
12317                scale,
12318                q_row_stride,
12319                k_row_stride,
12320                v_row_stride,
12321                bhsd,
12322            } => {
12323                let (b, q_s, k_s, nh, dh) = (
12324                    *batch as usize,
12325                    *seq as usize,
12326                    *kv_seq as usize,
12327                    *heads as usize,
12328                    *head_dim as usize,
12329                );
12330                let hs = nh * dh;
12331                // For [B, H, S, D] layout each (b, h) tile is dense
12332                // contiguous; the qrs/krs/vrs strides are not used.
12333                let (qrs, krs, vrs) = if *bhsd {
12334                    (dh, dh, dh)
12335                } else {
12336                    (
12337                        *q_row_stride as usize,
12338                        *k_row_stride as usize,
12339                        *v_row_stride as usize,
12340                    )
12341                };
12342                let bhsd = *bhsd;
12343                let _ = (q_row_stride, k_row_stride, v_row_stride);
12344                let scale = *scale;
12345                let ss = q_s * k_s;
12346                let cfg = crate::config::RuntimeConfig::global();
12347                unsafe {
12348                    // Slice lengths cover the strided span. When Q/K/V
12349                    // alias the parent QKV (post-#46-fusion), the same
12350                    // bytes back all three slices — compiler bounds
12351                    // checks see the right size. For [B, H, S, D] the
12352                    // buffer is densely B*H*S*D elements; the row
12353                    // strides aren't used.
12354                    let q_len = if bhsd {
12355                        b * nh * q_s * dh
12356                    } else {
12357                        b * q_s * qrs
12358                    };
12359                    let k_len = if bhsd {
12360                        b * nh * k_s * dh
12361                    } else {
12362                        b * k_s * krs
12363                    };
12364                    let v_len = if bhsd {
12365                        b * nh * k_s * dh
12366                    } else {
12367                        b * k_s * vrs
12368                    };
12369                    let q_data = sl(*q, base, q_len);
12370                    let k_data = sl(*k, base, k_len);
12371                    let v_data = sl(*v, base, v_len);
12372                    let mask_data: &[f32] = match mask_kind {
12373                        rlx_ir::op::MaskKind::Custom => sl(*mask, base, b * k_s),
12374                        rlx_ir::op::MaskKind::Bias => sl(*mask, base, b * nh * q_s * k_s),
12375                        _ => &[],
12376                    };
12377                    let out_len = if bhsd {
12378                        b * nh * q_s * dh
12379                    } else {
12380                        b * q_s * hs
12381                    };
12382                    let out_data = sl_mut(*out, base, out_len);
12383
12384                    // ── [B, H, S, D] fallback ──────────────────────
12385                    // The NEON / strided-BLAS specializations below
12386                    // are written for the [B, S, H, D] layout. When
12387                    // the input is head-major ([B, H, S, D] —
12388                    // matching rlx-cuda / rlx-rocm / rlx-tpu), bypass
12389                    // them and run a simple (correct but slower)
12390                    // scalar implementation. Production-CPU inference
12391                    // graphs use [B, S, H, D] so they still hit the
12392                    // hot path; cross-backend parity tests use
12393                    // [B, H, S, D] and land here.
12394                    if bhsd {
12395                        let scores = &mut sdpa_scores[..ss];
12396                        for bi in 0..b {
12397                            for hi in 0..nh {
12398                                let q_head_base = bi * nh * q_s * dh + hi * q_s * dh;
12399                                let k_head_base = bi * nh * k_s * dh + hi * k_s * dh;
12400                                // Q@K^T
12401                                for qi in 0..q_s {
12402                                    let q_base = q_head_base + qi * dh;
12403                                    for ki in 0..k_s {
12404                                        let k_base = k_head_base + ki * dh;
12405                                        let mut dot = 0f32;
12406                                        for d in 0..dh {
12407                                            dot += q_data[q_base + d] * k_data[k_base + d];
12408                                        }
12409                                        scores[qi * k_s + ki] = dot * scale;
12410                                        if matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
12411                                            && !mask_data.is_empty()
12412                                            && mask_data[bi * k_s + ki] < mask_thr
12413                                        {
12414                                            scores[qi * k_s + ki] = mask_neg;
12415                                        }
12416                                    }
12417                                }
12418                                if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) {
12419                                    let off = (bi * nh + hi) * q_s * k_s;
12420                                    for i in 0..q_s * k_s {
12421                                        scores[i] += mask_data[off + i];
12422                                    }
12423                                }
12424                                apply_synthetic_mask(scores, q_s, k_s, *mask_kind);
12425                                crate::kernels::neon_softmax(scores, q_s, k_s);
12426                                // score @ V
12427                                for qi in 0..q_s {
12428                                    let o_base = q_head_base + qi * dh;
12429                                    for d in 0..dh {
12430                                        out_data[o_base + d] = 0.0;
12431                                    }
12432                                    for ki in 0..k_s {
12433                                        let sc = scores[qi * k_s + ki];
12434                                        if sc > score_thr {
12435                                            let v_base = k_head_base + ki * dh;
12436                                            for d in 0..dh {
12437                                                out_data[o_base + d] += sc * v_data[v_base + d];
12438                                            }
12439                                        }
12440                                    }
12441                                }
12442                            }
12443                        }
12444                        continue;
12445                    }
12446
12447                    // ── Auto-select kernel: NEON dots vs strided BLAS ───
12448                    // For tiny inputs (batch=1, short seq), per-head BLAS call
12449                    // overhead (~0.5µs × 2 calls × num_heads × num_layers)
12450                    // exceeds the NEON compute cost. Use direct strided NEON
12451                    // with zero dispatch overhead.
12452                    // For batch≥2: always BLAS + par_for (parallelism wins).
12453                    if b == 1 && q_s.max(k_s) <= cfg.sdpa_seq_threshold {
12454                        // ── Sequential NEON path (zero overhead) ──
12455                        let scores = &mut sdpa_scores[..ss];
12456                        #[cfg(target_arch = "aarch64")]
12457                        let neon_chunks = dh / 4;
12458
12459                        for bi in 0..b {
12460                            for hi in 0..nh {
12461                                // Q@K^T via strided NEON dot products
12462                                for qi in 0..q_s {
12463                                    let q_off = bi * q_s * qrs + qi * qrs + hi * dh;
12464                                    for ki in 0..k_s {
12465                                        let k_off = bi * k_s * krs + ki * krs + hi * dh;
12466                                        #[cfg(target_arch = "aarch64")]
12467                                        let mut dot;
12468                                        #[cfg(not(target_arch = "aarch64"))]
12469                                        let mut dot = 0f32;
12470                                        #[cfg(target_arch = "aarch64")]
12471                                        {
12472                                            use std::arch::aarch64::*;
12473                                            let mut acc = vdupq_n_f32(0.0);
12474                                            for c in 0..neon_chunks {
12475                                                let vq =
12476                                                    vld1q_f32(q_data.as_ptr().add(q_off + c * 4));
12477                                                let vk =
12478                                                    vld1q_f32(k_data.as_ptr().add(k_off + c * 4));
12479                                                acc = vfmaq_f32(acc, vq, vk);
12480                                            }
12481                                            dot = vaddvq_f32(acc);
12482                                            for d in (neon_chunks * 4)..dh {
12483                                                dot += q_data[q_off + d] * k_data[k_off + d];
12484                                            }
12485                                        }
12486                                        #[cfg(not(target_arch = "aarch64"))]
12487                                        for d in 0..dh {
12488                                            dot += q_data[q_off + d] * k_data[k_off + d];
12489                                        }
12490                                        scores[qi * k_s + ki] = dot * scale;
12491                                        // Inner-loop Custom mask check —
12492                                        // Causal / SlidingWindow / None
12493                                        // apply outside the loop below.
12494                                        // Skip for Bias — that mask is a
12495                                        // per-head additive tensor, not a
12496                                        // 0/1 key-padding mask.
12497                                        if matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
12498                                            && !mask_data.is_empty()
12499                                            && mask_data[bi * k_s + ki] < mask_thr
12500                                        {
12501                                            scores[qi * k_s + ki] = mask_neg;
12502                                        }
12503                                    }
12504                                }
12505
12506                                if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) {
12507                                    let off = (bi * nh + hi) * q_s * k_s;
12508                                    for i in 0..q_s * k_s {
12509                                        scores[i] += mask_data[off + i];
12510                                    }
12511                                }
12512                                apply_synthetic_mask(scores, q_s, k_s, *mask_kind);
12513                                crate::kernels::neon_softmax(scores, q_s, k_s);
12514
12515                                // Score@V via strided NEON accumulation (zero-copy)
12516                                for qi in 0..q_s {
12517                                    let o_off = bi * q_s * hs + qi * hs + hi * dh;
12518                                    // Zero output for this head position
12519                                    for d in 0..dh {
12520                                        out_data[o_off + d] = 0.0;
12521                                    }
12522                                    for ki in 0..k_s {
12523                                        let sc = scores[qi * k_s + ki];
12524                                        if sc > score_thr {
12525                                            let v_off = bi * k_s * vrs + ki * vrs + hi * dh;
12526                                            #[cfg(target_arch = "aarch64")]
12527                                            {
12528                                                use std::arch::aarch64::*;
12529                                                let vsc = vdupq_n_f32(sc);
12530                                                for c in 0..neon_chunks {
12531                                                    let off = c * 4;
12532                                                    let vo = vld1q_f32(
12533                                                        out_data.as_ptr().add(o_off + off),
12534                                                    );
12535                                                    let vv =
12536                                                        vld1q_f32(v_data.as_ptr().add(v_off + off));
12537                                                    vst1q_f32(
12538                                                        out_data.as_mut_ptr().add(o_off + off),
12539                                                        vfmaq_f32(vo, vsc, vv),
12540                                                    );
12541                                                }
12542                                            }
12543                                            #[cfg(not(target_arch = "aarch64"))]
12544                                            for d in 0..dh {
12545                                                out_data[o_off + d] += sc * v_data[v_off + d];
12546                                            }
12547                                        }
12548                                    }
12549                                }
12550                            }
12551                        }
12552                    } else {
12553                        // ── Parallel strided BLAS path (high throughput) ──
12554                        let total_work = b * nh;
12555                        let q_addr = q_data.as_ptr() as usize;
12556                        let k_addr = k_data.as_ptr() as usize;
12557                        let v_addr = v_data.as_ptr() as usize;
12558                        let m_addr = mask_data.as_ptr() as usize;
12559                        let o_addr = out_data.as_mut_ptr() as usize;
12560                        let sc_addr = sdpa_scores.as_mut_ptr() as usize;
12561
12562                        crate::pool::par_for(total_work, 1, &|off, cnt| {
12563                            for idx in off..off + cnt {
12564                                let bi = idx / nh;
12565                                let hi = idx % nh;
12566
12567                                let q_start = (q_addr as *const f32).add(bi * q_s * qrs + hi * dh);
12568                                let k_start = (k_addr as *const f32).add(bi * k_s * krs + hi * dh);
12569                                let v_start = (v_addr as *const f32).add(bi * k_s * vrs + hi * dh);
12570                                let o_start = (o_addr as *mut f32).add(bi * q_s * hs + hi * dh);
12571                                let sc = std::slice::from_raw_parts_mut(
12572                                    (sc_addr as *mut f32).add(idx * ss),
12573                                    ss,
12574                                );
12575
12576                                // LDA = qrs, LDB = krs (parent row strides
12577                                // when fused; hs otherwise).
12578                                crate::blas::sgemm_general(
12579                                    q_start,
12580                                    k_start,
12581                                    sc.as_mut_ptr(),
12582                                    q_s,
12583                                    k_s,
12584                                    dh,
12585                                    scale,
12586                                    0.0,
12587                                    qrs,
12588                                    krs,
12589                                    k_s,
12590                                    false,
12591                                    true,
12592                                );
12593
12594                                match mask_kind {
12595                                    rlx_ir::op::MaskKind::Custom => {
12596                                        let mask_bi = std::slice::from_raw_parts(
12597                                            (m_addr as *const f32).add(bi * k_s),
12598                                            k_s,
12599                                        );
12600                                        for ki in 0..k_s {
12601                                            if mask_bi[ki] < mask_thr {
12602                                                for qi in 0..q_s {
12603                                                    sc[qi * k_s + ki] = mask_neg;
12604                                                }
12605                                            }
12606                                        }
12607                                    }
12608                                    rlx_ir::op::MaskKind::Bias => {
12609                                        // Per-head additive bias slice.
12610                                        let bias = std::slice::from_raw_parts(
12611                                            (m_addr as *const f32).add((bi * nh + hi) * q_s * k_s),
12612                                            q_s * k_s,
12613                                        );
12614                                        for i in 0..q_s * k_s {
12615                                            sc[i] += bias[i];
12616                                        }
12617                                    }
12618                                    _ => apply_synthetic_mask(sc, q_s, k_s, *mask_kind),
12619                                }
12620
12621                                crate::kernels::neon_softmax(sc, q_s, k_s);
12622
12623                                // LDB = vrs (parent row stride when
12624                                // fused; hs otherwise). LDC stays hs —
12625                                // output is its own contiguous buffer.
12626                                crate::blas::sgemm_general(
12627                                    sc.as_ptr(),
12628                                    v_start,
12629                                    o_start,
12630                                    q_s,
12631                                    dh,
12632                                    k_s,
12633                                    1.0,
12634                                    0.0,
12635                                    k_s,
12636                                    vrs,
12637                                    hs,
12638                                    false,
12639                                    false,
12640                                );
12641                            }
12642                        });
12643                    }
12644                }
12645            }
12646
12647            Thunk::AttentionBackward {
12648                q,
12649                k,
12650                v,
12651                dy,
12652                mask,
12653                out,
12654                batch,
12655                seq,
12656                kv_seq,
12657                heads,
12658                head_dim,
12659                mask_kind,
12660                wrt,
12661                bhsd,
12662            } => {
12663                let (b, q_s, k_s, nh, dh) = (
12664                    *batch as usize,
12665                    *seq as usize,
12666                    *kv_seq as usize,
12667                    *heads as usize,
12668                    *head_dim as usize,
12669                );
12670                unsafe {
12671                    let q_len = if *bhsd {
12672                        b * nh * q_s * dh
12673                    } else {
12674                        b * q_s * nh * dh
12675                    };
12676                    let k_len = if *bhsd {
12677                        b * nh * k_s * dh
12678                    } else {
12679                        b * k_s * nh * dh
12680                    };
12681                    let out_len = match wrt {
12682                        rlx_ir::op::AttentionBwdWrt::Key | rlx_ir::op::AttentionBwdWrt::Value => {
12683                            k_len
12684                        }
12685                        rlx_ir::op::AttentionBwdWrt::Query => q_len,
12686                    };
12687                    let q_data = sl(*q, base, q_len);
12688                    let k_data = sl(*k, base, k_len);
12689                    let v_data = sl(*v, base, k_len);
12690                    let dy_data = sl(*dy, base, q_len);
12691                    let out_data = sl_mut(*out, base, out_len);
12692                    let mask_data: &[f32] = if *mask != 0 {
12693                        let ml = match mask_kind {
12694                            rlx_ir::op::MaskKind::Custom => b * k_s,
12695                            rlx_ir::op::MaskKind::Bias => b * nh * q_s * k_s,
12696                            _ => 0,
12697                        };
12698                        sl(*mask, base, ml)
12699                    } else {
12700                        &[]
12701                    };
12702                    crate::attention_bwd::attention_backward(
12703                        *wrt, q_data, k_data, v_data, dy_data, out_data, b, nh, q_s, k_s, dh,
12704                        *mask_kind, mask_data, *bhsd,
12705                    );
12706                }
12707            }
12708
12709            Thunk::ActivationInPlace { data, len, act } => {
12710                let len = *len as usize;
12711                unsafe {
12712                    let d = sl_mut(*data, base, len);
12713                    match act {
12714                        Activation::Gelu => crate::kernels::par_gelu_inplace(d),
12715                        Activation::GeluApprox => crate::kernels::par_gelu_approx_inplace(d),
12716                        Activation::Silu => crate::kernels::par_silu_inplace(d),
12717                        Activation::Relu => {
12718                            for v in d.iter_mut() {
12719                                *v = v.max(0.0);
12720                            }
12721                        }
12722                        Activation::Sigmoid => {
12723                            for v in d.iter_mut() {
12724                                *v = 1.0 / (1.0 + (-*v).exp());
12725                            }
12726                        }
12727                        Activation::Tanh => {
12728                            for v in d.iter_mut() {
12729                                *v = v.tanh();
12730                            }
12731                        }
12732                        Activation::Exp => {
12733                            for v in d.iter_mut() {
12734                                *v = v.exp();
12735                            }
12736                        }
12737                        Activation::Log => {
12738                            for v in d.iter_mut() {
12739                                *v = v.ln();
12740                            }
12741                        }
12742                        Activation::Sqrt => {
12743                            for v in d.iter_mut() {
12744                                *v = v.sqrt();
12745                            }
12746                        }
12747                        Activation::Rsqrt => {
12748                            for v in d.iter_mut() {
12749                                *v = 1.0 / v.sqrt();
12750                            }
12751                        }
12752                        Activation::Neg => {
12753                            for v in d.iter_mut() {
12754                                *v = -*v;
12755                            }
12756                        }
12757                        Activation::Abs => {
12758                            for v in d.iter_mut() {
12759                                *v = v.abs();
12760                            }
12761                        }
12762                        Activation::Round => {
12763                            for v in d.iter_mut() {
12764                                *v = v.round();
12765                            }
12766                        }
12767                        Activation::Sin => {
12768                            for v in d.iter_mut() {
12769                                *v = v.sin();
12770                            }
12771                        }
12772                        Activation::Cos => {
12773                            for v in d.iter_mut() {
12774                                *v = v.cos();
12775                            }
12776                        }
12777                        Activation::Tan => {
12778                            for v in d.iter_mut() {
12779                                *v = v.tan();
12780                            }
12781                        }
12782                        Activation::Atan => {
12783                            for v in d.iter_mut() {
12784                                *v = v.atan();
12785                            }
12786                        }
12787                    }
12788                }
12789            }
12790
12791            Thunk::FusedAttnBlock {
12792                hidden,
12793                qkv_w,
12794                out_w,
12795                mask,
12796                mask_kind,
12797                out,
12798                qkv_b,
12799                out_b,
12800                cos,
12801                sin,
12802                cos_len,
12803                batch,
12804                seq,
12805                hs,
12806                nh,
12807                dh,
12808                has_bias,
12809                has_rope,
12810                interleaved,
12811            } => {
12812                let (b, s) = (*batch as usize, *seq as usize);
12813                let (h, n_h, d_h) = (*hs as usize, *nh as usize, *dh as usize);
12814                let interleaved = *interleaved;
12815                let m = b * s;
12816                let scale = (d_h as f32).powf(-0.5);
12817                let half = d_h / 2;
12818                // Only `Custom` consumes the per-key padding buffer; `Causal` /
12819                // `SlidingWindow` are synthesized from (qi, ki) below, and have
12820                // no mask buffer (so reading one would touch unrelated arena
12821                // bytes). q_seq == kv_seq here (guaranteed at fusion time), so
12822                // the absolute query position is just `qi`.
12823                let use_custom_mask = matches!(mask_kind, rlx_ir::op::MaskKind::Custom);
12824                unsafe {
12825                    let inp = sl(*hidden, base, m * h);
12826                    let wq = sl(*qkv_w, base, h * 3 * h);
12827                    let wo = sl(*out_w, base, h * h);
12828                    let mk = if use_custom_mask {
12829                        sl(*mask, base, b * s)
12830                    } else {
12831                        &[]
12832                    };
12833                    let dst = sl_mut(*out, base, m * h);
12834
12835                    // Stack-allocated intermediates — all fit in L1 cache for small batch
12836                    let mut qkv = vec![0f32; m * 3 * h];
12837                    let mut attn_out = vec![0f32; m * h];
12838                    let mut scores_buf = vec![0f32; s * s]; // one head at a time
12839
12840                    // 1. QKV projection: [m, h] @ [h, 3h] → [m, 3h]
12841                    crate::blas::sgemm(inp, wq, &mut qkv, m, h, 3 * h);
12842                    if *has_bias {
12843                        let bias = sl(*qkv_b, base, 3 * h);
12844                        crate::blas::bias_add(&mut qkv, bias, m, 3 * h);
12845                    }
12846
12847                    // 2. Multi-head SDPA (Q/K/V are views into qkv at offsets 0, h, 2h)
12848                    //    Process heads sequentially with inline RoPE — zero copy.
12849                    #[cfg(target_arch = "aarch64")]
12850                    let neon_chunks = d_h / 4;
12851                    #[cfg(target_arch = "aarch64")]
12852                    let _rope_chunks = half / 4;
12853
12854                    for bi in 0..b {
12855                        for hi in 0..n_h {
12856                            // For each (query_pos, key_pos): compute Q@K^T with inline RoPE
12857                            for qi in 0..s {
12858                                let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
12859                                for ki in 0..s {
12860                                    let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
12861                                    let mut dot = 0f32;
12862
12863                                    if *has_rope {
12864                                        // Apply RoPE inline during dot product
12865                                        let q_cos = qi * half;
12866                                        let k_cos = ki * half;
12867                                        let cos_tab = sl(*cos, base, *cos_len as usize);
12868                                        let sin_tab = sl(*sin, base, *cos_len as usize);
12869                                        // Rotate per pair, then dot. The q·k sum
12870                                        // is layout-independent, so only the pair
12871                                        // element offsets differ by style:
12872                                        //   NeoX:  (i, i+half)   GPT-J: (2i, 2i+1)
12873                                        // angle index is the pair index `i` for both.
12874                                        for i in 0..half {
12875                                            let (qo1, qo2, ko1, ko2) = if interleaved {
12876                                                (2 * i, 2 * i + 1, 2 * i, 2 * i + 1)
12877                                            } else {
12878                                                (i, half + i, i, half + i)
12879                                            };
12880                                            let q1 = qkv[q_base + qo1];
12881                                            let q2 = qkv[q_base + qo2];
12882                                            let k1 = qkv[k_base + ko1];
12883                                            let k2 = qkv[k_base + ko2];
12884                                            let c_q = cos_tab[q_cos + i];
12885                                            let s_q = sin_tab[q_cos + i];
12886                                            let c_k = cos_tab[k_cos + i];
12887                                            let s_k = sin_tab[k_cos + i];
12888                                            let qr1 = q1 * c_q - q2 * s_q;
12889                                            let kr1 = k1 * c_k - k2 * s_k;
12890                                            let qr2 = q2 * c_q + q1 * s_q;
12891                                            let kr2 = k2 * c_k + k1 * s_k;
12892                                            dot += qr1 * kr1 + qr2 * kr2;
12893                                        }
12894                                    } else {
12895                                        // Standard dot product
12896                                        #[cfg(target_arch = "aarch64")]
12897                                        {
12898                                            use std::arch::aarch64::*;
12899                                            let mut acc = vdupq_n_f32(0.0);
12900                                            for c in 0..neon_chunks {
12901                                                let vq =
12902                                                    vld1q_f32(qkv.as_ptr().add(q_base + c * 4));
12903                                                let vk =
12904                                                    vld1q_f32(qkv.as_ptr().add(k_base + c * 4));
12905                                                acc = vfmaq_f32(acc, vq, vk);
12906                                            }
12907                                            dot = vaddvq_f32(acc);
12908                                            for d in (neon_chunks * 4)..d_h {
12909                                                dot += qkv[q_base + d] * qkv[k_base + d];
12910                                            }
12911                                        }
12912                                        #[cfg(not(target_arch = "aarch64"))]
12913                                        for d in 0..d_h {
12914                                            dot += qkv[q_base + d] * qkv[k_base + d];
12915                                        }
12916                                    }
12917
12918                                    scores_buf[qi * s + ki] = dot * scale;
12919                                    // Synthesized position masks (q_offset == 0):
12920                                    //   Causal         → mask future keys ki > qi
12921                                    //   SlidingWindow  → also mask ki + w < qi
12922                                    let pos_masked = match mask_kind {
12923                                        rlx_ir::op::MaskKind::Causal => ki > qi,
12924                                        rlx_ir::op::MaskKind::SlidingWindow(w) => {
12925                                            ki > qi || ki + *w < qi
12926                                        }
12927                                        _ => false,
12928                                    };
12929                                    if pos_masked || (use_custom_mask && mk[bi * s + ki] < mask_thr)
12930                                    {
12931                                        scores_buf[qi * s + ki] = mask_neg;
12932                                    }
12933                                }
12934                            }
12935
12936                            // Softmax
12937                            crate::kernels::neon_softmax(&mut scores_buf[..s * s], s, s);
12938
12939                            // Score @ V accumulation (V at offset 2h in QKV)
12940                            for qi in 0..s {
12941                                let o_base = bi * s * h + qi * h + hi * d_h;
12942                                for d in 0..d_h {
12943                                    attn_out[o_base + d] = 0.0;
12944                                }
12945                                for ki in 0..s {
12946                                    let sc = scores_buf[qi * s + ki];
12947                                    if sc > score_thr {
12948                                        let v_base = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
12949                                        #[cfg(target_arch = "aarch64")]
12950                                        {
12951                                            use std::arch::aarch64::*;
12952                                            let vsc = vdupq_n_f32(sc);
12953                                            for c in 0..neon_chunks {
12954                                                let off = c * 4;
12955                                                let vo =
12956                                                    vld1q_f32(attn_out.as_ptr().add(o_base + off));
12957                                                let vv = vld1q_f32(qkv.as_ptr().add(v_base + off));
12958                                                vst1q_f32(
12959                                                    attn_out.as_mut_ptr().add(o_base + off),
12960                                                    vfmaq_f32(vo, vsc, vv),
12961                                                );
12962                                            }
12963                                        }
12964                                        #[cfg(not(target_arch = "aarch64"))]
12965                                        for d in 0..d_h {
12966                                            attn_out[o_base + d] += sc * qkv[v_base + d];
12967                                        }
12968                                    }
12969                                }
12970                            }
12971                        }
12972                    }
12973
12974                    // 3. Output projection: [m, h] @ [h, h] → dst
12975                    crate::blas::sgemm(&attn_out, wo, dst, m, h, h);
12976                    if *has_bias {
12977                        let bias = sl(*out_b, base, h);
12978                        crate::blas::bias_add(dst, bias, m, h);
12979                    }
12980                }
12981            }
12982
12983            Thunk::Rope {
12984                src,
12985                cos,
12986                sin,
12987                dst,
12988                batch,
12989                seq,
12990                hidden,
12991                head_dim,
12992                n_rot,
12993                cos_len,
12994                src_row_stride,
12995                interleaved,
12996            } => {
12997                let interleaved = *interleaved;
12998                let (b, s, hs, dh, nr) = (
12999                    *batch as usize,
13000                    *seq as usize,
13001                    *hidden as usize,
13002                    *head_dim as usize,
13003                    *n_rot as usize,
13004                );
13005                let tab_half = dh / 2;
13006                let rot_half = nr / 2;
13007                let nh = hs / dh;
13008                let cl = *cos_len as usize;
13009                let src_rs = *src_row_stride as usize;
13010                // Number of rows in the RoPE table. A per-(batch·seq) table
13011                // (`cos_rows == b*s`, distinct from the shared per-seq table) is
13012                // indexed by the *global* token so ragged batched decode can
13013                // give each sequence its own absolute position.
13014                let cos_rows = cl / tab_half.max(1);
13015                let per_token = cos_rows == b * s && cos_rows != s;
13016                unsafe {
13017                    let x = sl(*src, base, b * s * src_rs);
13018                    let cos_tab = sl(*cos, base, cl);
13019                    let sin_tab = sl(*sin, base, cl);
13020                    let out = sl_mut(*dst, base, b * s * hs);
13021
13022                    let total = b * s;
13023                    let x_ptr = x.as_ptr() as usize;
13024                    let o_ptr = out.as_mut_ptr() as usize;
13025                    let c_ptr = cos_tab.as_ptr() as usize;
13026                    let s_ptr = sin_tab.as_ptr() as usize;
13027
13028                    crate::pool::par_for(total, 4, &|off, cnt| {
13029                        for idx in off..off + cnt {
13030                            let bi = idx / s;
13031                            let si = idx % s;
13032                            let tab_off = if per_token { idx } else { si } * tab_half;
13033
13034                            for hi in 0..nh {
13035                                let src_base = bi * s * src_rs + si * src_rs + hi * dh;
13036                                let dst_base = bi * s * hs + si * hs + hi * dh;
13037                                let xp = (x_ptr as *const f32).add(src_base);
13038                                let op = (o_ptr as *mut f32).add(dst_base);
13039                                let cp = (c_ptr as *const f32).add(tab_off);
13040                                let sp = (s_ptr as *const f32).add(tab_off);
13041
13042                                if interleaved {
13043                                    // GPT-J / llama.cpp-NORM: rotate adjacent
13044                                    // pairs (2i, 2i+1) by angle i.
13045                                    for i in 0..rot_half {
13046                                        let x1 = *xp.add(2 * i);
13047                                        let x2 = *xp.add(2 * i + 1);
13048                                        let cv = *cp.add(i);
13049                                        let sv = *sp.add(i);
13050                                        *op.add(2 * i) = x1 * cv - x2 * sv;
13051                                        *op.add(2 * i + 1) = x2 * cv + x1 * sv;
13052                                    }
13053                                } else {
13054                                    // HF / NeoX rotate-half: pair (i, i+rot_half).
13055                                    for i in 0..rot_half {
13056                                        let x1 = *xp.add(i);
13057                                        let x2 = *xp.add(rot_half + i);
13058                                        let cv = *cp.add(i);
13059                                        let sv = *sp.add(i);
13060                                        *op.add(i) = x1 * cv - x2 * sv;
13061                                        *op.add(rot_half + i) = x2 * cv + x1 * sv;
13062                                    }
13063                                }
13064                                for j in nr..dh {
13065                                    *op.add(j) = *xp.add(j);
13066                                }
13067                            }
13068                        }
13069                    });
13070                }
13071            }
13072            Thunk::FusedBertLayer {
13073                hidden,
13074                qkv_w,
13075                qkv_b,
13076                out_w,
13077                out_b,
13078                mask,
13079                ln1_g,
13080                ln1_b,
13081                eps1,
13082                fc1_w,
13083                fc1_b,
13084                fc2_w,
13085                fc2_b,
13086                ln2_g,
13087                ln2_b,
13088                eps2,
13089                out,
13090                batch,
13091                seq,
13092                hs,
13093                nh,
13094                dh,
13095                int_dim,
13096            } => {
13097                let (b, s, h, n_h, d_h) = (
13098                    *batch as usize,
13099                    *seq as usize,
13100                    *hs as usize,
13101                    *nh as usize,
13102                    *dh as usize,
13103                );
13104                let m = b * s;
13105                let id = *int_dim as usize;
13106                let scale = (d_h as f32).powf(-0.5);
13107                let _half = d_h / 2;
13108                #[cfg(target_arch = "aarch64")]
13109                let neon_chunks = d_h / 4;
13110                unsafe {
13111                    let inp = sl(*hidden, base, m * h);
13112                    let dst = sl_mut(*out, base, m * h);
13113                    let mk = sl(*mask, base, b * s);
13114
13115                    // Pre-allocated buffers (zero malloc per layer — allocated once before thunk loop)
13116                    let qkv = std::slice::from_raw_parts_mut(fl_qkv.as_mut_ptr(), m * 3 * h);
13117                    let attn = std::slice::from_raw_parts_mut(fl_attn.as_mut_ptr(), m * h);
13118                    let res = std::slice::from_raw_parts_mut(fl_res.as_mut_ptr(), m * h);
13119                    let normed = std::slice::from_raw_parts_mut(fl_normed.as_mut_ptr(), m * h);
13120                    let ffn = std::slice::from_raw_parts_mut(fl_ffn.as_mut_ptr(), m * id);
13121                    let sc = std::slice::from_raw_parts_mut(fl_sc.as_mut_ptr(), s * s);
13122
13123                    // QKV (parallelized across cores — multiple AMX coprocessors)
13124                    crate::blas::par_sgemm_bias(
13125                        inp,
13126                        sl(*qkv_w, base, h * 3 * h),
13127                        sl(*qkv_b, base, 3 * h),
13128                        qkv,
13129                        m,
13130                        h,
13131                        3 * h,
13132                    );
13133
13134                    // SDPA per head (sequential NEON, inline — zero overhead)
13135                    for bi in 0..b {
13136                        for hi in 0..n_h {
13137                            for qi in 0..s {
13138                                for ki in 0..s {
13139                                    let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
13140                                    let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
13141                                    #[cfg(target_arch = "aarch64")]
13142                                    let dot;
13143                                    #[cfg(not(target_arch = "aarch64"))]
13144                                    let mut dot = 0f32;
13145                                    #[cfg(target_arch = "aarch64")]
13146                                    {
13147                                        use std::arch::aarch64::*;
13148                                        let mut acc = vdupq_n_f32(0.0);
13149                                        for c in 0..neon_chunks {
13150                                            acc = vfmaq_f32(
13151                                                acc,
13152                                                vld1q_f32(qkv.as_ptr().add(q_base + c * 4)),
13153                                                vld1q_f32(qkv.as_ptr().add(k_base + c * 4)),
13154                                            );
13155                                        }
13156                                        dot = vaddvq_f32(acc);
13157                                    }
13158                                    #[cfg(not(target_arch = "aarch64"))]
13159                                    for d in 0..d_h {
13160                                        dot += qkv[q_base + d] * qkv[k_base + d];
13161                                    }
13162                                    sc[qi * s + ki] = dot * scale;
13163                                    if mk[bi * s + ki] < mask_thr {
13164                                        sc[qi * s + ki] = mask_neg;
13165                                    }
13166                                }
13167                            }
13168                            crate::kernels::neon_softmax(&mut sc[..s * s], s, s);
13169                            for qi in 0..s {
13170                                let o = bi * s * h + qi * h + hi * d_h;
13171                                for d in 0..d_h {
13172                                    attn[o + d] = 0.0;
13173                                }
13174                                for ki in 0..s {
13175                                    let w = sc[qi * s + ki];
13176                                    if w > score_thr {
13177                                        let v = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
13178                                        #[cfg(target_arch = "aarch64")]
13179                                        {
13180                                            use std::arch::aarch64::*;
13181                                            let vw = vdupq_n_f32(w);
13182                                            for c in 0..neon_chunks {
13183                                                let off = c * 4;
13184                                                vst1q_f32(
13185                                                    attn.as_mut_ptr().add(o + off),
13186                                                    vfmaq_f32(
13187                                                        vld1q_f32(attn.as_ptr().add(o + off)),
13188                                                        vw,
13189                                                        vld1q_f32(qkv.as_ptr().add(v + off)),
13190                                                    ),
13191                                                );
13192                                            }
13193                                        }
13194                                        #[cfg(not(target_arch = "aarch64"))]
13195                                        for d in 0..d_h {
13196                                            attn[o + d] += w * qkv[v + d];
13197                                        }
13198                                    }
13199                                }
13200                            }
13201                        }
13202                    }
13203
13204                    // Out proj (sgemm + bias fused) + residual add with NEON
13205                    crate::blas::sgemm_bias(
13206                        attn,
13207                        sl(*out_w, base, h * h),
13208                        sl(*out_b, base, h),
13209                        res,
13210                        m,
13211                        h,
13212                        h,
13213                    );
13214                    #[cfg(target_arch = "aarch64")]
13215                    {
13216                        use std::arch::aarch64::*;
13217                        let chunks_h = (m * h) / 4;
13218                        for c in 0..chunks_h {
13219                            let off = c * 4;
13220                            vst1q_f32(
13221                                res.as_mut_ptr().add(off),
13222                                vaddq_f32(
13223                                    vld1q_f32(res.as_ptr().add(off)),
13224                                    vld1q_f32(inp.as_ptr().add(off)),
13225                                ),
13226                            );
13227                        }
13228                        for i in (chunks_h * 4)..(m * h) {
13229                            res[i] += inp[i];
13230                        }
13231                    }
13232                    #[cfg(not(target_arch = "aarch64"))]
13233                    for i in 0..m * h {
13234                        res[i] += inp[i];
13235                    }
13236
13237                    // LN1 (fused residual already done above — just normalize)
13238                    let g1 = sl(*ln1_g, base, h);
13239                    let b1 = sl(*ln1_b, base, h);
13240                    for r in 0..m {
13241                        crate::kernels::layer_norm_row(
13242                            &res[r * h..(r + 1) * h],
13243                            g1,
13244                            b1,
13245                            &mut normed[r * h..(r + 1) * h],
13246                            h,
13247                            *eps1,
13248                        );
13249                    }
13250
13251                    // FFN: fc1 (parallel across cores) + GELU
13252                    crate::blas::par_sgemm_bias(
13253                        normed,
13254                        sl(*fc1_w, base, h * id),
13255                        sl(*fc1_b, base, id),
13256                        ffn,
13257                        m,
13258                        h,
13259                        id,
13260                    );
13261                    crate::kernels::par_gelu_inplace(ffn);
13262
13263                    // fc2 + bias (parallel across cores) + residual with NEON
13264                    crate::blas::par_sgemm_bias(
13265                        ffn,
13266                        sl(*fc2_w, base, id * h),
13267                        sl(*fc2_b, base, h),
13268                        res,
13269                        m,
13270                        id,
13271                        h,
13272                    );
13273                    #[cfg(target_arch = "aarch64")]
13274                    {
13275                        use std::arch::aarch64::*;
13276                        let chunks_h = (m * h) / 4;
13277                        for c in 0..chunks_h {
13278                            let off = c * 4;
13279                            vst1q_f32(
13280                                res.as_mut_ptr().add(off),
13281                                vaddq_f32(
13282                                    vld1q_f32(res.as_ptr().add(off)),
13283                                    vld1q_f32(normed.as_ptr().add(off)),
13284                                ),
13285                            );
13286                        }
13287                        for i in (chunks_h * 4)..(m * h) {
13288                            res[i] += normed[i];
13289                        }
13290                    }
13291                    #[cfg(not(target_arch = "aarch64"))]
13292                    for i in 0..m * h {
13293                        res[i] += normed[i];
13294                    }
13295
13296                    // LN2 → output
13297                    let g2 = sl(*ln2_g, base, h);
13298                    let b2 = sl(*ln2_b, base, h);
13299                    for r in 0..m {
13300                        crate::kernels::layer_norm_row(
13301                            &res[r * h..(r + 1) * h],
13302                            g2,
13303                            b2,
13304                            &mut dst[r * h..(r + 1) * h],
13305                            h,
13306                            *eps2,
13307                        );
13308                    }
13309                }
13310            }
13311
13312            Thunk::FusedNomicLayer {
13313                hidden,
13314                qkv_w,
13315                out_w,
13316                mask,
13317                cos,
13318                sin,
13319                cos_len,
13320                ln1_g,
13321                ln1_b,
13322                eps1,
13323                fc11_w,
13324                fc12_w: _,
13325                fc2_w,
13326                ln2_g,
13327                ln2_b,
13328                eps2,
13329                out,
13330                batch,
13331                seq,
13332                hs,
13333                nh,
13334                dh,
13335                int_dim,
13336                interleaved,
13337            } => {
13338                let interleaved = *interleaved;
13339                let (b, s, h, n_h, d_h) = (
13340                    *batch as usize,
13341                    *seq as usize,
13342                    *hs as usize,
13343                    *nh as usize,
13344                    *dh as usize,
13345                );
13346                let m = b * s;
13347                let id = *int_dim as usize;
13348                let scale = (d_h as f32).powf(-0.5);
13349                let half_dh = d_h / 2;
13350                #[cfg(target_arch = "aarch64")]
13351                let neon_chunks = d_h / 4;
13352                unsafe {
13353                    let inp = sl(*hidden, base, m * h);
13354                    let dst = sl_mut(*out, base, m * h);
13355                    let mk = sl(*mask, base, b * s);
13356                    let cos_tab = sl(*cos, base, *cos_len as usize);
13357                    let sin_tab = sl(*sin, base, *cos_len as usize);
13358                    // fc11_w is the fused [h, 2*int_dim] weight (fc11 || fc12 concatenated)
13359                    let fused_fc_w = sl(*fc11_w, base, h * 2 * id);
13360
13361                    let mut qkv = vec![0f32; m * 3 * h];
13362                    let mut attn = vec![0f32; m * h];
13363                    let mut res = vec![0f32; m * h];
13364                    let mut normed = vec![0f32; m * h];
13365                    let mut ffn_concat = vec![0f32; m * 2 * id]; // fc11||fc12 output
13366                    let mut sc = vec![0f32; s * s];
13367
13368                    // QKV (no bias)
13369                    crate::blas::sgemm(inp, sl(*qkv_w, base, h * 3 * h), &mut qkv, m, h, 3 * h);
13370
13371                    // SDPA with inline RoPE
13372                    for bi in 0..b {
13373                        for hi in 0..n_h {
13374                            for qi in 0..s {
13375                                for ki in 0..s {
13376                                    let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
13377                                    let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
13378                                    let mut dot = 0f32;
13379                                    for i in 0..half_dh {
13380                                        // NeoX pairs (i, i+half); GPT-J pairs (2i, 2i+1).
13381                                        let (o1, o2) = if interleaved {
13382                                            (2 * i, 2 * i + 1)
13383                                        } else {
13384                                            (i, half_dh + i)
13385                                        };
13386                                        let q1 = qkv[q_base + o1];
13387                                        let q2 = qkv[q_base + o2];
13388                                        let k1 = qkv[k_base + o1];
13389                                        let k2 = qkv[k_base + o2];
13390                                        let cq = cos_tab[qi * half_dh + i];
13391                                        let sq = sin_tab[qi * half_dh + i];
13392                                        let ck = cos_tab[ki * half_dh + i];
13393                                        let sk = sin_tab[ki * half_dh + i];
13394                                        dot += (q1 * cq - q2 * sq) * (k1 * ck - k2 * sk)
13395                                            + (q2 * cq + q1 * sq) * (k2 * ck + k1 * sk);
13396                                    }
13397                                    sc[qi * s + ki] = dot * scale;
13398                                    if mk[bi * s + ki] < mask_thr {
13399                                        sc[qi * s + ki] = mask_neg;
13400                                    }
13401                                }
13402                            }
13403                            crate::kernels::neon_softmax(&mut sc[..s * s], s, s);
13404                            for qi in 0..s {
13405                                let o = bi * s * h + qi * h + hi * d_h;
13406                                for d in 0..d_h {
13407                                    attn[o + d] = 0.0;
13408                                }
13409                                for ki in 0..s {
13410                                    let w = sc[qi * s + ki];
13411                                    if w > score_thr {
13412                                        let v = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
13413                                        #[cfg(target_arch = "aarch64")]
13414                                        {
13415                                            use std::arch::aarch64::*;
13416                                            let vw = vdupq_n_f32(w);
13417                                            for c in 0..neon_chunks {
13418                                                let off = c * 4;
13419                                                vst1q_f32(
13420                                                    attn.as_mut_ptr().add(o + off),
13421                                                    vfmaq_f32(
13422                                                        vld1q_f32(attn.as_ptr().add(o + off)),
13423                                                        vw,
13424                                                        vld1q_f32(qkv.as_ptr().add(v + off)),
13425                                                    ),
13426                                                );
13427                                            }
13428                                        }
13429                                        #[cfg(not(target_arch = "aarch64"))]
13430                                        for d in 0..d_h {
13431                                            attn[o + d] += w * qkv[v + d];
13432                                        }
13433                                    }
13434                                }
13435                            }
13436                        }
13437                    }
13438
13439                    // Out proj (no bias) + residual
13440                    crate::blas::sgemm(&attn, sl(*out_w, base, h * h), &mut res, m, h, h);
13441                    for i in 0..m * h {
13442                        res[i] += inp[i];
13443                    }
13444
13445                    // LN1
13446                    let g1 = sl(*ln1_g, base, h);
13447                    let b1 = sl(*ln1_b, base, h);
13448                    for r in 0..m {
13449                        crate::kernels::layer_norm_row(
13450                            &res[r * h..(r + 1) * h],
13451                            g1,
13452                            b1,
13453                            &mut normed[r * h..(r + 1) * h],
13454                            h,
13455                            *eps1,
13456                        );
13457                    }
13458
13459                    // SwiGLU: fused fc11+fc12 sgemm, then split, silu, mul
13460                    crate::blas::sgemm(&normed, fused_fc_w, &mut ffn_concat, m, h, 2 * id);
13461                    // Split: first id cols = fc11 (up), second id cols = fc12 (gate)
13462                    // SiLU on gate, then multiply up * gate → store in up region
13463                    for row in 0..m {
13464                        let bo = row * 2 * id;
13465                        // SiLU in-place on gate portion
13466                        for j in 0..id {
13467                            let x = ffn_concat[bo + id + j];
13468                            ffn_concat[bo + id + j] = x / (1.0 + (-x).exp());
13469                        }
13470                        // Multiply: up[j] *= gate[j]
13471                        for j in 0..id {
13472                            ffn_concat[bo + j] *= ffn_concat[bo + id + j];
13473                        }
13474                    }
13475
13476                    // fc2 (no bias) + residual. The up*silu(gate) product lives in
13477                    // the FIRST `id` cols of each 2*id-wide ffn_concat row; gather
13478                    // it contiguous and run the SAME `sgemm` dispatch the unfused
13479                    // path uses (a strided `sgemm_general` here would force the
13480                    // BLAS/scalar path and diverge from the unfused NEON sgemm).
13481                    let mut swiglu_contig = vec![0f32; m * id];
13482                    for row in 0..m {
13483                        let bo = row * 2 * id;
13484                        swiglu_contig[row * id..(row + 1) * id]
13485                            .copy_from_slice(&ffn_concat[bo..bo + id]);
13486                    }
13487                    crate::blas::sgemm(
13488                        &swiglu_contig,
13489                        sl(*fc2_w, base, id * h),
13490                        &mut res,
13491                        m,
13492                        id,
13493                        h,
13494                    );
13495                    for i in 0..m * h {
13496                        res[i] += normed[i];
13497                    }
13498
13499                    // LN2 → output
13500                    let g2 = sl(*ln2_g, base, h);
13501                    let b2 = sl(*ln2_b, base, h);
13502                    for r in 0..m {
13503                        crate::kernels::layer_norm_row(
13504                            &res[r * h..(r + 1) * h],
13505                            g2,
13506                            b2,
13507                            &mut dst[r * h..(r + 1) * h],
13508                            h,
13509                            *eps2,
13510                        );
13511                    }
13512                }
13513            }
13514
13515            Thunk::FusedSwiGLU {
13516                src,
13517                dst,
13518                n_half,
13519                total,
13520                gate_first,
13521            } => {
13522                let n = *n_half as usize;
13523                let t = *total as usize;
13524                let outer = t / n;
13525                let in_total = outer * 2 * n;
13526                let gate_first = *gate_first;
13527                unsafe {
13528                    let inp = sl(*src, base, in_total);
13529                    let out = sl_mut(*dst, base, t);
13530                    for o in 0..outer {
13531                        let in_row = &inp[o * 2 * n..(o + 1) * 2 * n];
13532                        let out_row = &mut out[o * n..(o + 1) * n];
13533                        for i in 0..n {
13534                            let (up, gate) = if gate_first {
13535                                (in_row[n + i], in_row[i])
13536                            } else {
13537                                (in_row[i], in_row[n + i])
13538                            };
13539                            out_row[i] = up * (gate / (1.0 + (-gate).exp()));
13540                        }
13541                    }
13542                }
13543            }
13544
13545            Thunk::Concat {
13546                dst,
13547                outer,
13548                inner,
13549                total_axis,
13550                inputs,
13551            } => {
13552                let outer = *outer as usize;
13553                let inner = *inner as usize;
13554                let total_axis = *total_axis as usize;
13555                let row_stride = total_axis * inner;
13556                let out_total = outer * row_stride;
13557                unsafe {
13558                    let out = sl_mut(*dst, base, out_total);
13559                    let mut cum: usize = 0;
13560                    for (src_off, in_axis, in_numel) in inputs {
13561                        let in_axis = *in_axis as usize;
13562                        let copy_per_row = in_axis * inner;
13563                        let dst_col_off = cum * inner;
13564                        let inp = sl(*src_off, base, (*in_numel as usize).max(1));
13565                        concat_copy_rows_f32(
13566                            out,
13567                            inp,
13568                            outer,
13569                            copy_per_row,
13570                            row_stride,
13571                            dst_col_off,
13572                            *in_numel as usize,
13573                        );
13574                        cum += in_axis;
13575                    }
13576                }
13577            }
13578
13579            Thunk::ConcatF64 {
13580                dst,
13581                outer,
13582                inner,
13583                total_axis,
13584                inputs,
13585            } => {
13586                let outer = *outer as usize;
13587                let inner = *inner as usize;
13588                let total_axis = *total_axis as usize;
13589                let row_stride = total_axis * inner;
13590                let out_total = outer * row_stride;
13591                unsafe {
13592                    let out = sl_mut_f64(*dst, base, out_total);
13593                    let mut cum: usize = 0;
13594                    for (src_off, in_axis, in_numel) in inputs {
13595                        let in_axis = *in_axis as usize;
13596                        let copy_per_row = in_axis * inner;
13597                        let dst_col_off = cum * inner;
13598                        let inp = sl_f64(*src_off, base, (*in_numel as usize).max(1));
13599                        concat_copy_rows_f64(
13600                            out,
13601                            inp,
13602                            outer,
13603                            copy_per_row,
13604                            row_stride,
13605                            dst_col_off,
13606                            *in_numel as usize,
13607                        );
13608                        cum += in_axis;
13609                    }
13610                }
13611            }
13612
13613            Thunk::Compare {
13614                lhs,
13615                rhs,
13616                dst,
13617                len,
13618                op,
13619                inputs_i64,
13620                inputs_elem_bytes,
13621                dst_elem_bytes,
13622            } => {
13623                let len = *len as usize;
13624                let arena_len = arena_buf.len();
13625                let elem = (*inputs_elem_bytes).max(1) as usize;
13626                let dst_eb = (*dst_elem_bytes).max(1) as usize;
13627                let max_l = (arena_len.saturating_sub(*lhs)) / elem;
13628                let max_r = (arena_len.saturating_sub(*rhs)) / elem;
13629                let max_d = (arena_len.saturating_sub(*dst)) / dst_eb;
13630                let len = len.min(max_l).min(max_r).min(max_d);
13631                if trace_thunks && len > 0 {
13632                    eprintln!("[compare] len={len} lhs={} rhs={} dst={}", *lhs, *rhs, *dst);
13633                }
13634                if elem == 1 {
13635                    let l = arena_buf[*lhs..*lhs + len].to_vec();
13636                    let r = arena_buf[*rhs..*rhs + len].to_vec();
13637                    for i in 0..len {
13638                        let v = match op {
13639                            CmpOp::Eq => l[i] == r[i],
13640                            CmpOp::Ne => l[i] != r[i],
13641                            CmpOp::Lt => l[i] < r[i],
13642                            CmpOp::Le => l[i] <= r[i],
13643                            CmpOp::Gt => l[i] > r[i],
13644                            CmpOp::Ge => l[i] >= r[i],
13645                        };
13646                        if *dst_elem_bytes == 1 {
13647                            arena_buf[*dst + i] = u8::from(v);
13648                        } else {
13649                            unsafe {
13650                                let o = sl_mut(*dst, base, len);
13651                                o[i] = if v { 1.0 } else { 0.0 };
13652                            }
13653                        }
13654                    }
13655                } else if *inputs_i64 != 0 {
13656                    unsafe {
13657                        let l = sl_i64(*lhs, base, len);
13658                        let r = sl_i64(*rhs, base, len);
13659                        for i in 0..len {
13660                            let v = match op {
13661                                CmpOp::Eq => l[i] == r[i],
13662                                CmpOp::Ne => l[i] != r[i],
13663                                CmpOp::Lt => l[i] < r[i],
13664                                CmpOp::Le => l[i] <= r[i],
13665                                CmpOp::Gt => l[i] > r[i],
13666                                CmpOp::Ge => l[i] >= r[i],
13667                            };
13668                            if *dst_elem_bytes == 1 {
13669                                arena_buf[*dst + i] = u8::from(v);
13670                            } else {
13671                                let o = sl_mut(*dst, base, len);
13672                                o[i] = if v { 1.0 } else { 0.0 };
13673                            }
13674                        }
13675                    }
13676                } else {
13677                    unsafe {
13678                        let l = sl(*lhs, base, len);
13679                        let r = sl(*rhs, base, len);
13680                        for i in 0..len {
13681                            let v = match op {
13682                                CmpOp::Eq => l[i] == r[i],
13683                                CmpOp::Ne => l[i] != r[i],
13684                                CmpOp::Lt => l[i] < r[i],
13685                                CmpOp::Le => l[i] <= r[i],
13686                                CmpOp::Gt => l[i] > r[i],
13687                                CmpOp::Ge => l[i] >= r[i],
13688                            };
13689                            if *dst_elem_bytes == 1 {
13690                                arena_buf[*dst + i] = u8::from(v);
13691                            } else {
13692                                let o = sl_mut(*dst, base, len);
13693                                o[i] = if v { 1.0 } else { 0.0 };
13694                            }
13695                        }
13696                    }
13697                }
13698            }
13699
13700            Thunk::Where {
13701                cond,
13702                on_true,
13703                on_false,
13704                dst,
13705                len,
13706                elem_bytes,
13707                cond_elem_bytes,
13708            } => {
13709                let len = *len as usize;
13710                let eb = *elem_bytes as usize;
13711                let cond_eb = (*cond_elem_bytes).max(1) as usize;
13712                let arena_len = arena_buf.len();
13713                let len = len
13714                    .min((arena_len.saturating_sub(*cond)) / cond_eb)
13715                    .min((arena_len.saturating_sub(*on_true)) / eb)
13716                    .min((arena_len.saturating_sub(*on_false)) / eb)
13717                    .min((arena_len.saturating_sub(*dst)) / eb);
13718                unsafe {
13719                    if *elem_bytes == 8 {
13720                        let t = sl_i64(*on_true, base, len);
13721                        let e = sl_i64(*on_false, base, len);
13722                        let o = sl_mut_i64(*dst, base, len);
13723                        if *cond_elem_bytes == 1 {
13724                            let c = &arena_buf[*cond..*cond + len];
13725                            for i in 0..len {
13726                                o[i] = if c[i] != 0 { t[i] } else { e[i] };
13727                            }
13728                        } else {
13729                            let c = sl_i64(*cond, base, len);
13730                            for i in 0..len {
13731                                o[i] = if c[i] != 0 { t[i] } else { e[i] };
13732                            }
13733                        }
13734                    } else if *cond_elem_bytes == 1 {
13735                        let c = &arena_buf[*cond..*cond + len];
13736                        let t = sl(*on_true, base, len);
13737                        let e = sl(*on_false, base, len);
13738                        let o = sl_mut(*dst, base, len);
13739                        for i in 0..len {
13740                            o[i] = if c[i] != 0 { t[i] } else { e[i] };
13741                        }
13742                    } else {
13743                        let c = sl(*cond, base, len);
13744                        let t = sl(*on_true, base, len);
13745                        let e = sl(*on_false, base, len);
13746                        let o = sl_mut(*dst, base, len);
13747                        for i in 0..len {
13748                            o[i] = if c[i] != 0.0 { t[i] } else { e[i] };
13749                        }
13750                    }
13751                }
13752            }
13753
13754            Thunk::Fma {
13755                a,
13756                b,
13757                c,
13758                dst,
13759                len,
13760                elem_bytes,
13761            } => {
13762                let len = *len as usize;
13763                let eb = (*elem_bytes).max(1) as usize;
13764                let arena_len = arena_buf.len();
13765                let len = len
13766                    .min(arena_len.saturating_sub(*a) / eb)
13767                    .min(arena_len.saturating_sub(*b) / eb)
13768                    .min(arena_len.saturating_sub(*c) / eb)
13769                    .min(arena_len.saturating_sub(*dst) / eb);
13770                unsafe {
13771                    if *elem_bytes == 8 {
13772                        let av = sl_f64(*a, base, len);
13773                        let bv = sl_f64(*b, base, len);
13774                        let cv = sl_f64(*c, base, len);
13775                        let o = sl_mut_f64(*dst, base, len);
13776                        for i in 0..len {
13777                            o[i] = av[i].mul_add(bv[i], cv[i]);
13778                        }
13779                    } else {
13780                        let av = sl(*a, base, len);
13781                        let bv = sl(*b, base, len);
13782                        let cv = sl(*c, base, len);
13783                        let o = sl_mut(*dst, base, len);
13784                        for i in 0..len {
13785                            o[i] = av[i].mul_add(bv[i], cv[i]);
13786                        }
13787                    }
13788                }
13789            }
13790
13791            Thunk::ScatterAdd {
13792                updates,
13793                indices,
13794                dst,
13795                num_updates,
13796                out_dim,
13797                trailing,
13798            } => {
13799                let num_updates = *num_updates as usize;
13800                let out_dim = *out_dim as usize;
13801                let trailing = *trailing as usize;
13802                unsafe {
13803                    let upd = sl(*updates, base, num_updates * trailing);
13804                    let ids = sl(*indices, base, num_updates);
13805                    let out = sl_mut(*dst, base, out_dim * trailing);
13806                    // Zero the output first — semantics are accumulate-into-zeros.
13807                    for v in out.iter_mut() {
13808                        *v = 0.0;
13809                    }
13810                    for i in 0..num_updates {
13811                        let row = ids[i] as usize;
13812                        debug_assert!(row < out_dim, "ScatterAdd index out of range");
13813                        let src_off = i * trailing;
13814                        let dst_off = row * trailing;
13815                        for j in 0..trailing {
13816                            out[dst_off + j] += upd[src_off + j];
13817                        }
13818                    }
13819                }
13820            }
13821
13822            Thunk::GroupedMatMul {
13823                input,
13824                weight,
13825                expert_idx,
13826                dst,
13827                m,
13828                k_dim,
13829                n,
13830                num_experts,
13831            } => {
13832                let m = *m as usize;
13833                let k_dim = *k_dim as usize;
13834                let n = *n as usize;
13835                let num_experts = *num_experts as usize;
13836                unsafe {
13837                    let inp = sl(*input, base, m * k_dim);
13838                    let wt = sl(*weight, base, num_experts * k_dim * n);
13839                    let ids = sl(*expert_idx, base, m);
13840                    let out = sl_mut(*dst, base, m * n);
13841
13842                    // Counting-sort tokens by their assigned expert.
13843                    // counts[e] = how many tokens routed to expert e.
13844                    let mut counts = vec![0usize; num_experts];
13845                    for i in 0..m {
13846                        let e = ids[i] as usize;
13847                        debug_assert!(
13848                            e < num_experts,
13849                            "expert_idx out of range: {e} >= {num_experts}"
13850                        );
13851                        counts[e] += 1;
13852                    }
13853                    // Cumulative offsets into the packed buffer.
13854                    let mut offsets = vec![0usize; num_experts + 1];
13855                    for e in 0..num_experts {
13856                        offsets[e + 1] = offsets[e] + counts[e];
13857                    }
13858                    // Pack: each expert's rows land contiguously in `packed_in`.
13859                    // `original_pos[packed_idx] = original_token_idx` for the
13860                    // unpermute step at the end.
13861                    let mut packed_in = vec![0f32; m * k_dim];
13862                    let mut original_pos = vec![0usize; m];
13863                    let mut write_idx = vec![0usize; num_experts];
13864                    for i in 0..m {
13865                        let e = ids[i] as usize;
13866                        let dst_row = offsets[e] + write_idx[e];
13867                        packed_in[dst_row * k_dim..(dst_row + 1) * k_dim]
13868                            .copy_from_slice(&inp[i * k_dim..(i + 1) * k_dim]);
13869                        original_pos[dst_row] = i;
13870                        write_idx[e] += 1;
13871                    }
13872
13873                    // One BLAS sgemm per expert. Skip experts with no
13874                    // tokens — common at the tail when M is much smaller
13875                    // than num_experts × k.
13876                    let mut packed_out = vec![0f32; m * n];
13877                    let expert_stride = k_dim * n;
13878                    let gmm_ord = crate::moe_residency::next_gmm_ord();
13879                    let moe_layer = gmm_ord / 3;
13880                    for e in 0..num_experts {
13881                        let count = counts[e];
13882                        if count == 0 {
13883                            continue;
13884                        }
13885                        crate::moe_residency::record_expert_tokens(moe_layer, e, count);
13886                        let in_start = offsets[e];
13887                        let in_slice = &packed_in[in_start * k_dim..(in_start + count) * k_dim];
13888                        let w_slab: &[f32] =
13889                            if !crate::moe_residency::expert_on_device_for_layer(moe_layer, e) {
13890                                if let Some(ptr) =
13891                                    crate::moe_residency::host_expert_weight_ptr(gmm_ord, e)
13892                                {
13893                                    std::slice::from_raw_parts(ptr, expert_stride)
13894                                } else {
13895                                    &wt[e * expert_stride..(e + 1) * expert_stride]
13896                                }
13897                            } else {
13898                                &wt[e * expert_stride..(e + 1) * expert_stride]
13899                            };
13900                        let out_slice = &mut packed_out[in_start * n..(in_start + count) * n];
13901                        crate::blas::sgemm(in_slice, w_slab, out_slice, count, k_dim, n);
13902                    }
13903
13904                    // Unpermute back to original token order.
13905                    for packed_idx in 0..m {
13906                        let i = original_pos[packed_idx];
13907                        out[i * n..(i + 1) * n]
13908                            .copy_from_slice(&packed_out[packed_idx * n..(packed_idx + 1) * n]);
13909                    }
13910                }
13911            }
13912
13913            Thunk::DequantGroupedMatMulGguf {
13914                input,
13915                w_q,
13916                expert_idx,
13917                dst,
13918                m,
13919                k_dim,
13920                n,
13921                num_experts,
13922                scheme,
13923            } => {
13924                let m = *m as usize;
13925                let k_dim = *k_dim as usize;
13926                let n = *n as usize;
13927                let num_experts = *num_experts as usize;
13928                let block_elems = scheme.gguf_block_size() as usize;
13929                let block_bytes = scheme.gguf_block_bytes() as usize;
13930                let slab_bytes = (k_dim * n) / block_elems * block_bytes;
13931                unsafe {
13932                    let inp = sl(*input, base, m * k_dim);
13933                    let wt = std::slice::from_raw_parts(
13934                        base.add(*w_q) as *const u8,
13935                        num_experts * slab_bytes,
13936                    );
13937                    let ids = sl(*expert_idx, base, m);
13938                    let out = sl_mut(*dst, base, m * n);
13939                    crate::gguf_matmul::gguf_grouped_matmul_bt(
13940                        inp,
13941                        wt,
13942                        ids,
13943                        out,
13944                        m,
13945                        k_dim,
13946                        n,
13947                        num_experts,
13948                        *scheme,
13949                    );
13950                }
13951            }
13952
13953            Thunk::DequantMoEWeightsGguf {
13954                w_q,
13955                dst,
13956                k_dim,
13957                n,
13958                num_experts,
13959                scheme,
13960            } => {
13961                let k_dim = *k_dim as usize;
13962                let n = *n as usize;
13963                let num_experts = *num_experts as usize;
13964                let block_elems = scheme.gguf_block_size() as usize;
13965                let block_bytes = scheme.gguf_block_bytes() as usize;
13966                let slab_bytes = (k_dim * n) / block_elems * block_bytes;
13967                unsafe {
13968                    let wt = std::slice::from_raw_parts(
13969                        base.add(*w_q) as *const u8,
13970                        num_experts * slab_bytes,
13971                    );
13972                    let out = sl_mut(*dst, base, num_experts * k_dim * n);
13973                    crate::gguf_matmul::dequant_moe_weights_to_grouped_f32(
13974                        wt,
13975                        out,
13976                        num_experts,
13977                        k_dim,
13978                        n,
13979                        *scheme,
13980                    );
13981                }
13982            }
13983
13984            Thunk::TopK {
13985                src,
13986                dst,
13987                outer,
13988                axis_dim,
13989                k,
13990                indices_i64,
13991            } => {
13992                let outer = *outer as usize;
13993                let axis_dim = *axis_dim as usize;
13994                let k = *k as usize;
13995                unsafe {
13996                    let inp = sl(*src, base, outer * axis_dim);
13997                    // Repeated argmax with masking. O(k * axis_dim) per row;
13998                    // good enough for small k (MoE typical k=2–8). For larger
13999                    // k a partial heap would win.
14000                    let mut row_buf: Vec<f32> = vec![0.0; axis_dim];
14001                    if *indices_i64 != 0 {
14002                        let out = sl_mut_i64(*dst, base, outer * k);
14003                        for o in 0..outer {
14004                            row_buf.copy_from_slice(&inp[o * axis_dim..(o + 1) * axis_dim]);
14005                            for ki in 0..k {
14006                                let mut best_i = 0usize;
14007                                let mut best_v = row_buf[0];
14008                                for i in 1..axis_dim {
14009                                    let v = row_buf[i];
14010                                    if v > best_v {
14011                                        best_v = v;
14012                                        best_i = i;
14013                                    }
14014                                }
14015                                out[o * k + ki] = best_i as i64;
14016                                row_buf[best_i] = f32::NEG_INFINITY;
14017                            }
14018                        }
14019                    } else {
14020                        let out = sl_mut(*dst, base, outer * k);
14021                        for o in 0..outer {
14022                            row_buf.copy_from_slice(&inp[o * axis_dim..(o + 1) * axis_dim]);
14023                            for ki in 0..k {
14024                                let mut best_i = 0usize;
14025                                let mut best_v = row_buf[0];
14026                                for i in 1..axis_dim {
14027                                    let v = row_buf[i];
14028                                    if v > best_v {
14029                                        best_v = v;
14030                                        best_i = i;
14031                                    }
14032                                }
14033                                out[o * k + ki] = best_i as f32;
14034                                row_buf[best_i] = f32::NEG_INFINITY;
14035                            }
14036                        }
14037                        if let Some(cap) = schedule.moe_topk_capture.as_ref() {
14038                            cap.push_topk_f32(&out[..outer * k], axis_dim);
14039                        }
14040                    }
14041                }
14042            }
14043
14044            Thunk::Reduce {
14045                src,
14046                dst,
14047                outer,
14048                reduced,
14049                inner,
14050                op,
14051            } => {
14052                let outer = *outer as usize;
14053                let reduced = *reduced as usize;
14054                let inner = *inner as usize;
14055                let in_total = outer * reduced * inner;
14056                let out_total = outer * inner;
14057                unsafe {
14058                    let inp = sl(*src, base, in_total);
14059                    let out = sl_mut(*dst, base, out_total);
14060                    // Each output element reduces a disjoint strided strip, so
14061                    // the output range parallelizes (the bias-gradient reductions
14062                    // read the big [N,C,H,W] tensors down to [C]; that's C-way).
14063                    let reduce_one = |oi: usize| -> f32 {
14064                        let o = oi / inner;
14065                        let i = oi % inner;
14066                        let mut acc = match op {
14067                            ReduceOp::Max => f32::NEG_INFINITY,
14068                            ReduceOp::Min => f32::INFINITY,
14069                            ReduceOp::Prod => 1.0f32,
14070                            _ => 0.0f32, // Sum / Mean
14071                        };
14072                        for r in 0..reduced {
14073                            let v = inp[o * reduced * inner + r * inner + i];
14074                            acc = match op {
14075                                ReduceOp::Sum | ReduceOp::Mean => acc + v,
14076                                ReduceOp::Max => acc.max(v),
14077                                ReduceOp::Min => acc.min(v),
14078                                ReduceOp::Prod => acc * v,
14079                            };
14080                        }
14081                        if matches!(op, ReduceOp::Mean) {
14082                            acc /= reduced as f32;
14083                        }
14084                        acc
14085                    };
14086                    if fast_conv_enabled()
14087                        && crate::pool::should_parallelize(in_total)
14088                        && out_total > 1
14089                    {
14090                        let out_addr = out.as_mut_ptr() as usize;
14091                        crate::pool::par_for(
14092                            out_total,
14093                            crate::pool::outer_chunk(out_total),
14094                            &|off, cnt| {
14095                                for oi in off..off + cnt {
14096                                    *((out_addr as *mut f32).add(oi)) = reduce_one(oi);
14097                                }
14098                            },
14099                        );
14100                    } else {
14101                        for oi in 0..out_total {
14102                            out[oi] = reduce_one(oi);
14103                        }
14104                    }
14105                }
14106            }
14107
14108            Thunk::ArgReduce {
14109                src,
14110                dst,
14111                outer,
14112                reduced,
14113                inner,
14114                is_max,
14115            } => {
14116                let outer = *outer as usize;
14117                let reduced = *reduced as usize;
14118                let inner = *inner as usize;
14119                let in_total = outer * reduced * inner;
14120                let out_total = outer * inner;
14121                unsafe {
14122                    let inp = sl(*src, base, in_total);
14123                    let out = sl_mut(*dst, base, out_total);
14124                    for o in 0..outer {
14125                        for i in 0..inner {
14126                            let mut best = inp[o * reduced * inner + i];
14127                            let mut best_idx = 0usize;
14128                            for r in 1..reduced {
14129                                let v = inp[o * reduced * inner + r * inner + i];
14130                                let better = if *is_max { v > best } else { v < best };
14131                                if better {
14132                                    best = v;
14133                                    best_idx = r;
14134                                }
14135                            }
14136                            out[o * inner + i] = best_idx as f32;
14137                        }
14138                    }
14139                }
14140            }
14141
14142            Thunk::Conv2D1x1 {
14143                src,
14144                weight,
14145                dst,
14146                n,
14147                c_in,
14148                c_out,
14149                hw,
14150            } => {
14151                let n = *n as usize;
14152                let c_in = *c_in as usize;
14153                let c_out = *c_out as usize;
14154                let hw = *hw as usize;
14155                unsafe {
14156                    let inp = sl(*src, base, n * c_in * hw);
14157                    let wt = sl(*weight, base, c_out * c_in);
14158                    let out = sl_mut(*dst, base, n * c_out * hw);
14159                    // Per-batch sgemm: weight [c_out, c_in] @ input
14160                    // [c_in, hw] = output [c_out, hw]. The weight is
14161                    // shared across batches, so we get to dispatch
14162                    // BLAS once per N (typically 1).
14163                    for ni in 0..n {
14164                        let in_off = ni * c_in * hw;
14165                        let out_off = ni * c_out * hw;
14166                        crate::blas::sgemm(
14167                            wt,
14168                            &inp[in_off..in_off + c_in * hw],
14169                            &mut out[out_off..out_off + c_out * hw],
14170                            c_out,
14171                            c_in,
14172                            hw,
14173                        );
14174                    }
14175                }
14176            }
14177
14178            Thunk::Conv2D {
14179                src,
14180                weight,
14181                dst,
14182                n,
14183                c_in,
14184                h,
14185                w,
14186                c_out,
14187                h_out,
14188                w_out,
14189                kh,
14190                kw,
14191                sh,
14192                sw,
14193                ph,
14194                pw,
14195                dh,
14196                dw,
14197                groups,
14198            } => {
14199                let n = *n as usize;
14200                let c_in = *c_in as usize;
14201                let h = *h as usize;
14202                let w = *w as usize;
14203                let c_out = *c_out as usize;
14204                let h_out = *h_out as usize;
14205                let w_out = *w_out as usize;
14206                let kh = *kh as usize;
14207                let kw = *kw as usize;
14208                let sh = *sh as usize;
14209                let sw = *sw as usize;
14210                let ph = *ph as usize;
14211                let pw = *pw as usize;
14212                let dh = *dh as usize;
14213                let dw = *dw as usize;
14214                let groups = *groups as usize;
14215                let c_in_per_g = c_in / groups;
14216                unsafe {
14217                    let inp = sl(*src, base, n * c_in * h * w);
14218                    let wt = sl(*weight, base, c_out * c_in_per_g * kh * kw);
14219                    let out = sl_mut(*dst, base, n * c_out * h_out * w_out);
14220                    // Forward conv has two interchangeable kernels:
14221                    //   * a reference scalar nested loop (default), and
14222                    //   * im2col + BLAS sgemm, enabled with RLX_FAST_CONV=1.
14223                    // The fast path mirrors Conv2D1x1 / Conv2dBackwardWeight:
14224                    // gather patches per (batch, group) then dispatch one
14225                    // sgemm. Results match up to float reassociation.
14226                    // Eligibility: stride-1, no padding, dilation-1 — the direct
14227                    // kernel's no-bounds SAXPY form (and Winograd's tiling).
14228                    let s1_nopad = sh == 1 && sw == 1 && ph == 0 && pw == 0 && dh == 1 && dw == 1;
14229                    let winograd_ok = s1_nopad && kh == 3 && kw == 3 && groups == 1;
14230                    // im2col+BLAS is the measured CPU optimum for these shapes;
14231                    // Winograd (RLX_WINOGRAD) and the direct kernel
14232                    // (RLX_DIRECT_CONV) are opt-in alternatives that win only at
14233                    // higher channel counts — both measured slower on TinyConv.
14234                    if fast_conv_enabled() && winograd_enabled() && winograd_ok {
14235                        conv2d_forward_winograd(inp, wt, out, n, c_in, h, w, c_out, h_out, w_out);
14236                    } else if fast_conv_enabled() && direct_conv_enabled() && s1_nopad {
14237                        conv2d_forward_direct(
14238                            inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, groups,
14239                        );
14240                    } else if fast_conv_enabled() {
14241                        conv2d_forward_im2col(
14242                            inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, sh, sw, ph,
14243                            pw, dh, dw, groups,
14244                        );
14245                    } else {
14246                        conv2d_forward_naive(
14247                            inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, sh, sw, ph,
14248                            pw, dh, dw, groups,
14249                        );
14250                    }
14251                }
14252            }
14253
14254            Thunk::Pool2D {
14255                src,
14256                dst,
14257                n,
14258                c,
14259                h,
14260                w,
14261                h_out,
14262                w_out,
14263                kh,
14264                kw,
14265                sh,
14266                sw,
14267                ph,
14268                pw,
14269                kind,
14270            } => {
14271                let n = *n as usize;
14272                let c = *c as usize;
14273                let h = *h as usize;
14274                let w = *w as usize;
14275                let h_out = *h_out as usize;
14276                let w_out = *w_out as usize;
14277                let kh = *kh as usize;
14278                let kw = *kw as usize;
14279                let sh = *sh as usize;
14280                let sw = *sw as usize;
14281                let ph = *ph as usize;
14282                let pw = *pw as usize;
14283                let kernel_area = (kh * kw) as f32;
14284                unsafe {
14285                    let inp = sl(*src, base, n * c * h * w);
14286                    let out = sl_mut(*dst, base, n * c * h_out * w_out);
14287                    // Each (n, c) plane is independent and writes a disjoint
14288                    // output region, so pooling fans out over the channel-batch
14289                    // when RLX_FAST_CONV is set.
14290                    let out_addr = out.as_mut_ptr() as usize;
14291                    let is_max = matches!(kind, ReduceOp::Max);
14292                    let is_mean = matches!(kind, ReduceOp::Mean);
14293                    // No-padding windows (the conv-net case) are always fully
14294                    // in-bounds, so the hot path drops the per-element bounds
14295                    // branches and hoists the reduce-op choice out of the loop.
14296                    let nopad = ph == 0 && pw == 0;
14297                    let pool_plane = |nc: usize| {
14298                        let ni = nc / c;
14299                        let ci = nc % c;
14300                        let in_chan = ni * c * h * w + ci * h * w;
14301                        let out_chan = ni * c * h_out * w_out + ci * h_out * w_out;
14302                        let op = out_addr as *mut f32;
14303                        for ho in 0..h_out {
14304                            for wo in 0..w_out {
14305                                let acc = if nopad {
14306                                    let row0 = in_chan + (ho * sh) * w + wo * sw;
14307                                    let mut a = if is_max { f32::NEG_INFINITY } else { 0.0 };
14308                                    for ki in 0..kh {
14309                                        let row = row0 + ki * w;
14310                                        if is_max {
14311                                            for kj in 0..kw {
14312                                                a = a.max(inp[row + kj]);
14313                                            }
14314                                        } else {
14315                                            for kj in 0..kw {
14316                                                a += inp[row + kj];
14317                                            }
14318                                        }
14319                                    }
14320                                    a
14321                                } else {
14322                                    let mut a = if is_max { f32::NEG_INFINITY } else { 0.0 };
14323                                    for ki in 0..kh {
14324                                        for kj in 0..kw {
14325                                            let hi = ho * sh + ki;
14326                                            let wi = wo * sw + kj;
14327                                            if hi < ph || wi < pw {
14328                                                continue;
14329                                            }
14330                                            let hi = hi - ph;
14331                                            let wi = wi - pw;
14332                                            if hi >= h || wi >= w {
14333                                                continue;
14334                                            }
14335                                            let v = inp[in_chan + hi * w + wi];
14336                                            if is_max {
14337                                                a = a.max(v);
14338                                            } else {
14339                                                a += v;
14340                                            }
14341                                        }
14342                                    }
14343                                    a
14344                                };
14345                                let acc = if is_mean { acc / kernel_area } else { acc };
14346                                *op.add(out_chan + ho * w_out + wo) = acc;
14347                            }
14348                        }
14349                    };
14350                    if fast_conv_enabled() && crate::pool::should_parallelize(n * c * h_out * w_out)
14351                    {
14352                        crate::pool::par_for(
14353                            n * c,
14354                            crate::pool::outer_chunk(n * c),
14355                            &|off, cnt| {
14356                                for nc in off..off + cnt {
14357                                    pool_plane(nc);
14358                                }
14359                            },
14360                        );
14361                    } else {
14362                        for nc in 0..n * c {
14363                            pool_plane(nc);
14364                        }
14365                    }
14366                }
14367            }
14368
14369            Thunk::ReluBackward { x, dy, dx, len } => {
14370                let len = *len as usize;
14371                unsafe {
14372                    let xs = sl(*x, base, len);
14373                    let dys = sl(*dy, base, len);
14374                    let out = sl_mut(*dx, base, len);
14375                    if fast_conv_enabled() && crate::pool::should_parallelize(len) {
14376                        let oa = out.as_mut_ptr() as usize;
14377                        crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
14378                            for i in off..off + cnt {
14379                                *((oa as *mut f32).add(i)) = if xs[i] > 0.0 { dys[i] } else { 0.0 };
14380                            }
14381                        });
14382                    } else {
14383                        for i in 0..len {
14384                            out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
14385                        }
14386                    }
14387                }
14388            }
14389
14390            Thunk::ReluBackwardF64 { x, dy, dx, len } => {
14391                let len = *len as usize;
14392                unsafe {
14393                    let xs = sl_f64(*x, base, len);
14394                    let dys = sl_f64(*dy, base, len);
14395                    let out = sl_mut_f64(*dx, base, len);
14396                    for i in 0..len {
14397                        out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
14398                    }
14399                }
14400            }
14401
14402            Thunk::QMatMul {
14403                x,
14404                w,
14405                bias,
14406                out,
14407                m,
14408                k,
14409                n,
14410                x_zp,
14411                w_zp,
14412                out_zp,
14413                mult,
14414            } => {
14415                let m = *m as usize;
14416                let k = *k as usize;
14417                let n = *n as usize;
14418                unsafe {
14419                    let x_ptr = base.add(*x) as *const i8;
14420                    let w_ptr = base.add(*w) as *const i8;
14421                    let bias_ptr = base.add(*bias) as *const i32;
14422                    let out_ptr = base.add(*out) as *mut i8;
14423                    for mi in 0..m {
14424                        for ni in 0..n {
14425                            let mut acc: i32 = *bias_ptr.add(ni);
14426                            for ki in 0..k {
14427                                let xv = *x_ptr.add(mi * k + ki) as i32 - *x_zp;
14428                                let wv = *w_ptr.add(ki * n + ni) as i32 - *w_zp;
14429                                acc += xv * wv;
14430                            }
14431                            // Requantize: round(acc · mult) + out_zp,
14432                            // clamped to i8.
14433                            let r = (acc as f32 * *mult).round() as i32 + *out_zp;
14434                            let r = r.clamp(-128, 127) as i8;
14435                            *out_ptr.add(mi * n + ni) = r;
14436                        }
14437                    }
14438                }
14439            }
14440
14441            Thunk::QConv2d {
14442                x,
14443                w,
14444                bias,
14445                out,
14446                n,
14447                c_in,
14448                h,
14449                w_in,
14450                c_out,
14451                h_out,
14452                w_out,
14453                kh,
14454                kw,
14455                sh,
14456                sw,
14457                ph,
14458                pw,
14459                dh,
14460                dw,
14461                groups,
14462                x_zp,
14463                w_zp,
14464                out_zp,
14465                mult,
14466            } => {
14467                let n = *n as usize;
14468                let c_in = *c_in as usize;
14469                let h = *h as usize;
14470                let w_in = *w_in as usize;
14471                let c_out = *c_out as usize;
14472                let h_out = *h_out as usize;
14473                let w_out = *w_out as usize;
14474                let kh = *kh as usize;
14475                let kw = *kw as usize;
14476                let sh = *sh as usize;
14477                let sw = *sw as usize;
14478                let ph = *ph as usize;
14479                let pw = *pw as usize;
14480                let dh = *dh as usize;
14481                let dw = *dw as usize;
14482                let groups = *groups as usize;
14483                let c_in_per_g = c_in / groups;
14484                let c_out_per_g = c_out / groups;
14485                unsafe {
14486                    let x_ptr = base.add(*x) as *const i8;
14487                    let w_ptr = base.add(*w) as *const i8;
14488                    let bias_ptr = base.add(*bias) as *const i32;
14489                    let out_ptr = base.add(*out) as *mut i8;
14490                    for ni in 0..n {
14491                        for co in 0..c_out {
14492                            let g = co / c_out_per_g;
14493                            let ci_start = g * c_in_per_g;
14494                            for ho in 0..h_out {
14495                                for wo in 0..w_out {
14496                                    let mut acc: i32 = *bias_ptr.add(co);
14497                                    for ci_off in 0..c_in_per_g {
14498                                        let ci = ci_start + ci_off;
14499                                        let in_chan = ((ni * c_in) + ci) * h * w_in;
14500                                        let wt_chan = ((co * c_in_per_g) + ci_off) * kh * kw;
14501                                        for ki in 0..kh {
14502                                            for kj in 0..kw {
14503                                                let hi = ho * sh + ki * dh;
14504                                                let wi = wo * sw + kj * dw;
14505                                                if hi < ph || wi < pw {
14506                                                    continue;
14507                                                }
14508                                                let hi = hi - ph;
14509                                                let wi = wi - pw;
14510                                                if hi >= h || wi >= w_in {
14511                                                    continue;
14512                                                }
14513                                                let xv = *x_ptr.add(in_chan + hi * w_in + wi)
14514                                                    as i32
14515                                                    - *x_zp;
14516                                                let wv = *w_ptr.add(wt_chan + ki * kw + kj) as i32
14517                                                    - *w_zp;
14518                                                acc += xv * wv;
14519                                            }
14520                                        }
14521                                    }
14522                                    let r = (acc as f32 * *mult).round() as i32 + *out_zp;
14523                                    let r = r.clamp(-128, 127) as i8;
14524                                    let dst = ((ni * c_out) + co) * h_out * w_out + ho * w_out + wo;
14525                                    *out_ptr.add(dst) = r;
14526                                }
14527                            }
14528                        }
14529                    }
14530                }
14531            }
14532
14533            Thunk::Quantize {
14534                x,
14535                q,
14536                len,
14537                chan_axis: _,
14538                chan_dim,
14539                inner,
14540                scales,
14541                zero_points,
14542            } => {
14543                let len = *len as usize;
14544                let chan_dim = *chan_dim as usize;
14545                let inner = *inner as usize;
14546                unsafe {
14547                    let xs = sl(*x, base, len);
14548                    let q_ptr = base.add(*q) as *mut i8;
14549                    for i in 0..len {
14550                        let c = if chan_dim == 1 {
14551                            0
14552                        } else {
14553                            (i / inner) % chan_dim
14554                        };
14555                        let inv_scale = 1.0 / scales[c];
14556                        let zp = zero_points[c];
14557                        let v = (xs[i] * inv_scale).round() as i32 + zp;
14558                        *q_ptr.add(i) = v.clamp(-128, 127) as i8;
14559                    }
14560                }
14561            }
14562
14563            Thunk::Dequantize {
14564                q,
14565                x,
14566                len,
14567                chan_axis: _,
14568                chan_dim,
14569                inner,
14570                scales,
14571                zero_points,
14572            } => {
14573                let len = *len as usize;
14574                let chan_dim = *chan_dim as usize;
14575                let inner = *inner as usize;
14576                unsafe {
14577                    let q_ptr = base.add(*q) as *const i8;
14578                    let out = sl_mut(*x, base, len);
14579                    for i in 0..len {
14580                        let c = if chan_dim == 1 {
14581                            0
14582                        } else {
14583                            (i / inner) % chan_dim
14584                        };
14585                        let scale = scales[c];
14586                        let zp = zero_points[c];
14587                        let qv = *q_ptr.add(i) as i32;
14588                        out[i] = (qv - zp) as f32 * scale;
14589                    }
14590                }
14591            }
14592
14593            Thunk::FakeQuantize {
14594                x,
14595                out,
14596                len,
14597                chan_axis: _,
14598                chan_dim,
14599                inner,
14600                bits,
14601                ste: _,
14602                scale_mode,
14603                state_off,
14604            } => {
14605                use rlx_ir::op::ScaleMode;
14606                let len = *len as usize;
14607                let chan_dim = *chan_dim as usize;
14608                let inner = *inner as usize;
14609                let q_max: f32 = match *bits {
14610                    8 => 127.0,
14611                    4 => 7.0,
14612                    2 => 1.0,
14613                    n => panic!("FakeQuantize: unsupported bits {n}"),
14614                };
14615                unsafe {
14616                    let xs = sl(*x, base, len);
14617                    let outs = sl_mut(*out, base, len);
14618
14619                    let mut scale = vec![0f32; chan_dim];
14620                    match scale_mode {
14621                        ScaleMode::PerBatch => {
14622                            let mut max_abs = vec![0f32; chan_dim];
14623                            for i in 0..len {
14624                                let c = if chan_dim == 1 {
14625                                    0
14626                                } else {
14627                                    (i / inner) % chan_dim
14628                                };
14629                                let a = xs[i].abs();
14630                                if a > max_abs[c] {
14631                                    max_abs[c] = a;
14632                                }
14633                            }
14634                            for c in 0..chan_dim {
14635                                scale[c] = (max_abs[c] / q_max).max(1e-12);
14636                            }
14637                        }
14638                        ScaleMode::EMA { decay } => {
14639                            // Per-channel current max-abs, then blend
14640                            // into the running state in place.
14641                            let mut max_abs = vec![0f32; chan_dim];
14642                            for i in 0..len {
14643                                let c = if chan_dim == 1 {
14644                                    0
14645                                } else {
14646                                    (i / inner) % chan_dim
14647                                };
14648                                let a = xs[i].abs();
14649                                if a > max_abs[c] {
14650                                    max_abs[c] = a;
14651                                }
14652                            }
14653                            let state =
14654                                sl_mut(state_off.expect("EMA needs state_off"), base, chan_dim);
14655                            for c in 0..chan_dim {
14656                                let cur = (max_abs[c] / q_max).max(1e-12);
14657                                // Cold-start: state==0 → seed directly.
14658                                let blended = if state[c] <= 0.0 {
14659                                    cur
14660                                } else {
14661                                    *decay * state[c] + (1.0 - *decay) * cur
14662                                };
14663                                state[c] = blended;
14664                                scale[c] = blended;
14665                            }
14666                        }
14667                        ScaleMode::Fixed => {
14668                            let state =
14669                                sl(state_off.expect("Fixed needs state_off"), base, chan_dim);
14670                            for c in 0..chan_dim {
14671                                scale[c] = state[c].max(1e-12);
14672                            }
14673                        }
14674                    }
14675
14676                    for i in 0..len {
14677                        let c = if chan_dim == 1 {
14678                            0
14679                        } else {
14680                            (i / inner) % chan_dim
14681                        };
14682                        let s = scale[c];
14683                        let qv = (xs[i] / s).round().clamp(-q_max, q_max);
14684                        outs[i] = qv * s;
14685                    }
14686                }
14687            }
14688
14689            Thunk::ActivationBackward {
14690                x,
14691                dy,
14692                dx,
14693                len,
14694                kind,
14695            } => {
14696                let len = *len as usize;
14697                unsafe {
14698                    let xs = sl(*x, base, len);
14699                    let dys = sl(*dy, base, len);
14700                    let out = sl_mut(*dx, base, len);
14701                    activation_backward_kernel(*kind, xs, dys, out);
14702                }
14703            }
14704
14705            Thunk::ActivationBackwardF64 {
14706                x,
14707                dy,
14708                dx,
14709                len,
14710                kind,
14711            } => {
14712                let len = *len as usize;
14713                unsafe {
14714                    let xs = sl_f64(*x, base, len);
14715                    let dys = sl_f64(*dy, base, len);
14716                    let out = sl_mut_f64(*dx, base, len);
14717                    activation_backward_kernel_f64(*kind, xs, dys, out);
14718                }
14719            }
14720
14721            Thunk::FakeQuantizeLSQ {
14722                x,
14723                scale_off,
14724                out,
14725                len,
14726                chan_axis: _,
14727                chan_dim,
14728                inner,
14729                bits,
14730            } => {
14731                let len = *len as usize;
14732                let chan_dim = *chan_dim as usize;
14733                let inner = *inner as usize;
14734                let q_max: f32 = match *bits {
14735                    8 => 127.0,
14736                    4 => 7.0,
14737                    2 => 1.0,
14738                    n => panic!("FakeQuantizeLSQ: bad bits {n}"),
14739                };
14740                unsafe {
14741                    let xs = sl(*x, base, len);
14742                    let scale = sl(*scale_off, base, chan_dim);
14743                    let outs = sl_mut(*out, base, len);
14744                    for i in 0..len {
14745                        let c = if chan_dim == 1 {
14746                            0
14747                        } else {
14748                            (i / inner) % chan_dim
14749                        };
14750                        let s = scale[c].max(1e-12);
14751                        let qv = (xs[i] / s).round().clamp(-q_max, q_max);
14752                        outs[i] = qv * s;
14753                    }
14754                }
14755            }
14756
14757            Thunk::FakeQuantizeLSQBackwardX {
14758                x,
14759                scale_off,
14760                dy,
14761                dx,
14762                len,
14763                chan_axis: _,
14764                chan_dim,
14765                inner,
14766                bits,
14767            } => {
14768                let len = *len as usize;
14769                let chan_dim = *chan_dim as usize;
14770                let inner = *inner as usize;
14771                let q_max: f32 = match *bits {
14772                    8 => 127.0,
14773                    4 => 7.0,
14774                    2 => 1.0,
14775                    n => panic!("FakeQuantizeLSQBackwardX: bad bits {n}"),
14776                };
14777                unsafe {
14778                    let xs = sl(*x, base, len);
14779                    let scale = sl(*scale_off, base, chan_dim);
14780                    let dys = sl(*dy, base, len);
14781                    let outs = sl_mut(*dx, base, len);
14782                    // STE-clipped: dx = dy when |x/s| ≤ q_max, else 0.
14783                    for i in 0..len {
14784                        let c = if chan_dim == 1 {
14785                            0
14786                        } else {
14787                            (i / inner) % chan_dim
14788                        };
14789                        let z = xs[i] / scale[c].max(1e-12);
14790                        outs[i] = if z.abs() <= q_max { dys[i] } else { 0.0 };
14791                    }
14792                }
14793            }
14794
14795            Thunk::FakeQuantizeLSQBackwardScale {
14796                x,
14797                scale_off,
14798                dy,
14799                dscale,
14800                len,
14801                chan_axis: _,
14802                chan_dim,
14803                inner,
14804                bits,
14805            } => {
14806                let len = *len as usize;
14807                let chan_dim = *chan_dim as usize;
14808                let inner = *inner as usize;
14809                let q_max: f32 = match *bits {
14810                    8 => 127.0,
14811                    4 => 7.0,
14812                    2 => 1.0,
14813                    n => panic!("FakeQuantizeLSQBackwardScale: bad bits {n}"),
14814                };
14815                unsafe {
14816                    let xs = sl(*x, base, len);
14817                    let scale = sl(*scale_off, base, chan_dim);
14818                    let dys = sl(*dy, base, len);
14819                    let outs = sl_mut(*dscale, base, chan_dim);
14820                    for v in outs.iter_mut() {
14821                        *v = 0.0;
14822                    }
14823                    // ψ(z) = -z + round(z) inside range, sign(z)·q_max outside.
14824                    // dscale[c] = sum_i ψ(x_i/s[c]) * upstream[i].
14825                    for i in 0..len {
14826                        let c = if chan_dim == 1 {
14827                            0
14828                        } else {
14829                            (i / inner) % chan_dim
14830                        };
14831                        let s = scale[c].max(1e-12);
14832                        let z = xs[i] / s;
14833                        let psi = if z.abs() <= q_max {
14834                            -z + z.round()
14835                        } else if z > 0.0 {
14836                            q_max
14837                        } else {
14838                            -q_max
14839                        };
14840                        outs[c] += psi * dys[i];
14841                    }
14842                }
14843            }
14844
14845            Thunk::FakeQuantizeBackward {
14846                x,
14847                dy,
14848                dx,
14849                len,
14850                chan_axis: _,
14851                chan_dim,
14852                inner,
14853                bits,
14854                ste,
14855            } => {
14856                use rlx_ir::op::SteKind;
14857                let len = *len as usize;
14858                let chan_dim = *chan_dim as usize;
14859                let inner = *inner as usize;
14860                let q_max: f32 = match *bits {
14861                    8 => 127.0,
14862                    4 => 7.0,
14863                    2 => 1.0,
14864                    n => panic!("FakeQuantizeBackward: bad bits {n}"),
14865                };
14866                unsafe {
14867                    let xs = sl(*x, base, len);
14868                    let dys = sl(*dy, base, len);
14869                    let outs = sl_mut(*dx, base, len);
14870
14871                    // Per-channel max-abs → scale, same as forward.
14872                    let mut max_abs = vec![0f32; chan_dim];
14873                    for i in 0..len {
14874                        let c = if chan_dim == 1 {
14875                            0
14876                        } else {
14877                            (i / inner) % chan_dim
14878                        };
14879                        let a = xs[i].abs();
14880                        if a > max_abs[c] {
14881                            max_abs[c] = a;
14882                        }
14883                    }
14884                    let mut scale = vec![0f32; chan_dim];
14885                    for c in 0..chan_dim {
14886                        scale[c] = (max_abs[c] / q_max).max(1e-12);
14887                    }
14888
14889                    match *ste {
14890                        SteKind::Identity => {
14891                            // dx = dy unchanged.
14892                            outs.copy_from_slice(dys);
14893                        }
14894                        SteKind::ClippedIdentity => {
14895                            // dx = dy * (|x| <= q_max·s); zero if the
14896                            // forward saturated.
14897                            for i in 0..len {
14898                                let c = if chan_dim == 1 {
14899                                    0
14900                                } else {
14901                                    (i / inner) % chan_dim
14902                                };
14903                                let bound = q_max * scale[c];
14904                                outs[i] = if xs[i].abs() <= bound { dys[i] } else { 0.0 };
14905                            }
14906                        }
14907                        SteKind::Tanh => {
14908                            // dx = dy * (1 - tanh²(x/s)).
14909                            for i in 0..len {
14910                                let c = if chan_dim == 1 {
14911                                    0
14912                                } else {
14913                                    (i / inner) % chan_dim
14914                                };
14915                                let t = (xs[i] / scale[c]).tanh();
14916                                outs[i] = dys[i] * (1.0 - t * t);
14917                            }
14918                        }
14919                        SteKind::HardTanh => {
14920                            // dx = dy * max(0, 1 - |x/(q_max·s)|).
14921                            for i in 0..len {
14922                                let c = if chan_dim == 1 {
14923                                    0
14924                                } else {
14925                                    (i / inner) % chan_dim
14926                                };
14927                                let bound = q_max * scale[c];
14928                                let attenuation = (1.0 - (xs[i] / bound).abs()).max(0.0);
14929                                outs[i] = dys[i] * attenuation;
14930                            }
14931                        }
14932                    }
14933                }
14934            }
14935
14936            Thunk::LayerNormBackwardInput {
14937                x,
14938                gamma,
14939                dy,
14940                dx,
14941                rows,
14942                h,
14943                eps,
14944            } => {
14945                let rows = *rows as usize;
14946                let h = *h as usize;
14947                let eps = *eps;
14948                unsafe {
14949                    let xs = sl(*x, base, rows * h);
14950                    let g = sl(*gamma, base, h);
14951                    let dys = sl(*dy, base, rows * h);
14952                    let out = sl_mut(*dx, base, rows * h);
14953                    let n_inv = 1.0 / h as f32;
14954                    for r in 0..rows {
14955                        let xr = &xs[r * h..(r + 1) * h];
14956                        let dyr = &dys[r * h..(r + 1) * h];
14957                        // Per-row mean and inv_std (recompute — no saved
14958                        // tensor from the forward pass).
14959                        let mut sum = 0f32;
14960                        for &v in xr {
14961                            sum += v;
14962                        }
14963                        let mean = sum * n_inv;
14964                        let mut var = 0f32;
14965                        for &v in xr {
14966                            let d = v - mean;
14967                            var += d * d;
14968                        }
14969                        let inv_std = 1.0 / (var * n_inv + eps).sqrt();
14970
14971                        // sums needed for the closed-form:
14972                        //   mean(dy·γ) and mean(dy·γ·x̂)
14973                        let mut s_sy = 0f32;
14974                        let mut s_sxh = 0f32;
14975                        for d in 0..h {
14976                            let xh = (xr[d] - mean) * inv_std;
14977                            let sy = dyr[d] * g[d];
14978                            s_sy += sy;
14979                            s_sxh += sy * xh;
14980                        }
14981                        let m_sy = s_sy * n_inv;
14982                        let m_sxh = s_sxh * n_inv;
14983
14984                        for d in 0..h {
14985                            let xh = (xr[d] - mean) * inv_std;
14986                            let sy = dyr[d] * g[d];
14987                            out[r * h + d] = inv_std * (sy - m_sy - xh * m_sxh);
14988                        }
14989                    }
14990                }
14991            }
14992
14993            Thunk::BatchNormInferenceBackwardInput {
14994                x,
14995                gamma,
14996                mean,
14997                var,
14998                dy,
14999                dx,
15000                count,
15001                channels,
15002                eps,
15003            } => {
15004                let count = *count as usize;
15005                let c = *channels as usize;
15006                let n = count * c;
15007                let eps = *eps;
15008                unsafe {
15009                    crate::kernels::batch_norm_inference_backward_input(
15010                        sl(*x, base, n),
15011                        sl(*gamma, base, c),
15012                        sl(*mean, base, c),
15013                        sl(*var, base, c),
15014                        sl(*dy, base, n),
15015                        sl_mut(*dx, base, n),
15016                        c,
15017                        eps,
15018                    );
15019                }
15020            }
15021
15022            Thunk::BatchNormInferenceBackwardGamma {
15023                x,
15024                mean,
15025                var,
15026                dy,
15027                dgamma,
15028                count,
15029                channels,
15030                eps,
15031            } => {
15032                let count = *count as usize;
15033                let c = *channels as usize;
15034                let n = count * c;
15035                let eps = *eps;
15036                unsafe {
15037                    crate::kernels::batch_norm_inference_backward_gamma(
15038                        sl(*x, base, n),
15039                        sl(*mean, base, c),
15040                        sl(*var, base, c),
15041                        sl(*dy, base, n),
15042                        sl_mut(*dgamma, base, c),
15043                        c,
15044                        eps,
15045                    );
15046                }
15047            }
15048
15049            Thunk::BatchNormInferenceBackwardBeta {
15050                dy,
15051                dbeta,
15052                count,
15053                channels,
15054            } => {
15055                let count = *count as usize;
15056                let c = *channels as usize;
15057                let n = count * c;
15058                unsafe {
15059                    crate::kernels::batch_norm_inference_backward_beta(
15060                        sl(*dy, base, n),
15061                        sl_mut(*dbeta, base, c),
15062                        c,
15063                    );
15064                }
15065            }
15066
15067            Thunk::LayerNormBackwardGamma {
15068                x,
15069                dy,
15070                dgamma,
15071                rows,
15072                h,
15073                eps,
15074            } => {
15075                let rows = *rows as usize;
15076                let h = *h as usize;
15077                let eps = *eps;
15078                unsafe {
15079                    let xs = sl(*x, base, rows * h);
15080                    let dys = sl(*dy, base, rows * h);
15081                    let out = sl_mut(*dgamma, base, h);
15082                    for v in out.iter_mut() {
15083                        *v = 0.0;
15084                    }
15085                    let n_inv = 1.0 / h as f32;
15086                    for r in 0..rows {
15087                        let xr = &xs[r * h..(r + 1) * h];
15088                        let dyr = &dys[r * h..(r + 1) * h];
15089                        let mut sum = 0f32;
15090                        for &v in xr {
15091                            sum += v;
15092                        }
15093                        let mean = sum * n_inv;
15094                        let mut var = 0f32;
15095                        for &v in xr {
15096                            let d = v - mean;
15097                            var += d * d;
15098                        }
15099                        let inv_std = 1.0 / (var * n_inv + eps).sqrt();
15100                        for d in 0..h {
15101                            let xh = (xr[d] - mean) * inv_std;
15102                            out[d] += dyr[d] * xh;
15103                        }
15104                    }
15105                }
15106            }
15107
15108            Thunk::RmsNormBackwardInput {
15109                x,
15110                gamma,
15111                beta,
15112                dy,
15113                dx,
15114                rows,
15115                h,
15116                eps,
15117            } => {
15118                let (rows, h) = (*rows as usize, *h as usize);
15119                unsafe {
15120                    let xs = sl(*x, base, rows * h);
15121                    let g = sl(*gamma, base, h);
15122                    let b = sl(*beta, base, h);
15123                    let dys = sl(*dy, base, rows * h);
15124                    let out = sl_mut(*dx, base, rows * h);
15125                    let mut dg = vec![0f32; h];
15126                    let mut db = vec![0f32; h];
15127                    for r in 0..rows {
15128                        crate::training_bwd::rms_norm_backward_row(
15129                            &xs[r * h..(r + 1) * h],
15130                            g,
15131                            b,
15132                            &dys[r * h..(r + 1) * h],
15133                            &mut out[r * h..(r + 1) * h],
15134                            &mut dg,
15135                            &mut db,
15136                            *eps,
15137                        );
15138                    }
15139                }
15140            }
15141
15142            Thunk::RmsNormBackwardGamma {
15143                x,
15144                gamma,
15145                beta,
15146                dy,
15147                dgamma,
15148                rows,
15149                h,
15150                eps,
15151            } => {
15152                let (rows, h) = (*rows as usize, *h as usize);
15153                unsafe {
15154                    let xs = sl(*x, base, rows * h);
15155                    let g = sl(*gamma, base, h);
15156                    let b = sl(*beta, base, h);
15157                    let dys = sl(*dy, base, rows * h);
15158                    let out = sl_mut(*dgamma, base, h);
15159                    for v in out.iter_mut() {
15160                        *v = 0.0;
15161                    }
15162                    let mut dx = vec![0f32; h];
15163                    let mut db = vec![0f32; h];
15164                    for r in 0..rows {
15165                        crate::training_bwd::rms_norm_backward_row(
15166                            &xs[r * h..(r + 1) * h],
15167                            g,
15168                            b,
15169                            &dys[r * h..(r + 1) * h],
15170                            &mut dx,
15171                            &mut *out,
15172                            &mut db,
15173                            *eps,
15174                        );
15175                    }
15176                }
15177            }
15178
15179            Thunk::RmsNormBackwardBeta {
15180                x,
15181                gamma,
15182                beta,
15183                dy,
15184                dbeta,
15185                rows,
15186                h,
15187                eps,
15188            } => {
15189                let (rows, h) = (*rows as usize, *h as usize);
15190                unsafe {
15191                    let xs = sl(*x, base, rows * h);
15192                    let g = sl(*gamma, base, h);
15193                    let b = sl(*beta, base, h);
15194                    let dys = sl(*dy, base, rows * h);
15195                    let out = sl_mut(*dbeta, base, h);
15196                    for v in out.iter_mut() {
15197                        *v = 0.0;
15198                    }
15199                    let mut dx = vec![0f32; h];
15200                    let mut dg = vec![0f32; h];
15201                    for r in 0..rows {
15202                        crate::training_bwd::rms_norm_backward_row(
15203                            &xs[r * h..(r + 1) * h],
15204                            g,
15205                            b,
15206                            &dys[r * h..(r + 1) * h],
15207                            &mut dx,
15208                            &mut dg,
15209                            &mut *out,
15210                            *eps,
15211                        );
15212                    }
15213                }
15214            }
15215
15216            Thunk::RopeBackward {
15217                dy,
15218                cos,
15219                sin,
15220                dx,
15221                batch,
15222                seq,
15223                hidden,
15224                head_dim,
15225                n_rot,
15226                cos_len,
15227            } => {
15228                let (b, s, hs, dh, nr, cl) = (
15229                    *batch as usize,
15230                    *seq as usize,
15231                    *hidden as usize,
15232                    *head_dim as usize,
15233                    *n_rot as usize,
15234                    *cos_len as usize,
15235                );
15236                let nh = hs / dh;
15237                let tab_half = dh / 2;
15238                unsafe {
15239                    let dys = sl(*dy, base, b * s * hs);
15240                    let cos_tab = sl(*cos, base, cl);
15241                    let sin_tab = sl(*sin, base, cl);
15242                    let out = sl_mut(*dx, base, b * s * hs);
15243                    for bi in 0..b {
15244                        for si in 0..s {
15245                            let tab_off = si.saturating_mul(tab_half) % cl.max(1);
15246                            let cp = &cos_tab[tab_off..tab_off + tab_half.min(cl)];
15247                            let sp = &sin_tab[tab_off..tab_off + tab_half.min(cl)];
15248                            for hi in 0..nh {
15249                                let base_idx = bi * s * hs + si * hs + hi * dh;
15250                                crate::training_bwd::rope_backward_row(
15251                                    &dys[base_idx..base_idx + dh],
15252                                    cp,
15253                                    sp,
15254                                    &mut out[base_idx..base_idx + dh],
15255                                    dh,
15256                                    nr,
15257                                );
15258                            }
15259                        }
15260                    }
15261                }
15262            }
15263
15264            Thunk::CumsumBackward {
15265                dy,
15266                dx,
15267                rows,
15268                cols,
15269                exclusive,
15270            } => {
15271                let (rows, cols) = (*rows as usize, *cols as usize);
15272                unsafe {
15273                    let dys = sl(*dy, base, rows * cols);
15274                    let out = sl_mut(*dx, base, rows * cols);
15275                    for r in 0..rows {
15276                        crate::training_bwd::cumsum_backward_row(
15277                            &dys[r * cols..(r + 1) * cols],
15278                            &mut out[r * cols..(r + 1) * cols],
15279                            *exclusive,
15280                        );
15281                    }
15282                }
15283            }
15284
15285            Thunk::GroupNormBackwardInput {
15286                x,
15287                gamma,
15288                beta: _beta,
15289                dy,
15290                dx,
15291                n,
15292                c,
15293                h,
15294                w,
15295                num_groups,
15296                eps,
15297            } => {
15298                let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
15299                let plane = c * h * w;
15300                unsafe {
15301                    let xs = sl(*x, base, n * plane);
15302                    let g = sl(*gamma, base, c);
15303                    let dys = sl(*dy, base, n * plane);
15304                    let out = sl_mut(*dx, base, n * plane);
15305                    crate::training_bwd::group_norm_backward_input_nchw(
15306                        xs,
15307                        g,
15308                        dys,
15309                        out,
15310                        n,
15311                        c,
15312                        h,
15313                        w,
15314                        *num_groups as usize,
15315                        *eps,
15316                    );
15317                }
15318            }
15319
15320            Thunk::GroupNormBackwardGamma {
15321                x,
15322                dy,
15323                dgamma,
15324                n,
15325                c,
15326                h,
15327                w,
15328                num_groups,
15329                eps,
15330            } => {
15331                let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
15332                let plane = c * h * w;
15333                unsafe {
15334                    let xs = sl(*x, base, n * plane);
15335                    let dys = sl(*dy, base, n * plane);
15336                    let out = sl_mut(*dgamma, base, c);
15337                    crate::training_bwd::group_norm_backward_gamma_nchw(
15338                        xs,
15339                        dys,
15340                        out,
15341                        n,
15342                        c,
15343                        h,
15344                        w,
15345                        *num_groups as usize,
15346                        *eps,
15347                    );
15348                }
15349            }
15350
15351            Thunk::GroupNormBackwardBeta {
15352                dy,
15353                dbeta,
15354                n,
15355                c,
15356                h,
15357                w,
15358            } => {
15359                let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
15360                let plane = c * h * w;
15361                unsafe {
15362                    let dys = sl(*dy, base, n * plane);
15363                    let out = sl_mut(*dbeta, base, c);
15364                    crate::training_bwd::group_norm_backward_beta_nchw(dys, out, n, c, h, w);
15365                }
15366            }
15367
15368            Thunk::GatherBackward {
15369                dy,
15370                indices,
15371                dst,
15372                outer,
15373                axis_dim,
15374                num_idx,
15375                trailing,
15376            } => {
15377                let (outer, axis_dim, num_idx, trailing) = (
15378                    *outer as usize,
15379                    *axis_dim as usize,
15380                    *num_idx as usize,
15381                    *trailing as usize,
15382                );
15383                unsafe {
15384                    let dys = sl(*dy, base, outer * num_idx * trailing);
15385                    let ids = sl(*indices, base, num_idx);
15386                    let out = sl_mut(*dst, base, outer * axis_dim * trailing);
15387                    for v in out.iter_mut() {
15388                        *v = 0.0;
15389                    }
15390                    crate::training_bwd::gather_axis_backward(
15391                        dys, ids, out, outer, axis_dim, num_idx, trailing,
15392                    );
15393                }
15394            }
15395
15396            Thunk::MaxPool2dBackward {
15397                x,
15398                dy,
15399                dx,
15400                n,
15401                c,
15402                h,
15403                w,
15404                h_out,
15405                w_out,
15406                kh,
15407                kw,
15408                sh,
15409                sw,
15410                ph,
15411                pw,
15412            } => unsafe {
15413                execute_maxpool2d_backward_f32(
15414                    *x, *dy, *dx, *n, *c, *h, *w, *h_out, *w_out, *kh, *kw, *sh, *sw, *ph, *pw,
15415                    base,
15416                );
15417            },
15418
15419            Thunk::Conv2dBackwardInput {
15420                dy,
15421                w,
15422                dx,
15423                n,
15424                c_in,
15425                h,
15426                w_in,
15427                c_out,
15428                h_out,
15429                w_out,
15430                kh,
15431                kw,
15432                sh,
15433                sw,
15434                ph,
15435                pw,
15436                dh,
15437                dw,
15438                groups,
15439            } => {
15440                // Per-group GEMM + col2im. Two orders of magnitude faster
15441                // than the naive 6-deep nested loop on training shapes.
15442                //
15443                //   dcol_n_g = w_g^T  @  dy_n_g            (sgemm)
15444                //   dx_n_g  += col2im(dcol_n_g)            (scatter-add)
15445                //
15446                // Layouts (all row-major):
15447                //   w_g       [c_out_per_g, c_in_per_g · kh · kw]
15448                //   dy_n_g    [c_out_per_g, h_out · w_out]
15449                //   dcol_n_g  [c_in_per_g · kh · kw, h_out · w_out]
15450                //   dx_n_g    [c_in_per_g, h · w_in]
15451                let n = *n as usize;
15452                let c_in = *c_in as usize;
15453                let h = *h as usize;
15454                let w_in = *w_in as usize;
15455                let c_out = *c_out as usize;
15456                let h_out = *h_out as usize;
15457                let w_out = *w_out as usize;
15458                let kh = *kh as usize;
15459                let kw = *kw as usize;
15460                let sh = *sh as usize;
15461                let sw = *sw as usize;
15462                let ph = *ph as usize;
15463                let pw = *pw as usize;
15464                let dh = *dh as usize;
15465                let dw = *dw as usize;
15466                let groups = *groups as usize;
15467                let c_in_per_g = c_in / groups;
15468                let c_out_per_g = c_out / groups;
15469
15470                let m_dim = c_in_per_g * kh * kw;
15471                let n_dim = h_out * w_out;
15472                let k_dim = c_out_per_g;
15473
15474                let dy_stride_n = c_out * h_out * w_out;
15475                let dy_stride_g = c_out_per_g * h_out * w_out;
15476                let w_stride_g = c_out_per_g * c_in_per_g * kh * kw;
15477                let dx_stride_n = c_in * h * w_in;
15478                let dx_stride_g = c_in_per_g * h * w_in;
15479
15480                unsafe {
15481                    let dys = sl(*dy, base, n * c_out * h_out * w_out);
15482                    let ws = sl(*w, base, c_out * c_in_per_g * kh * kw);
15483                    let dxs = sl_mut(*dx, base, n * c_in * h * w_in);
15484                    for v in dxs.iter_mut() {
15485                        *v = 0.0;
15486                    }
15487
15488                    // Each (ni, g) writes a disjoint dx_n_g region (col2im
15489                    // scatter-adds into the freshly-zeroed slice), so the batch
15490                    // loop fans out over the pool when RLX_FAST_CONV is set —
15491                    // each worker owns a `dcol` scratch and a raw pointer it
15492                    // offsets into its own dx window.
15493                    if fast_conv_enabled() {
15494                        let dx_addr = dxs.as_mut_ptr() as usize;
15495                        crate::pool::par_for(n, 1, &|off, cnt| {
15496                            let mut dcol = vec![0f32; m_dim * n_dim];
15497                            for ni in off..off + cnt {
15498                                for g in 0..groups {
15499                                    let w_g_off = g * w_stride_g;
15500                                    let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
15501                                    let dx_n_g_off = ni * dx_stride_n + g * dx_stride_g;
15502                                    crate::blas::sgemm_general(
15503                                        ws.as_ptr().add(w_g_off),
15504                                        dys.as_ptr().add(dy_n_g_off),
15505                                        dcol.as_mut_ptr(),
15506                                        m_dim,
15507                                        n_dim,
15508                                        k_dim,
15509                                        1.0,
15510                                        0.0,
15511                                        m_dim,
15512                                        n_dim,
15513                                        n_dim,
15514                                        true,
15515                                        false,
15516                                    );
15517                                    let dx_g = std::slice::from_raw_parts_mut(
15518                                        (dx_addr as *mut f32).add(dx_n_g_off),
15519                                        dx_stride_g,
15520                                    );
15521                                    col2im(
15522                                        &dcol, dx_g, c_in_per_g, h, w_in, h_out, w_out, kh, kw, sh,
15523                                        sw, ph, pw, dh, dw,
15524                                    );
15525                                }
15526                            }
15527                        });
15528                    } else {
15529                        // Reused scratch buffer for the [m_dim, n_dim] dcol.
15530                        let mut dcol = vec![0f32; m_dim * n_dim];
15531                        for ni in 0..n {
15532                            for g in 0..groups {
15533                                let w_g_off = g * w_stride_g;
15534                                let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
15535                                let dx_n_g_off = ni * dx_stride_n + g * dx_stride_g;
15536
15537                                // dcol = w_g^T @ dy_n_g
15538                                // w_g  is stored as [k_dim rows, m_dim cols] row-major
15539                                // (i.e. K×M storage with lda = M = m_dim — exactly what
15540                                // sgemm_general wants for trans_a=true).
15541                                crate::blas::sgemm_general(
15542                                    ws.as_ptr().add(w_g_off),
15543                                    dys.as_ptr().add(dy_n_g_off),
15544                                    dcol.as_mut_ptr(),
15545                                    m_dim,
15546                                    n_dim,
15547                                    k_dim,
15548                                    1.0,
15549                                    0.0,
15550                                    /*lda=*/ m_dim,
15551                                    /*ldb=*/ n_dim,
15552                                    /*ldc=*/ n_dim,
15553                                    /*trans_a=*/ true,
15554                                    /*trans_b=*/ false,
15555                                );
15556
15557                                // dx_n_g += col2im(dcol)
15558                                col2im(
15559                                    &dcol,
15560                                    &mut dxs[dx_n_g_off..dx_n_g_off + dx_stride_g],
15561                                    c_in_per_g,
15562                                    h,
15563                                    w_in,
15564                                    h_out,
15565                                    w_out,
15566                                    kh,
15567                                    kw,
15568                                    sh,
15569                                    sw,
15570                                    ph,
15571                                    pw,
15572                                    dh,
15573                                    dw,
15574                                );
15575                            }
15576                        }
15577                    }
15578                }
15579            }
15580
15581            Thunk::Conv2dBackwardWeight {
15582                x,
15583                dy,
15584                dw,
15585                n,
15586                c_in,
15587                h,
15588                w,
15589                c_out,
15590                h_out,
15591                w_out,
15592                kh,
15593                kw,
15594                sh,
15595                sw,
15596                ph,
15597                pw,
15598                dh,
15599                dw_dil,
15600                groups,
15601            } => {
15602                let n = *n as usize;
15603                let c_in = *c_in as usize;
15604                let h = *h as usize;
15605                let w = *w as usize;
15606                // Per-group im2col + GEMM, summed across batch.
15607                //
15608                //   col_n_g  = im2col(x_n_g)               (gather)
15609                //   dw_g    += dy_n_g  @  col_n_g^T        (sgemm, β=1)
15610                //
15611                // Layouts:
15612                //   x_n_g     [c_in_per_g, h · w]
15613                //   col_n_g   [c_in_per_g · kh · kw, h_out · w_out]
15614                //   dy_n_g    [c_out_per_g, h_out · w_out]
15615                //   dw_g      [c_out_per_g, c_in_per_g · kh · kw]
15616                let c_out = *c_out as usize;
15617                let h_out = *h_out as usize;
15618                let w_out = *w_out as usize;
15619                let kh = *kh as usize;
15620                let kw = *kw as usize;
15621                let sh = *sh as usize;
15622                let sw = *sw as usize;
15623                let ph = *ph as usize;
15624                let pw = *pw as usize;
15625                let dh = *dh as usize;
15626                let dw_dil = *dw_dil as usize;
15627                let groups = *groups as usize;
15628                let c_in_per_g = c_in / groups;
15629                let c_out_per_g = c_out / groups;
15630
15631                let m_dim = c_out_per_g;
15632                let n_dim = c_in_per_g * kh * kw;
15633                let k_dim = h_out * w_out;
15634
15635                let x_stride_n = c_in * h * w;
15636                let x_stride_g = c_in_per_g * h * w;
15637                let dy_stride_n = c_out * h_out * w_out;
15638                let dy_stride_g = c_out_per_g * h_out * w_out;
15639                let dw_stride_g = c_out_per_g * c_in_per_g * kh * kw;
15640
15641                unsafe {
15642                    let xs = sl(*x, base, n * c_in * h * w);
15643                    let dys = sl(*dy, base, n * c_out * h_out * w_out);
15644                    let dws = sl_mut(*dw, base, c_out * c_in_per_g * kh * kw);
15645                    for v in dws.iter_mut() {
15646                        *v = 0.0;
15647                    }
15648
15649                    // dw is a cross-batch reduction (β=1 accumulate), so the
15650                    // parallel path gives each worker a private `local` dw,
15651                    // accumulates into it over its slice of the batch, then adds
15652                    // it into the shared `dws` once under a lock — O(threads)
15653                    // contention, not O(batch). RLX_FAST_CONV gates it.
15654                    if fast_conv_enabled() {
15655                        let dw_len = dws.len();
15656                        let dws_addr = dws.as_mut_ptr() as usize;
15657                        let lock = std::sync::Mutex::new(());
15658                        crate::pool::par_for(n, 1, &|off, cnt| {
15659                            let mut col = vec![0f32; n_dim * k_dim];
15660                            let mut local = vec![0f32; dw_len];
15661                            for ni in off..off + cnt {
15662                                for g in 0..groups {
15663                                    let x_n_g_off = ni * x_stride_n + g * x_stride_g;
15664                                    let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
15665                                    let dw_g_off = g * dw_stride_g;
15666                                    // Rows-layout im2col: col is [P, K] row-major, so
15667                                    // the GEMM dy[c_out,P] @ col[P,K] reads col
15668                                    // contiguously (trans_b=false) instead of the
15669                                    // strided transposed read the [K,P] layout forced.
15670                                    crate::im2col::im2col_rows_layout(
15671                                        &xs[x_n_g_off..x_n_g_off + x_stride_g],
15672                                        &mut col,
15673                                        1,
15674                                        c_in_per_g,
15675                                        h,
15676                                        w,
15677                                        h_out,
15678                                        w_out,
15679                                        kh,
15680                                        kw,
15681                                        sh,
15682                                        sw,
15683                                        ph,
15684                                        pw,
15685                                        dh,
15686                                        dw_dil,
15687                                    );
15688                                    crate::blas::sgemm_general(
15689                                        dys.as_ptr().add(dy_n_g_off),
15690                                        col.as_ptr(),
15691                                        local.as_mut_ptr().add(dw_g_off),
15692                                        m_dim,
15693                                        n_dim,
15694                                        k_dim,
15695                                        1.0,
15696                                        1.0,
15697                                        k_dim,
15698                                        n_dim,
15699                                        n_dim,
15700                                        false,
15701                                        false,
15702                                    );
15703                                }
15704                            }
15705                            let _guard = lock.lock().unwrap();
15706                            let dws = std::slice::from_raw_parts_mut(dws_addr as *mut f32, dw_len);
15707                            for (d, l) in dws.iter_mut().zip(local.iter()) {
15708                                *d += *l;
15709                            }
15710                        });
15711                    } else {
15712                        let mut col = vec![0f32; n_dim * k_dim];
15713                        for ni in 0..n {
15714                            for g in 0..groups {
15715                                let x_n_g_off = ni * x_stride_n + g * x_stride_g;
15716                                im2col(
15717                                    &xs[x_n_g_off..x_n_g_off + x_stride_g],
15718                                    &mut col,
15719                                    c_in_per_g,
15720                                    h,
15721                                    w,
15722                                    h_out,
15723                                    w_out,
15724                                    kh,
15725                                    kw,
15726                                    sh,
15727                                    sw,
15728                                    ph,
15729                                    pw,
15730                                    dh,
15731                                    dw_dil,
15732                                );
15733
15734                                let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
15735                                let dw_g_off = g * dw_stride_g;
15736
15737                                // dw_g += dy_n_g @ col^T
15738                                //
15739                                // Output shape m × n_out = c_out_per_g × (c_in_per_g·kh·kw).
15740                                // dy_n_g is stored M×K row-major (lda = K = k_dim).
15741                                // col is stored as N×K row-major; with trans_b=true,
15742                                // sgemm_general uses ldb = K = k_dim and treats it as
15743                                // transposed. β=1 accumulates across the batch loop.
15744                                crate::blas::sgemm_general(
15745                                    dys.as_ptr().add(dy_n_g_off),
15746                                    col.as_ptr(),
15747                                    dws.as_mut_ptr().add(dw_g_off),
15748                                    m_dim,
15749                                    n_dim,
15750                                    k_dim,
15751                                    1.0,
15752                                    1.0,
15753                                    /*lda=*/ k_dim,
15754                                    /*ldb=*/ k_dim,
15755                                    /*ldc=*/ n_dim,
15756                                    /*trans_a=*/ false,
15757                                    /*trans_b=*/ true,
15758                                );
15759                            }
15760                        }
15761                    }
15762                }
15763            }
15764
15765            Thunk::Im2Col {
15766                x,
15767                col,
15768                n,
15769                c_in,
15770                h,
15771                w,
15772                h_out,
15773                w_out,
15774                kh,
15775                kw,
15776                sh,
15777                sw,
15778                ph,
15779                pw,
15780                dh,
15781                dw_dil,
15782            } => {
15783                let c_in = *c_in as usize;
15784                let h = *h as usize;
15785                let w = *w as usize;
15786                let h_out = *h_out as usize;
15787                let w_out = *w_out as usize;
15788                let kh = *kh as usize;
15789                let kw = *kw as usize;
15790                let sh = *sh as usize;
15791                let sw = *sw as usize;
15792                let ph = *ph as usize;
15793                let pw = *pw as usize;
15794                let dh = *dh as usize;
15795                let dw_dil = *dw_dil as usize;
15796                let per_batch = c_in * h * w;
15797                unsafe {
15798                    let n_eff = if *n == 0 { 0usize } else { *n as usize };
15799                    let x_floats = if n_eff == 0 {
15800                        per_batch.max(1)
15801                    } else {
15802                        n_eff * per_batch
15803                    };
15804                    let xs = sl(*x, base, x_floats);
15805                    let n = if *n == 0 {
15806                        xs.len() / per_batch.max(1)
15807                    } else {
15808                        n_eff
15809                    };
15810                    let m = n * h_out * w_out;
15811                    let k = c_in * kh * kw;
15812                    let cols = sl_mut(*col, base, m * k);
15813                    crate::im2col::im2col_rows_layout(
15814                        xs, cols, n, c_in, h, w, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw_dil,
15815                    );
15816                }
15817            }
15818
15819            Thunk::SoftmaxCrossEntropyDense {
15820                logits,
15821                targets,
15822                dst,
15823                n,
15824                c,
15825            } => {
15826                let n = *n as usize;
15827                let c = *c as usize;
15828                unsafe {
15829                    let lg = sl(*logits, base, n * c);
15830                    let tg = sl(*targets, base, n * c);
15831                    let out = sl_mut(*dst, base, n);
15832                    for ni in 0..n {
15833                        let row = &lg[ni * c..(ni + 1) * c];
15834                        let trow = &tg[ni * c..(ni + 1) * c];
15835                        // log-sum-exp: max-subtract for stability.
15836                        let mut m = f32::NEG_INFINITY;
15837                        for &v in row {
15838                            if v > m {
15839                                m = v;
15840                            }
15841                        }
15842                        let mut sum = 0f32;
15843                        for &v in row {
15844                            sum += (v - m).exp();
15845                        }
15846                        let lse = m + sum.ln();
15847                        // loss = lse - Σ_c targets[c]·logits[c].
15848                        let mut dot = 0f32;
15849                        for k in 0..c {
15850                            dot += trow[k] * row[k];
15851                        }
15852                        out[ni] = lse - dot;
15853                    }
15854                }
15855            }
15856
15857            Thunk::SoftmaxCrossEntropy {
15858                logits,
15859                labels,
15860                dst,
15861                n,
15862                c,
15863            } => {
15864                let n = *n as usize;
15865                let c = *c as usize;
15866                unsafe {
15867                    let lg = sl(*logits, base, n * c);
15868                    let lb = sl(*labels, base, n);
15869                    let out = sl_mut(*dst, base, n);
15870                    for ni in 0..n {
15871                        let row = &lg[ni * c..(ni + 1) * c];
15872                        // log-sum-exp: max-subtract for stability.
15873                        let mut m = f32::NEG_INFINITY;
15874                        for &v in row {
15875                            if v > m {
15876                                m = v;
15877                            }
15878                        }
15879                        let mut sum = 0f32;
15880                        for &v in row {
15881                            sum += (v - m).exp();
15882                        }
15883                        let lse = m + sum.ln();
15884                        let label_idx = lb[ni] as usize;
15885                        // loss = -(logits[label] - lse) = lse - logits[label].
15886                        out[ni] = lse - row[label_idx];
15887                    }
15888                }
15889            }
15890
15891            Thunk::SoftmaxCrossEntropyBackward {
15892                logits,
15893                labels,
15894                d_loss,
15895                dlogits,
15896                n,
15897                c,
15898            } => {
15899                let n = *n as usize;
15900                let c = *c as usize;
15901                unsafe {
15902                    let lg = sl(*logits, base, n * c);
15903                    let lb = sl(*labels, base, n);
15904                    let dl = sl(*d_loss, base, n);
15905                    let out = sl_mut(*dlogits, base, n * c);
15906                    for ni in 0..n {
15907                        let row = &lg[ni * c..(ni + 1) * c];
15908                        let label_idx = lb[ni] as usize;
15909                        let scale = dl[ni];
15910                        let mut m = f32::NEG_INFINITY;
15911                        for &v in row {
15912                            if v > m {
15913                                m = v;
15914                            }
15915                        }
15916                        let mut sum = 0f32;
15917                        for &v in row {
15918                            sum += (v - m).exp();
15919                        }
15920                        let inv_sum = 1.0 / sum;
15921                        let dst_row = &mut out[ni * c..(ni + 1) * c];
15922                        for k in 0..c {
15923                            let p = (row[k] - m).exp() * inv_sum;
15924                            let one_hot = if k == label_idx { 1.0 } else { 0.0 };
15925                            dst_row[k] = (p - one_hot) * scale;
15926                        }
15927                    }
15928                }
15929            }
15930
15931            Thunk::GatherAxis {
15932                table,
15933                idx,
15934                dst,
15935                outer,
15936                axis_dim,
15937                num_idx,
15938                trailing,
15939                idx_i64,
15940                table_bytes,
15941            } => {
15942                let outer = *outer as usize;
15943                let axis_dim = *axis_dim as usize;
15944                let num_idx = *num_idx as usize;
15945                let trailing = *trailing as usize;
15946                unsafe {
15947                    if *table_bytes == 8 {
15948                        let tab = sl_i64(*table, base, outer * axis_dim * trailing);
15949                        let out = sl_mut_i64(*dst, base, outer * num_idx * trailing);
15950                        for o in 0..outer {
15951                            let tab_outer = o * axis_dim * trailing;
15952                            let out_outer = o * num_idx * trailing;
15953                            if *idx_i64 != 0 {
15954                                let ids = sl_i64(*idx, base, num_idx);
15955                                for k in 0..num_idx {
15956                                    let row = ids[k].max(0) as usize;
15957                                    if row < axis_dim {
15958                                        let tab_row = tab_outer + row * trailing;
15959                                        let out_row = out_outer + k * trailing;
15960                                        out[out_row..out_row + trailing]
15961                                            .copy_from_slice(&tab[tab_row..tab_row + trailing]);
15962                                    }
15963                                }
15964                            } else {
15965                                let ids = sl(*idx, base, num_idx);
15966                                for k in 0..num_idx {
15967                                    let row = ids[k] as usize;
15968                                    if row < axis_dim {
15969                                        let tab_row = tab_outer + row * trailing;
15970                                        let out_row = out_outer + k * trailing;
15971                                        out[out_row..out_row + trailing]
15972                                            .copy_from_slice(&tab[tab_row..tab_row + trailing]);
15973                                    }
15974                                }
15975                            }
15976                        }
15977                    } else {
15978                        let tab = sl(*table, base, outer * axis_dim * trailing);
15979                        let out = sl_mut(*dst, base, outer * num_idx * trailing);
15980                        for o in 0..outer {
15981                            let tab_outer = o * axis_dim * trailing;
15982                            let out_outer = o * num_idx * trailing;
15983                            if *idx_i64 != 0 {
15984                                let ids = sl_i64(*idx, base, num_idx);
15985                                for k in 0..num_idx {
15986                                    let row = ids[k].max(0) as usize;
15987                                    if row < axis_dim {
15988                                        let tab_row = tab_outer + row * trailing;
15989                                        let out_row = out_outer + k * trailing;
15990                                        out[out_row..out_row + trailing]
15991                                            .copy_from_slice(&tab[tab_row..tab_row + trailing]);
15992                                    }
15993                                }
15994                            } else {
15995                                let ids = sl(*idx, base, num_idx);
15996                                for k in 0..num_idx {
15997                                    let row = ids[k] as usize;
15998                                    if row < axis_dim {
15999                                        let tab_row = tab_outer + row * trailing;
16000                                        let out_row = out_outer + k * trailing;
16001                                        out[out_row..out_row + trailing]
16002                                            .copy_from_slice(&tab[tab_row..tab_row + trailing]);
16003                                    }
16004                                }
16005                            }
16006                        }
16007                    }
16008                }
16009            }
16010
16011            Thunk::Transpose {
16012                src,
16013                dst,
16014                in_total,
16015                out_dims,
16016                in_strides,
16017                elem_bytes,
16018            } => {
16019                // N-D index walk: for each output flat index, decompose into
16020                // multi-dim coords using out_dims, then dot with in_strides
16021                // to find the source flat index. Stride 0 = broadcast (read
16022                // the same input element repeatedly along that dim).
16023                let rank = out_dims.len();
16024                let total: usize = out_dims.iter().map(|&d| d as usize).product();
16025                let in_total = *in_total as usize;
16026                unsafe {
16027                    if *elem_bytes == 1 {
16028                        // 1-byte dtypes (Bool / I8 / U8). Without this branch the
16029                        // `else` path below reads/writes 4 bytes per element via the
16030                        // f32 slice, corrupting e.g. a broadcast of the VITS attention
16031                        // mask (Bool, expanded over heads) — masking wrong positions.
16032                        let inp = arena_buf[*src..*src + in_total].to_vec();
16033                        let out = &mut arena_buf[*dst..*dst + total];
16034                        let mut idx = vec![0usize; rank];
16035                        for o in 0..total {
16036                            let mut src_idx = 0usize;
16037                            for d in 0..rank {
16038                                src_idx += idx[d] * in_strides[d] as usize;
16039                            }
16040                            out[o] = inp[broadcast_src_index(src_idx, in_total)];
16041                            for d in (0..rank).rev() {
16042                                idx[d] += 1;
16043                                if idx[d] < out_dims[d] as usize {
16044                                    break;
16045                                }
16046                                idx[d] = 0;
16047                            }
16048                        }
16049                    } else if *elem_bytes == 8 {
16050                        let inp = sl_i64(*src, base, in_total);
16051                        let out = sl_mut_i64(*dst, base, total);
16052                        let mut idx = vec![0usize; rank];
16053                        for o in 0..total {
16054                            let mut src_idx = 0usize;
16055                            for d in 0..rank {
16056                                src_idx += idx[d] * in_strides[d] as usize;
16057                            }
16058                            out[o] = inp[broadcast_src_index(src_idx, in_total)];
16059                            for d in (0..rank).rev() {
16060                                idx[d] += 1;
16061                                if idx[d] < out_dims[d] as usize {
16062                                    break;
16063                                }
16064                                idx[d] = 0;
16065                            }
16066                        }
16067                    } else {
16068                        let inp = sl(*src, base, in_total);
16069                        let out = sl_mut(*dst, base, total);
16070                        if rank == 4
16071                            && in_strides[0] == 0
16072                            && in_strides[2] == 0
16073                            && in_strides[3] == 0
16074                            && in_strides[1] != 0
16075                        {
16076                            // Per-channel broadcast out[n,c,h,w] = in[c*sc] — how
16077                            // a conv bias `[C]` reaches `[N,C,H,W]` (and its
16078                            // recompute in the backward graph). Fill each (n,c)
16079                            // plane with its scalar instead of an N-D index walk
16080                            // over every element; parallel over the channel-batch.
16081                            let d1 = out_dims[1] as usize;
16082                            let sc = in_strides[1] as usize;
16083                            let plane = (out_dims[2] as usize) * (out_dims[3] as usize);
16084                            let nc_total = (out_dims[0] as usize) * d1;
16085                            let out_addr = out.as_mut_ptr() as usize;
16086                            let fill = |nc0: usize, nc1: usize| {
16087                                let op = out_addr as *mut f32;
16088                                for nc in nc0..nc1 {
16089                                    let v = inp[(nc % d1) * sc];
16090                                    let base_off = nc * plane;
16091                                    for k in 0..plane {
16092                                        *op.add(base_off + k) = v;
16093                                    }
16094                                }
16095                            };
16096                            if fast_conv_enabled() && crate::pool::should_parallelize(total) {
16097                                crate::pool::par_for(
16098                                    nc_total,
16099                                    crate::pool::outer_chunk(nc_total),
16100                                    &|off, cnt| fill(off, off + cnt),
16101                                );
16102                            } else {
16103                                fill(0, nc_total);
16104                            }
16105                        } else if rank == 2 && in_strides[0] != 0 && in_strides[1] != 0 {
16106                            // Fast 2D transpose (the common matmul-backward case:
16107                            // xᵀ, wᵀ). out[i,j] = in[i*s0 + j*s1]; tiled over
16108                            // columns for write-locality, parallel over rows. Far
16109                            // cheaper than the general per-element index walk.
16110                            let d0 = out_dims[0] as usize;
16111                            let d1 = out_dims[1] as usize;
16112                            let s0 = in_strides[0] as usize;
16113                            let s1 = in_strides[1] as usize;
16114                            let out_addr = out.as_mut_ptr() as usize;
16115                            let tile = |i0: usize, i1: usize| {
16116                                let op = out_addr as *mut f32;
16117                                const T: usize = 32;
16118                                let mut j0 = 0;
16119                                while j0 < d1 {
16120                                    let j1 = (j0 + T).min(d1);
16121                                    for i in i0..i1 {
16122                                        let inb = i * s0;
16123                                        let outb = i * d1;
16124                                        for j in j0..j1 {
16125                                            *op.add(outb + j) = inp[inb + j * s1];
16126                                        }
16127                                    }
16128                                    j0 = j1;
16129                                }
16130                            };
16131                            if fast_conv_enabled() && crate::pool::should_parallelize(total) {
16132                                crate::pool::par_for(
16133                                    d0,
16134                                    crate::pool::outer_chunk(d0),
16135                                    &|off, cnt| tile(off, off + cnt),
16136                                );
16137                            } else {
16138                                tile(0, d0);
16139                            }
16140                        } else if fast_conv_enabled() && crate::pool::should_parallelize(total) {
16141                            // Parallel: each chunk seeds its starting multi-index
16142                            // from `off`, then walks incrementally. Output writes
16143                            // are disjoint per `o`.
16144                            let out_addr = out.as_mut_ptr() as usize;
16145                            crate::pool::par_for(
16146                                total,
16147                                crate::pool::chunk_floor(total),
16148                                &|off, cnt| {
16149                                    let mut idx = vec![0usize; rank];
16150                                    let mut rem = off;
16151                                    for d in (0..rank).rev() {
16152                                        let dim = out_dims[d] as usize;
16153                                        idx[d] = rem % dim;
16154                                        rem /= dim;
16155                                    }
16156                                    for o in off..off + cnt {
16157                                        let mut src_idx = 0usize;
16158                                        for d in 0..rank {
16159                                            src_idx += idx[d] * in_strides[d] as usize;
16160                                        }
16161                                        let v = inp[broadcast_src_index(src_idx, in_total)];
16162                                        *((out_addr as *mut f32).add(o)) = v;
16163                                        for d in (0..rank).rev() {
16164                                            idx[d] += 1;
16165                                            if idx[d] < out_dims[d] as usize {
16166                                                break;
16167                                            }
16168                                            idx[d] = 0;
16169                                        }
16170                                    }
16171                                },
16172                            );
16173                        } else {
16174                            let mut idx = vec![0usize; rank];
16175                            for o in 0..total {
16176                                let mut src_idx = 0usize;
16177                                for d in 0..rank {
16178                                    src_idx += idx[d] * in_strides[d] as usize;
16179                                }
16180                                out[o] = inp[broadcast_src_index(src_idx, in_total)];
16181                                for d in (0..rank).rev() {
16182                                    idx[d] += 1;
16183                                    if idx[d] < out_dims[d] as usize {
16184                                        break;
16185                                    }
16186                                    idx[d] = 0;
16187                                }
16188                            }
16189                        }
16190                    }
16191                }
16192            }
16193
16194            // (Thunk::DenseSolveF64 / Thunk::ScanBackward had panic
16195            // stubs here as placeholders during the wire-up; both
16196            // are now reached by the real implementations earlier in
16197            // this same match — the stubs were dead code shadowed by
16198            // the specific-pattern arms above. Removed.)
16199            Thunk::CustomOp {
16200                kernel,
16201                inputs,
16202                output,
16203                attrs,
16204            } => {
16205                let (out_off, out_len, out_shape) = output;
16206                unsafe {
16207                    dispatch_custom_op(
16208                        &**kernel, inputs, *out_off, *out_len, out_shape, attrs, base,
16209                    );
16210                }
16211            }
16212
16213            Thunk::Reverse {
16214                src,
16215                dst,
16216                dims,
16217                rev_mask,
16218                elem_bytes,
16219            } => {
16220                let eb = *elem_bytes as usize;
16221                let rank = dims.len();
16222                let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
16223                let mut strides = vec![1usize; rank];
16224                for i in (0..rank.saturating_sub(1)).rev() {
16225                    strides[i] = strides[i + 1] * dims[i + 1] as usize;
16226                }
16227                unsafe {
16228                    let src_base = base.add(*src);
16229                    let dst_base = base.add(*dst);
16230                    for o in 0..total {
16231                        let mut rem = o;
16232                        let mut in_flat = 0usize;
16233                        for ax in 0..rank {
16234                            let idx = rem / strides[ax];
16235                            rem %= strides[ax];
16236                            let in_idx = if rev_mask[ax] {
16237                                dims[ax] as usize - 1 - idx
16238                            } else {
16239                                idx
16240                            };
16241                            in_flat += in_idx * strides[ax];
16242                        }
16243                        std::ptr::copy_nonoverlapping(
16244                            src_base.add(in_flat * eb),
16245                            dst_base.add(o * eb),
16246                            eb,
16247                        );
16248                    }
16249                }
16250            }
16251        }
16252        if trace_done {
16253            eprintln!("[thunk {i} done]");
16254        }
16255    }
16256}
16257
16258/// Griewank treeverse: process backward iterations `[t_lo..=t_hi]` (with
16259/// the carry entering iteration `t_lo` supplied as `anchor_carry`) by
16260/// recursive binary subdivision. Total work `O((t_hi-t_lo+1) · log)`,
16261/// auxiliary memory `O(log · carry_bytes)` for the recursion stack.
16262///
16263/// Compared to the iterative segment-cached scheme, this trades extra
16264/// recompute for less working memory — each level of recursion holds
16265/// one `cb`-sized intermediate carry on the stack but never the whole
16266/// segment at once. With K saved outer checkpoints, the outer driver
16267/// invokes this helper once per segment.
16268///
16269/// `process_iter(t, carry_at_t)` is the per-iteration leaf action: it
16270/// runs `body_vjp` at iteration `t` with the supplied carry, threads
16271/// `dcarry` backward, and (for ScanBackwardXs) writes `dxs[t]`.
16272#[allow(clippy::too_many_arguments)]
16273unsafe fn griewank_process_segment(
16274    t_lo: usize,
16275    t_hi: usize,
16276    anchor_carry: &[u8],
16277    cb: usize,
16278    fwd_sched: &ThunkSchedule,
16279    fwd_init: &[u8],
16280    fwd_carry_in_off: usize,
16281    fwd_output_off: usize,
16282    fwd_x_offs: &[usize],
16283    base: *mut u8,
16284    outer_xs_offs: &[(usize, u32)],
16285    fwd_buf: &mut Vec<u8>,
16286    leaf_threshold: usize,
16287    process_iter: &mut dyn FnMut(usize, &[u8]),
16288) {
16289    unsafe {
16290        let size = t_hi - t_lo + 1;
16291        if size == 1 {
16292            process_iter(t_lo, anchor_carry);
16293            return;
16294        }
16295        if size <= leaf_threshold {
16296            // Walk forward, cache each carry, run backward in reverse.
16297            let mut cache: Vec<u8> = Vec::with_capacity(size * cb);
16298            cache.extend_from_slice(anchor_carry);
16299            fwd_buf.copy_from_slice(fwd_init);
16300            std::ptr::copy_nonoverlapping(
16301                anchor_carry.as_ptr(),
16302                fwd_buf.as_mut_ptr().add(fwd_carry_in_off),
16303                cb,
16304            );
16305            for i in 1..size {
16306                let cur_iter = t_lo + i - 1;
16307                for (idx, fb_x_off) in fwd_x_offs.iter().enumerate() {
16308                    let (outer_xs_off, x_psb) = outer_xs_offs[idx];
16309                    let xb = x_psb as usize;
16310                    std::ptr::copy_nonoverlapping(
16311                        base.add(outer_xs_off + cur_iter * xb),
16312                        fwd_buf.as_mut_ptr().add(*fb_x_off),
16313                        xb,
16314                    );
16315                }
16316                execute_thunks(fwd_sched, fwd_buf);
16317                if fwd_output_off != fwd_carry_in_off {
16318                    fwd_buf.copy_within(fwd_output_off..fwd_output_off + cb, fwd_carry_in_off);
16319                }
16320                cache.extend_from_slice(&fwd_buf[fwd_carry_in_off..fwd_carry_in_off + cb]);
16321            }
16322            // Process backward.
16323            for t in (t_lo..=t_hi).rev() {
16324                let idx = t - t_lo;
16325                let carry = &cache[idx * cb..(idx + 1) * cb];
16326                process_iter(t, carry);
16327            }
16328            return;
16329        }
16330
16331        // Split: walk forward from anchor to compute carry entering `mid`.
16332        // (We need `mid - t_lo` body executions: one per iteration in
16333        // [t_lo, mid).)
16334        let mid = t_lo + size / 2;
16335        fwd_buf.copy_from_slice(fwd_init);
16336        std::ptr::copy_nonoverlapping(
16337            anchor_carry.as_ptr(),
16338            fwd_buf.as_mut_ptr().add(fwd_carry_in_off),
16339            cb,
16340        );
16341        for cur_iter in t_lo..mid {
16342            for (idx, fb_x_off) in fwd_x_offs.iter().enumerate() {
16343                let (outer_xs_off, x_psb) = outer_xs_offs[idx];
16344                let xb = x_psb as usize;
16345                std::ptr::copy_nonoverlapping(
16346                    base.add(outer_xs_off + cur_iter * xb),
16347                    fwd_buf.as_mut_ptr().add(*fb_x_off),
16348                    xb,
16349                );
16350            }
16351            execute_thunks(fwd_sched, fwd_buf);
16352            if fwd_output_off != fwd_carry_in_off {
16353                fwd_buf.copy_within(fwd_output_off..fwd_output_off + cb, fwd_carry_in_off);
16354            }
16355        }
16356        let mid_carry: Vec<u8> = fwd_buf[fwd_carry_in_off..fwd_carry_in_off + cb].to_vec();
16357
16358        // Right half first (higher t values processed first to match the
16359        // canonical reverse-mode iteration order: dcarry threads from
16360        // t=length-1 down to t=0).
16361        griewank_process_segment(
16362            mid,
16363            t_hi,
16364            &mid_carry,
16365            cb,
16366            fwd_sched,
16367            fwd_init,
16368            fwd_carry_in_off,
16369            fwd_output_off,
16370            fwd_x_offs,
16371            base,
16372            outer_xs_offs,
16373            fwd_buf,
16374            leaf_threshold,
16375            process_iter,
16376        );
16377        // Then left half with original anchor.
16378        griewank_process_segment(
16379            t_lo,
16380            mid - 1,
16381            anchor_carry,
16382            cb,
16383            fwd_sched,
16384            fwd_init,
16385            fwd_carry_in_off,
16386            fwd_output_off,
16387            fwd_x_offs,
16388            base,
16389            outer_xs_offs,
16390            fwd_buf,
16391            leaf_threshold,
16392            process_iter,
16393        );
16394    }
16395}
16396
16397/// Execute a batched 1D FFT in the f64 2N-real-block layout.
16398/// Each "row" is `2N` f64 elements: first `N` real, then `N` imag.
16399/// The `outer` rows are independent and processed sequentially.
16400///
16401/// Both forward and inverse use the same Cooley-Tukey radix-2 DIT
16402/// kernel — only the twiddle-factor sign differs. Power-of-2 only
16403/// (the IR builder rejects non-power-of-2 sizes at graph-build time).
16404/// Batched 1D FFT on the f64 2N-real-block layout. Public so other
16405/// backend crates can invoke this as a host fallback against a
16406/// unified-memory arena (e.g. rlx-metal: sync the command buffer,
16407/// pass the Metal `Buffer::contents()` pointer as `base`, restart the
16408/// command buffer). Self-contained — no rlx-cpu state required.
16409///
16410/// Safety: `base + src` and `base + dst` must be valid for the
16411/// `outer * 2 * n_complex * sizeof::<f64>()` byte range and stay
16412/// alive for the duration of the call.
16413pub unsafe fn execute_fft1d_f64(
16414    src: usize,
16415    dst: usize,
16416    outer: usize,
16417    n_complex: usize,
16418    inverse: bool,
16419    norm_tag: u32,
16420    base: *mut u8,
16421) {
16422    let row_elems = 2 * n_complex;
16423    let mut re = vec![0f64; n_complex];
16424    let mut im = vec![0f64; n_complex];
16425    let norm = rlx_ir::fft::FftNorm::from_tag(norm_tag);
16426    let scale = norm.output_scale(n_complex, inverse);
16427    // Scratch reused across rows for the Bluestein path. Empty when
16428    // we're on the radix-2 fast path.
16429    let mut scratch = if n_complex.is_power_of_two() || n_complex <= 16 {
16430        BluesteinScratchF64::empty()
16431    } else {
16432        BluesteinScratchF64::build(n_complex, inverse)
16433    };
16434    for o in 0..outer {
16435        let row_offset = src + o * row_elems * std::mem::size_of::<f64>();
16436        let s = unsafe { sl_f64(row_offset, base, row_elems) };
16437        re.copy_from_slice(&s[..n_complex]);
16438        im.copy_from_slice(&s[n_complex..]);
16439        if n_complex.is_power_of_two() {
16440            fft_radix2_inplace_f64(&mut re, &mut im, inverse);
16441        } else if n_complex <= 16 {
16442            fft_naive_inplace_f64(&mut re, &mut im, inverse);
16443        } else {
16444            fft_bluestein_inplace_f64(&mut re, &mut im, inverse, &mut scratch);
16445        }
16446        if scale != 1.0 {
16447            re.iter_mut().for_each(|v| *v *= scale);
16448            im.iter_mut().for_each(|v| *v *= scale);
16449        }
16450        let dst_offset = dst + o * row_elems * std::mem::size_of::<f64>();
16451        let d = unsafe { sl_mut_f64(dst_offset, base, row_elems) };
16452        d[..n_complex].copy_from_slice(&re);
16453        d[n_complex..].copy_from_slice(&im);
16454    }
16455}
16456
16457/// f32 counterpart of `execute_fft1d_f64`. Same 2N-real-block layout
16458/// (first N real, second N imag per row), same unnormalized
16459/// convention; only the element width differs. Twiddle factors are
16460/// computed in f64 and cast to f32 to keep large-N error closer to
16461/// the f64 path (the savings from f32 are in memory bandwidth, not in
16462/// twiddle precision).
16463/// Complex (C64) dense GEMM `C[m,n] = A[m,k] · B[k,n]`. Operands are
16464/// interleaved `[re, im]` f32; `a_off`/`b_off`/`c_off` are byte offsets
16465/// into `base`. Parallel over output rows (disjoint writes).
16466unsafe fn cgemm_c64(
16467    a_off: usize,
16468    b_off: usize,
16469    c_off: usize,
16470    m: usize,
16471    k: usize,
16472    n: usize,
16473    base: *mut u8,
16474) {
16475    let bptr = base as usize;
16476    unsafe {
16477        let a = std::slice::from_raw_parts((bptr + a_off) as *const f32, 2 * m * k);
16478        let b = std::slice::from_raw_parts((bptr + b_off) as *const f32, 2 * k * n);
16479        let c_base = bptr + c_off;
16480        crate::pool::par_range(m, |i| {
16481            let crow = std::slice::from_raw_parts_mut((c_base + i * n * 8) as *mut f32, 2 * n);
16482            for j in 0..n {
16483                let mut re = 0f32;
16484                let mut im = 0f32;
16485                for l in 0..k {
16486                    let ar = a[2 * (i * k + l)];
16487                    let ai = a[2 * (i * k + l) + 1];
16488                    let br = b[2 * (l * n + j)];
16489                    let bi = b[2 * (l * n + j) + 1];
16490                    re += ar * br - ai * bi;
16491                    im += ar * bi + ai * br;
16492                }
16493                crow[2 * j] = re;
16494                crow[2 * j + 1] = im;
16495            }
16496        });
16497    }
16498}
16499
16500/// Reference / host-fallback entry for `Op::Lstm` (multi-layer,
16501/// optionally bidirectional, optional decode carry). Gate order i, f, g,
16502/// o. Shared by the CPU backend and the CUDA / ROCm / wgpu / Metal host
16503/// fallbacks. Tensors are `f32` in the arena; weights are packed (see
16504/// `Op::Lstm`). `h0`/`c0` are byte offsets when `carry`, else ignored;
16505/// the final `hn`/`cn` are written back into them in place. `dst` is
16506/// `[batch, seq, D*hidden]`. Batch items run in parallel per direction.
16507#[allow(clippy::too_many_arguments)]
16508pub unsafe fn execute_lstm_f32(
16509    x: usize,
16510    w_ih: usize,
16511    w_hh: usize,
16512    bias: usize,
16513    h0: usize,
16514    c0: usize,
16515    dst: usize,
16516    batch: usize,
16517    seq: usize,
16518    input_size: usize,
16519    hidden: usize,
16520    num_layers: usize,
16521    bidirectional: bool,
16522    carry: bool,
16523    base: *mut u8,
16524) {
16525    #[inline]
16526    fn sigmoid(z: f32) -> f32 {
16527        1.0 / (1.0 + (-z).exp())
16528    }
16529
16530    let bptr = base as usize;
16531    let four_h = 4 * hidden;
16532    let dirs = if bidirectional { 2 } else { 1 };
16533
16534    unsafe {
16535        let f32s = |off: usize, n: usize| -> &[f32] {
16536            std::slice::from_raw_parts((bptr + off) as *const f32, n)
16537        };
16538
16539        // Layer 0 reads x; later layers read the previous layer's output.
16540        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
16541        let mut in_l = input_size;
16542        // Running element cursor into the packed `w_ih` buffer (block width
16543        // `4h * in_l` varies per layer; `w_hh`/`bias` blocks are uniform).
16544        let mut wih_cursor = 0usize;
16545
16546        for l in 0..num_layers {
16547            let out_width = dirs * hidden;
16548            let mut layer_out = vec![0f32; batch * seq * out_width];
16549            let lo_ptr = layer_out.as_mut_ptr() as usize;
16550            let li_ref: &[f32] = &layer_in;
16551            let wih_block = four_h * in_l;
16552
16553            for dir in 0..dirs {
16554                let ld = l * dirs + dir;
16555                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
16556                let whh = f32s(w_hh + ld * four_h * hidden * 4, four_h * hidden);
16557                let bs = f32s(bias + ld * four_h * 4, four_h);
16558                let h0p = bptr + h0 + ld * batch * hidden * 4;
16559                let c0p = bptr + c0 + ld * batch * hidden * 4;
16560
16561                crate::pool::par_range(batch, |b| {
16562                    let lo = lo_ptr as *mut f32;
16563                    let mut h = vec![0f32; hidden];
16564                    let mut c = vec![0f32; hidden];
16565                    if carry {
16566                        let hin = std::slice::from_raw_parts(
16567                            (h0p + b * hidden * 4) as *const f32,
16568                            hidden,
16569                        );
16570                        let cin = std::slice::from_raw_parts(
16571                            (c0p + b * hidden * 4) as *const f32,
16572                            hidden,
16573                        );
16574                        h.copy_from_slice(hin);
16575                        c.copy_from_slice(cin);
16576                    }
16577                    let mut z = vec![0f32; four_h];
16578                    for step in 0..seq {
16579                        let t = if dir == 0 { step } else { seq - 1 - step };
16580                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
16581                        for r in 0..four_h {
16582                            let wr = &wih[r * in_l..(r + 1) * in_l];
16583                            let mut acc = bs[r];
16584                            for j in 0..in_l {
16585                                acc += wr[j] * x_t[j];
16586                            }
16587                            let hr = &whh[r * hidden..(r + 1) * hidden];
16588                            for (j, &hj) in h.iter().enumerate() {
16589                                acc += hr[j] * hj;
16590                            }
16591                            z[r] = acc;
16592                        }
16593                        for k in 0..hidden {
16594                            let i_g = sigmoid(z[k]);
16595                            let f_g = sigmoid(z[hidden + k]);
16596                            let g_g = z[2 * hidden + k].tanh();
16597                            let o_g = sigmoid(z[3 * hidden + k]);
16598                            let c_new = f_g * c[k] + i_g * g_g;
16599                            c[k] = c_new;
16600                            let h_new = o_g * c_new.tanh();
16601                            h[k] = h_new;
16602                            // [batch, seq, D*hidden]; this direction owns the
16603                            // `dir*hidden .. dir*hidden+hidden` feature slice.
16604                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new;
16605                        }
16606                    }
16607                    if carry {
16608                        let hout = std::slice::from_raw_parts_mut(
16609                            (h0p + b * hidden * 4) as *mut f32,
16610                            hidden,
16611                        );
16612                        let cout = std::slice::from_raw_parts_mut(
16613                            (c0p + b * hidden * 4) as *mut f32,
16614                            hidden,
16615                        );
16616                        hout.copy_from_slice(&h);
16617                        cout.copy_from_slice(&c);
16618                    }
16619                });
16620            }
16621
16622            wih_cursor += dirs * wih_block;
16623            layer_in = layer_out;
16624            in_l = out_width;
16625        }
16626
16627        // Final layer output → dst [batch, seq, D*hidden].
16628        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
16629        dst_slice.copy_from_slice(&layer_in);
16630    }
16631}
16632
16633/// Reference / host-fallback for `Op::Gru` (PyTorch GRU; gate order r, z, n;
16634/// multi-layer / bidirectional / carry). Separate `b_ih`/`b_hh` because the
16635/// reset gate is applied to the hidden term *after* its bias:
16636/// `n = tanh(x·W_inᵀ+b_in + r ⊙ (h·W_hnᵀ+b_hn))`,
16637/// `h' = (1-z)⊙n + z⊙h`. Packing mirrors `Op::Lstm` with `3*hidden` gate rows.
16638#[allow(clippy::too_many_arguments)]
16639pub unsafe fn execute_gru_f32(
16640    x: usize,
16641    w_ih: usize,
16642    w_hh: usize,
16643    b_ih: usize,
16644    b_hh: usize,
16645    h0: usize,
16646    dst: usize,
16647    batch: usize,
16648    seq: usize,
16649    input_size: usize,
16650    hidden: usize,
16651    num_layers: usize,
16652    bidirectional: bool,
16653    carry: bool,
16654    base: *mut u8,
16655) {
16656    #[inline]
16657    fn sigmoid(z: f32) -> f32 {
16658        1.0 / (1.0 + (-z).exp())
16659    }
16660
16661    let bptr = base as usize;
16662    let three_h = 3 * hidden;
16663    let dirs = if bidirectional { 2 } else { 1 };
16664
16665    unsafe {
16666        let f32s = |off: usize, n: usize| -> &[f32] {
16667            std::slice::from_raw_parts((bptr + off) as *const f32, n)
16668        };
16669
16670        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
16671        let mut in_l = input_size;
16672        let mut wih_cursor = 0usize;
16673
16674        for l in 0..num_layers {
16675            let out_width = dirs * hidden;
16676            let mut layer_out = vec![0f32; batch * seq * out_width];
16677            let lo_ptr = layer_out.as_mut_ptr() as usize;
16678            let li_ref: &[f32] = &layer_in;
16679            let wih_block = three_h * in_l;
16680
16681            for dir in 0..dirs {
16682                let ld = l * dirs + dir;
16683                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
16684                let whh = f32s(w_hh + ld * three_h * hidden * 4, three_h * hidden);
16685                let bih = f32s(b_ih + ld * three_h * 4, three_h);
16686                let bhh = f32s(b_hh + ld * three_h * 4, three_h);
16687                let h0p = bptr + h0 + ld * batch * hidden * 4;
16688
16689                crate::pool::par_range(batch, |b| {
16690                    let lo = lo_ptr as *mut f32;
16691                    let mut h = vec![0f32; hidden];
16692                    if carry {
16693                        let hin = std::slice::from_raw_parts(
16694                            (h0p + b * hidden * 4) as *const f32,
16695                            hidden,
16696                        );
16697                        h.copy_from_slice(hin);
16698                    }
16699                    let mut xi = vec![0f32; three_h]; // x·W_ihᵀ + b_ih
16700                    let mut hi = vec![0f32; three_h]; // h·W_hhᵀ + b_hh
16701                    for step in 0..seq {
16702                        let t = if dir == 0 { step } else { seq - 1 - step };
16703                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
16704                        for r in 0..three_h {
16705                            let wr = &wih[r * in_l..(r + 1) * in_l];
16706                            let mut a = bih[r];
16707                            for j in 0..in_l {
16708                                a += wr[j] * x_t[j];
16709                            }
16710                            xi[r] = a;
16711                            let hr = &whh[r * hidden..(r + 1) * hidden];
16712                            let mut bb = bhh[r];
16713                            for (j, &hj) in h.iter().enumerate() {
16714                                bb += hr[j] * hj;
16715                            }
16716                            hi[r] = bb;
16717                        }
16718                        for k in 0..hidden {
16719                            let rg = sigmoid(xi[k] + hi[k]);
16720                            let zg = sigmoid(xi[hidden + k] + hi[hidden + k]);
16721                            // Reset applied to hidden term after its bias.
16722                            let ng = (xi[2 * hidden + k] + rg * hi[2 * hidden + k]).tanh();
16723                            let h_new = (1.0 - zg) * ng + zg * h[k];
16724                            h[k] = h_new;
16725                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new;
16726                        }
16727                    }
16728                    if carry {
16729                        let hout = std::slice::from_raw_parts_mut(
16730                            (h0p + b * hidden * 4) as *mut f32,
16731                            hidden,
16732                        );
16733                        hout.copy_from_slice(&h);
16734                    }
16735                });
16736            }
16737
16738            wih_cursor += dirs * wih_block;
16739            layer_in = layer_out;
16740            in_l = out_width;
16741        }
16742
16743        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
16744        dst_slice.copy_from_slice(&layer_in);
16745    }
16746}
16747
16748/// Reference / host-fallback for `Op::Rnn` (Elman; `act = relu` when `relu`
16749/// else `tanh`; multi-layer / bidirectional / carry). Single merged bias.
16750/// `h' = act(x·W_ihᵀ + h·W_hhᵀ + bias)`. Packing mirrors `Op::Lstm` with
16751/// `hidden` gate rows.
16752#[allow(clippy::too_many_arguments)]
16753pub unsafe fn execute_rnn_f32(
16754    x: usize,
16755    w_ih: usize,
16756    w_hh: usize,
16757    bias: usize,
16758    h0: usize,
16759    dst: usize,
16760    batch: usize,
16761    seq: usize,
16762    input_size: usize,
16763    hidden: usize,
16764    num_layers: usize,
16765    bidirectional: bool,
16766    carry: bool,
16767    relu: bool,
16768    base: *mut u8,
16769) {
16770    let bptr = base as usize;
16771    let dirs = if bidirectional { 2 } else { 1 };
16772
16773    unsafe {
16774        let f32s = |off: usize, n: usize| -> &[f32] {
16775            std::slice::from_raw_parts((bptr + off) as *const f32, n)
16776        };
16777
16778        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
16779        let mut in_l = input_size;
16780        let mut wih_cursor = 0usize;
16781
16782        for l in 0..num_layers {
16783            let out_width = dirs * hidden;
16784            let mut layer_out = vec![0f32; batch * seq * out_width];
16785            let lo_ptr = layer_out.as_mut_ptr() as usize;
16786            let li_ref: &[f32] = &layer_in;
16787            let wih_block = hidden * in_l;
16788
16789            for dir in 0..dirs {
16790                let ld = l * dirs + dir;
16791                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
16792                let whh = f32s(w_hh + ld * hidden * hidden * 4, hidden * hidden);
16793                let bs = f32s(bias + ld * hidden * 4, hidden);
16794                let h0p = bptr + h0 + ld * batch * hidden * 4;
16795
16796                crate::pool::par_range(batch, |b| {
16797                    let lo = lo_ptr as *mut f32;
16798                    let mut h = vec![0f32; hidden];
16799                    if carry {
16800                        let hin = std::slice::from_raw_parts(
16801                            (h0p + b * hidden * 4) as *const f32,
16802                            hidden,
16803                        );
16804                        h.copy_from_slice(hin);
16805                    }
16806                    for step in 0..seq {
16807                        let t = if dir == 0 { step } else { seq - 1 - step };
16808                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
16809                        let mut h_new = vec![0f32; hidden];
16810                        for k in 0..hidden {
16811                            let wr = &wih[k * in_l..(k + 1) * in_l];
16812                            let mut acc = bs[k];
16813                            for j in 0..in_l {
16814                                acc += wr[j] * x_t[j];
16815                            }
16816                            let hr = &whh[k * hidden..(k + 1) * hidden];
16817                            for (j, &hj) in h.iter().enumerate() {
16818                                acc += hr[j] * hj;
16819                            }
16820                            h_new[k] = if relu { acc.max(0.0) } else { acc.tanh() };
16821                        }
16822                        for k in 0..hidden {
16823                            h[k] = h_new[k];
16824                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new[k];
16825                        }
16826                    }
16827                    if carry {
16828                        let hout = std::slice::from_raw_parts_mut(
16829                            (h0p + b * hidden * 4) as *mut f32,
16830                            hidden,
16831                        );
16832                        hout.copy_from_slice(&h);
16833                    }
16834                });
16835            }
16836
16837            wih_cursor += dirs * wih_block;
16838            layer_in = layer_out;
16839            in_l = out_width;
16840        }
16841
16842        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
16843        dst_slice.copy_from_slice(&layer_in);
16844    }
16845}
16846
16847/// Reference for `Op::ArgMax`/`Op::ArgMin` (f32-encoded indices, first-hit
16848/// tie-break). Reduces the middle `reduced` axis: input is logically
16849/// `[outer, reduced, inner]`, output `[outer, inner]`. Shared by the CPU thunk
16850/// and the Metal/WGPU host paths.
16851pub unsafe fn execute_argreduce_f32(
16852    src: usize,
16853    dst: usize,
16854    outer: usize,
16855    reduced: usize,
16856    inner: usize,
16857    is_max: bool,
16858    base: *mut u8,
16859) {
16860    let bptr = base as usize;
16861    unsafe {
16862        let inp = std::slice::from_raw_parts((bptr + src) as *const f32, outer * reduced * inner);
16863        let out = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, outer * inner);
16864        for o in 0..outer {
16865            for i in 0..inner {
16866                let mut best = inp[o * reduced * inner + i];
16867                let mut best_idx = 0usize;
16868                for r in 1..reduced {
16869                    let v = inp[o * reduced * inner + r * inner + i];
16870                    let better = if is_max { v > best } else { v < best };
16871                    if better {
16872                        best = v;
16873                        best_idx = r;
16874                    }
16875                }
16876                out[o * inner + i] = best_idx as f32;
16877            }
16878        }
16879    }
16880}
16881
16882/// Reference for `Op::Mamba2` — SSD scalar-decay SSM scan. Inputs (f32):
16883/// `x [B,S,H,P]`, `dt [B,S,H]`, `a [H]`, `b/c [B,S,H,N]`. Per `(batch, head)`
16884/// the state `S[P,N]` is zero-init: `dA=exp(dt·a)`, `S=dA·S + (dt·x)⊗b`,
16885/// `y=Σ_n S[:,n]·c[n]`. Output `[B,S,H,P]`.
16886#[allow(clippy::too_many_arguments)]
16887pub unsafe fn execute_mamba2_f32(
16888    x: usize,
16889    dt: usize,
16890    a: usize,
16891    b: usize,
16892    c: usize,
16893    dst: usize,
16894    batch: usize,
16895    seq: usize,
16896    heads: usize,
16897    head_dim: usize,
16898    state_size: usize,
16899    base: *mut u8,
16900) {
16901    let (bn, s, h, p, n) = (batch, seq, heads, head_dim, state_size);
16902    let bptr = base as usize;
16903    unsafe {
16904        let f32s = |off: usize, len: usize| -> &[f32] {
16905            std::slice::from_raw_parts((bptr + off) as *const f32, len)
16906        };
16907        let xs = f32s(x, bn * s * h * p);
16908        let dts = f32s(dt, bn * s * h);
16909        let am = f32s(a, h);
16910        let bm = f32s(b, bn * s * h * n);
16911        let cm = f32s(c, bn * s * h * n);
16912        let out_ptr = bptr + dst;
16913
16914        // Independent per (batch, head).
16915        crate::pool::par_range(bn * h, |bh| {
16916            let bi = bh / h;
16917            let hi = bh % h;
16918            let out = out_ptr as *mut f32;
16919            let mut state = vec![0f32; p * n];
16920            for t in 0..s {
16921                let dt_t = dts[(bi * s + t) * h + hi];
16922                let da = (dt_t * am[hi]).exp();
16923                let x_off = ((bi * s + t) * h + hi) * p;
16924                let bc_off = ((bi * s + t) * h + hi) * n;
16925                for pi in 0..p {
16926                    let dtx = dt_t * xs[x_off + pi];
16927                    for ni in 0..n {
16928                        state[pi * n + ni] = da * state[pi * n + ni] + dtx * bm[bc_off + ni];
16929                    }
16930                }
16931                for pi in 0..p {
16932                    let mut acc = 0f32;
16933                    for ni in 0..n {
16934                        acc += state[pi * n + ni] * cm[bc_off + ni];
16935                    }
16936                    *out.add(x_off + pi) = acc;
16937                }
16938            }
16939        });
16940    }
16941}
16942
16943/// Host-fallback entry for `Op::GatedDeltaNet` (Metal / unified memory).
16944/// When `state == 0`, uses a zero-initialized scratch state per batch item.
16945/// Batch-general reverse/flip (dtype-agnostic, byte-copy). Shared by the CPU
16946/// `Thunk::Reverse` arm and the Metal/WGPU host paths. `dims` is the row-major
16947/// input shape; `rev_mask[a]` flips axis `a`. `src`/`dst` are byte offsets.
16948pub unsafe fn execute_reverse(
16949    src: usize,
16950    dst: usize,
16951    dims: &[u32],
16952    rev_mask: &[bool],
16953    elem_bytes: usize,
16954    base: *mut u8,
16955) {
16956    let rank = dims.len();
16957    let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
16958    let mut strides = vec![1usize; rank];
16959    for i in (0..rank.saturating_sub(1)).rev() {
16960        strides[i] = strides[i + 1] * dims[i + 1] as usize;
16961    }
16962    unsafe {
16963        let src_base = base.add(src);
16964        let dst_base = base.add(dst);
16965        for o in 0..total {
16966            let mut rem = o;
16967            let mut in_flat = 0usize;
16968            for ax in 0..rank {
16969                let idx = rem / strides[ax];
16970                rem %= strides[ax];
16971                let in_idx = if rev_mask[ax] {
16972                    dims[ax] as usize - 1 - idx
16973                } else {
16974                    idx
16975                };
16976                in_flat += in_idx * strides[ax];
16977            }
16978            std::ptr::copy_nonoverlapping(
16979                src_base.add(in_flat * elem_bytes),
16980                dst_base.add(o * elem_bytes),
16981                elem_bytes,
16982            );
16983        }
16984    }
16985}
16986
16987/// Token-sampling reference. Shared by the CPU `Thunk::Sample` arm and the
16988/// Metal unified-memory host fallback. `logits` is `[batch, vocab]` f32;
16989/// writes one f32-encoded index per batch row to `dst`. With a fixed `seed`
16990/// (and same top_k/top_p/temperature) the result is deterministic.
16991pub unsafe fn execute_sample_f32(
16992    logits: usize,
16993    dst: usize,
16994    batch: usize,
16995    vocab: usize,
16996    top_k: usize,
16997    top_p: f32,
16998    temperature: f32,
16999    seed: u64,
17000    base: *mut u8,
17001) {
17002    let (b, v) = (batch, vocab);
17003    let k = top_k.min(v);
17004    unsafe {
17005        let lg = sl(logits, base, b * v);
17006        let out = sl_mut(dst, base, b);
17007        let mut rng = rlx_ir::Philox4x32::new(if seed == 0 { 0xDEADBEEF } else { seed });
17008        for bi in 0..b {
17009            let row = &lg[bi * v..(bi + 1) * v];
17010            out[bi] = sample_row(row, k, top_p, temperature, &mut rng) as f32;
17011        }
17012    }
17013}
17014
17015/// Mamba selective-scan reference (f32). Shared by the CPU `Thunk::SelectiveScan`
17016/// arm and the Metal unified-memory host fallback. `base` is the arena byte
17017/// pointer; the five inputs (`x`, `delta`, `a`, `b`, `c`) and `dst` are byte
17018/// offsets into it. Recurrence per `(batch, seq, channel, state)`:
17019/// `h[t] = exp(Δ·A)·h[t-1] + Δ·B·x;  y = Σ_n C·h`. State resets per batch row.
17020pub unsafe fn execute_selective_scan_f32(
17021    x: usize,
17022    delta: usize,
17023    a: usize,
17024    b: usize,
17025    c: usize,
17026    dst: usize,
17027    batch: usize,
17028    seq: usize,
17029    hidden: usize,
17030    state_size: usize,
17031    base: *mut u8,
17032) {
17033    let (bn, s, h, n) = (batch, seq, hidden, state_size);
17034    unsafe {
17035        let xs = sl(x, base, bn * s * h);
17036        let dt = sl(delta, base, bn * s * h);
17037        let am = sl(a, base, h * n);
17038        let bm = sl(b, base, bn * s * n);
17039        let cm = sl(c, base, bn * s * n);
17040        let out = sl_mut(dst, base, bn * s * h);
17041
17042        // State buffer per-batch: h channels × n state. Sequential along
17043        // the seq dimension; reset at the start of each batch row.
17044        let mut state = vec![0f32; h * n];
17045        for bi in 0..bn {
17046            for v in state.iter_mut() {
17047                *v = 0.0;
17048            }
17049            for si in 0..s {
17050                let x_row = &xs[bi * s * h + si * h..bi * s * h + (si + 1) * h];
17051                let dt_row = &dt[bi * s * h + si * h..bi * s * h + (si + 1) * h];
17052                let b_row = &bm[bi * s * n + si * n..bi * s * n + (si + 1) * n];
17053                let c_row = &cm[bi * s * n + si * n..bi * s * n + (si + 1) * n];
17054                let out_row = &mut out[bi * s * h + si * h..bi * s * h + (si + 1) * h];
17055
17056                for ci in 0..h {
17057                    let d = dt_row[ci];
17058                    let xv = x_row[ci];
17059                    let mut acc = 0f32;
17060                    for ni in 0..n {
17061                        // Discretize: exp(d * a) and d * b.
17062                        let da = (d * am[ci * n + ni]).exp();
17063                        state[ci * n + ni] = da * state[ci * n + ni] + d * b_row[ni] * xv;
17064                        acc += c_row[ni] * state[ci * n + ni];
17065                    }
17066                    out_row[ci] = acc;
17067                }
17068            }
17069        }
17070    }
17071}
17072
17073pub unsafe fn execute_gated_delta_net_f32(
17074    q: usize,
17075    k: usize,
17076    v: usize,
17077    g: usize,
17078    beta: usize,
17079    state: usize,
17080    dst: usize,
17081    batch: usize,
17082    seq: usize,
17083    heads: usize,
17084    state_size: usize,
17085    base: *mut u8,
17086) {
17087    #[derive(Copy, Clone)]
17088    struct ArenaPtr(usize);
17089    unsafe impl Send for ArenaPtr {}
17090    unsafe impl Sync for ArenaPtr {}
17091    impl ArenaPtr {
17092        #[inline]
17093        fn get(self) -> *mut u8 {
17094            self.0 as *mut u8
17095        }
17096    }
17097
17098    unsafe {
17099        let arena = ArenaPtr(base as usize);
17100        let (b, s, h, n) = (batch, seq, heads, state_size);
17101        let scale = 1.0f32 / (n as f32).sqrt();
17102        let use_external = state != 0;
17103        let mut owned_state = vec![0f32; h * n * n];
17104
17105        crate::pool::num_threads();
17106
17107        assert!(
17108            n <= crate::gdn::GDN_MAX_STATE,
17109            "GatedDeltaNet state_size={n} exceeds stack scratch ({})",
17110            crate::gdn::GDN_MAX_STATE
17111        );
17112
17113        let qs = sl(q, arena.get(), b * s * h * n);
17114        let ks = sl(k, arena.get(), b * s * h * n);
17115        let vs = sl(v, arena.get(), b * s * h * n);
17116        let gs = sl(g, arena.get(), b * s * h);
17117        let betas = sl(beta, arena.get(), b * s * h);
17118        let _out = sl_mut(dst, arena.get(), b * s * h * n);
17119        let hs_n = h * n;
17120
17121        let run_head = |bi: usize, hi: usize, s_mat: &mut [f32], sk: &mut [f32]| {
17122            for ti in 0..s {
17123                let qkv_step = bi * s * hs_n + ti * hs_n + hi * n;
17124                let gb_step = bi * s * h + ti * h + hi;
17125                let out_row = sl_mut(dst + qkv_step * std::mem::size_of::<f32>(), arena.get(), n);
17126                crate::gdn::gdn_step_blas(
17127                    s_mat,
17128                    &qs[qkv_step..qkv_step + n],
17129                    &ks[qkv_step..qkv_step + n],
17130                    &vs[qkv_step..qkv_step + n],
17131                    gs[gb_step],
17132                    betas[gb_step],
17133                    out_row,
17134                    sk,
17135                    n,
17136                    scale,
17137                );
17138            }
17139        };
17140
17141        // Prefill (seq>1, ephemeral state): time-outer, parallel over heads —
17142        // better occupancy than head-outer when prompt length dominates.
17143        if !use_external && s > 1 {
17144            for bi in 0..b {
17145                crate::pool::par_range(h, |hi| {
17146                    let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
17147                    let sk = &mut sk_buf[..n];
17148                    let mut local_state =
17149                        [0f32; crate::gdn::GDN_MAX_STATE * crate::gdn::GDN_MAX_STATE];
17150                    let s_mat = &mut local_state[..n * n];
17151                    s_mat.fill(0.0);
17152                    run_head(bi, hi, s_mat, sk);
17153                });
17154            }
17155            return;
17156        }
17157
17158        if use_external {
17159            let state_bytes = state;
17160            crate::pool::par_range(b * h, |bhi| {
17161                let bi = bhi / h;
17162                let hi = bhi % h;
17163                let elem_off = bi * h * n * n + hi * n * n;
17164                let s_mat = sl_mut(
17165                    state_bytes + elem_off * std::mem::size_of::<f32>(),
17166                    arena.get(),
17167                    n * n,
17168                );
17169                let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
17170                run_head(bi, hi, s_mat, &mut sk_buf[..n]);
17171            });
17172        } else {
17173            for bi in 0..b {
17174                owned_state.fill(0.0);
17175                #[cfg(not(target_arch = "wasm32"))]
17176                {
17177                    use rayon::prelude::*;
17178                    owned_state
17179                        .par_chunks_mut(n * n)
17180                        .enumerate()
17181                        .for_each(|(hi, s_mat)| {
17182                            let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
17183                            run_head(bi, hi, s_mat, &mut sk_buf[..n]);
17184                        });
17185                }
17186                #[cfg(target_arch = "wasm32")]
17187                {
17188                    owned_state
17189                        .chunks_mut(n * n)
17190                        .enumerate()
17191                        .for_each(|(hi, s_mat)| {
17192                            let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
17193                            run_head(bi, hi, s_mat, &mut sk_buf[..n]);
17194                        });
17195                }
17196            }
17197        }
17198    }
17199}
17200
17201/// Host-fallback: `Op::RmsNormBackwardInput` (GPU unified-memory / D2H arenas).
17202pub unsafe fn execute_rms_norm_backward_input_f32(
17203    x: usize,
17204    gamma: usize,
17205    beta: usize,
17206    dy: usize,
17207    dx: usize,
17208    rows: u32,
17209    h: u32,
17210    eps: f32,
17211    base: *mut u8,
17212) {
17213    let (rows, h) = (rows as usize, h as usize);
17214    let mut dg = vec![0f32; h];
17215    let mut db = vec![0f32; h];
17216    let xs = sl(x, base, rows * h);
17217    let dys = sl(dy, base, rows * h);
17218    let g = sl(gamma, base, h);
17219    let b = sl(beta, base, h);
17220    let out = sl_mut(dx, base, rows * h);
17221    for r in 0..rows {
17222        crate::training_bwd::rms_norm_backward_row(
17223            &xs[r * h..(r + 1) * h],
17224            g,
17225            b,
17226            &dys[r * h..(r + 1) * h],
17227            &mut out[r * h..(r + 1) * h],
17228            &mut dg,
17229            &mut db,
17230            eps,
17231        );
17232    }
17233}
17234
17235pub unsafe fn execute_rms_norm_backward_gamma_f32(
17236    x: usize,
17237    gamma: usize,
17238    beta: usize,
17239    dy: usize,
17240    dgamma: usize,
17241    rows: u32,
17242    h: u32,
17243    eps: f32,
17244    base: *mut u8,
17245) {
17246    let (rows, h) = (rows as usize, h as usize);
17247    let out = sl_mut(dgamma, base, h);
17248    out.fill(0.0);
17249    let mut dx = vec![0f32; h];
17250    let mut db = vec![0f32; h];
17251    let xs = sl(x, base, rows * h);
17252    let dys = sl(dy, base, rows * h);
17253    let g = sl(gamma, base, h);
17254    let b = sl(beta, base, h);
17255    for r in 0..rows {
17256        crate::training_bwd::rms_norm_backward_row(
17257            &xs[r * h..(r + 1) * h],
17258            g,
17259            b,
17260            &dys[r * h..(r + 1) * h],
17261            &mut dx,
17262            out,
17263            &mut db,
17264            eps,
17265        );
17266    }
17267}
17268
17269pub unsafe fn execute_rms_norm_backward_beta_f32(
17270    x: usize,
17271    gamma: usize,
17272    beta: usize,
17273    dy: usize,
17274    dbeta: usize,
17275    rows: u32,
17276    h: u32,
17277    eps: f32,
17278    base: *mut u8,
17279) {
17280    let (rows, h) = (rows as usize, h as usize);
17281    let out = sl_mut(dbeta, base, h);
17282    out.fill(0.0);
17283    let mut dx = vec![0f32; h];
17284    let mut dg = vec![0f32; h];
17285    let xs = sl(x, base, rows * h);
17286    let dys = sl(dy, base, rows * h);
17287    let g = sl(gamma, base, h);
17288    let b = sl(beta, base, h);
17289    for r in 0..rows {
17290        crate::training_bwd::rms_norm_backward_row(
17291            &xs[r * h..(r + 1) * h],
17292            g,
17293            b,
17294            &dys[r * h..(r + 1) * h],
17295            &mut dx,
17296            &mut dg,
17297            out,
17298            eps,
17299        );
17300    }
17301}
17302
17303#[allow(clippy::too_many_arguments)]
17304pub unsafe fn execute_conv2d_forward_f32(
17305    src: usize,
17306    weight: usize,
17307    dst: usize,
17308    n: u32,
17309    c_in: u32,
17310    h: u32,
17311    w: u32,
17312    c_out: u32,
17313    h_out: u32,
17314    w_out: u32,
17315    kh: u32,
17316    kw: u32,
17317    sh: u32,
17318    sw: u32,
17319    ph: u32,
17320    pw: u32,
17321    dh: u32,
17322    dw: u32,
17323    groups: u32,
17324    base: *mut u8,
17325) {
17326    let n = n as usize;
17327    let c_in = c_in as usize;
17328    let h = h as usize;
17329    let w = w as usize;
17330    let c_out = c_out as usize;
17331    let h_out = h_out as usize;
17332    let w_out = w_out as usize;
17333    let kh = kh as usize;
17334    let kw = kw as usize;
17335    let sh = sh as usize;
17336    let sw = sw as usize;
17337    let ph = ph as usize;
17338    let pw = pw as usize;
17339    let dh = dh as usize;
17340    let dw = dw as usize;
17341    let groups = groups as usize;
17342    let c_in_per_g = c_in / groups;
17343    let inp = sl(src, base, n * c_in * h * w);
17344    let wt = sl(weight, base, c_out * c_in_per_g * kh * kw);
17345    let out = sl_mut(dst, base, n * c_out * h_out * w_out);
17346    crate::conv_fwd::conv2d_forward_nchw_f32(
17347        inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw, groups,
17348    );
17349}
17350
17351pub unsafe fn execute_maxpool2d_backward_f32(
17352    x: usize,
17353    dy: usize,
17354    dx: usize,
17355    n: u32,
17356    c: u32,
17357    h: u32,
17358    w: u32,
17359    h_out: u32,
17360    w_out: u32,
17361    kh: u32,
17362    kw: u32,
17363    sh: u32,
17364    sw: u32,
17365    ph: u32,
17366    pw: u32,
17367    base: *mut u8,
17368) {
17369    let (n, c, h, w) = (n as usize, c as usize, h as usize, w as usize);
17370    let (h_out, w_out) = (h_out as usize, w_out as usize);
17371    let (kh, kw) = (kh as usize, kw as usize);
17372    let (sh, sw) = (sh as usize, sw as usize);
17373    let (ph, pw) = (ph as usize, pw as usize);
17374    let xs = sl(x, base, n * c * h * w);
17375    let dys = sl(dy, base, n * c * h_out * w_out);
17376    let dxs = sl_mut(dx, base, n * c * h * w);
17377    // Each (n, c) plane is independent — under RLX_FAST_CONV, fan the existing
17378    // per-plane kernel out over the channel-batch (disjoint dx windows).
17379    if fast_conv_enabled() && crate::pool::should_parallelize(n * c * h * w) {
17380        let (in_plane, out_plane) = (h * w, h_out * w_out);
17381        let x_addr = xs.as_ptr() as usize;
17382        let dy_addr = dys.as_ptr() as usize;
17383        let dx_addr = dxs.as_mut_ptr() as usize;
17384        crate::pool::par_for(n * c, crate::pool::outer_chunk(n * c), &|off, cnt| {
17385            for nc in off..off + cnt {
17386                let xp =
17387                    std::slice::from_raw_parts((x_addr as *const f32).add(nc * in_plane), in_plane);
17388                let dyp = std::slice::from_raw_parts(
17389                    (dy_addr as *const f32).add(nc * out_plane),
17390                    out_plane,
17391                );
17392                let dxp = std::slice::from_raw_parts_mut(
17393                    (dx_addr as *mut f32).add(nc * in_plane),
17394                    in_plane,
17395                );
17396                crate::training_bwd::maxpool2d_backward_nchw(
17397                    xp, dyp, dxp, 1, 1, h, w, h_out, w_out, kh, kw, sh, sw, ph, pw,
17398                );
17399            }
17400        });
17401    } else {
17402        crate::training_bwd::maxpool2d_backward_nchw(
17403            xs, dys, dxs, n, c, h, w, h_out, w_out, kh, kw, sh, sw, ph, pw,
17404        );
17405    }
17406}
17407
17408pub unsafe fn execute_rope_backward_f32(
17409    dy: usize,
17410    cos: usize,
17411    sin: usize,
17412    dx: usize,
17413    batch: u32,
17414    seq: u32,
17415    hidden: u32,
17416    head_dim: u32,
17417    n_rot: u32,
17418    cos_len: u32,
17419    base: *mut u8,
17420) {
17421    let (b, s, hs, dh, nr, cl) = (
17422        batch as usize,
17423        seq as usize,
17424        hidden as usize,
17425        head_dim as usize,
17426        n_rot as usize,
17427        cos_len as usize,
17428    );
17429    let nh = hs / dh;
17430    let tab_half = dh / 2;
17431    let dys = sl(dy, base, b * s * hs);
17432    let cos_tab = sl(cos, base, cl);
17433    let sin_tab = sl(sin, base, cl);
17434    let out = sl_mut(dx, base, b * s * hs);
17435    for bi in 0..b {
17436        for si in 0..s {
17437            let tab_off = si.saturating_mul(tab_half) % cl.max(1);
17438            let cp = &cos_tab[tab_off..tab_off + tab_half.min(cl)];
17439            let sp = &sin_tab[tab_off..tab_off + tab_half.min(cl)];
17440            for hi in 0..nh {
17441                let base_idx = bi * s * hs + si * hs + hi * dh;
17442                crate::training_bwd::rope_backward_row(
17443                    &dys[base_idx..base_idx + dh],
17444                    cp,
17445                    sp,
17446                    &mut out[base_idx..base_idx + dh],
17447                    dh,
17448                    nr,
17449                );
17450            }
17451        }
17452    }
17453}
17454
17455pub unsafe fn execute_cumsum_backward_f32(
17456    dy: usize,
17457    dx: usize,
17458    rows: u32,
17459    cols: u32,
17460    exclusive: bool,
17461    base: *mut u8,
17462) {
17463    let (rows, cols) = (rows as usize, cols as usize);
17464    let dys = sl(dy, base, rows * cols);
17465    let out = sl_mut(dx, base, rows * cols);
17466    for r in 0..rows {
17467        crate::training_bwd::cumsum_backward_row(
17468            &dys[r * cols..(r + 1) * cols],
17469            &mut out[r * cols..(r + 1) * cols],
17470            exclusive,
17471        );
17472    }
17473}
17474
17475pub unsafe fn execute_gather_backward_f32(
17476    dy: usize,
17477    indices: usize,
17478    dst: usize,
17479    outer: u32,
17480    axis_dim: u32,
17481    num_idx: u32,
17482    trailing: u32,
17483    base: *mut u8,
17484) {
17485    let (outer, axis_dim, num_idx, trailing) = (
17486        outer as usize,
17487        axis_dim as usize,
17488        num_idx as usize,
17489        trailing as usize,
17490    );
17491    let out = sl_mut(dst, base, outer * axis_dim * trailing);
17492    out.fill(0.0);
17493    crate::training_bwd::gather_axis_backward(
17494        sl(dy, base, outer * num_idx * trailing),
17495        sl(indices, base, num_idx),
17496        out,
17497        outer,
17498        axis_dim,
17499        num_idx,
17500        trailing,
17501    );
17502}
17503
17504/// Host-fallback entry for GGUF `Op::DequantMatMul` (Metal unified memory).
17505pub unsafe fn execute_dequant_matmul_gguf_f32(
17506    x: usize,
17507    w_q: usize,
17508    dst: usize,
17509    m: usize,
17510    k: usize,
17511    n: usize,
17512    scheme: rlx_ir::quant::QuantScheme,
17513    base: *mut u8,
17514) {
17515    unsafe {
17516        let block_bytes = scheme.gguf_block_bytes() as usize;
17517        let block_elems = scheme.gguf_block_size() as usize;
17518        let total_bytes = (k * n) / block_elems * block_bytes;
17519        let xs = sl(x, base, m * k);
17520        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, total_bytes);
17521        let out = sl_mut(dst, base, m * n);
17522        crate::gguf_matmul::gguf_matmul_bt(xs, w_bytes, out, m, k, n, scheme);
17523    }
17524}
17525
17526/// Host-fallback entry for GGUF `Op::DequantGroupedMatMul` (MoE expert stack).
17527pub unsafe fn execute_dequant_grouped_matmul_gguf_f32(
17528    input: usize,
17529    w_q: usize,
17530    expert_idx: usize,
17531    dst: usize,
17532    m: usize,
17533    k: usize,
17534    n: usize,
17535    num_experts: usize,
17536    scheme: rlx_ir::quant::QuantScheme,
17537    base: *mut u8,
17538) {
17539    unsafe {
17540        let block_bytes = scheme.gguf_block_bytes() as usize;
17541        let block_elems = scheme.gguf_block_size() as usize;
17542        let slab_bytes = (k * n) / block_elems * block_bytes;
17543        let xs = sl(input, base, m * k);
17544        let w_bytes =
17545            std::slice::from_raw_parts(base.add(w_q) as *const u8, num_experts * slab_bytes);
17546        let ids = sl(expert_idx, base, m);
17547        let out = sl_mut(dst, base, m * n);
17548        crate::gguf_matmul::gguf_grouped_matmul_bt(
17549            xs,
17550            w_bytes,
17551            ids,
17552            out,
17553            m,
17554            k,
17555            n,
17556            num_experts,
17557            scheme,
17558        );
17559    }
17560}
17561
17562/// Host-fallback entry for Int8 `Op::DequantMatMul` (Metal unified memory).
17563pub unsafe fn execute_dequant_matmul_int8_f32(
17564    x: usize,
17565    w_q: usize,
17566    scale: usize,
17567    zp: usize,
17568    dst: usize,
17569    m: usize,
17570    k: usize,
17571    n: usize,
17572    block_size: u32,
17573    is_asymmetric: bool,
17574    base: *mut u8,
17575) {
17576    let bs = block_size as usize;
17577    let n_blocks = k.div_ceil(bs);
17578    unsafe {
17579        let xs = sl(x, base, m * k);
17580        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const i8, k * n);
17581        let scales = sl(scale, base, n_blocks * n);
17582        let zps = if is_asymmetric {
17583            sl(zp, base, n_blocks * n)
17584        } else {
17585            &[][..]
17586        };
17587        let out = sl_mut(dst, base, m * n);
17588        dequant_matmul_int8(xs, w_bytes, scales, zps, out, m, k, n, bs, is_asymmetric);
17589    }
17590}
17591
17592/// Host-fallback entry for Int4 `Op::DequantMatMul` (Metal unified memory).
17593pub unsafe fn execute_dequant_matmul_int4_f32(
17594    x: usize,
17595    w_q: usize,
17596    scale: usize,
17597    zp: usize,
17598    dst: usize,
17599    m: usize,
17600    k: usize,
17601    n: usize,
17602    block_size: u32,
17603    is_asymmetric: bool,
17604    base: *mut u8,
17605) {
17606    let bs = block_size as usize;
17607    let n_blocks = k.div_ceil(bs);
17608    unsafe {
17609        let xs = sl(x, base, m * k);
17610        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, (k * n).div_ceil(2));
17611        let scales = sl(scale, base, n_blocks * n);
17612        let zps = if is_asymmetric {
17613            sl(zp, base, n_blocks * n)
17614        } else {
17615            &[][..]
17616        };
17617        let out = sl_mut(dst, base, m * n);
17618        dequant_matmul_int4(xs, w_bytes, scales, zps, out, m, k, n, bs, is_asymmetric);
17619    }
17620}
17621
17622/// Host-fallback entry for FP8 `Op::DequantMatMul` (Metal unified memory).
17623pub unsafe fn execute_dequant_matmul_fp8_f32(
17624    x: usize,
17625    w_q: usize,
17626    scale: usize,
17627    dst: usize,
17628    m: usize,
17629    k: usize,
17630    n: usize,
17631    e5m2: bool,
17632    base: *mut u8,
17633) {
17634    unsafe {
17635        let xs = sl(x, base, m * k);
17636        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, k * n);
17637        let scales = sl(scale, base, n);
17638        let out = sl_mut(dst, base, m * n);
17639        dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, e5m2);
17640    }
17641}
17642
17643/// Host-fallback entry for NVFP4 `Op::DequantMatMul` (Metal unified memory).
17644pub unsafe fn execute_dequant_matmul_nvfp4_f32(
17645    x: usize,
17646    w_q: usize,
17647    scale: usize,
17648    global_scale: usize,
17649    dst: usize,
17650    m: usize,
17651    k: usize,
17652    n: usize,
17653    base: *mut u8,
17654) {
17655    let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
17656    unsafe {
17657        let xs = sl(x, base, m * k);
17658        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, (k * n).div_ceil(2));
17659        let scale_bytes = std::slice::from_raw_parts(base.add(scale) as *const u8, n_scale);
17660        let gs = sl(global_scale, base, 1)[0];
17661        let out = sl_mut(dst, base, m * n);
17662        dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
17663    }
17664}
17665
17666// ── Native low-precision ScaledMatMul host fallbacks (unified-memory) ──
17667// Reuse the CPU oracle kernels so Metal (no FP8 matrix HW) runs the exact same
17668// decode-and-accumulate reference. TN layout: lhs [m,k], rhs [n,k].
17669
17670/// Host fallback for `Op::ScaledQuantScale`. Byte offsets into `base`.
17671pub unsafe fn execute_scaled_quant_scale_f32(
17672    x: usize,
17673    dst: usize,
17674    rows: usize,
17675    cols: usize,
17676    fmt: rlx_ir::ScaledFormat,
17677    layout: rlx_ir::ScaleLayout,
17678    base: *mut u8,
17679) {
17680    unsafe {
17681        let xs = sl(x, base, rows * cols);
17682        let scales = lowp_compute_scales(xs, fmt, layout, rows, cols);
17683        let nblk = lowp_nblk(cols, layout);
17684        match layout {
17685            rlx_ir::ScaleLayout::PerTensor => {
17686                sl_mut(dst, base, 1)[0] = scales[0];
17687            }
17688            rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
17689                let out = std::slice::from_raw_parts_mut(base.add(dst), rows * nblk);
17690                for (o, &s) in out.iter_mut().zip(&scales) {
17691                    *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
17692                }
17693            }
17694            rlx_ir::ScaleLayout::Nvfp4 { .. } => {
17695                let out = std::slice::from_raw_parts_mut(base.add(dst), rows * nblk);
17696                for (o, &s) in out.iter_mut().zip(&scales) {
17697                    *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
17698                }
17699            }
17700        }
17701    }
17702}
17703
17704/// Host fallback for `Op::ScaledQuantize`. Byte offsets into `base`.
17705#[allow(clippy::too_many_arguments)]
17706pub unsafe fn execute_scaled_quantize_f32(
17707    x: usize,
17708    scale: usize,
17709    dst: usize,
17710    rows: usize,
17711    cols: usize,
17712    fmt: rlx_ir::ScaledFormat,
17713    layout: rlx_ir::ScaleLayout,
17714    base: *mut u8,
17715) {
17716    unsafe {
17717        let xs = sl(x, base, rows * cols);
17718        let nblk = lowp_nblk(cols, layout);
17719        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
17720            1
17721        } else {
17722            rows * nblk
17723        };
17724        let scales = lowp_read_scales(layout, base, scale, n_scale);
17725        let out = std::slice::from_raw_parts_mut(base.add(dst), rows * cols);
17726        lowp_quantize(xs, &scales, fmt, layout, rows, cols, out);
17727    }
17728}
17729
17730/// Host fallback for `Op::ScaledDequantize` — packed codes (`U8`) → f32, the
17731/// inverse of [`execute_scaled_quantize_f32`]. Byte offsets into `base`.
17732#[allow(clippy::too_many_arguments)]
17733pub unsafe fn execute_scaled_dequantize_f32(
17734    codes: usize,
17735    scale: usize,
17736    dst: usize,
17737    rows: usize,
17738    cols: usize,
17739    fmt: rlx_ir::ScaledFormat,
17740    layout: rlx_ir::ScaleLayout,
17741    base: *mut u8,
17742) {
17743    unsafe {
17744        let nblk = lowp_nblk(cols, layout);
17745        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
17746            1
17747        } else {
17748            rows * nblk
17749        };
17750        let cs = std::slice::from_raw_parts(base.add(codes), rows * cols);
17751        let scales = lowp_read_scales(layout, base, scale, n_scale);
17752        let out = std::slice::from_raw_parts_mut(base.add(dst) as *mut f32, rows * cols);
17753        lowp_dequantize(cs, &scales, fmt, layout, rows, cols, out);
17754    }
17755}
17756
17757/// Host fallback for `Op::ScaledMatMul` (TN). Byte offsets into `base`.
17758#[allow(clippy::too_many_arguments)]
17759pub unsafe fn execute_scaled_matmul_f32(
17760    lhs: usize,
17761    rhs: usize,
17762    lhs_scale: usize,
17763    rhs_scale: usize,
17764    bias: usize,
17765    dst: usize,
17766    m: usize,
17767    k: usize,
17768    n: usize,
17769    has_bias: bool,
17770    lhs_fmt: rlx_ir::ScaledFormat,
17771    rhs_fmt: rlx_ir::ScaledFormat,
17772    layout: rlx_ir::ScaleLayout,
17773    base: *mut u8,
17774) {
17775    unsafe {
17776        let lhs_b = std::slice::from_raw_parts(base.add(lhs), m * k);
17777        let rhs_b = std::slice::from_raw_parts(base.add(rhs), n * k);
17778        let nblk = lowp_nblk(k, layout);
17779        let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
17780        let n_l = if per_tensor { 1 } else { m * nblk };
17781        let n_r = if per_tensor { 1 } else { n * nblk };
17782        let ls = lowp_read_scales(layout, base, lhs_scale, n_l);
17783        let rs = lowp_read_scales(layout, base, rhs_scale, n_r);
17784        let bias_s = if has_bias {
17785            Some(sl(bias, base, n))
17786        } else {
17787            None
17788        };
17789        let out = sl_mut(dst, base, m * n);
17790        lowp_scaled_matmul(
17791            lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, lhs_fmt, rhs_fmt,
17792        );
17793    }
17794}
17795
17796/// Host-fallback entry for f16 `Op::GatedDeltaNet` tensors on Metal.
17797pub unsafe fn execute_gated_delta_net_f16(
17798    q: usize,
17799    k: usize,
17800    v: usize,
17801    g: usize,
17802    beta: usize,
17803    state: usize,
17804    dst: usize,
17805    batch: usize,
17806    seq: usize,
17807    heads: usize,
17808    state_size: usize,
17809    base: *mut u8,
17810) {
17811    use half::f16;
17812    unsafe {
17813        let read_f16 = |off: usize, len: usize| -> Vec<f32> {
17814            let raw = std::slice::from_raw_parts(base.add(off) as *const u8, len * 2);
17815            raw.chunks_exact(2)
17816                .map(|c| f16::from_le_bytes([c[0], c[1]]).to_f32())
17817                .collect()
17818        };
17819        let write_f16 = |off: usize, data: &[f32]| {
17820            let out = std::slice::from_raw_parts_mut(base.add(off), data.len() * 2);
17821            for (i, &v) in data.iter().enumerate() {
17822                let le = f16::from_f32(v).to_le_bytes();
17823                out[i * 2] = le[0];
17824                out[i * 2 + 1] = le[1];
17825            }
17826        };
17827
17828        let (b, s, h, n) = (batch, seq, heads, state_size);
17829        let q_f = read_f16(q, b * s * h * n);
17830        let k_f = read_f16(k, b * s * h * n);
17831        let v_f = read_f16(v, b * s * h * n);
17832        let g_f = read_f16(g, b * s * h);
17833        let b_f = read_f16(beta, b * s * h);
17834        let mut state_f = if state != 0 {
17835            read_f16(state, b * h * n * n)
17836        } else {
17837            vec![0f32; b * h * n * n]
17838        };
17839        let mut out_f = vec![0f32; b * s * h * n];
17840        let scale = 1.0f32 / (n as f32).sqrt();
17841        let mut sk_buf = vec![0f32; n];
17842        let mut owned_state = vec![0f32; h * n * n];
17843
17844        for bi in 0..b {
17845            let state_slice: &mut [f32] = if state != 0 {
17846                let start = bi * h * n * n;
17847                &mut state_f[start..start + h * n * n]
17848            } else {
17849                owned_state.fill(0.0);
17850                &mut owned_state
17851            };
17852
17853            for ti in 0..s {
17854                let qkv_step_base = bi * s * h * n + ti * h * n;
17855                let gb_step_base = bi * s * h + ti * h;
17856
17857                for hi in 0..h {
17858                    let q_row = &q_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
17859                    let k_row = &k_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
17860                    let v_row = &v_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
17861                    let g_t = g_f[gb_step_base + hi];
17862                    let beta_t = b_f[gb_step_base + hi];
17863
17864                    let s_base = hi * n * n;
17865                    let s_mat = &mut state_slice[s_base..s_base + n * n];
17866
17867                    let g_exp = g_t.exp();
17868                    for st in s_mat.iter_mut() {
17869                        *st *= g_exp;
17870                    }
17871
17872                    for j in 0..n {
17873                        let mut acc = 0f32;
17874                        for i in 0..n {
17875                            acc += s_mat[i * n + j] * k_row[i];
17876                        }
17877                        sk_buf[j] = acc;
17878                    }
17879
17880                    for j in 0..n {
17881                        sk_buf[j] = (v_row[j] - sk_buf[j]) * beta_t;
17882                    }
17883
17884                    for i in 0..n {
17885                        let ki = k_row[i];
17886                        for j in 0..n {
17887                            s_mat[i * n + j] += ki * sk_buf[j];
17888                        }
17889                    }
17890
17891                    let out_row = &mut out_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
17892                    for j in 0..n {
17893                        let mut acc = 0f32;
17894                        for i in 0..n {
17895                            acc += s_mat[i * n + j] * q_row[i];
17896                        }
17897                        out_row[j] = acc * scale;
17898                    }
17899                }
17900            }
17901        }
17902
17903        write_f16(dst, &out_f);
17904        if state != 0 {
17905            write_f16(state, &state_f);
17906        }
17907    }
17908}
17909
17910/// Host fallback for NCHW group norm (Metal unified-memory arena).
17911pub unsafe fn execute_group_norm_nchw_f32(
17912    src: usize,
17913    g: usize,
17914    b: usize,
17915    dst: usize,
17916    n: usize,
17917    c: usize,
17918    h: usize,
17919    w: usize,
17920    num_groups: usize,
17921    eps: f32,
17922    base: *mut u8,
17923) {
17924    let plane = c * h * w;
17925    for ni in 0..n {
17926        let input = unsafe { sl(src + ni * plane * std::mem::size_of::<f32>(), base, plane) };
17927        let gamma = unsafe { sl(g, base, c) };
17928        let beta = unsafe { sl(b, base, c) };
17929        let output = unsafe { sl_mut(dst + ni * plane * std::mem::size_of::<f32>(), base, plane) };
17930        crate::kernels::group_norm_nchw(input, gamma, beta, output, 1, c, h, w, num_groups, eps);
17931    }
17932}
17933
17934/// Host fallback for NCHW LayerNorm2d (SAM / candle semantics).
17935pub unsafe fn execute_layer_norm2d_nchw_f32(
17936    src: usize,
17937    g: usize,
17938    b: usize,
17939    dst: usize,
17940    n: usize,
17941    c: usize,
17942    h: usize,
17943    w: usize,
17944    eps: f32,
17945    base: *mut u8,
17946) {
17947    let plane = c * h * w;
17948    unsafe {
17949        let input = sl(src, base, n * plane);
17950        let gamma = sl(g, base, c);
17951        let beta = sl(b, base, c);
17952        let output = sl_mut(dst, base, n * plane);
17953        crate::kernels::layer_norm2d_nchw(input, gamma, beta, output, n, c, h, w, eps);
17954    }
17955}
17956
17957/// Host fallback for NCHW ConvTranspose2d.
17958pub unsafe fn execute_conv_transpose2d_nchw_f32(
17959    src: usize,
17960    weight: usize,
17961    dst: usize,
17962    n: usize,
17963    c_in: usize,
17964    h: usize,
17965    w_in: usize,
17966    c_out: usize,
17967    h_out: usize,
17968    w_out: usize,
17969    kh: usize,
17970    kw: usize,
17971    sh: usize,
17972    sw: usize,
17973    ph: usize,
17974    pw: usize,
17975    dh: usize,
17976    dw: usize,
17977    groups: usize,
17978    base: *mut u8,
17979) {
17980    let in_elems = n * c_in * h * w_in;
17981    let w_elems = c_in * (c_out / groups) * kh * kw;
17982    let out_elems = n * c_out * h_out * w_out;
17983    unsafe {
17984        let input = sl(src, base, in_elems);
17985        let wt = sl(weight, base, w_elems);
17986        let output = sl_mut(dst, base, out_elems);
17987        crate::kernels::conv_transpose2d_nchw(
17988            input, wt, output, n, c_in, h, w_in, c_out, h_out, w_out, kh, kw, sh, sw, ph, pw, dh,
17989            dw, groups,
17990        );
17991    }
17992}
17993
17994/// Host fallback for nearest 2× upsample on NCHW.
17995pub unsafe fn execute_resize_nearest_2x_f32(
17996    src: usize,
17997    dst: usize,
17998    n: usize,
17999    c: usize,
18000    h: usize,
18001    w: usize,
18002    base: *mut u8,
18003) {
18004    let in_plane = c * h * w;
18005    let out_plane = c * h * 2 * w * 2;
18006    for ni in 0..n {
18007        let input = unsafe {
18008            sl(
18009                src + ni * in_plane * std::mem::size_of::<f32>(),
18010                base,
18011                in_plane,
18012            )
18013        };
18014        let output = unsafe {
18015            sl_mut(
18016                dst + ni * out_plane * std::mem::size_of::<f32>(),
18017                base,
18018                out_plane,
18019            )
18020        };
18021        crate::kernels::resize_nearest_2x_nchw(input, output, c, h, w);
18022    }
18023}
18024
18025/// Host axial 2-D RoPE for Metal (and other) fallbacks on unified memory.
18026pub unsafe fn execute_axial_rope2d_f32(
18027    src: usize,
18028    dst: usize,
18029    batch: usize,
18030    seq: usize,
18031    hidden: usize,
18032    end_x: usize,
18033    end_y: usize,
18034    head_dim: usize,
18035    num_heads: usize,
18036    theta: f32,
18037    repeat_factor: usize,
18038    base: *mut u8,
18039) {
18040    let plane = seq * hidden;
18041    let plane_bytes = plane * std::mem::size_of::<f32>();
18042    for bi in 0..batch {
18043        let in_off = src + bi * plane_bytes;
18044        let input = unsafe { sl(in_off, base, plane) };
18045        let rotated = rlx_ir::ops::axial_rope2d::apply_axial_rope2d(
18046            input,
18047            num_heads,
18048            seq,
18049            head_dim,
18050            end_x,
18051            end_y,
18052            theta,
18053            repeat_factor,
18054        );
18055        let out_off = dst + bi * plane_bytes;
18056        let output = unsafe { sl_mut(out_off, base, plane) };
18057        output.copy_from_slice(&rotated);
18058    }
18059}
18060
18061/// Ternary pruned radix-2 butterfly stage on `[batch, n_fft, 2]` interleaved state.
18062pub unsafe fn execute_fft_butterfly_stage_f32(
18063    state_src: usize,
18064    state_dst: usize,
18065    gate_src: usize,
18066    rev_src: usize,
18067    tw_re_src: usize,
18068    tw_im_src: usize,
18069    batch: usize,
18070    n_fft: usize,
18071    stage: usize,
18072    base: *mut u8,
18073) {
18074    let half = n_fft / 2;
18075    let stride = 1usize << stage;
18076    let gate = unsafe { sl(gate_src, base, half) };
18077    let rev = unsafe { sl(rev_src, base, half) };
18078    let tw_re = unsafe { sl(tw_re_src, base, half) };
18079    let tw_im = unsafe { sl(tw_im_src, base, half) };
18080    let row_elems = n_fft * 2;
18081    for b in 0..batch {
18082        let in_off = state_src + b * row_elems * std::mem::size_of::<f32>();
18083        let out_off = state_dst + b * row_elems * std::mem::size_of::<f32>();
18084        let inp = unsafe { sl(in_off, base, row_elems) };
18085        let out = unsafe { sl_mut(out_off, base, row_elems) };
18086        out.copy_from_slice(inp);
18087        for bf in 0..half {
18088            if gate[bf] == 0.0 {
18089                continue;
18090            }
18091            let group = bf / stride;
18092            let k = bf % stride;
18093            let i0 = group * 2 * stride + k;
18094            let i1 = i0 + stride;
18095            let w_re = tw_re[bf];
18096            let w_im = tw_im[bf];
18097            let in_a_re = inp[i0 * 2];
18098            let in_a_im = inp[i0 * 2 + 1];
18099            let in_b_re = inp[i1 * 2];
18100            let in_b_im = inp[i1 * 2 + 1];
18101            let (b_re, b_im) = (
18102                in_b_re * w_re - in_b_im * w_im,
18103                in_b_re * w_im + in_b_im * w_re,
18104            );
18105            let (top_re, top_im) = (in_a_re + b_re, in_a_im + b_im);
18106            let (bot_re, bot_im) = (in_a_re - b_re, in_a_im - b_im);
18107            let (oa_re, oa_im, ob_re, ob_im) = if rev[bf] >= 0.5 {
18108                (bot_re, bot_im, top_re, top_im)
18109            } else {
18110                (top_re, top_im, bot_re, bot_im)
18111            };
18112            out[i0 * 2] = oa_re;
18113            out[i0 * 2 + 1] = oa_im;
18114            out[i1 * 2] = ob_re;
18115            out[i1 * 2 + 1] = ob_im;
18116        }
18117    }
18118}
18119
18120/// f32 mirror of `execute_fft1d_f64`. Same public-host-fallback role.
18121pub unsafe fn execute_fft1d_f32(
18122    src: usize,
18123    dst: usize,
18124    outer: usize,
18125    n_complex: usize,
18126    inverse: bool,
18127    norm_tag: u32,
18128    base: *mut u8,
18129) {
18130    let row_elems = 2 * n_complex;
18131    let mut re = vec![0f32; n_complex];
18132    let mut im = vec![0f32; n_complex];
18133    let norm = rlx_ir::fft::FftNorm::from_tag(norm_tag);
18134    let scale = norm.output_scale(n_complex, inverse) as f32;
18135    let mut scratch = if n_complex.is_power_of_two() || n_complex <= 16 {
18136        BluesteinScratchF32::empty()
18137    } else {
18138        BluesteinScratchF32::build(n_complex, inverse)
18139    };
18140    for o in 0..outer {
18141        let row_offset = src + o * row_elems * std::mem::size_of::<f32>();
18142        let s = unsafe { sl(row_offset, base, row_elems) };
18143        re.copy_from_slice(&s[..n_complex]);
18144        im.copy_from_slice(&s[n_complex..]);
18145        if n_complex.is_power_of_two() {
18146            fft_radix2_inplace_f32(&mut re, &mut im, inverse);
18147        } else if n_complex <= 16 {
18148            fft_naive_inplace_f32(&mut re, &mut im, inverse);
18149        } else {
18150            fft_bluestein_inplace_f32(&mut re, &mut im, inverse, &mut scratch);
18151        }
18152        if scale != 1.0 {
18153            re.iter_mut().for_each(|v| *v *= scale);
18154            im.iter_mut().for_each(|v| *v *= scale);
18155        }
18156        let dst_offset = dst + o * row_elems * std::mem::size_of::<f32>();
18157        let d = unsafe { sl_mut(dst_offset, base, row_elems) };
18158        d[..n_complex].copy_from_slice(&re);
18159        d[n_complex..].copy_from_slice(&im);
18160    }
18161}
18162
18163/// C64 interleaved layout: each complex element is `[re: f32, im: f32]`.
18164pub unsafe fn execute_fft1d_c64(
18165    src: usize,
18166    dst: usize,
18167    outer: usize,
18168    n_complex: usize,
18169    inverse: bool,
18170    norm_tag: u32,
18171    base: *mut u8,
18172) {
18173    let row_bytes = n_complex * 8;
18174    let mut re = vec![0f32; n_complex];
18175    let mut im = vec![0f32; n_complex];
18176    let norm = rlx_ir::fft::FftNorm::from_tag(norm_tag);
18177    let scale = norm.output_scale(n_complex, inverse) as f32;
18178    let mut scratch = if n_complex.is_power_of_two() || n_complex <= 16 {
18179        BluesteinScratchF32::empty()
18180    } else {
18181        BluesteinScratchF32::build(n_complex, inverse)
18182    };
18183    for o in 0..outer {
18184        let row_offset = src + o * row_bytes;
18185        for i in 0..n_complex {
18186            let elem_off = row_offset + i * 8;
18187            re[i] = f32::from_le_bytes([
18188                *base.add(elem_off),
18189                *base.add(elem_off + 1),
18190                *base.add(elem_off + 2),
18191                *base.add(elem_off + 3),
18192            ]);
18193            im[i] = f32::from_le_bytes([
18194                *base.add(elem_off + 4),
18195                *base.add(elem_off + 5),
18196                *base.add(elem_off + 6),
18197                *base.add(elem_off + 7),
18198            ]);
18199        }
18200        if n_complex.is_power_of_two() {
18201            fft_radix2_inplace_f32(&mut re, &mut im, inverse);
18202        } else if n_complex <= 16 {
18203            fft_naive_inplace_f32(&mut re, &mut im, inverse);
18204        } else {
18205            fft_bluestein_inplace_f32(&mut re, &mut im, inverse, &mut scratch);
18206        }
18207        if scale != 1.0 {
18208            re.iter_mut().for_each(|v| *v *= scale);
18209            im.iter_mut().for_each(|v| *v *= scale);
18210        }
18211        let dst_row = dst + o * row_bytes;
18212        for i in 0..n_complex {
18213            let elem_off = dst_row + i * 8;
18214            let re_b = re[i].to_le_bytes();
18215            let im_b = im[i].to_le_bytes();
18216            for j in 0..4 {
18217                *base.add(elem_off + j) = re_b[j];
18218                *base.add(elem_off + 4 + j) = im_b[j];
18219            }
18220        }
18221    }
18222}
18223
18224/// Dtype-dispatching host entry for `Op::LogMel` (shared by GPU host fallbacks).
18225pub unsafe fn execute_log_mel(
18226    spec: usize,
18227    filters: usize,
18228    dst: usize,
18229    outer: usize,
18230    n_fft: usize,
18231    n_bins: usize,
18232    n_mels: usize,
18233    base: *mut u8,
18234) {
18235    execute_log_mel_f32(spec, filters, dst, outer, n_fft, n_bins, n_mels, base);
18236}
18237
18238pub unsafe fn execute_log_mel_f32(
18239    spec: usize,
18240    filters: usize,
18241    dst: usize,
18242    outer: usize,
18243    n_fft: usize,
18244    n_bins: usize,
18245    n_mels: usize,
18246    base: *mut u8,
18247) {
18248    let spec_ptr = base.add(spec) as *const f32;
18249    let filt_ptr = base.add(filters) as *const f32;
18250    let dst_ptr = base.add(dst) as *mut f32;
18251    let spec = std::slice::from_raw_parts(spec_ptr, outer * n_fft * 2);
18252    let filters = std::slice::from_raw_parts(filt_ptr, n_mels * n_bins);
18253    let out = std::slice::from_raw_parts_mut(dst_ptr, outer * n_mels);
18254    rlx_ir::audio::log_mel_block_f32(spec, filters, outer, n_fft, n_bins, n_mels, out);
18255}
18256
18257pub unsafe fn execute_welch_peaks_f32(
18258    spec: usize,
18259    dst: usize,
18260    welch_batch: usize,
18261    n_fft: usize,
18262    n_segments: usize,
18263    k: usize,
18264    base: *mut u8,
18265) {
18266    let spec_ptr = base.add(spec) as *const f32;
18267    let dst_ptr = base.add(dst) as *mut f32;
18268    let outer = welch_batch * n_segments;
18269    let spec = std::slice::from_raw_parts(spec_ptr, outer * n_fft * 2);
18270    let out = std::slice::from_raw_parts_mut(dst_ptr, welch_batch * k * 2);
18271    rlx_ir::audio::welch_peaks_block_f32(spec, welch_batch, n_fft, n_segments, k, out);
18272}
18273
18274pub unsafe fn execute_log_mel_backward_f32(
18275    spec: usize,
18276    filters: usize,
18277    dy: usize,
18278    dst: usize,
18279    outer: usize,
18280    n_fft: usize,
18281    n_bins: usize,
18282    n_mels: usize,
18283    base: *mut u8,
18284) {
18285    let spec_ptr = base.add(spec) as *const f32;
18286    let filt_ptr = base.add(filters) as *const f32;
18287    let dy_ptr = base.add(dy) as *const f32;
18288    let dst_ptr = base.add(dst) as *mut f32;
18289    let spec = std::slice::from_raw_parts(spec_ptr, outer * n_fft * 2);
18290    let filters = std::slice::from_raw_parts(filt_ptr, n_mels * n_bins);
18291    let dy = std::slice::from_raw_parts(dy_ptr, outer * n_mels);
18292    let d_spec = std::slice::from_raw_parts_mut(dst_ptr, outer * n_fft * 2);
18293    d_spec.fill(0.0);
18294    rlx_ir::audio::log_mel_block_vjp(spec, filters, dy, outer, n_fft, n_bins, n_mels, d_spec);
18295}
18296
18297/// Dtype-dispatching host entry for `Op::Fft` (shared by GPU host fallbacks).
18298pub unsafe fn execute_fft1d(
18299    src: usize,
18300    dst: usize,
18301    outer: usize,
18302    n_complex: usize,
18303    inverse: bool,
18304    norm_tag: u32,
18305    dtype: rlx_ir::DType,
18306    base: *mut u8,
18307) {
18308    match dtype {
18309        rlx_ir::DType::F32 => {
18310            execute_fft1d_f32(src, dst, outer, n_complex, inverse, norm_tag, base)
18311        }
18312        rlx_ir::DType::F64 => {
18313            execute_fft1d_f64(src, dst, outer, n_complex, inverse, norm_tag, base)
18314        }
18315        rlx_ir::DType::C64 => {
18316            execute_fft1d_c64(src, dst, outer, n_complex, inverse, norm_tag, base)
18317        }
18318        other => panic!("execute_fft1d: unsupported dtype {other:?}"),
18319    }
18320}
18321
18322/// f32 in-place radix-2 DIT Cooley-Tukey. Structurally identical to
18323/// the f64 path; twiddle recurrence is kept in f64 so accumulated
18324/// rotation drift doesn't dominate the per-stage error budget at
18325/// larger N.
18326fn fft_radix2_inplace_f32(re: &mut [f32], im: &mut [f32], inverse: bool) {
18327    let n = re.len();
18328    debug_assert_eq!(im.len(), n);
18329    debug_assert!(
18330        n.is_power_of_two(),
18331        "fft_radix2_f32: n={n} must be a power of two"
18332    );
18333    if n <= 1 {
18334        return;
18335    }
18336
18337    let mut j = 0usize;
18338    for i in 1..n {
18339        let mut bit = n >> 1;
18340        while j & bit != 0 {
18341            j ^= bit;
18342            bit >>= 1;
18343        }
18344        j ^= bit;
18345        if i < j {
18346            re.swap(i, j);
18347            im.swap(i, j);
18348        }
18349    }
18350
18351    let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
18352    let mut len = 2usize;
18353    while len <= n {
18354        let half = len / 2;
18355        let theta = sign * 2.0 * std::f64::consts::PI / (len as f64);
18356        let w_re_step = theta.cos();
18357        let w_im_step = theta.sin();
18358        let mut i = 0usize;
18359        while i < n {
18360            let mut wre = 1.0_f64;
18361            let mut wim = 0.0_f64;
18362            for k in 0..half {
18363                let wre_f = wre as f32;
18364                let wim_f = wim as f32;
18365                let t_re = wre_f * re[i + k + half] - wim_f * im[i + k + half];
18366                let t_im = wre_f * im[i + k + half] + wim_f * re[i + k + half];
18367                let u_re = re[i + k];
18368                let u_im = im[i + k];
18369                re[i + k] = u_re + t_re;
18370                im[i + k] = u_im + t_im;
18371                re[i + k + half] = u_re - t_re;
18372                im[i + k + half] = u_im - t_im;
18373                let new_wre = wre * w_re_step - wim * w_im_step;
18374                let new_wim = wre * w_im_step + wim * w_re_step;
18375                wre = new_wre;
18376                wim = new_wim;
18377            }
18378            i += len;
18379        }
18380        len <<= 1;
18381    }
18382}
18383
18384/// In-place radix-2 DIT Cooley-Tukey FFT on split (real, imag) f64
18385/// arrays. `n = re.len() = im.len()` must be a power of two. Forward
18386/// uses ω = exp(-2πi/n); inverse uses ω = exp(+2πi/n) (no 1/N scale).
18387fn fft_radix2_inplace_f64(re: &mut [f64], im: &mut [f64], inverse: bool) {
18388    let n = re.len();
18389    debug_assert_eq!(im.len(), n);
18390    debug_assert!(
18391        n.is_power_of_two(),
18392        "fft_radix2: n={n} must be a power of two"
18393    );
18394    if n <= 1 {
18395        return;
18396    }
18397
18398    // Bit-reverse permutation.
18399    let mut j = 0usize;
18400    for i in 1..n {
18401        let mut bit = n >> 1;
18402        while j & bit != 0 {
18403            j ^= bit;
18404            bit >>= 1;
18405        }
18406        j ^= bit;
18407        if i < j {
18408            re.swap(i, j);
18409            im.swap(i, j);
18410        }
18411    }
18412
18413    // Cooley-Tukey butterflies: ω_len = exp(±2πi/len).
18414    let sign = if inverse { 1.0 } else { -1.0 };
18415    let mut len = 2usize;
18416    while len <= n {
18417        let half = len / 2;
18418        let theta = sign * 2.0 * std::f64::consts::PI / (len as f64);
18419        let w_re_step = theta.cos();
18420        let w_im_step = theta.sin();
18421        let mut i = 0usize;
18422        while i < n {
18423            // Twiddle starts at 1+0i for each segment.
18424            let mut wre = 1.0_f64;
18425            let mut wim = 0.0_f64;
18426            for k in 0..half {
18427                let t_re = wre * re[i + k + half] - wim * im[i + k + half];
18428                let t_im = wre * im[i + k + half] + wim * re[i + k + half];
18429                let u_re = re[i + k];
18430                let u_im = im[i + k];
18431                re[i + k] = u_re + t_re;
18432                im[i + k] = u_im + t_im;
18433                re[i + k + half] = u_re - t_re;
18434                im[i + k + half] = u_im - t_im;
18435                let new_wre = wre * w_re_step - wim * w_im_step;
18436                let new_wim = wre * w_im_step + wim * w_re_step;
18437                wre = new_wre;
18438                wim = new_wim;
18439            }
18440            i += len;
18441        }
18442        len <<= 1;
18443    }
18444}
18445
18446/// Pre-computed chirp + filter-spectrum for one (N, direction) pair.
18447/// Built once per call to `execute_fft1d_f64` and reused across rows
18448/// when `outer > 1` — the chirp and FFT(b) don't depend on the input.
18449struct BluesteinScratchF64 {
18450    /// Power-of-two convolution length, ≥ 2N - 1.
18451    m: usize,
18452    /// `w[k] = exp(sign · iπ · k² / N)` for k=0..N, where sign matches
18453    /// the requested direction. Forward chirp on the way in, output
18454    /// chirp on the way out.
18455    w_re: Vec<f64>,
18456    w_im: Vec<f64>,
18457    /// FFT of the embedded filter `b[k] = conj(w[|k|])` in length-M.
18458    /// Doesn't depend on the input — precomputed once.
18459    bf_re: Vec<f64>,
18460    bf_im: Vec<f64>,
18461    /// Workspace reused per row (avoids per-row allocation).
18462    ar: Vec<f64>,
18463    ai: Vec<f64>,
18464}
18465
18466impl BluesteinScratchF64 {
18467    fn empty() -> Self {
18468        Self {
18469            m: 0,
18470            w_re: Vec::new(),
18471            w_im: Vec::new(),
18472            bf_re: Vec::new(),
18473            bf_im: Vec::new(),
18474            ar: Vec::new(),
18475            ai: Vec::new(),
18476        }
18477    }
18478
18479    fn build(n: usize, inverse: bool) -> Self {
18480        // M = next power of two ≥ 2N - 1 keeps the inner FFT on the
18481        // fast radix-2 path. For N=1 fall back to M=1 (no-op convolution).
18482        let m = if n <= 1 {
18483            1
18484        } else {
18485            (2 * n - 1).next_power_of_two()
18486        };
18487
18488        // Chirp arg reduced via k² mod 2N — without this, large N
18489        // bleeds precision into the trig call (n² grows quadratically).
18490        let mod_2n = (2 * n) as u64;
18491        let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
18492        let mut w_re = vec![0.0_f64; n];
18493        let mut w_im = vec![0.0_f64; n];
18494        for k in 0..n {
18495            let k2 = (k as u64).wrapping_mul(k as u64) % mod_2n;
18496            let theta = sign * std::f64::consts::PI * (k2 as f64) / (n as f64);
18497            w_re[k] = theta.cos();
18498            w_im[k] = theta.sin();
18499        }
18500
18501        // Embed b[k] = conj(w[|k|]) into length M with the negative
18502        // indices wrapping to the tail: b[-j] → B[M-j] for j=1..N-1.
18503        let mut bf_re = vec![0.0_f64; m];
18504        let mut bf_im = vec![0.0_f64; m];
18505        if n > 0 {
18506            bf_re[0] = w_re[0];
18507            bf_im[0] = -w_im[0];
18508            for k in 1..n {
18509                bf_re[k] = w_re[k];
18510                bf_im[k] = -w_im[k];
18511                bf_re[m - k] = w_re[k];
18512                bf_im[m - k] = -w_im[k];
18513            }
18514        }
18515        if m > 1 {
18516            fft_radix2_inplace_f64(&mut bf_re, &mut bf_im, false);
18517        }
18518
18519        Self {
18520            m,
18521            w_re,
18522            w_im,
18523            bf_re,
18524            bf_im,
18525            ar: vec![0.0_f64; m],
18526            ai: vec![0.0_f64; m],
18527        }
18528    }
18529}
18530
18531/// Direct O(N²) DFT for small non-pow2 N (faster than Bluestein setup).
18532fn fft_naive_inplace_f64(re: &mut [f64], im: &mut [f64], inverse: bool) {
18533    let n = re.len();
18534    if n <= 1 {
18535        return;
18536    }
18537    let sign = if inverse { 1.0 } else { -1.0 };
18538    let mut out_re = vec![0.0_f64; n];
18539    let mut out_im = vec![0.0_f64; n];
18540    for k in 0..n {
18541        for nn in 0..n {
18542            let theta = sign * 2.0 * std::f64::consts::PI * (nn as f64) * (k as f64) / (n as f64);
18543            let c = theta.cos();
18544            let s = theta.sin();
18545            out_re[k] += re[nn] * c - im[nn] * s;
18546            out_im[k] += re[nn] * s + im[nn] * c;
18547        }
18548    }
18549    re.copy_from_slice(&out_re);
18550    im.copy_from_slice(&out_im);
18551}
18552
18553fn fft_naive_inplace_f32(re: &mut [f32], im: &mut [f32], inverse: bool) {
18554    let n = re.len();
18555    if n <= 1 {
18556        return;
18557    }
18558    let sign = if inverse { 1.0f32 } else { -1.0f32 };
18559    let mut out_re = vec![0.0_f32; n];
18560    let mut out_im = vec![0.0_f32; n];
18561    for k in 0..n {
18562        for nn in 0..n {
18563            let theta = sign * 2.0 * std::f32::consts::PI * (nn as f32) * (k as f32) / (n as f32);
18564            let c = theta.cos();
18565            let s = theta.sin();
18566            out_re[k] += re[nn] * c - im[nn] * s;
18567            out_im[k] += re[nn] * s + im[nn] * c;
18568        }
18569    }
18570    re.copy_from_slice(&out_re);
18571    im.copy_from_slice(&out_im);
18572}
18573
18574/// Bluestein (chirp-z) FFT for arbitrary N. Identity used:
18575///   `n·k = (n² + k² - (k-n)²) / 2`
18576/// which lets the DFT be written as a linear convolution sandwiched
18577/// between two chirp multiplies:
18578///   `X[k] = w[k] · ((x·w) ⊛ conj(w))[k]`   where `w[n] = exp(±iπ·n²/N)`.
18579/// The convolution is computed via a length-M radix-2 FFT (M ≥ 2N-1).
18580/// Both directions stay unnormalized to match the radix-2 path, so the
18581/// chain rule keeps working without scaling.
18582fn fft_bluestein_inplace_f64(
18583    re: &mut [f64],
18584    im: &mut [f64],
18585    _inverse: bool,
18586    s: &mut BluesteinScratchF64,
18587) {
18588    let n = re.len();
18589    debug_assert_eq!(im.len(), n);
18590    debug_assert_eq!(s.w_re.len(), n);
18591    if n <= 1 {
18592        return;
18593    }
18594    let m = s.m;
18595
18596    // Pre-chirp: a[k] = x[k] · w[k], zero-padded to M.
18597    for k in 0..m {
18598        s.ar[k] = 0.0;
18599        s.ai[k] = 0.0;
18600    }
18601    for k in 0..n {
18602        s.ar[k] = re[k] * s.w_re[k] - im[k] * s.w_im[k];
18603        s.ai[k] = re[k] * s.w_im[k] + im[k] * s.w_re[k];
18604    }
18605
18606    // Length-M forward FFT of the padded chirped input.
18607    fft_radix2_inplace_f64(&mut s.ar, &mut s.ai, false);
18608
18609    // Pointwise product with FFT(b). Stored back into (ar, ai).
18610    for k in 0..m {
18611        let ar = s.ar[k];
18612        let ai = s.ai[k];
18613        let br = s.bf_re[k];
18614        let bi = s.bf_im[k];
18615        s.ar[k] = ar * br - ai * bi;
18616        s.ai[k] = ar * bi + ai * br;
18617    }
18618
18619    // Inverse FFT — radix-2 here is the unnormalized inverse, so we
18620    // divide by M to recover the true circular convolution.
18621    fft_radix2_inplace_f64(&mut s.ar, &mut s.ai, true);
18622    let inv_m = 1.0 / (m as f64);
18623
18624    // Post-chirp: X[k] = w[k] · Y[k] / M for k = 0..N.
18625    for k in 0..n {
18626        let yr = s.ar[k] * inv_m;
18627        let yi = s.ai[k] * inv_m;
18628        re[k] = yr * s.w_re[k] - yi * s.w_im[k];
18629        im[k] = yr * s.w_im[k] + yi * s.w_re[k];
18630    }
18631}
18632
18633/// f32 mirror of `BluesteinScratchF64`. Chirp is computed in f64 for
18634/// precision (same justification as the radix-2 f32 path: twiddles in
18635/// f64, butterflies in f32). The actual conv buffers are f32.
18636struct BluesteinScratchF32 {
18637    m: usize,
18638    w_re: Vec<f32>,
18639    w_im: Vec<f32>,
18640    bf_re: Vec<f32>,
18641    bf_im: Vec<f32>,
18642    ar: Vec<f32>,
18643    ai: Vec<f32>,
18644}
18645
18646impl BluesteinScratchF32 {
18647    fn empty() -> Self {
18648        Self {
18649            m: 0,
18650            w_re: Vec::new(),
18651            w_im: Vec::new(),
18652            bf_re: Vec::new(),
18653            bf_im: Vec::new(),
18654            ar: Vec::new(),
18655            ai: Vec::new(),
18656        }
18657    }
18658
18659    fn build(n: usize, inverse: bool) -> Self {
18660        let m = if n <= 1 {
18661            1
18662        } else {
18663            (2 * n - 1).next_power_of_two()
18664        };
18665
18666        let mod_2n = (2 * n) as u64;
18667        let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
18668        let mut w_re = vec![0.0_f32; n];
18669        let mut w_im = vec![0.0_f32; n];
18670        for k in 0..n {
18671            let k2 = (k as u64).wrapping_mul(k as u64) % mod_2n;
18672            let theta = sign * std::f64::consts::PI * (k2 as f64) / (n as f64);
18673            w_re[k] = theta.cos() as f32;
18674            w_im[k] = theta.sin() as f32;
18675        }
18676
18677        let mut bf_re = vec![0.0_f32; m];
18678        let mut bf_im = vec![0.0_f32; m];
18679        if n > 0 {
18680            bf_re[0] = w_re[0];
18681            bf_im[0] = -w_im[0];
18682            for k in 1..n {
18683                bf_re[k] = w_re[k];
18684                bf_im[k] = -w_im[k];
18685                bf_re[m - k] = w_re[k];
18686                bf_im[m - k] = -w_im[k];
18687            }
18688        }
18689        if m > 1 {
18690            fft_radix2_inplace_f32(&mut bf_re, &mut bf_im, false);
18691        }
18692
18693        Self {
18694            m,
18695            w_re,
18696            w_im,
18697            bf_re,
18698            bf_im,
18699            ar: vec![0.0_f32; m],
18700            ai: vec![0.0_f32; m],
18701        }
18702    }
18703}
18704
18705fn fft_bluestein_inplace_f32(
18706    re: &mut [f32],
18707    im: &mut [f32],
18708    _inverse: bool,
18709    s: &mut BluesteinScratchF32,
18710) {
18711    let n = re.len();
18712    debug_assert_eq!(im.len(), n);
18713    debug_assert_eq!(s.w_re.len(), n);
18714    if n <= 1 {
18715        return;
18716    }
18717    let m = s.m;
18718
18719    for k in 0..m {
18720        s.ar[k] = 0.0;
18721        s.ai[k] = 0.0;
18722    }
18723    for k in 0..n {
18724        s.ar[k] = re[k] * s.w_re[k] - im[k] * s.w_im[k];
18725        s.ai[k] = re[k] * s.w_im[k] + im[k] * s.w_re[k];
18726    }
18727
18728    fft_radix2_inplace_f32(&mut s.ar, &mut s.ai, false);
18729
18730    for k in 0..m {
18731        let ar = s.ar[k];
18732        let ai = s.ai[k];
18733        let br = s.bf_re[k];
18734        let bi = s.bf_im[k];
18735        s.ar[k] = ar * br - ai * bi;
18736        s.ai[k] = ar * bi + ai * br;
18737    }
18738
18739    fft_radix2_inplace_f32(&mut s.ar, &mut s.ai, true);
18740    let inv_m = 1.0_f32 / (m as f32);
18741
18742    for k in 0..n {
18743        let yr = s.ar[k] * inv_m;
18744        let yi = s.ai[k] * inv_m;
18745        re[k] = yr * s.w_re[k] - yi * s.w_im[k];
18746        im[k] = yr * s.w_im[k] + yi * s.w_re[k];
18747    }
18748}
18749
18750/// Shared dispatch path for `Thunk::CustomOp`. Builds a typed
18751/// [`CpuTensorRef`] for each input *at that input's declared dtype*
18752/// (so a sparse-LU op with mixed F64/I32 inputs gets the right
18753/// typed slices) and a [`CpuTensorMut`] for the output, then calls
18754/// the kernel's single `execute` method.
18755unsafe fn dispatch_custom_op(
18756    kernel: &dyn crate::op_registry::CpuKernel,
18757    inputs: &[(usize, u32, Shape)],
18758    out_off: usize,
18759    out_len: u32,
18760    out_shape: &Shape,
18761    attrs: &[u8],
18762    base: *mut u8,
18763) {
18764    use crate::op_registry::{CpuTensorMut, CpuTensorRef};
18765    use rlx_ir::DType;
18766
18767    // One arm per `DType` variant — single source of truth for
18768    // "which dtypes the CPU custom-op dispatcher wires." If a new
18769    // DType lands in `rlx-ir`, the compiler flags this match as
18770    // non-exhaustive and the gap gets named at the right place.
18771    macro_rules! build_in_view {
18772        ($shape:expr, $off:expr, $n:expr, $variant:ident, $rust_ty:ty) => {
18773            CpuTensorRef::$variant {
18774                data: unsafe { sl_typed::<$rust_ty>($off, base, $n) },
18775                shape: $shape,
18776            }
18777        };
18778    }
18779    macro_rules! build_out_view {
18780        ($variant:ident, $rust_ty:ty) => {
18781            CpuTensorMut::$variant {
18782                data: unsafe { sl_mut_typed::<$rust_ty>(out_off, base, out_len as usize) },
18783                shape: out_shape,
18784            }
18785        };
18786    }
18787
18788    let in_views: Vec<CpuTensorRef<'_>> = inputs
18789        .iter()
18790        .map(|(off, len, shape)| {
18791            let n = *len as usize;
18792            let off = *off;
18793            match shape.dtype() {
18794                DType::F32 => build_in_view!(shape, off, n, F32, f32),
18795                DType::F64 => build_in_view!(shape, off, n, F64, f64),
18796                DType::F16 => build_in_view!(shape, off, n, F16, half::f16),
18797                DType::BF16 => build_in_view!(shape, off, n, BF16, half::bf16),
18798                DType::I8 => build_in_view!(shape, off, n, I8, i8),
18799                DType::I16 => build_in_view!(shape, off, n, I16, i16),
18800                DType::I32 => build_in_view!(shape, off, n, I32, i32),
18801                DType::I64 => build_in_view!(shape, off, n, I64, i64),
18802                DType::U8 => build_in_view!(shape, off, n, U8, u8),
18803                DType::U32 => build_in_view!(shape, off, n, U32, u32),
18804                DType::Bool => build_in_view!(shape, off, n, Bool, u8),
18805                // C64 isn't a CpuTensor variant today; the user-registered
18806                // op_registry path doesn't see complex inputs (those are
18807                // handled by built-in ops with dedicated kernels).
18808                DType::C64 => panic!(
18809                    "Op::Custom kernel input has DType::C64 — built-in \
18810                 complex ops handle their own kernels; user-registered \
18811                 ops don't yet see complex tensors"
18812                ),
18813            }
18814        })
18815        .collect();
18816
18817    let result = match out_shape.dtype() {
18818        DType::F32 => kernel.execute(&in_views, build_out_view!(F32, f32), attrs),
18819        DType::F64 => kernel.execute(&in_views, build_out_view!(F64, f64), attrs),
18820        DType::F16 => kernel.execute(&in_views, build_out_view!(F16, half::f16), attrs),
18821        DType::BF16 => kernel.execute(&in_views, build_out_view!(BF16, half::bf16), attrs),
18822        DType::I8 => kernel.execute(&in_views, build_out_view!(I8, i8), attrs),
18823        DType::I16 => kernel.execute(&in_views, build_out_view!(I16, i16), attrs),
18824        DType::I32 => kernel.execute(&in_views, build_out_view!(I32, i32), attrs),
18825        DType::I64 => kernel.execute(&in_views, build_out_view!(I64, i64), attrs),
18826        DType::U8 => kernel.execute(&in_views, build_out_view!(U8, u8), attrs),
18827        DType::U32 => kernel.execute(&in_views, build_out_view!(U32, u32), attrs),
18828        DType::Bool => kernel.execute(&in_views, build_out_view!(Bool, u8), attrs),
18829        DType::C64 => panic!("Op::Custom output DType::C64 not supported"),
18830    };
18831    if let Err(e) = result {
18832        panic!("Op::Custom('{}') CPU kernel failed: {e}", kernel.name());
18833    }
18834}
18835
18836/// Generic raw-cast slice helper. The existing per-dtype `sl_*` /
18837/// `sl_mut_*` helpers stay in place for the rest of `thunk.rs` (which
18838/// uses them at call sites with concrete dtypes); the custom-op
18839/// dispatcher uses these to enumerate every `DType` uniformly without
18840/// listing one helper per dtype.
18841#[inline(always)]
18842unsafe fn sl_typed<T>(offset: usize, base: *mut u8, len: usize) -> &'static [T] {
18843    if offset == usize::MAX {
18844        return &[];
18845    }
18846    unsafe { std::slice::from_raw_parts(base.add(offset) as *const T, len) }
18847}
18848
18849#[inline(always)]
18850unsafe fn sl_mut_typed<T>(offset: usize, base: *mut u8, len: usize) -> &'static mut [T] {
18851    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut T, len) }
18852}
18853
18854/// Scalar activation for the fused-region interpreter. Matches the GPU region
18855/// kernel's math (e.g. tanh-approx GELU) so CPU/GPU region results agree.
18856#[inline]
18857fn region_activation_scalar(act: rlx_ir::op::Activation, x: f32) -> f32 {
18858    use rlx_ir::op::Activation as A;
18859    const GC: f32 = 0.797_884_6; // sqrt(2/pi)
18860    match act {
18861        A::Relu => x.max(0.0),
18862        A::Gelu | A::GeluApprox => 0.5 * x * (1.0 + (GC * (x + 0.044715 * x * x * x)).tanh()),
18863        A::Silu => x / (1.0 + (-x).exp()),
18864        A::Sigmoid => 1.0 / (1.0 + (-x).exp()),
18865        A::Tanh => x.tanh(),
18866        A::Exp => x.exp(),
18867        A::Log => x.ln(),
18868        A::Sqrt => x.sqrt(),
18869        A::Rsqrt => 1.0 / x.sqrt(),
18870        A::Neg => -x,
18871        A::Abs => x.abs(),
18872        A::Sin => x.sin(),
18873        A::Cos => x.cos(),
18874        A::Tan => x.tan(),
18875        A::Atan => x.atan(),
18876        A::Round => x.round(),
18877    }
18878}
18879
18880#[inline]
18881fn region_binary_scalar(op: rlx_ir::op::BinaryOp, l: f32, r: f32) -> f32 {
18882    use rlx_ir::op::BinaryOp as B;
18883    match op {
18884        B::Add => l + r,
18885        B::Sub => l - r,
18886        B::Mul => l * r,
18887        B::Div => l / r,
18888        B::Max => l.max(r),
18889        B::Min => l.min(r),
18890        B::Pow => l.powf(r),
18891    }
18892}
18893
18894#[inline]
18895fn region_compare_scalar(op: rlx_ir::op::CmpOp, l: f32, r: f32) -> bool {
18896    use rlx_ir::op::CmpOp as C;
18897    match op {
18898        C::Eq => l == r,
18899        C::Ne => l != r,
18900        C::Lt => l < r,
18901        C::Le => l <= r,
18902        C::Gt => l > r,
18903        C::Ge => l >= r,
18904    }
18905}
18906
18907/// Resolve one chain operand for output element `gid`: a previous step result
18908/// (`scratch[s]`) or an external input read with broadcast (`input[gid % mod]`,
18909/// scalar inputs read element 0). `base` is the arena byte base.
18910#[inline]
18911fn region_resolve_operand(
18912    op: &rlx_ir::op::ChainOperand,
18913    gid: usize,
18914    base: *const u8,
18915    input_offs: &[usize],
18916    scalar_mask: u32,
18917    modulus: &[u32; 16],
18918    scratch: &[f32; 32],
18919) -> f32 {
18920    use rlx_ir::op::ChainOperand as O;
18921    match op {
18922        O::Step(s) => scratch[*s as usize],
18923        O::Input(i) => {
18924            let i = *i as usize;
18925            let row = if (scalar_mask >> i) & 1 == 1 {
18926                0
18927            } else if modulus[i] != 0 {
18928                gid % modulus[i] as usize
18929            } else {
18930                gid
18931            };
18932            unsafe { *(base.add(input_offs[i]) as *const f32).add(row) }
18933        }
18934    }
18935}
18936
18937/// Evaluate a fused element-wise chain for output element `gid`, returning the
18938/// final step's value (what gets written to `dst[gid]`).
18939#[inline]
18940fn region_eval_elem(
18941    gid: usize,
18942    base: *const u8,
18943    input_offs: &[usize],
18944    chain: &[rlx_ir::op::ChainStep],
18945    scalar_mask: u32,
18946    modulus: &[u32; 16],
18947) -> f32 {
18948    use rlx_ir::op::ChainStep as S;
18949    let mut scratch = [0f32; 32];
18950    let r = |o: &rlx_ir::op::ChainOperand, sc: &[f32; 32]| {
18951        region_resolve_operand(o, gid, base, input_offs, scalar_mask, modulus, sc)
18952    };
18953    for (k, step) in chain.iter().enumerate() {
18954        scratch[k] = match step {
18955            S::Activation(a, x) => region_activation_scalar(*a, r(x, &scratch)),
18956            S::Cast(_, x) => r(x, &scratch), // f32→f32 identity (chains are same-dtype)
18957            S::Binary(op, l, rr) => region_binary_scalar(*op, r(l, &scratch), r(rr, &scratch)),
18958            S::Compare(op, l, rr) => {
18959                if region_compare_scalar(*op, r(l, &scratch), r(rr, &scratch)) {
18960                    1.0
18961                } else {
18962                    0.0
18963                }
18964            }
18965            S::Where(c, t, f) => {
18966                if r(c, &scratch) != 0.0 {
18967                    r(t, &scratch)
18968                } else {
18969                    r(f, &scratch)
18970                }
18971            }
18972        };
18973    }
18974    scratch[chain.len() - 1]
18975}
18976
18977// Unsafe helpers to create slices from arena base + offset
18978/// In-place per-element activation. Mirrors the dispatch in
18979/// `Thunk::ActivationInPlace`. Used by `Thunk::FusedMmBiasAct` to
18980/// apply the activation after `bias_add` for all non-Gelu cases.
18981#[inline(always)]
18982fn apply_activation_inplace(d: &mut [f32], act: rlx_ir::op::Activation) {
18983    use rlx_ir::op::Activation;
18984    match act {
18985        Activation::Gelu => crate::kernels::par_gelu_inplace(d),
18986        Activation::GeluApprox => crate::kernels::par_gelu_approx_inplace(d),
18987        Activation::Silu => crate::kernels::par_silu_inplace(d),
18988        Activation::Relu => {
18989            for v in d.iter_mut() {
18990                *v = v.max(0.0);
18991            }
18992        }
18993        Activation::Sigmoid => {
18994            for v in d.iter_mut() {
18995                *v = 1.0 / (1.0 + (-*v).exp());
18996            }
18997        }
18998        Activation::Tanh => {
18999            for v in d.iter_mut() {
19000                *v = v.tanh();
19001            }
19002        }
19003        Activation::Exp => {
19004            for v in d.iter_mut() {
19005                *v = v.exp();
19006            }
19007        }
19008        Activation::Log => {
19009            for v in d.iter_mut() {
19010                *v = v.ln();
19011            }
19012        }
19013        Activation::Sqrt => {
19014            for v in d.iter_mut() {
19015                *v = v.sqrt();
19016            }
19017        }
19018        Activation::Rsqrt => {
19019            for v in d.iter_mut() {
19020                *v = 1.0 / v.sqrt();
19021            }
19022        }
19023        Activation::Neg => {
19024            for v in d.iter_mut() {
19025                *v = -*v;
19026            }
19027        }
19028        Activation::Abs => {
19029            for v in d.iter_mut() {
19030                *v = v.abs();
19031            }
19032        }
19033        Activation::Round => {
19034            for v in d.iter_mut() {
19035                *v = v.round();
19036            }
19037        }
19038        Activation::Sin => {
19039            for v in d.iter_mut() {
19040                *v = v.sin();
19041            }
19042        }
19043        Activation::Cos => {
19044            for v in d.iter_mut() {
19045                *v = v.cos();
19046            }
19047        }
19048        Activation::Tan => {
19049            for v in d.iter_mut() {
19050                *v = v.tan();
19051            }
19052        }
19053        Activation::Atan => {
19054            for v in d.iter_mut() {
19055                *v = v.atan();
19056            }
19057        }
19058    }
19059}
19060
19061/// im2col for one image (single batch + group slice).
19062///
19063/// Source `x` is `[c_in, H, W]` row-major. Destination `col` is
19064/// `[c_in · kH · kW, H_out · W_out]` row-major. Out-of-bounds positions
19065/// (in the padded region) are written as 0.
19066///
19067/// `col[(ci · kH · kW + ki · kW + kj) · n_dim + ho · W_out + wo] =
19068///    x[ci, ho·sh + ki·dh − ph, wo·sw + kj·dw_dil − pw]`
19069#[allow(clippy::too_many_arguments)]
19070/// Is the im2col+BLAS forward-conv kernel enabled? Read once from
19071/// `RLX_FAST_CONV` (1/on/true/yes). When off (the default), forward conv
19072/// runs the reference scalar loop. The flag is process-global so the two
19073/// benchmark configurations ("fused"/optimized vs reference) are produced
19074/// by separate process invocations — see the cortexm trainer.
19075fn fast_conv_enabled() -> bool {
19076    use std::sync::OnceLock;
19077    static FAST_CONV: OnceLock<bool> = OnceLock::new();
19078    *FAST_CONV.get_or_init(|| {
19079        matches!(
19080            rlx_ir::env::var("RLX_FAST_CONV").as_deref(),
19081            Some("1") | Some("on") | Some("true") | Some("yes")
19082        )
19083    })
19084}
19085
19086/// Reference forward convolution: a direct scalar nested loop. Correct and
19087/// dependency-free, but leaves the CPU idle (scalar, single-threaded, a
19088/// bounds-check branch in the hot loop). Kept as the parity oracle for
19089/// `conv2d_forward_im2col` and as the default kernel.
19090#[allow(clippy::too_many_arguments)]
19091fn conv2d_forward_naive(
19092    inp: &[f32],
19093    wt: &[f32],
19094    out: &mut [f32],
19095    n: usize,
19096    c_in: usize,
19097    h: usize,
19098    w: usize,
19099    c_out: usize,
19100    h_out: usize,
19101    w_out: usize,
19102    kh: usize,
19103    kw: usize,
19104    sh: usize,
19105    sw: usize,
19106    ph: usize,
19107    pw: usize,
19108    dh: usize,
19109    dw: usize,
19110    groups: usize,
19111) {
19112    let c_in_per_g = c_in / groups;
19113    let c_out_per_g = c_out / groups;
19114    for ni in 0..n {
19115        for co in 0..c_out {
19116            let g = co / c_out_per_g;
19117            let ci_start = g * c_in_per_g;
19118            for ho in 0..h_out {
19119                for wo in 0..w_out {
19120                    let mut acc = 0f32;
19121                    for ci_off in 0..c_in_per_g {
19122                        let ci = ci_start + ci_off;
19123                        let in_chan = ((ni * c_in) + ci) * h * w;
19124                        let wt_chan = ((co * c_in_per_g) + ci_off) * kh * kw;
19125                        for ki in 0..kh {
19126                            for kj in 0..kw {
19127                                let hi = ho * sh + ki * dh;
19128                                let wi = wo * sw + kj * dw;
19129                                if hi < ph || wi < pw {
19130                                    continue;
19131                                }
19132                                let hi = hi - ph;
19133                                let wi = wi - pw;
19134                                if hi >= h || wi >= w {
19135                                    continue;
19136                                }
19137                                acc += inp[in_chan + hi * w + wi] * wt[wt_chan + ki * kw + kj];
19138                            }
19139                        }
19140                    }
19141                    out[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = acc;
19142                }
19143            }
19144        }
19145    }
19146}
19147
19148/// Winograd F(2×2,3×3) enabled? Read once from `RLX_WINOGRAD`. Applies only to
19149/// 3×3 stride-1 no-dilation groups-1 convs (the conv-net hot case).
19150fn winograd_enabled() -> bool {
19151    use std::sync::OnceLock;
19152    static W: OnceLock<bool> = OnceLock::new();
19153    *W.get_or_init(|| {
19154        matches!(
19155            rlx_ir::env::var("RLX_WINOGRAD").as_deref(),
19156            Some("1") | Some("on") | Some("true") | Some("yes")
19157        )
19158    })
19159}
19160
19161/// Direct (no-im2col) forward conv enabled? `RLX_DIRECT_CONV`. Off by default —
19162/// im2col+BLAS is faster for low-channel convs; this wins only at high channels.
19163fn direct_conv_enabled() -> bool {
19164    use std::sync::OnceLock;
19165    static D: OnceLock<bool> = OnceLock::new();
19166    *D.get_or_init(|| {
19167        matches!(
19168            rlx_ir::env::var("RLX_DIRECT_CONV").as_deref(),
19169            Some("1") | Some("on") | Some("true") | Some("yes")
19170        )
19171    })
19172}
19173
19174/// Filter transform U = G·g·Gᵀ for one 3×3 filter → 4×4 (row-major).
19175#[inline]
19176fn winograd_filter_transform(g: &[f32]) -> [f32; 16] {
19177    let mut tmp = [0f32; 12]; // 4×3 = G·g
19178    for j in 0..3 {
19179        let (g0, g1, g2) = (g[j], g[3 + j], g[6 + j]);
19180        tmp[j] = g0;
19181        tmp[3 + j] = 0.5 * (g0 + g1 + g2);
19182        tmp[6 + j] = 0.5 * (g0 - g1 + g2);
19183        tmp[9 + j] = g2;
19184    }
19185    let mut u = [0f32; 16];
19186    for i in 0..4 {
19187        let (t0, t1, t2) = (tmp[i * 3], tmp[i * 3 + 1], tmp[i * 3 + 2]);
19188        u[i * 4] = t0;
19189        u[i * 4 + 1] = 0.5 * (t0 + t1 + t2);
19190        u[i * 4 + 2] = 0.5 * (t0 - t1 + t2);
19191        u[i * 4 + 3] = t2;
19192    }
19193    u
19194}
19195
19196/// Input transform V = Bᵀ·d·B for one 4×4 tile → 4×4 (row-major).
19197#[inline]
19198fn winograd_input_transform(d: &[f32; 16]) -> [f32; 16] {
19199    let mut t = [0f32; 16];
19200    for j in 0..4 {
19201        let (d0, d1, d2, d3) = (d[j], d[4 + j], d[8 + j], d[12 + j]);
19202        t[j] = d0 - d2;
19203        t[4 + j] = d1 + d2;
19204        t[8 + j] = d2 - d1;
19205        t[12 + j] = d1 - d3;
19206    }
19207    let mut v = [0f32; 16];
19208    for i in 0..4 {
19209        let (a0, a1, a2, a3) = (t[i * 4], t[i * 4 + 1], t[i * 4 + 2], t[i * 4 + 3]);
19210        v[i * 4] = a0 - a2;
19211        v[i * 4 + 1] = a1 + a2;
19212        v[i * 4 + 2] = a2 - a1;
19213        v[i * 4 + 3] = a1 - a3;
19214    }
19215    v
19216}
19217
19218/// Output transform Y = Aᵀ·m·A for one 4×4 accumulator → 2×2 (row-major).
19219#[inline]
19220fn winograd_output_transform(m: &[f32; 16]) -> [f32; 4] {
19221    let mut s = [0f32; 8];
19222    for j in 0..4 {
19223        let (m0, m1, m2, m3) = (m[j], m[4 + j], m[8 + j], m[12 + j]);
19224        s[j] = m0 + m1 + m2;
19225        s[4 + j] = m1 - m2 - m3;
19226    }
19227    let mut y = [0f32; 4];
19228    for i in 0..2 {
19229        let (a0, a1, a2, a3) = (s[i * 4], s[i * 4 + 1], s[i * 4 + 2], s[i * 4 + 3]);
19230        y[i * 2] = a0 + a1 + a2;
19231        y[i * 2 + 1] = a1 - a2 - a3;
19232    }
19233    y
19234}
19235
19236/// Winograd F(2×2,3×3) forward convolution (3×3, stride 1, no dilation, groups
19237/// 1, valid). ~2.25× fewer multiplies than im2col by working on 4×4 tiles:
19238/// transform filter (G·g·Gᵀ) and input (Bᵀ·d·B), 16 channel-GEMMs (one per
19239/// tile position), then output transform (Aᵀ·m·A). Boundary tiles zero-pad the
19240/// input read and skip out-of-range output writes (handles odd H_out/W_out).
19241#[allow(clippy::too_many_arguments)]
19242fn conv2d_forward_winograd(
19243    inp: &[f32],
19244    wt: &[f32],
19245    out: &mut [f32],
19246    n: usize,
19247    c_in: usize,
19248    h: usize,
19249    w: usize,
19250    c_out: usize,
19251    h_out: usize,
19252    w_out: usize,
19253) {
19254    let th = h_out.div_ceil(2);
19255    let tw = w_out.div_ceil(2);
19256    let tiles_per = th * tw;
19257    let nt = n * tiles_per;
19258    if nt == 0 {
19259        return;
19260    }
19261
19262    // Filter transform U[p][co][ci]  (p = 0..16).
19263    let mut u = vec![0f32; 16 * c_out * c_in];
19264    for co in 0..c_out {
19265        for ci in 0..c_in {
19266            let uu = winograd_filter_transform(&wt[(co * c_in + ci) * 9..(co * c_in + ci) * 9 + 9]);
19267            for (p, &val) in uu.iter().enumerate() {
19268                u[p * c_out * c_in + co * c_in + ci] = val;
19269            }
19270        }
19271    }
19272
19273    // Input transform V[p][ci][tile].
19274    let mut v = vec![0f32; 16 * c_in * nt];
19275    let v_addr = v.as_mut_ptr() as usize;
19276    let in_xform = |tile: usize| {
19277        let ni = tile / tiles_per;
19278        let rem = tile % tiles_per;
19279        let (h0, w0) = (2 * (rem / tw), 2 * (rem % tw));
19280        for ci in 0..c_in {
19281            let mut d = [0f32; 16];
19282            for di in 0..4 {
19283                let hh = h0 + di;
19284                if hh >= h {
19285                    continue;
19286                }
19287                let base = ((ni * c_in + ci) * h + hh) * w;
19288                for dj in 0..4 {
19289                    let ww = w0 + dj;
19290                    if ww < w {
19291                        d[di * 4 + dj] = inp[base + ww];
19292                    }
19293                }
19294            }
19295            let vv = winograd_input_transform(&d);
19296            for (p, &val) in vv.iter().enumerate() {
19297                unsafe {
19298                    *((v_addr as *mut f32).add(p * c_in * nt + ci * nt + tile)) = val;
19299                }
19300            }
19301        }
19302    };
19303    if fast_conv_enabled() && crate::pool::should_parallelize(nt * c_in * 16) {
19304        crate::pool::par_for(nt, crate::pool::outer_chunk(nt), &|off, cnt| {
19305            for t in off..off + cnt {
19306                in_xform(t);
19307            }
19308        });
19309    } else {
19310        for t in 0..nt {
19311            in_xform(t);
19312        }
19313    }
19314
19315    // 16 channel-GEMMs: M[p] = U[p]·V[p]  → [c_out, nt].
19316    let mut m = vec![0f32; 16 * c_out * nt];
19317    for p in 0..16 {
19318        crate::blas::sgemm(
19319            &u[p * c_out * c_in..(p + 1) * c_out * c_in],
19320            &v[p * c_in * nt..(p + 1) * c_in * nt],
19321            &mut m[p * c_out * nt..(p + 1) * c_out * nt],
19322            c_out,
19323            c_in,
19324            nt,
19325        );
19326    }
19327
19328    // Output transform + scatter.
19329    let out_addr = out.as_mut_ptr() as usize;
19330    let out_xform = |tile: usize| {
19331        let ni = tile / tiles_per;
19332        let rem = tile % tiles_per;
19333        let (ho0, wo0) = (2 * (rem / tw), 2 * (rem % tw));
19334        for co in 0..c_out {
19335            let mut mm = [0f32; 16];
19336            for (p, slot) in mm.iter_mut().enumerate() {
19337                *slot = m[p * c_out * nt + co * nt + tile];
19338            }
19339            let y = winograd_output_transform(&mm);
19340            for yi in 0..2 {
19341                let oh = ho0 + yi;
19342                if oh >= h_out {
19343                    continue;
19344                }
19345                for yj in 0..2 {
19346                    let ow = wo0 + yj;
19347                    if ow < w_out {
19348                        unsafe {
19349                            *((out_addr as *mut f32)
19350                                .add(((ni * c_out + co) * h_out + oh) * w_out + ow)) =
19351                                y[yi * 2 + yj];
19352                        }
19353                    }
19354                }
19355            }
19356        }
19357    };
19358    if fast_conv_enabled() && crate::pool::should_parallelize(nt * c_out * 16) {
19359        crate::pool::par_for(nt, crate::pool::outer_chunk(nt), &|off, cnt| {
19360            for t in off..off + cnt {
19361                out_xform(t);
19362            }
19363        });
19364    } else {
19365        for t in 0..nt {
19366            out_xform(t);
19367        }
19368    }
19369}
19370
19371/// Direct forward convolution for the stride-1, no-padding, dilation-1 case
19372/// (the conv-net hot path). Unlike im2col it never materialises the 9×-expanded
19373/// patch buffer — each output plane stays L1-resident while we accumulate over
19374/// (c_in, kh, kw), and the innermost loop is a contiguous SAXPY over the output
19375/// row (`out[wo] += w * in[wo+kj]`) that autovectorizes. Compute-bound, not
19376/// bandwidth-bound — the win over im2col for low-channel convs. Parallel over
19377/// (batch × c_out); caller guarantees stride/pad/dilation eligibility.
19378#[allow(clippy::too_many_arguments)]
19379fn conv2d_forward_direct(
19380    inp: &[f32],
19381    wt: &[f32],
19382    out: &mut [f32],
19383    n: usize,
19384    c_in: usize,
19385    h: usize,
19386    w: usize,
19387    c_out: usize,
19388    h_out: usize,
19389    w_out: usize,
19390    kh: usize,
19391    kw: usize,
19392    groups: usize,
19393) {
19394    let c_in_per_g = c_in / groups;
19395    let c_out_per_g = c_out / groups;
19396    let out_plane = h_out * w_out;
19397    let in_plane = h * w;
19398    let out_addr = out.as_mut_ptr() as usize;
19399    let compute = |nco: usize| {
19400        let ni = nco / c_out;
19401        let co = nco % c_out;
19402        let ci_start = (co / c_out_per_g) * c_in_per_g;
19403        let out_base = (ni * c_out + co) * out_plane;
19404        let op = unsafe {
19405            std::slice::from_raw_parts_mut((out_addr as *mut f32).add(out_base), out_plane)
19406        };
19407        for v in op.iter_mut() {
19408            *v = 0.0;
19409        }
19410        for ci_off in 0..c_in_per_g {
19411            let in_base = (ni * c_in + ci_start + ci_off) * in_plane;
19412            let wt_base = (co * c_in_per_g + ci_off) * kh * kw;
19413            for ki in 0..kh {
19414                for kj in 0..kw {
19415                    let wv = wt[wt_base + ki * kw + kj];
19416                    for ho in 0..h_out {
19417                        let in_row = in_base + (ho + ki) * w + kj;
19418                        let dst = &mut op[ho * w_out..ho * w_out + w_out];
19419                        let src = &inp[in_row..in_row + w_out];
19420                        for wo in 0..w_out {
19421                            dst[wo] += wv * src[wo];
19422                        }
19423                    }
19424                }
19425            }
19426        }
19427    };
19428    if fast_conv_enabled() && crate::pool::should_parallelize(n * c_out * out_plane) {
19429        crate::pool::par_for(
19430            n * c_out,
19431            crate::pool::outer_chunk(n * c_out),
19432            &|off, cnt| {
19433                for nco in off..off + cnt {
19434                    compute(nco);
19435                }
19436            },
19437        );
19438    } else {
19439        for nco in 0..n * c_out {
19440            compute(nco);
19441        }
19442    }
19443}
19444
19445/// im2col + BLAS forward convolution. For each (batch, group) we gather the
19446/// receptive-field patches into a `[c_in_per_g·kH·kW, H_out·W_out]` column
19447/// matrix and dispatch a single `sgemm`:
19448///
19449/// ```text
19450///   out_n_g [c_out_per_g, P]  =  weight_g [c_out_per_g, K]  @  col [K, P]
19451/// ```
19452///
19453/// The result lands in NCHW directly (matching `Conv2D1x1`). The batch loop
19454/// is embarrassingly parallel — each image writes a disjoint output region —
19455/// so it fans out over the thread pool, each worker owning a `col` scratch.
19456#[allow(clippy::too_many_arguments)]
19457fn conv2d_forward_im2col(
19458    inp: &[f32],
19459    wt: &[f32],
19460    out: &mut [f32],
19461    n: usize,
19462    c_in: usize,
19463    h: usize,
19464    w: usize,
19465    c_out: usize,
19466    h_out: usize,
19467    w_out: usize,
19468    kh: usize,
19469    kw: usize,
19470    sh: usize,
19471    sw: usize,
19472    ph: usize,
19473    pw: usize,
19474    dh: usize,
19475    dw: usize,
19476    groups: usize,
19477) {
19478    let c_in_per_g = c_in / groups;
19479    let c_out_per_g = c_out / groups;
19480    let k_dim = c_in_per_g * kh * kw; // im2col rows (contraction dim)
19481    let p_dim = h_out * w_out; // spatial positions
19482    let x_stride_n = c_in * h * w;
19483    let x_stride_g = c_in_per_g * h * w;
19484    let out_stride_n = c_out * h_out * w_out;
19485    let out_stride_g = c_out_per_g * p_dim;
19486    let w_stride_g = c_out_per_g * k_dim;
19487
19488    // Hand each worker the output base as a raw address: every (ni, g) writes
19489    // a disjoint `[out_stride_g]` window, so the aliasing is provably safe.
19490    let out_addr = out.as_mut_ptr() as usize;
19491    crate::pool::par_for(n, 1, &|off, cnt| {
19492        let mut col = vec![0f32; k_dim * p_dim];
19493        for ni in off..off + cnt {
19494            for g in 0..groups {
19495                let x_off = ni * x_stride_n + g * x_stride_g;
19496                im2col(
19497                    &inp[x_off..x_off + x_stride_g],
19498                    &mut col,
19499                    c_in_per_g,
19500                    h,
19501                    w,
19502                    h_out,
19503                    w_out,
19504                    kh,
19505                    kw,
19506                    sh,
19507                    sw,
19508                    ph,
19509                    pw,
19510                    dh,
19511                    dw,
19512                );
19513                let w_off = g * w_stride_g;
19514                let o_off = ni * out_stride_n + g * out_stride_g;
19515                let out_g = unsafe {
19516                    std::slice::from_raw_parts_mut((out_addr as *mut f32).add(o_off), out_stride_g)
19517                };
19518                crate::blas::sgemm(
19519                    &wt[w_off..w_off + w_stride_g],
19520                    &col,
19521                    out_g,
19522                    c_out_per_g,
19523                    k_dim,
19524                    p_dim,
19525                );
19526            }
19527        }
19528    });
19529}
19530
19531fn im2col(
19532    x: &[f32],
19533    col: &mut [f32],
19534    c_in: usize,
19535    h: usize,
19536    w: usize,
19537    h_out: usize,
19538    w_out: usize,
19539    kh: usize,
19540    kw: usize,
19541    sh: usize,
19542    sw: usize,
19543    ph: usize,
19544    pw: usize,
19545    dh: usize,
19546    dw_dil: usize,
19547) {
19548    let n_dim = h_out * w_out;
19549    debug_assert_eq!(col.len(), c_in * kh * kw * n_dim);
19550    debug_assert_eq!(x.len(), c_in * h * w);
19551    let h_isz = h as isize;
19552    let w_isz = w as isize;
19553    let ph_isz = ph as isize;
19554    let pw_isz = pw as isize;
19555    for ci in 0..c_in {
19556        for ki in 0..kh {
19557            for kj in 0..kw {
19558                let row = ((ci * kh) + ki) * kw + kj;
19559                let row_off = row * n_dim;
19560                for ho in 0..h_out {
19561                    let hi = (ho * sh + ki * dh) as isize - ph_isz;
19562                    if hi < 0 || hi >= h_isz {
19563                        for wo in 0..w_out {
19564                            col[row_off + ho * w_out + wo] = 0.0;
19565                        }
19566                        continue;
19567                    }
19568                    let hi = hi as usize;
19569                    let in_row_off = (ci * h + hi) * w;
19570                    for wo in 0..w_out {
19571                        let wi = (wo * sw + kj * dw_dil) as isize - pw_isz;
19572                        col[row_off + ho * w_out + wo] = if wi < 0 || wi >= w_isz {
19573                            0.0
19574                        } else {
19575                            x[in_row_off + wi as usize]
19576                        };
19577                    }
19578                }
19579            }
19580        }
19581    }
19582}
19583
19584/// col2im — inverse of `im2col` with scatter-accumulation. The caller
19585/// is responsible for zeroing `x` if it doesn't already start zero
19586/// (the conv-input-grad path zeros once before the batch loop).
19587///
19588/// `x[ci, hi, wi] += col[(ci · kH · kW + ki · kW + kj) · n_dim + ho · W_out + wo]`
19589/// for all `(ki, kj, ho, wo)` whose `(hi, wi)` lands in `[0, H) × [0, W)`.
19590#[allow(clippy::too_many_arguments)]
19591fn col2im(
19592    col: &[f32],
19593    x: &mut [f32],
19594    c_in: usize,
19595    h: usize,
19596    w: usize,
19597    h_out: usize,
19598    w_out: usize,
19599    kh: usize,
19600    kw: usize,
19601    sh: usize,
19602    sw: usize,
19603    ph: usize,
19604    pw: usize,
19605    dh: usize,
19606    dw_dil: usize,
19607) {
19608    let n_dim = h_out * w_out;
19609    debug_assert_eq!(col.len(), c_in * kh * kw * n_dim);
19610    debug_assert_eq!(x.len(), c_in * h * w);
19611    let h_isz = h as isize;
19612    let w_isz = w as isize;
19613    let ph_isz = ph as isize;
19614    let pw_isz = pw as isize;
19615    for ci in 0..c_in {
19616        for ki in 0..kh {
19617            for kj in 0..kw {
19618                let row = ((ci * kh) + ki) * kw + kj;
19619                let row_off = row * n_dim;
19620                for ho in 0..h_out {
19621                    let hi = (ho * sh + ki * dh) as isize - ph_isz;
19622                    if hi < 0 || hi >= h_isz {
19623                        continue;
19624                    }
19625                    let hi = hi as usize;
19626                    let in_row_off = (ci * h + hi) * w;
19627                    for wo in 0..w_out {
19628                        let wi = (wo * sw + kj * dw_dil) as isize - pw_isz;
19629                        if wi < 0 || wi >= w_isz {
19630                            continue;
19631                        }
19632                        x[in_row_off + wi as usize] += col[row_off + ho * w_out + wo];
19633                    }
19634                }
19635            }
19636        }
19637    }
19638}
19639
19640/// Element-wise backward for `Op::Activation`. `xs` is the original
19641/// input to the forward activation; `dys` is the upstream gradient.
19642/// Writes `out[i] = (d/dx act(xs[i])) * dys[i]`.
19643/// Decompose a per-channel quantization shape into the
19644/// `(chan_axis, chan_dim, inner)` triplet the kernel needs to map a
19645/// flat output index to a channel index. Per-tensor (`axis = None`)
19646/// degenerates to `chan_dim = 1, inner = len`, which makes the
19647/// kernel's `(i / inner) % chan_dim` always 0 — same fast path the
19648/// scalar version used.
19649fn quant_layout(shape: &rlx_ir::Shape, axis: Option<usize>) -> (usize, usize, usize) {
19650    match axis {
19651        None => (0, 1, shape.num_elements().unwrap_or(0).max(1)),
19652        Some(d) => {
19653            let chan_dim = shape.dim(d).unwrap_static();
19654            let inner: usize = (d + 1..shape.rank())
19655                .map(|i| shape.dim(i).unwrap_static())
19656                .product::<usize>()
19657                .max(1);
19658            (d, chan_dim, inner)
19659        }
19660    }
19661}
19662
19663fn activation_backward_kernel(
19664    act: rlx_ir::op::Activation,
19665    xs: &[f32],
19666    dys: &[f32],
19667    out: &mut [f32],
19668) {
19669    use rlx_ir::op::Activation;
19670    let n = xs.len();
19671    debug_assert_eq!(dys.len(), n);
19672    debug_assert_eq!(out.len(), n);
19673    match act {
19674        Activation::Relu => {
19675            for i in 0..n {
19676                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
19677            }
19678        }
19679        Activation::Sigmoid => {
19680            for i in 0..n {
19681                let s = 1.0 / (1.0 + (-xs[i]).exp());
19682                out[i] = s * (1.0 - s) * dys[i];
19683            }
19684        }
19685        Activation::Tanh => {
19686            for i in 0..n {
19687                let t = xs[i].tanh();
19688                out[i] = (1.0 - t * t) * dys[i];
19689            }
19690        }
19691        Activation::Silu => {
19692            // y = x * σ(x);  dy/dx = σ(x) * (1 + x * (1 - σ(x))).
19693            for i in 0..n {
19694                let s = 1.0 / (1.0 + (-xs[i]).exp());
19695                out[i] = s * (1.0 + xs[i] * (1.0 - s)) * dys[i];
19696            }
19697        }
19698        Activation::Gelu => {
19699            // Exact erf-based GELU:  y = 0.5 x (1 + erf(x / √2)).
19700            //   dy/dx = 0.5 (1 + erf(x/√2)) + (x / √(2π)) · exp(-x²/2)
19701            const INV_SQRT2: f32 = 0.707_106_77;
19702            const INV_SQRT_2PI: f32 = 0.398_942_3;
19703            for i in 0..n {
19704                let x = xs[i];
19705                let phi = 0.5 * (1.0 + erf_f32(x * INV_SQRT2));
19706                let pdf = INV_SQRT_2PI * (-(x * x) * 0.5).exp();
19707                out[i] = (phi + x * pdf) * dys[i];
19708            }
19709        }
19710        Activation::GeluApprox => {
19711            // Tanh-approximation:
19712            //   y = 0.5 x (1 + tanh(c · (x + 0.044715 x³))) where c = √(2/π).
19713            const C: f32 = 0.797_884_6; // √(2/π)
19714            const A: f32 = 0.044_715;
19715            for i in 0..n {
19716                let x = xs[i];
19717                let inner = C * (x + A * x * x * x);
19718                let t = inner.tanh();
19719                let dinner = C * (1.0 + 3.0 * A * x * x);
19720                let d = 0.5 * (1.0 + t) + 0.5 * x * (1.0 - t * t) * dinner;
19721                out[i] = d * dys[i];
19722            }
19723        }
19724        Activation::Exp => {
19725            for i in 0..n {
19726                out[i] = xs[i].exp() * dys[i];
19727            }
19728        }
19729        Activation::Log => {
19730            for i in 0..n {
19731                out[i] = dys[i] / xs[i];
19732            }
19733        }
19734        Activation::Sqrt => {
19735            // d/dx √x = 0.5 / √x — undefined at x=0; clamp to 0.
19736            for i in 0..n {
19737                let s = xs[i].sqrt();
19738                out[i] = if s > 0.0 { 0.5 * dys[i] / s } else { 0.0 };
19739            }
19740        }
19741        Activation::Rsqrt => {
19742            // d/dx (1/√x) = -0.5 · x^(-3/2).
19743            for i in 0..n {
19744                let s = xs[i].sqrt();
19745                out[i] = if s > 0.0 {
19746                    -0.5 * dys[i] / (xs[i] * s)
19747                } else {
19748                    0.0
19749                };
19750            }
19751        }
19752        Activation::Neg => {
19753            for i in 0..n {
19754                out[i] = -dys[i];
19755            }
19756        }
19757        Activation::Abs => {
19758            // sign(x); 0 at x=0.
19759            for i in 0..n {
19760                let x = xs[i];
19761                let s = if x > 0.0 {
19762                    1.0
19763                } else if x < 0.0 {
19764                    -1.0
19765                } else {
19766                    0.0
19767                };
19768                out[i] = s * dys[i];
19769            }
19770        }
19771        Activation::Round => {
19772            // STE: pretend the round was identity in the backward
19773            // pass. The round step has zero gradient almost
19774            // everywhere, so without this trick the optimizer can't
19775            // learn through it.
19776            out.copy_from_slice(dys);
19777        }
19778        Activation::Sin => {
19779            // d/dx sin(x) = cos(x).
19780            for i in 0..n {
19781                out[i] = xs[i].cos() * dys[i];
19782            }
19783        }
19784        Activation::Cos => {
19785            for i in 0..n {
19786                out[i] = -xs[i].sin() * dys[i];
19787            }
19788        }
19789        Activation::Tan => {
19790            // d/dx tan(x) = sec²(x) = 1 + tan²(x)
19791            for i in 0..n {
19792                let t = xs[i].tan();
19793                out[i] = (1.0 + t * t) * dys[i];
19794            }
19795        }
19796        Activation::Atan => {
19797            // d/dx atan(x) = 1 / (1 + x²)
19798            for i in 0..n {
19799                let x = xs[i];
19800                out[i] = dys[i] / (1.0 + x * x);
19801            }
19802        }
19803    }
19804}
19805
19806/// f64 sibling of `activation_backward_kernel`. Same math, twice the
19807/// precision — used by f64 graphs where the f32 kernel reading bytes
19808/// as `&[f32]` would silently discard half of every f64 value.
19809fn activation_backward_kernel_f64(
19810    act: rlx_ir::op::Activation,
19811    xs: &[f64],
19812    dys: &[f64],
19813    out: &mut [f64],
19814) {
19815    use rlx_ir::op::Activation;
19816    let n = xs.len();
19817    debug_assert_eq!(dys.len(), n);
19818    debug_assert_eq!(out.len(), n);
19819    match act {
19820        Activation::Relu => {
19821            for i in 0..n {
19822                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
19823            }
19824        }
19825        Activation::Sigmoid => {
19826            for i in 0..n {
19827                let s = 1.0 / (1.0 + (-xs[i]).exp());
19828                out[i] = s * (1.0 - s) * dys[i];
19829            }
19830        }
19831        Activation::Tanh => {
19832            for i in 0..n {
19833                let t = xs[i].tanh();
19834                out[i] = (1.0 - t * t) * dys[i];
19835            }
19836        }
19837        Activation::Silu => {
19838            for i in 0..n {
19839                let s = 1.0 / (1.0 + (-xs[i]).exp());
19840                out[i] = s * (1.0 + xs[i] * (1.0 - s)) * dys[i];
19841            }
19842        }
19843        Activation::Gelu | Activation::GeluApprox => {
19844            // Both rare on f64 paths; use the high-quality libm erf.
19845            const INV_SQRT2: f64 = std::f64::consts::FRAC_1_SQRT_2;
19846            const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
19847            for i in 0..n {
19848                let x = xs[i];
19849                let phi = 0.5 * (1.0 + erf_f64(x * INV_SQRT2));
19850                let pdf = INV_SQRT_2PI * (-(x * x) * 0.5).exp();
19851                out[i] = (phi + x * pdf) * dys[i];
19852            }
19853        }
19854        Activation::Exp => {
19855            for i in 0..n {
19856                out[i] = xs[i].exp() * dys[i];
19857            }
19858        }
19859        Activation::Log => {
19860            for i in 0..n {
19861                out[i] = dys[i] / xs[i];
19862            }
19863        }
19864        Activation::Sqrt => {
19865            for i in 0..n {
19866                let s = xs[i].sqrt();
19867                out[i] = if s > 0.0 { 0.5 * dys[i] / s } else { 0.0 };
19868            }
19869        }
19870        Activation::Rsqrt => {
19871            for i in 0..n {
19872                let s = xs[i].sqrt();
19873                out[i] = if s > 0.0 {
19874                    -0.5 * dys[i] / (xs[i] * s)
19875                } else {
19876                    0.0
19877                };
19878            }
19879        }
19880        Activation::Neg => {
19881            for i in 0..n {
19882                out[i] = -dys[i];
19883            }
19884        }
19885        Activation::Abs => {
19886            for i in 0..n {
19887                let x = xs[i];
19888                let s = if x > 0.0 {
19889                    1.0
19890                } else if x < 0.0 {
19891                    -1.0
19892                } else {
19893                    0.0
19894                };
19895                out[i] = s * dys[i];
19896            }
19897        }
19898        Activation::Round => {
19899            out.copy_from_slice(dys);
19900        }
19901        Activation::Sin => {
19902            for i in 0..n {
19903                out[i] = xs[i].cos() * dys[i];
19904            }
19905        }
19906        Activation::Cos => {
19907            for i in 0..n {
19908                out[i] = -xs[i].sin() * dys[i];
19909            }
19910        }
19911        Activation::Tan => {
19912            for i in 0..n {
19913                let t = xs[i].tan();
19914                out[i] = (1.0 + t * t) * dys[i];
19915            }
19916        }
19917        Activation::Atan => {
19918            for i in 0..n {
19919                let x = xs[i];
19920                out[i] = dys[i] / (1.0 + x * x);
19921            }
19922        }
19923    }
19924}
19925
19926/// f64 erf via A&S 7.1.26 — same coefficients as `erf_f32`, computed
19927/// at f64 width. Max error ~1.5e-7 (limited by the polynomial, not the
19928/// arithmetic). Adequate for gradient kernels; if higher precision is
19929/// needed, swap in a libm dependency.
19930#[inline(always)]
19931fn erf_f64(x: f64) -> f64 {
19932    let s = x.signum();
19933    let x = x.abs();
19934    let t = 1.0 / (1.0 + 0.327_591_1 * x);
19935    let y = 1.0
19936        - (((((1.061_405_43 * t - 1.453_152_03) * t) + 1.421_413_75) * t - 0.284_496_74) * t
19937            + 0.254_829_59)
19938            * t
19939            * (-x * x).exp();
19940    s * y
19941}
19942
19943/// Cheap erf approximation (Abramowitz & Stegun 7.1.26, max error ~1.5e-7
19944/// over all of ℝ — plenty for f32 gradient kernels).
19945#[inline(always)]
19946fn erf_f32(x: f32) -> f32 {
19947    let s = x.signum();
19948    let x = x.abs();
19949    let t = 1.0 / (1.0 + 0.327_591_1 * x);
19950    let y = 1.0
19951        - (((((1.061_405_4 * t - 1.453_152_1) * t) + 1.421_413_8) * t - 0.284_496_74) * t
19952            + 0.254_829_6)
19953            * t
19954            * (-x * x).exp();
19955    s * y
19956}
19957
19958fn narrow_thunk_closure(
19959    src: usize,
19960    dst: usize,
19961    outer: u32,
19962    src_stride: u32,
19963    dst_stride: u32,
19964    inner: u32,
19965    elem_bytes: u8,
19966) -> Arc<dyn Fn(*mut u8) + Send + Sync> {
19967    let (outer, ss, ds, inner, eb) = (
19968        outer as usize,
19969        src_stride as usize,
19970        dst_stride as usize,
19971        inner as usize,
19972        elem_bytes as usize,
19973    );
19974    let row_bytes = inner.saturating_mul(eb);
19975    let src_row_stride = ss.saturating_mul(eb);
19976    let dst_row_stride = ds.saturating_mul(eb);
19977    Arc::new(move |base: *mut u8| unsafe {
19978        if row_bytes == 0 || src == dst {
19979            return;
19980        }
19981        // Compiled-fn path has no arena length; skip if offsets look bogus.
19982        let arena_len = usize::MAX;
19983        for o in 0..outer {
19984            let s_off = src + o * src_row_stride;
19985            let d_off = dst + o * dst_row_stride;
19986            if s_off == d_off {
19987                continue;
19988            }
19989            if s_off.saturating_add(row_bytes) > arena_len
19990                || d_off.saturating_add(row_bytes) > arena_len
19991            {
19992                break;
19993            }
19994            std::ptr::copy_nonoverlapping(base.add(s_off), base.add(d_off), row_bytes);
19995        }
19996    })
19997}
19998
19999unsafe fn sl(offset: usize, base: *mut u8, len: usize) -> &'static [f32] {
20000    if offset == usize::MAX {
20001        return &[];
20002    }
20003    unsafe { std::slice::from_raw_parts(base.add(offset) as *const f32, len) }
20004}
20005
20006#[inline(always)]
20007unsafe fn sl_mut(offset: usize, base: *mut u8, len: usize) -> &'static mut [f32] {
20008    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut f32, len) }
20009}
20010
20011#[inline(always)]
20012unsafe fn sl_f64(offset: usize, base: *mut u8, len: usize) -> &'static [f64] {
20013    if offset == usize::MAX {
20014        return &[];
20015    }
20016    unsafe { std::slice::from_raw_parts(base.add(offset) as *const f64, len) }
20017}
20018
20019#[inline(always)]
20020unsafe fn sl_mut_f64(offset: usize, base: *mut u8, len: usize) -> &'static mut [f64] {
20021    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut f64, len) }
20022}
20023
20024// i32 / i64 typed slice helpers — siblings of sl_f32/sl_f64. Kept for
20025// integer-tensor thunks that haven't landed yet (Sample, Gather index
20026// buffers); deleting them now would force re-deriving the unsafe
20027// boilerplate when the next int-typed thunk lands.
20028#[inline(always)]
20029#[allow(dead_code)]
20030unsafe fn sl_i32(offset: usize, base: *mut u8, len: usize) -> &'static [i32] {
20031    if offset == usize::MAX {
20032        return &[];
20033    }
20034    unsafe { std::slice::from_raw_parts(base.add(offset) as *const i32, len) }
20035}
20036
20037#[inline(always)]
20038#[allow(dead_code)]
20039unsafe fn sl_mut_i32(offset: usize, base: *mut u8, len: usize) -> &'static mut [i32] {
20040    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut i32, len) }
20041}
20042
20043#[inline(always)]
20044unsafe fn sl_i64(offset: usize, base: *mut u8, len: usize) -> &'static [i64] {
20045    if offset == usize::MAX {
20046        return &[];
20047    }
20048    unsafe { std::slice::from_raw_parts(base.add(offset) as *const i64, len) }
20049}
20050
20051#[inline(always)]
20052unsafe fn sl_mut_i64(offset: usize, base: *mut u8, len: usize) -> &'static mut [i64] {
20053    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut i64, len) }
20054}
20055
20056/// f64 N-D index walk used by Transpose and Expand. `out_dims` gives
20057/// the output shape; `in_strides` gives the source stride for each
20058/// output dim (broadcast axes have stride 0).
20059fn transpose_walk_f64(inp: &[f64], out: &mut [f64], out_dims: &[u32], in_strides: &[u32]) {
20060    let rank = out_dims.len();
20061    let mut idx = vec![0u32; rank];
20062    for o in 0..out.len() {
20063        let mut src_off = 0usize;
20064        for d in 0..rank {
20065            src_off += idx[d] as usize * in_strides[d] as usize;
20066        }
20067        out[o] = inp[broadcast_src_index(src_off, inp.len())];
20068        // Increment index — last dim varies fastest.
20069        for d in (0..rank).rev() {
20070            idx[d] += 1;
20071            if idx[d] < out_dims[d] {
20072                break;
20073            }
20074            idx[d] = 0;
20075        }
20076    }
20077}
20078
20079/// f64 elementwise activation. Reads `inp`, writes `out`. For now
20080/// covers what the autodiff-emitted gradient graph needs (Neg, Exp,
20081/// Log, Sqrt, Rsqrt, Abs, Tanh, Sigmoid, Relu — the
20082/// transcendental-free subset). Approximate Gelu/Silu deferred until a
20083/// workload demands them at f64.
20084fn apply_activation_f64(inp: &[f64], out: &mut [f64], kind: Activation) {
20085    match kind {
20086        Activation::Neg => {
20087            for (o, &v) in out.iter_mut().zip(inp) {
20088                *o = -v;
20089            }
20090        }
20091        Activation::Exp => {
20092            for (o, &v) in out.iter_mut().zip(inp) {
20093                *o = v.exp();
20094            }
20095        }
20096        Activation::Log => {
20097            for (o, &v) in out.iter_mut().zip(inp) {
20098                *o = v.ln();
20099            }
20100        }
20101        Activation::Sqrt => {
20102            for (o, &v) in out.iter_mut().zip(inp) {
20103                *o = v.sqrt();
20104            }
20105        }
20106        Activation::Rsqrt => {
20107            for (o, &v) in out.iter_mut().zip(inp) {
20108                *o = 1.0 / v.sqrt();
20109            }
20110        }
20111        Activation::Abs => {
20112            for (o, &v) in out.iter_mut().zip(inp) {
20113                *o = v.abs();
20114            }
20115        }
20116        Activation::Tanh => {
20117            for (o, &v) in out.iter_mut().zip(inp) {
20118                *o = v.tanh();
20119            }
20120        }
20121        Activation::Sigmoid => {
20122            for (o, &v) in out.iter_mut().zip(inp) {
20123                *o = 1.0 / (1.0 + (-v).exp());
20124            }
20125        }
20126        Activation::Relu => {
20127            for (o, &v) in out.iter_mut().zip(inp) {
20128                *o = v.max(0.0);
20129            }
20130        }
20131        Activation::Round => {
20132            for (o, &v) in out.iter_mut().zip(inp) {
20133                *o = v.round_ties_even();
20134            }
20135        }
20136        Activation::Sin => {
20137            for (o, &v) in out.iter_mut().zip(inp) {
20138                *o = v.sin();
20139            }
20140        }
20141        Activation::Cos => {
20142            for (o, &v) in out.iter_mut().zip(inp) {
20143                *o = v.cos();
20144            }
20145        }
20146        Activation::Tan => {
20147            for (o, &v) in out.iter_mut().zip(inp) {
20148                *o = v.tan();
20149            }
20150        }
20151        Activation::Atan => {
20152            for (o, &v) in out.iter_mut().zip(inp) {
20153                *o = v.atan();
20154            }
20155        }
20156        Activation::Gelu | Activation::GeluApprox | Activation::Silu => {
20157            panic!(
20158                "apply_activation_f64: {kind:?} not yet implemented at f64. \
20159                    Add when a workload needs it."
20160            );
20161        }
20162    }
20163}
20164
20165#[inline]
20166fn binary_op_f64(op: BinaryOp, a: f64, b: f64) -> f64 {
20167    match op {
20168        BinaryOp::Add => a + b,
20169        BinaryOp::Sub => a - b,
20170        BinaryOp::Mul => a * b,
20171        BinaryOp::Div => a / b,
20172        BinaryOp::Max => a.max(b),
20173        BinaryOp::Min => a.min(b),
20174        BinaryOp::Pow => a.powf(b),
20175    }
20176}
20177
20178/// f64 sum reduction over a contiguous middle range.
20179/// Layout: input is `[outer, reduced, inner]`, output is `[outer, inner]`.
20180fn reduce_sum_f64(inp: &[f64], out: &mut [f64], outer: usize, reduced: usize, inner: usize) {
20181    for o in 0..outer {
20182        for n in 0..inner {
20183            let mut acc = 0.0_f64;
20184            for r in 0..reduced {
20185                acc += inp[o * reduced * inner + r * inner + n];
20186            }
20187            out[o * inner + n] = acc;
20188        }
20189    }
20190}
20191
20192/// Host-side RNG fill against a byte arena (Metal/CUDA unified-memory fallback).
20193///
20194/// # Safety
20195///
20196/// `arena` must point to a valid allocation with at least `dst_off + len * 4` bytes.
20197pub unsafe fn fill_rng_normal_arena(
20198    dst_off: usize,
20199    len: usize,
20200    mean: f32,
20201    scale: f32,
20202    key: u64,
20203    op_seed: Option<f32>,
20204    opts: rlx_ir::RngOptions,
20205    arena: *mut u8,
20206) {
20207    if len == 0 {
20208        return;
20209    }
20210    unsafe {
20211        let out = std::slice::from_raw_parts_mut((arena.add(dst_off)) as *mut f32, len);
20212        rlx_ir::fill_normal_like(out, mean, scale, opts, key, op_seed);
20213    }
20214}
20215
20216pub unsafe fn fill_rng_uniform_arena(
20217    dst_off: usize,
20218    len: usize,
20219    low: f32,
20220    high: f32,
20221    key: u64,
20222    op_seed: Option<f32>,
20223    opts: rlx_ir::RngOptions,
20224    arena: *mut u8,
20225) {
20226    if len == 0 {
20227        return;
20228    }
20229    unsafe {
20230        let out = std::slice::from_raw_parts_mut((arena.add(dst_off)) as *mut f32, len);
20231        rlx_ir::fill_uniform_like(out, low, high, opts, key, op_seed);
20232    }
20233}
20234
20235#[cfg(test)]
20236mod tests {
20237    use super::*;
20238    use rlx_ir::*;
20239
20240    /// The im2col+BLAS forward conv must match the reference scalar loop
20241    /// (up to float reassociation) across a range of shapes — strided,
20242    /// dilated, padded, grouped, and multi-batch. Guards the RLX_FAST_CONV
20243    /// fast path used by the "fused" benchmark configuration.
20244    #[test]
20245    fn conv2d_im2col_matches_naive() {
20246        // (n, c_in, h, w, c_out, kh, kw, sh, sw, ph, pw, dh, dw, groups)
20247        let cases = [
20248            (1, 1, 28, 28, 8, 3, 3, 1, 1, 0, 0, 1, 1, 1), // TinyConv layer 1
20249            (4, 8, 13, 13, 16, 3, 3, 1, 1, 0, 0, 1, 1, 1), // TinyConv layer 2, batched
20250            (2, 3, 16, 16, 6, 3, 3, 2, 2, 1, 1, 1, 1, 1), // stride 2, pad 1
20251            (1, 4, 12, 12, 4, 3, 3, 1, 1, 2, 2, 2, 2, 1), // dilation 2
20252            (3, 8, 10, 10, 8, 3, 3, 1, 1, 1, 1, 1, 1, 2), // groups = 2
20253            (1, 2, 7, 7, 5, 1, 1, 1, 1, 0, 0, 1, 1, 1),   // 1x1
20254        ];
20255        for (idx, &(n, c_in, h, w, c_out, kh, kw, sh, sw, ph, pw, dh, dw, groups)) in
20256            cases.iter().enumerate()
20257        {
20258            let c_in_per_g = c_in / groups;
20259            let h_out = (h + 2 * ph - dh * (kh - 1) - 1) / sh + 1;
20260            let w_out = (w + 2 * pw - dw * (kw - 1) - 1) / sw + 1;
20261            // Deterministic pseudo-random inputs (no rng dep in tests).
20262            let mut s: u32 = 0x9e37_79b9 ^ (idx as u32 + 1);
20263            let mut rand = || {
20264                s ^= s << 13;
20265                s ^= s >> 17;
20266                s ^= s << 5;
20267                (s as f32 / u32::MAX as f32) - 0.5
20268            };
20269            let inp: Vec<f32> = (0..n * c_in * h * w).map(|_| rand()).collect();
20270            let wt: Vec<f32> = (0..c_out * c_in_per_g * kh * kw).map(|_| rand()).collect();
20271            let mut out_ref = vec![0f32; n * c_out * h_out * w_out];
20272            let mut out_fast = vec![0f32; n * c_out * h_out * w_out];
20273
20274            conv2d_forward_naive(
20275                &inp,
20276                &wt,
20277                &mut out_ref,
20278                n,
20279                c_in,
20280                h,
20281                w,
20282                c_out,
20283                h_out,
20284                w_out,
20285                kh,
20286                kw,
20287                sh,
20288                sw,
20289                ph,
20290                pw,
20291                dh,
20292                dw,
20293                groups,
20294            );
20295            conv2d_forward_im2col(
20296                &inp,
20297                &wt,
20298                &mut out_fast,
20299                n,
20300                c_in,
20301                h,
20302                w,
20303                c_out,
20304                h_out,
20305                w_out,
20306                kh,
20307                kw,
20308                sh,
20309                sw,
20310                ph,
20311                pw,
20312                dh,
20313                dw,
20314                groups,
20315            );
20316
20317            let max_abs = out_ref
20318                .iter()
20319                .zip(&out_fast)
20320                .map(|(a, b)| (a - b).abs())
20321                .fold(0f32, f32::max);
20322            assert!(
20323                max_abs < 1e-3,
20324                "case {idx}: im2col vs naive max abs diff {max_abs}"
20325            );
20326        }
20327    }
20328
20329    /// Direct forward conv (stride-1, no-pad) must match the reference scalar
20330    /// conv exactly-ish across channel/batch/group shapes.
20331    #[test]
20332    fn conv2d_direct_matches_naive() {
20333        // (n, c_in, h, w, c_out, kh, kw, groups)
20334        let cases = [
20335            (1, 1, 28, 28, 8, 3, 3, 1),  // TinyConv L1
20336            (4, 8, 13, 13, 16, 3, 3, 1), // TinyConv L2, batched
20337            (2, 6, 10, 10, 9, 3, 3, 3),  // groups=3
20338            (1, 4, 9, 9, 4, 5, 5, 1),    // 5×5 kernel
20339            (3, 2, 7, 7, 2, 1, 1, 1),    // 1×1
20340        ];
20341        for (idx, &(n, c_in, h, w, c_out, kh, kw, groups)) in cases.iter().enumerate() {
20342            let h_out = h - kh + 1;
20343            let w_out = w - kw + 1;
20344            let c_in_per_g = c_in / groups;
20345            let mut s: u32 = 0xfeed_1234 ^ (idx as u32 + 1);
20346            let mut rand = || {
20347                s ^= s << 13;
20348                s ^= s >> 17;
20349                s ^= s << 5;
20350                (s as f32 / u32::MAX as f32) - 0.5
20351            };
20352            let inp: Vec<f32> = (0..n * c_in * h * w).map(|_| rand()).collect();
20353            let wt: Vec<f32> = (0..c_out * c_in_per_g * kh * kw).map(|_| rand()).collect();
20354            let mut r = vec![0f32; n * c_out * h_out * w_out];
20355            let mut d = vec![0f32; n * c_out * h_out * w_out];
20356            conv2d_forward_naive(
20357                &inp, &wt, &mut r, n, c_in, h, w, c_out, h_out, w_out, kh, kw, 1, 1, 0, 0, 1, 1,
20358                groups,
20359            );
20360            conv2d_forward_direct(
20361                &inp, &wt, &mut d, n, c_in, h, w, c_out, h_out, w_out, kh, kw, groups,
20362            );
20363            let mx = r
20364                .iter()
20365                .zip(&d)
20366                .map(|(a, b)| (a - b).abs())
20367                .fold(0f32, f32::max);
20368            assert!(mx < 1e-4, "case {idx}: direct vs naive max abs diff {mx}");
20369        }
20370    }
20371
20372    /// Winograd F(2,3) forward conv must match the reference scalar conv (up to
20373    /// the transform's float reassociation) for 3×3 stride-1 valid convs,
20374    /// including odd output dims (boundary tiles) and multiple channels/batches.
20375    #[test]
20376    fn conv2d_winograd_matches_naive() {
20377        // (n, c_in, h, w, c_out)  — all 3×3 stride1 valid; covers even (26) and
20378        // odd (11) output dims and the TinyConv channel shapes.
20379        let cases = [
20380            (1, 1, 28, 28, 8),  // TinyConv layer 1: out 26×26 (even)
20381            (4, 8, 13, 13, 16), // TinyConv layer 2: out 11×11 (odd) — boundary tiles
20382            (2, 3, 9, 9, 5),    // out 7×7 (odd)
20383            (1, 4, 8, 8, 4),    // out 6×6 (even)
20384        ];
20385        for (idx, &(n, c_in, h, w, c_out)) in cases.iter().enumerate() {
20386            let h_out = h - 2;
20387            let w_out = w - 2;
20388            let mut s: u32 = 0x1234_5678 ^ (idx as u32 + 1);
20389            let mut rand = || {
20390                s ^= s << 13;
20391                s ^= s >> 17;
20392                s ^= s << 5;
20393                (s as f32 / u32::MAX as f32) - 0.5
20394            };
20395            let inp: Vec<f32> = (0..n * c_in * h * w).map(|_| rand()).collect();
20396            let wt: Vec<f32> = (0..c_out * c_in * 9).map(|_| rand()).collect();
20397            let mut out_ref = vec![0f32; n * c_out * h_out * w_out];
20398            let mut out_win = vec![0f32; n * c_out * h_out * w_out];
20399            conv2d_forward_naive(
20400                &inp,
20401                &wt,
20402                &mut out_ref,
20403                n,
20404                c_in,
20405                h,
20406                w,
20407                c_out,
20408                h_out,
20409                w_out,
20410                3,
20411                3,
20412                1,
20413                1,
20414                0,
20415                0,
20416                1,
20417                1,
20418                1,
20419            );
20420            conv2d_forward_winograd(&inp, &wt, &mut out_win, n, c_in, h, w, c_out, h_out, w_out);
20421            let max_abs = out_ref
20422                .iter()
20423                .zip(&out_win)
20424                .map(|(a, b)| (a - b).abs())
20425                .fold(0f32, f32::max);
20426            assert!(
20427                max_abs < 1e-3,
20428                "case {idx}: winograd vs naive max abs diff {max_abs}"
20429            );
20430        }
20431    }
20432
20433    /// Plan #45: when a Narrow's only consumer is a Rope, the thunk
20434    /// fusion pass collapses them — the Narrow becomes Nop, and the
20435    /// Rope reads from the parent buffer with its row stride. This
20436    /// test runs the unfused path (batch*seq > FusedAttnBlock
20437    /// threshold) and asserts the rewrite happened.
20438    #[test]
20439    fn narrow_rope_fuses_in_unfused_path() {
20440        let f = DType::F32;
20441        let mut g = Graph::new("nr_fuse");
20442        // Force batch*seq > 64 so FusedAttnBlock doesn't pre-empt us.
20443        let qkv = g.input("qkv", Shape::new(&[16, 8, 192], f)); // 16*8=128 > 64
20444        let cos = g.input("cos", Shape::new(&[16], f));
20445        let sin = g.input("sin", Shape::new(&[16], f));
20446        // Last-axis narrow: Q = qkv[..., 0..64]
20447        let q = g.narrow_(qkv, 2, 0, 64);
20448        let q_rope = g.rope(q, cos, sin, 16);
20449        g.set_outputs(vec![q_rope]);
20450
20451        let plan = rlx_opt::memory::plan_memory(&g);
20452        let arena = crate::arena::Arena::from_plan(plan);
20453        let sched = compile_thunks(&g, &arena);
20454
20455        let mut narrow_count = 0;
20456        let mut rope_with_stride: Option<u32> = None;
20457        for t in &sched.thunks {
20458            match t {
20459                Thunk::Narrow { .. } => narrow_count += 1,
20460                Thunk::Rope { src_row_stride, .. } => rope_with_stride = Some(*src_row_stride),
20461                _ => {}
20462            }
20463        }
20464        // After fusion the Narrow is gone; only the Rope remains, and
20465        // it now walks with the parent QKV's row stride (3 * 64 = 192).
20466        assert_eq!(
20467            narrow_count, 0,
20468            "Narrow→Rope fusion should leave zero Narrow thunks; saw {narrow_count}"
20469        );
20470        assert_eq!(
20471            rope_with_stride,
20472            Some(192),
20473            "Rope's src_row_stride should be 192 (parent qkv axis), saw {rope_with_stride:?}"
20474        );
20475    }
20476
20477    /// Plan #15: SSM selective scan matches a naive Python-style
20478    /// Python-style sequential reference.
20479    #[test]
20480    fn ssm_selective_scan_matches_reference() {
20481        use rlx_ir::Philox4x32;
20482        let bch = 1usize;
20483        let s = 4usize;
20484        let h = 3usize;
20485        let n = 2usize;
20486
20487        let mut rng = Philox4x32::new(13);
20488        let mut x = vec![0f32; bch * s * h];
20489        rng.fill_normal(&mut x);
20490        let mut delta = vec![0f32; bch * s * h];
20491        // Keep Δ small so exp(Δ·A) doesn't blow up.
20492        for v in delta.iter_mut() {
20493            *v = (rng.next_f32() - 0.5) * 0.1;
20494        }
20495        let mut a = vec![0f32; h * n];
20496        for v in a.iter_mut() {
20497            *v = -(rng.next_f32() * 0.5 + 0.1);
20498        } // negative for stability
20499        let mut b = vec![0f32; bch * s * n];
20500        rng.fill_normal(&mut b);
20501        let mut c = vec![0f32; bch * s * n];
20502        rng.fill_normal(&mut c);
20503
20504        // Reference scan.
20505        let mut expected = vec![0f32; bch * s * h];
20506        for bi in 0..bch {
20507            let mut state = vec![0f32; h * n];
20508            for si in 0..s {
20509                for ci in 0..h {
20510                    let d = delta[bi * s * h + si * h + ci];
20511                    let xv = x[bi * s * h + si * h + ci];
20512                    let mut acc = 0f32;
20513                    for ni in 0..n {
20514                        let da = (d * a[ci * n + ni]).exp();
20515                        state[ci * n + ni] =
20516                            da * state[ci * n + ni] + d * b[bi * s * n + si * n + ni] * xv;
20517                        acc += c[bi * s * n + si * n + ni] * state[ci * n + ni];
20518                    }
20519                    expected[bi * s * h + si * h + ci] = acc;
20520                }
20521            }
20522        }
20523
20524        // RLX path.
20525        let f = DType::F32;
20526        let mut g = Graph::new("ssm");
20527        let xn = g.input("x", Shape::new(&[bch, s, h], f));
20528        let dn = g.input("delta", Shape::new(&[bch, s, h], f));
20529        let an = g.param("a", Shape::new(&[h, n], f));
20530        let bn = g.param("b", Shape::new(&[bch, s, n], f));
20531        let cn = g.param("c", Shape::new(&[bch, s, n], f));
20532        let yn = g.selective_scan(xn, dn, an, bn, cn, n, Shape::new(&[bch, s, h], f));
20533        g.set_outputs(vec![yn]);
20534
20535        let plan = rlx_opt::memory::plan_memory(&g);
20536        let mut arena = crate::arena::Arena::from_plan(plan);
20537        let sched = compile_thunks(&g, &arena);
20538
20539        let xn_off = arena.byte_offset(xn);
20540        let dn_off = arena.byte_offset(dn);
20541        let an_off = arena.byte_offset(an);
20542        let bn_off = arena.byte_offset(bn);
20543        let cn_off = arena.byte_offset(cn);
20544        let yn_off = arena.byte_offset(yn);
20545        let buf = arena.raw_buf_mut();
20546        unsafe {
20547            let copy = |dst: *mut f32, data: &[f32]| {
20548                for (i, &v) in data.iter().enumerate() {
20549                    *dst.add(i) = v;
20550                }
20551            };
20552            copy(buf.as_mut_ptr().add(xn_off) as *mut f32, &x);
20553            copy(buf.as_mut_ptr().add(dn_off) as *mut f32, &delta);
20554            copy(buf.as_mut_ptr().add(an_off) as *mut f32, &a);
20555            copy(buf.as_mut_ptr().add(bn_off) as *mut f32, &b);
20556            copy(buf.as_mut_ptr().add(cn_off) as *mut f32, &c);
20557        }
20558        execute_thunks(&sched, arena.raw_buf_mut());
20559
20560        let actual: Vec<f32> = unsafe {
20561            let p = arena.raw_buf().as_ptr().add(yn_off) as *const f32;
20562            (0..bch * s * h).map(|i| *p.add(i)).collect()
20563        };
20564
20565        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
20566            assert!(
20567                (e - a).abs() < 1e-3,
20568                "mismatch at {i}: expected {e}, got {a}"
20569            );
20570        }
20571    }
20572
20573    /// Plan #26: 1×1 conv lowers to per-batch sgemm and matches the
20574    /// scalar 7-loop reference.
20575    #[test]
20576    fn conv_1x1_fast_path_matches_scalar() {
20577        use rlx_ir::Philox4x32;
20578        // [N=2, C_in=4, H=3, W=3]
20579        let n = 2usize;
20580        let c_in = 4usize;
20581        let h = 3usize;
20582        let w = 3usize;
20583        let c_out = 5usize;
20584        let mut rng = Philox4x32::new(31);
20585        let mut x = vec![0f32; n * c_in * h * w];
20586        rng.fill_normal(&mut x);
20587        let mut weight = vec![0f32; c_out * c_in];
20588        rng.fill_normal(&mut weight);
20589
20590        // Reference: scalar 1×1 conv = per-batch matmul
20591        // out[ni, co, hi, wi] = sum_ci weight[co, ci] * x[ni, ci, hi, wi]
20592        let mut expected = vec![0f32; n * c_out * h * w];
20593        for ni in 0..n {
20594            for co in 0..c_out {
20595                for hi in 0..h {
20596                    for wi in 0..w {
20597                        let mut acc = 0f32;
20598                        for ci in 0..c_in {
20599                            acc += weight[co * c_in + ci]
20600                                * x[((ni * c_in) + ci) * h * w + hi * w + wi];
20601                        }
20602                        expected[((ni * c_out) + co) * h * w + hi * w + wi] = acc;
20603                    }
20604                }
20605            }
20606        }
20607
20608        // RLX path: build a graph with Op::Conv (kernel=[1,1], stride=[1,1], etc).
20609        let f = DType::F32;
20610        let mut g = Graph::new("conv1x1");
20611        let xn = g.input("x", Shape::new(&[n, c_in, h, w], f));
20612        let wn = g.param("w", Shape::new(&[c_out, c_in, 1, 1], f));
20613        // Manually add Op::Conv since there's no `g.conv()` helper.
20614        let cn = g.add_node(
20615            rlx_ir::Op::Conv {
20616                kernel_size: vec![1, 1],
20617                stride: vec![1, 1],
20618                padding: vec![0, 0],
20619                dilation: vec![1, 1],
20620                groups: 1,
20621            },
20622            vec![xn, wn],
20623            Shape::new(&[n, c_out, h, w], f),
20624        );
20625        g.set_outputs(vec![cn]);
20626
20627        let plan = rlx_opt::memory::plan_memory(&g);
20628        let mut arena = crate::arena::Arena::from_plan(plan);
20629        let sched = compile_thunks(&g, &arena);
20630
20631        // Verify the fast path was selected.
20632        let saw_fast = sched
20633            .thunks
20634            .iter()
20635            .any(|t| matches!(t, Thunk::Conv2D1x1 { .. }));
20636        let saw_slow = sched
20637            .thunks
20638            .iter()
20639            .any(|t| matches!(t, Thunk::Conv2D { .. }));
20640        assert!(saw_fast, "1×1 conv should emit Conv2D1x1");
20641        assert!(!saw_slow, "1×1 conv must not fall through to scalar Conv2D");
20642
20643        let xn_off = arena.byte_offset(xn);
20644        let wn_off = arena.byte_offset(wn);
20645        let cn_off = arena.byte_offset(cn);
20646        let buf = arena.raw_buf_mut();
20647        unsafe {
20648            let xp = buf.as_mut_ptr().add(xn_off) as *mut f32;
20649            for (i, &v) in x.iter().enumerate() {
20650                *xp.add(i) = v;
20651            }
20652            let wp = buf.as_mut_ptr().add(wn_off) as *mut f32;
20653            for (i, &v) in weight.iter().enumerate() {
20654                *wp.add(i) = v;
20655            }
20656        }
20657        execute_thunks(&sched, arena.raw_buf_mut());
20658
20659        let actual: Vec<f32> = unsafe {
20660            let p = arena.raw_buf().as_ptr().add(cn_off) as *const f32;
20661            (0..(n * c_out * h * w)).map(|i| *p.add(i)).collect()
20662        };
20663
20664        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
20665            assert!(
20666                (e - a).abs() < 1e-3,
20667                "mismatch at {i}: expected {e}, got {a}"
20668            );
20669        }
20670    }
20671
20672    /// Plan #5: fused dequant matmul matches the dequant-then-matmul
20673    /// reference (i.e. `(scale * (q - z)) @ x` materialized).
20674    #[test]
20675    fn dequant_matmul_int8_sym_matches_reference() {
20676        use rlx_ir::Philox4x32;
20677        use rlx_ir::quant::QuantScheme;
20678
20679        let m = 3usize;
20680        let k = 8usize;
20681        let n = 4usize;
20682        let block_size = 4usize; // 2 blocks per column
20683        let blocks_per_col = k / block_size;
20684
20685        // Random inputs: x f32, w_q i8, scales f32. Symmetric → no zp.
20686        let mut rng = Philox4x32::new(99);
20687        let mut x = vec![0f32; m * k];
20688        rng.fill_normal(&mut x);
20689        let w_q: Vec<i8> = (0..(k * n))
20690            .map(|i| ((i as i32 * 13 + 7) % 127 - 63) as i8)
20691            .collect();
20692        let scales: Vec<f32> = (0..(blocks_per_col * n))
20693            .map(|i| 0.01 + 0.001 * i as f32)
20694            .collect();
20695
20696        // Reference: build f32 weights from (q * scale) per block.
20697        let mut w_f32 = vec![0f32; k * n];
20698        for p in 0..k {
20699            let block = p / block_size;
20700            for j in 0..n {
20701                let s = scales[block * n + j];
20702                w_f32[p * n + j] = w_q[p * n + j] as f32 * s;
20703            }
20704        }
20705        let mut expected = vec![0f32; m * n];
20706        for i in 0..m {
20707            for j in 0..n {
20708                let mut acc = 0f32;
20709                for p in 0..k {
20710                    acc += x[i * k + p] * w_f32[p * n + j];
20711                }
20712                expected[i * n + j] = acc;
20713            }
20714        }
20715
20716        // RLX path.
20717        let f = DType::F32;
20718        let mut g = Graph::new("dq");
20719        let xn = g.input("x", Shape::new(&[m, k], f));
20720        let wn = g.param("w", Shape::new(&[k, n], DType::I8));
20721        let sn = g.param("scale", Shape::new(&[blocks_per_col, n], f));
20722        let zn = g.param("zp", Shape::new(&[blocks_per_col, n], f)); // unused (sym)
20723        let dq = g.dequant_matmul(
20724            xn,
20725            wn,
20726            sn,
20727            zn,
20728            QuantScheme::Int8Block {
20729                block_size: block_size as u32,
20730            },
20731            Shape::new(&[m, n], f),
20732        );
20733        g.set_outputs(vec![dq]);
20734
20735        let plan = rlx_opt::memory::plan_memory(&g);
20736        let mut arena = crate::arena::Arena::from_plan(plan);
20737        let sched = compile_thunks(&g, &arena);
20738
20739        let xn_off = arena.byte_offset(xn);
20740        let wn_off = arena.byte_offset(wn);
20741        let sn_off = arena.byte_offset(sn);
20742        let zn_off = arena.byte_offset(zn);
20743        let dq_off = arena.byte_offset(dq);
20744        let buf = arena.raw_buf_mut();
20745        unsafe {
20746            // Seed f32 inputs.
20747            let xp = buf.as_mut_ptr().add(xn_off) as *mut f32;
20748            for (i, &v) in x.iter().enumerate() {
20749                *xp.add(i) = v;
20750            }
20751            let sp = buf.as_mut_ptr().add(sn_off) as *mut f32;
20752            for (i, &v) in scales.iter().enumerate() {
20753                *sp.add(i) = v;
20754            }
20755            let zp = buf.as_mut_ptr().add(zn_off) as *mut f32;
20756            for i in 0..(blocks_per_col * n) {
20757                *zp.add(i) = 0.0;
20758            }
20759            // Seed i8 weights byte-by-byte.
20760            let wp = buf.as_mut_ptr().add(wn_off) as *mut i8;
20761            for (i, &v) in w_q.iter().enumerate() {
20762                *wp.add(i) = v;
20763            }
20764        }
20765        execute_thunks(&sched, arena.raw_buf_mut());
20766
20767        let actual: Vec<f32> = unsafe {
20768            let p = arena.raw_buf().as_ptr().add(dq_off) as *const f32;
20769            (0..m * n).map(|i| *p.add(i)).collect()
20770        };
20771
20772        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
20773            assert!(
20774                (e - a).abs() < 1e-3,
20775                "mismatch at {i}: expected {e}, got {a}"
20776            );
20777        }
20778    }
20779
20780    /// Plan #9: LoRA matmul matches the unfused 3-matmul reference.
20781    #[test]
20782    fn lora_matmul_matches_unfused_reference() {
20783        use rlx_ir::Philox4x32;
20784
20785        let m = 4usize;
20786        let k = 8usize;
20787        let n = 6usize;
20788        let r = 2usize;
20789        let scale = 0.5f32;
20790
20791        // Random inputs (deterministic via Philox).
20792        let mut rng = Philox4x32::new(42);
20793        let mut x = vec![0f32; m * k];
20794        rng.fill_normal(&mut x);
20795        let mut w = vec![0f32; k * n];
20796        rng.fill_normal(&mut w);
20797        let mut a = vec![0f32; k * r];
20798        rng.fill_normal(&mut a);
20799        let mut b = vec![0f32; r * n];
20800        rng.fill_normal(&mut b);
20801
20802        // Reference: out = x·W + scale * x·A·B. Naive triple-loop.
20803        let naive = |a_buf: &[f32], b_buf: &[f32], rows: usize, inner: usize, cols: usize| {
20804            let mut o = vec![0f32; rows * cols];
20805            for i in 0..rows {
20806                for j in 0..cols {
20807                    let mut acc = 0f32;
20808                    for p in 0..inner {
20809                        acc += a_buf[i * inner + p] * b_buf[p * cols + j];
20810                    }
20811                    o[i * cols + j] = acc;
20812                }
20813            }
20814            o
20815        };
20816        let xw = naive(&x, &w, m, k, n);
20817        let xa = naive(&x, &a, m, k, r);
20818        let xab = naive(&xa, &b, m, r, n);
20819        let mut expected = xw;
20820        for i in 0..(m * n) {
20821            expected[i] += scale * xab[i];
20822        }
20823
20824        // RLX path: build a graph with one LoraMatMul.
20825        let f = DType::F32;
20826        let mut g = Graph::new("lora");
20827        let xn = g.input("x", Shape::new(&[m, k], f));
20828        let wn = g.param("w", Shape::new(&[k, n], f));
20829        let an = g.param("a", Shape::new(&[k, r], f));
20830        let bn = g.param("b", Shape::new(&[r, n], f));
20831        let lm = g.lora_matmul(xn, wn, an, bn, scale, Shape::new(&[m, n], f));
20832        g.set_outputs(vec![lm]);
20833
20834        let plan = rlx_opt::memory::plan_memory(&g);
20835        let mut arena = crate::arena::Arena::from_plan(plan);
20836        let sched = compile_thunks(&g, &arena);
20837
20838        let xn_off = arena.byte_offset(xn);
20839        let wn_off = arena.byte_offset(wn);
20840        let an_off = arena.byte_offset(an);
20841        let bn_off = arena.byte_offset(bn);
20842        let lm_off = arena.byte_offset(lm);
20843        let buf = arena.raw_buf_mut();
20844        unsafe {
20845            let copy = |dst: *mut f32, data: &[f32]| {
20846                for (i, &v) in data.iter().enumerate() {
20847                    *dst.add(i) = v;
20848                }
20849            };
20850            copy(buf.as_mut_ptr().add(xn_off) as *mut f32, &x);
20851            copy(buf.as_mut_ptr().add(wn_off) as *mut f32, &w);
20852            copy(buf.as_mut_ptr().add(an_off) as *mut f32, &a);
20853            copy(buf.as_mut_ptr().add(bn_off) as *mut f32, &b);
20854        }
20855        execute_thunks(&sched, arena.raw_buf_mut());
20856
20857        let actual: Vec<f32> = unsafe {
20858            let p = arena.raw_buf().as_ptr().add(lm_off) as *const f32;
20859            (0..m * n).map(|i| *p.add(i)).collect()
20860        };
20861
20862        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
20863            assert!(
20864                (e - a).abs() < 1e-3,
20865                "mismatch at {i}: expected {e}, got {a}"
20866            );
20867        }
20868    }
20869
20870    /// Plan #42: fused sampling kernel determinism + greedy fallback.
20871    #[test]
20872    fn sample_temperature_zero_is_argmax() {
20873        // Very low temperature → distribution collapses on argmax.
20874        // Same seed → same output bit-for-bit.
20875        let f = DType::F32;
20876        let mut g = Graph::new("samp");
20877        let logits = g.input("logits", Shape::new(&[1, 8], f));
20878        let s = g.sample(logits, 0, 1.0, 1e-3, 42, Shape::new(&[1], f));
20879        g.set_outputs(vec![s]);
20880        let plan = rlx_opt::memory::plan_memory(&g);
20881        let mut arena = crate::arena::Arena::from_plan(plan);
20882        let sched = compile_thunks(&g, &arena);
20883
20884        let logits_off = arena.byte_offset(logits);
20885        let s_off = arena.byte_offset(s);
20886        let buf = arena.raw_buf_mut();
20887        unsafe {
20888            let p = buf.as_mut_ptr().add(logits_off) as *mut f32;
20889            // argmax = index 5 (value 9.0).
20890            let inputs = [0.1f32, 0.2, 0.3, 0.4, 0.5, 9.0, 0.7, 0.8];
20891            for (i, &v) in inputs.iter().enumerate() {
20892                *p.add(i) = v;
20893            }
20894        }
20895        execute_thunks(&sched, arena.raw_buf_mut());
20896
20897        let token = unsafe {
20898            let p = arena.raw_buf().as_ptr().add(s_off) as *const f32;
20899            *p as usize
20900        };
20901        assert_eq!(token, 5, "low-temp sampling should pick the argmax");
20902    }
20903
20904    #[test]
20905    fn sample_top_k_one_is_deterministic() {
20906        // top_k=1 forces only the argmax to have nonzero probability.
20907        let f = DType::F32;
20908        let mut g = Graph::new("samp_k1");
20909        let logits = g.input("logits", Shape::new(&[1, 4], f));
20910        let s = g.sample(logits, 1, 1.0, 1.0, 7, Shape::new(&[1], f));
20911        g.set_outputs(vec![s]);
20912        let plan = rlx_opt::memory::plan_memory(&g);
20913        let mut arena = crate::arena::Arena::from_plan(plan);
20914        let sched = compile_thunks(&g, &arena);
20915
20916        let logits_off = arena.byte_offset(logits);
20917        let s_off = arena.byte_offset(s);
20918        let buf = arena.raw_buf_mut();
20919        unsafe {
20920            let p = buf.as_mut_ptr().add(logits_off) as *mut f32;
20921            let inputs = [0.1f32, 5.0, 0.3, 0.4]; // argmax = 1
20922            for (i, &v) in inputs.iter().enumerate() {
20923                *p.add(i) = v;
20924            }
20925        }
20926        execute_thunks(&sched, arena.raw_buf_mut());
20927        let token = unsafe {
20928            let p = arena.raw_buf().as_ptr().add(s_off) as *const f32;
20929            *p as usize
20930        };
20931        assert_eq!(token, 1);
20932    }
20933
20934    /// Plan #44: cumsum primitive parity vs. naive scan.
20935    #[test]
20936    fn cumsum_inclusive_matches_naive() {
20937        let f = DType::F32;
20938        let mut g = Graph::new("cumsum");
20939        let x = g.input("x", Shape::new(&[2, 4], f));
20940        let cs = g.cumsum(x, -1, false, Shape::new(&[2, 4], f));
20941        g.set_outputs(vec![cs]);
20942        let plan = rlx_opt::memory::plan_memory(&g);
20943        let mut arena = crate::arena::Arena::from_plan(plan);
20944        let sched = compile_thunks(&g, &arena);
20945
20946        // Cache offsets up-front so we can drop the immutable borrow.
20947        let x_off = arena.byte_offset(x);
20948        let out_off = arena.byte_offset(cs);
20949        let buf = arena.raw_buf_mut();
20950        unsafe {
20951            let p = buf.as_mut_ptr().add(x_off) as *mut f32;
20952            let inputs = [1.0f32, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0];
20953            for (i, &v) in inputs.iter().enumerate() {
20954                *p.add(i) = v;
20955            }
20956        }
20957        execute_thunks(&sched, arena.raw_buf_mut());
20958
20959        let out: Vec<f32> = unsafe {
20960            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
20961            (0..8).map(|i| *p.add(i)).collect()
20962        };
20963        assert_eq!(out, vec![1.0, 3.0, 6.0, 10.0, 10.0, 30.0, 60.0, 100.0]);
20964    }
20965
20966    /// Plan #46 deep: Narrow×3 → Attention fusion. The three QKV
20967    /// narrows that BERT/Nomic emit on the unfused (batch*seq > 64)
20968    /// path collapse into a single strided-Attention thunk.
20969    #[test]
20970    fn narrow_attention_fuses_in_unfused_path() {
20971        let f = DType::F32;
20972        let mut g = Graph::new("nattn_fuse");
20973        // batch*seq = 8*16 = 128 > 64 so FusedAttnBlock skips.
20974        let qkv = g.input("qkv", Shape::new(&[8, 16, 192], f)); // 3*64 = 192
20975        let mask = g.input("mask", Shape::new(&[8, 16], f));
20976        let q = g.narrow_(qkv, 2, 0, 64);
20977        let k = g.narrow_(qkv, 2, 64, 64);
20978        let v = g.narrow_(qkv, 2, 128, 64);
20979        let attn = g.attention(q, k, v, mask, 4, 16, Shape::new(&[8, 16, 64], f));
20980        g.set_outputs(vec![attn]);
20981
20982        let plan = rlx_opt::memory::plan_memory(&g);
20983        let arena = crate::arena::Arena::from_plan(plan);
20984        let sched = compile_thunks(&g, &arena);
20985
20986        let mut narrow_count = 0;
20987        let mut attn_strides: Option<(u32, u32, u32)> = None;
20988        for t in &sched.thunks {
20989            match t {
20990                Thunk::Narrow { .. } => narrow_count += 1,
20991                Thunk::Attention {
20992                    q_row_stride,
20993                    k_row_stride,
20994                    v_row_stride,
20995                    ..
20996                } => attn_strides = Some((*q_row_stride, *k_row_stride, *v_row_stride)),
20997                _ => {}
20998            }
20999        }
21000        // After fusion the 3 narrows are gone; Attention now walks the
21001        // QKV with parent row stride = 192 (3 × 64) on all three inputs.
21002        assert_eq!(
21003            narrow_count, 0,
21004            "Narrow×3→Attention fusion should eliminate all 3 narrows; saw {narrow_count}"
21005        );
21006        assert_eq!(
21007            attn_strides,
21008            Some((192, 192, 192)),
21009            "Attention should walk Q/K/V with parent row stride 192"
21010        );
21011    }
21012
21013    /// Regression: when the QKV→Narrow×3→RoPE×2→Attention→OutProj chain
21014    /// collapses into a single `FusedAttnBlock` (small batch·seq), the fused
21015    /// kernel must honor a **causal** mask. It previously applied only the
21016    /// per-key padding mask and dropped `mask_kind`, so a later token leaked
21017    /// into earlier positions (decoder attention attended to the future).
21018    #[test]
21019    fn fused_attn_block_respects_causal_mask() {
21020        let f = DType::F32;
21021        let (s, d, nh, dh) = (5usize, 8usize, 2usize, 4usize);
21022        let half = dh / 2;
21023
21024        let mut g = Graph::new("fused_causal");
21025        let hidden = g.input("hidden", Shape::new(&[s, d], f));
21026        let wqkv = g.input("wqkv", Shape::new(&[d, 3 * d], f));
21027        let wo = g.input("wo", Shape::new(&[d, d], f));
21028        let cos = g.input("cos", Shape::new(&[s, half], f));
21029        let sin = g.input("sin", Shape::new(&[s, half], f));
21030        let qkv = g.matmul(hidden, wqkv, Shape::new(&[s, 3 * d], f));
21031        let q = g.narrow_(qkv, 1, 0, d);
21032        let k = g.narrow_(qkv, 1, d, d);
21033        let v = g.narrow_(qkv, 1, 2 * d, d);
21034        let q3 = g.reshape(q, vec![1, s as i64, d as i64], Shape::new(&[1, s, d], f));
21035        let k3 = g.reshape(k, vec![1, s as i64, d as i64], Shape::new(&[1, s, d], f));
21036        let v3 = g.reshape(v, vec![1, s as i64, d as i64], Shape::new(&[1, s, d], f));
21037        let qr = g.rope(q3, cos, sin, dh);
21038        let kr = g.rope(k3, cos, sin, dh);
21039        let attn = g.attention_kind(
21040            qr,
21041            kr,
21042            v3,
21043            nh,
21044            dh,
21045            rlx_ir::op::MaskKind::Causal,
21046            Shape::new(&[1, s, d], f),
21047        );
21048        let a2 = g.reshape(attn, vec![s as i64, d as i64], Shape::new(&[s, d], f));
21049        let out = g.matmul(a2, wo, Shape::new(&[s, d], f));
21050        g.set_outputs(vec![out]);
21051
21052        // The fusion must actually fire AND carry the causal kind.
21053        let plan = rlx_opt::memory::plan_memory(&g);
21054        let arena = crate::arena::Arena::from_plan(plan);
21055        let sched = compile_thunks(&g, &arena);
21056        assert!(
21057            sched.thunks.iter().any(|t| matches!(
21058                t,
21059                Thunk::FusedAttnBlock {
21060                    mask_kind: rlx_ir::op::MaskKind::Causal,
21061                    ..
21062                }
21063            )),
21064            "expected a FusedAttnBlock carrying MaskKind::Causal"
21065        );
21066
21067        let wqkv_d: Vec<f32> = (0..d * 3 * d)
21068            .map(|i| ((i % 7) as f32 - 3.0) * 0.05)
21069            .collect();
21070        let wo_d: Vec<f32> = (0..d * d).map(|i| ((i % 5) as f32 - 2.0) * 0.05).collect();
21071        let mut cos_d = vec![0f32; s * half];
21072        let mut sin_d = vec![0f32; s * half];
21073        for p in 0..s {
21074            for i in 0..half {
21075                let fr = 1.0f32 / 10000f32.powf(2.0 * i as f32 / dh as f32);
21076                cos_d[p * half + i] = (p as f32 * fr).cos();
21077                sin_d[p * half + i] = (p as f32 * fr).sin();
21078            }
21079        }
21080        let base_h: Vec<f32> = (0..s * d).map(|i| ((i % 11) as f32 - 5.0) * 0.1).collect();
21081        let run = |hin: &[f32]| {
21082            run_graph(
21083                &g,
21084                &[
21085                    (hidden, hin),
21086                    (wqkv, &wqkv_d),
21087                    (wo, &wo_d),
21088                    (cos, &cos_d),
21089                    (sin, &sin_d),
21090                ],
21091                out,
21092                s * d,
21093            )
21094        };
21095        let a = run(&base_h);
21096        // Perturb only the LAST position's hidden row.
21097        let mut changed = base_h.clone();
21098        for j in 0..d {
21099            changed[4 * d + j] += 1.0;
21100        }
21101        let b = run(&changed);
21102        for pos in 0..4 {
21103            for j in 0..d {
21104                let i = pos * d + j;
21105                assert!(
21106                    (a[i] - b[i]).abs() < 1e-5,
21107                    "causal leak at pos {pos}: {} vs {}",
21108                    a[i],
21109                    b[i]
21110                );
21111            }
21112        }
21113        let last: f32 = (0..d).map(|j| (a[4 * d + j] - b[4 * d + j]).abs()).sum();
21114        assert!(last > 1e-4, "last position must react to its own token");
21115    }
21116
21117    // ── Backward / training op parity tests ────────────────────
21118    //
21119    // Strategy: build a graph that contains exactly the backward op
21120    // under test (plus its inputs as graph Inputs), execute, and
21121    // compare against a hand-rolled scalar reference. For
21122    // Conv2dBackwardInput we additionally check against the numerical
21123    // gradient of the forward Conv2D — that's the gold-standard test
21124    // that validates the math, not just consistency between two
21125    // implementations of the same formula.
21126
21127    fn run_graph(
21128        g: &Graph,
21129        inputs: &[(NodeId, &[f32])],
21130        out_id: NodeId,
21131        out_len: usize,
21132    ) -> Vec<f32> {
21133        let plan = rlx_opt::memory::plan_memory(g);
21134        let mut arena = crate::arena::Arena::from_plan(plan);
21135        let sched = compile_thunks(g, &arena);
21136        for &(id, data) in inputs {
21137            let off = arena.byte_offset(id);
21138            let buf = arena.raw_buf_mut();
21139            unsafe {
21140                let p = buf.as_mut_ptr().add(off) as *mut f32;
21141                for (i, &v) in data.iter().enumerate() {
21142                    *p.add(i) = v;
21143                }
21144            }
21145        }
21146        execute_thunks(&sched, arena.raw_buf_mut());
21147        let off = arena.byte_offset(out_id);
21148        unsafe {
21149            let p = arena.raw_buf().as_ptr().add(off) as *const f32;
21150            (0..out_len).map(|i| *p.add(i)).collect()
21151        }
21152    }
21153
21154    #[test]
21155    fn relu_backward_matches_mask() {
21156        let f = DType::F32;
21157        let len = 7usize;
21158        let x: Vec<f32> = vec![-2.0, -0.1, 0.0, 0.1, 1.0, 3.0, -5.0];
21159        let dy: Vec<f32> = vec![0.5, 1.5, 2.5, -0.7, 4.0, -1.0, 9.0];
21160
21161        let mut g = Graph::new("relu_bw");
21162        let xn = g.input("x", Shape::new(&[len], f));
21163        let dyn_ = g.input("dy", Shape::new(&[len], f));
21164        let dx = g.relu_backward(xn, dyn_);
21165        g.set_outputs(vec![dx]);
21166
21167        let actual = run_graph(&g, &[(xn, &x), (dyn_, &dy)], dx, len);
21168        // Reference: gradient is dy where x>0 strictly, else 0.
21169        // (zero is not "positive" — the forward applied max(0, x), and at
21170        // x=0 the subgradient could be anything in [0, dy]; we pick 0.)
21171        let expected: Vec<f32> = x
21172            .iter()
21173            .zip(&dy)
21174            .map(|(&xi, &dyi)| if xi > 0.0 { dyi } else { 0.0 })
21175            .collect();
21176        for (a, e) in actual.iter().zip(&expected) {
21177            assert!((a - e).abs() < 1e-6, "relu_bw mismatch: {a} vs {e}");
21178        }
21179    }
21180
21181    #[test]
21182    fn maxpool2d_backward_routes_to_argmax() {
21183        let f = DType::F32;
21184        // [N=1, C=1, H=4, W=4] → 2x2 max-pool stride 2 → [1,1,2,2].
21185        let x: Vec<f32> = vec![
21186            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
21187        ];
21188        // Argmax of each 2x2 window:
21189        //   (0,0)→6 (idx 5), (0,1)→8 (idx 7),
21190        //   (1,0)→14(idx 13),(1,1)→16(idx 15).
21191        let dy: Vec<f32> = vec![0.5, 1.0, 2.0, 4.0];
21192
21193        let mut g = Graph::new("maxpool_bw");
21194        let xn = g.input("x", Shape::new(&[1, 1, 4, 4], f));
21195        let dyn_ = g.input("dy", Shape::new(&[1, 1, 2, 2], f));
21196        let dx = g.maxpool2d_backward(xn, dyn_, vec![2, 2], vec![2, 2], vec![0, 0]);
21197        g.set_outputs(vec![dx]);
21198
21199        let actual = run_graph(&g, &[(xn, &x), (dyn_, &dy)], dx, 16);
21200        let mut expected = vec![0f32; 16];
21201        expected[5] = 0.5;
21202        expected[7] = 1.0;
21203        expected[13] = 2.0;
21204        expected[15] = 4.0;
21205        for (i, (a, e)) in actual.iter().zip(&expected).enumerate() {
21206            assert!((a - e).abs() < 1e-6, "maxpool_bw[{i}] mismatch: {a} vs {e}");
21207        }
21208    }
21209
21210    #[test]
21211    fn conv2d_backward_input_matches_numerical_gradient() {
21212        use rlx_ir::Philox4x32;
21213        // Small enough to numerically differentiate exhaustively but
21214        // big enough to exercise stride/padding edge cases.
21215        let n = 1usize;
21216        let c_in = 2usize;
21217        let h = 4usize;
21218        let w = 4usize;
21219        let c_out = 3usize;
21220        let kh = 3usize;
21221        let kw = 3usize;
21222        let ph = 1usize;
21223        let pw = 1usize;
21224        let sh = 1usize;
21225        let sw = 1usize;
21226        // Output dims with padding=1, stride=1: same as input.
21227        let h_out = (h + 2 * ph - kh) / sh + 1;
21228        let w_out = (w + 2 * pw - kw) / sw + 1;
21229        assert_eq!(h_out, 4);
21230        assert_eq!(w_out, 4);
21231
21232        let mut rng = Philox4x32::new(7);
21233        let mut x = vec![0f32; n * c_in * h * w];
21234        rng.fill_normal(&mut x);
21235        let mut wt = vec![0f32; c_out * c_in * kh * kw];
21236        rng.fill_normal(&mut wt);
21237        let mut dy = vec![0f32; n * c_out * h_out * w_out];
21238        rng.fill_normal(&mut dy);
21239
21240        // Analytical: Conv2dBackwardInput on (dy, w).
21241        let f = DType::F32;
21242        let mut g = Graph::new("conv_bwi");
21243        let dy_in = g.input("dy", Shape::new(&[n, c_out, h_out, w_out], f));
21244        let w_in = g.input("w", Shape::new(&[c_out, c_in, kh, kw], f));
21245        let dx = g.conv2d_backward_input(
21246            dy_in,
21247            w_in,
21248            Shape::new(&[n, c_in, h, w], f),
21249            vec![kh, kw],
21250            vec![sh, sw],
21251            vec![ph, pw],
21252            vec![1, 1],
21253            1,
21254        );
21255        g.set_outputs(vec![dx]);
21256        let analytical = run_graph(&g, &[(dy_in, &dy), (w_in, &wt)], dx, n * c_in * h * w);
21257
21258        // Numerical: for each x[i], finite-difference forward conv twice.
21259        // Forward: y[j] = sum over filter window of w * x ; dot(dy, y) is
21260        // the scalar we differentiate. Then dx[i] = ∂(dot(dy, y))/∂x[i].
21261        let forward = |x: &[f32]| -> Vec<f32> {
21262            let mut out = vec![0f32; n * c_out * h_out * w_out];
21263            for ni in 0..n {
21264                for co in 0..c_out {
21265                    for ho in 0..h_out {
21266                        for wo in 0..w_out {
21267                            let mut acc = 0f32;
21268                            for ci in 0..c_in {
21269                                for ki in 0..kh {
21270                                    for kj in 0..kw {
21271                                        let hi = ho * sh + ki;
21272                                        let wi = wo * sw + kj;
21273                                        if hi < ph || wi < pw {
21274                                            continue;
21275                                        }
21276                                        let hi = hi - ph;
21277                                        let wi = wi - pw;
21278                                        if hi >= h || wi >= w {
21279                                            continue;
21280                                        }
21281                                        let xv = x[((ni * c_in) + ci) * h * w + hi * w + wi];
21282                                        let wv = wt[((co * c_in) + ci) * kh * kw + ki * kw + kj];
21283                                        acc += xv * wv;
21284                                    }
21285                                }
21286                            }
21287                            out[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = acc;
21288                        }
21289                    }
21290                }
21291            }
21292            out
21293        };
21294        let dot = |a: &[f32], b: &[f32]| -> f32 { a.iter().zip(b).map(|(&u, &v)| u * v).sum() };
21295        let eps = 1e-3f32;
21296        let mut numerical = vec![0f32; x.len()];
21297        for i in 0..x.len() {
21298            let saved = x[i];
21299            x[i] = saved + eps;
21300            let plus = dot(&forward(&x), &dy);
21301            x[i] = saved - eps;
21302            let minus = dot(&forward(&x), &dy);
21303            x[i] = saved;
21304            numerical[i] = (plus - minus) / (2.0 * eps);
21305        }
21306        for (i, (a, n)) in analytical.iter().zip(&numerical).enumerate() {
21307            // f32 + eps=1e-3 numerical grad → ~1e-3 absolute is realistic.
21308            assert!(
21309                (a - n).abs() < 5e-3,
21310                "conv_bw_input[{i}]: analytical {a} vs numerical {n}"
21311            );
21312        }
21313    }
21314
21315    #[test]
21316    fn conv2d_backward_weight_matches_numerical_gradient() {
21317        use rlx_ir::Philox4x32;
21318        let n = 2usize;
21319        let c_in = 2usize;
21320        let h = 4usize;
21321        let w = 4usize;
21322        let c_out = 2usize;
21323        let kh = 3usize;
21324        let kw = 3usize;
21325        let ph = 0usize;
21326        let pw = 0usize;
21327        let sh = 1usize;
21328        let sw = 1usize;
21329        let h_out = (h + 2 * ph - kh) / sh + 1;
21330        let w_out = (w + 2 * pw - kw) / sw + 1;
21331
21332        let mut rng = Philox4x32::new(11);
21333        let mut x = vec![0f32; n * c_in * h * w];
21334        rng.fill_normal(&mut x);
21335        let mut wt = vec![0f32; c_out * c_in * kh * kw];
21336        rng.fill_normal(&mut wt);
21337        let mut dy = vec![0f32; n * c_out * h_out * w_out];
21338        rng.fill_normal(&mut dy);
21339
21340        let f = DType::F32;
21341        let mut g = Graph::new("conv_bww");
21342        let xn = g.input("x", Shape::new(&[n, c_in, h, w], f));
21343        let dyn_ = g.input("dy", Shape::new(&[n, c_out, h_out, w_out], f));
21344        let dwn = g.conv2d_backward_weight(
21345            xn,
21346            dyn_,
21347            Shape::new(&[c_out, c_in, kh, kw], f),
21348            vec![kh, kw],
21349            vec![sh, sw],
21350            vec![ph, pw],
21351            vec![1, 1],
21352            1,
21353        );
21354        g.set_outputs(vec![dwn]);
21355        let analytical = run_graph(&g, &[(xn, &x), (dyn_, &dy)], dwn, c_out * c_in * kh * kw);
21356
21357        let forward = |wt: &[f32]| -> Vec<f32> {
21358            let mut out = vec![0f32; n * c_out * h_out * w_out];
21359            for ni in 0..n {
21360                for co in 0..c_out {
21361                    for ho in 0..h_out {
21362                        for wo in 0..w_out {
21363                            let mut acc = 0f32;
21364                            for ci in 0..c_in {
21365                                for ki in 0..kh {
21366                                    for kj in 0..kw {
21367                                        let hi = ho + ki;
21368                                        let wi = wo + kj;
21369                                        let xv = x[((ni * c_in) + ci) * h * w + hi * w + wi];
21370                                        let wv = wt[((co * c_in) + ci) * kh * kw + ki * kw + kj];
21371                                        acc += xv * wv;
21372                                    }
21373                                }
21374                            }
21375                            out[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = acc;
21376                        }
21377                    }
21378                }
21379            }
21380            out
21381        };
21382        let dot = |a: &[f32], b: &[f32]| -> f32 { a.iter().zip(b).map(|(&u, &v)| u * v).sum() };
21383        let eps = 1e-3f32;
21384        let mut numerical = vec![0f32; wt.len()];
21385        for i in 0..wt.len() {
21386            let saved = wt[i];
21387            wt[i] = saved + eps;
21388            let plus = dot(&forward(&wt), &dy);
21389            wt[i] = saved - eps;
21390            let minus = dot(&forward(&wt), &dy);
21391            wt[i] = saved;
21392            numerical[i] = (plus - minus) / (2.0 * eps);
21393        }
21394        for (i, (a, n)) in analytical.iter().zip(&numerical).enumerate() {
21395            assert!(
21396                (a - n).abs() < 5e-3,
21397                "conv_bw_weight[{i}]: analytical {a} vs numerical {n}"
21398            );
21399        }
21400    }
21401
21402    #[test]
21403    fn softmax_cross_entropy_matches_reference() {
21404        let f = DType::F32;
21405        let logits: Vec<f32> = vec![
21406            1.0, 2.0, 3.0, // row 0: max=3 (idx 2)
21407            -1.0, 0.0, 4.0, // row 1: max=4 (idx 2)
21408            5.0, 5.0, 5.0, // row 2: uniform
21409        ];
21410        let labels: Vec<f32> = vec![2.0, 0.0, 1.0];
21411
21412        let mut g = Graph::new("sce");
21413        let lg = g.input("logits", Shape::new(&[3, 3], f));
21414        let lb = g.input("labels", Shape::new(&[3], f));
21415        let loss = g.softmax_cross_entropy_with_logits(lg, lb);
21416        g.set_outputs(vec![loss]);
21417        let actual = run_graph(&g, &[(lg, &logits), (lb, &labels)], loss, 3);
21418
21419        // Reference per-row: -log(softmax(row)[label]).
21420        let mut expected = vec![0f32; 3];
21421        for ni in 0..3 {
21422            let row = &logits[ni * 3..(ni + 1) * 3];
21423            let m = row.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
21424            let sum: f32 = row.iter().map(|&v| (v - m).exp()).sum();
21425            let lse = m + sum.ln();
21426            let label_idx = labels[ni] as usize;
21427            expected[ni] = lse - row[label_idx];
21428        }
21429        for (i, (a, e)) in actual.iter().zip(&expected).enumerate() {
21430            assert!((a - e).abs() < 1e-5, "sce loss[{i}]: {a} vs {e}");
21431        }
21432    }
21433
21434    #[test]
21435    fn softmax_cross_entropy_backward_matches_numerical_gradient() {
21436        use rlx_ir::Philox4x32;
21437        let n = 4usize;
21438        let c = 5usize;
21439        let mut rng = Philox4x32::new(23);
21440        let mut logits = vec![0f32; n * c];
21441        rng.fill_normal(&mut logits);
21442        let labels: Vec<f32> = (0..n).map(|i| (i % c) as f32).collect();
21443        let mut d_loss = vec![0f32; n];
21444        rng.fill_normal(&mut d_loss);
21445
21446        let f = DType::F32;
21447        let mut g = Graph::new("sce_bw");
21448        let lg = g.input("logits", Shape::new(&[n, c], f));
21449        let lb = g.input("labels", Shape::new(&[n], f));
21450        let dl = g.input("d_loss", Shape::new(&[n], f));
21451        let dlogits = g.softmax_cross_entropy_backward(lg, lb, dl);
21452        g.set_outputs(vec![dlogits]);
21453        let analytical = run_graph(
21454            &g,
21455            &[(lg, &logits), (lb, &labels), (dl, &d_loss)],
21456            dlogits,
21457            n * c,
21458        );
21459
21460        // Numerical: differentiate dot(d_loss, sce_loss(logits)) w.r.t. each logit.
21461        let sce_loss = |logits: &[f32]| -> Vec<f32> {
21462            let mut out = vec![0f32; n];
21463            for ni in 0..n {
21464                let row = &logits[ni * c..(ni + 1) * c];
21465                let m = row.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
21466                let sum: f32 = row.iter().map(|&v| (v - m).exp()).sum();
21467                out[ni] = (m + sum.ln()) - row[labels[ni] as usize];
21468            }
21469            out
21470        };
21471        let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(&u, &v)| u * v).sum::<f32>();
21472        let eps = 1e-3f32;
21473        let mut numerical = vec![0f32; logits.len()];
21474        for i in 0..logits.len() {
21475            let saved = logits[i];
21476            logits[i] = saved + eps;
21477            let plus = dot(&sce_loss(&logits), &d_loss);
21478            logits[i] = saved - eps;
21479            let minus = dot(&sce_loss(&logits), &d_loss);
21480            logits[i] = saved;
21481            numerical[i] = (plus - minus) / (2.0 * eps);
21482        }
21483        for (i, (a, num)) in analytical.iter().zip(&numerical).enumerate() {
21484            assert!(
21485                (a - num).abs() < 5e-3,
21486                "sce_bw[{i}]: analytical {a} vs numerical {num}"
21487            );
21488        }
21489    }
21490
21491    // ── End-to-end autodiff parity tests ──────────────────────
21492    //
21493    // Build a forward graph, run `grad_with_loss` to produce a graph
21494    // that emits [loss, gradients...], execute it through rlx-cpu,
21495    // and compare each gradient to a finite-difference estimate
21496    // produced by re-running the forward graph with each parameter
21497    // entry perturbed. f32 + ε=1e-3 puts the tolerance floor around
21498    // 5e-3 absolute error.
21499
21500    /// Initialize Op::Constant slots in the arena with their literal
21501    /// data. Mirrors the loop in rlx_runtime::backend (which serves
21502    /// the same role for production runs).
21503    fn fill_constants_into_arena(graph: &Graph, arena: &mut crate::arena::Arena) {
21504        for node in graph.nodes() {
21505            if let Op::Constant { data } = &node.op
21506                && arena.has_buffer(node.id)
21507                && !data.is_empty()
21508            {
21509                let buf = arena.slice_mut(node.id);
21510                let n_floats = data.len() / 4;
21511                let n = buf.len().min(n_floats);
21512                for i in 0..n {
21513                    let bytes = [
21514                        data[i * 4],
21515                        data[i * 4 + 1],
21516                        data[i * 4 + 2],
21517                        data[i * 4 + 3],
21518                    ];
21519                    buf[i] = f32::from_le_bytes(bytes);
21520                }
21521            }
21522        }
21523    }
21524
21525    /// Compile + arena-prep helper for these tests. Returns the
21526    /// schedule and a populated arena. `seed_inputs` writes f32 input
21527    /// data into the arena slot for each (NodeId, &[f32]) pair.
21528    fn prepare(
21529        graph: &Graph,
21530        seed_inputs: &[(NodeId, &[f32])],
21531    ) -> (ThunkSchedule, crate::arena::Arena) {
21532        let plan = rlx_opt::memory::plan_memory(graph);
21533        let mut arena = crate::arena::Arena::from_plan(plan);
21534        let sched = compile_thunks(graph, &arena);
21535        fill_constants_into_arena(graph, &mut arena);
21536        for &(id, data) in seed_inputs {
21537            let off = arena.byte_offset(id);
21538            let buf = arena.raw_buf_mut();
21539            unsafe {
21540                let p = buf.as_mut_ptr().add(off) as *mut f32;
21541                for (i, &v) in data.iter().enumerate() {
21542                    *p.add(i) = v;
21543                }
21544            }
21545        }
21546        (sched, arena)
21547    }
21548
21549    fn read_arena(arena: &crate::arena::Arena, id: NodeId, len: usize) -> Vec<f32> {
21550        let off = arena.byte_offset(id);
21551        unsafe {
21552            let p = arena.raw_buf().as_ptr().add(off) as *const f32;
21553            (0..len).map(|i| *p.add(i)).collect()
21554        }
21555    }
21556
21557    fn write_arena(arena: &mut crate::arena::Arena, id: NodeId, data: &[f32]) {
21558        let off = arena.byte_offset(id);
21559        let buf = arena.raw_buf_mut();
21560        unsafe {
21561            let p = buf.as_mut_ptr().add(off) as *mut f32;
21562            for (i, &v) in data.iter().enumerate() {
21563                *p.add(i) = v;
21564            }
21565        }
21566    }
21567
21568    /// f64 sibling of `prepare`. Writes f64 input data into the arena.
21569    fn prepare_f64(
21570        graph: &Graph,
21571        seed_inputs: &[(NodeId, &[f64])],
21572    ) -> (ThunkSchedule, crate::arena::Arena) {
21573        let plan = rlx_opt::memory::plan_memory(graph);
21574        let mut arena = crate::arena::Arena::from_plan(plan);
21575        let sched = compile_thunks(graph, &arena);
21576        fill_constants_into_arena(graph, &mut arena);
21577        for &(id, data) in seed_inputs {
21578            let off = arena.byte_offset(id);
21579            let buf = arena.raw_buf_mut();
21580            unsafe {
21581                let p = buf.as_mut_ptr().add(off) as *mut f64;
21582                for (i, &v) in data.iter().enumerate() {
21583                    *p.add(i) = v;
21584                }
21585            }
21586        }
21587        (sched, arena)
21588    }
21589
21590    fn read_arena_f64(arena: &crate::arena::Arena, id: NodeId, len: usize) -> Vec<f64> {
21591        let off = arena.byte_offset(id);
21592        unsafe {
21593            let p = arena.raw_buf().as_ptr().add(off) as *const f64;
21594            (0..len).map(|i| *p.add(i)).collect()
21595        }
21596    }
21597
21598    /// End-to-end f64 DenseSolve through the full compile + execute
21599    /// path. Validates: IR shape inference, memory planner f64 sizing,
21600    /// arena f64 accessors, Thunk::DenseSolveF64 lowering, executor
21601    /// dispatch, Accelerate dgesv FFI.
21602    ///
21603    /// System:
21604    ///   A = [[2, 1],
21605    ///        [1, 3]]   b = [5, 10]
21606    ///   ⇒  x = [1, 3]   (verified by hand)
21607    #[test]
21608    fn dense_solve_f64_end_to_end() {
21609        let mut g = Graph::new("solve_e2e");
21610        let a = g.input("A", Shape::new(&[2, 2], DType::F64));
21611        let b = g.input("b", Shape::new(&[2], DType::F64));
21612        let x = g.dense_solve(a, b, Shape::new(&[2], DType::F64));
21613        g.set_outputs(vec![x]);
21614
21615        let a_data = [2.0, 1.0, 1.0, 3.0_f64];
21616        let b_data = [5.0, 10.0_f64];
21617        let (sched, mut arena) = prepare_f64(&g, &[(a, &a_data), (b, &b_data)]);
21618        execute_thunks(&sched, arena.raw_buf_mut());
21619
21620        let got = read_arena_f64(&arena, x, 2);
21621        let want = [1.0, 3.0_f64];
21622        for i in 0..2 {
21623            assert!(
21624                (got[i] - want[i]).abs() < 1e-12,
21625                "x[{i}] = {} (expected {})",
21626                got[i],
21627                want[i]
21628            );
21629        }
21630    }
21631
21632    /// Scaled-up f64 DenseSolve — tridiagonal Laplacian-shape (typical
21633    /// MNA structure for a passive RC mesh in Circulax). Validates
21634    /// that the solve scales beyond the trivial 2×2 and that the
21635    /// row-major ↔ col-major dance in `dgesv` is correct for the
21636    /// general case.
21637    #[test]
21638    fn dense_solve_f64_5x5_laplacian() {
21639        let n = 5usize;
21640        let mut g = Graph::new("solve_5x5");
21641        let a = g.input("A", Shape::new(&[n, n], DType::F64));
21642        let b = g.input("b", Shape::new(&[n], DType::F64));
21643        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
21644        g.set_outputs(vec![x]);
21645
21646        // 1-D Laplacian: 2 on diagonal, -1 on off-diagonals, 0 elsewhere.
21647        let mut a_data = vec![0.0_f64; n * n];
21648        for i in 0..n {
21649            a_data[i * n + i] = 2.0;
21650            if i > 0 {
21651                a_data[i * n + (i - 1)] = -1.0;
21652            }
21653            if i + 1 < n {
21654                a_data[i * n + (i + 1)] = -1.0;
21655            }
21656        }
21657        let b_data: Vec<f64> = (0..n).map(|i| (i + 1) as f64).collect();
21658        let (sched, mut arena) = prepare_f64(&g, &[(a, &a_data), (b, &b_data)]);
21659        execute_thunks(&sched, arena.raw_buf_mut());
21660
21661        let got = read_arena_f64(&arena, x, n);
21662        // Verify A·x ≈ b by computing the residual.
21663        let mut residual = vec![0.0_f64; n];
21664        for i in 0..n {
21665            for j in 0..n {
21666                residual[i] += a_data[i * n + j] * got[j];
21667            }
21668        }
21669        for i in 0..n {
21670            assert!(
21671                (residual[i] - b_data[i]).abs() < 1e-10,
21672                "row {i}: residual {} vs b {}",
21673                residual[i],
21674                b_data[i]
21675            );
21676        }
21677    }
21678
21679    /// Hello Resistor: end-to-end f64 gradient through a dense solve.
21680    ///
21681    /// Forward:
21682    ///   A      : Param  [N, N]   f64
21683    ///   b      : Input  [N]      f64
21684    ///   x      = solve(A, b)            (DenseSolve)
21685    ///   loss   = sum(x)                 (Reduce::Sum)
21686    ///
21687    /// Backward (via grad_with_loss):
21688    ///   ones [N] = expand(d_output, [N])      (Reduce::Sum VJP)
21689    ///   dx_int   = solve(Aᵀ, ones)             (DenseSolve VJP step 1)
21690    ///   dA       = -outer(dx_int, x)           (DenseSolve VJP step 2)
21691    ///   db       = dx_int                       (DenseSolve VJP step 3)
21692    ///
21693    /// Closed form: with loss = sum(solve(A, b)) = ones·x and
21694    /// implicit-function calculus, db = (Aᵀ)⁻¹·ones, dA = -db ⊗ x.
21695    /// We verify this against the autodiff-emitted graph's output and
21696    /// against a finite-difference baseline.
21697    #[test]
21698    fn hello_resistor_gradient_end_to_end() {
21699        use rlx_opt::autodiff::grad_with_loss;
21700        let n = 3usize;
21701
21702        // ── Build forward graph ──
21703        let mut g = Graph::new("hello_resistor");
21704        let a = g.param("A", Shape::new(&[n, n], DType::F64));
21705        let b = g.input("b", Shape::new(&[n], DType::F64));
21706        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
21707        let loss = g.reduce(
21708            x,
21709            ReduceOp::Sum,
21710            vec![0],
21711            false,
21712            Shape::new(&[1], DType::F64),
21713        );
21714        g.set_outputs(vec![loss]);
21715
21716        // ── Run reverse-mode AD ──
21717        let bwd = grad_with_loss(&g, &[a, b]);
21718        assert_eq!(bwd.outputs.len(), 3, "expect [loss, dA, db]");
21719
21720        // ── Locate the inputs the bwd graph still needs from us ──
21721        // grad_with_loss copies forward nodes into bwd, so A/b/d_output
21722        // appear under their original names. Find them by name.
21723        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
21724            for node in graph.nodes() {
21725                let name = match &node.op {
21726                    rlx_ir::Op::Input { name } => Some(name.as_str()),
21727                    rlx_ir::Op::Param { name } => Some(name.as_str()),
21728                    _ => None,
21729                };
21730                if name == Some(want) {
21731                    return node.id;
21732                }
21733            }
21734            panic!("no node named {want:?} in bwd graph");
21735        };
21736        let a_bwd = find_by_name(&bwd, "A");
21737        let b_bwd = find_by_name(&bwd, "b");
21738        let d_out_bwd = find_by_name(&bwd, "d_output");
21739
21740        // ── Test data ──
21741        // A = [[2,1,0],[1,3,1],[0,1,2]]   (SPD tridiagonal, well-conditioned)
21742        // b = [1,2,3]
21743        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
21744        let b_data = [1.0, 2.0, 3.0_f64];
21745        let d_output = [1.0_f64]; // ∂loss/∂loss
21746
21747        // ── Compile + execute backward graph ──
21748        let (sched, mut arena) = prepare_f64(
21749            &bwd,
21750            &[(a_bwd, &a_data), (b_bwd, &b_data), (d_out_bwd, &d_output)],
21751        );
21752        execute_thunks(&sched, arena.raw_buf_mut());
21753
21754        let loss_out = read_arena_f64(&arena, bwd.outputs[0], 1);
21755        let da_out = read_arena_f64(&arena, bwd.outputs[1], n * n);
21756        let db_out = read_arena_f64(&arena, bwd.outputs[2], n);
21757
21758        // ── Closed-form reference ──
21759        // x = A⁻¹ b ; loss = sum(x).
21760        let x_ref = {
21761            let mut a = a_data;
21762            let mut b = b_data;
21763            let info = crate::blas::dgesv(&mut a, &mut b, n, 1);
21764            assert_eq!(info, 0);
21765            b
21766        };
21767        let loss_ref: f64 = x_ref.iter().sum();
21768        // db = (Aᵀ)⁻¹ · 1
21769        let db_ref = {
21770            let mut at = [0.0_f64; 9];
21771            for i in 0..n {
21772                for j in 0..n {
21773                    at[i * n + j] = a_data[j * n + i];
21774                }
21775            }
21776            let mut ones = [1.0_f64; 3];
21777            let info = crate::blas::dgesv(&mut at, &mut ones, n, 1);
21778            assert_eq!(info, 0);
21779            ones
21780        };
21781        // dA = -outer(db, x) ; dA[i,j] = -db[i] * x[j]
21782        let mut da_ref = [0.0_f64; 9];
21783        for i in 0..n {
21784            for j in 0..n {
21785                da_ref[i * n + j] = -db_ref[i] * x_ref[j];
21786            }
21787        }
21788
21789        // ── Assertions vs analytic answer ──
21790        assert!(
21791            (loss_out[0] - loss_ref).abs() < 1e-10,
21792            "loss: got {}, want {}",
21793            loss_out[0],
21794            loss_ref
21795        );
21796        for i in 0..n {
21797            assert!(
21798                (db_out[i] - db_ref[i]).abs() < 1e-10,
21799                "db[{i}]: got {}, want {}",
21800                db_out[i],
21801                db_ref[i]
21802            );
21803        }
21804        for i in 0..n * n {
21805            assert!(
21806                (da_out[i] - da_ref[i]).abs() < 1e-10,
21807                "dA[{i}]: got {}, want {}",
21808                da_out[i],
21809                da_ref[i]
21810            );
21811        }
21812
21813        // ── Cross-check vs finite differences on db (a few entries) ──
21814        // ∂loss/∂b[k] ≈ (loss(b + h·e_k) - loss(b - h·e_k)) / (2h).
21815        let h = 1e-6_f64;
21816        for k in 0..n {
21817            let mut bp = b_data;
21818            bp[k] += h;
21819            let mut bm = b_data;
21820            bm[k] -= h;
21821            let lp = {
21822                let mut ac = a_data;
21823                let info = crate::blas::dgesv(&mut ac, &mut bp, n, 1);
21824                assert_eq!(info, 0);
21825                bp.iter().sum::<f64>()
21826            };
21827            let lm = {
21828                let mut ac = a_data;
21829                let info = crate::blas::dgesv(&mut ac, &mut bm, n, 1);
21830                assert_eq!(info, 0);
21831                bm.iter().sum::<f64>()
21832            };
21833            let fd = (lp - lm) / (2.0 * h);
21834            assert!(
21835                (db_out[k] - fd).abs() < 1e-7,
21836                "FD mismatch on db[{k}]: AD={} FD={}",
21837                db_out[k],
21838                fd
21839            );
21840        }
21841    }
21842
21843    /// Smallest possible Op::Scan basic test: geometric growth.
21844    /// init = [1, 1, 1] f64, body = (x → x + 0.1·x) = (x → 1.1·x),
21845    /// length = 10. Final carry must equal init·(1.1)^10 ≈ 2.5937…
21846    /// to f64 precision.
21847    #[test]
21848    fn scan_geometric_growth_f64() {
21849        let n = 3usize;
21850        let length = 10u32;
21851
21852        // Body: (x) → x + 0.1·x. One Input, one output, same shape/dtype.
21853        let mut body = Graph::new("scan_body");
21854        let x = body.input("carry", Shape::new(&[n], DType::F64));
21855        let scale_bytes: Vec<u8> = (0..n).flat_map(|_| 0.1_f64.to_le_bytes()).collect();
21856        let scale = body.add_node(
21857            Op::Constant { data: scale_bytes },
21858            vec![],
21859            Shape::new(&[n], DType::F64),
21860        );
21861        let scaled = body.binary(BinaryOp::Mul, x, scale, Shape::new(&[n], DType::F64));
21862        let next = body.binary(BinaryOp::Add, x, scaled, Shape::new(&[n], DType::F64));
21863        body.set_outputs(vec![next]);
21864
21865        // Outer graph: scan(init, body, length).
21866        let mut g = Graph::new("scan_outer");
21867        let init = g.input("init", Shape::new(&[n], DType::F64));
21868        let final_carry = g.scan(init, body, length);
21869        g.set_outputs(vec![final_carry]);
21870
21871        let init_data = vec![1.0_f64; n];
21872        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data)]);
21873        execute_thunks(&sched, arena.raw_buf_mut());
21874        let got = read_arena_f64(&arena, final_carry, n);
21875        let want: f64 = 1.1_f64.powi(length as i32);
21876        for i in 0..n {
21877            assert!(
21878                (got[i] - want).abs() < 1e-12,
21879                "got[{i}] = {} want {}",
21880                got[i],
21881                want
21882            );
21883        }
21884    }
21885
21886    /// Per-step xs scan: cumulative-sum.
21887    ///   carry_0 = init
21888    ///   carry_{t+1} = carry_t + xs\[t\]
21889    ///   final = sum_{t<length} xs\[t\] + init
21890    /// Body has 2 inputs (carry, x_t) in that NodeId order; one output
21891    /// (next carry). Validates the per-step-input plumbing end-to-end.
21892    #[test]
21893    fn scan_with_xs_cumulative_sum() {
21894        let n = 3usize;
21895        let length = 4u32;
21896
21897        let mut body = Graph::new("cumsum_body");
21898        // carry must come first in NodeId order — declare it first.
21899        let carry = body.input("carry", Shape::new(&[n], DType::F64));
21900        let x_t = body.input("x_t", Shape::new(&[n], DType::F64));
21901        let next = body.binary(BinaryOp::Add, carry, x_t, Shape::new(&[n], DType::F64));
21902        body.set_outputs(vec![next]);
21903
21904        let mut g = Graph::new("cumsum_outer");
21905        let init = g.input("init", Shape::new(&[n], DType::F64));
21906        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
21907        let final_carry = g.scan_with_xs(init, &[xs], body, length);
21908        g.set_outputs(vec![final_carry]);
21909
21910        let init_data = vec![0.0_f64; n];
21911        let xs_data: Vec<f64> = (0..length as usize * n).map(|i| (i + 1) as f64).collect(); // 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
21912        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data), (xs, &xs_data)]);
21913        execute_thunks(&sched, arena.raw_buf_mut());
21914        let got = read_arena_f64(&arena, final_carry, n);
21915
21916        // Reference: column-wise sum of xs rows + init. With our row-major
21917        // layout, column j of xs is xs_data[j], xs_data[n+j], xs_data[2n+j], ...
21918        // (per-step row at offset t*n contributes element j to slot j).
21919        let mut want = init_data.clone();
21920        for t in 0..length as usize {
21921            for j in 0..n {
21922                want[j] += xs_data[t * n + j];
21923            }
21924        }
21925        for i in 0..n {
21926            assert!(
21927                (got[i] - want[i]).abs() < 1e-12,
21928                "got[{i}] = {} want {}",
21929                got[i],
21930                want[i]
21931            );
21932        }
21933    }
21934
21935    /// Per-step xs scan composing with DenseSolve — Circulax-shaped:
21936    ///   carry_{t+1} = solve(M, carry_t + xs\[t\])
21937    /// Models a Backward-Euler step driven by a time-varying source.
21938    #[test]
21939    fn scan_with_xs_be_with_drive() {
21940        let n = 3usize;
21941        let length = 4u32;
21942        let dt = 0.1_f64;
21943
21944        let mut m_data = vec![0.0_f64; n * n];
21945        for i in 0..n {
21946            m_data[i * n + i] = 1.0 + dt * 2.0;
21947            if i > 0 {
21948                m_data[i * n + (i - 1)] = -dt;
21949            }
21950            if i + 1 < n {
21951                m_data[i * n + (i + 1)] = -dt;
21952            }
21953        }
21954        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
21955
21956        let mut body = Graph::new("be_drive_body");
21957        let carry = body.input("carry", Shape::new(&[n], DType::F64));
21958        let drive = body.input("drive", Shape::new(&[n], DType::F64));
21959        let m = body.add_node(
21960            Op::Constant { data: m_bytes },
21961            vec![],
21962            Shape::new(&[n, n], DType::F64),
21963        );
21964        let driven = body.binary(BinaryOp::Add, carry, drive, Shape::new(&[n], DType::F64));
21965        let next = body.dense_solve(m, driven, Shape::new(&[n], DType::F64));
21966        body.set_outputs(vec![next]);
21967
21968        let mut g = Graph::new("be_drive_outer");
21969        let init = g.input("init", Shape::new(&[n], DType::F64));
21970        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
21971        let final_carry = g.scan_with_xs(init, &[xs], body, length);
21972        g.set_outputs(vec![final_carry]);
21973
21974        let init_data = vec![0.0_f64; n];
21975        // Drive the system with a unit pulse on element 0 at t=0,
21976        // zeros after.
21977        let mut xs_data = vec![0.0_f64; length as usize * n];
21978        xs_data[0] = 1.0;
21979
21980        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data), (xs, &xs_data)]);
21981        execute_thunks(&sched, arena.raw_buf_mut());
21982        let got = read_arena_f64(&arena, final_carry, n);
21983
21984        // Reference: per-step in pure Rust.
21985        let mut x = init_data.clone();
21986        for t in 0..length as usize {
21987            for j in 0..n {
21988                x[j] += xs_data[t * n + j];
21989            }
21990            let mut a_copy = m_data.clone();
21991            crate::blas::dgesv(&mut a_copy, &mut x, n, 1);
21992        }
21993        for i in 0..n {
21994            assert!(
21995                (got[i] - x[i]).abs() < 1e-12,
21996                "got[{i}] = {} ref {}",
21997                got[i],
21998                x[i]
21999            );
22000        }
22001    }
22002
22003    /// Reverse-mode AD through Op::BatchedDenseSolve. Forward solves
22004    /// `[B, N, N] · x = [B, N]`; loss = sum of all entries. Closed
22005    /// form: dB = (Aᵀ)⁻¹·1, dA = -(Aᵀ)⁻¹·1 ⊗ x. Verified analytically
22006    /// per batch (each slice matches what the unbatched DenseSolve VJP
22007    /// would compute).
22008    #[test]
22009    fn batched_dense_solve_gradient_matches_per_batch_analytic() {
22010        use rlx_opt::autodiff::grad_with_loss;
22011        let n = 3usize;
22012        let batch = 4usize;
22013
22014        let mut g = Graph::new("bds_grad");
22015        let a = g.param("A", Shape::new(&[batch, n, n], DType::F64));
22016        let b = g.input("b", Shape::new(&[batch, n], DType::F64));
22017        let x = g.batched_dense_solve(a, b, Shape::new(&[batch, n], DType::F64));
22018        let loss = g.reduce(
22019            x,
22020            ReduceOp::Sum,
22021            vec![0, 1],
22022            false,
22023            Shape::new(&[1], DType::F64),
22024        );
22025        g.set_outputs(vec![loss]);
22026
22027        let bwd = grad_with_loss(&g, &[a, b]);
22028
22029        let find = |graph: &Graph, want: &str| -> NodeId {
22030            for node in graph.nodes() {
22031                let name = match &node.op {
22032                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
22033                    _ => None,
22034                };
22035                if name == Some(want) {
22036                    return node.id;
22037                }
22038            }
22039            panic!("no node named {want}");
22040        };
22041        let a_id = find(&bwd, "A");
22042        let b_id = find(&bwd, "b");
22043        let d_out_id = find(&bwd, "d_output");
22044
22045        let mut rng = rlx_ir::Philox4x32::new(0x57e1_u64);
22046        let mut a_data = vec![0.0_f64; batch * n * n];
22047        let mut b_data = vec![0.0_f64; batch * n];
22048        for bi in 0..batch {
22049            for i in 0..n {
22050                for j in 0..n {
22051                    a_data[bi * n * n + i * n + j] = rng.next_f32() as f64 * 0.1;
22052                }
22053                a_data[bi * n * n + i * n + i] += 1.0 + n as f64;
22054            }
22055            for i in 0..n {
22056                b_data[bi * n + i] = rng.next_f32() as f64;
22057            }
22058        }
22059        let d_seed = [1.0_f64];
22060
22061        let (sched, mut arena) = prepare_f64(
22062            &bwd,
22063            &[(a_id, &a_data), (b_id, &b_data), (d_out_id, &d_seed)],
22064        );
22065        execute_thunks(&sched, arena.raw_buf_mut());
22066        let da_out = read_arena_f64(&arena, bwd.outputs[1], batch * n * n);
22067        let db_out = read_arena_f64(&arena, bwd.outputs[2], batch * n);
22068
22069        // Reference: per-batch analytic solve. dB_i = (A_iᵀ)⁻¹ · 1,
22070        // dA_i = -dB_i ⊗ x_i.
22071        for bi in 0..batch {
22072            let a_slice: Vec<f64> = a_data[bi * n * n..(bi + 1) * n * n].to_vec();
22073            let mut b_slice: Vec<f64> = b_data[bi * n..(bi + 1) * n].to_vec();
22074            let mut a_copy = a_slice.clone();
22075            crate::blas::dgesv(&mut a_copy, &mut b_slice, n, 1);
22076            let x_ref = b_slice.clone();
22077            // dB: solve(A^T, ones)
22078            let mut at = vec![0.0_f64; n * n];
22079            for i in 0..n {
22080                for j in 0..n {
22081                    at[i * n + j] = a_slice[j * n + i];
22082                }
22083            }
22084            let mut ones = vec![1.0_f64; n];
22085            crate::blas::dgesv(&mut at, &mut ones, n, 1);
22086            let db_ref = ones;
22087            for i in 0..n {
22088                let got = db_out[bi * n + i];
22089                assert!(
22090                    (got - db_ref[i]).abs() < 1e-10,
22091                    "batch {bi}, db[{i}]: got {got} ref {}",
22092                    db_ref[i]
22093                );
22094            }
22095            // dA: -outer(db, x)
22096            for i in 0..n {
22097                for j in 0..n {
22098                    let got = da_out[bi * n * n + i * n + j];
22099                    let want = -db_ref[i] * x_ref[j];
22100                    assert!(
22101                        (got - want).abs() < 1e-10,
22102                        "batch {bi}, dA[{i},{j}]: got {got} ref {want}"
22103                    );
22104                }
22105            }
22106        }
22107    }
22108
22109    /// AD knob: gradient through `scan_checkpointed` automatically
22110    /// uses the recompute backward path. Compares dinit from a plain
22111    /// scan against the same forward written with `scan_checkpointed`,
22112    /// both run through `grad_with_loss`. They must match to f64.
22113    #[test]
22114    fn scan_checkpointed_grad_matches_plain_scan_grad() {
22115        use rlx_opt::autodiff::grad_with_loss;
22116        let n = 2usize;
22117        let length = 6u32;
22118
22119        let make_body = || {
22120            let mut body = Graph::new("ck_body");
22121            let carry = body.input("carry", Shape::new(&[n], DType::F64));
22122            let scale_bytes: Vec<u8> = (0..n).flat_map(|_| 1.05_f64.to_le_bytes()).collect();
22123            let scale = body.add_node(
22124                Op::Constant { data: scale_bytes },
22125                vec![],
22126                Shape::new(&[n], DType::F64),
22127            );
22128            let next = body.binary(BinaryOp::Mul, carry, scale, Shape::new(&[n], DType::F64));
22129            body.set_outputs(vec![next]);
22130            body
22131        };
22132
22133        // Plain scan path.
22134        let mut g_plain = Graph::new("ck_plain");
22135        let init_p = g_plain.input("init", Shape::new(&[n], DType::F64));
22136        let final_p = g_plain.scan(init_p, make_body(), length);
22137        let loss_p = g_plain.reduce(
22138            final_p,
22139            ReduceOp::Sum,
22140            vec![0],
22141            false,
22142            Shape::new(&[1], DType::F64),
22143        );
22144        g_plain.set_outputs(vec![loss_p]);
22145        let bwd_p = grad_with_loss(&g_plain, &[init_p]);
22146
22147        // Checkpointed scan path with K=2 (length=6).
22148        let mut g_ck = Graph::new("ck_ckpt");
22149        let init_c = g_ck.input("init", Shape::new(&[n], DType::F64));
22150        let final_c = g_ck.scan_checkpointed(init_c, make_body(), length, 2);
22151        let loss_c = g_ck.reduce(
22152            final_c,
22153            ReduceOp::Sum,
22154            vec![0],
22155            false,
22156            Shape::new(&[1], DType::F64),
22157        );
22158        g_ck.set_outputs(vec![loss_c]);
22159        let bwd_c = grad_with_loss(&g_ck, &[init_c]);
22160
22161        let find = |graph: &Graph, want: &str| -> NodeId {
22162            for node in graph.nodes() {
22163                let name = match &node.op {
22164                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
22165                    _ => None,
22166                };
22167                if name == Some(want) {
22168                    return node.id;
22169                }
22170            }
22171            panic!("no {want}");
22172        };
22173
22174        let init_data = vec![0.5_f64, -0.5];
22175        let d_seed = [1.0_f64];
22176
22177        let (s_p, mut a_p) = prepare_f64(
22178            &bwd_p,
22179            &[
22180                (find(&bwd_p, "init"), &init_data),
22181                (find(&bwd_p, "d_output"), &d_seed),
22182            ],
22183        );
22184        execute_thunks(&s_p, a_p.raw_buf_mut());
22185        let dinit_p = read_arena_f64(&a_p, bwd_p.outputs[1], n);
22186
22187        let (s_c, mut a_c) = prepare_f64(
22188            &bwd_c,
22189            &[
22190                (find(&bwd_c, "init"), &init_data),
22191                (find(&bwd_c, "d_output"), &d_seed),
22192            ],
22193        );
22194        execute_thunks(&s_c, a_c.raw_buf_mut());
22195        let dinit_c = read_arena_f64(&a_c, bwd_c.outputs[1], n);
22196
22197        for i in 0..n {
22198            assert!(
22199                (dinit_p[i] - dinit_c[i]).abs() < 1e-12,
22200                "dinit[{i}]: plain={} checkpointed={}",
22201                dinit_p[i],
22202                dinit_c[i]
22203            );
22204        }
22205    }
22206
22207    /// Recursive checkpointing end-to-end: build a ScanBackward
22208    /// configured with K=2 checkpoints (for length=4), and compare
22209    /// dinit against the same backward graph with full trajectory
22210    /// (K=0). Forward computes a cumulative-sum-style scan; loss = sum.
22211    /// Both paths must agree to f64 precision.
22212    #[test]
22213    fn recursive_checkpointing_matches_full_trajectory() {
22214        let n = 2usize;
22215        let length = 4u32;
22216
22217        // Body: carry + ones (deterministic, no xs)
22218        let build_body = || -> Graph {
22219            let mut body = Graph::new("rc_body");
22220            let carry = body.input("carry", Shape::new(&[n], DType::F64));
22221            let ones_bytes: Vec<u8> = (0..n).flat_map(|_| 1.0_f64.to_le_bytes()).collect();
22222            let ones = body.add_node(
22223                Op::Constant { data: ones_bytes },
22224                vec![],
22225                Shape::new(&[n], DType::F64),
22226            );
22227            let next = body.binary(BinaryOp::Add, carry, ones, Shape::new(&[n], DType::F64));
22228            body.set_outputs(vec![next]);
22229            body
22230        };
22231
22232        // body_vjp: same body + d_output, output dcarry. body_vjp is
22233        // used by ScanBackward to walk the chain rule per step.
22234        let body_vjp_for = || -> Graph {
22235            use rlx_opt::autodiff::grad;
22236            let body = build_body();
22237            // grad(body, [carry_id]) → graph with dcarry as the output.
22238            let carry_id = body
22239                .nodes()
22240                .iter()
22241                .find(|n| matches!(n.op, Op::Input { .. }))
22242                .map(|n| n.id)
22243                .unwrap();
22244            grad(&body, &[carry_id])
22245        };
22246
22247        // ── Forward (All-strategy): scan with full trajectory ──
22248        let mut g_full = Graph::new("rc_outer_full");
22249        let init_full = g_full.input("init", Shape::new(&[n], DType::F64));
22250        let traj_full_id = g_full.scan_trajectory(init_full, build_body(), length);
22251        // Hand-build a ScanBackward node that reads the full trajectory.
22252        let upstream_full = g_full.input("upstream", Shape::new(&[length as usize, n], DType::F64));
22253        let dinit_full_id = g_full.scan_backward(
22254            init_full,
22255            traj_full_id,
22256            upstream_full,
22257            &[],
22258            body_vjp_for(),
22259            length,
22260            true,
22261            Shape::new(&[n], DType::F64),
22262        );
22263        g_full.set_outputs(vec![dinit_full_id]);
22264
22265        // ── Forward (Recursive-2): scan saves only K=2 rows ──
22266        // Build the trajectory shape [K, *carry] = [2, 2].
22267        let k = 2u32;
22268        let mut g_rec = Graph::new("rc_outer_rec");
22269        let init_rec = g_rec.input("init", Shape::new(&[n], DType::F64));
22270        let traj_rec_id = g_rec.add_node(
22271            Op::Scan {
22272                body: Box::new(build_body()),
22273                length,
22274                save_trajectory: true,
22275                num_bcast: 0,
22276                num_xs: 0,
22277                num_checkpoints: k,
22278            },
22279            vec![init_rec],
22280            Shape::new(&[k as usize, n], DType::F64),
22281        );
22282        // Same upstream shape as the full version (the upstream is per
22283        // *forward step*, length rows — independent of K).
22284        let upstream_rec = g_rec.input("upstream", Shape::new(&[length as usize, n], DType::F64));
22285        let dinit_rec_id = g_rec.add_node(
22286            Op::ScanBackward {
22287                body_vjp: Box::new(body_vjp_for()),
22288                length,
22289                save_trajectory: true,
22290                num_xs: 0,
22291                num_checkpoints: k,
22292                forward_body: Some(Box::new(build_body())),
22293            },
22294            vec![init_rec, traj_rec_id, upstream_rec],
22295            Shape::new(&[n], DType::F64),
22296        );
22297        g_rec.set_outputs(vec![dinit_rec_id]);
22298
22299        // ── Run both, same inputs ──
22300        let init_data = vec![0.5_f64, -0.5];
22301        let upstream_data: Vec<f64> = (0..length as usize * n).map(|i| (i as f64) * 0.1).collect();
22302
22303        let find = |graph: &Graph, want: &str| -> NodeId {
22304            for node in graph.nodes() {
22305                if let Op::Input { name } = &node.op
22306                    && name == want
22307                {
22308                    return node.id;
22309                }
22310            }
22311            panic!("no input {want}");
22312        };
22313
22314        let (s_full, mut a_full) = prepare_f64(
22315            &g_full,
22316            &[
22317                (find(&g_full, "init"), &init_data),
22318                (find(&g_full, "upstream"), &upstream_data),
22319            ],
22320        );
22321        execute_thunks(&s_full, a_full.raw_buf_mut());
22322        let dinit_full = read_arena_f64(&a_full, g_full.outputs[0], n);
22323
22324        let (s_rec, mut a_rec) = prepare_f64(
22325            &g_rec,
22326            &[
22327                (find(&g_rec, "init"), &init_data),
22328                (find(&g_rec, "upstream"), &upstream_data),
22329            ],
22330        );
22331        execute_thunks(&s_rec, a_rec.raw_buf_mut());
22332        let dinit_rec = read_arena_f64(&a_rec, g_rec.outputs[0], n);
22333
22334        for i in 0..n {
22335            assert!(
22336                (dinit_full[i] - dinit_rec[i]).abs() < 1e-12,
22337                "i={i}: full={} rec={}",
22338                dinit_full[i],
22339                dinit_rec[i]
22340            );
22341        }
22342    }
22343
22344    /// vmap-of-grad: gradient through Scan, vmap'd over init.
22345    /// Forward (per row):
22346    ///   carry_{t+1} = carry_t + ones    (body adds a constant)
22347    ///   loss = sum(carry_length) = sum(init) + length·n
22348    /// Closed form: dloss/dinit_i = 1 for every i. vmap over init at
22349    /// batch=3 → dinit_batched is all-ones [3, n]. Cross-checks
22350    /// against per-row grad_with_loss runs. Validates the vmap rule
22351    /// for Op::ScanBackward.
22352    #[test]
22353    fn vmap_of_grad_scan_matches_per_row_runs() {
22354        use rlx_opt::autodiff::grad_with_loss;
22355        use rlx_opt::vmap::vmap;
22356        let n = 2usize;
22357        let length = 3u32;
22358        let batch = 3usize;
22359
22360        let mut body = Graph::new("scan_grad_body");
22361        let carry = body.input("carry", Shape::new(&[n], DType::F64));
22362        let ones_bytes: Vec<u8> = (0..n).flat_map(|_| 1.0_f64.to_le_bytes()).collect();
22363        let ones = body.add_node(
22364            Op::Constant { data: ones_bytes },
22365            vec![],
22366            Shape::new(&[n], DType::F64),
22367        );
22368        let next = body.binary(BinaryOp::Add, carry, ones, Shape::new(&[n], DType::F64));
22369        body.set_outputs(vec![next]);
22370
22371        let mut g = Graph::new("scan_grad_outer");
22372        let init = g.input("init", Shape::new(&[n], DType::F64));
22373        let final_x = g.scan(init, body, length);
22374        let loss = g.reduce(
22375            final_x,
22376            ReduceOp::Sum,
22377            vec![0],
22378            false,
22379            Shape::new(&[1], DType::F64),
22380        );
22381        g.set_outputs(vec![loss]);
22382
22383        let bwd = grad_with_loss(&g, &[init]);
22384        let bg = vmap(&bwd, &["init"], batch);
22385
22386        let find = |graph: &Graph, want: &str| -> NodeId {
22387            for node in graph.nodes() {
22388                let name = match &node.op {
22389                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
22390                    _ => None,
22391                };
22392                if name == Some(want) {
22393                    return node.id;
22394                }
22395            }
22396            panic!("no node named {want}");
22397        };
22398        let init_b = find(&bg, "init");
22399        let d_out_b = find(&bg, "d_output");
22400
22401        let init_data: Vec<f64> = (0..batch * n).map(|i| (i as f64) * 0.5).collect();
22402        let d_seed = [1.0_f64];
22403
22404        let (sched, mut arena) = prepare_f64(&bg, &[(init_b, &init_data), (d_out_b, &d_seed)]);
22405        execute_thunks(&sched, arena.raw_buf_mut());
22406        let dinit_b = read_arena_f64(&arena, bg.outputs[1], batch * n);
22407
22408        for i in 0..batch * n {
22409            assert!(
22410                (dinit_b[i] - 1.0).abs() < 1e-12,
22411                "dinit[{i}] = {} (expected 1.0)",
22412                dinit_b[i]
22413            );
22414        }
22415
22416        // Cross-check vs per-row grad_with_loss.
22417        for bi in 0..batch {
22418            let row = &init_data[bi * n..(bi + 1) * n];
22419            let mut g2 = Graph::new("per_row_grad");
22420            let init2 = g2.input("init", Shape::new(&[n], DType::F64));
22421            let mut body2 = Graph::new("per_row_body");
22422            let c2 = body2.input("carry", Shape::new(&[n], DType::F64));
22423            let ones2_bytes: Vec<u8> = (0..n).flat_map(|_| 1.0_f64.to_le_bytes()).collect();
22424            let ones2 = body2.add_node(
22425                Op::Constant { data: ones2_bytes },
22426                vec![],
22427                Shape::new(&[n], DType::F64),
22428            );
22429            let next2 = body2.binary(BinaryOp::Add, c2, ones2, Shape::new(&[n], DType::F64));
22430            body2.set_outputs(vec![next2]);
22431            let final2 = g2.scan(init2, body2, length);
22432            let loss2 = g2.reduce(
22433                final2,
22434                ReduceOp::Sum,
22435                vec![0],
22436                false,
22437                Shape::new(&[1], DType::F64),
22438            );
22439            g2.set_outputs(vec![loss2]);
22440            let bwd2 = grad_with_loss(&g2, &[init2]);
22441            let init2_id = find(&bwd2, "init");
22442            let d_out2_id = find(&bwd2, "d_output");
22443            let (s2, mut a2) = prepare_f64(&bwd2, &[(init2_id, row), (d_out2_id, &d_seed)]);
22444            execute_thunks(&s2, a2.raw_buf_mut());
22445            let row_dinit = read_arena_f64(&a2, bwd2.outputs[1], n);
22446            for j in 0..n {
22447                let got = dinit_b[bi * n + j];
22448                let want = row_dinit[j];
22449                assert!(
22450                    (got - want).abs() < 1e-12,
22451                    "row {bi}, j {j}: vmap'd={got} per-row={want}"
22452                );
22453            }
22454        }
22455    }
22456
22457    /// vmap of Op::Scan: batched cumulative-sum. Forward
22458    ///   carry_{t+1} = carry_t + xs\[t\]
22459    ///   final = init + sum(xs)
22460    /// vmap over both init and xs at batch=3. Each batch row should
22461    /// equal the scalar run of the same body+xs subset.
22462    #[test]
22463    fn vmap_scan_cumulative_sum_matches_scalar_runs() {
22464        use rlx_opt::vmap::vmap;
22465        let n = 2usize;
22466        let length = 4u32;
22467        let batch = 3usize;
22468
22469        // Body: (carry, x_t) → carry + x_t
22470        let mut body = Graph::new("scan_body_cumsum");
22471        let carry = body.input("carry", Shape::new(&[n], DType::F64));
22472        let x_t = body.input("x_t", Shape::new(&[n], DType::F64));
22473        let next = body.binary(BinaryOp::Add, carry, x_t, Shape::new(&[n], DType::F64));
22474        body.set_outputs(vec![next]);
22475
22476        let mut g = Graph::new("scan_outer_cumsum");
22477        let init = g.input("init", Shape::new(&[n], DType::F64));
22478        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
22479        let final_carry = g.scan_with_xs(init, &[xs], body, length);
22480        g.set_outputs(vec![final_carry]);
22481
22482        // vmap over both init and xs.
22483        let bg = vmap(&g, &["init", "xs"], batch);
22484
22485        // Test data — distinct per-batch rows.
22486        let init_data: Vec<f64> = (0..batch * n).map(|i| (i + 1) as f64).collect();
22487        // xs has shape [B, length, n] after vmap (the outer's xs is
22488        // [length, n]; vmap lifts it to [B, length, n]).
22489        let xs_data: Vec<f64> = (0..batch * length as usize * n)
22490            .map(|i| 0.1 * (i as f64))
22491            .collect();
22492
22493        let find = |graph: &Graph, want: &str| -> NodeId {
22494            for node in graph.nodes() {
22495                if let Op::Input { name } = &node.op
22496                    && name == want
22497                {
22498                    return node.id;
22499                }
22500            }
22501            panic!("no input {want}");
22502        };
22503        let init_b = find(&bg, "init");
22504        let xs_b = find(&bg, "xs");
22505        let (sched, mut arena) = prepare_f64(&bg, &[(init_b, &init_data), (xs_b, &xs_data)]);
22506        execute_thunks(&sched, arena.raw_buf_mut());
22507        let batched_out = read_arena_f64(&arena, bg.outputs[0], batch * n);
22508
22509        // Reference: per-batch scalar Scan.
22510        for bi in 0..batch {
22511            let init_slice = &init_data[bi * n..(bi + 1) * n];
22512            let mut x = init_slice.to_vec();
22513            for t in 0..length as usize {
22514                for j in 0..n {
22515                    x[j] += xs_data[bi * length as usize * n + t * n + j];
22516                }
22517            }
22518
22519            for i in 0..n {
22520                let got = batched_out[bi * n + i];
22521                assert!(
22522                    (got - x[i]).abs() < 1e-12,
22523                    "row {bi}, i {i}: got {got} ref {}",
22524                    x[i]
22525                );
22526            }
22527        }
22528    }
22529
22530    /// vmap of dense solve — Circulax-shaped batched parameter sweep.
22531    /// Forward: x = solve(A, b). vmap over both A (batched [B,N,N])
22532    /// and b (batched [B,N]). Run on CPU and compare each batch row
22533    /// against an independent scalar dgesv.
22534    #[test]
22535    fn vmap_dense_solve_matches_scalar_runs() {
22536        use rlx_opt::vmap::vmap;
22537        let n = 3usize;
22538        let batch = 4usize;
22539
22540        let mut g = Graph::new("solve_forward");
22541        let a = g.input("A", Shape::new(&[n, n], DType::F64));
22542        let b = g.input("b", Shape::new(&[n], DType::F64));
22543        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
22544        g.set_outputs(vec![x]);
22545
22546        // vmap both A and b across the batch.
22547        let bg = vmap(&g, &["A", "b"], batch);
22548
22549        // Independent A and b per batch row.
22550        let mut rng = rlx_ir::Philox4x32::new(0xb47c_u64);
22551        let mut a_data = vec![0.0_f64; batch * n * n];
22552        let mut b_data = vec![0.0_f64; batch * n];
22553        for bi in 0..batch {
22554            // Diagonally dominant A — guaranteed non-singular.
22555            for i in 0..n {
22556                for j in 0..n {
22557                    a_data[bi * n * n + i * n + j] = rng.next_f32() as f64 * 0.1;
22558                }
22559                a_data[bi * n * n + i * n + i] += 1.0 + n as f64;
22560            }
22561            for i in 0..n {
22562                b_data[bi * n + i] = rng.next_f32() as f64;
22563            }
22564        }
22565
22566        let find = |graph: &Graph, want: &str| -> NodeId {
22567            for node in graph.nodes() {
22568                if let Op::Input { name } = &node.op
22569                    && name == want
22570                {
22571                    return node.id;
22572                }
22573            }
22574            panic!("no input named {want}");
22575        };
22576        let ba = find(&bg, "A");
22577        let bb = find(&bg, "b");
22578        let (sched, mut arena) = prepare_f64(&bg, &[(ba, &a_data), (bb, &b_data)]);
22579        execute_thunks(&sched, arena.raw_buf_mut());
22580        let batched_x = read_arena_f64(&arena, bg.outputs[0], batch * n);
22581
22582        // Reference: per-batch dgesv.
22583        for bi in 0..batch {
22584            let mut a_slice: Vec<f64> = a_data[bi * n * n..(bi + 1) * n * n].to_vec();
22585            let mut b_slice: Vec<f64> = b_data[bi * n..(bi + 1) * n].to_vec();
22586            crate::blas::dgesv(&mut a_slice, &mut b_slice, n, 1);
22587            for i in 0..n {
22588                let got = batched_x[bi * n + i];
22589                let want = b_slice[i];
22590                assert!(
22591                    (got - want).abs() < 1e-12,
22592                    "row {bi}, i {i}: got {got} want {want}"
22593                );
22594            }
22595        }
22596    }
22597
22598    /// vmap end-to-end: build a graph that computes y = MatMul(x, w) + b
22599    /// and reduces to a per-element loss. vmap over x with batch=4.
22600    /// Run the batched graph and compare each output row against an
22601    /// independent scalar run of the original graph. Validates the
22602    /// structural lift + the runtime path for batched MatMul +
22603    /// batched Binary + batched Reduce.
22604    #[test]
22605    fn vmap_matmul_add_reduce_matches_scalar_runs() {
22606        use rlx_opt::vmap::vmap;
22607        let n = 3usize;
22608        let batch = 4usize;
22609
22610        // Forward graph: y = MatMul(reshape(x, [1,n]), w) + b ; loss = sum(y).
22611        let mut g = Graph::new("vmap_e2e_forward");
22612        let x = g.input("x", Shape::new(&[n], DType::F64));
22613        let w = g.input("w", Shape::new(&[n, n], DType::F64));
22614        let b = g.input("b", Shape::new(&[n], DType::F64));
22615        let x_row = g.add_node(
22616            Op::Reshape {
22617                new_shape: vec![1, n as i64],
22618            },
22619            vec![x],
22620            Shape::new(&[1, n], DType::F64),
22621        );
22622        let mm = g.matmul(x_row, w, Shape::new(&[1, n], DType::F64));
22623        let mm_flat = g.add_node(
22624            Op::Reshape {
22625                new_shape: vec![n as i64],
22626            },
22627            vec![mm],
22628            Shape::new(&[n], DType::F64),
22629        );
22630        let yv = g.binary(BinaryOp::Add, mm_flat, b, Shape::new(&[n], DType::F64));
22631        let loss = g.reduce(
22632            yv,
22633            ReduceOp::Sum,
22634            vec![0],
22635            false,
22636            Shape::new(&[1], DType::F64),
22637        );
22638        g.set_outputs(vec![loss]);
22639
22640        // Build the vmap'd version (batch over x; w and b shared).
22641        let bg = vmap(&g, &["x"], batch);
22642
22643        // Test data — distinct rows so we can verify the per-row dispatch.
22644        let mut rng = rlx_ir::Philox4x32::new(0xc1c0_u64);
22645        let n_w = n * n;
22646        let w_data: Vec<f64> = (0..n_w).map(|_| rng.next_f32() as f64).collect();
22647        let b_data: Vec<f64> = (0..n).map(|_| rng.next_f32() as f64).collect();
22648        let mut x_data_batched: Vec<f64> = Vec::with_capacity(batch * n);
22649        for _ in 0..batch * n {
22650            x_data_batched.push(rng.next_f32() as f64);
22651        }
22652
22653        // Run the batched graph.
22654        let find = |graph: &Graph, want: &str| -> NodeId {
22655            for node in graph.nodes() {
22656                if let Op::Input { name } = &node.op
22657                    && name == want
22658                {
22659                    return node.id;
22660                }
22661            }
22662            panic!("no input named {want}");
22663        };
22664        let bx = find(&bg, "x");
22665        let bw = find(&bg, "w");
22666        let bb = find(&bg, "b");
22667        let (sched, mut arena) =
22668            prepare_f64(&bg, &[(bx, &x_data_batched), (bw, &w_data), (bb, &b_data)]);
22669        execute_thunks(&sched, arena.raw_buf_mut());
22670        // Reduce::Sum on shifted axis 1 with keep_dim=false → output [B, 1]
22671        // (it preserves the leading batch axis but reduces what was [n] to [].
22672        // Since the original output was [1] f64 and the reduce was over
22673        // axis 0, after vmap the leading-axis-shifted reduce keeps the
22674        // leading 1 from the original output's [1] shape.)
22675        let batched_out = read_arena_f64(&arena, bg.outputs[0], batch);
22676
22677        // Reference: run the original (un-batched) graph once per batch row.
22678        for bi in 0..batch {
22679            let xs_slice = &x_data_batched[bi * n..(bi + 1) * n];
22680            let mut g2 = Graph::new("scalar_run");
22681            let x2 = g2.input("x", Shape::new(&[n], DType::F64));
22682            let w2 = g2.input("w", Shape::new(&[n, n], DType::F64));
22683            let b2 = g2.input("b", Shape::new(&[n], DType::F64));
22684            let xr = g2.add_node(
22685                Op::Reshape {
22686                    new_shape: vec![1, n as i64],
22687                },
22688                vec![x2],
22689                Shape::new(&[1, n], DType::F64),
22690            );
22691            let m = g2.matmul(xr, w2, Shape::new(&[1, n], DType::F64));
22692            let mf = g2.add_node(
22693                Op::Reshape {
22694                    new_shape: vec![n as i64],
22695                },
22696                vec![m],
22697                Shape::new(&[n], DType::F64),
22698            );
22699            let yv2 = g2.binary(BinaryOp::Add, mf, b2, Shape::new(&[n], DType::F64));
22700            let l2 = g2.reduce(
22701                yv2,
22702                ReduceOp::Sum,
22703                vec![0],
22704                false,
22705                Shape::new(&[1], DType::F64),
22706            );
22707            g2.set_outputs(vec![l2]);
22708            let (s2, mut a2) = prepare_f64(&g2, &[(x2, xs_slice), (w2, &w_data), (b2, &b_data)]);
22709            execute_thunks(&s2, a2.raw_buf_mut());
22710            let scalar_out = read_arena_f64(&a2, l2, 1);
22711            assert!(
22712                (batched_out[bi] - scalar_out[0]).abs() < 1e-12,
22713                "row {bi}: batched={} scalar={}",
22714                batched_out[bi],
22715                scalar_out[0]
22716            );
22717        }
22718    }
22719
22720    /// Full gradient through scan-with-xs: dinit AND dxs both checked
22721    /// against finite differences. Forward
22722    ///   carry_{t+1} = solve(M, carry_t + xs\[t\])
22723    ///   loss        = sum(carry_length)
22724    /// Verifies that grad_with_loss returns gradients w.r.t. both
22725    /// `init` and `xs` and that dxs matches per-element FD.
22726    #[test]
22727    fn scan_with_xs_dxs_matches_fd() {
22728        use rlx_opt::autodiff::grad_with_loss;
22729        let n = 3usize;
22730        let length = 3u32;
22731        let dt = 0.1_f64;
22732
22733        let mut m_data = vec![0.0_f64; n * n];
22734        for i in 0..n {
22735            m_data[i * n + i] = 1.0 + dt * 2.0;
22736            if i > 0 {
22737                m_data[i * n + (i - 1)] = -dt;
22738            }
22739            if i + 1 < n {
22740                m_data[i * n + (i + 1)] = -dt;
22741            }
22742        }
22743        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
22744
22745        let mut body = Graph::new("be_dxs_body");
22746        let carry = body.input("carry", Shape::new(&[n], DType::F64));
22747        let drive = body.input("drive", Shape::new(&[n], DType::F64));
22748        let m = body.add_node(
22749            Op::Constant { data: m_bytes },
22750            vec![],
22751            Shape::new(&[n, n], DType::F64),
22752        );
22753        let driven = body.binary(BinaryOp::Add, carry, drive, Shape::new(&[n], DType::F64));
22754        let next = body.dense_solve(m, driven, Shape::new(&[n], DType::F64));
22755        body.set_outputs(vec![next]);
22756
22757        let mut g = Graph::new("be_dxs_outer");
22758        let init = g.input("init", Shape::new(&[n], DType::F64));
22759        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
22760        let final_carry = g.scan_with_xs(init, &[xs], body, length);
22761        let loss = g.reduce(
22762            final_carry,
22763            ReduceOp::Sum,
22764            vec![0],
22765            false,
22766            Shape::new(&[1], DType::F64),
22767        );
22768        g.set_outputs(vec![loss]);
22769
22770        // wrt = [init, xs] — get both gradients back.
22771        let bwd = grad_with_loss(&g, &[init, xs]);
22772        assert_eq!(bwd.outputs.len(), 3, "[loss, dinit, dxs]");
22773
22774        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
22775            for node in graph.nodes() {
22776                let name = match &node.op {
22777                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
22778                    _ => None,
22779                };
22780                if name == Some(want) {
22781                    return node.id;
22782                }
22783            }
22784            panic!("no node named {want:?}");
22785        };
22786        let init_bwd = find_by_name(&bwd, "init");
22787        let xs_bwd = find_by_name(&bwd, "xs");
22788        let d_out_bwd = find_by_name(&bwd, "d_output");
22789
22790        let init_data = vec![0.5_f64, 0.0, -0.5];
22791        let xs_data: Vec<f64> = (0..length as usize * n)
22792            .map(|i| 0.1_f64 * ((i as f64) - 4.0))
22793            .collect();
22794        let d_seed = [1.0_f64];
22795
22796        let (sched, mut arena) = prepare_f64(
22797            &bwd,
22798            &[
22799                (init_bwd, &init_data),
22800                (xs_bwd, &xs_data),
22801                (d_out_bwd, &d_seed),
22802            ],
22803        );
22804        execute_thunks(&sched, arena.raw_buf_mut());
22805        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
22806        let dxs = read_arena_f64(&arena, bwd.outputs[2], length as usize * n);
22807
22808        let h = 1e-6;
22809        let loss_at = |x0: &[f64], xs_in: &[f64]| -> f64 {
22810            let mut acc = x0.to_vec();
22811            for t in 0..length as usize {
22812                for j in 0..n {
22813                    acc[j] += xs_in[t * n + j];
22814                }
22815                let mut a_copy = m_data.clone();
22816                crate::blas::dgesv(&mut a_copy, &mut acc, n, 1);
22817            }
22818            acc.iter().sum()
22819        };
22820
22821        // FD on dinit (sanity).
22822        for i in 0..n {
22823            let mut ip = init_data.to_vec();
22824            ip[i] += h;
22825            let mut im = init_data.to_vec();
22826            im[i] -= h;
22827            let fd = (loss_at(&ip, &xs_data) - loss_at(&im, &xs_data)) / (2.0 * h);
22828            assert!(
22829                (dinit[i] - fd).abs() < 1e-7,
22830                "FD dinit[{i}]: AD={} FD={}",
22831                dinit[i],
22832                fd
22833            );
22834        }
22835
22836        // FD on every dxs entry — full per-step gradient check.
22837        for t in 0..length as usize {
22838            for j in 0..n {
22839                let idx = t * n + j;
22840                let mut xp = xs_data.clone();
22841                xp[idx] += h;
22842                let mut xm = xs_data.clone();
22843                xm[idx] -= h;
22844                let fd = (loss_at(&init_data, &xp) - loss_at(&init_data, &xm)) / (2.0 * h);
22845                assert!(
22846                    (dxs[idx] - fd).abs() < 1e-7,
22847                    "FD dxs[t={t},j={j}]: AD={} FD={}",
22848                    dxs[idx],
22849                    fd
22850                );
22851            }
22852        }
22853    }
22854
22855    /// Gradient through a scan with per-step xs (Circulax-shaped).
22856    /// Forward:
22857    ///   carry_{t+1} = solve(M, carry_t + xs\[t\])
22858    ///   loss = sum(carry_length)
22859    /// dxs is out of MVP (asserted in the VJP rule's body_vjp `wrt`),
22860    /// but `dinit` flows correctly through the body's reverse Jacobian
22861    /// even with xs in the chain. Verify dinit against finite differences.
22862    #[test]
22863    fn scan_with_xs_gradient_dinit_matches_fd() {
22864        use rlx_opt::autodiff::grad_with_loss;
22865        let n = 3usize;
22866        let length = 3u32;
22867        let dt = 0.1_f64;
22868
22869        let mut m_data = vec![0.0_f64; n * n];
22870        for i in 0..n {
22871            m_data[i * n + i] = 1.0 + dt * 2.0;
22872            if i > 0 {
22873                m_data[i * n + (i - 1)] = -dt;
22874            }
22875            if i + 1 < n {
22876                m_data[i * n + (i + 1)] = -dt;
22877            }
22878        }
22879        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
22880
22881        let mut body = Graph::new("be_xs_grad_body");
22882        let carry = body.input("carry", Shape::new(&[n], DType::F64));
22883        let drive = body.input("drive", Shape::new(&[n], DType::F64));
22884        let m = body.add_node(
22885            Op::Constant { data: m_bytes },
22886            vec![],
22887            Shape::new(&[n, n], DType::F64),
22888        );
22889        let driven = body.binary(BinaryOp::Add, carry, drive, Shape::new(&[n], DType::F64));
22890        let next = body.dense_solve(m, driven, Shape::new(&[n], DType::F64));
22891        body.set_outputs(vec![next]);
22892
22893        let mut g = Graph::new("be_xs_grad_outer");
22894        let init = g.input("init", Shape::new(&[n], DType::F64));
22895        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
22896        let final_carry = g.scan_with_xs(init, &[xs], body, length);
22897        let loss = g.reduce(
22898            final_carry,
22899            ReduceOp::Sum,
22900            vec![0],
22901            false,
22902            Shape::new(&[1], DType::F64),
22903        );
22904        g.set_outputs(vec![loss]);
22905
22906        let bwd = grad_with_loss(&g, &[init]);
22907
22908        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
22909            for node in graph.nodes() {
22910                let name = match &node.op {
22911                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
22912                    _ => None,
22913                };
22914                if name == Some(want) {
22915                    return node.id;
22916                }
22917            }
22918            panic!("no node named {want:?}");
22919        };
22920        let init_bwd = find_by_name(&bwd, "init");
22921        let xs_bwd = find_by_name(&bwd, "xs");
22922        let d_out_bwd = find_by_name(&bwd, "d_output");
22923
22924        let init_data = vec![0.5_f64, 0.0, -0.5];
22925        // Drive: small per-step pulse, varying per element.
22926        let xs_data: Vec<f64> = (0..length as usize * n)
22927            .map(|i| 0.1_f64 * ((i as f64) - 4.0))
22928            .collect();
22929        let d_seed = [1.0_f64];
22930
22931        let (sched, mut arena) = prepare_f64(
22932            &bwd,
22933            &[
22934                (init_bwd, &init_data),
22935                (xs_bwd, &xs_data),
22936                (d_out_bwd, &d_seed),
22937            ],
22938        );
22939        execute_thunks(&sched, arena.raw_buf_mut());
22940        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
22941
22942        let h = 1e-6;
22943        let loss_at = |x0: &[f64]| -> f64 {
22944            let mut acc = x0.to_vec();
22945            for t in 0..length as usize {
22946                for j in 0..n {
22947                    acc[j] += xs_data[t * n + j];
22948                }
22949                let mut a_copy = m_data.clone();
22950                crate::blas::dgesv(&mut a_copy, &mut acc, n, 1);
22951            }
22952            acc.iter().sum()
22953        };
22954        for i in 0..n {
22955            let mut ip = init_data.to_vec();
22956            ip[i] += h;
22957            let mut im = init_data.to_vec();
22958            im[i] -= h;
22959            let fd = (loss_at(&ip) - loss_at(&im)) / (2.0 * h);
22960            assert!(
22961                (dinit[i] - fd).abs() < 1e-7,
22962                "FD dinit[{i}]: AD={} FD={}",
22963                dinit[i],
22964                fd
22965            );
22966        }
22967    }
22968
22969    /// Gradient through a geometric-growth scan: forward
22970    ///   x_{t+1} = 1.1 · x_t,    x_0 = init
22971    ///   final   = x_length     = init · 1.1^length
22972    ///   loss    = sum(final)
22973    /// closed-form ∂loss/∂init\[i\] = 1.1^length for every i.
22974    /// Validates the VJP path: AD pre-pass rewrites save_trajectory=false
22975    /// to true, autodiff emits Op::ScanBackward, executor walks t back.
22976    #[test]
22977    fn scan_gradient_geometric_matches_closed_form() {
22978        use rlx_opt::autodiff::grad_with_loss;
22979        let n = 3usize;
22980        let length = 5u32;
22981
22982        let mut body = Graph::new("scan_grad_body");
22983        let x = body.input("carry", Shape::new(&[n], DType::F64));
22984        let scale_bytes: Vec<u8> = (0..n).flat_map(|_| 1.1_f64.to_le_bytes()).collect();
22985        let scale = body.add_node(
22986            Op::Constant { data: scale_bytes },
22987            vec![],
22988            Shape::new(&[n], DType::F64),
22989        );
22990        let next = body.binary(BinaryOp::Mul, x, scale, Shape::new(&[n], DType::F64));
22991        body.set_outputs(vec![next]);
22992
22993        let mut g = Graph::new("scan_grad_outer");
22994        let init = g.input("init", Shape::new(&[n], DType::F64));
22995        let final_x = g.scan(init, body, length);
22996        let loss = g.reduce(
22997            final_x,
22998            ReduceOp::Sum,
22999            vec![0],
23000            false,
23001            Shape::new(&[1], DType::F64),
23002        );
23003        g.set_outputs(vec![loss]);
23004
23005        let bwd = grad_with_loss(&g, &[init]);
23006        assert_eq!(bwd.outputs.len(), 2);
23007
23008        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
23009            for node in graph.nodes() {
23010                let name = match &node.op {
23011                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
23012                    _ => None,
23013                };
23014                if name == Some(want) {
23015                    return node.id;
23016                }
23017            }
23018            panic!("no node named {want:?}");
23019        };
23020        let init_bwd = find_by_name(&bwd, "init");
23021        let d_out_bwd = find_by_name(&bwd, "d_output");
23022
23023        let init_data = vec![1.0_f64; n];
23024        let d_seed = [1.0_f64];
23025        let (sched, mut arena) = prepare_f64(&bwd, &[(init_bwd, &init_data), (d_out_bwd, &d_seed)]);
23026        execute_thunks(&sched, arena.raw_buf_mut());
23027        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
23028
23029        let want = 1.1_f64.powi(length as i32);
23030        for i in 0..n {
23031            assert!(
23032                (dinit[i] - want).abs() < 1e-12,
23033                "dinit[{i}] = {} want {}",
23034                dinit[i],
23035                want
23036            );
23037        }
23038
23039        // Finite-difference cross-check on init[0].
23040        let h = 1e-6;
23041        let loss_at = |x: &[f64]| -> f64 {
23042            let mut acc = x.to_vec();
23043            for _ in 0..length {
23044                for v in acc.iter_mut() {
23045                    *v *= 1.1;
23046                }
23047            }
23048            acc.iter().sum()
23049        };
23050        let mut ip = init_data.clone();
23051        ip[0] += h;
23052        let mut im = init_data.clone();
23053        im[0] -= h;
23054        let fd = (loss_at(&ip) - loss_at(&im)) / (2.0 * h);
23055        assert!(
23056            (dinit[0] - fd).abs() < 1e-7,
23057            "FD dinit[0]: AD={} FD={}",
23058            dinit[0],
23059            fd
23060        );
23061    }
23062
23063    /// Gradient through Backward Euler scan composing with DenseSolve.
23064    /// Asserts dinit matches finite-difference per coordinate.
23065    #[test]
23066    fn scan_gradient_backward_euler_matches_fd() {
23067        use rlx_opt::autodiff::grad_with_loss;
23068        let n = 4usize;
23069        let length = 3u32;
23070        let dt = 0.05_f64;
23071
23072        let mut m_data = vec![0.0_f64; n * n];
23073        for i in 0..n {
23074            m_data[i * n + i] = 1.0 + dt * 2.0;
23075            if i > 0 {
23076                m_data[i * n + (i - 1)] = -dt;
23077            }
23078            if i + 1 < n {
23079                m_data[i * n + (i + 1)] = -dt;
23080            }
23081        }
23082        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
23083
23084        let mut body = Graph::new("be_grad_body");
23085        let x = body.input("x", Shape::new(&[n], DType::F64));
23086        let m = body.add_node(
23087            Op::Constant { data: m_bytes },
23088            vec![],
23089            Shape::new(&[n, n], DType::F64),
23090        );
23091        let next = body.dense_solve(m, x, Shape::new(&[n], DType::F64));
23092        body.set_outputs(vec![next]);
23093
23094        let mut g = Graph::new("be_grad_outer");
23095        let init = g.input("x0", Shape::new(&[n], DType::F64));
23096        let final_x = g.scan(init, body, length);
23097        let loss = g.reduce(
23098            final_x,
23099            ReduceOp::Sum,
23100            vec![0],
23101            false,
23102            Shape::new(&[1], DType::F64),
23103        );
23104        g.set_outputs(vec![loss]);
23105
23106        let bwd = grad_with_loss(&g, &[init]);
23107
23108        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
23109            for node in graph.nodes() {
23110                let name = match &node.op {
23111                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
23112                    _ => None,
23113                };
23114                if name == Some(want) {
23115                    return node.id;
23116                }
23117            }
23118            panic!("no node named {want:?}");
23119        };
23120        let init_bwd = find_by_name(&bwd, "x0");
23121        let d_out_bwd = find_by_name(&bwd, "d_output");
23122
23123        let init_data: [f64; 4] = [0.0, 1.0, 0.0, 0.0];
23124        let d_seed = [1.0_f64];
23125        let (sched, mut arena) = prepare_f64(&bwd, &[(init_bwd, &init_data), (d_out_bwd, &d_seed)]);
23126        execute_thunks(&sched, arena.raw_buf_mut());
23127        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
23128
23129        let h = 1e-6;
23130        let loss_at = |x0: &[f64]| -> f64 {
23131            let mut acc = x0.to_vec();
23132            for _ in 0..length {
23133                let mut a_copy = m_data.clone();
23134                crate::blas::dgesv(&mut a_copy, &mut acc, n, 1);
23135            }
23136            acc.iter().sum()
23137        };
23138        for i in 0..n {
23139            let mut ip = init_data.to_vec();
23140            ip[i] += h;
23141            let mut im = init_data.to_vec();
23142            im[i] -= h;
23143            let fd = (loss_at(&ip) - loss_at(&im)) / (2.0 * h);
23144            assert!(
23145                (dinit[i] - fd).abs() < 1e-7,
23146                "FD dinit[{i}]: AD={} FD={}",
23147                dinit[i],
23148                fd
23149            );
23150        }
23151    }
23152
23153    /// Trajectory-mode scan: same Backward Euler body, but record the
23154    /// carry at every step. Output is `[length, n]` — row `t` is the
23155    /// state after step `t+1`. Validates the SaveAt-style waveform
23156    /// recording end-to-end, including that the last row equals what
23157    /// the no-trajectory variant would have returned.
23158    #[test]
23159    fn scan_trajectory_backward_euler_records_waveform() {
23160        let n = 4usize;
23161        let length = 5u32;
23162        let dt = 0.05_f64;
23163
23164        let mut m_data = vec![0.0_f64; n * n];
23165        for i in 0..n {
23166            m_data[i * n + i] = 1.0 + dt * 2.0;
23167            if i > 0 {
23168                m_data[i * n + (i - 1)] = -dt;
23169            }
23170            if i + 1 < n {
23171                m_data[i * n + (i + 1)] = -dt;
23172            }
23173        }
23174        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
23175
23176        let mut body = Graph::new("be_traj_body");
23177        let x = body.input("x", Shape::new(&[n], DType::F64));
23178        let m = body.add_node(
23179            Op::Constant { data: m_bytes },
23180            vec![],
23181            Shape::new(&[n, n], DType::F64),
23182        );
23183        let next = body.dense_solve(m, x, Shape::new(&[n], DType::F64));
23184        body.set_outputs(vec![next]);
23185
23186        let mut g = Graph::new("be_traj_outer");
23187        let init = g.input("x0", Shape::new(&[n], DType::F64));
23188        let traj = g.scan_trajectory(init, body, length);
23189        g.set_outputs(vec![traj]);
23190
23191        let init_data: [f64; 4] = [0.0, 1.0, 0.0, 0.0];
23192        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data)]);
23193        execute_thunks(&sched, arena.raw_buf_mut());
23194        let got = read_arena_f64(&arena, traj, length as usize * n);
23195
23196        // Reference: each step's solve, recorded.
23197        let mut want = Vec::<f64>::with_capacity(length as usize * n);
23198        let mut x_ref = init_data.to_vec();
23199        for _ in 0..length {
23200            let mut a_copy = m_data.clone();
23201            crate::blas::dgesv(&mut a_copy, &mut x_ref, n, 1);
23202            want.extend_from_slice(&x_ref);
23203        }
23204        for i in 0..length as usize * n {
23205            assert!(
23206                (got[i] - want[i]).abs() < 1e-12,
23207                "got[{i}] = {} ref {}",
23208                got[i],
23209                want[i]
23210            );
23211        }
23212
23213        // Sanity: trajectory rows are monotone-decreasing in mass
23214        // (Backward Euler diffuses; boundary leak removes mass).
23215        for t in 1..length as usize {
23216            let prev: f64 = got[(t - 1) * n..t * n].iter().sum();
23217            let curr: f64 = got[t * n..(t + 1) * n].iter().sum();
23218            assert!(
23219                curr <= prev + 1e-15,
23220                "mass should decay: row {} sum {prev}, row {t} sum {curr}",
23221                t - 1
23222            );
23223        }
23224
23225        // Last row of the trajectory equals what a non-trajectory
23226        // scan returns — verify by running the same forward through
23227        // the simpler API and comparing.
23228        let mut body2 = Graph::new("be_final_body");
23229        let x2 = body2.input("x", Shape::new(&[n], DType::F64));
23230        let m_bytes2: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
23231        let m2 = body2.add_node(
23232            Op::Constant { data: m_bytes2 },
23233            vec![],
23234            Shape::new(&[n, n], DType::F64),
23235        );
23236        let next2 = body2.dense_solve(m2, x2, Shape::new(&[n], DType::F64));
23237        body2.set_outputs(vec![next2]);
23238
23239        let mut g2 = Graph::new("be_final_outer");
23240        let init2 = g2.input("x0", Shape::new(&[n], DType::F64));
23241        let final_x = g2.scan(init2, body2, length);
23242        g2.set_outputs(vec![final_x]);
23243        let (sched2, mut arena2) = prepare_f64(&g2, &[(init2, &init_data)]);
23244        execute_thunks(&sched2, arena2.raw_buf_mut());
23245        let final_got = read_arena_f64(&arena2, final_x, n);
23246
23247        let last_row = &got[(length as usize - 1) * n..length as usize * n];
23248        for i in 0..n {
23249            assert!(
23250                (last_row[i] - final_got[i]).abs() < 1e-15,
23251                "last trajectory row[{i}] = {} vs final-scan = {}",
23252                last_row[i],
23253                final_got[i]
23254            );
23255        }
23256    }
23257
23258    /// Op::Scan composing with Op::DenseSolve — the Circulax-shaped
23259    /// pattern for Backward Euler.
23260    /// Body: x_{t+1} = solve(I + dt·A, x_t).
23261    /// 1-D heat-equation Laplacian A; analytic ground truth from
23262    /// composing the same per-step solve in Rust.
23263    #[test]
23264    fn scan_backward_euler_heat_f64() {
23265        let n = 4usize;
23266        let length = 5u32;
23267        let dt = 0.05_f64;
23268
23269        // Construct M = I + dt · L  where L is the Laplacian (-1, 2, -1).
23270        // M is constant across iterations; embed it in the body via Op::Constant.
23271        let mut m_data = vec![0.0_f64; n * n];
23272        for i in 0..n {
23273            m_data[i * n + i] = 1.0 + dt * 2.0;
23274            if i > 0 {
23275                m_data[i * n + (i - 1)] = -dt;
23276            }
23277            if i + 1 < n {
23278                m_data[i * n + (i + 1)] = -dt;
23279            }
23280        }
23281        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
23282
23283        let mut body = Graph::new("be_body");
23284        let x = body.input("x", Shape::new(&[n], DType::F64));
23285        let m = body.add_node(
23286            Op::Constant { data: m_bytes },
23287            vec![],
23288            Shape::new(&[n, n], DType::F64),
23289        );
23290        let next = body.dense_solve(m, x, Shape::new(&[n], DType::F64));
23291        body.set_outputs(vec![next]);
23292
23293        let mut g = Graph::new("be_outer");
23294        let init = g.input("x0", Shape::new(&[n], DType::F64));
23295        let final_x = g.scan(init, body, length);
23296        g.set_outputs(vec![final_x]);
23297
23298        // Initial: a sharp pulse at index 1.
23299        let init_data: [f64; 4] = [0.0, 1.0, 0.0, 0.0];
23300        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data)]);
23301        execute_thunks(&sched, arena.raw_buf_mut());
23302        let got = read_arena_f64(&arena, final_x, n);
23303
23304        // Reference: apply the same M-solve `length` times in pure Rust.
23305        let mut ref_x = init_data.to_vec();
23306        for _ in 0..length {
23307            let mut a_copy = m_data.clone();
23308            crate::blas::dgesv(&mut a_copy, &mut ref_x, n, 1);
23309        }
23310        for i in 0..n {
23311            assert!(
23312                (got[i] - ref_x[i]).abs() < 1e-12,
23313                "got[{i}] = {} ref {}",
23314                got[i],
23315                ref_x[i]
23316            );
23317        }
23318        // Sanity: pulse should diffuse, mass should be conserved-ish
23319        // (Backward Euler is mass-conserving for this stencil with
23320        // zero-flux boundaries — but our boundaries leak, so check
23321        // that mass strictly decreases instead).
23322        let mass: f64 = got.iter().sum();
23323        assert!(mass > 0.0 && mass < 1.0, "diffusion mass: {mass}");
23324    }
23325
23326    /// Multi-RHS forward DenseSolve: X = solve(A, B) with B [N, K]
23327    /// stays correct end-to-end. Verifies the executor/lowering and
23328    /// the LAPACK column-major dance both honour `nrhs > 1`.
23329    #[test]
23330    fn dense_solve_f64_multi_rhs_forward() {
23331        let n = 3usize;
23332        let k = 2usize;
23333        let mut g = Graph::new("solve_multi_rhs");
23334        let a = g.input("A", Shape::new(&[n, n], DType::F64));
23335        let b = g.input("B", Shape::new(&[n, k], DType::F64));
23336        let x = g.dense_solve(a, b, Shape::new(&[n, k], DType::F64));
23337        g.set_outputs(vec![x]);
23338
23339        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
23340        let b_data = [1.0, 4.0, 2.0, -1.0, 3.0, 2.0_f64];
23341        let (sched, mut arena) = prepare_f64(&g, &[(a, &a_data), (b, &b_data)]);
23342        execute_thunks(&sched, arena.raw_buf_mut());
23343        let x_got = read_arena_f64(&arena, x, n * k);
23344        for c in 0..k {
23345            for i in 0..n {
23346                let mut acc = 0.0_f64;
23347                for j in 0..n {
23348                    acc += a_data[i * n + j] * x_got[j * k + c];
23349                }
23350                let want = b_data[i * k + c];
23351                assert!(
23352                    (acc - want).abs() < 1e-10,
23353                    "col {c} row {i}: got {acc} want {want}"
23354                );
23355            }
23356        }
23357    }
23358
23359    /// Multi-RHS reverse-mode VJP: dB = (Aᵀ)⁻¹·1, dA = -dB · Xᵀ.
23360    /// Verified analytically + finite differences on dB[0,0].
23361    #[test]
23362    fn dense_solve_f64_multi_rhs_gradient() {
23363        use rlx_opt::autodiff::grad_with_loss;
23364        let n = 3usize;
23365        let k = 2usize;
23366        let mut g = Graph::new("solve_mrhs_grad");
23367        let a = g.param("A", Shape::new(&[n, n], DType::F64));
23368        let b = g.input("B", Shape::new(&[n, k], DType::F64));
23369        let x = g.dense_solve(a, b, Shape::new(&[n, k], DType::F64));
23370        let loss = g.reduce(
23371            x,
23372            ReduceOp::Sum,
23373            vec![0, 1],
23374            false,
23375            Shape::new(&[1], DType::F64),
23376        );
23377        g.set_outputs(vec![loss]);
23378
23379        let bwd = grad_with_loss(&g, &[a, b]);
23380        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
23381            for node in graph.nodes() {
23382                let name = match &node.op {
23383                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
23384                    _ => None,
23385                };
23386                if name == Some(want) {
23387                    return node.id;
23388                }
23389            }
23390            panic!("no node named {want:?}");
23391        };
23392        let a_bwd = find_by_name(&bwd, "A");
23393        let b_bwd = find_by_name(&bwd, "B");
23394        let d_out = find_by_name(&bwd, "d_output");
23395
23396        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
23397        let b_data = [1.0, 4.0, 2.0, -1.0, 3.0, 2.0_f64];
23398        let d_seed = [1.0_f64];
23399
23400        let (sched, mut arena) = prepare_f64(
23401            &bwd,
23402            &[(a_bwd, &a_data), (b_bwd, &b_data), (d_out, &d_seed)],
23403        );
23404        execute_thunks(&sched, arena.raw_buf_mut());
23405        let da_got = read_arena_f64(&arena, bwd.outputs[1], n * n);
23406        let db_got = read_arena_f64(&arena, bwd.outputs[2], n * k);
23407
23408        // Reference.
23409        let mut x_ref = b_data;
23410        {
23411            let mut a_copy = a_data;
23412            crate::blas::dgesv(&mut a_copy, &mut x_ref, n, k);
23413        }
23414        let mut at = [0.0_f64; 9];
23415        for i in 0..n {
23416            for j in 0..n {
23417                at[i * n + j] = a_data[j * n + i];
23418            }
23419        }
23420        let mut ones_nk = vec![1.0_f64; n * k];
23421        crate::blas::dgesv(&mut at, &mut ones_nk, n, k);
23422        let db_ref = ones_nk;
23423        let mut da_ref = [0.0_f64; 9];
23424        for i in 0..n {
23425            for j in 0..n {
23426                let mut acc = 0.0_f64;
23427                for c in 0..k {
23428                    acc += db_ref[i * k + c] * x_ref[j * k + c];
23429                }
23430                da_ref[i * n + j] = -acc;
23431            }
23432        }
23433        for i in 0..n * k {
23434            assert!(
23435                (db_got[i] - db_ref[i]).abs() < 1e-10,
23436                "dB[{i}]: got {} want {}",
23437                db_got[i],
23438                db_ref[i]
23439            );
23440        }
23441        for i in 0..n * n {
23442            assert!(
23443                (da_got[i] - da_ref[i]).abs() < 1e-10,
23444                "dA[{i}]: got {} want {}",
23445                da_got[i],
23446                da_ref[i]
23447            );
23448        }
23449
23450        // FD on dB[0,0].
23451        let h = 1e-6;
23452        let mut bp = b_data;
23453        bp[0] += h;
23454        let mut bm = b_data;
23455        bm[0] -= h;
23456        let xp = {
23457            let mut a_copy = a_data;
23458            crate::blas::dgesv(&mut a_copy, &mut bp, n, k);
23459            bp
23460        };
23461        let xm = {
23462            let mut a_copy = a_data;
23463            crate::blas::dgesv(&mut a_copy, &mut bm, n, k);
23464            bm
23465        };
23466        let lp: f64 = xp.iter().sum();
23467        let lm: f64 = xm.iter().sum();
23468        let fd = (lp - lm) / (2.0 * h);
23469        assert!(
23470            (db_got[0] - fd).abs() < 1e-7,
23471            "FD dB[0,0]: AD={} FD={}",
23472            db_got[0],
23473            fd
23474        );
23475    }
23476
23477    /// Multi-RHS forward-mode JVP w.r.t. B. Closed form: t_X = solve(A, t_B).
23478    #[test]
23479    fn dense_solve_f64_multi_rhs_jvp() {
23480        use rlx_opt::autodiff_fwd::jvp;
23481        let n = 3usize;
23482        let k = 2usize;
23483        let mut g = Graph::new("solve_mrhs_jvp");
23484        let a = g.input("A", Shape::new(&[n, n], DType::F64));
23485        let b = g.input("B", Shape::new(&[n, k], DType::F64));
23486        let x = g.dense_solve(a, b, Shape::new(&[n, k], DType::F64));
23487        g.set_outputs(vec![x]);
23488
23489        let jg = jvp(&g, &[b]);
23490        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
23491            for node in graph.nodes() {
23492                let name = match &node.op {
23493                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
23494                    _ => None,
23495                };
23496                if name == Some(want) {
23497                    return node.id;
23498                }
23499            }
23500            panic!("no node named {want:?}");
23501        };
23502        let a_id = find_by_name(&jg, "A");
23503        let b_id = find_by_name(&jg, "B");
23504        let tb_id = find_by_name(&jg, "tangent_B");
23505
23506        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
23507        let b_data = [1.0, 4.0, 2.0, -1.0, 3.0, 2.0_f64];
23508        let tb_data = [0.5, 0.0, -0.25, 1.0, 1.0, -0.5_f64];
23509
23510        let (sched, mut arena) =
23511            prepare_f64(&jg, &[(a_id, &a_data), (b_id, &b_data), (tb_id, &tb_data)]);
23512        execute_thunks(&sched, arena.raw_buf_mut());
23513        let tangent_x = read_arena_f64(&arena, jg.outputs[1], n * k);
23514
23515        let mut a_copy = a_data;
23516        let mut tb_copy = tb_data;
23517        crate::blas::dgesv(&mut a_copy, &mut tb_copy, n, k);
23518        for i in 0..n * k {
23519            assert!(
23520                (tangent_x[i] - tb_copy[i]).abs() < 1e-10,
23521                "t_X[{i}]: AD={} ref={}",
23522                tangent_x[i],
23523                tb_copy[i]
23524            );
23525        }
23526
23527        let h = 1e-6;
23528        let mut bp = b_data;
23529        let mut bm = b_data;
23530        for i in 0..n * k {
23531            bp[i] += h * tb_data[i];
23532            bm[i] -= h * tb_data[i];
23533        }
23534        let xp = {
23535            let mut a_copy = a_data;
23536            crate::blas::dgesv(&mut a_copy, &mut bp, n, k);
23537            bp
23538        };
23539        let xm = {
23540            let mut a_copy = a_data;
23541            crate::blas::dgesv(&mut a_copy, &mut bm, n, k);
23542            bm
23543        };
23544        for i in 0..n * k {
23545            let fd = (xp[i] - xm[i]) / (2.0 * h);
23546            assert!(
23547                (tangent_x[i] - fd).abs() < 1e-7,
23548                "FD t_X[{i}]: AD={} FD={}",
23549                tangent_x[i],
23550                fd
23551            );
23552        }
23553    }
23554
23555    /// Forward-mode JVP through DenseSolve, end-to-end at f64.
23556    ///
23557    /// Build forward x = solve(A, b), call `jvp(forward, [b])`,
23558    /// compile + run, and check the tangent output matches the
23559    /// closed form `t_x = solve(A, t_b)` plus a finite-difference
23560    /// cross-check `(solve(A, b + h·t_b) − solve(A, b − h·t_b)) / 2h`.
23561    #[test]
23562    fn jvp_dense_solve_b_runs_and_matches_fd() {
23563        use rlx_opt::autodiff_fwd::jvp;
23564        let n = 3usize;
23565
23566        // Forward.
23567        let mut g = Graph::new("jvp_b_e2e");
23568        let a = g.input("A", Shape::new(&[n, n], DType::F64));
23569        let b = g.input("b", Shape::new(&[n], DType::F64));
23570        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
23571        g.set_outputs(vec![x]);
23572
23573        // JVP graph perturbing b only.
23574        let jg = jvp(&g, &[b]);
23575        // The JVP graph holds a fresh "tangent_b" Input on top of A and b.
23576        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
23577            for node in graph.nodes() {
23578                let name = match &node.op {
23579                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
23580                    _ => None,
23581                };
23582                if name == Some(want) {
23583                    return node.id;
23584                }
23585            }
23586            panic!("no node named {want:?}");
23587        };
23588        let a_id = find_by_name(&jg, "A");
23589        let b_id = find_by_name(&jg, "b");
23590        let tb_id = find_by_name(&jg, "tangent_b");
23591
23592        let a_data: [f64; 9] = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
23593        let b_data: [f64; 3] = [1.0, 2.0, 3.0];
23594        // Pick an arbitrary perturbation direction.
23595        let tb_data: [f64; 3] = [0.5, -0.25, 1.0];
23596
23597        let (sched, mut arena) =
23598            prepare_f64(&jg, &[(a_id, &a_data), (b_id, &b_data), (tb_id, &tb_data)]);
23599        execute_thunks(&sched, arena.raw_buf_mut());
23600
23601        // Outputs: [primal_x, tangent_x].
23602        let primal_x = read_arena_f64(&arena, jg.outputs[0], n);
23603        let tangent_x = read_arena_f64(&arena, jg.outputs[1], n);
23604
23605        // Closed form: t_x = solve(A, t_b).
23606        let t_x_ref = {
23607            let mut a = a_data;
23608            let mut tb = tb_data;
23609            let info = crate::blas::dgesv(&mut a, &mut tb, n, 1);
23610            assert_eq!(info, 0);
23611            tb
23612        };
23613        for i in 0..n {
23614            assert!(
23615                (tangent_x[i] - t_x_ref[i]).abs() < 1e-10,
23616                "t_x[{i}]: got {} want {}",
23617                tangent_x[i],
23618                t_x_ref[i]
23619            );
23620        }
23621
23622        // FD: x(b + h·tb) − x(b − h·tb)) / 2h
23623        let h = 1e-6;
23624        let mut bp = b_data;
23625        let mut bm = b_data;
23626        for i in 0..n {
23627            bp[i] += h * tb_data[i];
23628            bm[i] -= h * tb_data[i];
23629        }
23630        let xp = {
23631            let mut a = a_data;
23632            let info = crate::blas::dgesv(&mut a, &mut bp, n, 1);
23633            assert_eq!(info, 0);
23634            bp
23635        };
23636        let xm = {
23637            let mut a = a_data;
23638            let info = crate::blas::dgesv(&mut a, &mut bm, n, 1);
23639            assert_eq!(info, 0);
23640            bm
23641        };
23642        let fd: Vec<f64> = (0..n).map(|i| (xp[i] - xm[i]) / (2.0 * h)).collect();
23643        for i in 0..n {
23644            assert!(
23645                (tangent_x[i] - fd[i]).abs() < 1e-7,
23646                "FD mismatch t_x[{i}]: AD={} FD={}",
23647                tangent_x[i],
23648                fd[i]
23649            );
23650        }
23651        // Sanity: primal output is the actual solve.
23652        let primal_ref = {
23653            let mut a = a_data;
23654            let mut b = b_data;
23655            crate::blas::dgesv(&mut a, &mut b, n, 1);
23656            b
23657        };
23658        for i in 0..n {
23659            assert!((primal_x[i] - primal_ref[i]).abs() < 1e-10);
23660        }
23661    }
23662
23663    /// Forward-mode JVP through DenseSolve perturbing A. The tangent
23664    /// path includes the −t_A·x correction term.
23665    /// `t_x = −solve(A, t_A · x)` should match a finite-difference
23666    /// directional derivative of `solve(A, b)` w.r.t. A in the
23667    /// `t_A` direction.
23668    #[test]
23669    fn jvp_dense_solve_a_runs_and_matches_fd() {
23670        use rlx_opt::autodiff_fwd::jvp;
23671        let n = 3usize;
23672
23673        let mut g = Graph::new("jvp_a_e2e");
23674        let a = g.input("A", Shape::new(&[n, n], DType::F64));
23675        let b = g.input("b", Shape::new(&[n], DType::F64));
23676        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
23677        g.set_outputs(vec![x]);
23678
23679        let jg = jvp(&g, &[a]);
23680        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
23681            for node in graph.nodes() {
23682                let name = match &node.op {
23683                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
23684                    _ => None,
23685                };
23686                if name == Some(want) {
23687                    return node.id;
23688                }
23689            }
23690            panic!("no node named {want:?}");
23691        };
23692        let a_id = find_by_name(&jg, "A");
23693        let b_id = find_by_name(&jg, "b");
23694        let ta_id = find_by_name(&jg, "tangent_A");
23695
23696        let a_data: [f64; 9] = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
23697        let b_data: [f64; 3] = [1.0, 2.0, 3.0];
23698        // Asymmetric perturbation direction for A.
23699        let ta_data: [f64; 9] = [0.10, -0.05, 0.02, 0.03, 0.20, -0.04, -0.01, 0.07, 0.15];
23700
23701        let (sched, mut arena) =
23702            prepare_f64(&jg, &[(a_id, &a_data), (b_id, &b_data), (ta_id, &ta_data)]);
23703        execute_thunks(&sched, arena.raw_buf_mut());
23704
23705        let tangent_x = read_arena_f64(&arena, jg.outputs[1], n);
23706
23707        // Closed form: x = solve(A, b); t_x = −solve(A, t_A · x).
23708        let x_ref = {
23709            let mut a = a_data;
23710            let mut b = b_data;
23711            crate::blas::dgesv(&mut a, &mut b, n, 1);
23712            b
23713        };
23714        let mut prod = [0.0_f64; 3];
23715        for i in 0..n {
23716            for j in 0..n {
23717                prod[i] += ta_data[i * n + j] * x_ref[j];
23718            }
23719        }
23720        let t_x_ref = {
23721            let mut a = a_data;
23722            let mut p = prod;
23723            crate::blas::dgesv(&mut a, &mut p, n, 1);
23724            [-p[0], -p[1], -p[2]]
23725        };
23726        for i in 0..n {
23727            assert!(
23728                (tangent_x[i] - t_x_ref[i]).abs() < 1e-10,
23729                "closed-form t_x[{i}]: AD={} ref={}",
23730                tangent_x[i],
23731                t_x_ref[i]
23732            );
23733        }
23734
23735        // FD: solve(A + h·t_A, b) and solve(A − h·t_A, b).
23736        let h = 1e-6;
23737        let mut ap = a_data;
23738        let mut am = a_data;
23739        for i in 0..n * n {
23740            ap[i] += h * ta_data[i];
23741            am[i] -= h * ta_data[i];
23742        }
23743        let xp = {
23744            let mut a = ap;
23745            let mut b = b_data;
23746            crate::blas::dgesv(&mut a, &mut b, n, 1);
23747            b
23748        };
23749        let xm = {
23750            let mut a = am;
23751            let mut b = b_data;
23752            crate::blas::dgesv(&mut a, &mut b, n, 1);
23753            b
23754        };
23755        for i in 0..n {
23756            let fd = (xp[i] - xm[i]) / (2.0 * h);
23757            assert!(
23758                (tangent_x[i] - fd).abs() < 1e-7,
23759                "FD t_x[{i}]: AD={} FD={}",
23760                tangent_x[i],
23761                fd
23762            );
23763        }
23764    }
23765
23766    /// Real INT8 conv2d parity. Same setup as QMatMul: pre-quantize
23767    /// f32 inputs to i8, run `Op::QConv2d`, compare against an
23768    /// in-test reference loop that does the same i32 accumulation
23769    /// and requantize math. Symmetric quant (zp=0) to keep the math
23770    /// head-to-head.
23771    #[test]
23772    fn q_conv2d_matches_reference() {
23773        use rlx_ir::Philox4x32;
23774        // Small NCHW shape — enough to exercise stride/padding edges.
23775        let n = 1usize;
23776        let c_in = 2usize;
23777        let h = 5usize;
23778        let w_in = 5usize;
23779        let c_out = 3usize;
23780        let kh = 3usize;
23781        let kw = 3usize;
23782        let ph = 1usize;
23783        let pw = 1usize;
23784        let sh = 1usize;
23785        let sw = 1usize;
23786        let h_out = (h + 2 * ph - kh) / sh + 1;
23787        let w_out = (w_in + 2 * pw - kw) / sw + 1;
23788
23789        let x_scale = 0.04f32;
23790        let w_scale = 0.02f32;
23791        let out_scale = 0.5f32;
23792        let mult = x_scale * w_scale / out_scale;
23793
23794        let mut rng = Philox4x32::new(2099);
23795        let mut xf = vec![0f32; n * c_in * h * w_in];
23796        rng.fill_normal(&mut xf);
23797        let mut wf = vec![0f32; c_out * c_in * kh * kw];
23798        rng.fill_normal(&mut wf);
23799        let xq: Vec<i8> = xf
23800            .iter()
23801            .map(|&v| ((v / x_scale).round() as i32).clamp(-128, 127) as i8)
23802            .collect();
23803        let wq: Vec<i8> = wf
23804            .iter()
23805            .map(|&v| ((v / w_scale).round() as i32).clamp(-128, 127) as i8)
23806            .collect();
23807        let bias: Vec<i32> = vec![0i32; c_out];
23808
23809        let mut g = Graph::new("qconv");
23810        let xn = g.input("x", Shape::new(&[n, c_in, h, w_in], DType::I8));
23811        let wn = g.input("w", Shape::new(&[c_out, c_in, kh, kw], DType::I8));
23812        let bn = g.input("b", Shape::new(&[c_out], DType::I32));
23813        let out = g.q_conv2d(
23814            xn,
23815            wn,
23816            bn,
23817            vec![kh, kw],
23818            vec![sh, sw],
23819            vec![ph, pw],
23820            vec![1, 1],
23821            1,
23822            0,
23823            0,
23824            0,
23825            mult,
23826            Shape::new(&[n, c_out, h_out, w_out], DType::I8),
23827        );
23828        g.set_outputs(vec![out]);
23829
23830        let plan = rlx_opt::memory::plan_memory(&g);
23831        let mut arena = crate::arena::Arena::from_plan(plan);
23832        let sched = compile_thunks(&g, &arena);
23833        // Capture offsets before borrowing the buf mutably (avoids
23834        // overlap between &mut and the &arena.byte_offset reads).
23835        let xn_off = arena.byte_offset(xn);
23836        let wn_off = arena.byte_offset(wn);
23837        let bn_off = arena.byte_offset(bn);
23838        let out_off = arena.byte_offset(out);
23839        let buf = arena.raw_buf_mut();
23840        unsafe {
23841            let p = buf.as_mut_ptr().add(xn_off) as *mut i8;
23842            for (i, &v) in xq.iter().enumerate() {
23843                *p.add(i) = v;
23844            }
23845            let p = buf.as_mut_ptr().add(wn_off) as *mut i8;
23846            for (i, &v) in wq.iter().enumerate() {
23847                *p.add(i) = v;
23848            }
23849            let p = buf.as_mut_ptr().add(bn_off) as *mut i32;
23850            for (i, &v) in bias.iter().enumerate() {
23851                *p.add(i) = v;
23852            }
23853        }
23854        execute_thunks(&sched, arena.raw_buf_mut());
23855        let out_q: Vec<i8> = unsafe {
23856            let p = arena.raw_buf().as_ptr().add(out_off) as *const i8;
23857            (0..n * c_out * h_out * w_out).map(|i| *p.add(i)).collect()
23858        };
23859
23860        // Reference: scalar loop in NCHW with the same requantize.
23861        let mut out_ref = vec![0i8; n * c_out * h_out * w_out];
23862        for ni in 0..n {
23863            for co in 0..c_out {
23864                for ho in 0..h_out {
23865                    for wo in 0..w_out {
23866                        let mut acc: i32 = 0;
23867                        for ci in 0..c_in {
23868                            for ki in 0..kh {
23869                                for kj in 0..kw {
23870                                    let hi = ho * sh + ki;
23871                                    let wi = wo * sw + kj;
23872                                    if hi < ph || wi < pw {
23873                                        continue;
23874                                    }
23875                                    let hi = hi - ph;
23876                                    let wi = wi - pw;
23877                                    if hi >= h || wi >= w_in {
23878                                        continue;
23879                                    }
23880                                    let xv =
23881                                        xq[((ni * c_in) + ci) * h * w_in + hi * w_in + wi] as i32;
23882                                    let wv = wq[((co * c_in) + ci) * kh * kw + ki * kw + kj] as i32;
23883                                    acc += xv * wv;
23884                                }
23885                            }
23886                        }
23887                        let r = (acc as f32 * mult).round() as i32;
23888                        let r = r.clamp(-128, 127) as i8;
23889                        out_ref[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = r;
23890                    }
23891                }
23892            }
23893        }
23894
23895        for (i, (a, r)) in out_q.iter().zip(&out_ref).enumerate() {
23896            assert_eq!(a, r, "q_conv2d[{i}]: kernel {a} vs reference {r}");
23897        }
23898    }
23899
23900    /// Real INT8 matmul parity: compare `Op::QMatMul` against the
23901    /// fake-quant reference `Dequantize → MatMul → Quantize` that
23902    /// would produce the same output if we round-tripped through
23903    /// f32. Both should agree element-for-element (or within ±1 i8
23904    /// step, since rounding in the requantize uses different code
23905    /// paths). Symmetric quantization (zp=0) for both paths to keep
23906    /// the math head-to-head.
23907    #[test]
23908    fn q_matmul_matches_fake_quant_reference() {
23909        use rlx_ir::Philox4x32;
23910        let m = 3usize;
23911        let k = 8usize;
23912        let n = 5usize;
23913        let mut rng = Philox4x32::new(2031);
23914
23915        // Pick scales and quantize random f32 inputs to i8.
23916        let x_scale = 0.05f32;
23917        let w_scale = 0.03f32;
23918        let out_scale = 0.4f32;
23919        let mult = x_scale * w_scale / out_scale;
23920        let mut xf = vec![0f32; m * k];
23921        rng.fill_normal(&mut xf);
23922        let mut wf = vec![0f32; k * n];
23923        rng.fill_normal(&mut wf);
23924        let xq: Vec<i8> = xf
23925            .iter()
23926            .map(|&v| ((v / x_scale).round() as i32).clamp(-128, 127) as i8)
23927            .collect();
23928        let wq: Vec<i8> = wf
23929            .iter()
23930            .map(|&v| ((v / w_scale).round() as i32).clamp(-128, 127) as i8)
23931            .collect();
23932        let bias: Vec<i32> = vec![0i32; n];
23933
23934        // ── Direct INT8 path ──
23935        let _f = DType::F32;
23936        let mut g_q = Graph::new("qmm_direct");
23937        let xn = g_q.input("x", Shape::new(&[m, k], DType::I8));
23938        let wn = g_q.input("w", Shape::new(&[k, n], DType::I8));
23939        let bn = g_q.input("b", Shape::new(&[n], DType::I32));
23940        let out = g_q.q_matmul(xn, wn, bn, 0, 0, 0, mult, Shape::new(&[m, n], DType::I8));
23941        g_q.set_outputs(vec![out]);
23942        let plan = rlx_opt::memory::plan_memory(&g_q);
23943        let mut arena = crate::arena::Arena::from_plan(plan);
23944        let sched = compile_thunks(&g_q, &arena);
23945
23946        // Fill inputs.
23947        let xn_off = arena.byte_offset(xn);
23948        let wn_off = arena.byte_offset(wn);
23949        let bn_off = arena.byte_offset(bn);
23950        let out_off = arena.byte_offset(out);
23951        let buf = arena.raw_buf_mut();
23952        unsafe {
23953            let p = buf.as_mut_ptr().add(xn_off) as *mut i8;
23954            for (i, &v) in xq.iter().enumerate() {
23955                *p.add(i) = v;
23956            }
23957            let p = buf.as_mut_ptr().add(wn_off) as *mut i8;
23958            for (i, &v) in wq.iter().enumerate() {
23959                *p.add(i) = v;
23960            }
23961            let p = buf.as_mut_ptr().add(bn_off) as *mut i32;
23962            for (i, &v) in bias.iter().enumerate() {
23963                *p.add(i) = v;
23964            }
23965        }
23966        execute_thunks(&sched, arena.raw_buf_mut());
23967        let out_q: Vec<i8> = unsafe {
23968            let p = arena.raw_buf().as_ptr().add(out_off) as *const i8;
23969            (0..m * n).map(|i| *p.add(i)).collect()
23970        };
23971
23972        // ── Fake-quant reference: scalar emulation in plain Rust ──
23973        // Same arithmetic the kernel does, but in a verifier loop:
23974        //   acc = Σ (x[m,k]) · (w[k,n]),  // zps are 0
23975        //   out[m,n] = saturate_i8(round(acc · mult) + 0)
23976        let mut out_ref = vec![0i8; m * n];
23977        for mi in 0..m {
23978            for ni in 0..n {
23979                let mut acc: i32 = 0;
23980                for ki in 0..k {
23981                    acc += (xq[mi * k + ki] as i32) * (wq[ki * n + ni] as i32);
23982                }
23983                let r = (acc as f32 * mult).round() as i32;
23984                out_ref[mi * n + ni] = r.clamp(-128, 127) as i8;
23985            }
23986        }
23987
23988        for (i, (a, r)) in out_q.iter().zip(&out_ref).enumerate() {
23989            assert_eq!(a, r, "q_matmul[{i}]: kernel {a} vs reference {r}");
23990        }
23991    }
23992
23993    /// Quantize/Dequantize round-trip — quantize an f32 tensor, then
23994    /// dequantize back, and confirm the result tracks the input
23995    /// within the per-element scale (the inevitable rounding error).
23996    /// Also pins the kernel's saturation behavior at the i8 limits.
23997    #[test]
23998    fn quantize_dequantize_round_trip() {
23999        use rlx_ir::Philox4x32;
24000        let len = 64;
24001        let mut rng = Philox4x32::new(2027);
24002        let mut x = vec![0f32; len];
24003        rng.fill_normal(&mut x);
24004        // Stretch a couple values past the +/- saturation cliff so
24005        // the saturate_i8 path is exercised.
24006        x[0] = 999.0;
24007        x[1] = -999.0;
24008
24009        let scale = 0.05f32;
24010        let zp = 3i32;
24011
24012        let f = DType::F32;
24013        let mut g = Graph::new("qdq");
24014        let xn = g.input("x", Shape::new(&[len], f));
24015        let q = g.quantize(xn, scale, zp);
24016        let dq = g.dequantize(q, scale, zp);
24017        g.set_outputs(vec![dq]);
24018
24019        let plan = rlx_opt::memory::plan_memory(&g);
24020        let mut arena = crate::arena::Arena::from_plan(plan);
24021        let sched = compile_thunks(&g, &arena);
24022        let xn_off = arena.byte_offset(xn);
24023        let dq_off = arena.byte_offset(dq);
24024        let buf = arena.raw_buf_mut();
24025        unsafe {
24026            let p = buf.as_mut_ptr().add(xn_off) as *mut f32;
24027            for (i, &v) in x.iter().enumerate() {
24028                *p.add(i) = v;
24029            }
24030        }
24031        execute_thunks(&sched, arena.raw_buf_mut());
24032        let out: Vec<f32> = unsafe {
24033            let p = arena.raw_buf().as_ptr().add(dq_off) as *const f32;
24034            (0..len).map(|i| *p.add(i)).collect()
24035        };
24036
24037        // Saturated values at i=0,1 should clamp to ±127's dequant
24038        // range (= (±127 - zp) · scale).
24039        let sat_pos = (127 - zp) as f32 * scale;
24040        let sat_neg = (-128 - zp) as f32 * scale;
24041        assert!((out[0] - sat_pos).abs() < 1e-6, "+sat: {}", out[0]);
24042        assert!((out[1] - sat_neg).abs() < 1e-6, "-sat: {}", out[1]);
24043
24044        // Everything else should round-trip within `scale` (one quant
24045        // step = the worst-case rounding error).
24046        for i in 2..len {
24047            assert!(
24048                (out[i] - x[i]).abs() <= scale + 1e-5,
24049                "qdq[{i}]: {} → {}, scale={scale}",
24050                x[i],
24051                out[i]
24052            );
24053        }
24054    }
24055
24056    /// Per-channel quantize / dequantize: independent scale and zp
24057    /// per slice along an axis. Verifies (a) each channel uses its
24058    /// own scale (not a shared one), (b) saturation still respects
24059    /// the i8 range, (c) channel data layout decomposition is
24060    /// correct (no cross-channel leakage).
24061    #[test]
24062    fn quantize_per_channel_round_trip() {
24063        let c = 4usize;
24064        let inner = 5usize;
24065        // Different magnitudes per channel — proves the per-channel
24066        // scale is actually being read for each row.
24067        let mags = [0.01f32, 0.5, 5.0, 50.0];
24068        let mut x = vec![0f32; c * inner];
24069        for ci in 0..c {
24070            for ii in 0..inner {
24071                // Sweep through values that span [-max_abs, +max_abs]
24072                // for each channel, plus one value past the cliff to
24073                // trigger saturation.
24074                x[ci * inner + ii] = match ii {
24075                    0 => -mags[ci],
24076                    1 => 0.0,
24077                    2 => mags[ci],
24078                    3 => mags[ci] * 1000.0,  // saturates +
24079                    _ => -mags[ci] * 1000.0, // saturates -
24080                };
24081            }
24082        }
24083        let scales: Vec<f32> = mags.iter().map(|&m| m / 127.0).collect();
24084        let zps: Vec<i32> = vec![0, 0, 0, 0];
24085
24086        let f = DType::F32;
24087        let mut g = Graph::new("qdq_pc");
24088        let xn = g.input("x", Shape::new(&[c, inner], f));
24089        let q = g.quantize_per_channel(xn, 0, scales.clone(), zps.clone());
24090        let dq = g.dequantize_per_channel(q, 0, scales.clone(), zps);
24091        g.set_outputs(vec![dq]);
24092
24093        let plan = rlx_opt::memory::plan_memory(&g);
24094        let mut arena = crate::arena::Arena::from_plan(plan);
24095        let sched = compile_thunks(&g, &arena);
24096        let xn_off = arena.byte_offset(xn);
24097        let dq_off = arena.byte_offset(dq);
24098        let buf = arena.raw_buf_mut();
24099        unsafe {
24100            let p = buf.as_mut_ptr().add(xn_off) as *mut f32;
24101            for (i, &v) in x.iter().enumerate() {
24102                *p.add(i) = v;
24103            }
24104        }
24105        execute_thunks(&sched, arena.raw_buf_mut());
24106        let out: Vec<f32> = unsafe {
24107            let p = arena.raw_buf().as_ptr().add(dq_off) as *const f32;
24108            (0..c * inner).map(|i| *p.add(i)).collect()
24109        };
24110
24111        for ci in 0..c {
24112            // Within-range entries (positions 0, 1, 2) must round-trip
24113            // within one quant step of *that channel's* scale.
24114            for ii in 0..3 {
24115                let idx = ci * inner + ii;
24116                assert!(
24117                    (out[idx] - x[idx]).abs() <= scales[ci] + 1e-5,
24118                    "ch {ci} idx {ii}: {} vs {}",
24119                    x[idx],
24120                    out[idx]
24121                );
24122            }
24123            // Saturated positions clamp to ±127 · scale[ci].
24124            let sat_pos = 127.0 * scales[ci];
24125            let sat_neg = -128.0 * scales[ci];
24126            assert!(
24127                (out[ci * inner + 3] - sat_pos).abs() < 1e-5,
24128                "ch {ci} +sat: {}",
24129                out[ci * inner + 3]
24130            );
24131            assert!(
24132                (out[ci * inner + 4] - sat_neg).abs() < 1e-5,
24133                "ch {ci} -sat: {}",
24134                out[ci * inner + 4]
24135            );
24136        }
24137    }
24138
24139    /// `Op::ActivationBackward` parity for every supported kind.
24140    /// Builds a single-op graph `dx = activation_backward(x, dy)` and
24141    /// compares each `dx[i]` to the central-difference `(act(x+ε) -
24142    /// act(x-ε)) / (2ε) · dy\[i\]`. Sweeps the closed-form covered by
24143    /// the kernel.
24144    #[test]
24145    fn activation_backward_matches_numerical_per_kind() {
24146        use rlx_ir::Philox4x32;
24147        use rlx_ir::op::Activation;
24148        let mut rng = Philox4x32::new(91);
24149        let len = 32;
24150        // x sampled away from kink/branch points: shifted positive
24151        // (exp/sqrt/log domain) for the unary-positive activations;
24152        // wide range otherwise. Two parallel tests would be cleaner
24153        // but this is concise enough.
24154        let mut x_pos = vec![0f32; len];
24155        rng.fill_normal(&mut x_pos);
24156        for v in x_pos.iter_mut() {
24157            *v = v.abs() + 0.5;
24158        }
24159        let mut x_any = vec![0f32; len];
24160        rng.fill_normal(&mut x_any);
24161        let mut dy = vec![0f32; len];
24162        rng.fill_normal(&mut dy);
24163
24164        for &(kind, x_data, eps, tol) in &[
24165            (Activation::Sigmoid, &x_any[..], 1e-3, 5e-3),
24166            (Activation::Tanh, &x_any[..], 1e-3, 5e-3),
24167            (Activation::Silu, &x_any[..], 1e-3, 5e-3),
24168            (Activation::Gelu, &x_any[..], 1e-3, 5e-3),
24169            (Activation::GeluApprox, &x_any[..], 1e-3, 5e-3),
24170            (Activation::Exp, &x_any[..], 1e-4, 5e-3),
24171            (Activation::Log, &x_pos[..], 1e-4, 5e-3),
24172            (Activation::Sqrt, &x_pos[..], 1e-4, 5e-3),
24173            (Activation::Rsqrt, &x_pos[..], 1e-4, 5e-3),
24174            (Activation::Neg, &x_any[..], 1e-3, 5e-4),
24175        ] {
24176            let f = DType::F32;
24177            let mut g = Graph::new("act_bw");
24178            let xn = g.input("x", Shape::new(&[len], f));
24179            let dyn_ = g.input("dy", Shape::new(&[len], f));
24180            let dx = g.activation_backward(kind, xn, dyn_);
24181            g.set_outputs(vec![dx]);
24182
24183            let plan = rlx_opt::memory::plan_memory(&g);
24184            let mut arena = crate::arena::Arena::from_plan(plan);
24185            let sched = compile_thunks(&g, &arena);
24186
24187            let xn_off = arena.byte_offset(xn);
24188            let dyn_off = arena.byte_offset(dyn_);
24189            let dx_off = arena.byte_offset(dx);
24190            let buf = arena.raw_buf_mut();
24191            unsafe {
24192                let p = buf.as_mut_ptr().add(xn_off) as *mut f32;
24193                for (i, &v) in x_data.iter().enumerate() {
24194                    *p.add(i) = v;
24195                }
24196                let p = buf.as_mut_ptr().add(dyn_off) as *mut f32;
24197                for (i, &v) in dy.iter().enumerate() {
24198                    *p.add(i) = v;
24199                }
24200            }
24201            execute_thunks(&sched, arena.raw_buf_mut());
24202            let analytical: Vec<f32> = unsafe {
24203                let p = arena.raw_buf().as_ptr().add(dx_off) as *const f32;
24204                (0..len).map(|i| *p.add(i)).collect()
24205            };
24206
24207            // Apply the forward activation manually; finite-difference
24208            // each element.
24209            let act_apply = |kind: Activation, x: f32| -> f32 {
24210                match kind {
24211                    Activation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
24212                    Activation::Tanh => x.tanh(),
24213                    Activation::Silu => x / (1.0 + (-x).exp()),
24214                    Activation::Gelu => {
24215                        // Match the kernel's exact erf form.
24216                        const INV_SQRT2: f32 = 0.707_106_77;
24217                        0.5 * x * (1.0 + erf_f32(x * INV_SQRT2))
24218                    }
24219                    Activation::GeluApprox => {
24220                        const C: f32 = 0.797_884_6;
24221                        const A: f32 = 0.044_715;
24222                        let inner = C * (x + A * x * x * x);
24223                        0.5 * x * (1.0 + inner.tanh())
24224                    }
24225                    Activation::Exp => x.exp(),
24226                    Activation::Log => x.ln(),
24227                    Activation::Sqrt => x.sqrt(),
24228                    Activation::Rsqrt => 1.0 / x.sqrt(),
24229                    Activation::Neg => -x,
24230                    Activation::Relu => x.max(0.0),
24231                    Activation::Abs => x.abs(),
24232                    Activation::Round => x.round(),
24233                    Activation::Sin => x.sin(),
24234                    Activation::Cos => x.cos(),
24235                    Activation::Tan => x.tan(),
24236                    Activation::Atan => x.atan(),
24237                }
24238            };
24239            for i in 0..len {
24240                let xv = x_data[i];
24241                let plus = act_apply(kind, xv + eps);
24242                let minus = act_apply(kind, xv - eps);
24243                let num = (plus - minus) / (2.0 * eps) * dy[i];
24244                assert!(
24245                    (analytical[i] - num).abs() < tol,
24246                    "{kind:?}[{i}]: analytical {} vs numerical {num}",
24247                    analytical[i]
24248                );
24249            }
24250        }
24251    }
24252
24253    /// Batched 3-D MatMul VJP — the transformer-attention shape
24254    /// `[B, M, K] @ [B, K, N] = [B, M, N]`. Both gradients flow through
24255    /// `Op::Transpose` with a perm that swaps the last two dims.
24256    #[test]
24257    fn matmul_3d_gradient_matches_numerical() {
24258        use rlx_ir::Philox4x32;
24259        let batch = 2usize;
24260        let m = 3usize;
24261        let k = 4usize;
24262        let n = 5usize;
24263        let mut rng = Philox4x32::new(101);
24264        let mut a_data = vec![0f32; batch * m * k];
24265        rng.fill_normal(&mut a_data);
24266        let mut b_data = vec![0f32; batch * k * n];
24267        rng.fill_normal(&mut b_data);
24268
24269        let f = DType::F32;
24270        let mut fwd = Graph::new("matmul_3d");
24271        let an = fwd.input("a", Shape::new(&[batch, m, k], f));
24272        let bp = fwd.param("b", Shape::new(&[batch, k, n], f));
24273        let mm = fwd.matmul(an, bp, Shape::new(&[batch, m, n], f));
24274        let loss = fwd.add_node(
24275            Op::Reduce {
24276                op: ReduceOp::Sum,
24277                axes: vec![0, 1, 2],
24278                keep_dim: false,
24279            },
24280            vec![mm],
24281            Shape::from_dims(&[], f),
24282        );
24283        fwd.set_outputs(vec![loss]);
24284
24285        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[bp]);
24286        let d_out = bwd_graph
24287            .nodes()
24288            .iter()
24289            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
24290            .map(|n| n.id)
24291            .unwrap();
24292
24293        let plan = rlx_opt::memory::plan_memory(&bwd_graph);
24294        let mut arena = crate::arena::Arena::from_plan(plan);
24295        let sched = compile_thunks(&bwd_graph, &arena);
24296        for &(id, data) in &[(an, &a_data), (bp, &b_data), (d_out, &vec![1.0f32])] {
24297            let off = arena.byte_offset(id);
24298            let buf = arena.raw_buf_mut();
24299            unsafe {
24300                let p = buf.as_mut_ptr().add(off) as *mut f32;
24301                for (i, &v) in data.iter().enumerate() {
24302                    *p.add(i) = v;
24303                }
24304            }
24305        }
24306        execute_thunks(&sched, arena.raw_buf_mut());
24307        let gb_id = bwd_graph.outputs[1];
24308        let g_b: Vec<f32> = unsafe {
24309            let p = arena.raw_buf().as_ptr().add(arena.byte_offset(gb_id)) as *const f32;
24310            (0..batch * k * n).map(|i| *p.add(i)).collect()
24311        };
24312
24313        // Numerical gradient: differentiate sum(a @ b) w.r.t. each b entry.
24314        let forward_loss = |b_vals: &[f32]| -> f32 {
24315            let mut out = vec![0f32; batch * m * n];
24316            for bi in 0..batch {
24317                for mi in 0..m {
24318                    for ni in 0..n {
24319                        let mut acc = 0f32;
24320                        for ki in 0..k {
24321                            acc +=
24322                                a_data[bi * m * k + mi * k + ki] * b_vals[bi * k * n + ki * n + ni];
24323                        }
24324                        out[bi * m * n + mi * n + ni] = acc;
24325                    }
24326                }
24327            }
24328            out.iter().sum()
24329        };
24330        let eps = 1e-3f32;
24331        let mut bp_p = b_data.clone();
24332        let mut g_b_num = vec![0f32; b_data.len()];
24333        for i in 0..b_data.len() {
24334            let s = bp_p[i];
24335            bp_p[i] = s + eps;
24336            let lp = forward_loss(&bp_p);
24337            bp_p[i] = s - eps;
24338            let lm = forward_loss(&bp_p);
24339            bp_p[i] = s;
24340            g_b_num[i] = (lp - lm) / (2.0 * eps);
24341        }
24342        for (i, (a, n)) in g_b.iter().zip(&g_b_num).enumerate() {
24343            assert!(
24344                (a - n).abs() < 5e-3,
24345                "matmul_3d g_b[{i}]: analytical {a} vs numerical {n}"
24346            );
24347        }
24348    }
24349
24350    /// Composed `Op::Softmax` VJP — the gradient is built from
24351    /// `mul + reduce_sum + expand + sub + mul`, no dedicated
24352    /// SoftmaxBackward kernel. Verifies the closed-form
24353    /// `dx = y · (g - Σ y·g)` matches the FD gradient over a small
24354    /// 2-D logits tensor.
24355    #[test]
24356    fn softmax_gradient_matches_numerical() {
24357        use rlx_ir::Philox4x32;
24358        let n = 3usize;
24359        let c = 5usize;
24360        let mut rng = Philox4x32::new(57);
24361        let mut x_data = vec![0f32; n * c];
24362        rng.fill_normal(&mut x_data);
24363
24364        let f = DType::F32;
24365        let mut fwd = Graph::new("softmax_only");
24366        let xn = fwd.input("x", Shape::new(&[n, c], f));
24367        let sm = fwd.add_node(Op::Softmax { axis: -1 }, vec![xn], Shape::new(&[n, c], f));
24368        // Loss = sum(softmax · target) for some random fixed target —
24369        // any linear loss will do; sum-of-all is the simplest and gives
24370        // a uniform gradient flow into the softmax.
24371        let loss = fwd.add_node(
24372            Op::Reduce {
24373                op: ReduceOp::Sum,
24374                axes: vec![0, 1],
24375                keep_dim: false,
24376            },
24377            vec![sm],
24378            Shape::from_dims(&[], f),
24379        );
24380        fwd.set_outputs(vec![loss]);
24381
24382        // `wrt = [xn]` — autodiff exposes the gradient w.r.t. the
24383        // input so we can compare it directly. The forward NodeId for
24384        // `xn` doubles as its bwd-graph mirror.
24385        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[xn]);
24386        let d_out = bwd_graph
24387            .nodes()
24388            .iter()
24389            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
24390            .map(|n| n.id)
24391            .unwrap();
24392
24393        let plan = rlx_opt::memory::plan_memory(&bwd_graph);
24394        let mut arena = crate::arena::Arena::from_plan(plan);
24395        let sched = compile_thunks(&bwd_graph, &arena);
24396        for &(id, data) in &[(xn, &x_data), (d_out, &vec![1.0f32])] {
24397            let off = arena.byte_offset(id);
24398            let buf = arena.raw_buf_mut();
24399            unsafe {
24400                let p = buf.as_mut_ptr().add(off) as *mut f32;
24401                for (i, &v) in data.iter().enumerate() {
24402                    *p.add(i) = v;
24403                }
24404            }
24405        }
24406        execute_thunks(&sched, arena.raw_buf_mut());
24407        let g_x_id = bwd_graph.outputs[1];
24408        let g_x: Vec<f32> = unsafe {
24409            let p = arena.raw_buf().as_ptr().add(arena.byte_offset(g_x_id)) as *const f32;
24410            (0..n * c).map(|i| *p.add(i)).collect()
24411        };
24412
24413        // Loss derivative: softmax sums to 1 per row → d/dx_i sum(softmax) = 0
24414        // analytically. So expect g_x ≈ 0 within FD precision. (This
24415        // doubles as a strong sanity check for the composition.)
24416        let forward_loss = |x: &[f32]| -> f32 {
24417            let mut total = 0f32;
24418            for ni in 0..n {
24419                let row = &x[ni * c..(ni + 1) * c];
24420                let m = row.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
24421                let denom: f32 = row.iter().map(|&v| (v - m).exp()).sum();
24422                for &v in row {
24423                    total += (v - m).exp() / denom;
24424                }
24425            }
24426            total
24427        };
24428        let eps = 1e-3f32;
24429        let mut p = x_data.clone();
24430        for i in 0..x_data.len() {
24431            let s = p[i];
24432            p[i] = s + eps;
24433            let lp = forward_loss(&p);
24434            p[i] = s - eps;
24435            let lm = forward_loss(&p);
24436            p[i] = s;
24437            let num = (lp - lm) / (2.0 * eps);
24438            assert!(
24439                (g_x[i] - num).abs() < 5e-3,
24440                "softmax g_x[{i}]: analytical {} vs numerical {num}",
24441                g_x[i]
24442            );
24443        }
24444    }
24445
24446    /// LayerNorm VJP — three gradients in one pass:
24447    ///   d_x via `LayerNormBackwardInput`,
24448    ///   d_gamma via `LayerNormBackwardGamma`,
24449    ///   d_beta = `unbroadcast(upstream)` to gamma's shape.
24450    #[test]
24451    fn layer_norm_gradient_matches_numerical() {
24452        use rlx_ir::Philox4x32;
24453        let rows = 3usize;
24454        let h = 6usize;
24455        let mut rng = Philox4x32::new(1009);
24456        let mut x_data = vec![0f32; rows * h];
24457        rng.fill_normal(&mut x_data);
24458        let mut g_data = vec![0f32; h];
24459        rng.fill_normal(&mut g_data);
24460        for v in g_data.iter_mut() {
24461            *v = v.abs() + 0.5;
24462        }
24463        let mut b_data = vec![0f32; h];
24464        rng.fill_normal(&mut b_data);
24465        let eps = 1e-5f32;
24466
24467        let f = DType::F32;
24468        let mut fwd = Graph::new("ln_only");
24469        let xn = fwd.input("x", Shape::new(&[rows, h], f));
24470        let gp = fwd.param("gamma", Shape::new(&[h], f));
24471        let bp = fwd.param("beta", Shape::new(&[h], f));
24472        let ln = fwd.add_node(
24473            Op::LayerNorm { axis: -1, eps },
24474            vec![xn, gp, bp],
24475            Shape::new(&[rows, h], f),
24476        );
24477        let loss = fwd.add_node(
24478            Op::Reduce {
24479                op: ReduceOp::Sum,
24480                axes: vec![0, 1],
24481                keep_dim: false,
24482            },
24483            vec![ln],
24484            Shape::from_dims(&[], f),
24485        );
24486        fwd.set_outputs(vec![loss]);
24487
24488        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[xn, gp, bp]);
24489        let d_out = bwd_graph
24490            .nodes()
24491            .iter()
24492            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
24493            .map(|n| n.id)
24494            .unwrap();
24495
24496        let plan = rlx_opt::memory::plan_memory(&bwd_graph);
24497        let mut arena = crate::arena::Arena::from_plan(plan);
24498        let sched = compile_thunks(&bwd_graph, &arena);
24499        for &(id, data) in &[
24500            (xn, &x_data),
24501            (gp, &g_data),
24502            (bp, &b_data),
24503            (d_out, &vec![1.0f32]),
24504        ] {
24505            let off = arena.byte_offset(id);
24506            let buf = arena.raw_buf_mut();
24507            unsafe {
24508                let p = buf.as_mut_ptr().add(off) as *mut f32;
24509                for (i, &v) in data.iter().enumerate() {
24510                    *p.add(i) = v;
24511                }
24512            }
24513        }
24514        execute_thunks(&sched, arena.raw_buf_mut());
24515        let read = |id: NodeId, n: usize| -> Vec<f32> {
24516            let off = arena.byte_offset(id);
24517            unsafe {
24518                let p = arena.raw_buf().as_ptr().add(off) as *const f32;
24519                (0..n).map(|i| *p.add(i)).collect()
24520            }
24521        };
24522        let dx_a = read(bwd_graph.outputs[1], rows * h);
24523        let dg_a = read(bwd_graph.outputs[2], h);
24524        let db_a = read(bwd_graph.outputs[3], h);
24525
24526        let forward_loss = |x: &[f32], g: &[f32], b: &[f32]| -> f32 {
24527            let mut total = 0f32;
24528            for r in 0..rows {
24529                let row = &x[r * h..(r + 1) * h];
24530                let mean = row.iter().sum::<f32>() / h as f32;
24531                let var = row.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / h as f32;
24532                let inv_std = 1.0 / (var + eps).sqrt();
24533                for d in 0..h {
24534                    total += ((row[d] - mean) * inv_std) * g[d] + b[d];
24535                }
24536            }
24537            total
24538        };
24539        let h_eps = 1e-3f32;
24540
24541        let mut x_p = x_data.clone();
24542        for i in 0..x_p.len() {
24543            let s = x_p[i];
24544            x_p[i] = s + h_eps;
24545            let lp = forward_loss(&x_p, &g_data, &b_data);
24546            x_p[i] = s - h_eps;
24547            let lm = forward_loss(&x_p, &g_data, &b_data);
24548            x_p[i] = s;
24549            let num = (lp - lm) / (2.0 * h_eps);
24550            assert!(
24551                (dx_a[i] - num).abs() < 5e-3,
24552                "ln dx[{i}]: analytical {} vs numerical {num}",
24553                dx_a[i]
24554            );
24555        }
24556        let mut g_p = g_data.clone();
24557        for i in 0..g_p.len() {
24558            let s = g_p[i];
24559            g_p[i] = s + h_eps;
24560            let lp = forward_loss(&x_data, &g_p, &b_data);
24561            g_p[i] = s - h_eps;
24562            let lm = forward_loss(&x_data, &g_p, &b_data);
24563            g_p[i] = s;
24564            let num = (lp - lm) / (2.0 * h_eps);
24565            assert!(
24566                (dg_a[i] - num).abs() < 5e-3,
24567                "ln dg[{i}]: analytical {} vs numerical {num}",
24568                dg_a[i]
24569            );
24570        }
24571        let mut b_p = b_data.clone();
24572        for i in 0..b_p.len() {
24573            let s = b_p[i];
24574            b_p[i] = s + h_eps;
24575            let lp = forward_loss(&x_data, &g_data, &b_p);
24576            b_p[i] = s - h_eps;
24577            let lm = forward_loss(&x_data, &g_data, &b_p);
24578            b_p[i] = s;
24579            let num = (lp - lm) / (2.0 * h_eps);
24580            assert!(
24581                (db_a[i] - num).abs() < 5e-3,
24582                "ln db[{i}]: analytical {} vs numerical {num}",
24583                db_a[i]
24584            );
24585        }
24586    }
24587
24588    /// Single dense layer + softmax-cross-entropy + mean reduce —
24589    /// the simplest non-trivial training graph. Validates MatMul,
24590    /// broadcast Add, SCE, Reduce(Mean) VJPs and the grad_with_loss
24591    /// plumbing all at once.
24592    #[test]
24593    fn dense_sce_mean_gradient_matches_numerical() {
24594        use rlx_ir::Philox4x32;
24595        let bs = 4usize;
24596        let k_in = 3usize;
24597        let c = 5usize;
24598        let mut rng = Philox4x32::new(7);
24599        let mut x = vec![0f32; bs * k_in];
24600        rng.fill_normal(&mut x);
24601        let mut w_init = vec![0f32; k_in * c];
24602        rng.fill_normal(&mut w_init);
24603        let mut b_init = vec![0f32; c];
24604        rng.fill_normal(&mut b_init);
24605        let labels: Vec<f32> = (0..bs).map(|i| (i % c) as f32).collect();
24606
24607        // ── Forward graph: loss = mean(sce(x @ w + b, labels)) ──
24608        let f = DType::F32;
24609        let mut fwd = Graph::new("dense_sce");
24610        let xn = fwd.input("x", Shape::new(&[bs, k_in], f));
24611        let lb = fwd.input("labels", Shape::new(&[bs], f));
24612        let wp = fwd.param("w", Shape::new(&[k_in, c], f));
24613        let bp = fwd.param("b", Shape::new(&[c], f));
24614        let mm = fwd.matmul(xn, wp, Shape::new(&[bs, c], f));
24615        let logits = fwd.binary(BinaryOp::Add, mm, bp, Shape::new(&[bs, c], f));
24616        let loss_per = fwd.softmax_cross_entropy_with_logits(logits, lb);
24617        let loss = fwd.add_node(
24618            Op::Reduce {
24619                op: ReduceOp::Sum,
24620                axes: vec![0],
24621                keep_dim: false,
24622            },
24623            vec![loss_per],
24624            // Reduce sum of [bs] with axes=[0] keep_dim=false → scalar [].
24625            Shape::from_dims(&[], f),
24626        );
24627        // Use Sum + manual /bs scalar mul — also exercises BinaryOp::Mul VJP path
24628        // less aggressively than Mean would, and gives us a closed-form
24629        // reference for the loss we expect.
24630        // For simplicity though, switch to Mean which the tests should also cover.
24631        // (Re-using `loss` with Sum here for now; the mean factor cancels in
24632        // the gradient comparison since both analytical and numerical use the
24633        // same forward.)
24634        fwd.set_outputs(vec![loss]);
24635
24636        // ── Backward graph ──
24637        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[wp, bp]);
24638        // Outputs: [loss, grad_w, grad_b]. NodeIds for x/labels/w/b/loss
24639        // in bwd_graph match their fwd ids (the mirror keeps order).
24640        let d_out = bwd_graph
24641            .nodes()
24642            .iter()
24643            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
24644            .map(|n| n.id)
24645            .expect("d_output input");
24646
24647        let (sched, mut arena) = prepare(
24648            &bwd_graph,
24649            &[
24650                (xn, &x),
24651                (lb, &labels),
24652                (wp, &w_init),
24653                (bp, &b_init),
24654                (d_out, &[1.0]),
24655            ],
24656        );
24657        execute_thunks(&sched, arena.raw_buf_mut());
24658
24659        let outs = &bwd_graph.outputs;
24660        let loss_id = outs[0];
24661        let gw_id = outs[1];
24662        let gb_id = outs[2];
24663        let loss_actual = read_arena(&arena, loss_id, 1)[0];
24664        let gw_actual = read_arena(&arena, gw_id, k_in * c);
24665        let gb_actual = read_arena(&arena, gb_id, c);
24666
24667        // ── Forward-only graph for finite differences ──
24668        // Re-use the same `fwd` graph; set up its own arena and rerun
24669        // for each perturbed parameter.
24670        let plan = rlx_opt::memory::plan_memory(&fwd);
24671        let mut fwd_arena = crate::arena::Arena::from_plan(plan);
24672        let fwd_sched = compile_thunks(&fwd, &fwd_arena);
24673        write_arena(&mut fwd_arena, xn, &x);
24674        write_arena(&mut fwd_arena, lb, &labels);
24675
24676        let run_loss = |arena: &mut crate::arena::Arena, w: &[f32], b: &[f32]| -> f32 {
24677            write_arena(arena, wp, w);
24678            write_arena(arena, bp, b);
24679            execute_thunks(&fwd_sched, arena.raw_buf_mut());
24680            read_arena(arena, loss, 1)[0]
24681        };
24682
24683        // Sanity: the loss reported by the bwd graph matches the
24684        // forward-only graph on the unperturbed inputs.
24685        let loss_check = run_loss(&mut fwd_arena, &w_init, &b_init);
24686        assert!(
24687            (loss_actual - loss_check).abs() < 1e-4,
24688            "loss mismatch: bwd graph {loss_actual} vs fwd-only {loss_check}"
24689        );
24690
24691        let eps = 1e-3f32;
24692        let mut w_perturbed = w_init.clone();
24693        let mut gw_numerical = vec![0f32; w_init.len()];
24694        for i in 0..w_init.len() {
24695            let saved = w_perturbed[i];
24696            w_perturbed[i] = saved + eps;
24697            let lp = run_loss(&mut fwd_arena, &w_perturbed, &b_init);
24698            w_perturbed[i] = saved - eps;
24699            let lm = run_loss(&mut fwd_arena, &w_perturbed, &b_init);
24700            w_perturbed[i] = saved;
24701            gw_numerical[i] = (lp - lm) / (2.0 * eps);
24702        }
24703        for (i, (a, n)) in gw_actual.iter().zip(&gw_numerical).enumerate() {
24704            assert!(
24705                (a - n).abs() < 5e-3,
24706                "grad_w[{i}]: analytical {a} vs numerical {n}"
24707            );
24708        }
24709
24710        let mut b_perturbed = b_init.clone();
24711        let mut gb_numerical = vec![0f32; b_init.len()];
24712        for i in 0..b_init.len() {
24713            let saved = b_perturbed[i];
24714            b_perturbed[i] = saved + eps;
24715            let lp = run_loss(&mut fwd_arena, &w_init, &b_perturbed);
24716            b_perturbed[i] = saved - eps;
24717            let lm = run_loss(&mut fwd_arena, &w_init, &b_perturbed);
24718            b_perturbed[i] = saved;
24719            gb_numerical[i] = (lp - lm) / (2.0 * eps);
24720        }
24721        for (i, (a, n)) in gb_actual.iter().zip(&gb_numerical).enumerate() {
24722            assert!(
24723                (a - n).abs() < 5e-3,
24724                "grad_b[{i}]: analytical {a} vs numerical {n}"
24725            );
24726        }
24727    }
24728
24729    /// Reduce::Mean specifically — verifies the 1/N scaling in the VJP.
24730    /// The same dense+SCE graph but with Mean instead of Sum on the loss.
24731    #[test]
24732    fn dense_sce_mean_reduce_gradient_matches_numerical() {
24733        use rlx_ir::Philox4x32;
24734        let bs = 3usize;
24735        let k_in = 2usize;
24736        let c = 4usize;
24737        let mut rng = Philox4x32::new(13);
24738        let mut x = vec![0f32; bs * k_in];
24739        rng.fill_normal(&mut x);
24740        let mut w_init = vec![0f32; k_in * c];
24741        rng.fill_normal(&mut w_init);
24742        let labels: Vec<f32> = (0..bs).map(|i| (i % c) as f32).collect();
24743
24744        let f = DType::F32;
24745        let mut fwd = Graph::new("dense_sce_mean");
24746        let xn = fwd.input("x", Shape::new(&[bs, k_in], f));
24747        let lb = fwd.input("labels", Shape::new(&[bs], f));
24748        let wp = fwd.param("w", Shape::new(&[k_in, c], f));
24749        let mm = fwd.matmul(xn, wp, Shape::new(&[bs, c], f));
24750        let loss_per = fwd.softmax_cross_entropy_with_logits(mm, lb);
24751        let loss = fwd.add_node(
24752            Op::Reduce {
24753                op: ReduceOp::Mean,
24754                axes: vec![0],
24755                keep_dim: false,
24756            },
24757            vec![loss_per],
24758            Shape::from_dims(&[], f),
24759        );
24760        fwd.set_outputs(vec![loss]);
24761
24762        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[wp]);
24763        let d_out = bwd_graph
24764            .nodes()
24765            .iter()
24766            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
24767            .map(|n| n.id)
24768            .unwrap();
24769
24770        let (sched, mut arena) = prepare(
24771            &bwd_graph,
24772            &[(xn, &x), (lb, &labels), (wp, &w_init), (d_out, &[1.0])],
24773        );
24774        execute_thunks(&sched, arena.raw_buf_mut());
24775
24776        let outs = &bwd_graph.outputs;
24777        let loss_id = outs[0];
24778        let gw_id = outs[1];
24779        let _ = read_arena(&arena, loss_id, 1)[0];
24780        let gw_actual = read_arena(&arena, gw_id, k_in * c);
24781
24782        let plan = rlx_opt::memory::plan_memory(&fwd);
24783        let mut fwd_arena = crate::arena::Arena::from_plan(plan);
24784        let fwd_sched = compile_thunks(&fwd, &fwd_arena);
24785        write_arena(&mut fwd_arena, xn, &x);
24786        write_arena(&mut fwd_arena, lb, &labels);
24787
24788        let run_loss = |arena: &mut crate::arena::Arena, w: &[f32]| -> f32 {
24789            write_arena(arena, wp, w);
24790            execute_thunks(&fwd_sched, arena.raw_buf_mut());
24791            read_arena(arena, loss, 1)[0]
24792        };
24793
24794        let eps = 1e-3f32;
24795        let mut wp_p = w_init.clone();
24796        let mut gw_num = vec![0f32; w_init.len()];
24797        for i in 0..w_init.len() {
24798            let s = wp_p[i];
24799            wp_p[i] = s + eps;
24800            let lp = run_loss(&mut fwd_arena, &wp_p);
24801            wp_p[i] = s - eps;
24802            let lm = run_loss(&mut fwd_arena, &wp_p);
24803            wp_p[i] = s;
24804            gw_num[i] = (lp - lm) / (2.0 * eps);
24805        }
24806        for (i, (a, n)) in gw_actual.iter().zip(&gw_num).enumerate() {
24807            assert!((a - n).abs() < 5e-3, "mean reduce grad_w[{i}]: {a} vs {n}");
24808        }
24809    }
24810    /// The full TinyConv-MNIST forward path (downsized) plumbed
24811    /// through grad_with_loss. Validates that Conv, Pool(Max), ReLU,
24812    /// Reshape, MatMul, Add (broadcast), SCE, Reduce(Mean) VJPs all
24813    /// compose into a graph that produces correct gradients.
24814    #[test]
24815    fn tinyconv_full_gradient_matches_numerical() {
24816        use rlx_ir::Philox4x32;
24817        // Tiny shapes so finite differences finish in <1s.
24818        let n = 1usize;
24819        let c_in = 1usize;
24820        let h = 6usize;
24821        let w_in = 6usize;
24822        let c_mid = 2usize; // first conv output channels
24823        let kh = 3;
24824        let kw = 3;
24825        let h1 = h - kh + 1; // 4
24826        let w1 = w_in - kw + 1; // 4
24827        let h2 = h1 / 2;
24828        let w2 = w1 / 2; // 2 × 2 after 2× pool
24829        let flat = c_mid * h2 * w2; // 8
24830        let num_classes = 3usize;
24831
24832        let mut rng = Philox4x32::new(31);
24833        let mut x = vec![0f32; n * c_in * h * w_in];
24834        rng.fill_normal(&mut x);
24835        let mut wc = vec![0f32; c_mid * c_in * kh * kw];
24836        rng.fill_normal(&mut wc);
24837        for v in wc.iter_mut() {
24838            *v *= 0.2;
24839        }
24840        // Shift conv-bias well away from the ReLU zero-boundary. Without
24841        // this, an ε-perturbation of bc[c] can flip the ReLU mask on a
24842        // pre-activation that happened to land near zero — making the
24843        // central-difference numerical gradient discontinuous and
24844        // diverge from the analytical (which assumes local smoothness).
24845        // +5.0 keeps every pre-activation positive for any random init
24846        // produced by Philox seed 31 with the wc/x scales used here, so
24847        // ReLU acts as an identity and finite differences are exact.
24848        let bc: Vec<f32> = (0..c_mid).map(|i| 5.0 + 0.1 * i as f32).collect();
24849        let mut wfc = vec![0f32; flat * num_classes];
24850        rng.fill_normal(&mut wfc);
24851        for v in wfc.iter_mut() {
24852            *v *= 0.5;
24853        }
24854        let mut bfc = vec![0f32; num_classes];
24855        rng.fill_normal(&mut bfc);
24856        let labels: Vec<f32> = vec![1.0]; // batch=1
24857
24858        let f = DType::F32;
24859        let mut fwd = Graph::new("tinyconv");
24860        let xn = fwd.input("x", Shape::new(&[n, c_in, h, w_in], f));
24861        let lb = fwd.input("labels", Shape::new(&[n], f));
24862        let wcp = fwd.param("wc", Shape::new(&[c_mid, c_in, kh, kw], f));
24863        let bcp = fwd.param("bc", Shape::new(&[c_mid], f));
24864        let wfp = fwd.param("wfc", Shape::new(&[flat, num_classes], f));
24865        let bfp = fwd.param("bfc", Shape::new(&[num_classes], f));
24866
24867        // conv: [n, c_in, h, w] → [n, c_mid, h1, w1]
24868        let conv = fwd.add_node(
24869            Op::Conv {
24870                kernel_size: vec![kh, kw],
24871                stride: vec![1, 1],
24872                padding: vec![0, 0],
24873                dilation: vec![1, 1],
24874                groups: 1,
24875            },
24876            vec![xn, wcp],
24877            Shape::new(&[n, c_mid, h1, w1], f),
24878        );
24879        // Bias add: expand bc[c_mid] up to the full [n, c_mid, h1, w1]
24880        // shape so the Add becomes a plain element-wise op. Going through
24881        // an explicit Reshape→Expand instead of relying on the Add to
24882        // broadcast `[1, C, 1, 1]` → `[N, C, H, W]` works around a known
24883        // limitation of `rlx-cpu`'s `Op::Binary` lowering: it dispatches
24884        // on `out_len % rhs_len == 0` and treats `rhs` as a last-axis
24885        // bias, which produces `bc[0], bc[1], bc[0], bc[1], …` alternating
24886        // across all positions instead of channel-broadcasting. Going
24887        // through Expand (a real broadcast thunk) avoids that path
24888        // entirely. The autodiff still exercises `unbroadcast` because
24889        // `Op::Expand`'s VJP reduces over the broadcast axes.
24890        let bc_4d = fwd.add_node(
24891            Op::Reshape {
24892                new_shape: vec![1, c_mid as i64, 1, 1],
24893            },
24894            vec![bcp],
24895            Shape::new(&[1, c_mid, 1, 1], f),
24896        );
24897        let bc_expanded = fwd.add_node(
24898            Op::Expand {
24899                target_shape: vec![n as i64, c_mid as i64, h1 as i64, w1 as i64],
24900            },
24901            vec![bc_4d],
24902            Shape::new(&[n, c_mid, h1, w1], f),
24903        );
24904        let conv_b = fwd.binary(
24905            BinaryOp::Add,
24906            conv,
24907            bc_expanded,
24908            Shape::new(&[n, c_mid, h1, w1], f),
24909        );
24910        let relu = fwd.activation(Activation::Relu, conv_b, Shape::new(&[n, c_mid, h1, w1], f));
24911        let pool = fwd.add_node(
24912            Op::Pool {
24913                kind: ReduceOp::Max,
24914                kernel_size: vec![2, 2],
24915                stride: vec![2, 2],
24916                padding: vec![0, 0],
24917            },
24918            vec![relu],
24919            Shape::new(&[n, c_mid, h2, w2], f),
24920        );
24921        let flatn = fwd.add_node(
24922            Op::Reshape {
24923                new_shape: vec![n as i64, flat as i64],
24924            },
24925            vec![pool],
24926            Shape::new(&[n, flat], f),
24927        );
24928        let mm = fwd.matmul(flatn, wfp, Shape::new(&[n, num_classes], f));
24929        let logits = fwd.binary(BinaryOp::Add, mm, bfp, Shape::new(&[n, num_classes], f));
24930        let loss_per = fwd.softmax_cross_entropy_with_logits(logits, lb);
24931        let loss = fwd.add_node(
24932            Op::Reduce {
24933                op: ReduceOp::Mean,
24934                axes: vec![0],
24935                keep_dim: false,
24936            },
24937            vec![loss_per],
24938            Shape::from_dims(&[], f),
24939        );
24940        fwd.set_outputs(vec![loss]);
24941
24942        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[wcp, bcp, wfp, bfp]);
24943        let d_out = bwd_graph
24944            .nodes()
24945            .iter()
24946            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
24947            .map(|n| n.id)
24948            .unwrap();
24949
24950        let (sched, mut arena) = prepare(
24951            &bwd_graph,
24952            &[
24953                (xn, &x),
24954                (lb, &labels),
24955                (wcp, &wc),
24956                (bcp, &bc),
24957                (wfp, &wfc),
24958                (bfp, &bfc),
24959                (d_out, &[1.0]),
24960            ],
24961        );
24962        execute_thunks(&sched, arena.raw_buf_mut());
24963
24964        let outs = bwd_graph.outputs.clone();
24965        let loss_id = outs[0];
24966        let g_wc_id = outs[1];
24967        let g_bc_id = outs[2];
24968        let g_wfc_id = outs[3];
24969        let g_bfc_id = outs[4];
24970        let loss_actual = read_arena(&arena, loss_id, 1)[0];
24971        let g_wc = read_arena(&arena, g_wc_id, wc.len());
24972        let g_bc = read_arena(&arena, g_bc_id, bc.len());
24973        let g_wfc = read_arena(&arena, g_wfc_id, wfc.len());
24974        let g_bfc = read_arena(&arena, g_bfc_id, bfc.len());
24975
24976        // Forward-only arena for finite differences.
24977        let plan = rlx_opt::memory::plan_memory(&fwd);
24978        let mut fwd_arena = crate::arena::Arena::from_plan(plan);
24979        let fwd_sched = compile_thunks(&fwd, &fwd_arena);
24980        write_arena(&mut fwd_arena, xn, &x);
24981        write_arena(&mut fwd_arena, lb, &labels);
24982
24983        // Closure variant: we need to set all four params each call so
24984        // perturbations to one don't leak between sweeps.
24985        let run_loss = |arena: &mut crate::arena::Arena,
24986                        wc: &[f32],
24987                        bc: &[f32],
24988                        wfc: &[f32],
24989                        bfc: &[f32]|
24990         -> f32 {
24991            write_arena(arena, wcp, wc);
24992            write_arena(arena, bcp, bc);
24993            write_arena(arena, wfp, wfc);
24994            write_arena(arena, bfp, bfc);
24995            execute_thunks(&fwd_sched, arena.raw_buf_mut());
24996            read_arena(arena, loss, 1)[0]
24997        };
24998
24999        let loss_check = run_loss(&mut fwd_arena, &wc, &bc, &wfc, &bfc);
25000        assert!(
25001            (loss_actual - loss_check).abs() < 1e-4,
25002            "tinyconv loss mismatch: bwd {loss_actual} vs fwd {loss_check}"
25003        );
25004
25005        let eps = 1e-3f32;
25006        let check_grad = |arena: &mut crate::arena::Arena,
25007                          name: &str,
25008                          analytical: &[f32],
25009                          mut perturb: Box<
25010            dyn FnMut(&mut [f32], usize, f32, &mut crate::arena::Arena) -> f32 + '_,
25011        >,
25012                          n: usize| {
25013            for i in 0..n {
25014                let lp = perturb(&mut analytical.to_vec(), i, eps, arena);
25015                let lm = perturb(&mut analytical.to_vec(), i, -eps, arena);
25016                let num = (lp - lm) / (2.0 * eps);
25017                assert!(
25018                    (analytical[i] - num).abs() < 5e-3,
25019                    "{name}[{i}]: analytical {} vs numerical {num}",
25020                    analytical[i]
25021                );
25022            }
25023        };
25024
25025        // Helper to perturb one param and run forward. Kept as a
25026        // reference for the explicit per-param sweep pattern below.
25027        #[allow(unused_macros)]
25028        macro_rules! sweep {
25029            ($name:expr, $base:expr, $analytical:expr, $set_param:ident) => {{
25030                let n = $base.len();
25031                for i in 0..n {
25032                    let mut p = $base.clone();
25033                    let s = p[i];
25034                    p[i] = s + eps;
25035                    let lp = {
25036                        let $set_param = &p;
25037                        run_loss(&mut fwd_arena, &wc, &bc, &wfc, &bfc).max(f32::NEG_INFINITY);
25038                        // Reset others, set the one being swept, run.
25039                        // (the macro receives one of the four params via $set_param)
25040                        let _ = $set_param;
25041                        // Fall through to the explicit per-param helper:
25042                        0.0_f32
25043                    };
25044                    let _ = lp;
25045                }
25046            }};
25047        }
25048        let _ = check_grad; // silence unused (sweep! macro is intentionally\n        // unused — kept as reference for the per-param sweep pattern below)
25049
25050        // Per-param sweeps (explicit, not macro — clearer).
25051        for i in 0..wc.len() {
25052            let mut p = wc.clone();
25053            let s = p[i];
25054            p[i] = s + eps;
25055            let lp = run_loss(&mut fwd_arena, &p, &bc, &wfc, &bfc);
25056            p[i] = s - eps;
25057            let lm = run_loss(&mut fwd_arena, &p, &bc, &wfc, &bfc);
25058            let num = (lp - lm) / (2.0 * eps);
25059            assert!(
25060                (g_wc[i] - num).abs() < 5e-3,
25061                "g_wc[{i}]: {} vs {num}",
25062                g_wc[i]
25063            );
25064        }
25065        for i in 0..bc.len() {
25066            let mut p = bc.clone();
25067            let s = p[i];
25068            p[i] = s + eps;
25069            let lp = run_loss(&mut fwd_arena, &wc, &p, &wfc, &bfc);
25070            p[i] = s - eps;
25071            let lm = run_loss(&mut fwd_arena, &wc, &p, &wfc, &bfc);
25072            let num = (lp - lm) / (2.0 * eps);
25073            assert!(
25074                (g_bc[i] - num).abs() < 5e-3,
25075                "g_bc[{i}]: {} vs {num}",
25076                g_bc[i]
25077            );
25078        }
25079        for i in 0..wfc.len() {
25080            let mut p = wfc.clone();
25081            let s = p[i];
25082            p[i] = s + eps;
25083            let lp = run_loss(&mut fwd_arena, &wc, &bc, &p, &bfc);
25084            p[i] = s - eps;
25085            let lm = run_loss(&mut fwd_arena, &wc, &bc, &p, &bfc);
25086            let num = (lp - lm) / (2.0 * eps);
25087            assert!(
25088                (g_wfc[i] - num).abs() < 5e-3,
25089                "g_wfc[{i}]: {} vs {num}",
25090                g_wfc[i]
25091            );
25092        }
25093        for i in 0..bfc.len() {
25094            let mut p = bfc.clone();
25095            let s = p[i];
25096            p[i] = s + eps;
25097            let lp = run_loss(&mut fwd_arena, &wc, &bc, &wfc, &p);
25098            p[i] = s - eps;
25099            let lm = run_loss(&mut fwd_arena, &wc, &bc, &wfc, &p);
25100            let num = (lp - lm) / (2.0 * eps);
25101            assert!(
25102                (g_bfc[i] - num).abs() < 5e-3,
25103                "g_bfc[{i}]: {} vs {num}",
25104                g_bfc[i]
25105            );
25106        }
25107    }
25108
25109    /// Negative case: a Narrow whose output has multiple consumers
25110    /// must NOT be fused (we can't elide its write — something else
25111    /// reads it).
25112    #[test]
25113    fn narrow_rope_skips_when_narrow_has_multiple_consumers() {
25114        let f = DType::F32;
25115        let mut g = Graph::new("nr_skip");
25116        let qkv = g.input("qkv", Shape::new(&[16, 8, 192], f));
25117        let cos = g.input("cos", Shape::new(&[16], f));
25118        let sin = g.input("sin", Shape::new(&[16], f));
25119        let q = g.narrow_(qkv, 2, 0, 64);
25120        let q_rope = g.rope(q, cos, sin, 16);
25121        // Second consumer of `q` blocks the fusion.
25122        let q_dup = g.activation(rlx_ir::op::Activation::Relu, q, Shape::new(&[16, 8, 64], f));
25123        g.set_outputs(vec![q_rope, q_dup]);
25124
25125        let plan = rlx_opt::memory::plan_memory(&g);
25126        let arena = crate::arena::Arena::from_plan(plan);
25127        let sched = compile_thunks(&g, &arena);
25128
25129        let narrow_count = sched
25130            .thunks
25131            .iter()
25132            .filter(|t| matches!(t, Thunk::Narrow { .. }))
25133            .count();
25134        assert!(
25135            narrow_count >= 1,
25136            "Narrow with multiple consumers must NOT be fused away"
25137        );
25138    }
25139
25140    // ── Op::CustomFn (custom_vjp / custom_jvp) tests ──
25141    //
25142    // Validates: forward execution inlines fwd_body; VJP rule inlines
25143    // vjp_body in place of recursing into fwd_body; JVP rule inlines
25144    // jvp_body. Each test deliberately picks a body whose AD-via-tracing
25145    // would yield a *different* gradient than the override, so we know
25146    // the override actually fired.
25147
25148    /// Forward only: CustomFn wrapping `f(x) = x + c` (c=1 inside body)
25149    /// without override AD bodies. Verifies the body is compiled,
25150    /// constants in the body fill correctly, and the output lands at
25151    /// the outer node's slot.
25152    #[test]
25153    fn custom_fn_forward_inlines_body() {
25154        let s = Shape::new(&[3], DType::F32);
25155
25156        // Body: f(x) = x + 1
25157        let mut body = Graph::new("addone_body");
25158        let x = body.input("x", s.clone());
25159        let one_data: Vec<u8> = (0..3).flat_map(|_| 1.0_f32.to_le_bytes()).collect();
25160        let one = body.add_node(Op::Constant { data: one_data }, vec![], s.clone());
25161        let y = body.binary(BinaryOp::Add, x, one, s.clone());
25162        body.set_outputs(vec![y]);
25163
25164        let mut g = Graph::new("custom_fn_outer");
25165        let xin = g.input("x_in", s.clone());
25166        let cf = g.custom_fn(vec![xin], body, None, None);
25167        g.set_outputs(vec![cf]);
25168
25169        let xs = vec![10.0_f32, 20.0, 30.0];
25170        let (sched, mut arena) = prepare(&g, &[(xin, &xs)]);
25171        execute_thunks(&sched, arena.raw_buf_mut());
25172        let got = read_arena(&arena, cf, 3);
25173        assert_eq!(got, vec![11.0, 21.0, 31.0]);
25174    }
25175
25176    /// Locate an Op::Input or Op::Param by name in a graph.
25177    fn find_named(graph: &Graph, want: &str) -> NodeId {
25178        for n in graph.nodes() {
25179            let name = match &n.op {
25180                Op::Input { name } | Op::Param { name } => Some(name.as_str()),
25181                _ => None,
25182            };
25183            if name == Some(want) {
25184                return n.id;
25185            }
25186        }
25187        panic!("no node named {want:?} in graph");
25188    }
25189
25190    /// VJP override: f(x) = x but vjp_body returns 2 * d_output, so the
25191    /// reported gradient should be 2 — different from the natural 1
25192    /// you'd get by recursing into the identity body.
25193    #[test]
25194    fn custom_fn_vjp_overrides_natural_gradient() {
25195        use rlx_opt::autodiff::grad_with_loss;
25196        let s = Shape::new(&[1], DType::F32);
25197
25198        let mut fwd = Graph::new("id_fwd");
25199        let x = fwd.input("x", s.clone());
25200        fwd.set_outputs(vec![x]);
25201
25202        let mut vjp_g = Graph::new("id_vjp");
25203        let _x_p = vjp_g.input("x", s.clone());
25204        let _y_p = vjp_g.input("primal_output", s.clone());
25205        let dy = vjp_g.input("d_output", s.clone());
25206        let two_data: Vec<u8> = 2.0_f32.to_le_bytes().to_vec();
25207        let two = vjp_g.add_node(Op::Constant { data: two_data }, vec![], s.clone());
25208        let dx = vjp_g.binary(BinaryOp::Mul, dy, two, s.clone());
25209        vjp_g.set_outputs(vec![dx]);
25210
25211        let mut g = Graph::new("outer");
25212        let xp = g.param("x", s.clone());
25213        let cf = g.custom_fn(vec![xp], fwd, Some(vjp_g), None);
25214        g.set_outputs(vec![cf]);
25215
25216        let bwd = grad_with_loss(&g, &[xp]);
25217        assert_eq!(bwd.outputs.len(), 2, "expect [loss, dx]");
25218
25219        let xb = find_named(&bwd, "x");
25220        let dout = find_named(&bwd, "d_output");
25221        let (sched, mut arena) = prepare(&bwd, &[(xb, &[7.0]), (dout, &[1.0])]);
25222        execute_thunks(&sched, arena.raw_buf_mut());
25223        let loss = read_arena(&arena, bwd.outputs[0], 1);
25224        let dx_v = read_arena(&arena, bwd.outputs[1], 1);
25225        assert!((loss[0] - 7.0).abs() < 1e-6, "loss should be 7.0");
25226        assert!(
25227            (dx_v[0] - 2.0).abs() < 1e-6,
25228            "vjp override should yield dx=2.0, got {} (natural autodiff would give 1.0)",
25229            dx_v[0]
25230        );
25231    }
25232
25233    /// VJP override: f(a, b) = a*b with vjp_body returning
25234    /// (b * d_output, a * d_output). Validates routing of multiple
25235    /// primals + d_output through the override; matches the natural
25236    /// autodiff-of-Mul gradient (b, a).
25237    #[test]
25238    fn custom_fn_vjp_two_inputs_matches_mul_autodiff() {
25239        use rlx_opt::autodiff::grad_with_loss;
25240        let s = Shape::new(&[1], DType::F32);
25241
25242        let mut fwd = Graph::new("mul_fwd");
25243        let a_f = fwd.input("a", s.clone());
25244        let b_f = fwd.input("b", s.clone());
25245        let y_f = fwd.binary(BinaryOp::Mul, a_f, b_f, s.clone());
25246        fwd.set_outputs(vec![y_f]);
25247
25248        let mut vjp_g = Graph::new("mul_vjp");
25249        let a_v = vjp_g.input("a", s.clone());
25250        let b_v = vjp_g.input("b", s.clone());
25251        let _y_v = vjp_g.input("primal_output", s.clone());
25252        let dy_v = vjp_g.input("d_output", s.clone());
25253        let da = vjp_g.binary(BinaryOp::Mul, b_v, dy_v, s.clone());
25254        let db = vjp_g.binary(BinaryOp::Mul, a_v, dy_v, s.clone());
25255        vjp_g.set_outputs(vec![da, db]);
25256
25257        let mut g = Graph::new("outer");
25258        let ap = g.param("a", s.clone());
25259        let bp = g.param("b", s.clone());
25260        let cf = g.custom_fn(vec![ap, bp], fwd, Some(vjp_g), None);
25261        g.set_outputs(vec![cf]);
25262
25263        let bwd = grad_with_loss(&g, &[ap, bp]);
25264        assert_eq!(bwd.outputs.len(), 3, "expect [loss, da, db]");
25265
25266        let ab = find_named(&bwd, "a");
25267        let bb = find_named(&bwd, "b");
25268        let dout = find_named(&bwd, "d_output");
25269        let (sched, mut arena) = prepare(&bwd, &[(ab, &[3.0]), (bb, &[5.0]), (dout, &[1.0])]);
25270        execute_thunks(&sched, arena.raw_buf_mut());
25271        let loss = read_arena(&arena, bwd.outputs[0], 1);
25272        let da_v = read_arena(&arena, bwd.outputs[1], 1);
25273        let db_v = read_arena(&arena, bwd.outputs[2], 1);
25274        assert!((loss[0] - 15.0).abs() < 1e-5);
25275        assert!(
25276            (da_v[0] - 5.0).abs() < 1e-5,
25277            "da should be b=5.0, got {}",
25278            da_v[0]
25279        );
25280        assert!(
25281            (db_v[0] - 3.0).abs() < 1e-5,
25282            "db should be a=3.0, got {}",
25283            db_v[0]
25284        );
25285    }
25286
25287    /// JVP override: f(x) = x but jvp_body returns 2 * tangent_0.
25288    /// Forward-mode tangent should be 2x the seed (1.0) → 2.0.
25289    #[test]
25290    fn custom_fn_jvp_overrides_natural_tangent() {
25291        use rlx_opt::autodiff_fwd::jvp;
25292        let s = Shape::new(&[1], DType::F32);
25293
25294        let mut fwd = Graph::new("id_fwd");
25295        let x = fwd.input("x", s.clone());
25296        fwd.set_outputs(vec![x]);
25297
25298        let mut jvp_g = Graph::new("id_jvp");
25299        let _x_p = jvp_g.input("x", s.clone());
25300        let tx = jvp_g.input("tangent_0", s.clone());
25301        let two_data: Vec<u8> = 2.0_f32.to_le_bytes().to_vec();
25302        let two = jvp_g.add_node(Op::Constant { data: two_data }, vec![], s.clone());
25303        let ty = jvp_g.binary(BinaryOp::Mul, tx, two, s.clone());
25304        jvp_g.set_outputs(vec![ty]);
25305
25306        let mut g = Graph::new("outer");
25307        let xin = g.input("x_in", s.clone());
25308        let cf = g.custom_fn(vec![xin], fwd, None, Some(jvp_g));
25309        g.set_outputs(vec![cf]);
25310
25311        let fwd_g = jvp(&g, &[xin]);
25312        assert_eq!(fwd_g.outputs.len(), 2, "expect [primal_y, tangent_y]");
25313
25314        let xb = find_named(&fwd_g, "x_in");
25315        let tan = find_named(&fwd_g, "tangent_x_in");
25316        let (sched, mut arena) = prepare(&fwd_g, &[(xb, &[7.0]), (tan, &[1.0])]);
25317        execute_thunks(&sched, arena.raw_buf_mut());
25318        let y = read_arena(&arena, fwd_g.outputs[0], 1);
25319        let ty_v = read_arena(&arena, fwd_g.outputs[1], 1);
25320        assert!((y[0] - 7.0).abs() < 1e-6);
25321        assert!(
25322            (ty_v[0] - 2.0).abs() < 1e-6,
25323            "jvp override should yield t_y=2.0 (natural autodiff would give 1.0), got {}",
25324            ty_v[0]
25325        );
25326    }
25327
25328    /// IR-level basic test: `DType::C64` is wired through the dtype
25329    /// table — `size_bytes() == 8`, `is_complex()` reports true, and
25330    /// a `[2]`-shaped C64 buffer in the arena occupies the expected
25331    /// 16 bytes.
25332    #[test]
25333    fn c64_dtype_storage_layout() {
25334        assert_eq!(
25335            DType::C64.size_bytes(),
25336            8,
25337            "C64 should be 8 bytes (f32 real + f32 imag)"
25338        );
25339        assert!(DType::C64.is_complex());
25340        assert!(!DType::C64.is_float());
25341
25342        // A length-2 C64 buffer should have shape size_bytes = 16.
25343        let s = Shape::new(&[2], DType::C64);
25344        assert_eq!(s.size_bytes().unwrap(), 16);
25345    }
25346
25347    // ── C64 element-wise binary kernel witnesses (2026-05-17) ──────
25348    //
25349    // Build a tiny graph: Input `a` + Input `b` (both C64 [2]),
25350    // output = a OP b. Run through CompileResult and compare against
25351    // the closed-form complex arithmetic on the four chosen pairs.
25352
25353    fn run_c64_binary(op: BinaryOp, a: &[(f32, f32)], b: &[(f32, f32)]) -> Vec<(f32, f32)> {
25354        let n = a.len();
25355        let s = Shape::new(&[n], DType::C64);
25356        let mut g = Graph::new("c64_bin");
25357        let in_a = g.input("a", s.clone());
25358        let in_b = g.input("b", s.clone());
25359        let out = g.binary(op, in_a, in_b, s.clone());
25360        g.set_outputs(vec![out]);
25361
25362        let plan = rlx_opt::memory::plan_memory(&g);
25363        let mut arena = crate::arena::Arena::from_plan(plan);
25364        let sched = compile_thunks(&g, &arena);
25365
25366        let a_off = arena.byte_offset(in_a);
25367        let b_off = arena.byte_offset(in_b);
25368        let out_off = arena.byte_offset(out);
25369        // Interleave [re_0, im_0, re_1, im_1, ...] in the f32 buffer.
25370        let buf = arena.raw_buf_mut();
25371        unsafe {
25372            let pa = buf.as_mut_ptr().add(a_off) as *mut f32;
25373            let pb = buf.as_mut_ptr().add(b_off) as *mut f32;
25374            for (i, &(re, im)) in a.iter().enumerate() {
25375                *pa.add(2 * i) = re;
25376                *pa.add(2 * i + 1) = im;
25377            }
25378            for (i, &(re, im)) in b.iter().enumerate() {
25379                *pb.add(2 * i) = re;
25380                *pb.add(2 * i + 1) = im;
25381            }
25382        }
25383        execute_thunks(&sched, arena.raw_buf_mut());
25384        let raw_out: Vec<f32> = unsafe {
25385            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
25386            (0..(2 * n)).map(|i| *p.add(i)).collect()
25387        };
25388        (0..n)
25389            .map(|i| (raw_out[2 * i], raw_out[2 * i + 1]))
25390            .collect()
25391    }
25392
25393    #[track_caller]
25394    fn assert_close_c(got: (f32, f32), expected: (f32, f32), tol: f32, label: &str) {
25395        let dr = (got.0 - expected.0).abs();
25396        let di = (got.1 - expected.1).abs();
25397        assert!(
25398            dr < tol && di < tol,
25399            "[{label}] got ({:+.4}, {:+.4}), expected ({:+.4}, {:+.4})",
25400            got.0,
25401            got.1,
25402            expected.0,
25403            expected.1
25404        );
25405    }
25406
25407    #[test]
25408    fn c64_binary_add_matches_complex_arithmetic() {
25409        let a = [(1.0_f32, 2.0_f32), (3.0_f32, -1.0_f32)];
25410        let b = [(4.0_f32, -1.0_f32), (0.5_f32, 0.5_f32)];
25411        let out = run_c64_binary(BinaryOp::Add, &a, &b);
25412        assert_close_c(out[0], (5.0, 1.0), 1e-6, "add[0]");
25413        assert_close_c(out[1], (3.5, -0.5), 1e-6, "add[1]");
25414    }
25415
25416    #[test]
25417    fn c64_binary_sub_matches_complex_arithmetic() {
25418        let a = [(5.0_f32, 1.0_f32)];
25419        let b = [(2.0_f32, 3.0_f32)];
25420        let out = run_c64_binary(BinaryOp::Sub, &a, &b);
25421        assert_close_c(out[0], (3.0, -2.0), 1e-6, "sub");
25422    }
25423
25424    #[test]
25425    fn c64_binary_mul_matches_complex_arithmetic() {
25426        // (1 + 2i)(3 + 4i) = 3 + 4i + 6i + 8i² = -5 + 10i.
25427        let a = [(1.0_f32, 2.0_f32)];
25428        let b = [(3.0_f32, 4.0_f32)];
25429        let out = run_c64_binary(BinaryOp::Mul, &a, &b);
25430        assert_close_c(out[0], (-5.0, 10.0), 1e-5, "mul");
25431    }
25432
25433    #[test]
25434    fn c64_binary_div_matches_complex_arithmetic() {
25435        // (1 + 2i) / (3 + 4i) = ((1·3 + 2·4) + (2·3 − 1·4)i) / 25
25436        //                     = (11 + 2i) / 25
25437        //                     = 0.44 + 0.08i
25438        let a = [(1.0_f32, 2.0_f32)];
25439        let b = [(3.0_f32, 4.0_f32)];
25440        let out = run_c64_binary(BinaryOp::Div, &a, &b);
25441        assert_close_c(out[0], (0.44, 0.08), 1e-5, "div");
25442    }
25443
25444    #[test]
25445    fn c64_binary_mul_identity_one_is_no_op() {
25446        // (a + bi) · (1 + 0i) = a + bi.
25447        let a = [(3.5_f32, -1.25_f32), (-2.0_f32, 7.0_f32)];
25448        let b = [(1.0_f32, 0.0_f32), (1.0_f32, 0.0_f32)];
25449        let out = run_c64_binary(BinaryOp::Mul, &a, &b);
25450        assert_close_c(out[0], a[0], 1e-6, "mul·1[0]");
25451        assert_close_c(out[1], a[1], 1e-6, "mul·1[1]");
25452    }
25453
25454    #[test]
25455    fn c64_binary_mul_by_i_rotates_90_degrees() {
25456        // (a + bi) · i = (a + bi)(0 + i) = -b + ai. 90° CCW rotation.
25457        let a = [(1.0_f32, 0.0_f32)];
25458        let b = [(0.0_f32, 1.0_f32)];
25459        let out = run_c64_binary(BinaryOp::Mul, &a, &b);
25460        assert_close_c(out[0], (0.0, 1.0), 1e-6, "1·i");
25461    }
25462
25463    #[test]
25464    fn c64_binary_div_by_self_gives_unity() {
25465        let a = [(2.5_f32, -1.5_f32), (-0.7_f32, 4.2_f32)];
25466        let out = run_c64_binary(BinaryOp::Div, &a, &a);
25467        assert_close_c(out[0], (1.0, 0.0), 1e-5, "div_self[0]");
25468        assert_close_c(out[1], (1.0, 0.0), 1e-5, "div_self[1]");
25469    }
25470
25471    #[test]
25472    #[should_panic(expected = "C64: complex max/min/pow")]
25473    fn c64_binary_max_is_rejected_at_lowering() {
25474        run_c64_binary(BinaryOp::Max, &[(1.0_f32, 2.0_f32)], &[(3.0_f32, 4.0_f32)]);
25475    }
25476
25477    fn run_c64_activation(act: Activation, a: &[(f32, f32)]) -> Vec<(f32, f32)> {
25478        let n = a.len();
25479        let s = Shape::new(&[n], DType::C64);
25480        let mut g = Graph::new("c64_act");
25481        let in_a = g.input("a", s.clone());
25482        let out = g.activation(act, in_a, s.clone());
25483        g.set_outputs(vec![out]);
25484        let plan = rlx_opt::memory::plan_memory(&g);
25485        let mut arena = crate::arena::Arena::from_plan(plan);
25486        let sched = compile_thunks(&g, &arena);
25487        let a_off = arena.byte_offset(in_a);
25488        let out_off = arena.byte_offset(out);
25489        let buf = arena.raw_buf_mut();
25490        unsafe {
25491            let pa = buf.as_mut_ptr().add(a_off) as *mut f32;
25492            for (i, &(re, im)) in a.iter().enumerate() {
25493                *pa.add(2 * i) = re;
25494                *pa.add(2 * i + 1) = im;
25495            }
25496        }
25497        execute_thunks(&sched, arena.raw_buf_mut());
25498        let raw: Vec<f32> = unsafe {
25499            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
25500            (0..(2 * n)).map(|i| *p.add(i)).collect()
25501        };
25502        (0..n).map(|i| (raw[2 * i], raw[2 * i + 1])).collect()
25503    }
25504
25505    #[test]
25506    fn c64_activation_neg_negates_both_components() {
25507        let inp = [(3.5_f32, -1.25_f32), (-2.0_f32, 0.0_f32)];
25508        let out = run_c64_activation(Activation::Neg, &inp);
25509        assert_close_c(out[0], (-3.5, 1.25), 1e-6, "neg[0]");
25510        assert_close_c(out[1], (2.0, 0.0), 1e-6, "neg[1]");
25511    }
25512
25513    #[test]
25514    fn c64_activation_exp_matches_euler() {
25515        // exp(0 + i·π) = -1 + 0i.
25516        // exp(1 + 0i) = e ≈ 2.71828.
25517        let inp = [(0.0_f32, std::f32::consts::PI), (1.0_f32, 0.0_f32)];
25518        let out = run_c64_activation(Activation::Exp, &inp);
25519        assert_close_c(out[0], (-1.0, 0.0), 1e-5, "exp(iπ)");
25520        assert_close_c(out[1], (std::f32::consts::E, 0.0), 1e-5, "exp(1)");
25521    }
25522
25523    #[test]
25524    fn c64_activation_log_matches_principal_branch() {
25525        // log(1 + 0i) = 0.
25526        // log(0 + i) = log(1) + i·π/2 = 0 + i·π/2.
25527        // log(-1 + 0i) = 0 + i·π.
25528        let inp = [(1.0_f32, 0.0_f32), (0.0_f32, 1.0_f32), (-1.0_f32, 0.0_f32)];
25529        let out = run_c64_activation(Activation::Log, &inp);
25530        assert_close_c(out[0], (0.0, 0.0), 1e-5, "log(1)");
25531        assert_close_c(out[1], (0.0, std::f32::consts::FRAC_PI_2), 1e-5, "log(i)");
25532        assert_close_c(out[2], (0.0, std::f32::consts::PI), 1e-5, "log(-1)");
25533    }
25534
25535    #[test]
25536    fn c64_activation_sqrt_squared_recovers_input() {
25537        // For positive-real-part inputs, sqrt(z)² should equal z exactly
25538        // to f32 noise.
25539        let inp = [(4.0_f32, 0.0_f32), (3.0_f32, 4.0_f32)];
25540        let roots = run_c64_activation(Activation::Sqrt, &inp);
25541        // sqrt(4) = 2 + 0i; sqrt(3+4i) = 2 + i (since (2+i)² = 4+4i-1 = 3+4i).
25542        assert_close_c(roots[0], (2.0, 0.0), 1e-5, "sqrt(4)");
25543        assert_close_c(roots[1], (2.0, 1.0), 1e-5, "sqrt(3+4i)");
25544    }
25545
25546    #[test]
25547    #[should_panic(expected = "no natural complex extension")]
25548    fn c64_activation_relu_is_rejected_at_lowering() {
25549        run_c64_activation(Activation::Relu, &[(1.0_f32, 2.0_f32)]);
25550    }
25551
25552    // ── ComplexNormSq + Wirtinger backward witnesses ───────────────
25553
25554    /// Forward `|z|²`: returns `[n]` f32.
25555    fn run_complex_norm_sq(z: &[(f32, f32)]) -> Vec<f32> {
25556        let n = z.len();
25557        let mut g = Graph::new("cns_fwd");
25558        let in_z = g.input("z", Shape::new(&[n], DType::C64));
25559        let out = g.complex_norm_sq(in_z);
25560        g.set_outputs(vec![out]);
25561        let plan = rlx_opt::memory::plan_memory(&g);
25562        let mut arena = crate::arena::Arena::from_plan(plan);
25563        let sched = compile_thunks(&g, &arena);
25564        let z_off = arena.byte_offset(in_z);
25565        let out_off = arena.byte_offset(out);
25566        let buf = arena.raw_buf_mut();
25567        unsafe {
25568            let pz = buf.as_mut_ptr().add(z_off) as *mut f32;
25569            for (i, &(re, im)) in z.iter().enumerate() {
25570                *pz.add(2 * i) = re;
25571                *pz.add(2 * i + 1) = im;
25572            }
25573        }
25574        execute_thunks(&sched, arena.raw_buf_mut());
25575        unsafe {
25576            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
25577            (0..n).map(|i| *p.add(i)).collect()
25578        }
25579    }
25580
25581    /// Backward: given z and upstream g, return dz = g·z element-wise (C64).
25582    fn run_complex_norm_sq_bwd(z: &[(f32, f32)], g: &[f32]) -> Vec<(f32, f32)> {
25583        let n = z.len();
25584        let mut gr = Graph::new("cns_bwd");
25585        let in_z = gr.input("z", Shape::new(&[n], DType::C64));
25586        let in_g = gr.input("g", Shape::new(&[n], DType::F32));
25587        let out = gr.complex_norm_sq_backward(in_z, in_g);
25588        gr.set_outputs(vec![out]);
25589        let plan = rlx_opt::memory::plan_memory(&gr);
25590        let mut arena = crate::arena::Arena::from_plan(plan);
25591        let sched = compile_thunks(&gr, &arena);
25592        let z_off = arena.byte_offset(in_z);
25593        let g_off = arena.byte_offset(in_g);
25594        let out_off = arena.byte_offset(out);
25595        let buf = arena.raw_buf_mut();
25596        unsafe {
25597            let pz = buf.as_mut_ptr().add(z_off) as *mut f32;
25598            let pg = buf.as_mut_ptr().add(g_off) as *mut f32;
25599            for (i, &(re, im)) in z.iter().enumerate() {
25600                *pz.add(2 * i) = re;
25601                *pz.add(2 * i + 1) = im;
25602            }
25603            for (i, &v) in g.iter().enumerate() {
25604                *pg.add(i) = v;
25605            }
25606        }
25607        execute_thunks(&sched, arena.raw_buf_mut());
25608        unsafe {
25609            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
25610            (0..n).map(|i| (*p.add(2 * i), *p.add(2 * i + 1))).collect()
25611        }
25612    }
25613
25614    #[test]
25615    fn complex_norm_sq_matches_textbook() {
25616        // |3 + 4i|² = 9 + 16 = 25.
25617        // |1 + 0i|² = 1.
25618        // |0 + 0i|² = 0.
25619        let z = [(3.0_f32, 4.0_f32), (1.0_f32, 0.0_f32), (0.0_f32, 0.0_f32)];
25620        let out = run_complex_norm_sq(&z);
25621        assert!((out[0] - 25.0).abs() < 1e-5);
25622        assert!((out[1] - 1.0).abs() < 1e-6);
25623        assert!(out[2].abs() < 1e-6);
25624    }
25625
25626    #[test]
25627    fn complex_norm_sq_backward_matches_wirtinger_formula() {
25628        // Wirtinger: ∂|z|²/∂z̄ = z. With upstream g = 1, dz = z.
25629        let z = [(3.0_f32, 4.0_f32), (1.5_f32, -2.5_f32)];
25630        let g = [1.0_f32, 1.0_f32];
25631        let dz = run_complex_norm_sq_bwd(&z, &g);
25632        assert_close_c(dz[0], z[0], 1e-6, "dz[0] = g·z[0]");
25633        assert_close_c(dz[1], z[1], 1e-6, "dz[1] = g·z[1]");
25634    }
25635
25636    #[test]
25637    fn complex_norm_sq_backward_scales_with_upstream() {
25638        // With upstream g[i] ≠ 1: dz[i] = g[i]·z[i].
25639        let z = [(2.0_f32, 1.0_f32), (-1.0_f32, 3.0_f32)];
25640        let g = [0.5_f32, -2.0_f32];
25641        let dz = run_complex_norm_sq_bwd(&z, &g);
25642        assert_close_c(dz[0], (1.0, 0.5), 1e-6, "g=0.5 · (2,1)");
25643        assert_close_c(dz[1], (2.0, -6.0), 1e-6, "g=-2 · (-1,3)");
25644    }
25645
25646    /// Multi-output Op::CustomFn via the concat-with-Narrow design
25647    /// (rlx-ir::Graph::custom_fn_multi). Build a custom_fn whose
25648    /// fwd_body returns two outputs (x², 2x), then materialize each
25649    /// via the MultiOutputHandle and verify both numerically.
25650    #[test]
25651    fn custom_fn_multi_extracts_each_subgraph_output() {
25652        use rlx_ir::ops::special::MultiOutputHandle;
25653
25654        let _ = MultiOutputHandle {
25655            source: NodeId(0),
25656            sub_shapes: vec![],
25657            offsets: vec![],
25658        }; // import sanity
25659
25660        // Inner body: input x [3] f32, outputs (x², 2x) both [3] f32.
25661        let mut body = Graph::new("multi_body");
25662        let s3 = Shape::new(&[3], DType::F32);
25663        let x = body.input("x", s3.clone());
25664        let x_sq = body.binary(BinaryOp::Mul, x, x, s3.clone());
25665        let two = body.add_node(
25666            Op::Constant {
25667                data: vec![
25668                    2.0_f32.to_le_bytes(),
25669                    2.0_f32.to_le_bytes(),
25670                    2.0_f32.to_le_bytes(),
25671                ]
25672                .into_iter()
25673                .flatten()
25674                .collect(),
25675            },
25676            vec![],
25677            s3.clone(),
25678        );
25679        let two_x = body.binary(BinaryOp::Mul, two, x, s3.clone());
25680        body.set_outputs(vec![x_sq, two_x]);
25681
25682        // Outer graph: feed in_x → custom_fn_multi → handle.output(0/1).
25683        let mut outer = Graph::new("multi_outer");
25684        let in_x = outer.input("xin", s3.clone());
25685        let handle = outer.custom_fn_multi(vec![in_x], body);
25686        assert_eq!(handle.n_outputs(), 2);
25687        let out0 = handle.output(&mut outer, 0); // x²
25688        let out1 = handle.output(&mut outer, 1); // 2x
25689        outer.set_outputs(vec![out0, out1]);
25690
25691        let plan = rlx_opt::memory::plan_memory(&outer);
25692        let mut arena = crate::arena::Arena::from_plan(plan);
25693        let sched = compile_thunks(&outer, &arena);
25694        let xin_off = arena.byte_offset(in_x);
25695        let out0_off = arena.byte_offset(out0);
25696        let out1_off = arena.byte_offset(out1);
25697        let xs = [1.0_f32, 2.0, 3.0];
25698        unsafe {
25699            let p = arena.raw_buf_mut().as_mut_ptr().add(xin_off) as *mut f32;
25700            for (i, &v) in xs.iter().enumerate() {
25701                *p.add(i) = v;
25702            }
25703        }
25704        execute_thunks(&sched, arena.raw_buf_mut());
25705        let out0_v: Vec<f32> = unsafe {
25706            let p = arena.raw_buf().as_ptr().add(out0_off) as *const f32;
25707            (0..3).map(|i| *p.add(i)).collect()
25708        };
25709        let out1_v: Vec<f32> = unsafe {
25710            let p = arena.raw_buf().as_ptr().add(out1_off) as *const f32;
25711            (0..3).map(|i| *p.add(i)).collect()
25712        };
25713        // x² = [1, 4, 9]; 2x = [2, 4, 6].
25714        for i in 0..3 {
25715            assert!(
25716                (out0_v[i] - xs[i] * xs[i]).abs() < 1e-5,
25717                "out0[{i}] = {} != x² = {}",
25718                out0_v[i],
25719                xs[i] * xs[i]
25720            );
25721            assert!(
25722                (out1_v[i] - 2.0 * xs[i]).abs() < 1e-5,
25723                "out1[{i}] = {} != 2x = {}",
25724                out1_v[i],
25725                2.0 * xs[i]
25726            );
25727        }
25728    }
25729
25730    #[test]
25731    fn complex_norm_sq_gradient_matches_finite_difference() {
25732        // Numerical sanity: perturb z[0].re by ε, observe Δ|z|² ≈ 2·re·ε.
25733        let z = [(3.0_f32, 4.0_f32)];
25734        let eps = 1e-3_f32;
25735        let v0 = run_complex_norm_sq(&z)[0];
25736        let z_pert = [(3.0_f32 + eps, 4.0_f32)];
25737        let v1 = run_complex_norm_sq(&z_pert)[0];
25738        let fd_re = (v1 - v0) / eps;
25739        let analytic_re = 2.0 * z[0].0;
25740        assert!((fd_re - analytic_re).abs() < 1e-2);
25741
25742        // ∂/∂im at z = (3, 4) is 2·im = 8.
25743        let z_pert_im = [(3.0_f32, 4.0_f32 + eps)];
25744        let v2 = run_complex_norm_sq(&z_pert_im)[0];
25745        let fd_im = (v2 - v0) / eps;
25746        let analytic_im = 2.0 * z[0].1;
25747        assert!((fd_im - analytic_im).abs() < 1e-2);
25748
25749        // Compare with the Wirtinger backward at upstream g = 1.
25750        // Wirtinger ∂/∂z̄ = z gives dz = (re, im). The "real
25751        // gradient" wrt (re, im) is 2·(re, im), i.e. 2·dz = (2·re,
25752        // 2·im) — that's the factor 2 difference between Wirtinger
25753        // ∂/∂z̄ and the real-vector gradient on (re, im).
25754        let dz = run_complex_norm_sq_bwd(&z, &[1.0_f32]);
25755        assert!((2.0 * dz[0].0 - analytic_re).abs() < 1e-5);
25756        assert!((2.0 * dz[0].1 - analytic_im).abs() < 1e-5);
25757    }
25758
25759    /// Direct regression test for the 5-D mid-shape singleton broadcast
25760    /// (SAM rel_pos pattern: `[bh, h, w, 1, w] + [bh, h, w, h, w]`).
25761    /// The SAM port worked around this by `concat`-tiling the rhs; this
25762    /// test verifies the in-graph broadcast path is bit-correct.
25763    #[test]
25764    fn binary_full_5d_mid_singleton_broadcast() {
25765        let bh = 2usize;
25766        let h = 3;
25767        let w = 4;
25768        let f = DType::F32;
25769
25770        let mut g = Graph::new("bcast_5d");
25771        let lhs = g.input("lhs", Shape::new(&[bh, h, w, h, w], f));
25772        // rhs shape with size-1 at axis 3 (mid-shape singleton).
25773        let rhs = g.input("rhs", Shape::new(&[bh, h, w, 1, w], f));
25774        let out = g.binary(BinaryOp::Add, lhs, rhs, Shape::new(&[bh, h, w, h, w], f));
25775        g.set_outputs(vec![out]);
25776
25777        // Deterministic data.
25778        let lhs_data: Vec<f32> = (0..bh * h * w * h * w).map(|i| i as f32 * 0.01).collect();
25779        let rhs_data: Vec<f32> = (0..bh * h * w * w)
25780            .map(|i| (i as f32 + 100.0) * 0.01)
25781            .collect();
25782
25783        // Compute expected output by hand.
25784        let mut expected = vec![0f32; bh * h * w * h * w];
25785        for b_ in 0..bh {
25786            for hq in 0..h {
25787                for wq in 0..w {
25788                    for hk in 0..h {
25789                        for wk in 0..w {
25790                            let li = (((b_ * h + hq) * w + wq) * h + hk) * w + wk;
25791                            // rhs has hk dim = 1, so it's always index 0 there.
25792                            let ri = ((b_ * h + hq) * w + wq) * w + wk;
25793                            expected[li] = lhs_data[li] + rhs_data[ri];
25794                        }
25795                    }
25796                }
25797            }
25798        }
25799
25800        let plan = rlx_opt::memory::plan_memory(&g);
25801        let mut arena = crate::arena::Arena::from_plan(plan);
25802        let sched = compile_thunks(&g, &arena);
25803        let lhs_off = arena.byte_offset(lhs);
25804        let rhs_off = arena.byte_offset(rhs);
25805        let out_off = arena.byte_offset(out);
25806        let buf = arena.raw_buf_mut();
25807        unsafe {
25808            let p = buf.as_mut_ptr().add(lhs_off) as *mut f32;
25809            for (i, &v) in lhs_data.iter().enumerate() {
25810                *p.add(i) = v;
25811            }
25812            let p = buf.as_mut_ptr().add(rhs_off) as *mut f32;
25813            for (i, &v) in rhs_data.iter().enumerate() {
25814                *p.add(i) = v;
25815            }
25816        }
25817        execute_thunks(&sched, arena.raw_buf_mut());
25818        let actual: Vec<f32> = unsafe {
25819            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
25820            (0..bh * h * w * h * w).map(|i| *p.add(i)).collect()
25821        };
25822
25823        // Bit-exact check.
25824        let mut max_diff = 0f32;
25825        let mut max_idx = 0;
25826        for i in 0..actual.len() {
25827            let d = (actual[i] - expected[i]).abs();
25828            if d > max_diff {
25829                max_diff = d;
25830                max_idx = i;
25831            }
25832        }
25833        assert!(
25834            max_diff < 1e-6,
25835            "5D mid-shape singleton broadcast wrong: max |Δ| = {max_diff} at idx {max_idx} \
25836             (actual={}, expected={})",
25837            actual[max_idx],
25838            expected[max_idx]
25839        );
25840    }
25841
25842    #[test]
25843    fn layer_norm2d_and_conv_transpose2d_kernels() {
25844        let mut out = vec![0f32; 8];
25845        crate::kernels::layer_norm2d_nchw(
25846            &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
25847            &[1.0, 1.0],
25848            &[0.0, 0.0],
25849            &mut out,
25850            1,
25851            2,
25852            2,
25853            2,
25854            1e-5,
25855        );
25856        let mean0: f32 = (1.0 + 3.0) / 2.0;
25857        assert!((out[0] - mean0).abs() > 0.1);
25858
25859        let mut up = vec![0f32; 4];
25860        crate::kernels::conv_transpose2d_nchw(
25861            &[2.0],
25862            &[1.0, 0.0, 0.0, 1.0],
25863            &mut up,
25864            1,
25865            1,
25866            1,
25867            1,
25868            1,
25869            2,
25870            2,
25871            2,
25872            2,
25873            2,
25874            2,
25875            0,
25876            0,
25877            1,
25878            1,
25879            1,
25880        );
25881        assert!((up[0] - 2.0).abs() < 1e-5);
25882        assert!((up[3] - 2.0).abs() < 1e-5);
25883    }
25884
25885    /// End-to-end native-low-precision GEMM oracle: build the full
25886    /// ScaledQuantScale → ScaledQuantize → ScaledMatMul pipeline through the
25887    /// thunk path and check the f32-accumulated result tracks a plain f32 TN
25888    /// matmul (cosine), for every format × scale-layout. This exercises shape
25889    /// inference, memory planning, the build arms, and both execute paths.
25890    #[test]
25891    fn scaled_matmul_oracle_matches_f32() {
25892        use rlx_ir::ScaledFormat::*;
25893        use rlx_ir::{ScaleLayout, ScaledFormat};
25894
25895        fn cosine(a: &[f32], b: &[f32]) -> f32 {
25896            let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
25897            let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
25898            let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
25899            dot / (na * nb)
25900        }
25901
25902        #[allow(clippy::too_many_arguments)]
25903        fn run_scaled(
25904            lhs: &[f32],
25905            rhs: &[f32],
25906            m: usize,
25907            k: usize,
25908            n: usize,
25909            lf: ScaledFormat,
25910            rf: ScaledFormat,
25911            layout: ScaleLayout,
25912        ) -> Vec<f32> {
25913            let f = DType::F32;
25914            let u8t = DType::U8;
25915            let mut g = Graph::new("scaled");
25916            let lhs_in = g.input("lhs", Shape::new(&[m, k], f));
25917            let rhs_in = g.input("rhs", Shape::new(&[n, k], f));
25918            let (ls_shape, rs_shape) = match layout {
25919                ScaleLayout::PerTensor => (Shape::new(&[1], f), Shape::new(&[1], f)),
25920                _ => {
25921                    let nb = k.div_ceil(layout.block() as usize);
25922                    (Shape::new(&[m, nb], u8t), Shape::new(&[n, nb], u8t))
25923                }
25924            };
25925            let ls = g.add_node(
25926                Op::ScaledQuantScale {
25927                    format: lf,
25928                    scale_layout: layout,
25929                },
25930                vec![lhs_in],
25931                ls_shape,
25932            );
25933            let lq = g.add_node(
25934                Op::ScaledQuantize {
25935                    format: lf,
25936                    scale_layout: layout,
25937                },
25938                vec![lhs_in, ls],
25939                Shape::new(&[m, k], u8t),
25940            );
25941            let rs = g.add_node(
25942                Op::ScaledQuantScale {
25943                    format: rf,
25944                    scale_layout: layout,
25945                },
25946                vec![rhs_in],
25947                rs_shape,
25948            );
25949            let rq = g.add_node(
25950                Op::ScaledQuantize {
25951                    format: rf,
25952                    scale_layout: layout,
25953                },
25954                vec![rhs_in, rs],
25955                Shape::new(&[n, k], u8t),
25956            );
25957            let out = g.add_node(
25958                Op::ScaledMatMul {
25959                    lhs_format: lf,
25960                    rhs_format: rf,
25961                    scale_layout: layout,
25962                    has_bias: false,
25963                },
25964                vec![lq, rq, ls, rs],
25965                Shape::new(&[m, n], f),
25966            );
25967            g.set_outputs(vec![out]);
25968
25969            let plan = rlx_opt::memory::plan_memory(&g);
25970            let mut arena = crate::arena::Arena::from_plan(plan);
25971            let sched = compile_thunks(&g, &arena);
25972            let lhs_off = arena.byte_offset(lhs_in);
25973            let rhs_off = arena.byte_offset(rhs_in);
25974            let out_off = arena.byte_offset(out);
25975            let buf = arena.raw_buf_mut();
25976            unsafe {
25977                let lp = buf.as_mut_ptr().add(lhs_off) as *mut f32;
25978                for (i, &v) in lhs.iter().enumerate() {
25979                    *lp.add(i) = v;
25980                }
25981                let rp = buf.as_mut_ptr().add(rhs_off) as *mut f32;
25982                for (i, &v) in rhs.iter().enumerate() {
25983                    *rp.add(i) = v;
25984                }
25985            }
25986            execute_thunks(&sched, arena.raw_buf_mut());
25987            unsafe {
25988                let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
25989                (0..m * n).map(|i| *p.add(i)).collect()
25990            }
25991        }
25992
25993        let (m, k, n) = (4usize, 64usize, 8usize);
25994        let lhs: Vec<f32> = (0..m * k).map(|i| (i as f32 * 0.13).sin() * 1.5).collect();
25995        let rhs: Vec<f32> = (0..n * k).map(|i| (i as f32 * 0.07).cos() * 1.2).collect();
25996        let mut reference = vec![0f32; m * n];
25997        for i in 0..m {
25998            for j in 0..n {
25999                let mut acc = 0f32;
26000                for p in 0..k {
26001                    acc += lhs[i * k + p] * rhs[j * k + p];
26002                }
26003                reference[i * n + j] = acc;
26004            }
26005        }
26006
26007        // Per-tensor scaling across all 7 element formats.
26008        let cases = [
26009            (F8E4M3, 0.999f32),
26010            (F8E5M2, 0.99),
26011            (F8E4M3Fnuz, 0.999),
26012            (F8E5M2Fnuz, 0.99),
26013            (F6E2M3, 0.99),
26014            (F6E3M2, 0.98),
26015            (F4E2M1, 0.90),
26016        ];
26017        for (fmt, thresh) in cases {
26018            let out = run_scaled(&lhs, &rhs, m, k, n, fmt, fmt, ScaleLayout::PerTensor);
26019            let c = cosine(&out, &reference);
26020            assert!(c >= thresh, "{fmt} per-tensor cosine {c} < {thresh}");
26021        }
26022
26023        // Block layouts: MX E8M0 (FP8) and NVFP4 (FP4) — finer scaling, higher fidelity.
26024        let out_mx = run_scaled(&lhs, &rhs, m, k, n, F8E4M3, F8E4M3, ScaleLayout::mx());
26025        let c_mx = cosine(&out_mx, &reference);
26026        assert!(c_mx >= 0.999, "mx-e8m0 e4m3 cosine {c_mx}");
26027        let out_nv = run_scaled(&lhs, &rhs, m, k, n, F4E2M1, F4E2M1, ScaleLayout::nvfp4());
26028        let c_nv = cosine(&out_nv, &reference);
26029        assert!(c_nv >= 0.95, "nvfp4 e2m1 cosine {c_nv}");
26030    }
26031
26032    /// `Op::ScaledDequantize` (`decode(code)·scale`) must invert
26033    /// `Op::ScaledQuantize`: a quantize→dequantize round-trip reconstructs the
26034    /// original f32 within the format's resolution. Exercises the standalone
26035    /// dequantizer the `ScaledMatMul` backward graph relies on.
26036    #[test]
26037    fn scaled_dequantize_inverts_quantize() {
26038        use rlx_ir::ScaledFormat::*;
26039        use rlx_ir::{ScaleLayout, ScaledFormat};
26040
26041        fn cosine(a: &[f32], b: &[f32]) -> f32 {
26042            let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
26043            let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
26044            let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
26045            dot / (na * nb)
26046        }
26047
26048        fn roundtrip(x: &[f32], rows: usize, cols: usize, fmt: ScaledFormat) -> Vec<f32> {
26049            let f = DType::F32;
26050            let u8t = DType::U8;
26051            let layout = ScaleLayout::PerTensor;
26052            let mut g = Graph::new("dequant_rt");
26053            let x_in = g.input("x", Shape::new(&[rows, cols], f));
26054            let scale = g.add_node(
26055                Op::ScaledQuantScale {
26056                    format: fmt,
26057                    scale_layout: layout,
26058                },
26059                vec![x_in],
26060                Shape::new(&[1], f),
26061            );
26062            let codes = g.add_node(
26063                Op::ScaledQuantize {
26064                    format: fmt,
26065                    scale_layout: layout,
26066                },
26067                vec![x_in, scale],
26068                Shape::new(&[rows, cols], u8t),
26069            );
26070            let recon = g.add_node(
26071                Op::ScaledDequantize {
26072                    format: fmt,
26073                    scale_layout: layout,
26074                },
26075                vec![codes, scale],
26076                Shape::new(&[rows, cols], f),
26077            );
26078            g.set_outputs(vec![recon]);
26079
26080            let plan = rlx_opt::memory::plan_memory(&g);
26081            let mut arena = crate::arena::Arena::from_plan(plan);
26082            let sched = compile_thunks(&g, &arena);
26083            let x_off = arena.byte_offset(x_in);
26084            let r_off = arena.byte_offset(recon);
26085            unsafe {
26086                let p = arena.raw_buf_mut().as_mut_ptr().add(x_off) as *mut f32;
26087                for (i, &v) in x.iter().enumerate() {
26088                    *p.add(i) = v;
26089                }
26090            }
26091            execute_thunks(&sched, arena.raw_buf_mut());
26092            unsafe {
26093                let p = arena.raw_buf().as_ptr().add(r_off) as *const f32;
26094                (0..rows * cols).map(|i| *p.add(i)).collect()
26095            }
26096        }
26097
26098        let (rows, cols) = (4usize, 16usize);
26099        let x: Vec<f32> = (0..rows * cols)
26100            .map(|i| (i as f32 * 0.21).sin() * 1.7)
26101            .collect();
26102        // FP8 reconstructs tightly; FP4 is coarse but still strongly correlated.
26103        for (fmt, min_cos) in [(F8E4M3, 0.999f32), (F8E5M2, 0.99), (F4E2M1, 0.93)] {
26104            let recon = roundtrip(&x, rows, cols, fmt);
26105            assert_eq!(recon.len(), x.len());
26106            assert!(
26107                recon.iter().all(|v| v.is_finite()),
26108                "{fmt:?} produced non-finite"
26109            );
26110            let c = cosine(&recon, &x);
26111            assert!(c >= min_cos, "{fmt:?} round-trip cosine {c} < {min_cos}");
26112        }
26113    }
26114
26115    /// `Op::Fma` computes the single-rounded `a*b + c` elementwise. Verify it
26116    /// matches `f32::mul_add` (the hardware FMA), and that the `LowerFma`
26117    /// fallback (`Mul` + `Add`) stays close on a benign case.
26118    #[test]
26119    fn fma_matches_mul_add() {
26120        let f = DType::F32;
26121        let n = 9usize;
26122        let a: Vec<f32> = vec![1.5, -2.0, 0.0, 3.25, -1.1, 7.0, -0.5, 2.2, 9.9];
26123        let b: Vec<f32> = vec![2.0, 0.5, 4.0, -1.0, 6.0, -2.5, 8.0, -3.3, 0.1];
26124        let c: Vec<f32> = vec![0.25, 1.0, -3.0, 2.0, -0.5, 4.0, 1.5, -2.2, 0.0];
26125
26126        let mut g = Graph::new("fma");
26127        let an = g.input("a", Shape::new(&[n], f));
26128        let bn = g.input("b", Shape::new(&[n], f));
26129        let cn = g.input("c", Shape::new(&[n], f));
26130        let out = g.add_node(Op::Fma, vec![an, bn, cn], Shape::new(&[n], f));
26131        g.set_outputs(vec![out]);
26132
26133        let actual = run_graph(&g, &[(an, &a), (bn, &b), (cn, &c)], out, n);
26134        for i in 0..n {
26135            let expected = a[i].mul_add(b[i], c[i]);
26136            assert!(
26137                (actual[i] - expected).abs() <= f32::EPSILON * (1.0 + expected.abs()),
26138                "fma[{i}]: {} vs mul_add {expected}",
26139                actual[i]
26140            );
26141        }
26142    }
26143
26144    /// End-to-end AMP-FP8: take a plain `MatMul` graph, run the
26145    /// `insert_scaled_matmul` compile pass, then execute the rewritten graph on
26146    /// CPU and confirm it tracks the f32 matmul. Proves the pass emits a
26147    /// runnable, numerically-sound graph (rhs transpose + dynamic quantize).
26148    #[test]
26149    fn scaled_quant_pass_runs_end_to_end() {
26150        use rlx_opt::rlx_compile::scaled_quant_insert::{ScaledQuantConfig, insert_scaled_matmul};
26151
26152        let f = DType::F32;
26153        let (m, k, n) = (3usize, 16usize, 5usize);
26154        let mut g = Graph::new("amp_fp8");
26155        let x = g.input("x", Shape::new(&[m, k], f));
26156        let w = g.param("w", Shape::new(&[k, n], f));
26157        let mm = g.matmul(x, w, Shape::new(&[m, n], f));
26158        g.set_outputs(vec![mm]);
26159
26160        let g = insert_scaled_matmul(g, ScaledQuantConfig::fp8_e4m3());
26161
26162        let x_data: Vec<f32> = (0..m * k).map(|i| (i as f32 * 0.11).sin()).collect();
26163        let w_data: Vec<f32> = (0..k * n).map(|i| (i as f32 * 0.05).cos()).collect();
26164        // reference NN matmul: out[i,j] = Σ_p x[i,p]·w[p,j]
26165        let mut reference = vec![0f32; m * n];
26166        for i in 0..m {
26167            for j in 0..n {
26168                let mut acc = 0f32;
26169                for p in 0..k {
26170                    acc += x_data[i * k + p] * w_data[p * n + j];
26171                }
26172                reference[i * n + j] = acc;
26173            }
26174        }
26175
26176        // Locate the (preserved) input/param + output nodes in the rewritten graph.
26177        let mut x_id = None;
26178        let mut w_id = None;
26179        for node in g.nodes() {
26180            match &node.op {
26181                Op::Input { name } if name == "x" => x_id = Some(node.id),
26182                Op::Param { name } if name == "w" => w_id = Some(node.id),
26183                _ => {}
26184            }
26185        }
26186        let (x_id, w_id) = (x_id.unwrap(), w_id.unwrap());
26187        let out_id = g.outputs[0];
26188
26189        let plan = rlx_opt::memory::plan_memory(&g);
26190        let mut arena = crate::arena::Arena::from_plan(plan);
26191        let sched = compile_thunks(&g, &arena);
26192        let x_off = arena.byte_offset(x_id);
26193        let w_off = arena.byte_offset(w_id);
26194        let out_off = arena.byte_offset(out_id);
26195        let buf = arena.raw_buf_mut();
26196        unsafe {
26197            let xp = buf.as_mut_ptr().add(x_off) as *mut f32;
26198            for (i, &v) in x_data.iter().enumerate() {
26199                *xp.add(i) = v;
26200            }
26201            let wp = buf.as_mut_ptr().add(w_off) as *mut f32;
26202            for (i, &v) in w_data.iter().enumerate() {
26203                *wp.add(i) = v;
26204            }
26205        }
26206        execute_thunks(&sched, arena.raw_buf_mut());
26207        let actual: Vec<f32> = unsafe {
26208            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
26209            (0..m * n).map(|i| *p.add(i)).collect()
26210        };
26211
26212        let dot: f32 = actual.iter().zip(&reference).map(|(a, b)| a * b).sum();
26213        let na = actual.iter().map(|x| x * x).sum::<f32>().sqrt();
26214        let nb = reference.iter().map(|x| x * x).sum::<f32>().sqrt();
26215        let cos = dot / (na * nb);
26216        assert!(cos >= 0.999, "AMP-fp8 e2e cosine {cos}");
26217    }
26218}