Skip to main content

rlx_cpu/thunk/
types.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4#![allow(unsafe_op_in_unsafe_fn)]
5use crate::thunk::*;
6
7/// Instrumentation: count of `FusedNomicLayer` thunks emitted by the full-layer
8/// fusion in this process. The fusion is otherwise invisible through the
9/// `Session` API, so out-of-tree model tests (rlx-models' Nomic parity test)
10/// load this to confirm the fusion actually fired (non-vacuous parity). Reset
11/// by storing 0 before a compile.
12pub static FUSED_NOMIC_LAYER_COUNT: std::sync::atomic::AtomicU64 =
13    std::sync::atomic::AtomicU64::new(0);
14/// Storage width of one lane of a fused region operand.
15///
16/// A region computes in `f32` scratch, but its operands are not always stored
17/// as `f32`: `plan_memory_native*` packs an F16/BF16 tensor at 2 bytes and an
18/// F64 one at 8, while `plan_memory_f32_uniform` widens everything to 4. The
19/// declared dtype alone therefore cannot say how to address a lane — a 4-byte
20/// slot holding an `F16`-typed node contains a widened `f32`. Resolved at
21/// compile time from `Arena::byte_size`, which knows what the plan actually
22/// did.
23///
24/// Only widths that are unambiguous are distinguished. Every 4-byte lane stays
25/// `F32`, including integer-typed ones: under the f32-uniform plan those slots
26/// really do hold floats, and the region has always written them that way.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum LaneKind {
29    F32,
30    F16,
31    Bf16,
32    F64,
33}
34
35impl LaneKind {
36    /// Pick the lane from the width the memory plan actually assigned.
37    pub fn resolve(dtype: rlx_ir::DType, elem_bytes: usize) -> Self {
38        match (dtype, elem_bytes) {
39            (rlx_ir::DType::F16, 2) => LaneKind::F16,
40            (rlx_ir::DType::BF16, 2) => LaneKind::Bf16,
41            (rlx_ir::DType::F64, 8) => LaneKind::F64,
42            _ => LaneKind::F32,
43        }
44    }
45
46    /// Read one lane and widen it to the region's `f32` working precision.
47    ///
48    /// # Safety
49    /// `base + off` must point at a slot of at least `(idx + 1)` lanes.
50    #[inline(always)]
51    pub unsafe fn load(self, base: *const u8, off: usize, idx: usize) -> f32 {
52        unsafe {
53            let p = base.add(off);
54            match self {
55                LaneKind::F32 => *(p as *const f32).add(idx),
56                LaneKind::F16 => half::f16::from_bits(*(p as *const u16).add(idx)).to_f32(),
57                LaneKind::Bf16 => half::bf16::from_bits(*(p as *const u16).add(idx)).to_f32(),
58                LaneKind::F64 => *(p as *const f64).add(idx) as f32,
59            }
60        }
61    }
62
63    /// Narrow the region's `f32` result back into one lane.
64    ///
65    /// # Safety
66    /// `base + off` must point at a writable slot of at least `(idx + 1)` lanes.
67    #[inline(always)]
68    pub unsafe fn store(self, base: *mut u8, off: usize, idx: usize, v: f32) {
69        unsafe {
70            let p = base.add(off);
71            match self {
72                LaneKind::F32 => *(p as *mut f32).add(idx) = v,
73                LaneKind::F16 => *(p as *mut u16).add(idx) = half::f16::from_f32(v).to_bits(),
74                LaneKind::Bf16 => *(p as *mut u16).add(idx) = half::bf16::from_f32(v).to_bits(),
75                LaneKind::F64 => *(p as *mut f64).add(idx) = v as f64,
76            }
77        }
78    }
79}
80
81/// A pre-compiled kernel call with all args resolved to arena offsets.
82///
83/// `IntoStaticStr` gives every variant a zero-alloc `&'static str` name (its
84/// identifier) via `<&'static str>::from(&thunk)`, used by the profiler and
85/// tracer (`thunk_kind_name`). Deriving it means a newly added variant is
86/// labeled automatically instead of silently landing in an `"Other"` bucket.
87#[derive(Clone, strum_macros::IntoStaticStr)]
88pub enum Thunk {
89    /// Skip (Input/Param already in arena)
90    Nop,
91    /// Fused element-wise region: ONE pass applies the whole `chain` of scalar
92    /// steps per output element (bias-add → relu → … collapse into a single
93    /// kernel, no intermediate tensors). This is RLX's XLA-style auto-fusion
94    /// path on CPU — the GPU backends already run the same `ElementwiseRegion`
95    /// IR via a generated kernel; this is the scalar interpreter twin so any
96    /// fused chain runs without a hand-written per-op kernel.
97    /// `dst`/`input_offs` are byte offsets; `input_modulus[i]` is input i's
98    /// broadcast period (0 ⇒ read element `gid`; scalar inputs read element 0).
99    ElementwiseRegion {
100        dst: usize,
101        len: u32,
102        input_offs: Vec<usize>,
103        chain: Vec<rlx_ir::op::ChainStep>,
104        scalar_input_mask: u32,
105        input_modulus: [u32; 16],
106        /// Storage lane of each input, parallel to `input_offs`.
107        in_lanes: Vec<LaneKind>,
108        /// Storage lane of `dst`.
109        out_lane: LaneKind,
110    },
111    /// C = A @ B (BLAS sgemm)
112    Sgemm {
113        a: usize,
114        b: usize,
115        c: usize,
116        m: u32,
117        k: u32,
118        n: u32,
119    },
120    /// `C[m,n] = A[m,k](f32) @ B[k,n](BF16)` — dequant-on-the-fly GEMM with a
121    /// BF16 right-hand (half the weight memory traffic; used for a bf16-resident
122    /// LM head). `b` is a BF16 arena buffer (`k*n` `u16`), `a`/`c` are f32.
123    /// `C[m,n] = A[m,k] @ B[k,n]` where any operand may live in the arena
124    /// PACKED at 2 B/elem. Packed sides are widened to f32, the GEMM runs in
125    /// f32, and a packed `c` is rounded back. This is the general fallback for
126    /// autocast graphs, where a half tensor is often a plain activation rather
127    /// than a weight and the planner may or may not have widened its slot.
128    SgemmHalf {
129        a: usize,
130        b: usize,
131        c: usize,
132        m: u32,
133        k: u32,
134        n: u32,
135        a_kind: HalfKind,
136        b_kind: HalfKind,
137        c_kind: HalfKind,
138    },
139    SgemmBf16 {
140        a: usize,
141        b: usize,
142        c: usize,
143        m: u32,
144        k: u32,
145        n: u32,
146    },
147    /// `C[m,n] = A[m,k](f32) @ B[k,n](F16)` — IEEE-half twin of [`Self::SgemmBf16`].
148    /// `b` is an F16 arena buffer (`k*n` `u16`), `a`/`c` are f32. Without this a
149    /// generic `Op::MatMul` with an F16 weight read the half bytes as f32 garbage.
150    SgemmF16 {
151        a: usize,
152        b: usize,
153        c: usize,
154        m: u32,
155        k: u32,
156        n: u32,
157    },
158    /// `C[m,n] = op(A) @ op(B)` with optional transpose of each operand,
159    /// done via cblas trans flags (no materialized transpose). Emitted when
160    /// the thunk compiler folds a last-two-axis `Op::Transpose` feeding a
161    /// matmul (the dominant cost in matmul backprop). `a`/`b` are the
162    /// *pre-transpose* operands. lda/ldb are derived from the trans flags.
163    SgemmT {
164        a: usize,
165        b: usize,
166        c: usize,
167        m: u32,
168        k: u32,
169        n: u32,
170        ta: bool,
171        tb: bool,
172    },
173    /// Fused SGD-with-momentum parameter update, one pass per param.
174    /// Computes `v' = mom·v + g ; p' = p − lr·v'` reading `param`/`vel`/
175    /// `grad` and writing `p_out`/`v_out`. Emitted when the thunk compiler
176    /// folds the in-graph SGD chain `Sub(p, Mul(Add(Mul(v,mom),g), lr))`
177    /// (4 `BinaryFull` ops + 2 full-size constants per param) into a single
178    /// kernel — the dominant cost of the fully-fused training step. All
179    /// fields are arena byte offsets except the scalar hyperparameters.
180    SgdMomentum {
181        param: usize,
182        vel: usize,
183        grad: usize,
184        p_out: usize,
185        v_out: usize,
186        lr: f32,
187        mom: f32,
188        len: u32,
189    },
190    /// Complex (C64) dense GEMM `C = A·B`. Operands are interleaved
191    /// `[re, im]` f32; `a`/`b`/`c` are byte offsets, `m`/`k`/`n` are
192    /// complex-element matrix dims (`A` `[m,k]`, `B` `[k,n]`, `C` `[m,n]`).
193    CgemmC64 {
194        a: usize,
195        b: usize,
196        c: usize,
197        m: u32,
198        k: u32,
199        n: u32,
200    },
201    /// f64 dense solve `x = A⁻¹·b` via LAPACK dgesv.
202    /// `a`, `b`, `x` are byte-offsets into the arena. `n` is the matrix
203    /// dimension; `nrhs` is 1 for a vector RHS or >1 for multi-RHS.
204    /// The kernel materializes scratch copies of A and b internally
205    /// (LAPACK overwrites both with LU factors and solution).
206    DenseSolveF64 {
207        a: usize,
208        b: usize,
209        x: usize,
210        n: u32,
211        nrhs: u32,
212    },
213    /// f32 twin of `DenseSolveF64`. Calls LAPACK `sgesv` (or the
214    /// no-blas Rust fallback). Same arena byte-offset contract.
215    DenseSolveF32 {
216        a: usize,
217        b: usize,
218        x: usize,
219        n: u32,
220        nrhs: u32,
221    },
222    /// Cholesky factorization `A = L·Lᵀ`. `a` input `[n,n]`, `l` output `[n,n]`
223    /// lower-triangular (upper zeroed). f32 arena; promoted to f64 for LAPACK
224    /// `dpotrf`.
225    CholeskyF32 {
226        a: usize,
227        l: usize,
228        n: u32,
229    },
230    /// Triangular solve `op(A)·X = B` (f32 arena; promoted to f64 for BLAS
231    /// `trsm`). `a` `[n,n]`, `b`/`x` `[n,nrhs]`.
232    TriangularSolveF32 {
233        a: usize,
234        b: usize,
235        x: usize,
236        n: u32,
237        nrhs: u32,
238        lower: bool,
239        transpose: bool,
240    },
241    /// Determinant (`log_abs=false`) or `log|det|` (`log_abs=true`) of `a`
242    /// `[n,n]` → scalar `out`. f32 arena; promoted to f64 for LAPACK LU.
243    DetF32 {
244        a: usize,
245        out: usize,
246        n: u32,
247        log_abs: bool,
248    },
249    /// Sort (`arg=false`, output values) or argsort (`arg=true`, output f32
250    /// indices) along an axis, viewed as `outer × axis_dim × inner`.
251    SortF32 {
252        src: usize,
253        dst: usize,
254        outer: u32,
255        axis_dim: u32,
256        inner: u32,
257        descending: bool,
258        arg: bool,
259    },
260    /// One factor of the thin SVD of `a` `[m,n]`. `part`: 0=U `[m,k]`, 1=S `[k]`,
261    /// 2=Vt `[k,n]` (`k=min(m,n)`). f32 arena; promoted to f64 for LAPACK `gesdd`.
262    SvdF32 {
263        a: usize,
264        out: usize,
265        m: u32,
266        n: u32,
267        part: u8,
268    },
269    /// One factor of the thin QR of `a` `[m,n]`. `part`: 0=Q `[m,k]`, 1=R `[k,n]`
270    /// (`k=min(m,n)`). f32 arena; promoted to f64 for LAPACK `geqrf`/`orgqr`.
271    QrF32 {
272        a: usize,
273        out: usize,
274        m: u32,
275        n: u32,
276        part: u8,
277    },
278    /// Batched f64 dense solve. `a`, `b`, `x` are byte-offsets to
279    /// the leading slice; `batch` is the number of independent
280    /// systems. Per slice the kernel calls `dgesv(A_i, b_i, n, nrhs)`
281    /// — LAPACK has no batched dgesv on Accelerate, so we loop.
282    BatchedDenseSolveF64 {
283        a: usize,
284        b: usize,
285        x: usize,
286        batch: u32,
287        n: u32,
288        nrhs: u32,
289    },
290    /// Batched f32 dense solve — loop of `sgesv` per batch slice.
291    BatchedDenseSolveF32 {
292        a: usize,
293        b: usize,
294        x: usize,
295        batch: u32,
296        n: u32,
297        nrhs: u32,
298    },
299    /// Batched f64 matmul. Both inputs and output have a leading
300    /// batch axis of size `batch`. Per-batch independent dgemm:
301    /// `C[i] = A[i] @ B[i]` for `i in 0..batch`. Used by VJP rules
302    /// that emit per-batch outer products (e.g., BatchedDenseSolve
303    /// VJP). The unbatched `Dgemm` thunk handles the rank-2 case.
304    BatchedDgemmF64 {
305        a: usize,
306        b: usize,
307        c: usize,
308        batch: u32,
309        m: u32,
310        k: u32,
311        n: u32,
312        /// `true` when operand `a`/`b` has batch dim 1 and is BROADCAST across
313        /// every output batch (per-matrix stride 0 — reuse matrix 0). Mirrors
314        /// [`Thunk::BatchedSgemm`]; without it the loop over-reads a batch-1
315        /// operand for `bi > 0`.
316        a_bcast: bool,
317        b_bcast: bool,
318    },
319    /// Batched f32 matmul — same loop-per-batch shape as
320    /// `BatchedDgemmF64` but calling `sgemm`. Needed for attention
321    /// patterns where both operands carry a batch dim (e.g. q@k^T
322    /// and attn@v in decomposed self-attention). The 2-D `Sgemm`
323    /// flatten trick is wrong in that case because it treats `b` as
324    /// a single shared RHS across every batch.
325    BatchedSgemm {
326        a: usize,
327        b: usize,
328        c: usize,
329        batch: u32,
330        m: u32,
331        k: u32,
332        n: u32,
333        /// `true` when operand `a`/`b` has batch dim 1 and is BROADCAST across
334        /// every output batch (its per-matrix stride is 0 — reuse matrix 0).
335        /// Without this the loop over-reads a batch-1 operand for `bi>0`.
336        a_bcast: bool,
337        b_bcast: bool,
338    },
339    /// C = A @ B via Accelerate cblas_dgemm. Mirror of `Sgemm` at f64.
340    Dgemm {
341        a: usize,
342        b: usize,
343        c: usize,
344        m: u32,
345        k: u32,
346        n: u32,
347    },
348    /// f64 N-D index walk used for both `Op::Transpose` and `Op::Expand`.
349    /// `in_strides` carries 0s on broadcast axes (Expand) or permuted
350    /// strides (Transpose). Mirror of `Thunk::Transpose` at f64.
351    TransposeF64 {
352        src: usize,
353        dst: usize,
354        in_total: u32,
355        out_dims: Vec<u32>,
356        in_strides: Vec<u32>,
357    },
358    /// f64 element-wise activation. Single-input, single-output. The
359    /// kernel always reads from `src` and writes to `dst`, so it works
360    /// whether or not the planner aliased the two slots.
361    ActivationF64 {
362        src: usize,
363        dst: usize,
364        len: u32,
365        kind: Activation,
366    },
367    /// Element-wise complex squared-magnitude: `|z|² = re² + im²`.
368    /// Reads the C64 input at `src` as `2·len` f32 (`re,im` pairs),
369    /// writes `len` f32 to `dst`.
370    ComplexNormSqF32 {
371        src: usize,
372        dst: usize,
373        /// Logical element count (number of complex values).
374        len: u32,
375    },
376    /// Wirtinger backward for `ComplexNormSqF32`: `dz = g · z` as
377    /// C64. Reads `z` at `2·len` f32 + `g` at `len` f32; writes
378    /// `2·len` f32 to `dz`.
379    ComplexNormSqBackwardF32 {
380        z: usize,
381        g: usize,
382        dz: usize,
383        len: u32,
384    },
385    /// Element-wise C64 conjugate: writes `[re_i, -im_i]` per element.
386    /// Layout matches the rest of C64 here (`re,im` interleaved f32).
387    ConjugateC64 {
388        src: usize,
389        dst: usize,
390        len: u32,
391    },
392    /// C64 element-wise activation. Only kinds with well-defined
393    /// complex extensions are supported: Neg, Exp, Log, Sqrt.
394    /// Everything else (Sigmoid, Tanh, Relu, Abs, Sin/Cos/Tan/Atan,
395    /// Round, GeLU family) is rejected at lowering — those don't have
396    /// single natural complex definitions. `len` is the **complex
397    /// element count** (the f32 buffer holds `2·len` floats).
398    ActivationC64 {
399        src: usize,
400        dst: usize,
401        len: u32,
402        kind: Activation,
403    },
404    /// f64 contiguous reduction along a single axis range. Layout
405    /// `[outer, reduced, inner]` in memory; output is `[outer, inner]`.
406    /// Sum only for now (Mean composes via 1/N multiply post-pass).
407    ReduceSumF64 {
408        src: usize,
409        dst: usize,
410        outer: u32,
411        reduced: u32,
412        inner: u32,
413    },
414    /// f64 plain copy (Reshape / Cast at the same dtype). Mirrors `Copy`
415    /// but at 8 bytes per element.
416    CopyF64 {
417        src: usize,
418        dst: usize,
419        len: u32,
420    },
421    /// i64 element copy (Reshape/Cast on i64 tensors).
422    CopyI64 {
423        src: usize,
424        dst: usize,
425        len: u32,
426    },
427    /// Truncating f32 → i64 (ONNX Cast; toward zero).
428    CastF32ToI64 {
429        src: usize,
430        dst: usize,
431        len: u32,
432    },
433    CastF32ToF64 {
434        src: usize,
435        dst: usize,
436        len: u32,
437    },
438    /// Narrowing f64 → f32 (`x as f32`, round-to-nearest). Without this the
439    /// generic Copy fallback byte-copies `len` f32-width elements out of the
440    /// 8-byte-per-element f64 buffer, so it reads each f64's two 4-byte halves
441    /// as two garbage f32s (round-trip F32→F64→F32 returned garbage).
442    CastF64ToF32 {
443        src: usize,
444        dst: usize,
445        len: u32,
446    },
447    /// Truncating f64 → i64 (ONNX Cast; toward zero). The generic 4-byte Copy
448    /// fallback reads/writes the wrong lane width against the 8-byte f64/i64
449    /// buffers (and would be a bit-copy, not a numeric convert).
450    CastF64ToI64 {
451        src: usize,
452        dst: usize,
453        len: u32,
454    },
455    /// Truncating f64 → i32 (ONNX Cast; toward zero). f64 is 8 bytes, i32 is 4;
456    /// the generic Copy reads only the low 4 bytes of each f64 as garbage.
457    CastF64ToI32 {
458        src: usize,
459        dst: usize,
460        len: u32,
461    },
462    /// f64 → Bool (`x != 0.0`). f64 is 8 bytes, Bool is 1; the generic Copy
463    /// misreads widths entirely.
464    CastF64ToBool {
465        src: usize,
466        dst: usize,
467        len: u32,
468    },
469    /// Narrowing i64 → i32 (`x as i32`, wrapping). 8-byte → 4-byte; the generic
470    /// Copy would keep only the low 4 bytes of each i64 (correct only for the
471    /// bit-truncation case, wrong as a Copy of `len` 4-byte lanes off an 8-byte
472    /// buffer). Mirror of the existing `CastI32ToI64` widening.
473    CastI64ToI32 {
474        src: usize,
475        dst: usize,
476        len: u32,
477    },
478    /// i64 → f64 (`x as f64`). Both are 8 bytes, but the generic 4-byte Copy
479    /// would move only half of each element AND bit-reinterpret (not a numeric
480    /// convert). Uses the existing i64/f64 accessors.
481    CastI64ToF64 {
482        src: usize,
483        dst: usize,
484        len: u32,
485    },
486    /// i32 → f64 (`x as f64`). 4-byte src → 8-byte dst; the generic Copy reads
487    /// too few source bytes and writes the wrong width.
488    CastI32ToF64 {
489        src: usize,
490        dst: usize,
491        len: u32,
492    },
493    /// f32 → f16 (`half::f16::from_f32`, round-to-nearest-even). Output is
494    /// 2-byte f16; there is no `sl_f16` accessor so the kernel writes the
495    /// little-endian f16 bytes straight into the arena.
496    CastF32ToF16 {
497        src: usize,
498        dst: usize,
499        len: u32,
500    },
501    /// f16 → f32 (`half::f16::to_f32`, exact). Input is 2-byte f16 read from the
502    /// arena bytes; output is native f32.
503    CastF16ToF32 {
504        src: usize,
505        dst: usize,
506        len: u32,
507    },
508    /// f32 → bf16 (`half::bf16::from_f32`, round-to-nearest-even). 2-byte bf16
509    /// output written as little-endian arena bytes.
510    CastF32ToBf16 {
511        src: usize,
512        dst: usize,
513        len: u32,
514    },
515    /// bf16 → f32 (`half::bf16::to_f32`, exact). 2-byte bf16 input read from the
516    /// arena bytes; output is native f32.
517    CastBf16ToF32 {
518        src: usize,
519        dst: usize,
520        len: u32,
521    },
522    /// Truncating f32 → i32 (ONNX Cast; toward zero).
523    CastF32ToI32 {
524        src: usize,
525        dst: usize,
526        len: u32,
527    },
528    /// i64 → f32 (ONNX Cast on shape scalars, e.g. Albert head-dim).
529    CastI64ToF32 {
530        src: usize,
531        dst: usize,
532        len: u32,
533    },
534    /// bool → i32 (BERT attention mask grid).
535    CastBoolToI32 {
536        src: usize,
537        dst: usize,
538        len: u32,
539    },
540    CastBoolToF32 {
541        src: usize,
542        dst: usize,
543        len: u32,
544    },
545    /// f32 → Bool (`x != 0.0`). Lets logical And/Or lower their {0,1} f32
546    /// arithmetic result straight back to a 1-byte Bool without a scalar
547    /// `Compare` (whose thunk has no broadcast and would read a scalar rhs
548    /// out of bounds).
549    CastF32ToBool {
550        src: usize,
551        dst: usize,
552        len: u32,
553    },
554    /// i32 → f32 (BERT attention mask cast before subtract).
555    CastI32ToF32 {
556        src: usize,
557        dst: usize,
558        len: u32,
559    },
560    /// i32 → i64 (widen integer indices/ids; e.g. an ONNX graph with I32
561    /// `context_tokens` feeding a codebook Gather whose kernel wants i64).
562    CastI32ToI64 {
563        src: usize,
564        dst: usize,
565        len: u32,
566    },
567    /// i32 → bool: `out[i] = (in[i] != 0) as u8`. Element-wise; the generic Copy
568    /// fallback byte-copies (4-byte i32 → 1-byte bool) and reads garbage. Needed by
569    /// MOSS-TTS's `Cast(attention_mask i32 → bool)` in the RoPE-position cumsum.
570    CastI32ToBool {
571        src: usize,
572        dst: usize,
573        len: u32,
574    },
575    /// i64 → bool: `out[i] = (in[i] != 0) as u8`. Element-wise; Copy would treat
576    /// 8-byte lanes as 1-byte bools. Soprano KV-backbone casts `attention_mask`
577    /// (I64 ones) to Bool before the causal Where(-inf) mask.
578    CastI64ToBool {
579        src: usize,
580        dst: usize,
581        len: u32,
582    },
583    /// bool → i64: `out[i] = in[i] as i64` (0/1). Widen a bool mask for CumSum/arith.
584    CastBoolToI64 {
585        src: usize,
586        dst: usize,
587        len: u32,
588    },
589    /// Generic scalar cast for any numeric dtype pair not covered by a
590    /// hand-written fast-path arm above (exotic widths: I8/I16/U8/U32, and
591    /// F16/BF16 against any non-F32 dtype). Converts element-by-element with
592    /// correct numeric semantics (float→int saturates, int→int wraps,
593    /// round-to-nearest for F16/BF16) instead of the corrupting generic 4-byte
594    /// `Copy` that silently mis-strides / bit-reinterprets. C64 is never routed
595    /// here (no scalar conversion is defined for complex).
596    CastGeneric {
597        src: usize,
598        dst: usize,
599        len: u32,
600        src_dtype: rlx_ir::DType,
601        dst_dtype: rlx_ir::DType,
602    },
603    /// f64 element-wise binary with broadcast. `len`/`lhs_len`/`rhs_len`
604    /// are element counts; kernel does `out[i] = lhs[i % lhs_len] OP rhs[i % rhs_len]`.
605    /// Mirror of `BinaryFull` at 8 bytes per element.
606    BinaryFullF64 {
607        lhs: usize,
608        rhs: usize,
609        dst: usize,
610        len: u32,
611        lhs_len: u32,
612        rhs_len: u32,
613        op: BinaryOp,
614        /// Output shape dims (row-major). Empty in the fast path. See
615        /// `BinaryFull` doc for the broadcast convention.
616        out_dims_bcast: Vec<u32>,
617        bcast_lhs_strides: Vec<u32>,
618        bcast_rhs_strides: Vec<u32>,
619    },
620    /// f64 concat — byte-for-byte mirror of `Concat` but copies
621    /// 8 bytes per element. Element-counted offsets/strides match
622    /// the f32 variant; the executor scales by elem_size internally.
623    ConcatF64 {
624        dst: usize,
625        outer: u32,
626        inner: u32,
627        total_axis: u32,
628        inputs: Vec<(usize, u32, u32)>,
629    },
630    /// C64 element-wise binary with broadcast. Same `len` /
631    /// `lhs_len` / `rhs_len` semantics as `BinaryFull` but each
632    /// "element" is one complex value (8 bytes = `[re, im]` as two
633    /// f32s). The executor reads the underlying f32 buffer at
634    /// `2·len` floats and walks element pairs. Supports Add / Sub /
635    /// Mul / Div; Max / Min / Pow have no single natural complex
636    /// definition and panic at lowering.
637    BinaryFullC64 {
638        lhs: usize,
639        rhs: usize,
640        dst: usize,
641        /// Complex element count (NOT f32 count). f32 buffer length
642        /// is `2·len`.
643        len: u32,
644        lhs_len: u32,
645        rhs_len: u32,
646        op: BinaryOp,
647        out_dims_bcast: Vec<u32>,
648        bcast_lhs_strides: Vec<u32>,
649        bcast_rhs_strides: Vec<u32>,
650    },
651    /// Bounded scan. Holds a recursively-compiled body schedule + a
652    /// pre-initialized body arena snapshot (constants filled). Each
653    /// outer execution clones the snapshot, copies the carry-in slot
654    /// from the outer arena, runs the body schedule `length` times,
655    /// then writes the final carry to the outer arena.
656    ///
657    /// Single-carry MVP — body has exactly one Input and one output,
658    /// both same shape and dtype.
659    Scan {
660        body: Arc<ThunkSchedule>,
661        body_init: Arc<Vec<u8>>, // pristine body arena bytes
662        body_input_off: usize,   // byte offset of the body's carry-Input slot
663        body_output_off: usize,  // byte offset of the body's output slot
664        outer_init_off: usize,   // outer-arena offset of the initial carry
665        outer_final_off: usize,  // outer-arena offset of the final carry / trajectory base
666        length: u32,
667        carry_bytes: u32, // carry size in bytes
668        /// When true, write each step's carry to the outer arena at
669        /// offset `outer_final_off + t * carry_bytes`, producing a
670        /// `[length, *carry]` stacked trajectory. When false, only the
671        /// final carry lands at `outer_final_off`.
672        save_trajectory: bool,
673        /// Per-step `xs` inputs. For each: (body_x_input_off,
674        /// outer_xs_base_off, per_step_bytes). Per iteration `t`, the
675        /// executor copies `outer_xs_base_off + t * per_step_bytes`
676        /// into `body_x_input_off`. Empty when the scan has no xs.
677        xs_inputs: Arc<Vec<(usize, usize, u32)>>,
678        /// Broadcast inputs — values constant across iterations. For
679        /// each: (body_bcast_input_off, outer_bcast_off, total_bytes).
680        /// Filled into `body_buf` ONCE before the scan loop starts
681        /// (xs in contrast are re-filled every iteration). Empty when
682        /// the scan has no bcasts.
683        bcast_inputs: Arc<Vec<(usize, usize, u32)>>,
684        /// Number of trajectory checkpoints (when `save_trajectory`).
685        /// `0` or `length` ⇒ save every iteration. Otherwise save only
686        /// `K` rows at indices `floor((k+1) * length / K) - 1` for
687        /// `k in 0..K`. Last index is always `length-1` so the final
688        /// carry is always cached.
689        num_checkpoints: u32,
690    },
691
692    /// Reverse-mode AD companion to `Thunk::Scan`. Walks `t = length-1
693    /// .. 0`, threading `dcarry` through the body's VJP. Per iteration:
694    /// writes `carry_t` (from outer init or trajectory), each `xs_i[t]`
695    /// slice, and the current `dcarry` into the body_vjp's Input
696    /// slots, runs body_vjp, reads new `dcarry` from its single output.
697    /// f64 carry only — the upstream-accumulation step in trajectory
698    /// mode does an element-wise f64 add.
699    ScanBackward {
700        body_vjp: Arc<ThunkSchedule>,
701        body_init: Arc<Vec<u8>>,
702        body_carry_in_off: usize, // body_vjp's mirrored body-carry-input slot
703        body_x_offs: Arc<Vec<usize>>, // body_vjp's mirrored x_t_i Input slots, in xs order
704        body_d_output_off: usize, // body_vjp's "d_output" Input slot
705        body_dcarry_out_off: usize, // body_vjp's gradient output
706        outer_init_off: usize,    // original init carry
707        outer_traj_off: usize,    // [length-or-K, *carry] trajectory base
708        outer_upstream_off: usize, // upstream gradient (carry shape, or [length, *carry])
709        /// Per-xs entries: (outer_xs_base_off, per_step_bytes). Read
710        /// `xs_i[t]` from `outer_xs_base_off + t * per_step_bytes`.
711        outer_xs_offs: Arc<Vec<(usize, u32)>>,
712        outer_dinit_off: usize, // output: dinit
713        length: u32,
714        carry_bytes: u32,
715        /// Bytes per element in the carry tensor: 4 for f32, 8 for f64.
716        /// Used to dispatch the trajectory-mode upstream accumulation
717        /// kernel (the dcarry += upstream\[t\] add must use the right
718        /// floating-point type — a hard-coded f64 add silently does
719        /// nothing for an f32 carry whose `cb` isn't divisible by 8).
720        carry_elem_size: u32,
721        save_trajectory: bool, // true → upstream is per-step; false → just final
722        /// Recursive checkpointing config. `0` or `length` ⇒ full
723        /// trajectory cached, no recompute (existing behavior).
724        /// `0 < K < length` ⇒ trajectory has only K rows; the executor
725        /// recomputes intermediate carries via `forward_body` between
726        /// checkpoints. Memory: O(K · carry_bytes); time: O(length).
727        num_checkpoints: u32,
728        /// Forward body schedule (same compiled body as the forward
729        /// Op::Scan), used for recompute when `num_checkpoints` is
730        /// active. `None` for the All strategy.
731        forward_body: Option<Arc<ThunkSchedule>>,
732        /// Pristine forward body arena bytes (constants filled).
733        forward_body_init: Option<Arc<Vec<u8>>>,
734        /// Forward body's carry-Input and output slot offsets — needed
735        /// to seed/read the body during recompute.
736        forward_body_carry_in_off: usize,
737        forward_body_output_off: usize,
738        /// Forward body's per-step xs Input slots (one per outer xs).
739        /// Same indexing convention as `body_x_offs`.
740        forward_body_x_offs: Arc<Vec<usize>>,
741    },
742
743    /// Companion to `ScanBackward` that materializes one stacked
744    /// `dxs_i`. Same backward loop; per iteration, after running
745    /// body_vjp, copies its `body_dxs_out_off` slot into the outer
746    /// arena at `outer_dxs_off + t * per_step_bytes`. dcarry threading
747    /// is identical — we still need it for the body_vjp recurrence
748    /// even though we don't write it back to the outer arena.
749    ScanBackwardXs {
750        body_vjp: Arc<ThunkSchedule>,
751        body_init: Arc<Vec<u8>>,
752        body_carry_in_off: usize,
753        body_x_offs: Arc<Vec<usize>>,
754        body_d_output_off: usize,
755        body_dcarry_out_off: usize,
756        body_dxs_out_off: usize, // the body_vjp output we extract per step
757        outer_init_off: usize,
758        outer_traj_off: usize,
759        outer_upstream_off: usize,
760        outer_xs_offs: Arc<Vec<(usize, u32)>>,
761        outer_dxs_off: usize, // base of the stacked [length, *per_step] output
762        length: u32,
763        carry_bytes: u32,
764        /// Same role as `Thunk::ScanBackward::carry_elem_size`.
765        carry_elem_size: u32,
766        per_step_bytes: u32, // bytes per row of the dxs output
767        save_trajectory: bool,
768        /// Recursive checkpointing config. Same semantics as
769        /// `Thunk::ScanBackward::num_checkpoints` — `0` or `length`
770        /// means "save every step's carry"; `0 < K < length` means
771        /// the trajectory has only K rows and the executor recomputes
772        /// intermediate carries via `forward_body` (which must be
773        /// `Some`). Implemented via segment-cached recompute,
774        /// mirroring the `ScanBackward` path.
775        num_checkpoints: u32,
776        forward_body: Option<Arc<ThunkSchedule>>,
777        forward_body_init: Option<Arc<Vec<u8>>>,
778        forward_body_carry_in_off: usize,
779        forward_body_output_off: usize,
780        forward_body_x_offs: Arc<Vec<usize>>,
781    },
782    /// User-defined sub-graph (`Op::CustomFn`) — runs `fwd_body` once.
783    /// Per execution: clone `body_init`, copy each primal input from the
784    /// outer arena into its body Input slot, run the body schedule,
785    /// copy the body's single output back to the outer arena.
786    CustomFn {
787        body: Arc<ThunkSchedule>,
788        body_init: Arc<Vec<u8>>,
789        /// Per primal input: (body_input_off, outer_input_off, bytes).
790        inputs: Arc<Vec<(usize, usize, u32)>>,
791        body_output_off: usize,
792        outer_output_off: usize,
793        out_bytes: u32,
794    },
795    /// C = A @ B; C += bias; C = act(C)
796    FusedMmBiasAct {
797        a: usize,
798        w: usize,
799        bias: usize,
800        c: usize,
801        m: u32,
802        k: u32,
803        n: u32,
804        act: Option<Activation>,
805    },
806    /// out = LN(x + residual + bias, gamma, beta)
807    FusedResidualLN {
808        x: usize,
809        res: usize,
810        bias: usize,
811        g: usize,
812        b: usize,
813        out: usize,
814        rows: u32,
815        h: u32,
816        eps: f32,
817        has_bias: bool,
818    },
819    /// out = RmsNorm(x + residual + bias, gamma, beta)
820    FusedResidualRmsNorm {
821        x: usize,
822        res: usize,
823        bias: usize,
824        g: usize,
825        b: usize,
826        out: usize,
827        rows: u32,
828        h: u32,
829        eps: f32,
830        has_bias: bool,
831    },
832    /// out = bias_add(data, bias, m, n) for Binary::Add with broadcast
833    BiasAdd {
834        src: usize,
835        bias: usize,
836        dst: usize,
837        m: u32,
838        n: u32,
839    },
840    /// Element-wise binary op with NumPy-style broadcast.
841    ///
842    /// Fast path (`lhs_len == rhs_len == len`): plain element-wise loop,
843    /// SIMD-vectorized on aarch64 for `Add`/`Mul`. `bcast_*` fields
844    /// are unused.
845    ///
846    /// Broadcast path: uses `out_dims_bcast` + `bcast_lhs_strides` +
847    /// `bcast_rhs_strides` to compute per-cell indices into each
848    /// operand. The strides are precomputed at thunk-construction
849    /// time from the operands' true shapes (with stride 0 on any axis
850    /// where the operand has size 1). This is the only correct way
851    /// to handle bidirectional broadcasts like `[N, 1] op [1, S]
852    /// → [N, S]`, which simple `i % lhs_len` modulo indexing maps to
853    /// wrong cells.
854    BinaryFull {
855        lhs: usize,
856        rhs: usize,
857        dst: usize,
858        len: u32,
859        lhs_len: u32,
860        rhs_len: u32,
861        op: BinaryOp,
862        /// Output shape dims (row-major). Empty in the fast path.
863        out_dims_bcast: Vec<u32>,
864        /// Per-dim stride into `lhs` (0 where lhs broadcasts).
865        bcast_lhs_strides: Vec<u32>,
866        /// Per-dim stride into `rhs`.
867        bcast_rhs_strides: Vec<u32>,
868        /// Element size (4 = F32, 8 = I64).
869        elem_bytes: u8,
870    },
871    /// Activation in-place
872    ActivationInPlace {
873        data: usize,
874        len: u32,
875        act: Activation,
876    },
877    /// Gather axis=0: table\[idx\] → out
878    Gather {
879        table: usize,
880        table_len: u32,
881        idx: usize,
882        dst: usize,
883        num_idx: u32,
884        trailing: u32,
885        /// 1 when the index tensor is i64 (ONNX Gather indices).
886        idx_i64: u8,
887        /// Element size of table/output (4 = f32, 8 = i64).
888        table_bytes: u8,
889    },
890    /// Narrow: copy slice (`elem_bytes` = source element size: 4 for f32, 8 for f64).
891    Narrow {
892        src: usize,
893        dst: usize,
894        outer: u32,
895        src_stride: u32,
896        dst_stride: u32,
897        inner: u32,
898        elem_bytes: u8,
899    },
900    /// Reverse (flip) element order along the axes in `rev_mask`. `dims` is the
901    /// row-major input shape; output shape is identical. Dtype-agnostic
902    /// (byte-copy of `elem_bytes` per element).
903    Reverse {
904        src: usize,
905        dst: usize,
906        dims: Vec<u32>,
907        rev_mask: Vec<bool>,
908        elem_bytes: u8,
909    },
910    /// Copy (reshape, expand)
911    Copy {
912        src: usize,
913        dst: usize,
914        len: u32,
915    },
916    /// Round f32 values to half precision **in f32 storage**.
917    ///
918    /// A half-typed *activation* gets a 4-byte slot holding f32
919    /// (`plan_memory_native_in_order` widens them because the thunks compute
920    /// in f32); only half *params* stay packed at 2 B. So a `Cast` to BF16/F16
921    /// whose destination is an activation must round the value and keep the
922    /// f32 layout — writing packed halves into a widened slot reads back as
923    /// pairs of halves and corrupts everything downstream.
924    RoundHalf {
925        src: usize,
926        dst: usize,
927        len: u32,
928        /// `true` = bf16, `false` = IEEE f16.
929        bf16: bool,
930    },
931    /// LayerNorm standalone
932    LayerNorm {
933        src: usize,
934        g: usize,
935        b: usize,
936        dst: usize,
937        rows: u32,
938        h: u32,
939        eps: f32,
940    },
941    /// GroupNorm on NCHW `[N,C,H,W]`.
942    GroupNorm {
943        src: usize,
944        g: usize,
945        b: usize,
946        dst: usize,
947        n: u32,
948        c: u32,
949        h: u32,
950        w: u32,
951        num_groups: u32,
952        eps: f32,
953    },
954    /// BatchNorm inference: frozen mean/var, feature axis last.
955    BatchNormInference {
956        src: usize,
957        g: usize,
958        b: usize,
959        mean: usize,
960        var: usize,
961        dst: usize,
962        count: u32,
963        channels: u32,
964        eps: f32,
965    },
966    BatchNormInferenceBackwardInput {
967        x: usize,
968        gamma: usize,
969        mean: usize,
970        var: usize,
971        dy: usize,
972        dx: usize,
973        count: u32,
974        channels: u32,
975        eps: f32,
976    },
977    BatchNormInferenceBackwardGamma {
978        x: usize,
979        mean: usize,
980        var: usize,
981        dy: usize,
982        dgamma: usize,
983        count: u32,
984        channels: u32,
985        eps: f32,
986    },
987    BatchNormInferenceBackwardBeta {
988        dy: usize,
989        dbeta: usize,
990        count: u32,
991        channels: u32,
992    },
993    /// LayerNorm2d on NCHW (SAM / candle semantics).
994    LayerNorm2d {
995        src: usize,
996        g: usize,
997        b: usize,
998        dst: usize,
999        n: u32,
1000        c: u32,
1001        h: u32,
1002        w: u32,
1003        eps: f32,
1004    },
1005    /// ConvTranspose2d on NCHW.
1006    ConvTranspose2d {
1007        src: usize,
1008        weight: usize,
1009        dst: usize,
1010        n: u32,
1011        c_in: u32,
1012        h: u32,
1013        w_in: u32,
1014        c_out: u32,
1015        h_out: u32,
1016        w_out: u32,
1017        kh: u32,
1018        kw: u32,
1019        sh: u32,
1020        sw: u32,
1021        ph: u32,
1022        pw: u32,
1023        dh: u32,
1024        dw: u32,
1025        groups: u32,
1026    },
1027    /// 3D convolution on NCDHW. Input [N, C_in, D, H, W], weight
1028    /// [C_out, C_in/groups, kD, kH, kW], output [N, C_out, D_out, H_out, W_out].
1029    /// Naive direct convolution; the depth-axis analogue of `Thunk::Conv2D`.
1030    Conv3d {
1031        src: usize,
1032        weight: usize,
1033        dst: usize,
1034        n: u32,
1035        c_in: u32,
1036        d: u32,
1037        h: u32,
1038        w: u32,
1039        c_out: u32,
1040        d_out: u32,
1041        h_out: u32,
1042        w_out: u32,
1043        kd: u32,
1044        kh: u32,
1045        kw: u32,
1046        sd: u32,
1047        sh: u32,
1048        sw: u32,
1049        pd: u32,
1050        ph: u32,
1051        pw: u32,
1052        dd: u32,
1053        dh: u32,
1054        dw: u32,
1055        groups: u32,
1056    },
1057    /// ConvTranspose3d on NCDHW. Weight [C_in, C_out/groups, kD, kH, kW].
1058    /// `output_padding` is already folded into `d_out/h_out/w_out`.
1059    ConvTranspose3d {
1060        src: usize,
1061        weight: usize,
1062        dst: usize,
1063        n: u32,
1064        c_in: u32,
1065        d: u32,
1066        h: u32,
1067        w_in: u32,
1068        c_out: u32,
1069        d_out: u32,
1070        h_out: u32,
1071        w_out: u32,
1072        kd: u32,
1073        kh: u32,
1074        kw: u32,
1075        sd: u32,
1076        sh: u32,
1077        sw: u32,
1078        pd: u32,
1079        ph: u32,
1080        pw: u32,
1081        dd: u32,
1082        dh: u32,
1083        dw: u32,
1084        groups: u32,
1085    },
1086    /// Nearest 2× upsample on NCHW (per-batch slice).
1087    ResizeNearest2x {
1088        src: usize,
1089        dst: usize,
1090        n: u32,
1091        c: u32,
1092        h: u32,
1093        w: u32,
1094    },
1095    /// Nearest NCDHW resample to an explicit spatial size.
1096    Interpolate3d {
1097        src: usize,
1098        dst: usize,
1099        n: u32,
1100        c: u32,
1101        d_in: u32,
1102        h_in: u32,
1103        w_in: u32,
1104        d_out: u32,
1105        h_out: u32,
1106        w_out: u32,
1107    },
1108    /// SAM2 axial 2-D RoPE on `[batch, seq, num_heads * head_dim]`.
1109    AxialRope2d {
1110        src: usize,
1111        dst: usize,
1112        batch: u32,
1113        seq: u32,
1114        hidden: u32,
1115        end_x: u32,
1116        end_y: u32,
1117        head_dim: u32,
1118        num_heads: u32,
1119        theta: f32,
1120        repeat_factor: u32,
1121    },
1122    /// RMSNorm: out = (x / sqrt(mean(x^2) + eps)) * gamma + beta. No mean
1123    /// subtraction, hence cheaper than LayerNorm. Used by Llama-class models.
1124    RmsNorm {
1125        src: usize,
1126        g: usize,
1127        b: usize,
1128        dst: usize,
1129        rows: u32,
1130        h: u32,
1131        eps: f32,
1132    },
1133    /// DiT adaLN-Zero modulation: `out = norm(x)·(1+scale)+shift`, one pass —
1134    /// mean/var (or RMS) and the per-conditioning affine fused, the pre-norm
1135    /// activation never materialized. `scale`/`shift` share the modulation
1136    /// shape (`[B,1,D]`); `mod_off[row]` is the element offset into each for
1137    /// output `row` (the built-in broadcast that avoids an `Op::Expand`).
1138    AdaLayerNorm {
1139        src: usize,
1140        scale: usize,
1141        shift: usize,
1142        dst: usize,
1143        rows: u32,
1144        h: u32,
1145        eps: f32,
1146        /// `true` ⇒ LayerNorm (mean+var); `false` ⇒ RMSNorm (rms only).
1147        layer_norm: bool,
1148        /// Element count of the `scale`/`shift` buffers (they share a shape).
1149        mod_len: u32,
1150        /// Per-row element offset into `scale`/`shift` (broadcast map).
1151        mod_off: Vec<u32>,
1152    },
1153    /// DiT gated residual: `out = x + gate·y`. `gate` carries the modulation
1154    /// shape (`[B,1,D]`); `gate_off[row]` is the element offset into `gate` for
1155    /// output `row`.
1156    GatedResidual {
1157        x: usize,
1158        y: usize,
1159        gate: usize,
1160        dst: usize,
1161        rows: u32,
1162        h: u32,
1163        /// Element count of the `gate` buffer.
1164        gate_len: u32,
1165        /// Per-row element offset into `gate` (broadcast map).
1166        gate_off: Vec<u32>,
1167    },
1168    /// Packed `[dx ∥ dscale ∥ dshift]` for [`Op::AdaLayerNormBackward`].
1169    AdaLayerNormBackward {
1170        x: usize,
1171        scale: usize,
1172        shift: usize,
1173        dy: usize,
1174        dst: usize,
1175        rows: u32,
1176        h: u32,
1177        eps: f32,
1178        layer_norm: bool,
1179        mod_len: u32,
1180        mod_off: Vec<u32>,
1181    },
1182    /// Packed `[dx ∥ dy ∥ dgate]` for [`Op::GatedResidualBackward`].
1183    GatedResidualBackward {
1184        x: usize,
1185        y: usize,
1186        gate: usize,
1187        dy: usize,
1188        dst: usize,
1189        rows: u32,
1190        h: u32,
1191        gate_len: u32,
1192        gate_off: Vec<u32>,
1193    },
1194    /// Softmax
1195    Softmax {
1196        data: usize,
1197        rows: u32,
1198        cols: u32,
1199        /// Distance between successive elements along the softmax axis.
1200        ///
1201        /// `1` for the usual last-axis softmax, where a row is contiguous.
1202        /// Anything else means the axis is *not* last — `[N,C,D,H,W]` with
1203        /// `axis=1` gives `inner = D·H·W` — and the contiguous kernels would
1204        /// silently normalise over the wrong elements.
1205        inner: u32,
1206    },
1207    /// Inclusive (or exclusive) cumulative sum along the last axis
1208    /// (callers pre-flatten higher-dim cumsums via reshape views).
1209    Cumsum {
1210        src: usize,
1211        dst: usize,
1212        rows: u32,
1213        cols: u32,
1214        exclusive: bool,
1215        /// Element dtype — the scan runs in f32 or i64 (integer position ids /
1216        /// ragged offsets, e.g. MOSS-TTS's `cumsum(attention_mask)-1` RoPE positions
1217        /// must NOT go through the f32 path, which reads the i64 buffer as garbage).
1218        dtype: rlx_ir::DType,
1219    },
1220    /// Inclusive (or exclusive) cumulative product / maximum along the last
1221    /// axis (f32). `is_max` selects max over product. Mirrors [`Thunk::Cumsum`]'s
1222    /// row/col flattening; the reference O(L) scan behind `Op::{CumProd, CumMax}`.
1223    CumScan {
1224        src: usize,
1225        dst: usize,
1226        rows: u32,
1227        cols: u32,
1228        exclusive: bool,
1229        is_max: bool,
1230    },
1231    /// Mamba-style selective scan (plan #15).
1232    /// Inputs: x, delta \[b,s,h\], a \[h,n\], b \[b,s,n\], c \[b,s,n\].
1233    /// Output: y \[b,s,h\]. State h carries through the seq.
1234    SelectiveScan {
1235        x: usize,
1236        delta: usize,
1237        a: usize,
1238        b: usize,
1239        c: usize,
1240        dst: usize,
1241        batch: u32,
1242        seq: u32,
1243        hidden: u32,
1244        state_size: u32,
1245    },
1246
1247    /// Gated DeltaNet linear-attention scan (Qwen3.5/3.6 trunk).
1248    /// Inputs: q, k, v `[b, s, h, n]`; g, beta `[b, s, h]`. Output:
1249    /// `[b, s, h, n]`. See `Op::GatedDeltaNet` for math.
1250    /// Fused backward of [`Thunk::GatedDeltaNet`]. `dst` is the packed
1251    /// gradient bundle described by `rlx_ir::GdnBackwardLayout`.
1252    GatedDeltaNetBackward {
1253        q: usize,
1254        k: usize,
1255        v: usize,
1256        g: usize,
1257        beta: usize,
1258        /// Initial `[b, h, n, n]` state; meaningful only when `carry_state`.
1259        state: usize,
1260        dy: usize,
1261        dst: usize,
1262        batch: u32,
1263        seq: u32,
1264        heads: u32,
1265        state_size: u32,
1266        gate_per_channel: bool,
1267        carry_state: bool,
1268    },
1269
1270    GatedDeltaNet {
1271        q: usize,
1272        k: usize,
1273        v: usize,
1274        g: usize,
1275        beta: usize,
1276        /// Arena offset of the initial `[b, h, n, n]` state (valid only when
1277        /// `carry_state`; may legitimately be 0). When carrying, the initial
1278        /// state is loaded from here and the final state written back in place.
1279        state: usize,
1280        dst: usize,
1281        batch: u32,
1282        seq: u32,
1283        heads: u32,
1284        state_size: u32,
1285        /// When true (Kimi-K3 KDA), `g` is `[b, s, h, n]` (per key channel).
1286        gate_per_channel: bool,
1287        /// When true, resume from / write back the external `state` (decode).
1288        /// An explicit flag — NOT `state != 0`, since offset 0 is a valid slot.
1289        carry_state: bool,
1290    },
1291
1292    /// Multi-layer (optionally bidirectional, optional carry) LSTM with
1293    /// packed weights. See `Op::Lstm`. `h0`/`c0` are valid only when
1294    /// `carry`; `dst` is `[b, s, D*h]`.
1295    Lstm {
1296        x: usize,
1297        w_ih: usize,
1298        w_hh: usize,
1299        bias: usize,
1300        h0: usize,
1301        c0: usize,
1302        dst: usize,
1303        batch: u32,
1304        seq: u32,
1305        input_size: u32,
1306        hidden: u32,
1307        num_layers: u32,
1308        bidirectional: bool,
1309        carry: bool,
1310    },
1311    /// GRU (gate order r, z, n) with separate `b_ih`/`b_hh`. See `Op::Gru`.
1312    Gru {
1313        x: usize,
1314        w_ih: usize,
1315        w_hh: usize,
1316        b_ih: usize,
1317        b_hh: usize,
1318        h0: usize,
1319        dst: usize,
1320        batch: u32,
1321        seq: u32,
1322        input_size: u32,
1323        hidden: u32,
1324        num_layers: u32,
1325        bidirectional: bool,
1326        carry: bool,
1327    },
1328    /// Elman RNN (`tanh`/`relu`), single merged bias. See `Op::Rnn`.
1329    Rnn {
1330        x: usize,
1331        w_ih: usize,
1332        w_hh: usize,
1333        bias: usize,
1334        h0: usize,
1335        dst: usize,
1336        batch: u32,
1337        seq: u32,
1338        input_size: u32,
1339        hidden: u32,
1340        num_layers: u32,
1341        bidirectional: bool,
1342        carry: bool,
1343        relu: bool,
1344    },
1345    /// Mamba-2 / SSD scalar-decay SSM scan. See `Op::Mamba2`.
1346    Mamba2 {
1347        x: usize,
1348        dt: usize,
1349        a: usize,
1350        b: usize,
1351        c: usize,
1352        dst: usize,
1353        batch: u32,
1354        seq: u32,
1355        heads: u32,
1356        head_dim: u32,
1357        state_size: u32,
1358    },
1359
1360    /// 1×1 conv fast path (plan #26). The general Conv2D thunk
1361    /// runs the textbook 7-deep loop; a 1×1 stride-1 padding-0
1362    /// groups-1 conv is mathematically a per-batch matmul, and
1363    /// dispatching it through BLAS is 3-10× faster than the
1364    /// scalar nest. Common case: ViT patch-projection follow-on,
1365    /// transformer "expert" reductions in some MoE designs.
1366    ///
1367    /// Per batch: weight `[c_out, c_in]` × input `[c_in, h*w]`
1368    ///         = output `[c_out, h*w]`.
1369    Conv2D1x1 {
1370        src: usize,
1371        weight: usize,
1372        dst: usize,
1373        n: u32,
1374        c_in: u32,
1375        c_out: u32,
1376        hw: u32,
1377    },
1378
1379    /// Fused dequant + matmul (plan #5). Today supports
1380    /// `QuantScheme::Int8Block` (symmetric); other schemes panic
1381    /// at lowering time with a clear message until kernels are added.
1382    DequantMatMul {
1383        x: usize,
1384        w_q: usize,   // packed i8 bytes for Int8 schemes
1385        scale: usize, // [k/block, n] f32 scale
1386        zp: usize,    // [k/block, n] f32 zero-point (0 for sym)
1387        dst: usize,
1388        m: u32,
1389        k: u32,
1390        n: u32,
1391        block_size: u32,
1392        is_asymmetric: bool,
1393    },
1394
1395    /// Codebook weight-synthesis matmul (single-level vector quant).
1396    /// Weight is stored transposed `[n, k]`; each contiguous
1397    /// `entry_dim`-length sub-vector along `k` is replaced by a learned
1398    /// codebook centroid `codebook[index]`. The centroid is reconstructed
1399    /// inside the matmul inner loop — the weight is never materialized.
1400    SynthMatMul {
1401        x: usize,        // [m, k] f32 activations
1402        indices: usize,  // [n, k/entry_dim] u8 codebook indices
1403        codebook: usize, // [num_entries, entry_dim] f32 centroids
1404        dst: usize,      // [m, n] f32 output
1405        m: u32,
1406        k: u32,
1407        n: u32,
1408        entry_dim: u32,
1409        num_entries: u32,
1410    },
1411
1412    /// KAN Gaussian-RBF spline activation (per-channel learned basis).
1413    /// Shape-preserving: `y[.., c] = Σ_g coeff[c,g]·exp(-((x−center_g)·inv_h)²)`.
1414    SplineActivation {
1415        x: usize,     // [rows, channels] f32
1416        coeff: usize, // [channels, num_basis] f32
1417        dst: usize,   // [rows, channels] f32
1418        rows: u32,
1419        channels: u32,
1420        num_basis: u32,
1421        grid_min: f32,
1422        grid_max: f32,
1423    },
1424
1425    /// GGUF-format dequant + matmul. Weight is a packed byte tensor
1426    /// in one of the K-quant super-block layouts (Q4_K, Q5_K, Q6_K,
1427    /// Q8_K). Scales / mins live inside the packed bytes — no
1428    /// side-channel scale tensor.
1429    ///
1430    /// Today this is a "dequant-to-scratch then sgemm" kernel — it
1431    /// keeps the *arena* memory footprint down (weights stay packed)
1432    /// but the dequant itself happens per matmul. A future fully
1433    /// fused tile-streaming kernel would close the compute gap.
1434    DequantMatMulGguf {
1435        x: usize,   // f32 activations [m, k]
1436        w_q: usize, // packed weight bytes (k*n elements packed)
1437        dst: usize, // f32 output [m, n]
1438        m: u32,
1439        k: u32,
1440        n: u32,
1441        scheme: rlx_ir::quant::QuantScheme,
1442    },
1443
1444    /// Int4 block dequant + matmul (packed nibbles, side scale/zp).
1445    DequantMatMulInt4 {
1446        x: usize,
1447        w_q: usize,
1448        scale: usize,
1449        zp: usize,
1450        dst: usize,
1451        m: u32,
1452        k: u32,
1453        n: u32,
1454        block_size: u32,
1455        is_asymmetric: bool,
1456    },
1457
1458    /// FP8 dequant + matmul (per-tensor or per-column scale).
1459    DequantMatMulFp8 {
1460        x: usize,
1461        w_q: usize,
1462        scale: usize,
1463        dst: usize,
1464        m: u32,
1465        k: u32,
1466        n: u32,
1467        e5m2: bool,
1468    },
1469
1470    /// NVFP4 (E2M1) block dequant + matmul — 16-wide groups, FP8 scales.
1471    DequantMatMulNvfp4 {
1472        x: usize,
1473        w_q: usize,
1474        scale: usize,
1475        global_scale: usize,
1476        dst: usize,
1477        m: u32,
1478        k: u32,
1479        n: u32,
1480    },
1481
1482    /// MxFp4x2 two-level residual E2M1 DequantMatMul. `w_q` = `[plane0|plane1]`
1483    /// nibbles, `scale` = `[s0|s1]` f32 per (k/`group`, n) block.
1484    DequantMatMulMxFp4x2 {
1485        x: usize,
1486        w_q: usize,
1487        scale: usize,
1488        dst: usize,
1489        m: u32,
1490        k: u32,
1491        n: u32,
1492        group: u32,
1493    },
1494
1495    /// MLX affine / mxfp DequantMatMul — host dequant via `rlx-mlx-io`.
1496    DequantMatMulMlx {
1497        x: usize,
1498        w_q: usize,
1499        scale: usize,
1500        zp: usize,
1501        dst: usize,
1502        m: u32,
1503        k: u32,
1504        n: u32,
1505        scheme: rlx_ir::quant::QuantScheme,
1506    },
1507
1508    /// Native low-precision scaled GEMM (FP8/FP6/FP4) — CPU reference oracle.
1509    /// TN layout: lhs `m,k`, rhs `n,k` codes, out `m,n` f32.
1510    ScaledMatMul {
1511        lhs: usize,
1512        rhs: usize,
1513        lhs_scale: usize,
1514        rhs_scale: usize,
1515        bias: usize, // valid iff has_bias
1516        dst: usize,
1517        m: u32,
1518        k: u32,
1519        n: u32,
1520        lhs_fmt: rlx_ir::ScaledFormat,
1521        rhs_fmt: rlx_ir::ScaledFormat,
1522        layout: rlx_ir::ScaleLayout,
1523        has_bias: bool,
1524    },
1525
1526    /// Native low-precision *grouped* (MoE) TN GEMM — expert-indexed
1527    /// `ScaledMatMul`. `weight [E,n,k]` codes, per-expert scales/bias.
1528    ScaledGroupedMatMul {
1529        input: usize,
1530        weight: usize,
1531        input_scale: usize,
1532        weight_scale: usize,
1533        expert_idx: usize,
1534        bias: usize, // valid iff has_bias
1535        dst: usize,
1536        m: u32,
1537        k: u32,
1538        n: u32,
1539        num_experts: u32,
1540        lhs_fmt: rlx_ir::ScaledFormat,
1541        rhs_fmt: rlx_ir::ScaledFormat,
1542        layout: rlx_ir::ScaleLayout,
1543        has_bias: bool,
1544    },
1545
1546    /// Quantize f32 → packed low-precision codes (reads the scale tensor).
1547    ScaledQuantize {
1548        x: usize,
1549        scale: usize,
1550        dst: usize,
1551        rows: u32,
1552        cols: u32,
1553        fmt: rlx_ir::ScaledFormat,
1554        layout: rlx_ir::ScaleLayout,
1555    },
1556
1557    /// Compute the per-tensor / per-block scale tensor for `Op::ScaledQuantize`.
1558    ScaledQuantScale {
1559        x: usize,
1560        dst: usize,
1561        rows: u32,
1562        cols: u32,
1563        fmt: rlx_ir::ScaledFormat,
1564        layout: rlx_ir::ScaleLayout,
1565    },
1566
1567    /// Reconstruct f32 from packed codes (`Op::ScaledDequantize`).
1568    ScaledDequantize {
1569        codes: usize,
1570        scale: usize,
1571        dst: usize,
1572        rows: u32,
1573        cols: u32,
1574        fmt: rlx_ir::ScaledFormat,
1575        layout: rlx_ir::ScaleLayout,
1576    },
1577
1578    /// Fused LoRA matmul (plan #9): out = x·W + scale * (x·A)·B.
1579    /// `r` is the LoRA rank (typically 4-64) — the rank-r
1580    /// intermediate `x·A` lives in scratch, never on the arena.
1581    LoraMatMul {
1582        x: usize,
1583        w: usize,
1584        a: usize,
1585        b: usize,
1586        dst: usize,
1587        m: u32,
1588        k: u32,
1589        n: u32,
1590        r: u32,
1591        scale: f32,
1592    },
1593    /// Fused sample: logits [batch, vocab] → token ids \[batch\].
1594    /// See Op::Sample. Output values are f32-encoded usize indices
1595    /// (matches the rest of the IR's "ids as f32" convention).
1596    Sample {
1597        logits: usize,
1598        dst: usize,
1599        batch: u32,
1600        vocab: u32,
1601        top_k: u32,       // 0 = disabled
1602        top_p: f32,       // 1.0 = disabled
1603        temperature: f32, // 1.0 = neutral
1604        seed: u64,
1605    },
1606    /// ONNX `RandomNormalLike` fill.
1607    RngNormal {
1608        dst: usize,
1609        len: u32,
1610        mean: f32,
1611        scale: f32,
1612        key: u64,
1613        op_seed: Option<f32>,
1614    },
1615    /// ONNX `RandomUniformLike` fill.
1616    RngUniform {
1617        dst: usize,
1618        len: u32,
1619        low: f32,
1620        high: f32,
1621        key: u64,
1622        op_seed: Option<f32>,
1623    },
1624    /// Attention SDPA. `mask` is the offset of the optional mask tensor
1625    /// (only meaningful when `mask_kind == MaskKind::Custom`); other
1626    /// kinds synthesize the mask in-kernel.
1627    ///
1628    /// Q/K/V each carry a `_row_stride` (elements per source row).
1629    /// Defaults to `heads * head_dim` — matches the standalone
1630    /// "Q/K/V are their own contiguous buffers" case. The Narrow→
1631    /// Attention fusion below rewrites these to the parent QKV stride
1632    /// (typically `3 * heads * head_dim`) so the kernel reads QKV
1633    /// directly without materializing the per-head buffers (plan #46).
1634    Attention {
1635        q: usize,
1636        k: usize,
1637        v: usize,
1638        mask: usize,
1639        out: usize,
1640        batch: u32,
1641        /// Query sequence length.
1642        seq: u32,
1643        /// Key/value sequence length. Differs from `seq` during cached decode.
1644        kv_seq: u32,
1645        heads: u32,
1646        /// GQA/MQA: number of key/value heads that the query heads share.
1647        /// Equals `heads` for plain MHA (the common case); only `< heads` on
1648        /// the non-fused standalone `Op::Attention` path.
1649        kv_heads: u32,
1650        head_dim: u32,
1651        /// V/output per-head width. Equals `head_dim` for symmetric SDPA; only
1652        /// `!= head_dim` for MLA-style asymmetric attention (V read + output
1653        /// write use this; Q/K scores still use `head_dim`).
1654        v_head_dim: u32,
1655        mask_kind: rlx_ir::op::MaskKind,
1656        /// Softmax score scale (`Op::Attention::score_scale`). `head_dim^-0.5`
1657        /// when the op left it unset. Must be honored — Gemma 4 uses `1.0`
1658        /// (Q/K are per-head RMS-normed, so no `1/sqrt(d)` pre-scale).
1659        scale: f32,
1660        /// Attention logit soft-cap (`Op::Attention::attn_logit_softcap`).
1661        /// `cap*tanh(score/cap)` applied pre-softmax; 0 = disabled. Gemma 2
1662        /// uses 50.0 — must be honored to match HF / rlx-metal / rlx-cuda.
1663        softcap: f32,
1664        q_row_stride: u32,
1665        k_row_stride: u32,
1666        v_row_stride: u32,
1667        /// Memory layout flag. `false` (the historical default) →
1668        /// `[B, S, H, D]` row-major: per-head offset is
1669        /// `bi*S*H*D + si*H*D + hi*D`. `true` → `[B, H, S, D]`
1670        /// (head-major), matching the convention used by rlx-cuda /
1671        /// rlx-rocm / rlx-tpu: per-head offset is
1672        /// `bi*H*S*D + hi*S*D + si*D`. Detected at lowering time
1673        /// from the input shape vs `num_heads` / `head_dim`.
1674        bhsd: bool,
1675    },
1676    /// [`Op::AttentionBackward`] — emits dQ, dK, or dV (see `wrt`).
1677    AttentionBackward {
1678        q: usize,
1679        k: usize,
1680        v: usize,
1681        dy: usize,
1682        mask: usize,
1683        out: usize,
1684        batch: u32,
1685        seq: u32,
1686        kv_seq: u32,
1687        heads: u32,
1688        head_dim: u32,
1689        mask_kind: rlx_ir::op::MaskKind,
1690        wrt: rlx_ir::op::AttentionBwdWrt,
1691        bhsd: bool,
1692    },
1693    /// [`rlx_ir::Op::AttentionBackwardAll`] — computes dQ, dK and dV from ONE
1694    /// score+softmax recompute, writing them into three disjoint sub-ranges of
1695    /// this node's own packed `[3B,S,H,D]` output slot (`out_q`/`out_k`/`out_v`
1696    /// are byte offsets into the same slot, so the planner manages it as one
1697    /// buffer). Downstream axis-0 `Narrow`s (view ops) recover dQ/dK/dV.
1698    AttentionBackwardAll {
1699        q: usize,
1700        k: usize,
1701        v: usize,
1702        dy: usize,
1703        mask: usize,
1704        out_q: usize,
1705        out_k: usize,
1706        out_v: usize,
1707        batch: u32,
1708        seq: u32,
1709        kv_seq: u32,
1710        heads: u32,
1711        head_dim: u32,
1712        mask_kind: rlx_ir::op::MaskKind,
1713        bhsd: bool,
1714    },
1715    /// RoPE (rotary position embeddings).
1716    /// `src_row_stride` is elements per source row (defaults to `hidden`
1717    /// for the standalone case; set to `qkv_axis * inner` when the
1718    /// thunk fusion pass below rewires Rope to read directly from the
1719    /// fused QKV buffer — plan #45).
1720    Rope {
1721        src: usize,
1722        cos: usize,
1723        sin: usize,
1724        dst: usize,
1725        batch: u32,
1726        seq: u32,
1727        hidden: u32,
1728        head_dim: u32,
1729        n_rot: u32,
1730        cos_len: u32,
1731        /// Elements per position in the cos/sin tables — the table's own last
1732        /// dimension, NOT something derived from `head_dim` or `n_rot`.
1733        ///
1734        /// The two are not the same under partial rotation and the layout is a
1735        /// per-model choice: Qwen3.5 allocates `[max_pos, head_dim/2]` and uses
1736        /// the leading `n_rot/2` columns of each row, while DeepSeek-V4 MLA
1737        /// packs `[.., n_rot/2]` with no slack. Assuming either one reads the
1738        /// wrong row for every position past the first on the other, so take it
1739        /// from the shape instead of guessing.
1740        cos_row_stride: u32,
1741        src_row_stride: u32,
1742        /// `true` = GPT-J / llama.cpp-NORM interleaved pairs `(2i, 2i+1)`;
1743        /// `false` = HF / NeoX rotate-half pairs `(i, i+n_rot/2)`.
1744        interleaved: bool,
1745    },
1746    /// Fused attention block: QKV proj → split → \[RoPE\] → SDPA → output proj.
1747    /// All intermediates stay in L1 cache. Zero arena writes between ops.
1748    FusedAttnBlock {
1749        hidden: usize,
1750        qkv_w: usize,
1751        out_w: usize,
1752        mask: usize,
1753        /// How to mask attention scores. `Custom` reads the per-key `mask`
1754        /// buffer (BERT-style padding); `Causal` / `SlidingWindow` are
1755        /// synthesized in-kernel with no buffer; `None` applies no mask.
1756        /// Mirrors [`Thunk::Attention`]'s `mask_kind` — the attention-block
1757        /// fusion MUST carry it through, otherwise a causal decoder silently
1758        /// attends to future tokens (the fused kernel would only ever apply
1759        /// the per-key padding mask).
1760        mask_kind: rlx_ir::op::MaskKind,
1761        out: usize,
1762        qkv_b: usize,
1763        out_b: usize, // 0 = no bias
1764        cos: usize,
1765        sin: usize,
1766        cos_len: u32, // 0 = no RoPE
1767        batch: u32,
1768        seq: u32,
1769        hs: u32,
1770        nh: u32,
1771        dh: u32,
1772        /// QKV-projection input width (`in_dim`) and output-projection output
1773        /// width (`out_dim`). Both equal `hs` in the BERT case; MoonViT-style
1774        /// attention has `in_dim != hs` (input hidden ≠ nh·dh) and/or
1775        /// `out_dim != hs`. The kernel reads the input as `m·in_dim`, runs the
1776        /// QKV GEMM with K=in_dim, the out GEMM with N=out_dim, and writes
1777        /// `m·out_dim`; the attention core still works on the `hs`-wide qkv.
1778        in_dim: u32,
1779        out_dim: u32,
1780        has_bias: bool,
1781        has_rope: bool,
1782        /// RoPE pairing: `true` = GPT-J interleaved `(2i,2i+1)`, `false` = NeoX
1783        /// rotate-half `(i,i+d/2)`. Captured from the fused `Op::Rope` so the
1784        /// inline rope matches the standalone kernel (a GptJ model must not be
1785        /// silently rotated NeoX-style by the fused path).
1786        interleaved: bool,
1787    },
1788    /// Fused ENTIRE transformer layer: attention + residual + LN + FFN + residual + LN.
1789    /// Combines ~10 thunks into 1. All intermediates on stack. Zero arena traffic.
1790    FusedBertLayer {
1791        // attention
1792        hidden: usize,
1793        qkv_w: usize,
1794        qkv_b: usize,
1795        out_w: usize,
1796        out_b: usize,
1797        mask: usize,
1798        // LN1
1799        ln1_g: usize,
1800        ln1_b: usize,
1801        eps1: f32,
1802        // FFN (GELU)
1803        fc1_w: usize,
1804        fc1_b: usize,
1805        fc2_w: usize,
1806        fc2_b: usize,
1807        // LN2
1808        ln2_g: usize,
1809        ln2_b: usize,
1810        eps2: f32,
1811        // output
1812        out: usize,
1813        // dims
1814        batch: u32,
1815        seq: u32,
1816        hs: u32,
1817        nh: u32,
1818        dh: u32,
1819        int_dim: u32,
1820    },
1821    /// Fused Nomic transformer layer: attention+RoPE + residual + LN + SwiGLU FFN + residual + LN.
1822    FusedNomicLayer {
1823        hidden: usize,
1824        qkv_w: usize,
1825        out_w: usize,
1826        mask: usize,
1827        cos: usize,
1828        sin: usize,
1829        cos_len: u32,
1830        ln1_g: usize,
1831        ln1_b: usize,
1832        eps1: f32,
1833        fc11_w: usize,
1834        fc12_w: usize,
1835        fc2_w: usize,
1836        ln2_g: usize,
1837        ln2_b: usize,
1838        eps2: f32,
1839        out: usize,
1840        batch: u32,
1841        seq: u32,
1842        hs: u32,
1843        nh: u32,
1844        dh: u32,
1845        int_dim: u32,
1846        /// RoPE pairing, threaded from the consumed `FusedAttnBlock`
1847        /// (`true` = GPT-J interleaved, `false` = NeoX rotate-half).
1848        interleaved: bool,
1849    },
1850    /// Fused SwiGLU: out\[r,i\] = x\[r,i\] * silu(x[r, n_half+i]).
1851    /// Input: [outer, 2*n_half] — concatenated up||gate per row.
1852    /// Output: [outer, n_half].
1853    FusedSwiGLU {
1854        src: usize,
1855        dst: usize,
1856        n_half: u32,
1857        total: u32,
1858        gate_first: bool,
1859    },
1860    /// In-place KV-cache append: write one row into `dst` at sequence index
1861    /// `pos`. `dst` is the cache buffer (the op's output aliases it), so this
1862    /// touches `outer * inner` elements regardless of how long the context is,
1863    /// where the `concat(past, row)` it replaces re-copies the whole cache
1864    /// every step.
1865    ///
1866    /// Layout mirrors the GPU backends' thunk of the same name: byte-slice
1867    /// `o` of the row lands at `o * seq_cap * inner_bytes + pos * inner_bytes`.
1868    /// `outer == 1` (the usual `[1, seq, heads*dim]` cache with `axis = 1`)
1869    /// makes it a single contiguous `memcpy`.
1870    ///
1871    /// Sized in BYTES, not elements, and copied as raw bytes: an append moves
1872    /// data without touching its values, so it needs no dtype-specific variant
1873    /// the way `Concat`/`ConcatF64` do — and a `memcpy` beats an element loop.
1874    KvAppend {
1875        src: usize,
1876        dst: usize,
1877        outer: u32,
1878        seq_cap: u32,
1879        pos: u32,
1880        inner_bytes: u32,
1881    },
1882    /// Concat along an axis: output[outer, axis, inner] = inputs concatenated.
1883    /// Each entry of `inputs` is (src_offset, axis_len_for_that_input) in u32
1884    /// elements. `outer`, `inner`, and `total_axis_len` are pre-computed
1885    /// at compile time to avoid per-run shape work.
1886    Concat {
1887        dst: usize,
1888        outer: u32,
1889        inner: u32,
1890        total_axis: u32,
1891        /// `(src_offset, axis_extent, input_numel)` — `input_numel` enables
1892        /// outer-dim broadcast when rank-deficient inputs are concatenated.
1893        inputs: Vec<(usize, u32, u32)>,
1894    },
1895    /// Element-wise comparison: out = (lhs CMP rhs) ? 1 : 0 (Bool u8 or F32 0/1).
1896    /// `lhs_scalar` / `rhs_scalar` broadcast a 1-element operand across `len`
1897    /// (ONNX Less/Greater with a scalar Constant).
1898    Compare {
1899        lhs: usize,
1900        rhs: usize,
1901        dst: usize,
1902        len: u32,
1903        op: CmpOp,
1904        /// Nonzero when lhs/rhs are i64 (mask/range ops).
1905        inputs_i64: u8,
1906        /// Input element size (1 = Bool, 4 = F32, 8 = I64).
1907        inputs_elem_bytes: u8,
1908        /// Output element size (1 = Bool, 4 = F32).
1909        dst_elem_bytes: u8,
1910        lhs_scalar: bool,
1911        rhs_scalar: bool,
1912        /// TRUE element counts of each operand. Needed when an operand is a
1913        /// *partial* broadcast (`[.,L,1]` into `[.,L,L]`): its numel is neither
1914        /// 1 (scalar) nor `len` (full), so the flat fast path would read past
1915        /// it into adjacent arena memory.
1916        lhs_len: u32,
1917        rhs_len: u32,
1918        /// Shape-aware broadcast metadata (mirrors `BinaryFull`). Empty
1919        /// `out_dims_bcast` selects the flat scalar/full fast path; when
1920        /// non-empty, each output element indexes its operands via row-major
1921        /// coords · strides — the ONLY correct path for a trailing/partial
1922        /// size-1 broadcast, which the flat path silently miscomputes.
1923        out_dims_bcast: Vec<u32>,
1924        bcast_lhs_strides: Vec<u32>,
1925        bcast_rhs_strides: Vec<u32>,
1926    },
1927    /// Reduction along a contiguous range of axes. Input layout (after
1928    /// shape decomposition) is `[outer, reduced, inner]`; output is
1929    /// `[outer, inner]`. The single-axis cases (axis=0 → outer=1;
1930    /// axis=last → inner=1) and contiguous multi-axis (e.g. reduce over
1931    /// [0, 1] of an [N, C, H, W] tensor → outer=1, reduced=N*C, inner=H*W)
1932    /// all map onto this triplet. Non-contiguous axes are not supported
1933    /// and bail to Nop in the compile pass.
1934    Reduce {
1935        src: usize,
1936        dst: usize,
1937        outer: u32,
1938        reduced: u32,
1939        inner: u32,
1940        op: ReduceOp,
1941    },
1942    /// Histogram: bucket `n` input elements into `bins` equal-width buckets over
1943    /// `[min, max]` and write f32 counts into `dst[bins]`. Out-of-range elements
1944    /// are dropped; `x == max` lands in the last bin. See `Op::Histogram`.
1945    Histogram {
1946        src: usize,
1947        dst: usize,
1948        n: u32,
1949        bins: u32,
1950        min: f32,
1951        max: f32,
1952    },
1953    /// Index of the max (`is_max`) or min along the reduced axis; writes the
1954    /// winning index as an f32 into `dst`.
1955    ArgReduce {
1956        src: usize,
1957        dst: usize,
1958        outer: u32,
1959        reduced: u32,
1960        inner: u32,
1961        is_max: bool,
1962    },
1963    /// Top-K **indices** along the last axis. Input shape `[outer, axis_dim]`,
1964    /// output `[outer, k]` (f32 or i64 per `indices_i64`). Ties broken by
1965    /// smaller index. Used by MoE gating + beam search.
1966    TopK {
1967        src: usize,
1968        dst: usize,
1969        outer: u32,
1970        axis_dim: u32,
1971        k: u32,
1972        indices_i64: u8,
1973    },
1974    /// Indexed batched matmul: out\[i\] = input\[i\] @ weight[expert_idx\[i\]].
1975    /// Naive impl per token; for real MoE workloads, sort-by-expert + run
1976    /// segmented GEMM would amortize. Done when there's a workload.
1977    GroupedMatMul {
1978        input: usize,
1979        weight: usize,
1980        expert_idx: usize,
1981        dst: usize,
1982        m: u32,
1983        k_dim: u32,
1984        n: u32,
1985        num_experts: u32,
1986        /// `weight` holds `[E, N, K]` rather than `[E, K, N]`, so each expert's
1987        /// GEMM runs with B transposed instead of materializing the swap.
1988        ///
1989        /// Set when the compiler folds a last-two-axis `Transpose` feeding this
1990        /// node. See `compile_dispatch`'s fold pass for why it matters: GGUF
1991        /// stores expert banks as `[E, out, in]` and this op wants
1992        /// `[E, in, out]`, so a dense MoE layer transposes every bank on every
1993        /// forward — measured as ~99% of a paged GLM-5.3-Flash layer.
1994        w_transposed: bool,
1995    },
1996    /// GGUF K-quant packed expert stack + grouped matmul (MoE FFN).
1997    DequantGroupedMatMulGguf {
1998        input: usize,
1999        w_q: usize,
2000        expert_idx: usize,
2001        dst: usize,
2002        m: u32,
2003        k_dim: u32,
2004        n: u32,
2005        num_experts: u32,
2006        scheme: rlx_ir::quant::QuantScheme,
2007    },
2008    /// MLX-affine grouped expert matmul: per row, dequant expert `idx[row]`'s
2009    /// affine slab (codes+scales+biases) and matmul. `[M,K]×dequant([N,K])→[M,N]`.
2010    DequantGroupedMatMulMlx {
2011        input: usize,
2012        w_q: usize,
2013        scale: usize,
2014        zp: usize,
2015        expert_idx: usize,
2016        dst: usize,
2017        m: u32,
2018        k_dim: u32,
2019        n: u32,
2020        num_experts: u32,
2021        /// Packed-code bytes per expert (`total_w_q_bytes / num_experts`).
2022        slab_bytes: u32,
2023        scheme: rlx_ir::quant::QuantScheme,
2024        /// Scales/biases stored as BF16 (2 bytes) instead of F32 — halves the
2025        /// resident scale memory (the MoE scale slabs rival the packed codes at
2026        /// 2-bit). Decoded to f32 per-expert on the fly.
2027        scale_bf16: bool,
2028    },
2029    /// Materialize packed MoE weights to F32 `[E, K, N]` (autodiff helper).
2030    DequantMoEWeightsGguf {
2031        w_q: usize,
2032        dst: usize,
2033        k_dim: u32,
2034        n: u32,
2035        num_experts: u32,
2036        scheme: rlx_ir::quant::QuantScheme,
2037    },
2038    /// Scatter-add: dst[indices\[i\] * trailing + j] += updates[i * trailing + j].
2039    /// Output is zeroed first; multiple updates to the same row accumulate.
2040    ScatterAdd {
2041        updates: usize,
2042        indices: usize,
2043        dst: usize,
2044        num_updates: u32,
2045        out_dim: u32,
2046        trailing: u32,
2047    },
2048    /// ONNX ScatterND: copy `data`, write `updates` at multi-index locations.
2049    /// Inputs `[data, indices, updates]`; `indices_i64` selects I64 vs F32 index storage.
2050    ScatterNd {
2051        data: usize,
2052        indices: usize,
2053        updates: usize,
2054        dst: usize,
2055        data_shape: Vec<u32>,
2056        indices_shape: Vec<u32>,
2057        data_len: u32,
2058        updates_len: u32,
2059        indices_len: u32,
2060        indices_i64: u8,
2061        reduction: rlx_ir::ScatterNdReduction,
2062    },
2063    /// ONNX ScatterElements along `axis`.
2064    ScatterElements {
2065        data: usize,
2066        indices: usize,
2067        updates: usize,
2068        dst: usize,
2069        data_shape: Vec<u32>,
2070        /// Shape of `indices`. ONNX lets it be *smaller* than `data` along any
2071        /// axis, and then the flat position of an index decomposes by the
2072        /// indices' strides, not the data's — without this the kernel has to
2073        /// guess and gets every row but the first wrong.
2074        indices_shape: Vec<u32>,
2075        data_len: u32,
2076        updates_len: u32,
2077        indices_len: u32,
2078        indices_i64: u8,
2079        axis: i32,
2080        reduction: rlx_ir::ScatterNdReduction,
2081    },
2082    /// ONNX GatherND.
2083    GatherNd {
2084        data: usize,
2085        indices: usize,
2086        dst: usize,
2087        data_shape: Vec<u32>,
2088        indices_shape: Vec<u32>,
2089        data_len: u32,
2090        indices_len: u32,
2091        out_len: u32,
2092        indices_i64: u8,
2093        batch_dims: i32,
2094    },
2095    /// ONNX GatherElements / take_along_axis.
2096    GatherElements {
2097        data: usize,
2098        indices: usize,
2099        dst: usize,
2100        data_shape: Vec<u32>,
2101        indices_shape: Vec<u32>,
2102        data_len: u32,
2103        indices_len: u32,
2104        out_len: u32,
2105        indices_i64: u8,
2106        /// Byte width of a data/output element (GatherElements preserves dtype).
2107        /// Non-f32 data (e.g. int64 token ids) must be gathered at its true width.
2108        data_elem_bytes: u8,
2109        axis: i32,
2110    },
2111    /// Ternary select: out = cond != 0 ? on_true : on_false.
2112    /// `*_scalar` broadcast a 1-element operand across `len`.
2113    Where {
2114        cond: usize,
2115        on_true: usize,
2116        on_false: usize,
2117        dst: usize,
2118        len: u32,
2119        elem_bytes: u8,
2120        /// Element size for cond (1 = Bool mask, 4 = F32 0/1).
2121        cond_elem_bytes: u8,
2122        cond_scalar: bool,
2123        true_scalar: bool,
2124        false_scalar: bool,
2125    },
2126    /// Single-rounded fused multiply-add: `dst = a*b + c` (one rounding —
2127    /// enables error-free transforms / compensated arithmetic).
2128    Fma {
2129        a: usize,
2130        b: usize,
2131        c: usize,
2132        dst: usize,
2133        len: u32,
2134        elem_bytes: u8,
2135    },
2136    /// General N-D transpose / broadcast. `out_dims[i]` is the output's dim
2137    /// i length; `in_strides[i]` is the input stride (in elements) used to
2138    /// index that dim — 0 for broadcast dims (Expand). `in_total` is the
2139    /// total element count in the source buffer (≤ output total when
2140    /// broadcasting). Strides are pre-computed at compile time.
2141    Transpose {
2142        src: usize,
2143        dst: usize,
2144        in_total: u32,
2145        out_dims: Vec<u32>,
2146        in_strides: Vec<u32>,
2147        elem_bytes: u8,
2148    },
2149    /// Gather along an arbitrary axis. `outer = product(dims[..axis])`,
2150    /// `trailing = product(dims[axis+1..])`, `axis_dim` = the dimension
2151    /// being indexed into. Output: outer × num_idx × trailing.
2152    /// (axis=0 still routes to the simpler Thunk::Gather fast path.)
2153    GatherAxis {
2154        table: usize,
2155        idx: usize,
2156        dst: usize,
2157        outer: u32,
2158        axis_dim: u32,
2159        num_idx: u32,
2160        trailing: u32,
2161        idx_i64: u8,
2162        table_bytes: u8,
2163    },
2164    /// 2D pooling (Max or Mean). Input layout [N, C, H, W], output
2165    /// [N, C, H_out, W_out]. Padding is implicit-zero; Mean divides by
2166    /// the full kernel area (matches torch's `count_include_pad=True`).
2167    Pool2D {
2168        src: usize,
2169        dst: usize,
2170        n: u32,
2171        c: u32,
2172        h: u32,
2173        w: u32,
2174        h_out: u32,
2175        w_out: u32,
2176        kh: u32,
2177        kw: u32,
2178        sh: u32,
2179        sw: u32,
2180        ph: u32,
2181        pw: u32,
2182        kind: ReduceOp,
2183    },
2184    /// 3D pooling (Max or Mean). Input `[N, C, D, H, W]`, output
2185    /// `[N, C, D_out, H_out, W_out]`. Mean divides by the full kernel volume
2186    /// (`count_include_pad=True`).
2187    Pool3D {
2188        src: usize,
2189        dst: usize,
2190        n: u32,
2191        c: u32,
2192        d: u32,
2193        h: u32,
2194        w: u32,
2195        d_out: u32,
2196        h_out: u32,
2197        w_out: u32,
2198        kd: u32,
2199        kh: u32,
2200        kw: u32,
2201        sd: u32,
2202        sh: u32,
2203        sw: u32,
2204        pd: u32,
2205        ph: u32,
2206        pw: u32,
2207        kind: ReduceOp,
2208    },
2209    /// 2D convolution. Input [N, C_in, H, W], weight [C_out, C_in_per_group, kH, kW],
2210    /// output [N, C_out, H_out, W_out]. Bias is a separate Op::Binary::Add
2211    /// after the conv (matching the IR's input layout — Op::Conv has 2 inputs).
2212    /// Naive direct convolution; sufficient for correctness, not optimised.
2213    Conv2D {
2214        src: usize,
2215        weight: usize,
2216        dst: usize,
2217        n: u32,
2218        c_in: u32,
2219        h: u32,
2220        w: u32,
2221        c_out: u32,
2222        h_out: u32,
2223        w_out: u32,
2224        kh: u32,
2225        kw: u32,
2226        sh: u32,
2227        sw: u32,
2228        ph: u32,
2229        pw: u32,
2230        dh: u32,
2231        dw: u32,
2232        groups: u32,
2233    },
2234
2235    // ── Backward / training kernels ─────────────────────────────
2236    /// Real INT8 matmul with i32 accumulation.
2237    ///   `out[m, n] = requantize(bias[n] + Σₖ (x[m,k]-x_zp)·(w[k,n]-w_zp), mult, out_zp)`
2238    /// Reads `x` and `w` as i8, `bias` as i32; writes `out` as i8.
2239    /// Same kernel shape as `rlx_cortexm::dense::dense_i8` — promoted
2240    /// to a desktop thunk so a quantized graph compiled here doesn't
2241    /// have to round-trip through fake-quant.
2242    QMatMul {
2243        x: usize,
2244        w: usize,
2245        bias: usize,
2246        out: usize,
2247        m: u32,
2248        k: u32,
2249        n: u32,
2250        x_zp: i32,
2251        w_zp: i32,
2252        out_zp: i32,
2253        mult: f32,
2254    },
2255
2256    /// Real INT8 conv2d, NCHW layout. Same loop shape as `Thunk::Conv2D`
2257    /// but with i8 reads, i32 accumulation, and per-output requantize
2258    /// to i8. Bias is i32 in the accumulator scale.
2259    QConv2d {
2260        x: usize,
2261        w: usize,
2262        bias: usize,
2263        out: usize,
2264        n: u32,
2265        c_in: u32,
2266        h: u32,
2267        w_in: u32,
2268        c_out: u32,
2269        h_out: u32,
2270        w_out: u32,
2271        kh: u32,
2272        kw: u32,
2273        sh: u32,
2274        sw: u32,
2275        ph: u32,
2276        pw: u32,
2277        dh: u32,
2278        dw: u32,
2279        groups: u32,
2280        x_zp: i32,
2281        w_zp: i32,
2282        out_zp: i32,
2283        mult: f32,
2284    },
2285
2286    /// INT8 quantize. Reads `x` as f32, writes `q` as i8.
2287    /// `chan = (i / inner) % chan_dim` selects the per-channel
2288    /// scale/zp; `chan_axis` is informational only (the kernel uses
2289    /// `chan_dim` and `inner` directly).
2290    /// For per-tensor, `chan_dim = 1` and `inner = len` so `chan` is
2291    /// always 0.
2292    Quantize {
2293        x: usize,
2294        q: usize,
2295        len: u32,
2296        chan_axis: u32,
2297        chan_dim: u32,
2298        inner: u32,
2299        scales: Vec<f32>,
2300        zero_points: Vec<i32>,
2301    },
2302
2303    /// INT8 dequantize — inverse of `Thunk::Quantize`.
2304    Dequantize {
2305        q: usize,
2306        x: usize,
2307        len: u32,
2308        chan_axis: u32,
2309        chan_dim: u32,
2310        inner: u32,
2311        scales: Vec<f32>,
2312        zero_points: Vec<i32>,
2313    },
2314
2315    /// QAT fake-quantize. Per-channel (or per-tensor) symmetric
2316    /// quantize-then-dequantize on the fly. Computes
2317    ///   `s[c] = max(|x[..., c, ...]|) / q_max`
2318    /// then
2319    ///   `out[i] = clamp(round(x[i]/s[c]), -q_max, q_max) * s[c]`
2320    /// with `q_max = {127, 7, 1}` for `bits = {8, 4, 2}`. Same
2321    /// channel-layout convention as `Thunk::Quantize`: every
2322    /// element's channel is `(i / inner) % chan_dim`. The kernel
2323    /// does two passes — one to scan max-abs per channel, one to
2324    /// quant-dequant per element.
2325    FakeQuantize {
2326        x: usize,
2327        out: usize,
2328        len: u32,
2329        chan_axis: u32,
2330        chan_dim: u32,
2331        inner: u32,
2332        bits: u8,
2333        /// STE variant — informational on the forward side (output is
2334        /// the same regardless), kernel-relevant in the matching
2335        /// `FakeQuantizeBackward` thunk.
2336        ste: rlx_ir::op::SteKind,
2337        /// Scale-tracking strategy. `PerBatch` recomputes
2338        /// `max_abs/q_max` every call (the original path). `EMA{decay}`
2339        /// blends per-batch max-abs into the `state_off` buffer; `Fixed`
2340        /// reads `state_off` and never updates it.
2341        scale_mode: rlx_ir::op::ScaleMode,
2342        /// `Some(off)` for `EMA` and `Fixed`; `None` for `PerBatch`.
2343        /// Points at a `[chan_dim]` f32 buffer holding the running scale
2344        /// per channel.
2345        state_off: Option<usize>,
2346    },
2347
2348    /// Backward pass for `Op::FakeQuantize` under one of four STE
2349    /// variants. Computes `dx[i]` from the f32 forward input `x` and
2350    /// the upstream gradient `dy`, using the same per-channel scale
2351    /// scheme as the forward.
2352    FakeQuantizeBackward {
2353        x: usize,
2354        dy: usize,
2355        dx: usize,
2356        len: u32,
2357        chan_axis: u32,
2358        chan_dim: u32,
2359        inner: u32,
2360        bits: u8,
2361        ste: rlx_ir::op::SteKind,
2362    },
2363
2364    /// LSQ forward — same kernel shape as `FakeQuantize` Fixed mode.
2365    /// Reads scale from `scale_off` (a `[chan_dim]` Param tensor).
2366    FakeQuantizeLSQ {
2367        x: usize,
2368        scale_off: usize,
2369        out: usize,
2370        len: u32,
2371        chan_axis: u32,
2372        chan_dim: u32,
2373        inner: u32,
2374        bits: u8,
2375    },
2376
2377    /// LSQ backward, x-gradient. STE-clipped: passes upstream
2378    /// through inside the quantization range, zeros outside.
2379    FakeQuantizeLSQBackwardX {
2380        x: usize,
2381        scale_off: usize,
2382        dy: usize,
2383        dx: usize,
2384        len: u32,
2385        chan_axis: u32,
2386        chan_dim: u32,
2387        inner: u32,
2388        bits: u8,
2389    },
2390
2391    /// LSQ backward, scale-gradient. Per-channel:
2392    ///   `dscale[c] = sum_i ψ(x[i]/s[c]) · upstream[i]`
2393    /// where `ψ(z) = -z + round(z)` if `|z| ≤ q_max` else
2394    /// `sign(z) · q_max`. Output shape: `[chan_dim]`.
2395    FakeQuantizeLSQBackwardScale {
2396        x: usize,
2397        scale_off: usize,
2398        dy: usize,
2399        dscale: usize,
2400        len: u32,
2401        chan_axis: u32,
2402        chan_dim: u32,
2403        inner: u32,
2404        bits: u8,
2405    },
2406
2407    /// ReLU backward: `dx[i] = dy[i] if x[i] > 0 else 0`.
2408    ReluBackward {
2409        x: usize,
2410        dy: usize,
2411        dx: usize,
2412        len: u32,
2413    },
2414    /// f64 sibling of `ReluBackward` — same shape as the f32 variant
2415    /// but reads/writes 8 bytes per element. Required because
2416    /// `ReluBackward`'s `&[f32]` slot view returns half of every f64
2417    /// otherwise → backward silently produces 0 gradients on an f64
2418    /// graph. Mirrors the `ActivationBackwardF64` split.
2419    ReluBackwardF64 {
2420        x: usize,
2421        dy: usize,
2422        dx: usize,
2423        len: u32,
2424    },
2425
2426    /// Generic element-wise activation backward.
2427    /// `dx[i] = (d/dx act(x))[i] · dy[i]`. The closure dispatch is
2428    /// per-element; expensive activations (Gelu) recompute internals
2429    /// inline rather than threading an extra "saved y" tensor through.
2430    ActivationBackward {
2431        x: usize,
2432        dy: usize,
2433        dx: usize,
2434        len: u32,
2435        kind: Activation,
2436    },
2437    /// f64 sibling of `ActivationBackward` — slot offsets, len in
2438    /// elements; kernel reads/writes 8 bytes per element. Required
2439    /// because `ActivationBackward`'s `&[f32]` slot view silently
2440    /// returns garbage on an f64 graph (cb % 4 still works but every
2441    /// loaded value is half of an f64 → wrong gradient).
2442    ActivationBackwardF64 {
2443        x: usize,
2444        dy: usize,
2445        dx: usize,
2446        len: u32,
2447        kind: Activation,
2448    },
2449
2450    /// LayerNorm backward — input gradient. Recomputes mean/var/x̂ from
2451    /// `x` and emits the closed-form `d_x` per row.
2452    LayerNormBackwardInput {
2453        x: usize,
2454        gamma: usize,
2455        dy: usize,
2456        dx: usize,
2457        rows: u32,
2458        h: u32,
2459        eps: f32,
2460    },
2461
2462    /// LayerNorm backward — gamma gradient. `d_gamma[d] = Σ_row dy·x̂`.
2463    LayerNormBackwardGamma {
2464        x: usize,
2465        dy: usize,
2466        dgamma: usize,
2467        rows: u32,
2468        h: u32,
2469        eps: f32,
2470    },
2471
2472    RmsNormBackwardInput {
2473        x: usize,
2474        gamma: usize,
2475        beta: usize,
2476        dy: usize,
2477        dx: usize,
2478        rows: u32,
2479        h: u32,
2480        eps: f32,
2481    },
2482    RmsNormBackwardGamma {
2483        x: usize,
2484        gamma: usize,
2485        beta: usize,
2486        dy: usize,
2487        dgamma: usize,
2488        rows: u32,
2489        h: u32,
2490        eps: f32,
2491    },
2492    RmsNormBackwardBeta {
2493        x: usize,
2494        gamma: usize,
2495        beta: usize,
2496        dy: usize,
2497        dbeta: usize,
2498        rows: u32,
2499        h: u32,
2500        eps: f32,
2501    },
2502    RopeBackward {
2503        dy: usize,
2504        cos: usize,
2505        sin: usize,
2506        dx: usize,
2507        batch: u32,
2508        seq: u32,
2509        hidden: u32,
2510        head_dim: u32,
2511        n_rot: u32,
2512        cos_len: u32,
2513        /// Elements per position in the cos/sin tables — same meaning, and the
2514        /// same reason, as [`Thunk::Rope::cos_row_stride`].
2515        ///
2516        /// The backward must read the table with the *identical* stride to the
2517        /// forward or the adjoint is not the transpose of the forward: it picks
2518        /// up a different position's angles for every row past the first. The
2519        /// forward gained this field while the backward kept deriving the
2520        /// stride as `n_rot/2`, which the finite-difference check caught (the
2521        /// cross-backend parity tests could not — every backend that shared the
2522        /// wrong assumption agreed with itself).
2523        cos_row_stride: u32,
2524        /// GptJ pairing: rotate adjacent lanes `(2i, 2i+1)` rather than NeoX's
2525        /// `(i, i + n_rot/2)`. Mirrors [`Thunk::Rope::interleaved`] — and for
2526        /// the same reason as `cos_row_stride` above: the forward carried the
2527        /// distinction, the backward did not, so the adjoint stopped being the
2528        /// transpose of the forward for every GptJ (GGUF) rotation.
2529        interleaved: bool,
2530    },
2531    CumsumBackward {
2532        dy: usize,
2533        dx: usize,
2534        rows: u32,
2535        cols: u32,
2536        exclusive: bool,
2537    },
2538    GatherBackward {
2539        dy: usize,
2540        indices: usize,
2541        dst: usize,
2542        outer: u32,
2543        axis_dim: u32,
2544        num_idx: u32,
2545        trailing: u32,
2546    },
2547
2548    GroupNormBackwardInput {
2549        x: usize,
2550        gamma: usize,
2551        beta: usize,
2552        dy: usize,
2553        dx: usize,
2554        n: u32,
2555        c: u32,
2556        h: u32,
2557        w: u32,
2558        num_groups: u32,
2559        eps: f32,
2560    },
2561    GroupNormBackwardGamma {
2562        x: usize,
2563        dy: usize,
2564        dgamma: usize,
2565        n: u32,
2566        c: u32,
2567        h: u32,
2568        w: u32,
2569        num_groups: u32,
2570        eps: f32,
2571    },
2572    GroupNormBackwardBeta {
2573        dy: usize,
2574        dbeta: usize,
2575        n: u32,
2576        c: u32,
2577        h: u32,
2578        w: u32,
2579    },
2580
2581    /// 2D max-pool backward (NCHW). Recomputes the argmax position
2582    /// inside each window and accumulates `dy` into `dx` at that
2583    /// position. Output is zeroed first; ties resolve to the first
2584    /// hit (lowest (kh,kw) index), matching what the forward kernel
2585    /// does with `acc.max(v)`.
2586    MaxPool2dBackward {
2587        x: usize,
2588        dy: usize,
2589        dx: usize,
2590        n: u32,
2591        c: u32,
2592        h: u32,
2593        w: u32,
2594        h_out: u32,
2595        w_out: u32,
2596        kh: u32,
2597        kw: u32,
2598        sh: u32,
2599        sw: u32,
2600        ph: u32,
2601        pw: u32,
2602    },
2603
2604    /// 2D conv backward w.r.t. input (`dx = conv_transpose(dy, w)`).
2605    /// `dy [N, C_out, H_out, W_out]`, `w [C_out, C_in_per_group, kH, kW]`,
2606    /// `dx [N, C_in, H, W]`.
2607    Conv2dBackwardInput {
2608        dy: usize,
2609        w: usize,
2610        dx: usize,
2611        n: u32,
2612        c_in: u32,
2613        h: u32,
2614        w_in: u32,
2615        c_out: u32,
2616        h_out: u32,
2617        w_out: u32,
2618        kh: u32,
2619        kw: u32,
2620        sh: u32,
2621        sw: u32,
2622        ph: u32,
2623        pw: u32,
2624        dh: u32,
2625        dw: u32,
2626        groups: u32,
2627    },
2628
2629    /// 2D conv backward w.r.t. weight. `x [N, C_in, H, W]`,
2630    /// `dy [N, C_out, H_out, W_out]`, `dw [C_out, C_in_per_group, kH, kW]`.
2631    /// `dw` is zeroed before accumulation.
2632    Conv2dBackwardWeight {
2633        x: usize,
2634        dy: usize,
2635        dw: usize,
2636        n: u32,
2637        c_in: u32,
2638        h: u32,
2639        w: u32,
2640        c_out: u32,
2641        h_out: u32,
2642        w_out: u32,
2643        kh: u32,
2644        kw: u32,
2645        sh: u32,
2646        sw: u32,
2647        ph: u32,
2648        pw: u32,
2649        dh: u32,
2650        dw_dil: u32,
2651        groups: u32,
2652    },
2653
2654    MaxPool3dBackward {
2655        x: usize,
2656        dy: usize,
2657        dx: usize,
2658        n: u32,
2659        c: u32,
2660        d: u32,
2661        h: u32,
2662        w: u32,
2663        d_out: u32,
2664        h_out: u32,
2665        w_out: u32,
2666        kd: u32,
2667        kh: u32,
2668        kw: u32,
2669        sd: u32,
2670        sh: u32,
2671        sw: u32,
2672        pd: u32,
2673        ph: u32,
2674        pw: u32,
2675    },
2676
2677    Conv3dBackwardInput {
2678        dy: usize,
2679        w: usize,
2680        dx: usize,
2681        n: u32,
2682        c_in: u32,
2683        d: u32,
2684        h: u32,
2685        w_in: u32,
2686        c_out: u32,
2687        d_out: u32,
2688        h_out: u32,
2689        w_out: u32,
2690        kd: u32,
2691        kh: u32,
2692        kw: u32,
2693        sd: u32,
2694        sh: u32,
2695        sw: u32,
2696        pd: u32,
2697        ph: u32,
2698        pw: u32,
2699        dd: u32,
2700        dh: u32,
2701        dw: u32,
2702        groups: u32,
2703    },
2704
2705    Conv3dBackwardWeight {
2706        x: usize,
2707        dy: usize,
2708        dw: usize,
2709        n: u32,
2710        c_in: u32,
2711        d: u32,
2712        h: u32,
2713        w: u32,
2714        c_out: u32,
2715        d_out: u32,
2716        h_out: u32,
2717        w_out: u32,
2718        kd: u32,
2719        kh: u32,
2720        kw: u32,
2721        sd: u32,
2722        sh: u32,
2723        sw: u32,
2724        pd: u32,
2725        ph: u32,
2726        pw: u32,
2727        dd: u32,
2728        dh: u32,
2729        dw_dil: u32,
2730        groups: u32,
2731    },
2732
2733    /// NCHW im2col for conv backward-weight matmul. Output `[M, C·kH·kW]`
2734    /// with `M = N · H_out · W_out`. `n == 0` means infer batch from `x`.
2735    Im2Col {
2736        x: usize,
2737        col: usize,
2738        n: u32,
2739        c_in: u32,
2740        h: u32,
2741        w: u32,
2742        h_out: u32,
2743        w_out: u32,
2744        kh: u32,
2745        kw: u32,
2746        sh: u32,
2747        sw: u32,
2748        ph: u32,
2749        pw: u32,
2750        dh: u32,
2751        dw_dil: u32,
2752    },
2753
2754    /// Fused softmax + cross-entropy loss against a dense target
2755    /// distribution. `logits [N, C]`, `targets [N, C]`, output `[N]`
2756    /// per-row loss `lse(logits[n]) - Σ_c targets[n,c]·logits[n,c]`.
2757    /// Numerically stable (max-subtract before exp).
2758    SoftmaxCrossEntropyDense {
2759        logits: usize,
2760        targets: usize,
2761        dst: usize,
2762        n: u32,
2763        c: u32,
2764    },
2765
2766    /// Fused softmax + cross-entropy loss with f32-encoded integer
2767    /// labels. `logits [N, C]`, `labels [N]`, output `[N]` per-row loss.
2768    /// Numerically stable (max-subtract before exp).
2769    SoftmaxCrossEntropy {
2770        logits: usize,
2771        labels: usize,
2772        dst: usize,
2773        n: u32,
2774        c: u32,
2775    },
2776
2777    /// Backward of the fused loss above.
2778    /// `dlogits[n, k] = (softmax(logits[n])[k] - one_hot(labels[n])[k]) * d_loss[n]`.
2779    SoftmaxCrossEntropyBackward {
2780        logits: usize,
2781        labels: usize,
2782        d_loss: usize,
2783        dlogits: usize,
2784        n: u32,
2785        c: u32,
2786    },
2787
2788    /// User-registered custom op (CPU side). Lowered from `Op::Custom`.
2789    /// `kernel` is resolved against the global CPU kernel registry at
2790    /// compile time and stored as `Arc<dyn CpuKernel>` so execution
2791    /// avoids per-call lookups. v1: f32 contiguous only — see
2792    /// `op_registry::CpuKernel::execute_f32`.
2793    CustomOp {
2794        kernel: Arc<dyn CpuKernel>,
2795        inputs: Vec<(usize, u32, Shape)>, // (offset, len_elements, shape)
2796        output: (usize, u32, Shape),      // (offset, len_elements, shape)
2797        attrs: Vec<u8>,
2798    },
2799
2800    /// 1D FFT along the last axis. Input/output are `[..., 2N]`
2801    /// real-block layout (first N real, second N imag along the
2802    /// transformed axis). `outer` is the product of all leading axes;
2803    /// `n_complex` is N (the number of complex points). Both halves
2804    /// of the real-block layout are read together by the kernel.
2805    /// `dtype` selects the f32 or f64 path; the two share structure
2806    /// but not buffers, so a flag at compile time avoids per-row
2807    /// dispatch.
2808    /// CPU reference 3D Gaussian splat render ([`rlx_ir::Op::GaussianSplatRender`]).
2809    GaussianSplatRender {
2810        positions_off: usize,
2811        positions_len: usize,
2812        scales_off: usize,
2813        scales_len: usize,
2814        rotations_off: usize,
2815        rotations_len: usize,
2816        opacities_off: usize,
2817        opacities_len: usize,
2818        colors_off: usize,
2819        colors_len: usize,
2820        sh_coeffs_off: usize,
2821        sh_coeffs_len: usize,
2822        meta_off: usize,
2823        dst_off: usize,
2824        dst_len: usize,
2825        width: u32,
2826        height: u32,
2827        tile_size: u32,
2828        radius_scale: f32,
2829        alpha_cutoff: f32,
2830        max_splat_steps: u32,
2831        transmittance_threshold: f32,
2832        max_list_entries: u32,
2833    },
2834    GaussianSplatRenderBackward {
2835        positions_off: usize,
2836        positions_len: usize,
2837        scales_off: usize,
2838        scales_len: usize,
2839        rotations_off: usize,
2840        rotations_len: usize,
2841        opacities_off: usize,
2842        opacities_len: usize,
2843        colors_off: usize,
2844        colors_len: usize,
2845        sh_coeffs_off: usize,
2846        sh_coeffs_len: usize,
2847        meta_off: usize,
2848        d_loss_off: usize,
2849        d_loss_len: usize,
2850        packed_off: usize,
2851        packed_len: usize,
2852        width: u32,
2853        height: u32,
2854        tile_size: u32,
2855        radius_scale: f32,
2856        alpha_cutoff: f32,
2857        max_splat_steps: u32,
2858        transmittance_threshold: f32,
2859        max_list_entries: u32,
2860        loss_grad_clip: f32,
2861        sh_band: u32,
2862        max_anisotropy: f32,
2863    },
2864    /// Strict IR stage 1 — project + bin + sort + rays ([`Op::GaussianSplatPrepare`]).
2865    GaussianSplatPrepare {
2866        positions_off: usize,
2867        positions_len: usize,
2868        scales_off: usize,
2869        scales_len: usize,
2870        rotations_off: usize,
2871        rotations_len: usize,
2872        opacities_off: usize,
2873        opacities_len: usize,
2874        colors_off: usize,
2875        colors_len: usize,
2876        sh_coeffs_off: usize,
2877        sh_coeffs_len: usize,
2878        meta_off: usize,
2879        meta_len: usize,
2880        prep_off: usize,
2881        prep_len: usize,
2882        width: u32,
2883        height: u32,
2884        tile_size: u32,
2885        radius_scale: f32,
2886        alpha_cutoff: f32,
2887        max_splat_steps: u32,
2888        transmittance_threshold: f32,
2889        max_list_entries: u32,
2890    },
2891    /// Strict IR stage 2 — tile raster from prepare buffer ([`Op::GaussianSplatRasterize`]).
2892    GaussianSplatRasterize {
2893        prep_off: usize,
2894        prep_len: usize,
2895        meta_off: usize,
2896        meta_len: usize,
2897        dst_off: usize,
2898        dst_len: usize,
2899        count: usize,
2900        width: u32,
2901        height: u32,
2902        tile_size: u32,
2903        alpha_cutoff: f32,
2904        max_splat_steps: u32,
2905        transmittance_threshold: f32,
2906        max_list_entries: u32,
2907    },
2908    Fft1d {
2909        src: usize,
2910        dst: usize,
2911        outer: u32,
2912        n_complex: u32,
2913        inverse: bool,
2914        norm_tag: u32,
2915        dtype: rlx_ir::DType,
2916    },
2917    /// Fixed-point 1D FFT — see `rlx_ir::Op::FftQ`.
2918    Fft1dQ {
2919        src: usize,
2920        dst: usize,
2921        outer: u32,
2922        n_complex: u32,
2923        inverse: bool,
2924        norm_tag: u32,
2925        scale_tag: u32,
2926    },
2927    FftButterflyStage {
2928        state_src: usize,
2929        state_dst: usize,
2930        gate_src: usize,
2931        rev_src: usize,
2932        tw_re_src: usize,
2933        tw_im_src: usize,
2934        batch: u32,
2935        n_fft: u32,
2936        stage: u32,
2937    },
2938    LogMel {
2939        spec: usize,
2940        filters: usize,
2941        dst: usize,
2942        outer: u32,
2943        n_fft: u32,
2944        n_bins: u32,
2945        n_mels: u32,
2946    },
2947    LogMelBackward {
2948        spec: usize,
2949        filters: usize,
2950        dy: usize,
2951        dst: usize,
2952        outer: u32,
2953        n_fft: u32,
2954        n_bins: u32,
2955        n_mels: u32,
2956    },
2957    WelchPeaks {
2958        spec: usize,
2959        dst: usize,
2960        welch_batch: u32,
2961        n_fft: u32,
2962        n_segments: u32,
2963        k: u32,
2964    },
2965}
2966
2967/// Compiled thunk schedule — the runtime hot path.
2968/// Nop thunks are filtered out at compile time for zero iteration overhead.
2969#[derive(Clone)]
2970pub struct ThunkSchedule {
2971    pub thunks: Vec<Thunk>,
2972    /// TIDE merged placement mask (union across layers).
2973    pub moe_resident: Option<std::sync::Arc<[bool]>>,
2974    /// Per MoE layer placement (`layer[e]`); preferred when set.
2975    pub moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
2976    /// MoE router TopK capture (per-layer refresh).
2977    pub moe_topk_capture: Option<std::sync::Arc<crate::moe_topk_capture::MoeTopkCapture>>,
2978    /// Cached config values.
2979    pub mask_threshold: f32,
2980    pub mask_neg_inf: f32,
2981    pub score_skip: f32,
2982    /// Pre-compiled closure dispatch (zero match overhead). `Arc` (not
2983    /// `Box`) so the schedule can be `Clone` — multiple parallel
2984    /// executors share the same compiled closures (they're read-only
2985    /// `Fn(*mut u8)` so concurrent dispatch is safe; the arena pointer
2986    /// they receive is the only mutable state and is per-executor).
2987    pub compiled_fns: Vec<Arc<dyn Fn(*mut u8) + Send + Sync>>,
2988    /// Runtime-mutable RNG policy for [`Thunk::RngNormal`] / [`Thunk::RngUniform`].
2989    pub rng: Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
2990}
2991
2992impl ThunkSchedule {
2993    pub fn strip_nops(&mut self) {
2994        self.thunks.retain(|t| !matches!(t, Thunk::Nop));
2995        // compiled_fns must be rebuilt after stripping — caller should
2996        // call strip_nops() before compile_closures().
2997        self.compiled_fns.clear();
2998    }
2999}
3000
3001/// Get the arena byte offset for a node.
3002pub(crate) fn node_offset(arena: &Arena, id: NodeId) -> usize {
3003    if arena.has_buffer(id) {
3004        arena.byte_offset(id)
3005    } else {
3006        usize::MAX
3007    }
3008}
3009
3010/// Every byte-offset that a thunk reads from. Used by the Narrow→Rope
3011/// fusion (#45) to verify a Narrow's dst has exactly one consumer
3012/// before eliding it. Conservative: when in doubt about reads (an op
3013/// not yet listed here), the fusion will skip — correctness over
3014/// completeness.
3015pub(crate) fn thunk_read_offsets(t: &Thunk) -> Vec<usize> {
3016    match t {
3017        Thunk::Sgemm { a, b, .. } => vec![*a, *b],
3018        Thunk::SgemmBf16 { a, b, .. } => vec![*a, *b],
3019        Thunk::SgemmF16 { a, b, .. } => vec![*a, *b],
3020        Thunk::SgemmT { a, b, .. } => vec![*a, *b],
3021        Thunk::SgdMomentum {
3022            param, vel, grad, ..
3023        } => vec![*param, *vel, *grad],
3024        Thunk::DenseSolveF64 { a, b, .. } => vec![*a, *b],
3025        Thunk::DenseSolveF32 { a, b, .. } => vec![*a, *b],
3026        Thunk::BatchedDenseSolveF64 { a, b, .. } => vec![*a, *b],
3027        Thunk::BatchedDgemmF64 { a, b, .. } => vec![*a, *b],
3028        Thunk::BatchedSgemm { a, b, .. } => vec![*a, *b],
3029        Thunk::FusedMmBiasAct { a, w, bias, .. } => vec![*a, *w, *bias],
3030        Thunk::ElementwiseRegion { input_offs, .. } => input_offs.clone(),
3031        Thunk::BiasAdd { src, bias, .. } => vec![*src, *bias],
3032        Thunk::BinaryFull { lhs, rhs, .. } => vec![*lhs, *rhs],
3033        Thunk::BinaryFullF64 { lhs, rhs, .. } => vec![*lhs, *rhs],
3034        Thunk::BinaryFullC64 { lhs, rhs, .. } => vec![*lhs, *rhs],
3035        Thunk::ComplexNormSqF32 { src, .. } => vec![*src],
3036        Thunk::ComplexNormSqBackwardF32 { z, g, .. } => vec![*z, *g],
3037        Thunk::ConjugateC64 { src, .. } => vec![*src],
3038        Thunk::Scan {
3039            outer_init_off,
3040            xs_inputs,
3041            ..
3042        } => {
3043            let mut v = vec![*outer_init_off];
3044            for (_, outer_xs_off, _) in xs_inputs.iter() {
3045                v.push(*outer_xs_off);
3046            }
3047            v
3048        }
3049        Thunk::ScanBackward {
3050            outer_init_off,
3051            outer_traj_off,
3052            outer_upstream_off,
3053            outer_xs_offs,
3054            ..
3055        } => {
3056            let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
3057            for (off, _) in outer_xs_offs.iter() {
3058                v.push(*off);
3059            }
3060            v
3061        }
3062        Thunk::ScanBackwardXs {
3063            outer_init_off,
3064            outer_traj_off,
3065            outer_upstream_off,
3066            outer_xs_offs,
3067            ..
3068        } => {
3069            let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
3070            for (off, _) in outer_xs_offs.iter() {
3071                v.push(*off);
3072            }
3073            v
3074        }
3075        Thunk::CustomFn { inputs, .. } => {
3076            inputs.iter().map(|(_, outer_off, _)| *outer_off).collect()
3077        }
3078        Thunk::ActivationInPlace { data, .. } => vec![*data],
3079        Thunk::LayerNorm { src, g, b, .. } | Thunk::GroupNorm { src, g, b, .. } => {
3080            vec![*src, *g, *b]
3081        }
3082        Thunk::BatchNormInference {
3083            src,
3084            g,
3085            b,
3086            mean,
3087            var,
3088            ..
3089        } => vec![*src, *g, *b, *mean, *var],
3090        Thunk::ResizeNearest2x { src, .. } => vec![*src],
3091        Thunk::Interpolate3d { src, .. } => vec![*src],
3092        Thunk::AxialRope2d { src, .. } => vec![*src],
3093        Thunk::FusedResidualLN {
3094            x, res, bias, g, b, ..
3095        } => vec![*x, *res, *bias, *g, *b],
3096        Thunk::FusedResidualRmsNorm {
3097            x, res, bias, g, b, ..
3098        } => vec![*x, *res, *bias, *g, *b],
3099        Thunk::RmsNorm { src, g, b, .. } => vec![*src, *g, *b],
3100        Thunk::AdaLayerNorm {
3101            src, scale, shift, ..
3102        } => vec![*src, *scale, *shift],
3103        Thunk::GatedResidual { x, y, gate, .. } => vec![*x, *y, *gate],
3104        Thunk::AdaLayerNormBackward {
3105            x,
3106            scale,
3107            shift,
3108            dy,
3109            ..
3110        } => vec![*x, *scale, *shift, *dy],
3111        Thunk::GatedResidualBackward { x, y, gate, dy, .. } => vec![*x, *y, *gate, *dy],
3112        Thunk::Softmax { data, .. } => vec![*data],
3113        Thunk::Cumsum { src, .. } => vec![*src],
3114        Thunk::CumScan { src, .. } => vec![*src],
3115        Thunk::Sample { logits, .. } => vec![*logits],
3116        Thunk::RngNormal { .. } | Thunk::RngUniform { .. } => vec![],
3117        Thunk::LoraMatMul { x, w, a, b, .. } => vec![*x, *w, *a, *b],
3118        Thunk::DequantMatMul {
3119            x, w_q, scale, zp, ..
3120        } => vec![*x, *w_q, *scale, *zp],
3121        Thunk::SynthMatMul {
3122            x,
3123            indices,
3124            codebook,
3125            ..
3126        } => vec![*x, *indices, *codebook],
3127        Thunk::SplineActivation { x, coeff, .. } => vec![*x, *coeff],
3128        Thunk::DequantMatMulGguf { x, w_q, .. } => vec![*x, *w_q],
3129        Thunk::DequantMatMulInt4 {
3130            x, w_q, scale, zp, ..
3131        } => vec![*x, *w_q, *scale, *zp],
3132        Thunk::DequantMatMulFp8 { x, w_q, scale, .. } => vec![*x, *w_q, *scale],
3133        Thunk::DequantMatMulNvfp4 {
3134            x,
3135            w_q,
3136            scale,
3137            global_scale,
3138            ..
3139        } => vec![*x, *w_q, *scale, *global_scale],
3140        Thunk::DequantMatMulMxFp4x2 { x, w_q, scale, .. } => vec![*x, *w_q, *scale],
3141        Thunk::DequantMatMulMlx {
3142            x, w_q, scale, zp, ..
3143        } => vec![*x, *w_q, *scale, *zp],
3144        Thunk::ScaledMatMul {
3145            lhs,
3146            rhs,
3147            lhs_scale,
3148            rhs_scale,
3149            bias,
3150            has_bias,
3151            ..
3152        } => {
3153            let mut v = vec![*lhs, *rhs, *lhs_scale, *rhs_scale];
3154            if *has_bias {
3155                v.push(*bias);
3156            }
3157            v
3158        }
3159        Thunk::ScaledQuantize { x, scale, .. } => vec![*x, *scale],
3160        Thunk::ScaledQuantScale { x, .. } => vec![*x],
3161        Thunk::ScaledDequantize { codes, scale, .. } => vec![*codes, *scale],
3162        Thunk::Conv2D1x1 { src, weight, .. } => vec![*src, *weight],
3163        Thunk::SelectiveScan {
3164            x, delta, a, b, c, ..
3165        } => vec![*x, *delta, *a, *b, *c],
3166        Thunk::GatedDeltaNet {
3167            q,
3168            k,
3169            v,
3170            g,
3171            beta,
3172            state,
3173            carry_state,
3174            ..
3175        } => {
3176            let mut v = vec![*q, *k, *v, *g, *beta];
3177            if *carry_state {
3178                v.push(*state);
3179            }
3180            v
3181        }
3182        Thunk::Attention { q, k, v, mask, .. } => vec![*q, *k, *v, *mask],
3183        Thunk::AttentionBackward {
3184            q, k, v, dy, mask, ..
3185        } => {
3186            let mut v = vec![*q, *k, *v, *dy];
3187            if *mask != 0 {
3188                v.push(*mask);
3189            }
3190            v
3191        }
3192        Thunk::Fft1dQ { src, .. } => vec![*src],
3193        Thunk::Rope { src, cos, sin, .. } => vec![*src, *cos, *sin],
3194        Thunk::FusedAttnBlock {
3195            hidden,
3196            qkv_w,
3197            out_w,
3198            mask,
3199            qkv_b,
3200            out_b,
3201            cos,
3202            sin,
3203            ..
3204        } => vec![*hidden, *qkv_w, *out_w, *mask, *qkv_b, *out_b, *cos, *sin],
3205        Thunk::FusedSwiGLU { src, .. } => vec![*src],
3206        Thunk::Concat { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
3207        Thunk::ConcatF64 { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
3208        Thunk::Narrow { src, .. } => vec![*src],
3209        Thunk::Copy { src, .. } => vec![*src],
3210        Thunk::RoundHalf { src, .. } => vec![*src],
3211        Thunk::Gather { table, idx, .. } => vec![*table, *idx],
3212        Thunk::Histogram { src, .. } => vec![*src],
3213        // Anything not enumerated → return the dst as a "read" too,
3214        // forcing the fusion to bail (read_count >= 2 → skip). Keeps
3215        // this list safe to be incomplete.
3216        _ => vec![],
3217    }
3218}
3219
3220/// A scan body compiled into a self-contained CPU schedule plus the byte
3221/// offsets needed to drive it. Backends without a native `Op::Scan` kernel
3222/// (e.g. Metal via the unified-memory host fallback) build this once at compile
3223/// time and hand it to [`execute_scan_host`] at run time. Mirrors the inline
3224/// `Op::Scan` compilation used by the CPU executor.
3225pub struct ScanBodyPlan {
3226    pub body: ThunkSchedule,
3227    pub body_init: Vec<u8>,
3228    pub body_input_off: usize,
3229    pub body_output_off: usize,
3230    pub carry_bytes: usize,
3231    /// Body-arena offsets of the broadcast inputs, in declaration order.
3232    pub bcast_body_offs: Vec<usize>,
3233    /// Body-arena offsets of the per-step (`xs`) inputs, in declaration order.
3234    pub xs_body_offs: Vec<usize>,
3235}
3236
3237impl std::fmt::Debug for ScanBodyPlan {
3238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3239        f.debug_struct("ScanBodyPlan")
3240            .field("carry_bytes", &self.carry_bytes)
3241            .field("body_input_off", &self.body_input_off)
3242            .field("body_output_off", &self.body_output_off)
3243            .field("num_bcast", &self.bcast_body_offs.len())
3244            .field("num_xs", &self.xs_body_offs.len())
3245            .finish()
3246    }
3247}
3248
3249/// How one GEMM operand is laid out in the arena: full f32, or packed 2 B/elem.
3250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3251pub enum HalfKind {
3252    /// 4 B/elem f32 — either a genuine f32 tensor or a widened half slot.
3253    F32,
3254    /// 2 B/elem bfloat16 bits.
3255    Bf16,
3256    /// 2 B/elem IEEE binary16 bits.
3257    F16,
3258}
3259
3260impl HalfKind {
3261    /// Packed layouts need widening before an f32 kernel may read them.
3262    pub fn packed(self) -> bool {
3263        !matches!(self, HalfKind::F32)
3264    }
3265}