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