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    /// 3D convolution on NCDHW. Input [N, C_in, D, H, W], weight
722    /// [C_out, C_in/groups, kD, kH, kW], output [N, C_out, D_out, H_out, W_out].
723    /// Naive direct convolution; the depth-axis analogue of `Thunk::Conv2D`.
724    Conv3d {
725        src: usize,
726        weight: usize,
727        dst: usize,
728        n: u32,
729        c_in: u32,
730        d: u32,
731        h: u32,
732        w: u32,
733        c_out: u32,
734        d_out: u32,
735        h_out: u32,
736        w_out: u32,
737        kd: u32,
738        kh: u32,
739        kw: u32,
740        sd: u32,
741        sh: u32,
742        sw: u32,
743        pd: u32,
744        ph: u32,
745        pw: u32,
746        dd: u32,
747        dh: u32,
748        dw: u32,
749        groups: u32,
750    },
751    /// ConvTranspose3d on NCDHW. Weight [C_in, C_out/groups, kD, kH, kW].
752    /// `output_padding` is already folded into `d_out/h_out/w_out`.
753    ConvTranspose3d {
754        src: usize,
755        weight: usize,
756        dst: usize,
757        n: u32,
758        c_in: u32,
759        d: u32,
760        h: u32,
761        w_in: u32,
762        c_out: u32,
763        d_out: u32,
764        h_out: u32,
765        w_out: u32,
766        kd: u32,
767        kh: u32,
768        kw: u32,
769        sd: u32,
770        sh: u32,
771        sw: u32,
772        pd: u32,
773        ph: u32,
774        pw: u32,
775        dd: u32,
776        dh: u32,
777        dw: u32,
778        groups: u32,
779    },
780    /// Nearest 2× upsample on NCHW (per-batch slice).
781    ResizeNearest2x {
782        src: usize,
783        dst: usize,
784        n: u32,
785        c: u32,
786        h: u32,
787        w: u32,
788    },
789    /// SAM2 axial 2-D RoPE on `[batch, seq, num_heads * head_dim]`.
790    AxialRope2d {
791        src: usize,
792        dst: usize,
793        batch: u32,
794        seq: u32,
795        hidden: u32,
796        end_x: u32,
797        end_y: u32,
798        head_dim: u32,
799        num_heads: u32,
800        theta: f32,
801        repeat_factor: u32,
802    },
803    /// RMSNorm: out = (x / sqrt(mean(x^2) + eps)) * gamma + beta. No mean
804    /// subtraction, hence cheaper than LayerNorm. Used by Llama-class models.
805    RmsNorm {
806        src: usize,
807        g: usize,
808        b: usize,
809        dst: usize,
810        rows: u32,
811        h: u32,
812        eps: f32,
813    },
814    /// Softmax
815    Softmax {
816        data: usize,
817        rows: u32,
818        cols: u32,
819    },
820    /// Inclusive (or exclusive) cumulative sum along the last axis
821    /// (callers pre-flatten higher-dim cumsums via reshape views).
822    Cumsum {
823        src: usize,
824        dst: usize,
825        rows: u32,
826        cols: u32,
827        exclusive: bool,
828    },
829    /// Mamba-style selective scan (plan #15).
830    /// Inputs: x, delta \[b,s,h\], a \[h,n\], b \[b,s,n\], c \[b,s,n\].
831    /// Output: y \[b,s,h\]. State h carries through the seq.
832    SelectiveScan {
833        x: usize,
834        delta: usize,
835        a: usize,
836        b: usize,
837        c: usize,
838        dst: usize,
839        batch: u32,
840        seq: u32,
841        hidden: u32,
842        state_size: u32,
843    },
844
845    /// Gated DeltaNet linear-attention scan (Qwen3.5/3.6 trunk).
846    /// Inputs: q, k, v `[b, s, h, n]`; g, beta `[b, s, h]`. Output:
847    /// `[b, s, h, n]`. See `Op::GatedDeltaNet` for math.
848    GatedDeltaNet {
849        q: usize,
850        k: usize,
851        v: usize,
852        g: usize,
853        beta: usize,
854        /// When non-zero, load initial `[b, h, n, n]` state and write
855        /// the final state back in place after the scan.
856        state: usize,
857        dst: usize,
858        batch: u32,
859        seq: u32,
860        heads: u32,
861        state_size: u32,
862    },
863
864    /// Multi-layer (optionally bidirectional, optional carry) LSTM with
865    /// packed weights. See `Op::Lstm`. `h0`/`c0` are valid only when
866    /// `carry`; `dst` is `[b, s, D*h]`.
867    Lstm {
868        x: usize,
869        w_ih: usize,
870        w_hh: usize,
871        bias: usize,
872        h0: usize,
873        c0: usize,
874        dst: usize,
875        batch: u32,
876        seq: u32,
877        input_size: u32,
878        hidden: u32,
879        num_layers: u32,
880        bidirectional: bool,
881        carry: bool,
882    },
883    /// GRU (gate order r, z, n) with separate `b_ih`/`b_hh`. See `Op::Gru`.
884    Gru {
885        x: usize,
886        w_ih: usize,
887        w_hh: usize,
888        b_ih: usize,
889        b_hh: usize,
890        h0: usize,
891        dst: usize,
892        batch: u32,
893        seq: u32,
894        input_size: u32,
895        hidden: u32,
896        num_layers: u32,
897        bidirectional: bool,
898        carry: bool,
899    },
900    /// Elman RNN (`tanh`/`relu`), single merged bias. See `Op::Rnn`.
901    Rnn {
902        x: usize,
903        w_ih: usize,
904        w_hh: usize,
905        bias: usize,
906        h0: usize,
907        dst: usize,
908        batch: u32,
909        seq: u32,
910        input_size: u32,
911        hidden: u32,
912        num_layers: u32,
913        bidirectional: bool,
914        carry: bool,
915        relu: bool,
916    },
917    /// Mamba-2 / SSD scalar-decay SSM scan. See `Op::Mamba2`.
918    Mamba2 {
919        x: usize,
920        dt: usize,
921        a: usize,
922        b: usize,
923        c: usize,
924        dst: usize,
925        batch: u32,
926        seq: u32,
927        heads: u32,
928        head_dim: u32,
929        state_size: u32,
930    },
931
932    /// 1×1 conv fast path (plan #26). The general Conv2D thunk
933    /// runs the textbook 7-deep loop; a 1×1 stride-1 padding-0
934    /// groups-1 conv is mathematically a per-batch matmul, and
935    /// dispatching it through BLAS is 3-10× faster than the
936    /// scalar nest. Common case: ViT patch-projection follow-on,
937    /// transformer "expert" reductions in some MoE designs.
938    ///
939    /// Per batch: weight `[c_out, c_in]` × input `[c_in, h*w]`
940    ///         = output `[c_out, h*w]`.
941    Conv2D1x1 {
942        src: usize,
943        weight: usize,
944        dst: usize,
945        n: u32,
946        c_in: u32,
947        c_out: u32,
948        hw: u32,
949    },
950
951    /// Fused dequant + matmul (plan #5). Today supports
952    /// `QuantScheme::Int8Block` (symmetric); other schemes panic
953    /// at lowering time with a clear message until kernels are added.
954    DequantMatMul {
955        x: usize,
956        w_q: usize,   // packed i8 bytes for Int8 schemes
957        scale: usize, // [k/block, n] f32 scale
958        zp: usize,    // [k/block, n] f32 zero-point (0 for sym)
959        dst: usize,
960        m: u32,
961        k: u32,
962        n: u32,
963        block_size: u32,
964        is_asymmetric: bool,
965    },
966
967    /// GGUF-format dequant + matmul. Weight is a packed byte tensor
968    /// in one of the K-quant super-block layouts (Q4_K, Q5_K, Q6_K,
969    /// Q8_K). Scales / mins live inside the packed bytes — no
970    /// side-channel scale tensor.
971    ///
972    /// Today this is a "dequant-to-scratch then sgemm" kernel — it
973    /// keeps the *arena* memory footprint down (weights stay packed)
974    /// but the dequant itself happens per matmul. A future fully
975    /// fused tile-streaming kernel would close the compute gap.
976    DequantMatMulGguf {
977        x: usize,   // f32 activations [m, k]
978        w_q: usize, // packed weight bytes (k*n elements packed)
979        dst: usize, // f32 output [m, n]
980        m: u32,
981        k: u32,
982        n: u32,
983        scheme: rlx_ir::quant::QuantScheme,
984    },
985
986    /// Int4 block dequant + matmul (packed nibbles, side scale/zp).
987    DequantMatMulInt4 {
988        x: usize,
989        w_q: usize,
990        scale: usize,
991        zp: usize,
992        dst: usize,
993        m: u32,
994        k: u32,
995        n: u32,
996        block_size: u32,
997        is_asymmetric: bool,
998    },
999
1000    /// FP8 dequant + matmul (per-tensor or per-column scale).
1001    DequantMatMulFp8 {
1002        x: usize,
1003        w_q: usize,
1004        scale: usize,
1005        dst: usize,
1006        m: u32,
1007        k: u32,
1008        n: u32,
1009        e5m2: bool,
1010    },
1011
1012    /// NVFP4 (E2M1) block dequant + matmul — 16-wide groups, FP8 scales.
1013    DequantMatMulNvfp4 {
1014        x: usize,
1015        w_q: usize,
1016        scale: usize,
1017        global_scale: usize,
1018        dst: usize,
1019        m: u32,
1020        k: u32,
1021        n: u32,
1022    },
1023
1024    /// Native low-precision scaled GEMM (FP8/FP6/FP4) — CPU reference oracle.
1025    /// TN layout: lhs [m,k], rhs [n,k] codes, out [m,n] f32.
1026    ScaledMatMul {
1027        lhs: usize,
1028        rhs: usize,
1029        lhs_scale: usize,
1030        rhs_scale: usize,
1031        bias: usize, // valid iff has_bias
1032        dst: usize,
1033        m: u32,
1034        k: u32,
1035        n: u32,
1036        lhs_fmt: rlx_ir::ScaledFormat,
1037        rhs_fmt: rlx_ir::ScaledFormat,
1038        layout: rlx_ir::ScaleLayout,
1039        has_bias: bool,
1040    },
1041
1042    /// Quantize f32 → packed low-precision codes (reads the scale tensor).
1043    ScaledQuantize {
1044        x: usize,
1045        scale: usize,
1046        dst: usize,
1047        rows: u32,
1048        cols: u32,
1049        fmt: rlx_ir::ScaledFormat,
1050        layout: rlx_ir::ScaleLayout,
1051    },
1052
1053    /// Compute the per-tensor / per-block scale tensor for `Op::ScaledQuantize`.
1054    ScaledQuantScale {
1055        x: usize,
1056        dst: usize,
1057        rows: u32,
1058        cols: u32,
1059        fmt: rlx_ir::ScaledFormat,
1060        layout: rlx_ir::ScaleLayout,
1061    },
1062
1063    /// Reconstruct f32 from packed codes (`Op::ScaledDequantize`).
1064    ScaledDequantize {
1065        codes: usize,
1066        scale: usize,
1067        dst: usize,
1068        rows: u32,
1069        cols: u32,
1070        fmt: rlx_ir::ScaledFormat,
1071        layout: rlx_ir::ScaleLayout,
1072    },
1073
1074    /// Fused LoRA matmul (plan #9): out = x·W + scale * (x·A)·B.
1075    /// `r` is the LoRA rank (typically 4-64) — the rank-r
1076    /// intermediate `x·A` lives in scratch, never on the arena.
1077    LoraMatMul {
1078        x: usize,
1079        w: usize,
1080        a: usize,
1081        b: usize,
1082        dst: usize,
1083        m: u32,
1084        k: u32,
1085        n: u32,
1086        r: u32,
1087        scale: f32,
1088    },
1089    /// Fused sample: logits [batch, vocab] → token ids \[batch\].
1090    /// See Op::Sample. Output values are f32-encoded usize indices
1091    /// (matches the rest of the IR's "ids as f32" convention).
1092    Sample {
1093        logits: usize,
1094        dst: usize,
1095        batch: u32,
1096        vocab: u32,
1097        top_k: u32,       // 0 = disabled
1098        top_p: f32,       // 1.0 = disabled
1099        temperature: f32, // 1.0 = neutral
1100        seed: u64,
1101    },
1102    /// ONNX `RandomNormalLike` fill.
1103    RngNormal {
1104        dst: usize,
1105        len: u32,
1106        mean: f32,
1107        scale: f32,
1108        key: u64,
1109        op_seed: Option<f32>,
1110    },
1111    /// ONNX `RandomUniformLike` fill.
1112    RngUniform {
1113        dst: usize,
1114        len: u32,
1115        low: f32,
1116        high: f32,
1117        key: u64,
1118        op_seed: Option<f32>,
1119    },
1120    /// Attention SDPA. `mask` is the offset of the optional mask tensor
1121    /// (only meaningful when `mask_kind == MaskKind::Custom`); other
1122    /// kinds synthesize the mask in-kernel.
1123    ///
1124    /// Q/K/V each carry a `_row_stride` (elements per source row).
1125    /// Defaults to `heads * head_dim` — matches the standalone
1126    /// "Q/K/V are their own contiguous buffers" case. The Narrow→
1127    /// Attention fusion below rewrites these to the parent QKV stride
1128    /// (typically `3 * heads * head_dim`) so the kernel reads QKV
1129    /// directly without materializing the per-head buffers (plan #46).
1130    Attention {
1131        q: usize,
1132        k: usize,
1133        v: usize,
1134        mask: usize,
1135        out: usize,
1136        batch: u32,
1137        /// Query sequence length.
1138        seq: u32,
1139        /// Key/value sequence length. Differs from `seq` during cached decode.
1140        kv_seq: u32,
1141        heads: u32,
1142        /// GQA/MQA: number of key/value heads that the query heads share.
1143        /// Equals `heads` for plain MHA (the common case); only `< heads` on
1144        /// the non-fused standalone `Op::Attention` path.
1145        kv_heads: u32,
1146        head_dim: u32,
1147        mask_kind: rlx_ir::op::MaskKind,
1148        /// Softmax score scale (`Op::Attention::score_scale`). `head_dim^-0.5`
1149        /// when the op left it unset. Must be honored — Gemma 4 uses `1.0`
1150        /// (Q/K are per-head RMS-normed, so no `1/sqrt(d)` pre-scale).
1151        scale: f32,
1152        /// Attention logit soft-cap (`Op::Attention::attn_logit_softcap`).
1153        /// `cap*tanh(score/cap)` applied pre-softmax; 0 = disabled. Gemma 2
1154        /// uses 50.0 — must be honored to match HF / rlx-metal / rlx-cuda.
1155        softcap: f32,
1156        q_row_stride: u32,
1157        k_row_stride: u32,
1158        v_row_stride: u32,
1159        /// Memory layout flag. `false` (the historical default) →
1160        /// `[B, S, H, D]` row-major: per-head offset is
1161        /// `bi*S*H*D + si*H*D + hi*D`. `true` → `[B, H, S, D]`
1162        /// (head-major), matching the convention used by rlx-cuda /
1163        /// rlx-rocm / rlx-tpu: per-head offset is
1164        /// `bi*H*S*D + hi*S*D + si*D`. Detected at lowering time
1165        /// from the input shape vs `num_heads` / `head_dim`.
1166        bhsd: bool,
1167    },
1168    /// [`Op::AttentionBackward`] — emits dQ, dK, or dV (see `wrt`).
1169    AttentionBackward {
1170        q: usize,
1171        k: usize,
1172        v: usize,
1173        dy: usize,
1174        mask: usize,
1175        out: usize,
1176        batch: u32,
1177        seq: u32,
1178        kv_seq: u32,
1179        heads: u32,
1180        head_dim: u32,
1181        mask_kind: rlx_ir::op::MaskKind,
1182        wrt: rlx_ir::op::AttentionBwdWrt,
1183        bhsd: bool,
1184    },
1185    /// RoPE (rotary position embeddings).
1186    /// `src_row_stride` is elements per source row (defaults to `hidden`
1187    /// for the standalone case; set to `qkv_axis * inner` when the
1188    /// thunk fusion pass below rewires Rope to read directly from the
1189    /// fused QKV buffer — plan #45).
1190    Rope {
1191        src: usize,
1192        cos: usize,
1193        sin: usize,
1194        dst: usize,
1195        batch: u32,
1196        seq: u32,
1197        hidden: u32,
1198        head_dim: u32,
1199        n_rot: u32,
1200        cos_len: u32,
1201        src_row_stride: u32,
1202        /// `true` = GPT-J / llama.cpp-NORM interleaved pairs `(2i, 2i+1)`;
1203        /// `false` = HF / NeoX rotate-half pairs `(i, i+n_rot/2)`.
1204        interleaved: bool,
1205    },
1206    /// Fused attention block: QKV proj → split → \[RoPE\] → SDPA → output proj.
1207    /// All intermediates stay in L1 cache. Zero arena writes between ops.
1208    FusedAttnBlock {
1209        hidden: usize,
1210        qkv_w: usize,
1211        out_w: usize,
1212        mask: usize,
1213        /// How to mask attention scores. `Custom` reads the per-key `mask`
1214        /// buffer (BERT-style padding); `Causal` / `SlidingWindow` are
1215        /// synthesized in-kernel with no buffer; `None` applies no mask.
1216        /// Mirrors [`Thunk::Attention`]'s `mask_kind` — the attention-block
1217        /// fusion MUST carry it through, otherwise a causal decoder silently
1218        /// attends to future tokens (the fused kernel would only ever apply
1219        /// the per-key padding mask).
1220        mask_kind: rlx_ir::op::MaskKind,
1221        out: usize,
1222        qkv_b: usize,
1223        out_b: usize, // 0 = no bias
1224        cos: usize,
1225        sin: usize,
1226        cos_len: u32, // 0 = no RoPE
1227        batch: u32,
1228        seq: u32,
1229        hs: u32,
1230        nh: u32,
1231        dh: u32,
1232        has_bias: bool,
1233        has_rope: bool,
1234        /// RoPE pairing: `true` = GPT-J interleaved `(2i,2i+1)`, `false` = NeoX
1235        /// rotate-half `(i,i+d/2)`. Captured from the fused `Op::Rope` so the
1236        /// inline rope matches the standalone kernel (a GptJ model must not be
1237        /// silently rotated NeoX-style by the fused path).
1238        interleaved: bool,
1239    },
1240    /// Fused ENTIRE transformer layer: attention + residual + LN + FFN + residual + LN.
1241    /// Combines ~10 thunks into 1. All intermediates on stack. Zero arena traffic.
1242    FusedBertLayer {
1243        // attention
1244        hidden: usize,
1245        qkv_w: usize,
1246        qkv_b: usize,
1247        out_w: usize,
1248        out_b: usize,
1249        mask: usize,
1250        // LN1
1251        ln1_g: usize,
1252        ln1_b: usize,
1253        eps1: f32,
1254        // FFN (GELU)
1255        fc1_w: usize,
1256        fc1_b: usize,
1257        fc2_w: usize,
1258        fc2_b: usize,
1259        // LN2
1260        ln2_g: usize,
1261        ln2_b: usize,
1262        eps2: f32,
1263        // output
1264        out: usize,
1265        // dims
1266        batch: u32,
1267        seq: u32,
1268        hs: u32,
1269        nh: u32,
1270        dh: u32,
1271        int_dim: u32,
1272    },
1273    /// Fused Nomic transformer layer: attention+RoPE + residual + LN + SwiGLU FFN + residual + LN.
1274    FusedNomicLayer {
1275        hidden: usize,
1276        qkv_w: usize,
1277        out_w: usize,
1278        mask: usize,
1279        cos: usize,
1280        sin: usize,
1281        cos_len: u32,
1282        ln1_g: usize,
1283        ln1_b: usize,
1284        eps1: f32,
1285        fc11_w: usize,
1286        fc12_w: usize,
1287        fc2_w: usize,
1288        ln2_g: usize,
1289        ln2_b: usize,
1290        eps2: f32,
1291        out: usize,
1292        batch: u32,
1293        seq: u32,
1294        hs: u32,
1295        nh: u32,
1296        dh: u32,
1297        int_dim: u32,
1298        /// RoPE pairing, threaded from the consumed `FusedAttnBlock`
1299        /// (`true` = GPT-J interleaved, `false` = NeoX rotate-half).
1300        interleaved: bool,
1301    },
1302    /// Fused SwiGLU: out\[r,i\] = x\[r,i\] * silu(x[r, n_half+i]).
1303    /// Input: [outer, 2*n_half] — concatenated up||gate per row.
1304    /// Output: [outer, n_half].
1305    FusedSwiGLU {
1306        src: usize,
1307        dst: usize,
1308        n_half: u32,
1309        total: u32,
1310        gate_first: bool,
1311    },
1312    /// Concat along an axis: output[outer, axis, inner] = inputs concatenated.
1313    /// Each entry of `inputs` is (src_offset, axis_len_for_that_input) in u32
1314    /// elements. `outer`, `inner`, and `total_axis_len` are pre-computed
1315    /// at compile time to avoid per-run shape work.
1316    Concat {
1317        dst: usize,
1318        outer: u32,
1319        inner: u32,
1320        total_axis: u32,
1321        /// `(src_offset, axis_extent, input_numel)` — `input_numel` enables
1322        /// outer-dim broadcast when rank-deficient inputs are concatenated.
1323        inputs: Vec<(usize, u32, u32)>,
1324    },
1325    /// Element-wise comparison: out = (lhs CMP rhs) ? 1 : 0 (Bool u8 or F32 0/1).
1326    Compare {
1327        lhs: usize,
1328        rhs: usize,
1329        dst: usize,
1330        len: u32,
1331        op: CmpOp,
1332        /// Nonzero when lhs/rhs are i64 (mask/range ops).
1333        inputs_i64: u8,
1334        /// Input element size (1 = Bool, 4 = F32, 8 = I64).
1335        inputs_elem_bytes: u8,
1336        /// Output element size (1 = Bool, 4 = F32).
1337        dst_elem_bytes: u8,
1338    },
1339    /// Reduction along a contiguous range of axes. Input layout (after
1340    /// shape decomposition) is `[outer, reduced, inner]`; output is
1341    /// `[outer, inner]`. The single-axis cases (axis=0 → outer=1;
1342    /// axis=last → inner=1) and contiguous multi-axis (e.g. reduce over
1343    /// [0, 1] of an [N, C, H, W] tensor → outer=1, reduced=N*C, inner=H*W)
1344    /// all map onto this triplet. Non-contiguous axes are not supported
1345    /// and bail to Nop in the compile pass.
1346    Reduce {
1347        src: usize,
1348        dst: usize,
1349        outer: u32,
1350        reduced: u32,
1351        inner: u32,
1352        op: ReduceOp,
1353    },
1354    /// Index of the max (`is_max`) or min along the reduced axis; writes the
1355    /// winning index as an f32 into `dst`.
1356    ArgReduce {
1357        src: usize,
1358        dst: usize,
1359        outer: u32,
1360        reduced: u32,
1361        inner: u32,
1362        is_max: bool,
1363    },
1364    /// Top-K **indices** along the last axis. Input shape `[outer, axis_dim]`,
1365    /// output `[outer, k]` (f32 or i64 per `indices_i64`). Ties broken by
1366    /// smaller index. Used by MoE gating + beam search.
1367    TopK {
1368        src: usize,
1369        dst: usize,
1370        outer: u32,
1371        axis_dim: u32,
1372        k: u32,
1373        indices_i64: u8,
1374    },
1375    /// Indexed batched matmul: out\[i\] = input\[i\] @ weight[expert_idx\[i\]].
1376    /// Naive impl per token; for real MoE workloads, sort-by-expert + run
1377    /// segmented GEMM would amortize. Done when there's a workload.
1378    GroupedMatMul {
1379        input: usize,
1380        weight: usize,
1381        expert_idx: usize,
1382        dst: usize,
1383        m: u32,
1384        k_dim: u32,
1385        n: u32,
1386        num_experts: u32,
1387    },
1388    /// GGUF K-quant packed expert stack + grouped matmul (MoE FFN).
1389    DequantGroupedMatMulGguf {
1390        input: usize,
1391        w_q: usize,
1392        expert_idx: usize,
1393        dst: usize,
1394        m: u32,
1395        k_dim: u32,
1396        n: u32,
1397        num_experts: u32,
1398        scheme: rlx_ir::quant::QuantScheme,
1399    },
1400    /// Materialize packed MoE weights to F32 `[E, K, N]` (autodiff helper).
1401    DequantMoEWeightsGguf {
1402        w_q: usize,
1403        dst: usize,
1404        k_dim: u32,
1405        n: u32,
1406        num_experts: u32,
1407        scheme: rlx_ir::quant::QuantScheme,
1408    },
1409    /// Scatter-add: dst[indices\[i\] * trailing + j] += updates[i * trailing + j].
1410    /// Output is zeroed first; multiple updates to the same row accumulate.
1411    ScatterAdd {
1412        updates: usize,
1413        indices: usize,
1414        dst: usize,
1415        num_updates: u32,
1416        out_dim: u32,
1417        trailing: u32,
1418    },
1419    /// Ternary select: out = cond != 0 ? on_true : on_false
1420    Where {
1421        cond: usize,
1422        on_true: usize,
1423        on_false: usize,
1424        dst: usize,
1425        len: u32,
1426        elem_bytes: u8,
1427        /// Element size for cond (1 = Bool mask, 4 = F32 0/1).
1428        cond_elem_bytes: u8,
1429    },
1430    /// Single-rounded fused multiply-add: `dst = a*b + c` (one rounding —
1431    /// enables error-free transforms / compensated arithmetic).
1432    Fma {
1433        a: usize,
1434        b: usize,
1435        c: usize,
1436        dst: usize,
1437        len: u32,
1438        elem_bytes: u8,
1439    },
1440    /// General N-D transpose / broadcast. `out_dims[i]` is the output's dim
1441    /// i length; `in_strides[i]` is the input stride (in elements) used to
1442    /// index that dim — 0 for broadcast dims (Expand). `in_total` is the
1443    /// total element count in the source buffer (≤ output total when
1444    /// broadcasting). Strides are pre-computed at compile time.
1445    Transpose {
1446        src: usize,
1447        dst: usize,
1448        in_total: u32,
1449        out_dims: Vec<u32>,
1450        in_strides: Vec<u32>,
1451        elem_bytes: u8,
1452    },
1453    /// Gather along an arbitrary axis. `outer = product(dims[..axis])`,
1454    /// `trailing = product(dims[axis+1..])`, `axis_dim` = the dimension
1455    /// being indexed into. Output: outer × num_idx × trailing.
1456    /// (axis=0 still routes to the simpler Thunk::Gather fast path.)
1457    GatherAxis {
1458        table: usize,
1459        idx: usize,
1460        dst: usize,
1461        outer: u32,
1462        axis_dim: u32,
1463        num_idx: u32,
1464        trailing: u32,
1465        idx_i64: u8,
1466        table_bytes: u8,
1467    },
1468    /// 2D pooling (Max or Mean). Input layout [N, C, H, W], output
1469    /// [N, C, H_out, W_out]. Padding is implicit-zero; Mean divides by
1470    /// the full kernel area (matches torch's `count_include_pad=True`).
1471    Pool2D {
1472        src: usize,
1473        dst: usize,
1474        n: u32,
1475        c: u32,
1476        h: u32,
1477        w: u32,
1478        h_out: u32,
1479        w_out: u32,
1480        kh: u32,
1481        kw: u32,
1482        sh: u32,
1483        sw: u32,
1484        ph: u32,
1485        pw: u32,
1486        kind: ReduceOp,
1487    },
1488    /// 2D convolution. Input [N, C_in, H, W], weight [C_out, C_in_per_group, kH, kW],
1489    /// output [N, C_out, H_out, W_out]. Bias is a separate Op::Binary::Add
1490    /// after the conv (matching the IR's input layout — Op::Conv has 2 inputs).
1491    /// Naive direct convolution; sufficient for correctness, not optimised.
1492    Conv2D {
1493        src: usize,
1494        weight: usize,
1495        dst: usize,
1496        n: u32,
1497        c_in: u32,
1498        h: u32,
1499        w: u32,
1500        c_out: u32,
1501        h_out: u32,
1502        w_out: u32,
1503        kh: u32,
1504        kw: u32,
1505        sh: u32,
1506        sw: u32,
1507        ph: u32,
1508        pw: u32,
1509        dh: u32,
1510        dw: u32,
1511        groups: u32,
1512    },
1513
1514    // ── Backward / training kernels ─────────────────────────────
1515    /// Real INT8 matmul with i32 accumulation.
1516    ///   `out[m, n] = requantize(bias[n] + Σₖ (x[m,k]-x_zp)·(w[k,n]-w_zp), mult, out_zp)`
1517    /// Reads `x` and `w` as i8, `bias` as i32; writes `out` as i8.
1518    /// Same kernel shape as `rlx_cortexm::dense::dense_i8` — promoted
1519    /// to a desktop thunk so a quantized graph compiled here doesn't
1520    /// have to round-trip through fake-quant.
1521    QMatMul {
1522        x: usize,
1523        w: usize,
1524        bias: usize,
1525        out: usize,
1526        m: u32,
1527        k: u32,
1528        n: u32,
1529        x_zp: i32,
1530        w_zp: i32,
1531        out_zp: i32,
1532        mult: f32,
1533    },
1534
1535    /// Real INT8 conv2d, NCHW layout. Same loop shape as `Thunk::Conv2D`
1536    /// but with i8 reads, i32 accumulation, and per-output requantize
1537    /// to i8. Bias is i32 in the accumulator scale.
1538    QConv2d {
1539        x: usize,
1540        w: usize,
1541        bias: usize,
1542        out: usize,
1543        n: u32,
1544        c_in: u32,
1545        h: u32,
1546        w_in: u32,
1547        c_out: u32,
1548        h_out: u32,
1549        w_out: u32,
1550        kh: u32,
1551        kw: u32,
1552        sh: u32,
1553        sw: u32,
1554        ph: u32,
1555        pw: u32,
1556        dh: u32,
1557        dw: u32,
1558        groups: u32,
1559        x_zp: i32,
1560        w_zp: i32,
1561        out_zp: i32,
1562        mult: f32,
1563    },
1564
1565    /// INT8 quantize. Reads `x` as f32, writes `q` as i8.
1566    /// `chan = (i / inner) % chan_dim` selects the per-channel
1567    /// scale/zp; `chan_axis` is informational only (the kernel uses
1568    /// `chan_dim` and `inner` directly).
1569    /// For per-tensor, `chan_dim = 1` and `inner = len` so `chan` is
1570    /// always 0.
1571    Quantize {
1572        x: usize,
1573        q: usize,
1574        len: u32,
1575        chan_axis: u32,
1576        chan_dim: u32,
1577        inner: u32,
1578        scales: Vec<f32>,
1579        zero_points: Vec<i32>,
1580    },
1581
1582    /// INT8 dequantize — inverse of `Thunk::Quantize`.
1583    Dequantize {
1584        q: usize,
1585        x: usize,
1586        len: u32,
1587        chan_axis: u32,
1588        chan_dim: u32,
1589        inner: u32,
1590        scales: Vec<f32>,
1591        zero_points: Vec<i32>,
1592    },
1593
1594    /// QAT fake-quantize. Per-channel (or per-tensor) symmetric
1595    /// quantize-then-dequantize on the fly. Computes
1596    ///   `s[c] = max(|x[..., c, ...]|) / q_max`
1597    /// then
1598    ///   `out[i] = clamp(round(x[i]/s[c]), -q_max, q_max) * s[c]`
1599    /// with `q_max = {127, 7, 1}` for `bits = {8, 4, 2}`. Same
1600    /// channel-layout convention as `Thunk::Quantize`: every
1601    /// element's channel is `(i / inner) % chan_dim`. The kernel
1602    /// does two passes — one to scan max-abs per channel, one to
1603    /// quant-dequant per element.
1604    FakeQuantize {
1605        x: usize,
1606        out: usize,
1607        len: u32,
1608        chan_axis: u32,
1609        chan_dim: u32,
1610        inner: u32,
1611        bits: u8,
1612        /// STE variant — informational on the forward side (output is
1613        /// the same regardless), kernel-relevant in the matching
1614        /// `FakeQuantizeBackward` thunk.
1615        ste: rlx_ir::op::SteKind,
1616        /// Scale-tracking strategy. `PerBatch` recomputes
1617        /// `max_abs/q_max` every call (the original path). `EMA{decay}`
1618        /// blends per-batch max-abs into the `state_off` buffer; `Fixed`
1619        /// reads `state_off` and never updates it.
1620        scale_mode: rlx_ir::op::ScaleMode,
1621        /// `Some(off)` for `EMA` and `Fixed`; `None` for `PerBatch`.
1622        /// Points at a `[chan_dim]` f32 buffer holding the running scale
1623        /// per channel.
1624        state_off: Option<usize>,
1625    },
1626
1627    /// Backward pass for `Op::FakeQuantize` under one of four STE
1628    /// variants. Computes `dx[i]` from the f32 forward input `x` and
1629    /// the upstream gradient `dy`, using the same per-channel scale
1630    /// scheme as the forward.
1631    FakeQuantizeBackward {
1632        x: usize,
1633        dy: usize,
1634        dx: usize,
1635        len: u32,
1636        chan_axis: u32,
1637        chan_dim: u32,
1638        inner: u32,
1639        bits: u8,
1640        ste: rlx_ir::op::SteKind,
1641    },
1642
1643    /// LSQ forward — same kernel shape as `FakeQuantize` Fixed mode.
1644    /// Reads scale from `scale_off` (a `[chan_dim]` Param tensor).
1645    FakeQuantizeLSQ {
1646        x: usize,
1647        scale_off: usize,
1648        out: usize,
1649        len: u32,
1650        chan_axis: u32,
1651        chan_dim: u32,
1652        inner: u32,
1653        bits: u8,
1654    },
1655
1656    /// LSQ backward, x-gradient. STE-clipped: passes upstream
1657    /// through inside the quantization range, zeros outside.
1658    FakeQuantizeLSQBackwardX {
1659        x: usize,
1660        scale_off: usize,
1661        dy: usize,
1662        dx: usize,
1663        len: u32,
1664        chan_axis: u32,
1665        chan_dim: u32,
1666        inner: u32,
1667        bits: u8,
1668    },
1669
1670    /// LSQ backward, scale-gradient. Per-channel:
1671    ///   `dscale[c] = sum_i ψ(x[i]/s[c]) · upstream[i]`
1672    /// where `ψ(z) = -z + round(z)` if `|z| ≤ q_max` else
1673    /// `sign(z) · q_max`. Output shape: `[chan_dim]`.
1674    FakeQuantizeLSQBackwardScale {
1675        x: usize,
1676        scale_off: usize,
1677        dy: usize,
1678        dscale: usize,
1679        len: u32,
1680        chan_axis: u32,
1681        chan_dim: u32,
1682        inner: u32,
1683        bits: u8,
1684    },
1685
1686    /// ReLU backward: `dx[i] = dy[i] if x[i] > 0 else 0`.
1687    ReluBackward {
1688        x: usize,
1689        dy: usize,
1690        dx: usize,
1691        len: u32,
1692    },
1693    /// f64 sibling of `ReluBackward` — same shape as the f32 variant
1694    /// but reads/writes 8 bytes per element. Required because
1695    /// `ReluBackward`'s `&[f32]` slot view returns half of every f64
1696    /// otherwise → backward silently produces 0 gradients on an f64
1697    /// graph. Mirrors the `ActivationBackwardF64` split.
1698    ReluBackwardF64 {
1699        x: usize,
1700        dy: usize,
1701        dx: usize,
1702        len: u32,
1703    },
1704
1705    /// Generic element-wise activation backward.
1706    /// `dx[i] = (d/dx act(x))[i] · dy[i]`. The closure dispatch is
1707    /// per-element; expensive activations (Gelu) recompute internals
1708    /// inline rather than threading an extra "saved y" tensor through.
1709    ActivationBackward {
1710        x: usize,
1711        dy: usize,
1712        dx: usize,
1713        len: u32,
1714        kind: Activation,
1715    },
1716    /// f64 sibling of `ActivationBackward` — slot offsets, len in
1717    /// elements; kernel reads/writes 8 bytes per element. Required
1718    /// because `ActivationBackward`'s `&[f32]` slot view silently
1719    /// returns garbage on an f64 graph (cb % 4 still works but every
1720    /// loaded value is half of an f64 → wrong gradient).
1721    ActivationBackwardF64 {
1722        x: usize,
1723        dy: usize,
1724        dx: usize,
1725        len: u32,
1726        kind: Activation,
1727    },
1728
1729    /// LayerNorm backward — input gradient. Recomputes mean/var/x̂ from
1730    /// `x` and emits the closed-form `d_x` per row.
1731    LayerNormBackwardInput {
1732        x: usize,
1733        gamma: usize,
1734        dy: usize,
1735        dx: usize,
1736        rows: u32,
1737        h: u32,
1738        eps: f32,
1739    },
1740
1741    /// LayerNorm backward — gamma gradient. `d_gamma[d] = Σ_row dy·x̂`.
1742    LayerNormBackwardGamma {
1743        x: usize,
1744        dy: usize,
1745        dgamma: usize,
1746        rows: u32,
1747        h: u32,
1748        eps: f32,
1749    },
1750
1751    RmsNormBackwardInput {
1752        x: usize,
1753        gamma: usize,
1754        beta: usize,
1755        dy: usize,
1756        dx: usize,
1757        rows: u32,
1758        h: u32,
1759        eps: f32,
1760    },
1761    RmsNormBackwardGamma {
1762        x: usize,
1763        gamma: usize,
1764        beta: usize,
1765        dy: usize,
1766        dgamma: usize,
1767        rows: u32,
1768        h: u32,
1769        eps: f32,
1770    },
1771    RmsNormBackwardBeta {
1772        x: usize,
1773        gamma: usize,
1774        beta: usize,
1775        dy: usize,
1776        dbeta: usize,
1777        rows: u32,
1778        h: u32,
1779        eps: f32,
1780    },
1781    RopeBackward {
1782        dy: usize,
1783        cos: usize,
1784        sin: usize,
1785        dx: usize,
1786        batch: u32,
1787        seq: u32,
1788        hidden: u32,
1789        head_dim: u32,
1790        n_rot: u32,
1791        cos_len: u32,
1792    },
1793    CumsumBackward {
1794        dy: usize,
1795        dx: usize,
1796        rows: u32,
1797        cols: u32,
1798        exclusive: bool,
1799    },
1800    GatherBackward {
1801        dy: usize,
1802        indices: usize,
1803        dst: usize,
1804        outer: u32,
1805        axis_dim: u32,
1806        num_idx: u32,
1807        trailing: u32,
1808    },
1809
1810    GroupNormBackwardInput {
1811        x: usize,
1812        gamma: usize,
1813        beta: usize,
1814        dy: usize,
1815        dx: usize,
1816        n: u32,
1817        c: u32,
1818        h: u32,
1819        w: u32,
1820        num_groups: u32,
1821        eps: f32,
1822    },
1823    GroupNormBackwardGamma {
1824        x: usize,
1825        dy: usize,
1826        dgamma: usize,
1827        n: u32,
1828        c: u32,
1829        h: u32,
1830        w: u32,
1831        num_groups: u32,
1832        eps: f32,
1833    },
1834    GroupNormBackwardBeta {
1835        dy: usize,
1836        dbeta: usize,
1837        n: u32,
1838        c: u32,
1839        h: u32,
1840        w: u32,
1841    },
1842
1843    /// 2D max-pool backward (NCHW). Recomputes the argmax position
1844    /// inside each window and accumulates `dy` into `dx` at that
1845    /// position. Output is zeroed first; ties resolve to the first
1846    /// hit (lowest (kh,kw) index), matching what the forward kernel
1847    /// does with `acc.max(v)`.
1848    MaxPool2dBackward {
1849        x: usize,
1850        dy: usize,
1851        dx: usize,
1852        n: u32,
1853        c: u32,
1854        h: u32,
1855        w: u32,
1856        h_out: u32,
1857        w_out: u32,
1858        kh: u32,
1859        kw: u32,
1860        sh: u32,
1861        sw: u32,
1862        ph: u32,
1863        pw: u32,
1864    },
1865
1866    /// 2D conv backward w.r.t. input (`dx = conv_transpose(dy, w)`).
1867    /// `dy [N, C_out, H_out, W_out]`, `w [C_out, C_in_per_group, kH, kW]`,
1868    /// `dx [N, C_in, H, W]`.
1869    Conv2dBackwardInput {
1870        dy: usize,
1871        w: usize,
1872        dx: usize,
1873        n: u32,
1874        c_in: u32,
1875        h: u32,
1876        w_in: u32,
1877        c_out: u32,
1878        h_out: u32,
1879        w_out: u32,
1880        kh: u32,
1881        kw: u32,
1882        sh: u32,
1883        sw: u32,
1884        ph: u32,
1885        pw: u32,
1886        dh: u32,
1887        dw: u32,
1888        groups: u32,
1889    },
1890
1891    /// 2D conv backward w.r.t. weight. `x [N, C_in, H, W]`,
1892    /// `dy [N, C_out, H_out, W_out]`, `dw [C_out, C_in_per_group, kH, kW]`.
1893    /// `dw` is zeroed before accumulation.
1894    Conv2dBackwardWeight {
1895        x: usize,
1896        dy: usize,
1897        dw: usize,
1898        n: u32,
1899        c_in: u32,
1900        h: u32,
1901        w: u32,
1902        c_out: u32,
1903        h_out: u32,
1904        w_out: u32,
1905        kh: u32,
1906        kw: u32,
1907        sh: u32,
1908        sw: u32,
1909        ph: u32,
1910        pw: u32,
1911        dh: u32,
1912        dw_dil: u32,
1913        groups: u32,
1914    },
1915
1916    /// NCHW im2col for conv backward-weight matmul. Output `[M, C·kH·kW]`
1917    /// with `M = N · H_out · W_out`. `n == 0` means infer batch from `x`.
1918    Im2Col {
1919        x: usize,
1920        col: usize,
1921        n: u32,
1922        c_in: u32,
1923        h: u32,
1924        w: u32,
1925        h_out: u32,
1926        w_out: u32,
1927        kh: u32,
1928        kw: u32,
1929        sh: u32,
1930        sw: u32,
1931        ph: u32,
1932        pw: u32,
1933        dh: u32,
1934        dw_dil: u32,
1935    },
1936
1937    /// Fused softmax + cross-entropy loss against a dense target
1938    /// distribution. `logits [N, C]`, `targets [N, C]`, output `[N]`
1939    /// per-row loss `lse(logits[n]) - Σ_c targets[n,c]·logits[n,c]`.
1940    /// Numerically stable (max-subtract before exp).
1941    SoftmaxCrossEntropyDense {
1942        logits: usize,
1943        targets: usize,
1944        dst: usize,
1945        n: u32,
1946        c: u32,
1947    },
1948
1949    /// Fused softmax + cross-entropy loss with f32-encoded integer
1950    /// labels. `logits [N, C]`, `labels [N]`, output `[N]` per-row loss.
1951    /// Numerically stable (max-subtract before exp).
1952    SoftmaxCrossEntropy {
1953        logits: usize,
1954        labels: usize,
1955        dst: usize,
1956        n: u32,
1957        c: u32,
1958    },
1959
1960    /// Backward of the fused loss above.
1961    /// `dlogits[n, k] = (softmax(logits[n])[k] - one_hot(labels[n])[k]) * d_loss[n]`.
1962    SoftmaxCrossEntropyBackward {
1963        logits: usize,
1964        labels: usize,
1965        d_loss: usize,
1966        dlogits: usize,
1967        n: u32,
1968        c: u32,
1969    },
1970
1971    /// User-registered custom op (CPU side). Lowered from `Op::Custom`.
1972    /// `kernel` is resolved against the global CPU kernel registry at
1973    /// compile time and stored as `Arc<dyn CpuKernel>` so execution
1974    /// avoids per-call lookups. v1: f32 contiguous only — see
1975    /// `op_registry::CpuKernel::execute_f32`.
1976    CustomOp {
1977        kernel: Arc<dyn CpuKernel>,
1978        inputs: Vec<(usize, u32, Shape)>, // (offset, len_elements, shape)
1979        output: (usize, u32, Shape),      // (offset, len_elements, shape)
1980        attrs: Vec<u8>,
1981    },
1982
1983    /// 1D FFT along the last axis. Input/output are `[..., 2N]`
1984    /// real-block layout (first N real, second N imag along the
1985    /// transformed axis). `outer` is the product of all leading axes;
1986    /// `n_complex` is N (the number of complex points). Both halves
1987    /// of the real-block layout are read together by the kernel.
1988    /// `dtype` selects the f32 or f64 path; the two share structure
1989    /// but not buffers, so a flag at compile time avoids per-row
1990    /// dispatch.
1991    /// CPU reference 3D Gaussian splat render ([`rlx_ir::Op::GaussianSplatRender`]).
1992    GaussianSplatRender {
1993        positions_off: usize,
1994        positions_len: usize,
1995        scales_off: usize,
1996        scales_len: usize,
1997        rotations_off: usize,
1998        rotations_len: usize,
1999        opacities_off: usize,
2000        opacities_len: usize,
2001        colors_off: usize,
2002        colors_len: usize,
2003        sh_coeffs_off: usize,
2004        sh_coeffs_len: usize,
2005        meta_off: usize,
2006        dst_off: usize,
2007        dst_len: usize,
2008        width: u32,
2009        height: u32,
2010        tile_size: u32,
2011        radius_scale: f32,
2012        alpha_cutoff: f32,
2013        max_splat_steps: u32,
2014        transmittance_threshold: f32,
2015        max_list_entries: u32,
2016    },
2017    GaussianSplatRenderBackward {
2018        positions_off: usize,
2019        positions_len: usize,
2020        scales_off: usize,
2021        scales_len: usize,
2022        rotations_off: usize,
2023        rotations_len: usize,
2024        opacities_off: usize,
2025        opacities_len: usize,
2026        colors_off: usize,
2027        colors_len: usize,
2028        sh_coeffs_off: usize,
2029        sh_coeffs_len: usize,
2030        meta_off: usize,
2031        d_loss_off: usize,
2032        d_loss_len: usize,
2033        packed_off: usize,
2034        packed_len: usize,
2035        width: u32,
2036        height: u32,
2037        tile_size: u32,
2038        radius_scale: f32,
2039        alpha_cutoff: f32,
2040        max_splat_steps: u32,
2041        transmittance_threshold: f32,
2042        max_list_entries: u32,
2043        loss_grad_clip: f32,
2044        sh_band: u32,
2045        max_anisotropy: f32,
2046    },
2047    /// Strict IR stage 1 — project + bin + sort + rays ([`Op::GaussianSplatPrepare`]).
2048    GaussianSplatPrepare {
2049        positions_off: usize,
2050        positions_len: usize,
2051        scales_off: usize,
2052        scales_len: usize,
2053        rotations_off: usize,
2054        rotations_len: usize,
2055        opacities_off: usize,
2056        opacities_len: usize,
2057        colors_off: usize,
2058        colors_len: usize,
2059        sh_coeffs_off: usize,
2060        sh_coeffs_len: usize,
2061        meta_off: usize,
2062        meta_len: usize,
2063        prep_off: usize,
2064        prep_len: usize,
2065        width: u32,
2066        height: u32,
2067        tile_size: u32,
2068        radius_scale: f32,
2069        alpha_cutoff: f32,
2070        max_splat_steps: u32,
2071        transmittance_threshold: f32,
2072        max_list_entries: u32,
2073    },
2074    /// Strict IR stage 2 — tile raster from prepare buffer ([`Op::GaussianSplatRasterize`]).
2075    GaussianSplatRasterize {
2076        prep_off: usize,
2077        prep_len: usize,
2078        meta_off: usize,
2079        meta_len: usize,
2080        dst_off: usize,
2081        dst_len: usize,
2082        count: usize,
2083        width: u32,
2084        height: u32,
2085        tile_size: u32,
2086        alpha_cutoff: f32,
2087        max_splat_steps: u32,
2088        transmittance_threshold: f32,
2089        max_list_entries: u32,
2090    },
2091    Fft1d {
2092        src: usize,
2093        dst: usize,
2094        outer: u32,
2095        n_complex: u32,
2096        inverse: bool,
2097        norm_tag: u32,
2098        dtype: rlx_ir::DType,
2099    },
2100    FftButterflyStage {
2101        state_src: usize,
2102        state_dst: usize,
2103        gate_src: usize,
2104        rev_src: usize,
2105        tw_re_src: usize,
2106        tw_im_src: usize,
2107        batch: u32,
2108        n_fft: u32,
2109        stage: u32,
2110    },
2111    LogMel {
2112        spec: usize,
2113        filters: usize,
2114        dst: usize,
2115        outer: u32,
2116        n_fft: u32,
2117        n_bins: u32,
2118        n_mels: u32,
2119    },
2120    LogMelBackward {
2121        spec: usize,
2122        filters: usize,
2123        dy: usize,
2124        dst: usize,
2125        outer: u32,
2126        n_fft: u32,
2127        n_bins: u32,
2128        n_mels: u32,
2129    },
2130    WelchPeaks {
2131        spec: usize,
2132        dst: usize,
2133        welch_batch: u32,
2134        n_fft: u32,
2135        n_segments: u32,
2136        k: u32,
2137    },
2138}
2139
2140/// Compiled thunk schedule — the runtime hot path.
2141/// Nop thunks are filtered out at compile time for zero iteration overhead.
2142#[derive(Clone)]
2143pub struct ThunkSchedule {
2144    pub thunks: Vec<Thunk>,
2145    /// TIDE merged placement mask (union across layers).
2146    pub moe_resident: Option<std::sync::Arc<[bool]>>,
2147    /// Per MoE layer placement (`layer[e]`); preferred when set.
2148    pub moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
2149    /// MoE router TopK capture (per-layer refresh).
2150    pub moe_topk_capture: Option<std::sync::Arc<crate::moe_topk_capture::MoeTopkCapture>>,
2151    /// Cached config values.
2152    pub mask_threshold: f32,
2153    pub mask_neg_inf: f32,
2154    pub score_skip: f32,
2155    /// Pre-compiled closure dispatch (zero match overhead). `Arc` (not
2156    /// `Box`) so the schedule can be `Clone` — multiple parallel
2157    /// executors share the same compiled closures (they're read-only
2158    /// `Fn(*mut u8)` so concurrent dispatch is safe; the arena pointer
2159    /// they receive is the only mutable state and is per-executor).
2160    pub compiled_fns: Vec<Arc<dyn Fn(*mut u8) + Send + Sync>>,
2161    /// Runtime-mutable RNG policy for [`Thunk::RngNormal`] / [`Thunk::RngUniform`].
2162    pub rng: Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
2163}
2164
2165impl ThunkSchedule {
2166    pub fn strip_nops(&mut self) {
2167        self.thunks.retain(|t| !matches!(t, Thunk::Nop));
2168        // compiled_fns must be rebuilt after stripping — caller should
2169        // call strip_nops() before compile_closures().
2170        self.compiled_fns.clear();
2171    }
2172}
2173
2174/// Get the arena byte offset for a node.
2175fn node_offset(arena: &Arena, id: NodeId) -> usize {
2176    if arena.has_buffer(id) {
2177        arena.byte_offset(id)
2178    } else {
2179        usize::MAX
2180    }
2181}
2182
2183/// Every byte-offset that a thunk reads from. Used by the Narrow→Rope
2184/// fusion (#45) to verify a Narrow's dst has exactly one consumer
2185/// before eliding it. Conservative: when in doubt about reads (an op
2186/// not yet listed here), the fusion will skip — correctness over
2187/// completeness.
2188fn thunk_read_offsets(t: &Thunk) -> Vec<usize> {
2189    match t {
2190        Thunk::Sgemm { a, b, .. } => vec![*a, *b],
2191        Thunk::SgemmT { a, b, .. } => vec![*a, *b],
2192        Thunk::SgdMomentum {
2193            param, vel, grad, ..
2194        } => vec![*param, *vel, *grad],
2195        Thunk::DenseSolveF64 { a, b, .. } => vec![*a, *b],
2196        Thunk::DenseSolveF32 { a, b, .. } => vec![*a, *b],
2197        Thunk::BatchedDenseSolveF64 { a, b, .. } => vec![*a, *b],
2198        Thunk::BatchedDgemmF64 { a, b, .. } => vec![*a, *b],
2199        Thunk::BatchedSgemm { a, b, .. } => vec![*a, *b],
2200        Thunk::FusedMmBiasAct { a, w, bias, .. } => vec![*a, *w, *bias],
2201        Thunk::ElementwiseRegion { input_offs, .. } => input_offs.clone(),
2202        Thunk::BiasAdd { src, bias, .. } => vec![*src, *bias],
2203        Thunk::BinaryFull { lhs, rhs, .. } => vec![*lhs, *rhs],
2204        Thunk::BinaryFullF64 { lhs, rhs, .. } => vec![*lhs, *rhs],
2205        Thunk::BinaryFullC64 { lhs, rhs, .. } => vec![*lhs, *rhs],
2206        Thunk::ComplexNormSqF32 { src, .. } => vec![*src],
2207        Thunk::ComplexNormSqBackwardF32 { z, g, .. } => vec![*z, *g],
2208        Thunk::ConjugateC64 { src, .. } => vec![*src],
2209        Thunk::Scan {
2210            outer_init_off,
2211            xs_inputs,
2212            ..
2213        } => {
2214            let mut v = vec![*outer_init_off];
2215            for (_, outer_xs_off, _) in xs_inputs.iter() {
2216                v.push(*outer_xs_off);
2217            }
2218            v
2219        }
2220        Thunk::ScanBackward {
2221            outer_init_off,
2222            outer_traj_off,
2223            outer_upstream_off,
2224            outer_xs_offs,
2225            ..
2226        } => {
2227            let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
2228            for (off, _) in outer_xs_offs.iter() {
2229                v.push(*off);
2230            }
2231            v
2232        }
2233        Thunk::ScanBackwardXs {
2234            outer_init_off,
2235            outer_traj_off,
2236            outer_upstream_off,
2237            outer_xs_offs,
2238            ..
2239        } => {
2240            let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
2241            for (off, _) in outer_xs_offs.iter() {
2242                v.push(*off);
2243            }
2244            v
2245        }
2246        Thunk::CustomFn { inputs, .. } => {
2247            inputs.iter().map(|(_, outer_off, _)| *outer_off).collect()
2248        }
2249        Thunk::ActivationInPlace { data, .. } => vec![*data],
2250        Thunk::LayerNorm { src, g, b, .. } | Thunk::GroupNorm { src, g, b, .. } => {
2251            vec![*src, *g, *b]
2252        }
2253        Thunk::BatchNormInference {
2254            src,
2255            g,
2256            b,
2257            mean,
2258            var,
2259            ..
2260        } => vec![*src, *g, *b, *mean, *var],
2261        Thunk::ResizeNearest2x { src, .. } => vec![*src],
2262        Thunk::AxialRope2d { src, .. } => vec![*src],
2263        Thunk::FusedResidualLN {
2264            x, res, bias, g, b, ..
2265        } => vec![*x, *res, *bias, *g, *b],
2266        Thunk::FusedResidualRmsNorm {
2267            x, res, bias, g, b, ..
2268        } => vec![*x, *res, *bias, *g, *b],
2269        Thunk::RmsNorm { src, g, b, .. } => vec![*src, *g, *b],
2270        Thunk::Softmax { data, .. } => vec![*data],
2271        Thunk::Cumsum { src, .. } => vec![*src],
2272        Thunk::Sample { logits, .. } => vec![*logits],
2273        Thunk::RngNormal { .. } | Thunk::RngUniform { .. } => vec![],
2274        Thunk::LoraMatMul { x, w, a, b, .. } => vec![*x, *w, *a, *b],
2275        Thunk::DequantMatMul {
2276            x, w_q, scale, zp, ..
2277        } => vec![*x, *w_q, *scale, *zp],
2278        Thunk::DequantMatMulGguf { x, w_q, .. } => vec![*x, *w_q],
2279        Thunk::DequantMatMulInt4 {
2280            x, w_q, scale, zp, ..
2281        } => vec![*x, *w_q, *scale, *zp],
2282        Thunk::DequantMatMulFp8 { x, w_q, scale, .. } => vec![*x, *w_q, *scale],
2283        Thunk::DequantMatMulNvfp4 {
2284            x,
2285            w_q,
2286            scale,
2287            global_scale,
2288            ..
2289        } => vec![*x, *w_q, *scale, *global_scale],
2290        Thunk::ScaledMatMul {
2291            lhs,
2292            rhs,
2293            lhs_scale,
2294            rhs_scale,
2295            bias,
2296            has_bias,
2297            ..
2298        } => {
2299            let mut v = vec![*lhs, *rhs, *lhs_scale, *rhs_scale];
2300            if *has_bias {
2301                v.push(*bias);
2302            }
2303            v
2304        }
2305        Thunk::ScaledQuantize { x, scale, .. } => vec![*x, *scale],
2306        Thunk::ScaledQuantScale { x, .. } => vec![*x],
2307        Thunk::ScaledDequantize { codes, scale, .. } => vec![*codes, *scale],
2308        Thunk::Conv2D1x1 { src, weight, .. } => vec![*src, *weight],
2309        Thunk::SelectiveScan {
2310            x, delta, a, b, c, ..
2311        } => vec![*x, *delta, *a, *b, *c],
2312        Thunk::GatedDeltaNet {
2313            q,
2314            k,
2315            v,
2316            g,
2317            beta,
2318            state,
2319            ..
2320        } => {
2321            let mut v = vec![*q, *k, *v, *g, *beta];
2322            if *state != 0 {
2323                v.push(*state);
2324            }
2325            v
2326        }
2327        Thunk::Attention { q, k, v, mask, .. } => vec![*q, *k, *v, *mask],
2328        Thunk::AttentionBackward {
2329            q, k, v, dy, mask, ..
2330        } => {
2331            let mut v = vec![*q, *k, *v, *dy];
2332            if *mask != 0 {
2333                v.push(*mask);
2334            }
2335            v
2336        }
2337        Thunk::Rope { src, cos, sin, .. } => vec![*src, *cos, *sin],
2338        Thunk::FusedAttnBlock {
2339            hidden,
2340            qkv_w,
2341            out_w,
2342            mask,
2343            qkv_b,
2344            out_b,
2345            cos,
2346            sin,
2347            ..
2348        } => vec![*hidden, *qkv_w, *out_w, *mask, *qkv_b, *out_b, *cos, *sin],
2349        Thunk::FusedSwiGLU { src, .. } => vec![*src],
2350        Thunk::Concat { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
2351        Thunk::ConcatF64 { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
2352        Thunk::Narrow { src, .. } => vec![*src],
2353        Thunk::Copy { src, .. } => vec![*src],
2354        Thunk::Gather { table, idx, .. } => vec![*table, *idx],
2355        // Anything not enumerated → return the dst as a "read" too,
2356        // forcing the fusion to bail (read_count >= 2 → skip). Keeps
2357        // this list safe to be incomplete.
2358        _ => vec![],
2359    }
2360}
2361
2362/// Fused dequant + matmul (plan #5). Int8-blockwise weights: each
2363/// `block_size` consecutive elements of a column share one f32
2364/// scale (and optionally a zero-point). The dequant happens inside
2365/// the inner accumulate so the f32 weight is never materialized.
2366///
2367/// `w_bytes` is the row-major i8 weight matrix `[k, n]`. `scales`
2368/// and `zps` are `[k/block, n]`. When `asym=false`, `zps` may be
2369/// empty.
2370///
2371/// Today this is the reference scalar implementation — the win is
2372/// memory bandwidth, not flops, since LLM weights dominate the
2373/// working set. A NEON SIMD path that loads 16 i8 → splat-scale →
2374/// fused-multiply-add is the natural follow-on.
2375#[allow(clippy::too_many_arguments)]
2376pub fn dequant_matmul_int8(
2377    x: &[f32],       // [m, k]
2378    w_bytes: &[i8],  // [k, n]
2379    scales: &[f32],  // [k/block, n]
2380    zps: &[f32],     // [k/block, n] or empty
2381    out: &mut [f32], // [m, n]
2382    m: usize,
2383    k: usize,
2384    n: usize,
2385    block_size: usize,
2386    asym: bool,
2387) {
2388    let blocks_per_col = k.div_ceil(block_size);
2389    for i in 0..m {
2390        for j in 0..n {
2391            let mut acc = 0f32;
2392            for p in 0..k {
2393                let block = p / block_size;
2394                let s = scales[block * n + j];
2395                let z = if asym { zps[block * n + j] } else { 0.0 };
2396                let q = w_bytes[p * n + j] as f32;
2397                let dequantized = (q - z) * s;
2398                acc += x[i * k + p] * dequantized;
2399            }
2400            out[i * n + j] = acc;
2401        }
2402    }
2403    let _ = blocks_per_col;
2404}
2405
2406#[allow(clippy::too_many_arguments)]
2407fn dequant_matmul_int4(
2408    x: &[f32],
2409    w_bytes: &[u8],
2410    scales: &[f32],
2411    zps: &[f32],
2412    out: &mut [f32],
2413    m: usize,
2414    k: usize,
2415    n: usize,
2416    block_size: usize,
2417    asym: bool,
2418) {
2419    for i in 0..m {
2420        for j in 0..n {
2421            let mut acc = 0f32;
2422            for p in 0..k {
2423                let block = p / block_size;
2424                let s = scales[block * n + j];
2425                let z = if asym { zps[block * n + j] } else { 0.0 };
2426                let byte_idx = (p * n + j) / 2;
2427                let nibble = if (p * n + j) & 1 == 0 {
2428                    w_bytes[byte_idx] & 0x0F
2429                } else {
2430                    w_bytes[byte_idx] >> 4
2431                };
2432                let dequantized = (nibble as f32 - z) * s;
2433                acc += x[i * k + p] * dequantized;
2434            }
2435            out[i * n + j] = acc;
2436        }
2437    }
2438}
2439
2440fn fp8_e4m3_to_f32(b: u8) -> f32 {
2441    let sign = if b & 0x80 != 0 { -1.0 } else { 1.0 };
2442    let exp = (b >> 3) & 0x0F;
2443    let mant = b & 0x07;
2444    if exp == 0 {
2445        if mant == 0 {
2446            return 0.0;
2447        }
2448        return sign * (mant as f32) * 2f32.powi(-9);
2449    }
2450    if exp == 0x0F {
2451        return if mant == 0 {
2452            sign * f32::INFINITY
2453        } else {
2454            f32::NAN
2455        };
2456    }
2457    sign * (1.0 + mant as f32 / 8.0) * 2f32.powi(exp as i32 - 7)
2458}
2459
2460fn fp8_e5m2_to_f32(b: u8) -> f32 {
2461    let sign = if b & 0x80 != 0 { -1.0 } else { 1.0 };
2462    let exp = (b >> 2) & 0x1F;
2463    let mant = b & 0x03;
2464    if exp == 0 {
2465        if mant == 0 {
2466            return 0.0;
2467        }
2468        return sign * (mant as f32) * 2f32.powi(-16);
2469    }
2470    if exp == 0x1F {
2471        return if mant == 0 {
2472            sign * f32::INFINITY
2473        } else {
2474            f32::NAN
2475        };
2476    }
2477    sign * (1.0 + mant as f32 / 4.0) * 2f32.powi(exp as i32 - 15)
2478}
2479
2480#[allow(clippy::too_many_arguments)]
2481fn dequant_matmul_fp8(
2482    x: &[f32],
2483    w_bytes: &[u8],
2484    scales: &[f32],
2485    out: &mut [f32],
2486    m: usize,
2487    k: usize,
2488    n: usize,
2489    e5m2: bool,
2490) {
2491    let dequant = if e5m2 {
2492        fp8_e5m2_to_f32
2493    } else {
2494        fp8_e4m3_to_f32
2495    };
2496    for i in 0..m {
2497        for j in 0..n {
2498            let mut acc = 0f32;
2499            for p in 0..k {
2500                let w = dequant(w_bytes[p * n + j]);
2501                let s = scales.get(j).copied().unwrap_or(1.0);
2502                acc += x[i * k + p] * w * s;
2503            }
2504            out[i * n + j] = acc;
2505        }
2506    }
2507}
2508
2509#[allow(clippy::too_many_arguments)]
2510pub fn dequant_matmul_nvfp4(
2511    x: &[f32],
2512    w_bytes: &[u8],
2513    scale_bytes: &[u8],
2514    global_scale: f32,
2515    out: &mut [f32],
2516    m: usize,
2517    k: usize,
2518    n: usize,
2519) {
2520    use rlx_ir::{NVFP4_GROUP_SIZE, fp4_e2m1_to_f32, fp8_e4m3_scale_to_f32};
2521    let gs = NVFP4_GROUP_SIZE;
2522    for i in 0..m {
2523        for j in 0..n {
2524            let mut acc = 0f32;
2525            for p in 0..k {
2526                let byte_idx = (p * n + j) / 2;
2527                let nibble = if (p * n + j) & 1 == 0 {
2528                    w_bytes[byte_idx] & 0x0F
2529                } else {
2530                    w_bytes[byte_idx] >> 4
2531                };
2532                let block = p / gs;
2533                let scale = fp8_e4m3_scale_to_f32(scale_bytes[block * n + j]);
2534                let w = fp4_e2m1_to_f32(nibble) * scale * global_scale;
2535                acc += x[i * k + p] * w;
2536            }
2537            out[i * n + j] = acc;
2538        }
2539    }
2540}
2541
2542// ── Native low-precision (FP8/FP6/FP4) scaled GEMM — CPU reference oracle ──
2543//
2544// Decode-and-accumulate *reference* for `Op::ScaledMatMul` + its quantize
2545// producers. CPUs have no fp8 matrix units; correctness, not speed, is the
2546// point — every GPU backend's native tensor-core path is checked against these.
2547// Layout is TN: lhs [m,k], rhs [n,k] (K-last), out = lhs·rhsᵀ. Block scales
2548// (when any) run along the last/contraction axis of each operand.
2549
2550/// Blocks along a `len`-element axis (1 for per-tensor).
2551#[inline]
2552fn lowp_nblk(len: usize, layout: rlx_ir::ScaleLayout) -> usize {
2553    match layout {
2554        rlx_ir::ScaleLayout::PerTensor => 1,
2555        _ => len.div_ceil(layout.block() as usize),
2556    }
2557}
2558
2559/// Snap a raw f32 scale to the grid storable for `layout`, so quantizer and
2560/// matmul agree bit-for-bit on the reconstructed value.
2561#[inline]
2562fn lowp_snap_scale(layout: rlx_ir::ScaleLayout, s: f32) -> f32 {
2563    use rlx_ir::lowp_codec;
2564    match layout {
2565        rlx_ir::ScaleLayout::PerTensor => s,
2566        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
2567            lowp_codec::e8m0_to_f32(lowp_codec::f32_to_e8m0(s))
2568        }
2569        rlx_ir::ScaleLayout::Nvfp4 { .. } => lowp_codec::decode(
2570            rlx_ir::ScaledFormat::F8E4M3,
2571            lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s),
2572        ),
2573    }
2574}
2575
2576/// Scale for element (`free`, `contract`) given decoded raw scales.
2577#[inline]
2578fn lowp_scale_at(
2579    layout: rlx_ir::ScaleLayout,
2580    scales: &[f32],
2581    free: usize,
2582    contract: usize,
2583    nblk: usize,
2584) -> f32 {
2585    match layout {
2586        rlx_ir::ScaleLayout::PerTensor => scales.first().copied().unwrap_or(1.0),
2587        _ => scales[free * nblk + contract / layout.block() as usize],
2588    }
2589}
2590
2591/// Compute (snapped) raw f32 scales for `x` (`[rows, cols]`, blocks along
2592/// `cols` = contraction). PerTensor → 1 value; block → `rows*nblk` row-major.
2593fn lowp_compute_scales(
2594    x: &[f32],
2595    fmt: rlx_ir::ScaledFormat,
2596    layout: rlx_ir::ScaleLayout,
2597    rows: usize,
2598    cols: usize,
2599) -> Vec<f32> {
2600    let maxf = fmt.max_finite();
2601    let to_scale = |amax: f32| if amax > 0.0 { amax / maxf } else { 1.0 };
2602    match layout {
2603        rlx_ir::ScaleLayout::PerTensor => {
2604            let amax = x.iter().fold(0.0f32, |a, &v| a.max(v.abs()));
2605            vec![to_scale(amax)]
2606        }
2607        _ => {
2608            let block = layout.block() as usize;
2609            let nblk = cols.div_ceil(block);
2610            let mut out = vec![1.0f32; rows * nblk];
2611            for r in 0..rows {
2612                for b in 0..nblk {
2613                    let lo = b * block;
2614                    let hi = (lo + block).min(cols);
2615                    let mut amax = 0.0f32;
2616                    for c in lo..hi {
2617                        amax = amax.max(x[r * cols + c].abs());
2618                    }
2619                    out[r * nblk + b] = lowp_snap_scale(layout, to_scale(amax));
2620                }
2621            }
2622            out
2623        }
2624    }
2625}
2626
2627/// Quantize `x` (`[rows, cols]`, blocks along cols) to packed codes using the
2628/// already-snapped, decoded raw `scales`.
2629fn lowp_quantize(
2630    x: &[f32],
2631    scales: &[f32],
2632    fmt: rlx_ir::ScaledFormat,
2633    layout: rlx_ir::ScaleLayout,
2634    rows: usize,
2635    cols: usize,
2636    out: &mut [u8],
2637) {
2638    let nblk = lowp_nblk(cols, layout);
2639    for r in 0..rows {
2640        for c in 0..cols {
2641            let s = lowp_scale_at(layout, scales, r, c, nblk);
2642            let v = if s != 0.0 { x[r * cols + c] / s } else { 0.0 };
2643            out[r * cols + c] = rlx_ir::lowp_codec::encode(fmt, v);
2644        }
2645    }
2646}
2647
2648/// TN scaled GEMM: lhs [m,k] codes, rhs [n,k] codes, out [m,n] f32.
2649#[allow(clippy::too_many_arguments)]
2650fn lowp_scaled_matmul(
2651    lhs: &[u8],
2652    rhs: &[u8],
2653    lhs_scales: &[f32],
2654    rhs_scales: &[f32],
2655    bias: Option<&[f32]>,
2656    out: &mut [f32],
2657    m: usize,
2658    n: usize,
2659    k: usize,
2660    layout: rlx_ir::ScaleLayout,
2661    lhs_fmt: rlx_ir::ScaledFormat,
2662    rhs_fmt: rlx_ir::ScaledFormat,
2663) {
2664    use rlx_ir::lowp_codec::decode;
2665    let nblk = lowp_nblk(k, layout);
2666    for i in 0..m {
2667        for j in 0..n {
2668            let mut acc = 0f32;
2669            for p in 0..k {
2670                let a =
2671                    decode(lhs_fmt, lhs[i * k + p]) * lowp_scale_at(layout, lhs_scales, i, p, nblk);
2672                let b =
2673                    decode(rhs_fmt, rhs[j * k + p]) * lowp_scale_at(layout, rhs_scales, j, p, nblk);
2674                acc += a * b;
2675            }
2676            out[i * n + j] = acc + bias.map_or(0.0, |bb| bb[j]);
2677        }
2678    }
2679}
2680
2681/// Reconstruct f32 from packed codes: `out[i] = decode(code[i]) · scale(block)`.
2682/// `[rows, cols]`, blocks along cols. Inverse of [`lowp_quantize`].
2683fn lowp_dequantize(
2684    codes: &[u8],
2685    scales: &[f32],
2686    fmt: rlx_ir::ScaledFormat,
2687    layout: rlx_ir::ScaleLayout,
2688    rows: usize,
2689    cols: usize,
2690    out: &mut [f32],
2691) {
2692    use rlx_ir::lowp_codec::decode;
2693    let nblk = lowp_nblk(cols, layout);
2694    for r in 0..rows {
2695        for c in 0..cols {
2696            let s = lowp_scale_at(layout, scales, r, c, nblk);
2697            out[r * cols + c] = decode(fmt, codes[r * cols + c]) * s;
2698        }
2699    }
2700}
2701
2702/// Decode a stored scale tensor (f32 for per-tensor, E8M0/E4M3 bytes for block
2703/// layouts) into raw f32 scales. `n` is the scale-element count.
2704unsafe fn lowp_read_scales(
2705    layout: rlx_ir::ScaleLayout,
2706    base: *mut u8,
2707    offset: usize,
2708    n: usize,
2709) -> Vec<f32> {
2710    use rlx_ir::lowp_codec;
2711    match layout {
2712        rlx_ir::ScaleLayout::PerTensor => {
2713            unsafe { std::slice::from_raw_parts(base.add(offset) as *const f32, n) }.to_vec()
2714        }
2715        rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
2716            let bytes = unsafe { std::slice::from_raw_parts(base.add(offset), n) };
2717            bytes.iter().map(|&b| lowp_codec::e8m0_to_f32(b)).collect()
2718        }
2719        rlx_ir::ScaleLayout::Nvfp4 { .. } => {
2720            let bytes = unsafe { std::slice::from_raw_parts(base.add(offset), n) };
2721            bytes
2722                .iter()
2723                .map(|&b| lowp_codec::decode(rlx_ir::ScaledFormat::F8E4M3, b))
2724                .collect()
2725        }
2726    }
2727}
2728
2729/// Fused sampling step: logits → top-k filter → top-p truncation
2730/// → softmax → multinomial sample. Operates on one row of length
2731/// `vocab` and returns the sampled index. Plan #42.
2732///
2733/// Internal scratch is on the stack via SmallVec-style fallback —
2734/// for `vocab > 8192` we heap-allocate a working buffer; below
2735/// that we keep things in a fixed array. (TODO: thread the
2736/// scratch through ThunkSchedule like sdpa_scores does.)
2737fn sample_row(
2738    logits: &[f32],
2739    top_k: usize,
2740    top_p: f32,
2741    temperature: f32,
2742    rng: &mut rlx_ir::Philox4x32,
2743) -> usize {
2744    let v = logits.len();
2745    if v == 0 {
2746        return 0;
2747    }
2748    let temp = temperature.max(1e-6);
2749    // Copy + temperature-scale into a working buffer.
2750    let mut scaled: Vec<f32> = logits.iter().map(|&x| x / temp).collect();
2751
2752    // Top-k: zero out everything but the k largest by setting to -inf.
2753    if top_k > 0 && top_k < v {
2754        // Partial selection: find k-th largest then mask below.
2755        let mut indexed: Vec<(usize, f32)> = scaled.iter().copied().enumerate().collect();
2756        // Sort descending; partial would be O(n log k), full sort is fine
2757        // for typical vocab sizes (32k-128k) — single-row work.
2758        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2759        let cutoff = indexed[top_k - 1].1;
2760        for x in scaled.iter_mut() {
2761            if *x < cutoff {
2762                *x = f32::NEG_INFINITY;
2763            }
2764        }
2765    }
2766
2767    // Stable softmax.
2768    let mut max_l = f32::NEG_INFINITY;
2769    for &x in &scaled {
2770        if x > max_l {
2771            max_l = x;
2772        }
2773    }
2774    let mut sum = 0.0f32;
2775    for x in scaled.iter_mut() {
2776        *x = (*x - max_l).exp();
2777        sum += *x;
2778    }
2779    let inv = 1.0 / sum.max(f32::MIN_POSITIVE);
2780    for x in scaled.iter_mut() {
2781        *x *= inv;
2782    }
2783
2784    // Top-p: keep the smallest set of tokens whose cumulative
2785    // probability exceeds top_p (after sorting descending).
2786    if top_p < 1.0 {
2787        let mut indexed: Vec<(usize, f32)> = scaled.iter().copied().enumerate().collect();
2788        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2789        let mut cum = 0.0f32;
2790        let mut keep = vec![false; v];
2791        for (idx, p) in indexed.iter() {
2792            keep[*idx] = true;
2793            cum += *p;
2794            if cum >= top_p {
2795                break;
2796            }
2797        }
2798        let mut new_sum = 0.0f32;
2799        for (i, x) in scaled.iter_mut().enumerate() {
2800            if !keep[i] {
2801                *x = 0.0;
2802            }
2803            new_sum += *x;
2804        }
2805        let inv = 1.0 / new_sum.max(f32::MIN_POSITIVE);
2806        for x in scaled.iter_mut() {
2807            *x *= inv;
2808        }
2809    }
2810
2811    // Multinomial sample via inverse-CDF.
2812    let r = rng.next_f32();
2813    let mut acc = 0.0f32;
2814    for (i, &p) in scaled.iter().enumerate() {
2815        acc += p;
2816        if r <= acc {
2817            return i;
2818        }
2819    }
2820    v - 1 // floating-point edge case fallback
2821}
2822
2823/// Apply a synthetic (kernel-generated) attention mask to a `[q_seq, k_seq]`
2824/// scores matrix. Custom masks are read from a tensor and not handled here.
2825/// `None` is a no-op so callers don't need to special-case it.
2826#[inline]
2827fn apply_synthetic_mask(
2828    scores: &mut [f32],
2829    q_seq: usize,
2830    k_seq: usize,
2831    kind: rlx_ir::op::MaskKind,
2832) {
2833    let neg = crate::config::RuntimeConfig::global().attn_mask_neg_inf;
2834    let q_offset = k_seq.saturating_sub(q_seq);
2835    match kind {
2836        rlx_ir::op::MaskKind::None | rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias => {}
2837        rlx_ir::op::MaskKind::Causal => {
2838            for qi in 0..q_seq {
2839                let abs_q = q_offset + qi;
2840                for ki in (abs_q + 1)..k_seq {
2841                    scores[qi * k_seq + ki] = neg;
2842                }
2843            }
2844        }
2845        rlx_ir::op::MaskKind::SlidingWindow(w) => {
2846            for qi in 0..q_seq {
2847                let abs_q = q_offset + qi;
2848                let lo = abs_q.saturating_sub(w);
2849                for ki in 0..k_seq {
2850                    if ki < lo || ki > abs_q {
2851                        scores[qi * k_seq + ki] = neg;
2852                    }
2853                }
2854            }
2855        }
2856    }
2857}
2858
2859/// NCL `[N,C,L]` or NCHW `[N,C,H,W]` → `(n, c, h, w)` for 2D conv/norm thunks.
2860fn conv_nchw_dims(shape: &Shape) -> (u32, u32, u32, u32) {
2861    match shape.rank() {
2862        3 => (
2863            shape.dim(0).unwrap_static() as u32,
2864            shape.dim(1).unwrap_static() as u32,
2865            1,
2866            shape.dim(2).unwrap_static() as u32,
2867        ),
2868        4 => (
2869            shape.dim(0).unwrap_static() as u32,
2870            shape.dim(1).unwrap_static() as u32,
2871            shape.dim(2).unwrap_static() as u32,
2872            shape.dim(3).unwrap_static() as u32,
2873        ),
2874        r => panic!("conv_nchw_dims: expected rank 3 or 4, got {r}"),
2875    }
2876}
2877
2878/// (N, C, D, H, W) for a rank-5 NCDHW conv tensor.
2879fn conv_ncdhw_dims(shape: &Shape) -> (u32, u32, u32, u32, u32) {
2880    assert_eq!(
2881        shape.rank(),
2882        5,
2883        "conv_ncdhw_dims: expected rank 5, got {}",
2884        shape.rank()
2885    );
2886    (
2887        shape.dim(0).unwrap_static() as u32,
2888        shape.dim(1).unwrap_static() as u32,
2889        shape.dim(2).unwrap_static() as u32,
2890        shape.dim(3).unwrap_static() as u32,
2891        shape.dim(4).unwrap_static() as u32,
2892    )
2893}
2894
2895/// Compile graph into thunk schedule.
2896pub fn compile_thunks(graph: &Graph, arena: &Arena) -> ThunkSchedule {
2897    compile_thunks_with_rng(graph, arena, rlx_ir::RngOptions::default())
2898}
2899
2900/// A scan body compiled into a self-contained CPU schedule plus the byte
2901/// offsets needed to drive it. Backends without a native `Op::Scan` kernel
2902/// (e.g. Metal via the unified-memory host fallback) build this once at compile
2903/// time and hand it to [`execute_scan_host`] at run time. Mirrors the inline
2904/// `Op::Scan` compilation used by the CPU executor.
2905pub struct ScanBodyPlan {
2906    pub body: ThunkSchedule,
2907    pub body_init: Vec<u8>,
2908    pub body_input_off: usize,
2909    pub body_output_off: usize,
2910    pub carry_bytes: usize,
2911    /// Body-arena offsets of the broadcast inputs, in declaration order.
2912    pub bcast_body_offs: Vec<usize>,
2913    /// Body-arena offsets of the per-step (`xs`) inputs, in declaration order.
2914    pub xs_body_offs: Vec<usize>,
2915}
2916
2917impl std::fmt::Debug for ScanBodyPlan {
2918    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2919        f.debug_struct("ScanBodyPlan")
2920            .field("carry_bytes", &self.carry_bytes)
2921            .field("body_input_off", &self.body_input_off)
2922            .field("body_output_off", &self.body_output_off)
2923            .field("num_bcast", &self.bcast_body_offs.len())
2924            .field("num_xs", &self.xs_body_offs.len())
2925            .finish()
2926    }
2927}
2928
2929/// Compile a scan body `Graph` — `1` carry + `num_bcast` broadcast +
2930/// `num_xs` per-step `Op::Input`s in NodeId order, a single output (the next
2931/// carry) — into a [`ScanBodyPlan`]. RNG ops inside the body are not supported
2932/// via this path (scan bodies are deterministic recurrences).
2933pub fn compile_scan_body(body: &Graph, num_bcast: usize, num_xs: usize) -> ScanBodyPlan {
2934    let body_plan = rlx_opt::memory::plan_memory(body);
2935    let body_offsets: HashMap<NodeId, usize> = body_plan
2936        .assignments
2937        .iter()
2938        .map(|(id, slot)| (*id, slot.offset))
2939        .collect();
2940    let body_inputs: Vec<NodeId> = body
2941        .nodes()
2942        .iter()
2943        .filter(|n| matches!(n.op, Op::Input { .. }))
2944        .map(|n| n.id)
2945        .collect();
2946    assert_eq!(
2947        body_inputs.len(),
2948        1 + num_bcast + num_xs,
2949        "compile_scan_body: body has {} inputs, expected {}",
2950        body_inputs.len(),
2951        1 + num_bcast + num_xs
2952    );
2953    let body_input_off = body_offsets[&body_inputs[0]];
2954    let carry_bytes = body
2955        .node(body_inputs[0])
2956        .shape
2957        .size_bytes()
2958        .expect("scan carry must have static shape");
2959    let body_output_id = *body
2960        .outputs
2961        .first()
2962        .expect("scan body must declare one output");
2963    let body_output_off = body_offsets[&body_output_id];
2964    let bcast_body_offs: Vec<usize> = (0..num_bcast)
2965        .map(|i| body_offsets[&body_inputs[1 + i]])
2966        .collect();
2967    let xs_body_offs: Vec<usize> = (0..num_xs)
2968        .map(|i| body_offsets[&body_inputs[1 + num_bcast + i]])
2969        .collect();
2970
2971    let mut body_arena = crate::arena::Arena::from_plan(body_plan);
2972    for n in body.nodes() {
2973        if let Op::Constant { data } = &n.op
2974            && body_arena.has_buffer(n.id)
2975            && !data.is_empty()
2976        {
2977            match n.shape.dtype() {
2978                rlx_ir::DType::F64 => {
2979                    let off = body_arena.byte_offset(n.id);
2980                    let buf = body_arena.raw_buf_mut();
2981                    let nbytes = (buf.len() - off).min(data.len());
2982                    buf[off..off + nbytes].copy_from_slice(&data[..nbytes]);
2983                }
2984                _ => {
2985                    let buf = body_arena.slice_mut(n.id);
2986                    let n_floats = data.len() / 4;
2987                    let n_lim = buf.len().min(n_floats);
2988                    for i in 0..n_lim {
2989                        buf[i] = f32::from_le_bytes([
2990                            data[i * 4],
2991                            data[i * 4 + 1],
2992                            data[i * 4 + 2],
2993                            data[i * 4 + 3],
2994                        ]);
2995                    }
2996                }
2997            }
2998        }
2999    }
3000    let body_init = body_arena.raw_buf().to_vec();
3001    let body_schedule = compile_thunks(body, &body_arena);
3002    ScanBodyPlan {
3003        body: body_schedule,
3004        body_init,
3005        body_input_off,
3006        body_output_off,
3007        carry_bytes,
3008        bcast_body_offs,
3009        xs_body_offs,
3010    }
3011}
3012
3013/// Execute a compiled scan body over `length` steps against a raw arena buffer
3014/// `base`. `outer_init_off` / `outer_final_off` are byte offsets into that
3015/// arena; `xs_outer[i] = (outer_off, per_step_bytes)` and
3016/// `bcast_outer[i] = (outer_off, total_bytes)` align with the plan's
3017/// `xs_body_offs` / `bcast_body_offs`. When `save_trajectory`, row `t` is
3018/// written to `outer_final_off + t * carry_bytes` (every step; checkpointed
3019/// scans are not supported via this path).
3020///
3021/// # Safety
3022/// `base` must point to a valid arena spanning every referenced offset.
3023pub unsafe fn execute_scan_host(
3024    base: *mut u8,
3025    plan: &ScanBodyPlan,
3026    outer_init_off: usize,
3027    outer_final_off: usize,
3028    length: u32,
3029    save_trajectory: bool,
3030    xs_outer: &[(usize, usize)],
3031    bcast_outer: &[(usize, usize)],
3032) {
3033    let cb = plan.carry_bytes;
3034    let mut body_buf: Vec<u8> = plan.body_init.clone();
3035    unsafe {
3036        std::ptr::copy_nonoverlapping(
3037            base.add(outer_init_off),
3038            body_buf.as_mut_ptr().add(plan.body_input_off),
3039            cb,
3040        );
3041        for (i, &(outer_b_off, total)) in bcast_outer.iter().enumerate() {
3042            std::ptr::copy_nonoverlapping(
3043                base.add(outer_b_off),
3044                body_buf.as_mut_ptr().add(plan.bcast_body_offs[i]),
3045                total,
3046            );
3047        }
3048    }
3049    for t in 0..length as usize {
3050        for (i, &(outer_xs_off, psb)) in xs_outer.iter().enumerate() {
3051            unsafe {
3052                std::ptr::copy_nonoverlapping(
3053                    base.add(outer_xs_off + t * psb),
3054                    body_buf.as_mut_ptr().add(plan.xs_body_offs[i]),
3055                    psb,
3056                );
3057            }
3058        }
3059        execute_thunks(&plan.body, &mut body_buf);
3060        if save_trajectory {
3061            unsafe {
3062                std::ptr::copy_nonoverlapping(
3063                    body_buf.as_ptr().add(plan.body_output_off),
3064                    base.add(outer_final_off + t * cb),
3065                    cb,
3066                );
3067            }
3068        }
3069        if plan.body_output_off != plan.body_input_off {
3070            body_buf.copy_within(
3071                plan.body_output_off..plan.body_output_off + cb,
3072                plan.body_input_off,
3073            );
3074        }
3075    }
3076    if !save_trajectory {
3077        unsafe {
3078            std::ptr::copy_nonoverlapping(
3079                body_buf.as_ptr().add(plan.body_output_off),
3080                base.add(outer_final_off),
3081                cb,
3082            );
3083        }
3084    }
3085}
3086
3087/// Compile graph into thunk schedule with explicit RNG policy.
3088#[allow(unused_variables)]
3089pub fn compile_thunks_with_rng(
3090    graph: &Graph,
3091    arena: &Arena,
3092    rng: rlx_ir::RngOptions,
3093) -> ThunkSchedule {
3094    let rng_shared = Arc::new(std::sync::RwLock::new(rng));
3095    let mut thunks = Vec::with_capacity(graph.len());
3096
3097    // ── Auto-fuse last-two-axis Transpose → MatMul into a trans-Sgemm ──
3098    // Matmul backprop emits `Transpose(operand) → MatMul` for `dA=g·Bᵀ` and
3099    // `dB=Aᵀ·g`; the transpose is a full copy (the dominant backward cost).
3100    // Fold it into cblas trans flags (no copy). Guarded to the safe 2-D F32
3101    // case where the transpose is used only by this matmul.
3102    let mut use_counts: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
3103    for n in graph.nodes() {
3104        for &i in &n.inputs {
3105            *use_counts.entry(i).or_insert(0) += 1;
3106        }
3107    }
3108    let is_t2 = |g: &Graph, id: NodeId| -> bool {
3109        matches!(&g.node(id).op, Op::Transpose { perm } if perm.as_slice() == [1, 0])
3110            && g.node(id).shape.rank() == 2
3111    };
3112    let mut folded_transpose: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
3113    let mut matmul_fold: std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)> =
3114        std::collections::HashMap::new();
3115    for n in graph.nodes() {
3116        if !matches!(n.op, Op::MatMul) {
3117            continue;
3118        }
3119        let (a_id, b_id) = (n.inputs[0], n.inputs[1]);
3120        if graph.node(a_id).shape.rank() != 2
3121            || graph.node(b_id).shape.rank() != 2
3122            || n.shape.dtype() != rlx_ir::DType::F32
3123        {
3124            continue;
3125        }
3126        let fold_a = is_t2(graph, a_id) && use_counts.get(&a_id) == Some(&1);
3127        let fold_b = is_t2(graph, b_id) && use_counts.get(&b_id) == Some(&1);
3128        if !fold_a && !fold_b {
3129            continue;
3130        }
3131        let (asrc, ta) = if fold_a {
3132            (graph.node(a_id).inputs[0], true)
3133        } else {
3134            (a_id, false)
3135        };
3136        let (bsrc, tb) = if fold_b {
3137            (graph.node(b_id).inputs[0], true)
3138        } else {
3139            (b_id, false)
3140        };
3141        matmul_fold.insert(n.id, (asrc, ta, bsrc, tb));
3142        if fold_a {
3143            folded_transpose.insert(a_id);
3144        }
3145        if fold_b {
3146            folded_transpose.insert(b_id);
3147        }
3148    }
3149
3150    // ── Auto-fuse the in-graph SGD-with-momentum update into one kernel ──
3151    // The fully-fused training step appends, per parameter, the chain
3152    //   v' = Add(Mul(vel, momᶜ), grad) ;  p' = Sub(param, Mul(v', lrᶜ))
3153    // where `momᶜ`/`lrᶜ` are full-size constant tensors. That lowers to 4
3154    // `BinaryFull` ops (+2 constants) per param and dominates the step
3155    // (~60% of CPU time on the MLP). Collapse the whole chain into a single
3156    // `SgdMomentum` thunk attached to the `Sub` (p'), which also writes v''s
3157    // slot. Guarded to exactly this shape: both p' and v' are graph outputs,
3158    // the inner nodes have a single in-graph use, and the scalars are
3159    // uniform full-size F32 constants.
3160    let out_set: std::collections::HashSet<NodeId> = graph.outputs.iter().copied().collect();
3161    let const_scalar = |g: &Graph, id: NodeId, n: usize| -> Option<f32> {
3162        if let Op::Constant { data } = &g.node(id).op {
3163            if data.len() == n * 4 {
3164                return Some(f32::from_le_bytes([data[0], data[1], data[2], data[3]]));
3165            }
3166        }
3167        None
3168    };
3169    // p' node → (param, vel, grad, v' node, lr, mom, len)
3170    let mut sgd_fold: std::collections::HashMap<
3171        NodeId,
3172        (NodeId, NodeId, NodeId, NodeId, f32, f32, usize),
3173    > = std::collections::HashMap::new();
3174    let mut sgd_elim: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
3175    for n in graph.nodes() {
3176        // p' = Sub(param, lr_v)
3177        if !matches!(n.op, Op::Binary(BinaryOp::Sub)) || n.shape.dtype() != rlx_ir::DType::F32 {
3178            continue;
3179        }
3180        if !out_set.contains(&n.id) {
3181            continue;
3182        }
3183        let len = match n.shape.num_elements() {
3184            Some(l) => l,
3185            None => continue,
3186        };
3187        let (param, lr_v) = (n.inputs[0], n.inputs[1]);
3188        // lr_v = Mul(v', lrᶜ), single in-graph use
3189        let lrv = graph.node(lr_v);
3190        if !matches!(lrv.op, Op::Binary(BinaryOp::Mul)) || use_counts.get(&lr_v) != Some(&1) {
3191            continue;
3192        }
3193        let (v_new, lr_c) = (lrv.inputs[0], lrv.inputs[1]);
3194        let lr = match const_scalar(graph, lr_c, len) {
3195            Some(v) => v,
3196            None => continue,
3197        };
3198        // v' = Add(v_scaled, grad), graph output, single in-graph use
3199        let vnew = graph.node(v_new);
3200        if !matches!(vnew.op, Op::Binary(BinaryOp::Add))
3201            || use_counts.get(&v_new) != Some(&1)
3202            || !out_set.contains(&v_new)
3203        {
3204            continue;
3205        }
3206        let (v_scaled, grad) = (vnew.inputs[0], vnew.inputs[1]);
3207        // v_scaled = Mul(vel, momᶜ), single in-graph use
3208        let vs = graph.node(v_scaled);
3209        if !matches!(vs.op, Op::Binary(BinaryOp::Mul)) || use_counts.get(&v_scaled) != Some(&1) {
3210            continue;
3211        }
3212        let (vel, mom_c) = (vs.inputs[0], vs.inputs[1]);
3213        let mom = match const_scalar(graph, mom_c, len) {
3214            Some(v) => v,
3215            None => continue,
3216        };
3217        sgd_fold.insert(n.id, (param, vel, grad, v_new, lr, mom, len));
3218        sgd_elim.insert(v_scaled);
3219        sgd_elim.insert(v_new);
3220        sgd_elim.insert(lr_v);
3221    }
3222
3223    for node in graph.nodes() {
3224        // View ops (Reshape / same-dtype Cast / axis-0 Narrow) are aliased
3225        // to their parent's slot by the memory planner — no copy needed.
3226        // Plan #46.
3227        if rlx_opt::is_pure_view(graph, node) {
3228            thunks.push(Thunk::Nop);
3229            continue;
3230        }
3231        // Transpose folded into a downstream matmul's trans flag — skip it.
3232        if folded_transpose.contains(&node.id) {
3233            thunks.push(Thunk::Nop);
3234            continue;
3235        }
3236        // Inner nodes of an SGD chain folded into one SgdMomentum thunk.
3237        if sgd_elim.contains(&node.id) {
3238            thunks.push(Thunk::Nop);
3239            continue;
3240        }
3241        let t = match &node.op {
3242            Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => Thunk::Nop,
3243
3244            Op::FusedMatMulBiasAct { activation } => {
3245                compile_fused_mat_mul_bias_act(node, graph, arena, &matmul_fold, &rng_shared, rng)
3246            }
3247            Op::FusedResidualLN { has_bias, eps } => {
3248                compile_fused_residual_l_n(node, graph, arena, &matmul_fold, &rng_shared, rng)
3249            }
3250            Op::FusedResidualRmsNorm { has_bias, eps } => {
3251                compile_fused_residual_rms_norm(node, graph, arena, &matmul_fold, &rng_shared, rng)
3252            }
3253            Op::MatMul => compile_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng),
3254            Op::Binary(op) => {
3255                if let Some(&(param, vel, grad, v_new, lr, mom, len)) = sgd_fold.get(&node.id) {
3256                    // Folded SGD-momentum: this `Sub` (p') drives a single
3257                    // SgdMomentum thunk that also writes v''s slot.
3258                    thunks.push(Thunk::SgdMomentum {
3259                        param: node_offset(arena, param),
3260                        vel: node_offset(arena, vel),
3261                        grad: node_offset(arena, grad),
3262                        p_out: node_offset(arena, node.id),
3263                        v_out: node_offset(arena, v_new),
3264                        lr,
3265                        mom,
3266                        len: len as u32,
3267                    });
3268                    continue;
3269                }
3270                let lhs_len = get_len(graph, node.inputs[0]);
3271                let rhs_len = get_len(graph, node.inputs[1]);
3272                let out_len = node.shape.num_elements().unwrap();
3273                if node.shape.dtype() == rlx_ir::DType::C64 {
3274                    // Native C64 element-wise. Add/Sub/Mul/Div lower
3275                    // to `BinaryFullC64`; the rest don't have a
3276                    // single natural complex definition.
3277                    match op {
3278                        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {}
3279                        BinaryOp::Max | BinaryOp::Min | BinaryOp::Pow => panic!(
3280                            "Op::Binary({op:?}) on DType::C64: complex \
3281                             max/min/pow have no single natural definition \
3282                             — caller should drop to 2N-real-block (see \
3283                             spike-ac) and pick a convention there"
3284                        ),
3285                    }
3286                }
3287                // Compute broadcast strides for the slow path. Empty
3288                // vectors when no broadcast is needed (the fast-path
3289                // kernel ignores them anyway).
3290                let (out_dims_bcast, bcast_lhs_strides, bcast_rhs_strides) =
3291                    if lhs_len == out_len && rhs_len == out_len {
3292                        (Vec::new(), Vec::new(), Vec::new())
3293                    } else {
3294                        let lhs_dims = get_static_dims(graph, node.inputs[0]);
3295                        let rhs_dims = get_static_dims(graph, node.inputs[1]);
3296                        let out_dims_v = get_static_dims(graph, node.id);
3297                        if lhs_dims.is_empty() || rhs_dims.is_empty() || out_dims_v.is_empty() {
3298                            // Dynamic shape — fall back to the legacy
3299                            // modulo path (correct for scalar / last-
3300                            // axis broadcast, which is the only
3301                            // dynamic case in practice).
3302                            (Vec::new(), Vec::new(), Vec::new())
3303                        } else {
3304                            let ls = broadcast_strides(&lhs_dims, &out_dims_v);
3305                            let rs = broadcast_strides(&rhs_dims, &out_dims_v);
3306                            let od: Vec<u32> = out_dims_v.iter().map(|x| *x as u32).collect();
3307                            (od, ls, rs)
3308                        }
3309                    };
3310                if node.shape.dtype() == rlx_ir::DType::C64 {
3311                    Thunk::BinaryFullC64 {
3312                        lhs: node_offset(arena, node.inputs[0]),
3313                        rhs: node_offset(arena, node.inputs[1]),
3314                        dst: node_offset(arena, node.id),
3315                        len: out_len as u32,
3316                        lhs_len: lhs_len as u32,
3317                        rhs_len: rhs_len as u32,
3318                        op: *op,
3319                        out_dims_bcast,
3320                        bcast_lhs_strides,
3321                        bcast_rhs_strides,
3322                    }
3323                } else if node.shape.dtype() == rlx_ir::DType::F64 {
3324                    // f64 path — no BiasAdd fast-path (yet); use the
3325                    // general binary-with-broadcast kernel.
3326                    Thunk::BinaryFullF64 {
3327                        lhs: node_offset(arena, node.inputs[0]),
3328                        rhs: node_offset(arena, node.inputs[1]),
3329                        dst: node_offset(arena, node.id),
3330                        len: out_len as u32,
3331                        lhs_len: lhs_len as u32,
3332                        rhs_len: rhs_len as u32,
3333                        op: *op,
3334                        out_dims_bcast,
3335                        bcast_lhs_strides,
3336                        bcast_rhs_strides,
3337                    }
3338                } else if matches!(op, BinaryOp::Add)
3339                    && rhs_len < out_len
3340                    && out_len % rhs_len == 0
3341                    && is_trailing_bias_broadcast(
3342                        graph.node(node.inputs[1]).shape.dims(),
3343                        graph.node(node.id).shape.dims(),
3344                    )
3345                {
3346                    // `BiasAdd` is only correct when the bias is a
3347                    // *trailing* broadcast — rhs dims match the right-
3348                    // hand side of the output dims (with size-1 only
3349                    // allowed in left-padded outer positions).
3350                    // SAM's rel-pos `[bh, h, w, 1, w] + [bh, h, w, h, w]`
3351                    // has rhs_len divide out_len cleanly but is a
3352                    // mid-shape singleton, NOT a trailing broadcast.
3353                    // Routing it through BiasAdd silently treats it as
3354                    // last-`rhs_len`-cols repeated — wrong values.
3355                    Thunk::BiasAdd {
3356                        src: node_offset(arena, node.inputs[0]),
3357                        bias: node_offset(arena, node.inputs[1]),
3358                        dst: node_offset(arena, node.id),
3359                        m: (out_len / rhs_len) as u32,
3360                        n: rhs_len as u32,
3361                    }
3362                } else {
3363                    let lhs_len = get_len(graph, node.inputs[0]);
3364                    Thunk::BinaryFull {
3365                        lhs: node_offset(arena, node.inputs[0]),
3366                        rhs: node_offset(arena, node.inputs[1]),
3367                        dst: node_offset(arena, node.id),
3368                        len: out_len as u32,
3369                        lhs_len: lhs_len as u32,
3370                        rhs_len: rhs_len as u32,
3371                        op: *op,
3372                        out_dims_bcast,
3373                        bcast_lhs_strides,
3374                        bcast_rhs_strides,
3375                        elem_bytes: node.shape.dtype().size_bytes() as u8,
3376                    }
3377                }
3378            }
3379
3380            Op::Activation(act) => {
3381                let len = node.shape.num_elements().unwrap();
3382                let in_off = node_offset(arena, node.inputs[0]);
3383                let out_off = node_offset(arena, node.id);
3384                if node.shape.dtype() == rlx_ir::DType::C64 {
3385                    // Only Neg/Exp/Log/Sqrt have natural complex
3386                    // extensions used in signal-processing graphs.
3387                    // Everything else (Sigmoid, Tanh, Relu, Abs,
3388                    // Sin/Cos/Tan/Atan, Round, GeLU family) is rejected.
3389                    match act {
3390                        Activation::Neg | Activation::Exp | Activation::Log | Activation::Sqrt => {}
3391                        other => panic!(
3392                            "Op::Activation({other:?}) on DType::C64: no \
3393                             natural complex extension — supported on C64: \
3394                             Neg, Exp, Log, Sqrt"
3395                        ),
3396                    }
3397                    Thunk::ActivationC64 {
3398                        src: in_off,
3399                        dst: out_off,
3400                        len: len as u32,
3401                        kind: *act,
3402                    }
3403                } else if node.shape.dtype() == rlx_ir::DType::F64 {
3404                    Thunk::ActivationF64 {
3405                        src: in_off,
3406                        dst: out_off,
3407                        len: len as u32,
3408                        kind: *act,
3409                    }
3410                } else if in_off == out_off {
3411                    // ActivationInPlace operates on a single buffer. When the
3412                    // planner has assigned input and output the same slot
3413                    // (typical post-fusion case), we just run on that slot.
3414                    Thunk::ActivationInPlace {
3415                        data: out_off,
3416                        len: len as u32,
3417                        act: *act,
3418                    }
3419                } else {
3420                    // Two-step: copy input → output, then activate output in place.
3421                    // The schedule executes them in this order; downstream
3422                    // thunks see the activated output at out_off.
3423                    thunks.push(Thunk::Copy {
3424                        src: in_off,
3425                        dst: out_off,
3426                        len: len as u32,
3427                    });
3428                    Thunk::ActivationInPlace {
3429                        data: out_off,
3430                        len: len as u32,
3431                        act: *act,
3432                    }
3433                }
3434            }
3435
3436            Op::Gather { axis } if *axis == 0 => {
3437                let table_shape = &graph.node(node.inputs[0]).shape;
3438                let table_total = table_shape.num_elements().unwrap();
3439                let trailing: usize = (1..table_shape.rank())
3440                    .map(|i| table_shape.dim(i).unwrap_static())
3441                    .product();
3442                let idx_len = get_len(graph, node.inputs[1]);
3443                let idx_i64 =
3444                    u8::from(graph.node(node.inputs[1]).shape.dtype() == rlx_ir::DType::I64);
3445                let table_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
3446                Thunk::Gather {
3447                    table: node_offset(arena, node.inputs[0]),
3448                    table_len: table_total as u32,
3449                    idx: node_offset(arena, node.inputs[1]),
3450                    dst: node_offset(arena, node.id),
3451                    num_idx: idx_len as u32,
3452                    trailing: trailing as u32,
3453                    idx_i64,
3454                    table_bytes,
3455                }
3456            }
3457
3458            Op::Gather { axis } => {
3459                compile_gather(node, graph, arena, &matmul_fold, &rng_shared, rng)
3460            }
3461            Op::Narrow { axis, start, len } => {
3462                compile_narrow(node, graph, arena, &matmul_fold, &rng_shared, rng)
3463            }
3464            Op::Reverse { axes } => {
3465                compile_reverse(node, graph, arena, &matmul_fold, &rng_shared, rng)
3466            }
3467            Op::Reshape { .. } | Op::StopGradient => {
3468                // Pure layout change: same total element count, plain copy.
3469                let len = node.shape.num_elements().unwrap();
3470                let src = node_offset(arena, node.inputs[0]);
3471                let dst = node_offset(arena, node.id);
3472                match node.shape.dtype() {
3473                    rlx_ir::DType::F64 => Thunk::CopyF64 {
3474                        src,
3475                        dst,
3476                        len: len as u32,
3477                    },
3478                    rlx_ir::DType::I64 => Thunk::CopyI64 {
3479                        src,
3480                        dst,
3481                        len: len as u32,
3482                    },
3483                    _ => Thunk::Copy {
3484                        src,
3485                        dst,
3486                        len: len as u32,
3487                    },
3488                }
3489            }
3490
3491            Op::Cast { to } => compile_cast(node, graph, arena, &matmul_fold, &rng_shared, rng),
3492            Op::Quantize {
3493                axis,
3494                scales,
3495                zero_points,
3496            } => compile_quantize(node, graph, arena, &matmul_fold, &rng_shared, rng),
3497            Op::FakeQuantize {
3498                bits,
3499                axis,
3500                ste,
3501                scale_mode,
3502            } => compile_fake_quantize(node, graph, arena, &matmul_fold, &rng_shared, rng),
3503            Op::FakeQuantizeLSQ { bits, axis } => {
3504                compile_fake_quantize_l_s_q(node, graph, arena, &matmul_fold, &rng_shared, rng)
3505            }
3506            Op::FakeQuantizeLSQBackwardX { bits, axis } => compile_fake_quantize_l_s_q_backward_x(
3507                node,
3508                graph,
3509                arena,
3510                &matmul_fold,
3511                &rng_shared,
3512                rng,
3513            ),
3514            Op::FakeQuantizeLSQBackwardScale { bits, axis } => {
3515                compile_fake_quantize_l_s_q_backward_scale(
3516                    node,
3517                    graph,
3518                    arena,
3519                    &matmul_fold,
3520                    &rng_shared,
3521                    rng,
3522                )
3523            }
3524            Op::FakeQuantizeBackward { bits, axis, ste } => {
3525                compile_fake_quantize_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3526            }
3527            Op::Dequantize {
3528                axis,
3529                scales,
3530                zero_points,
3531            } => compile_dequantize(node, graph, arena, &matmul_fold, &rng_shared, rng),
3532            Op::Expand { .. } => compile_expand(node, graph, arena, &matmul_fold, &rng_shared, rng),
3533            Op::RmsNorm { eps, .. } => {
3534                compile_rms_norm(node, graph, arena, &matmul_fold, &rng_shared, rng)
3535            }
3536            Op::LayerNorm { eps, .. } => {
3537                compile_layer_norm(node, graph, arena, &matmul_fold, &rng_shared, rng)
3538            }
3539            Op::GroupNorm { num_groups, eps } => {
3540                compile_group_norm(node, graph, arena, &matmul_fold, &rng_shared, rng)
3541            }
3542            Op::BatchNormInference { eps } => {
3543                compile_batch_norm_inference(node, graph, arena, &matmul_fold, &rng_shared, rng)
3544            }
3545            Op::BatchNormInferenceBackwardInput { eps } => {
3546                compile_batch_norm_inference_backward_input(
3547                    node,
3548                    graph,
3549                    arena,
3550                    &matmul_fold,
3551                    &rng_shared,
3552                    rng,
3553                )
3554            }
3555            Op::BatchNormInferenceBackwardGamma { eps } => {
3556                compile_batch_norm_inference_backward_gamma(
3557                    node,
3558                    graph,
3559                    arena,
3560                    &matmul_fold,
3561                    &rng_shared,
3562                    rng,
3563                )
3564            }
3565            Op::BatchNormInferenceBackwardBeta => compile_batch_norm_inference_backward_beta(
3566                node,
3567                graph,
3568                arena,
3569                &matmul_fold,
3570                &rng_shared,
3571                rng,
3572            ),
3573            Op::LayerNorm2d { eps } => {
3574                compile_layer_norm2d(node, graph, arena, &matmul_fold, &rng_shared, rng)
3575            }
3576            Op::ConvTranspose2d {
3577                kernel_size,
3578                stride,
3579                padding,
3580                dilation,
3581                output_padding: _,
3582                groups,
3583            } => compile_conv_transpose2d(node, graph, arena, &matmul_fold, &rng_shared, rng),
3584            Op::ResizeNearest2x => {
3585                compile_resize_nearest2x(node, graph, arena, &matmul_fold, &rng_shared, rng)
3586            }
3587            Op::AxialRope2d {
3588                end_x,
3589                end_y,
3590                head_dim,
3591                num_heads,
3592                theta,
3593                repeat_factor,
3594            } => compile_axial_rope2d(node, graph, arena, &matmul_fold, &rng_shared, rng),
3595            Op::Softmax { axis } => {
3596                let rank = node.shape.rank();
3597                let ax = if *axis < 0 {
3598                    (rank as i32 + axis) as usize
3599                } else {
3600                    *axis as usize
3601                };
3602                let cols = node.shape.dim(ax).unwrap_static();
3603                let total = node.shape.num_elements().unwrap();
3604                let in_off = node_offset(arena, node.inputs[0]);
3605                let out_off = node_offset(arena, node.id);
3606                // Softmax kernel runs in-place on its data buffer. If the
3607                // planner gave input and output separate slots (their live
3608                // ranges overlap, so no aliasing), the output starts
3609                // uninitialized — emit a Copy first so the data is there.
3610                // Same pattern as Op::Activation.
3611                if in_off != out_off {
3612                    thunks.push(Thunk::Copy {
3613                        src: in_off,
3614                        dst: out_off,
3615                        len: total as u32,
3616                    });
3617                }
3618                Thunk::Softmax {
3619                    data: out_off,
3620                    rows: (total / cols) as u32,
3621                    cols: cols as u32,
3622                }
3623            }
3624
3625            Op::SelectiveScan { state_size } => {
3626                compile_selective_scan(node, graph, arena, &matmul_fold, &rng_shared, rng)
3627            }
3628            Op::GatedDeltaNet {
3629                state_size,
3630                carry_state,
3631            } => compile_gated_delta_net(node, graph, arena, &matmul_fold, &rng_shared, rng),
3632            Op::Lstm {
3633                hidden_size,
3634                num_layers,
3635                bidirectional,
3636                carry,
3637            } => compile_lstm(node, graph, arena, &matmul_fold, &rng_shared, rng),
3638            Op::Gru {
3639                hidden_size,
3640                num_layers,
3641                bidirectional,
3642                carry,
3643            } => compile_gru(node, graph, arena, &matmul_fold, &rng_shared, rng),
3644            Op::Rnn {
3645                hidden_size,
3646                num_layers,
3647                bidirectional,
3648                carry,
3649                relu,
3650            } => compile_rnn(node, graph, arena, &matmul_fold, &rng_shared, rng),
3651            Op::Mamba2 {
3652                head_dim,
3653                state_size,
3654            } => compile_mamba2(node, graph, arena, &matmul_fold, &rng_shared, rng),
3655            Op::QMatMul {
3656                x_zp,
3657                w_zp,
3658                out_zp,
3659                mult,
3660            } => compile_q_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng),
3661            Op::QConv2d {
3662                kernel_size,
3663                stride,
3664                padding,
3665                dilation,
3666                groups,
3667                x_zp,
3668                w_zp,
3669                out_zp,
3670                mult,
3671            } => compile_q_conv2d(node, graph, arena, &matmul_fold, &rng_shared, rng),
3672            Op::DequantMatMul { scheme } => {
3673                compile_dequant_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng)
3674            }
3675            Op::ScaledMatMul {
3676                lhs_format,
3677                rhs_format,
3678                scale_layout,
3679                has_bias,
3680            } => compile_scaled_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng),
3681            Op::ScaledQuantize {
3682                format,
3683                scale_layout,
3684            } => compile_scaled_quantize(node, graph, arena, &matmul_fold, &rng_shared, rng),
3685            Op::ScaledQuantScale {
3686                format,
3687                scale_layout,
3688            } => compile_scaled_quant_scale(node, graph, arena, &matmul_fold, &rng_shared, rng),
3689            Op::ScaledDequantize {
3690                format,
3691                scale_layout,
3692            } => compile_scaled_dequantize(node, graph, arena, &matmul_fold, &rng_shared, rng),
3693            Op::LoraMatMul { scale } => {
3694                compile_lora_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng)
3695            }
3696            Op::Sample {
3697                top_k,
3698                top_p,
3699                temperature,
3700                seed,
3701            } => compile_sample(node, graph, arena, &matmul_fold, &rng_shared, rng),
3702            Op::RngNormal {
3703                mean,
3704                scale,
3705                key,
3706                op_seed,
3707            } => compile_rng_normal(node, graph, arena, &matmul_fold, &rng_shared, rng),
3708            Op::RngUniform {
3709                low,
3710                high,
3711                key,
3712                op_seed,
3713            } => compile_rng_uniform(node, graph, arena, &matmul_fold, &rng_shared, rng),
3714            Op::Cumsum { axis, exclusive } => {
3715                compile_cumsum(node, graph, arena, &matmul_fold, &rng_shared, rng)
3716            }
3717            Op::Attention {
3718                num_heads,
3719                head_dim,
3720                mask_kind,
3721                score_scale,
3722                attn_logit_softcap,
3723            } => compile_attention(node, graph, arena, &matmul_fold, &rng_shared, rng),
3724            Op::AttentionBackward {
3725                num_heads,
3726                head_dim,
3727                mask_kind,
3728                wrt,
3729            } => compile_attention_backward(node, graph, arena, &matmul_fold, &rng_shared, rng),
3730            Op::FusedAttentionBlock {
3731                num_heads,
3732                head_dim,
3733                has_bias,
3734                has_rope,
3735            } => compile_fused_attention_block(node, graph, arena, &matmul_fold, &rng_shared, rng),
3736            Op::Rope {
3737                head_dim,
3738                n_rot,
3739                style,
3740            } => compile_rope(node, graph, arena, &matmul_fold, &rng_shared, rng),
3741            Op::FusedSwiGLU {
3742                cast_to: _,
3743                gate_first,
3744            } => compile_fused_swi_g_l_u(node, graph, arena, &matmul_fold, &rng_shared, rng),
3745            Op::Conv {
3746                kernel_size,
3747                stride,
3748                padding,
3749                dilation,
3750                groups,
3751            } => compile_conv(node, graph, arena, &matmul_fold, &rng_shared, rng),
3752            Op::Conv3d { .. } => compile_conv3d(node, graph, arena, &matmul_fold, &rng_shared, rng),
3753            Op::ConvTranspose3d { .. } => {
3754                compile_conv_transpose3d(node, graph, arena, &matmul_fold, &rng_shared, rng)
3755            }
3756            Op::Pool {
3757                kind,
3758                kernel_size,
3759                stride,
3760                padding,
3761            } => compile_pool(node, graph, arena, &matmul_fold, &rng_shared, rng),
3762            Op::Transpose { perm } => {
3763                compile_transpose(node, graph, arena, &matmul_fold, &rng_shared, rng)
3764            }
3765            Op::ScatterAdd => {
3766                compile_scatter_add(node, graph, arena, &matmul_fold, &rng_shared, rng)
3767            }
3768            Op::GroupedMatMul => {
3769                compile_grouped_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng)
3770            }
3771            Op::DequantGroupedMatMul { scheme } => {
3772                compile_dequant_grouped_mat_mul(node, graph, arena, &matmul_fold, &rng_shared, rng)
3773            }
3774            Op::DequantMoEWeights { scheme } => {
3775                compile_dequant_mo_e_weights(node, graph, arena, &matmul_fold, &rng_shared, rng)
3776            }
3777            Op::TopK { k } => compile_top_k(node, graph, arena, &matmul_fold, &rng_shared, rng),
3778            Op::Reduce {
3779                op,
3780                axes,
3781                keep_dim: _,
3782            } => compile_reduce(node, graph, arena, &matmul_fold, &rng_shared, rng),
3783            Op::ArgMax { axis, keep_dim: _ } | Op::ArgMin { axis, keep_dim: _ } => {
3784                let in_shape = &graph.node(node.inputs[0]).shape;
3785                let rank = in_shape.rank();
3786                let outer: usize = (0..*axis)
3787                    .map(|i| in_shape.dim(i).unwrap_static())
3788                    .product::<usize>()
3789                    .max(1);
3790                let reduced = in_shape.dim(*axis).unwrap_static();
3791                let inner: usize = (*axis + 1..rank)
3792                    .map(|i| in_shape.dim(i).unwrap_static())
3793                    .product::<usize>()
3794                    .max(1);
3795                Thunk::ArgReduce {
3796                    src: node_offset(arena, node.inputs[0]),
3797                    dst: node_offset(arena, node.id),
3798                    outer: outer as u32,
3799                    reduced: reduced as u32,
3800                    inner: inner as u32,
3801                    is_max: matches!(node.op, Op::ArgMax { .. }),
3802                }
3803            }
3804
3805            Op::Compare(cmp) => compile_compare(node, graph, arena, &matmul_fold, &rng_shared, rng),
3806            Op::Where => compile_where(node, graph, arena, &matmul_fold, &rng_shared, rng),
3807            Op::Fma => compile_fma(node, graph, arena, &matmul_fold, &rng_shared, rng),
3808            Op::ReluBackward => {
3809                compile_relu_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3810            }
3811            Op::ComplexNormSq => {
3812                compile_complex_norm_sq(node, graph, arena, &matmul_fold, &rng_shared, rng)
3813            }
3814            Op::ComplexNormSqBackward => {
3815                compile_complex_norm_sq_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3816            }
3817            Op::Conjugate => compile_conjugate(node, graph, arena, &matmul_fold, &rng_shared, rng),
3818            Op::ActivationBackward { kind } => {
3819                compile_activation_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3820            }
3821            Op::LayerNormBackwardInput { eps, .. } => compile_layer_norm_backward_input(
3822                node,
3823                graph,
3824                arena,
3825                &matmul_fold,
3826                &rng_shared,
3827                rng,
3828            ),
3829            Op::LayerNormBackwardGamma { eps, .. } => compile_layer_norm_backward_gamma(
3830                node,
3831                graph,
3832                arena,
3833                &matmul_fold,
3834                &rng_shared,
3835                rng,
3836            ),
3837            Op::RmsNormBackwardInput { eps, .. }
3838            | Op::RmsNormBackwardGamma { eps, .. }
3839            | Op::RmsNormBackwardBeta { eps, .. } => {
3840                let x_shape = &graph.node(node.inputs[0]).shape;
3841                let h = x_shape.dim(x_shape.rank() - 1).unwrap_static();
3842                let rows = (x_shape.num_elements().unwrap() / h) as u32;
3843                let off = |i: usize| node_offset(arena, node.inputs[i]);
3844                let common = (off(0), off(1), off(2), off(3), rows, h as u32, *eps);
3845                match &node.op {
3846                    Op::RmsNormBackwardInput { .. } => Thunk::RmsNormBackwardInput {
3847                        x: common.0,
3848                        gamma: common.1,
3849                        beta: common.2,
3850                        dy: common.3,
3851                        dx: node_offset(arena, node.id),
3852                        rows: common.4,
3853                        h: common.5,
3854                        eps: common.6,
3855                    },
3856                    Op::RmsNormBackwardGamma { .. } => Thunk::RmsNormBackwardGamma {
3857                        x: common.0,
3858                        gamma: common.1,
3859                        beta: common.2,
3860                        dy: common.3,
3861                        dgamma: node_offset(arena, node.id),
3862                        rows: common.4,
3863                        h: common.5,
3864                        eps: common.6,
3865                    },
3866                    Op::RmsNormBackwardBeta { .. } => Thunk::RmsNormBackwardBeta {
3867                        x: common.0,
3868                        gamma: common.1,
3869                        beta: common.2,
3870                        dy: common.3,
3871                        dbeta: node_offset(arena, node.id),
3872                        rows: common.4,
3873                        h: common.5,
3874                        eps: common.6,
3875                    },
3876                    _ => unreachable!(),
3877                }
3878            }
3879
3880            Op::RopeBackward { head_dim, n_rot } => {
3881                compile_rope_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3882            }
3883            Op::CumsumBackward { exclusive, .. } => {
3884                compile_cumsum_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3885            }
3886            Op::GatherBackward { .. } => {
3887                compile_gather_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
3888            }
3889            Op::GroupNormBackwardInput { num_groups, eps }
3890            | Op::GroupNormBackwardGamma { num_groups, eps }
3891            | Op::GroupNormBackwardBeta { num_groups, eps } => {
3892                let x_shape = &graph.node(node.inputs[0]).shape;
3893                let n = x_shape.dim(0).unwrap_static() as u32;
3894                let c = x_shape.dim(1).unwrap_static() as u32;
3895                let h = x_shape.dim(2).unwrap_static() as u32;
3896                let w = x_shape.dim(3).unwrap_static() as u32;
3897                match &node.op {
3898                    Op::GroupNormBackwardInput { .. } => Thunk::GroupNormBackwardInput {
3899                        x: node_offset(arena, node.inputs[0]),
3900                        gamma: node_offset(arena, node.inputs[1]),
3901                        beta: node_offset(arena, node.inputs[2]),
3902                        dy: node_offset(arena, node.inputs[3]),
3903                        dx: node_offset(arena, node.id),
3904                        n,
3905                        c,
3906                        h,
3907                        w,
3908                        num_groups: *num_groups as u32,
3909                        eps: *eps,
3910                    },
3911                    Op::GroupNormBackwardGamma { .. } => Thunk::GroupNormBackwardGamma {
3912                        x: node_offset(arena, node.inputs[0]),
3913                        dy: node_offset(arena, node.inputs[1]),
3914                        dgamma: node_offset(arena, node.id),
3915                        n,
3916                        c,
3917                        h,
3918                        w,
3919                        num_groups: *num_groups as u32,
3920                        eps: *eps,
3921                    },
3922                    Op::GroupNormBackwardBeta { .. } => Thunk::GroupNormBackwardBeta {
3923                        dy: node_offset(arena, node.inputs[1]),
3924                        dbeta: node_offset(arena, node.id),
3925                        n,
3926                        c,
3927                        h,
3928                        w,
3929                    },
3930                    _ => unreachable!(),
3931                }
3932            }
3933
3934            Op::MaxPool2dBackward {
3935                kernel_size,
3936                stride,
3937                padding,
3938            } => compile_max_pool2d_backward(node, graph, arena, &matmul_fold, &rng_shared, rng),
3939            Op::Conv2dBackwardInput {
3940                kernel_size,
3941                stride,
3942                padding,
3943                dilation,
3944                groups,
3945            } => compile_conv2d_backward_input(node, graph, arena, &matmul_fold, &rng_shared, rng),
3946            Op::Conv2dBackwardWeight {
3947                kernel_size,
3948                stride,
3949                padding,
3950                dilation,
3951                groups,
3952            } => compile_conv2d_backward_weight(node, graph, arena, &matmul_fold, &rng_shared, rng),
3953            Op::Im2Col {
3954                kernel_size,
3955                stride,
3956                padding,
3957                dilation,
3958            } => compile_im2_col(node, graph, arena, &matmul_fold, &rng_shared, rng),
3959            Op::SoftmaxCrossEntropy => {
3960                compile_softmax_cross_entropy(node, graph, arena, &matmul_fold, &rng_shared, rng)
3961            }
3962            Op::SoftmaxCrossEntropyWithLogits => compile_softmax_cross_entropy_with_logits(
3963                node,
3964                graph,
3965                arena,
3966                &matmul_fold,
3967                &rng_shared,
3968                rng,
3969            ),
3970            Op::SoftmaxCrossEntropyBackward => compile_softmax_cross_entropy_backward(
3971                node,
3972                graph,
3973                arena,
3974                &matmul_fold,
3975                &rng_shared,
3976                rng,
3977            ),
3978            Op::DenseSolve => {
3979                compile_dense_solve(node, graph, arena, &matmul_fold, &rng_shared, rng)
3980            }
3981            Op::BatchedDenseSolve => {
3982                compile_batched_dense_solve(node, graph, arena, &matmul_fold, &rng_shared, rng)
3983            }
3984            Op::Scan {
3985                body,
3986                length,
3987                save_trajectory,
3988                num_bcast,
3989                num_xs,
3990                num_checkpoints,
3991            } => compile_scan(node, graph, arena, &matmul_fold, &rng_shared, rng),
3992            Op::ScanBackward {
3993                body_vjp,
3994                length,
3995                save_trajectory,
3996                num_xs,
3997                num_checkpoints,
3998                forward_body,
3999            } => compile_scan_backward(node, graph, arena, &matmul_fold, &rng_shared, rng),
4000            Op::ScanBackwardXs {
4001                body_vjp,
4002                length,
4003                save_trajectory,
4004                num_xs,
4005                xs_idx,
4006                num_checkpoints,
4007                forward_body,
4008            } => compile_scan_backward_xs(node, graph, arena, &matmul_fold, &rng_shared, rng),
4009            Op::Concat { axis } => {
4010                compile_concat(node, graph, arena, &matmul_fold, &rng_shared, rng)
4011            }
4012            Op::GaussianSplatRender {
4013                width,
4014                height,
4015                tile_size,
4016                radius_scale,
4017                alpha_cutoff,
4018                max_splat_steps,
4019                transmittance_threshold,
4020                max_list_entries,
4021            } => compile_gaussian_splat_render(node, graph, arena, &matmul_fold, &rng_shared, rng),
4022            Op::GaussianSplatRenderBackward {
4023                width,
4024                height,
4025                tile_size,
4026                radius_scale,
4027                alpha_cutoff,
4028                max_splat_steps,
4029                transmittance_threshold,
4030                max_list_entries,
4031                loss_grad_clip,
4032                sh_band,
4033                max_anisotropy,
4034            } => compile_gaussian_splat_render_backward(
4035                node,
4036                graph,
4037                arena,
4038                &matmul_fold,
4039                &rng_shared,
4040                rng,
4041            ),
4042            Op::GaussianSplatPrepare {
4043                width,
4044                height,
4045                tile_size,
4046                radius_scale,
4047                alpha_cutoff,
4048                max_splat_steps,
4049                transmittance_threshold,
4050                max_list_entries,
4051            } => compile_gaussian_splat_prepare(node, graph, arena, &matmul_fold, &rng_shared, rng),
4052            Op::GaussianSplatRasterize {
4053                width,
4054                height,
4055                tile_size,
4056                alpha_cutoff,
4057                max_splat_steps,
4058                transmittance_threshold,
4059                max_list_entries,
4060            } => {
4061                compile_gaussian_splat_rasterize(node, graph, arena, &matmul_fold, &rng_shared, rng)
4062            }
4063            Op::Custom { name, attrs, .. } => {
4064                compile_custom(node, graph, arena, &matmul_fold, &rng_shared, rng)
4065            }
4066            Op::Fft { inverse, norm } => {
4067                compile_fft(node, graph, arena, &matmul_fold, &rng_shared, rng)
4068            }
4069            Op::FftButterflyStage { stage, n_fft } => {
4070                compile_fft_butterfly_stage(node, graph, arena, &matmul_fold, &rng_shared, rng)
4071            }
4072            Op::LogMel => compile_log_mel(node, graph, arena, &matmul_fold, &rng_shared, rng),
4073            Op::LogMelBackward => {
4074                compile_log_mel_backward(node, graph, arena, &matmul_fold, &rng_shared, rng)
4075            }
4076            Op::WelchPeaks { k, n_segments } => {
4077                compile_welch_peaks(node, graph, arena, &matmul_fold, &rng_shared, rng)
4078            }
4079            Op::CustomFn {
4080                fwd_body,
4081                num_inputs,
4082                ..
4083            } => compile_custom_fn(node, graph, arena, &matmul_fold, &rng_shared, rng),
4084            Op::ElementwiseRegion {
4085                chain,
4086                scalar_input_mask,
4087                input_modulus,
4088                prologue,
4089                ..
4090            } => compile_elementwise_region(node, graph, arena, &matmul_fold, &rng_shared, rng),
4091            _ => Thunk::Nop,
4092        };
4093        thunks.push(t);
4094    }
4095
4096    let cfg = crate::config::RuntimeConfig::global();
4097    let mask_thr = cfg.mask_binary_threshold;
4098    let mask_neg = cfg.attn_mask_neg_inf;
4099    let score_skip = cfg.score_skip_threshold;
4100
4101    // Pre-compile closures (skip Nops — they're filtered out)
4102    let compiled_fns: Vec<Arc<dyn Fn(*mut u8) + Send + Sync>> = thunks
4103        .iter()
4104        .filter(|t| !matches!(t, Thunk::Nop))
4105        .map(|thunk| {
4106            match thunk.clone() {
4107                Thunk::Nop => Arc::new(|_: *mut u8| {}) as Arc<dyn Fn(*mut u8) + Send + Sync>,
4108
4109                Thunk::Sgemm { a, b, c, m, k, n } => {
4110                    let (m, k, n) = (m as usize, k as usize, n as usize);
4111                    Arc::new(move |base: *mut u8| unsafe {
4112                        crate::blas::sgemm(
4113                            sl(a, base, m * k),
4114                            sl(b, base, k * n),
4115                            sl_mut(c, base, m * n),
4116                            m,
4117                            k,
4118                            n,
4119                        );
4120                    })
4121                }
4122
4123                Thunk::CgemmC64 { a, b, c, m, k, n } => {
4124                    let (m, k, n) = (m as usize, k as usize, n as usize);
4125                    Arc::new(move |base: *mut u8| unsafe {
4126                        cgemm_c64(a, b, c, m, k, n, base);
4127                    })
4128                }
4129
4130                Thunk::DenseSolveF64 { a, b, x, n, nrhs } => {
4131                    let (n_, nrhs_) = (n as usize, nrhs as usize);
4132                    Arc::new(move |base: *mut u8| unsafe {
4133                        let a_src = sl_f64(a, base, n_ * n_);
4134                        let b_src = sl_f64(b, base, n_ * nrhs_);
4135                        let mut a_scratch: Vec<f64> = a_src.to_vec();
4136                        let mut x_buf: Vec<f64> = b_src.to_vec();
4137                        let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
4138                        if info != 0 {
4139                            panic!("DenseSolveF64: singular (info={info})");
4140                        }
4141                        sl_mut_f64(x, base, n_ * nrhs_).copy_from_slice(&x_buf);
4142                    })
4143                }
4144
4145                Thunk::DenseSolveF32 { a, b, x, n, nrhs } => {
4146                    let (n_, nrhs_) = (n as usize, nrhs as usize);
4147                    Arc::new(move |base: *mut u8| unsafe {
4148                        let a_src = sl(a, base, n_ * n_);
4149                        let b_src = sl(b, base, n_ * nrhs_);
4150                        let mut a_scratch: Vec<f32> = a_src.to_vec();
4151                        let mut x_buf: Vec<f32> = b_src.to_vec();
4152                        let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
4153                        if info != 0 {
4154                            panic!("DenseSolveF32: singular (info={info})");
4155                        }
4156                        sl_mut(x, base, n_ * nrhs_).copy_from_slice(&x_buf);
4157                    })
4158                }
4159
4160                Thunk::FusedMmBiasAct {
4161                    a,
4162                    w,
4163                    bias,
4164                    c,
4165                    m,
4166                    k,
4167                    n,
4168                    act,
4169                } => {
4170                    let (m, k, n) = (m as usize, k as usize, n as usize);
4171                    Arc::new(move |base: *mut u8| unsafe {
4172                        let out = sl_mut(c, base, m * n);
4173                        crate::blas::sgemm(sl(a, base, m * k), sl(w, base, k * n), out, m, k, n);
4174                        // Bias + activation epilogue. Gelu uses the fused
4175                        // `par_bias_gelu` kernel (bias add + Gelu in one
4176                        // pass). For everything else, do the bias add first
4177                        // and then apply the activation per-element. The
4178                        // pre-fix code dispatched `_ => bias_add` and dropped
4179                        // the activation entirely — silent correctness bug
4180                        // for Silu/Relu/Sigmoid/etc.
4181                        match act {
4182                            Some(Activation::Gelu) => {
4183                                crate::kernels::par_bias_gelu(out, sl(bias, base, n), m, n)
4184                            }
4185                            Some(other) => {
4186                                crate::blas::bias_add(out, sl(bias, base, n), m, n);
4187                                apply_activation_inplace(out, other);
4188                            }
4189                            None => crate::blas::bias_add(out, sl(bias, base, n), m, n),
4190                        }
4191                    })
4192                }
4193
4194                Thunk::FusedResidualLN {
4195                    x,
4196                    res,
4197                    bias,
4198                    g,
4199                    b,
4200                    out,
4201                    rows,
4202                    h,
4203                    eps,
4204                    has_bias,
4205                } => {
4206                    let (rows, h) = (rows as usize, h as usize);
4207                    Arc::new(move |base: *mut u8| unsafe {
4208                        let zero = vec![0f32; h]; // closure only — not hot path
4209                        let bi = if has_bias { sl(bias, base, h) } else { &zero };
4210                        let xp = sl(x, base, rows * h).as_ptr() as usize;
4211                        let rp = sl(res, base, rows * h).as_ptr() as usize;
4212                        let op = sl_mut(out, base, rows * h).as_mut_ptr() as usize;
4213                        let bp = bi.as_ptr() as usize;
4214                        let gp = sl(g, base, h).as_ptr() as usize;
4215                        let bbp = sl(b, base, h).as_ptr() as usize;
4216                        crate::pool::par_for(rows, 4, &|off, cnt| {
4217                            let xs = std::slice::from_raw_parts(
4218                                (xp as *const f32).add(off * h),
4219                                cnt * h,
4220                            );
4221                            let rs = std::slice::from_raw_parts(
4222                                (rp as *const f32).add(off * h),
4223                                cnt * h,
4224                            );
4225                            let os = std::slice::from_raw_parts_mut(
4226                                (op as *mut f32).add(off * h),
4227                                cnt * h,
4228                            );
4229                            let bi = std::slice::from_raw_parts(bp as *const f32, h);
4230                            let g = std::slice::from_raw_parts(gp as *const f32, h);
4231                            let b = std::slice::from_raw_parts(bbp as *const f32, h);
4232                            crate::kernels::residual_bias_layer_norm(
4233                                xs, rs, bi, g, b, os, cnt, h, eps,
4234                            );
4235                        });
4236                    })
4237                }
4238
4239                Thunk::BiasAdd {
4240                    src,
4241                    bias,
4242                    dst,
4243                    m,
4244                    n,
4245                } => {
4246                    let (m, n) = (m as usize, n as usize);
4247                    let len = m * n;
4248                    Arc::new(move |base: *mut u8| unsafe {
4249                        let out = sl_mut(dst, base, len);
4250                        if src != dst {
4251                            let src_ptr = base.add(src) as *const f32;
4252                            let dst_ptr = base.add(dst) as *mut f32;
4253                            if src_ptr != dst_ptr {
4254                                std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
4255                            }
4256                        }
4257                        crate::blas::bias_add(out, sl(bias, base, n), m, n);
4258                    })
4259                }
4260
4261                Thunk::Gather {
4262                    table,
4263                    table_len,
4264                    idx,
4265                    dst,
4266                    num_idx,
4267                    trailing,
4268                    idx_i64,
4269                    table_bytes,
4270                } => {
4271                    let (ni, tr, tl) = (num_idx as usize, trailing as usize, table_len as usize);
4272                    let rows = tl / tr.max(1);
4273                    let (idx_i64, table_bytes) = (idx_i64, table_bytes);
4274                    Arc::new(move |base: *mut u8| unsafe {
4275                        if table_bytes == 8 {
4276                            let tab = sl_i64(table, base, tl);
4277                            let out = sl_mut_i64(dst, base, ni * tr);
4278                            if idx_i64 != 0 {
4279                                let ids = sl_i64(idx, base, ni);
4280                                for i in 0..ni {
4281                                    let row = ids[i].max(0) as usize;
4282                                    if row < rows {
4283                                        out[i * tr..(i + 1) * tr]
4284                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
4285                                    }
4286                                }
4287                            } else {
4288                                let ids = sl(idx, base, ni);
4289                                for i in 0..ni {
4290                                    let row = ids[i] as usize;
4291                                    if row < rows {
4292                                        out[i * tr..(i + 1) * tr]
4293                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
4294                                    }
4295                                }
4296                            }
4297                        } else {
4298                            let tab = sl(table, base, tl);
4299                            let out = sl_mut(dst, base, ni * tr);
4300                            if idx_i64 != 0 {
4301                                let ids = sl_i64(idx, base, ni);
4302                                for i in 0..ni {
4303                                    let row = ids[i].max(0) as usize;
4304                                    if row < rows {
4305                                        out[i * tr..(i + 1) * tr]
4306                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
4307                                    }
4308                                }
4309                            } else {
4310                                let ids = sl(idx, base, ni);
4311                                for i in 0..ni {
4312                                    let row = ids[i] as usize;
4313                                    if row < rows {
4314                                        out[i * tr..(i + 1) * tr]
4315                                            .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
4316                                    }
4317                                }
4318                            }
4319                        }
4320                    })
4321                }
4322
4323                Thunk::Narrow {
4324                    src,
4325                    dst,
4326                    outer,
4327                    src_stride,
4328                    dst_stride,
4329                    inner,
4330                    elem_bytes,
4331                } => {
4332                    narrow_thunk_closure(src, dst, outer, src_stride, dst_stride, inner, elem_bytes)
4333                }
4334
4335                Thunk::Reverse {
4336                    src,
4337                    dst,
4338                    dims,
4339                    rev_mask,
4340                    elem_bytes,
4341                } => {
4342                    let eb = elem_bytes as usize;
4343                    let rank = dims.len();
4344                    let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
4345                    // Row-major element strides.
4346                    let mut strides = vec![1usize; rank];
4347                    for i in (0..rank.saturating_sub(1)).rev() {
4348                        strides[i] = strides[i + 1] * dims[i + 1] as usize;
4349                    }
4350                    let dims_u: Vec<usize> = dims.iter().map(|&d| d as usize).collect();
4351                    Arc::new(move |base: *mut u8| unsafe {
4352                        let src_base = base.add(src);
4353                        let dst_base = base.add(dst);
4354                        for o in 0..total {
4355                            // Output flat index → multi-index → (axis-reversed)
4356                            // input flat index.
4357                            let mut rem = o;
4358                            let mut in_flat = 0usize;
4359                            for ax in 0..rank {
4360                                let idx = rem / strides[ax];
4361                                rem %= strides[ax];
4362                                let in_idx = if rev_mask[ax] {
4363                                    dims_u[ax] - 1 - idx
4364                                } else {
4365                                    idx
4366                                };
4367                                in_flat += in_idx * strides[ax];
4368                            }
4369                            std::ptr::copy_nonoverlapping(
4370                                src_base.add(in_flat * eb),
4371                                dst_base.add(o * eb),
4372                                eb,
4373                            );
4374                        }
4375                    })
4376                }
4377
4378                Thunk::Copy { src, dst, len } => {
4379                    let len = len as usize;
4380                    Arc::new(move |base: *mut u8| unsafe {
4381                        if src == dst || len == 0 {
4382                            return;
4383                        }
4384                        let src_ptr = base.add(src) as *const f32;
4385                        let dst_ptr = base.add(dst) as *mut f32;
4386                        if src_ptr == dst_ptr {
4387                            return;
4388                        }
4389                        std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
4390                    })
4391                }
4392
4393                Thunk::Softmax { data, rows, cols } => {
4394                    let (rows, cols) = (rows as usize, cols as usize);
4395                    Arc::new(move |base: *mut u8| unsafe {
4396                        crate::naive::softmax(sl_mut(data, base, rows * cols), rows, cols);
4397                    })
4398                }
4399
4400                Thunk::Cumsum {
4401                    src,
4402                    dst,
4403                    rows,
4404                    cols,
4405                    exclusive,
4406                } => {
4407                    let (rows, cols) = (rows as usize, cols as usize);
4408                    Arc::new(move |base: *mut u8| unsafe {
4409                        let s = sl(src, base, rows * cols);
4410                        let d = sl_mut(dst, base, rows * cols);
4411                        if exclusive {
4412                            for r in 0..rows {
4413                                let mut acc = 0.0f32;
4414                                for c in 0..cols {
4415                                    d[r * cols + c] = acc;
4416                                    acc += s[r * cols + c];
4417                                }
4418                            }
4419                        } else {
4420                            for r in 0..rows {
4421                                let mut acc = 0.0f32;
4422                                for c in 0..cols {
4423                                    acc += s[r * cols + c];
4424                                    d[r * cols + c] = acc;
4425                                }
4426                            }
4427                        }
4428                    })
4429                }
4430
4431                Thunk::Sample {
4432                    logits,
4433                    dst,
4434                    batch,
4435                    vocab,
4436                    top_k,
4437                    top_p,
4438                    temperature,
4439                    seed,
4440                } => {
4441                    let (b, v) = (batch as usize, vocab as usize);
4442                    let k = (top_k as usize).min(v);
4443                    Arc::new(move |base: *mut u8| unsafe {
4444                        let lg = sl(logits, base, b * v);
4445                        let out = sl_mut(dst, base, b);
4446                        let mut rng =
4447                            rlx_ir::Philox4x32::new(if seed == 0 { 0xDEADBEEF } else { seed });
4448                        for bi in 0..b {
4449                            let row = &lg[bi * v..(bi + 1) * v];
4450                            out[bi] = sample_row(row, k, top_p, temperature, &mut rng) as f32;
4451                        }
4452                    })
4453                }
4454
4455                Thunk::RngNormal {
4456                    dst,
4457                    len,
4458                    mean,
4459                    scale,
4460                    key,
4461                    op_seed,
4462                } => {
4463                    let n = len as usize;
4464                    let rng = rng_shared.clone();
4465                    Arc::new(move |base: *mut u8| unsafe {
4466                        let out = sl_mut(dst, base, n);
4467                        let opts = *rng.read().unwrap();
4468                        rlx_ir::fill_normal_like(out, mean, scale, opts, key, op_seed);
4469                    })
4470                }
4471
4472                Thunk::RngUniform {
4473                    dst,
4474                    len,
4475                    low,
4476                    high,
4477                    key,
4478                    op_seed,
4479                } => {
4480                    let n = len as usize;
4481                    let rng = rng_shared.clone();
4482                    Arc::new(move |base: *mut u8| unsafe {
4483                        let out = sl_mut(dst, base, n);
4484                        let opts = *rng.read().unwrap();
4485                        rlx_ir::fill_uniform_like(out, low, high, opts, key, op_seed);
4486                    })
4487                }
4488
4489                Thunk::DequantMatMul {
4490                    x,
4491                    w_q,
4492                    scale,
4493                    zp,
4494                    dst,
4495                    m,
4496                    k,
4497                    n,
4498                    block_size,
4499                    is_asymmetric,
4500                } => {
4501                    let (m, k, n, bs) = (m as usize, k as usize, n as usize, block_size as usize);
4502                    let n_blocks_per_col = k.div_ceil(bs);
4503                    Arc::new(move |base: *mut u8| unsafe {
4504                        let xs = sl(x, base, m * k);
4505                        // w_q is packed i8 — use raw byte slice + reinterpret.
4506                        let raw = base.add(w_q);
4507                        let w_bytes = std::slice::from_raw_parts(raw as *const i8, k * n);
4508                        let scales = sl(scale, base, n_blocks_per_col * n);
4509                        let zps = if is_asymmetric {
4510                            sl(zp, base, n_blocks_per_col * n)
4511                        } else {
4512                            &[][..]
4513                        };
4514                        let out = sl_mut(dst, base, m * n);
4515                        dequant_matmul_int8(
4516                            xs,
4517                            w_bytes,
4518                            scales,
4519                            zps,
4520                            out,
4521                            m,
4522                            k,
4523                            n,
4524                            bs,
4525                            is_asymmetric,
4526                        );
4527                    })
4528                }
4529
4530                Thunk::DequantMatMulGguf {
4531                    x,
4532                    w_q,
4533                    dst,
4534                    m,
4535                    k,
4536                    n,
4537                    scheme,
4538                } => {
4539                    let (m, k, n) = (m as usize, k as usize, n as usize);
4540                    let block_bytes = scheme.gguf_block_bytes() as usize;
4541                    let block_elems = scheme.gguf_block_size() as usize;
4542                    let total_bytes = (k * n) / block_elems * block_bytes;
4543                    Arc::new(move |base: *mut u8| unsafe {
4544                        let xs = sl(x, base, m * k);
4545                        let w_bytes =
4546                            std::slice::from_raw_parts(base.add(w_q) as *const u8, total_bytes);
4547                        let out = sl_mut(dst, base, m * n);
4548                        crate::gguf_matmul::gguf_matmul_bt_dispatch(xs, w_bytes, out, m, k, n, scheme);
4549                    })
4550                }
4551
4552                Thunk::DequantMatMulInt4 {
4553                    x,
4554                    w_q,
4555                    scale,
4556                    zp,
4557                    dst,
4558                    m,
4559                    k,
4560                    n,
4561                    block_size,
4562                    is_asymmetric,
4563                } => {
4564                    let (m, k, n, bs) = (m as usize, k as usize, n as usize, block_size as usize);
4565                    let n_blocks = k.div_ceil(bs);
4566                    Arc::new(move |base: *mut u8| unsafe {
4567                        let xs = sl(x, base, m * k);
4568                        let w_bytes = std::slice::from_raw_parts(
4569                            base.add(w_q) as *const u8,
4570                            (k * n).div_ceil(2),
4571                        );
4572                        let scales = sl(scale, base, n_blocks * n);
4573                        let zps = if is_asymmetric {
4574                            sl(zp, base, n_blocks * n)
4575                        } else {
4576                            &[][..]
4577                        };
4578                        let out = sl_mut(dst, base, m * n);
4579                        dequant_matmul_int4(
4580                            xs,
4581                            w_bytes,
4582                            scales,
4583                            zps,
4584                            out,
4585                            m,
4586                            k,
4587                            n,
4588                            bs,
4589                            is_asymmetric,
4590                        );
4591                    })
4592                }
4593
4594                Thunk::DequantMatMulFp8 {
4595                    x,
4596                    w_q,
4597                    scale,
4598                    dst,
4599                    m,
4600                    k,
4601                    n,
4602                    e5m2,
4603                } => {
4604                    let (m, k, n) = (m as usize, k as usize, n as usize);
4605                    Arc::new(move |base: *mut u8| unsafe {
4606                        let xs = sl(x, base, m * k);
4607                        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, k * n);
4608                        let scales = sl(scale, base, n);
4609                        let out = sl_mut(dst, base, m * n);
4610                        dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, e5m2);
4611                    })
4612                }
4613
4614                Thunk::DequantMatMulNvfp4 {
4615                    x,
4616                    w_q,
4617                    scale,
4618                    global_scale,
4619                    dst,
4620                    m,
4621                    k,
4622                    n,
4623                } => {
4624                    let (m, k, n) = (m as usize, k as usize, n as usize);
4625                    let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
4626                    Arc::new(move |base: *mut u8| unsafe {
4627                        let xs = sl(x, base, m * k);
4628                        let w_bytes = std::slice::from_raw_parts(
4629                            base.add(w_q) as *const u8,
4630                            (k * n).div_ceil(2),
4631                        );
4632                        let scale_bytes =
4633                            std::slice::from_raw_parts(base.add(scale) as *const u8, n_scale);
4634                        let gs = sl(global_scale, base, 1)[0];
4635                        let out = sl_mut(dst, base, m * n);
4636                        dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
4637                    })
4638                }
4639
4640                Thunk::ScaledMatMul {
4641                    lhs,
4642                    rhs,
4643                    lhs_scale,
4644                    rhs_scale,
4645                    bias,
4646                    dst,
4647                    m,
4648                    k,
4649                    n,
4650                    lhs_fmt,
4651                    rhs_fmt,
4652                    layout,
4653                    has_bias,
4654                } => {
4655                    let (m, k, n) = (m as usize, k as usize, n as usize);
4656                    let nblk = lowp_nblk(k, layout);
4657                    let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
4658                    let n_lscale = if per_tensor { 1 } else { m * nblk };
4659                    let n_rscale = if per_tensor { 1 } else { n * nblk };
4660                    Arc::new(move |base: *mut u8| unsafe {
4661                        let lhs_b = std::slice::from_raw_parts(base.add(lhs) as *const u8, m * k);
4662                        let rhs_b = std::slice::from_raw_parts(base.add(rhs) as *const u8, n * k);
4663                        let ls = lowp_read_scales(layout, base, lhs_scale, n_lscale);
4664                        let rs = lowp_read_scales(layout, base, rhs_scale, n_rscale);
4665                        let bias_s = if has_bias { Some(sl(bias, base, n)) } else { None };
4666                        let out = sl_mut(dst, base, m * n);
4667                        lowp_scaled_matmul(
4668                            lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, lhs_fmt, rhs_fmt,
4669                        );
4670                    })
4671                }
4672
4673                Thunk::ScaledQuantize {
4674                    x,
4675                    scale,
4676                    dst,
4677                    rows,
4678                    cols,
4679                    fmt,
4680                    layout,
4681                } => {
4682                    let (rows, cols) = (rows as usize, cols as usize);
4683                    let nblk = lowp_nblk(cols, layout);
4684                    let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
4685                        1
4686                    } else {
4687                        rows * nblk
4688                    };
4689                    Arc::new(move |base: *mut u8| unsafe {
4690                        let xs = sl(x, base, rows * cols);
4691                        let scales = lowp_read_scales(layout, base, scale, n_scale);
4692                        let out =
4693                            std::slice::from_raw_parts_mut(base.add(dst), rows * cols);
4694                        lowp_quantize(xs, &scales, fmt, layout, rows, cols, out);
4695                    })
4696                }
4697
4698                Thunk::ScaledQuantScale {
4699                    x,
4700                    dst,
4701                    rows,
4702                    cols,
4703                    fmt,
4704                    layout,
4705                } => {
4706                    let (rows, cols) = (rows as usize, cols as usize);
4707                    let nblk = lowp_nblk(cols, layout);
4708                    Arc::new(move |base: *mut u8| unsafe {
4709                        let xs = sl(x, base, rows * cols);
4710                        let scales = lowp_compute_scales(xs, fmt, layout, rows, cols);
4711                        match layout {
4712                            rlx_ir::ScaleLayout::PerTensor => {
4713                                sl_mut(dst, base, 1)[0] = scales[0];
4714                            }
4715                            rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
4716                                let out = std::slice::from_raw_parts_mut(
4717                                    base.add(dst),
4718                                    rows * nblk,
4719                                );
4720                                for (o, &s) in out.iter_mut().zip(&scales) {
4721                                    *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
4722                                }
4723                            }
4724                            rlx_ir::ScaleLayout::Nvfp4 { .. } => {
4725                                let out = std::slice::from_raw_parts_mut(
4726                                    base.add(dst),
4727                                    rows * nblk,
4728                                );
4729                                for (o, &s) in out.iter_mut().zip(&scales) {
4730                                    *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
4731                                }
4732                            }
4733                        }
4734                    })
4735                }
4736
4737                Thunk::ScaledDequantize {
4738                    codes,
4739                    scale,
4740                    dst,
4741                    rows,
4742                    cols,
4743                    fmt,
4744                    layout,
4745                } => {
4746                    let (rows, cols) = (rows as usize, cols as usize);
4747                    let nblk = lowp_nblk(cols, layout);
4748                    let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
4749                        1
4750                    } else {
4751                        rows * nblk
4752                    };
4753                    Arc::new(move |base: *mut u8| unsafe {
4754                        let cs = std::slice::from_raw_parts(base.add(codes) as *const u8, rows * cols);
4755                        let scales = lowp_read_scales(layout, base, scale, n_scale);
4756                        let out = sl_mut(dst, base, rows * cols);
4757                        lowp_dequantize(cs, &scales, fmt, layout, rows, cols, out);
4758                    })
4759                }
4760
4761                Thunk::LoraMatMul {
4762                    x,
4763                    w,
4764                    a,
4765                    b,
4766                    dst,
4767                    m,
4768                    k,
4769                    n,
4770                    r,
4771                    scale,
4772                } => {
4773                    let (m, k, n, r) = (m as usize, k as usize, n as usize, r as usize);
4774                    Arc::new(move |base: *mut u8| unsafe {
4775                        let xs = sl(x, base, m * k);
4776                        let ws = sl(w, base, k * n);
4777                        let a_s = sl(a, base, k * r);
4778                        let bs = sl(b, base, r * n);
4779                        let out = sl_mut(dst, base, m * n);
4780                        // Step 1: out = x · W.
4781                        crate::blas::sgemm(xs, ws, out, m, k, n);
4782                        // Step 2: tmp = x · A (rank-r intermediate; tiny).
4783                        let mut tmp = vec![0f32; m * r];
4784                        crate::blas::sgemm(xs, a_s, &mut tmp, m, k, r);
4785                        // Step 3: out += scale * (tmp · B).
4786                        // sgemm_accumulate uses alpha=1.0 internally, so
4787                        // scale tmp first.
4788                        if scale != 1.0 {
4789                            for v in tmp.iter_mut() {
4790                                *v *= scale;
4791                            }
4792                        }
4793                        crate::blas::sgemm_accumulate(&tmp, bs, out, m, r, n);
4794                    })
4795                }
4796
4797                Thunk::LayerNorm {
4798                    src,
4799                    g,
4800                    b,
4801                    dst,
4802                    rows,
4803                    h,
4804                    eps,
4805                } => {
4806                    let (rows, h) = (rows as usize, h as usize);
4807                    Arc::new(move |base: *mut u8| unsafe {
4808                        let inp = sl(src, base, rows * h);
4809                        let gamma = sl(g, base, h);
4810                        let beta = sl(b, base, h);
4811                        let out = sl_mut(dst, base, rows * h);
4812                        for row in 0..rows {
4813                            crate::kernels::layer_norm_row(
4814                                &inp[row * h..(row + 1) * h],
4815                                gamma,
4816                                beta,
4817                                &mut out[row * h..(row + 1) * h],
4818                                h,
4819                                eps,
4820                            );
4821                        }
4822                    })
4823                }
4824
4825                Thunk::BatchNormInference {
4826                    src,
4827                    g,
4828                    b,
4829                    mean,
4830                    var,
4831                    dst,
4832                    count,
4833                    channels,
4834                    eps,
4835                } => {
4836                    let count = count as usize;
4837                    let c = channels as usize;
4838                    let n = count * c;
4839                    let (src, g, b, mean, var, dst) = (src, g, b, mean, var, dst);
4840                    Arc::new(move |base: *mut u8| unsafe {
4841                        crate::kernels::batch_norm_inference(
4842                            sl(src, base, n),
4843                            sl(g, base, c),
4844                            sl(b, base, c),
4845                            sl(mean, base, c),
4846                            sl(var, base, c),
4847                            sl_mut(dst, base, n),
4848                            c,
4849                            eps,
4850                        );
4851                    })
4852                }
4853
4854                Thunk::Attention {
4855                    q,
4856                    k,
4857                    v,
4858                    mask,
4859                    out,
4860                    batch,
4861                    seq,
4862                    kv_seq,
4863                    heads,
4864                    kv_heads,
4865                    head_dim,
4866                    mask_kind,
4867                    scale,
4868                    softcap,
4869                    q_row_stride,
4870                    k_row_stride,
4871                    v_row_stride,
4872                    bhsd,
4873                } => {
4874                    if std::env::var("RLX_ATTN_DEBUG").is_ok() {
4875                        eprintln!("[attn-compile] batch={batch} seq={seq} kv_seq={kv_seq} heads={heads} bhsd={bhsd}");
4876                    }
4877                    // Q seq length (`q_s`) and K/V seq length (`k_s`) differ
4878                    // during cached decode (`q_s=1`, `k_s=past_seq+1`). The
4879                    // earlier version of this kernel destructured
4880                    // `kv_seq: _` and used a single `s = seq` for both axes,
4881                    // so cached decode only scored 1×1 instead of 1×k_s —
4882                    // attention couldn't see the past K cache and decode
4883                    // collapsed into repetitive fragments
4884                    // (`Self-based on [1\nAnswer: Self-based on [1…`).
4885                    let (b, q_s, k_s, nh, dh) = (
4886                        batch as usize,
4887                        seq as usize,
4888                        kv_seq as usize,
4889                        heads as usize,
4890                        head_dim as usize,
4891                    );
4892                    let hs = nh * dh;
4893                    // GQA/MQA: `group` query heads share one KV head. group == 1
4894                    // for MHA (kv_heads == heads), so this is a no-op there.
4895                    let nkv = (kv_heads as usize).max(1);
4896                    let group = (nh / nkv).max(1);
4897                    let qrs = q_row_stride as usize;
4898                    let krs = k_row_stride as usize;
4899                    let vrs = v_row_stride as usize;
4900                    // honor Op::Attention::score_scale (e.g. Gemma 4 = 1.0)
4901                    Arc::new(move |base: *mut u8| unsafe {
4902                        if std::env::var("RLX_ATTN_DEBUG").is_ok() {
4903                            eprintln!("[attn] b={b} q_s={q_s} k_s={k_s} nh={nh} dh={dh} bhsd={bhsd} mask_kind={:?}", mask_kind);
4904                        }
4905                        // Slice lengths use the source's row stride so the
4906                        // compiler-emitted bounds checks cover the whole
4907                        // strided span (the kernel walks with q/k/v_rs).
4908                        // For [B, H, S, D] the buffer is dense B*H*S*D.
4909                        let (q_len, k_len, v_len, o_len) = if bhsd {
4910                            let qn = b * nh * q_s * dh;
4911                            let kn = b * nkv * k_s * dh;
4912                            (qn, kn, kn, qn)
4913                        } else {
4914                            (b * q_s * qrs, b * k_s * krs, b * k_s * vrs, b * q_s * hs)
4915                        };
4916                        let q_d = sl(q, base, q_len);
4917                        let k_d = sl(k, base, k_len);
4918                        let v_d = sl(v, base, v_len);
4919                        let m_d: &[f32] = match mask_kind {
4920                            rlx_ir::op::MaskKind::Custom => sl(mask, base, b * k_s),
4921                            rlx_ir::op::MaskKind::Bias => sl(mask, base, b * nh * q_s * k_s),
4922                            _ => &[],
4923                        };
4924                        let o_d = sl_mut(out, base, o_len);
4925                        let mut qh = vec![0f32; q_s * dh];
4926                        let mut kh = vec![0f32; k_s * dh];
4927                        let mut vh = vec![0f32; k_s * dh];
4928                        let mut sc = vec![0f32; q_s * k_s];
4929                        let mut oh = vec![0f32; q_s * dh];
4930                        for bi in 0..b {
4931                            for hi in 0..nh {
4932                                // Gather per-head Q.
4933                                for si in 0..q_s {
4934                                    let q_off = if bhsd {
4935                                        bi * nh * q_s * dh + hi * q_s * dh + si * dh
4936                                    } else {
4937                                        bi * q_s * qrs + si * qrs + hi * dh
4938                                    };
4939                                    qh[si * dh..(si + 1) * dh]
4940                                        .copy_from_slice(&q_d[q_off..q_off + dh]);
4941                                }
4942                                // Gather per-head K, V. GQA/MQA: several query
4943                                // heads share one KV head (kv_hi); group == 1 for
4944                                // MHA, so kv_hi == hi.
4945                                let kv_hi = hi / group;
4946                                for si in 0..k_s {
4947                                    let (k_off, v_off) = if bhsd {
4948                                        (
4949                                            bi * nkv * k_s * dh + kv_hi * k_s * dh + si * dh,
4950                                            bi * nkv * k_s * dh + kv_hi * k_s * dh + si * dh,
4951                                        )
4952                                    } else {
4953                                        (
4954                                            bi * k_s * krs + si * krs + kv_hi * dh,
4955                                            bi * k_s * vrs + si * vrs + kv_hi * dh,
4956                                        )
4957                                    };
4958                                    kh[si * dh..(si + 1) * dh]
4959                                        .copy_from_slice(&k_d[k_off..k_off + dh]);
4960                                    vh[si * dh..(si + 1) * dh]
4961                                        .copy_from_slice(&v_d[v_off..v_off + dh]);
4962                                }
4963                                for qi in 0..q_s {
4964                                    for ki in 0..k_s {
4965                                        let mut dot = 0f32;
4966                                        for d in 0..dh {
4967                                            dot += qh[qi * dh + d] * kh[ki * dh + d];
4968                                        }
4969                                        sc[qi * k_s + ki] = dot * scale;
4970                                    }
4971                                }
4972                                // Apply mask. Causal/SlidingWindow use absolute
4973                                // positions so they handle Lq != Lk (decode mode
4974                                // with cached K/V): q_offset = k_s - q_s.
4975                                let q_offset = k_s.saturating_sub(q_s);
4976                                match mask_kind {
4977                                    rlx_ir::op::MaskKind::None => {}
4978                                    rlx_ir::op::MaskKind::Causal => {
4979                                        for qi in 0..q_s {
4980                                            let abs_q = q_offset + qi;
4981                                            for ki in (abs_q + 1)..k_s {
4982                                                sc[qi * k_s + ki] = mask_neg;
4983                                            }
4984                                        }
4985                                    }
4986                                    rlx_ir::op::MaskKind::SlidingWindow(w) => {
4987                                        for qi in 0..q_s {
4988                                            let abs_q = q_offset + qi;
4989                                            let lo = abs_q.saturating_sub(w);
4990                                            for ki in 0..k_s {
4991                                                if ki < lo || ki > abs_q {
4992                                                    sc[qi * k_s + ki] = mask_neg;
4993                                                }
4994                                            }
4995                                        }
4996                                    }
4997                                    rlx_ir::op::MaskKind::Custom => {
4998                                        for qi in 0..q_s {
4999                                            for ki in 0..k_s {
5000                                                if m_d[bi * k_s + ki] < mask_thr {
5001                                                    sc[qi * k_s + ki] = mask_neg;
5002                                                }
5003                                            }
5004                                        }
5005                                    }
5006                                    rlx_ir::op::MaskKind::Bias => {
5007                                        let per_bh = q_s * k_s;
5008                                        let off = (bi * nh + hi) * per_bh;
5009                                        for i in 0..per_bh {
5010                                            sc[i] += m_d[off + i];
5011                                        }
5012                                    }
5013                                }
5014                                // Gemma 2 attention logit soft-cap (post-mask,
5015                                // pre-softmax) — matches rlx-cpu executor.rs and
5016                                // rlx-metal / rlx-cuda native attention.
5017                                if softcap > 0.0 {
5018                                    for s in sc.iter_mut() {
5019                                        *s = softcap * (*s / softcap).tanh();
5020                                    }
5021                                }
5022                                crate::naive::softmax(&mut sc, q_s, k_s);
5023                                oh.fill(0.0);
5024                                for qi in 0..q_s {
5025                                    for ki in 0..k_s {
5026                                        let w = sc[qi * k_s + ki];
5027                                        if w > score_skip {
5028                                            for d in 0..dh {
5029                                                oh[qi * dh + d] += w * vh[ki * dh + d];
5030                                            }
5031                                        }
5032                                    }
5033                                }
5034                                for si in 0..q_s {
5035                                    let off = if bhsd {
5036                                        bi * nh * q_s * dh + hi * q_s * dh + si * dh
5037                                    } else {
5038                                        bi * q_s * hs + si * hs + hi * dh
5039                                    };
5040                                    o_d[off..off + dh].copy_from_slice(&oh[si * dh..(si + 1) * dh]);
5041                                }
5042                            }
5043                        }
5044                    })
5045                }
5046
5047                Thunk::FusedSwiGLU {
5048                    src,
5049                    dst,
5050                    n_half,
5051                    total,
5052                    gate_first,
5053                } => {
5054                    let n = n_half as usize;
5055                    let t = total as usize;
5056                    let outer = t / n;
5057                    let in_total = outer * 2 * n;
5058                    Arc::new(move |base: *mut u8| unsafe {
5059                        let inp = sl(src, base, in_total);
5060                        let out = sl_mut(dst, base, t);
5061                        for o in 0..outer {
5062                            let in_row = &inp[o * 2 * n..(o + 1) * 2 * n];
5063                            let out_row = &mut out[o * n..(o + 1) * n];
5064                            for i in 0..n {
5065                                let (up, gate) = if gate_first {
5066                                    (in_row[n + i], in_row[i])
5067                                } else {
5068                                    (in_row[i], in_row[n + i])
5069                                };
5070                                out_row[i] = up * (gate / (1.0 + (-gate).exp()));
5071                            }
5072                        }
5073                    })
5074                }
5075
5076                Thunk::Concat {
5077                    dst,
5078                    outer,
5079                    inner,
5080                    total_axis,
5081                    inputs,
5082                } => {
5083                    let outer = outer as usize;
5084                    let inner = inner as usize;
5085                    let total_axis = total_axis as usize;
5086                    let out_total = outer * total_axis * inner;
5087                    let mut layout: Vec<(usize, usize, usize, usize)> =
5088                        Vec::with_capacity(inputs.len());
5089                    let mut cum: usize = 0;
5090                    for (src_off, in_axis, in_numel) in &inputs {
5091                        let in_axis = *in_axis as usize;
5092                        layout.push((*src_off, cum * inner, in_axis * inner, *in_numel as usize));
5093                        cum += in_axis;
5094                    }
5095                    Arc::new(move |base: *mut u8| unsafe {
5096                        let out = sl_mut(dst, base, out_total);
5097                        let row_stride = total_axis * inner;
5098                        for (src_off, dst_col_off, copy_per_row, in_numel) in &layout {
5099                            let inp = sl(*src_off, base, (*in_numel).max(1));
5100                            concat_copy_rows_f32(
5101                                out,
5102                                inp,
5103                                outer,
5104                                *copy_per_row,
5105                                row_stride,
5106                                *dst_col_off,
5107                                *in_numel,
5108                            );
5109                        }
5110                    })
5111                }
5112
5113                Thunk::CustomOp {
5114                    kernel,
5115                    inputs,
5116                    output,
5117                    attrs,
5118                } => {
5119                    // Capture-by-move: clone the Arc and Vecs once into the
5120                    // closure. Dispatch by output dtype each call (the
5121                    // dtype is fixed at compile time but it's cheaper to
5122                    // branch once per execution than to monomorphize a
5123                    // dozen closure variants).
5124                    let kernel = kernel.clone();
5125                    let attrs = attrs.clone();
5126                    let inputs = inputs.clone();
5127                    let (out_off, out_len, out_shape) = output.clone();
5128                    Arc::new(move |base: *mut u8| unsafe {
5129                        dispatch_custom_op(
5130                            &*kernel, &inputs, out_off, out_len, &out_shape, &attrs, base,
5131                        );
5132                    })
5133                }
5134
5135                Thunk::GaussianSplatRender {
5136                    positions_off,
5137                    positions_len,
5138                    scales_off,
5139                    scales_len,
5140                    rotations_off,
5141                    rotations_len,
5142                    opacities_off,
5143                    opacities_len,
5144                    colors_off,
5145                    colors_len,
5146                    sh_coeffs_off,
5147                    sh_coeffs_len,
5148                    meta_off,
5149                    dst_off,
5150                    dst_len,
5151                    width,
5152                    height,
5153                    tile_size,
5154                    radius_scale,
5155                    alpha_cutoff,
5156                    max_splat_steps,
5157                    transmittance_threshold,
5158                    max_list_entries,
5159                } => Arc::new(move |base: *mut u8| unsafe {
5160                    crate::splat::execute_gaussian_splat_render(
5161                        positions_off,
5162                        positions_len,
5163                        scales_off,
5164                        scales_len,
5165                        rotations_off,
5166                        rotations_len,
5167                        opacities_off,
5168                        opacities_len,
5169                        colors_off,
5170                        colors_len,
5171                        sh_coeffs_off,
5172                        sh_coeffs_len,
5173                        meta_off,
5174                        dst_off,
5175                        dst_len,
5176                        width,
5177                        height,
5178                        tile_size,
5179                        radius_scale,
5180                        alpha_cutoff,
5181                        max_splat_steps,
5182                        transmittance_threshold,
5183                        max_list_entries,
5184                        base,
5185                    );
5186                }),
5187
5188                Thunk::GaussianSplatRenderBackward {
5189                    positions_off,
5190                    positions_len,
5191                    scales_off,
5192                    scales_len,
5193                    rotations_off,
5194                    rotations_len,
5195                    opacities_off,
5196                    opacities_len,
5197                    colors_off,
5198                    colors_len,
5199                    sh_coeffs_off,
5200                    sh_coeffs_len,
5201                    meta_off,
5202                    d_loss_off,
5203                    d_loss_len,
5204                    packed_off,
5205                    packed_len,
5206                    width,
5207                    height,
5208                    tile_size,
5209                    radius_scale,
5210                    alpha_cutoff,
5211                    max_splat_steps,
5212                    transmittance_threshold,
5213                    max_list_entries,
5214                    loss_grad_clip,
5215                    sh_band,
5216                    max_anisotropy,
5217                } => Arc::new(move |base: *mut u8| unsafe {
5218                    crate::splat::execute_gaussian_splat_render_backward(
5219                        positions_off,
5220                        positions_len,
5221                        scales_off,
5222                        scales_len,
5223                        rotations_off,
5224                        rotations_len,
5225                        opacities_off,
5226                        opacities_len,
5227                        colors_off,
5228                        colors_len,
5229                        sh_coeffs_off,
5230                        sh_coeffs_len,
5231                        meta_off,
5232                        d_loss_off,
5233                        d_loss_len,
5234                        packed_off,
5235                        packed_len,
5236                        width,
5237                        height,
5238                        tile_size,
5239                        radius_scale,
5240                        alpha_cutoff,
5241                        max_splat_steps,
5242                        transmittance_threshold,
5243                        max_list_entries,
5244                        loss_grad_clip,
5245                        sh_band,
5246                        max_anisotropy,
5247                        base,
5248                    );
5249                }),
5250
5251                Thunk::GaussianSplatPrepare {
5252                    positions_off,
5253                    positions_len,
5254                    scales_off,
5255                    scales_len,
5256                    rotations_off,
5257                    rotations_len,
5258                    opacities_off,
5259                    opacities_len,
5260                    colors_off,
5261                    colors_len,
5262                    sh_coeffs_off,
5263                    sh_coeffs_len,
5264                    meta_off,
5265                    meta_len,
5266                    prep_off,
5267                    prep_len,
5268                    width,
5269                    height,
5270                    tile_size,
5271                    radius_scale,
5272                    alpha_cutoff,
5273                    max_splat_steps,
5274                    transmittance_threshold,
5275                    max_list_entries,
5276                } => Arc::new(move |base: *mut u8| unsafe {
5277                    crate::splat::execute_gaussian_splat_prepare(
5278                        positions_off,
5279                        positions_len,
5280                        scales_off,
5281                        scales_len,
5282                        rotations_off,
5283                        rotations_len,
5284                        opacities_off,
5285                        opacities_len,
5286                        colors_off,
5287                        colors_len,
5288                        sh_coeffs_off,
5289                        sh_coeffs_len,
5290                        meta_off,
5291                        meta_len,
5292                        prep_off,
5293                        prep_len,
5294                        width,
5295                        height,
5296                        tile_size,
5297                        radius_scale,
5298                        alpha_cutoff,
5299                        max_splat_steps,
5300                        transmittance_threshold,
5301                        max_list_entries,
5302                        base,
5303                    );
5304                }),
5305
5306                Thunk::GaussianSplatRasterize {
5307                    prep_off,
5308                    prep_len,
5309                    meta_off,
5310                    meta_len,
5311                    dst_off,
5312                    dst_len,
5313                    count,
5314                    width,
5315                    height,
5316                    tile_size,
5317                    alpha_cutoff,
5318                    max_splat_steps,
5319                    transmittance_threshold,
5320                    max_list_entries,
5321                } => Arc::new(move |base: *mut u8| unsafe {
5322                    crate::splat::execute_gaussian_splat_rasterize(
5323                        prep_off,
5324                        prep_len,
5325                        meta_off,
5326                        meta_len,
5327                        dst_off,
5328                        dst_len,
5329                        count,
5330                        width,
5331                        height,
5332                        tile_size,
5333                        alpha_cutoff,
5334                        max_splat_steps,
5335                        transmittance_threshold,
5336                        max_list_entries,
5337                        base,
5338                    );
5339                }),
5340
5341                Thunk::Fft1d {
5342                    src,
5343                    dst,
5344                    outer,
5345                    n_complex,
5346                    inverse,
5347                    norm_tag,
5348                    dtype,
5349                } => {
5350                    let f: Arc<dyn Fn(*mut u8) + Send + Sync> = match dtype {
5351                        rlx_ir::DType::F64 => Arc::new(move |base: *mut u8| unsafe {
5352                            execute_fft1d_f64(
5353                                src,
5354                                dst,
5355                                outer as usize,
5356                                n_complex as usize,
5357                                inverse,
5358                                norm_tag,
5359                                base,
5360                            );
5361                        }),
5362                        rlx_ir::DType::F32 => Arc::new(move |base: *mut u8| unsafe {
5363                            execute_fft1d_f32(
5364                                src,
5365                                dst,
5366                                outer as usize,
5367                                n_complex as usize,
5368                                inverse,
5369                                norm_tag,
5370                                base,
5371                            );
5372                        }),
5373                        rlx_ir::DType::C64 => Arc::new(move |base: *mut u8| unsafe {
5374                            execute_fft1d_c64(
5375                                src,
5376                                dst,
5377                                outer as usize,
5378                                n_complex as usize,
5379                                inverse,
5380                                norm_tag,
5381                                base,
5382                            );
5383                        }),
5384                        other => panic!("Op::Fft on CPU requires F32/F64/C64, got {other:?}"),
5385                    };
5386                    f
5387                }
5388
5389                Thunk::FftButterflyStage {
5390                    state_src,
5391                    state_dst,
5392                    gate_src,
5393                    rev_src,
5394                    tw_re_src,
5395                    tw_im_src,
5396                    batch,
5397                    n_fft,
5398                    stage,
5399                } => Arc::new(move |base: *mut u8| unsafe {
5400                    execute_fft_butterfly_stage_f32(
5401                        state_src,
5402                        state_dst,
5403                        gate_src,
5404                        rev_src,
5405                        tw_re_src,
5406                        tw_im_src,
5407                        batch as usize,
5408                        n_fft as usize,
5409                        stage as usize,
5410                        base,
5411                    );
5412                }),
5413
5414                Thunk::LogMel {
5415                    spec,
5416                    filters,
5417                    dst,
5418                    outer,
5419                    n_fft,
5420                    n_bins,
5421                    n_mels,
5422                } => Arc::new(move |base: *mut u8| unsafe {
5423                    execute_log_mel_f32(
5424                        spec,
5425                        filters,
5426                        dst,
5427                        outer as usize,
5428                        n_fft as usize,
5429                        n_bins as usize,
5430                        n_mels as usize,
5431                        base,
5432                    );
5433                }),
5434
5435                Thunk::LogMelBackward {
5436                    spec,
5437                    filters,
5438                    dy,
5439                    dst,
5440                    outer,
5441                    n_fft,
5442                    n_bins,
5443                    n_mels,
5444                } => Arc::new(move |base: *mut u8| unsafe {
5445                    execute_log_mel_backward_f32(
5446                        spec,
5447                        filters,
5448                        dy,
5449                        dst,
5450                        outer as usize,
5451                        n_fft as usize,
5452                        n_bins as usize,
5453                        n_mels as usize,
5454                        base,
5455                    );
5456                }),
5457
5458                Thunk::WelchPeaks {
5459                    spec,
5460                    dst,
5461                    welch_batch,
5462                    n_fft,
5463                    n_segments,
5464                    k,
5465                } => Arc::new(move |base: *mut u8| unsafe {
5466                    execute_welch_peaks_f32(
5467                        spec,
5468                        dst,
5469                        welch_batch as usize,
5470                        n_fft as usize,
5471                        n_segments as usize,
5472                        k as usize,
5473                        base,
5474                    );
5475                }),
5476
5477                Thunk::SgdMomentum { param, vel, grad, p_out, v_out, lr, mom, len } => {
5478                    let len = len as usize;
5479                    Arc::new(move |base: *mut u8| unsafe {
5480                        let p = sl(param, base, len);
5481                        let v = sl(vel, base, len);
5482                        let g = sl(grad, base, len);
5483                        let po = sl_mut(p_out, base, len);
5484                        let vo = sl_mut(v_out, base, len);
5485                        for i in 0..len {
5486                            let vn = mom * v[i] + g[i];
5487                            vo[i] = vn;
5488                            po[i] = p[i] - lr * vn;
5489                        }
5490                    })
5491                }
5492
5493                _ => Arc::new(|_: *mut u8| {}),
5494            }
5495        })
5496        .collect();
5497
5498    // ── Thunk-level attention fusion ──────────────────────
5499    // For small batch*seq, fuse QKV→Narrow×3→[Rope×2]→Attention→OutProj
5500    // into a single FusedAttnBlock. Auto-detects from Attention thunks.
5501    let fuse_threshold: usize = rlx_ir::env::var("RLX_FUSE_ATTN_THRESHOLD")
5502        .and_then(|v| v.parse().ok())
5503        .unwrap_or(64);
5504    let should_fuse = thunks.iter().any(|t| match t {
5505        Thunk::Attention { batch, seq, .. } => {
5506            (*batch as usize) * (*seq as usize) <= fuse_threshold
5507        }
5508        _ => false,
5509    });
5510
5511    if should_fuse {
5512        // Build non-Nop index for pattern matching across Nop gaps
5513        let active: Vec<usize> = thunks
5514            .iter()
5515            .enumerate()
5516            .filter(|(_, t)| !matches!(t, Thunk::Nop))
5517            .map(|(i, _)| i)
5518            .collect();
5519
5520        let mut kill = vec![false; thunks.len()]; // mark thunks to remove
5521        let mut insertions: Vec<(usize, Thunk)> = Vec::new(); // (position, replacement)
5522
5523        let mut ai = 0;
5524        while ai < active.len() {
5525            // Helper: get active thunk at offset from current
5526            let a = |off: usize| -> Option<(usize, &Thunk)> {
5527                active.get(ai + off).map(|&idx| (idx, &thunks[idx]))
5528            };
5529
5530            // Try BERT pattern: FusedMmBiasAct(QKV) → Narrow×3 → Attention → FusedMmBiasAct(out)
5531            let matched = (|| {
5532                let (_i0, t0) = a(0)?;
5533                let (_, t1) = a(1)?;
5534                let (_, t2) = a(2)?;
5535                let (_, t3) = a(3)?;
5536
5537                // a[0] must be FusedMmBiasAct or Sgemm (QKV projection)
5538                let (hidden, qkv_w, qkv_b, has_b) = match t0 {
5539                    Thunk::FusedMmBiasAct {
5540                        a,
5541                        w,
5542                        bias,
5543                        n: _,
5544                        act: None,
5545                        ..
5546                    } => (*a, *w, *bias, true),
5547                    Thunk::Sgemm { a, b, n: _, .. } => (*a, *b, 0, false),
5548                    _ => return None,
5549                };
5550
5551                // a[1..3] must be Narrows
5552                if !matches!(t1, Thunk::Narrow { .. }) {
5553                    return None;
5554                }
5555                if !matches!(t2, Thunk::Narrow { .. }) {
5556                    return None;
5557                }
5558                if !matches!(t3, Thunk::Narrow { .. }) {
5559                    return None;
5560                }
5561
5562                // Look for optional Rope×2 then Attention. Capture the rope
5563                // pairing (`interleaved` = GPT-J) from the q-rope thunk and
5564                // require the k-rope to agree — the fused kernel applies one
5565                // pairing to both.
5566                let (has_rope, attn_ai, cos_off, sin_off, cl, rope_interleaved) = if let Some((
5567                    _,
5568                    Thunk::Rope {
5569                        cos,
5570                        sin,
5571                        cos_len,
5572                        interleaved,
5573                        ..
5574                    },
5575                )) = a(4)
5576                {
5577                    let q_il = *interleaved;
5578                    match a(5).map(|x| x.1) {
5579                        Some(Thunk::Rope {
5580                            interleaved: k_il, ..
5581                        }) if *k_il == q_il => {
5582                            if matches!(a(6).map(|x| x.1), Some(Thunk::Attention { .. })) {
5583                                (true, 6, *cos, *sin, *cos_len, q_il)
5584                            } else {
5585                                return None;
5586                            }
5587                        }
5588                        _ => return None,
5589                    }
5590                } else if matches!(a(4).map(|x| x.1), Some(Thunk::Attention { .. })) {
5591                    (false, 4, 0, 0, 0, false)
5592                } else {
5593                    return None;
5594                };
5595
5596                let (_attn_real_idx, attn_t) = a(attn_ai)?;
5597                let (batch, seq, heads, head_dim, mask, mask_kind, kv_seq, softcap) = match attn_t {
5598                    Thunk::Attention {
5599                        batch,
5600                        seq,
5601                        heads,
5602                        head_dim,
5603                        mask,
5604                        mask_kind,
5605                        kv_seq,
5606                        softcap,
5607                        ..
5608                    } => (
5609                        *batch, *seq, *heads, *head_dim, *mask, *mask_kind, *kv_seq, *softcap,
5610                    ),
5611                    _ => return None,
5612                };
5613                // The fused kernel synthesizes Causal / SlidingWindow in-kernel
5614                // and reads the buffer for Custom; it has no additive-`Bias`
5615                // path, no attention logit soft-cap (Gemma 2), and its in-kernel
5616                // position math assumes prefill (`q_seq == kv_seq`, i.e. no KV
5617                // cache). Fall back to the unfused Attention thunk (which honors
5618                // softcap) for anything else.
5619                if matches!(mask_kind, rlx_ir::op::MaskKind::Bias)
5620                    || kv_seq != seq
5621                    || softcap != 0.0
5622                {
5623                    return None;
5624                }
5625
5626                // Next active must be out projection (FusedMmBiasAct or Sgemm)
5627                let (_out_real_idx, out_t) = a(attn_ai + 1)?;
5628                let (out_w, out_b, out_dst) = match out_t {
5629                    Thunk::FusedMmBiasAct {
5630                        w,
5631                        bias,
5632                        c,
5633                        act: None,
5634                        ..
5635                    } => (*w, *bias, *c),
5636                    Thunk::Sgemm { b: w, c, .. } => (*w, 0, *c),
5637                    _ => return None,
5638                };
5639
5640                let hs = heads * head_dim;
5641                let total_active = attn_ai + 2; // number of active thunks consumed
5642
5643                Some((
5644                    total_active,
5645                    Thunk::FusedAttnBlock {
5646                        hidden,
5647                        qkv_w,
5648                        out_w,
5649                        mask,
5650                        mask_kind,
5651                        out: out_dst,
5652                        qkv_b: if has_b { qkv_b } else { 0 },
5653                        out_b: if has_b { out_b } else { 0 },
5654                        cos: cos_off,
5655                        sin: sin_off,
5656                        cos_len: cl,
5657                        batch,
5658                        seq,
5659                        hs,
5660                        nh: heads,
5661                        dh: head_dim,
5662                        has_bias: has_b,
5663                        has_rope,
5664                        interleaved: rope_interleaved,
5665                    },
5666                ))
5667            })();
5668
5669            if let Some((count, fused_thunk)) = matched {
5670                // Mark consumed thunks for removal
5671                for off in 0..count {
5672                    if let Some(&idx) = active.get(ai + off) {
5673                        kill[idx] = true;
5674                    }
5675                }
5676                // Insert replacement at position of the QKV thunk
5677                insertions.push((active[ai], fused_thunk));
5678                ai += count;
5679            } else {
5680                ai += 1;
5681            }
5682        }
5683
5684        // Rebuild thunk list: keep non-killed, insert fused at right positions
5685        if !insertions.is_empty() {
5686            let mut new_thunks = Vec::with_capacity(thunks.len());
5687            let mut insert_idx = 0;
5688            for (i, t) in thunks.into_iter().enumerate() {
5689                if insert_idx < insertions.len() && insertions[insert_idx].0 == i {
5690                    new_thunks.push(insertions[insert_idx].1.clone());
5691                    insert_idx += 1;
5692                }
5693                if !kill[i] {
5694                    new_thunks.push(t);
5695                }
5696            }
5697            if cfg.verbose >= 1 {
5698                eprintln!(
5699                    "[rlx] fused_attention: {} attention blocks fused",
5700                    insertions.len()
5701                );
5702            }
5703            thunks = new_thunks;
5704        }
5705    }
5706
5707    // ── Full layer fusion ──────────────────────────────────
5708    // After attention blocks are fused, scan for full layer patterns:
5709    // BERT:  FusedAttnBlock → FusedResidualLN → FusedMmBiasAct(gelu) → Sgemm → BiasAdd → FusedResidualLN
5710    // Nomic: FusedAttnBlock → BinaryFull(add) → LayerNorm → Sgemm → [Narrow×2 → Silu → BinaryFull(mul)] → Sgemm → BinaryFull(add) → LayerNorm
5711    if should_fuse {
5712        let active: Vec<usize> = thunks
5713            .iter()
5714            .enumerate()
5715            .filter(|(_, t)| !matches!(t, Thunk::Nop))
5716            .map(|(i, _)| i)
5717            .collect();
5718
5719        let mut kill = vec![false; thunks.len()];
5720        let mut insertions: Vec<(usize, Thunk)> = Vec::new();
5721
5722        let a = |ai: usize| -> Option<&Thunk> { active.get(ai).map(|&i| &thunks[i]) };
5723
5724        let mut ai = 0;
5725        while ai < active.len() {
5726            // BERT pattern: FusedAttnBlock → FusedResidualLN → FusedMmBiasAct(gelu) → FusedMmBiasAct(none) → FusedResidualLN
5727            let bert_match = (|| -> Option<usize> {
5728                let fab = a(ai)?;
5729                let rln1 = a(ai + 1)?;
5730                let ffn1 = a(ai + 2)?;
5731                let ffn2 = a(ai + 3)?;
5732                let rln2 = a(ai + 4)?;
5733
5734                let (hidden, qkv_w, qkv_b, out_w, out_b, mask, batch, seq, hs, nh, dh) = match fab {
5735                    Thunk::FusedAttnBlock {
5736                        hidden,
5737                        qkv_w,
5738                        qkv_b,
5739                        out_w,
5740                        out_b,
5741                        mask,
5742                        // FusedBertLayer applies only the per-key padding mask;
5743                        // it has no synthesized causal/sliding path, so it must
5744                        // not swallow a non-`Custom` attention block.
5745                        mask_kind: rlx_ir::op::MaskKind::Custom,
5746                        batch,
5747                        seq,
5748                        hs,
5749                        nh,
5750                        dh,
5751                        has_bias: true,
5752                        has_rope: false,
5753                        ..
5754                    } => (
5755                        *hidden, *qkv_w, *qkv_b, *out_w, *out_b, *mask, *batch, *seq, *hs, *nh, *dh,
5756                    ),
5757                    _ => return None,
5758                };
5759                let (ln1_g, ln1_b, eps1) = match rln1 {
5760                    Thunk::FusedResidualLN { g, b, eps, .. } => (*g, *b, *eps),
5761                    _ => return None,
5762                };
5763                let (fc1_w, fc1_b, int_dim) = match ffn1 {
5764                    Thunk::FusedMmBiasAct {
5765                        w,
5766                        bias,
5767                        n,
5768                        act: Some(Activation::Gelu),
5769                        ..
5770                    } => (*w, *bias, *n),
5771                    _ => return None,
5772                };
5773                let (fc2_w, fc2_b) = match ffn2 {
5774                    Thunk::FusedMmBiasAct {
5775                        w, bias, act: None, ..
5776                    } => (*w, *bias),
5777                    _ => return None,
5778                };
5779                let (ln2_g, ln2_b, eps2, out) = match rln2 {
5780                    Thunk::FusedResidualLN { g, b, eps, out, .. } => (*g, *b, *eps, *out),
5781                    _ => return None,
5782                };
5783
5784                for off in 0..5 {
5785                    kill[active[ai + off]] = true;
5786                }
5787                insertions.push((
5788                    active[ai],
5789                    Thunk::FusedBertLayer {
5790                        hidden,
5791                        qkv_w,
5792                        qkv_b,
5793                        out_w,
5794                        out_b,
5795                        mask,
5796                        ln1_g,
5797                        ln1_b,
5798                        eps1,
5799                        fc1_w,
5800                        fc1_b,
5801                        fc2_w,
5802                        fc2_b,
5803                        ln2_g,
5804                        ln2_b,
5805                        eps2,
5806                        out,
5807                        batch,
5808                        seq,
5809                        hs,
5810                        nh,
5811                        dh,
5812                        int_dim,
5813                    },
5814                ));
5815                Some(5)
5816            })();
5817            if let Some(n) = bert_match {
5818                ai += n;
5819                continue;
5820            }
5821
5822            // Nomic full-layer fusion — DISABLED. The matcher below targets a
5823            // stale pipeline shape (`FusedAttnBlock → FusedResidualLN → Sgemm →
5824            // Narrow×2 → SiLU → Mul → Sgemm → FusedResidualLN`). The current CPU
5825            // pipeline collapses the SwiGLU itself (`FuseSwiGLU` → one
5826            // `Op::FusedSwiGLU`/`Thunk::FusedSwiGLU`) and emits a runtime weight
5827            // `Concat` (runtime weight concat) before the fused fc matmul. So the
5828            // current post-fusion shape is:
5829            //   FusedAttnBlock(rope,no-bias) → FusedResidualLN(LN1) → [Concat] →
5830            //   Sgemm(fc11‖fc12) → FusedSwiGLU → Sgemm(fc2) → FusedResidualLN(LN2)
5831            // which this matches (the weight `Concat`s are kept — they produce the
5832            // fused fc weight the kernel reads). The `FusedNomicLayer` exec only
5833            // implements the up‖gate SwiGLU (`gate_first == false`, what real
5834            // Nomic emits: `up=fc11`, `gate=silu(fc12)`) and a per-key (`Custom`)
5835            // mask, so the match bails otherwise. Escape hatch:
5836            // `RLX_DISABLE_NOMIC_FUSION`. Validated against the real model in
5837            // `../rlx-models/crates/rlx-nomic` (CPU fused-vs-unfused parity).
5838            let nomic_match = (|| -> Option<usize> {
5839                if rlx_ir::env::flag("RLX_DISABLE_NOMIC_FUSION") {
5840                    return None;
5841                }
5842                let (
5843                    hidden,
5844                    qkv_w,
5845                    out_w,
5846                    mask,
5847                    cos,
5848                    sin,
5849                    cos_len,
5850                    batch,
5851                    seq,
5852                    hs,
5853                    nh,
5854                    dh,
5855                    interleaved,
5856                ) = match a(ai)? {
5857                    Thunk::FusedAttnBlock {
5858                        hidden,
5859                        qkv_w,
5860                        out_w,
5861                        mask,
5862                        cos,
5863                        sin,
5864                        cos_len,
5865                        batch,
5866                        seq,
5867                        hs,
5868                        nh,
5869                        dh,
5870                        has_bias: false,
5871                        has_rope: true,
5872                        mask_kind: rlx_ir::op::MaskKind::Custom,
5873                        interleaved,
5874                        ..
5875                    } => (
5876                        *hidden,
5877                        *qkv_w,
5878                        *out_w,
5879                        *mask,
5880                        *cos,
5881                        *sin,
5882                        *cos_len,
5883                        *batch,
5884                        *seq,
5885                        *hs,
5886                        *nh,
5887                        *dh,
5888                        *interleaved,
5889                    ),
5890                    _ => return None,
5891                };
5892                // FusedResidualLN for LN1
5893                let (ln1_g, ln1_b, eps1) = match a(ai + 1)? {
5894                    Thunk::FusedResidualLN { g, b, eps, .. } => (*g, *b, *eps),
5895                    _ => return None,
5896                };
5897                // Consumed active thunks to remove (the kept weight `Concat`s are
5898                // NOT added here — the fused kernel still reads their output).
5899                let mut kills: Vec<usize> = vec![ai, ai + 1];
5900                // Optional runtime weight Concat (fc11‖fc12), then Sgemm.
5901                let mut o = 2;
5902                if matches!(a(ai + o)?, Thunk::Concat { .. }) {
5903                    o += 1;
5904                }
5905                let fused_fc_w = match a(ai + o)? {
5906                    Thunk::Sgemm { b: w, .. } => *w,
5907                    _ => return None,
5908                };
5909                kills.push(ai + o);
5910                o += 1;
5911                // FusedSwiGLU — int_dim is its half width; the exec only handles
5912                // up‖gate (gate is the SECOND half), so require !gate_first.
5913                let int_dim = match a(ai + o)? {
5914                    Thunk::FusedSwiGLU {
5915                        n_half,
5916                        gate_first: false,
5917                        ..
5918                    } => *n_half,
5919                    _ => return None,
5920                };
5921                kills.push(ai + o);
5922                o += 1;
5923                // Sgemm (fc2)
5924                let fc2_w = match a(ai + o)? {
5925                    Thunk::Sgemm { b: w, .. } => *w,
5926                    _ => return None,
5927                };
5928                kills.push(ai + o);
5929                o += 1;
5930                // FusedResidualLN for LN2
5931                let (ln2_g, ln2_b, eps2, out) = match a(ai + o)? {
5932                    Thunk::FusedResidualLN { g, b, eps, out, .. } => (*g, *b, *eps, *out),
5933                    _ => return None,
5934                };
5935                kills.push(ai + o);
5936                let consumed = o + 1;
5937
5938                for ki in kills {
5939                    kill[active[ki]] = true;
5940                }
5941                FUSED_NOMIC_LAYER_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5942                // Insert at the LAST consumed position (LN2), NOT the first
5943                // (FusedAttnBlock): the fused kernel reads the runtime weight
5944                // `Concat` (fc11‖fc12, and qkv for split-qkv models) that sits
5945                // between them, so it must execute AFTER those concats run.
5946                insertions.push((
5947                    active[ai + o],
5948                    Thunk::FusedNomicLayer {
5949                        hidden,
5950                        qkv_w,
5951                        out_w,
5952                        mask,
5953                        cos,
5954                        sin,
5955                        cos_len,
5956                        ln1_g,
5957                        ln1_b,
5958                        eps1,
5959                        fc11_w: fused_fc_w,
5960                        fc12_w: 0,
5961                        fc2_w,
5962                        ln2_g,
5963                        ln2_b,
5964                        eps2,
5965                        out,
5966                        batch,
5967                        seq,
5968                        hs,
5969                        nh,
5970                        dh,
5971                        int_dim,
5972                        interleaved,
5973                    },
5974                ));
5975                Some(consumed)
5976            })();
5977            if let Some(n) = nomic_match {
5978                ai += n;
5979                continue;
5980            }
5981
5982            ai += 1;
5983        }
5984
5985        if !insertions.is_empty() {
5986            let mut new_thunks = Vec::with_capacity(thunks.len());
5987            let mut ins_idx = 0;
5988            for (i, t) in thunks.into_iter().enumerate() {
5989                if ins_idx < insertions.len() && insertions[ins_idx].0 == i {
5990                    new_thunks.push(insertions[ins_idx].1.clone());
5991                    ins_idx += 1;
5992                }
5993                if !kill[i] {
5994                    new_thunks.push(t);
5995                }
5996            }
5997            if cfg.verbose >= 1 {
5998                eprintln!(
5999                    "[rlx] fused_layer: {} full transformer layers fused",
6000                    insertions.len()
6001                );
6002            }
6003            thunks = new_thunks;
6004        }
6005    }
6006
6007    // ── Narrow → Rope thunk fusion (plan #45) ──────────────
6008    // Runs *after* FusedAttnBlock fusion so it only catches the medium-
6009    // batch path (batch*seq > 64) where the bigger fusion didn't fire.
6010    // Pattern: a Rope thunk whose `src` is the dst of an immediately-
6011    // preceding Narrow whose dst has no other consumer in this schedule.
6012    // Rewrite Rope to read directly from the parent buffer with the
6013    // parent's row stride; the Narrow becomes a Nop.
6014    //
6015    // Skipping the Narrow's write saves one full pass over Q/K (B*S*hs
6016    // f32) per Rope. For Nomic h=768 / batch=8 / seq=15 / 12 layers
6017    // that's 2 ropes/layer × 369 KB = ~8.9 MB of write traffic gone.
6018    {
6019        // Collect every byte-offset that's read as a thunk's `src` so
6020        // we know whether a Narrow's dst has consumers other than Rope.
6021        let mut read_offsets: HashMap<usize, usize> = HashMap::new();
6022        for t in &thunks {
6023            for off in thunk_read_offsets(t) {
6024                *read_offsets.entry(off).or_insert(0) += 1;
6025            }
6026        }
6027
6028        let mut fused_count = 0usize;
6029        for i in 0..thunks.len().saturating_sub(1) {
6030            // Look for Rope at i+1 reading from Narrow at i (skip Nops
6031            // between them since the planner left them in place).
6032            let narrow = match &thunks[i] {
6033                Thunk::Narrow { .. } => i,
6034                _ => continue,
6035            };
6036            // Find the next non-Nop thunk
6037            let mut j = narrow + 1;
6038            while j < thunks.len() && matches!(thunks[j], Thunk::Nop) {
6039                j += 1;
6040            }
6041            if j >= thunks.len() {
6042                continue;
6043            }
6044            // Must be Rope reading Narrow's dst
6045            let (n_src, n_dst, n_src_stride) = match &thunks[narrow] {
6046                Thunk::Narrow {
6047                    src,
6048                    dst,
6049                    src_stride,
6050                    ..
6051                } => (*src, *dst, *src_stride),
6052                _ => continue,
6053            };
6054            let rope_reads_narrow = matches!(&thunks[j],
6055                Thunk::Rope { src, .. } if *src == n_dst);
6056            if !rope_reads_narrow {
6057                continue;
6058            }
6059            // Conservatively require that the Narrow's dst has exactly
6060            // one reader (the Rope). Anything else and rewriting would
6061            // skip a needed write.
6062            if read_offsets.get(&n_dst).copied().unwrap_or(0) != 1 {
6063                continue;
6064            }
6065
6066            // Rewire: Rope reads from Narrow's adjusted source with the
6067            // parent buffer's row stride.
6068            if let Thunk::Rope {
6069                src,
6070                src_row_stride,
6071                ..
6072            } = &mut thunks[j]
6073            {
6074                *src = n_src;
6075                *src_row_stride = n_src_stride;
6076            }
6077            thunks[narrow] = Thunk::Nop;
6078            fused_count += 1;
6079        }
6080
6081        if fused_count > 0 && cfg.verbose >= 1 {
6082            eprintln!(
6083                "[rlx] fused_qk_rope: {} Narrow→Rope pairs collapsed",
6084                fused_count
6085            );
6086        }
6087    }
6088
6089    // ── Narrow×3 → Attention thunk fusion (plan #46 deep) ────
6090    // For each Attention thunk in the schedule, look up the producers
6091    // of its q/k/v inputs. If each is a Narrow whose dst has exactly
6092    // one consumer (the Attention), rewire Attention to read directly
6093    // from the parent buffer with the parent's row stride. The three
6094    // Narrows become Nops.
6095    //
6096    // This catches the BERT/Nomic QKV split path that FusedAttnBlock
6097    // misses (batch*seq > 64) — eliminates Q/K/V copies entirely.
6098    // For minilm6 batch=32 seq=16 hs=384: 3 × 32*16*384*4 = 2.3 MB
6099    // per layer × 6 layers = ~14 MB of write traffic gone.
6100    {
6101        let mut read_counts: HashMap<usize, usize> = HashMap::new();
6102        for t in &thunks {
6103            for off in thunk_read_offsets(t) {
6104                *read_counts.entry(off).or_insert(0) += 1;
6105            }
6106        }
6107        // Build dst→index map for fast producer lookup.
6108        let mut dst_to_idx: HashMap<usize, usize> = HashMap::new();
6109        for (i, t) in thunks.iter().enumerate() {
6110            if let Thunk::Narrow { dst, .. } = t {
6111                dst_to_idx.insert(*dst, i);
6112            }
6113        }
6114
6115        let mut fused_count = 0usize;
6116        for i in 0..thunks.len() {
6117            let (q_off, k_off, v_off) = match &thunks[i] {
6118                Thunk::Attention { q, k, v, .. } => (*q, *k, *v),
6119                _ => continue,
6120            };
6121            // All three inputs must come from Narrows.
6122            let q_n = match dst_to_idx.get(&q_off).copied() {
6123                Some(x) => x,
6124                None => continue,
6125            };
6126            let k_n = match dst_to_idx.get(&k_off).copied() {
6127                Some(x) => x,
6128                None => continue,
6129            };
6130            let v_n = match dst_to_idx.get(&v_off).copied() {
6131                Some(x) => x,
6132                None => continue,
6133            };
6134            // Each Narrow's dst must have exactly one reader (this Attn).
6135            if read_counts.get(&q_off).copied().unwrap_or(0) != 1 {
6136                continue;
6137            }
6138            if read_counts.get(&k_off).copied().unwrap_or(0) != 1 {
6139                continue;
6140            }
6141            if read_counts.get(&v_off).copied().unwrap_or(0) != 1 {
6142                continue;
6143            }
6144
6145            let (q_src, q_stride) = match &thunks[q_n] {
6146                Thunk::Narrow {
6147                    src, src_stride, ..
6148                } => (*src, *src_stride),
6149                _ => continue,
6150            };
6151            let (k_src, k_stride) = match &thunks[k_n] {
6152                Thunk::Narrow {
6153                    src, src_stride, ..
6154                } => (*src, *src_stride),
6155                _ => continue,
6156            };
6157            let (v_src, v_stride) = match &thunks[v_n] {
6158                Thunk::Narrow {
6159                    src, src_stride, ..
6160                } => (*src, *src_stride),
6161                _ => continue,
6162            };
6163
6164            if let Thunk::Attention {
6165                q,
6166                k,
6167                v,
6168                q_row_stride,
6169                k_row_stride,
6170                v_row_stride,
6171                ..
6172            } = &mut thunks[i]
6173            {
6174                *q = q_src;
6175                *k = k_src;
6176                *v = v_src;
6177                *q_row_stride = q_stride;
6178                *k_row_stride = k_stride;
6179                *v_row_stride = v_stride;
6180            }
6181            thunks[q_n] = Thunk::Nop;
6182            thunks[k_n] = Thunk::Nop;
6183            thunks[v_n] = Thunk::Nop;
6184            fused_count += 1;
6185        }
6186
6187        if fused_count > 0 && cfg.verbose >= 1 {
6188            eprintln!(
6189                "[rlx] fused_strided_attn: {} Narrow×3→Attention rewrites",
6190                fused_count
6191            );
6192        }
6193    }
6194
6195    ThunkSchedule {
6196        thunks,
6197        moe_resident: None,
6198        moe_resident_layers: None,
6199        moe_topk_capture: None,
6200        mask_threshold: cfg.mask_binary_threshold,
6201        mask_neg_inf: cfg.attn_mask_neg_inf,
6202        score_skip: cfg.score_skip_threshold,
6203        compiled_fns,
6204        rng: rng_shared,
6205    }
6206}
6207
6208#[allow(unused_variables)]
6209fn compile_fused_mat_mul_bias_act(
6210    node: &rlx_ir::Node,
6211    graph: &Graph,
6212    arena: &crate::arena::Arena,
6213    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6214    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6215    rng: rlx_ir::RngOptions,
6216) -> Thunk {
6217    let Op::FusedMatMulBiasAct { activation } = &node.op else {
6218        unreachable!()
6219    };
6220    {
6221        let shape = &node.shape;
6222        let n = shape.dim(shape.rank() - 1).unwrap_static();
6223        let total = shape.num_elements().unwrap();
6224        let m = total / n;
6225        let a_len = get_len(graph, node.inputs[0]);
6226        let k = a_len / m;
6227        Thunk::FusedMmBiasAct {
6228            a: node_offset(arena, node.inputs[0]),
6229            w: node_offset(arena, node.inputs[1]),
6230            bias: node_offset(arena, node.inputs[2]),
6231            c: node_offset(arena, node.id),
6232            m: m as u32,
6233            k: k as u32,
6234            n: n as u32,
6235            act: *activation,
6236        }
6237    }
6238}
6239
6240#[allow(unused_variables)]
6241fn compile_fused_residual_l_n(
6242    node: &rlx_ir::Node,
6243    graph: &Graph,
6244    arena: &crate::arena::Arena,
6245    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6246    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6247    rng: rlx_ir::RngOptions,
6248) -> Thunk {
6249    let Op::FusedResidualLN { has_bias, eps } = &node.op else {
6250        unreachable!()
6251    };
6252    {
6253        let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
6254        let total = node.shape.num_elements().unwrap();
6255        let rows = total / h;
6256        let (g_idx, b_idx) = if *has_bias { (3, 4) } else { (2, 3) };
6257        Thunk::FusedResidualLN {
6258            x: node_offset(arena, node.inputs[0]),
6259            res: node_offset(arena, node.inputs[1]),
6260            bias: if *has_bias {
6261                node_offset(arena, node.inputs[2])
6262            } else {
6263                0
6264            },
6265            g: node_offset(arena, node.inputs[g_idx]),
6266            b: node_offset(arena, node.inputs[b_idx]),
6267            out: node_offset(arena, node.id),
6268            rows: rows as u32,
6269            h: h as u32,
6270            eps: *eps,
6271            has_bias: *has_bias,
6272        }
6273    }
6274}
6275
6276#[allow(unused_variables)]
6277fn compile_fused_residual_rms_norm(
6278    node: &rlx_ir::Node,
6279    graph: &Graph,
6280    arena: &crate::arena::Arena,
6281    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6282    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6283    rng: rlx_ir::RngOptions,
6284) -> Thunk {
6285    let Op::FusedResidualRmsNorm { has_bias, eps } = &node.op else {
6286        unreachable!()
6287    };
6288    {
6289        let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
6290        let total = node.shape.num_elements().unwrap();
6291        let rows = total / h;
6292        let (g_idx, b_idx) = if *has_bias { (3, 4) } else { (2, 3) };
6293        Thunk::FusedResidualRmsNorm {
6294            x: node_offset(arena, node.inputs[0]),
6295            res: node_offset(arena, node.inputs[1]),
6296            bias: if *has_bias {
6297                node_offset(arena, node.inputs[2])
6298            } else {
6299                0
6300            },
6301            g: node_offset(arena, node.inputs[g_idx]),
6302            b: node_offset(arena, node.inputs[b_idx]),
6303            out: node_offset(arena, node.id),
6304            rows: rows as u32,
6305            h: h as u32,
6306            eps: *eps,
6307            has_bias: *has_bias,
6308        }
6309    }
6310}
6311
6312#[allow(unused_variables)]
6313fn compile_mat_mul(
6314    node: &rlx_ir::Node,
6315    graph: &Graph,
6316    arena: &crate::arena::Arena,
6317    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6318    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6319    rng: rlx_ir::RngOptions,
6320) -> Thunk {
6321    let Op::MatMul = &node.op else { unreachable!() };
6322    {
6323        let shape = &node.shape;
6324        let a_shape = &graph.node(node.inputs[0]).shape;
6325        let b_shape = &graph.node(node.inputs[1]).shape;
6326        // Prefer inferred matmul shape from operands — ONNX bundle
6327        // meta often over-ranks outputs (e.g. [seq, seq, H]).
6328        let eff = rlx_ir::shape::matmul_shape(a_shape, b_shape).unwrap_or_else(|_| shape.clone());
6329        let rank = eff.rank().max(2);
6330        let n = eff.dim(rank - 1).unwrap_static();
6331        let k_dim = a_shape.dim(a_shape.rank().max(2) - 1).unwrap_static();
6332        if shape.dtype() == rlx_ir::DType::C64 {
6333            // Complex GEMM (interleaved re/im). Handles 2D and
6334            // 3D×2D (flatten M); both-operand batched C64 is not
6335            // yet wired.
6336            let both = a_shape.rank() >= 3 && b_shape.rank() >= 3;
6337            assert!(!both, "batched (both-operand) C64 matmul not yet supported");
6338            let m: usize = if a_shape.rank() >= 3 {
6339                (0..a_shape.rank() - 1)
6340                    .map(|d| a_shape.dim(d).unwrap_static())
6341                    .product()
6342            } else {
6343                a_shape.dim(a_shape.rank() - 2).unwrap_static()
6344            };
6345            Thunk::CgemmC64 {
6346                a: node_offset(arena, node.inputs[0]),
6347                b: node_offset(arena, node.inputs[1]),
6348                c: node_offset(arena, node.id),
6349                m: m as u32,
6350                k: k_dim as u32,
6351                n: n as u32,
6352            }
6353        } else {
6354            // Batched GEMM only when both operands carry batch dimensions.
6355            // 3D×2D (activations × shared weight) must flatten to one Sgemm.
6356            let both_batched = a_shape.rank() >= 3 && b_shape.rank() >= 3;
6357            let batched_3d = rank >= 3 && both_batched && a_shape.rank() + b_shape.rank() > 4;
6358            if batched_3d && shape.dtype() == rlx_ir::DType::F64 {
6359                let mut batch_prod = 1usize;
6360                for d in 0..rank - 2 {
6361                    batch_prod *= eff.dim(d).unwrap_static();
6362                }
6363                let m_dim = eff.dim(rank - 2).unwrap_static();
6364                Thunk::BatchedDgemmF64 {
6365                    a: node_offset(arena, node.inputs[0]),
6366                    b: node_offset(arena, node.inputs[1]),
6367                    c: node_offset(arena, node.id),
6368                    batch: batch_prod as u32,
6369                    m: m_dim as u32,
6370                    k: k_dim as u32,
6371                    n: n as u32,
6372                }
6373            } else if batched_3d && shape.dtype() == rlx_ir::DType::F32 {
6374                let mut batch_prod = 1usize;
6375                for d in 0..rank - 2 {
6376                    batch_prod *= eff.dim(d).unwrap_static();
6377                }
6378                let m_dim = eff.dim(rank - 2).unwrap_static();
6379                Thunk::BatchedSgemm {
6380                    a: node_offset(arena, node.inputs[0]),
6381                    b: node_offset(arena, node.inputs[1]),
6382                    c: node_offset(arena, node.id),
6383                    batch: batch_prod as u32,
6384                    m: m_dim as u32,
6385                    k: k_dim as u32,
6386                    n: n as u32,
6387                }
6388            } else {
6389                let m = if a_shape.rank() >= 3 && b_shape.rank() <= 2 {
6390                    let mut m_prod = 1usize;
6391                    for d in 0..a_shape.rank() - 1 {
6392                        m_prod *= a_shape.dim(d).unwrap_static();
6393                    }
6394                    m_prod
6395                } else if a_shape.rank() >= 2 {
6396                    a_shape.dim(a_shape.rank() - 2).unwrap_static()
6397                } else {
6398                    eff.num_elements().unwrap_or(1) / n.max(1)
6399                };
6400                match shape.dtype() {
6401                    rlx_ir::DType::F64 => Thunk::Dgemm {
6402                        a: node_offset(arena, node.inputs[0]),
6403                        b: node_offset(arena, node.inputs[1]),
6404                        c: node_offset(arena, node.id),
6405                        m: m as u32,
6406                        k: k_dim as u32,
6407                        n: n as u32,
6408                    },
6409                    _ => {
6410                        if let Some(&(asrc, ta, bsrc, tb)) = matmul_fold.get(&node.id) {
6411                            // Folded Transpose→MatMul: read pre-transpose
6412                            // operands, do the transpose via cblas flags.
6413                            Thunk::SgemmT {
6414                                a: node_offset(arena, asrc),
6415                                b: node_offset(arena, bsrc),
6416                                c: node_offset(arena, node.id),
6417                                m: m as u32,
6418                                k: k_dim as u32,
6419                                n: n as u32,
6420                                ta,
6421                                tb,
6422                            }
6423                        } else {
6424                            Thunk::Sgemm {
6425                                a: node_offset(arena, node.inputs[0]),
6426                                b: node_offset(arena, node.inputs[1]),
6427                                c: node_offset(arena, node.id),
6428                                m: m as u32,
6429                                k: k_dim as u32,
6430                                n: n as u32,
6431                            }
6432                        }
6433                    }
6434                }
6435            }
6436        }
6437    }
6438}
6439
6440#[allow(unused_variables)]
6441fn compile_gather(
6442    node: &rlx_ir::Node,
6443    graph: &Graph,
6444    arena: &crate::arena::Arena,
6445    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6446    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6447    rng: rlx_ir::RngOptions,
6448) -> Thunk {
6449    let Op::Gather { axis } = &node.op else {
6450        unreachable!()
6451    };
6452    {
6453        // Non-zero axis: outer × num_idx × trailing layout.
6454        let table_shape = &graph.node(node.inputs[0]).shape;
6455        let rank = table_shape.rank();
6456        let outer: usize = (0..*axis)
6457            .map(|i| table_shape.dim(i).unwrap_static())
6458            .product::<usize>()
6459            .max(1);
6460        let trailing: usize = (*axis + 1..rank)
6461            .map(|i| table_shape.dim(i).unwrap_static())
6462            .product::<usize>()
6463            .max(1);
6464        let axis_dim = table_shape.dim(*axis).unwrap_static();
6465        let idx_len = get_len(graph, node.inputs[1]);
6466        let idx_i64 = u8::from(graph.node(node.inputs[1]).shape.dtype() == rlx_ir::DType::I64);
6467        let table_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
6468        Thunk::GatherAxis {
6469            table: node_offset(arena, node.inputs[0]),
6470            idx: node_offset(arena, node.inputs[1]),
6471            dst: node_offset(arena, node.id),
6472            outer: outer as u32,
6473            axis_dim: axis_dim as u32,
6474            num_idx: idx_len as u32,
6475            trailing: trailing as u32,
6476            idx_i64,
6477            table_bytes,
6478        }
6479    }
6480}
6481
6482#[allow(unused_variables)]
6483fn compile_narrow(
6484    node: &rlx_ir::Node,
6485    graph: &Graph,
6486    arena: &crate::arena::Arena,
6487    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6488    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6489    rng: rlx_ir::RngOptions,
6490) -> Thunk {
6491    let Op::Narrow { axis, start, len } = &node.op else {
6492        unreachable!()
6493    };
6494    {
6495        let in_shape = &graph.node(node.inputs[0]).shape;
6496        let elem_bytes = in_shape.dtype().size_bytes() as u8;
6497        let rank = in_shape.rank();
6498        let outer: usize = (0..*axis)
6499            .map(|i| in_shape.dim(i).unwrap_static())
6500            .product::<usize>()
6501            .max(1);
6502        let inner: usize = (*axis + 1..rank)
6503            .map(|i| in_shape.dim(i).unwrap_static())
6504            .product::<usize>()
6505            .max(1);
6506        let in_axis = in_shape.dim(*axis).unwrap_static();
6507        let src_byte_offset =
6508            node_offset(arena, node.inputs[0]) + start * inner * elem_bytes as usize;
6509        Thunk::Narrow {
6510            src: src_byte_offset,
6511            dst: node_offset(arena, node.id),
6512            outer: outer as u32,
6513            src_stride: (in_axis * inner) as u32, // elements per outer step in source
6514            dst_stride: (*len * inner) as u32,    // elements per outer step in dest
6515            inner: (*len * inner) as u32,         // elements to copy per outer step
6516            elem_bytes,
6517        }
6518    }
6519}
6520
6521#[allow(unused_variables)]
6522fn compile_reverse(
6523    node: &rlx_ir::Node,
6524    graph: &Graph,
6525    arena: &crate::arena::Arena,
6526    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6527    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6528    rng: rlx_ir::RngOptions,
6529) -> Thunk {
6530    let Op::Reverse { axes } = &node.op else {
6531        unreachable!()
6532    };
6533    {
6534        let in_shape = &graph.node(node.inputs[0]).shape;
6535        let rank = in_shape.rank();
6536        let dims: Vec<u32> = (0..rank)
6537            .map(|i| in_shape.dim(i).unwrap_static() as u32)
6538            .collect();
6539        let mut rev_mask = vec![false; rank];
6540        for &a in axes {
6541            if a < rank {
6542                rev_mask[a] = true;
6543            }
6544        }
6545        Thunk::Reverse {
6546            src: node_offset(arena, node.inputs[0]),
6547            dst: node_offset(arena, node.id),
6548            dims,
6549            rev_mask,
6550            elem_bytes: in_shape.dtype().size_bytes() as u8,
6551        }
6552    }
6553}
6554
6555#[allow(unused_variables)]
6556fn compile_cast(
6557    node: &rlx_ir::Node,
6558    graph: &Graph,
6559    arena: &crate::arena::Arena,
6560    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6561    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6562    rng: rlx_ir::RngOptions,
6563) -> Thunk {
6564    let Op::Cast { to } = &node.op else {
6565        unreachable!()
6566    };
6567    {
6568        let in_node = graph.node(node.inputs[0]);
6569        let in_dtype = in_node.shape.dtype();
6570        let out_dtype = *to;
6571        let len = node.shape.num_elements().unwrap();
6572        let src = node_offset(arena, node.inputs[0]);
6573        let dst = node_offset(arena, node.id);
6574        if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::I64 {
6575            Thunk::CastF32ToI64 {
6576                src,
6577                dst,
6578                len: len as u32,
6579            }
6580        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::F64 {
6581            Thunk::CastF32ToF64 {
6582                src,
6583                dst,
6584                len: len as u32,
6585            }
6586        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::I32 {
6587            Thunk::CastF32ToI32 {
6588                src,
6589                dst,
6590                len: len as u32,
6591            }
6592        } else if in_dtype == rlx_ir::DType::I64 && out_dtype == rlx_ir::DType::F32 {
6593            Thunk::CastI64ToF32 {
6594                src,
6595                dst,
6596                len: len as u32,
6597            }
6598        } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::I32 {
6599            Thunk::CastBoolToI32 {
6600                src,
6601                dst,
6602                len: len as u32,
6603            }
6604        } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::F32 {
6605            // Bool is 1 byte; the generic f32 Copy below would misread it as
6606            // 4-byte f32. VITS sequence masks are `Cast(Less(...), f32)`.
6607            Thunk::CastBoolToF32 {
6608                src,
6609                dst,
6610                len: len as u32,
6611            }
6612        } else if in_dtype == rlx_ir::DType::I32 && out_dtype == rlx_ir::DType::F32 {
6613            Thunk::CastI32ToF32 {
6614                src,
6615                dst,
6616                len: len as u32,
6617            }
6618        } else if in_dtype == out_dtype {
6619            match out_dtype {
6620                rlx_ir::DType::F64 => Thunk::CopyF64 {
6621                    src,
6622                    dst,
6623                    len: len as u32,
6624                },
6625                rlx_ir::DType::I64 => Thunk::CopyI64 {
6626                    src,
6627                    dst,
6628                    len: len as u32,
6629                },
6630                _ => Thunk::Copy {
6631                    src,
6632                    dst,
6633                    len: len as u32,
6634                },
6635            }
6636        } else {
6637            Thunk::Copy {
6638                src,
6639                dst,
6640                len: len as u32,
6641            }
6642        }
6643    }
6644}
6645
6646#[allow(unused_variables)]
6647fn compile_quantize(
6648    node: &rlx_ir::Node,
6649    graph: &Graph,
6650    arena: &crate::arena::Arena,
6651    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6652    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6653    rng: rlx_ir::RngOptions,
6654) -> Thunk {
6655    let Op::Quantize {
6656        axis,
6657        scales,
6658        zero_points,
6659    } = &node.op
6660    else {
6661        unreachable!()
6662    };
6663    {
6664        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
6665        Thunk::Quantize {
6666            x: node_offset(arena, node.inputs[0]),
6667            q: node_offset(arena, node.id),
6668            len: node.shape.num_elements().unwrap() as u32,
6669            chan_axis: chan_axis as u32,
6670            chan_dim: chan_dim as u32,
6671            inner: inner as u32,
6672            scales: scales.clone(),
6673            zero_points: zero_points.clone(),
6674        }
6675    }
6676}
6677
6678#[allow(unused_variables)]
6679fn compile_fake_quantize(
6680    node: &rlx_ir::Node,
6681    graph: &Graph,
6682    arena: &crate::arena::Arena,
6683    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6684    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6685    rng: rlx_ir::RngOptions,
6686) -> Thunk {
6687    let Op::FakeQuantize {
6688        bits,
6689        axis,
6690        ste,
6691        scale_mode,
6692    } = &node.op
6693    else {
6694        unreachable!()
6695    };
6696    {
6697        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
6698        let state_off = match scale_mode {
6699            rlx_ir::op::ScaleMode::PerBatch => None,
6700            rlx_ir::op::ScaleMode::EMA { .. } | rlx_ir::op::ScaleMode::Fixed => {
6701                // Second input carries the [chan_dim] scale state.
6702                debug_assert_eq!(
6703                    node.inputs.len(),
6704                    2,
6705                    "EMA/Fixed FakeQuantize needs a state input"
6706                );
6707                Some(node_offset(arena, node.inputs[1]))
6708            }
6709        };
6710        Thunk::FakeQuantize {
6711            x: node_offset(arena, node.inputs[0]),
6712            out: node_offset(arena, node.id),
6713            len: node.shape.num_elements().unwrap() as u32,
6714            chan_axis: chan_axis as u32,
6715            chan_dim: chan_dim as u32,
6716            inner: inner as u32,
6717            bits: *bits,
6718            ste: *ste,
6719            scale_mode: *scale_mode,
6720            state_off,
6721        }
6722    }
6723}
6724
6725#[allow(unused_variables)]
6726fn compile_fake_quantize_l_s_q(
6727    node: &rlx_ir::Node,
6728    graph: &Graph,
6729    arena: &crate::arena::Arena,
6730    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6731    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6732    rng: rlx_ir::RngOptions,
6733) -> Thunk {
6734    let Op::FakeQuantizeLSQ { bits, axis } = &node.op else {
6735        unreachable!()
6736    };
6737    {
6738        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
6739        Thunk::FakeQuantizeLSQ {
6740            x: node_offset(arena, node.inputs[0]),
6741            scale_off: node_offset(arena, node.inputs[1]),
6742            out: node_offset(arena, node.id),
6743            len: node.shape.num_elements().unwrap() as u32,
6744            chan_axis: chan_axis as u32,
6745            chan_dim: chan_dim as u32,
6746            inner: inner as u32,
6747            bits: *bits,
6748        }
6749    }
6750}
6751
6752#[allow(unused_variables)]
6753fn compile_fake_quantize_l_s_q_backward_x(
6754    node: &rlx_ir::Node,
6755    graph: &Graph,
6756    arena: &crate::arena::Arena,
6757    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6758    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6759    rng: rlx_ir::RngOptions,
6760) -> Thunk {
6761    let Op::FakeQuantizeLSQBackwardX { bits, axis } = &node.op else {
6762        unreachable!()
6763    };
6764    {
6765        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
6766        Thunk::FakeQuantizeLSQBackwardX {
6767            x: node_offset(arena, node.inputs[0]),
6768            scale_off: node_offset(arena, node.inputs[1]),
6769            dy: node_offset(arena, node.inputs[2]),
6770            dx: node_offset(arena, node.id),
6771            len: node.shape.num_elements().unwrap() as u32,
6772            chan_axis: chan_axis as u32,
6773            chan_dim: chan_dim as u32,
6774            inner: inner as u32,
6775            bits: *bits,
6776        }
6777    }
6778}
6779
6780#[allow(unused_variables)]
6781fn compile_fake_quantize_l_s_q_backward_scale(
6782    node: &rlx_ir::Node,
6783    graph: &Graph,
6784    arena: &crate::arena::Arena,
6785    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6786    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6787    rng: rlx_ir::RngOptions,
6788) -> Thunk {
6789    let Op::FakeQuantizeLSQBackwardScale { bits, axis } = &node.op else {
6790        unreachable!()
6791    };
6792    {
6793        // Output shape is [chan_dim] — node.shape doesn't
6794        // describe the input data layout, but inputs[0] does.
6795        let in_shape = &graph.node(node.inputs[0]).shape;
6796        let (chan_axis, chan_dim, inner) = quant_layout(in_shape, *axis);
6797        Thunk::FakeQuantizeLSQBackwardScale {
6798            x: node_offset(arena, node.inputs[0]),
6799            scale_off: node_offset(arena, node.inputs[1]),
6800            dy: node_offset(arena, node.inputs[2]),
6801            dscale: node_offset(arena, node.id),
6802            len: in_shape.num_elements().unwrap() as u32,
6803            chan_axis: chan_axis as u32,
6804            chan_dim: chan_dim as u32,
6805            inner: inner as u32,
6806            bits: *bits,
6807        }
6808    }
6809}
6810
6811#[allow(unused_variables)]
6812fn compile_fake_quantize_backward(
6813    node: &rlx_ir::Node,
6814    graph: &Graph,
6815    arena: &crate::arena::Arena,
6816    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6817    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6818    rng: rlx_ir::RngOptions,
6819) -> Thunk {
6820    let Op::FakeQuantizeBackward { bits, axis, ste } = &node.op else {
6821        unreachable!()
6822    };
6823    {
6824        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
6825        Thunk::FakeQuantizeBackward {
6826            x: node_offset(arena, node.inputs[0]),
6827            dy: node_offset(arena, node.inputs[1]),
6828            dx: node_offset(arena, node.id),
6829            len: node.shape.num_elements().unwrap() as u32,
6830            chan_axis: chan_axis as u32,
6831            chan_dim: chan_dim as u32,
6832            inner: inner as u32,
6833            bits: *bits,
6834            ste: *ste,
6835        }
6836    }
6837}
6838
6839#[allow(unused_variables)]
6840fn compile_dequantize(
6841    node: &rlx_ir::Node,
6842    graph: &Graph,
6843    arena: &crate::arena::Arena,
6844    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6845    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6846    rng: rlx_ir::RngOptions,
6847) -> Thunk {
6848    let Op::Dequantize {
6849        axis,
6850        scales,
6851        zero_points,
6852    } = &node.op
6853    else {
6854        unreachable!()
6855    };
6856    {
6857        let (chan_axis, chan_dim, inner) = quant_layout(&node.shape, *axis);
6858        Thunk::Dequantize {
6859            q: node_offset(arena, node.inputs[0]),
6860            x: node_offset(arena, node.id),
6861            len: node.shape.num_elements().unwrap() as u32,
6862            chan_axis: chan_axis as u32,
6863            chan_dim: chan_dim as u32,
6864            inner: inner as u32,
6865            scales: scales.clone(),
6866            zero_points: zero_points.clone(),
6867        }
6868    }
6869}
6870
6871#[allow(unused_variables)]
6872fn compile_expand(
6873    node: &rlx_ir::Node,
6874    graph: &Graph,
6875    arena: &crate::arena::Arena,
6876    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6877    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6878    rng: rlx_ir::RngOptions,
6879) -> Thunk {
6880    let Op::Expand { .. } = &node.op else {
6881        unreachable!()
6882    };
6883    {
6884        // Broadcast: build per-output-dim strides where any input dim
6885        // of size 1 has stride 0 (read the same element repeatedly).
6886        // Reuses the Thunk::Transpose runtime — N-D walk with strides
6887        // is identical; only the strides differ.
6888        let in_shape = &graph.node(node.inputs[0]).shape;
6889        let out_shape = &node.shape;
6890        let in_rank = in_shape.rank();
6891        let out_rank = out_shape.rank();
6892        // Implicit leading 1s if input has lower rank.
6893        let pad = out_rank.saturating_sub(in_rank);
6894        let in_dims: Vec<usize> = (0..out_rank)
6895            .map(|i| {
6896                if i < pad {
6897                    1
6898                } else {
6899                    in_shape.dim(i - pad).unwrap_static()
6900                }
6901            })
6902            .collect();
6903        // Row-major input strides (over the padded shape).
6904        let mut in_strides_full = vec![1usize; out_rank];
6905        for d in (0..out_rank.saturating_sub(1)).rev() {
6906            in_strides_full[d] = in_strides_full[d + 1] * in_dims[d + 1];
6907        }
6908        let out_dims: Vec<u32> = (0..out_rank)
6909            .map(|i| out_shape.dim(i).unwrap_static() as u32)
6910            .collect();
6911        // Stride is 0 for broadcast dims (in_dim == 1 && out_dim > 1).
6912        let in_strides: Vec<u32> = (0..out_rank)
6913            .map(|i| {
6914                if in_dims[i] == 1 && (out_dims[i] as usize) > 1 {
6915                    0
6916                } else {
6917                    in_strides_full[i] as u32
6918                }
6919            })
6920            .collect();
6921        let in_total = in_dims.iter().product::<usize>() as u32;
6922        let src = node_offset(arena, node.inputs[0]);
6923        let dst = node_offset(arena, node.id);
6924        let elem_bytes = node.shape.dtype().size_bytes() as u8;
6925        match node.shape.dtype() {
6926            rlx_ir::DType::F64 => Thunk::TransposeF64 {
6927                src,
6928                dst,
6929                in_total,
6930                out_dims,
6931                in_strides,
6932            },
6933            _ => Thunk::Transpose {
6934                src,
6935                dst,
6936                in_total,
6937                out_dims,
6938                in_strides,
6939                elem_bytes,
6940            },
6941        }
6942    }
6943}
6944
6945#[allow(unused_variables)]
6946fn compile_rms_norm(
6947    node: &rlx_ir::Node,
6948    graph: &Graph,
6949    arena: &crate::arena::Arena,
6950    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6951    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6952    rng: rlx_ir::RngOptions,
6953) -> Thunk {
6954    let Op::RmsNorm { eps, .. } = &node.op else {
6955        unreachable!()
6956    };
6957    {
6958        let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
6959        let total = node.shape.num_elements().unwrap();
6960        Thunk::RmsNorm {
6961            src: node_offset(arena, node.inputs[0]),
6962            g: node_offset(arena, node.inputs[1]),
6963            b: node_offset(arena, node.inputs[2]),
6964            dst: node_offset(arena, node.id),
6965            rows: (total / h) as u32,
6966            h: h as u32,
6967            eps: *eps,
6968        }
6969    }
6970}
6971
6972#[allow(unused_variables)]
6973fn compile_layer_norm(
6974    node: &rlx_ir::Node,
6975    graph: &Graph,
6976    arena: &crate::arena::Arena,
6977    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
6978    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
6979    rng: rlx_ir::RngOptions,
6980) -> Thunk {
6981    let Op::LayerNorm { eps, .. } = &node.op else {
6982        unreachable!()
6983    };
6984    {
6985        let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
6986        let total = node.shape.num_elements().unwrap();
6987        Thunk::LayerNorm {
6988            src: node_offset(arena, node.inputs[0]),
6989            g: node_offset(arena, node.inputs[1]),
6990            b: node_offset(arena, node.inputs[2]),
6991            dst: node_offset(arena, node.id),
6992            rows: (total / h) as u32,
6993            h: h as u32,
6994            eps: *eps,
6995        }
6996    }
6997}
6998
6999#[allow(unused_variables)]
7000fn compile_group_norm(
7001    node: &rlx_ir::Node,
7002    graph: &Graph,
7003    arena: &crate::arena::Arena,
7004    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7005    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7006    rng: rlx_ir::RngOptions,
7007) -> Thunk {
7008    let Op::GroupNorm { num_groups, eps } = &node.op else {
7009        unreachable!()
7010    };
7011    {
7012        let in_shape = &graph.node(node.inputs[0]).shape;
7013        let (n, c, h, w) = conv_nchw_dims(in_shape);
7014        Thunk::GroupNorm {
7015            src: node_offset(arena, node.inputs[0]),
7016            g: node_offset(arena, node.inputs[1]),
7017            b: node_offset(arena, node.inputs[2]),
7018            dst: node_offset(arena, node.id),
7019            n,
7020            c,
7021            h,
7022            w,
7023            num_groups: *num_groups as u32,
7024            eps: *eps,
7025        }
7026    }
7027}
7028
7029#[allow(unused_variables)]
7030fn compile_batch_norm_inference(
7031    node: &rlx_ir::Node,
7032    graph: &Graph,
7033    arena: &crate::arena::Arena,
7034    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7035    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7036    rng: rlx_ir::RngOptions,
7037) -> Thunk {
7038    let Op::BatchNormInference { eps } = &node.op else {
7039        unreachable!()
7040    };
7041    {
7042        let in_shape = &graph.node(node.inputs[0]).shape;
7043        let rank = in_shape.rank();
7044        let channels = in_shape.dim(rank - 1).unwrap_static();
7045        let total = in_shape.num_elements().unwrap_or(0);
7046        let count = (total / channels.max(1)) as u32;
7047        Thunk::BatchNormInference {
7048            src: node_offset(arena, node.inputs[0]),
7049            g: node_offset(arena, node.inputs[1]),
7050            b: node_offset(arena, node.inputs[2]),
7051            mean: node_offset(arena, node.inputs[3]),
7052            var: node_offset(arena, node.inputs[4]),
7053            dst: node_offset(arena, node.id),
7054            count,
7055            channels: channels as u32,
7056            eps: *eps,
7057        }
7058    }
7059}
7060
7061#[allow(unused_variables)]
7062fn compile_batch_norm_inference_backward_input(
7063    node: &rlx_ir::Node,
7064    graph: &Graph,
7065    arena: &crate::arena::Arena,
7066    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7067    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7068    rng: rlx_ir::RngOptions,
7069) -> Thunk {
7070    let Op::BatchNormInferenceBackwardInput { eps } = &node.op else {
7071        unreachable!()
7072    };
7073    {
7074        let x_shape = &graph.node(node.inputs[0]).shape;
7075        let rank = x_shape.rank();
7076        let channels = x_shape.dim(rank - 1).unwrap_static();
7077        let total = x_shape.num_elements().unwrap_or(0);
7078        Thunk::BatchNormInferenceBackwardInput {
7079            x: node_offset(arena, node.inputs[0]),
7080            gamma: node_offset(arena, node.inputs[1]),
7081            mean: node_offset(arena, node.inputs[2]),
7082            var: node_offset(arena, node.inputs[3]),
7083            dy: node_offset(arena, node.inputs[4]),
7084            dx: node_offset(arena, node.id),
7085            count: (total / channels.max(1)) as u32,
7086            channels: channels as u32,
7087            eps: *eps,
7088        }
7089    }
7090}
7091
7092#[allow(unused_variables)]
7093fn compile_batch_norm_inference_backward_gamma(
7094    node: &rlx_ir::Node,
7095    graph: &Graph,
7096    arena: &crate::arena::Arena,
7097    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7098    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7099    rng: rlx_ir::RngOptions,
7100) -> Thunk {
7101    let Op::BatchNormInferenceBackwardGamma { eps } = &node.op else {
7102        unreachable!()
7103    };
7104    {
7105        let x_shape = &graph.node(node.inputs[0]).shape;
7106        let rank = x_shape.rank();
7107        let channels = x_shape.dim(rank - 1).unwrap_static();
7108        let total = x_shape.num_elements().unwrap_or(0);
7109        let _gamma_shape = &graph.node(node.id).shape;
7110        Thunk::BatchNormInferenceBackwardGamma {
7111            x: node_offset(arena, node.inputs[0]),
7112            mean: node_offset(arena, node.inputs[1]),
7113            var: node_offset(arena, node.inputs[2]),
7114            dy: node_offset(arena, node.inputs[3]),
7115            dgamma: node_offset(arena, node.id),
7116            count: (total / channels.max(1)) as u32,
7117            channels: channels as u32,
7118            eps: *eps,
7119        }
7120    }
7121}
7122
7123#[allow(unused_variables)]
7124fn compile_batch_norm_inference_backward_beta(
7125    node: &rlx_ir::Node,
7126    graph: &Graph,
7127    arena: &crate::arena::Arena,
7128    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7129    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7130    rng: rlx_ir::RngOptions,
7131) -> Thunk {
7132    let Op::BatchNormInferenceBackwardBeta = &node.op else {
7133        unreachable!()
7134    };
7135    {
7136        let dy_shape = &graph.node(node.inputs[0]).shape;
7137        let rank = dy_shape.rank();
7138        let channels = dy_shape.dim(rank - 1).unwrap_static();
7139        let total = dy_shape.num_elements().unwrap_or(0);
7140        Thunk::BatchNormInferenceBackwardBeta {
7141            dy: node_offset(arena, node.inputs[0]),
7142            dbeta: node_offset(arena, node.id),
7143            count: (total / channels.max(1)) as u32,
7144            channels: channels as u32,
7145        }
7146    }
7147}
7148
7149#[allow(unused_variables)]
7150fn compile_layer_norm2d(
7151    node: &rlx_ir::Node,
7152    graph: &Graph,
7153    arena: &crate::arena::Arena,
7154    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7155    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7156    rng: rlx_ir::RngOptions,
7157) -> Thunk {
7158    let Op::LayerNorm2d { eps } = &node.op else {
7159        unreachable!()
7160    };
7161    {
7162        let in_shape = &graph.node(node.inputs[0]).shape;
7163        let (n, c, h, w) = conv_nchw_dims(in_shape);
7164        Thunk::LayerNorm2d {
7165            src: node_offset(arena, node.inputs[0]),
7166            g: node_offset(arena, node.inputs[1]),
7167            b: node_offset(arena, node.inputs[2]),
7168            dst: node_offset(arena, node.id),
7169            n,
7170            c,
7171            h,
7172            w,
7173            eps: *eps,
7174        }
7175    }
7176}
7177
7178#[allow(unused_variables)]
7179fn compile_conv_transpose2d(
7180    node: &rlx_ir::Node,
7181    graph: &Graph,
7182    arena: &crate::arena::Arena,
7183    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7184    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7185    rng: rlx_ir::RngOptions,
7186) -> Thunk {
7187    let Op::ConvTranspose2d {
7188        kernel_size,
7189        stride,
7190        padding,
7191        dilation,
7192        output_padding: _,
7193        groups,
7194    } = &node.op
7195    else {
7196        unreachable!()
7197    };
7198    {
7199        let in_shape = &graph.node(node.inputs[0]).shape;
7200        let out_shape = &node.shape;
7201        let (n, c_in, h, w_in) = conv_nchw_dims(in_shape);
7202        let (_, c_out, h_out, w_out) = conv_nchw_dims(out_shape);
7203        Thunk::ConvTranspose2d {
7204            src: node_offset(arena, node.inputs[0]),
7205            weight: node_offset(arena, node.inputs[1]),
7206            dst: node_offset(arena, node.id),
7207            n,
7208            c_in,
7209            h,
7210            w_in,
7211            c_out,
7212            h_out,
7213            w_out,
7214            kh: kernel_size[0] as u32,
7215            kw: kernel_size[1] as u32,
7216            sh: stride.first().copied().unwrap_or(1) as u32,
7217            sw: stride.get(1).copied().unwrap_or(1) as u32,
7218            ph: padding.first().copied().unwrap_or(0) as u32,
7219            pw: padding.get(1).copied().unwrap_or(0) as u32,
7220            dh: dilation.first().copied().unwrap_or(1) as u32,
7221            dw: dilation.get(1).copied().unwrap_or(1) as u32,
7222            groups: *groups as u32,
7223        }
7224    }
7225}
7226
7227#[allow(unused_variables)]
7228fn compile_resize_nearest2x(
7229    node: &rlx_ir::Node,
7230    graph: &Graph,
7231    arena: &crate::arena::Arena,
7232    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7233    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7234    rng: rlx_ir::RngOptions,
7235) -> Thunk {
7236    let Op::ResizeNearest2x = &node.op else {
7237        unreachable!()
7238    };
7239    {
7240        let in_shape = &graph.node(node.inputs[0]).shape;
7241        let (n, c, h, w) = conv_nchw_dims(in_shape);
7242        Thunk::ResizeNearest2x {
7243            src: node_offset(arena, node.inputs[0]),
7244            dst: node_offset(arena, node.id),
7245            n,
7246            c,
7247            h,
7248            w,
7249        }
7250    }
7251}
7252
7253#[allow(unused_variables)]
7254fn compile_axial_rope2d(
7255    node: &rlx_ir::Node,
7256    graph: &Graph,
7257    arena: &crate::arena::Arena,
7258    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7259    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7260    rng: rlx_ir::RngOptions,
7261) -> Thunk {
7262    let Op::AxialRope2d {
7263        end_x,
7264        end_y,
7265        head_dim,
7266        num_heads,
7267        theta,
7268        repeat_factor,
7269    } = &node.op
7270    else {
7271        unreachable!()
7272    };
7273    {
7274        let in_shape = &graph.node(node.inputs[0]).shape;
7275        let batch = in_shape.dim(0).unwrap_static() as u32;
7276        let seq = in_shape.dim(1).unwrap_static() as u32;
7277        let hidden = in_shape.dim(2).unwrap_static() as u32;
7278        Thunk::AxialRope2d {
7279            src: node_offset(arena, node.inputs[0]),
7280            dst: node_offset(arena, node.id),
7281            batch,
7282            seq,
7283            hidden,
7284            end_x: *end_x as u32,
7285            end_y: *end_y as u32,
7286            head_dim: *head_dim as u32,
7287            num_heads: *num_heads as u32,
7288            theta: *theta,
7289            repeat_factor: *repeat_factor as u32,
7290        }
7291    }
7292}
7293
7294#[allow(unused_variables)]
7295fn compile_selective_scan(
7296    node: &rlx_ir::Node,
7297    graph: &Graph,
7298    arena: &crate::arena::Arena,
7299    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7300    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7301    rng: rlx_ir::RngOptions,
7302) -> Thunk {
7303    let Op::SelectiveScan { state_size } = &node.op else {
7304        unreachable!()
7305    };
7306    {
7307        let in_shape = &graph.node(node.inputs[0]).shape;
7308        let (batch, seq, hidden) = (
7309            in_shape.dim(0).unwrap_static(),
7310            in_shape.dim(1).unwrap_static(),
7311            in_shape.dim(2).unwrap_static(),
7312        );
7313        Thunk::SelectiveScan {
7314            x: node_offset(arena, node.inputs[0]),
7315            delta: node_offset(arena, node.inputs[1]),
7316            a: node_offset(arena, node.inputs[2]),
7317            b: node_offset(arena, node.inputs[3]),
7318            c: node_offset(arena, node.inputs[4]),
7319            dst: node_offset(arena, node.id),
7320            batch: batch as u32,
7321            seq: seq as u32,
7322            hidden: hidden as u32,
7323            state_size: *state_size as u32,
7324        }
7325    }
7326}
7327
7328#[allow(unused_variables)]
7329fn compile_gated_delta_net(
7330    node: &rlx_ir::Node,
7331    graph: &Graph,
7332    arena: &crate::arena::Arena,
7333    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7334    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7335    rng: rlx_ir::RngOptions,
7336) -> Thunk {
7337    let Op::GatedDeltaNet {
7338        state_size,
7339        carry_state,
7340    } = &node.op
7341    else {
7342        unreachable!()
7343    };
7344    {
7345        let q_shape = &graph.node(node.inputs[0]).shape;
7346        let (batch, seq, heads) = (
7347            q_shape.dim(0).unwrap_static(),
7348            q_shape.dim(1).unwrap_static(),
7349            q_shape.dim(2).unwrap_static(),
7350        );
7351        let state_off = if *carry_state {
7352            node_offset(arena, node.inputs[5])
7353        } else {
7354            0
7355        };
7356        Thunk::GatedDeltaNet {
7357            q: node_offset(arena, node.inputs[0]),
7358            k: node_offset(arena, node.inputs[1]),
7359            v: node_offset(arena, node.inputs[2]),
7360            g: node_offset(arena, node.inputs[3]),
7361            beta: node_offset(arena, node.inputs[4]),
7362            state: state_off,
7363            dst: node_offset(arena, node.id),
7364            batch: batch as u32,
7365            seq: seq as u32,
7366            heads: heads as u32,
7367            state_size: *state_size as u32,
7368        }
7369    }
7370}
7371
7372#[allow(unused_variables)]
7373fn compile_lstm(
7374    node: &rlx_ir::Node,
7375    graph: &Graph,
7376    arena: &crate::arena::Arena,
7377    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7378    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7379    rng: rlx_ir::RngOptions,
7380) -> Thunk {
7381    let Op::Lstm {
7382        hidden_size,
7383        num_layers,
7384        bidirectional,
7385        carry,
7386    } = &node.op
7387    else {
7388        unreachable!()
7389    };
7390    {
7391        let x_shape = &graph.node(node.inputs[0]).shape;
7392        let (batch, seq, input_size) = (
7393            x_shape.dim(0).unwrap_static(),
7394            x_shape.dim(1).unwrap_static(),
7395            x_shape.dim(2).unwrap_static(),
7396        );
7397        let (h0, c0) = if *carry {
7398            (
7399                node_offset(arena, node.inputs[4]),
7400                node_offset(arena, node.inputs[5]),
7401            )
7402        } else {
7403            (0, 0)
7404        };
7405        Thunk::Lstm {
7406            x: node_offset(arena, node.inputs[0]),
7407            w_ih: node_offset(arena, node.inputs[1]),
7408            w_hh: node_offset(arena, node.inputs[2]),
7409            bias: node_offset(arena, node.inputs[3]),
7410            h0,
7411            c0,
7412            dst: node_offset(arena, node.id),
7413            batch: batch as u32,
7414            seq: seq as u32,
7415            input_size: input_size as u32,
7416            hidden: *hidden_size as u32,
7417            num_layers: *num_layers as u32,
7418            bidirectional: *bidirectional,
7419            carry: *carry,
7420        }
7421    }
7422}
7423
7424#[allow(unused_variables)]
7425fn compile_gru(
7426    node: &rlx_ir::Node,
7427    graph: &Graph,
7428    arena: &crate::arena::Arena,
7429    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7430    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7431    rng: rlx_ir::RngOptions,
7432) -> Thunk {
7433    let Op::Gru {
7434        hidden_size,
7435        num_layers,
7436        bidirectional,
7437        carry,
7438    } = &node.op
7439    else {
7440        unreachable!()
7441    };
7442    {
7443        let x_shape = &graph.node(node.inputs[0]).shape;
7444        let (batch, seq, input_size) = (
7445            x_shape.dim(0).unwrap_static(),
7446            x_shape.dim(1).unwrap_static(),
7447            x_shape.dim(2).unwrap_static(),
7448        );
7449        // Inputs: x, w_ih, w_hh, b_ih, b_hh (+ h0 when carry).
7450        let h0 = if *carry {
7451            node_offset(arena, node.inputs[5])
7452        } else {
7453            0
7454        };
7455        Thunk::Gru {
7456            x: node_offset(arena, node.inputs[0]),
7457            w_ih: node_offset(arena, node.inputs[1]),
7458            w_hh: node_offset(arena, node.inputs[2]),
7459            b_ih: node_offset(arena, node.inputs[3]),
7460            b_hh: node_offset(arena, node.inputs[4]),
7461            h0,
7462            dst: node_offset(arena, node.id),
7463            batch: batch as u32,
7464            seq: seq as u32,
7465            input_size: input_size as u32,
7466            hidden: *hidden_size as u32,
7467            num_layers: *num_layers as u32,
7468            bidirectional: *bidirectional,
7469            carry: *carry,
7470        }
7471    }
7472}
7473
7474#[allow(unused_variables)]
7475fn compile_rnn(
7476    node: &rlx_ir::Node,
7477    graph: &Graph,
7478    arena: &crate::arena::Arena,
7479    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7480    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7481    rng: rlx_ir::RngOptions,
7482) -> Thunk {
7483    let Op::Rnn {
7484        hidden_size,
7485        num_layers,
7486        bidirectional,
7487        carry,
7488        relu,
7489    } = &node.op
7490    else {
7491        unreachable!()
7492    };
7493    {
7494        let x_shape = &graph.node(node.inputs[0]).shape;
7495        let (batch, seq, input_size) = (
7496            x_shape.dim(0).unwrap_static(),
7497            x_shape.dim(1).unwrap_static(),
7498            x_shape.dim(2).unwrap_static(),
7499        );
7500        // Inputs: x, w_ih, w_hh, bias (+ h0 when carry).
7501        let h0 = if *carry {
7502            node_offset(arena, node.inputs[4])
7503        } else {
7504            0
7505        };
7506        Thunk::Rnn {
7507            x: node_offset(arena, node.inputs[0]),
7508            w_ih: node_offset(arena, node.inputs[1]),
7509            w_hh: node_offset(arena, node.inputs[2]),
7510            bias: node_offset(arena, node.inputs[3]),
7511            h0,
7512            dst: node_offset(arena, node.id),
7513            batch: batch as u32,
7514            seq: seq as u32,
7515            input_size: input_size as u32,
7516            hidden: *hidden_size as u32,
7517            num_layers: *num_layers as u32,
7518            bidirectional: *bidirectional,
7519            carry: *carry,
7520            relu: *relu,
7521        }
7522    }
7523}
7524
7525#[allow(unused_variables)]
7526fn compile_mamba2(
7527    node: &rlx_ir::Node,
7528    graph: &Graph,
7529    arena: &crate::arena::Arena,
7530    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7531    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7532    rng: rlx_ir::RngOptions,
7533) -> Thunk {
7534    let Op::Mamba2 {
7535        head_dim,
7536        state_size,
7537    } = &node.op
7538    else {
7539        unreachable!()
7540    };
7541    {
7542        // x [B,S,H,P]; dt [B,S,H]; a [H]; b,c [B,S,H,N].
7543        let x_shape = &graph.node(node.inputs[0]).shape;
7544        Thunk::Mamba2 {
7545            x: node_offset(arena, node.inputs[0]),
7546            dt: node_offset(arena, node.inputs[1]),
7547            a: node_offset(arena, node.inputs[2]),
7548            b: node_offset(arena, node.inputs[3]),
7549            c: node_offset(arena, node.inputs[4]),
7550            dst: node_offset(arena, node.id),
7551            batch: x_shape.dim(0).unwrap_static() as u32,
7552            seq: x_shape.dim(1).unwrap_static() as u32,
7553            heads: x_shape.dim(2).unwrap_static() as u32,
7554            head_dim: *head_dim as u32,
7555            state_size: *state_size as u32,
7556        }
7557    }
7558}
7559
7560#[allow(unused_variables)]
7561fn compile_q_mat_mul(
7562    node: &rlx_ir::Node,
7563    graph: &Graph,
7564    arena: &crate::arena::Arena,
7565    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7566    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7567    rng: rlx_ir::RngOptions,
7568) -> Thunk {
7569    let Op::QMatMul {
7570        x_zp,
7571        w_zp,
7572        out_zp,
7573        mult,
7574    } = &node.op
7575    else {
7576        unreachable!()
7577    };
7578    {
7579        let x_shape = &graph.node(node.inputs[0]).shape;
7580        let w_shape = &graph.node(node.inputs[1]).shape;
7581        let m = x_shape.dim(0).unwrap_static();
7582        let k = x_shape.dim(1).unwrap_static();
7583        let n = w_shape.dim(1).unwrap_static();
7584        Thunk::QMatMul {
7585            x: node_offset(arena, node.inputs[0]),
7586            w: node_offset(arena, node.inputs[1]),
7587            bias: node_offset(arena, node.inputs[2]),
7588            out: node_offset(arena, node.id),
7589            m: m as u32,
7590            k: k as u32,
7591            n: n as u32,
7592            x_zp: *x_zp,
7593            w_zp: *w_zp,
7594            out_zp: *out_zp,
7595            mult: *mult,
7596        }
7597    }
7598}
7599
7600#[allow(unused_variables)]
7601fn compile_q_conv2d(
7602    node: &rlx_ir::Node,
7603    graph: &Graph,
7604    arena: &crate::arena::Arena,
7605    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7606    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7607    rng: rlx_ir::RngOptions,
7608) -> Thunk {
7609    let Op::QConv2d {
7610        kernel_size,
7611        stride,
7612        padding,
7613        dilation,
7614        groups,
7615        x_zp,
7616        w_zp,
7617        out_zp,
7618        mult,
7619    } = &node.op
7620    else {
7621        unreachable!()
7622    };
7623    {
7624        let in_shape = &graph.node(node.inputs[0]).shape;
7625        let w_shape = &graph.node(node.inputs[1]).shape;
7626        let out_shape = &node.shape;
7627        if kernel_size.len() == 2
7628            && in_shape.rank() == 4
7629            && w_shape.rank() == 4
7630            && out_shape.rank() == 4
7631        {
7632            Thunk::QConv2d {
7633                x: node_offset(arena, node.inputs[0]),
7634                w: node_offset(arena, node.inputs[1]),
7635                bias: node_offset(arena, node.inputs[2]),
7636                out: node_offset(arena, node.id),
7637                n: in_shape.dim(0).unwrap_static() as u32,
7638                c_in: in_shape.dim(1).unwrap_static() as u32,
7639                h: in_shape.dim(2).unwrap_static() as u32,
7640                w_in: in_shape.dim(3).unwrap_static() as u32,
7641                c_out: out_shape.dim(1).unwrap_static() as u32,
7642                h_out: out_shape.dim(2).unwrap_static() as u32,
7643                w_out: out_shape.dim(3).unwrap_static() as u32,
7644                kh: kernel_size[0] as u32,
7645                kw: kernel_size[1] as u32,
7646                sh: stride.first().copied().unwrap_or(1) as u32,
7647                sw: stride.get(1).copied().unwrap_or(1) as u32,
7648                ph: padding.first().copied().unwrap_or(0) as u32,
7649                pw: padding.get(1).copied().unwrap_or(0) as u32,
7650                dh: dilation.first().copied().unwrap_or(1) as u32,
7651                dw: dilation.get(1).copied().unwrap_or(1) as u32,
7652                groups: *groups as u32,
7653                x_zp: *x_zp,
7654                w_zp: *w_zp,
7655                out_zp: *out_zp,
7656                mult: *mult,
7657            }
7658        } else {
7659            Thunk::Nop
7660        }
7661    }
7662}
7663
7664#[allow(unused_variables)]
7665fn compile_dequant_mat_mul(
7666    node: &rlx_ir::Node,
7667    graph: &Graph,
7668    arena: &crate::arena::Arena,
7669    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7670    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7671    rng: rlx_ir::RngOptions,
7672) -> Thunk {
7673    let Op::DequantMatMul { scheme } = &node.op else {
7674        unreachable!()
7675    };
7676    {
7677        use rlx_ir::quant::QuantScheme;
7678        let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
7679        let total = node.shape.num_elements().unwrap();
7680        let m = total / n.max(1);
7681        let x_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
7682        let k = x_total / m.max(1);
7683        if scheme.is_gguf() {
7684            Thunk::DequantMatMulGguf {
7685                x: node_offset(arena, node.inputs[0]),
7686                w_q: node_offset(arena, node.inputs[1]),
7687                dst: node_offset(arena, node.id),
7688                m: m as u32,
7689                k: k as u32,
7690                n: n as u32,
7691                scheme: *scheme,
7692            }
7693        } else {
7694            match scheme {
7695                QuantScheme::Nvfp4Block => Thunk::DequantMatMulNvfp4 {
7696                    x: node_offset(arena, node.inputs[0]),
7697                    w_q: node_offset(arena, node.inputs[1]),
7698                    scale: node_offset(arena, node.inputs[2]),
7699                    global_scale: node_offset(arena, node.inputs[3]),
7700                    dst: node_offset(arena, node.id),
7701                    m: m as u32,
7702                    k: k as u32,
7703                    n: n as u32,
7704                },
7705                QuantScheme::Int4Block { block_size } => Thunk::DequantMatMulInt4 {
7706                    x: node_offset(arena, node.inputs[0]),
7707                    w_q: node_offset(arena, node.inputs[1]),
7708                    scale: node_offset(arena, node.inputs[2]),
7709                    zp: node_offset(arena, node.inputs[3]),
7710                    dst: node_offset(arena, node.id),
7711                    m: m as u32,
7712                    k: k as u32,
7713                    n: n as u32,
7714                    block_size: *block_size,
7715                    is_asymmetric: false,
7716                },
7717                QuantScheme::Fp8E4m3 => Thunk::DequantMatMulFp8 {
7718                    x: node_offset(arena, node.inputs[0]),
7719                    w_q: node_offset(arena, node.inputs[1]),
7720                    scale: node_offset(arena, node.inputs[2]),
7721                    dst: node_offset(arena, node.id),
7722                    m: m as u32,
7723                    k: k as u32,
7724                    n: n as u32,
7725                    e5m2: false,
7726                },
7727                QuantScheme::Fp8E5m2 => Thunk::DequantMatMulFp8 {
7728                    x: node_offset(arena, node.inputs[0]),
7729                    w_q: node_offset(arena, node.inputs[1]),
7730                    scale: node_offset(arena, node.inputs[2]),
7731                    dst: node_offset(arena, node.id),
7732                    m: m as u32,
7733                    k: k as u32,
7734                    n: n as u32,
7735                    e5m2: true,
7736                },
7737                QuantScheme::Int8Block { block_size } => Thunk::DequantMatMul {
7738                    x: node_offset(arena, node.inputs[0]),
7739                    w_q: node_offset(arena, node.inputs[1]),
7740                    scale: node_offset(arena, node.inputs[2]),
7741                    zp: node_offset(arena, node.inputs[3]),
7742                    dst: node_offset(arena, node.id),
7743                    m: m as u32,
7744                    k: k as u32,
7745                    n: n as u32,
7746                    block_size: *block_size,
7747                    is_asymmetric: false,
7748                },
7749                QuantScheme::Int8BlockAsym { block_size } => Thunk::DequantMatMul {
7750                    x: node_offset(arena, node.inputs[0]),
7751                    w_q: node_offset(arena, node.inputs[1]),
7752                    scale: node_offset(arena, node.inputs[2]),
7753                    zp: node_offset(arena, node.inputs[3]),
7754                    dst: node_offset(arena, node.id),
7755                    m: m as u32,
7756                    k: k as u32,
7757                    n: n as u32,
7758                    block_size: *block_size,
7759                    is_asymmetric: true,
7760                },
7761                other => panic!(
7762                    "DequantMatMul on CPU supports Int8/Int4/FP8/NVFP4 legacy or GGUF schemes; got {other}"
7763                ),
7764            }
7765        }
7766    }
7767}
7768
7769#[allow(unused_variables)]
7770fn compile_scaled_mat_mul(
7771    node: &rlx_ir::Node,
7772    graph: &Graph,
7773    arena: &crate::arena::Arena,
7774    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7775    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7776    rng: rlx_ir::RngOptions,
7777) -> Thunk {
7778    let Op::ScaledMatMul {
7779        lhs_format,
7780        rhs_format,
7781        scale_layout,
7782        has_bias,
7783    } = &node.op
7784    else {
7785        unreachable!()
7786    };
7787    {
7788        // TN: lhs [m,k], rhs [n,k], out [m,n].
7789        let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
7790        let total = node.shape.num_elements().unwrap();
7791        let m = total / n.max(1);
7792        let lhs_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
7793        let k = lhs_total / m.max(1);
7794        Thunk::ScaledMatMul {
7795            lhs: node_offset(arena, node.inputs[0]),
7796            rhs: node_offset(arena, node.inputs[1]),
7797            lhs_scale: node_offset(arena, node.inputs[2]),
7798            rhs_scale: node_offset(arena, node.inputs[3]),
7799            bias: if *has_bias {
7800                node_offset(arena, node.inputs[4])
7801            } else {
7802                0
7803            },
7804            dst: node_offset(arena, node.id),
7805            m: m as u32,
7806            k: k as u32,
7807            n: n as u32,
7808            lhs_fmt: *lhs_format,
7809            rhs_fmt: *rhs_format,
7810            layout: *scale_layout,
7811            has_bias: *has_bias,
7812        }
7813    }
7814}
7815
7816#[allow(unused_variables)]
7817fn compile_scaled_quantize(
7818    node: &rlx_ir::Node,
7819    graph: &Graph,
7820    arena: &crate::arena::Arena,
7821    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7822    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7823    rng: rlx_ir::RngOptions,
7824) -> Thunk {
7825    let Op::ScaledQuantize {
7826        format,
7827        scale_layout,
7828    } = &node.op
7829    else {
7830        unreachable!()
7831    };
7832    {
7833        let xs = &graph.node(node.inputs[0]).shape;
7834        let cols = xs.dim(xs.rank() - 1).unwrap_static();
7835        let rows = xs.num_elements().unwrap() / cols.max(1);
7836        Thunk::ScaledQuantize {
7837            x: node_offset(arena, node.inputs[0]),
7838            scale: node_offset(arena, node.inputs[1]),
7839            dst: node_offset(arena, node.id),
7840            rows: rows as u32,
7841            cols: cols as u32,
7842            fmt: *format,
7843            layout: *scale_layout,
7844        }
7845    }
7846}
7847
7848#[allow(unused_variables)]
7849fn compile_scaled_quant_scale(
7850    node: &rlx_ir::Node,
7851    graph: &Graph,
7852    arena: &crate::arena::Arena,
7853    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7854    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7855    rng: rlx_ir::RngOptions,
7856) -> Thunk {
7857    let Op::ScaledQuantScale {
7858        format,
7859        scale_layout,
7860    } = &node.op
7861    else {
7862        unreachable!()
7863    };
7864    {
7865        let xs = &graph.node(node.inputs[0]).shape;
7866        let cols = xs.dim(xs.rank() - 1).unwrap_static();
7867        let rows = xs.num_elements().unwrap() / cols.max(1);
7868        Thunk::ScaledQuantScale {
7869            x: node_offset(arena, node.inputs[0]),
7870            dst: node_offset(arena, node.id),
7871            rows: rows as u32,
7872            cols: cols as u32,
7873            fmt: *format,
7874            layout: *scale_layout,
7875        }
7876    }
7877}
7878
7879#[allow(unused_variables)]
7880fn compile_scaled_dequantize(
7881    node: &rlx_ir::Node,
7882    graph: &Graph,
7883    arena: &crate::arena::Arena,
7884    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7885    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7886    rng: rlx_ir::RngOptions,
7887) -> Thunk {
7888    let Op::ScaledDequantize {
7889        format,
7890        scale_layout,
7891    } = &node.op
7892    else {
7893        unreachable!()
7894    };
7895    {
7896        let xs = &graph.node(node.inputs[0]).shape;
7897        let cols = xs.dim(xs.rank() - 1).unwrap_static();
7898        let rows = xs.num_elements().unwrap() / cols.max(1);
7899        Thunk::ScaledDequantize {
7900            codes: node_offset(arena, node.inputs[0]),
7901            scale: node_offset(arena, node.inputs[1]),
7902            dst: node_offset(arena, node.id),
7903            rows: rows as u32,
7904            cols: cols as u32,
7905            fmt: *format,
7906            layout: *scale_layout,
7907        }
7908    }
7909}
7910
7911#[allow(unused_variables)]
7912fn compile_lora_mat_mul(
7913    node: &rlx_ir::Node,
7914    graph: &Graph,
7915    arena: &crate::arena::Arena,
7916    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7917    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7918    rng: rlx_ir::RngOptions,
7919) -> Thunk {
7920    let Op::LoraMatMul { scale } = &node.op else {
7921        unreachable!()
7922    };
7923    {
7924        // x [m, k], w [k, n], a [k, r], b [r, n].
7925        let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
7926        let total = node.shape.num_elements().unwrap();
7927        let m = total / n.max(1);
7928        let x_total = graph.node(node.inputs[0]).shape.num_elements().unwrap();
7929        let k = x_total / m.max(1);
7930        let a_total = graph.node(node.inputs[2]).shape.num_elements().unwrap();
7931        let r = a_total / k.max(1);
7932        Thunk::LoraMatMul {
7933            x: node_offset(arena, node.inputs[0]),
7934            w: node_offset(arena, node.inputs[1]),
7935            a: node_offset(arena, node.inputs[2]),
7936            b: node_offset(arena, node.inputs[3]),
7937            dst: node_offset(arena, node.id),
7938            m: m as u32,
7939            k: k as u32,
7940            n: n as u32,
7941            r: r as u32,
7942            scale: *scale,
7943        }
7944    }
7945}
7946
7947#[allow(unused_variables)]
7948fn compile_sample(
7949    node: &rlx_ir::Node,
7950    graph: &Graph,
7951    arena: &crate::arena::Arena,
7952    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7953    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7954    rng: rlx_ir::RngOptions,
7955) -> Thunk {
7956    let Op::Sample {
7957        top_k,
7958        top_p,
7959        temperature,
7960        seed,
7961    } = &node.op
7962    else {
7963        unreachable!()
7964    };
7965    {
7966        let in_shape = &graph.node(node.inputs[0]).shape;
7967        // Logits are [batch, vocab] (or [vocab] → batch=1).
7968        let (batch, vocab) = if in_shape.rank() >= 2 {
7969            (
7970                in_shape.dim(0).unwrap_static(),
7971                in_shape.dim(in_shape.rank() - 1).unwrap_static(),
7972            )
7973        } else {
7974            (1, in_shape.num_elements().unwrap_or(0))
7975        };
7976        Thunk::Sample {
7977            logits: node_offset(arena, node.inputs[0]),
7978            dst: node_offset(arena, node.id),
7979            batch: batch as u32,
7980            vocab: vocab as u32,
7981            top_k: *top_k as u32,
7982            top_p: *top_p,
7983            temperature: *temperature,
7984            seed: *seed,
7985        }
7986    }
7987}
7988
7989#[allow(unused_variables)]
7990fn compile_rng_normal(
7991    node: &rlx_ir::Node,
7992    graph: &Graph,
7993    arena: &crate::arena::Arena,
7994    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
7995    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
7996    rng: rlx_ir::RngOptions,
7997) -> Thunk {
7998    let Op::RngNormal {
7999        mean,
8000        scale,
8001        key,
8002        op_seed,
8003    } = &node.op
8004    else {
8005        unreachable!()
8006    };
8007    Thunk::RngNormal {
8008        dst: node_offset(arena, node.id),
8009        len: node.shape.num_elements().unwrap_or(0) as u32,
8010        mean: *mean,
8011        scale: *scale,
8012        key: *key,
8013        op_seed: *op_seed,
8014    }
8015}
8016
8017#[allow(unused_variables)]
8018fn compile_rng_uniform(
8019    node: &rlx_ir::Node,
8020    graph: &Graph,
8021    arena: &crate::arena::Arena,
8022    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8023    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8024    rng: rlx_ir::RngOptions,
8025) -> Thunk {
8026    let Op::RngUniform {
8027        low,
8028        high,
8029        key,
8030        op_seed,
8031    } = &node.op
8032    else {
8033        unreachable!()
8034    };
8035    Thunk::RngUniform {
8036        dst: node_offset(arena, node.id),
8037        len: node.shape.num_elements().unwrap_or(0) as u32,
8038        low: *low,
8039        high: *high,
8040        key: *key,
8041        op_seed: *op_seed,
8042    }
8043}
8044
8045#[allow(unused_variables)]
8046fn compile_cumsum(
8047    node: &rlx_ir::Node,
8048    graph: &Graph,
8049    arena: &crate::arena::Arena,
8050    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8051    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8052    rng: rlx_ir::RngOptions,
8053) -> Thunk {
8054    let Op::Cumsum { axis, exclusive } = &node.op else {
8055        unreachable!()
8056    };
8057    {
8058        // For now CPU only supports last-axis cumsum (the
8059        // common case for sampling / ragged offsets).
8060        // Other axes can lower via Transpose → Cumsum →
8061        // Transpose; not on the hot path today.
8062        let rank = node.shape.rank();
8063        let ax = if *axis < 0 {
8064            (rank as i32 + axis) as usize
8065        } else {
8066            *axis as usize
8067        };
8068        assert_eq!(
8069            ax,
8070            rank - 1,
8071            "Cumsum only supports the last axis on CPU today"
8072        );
8073        let cols = node.shape.dim(ax).unwrap_static();
8074        let total = node.shape.num_elements().unwrap();
8075        Thunk::Cumsum {
8076            src: node_offset(arena, node.inputs[0]),
8077            dst: node_offset(arena, node.id),
8078            rows: (total / cols) as u32,
8079            cols: cols as u32,
8080            exclusive: *exclusive,
8081        }
8082    }
8083}
8084
8085#[allow(unused_variables)]
8086fn compile_attention(
8087    node: &rlx_ir::Node,
8088    graph: &Graph,
8089    arena: &crate::arena::Arena,
8090    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8091    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8092    rng: rlx_ir::RngOptions,
8093) -> Thunk {
8094    let Op::Attention {
8095        num_heads,
8096        head_dim,
8097        mask_kind,
8098        score_scale,
8099        attn_logit_softcap,
8100    } = &node.op
8101    else {
8102        unreachable!()
8103    };
8104    {
8105        // Layout dispatch: rank-4 input could be either
8106        // `[B, S, H, D]` (CPU's historical convention) or
8107        // `[B, H, S, D]` (the convention the GPU/TPU backends
8108        // share). Disambiguate by which axis matches
8109        // `num_heads`. Rank-3 is always `[B, S, H*D]`.
8110        let q_shape = &graph.node(node.inputs[0]).shape;
8111        let k_shape = &graph.node(node.inputs[1]).shape;
8112        let rank = q_shape.rank();
8113        let (batch, seq, kv_seq, bhsd) = if rank == 4 {
8114            let d1 = q_shape.dim(1).unwrap_static();
8115            let d2 = q_shape.dim(2).unwrap_static();
8116            if d1 == *num_heads {
8117                // [B, H, S, D]
8118                (
8119                    q_shape.dim(0).unwrap_static(),
8120                    d2,
8121                    k_shape.dim(2).unwrap_static(),
8122                    true,
8123                )
8124            } else {
8125                // [B, S, H, D]
8126                (
8127                    q_shape.dim(0).unwrap_static(),
8128                    d1,
8129                    k_shape.dim(1).unwrap_static(),
8130                    false,
8131                )
8132            }
8133        } else if rank >= 3 {
8134            (
8135                q_shape.dim(0).unwrap_static(),
8136                q_shape.dim(1).unwrap_static(),
8137                k_shape.dim(1).unwrap_static(),
8138                false,
8139            )
8140        } else {
8141            (
8142                1,
8143                q_shape.dim(0).unwrap_static(),
8144                k_shape.dim(0).unwrap_static(),
8145                false,
8146            )
8147        };
8148        let mask_off = if matches!(
8149            mask_kind,
8150            rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias
8151        ) {
8152            node_offset(arena, node.inputs[3])
8153        } else {
8154            0
8155        };
8156        let hs = (*num_heads * *head_dim) as u32;
8157        // GQA/MQA: KV-head count from K's element count over B·S_k·D (layout-
8158        // independent). == num_heads for MHA; < num_heads only on the raw
8159        // standalone path (real models fuse or replicate K/V to num_heads).
8160        let k_numel = k_shape
8161            .num_elements()
8162            .unwrap_or(batch * kv_seq * *num_heads * *head_dim);
8163        let nkv = (k_numel / (batch.max(1) * kv_seq.max(1) * (*head_dim).max(1))).max(1) as u32;
8164        let kv_hs = nkv * *head_dim as u32;
8165        Thunk::Attention {
8166            q: node_offset(arena, node.inputs[0]),
8167            k: node_offset(arena, node.inputs[1]),
8168            v: node_offset(arena, node.inputs[2]),
8169            mask: mask_off,
8170            out: node_offset(arena, node.id),
8171            batch: batch as u32,
8172            seq: seq as u32,
8173            kv_seq: kv_seq as u32,
8174            heads: *num_heads as u32,
8175            kv_heads: nkv,
8176            head_dim: *head_dim as u32,
8177            mask_kind: *mask_kind,
8178            scale: score_scale.unwrap_or((*head_dim as f32).powf(-0.5)),
8179            softcap: attn_logit_softcap.unwrap_or(0.0),
8180            // Defaults: each input is its own contiguous buffer
8181            // with row stride = hidden. Rewritten by the
8182            // Narrow→Attention fusion when applicable.
8183            q_row_stride: hs,
8184            k_row_stride: kv_hs,
8185            v_row_stride: kv_hs,
8186            bhsd,
8187        }
8188    }
8189}
8190
8191#[allow(unused_variables)]
8192fn compile_attention_backward(
8193    node: &rlx_ir::Node,
8194    graph: &Graph,
8195    arena: &crate::arena::Arena,
8196    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8197    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8198    rng: rlx_ir::RngOptions,
8199) -> Thunk {
8200    let Op::AttentionBackward {
8201        num_heads,
8202        head_dim,
8203        mask_kind,
8204        wrt,
8205    } = &node.op
8206    else {
8207        unreachable!()
8208    };
8209    {
8210        let q_shape = &graph.node(node.inputs[0]).shape;
8211        let k_shape = &graph.node(node.inputs[1]).shape;
8212        let rank = q_shape.rank();
8213        let (batch, seq, kv_seq, bhsd) = if rank == 4 {
8214            let d1 = q_shape.dim(1).unwrap_static();
8215            let d2 = q_shape.dim(2).unwrap_static();
8216            if d1 == *num_heads {
8217                (
8218                    q_shape.dim(0).unwrap_static(),
8219                    d2,
8220                    k_shape.dim(2).unwrap_static(),
8221                    true,
8222                )
8223            } else {
8224                (
8225                    q_shape.dim(0).unwrap_static(),
8226                    d1,
8227                    k_shape.dim(1).unwrap_static(),
8228                    false,
8229                )
8230            }
8231        } else if rank >= 3 {
8232            (
8233                q_shape.dim(0).unwrap_static(),
8234                q_shape.dim(1).unwrap_static(),
8235                k_shape.dim(1).unwrap_static(),
8236                false,
8237            )
8238        } else {
8239            (
8240                1,
8241                q_shape.dim(0).unwrap_static(),
8242                k_shape.dim(0).unwrap_static(),
8243                false,
8244            )
8245        };
8246        let mask_off = if matches!(
8247            mask_kind,
8248            rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::Bias
8249        ) {
8250            node_offset(arena, node.inputs[4])
8251        } else {
8252            0
8253        };
8254        Thunk::AttentionBackward {
8255            q: node_offset(arena, node.inputs[0]),
8256            k: node_offset(arena, node.inputs[1]),
8257            v: node_offset(arena, node.inputs[2]),
8258            dy: node_offset(arena, node.inputs[3]),
8259            mask: mask_off,
8260            out: node_offset(arena, node.id),
8261            batch: batch as u32,
8262            seq: seq as u32,
8263            kv_seq: kv_seq as u32,
8264            heads: *num_heads as u32,
8265            head_dim: *head_dim as u32,
8266            mask_kind: *mask_kind,
8267            wrt: *wrt,
8268            bhsd,
8269        }
8270    }
8271}
8272
8273#[allow(unused_variables)]
8274fn compile_fused_attention_block(
8275    node: &rlx_ir::Node,
8276    graph: &Graph,
8277    arena: &crate::arena::Arena,
8278    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8279    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8280    rng: rlx_ir::RngOptions,
8281) -> Thunk {
8282    let Op::FusedAttentionBlock {
8283        num_heads,
8284        head_dim,
8285        has_bias,
8286        has_rope,
8287    } = &node.op
8288    else {
8289        unreachable!()
8290    };
8291    {
8292        let x_shape = &graph.node(node.inputs[0]).shape;
8293        let (batch, seq) = if x_shape.rank() >= 3 {
8294            (
8295                x_shape.dim(0).unwrap_static(),
8296                x_shape.dim(1).unwrap_static(),
8297            )
8298        } else {
8299            let total = x_shape.num_elements().unwrap();
8300            let s = x_shape.dim(x_shape.rank() - 2).unwrap_static();
8301            (total / (s * num_heads * head_dim), s)
8302        };
8303        let hs = (*num_heads * *head_dim) as u32;
8304        // Inputs: hidden, qkv_w, out_w, mask, [qkv_b, out_b], [cos, sin]
8305        let mut idx = 4;
8306        let (qkv_b_off, out_b_off) = if *has_bias {
8307            let qb = node_offset(arena, node.inputs[idx]);
8308            let ob = node_offset(arena, node.inputs[idx + 1]);
8309            idx += 2;
8310            (qb, ob)
8311        } else {
8312            (0, 0)
8313        };
8314        let (cos_off, sin_off, cl) = if *has_rope {
8315            let c = node_offset(arena, node.inputs[idx]);
8316            let s = node_offset(arena, node.inputs[idx + 1]);
8317            let clen = get_len(graph, node.inputs[idx]);
8318            (c, s, clen as u32)
8319        } else {
8320            (0, 0, 0)
8321        };
8322
8323        Thunk::FusedAttnBlock {
8324            hidden: node_offset(arena, node.inputs[0]),
8325            qkv_w: node_offset(arena, node.inputs[1]),
8326            out_w: node_offset(arena, node.inputs[2]),
8327            mask: node_offset(arena, node.inputs[3]),
8328            // The MIR `Op::FusedAttentionBlock` is emitted only for the
8329            // BERT-style per-key padding mask (the MIR fusion pass is
8330            // `Custom`-only), so the buffer mask is authoritative here.
8331            mask_kind: rlx_ir::op::MaskKind::Custom,
8332            out: node_offset(arena, node.id),
8333            qkv_b: qkv_b_off,
8334            out_b: out_b_off,
8335            cos: cos_off,
8336            sin: sin_off,
8337            cos_len: cl,
8338            batch: batch as u32,
8339            seq: seq as u32,
8340            hs,
8341            nh: *num_heads as u32,
8342            dh: *head_dim as u32,
8343            has_bias: *has_bias,
8344            has_rope: *has_rope,
8345            // The MIR `Op::FusedAttentionBlock` is BERT-only (NeoX rope).
8346            interleaved: false,
8347        }
8348    }
8349}
8350
8351#[allow(unused_variables)]
8352fn compile_rope(
8353    node: &rlx_ir::Node,
8354    graph: &Graph,
8355    arena: &crate::arena::Arena,
8356    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8357    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8358    rng: rlx_ir::RngOptions,
8359) -> Thunk {
8360    let Op::Rope {
8361        head_dim,
8362        n_rot,
8363        style,
8364    } = &node.op
8365    else {
8366        unreachable!()
8367    };
8368    {
8369        let x_shape = &graph.node(node.inputs[0]).shape;
8370        let (batch, seq, hidden) = if x_shape.rank() >= 3 {
8371            (
8372                x_shape.dim(0).unwrap_static(),
8373                x_shape.dim(1).unwrap_static(),
8374                x_shape.dim(2).unwrap_static(),
8375            )
8376        } else {
8377            let total = x_shape.num_elements().unwrap();
8378            (
8379                1,
8380                x_shape.dim(0).unwrap_static(),
8381                total / x_shape.dim(0).unwrap_static(),
8382            )
8383        };
8384        let cos_len = get_len(graph, node.inputs[1]);
8385        Thunk::Rope {
8386            src: node_offset(arena, node.inputs[0]),
8387            cos: node_offset(arena, node.inputs[1]),
8388            sin: node_offset(arena, node.inputs[2]),
8389            dst: node_offset(arena, node.id),
8390            batch: batch as u32,
8391            seq: seq as u32,
8392            hidden: hidden as u32,
8393            head_dim: *head_dim as u32,
8394            n_rot: *n_rot as u32,
8395            cos_len: cos_len as u32,
8396            // Default: source rows are tightly packed (rewritten
8397            // by the Narrow→Rope fusion pass below if Rope ends
8398            // up reading from a wider parent like QKV).
8399            src_row_stride: hidden as u32,
8400            interleaved: matches!(style, rlx_ir::op::RopeStyle::GptJ),
8401        }
8402    }
8403}
8404
8405#[allow(unused_variables)]
8406fn compile_fused_swi_g_l_u(
8407    node: &rlx_ir::Node,
8408    graph: &Graph,
8409    arena: &crate::arena::Arena,
8410    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8411    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8412    rng: rlx_ir::RngOptions,
8413) -> Thunk {
8414    let Op::FusedSwiGLU {
8415        cast_to: _,
8416        gate_first,
8417    } = &node.op
8418    else {
8419        unreachable!()
8420    };
8421    {
8422        let n_half = node.shape.dim(node.shape.rank() - 1).unwrap_static();
8423        let total = node.shape.num_elements().unwrap();
8424        Thunk::FusedSwiGLU {
8425            src: node_offset(arena, node.inputs[0]),
8426            dst: node_offset(arena, node.id),
8427            n_half: n_half as u32,
8428            total: total as u32,
8429            gate_first: *gate_first,
8430        }
8431    }
8432}
8433
8434#[allow(unused_variables)]
8435fn compile_conv(
8436    node: &rlx_ir::Node,
8437    graph: &Graph,
8438    arena: &crate::arena::Arena,
8439    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8440    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8441    rng: rlx_ir::RngOptions,
8442) -> Thunk {
8443    let Op::Conv {
8444        kernel_size,
8445        stride,
8446        padding,
8447        dilation,
8448        groups,
8449    } = &node.op
8450    else {
8451        unreachable!()
8452    };
8453    {
8454        let in_shape = &graph.node(node.inputs[0]).shape;
8455        let w_shape = &graph.node(node.inputs[1]).shape;
8456        let out_shape = &node.shape;
8457        // 1×1 fast path (plan #26): kH=kW=1, stride=1,
8458        // padding=0, dilation=1, groups=1. Emits a single
8459        // Conv2D1x1 thunk that BLAS-dispatches per batch.
8460        let is_1x1_simple = kernel_size.len() == 2
8461            && kernel_size[0] == 1
8462            && kernel_size[1] == 1
8463            && stride.iter().all(|&s| s == 1)
8464            && padding.iter().all(|&p| p == 0)
8465            && dilation.iter().all(|&d| d == 1)
8466            && *groups == 1;
8467        if is_1x1_simple && in_shape.rank() >= 3 && out_shape.rank() >= 3 && w_shape.rank() >= 2 {
8468            let (n, c_in, h, w) = conv_nchw_dims(in_shape);
8469            let (_, c_out, _, _) = conv_nchw_dims(out_shape);
8470            Thunk::Conv2D1x1 {
8471                src: node_offset(arena, node.inputs[0]),
8472                weight: node_offset(arena, node.inputs[1]),
8473                dst: node_offset(arena, node.id),
8474                n,
8475                c_in,
8476                c_out,
8477                hw: h.saturating_mul(w),
8478            }
8479        } else if kernel_size.len() == 2
8480            && in_shape.rank() >= 3
8481            && w_shape.rank() >= 2
8482            && out_shape.rank() >= 3
8483        {
8484            let (n, c_in, h, w_in) = conv_nchw_dims(in_shape);
8485            let (_, c_out, h_out, w_out) = conv_nchw_dims(out_shape);
8486            // rlx lowers ONNX 1D convs as 2D NCHW with a unit H axis and the
8487            // length in W (`[N,C,1,L]`), but keeps the length kernel/stride/pad/
8488            // dilation at index 0 (`kernel=[k,1]`). A literal 2D conv would run
8489            // the k-tap kernel over the singleton H axis and ignore the length.
8490            // Since `[N,C,1,L]` and `[N,C,L,1]` share the same row-major layout,
8491            // relabel the length onto the H axis (no data copy) so the kernel
8492            // convolves it — matching the MLX 1D path and onnxruntime.
8493            let one_d_w = h == 1
8494                && w_in > 1
8495                && kernel_size[0] > 1
8496                && kernel_size.get(1).copied().unwrap_or(1) == 1;
8497            let (h, w_in, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw) = if one_d_w {
8498                (
8499                    w_in,
8500                    1,
8501                    w_out,
8502                    1,
8503                    kernel_size[0] as u32,
8504                    1,
8505                    stride.first().copied().unwrap_or(1) as u32,
8506                    1,
8507                    padding.first().copied().unwrap_or(0) as u32,
8508                    0,
8509                    dilation.first().copied().unwrap_or(1) as u32,
8510                    1,
8511                )
8512            } else {
8513                (
8514                    h,
8515                    w_in,
8516                    h_out,
8517                    w_out,
8518                    kernel_size[0] as u32,
8519                    kernel_size[1] as u32,
8520                    stride.first().copied().unwrap_or(1) as u32,
8521                    stride.get(1).copied().unwrap_or(1) as u32,
8522                    padding.first().copied().unwrap_or(0) as u32,
8523                    padding.get(1).copied().unwrap_or(0) as u32,
8524                    dilation.first().copied().unwrap_or(1) as u32,
8525                    dilation.get(1).copied().unwrap_or(1) as u32,
8526                )
8527            };
8528            Thunk::Conv2D {
8529                src: node_offset(arena, node.inputs[0]),
8530                weight: node_offset(arena, node.inputs[1]),
8531                dst: node_offset(arena, node.id),
8532                n,
8533                c_in,
8534                h,
8535                w: w_in,
8536                c_out,
8537                h_out,
8538                w_out,
8539                kh,
8540                kw,
8541                sh,
8542                sw,
8543                ph,
8544                pw,
8545                dh,
8546                dw,
8547                groups: *groups as u32,
8548            }
8549        } else {
8550            Thunk::Nop
8551        }
8552    }
8553}
8554
8555#[allow(unused_variables)]
8556fn compile_conv3d(
8557    node: &rlx_ir::Node,
8558    graph: &Graph,
8559    arena: &crate::arena::Arena,
8560    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8561    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8562    rng: rlx_ir::RngOptions,
8563) -> Thunk {
8564    let Op::Conv3d {
8565        stride,
8566        padding,
8567        dilation,
8568        groups,
8569    } = &node.op
8570    else {
8571        unreachable!()
8572    };
8573    {
8574        let in_shape = &graph.node(node.inputs[0]).shape;
8575        let w_shape = &graph.node(node.inputs[1]).shape;
8576        let out_shape = &node.shape;
8577        if in_shape.rank() == 5 && w_shape.rank() == 5 && out_shape.rank() == 5 {
8578            let (n, c_in, d, h, w) = conv_ncdhw_dims(in_shape);
8579            let (_, c_out, d_out, h_out, w_out) = conv_ncdhw_dims(out_shape);
8580            // Kernel extent from the weight [C_out, C_in/g, kD, kH, kW].
8581            let kd = w_shape.dim(2).unwrap_static() as u32;
8582            let kh = w_shape.dim(3).unwrap_static() as u32;
8583            let kw = w_shape.dim(4).unwrap_static() as u32;
8584            Thunk::Conv3d {
8585                src: node_offset(arena, node.inputs[0]),
8586                weight: node_offset(arena, node.inputs[1]),
8587                dst: node_offset(arena, node.id),
8588                n,
8589                c_in,
8590                d,
8591                h,
8592                w,
8593                c_out,
8594                d_out,
8595                h_out,
8596                w_out,
8597                kd,
8598                kh,
8599                kw,
8600                sd: stride[0] as u32,
8601                sh: stride[1] as u32,
8602                sw: stride[2] as u32,
8603                pd: padding[0] as u32,
8604                ph: padding[1] as u32,
8605                pw: padding[2] as u32,
8606                dd: dilation[0] as u32,
8607                dh: dilation[1] as u32,
8608                dw: dilation[2] as u32,
8609                groups: *groups as u32,
8610            }
8611        } else {
8612            Thunk::Nop
8613        }
8614    }
8615}
8616
8617#[allow(unused_variables)]
8618fn compile_conv_transpose3d(
8619    node: &rlx_ir::Node,
8620    graph: &Graph,
8621    arena: &crate::arena::Arena,
8622    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8623    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8624    rng: rlx_ir::RngOptions,
8625) -> Thunk {
8626    let Op::ConvTranspose3d {
8627        stride,
8628        padding,
8629        dilation,
8630        output_padding: _,
8631        groups,
8632    } = &node.op
8633    else {
8634        unreachable!()
8635    };
8636    {
8637        let in_shape = &graph.node(node.inputs[0]).shape;
8638        let w_shape = &graph.node(node.inputs[1]).shape;
8639        let out_shape = &node.shape;
8640        if in_shape.rank() == 5 && w_shape.rank() == 5 && out_shape.rank() == 5 {
8641            let (n, c_in, d, h, w_in) = conv_ncdhw_dims(in_shape);
8642            let (_, c_out, d_out, h_out, w_out) = conv_ncdhw_dims(out_shape);
8643            // Kernel extent from the weight [C_in, C_out/g, kD, kH, kW].
8644            let kd = w_shape.dim(2).unwrap_static() as u32;
8645            let kh = w_shape.dim(3).unwrap_static() as u32;
8646            let kw = w_shape.dim(4).unwrap_static() as u32;
8647            Thunk::ConvTranspose3d {
8648                src: node_offset(arena, node.inputs[0]),
8649                weight: node_offset(arena, node.inputs[1]),
8650                dst: node_offset(arena, node.id),
8651                n,
8652                c_in,
8653                d,
8654                h,
8655                w_in,
8656                c_out,
8657                d_out,
8658                h_out,
8659                w_out,
8660                kd,
8661                kh,
8662                kw,
8663                sd: stride[0] as u32,
8664                sh: stride[1] as u32,
8665                sw: stride[2] as u32,
8666                pd: padding[0] as u32,
8667                ph: padding[1] as u32,
8668                pw: padding[2] as u32,
8669                dd: dilation[0] as u32,
8670                dh: dilation[1] as u32,
8671                dw: dilation[2] as u32,
8672                groups: *groups as u32,
8673            }
8674        } else {
8675            Thunk::Nop
8676        }
8677    }
8678}
8679
8680#[allow(unused_variables)]
8681fn compile_pool(
8682    node: &rlx_ir::Node,
8683    graph: &Graph,
8684    arena: &crate::arena::Arena,
8685    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8686    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8687    rng: rlx_ir::RngOptions,
8688) -> Thunk {
8689    let Op::Pool {
8690        kind,
8691        kernel_size,
8692        stride,
8693        padding,
8694    } = &node.op
8695    else {
8696        unreachable!()
8697    };
8698    {
8699        // Currently support 2D pooling on rank-4 NCHW tensors.
8700        let in_shape = &graph.node(node.inputs[0]).shape;
8701        let out_shape = &node.shape;
8702        if kernel_size.len() == 2 && in_shape.rank() == 4 && out_shape.rank() == 4 {
8703            Thunk::Pool2D {
8704                src: node_offset(arena, node.inputs[0]),
8705                dst: node_offset(arena, node.id),
8706                n: in_shape.dim(0).unwrap_static() as u32,
8707                c: in_shape.dim(1).unwrap_static() as u32,
8708                h: in_shape.dim(2).unwrap_static() as u32,
8709                w: in_shape.dim(3).unwrap_static() as u32,
8710                h_out: out_shape.dim(2).unwrap_static() as u32,
8711                w_out: out_shape.dim(3).unwrap_static() as u32,
8712                kh: kernel_size[0] as u32,
8713                kw: kernel_size[1] as u32,
8714                sh: stride.first().copied().unwrap_or(1) as u32,
8715                sw: stride.get(1).copied().unwrap_or(1) as u32,
8716                ph: padding.first().copied().unwrap_or(0) as u32,
8717                pw: padding.get(1).copied().unwrap_or(0) as u32,
8718                kind: *kind,
8719            }
8720        } else {
8721            Thunk::Nop
8722        }
8723    }
8724}
8725
8726#[allow(unused_variables)]
8727fn compile_transpose(
8728    node: &rlx_ir::Node,
8729    graph: &Graph,
8730    arena: &crate::arena::Arena,
8731    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8732    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8733    rng: rlx_ir::RngOptions,
8734) -> Thunk {
8735    let Op::Transpose { perm } = &node.op else {
8736        unreachable!()
8737    };
8738    {
8739        // Pre-compute (out_dims, in_strides_for_each_out_dim) so the
8740        // runtime loop is just an N-D index walk + scatter.
8741        let in_shape = &graph.node(node.inputs[0]).shape;
8742        let in_rank = in_shape.rank();
8743        if perm.iter().any(|&p| p >= in_rank) {
8744            Thunk::Nop
8745        } else {
8746            let in_dims: Vec<usize> = (0..in_rank)
8747                .map(|i| in_shape.dim(i).unwrap_static())
8748                .collect();
8749            // Row-major input strides: stride[d] = product of dims[d+1..].
8750            let mut in_strides_full = vec![1usize; in_rank];
8751            for d in (0..in_rank.saturating_sub(1)).rev() {
8752                in_strides_full[d] = in_strides_full[d + 1] * in_dims[d + 1];
8753            }
8754            let out_dims: Vec<u32> = perm.iter().map(|&p| in_dims[p] as u32).collect();
8755            let in_strides: Vec<u32> = perm.iter().map(|&p| in_strides_full[p] as u32).collect();
8756            let in_total = in_dims.iter().product::<usize>() as u32;
8757            let src = node_offset(arena, node.inputs[0]);
8758            let dst = node_offset(arena, node.id);
8759            let elem_bytes = node.shape.dtype().size_bytes() as u8;
8760            match node.shape.dtype() {
8761                rlx_ir::DType::F64 => Thunk::TransposeF64 {
8762                    src,
8763                    dst,
8764                    in_total,
8765                    out_dims,
8766                    in_strides,
8767                },
8768                _ => Thunk::Transpose {
8769                    src,
8770                    dst,
8771                    in_total,
8772                    out_dims,
8773                    in_strides,
8774                    elem_bytes,
8775                },
8776            }
8777        }
8778    }
8779}
8780
8781#[allow(unused_variables)]
8782fn compile_scatter_add(
8783    node: &rlx_ir::Node,
8784    graph: &Graph,
8785    arena: &crate::arena::Arena,
8786    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8787    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8788    rng: rlx_ir::RngOptions,
8789) -> Thunk {
8790    let Op::ScatterAdd = &node.op else {
8791        unreachable!()
8792    };
8793    {
8794        // updates: [num_updates, ...trailing], indices: [num_updates],
8795        // output: [out_dim, ...trailing]
8796        let upd_shape = &graph.node(node.inputs[0]).shape;
8797        let out_shape = &node.shape;
8798        let num_updates = upd_shape.dim(0).unwrap_static();
8799        let out_dim = out_shape.dim(0).unwrap_static();
8800        let trailing: usize = (1..out_shape.rank())
8801            .map(|i| out_shape.dim(i).unwrap_static())
8802            .product::<usize>()
8803            .max(1);
8804        Thunk::ScatterAdd {
8805            updates: node_offset(arena, node.inputs[0]),
8806            indices: node_offset(arena, node.inputs[1]),
8807            dst: node_offset(arena, node.id),
8808            num_updates: num_updates as u32,
8809            out_dim: out_dim as u32,
8810            trailing: trailing as u32,
8811        }
8812    }
8813}
8814
8815#[allow(unused_variables)]
8816fn compile_grouped_mat_mul(
8817    node: &rlx_ir::Node,
8818    graph: &Graph,
8819    arena: &crate::arena::Arena,
8820    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8821    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8822    rng: rlx_ir::RngOptions,
8823) -> Thunk {
8824    let Op::GroupedMatMul = &node.op else {
8825        unreachable!()
8826    };
8827    {
8828        // Inputs: [input(M, K), weight(E, K, N), expert_idx(M)]
8829        let in_shape = &graph.node(node.inputs[0]).shape;
8830        let w_shape = &graph.node(node.inputs[1]).shape;
8831        let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
8832        let k_dim = in_shape.dim(in_shape.rank() - 1).unwrap_static();
8833        let num_experts = w_shape.dim(0).unwrap_static();
8834        let n = w_shape.dim(2).unwrap_static();
8835        Thunk::GroupedMatMul {
8836            input: node_offset(arena, node.inputs[0]),
8837            weight: node_offset(arena, node.inputs[1]),
8838            expert_idx: node_offset(arena, node.inputs[2]),
8839            dst: node_offset(arena, node.id),
8840            m: m as u32,
8841            k_dim: k_dim as u32,
8842            n: n as u32,
8843            num_experts: num_experts as u32,
8844        }
8845    }
8846}
8847
8848#[allow(unused_variables)]
8849fn compile_dequant_grouped_mat_mul(
8850    node: &rlx_ir::Node,
8851    graph: &Graph,
8852    arena: &crate::arena::Arena,
8853    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8854    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8855    rng: rlx_ir::RngOptions,
8856) -> Thunk {
8857    let Op::DequantGroupedMatMul { scheme } = &node.op else {
8858        unreachable!()
8859    };
8860    {
8861        let in_shape = &graph.node(node.inputs[0]).shape;
8862        let w_shape = &graph.node(node.inputs[1]).shape;
8863        let m = in_shape.dim(in_shape.rank() - 2).unwrap_static();
8864        let k_dim = in_shape.dim(in_shape.rank() - 1).unwrap_static();
8865        let out_shape = &node.shape;
8866        let n = out_shape.dim(out_shape.rank() - 1).unwrap_static();
8867        let block_elems = scheme.gguf_block_size() as usize;
8868        let block_bytes = scheme.gguf_block_bytes() as usize;
8869        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
8870        let total_bytes = w_shape.num_elements().unwrap();
8871        let num_experts = total_bytes / slab_bytes.max(1);
8872        Thunk::DequantGroupedMatMulGguf {
8873            input: node_offset(arena, node.inputs[0]),
8874            w_q: node_offset(arena, node.inputs[1]),
8875            expert_idx: node_offset(arena, node.inputs[2]),
8876            dst: node_offset(arena, node.id),
8877            m: m as u32,
8878            k_dim: k_dim as u32,
8879            n: n as u32,
8880            num_experts: num_experts as u32,
8881            scheme: *scheme,
8882        }
8883    }
8884}
8885
8886#[allow(unused_variables)]
8887fn compile_dequant_mo_e_weights(
8888    node: &rlx_ir::Node,
8889    graph: &Graph,
8890    arena: &crate::arena::Arena,
8891    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8892    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8893    rng: rlx_ir::RngOptions,
8894) -> Thunk {
8895    let Op::DequantMoEWeights { scheme } = &node.op else {
8896        unreachable!()
8897    };
8898    {
8899        let w_shape = &graph.node(node.inputs[0]).shape;
8900        let out_shape = &node.shape;
8901        let num_experts = out_shape.dim(0).unwrap_static();
8902        let k_dim = out_shape.dim(1).unwrap_static();
8903        let n = out_shape.dim(2).unwrap_static();
8904        let block_elems = scheme.gguf_block_size() as usize;
8905        let block_bytes = scheme.gguf_block_bytes() as usize;
8906        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
8907        let total_bytes = w_shape.num_elements().unwrap();
8908        assert_eq!(
8909            total_bytes,
8910            num_experts * slab_bytes,
8911            "DequantMoEWeights packed bytes mismatch"
8912        );
8913        Thunk::DequantMoEWeightsGguf {
8914            w_q: node_offset(arena, node.inputs[0]),
8915            dst: node_offset(arena, node.id),
8916            k_dim: k_dim as u32,
8917            n: n as u32,
8918            num_experts: num_experts as u32,
8919            scheme: *scheme,
8920        }
8921    }
8922}
8923
8924#[allow(unused_variables)]
8925fn compile_top_k(
8926    node: &rlx_ir::Node,
8927    graph: &Graph,
8928    arena: &crate::arena::Arena,
8929    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8930    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8931    rng: rlx_ir::RngOptions,
8932) -> Thunk {
8933    let Op::TopK { k } = &node.op else {
8934        unreachable!()
8935    };
8936    {
8937        let in_shape = &graph.node(node.inputs[0]).shape;
8938        let rank = in_shape.rank();
8939        let axis_dim = in_shape.dim(rank - 1).unwrap_static();
8940        let outer = in_shape.num_elements().unwrap() / axis_dim;
8941        let indices_i64 = u8::from(graph.node(node.id).shape.dtype() == rlx_ir::DType::I64);
8942        Thunk::TopK {
8943            src: node_offset(arena, node.inputs[0]),
8944            dst: node_offset(arena, node.id),
8945            outer: outer as u32,
8946            axis_dim: axis_dim as u32,
8947            k: *k as u32,
8948            indices_i64,
8949        }
8950    }
8951}
8952
8953#[allow(unused_variables)]
8954fn compile_reduce(
8955    node: &rlx_ir::Node,
8956    graph: &Graph,
8957    arena: &crate::arena::Arena,
8958    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
8959    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
8960    rng: rlx_ir::RngOptions,
8961) -> Thunk {
8962    let Op::Reduce {
8963        op,
8964        axes,
8965        keep_dim: _,
8966    } = &node.op
8967    else {
8968        unreachable!()
8969    };
8970    {
8971        // Decompose the input shape into [outer, reduced, inner]
8972        // around the reduced axis range. Non-contiguous reduced
8973        // axes aren't supported here — caller must transpose them
8974        // contiguous first (the coverage tool would surface the
8975        // gap if a model needs it).
8976        let in_shape = &graph.node(node.inputs[0]).shape;
8977        let rank = in_shape.rank();
8978        let mut sorted = axes.clone();
8979        sorted.sort();
8980        sorted.dedup();
8981        let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1)
8982            && !sorted.is_empty()
8983            && *sorted.last().unwrap() < rank;
8984        if !contiguous {
8985            Thunk::Nop
8986        } else {
8987            let first = sorted[0];
8988            let last = *sorted.last().unwrap();
8989            let outer: usize = (0..first)
8990                .map(|i| in_shape.dim(i).unwrap_static())
8991                .product::<usize>()
8992                .max(1);
8993            let reduced: usize = (first..=last)
8994                .map(|i| in_shape.dim(i).unwrap_static())
8995                .product();
8996            let inner: usize = (last + 1..rank)
8997                .map(|i| in_shape.dim(i).unwrap_static())
8998                .product::<usize>()
8999                .max(1);
9000            let src = node_offset(arena, node.inputs[0]);
9001            let dst = node_offset(arena, node.id);
9002            if node.shape.dtype() == rlx_ir::DType::F64 && matches!(op, ReduceOp::Sum) {
9003                Thunk::ReduceSumF64 {
9004                    src,
9005                    dst,
9006                    outer: outer as u32,
9007                    reduced: reduced as u32,
9008                    inner: inner as u32,
9009                }
9010            } else {
9011                Thunk::Reduce {
9012                    src,
9013                    dst,
9014                    outer: outer as u32,
9015                    reduced: reduced as u32,
9016                    inner: inner as u32,
9017                    op: *op,
9018                }
9019            }
9020        }
9021    }
9022}
9023
9024#[allow(unused_variables)]
9025fn compile_compare(
9026    node: &rlx_ir::Node,
9027    graph: &Graph,
9028    arena: &crate::arena::Arena,
9029    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9030    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9031    rng: rlx_ir::RngOptions,
9032) -> Thunk {
9033    let Op::Compare(cmp) = &node.op else {
9034        unreachable!()
9035    };
9036    {
9037        let len = node.shape.num_elements().unwrap();
9038        let in_dtype = graph.node(node.inputs[0]).shape.dtype();
9039        let inputs_i64 = u8::from(in_dtype == rlx_ir::DType::I64);
9040        Thunk::Compare {
9041            lhs: node_offset(arena, node.inputs[0]),
9042            rhs: node_offset(arena, node.inputs[1]),
9043            dst: node_offset(arena, node.id),
9044            len: len as u32,
9045            op: *cmp,
9046            inputs_i64,
9047            inputs_elem_bytes: in_dtype.size_bytes() as u8,
9048            dst_elem_bytes: node.shape.dtype().size_bytes() as u8,
9049        }
9050    }
9051}
9052
9053#[allow(unused_variables)]
9054fn compile_where(
9055    node: &rlx_ir::Node,
9056    graph: &Graph,
9057    arena: &crate::arena::Arena,
9058    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9059    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9060    rng: rlx_ir::RngOptions,
9061) -> Thunk {
9062    let Op::Where = &node.op else { unreachable!() };
9063    {
9064        let len = node.shape.num_elements().unwrap();
9065        let elem_bytes = node.shape.dtype().size_bytes() as u8;
9066        let cond_elem_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
9067        Thunk::Where {
9068            cond: node_offset(arena, node.inputs[0]),
9069            on_true: node_offset(arena, node.inputs[1]),
9070            on_false: node_offset(arena, node.inputs[2]),
9071            dst: node_offset(arena, node.id),
9072            len: len as u32,
9073            elem_bytes,
9074            cond_elem_bytes,
9075        }
9076    }
9077}
9078
9079#[allow(unused_variables)]
9080fn compile_fma(
9081    node: &rlx_ir::Node,
9082    graph: &Graph,
9083    arena: &crate::arena::Arena,
9084    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9085    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9086    rng: rlx_ir::RngOptions,
9087) -> Thunk {
9088    let Op::Fma = &node.op else { unreachable!() };
9089    {
9090        let len = node.shape.num_elements().unwrap();
9091        Thunk::Fma {
9092            a: node_offset(arena, node.inputs[0]),
9093            b: node_offset(arena, node.inputs[1]),
9094            c: node_offset(arena, node.inputs[2]),
9095            dst: node_offset(arena, node.id),
9096            len: len as u32,
9097            elem_bytes: node.shape.dtype().size_bytes() as u8,
9098        }
9099    }
9100}
9101
9102#[allow(unused_variables)]
9103fn compile_relu_backward(
9104    node: &rlx_ir::Node,
9105    graph: &Graph,
9106    arena: &crate::arena::Arena,
9107    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9108    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9109    rng: rlx_ir::RngOptions,
9110) -> Thunk {
9111    let Op::ReluBackward = &node.op else {
9112        unreachable!()
9113    };
9114    {
9115        let len: usize = (0..node.shape.rank())
9116            .map(|i| node.shape.dim(i).unwrap_static())
9117            .product();
9118        let x = node_offset(arena, node.inputs[0]);
9119        let dy = node_offset(arena, node.inputs[1]);
9120        let dx = node_offset(arena, node.id);
9121        match node.shape.dtype() {
9122            rlx_ir::DType::F64 => Thunk::ReluBackwardF64 {
9123                x,
9124                dy,
9125                dx,
9126                len: len as u32,
9127            },
9128            _ => Thunk::ReluBackward {
9129                x,
9130                dy,
9131                dx,
9132                len: len as u32,
9133            },
9134        }
9135    }
9136}
9137
9138#[allow(unused_variables)]
9139fn compile_complex_norm_sq(
9140    node: &rlx_ir::Node,
9141    graph: &Graph,
9142    arena: &crate::arena::Arena,
9143    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9144    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9145    rng: rlx_ir::RngOptions,
9146) -> Thunk {
9147    let Op::ComplexNormSq = &node.op else {
9148        unreachable!()
9149    };
9150    {
9151        let len: usize = (0..node.shape.rank())
9152            .map(|i| node.shape.dim(i).unwrap_static())
9153            .product();
9154        let src = node_offset(arena, node.inputs[0]);
9155        let dst = node_offset(arena, node.id);
9156        Thunk::ComplexNormSqF32 {
9157            src,
9158            dst,
9159            len: len as u32,
9160        }
9161    }
9162}
9163
9164#[allow(unused_variables)]
9165fn compile_complex_norm_sq_backward(
9166    node: &rlx_ir::Node,
9167    graph: &Graph,
9168    arena: &crate::arena::Arena,
9169    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9170    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9171    rng: rlx_ir::RngOptions,
9172) -> Thunk {
9173    let Op::ComplexNormSqBackward = &node.op else {
9174        unreachable!()
9175    };
9176    {
9177        let len: usize = (0..node.shape.rank())
9178            .map(|i| node.shape.dim(i).unwrap_static())
9179            .product();
9180        let z = node_offset(arena, node.inputs[0]);
9181        let g = node_offset(arena, node.inputs[1]);
9182        let dz = node_offset(arena, node.id);
9183        Thunk::ComplexNormSqBackwardF32 {
9184            z,
9185            g,
9186            dz,
9187            len: len as u32,
9188        }
9189    }
9190}
9191
9192#[allow(unused_variables)]
9193fn compile_conjugate(
9194    node: &rlx_ir::Node,
9195    graph: &Graph,
9196    arena: &crate::arena::Arena,
9197    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9198    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9199    rng: rlx_ir::RngOptions,
9200) -> Thunk {
9201    let Op::Conjugate = &node.op else {
9202        unreachable!()
9203    };
9204    {
9205        let len: usize = (0..node.shape.rank())
9206            .map(|i| node.shape.dim(i).unwrap_static())
9207            .product();
9208        Thunk::ConjugateC64 {
9209            src: node_offset(arena, node.inputs[0]),
9210            dst: node_offset(arena, node.id),
9211            len: len as u32,
9212        }
9213    }
9214}
9215
9216#[allow(unused_variables)]
9217fn compile_activation_backward(
9218    node: &rlx_ir::Node,
9219    graph: &Graph,
9220    arena: &crate::arena::Arena,
9221    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9222    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9223    rng: rlx_ir::RngOptions,
9224) -> Thunk {
9225    let Op::ActivationBackward { kind } = &node.op else {
9226        unreachable!()
9227    };
9228    {
9229        let len: usize = (0..node.shape.rank())
9230            .map(|i| node.shape.dim(i).unwrap_static())
9231            .product();
9232        let x = node_offset(arena, node.inputs[0]);
9233        let dy = node_offset(arena, node.inputs[1]);
9234        let dx = node_offset(arena, node.id);
9235        match node.shape.dtype() {
9236            rlx_ir::DType::F64 => Thunk::ActivationBackwardF64 {
9237                x,
9238                dy,
9239                dx,
9240                len: len as u32,
9241                kind: *kind,
9242            },
9243            _ => Thunk::ActivationBackward {
9244                x,
9245                dy,
9246                dx,
9247                len: len as u32,
9248                kind: *kind,
9249            },
9250        }
9251    }
9252}
9253
9254#[allow(unused_variables)]
9255fn compile_layer_norm_backward_input(
9256    node: &rlx_ir::Node,
9257    graph: &Graph,
9258    arena: &crate::arena::Arena,
9259    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9260    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9261    rng: rlx_ir::RngOptions,
9262) -> Thunk {
9263    let Op::LayerNormBackwardInput { eps, .. } = &node.op else {
9264        unreachable!()
9265    };
9266    {
9267        // axis = -1 only (matches forward LayerNorm thunk).
9268        let h = node.shape.dim(node.shape.rank() - 1).unwrap_static();
9269        let total = node.shape.num_elements().unwrap();
9270        Thunk::LayerNormBackwardInput {
9271            x: node_offset(arena, node.inputs[0]),
9272            gamma: node_offset(arena, node.inputs[1]),
9273            dy: node_offset(arena, node.inputs[2]),
9274            dx: node_offset(arena, node.id),
9275            rows: (total / h) as u32,
9276            h: h as u32,
9277            eps: *eps,
9278        }
9279    }
9280}
9281
9282#[allow(unused_variables)]
9283fn compile_layer_norm_backward_gamma(
9284    node: &rlx_ir::Node,
9285    graph: &Graph,
9286    arena: &crate::arena::Arena,
9287    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9288    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9289    rng: rlx_ir::RngOptions,
9290) -> Thunk {
9291    let Op::LayerNormBackwardGamma { eps, .. } = &node.op else {
9292        unreachable!()
9293    };
9294    {
9295        let x_shape = &graph.node(node.inputs[0]).shape;
9296        let h = x_shape.dim(x_shape.rank() - 1).unwrap_static();
9297        let x_total = x_shape.num_elements().unwrap();
9298        Thunk::LayerNormBackwardGamma {
9299            x: node_offset(arena, node.inputs[0]),
9300            dy: node_offset(arena, node.inputs[1]),
9301            dgamma: node_offset(arena, node.id),
9302            rows: (x_total / h) as u32,
9303            h: h as u32,
9304            eps: *eps,
9305        }
9306    }
9307}
9308
9309#[allow(unused_variables)]
9310fn compile_rope_backward(
9311    node: &rlx_ir::Node,
9312    graph: &Graph,
9313    arena: &crate::arena::Arena,
9314    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9315    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9316    rng: rlx_ir::RngOptions,
9317) -> Thunk {
9318    let Op::RopeBackward { head_dim, n_rot } = &node.op else {
9319        unreachable!()
9320    };
9321    {
9322        let dy_shape = &graph.node(node.inputs[0]).shape;
9323        let (batch, seq, hidden) = if dy_shape.rank() >= 3 {
9324            (
9325                dy_shape.dim(0).unwrap_static(),
9326                dy_shape.dim(1).unwrap_static(),
9327                dy_shape.dim(2).unwrap_static(),
9328            )
9329        } else {
9330            (
9331                1,
9332                dy_shape.dim(0).unwrap_static(),
9333                dy_shape.dim(1).unwrap_static(),
9334            )
9335        };
9336        let cos_shape = &graph.node(node.inputs[1]).shape;
9337        let cos_len = cos_shape.num_elements().unwrap();
9338        Thunk::RopeBackward {
9339            dy: node_offset(arena, node.inputs[0]),
9340            cos: node_offset(arena, node.inputs[1]),
9341            sin: node_offset(arena, node.inputs[2]),
9342            dx: node_offset(arena, node.id),
9343            batch: batch as u32,
9344            seq: seq as u32,
9345            hidden: hidden as u32,
9346            head_dim: *head_dim as u32,
9347            n_rot: *n_rot as u32,
9348            cos_len: cos_len as u32,
9349        }
9350    }
9351}
9352
9353#[allow(unused_variables)]
9354fn compile_cumsum_backward(
9355    node: &rlx_ir::Node,
9356    graph: &Graph,
9357    arena: &crate::arena::Arena,
9358    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9359    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9360    rng: rlx_ir::RngOptions,
9361) -> Thunk {
9362    let Op::CumsumBackward { exclusive, .. } = &node.op else {
9363        unreachable!()
9364    };
9365    {
9366        let dy_shape = &graph.node(node.inputs[0]).shape;
9367        let rank = dy_shape.rank();
9368        let cols = dy_shape.dim(rank - 1).unwrap_static();
9369        let rows = dy_shape.num_elements().unwrap() / cols;
9370        Thunk::CumsumBackward {
9371            dy: node_offset(arena, node.inputs[0]),
9372            dx: node_offset(arena, node.id),
9373            rows: rows as u32,
9374            cols: cols as u32,
9375            exclusive: *exclusive,
9376        }
9377    }
9378}
9379
9380#[allow(unused_variables)]
9381fn compile_gather_backward(
9382    node: &rlx_ir::Node,
9383    graph: &Graph,
9384    arena: &crate::arena::Arena,
9385    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9386    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9387    rng: rlx_ir::RngOptions,
9388) -> Thunk {
9389    let Op::GatherBackward { .. } = &node.op else {
9390        unreachable!()
9391    };
9392    {
9393        let dy_shape = &graph.node(node.inputs[0]).shape;
9394        let idx_shape = &graph.node(node.inputs[1]).shape;
9395        let out_shape = &node.shape;
9396        let rank = out_shape.rank();
9397        let axis = match &node.op {
9398            Op::GatherBackward { axis } => *axis,
9399            _ => 0,
9400        };
9401        let axis_u = if axis < 0 {
9402            (rank as i32 + axis) as usize
9403        } else {
9404            axis as usize
9405        };
9406        let outer: usize = (0..axis_u)
9407            .map(|i| dy_shape.dim(i).unwrap_static())
9408            .product::<usize>()
9409            .max(1);
9410        let num_idx = idx_shape.dim(axis_u).unwrap_static();
9411        let trailing: usize = (axis_u + 1..dy_shape.rank())
9412            .map(|i| dy_shape.dim(i).unwrap_static())
9413            .product::<usize>()
9414            .max(1);
9415        let axis_dim = out_shape.dim(axis_u).unwrap_static();
9416        Thunk::GatherBackward {
9417            dy: node_offset(arena, node.inputs[0]),
9418            indices: node_offset(arena, node.inputs[1]),
9419            dst: node_offset(arena, node.id),
9420            outer: outer as u32,
9421            axis_dim: axis_dim as u32,
9422            num_idx: num_idx as u32,
9423            trailing: trailing as u32,
9424        }
9425    }
9426}
9427
9428#[allow(unused_variables)]
9429fn compile_max_pool2d_backward(
9430    node: &rlx_ir::Node,
9431    graph: &Graph,
9432    arena: &crate::arena::Arena,
9433    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9434    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9435    rng: rlx_ir::RngOptions,
9436) -> Thunk {
9437    let Op::MaxPool2dBackward {
9438        kernel_size,
9439        stride,
9440        padding,
9441    } = &node.op
9442    else {
9443        unreachable!()
9444    };
9445    {
9446        let x_shape = &graph.node(node.inputs[0]).shape;
9447        let dy_shape = &graph.node(node.inputs[1]).shape;
9448        if kernel_size.len() == 2 && x_shape.rank() == 4 && dy_shape.rank() == 4 {
9449            Thunk::MaxPool2dBackward {
9450                x: node_offset(arena, node.inputs[0]),
9451                dy: node_offset(arena, node.inputs[1]),
9452                dx: node_offset(arena, node.id),
9453                n: x_shape.dim(0).unwrap_static() as u32,
9454                c: x_shape.dim(1).unwrap_static() as u32,
9455                h: x_shape.dim(2).unwrap_static() as u32,
9456                w: x_shape.dim(3).unwrap_static() as u32,
9457                h_out: dy_shape.dim(2).unwrap_static() as u32,
9458                w_out: dy_shape.dim(3).unwrap_static() as u32,
9459                kh: kernel_size[0] as u32,
9460                kw: kernel_size[1] as u32,
9461                sh: stride.first().copied().unwrap_or(1) as u32,
9462                sw: stride.get(1).copied().unwrap_or(1) as u32,
9463                ph: padding.first().copied().unwrap_or(0) as u32,
9464                pw: padding.get(1).copied().unwrap_or(0) as u32,
9465            }
9466        } else {
9467            Thunk::Nop
9468        }
9469    }
9470}
9471
9472#[allow(unused_variables)]
9473fn compile_conv2d_backward_input(
9474    node: &rlx_ir::Node,
9475    graph: &Graph,
9476    arena: &crate::arena::Arena,
9477    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9478    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9479    rng: rlx_ir::RngOptions,
9480) -> Thunk {
9481    let Op::Conv2dBackwardInput {
9482        kernel_size,
9483        stride,
9484        padding,
9485        dilation,
9486        groups,
9487    } = &node.op
9488    else {
9489        unreachable!()
9490    };
9491    {
9492        let dy_shape = &graph.node(node.inputs[0]).shape;
9493        let w_shape = &graph.node(node.inputs[1]).shape;
9494        let out_shape = &node.shape;
9495        if kernel_size.len() == 2
9496            && dy_shape.rank() == 4
9497            && w_shape.rank() == 4
9498            && out_shape.rank() == 4
9499        {
9500            Thunk::Conv2dBackwardInput {
9501                dy: node_offset(arena, node.inputs[0]),
9502                w: node_offset(arena, node.inputs[1]),
9503                dx: node_offset(arena, node.id),
9504                n: out_shape.dim(0).unwrap_static() as u32,
9505                c_in: out_shape.dim(1).unwrap_static() as u32,
9506                h: out_shape.dim(2).unwrap_static() as u32,
9507                w_in: out_shape.dim(3).unwrap_static() as u32,
9508                c_out: dy_shape.dim(1).unwrap_static() as u32,
9509                h_out: dy_shape.dim(2).unwrap_static() as u32,
9510                w_out: dy_shape.dim(3).unwrap_static() as u32,
9511                kh: kernel_size[0] as u32,
9512                kw: kernel_size[1] as u32,
9513                sh: stride.first().copied().unwrap_or(1) as u32,
9514                sw: stride.get(1).copied().unwrap_or(1) as u32,
9515                ph: padding.first().copied().unwrap_or(0) as u32,
9516                pw: padding.get(1).copied().unwrap_or(0) as u32,
9517                dh: dilation.first().copied().unwrap_or(1) as u32,
9518                dw: dilation.get(1).copied().unwrap_or(1) as u32,
9519                groups: *groups as u32,
9520            }
9521        } else {
9522            Thunk::Nop
9523        }
9524    }
9525}
9526
9527#[allow(unused_variables)]
9528fn compile_conv2d_backward_weight(
9529    node: &rlx_ir::Node,
9530    graph: &Graph,
9531    arena: &crate::arena::Arena,
9532    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9533    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9534    rng: rlx_ir::RngOptions,
9535) -> Thunk {
9536    let Op::Conv2dBackwardWeight {
9537        kernel_size,
9538        stride,
9539        padding,
9540        dilation,
9541        groups,
9542    } = &node.op
9543    else {
9544        unreachable!()
9545    };
9546    {
9547        let x_shape = &graph.node(node.inputs[0]).shape;
9548        let dy_shape = &graph.node(node.inputs[1]).shape;
9549        let dw_shape = &node.shape;
9550        if kernel_size.len() == 2
9551            && x_shape.rank() == 4
9552            && dy_shape.rank() == 4
9553            && dw_shape.rank() == 4
9554        {
9555            Thunk::Conv2dBackwardWeight {
9556                x: node_offset(arena, node.inputs[0]),
9557                dy: node_offset(arena, node.inputs[1]),
9558                dw: node_offset(arena, node.id),
9559                n: x_shape.dim(0).unwrap_static() as u32,
9560                c_in: x_shape.dim(1).unwrap_static() as u32,
9561                h: x_shape.dim(2).unwrap_static() as u32,
9562                w: x_shape.dim(3).unwrap_static() as u32,
9563                c_out: dy_shape.dim(1).unwrap_static() as u32,
9564                h_out: dy_shape.dim(2).unwrap_static() as u32,
9565                w_out: dy_shape.dim(3).unwrap_static() as u32,
9566                kh: kernel_size[0] as u32,
9567                kw: kernel_size[1] as u32,
9568                sh: stride.first().copied().unwrap_or(1) as u32,
9569                sw: stride.get(1).copied().unwrap_or(1) as u32,
9570                ph: padding.first().copied().unwrap_or(0) as u32,
9571                pw: padding.get(1).copied().unwrap_or(0) as u32,
9572                dh: dilation.first().copied().unwrap_or(1) as u32,
9573                dw_dil: dilation.get(1).copied().unwrap_or(1) as u32,
9574                groups: *groups as u32,
9575            }
9576        } else {
9577            Thunk::Nop
9578        }
9579    }
9580}
9581
9582#[allow(unused_variables)]
9583fn compile_im2_col(
9584    node: &rlx_ir::Node,
9585    graph: &Graph,
9586    arena: &crate::arena::Arena,
9587    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9588    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9589    rng: rlx_ir::RngOptions,
9590) -> Thunk {
9591    let Op::Im2Col {
9592        kernel_size,
9593        stride,
9594        padding,
9595        dilation,
9596    } = &node.op
9597    else {
9598        unreachable!()
9599    };
9600    {
9601        let x_shape = &graph.node(node.inputs[0]).shape;
9602        let out_shape = &node.shape;
9603        if kernel_size.len() == 2 && x_shape.rank() == 4 && out_shape.rank() == 2 {
9604            let n = match x_shape.dim(0) {
9605                rlx_ir::shape::Dim::Static(v) => v as u32,
9606                _ => 0,
9607            };
9608            let c_in = x_shape.dim(1).unwrap_static() as u32;
9609            let h = x_shape.dim(2).unwrap_static() as u32;
9610            let w = x_shape.dim(3).unwrap_static() as u32;
9611            let kh = kernel_size[0] as u32;
9612            let kw = kernel_size[1] as u32;
9613            let sh = stride.first().copied().unwrap_or(1) as u32;
9614            let sw = stride.get(1).copied().unwrap_or(1) as u32;
9615            let ph = padding.first().copied().unwrap_or(0) as u32;
9616            let pw = padding.get(1).copied().unwrap_or(0) as u32;
9617            let dh = dilation.first().copied().unwrap_or(1) as u32;
9618            let dw_dil = dilation.get(1).copied().unwrap_or(1) as u32;
9619            let h_out = rlx_ir::shape::conv2d_spatial_output(
9620                h as usize,
9621                kh as usize,
9622                sh as usize,
9623                ph as usize,
9624                dh as usize,
9625            ) as u32;
9626            let w_out = rlx_ir::shape::conv2d_spatial_output(
9627                w as usize,
9628                kw as usize,
9629                sw as usize,
9630                pw as usize,
9631                dw_dil as usize,
9632            ) as u32;
9633            Thunk::Im2Col {
9634                x: node_offset(arena, node.inputs[0]),
9635                col: node_offset(arena, node.id),
9636                n,
9637                c_in,
9638                h,
9639                w,
9640                h_out,
9641                w_out,
9642                kh,
9643                kw,
9644                sh,
9645                sw,
9646                ph,
9647                pw,
9648                dh,
9649                dw_dil,
9650            }
9651        } else {
9652            Thunk::Nop
9653        }
9654    }
9655}
9656
9657#[allow(unused_variables)]
9658fn compile_softmax_cross_entropy(
9659    node: &rlx_ir::Node,
9660    graph: &Graph,
9661    arena: &crate::arena::Arena,
9662    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9663    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9664    rng: rlx_ir::RngOptions,
9665) -> Thunk {
9666    let Op::SoftmaxCrossEntropy = &node.op else {
9667        unreachable!()
9668    };
9669    {
9670        let logits_shape = &graph.node(node.inputs[0]).shape;
9671        if logits_shape.rank() == 2 {
9672            Thunk::SoftmaxCrossEntropyDense {
9673                logits: node_offset(arena, node.inputs[0]),
9674                targets: node_offset(arena, node.inputs[1]),
9675                dst: node_offset(arena, node.id),
9676                n: logits_shape.dim(0).unwrap_static() as u32,
9677                c: logits_shape.dim(1).unwrap_static() as u32,
9678            }
9679        } else {
9680            Thunk::Nop
9681        }
9682    }
9683}
9684
9685#[allow(unused_variables)]
9686fn compile_softmax_cross_entropy_with_logits(
9687    node: &rlx_ir::Node,
9688    graph: &Graph,
9689    arena: &crate::arena::Arena,
9690    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9691    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9692    rng: rlx_ir::RngOptions,
9693) -> Thunk {
9694    let Op::SoftmaxCrossEntropyWithLogits = &node.op else {
9695        unreachable!()
9696    };
9697    {
9698        let logits_shape = &graph.node(node.inputs[0]).shape;
9699        if logits_shape.rank() == 2 {
9700            Thunk::SoftmaxCrossEntropy {
9701                logits: node_offset(arena, node.inputs[0]),
9702                labels: node_offset(arena, node.inputs[1]),
9703                dst: node_offset(arena, node.id),
9704                n: logits_shape.dim(0).unwrap_static() as u32,
9705                c: logits_shape.dim(1).unwrap_static() as u32,
9706            }
9707        } else {
9708            Thunk::Nop
9709        }
9710    }
9711}
9712
9713#[allow(unused_variables)]
9714fn compile_softmax_cross_entropy_backward(
9715    node: &rlx_ir::Node,
9716    graph: &Graph,
9717    arena: &crate::arena::Arena,
9718    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9719    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9720    rng: rlx_ir::RngOptions,
9721) -> Thunk {
9722    let Op::SoftmaxCrossEntropyBackward = &node.op else {
9723        unreachable!()
9724    };
9725    {
9726        let logits_shape = &graph.node(node.inputs[0]).shape;
9727        if logits_shape.rank() == 2 {
9728            Thunk::SoftmaxCrossEntropyBackward {
9729                logits: node_offset(arena, node.inputs[0]),
9730                labels: node_offset(arena, node.inputs[1]),
9731                d_loss: node_offset(arena, node.inputs[2]),
9732                dlogits: node_offset(arena, node.id),
9733                n: logits_shape.dim(0).unwrap_static() as u32,
9734                c: logits_shape.dim(1).unwrap_static() as u32,
9735            }
9736        } else {
9737            Thunk::Nop
9738        }
9739    }
9740}
9741
9742#[allow(unused_variables)]
9743fn compile_dense_solve(
9744    node: &rlx_ir::Node,
9745    graph: &Graph,
9746    arena: &crate::arena::Arena,
9747    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9748    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9749    rng: rlx_ir::RngOptions,
9750) -> Thunk {
9751    let Op::DenseSolve = &node.op else {
9752        unreachable!()
9753    };
9754    {
9755        // A: [n, n], b: [n] or [n, nrhs]. Output matches b.
9756        let a_shape = &graph.node(node.inputs[0]).shape;
9757        let n = a_shape.dim(0).unwrap_static();
9758        debug_assert_eq!(
9759            n,
9760            a_shape.dim(1).unwrap_static(),
9761            "DenseSolve: A must be square"
9762        );
9763        let b_elems = node.shape.num_elements().unwrap();
9764        let nrhs = b_elems / n;
9765        match node.shape.dtype() {
9766            rlx_ir::DType::F64 => Thunk::DenseSolveF64 {
9767                a: node_offset(arena, node.inputs[0]),
9768                b: node_offset(arena, node.inputs[1]),
9769                x: node_offset(arena, node.id),
9770                n: n as u32,
9771                nrhs: nrhs as u32,
9772            },
9773            rlx_ir::DType::F32 => Thunk::DenseSolveF32 {
9774                a: node_offset(arena, node.inputs[0]),
9775                b: node_offset(arena, node.inputs[1]),
9776                x: node_offset(arena, node.id),
9777                n: n as u32,
9778                nrhs: nrhs as u32,
9779            },
9780            other => panic!(
9781                "DenseSolve: F32 + F64 lowered; got {other:?}. \
9782                         Add another variant when needed."
9783            ),
9784        }
9785    }
9786}
9787
9788#[allow(unused_variables)]
9789fn compile_batched_dense_solve(
9790    node: &rlx_ir::Node,
9791    graph: &Graph,
9792    arena: &crate::arena::Arena,
9793    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9794    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9795    rng: rlx_ir::RngOptions,
9796) -> Thunk {
9797    let Op::BatchedDenseSolve = &node.op else {
9798        unreachable!()
9799    };
9800    {
9801        // A: [B, N, N], b: [B, N] or [B, N, K]. Output matches b.
9802        let a_shape = &graph.node(node.inputs[0]).shape;
9803        assert_eq!(a_shape.rank(), 3, "BatchedDenseSolve: A rank must be 3");
9804        let batch = a_shape.dim(0).unwrap_static();
9805        let n = a_shape.dim(1).unwrap_static();
9806        debug_assert_eq!(
9807            n,
9808            a_shape.dim(2).unwrap_static(),
9809            "BatchedDenseSolve: A's last two dims must match"
9810        );
9811        let total = node.shape.num_elements().unwrap();
9812        let nrhs = total / (batch * n);
9813        match node.shape.dtype() {
9814            rlx_ir::DType::F32 => Thunk::BatchedDenseSolveF32 {
9815                a: node_offset(arena, node.inputs[0]),
9816                b: node_offset(arena, node.inputs[1]),
9817                x: node_offset(arena, node.id),
9818                batch: batch as u32,
9819                n: n as u32,
9820                nrhs: nrhs as u32,
9821            },
9822            rlx_ir::DType::F64 => Thunk::BatchedDenseSolveF64 {
9823                a: node_offset(arena, node.inputs[0]),
9824                b: node_offset(arena, node.inputs[1]),
9825                x: node_offset(arena, node.id),
9826                batch: batch as u32,
9827                n: n as u32,
9828                nrhs: nrhs as u32,
9829            },
9830            other => panic!("BatchedDenseSolve: F32 + F64 only, got {other:?}"),
9831        }
9832    }
9833}
9834
9835#[allow(unused_variables)]
9836fn compile_scan(
9837    node: &rlx_ir::Node,
9838    graph: &Graph,
9839    arena: &crate::arena::Arena,
9840    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
9841    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
9842    rng: rlx_ir::RngOptions,
9843) -> Thunk {
9844    let Op::Scan {
9845        body,
9846        length,
9847        save_trajectory,
9848        num_bcast,
9849        num_xs,
9850        num_checkpoints,
9851    } = &node.op
9852    else {
9853        unreachable!()
9854    };
9855    {
9856        assert!(
9857            *num_checkpoints == 0 || *num_checkpoints <= *length,
9858            "Op::Scan: num_checkpoints={} must be 0 or ≤ length={}",
9859            *num_checkpoints,
9860            *length
9861        );
9862        if *num_checkpoints != 0 && *num_checkpoints != *length {
9863            assert!(
9864                *save_trajectory,
9865                "Op::Scan: num_checkpoints<length only meaningful when save_trajectory=true"
9866            );
9867        }
9868        // Plan + compile the body sub-graph standalone. The body
9869        // gets its own Arena; per execution we clone its
9870        // pristine bytes, copy the outer carry (and per-step xs
9871        // slices, if any) into the body's Input slots, run the
9872        // body schedule N times, then copy the body's output
9873        // back to the outer arena.
9874        //
9875        // Body invariants: 1 + num_xs Op::Inputs in NodeId order
9876        // — first declared is the carry, rest are x_t_i. Single
9877        // graph output (the next carry), same shape as carry.
9878        let body_plan = rlx_opt::memory::plan_memory(body);
9879        let _body_arena_size = body_plan.arena_size;
9880        // Snapshot per-input byte offsets before plan_memory
9881        // moves into the Arena below.
9882        let body_offsets: HashMap<NodeId, usize> = body_plan
9883            .assignments
9884            .iter()
9885            .map(|(id, slot)| (*id, slot.offset))
9886            .collect();
9887
9888        // Collect body Input nodes in NodeId order; first is
9889        // carry, rest are per-step xs in matching order.
9890        let mut body_inputs: Vec<NodeId> = body
9891            .nodes()
9892            .iter()
9893            .filter(|n| matches!(n.op, Op::Input { .. }))
9894            .map(|n| n.id)
9895            .collect();
9896        body_inputs.sort();
9897        let n_body_inputs = body_inputs.len();
9898        let expected = 1 + *num_bcast as usize + *num_xs as usize;
9899        if n_body_inputs != expected {
9900            let names: Vec<String> = body
9901                .nodes()
9902                .iter()
9903                .filter_map(|n| match &n.op {
9904                    Op::Input { name } => Some(format!("{}={}", n.id, name)),
9905                    _ => None,
9906                })
9907                .collect();
9908            panic!(
9909                "Op::Scan body has {} Op::Input nodes; expected {} \
9910                            (1 carry + {} bcast + {} xs). Inputs by NodeId: [{}]",
9911                n_body_inputs,
9912                expected,
9913                *num_bcast,
9914                *num_xs,
9915                names.join(", ")
9916            );
9917        }
9918
9919        let body_input_id = body_inputs[0];
9920        let body_input_off = body_offsets[&body_input_id];
9921        let body_output_id = body
9922            .outputs
9923            .first()
9924            .copied()
9925            .expect("Op::Scan body must declare one output");
9926        let body_output_off = body_offsets[&body_output_id];
9927
9928        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
9929        // Fill body Constant nodes — mirror the outer-graph logic
9930        // in rlx-runtime/src/backend.rs (dtype-aware).
9931        for n in body.nodes() {
9932            if let Op::Constant { data } = &n.op
9933                && body_arena.has_buffer(n.id)
9934                && !data.is_empty()
9935            {
9936                match n.shape.dtype() {
9937                    rlx_ir::DType::F64 => {
9938                        let off = body_arena.byte_offset(n.id);
9939                        let buf = body_arena.raw_buf_mut();
9940                        let nbytes = (buf.len() - off).min(data.len());
9941                        buf[off..off + nbytes].copy_from_slice(&data[..nbytes]);
9942                    }
9943                    _ => {
9944                        let buf = body_arena.slice_mut(n.id);
9945                        let n_floats = data.len() / 4;
9946                        let n_lim = buf.len().min(n_floats);
9947                        for i in 0..n_lim {
9948                            let bytes = [
9949                                data[i * 4],
9950                                data[i * 4 + 1],
9951                                data[i * 4 + 2],
9952                                data[i * 4 + 3],
9953                            ];
9954                            buf[i] = f32::from_le_bytes(bytes);
9955                        }
9956                    }
9957                }
9958            }
9959        }
9960        let body_init = body_arena.raw_buf().to_vec();
9961        let body_schedule = compile_thunks_with_rng(body, &body_arena, rng);
9962
9963        // Carry bytes — for trajectory mode, the outer node's
9964        // shape is [length, *carry_shape], so dividing by length
9965        // gives one row's bytes; the body's input slot still
9966        // holds carry_shape bytes.
9967        let carry_bytes = if *save_trajectory {
9968            let total = node
9969                .shape
9970                .size_bytes()
9971                .expect("Op::Scan trajectory output must have static shape");
9972            total / *length as usize
9973        } else {
9974            node.shape
9975                .size_bytes()
9976                .expect("Op::Scan carry must have static shape")
9977        };
9978
9979        // Bcast inputs occupy body_inputs[1..1+num_bcast] and
9980        // outer node.inputs[1..1+num_bcast]. They keep their
9981        // natural shape (no [length, ...] prefix) and are
9982        // copied into body_buf ONCE before the scan loop.
9983        let mut bcast_inputs: Vec<(usize, usize, u32)> = Vec::with_capacity(*num_bcast as usize);
9984        for i in 0..*num_bcast as usize {
9985            let body_b_id = body_inputs[1 + i];
9986            let body_b_off = body_offsets[&body_b_id];
9987            let outer_b_id = node.inputs[1 + i];
9988            let outer_b_off = node_offset(arena, outer_b_id);
9989            let outer_b_shape = &graph.node(outer_b_id).shape;
9990            let total = outer_b_shape
9991                .size_bytes()
9992                .expect("Op::Scan bcast must have static shape");
9993            bcast_inputs.push((body_b_off, outer_b_off, total as u32));
9994        }
9995
9996        // xs occupy body_inputs[1+num_bcast..] and node.inputs
9997        // [1+num_bcast..]. Each has shape [length, *per_step];
9998        // per-step bytes = total / length.
9999        let mut xs_inputs: Vec<(usize, usize, u32)> = Vec::with_capacity(*num_xs as usize);
10000        let xs_base = 1 + *num_bcast as usize;
10001        for i in 0..*num_xs as usize {
10002            let body_x_id = body_inputs[xs_base + i];
10003            let body_x_off = body_offsets[&body_x_id];
10004            let outer_xs_id = node.inputs[xs_base + i];
10005            let outer_xs_off = node_offset(arena, outer_xs_id);
10006            let outer_xs_shape = &graph.node(outer_xs_id).shape;
10007            let total = outer_xs_shape
10008                .size_bytes()
10009                .expect("Op::Scan xs must have static shape");
10010            let per_step = total / *length as usize;
10011            xs_inputs.push((body_x_off, outer_xs_off, per_step as u32));
10012        }
10013
10014        Thunk::Scan {
10015            body: Arc::new(body_schedule),
10016            body_init: Arc::new(body_init),
10017            body_input_off,
10018            body_output_off,
10019            outer_init_off: node_offset(arena, node.inputs[0]),
10020            outer_final_off: node_offset(arena, node.id),
10021            length: *length,
10022            carry_bytes: carry_bytes as u32,
10023            save_trajectory: *save_trajectory,
10024            xs_inputs: Arc::new(xs_inputs),
10025            bcast_inputs: Arc::new(bcast_inputs),
10026            num_checkpoints: *num_checkpoints,
10027        }
10028    }
10029}
10030
10031#[allow(unused_variables)]
10032fn compile_scan_backward(
10033    node: &rlx_ir::Node,
10034    graph: &Graph,
10035    arena: &crate::arena::Arena,
10036    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10037    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10038    rng: rlx_ir::RngOptions,
10039) -> Thunk {
10040    let Op::ScanBackward {
10041        body_vjp,
10042        length,
10043        save_trajectory,
10044        num_xs,
10045        num_checkpoints,
10046        forward_body,
10047    } = &node.op
10048    else {
10049        unreachable!()
10050    };
10051    {
10052        let is_recursive = *num_checkpoints != 0 && *num_checkpoints != *length;
10053        if is_recursive {
10054            assert!(
10055                forward_body.is_some(),
10056                "Op::ScanBackward with num_checkpoints<length requires forward_body"
10057            );
10058        }
10059        // body_vjp has signature
10060        //   (carry, x_t_0, ..., x_t_{num_xs-1}, d_output) → dcarry
10061        // Identify slots:
10062        //   * "d_output" by exact name (AD-introduced seed Input).
10063        //   * Remaining Inputs sorted by NodeId — first is the
10064        //     carry mirror, rest are x_t_i mirrors in body's
10065        //     original Op::Input declaration order.
10066        let body_plan = rlx_opt::memory::plan_memory(body_vjp);
10067        let body_offsets: HashMap<NodeId, usize> = body_plan
10068            .assignments
10069            .iter()
10070            .map(|(id, slot)| (*id, slot.offset))
10071            .collect();
10072        let mut body_d_output_off: Option<usize> = None;
10073        let mut body_other_inputs: Vec<(NodeId, usize)> = Vec::new();
10074        for n in body_vjp.nodes() {
10075            if let Op::Input { name } = &n.op {
10076                let off = body_offsets[&n.id];
10077                if name == "d_output" {
10078                    body_d_output_off = Some(off);
10079                } else {
10080                    body_other_inputs.push((n.id, off));
10081                }
10082            }
10083        }
10084        body_other_inputs.sort_by_key(|(id, _)| *id);
10085        let body_d_output_off =
10086            body_d_output_off.expect("ScanBackward body_vjp missing 'd_output' Input");
10087        let expected_others = 1 + *num_xs as usize;
10088        assert_eq!(
10089            body_other_inputs.len(),
10090            expected_others,
10091            "ScanBackward body_vjp has {} non-d_output Inputs; \
10092                     expected {} (1 carry + {} xs)",
10093            body_other_inputs.len(),
10094            expected_others,
10095            num_xs
10096        );
10097        let body_carry_in_off = body_other_inputs[0].1;
10098        let body_x_offs: Vec<usize> = body_other_inputs
10099            .iter()
10100            .skip(1)
10101            .map(|(_, off)| *off)
10102            .collect();
10103        let body_dcarry_out_off = body_offsets[&body_vjp.outputs[0]];
10104
10105        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
10106        // Fill body_vjp's Constants (mirrors the Scan lowering).
10107        for n in body_vjp.nodes() {
10108            if let Op::Constant { data } = &n.op
10109                && body_arena.has_buffer(n.id)
10110                && !data.is_empty()
10111            {
10112                match n.shape.dtype() {
10113                    rlx_ir::DType::F64 => {
10114                        let off = body_arena.byte_offset(n.id);
10115                        let buf = body_arena.raw_buf_mut();
10116                        let nb = (buf.len() - off).min(data.len());
10117                        buf[off..off + nb].copy_from_slice(&data[..nb]);
10118                    }
10119                    _ => {
10120                        let buf = body_arena.slice_mut(n.id);
10121                        let nf = data.len() / 4;
10122                        let nl = buf.len().min(nf);
10123                        for i in 0..nl {
10124                            let bytes = [
10125                                data[i * 4],
10126                                data[i * 4 + 1],
10127                                data[i * 4 + 2],
10128                                data[i * 4 + 3],
10129                            ];
10130                            buf[i] = f32::from_le_bytes(bytes);
10131                        }
10132                    }
10133                }
10134            }
10135        }
10136        let body_init = body_arena.raw_buf().to_vec();
10137        let body_schedule = compile_thunks_with_rng(body_vjp, &body_arena, rng);
10138
10139        // Carry bytes from the dcarry output node (== carry shape).
10140        let carry_bytes = body_vjp
10141            .node(body_vjp.outputs[0])
10142            .shape
10143            .size_bytes()
10144            .expect("ScanBackward dcarry must be statically shaped");
10145        let carry_elem_size = body_vjp
10146            .node(body_vjp.outputs[0])
10147            .shape
10148            .dtype()
10149            .size_bytes() as u32;
10150
10151        // For each xs input on the outer node:
10152        // (outer_xs_base, per_step_bytes).
10153        let mut outer_xs_offs: Vec<(usize, u32)> = Vec::with_capacity(*num_xs as usize);
10154        for i in 0..*num_xs as usize {
10155            let outer_xs_id = node.inputs[3 + i];
10156            let outer_xs_off = node_offset(arena, outer_xs_id);
10157            let outer_xs_shape = &graph.node(outer_xs_id).shape;
10158            let total = outer_xs_shape
10159                .size_bytes()
10160                .expect("ScanBackward xs must have static shape");
10161            let per_step = total / *length as usize;
10162            outer_xs_offs.push((outer_xs_off, per_step as u32));
10163        }
10164
10165        // If recursive checkpointing is active, we also compile
10166        // the forward body so the executor can recompute
10167        // intermediate carries. The forward body is supplied
10168        // by the AD pass via `forward_body: Some(_)`.
10169        let (fb_schedule, fb_init, fb_carry_in_off, fb_output_off, fb_x_offs) = if is_recursive {
10170            let fb = forward_body.as_ref().unwrap();
10171            let fb_plan = rlx_opt::memory::plan_memory(fb);
10172            let fb_offsets: HashMap<NodeId, usize> = fb_plan
10173                .assignments
10174                .iter()
10175                .map(|(id, slot)| (*id, slot.offset))
10176                .collect();
10177            let mut fb_inputs: Vec<NodeId> = fb
10178                .nodes()
10179                .iter()
10180                .filter(|n| matches!(n.op, Op::Input { .. }))
10181                .map(|n| n.id)
10182                .collect();
10183            fb_inputs.sort();
10184            let fb_carry = fb_offsets[&fb_inputs[0]];
10185            let fb_xs: Vec<usize> = (1..fb_inputs.len())
10186                .map(|i| fb_offsets[&fb_inputs[i]])
10187                .collect();
10188            let fb_out = fb_offsets[&fb.outputs[0]];
10189            let mut fb_arena = crate::arena::Arena::from_plan(fb_plan);
10190            for n in fb.nodes() {
10191                if let Op::Constant { data } = &n.op
10192                    && fb_arena.has_buffer(n.id)
10193                    && !data.is_empty()
10194                {
10195                    // Byte-copy works for any
10196                    // numeric dtype as long as the
10197                    // arena slot is sized to hold
10198                    // it — the Constant's `data`
10199                    // already encodes the right
10200                    // bytes per element.
10201                    let off = fb_arena.byte_offset(n.id);
10202                    let buf = fb_arena.raw_buf_mut();
10203                    let nb = (buf.len() - off).min(data.len());
10204                    buf[off..off + nb].copy_from_slice(&data[..nb]);
10205                }
10206            }
10207            let fb_init_bytes = fb_arena.raw_buf().to_vec();
10208            let fb_sched = compile_thunks_with_rng(fb, &fb_arena, rng);
10209            (
10210                Some(Arc::new(fb_sched)),
10211                Some(Arc::new(fb_init_bytes)),
10212                fb_carry,
10213                fb_out,
10214                fb_xs,
10215            )
10216        } else {
10217            (None, None, 0, 0, Vec::new())
10218        };
10219
10220        Thunk::ScanBackward {
10221            body_vjp: Arc::new(body_schedule),
10222            body_init: Arc::new(body_init),
10223            body_carry_in_off,
10224            body_x_offs: Arc::new(body_x_offs),
10225            body_d_output_off,
10226            body_dcarry_out_off,
10227            outer_init_off: node_offset(arena, node.inputs[0]),
10228            outer_traj_off: node_offset(arena, node.inputs[1]),
10229            outer_upstream_off: node_offset(arena, node.inputs[2]),
10230            outer_xs_offs: Arc::new(outer_xs_offs),
10231            outer_dinit_off: node_offset(arena, node.id),
10232            length: *length,
10233            carry_bytes: carry_bytes as u32,
10234            carry_elem_size,
10235            save_trajectory: *save_trajectory,
10236            num_checkpoints: *num_checkpoints,
10237            forward_body: fb_schedule,
10238            forward_body_init: fb_init,
10239            forward_body_carry_in_off: fb_carry_in_off,
10240            forward_body_output_off: fb_output_off,
10241            forward_body_x_offs: Arc::new(fb_x_offs),
10242        }
10243    }
10244}
10245
10246#[allow(unused_variables)]
10247fn compile_scan_backward_xs(
10248    node: &rlx_ir::Node,
10249    graph: &Graph,
10250    arena: &crate::arena::Arena,
10251    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10252    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10253    rng: rlx_ir::RngOptions,
10254) -> Thunk {
10255    let Op::ScanBackwardXs {
10256        body_vjp,
10257        length,
10258        save_trajectory,
10259        num_xs,
10260        xs_idx,
10261        num_checkpoints,
10262        forward_body,
10263    } = &node.op
10264    else {
10265        unreachable!()
10266    };
10267    {
10268        assert!(
10269            *num_checkpoints == 0 || *num_checkpoints <= *length,
10270            "Op::ScanBackwardXs: num_checkpoints={} must be 0 or ≤ length={}",
10271            *num_checkpoints,
10272            *length
10273        );
10274        let is_recursive = *num_checkpoints != 0 && *num_checkpoints != *length;
10275        if is_recursive {
10276            assert!(
10277                forward_body.is_some(),
10278                "Op::ScanBackwardXs with num_checkpoints<length \
10279                         requires forward_body"
10280            );
10281        }
10282        // Mirror ScanBackward's body_vjp slot identification +
10283        // arena prep, then add: per-iteration extraction of the
10284        // body_vjp output that corresponds to the chosen xs.
10285        //
10286        // body_vjp's outputs (from `grad(body, [carry, xs_0, ..., xs_{num_xs-1}])`):
10287        //   outputs[0]      = dcarry
10288        //   outputs[1 + i]  = dx_t_i
10289        let body_plan = rlx_opt::memory::plan_memory(body_vjp);
10290        let body_offsets: HashMap<NodeId, usize> = body_plan
10291            .assignments
10292            .iter()
10293            .map(|(id, slot)| (*id, slot.offset))
10294            .collect();
10295        let mut body_d_output_off: Option<usize> = None;
10296        let mut body_other_inputs: Vec<(NodeId, usize)> = Vec::new();
10297        for n in body_vjp.nodes() {
10298            if let Op::Input { name } = &n.op {
10299                let off = body_offsets[&n.id];
10300                if name == "d_output" {
10301                    body_d_output_off = Some(off);
10302                } else {
10303                    body_other_inputs.push((n.id, off));
10304                }
10305            }
10306        }
10307        body_other_inputs.sort_by_key(|(id, _)| *id);
10308        let body_d_output_off =
10309            body_d_output_off.expect("ScanBackwardXs body_vjp missing 'd_output' Input");
10310        let expected_others = 1 + *num_xs as usize;
10311        assert_eq!(
10312            body_other_inputs.len(),
10313            expected_others,
10314            "ScanBackwardXs body_vjp has {} non-d_output Inputs; expected {}",
10315            body_other_inputs.len(),
10316            expected_others
10317        );
10318        let body_carry_in_off = body_other_inputs[0].1;
10319        let body_x_offs: Vec<usize> = body_other_inputs
10320            .iter()
10321            .skip(1)
10322            .map(|(_, off)| *off)
10323            .collect();
10324        let body_dcarry_out_off = body_offsets[&body_vjp.outputs[0]];
10325        let dxs_out_node = body_vjp.outputs[1 + *xs_idx as usize];
10326        let body_dxs_out_off = body_offsets[&dxs_out_node];
10327
10328        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
10329        for n in body_vjp.nodes() {
10330            if let Op::Constant { data } = &n.op
10331                && body_arena.has_buffer(n.id)
10332                && !data.is_empty()
10333            {
10334                match n.shape.dtype() {
10335                    rlx_ir::DType::F64 => {
10336                        let off = body_arena.byte_offset(n.id);
10337                        let buf = body_arena.raw_buf_mut();
10338                        let nb = (buf.len() - off).min(data.len());
10339                        buf[off..off + nb].copy_from_slice(&data[..nb]);
10340                    }
10341                    _ => {
10342                        let buf = body_arena.slice_mut(n.id);
10343                        let nf = data.len() / 4;
10344                        let nl = buf.len().min(nf);
10345                        for i in 0..nl {
10346                            let bytes = [
10347                                data[i * 4],
10348                                data[i * 4 + 1],
10349                                data[i * 4 + 2],
10350                                data[i * 4 + 3],
10351                            ];
10352                            buf[i] = f32::from_le_bytes(bytes);
10353                        }
10354                    }
10355                }
10356            }
10357        }
10358        let body_init = body_arena.raw_buf().to_vec();
10359        let body_schedule = compile_thunks_with_rng(body_vjp, &body_arena, rng);
10360
10361        let carry_bytes = body_vjp
10362            .node(body_vjp.outputs[0])
10363            .shape
10364            .size_bytes()
10365            .expect("ScanBackwardXs dcarry must be statically shaped");
10366        let carry_elem_size = body_vjp
10367            .node(body_vjp.outputs[0])
10368            .shape
10369            .dtype()
10370            .size_bytes() as u32;
10371        let per_step_bytes = body_vjp
10372            .node(dxs_out_node)
10373            .shape
10374            .size_bytes()
10375            .expect("ScanBackwardXs dxs body output must be statically shaped");
10376
10377        let mut outer_xs_offs: Vec<(usize, u32)> = Vec::with_capacity(*num_xs as usize);
10378        for i in 0..*num_xs as usize {
10379            let outer_xs_id = node.inputs[3 + i];
10380            let outer_xs_off = node_offset(arena, outer_xs_id);
10381            let outer_xs_shape = &graph.node(outer_xs_id).shape;
10382            let total = outer_xs_shape
10383                .size_bytes()
10384                .expect("ScanBackwardXs xs must have static shape");
10385            let per_step = total / *length as usize;
10386            outer_xs_offs.push((outer_xs_off, per_step as u32));
10387        }
10388
10389        // Compile forward_body for recompute when checkpointed.
10390        // Mirrors the same code path in the ScanBackward arm.
10391        let (fb_schedule, fb_init, fb_carry_in_off, fb_output_off, fb_x_offs) = if is_recursive {
10392            let fb = forward_body.as_ref().unwrap();
10393            let fb_plan = rlx_opt::memory::plan_memory(fb);
10394            let fb_offsets: HashMap<NodeId, usize> = fb_plan
10395                .assignments
10396                .iter()
10397                .map(|(id, slot)| (*id, slot.offset))
10398                .collect();
10399            let mut fb_inputs: Vec<NodeId> = fb
10400                .nodes()
10401                .iter()
10402                .filter(|n| matches!(n.op, Op::Input { .. }))
10403                .map(|n| n.id)
10404                .collect();
10405            fb_inputs.sort();
10406            let fb_carry = fb_offsets[&fb_inputs[0]];
10407            let fb_xs: Vec<usize> = (1..fb_inputs.len())
10408                .map(|i| fb_offsets[&fb_inputs[i]])
10409                .collect();
10410            let fb_out = fb_offsets[&fb.outputs[0]];
10411            let mut fb_arena = crate::arena::Arena::from_plan(fb_plan);
10412            for n in fb.nodes() {
10413                if let Op::Constant { data } = &n.op
10414                    && fb_arena.has_buffer(n.id)
10415                    && !data.is_empty()
10416                {
10417                    // Byte-copy works for any
10418                    // numeric dtype as long as the
10419                    // arena slot is sized to hold
10420                    // it — the Constant's `data`
10421                    // already encodes the right
10422                    // bytes per element.
10423                    let off = fb_arena.byte_offset(n.id);
10424                    let buf = fb_arena.raw_buf_mut();
10425                    let nb = (buf.len() - off).min(data.len());
10426                    buf[off..off + nb].copy_from_slice(&data[..nb]);
10427                }
10428            }
10429            let fb_init_bytes = fb_arena.raw_buf().to_vec();
10430            let fb_sched = compile_thunks_with_rng(fb, &fb_arena, rng);
10431            (
10432                Some(Arc::new(fb_sched)),
10433                Some(Arc::new(fb_init_bytes)),
10434                fb_carry,
10435                fb_out,
10436                fb_xs,
10437            )
10438        } else {
10439            (None, None, 0, 0, Vec::new())
10440        };
10441
10442        Thunk::ScanBackwardXs {
10443            body_vjp: Arc::new(body_schedule),
10444            body_init: Arc::new(body_init),
10445            body_carry_in_off,
10446            body_x_offs: Arc::new(body_x_offs),
10447            body_d_output_off,
10448            body_dcarry_out_off,
10449            body_dxs_out_off,
10450            outer_init_off: node_offset(arena, node.inputs[0]),
10451            outer_traj_off: node_offset(arena, node.inputs[1]),
10452            outer_upstream_off: node_offset(arena, node.inputs[2]),
10453            outer_xs_offs: Arc::new(outer_xs_offs),
10454            outer_dxs_off: node_offset(arena, node.id),
10455            length: *length,
10456            carry_bytes: carry_bytes as u32,
10457            carry_elem_size,
10458            per_step_bytes: per_step_bytes as u32,
10459            save_trajectory: *save_trajectory,
10460            num_checkpoints: *num_checkpoints,
10461            forward_body: fb_schedule,
10462            forward_body_init: fb_init,
10463            forward_body_carry_in_off: fb_carry_in_off,
10464            forward_body_output_off: fb_output_off,
10465            forward_body_x_offs: Arc::new(fb_x_offs),
10466        }
10467    }
10468}
10469
10470#[allow(unused_variables)]
10471fn compile_concat(
10472    node: &rlx_ir::Node,
10473    graph: &Graph,
10474    arena: &crate::arena::Arena,
10475    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10476    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10477    rng: rlx_ir::RngOptions,
10478) -> Thunk {
10479    let Op::Concat { axis } = &node.op else {
10480        unreachable!()
10481    };
10482    {
10483        // Compute outer/inner from the OUTPUT shape: all inputs share
10484        // the same shape except along `axis`. The output's leading
10485        // and trailing dims match.
10486        let out_shape = &node.shape;
10487        let rank = out_shape.rank();
10488        let outer: usize = (0..*axis)
10489            .map(|i| out_shape.dim(i).unwrap_static())
10490            .product::<usize>()
10491            .max(1);
10492        let inner: usize = (*axis + 1..rank)
10493            .map(|i| out_shape.dim(i).unwrap_static())
10494            .product::<usize>()
10495            .max(1);
10496        let total_axis = out_shape.dim(*axis).unwrap_static();
10497        let inputs: Vec<(usize, u32, u32)> = node
10498            .inputs
10499            .iter()
10500            .map(|&in_id| {
10501                let in_shape = &graph.node(in_id).shape;
10502                let in_axis = concat_axis_extent(in_shape, *axis, rank);
10503                let in_numel = in_shape.num_elements().unwrap_or(0) as u32;
10504                (node_offset(arena, in_id), in_axis as u32, in_numel)
10505            })
10506            .collect();
10507        let dst = node_offset(arena, node.id);
10508        match out_shape.dtype() {
10509            rlx_ir::DType::F64 => Thunk::ConcatF64 {
10510                dst,
10511                outer: outer as u32,
10512                inner: inner as u32,
10513                total_axis: total_axis as u32,
10514                inputs,
10515            },
10516            _ => Thunk::Concat {
10517                dst,
10518                outer: outer as u32,
10519                inner: inner as u32,
10520                total_axis: total_axis as u32,
10521                inputs,
10522            },
10523        }
10524    }
10525}
10526
10527#[allow(unused_variables)]
10528fn compile_gaussian_splat_render(
10529    node: &rlx_ir::Node,
10530    graph: &Graph,
10531    arena: &crate::arena::Arena,
10532    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10533    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10534    rng: rlx_ir::RngOptions,
10535) -> Thunk {
10536    let Op::GaussianSplatRender {
10537        width,
10538        height,
10539        tile_size,
10540        radius_scale,
10541        alpha_cutoff,
10542        max_splat_steps,
10543        transmittance_threshold,
10544        max_list_entries,
10545    } = &node.op
10546    else {
10547        unreachable!()
10548    };
10549    {
10550        let elem_len = |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
10551        Thunk::GaussianSplatRender {
10552            positions_off: node_offset(arena, node.inputs[0]),
10553            positions_len: elem_len(node.inputs[0]),
10554            scales_off: node_offset(arena, node.inputs[1]),
10555            scales_len: elem_len(node.inputs[1]),
10556            rotations_off: node_offset(arena, node.inputs[2]),
10557            rotations_len: elem_len(node.inputs[2]),
10558            opacities_off: node_offset(arena, node.inputs[3]),
10559            opacities_len: elem_len(node.inputs[3]),
10560            colors_off: node_offset(arena, node.inputs[4]),
10561            colors_len: elem_len(node.inputs[4]),
10562            sh_coeffs_off: node_offset(arena, node.inputs[5]),
10563            sh_coeffs_len: elem_len(node.inputs[5]),
10564            meta_off: node_offset(arena, node.inputs[6]),
10565            dst_off: node_offset(arena, node.id),
10566            dst_len: node.shape.num_elements().unwrap_or(0),
10567            width: *width,
10568            height: *height,
10569            tile_size: *tile_size,
10570            radius_scale: *radius_scale,
10571            alpha_cutoff: *alpha_cutoff,
10572            max_splat_steps: *max_splat_steps,
10573            transmittance_threshold: *transmittance_threshold,
10574            max_list_entries: *max_list_entries,
10575        }
10576    }
10577}
10578
10579#[allow(unused_variables)]
10580fn compile_gaussian_splat_render_backward(
10581    node: &rlx_ir::Node,
10582    graph: &Graph,
10583    arena: &crate::arena::Arena,
10584    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10585    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10586    rng: rlx_ir::RngOptions,
10587) -> Thunk {
10588    let Op::GaussianSplatRenderBackward {
10589        width,
10590        height,
10591        tile_size,
10592        radius_scale,
10593        alpha_cutoff,
10594        max_splat_steps,
10595        transmittance_threshold,
10596        max_list_entries,
10597        loss_grad_clip,
10598        sh_band,
10599        max_anisotropy,
10600    } = &node.op
10601    else {
10602        unreachable!()
10603    };
10604    {
10605        let elem_len = |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
10606        Thunk::GaussianSplatRenderBackward {
10607            positions_off: node_offset(arena, node.inputs[0]),
10608            positions_len: elem_len(node.inputs[0]),
10609            scales_off: node_offset(arena, node.inputs[1]),
10610            scales_len: elem_len(node.inputs[1]),
10611            rotations_off: node_offset(arena, node.inputs[2]),
10612            rotations_len: elem_len(node.inputs[2]),
10613            opacities_off: node_offset(arena, node.inputs[3]),
10614            opacities_len: elem_len(node.inputs[3]),
10615            colors_off: node_offset(arena, node.inputs[4]),
10616            colors_len: elem_len(node.inputs[4]),
10617            sh_coeffs_off: node_offset(arena, node.inputs[5]),
10618            sh_coeffs_len: elem_len(node.inputs[5]),
10619            meta_off: node_offset(arena, node.inputs[6]),
10620            d_loss_off: node_offset(arena, node.inputs[7]),
10621            d_loss_len: elem_len(node.inputs[7]),
10622            packed_off: node_offset(arena, node.id),
10623            packed_len: node.shape.num_elements().unwrap_or(0),
10624            width: *width,
10625            height: *height,
10626            tile_size: *tile_size,
10627            radius_scale: *radius_scale,
10628            alpha_cutoff: *alpha_cutoff,
10629            max_splat_steps: *max_splat_steps,
10630            transmittance_threshold: *transmittance_threshold,
10631            max_list_entries: *max_list_entries,
10632            loss_grad_clip: *loss_grad_clip,
10633            sh_band: *sh_band,
10634            max_anisotropy: *max_anisotropy,
10635        }
10636    }
10637}
10638
10639#[allow(unused_variables)]
10640fn compile_gaussian_splat_prepare(
10641    node: &rlx_ir::Node,
10642    graph: &Graph,
10643    arena: &crate::arena::Arena,
10644    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10645    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10646    rng: rlx_ir::RngOptions,
10647) -> Thunk {
10648    let Op::GaussianSplatPrepare {
10649        width,
10650        height,
10651        tile_size,
10652        radius_scale,
10653        alpha_cutoff,
10654        max_splat_steps,
10655        transmittance_threshold,
10656        max_list_entries,
10657    } = &node.op
10658    else {
10659        unreachable!()
10660    };
10661    {
10662        let elem_len = |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
10663        Thunk::GaussianSplatPrepare {
10664            positions_off: node_offset(arena, node.inputs[0]),
10665            positions_len: elem_len(node.inputs[0]),
10666            scales_off: node_offset(arena, node.inputs[1]),
10667            scales_len: elem_len(node.inputs[1]),
10668            rotations_off: node_offset(arena, node.inputs[2]),
10669            rotations_len: elem_len(node.inputs[2]),
10670            opacities_off: node_offset(arena, node.inputs[3]),
10671            opacities_len: elem_len(node.inputs[3]),
10672            colors_off: node_offset(arena, node.inputs[4]),
10673            colors_len: elem_len(node.inputs[4]),
10674            sh_coeffs_off: node_offset(arena, node.inputs[5]),
10675            sh_coeffs_len: elem_len(node.inputs[5]),
10676            meta_off: node_offset(arena, node.inputs[6]),
10677            meta_len: elem_len(node.inputs[6]),
10678            prep_off: node_offset(arena, node.id),
10679            prep_len: node.shape.num_elements().unwrap_or(0),
10680            width: *width,
10681            height: *height,
10682            tile_size: *tile_size,
10683            radius_scale: *radius_scale,
10684            alpha_cutoff: *alpha_cutoff,
10685            max_splat_steps: *max_splat_steps,
10686            transmittance_threshold: *transmittance_threshold,
10687            max_list_entries: *max_list_entries,
10688        }
10689    }
10690}
10691
10692#[allow(unused_variables)]
10693fn compile_gaussian_splat_rasterize(
10694    node: &rlx_ir::Node,
10695    graph: &Graph,
10696    arena: &crate::arena::Arena,
10697    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10698    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10699    rng: rlx_ir::RngOptions,
10700) -> Thunk {
10701    let Op::GaussianSplatRasterize {
10702        width,
10703        height,
10704        tile_size,
10705        alpha_cutoff,
10706        max_splat_steps,
10707        transmittance_threshold,
10708        max_list_entries,
10709    } = &node.op
10710    else {
10711        unreachable!()
10712    };
10713    {
10714        let elem_len = |id: NodeId| -> usize { graph.node(id).shape.num_elements().unwrap_or(0) };
10715        let prep_id = node.inputs[0];
10716        let count = match &graph.node(prep_id).op {
10717            rlx_ir::Op::GaussianSplatPrepare { .. } => elem_len(graph.node(prep_id).inputs[0]) / 3,
10718            _ => 1,
10719        };
10720        Thunk::GaussianSplatRasterize {
10721            prep_off: node_offset(arena, prep_id),
10722            prep_len: elem_len(prep_id),
10723            meta_off: node_offset(arena, node.inputs[1]),
10724            meta_len: elem_len(node.inputs[1]),
10725            dst_off: node_offset(arena, node.id),
10726            dst_len: node.shape.num_elements().unwrap_or(0),
10727            count,
10728            width: *width,
10729            height: *height,
10730            tile_size: *tile_size,
10731            alpha_cutoff: *alpha_cutoff,
10732            max_splat_steps: *max_splat_steps,
10733            transmittance_threshold: *transmittance_threshold,
10734            max_list_entries: *max_list_entries,
10735        }
10736    }
10737}
10738
10739#[allow(unused_variables)]
10740fn compile_custom(
10741    node: &rlx_ir::Node,
10742    graph: &Graph,
10743    arena: &crate::arena::Arena,
10744    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10745    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10746    rng: rlx_ir::RngOptions,
10747) -> Thunk {
10748    let Op::Custom { name, attrs, .. } = &node.op else {
10749        unreachable!()
10750    };
10751    {
10752        let kernel = crate::op_registry::lookup_cpu_kernel(name).unwrap_or_else(|| {
10753            panic!(
10754                "compile_thunks: no CPU kernel registered for \
10755                         Op::Custom('{name}'). Register one via \
10756                         rlx_cpu::op_registry::register_cpu_kernel \
10757                         before compiling on the CPU backend."
10758            )
10759        });
10760        let inputs_v: Vec<(usize, u32, Shape)> = node
10761            .inputs
10762            .iter()
10763            .map(|&in_id| {
10764                let s = graph.node(in_id).shape.clone();
10765                let len = s.num_elements().unwrap_or(0) as u32;
10766                (node_offset(arena, in_id), len, s)
10767            })
10768            .collect();
10769        let out_len = node.shape.num_elements().unwrap_or(0) as u32;
10770        Thunk::CustomOp {
10771            kernel,
10772            inputs: inputs_v,
10773            output: (node_offset(arena, node.id), out_len, node.shape.clone()),
10774            attrs: attrs.clone(),
10775        }
10776    }
10777}
10778
10779#[allow(unused_variables)]
10780fn compile_fft(
10781    node: &rlx_ir::Node,
10782    graph: &Graph,
10783    arena: &crate::arena::Arena,
10784    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10785    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10786    rng: rlx_ir::RngOptions,
10787) -> Thunk {
10788    let Op::Fft { inverse, norm } = &node.op else {
10789        unreachable!()
10790    };
10791    {
10792        let shape = &node.shape;
10793        let meta = rlx_ir::fft::fft_meta(shape);
10794        let dtype = shape.dtype();
10795        assert!(
10796            matches!(
10797                dtype,
10798                rlx_ir::DType::F32 | rlx_ir::DType::F64 | rlx_ir::DType::C64
10799            ),
10800            "Op::Fft on CPU requires F32, F64, or C64, got {dtype:?}"
10801        );
10802        Thunk::Fft1d {
10803            src: node_offset(arena, node.inputs[0]),
10804            dst: node_offset(arena, node.id),
10805            outer: meta.outer as u32,
10806            n_complex: meta.n_complex as u32,
10807            inverse: *inverse,
10808            norm_tag: norm.tag(),
10809            dtype,
10810        }
10811    }
10812}
10813
10814#[allow(unused_variables)]
10815fn compile_fft_butterfly_stage(
10816    node: &rlx_ir::Node,
10817    graph: &Graph,
10818    arena: &crate::arena::Arena,
10819    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10820    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10821    rng: rlx_ir::RngOptions,
10822) -> Thunk {
10823    let Op::FftButterflyStage { stage, n_fft } = &node.op else {
10824        unreachable!()
10825    };
10826    {
10827        let state_shape = graph.node(node.inputs[0]).shape.clone();
10828        assert_eq!(
10829            state_shape.dtype(),
10830            rlx_ir::DType::F32,
10831            "Op::FftButterflyStage requires F32 state"
10832        );
10833        let batch = state_shape.dim(0).unwrap_static() as u32;
10834        Thunk::FftButterflyStage {
10835            state_src: node_offset(arena, node.inputs[0]),
10836            state_dst: node_offset(arena, node.id),
10837            gate_src: node_offset(arena, node.inputs[1]),
10838            rev_src: node_offset(arena, node.inputs[2]),
10839            tw_re_src: node_offset(arena, node.inputs[3]),
10840            tw_im_src: node_offset(arena, node.inputs[4]),
10841            batch,
10842            n_fft: *n_fft,
10843            stage: *stage,
10844        }
10845    }
10846}
10847
10848#[allow(unused_variables)]
10849fn compile_log_mel(
10850    node: &rlx_ir::Node,
10851    graph: &Graph,
10852    arena: &crate::arena::Arena,
10853    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10854    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10855    rng: rlx_ir::RngOptions,
10856) -> Thunk {
10857    let Op::LogMel = &node.op else { unreachable!() };
10858    {
10859        let spec_shape = graph.node(node.inputs[0]).shape.clone();
10860        let filt_shape = graph.node(node.inputs[1]).shape.clone();
10861        let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
10862            .unwrap_or_else(|e| panic!("Op::LogMel: {e}"));
10863        Thunk::LogMel {
10864            spec: node_offset(arena, node.inputs[0]),
10865            filters: node_offset(arena, node.inputs[1]),
10866            dst: node_offset(arena, node.id),
10867            outer: meta.outer as u32,
10868            n_fft: meta.n_fft as u32,
10869            n_bins: meta.n_bins as u32,
10870            n_mels: meta.n_mels as u32,
10871        }
10872    }
10873}
10874
10875#[allow(unused_variables)]
10876fn compile_log_mel_backward(
10877    node: &rlx_ir::Node,
10878    graph: &Graph,
10879    arena: &crate::arena::Arena,
10880    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10881    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10882    rng: rlx_ir::RngOptions,
10883) -> Thunk {
10884    let Op::LogMelBackward = &node.op else {
10885        unreachable!()
10886    };
10887    {
10888        let spec_shape = graph.node(node.inputs[0]).shape.clone();
10889        let filt_shape = graph.node(node.inputs[1]).shape.clone();
10890        let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
10891            .unwrap_or_else(|e| panic!("Op::LogMelBackward: {e}"));
10892        Thunk::LogMelBackward {
10893            spec: node_offset(arena, node.inputs[0]),
10894            filters: node_offset(arena, node.inputs[1]),
10895            dy: node_offset(arena, node.inputs[2]),
10896            dst: node_offset(arena, node.id),
10897            outer: meta.outer as u32,
10898            n_fft: meta.n_fft as u32,
10899            n_bins: meta.n_bins as u32,
10900            n_mels: meta.n_mels as u32,
10901        }
10902    }
10903}
10904
10905#[allow(unused_variables)]
10906fn compile_welch_peaks(
10907    node: &rlx_ir::Node,
10908    graph: &Graph,
10909    arena: &crate::arena::Arena,
10910    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10911    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10912    rng: rlx_ir::RngOptions,
10913) -> Thunk {
10914    let Op::WelchPeaks { k, n_segments } = &node.op else {
10915        unreachable!()
10916    };
10917    {
10918        let spec_shape = graph.node(node.inputs[0]).shape.clone();
10919        let meta = rlx_ir::audio::welch_peaks_meta(&spec_shape, *k, *n_segments)
10920            .unwrap_or_else(|e| panic!("Op::WelchPeaks: {e}"));
10921        Thunk::WelchPeaks {
10922            spec: node_offset(arena, node.inputs[0]),
10923            dst: node_offset(arena, node.id),
10924            welch_batch: meta.welch_batch as u32,
10925            n_fft: meta.n_fft as u32,
10926            n_segments: meta.n_segments as u32,
10927            k: meta.k as u32,
10928        }
10929    }
10930}
10931
10932#[allow(unused_variables)]
10933fn compile_custom_fn(
10934    node: &rlx_ir::Node,
10935    graph: &Graph,
10936    arena: &crate::arena::Arena,
10937    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
10938    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
10939    rng: rlx_ir::RngOptions,
10940) -> Thunk {
10941    let Op::CustomFn {
10942        fwd_body,
10943        num_inputs,
10944        ..
10945    } = &node.op
10946    else {
10947        unreachable!()
10948    };
10949    {
10950        // Plan + compile the body sub-graph standalone, fill its
10951        // Constants (mirrors the Op::Scan body lowering), then
10952        // capture per-input copy specs and the output spec.
10953        // Body Inputs in NodeId order match the outer node's
10954        // operand vector by position.
10955        let body_plan = rlx_opt::memory::plan_memory(fwd_body);
10956        let body_offsets: HashMap<NodeId, usize> = body_plan
10957            .assignments
10958            .iter()
10959            .map(|(id, slot)| (*id, slot.offset))
10960            .collect();
10961
10962        let mut body_input_ids: Vec<NodeId> = fwd_body
10963            .nodes()
10964            .iter()
10965            .filter(|n| matches!(n.op, Op::Input { .. }))
10966            .map(|n| n.id)
10967            .collect();
10968        body_input_ids.sort();
10969        assert_eq!(
10970            body_input_ids.len(),
10971            *num_inputs as usize,
10972            "Op::CustomFn fwd_body has {} Op::Input(s); declared num_inputs={}",
10973            body_input_ids.len(),
10974            *num_inputs,
10975        );
10976
10977        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
10978        for n in fwd_body.nodes() {
10979            if let Op::Constant { data } = &n.op
10980                && body_arena.has_buffer(n.id)
10981                && !data.is_empty()
10982            {
10983                match n.shape.dtype() {
10984                    rlx_ir::DType::F64 => {
10985                        let off = body_arena.byte_offset(n.id);
10986                        let buf = body_arena.raw_buf_mut();
10987                        let nb = (buf.len() - off).min(data.len());
10988                        buf[off..off + nb].copy_from_slice(&data[..nb]);
10989                    }
10990                    _ => {
10991                        let buf = body_arena.slice_mut(n.id);
10992                        let nf = data.len() / 4;
10993                        let nl = buf.len().min(nf);
10994                        for i in 0..nl {
10995                            let bytes = [
10996                                data[i * 4],
10997                                data[i * 4 + 1],
10998                                data[i * 4 + 2],
10999                                data[i * 4 + 3],
11000                            ];
11001                            buf[i] = f32::from_le_bytes(bytes);
11002                        }
11003                    }
11004                }
11005            }
11006        }
11007        let body_init = body_arena.raw_buf().to_vec();
11008        let body_schedule = compile_thunks_with_rng(fwd_body, &body_arena, rng);
11009
11010        // Per primal input: (body_input_off, outer_input_off, bytes).
11011        let inputs_v: Vec<(usize, usize, u32)> = (0..*num_inputs as usize)
11012            .map(|i| {
11013                let body_in = body_input_ids[i];
11014                let body_off = body_offsets[&body_in];
11015                let outer_in = node.inputs[i];
11016                let outer_off = node_offset(arena, outer_in);
11017                let bytes = graph
11018                    .node(outer_in)
11019                    .shape
11020                    .size_bytes()
11021                    .expect("Op::CustomFn primal input must have static shape");
11022                (body_off, outer_off, bytes as u32)
11023            })
11024            .collect();
11025
11026        let body_output_id = fwd_body
11027            .outputs
11028            .first()
11029            .copied()
11030            .expect("Op::CustomFn fwd_body must declare exactly one output");
11031        let body_output_off = body_offsets[&body_output_id];
11032        let out_bytes = node
11033            .shape
11034            .size_bytes()
11035            .expect("Op::CustomFn output must have static shape");
11036
11037        Thunk::CustomFn {
11038            body: Arc::new(body_schedule),
11039            body_init: Arc::new(body_init),
11040            inputs: Arc::new(inputs_v),
11041            body_output_off,
11042            outer_output_off: node_offset(arena, node.id),
11043            out_bytes: out_bytes as u32,
11044        }
11045    }
11046}
11047
11048#[allow(unused_variables)]
11049fn compile_elementwise_region(
11050    node: &rlx_ir::Node,
11051    graph: &Graph,
11052    arena: &crate::arena::Arena,
11053    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
11054    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
11055    rng: rlx_ir::RngOptions,
11056) -> Thunk {
11057    let Op::ElementwiseRegion {
11058        chain,
11059        scalar_input_mask,
11060        input_modulus,
11061        prologue,
11062        ..
11063    } = &node.op
11064    else {
11065        unreachable!()
11066    };
11067    {
11068        // The scalar interpreter handles plain chains; prologue (resize)
11069        // regions are GPU-only and never reach here on the CPU path
11070        // (the graphfused fusion options disable prologue/FK fusion).
11071        if *prologue != rlx_ir::op::RegionPrologue::None {
11072            Thunk::Nop
11073        } else {
11074            let input_offs: Vec<usize> = node
11075                .inputs
11076                .iter()
11077                .map(|&id| node_offset(arena, id))
11078                .collect();
11079            Thunk::ElementwiseRegion {
11080                dst: node_offset(arena, node.id),
11081                len: node.shape.num_elements().unwrap_or(0) as u32,
11082                input_offs,
11083                chain: chain.clone(),
11084                scalar_input_mask: *scalar_input_mask,
11085                input_modulus: *input_modulus,
11086            }
11087        }
11088    }
11089}
11090
11091fn get_len(graph: &Graph, id: NodeId) -> usize {
11092    graph.node(id).shape.num_elements().unwrap_or(0)
11093}
11094
11095/// Static `usize` dims of a node's shape, or empty if any dim is dynamic.
11096fn get_static_dims(graph: &Graph, id: NodeId) -> Vec<usize> {
11097    let dims = graph.node(id).shape.dims();
11098    let mut out = Vec::with_capacity(dims.len());
11099    for d in dims {
11100        if let Some(s) = match d {
11101            rlx_ir::Dim::Static(s) => Some(*s),
11102            _ => None,
11103        } {
11104            out.push(s);
11105        } else {
11106            return Vec::new();
11107        }
11108    }
11109    out
11110}
11111
11112/// Extent along `axis` for a concat input, treating leading implicit 1s when
11113/// `input.rank() < output.rank()` (ONNX / numpy concat broadcast rules).
11114fn concat_axis_extent(input: &rlx_ir::Shape, axis: usize, out_rank: usize) -> usize {
11115    let in_rank = input.rank();
11116    if axis >= out_rank {
11117        return 1;
11118    }
11119    if axis < in_rank {
11120        input.dim(axis).unwrap_static()
11121    } else {
11122        1
11123    }
11124}
11125
11126fn broadcast_src_index(src_idx: usize, in_len: usize) -> usize {
11127    if in_len == 0 { 0 } else { src_idx % in_len }
11128}
11129
11130fn concat_copy_rows_f32(
11131    out: &mut [f32],
11132    inp: &[f32],
11133    outer: usize,
11134    copy_per_row: usize,
11135    row_stride: usize,
11136    dst_col_off: usize,
11137    in_numel: usize,
11138) {
11139    let need = outer.saturating_mul(copy_per_row.max(1));
11140    let broadcast_outer = in_numel < need;
11141    for o in 0..outer {
11142        let dst_row_start = o * row_stride + dst_col_off;
11143        if broadcast_outer {
11144            if in_numel == 1 {
11145                if copy_per_row == 1 {
11146                    out[dst_row_start] = inp[0];
11147                } else {
11148                    out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
11149                }
11150            } else if copy_per_row <= inp.len() {
11151                out[dst_row_start..dst_row_start + copy_per_row]
11152                    .copy_from_slice(&inp[..copy_per_row]);
11153            } else if !inp.is_empty() {
11154                out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
11155            }
11156        } else {
11157            let src_row_start = o * copy_per_row;
11158            out[dst_row_start..dst_row_start + copy_per_row]
11159                .copy_from_slice(&inp[src_row_start..src_row_start + copy_per_row]);
11160        }
11161    }
11162}
11163
11164fn concat_copy_rows_f64(
11165    out: &mut [f64],
11166    inp: &[f64],
11167    outer: usize,
11168    copy_per_row: usize,
11169    row_stride: usize,
11170    dst_col_off: usize,
11171    in_numel: usize,
11172) {
11173    let need = outer.saturating_mul(copy_per_row.max(1));
11174    let broadcast_outer = in_numel < need;
11175    for o in 0..outer {
11176        let dst_row_start = o * row_stride + dst_col_off;
11177        if broadcast_outer {
11178            if in_numel == 1 {
11179                if copy_per_row == 1 {
11180                    out[dst_row_start] = inp[0];
11181                } else {
11182                    out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
11183                }
11184            } else if copy_per_row <= inp.len() {
11185                out[dst_row_start..dst_row_start + copy_per_row]
11186                    .copy_from_slice(&inp[..copy_per_row]);
11187            } else if !inp.is_empty() {
11188                out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
11189            }
11190        } else {
11191            let src_row_start = o * copy_per_row;
11192            out[dst_row_start..dst_row_start + copy_per_row]
11193                .copy_from_slice(&inp[src_row_start..src_row_start + copy_per_row]);
11194        }
11195    }
11196}
11197
11198/// NumPy-style broadcast strides for one operand into the flat output
11199/// buffer. Returns a length-`out_dims.len()` `Vec<u32>` where entry
11200/// `d` is `0` if the input is size-1 (broadcast) at output dim `d`
11201/// (after left-padding with size-1 to match ranks), otherwise the
11202/// natural row-major stride into the *input* buffer.
11203///
11204/// Caller iterates output flat index `i` → output coords (row-major)
11205/// → input flat index = dot(coords, strides). The result is correct
11206/// for any broadcast pattern (scalar, last-axis, middle-axis,
11207/// bidirectional).
11208/// True when `rhs_dims` describes a *trailing* broadcast of `out_dims`
11209/// — i.e. every rhs dim either equals the corresponding output dim
11210/// (counting from the right) or rhs is shorter (left-padded with 1s).
11211/// Mid-shape singletons (e.g. rhs `[a, b, 1, d]` into out `[a, b, c, d]`
11212/// where `c > 1`) are NOT trailing broadcasts and require the
11213/// shape-aware `BinaryFull` slow path — `BiasAdd`'s linear bias-replicated
11214/// kernel silently miscomputes them.
11215fn is_trailing_bias_broadcast(rhs_dims: &[rlx_ir::Dim], out_dims: &[rlx_ir::Dim]) -> bool {
11216    if rhs_dims.len() > out_dims.len() {
11217        return false;
11218    }
11219    let off = out_dims.len() - rhs_dims.len();
11220    for i in 0..rhs_dims.len() {
11221        let r = match rhs_dims[i] {
11222            rlx_ir::Dim::Static(n) => n,
11223            _ => return false,
11224        };
11225        let o = match out_dims[off + i] {
11226            rlx_ir::Dim::Static(n) => n,
11227            _ => return false,
11228        };
11229        if r != o {
11230            return false;
11231        }
11232    }
11233    true
11234}
11235
11236fn broadcast_strides(in_dims: &[usize], out_dims: &[usize]) -> Vec<u32> {
11237    let r_out = out_dims.len();
11238    let r_in = in_dims.len();
11239    assert!(
11240        r_in <= r_out,
11241        "broadcast: input rank {r_in} > output rank {r_out}"
11242    );
11243    let pad = r_out - r_in;
11244    let mut strides = vec![0u32; r_out];
11245    let mut acc: usize = 1;
11246    for d in (0..r_out).rev() {
11247        let in_size = if d < pad { 1 } else { in_dims[d - pad] };
11248        if in_size == 1 {
11249            strides[d] = 0;
11250        } else {
11251            assert_eq!(
11252                in_size, out_dims[d],
11253                "broadcast: input dim {in_size} doesn't match output dim {} at axis {d}",
11254                out_dims[d]
11255            );
11256            strides[d] = acc as u32;
11257            acc *= in_size;
11258        }
11259    }
11260    strides
11261}
11262
11263/// Execute a thunk schedule on a raw arena buffer.
11264/// Fastest executor: call pre-compiled closures sequentially.
11265/// Zero match dispatch — each closure is a direct kernel call.
11266pub fn execute_compiled(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
11267    let base = arena_buf.as_mut_ptr();
11268    for f in &schedule.compiled_fns {
11269        f(base);
11270    }
11271}
11272
11273/// Active-extent execution stub. The runtime calls this when it has an
11274/// active-extent hint set. CPU doesn't implement per-thunk active-extent
11275/// scaling yet — return false so the caller falls back to the full
11276/// `execute_thunks` path.
11277pub fn execute_thunks_active(
11278    schedule: &ThunkSchedule,
11279    _arena_buf: &mut [u8],
11280    _actual: usize,
11281    _upper: usize,
11282) -> bool {
11283    let _ = schedule;
11284    false
11285}
11286
11287/// Match-based executor (fallback, used by tests).
11288struct MoeResidencyGuard;
11289impl Drop for MoeResidencyGuard {
11290    fn drop(&mut self) {
11291        if let Some(stats) = crate::moe_residency::take_stats() {
11292            crate::moe_residency::stash_last_forward_stats(stats);
11293        } else {
11294            crate::moe_residency::clear_mask();
11295        }
11296    }
11297}
11298
11299fn thunk_kind_name(t: &Thunk) -> &'static str {
11300    match t {
11301        Thunk::Nop => "Nop",
11302        Thunk::Gather { .. } => "Gather",
11303        Thunk::GatherAxis { .. } => "GatherAxis",
11304        Thunk::TopK { .. } => "TopK",
11305        Thunk::Copy { .. } => "Copy",
11306        Thunk::CopyF64 { .. } => "CopyF64",
11307        Thunk::CopyI64 { .. } => "CopyI64",
11308        Thunk::CastF32ToI64 { .. } => "CastF32ToI64",
11309        Thunk::CastI64ToF32 { .. } => "CastI64ToF32",
11310        Thunk::CastBoolToI32 { .. } => "CastBoolToI32",
11311        Thunk::CastBoolToF32 { .. } => "CastBoolToF32",
11312        Thunk::CastI32ToF32 { .. } => "CastI32ToF32",
11313        Thunk::Transpose { .. } => "Transpose",
11314        Thunk::TransposeF64 { .. } => "TransposeF64",
11315        Thunk::Where { .. } => "Where",
11316        Thunk::Fma { .. } => "Fma",
11317        Thunk::Compare { .. } => "Compare",
11318        Thunk::BinaryFull { .. } => "BinaryFull",
11319        Thunk::BinaryFullF64 { .. } => "BinaryFullF64",
11320        Thunk::Sgemm { .. } => "Sgemm",
11321        Thunk::SgemmT { .. } => "SgemmT",
11322        Thunk::SgdMomentum { .. } => "SgdMomentum",
11323        Thunk::Dgemm { .. } => "Dgemm",
11324        Thunk::FusedMmBiasAct { .. } => "FusedMmBiasAct",
11325        Thunk::BiasAdd { .. } => "BiasAdd",
11326        Thunk::LayerNorm { .. } => "LayerNorm",
11327        Thunk::Softmax { .. } => "Softmax",
11328        Thunk::Conv2D { .. } => "Conv2D",
11329        Thunk::Conv2D1x1 { .. } => "Conv2D1x1",
11330        Thunk::Conv3d { .. } => "Conv3d",
11331        Thunk::ConvTranspose3d { .. } => "ConvTranspose3d",
11332        Thunk::CustomOp { .. } => "CustomOp",
11333        Thunk::ActivationInPlace { .. } => "ActivationInPlace",
11334        Thunk::Narrow { .. } => "Narrow",
11335        Thunk::Cumsum { .. } => "Cumsum",
11336        Thunk::Reduce { .. } => "Reduce",
11337        Thunk::BatchedSgemm { .. } => "BatchedSgemm",
11338        Thunk::DequantMatMul { .. } => "DequantMatMul",
11339        Thunk::Quantize { .. } => "Quantize",
11340        Thunk::Dequantize { .. } => "Dequantize",
11341        Thunk::ConvTranspose2d { .. } => "ConvTranspose2d",
11342        Thunk::ResizeNearest2x { .. } => "ResizeNearest2x",
11343        Thunk::ElementwiseRegion { .. } => "ElementwiseRegion",
11344        Thunk::Conv2dBackwardInput { .. } => "Conv2dBackwardInput",
11345        Thunk::Conv2dBackwardWeight { .. } => "Conv2dBackwardWeight",
11346        Thunk::Pool2D { .. } => "Pool2D",
11347        Thunk::MaxPool2dBackward { .. } => "MaxPool2dBackward",
11348        Thunk::ReluBackward { .. } => "ReluBackward",
11349        Thunk::ActivationBackward { .. } => "ActivationBackward",
11350        Thunk::Im2Col { .. } => "Im2Col",
11351        Thunk::SoftmaxCrossEntropyDense { .. } => "SoftmaxCrossEntropyDense",
11352        Thunk::SoftmaxCrossEntropy { .. } => "SoftmaxCrossEntropy",
11353        Thunk::SoftmaxCrossEntropyBackward { .. } => "SoftmaxCrossEntropyBackward",
11354        _ => "Other",
11355    }
11356}
11357
11358/// Per-thunk-kind wall-time accumulator, populated only when the env var
11359/// `RLX_PROFILE_THUNKS` is set. Used to see which ops dominate a step so the
11360/// optimizer/kernels can target the real hotspots rather than guesses.
11361static THUNK_PROFILE: std::sync::Mutex<
11362    Option<std::collections::BTreeMap<&'static str, (u128, u64)>>,
11363> = std::sync::Mutex::new(None);
11364
11365#[inline]
11366fn profile_record(name: &'static str, d: std::time::Duration) {
11367    let mut g = THUNK_PROFILE.lock().unwrap();
11368    let map = g.get_or_insert_with(std::collections::BTreeMap::new);
11369    let e = map.entry(name).or_insert((0, 0));
11370    e.0 += d.as_nanos();
11371    e.1 += 1;
11372}
11373
11374/// Print and clear the per-thunk-kind time profile gathered under
11375/// `RLX_PROFILE_THUNKS`. Call after a run to see where the time went.
11376pub fn dump_thunk_profile() {
11377    let mut g = THUNK_PROFILE.lock().unwrap();
11378    if let Some(map) = g.take() {
11379        let mut v: Vec<_> = map.into_iter().collect();
11380        v.sort_by_key(|b| std::cmp::Reverse(b.1.0));
11381        let total: u128 = v.iter().map(|(_, (ns, _))| *ns).sum();
11382        eprintln!(
11383            "[thunk-profile] total {:.1}ms across kinds:",
11384            total as f64 / 1e6
11385        );
11386        for (name, (ns, c)) in v.iter().take(25) {
11387            eprintln!("  {name:<28} {:>8.1}ms  ({c} calls)", *ns as f64 / 1e6);
11388        }
11389    }
11390}
11391
11392pub fn execute_thunks(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
11393    crate::moe_residency::reset_gmm_counters();
11394    if let Some(layers) = schedule.moe_resident_layers.clone() {
11395        crate::moe_residency::set_per_layer_masks(Some(layers));
11396    } else {
11397        crate::moe_residency::set_mask(schedule.moe_resident.clone());
11398    }
11399    if let Some(cap) = schedule.moe_topk_capture.as_ref() {
11400        cap.clear();
11401    }
11402    let _moe_guard = MoeResidencyGuard;
11403    let base = arena_buf.as_mut_ptr();
11404    let mask_thr = schedule.mask_threshold;
11405    let mask_neg = schedule.mask_neg_inf;
11406    let score_thr = schedule.score_skip;
11407    let thunks = &schedule.thunks;
11408    let len = thunks.len();
11409
11410    // Pre-allocate ALL reusable buffers once (zero per-call allocation)
11411    let max_h = thunks
11412        .iter()
11413        .filter_map(|t| match t {
11414            Thunk::FusedResidualLN { h, .. }
11415            | Thunk::FusedResidualRmsNorm { h, .. }
11416            | Thunk::LayerNorm { h, .. } => Some(*h as usize),
11417            _ => None,
11418        })
11419        .max()
11420        .unwrap_or(0);
11421    let zero_bias = vec![0f32; max_h];
11422
11423    // Pre-allocate per-(batch,head) score buffers for parallel SDPA.
11424    // Q/K/V/out are accessed via strided BLAS — no deinterleave copy needed.
11425    let max_sdpa = thunks
11426        .iter()
11427        .filter_map(|t| match t {
11428            Thunk::Attention {
11429                batch,
11430                seq,
11431                kv_seq,
11432                heads,
11433                head_dim,
11434                ..
11435            } => Some((
11436                *batch as usize,
11437                (*seq as usize).max(*kv_seq as usize),
11438                *heads as usize,
11439                *head_dim as usize,
11440            )),
11441            _ => None,
11442        })
11443        .fold((0, 0, 0, 0), |(mb, ms, mh, md), (b, s, h, d)| {
11444            (mb.max(b), ms.max(s), mh.max(h), md.max(d))
11445        });
11446    let (max_batch, max_seq, max_heads, _max_dh) = max_sdpa;
11447    let max_units = max_batch * max_heads;
11448    let mut sdpa_scores = vec![0f32; max_units * max_seq * max_seq];
11449
11450    // Pre-allocate fused layer buffers (reused across all 12+ layers — zero malloc per layer)
11451    let fl = thunks
11452        .iter()
11453        .filter_map(|t| match t {
11454            Thunk::FusedBertLayer {
11455                batch,
11456                seq,
11457                hs,
11458                int_dim,
11459                ..
11460            } => {
11461                let m = (*batch as usize) * (*seq as usize);
11462                let h = *hs as usize;
11463                let id = *int_dim as usize;
11464                Some((m, h, id, m * (*seq as usize)))
11465            }
11466            Thunk::FusedNomicLayer {
11467                batch,
11468                seq,
11469                hs,
11470                int_dim,
11471                ..
11472            } => {
11473                let m = (*batch as usize) * (*seq as usize);
11474                let h = *hs as usize;
11475                let id = *int_dim as usize;
11476                Some((m, h, id, m * (*seq as usize)))
11477            }
11478            _ => None,
11479        })
11480        .fold((0, 0, 0, 0), |(mm, mh, mi, ms), (m, h, id, ss)| {
11481            (mm.max(m), mh.max(h), mi.max(id), ms.max(ss))
11482        });
11483    let (fl_m, fl_h, fl_int, fl_ss) = fl;
11484    let mut fl_qkv = vec![0f32; fl_m * 3 * fl_h];
11485    let mut fl_attn = vec![0f32; fl_m * fl_h];
11486    let mut fl_res = vec![0f32; fl_m * fl_h];
11487    let mut fl_normed = vec![0f32; fl_m * fl_h];
11488    let mut fl_ffn = vec![0f32; fl_m * fl_int.max(2 * fl_int)]; // Nomic needs 2×int for fused fc11+fc12
11489    let mut fl_sc = vec![0f32; fl_ss.max(1)];
11490
11491    let trace_thunks = std::env::var_os("RLX_TRACE_THUNK").is_some();
11492    if trace_thunks {
11493        eprintln!(
11494            "[thunk] prealloc max_h={max_h} sdpa={} fl_m={fl_m} fl_h={fl_h} fl_int={fl_int}",
11495            max_units * max_seq * max_seq
11496        );
11497    }
11498    let profile = std::env::var_os("RLX_PROFILE_THUNKS").is_some();
11499    // Time the previous thunk at the top of each iteration (avoids touching the
11500    // giant match's many arms). The last thunk's tail is folded into the next
11501    // step's first sample — negligible over a training run.
11502    let mut prof_prev: Option<(&'static str, std::time::Instant)> = None;
11503    for i in 0..len {
11504        if profile {
11505            if let Some((pn, pt)) = prof_prev.take() {
11506                profile_record(pn, pt.elapsed());
11507            }
11508        }
11509        let thunk = unsafe { thunks.get_unchecked(i) };
11510        if trace_thunks && (i < 120 || i % 200 == 0 || i + 1 == len) {
11511            eprintln!("[thunk {i}/{len}] {}", thunk_kind_name(thunk));
11512        }
11513        let trace_done = trace_thunks && i < 120;
11514        if profile {
11515            prof_prev = Some((thunk_kind_name(thunk), std::time::Instant::now()));
11516        }
11517        match thunk {
11518            Thunk::Nop => exec_nop(thunk),
11519            Thunk::ElementwiseRegion { .. } => exec_elementwise_region(thunk, base),
11520            Thunk::GaussianSplatRender { .. } => exec_gaussian_splat_render(thunk, base),
11521            Thunk::GaussianSplatRenderBackward { .. } => {
11522                exec_gaussian_splat_render_backward(thunk, base)
11523            }
11524            Thunk::GaussianSplatPrepare { .. } => exec_gaussian_splat_prepare(thunk, base),
11525            Thunk::GaussianSplatRasterize { .. } => exec_gaussian_splat_rasterize(thunk, base),
11526            Thunk::Fft1d { .. } => exec_fft1d(thunk, base),
11527            Thunk::FftButterflyStage { .. } => exec_fft_butterfly_stage(thunk, base),
11528            Thunk::LogMel { .. } => exec_log_mel(thunk, base),
11529            Thunk::LogMelBackward { .. } => exec_log_mel_backward(thunk, base),
11530            Thunk::WelchPeaks { .. } => exec_welch_peaks(thunk, base),
11531            Thunk::CustomFn { .. } => exec_custom_fn(thunk, base),
11532            Thunk::Sgemm { a, b, c, m, k, n } => {
11533                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
11534                if trace_thunks {
11535                    eprintln!("[sgemm] m={m} k={k} n={n} a={} b={} c={}", *a, *b, *c);
11536                }
11537                let c_len = m.saturating_mul(n);
11538                let a_len = m.saturating_mul(k);
11539                let b_len = k.saturating_mul(n);
11540                let arena_len = arena_buf.len();
11541                let max_a = (arena_len.saturating_sub(*a)) / 4;
11542                let max_b = (arena_len.saturating_sub(*b)) / 4;
11543                let max_c = (arena_len.saturating_sub(*c)) / 4;
11544                let a_len = a_len.min(max_a);
11545                let b_len = b_len.min(max_b);
11546                let c_len = c_len.min(max_c);
11547                unsafe {
11548                    let a_sl = sl(*a, base, a_len);
11549                    let b_sl = sl(*b, base, b_len);
11550                    let c_sl = sl_mut(*c, base, c_len);
11551                    if std::ptr::eq(a_sl.as_ptr(), c_sl.as_ptr())
11552                        || std::ptr::eq(b_sl.as_ptr(), c_sl.as_ptr())
11553                    {
11554                        let mut tmp = vec![0.0f32; c_len];
11555                        crate::blas::sgemm_auto(a_sl, b_sl, &mut tmp, m, k, n);
11556                        c_sl.copy_from_slice(&tmp);
11557                    } else {
11558                        crate::blas::sgemm_auto(a_sl, b_sl, c_sl, m, k, n);
11559                    }
11560                }
11561            }
11562
11563            Thunk::SgemmT {
11564                a,
11565                b,
11566                c,
11567                m,
11568                k,
11569                n,
11570                ta,
11571                tb,
11572            } => {
11573                // C[m,n] = op(A) @ op(B). RowMajor cblas: lda/ldb = stored
11574                // row-length of each operand → m if A is transposed else k;
11575                // k if B is transposed else n. Element counts are m*k / k*n
11576                // regardless of layout.
11577                let (m, k, n) = (*m as usize, *k as usize, *n as usize);
11578                let lda = if *ta { m } else { k };
11579                let ldb = if *tb { k } else { n };
11580                let arena_len = arena_buf.len();
11581                let a_len = (m * k).min((arena_len.saturating_sub(*a)) / 4);
11582                let b_len = (k * n).min((arena_len.saturating_sub(*b)) / 4);
11583                let c_len = (m * n).min((arena_len.saturating_sub(*c)) / 4);
11584                unsafe {
11585                    let a_sl = sl(*a, base, a_len);
11586                    let b_sl = sl(*b, base, b_len);
11587                    let c_sl = sl_mut(*c, base, c_len);
11588                    let (ap, bp) = (a_sl.as_ptr(), b_sl.as_ptr());
11589                    if std::ptr::eq(ap, c_sl.as_ptr()) || std::ptr::eq(bp, c_sl.as_ptr()) {
11590                        let mut tmp = vec![0.0f32; c_len];
11591                        crate::blas::sgemm_general(
11592                            ap,
11593                            bp,
11594                            tmp.as_mut_ptr(),
11595                            m,
11596                            n,
11597                            k,
11598                            1.0,
11599                            0.0,
11600                            lda,
11601                            ldb,
11602                            n,
11603                            *ta,
11604                            *tb,
11605                        );
11606                        c_sl.copy_from_slice(&tmp);
11607                    } else {
11608                        crate::blas::sgemm_general(
11609                            ap,
11610                            bp,
11611                            c_sl.as_mut_ptr(),
11612                            m,
11613                            n,
11614                            k,
11615                            1.0,
11616                            0.0,
11617                            lda,
11618                            ldb,
11619                            n,
11620                            *ta,
11621                            *tb,
11622                        );
11623                    }
11624                }
11625            }
11626
11627            Thunk::SgdMomentum { .. } => exec_sgd_momentum(thunk, base),
11628            Thunk::CgemmC64 { .. } => exec_cgemm_c64(thunk, base),
11629            Thunk::DenseSolveF64 { .. } => exec_dense_solve_f64(thunk, base),
11630            Thunk::DenseSolveF32 { .. } => exec_dense_solve_f32(thunk, base),
11631            Thunk::BatchedDenseSolveF64 { .. } => exec_batched_dense_solve_f64(thunk, base),
11632            Thunk::BatchedDenseSolveF32 { .. } => exec_batched_dense_solve_f32(thunk, base),
11633            Thunk::BatchedDgemmF64 { .. } => exec_batched_dgemm_f64(thunk, base),
11634            Thunk::BatchedSgemm {
11635                a,
11636                b,
11637                c,
11638                batch,
11639                m,
11640                k,
11641                n,
11642            } => {
11643                let (b_, m_, k_, n_) = (*batch as usize, *m as usize, *k as usize, *n as usize);
11644                if trace_thunks {
11645                    eprintln!(
11646                        "[batched-sgemm] batch={b_} m={m_} k={k_} n={n_} a={} b={} c={}",
11647                        *a, *b, *c
11648                    );
11649                }
11650                let a_stride = m_.saturating_mul(k_);
11651                let b_stride = k_.saturating_mul(n_);
11652                let c_stride = m_.saturating_mul(n_);
11653                let arena_len = arena_buf.len();
11654                let a_cap = (arena_len.saturating_sub(*a)) / 4;
11655                let b_cap = (arena_len.saturating_sub(*b)) / 4;
11656                let c_cap = (arena_len.saturating_sub(*c)) / 4;
11657                let a_elems = (b_ * a_stride).min(a_cap);
11658                let b_elems = (b_ * b_stride).min(b_cap);
11659                let c_elems = (b_ * c_stride).min(c_cap);
11660                let b_eff = b_
11661                    .min(a_elems.checked_div(a_stride).unwrap_or(0))
11662                    .min(b_elems.checked_div(b_stride).unwrap_or(0))
11663                    .min(c_elems.checked_div(c_stride).unwrap_or(0));
11664                unsafe {
11665                    let a_full = sl(*a, base, a_elems);
11666                    let b_full = sl(*b, base, b_elems);
11667                    let c_full = sl_mut(*c, base, c_elems);
11668                    for bi in 0..b_eff {
11669                        let a0 = bi * a_stride;
11670                        let b0 = bi * b_stride;
11671                        let c0 = bi * c_stride;
11672                        if a0 + a_stride > a_full.len()
11673                            || b0 + b_stride > b_full.len()
11674                            || c0 + c_stride > c_full.len()
11675                        {
11676                            break;
11677                        }
11678                        let a_slice = &a_full[a0..a0 + a_stride];
11679                        let b_slice = &b_full[b0..b0 + b_stride];
11680                        let c_slice = &mut c_full[c0..c0 + c_stride];
11681                        if std::ptr::eq(a_slice.as_ptr(), c_slice.as_mut_ptr())
11682                            || std::ptr::eq(b_slice.as_ptr(), c_slice.as_mut_ptr())
11683                        {
11684                            let mut tmp = vec![0.0f32; c_stride];
11685                            crate::blas::sgemm_auto(a_slice, b_slice, &mut tmp, m_, k_, n_);
11686                            c_slice.copy_from_slice(&tmp);
11687                        } else {
11688                            crate::blas::sgemm_auto(a_slice, b_slice, c_slice, m_, k_, n_);
11689                        }
11690                    }
11691                }
11692            }
11693
11694            Thunk::Dgemm { .. } => exec_dgemm(thunk, base),
11695            Thunk::TransposeF64 { .. } => exec_transpose_f64(thunk, base),
11696            Thunk::ActivationF64 { .. } => exec_activation_f64(thunk, base),
11697            Thunk::ReduceSumF64 { .. } => exec_reduce_sum_f64(thunk, base),
11698            Thunk::CopyF64 { src, dst, len } => {
11699                let mut len = *len as usize;
11700                if *src == *dst || len == 0 {
11701                    continue;
11702                }
11703                let arena_len = arena_buf.len();
11704                let max_from_src = (arena_len.saturating_sub(*src)) / 8;
11705                let max_from_dst = (arena_len.saturating_sub(*dst)) / 8;
11706                len = len.min(max_from_src).min(max_from_dst);
11707                if len == 0 {
11708                    continue;
11709                }
11710                let byte_len = len.saturating_mul(8);
11711                unsafe {
11712                    std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
11713                }
11714            }
11715
11716            Thunk::CopyI64 { src, dst, len } => {
11717                let mut len = *len as usize;
11718                if *src == *dst || len == 0 {
11719                    continue;
11720                }
11721                let arena_len = arena_buf.len();
11722                let max_from_src = (arena_len.saturating_sub(*src)) / 8;
11723                let max_from_dst = (arena_len.saturating_sub(*dst)) / 8;
11724                len = len.min(max_from_src).min(max_from_dst);
11725                if len == 0 {
11726                    continue;
11727                }
11728                let byte_len = len.saturating_mul(8);
11729                unsafe {
11730                    std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
11731                }
11732            }
11733
11734            Thunk::CastF32ToI64 { src, dst, len } => {
11735                let len = *len as usize;
11736                if len == 0 {
11737                    continue;
11738                }
11739                unsafe {
11740                    let inp = sl(*src, base, len);
11741                    let out = sl_mut_i64(*dst, base, len);
11742                    for i in 0..len {
11743                        out[i] = inp[i].round() as i64;
11744                    }
11745                }
11746            }
11747
11748            Thunk::CastF32ToF64 { src, dst, len } => {
11749                let len = *len as usize;
11750                if len == 0 {
11751                    continue;
11752                }
11753                unsafe {
11754                    let inp = sl(*src, base, len);
11755                    let out = sl_mut_f64(*dst, base, len);
11756                    for i in 0..len {
11757                        out[i] = inp[i] as f64;
11758                    }
11759                }
11760            }
11761
11762            Thunk::CastF32ToI32 { src, dst, len } => {
11763                let len = *len as usize;
11764                if len == 0 {
11765                    continue;
11766                }
11767                unsafe {
11768                    let inp = sl(*src, base, len);
11769                    let out = sl_mut_i32(*dst, base, len);
11770                    for i in 0..len {
11771                        out[i] = inp[i].round() as i32;
11772                    }
11773                }
11774            }
11775
11776            Thunk::CastI64ToF32 { src, dst, len } => {
11777                let len = *len as usize;
11778                if len == 0 {
11779                    continue;
11780                }
11781                unsafe {
11782                    let inp = sl_i64(*src, base, len);
11783                    let out = sl_mut(*dst, base, len);
11784                    for i in 0..len {
11785                        out[i] = inp[i] as f32;
11786                    }
11787                }
11788            }
11789
11790            Thunk::CastBoolToI32 { src, dst, len } => {
11791                let len = *len as usize;
11792                if len == 0 {
11793                    continue;
11794                }
11795                unsafe {
11796                    let inp = &arena_buf[*src..*src + len];
11797                    let out = sl_mut_i32(*dst, base, len);
11798                    for i in 0..len {
11799                        out[i] = i32::from(inp[i] != 0);
11800                    }
11801                }
11802            }
11803
11804            Thunk::CastI32ToF32 { src, dst, len } => {
11805                let len = *len as usize;
11806                if len == 0 {
11807                    continue;
11808                }
11809                unsafe {
11810                    let inp = sl_i32(*src, base, len);
11811                    let out = sl_mut(*dst, base, len);
11812                    for i in 0..len {
11813                        out[i] = inp[i] as f32;
11814                    }
11815                }
11816            }
11817
11818            Thunk::CastBoolToF32 { src, dst, len } => {
11819                let len = *len as usize;
11820                if len == 0 {
11821                    continue;
11822                }
11823                unsafe {
11824                    let inp = &arena_buf[*src..*src + len];
11825                    let out = sl_mut(*dst, base, len);
11826                    for i in 0..len {
11827                        out[i] = if inp[i] != 0 { 1.0 } else { 0.0 };
11828                    }
11829                }
11830            }
11831
11832            Thunk::BinaryFullF64 { .. } => exec_binary_full_f64(thunk, base),
11833            Thunk::BinaryFullC64 { .. } => exec_binary_full_c64(thunk, base),
11834            Thunk::ComplexNormSqF32 { .. } => exec_complex_norm_sq_f32(thunk, base),
11835            Thunk::ComplexNormSqBackwardF32 { .. } => {
11836                exec_complex_norm_sq_backward_f32(thunk, base)
11837            }
11838            Thunk::ConjugateC64 { .. } => exec_conjugate_c64(thunk, base),
11839            Thunk::ActivationC64 { .. } => exec_activation_c64(thunk, base),
11840            Thunk::Scan { .. } => exec_scan(thunk, base),
11841            Thunk::ScanBackward {
11842                body_vjp,
11843                body_init,
11844                body_carry_in_off,
11845                body_x_offs,
11846                body_d_output_off,
11847                body_dcarry_out_off,
11848                outer_init_off,
11849                outer_traj_off,
11850                outer_upstream_off,
11851                outer_xs_offs,
11852                outer_dinit_off,
11853                length,
11854                carry_bytes,
11855                save_trajectory,
11856                num_checkpoints,
11857                forward_body,
11858                forward_body_init,
11859                forward_body_carry_in_off,
11860                forward_body_output_off,
11861                forward_body_x_offs,
11862                carry_elem_size,
11863            } => {
11864                // Two backward paths share the same per-iteration body
11865                // (body_vjp run + dcarry threading). The "All" path
11866                // reads the carry directly from the saved trajectory
11867                // each step. The "Recursive checkpointing" path stores
11868                // only K saved checkpoints and reconstructs intermediate
11869                // carries via Griewank-style recursive subdivision —
11870                // see [`griewank_process_segment`]. Auxiliary memory
11871                // is `O(log(segment_size) · carry_bytes)` for the
11872                // recursion stack, vs the old segment-cache scheme's
11873                // `O(segment_size · carry_bytes)`. Total recompute work
11874                // grows from `O(length)` to `O(length · log)`, which
11875                // is the canonical Griewank trade.
11876                let cb = *carry_bytes as usize;
11877                let n_steps = *length as usize;
11878                let k_total = *num_checkpoints as usize;
11879                let is_recursive = k_total != 0 && k_total != n_steps;
11880                let checkpoint_t_for_k = |k: usize| -> usize {
11881                    ((k + 1) * n_steps)
11882                        .div_ceil(k_total)
11883                        .saturating_sub(1)
11884                        .min(n_steps - 1)
11885                };
11886
11887                let mut fwd_buf: Vec<u8> = if is_recursive {
11888                    (**forward_body_init.as_ref().unwrap()).clone()
11889                } else {
11890                    Vec::new()
11891                };
11892
11893                let mut dcarry: Vec<u8> = vec![0u8; cb];
11894                if !*save_trajectory {
11895                    unsafe {
11896                        std::ptr::copy_nonoverlapping(
11897                            base.add(*outer_upstream_off),
11898                            dcarry.as_mut_ptr(),
11899                            cb,
11900                        );
11901                    }
11902                }
11903
11904                let mut body_buf: Vec<u8> = (**body_init).clone();
11905
11906                // Per-iteration backward action — shared between the
11907                // direct-trajectory (All) and Griewank (Recursive) paths.
11908                // Both feed the same body_vjp run with carry-at-t,
11909                // x_t_i, and d_output, then thread dcarry backward.
11910                let process_iter =
11911                    |t: usize, carry_in: &[u8], dcarry: &mut Vec<u8>, body_buf: &mut Vec<u8>| {
11912                        if *save_trajectory {
11913                            unsafe {
11914                                let up_off = *outer_upstream_off + t * cb;
11915                                match *carry_elem_size {
11916                                    4 => {
11917                                        let up_ptr = base.add(up_off) as *const f32;
11918                                        let dc_ptr = dcarry.as_mut_ptr() as *mut f32;
11919                                        let n_elems = cb / 4;
11920                                        for i in 0..n_elems {
11921                                            *dc_ptr.add(i) += *up_ptr.add(i);
11922                                        }
11923                                    }
11924                                    8 => {
11925                                        let up_ptr = base.add(up_off) as *const f64;
11926                                        let dc_ptr = dcarry.as_mut_ptr() as *mut f64;
11927                                        let n_elems = cb / 8;
11928                                        for i in 0..n_elems {
11929                                            *dc_ptr.add(i) += *up_ptr.add(i);
11930                                        }
11931                                    }
11932                                    other => panic!(
11933                                        "ScanBackward: unsupported carry elem size {other} \
11934                                     (only f32/f64 carries are supported today)"
11935                                    ),
11936                                }
11937                            }
11938                        }
11939                        body_buf[*body_carry_in_off..*body_carry_in_off + cb]
11940                            .copy_from_slice(carry_in);
11941                        unsafe {
11942                            for (i, body_x_off) in body_x_offs.iter().enumerate() {
11943                                let (outer_xs_off, per_step_bytes) = outer_xs_offs[i];
11944                                let psb = per_step_bytes as usize;
11945                                std::ptr::copy_nonoverlapping(
11946                                    base.add(outer_xs_off + t * psb),
11947                                    body_buf.as_mut_ptr().add(*body_x_off),
11948                                    psb,
11949                                );
11950                            }
11951                            std::ptr::copy_nonoverlapping(
11952                                dcarry.as_ptr(),
11953                                body_buf.as_mut_ptr().add(*body_d_output_off),
11954                                cb,
11955                            );
11956                        }
11957                        execute_thunks(body_vjp, body_buf);
11958                        unsafe {
11959                            std::ptr::copy_nonoverlapping(
11960                                body_buf.as_ptr().add(*body_dcarry_out_off),
11961                                dcarry.as_mut_ptr(),
11962                                cb,
11963                            );
11964                        }
11965                    };
11966
11967                if is_recursive {
11968                    // Griewank treeverse path. Process saved-checkpoint
11969                    // segments from highest-t to lowest-t; within each,
11970                    // recursive binary subdivision via
11971                    // `griewank_process_segment`. Auxiliary memory:
11972                    // O(log(seg_size) · cb) for the recursion stack
11973                    // (vs O(seg_size · cb) for the older segment-cache
11974                    // scheme); recompute work: O(seg_size · log).
11975                    let leaf_threshold = 4usize;
11976                    let fb_sched = forward_body.as_ref().unwrap();
11977                    let fb_init = forward_body_init.as_ref().unwrap().as_slice();
11978                    let mut segment_end = n_steps - 1;
11979                    for seg_k in (0..k_total).rev() {
11980                        let segment_start = if seg_k == 0 {
11981                            0
11982                        } else {
11983                            checkpoint_t_for_k(seg_k - 1) + 1
11984                        };
11985                        let mut anchor: Vec<u8> = vec![0u8; cb];
11986                        unsafe {
11987                            let src = if seg_k == 0 {
11988                                base.add(*outer_init_off)
11989                            } else {
11990                                base.add(*outer_traj_off + (seg_k - 1) * cb)
11991                            };
11992                            std::ptr::copy_nonoverlapping(src, anchor.as_mut_ptr(), cb);
11993                        }
11994                        // Closure adapter for the helper's signature
11995                        // (mutably re-borrows dcarry / body_buf each call).
11996                        let mut leaf_action = |t: usize, carry_in: &[u8]| {
11997                            process_iter(t, carry_in, &mut dcarry, &mut body_buf);
11998                        };
11999                        unsafe {
12000                            griewank_process_segment(
12001                                segment_start,
12002                                segment_end,
12003                                &anchor,
12004                                cb,
12005                                fb_sched,
12006                                fb_init,
12007                                *forward_body_carry_in_off,
12008                                *forward_body_output_off,
12009                                forward_body_x_offs,
12010                                base,
12011                                outer_xs_offs,
12012                                &mut fwd_buf,
12013                                leaf_threshold,
12014                                &mut leaf_action,
12015                            );
12016                        }
12017                        if seg_k == 0 {
12018                            break;
12019                        }
12020                        segment_end = segment_start - 1;
12021                    }
12022                } else {
12023                    // All-trajectory path: read each carry directly
12024                    // from the saved trajectory buffer.
12025                    let mut carry_buf: Vec<u8> = vec![0u8; cb];
12026                    for t in (0..n_steps).rev() {
12027                        unsafe {
12028                            let src = if t == 0 {
12029                                base.add(*outer_init_off)
12030                            } else {
12031                                base.add(*outer_traj_off + (t - 1) * cb)
12032                            };
12033                            std::ptr::copy_nonoverlapping(src, carry_buf.as_mut_ptr(), cb);
12034                        }
12035                        process_iter(t, &carry_buf, &mut dcarry, &mut body_buf);
12036                    }
12037                }
12038
12039                unsafe {
12040                    std::ptr::copy_nonoverlapping(dcarry.as_ptr(), base.add(*outer_dinit_off), cb);
12041                }
12042            }
12043
12044            Thunk::ScanBackwardXs { .. } => exec_scan_backward_xs(thunk, base),
12045            Thunk::FusedMmBiasAct { .. } => exec_fused_mm_bias_act(thunk, base),
12046            Thunk::FusedResidualLN {
12047                x,
12048                res,
12049                bias,
12050                g,
12051                b,
12052                out,
12053                rows,
12054                h,
12055                eps,
12056                has_bias,
12057            } => {
12058                let (rows, h) = (*rows as usize, *h as usize);
12059                unsafe {
12060                    let zero = &zero_bias[..h];
12061                    let bi = if *has_bias { sl(*bias, base, h) } else { zero };
12062                    let x_ptr = sl(*x, base, rows * h).as_ptr() as usize;
12063                    let r_ptr = sl(*res, base, rows * h).as_ptr() as usize;
12064                    let o_ptr = sl_mut(*out, base, rows * h).as_mut_ptr() as usize;
12065                    let bi_ptr = bi.as_ptr() as usize;
12066                    let g_ptr = sl(*g, base, h).as_ptr() as usize;
12067                    let b_ptr = sl(*b, base, h).as_ptr() as usize;
12068                    let e = *eps;
12069                    crate::pool::par_for(rows, 4, &|off, cnt| {
12070                        let xs =
12071                            std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
12072                        let rs =
12073                            std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
12074                        let os = std::slice::from_raw_parts_mut(
12075                            (o_ptr as *mut f32).add(off * h),
12076                            cnt * h,
12077                        );
12078                        let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
12079                        let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
12080                        let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
12081                        crate::kernels::residual_bias_layer_norm(xs, rs, bi, g, b, os, cnt, h, e);
12082                    });
12083                }
12084            }
12085
12086            Thunk::FusedResidualRmsNorm {
12087                x,
12088                res,
12089                bias,
12090                g,
12091                b,
12092                out,
12093                rows,
12094                h,
12095                eps,
12096                has_bias,
12097            } => {
12098                let (rows, h) = (*rows as usize, *h as usize);
12099                unsafe {
12100                    let zero = &zero_bias[..h];
12101                    let bi = if *has_bias { sl(*bias, base, h) } else { zero };
12102                    let x_ptr = sl(*x, base, rows * h).as_ptr() as usize;
12103                    let r_ptr = sl(*res, base, rows * h).as_ptr() as usize;
12104                    let o_ptr = sl_mut(*out, base, rows * h).as_mut_ptr() as usize;
12105                    let bi_ptr = bi.as_ptr() as usize;
12106                    let g_ptr = sl(*g, base, h).as_ptr() as usize;
12107                    let b_ptr = sl(*b, base, h).as_ptr() as usize;
12108                    let e = *eps;
12109                    crate::pool::par_for(rows, 4, &|off, cnt| {
12110                        let xs =
12111                            std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
12112                        let rs =
12113                            std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
12114                        let os = std::slice::from_raw_parts_mut(
12115                            (o_ptr as *mut f32).add(off * h),
12116                            cnt * h,
12117                        );
12118                        let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
12119                        let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
12120                        let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
12121                        crate::kernels::residual_bias_rms_norm(xs, rs, bi, g, b, os, cnt, h, e);
12122                    });
12123                }
12124            }
12125
12126            Thunk::BiasAdd { .. } => exec_bias_add(thunk, base),
12127            Thunk::BinaryFull {
12128                lhs,
12129                rhs,
12130                dst,
12131                len,
12132                lhs_len,
12133                rhs_len,
12134                op,
12135                out_dims_bcast,
12136                bcast_lhs_strides,
12137                bcast_rhs_strides,
12138                elem_bytes,
12139            } => {
12140                let len = *len as usize;
12141                let ll = (*lhs_len as usize).max(1);
12142                let rl = (*rhs_len as usize).max(1);
12143                let eb = (*elem_bytes).max(1) as usize;
12144                let arena_len = arena_buf.len();
12145                let ll = ll.min((arena_len.saturating_sub(*lhs)) / eb);
12146                let rl = rl.min((arena_len.saturating_sub(*rhs)) / eb);
12147                let len = len.min((arena_len.saturating_sub(*dst)) / eb);
12148                unsafe {
12149                    if eb == 8 {
12150                        let l = sl_i64(*lhs, base, ll);
12151                        let r = sl_i64(*rhs, base, rl);
12152                        let o = sl_mut_i64(*dst, base, len);
12153                        if !out_dims_bcast.is_empty() {
12154                            let rank = out_dims_bcast.len();
12155                            let mut coords = vec![0u32; rank];
12156                            for i in 0..len {
12157                                let mut rem = i;
12158                                for ax in (0..rank).rev() {
12159                                    let sz = out_dims_bcast[ax] as usize;
12160                                    coords[ax] = (rem % sz) as u32;
12161                                    rem /= sz;
12162                                }
12163                                let mut li = 0usize;
12164                                let mut ri = 0usize;
12165                                for ax in 0..rank {
12166                                    li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
12167                                    ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
12168                                }
12169                                o[i] = match op {
12170                                    BinaryOp::Add => l[li].wrapping_add(r[ri]),
12171                                    BinaryOp::Sub => l[li].wrapping_sub(r[ri]),
12172                                    BinaryOp::Mul => l[li].wrapping_mul(r[ri]),
12173                                    BinaryOp::Div => {
12174                                        if r[ri] == 0 {
12175                                            0
12176                                        } else {
12177                                            l[li] / r[ri]
12178                                        }
12179                                    }
12180                                    BinaryOp::Max => l[li].max(r[ri]),
12181                                    BinaryOp::Min => l[li].min(r[ri]),
12182                                    BinaryOp::Pow => l[li].pow(r[ri].max(0) as u32),
12183                                };
12184                            }
12185                        } else {
12186                            for i in 0..len {
12187                                let li = if ll == 1 { 0 } else { i % ll };
12188                                let ri = if rl == 1 { 0 } else { i % rl };
12189                                o[i] = match op {
12190                                    BinaryOp::Add => l[li].wrapping_add(r[ri]),
12191                                    BinaryOp::Sub => l[li].wrapping_sub(r[ri]),
12192                                    BinaryOp::Mul => l[li].wrapping_mul(r[ri]),
12193                                    BinaryOp::Div => {
12194                                        if r[ri] == 0 {
12195                                            0
12196                                        } else {
12197                                            l[li] / r[ri]
12198                                        }
12199                                    }
12200                                    BinaryOp::Max => l[li].max(r[ri]),
12201                                    BinaryOp::Min => l[li].min(r[ri]),
12202                                    BinaryOp::Pow => l[li].pow(r[ri].max(0) as u32),
12203                                };
12204                            }
12205                        }
12206                    } else {
12207                        let l = sl(*lhs, base, ll);
12208                        let r = sl(*rhs, base, rl);
12209                        let o = sl_mut(*dst, base, len);
12210                        if ll == len && rl == len {
12211                            #[cfg(target_arch = "aarch64")]
12212                            if matches!(op, BinaryOp::Add | BinaryOp::Mul) {
12213                                use std::arch::aarch64::*;
12214                                let chunks = len / 4;
12215                                for c in 0..chunks {
12216                                    let off = c * 4;
12217                                    let vl = vld1q_f32(l.as_ptr().add(off));
12218                                    let vr = vld1q_f32(r.as_ptr().add(off));
12219                                    let res = match op {
12220                                        BinaryOp::Add => vaddq_f32(vl, vr),
12221                                        BinaryOp::Mul => vmulq_f32(vl, vr),
12222                                        _ => unreachable!(),
12223                                    };
12224                                    vst1q_f32(o.as_mut_ptr().add(off), res);
12225                                }
12226                                for i in (chunks * 4)..len {
12227                                    o[i] = match op {
12228                                        BinaryOp::Add => l[i] + r[i],
12229                                        BinaryOp::Mul => l[i] * r[i],
12230                                        _ => unreachable!(),
12231                                    };
12232                                }
12233                                continue;
12234                            }
12235                        }
12236                        if !out_dims_bcast.is_empty() {
12237                            let rank = out_dims_bcast.len();
12238                            let mut coords = vec![0u32; rank];
12239                            for i in 0..len {
12240                                let mut rem = i;
12241                                for ax in (0..rank).rev() {
12242                                    let sz = out_dims_bcast[ax] as usize;
12243                                    coords[ax] = (rem % sz) as u32;
12244                                    rem /= sz;
12245                                }
12246                                let mut li = 0usize;
12247                                let mut ri = 0usize;
12248                                for ax in 0..rank {
12249                                    li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
12250                                    ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
12251                                }
12252                                o[i] = match op {
12253                                    BinaryOp::Add => l[li] + r[ri],
12254                                    BinaryOp::Sub => l[li] - r[ri],
12255                                    BinaryOp::Mul => l[li] * r[ri],
12256                                    BinaryOp::Div => l[li] / r[ri],
12257                                    BinaryOp::Max => l[li].max(r[ri]),
12258                                    BinaryOp::Min => l[li].min(r[ri]),
12259                                    BinaryOp::Pow => l[li].powf(r[ri]),
12260                                };
12261                            }
12262                        } else {
12263                            for i in 0..len {
12264                                let li = if ll == 1 { 0 } else { i % ll };
12265                                let ri = if rl == 1 { 0 } else { i % rl };
12266                                o[i] = match op {
12267                                    BinaryOp::Add => l[li] + r[ri],
12268                                    BinaryOp::Sub => l[li] - r[ri],
12269                                    BinaryOp::Mul => l[li] * r[ri],
12270                                    BinaryOp::Div => l[li] / r[ri],
12271                                    BinaryOp::Max => l[li].max(r[ri]),
12272                                    BinaryOp::Min => l[li].min(r[ri]),
12273                                    BinaryOp::Pow => l[li].powf(r[ri]),
12274                                };
12275                            }
12276                        }
12277                    }
12278                }
12279            }
12280
12281            Thunk::Gather { .. } => exec_gather(thunk, base),
12282            Thunk::Narrow {
12283                src,
12284                dst,
12285                outer,
12286                src_stride,
12287                dst_stride,
12288                inner,
12289                elem_bytes,
12290            } => {
12291                let (outer, ss, ds, inner, eb) = (
12292                    *outer as usize,
12293                    *src_stride as usize,
12294                    *dst_stride as usize,
12295                    *inner as usize,
12296                    *elem_bytes as usize,
12297                );
12298                let row_bytes = inner.saturating_mul(eb);
12299                let src_row_stride = ss.saturating_mul(eb);
12300                let dst_row_stride = ds.saturating_mul(eb);
12301                if trace_thunks {
12302                    eprintln!(
12303                        "[narrow] src={} dst={} outer={outer} ss={ss} ds={ds} inner={inner} eb={eb} row={row_bytes} arena={}",
12304                        *src,
12305                        *dst,
12306                        arena_buf.len()
12307                    );
12308                }
12309                if row_bytes > 0 && *src != *dst {
12310                    let arena_len = arena_buf.len();
12311                    for o in 0..outer {
12312                        let s_off = *src + o * src_row_stride;
12313                        let d_off = *dst + o * dst_row_stride;
12314                        if s_off == d_off {
12315                            continue;
12316                        }
12317                        if s_off.saturating_add(row_bytes) > arena_len
12318                            || d_off.saturating_add(row_bytes) > arena_len
12319                        {
12320                            break;
12321                        }
12322                        unsafe {
12323                            std::ptr::copy_nonoverlapping(
12324                                base.add(s_off),
12325                                base.add(d_off),
12326                                row_bytes,
12327                            );
12328                        }
12329                    }
12330                }
12331            }
12332
12333            Thunk::Copy { src, dst, len } => {
12334                let mut len = *len as usize;
12335                if *src == *dst || len == 0 {
12336                    continue;
12337                }
12338                let arena_len = arena_buf.len();
12339                let max_from_src = (arena_len.saturating_sub(*src)) / 4;
12340                let max_from_dst = (arena_len.saturating_sub(*dst)) / 4;
12341                len = len.min(max_from_src).min(max_from_dst);
12342                if len == 0 {
12343                    continue;
12344                }
12345                let byte_len = len.saturating_mul(4);
12346                unsafe {
12347                    std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
12348                }
12349            }
12350
12351            Thunk::LayerNorm { .. } => exec_layer_norm(thunk, base),
12352            Thunk::GroupNorm { .. } => exec_group_norm(thunk, base),
12353            Thunk::BatchNormInference { .. } => exec_batch_norm_inference(thunk, base),
12354            Thunk::LayerNorm2d { .. } => exec_layer_norm2d(thunk, base),
12355            Thunk::ConvTranspose2d { .. } => exec_conv_transpose2d(thunk, base),
12356            Thunk::ResizeNearest2x { .. } => exec_resize_nearest2x(thunk, base),
12357            Thunk::AxialRope2d { .. } => exec_axial_rope2d(thunk, base),
12358            Thunk::RmsNorm { .. } => exec_rms_norm(thunk, base),
12359            Thunk::Softmax { .. } => exec_softmax(thunk, base),
12360            Thunk::Cumsum { .. } => exec_cumsum(thunk, base),
12361            Thunk::Sample { .. } => exec_sample(thunk, base),
12362            Thunk::RngNormal {
12363                dst,
12364                len,
12365                mean,
12366                scale,
12367                key,
12368                op_seed,
12369            } => {
12370                let n = *len as usize;
12371                unsafe {
12372                    let out = sl_mut(*dst, base, n);
12373                    let opts = *schedule.rng.read().unwrap();
12374                    rlx_ir::fill_normal_like(out, *mean, *scale, opts, *key, *op_seed);
12375                }
12376            }
12377
12378            Thunk::RngUniform {
12379                dst,
12380                len,
12381                low,
12382                high,
12383                key,
12384                op_seed,
12385            } => {
12386                let n = *len as usize;
12387                unsafe {
12388                    let out = sl_mut(*dst, base, n);
12389                    let opts = *schedule.rng.read().unwrap();
12390                    rlx_ir::fill_uniform_like(out, *low, *high, opts, *key, *op_seed);
12391                }
12392            }
12393
12394            Thunk::GatedDeltaNet { .. } => exec_gated_delta_net(thunk, base),
12395            Thunk::Lstm { .. } => exec_lstm(thunk, base),
12396            Thunk::Gru { .. } => exec_gru(thunk, base),
12397            Thunk::Rnn { .. } => exec_rnn(thunk, base),
12398            Thunk::Mamba2 { .. } => exec_mamba2(thunk, base),
12399            Thunk::SelectiveScan { .. } => exec_selective_scan(thunk, base),
12400            Thunk::DequantMatMul { .. } => exec_dequant_mat_mul(thunk, base),
12401            Thunk::DequantMatMulGguf { .. } => exec_dequant_mat_mul_gguf(thunk, base),
12402            Thunk::DequantMatMulInt4 { .. } => exec_dequant_mat_mul_int4(thunk, base),
12403            Thunk::DequantMatMulFp8 { .. } => exec_dequant_mat_mul_fp8(thunk, base),
12404            Thunk::DequantMatMulNvfp4 { .. } => exec_dequant_mat_mul_nvfp4(thunk, base),
12405            Thunk::ScaledMatMul { .. } => exec_scaled_mat_mul(thunk, base),
12406            Thunk::ScaledQuantize { .. } => exec_scaled_quantize(thunk, base),
12407            Thunk::ScaledQuantScale { .. } => exec_scaled_quant_scale(thunk, base),
12408            Thunk::ScaledDequantize { .. } => exec_scaled_dequantize(thunk, base),
12409            Thunk::LoraMatMul { .. } => exec_lora_mat_mul(thunk, base),
12410            Thunk::Attention {
12411                q,
12412                k,
12413                v,
12414                mask,
12415                out,
12416                batch,
12417                seq,
12418                kv_seq,
12419                heads,
12420                head_dim,
12421                mask_kind,
12422                scale,
12423                softcap,
12424                q_row_stride,
12425                k_row_stride,
12426                v_row_stride,
12427                bhsd,
12428                kv_heads,
12429            } => {
12430                let (b, q_s, k_s, nh, dh) = (
12431                    *batch as usize,
12432                    *seq as usize,
12433                    *kv_seq as usize,
12434                    *heads as usize,
12435                    *head_dim as usize,
12436                );
12437                let nkv = (*kv_heads as usize).max(1);
12438                let group = (nh / nkv).max(1); // query heads per KV head (GQA/MQA)
12439                let hs = nh * dh;
12440                // For [B, H, S, D] layout each (b, h) tile is dense
12441                // contiguous; the qrs/krs/vrs strides are not used.
12442                let (qrs, krs, vrs) = if *bhsd {
12443                    (dh, dh, dh)
12444                } else {
12445                    (
12446                        *q_row_stride as usize,
12447                        *k_row_stride as usize,
12448                        *v_row_stride as usize,
12449                    )
12450                };
12451                let bhsd = *bhsd;
12452                let _ = (q_row_stride, k_row_stride, v_row_stride);
12453                let scale = *scale;
12454                let ss = q_s * k_s;
12455                let cfg = crate::config::RuntimeConfig::global();
12456                unsafe {
12457                    // Slice lengths cover the strided span. When Q/K/V
12458                    // alias the parent QKV (post-#46-fusion), the same
12459                    // bytes back all three slices — compiler bounds
12460                    // checks see the right size. For [B, H, S, D] the
12461                    // buffer is densely B*H*S*D elements; the row
12462                    // strides aren't used.
12463                    let q_len = if bhsd {
12464                        b * nh * q_s * dh
12465                    } else {
12466                        b * q_s * qrs
12467                    };
12468                    let k_len = if bhsd {
12469                        b * nkv * k_s * dh
12470                    } else {
12471                        b * k_s * krs
12472                    };
12473                    let v_len = if bhsd {
12474                        b * nkv * k_s * dh
12475                    } else {
12476                        b * k_s * vrs
12477                    };
12478                    let q_data = sl(*q, base, q_len);
12479                    let k_data = sl(*k, base, k_len);
12480                    let v_data = sl(*v, base, v_len);
12481                    let mask_data: &[f32] = match mask_kind {
12482                        rlx_ir::op::MaskKind::Custom => sl(*mask, base, b * k_s),
12483                        rlx_ir::op::MaskKind::Bias => sl(*mask, base, b * nh * q_s * k_s),
12484                        _ => &[],
12485                    };
12486                    let out_len = if bhsd {
12487                        b * nh * q_s * dh
12488                    } else {
12489                        b * q_s * hs
12490                    };
12491                    let out_data = sl_mut(*out, base, out_len);
12492
12493                    // ── [B, H, S, D] fallback ──────────────────────
12494                    // The NEON / strided-BLAS specializations below
12495                    // are written for the [B, S, H, D] layout. When
12496                    // the input is head-major ([B, H, S, D] —
12497                    // matching rlx-cuda / rlx-rocm / rlx-tpu), bypass
12498                    // them and run a simple (correct but slower)
12499                    // scalar implementation. Production-CPU inference
12500                    // graphs use [B, S, H, D] so they still hit the
12501                    // hot path; cross-backend parity tests use
12502                    // [B, H, S, D] and land here.
12503                    if bhsd {
12504                        let scores = &mut sdpa_scores[..ss];
12505                        for bi in 0..b {
12506                            for hi in 0..nh {
12507                                let kv_hi = hi / group; // GQA/MQA: shared KV head
12508                                let q_head_base = bi * nh * q_s * dh + hi * q_s * dh;
12509                                let k_head_base = bi * nkv * k_s * dh + kv_hi * k_s * dh;
12510                                // Q@K^T
12511                                for qi in 0..q_s {
12512                                    let q_base = q_head_base + qi * dh;
12513                                    for ki in 0..k_s {
12514                                        let k_base = k_head_base + ki * dh;
12515                                        let mut dot = 0f32;
12516                                        for d in 0..dh {
12517                                            dot += q_data[q_base + d] * k_data[k_base + d];
12518                                        }
12519                                        scores[qi * k_s + ki] = dot * scale;
12520                                        if matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
12521                                            && !mask_data.is_empty()
12522                                            && mask_data[bi * k_s + ki] < mask_thr
12523                                        {
12524                                            scores[qi * k_s + ki] = mask_neg;
12525                                        }
12526                                    }
12527                                }
12528                                if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) {
12529                                    let off = (bi * nh + hi) * q_s * k_s;
12530                                    for i in 0..q_s * k_s {
12531                                        scores[i] += mask_data[off + i];
12532                                    }
12533                                }
12534                                apply_synthetic_mask(scores, q_s, k_s, *mask_kind);
12535                                // Gemma 2 attention logit soft-cap (post-mask, pre-softmax).
12536                                if *softcap > 0.0 {
12537                                    for s in scores.iter_mut() {
12538                                        *s = *softcap * (*s / *softcap).tanh();
12539                                    }
12540                                }
12541                                crate::kernels::neon_softmax(scores, q_s, k_s);
12542                                // score @ V
12543                                for qi in 0..q_s {
12544                                    let o_base = q_head_base + qi * dh;
12545                                    for d in 0..dh {
12546                                        out_data[o_base + d] = 0.0;
12547                                    }
12548                                    for ki in 0..k_s {
12549                                        let sc = scores[qi * k_s + ki];
12550                                        if sc > score_thr {
12551                                            let v_base = k_head_base + ki * dh;
12552                                            for d in 0..dh {
12553                                                out_data[o_base + d] += sc * v_data[v_base + d];
12554                                            }
12555                                        }
12556                                    }
12557                                }
12558                            }
12559                        }
12560                        continue;
12561                    }
12562
12563                    // ── Auto-select kernel: NEON dots vs strided BLAS ───
12564                    // For tiny inputs (batch=1, short seq), per-head BLAS call
12565                    // overhead (~0.5µs × 2 calls × num_heads × num_layers)
12566                    // exceeds the NEON compute cost. Use direct strided NEON
12567                    // with zero dispatch overhead.
12568                    // For batch≥2: always BLAS + par_for (parallelism wins).
12569                    if b == 1 && q_s.max(k_s) <= cfg.sdpa_seq_threshold {
12570                        // ── Sequential NEON path (zero overhead) ──
12571                        let scores = &mut sdpa_scores[..ss];
12572                        #[cfg(target_arch = "aarch64")]
12573                        let neon_chunks = dh / 4;
12574
12575                        for bi in 0..b {
12576                            for hi in 0..nh {
12577                                let kv_hi = hi / group; // GQA/MQA: shared KV head
12578                                // Q@K^T via strided NEON dot products
12579                                for qi in 0..q_s {
12580                                    let q_off = bi * q_s * qrs + qi * qrs + hi * dh;
12581                                    for ki in 0..k_s {
12582                                        let k_off = bi * k_s * krs + ki * krs + kv_hi * dh;
12583                                        #[cfg(target_arch = "aarch64")]
12584                                        let mut dot;
12585                                        #[cfg(not(target_arch = "aarch64"))]
12586                                        let mut dot = 0f32;
12587                                        #[cfg(target_arch = "aarch64")]
12588                                        {
12589                                            use std::arch::aarch64::*;
12590                                            let mut acc = vdupq_n_f32(0.0);
12591                                            for c in 0..neon_chunks {
12592                                                let vq =
12593                                                    vld1q_f32(q_data.as_ptr().add(q_off + c * 4));
12594                                                let vk =
12595                                                    vld1q_f32(k_data.as_ptr().add(k_off + c * 4));
12596                                                acc = vfmaq_f32(acc, vq, vk);
12597                                            }
12598                                            dot = vaddvq_f32(acc);
12599                                            for d in (neon_chunks * 4)..dh {
12600                                                dot += q_data[q_off + d] * k_data[k_off + d];
12601                                            }
12602                                        }
12603                                        #[cfg(not(target_arch = "aarch64"))]
12604                                        for d in 0..dh {
12605                                            dot += q_data[q_off + d] * k_data[k_off + d];
12606                                        }
12607                                        scores[qi * k_s + ki] = dot * scale;
12608                                        // Inner-loop Custom mask check —
12609                                        // Causal / SlidingWindow / None
12610                                        // apply outside the loop below.
12611                                        // Skip for Bias — that mask is a
12612                                        // per-head additive tensor, not a
12613                                        // 0/1 key-padding mask.
12614                                        if matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
12615                                            && !mask_data.is_empty()
12616                                            && mask_data[bi * k_s + ki] < mask_thr
12617                                        {
12618                                            scores[qi * k_s + ki] = mask_neg;
12619                                        }
12620                                    }
12621                                }
12622
12623                                if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) {
12624                                    let off = (bi * nh + hi) * q_s * k_s;
12625                                    for i in 0..q_s * k_s {
12626                                        scores[i] += mask_data[off + i];
12627                                    }
12628                                }
12629                                apply_synthetic_mask(scores, q_s, k_s, *mask_kind);
12630                                crate::kernels::neon_softmax(scores, q_s, k_s);
12631
12632                                // Score@V via strided NEON accumulation (zero-copy)
12633                                for qi in 0..q_s {
12634                                    let o_off = bi * q_s * hs + qi * hs + hi * dh;
12635                                    // Zero output for this head position
12636                                    for d in 0..dh {
12637                                        out_data[o_off + d] = 0.0;
12638                                    }
12639                                    for ki in 0..k_s {
12640                                        let sc = scores[qi * k_s + ki];
12641                                        if sc > score_thr {
12642                                            let v_off = bi * k_s * vrs + ki * vrs + kv_hi * dh;
12643                                            #[cfg(target_arch = "aarch64")]
12644                                            {
12645                                                use std::arch::aarch64::*;
12646                                                let vsc = vdupq_n_f32(sc);
12647                                                for c in 0..neon_chunks {
12648                                                    let off = c * 4;
12649                                                    let vo = vld1q_f32(
12650                                                        out_data.as_ptr().add(o_off + off),
12651                                                    );
12652                                                    let vv =
12653                                                        vld1q_f32(v_data.as_ptr().add(v_off + off));
12654                                                    vst1q_f32(
12655                                                        out_data.as_mut_ptr().add(o_off + off),
12656                                                        vfmaq_f32(vo, vsc, vv),
12657                                                    );
12658                                                }
12659                                            }
12660                                            #[cfg(not(target_arch = "aarch64"))]
12661                                            for d in 0..dh {
12662                                                out_data[o_off + d] += sc * v_data[v_off + d];
12663                                            }
12664                                        }
12665                                    }
12666                                }
12667                            }
12668                        }
12669                    } else {
12670                        // ── Parallel strided BLAS path (high throughput) ──
12671                        let total_work = b * nh;
12672                        let q_addr = q_data.as_ptr() as usize;
12673                        let k_addr = k_data.as_ptr() as usize;
12674                        let v_addr = v_data.as_ptr() as usize;
12675                        let m_addr = mask_data.as_ptr() as usize;
12676                        let o_addr = out_data.as_mut_ptr() as usize;
12677                        let sc_addr = sdpa_scores.as_mut_ptr() as usize;
12678
12679                        crate::pool::par_for(total_work, 1, &|off, cnt| {
12680                            for idx in off..off + cnt {
12681                                let bi = idx / nh;
12682                                let hi = idx % nh;
12683                                let kv_hi = hi / group; // GQA/MQA: shared KV head
12684
12685                                let q_start = (q_addr as *const f32).add(bi * q_s * qrs + hi * dh);
12686                                let k_start =
12687                                    (k_addr as *const f32).add(bi * k_s * krs + kv_hi * dh);
12688                                let v_start =
12689                                    (v_addr as *const f32).add(bi * k_s * vrs + kv_hi * dh);
12690                                let o_start = (o_addr as *mut f32).add(bi * q_s * hs + hi * dh);
12691                                let sc = std::slice::from_raw_parts_mut(
12692                                    (sc_addr as *mut f32).add(idx * ss),
12693                                    ss,
12694                                );
12695
12696                                // LDA = qrs, LDB = krs (parent row strides
12697                                // when fused; hs otherwise).
12698                                crate::blas::sgemm_general(
12699                                    q_start,
12700                                    k_start,
12701                                    sc.as_mut_ptr(),
12702                                    q_s,
12703                                    k_s,
12704                                    dh,
12705                                    scale,
12706                                    0.0,
12707                                    qrs,
12708                                    krs,
12709                                    k_s,
12710                                    false,
12711                                    true,
12712                                );
12713
12714                                match mask_kind {
12715                                    rlx_ir::op::MaskKind::Custom => {
12716                                        let mask_bi = std::slice::from_raw_parts(
12717                                            (m_addr as *const f32).add(bi * k_s),
12718                                            k_s,
12719                                        );
12720                                        for ki in 0..k_s {
12721                                            if mask_bi[ki] < mask_thr {
12722                                                for qi in 0..q_s {
12723                                                    sc[qi * k_s + ki] = mask_neg;
12724                                                }
12725                                            }
12726                                        }
12727                                    }
12728                                    rlx_ir::op::MaskKind::Bias => {
12729                                        // Per-head additive bias slice.
12730                                        let bias = std::slice::from_raw_parts(
12731                                            (m_addr as *const f32).add((bi * nh + hi) * q_s * k_s),
12732                                            q_s * k_s,
12733                                        );
12734                                        for i in 0..q_s * k_s {
12735                                            sc[i] += bias[i];
12736                                        }
12737                                    }
12738                                    _ => apply_synthetic_mask(sc, q_s, k_s, *mask_kind),
12739                                }
12740
12741                                crate::kernels::neon_softmax(sc, q_s, k_s);
12742
12743                                // LDB = vrs (parent row stride when
12744                                // fused; hs otherwise). LDC stays hs —
12745                                // output is its own contiguous buffer.
12746                                crate::blas::sgemm_general(
12747                                    sc.as_ptr(),
12748                                    v_start,
12749                                    o_start,
12750                                    q_s,
12751                                    dh,
12752                                    k_s,
12753                                    1.0,
12754                                    0.0,
12755                                    k_s,
12756                                    vrs,
12757                                    hs,
12758                                    false,
12759                                    false,
12760                                );
12761                            }
12762                        });
12763                    }
12764                }
12765            }
12766
12767            Thunk::AttentionBackward { .. } => exec_attention_backward(thunk, base),
12768            Thunk::ActivationInPlace { .. } => exec_activation_in_place(thunk, base),
12769            Thunk::FusedAttnBlock {
12770                hidden,
12771                qkv_w,
12772                out_w,
12773                mask,
12774                mask_kind,
12775                out,
12776                qkv_b,
12777                out_b,
12778                cos,
12779                sin,
12780                cos_len,
12781                batch,
12782                seq,
12783                hs,
12784                nh,
12785                dh,
12786                has_bias,
12787                has_rope,
12788                interleaved,
12789            } => {
12790                let (b, s) = (*batch as usize, *seq as usize);
12791                let (h, n_h, d_h) = (*hs as usize, *nh as usize, *dh as usize);
12792                let interleaved = *interleaved;
12793                let m = b * s;
12794                let scale = (d_h as f32).powf(-0.5);
12795                let half = d_h / 2;
12796                // Only `Custom` consumes the per-key padding buffer; `Causal` /
12797                // `SlidingWindow` are synthesized from (qi, ki) below, and have
12798                // no mask buffer (so reading one would touch unrelated arena
12799                // bytes). q_seq == kv_seq here (guaranteed at fusion time), so
12800                // the absolute query position is just `qi`.
12801                let use_custom_mask = matches!(mask_kind, rlx_ir::op::MaskKind::Custom);
12802                unsafe {
12803                    let inp = sl(*hidden, base, m * h);
12804                    let wq = sl(*qkv_w, base, h * 3 * h);
12805                    let wo = sl(*out_w, base, h * h);
12806                    let mk = if use_custom_mask {
12807                        sl(*mask, base, b * s)
12808                    } else {
12809                        &[]
12810                    };
12811                    let dst = sl_mut(*out, base, m * h);
12812
12813                    // Stack-allocated intermediates — all fit in L1 cache for small batch
12814                    let mut qkv = vec![0f32; m * 3 * h];
12815                    let mut attn_out = vec![0f32; m * h];
12816                    let mut scores_buf = vec![0f32; s * s]; // one head at a time
12817
12818                    // 1. QKV projection: [m, h] @ [h, 3h] → [m, 3h]
12819                    crate::blas::sgemm(inp, wq, &mut qkv, m, h, 3 * h);
12820                    if *has_bias {
12821                        let bias = sl(*qkv_b, base, 3 * h);
12822                        crate::blas::bias_add(&mut qkv, bias, m, 3 * h);
12823                    }
12824
12825                    // 2. Multi-head SDPA (Q/K/V are views into qkv at offsets 0, h, 2h)
12826                    //    Process heads sequentially with inline RoPE — zero copy.
12827                    #[cfg(target_arch = "aarch64")]
12828                    let neon_chunks = d_h / 4;
12829                    #[cfg(target_arch = "aarch64")]
12830                    let _rope_chunks = half / 4;
12831
12832                    for bi in 0..b {
12833                        for hi in 0..n_h {
12834                            // For each (query_pos, key_pos): compute Q@K^T with inline RoPE
12835                            for qi in 0..s {
12836                                let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
12837                                for ki in 0..s {
12838                                    let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
12839                                    let mut dot = 0f32;
12840
12841                                    if *has_rope {
12842                                        // Apply RoPE inline during dot product
12843                                        let q_cos = qi * half;
12844                                        let k_cos = ki * half;
12845                                        let cos_tab = sl(*cos, base, *cos_len as usize);
12846                                        let sin_tab = sl(*sin, base, *cos_len as usize);
12847                                        // Rotate per pair, then dot. The q·k sum
12848                                        // is layout-independent, so only the pair
12849                                        // element offsets differ by style:
12850                                        //   NeoX:  (i, i+half)   GPT-J: (2i, 2i+1)
12851                                        // angle index is the pair index `i` for both.
12852                                        for i in 0..half {
12853                                            let (qo1, qo2, ko1, ko2) = if interleaved {
12854                                                (2 * i, 2 * i + 1, 2 * i, 2 * i + 1)
12855                                            } else {
12856                                                (i, half + i, i, half + i)
12857                                            };
12858                                            let q1 = qkv[q_base + qo1];
12859                                            let q2 = qkv[q_base + qo2];
12860                                            let k1 = qkv[k_base + ko1];
12861                                            let k2 = qkv[k_base + ko2];
12862                                            let c_q = cos_tab[q_cos + i];
12863                                            let s_q = sin_tab[q_cos + i];
12864                                            let c_k = cos_tab[k_cos + i];
12865                                            let s_k = sin_tab[k_cos + i];
12866                                            let qr1 = q1 * c_q - q2 * s_q;
12867                                            let kr1 = k1 * c_k - k2 * s_k;
12868                                            let qr2 = q2 * c_q + q1 * s_q;
12869                                            let kr2 = k2 * c_k + k1 * s_k;
12870                                            dot += qr1 * kr1 + qr2 * kr2;
12871                                        }
12872                                    } else {
12873                                        // Standard dot product
12874                                        #[cfg(target_arch = "aarch64")]
12875                                        {
12876                                            use std::arch::aarch64::*;
12877                                            let mut acc = vdupq_n_f32(0.0);
12878                                            for c in 0..neon_chunks {
12879                                                let vq =
12880                                                    vld1q_f32(qkv.as_ptr().add(q_base + c * 4));
12881                                                let vk =
12882                                                    vld1q_f32(qkv.as_ptr().add(k_base + c * 4));
12883                                                acc = vfmaq_f32(acc, vq, vk);
12884                                            }
12885                                            dot = vaddvq_f32(acc);
12886                                            for d in (neon_chunks * 4)..d_h {
12887                                                dot += qkv[q_base + d] * qkv[k_base + d];
12888                                            }
12889                                        }
12890                                        #[cfg(not(target_arch = "aarch64"))]
12891                                        for d in 0..d_h {
12892                                            dot += qkv[q_base + d] * qkv[k_base + d];
12893                                        }
12894                                    }
12895
12896                                    scores_buf[qi * s + ki] = dot * scale;
12897                                    // Synthesized position masks (q_offset == 0):
12898                                    //   Causal         → mask future keys ki > qi
12899                                    //   SlidingWindow  → also mask ki + w < qi
12900                                    let pos_masked = match mask_kind {
12901                                        rlx_ir::op::MaskKind::Causal => ki > qi,
12902                                        rlx_ir::op::MaskKind::SlidingWindow(w) => {
12903                                            ki > qi || ki + *w < qi
12904                                        }
12905                                        _ => false,
12906                                    };
12907                                    if pos_masked || (use_custom_mask && mk[bi * s + ki] < mask_thr)
12908                                    {
12909                                        scores_buf[qi * s + ki] = mask_neg;
12910                                    }
12911                                }
12912                            }
12913
12914                            // Softmax
12915                            crate::kernels::neon_softmax(&mut scores_buf[..s * s], s, s);
12916
12917                            // Score @ V accumulation (V at offset 2h in QKV)
12918                            for qi in 0..s {
12919                                let o_base = bi * s * h + qi * h + hi * d_h;
12920                                for d in 0..d_h {
12921                                    attn_out[o_base + d] = 0.0;
12922                                }
12923                                for ki in 0..s {
12924                                    let sc = scores_buf[qi * s + ki];
12925                                    if sc > score_thr {
12926                                        let v_base = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
12927                                        #[cfg(target_arch = "aarch64")]
12928                                        {
12929                                            use std::arch::aarch64::*;
12930                                            let vsc = vdupq_n_f32(sc);
12931                                            for c in 0..neon_chunks {
12932                                                let off = c * 4;
12933                                                let vo =
12934                                                    vld1q_f32(attn_out.as_ptr().add(o_base + off));
12935                                                let vv = vld1q_f32(qkv.as_ptr().add(v_base + off));
12936                                                vst1q_f32(
12937                                                    attn_out.as_mut_ptr().add(o_base + off),
12938                                                    vfmaq_f32(vo, vsc, vv),
12939                                                );
12940                                            }
12941                                        }
12942                                        #[cfg(not(target_arch = "aarch64"))]
12943                                        for d in 0..d_h {
12944                                            attn_out[o_base + d] += sc * qkv[v_base + d];
12945                                        }
12946                                    }
12947                                }
12948                            }
12949                        }
12950                    }
12951
12952                    // 3. Output projection: [m, h] @ [h, h] → dst
12953                    crate::blas::sgemm(&attn_out, wo, dst, m, h, h);
12954                    if *has_bias {
12955                        let bias = sl(*out_b, base, h);
12956                        crate::blas::bias_add(dst, bias, m, h);
12957                    }
12958                }
12959            }
12960
12961            Thunk::Rope { .. } => exec_rope(thunk, base),
12962            Thunk::FusedBertLayer {
12963                hidden,
12964                qkv_w,
12965                qkv_b,
12966                out_w,
12967                out_b,
12968                mask,
12969                ln1_g,
12970                ln1_b,
12971                eps1,
12972                fc1_w,
12973                fc1_b,
12974                fc2_w,
12975                fc2_b,
12976                ln2_g,
12977                ln2_b,
12978                eps2,
12979                out,
12980                batch,
12981                seq,
12982                hs,
12983                nh,
12984                dh,
12985                int_dim,
12986            } => {
12987                let (b, s, h, n_h, d_h) = (
12988                    *batch as usize,
12989                    *seq as usize,
12990                    *hs as usize,
12991                    *nh as usize,
12992                    *dh as usize,
12993                );
12994                let m = b * s;
12995                let id = *int_dim as usize;
12996                let scale = (d_h as f32).powf(-0.5);
12997                let _half = d_h / 2;
12998                #[cfg(target_arch = "aarch64")]
12999                let neon_chunks = d_h / 4;
13000                unsafe {
13001                    let inp = sl(*hidden, base, m * h);
13002                    let dst = sl_mut(*out, base, m * h);
13003                    let mk = sl(*mask, base, b * s);
13004
13005                    // Pre-allocated buffers (zero malloc per layer — allocated once before thunk loop)
13006                    let qkv = std::slice::from_raw_parts_mut(fl_qkv.as_mut_ptr(), m * 3 * h);
13007                    let attn = std::slice::from_raw_parts_mut(fl_attn.as_mut_ptr(), m * h);
13008                    let res = std::slice::from_raw_parts_mut(fl_res.as_mut_ptr(), m * h);
13009                    let normed = std::slice::from_raw_parts_mut(fl_normed.as_mut_ptr(), m * h);
13010                    let ffn = std::slice::from_raw_parts_mut(fl_ffn.as_mut_ptr(), m * id);
13011                    let sc = std::slice::from_raw_parts_mut(fl_sc.as_mut_ptr(), s * s);
13012
13013                    // QKV (parallelized across cores — multiple AMX coprocessors)
13014                    crate::blas::par_sgemm_bias(
13015                        inp,
13016                        sl(*qkv_w, base, h * 3 * h),
13017                        sl(*qkv_b, base, 3 * h),
13018                        qkv,
13019                        m,
13020                        h,
13021                        3 * h,
13022                    );
13023
13024                    // SDPA per head (sequential NEON, inline — zero overhead)
13025                    for bi in 0..b {
13026                        for hi in 0..n_h {
13027                            for qi in 0..s {
13028                                for ki in 0..s {
13029                                    let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
13030                                    let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
13031                                    #[cfg(target_arch = "aarch64")]
13032                                    let dot;
13033                                    #[cfg(not(target_arch = "aarch64"))]
13034                                    let mut dot = 0f32;
13035                                    #[cfg(target_arch = "aarch64")]
13036                                    {
13037                                        use std::arch::aarch64::*;
13038                                        let mut acc = vdupq_n_f32(0.0);
13039                                        for c in 0..neon_chunks {
13040                                            acc = vfmaq_f32(
13041                                                acc,
13042                                                vld1q_f32(qkv.as_ptr().add(q_base + c * 4)),
13043                                                vld1q_f32(qkv.as_ptr().add(k_base + c * 4)),
13044                                            );
13045                                        }
13046                                        dot = vaddvq_f32(acc);
13047                                    }
13048                                    #[cfg(not(target_arch = "aarch64"))]
13049                                    for d in 0..d_h {
13050                                        dot += qkv[q_base + d] * qkv[k_base + d];
13051                                    }
13052                                    sc[qi * s + ki] = dot * scale;
13053                                    if mk[bi * s + ki] < mask_thr {
13054                                        sc[qi * s + ki] = mask_neg;
13055                                    }
13056                                }
13057                            }
13058                            crate::kernels::neon_softmax(&mut sc[..s * s], s, s);
13059                            for qi in 0..s {
13060                                let o = bi * s * h + qi * h + hi * d_h;
13061                                for d in 0..d_h {
13062                                    attn[o + d] = 0.0;
13063                                }
13064                                for ki in 0..s {
13065                                    let w = sc[qi * s + ki];
13066                                    if w > score_thr {
13067                                        let v = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
13068                                        #[cfg(target_arch = "aarch64")]
13069                                        {
13070                                            use std::arch::aarch64::*;
13071                                            let vw = vdupq_n_f32(w);
13072                                            for c in 0..neon_chunks {
13073                                                let off = c * 4;
13074                                                vst1q_f32(
13075                                                    attn.as_mut_ptr().add(o + off),
13076                                                    vfmaq_f32(
13077                                                        vld1q_f32(attn.as_ptr().add(o + off)),
13078                                                        vw,
13079                                                        vld1q_f32(qkv.as_ptr().add(v + off)),
13080                                                    ),
13081                                                );
13082                                            }
13083                                        }
13084                                        #[cfg(not(target_arch = "aarch64"))]
13085                                        for d in 0..d_h {
13086                                            attn[o + d] += w * qkv[v + d];
13087                                        }
13088                                    }
13089                                }
13090                            }
13091                        }
13092                    }
13093
13094                    // Out proj (sgemm + bias fused) + residual add with NEON
13095                    crate::blas::sgemm_bias(
13096                        attn,
13097                        sl(*out_w, base, h * h),
13098                        sl(*out_b, base, h),
13099                        res,
13100                        m,
13101                        h,
13102                        h,
13103                    );
13104                    #[cfg(target_arch = "aarch64")]
13105                    {
13106                        use std::arch::aarch64::*;
13107                        let chunks_h = (m * h) / 4;
13108                        for c in 0..chunks_h {
13109                            let off = c * 4;
13110                            vst1q_f32(
13111                                res.as_mut_ptr().add(off),
13112                                vaddq_f32(
13113                                    vld1q_f32(res.as_ptr().add(off)),
13114                                    vld1q_f32(inp.as_ptr().add(off)),
13115                                ),
13116                            );
13117                        }
13118                        for i in (chunks_h * 4)..(m * h) {
13119                            res[i] += inp[i];
13120                        }
13121                    }
13122                    #[cfg(not(target_arch = "aarch64"))]
13123                    for i in 0..m * h {
13124                        res[i] += inp[i];
13125                    }
13126
13127                    // LN1 (fused residual already done above — just normalize)
13128                    let g1 = sl(*ln1_g, base, h);
13129                    let b1 = sl(*ln1_b, base, h);
13130                    for r in 0..m {
13131                        crate::kernels::layer_norm_row(
13132                            &res[r * h..(r + 1) * h],
13133                            g1,
13134                            b1,
13135                            &mut normed[r * h..(r + 1) * h],
13136                            h,
13137                            *eps1,
13138                        );
13139                    }
13140
13141                    // FFN: fc1 (parallel across cores) + GELU
13142                    crate::blas::par_sgemm_bias(
13143                        normed,
13144                        sl(*fc1_w, base, h * id),
13145                        sl(*fc1_b, base, id),
13146                        ffn,
13147                        m,
13148                        h,
13149                        id,
13150                    );
13151                    crate::kernels::par_gelu_inplace(ffn);
13152
13153                    // fc2 + bias (parallel across cores) + residual with NEON
13154                    crate::blas::par_sgemm_bias(
13155                        ffn,
13156                        sl(*fc2_w, base, id * h),
13157                        sl(*fc2_b, base, h),
13158                        res,
13159                        m,
13160                        id,
13161                        h,
13162                    );
13163                    #[cfg(target_arch = "aarch64")]
13164                    {
13165                        use std::arch::aarch64::*;
13166                        let chunks_h = (m * h) / 4;
13167                        for c in 0..chunks_h {
13168                            let off = c * 4;
13169                            vst1q_f32(
13170                                res.as_mut_ptr().add(off),
13171                                vaddq_f32(
13172                                    vld1q_f32(res.as_ptr().add(off)),
13173                                    vld1q_f32(normed.as_ptr().add(off)),
13174                                ),
13175                            );
13176                        }
13177                        for i in (chunks_h * 4)..(m * h) {
13178                            res[i] += normed[i];
13179                        }
13180                    }
13181                    #[cfg(not(target_arch = "aarch64"))]
13182                    for i in 0..m * h {
13183                        res[i] += normed[i];
13184                    }
13185
13186                    // LN2 → output
13187                    let g2 = sl(*ln2_g, base, h);
13188                    let b2 = sl(*ln2_b, base, h);
13189                    for r in 0..m {
13190                        crate::kernels::layer_norm_row(
13191                            &res[r * h..(r + 1) * h],
13192                            g2,
13193                            b2,
13194                            &mut dst[r * h..(r + 1) * h],
13195                            h,
13196                            *eps2,
13197                        );
13198                    }
13199                }
13200            }
13201
13202            Thunk::FusedNomicLayer {
13203                hidden,
13204                qkv_w,
13205                out_w,
13206                mask,
13207                cos,
13208                sin,
13209                cos_len,
13210                ln1_g,
13211                ln1_b,
13212                eps1,
13213                fc11_w,
13214                fc12_w: _,
13215                fc2_w,
13216                ln2_g,
13217                ln2_b,
13218                eps2,
13219                out,
13220                batch,
13221                seq,
13222                hs,
13223                nh,
13224                dh,
13225                int_dim,
13226                interleaved,
13227            } => {
13228                let interleaved = *interleaved;
13229                let (b, s, h, n_h, d_h) = (
13230                    *batch as usize,
13231                    *seq as usize,
13232                    *hs as usize,
13233                    *nh as usize,
13234                    *dh as usize,
13235                );
13236                let m = b * s;
13237                let id = *int_dim as usize;
13238                let scale = (d_h as f32).powf(-0.5);
13239                let half_dh = d_h / 2;
13240                #[cfg(target_arch = "aarch64")]
13241                let neon_chunks = d_h / 4;
13242                unsafe {
13243                    let inp = sl(*hidden, base, m * h);
13244                    let dst = sl_mut(*out, base, m * h);
13245                    let mk = sl(*mask, base, b * s);
13246                    let cos_tab = sl(*cos, base, *cos_len as usize);
13247                    let sin_tab = sl(*sin, base, *cos_len as usize);
13248                    // fc11_w is the fused [h, 2*int_dim] weight (fc11 || fc12 concatenated)
13249                    let fused_fc_w = sl(*fc11_w, base, h * 2 * id);
13250
13251                    let mut qkv = vec![0f32; m * 3 * h];
13252                    let mut attn = vec![0f32; m * h];
13253                    let mut res = vec![0f32; m * h];
13254                    let mut normed = vec![0f32; m * h];
13255                    let mut ffn_concat = vec![0f32; m * 2 * id]; // fc11||fc12 output
13256                    let mut sc = vec![0f32; s * s];
13257
13258                    // QKV (no bias)
13259                    crate::blas::sgemm(inp, sl(*qkv_w, base, h * 3 * h), &mut qkv, m, h, 3 * h);
13260
13261                    // SDPA with inline RoPE
13262                    for bi in 0..b {
13263                        for hi in 0..n_h {
13264                            for qi in 0..s {
13265                                for ki in 0..s {
13266                                    let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
13267                                    let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
13268                                    let mut dot = 0f32;
13269                                    for i in 0..half_dh {
13270                                        // NeoX pairs (i, i+half); GPT-J pairs (2i, 2i+1).
13271                                        let (o1, o2) = if interleaved {
13272                                            (2 * i, 2 * i + 1)
13273                                        } else {
13274                                            (i, half_dh + i)
13275                                        };
13276                                        let q1 = qkv[q_base + o1];
13277                                        let q2 = qkv[q_base + o2];
13278                                        let k1 = qkv[k_base + o1];
13279                                        let k2 = qkv[k_base + o2];
13280                                        let cq = cos_tab[qi * half_dh + i];
13281                                        let sq = sin_tab[qi * half_dh + i];
13282                                        let ck = cos_tab[ki * half_dh + i];
13283                                        let sk = sin_tab[ki * half_dh + i];
13284                                        dot += (q1 * cq - q2 * sq) * (k1 * ck - k2 * sk)
13285                                            + (q2 * cq + q1 * sq) * (k2 * ck + k1 * sk);
13286                                    }
13287                                    sc[qi * s + ki] = dot * scale;
13288                                    if mk[bi * s + ki] < mask_thr {
13289                                        sc[qi * s + ki] = mask_neg;
13290                                    }
13291                                }
13292                            }
13293                            crate::kernels::neon_softmax(&mut sc[..s * s], s, s);
13294                            for qi in 0..s {
13295                                let o = bi * s * h + qi * h + hi * d_h;
13296                                for d in 0..d_h {
13297                                    attn[o + d] = 0.0;
13298                                }
13299                                for ki in 0..s {
13300                                    let w = sc[qi * s + ki];
13301                                    if w > score_thr {
13302                                        let v = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
13303                                        #[cfg(target_arch = "aarch64")]
13304                                        {
13305                                            use std::arch::aarch64::*;
13306                                            let vw = vdupq_n_f32(w);
13307                                            for c in 0..neon_chunks {
13308                                                let off = c * 4;
13309                                                vst1q_f32(
13310                                                    attn.as_mut_ptr().add(o + off),
13311                                                    vfmaq_f32(
13312                                                        vld1q_f32(attn.as_ptr().add(o + off)),
13313                                                        vw,
13314                                                        vld1q_f32(qkv.as_ptr().add(v + off)),
13315                                                    ),
13316                                                );
13317                                            }
13318                                        }
13319                                        #[cfg(not(target_arch = "aarch64"))]
13320                                        for d in 0..d_h {
13321                                            attn[o + d] += w * qkv[v + d];
13322                                        }
13323                                    }
13324                                }
13325                            }
13326                        }
13327                    }
13328
13329                    // Out proj (no bias) + residual
13330                    crate::blas::sgemm(&attn, sl(*out_w, base, h * h), &mut res, m, h, h);
13331                    for i in 0..m * h {
13332                        res[i] += inp[i];
13333                    }
13334
13335                    // LN1
13336                    let g1 = sl(*ln1_g, base, h);
13337                    let b1 = sl(*ln1_b, base, h);
13338                    for r in 0..m {
13339                        crate::kernels::layer_norm_row(
13340                            &res[r * h..(r + 1) * h],
13341                            g1,
13342                            b1,
13343                            &mut normed[r * h..(r + 1) * h],
13344                            h,
13345                            *eps1,
13346                        );
13347                    }
13348
13349                    // SwiGLU: fused fc11+fc12 sgemm, then split, silu, mul
13350                    crate::blas::sgemm(&normed, fused_fc_w, &mut ffn_concat, m, h, 2 * id);
13351                    // Split: first id cols = fc11 (up), second id cols = fc12 (gate)
13352                    // SiLU on gate, then multiply up * gate → store in up region
13353                    for row in 0..m {
13354                        let bo = row * 2 * id;
13355                        // SiLU in-place on gate portion
13356                        for j in 0..id {
13357                            let x = ffn_concat[bo + id + j];
13358                            ffn_concat[bo + id + j] = x / (1.0 + (-x).exp());
13359                        }
13360                        // Multiply: up[j] *= gate[j]
13361                        for j in 0..id {
13362                            ffn_concat[bo + j] *= ffn_concat[bo + id + j];
13363                        }
13364                    }
13365
13366                    // fc2 (no bias) + residual. The up*silu(gate) product lives in
13367                    // the FIRST `id` cols of each 2*id-wide ffn_concat row; gather
13368                    // it contiguous and run the SAME `sgemm` dispatch the unfused
13369                    // path uses (a strided `sgemm_general` here would force the
13370                    // BLAS/scalar path and diverge from the unfused NEON sgemm).
13371                    let mut swiglu_contig = vec![0f32; m * id];
13372                    for row in 0..m {
13373                        let bo = row * 2 * id;
13374                        swiglu_contig[row * id..(row + 1) * id]
13375                            .copy_from_slice(&ffn_concat[bo..bo + id]);
13376                    }
13377                    crate::blas::sgemm(
13378                        &swiglu_contig,
13379                        sl(*fc2_w, base, id * h),
13380                        &mut res,
13381                        m,
13382                        id,
13383                        h,
13384                    );
13385                    for i in 0..m * h {
13386                        res[i] += normed[i];
13387                    }
13388
13389                    // LN2 → output
13390                    let g2 = sl(*ln2_g, base, h);
13391                    let b2 = sl(*ln2_b, base, h);
13392                    for r in 0..m {
13393                        crate::kernels::layer_norm_row(
13394                            &res[r * h..(r + 1) * h],
13395                            g2,
13396                            b2,
13397                            &mut dst[r * h..(r + 1) * h],
13398                            h,
13399                            *eps2,
13400                        );
13401                    }
13402                }
13403            }
13404
13405            Thunk::FusedSwiGLU { .. } => exec_fused_swi_g_l_u(thunk, base),
13406            Thunk::Concat { .. } => exec_concat(thunk, base),
13407            Thunk::ConcatF64 { .. } => exec_concat_f64(thunk, base),
13408            Thunk::Compare {
13409                lhs,
13410                rhs,
13411                dst,
13412                len,
13413                op,
13414                inputs_i64,
13415                inputs_elem_bytes,
13416                dst_elem_bytes,
13417            } => {
13418                let len = *len as usize;
13419                let arena_len = arena_buf.len();
13420                let elem = (*inputs_elem_bytes).max(1) as usize;
13421                let dst_eb = (*dst_elem_bytes).max(1) as usize;
13422                let max_l = (arena_len.saturating_sub(*lhs)) / elem;
13423                let max_r = (arena_len.saturating_sub(*rhs)) / elem;
13424                let max_d = (arena_len.saturating_sub(*dst)) / dst_eb;
13425                let len = len.min(max_l).min(max_r).min(max_d);
13426                if trace_thunks && len > 0 {
13427                    eprintln!("[compare] len={len} lhs={} rhs={} dst={}", *lhs, *rhs, *dst);
13428                }
13429                if elem == 1 {
13430                    let l = arena_buf[*lhs..*lhs + len].to_vec();
13431                    let r = arena_buf[*rhs..*rhs + len].to_vec();
13432                    for i in 0..len {
13433                        let v = match op {
13434                            CmpOp::Eq => l[i] == r[i],
13435                            CmpOp::Ne => l[i] != r[i],
13436                            CmpOp::Lt => l[i] < r[i],
13437                            CmpOp::Le => l[i] <= r[i],
13438                            CmpOp::Gt => l[i] > r[i],
13439                            CmpOp::Ge => l[i] >= r[i],
13440                        };
13441                        if *dst_elem_bytes == 1 {
13442                            arena_buf[*dst + i] = u8::from(v);
13443                        } else {
13444                            unsafe {
13445                                let o = sl_mut(*dst, base, len);
13446                                o[i] = if v { 1.0 } else { 0.0 };
13447                            }
13448                        }
13449                    }
13450                } else if *inputs_i64 != 0 {
13451                    unsafe {
13452                        let l = sl_i64(*lhs, base, len);
13453                        let r = sl_i64(*rhs, base, len);
13454                        for i in 0..len {
13455                            let v = match op {
13456                                CmpOp::Eq => l[i] == r[i],
13457                                CmpOp::Ne => l[i] != r[i],
13458                                CmpOp::Lt => l[i] < r[i],
13459                                CmpOp::Le => l[i] <= r[i],
13460                                CmpOp::Gt => l[i] > r[i],
13461                                CmpOp::Ge => l[i] >= r[i],
13462                            };
13463                            if *dst_elem_bytes == 1 {
13464                                arena_buf[*dst + i] = u8::from(v);
13465                            } else {
13466                                let o = sl_mut(*dst, base, len);
13467                                o[i] = if v { 1.0 } else { 0.0 };
13468                            }
13469                        }
13470                    }
13471                } else {
13472                    unsafe {
13473                        let l = sl(*lhs, base, len);
13474                        let r = sl(*rhs, base, len);
13475                        for i in 0..len {
13476                            let v = match op {
13477                                CmpOp::Eq => l[i] == r[i],
13478                                CmpOp::Ne => l[i] != r[i],
13479                                CmpOp::Lt => l[i] < r[i],
13480                                CmpOp::Le => l[i] <= r[i],
13481                                CmpOp::Gt => l[i] > r[i],
13482                                CmpOp::Ge => l[i] >= r[i],
13483                            };
13484                            if *dst_elem_bytes == 1 {
13485                                arena_buf[*dst + i] = u8::from(v);
13486                            } else {
13487                                let o = sl_mut(*dst, base, len);
13488                                o[i] = if v { 1.0 } else { 0.0 };
13489                            }
13490                        }
13491                    }
13492                }
13493            }
13494
13495            Thunk::Where {
13496                cond,
13497                on_true,
13498                on_false,
13499                dst,
13500                len,
13501                elem_bytes,
13502                cond_elem_bytes,
13503            } => {
13504                let len = *len as usize;
13505                let eb = *elem_bytes as usize;
13506                let cond_eb = (*cond_elem_bytes).max(1) as usize;
13507                let arena_len = arena_buf.len();
13508                let len = len
13509                    .min((arena_len.saturating_sub(*cond)) / cond_eb)
13510                    .min((arena_len.saturating_sub(*on_true)) / eb)
13511                    .min((arena_len.saturating_sub(*on_false)) / eb)
13512                    .min((arena_len.saturating_sub(*dst)) / eb);
13513                unsafe {
13514                    if *elem_bytes == 8 {
13515                        let t = sl_i64(*on_true, base, len);
13516                        let e = sl_i64(*on_false, base, len);
13517                        let o = sl_mut_i64(*dst, base, len);
13518                        if *cond_elem_bytes == 1 {
13519                            let c = &arena_buf[*cond..*cond + len];
13520                            for i in 0..len {
13521                                o[i] = if c[i] != 0 { t[i] } else { e[i] };
13522                            }
13523                        } else {
13524                            let c = sl_i64(*cond, base, len);
13525                            for i in 0..len {
13526                                o[i] = if c[i] != 0 { t[i] } else { e[i] };
13527                            }
13528                        }
13529                    } else if *cond_elem_bytes == 1 {
13530                        let c = &arena_buf[*cond..*cond + len];
13531                        let t = sl(*on_true, base, len);
13532                        let e = sl(*on_false, base, len);
13533                        let o = sl_mut(*dst, base, len);
13534                        for i in 0..len {
13535                            o[i] = if c[i] != 0 { t[i] } else { e[i] };
13536                        }
13537                    } else {
13538                        let c = sl(*cond, base, len);
13539                        let t = sl(*on_true, base, len);
13540                        let e = sl(*on_false, base, len);
13541                        let o = sl_mut(*dst, base, len);
13542                        for i in 0..len {
13543                            o[i] = if c[i] != 0.0 { t[i] } else { e[i] };
13544                        }
13545                    }
13546                }
13547            }
13548
13549            Thunk::Fma {
13550                a,
13551                b,
13552                c,
13553                dst,
13554                len,
13555                elem_bytes,
13556            } => {
13557                let len = *len as usize;
13558                let eb = (*elem_bytes).max(1) as usize;
13559                let arena_len = arena_buf.len();
13560                let len = len
13561                    .min(arena_len.saturating_sub(*a) / eb)
13562                    .min(arena_len.saturating_sub(*b) / eb)
13563                    .min(arena_len.saturating_sub(*c) / eb)
13564                    .min(arena_len.saturating_sub(*dst) / eb);
13565                unsafe {
13566                    if *elem_bytes == 8 {
13567                        let av = sl_f64(*a, base, len);
13568                        let bv = sl_f64(*b, base, len);
13569                        let cv = sl_f64(*c, base, len);
13570                        let o = sl_mut_f64(*dst, base, len);
13571                        for i in 0..len {
13572                            o[i] = av[i].mul_add(bv[i], cv[i]);
13573                        }
13574                    } else {
13575                        let av = sl(*a, base, len);
13576                        let bv = sl(*b, base, len);
13577                        let cv = sl(*c, base, len);
13578                        let o = sl_mut(*dst, base, len);
13579                        for i in 0..len {
13580                            o[i] = av[i].mul_add(bv[i], cv[i]);
13581                        }
13582                    }
13583                }
13584            }
13585
13586            Thunk::ScatterAdd { .. } => exec_scatter_add(thunk, base),
13587            Thunk::GroupedMatMul {
13588                input,
13589                weight,
13590                expert_idx,
13591                dst,
13592                m,
13593                k_dim,
13594                n,
13595                num_experts,
13596            } => {
13597                let m = *m as usize;
13598                let k_dim = *k_dim as usize;
13599                let n = *n as usize;
13600                let num_experts = *num_experts as usize;
13601                unsafe {
13602                    let inp = sl(*input, base, m * k_dim);
13603                    let wt = sl(*weight, base, num_experts * k_dim * n);
13604                    let ids = sl(*expert_idx, base, m);
13605                    let out = sl_mut(*dst, base, m * n);
13606
13607                    // Counting-sort tokens by their assigned expert.
13608                    // counts[e] = how many tokens routed to expert e.
13609                    let mut counts = vec![0usize; num_experts];
13610                    for i in 0..m {
13611                        let e = ids[i] as usize;
13612                        debug_assert!(
13613                            e < num_experts,
13614                            "expert_idx out of range: {e} >= {num_experts}"
13615                        );
13616                        counts[e] += 1;
13617                    }
13618                    // Cumulative offsets into the packed buffer.
13619                    let mut offsets = vec![0usize; num_experts + 1];
13620                    for e in 0..num_experts {
13621                        offsets[e + 1] = offsets[e] + counts[e];
13622                    }
13623                    // Pack: each expert's rows land contiguously in `packed_in`.
13624                    // `original_pos[packed_idx] = original_token_idx` for the
13625                    // unpermute step at the end.
13626                    let mut packed_in = vec![0f32; m * k_dim];
13627                    let mut original_pos = vec![0usize; m];
13628                    let mut write_idx = vec![0usize; num_experts];
13629                    for i in 0..m {
13630                        let e = ids[i] as usize;
13631                        let dst_row = offsets[e] + write_idx[e];
13632                        packed_in[dst_row * k_dim..(dst_row + 1) * k_dim]
13633                            .copy_from_slice(&inp[i * k_dim..(i + 1) * k_dim]);
13634                        original_pos[dst_row] = i;
13635                        write_idx[e] += 1;
13636                    }
13637
13638                    // One BLAS sgemm per expert. Skip experts with no
13639                    // tokens — common at the tail when M is much smaller
13640                    // than num_experts × k.
13641                    let mut packed_out = vec![0f32; m * n];
13642                    let expert_stride = k_dim * n;
13643                    let gmm_ord = crate::moe_residency::next_gmm_ord();
13644                    let moe_layer = gmm_ord / 3;
13645                    for e in 0..num_experts {
13646                        let count = counts[e];
13647                        if count == 0 {
13648                            continue;
13649                        }
13650                        crate::moe_residency::record_expert_tokens(moe_layer, e, count);
13651                        let in_start = offsets[e];
13652                        let in_slice = &packed_in[in_start * k_dim..(in_start + count) * k_dim];
13653                        let w_slab: &[f32] =
13654                            if !crate::moe_residency::expert_on_device_for_layer(moe_layer, e) {
13655                                if let Some(ptr) =
13656                                    crate::moe_residency::host_expert_weight_ptr(gmm_ord, e)
13657                                {
13658                                    std::slice::from_raw_parts(ptr, expert_stride)
13659                                } else {
13660                                    &wt[e * expert_stride..(e + 1) * expert_stride]
13661                                }
13662                            } else {
13663                                &wt[e * expert_stride..(e + 1) * expert_stride]
13664                            };
13665                        let out_slice = &mut packed_out[in_start * n..(in_start + count) * n];
13666                        crate::blas::sgemm(in_slice, w_slab, out_slice, count, k_dim, n);
13667                    }
13668
13669                    // Unpermute back to original token order.
13670                    for packed_idx in 0..m {
13671                        let i = original_pos[packed_idx];
13672                        out[i * n..(i + 1) * n]
13673                            .copy_from_slice(&packed_out[packed_idx * n..(packed_idx + 1) * n]);
13674                    }
13675                }
13676            }
13677
13678            Thunk::DequantGroupedMatMulGguf { .. } => {
13679                exec_dequant_grouped_mat_mul_gguf(thunk, base)
13680            }
13681            Thunk::DequantMoEWeightsGguf { .. } => exec_dequant_mo_e_weights_gguf(thunk, base),
13682            Thunk::TopK {
13683                src,
13684                dst,
13685                outer,
13686                axis_dim,
13687                k,
13688                indices_i64,
13689            } => {
13690                let outer = *outer as usize;
13691                let axis_dim = *axis_dim as usize;
13692                let k = *k as usize;
13693                unsafe {
13694                    let inp = sl(*src, base, outer * axis_dim);
13695                    // Repeated argmax with masking. O(k * axis_dim) per row;
13696                    // good enough for small k (MoE typical k=2–8). For larger
13697                    // k a partial heap would win.
13698                    let mut row_buf: Vec<f32> = vec![0.0; axis_dim];
13699                    if *indices_i64 != 0 {
13700                        let out = sl_mut_i64(*dst, base, outer * k);
13701                        for o in 0..outer {
13702                            row_buf.copy_from_slice(&inp[o * axis_dim..(o + 1) * axis_dim]);
13703                            for ki in 0..k {
13704                                let mut best_i = 0usize;
13705                                let mut best_v = row_buf[0];
13706                                for i in 1..axis_dim {
13707                                    let v = row_buf[i];
13708                                    if v > best_v {
13709                                        best_v = v;
13710                                        best_i = i;
13711                                    }
13712                                }
13713                                out[o * k + ki] = best_i as i64;
13714                                row_buf[best_i] = f32::NEG_INFINITY;
13715                            }
13716                        }
13717                    } else {
13718                        let out = sl_mut(*dst, base, outer * k);
13719                        for o in 0..outer {
13720                            row_buf.copy_from_slice(&inp[o * axis_dim..(o + 1) * axis_dim]);
13721                            for ki in 0..k {
13722                                let mut best_i = 0usize;
13723                                let mut best_v = row_buf[0];
13724                                for i in 1..axis_dim {
13725                                    let v = row_buf[i];
13726                                    if v > best_v {
13727                                        best_v = v;
13728                                        best_i = i;
13729                                    }
13730                                }
13731                                out[o * k + ki] = best_i as f32;
13732                                row_buf[best_i] = f32::NEG_INFINITY;
13733                            }
13734                        }
13735                        if let Some(cap) = schedule.moe_topk_capture.as_ref() {
13736                            cap.push_topk_f32(&out[..outer * k], axis_dim);
13737                        }
13738                    }
13739                }
13740            }
13741
13742            Thunk::Reduce { .. } => exec_reduce(thunk, base),
13743            Thunk::ArgReduce { .. } => exec_arg_reduce(thunk, base),
13744            Thunk::Conv2D1x1 { .. } => exec_conv2_d1x1(thunk, base),
13745            Thunk::Conv2D { .. } => exec_conv2_d(thunk, base),
13746            Thunk::Conv3d { .. } => exec_conv3d(thunk, base),
13747            Thunk::ConvTranspose3d { .. } => exec_conv_transpose3d(thunk, base),
13748            Thunk::Pool2D {
13749                src,
13750                dst,
13751                n,
13752                c,
13753                h,
13754                w,
13755                h_out,
13756                w_out,
13757                kh,
13758                kw,
13759                sh,
13760                sw,
13761                ph,
13762                pw,
13763                kind,
13764            } => {
13765                let n = *n as usize;
13766                let c = *c as usize;
13767                let h = *h as usize;
13768                let w = *w as usize;
13769                let h_out = *h_out as usize;
13770                let w_out = *w_out as usize;
13771                let kh = *kh as usize;
13772                let kw = *kw as usize;
13773                let sh = *sh as usize;
13774                let sw = *sw as usize;
13775                let ph = *ph as usize;
13776                let pw = *pw as usize;
13777                let kernel_area = (kh * kw) as f32;
13778                unsafe {
13779                    let inp = sl(*src, base, n * c * h * w);
13780                    let out = sl_mut(*dst, base, n * c * h_out * w_out);
13781                    // Each (n, c) plane is independent and writes a disjoint
13782                    // output region, so pooling fans out over the channel-batch
13783                    // when RLX_FAST_CONV is set.
13784                    let out_addr = out.as_mut_ptr() as usize;
13785                    let is_max = matches!(kind, ReduceOp::Max);
13786                    let is_mean = matches!(kind, ReduceOp::Mean);
13787                    // No-padding windows (the conv-net case) are always fully
13788                    // in-bounds, so the hot path drops the per-element bounds
13789                    // branches and hoists the reduce-op choice out of the loop.
13790                    let nopad = ph == 0 && pw == 0;
13791                    let pool_plane = |nc: usize| {
13792                        let ni = nc / c;
13793                        let ci = nc % c;
13794                        let in_chan = ni * c * h * w + ci * h * w;
13795                        let out_chan = ni * c * h_out * w_out + ci * h_out * w_out;
13796                        let op = out_addr as *mut f32;
13797                        for ho in 0..h_out {
13798                            for wo in 0..w_out {
13799                                let acc = if nopad {
13800                                    let row0 = in_chan + (ho * sh) * w + wo * sw;
13801                                    let mut a = if is_max { f32::NEG_INFINITY } else { 0.0 };
13802                                    for ki in 0..kh {
13803                                        let row = row0 + ki * w;
13804                                        if is_max {
13805                                            for kj in 0..kw {
13806                                                a = a.max(inp[row + kj]);
13807                                            }
13808                                        } else {
13809                                            for kj in 0..kw {
13810                                                a += inp[row + kj];
13811                                            }
13812                                        }
13813                                    }
13814                                    a
13815                                } else {
13816                                    let mut a = if is_max { f32::NEG_INFINITY } else { 0.0 };
13817                                    for ki in 0..kh {
13818                                        for kj in 0..kw {
13819                                            let hi = ho * sh + ki;
13820                                            let wi = wo * sw + kj;
13821                                            if hi < ph || wi < pw {
13822                                                continue;
13823                                            }
13824                                            let hi = hi - ph;
13825                                            let wi = wi - pw;
13826                                            if hi >= h || wi >= w {
13827                                                continue;
13828                                            }
13829                                            let v = inp[in_chan + hi * w + wi];
13830                                            if is_max {
13831                                                a = a.max(v);
13832                                            } else {
13833                                                a += v;
13834                                            }
13835                                        }
13836                                    }
13837                                    a
13838                                };
13839                                let acc = if is_mean { acc / kernel_area } else { acc };
13840                                *op.add(out_chan + ho * w_out + wo) = acc;
13841                            }
13842                        }
13843                    };
13844                    if fast_conv_enabled() && crate::pool::should_parallelize(n * c * h_out * w_out)
13845                    {
13846                        crate::pool::par_for(
13847                            n * c,
13848                            crate::pool::outer_chunk(n * c),
13849                            &|off, cnt| {
13850                                for nc in off..off + cnt {
13851                                    pool_plane(nc);
13852                                }
13853                            },
13854                        );
13855                    } else {
13856                        for nc in 0..n * c {
13857                            pool_plane(nc);
13858                        }
13859                    }
13860                }
13861            }
13862
13863            Thunk::ReluBackward { .. } => exec_relu_backward(thunk, base),
13864            Thunk::ReluBackwardF64 { .. } => exec_relu_backward_f64(thunk, base),
13865            Thunk::QMatMul { .. } => exec_q_mat_mul(thunk, base),
13866            Thunk::QConv2d {
13867                x,
13868                w,
13869                bias,
13870                out,
13871                n,
13872                c_in,
13873                h,
13874                w_in,
13875                c_out,
13876                h_out,
13877                w_out,
13878                kh,
13879                kw,
13880                sh,
13881                sw,
13882                ph,
13883                pw,
13884                dh,
13885                dw,
13886                groups,
13887                x_zp,
13888                w_zp,
13889                out_zp,
13890                mult,
13891            } => {
13892                let n = *n as usize;
13893                let c_in = *c_in as usize;
13894                let h = *h as usize;
13895                let w_in = *w_in as usize;
13896                let c_out = *c_out as usize;
13897                let h_out = *h_out as usize;
13898                let w_out = *w_out as usize;
13899                let kh = *kh as usize;
13900                let kw = *kw as usize;
13901                let sh = *sh as usize;
13902                let sw = *sw as usize;
13903                let ph = *ph as usize;
13904                let pw = *pw as usize;
13905                let dh = *dh as usize;
13906                let dw = *dw as usize;
13907                let groups = *groups as usize;
13908                let c_in_per_g = c_in / groups;
13909                let c_out_per_g = c_out / groups;
13910                unsafe {
13911                    let x_ptr = base.add(*x) as *const i8;
13912                    let w_ptr = base.add(*w) as *const i8;
13913                    let bias_ptr = base.add(*bias) as *const i32;
13914                    let out_ptr = base.add(*out) as *mut i8;
13915                    for ni in 0..n {
13916                        for co in 0..c_out {
13917                            let g = co / c_out_per_g;
13918                            let ci_start = g * c_in_per_g;
13919                            for ho in 0..h_out {
13920                                for wo in 0..w_out {
13921                                    let mut acc: i32 = *bias_ptr.add(co);
13922                                    for ci_off in 0..c_in_per_g {
13923                                        let ci = ci_start + ci_off;
13924                                        let in_chan = ((ni * c_in) + ci) * h * w_in;
13925                                        let wt_chan = ((co * c_in_per_g) + ci_off) * kh * kw;
13926                                        for ki in 0..kh {
13927                                            for kj in 0..kw {
13928                                                let hi = ho * sh + ki * dh;
13929                                                let wi = wo * sw + kj * dw;
13930                                                if hi < ph || wi < pw {
13931                                                    continue;
13932                                                }
13933                                                let hi = hi - ph;
13934                                                let wi = wi - pw;
13935                                                if hi >= h || wi >= w_in {
13936                                                    continue;
13937                                                }
13938                                                let xv = *x_ptr.add(in_chan + hi * w_in + wi)
13939                                                    as i32
13940                                                    - *x_zp;
13941                                                let wv = *w_ptr.add(wt_chan + ki * kw + kj) as i32
13942                                                    - *w_zp;
13943                                                acc += xv * wv;
13944                                            }
13945                                        }
13946                                    }
13947                                    let r = (acc as f32 * *mult).round() as i32 + *out_zp;
13948                                    let r = r.clamp(-128, 127) as i8;
13949                                    let dst = ((ni * c_out) + co) * h_out * w_out + ho * w_out + wo;
13950                                    *out_ptr.add(dst) = r;
13951                                }
13952                            }
13953                        }
13954                    }
13955                }
13956            }
13957
13958            Thunk::Quantize { .. } => exec_quantize(thunk, base),
13959            Thunk::Dequantize { .. } => exec_dequantize(thunk, base),
13960            Thunk::FakeQuantize { .. } => exec_fake_quantize(thunk, base),
13961            Thunk::ActivationBackward { .. } => exec_activation_backward(thunk, base),
13962            Thunk::ActivationBackwardF64 { .. } => exec_activation_backward_f64(thunk, base),
13963            Thunk::FakeQuantizeLSQ { .. } => exec_fake_quantize_l_s_q(thunk, base),
13964            Thunk::FakeQuantizeLSQBackwardX { .. } => {
13965                exec_fake_quantize_l_s_q_backward_x(thunk, base)
13966            }
13967            Thunk::FakeQuantizeLSQBackwardScale { .. } => {
13968                exec_fake_quantize_l_s_q_backward_scale(thunk, base)
13969            }
13970            Thunk::FakeQuantizeBackward { .. } => exec_fake_quantize_backward(thunk, base),
13971            Thunk::LayerNormBackwardInput { .. } => exec_layer_norm_backward_input(thunk, base),
13972            Thunk::BatchNormInferenceBackwardInput { .. } => {
13973                exec_batch_norm_inference_backward_input(thunk, base)
13974            }
13975            Thunk::BatchNormInferenceBackwardGamma { .. } => {
13976                exec_batch_norm_inference_backward_gamma(thunk, base)
13977            }
13978            Thunk::BatchNormInferenceBackwardBeta { .. } => {
13979                exec_batch_norm_inference_backward_beta(thunk, base)
13980            }
13981            Thunk::LayerNormBackwardGamma { .. } => exec_layer_norm_backward_gamma(thunk, base),
13982            Thunk::RmsNormBackwardInput { .. } => exec_rms_norm_backward_input(thunk, base),
13983            Thunk::RmsNormBackwardGamma { .. } => exec_rms_norm_backward_gamma(thunk, base),
13984            Thunk::RmsNormBackwardBeta { .. } => exec_rms_norm_backward_beta(thunk, base),
13985            Thunk::RopeBackward { .. } => exec_rope_backward(thunk, base),
13986            Thunk::CumsumBackward { .. } => exec_cumsum_backward(thunk, base),
13987            Thunk::GroupNormBackwardInput { .. } => exec_group_norm_backward_input(thunk, base),
13988            Thunk::GroupNormBackwardGamma { .. } => exec_group_norm_backward_gamma(thunk, base),
13989            Thunk::GroupNormBackwardBeta { .. } => exec_group_norm_backward_beta(thunk, base),
13990            Thunk::GatherBackward { .. } => exec_gather_backward(thunk, base),
13991            Thunk::MaxPool2dBackward { .. } => exec_max_pool2d_backward(thunk, base),
13992            Thunk::Conv2dBackwardInput { .. } => exec_conv2d_backward_input(thunk, base),
13993            Thunk::Conv2dBackwardWeight { .. } => exec_conv2d_backward_weight(thunk, base),
13994            Thunk::Im2Col { .. } => exec_im2_col(thunk, base),
13995            Thunk::SoftmaxCrossEntropyDense { .. } => exec_softmax_cross_entropy_dense(thunk, base),
13996            Thunk::SoftmaxCrossEntropy { .. } => exec_softmax_cross_entropy(thunk, base),
13997            Thunk::SoftmaxCrossEntropyBackward { .. } => {
13998                exec_softmax_cross_entropy_backward(thunk, base)
13999            }
14000            Thunk::GatherAxis { .. } => exec_gather_axis(thunk, base),
14001            Thunk::Transpose {
14002                src,
14003                dst,
14004                in_total,
14005                out_dims,
14006                in_strides,
14007                elem_bytes,
14008            } => {
14009                // N-D index walk: for each output flat index, decompose into
14010                // multi-dim coords using out_dims, then dot with in_strides
14011                // to find the source flat index. Stride 0 = broadcast (read
14012                // the same input element repeatedly along that dim).
14013                let rank = out_dims.len();
14014                let total: usize = out_dims.iter().map(|&d| d as usize).product();
14015                let in_total = *in_total as usize;
14016                unsafe {
14017                    if *elem_bytes == 1 {
14018                        // 1-byte dtypes (Bool / I8 / U8). Without this branch the
14019                        // `else` path below reads/writes 4 bytes per element via the
14020                        // f32 slice, corrupting e.g. a broadcast of the VITS attention
14021                        // mask (Bool, expanded over heads) — masking wrong positions.
14022                        let inp = arena_buf[*src..*src + in_total].to_vec();
14023                        let out = &mut arena_buf[*dst..*dst + total];
14024                        let mut idx = vec![0usize; rank];
14025                        for o in 0..total {
14026                            let mut src_idx = 0usize;
14027                            for d in 0..rank {
14028                                src_idx += idx[d] * in_strides[d] as usize;
14029                            }
14030                            out[o] = inp[broadcast_src_index(src_idx, in_total)];
14031                            for d in (0..rank).rev() {
14032                                idx[d] += 1;
14033                                if idx[d] < out_dims[d] as usize {
14034                                    break;
14035                                }
14036                                idx[d] = 0;
14037                            }
14038                        }
14039                    } else if *elem_bytes == 8 {
14040                        let inp = sl_i64(*src, base, in_total);
14041                        let out = sl_mut_i64(*dst, base, total);
14042                        let mut idx = vec![0usize; rank];
14043                        for o in 0..total {
14044                            let mut src_idx = 0usize;
14045                            for d in 0..rank {
14046                                src_idx += idx[d] * in_strides[d] as usize;
14047                            }
14048                            out[o] = inp[broadcast_src_index(src_idx, in_total)];
14049                            for d in (0..rank).rev() {
14050                                idx[d] += 1;
14051                                if idx[d] < out_dims[d] as usize {
14052                                    break;
14053                                }
14054                                idx[d] = 0;
14055                            }
14056                        }
14057                    } else {
14058                        let inp = sl(*src, base, in_total);
14059                        let out = sl_mut(*dst, base, total);
14060                        if rank == 4
14061                            && in_strides[0] == 0
14062                            && in_strides[2] == 0
14063                            && in_strides[3] == 0
14064                            && in_strides[1] != 0
14065                        {
14066                            // Per-channel broadcast out[n,c,h,w] = in[c*sc] — how
14067                            // a conv bias `[C]` reaches `[N,C,H,W]` (and its
14068                            // recompute in the backward graph). Fill each (n,c)
14069                            // plane with its scalar instead of an N-D index walk
14070                            // over every element; parallel over the channel-batch.
14071                            let d1 = out_dims[1] as usize;
14072                            let sc = in_strides[1] as usize;
14073                            let plane = (out_dims[2] as usize) * (out_dims[3] as usize);
14074                            let nc_total = (out_dims[0] as usize) * d1;
14075                            let out_addr = out.as_mut_ptr() as usize;
14076                            let fill = |nc0: usize, nc1: usize| {
14077                                let op = out_addr as *mut f32;
14078                                for nc in nc0..nc1 {
14079                                    let v = inp[(nc % d1) * sc];
14080                                    let base_off = nc * plane;
14081                                    for k in 0..plane {
14082                                        *op.add(base_off + k) = v;
14083                                    }
14084                                }
14085                            };
14086                            if fast_conv_enabled() && crate::pool::should_parallelize(total) {
14087                                crate::pool::par_for(
14088                                    nc_total,
14089                                    crate::pool::outer_chunk(nc_total),
14090                                    &|off, cnt| fill(off, off + cnt),
14091                                );
14092                            } else {
14093                                fill(0, nc_total);
14094                            }
14095                        } else if rank == 2 && in_strides[0] != 0 && in_strides[1] != 0 {
14096                            // Fast 2D transpose (the common matmul-backward case:
14097                            // xᵀ, wᵀ). out[i,j] = in[i*s0 + j*s1]; tiled over
14098                            // columns for write-locality, parallel over rows. Far
14099                            // cheaper than the general per-element index walk.
14100                            let d0 = out_dims[0] as usize;
14101                            let d1 = out_dims[1] as usize;
14102                            let s0 = in_strides[0] as usize;
14103                            let s1 = in_strides[1] as usize;
14104                            let out_addr = out.as_mut_ptr() as usize;
14105                            let tile = |i0: usize, i1: usize| {
14106                                let op = out_addr as *mut f32;
14107                                const T: usize = 32;
14108                                let mut j0 = 0;
14109                                while j0 < d1 {
14110                                    let j1 = (j0 + T).min(d1);
14111                                    for i in i0..i1 {
14112                                        let inb = i * s0;
14113                                        let outb = i * d1;
14114                                        for j in j0..j1 {
14115                                            *op.add(outb + j) = inp[inb + j * s1];
14116                                        }
14117                                    }
14118                                    j0 = j1;
14119                                }
14120                            };
14121                            if fast_conv_enabled() && crate::pool::should_parallelize(total) {
14122                                crate::pool::par_for(
14123                                    d0,
14124                                    crate::pool::outer_chunk(d0),
14125                                    &|off, cnt| tile(off, off + cnt),
14126                                );
14127                            } else {
14128                                tile(0, d0);
14129                            }
14130                        } else if fast_conv_enabled() && crate::pool::should_parallelize(total) {
14131                            // Parallel: each chunk seeds its starting multi-index
14132                            // from `off`, then walks incrementally. Output writes
14133                            // are disjoint per `o`.
14134                            let out_addr = out.as_mut_ptr() as usize;
14135                            crate::pool::par_for(
14136                                total,
14137                                crate::pool::chunk_floor(total),
14138                                &|off, cnt| {
14139                                    let mut idx = vec![0usize; rank];
14140                                    let mut rem = off;
14141                                    for d in (0..rank).rev() {
14142                                        let dim = out_dims[d] as usize;
14143                                        idx[d] = rem % dim;
14144                                        rem /= dim;
14145                                    }
14146                                    for o in off..off + cnt {
14147                                        let mut src_idx = 0usize;
14148                                        for d in 0..rank {
14149                                            src_idx += idx[d] * in_strides[d] as usize;
14150                                        }
14151                                        let v = inp[broadcast_src_index(src_idx, in_total)];
14152                                        *((out_addr as *mut f32).add(o)) = v;
14153                                        for d in (0..rank).rev() {
14154                                            idx[d] += 1;
14155                                            if idx[d] < out_dims[d] as usize {
14156                                                break;
14157                                            }
14158                                            idx[d] = 0;
14159                                        }
14160                                    }
14161                                },
14162                            );
14163                        } else {
14164                            let mut idx = vec![0usize; rank];
14165                            for o in 0..total {
14166                                let mut src_idx = 0usize;
14167                                for d in 0..rank {
14168                                    src_idx += idx[d] * in_strides[d] as usize;
14169                                }
14170                                out[o] = inp[broadcast_src_index(src_idx, in_total)];
14171                                for d in (0..rank).rev() {
14172                                    idx[d] += 1;
14173                                    if idx[d] < out_dims[d] as usize {
14174                                        break;
14175                                    }
14176                                    idx[d] = 0;
14177                                }
14178                            }
14179                        }
14180                    }
14181                }
14182            }
14183
14184            Thunk::CustomOp { .. } => exec_custom_op(thunk, base),
14185            Thunk::Reverse { .. } => exec_reverse(thunk, base),
14186        }
14187        if trace_done {
14188            eprintln!("[thunk {i} done]");
14189        }
14190    }
14191}
14192
14193#[inline(always)]
14194fn exec_nop(t: &Thunk) {
14195    let Thunk::Nop = t else { unreachable!() };
14196    {}
14197}
14198
14199#[inline(always)]
14200fn exec_elementwise_region(t: &Thunk, base: *mut u8) {
14201    let Thunk::ElementwiseRegion {
14202        dst,
14203        len,
14204        input_offs,
14205        chain,
14206        scalar_input_mask,
14207        input_modulus,
14208    } = t
14209    else {
14210        unreachable!()
14211    };
14212    {
14213        let len = *len as usize;
14214        if !chain.is_empty() && len > 0 {
14215            let base_addr = base as usize;
14216            let dst = *dst;
14217            let scalar_mask = *scalar_input_mask;
14218            // Each output element is independent → fan over the range.
14219            let eval = |gid: usize| {
14220                let v = region_eval_elem(
14221                    gid,
14222                    base_addr as *const u8,
14223                    input_offs,
14224                    chain,
14225                    scalar_mask,
14226                    input_modulus,
14227                );
14228                unsafe {
14229                    *((base_addr as *mut u8).add(dst) as *mut f32).add(gid) = v;
14230                }
14231            };
14232            if fast_conv_enabled() && crate::pool::should_parallelize(len) {
14233                crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
14234                    for gid in off..off + cnt {
14235                        eval(gid);
14236                    }
14237                });
14238            } else {
14239                for gid in 0..len {
14240                    eval(gid);
14241                }
14242            }
14243        }
14244    }
14245}
14246
14247#[inline(always)]
14248fn exec_gaussian_splat_render(t: &Thunk, base: *mut u8) {
14249    let Thunk::GaussianSplatRender {
14250        positions_off,
14251        positions_len,
14252        scales_off,
14253        scales_len,
14254        rotations_off,
14255        rotations_len,
14256        opacities_off,
14257        opacities_len,
14258        colors_off,
14259        colors_len,
14260        sh_coeffs_off,
14261        sh_coeffs_len,
14262        meta_off,
14263        dst_off,
14264        dst_len,
14265        width,
14266        height,
14267        tile_size,
14268        radius_scale,
14269        alpha_cutoff,
14270        max_splat_steps,
14271        transmittance_threshold,
14272        max_list_entries,
14273    } = t
14274    else {
14275        unreachable!()
14276    };
14277    unsafe {
14278        crate::splat::execute_gaussian_splat_render(
14279            *positions_off,
14280            *positions_len,
14281            *scales_off,
14282            *scales_len,
14283            *rotations_off,
14284            *rotations_len,
14285            *opacities_off,
14286            *opacities_len,
14287            *colors_off,
14288            *colors_len,
14289            *sh_coeffs_off,
14290            *sh_coeffs_len,
14291            *meta_off,
14292            *dst_off,
14293            *dst_len,
14294            *width,
14295            *height,
14296            *tile_size,
14297            *radius_scale,
14298            *alpha_cutoff,
14299            *max_splat_steps,
14300            *transmittance_threshold,
14301            *max_list_entries,
14302            base,
14303        );
14304    }
14305}
14306
14307#[inline(always)]
14308fn exec_gaussian_splat_render_backward(t: &Thunk, base: *mut u8) {
14309    let Thunk::GaussianSplatRenderBackward {
14310        positions_off,
14311        positions_len,
14312        scales_off,
14313        scales_len,
14314        rotations_off,
14315        rotations_len,
14316        opacities_off,
14317        opacities_len,
14318        colors_off,
14319        colors_len,
14320        sh_coeffs_off,
14321        sh_coeffs_len,
14322        meta_off,
14323        d_loss_off,
14324        d_loss_len,
14325        packed_off,
14326        packed_len,
14327        width,
14328        height,
14329        tile_size,
14330        radius_scale,
14331        alpha_cutoff,
14332        max_splat_steps,
14333        transmittance_threshold,
14334        max_list_entries,
14335        loss_grad_clip,
14336        sh_band,
14337        max_anisotropy,
14338    } = t
14339    else {
14340        unreachable!()
14341    };
14342    unsafe {
14343        crate::splat::execute_gaussian_splat_render_backward(
14344            *positions_off,
14345            *positions_len,
14346            *scales_off,
14347            *scales_len,
14348            *rotations_off,
14349            *rotations_len,
14350            *opacities_off,
14351            *opacities_len,
14352            *colors_off,
14353            *colors_len,
14354            *sh_coeffs_off,
14355            *sh_coeffs_len,
14356            *meta_off,
14357            *d_loss_off,
14358            *d_loss_len,
14359            *packed_off,
14360            *packed_len,
14361            *width,
14362            *height,
14363            *tile_size,
14364            *radius_scale,
14365            *alpha_cutoff,
14366            *max_splat_steps,
14367            *transmittance_threshold,
14368            *max_list_entries,
14369            *loss_grad_clip,
14370            *sh_band,
14371            *max_anisotropy,
14372            base,
14373        );
14374    }
14375}
14376
14377#[inline(always)]
14378fn exec_gaussian_splat_prepare(t: &Thunk, base: *mut u8) {
14379    let Thunk::GaussianSplatPrepare {
14380        positions_off,
14381        positions_len,
14382        scales_off,
14383        scales_len,
14384        rotations_off,
14385        rotations_len,
14386        opacities_off,
14387        opacities_len,
14388        colors_off,
14389        colors_len,
14390        sh_coeffs_off,
14391        sh_coeffs_len,
14392        meta_off,
14393        meta_len,
14394        prep_off,
14395        prep_len,
14396        width,
14397        height,
14398        tile_size,
14399        radius_scale,
14400        alpha_cutoff,
14401        max_splat_steps,
14402        transmittance_threshold,
14403        max_list_entries,
14404    } = t
14405    else {
14406        unreachable!()
14407    };
14408    unsafe {
14409        crate::splat::execute_gaussian_splat_prepare(
14410            *positions_off,
14411            *positions_len,
14412            *scales_off,
14413            *scales_len,
14414            *rotations_off,
14415            *rotations_len,
14416            *opacities_off,
14417            *opacities_len,
14418            *colors_off,
14419            *colors_len,
14420            *sh_coeffs_off,
14421            *sh_coeffs_len,
14422            *meta_off,
14423            *meta_len,
14424            *prep_off,
14425            *prep_len,
14426            *width,
14427            *height,
14428            *tile_size,
14429            *radius_scale,
14430            *alpha_cutoff,
14431            *max_splat_steps,
14432            *transmittance_threshold,
14433            *max_list_entries,
14434            base,
14435        );
14436    }
14437}
14438
14439#[inline(always)]
14440fn exec_gaussian_splat_rasterize(t: &Thunk, base: *mut u8) {
14441    let Thunk::GaussianSplatRasterize {
14442        prep_off,
14443        prep_len,
14444        meta_off,
14445        meta_len,
14446        dst_off,
14447        dst_len,
14448        count,
14449        width,
14450        height,
14451        tile_size,
14452        alpha_cutoff,
14453        max_splat_steps,
14454        transmittance_threshold,
14455        max_list_entries,
14456    } = t
14457    else {
14458        unreachable!()
14459    };
14460    unsafe {
14461        crate::splat::execute_gaussian_splat_rasterize(
14462            *prep_off,
14463            *prep_len,
14464            *meta_off,
14465            *meta_len,
14466            *dst_off,
14467            *dst_len,
14468            *count,
14469            *width,
14470            *height,
14471            *tile_size,
14472            *alpha_cutoff,
14473            *max_splat_steps,
14474            *transmittance_threshold,
14475            *max_list_entries,
14476            base,
14477        );
14478    }
14479}
14480
14481#[inline(always)]
14482fn exec_fft1d(t: &Thunk, base: *mut u8) {
14483    let Thunk::Fft1d {
14484        src,
14485        dst,
14486        outer,
14487        n_complex,
14488        inverse,
14489        norm_tag,
14490        dtype,
14491    } = t
14492    else {
14493        unreachable!()
14494    };
14495    unsafe {
14496        match dtype {
14497            rlx_ir::DType::F64 => execute_fft1d_f64(
14498                *src,
14499                *dst,
14500                *outer as usize,
14501                *n_complex as usize,
14502                *inverse,
14503                *norm_tag,
14504                base,
14505            ),
14506            rlx_ir::DType::F32 => execute_fft1d_f32(
14507                *src,
14508                *dst,
14509                *outer as usize,
14510                *n_complex as usize,
14511                *inverse,
14512                *norm_tag,
14513                base,
14514            ),
14515            rlx_ir::DType::C64 => execute_fft1d_c64(
14516                *src,
14517                *dst,
14518                *outer as usize,
14519                *n_complex as usize,
14520                *inverse,
14521                *norm_tag,
14522                base,
14523            ),
14524            other => panic!("Op::Fft on CPU requires F32/F64/C64, got {other:?}"),
14525        }
14526    }
14527}
14528
14529#[inline(always)]
14530fn exec_fft_butterfly_stage(t: &Thunk, base: *mut u8) {
14531    let Thunk::FftButterflyStage {
14532        state_src,
14533        state_dst,
14534        gate_src,
14535        rev_src,
14536        tw_re_src,
14537        tw_im_src,
14538        batch,
14539        n_fft,
14540        stage,
14541    } = t
14542    else {
14543        unreachable!()
14544    };
14545    unsafe {
14546        execute_fft_butterfly_stage_f32(
14547            *state_src,
14548            *state_dst,
14549            *gate_src,
14550            *rev_src,
14551            *tw_re_src,
14552            *tw_im_src,
14553            *batch as usize,
14554            *n_fft as usize,
14555            *stage as usize,
14556            base,
14557        );
14558    }
14559}
14560
14561#[inline(always)]
14562fn exec_log_mel(t: &Thunk, base: *mut u8) {
14563    let Thunk::LogMel {
14564        spec,
14565        filters,
14566        dst,
14567        outer,
14568        n_fft,
14569        n_bins,
14570        n_mels,
14571    } = t
14572    else {
14573        unreachable!()
14574    };
14575    unsafe {
14576        execute_log_mel_f32(
14577            *spec,
14578            *filters,
14579            *dst,
14580            *outer as usize,
14581            *n_fft as usize,
14582            *n_bins as usize,
14583            *n_mels as usize,
14584            base,
14585        );
14586    }
14587}
14588
14589#[inline(always)]
14590fn exec_log_mel_backward(t: &Thunk, base: *mut u8) {
14591    let Thunk::LogMelBackward {
14592        spec,
14593        filters,
14594        dy,
14595        dst,
14596        outer,
14597        n_fft,
14598        n_bins,
14599        n_mels,
14600    } = t
14601    else {
14602        unreachable!()
14603    };
14604    unsafe {
14605        execute_log_mel_backward_f32(
14606            *spec,
14607            *filters,
14608            *dy,
14609            *dst,
14610            *outer as usize,
14611            *n_fft as usize,
14612            *n_bins as usize,
14613            *n_mels as usize,
14614            base,
14615        );
14616    }
14617}
14618
14619#[inline(always)]
14620fn exec_welch_peaks(t: &Thunk, base: *mut u8) {
14621    let Thunk::WelchPeaks {
14622        spec,
14623        dst,
14624        welch_batch,
14625        n_fft,
14626        n_segments,
14627        k,
14628    } = t
14629    else {
14630        unreachable!()
14631    };
14632    unsafe {
14633        execute_welch_peaks_f32(
14634            *spec,
14635            *dst,
14636            *welch_batch as usize,
14637            *n_fft as usize,
14638            *n_segments as usize,
14639            *k as usize,
14640            base,
14641        );
14642    }
14643}
14644
14645// CustomFn dispatch (interpreted path). Mirrors the
14646// pre-compiled-closure variant elsewhere in this file.
14647// Patched by rlx-eda.
14648#[inline(always)]
14649fn exec_custom_fn(t: &Thunk, base: *mut u8) {
14650    let Thunk::CustomFn {
14651        body,
14652        body_init,
14653        inputs,
14654        body_output_off,
14655        outer_output_off,
14656        out_bytes,
14657    } = t
14658    else {
14659        unreachable!()
14660    };
14661    {
14662        let mut body_buf: Vec<u8> = (**body_init).clone();
14663        unsafe {
14664            for (body_in_off, outer_in_off, n_bytes) in inputs.iter() {
14665                let src = (base as *const u8).add(*outer_in_off);
14666                let dst = body_buf.as_mut_ptr().add(*body_in_off);
14667                std::ptr::copy_nonoverlapping(src, dst, *n_bytes as usize);
14668            }
14669        }
14670        execute_thunks(body, &mut body_buf);
14671        unsafe {
14672            let src = body_buf.as_ptr().add(*body_output_off);
14673            let dst = base.add(*outer_output_off);
14674            std::ptr::copy_nonoverlapping(src, dst, *out_bytes as usize);
14675        }
14676    }
14677}
14678
14679#[inline(always)]
14680fn exec_sgd_momentum(t: &Thunk, base: *mut u8) {
14681    let Thunk::SgdMomentum {
14682        param,
14683        vel,
14684        grad,
14685        p_out,
14686        v_out,
14687        lr,
14688        mom,
14689        len,
14690    } = t
14691    else {
14692        unreachable!()
14693    };
14694    {
14695        // v' = mom·v + g ;  p' = p − lr·v'  — one fused pass per param,
14696        // replacing the 4 BinaryFull ops the in-graph SGD chain lowers
14697        // to. Element-wise, so in-place aliasing (p_out==param etc.) is
14698        // safe: read index i, write index i.
14699        let len = *len as usize;
14700        let (lr, mom) = (*lr, *mom);
14701        unsafe {
14702            let p = sl(*param, base, len);
14703            let v = sl(*vel, base, len);
14704            let g = sl(*grad, base, len);
14705            let po = sl_mut(*p_out, base, len);
14706            let vo = sl_mut(*v_out, base, len);
14707            if fast_conv_enabled() && crate::pool::should_parallelize(len) {
14708                let poa = po.as_mut_ptr() as usize;
14709                let voa = vo.as_mut_ptr() as usize;
14710                crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
14711                    for i in off..off + cnt {
14712                        let vn = mom * v[i] + g[i];
14713                        *((voa as *mut f32).add(i)) = vn;
14714                        *((poa as *mut f32).add(i)) = p[i] - lr * vn;
14715                    }
14716                });
14717            } else {
14718                for i in 0..len {
14719                    let vn = mom * v[i] + g[i];
14720                    vo[i] = vn;
14721                    po[i] = p[i] - lr * vn;
14722                }
14723            }
14724        }
14725    }
14726}
14727
14728#[inline(always)]
14729fn exec_cgemm_c64(t: &Thunk, base: *mut u8) {
14730    let Thunk::CgemmC64 { a, b, c, m, k, n } = t else {
14731        unreachable!()
14732    };
14733    unsafe {
14734        cgemm_c64(*a, *b, *c, *m as usize, *k as usize, *n as usize, base);
14735    }
14736}
14737
14738#[inline(always)]
14739fn exec_dense_solve_f64(t: &Thunk, base: *mut u8) {
14740    let Thunk::DenseSolveF64 { a, b, x, n, nrhs } = t else {
14741        unreachable!()
14742    };
14743    {
14744        let (n_, nrhs_) = (*n as usize, *nrhs as usize);
14745        // LAPACK overwrites both A and B; clone into scratch
14746        // each call. Caller's A and b must be preserved for
14747        // VJP recompute. (Eventually: swap to a factor-once /
14748        // solve-many scheme; that's the symbolic-reuse story
14749        // and lives with the sparse path.)
14750        unsafe {
14751            let a_src = sl_f64(*a, base, n_ * n_);
14752            let b_src = sl_f64(*b, base, n_ * nrhs_);
14753            let mut a_scratch: Vec<f64> = a_src.to_vec();
14754            let mut x_buf: Vec<f64> = b_src.to_vec();
14755            let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
14756            if info != 0 {
14757                panic!(
14758                    "DenseSolveF64: dgesv reported singular matrix \
14759                                (info={info}, n={n_}, nrhs={nrhs_})"
14760                );
14761            }
14762            let dst = sl_mut_f64(*x, base, n_ * nrhs_);
14763            dst.copy_from_slice(&x_buf);
14764        }
14765    }
14766}
14767
14768#[inline(always)]
14769fn exec_dense_solve_f32(t: &Thunk, base: *mut u8) {
14770    let Thunk::DenseSolveF32 { a, b, x, n, nrhs } = t else {
14771        unreachable!()
14772    };
14773    {
14774        let (n_, nrhs_) = (*n as usize, *nrhs as usize);
14775        unsafe {
14776            let a_src = sl(*a, base, n_ * n_);
14777            let b_src = sl(*b, base, n_ * nrhs_);
14778            let mut a_scratch: Vec<f32> = a_src.to_vec();
14779            let mut x_buf: Vec<f32> = b_src.to_vec();
14780            let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
14781            if info != 0 {
14782                panic!(
14783                    "DenseSolveF32: sgesv reported singular matrix \
14784                             (info={info}, n={n_}, nrhs={nrhs_})"
14785                );
14786            }
14787            let dst = sl_mut(*x, base, n_ * nrhs_);
14788            dst.copy_from_slice(&x_buf);
14789        }
14790    }
14791}
14792
14793#[inline(always)]
14794fn exec_batched_dense_solve_f64(t: &Thunk, base: *mut u8) {
14795    let Thunk::BatchedDenseSolveF64 {
14796        a,
14797        b,
14798        x,
14799        batch,
14800        n,
14801        nrhs,
14802    } = t
14803    else {
14804        unreachable!()
14805    };
14806    {
14807        // Per slice: extract A_i and b_i, dgesv, write x_i.
14808        // LAPACK has no batched dgesv on Accelerate, so this
14809        // is a serial loop over the batch axis. cuSOLVER /
14810        // hipSOLVER expose `getrfBatched` / `getrsBatched` for
14811        // the GPU path — we'll wire that in rlx-cuda when
14812        // someone needs Linux+CUDA.
14813        let (b_, n_, nrhs_) = (*batch as usize, *n as usize, *nrhs as usize);
14814        let a_stride = n_ * n_;
14815        let b_stride = n_ * nrhs_;
14816        unsafe {
14817            let a_full = sl_f64(*a, base, b_ * a_stride);
14818            let b_full = sl_f64(*b, base, b_ * b_stride);
14819            let x_full = sl_mut_f64(*x, base, b_ * b_stride);
14820            for bi in 0..b_ {
14821                let mut a_scratch: Vec<f64> = a_full[bi * a_stride..(bi + 1) * a_stride].to_vec();
14822                let mut x_buf: Vec<f64> = b_full[bi * b_stride..(bi + 1) * b_stride].to_vec();
14823                let info = crate::blas::dgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
14824                if info != 0 {
14825                    panic!(
14826                        "BatchedDenseSolveF64: slice {bi} \
14827                                    singular (info={info}, n={n_}, nrhs={nrhs_})"
14828                    );
14829                }
14830                x_full[bi * b_stride..(bi + 1) * b_stride].copy_from_slice(&x_buf);
14831            }
14832        }
14833    }
14834}
14835
14836#[inline(always)]
14837fn exec_batched_dense_solve_f32(t: &Thunk, base: *mut u8) {
14838    let Thunk::BatchedDenseSolveF32 {
14839        a,
14840        b,
14841        x,
14842        batch,
14843        n,
14844        nrhs,
14845    } = t
14846    else {
14847        unreachable!()
14848    };
14849    {
14850        let (b_, n_, nrhs_) = (*batch as usize, *n as usize, *nrhs as usize);
14851        let a_stride = n_ * n_;
14852        let b_stride = n_ * nrhs_;
14853        unsafe {
14854            let a_full = sl(*a, base, b_ * a_stride);
14855            let b_full = sl(*b, base, b_ * b_stride);
14856            let x_full = sl_mut(*x, base, b_ * b_stride);
14857            for bi in 0..b_ {
14858                let mut a_scratch = a_full[bi * a_stride..(bi + 1) * a_stride].to_vec();
14859                let mut x_buf = b_full[bi * b_stride..(bi + 1) * b_stride].to_vec();
14860                let info = crate::blas::sgesv(&mut a_scratch, &mut x_buf, n_, nrhs_);
14861                if info != 0 {
14862                    panic!("BatchedDenseSolveF32: slice {bi} singular (info={info})");
14863                }
14864                x_full[bi * b_stride..(bi + 1) * b_stride].copy_from_slice(&x_buf);
14865            }
14866        }
14867    }
14868}
14869
14870#[inline(always)]
14871fn exec_batched_dgemm_f64(t: &Thunk, base: *mut u8) {
14872    let Thunk::BatchedDgemmF64 {
14873        a,
14874        b,
14875        c,
14876        batch,
14877        m,
14878        k,
14879        n,
14880    } = t
14881    else {
14882        unreachable!()
14883    };
14884    {
14885        let (b_, m_, k_, n_) = (*batch as usize, *m as usize, *k as usize, *n as usize);
14886        let a_stride = m_ * k_;
14887        let b_stride = k_ * n_;
14888        let c_stride = m_ * n_;
14889        unsafe {
14890            let a_full = sl_f64(*a, base, b_ * a_stride);
14891            let b_full = sl_f64(*b, base, b_ * b_stride);
14892            let c_full = sl_mut_f64(*c, base, b_ * c_stride);
14893            for bi in 0..b_ {
14894                let a_slice = &a_full[bi * a_stride..(bi + 1) * a_stride];
14895                let b_slice = &b_full[bi * b_stride..(bi + 1) * b_stride];
14896                let c_slice = &mut c_full[bi * c_stride..(bi + 1) * c_stride];
14897                crate::blas::dgemm(a_slice, b_slice, c_slice, m_, k_, n_);
14898            }
14899        }
14900    }
14901}
14902
14903#[inline(always)]
14904fn exec_dgemm(t: &Thunk, base: *mut u8) {
14905    let Thunk::Dgemm { a, b, c, m, k, n } = t else {
14906        unreachable!()
14907    };
14908    {
14909        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
14910        unsafe {
14911            crate::blas::dgemm(
14912                sl_f64(*a, base, m * k),
14913                sl_f64(*b, base, k * n),
14914                sl_mut_f64(*c, base, m * n),
14915                m,
14916                k,
14917                n,
14918            );
14919        }
14920    }
14921}
14922
14923#[inline(always)]
14924fn exec_transpose_f64(t: &Thunk, base: *mut u8) {
14925    let Thunk::TransposeF64 {
14926        src,
14927        dst,
14928        in_total,
14929        out_dims,
14930        in_strides,
14931    } = t
14932    else {
14933        unreachable!()
14934    };
14935    unsafe {
14936        let inp = sl_f64(*src, base, *in_total as usize);
14937        let out_total: usize = out_dims.iter().map(|d| *d as usize).product();
14938        let out = sl_mut_f64(*dst, base, out_total);
14939        transpose_walk_f64(inp, out, out_dims, in_strides);
14940    }
14941}
14942
14943#[inline(always)]
14944fn exec_activation_f64(t: &Thunk, base: *mut u8) {
14945    let Thunk::ActivationF64 {
14946        src,
14947        dst,
14948        len,
14949        kind,
14950    } = t
14951    else {
14952        unreachable!()
14953    };
14954    {
14955        let len = *len as usize;
14956        unsafe {
14957            let inp = sl_f64(*src, base, len);
14958            let out = sl_mut_f64(*dst, base, len);
14959            apply_activation_f64(inp, out, *kind);
14960        }
14961    }
14962}
14963
14964#[inline(always)]
14965fn exec_reduce_sum_f64(t: &Thunk, base: *mut u8) {
14966    let Thunk::ReduceSumF64 {
14967        src,
14968        dst,
14969        outer,
14970        reduced,
14971        inner,
14972    } = t
14973    else {
14974        unreachable!()
14975    };
14976    {
14977        let (o, r, n) = (*outer as usize, *reduced as usize, *inner as usize);
14978        unsafe {
14979            let inp = sl_f64(*src, base, o * r * n);
14980            let out = sl_mut_f64(*dst, base, o * n);
14981            reduce_sum_f64(inp, out, o, r, n);
14982        }
14983    }
14984}
14985
14986#[inline(always)]
14987fn exec_binary_full_f64(t: &Thunk, base: *mut u8) {
14988    let Thunk::BinaryFullF64 {
14989        lhs,
14990        rhs,
14991        dst,
14992        len,
14993        lhs_len,
14994        rhs_len,
14995        op,
14996        out_dims_bcast,
14997        bcast_lhs_strides,
14998        bcast_rhs_strides,
14999    } = t
15000    else {
15001        unreachable!()
15002    };
15003    {
15004        let len = *len as usize;
15005        let lhs_len = *lhs_len as usize;
15006        let rhs_len = *rhs_len as usize;
15007        unsafe {
15008            let l = sl_f64(*lhs, base, lhs_len);
15009            let r = sl_f64(*rhs, base, rhs_len);
15010            let d = sl_mut_f64(*dst, base, len);
15011            if lhs_len == len && rhs_len == len {
15012                for i in 0..len {
15013                    d[i] = binary_op_f64(*op, l[i], r[i]);
15014                }
15015            } else if !out_dims_bcast.is_empty() {
15016                // Shape-aware broadcast path: correct for
15017                // arbitrary NumPy-style broadcasts including
15018                // bidirectional `[N,1] op [1,S]`.
15019                let rank = out_dims_bcast.len();
15020                let mut coords = vec![0u32; rank];
15021                for i in 0..len {
15022                    let mut rem = i;
15023                    for ax in (0..rank).rev() {
15024                        let sz = out_dims_bcast[ax] as usize;
15025                        coords[ax] = (rem % sz) as u32;
15026                        rem /= sz;
15027                    }
15028                    let mut li: usize = 0;
15029                    let mut ri: usize = 0;
15030                    for ax in 0..rank {
15031                        li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
15032                        ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
15033                    }
15034                    d[i] = binary_op_f64(*op, l[li], r[ri]);
15035                }
15036            } else {
15037                // Fallback: legacy modulo path (preserved for
15038                // dynamic-shape graphs where strides can't be
15039                // precomputed). Only correct for scalar /
15040                // last-axis broadcast.
15041                for i in 0..len {
15042                    d[i] = binary_op_f64(*op, l[i % lhs_len], r[i % rhs_len]);
15043                }
15044            }
15045        }
15046    }
15047}
15048
15049#[inline(always)]
15050fn exec_binary_full_c64(t: &Thunk, base: *mut u8) {
15051    let Thunk::BinaryFullC64 {
15052        lhs,
15053        rhs,
15054        dst,
15055        len,
15056        lhs_len,
15057        rhs_len,
15058        op,
15059        out_dims_bcast,
15060        bcast_lhs_strides,
15061        bcast_rhs_strides,
15062    } = t
15063    else {
15064        unreachable!()
15065    };
15066    {
15067        // Complex element layout: [re_0, im_0, re_1, im_1, ...]
15068        // Underlying f32 buffer length is 2·N (N = complex
15069        // element count). All offsets are byte offsets; the
15070        // `sl` helper reads as f32 starting at the byte
15071        // offset, so f32-length = 2·complex-len.
15072        let n_out = *len as usize;
15073        let n_l = *lhs_len as usize;
15074        let n_r = *rhs_len as usize;
15075        unsafe {
15076            let l = sl(*lhs, base, 2 * n_l);
15077            let r = sl(*rhs, base, 2 * n_r);
15078            let d = sl_mut(*dst, base, 2 * n_out);
15079            let do_c64 = |a_re: f32, a_im: f32, b_re: f32, b_im: f32| -> (f32, f32) {
15080                match op {
15081                    BinaryOp::Add => (a_re + b_re, a_im + b_im),
15082                    BinaryOp::Sub => (a_re - b_re, a_im - b_im),
15083                    BinaryOp::Mul => (a_re * b_re - a_im * b_im, a_re * b_im + a_im * b_re),
15084                    BinaryOp::Div => {
15085                        let denom = b_re * b_re + b_im * b_im;
15086                        (
15087                            (a_re * b_re + a_im * b_im) / denom,
15088                            (a_im * b_re - a_re * b_im) / denom,
15089                        )
15090                    }
15091                    BinaryOp::Max | BinaryOp::Min | BinaryOp::Pow => {
15092                        unreachable!("C64 max/min/pow rejected at lowering")
15093                    }
15094                }
15095            };
15096            if n_l == n_out && n_r == n_out {
15097                for i in 0..n_out {
15098                    let (re, im) = do_c64(l[2 * i], l[2 * i + 1], r[2 * i], r[2 * i + 1]);
15099                    d[2 * i] = re;
15100                    d[2 * i + 1] = im;
15101                }
15102            } else if !out_dims_bcast.is_empty() {
15103                // Strided complex broadcast: strides are in
15104                // *complex element* units; multiply by 2 when
15105                // indexing into the f32 buffer.
15106                let rank = out_dims_bcast.len();
15107                let mut coords = vec![0u32; rank];
15108                for i in 0..n_out {
15109                    let mut rem = i;
15110                    for ax in (0..rank).rev() {
15111                        let sz = out_dims_bcast[ax] as usize;
15112                        coords[ax] = (rem % sz) as u32;
15113                        rem /= sz;
15114                    }
15115                    let mut li: usize = 0;
15116                    let mut ri: usize = 0;
15117                    for ax in 0..rank {
15118                        li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
15119                        ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
15120                    }
15121                    let (re, im) = do_c64(l[2 * li], l[2 * li + 1], r[2 * ri], r[2 * ri + 1]);
15122                    d[2 * i] = re;
15123                    d[2 * i + 1] = im;
15124                }
15125            } else {
15126                // Modulo fallback (scalar / last-axis broadcast).
15127                for i in 0..n_out {
15128                    let li = if n_l == 1 { 0 } else { i % n_l };
15129                    let ri = if n_r == 1 { 0 } else { i % n_r };
15130                    let (re, im) = do_c64(l[2 * li], l[2 * li + 1], r[2 * ri], r[2 * ri + 1]);
15131                    d[2 * i] = re;
15132                    d[2 * i + 1] = im;
15133                }
15134            }
15135        }
15136    }
15137}
15138
15139#[inline(always)]
15140fn exec_complex_norm_sq_f32(t: &Thunk, base: *mut u8) {
15141    let Thunk::ComplexNormSqF32 { src, dst, len } = t else {
15142        unreachable!()
15143    };
15144    {
15145        let n = *len as usize;
15146        unsafe {
15147            let s = sl(*src, base, 2 * n);
15148            let d = sl_mut(*dst, base, n);
15149            for i in 0..n {
15150                let re = s[2 * i];
15151                let im = s[2 * i + 1];
15152                d[i] = re * re + im * im;
15153            }
15154        }
15155    }
15156}
15157
15158#[inline(always)]
15159fn exec_complex_norm_sq_backward_f32(t: &Thunk, base: *mut u8) {
15160    let Thunk::ComplexNormSqBackwardF32 { z, g, dz, len } = t else {
15161        unreachable!()
15162    };
15163    {
15164        // Wirtinger: dz = g · z, element-wise complex
15165        // (g is real, z is complex).
15166        let n = *len as usize;
15167        unsafe {
15168            let zb = sl(*z, base, 2 * n);
15169            let gb = sl(*g, base, n);
15170            let db = sl_mut(*dz, base, 2 * n);
15171            for i in 0..n {
15172                let re = zb[2 * i];
15173                let im = zb[2 * i + 1];
15174                let gv = gb[i];
15175                db[2 * i] = gv * re;
15176                db[2 * i + 1] = gv * im;
15177            }
15178        }
15179    }
15180}
15181
15182#[inline(always)]
15183fn exec_conjugate_c64(t: &Thunk, base: *mut u8) {
15184    let Thunk::ConjugateC64 { src, dst, len } = t else {
15185        unreachable!()
15186    };
15187    {
15188        let n = *len as usize;
15189        unsafe {
15190            let s = sl(*src, base, 2 * n);
15191            let d = sl_mut(*dst, base, 2 * n);
15192            for i in 0..n {
15193                d[2 * i] = s[2 * i];
15194                d[2 * i + 1] = -s[2 * i + 1];
15195            }
15196        }
15197    }
15198}
15199
15200#[inline(always)]
15201fn exec_activation_c64(t: &Thunk, base: *mut u8) {
15202    let Thunk::ActivationC64 {
15203        src,
15204        dst,
15205        len,
15206        kind,
15207    } = t
15208    else {
15209        unreachable!()
15210    };
15211    {
15212        let n = *len as usize;
15213        unsafe {
15214            let s = sl(*src, base, 2 * n);
15215            let d = sl_mut(*dst, base, 2 * n);
15216            for i in 0..n {
15217                let a = s[2 * i];
15218                let b = s[2 * i + 1];
15219                let (re, im) = match kind {
15220                    Activation::Neg => (-a, -b),
15221                    Activation::Exp => {
15222                        // exp(a + bi) = e^a · (cos b + i·sin b)
15223                        let ea = a.exp();
15224                        (ea * b.cos(), ea * b.sin())
15225                    }
15226                    Activation::Log => {
15227                        // log(z) = log|z| + i·arg(z), principal branch
15228                        let r = (a * a + b * b).sqrt();
15229                        (r.ln(), b.atan2(a))
15230                    }
15231                    Activation::Sqrt => {
15232                        // sqrt(a+bi) = sqrt((|z|+a)/2) + sign(b)·i·sqrt((|z|-a)/2)
15233                        // Principal branch; for b == 0 and a < 0 returns +i·sqrt(|a|).
15234                        let r = (a * a + b * b).sqrt();
15235                        let re = ((r + a) * 0.5).max(0.0).sqrt();
15236                        let im_mag = ((r - a) * 0.5).max(0.0).sqrt();
15237                        let im = if b >= 0.0 { im_mag } else { -im_mag };
15238                        (re, im)
15239                    }
15240                    _ => unreachable!("non-C64 activation kind survived lowering"),
15241                };
15242                d[2 * i] = re;
15243                d[2 * i + 1] = im;
15244            }
15245        }
15246    }
15247}
15248
15249#[inline(always)]
15250fn exec_scan(t: &Thunk, base: *mut u8) {
15251    let Thunk::Scan {
15252        body,
15253        body_init,
15254        body_input_off,
15255        body_output_off,
15256        outer_init_off,
15257        outer_final_off,
15258        length,
15259        carry_bytes,
15260        save_trajectory,
15261        xs_inputs,
15262        bcast_inputs,
15263        num_checkpoints,
15264    } = t
15265    else {
15266        unreachable!()
15267    };
15268    {
15269        let cb = *carry_bytes as usize;
15270        let n_steps = *length as usize;
15271        // Checkpoint mode: when 0 < K < length, save trajectory[k]
15272        // only when t == c_k = floor((k+1) * length / K) - 1.
15273        // The last index c_{K-1} = length - 1 always.
15274        let k_total = if *num_checkpoints == 0 || *num_checkpoints == *length {
15275            n_steps // save every step
15276        } else {
15277            *num_checkpoints as usize
15278        };
15279        let checkpoint_t_for_k = |k: usize| -> usize {
15280            if k_total == n_steps {
15281                k
15282            } else {
15283                ((k + 1) * n_steps)
15284                    .div_ceil(k_total)
15285                    .saturating_sub(1)
15286                    .min(n_steps - 1)
15287            }
15288        };
15289        let mut next_k = 0usize;
15290
15291        let mut body_buf: Vec<u8> = (**body_init).clone();
15292        unsafe {
15293            std::ptr::copy_nonoverlapping(
15294                base.add(*outer_init_off),
15295                body_buf.as_mut_ptr().add(*body_input_off),
15296                cb,
15297            );
15298            // Broadcast inputs: copy each one into the body's
15299            // input slot ONCE. They aren't touched in the
15300            // iteration loop below (in contrast to xs).
15301            for (body_b_off, outer_b_off, total_bytes) in bcast_inputs.iter() {
15302                std::ptr::copy_nonoverlapping(
15303                    base.add(*outer_b_off),
15304                    body_buf.as_mut_ptr().add(*body_b_off),
15305                    *total_bytes as usize,
15306                );
15307            }
15308        }
15309        for t in 0..n_steps {
15310            for (body_x_off, outer_xs_off, per_step_bytes) in xs_inputs.iter() {
15311                let psb = *per_step_bytes as usize;
15312                unsafe {
15313                    std::ptr::copy_nonoverlapping(
15314                        base.add(*outer_xs_off + t * psb),
15315                        body_buf.as_mut_ptr().add(*body_x_off),
15316                        psb,
15317                    );
15318                }
15319            }
15320
15321            execute_thunks(body, &mut body_buf);
15322
15323            if *save_trajectory && next_k < k_total && t == checkpoint_t_for_k(next_k) {
15324                unsafe {
15325                    std::ptr::copy_nonoverlapping(
15326                        body_buf.as_ptr().add(*body_output_off),
15327                        base.add(*outer_final_off + next_k * cb),
15328                        cb,
15329                    );
15330                }
15331                next_k += 1;
15332            }
15333
15334            if *body_output_off != *body_input_off {
15335                body_buf.copy_within(*body_output_off..*body_output_off + cb, *body_input_off);
15336            }
15337        }
15338
15339        if !*save_trajectory {
15340            // Single final-carry write.
15341            unsafe {
15342                std::ptr::copy_nonoverlapping(
15343                    body_buf.as_ptr().add(*body_output_off),
15344                    base.add(*outer_final_off),
15345                    cb,
15346                );
15347            }
15348        }
15349    }
15350}
15351
15352#[inline(always)]
15353fn exec_scan_backward_xs(t: &Thunk, base: *mut u8) {
15354    let Thunk::ScanBackwardXs {
15355        body_vjp,
15356        body_init,
15357        body_carry_in_off,
15358        body_x_offs,
15359        body_d_output_off,
15360        body_dcarry_out_off,
15361        body_dxs_out_off,
15362        outer_init_off,
15363        outer_traj_off,
15364        outer_upstream_off,
15365        outer_xs_offs,
15366        outer_dxs_off,
15367        length,
15368        carry_bytes,
15369        carry_elem_size,
15370        per_step_bytes,
15371        save_trajectory,
15372        num_checkpoints,
15373        forward_body,
15374        forward_body_init,
15375        forward_body_carry_in_off,
15376        forward_body_output_off,
15377        forward_body_x_offs,
15378    } = t
15379    else {
15380        unreachable!()
15381    };
15382    {
15383        let cb = *carry_bytes as usize;
15384        let psb = *per_step_bytes as usize;
15385        let n_steps = *length as usize;
15386        let k_total = *num_checkpoints as usize;
15387        let is_recursive = k_total != 0 && k_total != n_steps;
15388        let checkpoint_t_for_k = |k: usize| -> usize {
15389            ((k + 1) * n_steps)
15390                .div_ceil(k_total)
15391                .saturating_sub(1)
15392                .min(n_steps - 1)
15393        };
15394
15395        // Forward-body recompute scratch + segment cache —
15396        // exact mirror of the ScanBackward path. With ≈√length
15397        // checkpoints, total recompute work is O(length).
15398        let mut fwd_buf: Vec<u8> = if is_recursive {
15399            (**forward_body_init.as_ref().unwrap()).clone()
15400        } else {
15401            Vec::new()
15402        };
15403        let mut seg_cache: Vec<u8> = Vec::new();
15404        let mut seg_start_t: usize = usize::MAX;
15405        let mut seg_count: usize = 0;
15406        let recompute_carry_t = |t: usize,
15407                                 dst: &mut [u8],
15408                                 fwd_buf: &mut Vec<u8>,
15409                                 seg_cache: &mut Vec<u8>,
15410                                 seg_start_t: &mut usize,
15411                                 seg_count: &mut usize| {
15412            if !is_recursive {
15413                unsafe {
15414                    let src = if t == 0 {
15415                        base.add(*outer_init_off)
15416                    } else {
15417                        base.add(*outer_traj_off + (t - 1) * cb)
15418                    };
15419                    std::ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), cb);
15420                }
15421                return;
15422            }
15423            if *seg_start_t != usize::MAX && t >= *seg_start_t && t < *seg_start_t + *seg_count {
15424                let off = (t - *seg_start_t) * cb;
15425                dst.copy_from_slice(&seg_cache[off..off + cb]);
15426                return;
15427            }
15428            let seg_k = (0..k_total)
15429                .find(|&k| t <= checkpoint_t_for_k(k))
15430                .unwrap_or(k_total - 1);
15431            let (anchor_t, anchor_ptr): (usize, *const u8) = if seg_k == 0 {
15432                (0, unsafe { base.add(*outer_init_off) as *const u8 })
15433            } else {
15434                let prev_ck = checkpoint_t_for_k(seg_k - 1);
15435                (prev_ck + 1, unsafe {
15436                    base.add(*outer_traj_off + (seg_k - 1) * cb) as *const u8
15437                })
15438            };
15439            let seg_end_t = checkpoint_t_for_k(seg_k);
15440            let seg_size = seg_end_t - anchor_t + 1;
15441
15442            fwd_buf.copy_from_slice(forward_body_init.as_ref().unwrap());
15443            unsafe {
15444                std::ptr::copy_nonoverlapping(
15445                    anchor_ptr,
15446                    fwd_buf.as_mut_ptr().add(*forward_body_carry_in_off),
15447                    cb,
15448                );
15449            }
15450            seg_cache.resize(seg_size * cb, 0u8);
15451            seg_cache[0..cb].copy_from_slice(
15452                &fwd_buf[*forward_body_carry_in_off..*forward_body_carry_in_off + cb],
15453            );
15454            let fb_sched = forward_body.as_ref().unwrap();
15455            for i in 1..seg_size {
15456                let cur_iter = anchor_t + i - 1;
15457                for (idx, fb_x_off) in forward_body_x_offs.iter().enumerate() {
15458                    let (outer_xs_off, x_psb) = outer_xs_offs[idx];
15459                    let xb = x_psb as usize;
15460                    unsafe {
15461                        std::ptr::copy_nonoverlapping(
15462                            base.add(outer_xs_off + cur_iter * xb),
15463                            fwd_buf.as_mut_ptr().add(*fb_x_off),
15464                            xb,
15465                        );
15466                    }
15467                }
15468                execute_thunks(fb_sched, fwd_buf);
15469                if *forward_body_output_off != *forward_body_carry_in_off {
15470                    fwd_buf.copy_within(
15471                        *forward_body_output_off..*forward_body_output_off + cb,
15472                        *forward_body_carry_in_off,
15473                    );
15474                }
15475                let cache_off = i * cb;
15476                seg_cache[cache_off..cache_off + cb].copy_from_slice(
15477                    &fwd_buf[*forward_body_carry_in_off..*forward_body_carry_in_off + cb],
15478                );
15479            }
15480            *seg_start_t = anchor_t;
15481            *seg_count = seg_size;
15482
15483            let off = (t - anchor_t) * cb;
15484            dst.copy_from_slice(&seg_cache[off..off + cb]);
15485        };
15486
15487        let mut dcarry: Vec<u8> = vec![0u8; cb];
15488        if !*save_trajectory {
15489            unsafe {
15490                std::ptr::copy_nonoverlapping(
15491                    base.add(*outer_upstream_off),
15492                    dcarry.as_mut_ptr(),
15493                    cb,
15494                );
15495            }
15496        }
15497
15498        let mut body_buf: Vec<u8> = (**body_init).clone();
15499
15500        for t in (0..n_steps).rev() {
15501            if *save_trajectory {
15502                unsafe {
15503                    let up_off = *outer_upstream_off + t * cb;
15504                    match *carry_elem_size {
15505                        4 => {
15506                            let up_ptr = base.add(up_off) as *const f32;
15507                            let dc_ptr = dcarry.as_mut_ptr() as *mut f32;
15508                            let n_elems = cb / 4;
15509                            for i in 0..n_elems {
15510                                *dc_ptr.add(i) += *up_ptr.add(i);
15511                            }
15512                        }
15513                        8 => {
15514                            let up_ptr = base.add(up_off) as *const f64;
15515                            let dc_ptr = dcarry.as_mut_ptr() as *mut f64;
15516                            let n_elems = cb / 8;
15517                            for i in 0..n_elems {
15518                                *dc_ptr.add(i) += *up_ptr.add(i);
15519                            }
15520                        }
15521                        other => panic!(
15522                            "ScanBackwardXs: unsupported carry elem size {other} \
15523                                     (only f32/f64 carries are supported today)"
15524                        ),
15525                    }
15526                }
15527            }
15528
15529            // Seed body_vjp's carry input via the recompute
15530            // helper (works for both All and Recursive modes),
15531            // then x_t_i + d_output.
15532            let carry_dst_start = *body_carry_in_off;
15533            {
15534                let carry_slice = &mut body_buf[carry_dst_start..carry_dst_start + cb];
15535                recompute_carry_t(
15536                    t,
15537                    carry_slice,
15538                    &mut fwd_buf,
15539                    &mut seg_cache,
15540                    &mut seg_start_t,
15541                    &mut seg_count,
15542                );
15543            }
15544            unsafe {
15545                for (i, body_x_off) in body_x_offs.iter().enumerate() {
15546                    let (outer_xs_off, x_psb) = outer_xs_offs[i];
15547                    let xb = x_psb as usize;
15548                    std::ptr::copy_nonoverlapping(
15549                        base.add(outer_xs_off + t * xb),
15550                        body_buf.as_mut_ptr().add(*body_x_off),
15551                        xb,
15552                    );
15553                }
15554                std::ptr::copy_nonoverlapping(
15555                    dcarry.as_ptr(),
15556                    body_buf.as_mut_ptr().add(*body_d_output_off),
15557                    cb,
15558                );
15559            }
15560
15561            execute_thunks(body_vjp, &mut body_buf);
15562
15563            // Stash this step's dxs into row `t` of the outer
15564            // [length, *per_step_xs] output.
15565            unsafe {
15566                std::ptr::copy_nonoverlapping(
15567                    body_buf.as_ptr().add(*body_dxs_out_off),
15568                    base.add(*outer_dxs_off + t * psb),
15569                    psb,
15570                );
15571            }
15572
15573            // Update dcarry for next backward iteration.
15574            unsafe {
15575                std::ptr::copy_nonoverlapping(
15576                    body_buf.as_ptr().add(*body_dcarry_out_off),
15577                    dcarry.as_mut_ptr(),
15578                    cb,
15579                );
15580            }
15581        }
15582    }
15583}
15584
15585#[inline(always)]
15586fn exec_fused_mm_bias_act(t: &Thunk, base: *mut u8) {
15587    let Thunk::FusedMmBiasAct {
15588        a,
15589        w,
15590        bias,
15591        c,
15592        m,
15593        k,
15594        n,
15595        act,
15596    } = t
15597    else {
15598        unreachable!()
15599    };
15600    {
15601        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
15602        unsafe {
15603            let out = sl_mut(*c, base, m * n);
15604            crate::blas::sgemm_auto(sl(*a, base, m * k), sl(*w, base, k * n), out, m, k, n);
15605            match act {
15606                Some(Activation::Gelu) => {
15607                    crate::kernels::par_bias_gelu(out, sl(*bias, base, n), m, n)
15608                }
15609                Some(other) => {
15610                    crate::blas::bias_add(out, sl(*bias, base, n), m, n);
15611                    apply_activation_inplace(out, *other);
15612                }
15613                None => crate::blas::bias_add(out, sl(*bias, base, n), m, n),
15614            }
15615        }
15616    }
15617}
15618
15619#[inline(always)]
15620fn exec_bias_add(t: &Thunk, base: *mut u8) {
15621    let Thunk::BiasAdd {
15622        src,
15623        bias,
15624        dst,
15625        m,
15626        n,
15627    } = t
15628    else {
15629        unreachable!()
15630    };
15631    {
15632        let (m, n) = (*m as usize, *n as usize);
15633        let len = m * n;
15634        unsafe {
15635            let out = sl_mut(*dst, base, len);
15636            if *src != *dst {
15637                let src_ptr = base.add(*src) as *const f32;
15638                let dst_ptr = base.add(*dst) as *mut f32;
15639                if src_ptr != dst_ptr {
15640                    std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
15641                }
15642            }
15643            crate::blas::bias_add(out, sl(*bias, base, n), m, n);
15644        }
15645    }
15646}
15647
15648#[inline(always)]
15649fn exec_gather(t: &Thunk, base: *mut u8) {
15650    let Thunk::Gather {
15651        table,
15652        table_len,
15653        idx,
15654        dst,
15655        num_idx,
15656        trailing,
15657        idx_i64,
15658        table_bytes,
15659    } = t
15660    else {
15661        unreachable!()
15662    };
15663    {
15664        let (ni, tr) = (*num_idx as usize, *trailing as usize);
15665        let rows = *table_len as usize / tr.max(1);
15666        unsafe {
15667            if *table_bytes == 8 {
15668                let tab = sl_i64(*table, base, *table_len as usize);
15669                let out = sl_mut_i64(*dst, base, ni * tr);
15670                if *idx_i64 != 0 {
15671                    let ids = sl_i64(*idx, base, ni);
15672                    for i in 0..ni {
15673                        let row = ids[i].max(0) as usize;
15674                        if row < rows {
15675                            out[i * tr..(i + 1) * tr]
15676                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
15677                        }
15678                    }
15679                } else {
15680                    let ids = sl(*idx, base, ni);
15681                    for i in 0..ni {
15682                        let row = ids[i] as usize;
15683                        if row < rows {
15684                            out[i * tr..(i + 1) * tr]
15685                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
15686                        }
15687                    }
15688                }
15689            } else {
15690                let tab = sl(*table, base, *table_len as usize);
15691                let out = sl_mut(*dst, base, ni * tr);
15692                if *idx_i64 != 0 {
15693                    let ids = sl_i64(*idx, base, ni);
15694                    for i in 0..ni {
15695                        let row = ids[i].max(0) as usize;
15696                        if row < rows {
15697                            out[i * tr..(i + 1) * tr]
15698                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
15699                        }
15700                    }
15701                } else {
15702                    let ids = sl(*idx, base, ni);
15703                    for i in 0..ni {
15704                        let row = ids[i] as usize;
15705                        if row < rows {
15706                            out[i * tr..(i + 1) * tr]
15707                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
15708                        }
15709                    }
15710                }
15711            }
15712        }
15713    }
15714}
15715
15716#[inline(always)]
15717fn exec_layer_norm(t: &Thunk, base: *mut u8) {
15718    let Thunk::LayerNorm {
15719        src,
15720        g,
15721        b,
15722        dst,
15723        rows,
15724        h,
15725        eps,
15726    } = t
15727    else {
15728        unreachable!()
15729    };
15730    {
15731        let (rows, h) = (*rows as usize, *h as usize);
15732        unsafe {
15733            let input = sl(*src, base, rows * h);
15734            let gamma = sl(*g, base, h);
15735            let beta = sl(*b, base, h);
15736            let output = sl_mut(*dst, base, rows * h);
15737            // Parallelize across rows (same pattern as FusedResidualLN)
15738            if rows >= 4 && rows * h >= 30_000 {
15739                let i_ptr = input.as_ptr() as usize;
15740                let o_ptr = output.as_mut_ptr() as usize;
15741                let g_ptr = gamma.as_ptr() as usize;
15742                let b_ptr = beta.as_ptr() as usize;
15743                let e = *eps;
15744                crate::pool::par_for(rows, 4, &|off, cnt| {
15745                    let inp =
15746                        std::slice::from_raw_parts((i_ptr as *const f32).add(off * h), cnt * h);
15747                    let out =
15748                        std::slice::from_raw_parts_mut((o_ptr as *mut f32).add(off * h), cnt * h);
15749                    let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
15750                    let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
15751                    for row in 0..cnt {
15752                        crate::kernels::layer_norm_row(
15753                            &inp[row * h..(row + 1) * h],
15754                            g,
15755                            b,
15756                            &mut out[row * h..(row + 1) * h],
15757                            h,
15758                            e,
15759                        );
15760                    }
15761                });
15762            } else {
15763                for row in 0..rows {
15764                    crate::kernels::layer_norm_row(
15765                        &input[row * h..(row + 1) * h],
15766                        gamma,
15767                        beta,
15768                        &mut output[row * h..(row + 1) * h],
15769                        h,
15770                        *eps,
15771                    );
15772                }
15773            }
15774        }
15775    }
15776}
15777
15778#[inline(always)]
15779fn exec_group_norm(t: &Thunk, base: *mut u8) {
15780    let Thunk::GroupNorm {
15781        src,
15782        g,
15783        b,
15784        dst,
15785        n,
15786        c,
15787        h,
15788        w,
15789        num_groups,
15790        eps,
15791    } = t
15792    else {
15793        unreachable!()
15794    };
15795    {
15796        let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
15797        let plane = c * h * w;
15798        unsafe {
15799            // Per-batch plane stride is `plane` *f32 elements*; `base`
15800            // is a byte pointer, so advance by `plane * size_of::<f32>()`.
15801            // (Was `base.add(ni * plane)` — a 4× under-advance that read
15802            // batch row 1+ from the wrong offset; only n=1 was tested.)
15803            let stride = plane * std::mem::size_of::<f32>();
15804            for ni in 0..n {
15805                let input = sl(*src, base.add(ni * stride), plane);
15806                let gamma = sl(*g, base, c);
15807                let beta = sl(*b, base, c);
15808                let output = sl_mut(*dst, base.add(ni * stride), plane);
15809                crate::kernels::group_norm_nchw(
15810                    input,
15811                    gamma,
15812                    beta,
15813                    output,
15814                    1,
15815                    c,
15816                    h,
15817                    w,
15818                    *num_groups as usize,
15819                    *eps,
15820                );
15821            }
15822        }
15823    }
15824}
15825
15826#[inline(always)]
15827fn exec_batch_norm_inference(t: &Thunk, base: *mut u8) {
15828    let Thunk::BatchNormInference {
15829        src,
15830        g,
15831        b,
15832        mean,
15833        var,
15834        dst,
15835        count,
15836        channels,
15837        eps,
15838    } = t
15839    else {
15840        unreachable!()
15841    };
15842    {
15843        let count = *count as usize;
15844        let c = *channels as usize;
15845        let n = count * c;
15846        unsafe {
15847            crate::kernels::batch_norm_inference(
15848                sl(*src, base, n),
15849                sl(*g, base, c),
15850                sl(*b, base, c),
15851                sl(*mean, base, c),
15852                sl(*var, base, c),
15853                sl_mut(*dst, base, n),
15854                c,
15855                *eps,
15856            );
15857        }
15858    }
15859}
15860
15861#[inline(always)]
15862fn exec_layer_norm2d(t: &Thunk, base: *mut u8) {
15863    let Thunk::LayerNorm2d {
15864        src,
15865        g,
15866        b,
15867        dst,
15868        n,
15869        c,
15870        h,
15871        w,
15872        eps,
15873    } = t
15874    else {
15875        unreachable!()
15876    };
15877    {
15878        let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
15879        let plane = c * h * w;
15880        unsafe {
15881            let input = sl(*src, base, n * plane);
15882            let gamma = sl(*g, base, c);
15883            let beta = sl(*b, base, c);
15884            let output = sl_mut(*dst, base, n * plane);
15885            crate::kernels::layer_norm2d_nchw(input, gamma, beta, output, n, c, h, w, *eps);
15886        }
15887    }
15888}
15889
15890#[inline(always)]
15891fn exec_conv_transpose2d(t: &Thunk, base: *mut u8) {
15892    let Thunk::ConvTranspose2d {
15893        src,
15894        weight,
15895        dst,
15896        n,
15897        c_in,
15898        h,
15899        w_in,
15900        c_out,
15901        h_out,
15902        w_out,
15903        kh,
15904        kw,
15905        sh,
15906        sw,
15907        ph,
15908        pw,
15909        dh,
15910        dw,
15911        groups,
15912    } = t
15913    else {
15914        unreachable!()
15915    };
15916    {
15917        let n = *n as usize;
15918        let c_in = *c_in as usize;
15919        let h = *h as usize;
15920        let w_in = *w_in as usize;
15921        let c_out = *c_out as usize;
15922        let h_out = *h_out as usize;
15923        let w_out = *w_out as usize;
15924        unsafe {
15925            let inp = sl(*src, base, n * c_in * h * w_in);
15926            let wt = sl(
15927                *weight,
15928                base,
15929                c_in * (c_out / *groups as usize) * (*kh as usize) * (*kw as usize),
15930            );
15931            let out = sl_mut(*dst, base, n * c_out * h_out * w_out);
15932            crate::kernels::conv_transpose2d_nchw(
15933                inp,
15934                wt,
15935                out,
15936                n,
15937                c_in,
15938                h,
15939                w_in,
15940                c_out,
15941                h_out,
15942                w_out,
15943                *kh as usize,
15944                *kw as usize,
15945                *sh as usize,
15946                *sw as usize,
15947                *ph as usize,
15948                *pw as usize,
15949                *dh as usize,
15950                *dw as usize,
15951                *groups as usize,
15952            );
15953        }
15954    }
15955}
15956
15957fn exec_conv3d(t: &Thunk, base: *mut u8) {
15958    let Thunk::Conv3d {
15959        src,
15960        weight,
15961        dst,
15962        n,
15963        c_in,
15964        d,
15965        h,
15966        w,
15967        c_out,
15968        d_out,
15969        h_out,
15970        w_out,
15971        kd,
15972        kh,
15973        kw,
15974        sd,
15975        sh,
15976        sw,
15977        pd,
15978        ph,
15979        pw,
15980        dd,
15981        dh,
15982        dw,
15983        groups,
15984    } = t
15985    else {
15986        unreachable!()
15987    };
15988    unsafe {
15989        execute_conv3d_forward_f32(
15990            *src, *weight, *dst, *n, *c_in, *d, *h, *w, *c_out, *d_out, *h_out, *w_out, *kd, *kh,
15991            *kw, *sd, *sh, *sw, *pd, *ph, *pw, *dd, *dh, *dw, *groups, base,
15992        );
15993    }
15994}
15995
15996fn exec_conv_transpose3d(t: &Thunk, base: *mut u8) {
15997    let Thunk::ConvTranspose3d {
15998        src,
15999        weight,
16000        dst,
16001        n,
16002        c_in,
16003        d,
16004        h,
16005        w_in,
16006        c_out,
16007        d_out,
16008        h_out,
16009        w_out,
16010        kd,
16011        kh,
16012        kw,
16013        sd,
16014        sh,
16015        sw,
16016        pd,
16017        ph,
16018        pw,
16019        dd,
16020        dh,
16021        dw,
16022        groups,
16023    } = t
16024    else {
16025        unreachable!()
16026    };
16027    unsafe {
16028        execute_conv_transpose3d_ncdhw_f32(
16029            *src, *weight, *dst, *n, *c_in, *d, *h, *w_in, *c_out, *d_out, *h_out, *w_out, *kd,
16030            *kh, *kw, *sd, *sh, *sw, *pd, *ph, *pw, *dd, *dh, *dw, *groups, base,
16031        );
16032    }
16033}
16034
16035#[inline(always)]
16036fn exec_resize_nearest2x(t: &Thunk, base: *mut u8) {
16037    let Thunk::ResizeNearest2x {
16038        src,
16039        dst,
16040        n,
16041        c,
16042        h,
16043        w,
16044    } = t
16045    else {
16046        unreachable!()
16047    };
16048    {
16049        let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
16050        let in_plane = c * h * w;
16051        let out_plane = c * h * 2 * w * 2;
16052        // `base` is a byte pointer; batch planes are f32 elements, so
16053        // advance by `plane * size_of::<f32>()`. (Was `base.add(ni *
16054        // plane)` — a 4× under-advance; only n=1 was ever tested.)
16055        let fsz = std::mem::size_of::<f32>();
16056        unsafe {
16057            for ni in 0..n {
16058                let input = sl(*src, base.add(ni * in_plane * fsz), in_plane);
16059                let output = sl_mut(*dst, base.add(ni * out_plane * fsz), out_plane);
16060                crate::kernels::resize_nearest_2x_nchw(input, output, c, h, w);
16061            }
16062        }
16063    }
16064}
16065
16066#[inline(always)]
16067fn exec_axial_rope2d(t: &Thunk, base: *mut u8) {
16068    let Thunk::AxialRope2d {
16069        src,
16070        dst,
16071        batch,
16072        seq,
16073        hidden,
16074        end_x,
16075        end_y,
16076        head_dim,
16077        num_heads,
16078        theta,
16079        repeat_factor,
16080    } = t
16081    else {
16082        unreachable!()
16083    };
16084    {
16085        let b = *batch as usize;
16086        let s = *seq as usize;
16087        let hdim = *head_dim as usize;
16088        let nh = *num_heads as usize;
16089        let plane = s * (*hidden as usize);
16090        // `base` is a byte pointer; advance per-batch by element-stride
16091        // bytes. (Was `base.add(bi * plane)` — 4× under-advance for
16092        // batch>1; matches the corrected `execute_axial_rope2d_f32`.)
16093        let plane_bytes = plane * std::mem::size_of::<f32>();
16094        unsafe {
16095            for bi in 0..b {
16096                let input = sl(*src, base.add(bi * plane_bytes), plane);
16097                let output = sl_mut(*dst, base.add(bi * plane_bytes), plane);
16098                let rotated = rlx_ir::ops::axial_rope2d::apply_axial_rope2d(
16099                    input,
16100                    nh,
16101                    s,
16102                    hdim,
16103                    *end_x as usize,
16104                    *end_y as usize,
16105                    *theta,
16106                    *repeat_factor as usize,
16107                );
16108                output.copy_from_slice(&rotated);
16109            }
16110        }
16111    }
16112}
16113
16114#[inline(always)]
16115fn exec_rms_norm(t: &Thunk, base: *mut u8) {
16116    let Thunk::RmsNorm {
16117        src,
16118        g,
16119        b,
16120        dst,
16121        rows,
16122        h,
16123        eps,
16124    } = t
16125    else {
16126        unreachable!()
16127    };
16128    {
16129        let (rows, h) = (*rows as usize, *h as usize);
16130        unsafe {
16131            let input = sl(*src, base, rows * h);
16132            let gamma = sl(*g, base, h);
16133            let beta = sl(*b, base, h);
16134            let output = sl_mut(*dst, base, rows * h);
16135            let inv_h = 1.0 / h as f32;
16136            for row in 0..rows {
16137                let in_row = &input[row * h..(row + 1) * h];
16138                let out_row = &mut output[row * h..(row + 1) * h];
16139                // RMS = sqrt(mean(x^2) + eps); scale = 1/RMS.
16140                let mut sumsq = 0f32;
16141                for &v in in_row {
16142                    sumsq += v * v;
16143                }
16144                let inv_rms = (sumsq * inv_h + *eps).sqrt().recip();
16145                for i in 0..h {
16146                    out_row[i] = in_row[i] * inv_rms * gamma[i] + beta[i];
16147                }
16148            }
16149        }
16150    }
16151}
16152
16153#[inline(always)]
16154fn exec_softmax(t: &Thunk, base: *mut u8) {
16155    let Thunk::Softmax { data, rows, cols } = t else {
16156        unreachable!()
16157    };
16158    {
16159        let (rows, cols) = (*rows as usize, *cols as usize);
16160        unsafe {
16161            crate::kernels::neon_softmax(sl_mut(*data, base, rows * cols), rows, cols);
16162        }
16163    }
16164}
16165
16166#[inline(always)]
16167fn exec_cumsum(t: &Thunk, base: *mut u8) {
16168    let Thunk::Cumsum {
16169        src,
16170        dst,
16171        rows,
16172        cols,
16173        exclusive,
16174    } = t
16175    else {
16176        unreachable!()
16177    };
16178    {
16179        let (rows, cols) = (*rows as usize, *cols as usize);
16180        unsafe {
16181            let s = sl(*src, base, rows * cols);
16182            let d = sl_mut(*dst, base, rows * cols);
16183            if *exclusive {
16184                for r in 0..rows {
16185                    let mut acc = 0.0f32;
16186                    for c in 0..cols {
16187                        d[r * cols + c] = acc;
16188                        acc += s[r * cols + c];
16189                    }
16190                }
16191            } else {
16192                for r in 0..rows {
16193                    let mut acc = 0.0f32;
16194                    for c in 0..cols {
16195                        acc += s[r * cols + c];
16196                        d[r * cols + c] = acc;
16197                    }
16198                }
16199            }
16200        }
16201    }
16202}
16203
16204#[inline(always)]
16205fn exec_sample(t: &Thunk, base: *mut u8) {
16206    let Thunk::Sample {
16207        logits,
16208        dst,
16209        batch,
16210        vocab,
16211        top_k,
16212        top_p,
16213        temperature,
16214        seed,
16215    } = t
16216    else {
16217        unreachable!()
16218    };
16219    unsafe {
16220        execute_sample_f32(
16221            *logits,
16222            *dst,
16223            *batch as usize,
16224            *vocab as usize,
16225            *top_k as usize,
16226            *top_p,
16227            *temperature,
16228            *seed,
16229            base,
16230        );
16231    }
16232}
16233
16234#[inline(always)]
16235fn exec_gated_delta_net(t: &Thunk, base: *mut u8) {
16236    let Thunk::GatedDeltaNet {
16237        q,
16238        k,
16239        v,
16240        g,
16241        beta,
16242        state,
16243        dst,
16244        batch,
16245        seq,
16246        heads,
16247        state_size,
16248    } = t
16249    else {
16250        unreachable!()
16251    };
16252    unsafe {
16253        execute_gated_delta_net_f32(
16254            *q,
16255            *k,
16256            *v,
16257            *g,
16258            *beta,
16259            *state,
16260            *dst,
16261            *batch as usize,
16262            *seq as usize,
16263            *heads as usize,
16264            *state_size as usize,
16265            base,
16266        );
16267    }
16268}
16269
16270#[inline(always)]
16271fn exec_lstm(t: &Thunk, base: *mut u8) {
16272    let Thunk::Lstm {
16273        x,
16274        w_ih,
16275        w_hh,
16276        bias,
16277        h0,
16278        c0,
16279        dst,
16280        batch,
16281        seq,
16282        input_size,
16283        hidden,
16284        num_layers,
16285        bidirectional,
16286        carry,
16287    } = t
16288    else {
16289        unreachable!()
16290    };
16291    unsafe {
16292        execute_lstm_f32(
16293            *x,
16294            *w_ih,
16295            *w_hh,
16296            *bias,
16297            *h0,
16298            *c0,
16299            *dst,
16300            *batch as usize,
16301            *seq as usize,
16302            *input_size as usize,
16303            *hidden as usize,
16304            *num_layers as usize,
16305            *bidirectional,
16306            *carry,
16307            base,
16308        );
16309    }
16310}
16311
16312#[inline(always)]
16313fn exec_gru(t: &Thunk, base: *mut u8) {
16314    let Thunk::Gru {
16315        x,
16316        w_ih,
16317        w_hh,
16318        b_ih,
16319        b_hh,
16320        h0,
16321        dst,
16322        batch,
16323        seq,
16324        input_size,
16325        hidden,
16326        num_layers,
16327        bidirectional,
16328        carry,
16329    } = t
16330    else {
16331        unreachable!()
16332    };
16333    unsafe {
16334        execute_gru_f32(
16335            *x,
16336            *w_ih,
16337            *w_hh,
16338            *b_ih,
16339            *b_hh,
16340            *h0,
16341            *dst,
16342            *batch as usize,
16343            *seq as usize,
16344            *input_size as usize,
16345            *hidden as usize,
16346            *num_layers as usize,
16347            *bidirectional,
16348            *carry,
16349            base,
16350        );
16351    }
16352}
16353
16354#[inline(always)]
16355fn exec_rnn(t: &Thunk, base: *mut u8) {
16356    let Thunk::Rnn {
16357        x,
16358        w_ih,
16359        w_hh,
16360        bias,
16361        h0,
16362        dst,
16363        batch,
16364        seq,
16365        input_size,
16366        hidden,
16367        num_layers,
16368        bidirectional,
16369        carry,
16370        relu,
16371    } = t
16372    else {
16373        unreachable!()
16374    };
16375    unsafe {
16376        execute_rnn_f32(
16377            *x,
16378            *w_ih,
16379            *w_hh,
16380            *bias,
16381            *h0,
16382            *dst,
16383            *batch as usize,
16384            *seq as usize,
16385            *input_size as usize,
16386            *hidden as usize,
16387            *num_layers as usize,
16388            *bidirectional,
16389            *carry,
16390            *relu,
16391            base,
16392        );
16393    }
16394}
16395
16396#[inline(always)]
16397fn exec_mamba2(t: &Thunk, base: *mut u8) {
16398    let Thunk::Mamba2 {
16399        x,
16400        dt,
16401        a,
16402        b,
16403        c,
16404        dst,
16405        batch,
16406        seq,
16407        heads,
16408        head_dim,
16409        state_size,
16410    } = t
16411    else {
16412        unreachable!()
16413    };
16414    unsafe {
16415        execute_mamba2_f32(
16416            *x,
16417            *dt,
16418            *a,
16419            *b,
16420            *c,
16421            *dst,
16422            *batch as usize,
16423            *seq as usize,
16424            *heads as usize,
16425            *head_dim as usize,
16426            *state_size as usize,
16427            base,
16428        );
16429    }
16430}
16431
16432#[inline(always)]
16433fn exec_selective_scan(t: &Thunk, base: *mut u8) {
16434    let Thunk::SelectiveScan {
16435        x,
16436        delta,
16437        a,
16438        b: bp,
16439        c: cp,
16440        dst,
16441        batch,
16442        seq,
16443        hidden,
16444        state_size,
16445    } = t
16446    else {
16447        unreachable!()
16448    };
16449    unsafe {
16450        execute_selective_scan_f32(
16451            *x,
16452            *delta,
16453            *a,
16454            *bp,
16455            *cp,
16456            *dst,
16457            *batch as usize,
16458            *seq as usize,
16459            *hidden as usize,
16460            *state_size as usize,
16461            base,
16462        );
16463    }
16464}
16465
16466#[inline(always)]
16467fn exec_dequant_mat_mul(t: &Thunk, base: *mut u8) {
16468    let Thunk::DequantMatMul {
16469        x,
16470        w_q,
16471        scale,
16472        zp,
16473        dst,
16474        m,
16475        k,
16476        n,
16477        block_size,
16478        is_asymmetric,
16479    } = t
16480    else {
16481        unreachable!()
16482    };
16483    {
16484        let (m, k, n, bs) = (*m as usize, *k as usize, *n as usize, *block_size as usize);
16485        let n_blocks = k.div_ceil(bs);
16486        unsafe {
16487            let xs = sl(*x, base, m * k);
16488            let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const i8, k * n);
16489            let scales = sl(*scale, base, n_blocks * n);
16490            let zps = if *is_asymmetric {
16491                sl(*zp, base, n_blocks * n)
16492            } else {
16493                &[][..]
16494            };
16495            let out = sl_mut(*dst, base, m * n);
16496            dequant_matmul_int8(xs, w_bytes, scales, zps, out, m, k, n, bs, *is_asymmetric);
16497        }
16498    }
16499}
16500
16501#[inline(always)]
16502fn exec_dequant_mat_mul_gguf(t: &Thunk, base: *mut u8) {
16503    let Thunk::DequantMatMulGguf {
16504        x,
16505        w_q,
16506        dst,
16507        m,
16508        k,
16509        n,
16510        scheme,
16511    } = t
16512    else {
16513        unreachable!()
16514    };
16515    {
16516        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
16517        let block_bytes = scheme.gguf_block_bytes() as usize;
16518        let block_elems = scheme.gguf_block_size() as usize;
16519        debug_assert!(
16520            block_bytes > 0 && block_elems > 0,
16521            "non-GGUF scheme in GGUF arm"
16522        );
16523        debug_assert!(
16524            (k * n).is_multiple_of(block_elems),
16525            "k*n={} not aligned to GGUF block size {}",
16526            k * n,
16527            block_elems
16528        );
16529        let total_bytes = (k * n) / block_elems * block_bytes;
16530        unsafe {
16531            let xs = sl(*x, base, m * k);
16532            let w_bytes_ptr = base.add(*w_q) as *const u8;
16533            let w_bytes = std::slice::from_raw_parts(w_bytes_ptr, total_bytes);
16534            let out = sl_mut(*dst, base, m * n);
16535            crate::gguf_matmul::gguf_matmul_bt_dispatch(xs, w_bytes, out, m, k, n, *scheme);
16536        }
16537    }
16538}
16539
16540#[inline(always)]
16541fn exec_dequant_mat_mul_int4(t: &Thunk, base: *mut u8) {
16542    let Thunk::DequantMatMulInt4 {
16543        x,
16544        w_q,
16545        scale,
16546        zp,
16547        dst,
16548        m,
16549        k,
16550        n,
16551        block_size,
16552        is_asymmetric,
16553    } = t
16554    else {
16555        unreachable!()
16556    };
16557    {
16558        let (m, k, n, bs) = (*m as usize, *k as usize, *n as usize, *block_size as usize);
16559        let n_blocks = k.div_ceil(bs);
16560        unsafe {
16561            let xs = sl(*x, base, m * k);
16562            let w_bytes =
16563                std::slice::from_raw_parts(base.add(*w_q) as *const u8, (k * n).div_ceil(2));
16564            let scales = sl(*scale, base, n_blocks * n);
16565            let zps = if *is_asymmetric {
16566                sl(*zp, base, n_blocks * n)
16567            } else {
16568                &[][..]
16569            };
16570            let out = sl_mut(*dst, base, m * n);
16571            dequant_matmul_int4(xs, w_bytes, scales, zps, out, m, k, n, bs, *is_asymmetric);
16572        }
16573    }
16574}
16575
16576#[inline(always)]
16577fn exec_dequant_mat_mul_fp8(t: &Thunk, base: *mut u8) {
16578    let Thunk::DequantMatMulFp8 {
16579        x,
16580        w_q,
16581        scale,
16582        dst,
16583        m,
16584        k,
16585        n,
16586        e5m2,
16587    } = t
16588    else {
16589        unreachable!()
16590    };
16591    {
16592        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
16593        unsafe {
16594            let xs = sl(*x, base, m * k);
16595            let w_bytes = std::slice::from_raw_parts(base.add(*w_q) as *const u8, k * n);
16596            let scales = sl(*scale, base, n);
16597            let out = sl_mut(*dst, base, m * n);
16598            dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, *e5m2);
16599        }
16600    }
16601}
16602
16603#[inline(always)]
16604fn exec_dequant_mat_mul_nvfp4(t: &Thunk, base: *mut u8) {
16605    let Thunk::DequantMatMulNvfp4 {
16606        x,
16607        w_q,
16608        scale,
16609        global_scale,
16610        dst,
16611        m,
16612        k,
16613        n,
16614    } = t
16615    else {
16616        unreachable!()
16617    };
16618    {
16619        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
16620        let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
16621        unsafe {
16622            let xs = sl(*x, base, m * k);
16623            let w_bytes =
16624                std::slice::from_raw_parts(base.add(*w_q) as *const u8, (k * n).div_ceil(2));
16625            let scale_bytes = std::slice::from_raw_parts(base.add(*scale) as *const u8, n_scale);
16626            let gs = sl(*global_scale, base, 1)[0];
16627            let out = sl_mut(*dst, base, m * n);
16628            dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
16629        }
16630    }
16631}
16632
16633#[inline(always)]
16634fn exec_scaled_mat_mul(t: &Thunk, base: *mut u8) {
16635    let Thunk::ScaledMatMul {
16636        lhs,
16637        rhs,
16638        lhs_scale,
16639        rhs_scale,
16640        bias,
16641        dst,
16642        m,
16643        k,
16644        n,
16645        lhs_fmt,
16646        rhs_fmt,
16647        layout,
16648        has_bias,
16649    } = t
16650    else {
16651        unreachable!()
16652    };
16653    {
16654        let (m, k, n) = (*m as usize, *k as usize, *n as usize);
16655        let layout = *layout;
16656        let nblk = lowp_nblk(k, layout);
16657        let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
16658        let n_lscale = if per_tensor { 1 } else { m * nblk };
16659        let n_rscale = if per_tensor { 1 } else { n * nblk };
16660        unsafe {
16661            let lhs_b = std::slice::from_raw_parts(base.add(*lhs) as *const u8, m * k);
16662            let rhs_b = std::slice::from_raw_parts(base.add(*rhs) as *const u8, n * k);
16663            let ls = lowp_read_scales(layout, base, *lhs_scale, n_lscale);
16664            let rs = lowp_read_scales(layout, base, *rhs_scale, n_rscale);
16665            let bias_s = if *has_bias {
16666                Some(sl(*bias, base, n))
16667            } else {
16668                None
16669            };
16670            let out = sl_mut(*dst, base, m * n);
16671            lowp_scaled_matmul(
16672                lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, *lhs_fmt, *rhs_fmt,
16673            );
16674        }
16675    }
16676}
16677
16678#[inline(always)]
16679fn exec_scaled_quantize(t: &Thunk, base: *mut u8) {
16680    let Thunk::ScaledQuantize {
16681        x,
16682        scale,
16683        dst,
16684        rows,
16685        cols,
16686        fmt,
16687        layout,
16688    } = t
16689    else {
16690        unreachable!()
16691    };
16692    {
16693        let (rows, cols) = (*rows as usize, *cols as usize);
16694        let layout = *layout;
16695        let nblk = lowp_nblk(cols, layout);
16696        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
16697            1
16698        } else {
16699            rows * nblk
16700        };
16701        unsafe {
16702            let xs = sl(*x, base, rows * cols);
16703            let scales = lowp_read_scales(layout, base, *scale, n_scale);
16704            let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * cols);
16705            lowp_quantize(xs, &scales, *fmt, layout, rows, cols, out);
16706        }
16707    }
16708}
16709
16710#[inline(always)]
16711fn exec_scaled_quant_scale(t: &Thunk, base: *mut u8) {
16712    let Thunk::ScaledQuantScale {
16713        x,
16714        dst,
16715        rows,
16716        cols,
16717        fmt,
16718        layout,
16719    } = t
16720    else {
16721        unreachable!()
16722    };
16723    {
16724        let (rows, cols) = (*rows as usize, *cols as usize);
16725        let layout = *layout;
16726        let nblk = lowp_nblk(cols, layout);
16727        unsafe {
16728            let xs = sl(*x, base, rows * cols);
16729            let scales = lowp_compute_scales(xs, *fmt, layout, rows, cols);
16730            match layout {
16731                rlx_ir::ScaleLayout::PerTensor => {
16732                    sl_mut(*dst, base, 1)[0] = scales[0];
16733                }
16734                rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
16735                    let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * nblk);
16736                    for (o, &s) in out.iter_mut().zip(&scales) {
16737                        *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
16738                    }
16739                }
16740                rlx_ir::ScaleLayout::Nvfp4 { .. } => {
16741                    let out = std::slice::from_raw_parts_mut(base.add(*dst), rows * nblk);
16742                    for (o, &s) in out.iter_mut().zip(&scales) {
16743                        *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
16744                    }
16745                }
16746            }
16747        }
16748    }
16749}
16750
16751#[inline(always)]
16752fn exec_scaled_dequantize(t: &Thunk, base: *mut u8) {
16753    let Thunk::ScaledDequantize {
16754        codes,
16755        scale,
16756        dst,
16757        rows,
16758        cols,
16759        fmt,
16760        layout,
16761    } = t
16762    else {
16763        unreachable!()
16764    };
16765    unsafe {
16766        execute_scaled_dequantize_f32(
16767            *codes,
16768            *scale,
16769            *dst,
16770            *rows as usize,
16771            *cols as usize,
16772            *fmt,
16773            *layout,
16774            base,
16775        );
16776    }
16777}
16778
16779#[inline(always)]
16780fn exec_lora_mat_mul(t: &Thunk, base: *mut u8) {
16781    let Thunk::LoraMatMul {
16782        x,
16783        w,
16784        a,
16785        b,
16786        dst,
16787        m,
16788        k,
16789        n,
16790        r,
16791        scale,
16792    } = t
16793    else {
16794        unreachable!()
16795    };
16796    {
16797        let (m, k, n, r) = (*m as usize, *k as usize, *n as usize, *r as usize);
16798        unsafe {
16799            let xs = sl(*x, base, m * k);
16800            let ws = sl(*w, base, k * n);
16801            let a_s = sl(*a, base, k * r);
16802            let bs = sl(*b, base, r * n);
16803            let out = sl_mut(*dst, base, m * n);
16804            crate::blas::sgemm(xs, ws, out, m, k, n);
16805            let mut tmp = vec![0f32; m * r];
16806            crate::blas::sgemm(xs, a_s, &mut tmp, m, k, r);
16807            if *scale != 1.0 {
16808                for v in tmp.iter_mut() {
16809                    *v *= *scale;
16810                }
16811            }
16812            crate::blas::sgemm_accumulate(&tmp, bs, out, m, r, n);
16813        }
16814    }
16815}
16816
16817#[inline(always)]
16818fn exec_attention_backward(t: &Thunk, base: *mut u8) {
16819    let Thunk::AttentionBackward {
16820        q,
16821        k,
16822        v,
16823        dy,
16824        mask,
16825        out,
16826        batch,
16827        seq,
16828        kv_seq,
16829        heads,
16830        head_dim,
16831        mask_kind,
16832        wrt,
16833        bhsd,
16834    } = t
16835    else {
16836        unreachable!()
16837    };
16838    {
16839        let (b, q_s, k_s, nh, dh) = (
16840            *batch as usize,
16841            *seq as usize,
16842            *kv_seq as usize,
16843            *heads as usize,
16844            *head_dim as usize,
16845        );
16846        unsafe {
16847            let q_len = if *bhsd {
16848                b * nh * q_s * dh
16849            } else {
16850                b * q_s * nh * dh
16851            };
16852            let k_len = if *bhsd {
16853                b * nh * k_s * dh
16854            } else {
16855                b * k_s * nh * dh
16856            };
16857            let out_len = match wrt {
16858                rlx_ir::op::AttentionBwdWrt::Key | rlx_ir::op::AttentionBwdWrt::Value => k_len,
16859                rlx_ir::op::AttentionBwdWrt::Query => q_len,
16860            };
16861            let q_data = sl(*q, base, q_len);
16862            let k_data = sl(*k, base, k_len);
16863            let v_data = sl(*v, base, k_len);
16864            let dy_data = sl(*dy, base, q_len);
16865            let out_data = sl_mut(*out, base, out_len);
16866            let mask_data: &[f32] = if *mask != 0 {
16867                let ml = match mask_kind {
16868                    rlx_ir::op::MaskKind::Custom => b * k_s,
16869                    rlx_ir::op::MaskKind::Bias => b * nh * q_s * k_s,
16870                    _ => 0,
16871                };
16872                sl(*mask, base, ml)
16873            } else {
16874                &[]
16875            };
16876            crate::attention_bwd::attention_backward(
16877                *wrt, q_data, k_data, v_data, dy_data, out_data, b, nh, q_s, k_s, dh, *mask_kind,
16878                mask_data, *bhsd,
16879            );
16880        }
16881    }
16882}
16883
16884#[inline(always)]
16885fn exec_activation_in_place(t: &Thunk, base: *mut u8) {
16886    let Thunk::ActivationInPlace { data, len, act } = t else {
16887        unreachable!()
16888    };
16889    {
16890        let len = *len as usize;
16891        unsafe {
16892            let d = sl_mut(*data, base, len);
16893            match act {
16894                Activation::Gelu => crate::kernels::par_gelu_inplace(d),
16895                Activation::GeluApprox => crate::kernels::par_gelu_approx_inplace(d),
16896                Activation::Silu => crate::kernels::par_silu_inplace(d),
16897                Activation::Relu => {
16898                    for v in d.iter_mut() {
16899                        *v = v.max(0.0);
16900                    }
16901                }
16902                Activation::Sigmoid => {
16903                    for v in d.iter_mut() {
16904                        *v = 1.0 / (1.0 + (-*v).exp());
16905                    }
16906                }
16907                Activation::Tanh => {
16908                    for v in d.iter_mut() {
16909                        *v = v.tanh();
16910                    }
16911                }
16912                Activation::Exp => {
16913                    for v in d.iter_mut() {
16914                        *v = v.exp();
16915                    }
16916                }
16917                Activation::Log => {
16918                    for v in d.iter_mut() {
16919                        *v = v.ln();
16920                    }
16921                }
16922                Activation::Sqrt => {
16923                    for v in d.iter_mut() {
16924                        *v = v.sqrt();
16925                    }
16926                }
16927                Activation::Rsqrt => {
16928                    for v in d.iter_mut() {
16929                        *v = 1.0 / v.sqrt();
16930                    }
16931                }
16932                Activation::Neg => {
16933                    for v in d.iter_mut() {
16934                        *v = -*v;
16935                    }
16936                }
16937                Activation::Abs => {
16938                    for v in d.iter_mut() {
16939                        *v = v.abs();
16940                    }
16941                }
16942                Activation::Round => {
16943                    for v in d.iter_mut() {
16944                        *v = v.round();
16945                    }
16946                }
16947                Activation::Sin => {
16948                    for v in d.iter_mut() {
16949                        *v = v.sin();
16950                    }
16951                }
16952                Activation::Cos => {
16953                    for v in d.iter_mut() {
16954                        *v = v.cos();
16955                    }
16956                }
16957                Activation::Tan => {
16958                    for v in d.iter_mut() {
16959                        *v = v.tan();
16960                    }
16961                }
16962                Activation::Atan => {
16963                    for v in d.iter_mut() {
16964                        *v = v.atan();
16965                    }
16966                }
16967            }
16968        }
16969    }
16970}
16971
16972#[inline(always)]
16973fn exec_rope(t: &Thunk, base: *mut u8) {
16974    let Thunk::Rope {
16975        src,
16976        cos,
16977        sin,
16978        dst,
16979        batch,
16980        seq,
16981        hidden,
16982        head_dim,
16983        n_rot,
16984        cos_len,
16985        src_row_stride,
16986        interleaved,
16987    } = t
16988    else {
16989        unreachable!()
16990    };
16991    {
16992        let interleaved = *interleaved;
16993        let (b, s, hs, dh, nr) = (
16994            *batch as usize,
16995            *seq as usize,
16996            *hidden as usize,
16997            *head_dim as usize,
16998            *n_rot as usize,
16999        );
17000        let tab_half = dh / 2;
17001        let rot_half = nr / 2;
17002        let nh = hs / dh;
17003        let cl = *cos_len as usize;
17004        let src_rs = *src_row_stride as usize;
17005        // Number of rows in the RoPE table. A per-(batch·seq) table
17006        // (`cos_rows == b*s`, distinct from the shared per-seq table) is
17007        // indexed by the *global* token so ragged batched decode can
17008        // give each sequence its own absolute position.
17009        let cos_rows = cl / tab_half.max(1);
17010        let per_token = cos_rows == b * s && cos_rows != s;
17011        unsafe {
17012            let x = sl(*src, base, b * s * src_rs);
17013            let cos_tab = sl(*cos, base, cl);
17014            let sin_tab = sl(*sin, base, cl);
17015            let out = sl_mut(*dst, base, b * s * hs);
17016
17017            let total = b * s;
17018            let x_ptr = x.as_ptr() as usize;
17019            let o_ptr = out.as_mut_ptr() as usize;
17020            let c_ptr = cos_tab.as_ptr() as usize;
17021            let s_ptr = sin_tab.as_ptr() as usize;
17022
17023            crate::pool::par_for(total, 4, &|off, cnt| {
17024                for idx in off..off + cnt {
17025                    let bi = idx / s;
17026                    let si = idx % s;
17027                    let tab_off = if per_token { idx } else { si } * tab_half;
17028
17029                    for hi in 0..nh {
17030                        let src_base = bi * s * src_rs + si * src_rs + hi * dh;
17031                        let dst_base = bi * s * hs + si * hs + hi * dh;
17032                        let xp = (x_ptr as *const f32).add(src_base);
17033                        let op = (o_ptr as *mut f32).add(dst_base);
17034                        let cp = (c_ptr as *const f32).add(tab_off);
17035                        let sp = (s_ptr as *const f32).add(tab_off);
17036
17037                        if interleaved {
17038                            // GPT-J / llama.cpp-NORM: rotate adjacent
17039                            // pairs (2i, 2i+1) by angle i.
17040                            for i in 0..rot_half {
17041                                let x1 = *xp.add(2 * i);
17042                                let x2 = *xp.add(2 * i + 1);
17043                                let cv = *cp.add(i);
17044                                let sv = *sp.add(i);
17045                                *op.add(2 * i) = x1 * cv - x2 * sv;
17046                                *op.add(2 * i + 1) = x2 * cv + x1 * sv;
17047                            }
17048                        } else {
17049                            // HF / NeoX rotate-half: pair (i, i+rot_half).
17050                            for i in 0..rot_half {
17051                                let x1 = *xp.add(i);
17052                                let x2 = *xp.add(rot_half + i);
17053                                let cv = *cp.add(i);
17054                                let sv = *sp.add(i);
17055                                *op.add(i) = x1 * cv - x2 * sv;
17056                                *op.add(rot_half + i) = x2 * cv + x1 * sv;
17057                            }
17058                        }
17059                        for j in nr..dh {
17060                            *op.add(j) = *xp.add(j);
17061                        }
17062                    }
17063                }
17064            });
17065        }
17066    }
17067}
17068
17069#[inline(always)]
17070fn exec_fused_swi_g_l_u(t: &Thunk, base: *mut u8) {
17071    let Thunk::FusedSwiGLU {
17072        src,
17073        dst,
17074        n_half,
17075        total,
17076        gate_first,
17077    } = t
17078    else {
17079        unreachable!()
17080    };
17081    {
17082        let n = *n_half as usize;
17083        let t = *total as usize;
17084        let outer = t / n;
17085        let in_total = outer * 2 * n;
17086        let gate_first = *gate_first;
17087        unsafe {
17088            let inp = sl(*src, base, in_total);
17089            let out = sl_mut(*dst, base, t);
17090            for o in 0..outer {
17091                let in_row = &inp[o * 2 * n..(o + 1) * 2 * n];
17092                let out_row = &mut out[o * n..(o + 1) * n];
17093                for i in 0..n {
17094                    let (up, gate) = if gate_first {
17095                        (in_row[n + i], in_row[i])
17096                    } else {
17097                        (in_row[i], in_row[n + i])
17098                    };
17099                    out_row[i] = up * (gate / (1.0 + (-gate).exp()));
17100                }
17101            }
17102        }
17103    }
17104}
17105
17106#[inline(always)]
17107fn exec_concat(t: &Thunk, base: *mut u8) {
17108    let Thunk::Concat {
17109        dst,
17110        outer,
17111        inner,
17112        total_axis,
17113        inputs,
17114    } = t
17115    else {
17116        unreachable!()
17117    };
17118    {
17119        let outer = *outer as usize;
17120        let inner = *inner as usize;
17121        let total_axis = *total_axis as usize;
17122        let row_stride = total_axis * inner;
17123        let out_total = outer * row_stride;
17124        unsafe {
17125            let out = sl_mut(*dst, base, out_total);
17126            let mut cum: usize = 0;
17127            for (src_off, in_axis, in_numel) in inputs {
17128                let in_axis = *in_axis as usize;
17129                let copy_per_row = in_axis * inner;
17130                let dst_col_off = cum * inner;
17131                let inp = sl(*src_off, base, (*in_numel as usize).max(1));
17132                concat_copy_rows_f32(
17133                    out,
17134                    inp,
17135                    outer,
17136                    copy_per_row,
17137                    row_stride,
17138                    dst_col_off,
17139                    *in_numel as usize,
17140                );
17141                cum += in_axis;
17142            }
17143        }
17144    }
17145}
17146
17147#[inline(always)]
17148fn exec_concat_f64(t: &Thunk, base: *mut u8) {
17149    let Thunk::ConcatF64 {
17150        dst,
17151        outer,
17152        inner,
17153        total_axis,
17154        inputs,
17155    } = t
17156    else {
17157        unreachable!()
17158    };
17159    {
17160        let outer = *outer as usize;
17161        let inner = *inner as usize;
17162        let total_axis = *total_axis as usize;
17163        let row_stride = total_axis * inner;
17164        let out_total = outer * row_stride;
17165        unsafe {
17166            let out = sl_mut_f64(*dst, base, out_total);
17167            let mut cum: usize = 0;
17168            for (src_off, in_axis, in_numel) in inputs {
17169                let in_axis = *in_axis as usize;
17170                let copy_per_row = in_axis * inner;
17171                let dst_col_off = cum * inner;
17172                let inp = sl_f64(*src_off, base, (*in_numel as usize).max(1));
17173                concat_copy_rows_f64(
17174                    out,
17175                    inp,
17176                    outer,
17177                    copy_per_row,
17178                    row_stride,
17179                    dst_col_off,
17180                    *in_numel as usize,
17181                );
17182                cum += in_axis;
17183            }
17184        }
17185    }
17186}
17187
17188#[inline(always)]
17189fn exec_scatter_add(t: &Thunk, base: *mut u8) {
17190    let Thunk::ScatterAdd {
17191        updates,
17192        indices,
17193        dst,
17194        num_updates,
17195        out_dim,
17196        trailing,
17197    } = t
17198    else {
17199        unreachable!()
17200    };
17201    {
17202        let num_updates = *num_updates as usize;
17203        let out_dim = *out_dim as usize;
17204        let trailing = *trailing as usize;
17205        unsafe {
17206            let upd = sl(*updates, base, num_updates * trailing);
17207            let ids = sl(*indices, base, num_updates);
17208            let out = sl_mut(*dst, base, out_dim * trailing);
17209            // Zero the output first — semantics are accumulate-into-zeros.
17210            for v in out.iter_mut() {
17211                *v = 0.0;
17212            }
17213            for i in 0..num_updates {
17214                let row = ids[i] as usize;
17215                debug_assert!(row < out_dim, "ScatterAdd index out of range");
17216                let src_off = i * trailing;
17217                let dst_off = row * trailing;
17218                for j in 0..trailing {
17219                    out[dst_off + j] += upd[src_off + j];
17220                }
17221            }
17222        }
17223    }
17224}
17225
17226#[inline(always)]
17227fn exec_dequant_grouped_mat_mul_gguf(t: &Thunk, base: *mut u8) {
17228    let Thunk::DequantGroupedMatMulGguf {
17229        input,
17230        w_q,
17231        expert_idx,
17232        dst,
17233        m,
17234        k_dim,
17235        n,
17236        num_experts,
17237        scheme,
17238    } = t
17239    else {
17240        unreachable!()
17241    };
17242    {
17243        let m = *m as usize;
17244        let k_dim = *k_dim as usize;
17245        let n = *n as usize;
17246        let num_experts = *num_experts as usize;
17247        let block_elems = scheme.gguf_block_size() as usize;
17248        let block_bytes = scheme.gguf_block_bytes() as usize;
17249        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
17250        unsafe {
17251            let inp = sl(*input, base, m * k_dim);
17252            let wt =
17253                std::slice::from_raw_parts(base.add(*w_q) as *const u8, num_experts * slab_bytes);
17254            let ids = sl(*expert_idx, base, m);
17255            let out = sl_mut(*dst, base, m * n);
17256            crate::gguf_matmul::gguf_grouped_matmul_bt(
17257                inp,
17258                wt,
17259                ids,
17260                out,
17261                m,
17262                k_dim,
17263                n,
17264                num_experts,
17265                *scheme,
17266            );
17267        }
17268    }
17269}
17270
17271#[inline(always)]
17272fn exec_dequant_mo_e_weights_gguf(t: &Thunk, base: *mut u8) {
17273    let Thunk::DequantMoEWeightsGguf {
17274        w_q,
17275        dst,
17276        k_dim,
17277        n,
17278        num_experts,
17279        scheme,
17280    } = t
17281    else {
17282        unreachable!()
17283    };
17284    {
17285        let k_dim = *k_dim as usize;
17286        let n = *n as usize;
17287        let num_experts = *num_experts as usize;
17288        let block_elems = scheme.gguf_block_size() as usize;
17289        let block_bytes = scheme.gguf_block_bytes() as usize;
17290        let slab_bytes = (k_dim * n) / block_elems * block_bytes;
17291        unsafe {
17292            let wt =
17293                std::slice::from_raw_parts(base.add(*w_q) as *const u8, num_experts * slab_bytes);
17294            let out = sl_mut(*dst, base, num_experts * k_dim * n);
17295            crate::gguf_matmul::dequant_moe_weights_to_grouped_f32(
17296                wt,
17297                out,
17298                num_experts,
17299                k_dim,
17300                n,
17301                *scheme,
17302            );
17303        }
17304    }
17305}
17306
17307#[inline(always)]
17308fn exec_reduce(t: &Thunk, base: *mut u8) {
17309    let Thunk::Reduce {
17310        src,
17311        dst,
17312        outer,
17313        reduced,
17314        inner,
17315        op,
17316    } = t
17317    else {
17318        unreachable!()
17319    };
17320    {
17321        let outer = *outer as usize;
17322        let reduced = *reduced as usize;
17323        let inner = *inner as usize;
17324        let in_total = outer * reduced * inner;
17325        let out_total = outer * inner;
17326        unsafe {
17327            let inp = sl(*src, base, in_total);
17328            let out = sl_mut(*dst, base, out_total);
17329            // Each output element reduces a disjoint strided strip, so
17330            // the output range parallelizes (the bias-gradient reductions
17331            // read the big [N,C,H,W] tensors down to [C]; that's C-way).
17332            let reduce_one = |oi: usize| -> f32 {
17333                let o = oi / inner;
17334                let i = oi % inner;
17335                let mut acc = match op {
17336                    ReduceOp::Max => f32::NEG_INFINITY,
17337                    ReduceOp::Min => f32::INFINITY,
17338                    ReduceOp::Prod => 1.0f32,
17339                    _ => 0.0f32, // Sum / Mean
17340                };
17341                for r in 0..reduced {
17342                    let v = inp[o * reduced * inner + r * inner + i];
17343                    acc = match op {
17344                        ReduceOp::Sum | ReduceOp::Mean => acc + v,
17345                        ReduceOp::Max => acc.max(v),
17346                        ReduceOp::Min => acc.min(v),
17347                        ReduceOp::Prod => acc * v,
17348                    };
17349                }
17350                if matches!(op, ReduceOp::Mean) {
17351                    acc /= reduced as f32;
17352                }
17353                acc
17354            };
17355            if fast_conv_enabled() && crate::pool::should_parallelize(in_total) && out_total > 1 {
17356                let out_addr = out.as_mut_ptr() as usize;
17357                crate::pool::par_for(
17358                    out_total,
17359                    crate::pool::outer_chunk(out_total),
17360                    &|off, cnt| {
17361                        for oi in off..off + cnt {
17362                            *((out_addr as *mut f32).add(oi)) = reduce_one(oi);
17363                        }
17364                    },
17365                );
17366            } else {
17367                for oi in 0..out_total {
17368                    out[oi] = reduce_one(oi);
17369                }
17370            }
17371        }
17372    }
17373}
17374
17375#[inline(always)]
17376fn exec_arg_reduce(t: &Thunk, base: *mut u8) {
17377    let Thunk::ArgReduce {
17378        src,
17379        dst,
17380        outer,
17381        reduced,
17382        inner,
17383        is_max,
17384    } = t
17385    else {
17386        unreachable!()
17387    };
17388    {
17389        let outer = *outer as usize;
17390        let reduced = *reduced as usize;
17391        let inner = *inner as usize;
17392        let in_total = outer * reduced * inner;
17393        let out_total = outer * inner;
17394        unsafe {
17395            let inp = sl(*src, base, in_total);
17396            let out = sl_mut(*dst, base, out_total);
17397            for o in 0..outer {
17398                for i in 0..inner {
17399                    let mut best = inp[o * reduced * inner + i];
17400                    let mut best_idx = 0usize;
17401                    for r in 1..reduced {
17402                        let v = inp[o * reduced * inner + r * inner + i];
17403                        let better = if *is_max { v > best } else { v < best };
17404                        if better {
17405                            best = v;
17406                            best_idx = r;
17407                        }
17408                    }
17409                    out[o * inner + i] = best_idx as f32;
17410                }
17411            }
17412        }
17413    }
17414}
17415
17416#[inline(always)]
17417fn exec_conv2_d1x1(t: &Thunk, base: *mut u8) {
17418    let Thunk::Conv2D1x1 {
17419        src,
17420        weight,
17421        dst,
17422        n,
17423        c_in,
17424        c_out,
17425        hw,
17426    } = t
17427    else {
17428        unreachable!()
17429    };
17430    {
17431        let n = *n as usize;
17432        let c_in = *c_in as usize;
17433        let c_out = *c_out as usize;
17434        let hw = *hw as usize;
17435        unsafe {
17436            let inp = sl(*src, base, n * c_in * hw);
17437            let wt = sl(*weight, base, c_out * c_in);
17438            let out = sl_mut(*dst, base, n * c_out * hw);
17439            // Per-batch sgemm: weight [c_out, c_in] @ input
17440            // [c_in, hw] = output [c_out, hw]. The weight is
17441            // shared across batches, so we get to dispatch
17442            // BLAS once per N (typically 1).
17443            for ni in 0..n {
17444                let in_off = ni * c_in * hw;
17445                let out_off = ni * c_out * hw;
17446                crate::blas::sgemm(
17447                    wt,
17448                    &inp[in_off..in_off + c_in * hw],
17449                    &mut out[out_off..out_off + c_out * hw],
17450                    c_out,
17451                    c_in,
17452                    hw,
17453                );
17454            }
17455        }
17456    }
17457}
17458
17459#[inline(always)]
17460fn exec_conv2_d(t: &Thunk, base: *mut u8) {
17461    let Thunk::Conv2D {
17462        src,
17463        weight,
17464        dst,
17465        n,
17466        c_in,
17467        h,
17468        w,
17469        c_out,
17470        h_out,
17471        w_out,
17472        kh,
17473        kw,
17474        sh,
17475        sw,
17476        ph,
17477        pw,
17478        dh,
17479        dw,
17480        groups,
17481    } = t
17482    else {
17483        unreachable!()
17484    };
17485    {
17486        let n = *n as usize;
17487        let c_in = *c_in as usize;
17488        let h = *h as usize;
17489        let w = *w as usize;
17490        let c_out = *c_out as usize;
17491        let h_out = *h_out as usize;
17492        let w_out = *w_out as usize;
17493        let kh = *kh as usize;
17494        let kw = *kw as usize;
17495        let sh = *sh as usize;
17496        let sw = *sw as usize;
17497        let ph = *ph as usize;
17498        let pw = *pw as usize;
17499        let dh = *dh as usize;
17500        let dw = *dw as usize;
17501        let groups = *groups as usize;
17502        let c_in_per_g = c_in / groups;
17503        unsafe {
17504            let inp = sl(*src, base, n * c_in * h * w);
17505            let wt = sl(*weight, base, c_out * c_in_per_g * kh * kw);
17506            let out = sl_mut(*dst, base, n * c_out * h_out * w_out);
17507            // Forward conv has two interchangeable kernels:
17508            //   * a reference scalar nested loop (default), and
17509            //   * im2col + BLAS sgemm, enabled with RLX_FAST_CONV=1.
17510            // The fast path mirrors Conv2D1x1 / Conv2dBackwardWeight:
17511            // gather patches per (batch, group) then dispatch one
17512            // sgemm. Results match up to float reassociation.
17513            // Eligibility: stride-1, no padding, dilation-1 — the direct
17514            // kernel's no-bounds SAXPY form (and Winograd's tiling).
17515            let s1_nopad = sh == 1 && sw == 1 && ph == 0 && pw == 0 && dh == 1 && dw == 1;
17516            let winograd_ok = s1_nopad && kh == 3 && kw == 3 && groups == 1;
17517            // im2col+BLAS is the measured CPU optimum for these shapes;
17518            // Winograd (RLX_WINOGRAD) and the direct kernel
17519            // (RLX_DIRECT_CONV) are opt-in alternatives that win only at
17520            // higher channel counts — both measured slower on TinyConv.
17521            if fast_conv_enabled() && winograd_enabled() && winograd_ok {
17522                conv2d_forward_winograd(inp, wt, out, n, c_in, h, w, c_out, h_out, w_out);
17523            } else if fast_conv_enabled() && direct_conv_enabled() && s1_nopad {
17524                conv2d_forward_direct(
17525                    inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, groups,
17526                );
17527            } else if fast_conv_enabled() {
17528                conv2d_forward_im2col(
17529                    inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, sh, sw, ph, pw, dh,
17530                    dw, groups,
17531                );
17532            } else {
17533                conv2d_forward_naive(
17534                    inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, sh, sw, ph, pw, dh,
17535                    dw, groups,
17536                );
17537            }
17538        }
17539    }
17540}
17541
17542#[inline(always)]
17543fn exec_relu_backward(t: &Thunk, base: *mut u8) {
17544    let Thunk::ReluBackward { x, dy, dx, len } = t else {
17545        unreachable!()
17546    };
17547    {
17548        let len = *len as usize;
17549        unsafe {
17550            let xs = sl(*x, base, len);
17551            let dys = sl(*dy, base, len);
17552            let out = sl_mut(*dx, base, len);
17553            if fast_conv_enabled() && crate::pool::should_parallelize(len) {
17554                let oa = out.as_mut_ptr() as usize;
17555                crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
17556                    for i in off..off + cnt {
17557                        *((oa as *mut f32).add(i)) = if xs[i] > 0.0 { dys[i] } else { 0.0 };
17558                    }
17559                });
17560            } else {
17561                for i in 0..len {
17562                    out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
17563                }
17564            }
17565        }
17566    }
17567}
17568
17569#[inline(always)]
17570fn exec_relu_backward_f64(t: &Thunk, base: *mut u8) {
17571    let Thunk::ReluBackwardF64 { x, dy, dx, len } = t else {
17572        unreachable!()
17573    };
17574    {
17575        let len = *len as usize;
17576        unsafe {
17577            let xs = sl_f64(*x, base, len);
17578            let dys = sl_f64(*dy, base, len);
17579            let out = sl_mut_f64(*dx, base, len);
17580            for i in 0..len {
17581                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
17582            }
17583        }
17584    }
17585}
17586
17587#[inline(always)]
17588fn exec_q_mat_mul(t: &Thunk, base: *mut u8) {
17589    let Thunk::QMatMul {
17590        x,
17591        w,
17592        bias,
17593        out,
17594        m,
17595        k,
17596        n,
17597        x_zp,
17598        w_zp,
17599        out_zp,
17600        mult,
17601    } = t
17602    else {
17603        unreachable!()
17604    };
17605    {
17606        let m = *m as usize;
17607        let k = *k as usize;
17608        let n = *n as usize;
17609        unsafe {
17610            let x_ptr = base.add(*x) as *const i8;
17611            let w_ptr = base.add(*w) as *const i8;
17612            let bias_ptr = base.add(*bias) as *const i32;
17613            let out_ptr = base.add(*out) as *mut i8;
17614            for mi in 0..m {
17615                for ni in 0..n {
17616                    let mut acc: i32 = *bias_ptr.add(ni);
17617                    for ki in 0..k {
17618                        let xv = *x_ptr.add(mi * k + ki) as i32 - *x_zp;
17619                        let wv = *w_ptr.add(ki * n + ni) as i32 - *w_zp;
17620                        acc += xv * wv;
17621                    }
17622                    // Requantize: round(acc · mult) + out_zp,
17623                    // clamped to i8.
17624                    let r = (acc as f32 * *mult).round() as i32 + *out_zp;
17625                    let r = r.clamp(-128, 127) as i8;
17626                    *out_ptr.add(mi * n + ni) = r;
17627                }
17628            }
17629        }
17630    }
17631}
17632
17633#[inline(always)]
17634fn exec_quantize(t: &Thunk, base: *mut u8) {
17635    let Thunk::Quantize {
17636        x,
17637        q,
17638        len,
17639        chan_axis: _,
17640        chan_dim,
17641        inner,
17642        scales,
17643        zero_points,
17644    } = t
17645    else {
17646        unreachable!()
17647    };
17648    {
17649        let len = *len as usize;
17650        let chan_dim = *chan_dim as usize;
17651        let inner = *inner as usize;
17652        unsafe {
17653            let xs = sl(*x, base, len);
17654            let q_ptr = base.add(*q) as *mut i8;
17655            for i in 0..len {
17656                let c = if chan_dim == 1 {
17657                    0
17658                } else {
17659                    (i / inner) % chan_dim
17660                };
17661                let inv_scale = 1.0 / scales[c];
17662                let zp = zero_points[c];
17663                let v = (xs[i] * inv_scale).round() as i32 + zp;
17664                *q_ptr.add(i) = v.clamp(-128, 127) as i8;
17665            }
17666        }
17667    }
17668}
17669
17670#[inline(always)]
17671fn exec_dequantize(t: &Thunk, base: *mut u8) {
17672    let Thunk::Dequantize {
17673        q,
17674        x,
17675        len,
17676        chan_axis: _,
17677        chan_dim,
17678        inner,
17679        scales,
17680        zero_points,
17681    } = t
17682    else {
17683        unreachable!()
17684    };
17685    {
17686        let len = *len as usize;
17687        let chan_dim = *chan_dim as usize;
17688        let inner = *inner as usize;
17689        unsafe {
17690            let q_ptr = base.add(*q) as *const i8;
17691            let out = sl_mut(*x, base, len);
17692            for i in 0..len {
17693                let c = if chan_dim == 1 {
17694                    0
17695                } else {
17696                    (i / inner) % chan_dim
17697                };
17698                let scale = scales[c];
17699                let zp = zero_points[c];
17700                let qv = *q_ptr.add(i) as i32;
17701                out[i] = (qv - zp) as f32 * scale;
17702            }
17703        }
17704    }
17705}
17706
17707#[inline(always)]
17708fn exec_fake_quantize(t: &Thunk, base: *mut u8) {
17709    let Thunk::FakeQuantize {
17710        x,
17711        out,
17712        len,
17713        chan_axis: _,
17714        chan_dim,
17715        inner,
17716        bits,
17717        ste: _,
17718        scale_mode,
17719        state_off,
17720    } = t
17721    else {
17722        unreachable!()
17723    };
17724    {
17725        use rlx_ir::op::ScaleMode;
17726        let len = *len as usize;
17727        let chan_dim = *chan_dim as usize;
17728        let inner = *inner as usize;
17729        let q_max: f32 = match *bits {
17730            8 => 127.0,
17731            4 => 7.0,
17732            2 => 1.0,
17733            n => panic!("FakeQuantize: unsupported bits {n}"),
17734        };
17735        unsafe {
17736            let xs = sl(*x, base, len);
17737            let outs = sl_mut(*out, base, len);
17738
17739            let mut scale = vec![0f32; chan_dim];
17740            match scale_mode {
17741                ScaleMode::PerBatch => {
17742                    let mut max_abs = vec![0f32; chan_dim];
17743                    for i in 0..len {
17744                        let c = if chan_dim == 1 {
17745                            0
17746                        } else {
17747                            (i / inner) % chan_dim
17748                        };
17749                        let a = xs[i].abs();
17750                        if a > max_abs[c] {
17751                            max_abs[c] = a;
17752                        }
17753                    }
17754                    for c in 0..chan_dim {
17755                        scale[c] = (max_abs[c] / q_max).max(1e-12);
17756                    }
17757                }
17758                ScaleMode::EMA { decay } => {
17759                    // Per-channel current max-abs, then blend
17760                    // into the running state in place.
17761                    let mut max_abs = vec![0f32; chan_dim];
17762                    for i in 0..len {
17763                        let c = if chan_dim == 1 {
17764                            0
17765                        } else {
17766                            (i / inner) % chan_dim
17767                        };
17768                        let a = xs[i].abs();
17769                        if a > max_abs[c] {
17770                            max_abs[c] = a;
17771                        }
17772                    }
17773                    let state = sl_mut(state_off.expect("EMA needs state_off"), base, chan_dim);
17774                    for c in 0..chan_dim {
17775                        let cur = (max_abs[c] / q_max).max(1e-12);
17776                        // Cold-start: state==0 → seed directly.
17777                        let blended = if state[c] <= 0.0 {
17778                            cur
17779                        } else {
17780                            *decay * state[c] + (1.0 - *decay) * cur
17781                        };
17782                        state[c] = blended;
17783                        scale[c] = blended;
17784                    }
17785                }
17786                ScaleMode::Fixed => {
17787                    let state = sl(state_off.expect("Fixed needs state_off"), base, chan_dim);
17788                    for c in 0..chan_dim {
17789                        scale[c] = state[c].max(1e-12);
17790                    }
17791                }
17792            }
17793
17794            for i in 0..len {
17795                let c = if chan_dim == 1 {
17796                    0
17797                } else {
17798                    (i / inner) % chan_dim
17799                };
17800                let s = scale[c];
17801                let qv = (xs[i] / s).round().clamp(-q_max, q_max);
17802                outs[i] = qv * s;
17803            }
17804        }
17805    }
17806}
17807
17808#[inline(always)]
17809fn exec_activation_backward(t: &Thunk, base: *mut u8) {
17810    let Thunk::ActivationBackward {
17811        x,
17812        dy,
17813        dx,
17814        len,
17815        kind,
17816    } = t
17817    else {
17818        unreachable!()
17819    };
17820    {
17821        let len = *len as usize;
17822        unsafe {
17823            let xs = sl(*x, base, len);
17824            let dys = sl(*dy, base, len);
17825            let out = sl_mut(*dx, base, len);
17826            activation_backward_kernel(*kind, xs, dys, out);
17827        }
17828    }
17829}
17830
17831#[inline(always)]
17832fn exec_activation_backward_f64(t: &Thunk, base: *mut u8) {
17833    let Thunk::ActivationBackwardF64 {
17834        x,
17835        dy,
17836        dx,
17837        len,
17838        kind,
17839    } = t
17840    else {
17841        unreachable!()
17842    };
17843    {
17844        let len = *len as usize;
17845        unsafe {
17846            let xs = sl_f64(*x, base, len);
17847            let dys = sl_f64(*dy, base, len);
17848            let out = sl_mut_f64(*dx, base, len);
17849            activation_backward_kernel_f64(*kind, xs, dys, out);
17850        }
17851    }
17852}
17853
17854#[inline(always)]
17855fn exec_fake_quantize_l_s_q(t: &Thunk, base: *mut u8) {
17856    let Thunk::FakeQuantizeLSQ {
17857        x,
17858        scale_off,
17859        out,
17860        len,
17861        chan_axis: _,
17862        chan_dim,
17863        inner,
17864        bits,
17865    } = t
17866    else {
17867        unreachable!()
17868    };
17869    {
17870        let len = *len as usize;
17871        let chan_dim = *chan_dim as usize;
17872        let inner = *inner as usize;
17873        let q_max: f32 = match *bits {
17874            8 => 127.0,
17875            4 => 7.0,
17876            2 => 1.0,
17877            n => panic!("FakeQuantizeLSQ: bad bits {n}"),
17878        };
17879        unsafe {
17880            let xs = sl(*x, base, len);
17881            let scale = sl(*scale_off, base, chan_dim);
17882            let outs = sl_mut(*out, base, len);
17883            for i in 0..len {
17884                let c = if chan_dim == 1 {
17885                    0
17886                } else {
17887                    (i / inner) % chan_dim
17888                };
17889                let s = scale[c].max(1e-12);
17890                let qv = (xs[i] / s).round().clamp(-q_max, q_max);
17891                outs[i] = qv * s;
17892            }
17893        }
17894    }
17895}
17896
17897#[inline(always)]
17898fn exec_fake_quantize_l_s_q_backward_x(t: &Thunk, base: *mut u8) {
17899    let Thunk::FakeQuantizeLSQBackwardX {
17900        x,
17901        scale_off,
17902        dy,
17903        dx,
17904        len,
17905        chan_axis: _,
17906        chan_dim,
17907        inner,
17908        bits,
17909    } = t
17910    else {
17911        unreachable!()
17912    };
17913    {
17914        let len = *len as usize;
17915        let chan_dim = *chan_dim as usize;
17916        let inner = *inner as usize;
17917        let q_max: f32 = match *bits {
17918            8 => 127.0,
17919            4 => 7.0,
17920            2 => 1.0,
17921            n => panic!("FakeQuantizeLSQBackwardX: bad bits {n}"),
17922        };
17923        unsafe {
17924            let xs = sl(*x, base, len);
17925            let scale = sl(*scale_off, base, chan_dim);
17926            let dys = sl(*dy, base, len);
17927            let outs = sl_mut(*dx, base, len);
17928            // STE-clipped: dx = dy when |x/s| ≤ q_max, else 0.
17929            for i in 0..len {
17930                let c = if chan_dim == 1 {
17931                    0
17932                } else {
17933                    (i / inner) % chan_dim
17934                };
17935                let z = xs[i] / scale[c].max(1e-12);
17936                outs[i] = if z.abs() <= q_max { dys[i] } else { 0.0 };
17937            }
17938        }
17939    }
17940}
17941
17942#[inline(always)]
17943fn exec_fake_quantize_l_s_q_backward_scale(t: &Thunk, base: *mut u8) {
17944    let Thunk::FakeQuantizeLSQBackwardScale {
17945        x,
17946        scale_off,
17947        dy,
17948        dscale,
17949        len,
17950        chan_axis: _,
17951        chan_dim,
17952        inner,
17953        bits,
17954    } = t
17955    else {
17956        unreachable!()
17957    };
17958    {
17959        let len = *len as usize;
17960        let chan_dim = *chan_dim as usize;
17961        let inner = *inner as usize;
17962        let q_max: f32 = match *bits {
17963            8 => 127.0,
17964            4 => 7.0,
17965            2 => 1.0,
17966            n => panic!("FakeQuantizeLSQBackwardScale: bad bits {n}"),
17967        };
17968        unsafe {
17969            let xs = sl(*x, base, len);
17970            let scale = sl(*scale_off, base, chan_dim);
17971            let dys = sl(*dy, base, len);
17972            let outs = sl_mut(*dscale, base, chan_dim);
17973            for v in outs.iter_mut() {
17974                *v = 0.0;
17975            }
17976            // ψ(z) = -z + round(z) inside range, sign(z)·q_max outside.
17977            // dscale[c] = sum_i ψ(x_i/s[c]) * upstream[i].
17978            for i in 0..len {
17979                let c = if chan_dim == 1 {
17980                    0
17981                } else {
17982                    (i / inner) % chan_dim
17983                };
17984                let s = scale[c].max(1e-12);
17985                let z = xs[i] / s;
17986                let psi = if z.abs() <= q_max {
17987                    -z + z.round()
17988                } else if z > 0.0 {
17989                    q_max
17990                } else {
17991                    -q_max
17992                };
17993                outs[c] += psi * dys[i];
17994            }
17995        }
17996    }
17997}
17998
17999#[inline(always)]
18000fn exec_fake_quantize_backward(t: &Thunk, base: *mut u8) {
18001    let Thunk::FakeQuantizeBackward {
18002        x,
18003        dy,
18004        dx,
18005        len,
18006        chan_axis: _,
18007        chan_dim,
18008        inner,
18009        bits,
18010        ste,
18011    } = t
18012    else {
18013        unreachable!()
18014    };
18015    {
18016        use rlx_ir::op::SteKind;
18017        let len = *len as usize;
18018        let chan_dim = *chan_dim as usize;
18019        let inner = *inner as usize;
18020        let q_max: f32 = match *bits {
18021            8 => 127.0,
18022            4 => 7.0,
18023            2 => 1.0,
18024            n => panic!("FakeQuantizeBackward: bad bits {n}"),
18025        };
18026        unsafe {
18027            let xs = sl(*x, base, len);
18028            let dys = sl(*dy, base, len);
18029            let outs = sl_mut(*dx, base, len);
18030
18031            // Per-channel max-abs → scale, same as forward.
18032            let mut max_abs = vec![0f32; chan_dim];
18033            for i in 0..len {
18034                let c = if chan_dim == 1 {
18035                    0
18036                } else {
18037                    (i / inner) % chan_dim
18038                };
18039                let a = xs[i].abs();
18040                if a > max_abs[c] {
18041                    max_abs[c] = a;
18042                }
18043            }
18044            let mut scale = vec![0f32; chan_dim];
18045            for c in 0..chan_dim {
18046                scale[c] = (max_abs[c] / q_max).max(1e-12);
18047            }
18048
18049            match *ste {
18050                SteKind::Identity => {
18051                    // dx = dy unchanged.
18052                    outs.copy_from_slice(dys);
18053                }
18054                SteKind::ClippedIdentity => {
18055                    // dx = dy * (|x| <= q_max·s); zero if the
18056                    // forward saturated.
18057                    for i in 0..len {
18058                        let c = if chan_dim == 1 {
18059                            0
18060                        } else {
18061                            (i / inner) % chan_dim
18062                        };
18063                        let bound = q_max * scale[c];
18064                        outs[i] = if xs[i].abs() <= bound { dys[i] } else { 0.0 };
18065                    }
18066                }
18067                SteKind::Tanh => {
18068                    // dx = dy * (1 - tanh²(x/s)).
18069                    for i in 0..len {
18070                        let c = if chan_dim == 1 {
18071                            0
18072                        } else {
18073                            (i / inner) % chan_dim
18074                        };
18075                        let t = (xs[i] / scale[c]).tanh();
18076                        outs[i] = dys[i] * (1.0 - t * t);
18077                    }
18078                }
18079                SteKind::HardTanh => {
18080                    // dx = dy * max(0, 1 - |x/(q_max·s)|).
18081                    for i in 0..len {
18082                        let c = if chan_dim == 1 {
18083                            0
18084                        } else {
18085                            (i / inner) % chan_dim
18086                        };
18087                        let bound = q_max * scale[c];
18088                        let attenuation = (1.0 - (xs[i] / bound).abs()).max(0.0);
18089                        outs[i] = dys[i] * attenuation;
18090                    }
18091                }
18092            }
18093        }
18094    }
18095}
18096
18097#[inline(always)]
18098fn exec_layer_norm_backward_input(t: &Thunk, base: *mut u8) {
18099    let Thunk::LayerNormBackwardInput {
18100        x,
18101        gamma,
18102        dy,
18103        dx,
18104        rows,
18105        h,
18106        eps,
18107    } = t
18108    else {
18109        unreachable!()
18110    };
18111    {
18112        let rows = *rows as usize;
18113        let h = *h as usize;
18114        let eps = *eps;
18115        unsafe {
18116            let xs = sl(*x, base, rows * h);
18117            let g = sl(*gamma, base, h);
18118            let dys = sl(*dy, base, rows * h);
18119            let out = sl_mut(*dx, base, rows * h);
18120            let n_inv = 1.0 / h as f32;
18121            for r in 0..rows {
18122                let xr = &xs[r * h..(r + 1) * h];
18123                let dyr = &dys[r * h..(r + 1) * h];
18124                // Per-row mean and inv_std (recompute — no saved
18125                // tensor from the forward pass).
18126                let mut sum = 0f32;
18127                for &v in xr {
18128                    sum += v;
18129                }
18130                let mean = sum * n_inv;
18131                let mut var = 0f32;
18132                for &v in xr {
18133                    let d = v - mean;
18134                    var += d * d;
18135                }
18136                let inv_std = 1.0 / (var * n_inv + eps).sqrt();
18137
18138                // sums needed for the closed-form:
18139                //   mean(dy·γ) and mean(dy·γ·x̂)
18140                let mut s_sy = 0f32;
18141                let mut s_sxh = 0f32;
18142                for d in 0..h {
18143                    let xh = (xr[d] - mean) * inv_std;
18144                    let sy = dyr[d] * g[d];
18145                    s_sy += sy;
18146                    s_sxh += sy * xh;
18147                }
18148                let m_sy = s_sy * n_inv;
18149                let m_sxh = s_sxh * n_inv;
18150
18151                for d in 0..h {
18152                    let xh = (xr[d] - mean) * inv_std;
18153                    let sy = dyr[d] * g[d];
18154                    out[r * h + d] = inv_std * (sy - m_sy - xh * m_sxh);
18155                }
18156            }
18157        }
18158    }
18159}
18160
18161#[inline(always)]
18162fn exec_batch_norm_inference_backward_input(t: &Thunk, base: *mut u8) {
18163    let Thunk::BatchNormInferenceBackwardInput {
18164        x,
18165        gamma,
18166        mean,
18167        var,
18168        dy,
18169        dx,
18170        count,
18171        channels,
18172        eps,
18173    } = t
18174    else {
18175        unreachable!()
18176    };
18177    {
18178        let count = *count as usize;
18179        let c = *channels as usize;
18180        let n = count * c;
18181        let eps = *eps;
18182        unsafe {
18183            crate::kernels::batch_norm_inference_backward_input(
18184                sl(*x, base, n),
18185                sl(*gamma, base, c),
18186                sl(*mean, base, c),
18187                sl(*var, base, c),
18188                sl(*dy, base, n),
18189                sl_mut(*dx, base, n),
18190                c,
18191                eps,
18192            );
18193        }
18194    }
18195}
18196
18197#[inline(always)]
18198fn exec_batch_norm_inference_backward_gamma(t: &Thunk, base: *mut u8) {
18199    let Thunk::BatchNormInferenceBackwardGamma {
18200        x,
18201        mean,
18202        var,
18203        dy,
18204        dgamma,
18205        count,
18206        channels,
18207        eps,
18208    } = t
18209    else {
18210        unreachable!()
18211    };
18212    {
18213        let count = *count as usize;
18214        let c = *channels as usize;
18215        let n = count * c;
18216        let eps = *eps;
18217        unsafe {
18218            crate::kernels::batch_norm_inference_backward_gamma(
18219                sl(*x, base, n),
18220                sl(*mean, base, c),
18221                sl(*var, base, c),
18222                sl(*dy, base, n),
18223                sl_mut(*dgamma, base, c),
18224                c,
18225                eps,
18226            );
18227        }
18228    }
18229}
18230
18231#[inline(always)]
18232fn exec_batch_norm_inference_backward_beta(t: &Thunk, base: *mut u8) {
18233    let Thunk::BatchNormInferenceBackwardBeta {
18234        dy,
18235        dbeta,
18236        count,
18237        channels,
18238    } = t
18239    else {
18240        unreachable!()
18241    };
18242    {
18243        let count = *count as usize;
18244        let c = *channels as usize;
18245        let n = count * c;
18246        unsafe {
18247            crate::kernels::batch_norm_inference_backward_beta(
18248                sl(*dy, base, n),
18249                sl_mut(*dbeta, base, c),
18250                c,
18251            );
18252        }
18253    }
18254}
18255
18256#[inline(always)]
18257fn exec_layer_norm_backward_gamma(t: &Thunk, base: *mut u8) {
18258    let Thunk::LayerNormBackwardGamma {
18259        x,
18260        dy,
18261        dgamma,
18262        rows,
18263        h,
18264        eps,
18265    } = t
18266    else {
18267        unreachable!()
18268    };
18269    {
18270        let rows = *rows as usize;
18271        let h = *h as usize;
18272        let eps = *eps;
18273        unsafe {
18274            let xs = sl(*x, base, rows * h);
18275            let dys = sl(*dy, base, rows * h);
18276            let out = sl_mut(*dgamma, base, h);
18277            for v in out.iter_mut() {
18278                *v = 0.0;
18279            }
18280            let n_inv = 1.0 / h as f32;
18281            for r in 0..rows {
18282                let xr = &xs[r * h..(r + 1) * h];
18283                let dyr = &dys[r * h..(r + 1) * h];
18284                let mut sum = 0f32;
18285                for &v in xr {
18286                    sum += v;
18287                }
18288                let mean = sum * n_inv;
18289                let mut var = 0f32;
18290                for &v in xr {
18291                    let d = v - mean;
18292                    var += d * d;
18293                }
18294                let inv_std = 1.0 / (var * n_inv + eps).sqrt();
18295                for d in 0..h {
18296                    let xh = (xr[d] - mean) * inv_std;
18297                    out[d] += dyr[d] * xh;
18298                }
18299            }
18300        }
18301    }
18302}
18303
18304#[inline(always)]
18305fn exec_rms_norm_backward_input(t: &Thunk, base: *mut u8) {
18306    let Thunk::RmsNormBackwardInput {
18307        x,
18308        gamma,
18309        beta,
18310        dy,
18311        dx,
18312        rows,
18313        h,
18314        eps,
18315    } = t
18316    else {
18317        unreachable!()
18318    };
18319    {
18320        let (rows, h) = (*rows as usize, *h as usize);
18321        unsafe {
18322            let xs = sl(*x, base, rows * h);
18323            let g = sl(*gamma, base, h);
18324            let b = sl(*beta, base, h);
18325            let dys = sl(*dy, base, rows * h);
18326            let out = sl_mut(*dx, base, rows * h);
18327            let mut dg = vec![0f32; h];
18328            let mut db = vec![0f32; h];
18329            for r in 0..rows {
18330                crate::training_bwd::rms_norm_backward_row(
18331                    &xs[r * h..(r + 1) * h],
18332                    g,
18333                    b,
18334                    &dys[r * h..(r + 1) * h],
18335                    &mut out[r * h..(r + 1) * h],
18336                    &mut dg,
18337                    &mut db,
18338                    *eps,
18339                );
18340            }
18341        }
18342    }
18343}
18344
18345#[inline(always)]
18346fn exec_rms_norm_backward_gamma(t: &Thunk, base: *mut u8) {
18347    let Thunk::RmsNormBackwardGamma {
18348        x,
18349        gamma,
18350        beta,
18351        dy,
18352        dgamma,
18353        rows,
18354        h,
18355        eps,
18356    } = t
18357    else {
18358        unreachable!()
18359    };
18360    {
18361        let (rows, h) = (*rows as usize, *h as usize);
18362        unsafe {
18363            let xs = sl(*x, base, rows * h);
18364            let g = sl(*gamma, base, h);
18365            let b = sl(*beta, base, h);
18366            let dys = sl(*dy, base, rows * h);
18367            let out = sl_mut(*dgamma, base, h);
18368            for v in out.iter_mut() {
18369                *v = 0.0;
18370            }
18371            let mut dx = vec![0f32; h];
18372            let mut db = vec![0f32; h];
18373            for r in 0..rows {
18374                crate::training_bwd::rms_norm_backward_row(
18375                    &xs[r * h..(r + 1) * h],
18376                    g,
18377                    b,
18378                    &dys[r * h..(r + 1) * h],
18379                    &mut dx,
18380                    &mut *out,
18381                    &mut db,
18382                    *eps,
18383                );
18384            }
18385        }
18386    }
18387}
18388
18389#[inline(always)]
18390fn exec_rms_norm_backward_beta(t: &Thunk, base: *mut u8) {
18391    let Thunk::RmsNormBackwardBeta {
18392        x,
18393        gamma,
18394        beta,
18395        dy,
18396        dbeta,
18397        rows,
18398        h,
18399        eps,
18400    } = t
18401    else {
18402        unreachable!()
18403    };
18404    {
18405        let (rows, h) = (*rows as usize, *h as usize);
18406        unsafe {
18407            let xs = sl(*x, base, rows * h);
18408            let g = sl(*gamma, base, h);
18409            let b = sl(*beta, base, h);
18410            let dys = sl(*dy, base, rows * h);
18411            let out = sl_mut(*dbeta, base, h);
18412            for v in out.iter_mut() {
18413                *v = 0.0;
18414            }
18415            let mut dx = vec![0f32; h];
18416            let mut dg = vec![0f32; h];
18417            for r in 0..rows {
18418                crate::training_bwd::rms_norm_backward_row(
18419                    &xs[r * h..(r + 1) * h],
18420                    g,
18421                    b,
18422                    &dys[r * h..(r + 1) * h],
18423                    &mut dx,
18424                    &mut dg,
18425                    &mut *out,
18426                    *eps,
18427                );
18428            }
18429        }
18430    }
18431}
18432
18433#[inline(always)]
18434fn exec_rope_backward(t: &Thunk, base: *mut u8) {
18435    let Thunk::RopeBackward {
18436        dy,
18437        cos,
18438        sin,
18439        dx,
18440        batch,
18441        seq,
18442        hidden,
18443        head_dim,
18444        n_rot,
18445        cos_len,
18446    } = t
18447    else {
18448        unreachable!()
18449    };
18450    {
18451        let (b, s, hs, dh, nr, cl) = (
18452            *batch as usize,
18453            *seq as usize,
18454            *hidden as usize,
18455            *head_dim as usize,
18456            *n_rot as usize,
18457            *cos_len as usize,
18458        );
18459        let nh = hs / dh;
18460        let tab_half = dh / 2;
18461        unsafe {
18462            let dys = sl(*dy, base, b * s * hs);
18463            let cos_tab = sl(*cos, base, cl);
18464            let sin_tab = sl(*sin, base, cl);
18465            let out = sl_mut(*dx, base, b * s * hs);
18466            for bi in 0..b {
18467                for si in 0..s {
18468                    let tab_off = si.saturating_mul(tab_half) % cl.max(1);
18469                    let cp = &cos_tab[tab_off..tab_off + tab_half.min(cl)];
18470                    let sp = &sin_tab[tab_off..tab_off + tab_half.min(cl)];
18471                    for hi in 0..nh {
18472                        let base_idx = bi * s * hs + si * hs + hi * dh;
18473                        crate::training_bwd::rope_backward_row(
18474                            &dys[base_idx..base_idx + dh],
18475                            cp,
18476                            sp,
18477                            &mut out[base_idx..base_idx + dh],
18478                            dh,
18479                            nr,
18480                        );
18481                    }
18482                }
18483            }
18484        }
18485    }
18486}
18487
18488#[inline(always)]
18489fn exec_cumsum_backward(t: &Thunk, base: *mut u8) {
18490    let Thunk::CumsumBackward {
18491        dy,
18492        dx,
18493        rows,
18494        cols,
18495        exclusive,
18496    } = t
18497    else {
18498        unreachable!()
18499    };
18500    {
18501        let (rows, cols) = (*rows as usize, *cols as usize);
18502        unsafe {
18503            let dys = sl(*dy, base, rows * cols);
18504            let out = sl_mut(*dx, base, rows * cols);
18505            for r in 0..rows {
18506                crate::training_bwd::cumsum_backward_row(
18507                    &dys[r * cols..(r + 1) * cols],
18508                    &mut out[r * cols..(r + 1) * cols],
18509                    *exclusive,
18510                );
18511            }
18512        }
18513    }
18514}
18515
18516#[inline(always)]
18517fn exec_group_norm_backward_input(t: &Thunk, base: *mut u8) {
18518    let Thunk::GroupNormBackwardInput {
18519        x,
18520        gamma,
18521        beta: _beta,
18522        dy,
18523        dx,
18524        n,
18525        c,
18526        h,
18527        w,
18528        num_groups,
18529        eps,
18530    } = t
18531    else {
18532        unreachable!()
18533    };
18534    {
18535        let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
18536        let plane = c * h * w;
18537        unsafe {
18538            let xs = sl(*x, base, n * plane);
18539            let g = sl(*gamma, base, c);
18540            let dys = sl(*dy, base, n * plane);
18541            let out = sl_mut(*dx, base, n * plane);
18542            crate::training_bwd::group_norm_backward_input_nchw(
18543                xs,
18544                g,
18545                dys,
18546                out,
18547                n,
18548                c,
18549                h,
18550                w,
18551                *num_groups as usize,
18552                *eps,
18553            );
18554        }
18555    }
18556}
18557
18558#[inline(always)]
18559fn exec_group_norm_backward_gamma(t: &Thunk, base: *mut u8) {
18560    let Thunk::GroupNormBackwardGamma {
18561        x,
18562        dy,
18563        dgamma,
18564        n,
18565        c,
18566        h,
18567        w,
18568        num_groups,
18569        eps,
18570    } = t
18571    else {
18572        unreachable!()
18573    };
18574    {
18575        let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
18576        let plane = c * h * w;
18577        unsafe {
18578            let xs = sl(*x, base, n * plane);
18579            let dys = sl(*dy, base, n * plane);
18580            let out = sl_mut(*dgamma, base, c);
18581            crate::training_bwd::group_norm_backward_gamma_nchw(
18582                xs,
18583                dys,
18584                out,
18585                n,
18586                c,
18587                h,
18588                w,
18589                *num_groups as usize,
18590                *eps,
18591            );
18592        }
18593    }
18594}
18595
18596#[inline(always)]
18597fn exec_group_norm_backward_beta(t: &Thunk, base: *mut u8) {
18598    let Thunk::GroupNormBackwardBeta {
18599        dy,
18600        dbeta,
18601        n,
18602        c,
18603        h,
18604        w,
18605    } = t
18606    else {
18607        unreachable!()
18608    };
18609    {
18610        let (n, c, h, w) = (*n as usize, *c as usize, *h as usize, *w as usize);
18611        let plane = c * h * w;
18612        unsafe {
18613            let dys = sl(*dy, base, n * plane);
18614            let out = sl_mut(*dbeta, base, c);
18615            crate::training_bwd::group_norm_backward_beta_nchw(dys, out, n, c, h, w);
18616        }
18617    }
18618}
18619
18620#[inline(always)]
18621fn exec_gather_backward(t: &Thunk, base: *mut u8) {
18622    let Thunk::GatherBackward {
18623        dy,
18624        indices,
18625        dst,
18626        outer,
18627        axis_dim,
18628        num_idx,
18629        trailing,
18630    } = t
18631    else {
18632        unreachable!()
18633    };
18634    {
18635        let (outer, axis_dim, num_idx, trailing) = (
18636            *outer as usize,
18637            *axis_dim as usize,
18638            *num_idx as usize,
18639            *trailing as usize,
18640        );
18641        unsafe {
18642            let dys = sl(*dy, base, outer * num_idx * trailing);
18643            let ids = sl(*indices, base, num_idx);
18644            let out = sl_mut(*dst, base, outer * axis_dim * trailing);
18645            for v in out.iter_mut() {
18646                *v = 0.0;
18647            }
18648            crate::training_bwd::gather_axis_backward(
18649                dys, ids, out, outer, axis_dim, num_idx, trailing,
18650            );
18651        }
18652    }
18653}
18654
18655#[inline(always)]
18656fn exec_max_pool2d_backward(t: &Thunk, base: *mut u8) {
18657    let Thunk::MaxPool2dBackward {
18658        x,
18659        dy,
18660        dx,
18661        n,
18662        c,
18663        h,
18664        w,
18665        h_out,
18666        w_out,
18667        kh,
18668        kw,
18669        sh,
18670        sw,
18671        ph,
18672        pw,
18673    } = t
18674    else {
18675        unreachable!()
18676    };
18677    unsafe {
18678        execute_maxpool2d_backward_f32(
18679            *x, *dy, *dx, *n, *c, *h, *w, *h_out, *w_out, *kh, *kw, *sh, *sw, *ph, *pw, base,
18680        );
18681    }
18682}
18683
18684#[inline(always)]
18685fn exec_conv2d_backward_input(t: &Thunk, base: *mut u8) {
18686    let Thunk::Conv2dBackwardInput {
18687        dy,
18688        w,
18689        dx,
18690        n,
18691        c_in,
18692        h,
18693        w_in,
18694        c_out,
18695        h_out,
18696        w_out,
18697        kh,
18698        kw,
18699        sh,
18700        sw,
18701        ph,
18702        pw,
18703        dh,
18704        dw,
18705        groups,
18706    } = t
18707    else {
18708        unreachable!()
18709    };
18710    {
18711        // Per-group GEMM + col2im. Two orders of magnitude faster
18712        // than the naive 6-deep nested loop on training shapes.
18713        //
18714        //   dcol_n_g = w_g^T  @  dy_n_g            (sgemm)
18715        //   dx_n_g  += col2im(dcol_n_g)            (scatter-add)
18716        //
18717        // Layouts (all row-major):
18718        //   w_g       [c_out_per_g, c_in_per_g · kh · kw]
18719        //   dy_n_g    [c_out_per_g, h_out · w_out]
18720        //   dcol_n_g  [c_in_per_g · kh · kw, h_out · w_out]
18721        //   dx_n_g    [c_in_per_g, h · w_in]
18722        let n = *n as usize;
18723        let c_in = *c_in as usize;
18724        let h = *h as usize;
18725        let w_in = *w_in as usize;
18726        let c_out = *c_out as usize;
18727        let h_out = *h_out as usize;
18728        let w_out = *w_out as usize;
18729        let kh = *kh as usize;
18730        let kw = *kw as usize;
18731        let sh = *sh as usize;
18732        let sw = *sw as usize;
18733        let ph = *ph as usize;
18734        let pw = *pw as usize;
18735        let dh = *dh as usize;
18736        let dw = *dw as usize;
18737        let groups = *groups as usize;
18738        let c_in_per_g = c_in / groups;
18739        let c_out_per_g = c_out / groups;
18740
18741        let m_dim = c_in_per_g * kh * kw;
18742        let n_dim = h_out * w_out;
18743        let k_dim = c_out_per_g;
18744
18745        let dy_stride_n = c_out * h_out * w_out;
18746        let dy_stride_g = c_out_per_g * h_out * w_out;
18747        let w_stride_g = c_out_per_g * c_in_per_g * kh * kw;
18748        let dx_stride_n = c_in * h * w_in;
18749        let dx_stride_g = c_in_per_g * h * w_in;
18750
18751        unsafe {
18752            let dys = sl(*dy, base, n * c_out * h_out * w_out);
18753            let ws = sl(*w, base, c_out * c_in_per_g * kh * kw);
18754            let dxs = sl_mut(*dx, base, n * c_in * h * w_in);
18755            for v in dxs.iter_mut() {
18756                *v = 0.0;
18757            }
18758
18759            // Each (ni, g) writes a disjoint dx_n_g region (col2im
18760            // scatter-adds into the freshly-zeroed slice), so the batch
18761            // loop fans out over the pool when RLX_FAST_CONV is set —
18762            // each worker owns a `dcol` scratch and a raw pointer it
18763            // offsets into its own dx window.
18764            if fast_conv_enabled() {
18765                let dx_addr = dxs.as_mut_ptr() as usize;
18766                crate::pool::par_for(n, 1, &|off, cnt| {
18767                    let mut dcol = vec![0f32; m_dim * n_dim];
18768                    for ni in off..off + cnt {
18769                        for g in 0..groups {
18770                            let w_g_off = g * w_stride_g;
18771                            let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
18772                            let dx_n_g_off = ni * dx_stride_n + g * dx_stride_g;
18773                            crate::blas::sgemm_general(
18774                                ws.as_ptr().add(w_g_off),
18775                                dys.as_ptr().add(dy_n_g_off),
18776                                dcol.as_mut_ptr(),
18777                                m_dim,
18778                                n_dim,
18779                                k_dim,
18780                                1.0,
18781                                0.0,
18782                                m_dim,
18783                                n_dim,
18784                                n_dim,
18785                                true,
18786                                false,
18787                            );
18788                            let dx_g = std::slice::from_raw_parts_mut(
18789                                (dx_addr as *mut f32).add(dx_n_g_off),
18790                                dx_stride_g,
18791                            );
18792                            col2im(
18793                                &dcol, dx_g, c_in_per_g, h, w_in, h_out, w_out, kh, kw, sh, sw, ph,
18794                                pw, dh, dw,
18795                            );
18796                        }
18797                    }
18798                });
18799            } else {
18800                // Reused scratch buffer for the [m_dim, n_dim] dcol.
18801                let mut dcol = vec![0f32; m_dim * n_dim];
18802                for ni in 0..n {
18803                    for g in 0..groups {
18804                        let w_g_off = g * w_stride_g;
18805                        let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
18806                        let dx_n_g_off = ni * dx_stride_n + g * dx_stride_g;
18807
18808                        // dcol = w_g^T @ dy_n_g
18809                        // w_g  is stored as [k_dim rows, m_dim cols] row-major
18810                        // (i.e. K×M storage with lda = M = m_dim — exactly what
18811                        // sgemm_general wants for trans_a=true).
18812                        crate::blas::sgemm_general(
18813                            ws.as_ptr().add(w_g_off),
18814                            dys.as_ptr().add(dy_n_g_off),
18815                            dcol.as_mut_ptr(),
18816                            m_dim,
18817                            n_dim,
18818                            k_dim,
18819                            1.0,
18820                            0.0,
18821                            /*lda=*/ m_dim,
18822                            /*ldb=*/ n_dim,
18823                            /*ldc=*/ n_dim,
18824                            /*trans_a=*/ true,
18825                            /*trans_b=*/ false,
18826                        );
18827
18828                        // dx_n_g += col2im(dcol)
18829                        col2im(
18830                            &dcol,
18831                            &mut dxs[dx_n_g_off..dx_n_g_off + dx_stride_g],
18832                            c_in_per_g,
18833                            h,
18834                            w_in,
18835                            h_out,
18836                            w_out,
18837                            kh,
18838                            kw,
18839                            sh,
18840                            sw,
18841                            ph,
18842                            pw,
18843                            dh,
18844                            dw,
18845                        );
18846                    }
18847                }
18848            }
18849        }
18850    }
18851}
18852
18853#[inline(always)]
18854fn exec_conv2d_backward_weight(t: &Thunk, base: *mut u8) {
18855    let Thunk::Conv2dBackwardWeight {
18856        x,
18857        dy,
18858        dw,
18859        n,
18860        c_in,
18861        h,
18862        w,
18863        c_out,
18864        h_out,
18865        w_out,
18866        kh,
18867        kw,
18868        sh,
18869        sw,
18870        ph,
18871        pw,
18872        dh,
18873        dw_dil,
18874        groups,
18875    } = t
18876    else {
18877        unreachable!()
18878    };
18879    {
18880        let n = *n as usize;
18881        let c_in = *c_in as usize;
18882        let h = *h as usize;
18883        let w = *w as usize;
18884        // Per-group im2col + GEMM, summed across batch.
18885        //
18886        //   col_n_g  = im2col(x_n_g)               (gather)
18887        //   dw_g    += dy_n_g  @  col_n_g^T        (sgemm, β=1)
18888        //
18889        // Layouts:
18890        //   x_n_g     [c_in_per_g, h · w]
18891        //   col_n_g   [c_in_per_g · kh · kw, h_out · w_out]
18892        //   dy_n_g    [c_out_per_g, h_out · w_out]
18893        //   dw_g      [c_out_per_g, c_in_per_g · kh · kw]
18894        let c_out = *c_out as usize;
18895        let h_out = *h_out as usize;
18896        let w_out = *w_out as usize;
18897        let kh = *kh as usize;
18898        let kw = *kw as usize;
18899        let sh = *sh as usize;
18900        let sw = *sw as usize;
18901        let ph = *ph as usize;
18902        let pw = *pw as usize;
18903        let dh = *dh as usize;
18904        let dw_dil = *dw_dil as usize;
18905        let groups = *groups as usize;
18906        let c_in_per_g = c_in / groups;
18907        let c_out_per_g = c_out / groups;
18908
18909        let m_dim = c_out_per_g;
18910        let n_dim = c_in_per_g * kh * kw;
18911        let k_dim = h_out * w_out;
18912
18913        let x_stride_n = c_in * h * w;
18914        let x_stride_g = c_in_per_g * h * w;
18915        let dy_stride_n = c_out * h_out * w_out;
18916        let dy_stride_g = c_out_per_g * h_out * w_out;
18917        let dw_stride_g = c_out_per_g * c_in_per_g * kh * kw;
18918
18919        unsafe {
18920            let xs = sl(*x, base, n * c_in * h * w);
18921            let dys = sl(*dy, base, n * c_out * h_out * w_out);
18922            let dws = sl_mut(*dw, base, c_out * c_in_per_g * kh * kw);
18923            for v in dws.iter_mut() {
18924                *v = 0.0;
18925            }
18926
18927            // dw is a cross-batch reduction (β=1 accumulate), so the
18928            // parallel path gives each worker a private `local` dw,
18929            // accumulates into it over its slice of the batch, then adds
18930            // it into the shared `dws` once under a lock — O(threads)
18931            // contention, not O(batch). RLX_FAST_CONV gates it.
18932            if fast_conv_enabled() {
18933                let dw_len = dws.len();
18934                let dws_addr = dws.as_mut_ptr() as usize;
18935                let lock = std::sync::Mutex::new(());
18936                crate::pool::par_for(n, 1, &|off, cnt| {
18937                    let mut col = vec![0f32; n_dim * k_dim];
18938                    let mut local = vec![0f32; dw_len];
18939                    for ni in off..off + cnt {
18940                        for g in 0..groups {
18941                            let x_n_g_off = ni * x_stride_n + g * x_stride_g;
18942                            let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
18943                            let dw_g_off = g * dw_stride_g;
18944                            // Rows-layout im2col: col is [P, K] row-major, so
18945                            // the GEMM dy[c_out,P] @ col[P,K] reads col
18946                            // contiguously (trans_b=false) instead of the
18947                            // strided transposed read the [K,P] layout forced.
18948                            crate::im2col::im2col_rows_layout(
18949                                &xs[x_n_g_off..x_n_g_off + x_stride_g],
18950                                &mut col,
18951                                1,
18952                                c_in_per_g,
18953                                h,
18954                                w,
18955                                h_out,
18956                                w_out,
18957                                kh,
18958                                kw,
18959                                sh,
18960                                sw,
18961                                ph,
18962                                pw,
18963                                dh,
18964                                dw_dil,
18965                            );
18966                            crate::blas::sgemm_general(
18967                                dys.as_ptr().add(dy_n_g_off),
18968                                col.as_ptr(),
18969                                local.as_mut_ptr().add(dw_g_off),
18970                                m_dim,
18971                                n_dim,
18972                                k_dim,
18973                                1.0,
18974                                1.0,
18975                                k_dim,
18976                                n_dim,
18977                                n_dim,
18978                                false,
18979                                false,
18980                            );
18981                        }
18982                    }
18983                    let _guard = lock.lock().unwrap();
18984                    let dws = std::slice::from_raw_parts_mut(dws_addr as *mut f32, dw_len);
18985                    for (d, l) in dws.iter_mut().zip(local.iter()) {
18986                        *d += *l;
18987                    }
18988                });
18989            } else {
18990                let mut col = vec![0f32; n_dim * k_dim];
18991                for ni in 0..n {
18992                    for g in 0..groups {
18993                        let x_n_g_off = ni * x_stride_n + g * x_stride_g;
18994                        im2col(
18995                            &xs[x_n_g_off..x_n_g_off + x_stride_g],
18996                            &mut col,
18997                            c_in_per_g,
18998                            h,
18999                            w,
19000                            h_out,
19001                            w_out,
19002                            kh,
19003                            kw,
19004                            sh,
19005                            sw,
19006                            ph,
19007                            pw,
19008                            dh,
19009                            dw_dil,
19010                        );
19011
19012                        let dy_n_g_off = ni * dy_stride_n + g * dy_stride_g;
19013                        let dw_g_off = g * dw_stride_g;
19014
19015                        // dw_g += dy_n_g @ col^T
19016                        //
19017                        // Output shape m × n_out = c_out_per_g × (c_in_per_g·kh·kw).
19018                        // dy_n_g is stored M×K row-major (lda = K = k_dim).
19019                        // col is stored as N×K row-major; with trans_b=true,
19020                        // sgemm_general uses ldb = K = k_dim and treats it as
19021                        // transposed. β=1 accumulates across the batch loop.
19022                        crate::blas::sgemm_general(
19023                            dys.as_ptr().add(dy_n_g_off),
19024                            col.as_ptr(),
19025                            dws.as_mut_ptr().add(dw_g_off),
19026                            m_dim,
19027                            n_dim,
19028                            k_dim,
19029                            1.0,
19030                            1.0,
19031                            /*lda=*/ k_dim,
19032                            /*ldb=*/ k_dim,
19033                            /*ldc=*/ n_dim,
19034                            /*trans_a=*/ false,
19035                            /*trans_b=*/ true,
19036                        );
19037                    }
19038                }
19039            }
19040        }
19041    }
19042}
19043
19044#[inline(always)]
19045fn exec_im2_col(t: &Thunk, base: *mut u8) {
19046    let Thunk::Im2Col {
19047        x,
19048        col,
19049        n,
19050        c_in,
19051        h,
19052        w,
19053        h_out,
19054        w_out,
19055        kh,
19056        kw,
19057        sh,
19058        sw,
19059        ph,
19060        pw,
19061        dh,
19062        dw_dil,
19063    } = t
19064    else {
19065        unreachable!()
19066    };
19067    {
19068        let c_in = *c_in as usize;
19069        let h = *h as usize;
19070        let w = *w as usize;
19071        let h_out = *h_out as usize;
19072        let w_out = *w_out as usize;
19073        let kh = *kh as usize;
19074        let kw = *kw as usize;
19075        let sh = *sh as usize;
19076        let sw = *sw as usize;
19077        let ph = *ph as usize;
19078        let pw = *pw as usize;
19079        let dh = *dh as usize;
19080        let dw_dil = *dw_dil as usize;
19081        let per_batch = c_in * h * w;
19082        unsafe {
19083            let n_eff = if *n == 0 { 0usize } else { *n as usize };
19084            let x_floats = if n_eff == 0 {
19085                per_batch.max(1)
19086            } else {
19087                n_eff * per_batch
19088            };
19089            let xs = sl(*x, base, x_floats);
19090            let n = if *n == 0 {
19091                xs.len() / per_batch.max(1)
19092            } else {
19093                n_eff
19094            };
19095            let m = n * h_out * w_out;
19096            let k = c_in * kh * kw;
19097            let cols = sl_mut(*col, base, m * k);
19098            crate::im2col::im2col_rows_layout(
19099                xs, cols, n, c_in, h, w, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw_dil,
19100            );
19101        }
19102    }
19103}
19104
19105#[inline(always)]
19106fn exec_softmax_cross_entropy_dense(t: &Thunk, base: *mut u8) {
19107    let Thunk::SoftmaxCrossEntropyDense {
19108        logits,
19109        targets,
19110        dst,
19111        n,
19112        c,
19113    } = t
19114    else {
19115        unreachable!()
19116    };
19117    {
19118        let n = *n as usize;
19119        let c = *c as usize;
19120        unsafe {
19121            let lg = sl(*logits, base, n * c);
19122            let tg = sl(*targets, base, n * c);
19123            let out = sl_mut(*dst, base, n);
19124            for ni in 0..n {
19125                let row = &lg[ni * c..(ni + 1) * c];
19126                let trow = &tg[ni * c..(ni + 1) * c];
19127                // log-sum-exp: max-subtract for stability.
19128                let mut m = f32::NEG_INFINITY;
19129                for &v in row {
19130                    if v > m {
19131                        m = v;
19132                    }
19133                }
19134                let mut sum = 0f32;
19135                for &v in row {
19136                    sum += (v - m).exp();
19137                }
19138                let lse = m + sum.ln();
19139                // loss = lse - Σ_c targets[c]·logits[c].
19140                let mut dot = 0f32;
19141                for k in 0..c {
19142                    dot += trow[k] * row[k];
19143                }
19144                out[ni] = lse - dot;
19145            }
19146        }
19147    }
19148}
19149
19150#[inline(always)]
19151fn exec_softmax_cross_entropy(t: &Thunk, base: *mut u8) {
19152    let Thunk::SoftmaxCrossEntropy {
19153        logits,
19154        labels,
19155        dst,
19156        n,
19157        c,
19158    } = t
19159    else {
19160        unreachable!()
19161    };
19162    {
19163        let n = *n as usize;
19164        let c = *c as usize;
19165        unsafe {
19166            let lg = sl(*logits, base, n * c);
19167            let lb = sl(*labels, base, n);
19168            let out = sl_mut(*dst, base, n);
19169            for ni in 0..n {
19170                let row = &lg[ni * c..(ni + 1) * c];
19171                // log-sum-exp: max-subtract for stability.
19172                let mut m = f32::NEG_INFINITY;
19173                for &v in row {
19174                    if v > m {
19175                        m = v;
19176                    }
19177                }
19178                let mut sum = 0f32;
19179                for &v in row {
19180                    sum += (v - m).exp();
19181                }
19182                let lse = m + sum.ln();
19183                let label_idx = lb[ni] as usize;
19184                // loss = -(logits[label] - lse) = lse - logits[label].
19185                out[ni] = lse - row[label_idx];
19186            }
19187        }
19188    }
19189}
19190
19191#[inline(always)]
19192fn exec_softmax_cross_entropy_backward(t: &Thunk, base: *mut u8) {
19193    let Thunk::SoftmaxCrossEntropyBackward {
19194        logits,
19195        labels,
19196        d_loss,
19197        dlogits,
19198        n,
19199        c,
19200    } = t
19201    else {
19202        unreachable!()
19203    };
19204    {
19205        let n = *n as usize;
19206        let c = *c as usize;
19207        unsafe {
19208            let lg = sl(*logits, base, n * c);
19209            let lb = sl(*labels, base, n);
19210            let dl = sl(*d_loss, base, n);
19211            let out = sl_mut(*dlogits, base, n * c);
19212            for ni in 0..n {
19213                let row = &lg[ni * c..(ni + 1) * c];
19214                let label_idx = lb[ni] as usize;
19215                let scale = dl[ni];
19216                let mut m = f32::NEG_INFINITY;
19217                for &v in row {
19218                    if v > m {
19219                        m = v;
19220                    }
19221                }
19222                let mut sum = 0f32;
19223                for &v in row {
19224                    sum += (v - m).exp();
19225                }
19226                let inv_sum = 1.0 / sum;
19227                let dst_row = &mut out[ni * c..(ni + 1) * c];
19228                for k in 0..c {
19229                    let p = (row[k] - m).exp() * inv_sum;
19230                    let one_hot = if k == label_idx { 1.0 } else { 0.0 };
19231                    dst_row[k] = (p - one_hot) * scale;
19232                }
19233            }
19234        }
19235    }
19236}
19237
19238#[inline(always)]
19239fn exec_gather_axis(t: &Thunk, base: *mut u8) {
19240    let Thunk::GatherAxis {
19241        table,
19242        idx,
19243        dst,
19244        outer,
19245        axis_dim,
19246        num_idx,
19247        trailing,
19248        idx_i64,
19249        table_bytes,
19250    } = t
19251    else {
19252        unreachable!()
19253    };
19254    {
19255        let outer = *outer as usize;
19256        let axis_dim = *axis_dim as usize;
19257        let num_idx = *num_idx as usize;
19258        let trailing = *trailing as usize;
19259        unsafe {
19260            if *table_bytes == 8 {
19261                let tab = sl_i64(*table, base, outer * axis_dim * trailing);
19262                let out = sl_mut_i64(*dst, base, outer * num_idx * trailing);
19263                for o in 0..outer {
19264                    let tab_outer = o * axis_dim * trailing;
19265                    let out_outer = o * num_idx * trailing;
19266                    if *idx_i64 != 0 {
19267                        let ids = sl_i64(*idx, base, num_idx);
19268                        for k in 0..num_idx {
19269                            let row = ids[k].max(0) as usize;
19270                            if row < axis_dim {
19271                                let tab_row = tab_outer + row * trailing;
19272                                let out_row = out_outer + k * trailing;
19273                                out[out_row..out_row + trailing]
19274                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
19275                            }
19276                        }
19277                    } else {
19278                        let ids = sl(*idx, base, num_idx);
19279                        for k in 0..num_idx {
19280                            let row = ids[k] as usize;
19281                            if row < axis_dim {
19282                                let tab_row = tab_outer + row * trailing;
19283                                let out_row = out_outer + k * trailing;
19284                                out[out_row..out_row + trailing]
19285                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
19286                            }
19287                        }
19288                    }
19289                }
19290            } else {
19291                let tab = sl(*table, base, outer * axis_dim * trailing);
19292                let out = sl_mut(*dst, base, outer * num_idx * trailing);
19293                for o in 0..outer {
19294                    let tab_outer = o * axis_dim * trailing;
19295                    let out_outer = o * num_idx * trailing;
19296                    if *idx_i64 != 0 {
19297                        let ids = sl_i64(*idx, base, num_idx);
19298                        for k in 0..num_idx {
19299                            let row = ids[k].max(0) as usize;
19300                            if row < axis_dim {
19301                                let tab_row = tab_outer + row * trailing;
19302                                let out_row = out_outer + k * trailing;
19303                                out[out_row..out_row + trailing]
19304                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
19305                            }
19306                        }
19307                    } else {
19308                        let ids = sl(*idx, base, num_idx);
19309                        for k in 0..num_idx {
19310                            let row = ids[k] as usize;
19311                            if row < axis_dim {
19312                                let tab_row = tab_outer + row * trailing;
19313                                let out_row = out_outer + k * trailing;
19314                                out[out_row..out_row + trailing]
19315                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
19316                            }
19317                        }
19318                    }
19319                }
19320            }
19321        }
19322    }
19323}
19324
19325// (Thunk::DenseSolveF64 / Thunk::ScanBackward had panic
19326// stubs here as placeholders during the wire-up; both
19327// are now reached by the real implementations earlier in
19328// this same match — the stubs were dead code shadowed by
19329// the specific-pattern arms above. Removed.)
19330#[inline(always)]
19331fn exec_custom_op(t: &Thunk, base: *mut u8) {
19332    let Thunk::CustomOp {
19333        kernel,
19334        inputs,
19335        output,
19336        attrs,
19337    } = t
19338    else {
19339        unreachable!()
19340    };
19341    {
19342        let (out_off, out_len, out_shape) = output;
19343        unsafe {
19344            dispatch_custom_op(
19345                &**kernel, inputs, *out_off, *out_len, out_shape, attrs, base,
19346            );
19347        }
19348    }
19349}
19350
19351#[inline(always)]
19352fn exec_reverse(t: &Thunk, base: *mut u8) {
19353    let Thunk::Reverse {
19354        src,
19355        dst,
19356        dims,
19357        rev_mask,
19358        elem_bytes,
19359    } = t
19360    else {
19361        unreachable!()
19362    };
19363    {
19364        let eb = *elem_bytes as usize;
19365        let rank = dims.len();
19366        let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
19367        let mut strides = vec![1usize; rank];
19368        for i in (0..rank.saturating_sub(1)).rev() {
19369            strides[i] = strides[i + 1] * dims[i + 1] as usize;
19370        }
19371        unsafe {
19372            let src_base = base.add(*src);
19373            let dst_base = base.add(*dst);
19374            for o in 0..total {
19375                let mut rem = o;
19376                let mut in_flat = 0usize;
19377                for ax in 0..rank {
19378                    let idx = rem / strides[ax];
19379                    rem %= strides[ax];
19380                    let in_idx = if rev_mask[ax] {
19381                        dims[ax] as usize - 1 - idx
19382                    } else {
19383                        idx
19384                    };
19385                    in_flat += in_idx * strides[ax];
19386                }
19387                std::ptr::copy_nonoverlapping(src_base.add(in_flat * eb), dst_base.add(o * eb), eb);
19388            }
19389        }
19390    }
19391}
19392
19393/// Griewank treeverse: process backward iterations `[t_lo..=t_hi]` (with
19394/// the carry entering iteration `t_lo` supplied as `anchor_carry`) by
19395/// recursive binary subdivision. Total work `O((t_hi-t_lo+1) · log)`,
19396/// auxiliary memory `O(log · carry_bytes)` for the recursion stack.
19397///
19398/// Compared to the iterative segment-cached scheme, this trades extra
19399/// recompute for less working memory — each level of recursion holds
19400/// one `cb`-sized intermediate carry on the stack but never the whole
19401/// segment at once. With K saved outer checkpoints, the outer driver
19402/// invokes this helper once per segment.
19403///
19404/// `process_iter(t, carry_at_t)` is the per-iteration leaf action: it
19405/// runs `body_vjp` at iteration `t` with the supplied carry, threads
19406/// `dcarry` backward, and (for ScanBackwardXs) writes `dxs[t]`.
19407#[allow(clippy::too_many_arguments)]
19408unsafe fn griewank_process_segment(
19409    t_lo: usize,
19410    t_hi: usize,
19411    anchor_carry: &[u8],
19412    cb: usize,
19413    fwd_sched: &ThunkSchedule,
19414    fwd_init: &[u8],
19415    fwd_carry_in_off: usize,
19416    fwd_output_off: usize,
19417    fwd_x_offs: &[usize],
19418    base: *mut u8,
19419    outer_xs_offs: &[(usize, u32)],
19420    fwd_buf: &mut Vec<u8>,
19421    leaf_threshold: usize,
19422    process_iter: &mut dyn FnMut(usize, &[u8]),
19423) {
19424    unsafe {
19425        let size = t_hi - t_lo + 1;
19426        if size == 1 {
19427            process_iter(t_lo, anchor_carry);
19428            return;
19429        }
19430        if size <= leaf_threshold {
19431            // Walk forward, cache each carry, run backward in reverse.
19432            let mut cache: Vec<u8> = Vec::with_capacity(size * cb);
19433            cache.extend_from_slice(anchor_carry);
19434            fwd_buf.copy_from_slice(fwd_init);
19435            std::ptr::copy_nonoverlapping(
19436                anchor_carry.as_ptr(),
19437                fwd_buf.as_mut_ptr().add(fwd_carry_in_off),
19438                cb,
19439            );
19440            for i in 1..size {
19441                let cur_iter = t_lo + i - 1;
19442                for (idx, fb_x_off) in fwd_x_offs.iter().enumerate() {
19443                    let (outer_xs_off, x_psb) = outer_xs_offs[idx];
19444                    let xb = x_psb as usize;
19445                    std::ptr::copy_nonoverlapping(
19446                        base.add(outer_xs_off + cur_iter * xb),
19447                        fwd_buf.as_mut_ptr().add(*fb_x_off),
19448                        xb,
19449                    );
19450                }
19451                execute_thunks(fwd_sched, fwd_buf);
19452                if fwd_output_off != fwd_carry_in_off {
19453                    fwd_buf.copy_within(fwd_output_off..fwd_output_off + cb, fwd_carry_in_off);
19454                }
19455                cache.extend_from_slice(&fwd_buf[fwd_carry_in_off..fwd_carry_in_off + cb]);
19456            }
19457            // Process backward.
19458            for t in (t_lo..=t_hi).rev() {
19459                let idx = t - t_lo;
19460                let carry = &cache[idx * cb..(idx + 1) * cb];
19461                process_iter(t, carry);
19462            }
19463            return;
19464        }
19465
19466        // Split: walk forward from anchor to compute carry entering `mid`.
19467        // (We need `mid - t_lo` body executions: one per iteration in
19468        // [t_lo, mid).)
19469        let mid = t_lo + size / 2;
19470        fwd_buf.copy_from_slice(fwd_init);
19471        std::ptr::copy_nonoverlapping(
19472            anchor_carry.as_ptr(),
19473            fwd_buf.as_mut_ptr().add(fwd_carry_in_off),
19474            cb,
19475        );
19476        for cur_iter in t_lo..mid {
19477            for (idx, fb_x_off) in fwd_x_offs.iter().enumerate() {
19478                let (outer_xs_off, x_psb) = outer_xs_offs[idx];
19479                let xb = x_psb as usize;
19480                std::ptr::copy_nonoverlapping(
19481                    base.add(outer_xs_off + cur_iter * xb),
19482                    fwd_buf.as_mut_ptr().add(*fb_x_off),
19483                    xb,
19484                );
19485            }
19486            execute_thunks(fwd_sched, fwd_buf);
19487            if fwd_output_off != fwd_carry_in_off {
19488                fwd_buf.copy_within(fwd_output_off..fwd_output_off + cb, fwd_carry_in_off);
19489            }
19490        }
19491        let mid_carry: Vec<u8> = fwd_buf[fwd_carry_in_off..fwd_carry_in_off + cb].to_vec();
19492
19493        // Right half first (higher t values processed first to match the
19494        // canonical reverse-mode iteration order: dcarry threads from
19495        // t=length-1 down to t=0).
19496        griewank_process_segment(
19497            mid,
19498            t_hi,
19499            &mid_carry,
19500            cb,
19501            fwd_sched,
19502            fwd_init,
19503            fwd_carry_in_off,
19504            fwd_output_off,
19505            fwd_x_offs,
19506            base,
19507            outer_xs_offs,
19508            fwd_buf,
19509            leaf_threshold,
19510            process_iter,
19511        );
19512        // Then left half with original anchor.
19513        griewank_process_segment(
19514            t_lo,
19515            mid - 1,
19516            anchor_carry,
19517            cb,
19518            fwd_sched,
19519            fwd_init,
19520            fwd_carry_in_off,
19521            fwd_output_off,
19522            fwd_x_offs,
19523            base,
19524            outer_xs_offs,
19525            fwd_buf,
19526            leaf_threshold,
19527            process_iter,
19528        );
19529    }
19530}
19531
19532/// Execute a batched 1D FFT in the f64 2N-real-block layout.
19533/// Each "row" is `2N` f64 elements: first `N` real, then `N` imag.
19534/// The `outer` rows are independent and processed sequentially.
19535///
19536/// Both forward and inverse use the same Cooley-Tukey radix-2 DIT
19537/// kernel — only the twiddle-factor sign differs. Power-of-2 only
19538/// (the IR builder rejects non-power-of-2 sizes at graph-build time).
19539/// Batched 1D FFT on the f64 2N-real-block layout. Public so other
19540/// backend crates can invoke this as a host fallback against a
19541/// unified-memory arena (e.g. rlx-metal: sync the command buffer,
19542/// pass the Metal `Buffer::contents()` pointer as `base`, restart the
19543/// command buffer). Self-contained — no rlx-cpu state required.
19544///
19545/// Safety: `base + src` and `base + dst` must be valid for the
19546/// `outer * 2 * n_complex * sizeof::<f64>()` byte range and stay
19547/// alive for the duration of the call.
19548pub unsafe fn execute_fft1d_f64(
19549    src: usize,
19550    dst: usize,
19551    outer: usize,
19552    n_complex: usize,
19553    inverse: bool,
19554    norm_tag: u32,
19555    base: *mut u8,
19556) {
19557    let row_elems = 2 * n_complex;
19558    let norm = rlx_ir::fft::FftNorm::from_tag(norm_tag);
19559    let scale = norm.output_scale(n_complex, inverse);
19560    let is_pow2 = n_complex.is_power_of_two();
19561    let use_naive = !is_pow2 && n_complex <= 16;
19562    let scratch_template = if is_pow2 || use_naive {
19563        BluesteinScratchF64::empty()
19564    } else {
19565        BluesteinScratchF64::build(n_complex, inverse)
19566    };
19567
19568    let run_row = |bp: *mut u8,
19569                   re: &mut [f64],
19570                   im: &mut [f64],
19571                   scratch: &mut BluesteinScratchF64,
19572                   o: usize| {
19573        let row_offset = src + o * row_elems * std::mem::size_of::<f64>();
19574        let s = unsafe { sl_f64(row_offset, bp, row_elems) };
19575        re.copy_from_slice(&s[..n_complex]);
19576        im.copy_from_slice(&s[n_complex..]);
19577        if is_pow2 {
19578            fft_radix2_inplace_f64(re, im, inverse);
19579        } else if use_naive {
19580            fft_naive_inplace_f64(re, im, inverse);
19581        } else {
19582            fft_bluestein_inplace_f64(re, im, inverse, scratch);
19583        }
19584        if scale != 1.0 {
19585            re.iter_mut().for_each(|v| *v *= scale);
19586            im.iter_mut().for_each(|v| *v *= scale);
19587        }
19588        let dst_offset = dst + o * row_elems * std::mem::size_of::<f64>();
19589        let d = unsafe { sl_mut_f64(dst_offset, bp, row_elems) };
19590        d[..n_complex].copy_from_slice(re);
19591        d[n_complex..].copy_from_slice(im);
19592    };
19593
19594    let parallel = outer >= 8
19595        && (outer as u64) * (n_complex as u64) >= (1 << 15)
19596        && cpu_fft_parallel_enabled();
19597    if parallel {
19598        use rayon::prelude::*;
19599        let p = FftArenaPtr(base);
19600        (0..outer).into_par_iter().for_each_init(
19601            || {
19602                (
19603                    vec![0f64; n_complex],
19604                    vec![0f64; n_complex],
19605                    scratch_template.clone(),
19606                )
19607            },
19608            move |(re, im, scratch), o| run_row(p.ptr(), re, im, scratch, o),
19609        );
19610    } else {
19611        let mut re = vec![0f64; n_complex];
19612        let mut im = vec![0f64; n_complex];
19613        let mut scratch = scratch_template;
19614        for o in 0..outer {
19615            run_row(base, &mut re, &mut im, &mut scratch, o);
19616        }
19617    }
19618}
19619
19620/// f32 counterpart of `execute_fft1d_f64`. Same 2N-real-block layout
19621/// (first N real, second N imag per row), same unnormalized
19622/// convention; only the element width differs. Twiddle factors are
19623/// computed in f64 and cast to f32 to keep large-N error closer to
19624/// the f64 path (the savings from f32 are in memory bandwidth, not in
19625/// twiddle precision).
19626/// Complex (C64) dense GEMM `C[m,n] = A[m,k] · B[k,n]`. Operands are
19627/// interleaved `[re, im]` f32; `a_off`/`b_off`/`c_off` are byte offsets
19628/// into `base`. Parallel over output rows (disjoint writes).
19629unsafe fn cgemm_c64(
19630    a_off: usize,
19631    b_off: usize,
19632    c_off: usize,
19633    m: usize,
19634    k: usize,
19635    n: usize,
19636    base: *mut u8,
19637) {
19638    let bptr = base as usize;
19639    unsafe {
19640        let a = std::slice::from_raw_parts((bptr + a_off) as *const f32, 2 * m * k);
19641        let b = std::slice::from_raw_parts((bptr + b_off) as *const f32, 2 * k * n);
19642        let c_base = bptr + c_off;
19643        crate::pool::par_range(m, |i| {
19644            let crow = std::slice::from_raw_parts_mut((c_base + i * n * 8) as *mut f32, 2 * n);
19645            for j in 0..n {
19646                let mut re = 0f32;
19647                let mut im = 0f32;
19648                for l in 0..k {
19649                    let ar = a[2 * (i * k + l)];
19650                    let ai = a[2 * (i * k + l) + 1];
19651                    let br = b[2 * (l * n + j)];
19652                    let bi = b[2 * (l * n + j) + 1];
19653                    re += ar * br - ai * bi;
19654                    im += ar * bi + ai * br;
19655                }
19656                crow[2 * j] = re;
19657                crow[2 * j + 1] = im;
19658            }
19659        });
19660    }
19661}
19662
19663/// Reference / host-fallback entry for `Op::Lstm` (multi-layer,
19664/// optionally bidirectional, optional decode carry). Gate order i, f, g,
19665/// o. Shared by the CPU backend and the CUDA / ROCm / wgpu / Metal host
19666/// fallbacks. Tensors are `f32` in the arena; weights are packed (see
19667/// `Op::Lstm`). `h0`/`c0` are byte offsets when `carry`, else ignored;
19668/// the final `hn`/`cn` are written back into them in place. `dst` is
19669/// `[batch, seq, D*hidden]`. Batch items run in parallel per direction.
19670#[allow(clippy::too_many_arguments)]
19671pub unsafe fn execute_lstm_f32(
19672    x: usize,
19673    w_ih: usize,
19674    w_hh: usize,
19675    bias: usize,
19676    h0: usize,
19677    c0: usize,
19678    dst: usize,
19679    batch: usize,
19680    seq: usize,
19681    input_size: usize,
19682    hidden: usize,
19683    num_layers: usize,
19684    bidirectional: bool,
19685    carry: bool,
19686    base: *mut u8,
19687) {
19688    #[inline]
19689    fn sigmoid(z: f32) -> f32 {
19690        1.0 / (1.0 + (-z).exp())
19691    }
19692
19693    let bptr = base as usize;
19694    let four_h = 4 * hidden;
19695    let dirs = if bidirectional { 2 } else { 1 };
19696
19697    unsafe {
19698        let f32s = |off: usize, n: usize| -> &[f32] {
19699            std::slice::from_raw_parts((bptr + off) as *const f32, n)
19700        };
19701
19702        // Layer 0 reads x; later layers read the previous layer's output.
19703        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
19704        let mut in_l = input_size;
19705        // Running element cursor into the packed `w_ih` buffer (block width
19706        // `4h * in_l` varies per layer; `w_hh`/`bias` blocks are uniform).
19707        let mut wih_cursor = 0usize;
19708
19709        for l in 0..num_layers {
19710            let out_width = dirs * hidden;
19711            let mut layer_out = vec![0f32; batch * seq * out_width];
19712            let lo_ptr = layer_out.as_mut_ptr() as usize;
19713            let li_ref: &[f32] = &layer_in;
19714            let wih_block = four_h * in_l;
19715
19716            for dir in 0..dirs {
19717                let ld = l * dirs + dir;
19718                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
19719                let whh = f32s(w_hh + ld * four_h * hidden * 4, four_h * hidden);
19720                let bs = f32s(bias + ld * four_h * 4, four_h);
19721                let h0p = bptr + h0 + ld * batch * hidden * 4;
19722                let c0p = bptr + c0 + ld * batch * hidden * 4;
19723
19724                crate::pool::par_range(batch, |b| {
19725                    let lo = lo_ptr as *mut f32;
19726                    let mut h = vec![0f32; hidden];
19727                    let mut c = vec![0f32; hidden];
19728                    if carry {
19729                        let hin = std::slice::from_raw_parts(
19730                            (h0p + b * hidden * 4) as *const f32,
19731                            hidden,
19732                        );
19733                        let cin = std::slice::from_raw_parts(
19734                            (c0p + b * hidden * 4) as *const f32,
19735                            hidden,
19736                        );
19737                        h.copy_from_slice(hin);
19738                        c.copy_from_slice(cin);
19739                    }
19740                    let mut z = vec![0f32; four_h];
19741                    for step in 0..seq {
19742                        let t = if dir == 0 { step } else { seq - 1 - step };
19743                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
19744                        for r in 0..four_h {
19745                            let wr = &wih[r * in_l..(r + 1) * in_l];
19746                            let mut acc = bs[r];
19747                            for j in 0..in_l {
19748                                acc += wr[j] * x_t[j];
19749                            }
19750                            let hr = &whh[r * hidden..(r + 1) * hidden];
19751                            for (j, &hj) in h.iter().enumerate() {
19752                                acc += hr[j] * hj;
19753                            }
19754                            z[r] = acc;
19755                        }
19756                        for k in 0..hidden {
19757                            let i_g = sigmoid(z[k]);
19758                            let f_g = sigmoid(z[hidden + k]);
19759                            let g_g = z[2 * hidden + k].tanh();
19760                            let o_g = sigmoid(z[3 * hidden + k]);
19761                            let c_new = f_g * c[k] + i_g * g_g;
19762                            c[k] = c_new;
19763                            let h_new = o_g * c_new.tanh();
19764                            h[k] = h_new;
19765                            // [batch, seq, D*hidden]; this direction owns the
19766                            // `dir*hidden .. dir*hidden+hidden` feature slice.
19767                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new;
19768                        }
19769                    }
19770                    if carry {
19771                        let hout = std::slice::from_raw_parts_mut(
19772                            (h0p + b * hidden * 4) as *mut f32,
19773                            hidden,
19774                        );
19775                        let cout = std::slice::from_raw_parts_mut(
19776                            (c0p + b * hidden * 4) as *mut f32,
19777                            hidden,
19778                        );
19779                        hout.copy_from_slice(&h);
19780                        cout.copy_from_slice(&c);
19781                    }
19782                });
19783            }
19784
19785            wih_cursor += dirs * wih_block;
19786            layer_in = layer_out;
19787            in_l = out_width;
19788        }
19789
19790        // Final layer output → dst [batch, seq, D*hidden].
19791        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
19792        dst_slice.copy_from_slice(&layer_in);
19793    }
19794}
19795
19796/// Reference / host-fallback for `Op::Gru` (PyTorch GRU; gate order r, z, n;
19797/// multi-layer / bidirectional / carry). Separate `b_ih`/`b_hh` because the
19798/// reset gate is applied to the hidden term *after* its bias:
19799/// `n = tanh(x·W_inᵀ+b_in + r ⊙ (h·W_hnᵀ+b_hn))`,
19800/// `h' = (1-z)⊙n + z⊙h`. Packing mirrors `Op::Lstm` with `3*hidden` gate rows.
19801#[allow(clippy::too_many_arguments)]
19802pub unsafe fn execute_gru_f32(
19803    x: usize,
19804    w_ih: usize,
19805    w_hh: usize,
19806    b_ih: usize,
19807    b_hh: usize,
19808    h0: usize,
19809    dst: usize,
19810    batch: usize,
19811    seq: usize,
19812    input_size: usize,
19813    hidden: usize,
19814    num_layers: usize,
19815    bidirectional: bool,
19816    carry: bool,
19817    base: *mut u8,
19818) {
19819    #[inline]
19820    fn sigmoid(z: f32) -> f32 {
19821        1.0 / (1.0 + (-z).exp())
19822    }
19823
19824    let bptr = base as usize;
19825    let three_h = 3 * hidden;
19826    let dirs = if bidirectional { 2 } else { 1 };
19827
19828    unsafe {
19829        let f32s = |off: usize, n: usize| -> &[f32] {
19830            std::slice::from_raw_parts((bptr + off) as *const f32, n)
19831        };
19832
19833        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
19834        let mut in_l = input_size;
19835        let mut wih_cursor = 0usize;
19836
19837        for l in 0..num_layers {
19838            let out_width = dirs * hidden;
19839            let mut layer_out = vec![0f32; batch * seq * out_width];
19840            let lo_ptr = layer_out.as_mut_ptr() as usize;
19841            let li_ref: &[f32] = &layer_in;
19842            let wih_block = three_h * in_l;
19843
19844            for dir in 0..dirs {
19845                let ld = l * dirs + dir;
19846                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
19847                let whh = f32s(w_hh + ld * three_h * hidden * 4, three_h * hidden);
19848                let bih = f32s(b_ih + ld * three_h * 4, three_h);
19849                let bhh = f32s(b_hh + ld * three_h * 4, three_h);
19850                let h0p = bptr + h0 + ld * batch * hidden * 4;
19851
19852                crate::pool::par_range(batch, |b| {
19853                    let lo = lo_ptr as *mut f32;
19854                    let mut h = vec![0f32; hidden];
19855                    if carry {
19856                        let hin = std::slice::from_raw_parts(
19857                            (h0p + b * hidden * 4) as *const f32,
19858                            hidden,
19859                        );
19860                        h.copy_from_slice(hin);
19861                    }
19862                    let mut xi = vec![0f32; three_h]; // x·W_ihᵀ + b_ih
19863                    let mut hi = vec![0f32; three_h]; // h·W_hhᵀ + b_hh
19864                    for step in 0..seq {
19865                        let t = if dir == 0 { step } else { seq - 1 - step };
19866                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
19867                        for r in 0..three_h {
19868                            let wr = &wih[r * in_l..(r + 1) * in_l];
19869                            let mut a = bih[r];
19870                            for j in 0..in_l {
19871                                a += wr[j] * x_t[j];
19872                            }
19873                            xi[r] = a;
19874                            let hr = &whh[r * hidden..(r + 1) * hidden];
19875                            let mut bb = bhh[r];
19876                            for (j, &hj) in h.iter().enumerate() {
19877                                bb += hr[j] * hj;
19878                            }
19879                            hi[r] = bb;
19880                        }
19881                        for k in 0..hidden {
19882                            let rg = sigmoid(xi[k] + hi[k]);
19883                            let zg = sigmoid(xi[hidden + k] + hi[hidden + k]);
19884                            // Reset applied to hidden term after its bias.
19885                            let ng = (xi[2 * hidden + k] + rg * hi[2 * hidden + k]).tanh();
19886                            let h_new = (1.0 - zg) * ng + zg * h[k];
19887                            h[k] = h_new;
19888                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new;
19889                        }
19890                    }
19891                    if carry {
19892                        let hout = std::slice::from_raw_parts_mut(
19893                            (h0p + b * hidden * 4) as *mut f32,
19894                            hidden,
19895                        );
19896                        hout.copy_from_slice(&h);
19897                    }
19898                });
19899            }
19900
19901            wih_cursor += dirs * wih_block;
19902            layer_in = layer_out;
19903            in_l = out_width;
19904        }
19905
19906        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
19907        dst_slice.copy_from_slice(&layer_in);
19908    }
19909}
19910
19911/// Reference / host-fallback for `Op::Rnn` (Elman; `act = relu` when `relu`
19912/// else `tanh`; multi-layer / bidirectional / carry). Single merged bias.
19913/// `h' = act(x·W_ihᵀ + h·W_hhᵀ + bias)`. Packing mirrors `Op::Lstm` with
19914/// `hidden` gate rows.
19915#[allow(clippy::too_many_arguments)]
19916pub unsafe fn execute_rnn_f32(
19917    x: usize,
19918    w_ih: usize,
19919    w_hh: usize,
19920    bias: usize,
19921    h0: usize,
19922    dst: usize,
19923    batch: usize,
19924    seq: usize,
19925    input_size: usize,
19926    hidden: usize,
19927    num_layers: usize,
19928    bidirectional: bool,
19929    carry: bool,
19930    relu: bool,
19931    base: *mut u8,
19932) {
19933    let bptr = base as usize;
19934    let dirs = if bidirectional { 2 } else { 1 };
19935
19936    unsafe {
19937        let f32s = |off: usize, n: usize| -> &[f32] {
19938            std::slice::from_raw_parts((bptr + off) as *const f32, n)
19939        };
19940
19941        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
19942        let mut in_l = input_size;
19943        let mut wih_cursor = 0usize;
19944
19945        for l in 0..num_layers {
19946            let out_width = dirs * hidden;
19947            let mut layer_out = vec![0f32; batch * seq * out_width];
19948            let lo_ptr = layer_out.as_mut_ptr() as usize;
19949            let li_ref: &[f32] = &layer_in;
19950            let wih_block = hidden * in_l;
19951
19952            for dir in 0..dirs {
19953                let ld = l * dirs + dir;
19954                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
19955                let whh = f32s(w_hh + ld * hidden * hidden * 4, hidden * hidden);
19956                let bs = f32s(bias + ld * hidden * 4, hidden);
19957                let h0p = bptr + h0 + ld * batch * hidden * 4;
19958
19959                crate::pool::par_range(batch, |b| {
19960                    let lo = lo_ptr as *mut f32;
19961                    let mut h = vec![0f32; hidden];
19962                    if carry {
19963                        let hin = std::slice::from_raw_parts(
19964                            (h0p + b * hidden * 4) as *const f32,
19965                            hidden,
19966                        );
19967                        h.copy_from_slice(hin);
19968                    }
19969                    for step in 0..seq {
19970                        let t = if dir == 0 { step } else { seq - 1 - step };
19971                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
19972                        let mut h_new = vec![0f32; hidden];
19973                        for k in 0..hidden {
19974                            let wr = &wih[k * in_l..(k + 1) * in_l];
19975                            let mut acc = bs[k];
19976                            for j in 0..in_l {
19977                                acc += wr[j] * x_t[j];
19978                            }
19979                            let hr = &whh[k * hidden..(k + 1) * hidden];
19980                            for (j, &hj) in h.iter().enumerate() {
19981                                acc += hr[j] * hj;
19982                            }
19983                            h_new[k] = if relu { acc.max(0.0) } else { acc.tanh() };
19984                        }
19985                        for k in 0..hidden {
19986                            h[k] = h_new[k];
19987                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new[k];
19988                        }
19989                    }
19990                    if carry {
19991                        let hout = std::slice::from_raw_parts_mut(
19992                            (h0p + b * hidden * 4) as *mut f32,
19993                            hidden,
19994                        );
19995                        hout.copy_from_slice(&h);
19996                    }
19997                });
19998            }
19999
20000            wih_cursor += dirs * wih_block;
20001            layer_in = layer_out;
20002            in_l = out_width;
20003        }
20004
20005        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
20006        dst_slice.copy_from_slice(&layer_in);
20007    }
20008}
20009
20010/// Reference for `Op::ArgMax`/`Op::ArgMin` (f32-encoded indices, first-hit
20011/// tie-break). Reduces the middle `reduced` axis: input is logically
20012/// `[outer, reduced, inner]`, output `[outer, inner]`. Shared by the CPU thunk
20013/// and the Metal/WGPU host paths.
20014pub unsafe fn execute_argreduce_f32(
20015    src: usize,
20016    dst: usize,
20017    outer: usize,
20018    reduced: usize,
20019    inner: usize,
20020    is_max: bool,
20021    base: *mut u8,
20022) {
20023    let bptr = base as usize;
20024    unsafe {
20025        let inp = std::slice::from_raw_parts((bptr + src) as *const f32, outer * reduced * inner);
20026        let out = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, outer * inner);
20027        for o in 0..outer {
20028            for i in 0..inner {
20029                let mut best = inp[o * reduced * inner + i];
20030                let mut best_idx = 0usize;
20031                for r in 1..reduced {
20032                    let v = inp[o * reduced * inner + r * inner + i];
20033                    let better = if is_max { v > best } else { v < best };
20034                    if better {
20035                        best = v;
20036                        best_idx = r;
20037                    }
20038                }
20039                out[o * inner + i] = best_idx as f32;
20040            }
20041        }
20042    }
20043}
20044
20045/// Reference for `Op::Mamba2` — SSD scalar-decay SSM scan. Inputs (f32):
20046/// `x [B,S,H,P]`, `dt [B,S,H]`, `a [H]`, `b/c [B,S,H,N]`. Per `(batch, head)`
20047/// the state `S[P,N]` is zero-init: `dA=exp(dt·a)`, `S=dA·S + (dt·x)⊗b`,
20048/// `y=Σ_n S[:,n]·c[n]`. Output `[B,S,H,P]`.
20049#[allow(clippy::too_many_arguments)]
20050pub unsafe fn execute_mamba2_f32(
20051    x: usize,
20052    dt: usize,
20053    a: usize,
20054    b: usize,
20055    c: usize,
20056    dst: usize,
20057    batch: usize,
20058    seq: usize,
20059    heads: usize,
20060    head_dim: usize,
20061    state_size: usize,
20062    base: *mut u8,
20063) {
20064    let (bn, s, h, p, n) = (batch, seq, heads, head_dim, state_size);
20065    let bptr = base as usize;
20066    unsafe {
20067        let f32s = |off: usize, len: usize| -> &[f32] {
20068            std::slice::from_raw_parts((bptr + off) as *const f32, len)
20069        };
20070        let xs = f32s(x, bn * s * h * p);
20071        let dts = f32s(dt, bn * s * h);
20072        let am = f32s(a, h);
20073        let bm = f32s(b, bn * s * h * n);
20074        let cm = f32s(c, bn * s * h * n);
20075        let out_ptr = bptr + dst;
20076
20077        // Independent per (batch, head).
20078        crate::pool::par_range(bn * h, |bh| {
20079            let bi = bh / h;
20080            let hi = bh % h;
20081            let out = out_ptr as *mut f32;
20082            let mut state = vec![0f32; p * n];
20083            for t in 0..s {
20084                let dt_t = dts[(bi * s + t) * h + hi];
20085                let da = (dt_t * am[hi]).exp();
20086                let x_off = ((bi * s + t) * h + hi) * p;
20087                let bc_off = ((bi * s + t) * h + hi) * n;
20088                for pi in 0..p {
20089                    let dtx = dt_t * xs[x_off + pi];
20090                    for ni in 0..n {
20091                        state[pi * n + ni] = da * state[pi * n + ni] + dtx * bm[bc_off + ni];
20092                    }
20093                }
20094                for pi in 0..p {
20095                    let mut acc = 0f32;
20096                    for ni in 0..n {
20097                        acc += state[pi * n + ni] * cm[bc_off + ni];
20098                    }
20099                    *out.add(x_off + pi) = acc;
20100                }
20101            }
20102        });
20103    }
20104}
20105
20106/// Host-fallback entry for `Op::GatedDeltaNet` (Metal / unified memory).
20107/// When `state == 0`, uses a zero-initialized scratch state per batch item.
20108/// Batch-general reverse/flip (dtype-agnostic, byte-copy). Shared by the CPU
20109/// `Thunk::Reverse` arm and the Metal/WGPU host paths. `dims` is the row-major
20110/// input shape; `rev_mask[a]` flips axis `a`. `src`/`dst` are byte offsets.
20111pub unsafe fn execute_reverse(
20112    src: usize,
20113    dst: usize,
20114    dims: &[u32],
20115    rev_mask: &[bool],
20116    elem_bytes: usize,
20117    base: *mut u8,
20118) {
20119    let rank = dims.len();
20120    let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
20121    let mut strides = vec![1usize; rank];
20122    for i in (0..rank.saturating_sub(1)).rev() {
20123        strides[i] = strides[i + 1] * dims[i + 1] as usize;
20124    }
20125    unsafe {
20126        let src_base = base.add(src);
20127        let dst_base = base.add(dst);
20128        for o in 0..total {
20129            let mut rem = o;
20130            let mut in_flat = 0usize;
20131            for ax in 0..rank {
20132                let idx = rem / strides[ax];
20133                rem %= strides[ax];
20134                let in_idx = if rev_mask[ax] {
20135                    dims[ax] as usize - 1 - idx
20136                } else {
20137                    idx
20138                };
20139                in_flat += in_idx * strides[ax];
20140            }
20141            std::ptr::copy_nonoverlapping(
20142                src_base.add(in_flat * elem_bytes),
20143                dst_base.add(o * elem_bytes),
20144                elem_bytes,
20145            );
20146        }
20147    }
20148}
20149
20150/// Token-sampling reference. Shared by the CPU `Thunk::Sample` arm and the
20151/// Metal unified-memory host fallback. `logits` is `[batch, vocab]` f32;
20152/// writes one f32-encoded index per batch row to `dst`. With a fixed `seed`
20153/// (and same top_k/top_p/temperature) the result is deterministic.
20154pub unsafe fn execute_sample_f32(
20155    logits: usize,
20156    dst: usize,
20157    batch: usize,
20158    vocab: usize,
20159    top_k: usize,
20160    top_p: f32,
20161    temperature: f32,
20162    seed: u64,
20163    base: *mut u8,
20164) {
20165    let (b, v) = (batch, vocab);
20166    let k = top_k.min(v);
20167    unsafe {
20168        let lg = sl(logits, base, b * v);
20169        let out = sl_mut(dst, base, b);
20170        let mut rng = rlx_ir::Philox4x32::new(if seed == 0 { 0xDEADBEEF } else { seed });
20171        for bi in 0..b {
20172            let row = &lg[bi * v..(bi + 1) * v];
20173            out[bi] = sample_row(row, k, top_p, temperature, &mut rng) as f32;
20174        }
20175    }
20176}
20177
20178/// Mamba selective-scan reference (f32). Shared by the CPU `Thunk::SelectiveScan`
20179/// arm and the Metal unified-memory host fallback. `base` is the arena byte
20180/// pointer; the five inputs (`x`, `delta`, `a`, `b`, `c`) and `dst` are byte
20181/// offsets into it. Recurrence per `(batch, seq, channel, state)`:
20182/// `h[t] = exp(Δ·A)·h[t-1] + Δ·B·x;  y = Σ_n C·h`. State resets per batch row.
20183pub unsafe fn execute_selective_scan_f32(
20184    x: usize,
20185    delta: usize,
20186    a: usize,
20187    b: usize,
20188    c: usize,
20189    dst: usize,
20190    batch: usize,
20191    seq: usize,
20192    hidden: usize,
20193    state_size: usize,
20194    base: *mut u8,
20195) {
20196    let (bn, s, h, n) = (batch, seq, hidden, state_size);
20197    unsafe {
20198        let xs = sl(x, base, bn * s * h);
20199        let dt = sl(delta, base, bn * s * h);
20200        let am = sl(a, base, h * n);
20201        let bm = sl(b, base, bn * s * n);
20202        let cm = sl(c, base, bn * s * n);
20203        let out = sl_mut(dst, base, bn * s * h);
20204
20205        // State buffer per-batch: h channels × n state. Sequential along
20206        // the seq dimension; reset at the start of each batch row.
20207        let mut state = vec![0f32; h * n];
20208        for bi in 0..bn {
20209            for v in state.iter_mut() {
20210                *v = 0.0;
20211            }
20212            for si in 0..s {
20213                let x_row = &xs[bi * s * h + si * h..bi * s * h + (si + 1) * h];
20214                let dt_row = &dt[bi * s * h + si * h..bi * s * h + (si + 1) * h];
20215                let b_row = &bm[bi * s * n + si * n..bi * s * n + (si + 1) * n];
20216                let c_row = &cm[bi * s * n + si * n..bi * s * n + (si + 1) * n];
20217                let out_row = &mut out[bi * s * h + si * h..bi * s * h + (si + 1) * h];
20218
20219                for ci in 0..h {
20220                    let d = dt_row[ci];
20221                    let xv = x_row[ci];
20222                    let mut acc = 0f32;
20223                    for ni in 0..n {
20224                        // Discretize: exp(d * a) and d * b.
20225                        let da = (d * am[ci * n + ni]).exp();
20226                        state[ci * n + ni] = da * state[ci * n + ni] + d * b_row[ni] * xv;
20227                        acc += c_row[ni] * state[ci * n + ni];
20228                    }
20229                    out_row[ci] = acc;
20230                }
20231            }
20232        }
20233    }
20234}
20235
20236pub unsafe fn execute_gated_delta_net_f32(
20237    q: usize,
20238    k: usize,
20239    v: usize,
20240    g: usize,
20241    beta: usize,
20242    state: usize,
20243    dst: usize,
20244    batch: usize,
20245    seq: usize,
20246    heads: usize,
20247    state_size: usize,
20248    base: *mut u8,
20249) {
20250    #[derive(Copy, Clone)]
20251    struct ArenaPtr(usize);
20252    unsafe impl Send for ArenaPtr {}
20253    unsafe impl Sync for ArenaPtr {}
20254    impl ArenaPtr {
20255        #[inline]
20256        fn get(self) -> *mut u8 {
20257            self.0 as *mut u8
20258        }
20259    }
20260
20261    unsafe {
20262        let arena = ArenaPtr(base as usize);
20263        let (b, s, h, n) = (batch, seq, heads, state_size);
20264        let scale = 1.0f32 / (n as f32).sqrt();
20265        let use_external = state != 0;
20266        let mut owned_state = vec![0f32; h * n * n];
20267
20268        crate::pool::num_threads();
20269
20270        assert!(
20271            n <= crate::gdn::GDN_MAX_STATE,
20272            "GatedDeltaNet state_size={n} exceeds stack scratch ({})",
20273            crate::gdn::GDN_MAX_STATE
20274        );
20275
20276        let qs = sl(q, arena.get(), b * s * h * n);
20277        let ks = sl(k, arena.get(), b * s * h * n);
20278        let vs = sl(v, arena.get(), b * s * h * n);
20279        let gs = sl(g, arena.get(), b * s * h);
20280        let betas = sl(beta, arena.get(), b * s * h);
20281        let _out = sl_mut(dst, arena.get(), b * s * h * n);
20282        let hs_n = h * n;
20283
20284        let run_head = |bi: usize, hi: usize, s_mat: &mut [f32], sk: &mut [f32]| {
20285            for ti in 0..s {
20286                let qkv_step = bi * s * hs_n + ti * hs_n + hi * n;
20287                let gb_step = bi * s * h + ti * h + hi;
20288                let out_row = sl_mut(dst + qkv_step * std::mem::size_of::<f32>(), arena.get(), n);
20289                crate::gdn::gdn_step_blas(
20290                    s_mat,
20291                    &qs[qkv_step..qkv_step + n],
20292                    &ks[qkv_step..qkv_step + n],
20293                    &vs[qkv_step..qkv_step + n],
20294                    gs[gb_step],
20295                    betas[gb_step],
20296                    out_row,
20297                    sk,
20298                    n,
20299                    scale,
20300                );
20301            }
20302        };
20303
20304        // Prefill (seq>1, ephemeral state): time-outer, parallel over heads —
20305        // better occupancy than head-outer when prompt length dominates.
20306        if !use_external && s > 1 {
20307            for bi in 0..b {
20308                crate::pool::par_range(h, |hi| {
20309                    let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
20310                    let sk = &mut sk_buf[..n];
20311                    let mut local_state =
20312                        [0f32; crate::gdn::GDN_MAX_STATE * crate::gdn::GDN_MAX_STATE];
20313                    let s_mat = &mut local_state[..n * n];
20314                    s_mat.fill(0.0);
20315                    run_head(bi, hi, s_mat, sk);
20316                });
20317            }
20318            return;
20319        }
20320
20321        if use_external {
20322            let state_bytes = state;
20323            crate::pool::par_range(b * h, |bhi| {
20324                let bi = bhi / h;
20325                let hi = bhi % h;
20326                let elem_off = bi * h * n * n + hi * n * n;
20327                let s_mat = sl_mut(
20328                    state_bytes + elem_off * std::mem::size_of::<f32>(),
20329                    arena.get(),
20330                    n * n,
20331                );
20332                let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
20333                run_head(bi, hi, s_mat, &mut sk_buf[..n]);
20334            });
20335        } else {
20336            for bi in 0..b {
20337                owned_state.fill(0.0);
20338                #[cfg(not(target_arch = "wasm32"))]
20339                {
20340                    use rayon::prelude::*;
20341                    owned_state
20342                        .par_chunks_mut(n * n)
20343                        .enumerate()
20344                        .for_each(|(hi, s_mat)| {
20345                            let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
20346                            run_head(bi, hi, s_mat, &mut sk_buf[..n]);
20347                        });
20348                }
20349                #[cfg(target_arch = "wasm32")]
20350                {
20351                    owned_state
20352                        .chunks_mut(n * n)
20353                        .enumerate()
20354                        .for_each(|(hi, s_mat)| {
20355                            let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
20356                            run_head(bi, hi, s_mat, &mut sk_buf[..n]);
20357                        });
20358                }
20359            }
20360        }
20361    }
20362}
20363
20364/// Host-fallback: `Op::RmsNormBackwardInput` (GPU unified-memory / D2H arenas).
20365pub unsafe fn execute_rms_norm_backward_input_f32(
20366    x: usize,
20367    gamma: usize,
20368    beta: usize,
20369    dy: usize,
20370    dx: usize,
20371    rows: u32,
20372    h: u32,
20373    eps: f32,
20374    base: *mut u8,
20375) {
20376    let (rows, h) = (rows as usize, h as usize);
20377    let mut dg = vec![0f32; h];
20378    let mut db = vec![0f32; h];
20379    let xs = sl(x, base, rows * h);
20380    let dys = sl(dy, base, rows * h);
20381    let g = sl(gamma, base, h);
20382    let b = sl(beta, base, h);
20383    let out = sl_mut(dx, base, rows * h);
20384    for r in 0..rows {
20385        crate::training_bwd::rms_norm_backward_row(
20386            &xs[r * h..(r + 1) * h],
20387            g,
20388            b,
20389            &dys[r * h..(r + 1) * h],
20390            &mut out[r * h..(r + 1) * h],
20391            &mut dg,
20392            &mut db,
20393            eps,
20394        );
20395    }
20396}
20397
20398pub unsafe fn execute_rms_norm_backward_gamma_f32(
20399    x: usize,
20400    gamma: usize,
20401    beta: usize,
20402    dy: usize,
20403    dgamma: usize,
20404    rows: u32,
20405    h: u32,
20406    eps: f32,
20407    base: *mut u8,
20408) {
20409    let (rows, h) = (rows as usize, h as usize);
20410    let out = sl_mut(dgamma, base, h);
20411    out.fill(0.0);
20412    let mut dx = vec![0f32; h];
20413    let mut db = vec![0f32; h];
20414    let xs = sl(x, base, rows * h);
20415    let dys = sl(dy, base, rows * h);
20416    let g = sl(gamma, base, h);
20417    let b = sl(beta, base, h);
20418    for r in 0..rows {
20419        crate::training_bwd::rms_norm_backward_row(
20420            &xs[r * h..(r + 1) * h],
20421            g,
20422            b,
20423            &dys[r * h..(r + 1) * h],
20424            &mut dx,
20425            out,
20426            &mut db,
20427            eps,
20428        );
20429    }
20430}
20431
20432pub unsafe fn execute_rms_norm_backward_beta_f32(
20433    x: usize,
20434    gamma: usize,
20435    beta: usize,
20436    dy: usize,
20437    dbeta: usize,
20438    rows: u32,
20439    h: u32,
20440    eps: f32,
20441    base: *mut u8,
20442) {
20443    let (rows, h) = (rows as usize, h as usize);
20444    let out = sl_mut(dbeta, base, h);
20445    out.fill(0.0);
20446    let mut dx = vec![0f32; h];
20447    let mut dg = vec![0f32; h];
20448    let xs = sl(x, base, rows * h);
20449    let dys = sl(dy, base, rows * h);
20450    let g = sl(gamma, base, h);
20451    let b = sl(beta, base, h);
20452    for r in 0..rows {
20453        crate::training_bwd::rms_norm_backward_row(
20454            &xs[r * h..(r + 1) * h],
20455            g,
20456            b,
20457            &dys[r * h..(r + 1) * h],
20458            &mut dx,
20459            &mut dg,
20460            out,
20461            eps,
20462        );
20463    }
20464}
20465
20466#[allow(clippy::too_many_arguments)]
20467pub unsafe fn execute_conv2d_forward_f32(
20468    src: usize,
20469    weight: usize,
20470    dst: usize,
20471    n: u32,
20472    c_in: u32,
20473    h: u32,
20474    w: u32,
20475    c_out: u32,
20476    h_out: u32,
20477    w_out: u32,
20478    kh: u32,
20479    kw: u32,
20480    sh: u32,
20481    sw: u32,
20482    ph: u32,
20483    pw: u32,
20484    dh: u32,
20485    dw: u32,
20486    groups: u32,
20487    base: *mut u8,
20488) {
20489    let n = n as usize;
20490    let c_in = c_in as usize;
20491    let h = h as usize;
20492    let w = w as usize;
20493    let c_out = c_out as usize;
20494    let h_out = h_out as usize;
20495    let w_out = w_out as usize;
20496    let kh = kh as usize;
20497    let kw = kw as usize;
20498    let sh = sh as usize;
20499    let sw = sw as usize;
20500    let ph = ph as usize;
20501    let pw = pw as usize;
20502    let dh = dh as usize;
20503    let dw = dw as usize;
20504    let groups = groups as usize;
20505    let c_in_per_g = c_in / groups;
20506    let inp = sl(src, base, n * c_in * h * w);
20507    let wt = sl(weight, base, c_out * c_in_per_g * kh * kw);
20508    let out = sl_mut(dst, base, n * c_out * h_out * w_out);
20509    crate::conv_fwd::conv2d_forward_nchw_f32(
20510        inp, wt, out, n, c_in, h, w, c_out, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw, groups,
20511    );
20512}
20513
20514/// Host / interpreter kernel for NCDHW `Thunk::Conv3d`. Slices `src`/`weight`
20515/// out of the arena and runs the naive 3-D forward conv. Mirrors
20516/// [`execute_conv2d_forward_f32`] with a depth axis.
20517#[allow(clippy::too_many_arguments)]
20518pub unsafe fn execute_conv3d_forward_f32(
20519    src: usize,
20520    weight: usize,
20521    dst: usize,
20522    n: u32,
20523    c_in: u32,
20524    d: u32,
20525    h: u32,
20526    w: u32,
20527    c_out: u32,
20528    d_out: u32,
20529    h_out: u32,
20530    w_out: u32,
20531    kd: u32,
20532    kh: u32,
20533    kw: u32,
20534    sd: u32,
20535    sh: u32,
20536    sw: u32,
20537    pd: u32,
20538    ph: u32,
20539    pw: u32,
20540    dd: u32,
20541    dh: u32,
20542    dw: u32,
20543    groups: u32,
20544    base: *mut u8,
20545) {
20546    let n = n as usize;
20547    let c_in = c_in as usize;
20548    let d = d as usize;
20549    let h = h as usize;
20550    let w = w as usize;
20551    let c_out = c_out as usize;
20552    let d_out = d_out as usize;
20553    let h_out = h_out as usize;
20554    let w_out = w_out as usize;
20555    let kd = kd as usize;
20556    let kh = kh as usize;
20557    let kw = kw as usize;
20558    let sd = sd as usize;
20559    let sh = sh as usize;
20560    let sw = sw as usize;
20561    let pd = pd as usize;
20562    let ph = ph as usize;
20563    let pw = pw as usize;
20564    let dd = dd as usize;
20565    let dh = dh as usize;
20566    let dw = dw as usize;
20567    let groups = groups as usize;
20568    let c_in_per_g = c_in / groups;
20569    let inp = sl(src, base, n * c_in * d * h * w);
20570    let wt = sl(weight, base, c_out * c_in_per_g * kd * kh * kw);
20571    let out = sl_mut(dst, base, n * c_out * d_out * h_out * w_out);
20572    crate::conv_fwd::conv3d_forward_ncdhw_f32(
20573        inp, wt, out, n, c_in, d, h, w, c_out, d_out, h_out, w_out, kd, kh, kw, sd, sh, sw, pd, ph,
20574        pw, dd, dh, dw, groups,
20575    );
20576}
20577
20578/// Host / interpreter kernel for NCDHW `Thunk::ConvTranspose3d`. Mirrors
20579/// [`execute_conv_transpose2d_nchw_f32`] with a depth axis.
20580#[allow(clippy::too_many_arguments)]
20581pub unsafe fn execute_conv_transpose3d_ncdhw_f32(
20582    src: usize,
20583    weight: usize,
20584    dst: usize,
20585    n: u32,
20586    c_in: u32,
20587    d: u32,
20588    h: u32,
20589    w_in: u32,
20590    c_out: u32,
20591    d_out: u32,
20592    h_out: u32,
20593    w_out: u32,
20594    kd: u32,
20595    kh: u32,
20596    kw: u32,
20597    sd: u32,
20598    sh: u32,
20599    sw: u32,
20600    pd: u32,
20601    ph: u32,
20602    pw: u32,
20603    dd: u32,
20604    dh: u32,
20605    dw: u32,
20606    groups: u32,
20607    base: *mut u8,
20608) {
20609    let n = n as usize;
20610    let c_in = c_in as usize;
20611    let d = d as usize;
20612    let h = h as usize;
20613    let w_in = w_in as usize;
20614    let c_out = c_out as usize;
20615    let d_out = d_out as usize;
20616    let h_out = h_out as usize;
20617    let w_out = w_out as usize;
20618    let kd = kd as usize;
20619    let kh = kh as usize;
20620    let kw = kw as usize;
20621    let sd = sd as usize;
20622    let sh = sh as usize;
20623    let sw = sw as usize;
20624    let pd = pd as usize;
20625    let ph = ph as usize;
20626    let pw = pw as usize;
20627    let dd = dd as usize;
20628    let dh = dh as usize;
20629    let dw = dw as usize;
20630    let groups = groups as usize;
20631    let in_elems = n * c_in * d * h * w_in;
20632    let w_elems = c_in * (c_out / groups) * kd * kh * kw;
20633    let out_elems = n * c_out * d_out * h_out * w_out;
20634    let input = sl(src, base, in_elems);
20635    let wt = sl(weight, base, w_elems);
20636    let output = sl_mut(dst, base, out_elems);
20637    crate::kernels::conv_transpose3d_ncdhw(
20638        input, wt, output, n, c_in, d, h, w_in, c_out, d_out, h_out, w_out, kd, kh, kw, sd, sh, sw,
20639        pd, ph, pw, dd, dh, dw, groups,
20640    );
20641}
20642
20643pub unsafe fn execute_maxpool2d_backward_f32(
20644    x: usize,
20645    dy: usize,
20646    dx: usize,
20647    n: u32,
20648    c: u32,
20649    h: u32,
20650    w: u32,
20651    h_out: u32,
20652    w_out: u32,
20653    kh: u32,
20654    kw: u32,
20655    sh: u32,
20656    sw: u32,
20657    ph: u32,
20658    pw: u32,
20659    base: *mut u8,
20660) {
20661    let (n, c, h, w) = (n as usize, c as usize, h as usize, w as usize);
20662    let (h_out, w_out) = (h_out as usize, w_out as usize);
20663    let (kh, kw) = (kh as usize, kw as usize);
20664    let (sh, sw) = (sh as usize, sw as usize);
20665    let (ph, pw) = (ph as usize, pw as usize);
20666    let xs = sl(x, base, n * c * h * w);
20667    let dys = sl(dy, base, n * c * h_out * w_out);
20668    let dxs = sl_mut(dx, base, n * c * h * w);
20669    // Each (n, c) plane is independent — under RLX_FAST_CONV, fan the existing
20670    // per-plane kernel out over the channel-batch (disjoint dx windows).
20671    if fast_conv_enabled() && crate::pool::should_parallelize(n * c * h * w) {
20672        let (in_plane, out_plane) = (h * w, h_out * w_out);
20673        let x_addr = xs.as_ptr() as usize;
20674        let dy_addr = dys.as_ptr() as usize;
20675        let dx_addr = dxs.as_mut_ptr() as usize;
20676        crate::pool::par_for(n * c, crate::pool::outer_chunk(n * c), &|off, cnt| {
20677            for nc in off..off + cnt {
20678                let xp =
20679                    std::slice::from_raw_parts((x_addr as *const f32).add(nc * in_plane), in_plane);
20680                let dyp = std::slice::from_raw_parts(
20681                    (dy_addr as *const f32).add(nc * out_plane),
20682                    out_plane,
20683                );
20684                let dxp = std::slice::from_raw_parts_mut(
20685                    (dx_addr as *mut f32).add(nc * in_plane),
20686                    in_plane,
20687                );
20688                crate::training_bwd::maxpool2d_backward_nchw(
20689                    xp, dyp, dxp, 1, 1, h, w, h_out, w_out, kh, kw, sh, sw, ph, pw,
20690                );
20691            }
20692        });
20693    } else {
20694        crate::training_bwd::maxpool2d_backward_nchw(
20695            xs, dys, dxs, n, c, h, w, h_out, w_out, kh, kw, sh, sw, ph, pw,
20696        );
20697    }
20698}
20699
20700pub unsafe fn execute_rope_backward_f32(
20701    dy: usize,
20702    cos: usize,
20703    sin: usize,
20704    dx: usize,
20705    batch: u32,
20706    seq: u32,
20707    hidden: u32,
20708    head_dim: u32,
20709    n_rot: u32,
20710    cos_len: u32,
20711    base: *mut u8,
20712) {
20713    let (b, s, hs, dh, nr, cl) = (
20714        batch as usize,
20715        seq as usize,
20716        hidden as usize,
20717        head_dim as usize,
20718        n_rot as usize,
20719        cos_len as usize,
20720    );
20721    let nh = hs / dh;
20722    let tab_half = dh / 2;
20723    let dys = sl(dy, base, b * s * hs);
20724    let cos_tab = sl(cos, base, cl);
20725    let sin_tab = sl(sin, base, cl);
20726    let out = sl_mut(dx, base, b * s * hs);
20727    for bi in 0..b {
20728        for si in 0..s {
20729            let tab_off = si.saturating_mul(tab_half) % cl.max(1);
20730            let cp = &cos_tab[tab_off..tab_off + tab_half.min(cl)];
20731            let sp = &sin_tab[tab_off..tab_off + tab_half.min(cl)];
20732            for hi in 0..nh {
20733                let base_idx = bi * s * hs + si * hs + hi * dh;
20734                crate::training_bwd::rope_backward_row(
20735                    &dys[base_idx..base_idx + dh],
20736                    cp,
20737                    sp,
20738                    &mut out[base_idx..base_idx + dh],
20739                    dh,
20740                    nr,
20741                );
20742            }
20743        }
20744    }
20745}
20746
20747pub unsafe fn execute_cumsum_backward_f32(
20748    dy: usize,
20749    dx: usize,
20750    rows: u32,
20751    cols: u32,
20752    exclusive: bool,
20753    base: *mut u8,
20754) {
20755    let (rows, cols) = (rows as usize, cols as usize);
20756    let dys = sl(dy, base, rows * cols);
20757    let out = sl_mut(dx, base, rows * cols);
20758    for r in 0..rows {
20759        crate::training_bwd::cumsum_backward_row(
20760            &dys[r * cols..(r + 1) * cols],
20761            &mut out[r * cols..(r + 1) * cols],
20762            exclusive,
20763        );
20764    }
20765}
20766
20767pub unsafe fn execute_gather_backward_f32(
20768    dy: usize,
20769    indices: usize,
20770    dst: usize,
20771    outer: u32,
20772    axis_dim: u32,
20773    num_idx: u32,
20774    trailing: u32,
20775    base: *mut u8,
20776) {
20777    let (outer, axis_dim, num_idx, trailing) = (
20778        outer as usize,
20779        axis_dim as usize,
20780        num_idx as usize,
20781        trailing as usize,
20782    );
20783    let out = sl_mut(dst, base, outer * axis_dim * trailing);
20784    out.fill(0.0);
20785    crate::training_bwd::gather_axis_backward(
20786        sl(dy, base, outer * num_idx * trailing),
20787        sl(indices, base, num_idx),
20788        out,
20789        outer,
20790        axis_dim,
20791        num_idx,
20792        trailing,
20793    );
20794}
20795
20796/// Host-fallback entry for GGUF `Op::DequantMatMul` (Metal unified memory).
20797pub unsafe fn execute_dequant_matmul_gguf_f32(
20798    x: usize,
20799    w_q: usize,
20800    dst: usize,
20801    m: usize,
20802    k: usize,
20803    n: usize,
20804    scheme: rlx_ir::quant::QuantScheme,
20805    base: *mut u8,
20806) {
20807    unsafe {
20808        let block_bytes = scheme.gguf_block_bytes() as usize;
20809        let block_elems = scheme.gguf_block_size() as usize;
20810        let total_bytes = (k * n) / block_elems * block_bytes;
20811        let xs = sl(x, base, m * k);
20812        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, total_bytes);
20813        let out = sl_mut(dst, base, m * n);
20814        crate::gguf_matmul::gguf_matmul_bt_dispatch(xs, w_bytes, out, m, k, n, scheme);
20815    }
20816}
20817
20818/// Host-fallback entry for GGUF `Op::DequantGroupedMatMul` (MoE expert stack).
20819pub unsafe fn execute_dequant_grouped_matmul_gguf_f32(
20820    input: usize,
20821    w_q: usize,
20822    expert_idx: usize,
20823    dst: usize,
20824    m: usize,
20825    k: usize,
20826    n: usize,
20827    num_experts: usize,
20828    scheme: rlx_ir::quant::QuantScheme,
20829    base: *mut u8,
20830) {
20831    unsafe {
20832        let block_bytes = scheme.gguf_block_bytes() as usize;
20833        let block_elems = scheme.gguf_block_size() as usize;
20834        let slab_bytes = (k * n) / block_elems * block_bytes;
20835        let xs = sl(input, base, m * k);
20836        let w_bytes =
20837            std::slice::from_raw_parts(base.add(w_q) as *const u8, num_experts * slab_bytes);
20838        let ids = sl(expert_idx, base, m);
20839        let out = sl_mut(dst, base, m * n);
20840        crate::gguf_matmul::gguf_grouped_matmul_bt(
20841            xs,
20842            w_bytes,
20843            ids,
20844            out,
20845            m,
20846            k,
20847            n,
20848            num_experts,
20849            scheme,
20850        );
20851    }
20852}
20853
20854/// Host-fallback entry for Int8 `Op::DequantMatMul` (Metal unified memory).
20855pub unsafe fn execute_dequant_matmul_int8_f32(
20856    x: usize,
20857    w_q: usize,
20858    scale: usize,
20859    zp: usize,
20860    dst: usize,
20861    m: usize,
20862    k: usize,
20863    n: usize,
20864    block_size: u32,
20865    is_asymmetric: bool,
20866    base: *mut u8,
20867) {
20868    let bs = block_size as usize;
20869    let n_blocks = k.div_ceil(bs);
20870    unsafe {
20871        let xs = sl(x, base, m * k);
20872        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const i8, k * n);
20873        let scales = sl(scale, base, n_blocks * n);
20874        let zps = if is_asymmetric {
20875            sl(zp, base, n_blocks * n)
20876        } else {
20877            &[][..]
20878        };
20879        let out = sl_mut(dst, base, m * n);
20880        dequant_matmul_int8(xs, w_bytes, scales, zps, out, m, k, n, bs, is_asymmetric);
20881    }
20882}
20883
20884/// Host-fallback entry for Int4 `Op::DequantMatMul` (Metal unified memory).
20885pub unsafe fn execute_dequant_matmul_int4_f32(
20886    x: usize,
20887    w_q: usize,
20888    scale: usize,
20889    zp: usize,
20890    dst: usize,
20891    m: usize,
20892    k: usize,
20893    n: usize,
20894    block_size: u32,
20895    is_asymmetric: bool,
20896    base: *mut u8,
20897) {
20898    let bs = block_size as usize;
20899    let n_blocks = k.div_ceil(bs);
20900    unsafe {
20901        let xs = sl(x, base, m * k);
20902        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, (k * n).div_ceil(2));
20903        let scales = sl(scale, base, n_blocks * n);
20904        let zps = if is_asymmetric {
20905            sl(zp, base, n_blocks * n)
20906        } else {
20907            &[][..]
20908        };
20909        let out = sl_mut(dst, base, m * n);
20910        dequant_matmul_int4(xs, w_bytes, scales, zps, out, m, k, n, bs, is_asymmetric);
20911    }
20912}
20913
20914/// Host-fallback entry for FP8 `Op::DequantMatMul` (Metal unified memory).
20915pub unsafe fn execute_dequant_matmul_fp8_f32(
20916    x: usize,
20917    w_q: usize,
20918    scale: usize,
20919    dst: usize,
20920    m: usize,
20921    k: usize,
20922    n: usize,
20923    e5m2: bool,
20924    base: *mut u8,
20925) {
20926    unsafe {
20927        let xs = sl(x, base, m * k);
20928        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, k * n);
20929        let scales = sl(scale, base, n);
20930        let out = sl_mut(dst, base, m * n);
20931        dequant_matmul_fp8(xs, w_bytes, scales, out, m, k, n, e5m2);
20932    }
20933}
20934
20935/// Host-fallback entry for NVFP4 `Op::DequantMatMul` (Metal unified memory).
20936pub unsafe fn execute_dequant_matmul_nvfp4_f32(
20937    x: usize,
20938    w_q: usize,
20939    scale: usize,
20940    global_scale: usize,
20941    dst: usize,
20942    m: usize,
20943    k: usize,
20944    n: usize,
20945    base: *mut u8,
20946) {
20947    let n_scale = k.div_ceil(rlx_ir::NVFP4_GROUP_SIZE) * n;
20948    unsafe {
20949        let xs = sl(x, base, m * k);
20950        let w_bytes = std::slice::from_raw_parts(base.add(w_q) as *const u8, (k * n).div_ceil(2));
20951        let scale_bytes = std::slice::from_raw_parts(base.add(scale) as *const u8, n_scale);
20952        let gs = sl(global_scale, base, 1)[0];
20953        let out = sl_mut(dst, base, m * n);
20954        dequant_matmul_nvfp4(xs, w_bytes, scale_bytes, gs, out, m, k, n);
20955    }
20956}
20957
20958// ── Native low-precision ScaledMatMul host fallbacks (unified-memory) ──
20959// Reuse the CPU oracle kernels so Metal (no FP8 matrix HW) runs the exact same
20960// decode-and-accumulate reference. TN layout: lhs [m,k], rhs [n,k].
20961
20962/// Host fallback for `Op::ScaledQuantScale`. Byte offsets into `base`.
20963pub unsafe fn execute_scaled_quant_scale_f32(
20964    x: usize,
20965    dst: usize,
20966    rows: usize,
20967    cols: usize,
20968    fmt: rlx_ir::ScaledFormat,
20969    layout: rlx_ir::ScaleLayout,
20970    base: *mut u8,
20971) {
20972    unsafe {
20973        let xs = sl(x, base, rows * cols);
20974        let scales = lowp_compute_scales(xs, fmt, layout, rows, cols);
20975        let nblk = lowp_nblk(cols, layout);
20976        match layout {
20977            rlx_ir::ScaleLayout::PerTensor => {
20978                sl_mut(dst, base, 1)[0] = scales[0];
20979            }
20980            rlx_ir::ScaleLayout::BlockMxE8M0 { .. } => {
20981                let out = std::slice::from_raw_parts_mut(base.add(dst), rows * nblk);
20982                for (o, &s) in out.iter_mut().zip(&scales) {
20983                    *o = rlx_ir::lowp_codec::f32_to_e8m0(s);
20984                }
20985            }
20986            rlx_ir::ScaleLayout::Nvfp4 { .. } => {
20987                let out = std::slice::from_raw_parts_mut(base.add(dst), rows * nblk);
20988                for (o, &s) in out.iter_mut().zip(&scales) {
20989                    *o = rlx_ir::lowp_codec::encode(rlx_ir::ScaledFormat::F8E4M3, s);
20990                }
20991            }
20992        }
20993    }
20994}
20995
20996/// Host fallback for `Op::ScaledQuantize`. Byte offsets into `base`.
20997#[allow(clippy::too_many_arguments)]
20998pub unsafe fn execute_scaled_quantize_f32(
20999    x: usize,
21000    scale: usize,
21001    dst: usize,
21002    rows: usize,
21003    cols: usize,
21004    fmt: rlx_ir::ScaledFormat,
21005    layout: rlx_ir::ScaleLayout,
21006    base: *mut u8,
21007) {
21008    unsafe {
21009        let xs = sl(x, base, rows * cols);
21010        let nblk = lowp_nblk(cols, layout);
21011        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
21012            1
21013        } else {
21014            rows * nblk
21015        };
21016        let scales = lowp_read_scales(layout, base, scale, n_scale);
21017        let out = std::slice::from_raw_parts_mut(base.add(dst), rows * cols);
21018        lowp_quantize(xs, &scales, fmt, layout, rows, cols, out);
21019    }
21020}
21021
21022/// Host fallback for `Op::ScaledDequantize` — packed codes (`U8`) → f32, the
21023/// inverse of [`execute_scaled_quantize_f32`]. Byte offsets into `base`.
21024#[allow(clippy::too_many_arguments)]
21025pub unsafe fn execute_scaled_dequantize_f32(
21026    codes: usize,
21027    scale: usize,
21028    dst: usize,
21029    rows: usize,
21030    cols: usize,
21031    fmt: rlx_ir::ScaledFormat,
21032    layout: rlx_ir::ScaleLayout,
21033    base: *mut u8,
21034) {
21035    unsafe {
21036        let nblk = lowp_nblk(cols, layout);
21037        let n_scale = if matches!(layout, rlx_ir::ScaleLayout::PerTensor) {
21038            1
21039        } else {
21040            rows * nblk
21041        };
21042        let cs = std::slice::from_raw_parts(base.add(codes), rows * cols);
21043        let scales = lowp_read_scales(layout, base, scale, n_scale);
21044        let out = std::slice::from_raw_parts_mut(base.add(dst) as *mut f32, rows * cols);
21045        lowp_dequantize(cs, &scales, fmt, layout, rows, cols, out);
21046    }
21047}
21048
21049/// Host fallback for `Op::ScaledMatMul` (TN). Byte offsets into `base`.
21050#[allow(clippy::too_many_arguments)]
21051pub unsafe fn execute_scaled_matmul_f32(
21052    lhs: usize,
21053    rhs: usize,
21054    lhs_scale: usize,
21055    rhs_scale: usize,
21056    bias: usize,
21057    dst: usize,
21058    m: usize,
21059    k: usize,
21060    n: usize,
21061    has_bias: bool,
21062    lhs_fmt: rlx_ir::ScaledFormat,
21063    rhs_fmt: rlx_ir::ScaledFormat,
21064    layout: rlx_ir::ScaleLayout,
21065    base: *mut u8,
21066) {
21067    unsafe {
21068        let lhs_b = std::slice::from_raw_parts(base.add(lhs), m * k);
21069        let rhs_b = std::slice::from_raw_parts(base.add(rhs), n * k);
21070        let nblk = lowp_nblk(k, layout);
21071        let per_tensor = matches!(layout, rlx_ir::ScaleLayout::PerTensor);
21072        let n_l = if per_tensor { 1 } else { m * nblk };
21073        let n_r = if per_tensor { 1 } else { n * nblk };
21074        let ls = lowp_read_scales(layout, base, lhs_scale, n_l);
21075        let rs = lowp_read_scales(layout, base, rhs_scale, n_r);
21076        let bias_s = if has_bias {
21077            Some(sl(bias, base, n))
21078        } else {
21079            None
21080        };
21081        let out = sl_mut(dst, base, m * n);
21082        lowp_scaled_matmul(
21083            lhs_b, rhs_b, &ls, &rs, bias_s, out, m, n, k, layout, lhs_fmt, rhs_fmt,
21084        );
21085    }
21086}
21087
21088/// Host-fallback entry for f16 `Op::GatedDeltaNet` tensors on Metal.
21089pub unsafe fn execute_gated_delta_net_f16(
21090    q: usize,
21091    k: usize,
21092    v: usize,
21093    g: usize,
21094    beta: usize,
21095    state: usize,
21096    dst: usize,
21097    batch: usize,
21098    seq: usize,
21099    heads: usize,
21100    state_size: usize,
21101    base: *mut u8,
21102) {
21103    use half::f16;
21104    unsafe {
21105        let read_f16 = |off: usize, len: usize| -> Vec<f32> {
21106            let raw = std::slice::from_raw_parts(base.add(off) as *const u8, len * 2);
21107            raw.chunks_exact(2)
21108                .map(|c| f16::from_le_bytes([c[0], c[1]]).to_f32())
21109                .collect()
21110        };
21111        let write_f16 = |off: usize, data: &[f32]| {
21112            let out = std::slice::from_raw_parts_mut(base.add(off), data.len() * 2);
21113            for (i, &v) in data.iter().enumerate() {
21114                let le = f16::from_f32(v).to_le_bytes();
21115                out[i * 2] = le[0];
21116                out[i * 2 + 1] = le[1];
21117            }
21118        };
21119
21120        let (b, s, h, n) = (batch, seq, heads, state_size);
21121        let q_f = read_f16(q, b * s * h * n);
21122        let k_f = read_f16(k, b * s * h * n);
21123        let v_f = read_f16(v, b * s * h * n);
21124        let g_f = read_f16(g, b * s * h);
21125        let b_f = read_f16(beta, b * s * h);
21126        let mut state_f = if state != 0 {
21127            read_f16(state, b * h * n * n)
21128        } else {
21129            vec![0f32; b * h * n * n]
21130        };
21131        let mut out_f = vec![0f32; b * s * h * n];
21132        let scale = 1.0f32 / (n as f32).sqrt();
21133        let mut sk_buf = vec![0f32; n];
21134        let mut owned_state = vec![0f32; h * n * n];
21135
21136        for bi in 0..b {
21137            let state_slice: &mut [f32] = if state != 0 {
21138                let start = bi * h * n * n;
21139                &mut state_f[start..start + h * n * n]
21140            } else {
21141                owned_state.fill(0.0);
21142                &mut owned_state
21143            };
21144
21145            for ti in 0..s {
21146                let qkv_step_base = bi * s * h * n + ti * h * n;
21147                let gb_step_base = bi * s * h + ti * h;
21148
21149                for hi in 0..h {
21150                    let q_row = &q_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
21151                    let k_row = &k_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
21152                    let v_row = &v_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
21153                    let g_t = g_f[gb_step_base + hi];
21154                    let beta_t = b_f[gb_step_base + hi];
21155
21156                    let s_base = hi * n * n;
21157                    let s_mat = &mut state_slice[s_base..s_base + n * n];
21158
21159                    let g_exp = g_t.exp();
21160                    for st in s_mat.iter_mut() {
21161                        *st *= g_exp;
21162                    }
21163
21164                    for j in 0..n {
21165                        let mut acc = 0f32;
21166                        for i in 0..n {
21167                            acc += s_mat[i * n + j] * k_row[i];
21168                        }
21169                        sk_buf[j] = acc;
21170                    }
21171
21172                    for j in 0..n {
21173                        sk_buf[j] = (v_row[j] - sk_buf[j]) * beta_t;
21174                    }
21175
21176                    for i in 0..n {
21177                        let ki = k_row[i];
21178                        for j in 0..n {
21179                            s_mat[i * n + j] += ki * sk_buf[j];
21180                        }
21181                    }
21182
21183                    let out_row = &mut out_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
21184                    for j in 0..n {
21185                        let mut acc = 0f32;
21186                        for i in 0..n {
21187                            acc += s_mat[i * n + j] * q_row[i];
21188                        }
21189                        out_row[j] = acc * scale;
21190                    }
21191                }
21192            }
21193        }
21194
21195        write_f16(dst, &out_f);
21196        if state != 0 {
21197            write_f16(state, &state_f);
21198        }
21199    }
21200}
21201
21202/// Host fallback for NCHW group norm (Metal unified-memory arena).
21203pub unsafe fn execute_group_norm_nchw_f32(
21204    src: usize,
21205    g: usize,
21206    b: usize,
21207    dst: usize,
21208    n: usize,
21209    c: usize,
21210    h: usize,
21211    w: usize,
21212    num_groups: usize,
21213    eps: f32,
21214    base: *mut u8,
21215) {
21216    let plane = c * h * w;
21217    for ni in 0..n {
21218        let input = unsafe { sl(src + ni * plane * std::mem::size_of::<f32>(), base, plane) };
21219        let gamma = unsafe { sl(g, base, c) };
21220        let beta = unsafe { sl(b, base, c) };
21221        let output = unsafe { sl_mut(dst + ni * plane * std::mem::size_of::<f32>(), base, plane) };
21222        crate::kernels::group_norm_nchw(input, gamma, beta, output, 1, c, h, w, num_groups, eps);
21223    }
21224}
21225
21226/// Host fallback for NCHW LayerNorm2d (SAM / candle semantics).
21227pub unsafe fn execute_layer_norm2d_nchw_f32(
21228    src: usize,
21229    g: usize,
21230    b: usize,
21231    dst: usize,
21232    n: usize,
21233    c: usize,
21234    h: usize,
21235    w: usize,
21236    eps: f32,
21237    base: *mut u8,
21238) {
21239    let plane = c * h * w;
21240    unsafe {
21241        let input = sl(src, base, n * plane);
21242        let gamma = sl(g, base, c);
21243        let beta = sl(b, base, c);
21244        let output = sl_mut(dst, base, n * plane);
21245        crate::kernels::layer_norm2d_nchw(input, gamma, beta, output, n, c, h, w, eps);
21246    }
21247}
21248
21249/// Host fallback for NCHW ConvTranspose2d.
21250pub unsafe fn execute_conv_transpose2d_nchw_f32(
21251    src: usize,
21252    weight: usize,
21253    dst: usize,
21254    n: usize,
21255    c_in: usize,
21256    h: usize,
21257    w_in: usize,
21258    c_out: usize,
21259    h_out: usize,
21260    w_out: usize,
21261    kh: usize,
21262    kw: usize,
21263    sh: usize,
21264    sw: usize,
21265    ph: usize,
21266    pw: usize,
21267    dh: usize,
21268    dw: usize,
21269    groups: usize,
21270    base: *mut u8,
21271) {
21272    let in_elems = n * c_in * h * w_in;
21273    let w_elems = c_in * (c_out / groups) * kh * kw;
21274    let out_elems = n * c_out * h_out * w_out;
21275    unsafe {
21276        let input = sl(src, base, in_elems);
21277        let wt = sl(weight, base, w_elems);
21278        let output = sl_mut(dst, base, out_elems);
21279        crate::kernels::conv_transpose2d_nchw(
21280            input, wt, output, n, c_in, h, w_in, c_out, h_out, w_out, kh, kw, sh, sw, ph, pw, dh,
21281            dw, groups,
21282        );
21283    }
21284}
21285
21286/// Host fallback for nearest 2× upsample on NCHW.
21287pub unsafe fn execute_resize_nearest_2x_f32(
21288    src: usize,
21289    dst: usize,
21290    n: usize,
21291    c: usize,
21292    h: usize,
21293    w: usize,
21294    base: *mut u8,
21295) {
21296    let in_plane = c * h * w;
21297    let out_plane = c * h * 2 * w * 2;
21298    for ni in 0..n {
21299        let input = unsafe {
21300            sl(
21301                src + ni * in_plane * std::mem::size_of::<f32>(),
21302                base,
21303                in_plane,
21304            )
21305        };
21306        let output = unsafe {
21307            sl_mut(
21308                dst + ni * out_plane * std::mem::size_of::<f32>(),
21309                base,
21310                out_plane,
21311            )
21312        };
21313        crate::kernels::resize_nearest_2x_nchw(input, output, c, h, w);
21314    }
21315}
21316
21317/// Host axial 2-D RoPE for Metal (and other) fallbacks on unified memory.
21318pub unsafe fn execute_axial_rope2d_f32(
21319    src: usize,
21320    dst: usize,
21321    batch: usize,
21322    seq: usize,
21323    hidden: usize,
21324    end_x: usize,
21325    end_y: usize,
21326    head_dim: usize,
21327    num_heads: usize,
21328    theta: f32,
21329    repeat_factor: usize,
21330    base: *mut u8,
21331) {
21332    let plane = seq * hidden;
21333    let plane_bytes = plane * std::mem::size_of::<f32>();
21334    for bi in 0..batch {
21335        let in_off = src + bi * plane_bytes;
21336        let input = unsafe { sl(in_off, base, plane) };
21337        let rotated = rlx_ir::ops::axial_rope2d::apply_axial_rope2d(
21338            input,
21339            num_heads,
21340            seq,
21341            head_dim,
21342            end_x,
21343            end_y,
21344            theta,
21345            repeat_factor,
21346        );
21347        let out_off = dst + bi * plane_bytes;
21348        let output = unsafe { sl_mut(out_off, base, plane) };
21349        output.copy_from_slice(&rotated);
21350    }
21351}
21352
21353/// Ternary pruned radix-2 butterfly stage on `[batch, n_fft, 2]` interleaved state.
21354pub unsafe fn execute_fft_butterfly_stage_f32(
21355    state_src: usize,
21356    state_dst: usize,
21357    gate_src: usize,
21358    rev_src: usize,
21359    tw_re_src: usize,
21360    tw_im_src: usize,
21361    batch: usize,
21362    n_fft: usize,
21363    stage: usize,
21364    base: *mut u8,
21365) {
21366    let half = n_fft / 2;
21367    let stride = 1usize << stage;
21368    let gate = unsafe { sl(gate_src, base, half) };
21369    let rev = unsafe { sl(rev_src, base, half) };
21370    let tw_re = unsafe { sl(tw_re_src, base, half) };
21371    let tw_im = unsafe { sl(tw_im_src, base, half) };
21372    let row_elems = n_fft * 2;
21373    for b in 0..batch {
21374        let in_off = state_src + b * row_elems * std::mem::size_of::<f32>();
21375        let out_off = state_dst + b * row_elems * std::mem::size_of::<f32>();
21376        let inp = unsafe { sl(in_off, base, row_elems) };
21377        let out = unsafe { sl_mut(out_off, base, row_elems) };
21378        out.copy_from_slice(inp);
21379        for bf in 0..half {
21380            if gate[bf] == 0.0 {
21381                continue;
21382            }
21383            let group = bf / stride;
21384            let k = bf % stride;
21385            let i0 = group * 2 * stride + k;
21386            let i1 = i0 + stride;
21387            let w_re = tw_re[bf];
21388            let w_im = tw_im[bf];
21389            let in_a_re = inp[i0 * 2];
21390            let in_a_im = inp[i0 * 2 + 1];
21391            let in_b_re = inp[i1 * 2];
21392            let in_b_im = inp[i1 * 2 + 1];
21393            let (b_re, b_im) = (
21394                in_b_re * w_re - in_b_im * w_im,
21395                in_b_re * w_im + in_b_im * w_re,
21396            );
21397            let (top_re, top_im) = (in_a_re + b_re, in_a_im + b_im);
21398            let (bot_re, bot_im) = (in_a_re - b_re, in_a_im - b_im);
21399            let (oa_re, oa_im, ob_re, ob_im) = if rev[bf] >= 0.5 {
21400                (bot_re, bot_im, top_re, top_im)
21401            } else {
21402                (top_re, top_im, bot_re, bot_im)
21403            };
21404            out[i0 * 2] = oa_re;
21405            out[i0 * 2 + 1] = oa_im;
21406            out[i1 * 2] = ob_re;
21407            out[i1 * 2 + 1] = ob_im;
21408        }
21409    }
21410}
21411
21412/// f32 mirror of `execute_fft1d_f64`. Same public-host-fallback role.
21413/// native FFT batch loop: rayon-parallelize rows unless `RLX_FFT_CPU_PARALLEL`
21414/// is `0`/`off`/`false`. Default on.
21415fn cpu_fft_parallel_enabled() -> bool {
21416    !rlx_ir::env::var("RLX_FFT_CPU_PARALLEL").is_some_and(|v| {
21417        v == "0" || v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("false")
21418    })
21419}
21420
21421/// Radix-4 CPU FFT for pure powers of four unless `RLX_FFT_RADIX4` is
21422/// `0`/`off`/`false`. Default on.
21423fn cpu_fft_radix4_enabled() -> bool {
21424    !rlx_ir::env::var("RLX_FFT_RADIX4").is_some_and(|v| {
21425        v == "0" || v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("false")
21426    })
21427}
21428
21429/// Arena base pointer wrapper that is `Send`/`Sync` so the FFT batch loop can
21430/// hand it to rayon workers. Safe because every row `o` reads and writes a
21431/// disjoint `row_elems`-wide slice of the arena (`src`/`dst` are either equal or
21432/// non-overlapping regions), so concurrent rows never touch the same bytes.
21433#[derive(Clone, Copy)]
21434struct FftArenaPtr(*mut u8);
21435// SAFETY: see FftArenaPtr doc — per-row slices are disjoint.
21436unsafe impl Send for FftArenaPtr {}
21437unsafe impl Sync for FftArenaPtr {}
21438impl FftArenaPtr {
21439    /// Take by `self` so closures capture the whole (Send/Sync) wrapper rather
21440    /// than the bare `*mut u8` field (edition-2021 disjoint closure capture).
21441    #[inline]
21442    fn ptr(self) -> *mut u8 {
21443        self.0
21444    }
21445}
21446
21447pub unsafe fn execute_fft1d_f32(
21448    src: usize,
21449    dst: usize,
21450    outer: usize,
21451    n_complex: usize,
21452    inverse: bool,
21453    norm_tag: u32,
21454    base: *mut u8,
21455) {
21456    let row_elems = 2 * n_complex;
21457    let norm = rlx_ir::fft::FftNorm::from_tag(norm_tag);
21458    let scale = norm.output_scale(n_complex, inverse) as f32;
21459    let is_pow2 = n_complex.is_power_of_two();
21460    let use_naive = !is_pow2 && n_complex <= 16;
21461    // Pure powers of four take the radix-4 path (fewer stages); `RLX_FFT_RADIX4=0`
21462    // forces radix-2 (A/B benchmarking). n<16 stays radix-2 (win negligible).
21463    let use_radix4 = is_pow2 && n_complex >= 16 && is_pow4(n_complex) && cpu_fft_radix4_enabled();
21464    let scratch_template = if is_pow2 || use_naive {
21465        BluesteinScratchF32::empty()
21466    } else {
21467        BluesteinScratchF32::build(n_complex, inverse)
21468    };
21469
21470    // One independent FFT per row. Writes to `re`/`im` scratch then to the row's
21471    // arena slice; safe to run rows concurrently (see FftArenaPtr).
21472    let run_row = |bp: *mut u8,
21473                   re: &mut [f32],
21474                   im: &mut [f32],
21475                   scratch: &mut BluesteinScratchF32,
21476                   o: usize| {
21477        let row_offset = src + o * row_elems * std::mem::size_of::<f32>();
21478        let s = unsafe { sl(row_offset, bp, row_elems) };
21479        re.copy_from_slice(&s[..n_complex]);
21480        im.copy_from_slice(&s[n_complex..]);
21481        if use_radix4 {
21482            fft_radix4_inplace_f32(re, im, inverse);
21483        } else if is_pow2 {
21484            fft_radix2_inplace_f32(re, im, inverse);
21485        } else if use_naive {
21486            fft_naive_inplace_f32(re, im, inverse);
21487        } else {
21488            fft_bluestein_inplace_f32(re, im, inverse, scratch);
21489        }
21490        if scale != 1.0 {
21491            re.iter_mut().for_each(|v| *v *= scale);
21492            im.iter_mut().for_each(|v| *v *= scale);
21493        }
21494        let dst_offset = dst + o * row_elems * std::mem::size_of::<f32>();
21495        let d = unsafe { sl_mut(dst_offset, bp, row_elems) };
21496        d[..n_complex].copy_from_slice(re);
21497        d[n_complex..].copy_from_slice(im);
21498    };
21499
21500    // Rows are independent — parallelize across the batch once there's enough
21501    // work to amortize rayon's dispatch overhead. `for_each_init` builds the
21502    // re/im scratch and a scratch-template clone once per worker, not per row.
21503    // `RLX_FFT_CPU_PARALLEL=0` forces serial (A/B benchmarking / escape hatch).
21504    // Threshold: measured break-even is ~outer·n ≈ 2^15 (bench_fft_cpu_parallel);
21505    // below it rayon dispatch dominates and parallel regresses (e.g. 0.5× at
21506    // n=256 batch=16), so gate on both a row floor and total work.
21507    let parallel = outer >= 8
21508        && (outer as u64) * (n_complex as u64) >= (1 << 15)
21509        && cpu_fft_parallel_enabled();
21510    if parallel {
21511        use rayon::prelude::*;
21512        let p = FftArenaPtr(base);
21513        (0..outer).into_par_iter().for_each_init(
21514            || {
21515                (
21516                    vec![0f32; n_complex],
21517                    vec![0f32; n_complex],
21518                    scratch_template.clone(),
21519                )
21520            },
21521            move |(re, im, scratch), o| run_row(p.ptr(), re, im, scratch, o),
21522        );
21523    } else {
21524        let mut re = vec![0f32; n_complex];
21525        let mut im = vec![0f32; n_complex];
21526        let mut scratch = scratch_template;
21527        for o in 0..outer {
21528            run_row(base, &mut re, &mut im, &mut scratch, o);
21529        }
21530    }
21531}
21532
21533/// C64 interleaved layout: each complex element is `[re: f32, im: f32]`.
21534pub unsafe fn execute_fft1d_c64(
21535    src: usize,
21536    dst: usize,
21537    outer: usize,
21538    n_complex: usize,
21539    inverse: bool,
21540    norm_tag: u32,
21541    base: *mut u8,
21542) {
21543    let row_bytes = n_complex * 8;
21544    let norm = rlx_ir::fft::FftNorm::from_tag(norm_tag);
21545    let scale = norm.output_scale(n_complex, inverse) as f32;
21546    let is_pow2 = n_complex.is_power_of_two();
21547    let use_naive = !is_pow2 && n_complex <= 16;
21548    let scratch_template = if is_pow2 || use_naive {
21549        BluesteinScratchF32::empty()
21550    } else {
21551        BluesteinScratchF32::build(n_complex, inverse)
21552    };
21553
21554    // One independent FFT per row (interleaved [re,im] complex layout).
21555    let run_row = |bp: *mut u8,
21556                   re: &mut [f32],
21557                   im: &mut [f32],
21558                   scratch: &mut BluesteinScratchF32,
21559                   o: usize| {
21560        let row_offset = src + o * row_bytes;
21561        for i in 0..n_complex {
21562            let elem_off = row_offset + i * 8;
21563            re[i] = f32::from_le_bytes([
21564                unsafe { *bp.add(elem_off) },
21565                unsafe { *bp.add(elem_off + 1) },
21566                unsafe { *bp.add(elem_off + 2) },
21567                unsafe { *bp.add(elem_off + 3) },
21568            ]);
21569            im[i] = f32::from_le_bytes([
21570                unsafe { *bp.add(elem_off + 4) },
21571                unsafe { *bp.add(elem_off + 5) },
21572                unsafe { *bp.add(elem_off + 6) },
21573                unsafe { *bp.add(elem_off + 7) },
21574            ]);
21575        }
21576        if is_pow2 {
21577            fft_radix2_inplace_f32(re, im, inverse);
21578        } else if use_naive {
21579            fft_naive_inplace_f32(re, im, inverse);
21580        } else {
21581            fft_bluestein_inplace_f32(re, im, inverse, scratch);
21582        }
21583        if scale != 1.0 {
21584            re.iter_mut().for_each(|v| *v *= scale);
21585            im.iter_mut().for_each(|v| *v *= scale);
21586        }
21587        let dst_row = dst + o * row_bytes;
21588        for i in 0..n_complex {
21589            let elem_off = dst_row + i * 8;
21590            let re_b = re[i].to_le_bytes();
21591            let im_b = im[i].to_le_bytes();
21592            for j in 0..4 {
21593                unsafe { *bp.add(elem_off + j) = re_b[j] };
21594                unsafe { *bp.add(elem_off + 4 + j) = im_b[j] };
21595            }
21596        }
21597    };
21598
21599    let parallel = outer >= 8
21600        && (outer as u64) * (n_complex as u64) >= (1 << 15)
21601        && cpu_fft_parallel_enabled();
21602    if parallel {
21603        use rayon::prelude::*;
21604        let p = FftArenaPtr(base);
21605        (0..outer).into_par_iter().for_each_init(
21606            || {
21607                (
21608                    vec![0f32; n_complex],
21609                    vec![0f32; n_complex],
21610                    scratch_template.clone(),
21611                )
21612            },
21613            move |(re, im, scratch), o| run_row(p.ptr(), re, im, scratch, o),
21614        );
21615    } else {
21616        let mut re = vec![0f32; n_complex];
21617        let mut im = vec![0f32; n_complex];
21618        let mut scratch = scratch_template;
21619        for o in 0..outer {
21620            run_row(base, &mut re, &mut im, &mut scratch, o);
21621        }
21622    }
21623}
21624
21625/// Dtype-dispatching host entry for `Op::LogMel` (shared by GPU host fallbacks).
21626pub unsafe fn execute_log_mel(
21627    spec: usize,
21628    filters: usize,
21629    dst: usize,
21630    outer: usize,
21631    n_fft: usize,
21632    n_bins: usize,
21633    n_mels: usize,
21634    base: *mut u8,
21635) {
21636    execute_log_mel_f32(spec, filters, dst, outer, n_fft, n_bins, n_mels, base);
21637}
21638
21639pub unsafe fn execute_log_mel_f32(
21640    spec: usize,
21641    filters: usize,
21642    dst: usize,
21643    outer: usize,
21644    n_fft: usize,
21645    n_bins: usize,
21646    n_mels: usize,
21647    base: *mut u8,
21648) {
21649    let spec_ptr = base.add(spec) as *const f32;
21650    let filt_ptr = base.add(filters) as *const f32;
21651    let dst_ptr = base.add(dst) as *mut f32;
21652    let spec = std::slice::from_raw_parts(spec_ptr, outer * n_fft * 2);
21653    let filters = std::slice::from_raw_parts(filt_ptr, n_mels * n_bins);
21654    let out = std::slice::from_raw_parts_mut(dst_ptr, outer * n_mels);
21655    rlx_ir::audio::log_mel_block_f32(spec, filters, outer, n_fft, n_bins, n_mels, out);
21656}
21657
21658pub unsafe fn execute_welch_peaks_f32(
21659    spec: usize,
21660    dst: usize,
21661    welch_batch: usize,
21662    n_fft: usize,
21663    n_segments: usize,
21664    k: usize,
21665    base: *mut u8,
21666) {
21667    let spec_ptr = base.add(spec) as *const f32;
21668    let dst_ptr = base.add(dst) as *mut f32;
21669    let outer = welch_batch * n_segments;
21670    let spec = std::slice::from_raw_parts(spec_ptr, outer * n_fft * 2);
21671    let out = std::slice::from_raw_parts_mut(dst_ptr, welch_batch * k * 2);
21672    rlx_ir::audio::welch_peaks_block_f32(spec, welch_batch, n_fft, n_segments, k, out);
21673}
21674
21675pub unsafe fn execute_log_mel_backward_f32(
21676    spec: usize,
21677    filters: usize,
21678    dy: usize,
21679    dst: usize,
21680    outer: usize,
21681    n_fft: usize,
21682    n_bins: usize,
21683    n_mels: usize,
21684    base: *mut u8,
21685) {
21686    let spec_ptr = base.add(spec) as *const f32;
21687    let filt_ptr = base.add(filters) as *const f32;
21688    let dy_ptr = base.add(dy) as *const f32;
21689    let dst_ptr = base.add(dst) as *mut f32;
21690    let spec = std::slice::from_raw_parts(spec_ptr, outer * n_fft * 2);
21691    let filters = std::slice::from_raw_parts(filt_ptr, n_mels * n_bins);
21692    let dy = std::slice::from_raw_parts(dy_ptr, outer * n_mels);
21693    let d_spec = std::slice::from_raw_parts_mut(dst_ptr, outer * n_fft * 2);
21694    d_spec.fill(0.0);
21695    rlx_ir::audio::log_mel_block_vjp(spec, filters, dy, outer, n_fft, n_bins, n_mels, d_spec);
21696}
21697
21698/// Dtype-dispatching host entry for `Op::Fft` (shared by GPU host fallbacks).
21699pub unsafe fn execute_fft1d(
21700    src: usize,
21701    dst: usize,
21702    outer: usize,
21703    n_complex: usize,
21704    inverse: bool,
21705    norm_tag: u32,
21706    dtype: rlx_ir::DType,
21707    base: *mut u8,
21708) {
21709    match dtype {
21710        rlx_ir::DType::F32 => {
21711            execute_fft1d_f32(src, dst, outer, n_complex, inverse, norm_tag, base)
21712        }
21713        rlx_ir::DType::F64 => {
21714            execute_fft1d_f64(src, dst, outer, n_complex, inverse, norm_tag, base)
21715        }
21716        rlx_ir::DType::C64 => {
21717            execute_fft1d_c64(src, dst, outer, n_complex, inverse, norm_tag, base)
21718        }
21719        other => panic!("execute_fft1d: unsupported dtype {other:?}"),
21720    }
21721}
21722
21723/// f32 in-place radix-2 DIT Cooley-Tukey. Structurally identical to
21724/// the f64 path; twiddle recurrence is kept in f64 so accumulated
21725/// rotation drift doesn't dominate the per-stage error budget at
21726/// larger N.
21727fn fft_radix2_inplace_f32(re: &mut [f32], im: &mut [f32], inverse: bool) {
21728    let n = re.len();
21729    debug_assert_eq!(im.len(), n);
21730    debug_assert!(
21731        n.is_power_of_two(),
21732        "fft_radix2_f32: n={n} must be a power of two"
21733    );
21734    if n <= 1 {
21735        return;
21736    }
21737
21738    let mut j = 0usize;
21739    for i in 1..n {
21740        let mut bit = n >> 1;
21741        while j & bit != 0 {
21742            j ^= bit;
21743            bit >>= 1;
21744        }
21745        j ^= bit;
21746        if i < j {
21747            re.swap(i, j);
21748            im.swap(i, j);
21749        }
21750    }
21751
21752    let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
21753    let mut len = 2usize;
21754    while len <= n {
21755        let half = len / 2;
21756        let theta = sign * 2.0 * std::f64::consts::PI / (len as f64);
21757        let w_re_step = theta.cos();
21758        let w_im_step = theta.sin();
21759        let mut i = 0usize;
21760        while i < n {
21761            let mut wre = 1.0_f64;
21762            let mut wim = 0.0_f64;
21763            for k in 0..half {
21764                let wre_f = wre as f32;
21765                let wim_f = wim as f32;
21766                let t_re = wre_f * re[i + k + half] - wim_f * im[i + k + half];
21767                let t_im = wre_f * im[i + k + half] + wim_f * re[i + k + half];
21768                let u_re = re[i + k];
21769                let u_im = im[i + k];
21770                re[i + k] = u_re + t_re;
21771                im[i + k] = u_im + t_im;
21772                re[i + k + half] = u_re - t_re;
21773                im[i + k + half] = u_im - t_im;
21774                let new_wre = wre * w_re_step - wim * w_im_step;
21775                let new_wim = wre * w_im_step + wim * w_re_step;
21776                wre = new_wre;
21777                wim = new_wim;
21778            }
21779            i += len;
21780        }
21781        len <<= 1;
21782    }
21783}
21784
21785/// True for a *pure* power of four (log2 even): 4, 16, 64, 256, 1024, 4096, …
21786/// These take the radix-4 path; other pow-2 sizes (2·4^k) stay on radix-2.
21787#[inline]
21788fn is_pow4(n: usize) -> bool {
21789    n.is_power_of_two() && n.trailing_zeros().is_multiple_of(2)
21790}
21791
21792/// In-place radix-4 DIT FFT on split (re, im) f32 arrays for `n = 4^m`.
21793/// Half the stages of radix-2 (log4 vs log2) → half the array sweeps and
21794/// twiddle-recurrence iterations, a per-row win that compounds with the
21795/// rayon batch parallelism. Twiddles run in f64 (as in the radix-2 path).
21796/// Forward ω = exp(-2πi/n); inverse ω = exp(+2πi/n), no 1/N scale.
21797fn fft_radix4_inplace_f32(re: &mut [f32], im: &mut [f32], inverse: bool) {
21798    let n = re.len();
21799    debug_assert_eq!(im.len(), n);
21800    debug_assert!(is_pow4(n), "fft_radix4_f32: n={n} must be a power of four");
21801    if n <= 1 {
21802        return;
21803    }
21804    let m = n.trailing_zeros() / 2; // number of base-4 digits
21805
21806    // Base-4 digit-reversal permutation.
21807    for i in 0..n {
21808        let mut x = i;
21809        let mut r = 0usize;
21810        for _ in 0..m {
21811            r = (r << 2) | (x & 3);
21812            x >>= 2;
21813        }
21814        if i < r {
21815            re.swap(i, r);
21816            im.swap(i, r);
21817        }
21818    }
21819
21820    // `-j` multiply for the odd radix-4 outputs (sign flips for inverse).
21821    // forward: (-j)(a+bi) = b - ai ; inverse: (+j)(a+bi) = -b + ai.
21822    let jsign = if inverse { 1.0_f32 } else { -1.0_f32 };
21823    let tw_sign = if inverse { 1.0_f64 } else { -1.0_f64 };
21824
21825    let mut len = 4usize;
21826    while len <= n {
21827        let q = len / 4;
21828        let theta = tw_sign * 2.0 * std::f64::consts::PI / (len as f64);
21829        let wstep_re = theta.cos();
21830        let wstep_im = theta.sin();
21831        let mut base = 0usize;
21832        while base < n {
21833            let mut w1_re = 1.0_f64;
21834            let mut w1_im = 0.0_f64;
21835            for k in 0..q {
21836                // w2 = w1², w3 = w1³.
21837                let w2_re = w1_re * w1_re - w1_im * w1_im;
21838                let w2_im = 2.0 * w1_re * w1_im;
21839                let w3_re = w2_re * w1_re - w2_im * w1_im;
21840                let w3_im = w2_re * w1_im + w2_im * w1_re;
21841
21842                let (i0, i1, i2, i3) = (base + k, base + k + q, base + k + 2 * q, base + k + 3 * q);
21843                let a_re = re[i0];
21844                let a_im = im[i0];
21845                let (w1r, w1i) = (w1_re as f32, w1_im as f32);
21846                let (w2r, w2i) = (w2_re as f32, w2_im as f32);
21847                let (w3r, w3i) = (w3_re as f32, w3_im as f32);
21848                let b_re = w1r * re[i1] - w1i * im[i1];
21849                let b_im = w1r * im[i1] + w1i * re[i1];
21850                let c_re = w2r * re[i2] - w2i * im[i2];
21851                let c_im = w2r * im[i2] + w2i * re[i2];
21852                let d_re = w3r * re[i3] - w3i * im[i3];
21853                let d_im = w3r * im[i3] + w3i * re[i3];
21854
21855                let t0_re = a_re + c_re;
21856                let t0_im = a_im + c_im;
21857                let t1_re = a_re - c_re;
21858                let t1_im = a_im - c_im;
21859                let t2_re = b_re + d_re;
21860                let t2_im = b_im + d_im;
21861                let t3_re = b_re - d_re;
21862                let t3_im = b_im - d_im;
21863                // Forward: X[i1] = t1 + (-j·t3), X[i3] = t1 - (-j·t3), where
21864                // (-j·t3) = (t3_im, -t3_re). jsign=-1 forward / +1 inverse.
21865                let jt3_re = -jsign * t3_im;
21866                let jt3_im = jsign * t3_re;
21867
21868                re[i0] = t0_re + t2_re;
21869                im[i0] = t0_im + t2_im;
21870                re[i2] = t0_re - t2_re;
21871                im[i2] = t0_im - t2_im;
21872                re[i1] = t1_re + jt3_re;
21873                im[i1] = t1_im + jt3_im;
21874                re[i3] = t1_re - jt3_re;
21875                im[i3] = t1_im - jt3_im;
21876
21877                let nw_re = w1_re * wstep_re - w1_im * wstep_im;
21878                let nw_im = w1_re * wstep_im + w1_im * wstep_re;
21879                w1_re = nw_re;
21880                w1_im = nw_im;
21881            }
21882            base += len;
21883        }
21884        len <<= 2;
21885    }
21886}
21887
21888/// In-place radix-2 DIT Cooley-Tukey FFT on split (real, imag) f64
21889/// arrays. `n = re.len() = im.len()` must be a power of two. Forward
21890/// uses ω = exp(-2πi/n); inverse uses ω = exp(+2πi/n) (no 1/N scale).
21891fn fft_radix2_inplace_f64(re: &mut [f64], im: &mut [f64], inverse: bool) {
21892    let n = re.len();
21893    debug_assert_eq!(im.len(), n);
21894    debug_assert!(
21895        n.is_power_of_two(),
21896        "fft_radix2: n={n} must be a power of two"
21897    );
21898    if n <= 1 {
21899        return;
21900    }
21901
21902    // Bit-reverse permutation.
21903    let mut j = 0usize;
21904    for i in 1..n {
21905        let mut bit = n >> 1;
21906        while j & bit != 0 {
21907            j ^= bit;
21908            bit >>= 1;
21909        }
21910        j ^= bit;
21911        if i < j {
21912            re.swap(i, j);
21913            im.swap(i, j);
21914        }
21915    }
21916
21917    // Cooley-Tukey butterflies: ω_len = exp(±2πi/len).
21918    let sign = if inverse { 1.0 } else { -1.0 };
21919    let mut len = 2usize;
21920    while len <= n {
21921        let half = len / 2;
21922        let theta = sign * 2.0 * std::f64::consts::PI / (len as f64);
21923        let w_re_step = theta.cos();
21924        let w_im_step = theta.sin();
21925        let mut i = 0usize;
21926        while i < n {
21927            // Twiddle starts at 1+0i for each segment.
21928            let mut wre = 1.0_f64;
21929            let mut wim = 0.0_f64;
21930            for k in 0..half {
21931                let t_re = wre * re[i + k + half] - wim * im[i + k + half];
21932                let t_im = wre * im[i + k + half] + wim * re[i + k + half];
21933                let u_re = re[i + k];
21934                let u_im = im[i + k];
21935                re[i + k] = u_re + t_re;
21936                im[i + k] = u_im + t_im;
21937                re[i + k + half] = u_re - t_re;
21938                im[i + k + half] = u_im - t_im;
21939                let new_wre = wre * w_re_step - wim * w_im_step;
21940                let new_wim = wre * w_im_step + wim * w_re_step;
21941                wre = new_wre;
21942                wim = new_wim;
21943            }
21944            i += len;
21945        }
21946        len <<= 1;
21947    }
21948}
21949
21950/// Pre-computed chirp + filter-spectrum for one (N, direction) pair.
21951/// Built once per call to `execute_fft1d_f64` and reused across rows
21952/// when `outer > 1` — the chirp and FFT(b) don't depend on the input.
21953#[derive(Clone)]
21954struct BluesteinScratchF64 {
21955    /// Power-of-two convolution length, ≥ 2N - 1.
21956    m: usize,
21957    /// `w[k] = exp(sign · iπ · k² / N)` for k=0..N, where sign matches
21958    /// the requested direction. Forward chirp on the way in, output
21959    /// chirp on the way out.
21960    w_re: Vec<f64>,
21961    w_im: Vec<f64>,
21962    /// FFT of the embedded filter `b[k] = conj(w[|k|])` in length-M.
21963    /// Doesn't depend on the input — precomputed once.
21964    bf_re: Vec<f64>,
21965    bf_im: Vec<f64>,
21966    /// Workspace reused per row (avoids per-row allocation).
21967    ar: Vec<f64>,
21968    ai: Vec<f64>,
21969}
21970
21971impl BluesteinScratchF64 {
21972    fn empty() -> Self {
21973        Self {
21974            m: 0,
21975            w_re: Vec::new(),
21976            w_im: Vec::new(),
21977            bf_re: Vec::new(),
21978            bf_im: Vec::new(),
21979            ar: Vec::new(),
21980            ai: Vec::new(),
21981        }
21982    }
21983
21984    fn build(n: usize, inverse: bool) -> Self {
21985        // M = next power of two ≥ 2N - 1 keeps the inner FFT on the
21986        // fast radix-2 path. For N=1 fall back to M=1 (no-op convolution).
21987        let m = if n <= 1 {
21988            1
21989        } else {
21990            (2 * n - 1).next_power_of_two()
21991        };
21992
21993        // Chirp arg reduced via k² mod 2N — without this, large N
21994        // bleeds precision into the trig call (n² grows quadratically).
21995        let mod_2n = (2 * n) as u64;
21996        let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
21997        let mut w_re = vec![0.0_f64; n];
21998        let mut w_im = vec![0.0_f64; n];
21999        for k in 0..n {
22000            let k2 = (k as u64).wrapping_mul(k as u64) % mod_2n;
22001            let theta = sign * std::f64::consts::PI * (k2 as f64) / (n as f64);
22002            w_re[k] = theta.cos();
22003            w_im[k] = theta.sin();
22004        }
22005
22006        // Embed b[k] = conj(w[|k|]) into length M with the negative
22007        // indices wrapping to the tail: b[-j] → B[M-j] for j=1..N-1.
22008        let mut bf_re = vec![0.0_f64; m];
22009        let mut bf_im = vec![0.0_f64; m];
22010        if n > 0 {
22011            bf_re[0] = w_re[0];
22012            bf_im[0] = -w_im[0];
22013            for k in 1..n {
22014                bf_re[k] = w_re[k];
22015                bf_im[k] = -w_im[k];
22016                bf_re[m - k] = w_re[k];
22017                bf_im[m - k] = -w_im[k];
22018            }
22019        }
22020        if m > 1 {
22021            fft_radix2_inplace_f64(&mut bf_re, &mut bf_im, false);
22022        }
22023
22024        Self {
22025            m,
22026            w_re,
22027            w_im,
22028            bf_re,
22029            bf_im,
22030            ar: vec![0.0_f64; m],
22031            ai: vec![0.0_f64; m],
22032        }
22033    }
22034}
22035
22036/// Direct O(N²) DFT for small non-pow2 N (faster than Bluestein setup).
22037fn fft_naive_inplace_f64(re: &mut [f64], im: &mut [f64], inverse: bool) {
22038    let n = re.len();
22039    if n <= 1 {
22040        return;
22041    }
22042    let sign = if inverse { 1.0 } else { -1.0 };
22043    let mut out_re = vec![0.0_f64; n];
22044    let mut out_im = vec![0.0_f64; n];
22045    for k in 0..n {
22046        for nn in 0..n {
22047            let theta = sign * 2.0 * std::f64::consts::PI * (nn as f64) * (k as f64) / (n as f64);
22048            let c = theta.cos();
22049            let s = theta.sin();
22050            out_re[k] += re[nn] * c - im[nn] * s;
22051            out_im[k] += re[nn] * s + im[nn] * c;
22052        }
22053    }
22054    re.copy_from_slice(&out_re);
22055    im.copy_from_slice(&out_im);
22056}
22057
22058fn fft_naive_inplace_f32(re: &mut [f32], im: &mut [f32], inverse: bool) {
22059    let n = re.len();
22060    if n <= 1 {
22061        return;
22062    }
22063    let sign = if inverse { 1.0f32 } else { -1.0f32 };
22064    let mut out_re = vec![0.0_f32; n];
22065    let mut out_im = vec![0.0_f32; n];
22066    for k in 0..n {
22067        for nn in 0..n {
22068            let theta = sign * 2.0 * std::f32::consts::PI * (nn as f32) * (k as f32) / (n as f32);
22069            let c = theta.cos();
22070            let s = theta.sin();
22071            out_re[k] += re[nn] * c - im[nn] * s;
22072            out_im[k] += re[nn] * s + im[nn] * c;
22073        }
22074    }
22075    re.copy_from_slice(&out_re);
22076    im.copy_from_slice(&out_im);
22077}
22078
22079/// Bluestein (chirp-z) FFT for arbitrary N. Identity used:
22080///   `n·k = (n² + k² - (k-n)²) / 2`
22081/// which lets the DFT be written as a linear convolution sandwiched
22082/// between two chirp multiplies:
22083///   `X[k] = w[k] · ((x·w) ⊛ conj(w))[k]`   where `w[n] = exp(±iπ·n²/N)`.
22084/// The convolution is computed via a length-M radix-2 FFT (M ≥ 2N-1).
22085/// Both directions stay unnormalized to match the radix-2 path, so the
22086/// chain rule keeps working without scaling.
22087fn fft_bluestein_inplace_f64(
22088    re: &mut [f64],
22089    im: &mut [f64],
22090    _inverse: bool,
22091    s: &mut BluesteinScratchF64,
22092) {
22093    let n = re.len();
22094    debug_assert_eq!(im.len(), n);
22095    debug_assert_eq!(s.w_re.len(), n);
22096    if n <= 1 {
22097        return;
22098    }
22099    let m = s.m;
22100
22101    // Pre-chirp: a[k] = x[k] · w[k], zero-padded to M.
22102    for k in 0..m {
22103        s.ar[k] = 0.0;
22104        s.ai[k] = 0.0;
22105    }
22106    for k in 0..n {
22107        s.ar[k] = re[k] * s.w_re[k] - im[k] * s.w_im[k];
22108        s.ai[k] = re[k] * s.w_im[k] + im[k] * s.w_re[k];
22109    }
22110
22111    // Length-M forward FFT of the padded chirped input.
22112    fft_radix2_inplace_f64(&mut s.ar, &mut s.ai, false);
22113
22114    // Pointwise product with FFT(b). Stored back into (ar, ai).
22115    for k in 0..m {
22116        let ar = s.ar[k];
22117        let ai = s.ai[k];
22118        let br = s.bf_re[k];
22119        let bi = s.bf_im[k];
22120        s.ar[k] = ar * br - ai * bi;
22121        s.ai[k] = ar * bi + ai * br;
22122    }
22123
22124    // Inverse FFT — radix-2 here is the unnormalized inverse, so we
22125    // divide by M to recover the true circular convolution.
22126    fft_radix2_inplace_f64(&mut s.ar, &mut s.ai, true);
22127    let inv_m = 1.0 / (m as f64);
22128
22129    // Post-chirp: X[k] = w[k] · Y[k] / M for k = 0..N.
22130    for k in 0..n {
22131        let yr = s.ar[k] * inv_m;
22132        let yi = s.ai[k] * inv_m;
22133        re[k] = yr * s.w_re[k] - yi * s.w_im[k];
22134        im[k] = yr * s.w_im[k] + yi * s.w_re[k];
22135    }
22136}
22137
22138/// f32 mirror of `BluesteinScratchF64`. Chirp is computed in f64 for
22139/// precision (same justification as the radix-2 f32 path: twiddles in
22140/// f64, butterflies in f32). The actual conv buffers are f32.
22141///
22142/// `Clone` lets each rayon worker own a copy (read-only `w`/`bf` tables plus
22143/// its own `ar`/`ai` convolution workspace) when the batch loop is parallel.
22144#[derive(Clone)]
22145struct BluesteinScratchF32 {
22146    m: usize,
22147    w_re: Vec<f32>,
22148    w_im: Vec<f32>,
22149    bf_re: Vec<f32>,
22150    bf_im: Vec<f32>,
22151    ar: Vec<f32>,
22152    ai: Vec<f32>,
22153}
22154
22155impl BluesteinScratchF32 {
22156    fn empty() -> Self {
22157        Self {
22158            m: 0,
22159            w_re: Vec::new(),
22160            w_im: Vec::new(),
22161            bf_re: Vec::new(),
22162            bf_im: Vec::new(),
22163            ar: Vec::new(),
22164            ai: Vec::new(),
22165        }
22166    }
22167
22168    fn build(n: usize, inverse: bool) -> Self {
22169        let m = if n <= 1 {
22170            1
22171        } else {
22172            (2 * n - 1).next_power_of_two()
22173        };
22174
22175        let mod_2n = (2 * n) as u64;
22176        let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
22177        let mut w_re = vec![0.0_f32; n];
22178        let mut w_im = vec![0.0_f32; n];
22179        for k in 0..n {
22180            let k2 = (k as u64).wrapping_mul(k as u64) % mod_2n;
22181            let theta = sign * std::f64::consts::PI * (k2 as f64) / (n as f64);
22182            w_re[k] = theta.cos() as f32;
22183            w_im[k] = theta.sin() as f32;
22184        }
22185
22186        let mut bf_re = vec![0.0_f32; m];
22187        let mut bf_im = vec![0.0_f32; m];
22188        if n > 0 {
22189            bf_re[0] = w_re[0];
22190            bf_im[0] = -w_im[0];
22191            for k in 1..n {
22192                bf_re[k] = w_re[k];
22193                bf_im[k] = -w_im[k];
22194                bf_re[m - k] = w_re[k];
22195                bf_im[m - k] = -w_im[k];
22196            }
22197        }
22198        if m > 1 {
22199            fft_radix2_inplace_f32(&mut bf_re, &mut bf_im, false);
22200        }
22201
22202        Self {
22203            m,
22204            w_re,
22205            w_im,
22206            bf_re,
22207            bf_im,
22208            ar: vec![0.0_f32; m],
22209            ai: vec![0.0_f32; m],
22210        }
22211    }
22212}
22213
22214fn fft_bluestein_inplace_f32(
22215    re: &mut [f32],
22216    im: &mut [f32],
22217    _inverse: bool,
22218    s: &mut BluesteinScratchF32,
22219) {
22220    let n = re.len();
22221    debug_assert_eq!(im.len(), n);
22222    debug_assert_eq!(s.w_re.len(), n);
22223    if n <= 1 {
22224        return;
22225    }
22226    let m = s.m;
22227
22228    for k in 0..m {
22229        s.ar[k] = 0.0;
22230        s.ai[k] = 0.0;
22231    }
22232    for k in 0..n {
22233        s.ar[k] = re[k] * s.w_re[k] - im[k] * s.w_im[k];
22234        s.ai[k] = re[k] * s.w_im[k] + im[k] * s.w_re[k];
22235    }
22236
22237    fft_radix2_inplace_f32(&mut s.ar, &mut s.ai, false);
22238
22239    for k in 0..m {
22240        let ar = s.ar[k];
22241        let ai = s.ai[k];
22242        let br = s.bf_re[k];
22243        let bi = s.bf_im[k];
22244        s.ar[k] = ar * br - ai * bi;
22245        s.ai[k] = ar * bi + ai * br;
22246    }
22247
22248    fft_radix2_inplace_f32(&mut s.ar, &mut s.ai, true);
22249    let inv_m = 1.0_f32 / (m as f32);
22250
22251    for k in 0..n {
22252        let yr = s.ar[k] * inv_m;
22253        let yi = s.ai[k] * inv_m;
22254        re[k] = yr * s.w_re[k] - yi * s.w_im[k];
22255        im[k] = yr * s.w_im[k] + yi * s.w_re[k];
22256    }
22257}
22258
22259/// Shared dispatch path for `Thunk::CustomOp`. Builds a typed
22260/// [`CpuTensorRef`] for each input *at that input's declared dtype*
22261/// (so a sparse-LU op with mixed F64/I32 inputs gets the right
22262/// typed slices) and a [`CpuTensorMut`] for the output, then calls
22263/// the kernel's single `execute` method.
22264unsafe fn dispatch_custom_op(
22265    kernel: &dyn crate::op_registry::CpuKernel,
22266    inputs: &[(usize, u32, Shape)],
22267    out_off: usize,
22268    out_len: u32,
22269    out_shape: &Shape,
22270    attrs: &[u8],
22271    base: *mut u8,
22272) {
22273    use crate::op_registry::{CpuTensorMut, CpuTensorRef};
22274    use rlx_ir::DType;
22275
22276    // One arm per `DType` variant — single source of truth for
22277    // "which dtypes the CPU custom-op dispatcher wires." If a new
22278    // DType lands in `rlx-ir`, the compiler flags this match as
22279    // non-exhaustive and the gap gets named at the right place.
22280    macro_rules! build_in_view {
22281        ($shape:expr, $off:expr, $n:expr, $variant:ident, $rust_ty:ty) => {
22282            CpuTensorRef::$variant {
22283                data: unsafe { sl_typed::<$rust_ty>($off, base, $n) },
22284                shape: $shape,
22285            }
22286        };
22287    }
22288    macro_rules! build_out_view {
22289        ($variant:ident, $rust_ty:ty) => {
22290            CpuTensorMut::$variant {
22291                data: unsafe { sl_mut_typed::<$rust_ty>(out_off, base, out_len as usize) },
22292                shape: out_shape,
22293            }
22294        };
22295    }
22296
22297    let in_views: Vec<CpuTensorRef<'_>> = inputs
22298        .iter()
22299        .map(|(off, len, shape)| {
22300            let n = *len as usize;
22301            let off = *off;
22302            match shape.dtype() {
22303                DType::F32 => build_in_view!(shape, off, n, F32, f32),
22304                DType::F64 => build_in_view!(shape, off, n, F64, f64),
22305                DType::F16 => build_in_view!(shape, off, n, F16, half::f16),
22306                DType::BF16 => build_in_view!(shape, off, n, BF16, half::bf16),
22307                DType::I8 => build_in_view!(shape, off, n, I8, i8),
22308                DType::I16 => build_in_view!(shape, off, n, I16, i16),
22309                DType::I32 => build_in_view!(shape, off, n, I32, i32),
22310                DType::I64 => build_in_view!(shape, off, n, I64, i64),
22311                DType::U8 => build_in_view!(shape, off, n, U8, u8),
22312                DType::U32 => build_in_view!(shape, off, n, U32, u32),
22313                DType::Bool => build_in_view!(shape, off, n, Bool, u8),
22314                // C64 isn't a CpuTensor variant today; the user-registered
22315                // op_registry path doesn't see complex inputs (those are
22316                // handled by built-in ops with dedicated kernels).
22317                DType::C64 => panic!(
22318                    "Op::Custom kernel input has DType::C64 — built-in \
22319                 complex ops handle their own kernels; user-registered \
22320                 ops don't yet see complex tensors"
22321                ),
22322            }
22323        })
22324        .collect();
22325
22326    let result = match out_shape.dtype() {
22327        DType::F32 => kernel.execute(&in_views, build_out_view!(F32, f32), attrs),
22328        DType::F64 => kernel.execute(&in_views, build_out_view!(F64, f64), attrs),
22329        DType::F16 => kernel.execute(&in_views, build_out_view!(F16, half::f16), attrs),
22330        DType::BF16 => kernel.execute(&in_views, build_out_view!(BF16, half::bf16), attrs),
22331        DType::I8 => kernel.execute(&in_views, build_out_view!(I8, i8), attrs),
22332        DType::I16 => kernel.execute(&in_views, build_out_view!(I16, i16), attrs),
22333        DType::I32 => kernel.execute(&in_views, build_out_view!(I32, i32), attrs),
22334        DType::I64 => kernel.execute(&in_views, build_out_view!(I64, i64), attrs),
22335        DType::U8 => kernel.execute(&in_views, build_out_view!(U8, u8), attrs),
22336        DType::U32 => kernel.execute(&in_views, build_out_view!(U32, u32), attrs),
22337        DType::Bool => kernel.execute(&in_views, build_out_view!(Bool, u8), attrs),
22338        DType::C64 => panic!("Op::Custom output DType::C64 not supported"),
22339    };
22340    if let Err(e) = result {
22341        panic!("Op::Custom('{}') CPU kernel failed: {e}", kernel.name());
22342    }
22343}
22344
22345/// Generic raw-cast slice helper. The existing per-dtype `sl_*` /
22346/// `sl_mut_*` helpers stay in place for the rest of `thunk.rs` (which
22347/// uses them at call sites with concrete dtypes); the custom-op
22348/// dispatcher uses these to enumerate every `DType` uniformly without
22349/// listing one helper per dtype.
22350#[inline(always)]
22351unsafe fn sl_typed<T>(offset: usize, base: *mut u8, len: usize) -> &'static [T] {
22352    if offset == usize::MAX {
22353        return &[];
22354    }
22355    unsafe { std::slice::from_raw_parts(base.add(offset) as *const T, len) }
22356}
22357
22358#[inline(always)]
22359unsafe fn sl_mut_typed<T>(offset: usize, base: *mut u8, len: usize) -> &'static mut [T] {
22360    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut T, len) }
22361}
22362
22363/// Scalar activation for the fused-region interpreter. Matches the GPU region
22364/// kernel's math (e.g. tanh-approx GELU) so CPU/GPU region results agree.
22365#[inline]
22366fn region_activation_scalar(act: rlx_ir::op::Activation, x: f32) -> f32 {
22367    use rlx_ir::op::Activation as A;
22368    const GC: f32 = 0.797_884_6; // sqrt(2/pi)
22369    match act {
22370        A::Relu => x.max(0.0),
22371        A::Gelu | A::GeluApprox => 0.5 * x * (1.0 + (GC * (x + 0.044715 * x * x * x)).tanh()),
22372        A::Silu => x / (1.0 + (-x).exp()),
22373        A::Sigmoid => 1.0 / (1.0 + (-x).exp()),
22374        A::Tanh => x.tanh(),
22375        A::Exp => x.exp(),
22376        A::Log => x.ln(),
22377        A::Sqrt => x.sqrt(),
22378        A::Rsqrt => 1.0 / x.sqrt(),
22379        A::Neg => -x,
22380        A::Abs => x.abs(),
22381        A::Sin => x.sin(),
22382        A::Cos => x.cos(),
22383        A::Tan => x.tan(),
22384        A::Atan => x.atan(),
22385        A::Round => x.round(),
22386    }
22387}
22388
22389#[inline]
22390fn region_binary_scalar(op: rlx_ir::op::BinaryOp, l: f32, r: f32) -> f32 {
22391    use rlx_ir::op::BinaryOp as B;
22392    match op {
22393        B::Add => l + r,
22394        B::Sub => l - r,
22395        B::Mul => l * r,
22396        B::Div => l / r,
22397        B::Max => l.max(r),
22398        B::Min => l.min(r),
22399        B::Pow => l.powf(r),
22400    }
22401}
22402
22403#[inline]
22404fn region_compare_scalar(op: rlx_ir::op::CmpOp, l: f32, r: f32) -> bool {
22405    use rlx_ir::op::CmpOp as C;
22406    match op {
22407        C::Eq => l == r,
22408        C::Ne => l != r,
22409        C::Lt => l < r,
22410        C::Le => l <= r,
22411        C::Gt => l > r,
22412        C::Ge => l >= r,
22413    }
22414}
22415
22416/// Resolve one chain operand for output element `gid`: a previous step result
22417/// (`scratch[s]`) or an external input read with broadcast (`input[gid % mod]`,
22418/// scalar inputs read element 0). `base` is the arena byte base.
22419#[inline]
22420fn region_resolve_operand(
22421    op: &rlx_ir::op::ChainOperand,
22422    gid: usize,
22423    base: *const u8,
22424    input_offs: &[usize],
22425    scalar_mask: u32,
22426    modulus: &[u32; 16],
22427    scratch: &[f32; 32],
22428) -> f32 {
22429    use rlx_ir::op::ChainOperand as O;
22430    match op {
22431        O::Step(s) => scratch[*s as usize],
22432        O::Input(i) => {
22433            let i = *i as usize;
22434            let row = if (scalar_mask >> i) & 1 == 1 {
22435                0
22436            } else if modulus[i] != 0 {
22437                gid % modulus[i] as usize
22438            } else {
22439                gid
22440            };
22441            unsafe { *(base.add(input_offs[i]) as *const f32).add(row) }
22442        }
22443    }
22444}
22445
22446/// Evaluate a fused element-wise chain for output element `gid`, returning the
22447/// final step's value (what gets written to `dst[gid]`).
22448#[inline]
22449fn region_eval_elem(
22450    gid: usize,
22451    base: *const u8,
22452    input_offs: &[usize],
22453    chain: &[rlx_ir::op::ChainStep],
22454    scalar_mask: u32,
22455    modulus: &[u32; 16],
22456) -> f32 {
22457    use rlx_ir::op::ChainStep as S;
22458    let mut scratch = [0f32; 32];
22459    let r = |o: &rlx_ir::op::ChainOperand, sc: &[f32; 32]| {
22460        region_resolve_operand(o, gid, base, input_offs, scalar_mask, modulus, sc)
22461    };
22462    for (k, step) in chain.iter().enumerate() {
22463        scratch[k] = match step {
22464            S::Activation(a, x) => region_activation_scalar(*a, r(x, &scratch)),
22465            S::Cast(_, x) => r(x, &scratch), // f32→f32 identity (chains are same-dtype)
22466            S::Binary(op, l, rr) => region_binary_scalar(*op, r(l, &scratch), r(rr, &scratch)),
22467            S::Compare(op, l, rr) => {
22468                if region_compare_scalar(*op, r(l, &scratch), r(rr, &scratch)) {
22469                    1.0
22470                } else {
22471                    0.0
22472                }
22473            }
22474            S::Where(c, t, f) => {
22475                if r(c, &scratch) != 0.0 {
22476                    r(t, &scratch)
22477                } else {
22478                    r(f, &scratch)
22479                }
22480            }
22481        };
22482    }
22483    scratch[chain.len() - 1]
22484}
22485
22486// Unsafe helpers to create slices from arena base + offset
22487/// In-place per-element activation. Mirrors the dispatch in
22488/// `Thunk::ActivationInPlace`. Used by `Thunk::FusedMmBiasAct` to
22489/// apply the activation after `bias_add` for all non-Gelu cases.
22490#[inline(always)]
22491fn apply_activation_inplace(d: &mut [f32], act: rlx_ir::op::Activation) {
22492    use rlx_ir::op::Activation;
22493    match act {
22494        Activation::Gelu => crate::kernels::par_gelu_inplace(d),
22495        Activation::GeluApprox => crate::kernels::par_gelu_approx_inplace(d),
22496        Activation::Silu => crate::kernels::par_silu_inplace(d),
22497        Activation::Relu => {
22498            for v in d.iter_mut() {
22499                *v = v.max(0.0);
22500            }
22501        }
22502        Activation::Sigmoid => {
22503            for v in d.iter_mut() {
22504                *v = 1.0 / (1.0 + (-*v).exp());
22505            }
22506        }
22507        Activation::Tanh => {
22508            for v in d.iter_mut() {
22509                *v = v.tanh();
22510            }
22511        }
22512        Activation::Exp => {
22513            for v in d.iter_mut() {
22514                *v = v.exp();
22515            }
22516        }
22517        Activation::Log => {
22518            for v in d.iter_mut() {
22519                *v = v.ln();
22520            }
22521        }
22522        Activation::Sqrt => {
22523            for v in d.iter_mut() {
22524                *v = v.sqrt();
22525            }
22526        }
22527        Activation::Rsqrt => {
22528            for v in d.iter_mut() {
22529                *v = 1.0 / v.sqrt();
22530            }
22531        }
22532        Activation::Neg => {
22533            for v in d.iter_mut() {
22534                *v = -*v;
22535            }
22536        }
22537        Activation::Abs => {
22538            for v in d.iter_mut() {
22539                *v = v.abs();
22540            }
22541        }
22542        Activation::Round => {
22543            for v in d.iter_mut() {
22544                *v = v.round();
22545            }
22546        }
22547        Activation::Sin => {
22548            for v in d.iter_mut() {
22549                *v = v.sin();
22550            }
22551        }
22552        Activation::Cos => {
22553            for v in d.iter_mut() {
22554                *v = v.cos();
22555            }
22556        }
22557        Activation::Tan => {
22558            for v in d.iter_mut() {
22559                *v = v.tan();
22560            }
22561        }
22562        Activation::Atan => {
22563            for v in d.iter_mut() {
22564                *v = v.atan();
22565            }
22566        }
22567    }
22568}
22569
22570/// im2col for one image (single batch + group slice).
22571///
22572/// Source `x` is `[c_in, H, W]` row-major. Destination `col` is
22573/// `[c_in · kH · kW, H_out · W_out]` row-major. Out-of-bounds positions
22574/// (in the padded region) are written as 0.
22575///
22576/// `col[(ci · kH · kW + ki · kW + kj) · n_dim + ho · W_out + wo] =
22577///    x[ci, ho·sh + ki·dh − ph, wo·sw + kj·dw_dil − pw]`
22578#[allow(clippy::too_many_arguments)]
22579/// Is the im2col+BLAS forward-conv kernel enabled? Read once from
22580/// `RLX_FAST_CONV` (1/on/true/yes). When off (the default), forward conv
22581/// runs the reference scalar loop. The flag is process-global so the two
22582/// benchmark configurations ("fused"/optimized vs reference) are produced
22583/// by separate process invocations — see the cortexm trainer.
22584fn fast_conv_enabled() -> bool {
22585    use std::sync::OnceLock;
22586    static FAST_CONV: OnceLock<bool> = OnceLock::new();
22587    *FAST_CONV.get_or_init(|| {
22588        matches!(
22589            rlx_ir::env::var("RLX_FAST_CONV").as_deref(),
22590            Some("1") | Some("on") | Some("true") | Some("yes")
22591        )
22592    })
22593}
22594
22595/// Reference forward convolution: a direct scalar nested loop. Correct and
22596/// dependency-free, but leaves the CPU idle (scalar, single-threaded, a
22597/// bounds-check branch in the hot loop). Kept as the parity oracle for
22598/// `conv2d_forward_im2col` and as the default kernel.
22599#[allow(clippy::too_many_arguments)]
22600fn conv2d_forward_naive(
22601    inp: &[f32],
22602    wt: &[f32],
22603    out: &mut [f32],
22604    n: usize,
22605    c_in: usize,
22606    h: usize,
22607    w: usize,
22608    c_out: usize,
22609    h_out: usize,
22610    w_out: usize,
22611    kh: usize,
22612    kw: usize,
22613    sh: usize,
22614    sw: usize,
22615    ph: usize,
22616    pw: usize,
22617    dh: usize,
22618    dw: usize,
22619    groups: usize,
22620) {
22621    let c_in_per_g = c_in / groups;
22622    let c_out_per_g = c_out / groups;
22623    for ni in 0..n {
22624        for co in 0..c_out {
22625            let g = co / c_out_per_g;
22626            let ci_start = g * c_in_per_g;
22627            for ho in 0..h_out {
22628                for wo in 0..w_out {
22629                    let mut acc = 0f32;
22630                    for ci_off in 0..c_in_per_g {
22631                        let ci = ci_start + ci_off;
22632                        let in_chan = ((ni * c_in) + ci) * h * w;
22633                        let wt_chan = ((co * c_in_per_g) + ci_off) * kh * kw;
22634                        for ki in 0..kh {
22635                            for kj in 0..kw {
22636                                let hi = ho * sh + ki * dh;
22637                                let wi = wo * sw + kj * dw;
22638                                if hi < ph || wi < pw {
22639                                    continue;
22640                                }
22641                                let hi = hi - ph;
22642                                let wi = wi - pw;
22643                                if hi >= h || wi >= w {
22644                                    continue;
22645                                }
22646                                acc += inp[in_chan + hi * w + wi] * wt[wt_chan + ki * kw + kj];
22647                            }
22648                        }
22649                    }
22650                    out[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = acc;
22651                }
22652            }
22653        }
22654    }
22655}
22656
22657/// Winograd F(2×2,3×3) enabled? Read once from `RLX_WINOGRAD`. Applies only to
22658/// 3×3 stride-1 no-dilation groups-1 convs (the conv-net hot case).
22659fn winograd_enabled() -> bool {
22660    use std::sync::OnceLock;
22661    static W: OnceLock<bool> = OnceLock::new();
22662    *W.get_or_init(|| {
22663        matches!(
22664            rlx_ir::env::var("RLX_WINOGRAD").as_deref(),
22665            Some("1") | Some("on") | Some("true") | Some("yes")
22666        )
22667    })
22668}
22669
22670/// Direct (no-im2col) forward conv enabled? `RLX_DIRECT_CONV`. Off by default —
22671/// im2col+BLAS is faster for low-channel convs; this wins only at high channels.
22672fn direct_conv_enabled() -> bool {
22673    use std::sync::OnceLock;
22674    static D: OnceLock<bool> = OnceLock::new();
22675    *D.get_or_init(|| {
22676        matches!(
22677            rlx_ir::env::var("RLX_DIRECT_CONV").as_deref(),
22678            Some("1") | Some("on") | Some("true") | Some("yes")
22679        )
22680    })
22681}
22682
22683/// Filter transform U = G·g·Gᵀ for one 3×3 filter → 4×4 (row-major).
22684#[inline]
22685fn winograd_filter_transform(g: &[f32]) -> [f32; 16] {
22686    let mut tmp = [0f32; 12]; // 4×3 = G·g
22687    for j in 0..3 {
22688        let (g0, g1, g2) = (g[j], g[3 + j], g[6 + j]);
22689        tmp[j] = g0;
22690        tmp[3 + j] = 0.5 * (g0 + g1 + g2);
22691        tmp[6 + j] = 0.5 * (g0 - g1 + g2);
22692        tmp[9 + j] = g2;
22693    }
22694    let mut u = [0f32; 16];
22695    for i in 0..4 {
22696        let (t0, t1, t2) = (tmp[i * 3], tmp[i * 3 + 1], tmp[i * 3 + 2]);
22697        u[i * 4] = t0;
22698        u[i * 4 + 1] = 0.5 * (t0 + t1 + t2);
22699        u[i * 4 + 2] = 0.5 * (t0 - t1 + t2);
22700        u[i * 4 + 3] = t2;
22701    }
22702    u
22703}
22704
22705/// Input transform V = Bᵀ·d·B for one 4×4 tile → 4×4 (row-major).
22706#[inline]
22707fn winograd_input_transform(d: &[f32; 16]) -> [f32; 16] {
22708    let mut t = [0f32; 16];
22709    for j in 0..4 {
22710        let (d0, d1, d2, d3) = (d[j], d[4 + j], d[8 + j], d[12 + j]);
22711        t[j] = d0 - d2;
22712        t[4 + j] = d1 + d2;
22713        t[8 + j] = d2 - d1;
22714        t[12 + j] = d1 - d3;
22715    }
22716    let mut v = [0f32; 16];
22717    for i in 0..4 {
22718        let (a0, a1, a2, a3) = (t[i * 4], t[i * 4 + 1], t[i * 4 + 2], t[i * 4 + 3]);
22719        v[i * 4] = a0 - a2;
22720        v[i * 4 + 1] = a1 + a2;
22721        v[i * 4 + 2] = a2 - a1;
22722        v[i * 4 + 3] = a1 - a3;
22723    }
22724    v
22725}
22726
22727/// Output transform Y = Aᵀ·m·A for one 4×4 accumulator → 2×2 (row-major).
22728#[inline]
22729fn winograd_output_transform(m: &[f32; 16]) -> [f32; 4] {
22730    let mut s = [0f32; 8];
22731    for j in 0..4 {
22732        let (m0, m1, m2, m3) = (m[j], m[4 + j], m[8 + j], m[12 + j]);
22733        s[j] = m0 + m1 + m2;
22734        s[4 + j] = m1 - m2 - m3;
22735    }
22736    let mut y = [0f32; 4];
22737    for i in 0..2 {
22738        let (a0, a1, a2, a3) = (s[i * 4], s[i * 4 + 1], s[i * 4 + 2], s[i * 4 + 3]);
22739        y[i * 2] = a0 + a1 + a2;
22740        y[i * 2 + 1] = a1 - a2 - a3;
22741    }
22742    y
22743}
22744
22745/// Winograd F(2×2,3×3) forward convolution (3×3, stride 1, no dilation, groups
22746/// 1, valid). ~2.25× fewer multiplies than im2col by working on 4×4 tiles:
22747/// transform filter (G·g·Gᵀ) and input (Bᵀ·d·B), 16 channel-GEMMs (one per
22748/// tile position), then output transform (Aᵀ·m·A). Boundary tiles zero-pad the
22749/// input read and skip out-of-range output writes (handles odd H_out/W_out).
22750#[allow(clippy::too_many_arguments)]
22751fn conv2d_forward_winograd(
22752    inp: &[f32],
22753    wt: &[f32],
22754    out: &mut [f32],
22755    n: usize,
22756    c_in: usize,
22757    h: usize,
22758    w: usize,
22759    c_out: usize,
22760    h_out: usize,
22761    w_out: usize,
22762) {
22763    let th = h_out.div_ceil(2);
22764    let tw = w_out.div_ceil(2);
22765    let tiles_per = th * tw;
22766    let nt = n * tiles_per;
22767    if nt == 0 {
22768        return;
22769    }
22770
22771    // Filter transform U[p][co][ci]  (p = 0..16).
22772    let mut u = vec![0f32; 16 * c_out * c_in];
22773    for co in 0..c_out {
22774        for ci in 0..c_in {
22775            let uu = winograd_filter_transform(&wt[(co * c_in + ci) * 9..(co * c_in + ci) * 9 + 9]);
22776            for (p, &val) in uu.iter().enumerate() {
22777                u[p * c_out * c_in + co * c_in + ci] = val;
22778            }
22779        }
22780    }
22781
22782    // Input transform V[p][ci][tile].
22783    let mut v = vec![0f32; 16 * c_in * nt];
22784    let v_addr = v.as_mut_ptr() as usize;
22785    let in_xform = |tile: usize| {
22786        let ni = tile / tiles_per;
22787        let rem = tile % tiles_per;
22788        let (h0, w0) = (2 * (rem / tw), 2 * (rem % tw));
22789        for ci in 0..c_in {
22790            let mut d = [0f32; 16];
22791            for di in 0..4 {
22792                let hh = h0 + di;
22793                if hh >= h {
22794                    continue;
22795                }
22796                let base = ((ni * c_in + ci) * h + hh) * w;
22797                for dj in 0..4 {
22798                    let ww = w0 + dj;
22799                    if ww < w {
22800                        d[di * 4 + dj] = inp[base + ww];
22801                    }
22802                }
22803            }
22804            let vv = winograd_input_transform(&d);
22805            for (p, &val) in vv.iter().enumerate() {
22806                unsafe {
22807                    *((v_addr as *mut f32).add(p * c_in * nt + ci * nt + tile)) = val;
22808                }
22809            }
22810        }
22811    };
22812    if fast_conv_enabled() && crate::pool::should_parallelize(nt * c_in * 16) {
22813        crate::pool::par_for(nt, crate::pool::outer_chunk(nt), &|off, cnt| {
22814            for t in off..off + cnt {
22815                in_xform(t);
22816            }
22817        });
22818    } else {
22819        for t in 0..nt {
22820            in_xform(t);
22821        }
22822    }
22823
22824    // 16 channel-GEMMs: M[p] = U[p]·V[p]  → [c_out, nt].
22825    let mut m = vec![0f32; 16 * c_out * nt];
22826    for p in 0..16 {
22827        crate::blas::sgemm(
22828            &u[p * c_out * c_in..(p + 1) * c_out * c_in],
22829            &v[p * c_in * nt..(p + 1) * c_in * nt],
22830            &mut m[p * c_out * nt..(p + 1) * c_out * nt],
22831            c_out,
22832            c_in,
22833            nt,
22834        );
22835    }
22836
22837    // Output transform + scatter.
22838    let out_addr = out.as_mut_ptr() as usize;
22839    let out_xform = |tile: usize| {
22840        let ni = tile / tiles_per;
22841        let rem = tile % tiles_per;
22842        let (ho0, wo0) = (2 * (rem / tw), 2 * (rem % tw));
22843        for co in 0..c_out {
22844            let mut mm = [0f32; 16];
22845            for (p, slot) in mm.iter_mut().enumerate() {
22846                *slot = m[p * c_out * nt + co * nt + tile];
22847            }
22848            let y = winograd_output_transform(&mm);
22849            for yi in 0..2 {
22850                let oh = ho0 + yi;
22851                if oh >= h_out {
22852                    continue;
22853                }
22854                for yj in 0..2 {
22855                    let ow = wo0 + yj;
22856                    if ow < w_out {
22857                        unsafe {
22858                            *((out_addr as *mut f32)
22859                                .add(((ni * c_out + co) * h_out + oh) * w_out + ow)) =
22860                                y[yi * 2 + yj];
22861                        }
22862                    }
22863                }
22864            }
22865        }
22866    };
22867    if fast_conv_enabled() && crate::pool::should_parallelize(nt * c_out * 16) {
22868        crate::pool::par_for(nt, crate::pool::outer_chunk(nt), &|off, cnt| {
22869            for t in off..off + cnt {
22870                out_xform(t);
22871            }
22872        });
22873    } else {
22874        for t in 0..nt {
22875            out_xform(t);
22876        }
22877    }
22878}
22879
22880/// Direct forward convolution for the stride-1, no-padding, dilation-1 case
22881/// (the conv-net hot path). Unlike im2col it never materialises the 9×-expanded
22882/// patch buffer — each output plane stays L1-resident while we accumulate over
22883/// (c_in, kh, kw), and the innermost loop is a contiguous SAXPY over the output
22884/// row (`out[wo] += w * in[wo+kj]`) that autovectorizes. Compute-bound, not
22885/// bandwidth-bound — the win over im2col for low-channel convs. Parallel over
22886/// (batch × c_out); caller guarantees stride/pad/dilation eligibility.
22887#[allow(clippy::too_many_arguments)]
22888fn conv2d_forward_direct(
22889    inp: &[f32],
22890    wt: &[f32],
22891    out: &mut [f32],
22892    n: usize,
22893    c_in: usize,
22894    h: usize,
22895    w: usize,
22896    c_out: usize,
22897    h_out: usize,
22898    w_out: usize,
22899    kh: usize,
22900    kw: usize,
22901    groups: usize,
22902) {
22903    let c_in_per_g = c_in / groups;
22904    let c_out_per_g = c_out / groups;
22905    let out_plane = h_out * w_out;
22906    let in_plane = h * w;
22907    let out_addr = out.as_mut_ptr() as usize;
22908    let compute = |nco: usize| {
22909        let ni = nco / c_out;
22910        let co = nco % c_out;
22911        let ci_start = (co / c_out_per_g) * c_in_per_g;
22912        let out_base = (ni * c_out + co) * out_plane;
22913        let op = unsafe {
22914            std::slice::from_raw_parts_mut((out_addr as *mut f32).add(out_base), out_plane)
22915        };
22916        for v in op.iter_mut() {
22917            *v = 0.0;
22918        }
22919        for ci_off in 0..c_in_per_g {
22920            let in_base = (ni * c_in + ci_start + ci_off) * in_plane;
22921            let wt_base = (co * c_in_per_g + ci_off) * kh * kw;
22922            for ki in 0..kh {
22923                for kj in 0..kw {
22924                    let wv = wt[wt_base + ki * kw + kj];
22925                    for ho in 0..h_out {
22926                        let in_row = in_base + (ho + ki) * w + kj;
22927                        let dst = &mut op[ho * w_out..ho * w_out + w_out];
22928                        let src = &inp[in_row..in_row + w_out];
22929                        for wo in 0..w_out {
22930                            dst[wo] += wv * src[wo];
22931                        }
22932                    }
22933                }
22934            }
22935        }
22936    };
22937    if fast_conv_enabled() && crate::pool::should_parallelize(n * c_out * out_plane) {
22938        crate::pool::par_for(
22939            n * c_out,
22940            crate::pool::outer_chunk(n * c_out),
22941            &|off, cnt| {
22942                for nco in off..off + cnt {
22943                    compute(nco);
22944                }
22945            },
22946        );
22947    } else {
22948        for nco in 0..n * c_out {
22949            compute(nco);
22950        }
22951    }
22952}
22953
22954/// im2col + BLAS forward convolution. For each (batch, group) we gather the
22955/// receptive-field patches into a `[c_in_per_g·kH·kW, H_out·W_out]` column
22956/// matrix and dispatch a single `sgemm`:
22957///
22958/// ```text
22959///   out_n_g [c_out_per_g, P]  =  weight_g [c_out_per_g, K]  @  col [K, P]
22960/// ```
22961///
22962/// The result lands in NCHW directly (matching `Conv2D1x1`). The batch loop
22963/// is embarrassingly parallel — each image writes a disjoint output region —
22964/// so it fans out over the thread pool, each worker owning a `col` scratch.
22965#[allow(clippy::too_many_arguments)]
22966fn conv2d_forward_im2col(
22967    inp: &[f32],
22968    wt: &[f32],
22969    out: &mut [f32],
22970    n: usize,
22971    c_in: usize,
22972    h: usize,
22973    w: usize,
22974    c_out: usize,
22975    h_out: usize,
22976    w_out: usize,
22977    kh: usize,
22978    kw: usize,
22979    sh: usize,
22980    sw: usize,
22981    ph: usize,
22982    pw: usize,
22983    dh: usize,
22984    dw: usize,
22985    groups: usize,
22986) {
22987    let c_in_per_g = c_in / groups;
22988    let c_out_per_g = c_out / groups;
22989    let k_dim = c_in_per_g * kh * kw; // im2col rows (contraction dim)
22990    let p_dim = h_out * w_out; // spatial positions
22991    let x_stride_n = c_in * h * w;
22992    let x_stride_g = c_in_per_g * h * w;
22993    let out_stride_n = c_out * h_out * w_out;
22994    let out_stride_g = c_out_per_g * p_dim;
22995    let w_stride_g = c_out_per_g * k_dim;
22996
22997    // Hand each worker the output base as a raw address: every (ni, g) writes
22998    // a disjoint `[out_stride_g]` window, so the aliasing is provably safe.
22999    let out_addr = out.as_mut_ptr() as usize;
23000    crate::pool::par_for(n, 1, &|off, cnt| {
23001        let mut col = vec![0f32; k_dim * p_dim];
23002        for ni in off..off + cnt {
23003            for g in 0..groups {
23004                let x_off = ni * x_stride_n + g * x_stride_g;
23005                im2col(
23006                    &inp[x_off..x_off + x_stride_g],
23007                    &mut col,
23008                    c_in_per_g,
23009                    h,
23010                    w,
23011                    h_out,
23012                    w_out,
23013                    kh,
23014                    kw,
23015                    sh,
23016                    sw,
23017                    ph,
23018                    pw,
23019                    dh,
23020                    dw,
23021                );
23022                let w_off = g * w_stride_g;
23023                let o_off = ni * out_stride_n + g * out_stride_g;
23024                let out_g = unsafe {
23025                    std::slice::from_raw_parts_mut((out_addr as *mut f32).add(o_off), out_stride_g)
23026                };
23027                crate::blas::sgemm(
23028                    &wt[w_off..w_off + w_stride_g],
23029                    &col,
23030                    out_g,
23031                    c_out_per_g,
23032                    k_dim,
23033                    p_dim,
23034                );
23035            }
23036        }
23037    });
23038}
23039
23040fn im2col(
23041    x: &[f32],
23042    col: &mut [f32],
23043    c_in: usize,
23044    h: usize,
23045    w: usize,
23046    h_out: usize,
23047    w_out: usize,
23048    kh: usize,
23049    kw: usize,
23050    sh: usize,
23051    sw: usize,
23052    ph: usize,
23053    pw: usize,
23054    dh: usize,
23055    dw_dil: usize,
23056) {
23057    let n_dim = h_out * w_out;
23058    debug_assert_eq!(col.len(), c_in * kh * kw * n_dim);
23059    debug_assert_eq!(x.len(), c_in * h * w);
23060    let h_isz = h as isize;
23061    let w_isz = w as isize;
23062    let ph_isz = ph as isize;
23063    let pw_isz = pw as isize;
23064    for ci in 0..c_in {
23065        for ki in 0..kh {
23066            for kj in 0..kw {
23067                let row = ((ci * kh) + ki) * kw + kj;
23068                let row_off = row * n_dim;
23069                for ho in 0..h_out {
23070                    let hi = (ho * sh + ki * dh) as isize - ph_isz;
23071                    if hi < 0 || hi >= h_isz {
23072                        for wo in 0..w_out {
23073                            col[row_off + ho * w_out + wo] = 0.0;
23074                        }
23075                        continue;
23076                    }
23077                    let hi = hi as usize;
23078                    let in_row_off = (ci * h + hi) * w;
23079                    for wo in 0..w_out {
23080                        let wi = (wo * sw + kj * dw_dil) as isize - pw_isz;
23081                        col[row_off + ho * w_out + wo] = if wi < 0 || wi >= w_isz {
23082                            0.0
23083                        } else {
23084                            x[in_row_off + wi as usize]
23085                        };
23086                    }
23087                }
23088            }
23089        }
23090    }
23091}
23092
23093/// col2im — inverse of `im2col` with scatter-accumulation. The caller
23094/// is responsible for zeroing `x` if it doesn't already start zero
23095/// (the conv-input-grad path zeros once before the batch loop).
23096///
23097/// `x[ci, hi, wi] += col[(ci · kH · kW + ki · kW + kj) · n_dim + ho · W_out + wo]`
23098/// for all `(ki, kj, ho, wo)` whose `(hi, wi)` lands in `[0, H) × [0, W)`.
23099#[allow(clippy::too_many_arguments)]
23100fn col2im(
23101    col: &[f32],
23102    x: &mut [f32],
23103    c_in: usize,
23104    h: usize,
23105    w: usize,
23106    h_out: usize,
23107    w_out: usize,
23108    kh: usize,
23109    kw: usize,
23110    sh: usize,
23111    sw: usize,
23112    ph: usize,
23113    pw: usize,
23114    dh: usize,
23115    dw_dil: usize,
23116) {
23117    let n_dim = h_out * w_out;
23118    debug_assert_eq!(col.len(), c_in * kh * kw * n_dim);
23119    debug_assert_eq!(x.len(), c_in * h * w);
23120    let h_isz = h as isize;
23121    let w_isz = w as isize;
23122    let ph_isz = ph as isize;
23123    let pw_isz = pw as isize;
23124    for ci in 0..c_in {
23125        for ki in 0..kh {
23126            for kj in 0..kw {
23127                let row = ((ci * kh) + ki) * kw + kj;
23128                let row_off = row * n_dim;
23129                for ho in 0..h_out {
23130                    let hi = (ho * sh + ki * dh) as isize - ph_isz;
23131                    if hi < 0 || hi >= h_isz {
23132                        continue;
23133                    }
23134                    let hi = hi as usize;
23135                    let in_row_off = (ci * h + hi) * w;
23136                    for wo in 0..w_out {
23137                        let wi = (wo * sw + kj * dw_dil) as isize - pw_isz;
23138                        if wi < 0 || wi >= w_isz {
23139                            continue;
23140                        }
23141                        x[in_row_off + wi as usize] += col[row_off + ho * w_out + wo];
23142                    }
23143                }
23144            }
23145        }
23146    }
23147}
23148
23149/// Element-wise backward for `Op::Activation`. `xs` is the original
23150/// input to the forward activation; `dys` is the upstream gradient.
23151/// Writes `out[i] = (d/dx act(xs[i])) * dys[i]`.
23152/// Decompose a per-channel quantization shape into the
23153/// `(chan_axis, chan_dim, inner)` triplet the kernel needs to map a
23154/// flat output index to a channel index. Per-tensor (`axis = None`)
23155/// degenerates to `chan_dim = 1, inner = len`, which makes the
23156/// kernel's `(i / inner) % chan_dim` always 0 — same fast path the
23157/// scalar version used.
23158fn quant_layout(shape: &rlx_ir::Shape, axis: Option<usize>) -> (usize, usize, usize) {
23159    match axis {
23160        None => (0, 1, shape.num_elements().unwrap_or(0).max(1)),
23161        Some(d) => {
23162            let chan_dim = shape.dim(d).unwrap_static();
23163            let inner: usize = (d + 1..shape.rank())
23164                .map(|i| shape.dim(i).unwrap_static())
23165                .product::<usize>()
23166                .max(1);
23167            (d, chan_dim, inner)
23168        }
23169    }
23170}
23171
23172fn activation_backward_kernel(
23173    act: rlx_ir::op::Activation,
23174    xs: &[f32],
23175    dys: &[f32],
23176    out: &mut [f32],
23177) {
23178    use rlx_ir::op::Activation;
23179    let n = xs.len();
23180    debug_assert_eq!(dys.len(), n);
23181    debug_assert_eq!(out.len(), n);
23182    match act {
23183        Activation::Relu => {
23184            for i in 0..n {
23185                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
23186            }
23187        }
23188        Activation::Sigmoid => {
23189            for i in 0..n {
23190                let s = 1.0 / (1.0 + (-xs[i]).exp());
23191                out[i] = s * (1.0 - s) * dys[i];
23192            }
23193        }
23194        Activation::Tanh => {
23195            for i in 0..n {
23196                let t = xs[i].tanh();
23197                out[i] = (1.0 - t * t) * dys[i];
23198            }
23199        }
23200        Activation::Silu => {
23201            // y = x * σ(x);  dy/dx = σ(x) * (1 + x * (1 - σ(x))).
23202            for i in 0..n {
23203                let s = 1.0 / (1.0 + (-xs[i]).exp());
23204                out[i] = s * (1.0 + xs[i] * (1.0 - s)) * dys[i];
23205            }
23206        }
23207        Activation::Gelu => {
23208            // Exact erf-based GELU:  y = 0.5 x (1 + erf(x / √2)).
23209            //   dy/dx = 0.5 (1 + erf(x/√2)) + (x / √(2π)) · exp(-x²/2)
23210            const INV_SQRT2: f32 = 0.707_106_77;
23211            const INV_SQRT_2PI: f32 = 0.398_942_3;
23212            for i in 0..n {
23213                let x = xs[i];
23214                let phi = 0.5 * (1.0 + erf_f32(x * INV_SQRT2));
23215                let pdf = INV_SQRT_2PI * (-(x * x) * 0.5).exp();
23216                out[i] = (phi + x * pdf) * dys[i];
23217            }
23218        }
23219        Activation::GeluApprox => {
23220            // Tanh-approximation:
23221            //   y = 0.5 x (1 + tanh(c · (x + 0.044715 x³))) where c = √(2/π).
23222            const C: f32 = 0.797_884_6; // √(2/π)
23223            const A: f32 = 0.044_715;
23224            for i in 0..n {
23225                let x = xs[i];
23226                let inner = C * (x + A * x * x * x);
23227                let t = inner.tanh();
23228                let dinner = C * (1.0 + 3.0 * A * x * x);
23229                let d = 0.5 * (1.0 + t) + 0.5 * x * (1.0 - t * t) * dinner;
23230                out[i] = d * dys[i];
23231            }
23232        }
23233        Activation::Exp => {
23234            for i in 0..n {
23235                out[i] = xs[i].exp() * dys[i];
23236            }
23237        }
23238        Activation::Log => {
23239            for i in 0..n {
23240                out[i] = dys[i] / xs[i];
23241            }
23242        }
23243        Activation::Sqrt => {
23244            // d/dx √x = 0.5 / √x — undefined at x=0; clamp to 0.
23245            for i in 0..n {
23246                let s = xs[i].sqrt();
23247                out[i] = if s > 0.0 { 0.5 * dys[i] / s } else { 0.0 };
23248            }
23249        }
23250        Activation::Rsqrt => {
23251            // d/dx (1/√x) = -0.5 · x^(-3/2).
23252            for i in 0..n {
23253                let s = xs[i].sqrt();
23254                out[i] = if s > 0.0 {
23255                    -0.5 * dys[i] / (xs[i] * s)
23256                } else {
23257                    0.0
23258                };
23259            }
23260        }
23261        Activation::Neg => {
23262            for i in 0..n {
23263                out[i] = -dys[i];
23264            }
23265        }
23266        Activation::Abs => {
23267            // sign(x); 0 at x=0.
23268            for i in 0..n {
23269                let x = xs[i];
23270                let s = if x > 0.0 {
23271                    1.0
23272                } else if x < 0.0 {
23273                    -1.0
23274                } else {
23275                    0.0
23276                };
23277                out[i] = s * dys[i];
23278            }
23279        }
23280        Activation::Round => {
23281            // STE: pretend the round was identity in the backward
23282            // pass. The round step has zero gradient almost
23283            // everywhere, so without this trick the optimizer can't
23284            // learn through it.
23285            out.copy_from_slice(dys);
23286        }
23287        Activation::Sin => {
23288            // d/dx sin(x) = cos(x).
23289            for i in 0..n {
23290                out[i] = xs[i].cos() * dys[i];
23291            }
23292        }
23293        Activation::Cos => {
23294            for i in 0..n {
23295                out[i] = -xs[i].sin() * dys[i];
23296            }
23297        }
23298        Activation::Tan => {
23299            // d/dx tan(x) = sec²(x) = 1 + tan²(x)
23300            for i in 0..n {
23301                let t = xs[i].tan();
23302                out[i] = (1.0 + t * t) * dys[i];
23303            }
23304        }
23305        Activation::Atan => {
23306            // d/dx atan(x) = 1 / (1 + x²)
23307            for i in 0..n {
23308                let x = xs[i];
23309                out[i] = dys[i] / (1.0 + x * x);
23310            }
23311        }
23312    }
23313}
23314
23315/// f64 sibling of `activation_backward_kernel`. Same math, twice the
23316/// precision — used by f64 graphs where the f32 kernel reading bytes
23317/// as `&[f32]` would silently discard half of every f64 value.
23318fn activation_backward_kernel_f64(
23319    act: rlx_ir::op::Activation,
23320    xs: &[f64],
23321    dys: &[f64],
23322    out: &mut [f64],
23323) {
23324    use rlx_ir::op::Activation;
23325    let n = xs.len();
23326    debug_assert_eq!(dys.len(), n);
23327    debug_assert_eq!(out.len(), n);
23328    match act {
23329        Activation::Relu => {
23330            for i in 0..n {
23331                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
23332            }
23333        }
23334        Activation::Sigmoid => {
23335            for i in 0..n {
23336                let s = 1.0 / (1.0 + (-xs[i]).exp());
23337                out[i] = s * (1.0 - s) * dys[i];
23338            }
23339        }
23340        Activation::Tanh => {
23341            for i in 0..n {
23342                let t = xs[i].tanh();
23343                out[i] = (1.0 - t * t) * dys[i];
23344            }
23345        }
23346        Activation::Silu => {
23347            for i in 0..n {
23348                let s = 1.0 / (1.0 + (-xs[i]).exp());
23349                out[i] = s * (1.0 + xs[i] * (1.0 - s)) * dys[i];
23350            }
23351        }
23352        Activation::Gelu | Activation::GeluApprox => {
23353            // Both rare on f64 paths; use the high-quality libm erf.
23354            const INV_SQRT2: f64 = std::f64::consts::FRAC_1_SQRT_2;
23355            const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
23356            for i in 0..n {
23357                let x = xs[i];
23358                let phi = 0.5 * (1.0 + erf_f64(x * INV_SQRT2));
23359                let pdf = INV_SQRT_2PI * (-(x * x) * 0.5).exp();
23360                out[i] = (phi + x * pdf) * dys[i];
23361            }
23362        }
23363        Activation::Exp => {
23364            for i in 0..n {
23365                out[i] = xs[i].exp() * dys[i];
23366            }
23367        }
23368        Activation::Log => {
23369            for i in 0..n {
23370                out[i] = dys[i] / xs[i];
23371            }
23372        }
23373        Activation::Sqrt => {
23374            for i in 0..n {
23375                let s = xs[i].sqrt();
23376                out[i] = if s > 0.0 { 0.5 * dys[i] / s } else { 0.0 };
23377            }
23378        }
23379        Activation::Rsqrt => {
23380            for i in 0..n {
23381                let s = xs[i].sqrt();
23382                out[i] = if s > 0.0 {
23383                    -0.5 * dys[i] / (xs[i] * s)
23384                } else {
23385                    0.0
23386                };
23387            }
23388        }
23389        Activation::Neg => {
23390            for i in 0..n {
23391                out[i] = -dys[i];
23392            }
23393        }
23394        Activation::Abs => {
23395            for i in 0..n {
23396                let x = xs[i];
23397                let s = if x > 0.0 {
23398                    1.0
23399                } else if x < 0.0 {
23400                    -1.0
23401                } else {
23402                    0.0
23403                };
23404                out[i] = s * dys[i];
23405            }
23406        }
23407        Activation::Round => {
23408            out.copy_from_slice(dys);
23409        }
23410        Activation::Sin => {
23411            for i in 0..n {
23412                out[i] = xs[i].cos() * dys[i];
23413            }
23414        }
23415        Activation::Cos => {
23416            for i in 0..n {
23417                out[i] = -xs[i].sin() * dys[i];
23418            }
23419        }
23420        Activation::Tan => {
23421            for i in 0..n {
23422                let t = xs[i].tan();
23423                out[i] = (1.0 + t * t) * dys[i];
23424            }
23425        }
23426        Activation::Atan => {
23427            for i in 0..n {
23428                let x = xs[i];
23429                out[i] = dys[i] / (1.0 + x * x);
23430            }
23431        }
23432    }
23433}
23434
23435/// f64 erf via A&S 7.1.26 — same coefficients as `erf_f32`, computed
23436/// at f64 width. Max error ~1.5e-7 (limited by the polynomial, not the
23437/// arithmetic). Adequate for gradient kernels; if higher precision is
23438/// needed, swap in a libm dependency.
23439#[inline(always)]
23440fn erf_f64(x: f64) -> f64 {
23441    let s = x.signum();
23442    let x = x.abs();
23443    let t = 1.0 / (1.0 + 0.327_591_1 * x);
23444    let y = 1.0
23445        - (((((1.061_405_43 * t - 1.453_152_03) * t) + 1.421_413_75) * t - 0.284_496_74) * t
23446            + 0.254_829_59)
23447            * t
23448            * (-x * x).exp();
23449    s * y
23450}
23451
23452/// Cheap erf approximation (Abramowitz & Stegun 7.1.26, max error ~1.5e-7
23453/// over all of ℝ — plenty for f32 gradient kernels).
23454#[inline(always)]
23455fn erf_f32(x: f32) -> f32 {
23456    let s = x.signum();
23457    let x = x.abs();
23458    let t = 1.0 / (1.0 + 0.327_591_1 * x);
23459    let y = 1.0
23460        - (((((1.061_405_4 * t - 1.453_152_1) * t) + 1.421_413_8) * t - 0.284_496_74) * t
23461            + 0.254_829_6)
23462            * t
23463            * (-x * x).exp();
23464    s * y
23465}
23466
23467fn narrow_thunk_closure(
23468    src: usize,
23469    dst: usize,
23470    outer: u32,
23471    src_stride: u32,
23472    dst_stride: u32,
23473    inner: u32,
23474    elem_bytes: u8,
23475) -> Arc<dyn Fn(*mut u8) + Send + Sync> {
23476    let (outer, ss, ds, inner, eb) = (
23477        outer as usize,
23478        src_stride as usize,
23479        dst_stride as usize,
23480        inner as usize,
23481        elem_bytes as usize,
23482    );
23483    let row_bytes = inner.saturating_mul(eb);
23484    let src_row_stride = ss.saturating_mul(eb);
23485    let dst_row_stride = ds.saturating_mul(eb);
23486    Arc::new(move |base: *mut u8| unsafe {
23487        if row_bytes == 0 || src == dst {
23488            return;
23489        }
23490        // Compiled-fn path has no arena length; skip if offsets look bogus.
23491        let arena_len = usize::MAX;
23492        for o in 0..outer {
23493            let s_off = src + o * src_row_stride;
23494            let d_off = dst + o * dst_row_stride;
23495            if s_off == d_off {
23496                continue;
23497            }
23498            if s_off.saturating_add(row_bytes) > arena_len
23499                || d_off.saturating_add(row_bytes) > arena_len
23500            {
23501                break;
23502            }
23503            std::ptr::copy_nonoverlapping(base.add(s_off), base.add(d_off), row_bytes);
23504        }
23505    })
23506}
23507
23508unsafe fn sl(offset: usize, base: *mut u8, len: usize) -> &'static [f32] {
23509    if offset == usize::MAX {
23510        return &[];
23511    }
23512    unsafe { std::slice::from_raw_parts(base.add(offset) as *const f32, len) }
23513}
23514
23515#[inline(always)]
23516unsafe fn sl_mut(offset: usize, base: *mut u8, len: usize) -> &'static mut [f32] {
23517    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut f32, len) }
23518}
23519
23520#[inline(always)]
23521unsafe fn sl_f64(offset: usize, base: *mut u8, len: usize) -> &'static [f64] {
23522    if offset == usize::MAX {
23523        return &[];
23524    }
23525    unsafe { std::slice::from_raw_parts(base.add(offset) as *const f64, len) }
23526}
23527
23528#[inline(always)]
23529unsafe fn sl_mut_f64(offset: usize, base: *mut u8, len: usize) -> &'static mut [f64] {
23530    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut f64, len) }
23531}
23532
23533// i32 / i64 typed slice helpers — siblings of sl_f32/sl_f64. Kept for
23534// integer-tensor thunks that haven't landed yet (Sample, Gather index
23535// buffers); deleting them now would force re-deriving the unsafe
23536// boilerplate when the next int-typed thunk lands.
23537#[inline(always)]
23538#[allow(dead_code)]
23539unsafe fn sl_i32(offset: usize, base: *mut u8, len: usize) -> &'static [i32] {
23540    if offset == usize::MAX {
23541        return &[];
23542    }
23543    unsafe { std::slice::from_raw_parts(base.add(offset) as *const i32, len) }
23544}
23545
23546#[inline(always)]
23547#[allow(dead_code)]
23548unsafe fn sl_mut_i32(offset: usize, base: *mut u8, len: usize) -> &'static mut [i32] {
23549    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut i32, len) }
23550}
23551
23552#[inline(always)]
23553unsafe fn sl_i64(offset: usize, base: *mut u8, len: usize) -> &'static [i64] {
23554    if offset == usize::MAX {
23555        return &[];
23556    }
23557    unsafe { std::slice::from_raw_parts(base.add(offset) as *const i64, len) }
23558}
23559
23560#[inline(always)]
23561unsafe fn sl_mut_i64(offset: usize, base: *mut u8, len: usize) -> &'static mut [i64] {
23562    unsafe { std::slice::from_raw_parts_mut(base.add(offset) as *mut i64, len) }
23563}
23564
23565/// f64 N-D index walk used by Transpose and Expand. `out_dims` gives
23566/// the output shape; `in_strides` gives the source stride for each
23567/// output dim (broadcast axes have stride 0).
23568fn transpose_walk_f64(inp: &[f64], out: &mut [f64], out_dims: &[u32], in_strides: &[u32]) {
23569    let rank = out_dims.len();
23570    let mut idx = vec![0u32; rank];
23571    for o in 0..out.len() {
23572        let mut src_off = 0usize;
23573        for d in 0..rank {
23574            src_off += idx[d] as usize * in_strides[d] as usize;
23575        }
23576        out[o] = inp[broadcast_src_index(src_off, inp.len())];
23577        // Increment index — last dim varies fastest.
23578        for d in (0..rank).rev() {
23579            idx[d] += 1;
23580            if idx[d] < out_dims[d] {
23581                break;
23582            }
23583            idx[d] = 0;
23584        }
23585    }
23586}
23587
23588/// f64 elementwise activation. Reads `inp`, writes `out`. For now
23589/// covers what the autodiff-emitted gradient graph needs (Neg, Exp,
23590/// Log, Sqrt, Rsqrt, Abs, Tanh, Sigmoid, Relu — the
23591/// transcendental-free subset). Approximate Gelu/Silu deferred until a
23592/// workload demands them at f64.
23593fn apply_activation_f64(inp: &[f64], out: &mut [f64], kind: Activation) {
23594    match kind {
23595        Activation::Neg => {
23596            for (o, &v) in out.iter_mut().zip(inp) {
23597                *o = -v;
23598            }
23599        }
23600        Activation::Exp => {
23601            for (o, &v) in out.iter_mut().zip(inp) {
23602                *o = v.exp();
23603            }
23604        }
23605        Activation::Log => {
23606            for (o, &v) in out.iter_mut().zip(inp) {
23607                *o = v.ln();
23608            }
23609        }
23610        Activation::Sqrt => {
23611            for (o, &v) in out.iter_mut().zip(inp) {
23612                *o = v.sqrt();
23613            }
23614        }
23615        Activation::Rsqrt => {
23616            for (o, &v) in out.iter_mut().zip(inp) {
23617                *o = 1.0 / v.sqrt();
23618            }
23619        }
23620        Activation::Abs => {
23621            for (o, &v) in out.iter_mut().zip(inp) {
23622                *o = v.abs();
23623            }
23624        }
23625        Activation::Tanh => {
23626            for (o, &v) in out.iter_mut().zip(inp) {
23627                *o = v.tanh();
23628            }
23629        }
23630        Activation::Sigmoid => {
23631            for (o, &v) in out.iter_mut().zip(inp) {
23632                *o = 1.0 / (1.0 + (-v).exp());
23633            }
23634        }
23635        Activation::Relu => {
23636            for (o, &v) in out.iter_mut().zip(inp) {
23637                *o = v.max(0.0);
23638            }
23639        }
23640        Activation::Round => {
23641            for (o, &v) in out.iter_mut().zip(inp) {
23642                *o = v.round_ties_even();
23643            }
23644        }
23645        Activation::Sin => {
23646            for (o, &v) in out.iter_mut().zip(inp) {
23647                *o = v.sin();
23648            }
23649        }
23650        Activation::Cos => {
23651            for (o, &v) in out.iter_mut().zip(inp) {
23652                *o = v.cos();
23653            }
23654        }
23655        Activation::Tan => {
23656            for (o, &v) in out.iter_mut().zip(inp) {
23657                *o = v.tan();
23658            }
23659        }
23660        Activation::Atan => {
23661            for (o, &v) in out.iter_mut().zip(inp) {
23662                *o = v.atan();
23663            }
23664        }
23665        Activation::Gelu | Activation::GeluApprox | Activation::Silu => {
23666            panic!(
23667                "apply_activation_f64: {kind:?} not yet implemented at f64. \
23668                    Add when a workload needs it."
23669            );
23670        }
23671    }
23672}
23673
23674#[inline]
23675fn binary_op_f64(op: BinaryOp, a: f64, b: f64) -> f64 {
23676    match op {
23677        BinaryOp::Add => a + b,
23678        BinaryOp::Sub => a - b,
23679        BinaryOp::Mul => a * b,
23680        BinaryOp::Div => a / b,
23681        BinaryOp::Max => a.max(b),
23682        BinaryOp::Min => a.min(b),
23683        BinaryOp::Pow => a.powf(b),
23684    }
23685}
23686
23687/// f64 sum reduction over a contiguous middle range.
23688/// Layout: input is `[outer, reduced, inner]`, output is `[outer, inner]`.
23689fn reduce_sum_f64(inp: &[f64], out: &mut [f64], outer: usize, reduced: usize, inner: usize) {
23690    for o in 0..outer {
23691        for n in 0..inner {
23692            let mut acc = 0.0_f64;
23693            for r in 0..reduced {
23694                acc += inp[o * reduced * inner + r * inner + n];
23695            }
23696            out[o * inner + n] = acc;
23697        }
23698    }
23699}
23700
23701/// Host-side RNG fill against a byte arena (Metal/CUDA unified-memory fallback).
23702///
23703/// # Safety
23704///
23705/// `arena` must point to a valid allocation with at least `dst_off + len * 4` bytes.
23706pub unsafe fn fill_rng_normal_arena(
23707    dst_off: usize,
23708    len: usize,
23709    mean: f32,
23710    scale: f32,
23711    key: u64,
23712    op_seed: Option<f32>,
23713    opts: rlx_ir::RngOptions,
23714    arena: *mut u8,
23715) {
23716    if len == 0 {
23717        return;
23718    }
23719    unsafe {
23720        let out = std::slice::from_raw_parts_mut((arena.add(dst_off)) as *mut f32, len);
23721        rlx_ir::fill_normal_like(out, mean, scale, opts, key, op_seed);
23722    }
23723}
23724
23725pub unsafe fn fill_rng_uniform_arena(
23726    dst_off: usize,
23727    len: usize,
23728    low: f32,
23729    high: f32,
23730    key: u64,
23731    op_seed: Option<f32>,
23732    opts: rlx_ir::RngOptions,
23733    arena: *mut u8,
23734) {
23735    if len == 0 {
23736        return;
23737    }
23738    unsafe {
23739        let out = std::slice::from_raw_parts_mut((arena.add(dst_off)) as *mut f32, len);
23740        rlx_ir::fill_uniform_like(out, low, high, opts, key, op_seed);
23741    }
23742}
23743
23744#[cfg(test)]
23745mod tests {
23746    use super::*;
23747    use rlx_ir::*;
23748
23749    /// The im2col+BLAS forward conv must match the reference scalar loop
23750    /// (up to float reassociation) across a range of shapes — strided,
23751    /// dilated, padded, grouped, and multi-batch. Guards the RLX_FAST_CONV
23752    /// fast path used by the "fused" benchmark configuration.
23753    #[test]
23754    fn conv2d_im2col_matches_naive() {
23755        // (n, c_in, h, w, c_out, kh, kw, sh, sw, ph, pw, dh, dw, groups)
23756        let cases = [
23757            (1, 1, 28, 28, 8, 3, 3, 1, 1, 0, 0, 1, 1, 1), // TinyConv layer 1
23758            (4, 8, 13, 13, 16, 3, 3, 1, 1, 0, 0, 1, 1, 1), // TinyConv layer 2, batched
23759            (2, 3, 16, 16, 6, 3, 3, 2, 2, 1, 1, 1, 1, 1), // stride 2, pad 1
23760            (1, 4, 12, 12, 4, 3, 3, 1, 1, 2, 2, 2, 2, 1), // dilation 2
23761            (3, 8, 10, 10, 8, 3, 3, 1, 1, 1, 1, 1, 1, 2), // groups = 2
23762            (1, 2, 7, 7, 5, 1, 1, 1, 1, 0, 0, 1, 1, 1),   // 1x1
23763        ];
23764        for (idx, &(n, c_in, h, w, c_out, kh, kw, sh, sw, ph, pw, dh, dw, groups)) in
23765            cases.iter().enumerate()
23766        {
23767            let c_in_per_g = c_in / groups;
23768            let h_out = (h + 2 * ph - dh * (kh - 1) - 1) / sh + 1;
23769            let w_out = (w + 2 * pw - dw * (kw - 1) - 1) / sw + 1;
23770            // Deterministic pseudo-random inputs (no rng dep in tests).
23771            let mut s: u32 = 0x9e37_79b9 ^ (idx as u32 + 1);
23772            let mut rand = || {
23773                s ^= s << 13;
23774                s ^= s >> 17;
23775                s ^= s << 5;
23776                (s as f32 / u32::MAX as f32) - 0.5
23777            };
23778            let inp: Vec<f32> = (0..n * c_in * h * w).map(|_| rand()).collect();
23779            let wt: Vec<f32> = (0..c_out * c_in_per_g * kh * kw).map(|_| rand()).collect();
23780            let mut out_ref = vec![0f32; n * c_out * h_out * w_out];
23781            let mut out_fast = vec![0f32; n * c_out * h_out * w_out];
23782
23783            conv2d_forward_naive(
23784                &inp,
23785                &wt,
23786                &mut out_ref,
23787                n,
23788                c_in,
23789                h,
23790                w,
23791                c_out,
23792                h_out,
23793                w_out,
23794                kh,
23795                kw,
23796                sh,
23797                sw,
23798                ph,
23799                pw,
23800                dh,
23801                dw,
23802                groups,
23803            );
23804            conv2d_forward_im2col(
23805                &inp,
23806                &wt,
23807                &mut out_fast,
23808                n,
23809                c_in,
23810                h,
23811                w,
23812                c_out,
23813                h_out,
23814                w_out,
23815                kh,
23816                kw,
23817                sh,
23818                sw,
23819                ph,
23820                pw,
23821                dh,
23822                dw,
23823                groups,
23824            );
23825
23826            let max_abs = out_ref
23827                .iter()
23828                .zip(&out_fast)
23829                .map(|(a, b)| (a - b).abs())
23830                .fold(0f32, f32::max);
23831            assert!(
23832                max_abs < 1e-3,
23833                "case {idx}: im2col vs naive max abs diff {max_abs}"
23834            );
23835        }
23836    }
23837
23838    /// Direct forward conv (stride-1, no-pad) must match the reference scalar
23839    /// conv exactly-ish across channel/batch/group shapes.
23840    #[test]
23841    fn conv2d_direct_matches_naive() {
23842        // (n, c_in, h, w, c_out, kh, kw, groups)
23843        let cases = [
23844            (1, 1, 28, 28, 8, 3, 3, 1),  // TinyConv L1
23845            (4, 8, 13, 13, 16, 3, 3, 1), // TinyConv L2, batched
23846            (2, 6, 10, 10, 9, 3, 3, 3),  // groups=3
23847            (1, 4, 9, 9, 4, 5, 5, 1),    // 5×5 kernel
23848            (3, 2, 7, 7, 2, 1, 1, 1),    // 1×1
23849        ];
23850        for (idx, &(n, c_in, h, w, c_out, kh, kw, groups)) in cases.iter().enumerate() {
23851            let h_out = h - kh + 1;
23852            let w_out = w - kw + 1;
23853            let c_in_per_g = c_in / groups;
23854            let mut s: u32 = 0xfeed_1234 ^ (idx as u32 + 1);
23855            let mut rand = || {
23856                s ^= s << 13;
23857                s ^= s >> 17;
23858                s ^= s << 5;
23859                (s as f32 / u32::MAX as f32) - 0.5
23860            };
23861            let inp: Vec<f32> = (0..n * c_in * h * w).map(|_| rand()).collect();
23862            let wt: Vec<f32> = (0..c_out * c_in_per_g * kh * kw).map(|_| rand()).collect();
23863            let mut r = vec![0f32; n * c_out * h_out * w_out];
23864            let mut d = vec![0f32; n * c_out * h_out * w_out];
23865            conv2d_forward_naive(
23866                &inp, &wt, &mut r, n, c_in, h, w, c_out, h_out, w_out, kh, kw, 1, 1, 0, 0, 1, 1,
23867                groups,
23868            );
23869            conv2d_forward_direct(
23870                &inp, &wt, &mut d, n, c_in, h, w, c_out, h_out, w_out, kh, kw, groups,
23871            );
23872            let mx = r
23873                .iter()
23874                .zip(&d)
23875                .map(|(a, b)| (a - b).abs())
23876                .fold(0f32, f32::max);
23877            assert!(mx < 1e-4, "case {idx}: direct vs naive max abs diff {mx}");
23878        }
23879    }
23880
23881    /// Winograd F(2,3) forward conv must match the reference scalar conv (up to
23882    /// the transform's float reassociation) for 3×3 stride-1 valid convs,
23883    /// including odd output dims (boundary tiles) and multiple channels/batches.
23884    #[test]
23885    fn conv2d_winograd_matches_naive() {
23886        // (n, c_in, h, w, c_out)  — all 3×3 stride1 valid; covers even (26) and
23887        // odd (11) output dims and the TinyConv channel shapes.
23888        let cases = [
23889            (1, 1, 28, 28, 8),  // TinyConv layer 1: out 26×26 (even)
23890            (4, 8, 13, 13, 16), // TinyConv layer 2: out 11×11 (odd) — boundary tiles
23891            (2, 3, 9, 9, 5),    // out 7×7 (odd)
23892            (1, 4, 8, 8, 4),    // out 6×6 (even)
23893        ];
23894        for (idx, &(n, c_in, h, w, c_out)) in cases.iter().enumerate() {
23895            let h_out = h - 2;
23896            let w_out = w - 2;
23897            let mut s: u32 = 0x1234_5678 ^ (idx as u32 + 1);
23898            let mut rand = || {
23899                s ^= s << 13;
23900                s ^= s >> 17;
23901                s ^= s << 5;
23902                (s as f32 / u32::MAX as f32) - 0.5
23903            };
23904            let inp: Vec<f32> = (0..n * c_in * h * w).map(|_| rand()).collect();
23905            let wt: Vec<f32> = (0..c_out * c_in * 9).map(|_| rand()).collect();
23906            let mut out_ref = vec![0f32; n * c_out * h_out * w_out];
23907            let mut out_win = vec![0f32; n * c_out * h_out * w_out];
23908            conv2d_forward_naive(
23909                &inp,
23910                &wt,
23911                &mut out_ref,
23912                n,
23913                c_in,
23914                h,
23915                w,
23916                c_out,
23917                h_out,
23918                w_out,
23919                3,
23920                3,
23921                1,
23922                1,
23923                0,
23924                0,
23925                1,
23926                1,
23927                1,
23928            );
23929            conv2d_forward_winograd(&inp, &wt, &mut out_win, n, c_in, h, w, c_out, h_out, w_out);
23930            let max_abs = out_ref
23931                .iter()
23932                .zip(&out_win)
23933                .map(|(a, b)| (a - b).abs())
23934                .fold(0f32, f32::max);
23935            assert!(
23936                max_abs < 1e-3,
23937                "case {idx}: winograd vs naive max abs diff {max_abs}"
23938            );
23939        }
23940    }
23941
23942    /// Plan #45: when a Narrow's only consumer is a Rope, the thunk
23943    /// fusion pass collapses them — the Narrow becomes Nop, and the
23944    /// Rope reads from the parent buffer with its row stride. This
23945    /// test runs the unfused path (batch*seq > FusedAttnBlock
23946    /// threshold) and asserts the rewrite happened.
23947    #[test]
23948    fn narrow_rope_fuses_in_unfused_path() {
23949        let f = DType::F32;
23950        let mut g = Graph::new("nr_fuse");
23951        // Force batch*seq > 64 so FusedAttnBlock doesn't pre-empt us.
23952        let qkv = g.input("qkv", Shape::new(&[16, 8, 192], f)); // 16*8=128 > 64
23953        let cos = g.input("cos", Shape::new(&[16], f));
23954        let sin = g.input("sin", Shape::new(&[16], f));
23955        // Last-axis narrow: Q = qkv[..., 0..64]
23956        let q = g.narrow_(qkv, 2, 0, 64);
23957        let q_rope = g.rope(q, cos, sin, 16);
23958        g.set_outputs(vec![q_rope]);
23959
23960        let plan = rlx_opt::memory::plan_memory(&g);
23961        let arena = crate::arena::Arena::from_plan(plan);
23962        let sched = compile_thunks(&g, &arena);
23963
23964        let mut narrow_count = 0;
23965        let mut rope_with_stride: Option<u32> = None;
23966        for t in &sched.thunks {
23967            match t {
23968                Thunk::Narrow { .. } => narrow_count += 1,
23969                Thunk::Rope { src_row_stride, .. } => rope_with_stride = Some(*src_row_stride),
23970                _ => {}
23971            }
23972        }
23973        // After fusion the Narrow is gone; only the Rope remains, and
23974        // it now walks with the parent QKV's row stride (3 * 64 = 192).
23975        assert_eq!(
23976            narrow_count, 0,
23977            "Narrow→Rope fusion should leave zero Narrow thunks; saw {narrow_count}"
23978        );
23979        assert_eq!(
23980            rope_with_stride,
23981            Some(192),
23982            "Rope's src_row_stride should be 192 (parent qkv axis), saw {rope_with_stride:?}"
23983        );
23984    }
23985
23986    /// Plan #15: SSM selective scan matches a naive Python-style
23987    /// Python-style sequential reference.
23988    #[test]
23989    fn ssm_selective_scan_matches_reference() {
23990        use rlx_ir::Philox4x32;
23991        let bch = 1usize;
23992        let s = 4usize;
23993        let h = 3usize;
23994        let n = 2usize;
23995
23996        let mut rng = Philox4x32::new(13);
23997        let mut x = vec![0f32; bch * s * h];
23998        rng.fill_normal(&mut x);
23999        let mut delta = vec![0f32; bch * s * h];
24000        // Keep Δ small so exp(Δ·A) doesn't blow up.
24001        for v in delta.iter_mut() {
24002            *v = (rng.next_f32() - 0.5) * 0.1;
24003        }
24004        let mut a = vec![0f32; h * n];
24005        for v in a.iter_mut() {
24006            *v = -(rng.next_f32() * 0.5 + 0.1);
24007        } // negative for stability
24008        let mut b = vec![0f32; bch * s * n];
24009        rng.fill_normal(&mut b);
24010        let mut c = vec![0f32; bch * s * n];
24011        rng.fill_normal(&mut c);
24012
24013        // Reference scan.
24014        let mut expected = vec![0f32; bch * s * h];
24015        for bi in 0..bch {
24016            let mut state = vec![0f32; h * n];
24017            for si in 0..s {
24018                for ci in 0..h {
24019                    let d = delta[bi * s * h + si * h + ci];
24020                    let xv = x[bi * s * h + si * h + ci];
24021                    let mut acc = 0f32;
24022                    for ni in 0..n {
24023                        let da = (d * a[ci * n + ni]).exp();
24024                        state[ci * n + ni] =
24025                            da * state[ci * n + ni] + d * b[bi * s * n + si * n + ni] * xv;
24026                        acc += c[bi * s * n + si * n + ni] * state[ci * n + ni];
24027                    }
24028                    expected[bi * s * h + si * h + ci] = acc;
24029                }
24030            }
24031        }
24032
24033        // RLX path.
24034        let f = DType::F32;
24035        let mut g = Graph::new("ssm");
24036        let xn = g.input("x", Shape::new(&[bch, s, h], f));
24037        let dn = g.input("delta", Shape::new(&[bch, s, h], f));
24038        let an = g.param("a", Shape::new(&[h, n], f));
24039        let bn = g.param("b", Shape::new(&[bch, s, n], f));
24040        let cn = g.param("c", Shape::new(&[bch, s, n], f));
24041        let yn = g.selective_scan(xn, dn, an, bn, cn, n, Shape::new(&[bch, s, h], f));
24042        g.set_outputs(vec![yn]);
24043
24044        let plan = rlx_opt::memory::plan_memory(&g);
24045        let mut arena = crate::arena::Arena::from_plan(plan);
24046        let sched = compile_thunks(&g, &arena);
24047
24048        let xn_off = arena.byte_offset(xn);
24049        let dn_off = arena.byte_offset(dn);
24050        let an_off = arena.byte_offset(an);
24051        let bn_off = arena.byte_offset(bn);
24052        let cn_off = arena.byte_offset(cn);
24053        let yn_off = arena.byte_offset(yn);
24054        let buf = arena.raw_buf_mut();
24055        unsafe {
24056            let copy = |dst: *mut f32, data: &[f32]| {
24057                for (i, &v) in data.iter().enumerate() {
24058                    *dst.add(i) = v;
24059                }
24060            };
24061            copy(buf.as_mut_ptr().add(xn_off) as *mut f32, &x);
24062            copy(buf.as_mut_ptr().add(dn_off) as *mut f32, &delta);
24063            copy(buf.as_mut_ptr().add(an_off) as *mut f32, &a);
24064            copy(buf.as_mut_ptr().add(bn_off) as *mut f32, &b);
24065            copy(buf.as_mut_ptr().add(cn_off) as *mut f32, &c);
24066        }
24067        execute_thunks(&sched, arena.raw_buf_mut());
24068
24069        let actual: Vec<f32> = unsafe {
24070            let p = arena.raw_buf().as_ptr().add(yn_off) as *const f32;
24071            (0..bch * s * h).map(|i| *p.add(i)).collect()
24072        };
24073
24074        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
24075            assert!(
24076                (e - a).abs() < 1e-3,
24077                "mismatch at {i}: expected {e}, got {a}"
24078            );
24079        }
24080    }
24081
24082    /// Plan #26: 1×1 conv lowers to per-batch sgemm and matches the
24083    /// scalar 7-loop reference.
24084    #[test]
24085    fn conv_1x1_fast_path_matches_scalar() {
24086        use rlx_ir::Philox4x32;
24087        // [N=2, C_in=4, H=3, W=3]
24088        let n = 2usize;
24089        let c_in = 4usize;
24090        let h = 3usize;
24091        let w = 3usize;
24092        let c_out = 5usize;
24093        let mut rng = Philox4x32::new(31);
24094        let mut x = vec![0f32; n * c_in * h * w];
24095        rng.fill_normal(&mut x);
24096        let mut weight = vec![0f32; c_out * c_in];
24097        rng.fill_normal(&mut weight);
24098
24099        // Reference: scalar 1×1 conv = per-batch matmul
24100        // out[ni, co, hi, wi] = sum_ci weight[co, ci] * x[ni, ci, hi, wi]
24101        let mut expected = vec![0f32; n * c_out * h * w];
24102        for ni in 0..n {
24103            for co in 0..c_out {
24104                for hi in 0..h {
24105                    for wi in 0..w {
24106                        let mut acc = 0f32;
24107                        for ci in 0..c_in {
24108                            acc += weight[co * c_in + ci]
24109                                * x[((ni * c_in) + ci) * h * w + hi * w + wi];
24110                        }
24111                        expected[((ni * c_out) + co) * h * w + hi * w + wi] = acc;
24112                    }
24113                }
24114            }
24115        }
24116
24117        // RLX path: build a graph with Op::Conv (kernel=[1,1], stride=[1,1], etc).
24118        let f = DType::F32;
24119        let mut g = Graph::new("conv1x1");
24120        let xn = g.input("x", Shape::new(&[n, c_in, h, w], f));
24121        let wn = g.param("w", Shape::new(&[c_out, c_in, 1, 1], f));
24122        // Manually add Op::Conv since there's no `g.conv()` helper.
24123        let cn = g.add_node(
24124            rlx_ir::Op::Conv {
24125                kernel_size: vec![1, 1],
24126                stride: vec![1, 1],
24127                padding: vec![0, 0],
24128                dilation: vec![1, 1],
24129                groups: 1,
24130            },
24131            vec![xn, wn],
24132            Shape::new(&[n, c_out, h, w], f),
24133        );
24134        g.set_outputs(vec![cn]);
24135
24136        let plan = rlx_opt::memory::plan_memory(&g);
24137        let mut arena = crate::arena::Arena::from_plan(plan);
24138        let sched = compile_thunks(&g, &arena);
24139
24140        // Verify the fast path was selected.
24141        let saw_fast = sched
24142            .thunks
24143            .iter()
24144            .any(|t| matches!(t, Thunk::Conv2D1x1 { .. }));
24145        let saw_slow = sched
24146            .thunks
24147            .iter()
24148            .any(|t| matches!(t, Thunk::Conv2D { .. }));
24149        assert!(saw_fast, "1×1 conv should emit Conv2D1x1");
24150        assert!(!saw_slow, "1×1 conv must not fall through to scalar Conv2D");
24151
24152        let xn_off = arena.byte_offset(xn);
24153        let wn_off = arena.byte_offset(wn);
24154        let cn_off = arena.byte_offset(cn);
24155        let buf = arena.raw_buf_mut();
24156        unsafe {
24157            let xp = buf.as_mut_ptr().add(xn_off) as *mut f32;
24158            for (i, &v) in x.iter().enumerate() {
24159                *xp.add(i) = v;
24160            }
24161            let wp = buf.as_mut_ptr().add(wn_off) as *mut f32;
24162            for (i, &v) in weight.iter().enumerate() {
24163                *wp.add(i) = v;
24164            }
24165        }
24166        execute_thunks(&sched, arena.raw_buf_mut());
24167
24168        let actual: Vec<f32> = unsafe {
24169            let p = arena.raw_buf().as_ptr().add(cn_off) as *const f32;
24170            (0..(n * c_out * h * w)).map(|i| *p.add(i)).collect()
24171        };
24172
24173        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
24174            assert!(
24175                (e - a).abs() < 1e-3,
24176                "mismatch at {i}: expected {e}, got {a}"
24177            );
24178        }
24179    }
24180
24181    /// Plan #5: fused dequant matmul matches the dequant-then-matmul
24182    /// reference (i.e. `(scale * (q - z)) @ x` materialized).
24183    #[test]
24184    fn dequant_matmul_int8_sym_matches_reference() {
24185        use rlx_ir::Philox4x32;
24186        use rlx_ir::quant::QuantScheme;
24187
24188        let m = 3usize;
24189        let k = 8usize;
24190        let n = 4usize;
24191        let block_size = 4usize; // 2 blocks per column
24192        let blocks_per_col = k / block_size;
24193
24194        // Random inputs: x f32, w_q i8, scales f32. Symmetric → no zp.
24195        let mut rng = Philox4x32::new(99);
24196        let mut x = vec![0f32; m * k];
24197        rng.fill_normal(&mut x);
24198        let w_q: Vec<i8> = (0..(k * n))
24199            .map(|i| ((i as i32 * 13 + 7) % 127 - 63) as i8)
24200            .collect();
24201        let scales: Vec<f32> = (0..(blocks_per_col * n))
24202            .map(|i| 0.01 + 0.001 * i as f32)
24203            .collect();
24204
24205        // Reference: build f32 weights from (q * scale) per block.
24206        let mut w_f32 = vec![0f32; k * n];
24207        for p in 0..k {
24208            let block = p / block_size;
24209            for j in 0..n {
24210                let s = scales[block * n + j];
24211                w_f32[p * n + j] = w_q[p * n + j] as f32 * s;
24212            }
24213        }
24214        let mut expected = vec![0f32; m * n];
24215        for i in 0..m {
24216            for j in 0..n {
24217                let mut acc = 0f32;
24218                for p in 0..k {
24219                    acc += x[i * k + p] * w_f32[p * n + j];
24220                }
24221                expected[i * n + j] = acc;
24222            }
24223        }
24224
24225        // RLX path.
24226        let f = DType::F32;
24227        let mut g = Graph::new("dq");
24228        let xn = g.input("x", Shape::new(&[m, k], f));
24229        let wn = g.param("w", Shape::new(&[k, n], DType::I8));
24230        let sn = g.param("scale", Shape::new(&[blocks_per_col, n], f));
24231        let zn = g.param("zp", Shape::new(&[blocks_per_col, n], f)); // unused (sym)
24232        let dq = g.dequant_matmul(
24233            xn,
24234            wn,
24235            sn,
24236            zn,
24237            QuantScheme::Int8Block {
24238                block_size: block_size as u32,
24239            },
24240            Shape::new(&[m, n], f),
24241        );
24242        g.set_outputs(vec![dq]);
24243
24244        let plan = rlx_opt::memory::plan_memory(&g);
24245        let mut arena = crate::arena::Arena::from_plan(plan);
24246        let sched = compile_thunks(&g, &arena);
24247
24248        let xn_off = arena.byte_offset(xn);
24249        let wn_off = arena.byte_offset(wn);
24250        let sn_off = arena.byte_offset(sn);
24251        let zn_off = arena.byte_offset(zn);
24252        let dq_off = arena.byte_offset(dq);
24253        let buf = arena.raw_buf_mut();
24254        unsafe {
24255            // Seed f32 inputs.
24256            let xp = buf.as_mut_ptr().add(xn_off) as *mut f32;
24257            for (i, &v) in x.iter().enumerate() {
24258                *xp.add(i) = v;
24259            }
24260            let sp = buf.as_mut_ptr().add(sn_off) as *mut f32;
24261            for (i, &v) in scales.iter().enumerate() {
24262                *sp.add(i) = v;
24263            }
24264            let zp = buf.as_mut_ptr().add(zn_off) as *mut f32;
24265            for i in 0..(blocks_per_col * n) {
24266                *zp.add(i) = 0.0;
24267            }
24268            // Seed i8 weights byte-by-byte.
24269            let wp = buf.as_mut_ptr().add(wn_off) as *mut i8;
24270            for (i, &v) in w_q.iter().enumerate() {
24271                *wp.add(i) = v;
24272            }
24273        }
24274        execute_thunks(&sched, arena.raw_buf_mut());
24275
24276        let actual: Vec<f32> = unsafe {
24277            let p = arena.raw_buf().as_ptr().add(dq_off) as *const f32;
24278            (0..m * n).map(|i| *p.add(i)).collect()
24279        };
24280
24281        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
24282            assert!(
24283                (e - a).abs() < 1e-3,
24284                "mismatch at {i}: expected {e}, got {a}"
24285            );
24286        }
24287    }
24288
24289    /// Plan #9: LoRA matmul matches the unfused 3-matmul reference.
24290    #[test]
24291    fn lora_matmul_matches_unfused_reference() {
24292        use rlx_ir::Philox4x32;
24293
24294        let m = 4usize;
24295        let k = 8usize;
24296        let n = 6usize;
24297        let r = 2usize;
24298        let scale = 0.5f32;
24299
24300        // Random inputs (deterministic via Philox).
24301        let mut rng = Philox4x32::new(42);
24302        let mut x = vec![0f32; m * k];
24303        rng.fill_normal(&mut x);
24304        let mut w = vec![0f32; k * n];
24305        rng.fill_normal(&mut w);
24306        let mut a = vec![0f32; k * r];
24307        rng.fill_normal(&mut a);
24308        let mut b = vec![0f32; r * n];
24309        rng.fill_normal(&mut b);
24310
24311        // Reference: out = x·W + scale * x·A·B. Naive triple-loop.
24312        let naive = |a_buf: &[f32], b_buf: &[f32], rows: usize, inner: usize, cols: usize| {
24313            let mut o = vec![0f32; rows * cols];
24314            for i in 0..rows {
24315                for j in 0..cols {
24316                    let mut acc = 0f32;
24317                    for p in 0..inner {
24318                        acc += a_buf[i * inner + p] * b_buf[p * cols + j];
24319                    }
24320                    o[i * cols + j] = acc;
24321                }
24322            }
24323            o
24324        };
24325        let xw = naive(&x, &w, m, k, n);
24326        let xa = naive(&x, &a, m, k, r);
24327        let xab = naive(&xa, &b, m, r, n);
24328        let mut expected = xw;
24329        for i in 0..(m * n) {
24330            expected[i] += scale * xab[i];
24331        }
24332
24333        // RLX path: build a graph with one LoraMatMul.
24334        let f = DType::F32;
24335        let mut g = Graph::new("lora");
24336        let xn = g.input("x", Shape::new(&[m, k], f));
24337        let wn = g.param("w", Shape::new(&[k, n], f));
24338        let an = g.param("a", Shape::new(&[k, r], f));
24339        let bn = g.param("b", Shape::new(&[r, n], f));
24340        let lm = g.lora_matmul(xn, wn, an, bn, scale, Shape::new(&[m, n], f));
24341        g.set_outputs(vec![lm]);
24342
24343        let plan = rlx_opt::memory::plan_memory(&g);
24344        let mut arena = crate::arena::Arena::from_plan(plan);
24345        let sched = compile_thunks(&g, &arena);
24346
24347        let xn_off = arena.byte_offset(xn);
24348        let wn_off = arena.byte_offset(wn);
24349        let an_off = arena.byte_offset(an);
24350        let bn_off = arena.byte_offset(bn);
24351        let lm_off = arena.byte_offset(lm);
24352        let buf = arena.raw_buf_mut();
24353        unsafe {
24354            let copy = |dst: *mut f32, data: &[f32]| {
24355                for (i, &v) in data.iter().enumerate() {
24356                    *dst.add(i) = v;
24357                }
24358            };
24359            copy(buf.as_mut_ptr().add(xn_off) as *mut f32, &x);
24360            copy(buf.as_mut_ptr().add(wn_off) as *mut f32, &w);
24361            copy(buf.as_mut_ptr().add(an_off) as *mut f32, &a);
24362            copy(buf.as_mut_ptr().add(bn_off) as *mut f32, &b);
24363        }
24364        execute_thunks(&sched, arena.raw_buf_mut());
24365
24366        let actual: Vec<f32> = unsafe {
24367            let p = arena.raw_buf().as_ptr().add(lm_off) as *const f32;
24368            (0..m * n).map(|i| *p.add(i)).collect()
24369        };
24370
24371        for (i, (e, a)) in expected.iter().zip(&actual).enumerate() {
24372            assert!(
24373                (e - a).abs() < 1e-3,
24374                "mismatch at {i}: expected {e}, got {a}"
24375            );
24376        }
24377    }
24378
24379    /// Plan #42: fused sampling kernel determinism + greedy fallback.
24380    #[test]
24381    fn sample_temperature_zero_is_argmax() {
24382        // Very low temperature → distribution collapses on argmax.
24383        // Same seed → same output bit-for-bit.
24384        let f = DType::F32;
24385        let mut g = Graph::new("samp");
24386        let logits = g.input("logits", Shape::new(&[1, 8], f));
24387        let s = g.sample(logits, 0, 1.0, 1e-3, 42, Shape::new(&[1], f));
24388        g.set_outputs(vec![s]);
24389        let plan = rlx_opt::memory::plan_memory(&g);
24390        let mut arena = crate::arena::Arena::from_plan(plan);
24391        let sched = compile_thunks(&g, &arena);
24392
24393        let logits_off = arena.byte_offset(logits);
24394        let s_off = arena.byte_offset(s);
24395        let buf = arena.raw_buf_mut();
24396        unsafe {
24397            let p = buf.as_mut_ptr().add(logits_off) as *mut f32;
24398            // argmax = index 5 (value 9.0).
24399            let inputs = [0.1f32, 0.2, 0.3, 0.4, 0.5, 9.0, 0.7, 0.8];
24400            for (i, &v) in inputs.iter().enumerate() {
24401                *p.add(i) = v;
24402            }
24403        }
24404        execute_thunks(&sched, arena.raw_buf_mut());
24405
24406        let token = unsafe {
24407            let p = arena.raw_buf().as_ptr().add(s_off) as *const f32;
24408            *p as usize
24409        };
24410        assert_eq!(token, 5, "low-temp sampling should pick the argmax");
24411    }
24412
24413    #[test]
24414    fn sample_top_k_one_is_deterministic() {
24415        // top_k=1 forces only the argmax to have nonzero probability.
24416        let f = DType::F32;
24417        let mut g = Graph::new("samp_k1");
24418        let logits = g.input("logits", Shape::new(&[1, 4], f));
24419        let s = g.sample(logits, 1, 1.0, 1.0, 7, Shape::new(&[1], f));
24420        g.set_outputs(vec![s]);
24421        let plan = rlx_opt::memory::plan_memory(&g);
24422        let mut arena = crate::arena::Arena::from_plan(plan);
24423        let sched = compile_thunks(&g, &arena);
24424
24425        let logits_off = arena.byte_offset(logits);
24426        let s_off = arena.byte_offset(s);
24427        let buf = arena.raw_buf_mut();
24428        unsafe {
24429            let p = buf.as_mut_ptr().add(logits_off) as *mut f32;
24430            let inputs = [0.1f32, 5.0, 0.3, 0.4]; // argmax = 1
24431            for (i, &v) in inputs.iter().enumerate() {
24432                *p.add(i) = v;
24433            }
24434        }
24435        execute_thunks(&sched, arena.raw_buf_mut());
24436        let token = unsafe {
24437            let p = arena.raw_buf().as_ptr().add(s_off) as *const f32;
24438            *p as usize
24439        };
24440        assert_eq!(token, 1);
24441    }
24442
24443    /// Plan #44: cumsum primitive parity vs. naive scan.
24444    #[test]
24445    fn cumsum_inclusive_matches_naive() {
24446        let f = DType::F32;
24447        let mut g = Graph::new("cumsum");
24448        let x = g.input("x", Shape::new(&[2, 4], f));
24449        let cs = g.cumsum(x, -1, false, Shape::new(&[2, 4], f));
24450        g.set_outputs(vec![cs]);
24451        let plan = rlx_opt::memory::plan_memory(&g);
24452        let mut arena = crate::arena::Arena::from_plan(plan);
24453        let sched = compile_thunks(&g, &arena);
24454
24455        // Cache offsets up-front so we can drop the immutable borrow.
24456        let x_off = arena.byte_offset(x);
24457        let out_off = arena.byte_offset(cs);
24458        let buf = arena.raw_buf_mut();
24459        unsafe {
24460            let p = buf.as_mut_ptr().add(x_off) as *mut f32;
24461            let inputs = [1.0f32, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0];
24462            for (i, &v) in inputs.iter().enumerate() {
24463                *p.add(i) = v;
24464            }
24465        }
24466        execute_thunks(&sched, arena.raw_buf_mut());
24467
24468        let out: Vec<f32> = unsafe {
24469            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
24470            (0..8).map(|i| *p.add(i)).collect()
24471        };
24472        assert_eq!(out, vec![1.0, 3.0, 6.0, 10.0, 10.0, 30.0, 60.0, 100.0]);
24473    }
24474
24475    /// Plan #46 deep: Narrow×3 → Attention fusion. The three QKV
24476    /// narrows that BERT/Nomic emit on the unfused (batch*seq > 64)
24477    /// path collapse into a single strided-Attention thunk.
24478    #[test]
24479    fn narrow_attention_fuses_in_unfused_path() {
24480        let f = DType::F32;
24481        let mut g = Graph::new("nattn_fuse");
24482        // batch*seq = 8*16 = 128 > 64 so FusedAttnBlock skips.
24483        let qkv = g.input("qkv", Shape::new(&[8, 16, 192], f)); // 3*64 = 192
24484        let mask = g.input("mask", Shape::new(&[8, 16], f));
24485        let q = g.narrow_(qkv, 2, 0, 64);
24486        let k = g.narrow_(qkv, 2, 64, 64);
24487        let v = g.narrow_(qkv, 2, 128, 64);
24488        let attn = g.attention(q, k, v, mask, 4, 16, Shape::new(&[8, 16, 64], f));
24489        g.set_outputs(vec![attn]);
24490
24491        let plan = rlx_opt::memory::plan_memory(&g);
24492        let arena = crate::arena::Arena::from_plan(plan);
24493        let sched = compile_thunks(&g, &arena);
24494
24495        let mut narrow_count = 0;
24496        let mut attn_strides: Option<(u32, u32, u32)> = None;
24497        for t in &sched.thunks {
24498            match t {
24499                Thunk::Narrow { .. } => narrow_count += 1,
24500                Thunk::Attention {
24501                    q_row_stride,
24502                    k_row_stride,
24503                    v_row_stride,
24504                    ..
24505                } => attn_strides = Some((*q_row_stride, *k_row_stride, *v_row_stride)),
24506                _ => {}
24507            }
24508        }
24509        // After fusion the 3 narrows are gone; Attention now walks the
24510        // QKV with parent row stride = 192 (3 × 64) on all three inputs.
24511        assert_eq!(
24512            narrow_count, 0,
24513            "Narrow×3→Attention fusion should eliminate all 3 narrows; saw {narrow_count}"
24514        );
24515        assert_eq!(
24516            attn_strides,
24517            Some((192, 192, 192)),
24518            "Attention should walk Q/K/V with parent row stride 192"
24519        );
24520    }
24521
24522    /// Regression: when the QKV→Narrow×3→RoPE×2→Attention→OutProj chain
24523    /// collapses into a single `FusedAttnBlock` (small batch·seq), the fused
24524    /// kernel must honor a **causal** mask. It previously applied only the
24525    /// per-key padding mask and dropped `mask_kind`, so a later token leaked
24526    /// into earlier positions (decoder attention attended to the future).
24527    #[test]
24528    fn fused_attn_block_respects_causal_mask() {
24529        let f = DType::F32;
24530        let (s, d, nh, dh) = (5usize, 8usize, 2usize, 4usize);
24531        let half = dh / 2;
24532
24533        let mut g = Graph::new("fused_causal");
24534        let hidden = g.input("hidden", Shape::new(&[s, d], f));
24535        let wqkv = g.input("wqkv", Shape::new(&[d, 3 * d], f));
24536        let wo = g.input("wo", Shape::new(&[d, d], f));
24537        let cos = g.input("cos", Shape::new(&[s, half], f));
24538        let sin = g.input("sin", Shape::new(&[s, half], f));
24539        let qkv = g.matmul(hidden, wqkv, Shape::new(&[s, 3 * d], f));
24540        let q = g.narrow_(qkv, 1, 0, d);
24541        let k = g.narrow_(qkv, 1, d, d);
24542        let v = g.narrow_(qkv, 1, 2 * d, d);
24543        let q3 = g.reshape(q, vec![1, s as i64, d as i64], Shape::new(&[1, s, d], f));
24544        let k3 = g.reshape(k, vec![1, s as i64, d as i64], Shape::new(&[1, s, d], f));
24545        let v3 = g.reshape(v, vec![1, s as i64, d as i64], Shape::new(&[1, s, d], f));
24546        let qr = g.rope(q3, cos, sin, dh);
24547        let kr = g.rope(k3, cos, sin, dh);
24548        let attn = g.attention_kind(
24549            qr,
24550            kr,
24551            v3,
24552            nh,
24553            dh,
24554            rlx_ir::op::MaskKind::Causal,
24555            Shape::new(&[1, s, d], f),
24556        );
24557        let a2 = g.reshape(attn, vec![s as i64, d as i64], Shape::new(&[s, d], f));
24558        let out = g.matmul(a2, wo, Shape::new(&[s, d], f));
24559        g.set_outputs(vec![out]);
24560
24561        // The fusion must actually fire AND carry the causal kind.
24562        let plan = rlx_opt::memory::plan_memory(&g);
24563        let arena = crate::arena::Arena::from_plan(plan);
24564        let sched = compile_thunks(&g, &arena);
24565        assert!(
24566            sched.thunks.iter().any(|t| matches!(
24567                t,
24568                Thunk::FusedAttnBlock {
24569                    mask_kind: rlx_ir::op::MaskKind::Causal,
24570                    ..
24571                }
24572            )),
24573            "expected a FusedAttnBlock carrying MaskKind::Causal"
24574        );
24575
24576        let wqkv_d: Vec<f32> = (0..d * 3 * d)
24577            .map(|i| ((i % 7) as f32 - 3.0) * 0.05)
24578            .collect();
24579        let wo_d: Vec<f32> = (0..d * d).map(|i| ((i % 5) as f32 - 2.0) * 0.05).collect();
24580        let mut cos_d = vec![0f32; s * half];
24581        let mut sin_d = vec![0f32; s * half];
24582        for p in 0..s {
24583            for i in 0..half {
24584                let fr = 1.0f32 / 10000f32.powf(2.0 * i as f32 / dh as f32);
24585                cos_d[p * half + i] = (p as f32 * fr).cos();
24586                sin_d[p * half + i] = (p as f32 * fr).sin();
24587            }
24588        }
24589        let base_h: Vec<f32> = (0..s * d).map(|i| ((i % 11) as f32 - 5.0) * 0.1).collect();
24590        let run = |hin: &[f32]| {
24591            run_graph(
24592                &g,
24593                &[
24594                    (hidden, hin),
24595                    (wqkv, &wqkv_d),
24596                    (wo, &wo_d),
24597                    (cos, &cos_d),
24598                    (sin, &sin_d),
24599                ],
24600                out,
24601                s * d,
24602            )
24603        };
24604        let a = run(&base_h);
24605        // Perturb only the LAST position's hidden row.
24606        let mut changed = base_h.clone();
24607        for j in 0..d {
24608            changed[4 * d + j] += 1.0;
24609        }
24610        let b = run(&changed);
24611        for pos in 0..4 {
24612            for j in 0..d {
24613                let i = pos * d + j;
24614                assert!(
24615                    (a[i] - b[i]).abs() < 1e-5,
24616                    "causal leak at pos {pos}: {} vs {}",
24617                    a[i],
24618                    b[i]
24619                );
24620            }
24621        }
24622        let last: f32 = (0..d).map(|j| (a[4 * d + j] - b[4 * d + j]).abs()).sum();
24623        assert!(last > 1e-4, "last position must react to its own token");
24624    }
24625
24626    // ── Backward / training op parity tests ────────────────────
24627    //
24628    // Strategy: build a graph that contains exactly the backward op
24629    // under test (plus its inputs as graph Inputs), execute, and
24630    // compare against a hand-rolled scalar reference. For
24631    // Conv2dBackwardInput we additionally check against the numerical
24632    // gradient of the forward Conv2D — that's the gold-standard test
24633    // that validates the math, not just consistency between two
24634    // implementations of the same formula.
24635
24636    fn run_graph(
24637        g: &Graph,
24638        inputs: &[(NodeId, &[f32])],
24639        out_id: NodeId,
24640        out_len: usize,
24641    ) -> Vec<f32> {
24642        let plan = rlx_opt::memory::plan_memory(g);
24643        let mut arena = crate::arena::Arena::from_plan(plan);
24644        let sched = compile_thunks(g, &arena);
24645        for &(id, data) in inputs {
24646            let off = arena.byte_offset(id);
24647            let buf = arena.raw_buf_mut();
24648            unsafe {
24649                let p = buf.as_mut_ptr().add(off) as *mut f32;
24650                for (i, &v) in data.iter().enumerate() {
24651                    *p.add(i) = v;
24652                }
24653            }
24654        }
24655        execute_thunks(&sched, arena.raw_buf_mut());
24656        let off = arena.byte_offset(out_id);
24657        unsafe {
24658            let p = arena.raw_buf().as_ptr().add(off) as *const f32;
24659            (0..out_len).map(|i| *p.add(i)).collect()
24660        }
24661    }
24662
24663    #[test]
24664    fn relu_backward_matches_mask() {
24665        let f = DType::F32;
24666        let len = 7usize;
24667        let x: Vec<f32> = vec![-2.0, -0.1, 0.0, 0.1, 1.0, 3.0, -5.0];
24668        let dy: Vec<f32> = vec![0.5, 1.5, 2.5, -0.7, 4.0, -1.0, 9.0];
24669
24670        let mut g = Graph::new("relu_bw");
24671        let xn = g.input("x", Shape::new(&[len], f));
24672        let dyn_ = g.input("dy", Shape::new(&[len], f));
24673        let dx = g.relu_backward(xn, dyn_);
24674        g.set_outputs(vec![dx]);
24675
24676        let actual = run_graph(&g, &[(xn, &x), (dyn_, &dy)], dx, len);
24677        // Reference: gradient is dy where x>0 strictly, else 0.
24678        // (zero is not "positive" — the forward applied max(0, x), and at
24679        // x=0 the subgradient could be anything in [0, dy]; we pick 0.)
24680        let expected: Vec<f32> = x
24681            .iter()
24682            .zip(&dy)
24683            .map(|(&xi, &dyi)| if xi > 0.0 { dyi } else { 0.0 })
24684            .collect();
24685        for (a, e) in actual.iter().zip(&expected) {
24686            assert!((a - e).abs() < 1e-6, "relu_bw mismatch: {a} vs {e}");
24687        }
24688    }
24689
24690    #[test]
24691    fn maxpool2d_backward_routes_to_argmax() {
24692        let f = DType::F32;
24693        // [N=1, C=1, H=4, W=4] → 2x2 max-pool stride 2 → [1,1,2,2].
24694        let x: Vec<f32> = vec![
24695            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,
24696        ];
24697        // Argmax of each 2x2 window:
24698        //   (0,0)→6 (idx 5), (0,1)→8 (idx 7),
24699        //   (1,0)→14(idx 13),(1,1)→16(idx 15).
24700        let dy: Vec<f32> = vec![0.5, 1.0, 2.0, 4.0];
24701
24702        let mut g = Graph::new("maxpool_bw");
24703        let xn = g.input("x", Shape::new(&[1, 1, 4, 4], f));
24704        let dyn_ = g.input("dy", Shape::new(&[1, 1, 2, 2], f));
24705        let dx = g.maxpool2d_backward(xn, dyn_, vec![2, 2], vec![2, 2], vec![0, 0]);
24706        g.set_outputs(vec![dx]);
24707
24708        let actual = run_graph(&g, &[(xn, &x), (dyn_, &dy)], dx, 16);
24709        let mut expected = vec![0f32; 16];
24710        expected[5] = 0.5;
24711        expected[7] = 1.0;
24712        expected[13] = 2.0;
24713        expected[15] = 4.0;
24714        for (i, (a, e)) in actual.iter().zip(&expected).enumerate() {
24715            assert!((a - e).abs() < 1e-6, "maxpool_bw[{i}] mismatch: {a} vs {e}");
24716        }
24717    }
24718
24719    #[test]
24720    fn conv2d_backward_input_matches_numerical_gradient() {
24721        use rlx_ir::Philox4x32;
24722        // Small enough to numerically differentiate exhaustively but
24723        // big enough to exercise stride/padding edge cases.
24724        let n = 1usize;
24725        let c_in = 2usize;
24726        let h = 4usize;
24727        let w = 4usize;
24728        let c_out = 3usize;
24729        let kh = 3usize;
24730        let kw = 3usize;
24731        let ph = 1usize;
24732        let pw = 1usize;
24733        let sh = 1usize;
24734        let sw = 1usize;
24735        // Output dims with padding=1, stride=1: same as input.
24736        let h_out = (h + 2 * ph - kh) / sh + 1;
24737        let w_out = (w + 2 * pw - kw) / sw + 1;
24738        assert_eq!(h_out, 4);
24739        assert_eq!(w_out, 4);
24740
24741        let mut rng = Philox4x32::new(7);
24742        let mut x = vec![0f32; n * c_in * h * w];
24743        rng.fill_normal(&mut x);
24744        let mut wt = vec![0f32; c_out * c_in * kh * kw];
24745        rng.fill_normal(&mut wt);
24746        let mut dy = vec![0f32; n * c_out * h_out * w_out];
24747        rng.fill_normal(&mut dy);
24748
24749        // Analytical: Conv2dBackwardInput on (dy, w).
24750        let f = DType::F32;
24751        let mut g = Graph::new("conv_bwi");
24752        let dy_in = g.input("dy", Shape::new(&[n, c_out, h_out, w_out], f));
24753        let w_in = g.input("w", Shape::new(&[c_out, c_in, kh, kw], f));
24754        let dx = g.conv2d_backward_input(
24755            dy_in,
24756            w_in,
24757            Shape::new(&[n, c_in, h, w], f),
24758            vec![kh, kw],
24759            vec![sh, sw],
24760            vec![ph, pw],
24761            vec![1, 1],
24762            1,
24763        );
24764        g.set_outputs(vec![dx]);
24765        let analytical = run_graph(&g, &[(dy_in, &dy), (w_in, &wt)], dx, n * c_in * h * w);
24766
24767        // Numerical: for each x[i], finite-difference forward conv twice.
24768        // Forward: y[j] = sum over filter window of w * x ; dot(dy, y) is
24769        // the scalar we differentiate. Then dx[i] = ∂(dot(dy, y))/∂x[i].
24770        let forward = |x: &[f32]| -> Vec<f32> {
24771            let mut out = vec![0f32; n * c_out * h_out * w_out];
24772            for ni in 0..n {
24773                for co in 0..c_out {
24774                    for ho in 0..h_out {
24775                        for wo in 0..w_out {
24776                            let mut acc = 0f32;
24777                            for ci in 0..c_in {
24778                                for ki in 0..kh {
24779                                    for kj in 0..kw {
24780                                        let hi = ho * sh + ki;
24781                                        let wi = wo * sw + kj;
24782                                        if hi < ph || wi < pw {
24783                                            continue;
24784                                        }
24785                                        let hi = hi - ph;
24786                                        let wi = wi - pw;
24787                                        if hi >= h || wi >= w {
24788                                            continue;
24789                                        }
24790                                        let xv = x[((ni * c_in) + ci) * h * w + hi * w + wi];
24791                                        let wv = wt[((co * c_in) + ci) * kh * kw + ki * kw + kj];
24792                                        acc += xv * wv;
24793                                    }
24794                                }
24795                            }
24796                            out[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = acc;
24797                        }
24798                    }
24799                }
24800            }
24801            out
24802        };
24803        let dot = |a: &[f32], b: &[f32]| -> f32 { a.iter().zip(b).map(|(&u, &v)| u * v).sum() };
24804        let eps = 1e-3f32;
24805        let mut numerical = vec![0f32; x.len()];
24806        for i in 0..x.len() {
24807            let saved = x[i];
24808            x[i] = saved + eps;
24809            let plus = dot(&forward(&x), &dy);
24810            x[i] = saved - eps;
24811            let minus = dot(&forward(&x), &dy);
24812            x[i] = saved;
24813            numerical[i] = (plus - minus) / (2.0 * eps);
24814        }
24815        for (i, (a, n)) in analytical.iter().zip(&numerical).enumerate() {
24816            // f32 + eps=1e-3 numerical grad → ~1e-3 absolute is realistic.
24817            assert!(
24818                (a - n).abs() < 5e-3,
24819                "conv_bw_input[{i}]: analytical {a} vs numerical {n}"
24820            );
24821        }
24822    }
24823
24824    #[test]
24825    fn conv2d_backward_weight_matches_numerical_gradient() {
24826        use rlx_ir::Philox4x32;
24827        let n = 2usize;
24828        let c_in = 2usize;
24829        let h = 4usize;
24830        let w = 4usize;
24831        let c_out = 2usize;
24832        let kh = 3usize;
24833        let kw = 3usize;
24834        let ph = 0usize;
24835        let pw = 0usize;
24836        let sh = 1usize;
24837        let sw = 1usize;
24838        let h_out = (h + 2 * ph - kh) / sh + 1;
24839        let w_out = (w + 2 * pw - kw) / sw + 1;
24840
24841        let mut rng = Philox4x32::new(11);
24842        let mut x = vec![0f32; n * c_in * h * w];
24843        rng.fill_normal(&mut x);
24844        let mut wt = vec![0f32; c_out * c_in * kh * kw];
24845        rng.fill_normal(&mut wt);
24846        let mut dy = vec![0f32; n * c_out * h_out * w_out];
24847        rng.fill_normal(&mut dy);
24848
24849        let f = DType::F32;
24850        let mut g = Graph::new("conv_bww");
24851        let xn = g.input("x", Shape::new(&[n, c_in, h, w], f));
24852        let dyn_ = g.input("dy", Shape::new(&[n, c_out, h_out, w_out], f));
24853        let dwn = g.conv2d_backward_weight(
24854            xn,
24855            dyn_,
24856            Shape::new(&[c_out, c_in, kh, kw], f),
24857            vec![kh, kw],
24858            vec![sh, sw],
24859            vec![ph, pw],
24860            vec![1, 1],
24861            1,
24862        );
24863        g.set_outputs(vec![dwn]);
24864        let analytical = run_graph(&g, &[(xn, &x), (dyn_, &dy)], dwn, c_out * c_in * kh * kw);
24865
24866        let forward = |wt: &[f32]| -> Vec<f32> {
24867            let mut out = vec![0f32; n * c_out * h_out * w_out];
24868            for ni in 0..n {
24869                for co in 0..c_out {
24870                    for ho in 0..h_out {
24871                        for wo in 0..w_out {
24872                            let mut acc = 0f32;
24873                            for ci in 0..c_in {
24874                                for ki in 0..kh {
24875                                    for kj in 0..kw {
24876                                        let hi = ho + ki;
24877                                        let wi = wo + kj;
24878                                        let xv = x[((ni * c_in) + ci) * h * w + hi * w + wi];
24879                                        let wv = wt[((co * c_in) + ci) * kh * kw + ki * kw + kj];
24880                                        acc += xv * wv;
24881                                    }
24882                                }
24883                            }
24884                            out[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = acc;
24885                        }
24886                    }
24887                }
24888            }
24889            out
24890        };
24891        let dot = |a: &[f32], b: &[f32]| -> f32 { a.iter().zip(b).map(|(&u, &v)| u * v).sum() };
24892        let eps = 1e-3f32;
24893        let mut numerical = vec![0f32; wt.len()];
24894        for i in 0..wt.len() {
24895            let saved = wt[i];
24896            wt[i] = saved + eps;
24897            let plus = dot(&forward(&wt), &dy);
24898            wt[i] = saved - eps;
24899            let minus = dot(&forward(&wt), &dy);
24900            wt[i] = saved;
24901            numerical[i] = (plus - minus) / (2.0 * eps);
24902        }
24903        for (i, (a, n)) in analytical.iter().zip(&numerical).enumerate() {
24904            assert!(
24905                (a - n).abs() < 5e-3,
24906                "conv_bw_weight[{i}]: analytical {a} vs numerical {n}"
24907            );
24908        }
24909    }
24910
24911    #[test]
24912    fn softmax_cross_entropy_matches_reference() {
24913        let f = DType::F32;
24914        let logits: Vec<f32> = vec![
24915            1.0, 2.0, 3.0, // row 0: max=3 (idx 2)
24916            -1.0, 0.0, 4.0, // row 1: max=4 (idx 2)
24917            5.0, 5.0, 5.0, // row 2: uniform
24918        ];
24919        let labels: Vec<f32> = vec![2.0, 0.0, 1.0];
24920
24921        let mut g = Graph::new("sce");
24922        let lg = g.input("logits", Shape::new(&[3, 3], f));
24923        let lb = g.input("labels", Shape::new(&[3], f));
24924        let loss = g.softmax_cross_entropy_with_logits(lg, lb);
24925        g.set_outputs(vec![loss]);
24926        let actual = run_graph(&g, &[(lg, &logits), (lb, &labels)], loss, 3);
24927
24928        // Reference per-row: -log(softmax(row)[label]).
24929        let mut expected = vec![0f32; 3];
24930        for ni in 0..3 {
24931            let row = &logits[ni * 3..(ni + 1) * 3];
24932            let m = row.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
24933            let sum: f32 = row.iter().map(|&v| (v - m).exp()).sum();
24934            let lse = m + sum.ln();
24935            let label_idx = labels[ni] as usize;
24936            expected[ni] = lse - row[label_idx];
24937        }
24938        for (i, (a, e)) in actual.iter().zip(&expected).enumerate() {
24939            assert!((a - e).abs() < 1e-5, "sce loss[{i}]: {a} vs {e}");
24940        }
24941    }
24942
24943    #[test]
24944    fn softmax_cross_entropy_backward_matches_numerical_gradient() {
24945        use rlx_ir::Philox4x32;
24946        let n = 4usize;
24947        let c = 5usize;
24948        let mut rng = Philox4x32::new(23);
24949        let mut logits = vec![0f32; n * c];
24950        rng.fill_normal(&mut logits);
24951        let labels: Vec<f32> = (0..n).map(|i| (i % c) as f32).collect();
24952        let mut d_loss = vec![0f32; n];
24953        rng.fill_normal(&mut d_loss);
24954
24955        let f = DType::F32;
24956        let mut g = Graph::new("sce_bw");
24957        let lg = g.input("logits", Shape::new(&[n, c], f));
24958        let lb = g.input("labels", Shape::new(&[n], f));
24959        let dl = g.input("d_loss", Shape::new(&[n], f));
24960        let dlogits = g.softmax_cross_entropy_backward(lg, lb, dl);
24961        g.set_outputs(vec![dlogits]);
24962        let analytical = run_graph(
24963            &g,
24964            &[(lg, &logits), (lb, &labels), (dl, &d_loss)],
24965            dlogits,
24966            n * c,
24967        );
24968
24969        // Numerical: differentiate dot(d_loss, sce_loss(logits)) w.r.t. each logit.
24970        let sce_loss = |logits: &[f32]| -> Vec<f32> {
24971            let mut out = vec![0f32; n];
24972            for ni in 0..n {
24973                let row = &logits[ni * c..(ni + 1) * c];
24974                let m = row.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
24975                let sum: f32 = row.iter().map(|&v| (v - m).exp()).sum();
24976                out[ni] = (m + sum.ln()) - row[labels[ni] as usize];
24977            }
24978            out
24979        };
24980        let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(&u, &v)| u * v).sum::<f32>();
24981        let eps = 1e-3f32;
24982        let mut numerical = vec![0f32; logits.len()];
24983        for i in 0..logits.len() {
24984            let saved = logits[i];
24985            logits[i] = saved + eps;
24986            let plus = dot(&sce_loss(&logits), &d_loss);
24987            logits[i] = saved - eps;
24988            let minus = dot(&sce_loss(&logits), &d_loss);
24989            logits[i] = saved;
24990            numerical[i] = (plus - minus) / (2.0 * eps);
24991        }
24992        for (i, (a, num)) in analytical.iter().zip(&numerical).enumerate() {
24993            assert!(
24994                (a - num).abs() < 5e-3,
24995                "sce_bw[{i}]: analytical {a} vs numerical {num}"
24996            );
24997        }
24998    }
24999
25000    // ── End-to-end autodiff parity tests ──────────────────────
25001    //
25002    // Build a forward graph, run `grad_with_loss` to produce a graph
25003    // that emits [loss, gradients...], execute it through rlx-cpu,
25004    // and compare each gradient to a finite-difference estimate
25005    // produced by re-running the forward graph with each parameter
25006    // entry perturbed. f32 + ε=1e-3 puts the tolerance floor around
25007    // 5e-3 absolute error.
25008
25009    /// Initialize Op::Constant slots in the arena with their literal
25010    /// data. Mirrors the loop in rlx_runtime::backend (which serves
25011    /// the same role for production runs).
25012    fn fill_constants_into_arena(graph: &Graph, arena: &mut crate::arena::Arena) {
25013        for node in graph.nodes() {
25014            if let Op::Constant { data } = &node.op
25015                && arena.has_buffer(node.id)
25016                && !data.is_empty()
25017            {
25018                let buf = arena.slice_mut(node.id);
25019                let n_floats = data.len() / 4;
25020                let n = buf.len().min(n_floats);
25021                for i in 0..n {
25022                    let bytes = [
25023                        data[i * 4],
25024                        data[i * 4 + 1],
25025                        data[i * 4 + 2],
25026                        data[i * 4 + 3],
25027                    ];
25028                    buf[i] = f32::from_le_bytes(bytes);
25029                }
25030            }
25031        }
25032    }
25033
25034    /// Compile + arena-prep helper for these tests. Returns the
25035    /// schedule and a populated arena. `seed_inputs` writes f32 input
25036    /// data into the arena slot for each (NodeId, &[f32]) pair.
25037    fn prepare(
25038        graph: &Graph,
25039        seed_inputs: &[(NodeId, &[f32])],
25040    ) -> (ThunkSchedule, crate::arena::Arena) {
25041        let plan = rlx_opt::memory::plan_memory(graph);
25042        let mut arena = crate::arena::Arena::from_plan(plan);
25043        let sched = compile_thunks(graph, &arena);
25044        fill_constants_into_arena(graph, &mut arena);
25045        for &(id, data) in seed_inputs {
25046            let off = arena.byte_offset(id);
25047            let buf = arena.raw_buf_mut();
25048            unsafe {
25049                let p = buf.as_mut_ptr().add(off) as *mut f32;
25050                for (i, &v) in data.iter().enumerate() {
25051                    *p.add(i) = v;
25052                }
25053            }
25054        }
25055        (sched, arena)
25056    }
25057
25058    fn read_arena(arena: &crate::arena::Arena, id: NodeId, len: usize) -> Vec<f32> {
25059        let off = arena.byte_offset(id);
25060        unsafe {
25061            let p = arena.raw_buf().as_ptr().add(off) as *const f32;
25062            (0..len).map(|i| *p.add(i)).collect()
25063        }
25064    }
25065
25066    fn write_arena(arena: &mut crate::arena::Arena, id: NodeId, data: &[f32]) {
25067        let off = arena.byte_offset(id);
25068        let buf = arena.raw_buf_mut();
25069        unsafe {
25070            let p = buf.as_mut_ptr().add(off) as *mut f32;
25071            for (i, &v) in data.iter().enumerate() {
25072                *p.add(i) = v;
25073            }
25074        }
25075    }
25076
25077    /// f64 sibling of `prepare`. Writes f64 input data into the arena.
25078    fn prepare_f64(
25079        graph: &Graph,
25080        seed_inputs: &[(NodeId, &[f64])],
25081    ) -> (ThunkSchedule, crate::arena::Arena) {
25082        let plan = rlx_opt::memory::plan_memory(graph);
25083        let mut arena = crate::arena::Arena::from_plan(plan);
25084        let sched = compile_thunks(graph, &arena);
25085        fill_constants_into_arena(graph, &mut arena);
25086        for &(id, data) in seed_inputs {
25087            let off = arena.byte_offset(id);
25088            let buf = arena.raw_buf_mut();
25089            unsafe {
25090                let p = buf.as_mut_ptr().add(off) as *mut f64;
25091                for (i, &v) in data.iter().enumerate() {
25092                    *p.add(i) = v;
25093                }
25094            }
25095        }
25096        (sched, arena)
25097    }
25098
25099    fn read_arena_f64(arena: &crate::arena::Arena, id: NodeId, len: usize) -> Vec<f64> {
25100        let off = arena.byte_offset(id);
25101        unsafe {
25102            let p = arena.raw_buf().as_ptr().add(off) as *const f64;
25103            (0..len).map(|i| *p.add(i)).collect()
25104        }
25105    }
25106
25107    /// End-to-end f64 DenseSolve through the full compile + execute
25108    /// path. Validates: IR shape inference, memory planner f64 sizing,
25109    /// arena f64 accessors, Thunk::DenseSolveF64 lowering, executor
25110    /// dispatch, Accelerate dgesv FFI.
25111    ///
25112    /// System:
25113    ///   A = [[2, 1],
25114    ///        [1, 3]]   b = [5, 10]
25115    ///   ⇒  x = [1, 3]   (verified by hand)
25116    #[test]
25117    fn dense_solve_f64_end_to_end() {
25118        let mut g = Graph::new("solve_e2e");
25119        let a = g.input("A", Shape::new(&[2, 2], DType::F64));
25120        let b = g.input("b", Shape::new(&[2], DType::F64));
25121        let x = g.dense_solve(a, b, Shape::new(&[2], DType::F64));
25122        g.set_outputs(vec![x]);
25123
25124        let a_data = [2.0, 1.0, 1.0, 3.0_f64];
25125        let b_data = [5.0, 10.0_f64];
25126        let (sched, mut arena) = prepare_f64(&g, &[(a, &a_data), (b, &b_data)]);
25127        execute_thunks(&sched, arena.raw_buf_mut());
25128
25129        let got = read_arena_f64(&arena, x, 2);
25130        let want = [1.0, 3.0_f64];
25131        for i in 0..2 {
25132            assert!(
25133                (got[i] - want[i]).abs() < 1e-12,
25134                "x[{i}] = {} (expected {})",
25135                got[i],
25136                want[i]
25137            );
25138        }
25139    }
25140
25141    /// Scaled-up f64 DenseSolve — tridiagonal Laplacian-shape (typical
25142    /// MNA structure for a passive RC mesh in Circulax). Validates
25143    /// that the solve scales beyond the trivial 2×2 and that the
25144    /// row-major ↔ col-major dance in `dgesv` is correct for the
25145    /// general case.
25146    #[test]
25147    fn dense_solve_f64_5x5_laplacian() {
25148        let n = 5usize;
25149        let mut g = Graph::new("solve_5x5");
25150        let a = g.input("A", Shape::new(&[n, n], DType::F64));
25151        let b = g.input("b", Shape::new(&[n], DType::F64));
25152        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
25153        g.set_outputs(vec![x]);
25154
25155        // 1-D Laplacian: 2 on diagonal, -1 on off-diagonals, 0 elsewhere.
25156        let mut a_data = vec![0.0_f64; n * n];
25157        for i in 0..n {
25158            a_data[i * n + i] = 2.0;
25159            if i > 0 {
25160                a_data[i * n + (i - 1)] = -1.0;
25161            }
25162            if i + 1 < n {
25163                a_data[i * n + (i + 1)] = -1.0;
25164            }
25165        }
25166        let b_data: Vec<f64> = (0..n).map(|i| (i + 1) as f64).collect();
25167        let (sched, mut arena) = prepare_f64(&g, &[(a, &a_data), (b, &b_data)]);
25168        execute_thunks(&sched, arena.raw_buf_mut());
25169
25170        let got = read_arena_f64(&arena, x, n);
25171        // Verify A·x ≈ b by computing the residual.
25172        let mut residual = vec![0.0_f64; n];
25173        for i in 0..n {
25174            for j in 0..n {
25175                residual[i] += a_data[i * n + j] * got[j];
25176            }
25177        }
25178        for i in 0..n {
25179            assert!(
25180                (residual[i] - b_data[i]).abs() < 1e-10,
25181                "row {i}: residual {} vs b {}",
25182                residual[i],
25183                b_data[i]
25184            );
25185        }
25186    }
25187
25188    /// Hello Resistor: end-to-end f64 gradient through a dense solve.
25189    ///
25190    /// Forward:
25191    ///   A      : Param  [N, N]   f64
25192    ///   b      : Input  [N]      f64
25193    ///   x      = solve(A, b)            (DenseSolve)
25194    ///   loss   = sum(x)                 (Reduce::Sum)
25195    ///
25196    /// Backward (via grad_with_loss):
25197    ///   ones [N] = expand(d_output, [N])      (Reduce::Sum VJP)
25198    ///   dx_int   = solve(Aᵀ, ones)             (DenseSolve VJP step 1)
25199    ///   dA       = -outer(dx_int, x)           (DenseSolve VJP step 2)
25200    ///   db       = dx_int                       (DenseSolve VJP step 3)
25201    ///
25202    /// Closed form: with loss = sum(solve(A, b)) = ones·x and
25203    /// implicit-function calculus, db = (Aᵀ)⁻¹·ones, dA = -db ⊗ x.
25204    /// We verify this against the autodiff-emitted graph's output and
25205    /// against a finite-difference baseline.
25206    #[test]
25207    fn hello_resistor_gradient_end_to_end() {
25208        use rlx_opt::autodiff::grad_with_loss;
25209        let n = 3usize;
25210
25211        // ── Build forward graph ──
25212        let mut g = Graph::new("hello_resistor");
25213        let a = g.param("A", Shape::new(&[n, n], DType::F64));
25214        let b = g.input("b", Shape::new(&[n], DType::F64));
25215        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
25216        let loss = g.reduce(
25217            x,
25218            ReduceOp::Sum,
25219            vec![0],
25220            false,
25221            Shape::new(&[1], DType::F64),
25222        );
25223        g.set_outputs(vec![loss]);
25224
25225        // ── Run reverse-mode AD ──
25226        let bwd = grad_with_loss(&g, &[a, b]);
25227        assert_eq!(bwd.outputs.len(), 3, "expect [loss, dA, db]");
25228
25229        // ── Locate the inputs the bwd graph still needs from us ──
25230        // grad_with_loss copies forward nodes into bwd, so A/b/d_output
25231        // appear under their original names. Find them by name.
25232        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
25233            for node in graph.nodes() {
25234                let name = match &node.op {
25235                    rlx_ir::Op::Input { name } => Some(name.as_str()),
25236                    rlx_ir::Op::Param { name } => Some(name.as_str()),
25237                    _ => None,
25238                };
25239                if name == Some(want) {
25240                    return node.id;
25241                }
25242            }
25243            panic!("no node named {want:?} in bwd graph");
25244        };
25245        let a_bwd = find_by_name(&bwd, "A");
25246        let b_bwd = find_by_name(&bwd, "b");
25247        let d_out_bwd = find_by_name(&bwd, "d_output");
25248
25249        // ── Test data ──
25250        // A = [[2,1,0],[1,3,1],[0,1,2]]   (SPD tridiagonal, well-conditioned)
25251        // b = [1,2,3]
25252        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
25253        let b_data = [1.0, 2.0, 3.0_f64];
25254        let d_output = [1.0_f64]; // ∂loss/∂loss
25255
25256        // ── Compile + execute backward graph ──
25257        let (sched, mut arena) = prepare_f64(
25258            &bwd,
25259            &[(a_bwd, &a_data), (b_bwd, &b_data), (d_out_bwd, &d_output)],
25260        );
25261        execute_thunks(&sched, arena.raw_buf_mut());
25262
25263        let loss_out = read_arena_f64(&arena, bwd.outputs[0], 1);
25264        let da_out = read_arena_f64(&arena, bwd.outputs[1], n * n);
25265        let db_out = read_arena_f64(&arena, bwd.outputs[2], n);
25266
25267        // ── Closed-form reference ──
25268        // x = A⁻¹ b ; loss = sum(x).
25269        let x_ref = {
25270            let mut a = a_data;
25271            let mut b = b_data;
25272            let info = crate::blas::dgesv(&mut a, &mut b, n, 1);
25273            assert_eq!(info, 0);
25274            b
25275        };
25276        let loss_ref: f64 = x_ref.iter().sum();
25277        // db = (Aᵀ)⁻¹ · 1
25278        let db_ref = {
25279            let mut at = [0.0_f64; 9];
25280            for i in 0..n {
25281                for j in 0..n {
25282                    at[i * n + j] = a_data[j * n + i];
25283                }
25284            }
25285            let mut ones = [1.0_f64; 3];
25286            let info = crate::blas::dgesv(&mut at, &mut ones, n, 1);
25287            assert_eq!(info, 0);
25288            ones
25289        };
25290        // dA = -outer(db, x) ; dA[i,j] = -db[i] * x[j]
25291        let mut da_ref = [0.0_f64; 9];
25292        for i in 0..n {
25293            for j in 0..n {
25294                da_ref[i * n + j] = -db_ref[i] * x_ref[j];
25295            }
25296        }
25297
25298        // ── Assertions vs analytic answer ──
25299        assert!(
25300            (loss_out[0] - loss_ref).abs() < 1e-10,
25301            "loss: got {}, want {}",
25302            loss_out[0],
25303            loss_ref
25304        );
25305        for i in 0..n {
25306            assert!(
25307                (db_out[i] - db_ref[i]).abs() < 1e-10,
25308                "db[{i}]: got {}, want {}",
25309                db_out[i],
25310                db_ref[i]
25311            );
25312        }
25313        for i in 0..n * n {
25314            assert!(
25315                (da_out[i] - da_ref[i]).abs() < 1e-10,
25316                "dA[{i}]: got {}, want {}",
25317                da_out[i],
25318                da_ref[i]
25319            );
25320        }
25321
25322        // ── Cross-check vs finite differences on db (a few entries) ──
25323        // ∂loss/∂b[k] ≈ (loss(b + h·e_k) - loss(b - h·e_k)) / (2h).
25324        let h = 1e-6_f64;
25325        for k in 0..n {
25326            let mut bp = b_data;
25327            bp[k] += h;
25328            let mut bm = b_data;
25329            bm[k] -= h;
25330            let lp = {
25331                let mut ac = a_data;
25332                let info = crate::blas::dgesv(&mut ac, &mut bp, n, 1);
25333                assert_eq!(info, 0);
25334                bp.iter().sum::<f64>()
25335            };
25336            let lm = {
25337                let mut ac = a_data;
25338                let info = crate::blas::dgesv(&mut ac, &mut bm, n, 1);
25339                assert_eq!(info, 0);
25340                bm.iter().sum::<f64>()
25341            };
25342            let fd = (lp - lm) / (2.0 * h);
25343            assert!(
25344                (db_out[k] - fd).abs() < 1e-7,
25345                "FD mismatch on db[{k}]: AD={} FD={}",
25346                db_out[k],
25347                fd
25348            );
25349        }
25350    }
25351
25352    /// Smallest possible Op::Scan basic test: geometric growth.
25353    /// init = [1, 1, 1] f64, body = (x → x + 0.1·x) = (x → 1.1·x),
25354    /// length = 10. Final carry must equal init·(1.1)^10 ≈ 2.5937…
25355    /// to f64 precision.
25356    #[test]
25357    fn scan_geometric_growth_f64() {
25358        let n = 3usize;
25359        let length = 10u32;
25360
25361        // Body: (x) → x + 0.1·x. One Input, one output, same shape/dtype.
25362        let mut body = Graph::new("scan_body");
25363        let x = body.input("carry", Shape::new(&[n], DType::F64));
25364        let scale_bytes: Vec<u8> = (0..n).flat_map(|_| 0.1_f64.to_le_bytes()).collect();
25365        let scale = body.add_node(
25366            Op::Constant { data: scale_bytes },
25367            vec![],
25368            Shape::new(&[n], DType::F64),
25369        );
25370        let scaled = body.binary(BinaryOp::Mul, x, scale, Shape::new(&[n], DType::F64));
25371        let next = body.binary(BinaryOp::Add, x, scaled, Shape::new(&[n], DType::F64));
25372        body.set_outputs(vec![next]);
25373
25374        // Outer graph: scan(init, body, length).
25375        let mut g = Graph::new("scan_outer");
25376        let init = g.input("init", Shape::new(&[n], DType::F64));
25377        let final_carry = g.scan(init, body, length);
25378        g.set_outputs(vec![final_carry]);
25379
25380        let init_data = vec![1.0_f64; n];
25381        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data)]);
25382        execute_thunks(&sched, arena.raw_buf_mut());
25383        let got = read_arena_f64(&arena, final_carry, n);
25384        let want: f64 = 1.1_f64.powi(length as i32);
25385        for i in 0..n {
25386            assert!(
25387                (got[i] - want).abs() < 1e-12,
25388                "got[{i}] = {} want {}",
25389                got[i],
25390                want
25391            );
25392        }
25393    }
25394
25395    /// Per-step xs scan: cumulative-sum.
25396    ///   carry_0 = init
25397    ///   carry_{t+1} = carry_t + xs\[t\]
25398    ///   final = sum_{t<length} xs\[t\] + init
25399    /// Body has 2 inputs (carry, x_t) in that NodeId order; one output
25400    /// (next carry). Validates the per-step-input plumbing end-to-end.
25401    #[test]
25402    fn scan_with_xs_cumulative_sum() {
25403        let n = 3usize;
25404        let length = 4u32;
25405
25406        let mut body = Graph::new("cumsum_body");
25407        // carry must come first in NodeId order — declare it first.
25408        let carry = body.input("carry", Shape::new(&[n], DType::F64));
25409        let x_t = body.input("x_t", Shape::new(&[n], DType::F64));
25410        let next = body.binary(BinaryOp::Add, carry, x_t, Shape::new(&[n], DType::F64));
25411        body.set_outputs(vec![next]);
25412
25413        let mut g = Graph::new("cumsum_outer");
25414        let init = g.input("init", Shape::new(&[n], DType::F64));
25415        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
25416        let final_carry = g.scan_with_xs(init, &[xs], body, length);
25417        g.set_outputs(vec![final_carry]);
25418
25419        let init_data = vec![0.0_f64; n];
25420        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
25421        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data), (xs, &xs_data)]);
25422        execute_thunks(&sched, arena.raw_buf_mut());
25423        let got = read_arena_f64(&arena, final_carry, n);
25424
25425        // Reference: column-wise sum of xs rows + init. With our row-major
25426        // layout, column j of xs is xs_data[j], xs_data[n+j], xs_data[2n+j], ...
25427        // (per-step row at offset t*n contributes element j to slot j).
25428        let mut want = init_data.clone();
25429        for t in 0..length as usize {
25430            for j in 0..n {
25431                want[j] += xs_data[t * n + j];
25432            }
25433        }
25434        for i in 0..n {
25435            assert!(
25436                (got[i] - want[i]).abs() < 1e-12,
25437                "got[{i}] = {} want {}",
25438                got[i],
25439                want[i]
25440            );
25441        }
25442    }
25443
25444    /// Per-step xs scan composing with DenseSolve — Circulax-shaped:
25445    ///   carry_{t+1} = solve(M, carry_t + xs\[t\])
25446    /// Models a Backward-Euler step driven by a time-varying source.
25447    #[test]
25448    fn scan_with_xs_be_with_drive() {
25449        let n = 3usize;
25450        let length = 4u32;
25451        let dt = 0.1_f64;
25452
25453        let mut m_data = vec![0.0_f64; n * n];
25454        for i in 0..n {
25455            m_data[i * n + i] = 1.0 + dt * 2.0;
25456            if i > 0 {
25457                m_data[i * n + (i - 1)] = -dt;
25458            }
25459            if i + 1 < n {
25460                m_data[i * n + (i + 1)] = -dt;
25461            }
25462        }
25463        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
25464
25465        let mut body = Graph::new("be_drive_body");
25466        let carry = body.input("carry", Shape::new(&[n], DType::F64));
25467        let drive = body.input("drive", Shape::new(&[n], DType::F64));
25468        let m = body.add_node(
25469            Op::Constant { data: m_bytes },
25470            vec![],
25471            Shape::new(&[n, n], DType::F64),
25472        );
25473        let driven = body.binary(BinaryOp::Add, carry, drive, Shape::new(&[n], DType::F64));
25474        let next = body.dense_solve(m, driven, Shape::new(&[n], DType::F64));
25475        body.set_outputs(vec![next]);
25476
25477        let mut g = Graph::new("be_drive_outer");
25478        let init = g.input("init", Shape::new(&[n], DType::F64));
25479        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
25480        let final_carry = g.scan_with_xs(init, &[xs], body, length);
25481        g.set_outputs(vec![final_carry]);
25482
25483        let init_data = vec![0.0_f64; n];
25484        // Drive the system with a unit pulse on element 0 at t=0,
25485        // zeros after.
25486        let mut xs_data = vec![0.0_f64; length as usize * n];
25487        xs_data[0] = 1.0;
25488
25489        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data), (xs, &xs_data)]);
25490        execute_thunks(&sched, arena.raw_buf_mut());
25491        let got = read_arena_f64(&arena, final_carry, n);
25492
25493        // Reference: per-step in pure Rust.
25494        let mut x = init_data.clone();
25495        for t in 0..length as usize {
25496            for j in 0..n {
25497                x[j] += xs_data[t * n + j];
25498            }
25499            let mut a_copy = m_data.clone();
25500            crate::blas::dgesv(&mut a_copy, &mut x, n, 1);
25501        }
25502        for i in 0..n {
25503            assert!(
25504                (got[i] - x[i]).abs() < 1e-12,
25505                "got[{i}] = {} ref {}",
25506                got[i],
25507                x[i]
25508            );
25509        }
25510    }
25511
25512    /// Reverse-mode AD through Op::BatchedDenseSolve. Forward solves
25513    /// `[B, N, N] · x = [B, N]`; loss = sum of all entries. Closed
25514    /// form: dB = (Aᵀ)⁻¹·1, dA = -(Aᵀ)⁻¹·1 ⊗ x. Verified analytically
25515    /// per batch (each slice matches what the unbatched DenseSolve VJP
25516    /// would compute).
25517    #[test]
25518    fn batched_dense_solve_gradient_matches_per_batch_analytic() {
25519        use rlx_opt::autodiff::grad_with_loss;
25520        let n = 3usize;
25521        let batch = 4usize;
25522
25523        let mut g = Graph::new("bds_grad");
25524        let a = g.param("A", Shape::new(&[batch, n, n], DType::F64));
25525        let b = g.input("b", Shape::new(&[batch, n], DType::F64));
25526        let x = g.batched_dense_solve(a, b, Shape::new(&[batch, n], DType::F64));
25527        let loss = g.reduce(
25528            x,
25529            ReduceOp::Sum,
25530            vec![0, 1],
25531            false,
25532            Shape::new(&[1], DType::F64),
25533        );
25534        g.set_outputs(vec![loss]);
25535
25536        let bwd = grad_with_loss(&g, &[a, b]);
25537
25538        let find = |graph: &Graph, want: &str| -> NodeId {
25539            for node in graph.nodes() {
25540                let name = match &node.op {
25541                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
25542                    _ => None,
25543                };
25544                if name == Some(want) {
25545                    return node.id;
25546                }
25547            }
25548            panic!("no node named {want}");
25549        };
25550        let a_id = find(&bwd, "A");
25551        let b_id = find(&bwd, "b");
25552        let d_out_id = find(&bwd, "d_output");
25553
25554        let mut rng = rlx_ir::Philox4x32::new(0x57e1_u64);
25555        let mut a_data = vec![0.0_f64; batch * n * n];
25556        let mut b_data = vec![0.0_f64; batch * n];
25557        for bi in 0..batch {
25558            for i in 0..n {
25559                for j in 0..n {
25560                    a_data[bi * n * n + i * n + j] = rng.next_f32() as f64 * 0.1;
25561                }
25562                a_data[bi * n * n + i * n + i] += 1.0 + n as f64;
25563            }
25564            for i in 0..n {
25565                b_data[bi * n + i] = rng.next_f32() as f64;
25566            }
25567        }
25568        let d_seed = [1.0_f64];
25569
25570        let (sched, mut arena) = prepare_f64(
25571            &bwd,
25572            &[(a_id, &a_data), (b_id, &b_data), (d_out_id, &d_seed)],
25573        );
25574        execute_thunks(&sched, arena.raw_buf_mut());
25575        let da_out = read_arena_f64(&arena, bwd.outputs[1], batch * n * n);
25576        let db_out = read_arena_f64(&arena, bwd.outputs[2], batch * n);
25577
25578        // Reference: per-batch analytic solve. dB_i = (A_iᵀ)⁻¹ · 1,
25579        // dA_i = -dB_i ⊗ x_i.
25580        for bi in 0..batch {
25581            let a_slice: Vec<f64> = a_data[bi * n * n..(bi + 1) * n * n].to_vec();
25582            let mut b_slice: Vec<f64> = b_data[bi * n..(bi + 1) * n].to_vec();
25583            let mut a_copy = a_slice.clone();
25584            crate::blas::dgesv(&mut a_copy, &mut b_slice, n, 1);
25585            let x_ref = b_slice.clone();
25586            // dB: solve(A^T, ones)
25587            let mut at = vec![0.0_f64; n * n];
25588            for i in 0..n {
25589                for j in 0..n {
25590                    at[i * n + j] = a_slice[j * n + i];
25591                }
25592            }
25593            let mut ones = vec![1.0_f64; n];
25594            crate::blas::dgesv(&mut at, &mut ones, n, 1);
25595            let db_ref = ones;
25596            for i in 0..n {
25597                let got = db_out[bi * n + i];
25598                assert!(
25599                    (got - db_ref[i]).abs() < 1e-10,
25600                    "batch {bi}, db[{i}]: got {got} ref {}",
25601                    db_ref[i]
25602                );
25603            }
25604            // dA: -outer(db, x)
25605            for i in 0..n {
25606                for j in 0..n {
25607                    let got = da_out[bi * n * n + i * n + j];
25608                    let want = -db_ref[i] * x_ref[j];
25609                    assert!(
25610                        (got - want).abs() < 1e-10,
25611                        "batch {bi}, dA[{i},{j}]: got {got} ref {want}"
25612                    );
25613                }
25614            }
25615        }
25616    }
25617
25618    /// AD knob: gradient through `scan_checkpointed` automatically
25619    /// uses the recompute backward path. Compares dinit from a plain
25620    /// scan against the same forward written with `scan_checkpointed`,
25621    /// both run through `grad_with_loss`. They must match to f64.
25622    #[test]
25623    fn scan_checkpointed_grad_matches_plain_scan_grad() {
25624        use rlx_opt::autodiff::grad_with_loss;
25625        let n = 2usize;
25626        let length = 6u32;
25627
25628        let make_body = || {
25629            let mut body = Graph::new("ck_body");
25630            let carry = body.input("carry", Shape::new(&[n], DType::F64));
25631            let scale_bytes: Vec<u8> = (0..n).flat_map(|_| 1.05_f64.to_le_bytes()).collect();
25632            let scale = body.add_node(
25633                Op::Constant { data: scale_bytes },
25634                vec![],
25635                Shape::new(&[n], DType::F64),
25636            );
25637            let next = body.binary(BinaryOp::Mul, carry, scale, Shape::new(&[n], DType::F64));
25638            body.set_outputs(vec![next]);
25639            body
25640        };
25641
25642        // Plain scan path.
25643        let mut g_plain = Graph::new("ck_plain");
25644        let init_p = g_plain.input("init", Shape::new(&[n], DType::F64));
25645        let final_p = g_plain.scan(init_p, make_body(), length);
25646        let loss_p = g_plain.reduce(
25647            final_p,
25648            ReduceOp::Sum,
25649            vec![0],
25650            false,
25651            Shape::new(&[1], DType::F64),
25652        );
25653        g_plain.set_outputs(vec![loss_p]);
25654        let bwd_p = grad_with_loss(&g_plain, &[init_p]);
25655
25656        // Checkpointed scan path with K=2 (length=6).
25657        let mut g_ck = Graph::new("ck_ckpt");
25658        let init_c = g_ck.input("init", Shape::new(&[n], DType::F64));
25659        let final_c = g_ck.scan_checkpointed(init_c, make_body(), length, 2);
25660        let loss_c = g_ck.reduce(
25661            final_c,
25662            ReduceOp::Sum,
25663            vec![0],
25664            false,
25665            Shape::new(&[1], DType::F64),
25666        );
25667        g_ck.set_outputs(vec![loss_c]);
25668        let bwd_c = grad_with_loss(&g_ck, &[init_c]);
25669
25670        let find = |graph: &Graph, want: &str| -> NodeId {
25671            for node in graph.nodes() {
25672                let name = match &node.op {
25673                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
25674                    _ => None,
25675                };
25676                if name == Some(want) {
25677                    return node.id;
25678                }
25679            }
25680            panic!("no {want}");
25681        };
25682
25683        let init_data = vec![0.5_f64, -0.5];
25684        let d_seed = [1.0_f64];
25685
25686        let (s_p, mut a_p) = prepare_f64(
25687            &bwd_p,
25688            &[
25689                (find(&bwd_p, "init"), &init_data),
25690                (find(&bwd_p, "d_output"), &d_seed),
25691            ],
25692        );
25693        execute_thunks(&s_p, a_p.raw_buf_mut());
25694        let dinit_p = read_arena_f64(&a_p, bwd_p.outputs[1], n);
25695
25696        let (s_c, mut a_c) = prepare_f64(
25697            &bwd_c,
25698            &[
25699                (find(&bwd_c, "init"), &init_data),
25700                (find(&bwd_c, "d_output"), &d_seed),
25701            ],
25702        );
25703        execute_thunks(&s_c, a_c.raw_buf_mut());
25704        let dinit_c = read_arena_f64(&a_c, bwd_c.outputs[1], n);
25705
25706        for i in 0..n {
25707            assert!(
25708                (dinit_p[i] - dinit_c[i]).abs() < 1e-12,
25709                "dinit[{i}]: plain={} checkpointed={}",
25710                dinit_p[i],
25711                dinit_c[i]
25712            );
25713        }
25714    }
25715
25716    /// Recursive checkpointing end-to-end: build a ScanBackward
25717    /// configured with K=2 checkpoints (for length=4), and compare
25718    /// dinit against the same backward graph with full trajectory
25719    /// (K=0). Forward computes a cumulative-sum-style scan; loss = sum.
25720    /// Both paths must agree to f64 precision.
25721    #[test]
25722    fn recursive_checkpointing_matches_full_trajectory() {
25723        let n = 2usize;
25724        let length = 4u32;
25725
25726        // Body: carry + ones (deterministic, no xs)
25727        let build_body = || -> Graph {
25728            let mut body = Graph::new("rc_body");
25729            let carry = body.input("carry", Shape::new(&[n], DType::F64));
25730            let ones_bytes: Vec<u8> = (0..n).flat_map(|_| 1.0_f64.to_le_bytes()).collect();
25731            let ones = body.add_node(
25732                Op::Constant { data: ones_bytes },
25733                vec![],
25734                Shape::new(&[n], DType::F64),
25735            );
25736            let next = body.binary(BinaryOp::Add, carry, ones, Shape::new(&[n], DType::F64));
25737            body.set_outputs(vec![next]);
25738            body
25739        };
25740
25741        // body_vjp: same body + d_output, output dcarry. body_vjp is
25742        // used by ScanBackward to walk the chain rule per step.
25743        let body_vjp_for = || -> Graph {
25744            use rlx_opt::autodiff::grad;
25745            let body = build_body();
25746            // grad(body, [carry_id]) → graph with dcarry as the output.
25747            let carry_id = body
25748                .nodes()
25749                .iter()
25750                .find(|n| matches!(n.op, Op::Input { .. }))
25751                .map(|n| n.id)
25752                .unwrap();
25753            grad(&body, &[carry_id])
25754        };
25755
25756        // ── Forward (All-strategy): scan with full trajectory ──
25757        let mut g_full = Graph::new("rc_outer_full");
25758        let init_full = g_full.input("init", Shape::new(&[n], DType::F64));
25759        let traj_full_id = g_full.scan_trajectory(init_full, build_body(), length);
25760        // Hand-build a ScanBackward node that reads the full trajectory.
25761        let upstream_full = g_full.input("upstream", Shape::new(&[length as usize, n], DType::F64));
25762        let dinit_full_id = g_full.scan_backward(
25763            init_full,
25764            traj_full_id,
25765            upstream_full,
25766            &[],
25767            body_vjp_for(),
25768            length,
25769            true,
25770            Shape::new(&[n], DType::F64),
25771        );
25772        g_full.set_outputs(vec![dinit_full_id]);
25773
25774        // ── Forward (Recursive-2): scan saves only K=2 rows ──
25775        // Build the trajectory shape [K, *carry] = [2, 2].
25776        let k = 2u32;
25777        let mut g_rec = Graph::new("rc_outer_rec");
25778        let init_rec = g_rec.input("init", Shape::new(&[n], DType::F64));
25779        let traj_rec_id = g_rec.add_node(
25780            Op::Scan {
25781                body: Box::new(build_body()),
25782                length,
25783                save_trajectory: true,
25784                num_bcast: 0,
25785                num_xs: 0,
25786                num_checkpoints: k,
25787            },
25788            vec![init_rec],
25789            Shape::new(&[k as usize, n], DType::F64),
25790        );
25791        // Same upstream shape as the full version (the upstream is per
25792        // *forward step*, length rows — independent of K).
25793        let upstream_rec = g_rec.input("upstream", Shape::new(&[length as usize, n], DType::F64));
25794        let dinit_rec_id = g_rec.add_node(
25795            Op::ScanBackward {
25796                body_vjp: Box::new(body_vjp_for()),
25797                length,
25798                save_trajectory: true,
25799                num_xs: 0,
25800                num_checkpoints: k,
25801                forward_body: Some(Box::new(build_body())),
25802            },
25803            vec![init_rec, traj_rec_id, upstream_rec],
25804            Shape::new(&[n], DType::F64),
25805        );
25806        g_rec.set_outputs(vec![dinit_rec_id]);
25807
25808        // ── Run both, same inputs ──
25809        let init_data = vec![0.5_f64, -0.5];
25810        let upstream_data: Vec<f64> = (0..length as usize * n).map(|i| (i as f64) * 0.1).collect();
25811
25812        let find = |graph: &Graph, want: &str| -> NodeId {
25813            for node in graph.nodes() {
25814                if let Op::Input { name } = &node.op
25815                    && name == want
25816                {
25817                    return node.id;
25818                }
25819            }
25820            panic!("no input {want}");
25821        };
25822
25823        let (s_full, mut a_full) = prepare_f64(
25824            &g_full,
25825            &[
25826                (find(&g_full, "init"), &init_data),
25827                (find(&g_full, "upstream"), &upstream_data),
25828            ],
25829        );
25830        execute_thunks(&s_full, a_full.raw_buf_mut());
25831        let dinit_full = read_arena_f64(&a_full, g_full.outputs[0], n);
25832
25833        let (s_rec, mut a_rec) = prepare_f64(
25834            &g_rec,
25835            &[
25836                (find(&g_rec, "init"), &init_data),
25837                (find(&g_rec, "upstream"), &upstream_data),
25838            ],
25839        );
25840        execute_thunks(&s_rec, a_rec.raw_buf_mut());
25841        let dinit_rec = read_arena_f64(&a_rec, g_rec.outputs[0], n);
25842
25843        for i in 0..n {
25844            assert!(
25845                (dinit_full[i] - dinit_rec[i]).abs() < 1e-12,
25846                "i={i}: full={} rec={}",
25847                dinit_full[i],
25848                dinit_rec[i]
25849            );
25850        }
25851    }
25852
25853    /// vmap-of-grad: gradient through Scan, vmap'd over init.
25854    /// Forward (per row):
25855    ///   carry_{t+1} = carry_t + ones    (body adds a constant)
25856    ///   loss = sum(carry_length) = sum(init) + length·n
25857    /// Closed form: dloss/dinit_i = 1 for every i. vmap over init at
25858    /// batch=3 → dinit_batched is all-ones [3, n]. Cross-checks
25859    /// against per-row grad_with_loss runs. Validates the vmap rule
25860    /// for Op::ScanBackward.
25861    #[test]
25862    fn vmap_of_grad_scan_matches_per_row_runs() {
25863        use rlx_opt::autodiff::grad_with_loss;
25864        use rlx_opt::vmap::vmap;
25865        let n = 2usize;
25866        let length = 3u32;
25867        let batch = 3usize;
25868
25869        let mut body = Graph::new("scan_grad_body");
25870        let carry = body.input("carry", Shape::new(&[n], DType::F64));
25871        let ones_bytes: Vec<u8> = (0..n).flat_map(|_| 1.0_f64.to_le_bytes()).collect();
25872        let ones = body.add_node(
25873            Op::Constant { data: ones_bytes },
25874            vec![],
25875            Shape::new(&[n], DType::F64),
25876        );
25877        let next = body.binary(BinaryOp::Add, carry, ones, Shape::new(&[n], DType::F64));
25878        body.set_outputs(vec![next]);
25879
25880        let mut g = Graph::new("scan_grad_outer");
25881        let init = g.input("init", Shape::new(&[n], DType::F64));
25882        let final_x = g.scan(init, body, length);
25883        let loss = g.reduce(
25884            final_x,
25885            ReduceOp::Sum,
25886            vec![0],
25887            false,
25888            Shape::new(&[1], DType::F64),
25889        );
25890        g.set_outputs(vec![loss]);
25891
25892        let bwd = grad_with_loss(&g, &[init]);
25893        let bg = vmap(&bwd, &["init"], batch);
25894
25895        let find = |graph: &Graph, want: &str| -> NodeId {
25896            for node in graph.nodes() {
25897                let name = match &node.op {
25898                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
25899                    _ => None,
25900                };
25901                if name == Some(want) {
25902                    return node.id;
25903                }
25904            }
25905            panic!("no node named {want}");
25906        };
25907        let init_b = find(&bg, "init");
25908        let d_out_b = find(&bg, "d_output");
25909
25910        let init_data: Vec<f64> = (0..batch * n).map(|i| (i as f64) * 0.5).collect();
25911        let d_seed = [1.0_f64];
25912
25913        let (sched, mut arena) = prepare_f64(&bg, &[(init_b, &init_data), (d_out_b, &d_seed)]);
25914        execute_thunks(&sched, arena.raw_buf_mut());
25915        let dinit_b = read_arena_f64(&arena, bg.outputs[1], batch * n);
25916
25917        for i in 0..batch * n {
25918            assert!(
25919                (dinit_b[i] - 1.0).abs() < 1e-12,
25920                "dinit[{i}] = {} (expected 1.0)",
25921                dinit_b[i]
25922            );
25923        }
25924
25925        // Cross-check vs per-row grad_with_loss.
25926        for bi in 0..batch {
25927            let row = &init_data[bi * n..(bi + 1) * n];
25928            let mut g2 = Graph::new("per_row_grad");
25929            let init2 = g2.input("init", Shape::new(&[n], DType::F64));
25930            let mut body2 = Graph::new("per_row_body");
25931            let c2 = body2.input("carry", Shape::new(&[n], DType::F64));
25932            let ones2_bytes: Vec<u8> = (0..n).flat_map(|_| 1.0_f64.to_le_bytes()).collect();
25933            let ones2 = body2.add_node(
25934                Op::Constant { data: ones2_bytes },
25935                vec![],
25936                Shape::new(&[n], DType::F64),
25937            );
25938            let next2 = body2.binary(BinaryOp::Add, c2, ones2, Shape::new(&[n], DType::F64));
25939            body2.set_outputs(vec![next2]);
25940            let final2 = g2.scan(init2, body2, length);
25941            let loss2 = g2.reduce(
25942                final2,
25943                ReduceOp::Sum,
25944                vec![0],
25945                false,
25946                Shape::new(&[1], DType::F64),
25947            );
25948            g2.set_outputs(vec![loss2]);
25949            let bwd2 = grad_with_loss(&g2, &[init2]);
25950            let init2_id = find(&bwd2, "init");
25951            let d_out2_id = find(&bwd2, "d_output");
25952            let (s2, mut a2) = prepare_f64(&bwd2, &[(init2_id, row), (d_out2_id, &d_seed)]);
25953            execute_thunks(&s2, a2.raw_buf_mut());
25954            let row_dinit = read_arena_f64(&a2, bwd2.outputs[1], n);
25955            for j in 0..n {
25956                let got = dinit_b[bi * n + j];
25957                let want = row_dinit[j];
25958                assert!(
25959                    (got - want).abs() < 1e-12,
25960                    "row {bi}, j {j}: vmap'd={got} per-row={want}"
25961                );
25962            }
25963        }
25964    }
25965
25966    /// vmap of Op::Scan: batched cumulative-sum. Forward
25967    ///   carry_{t+1} = carry_t + xs\[t\]
25968    ///   final = init + sum(xs)
25969    /// vmap over both init and xs at batch=3. Each batch row should
25970    /// equal the scalar run of the same body+xs subset.
25971    #[test]
25972    fn vmap_scan_cumulative_sum_matches_scalar_runs() {
25973        use rlx_opt::vmap::vmap;
25974        let n = 2usize;
25975        let length = 4u32;
25976        let batch = 3usize;
25977
25978        // Body: (carry, x_t) → carry + x_t
25979        let mut body = Graph::new("scan_body_cumsum");
25980        let carry = body.input("carry", Shape::new(&[n], DType::F64));
25981        let x_t = body.input("x_t", Shape::new(&[n], DType::F64));
25982        let next = body.binary(BinaryOp::Add, carry, x_t, Shape::new(&[n], DType::F64));
25983        body.set_outputs(vec![next]);
25984
25985        let mut g = Graph::new("scan_outer_cumsum");
25986        let init = g.input("init", Shape::new(&[n], DType::F64));
25987        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
25988        let final_carry = g.scan_with_xs(init, &[xs], body, length);
25989        g.set_outputs(vec![final_carry]);
25990
25991        // vmap over both init and xs.
25992        let bg = vmap(&g, &["init", "xs"], batch);
25993
25994        // Test data — distinct per-batch rows.
25995        let init_data: Vec<f64> = (0..batch * n).map(|i| (i + 1) as f64).collect();
25996        // xs has shape [B, length, n] after vmap (the outer's xs is
25997        // [length, n]; vmap lifts it to [B, length, n]).
25998        let xs_data: Vec<f64> = (0..batch * length as usize * n)
25999            .map(|i| 0.1 * (i as f64))
26000            .collect();
26001
26002        let find = |graph: &Graph, want: &str| -> NodeId {
26003            for node in graph.nodes() {
26004                if let Op::Input { name } = &node.op
26005                    && name == want
26006                {
26007                    return node.id;
26008                }
26009            }
26010            panic!("no input {want}");
26011        };
26012        let init_b = find(&bg, "init");
26013        let xs_b = find(&bg, "xs");
26014        let (sched, mut arena) = prepare_f64(&bg, &[(init_b, &init_data), (xs_b, &xs_data)]);
26015        execute_thunks(&sched, arena.raw_buf_mut());
26016        let batched_out = read_arena_f64(&arena, bg.outputs[0], batch * n);
26017
26018        // Reference: per-batch scalar Scan.
26019        for bi in 0..batch {
26020            let init_slice = &init_data[bi * n..(bi + 1) * n];
26021            let mut x = init_slice.to_vec();
26022            for t in 0..length as usize {
26023                for j in 0..n {
26024                    x[j] += xs_data[bi * length as usize * n + t * n + j];
26025                }
26026            }
26027
26028            for i in 0..n {
26029                let got = batched_out[bi * n + i];
26030                assert!(
26031                    (got - x[i]).abs() < 1e-12,
26032                    "row {bi}, i {i}: got {got} ref {}",
26033                    x[i]
26034                );
26035            }
26036        }
26037    }
26038
26039    /// vmap of dense solve — Circulax-shaped batched parameter sweep.
26040    /// Forward: x = solve(A, b). vmap over both A (batched [B,N,N])
26041    /// and b (batched [B,N]). Run on CPU and compare each batch row
26042    /// against an independent scalar dgesv.
26043    #[test]
26044    fn vmap_dense_solve_matches_scalar_runs() {
26045        use rlx_opt::vmap::vmap;
26046        let n = 3usize;
26047        let batch = 4usize;
26048
26049        let mut g = Graph::new("solve_forward");
26050        let a = g.input("A", Shape::new(&[n, n], DType::F64));
26051        let b = g.input("b", Shape::new(&[n], DType::F64));
26052        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
26053        g.set_outputs(vec![x]);
26054
26055        // vmap both A and b across the batch.
26056        let bg = vmap(&g, &["A", "b"], batch);
26057
26058        // Independent A and b per batch row.
26059        let mut rng = rlx_ir::Philox4x32::new(0xb47c_u64);
26060        let mut a_data = vec![0.0_f64; batch * n * n];
26061        let mut b_data = vec![0.0_f64; batch * n];
26062        for bi in 0..batch {
26063            // Diagonally dominant A — guaranteed non-singular.
26064            for i in 0..n {
26065                for j in 0..n {
26066                    a_data[bi * n * n + i * n + j] = rng.next_f32() as f64 * 0.1;
26067                }
26068                a_data[bi * n * n + i * n + i] += 1.0 + n as f64;
26069            }
26070            for i in 0..n {
26071                b_data[bi * n + i] = rng.next_f32() as f64;
26072            }
26073        }
26074
26075        let find = |graph: &Graph, want: &str| -> NodeId {
26076            for node in graph.nodes() {
26077                if let Op::Input { name } = &node.op
26078                    && name == want
26079                {
26080                    return node.id;
26081                }
26082            }
26083            panic!("no input named {want}");
26084        };
26085        let ba = find(&bg, "A");
26086        let bb = find(&bg, "b");
26087        let (sched, mut arena) = prepare_f64(&bg, &[(ba, &a_data), (bb, &b_data)]);
26088        execute_thunks(&sched, arena.raw_buf_mut());
26089        let batched_x = read_arena_f64(&arena, bg.outputs[0], batch * n);
26090
26091        // Reference: per-batch dgesv.
26092        for bi in 0..batch {
26093            let mut a_slice: Vec<f64> = a_data[bi * n * n..(bi + 1) * n * n].to_vec();
26094            let mut b_slice: Vec<f64> = b_data[bi * n..(bi + 1) * n].to_vec();
26095            crate::blas::dgesv(&mut a_slice, &mut b_slice, n, 1);
26096            for i in 0..n {
26097                let got = batched_x[bi * n + i];
26098                let want = b_slice[i];
26099                assert!(
26100                    (got - want).abs() < 1e-12,
26101                    "row {bi}, i {i}: got {got} want {want}"
26102                );
26103            }
26104        }
26105    }
26106
26107    /// vmap end-to-end: build a graph that computes y = MatMul(x, w) + b
26108    /// and reduces to a per-element loss. vmap over x with batch=4.
26109    /// Run the batched graph and compare each output row against an
26110    /// independent scalar run of the original graph. Validates the
26111    /// structural lift + the runtime path for batched MatMul +
26112    /// batched Binary + batched Reduce.
26113    #[test]
26114    fn vmap_matmul_add_reduce_matches_scalar_runs() {
26115        use rlx_opt::vmap::vmap;
26116        let n = 3usize;
26117        let batch = 4usize;
26118
26119        // Forward graph: y = MatMul(reshape(x, [1,n]), w) + b ; loss = sum(y).
26120        let mut g = Graph::new("vmap_e2e_forward");
26121        let x = g.input("x", Shape::new(&[n], DType::F64));
26122        let w = g.input("w", Shape::new(&[n, n], DType::F64));
26123        let b = g.input("b", Shape::new(&[n], DType::F64));
26124        let x_row = g.add_node(
26125            Op::Reshape {
26126                new_shape: vec![1, n as i64],
26127            },
26128            vec![x],
26129            Shape::new(&[1, n], DType::F64),
26130        );
26131        let mm = g.matmul(x_row, w, Shape::new(&[1, n], DType::F64));
26132        let mm_flat = g.add_node(
26133            Op::Reshape {
26134                new_shape: vec![n as i64],
26135            },
26136            vec![mm],
26137            Shape::new(&[n], DType::F64),
26138        );
26139        let yv = g.binary(BinaryOp::Add, mm_flat, b, Shape::new(&[n], DType::F64));
26140        let loss = g.reduce(
26141            yv,
26142            ReduceOp::Sum,
26143            vec![0],
26144            false,
26145            Shape::new(&[1], DType::F64),
26146        );
26147        g.set_outputs(vec![loss]);
26148
26149        // Build the vmap'd version (batch over x; w and b shared).
26150        let bg = vmap(&g, &["x"], batch);
26151
26152        // Test data — distinct rows so we can verify the per-row dispatch.
26153        let mut rng = rlx_ir::Philox4x32::new(0xc1c0_u64);
26154        let n_w = n * n;
26155        let w_data: Vec<f64> = (0..n_w).map(|_| rng.next_f32() as f64).collect();
26156        let b_data: Vec<f64> = (0..n).map(|_| rng.next_f32() as f64).collect();
26157        let mut x_data_batched: Vec<f64> = Vec::with_capacity(batch * n);
26158        for _ in 0..batch * n {
26159            x_data_batched.push(rng.next_f32() as f64);
26160        }
26161
26162        // Run the batched graph.
26163        let find = |graph: &Graph, want: &str| -> NodeId {
26164            for node in graph.nodes() {
26165                if let Op::Input { name } = &node.op
26166                    && name == want
26167                {
26168                    return node.id;
26169                }
26170            }
26171            panic!("no input named {want}");
26172        };
26173        let bx = find(&bg, "x");
26174        let bw = find(&bg, "w");
26175        let bb = find(&bg, "b");
26176        let (sched, mut arena) =
26177            prepare_f64(&bg, &[(bx, &x_data_batched), (bw, &w_data), (bb, &b_data)]);
26178        execute_thunks(&sched, arena.raw_buf_mut());
26179        // Reduce::Sum on shifted axis 1 with keep_dim=false → output [B, 1]
26180        // (it preserves the leading batch axis but reduces what was [n] to [].
26181        // Since the original output was [1] f64 and the reduce was over
26182        // axis 0, after vmap the leading-axis-shifted reduce keeps the
26183        // leading 1 from the original output's [1] shape.)
26184        let batched_out = read_arena_f64(&arena, bg.outputs[0], batch);
26185
26186        // Reference: run the original (un-batched) graph once per batch row.
26187        for bi in 0..batch {
26188            let xs_slice = &x_data_batched[bi * n..(bi + 1) * n];
26189            let mut g2 = Graph::new("scalar_run");
26190            let x2 = g2.input("x", Shape::new(&[n], DType::F64));
26191            let w2 = g2.input("w", Shape::new(&[n, n], DType::F64));
26192            let b2 = g2.input("b", Shape::new(&[n], DType::F64));
26193            let xr = g2.add_node(
26194                Op::Reshape {
26195                    new_shape: vec![1, n as i64],
26196                },
26197                vec![x2],
26198                Shape::new(&[1, n], DType::F64),
26199            );
26200            let m = g2.matmul(xr, w2, Shape::new(&[1, n], DType::F64));
26201            let mf = g2.add_node(
26202                Op::Reshape {
26203                    new_shape: vec![n as i64],
26204                },
26205                vec![m],
26206                Shape::new(&[n], DType::F64),
26207            );
26208            let yv2 = g2.binary(BinaryOp::Add, mf, b2, Shape::new(&[n], DType::F64));
26209            let l2 = g2.reduce(
26210                yv2,
26211                ReduceOp::Sum,
26212                vec![0],
26213                false,
26214                Shape::new(&[1], DType::F64),
26215            );
26216            g2.set_outputs(vec![l2]);
26217            let (s2, mut a2) = prepare_f64(&g2, &[(x2, xs_slice), (w2, &w_data), (b2, &b_data)]);
26218            execute_thunks(&s2, a2.raw_buf_mut());
26219            let scalar_out = read_arena_f64(&a2, l2, 1);
26220            assert!(
26221                (batched_out[bi] - scalar_out[0]).abs() < 1e-12,
26222                "row {bi}: batched={} scalar={}",
26223                batched_out[bi],
26224                scalar_out[0]
26225            );
26226        }
26227    }
26228
26229    /// Full gradient through scan-with-xs: dinit AND dxs both checked
26230    /// against finite differences. Forward
26231    ///   carry_{t+1} = solve(M, carry_t + xs\[t\])
26232    ///   loss        = sum(carry_length)
26233    /// Verifies that grad_with_loss returns gradients w.r.t. both
26234    /// `init` and `xs` and that dxs matches per-element FD.
26235    #[test]
26236    fn scan_with_xs_dxs_matches_fd() {
26237        use rlx_opt::autodiff::grad_with_loss;
26238        let n = 3usize;
26239        let length = 3u32;
26240        let dt = 0.1_f64;
26241
26242        let mut m_data = vec![0.0_f64; n * n];
26243        for i in 0..n {
26244            m_data[i * n + i] = 1.0 + dt * 2.0;
26245            if i > 0 {
26246                m_data[i * n + (i - 1)] = -dt;
26247            }
26248            if i + 1 < n {
26249                m_data[i * n + (i + 1)] = -dt;
26250            }
26251        }
26252        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
26253
26254        let mut body = Graph::new("be_dxs_body");
26255        let carry = body.input("carry", Shape::new(&[n], DType::F64));
26256        let drive = body.input("drive", Shape::new(&[n], DType::F64));
26257        let m = body.add_node(
26258            Op::Constant { data: m_bytes },
26259            vec![],
26260            Shape::new(&[n, n], DType::F64),
26261        );
26262        let driven = body.binary(BinaryOp::Add, carry, drive, Shape::new(&[n], DType::F64));
26263        let next = body.dense_solve(m, driven, Shape::new(&[n], DType::F64));
26264        body.set_outputs(vec![next]);
26265
26266        let mut g = Graph::new("be_dxs_outer");
26267        let init = g.input("init", Shape::new(&[n], DType::F64));
26268        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
26269        let final_carry = g.scan_with_xs(init, &[xs], body, length);
26270        let loss = g.reduce(
26271            final_carry,
26272            ReduceOp::Sum,
26273            vec![0],
26274            false,
26275            Shape::new(&[1], DType::F64),
26276        );
26277        g.set_outputs(vec![loss]);
26278
26279        // wrt = [init, xs] — get both gradients back.
26280        let bwd = grad_with_loss(&g, &[init, xs]);
26281        assert_eq!(bwd.outputs.len(), 3, "[loss, dinit, dxs]");
26282
26283        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
26284            for node in graph.nodes() {
26285                let name = match &node.op {
26286                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
26287                    _ => None,
26288                };
26289                if name == Some(want) {
26290                    return node.id;
26291                }
26292            }
26293            panic!("no node named {want:?}");
26294        };
26295        let init_bwd = find_by_name(&bwd, "init");
26296        let xs_bwd = find_by_name(&bwd, "xs");
26297        let d_out_bwd = find_by_name(&bwd, "d_output");
26298
26299        let init_data = vec![0.5_f64, 0.0, -0.5];
26300        let xs_data: Vec<f64> = (0..length as usize * n)
26301            .map(|i| 0.1_f64 * ((i as f64) - 4.0))
26302            .collect();
26303        let d_seed = [1.0_f64];
26304
26305        let (sched, mut arena) = prepare_f64(
26306            &bwd,
26307            &[
26308                (init_bwd, &init_data),
26309                (xs_bwd, &xs_data),
26310                (d_out_bwd, &d_seed),
26311            ],
26312        );
26313        execute_thunks(&sched, arena.raw_buf_mut());
26314        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
26315        let dxs = read_arena_f64(&arena, bwd.outputs[2], length as usize * n);
26316
26317        let h = 1e-6;
26318        let loss_at = |x0: &[f64], xs_in: &[f64]| -> f64 {
26319            let mut acc = x0.to_vec();
26320            for t in 0..length as usize {
26321                for j in 0..n {
26322                    acc[j] += xs_in[t * n + j];
26323                }
26324                let mut a_copy = m_data.clone();
26325                crate::blas::dgesv(&mut a_copy, &mut acc, n, 1);
26326            }
26327            acc.iter().sum()
26328        };
26329
26330        // FD on dinit (sanity).
26331        for i in 0..n {
26332            let mut ip = init_data.to_vec();
26333            ip[i] += h;
26334            let mut im = init_data.to_vec();
26335            im[i] -= h;
26336            let fd = (loss_at(&ip, &xs_data) - loss_at(&im, &xs_data)) / (2.0 * h);
26337            assert!(
26338                (dinit[i] - fd).abs() < 1e-7,
26339                "FD dinit[{i}]: AD={} FD={}",
26340                dinit[i],
26341                fd
26342            );
26343        }
26344
26345        // FD on every dxs entry — full per-step gradient check.
26346        for t in 0..length as usize {
26347            for j in 0..n {
26348                let idx = t * n + j;
26349                let mut xp = xs_data.clone();
26350                xp[idx] += h;
26351                let mut xm = xs_data.clone();
26352                xm[idx] -= h;
26353                let fd = (loss_at(&init_data, &xp) - loss_at(&init_data, &xm)) / (2.0 * h);
26354                assert!(
26355                    (dxs[idx] - fd).abs() < 1e-7,
26356                    "FD dxs[t={t},j={j}]: AD={} FD={}",
26357                    dxs[idx],
26358                    fd
26359                );
26360            }
26361        }
26362    }
26363
26364    /// Gradient through a scan with per-step xs (Circulax-shaped).
26365    /// Forward:
26366    ///   carry_{t+1} = solve(M, carry_t + xs\[t\])
26367    ///   loss = sum(carry_length)
26368    /// dxs is out of MVP (asserted in the VJP rule's body_vjp `wrt`),
26369    /// but `dinit` flows correctly through the body's reverse Jacobian
26370    /// even with xs in the chain. Verify dinit against finite differences.
26371    #[test]
26372    fn scan_with_xs_gradient_dinit_matches_fd() {
26373        use rlx_opt::autodiff::grad_with_loss;
26374        let n = 3usize;
26375        let length = 3u32;
26376        let dt = 0.1_f64;
26377
26378        let mut m_data = vec![0.0_f64; n * n];
26379        for i in 0..n {
26380            m_data[i * n + i] = 1.0 + dt * 2.0;
26381            if i > 0 {
26382                m_data[i * n + (i - 1)] = -dt;
26383            }
26384            if i + 1 < n {
26385                m_data[i * n + (i + 1)] = -dt;
26386            }
26387        }
26388        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
26389
26390        let mut body = Graph::new("be_xs_grad_body");
26391        let carry = body.input("carry", Shape::new(&[n], DType::F64));
26392        let drive = body.input("drive", Shape::new(&[n], DType::F64));
26393        let m = body.add_node(
26394            Op::Constant { data: m_bytes },
26395            vec![],
26396            Shape::new(&[n, n], DType::F64),
26397        );
26398        let driven = body.binary(BinaryOp::Add, carry, drive, Shape::new(&[n], DType::F64));
26399        let next = body.dense_solve(m, driven, Shape::new(&[n], DType::F64));
26400        body.set_outputs(vec![next]);
26401
26402        let mut g = Graph::new("be_xs_grad_outer");
26403        let init = g.input("init", Shape::new(&[n], DType::F64));
26404        let xs = g.input("xs", Shape::new(&[length as usize, n], DType::F64));
26405        let final_carry = g.scan_with_xs(init, &[xs], body, length);
26406        let loss = g.reduce(
26407            final_carry,
26408            ReduceOp::Sum,
26409            vec![0],
26410            false,
26411            Shape::new(&[1], DType::F64),
26412        );
26413        g.set_outputs(vec![loss]);
26414
26415        let bwd = grad_with_loss(&g, &[init]);
26416
26417        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
26418            for node in graph.nodes() {
26419                let name = match &node.op {
26420                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
26421                    _ => None,
26422                };
26423                if name == Some(want) {
26424                    return node.id;
26425                }
26426            }
26427            panic!("no node named {want:?}");
26428        };
26429        let init_bwd = find_by_name(&bwd, "init");
26430        let xs_bwd = find_by_name(&bwd, "xs");
26431        let d_out_bwd = find_by_name(&bwd, "d_output");
26432
26433        let init_data = vec![0.5_f64, 0.0, -0.5];
26434        // Drive: small per-step pulse, varying per element.
26435        let xs_data: Vec<f64> = (0..length as usize * n)
26436            .map(|i| 0.1_f64 * ((i as f64) - 4.0))
26437            .collect();
26438        let d_seed = [1.0_f64];
26439
26440        let (sched, mut arena) = prepare_f64(
26441            &bwd,
26442            &[
26443                (init_bwd, &init_data),
26444                (xs_bwd, &xs_data),
26445                (d_out_bwd, &d_seed),
26446            ],
26447        );
26448        execute_thunks(&sched, arena.raw_buf_mut());
26449        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
26450
26451        let h = 1e-6;
26452        let loss_at = |x0: &[f64]| -> f64 {
26453            let mut acc = x0.to_vec();
26454            for t in 0..length as usize {
26455                for j in 0..n {
26456                    acc[j] += xs_data[t * n + j];
26457                }
26458                let mut a_copy = m_data.clone();
26459                crate::blas::dgesv(&mut a_copy, &mut acc, n, 1);
26460            }
26461            acc.iter().sum()
26462        };
26463        for i in 0..n {
26464            let mut ip = init_data.to_vec();
26465            ip[i] += h;
26466            let mut im = init_data.to_vec();
26467            im[i] -= h;
26468            let fd = (loss_at(&ip) - loss_at(&im)) / (2.0 * h);
26469            assert!(
26470                (dinit[i] - fd).abs() < 1e-7,
26471                "FD dinit[{i}]: AD={} FD={}",
26472                dinit[i],
26473                fd
26474            );
26475        }
26476    }
26477
26478    /// Gradient through a geometric-growth scan: forward
26479    ///   x_{t+1} = 1.1 · x_t,    x_0 = init
26480    ///   final   = x_length     = init · 1.1^length
26481    ///   loss    = sum(final)
26482    /// closed-form ∂loss/∂init\[i\] = 1.1^length for every i.
26483    /// Validates the VJP path: AD pre-pass rewrites save_trajectory=false
26484    /// to true, autodiff emits Op::ScanBackward, executor walks t back.
26485    #[test]
26486    fn scan_gradient_geometric_matches_closed_form() {
26487        use rlx_opt::autodiff::grad_with_loss;
26488        let n = 3usize;
26489        let length = 5u32;
26490
26491        let mut body = Graph::new("scan_grad_body");
26492        let x = body.input("carry", Shape::new(&[n], DType::F64));
26493        let scale_bytes: Vec<u8> = (0..n).flat_map(|_| 1.1_f64.to_le_bytes()).collect();
26494        let scale = body.add_node(
26495            Op::Constant { data: scale_bytes },
26496            vec![],
26497            Shape::new(&[n], DType::F64),
26498        );
26499        let next = body.binary(BinaryOp::Mul, x, scale, Shape::new(&[n], DType::F64));
26500        body.set_outputs(vec![next]);
26501
26502        let mut g = Graph::new("scan_grad_outer");
26503        let init = g.input("init", Shape::new(&[n], DType::F64));
26504        let final_x = g.scan(init, body, length);
26505        let loss = g.reduce(
26506            final_x,
26507            ReduceOp::Sum,
26508            vec![0],
26509            false,
26510            Shape::new(&[1], DType::F64),
26511        );
26512        g.set_outputs(vec![loss]);
26513
26514        let bwd = grad_with_loss(&g, &[init]);
26515        assert_eq!(bwd.outputs.len(), 2);
26516
26517        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
26518            for node in graph.nodes() {
26519                let name = match &node.op {
26520                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
26521                    _ => None,
26522                };
26523                if name == Some(want) {
26524                    return node.id;
26525                }
26526            }
26527            panic!("no node named {want:?}");
26528        };
26529        let init_bwd = find_by_name(&bwd, "init");
26530        let d_out_bwd = find_by_name(&bwd, "d_output");
26531
26532        let init_data = vec![1.0_f64; n];
26533        let d_seed = [1.0_f64];
26534        let (sched, mut arena) = prepare_f64(&bwd, &[(init_bwd, &init_data), (d_out_bwd, &d_seed)]);
26535        execute_thunks(&sched, arena.raw_buf_mut());
26536        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
26537
26538        let want = 1.1_f64.powi(length as i32);
26539        for i in 0..n {
26540            assert!(
26541                (dinit[i] - want).abs() < 1e-12,
26542                "dinit[{i}] = {} want {}",
26543                dinit[i],
26544                want
26545            );
26546        }
26547
26548        // Finite-difference cross-check on init[0].
26549        let h = 1e-6;
26550        let loss_at = |x: &[f64]| -> f64 {
26551            let mut acc = x.to_vec();
26552            for _ in 0..length {
26553                for v in acc.iter_mut() {
26554                    *v *= 1.1;
26555                }
26556            }
26557            acc.iter().sum()
26558        };
26559        let mut ip = init_data.clone();
26560        ip[0] += h;
26561        let mut im = init_data.clone();
26562        im[0] -= h;
26563        let fd = (loss_at(&ip) - loss_at(&im)) / (2.0 * h);
26564        assert!(
26565            (dinit[0] - fd).abs() < 1e-7,
26566            "FD dinit[0]: AD={} FD={}",
26567            dinit[0],
26568            fd
26569        );
26570    }
26571
26572    /// Gradient through Backward Euler scan composing with DenseSolve.
26573    /// Asserts dinit matches finite-difference per coordinate.
26574    #[test]
26575    fn scan_gradient_backward_euler_matches_fd() {
26576        use rlx_opt::autodiff::grad_with_loss;
26577        let n = 4usize;
26578        let length = 3u32;
26579        let dt = 0.05_f64;
26580
26581        let mut m_data = vec![0.0_f64; n * n];
26582        for i in 0..n {
26583            m_data[i * n + i] = 1.0 + dt * 2.0;
26584            if i > 0 {
26585                m_data[i * n + (i - 1)] = -dt;
26586            }
26587            if i + 1 < n {
26588                m_data[i * n + (i + 1)] = -dt;
26589            }
26590        }
26591        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
26592
26593        let mut body = Graph::new("be_grad_body");
26594        let x = body.input("x", Shape::new(&[n], DType::F64));
26595        let m = body.add_node(
26596            Op::Constant { data: m_bytes },
26597            vec![],
26598            Shape::new(&[n, n], DType::F64),
26599        );
26600        let next = body.dense_solve(m, x, Shape::new(&[n], DType::F64));
26601        body.set_outputs(vec![next]);
26602
26603        let mut g = Graph::new("be_grad_outer");
26604        let init = g.input("x0", Shape::new(&[n], DType::F64));
26605        let final_x = g.scan(init, body, length);
26606        let loss = g.reduce(
26607            final_x,
26608            ReduceOp::Sum,
26609            vec![0],
26610            false,
26611            Shape::new(&[1], DType::F64),
26612        );
26613        g.set_outputs(vec![loss]);
26614
26615        let bwd = grad_with_loss(&g, &[init]);
26616
26617        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
26618            for node in graph.nodes() {
26619                let name = match &node.op {
26620                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
26621                    _ => None,
26622                };
26623                if name == Some(want) {
26624                    return node.id;
26625                }
26626            }
26627            panic!("no node named {want:?}");
26628        };
26629        let init_bwd = find_by_name(&bwd, "x0");
26630        let d_out_bwd = find_by_name(&bwd, "d_output");
26631
26632        let init_data: [f64; 4] = [0.0, 1.0, 0.0, 0.0];
26633        let d_seed = [1.0_f64];
26634        let (sched, mut arena) = prepare_f64(&bwd, &[(init_bwd, &init_data), (d_out_bwd, &d_seed)]);
26635        execute_thunks(&sched, arena.raw_buf_mut());
26636        let dinit = read_arena_f64(&arena, bwd.outputs[1], n);
26637
26638        let h = 1e-6;
26639        let loss_at = |x0: &[f64]| -> f64 {
26640            let mut acc = x0.to_vec();
26641            for _ in 0..length {
26642                let mut a_copy = m_data.clone();
26643                crate::blas::dgesv(&mut a_copy, &mut acc, n, 1);
26644            }
26645            acc.iter().sum()
26646        };
26647        for i in 0..n {
26648            let mut ip = init_data.to_vec();
26649            ip[i] += h;
26650            let mut im = init_data.to_vec();
26651            im[i] -= h;
26652            let fd = (loss_at(&ip) - loss_at(&im)) / (2.0 * h);
26653            assert!(
26654                (dinit[i] - fd).abs() < 1e-7,
26655                "FD dinit[{i}]: AD={} FD={}",
26656                dinit[i],
26657                fd
26658            );
26659        }
26660    }
26661
26662    /// Trajectory-mode scan: same Backward Euler body, but record the
26663    /// carry at every step. Output is `[length, n]` — row `t` is the
26664    /// state after step `t+1`. Validates the SaveAt-style waveform
26665    /// recording end-to-end, including that the last row equals what
26666    /// the no-trajectory variant would have returned.
26667    #[test]
26668    fn scan_trajectory_backward_euler_records_waveform() {
26669        let n = 4usize;
26670        let length = 5u32;
26671        let dt = 0.05_f64;
26672
26673        let mut m_data = vec![0.0_f64; n * n];
26674        for i in 0..n {
26675            m_data[i * n + i] = 1.0 + dt * 2.0;
26676            if i > 0 {
26677                m_data[i * n + (i - 1)] = -dt;
26678            }
26679            if i + 1 < n {
26680                m_data[i * n + (i + 1)] = -dt;
26681            }
26682        }
26683        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
26684
26685        let mut body = Graph::new("be_traj_body");
26686        let x = body.input("x", Shape::new(&[n], DType::F64));
26687        let m = body.add_node(
26688            Op::Constant { data: m_bytes },
26689            vec![],
26690            Shape::new(&[n, n], DType::F64),
26691        );
26692        let next = body.dense_solve(m, x, Shape::new(&[n], DType::F64));
26693        body.set_outputs(vec![next]);
26694
26695        let mut g = Graph::new("be_traj_outer");
26696        let init = g.input("x0", Shape::new(&[n], DType::F64));
26697        let traj = g.scan_trajectory(init, body, length);
26698        g.set_outputs(vec![traj]);
26699
26700        let init_data: [f64; 4] = [0.0, 1.0, 0.0, 0.0];
26701        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data)]);
26702        execute_thunks(&sched, arena.raw_buf_mut());
26703        let got = read_arena_f64(&arena, traj, length as usize * n);
26704
26705        // Reference: each step's solve, recorded.
26706        let mut want = Vec::<f64>::with_capacity(length as usize * n);
26707        let mut x_ref = init_data.to_vec();
26708        for _ in 0..length {
26709            let mut a_copy = m_data.clone();
26710            crate::blas::dgesv(&mut a_copy, &mut x_ref, n, 1);
26711            want.extend_from_slice(&x_ref);
26712        }
26713        for i in 0..length as usize * n {
26714            assert!(
26715                (got[i] - want[i]).abs() < 1e-12,
26716                "got[{i}] = {} ref {}",
26717                got[i],
26718                want[i]
26719            );
26720        }
26721
26722        // Sanity: trajectory rows are monotone-decreasing in mass
26723        // (Backward Euler diffuses; boundary leak removes mass).
26724        for t in 1..length as usize {
26725            let prev: f64 = got[(t - 1) * n..t * n].iter().sum();
26726            let curr: f64 = got[t * n..(t + 1) * n].iter().sum();
26727            assert!(
26728                curr <= prev + 1e-15,
26729                "mass should decay: row {} sum {prev}, row {t} sum {curr}",
26730                t - 1
26731            );
26732        }
26733
26734        // Last row of the trajectory equals what a non-trajectory
26735        // scan returns — verify by running the same forward through
26736        // the simpler API and comparing.
26737        let mut body2 = Graph::new("be_final_body");
26738        let x2 = body2.input("x", Shape::new(&[n], DType::F64));
26739        let m_bytes2: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
26740        let m2 = body2.add_node(
26741            Op::Constant { data: m_bytes2 },
26742            vec![],
26743            Shape::new(&[n, n], DType::F64),
26744        );
26745        let next2 = body2.dense_solve(m2, x2, Shape::new(&[n], DType::F64));
26746        body2.set_outputs(vec![next2]);
26747
26748        let mut g2 = Graph::new("be_final_outer");
26749        let init2 = g2.input("x0", Shape::new(&[n], DType::F64));
26750        let final_x = g2.scan(init2, body2, length);
26751        g2.set_outputs(vec![final_x]);
26752        let (sched2, mut arena2) = prepare_f64(&g2, &[(init2, &init_data)]);
26753        execute_thunks(&sched2, arena2.raw_buf_mut());
26754        let final_got = read_arena_f64(&arena2, final_x, n);
26755
26756        let last_row = &got[(length as usize - 1) * n..length as usize * n];
26757        for i in 0..n {
26758            assert!(
26759                (last_row[i] - final_got[i]).abs() < 1e-15,
26760                "last trajectory row[{i}] = {} vs final-scan = {}",
26761                last_row[i],
26762                final_got[i]
26763            );
26764        }
26765    }
26766
26767    /// Op::Scan composing with Op::DenseSolve — the Circulax-shaped
26768    /// pattern for Backward Euler.
26769    /// Body: x_{t+1} = solve(I + dt·A, x_t).
26770    /// 1-D heat-equation Laplacian A; analytic ground truth from
26771    /// composing the same per-step solve in Rust.
26772    #[test]
26773    fn scan_backward_euler_heat_f64() {
26774        let n = 4usize;
26775        let length = 5u32;
26776        let dt = 0.05_f64;
26777
26778        // Construct M = I + dt · L  where L is the Laplacian (-1, 2, -1).
26779        // M is constant across iterations; embed it in the body via Op::Constant.
26780        let mut m_data = vec![0.0_f64; n * n];
26781        for i in 0..n {
26782            m_data[i * n + i] = 1.0 + dt * 2.0;
26783            if i > 0 {
26784                m_data[i * n + (i - 1)] = -dt;
26785            }
26786            if i + 1 < n {
26787                m_data[i * n + (i + 1)] = -dt;
26788            }
26789        }
26790        let m_bytes: Vec<u8> = m_data.iter().flat_map(|x| x.to_le_bytes()).collect();
26791
26792        let mut body = Graph::new("be_body");
26793        let x = body.input("x", Shape::new(&[n], DType::F64));
26794        let m = body.add_node(
26795            Op::Constant { data: m_bytes },
26796            vec![],
26797            Shape::new(&[n, n], DType::F64),
26798        );
26799        let next = body.dense_solve(m, x, Shape::new(&[n], DType::F64));
26800        body.set_outputs(vec![next]);
26801
26802        let mut g = Graph::new("be_outer");
26803        let init = g.input("x0", Shape::new(&[n], DType::F64));
26804        let final_x = g.scan(init, body, length);
26805        g.set_outputs(vec![final_x]);
26806
26807        // Initial: a sharp pulse at index 1.
26808        let init_data: [f64; 4] = [0.0, 1.0, 0.0, 0.0];
26809        let (sched, mut arena) = prepare_f64(&g, &[(init, &init_data)]);
26810        execute_thunks(&sched, arena.raw_buf_mut());
26811        let got = read_arena_f64(&arena, final_x, n);
26812
26813        // Reference: apply the same M-solve `length` times in pure Rust.
26814        let mut ref_x = init_data.to_vec();
26815        for _ in 0..length {
26816            let mut a_copy = m_data.clone();
26817            crate::blas::dgesv(&mut a_copy, &mut ref_x, n, 1);
26818        }
26819        for i in 0..n {
26820            assert!(
26821                (got[i] - ref_x[i]).abs() < 1e-12,
26822                "got[{i}] = {} ref {}",
26823                got[i],
26824                ref_x[i]
26825            );
26826        }
26827        // Sanity: pulse should diffuse, mass should be conserved-ish
26828        // (Backward Euler is mass-conserving for this stencil with
26829        // zero-flux boundaries — but our boundaries leak, so check
26830        // that mass strictly decreases instead).
26831        let mass: f64 = got.iter().sum();
26832        assert!(mass > 0.0 && mass < 1.0, "diffusion mass: {mass}");
26833    }
26834
26835    /// Multi-RHS forward DenseSolve: X = solve(A, B) with B [N, K]
26836    /// stays correct end-to-end. Verifies the executor/lowering and
26837    /// the LAPACK column-major dance both honour `nrhs > 1`.
26838    #[test]
26839    fn dense_solve_f64_multi_rhs_forward() {
26840        let n = 3usize;
26841        let k = 2usize;
26842        let mut g = Graph::new("solve_multi_rhs");
26843        let a = g.input("A", Shape::new(&[n, n], DType::F64));
26844        let b = g.input("B", Shape::new(&[n, k], DType::F64));
26845        let x = g.dense_solve(a, b, Shape::new(&[n, k], DType::F64));
26846        g.set_outputs(vec![x]);
26847
26848        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
26849        let b_data = [1.0, 4.0, 2.0, -1.0, 3.0, 2.0_f64];
26850        let (sched, mut arena) = prepare_f64(&g, &[(a, &a_data), (b, &b_data)]);
26851        execute_thunks(&sched, arena.raw_buf_mut());
26852        let x_got = read_arena_f64(&arena, x, n * k);
26853        for c in 0..k {
26854            for i in 0..n {
26855                let mut acc = 0.0_f64;
26856                for j in 0..n {
26857                    acc += a_data[i * n + j] * x_got[j * k + c];
26858                }
26859                let want = b_data[i * k + c];
26860                assert!(
26861                    (acc - want).abs() < 1e-10,
26862                    "col {c} row {i}: got {acc} want {want}"
26863                );
26864            }
26865        }
26866    }
26867
26868    /// Multi-RHS reverse-mode VJP: dB = (Aᵀ)⁻¹·1, dA = -dB · Xᵀ.
26869    /// Verified analytically + finite differences on dB[0,0].
26870    #[test]
26871    fn dense_solve_f64_multi_rhs_gradient() {
26872        use rlx_opt::autodiff::grad_with_loss;
26873        let n = 3usize;
26874        let k = 2usize;
26875        let mut g = Graph::new("solve_mrhs_grad");
26876        let a = g.param("A", Shape::new(&[n, n], DType::F64));
26877        let b = g.input("B", Shape::new(&[n, k], DType::F64));
26878        let x = g.dense_solve(a, b, Shape::new(&[n, k], DType::F64));
26879        let loss = g.reduce(
26880            x,
26881            ReduceOp::Sum,
26882            vec![0, 1],
26883            false,
26884            Shape::new(&[1], DType::F64),
26885        );
26886        g.set_outputs(vec![loss]);
26887
26888        let bwd = grad_with_loss(&g, &[a, b]);
26889        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
26890            for node in graph.nodes() {
26891                let name = match &node.op {
26892                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
26893                    _ => None,
26894                };
26895                if name == Some(want) {
26896                    return node.id;
26897                }
26898            }
26899            panic!("no node named {want:?}");
26900        };
26901        let a_bwd = find_by_name(&bwd, "A");
26902        let b_bwd = find_by_name(&bwd, "B");
26903        let d_out = find_by_name(&bwd, "d_output");
26904
26905        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
26906        let b_data = [1.0, 4.0, 2.0, -1.0, 3.0, 2.0_f64];
26907        let d_seed = [1.0_f64];
26908
26909        let (sched, mut arena) = prepare_f64(
26910            &bwd,
26911            &[(a_bwd, &a_data), (b_bwd, &b_data), (d_out, &d_seed)],
26912        );
26913        execute_thunks(&sched, arena.raw_buf_mut());
26914        let da_got = read_arena_f64(&arena, bwd.outputs[1], n * n);
26915        let db_got = read_arena_f64(&arena, bwd.outputs[2], n * k);
26916
26917        // Reference.
26918        let mut x_ref = b_data;
26919        {
26920            let mut a_copy = a_data;
26921            crate::blas::dgesv(&mut a_copy, &mut x_ref, n, k);
26922        }
26923        let mut at = [0.0_f64; 9];
26924        for i in 0..n {
26925            for j in 0..n {
26926                at[i * n + j] = a_data[j * n + i];
26927            }
26928        }
26929        let mut ones_nk = vec![1.0_f64; n * k];
26930        crate::blas::dgesv(&mut at, &mut ones_nk, n, k);
26931        let db_ref = ones_nk;
26932        let mut da_ref = [0.0_f64; 9];
26933        for i in 0..n {
26934            for j in 0..n {
26935                let mut acc = 0.0_f64;
26936                for c in 0..k {
26937                    acc += db_ref[i * k + c] * x_ref[j * k + c];
26938                }
26939                da_ref[i * n + j] = -acc;
26940            }
26941        }
26942        for i in 0..n * k {
26943            assert!(
26944                (db_got[i] - db_ref[i]).abs() < 1e-10,
26945                "dB[{i}]: got {} want {}",
26946                db_got[i],
26947                db_ref[i]
26948            );
26949        }
26950        for i in 0..n * n {
26951            assert!(
26952                (da_got[i] - da_ref[i]).abs() < 1e-10,
26953                "dA[{i}]: got {} want {}",
26954                da_got[i],
26955                da_ref[i]
26956            );
26957        }
26958
26959        // FD on dB[0,0].
26960        let h = 1e-6;
26961        let mut bp = b_data;
26962        bp[0] += h;
26963        let mut bm = b_data;
26964        bm[0] -= h;
26965        let xp = {
26966            let mut a_copy = a_data;
26967            crate::blas::dgesv(&mut a_copy, &mut bp, n, k);
26968            bp
26969        };
26970        let xm = {
26971            let mut a_copy = a_data;
26972            crate::blas::dgesv(&mut a_copy, &mut bm, n, k);
26973            bm
26974        };
26975        let lp: f64 = xp.iter().sum();
26976        let lm: f64 = xm.iter().sum();
26977        let fd = (lp - lm) / (2.0 * h);
26978        assert!(
26979            (db_got[0] - fd).abs() < 1e-7,
26980            "FD dB[0,0]: AD={} FD={}",
26981            db_got[0],
26982            fd
26983        );
26984    }
26985
26986    /// Multi-RHS forward-mode JVP w.r.t. B. Closed form: t_X = solve(A, t_B).
26987    #[test]
26988    fn dense_solve_f64_multi_rhs_jvp() {
26989        use rlx_opt::autodiff_fwd::jvp;
26990        let n = 3usize;
26991        let k = 2usize;
26992        let mut g = Graph::new("solve_mrhs_jvp");
26993        let a = g.input("A", Shape::new(&[n, n], DType::F64));
26994        let b = g.input("B", Shape::new(&[n, k], DType::F64));
26995        let x = g.dense_solve(a, b, Shape::new(&[n, k], DType::F64));
26996        g.set_outputs(vec![x]);
26997
26998        let jg = jvp(&g, &[b]);
26999        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
27000            for node in graph.nodes() {
27001                let name = match &node.op {
27002                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
27003                    _ => None,
27004                };
27005                if name == Some(want) {
27006                    return node.id;
27007                }
27008            }
27009            panic!("no node named {want:?}");
27010        };
27011        let a_id = find_by_name(&jg, "A");
27012        let b_id = find_by_name(&jg, "B");
27013        let tb_id = find_by_name(&jg, "tangent_B");
27014
27015        let a_data = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0_f64];
27016        let b_data = [1.0, 4.0, 2.0, -1.0, 3.0, 2.0_f64];
27017        let tb_data = [0.5, 0.0, -0.25, 1.0, 1.0, -0.5_f64];
27018
27019        let (sched, mut arena) =
27020            prepare_f64(&jg, &[(a_id, &a_data), (b_id, &b_data), (tb_id, &tb_data)]);
27021        execute_thunks(&sched, arena.raw_buf_mut());
27022        let tangent_x = read_arena_f64(&arena, jg.outputs[1], n * k);
27023
27024        let mut a_copy = a_data;
27025        let mut tb_copy = tb_data;
27026        crate::blas::dgesv(&mut a_copy, &mut tb_copy, n, k);
27027        for i in 0..n * k {
27028            assert!(
27029                (tangent_x[i] - tb_copy[i]).abs() < 1e-10,
27030                "t_X[{i}]: AD={} ref={}",
27031                tangent_x[i],
27032                tb_copy[i]
27033            );
27034        }
27035
27036        let h = 1e-6;
27037        let mut bp = b_data;
27038        let mut bm = b_data;
27039        for i in 0..n * k {
27040            bp[i] += h * tb_data[i];
27041            bm[i] -= h * tb_data[i];
27042        }
27043        let xp = {
27044            let mut a_copy = a_data;
27045            crate::blas::dgesv(&mut a_copy, &mut bp, n, k);
27046            bp
27047        };
27048        let xm = {
27049            let mut a_copy = a_data;
27050            crate::blas::dgesv(&mut a_copy, &mut bm, n, k);
27051            bm
27052        };
27053        for i in 0..n * k {
27054            let fd = (xp[i] - xm[i]) / (2.0 * h);
27055            assert!(
27056                (tangent_x[i] - fd).abs() < 1e-7,
27057                "FD t_X[{i}]: AD={} FD={}",
27058                tangent_x[i],
27059                fd
27060            );
27061        }
27062    }
27063
27064    /// Forward-mode JVP through DenseSolve, end-to-end at f64.
27065    ///
27066    /// Build forward x = solve(A, b), call `jvp(forward, [b])`,
27067    /// compile + run, and check the tangent output matches the
27068    /// closed form `t_x = solve(A, t_b)` plus a finite-difference
27069    /// cross-check `(solve(A, b + h·t_b) − solve(A, b − h·t_b)) / 2h`.
27070    #[test]
27071    fn jvp_dense_solve_b_runs_and_matches_fd() {
27072        use rlx_opt::autodiff_fwd::jvp;
27073        let n = 3usize;
27074
27075        // Forward.
27076        let mut g = Graph::new("jvp_b_e2e");
27077        let a = g.input("A", Shape::new(&[n, n], DType::F64));
27078        let b = g.input("b", Shape::new(&[n], DType::F64));
27079        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
27080        g.set_outputs(vec![x]);
27081
27082        // JVP graph perturbing b only.
27083        let jg = jvp(&g, &[b]);
27084        // The JVP graph holds a fresh "tangent_b" Input on top of A and b.
27085        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
27086            for node in graph.nodes() {
27087                let name = match &node.op {
27088                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
27089                    _ => None,
27090                };
27091                if name == Some(want) {
27092                    return node.id;
27093                }
27094            }
27095            panic!("no node named {want:?}");
27096        };
27097        let a_id = find_by_name(&jg, "A");
27098        let b_id = find_by_name(&jg, "b");
27099        let tb_id = find_by_name(&jg, "tangent_b");
27100
27101        let a_data: [f64; 9] = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
27102        let b_data: [f64; 3] = [1.0, 2.0, 3.0];
27103        // Pick an arbitrary perturbation direction.
27104        let tb_data: [f64; 3] = [0.5, -0.25, 1.0];
27105
27106        let (sched, mut arena) =
27107            prepare_f64(&jg, &[(a_id, &a_data), (b_id, &b_data), (tb_id, &tb_data)]);
27108        execute_thunks(&sched, arena.raw_buf_mut());
27109
27110        // Outputs: [primal_x, tangent_x].
27111        let primal_x = read_arena_f64(&arena, jg.outputs[0], n);
27112        let tangent_x = read_arena_f64(&arena, jg.outputs[1], n);
27113
27114        // Closed form: t_x = solve(A, t_b).
27115        let t_x_ref = {
27116            let mut a = a_data;
27117            let mut tb = tb_data;
27118            let info = crate::blas::dgesv(&mut a, &mut tb, n, 1);
27119            assert_eq!(info, 0);
27120            tb
27121        };
27122        for i in 0..n {
27123            assert!(
27124                (tangent_x[i] - t_x_ref[i]).abs() < 1e-10,
27125                "t_x[{i}]: got {} want {}",
27126                tangent_x[i],
27127                t_x_ref[i]
27128            );
27129        }
27130
27131        // FD: x(b + h·tb) − x(b − h·tb)) / 2h
27132        let h = 1e-6;
27133        let mut bp = b_data;
27134        let mut bm = b_data;
27135        for i in 0..n {
27136            bp[i] += h * tb_data[i];
27137            bm[i] -= h * tb_data[i];
27138        }
27139        let xp = {
27140            let mut a = a_data;
27141            let info = crate::blas::dgesv(&mut a, &mut bp, n, 1);
27142            assert_eq!(info, 0);
27143            bp
27144        };
27145        let xm = {
27146            let mut a = a_data;
27147            let info = crate::blas::dgesv(&mut a, &mut bm, n, 1);
27148            assert_eq!(info, 0);
27149            bm
27150        };
27151        let fd: Vec<f64> = (0..n).map(|i| (xp[i] - xm[i]) / (2.0 * h)).collect();
27152        for i in 0..n {
27153            assert!(
27154                (tangent_x[i] - fd[i]).abs() < 1e-7,
27155                "FD mismatch t_x[{i}]: AD={} FD={}",
27156                tangent_x[i],
27157                fd[i]
27158            );
27159        }
27160        // Sanity: primal output is the actual solve.
27161        let primal_ref = {
27162            let mut a = a_data;
27163            let mut b = b_data;
27164            crate::blas::dgesv(&mut a, &mut b, n, 1);
27165            b
27166        };
27167        for i in 0..n {
27168            assert!((primal_x[i] - primal_ref[i]).abs() < 1e-10);
27169        }
27170    }
27171
27172    /// Forward-mode JVP through DenseSolve perturbing A. The tangent
27173    /// path includes the −t_A·x correction term.
27174    /// `t_x = −solve(A, t_A · x)` should match a finite-difference
27175    /// directional derivative of `solve(A, b)` w.r.t. A in the
27176    /// `t_A` direction.
27177    #[test]
27178    fn jvp_dense_solve_a_runs_and_matches_fd() {
27179        use rlx_opt::autodiff_fwd::jvp;
27180        let n = 3usize;
27181
27182        let mut g = Graph::new("jvp_a_e2e");
27183        let a = g.input("A", Shape::new(&[n, n], DType::F64));
27184        let b = g.input("b", Shape::new(&[n], DType::F64));
27185        let x = g.dense_solve(a, b, Shape::new(&[n], DType::F64));
27186        g.set_outputs(vec![x]);
27187
27188        let jg = jvp(&g, &[a]);
27189        let find_by_name = |graph: &Graph, want: &str| -> NodeId {
27190            for node in graph.nodes() {
27191                let name = match &node.op {
27192                    Op::Input { name } | Op::Param { name } => Some(name.as_str()),
27193                    _ => None,
27194                };
27195                if name == Some(want) {
27196                    return node.id;
27197                }
27198            }
27199            panic!("no node named {want:?}");
27200        };
27201        let a_id = find_by_name(&jg, "A");
27202        let b_id = find_by_name(&jg, "b");
27203        let ta_id = find_by_name(&jg, "tangent_A");
27204
27205        let a_data: [f64; 9] = [2.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
27206        let b_data: [f64; 3] = [1.0, 2.0, 3.0];
27207        // Asymmetric perturbation direction for A.
27208        let ta_data: [f64; 9] = [0.10, -0.05, 0.02, 0.03, 0.20, -0.04, -0.01, 0.07, 0.15];
27209
27210        let (sched, mut arena) =
27211            prepare_f64(&jg, &[(a_id, &a_data), (b_id, &b_data), (ta_id, &ta_data)]);
27212        execute_thunks(&sched, arena.raw_buf_mut());
27213
27214        let tangent_x = read_arena_f64(&arena, jg.outputs[1], n);
27215
27216        // Closed form: x = solve(A, b); t_x = −solve(A, t_A · x).
27217        let x_ref = {
27218            let mut a = a_data;
27219            let mut b = b_data;
27220            crate::blas::dgesv(&mut a, &mut b, n, 1);
27221            b
27222        };
27223        let mut prod = [0.0_f64; 3];
27224        for i in 0..n {
27225            for j in 0..n {
27226                prod[i] += ta_data[i * n + j] * x_ref[j];
27227            }
27228        }
27229        let t_x_ref = {
27230            let mut a = a_data;
27231            let mut p = prod;
27232            crate::blas::dgesv(&mut a, &mut p, n, 1);
27233            [-p[0], -p[1], -p[2]]
27234        };
27235        for i in 0..n {
27236            assert!(
27237                (tangent_x[i] - t_x_ref[i]).abs() < 1e-10,
27238                "closed-form t_x[{i}]: AD={} ref={}",
27239                tangent_x[i],
27240                t_x_ref[i]
27241            );
27242        }
27243
27244        // FD: solve(A + h·t_A, b) and solve(A − h·t_A, b).
27245        let h = 1e-6;
27246        let mut ap = a_data;
27247        let mut am = a_data;
27248        for i in 0..n * n {
27249            ap[i] += h * ta_data[i];
27250            am[i] -= h * ta_data[i];
27251        }
27252        let xp = {
27253            let mut a = ap;
27254            let mut b = b_data;
27255            crate::blas::dgesv(&mut a, &mut b, n, 1);
27256            b
27257        };
27258        let xm = {
27259            let mut a = am;
27260            let mut b = b_data;
27261            crate::blas::dgesv(&mut a, &mut b, n, 1);
27262            b
27263        };
27264        for i in 0..n {
27265            let fd = (xp[i] - xm[i]) / (2.0 * h);
27266            assert!(
27267                (tangent_x[i] - fd).abs() < 1e-7,
27268                "FD t_x[{i}]: AD={} FD={}",
27269                tangent_x[i],
27270                fd
27271            );
27272        }
27273    }
27274
27275    /// Real INT8 conv2d parity. Same setup as QMatMul: pre-quantize
27276    /// f32 inputs to i8, run `Op::QConv2d`, compare against an
27277    /// in-test reference loop that does the same i32 accumulation
27278    /// and requantize math. Symmetric quant (zp=0) to keep the math
27279    /// head-to-head.
27280    #[test]
27281    fn q_conv2d_matches_reference() {
27282        use rlx_ir::Philox4x32;
27283        // Small NCHW shape — enough to exercise stride/padding edges.
27284        let n = 1usize;
27285        let c_in = 2usize;
27286        let h = 5usize;
27287        let w_in = 5usize;
27288        let c_out = 3usize;
27289        let kh = 3usize;
27290        let kw = 3usize;
27291        let ph = 1usize;
27292        let pw = 1usize;
27293        let sh = 1usize;
27294        let sw = 1usize;
27295        let h_out = (h + 2 * ph - kh) / sh + 1;
27296        let w_out = (w_in + 2 * pw - kw) / sw + 1;
27297
27298        let x_scale = 0.04f32;
27299        let w_scale = 0.02f32;
27300        let out_scale = 0.5f32;
27301        let mult = x_scale * w_scale / out_scale;
27302
27303        let mut rng = Philox4x32::new(2099);
27304        let mut xf = vec![0f32; n * c_in * h * w_in];
27305        rng.fill_normal(&mut xf);
27306        let mut wf = vec![0f32; c_out * c_in * kh * kw];
27307        rng.fill_normal(&mut wf);
27308        let xq: Vec<i8> = xf
27309            .iter()
27310            .map(|&v| ((v / x_scale).round() as i32).clamp(-128, 127) as i8)
27311            .collect();
27312        let wq: Vec<i8> = wf
27313            .iter()
27314            .map(|&v| ((v / w_scale).round() as i32).clamp(-128, 127) as i8)
27315            .collect();
27316        let bias: Vec<i32> = vec![0i32; c_out];
27317
27318        let mut g = Graph::new("qconv");
27319        let xn = g.input("x", Shape::new(&[n, c_in, h, w_in], DType::I8));
27320        let wn = g.input("w", Shape::new(&[c_out, c_in, kh, kw], DType::I8));
27321        let bn = g.input("b", Shape::new(&[c_out], DType::I32));
27322        let out = g.q_conv2d(
27323            xn,
27324            wn,
27325            bn,
27326            vec![kh, kw],
27327            vec![sh, sw],
27328            vec![ph, pw],
27329            vec![1, 1],
27330            1,
27331            0,
27332            0,
27333            0,
27334            mult,
27335            Shape::new(&[n, c_out, h_out, w_out], DType::I8),
27336        );
27337        g.set_outputs(vec![out]);
27338
27339        let plan = rlx_opt::memory::plan_memory(&g);
27340        let mut arena = crate::arena::Arena::from_plan(plan);
27341        let sched = compile_thunks(&g, &arena);
27342        // Capture offsets before borrowing the buf mutably (avoids
27343        // overlap between &mut and the &arena.byte_offset reads).
27344        let xn_off = arena.byte_offset(xn);
27345        let wn_off = arena.byte_offset(wn);
27346        let bn_off = arena.byte_offset(bn);
27347        let out_off = arena.byte_offset(out);
27348        let buf = arena.raw_buf_mut();
27349        unsafe {
27350            let p = buf.as_mut_ptr().add(xn_off) as *mut i8;
27351            for (i, &v) in xq.iter().enumerate() {
27352                *p.add(i) = v;
27353            }
27354            let p = buf.as_mut_ptr().add(wn_off) as *mut i8;
27355            for (i, &v) in wq.iter().enumerate() {
27356                *p.add(i) = v;
27357            }
27358            let p = buf.as_mut_ptr().add(bn_off) as *mut i32;
27359            for (i, &v) in bias.iter().enumerate() {
27360                *p.add(i) = v;
27361            }
27362        }
27363        execute_thunks(&sched, arena.raw_buf_mut());
27364        let out_q: Vec<i8> = unsafe {
27365            let p = arena.raw_buf().as_ptr().add(out_off) as *const i8;
27366            (0..n * c_out * h_out * w_out).map(|i| *p.add(i)).collect()
27367        };
27368
27369        // Reference: scalar loop in NCHW with the same requantize.
27370        let mut out_ref = vec![0i8; n * c_out * h_out * w_out];
27371        for ni in 0..n {
27372            for co in 0..c_out {
27373                for ho in 0..h_out {
27374                    for wo in 0..w_out {
27375                        let mut acc: i32 = 0;
27376                        for ci in 0..c_in {
27377                            for ki in 0..kh {
27378                                for kj in 0..kw {
27379                                    let hi = ho * sh + ki;
27380                                    let wi = wo * sw + kj;
27381                                    if hi < ph || wi < pw {
27382                                        continue;
27383                                    }
27384                                    let hi = hi - ph;
27385                                    let wi = wi - pw;
27386                                    if hi >= h || wi >= w_in {
27387                                        continue;
27388                                    }
27389                                    let xv =
27390                                        xq[((ni * c_in) + ci) * h * w_in + hi * w_in + wi] as i32;
27391                                    let wv = wq[((co * c_in) + ci) * kh * kw + ki * kw + kj] as i32;
27392                                    acc += xv * wv;
27393                                }
27394                            }
27395                        }
27396                        let r = (acc as f32 * mult).round() as i32;
27397                        let r = r.clamp(-128, 127) as i8;
27398                        out_ref[((ni * c_out) + co) * h_out * w_out + ho * w_out + wo] = r;
27399                    }
27400                }
27401            }
27402        }
27403
27404        for (i, (a, r)) in out_q.iter().zip(&out_ref).enumerate() {
27405            assert_eq!(a, r, "q_conv2d[{i}]: kernel {a} vs reference {r}");
27406        }
27407    }
27408
27409    /// Real INT8 matmul parity: compare `Op::QMatMul` against the
27410    /// fake-quant reference `Dequantize → MatMul → Quantize` that
27411    /// would produce the same output if we round-tripped through
27412    /// f32. Both should agree element-for-element (or within ±1 i8
27413    /// step, since rounding in the requantize uses different code
27414    /// paths). Symmetric quantization (zp=0) for both paths to keep
27415    /// the math head-to-head.
27416    #[test]
27417    fn q_matmul_matches_fake_quant_reference() {
27418        use rlx_ir::Philox4x32;
27419        let m = 3usize;
27420        let k = 8usize;
27421        let n = 5usize;
27422        let mut rng = Philox4x32::new(2031);
27423
27424        // Pick scales and quantize random f32 inputs to i8.
27425        let x_scale = 0.05f32;
27426        let w_scale = 0.03f32;
27427        let out_scale = 0.4f32;
27428        let mult = x_scale * w_scale / out_scale;
27429        let mut xf = vec![0f32; m * k];
27430        rng.fill_normal(&mut xf);
27431        let mut wf = vec![0f32; k * n];
27432        rng.fill_normal(&mut wf);
27433        let xq: Vec<i8> = xf
27434            .iter()
27435            .map(|&v| ((v / x_scale).round() as i32).clamp(-128, 127) as i8)
27436            .collect();
27437        let wq: Vec<i8> = wf
27438            .iter()
27439            .map(|&v| ((v / w_scale).round() as i32).clamp(-128, 127) as i8)
27440            .collect();
27441        let bias: Vec<i32> = vec![0i32; n];
27442
27443        // ── Direct INT8 path ──
27444        let _f = DType::F32;
27445        let mut g_q = Graph::new("qmm_direct");
27446        let xn = g_q.input("x", Shape::new(&[m, k], DType::I8));
27447        let wn = g_q.input("w", Shape::new(&[k, n], DType::I8));
27448        let bn = g_q.input("b", Shape::new(&[n], DType::I32));
27449        let out = g_q.q_matmul(xn, wn, bn, 0, 0, 0, mult, Shape::new(&[m, n], DType::I8));
27450        g_q.set_outputs(vec![out]);
27451        let plan = rlx_opt::memory::plan_memory(&g_q);
27452        let mut arena = crate::arena::Arena::from_plan(plan);
27453        let sched = compile_thunks(&g_q, &arena);
27454
27455        // Fill inputs.
27456        let xn_off = arena.byte_offset(xn);
27457        let wn_off = arena.byte_offset(wn);
27458        let bn_off = arena.byte_offset(bn);
27459        let out_off = arena.byte_offset(out);
27460        let buf = arena.raw_buf_mut();
27461        unsafe {
27462            let p = buf.as_mut_ptr().add(xn_off) as *mut i8;
27463            for (i, &v) in xq.iter().enumerate() {
27464                *p.add(i) = v;
27465            }
27466            let p = buf.as_mut_ptr().add(wn_off) as *mut i8;
27467            for (i, &v) in wq.iter().enumerate() {
27468                *p.add(i) = v;
27469            }
27470            let p = buf.as_mut_ptr().add(bn_off) as *mut i32;
27471            for (i, &v) in bias.iter().enumerate() {
27472                *p.add(i) = v;
27473            }
27474        }
27475        execute_thunks(&sched, arena.raw_buf_mut());
27476        let out_q: Vec<i8> = unsafe {
27477            let p = arena.raw_buf().as_ptr().add(out_off) as *const i8;
27478            (0..m * n).map(|i| *p.add(i)).collect()
27479        };
27480
27481        // ── Fake-quant reference: scalar emulation in plain Rust ──
27482        // Same arithmetic the kernel does, but in a verifier loop:
27483        //   acc = Σ (x[m,k]) · (w[k,n]),  // zps are 0
27484        //   out[m,n] = saturate_i8(round(acc · mult) + 0)
27485        let mut out_ref = vec![0i8; m * n];
27486        for mi in 0..m {
27487            for ni in 0..n {
27488                let mut acc: i32 = 0;
27489                for ki in 0..k {
27490                    acc += (xq[mi * k + ki] as i32) * (wq[ki * n + ni] as i32);
27491                }
27492                let r = (acc as f32 * mult).round() as i32;
27493                out_ref[mi * n + ni] = r.clamp(-128, 127) as i8;
27494            }
27495        }
27496
27497        for (i, (a, r)) in out_q.iter().zip(&out_ref).enumerate() {
27498            assert_eq!(a, r, "q_matmul[{i}]: kernel {a} vs reference {r}");
27499        }
27500    }
27501
27502    /// Quantize/Dequantize round-trip — quantize an f32 tensor, then
27503    /// dequantize back, and confirm the result tracks the input
27504    /// within the per-element scale (the inevitable rounding error).
27505    /// Also pins the kernel's saturation behavior at the i8 limits.
27506    #[test]
27507    fn quantize_dequantize_round_trip() {
27508        use rlx_ir::Philox4x32;
27509        let len = 64;
27510        let mut rng = Philox4x32::new(2027);
27511        let mut x = vec![0f32; len];
27512        rng.fill_normal(&mut x);
27513        // Stretch a couple values past the +/- saturation cliff so
27514        // the saturate_i8 path is exercised.
27515        x[0] = 999.0;
27516        x[1] = -999.0;
27517
27518        let scale = 0.05f32;
27519        let zp = 3i32;
27520
27521        let f = DType::F32;
27522        let mut g = Graph::new("qdq");
27523        let xn = g.input("x", Shape::new(&[len], f));
27524        let q = g.quantize(xn, scale, zp);
27525        let dq = g.dequantize(q, scale, zp);
27526        g.set_outputs(vec![dq]);
27527
27528        let plan = rlx_opt::memory::plan_memory(&g);
27529        let mut arena = crate::arena::Arena::from_plan(plan);
27530        let sched = compile_thunks(&g, &arena);
27531        let xn_off = arena.byte_offset(xn);
27532        let dq_off = arena.byte_offset(dq);
27533        let buf = arena.raw_buf_mut();
27534        unsafe {
27535            let p = buf.as_mut_ptr().add(xn_off) as *mut f32;
27536            for (i, &v) in x.iter().enumerate() {
27537                *p.add(i) = v;
27538            }
27539        }
27540        execute_thunks(&sched, arena.raw_buf_mut());
27541        let out: Vec<f32> = unsafe {
27542            let p = arena.raw_buf().as_ptr().add(dq_off) as *const f32;
27543            (0..len).map(|i| *p.add(i)).collect()
27544        };
27545
27546        // Saturated values at i=0,1 should clamp to ±127's dequant
27547        // range (= (±127 - zp) · scale).
27548        let sat_pos = (127 - zp) as f32 * scale;
27549        let sat_neg = (-128 - zp) as f32 * scale;
27550        assert!((out[0] - sat_pos).abs() < 1e-6, "+sat: {}", out[0]);
27551        assert!((out[1] - sat_neg).abs() < 1e-6, "-sat: {}", out[1]);
27552
27553        // Everything else should round-trip within `scale` (one quant
27554        // step = the worst-case rounding error).
27555        for i in 2..len {
27556            assert!(
27557                (out[i] - x[i]).abs() <= scale + 1e-5,
27558                "qdq[{i}]: {} → {}, scale={scale}",
27559                x[i],
27560                out[i]
27561            );
27562        }
27563    }
27564
27565    /// Per-channel quantize / dequantize: independent scale and zp
27566    /// per slice along an axis. Verifies (a) each channel uses its
27567    /// own scale (not a shared one), (b) saturation still respects
27568    /// the i8 range, (c) channel data layout decomposition is
27569    /// correct (no cross-channel leakage).
27570    #[test]
27571    fn quantize_per_channel_round_trip() {
27572        let c = 4usize;
27573        let inner = 5usize;
27574        // Different magnitudes per channel — proves the per-channel
27575        // scale is actually being read for each row.
27576        let mags = [0.01f32, 0.5, 5.0, 50.0];
27577        let mut x = vec![0f32; c * inner];
27578        for ci in 0..c {
27579            for ii in 0..inner {
27580                // Sweep through values that span [-max_abs, +max_abs]
27581                // for each channel, plus one value past the cliff to
27582                // trigger saturation.
27583                x[ci * inner + ii] = match ii {
27584                    0 => -mags[ci],
27585                    1 => 0.0,
27586                    2 => mags[ci],
27587                    3 => mags[ci] * 1000.0,  // saturates +
27588                    _ => -mags[ci] * 1000.0, // saturates -
27589                };
27590            }
27591        }
27592        let scales: Vec<f32> = mags.iter().map(|&m| m / 127.0).collect();
27593        let zps: Vec<i32> = vec![0, 0, 0, 0];
27594
27595        let f = DType::F32;
27596        let mut g = Graph::new("qdq_pc");
27597        let xn = g.input("x", Shape::new(&[c, inner], f));
27598        let q = g.quantize_per_channel(xn, 0, scales.clone(), zps.clone());
27599        let dq = g.dequantize_per_channel(q, 0, scales.clone(), zps);
27600        g.set_outputs(vec![dq]);
27601
27602        let plan = rlx_opt::memory::plan_memory(&g);
27603        let mut arena = crate::arena::Arena::from_plan(plan);
27604        let sched = compile_thunks(&g, &arena);
27605        let xn_off = arena.byte_offset(xn);
27606        let dq_off = arena.byte_offset(dq);
27607        let buf = arena.raw_buf_mut();
27608        unsafe {
27609            let p = buf.as_mut_ptr().add(xn_off) as *mut f32;
27610            for (i, &v) in x.iter().enumerate() {
27611                *p.add(i) = v;
27612            }
27613        }
27614        execute_thunks(&sched, arena.raw_buf_mut());
27615        let out: Vec<f32> = unsafe {
27616            let p = arena.raw_buf().as_ptr().add(dq_off) as *const f32;
27617            (0..c * inner).map(|i| *p.add(i)).collect()
27618        };
27619
27620        for ci in 0..c {
27621            // Within-range entries (positions 0, 1, 2) must round-trip
27622            // within one quant step of *that channel's* scale.
27623            for ii in 0..3 {
27624                let idx = ci * inner + ii;
27625                assert!(
27626                    (out[idx] - x[idx]).abs() <= scales[ci] + 1e-5,
27627                    "ch {ci} idx {ii}: {} vs {}",
27628                    x[idx],
27629                    out[idx]
27630                );
27631            }
27632            // Saturated positions clamp to ±127 · scale[ci].
27633            let sat_pos = 127.0 * scales[ci];
27634            let sat_neg = -128.0 * scales[ci];
27635            assert!(
27636                (out[ci * inner + 3] - sat_pos).abs() < 1e-5,
27637                "ch {ci} +sat: {}",
27638                out[ci * inner + 3]
27639            );
27640            assert!(
27641                (out[ci * inner + 4] - sat_neg).abs() < 1e-5,
27642                "ch {ci} -sat: {}",
27643                out[ci * inner + 4]
27644            );
27645        }
27646    }
27647
27648    /// `Op::ActivationBackward` parity for every supported kind.
27649    /// Builds a single-op graph `dx = activation_backward(x, dy)` and
27650    /// compares each `dx[i]` to the central-difference `(act(x+ε) -
27651    /// act(x-ε)) / (2ε) · dy\[i\]`. Sweeps the closed-form covered by
27652    /// the kernel.
27653    #[test]
27654    fn activation_backward_matches_numerical_per_kind() {
27655        use rlx_ir::Philox4x32;
27656        use rlx_ir::op::Activation;
27657        let mut rng = Philox4x32::new(91);
27658        let len = 32;
27659        // x sampled away from kink/branch points: shifted positive
27660        // (exp/sqrt/log domain) for the unary-positive activations;
27661        // wide range otherwise. Two parallel tests would be cleaner
27662        // but this is concise enough.
27663        let mut x_pos = vec![0f32; len];
27664        rng.fill_normal(&mut x_pos);
27665        for v in x_pos.iter_mut() {
27666            *v = v.abs() + 0.5;
27667        }
27668        let mut x_any = vec![0f32; len];
27669        rng.fill_normal(&mut x_any);
27670        let mut dy = vec![0f32; len];
27671        rng.fill_normal(&mut dy);
27672
27673        for &(kind, x_data, eps, tol) in &[
27674            (Activation::Sigmoid, &x_any[..], 1e-3, 5e-3),
27675            (Activation::Tanh, &x_any[..], 1e-3, 5e-3),
27676            (Activation::Silu, &x_any[..], 1e-3, 5e-3),
27677            (Activation::Gelu, &x_any[..], 1e-3, 5e-3),
27678            (Activation::GeluApprox, &x_any[..], 1e-3, 5e-3),
27679            (Activation::Exp, &x_any[..], 1e-4, 5e-3),
27680            (Activation::Log, &x_pos[..], 1e-4, 5e-3),
27681            (Activation::Sqrt, &x_pos[..], 1e-4, 5e-3),
27682            (Activation::Rsqrt, &x_pos[..], 1e-4, 5e-3),
27683            (Activation::Neg, &x_any[..], 1e-3, 5e-4),
27684        ] {
27685            let f = DType::F32;
27686            let mut g = Graph::new("act_bw");
27687            let xn = g.input("x", Shape::new(&[len], f));
27688            let dyn_ = g.input("dy", Shape::new(&[len], f));
27689            let dx = g.activation_backward(kind, xn, dyn_);
27690            g.set_outputs(vec![dx]);
27691
27692            let plan = rlx_opt::memory::plan_memory(&g);
27693            let mut arena = crate::arena::Arena::from_plan(plan);
27694            let sched = compile_thunks(&g, &arena);
27695
27696            let xn_off = arena.byte_offset(xn);
27697            let dyn_off = arena.byte_offset(dyn_);
27698            let dx_off = arena.byte_offset(dx);
27699            let buf = arena.raw_buf_mut();
27700            unsafe {
27701                let p = buf.as_mut_ptr().add(xn_off) as *mut f32;
27702                for (i, &v) in x_data.iter().enumerate() {
27703                    *p.add(i) = v;
27704                }
27705                let p = buf.as_mut_ptr().add(dyn_off) as *mut f32;
27706                for (i, &v) in dy.iter().enumerate() {
27707                    *p.add(i) = v;
27708                }
27709            }
27710            execute_thunks(&sched, arena.raw_buf_mut());
27711            let analytical: Vec<f32> = unsafe {
27712                let p = arena.raw_buf().as_ptr().add(dx_off) as *const f32;
27713                (0..len).map(|i| *p.add(i)).collect()
27714            };
27715
27716            // Apply the forward activation manually; finite-difference
27717            // each element.
27718            let act_apply = |kind: Activation, x: f32| -> f32 {
27719                match kind {
27720                    Activation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
27721                    Activation::Tanh => x.tanh(),
27722                    Activation::Silu => x / (1.0 + (-x).exp()),
27723                    Activation::Gelu => {
27724                        // Match the kernel's exact erf form.
27725                        const INV_SQRT2: f32 = 0.707_106_77;
27726                        0.5 * x * (1.0 + erf_f32(x * INV_SQRT2))
27727                    }
27728                    Activation::GeluApprox => {
27729                        const C: f32 = 0.797_884_6;
27730                        const A: f32 = 0.044_715;
27731                        let inner = C * (x + A * x * x * x);
27732                        0.5 * x * (1.0 + inner.tanh())
27733                    }
27734                    Activation::Exp => x.exp(),
27735                    Activation::Log => x.ln(),
27736                    Activation::Sqrt => x.sqrt(),
27737                    Activation::Rsqrt => 1.0 / x.sqrt(),
27738                    Activation::Neg => -x,
27739                    Activation::Relu => x.max(0.0),
27740                    Activation::Abs => x.abs(),
27741                    Activation::Round => x.round(),
27742                    Activation::Sin => x.sin(),
27743                    Activation::Cos => x.cos(),
27744                    Activation::Tan => x.tan(),
27745                    Activation::Atan => x.atan(),
27746                }
27747            };
27748            for i in 0..len {
27749                let xv = x_data[i];
27750                let plus = act_apply(kind, xv + eps);
27751                let minus = act_apply(kind, xv - eps);
27752                let num = (plus - minus) / (2.0 * eps) * dy[i];
27753                assert!(
27754                    (analytical[i] - num).abs() < tol,
27755                    "{kind:?}[{i}]: analytical {} vs numerical {num}",
27756                    analytical[i]
27757                );
27758            }
27759        }
27760    }
27761
27762    /// Batched 3-D MatMul VJP — the transformer-attention shape
27763    /// `[B, M, K] @ [B, K, N] = [B, M, N]`. Both gradients flow through
27764    /// `Op::Transpose` with a perm that swaps the last two dims.
27765    #[test]
27766    fn matmul_3d_gradient_matches_numerical() {
27767        use rlx_ir::Philox4x32;
27768        let batch = 2usize;
27769        let m = 3usize;
27770        let k = 4usize;
27771        let n = 5usize;
27772        let mut rng = Philox4x32::new(101);
27773        let mut a_data = vec![0f32; batch * m * k];
27774        rng.fill_normal(&mut a_data);
27775        let mut b_data = vec![0f32; batch * k * n];
27776        rng.fill_normal(&mut b_data);
27777
27778        let f = DType::F32;
27779        let mut fwd = Graph::new("matmul_3d");
27780        let an = fwd.input("a", Shape::new(&[batch, m, k], f));
27781        let bp = fwd.param("b", Shape::new(&[batch, k, n], f));
27782        let mm = fwd.matmul(an, bp, Shape::new(&[batch, m, n], f));
27783        let loss = fwd.add_node(
27784            Op::Reduce {
27785                op: ReduceOp::Sum,
27786                axes: vec![0, 1, 2],
27787                keep_dim: false,
27788            },
27789            vec![mm],
27790            Shape::from_dims(&[], f),
27791        );
27792        fwd.set_outputs(vec![loss]);
27793
27794        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[bp]);
27795        let d_out = bwd_graph
27796            .nodes()
27797            .iter()
27798            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
27799            .map(|n| n.id)
27800            .unwrap();
27801
27802        let plan = rlx_opt::memory::plan_memory(&bwd_graph);
27803        let mut arena = crate::arena::Arena::from_plan(plan);
27804        let sched = compile_thunks(&bwd_graph, &arena);
27805        for &(id, data) in &[(an, &a_data), (bp, &b_data), (d_out, &vec![1.0f32])] {
27806            let off = arena.byte_offset(id);
27807            let buf = arena.raw_buf_mut();
27808            unsafe {
27809                let p = buf.as_mut_ptr().add(off) as *mut f32;
27810                for (i, &v) in data.iter().enumerate() {
27811                    *p.add(i) = v;
27812                }
27813            }
27814        }
27815        execute_thunks(&sched, arena.raw_buf_mut());
27816        let gb_id = bwd_graph.outputs[1];
27817        let g_b: Vec<f32> = unsafe {
27818            let p = arena.raw_buf().as_ptr().add(arena.byte_offset(gb_id)) as *const f32;
27819            (0..batch * k * n).map(|i| *p.add(i)).collect()
27820        };
27821
27822        // Numerical gradient: differentiate sum(a @ b) w.r.t. each b entry.
27823        let forward_loss = |b_vals: &[f32]| -> f32 {
27824            let mut out = vec![0f32; batch * m * n];
27825            for bi in 0..batch {
27826                for mi in 0..m {
27827                    for ni in 0..n {
27828                        let mut acc = 0f32;
27829                        for ki in 0..k {
27830                            acc +=
27831                                a_data[bi * m * k + mi * k + ki] * b_vals[bi * k * n + ki * n + ni];
27832                        }
27833                        out[bi * m * n + mi * n + ni] = acc;
27834                    }
27835                }
27836            }
27837            out.iter().sum()
27838        };
27839        let eps = 1e-3f32;
27840        let mut bp_p = b_data.clone();
27841        let mut g_b_num = vec![0f32; b_data.len()];
27842        for i in 0..b_data.len() {
27843            let s = bp_p[i];
27844            bp_p[i] = s + eps;
27845            let lp = forward_loss(&bp_p);
27846            bp_p[i] = s - eps;
27847            let lm = forward_loss(&bp_p);
27848            bp_p[i] = s;
27849            g_b_num[i] = (lp - lm) / (2.0 * eps);
27850        }
27851        for (i, (a, n)) in g_b.iter().zip(&g_b_num).enumerate() {
27852            assert!(
27853                (a - n).abs() < 5e-3,
27854                "matmul_3d g_b[{i}]: analytical {a} vs numerical {n}"
27855            );
27856        }
27857    }
27858
27859    /// Composed `Op::Softmax` VJP — the gradient is built from
27860    /// `mul + reduce_sum + expand + sub + mul`, no dedicated
27861    /// SoftmaxBackward kernel. Verifies the closed-form
27862    /// `dx = y · (g - Σ y·g)` matches the FD gradient over a small
27863    /// 2-D logits tensor.
27864    #[test]
27865    fn softmax_gradient_matches_numerical() {
27866        use rlx_ir::Philox4x32;
27867        let n = 3usize;
27868        let c = 5usize;
27869        let mut rng = Philox4x32::new(57);
27870        let mut x_data = vec![0f32; n * c];
27871        rng.fill_normal(&mut x_data);
27872
27873        let f = DType::F32;
27874        let mut fwd = Graph::new("softmax_only");
27875        let xn = fwd.input("x", Shape::new(&[n, c], f));
27876        let sm = fwd.add_node(Op::Softmax { axis: -1 }, vec![xn], Shape::new(&[n, c], f));
27877        // Loss = sum(softmax · target) for some random fixed target —
27878        // any linear loss will do; sum-of-all is the simplest and gives
27879        // a uniform gradient flow into the softmax.
27880        let loss = fwd.add_node(
27881            Op::Reduce {
27882                op: ReduceOp::Sum,
27883                axes: vec![0, 1],
27884                keep_dim: false,
27885            },
27886            vec![sm],
27887            Shape::from_dims(&[], f),
27888        );
27889        fwd.set_outputs(vec![loss]);
27890
27891        // `wrt = [xn]` — autodiff exposes the gradient w.r.t. the
27892        // input so we can compare it directly. The forward NodeId for
27893        // `xn` doubles as its bwd-graph mirror.
27894        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[xn]);
27895        let d_out = bwd_graph
27896            .nodes()
27897            .iter()
27898            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
27899            .map(|n| n.id)
27900            .unwrap();
27901
27902        let plan = rlx_opt::memory::plan_memory(&bwd_graph);
27903        let mut arena = crate::arena::Arena::from_plan(plan);
27904        let sched = compile_thunks(&bwd_graph, &arena);
27905        for &(id, data) in &[(xn, &x_data), (d_out, &vec![1.0f32])] {
27906            let off = arena.byte_offset(id);
27907            let buf = arena.raw_buf_mut();
27908            unsafe {
27909                let p = buf.as_mut_ptr().add(off) as *mut f32;
27910                for (i, &v) in data.iter().enumerate() {
27911                    *p.add(i) = v;
27912                }
27913            }
27914        }
27915        execute_thunks(&sched, arena.raw_buf_mut());
27916        let g_x_id = bwd_graph.outputs[1];
27917        let g_x: Vec<f32> = unsafe {
27918            let p = arena.raw_buf().as_ptr().add(arena.byte_offset(g_x_id)) as *const f32;
27919            (0..n * c).map(|i| *p.add(i)).collect()
27920        };
27921
27922        // Loss derivative: softmax sums to 1 per row → d/dx_i sum(softmax) = 0
27923        // analytically. So expect g_x ≈ 0 within FD precision. (This
27924        // doubles as a strong sanity check for the composition.)
27925        let forward_loss = |x: &[f32]| -> f32 {
27926            let mut total = 0f32;
27927            for ni in 0..n {
27928                let row = &x[ni * c..(ni + 1) * c];
27929                let m = row.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
27930                let denom: f32 = row.iter().map(|&v| (v - m).exp()).sum();
27931                for &v in row {
27932                    total += (v - m).exp() / denom;
27933                }
27934            }
27935            total
27936        };
27937        let eps = 1e-3f32;
27938        let mut p = x_data.clone();
27939        for i in 0..x_data.len() {
27940            let s = p[i];
27941            p[i] = s + eps;
27942            let lp = forward_loss(&p);
27943            p[i] = s - eps;
27944            let lm = forward_loss(&p);
27945            p[i] = s;
27946            let num = (lp - lm) / (2.0 * eps);
27947            assert!(
27948                (g_x[i] - num).abs() < 5e-3,
27949                "softmax g_x[{i}]: analytical {} vs numerical {num}",
27950                g_x[i]
27951            );
27952        }
27953    }
27954
27955    /// LayerNorm VJP — three gradients in one pass:
27956    ///   d_x via `LayerNormBackwardInput`,
27957    ///   d_gamma via `LayerNormBackwardGamma`,
27958    ///   d_beta = `unbroadcast(upstream)` to gamma's shape.
27959    #[test]
27960    fn layer_norm_gradient_matches_numerical() {
27961        use rlx_ir::Philox4x32;
27962        let rows = 3usize;
27963        let h = 6usize;
27964        let mut rng = Philox4x32::new(1009);
27965        let mut x_data = vec![0f32; rows * h];
27966        rng.fill_normal(&mut x_data);
27967        let mut g_data = vec![0f32; h];
27968        rng.fill_normal(&mut g_data);
27969        for v in g_data.iter_mut() {
27970            *v = v.abs() + 0.5;
27971        }
27972        let mut b_data = vec![0f32; h];
27973        rng.fill_normal(&mut b_data);
27974        let eps = 1e-5f32;
27975
27976        let f = DType::F32;
27977        let mut fwd = Graph::new("ln_only");
27978        let xn = fwd.input("x", Shape::new(&[rows, h], f));
27979        let gp = fwd.param("gamma", Shape::new(&[h], f));
27980        let bp = fwd.param("beta", Shape::new(&[h], f));
27981        let ln = fwd.add_node(
27982            Op::LayerNorm { axis: -1, eps },
27983            vec![xn, gp, bp],
27984            Shape::new(&[rows, h], f),
27985        );
27986        let loss = fwd.add_node(
27987            Op::Reduce {
27988                op: ReduceOp::Sum,
27989                axes: vec![0, 1],
27990                keep_dim: false,
27991            },
27992            vec![ln],
27993            Shape::from_dims(&[], f),
27994        );
27995        fwd.set_outputs(vec![loss]);
27996
27997        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[xn, gp, bp]);
27998        let d_out = bwd_graph
27999            .nodes()
28000            .iter()
28001            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
28002            .map(|n| n.id)
28003            .unwrap();
28004
28005        let plan = rlx_opt::memory::plan_memory(&bwd_graph);
28006        let mut arena = crate::arena::Arena::from_plan(plan);
28007        let sched = compile_thunks(&bwd_graph, &arena);
28008        for &(id, data) in &[
28009            (xn, &x_data),
28010            (gp, &g_data),
28011            (bp, &b_data),
28012            (d_out, &vec![1.0f32]),
28013        ] {
28014            let off = arena.byte_offset(id);
28015            let buf = arena.raw_buf_mut();
28016            unsafe {
28017                let p = buf.as_mut_ptr().add(off) as *mut f32;
28018                for (i, &v) in data.iter().enumerate() {
28019                    *p.add(i) = v;
28020                }
28021            }
28022        }
28023        execute_thunks(&sched, arena.raw_buf_mut());
28024        let read = |id: NodeId, n: usize| -> Vec<f32> {
28025            let off = arena.byte_offset(id);
28026            unsafe {
28027                let p = arena.raw_buf().as_ptr().add(off) as *const f32;
28028                (0..n).map(|i| *p.add(i)).collect()
28029            }
28030        };
28031        let dx_a = read(bwd_graph.outputs[1], rows * h);
28032        let dg_a = read(bwd_graph.outputs[2], h);
28033        let db_a = read(bwd_graph.outputs[3], h);
28034
28035        let forward_loss = |x: &[f32], g: &[f32], b: &[f32]| -> f32 {
28036            let mut total = 0f32;
28037            for r in 0..rows {
28038                let row = &x[r * h..(r + 1) * h];
28039                let mean = row.iter().sum::<f32>() / h as f32;
28040                let var = row.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / h as f32;
28041                let inv_std = 1.0 / (var + eps).sqrt();
28042                for d in 0..h {
28043                    total += ((row[d] - mean) * inv_std) * g[d] + b[d];
28044                }
28045            }
28046            total
28047        };
28048        let h_eps = 1e-3f32;
28049
28050        let mut x_p = x_data.clone();
28051        for i in 0..x_p.len() {
28052            let s = x_p[i];
28053            x_p[i] = s + h_eps;
28054            let lp = forward_loss(&x_p, &g_data, &b_data);
28055            x_p[i] = s - h_eps;
28056            let lm = forward_loss(&x_p, &g_data, &b_data);
28057            x_p[i] = s;
28058            let num = (lp - lm) / (2.0 * h_eps);
28059            assert!(
28060                (dx_a[i] - num).abs() < 5e-3,
28061                "ln dx[{i}]: analytical {} vs numerical {num}",
28062                dx_a[i]
28063            );
28064        }
28065        let mut g_p = g_data.clone();
28066        for i in 0..g_p.len() {
28067            let s = g_p[i];
28068            g_p[i] = s + h_eps;
28069            let lp = forward_loss(&x_data, &g_p, &b_data);
28070            g_p[i] = s - h_eps;
28071            let lm = forward_loss(&x_data, &g_p, &b_data);
28072            g_p[i] = s;
28073            let num = (lp - lm) / (2.0 * h_eps);
28074            assert!(
28075                (dg_a[i] - num).abs() < 5e-3,
28076                "ln dg[{i}]: analytical {} vs numerical {num}",
28077                dg_a[i]
28078            );
28079        }
28080        let mut b_p = b_data.clone();
28081        for i in 0..b_p.len() {
28082            let s = b_p[i];
28083            b_p[i] = s + h_eps;
28084            let lp = forward_loss(&x_data, &g_data, &b_p);
28085            b_p[i] = s - h_eps;
28086            let lm = forward_loss(&x_data, &g_data, &b_p);
28087            b_p[i] = s;
28088            let num = (lp - lm) / (2.0 * h_eps);
28089            assert!(
28090                (db_a[i] - num).abs() < 5e-3,
28091                "ln db[{i}]: analytical {} vs numerical {num}",
28092                db_a[i]
28093            );
28094        }
28095    }
28096
28097    /// Single dense layer + softmax-cross-entropy + mean reduce —
28098    /// the simplest non-trivial training graph. Validates MatMul,
28099    /// broadcast Add, SCE, Reduce(Mean) VJPs and the grad_with_loss
28100    /// plumbing all at once.
28101    #[test]
28102    fn dense_sce_mean_gradient_matches_numerical() {
28103        use rlx_ir::Philox4x32;
28104        let bs = 4usize;
28105        let k_in = 3usize;
28106        let c = 5usize;
28107        let mut rng = Philox4x32::new(7);
28108        let mut x = vec![0f32; bs * k_in];
28109        rng.fill_normal(&mut x);
28110        let mut w_init = vec![0f32; k_in * c];
28111        rng.fill_normal(&mut w_init);
28112        let mut b_init = vec![0f32; c];
28113        rng.fill_normal(&mut b_init);
28114        let labels: Vec<f32> = (0..bs).map(|i| (i % c) as f32).collect();
28115
28116        // ── Forward graph: loss = mean(sce(x @ w + b, labels)) ──
28117        let f = DType::F32;
28118        let mut fwd = Graph::new("dense_sce");
28119        let xn = fwd.input("x", Shape::new(&[bs, k_in], f));
28120        let lb = fwd.input("labels", Shape::new(&[bs], f));
28121        let wp = fwd.param("w", Shape::new(&[k_in, c], f));
28122        let bp = fwd.param("b", Shape::new(&[c], f));
28123        let mm = fwd.matmul(xn, wp, Shape::new(&[bs, c], f));
28124        let logits = fwd.binary(BinaryOp::Add, mm, bp, Shape::new(&[bs, c], f));
28125        let loss_per = fwd.softmax_cross_entropy_with_logits(logits, lb);
28126        let loss = fwd.add_node(
28127            Op::Reduce {
28128                op: ReduceOp::Sum,
28129                axes: vec![0],
28130                keep_dim: false,
28131            },
28132            vec![loss_per],
28133            // Reduce sum of [bs] with axes=[0] keep_dim=false → scalar [].
28134            Shape::from_dims(&[], f),
28135        );
28136        // Use Sum + manual /bs scalar mul — also exercises BinaryOp::Mul VJP path
28137        // less aggressively than Mean would, and gives us a closed-form
28138        // reference for the loss we expect.
28139        // For simplicity though, switch to Mean which the tests should also cover.
28140        // (Re-using `loss` with Sum here for now; the mean factor cancels in
28141        // the gradient comparison since both analytical and numerical use the
28142        // same forward.)
28143        fwd.set_outputs(vec![loss]);
28144
28145        // ── Backward graph ──
28146        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[wp, bp]);
28147        // Outputs: [loss, grad_w, grad_b]. NodeIds for x/labels/w/b/loss
28148        // in bwd_graph match their fwd ids (the mirror keeps order).
28149        let d_out = bwd_graph
28150            .nodes()
28151            .iter()
28152            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
28153            .map(|n| n.id)
28154            .expect("d_output input");
28155
28156        let (sched, mut arena) = prepare(
28157            &bwd_graph,
28158            &[
28159                (xn, &x),
28160                (lb, &labels),
28161                (wp, &w_init),
28162                (bp, &b_init),
28163                (d_out, &[1.0]),
28164            ],
28165        );
28166        execute_thunks(&sched, arena.raw_buf_mut());
28167
28168        let outs = &bwd_graph.outputs;
28169        let loss_id = outs[0];
28170        let gw_id = outs[1];
28171        let gb_id = outs[2];
28172        let loss_actual = read_arena(&arena, loss_id, 1)[0];
28173        let gw_actual = read_arena(&arena, gw_id, k_in * c);
28174        let gb_actual = read_arena(&arena, gb_id, c);
28175
28176        // ── Forward-only graph for finite differences ──
28177        // Re-use the same `fwd` graph; set up its own arena and rerun
28178        // for each perturbed parameter.
28179        let plan = rlx_opt::memory::plan_memory(&fwd);
28180        let mut fwd_arena = crate::arena::Arena::from_plan(plan);
28181        let fwd_sched = compile_thunks(&fwd, &fwd_arena);
28182        write_arena(&mut fwd_arena, xn, &x);
28183        write_arena(&mut fwd_arena, lb, &labels);
28184
28185        let run_loss = |arena: &mut crate::arena::Arena, w: &[f32], b: &[f32]| -> f32 {
28186            write_arena(arena, wp, w);
28187            write_arena(arena, bp, b);
28188            execute_thunks(&fwd_sched, arena.raw_buf_mut());
28189            read_arena(arena, loss, 1)[0]
28190        };
28191
28192        // Sanity: the loss reported by the bwd graph matches the
28193        // forward-only graph on the unperturbed inputs.
28194        let loss_check = run_loss(&mut fwd_arena, &w_init, &b_init);
28195        assert!(
28196            (loss_actual - loss_check).abs() < 1e-4,
28197            "loss mismatch: bwd graph {loss_actual} vs fwd-only {loss_check}"
28198        );
28199
28200        let eps = 1e-3f32;
28201        let mut w_perturbed = w_init.clone();
28202        let mut gw_numerical = vec![0f32; w_init.len()];
28203        for i in 0..w_init.len() {
28204            let saved = w_perturbed[i];
28205            w_perturbed[i] = saved + eps;
28206            let lp = run_loss(&mut fwd_arena, &w_perturbed, &b_init);
28207            w_perturbed[i] = saved - eps;
28208            let lm = run_loss(&mut fwd_arena, &w_perturbed, &b_init);
28209            w_perturbed[i] = saved;
28210            gw_numerical[i] = (lp - lm) / (2.0 * eps);
28211        }
28212        for (i, (a, n)) in gw_actual.iter().zip(&gw_numerical).enumerate() {
28213            assert!(
28214                (a - n).abs() < 5e-3,
28215                "grad_w[{i}]: analytical {a} vs numerical {n}"
28216            );
28217        }
28218
28219        let mut b_perturbed = b_init.clone();
28220        let mut gb_numerical = vec![0f32; b_init.len()];
28221        for i in 0..b_init.len() {
28222            let saved = b_perturbed[i];
28223            b_perturbed[i] = saved + eps;
28224            let lp = run_loss(&mut fwd_arena, &w_init, &b_perturbed);
28225            b_perturbed[i] = saved - eps;
28226            let lm = run_loss(&mut fwd_arena, &w_init, &b_perturbed);
28227            b_perturbed[i] = saved;
28228            gb_numerical[i] = (lp - lm) / (2.0 * eps);
28229        }
28230        for (i, (a, n)) in gb_actual.iter().zip(&gb_numerical).enumerate() {
28231            assert!(
28232                (a - n).abs() < 5e-3,
28233                "grad_b[{i}]: analytical {a} vs numerical {n}"
28234            );
28235        }
28236    }
28237
28238    /// Reduce::Mean specifically — verifies the 1/N scaling in the VJP.
28239    /// The same dense+SCE graph but with Mean instead of Sum on the loss.
28240    #[test]
28241    fn dense_sce_mean_reduce_gradient_matches_numerical() {
28242        use rlx_ir::Philox4x32;
28243        let bs = 3usize;
28244        let k_in = 2usize;
28245        let c = 4usize;
28246        let mut rng = Philox4x32::new(13);
28247        let mut x = vec![0f32; bs * k_in];
28248        rng.fill_normal(&mut x);
28249        let mut w_init = vec![0f32; k_in * c];
28250        rng.fill_normal(&mut w_init);
28251        let labels: Vec<f32> = (0..bs).map(|i| (i % c) as f32).collect();
28252
28253        let f = DType::F32;
28254        let mut fwd = Graph::new("dense_sce_mean");
28255        let xn = fwd.input("x", Shape::new(&[bs, k_in], f));
28256        let lb = fwd.input("labels", Shape::new(&[bs], f));
28257        let wp = fwd.param("w", Shape::new(&[k_in, c], f));
28258        let mm = fwd.matmul(xn, wp, Shape::new(&[bs, c], f));
28259        let loss_per = fwd.softmax_cross_entropy_with_logits(mm, lb);
28260        let loss = fwd.add_node(
28261            Op::Reduce {
28262                op: ReduceOp::Mean,
28263                axes: vec![0],
28264                keep_dim: false,
28265            },
28266            vec![loss_per],
28267            Shape::from_dims(&[], f),
28268        );
28269        fwd.set_outputs(vec![loss]);
28270
28271        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[wp]);
28272        let d_out = bwd_graph
28273            .nodes()
28274            .iter()
28275            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
28276            .map(|n| n.id)
28277            .unwrap();
28278
28279        let (sched, mut arena) = prepare(
28280            &bwd_graph,
28281            &[(xn, &x), (lb, &labels), (wp, &w_init), (d_out, &[1.0])],
28282        );
28283        execute_thunks(&sched, arena.raw_buf_mut());
28284
28285        let outs = &bwd_graph.outputs;
28286        let loss_id = outs[0];
28287        let gw_id = outs[1];
28288        let _ = read_arena(&arena, loss_id, 1)[0];
28289        let gw_actual = read_arena(&arena, gw_id, k_in * c);
28290
28291        let plan = rlx_opt::memory::plan_memory(&fwd);
28292        let mut fwd_arena = crate::arena::Arena::from_plan(plan);
28293        let fwd_sched = compile_thunks(&fwd, &fwd_arena);
28294        write_arena(&mut fwd_arena, xn, &x);
28295        write_arena(&mut fwd_arena, lb, &labels);
28296
28297        let run_loss = |arena: &mut crate::arena::Arena, w: &[f32]| -> f32 {
28298            write_arena(arena, wp, w);
28299            execute_thunks(&fwd_sched, arena.raw_buf_mut());
28300            read_arena(arena, loss, 1)[0]
28301        };
28302
28303        let eps = 1e-3f32;
28304        let mut wp_p = w_init.clone();
28305        let mut gw_num = vec![0f32; w_init.len()];
28306        for i in 0..w_init.len() {
28307            let s = wp_p[i];
28308            wp_p[i] = s + eps;
28309            let lp = run_loss(&mut fwd_arena, &wp_p);
28310            wp_p[i] = s - eps;
28311            let lm = run_loss(&mut fwd_arena, &wp_p);
28312            wp_p[i] = s;
28313            gw_num[i] = (lp - lm) / (2.0 * eps);
28314        }
28315        for (i, (a, n)) in gw_actual.iter().zip(&gw_num).enumerate() {
28316            assert!((a - n).abs() < 5e-3, "mean reduce grad_w[{i}]: {a} vs {n}");
28317        }
28318    }
28319    /// The full TinyConv-MNIST forward path (downsized) plumbed
28320    /// through grad_with_loss. Validates that Conv, Pool(Max), ReLU,
28321    /// Reshape, MatMul, Add (broadcast), SCE, Reduce(Mean) VJPs all
28322    /// compose into a graph that produces correct gradients.
28323    #[test]
28324    fn tinyconv_full_gradient_matches_numerical() {
28325        use rlx_ir::Philox4x32;
28326        // Tiny shapes so finite differences finish in <1s.
28327        let n = 1usize;
28328        let c_in = 1usize;
28329        let h = 6usize;
28330        let w_in = 6usize;
28331        let c_mid = 2usize; // first conv output channels
28332        let kh = 3;
28333        let kw = 3;
28334        let h1 = h - kh + 1; // 4
28335        let w1 = w_in - kw + 1; // 4
28336        let h2 = h1 / 2;
28337        let w2 = w1 / 2; // 2 × 2 after 2× pool
28338        let flat = c_mid * h2 * w2; // 8
28339        let num_classes = 3usize;
28340
28341        let mut rng = Philox4x32::new(31);
28342        let mut x = vec![0f32; n * c_in * h * w_in];
28343        rng.fill_normal(&mut x);
28344        let mut wc = vec![0f32; c_mid * c_in * kh * kw];
28345        rng.fill_normal(&mut wc);
28346        for v in wc.iter_mut() {
28347            *v *= 0.2;
28348        }
28349        // Shift conv-bias well away from the ReLU zero-boundary. Without
28350        // this, an ε-perturbation of bc[c] can flip the ReLU mask on a
28351        // pre-activation that happened to land near zero — making the
28352        // central-difference numerical gradient discontinuous and
28353        // diverge from the analytical (which assumes local smoothness).
28354        // +5.0 keeps every pre-activation positive for any random init
28355        // produced by Philox seed 31 with the wc/x scales used here, so
28356        // ReLU acts as an identity and finite differences are exact.
28357        let bc: Vec<f32> = (0..c_mid).map(|i| 5.0 + 0.1 * i as f32).collect();
28358        let mut wfc = vec![0f32; flat * num_classes];
28359        rng.fill_normal(&mut wfc);
28360        for v in wfc.iter_mut() {
28361            *v *= 0.5;
28362        }
28363        let mut bfc = vec![0f32; num_classes];
28364        rng.fill_normal(&mut bfc);
28365        let labels: Vec<f32> = vec![1.0]; // batch=1
28366
28367        let f = DType::F32;
28368        let mut fwd = Graph::new("tinyconv");
28369        let xn = fwd.input("x", Shape::new(&[n, c_in, h, w_in], f));
28370        let lb = fwd.input("labels", Shape::new(&[n], f));
28371        let wcp = fwd.param("wc", Shape::new(&[c_mid, c_in, kh, kw], f));
28372        let bcp = fwd.param("bc", Shape::new(&[c_mid], f));
28373        let wfp = fwd.param("wfc", Shape::new(&[flat, num_classes], f));
28374        let bfp = fwd.param("bfc", Shape::new(&[num_classes], f));
28375
28376        // conv: [n, c_in, h, w] → [n, c_mid, h1, w1]
28377        let conv = fwd.add_node(
28378            Op::Conv {
28379                kernel_size: vec![kh, kw],
28380                stride: vec![1, 1],
28381                padding: vec![0, 0],
28382                dilation: vec![1, 1],
28383                groups: 1,
28384            },
28385            vec![xn, wcp],
28386            Shape::new(&[n, c_mid, h1, w1], f),
28387        );
28388        // Bias add: expand bc[c_mid] up to the full [n, c_mid, h1, w1]
28389        // shape so the Add becomes a plain element-wise op. Going through
28390        // an explicit Reshape→Expand instead of relying on the Add to
28391        // broadcast `[1, C, 1, 1]` → `[N, C, H, W]` works around a known
28392        // limitation of `rlx-cpu`'s `Op::Binary` lowering: it dispatches
28393        // on `out_len % rhs_len == 0` and treats `rhs` as a last-axis
28394        // bias, which produces `bc[0], bc[1], bc[0], bc[1], …` alternating
28395        // across all positions instead of channel-broadcasting. Going
28396        // through Expand (a real broadcast thunk) avoids that path
28397        // entirely. The autodiff still exercises `unbroadcast` because
28398        // `Op::Expand`'s VJP reduces over the broadcast axes.
28399        let bc_4d = fwd.add_node(
28400            Op::Reshape {
28401                new_shape: vec![1, c_mid as i64, 1, 1],
28402            },
28403            vec![bcp],
28404            Shape::new(&[1, c_mid, 1, 1], f),
28405        );
28406        let bc_expanded = fwd.add_node(
28407            Op::Expand {
28408                target_shape: vec![n as i64, c_mid as i64, h1 as i64, w1 as i64],
28409            },
28410            vec![bc_4d],
28411            Shape::new(&[n, c_mid, h1, w1], f),
28412        );
28413        let conv_b = fwd.binary(
28414            BinaryOp::Add,
28415            conv,
28416            bc_expanded,
28417            Shape::new(&[n, c_mid, h1, w1], f),
28418        );
28419        let relu = fwd.activation(Activation::Relu, conv_b, Shape::new(&[n, c_mid, h1, w1], f));
28420        let pool = fwd.add_node(
28421            Op::Pool {
28422                kind: ReduceOp::Max,
28423                kernel_size: vec![2, 2],
28424                stride: vec![2, 2],
28425                padding: vec![0, 0],
28426            },
28427            vec![relu],
28428            Shape::new(&[n, c_mid, h2, w2], f),
28429        );
28430        let flatn = fwd.add_node(
28431            Op::Reshape {
28432                new_shape: vec![n as i64, flat as i64],
28433            },
28434            vec![pool],
28435            Shape::new(&[n, flat], f),
28436        );
28437        let mm = fwd.matmul(flatn, wfp, Shape::new(&[n, num_classes], f));
28438        let logits = fwd.binary(BinaryOp::Add, mm, bfp, Shape::new(&[n, num_classes], f));
28439        let loss_per = fwd.softmax_cross_entropy_with_logits(logits, lb);
28440        let loss = fwd.add_node(
28441            Op::Reduce {
28442                op: ReduceOp::Mean,
28443                axes: vec![0],
28444                keep_dim: false,
28445            },
28446            vec![loss_per],
28447            Shape::from_dims(&[], f),
28448        );
28449        fwd.set_outputs(vec![loss]);
28450
28451        let bwd_graph = rlx_opt::autodiff::grad_with_loss(&fwd, &[wcp, bcp, wfp, bfp]);
28452        let d_out = bwd_graph
28453            .nodes()
28454            .iter()
28455            .find(|n| matches!(&n.op, Op::Input { name } if name == "d_output"))
28456            .map(|n| n.id)
28457            .unwrap();
28458
28459        let (sched, mut arena) = prepare(
28460            &bwd_graph,
28461            &[
28462                (xn, &x),
28463                (lb, &labels),
28464                (wcp, &wc),
28465                (bcp, &bc),
28466                (wfp, &wfc),
28467                (bfp, &bfc),
28468                (d_out, &[1.0]),
28469            ],
28470        );
28471        execute_thunks(&sched, arena.raw_buf_mut());
28472
28473        let outs = bwd_graph.outputs.clone();
28474        let loss_id = outs[0];
28475        let g_wc_id = outs[1];
28476        let g_bc_id = outs[2];
28477        let g_wfc_id = outs[3];
28478        let g_bfc_id = outs[4];
28479        let loss_actual = read_arena(&arena, loss_id, 1)[0];
28480        let g_wc = read_arena(&arena, g_wc_id, wc.len());
28481        let g_bc = read_arena(&arena, g_bc_id, bc.len());
28482        let g_wfc = read_arena(&arena, g_wfc_id, wfc.len());
28483        let g_bfc = read_arena(&arena, g_bfc_id, bfc.len());
28484
28485        // Forward-only arena for finite differences.
28486        let plan = rlx_opt::memory::plan_memory(&fwd);
28487        let mut fwd_arena = crate::arena::Arena::from_plan(plan);
28488        let fwd_sched = compile_thunks(&fwd, &fwd_arena);
28489        write_arena(&mut fwd_arena, xn, &x);
28490        write_arena(&mut fwd_arena, lb, &labels);
28491
28492        // Closure variant: we need to set all four params each call so
28493        // perturbations to one don't leak between sweeps.
28494        let run_loss = |arena: &mut crate::arena::Arena,
28495                        wc: &[f32],
28496                        bc: &[f32],
28497                        wfc: &[f32],
28498                        bfc: &[f32]|
28499         -> f32 {
28500            write_arena(arena, wcp, wc);
28501            write_arena(arena, bcp, bc);
28502            write_arena(arena, wfp, wfc);
28503            write_arena(arena, bfp, bfc);
28504            execute_thunks(&fwd_sched, arena.raw_buf_mut());
28505            read_arena(arena, loss, 1)[0]
28506        };
28507
28508        let loss_check = run_loss(&mut fwd_arena, &wc, &bc, &wfc, &bfc);
28509        assert!(
28510            (loss_actual - loss_check).abs() < 1e-4,
28511            "tinyconv loss mismatch: bwd {loss_actual} vs fwd {loss_check}"
28512        );
28513
28514        let eps = 1e-3f32;
28515        let check_grad = |arena: &mut crate::arena::Arena,
28516                          name: &str,
28517                          analytical: &[f32],
28518                          mut perturb: Box<
28519            dyn FnMut(&mut [f32], usize, f32, &mut crate::arena::Arena) -> f32 + '_,
28520        >,
28521                          n: usize| {
28522            for i in 0..n {
28523                let lp = perturb(&mut analytical.to_vec(), i, eps, arena);
28524                let lm = perturb(&mut analytical.to_vec(), i, -eps, arena);
28525                let num = (lp - lm) / (2.0 * eps);
28526                assert!(
28527                    (analytical[i] - num).abs() < 5e-3,
28528                    "{name}[{i}]: analytical {} vs numerical {num}",
28529                    analytical[i]
28530                );
28531            }
28532        };
28533
28534        // Helper to perturb one param and run forward. Kept as a
28535        // reference for the explicit per-param sweep pattern below.
28536        #[allow(unused_macros)]
28537        macro_rules! sweep {
28538            ($name:expr, $base:expr, $analytical:expr, $set_param:ident) => {{
28539                let n = $base.len();
28540                for i in 0..n {
28541                    let mut p = $base.clone();
28542                    let s = p[i];
28543                    p[i] = s + eps;
28544                    let lp = {
28545                        let $set_param = &p;
28546                        run_loss(&mut fwd_arena, &wc, &bc, &wfc, &bfc).max(f32::NEG_INFINITY);
28547                        // Reset others, set the one being swept, run.
28548                        // (the macro receives one of the four params via $set_param)
28549                        let _ = $set_param;
28550                        // Fall through to the explicit per-param helper:
28551                        0.0_f32
28552                    };
28553                    let _ = lp;
28554                }
28555            }};
28556        }
28557        let _ = check_grad; // silence unused (sweep! macro is intentionally\n        // unused — kept as reference for the per-param sweep pattern below)
28558
28559        // Per-param sweeps (explicit, not macro — clearer).
28560        for i in 0..wc.len() {
28561            let mut p = wc.clone();
28562            let s = p[i];
28563            p[i] = s + eps;
28564            let lp = run_loss(&mut fwd_arena, &p, &bc, &wfc, &bfc);
28565            p[i] = s - eps;
28566            let lm = run_loss(&mut fwd_arena, &p, &bc, &wfc, &bfc);
28567            let num = (lp - lm) / (2.0 * eps);
28568            assert!(
28569                (g_wc[i] - num).abs() < 5e-3,
28570                "g_wc[{i}]: {} vs {num}",
28571                g_wc[i]
28572            );
28573        }
28574        for i in 0..bc.len() {
28575            let mut p = bc.clone();
28576            let s = p[i];
28577            p[i] = s + eps;
28578            let lp = run_loss(&mut fwd_arena, &wc, &p, &wfc, &bfc);
28579            p[i] = s - eps;
28580            let lm = run_loss(&mut fwd_arena, &wc, &p, &wfc, &bfc);
28581            let num = (lp - lm) / (2.0 * eps);
28582            assert!(
28583                (g_bc[i] - num).abs() < 5e-3,
28584                "g_bc[{i}]: {} vs {num}",
28585                g_bc[i]
28586            );
28587        }
28588        for i in 0..wfc.len() {
28589            let mut p = wfc.clone();
28590            let s = p[i];
28591            p[i] = s + eps;
28592            let lp = run_loss(&mut fwd_arena, &wc, &bc, &p, &bfc);
28593            p[i] = s - eps;
28594            let lm = run_loss(&mut fwd_arena, &wc, &bc, &p, &bfc);
28595            let num = (lp - lm) / (2.0 * eps);
28596            assert!(
28597                (g_wfc[i] - num).abs() < 5e-3,
28598                "g_wfc[{i}]: {} vs {num}",
28599                g_wfc[i]
28600            );
28601        }
28602        for i in 0..bfc.len() {
28603            let mut p = bfc.clone();
28604            let s = p[i];
28605            p[i] = s + eps;
28606            let lp = run_loss(&mut fwd_arena, &wc, &bc, &wfc, &p);
28607            p[i] = s - eps;
28608            let lm = run_loss(&mut fwd_arena, &wc, &bc, &wfc, &p);
28609            let num = (lp - lm) / (2.0 * eps);
28610            assert!(
28611                (g_bfc[i] - num).abs() < 5e-3,
28612                "g_bfc[{i}]: {} vs {num}",
28613                g_bfc[i]
28614            );
28615        }
28616    }
28617
28618    /// Negative case: a Narrow whose output has multiple consumers
28619    /// must NOT be fused (we can't elide its write — something else
28620    /// reads it).
28621    #[test]
28622    fn narrow_rope_skips_when_narrow_has_multiple_consumers() {
28623        let f = DType::F32;
28624        let mut g = Graph::new("nr_skip");
28625        let qkv = g.input("qkv", Shape::new(&[16, 8, 192], f));
28626        let cos = g.input("cos", Shape::new(&[16], f));
28627        let sin = g.input("sin", Shape::new(&[16], f));
28628        let q = g.narrow_(qkv, 2, 0, 64);
28629        let q_rope = g.rope(q, cos, sin, 16);
28630        // Second consumer of `q` blocks the fusion.
28631        let q_dup = g.activation(rlx_ir::op::Activation::Relu, q, Shape::new(&[16, 8, 64], f));
28632        g.set_outputs(vec![q_rope, q_dup]);
28633
28634        let plan = rlx_opt::memory::plan_memory(&g);
28635        let arena = crate::arena::Arena::from_plan(plan);
28636        let sched = compile_thunks(&g, &arena);
28637
28638        let narrow_count = sched
28639            .thunks
28640            .iter()
28641            .filter(|t| matches!(t, Thunk::Narrow { .. }))
28642            .count();
28643        assert!(
28644            narrow_count >= 1,
28645            "Narrow with multiple consumers must NOT be fused away"
28646        );
28647    }
28648
28649    // ── Op::CustomFn (custom_vjp / custom_jvp) tests ──
28650    //
28651    // Validates: forward execution inlines fwd_body; VJP rule inlines
28652    // vjp_body in place of recursing into fwd_body; JVP rule inlines
28653    // jvp_body. Each test deliberately picks a body whose AD-via-tracing
28654    // would yield a *different* gradient than the override, so we know
28655    // the override actually fired.
28656
28657    /// Forward only: CustomFn wrapping `f(x) = x + c` (c=1 inside body)
28658    /// without override AD bodies. Verifies the body is compiled,
28659    /// constants in the body fill correctly, and the output lands at
28660    /// the outer node's slot.
28661    #[test]
28662    fn custom_fn_forward_inlines_body() {
28663        let s = Shape::new(&[3], DType::F32);
28664
28665        // Body: f(x) = x + 1
28666        let mut body = Graph::new("addone_body");
28667        let x = body.input("x", s.clone());
28668        let one_data: Vec<u8> = (0..3).flat_map(|_| 1.0_f32.to_le_bytes()).collect();
28669        let one = body.add_node(Op::Constant { data: one_data }, vec![], s.clone());
28670        let y = body.binary(BinaryOp::Add, x, one, s.clone());
28671        body.set_outputs(vec![y]);
28672
28673        let mut g = Graph::new("custom_fn_outer");
28674        let xin = g.input("x_in", s.clone());
28675        let cf = g.custom_fn(vec![xin], body, None, None);
28676        g.set_outputs(vec![cf]);
28677
28678        let xs = vec![10.0_f32, 20.0, 30.0];
28679        let (sched, mut arena) = prepare(&g, &[(xin, &xs)]);
28680        execute_thunks(&sched, arena.raw_buf_mut());
28681        let got = read_arena(&arena, cf, 3);
28682        assert_eq!(got, vec![11.0, 21.0, 31.0]);
28683    }
28684
28685    /// Locate an Op::Input or Op::Param by name in a graph.
28686    fn find_named(graph: &Graph, want: &str) -> NodeId {
28687        for n in graph.nodes() {
28688            let name = match &n.op {
28689                Op::Input { name } | Op::Param { name } => Some(name.as_str()),
28690                _ => None,
28691            };
28692            if name == Some(want) {
28693                return n.id;
28694            }
28695        }
28696        panic!("no node named {want:?} in graph");
28697    }
28698
28699    /// VJP override: f(x) = x but vjp_body returns 2 * d_output, so the
28700    /// reported gradient should be 2 — different from the natural 1
28701    /// you'd get by recursing into the identity body.
28702    #[test]
28703    fn custom_fn_vjp_overrides_natural_gradient() {
28704        use rlx_opt::autodiff::grad_with_loss;
28705        let s = Shape::new(&[1], DType::F32);
28706
28707        let mut fwd = Graph::new("id_fwd");
28708        let x = fwd.input("x", s.clone());
28709        fwd.set_outputs(vec![x]);
28710
28711        let mut vjp_g = Graph::new("id_vjp");
28712        let _x_p = vjp_g.input("x", s.clone());
28713        let _y_p = vjp_g.input("primal_output", s.clone());
28714        let dy = vjp_g.input("d_output", s.clone());
28715        let two_data: Vec<u8> = 2.0_f32.to_le_bytes().to_vec();
28716        let two = vjp_g.add_node(Op::Constant { data: two_data }, vec![], s.clone());
28717        let dx = vjp_g.binary(BinaryOp::Mul, dy, two, s.clone());
28718        vjp_g.set_outputs(vec![dx]);
28719
28720        let mut g = Graph::new("outer");
28721        let xp = g.param("x", s.clone());
28722        let cf = g.custom_fn(vec![xp], fwd, Some(vjp_g), None);
28723        g.set_outputs(vec![cf]);
28724
28725        let bwd = grad_with_loss(&g, &[xp]);
28726        assert_eq!(bwd.outputs.len(), 2, "expect [loss, dx]");
28727
28728        let xb = find_named(&bwd, "x");
28729        let dout = find_named(&bwd, "d_output");
28730        let (sched, mut arena) = prepare(&bwd, &[(xb, &[7.0]), (dout, &[1.0])]);
28731        execute_thunks(&sched, arena.raw_buf_mut());
28732        let loss = read_arena(&arena, bwd.outputs[0], 1);
28733        let dx_v = read_arena(&arena, bwd.outputs[1], 1);
28734        assert!((loss[0] - 7.0).abs() < 1e-6, "loss should be 7.0");
28735        assert!(
28736            (dx_v[0] - 2.0).abs() < 1e-6,
28737            "vjp override should yield dx=2.0, got {} (natural autodiff would give 1.0)",
28738            dx_v[0]
28739        );
28740    }
28741
28742    /// VJP override: f(a, b) = a*b with vjp_body returning
28743    /// (b * d_output, a * d_output). Validates routing of multiple
28744    /// primals + d_output through the override; matches the natural
28745    /// autodiff-of-Mul gradient (b, a).
28746    #[test]
28747    fn custom_fn_vjp_two_inputs_matches_mul_autodiff() {
28748        use rlx_opt::autodiff::grad_with_loss;
28749        let s = Shape::new(&[1], DType::F32);
28750
28751        let mut fwd = Graph::new("mul_fwd");
28752        let a_f = fwd.input("a", s.clone());
28753        let b_f = fwd.input("b", s.clone());
28754        let y_f = fwd.binary(BinaryOp::Mul, a_f, b_f, s.clone());
28755        fwd.set_outputs(vec![y_f]);
28756
28757        let mut vjp_g = Graph::new("mul_vjp");
28758        let a_v = vjp_g.input("a", s.clone());
28759        let b_v = vjp_g.input("b", s.clone());
28760        let _y_v = vjp_g.input("primal_output", s.clone());
28761        let dy_v = vjp_g.input("d_output", s.clone());
28762        let da = vjp_g.binary(BinaryOp::Mul, b_v, dy_v, s.clone());
28763        let db = vjp_g.binary(BinaryOp::Mul, a_v, dy_v, s.clone());
28764        vjp_g.set_outputs(vec![da, db]);
28765
28766        let mut g = Graph::new("outer");
28767        let ap = g.param("a", s.clone());
28768        let bp = g.param("b", s.clone());
28769        let cf = g.custom_fn(vec![ap, bp], fwd, Some(vjp_g), None);
28770        g.set_outputs(vec![cf]);
28771
28772        let bwd = grad_with_loss(&g, &[ap, bp]);
28773        assert_eq!(bwd.outputs.len(), 3, "expect [loss, da, db]");
28774
28775        let ab = find_named(&bwd, "a");
28776        let bb = find_named(&bwd, "b");
28777        let dout = find_named(&bwd, "d_output");
28778        let (sched, mut arena) = prepare(&bwd, &[(ab, &[3.0]), (bb, &[5.0]), (dout, &[1.0])]);
28779        execute_thunks(&sched, arena.raw_buf_mut());
28780        let loss = read_arena(&arena, bwd.outputs[0], 1);
28781        let da_v = read_arena(&arena, bwd.outputs[1], 1);
28782        let db_v = read_arena(&arena, bwd.outputs[2], 1);
28783        assert!((loss[0] - 15.0).abs() < 1e-5);
28784        assert!(
28785            (da_v[0] - 5.0).abs() < 1e-5,
28786            "da should be b=5.0, got {}",
28787            da_v[0]
28788        );
28789        assert!(
28790            (db_v[0] - 3.0).abs() < 1e-5,
28791            "db should be a=3.0, got {}",
28792            db_v[0]
28793        );
28794    }
28795
28796    /// JVP override: f(x) = x but jvp_body returns 2 * tangent_0.
28797    /// Forward-mode tangent should be 2x the seed (1.0) → 2.0.
28798    #[test]
28799    fn custom_fn_jvp_overrides_natural_tangent() {
28800        use rlx_opt::autodiff_fwd::jvp;
28801        let s = Shape::new(&[1], DType::F32);
28802
28803        let mut fwd = Graph::new("id_fwd");
28804        let x = fwd.input("x", s.clone());
28805        fwd.set_outputs(vec![x]);
28806
28807        let mut jvp_g = Graph::new("id_jvp");
28808        let _x_p = jvp_g.input("x", s.clone());
28809        let tx = jvp_g.input("tangent_0", s.clone());
28810        let two_data: Vec<u8> = 2.0_f32.to_le_bytes().to_vec();
28811        let two = jvp_g.add_node(Op::Constant { data: two_data }, vec![], s.clone());
28812        let ty = jvp_g.binary(BinaryOp::Mul, tx, two, s.clone());
28813        jvp_g.set_outputs(vec![ty]);
28814
28815        let mut g = Graph::new("outer");
28816        let xin = g.input("x_in", s.clone());
28817        let cf = g.custom_fn(vec![xin], fwd, None, Some(jvp_g));
28818        g.set_outputs(vec![cf]);
28819
28820        let fwd_g = jvp(&g, &[xin]);
28821        assert_eq!(fwd_g.outputs.len(), 2, "expect [primal_y, tangent_y]");
28822
28823        let xb = find_named(&fwd_g, "x_in");
28824        let tan = find_named(&fwd_g, "tangent_x_in");
28825        let (sched, mut arena) = prepare(&fwd_g, &[(xb, &[7.0]), (tan, &[1.0])]);
28826        execute_thunks(&sched, arena.raw_buf_mut());
28827        let y = read_arena(&arena, fwd_g.outputs[0], 1);
28828        let ty_v = read_arena(&arena, fwd_g.outputs[1], 1);
28829        assert!((y[0] - 7.0).abs() < 1e-6);
28830        assert!(
28831            (ty_v[0] - 2.0).abs() < 1e-6,
28832            "jvp override should yield t_y=2.0 (natural autodiff would give 1.0), got {}",
28833            ty_v[0]
28834        );
28835    }
28836
28837    /// IR-level basic test: `DType::C64` is wired through the dtype
28838    /// table — `size_bytes() == 8`, `is_complex()` reports true, and
28839    /// a `[2]`-shaped C64 buffer in the arena occupies the expected
28840    /// 16 bytes.
28841    #[test]
28842    fn c64_dtype_storage_layout() {
28843        assert_eq!(
28844            DType::C64.size_bytes(),
28845            8,
28846            "C64 should be 8 bytes (f32 real + f32 imag)"
28847        );
28848        assert!(DType::C64.is_complex());
28849        assert!(!DType::C64.is_float());
28850
28851        // A length-2 C64 buffer should have shape size_bytes = 16.
28852        let s = Shape::new(&[2], DType::C64);
28853        assert_eq!(s.size_bytes().unwrap(), 16);
28854    }
28855
28856    // ── C64 element-wise binary kernel witnesses (2026-05-17) ──────
28857    //
28858    // Build a tiny graph: Input `a` + Input `b` (both C64 [2]),
28859    // output = a OP b. Run through CompileResult and compare against
28860    // the closed-form complex arithmetic on the four chosen pairs.
28861
28862    fn run_c64_binary(op: BinaryOp, a: &[(f32, f32)], b: &[(f32, f32)]) -> Vec<(f32, f32)> {
28863        let n = a.len();
28864        let s = Shape::new(&[n], DType::C64);
28865        let mut g = Graph::new("c64_bin");
28866        let in_a = g.input("a", s.clone());
28867        let in_b = g.input("b", s.clone());
28868        let out = g.binary(op, in_a, in_b, s.clone());
28869        g.set_outputs(vec![out]);
28870
28871        let plan = rlx_opt::memory::plan_memory(&g);
28872        let mut arena = crate::arena::Arena::from_plan(plan);
28873        let sched = compile_thunks(&g, &arena);
28874
28875        let a_off = arena.byte_offset(in_a);
28876        let b_off = arena.byte_offset(in_b);
28877        let out_off = arena.byte_offset(out);
28878        // Interleave [re_0, im_0, re_1, im_1, ...] in the f32 buffer.
28879        let buf = arena.raw_buf_mut();
28880        unsafe {
28881            let pa = buf.as_mut_ptr().add(a_off) as *mut f32;
28882            let pb = buf.as_mut_ptr().add(b_off) as *mut f32;
28883            for (i, &(re, im)) in a.iter().enumerate() {
28884                *pa.add(2 * i) = re;
28885                *pa.add(2 * i + 1) = im;
28886            }
28887            for (i, &(re, im)) in b.iter().enumerate() {
28888                *pb.add(2 * i) = re;
28889                *pb.add(2 * i + 1) = im;
28890            }
28891        }
28892        execute_thunks(&sched, arena.raw_buf_mut());
28893        let raw_out: Vec<f32> = unsafe {
28894            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
28895            (0..(2 * n)).map(|i| *p.add(i)).collect()
28896        };
28897        (0..n)
28898            .map(|i| (raw_out[2 * i], raw_out[2 * i + 1]))
28899            .collect()
28900    }
28901
28902    #[track_caller]
28903    fn assert_close_c(got: (f32, f32), expected: (f32, f32), tol: f32, label: &str) {
28904        let dr = (got.0 - expected.0).abs();
28905        let di = (got.1 - expected.1).abs();
28906        assert!(
28907            dr < tol && di < tol,
28908            "[{label}] got ({:+.4}, {:+.4}), expected ({:+.4}, {:+.4})",
28909            got.0,
28910            got.1,
28911            expected.0,
28912            expected.1
28913        );
28914    }
28915
28916    #[test]
28917    fn c64_binary_add_matches_complex_arithmetic() {
28918        let a = [(1.0_f32, 2.0_f32), (3.0_f32, -1.0_f32)];
28919        let b = [(4.0_f32, -1.0_f32), (0.5_f32, 0.5_f32)];
28920        let out = run_c64_binary(BinaryOp::Add, &a, &b);
28921        assert_close_c(out[0], (5.0, 1.0), 1e-6, "add[0]");
28922        assert_close_c(out[1], (3.5, -0.5), 1e-6, "add[1]");
28923    }
28924
28925    #[test]
28926    fn c64_binary_sub_matches_complex_arithmetic() {
28927        let a = [(5.0_f32, 1.0_f32)];
28928        let b = [(2.0_f32, 3.0_f32)];
28929        let out = run_c64_binary(BinaryOp::Sub, &a, &b);
28930        assert_close_c(out[0], (3.0, -2.0), 1e-6, "sub");
28931    }
28932
28933    #[test]
28934    fn c64_binary_mul_matches_complex_arithmetic() {
28935        // (1 + 2i)(3 + 4i) = 3 + 4i + 6i + 8i² = -5 + 10i.
28936        let a = [(1.0_f32, 2.0_f32)];
28937        let b = [(3.0_f32, 4.0_f32)];
28938        let out = run_c64_binary(BinaryOp::Mul, &a, &b);
28939        assert_close_c(out[0], (-5.0, 10.0), 1e-5, "mul");
28940    }
28941
28942    #[test]
28943    fn c64_binary_div_matches_complex_arithmetic() {
28944        // (1 + 2i) / (3 + 4i) = ((1·3 + 2·4) + (2·3 − 1·4)i) / 25
28945        //                     = (11 + 2i) / 25
28946        //                     = 0.44 + 0.08i
28947        let a = [(1.0_f32, 2.0_f32)];
28948        let b = [(3.0_f32, 4.0_f32)];
28949        let out = run_c64_binary(BinaryOp::Div, &a, &b);
28950        assert_close_c(out[0], (0.44, 0.08), 1e-5, "div");
28951    }
28952
28953    #[test]
28954    fn c64_binary_mul_identity_one_is_no_op() {
28955        // (a + bi) · (1 + 0i) = a + bi.
28956        let a = [(3.5_f32, -1.25_f32), (-2.0_f32, 7.0_f32)];
28957        let b = [(1.0_f32, 0.0_f32), (1.0_f32, 0.0_f32)];
28958        let out = run_c64_binary(BinaryOp::Mul, &a, &b);
28959        assert_close_c(out[0], a[0], 1e-6, "mul·1[0]");
28960        assert_close_c(out[1], a[1], 1e-6, "mul·1[1]");
28961    }
28962
28963    #[test]
28964    fn c64_binary_mul_by_i_rotates_90_degrees() {
28965        // (a + bi) · i = (a + bi)(0 + i) = -b + ai. 90° CCW rotation.
28966        let a = [(1.0_f32, 0.0_f32)];
28967        let b = [(0.0_f32, 1.0_f32)];
28968        let out = run_c64_binary(BinaryOp::Mul, &a, &b);
28969        assert_close_c(out[0], (0.0, 1.0), 1e-6, "1·i");
28970    }
28971
28972    #[test]
28973    fn c64_binary_div_by_self_gives_unity() {
28974        let a = [(2.5_f32, -1.5_f32), (-0.7_f32, 4.2_f32)];
28975        let out = run_c64_binary(BinaryOp::Div, &a, &a);
28976        assert_close_c(out[0], (1.0, 0.0), 1e-5, "div_self[0]");
28977        assert_close_c(out[1], (1.0, 0.0), 1e-5, "div_self[1]");
28978    }
28979
28980    #[test]
28981    #[should_panic(expected = "C64: complex max/min/pow")]
28982    fn c64_binary_max_is_rejected_at_lowering() {
28983        run_c64_binary(BinaryOp::Max, &[(1.0_f32, 2.0_f32)], &[(3.0_f32, 4.0_f32)]);
28984    }
28985
28986    fn run_c64_activation(act: Activation, a: &[(f32, f32)]) -> Vec<(f32, f32)> {
28987        let n = a.len();
28988        let s = Shape::new(&[n], DType::C64);
28989        let mut g = Graph::new("c64_act");
28990        let in_a = g.input("a", s.clone());
28991        let out = g.activation(act, in_a, s.clone());
28992        g.set_outputs(vec![out]);
28993        let plan = rlx_opt::memory::plan_memory(&g);
28994        let mut arena = crate::arena::Arena::from_plan(plan);
28995        let sched = compile_thunks(&g, &arena);
28996        let a_off = arena.byte_offset(in_a);
28997        let out_off = arena.byte_offset(out);
28998        let buf = arena.raw_buf_mut();
28999        unsafe {
29000            let pa = buf.as_mut_ptr().add(a_off) as *mut f32;
29001            for (i, &(re, im)) in a.iter().enumerate() {
29002                *pa.add(2 * i) = re;
29003                *pa.add(2 * i + 1) = im;
29004            }
29005        }
29006        execute_thunks(&sched, arena.raw_buf_mut());
29007        let raw: Vec<f32> = unsafe {
29008            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
29009            (0..(2 * n)).map(|i| *p.add(i)).collect()
29010        };
29011        (0..n).map(|i| (raw[2 * i], raw[2 * i + 1])).collect()
29012    }
29013
29014    #[test]
29015    fn c64_activation_neg_negates_both_components() {
29016        let inp = [(3.5_f32, -1.25_f32), (-2.0_f32, 0.0_f32)];
29017        let out = run_c64_activation(Activation::Neg, &inp);
29018        assert_close_c(out[0], (-3.5, 1.25), 1e-6, "neg[0]");
29019        assert_close_c(out[1], (2.0, 0.0), 1e-6, "neg[1]");
29020    }
29021
29022    #[test]
29023    fn c64_activation_exp_matches_euler() {
29024        // exp(0 + i·π) = -1 + 0i.
29025        // exp(1 + 0i) = e ≈ 2.71828.
29026        let inp = [(0.0_f32, std::f32::consts::PI), (1.0_f32, 0.0_f32)];
29027        let out = run_c64_activation(Activation::Exp, &inp);
29028        assert_close_c(out[0], (-1.0, 0.0), 1e-5, "exp(iπ)");
29029        assert_close_c(out[1], (std::f32::consts::E, 0.0), 1e-5, "exp(1)");
29030    }
29031
29032    #[test]
29033    fn c64_activation_log_matches_principal_branch() {
29034        // log(1 + 0i) = 0.
29035        // log(0 + i) = log(1) + i·π/2 = 0 + i·π/2.
29036        // log(-1 + 0i) = 0 + i·π.
29037        let inp = [(1.0_f32, 0.0_f32), (0.0_f32, 1.0_f32), (-1.0_f32, 0.0_f32)];
29038        let out = run_c64_activation(Activation::Log, &inp);
29039        assert_close_c(out[0], (0.0, 0.0), 1e-5, "log(1)");
29040        assert_close_c(out[1], (0.0, std::f32::consts::FRAC_PI_2), 1e-5, "log(i)");
29041        assert_close_c(out[2], (0.0, std::f32::consts::PI), 1e-5, "log(-1)");
29042    }
29043
29044    #[test]
29045    fn c64_activation_sqrt_squared_recovers_input() {
29046        // For positive-real-part inputs, sqrt(z)² should equal z exactly
29047        // to f32 noise.
29048        let inp = [(4.0_f32, 0.0_f32), (3.0_f32, 4.0_f32)];
29049        let roots = run_c64_activation(Activation::Sqrt, &inp);
29050        // sqrt(4) = 2 + 0i; sqrt(3+4i) = 2 + i (since (2+i)² = 4+4i-1 = 3+4i).
29051        assert_close_c(roots[0], (2.0, 0.0), 1e-5, "sqrt(4)");
29052        assert_close_c(roots[1], (2.0, 1.0), 1e-5, "sqrt(3+4i)");
29053    }
29054
29055    #[test]
29056    #[should_panic(expected = "no natural complex extension")]
29057    fn c64_activation_relu_is_rejected_at_lowering() {
29058        run_c64_activation(Activation::Relu, &[(1.0_f32, 2.0_f32)]);
29059    }
29060
29061    // ── ComplexNormSq + Wirtinger backward witnesses ───────────────
29062
29063    /// Forward `|z|²`: returns `[n]` f32.
29064    fn run_complex_norm_sq(z: &[(f32, f32)]) -> Vec<f32> {
29065        let n = z.len();
29066        let mut g = Graph::new("cns_fwd");
29067        let in_z = g.input("z", Shape::new(&[n], DType::C64));
29068        let out = g.complex_norm_sq(in_z);
29069        g.set_outputs(vec![out]);
29070        let plan = rlx_opt::memory::plan_memory(&g);
29071        let mut arena = crate::arena::Arena::from_plan(plan);
29072        let sched = compile_thunks(&g, &arena);
29073        let z_off = arena.byte_offset(in_z);
29074        let out_off = arena.byte_offset(out);
29075        let buf = arena.raw_buf_mut();
29076        unsafe {
29077            let pz = buf.as_mut_ptr().add(z_off) as *mut f32;
29078            for (i, &(re, im)) in z.iter().enumerate() {
29079                *pz.add(2 * i) = re;
29080                *pz.add(2 * i + 1) = im;
29081            }
29082        }
29083        execute_thunks(&sched, arena.raw_buf_mut());
29084        unsafe {
29085            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
29086            (0..n).map(|i| *p.add(i)).collect()
29087        }
29088    }
29089
29090    /// Backward: given z and upstream g, return dz = g·z element-wise (C64).
29091    fn run_complex_norm_sq_bwd(z: &[(f32, f32)], g: &[f32]) -> Vec<(f32, f32)> {
29092        let n = z.len();
29093        let mut gr = Graph::new("cns_bwd");
29094        let in_z = gr.input("z", Shape::new(&[n], DType::C64));
29095        let in_g = gr.input("g", Shape::new(&[n], DType::F32));
29096        let out = gr.complex_norm_sq_backward(in_z, in_g);
29097        gr.set_outputs(vec![out]);
29098        let plan = rlx_opt::memory::plan_memory(&gr);
29099        let mut arena = crate::arena::Arena::from_plan(plan);
29100        let sched = compile_thunks(&gr, &arena);
29101        let z_off = arena.byte_offset(in_z);
29102        let g_off = arena.byte_offset(in_g);
29103        let out_off = arena.byte_offset(out);
29104        let buf = arena.raw_buf_mut();
29105        unsafe {
29106            let pz = buf.as_mut_ptr().add(z_off) as *mut f32;
29107            let pg = buf.as_mut_ptr().add(g_off) as *mut f32;
29108            for (i, &(re, im)) in z.iter().enumerate() {
29109                *pz.add(2 * i) = re;
29110                *pz.add(2 * i + 1) = im;
29111            }
29112            for (i, &v) in g.iter().enumerate() {
29113                *pg.add(i) = v;
29114            }
29115        }
29116        execute_thunks(&sched, arena.raw_buf_mut());
29117        unsafe {
29118            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
29119            (0..n).map(|i| (*p.add(2 * i), *p.add(2 * i + 1))).collect()
29120        }
29121    }
29122
29123    #[test]
29124    fn complex_norm_sq_matches_textbook() {
29125        // |3 + 4i|² = 9 + 16 = 25.
29126        // |1 + 0i|² = 1.
29127        // |0 + 0i|² = 0.
29128        let z = [(3.0_f32, 4.0_f32), (1.0_f32, 0.0_f32), (0.0_f32, 0.0_f32)];
29129        let out = run_complex_norm_sq(&z);
29130        assert!((out[0] - 25.0).abs() < 1e-5);
29131        assert!((out[1] - 1.0).abs() < 1e-6);
29132        assert!(out[2].abs() < 1e-6);
29133    }
29134
29135    #[test]
29136    fn complex_norm_sq_backward_matches_wirtinger_formula() {
29137        // Wirtinger: ∂|z|²/∂z̄ = z. With upstream g = 1, dz = z.
29138        let z = [(3.0_f32, 4.0_f32), (1.5_f32, -2.5_f32)];
29139        let g = [1.0_f32, 1.0_f32];
29140        let dz = run_complex_norm_sq_bwd(&z, &g);
29141        assert_close_c(dz[0], z[0], 1e-6, "dz[0] = g·z[0]");
29142        assert_close_c(dz[1], z[1], 1e-6, "dz[1] = g·z[1]");
29143    }
29144
29145    #[test]
29146    fn complex_norm_sq_backward_scales_with_upstream() {
29147        // With upstream g[i] ≠ 1: dz[i] = g[i]·z[i].
29148        let z = [(2.0_f32, 1.0_f32), (-1.0_f32, 3.0_f32)];
29149        let g = [0.5_f32, -2.0_f32];
29150        let dz = run_complex_norm_sq_bwd(&z, &g);
29151        assert_close_c(dz[0], (1.0, 0.5), 1e-6, "g=0.5 · (2,1)");
29152        assert_close_c(dz[1], (2.0, -6.0), 1e-6, "g=-2 · (-1,3)");
29153    }
29154
29155    /// Multi-output Op::CustomFn via the concat-with-Narrow design
29156    /// (rlx-ir::Graph::custom_fn_multi). Build a custom_fn whose
29157    /// fwd_body returns two outputs (x², 2x), then materialize each
29158    /// via the MultiOutputHandle and verify both numerically.
29159    #[test]
29160    fn custom_fn_multi_extracts_each_subgraph_output() {
29161        use rlx_ir::ops::special::MultiOutputHandle;
29162
29163        let _ = MultiOutputHandle {
29164            source: NodeId(0),
29165            sub_shapes: vec![],
29166            offsets: vec![],
29167        }; // import sanity
29168
29169        // Inner body: input x [3] f32, outputs (x², 2x) both [3] f32.
29170        let mut body = Graph::new("multi_body");
29171        let s3 = Shape::new(&[3], DType::F32);
29172        let x = body.input("x", s3.clone());
29173        let x_sq = body.binary(BinaryOp::Mul, x, x, s3.clone());
29174        let two = body.add_node(
29175            Op::Constant {
29176                data: vec![
29177                    2.0_f32.to_le_bytes(),
29178                    2.0_f32.to_le_bytes(),
29179                    2.0_f32.to_le_bytes(),
29180                ]
29181                .into_iter()
29182                .flatten()
29183                .collect(),
29184            },
29185            vec![],
29186            s3.clone(),
29187        );
29188        let two_x = body.binary(BinaryOp::Mul, two, x, s3.clone());
29189        body.set_outputs(vec![x_sq, two_x]);
29190
29191        // Outer graph: feed in_x → custom_fn_multi → handle.output(0/1).
29192        let mut outer = Graph::new("multi_outer");
29193        let in_x = outer.input("xin", s3.clone());
29194        let handle = outer.custom_fn_multi(vec![in_x], body);
29195        assert_eq!(handle.n_outputs(), 2);
29196        let out0 = handle.output(&mut outer, 0); // x²
29197        let out1 = handle.output(&mut outer, 1); // 2x
29198        outer.set_outputs(vec![out0, out1]);
29199
29200        let plan = rlx_opt::memory::plan_memory(&outer);
29201        let mut arena = crate::arena::Arena::from_plan(plan);
29202        let sched = compile_thunks(&outer, &arena);
29203        let xin_off = arena.byte_offset(in_x);
29204        let out0_off = arena.byte_offset(out0);
29205        let out1_off = arena.byte_offset(out1);
29206        let xs = [1.0_f32, 2.0, 3.0];
29207        unsafe {
29208            let p = arena.raw_buf_mut().as_mut_ptr().add(xin_off) as *mut f32;
29209            for (i, &v) in xs.iter().enumerate() {
29210                *p.add(i) = v;
29211            }
29212        }
29213        execute_thunks(&sched, arena.raw_buf_mut());
29214        let out0_v: Vec<f32> = unsafe {
29215            let p = arena.raw_buf().as_ptr().add(out0_off) as *const f32;
29216            (0..3).map(|i| *p.add(i)).collect()
29217        };
29218        let out1_v: Vec<f32> = unsafe {
29219            let p = arena.raw_buf().as_ptr().add(out1_off) as *const f32;
29220            (0..3).map(|i| *p.add(i)).collect()
29221        };
29222        // x² = [1, 4, 9]; 2x = [2, 4, 6].
29223        for i in 0..3 {
29224            assert!(
29225                (out0_v[i] - xs[i] * xs[i]).abs() < 1e-5,
29226                "out0[{i}] = {} != x² = {}",
29227                out0_v[i],
29228                xs[i] * xs[i]
29229            );
29230            assert!(
29231                (out1_v[i] - 2.0 * xs[i]).abs() < 1e-5,
29232                "out1[{i}] = {} != 2x = {}",
29233                out1_v[i],
29234                2.0 * xs[i]
29235            );
29236        }
29237    }
29238
29239    #[test]
29240    fn complex_norm_sq_gradient_matches_finite_difference() {
29241        // Numerical sanity: perturb z[0].re by ε, observe Δ|z|² ≈ 2·re·ε.
29242        let z = [(3.0_f32, 4.0_f32)];
29243        let eps = 1e-3_f32;
29244        let v0 = run_complex_norm_sq(&z)[0];
29245        let z_pert = [(3.0_f32 + eps, 4.0_f32)];
29246        let v1 = run_complex_norm_sq(&z_pert)[0];
29247        let fd_re = (v1 - v0) / eps;
29248        let analytic_re = 2.0 * z[0].0;
29249        assert!((fd_re - analytic_re).abs() < 1e-2);
29250
29251        // ∂/∂im at z = (3, 4) is 2·im = 8.
29252        let z_pert_im = [(3.0_f32, 4.0_f32 + eps)];
29253        let v2 = run_complex_norm_sq(&z_pert_im)[0];
29254        let fd_im = (v2 - v0) / eps;
29255        let analytic_im = 2.0 * z[0].1;
29256        assert!((fd_im - analytic_im).abs() < 1e-2);
29257
29258        // Compare with the Wirtinger backward at upstream g = 1.
29259        // Wirtinger ∂/∂z̄ = z gives dz = (re, im). The "real
29260        // gradient" wrt (re, im) is 2·(re, im), i.e. 2·dz = (2·re,
29261        // 2·im) — that's the factor 2 difference between Wirtinger
29262        // ∂/∂z̄ and the real-vector gradient on (re, im).
29263        let dz = run_complex_norm_sq_bwd(&z, &[1.0_f32]);
29264        assert!((2.0 * dz[0].0 - analytic_re).abs() < 1e-5);
29265        assert!((2.0 * dz[0].1 - analytic_im).abs() < 1e-5);
29266    }
29267
29268    /// Direct regression test for the 5-D mid-shape singleton broadcast
29269    /// (SAM rel_pos pattern: `[bh, h, w, 1, w] + [bh, h, w, h, w]`).
29270    /// The SAM port worked around this by `concat`-tiling the rhs; this
29271    /// test verifies the in-graph broadcast path is bit-correct.
29272    #[test]
29273    fn binary_full_5d_mid_singleton_broadcast() {
29274        let bh = 2usize;
29275        let h = 3;
29276        let w = 4;
29277        let f = DType::F32;
29278
29279        let mut g = Graph::new("bcast_5d");
29280        let lhs = g.input("lhs", Shape::new(&[bh, h, w, h, w], f));
29281        // rhs shape with size-1 at axis 3 (mid-shape singleton).
29282        let rhs = g.input("rhs", Shape::new(&[bh, h, w, 1, w], f));
29283        let out = g.binary(BinaryOp::Add, lhs, rhs, Shape::new(&[bh, h, w, h, w], f));
29284        g.set_outputs(vec![out]);
29285
29286        // Deterministic data.
29287        let lhs_data: Vec<f32> = (0..bh * h * w * h * w).map(|i| i as f32 * 0.01).collect();
29288        let rhs_data: Vec<f32> = (0..bh * h * w * w)
29289            .map(|i| (i as f32 + 100.0) * 0.01)
29290            .collect();
29291
29292        // Compute expected output by hand.
29293        let mut expected = vec![0f32; bh * h * w * h * w];
29294        for b_ in 0..bh {
29295            for hq in 0..h {
29296                for wq in 0..w {
29297                    for hk in 0..h {
29298                        for wk in 0..w {
29299                            let li = (((b_ * h + hq) * w + wq) * h + hk) * w + wk;
29300                            // rhs has hk dim = 1, so it's always index 0 there.
29301                            let ri = ((b_ * h + hq) * w + wq) * w + wk;
29302                            expected[li] = lhs_data[li] + rhs_data[ri];
29303                        }
29304                    }
29305                }
29306            }
29307        }
29308
29309        let plan = rlx_opt::memory::plan_memory(&g);
29310        let mut arena = crate::arena::Arena::from_plan(plan);
29311        let sched = compile_thunks(&g, &arena);
29312        let lhs_off = arena.byte_offset(lhs);
29313        let rhs_off = arena.byte_offset(rhs);
29314        let out_off = arena.byte_offset(out);
29315        let buf = arena.raw_buf_mut();
29316        unsafe {
29317            let p = buf.as_mut_ptr().add(lhs_off) as *mut f32;
29318            for (i, &v) in lhs_data.iter().enumerate() {
29319                *p.add(i) = v;
29320            }
29321            let p = buf.as_mut_ptr().add(rhs_off) as *mut f32;
29322            for (i, &v) in rhs_data.iter().enumerate() {
29323                *p.add(i) = v;
29324            }
29325        }
29326        execute_thunks(&sched, arena.raw_buf_mut());
29327        let actual: Vec<f32> = unsafe {
29328            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
29329            (0..bh * h * w * h * w).map(|i| *p.add(i)).collect()
29330        };
29331
29332        // Bit-exact check.
29333        let mut max_diff = 0f32;
29334        let mut max_idx = 0;
29335        for i in 0..actual.len() {
29336            let d = (actual[i] - expected[i]).abs();
29337            if d > max_diff {
29338                max_diff = d;
29339                max_idx = i;
29340            }
29341        }
29342        assert!(
29343            max_diff < 1e-6,
29344            "5D mid-shape singleton broadcast wrong: max |Δ| = {max_diff} at idx {max_idx} \
29345             (actual={}, expected={})",
29346            actual[max_idx],
29347            expected[max_idx]
29348        );
29349    }
29350
29351    #[test]
29352    fn layer_norm2d_and_conv_transpose2d_kernels() {
29353        let mut out = vec![0f32; 8];
29354        crate::kernels::layer_norm2d_nchw(
29355            &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
29356            &[1.0, 1.0],
29357            &[0.0, 0.0],
29358            &mut out,
29359            1,
29360            2,
29361            2,
29362            2,
29363            1e-5,
29364        );
29365        let mean0: f32 = (1.0 + 3.0) / 2.0;
29366        assert!((out[0] - mean0).abs() > 0.1);
29367
29368        let mut up = vec![0f32; 4];
29369        crate::kernels::conv_transpose2d_nchw(
29370            &[2.0],
29371            &[1.0, 0.0, 0.0, 1.0],
29372            &mut up,
29373            1,
29374            1,
29375            1,
29376            1,
29377            1,
29378            2,
29379            2,
29380            2,
29381            2,
29382            2,
29383            2,
29384            0,
29385            0,
29386            1,
29387            1,
29388            1,
29389        );
29390        assert!((up[0] - 2.0).abs() < 1e-5);
29391        assert!((up[3] - 2.0).abs() < 1e-5);
29392    }
29393
29394    /// End-to-end native-low-precision GEMM oracle: build the full
29395    /// ScaledQuantScale → ScaledQuantize → ScaledMatMul pipeline through the
29396    /// thunk path and check the f32-accumulated result tracks a plain f32 TN
29397    /// matmul (cosine), for every format × scale-layout. This exercises shape
29398    /// inference, memory planning, the build arms, and both execute paths.
29399    #[test]
29400    fn scaled_matmul_oracle_matches_f32() {
29401        use rlx_ir::ScaledFormat::*;
29402        use rlx_ir::{ScaleLayout, ScaledFormat};
29403
29404        fn cosine(a: &[f32], b: &[f32]) -> f32 {
29405            let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
29406            let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
29407            let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
29408            dot / (na * nb)
29409        }
29410
29411        #[allow(clippy::too_many_arguments)]
29412        fn run_scaled(
29413            lhs: &[f32],
29414            rhs: &[f32],
29415            m: usize,
29416            k: usize,
29417            n: usize,
29418            lf: ScaledFormat,
29419            rf: ScaledFormat,
29420            layout: ScaleLayout,
29421        ) -> Vec<f32> {
29422            let f = DType::F32;
29423            let u8t = DType::U8;
29424            let mut g = Graph::new("scaled");
29425            let lhs_in = g.input("lhs", Shape::new(&[m, k], f));
29426            let rhs_in = g.input("rhs", Shape::new(&[n, k], f));
29427            let (ls_shape, rs_shape) = match layout {
29428                ScaleLayout::PerTensor => (Shape::new(&[1], f), Shape::new(&[1], f)),
29429                _ => {
29430                    let nb = k.div_ceil(layout.block() as usize);
29431                    (Shape::new(&[m, nb], u8t), Shape::new(&[n, nb], u8t))
29432                }
29433            };
29434            let ls = g.add_node(
29435                Op::ScaledQuantScale {
29436                    format: lf,
29437                    scale_layout: layout,
29438                },
29439                vec![lhs_in],
29440                ls_shape,
29441            );
29442            let lq = g.add_node(
29443                Op::ScaledQuantize {
29444                    format: lf,
29445                    scale_layout: layout,
29446                },
29447                vec![lhs_in, ls],
29448                Shape::new(&[m, k], u8t),
29449            );
29450            let rs = g.add_node(
29451                Op::ScaledQuantScale {
29452                    format: rf,
29453                    scale_layout: layout,
29454                },
29455                vec![rhs_in],
29456                rs_shape,
29457            );
29458            let rq = g.add_node(
29459                Op::ScaledQuantize {
29460                    format: rf,
29461                    scale_layout: layout,
29462                },
29463                vec![rhs_in, rs],
29464                Shape::new(&[n, k], u8t),
29465            );
29466            let out = g.add_node(
29467                Op::ScaledMatMul {
29468                    lhs_format: lf,
29469                    rhs_format: rf,
29470                    scale_layout: layout,
29471                    has_bias: false,
29472                },
29473                vec![lq, rq, ls, rs],
29474                Shape::new(&[m, n], f),
29475            );
29476            g.set_outputs(vec![out]);
29477
29478            let plan = rlx_opt::memory::plan_memory(&g);
29479            let mut arena = crate::arena::Arena::from_plan(plan);
29480            let sched = compile_thunks(&g, &arena);
29481            let lhs_off = arena.byte_offset(lhs_in);
29482            let rhs_off = arena.byte_offset(rhs_in);
29483            let out_off = arena.byte_offset(out);
29484            let buf = arena.raw_buf_mut();
29485            unsafe {
29486                let lp = buf.as_mut_ptr().add(lhs_off) as *mut f32;
29487                for (i, &v) in lhs.iter().enumerate() {
29488                    *lp.add(i) = v;
29489                }
29490                let rp = buf.as_mut_ptr().add(rhs_off) as *mut f32;
29491                for (i, &v) in rhs.iter().enumerate() {
29492                    *rp.add(i) = v;
29493                }
29494            }
29495            execute_thunks(&sched, arena.raw_buf_mut());
29496            unsafe {
29497                let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
29498                (0..m * n).map(|i| *p.add(i)).collect()
29499            }
29500        }
29501
29502        let (m, k, n) = (4usize, 64usize, 8usize);
29503        let lhs: Vec<f32> = (0..m * k).map(|i| (i as f32 * 0.13).sin() * 1.5).collect();
29504        let rhs: Vec<f32> = (0..n * k).map(|i| (i as f32 * 0.07).cos() * 1.2).collect();
29505        let mut reference = vec![0f32; m * n];
29506        for i in 0..m {
29507            for j in 0..n {
29508                let mut acc = 0f32;
29509                for p in 0..k {
29510                    acc += lhs[i * k + p] * rhs[j * k + p];
29511                }
29512                reference[i * n + j] = acc;
29513            }
29514        }
29515
29516        // Per-tensor scaling across all 7 element formats.
29517        let cases = [
29518            (F8E4M3, 0.999f32),
29519            (F8E5M2, 0.99),
29520            (F8E4M3Fnuz, 0.999),
29521            (F8E5M2Fnuz, 0.99),
29522            (F6E2M3, 0.99),
29523            (F6E3M2, 0.98),
29524            (F4E2M1, 0.90),
29525        ];
29526        for (fmt, thresh) in cases {
29527            let out = run_scaled(&lhs, &rhs, m, k, n, fmt, fmt, ScaleLayout::PerTensor);
29528            let c = cosine(&out, &reference);
29529            assert!(c >= thresh, "{fmt} per-tensor cosine {c} < {thresh}");
29530        }
29531
29532        // Block layouts: MX E8M0 (FP8) and NVFP4 (FP4) — finer scaling, higher fidelity.
29533        let out_mx = run_scaled(&lhs, &rhs, m, k, n, F8E4M3, F8E4M3, ScaleLayout::mx());
29534        let c_mx = cosine(&out_mx, &reference);
29535        assert!(c_mx >= 0.999, "mx-e8m0 e4m3 cosine {c_mx}");
29536        let out_nv = run_scaled(&lhs, &rhs, m, k, n, F4E2M1, F4E2M1, ScaleLayout::nvfp4());
29537        let c_nv = cosine(&out_nv, &reference);
29538        assert!(c_nv >= 0.95, "nvfp4 e2m1 cosine {c_nv}");
29539    }
29540
29541    /// `Op::ScaledDequantize` (`decode(code)·scale`) must invert
29542    /// `Op::ScaledQuantize`: a quantize→dequantize round-trip reconstructs the
29543    /// original f32 within the format's resolution. Exercises the standalone
29544    /// dequantizer the `ScaledMatMul` backward graph relies on.
29545    #[test]
29546    fn scaled_dequantize_inverts_quantize() {
29547        use rlx_ir::ScaledFormat::*;
29548        use rlx_ir::{ScaleLayout, ScaledFormat};
29549
29550        fn cosine(a: &[f32], b: &[f32]) -> f32 {
29551            let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
29552            let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
29553            let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
29554            dot / (na * nb)
29555        }
29556
29557        fn roundtrip(x: &[f32], rows: usize, cols: usize, fmt: ScaledFormat) -> Vec<f32> {
29558            let f = DType::F32;
29559            let u8t = DType::U8;
29560            let layout = ScaleLayout::PerTensor;
29561            let mut g = Graph::new("dequant_rt");
29562            let x_in = g.input("x", Shape::new(&[rows, cols], f));
29563            let scale = g.add_node(
29564                Op::ScaledQuantScale {
29565                    format: fmt,
29566                    scale_layout: layout,
29567                },
29568                vec![x_in],
29569                Shape::new(&[1], f),
29570            );
29571            let codes = g.add_node(
29572                Op::ScaledQuantize {
29573                    format: fmt,
29574                    scale_layout: layout,
29575                },
29576                vec![x_in, scale],
29577                Shape::new(&[rows, cols], u8t),
29578            );
29579            let recon = g.add_node(
29580                Op::ScaledDequantize {
29581                    format: fmt,
29582                    scale_layout: layout,
29583                },
29584                vec![codes, scale],
29585                Shape::new(&[rows, cols], f),
29586            );
29587            g.set_outputs(vec![recon]);
29588
29589            let plan = rlx_opt::memory::plan_memory(&g);
29590            let mut arena = crate::arena::Arena::from_plan(plan);
29591            let sched = compile_thunks(&g, &arena);
29592            let x_off = arena.byte_offset(x_in);
29593            let r_off = arena.byte_offset(recon);
29594            unsafe {
29595                let p = arena.raw_buf_mut().as_mut_ptr().add(x_off) as *mut f32;
29596                for (i, &v) in x.iter().enumerate() {
29597                    *p.add(i) = v;
29598                }
29599            }
29600            execute_thunks(&sched, arena.raw_buf_mut());
29601            unsafe {
29602                let p = arena.raw_buf().as_ptr().add(r_off) as *const f32;
29603                (0..rows * cols).map(|i| *p.add(i)).collect()
29604            }
29605        }
29606
29607        let (rows, cols) = (4usize, 16usize);
29608        let x: Vec<f32> = (0..rows * cols)
29609            .map(|i| (i as f32 * 0.21).sin() * 1.7)
29610            .collect();
29611        // FP8 reconstructs tightly; FP4 is coarse but still strongly correlated.
29612        for (fmt, min_cos) in [(F8E4M3, 0.999f32), (F8E5M2, 0.99), (F4E2M1, 0.93)] {
29613            let recon = roundtrip(&x, rows, cols, fmt);
29614            assert_eq!(recon.len(), x.len());
29615            assert!(
29616                recon.iter().all(|v| v.is_finite()),
29617                "{fmt:?} produced non-finite"
29618            );
29619            let c = cosine(&recon, &x);
29620            assert!(c >= min_cos, "{fmt:?} round-trip cosine {c} < {min_cos}");
29621        }
29622    }
29623
29624    /// `Op::Fma` computes the single-rounded `a*b + c` elementwise. Verify it
29625    /// matches `f32::mul_add` (the hardware FMA), and that the `LowerFma`
29626    /// fallback (`Mul` + `Add`) stays close on a benign case.
29627    #[test]
29628    fn fma_matches_mul_add() {
29629        let f = DType::F32;
29630        let n = 9usize;
29631        let a: Vec<f32> = vec![1.5, -2.0, 0.0, 3.25, -1.1, 7.0, -0.5, 2.2, 9.9];
29632        let b: Vec<f32> = vec![2.0, 0.5, 4.0, -1.0, 6.0, -2.5, 8.0, -3.3, 0.1];
29633        let c: Vec<f32> = vec![0.25, 1.0, -3.0, 2.0, -0.5, 4.0, 1.5, -2.2, 0.0];
29634
29635        let mut g = Graph::new("fma");
29636        let an = g.input("a", Shape::new(&[n], f));
29637        let bn = g.input("b", Shape::new(&[n], f));
29638        let cn = g.input("c", Shape::new(&[n], f));
29639        let out = g.add_node(Op::Fma, vec![an, bn, cn], Shape::new(&[n], f));
29640        g.set_outputs(vec![out]);
29641
29642        let actual = run_graph(&g, &[(an, &a), (bn, &b), (cn, &c)], out, n);
29643        for i in 0..n {
29644            let expected = a[i].mul_add(b[i], c[i]);
29645            assert!(
29646                (actual[i] - expected).abs() <= f32::EPSILON * (1.0 + expected.abs()),
29647                "fma[{i}]: {} vs mul_add {expected}",
29648                actual[i]
29649            );
29650        }
29651    }
29652
29653    /// End-to-end AMP-FP8: take a plain `MatMul` graph, run the
29654    /// `insert_scaled_matmul` compile pass, then execute the rewritten graph on
29655    /// CPU and confirm it tracks the f32 matmul. Proves the pass emits a
29656    /// runnable, numerically-sound graph (rhs transpose + dynamic quantize).
29657    #[test]
29658    fn scaled_quant_pass_runs_end_to_end() {
29659        use rlx_opt::rlx_compile::scaled_quant_insert::{ScaledQuantConfig, insert_scaled_matmul};
29660
29661        let f = DType::F32;
29662        let (m, k, n) = (3usize, 16usize, 5usize);
29663        let mut g = Graph::new("amp_fp8");
29664        let x = g.input("x", Shape::new(&[m, k], f));
29665        let w = g.param("w", Shape::new(&[k, n], f));
29666        let mm = g.matmul(x, w, Shape::new(&[m, n], f));
29667        g.set_outputs(vec![mm]);
29668
29669        let g = insert_scaled_matmul(g, ScaledQuantConfig::fp8_e4m3());
29670
29671        let x_data: Vec<f32> = (0..m * k).map(|i| (i as f32 * 0.11).sin()).collect();
29672        let w_data: Vec<f32> = (0..k * n).map(|i| (i as f32 * 0.05).cos()).collect();
29673        // reference NN matmul: out[i,j] = Σ_p x[i,p]·w[p,j]
29674        let mut reference = vec![0f32; m * n];
29675        for i in 0..m {
29676            for j in 0..n {
29677                let mut acc = 0f32;
29678                for p in 0..k {
29679                    acc += x_data[i * k + p] * w_data[p * n + j];
29680                }
29681                reference[i * n + j] = acc;
29682            }
29683        }
29684
29685        // Locate the (preserved) input/param + output nodes in the rewritten graph.
29686        let mut x_id = None;
29687        let mut w_id = None;
29688        for node in g.nodes() {
29689            match &node.op {
29690                Op::Input { name } if name == "x" => x_id = Some(node.id),
29691                Op::Param { name } if name == "w" => w_id = Some(node.id),
29692                _ => {}
29693            }
29694        }
29695        let (x_id, w_id) = (x_id.unwrap(), w_id.unwrap());
29696        let out_id = g.outputs[0];
29697
29698        let plan = rlx_opt::memory::plan_memory(&g);
29699        let mut arena = crate::arena::Arena::from_plan(plan);
29700        let sched = compile_thunks(&g, &arena);
29701        let x_off = arena.byte_offset(x_id);
29702        let w_off = arena.byte_offset(w_id);
29703        let out_off = arena.byte_offset(out_id);
29704        let buf = arena.raw_buf_mut();
29705        unsafe {
29706            let xp = buf.as_mut_ptr().add(x_off) as *mut f32;
29707            for (i, &v) in x_data.iter().enumerate() {
29708                *xp.add(i) = v;
29709            }
29710            let wp = buf.as_mut_ptr().add(w_off) as *mut f32;
29711            for (i, &v) in w_data.iter().enumerate() {
29712                *wp.add(i) = v;
29713            }
29714        }
29715        execute_thunks(&sched, arena.raw_buf_mut());
29716        let actual: Vec<f32> = unsafe {
29717            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
29718            (0..m * n).map(|i| *p.add(i)).collect()
29719        };
29720
29721        let dot: f32 = actual.iter().zip(&reference).map(|(a, b)| a * b).sum();
29722        let na = actual.iter().map(|x| x * x).sum::<f32>().sqrt();
29723        let nb = reference.iter().map(|x| x * x).sum::<f32>().sqrt();
29724        let cos = dot / (na * nb);
29725        assert!(cos >= 0.999, "AMP-fp8 e2e cosine {cos}");
29726    }
29727}