Skip to main content

rlx_ir/
op.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Operation types — every tensor op in the RLX IR.
17//!
18//! Designed for pattern-matching fusion: ops are grouped by category so
19//! fusion passes can reason about them structurally.
20
21use crate::DType;
22
23/// Unary element-wise activation functions.
24#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum Activation {
27    Gelu,
28    GeluApprox,
29    Silu, // SwiGLU gate activation
30    Relu,
31    Sigmoid,
32    Tanh,
33    Exp,
34    Log,
35    Sqrt,
36    Rsqrt,
37    Neg,
38    Abs,
39    /// `sin(x)`. Backward: `dx = upstream · cos(x)`.
40    Sin,
41    /// `cos(x)`. Backward: `dx = -upstream · sin(x)`.
42    Cos,
43    /// `tan(x)`. Backward: `dx = upstream · sec²(x) = upstream · (1 + tan²(x))`.
44    Tan,
45    /// `atan(x)`. Backward: `dx = upstream · (1 / (1 + x²))`.
46    Atan,
47    /// Round to nearest integer (half-to-even), in f32.
48    /// Forward: `x.round()`. Backward: STE — treats as identity, so
49    /// the gradient passes through unchanged. Useful as a primitive
50    /// for composing custom quantization schemes (Mul-by-recip-scale
51    /// → Round → Clamp → Mul-by-scale = a hand-rolled FakeQuantize
52    /// that the elementwise-region pass can fuse into a single kernel).
53    Round,
54}
55
56/// Scale-tracking strategy for `Op::FakeQuantize`. Determines how
57/// the per-channel `s[c]` is computed each forward pass.
58///
59/// * `PerBatch` — recompute `s[c] = max(|x|) / q_max` from the
60///   current data on every call. Simple, no extra inputs, but
61///   noisy for activations (max-abs jumps batch-to-batch).
62///
63/// * `EMA { decay }` — keep a running `s[c]` in a state tensor
64///   (passed as a second op input). On each call, blend the
65///   current per-batch max-abs into the state via
66///   `state' = decay·state + (1-decay)·max_abs`. Smooth scale
67///   over training, makes activation-QAT actually trainable.
68///   Typical `decay = 0.99`.
69///
70/// * `Fixed` — never recompute. The state tensor's value is
71///   used as-is each call (set once at construction or by the
72///   caller). Useful when scales are pre-calibrated.
73#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
74#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
75pub enum ScaleMode {
76    #[default]
77    PerBatch,
78    EMA {
79        decay: f32,
80    },
81    Fixed,
82}
83
84impl Eq for ScaleMode {}
85impl std::hash::Hash for ScaleMode {
86    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
87        match self {
88            ScaleMode::PerBatch => state.write_u8(0),
89            ScaleMode::EMA { decay } => {
90                state.write_u8(1);
91                state.write_u32(decay.to_bits());
92            }
93            ScaleMode::Fixed => state.write_u8(2),
94        }
95    }
96}
97
98/// Straight-through estimator variants for `Op::FakeQuantize`'s
99/// backward. The forward is the same regardless: discrete
100/// `clamp(round(x/s)) * s`. The choice here affects only the
101/// gradient w.r.t. `x` during training.
102///
103/// * `Identity` — `dx = upstream`. The original STE; treats the
104///   round as identity in the backward direction. Simplest, fine
105///   for moderate bit widths (i4 / i8).
106///
107/// * `ClippedIdentity` — `dx = upstream * (|x| ≤ q_max·s)`. Zero
108///   the gradient when the input was outside the quantization
109///   range (i.e. the clamp activated). Stops the optimizer from
110///   pushing weights further into saturation.
111///
112/// * `Tanh` — `dx = upstream * (1 - tanh²(x/s))`. Smooth surrogate
113///   for the round step. Slowly attenuates the gradient as `|x|`
114///   approaches `q_max·s`. Often best on tight bit widths (i2).
115///
116/// * `HardTanh` — `dx = upstream * (1 - |x/(q_max·s)|).max(0)`.
117///   Piecewise-linear cousin of `Tanh`; cheaper to compute and
118///   nearly as effective.
119#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
121pub enum SteKind {
122    #[default]
123    Identity,
124    ClippedIdentity,
125    Tanh,
126    HardTanh,
127}
128
129/// Binary element-wise operations.
130#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
132pub enum BinaryOp {
133    Add,
134    Sub,
135    Mul,
136    Div,
137    Max,
138    Min,
139    Pow,
140}
141
142/// Comparison operations (return Bool tensor).
143#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
145pub enum CmpOp {
146    Eq,
147    Ne,
148    Lt,
149    Le,
150    Gt,
151    Ge,
152}
153
154/// What kind of attention mask the kernel should apply.
155///
156/// Borrowed from MAX's `nn/attention/mha_mask.mojo` pattern (#20 in
157/// PLAN.md): one attention kernel handles all variants by branching on
158/// the mask kind, instead of forcing every caller to materialize a mask
159/// tensor. The win is two-fold:
160///   1. **`None`** — single unpadded sequence: no mask load, no per-key
161///      compare in the inner loop.
162///   2. **`Causal`** — autoregressive decode: kernel generates the upper-
163///      triangular fill from `(qi, ki)` directly; no `seq²` mask tensor
164///      ever exists.
165///
166/// `Custom` is the existing path — read mask values from the 4th input.
167#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum MaskKind {
170    /// No masking — every position attends to every position.
171    None,
172    /// Causal (autoregressive) — position `qi` attends only to `ki <= qi`.
173    Causal,
174    /// Sliding window — position `qi` attends to `ki ∈ [qi - w, qi]`.
175    SlidingWindow(usize),
176    /// Read mask values from the input tensor (default; matches BERT
177    /// padding-mask behavior). Tensor shape `[batch, key_len]` with
178    /// `1.0` = valid, `<0.5` = ignored.
179    Custom,
180    /// Additive per-head, per-query bias tensor
181    /// `[batch, num_heads, query_len, key_len]` added to the
182    /// `QK^T · scale` scores before softmax. Lets DETR-style boxRPB
183    /// and other learned position biases reuse the fast `Op::Attention`
184    /// path instead of decomposing into matmul + add + softmax + matmul.
185    Bias,
186}
187
188/// Which forward input an [`Op::AttentionBackward`] node differentiates.
189#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
191pub enum AttentionBwdWrt {
192    Query,
193    Key,
194    Value,
195}
196
197/// Reduction operations along specified axes.
198#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
200pub enum ReduceOp {
201    Sum,
202    Mean,
203    Max,
204    Min,
205    Prod,
206}
207
208/// PLAN L4: discriminant for each [`Op`] variant. Used by
209/// [`Op::kind`] + the `Backend::supported_ops` trait method to declare
210/// which ops a backend can lower; the `LegalizeForBackend` pass in
211/// `rlx-opt` checks the graph against this set and fails the compile
212/// when an unsupported op is present (instead of silent fallback).
213#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum OpKind {
216    Input,
217    Param,
218    Constant,
219    Activation,
220    Cast,
221    StopGradient,
222    Quantize,
223    Dequantize,
224    FakeQuantize,
225    FakeQuantizeLSQ,
226    FakeQuantizeLSQBackwardX,
227    FakeQuantizeLSQBackwardScale,
228    Binary,
229    Compare,
230    Where,
231    Fma,
232    ElementwiseRegion,
233    /// Fused sampling / geometry chain (FKL-style transform region).
234    TransformRegion,
235    /// Same element-wise chain over multiple batch planes (horizontal fusion).
236    BatchElementwiseRegion,
237    MatMul,
238    DotGeneral,
239    DenseSolve,
240    BatchedDenseSolve,
241    LayerNorm,
242    LayerNorm2d,
243    GroupNorm,
244    BatchNormInference,
245    RmsNorm,
246    ResizeNearest2x,
247    Attention,
248    Rope,
249    AxialRope2d,
250    Reshape,
251    Transpose,
252    Narrow,
253    Concat,
254    Expand,
255    Gather,
256    Reverse,
257    Reduce,
258    Softmax,
259    Cumsum,
260    ArgMax,
261    ArgMin,
262    TopK,
263    Sample,
264    /// ONNX `RandomNormalLike` — shape from input 0, output filled at runtime.
265    RngNormal,
266    /// ONNX `RandomUniformLike`.
267    RngUniform,
268    Conv,
269    Im2Col,
270    ConvTranspose2d,
271    Conv3d,
272    ConvTranspose3d,
273    Pool,
274    ReluBackward,
275    ActivationBackward,
276    FakeQuantizeBackward,
277    ComplexNormSq,
278    ComplexNormSqBackward,
279    Conjugate,
280    MaxPool2dBackward,
281    Conv2dBackwardInput,
282    Conv2dBackwardWeight,
283    SoftmaxCrossEntropy,
284    SoftmaxCrossEntropyWithLogits,
285    SoftmaxCrossEntropyBackward,
286    AttentionBackward,
287    LayerNormBackwardInput,
288    LayerNormBackwardGamma,
289    RmsNormBackwardInput,
290    RmsNormBackwardGamma,
291    RmsNormBackwardBeta,
292    RopeBackward,
293    GroupNormBackwardInput,
294    GroupNormBackwardGamma,
295    GroupNormBackwardBeta,
296    BatchNormInferenceBackwardInput,
297    BatchNormInferenceBackwardGamma,
298    BatchNormInferenceBackwardBeta,
299    CumsumBackward,
300    GatherBackward,
301    GroupedMatMul,
302    DequantGroupedMatMul,
303    DequantMoEWeights,
304    ScatterAdd,
305    LoraMatMul,
306    DequantMatMul,
307    QMatMul,
308    QConv2d,
309    ScaledMatMul,
310    ScaledQuantize,
311    ScaledQuantScale,
312    ScaledDequantize,
313    SelectiveScan,
314    GatedDeltaNet,
315    Lstm,
316    Gru,
317    Rnn,
318    Mamba2,
319    FusedSwiGLU,
320    FusedMatMulBiasAct,
321    FusedResidualLN,
322    FusedResidualRmsNorm,
323    FusedAttentionBlock,
324    FusedTransformerLayer,
325    If,
326    While,
327    Scan,
328    ScanBackward,
329    ScanBackwardXs,
330    /// CPU reference 3D Gaussian splat raster (project → bin → sort → raster).
331    /// See [`Op::GaussianSplatRender`].
332    GaussianSplatRender,
333    /// Backward of [`Op::GaussianSplatRender`] — packed scene parameter gradients.
334    GaussianSplatRenderBackward,
335    /// Project + tile bin + sort + ray grid (strict IR splat stage 1).
336    GaussianSplatPrepare,
337    /// Per-pixel raster from prepared buffers (strict IR splat stage 2).
338    GaussianSplatRasterize,
339    /// User-registered op dispatched through `op_registry`. All
340    /// custom ops (Sparse-LU, FFT, eigensolve, ...) share this kind;
341    /// the per-op identity lives in `Op::Custom::name`.
342    Custom,
343    /// User-defined sub-graph with optional override AD rules. See
344    /// [`Op::CustomFn`] / [`crate::Graph::custom_fn`].
345    CustomFn,
346    /// 1D FFT primitive (forward or inverse) — see [`Op::Fft`].
347    Fft,
348    /// Ternary pruned radix-2 butterfly stage — see [`Op::FftButterflyStage`].
349    FftButterflyStage,
350    /// Whisper-style log-mel from block-layout FFT spectrum — see [`Op::LogMel`].
351    LogMel,
352    /// Backward of [`Op::LogMel`] w.r.t. block-layout spectrum input 0.
353    LogMelBackward,
354    /// Welch PSD top-K spikes from block-layout FFT spectra — see [`Op::WelchPeaks`].
355    WelchPeaks,
356}
357
358/// An operand inside a fused [`ChainStep`] — either a graph-level input
359/// to the [`Op::ElementwiseRegion`] (by index 0..num_inputs) or the
360/// result of a previous step in the chain (by index 0..step_position).
361#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
363pub enum ChainOperand {
364    Input(u32),
365    Step(u32),
366}
367
368/// One step in a fused element-wise chain. Each step produces exactly
369/// one scalar result (per element); later steps can refer to it via
370/// [`ChainOperand::Step`]. The whole chain runs per element in registers.
371#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
372#[derive(Debug, Clone, PartialEq)]
373pub enum ChainStep {
374    Activation(Activation, ChainOperand),
375    Cast(DType, ChainOperand),
376    Binary(BinaryOp, ChainOperand, ChainOperand),
377    Compare(CmpOp, ChainOperand, ChainOperand),
378    /// 3-input element-wise select: `cond ? on_true : on_false`. Mirrors
379    /// `Op::Where` inside a chain. `cond` is treated as truthy iff
380    /// non-zero. Lets the optimizer fold attention masks / clamp-style
381    /// patterns into a single region kernel instead of breaking the
382    /// chain at the first `Op::Where`.
383    Where(ChainOperand, ChainOperand, ChainOperand),
384}
385
386/// Pre-region memory transform fused into [`Op::ElementwiseRegion`].
387#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
389pub enum RegionPrologue {
390    #[default]
391    None,
392    /// Input is half-resolution NCHW; output shape is 2× H×W (nearest 2×).
393    ResizeNearest2x,
394}
395
396/// One sampling step in [`Op::TransformRegion`].
397#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
398#[derive(Debug, Clone, PartialEq)]
399pub enum TransformStep {
400    ResizeNearest2x(ChainOperand),
401}
402
403/// Pairing convention for [`Op::Rope`]. Different model families bake RoPE
404/// into their weights differently, so the rotation kernel needs a flavor:
405///
406/// - [`RopeStyle::NeoX`] (default): HF / GPT-NeoX "rotate-half" — dimension `i`
407///   pairs with `i + n_rot/2`. Used by HF-safetensors checkpoints (Llama, Qwen,
408///   Gemma, …).
409/// - [`RopeStyle::GptJ`]: GPT-J / llama.cpp `NORM` — adjacent pairs `(2i, 2i+1)`.
410///   GGUF Llama weights are permuted by the HF→GGUF converter for this layout,
411///   so GGUF-backed Llama inference must rotate with this flavor.
412#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
414pub enum RopeStyle {
415    #[default]
416    NeoX,
417    GptJ,
418}
419
420/// An operation in the RLX IR graph.
421///
422/// Operations are categorized for fusion analysis:
423/// - Element-wise ops fuse with anything reading their output
424/// - Matmul/Conv are BLAS-dispatched and form fusion boundaries
425/// - Reductions are fusion roots (drive the loop iteration)
426#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
427#[derive(Debug, Clone, PartialEq)]
428pub enum Op {
429    // ── Graph inputs ────────────────────────────────────────────
430    /// Model input with a name (shape on the Node).
431    Input {
432        name: String,
433    },
434
435    /// Model parameter (weight/bias) with a name.
436    Param {
437        name: String,
438    },
439
440    /// Constant tensor embedded in the graph.
441    Constant {
442        data: Vec<u8>,
443    },
444
445    // ── Element-wise unary ──────────────────────────────────────
446    /// Unary activation: one input, same shape output.
447    Activation(Activation),
448
449    /// Cast to a different dtype.
450    Cast {
451        to: DType,
452    },
453
454    /// Stop-gradient (a.k.a. `detach` / `lax.stop_gradient`). Forward is
455    /// identity; the reverse-mode autodiff rule returns **no** gradient
456    /// contribution for the input. Single input, same shape & dtype
457    /// output. Used to build a Gradient-Reverse-Layer with identity
458    /// forward semantics in user code (see maet-rs `dat_loss`).
459    StopGradient,
460
461    /// INT8 quantization. Input f32; output i8 same shape.
462    ///   `q[i] = saturate_i8(round(x[i] / scale[c]) + zero_point[c])`
463    /// where `c` selects the per-channel scale/zp when `axis = Some(d)`
464    /// (`c = idx[d]`), or always uses index 0 when `axis = None`
465    /// (per-tensor). The `scales` / `zero_points` payload length must
466    /// match `1` for per-tensor and `input.dim(d)` for per-channel.
467    /// Static — typically produced at calibration time and baked
468    /// into the loaded model. Use `Op::Dequantize` for the inverse.
469    Quantize {
470        axis: Option<usize>,
471        scales: Vec<f32>,
472        zero_points: Vec<i32>,
473    },
474
475    /// INT8 dequantization (inverse of `Op::Quantize`). Input i8;
476    /// output f32 same shape.
477    ///   `x[i] = (q[i] - zero_point[c]) · scale[c]`
478    /// where `c` is selected by `axis` exactly as in `Op::Quantize`.
479    Dequantize {
480        axis: Option<usize>,
481        scales: Vec<f32>,
482        zero_points: Vec<i32>,
483    },
484
485    /// "Fake-quantize" op for **quantization-aware training** (QAT).
486    /// Input f32; output f32 same shape. Forward computes a per-axis
487    /// (or per-tensor when `axis = None`) max-abs scale on the fly:
488    ///   `s[c] = max(|x[..., c, ...]|) / q_max(bits)`
489    /// then quantizes-then-dequantizes:
490    ///   `out[i] = clamp(round(x[i] / s[c]), -q_max, q_max) * s[c]`
491    /// where `q_max` is `127` for `bits=8`, `7` for `bits=4`, `1` for
492    /// `bits=2` (ternary). Symmetric only — zero-point is always 0.
493    ///
494    /// The point of this op is to make the SGD optimizer "see" the
495    /// deployment-time rounding during training. Backward is the
496    /// **straight-through estimator** (STE): the gradient passes
497    /// through (variant chosen by `ste`), ignoring the discontinuity
498    /// at the round. Without STE the rounding would have zero
499    /// gradient almost everywhere and learning would stop.
500    ///
501    /// Inserted by the trainer on conv / FC weight tensors when
502    /// `--qat` is on; the existing `Op::Quantize` / packing path at
503    /// the end of training still handles the deployment-side
504    /// conversion to `i8`/`i4`/`i2` codes.
505    FakeQuantize {
506        bits: u8,
507        axis: Option<usize>,
508        ste: SteKind,
509        scale_mode: ScaleMode,
510    },
511
512    /// Learned Step Size Quantization (LSQ; Esser et al. 2020,
513    /// `arXiv:1902.08153`). Like `FakeQuantize` but the per-channel
514    /// `scale` is a *learned parameter*, passed as the second input.
515    /// Forward is identical to `FakeQuantize` with a fixed scale:
516    ///   `out[i] = clamp(round(x[i]/s[c]), -q_max, q_max) * s[c]`
517    /// Backward computes both `dx` (STE) and `dscale[c]` via the
518    /// closed-form gradient:
519    ///   `dscale[c] = sum_i ψ(x[i]/s[c]) · upstream[i]`
520    /// where `ψ(z) = -z + round(z)` if `|z| ≤ q_max` else
521    /// `sign(z) · q_max`. Routinely beats per-batch and EMA at
522    /// tight bit widths (i2 / i3).
523    ///
524    /// Inputs: `[x, scale]`. `scale` is `[chan_dim]` f32 (matches
525    /// `axis`); for `axis = None` it's `[1]`.
526    FakeQuantizeLSQ {
527        bits: u8,
528        axis: Option<usize>,
529    },
530
531    /// Backward pass for `Op::FakeQuantizeLSQ`. Computes BOTH the
532    /// gradient w.r.t. `x` (STE) and the gradient w.r.t. `scale`
533    /// (closed-form). Output shape matches `x`; the `scale` gradient
534    /// is reduced separately by `LsqScaleGradient`.
535    /// Inputs: `[x, scale, dy]`. Output: `dx`, same shape as `x`.
536    FakeQuantizeLSQBackwardX {
537        bits: u8,
538        axis: Option<usize>,
539    },
540
541    /// Companion to `FakeQuantizeLSQBackwardX`: computes the
542    /// `[chan_dim]` per-channel scale gradient. Inputs `[x, scale, dy]`.
543    /// Output shape matches `scale`.
544    FakeQuantizeLSQBackwardScale {
545        bits: u8,
546        axis: Option<usize>,
547    },
548
549    // ── Element-wise binary ─────────────────────────────────────
550    /// Binary op with broadcasting: two inputs, output shape is broadcast result.
551    Binary(BinaryOp),
552
553    // ── Comparison ──────────────────────────────────────────────
554    /// Element-wise comparison: two inputs, Bool output.
555    Compare(CmpOp),
556
557    /// Select elements: cond (Bool), on_true, on_false → output.
558    Where,
559
560    /// Fused multiply-add with a SINGLE rounding: `a*b + c`. Distinct from a
561    /// `mul` then `add` (two roundings) — the single rounding is what makes
562    /// error-free transforms (TwoProduct) and compensated/double-word
563    /// arithmetic possible. Inputs: a, b, c.
564    ///
565    /// The native (single-rounding) kernel is **elementwise on equal-shaped
566    /// operands** — broadcast a scalar to the common shape first. Backends
567    /// without a native FMA fall back to `mul`+`add` via `LowerFma` (two
568    /// roundings, but broadcasts), so this only constrains the precise path.
569    Fma,
570
571    /// Fused element-wise region (PLAN L2). Holds an N-step chain of
572    /// element-wise operations. Inputs are referenced by index 0..num_inputs;
573    /// each step's result can be referenced by later steps via
574    /// `ChainOperand::Step(idx)`. The output is the last step's result.
575    /// Emitted by `MarkElementwiseRegions` in `rlx-opt` from chains of
576    /// Activation/Cast/Binary/Compare/Where ops with single-consumer
577    /// intermediates and broadcast-compatible shapes. Backends that
578    /// don't have a region kernel can decompose back to the original
579    /// chain via `unfuse::unfuse_elementwise_regions`.
580    ///
581    /// `scalar_input_mask` is a per-input bitfield (bit `i` set ⇒
582    /// input `i` is a scalar broadcast — has shape `[1]`). Kept as a
583    /// fast-path indicator that lets kernels skip the modulo entirely
584    /// when they detect a scalar.
585    ///
586    /// `input_modulus[i]` is the per-input element count, used by
587    /// kernels to compute `arena[input_offs[i] + (gid % input_modulus[i])]`
588    /// — the trailing-shape broadcast pattern. `0` means "no broadcast"
589    /// (input matches the output element count; kernel reads `gid`
590    /// directly). `1` means scalar; any other value means the input
591    /// has fewer elements than the output and they tile by modulo.
592    /// The encoder only allows broadcasts where `out_elems % in_elems
593    /// == 0` so the modulo divides cleanly. Lets chains include bias /
594    /// scale / eps / mask factors that previously broke the chain at
595    /// a Binary op with mismatched shapes.
596    ElementwiseRegion {
597        chain: Vec<ChainStep>,
598        num_inputs: u32,
599        scalar_input_mask: u32,
600        input_modulus: [u32; 16],
601        /// FKL-style closed fusion: apply before the element-wise chain.
602        prologue: RegionPrologue,
603        /// External input index that supplies the prologue transform source (default 0).
604        prologue_input: u32,
605    },
606
607    /// Fused transform chain (resize, future crop/color). Decompose via
608    /// [`rlx_fusion::DecomposeFusionRegions`] when no native kernel exists.
609    TransformRegion {
610        steps: Vec<TransformStep>,
611        num_inputs: u32,
612    },
613
614    /// Identical [`Op::ElementwiseRegion`] chain over `num_batch_inputs` tensors
615    /// (horizontal / z-plane fusion). Inputs are separate batch slices.
616    BatchElementwiseRegion {
617        chain: Vec<ChainStep>,
618        num_batch_inputs: u32,
619        scalar_input_mask: u32,
620        input_modulus: [u32; 16],
621        prologue: RegionPrologue,
622        prologue_input: u32,
623    },
624
625    // ── Linear algebra ──────────────────────────────────────────
626    /// Matrix multiply. Inputs: [.., M, K] × [.., K, N] → [.., M, N].
627    /// Batch dimensions are broadcast.
628    MatMul,
629
630    /// Matrix multiply with explicit dimension specification.
631    /// Like XLA's DotGeneral — handles arbitrary batch/contracting dims.
632    DotGeneral {
633        lhs_contracting: Vec<usize>,
634        rhs_contracting: Vec<usize>,
635        lhs_batch: Vec<usize>,
636        rhs_batch: Vec<usize>,
637    },
638
639    /// Batched dense linear solve. Inputs: `A [B, N, N]`,
640    /// `b [B, N]` or `b [B, N, K]`. Output: same shape as `b`.
641    ///
642    /// Per-batch independent solve — each `A[i]` and `b[i]` are
643    /// solved as a separate `Op::DenseSolve`. Emitted by vmap of
644    /// `Op::DenseSolve`. The CPU lowering loops over the batch
645    /// dimension calling `dgesv` per slice (LAPACK doesn't expose a
646    /// batched solve on Accelerate; cuSOLVER does on NVIDIA).
647    BatchedDenseSolve,
648
649    /// Dense linear solve `x = A⁻¹ · b` via LU factorization.
650    /// Inputs: `A [N, N]`, `b [N]` (or `b [N, K]` for multi-RHS).
651    /// Output: same shape as `b`.
652    ///
653    /// VJP via the implicit-function theorem:
654    ///   `dx = solve(Aᵀ, upstream)`
655    ///   `dA = -outer(dx, x)`   (x is the forward output)
656    ///   `db = dx`
657    /// The rule is dtype-agnostic; lowering is per-backend (Accelerate
658    /// `dgesv` / `sgesv`, cuSOLVER, etc.).
659    DenseSolve,
660
661    // ── Normalization ───────────────────────────────────────────
662    /// Layer normalization: input, gamma, beta → normalized output.
663    /// `axis` is the feature dimension (usually -1).
664    LayerNorm {
665        axis: i32,
666        eps: f32,
667    },
668
669    /// Group normalization on NCHW tensors: `input`, `gamma`, `beta` → same shape.
670    /// Normalizes over `(C/num_groups) × H × W` per group.
671    GroupNorm {
672        num_groups: usize,
673        eps: f32,
674    },
675
676    /// LayerNorm2d on NCHW: normalize across the channel axis at each spatial
677    /// position (candle / SAM `LayerNorm2d` semantics — not PyTorch's H×W norm).
678    LayerNorm2d {
679        eps: f32,
680    },
681
682    /// Nearest-neighbor 2× upsample on NCHW (doubles spatial dims 2 and 3).
683    ResizeNearest2x,
684
685    /// RMS normalization: input, gamma → normalized output.
686    RmsNorm {
687        axis: i32,
688        eps: f32,
689    },
690
691    /// BatchNorm inference with frozen running statistics.
692    /// Inputs: `x`, `gamma`, `beta`, `running_mean`, `running_var`.
693    /// Feature dimension is the last axis of `x`; stats are 1-D `[C]`.
694    BatchNormInference {
695        eps: f32,
696    },
697
698    // ── Attention ───────────────────────────────────────────────
699    /// Scaled dot-product attention: Q, K, V, \[mask\] → output.
700    /// The compiler can lower this to fused SDPA or flash attention.
701    /// `mask_kind` controls how masking is applied — `Custom` reads from
702    /// the 4th input tensor; `None` / `Causal` / `SlidingWindow` skip the
703    /// mask load and apply the mask directly in the inner loop. See
704    /// `MaskKind` for the rationale.
705    ///
706    /// `score_scale`: when `Some(s)`, dot-product scores are multiplied by
707    /// `s` instead of the default `1/sqrt(head_dim)` (Gemma uses `head_dim^-0.5`
708    /// explicitly in config). `attn_logit_softcap`: when `Some(c)`, applies
709    /// `c * tanh(s/c)` to scores before softmax (Gemma 2).
710    ///
711    /// ## Operand layout
712    ///
713    /// Q/K/V (and the output) carry the heads explicitly and are accepted in two
714    /// rank-4 layouts — both end in `head_dim`, so backends disambiguate by which
715    /// axis equals `num_heads`:
716    ///
717    /// - **`[B, S, H, D]`** (heads at axis 2) — the dominant convention, produced
718    ///   by Llama/Moshi-style `reshape([B, S, H, D])` after the QKV projection.
719    ///   The CPU/Metal/MLX/wgpu kernels attend over axis 1 (`S`); CoreML
720    ///   transposes to `[B, H, S, D]` first (see `rlx-coreml`).
721    /// - **`[B, H, S, D]`** (heads at axis 1) — already head-canonical.
722    ///
723    /// Rank-3 `[B, S, D]` is single-head and head-canonical. `K`/`V` may have a
724    /// different sequence length than `Q` (`S_k >= S_q`) for KV-cache decode; the
725    /// causal mask places the `S_q` queries at the **end** of the `S_k` keys
726    /// (query `qi` is at absolute position `S_k - S_q + qi`).
727    Attention {
728        num_heads: usize,
729        head_dim: usize,
730        mask_kind: MaskKind,
731        score_scale: Option<f32>,
732        attn_logit_softcap: Option<f32>,
733    },
734
735    /// Rotary position embedding applied to one tensor: x, cos, sin → x_rotated.
736    /// Apply separately to Q and K. `head_dim` is the per-head width; `n_rot`
737    /// is how many leading dims get NeoX RoPE (pair offset `n_rot/2`). When
738    /// `n_rot < head_dim`, trailing dims are copied unchanged (Qwen3.5 MRoPE).
739    Rope {
740        head_dim: usize,
741        n_rot: usize,
742        /// Rotation pairing convention (default [`RopeStyle::NeoX`]).
743        style: RopeStyle,
744    },
745
746    /// SAM2 axial 2-D RoPE on `[batch, seq, num_heads * head_dim]`.
747    AxialRope2d {
748        end_x: usize,
749        end_y: usize,
750        head_dim: usize,
751        num_heads: usize,
752        theta: f32,
753        repeat_factor: usize,
754    },
755
756    // ── Shape manipulation ──────────────────────────────────────
757    Reshape {
758        new_shape: Vec<i64>,
759    },
760    Transpose {
761        perm: Vec<usize>,
762    },
763    /// Select a contiguous slice along an axis.
764    Narrow {
765        axis: usize,
766        start: usize,
767        len: usize,
768    },
769    /// Concatenate along an axis.
770    Concat {
771        axis: usize,
772    },
773    /// Expand (broadcast) to a target shape.
774    Expand {
775        target_shape: Vec<i64>,
776    },
777    /// Gather elements by index along an axis (embedding lookup).
778    Gather {
779        axis: usize,
780    },
781    /// Reverse (flip) the element order along each listed axis. Output shape
782    /// equals the input shape; element `[…, i, …]` on a reversed axis of size
783    /// `d` reads input `[…, d-1-i, …]`. Batch-general — only the listed axes
784    /// flip, all others pass through unchanged (the right primitive for
785    /// reversing a `[batch, seq, …]` sequence without a batch=1 assumption).
786    Reverse {
787        axes: Vec<usize>,
788    },
789
790    // ── Reduction ───────────────────────────────────────────────
791    /// Reduce along specified axes.
792    Reduce {
793        op: ReduceOp,
794        axes: Vec<usize>,
795        keep_dim: bool,
796    },
797
798    /// Selective scan (plan #15) — Mamba-style state-space model
799    /// step. The recurrence:
800    ///   `h[t] = exp(Δ[t] * A) * h[t-1] + Δ[t] * B[t] * x[t]`
801    ///   `y[t] = C[t] * h[t]`
802    /// where state `h` has dimension `state_size` and the input has
803    /// `(batch, seq, hidden)`.
804    ///
805    /// Inputs (in order):
806    ///   `x [b, s, h]`      f32 input
807    ///   `delta [b, s, h]`  f32 step size (per-position, per-channel)
808    ///   `a [h, n]`         f32 transition matrix (one per channel)
809    ///   `b [b, s, n]`      f32 input projection
810    ///   `c [b, s, n]`      f32 output projection
811    /// Output: `[b, s, h]` f32. State `h` is implicit; the kernel
812    /// scans through the seq dimension carrying it.
813    ///
814    /// `state_size` = `n` is exposed for the cost model.
815    SelectiveScan {
816        state_size: usize,
817    },
818
819    /// Gated DeltaNet linear-attention recurrence — the per-layer
820    /// kernel used by Qwen3.5/3.6 trunk "linear attention" blocks
821    /// (and Qwen3-Next, Kimi-Linear). Mirrors
822    /// `llama.cpp / src/models/delta-net-base.cpp` autoregressive
823    /// path; chunked + fused variants ride the same op identity.
824    ///
825    /// **Math (per token `t`, head `h`, state size `n`):**
826    /// state matrix `S[h, i, j]` is implicit (reset per batch).
827    /// ```text
828    ///   S[h]     *= exp(g[t,h])                     # scalar gate
829    ///   sk[h,j]   = Σ_i S[h,i,j] * k[t,h,i]
830    ///   d[h,j]    = (v[t,h,j] - sk[h,j]) * b[t,h]   # b = beta
831    ///   S[h,i,j] += k[t,h,i] * d[h,j]               # outer-prod
832    ///   o[t,h,j]  = Σ_i S[h,i,j] * (q[t,h,i] / √n)
833    /// ```
834    ///
835    /// Inputs:
836    ///   `q     [b, s, h_v, n]`  f32 queries (L2-normed by caller)
837    ///   `k     [b, s, h_v, n]`  f32 keys    (L2-normed by caller;
838    ///                            GQA-repeated to match `h_v`)
839    ///   `v     [b, s, h_v, n]`  f32 values
840    ///   `g     [b, s, h_v]`     f32 log-gate (exp'd inside kernel)
841    ///   `beta  [b, s, h_v]`     f32 delta-rule mixing factor
842    ///
843    /// Output: `[b, s, h_v, n]` f32.
844    ///
845    /// When `carry_state` is true, a sixth input `state [b, h_v, n, n]`
846    /// provides the initial SSM matrix per head; the kernel updates it
847    /// in place across the sequence and leaves the final state in the
848    /// same buffer (same layout as the internal scan state:
849    /// `state[h, i, j]` row-major over `(n, n)` per head).
850    GatedDeltaNet {
851        state_size: usize,
852        carry_state: bool,
853    },
854
855    /// Multi-layer (optionally bidirectional) LSTM over a
856    /// `[batch, seq, input]` sequence. Gate order i, f, g, o (PyTorch);
857    /// recurrence per step:
858    /// ```text
859    ///   z      = x_t · w_ihᵀ + h_{t-1} · w_hhᵀ + bias
860    ///   i,f,o  = σ(z_i), σ(z_f), σ(z_o);   g = tanh(z_g)
861    ///   c_t    = f · c_{t-1} + i · g;      h_t = o · tanh(c_t)
862    /// ```
863    /// `D = 2 if bidirectional else 1`. Inputs `[x, w_ih, w_hh, bias]`
864    /// (`+ [h0, c0]` when `carry`):
865    ///   * `x`:    `[batch, seq, input]`
866    ///   * `w_ih`: packed, all `(layer, direction)` blocks concatenated in
867    ///     `layer`-major then `direction` order. Block `(l,d)` is
868    ///     `[4*hidden, in_l]` with `in_l = input` for `l=0` else `D*hidden`.
869    ///   * `w_hh`: packed `L*D` blocks of `[4*hidden, hidden]`.
870    ///   * `bias`: packed `L*D` blocks of `[4*hidden]` (combined `b_ih+b_hh`).
871    ///   * `h0`, `c0` (carry only): `[L*D, batch, hidden]`; the final
872    ///     `hn`/`cn` are written back **in place** (decode threading).
873    ///
874    /// Output `y`: `[batch, seq, D*hidden]` — last layer's hidden states
875    /// (forward ‖ reverse concatenated on the feature axis). With
876    /// `num_layers = 1, bidirectional = false, carry = false` this is the
877    /// plain single-layer LSTM and the weight shapes reduce to
878    /// `[4*hidden, input]`, `[4*hidden, hidden]`, `[4*hidden]`.
879    Lstm {
880        hidden_size: usize,
881        num_layers: usize,
882        bidirectional: bool,
883        carry: bool,
884    },
885
886    /// Gated Recurrent Unit (PyTorch). `D = 2 if bidirectional else 1`;
887    /// gate order r, z, n. Recurrence:
888    /// ```text
889    ///   r = σ(x·W_irᵀ + b_ir + h·W_hrᵀ + b_hr)
890    ///   z = σ(x·W_izᵀ + b_iz + h·W_hzᵀ + b_hz)
891    ///   n = tanh(x·W_inᵀ + b_in + r ⊙ (h·W_hnᵀ + b_hn))
892    ///   h' = (1 - z) ⊙ n + z ⊙ h
893    /// ```
894    /// The new-gate reset is applied to the hidden term *after* its bias,
895    /// so `b_ih`/`b_hh` cannot be merged. Inputs `[x, w_ih, w_hh, b_ih, b_hh]`
896    /// (`+ [h0]` when carry); packing matches [`Op::Lstm`] with `3*hidden`
897    /// gate rows. `h0` `[L*D, batch, hidden]`. Output `[batch, seq, D*hidden]`.
898    Gru {
899        hidden_size: usize,
900        num_layers: usize,
901        bidirectional: bool,
902        carry: bool,
903    },
904
905    /// Elman RNN (PyTorch): `h' = act(x·w_ihᵀ + h·w_hhᵀ + bias)` with
906    /// `act = ReLU` when `relu` else `tanh`. Packed `w_ih` `[hidden, in_l]`,
907    /// `w_hh` `[hidden, hidden]`, merged `bias` `[hidden]` (per layer ×
908    /// direction). Inputs `[x, w_ih, w_hh, bias]` (`+ [h0]` when carry).
909    /// Output `[batch, seq, D*hidden]`.
910    Rnn {
911        hidden_size: usize,
912        num_layers: usize,
913        bidirectional: bool,
914        carry: bool,
915        relu: bool,
916    },
917
918    /// Mamba-2 / SSD (structured state-space duality) scan — the
919    /// scalar-decay SSM at the core of Mamba-2, sibling of
920    /// [`Op::SelectiveScan`] / [`Op::GatedDeltaNet`]. Inputs
921    /// `[x, dt, a, b, c]` (all `f32`):
922    ///   * `x`:  `[batch, seq, heads, head_dim]`
923    ///   * `dt`: `[batch, seq, heads]` — discretization step (already ≥ 0,
924    ///     e.g. softplus'd by the caller)
925    ///   * `a`:  `[heads]` — per-head decay rate (`dA = exp(dt·a)`)
926    ///   * `b`:  `[batch, seq, heads, state_size]`
927    ///   * `c`:  `[batch, seq, heads, state_size]`
928    ///
929    /// State `S [batch, heads, head_dim, state_size]` is zero-initialized
930    /// per sequence. Recurrence per timestep `t`:
931    /// ```text
932    ///   dA_t = exp(dt_t · a)
933    ///   S_t  = dA_t · S_{t-1} + (dt_t · x_t) ⊗ b_t
934    ///   y_t  = Σ_n S_t[:, n] · c_t[n]
935    /// ```
936    /// Output `y`: `[batch, seq, heads, head_dim]` (same shape as `x`).
937    Mamba2 {
938        head_dim: usize,
939        state_size: usize,
940    },
941
942    /// Fused dequant + matmul (plan #5). The biggest LLM-bandwidth
943    /// win on Apple Silicon: dequantizes weights inside the matmul
944    /// inner loop, never materializing f32 weights.
945    ///
946    /// **BREAKING CHANGE in 0.2.0:** `num_inputs()` is now
947    /// scheme-dependent — **4** for legacy Int8 schemes, **2** for
948    /// the new GGUF K-quant schemes (their scales/mins live inside
949    /// the packed bytes, so no side-channel `scale` / `zp` tensors
950    /// are fed in). Callers that assumed a fixed 4-input contract
951    /// must inspect `scheme.is_gguf()` before reading inputs.
952    ///
953    /// Inputs (Int8 schemes — `scheme.is_gguf() == false`):
954    ///   `x [m, k]`             f32 activations
955    ///   `w_q [k, n]` packed    quantized weight bytes (i8 per
956    ///                          element for Int8 schemes; 4-bit
957    ///                          packed two-per-byte for Int4)
958    ///   `scale [k/block, n]`   per-block f32 dequant scale
959    ///   `zp    [k/block, n]`   per-block f32 zero-point
960    ///                          (zero-tensor if symmetric)
961    ///
962    /// Inputs (`Nvfp4Block` — fixed group size 16 along K):
963    ///   `x [m, k]`             f32 activations
964    ///   `w_q [k,n/2]` packed   FP4 E2M1 codes (unsigned nibble 0..15)
965    ///   `scale [k/16, n]` u8   FP8 E4M3 block scales (one byte / group)
966    ///   `global_scale [1]` f32 per-tensor scale (pass `[1.0]` if unused)
967    ///
968    /// Inputs (GGUF schemes — `scheme.is_gguf() == true`):
969    ///   `x [m, k]`             f32 activations
970    ///   `packed_w [bytes]`     raw GGUF super-block bytes; the
971    ///                          dequantizer reads the per-sub-block
972    ///                          scales / mins / quants directly out
973    ///                          of the buffer per the K-quant block
974    ///                          layout (no side tensors).
975    ///
976    /// Output: `[m, n]` f32.
977    ///
978    /// `block_size` (on the Int8 schemes only) is the number of
979    /// consecutive elements that share one (scale, zero_point) pair.
980    /// The Op carries enough metadata that the kernel doesn't need
981    /// a separate `QuantMap` lookup at run time.
982    DequantMatMul {
983        scheme: crate::quant::QuantScheme,
984    },
985
986    /// Real INT8-arithmetic matrix multiply with i32 accumulation.
987    /// Inputs (in order):
988    ///   `x      [M, K]`  i8 activations (zero-point = `x_zp`)
989    ///   `w      [K, N]`  i8 weights     (zero-point = `w_zp`)
990    ///   `bias   [N]`     i32 (in accumulator scale = `x_scale·w_scale`),
991    ///                    pass a zeros tensor for "no bias"
992    /// Output:  `[M, N]`  i8 (zero-point = `out_zp`)
993    ///
994    /// Per-element compute:
995    ///   `out[m,n] = requantize(bias[n] + Σₖ (x[m,k]-x_zp)·(w[k,n]-w_zp), mult, out_zp)`
996    /// where `mult = x_scale · w_scale / out_scale`.
997    ///
998    /// This is the same kernel shape `rlx-cortexm/src/dense.rs`
999    /// uses for on-device int8 inference, lifted into the IR so the
1000    /// rlx-cpu backend can run a quantized graph directly (instead
1001    /// of round-tripping through fake-quant Dequantize → MatMul →
1002    /// Quantize). 2-D only — generalizing to batched comes when a
1003    /// real workload demands it.
1004    QMatMul {
1005        x_zp: i32,
1006        w_zp: i32,
1007        out_zp: i32,
1008        mult: f32,
1009    },
1010
1011    /// Real INT8-arithmetic 2-D convolution with i32 accumulation.
1012    /// Inputs:
1013    ///   `x      [N, C_in, H, W]`              i8 (zero-point = `x_zp`)
1014    ///   `w      [C_out, C_in/groups, kH, kW]` i8 (zero-point = `w_zp`)
1015    ///   `bias   [C_out]`                      i32 in accumulator scale
1016    /// Output: `[N, C_out, H_out, W_out]` i8 (zero-point = `out_zp`).
1017    /// Same NCHW geometry contract as `Op::Conv`; same requantize
1018    /// math as `Op::QMatMul` (per-element `acc·mult` rounded to i8).
1019    QConv2d {
1020        kernel_size: Vec<usize>,
1021        stride: Vec<usize>,
1022        padding: Vec<usize>,
1023        dilation: Vec<usize>,
1024        groups: usize,
1025        x_zp: i32,
1026        w_zp: i32,
1027        out_zp: i32,
1028        mult: f32,
1029    },
1030
1031    /// **Native low-precision tensor-core GEMM** with f32 accumulation —
1032    /// both operands are sub-f16 codes fed *directly* into the matmul, the
1033    /// opposite of `DequantMatMul` (which decodes weights to f32 first and
1034    /// keeps `x` in f32). This is the path that wins on Hopper / Ada /
1035    /// Blackwell (cublasLt FP8/FP4) and CDNA3 / CDNA4 (hipBLASLt). On CPU /
1036    /// Metal it is the decode-and-accumulate *reference* (no fp8 matrix HW).
1037    ///
1038    /// Layout is **TN** — both operands are K-last (`out = lhs · rhsᵀ`) so
1039    /// every block scale runs along the last/contraction axis uniformly, which
1040    /// is also cuBLASLt / hipBLASLt FP8's preferred layout.
1041    ///
1042    /// Inputs (in order):
1043    ///   `lhs   [m, k]`  U8 — packed [`crate::ScaledFormat`] codes (logical dims)
1044    ///   `rhs   [n, k]`  U8 — packed codes (K-last, i.e. weight transposed)
1045    ///   `lhs_s        `  scale tensor, dtype per [`crate::ScaleLayout::scale_dtype`]
1046    ///   `rhs_s        `  scale tensor
1047    ///   `bias  [n]    `  f32, present iff `has_bias`
1048    /// Output: `[m, n]` f32. Reconstruction: `decode(code)·scale(block)`.
1049    ///
1050    /// Operand `Shape`s carry **logical** element dims with `dtype = U8`; the
1051    /// physical byte layout (1 code/byte on the CPU oracle, packed nibbles on
1052    /// GPU) is a backend-paired detail shared by `ScaledQuantize` and the
1053    /// matmul kernel, so it never needs a `DType` variant.
1054    ScaledMatMul {
1055        lhs_format: crate::quant::ScaledFormat,
1056        rhs_format: crate::quant::ScaledFormat,
1057        scale_layout: crate::quant::ScaleLayout,
1058        has_bias: bool,
1059    },
1060
1061    /// Quantize an f32/f16 tensor to packed low-precision codes for
1062    /// [`Op::ScaledMatMul`]. Inputs: `x` (f32) and `scale` (from
1063    /// [`Op::ScaledQuantScale`]); output is `DType::U8` codes of the same
1064    /// logical shape as `x`. `code[i] = encode(format, x[i] / scale(block_of(i)))`.
1065    ScaledQuantize {
1066        format: crate::quant::ScaledFormat,
1067        scale_layout: crate::quant::ScaleLayout,
1068    },
1069
1070    /// Compute the (per-tensor or per-block) scale tensor for a tensor about
1071    /// to be quantized to `format`. Input: `x` (f32). Output dtype follows
1072    /// [`crate::ScaleLayout::scale_dtype`]. Split from `ScaledQuantize` to keep
1073    /// the IR single-output (mirrors the `FakeQuantizeLSQBackwardX/Scale` split).
1074    ScaledQuantScale {
1075        format: crate::quant::ScaledFormat,
1076        scale_layout: crate::quant::ScaleLayout,
1077    },
1078
1079    /// Reconstruct f32 from packed low-precision codes: `decode(code)·scale`.
1080    /// The inverse of [`Op::ScaledQuantize`]. Inputs: `codes` (U8), `scale`;
1081    /// output is f32 with the same logical shape as `codes`. Used by the
1082    /// straight-through VJP of [`Op::ScaledMatMul`] to rebuild operands in the
1083    /// backward graph (and as a standalone dequantizer).
1084    ScaledDequantize {
1085        format: crate::quant::ScaledFormat,
1086        scale_layout: crate::quant::ScaleLayout,
1087    },
1088
1089    /// Fused LoRA matmul: `out = x·W + scale * x·A·B`.
1090    /// Inputs (in order): `x [m, k]`, `w [k, n]`, `a [k, r]`, `b [r, n]`.
1091    /// `r` is the LoRA rank (typically 4-64). `scale` is the
1092    /// per-adapter `alpha / rank` knob.
1093    /// Plan #9: lifts LoRA from "three matmuls + an add" into one
1094    /// kernel that keeps the rank-r intermediate in registers.
1095    LoraMatMul {
1096        scale: f32,
1097    },
1098
1099    /// Fused sampling kernel: logits → optional top-k filter →
1100    /// optional top-p truncation → softmax → multinomial sample.
1101    /// One f32-encoded sampled token id per batch row (output
1102    /// shape `[batch]`).
1103    ///
1104    /// `temperature == 1.0` matches a plain argmax-of-softmax;
1105    /// lower → sharper, higher → flatter. `top_k == 0` disables.
1106    /// `top_p == 1.0` disables. `seed` is the Philox seed; pass 0
1107    /// for "use process-global counter" (still deterministic
1108    /// given the call order).
1109    /// Borrowed from MAX's nn/sampling.mojo (#42 in PLAN.md).
1110    /// Latency-critical: never materializes the full softmax
1111    /// distribution on the host.
1112    Sample {
1113        top_k: usize,     // 0 = disabled
1114        top_p: f32,       // 1.0 = disabled
1115        temperature: f32, // 1.0 = neutral
1116        seed: u64,        // 0 = use thread-local counter
1117    },
1118
1119    /// ONNX `RandomNormalLike` / `RandomNormal`: zero or one shape-template
1120    /// input (Like uses the template's shape; `RandomNormal` with a `shape`
1121    /// attribute needs no input). Output shape is fixed on the node.
1122    /// at compile/execute time. Optional ONNX `seed` attribute (f32) overrides
1123    /// the mixed seed on the Ort backend.
1124    RngNormal {
1125        mean: f32,
1126        scale: f32,
1127        key: u64,
1128        op_seed: Option<f32>,
1129    },
1130
1131    /// ONNX `RandomUniformLike`.
1132    RngUniform {
1133        low: f32,
1134        high: f32,
1135        key: u64,
1136        op_seed: Option<f32>,
1137    },
1138
1139    /// Inclusive cumulative sum along an axis. Same shape in/out.
1140    /// Underpins ragged-tensor offsets, sampling (top-p prefix sum),
1141    /// and sequence-position math (#44 in PLAN.md).
1142    /// `exclusive=true` shifts the result so output\[0\] = 0 (useful
1143    /// for offset arrays where the first segment starts at 0).
1144    Cumsum {
1145        axis: i32,
1146        exclusive: bool,
1147    },
1148
1149    /// Index of the maximum along `axis`. Output drops that axis (or keeps it
1150    /// as size 1 when `keep_dim`). Indices are **f32-encoded** (rlx is f32 at
1151    /// the I/O boundary, matching [`Op::TopK`]).
1152    ArgMax {
1153        axis: usize,
1154        keep_dim: bool,
1155    },
1156
1157    /// Index of the minimum along `axis`; see [`Op::ArgMax`].
1158    ArgMin {
1159        axis: usize,
1160        keep_dim: bool,
1161    },
1162
1163    /// Softmax along an axis (reduction + element-wise).
1164    Softmax {
1165        axis: i32,
1166    },
1167
1168    /// Top-K **indices** along the last axis. Output shape `[..., k]`,
1169    /// f32-encoded indices (rlx is f32-only at the I/O boundary).
1170    /// To recover the values, follow with a `Gather` against the
1171    /// original tensor — works because Gather already supports any axis.
1172    /// Ties broken by smaller index (matches NumPy / PyTorch
1173    /// `torch.topk(..., largest=True, sorted=True)`).
1174    /// Used by MoE gating; also useful for beam search.
1175    TopK {
1176        k: usize,
1177    },
1178
1179    /// Indexed batched matmul. The MoE GEMM primitive.
1180    /// Inputs: `[input, weight, expert_idx]`
1181    ///   input       : [M, K]                — per-token activations
1182    ///   weight      : [num_experts, K, N]   — stacked expert weights
1183    ///   expert_idx  : \[M\]                   — f32-encoded expert id per token
1184    /// Output         : [M, N]                — output\[i\] = input\[i\] @ weight[expert_idx\[i\]]
1185    /// CPU is a real segmented GEMM: counting-sort tokens by expert, one GEMM per
1186    /// expert, then unpermute. GPU backends dispatch a dedicated grouped kernel.
1187    GroupedMatMul,
1188
1189    /// Fused GGUF K-quant dequant + [`Op::GroupedMatMul`]. Same three
1190    /// inputs as `GroupedMatMul`, but `weight` is a U8 tensor holding
1191    /// `num_experts` contiguous packed expert slabs (GGML layout, expert
1192    /// dimension outermost). Scales live inside the packed bytes.
1193    DequantGroupedMatMul {
1194        scheme: crate::quant::QuantScheme,
1195    },
1196
1197    /// Dequant a packed MoE expert stack to F32 `[num_experts, K, N]` in
1198    /// GroupedMatMul layout. Input: U8 packed bytes; output shape is
1199    /// declared on the node (`[E, K, N]`).
1200    DequantMoEWeights {
1201        scheme: crate::quant::QuantScheme,
1202    },
1203
1204    /// Scatter-add into a destination tensor. The "unpermute" half of
1205    /// MoE routing (also useful for embedding gradient updates).
1206    /// Inputs: `[updates, indices]`
1207    ///   updates : [num_updates, trailing]   — values to add
1208    ///   indices : \[num_updates\]             — f32-encoded destination row
1209    /// Output    : [out_dim, trailing]       — output[indices\[i\]] += updates\[i\]
1210    /// `out_dim` is taken from the node's declared output shape.
1211    /// Initial output is zero; multiple updates to the same row
1212    /// accumulate (sequentially on CPU; with atomic-add on Metal).
1213    ScatterAdd,
1214
1215    // ── Convolution ─────────────────────────────────────────────
1216    /// 2D convolution on NCHW tensors. Also exposed as [`OpKind::Conv`] / `conv2d`.
1217    /// Weight layout: `[C_out, C_in / groups, kH, kW]`.
1218    Conv {
1219        kernel_size: Vec<usize>,
1220        stride: Vec<usize>,
1221        padding: Vec<usize>,
1222        dilation: Vec<usize>,
1223        groups: usize,
1224    },
1225
1226    /// NCHW im2col for conv backward-weight style matmul.
1227    /// Input `[N, C, H, W]`. Output `[M, C·kH·kW]` with
1228    /// `M = N · H_out · W_out`. When batch is [`dynamic::sym::BATCH`],
1229    /// output rows use [`dynamic::sym::ROWS`] (bind `N · H_out · W_out`).
1230    Im2Col {
1231        kernel_size: Vec<usize>,
1232        stride: Vec<usize>,
1233        padding: Vec<usize>,
1234        dilation: Vec<usize>,
1235    },
1236
1237    /// 2D transposed convolution on NCHW. Weight layout (PyTorch):
1238    /// `[C_in, C_out / groups, kH, kW]`.
1239    ConvTranspose2d {
1240        kernel_size: Vec<usize>,
1241        stride: Vec<usize>,
1242        padding: Vec<usize>,
1243        dilation: Vec<usize>,
1244        output_padding: Vec<usize>,
1245        groups: usize,
1246    },
1247
1248    /// 3D convolution on NCDHW tensors (forward / cross-correlation).
1249    /// Weight layout: `[C_out, C_in / groups, kD, kH, kW]`. Kernel size is
1250    /// derived from the weight shape. Mirrors [`Op::Conv`] with a depth axis.
1251    Conv3d {
1252        stride: [usize; 3],
1253        padding: [usize; 3],
1254        dilation: [usize; 3],
1255        groups: usize,
1256    },
1257
1258    /// 3D transposed convolution on NCDHW. Weight layout (PyTorch):
1259    /// `[C_in, C_out / groups, kD, kH, kW]`. Mirrors [`Op::ConvTranspose2d`].
1260    ConvTranspose3d {
1261        stride: [usize; 3],
1262        padding: [usize; 3],
1263        dilation: [usize; 3],
1264        output_padding: [usize; 3],
1265        groups: usize,
1266    },
1267
1268    // ── Pooling ─────────────────────────────────────────────────
1269    Pool {
1270        kind: ReduceOp,
1271        kernel_size: Vec<usize>,
1272        stride: Vec<usize>,
1273        padding: Vec<usize>,
1274    },
1275
1276    // ── Backward / training ops ─────────────────────────────────
1277    //
1278    // Closed-form gradient nodes emitted by `rlx-opt::autodiff`.
1279    // Pairing a forward op with a dedicated backward op (instead of
1280    // composing it from primitives) keeps the gradient kernel simple
1281    // and lets the backend recompute argmax / masks / softmax inline.
1282    /// ReLU backward: `dx = dy where x > 0 else 0`.
1283    /// Inputs: `[x, dy]` — both same shape; output matches.
1284    ReluBackward,
1285
1286    /// Element-wise complex squared-magnitude: `|z|² = z.re² + z.im²`.
1287    /// Input: 1 tensor with `DType::C64`. Output: same shape but
1288    /// `DType::F32`. The natural real-valued loss surface for
1289    /// Wirtinger reverse-mode AD on complex graphs — pair with
1290    /// [`Op::ComplexNormSqBackward`].
1291    ComplexNormSq,
1292
1293    /// Element-wise complex conjugate: `z̄ = z.re - i·z.im` per element.
1294    /// Input: 1 tensor with `DType::C64`. Output: same shape, same dtype.
1295    /// Used by Wirtinger VJP rules on `Op::Binary` over C64 (the rule
1296    /// for `y = a·b` is `dL/dā = upstream · conj(b)`, etc.).
1297    Conjugate,
1298
1299    /// Backward for [`Op::ComplexNormSq`] under Wirtinger calculus.
1300    /// `f(z) = |z|² = z·z̄`, so `∂f/∂z̄ = z`. Given upstream real
1301    /// cotangent `g` (same shape as the forward output), the C64
1302    /// gradient with respect to `z` is `g · z` element-wise, returned
1303    /// in C64 storage `[re_g·re_z, re_g·im_z]` per element.
1304    ///
1305    /// Inputs: `[z (C64), g (F32)]` — both same logical shape; output
1306    /// matches `z` (C64).
1307    ComplexNormSqBackward,
1308
1309    /// LayerNorm backward w.r.t. the input. Computes
1310    ///   `d_x[..., d] = inv_std · (dy·γ - mean(dy·γ) - x̂·mean(dy·γ·x̂))`
1311    /// over the feature axis, where `x̂ = (x - mean)/std` is recomputed
1312    /// inline from `x`. Inputs: `[x, gamma, dy]`; output shape = `x.shape`.
1313    /// Currently lowers axis=-1 only (matches the forward thunk).
1314    LayerNormBackwardInput {
1315        axis: i32,
1316        eps: f32,
1317    },
1318
1319    /// LayerNorm backward w.r.t. gamma. Computes
1320    ///   `d_gamma[d] = Σ_{batch} dy[..., d] · x̂[..., d]`
1321    /// — sums the per-element product of upstream and the (recomputed)
1322    /// normalized input over the leading axes. Inputs: `[x, dy]`;
1323    /// output shape = `gamma.shape` (= 1-D feature axis).
1324    LayerNormBackwardGamma {
1325        axis: i32,
1326        eps: f32,
1327    },
1328
1329    /// RMSNorm backward w.r.t. input. Inputs `[x, gamma, beta, dy]`; output = `x.shape`.
1330    RmsNormBackwardInput {
1331        axis: i32,
1332        eps: f32,
1333    },
1334
1335    /// RMSNorm backward w.r.t. gamma. Inputs `[x, gamma, beta, dy]`; output = `gamma.shape`.
1336    RmsNormBackwardGamma {
1337        axis: i32,
1338        eps: f32,
1339    },
1340
1341    /// RMSNorm backward w.r.t. beta. Inputs `[x, gamma, beta, dy]`; output = `beta.shape`.
1342    RmsNormBackwardBeta {
1343        axis: i32,
1344        eps: f32,
1345    },
1346
1347    /// RoPE backward w.r.t. `x`. Inputs `[dy, cos, sin]`; output = `dy.shape`.
1348    RopeBackward {
1349        head_dim: usize,
1350        n_rot: usize,
1351    },
1352
1353    /// GroupNorm (NCHW) backward w.r.t. input. Inputs `[x, gamma, beta, dy]`.
1354    GroupNormBackwardInput {
1355        num_groups: usize,
1356        eps: f32,
1357    },
1358
1359    /// GroupNorm backward w.r.t. gamma. Inputs `[x, dy]`; output = `gamma.shape`.
1360    GroupNormBackwardGamma {
1361        num_groups: usize,
1362        eps: f32,
1363    },
1364
1365    /// GroupNorm backward w.r.t. beta. Inputs `[x, dy]`; output = `beta.shape`.
1366    GroupNormBackwardBeta {
1367        num_groups: usize,
1368        eps: f32,
1369    },
1370
1371    /// BatchNorm inference backward w.r.t. `x`. Inputs `[x, gamma, mean, var, dy]`.
1372    BatchNormInferenceBackwardInput {
1373        eps: f32,
1374    },
1375
1376    /// BatchNorm inference backward w.r.t. `gamma`. Inputs `[x, mean, var, dy]`.
1377    BatchNormInferenceBackwardGamma {
1378        eps: f32,
1379    },
1380
1381    /// BatchNorm inference backward w.r.t. `beta`. Inputs `[dy]`; output = `beta.shape`.
1382    BatchNormInferenceBackwardBeta,
1383
1384    /// Cumsum backward along `axis`. Inputs `[dy]`; output matches forward input shape.
1385    CumsumBackward {
1386        axis: i32,
1387        exclusive: bool,
1388    },
1389
1390    /// Gather backward (scatter-add into table). Inputs `[dy, indices]`; output = table shape.
1391    /// `axis` matches forward [`Op::Gather`].
1392    GatherBackward {
1393        axis: i32,
1394    },
1395
1396    /// Generic element-wise activation backward. `kind` selects the
1397    /// closed-form derivative `d/dx act(x)`. Inputs: `[x, dy]`; output
1398    /// shape matches `x`. The kernel computes `d/dx · dy` per element.
1399    ///
1400    /// Closed forms (all element-wise):
1401    /// * `Gelu`     — exact derivative of erf-based GELU.
1402    /// * `GeluApprox` — derivative of the tanh approximation
1403    ///   `0.5 x (1 + tanh(√(2/π)(x + 0.044715 x³)))`.
1404    /// * `Silu`     — `σ(x)·(1 + x·(1 - σ(x)))`.
1405    /// * `Sigmoid`  — `σ(x)·(1 - σ(x))`.
1406    /// * `Tanh`     — `1 - tanh(x)²`.
1407    /// * `Exp`      — `exp(x)`.
1408    /// * `Log`      — `1 / x`.
1409    /// * `Sqrt`     — `0.5 / sqrt(x)`.
1410    /// * `Rsqrt`    — `-0.5 · x^(-3/2)`.
1411    /// * `Neg`      — `-1`.
1412    /// * `Abs`      — `sign(x)` (zero at x=0).
1413    /// * `Sin`      — `cos(x)`.
1414    /// * `Cos`      — `-sin(x)`.
1415    /// * `Tan`      — `1 + tan²(x)`.
1416    /// * `Atan`     — `1 / (1 + x²)`.
1417    /// * `Relu`     — kept here for completeness; the dedicated
1418    ///   `ReluBackward` op is preferred for relu and is what the
1419    ///   autodiff pass actually emits.
1420    ActivationBackward {
1421        kind: Activation,
1422    },
1423
1424    /// Backward for `Op::FakeQuantize` under a non-default STE.
1425    /// Inputs `[x, dy]`: the forward input and the upstream
1426    /// gradient. Output `dx` same shape. The `bits`/`axis`/`ste`
1427    /// fields must match the forward op so the kernel computes the
1428    /// same per-channel scale and applies the right STE attenuation.
1429    /// For `SteKind::Identity` this op is unnecessary — autodiff
1430    /// just routes `upstream` through unchanged.
1431    FakeQuantizeBackward {
1432        bits: u8,
1433        axis: Option<usize>,
1434        ste: SteKind,
1435    },
1436
1437    /// 2D max-pool backward. Routes each element of `dy` back into the
1438    /// position in `x`'s window where the forward max was taken.
1439    /// Inputs: `[x, dy]` with `x [N, C, H, W]` and
1440    /// `dy [N, C, H_out, W_out]`. Output: same shape as `x`.
1441    /// Carries the forward pool's geometry so the kernel can recompute
1442    /// the argmax position per window without a saved-indices tensor.
1443    MaxPool2dBackward {
1444        kernel_size: Vec<usize>,
1445        stride: Vec<usize>,
1446        padding: Vec<usize>,
1447    },
1448
1449    /// 2D conv backward w.r.t. input. Computes `dx = conv_transpose(dy, w)`.
1450    /// Inputs: `[dy, w]` with `dy [N, C_out, H_out, W_out]` and
1451    /// `w [C_out, C_in/groups, kH, kW]`. Output: `[N, C_in, H, W]`
1452    /// (declared on the node — caller knows the original input shape).
1453    /// Geometry is the forward conv's parameters, not the transposed
1454    /// conv's.
1455    Conv2dBackwardInput {
1456        kernel_size: Vec<usize>,
1457        stride: Vec<usize>,
1458        padding: Vec<usize>,
1459        dilation: Vec<usize>,
1460        groups: usize,
1461    },
1462
1463    /// 2D conv backward w.r.t. weight. Computes
1464    /// `dw[c_out, c_in, kh, kw] = sum_{n,h_out,w_out} x[n,c_in,...] * dy[n,c_out,h_out,w_out]`.
1465    /// Inputs: `[x, dy]`. Output: `[C_out, C_in/groups, kH, kW]`.
1466    Conv2dBackwardWeight {
1467        kernel_size: Vec<usize>,
1468        stride: Vec<usize>,
1469        padding: Vec<usize>,
1470        dilation: Vec<usize>,
1471        groups: usize,
1472    },
1473
1474    /// Fused softmax + cross-entropy against a **dense** target
1475    /// distribution (soft labels / one-hot probabilities) — the
1476    /// companion to the sparse integer-label
1477    /// [`Op::SoftmaxCrossEntropyWithLogits`]. Per-row output:
1478    /// `loss[n] = logsumexp(logits[n]) - Σ_c targets[n,c]·logits[n,c]`
1479    ///         = -Σ_c targets[n,c]·log_softmax(logits[n])[c]`.
1480    /// Inputs: `[logits, targets]`, both `[N, C]`. Output: `[N]`.
1481    /// Caller does the `Reduce::Mean` if they want a scalar.
1482    /// Numerically stable (max-subtract before exp). Used for label
1483    /// smoothing and knowledge distillation where targets are full
1484    /// probability rows rather than class indices.
1485    SoftmaxCrossEntropy,
1486
1487    /// Fused softmax + cross-entropy loss with integer (f32-encoded)
1488    /// targets — the standard classification loss. Per-row output:
1489    /// `loss[n] = -log(softmax(logits[n])[labels[n]])`.
1490    /// Inputs: `[logits, labels]` with `logits [N, C]` and
1491    /// `labels [N]` (f32-encoded class indices). Output: `[N]`.
1492    /// Caller does the `Reduce::Mean` if they want a scalar.
1493    SoftmaxCrossEntropyWithLogits,
1494
1495    /// Backward of the fused loss above. Emits
1496    /// `dlogits[n,c] = (softmax(logits[n])[c] - one_hot(labels)[n,c]) * d_loss[n]`.
1497    /// Inputs: `[logits, labels, d_loss]`. Output: `[N, C]` (same shape
1498    /// as `logits`). Recomputes the softmax inline rather than threading
1499    /// it through from the forward node.
1500    SoftmaxCrossEntropyBackward,
1501
1502    /// Backward of [`Op::Attention`]. Recomputes scaled `QK^T`, applies
1503    /// the same `mask_kind` as the forward op, softmaxes scores, then
1504    /// emits **one** of `dQ`, `dK`, or `dV` selected by [`AttentionBwdWrt`].
1505    /// Autodiff emits three nodes (one per `wrt`) so each output shape
1506    /// stays a normal single-output MIR node.
1507    ///
1508    /// Inputs: `[q, k, v, dy]` plus optional mask when `mask_kind` is
1509    /// [`MaskKind::Custom`] or [`MaskKind::Bias`] (same convention as
1510    /// forward). Output shape matches `q`, `k`, or `v` respectively.
1511    AttentionBackward {
1512        num_heads: usize,
1513        head_dim: usize,
1514        mask_kind: MaskKind,
1515        wrt: AttentionBwdWrt,
1516    },
1517
1518    // ── Fused operations (created by optimization passes) ──────
1519    /// Fused matmul + bias + activation. Created from MatMul → Add → Activation.
1520    FusedMatMulBiasAct {
1521        activation: Option<Activation>,
1522    },
1523
1524    /// Fused residual + optional bias + layer norm.
1525    /// Created from Add(x, residual) → [Add(bias)] → LayerNorm.
1526    FusedResidualLN {
1527        has_bias: bool,
1528        eps: f32,
1529    },
1530
1531    /// Fused residual + optional bias + RMS norm.
1532    /// Created from Add(x, residual) → [Add(bias)] → RmsNorm.
1533    FusedResidualRmsNorm {
1534        has_bias: bool,
1535        eps: f32,
1536    },
1537
1538    /// Fused SwiGLU: split input into up/gate halves, silu(gate) * up.
1539    /// Created from Split → Silu → Mul when fed by a concatenated matmul.
1540    ///
1541    /// `cast_to`: optional output dtype — when `Some(dt)` the kernel casts
1542    /// its result from the input dtype to `dt` in-register, saving a
1543    /// separate cast pass. Reserved for future fp8/fp4 quantization paths;
1544    /// for f32→f16 mixed precision the AutoMixedPrecision pass already
1545    /// inserts a Cast node so this stays `None` in current pipelines.
1546    FusedSwiGLU {
1547        cast_to: Option<DType>,
1548        /// When `true`, the concatenated input stores gate in the low half
1549        /// `[..., 0..N)` and up in the high half `[..., N..2N)` — the layout
1550        /// produced when gate projection is emitted before up in the builder.
1551        /// Default `false`: up @ low, gate @ high (canonical concat order).
1552        gate_first: bool,
1553    },
1554
1555    /// Fused full transformer layer: attention block + residual+LN + FFN + residual+LN.
1556    /// All intermediates resident in registers/threadgroup memory; one kernel
1557    /// per layer instead of ~30 (the CPU's batch=1 win, lifted to IR so any
1558    /// backend can implement it as a monolithic kernel).
1559    ///
1560    /// Inputs: hidden, qkv_w, qkv_b, out_w, out_b,
1561    ///         ln1_g, ln1_b, fc1_w, fc1_b, fc2_w, fc2_b, ln2_g, ln2_b, mask
1562    /// Output: same shape as hidden.
1563    ///
1564    /// **Backend status:** same as FusedAttentionBlock. CPU implements
1565    /// the L1-cache-resident merge at the thunk level. Metal deferred —
1566    /// requires a single MSL kernel for the whole layer to actually
1567    /// beat the unfused path. Multi-day work; revisit when there's a
1568    /// model whose Metal inference is bottlenecked here rather than on
1569    /// the wait latency floor.
1570    FusedTransformerLayer {
1571        num_heads: usize,
1572        head_dim: usize,
1573        intermediate_size: usize,
1574        eps1: f32,
1575        eps2: f32,
1576        activation: Activation,
1577        has_bias: bool,
1578    },
1579
1580    /// Fused attention block: QKV projection → split → \[RoPE\] → SDPA → output projection.
1581    /// Created by FuseAttentionBlock pass when batch*seq is small.
1582    /// All intermediates stay in L1 cache — no arena writes between ops.
1583    ///
1584    /// Inputs (in order):
1585    ///   hidden, qkv_w, out_w, mask,
1586    ///   [qkv_b, out_b]      if has_bias,
1587    ///   [rope_cos, rope_sin] if has_rope
1588    ///
1589    /// **Backend status (Phase C finalize):**
1590    ///   CPU  — implemented at the *thunk* level: the CPU schedule
1591    ///          recognizes the multi-thunk pattern and merges into
1592    ///          a single FusedAttnBlock that keeps Q/K/V in stack
1593    ///          buffers across stages (the L1-cache win).
1594    ///   Metal — **deferred**. A dispatch-wrapper version (chaining
1595    ///          existing kernels) buys nothing the unfused Metal path
1596    ///          doesn't already get, since per-run cost is dominated
1597    ///          by `wait_until_completed` (~150 µs), not encode. The
1598    ///          real win is a single MSL kernel keeping Q/K/V in
1599    ///          threadgroup memory across stages — multi-day work.
1600    ///          Until then, Metal runs the unfused chain (one matmul,
1601    ///          three narrows, two ropes, attention, one matmul) — all
1602    ///          covered in op_coverage and parity_harness.
1603    FusedAttentionBlock {
1604        num_heads: usize,
1605        head_dim: usize,
1606        has_bias: bool,
1607        has_rope: bool,
1608    },
1609
1610    // ── Control flow (subgraphs as op payloads) ─────────────────
1611    //
1612    // Status: IR is defined; helper `run_if` / `run_while` exist in
1613    // rlx-runtime/src/subgraph.rs; **executor wiring is not yet
1614    // implemented** (both CPU thunk and Metal thunk fall through to
1615    // `Thunk::Nop` for these ops). Wiring requires:
1616    //   1. Recursive subgraph compile at parent-compile time.
1617    //   2. Per-subgraph input/output binding through the arena.
1618    //   3. Schedule-level dispatch when the predicate / loop cond is
1619    //      resolved at runtime.
1620    // Estimate: 4–6 hours of focused work + parity tests. Deferred
1621    // because no current in-tree model uses these ops;
1622    // surface area without a validation target invites silent bugs.
1623    /// Conditional: pick between two subgraphs based on a boolean predicate.
1624    /// Inputs: [predicate, ...captures (used inside both branches)].
1625    /// `then_branch` and `else_branch` are sub-graphs that share the
1626    /// captured inputs and must produce identically-shaped outputs.
1627    /// Used for: shape-dependent execution, batched inference of
1628    /// dynamic-length sequences with padding masks.
1629    If {
1630        then_branch: Box<crate::Graph>,
1631        else_branch: Box<crate::Graph>,
1632    },
1633
1634    /// Loop: iterate `body` while `cond` evaluates true.
1635    /// Inputs: [...initial loop-carried values].
1636    /// `cond`'s single output is a Bool scalar.
1637    /// `body`'s outputs become the next iteration's loop-carried inputs.
1638    /// Outputs of While are the values after the final iteration.
1639    /// Used for: KV-cache-driven autoregressive generation, beam search.
1640    While {
1641        cond: Box<crate::Graph>,
1642        body: Box<crate::Graph>,
1643        max_iterations: Option<usize>,
1644    },
1645
1646    /// Bounded-length loop with a fixed-shape carry, optional per-step
1647    /// inputs, and optional stacked output. Mirrors JAX's `lax.scan`.
1648    ///
1649    /// Body signature: `(carry, x_t_0, ..., x_t_{num_xs-1}) → carry_next`
1650    /// — `1 + num_xs` Op::Inputs in NodeId construction order (first
1651    /// declared is the carry; the remaining `num_xs` are per-step
1652    /// slices). Single output (the next carry).
1653    ///
1654    /// Outer Op::Scan inputs (in order):
1655    ///   `[init_carry, xs_0, xs_1, ..., xs_{num_xs-1}]`
1656    /// Each `xs_i` has shape `[length, *per_step_shape_i]`; the body
1657    /// sees `xs_i[t]` (a `per_step_shape_i` slice) on iteration `t`.
1658    ///
1659    /// Outer Op::Scan output:
1660    ///   * `save_trajectory == false` — final carry, shape `*carry`.
1661    ///   * `save_trajectory == true`  — stacked trajectory of carries,
1662    ///     shape `[length, *carry]`. Row `t` is the carry after step
1663    ///     `t+1`, so row `length-1` matches the no-trajectory case.
1664    ///
1665    /// Mirrors JAX's `lax.scan`. Common uses include time-stepping
1666    /// integrators with time-varying drives, Mamba-style SSM scans
1667    /// reading per-step inputs, and RNN-style sequence processing.
1668    Scan {
1669        body: Box<crate::Graph>,
1670        length: u32,
1671        save_trajectory: bool,
1672        /// Number of "broadcast" inputs — values that are constant
1673        /// across iterations. Outer scan inputs in order:
1674        ///   `[init, bcast_0..bcast_{B-1}, xs_0..xs_{X-1}]`
1675        /// Body Op::Inputs in NodeId order:
1676        ///   `[carry, bcast_0..bcast_{B-1}, x_t_0..x_t_{X-1}]`
1677        /// CPU executor fills bcast slots ONCE before the iteration
1678        /// loop (xs slots are filled per-step). The reverse-mode AD
1679        /// pre-pass materialises each bcast into an xs of shape
1680        /// `[length, *bcast]` via broadcast `Mul` so the rest of the
1681        /// VJP / executor pipeline can stay unchanged. `0` (default)
1682        /// keeps the original carry+xs scan shape.
1683        num_bcast: u32,
1684        /// Number of per-step `xs` inputs. Total outer Op::Scan
1685        /// inputs is `1 + num_bcast + num_xs`.
1686        num_xs: u32,
1687        /// Number of trajectory checkpoints when `save_trajectory ==
1688        /// true`. `0` means "save all `length` rows" (default). A
1689        /// positive value `K` means save only `K` evenly-spaced rows
1690        /// at indices `floor(t * length / K)` for `t in 0..K`. Used
1691        /// by recursive checkpointed AD: store O(√T) carries during
1692        /// forward, recompute the rest in the backward pass.
1693        ///
1694        /// When `0` (or `K == length`), the saved trajectory has
1695        /// shape `[length, *carry]` — same as the original behavior.
1696        /// When `0 < K < length`, the saved trajectory has shape
1697        /// `[K, *carry]`.
1698        num_checkpoints: u32,
1699    },
1700
1701    /// Reverse-mode AD companion to `Op::Scan` — extracts the carry
1702    /// gradient `dinit`. Walks `t = length-1 .. 0`, applying `body_vjp`
1703    /// to thread `dcarry` back through the time loop.
1704    ///
1705    /// Inputs (in order):
1706    ///   `[init, trajectory, upstream, xs_0, ..., xs_{num_xs-1}]`
1707    /// Output: `dinit`, shape = carry shape.
1708    ///
1709    /// `body_vjp` is the result of
1710    /// `autodiff::grad(body, [carry_id, xs_0_id, ..., xs_{num_xs-1}_id])`
1711    /// — a graph with `1 + num_xs + 1` Inputs (carry + x_t_i for each
1712    /// xs + `"d_output"`) and `1 + num_xs` outputs
1713    /// (dcarry + dx_t_i for each xs). This op reads `outputs[0]` =
1714    /// dcarry; the sibling [`Self::ScanBackwardXs`] reads the
1715    /// `outputs[1 + xs_idx]` slot for each xs gradient.
1716    ScanBackward {
1717        body_vjp: Box<crate::Graph>,
1718        length: u32,
1719        save_trajectory: bool,
1720        num_xs: u32,
1721        /// When `0` or equal to `length`, the trajectory input has
1722        /// shape `[length, *carry]` — every step's carry is cached
1723        /// (`CheckpointStrategy::All`). When `0 < K < length`, the
1724        /// trajectory input has shape `[K, *carry]` and the executor
1725        /// recomputes intermediate carries via `forward_body` between
1726        /// checkpoints. `forward_body` must be `Some` whenever this
1727        /// is < length.
1728        num_checkpoints: u32,
1729        /// Forward body (the same `body` from the forward Op::Scan).
1730        /// Required when `num_checkpoints > 0 && < length` so the
1731        /// executor can recompute carries between saved checkpoints.
1732        /// `None` for the All strategy (no recompute needed).
1733        forward_body: Option<Box<crate::Graph>>,
1734    },
1735
1736    /// Companion to [`Self::ScanBackward`] that extracts one stacked
1737    /// per-step `dxs_i` (shape `[length, *per_step_xs_i]`). Same inputs
1738    /// and same `body_vjp` graph as ScanBackward — `xs_idx` selects
1739    /// which body_vjp output to stack into the result.
1740    ///
1741    /// Note: each ScanBackwardXs runs its own backward loop. A future
1742    /// optimization can fuse them into a single multi-output backward
1743    /// kernel; for now it's `1 + num_xs` independent sweeps.
1744    ScanBackwardXs {
1745        body_vjp: Box<crate::Graph>,
1746        length: u32,
1747        save_trajectory: bool,
1748        num_xs: u32,
1749        xs_idx: u32,
1750        num_checkpoints: u32,
1751        forward_body: Option<Box<crate::Graph>>,
1752    },
1753
1754    /// CPU reference 3D Gaussian splat forward render.
1755    ///
1756    /// Seven flat F32 inputs (scene buffers + camera/render meta):
1757    ///   0. positions `[N*3]`
1758    ///   1. scales `[N*3]` (log-space)
1759    ///   2. rotations `[N*4]` (xyzw)
1760    ///   3. opacities `[N]` (logit)
1761    ///   4. colors `[N*3]` (linear RGB)
1762    ///   5. sh_coeffs `[N * sh_coeff_count * 3]`
1763    ///   6. meta `[23]` — camera position/target/up/fov/near/far, background RGB,
1764    ///      then width/height/tile_size/radius_scale/alpha_cutoff/max_splat_steps/
1765    ///      transmittance_threshold/max_list_entries as f32 bit-patterns.
1766    ///
1767    /// Output: `[height * width * 4]` linear RGBA (display gamma baked in).
1768    /// Build via [`crate::Graph::gaussian_splat_render`].
1769    ///
1770    /// Differentiable backward is not implemented in v1; autodiff treats this
1771    /// op as non-differentiable (same as [`Op::Sample`]).
1772    GaussianSplatRender {
1773        width: u32,
1774        height: u32,
1775        tile_size: u32,
1776        radius_scale: f32,
1777        alpha_cutoff: f32,
1778        max_splat_steps: u32,
1779        transmittance_threshold: f32,
1780        max_list_entries: u32,
1781    },
1782
1783    /// Backward pass for [`Self::GaussianSplatRender`].
1784    ///
1785    /// Eight inputs: the same seven as forward plus `d_loss_rgba` `[W*H*4]`
1786    /// (only RGB channels are used). Re-runs the training forward internally.
1787    ///
1788    /// Output: packed gradients
1789    /// `[positions(3N) | scales(3N) | rotations(4N) | opacities(N) | colors(3N) | sh(N*sh*3)]`.
1790    /// Unpack via [`crate::ops::splat::unpack_gaussian_splat_packed_grads`].
1791    GaussianSplatRenderBackward {
1792        width: u32,
1793        height: u32,
1794        tile_size: u32,
1795        radius_scale: f32,
1796        alpha_cutoff: f32,
1797        max_splat_steps: u32,
1798        transmittance_threshold: f32,
1799        max_list_entries: u32,
1800        loss_grad_clip: f32,
1801        sh_band: u32,
1802        max_anisotropy: f32,
1803    },
1804
1805    /// Strict IR stage 1: project, bin, sort, build per-pixel rays.
1806    ///
1807    /// Seven inputs (same scene + meta as [`Self::GaussianSplatRender`]). Output: packed
1808    /// prepare buffer (see `rlx_splat::prep_layout::prep_packed_len`).
1809    GaussianSplatPrepare {
1810        width: u32,
1811        height: u32,
1812        tile_size: u32,
1813        radius_scale: f32,
1814        alpha_cutoff: f32,
1815        max_splat_steps: u32,
1816        transmittance_threshold: f32,
1817        max_list_entries: u32,
1818    },
1819
1820    /// Strict IR stage 2: tile raster from [`Self::GaussianSplatPrepare`] output.
1821    ///
1822    /// Inputs: `prep` packed buffer, `meta` `[23]`. Output: `[width * height * 4]` RGBA.
1823    GaussianSplatRasterize {
1824        width: u32,
1825        height: u32,
1826        tile_size: u32,
1827        alpha_cutoff: f32,
1828        max_splat_steps: u32,
1829        transmittance_threshold: f32,
1830        max_list_entries: u32,
1831    },
1832
1833    /// User-registered custom op. `name` keys into the
1834    /// [`crate::op_registry`] for shape inference, autodiff, and
1835    /// per-backend execution. `attrs` is an opaque blob passed
1836    /// through to those callbacks (FFT direction, SparseLU
1837    /// reordering strategy, etc.). `num_inputs` is captured at
1838    /// construction time so [`Op::num_inputs`] stays infallible
1839    /// without a registry lookup. Build via [`crate::Graph::custom_op`].
1840    Custom {
1841        name: String,
1842        num_inputs: u32,
1843        attrs: Vec<u8>,
1844    },
1845
1846    /// 1D Fast Fourier Transform along the last axis.
1847    ///
1848    /// **Layouts**
1849    /// - `F32` / `F64`: 2N real-block — last axis is `[re₀…re_{N-1}, im₀…im_{N-1}]`.
1850    /// - `C64`: interleaved `[re, im]` pairs per complex element along the last axis.
1851    ///
1852    /// **ND transforms** — use `Graph::fftn` / `Graph::ifftn`, which compose
1853    /// `fft_axis` (transpose → Fft → transpose). Multi-axis `fftn` requires
1854    /// `DType::C64`; the 2N-block layout describes a single complex axis.
1855    ///
1856    /// Default (`FftNorm::Backward`) is **unnormalized** on both directions:
1857    ///   `fft(x)[k] = Σ x[n]·exp(-2πi·nk/N)`
1858    ///   `ifft(y)[n] = Σ y[k]·exp(+2πi·nk/N)`
1859    /// so `ifft(fft(x)) = N·x`. Use `FftNorm::Forward` for gpu-fft-style
1860    /// `1/N` scaling on inverse, or `FftNorm::Ortho` for unitary scaling.
1861    ///
1862    /// AD: VJP(`fft`) = `ifft`, VJP(`ifft`) = `fft` when `norm=Backward`;
1863    /// other norms apply the chain rule via output scaling.
1864    Fft {
1865        inverse: bool,
1866        norm: crate::fft::FftNorm,
1867    },
1868
1869    /// Ternary pruned radix-2 butterfly stage on interleaved complex state.
1870    ///
1871    /// Inputs:
1872    ///   0 — state `[batch, n_fft, 2]` (re/im on axis 2)
1873    ///   1 — gate `[half]` — 0 = identity, 1 = run butterfly (`half = n_fft/2`)
1874    ///   2 — rev `[half]` — 0 = forward, 1 = swap outputs when gate=1
1875    ///   3 — tw_re `[half]`
1876    ///   4 — tw_im `[half]`
1877    ///
1878    /// Output: `[batch, n_fft, 2]` same layout. Slots with gate=0 copy inputs
1879    /// without twiddle math.
1880    FftButterflyStage {
1881        stage: u32,
1882        n_fft: u32,
1883    },
1884
1885    /// Log-mel spectrogram from RLX FFT block-layout spectrum.
1886    ///
1887    /// Inputs:
1888    ///   0 — spectrum `[..., 2*n_fft]` (re plane then im plane, same as `Op::Fft` output)
1889    ///   1 — mel filterbank `[n_mels, n_bins]` with `n_bins = n_fft/2 + 1`
1890    ///
1891    /// Output: `[..., n_mels]` with Whisper dynamic-range compression
1892    /// (`log10`, clamp to max−8 dB, `(x+4)/4`).
1893    LogMel,
1894
1895    /// VJP of [`Op::LogMel`] w.r.t. spectrum (input 0).
1896    ///
1897    /// Inputs: spectrum block, mel filters, upstream `dy`.
1898    /// Output: `d_spectrum` (same shape as input 0).
1899    LogMelBackward,
1900
1901    /// Top-K Welch peaks from block-layout segment spectra.
1902    ///
1903    /// Input 0: spectrum `[batch * n_segments, 2*n_fft]` (re ∥ im planes).
1904    /// Output: `[batch, k*2]` interleaved `(bin, power)` per spike.
1905    WelchPeaks {
1906        k: usize,
1907        n_segments: usize,
1908    },
1909
1910    /// User-defined sub-graph with optional override AD rules.
1911    /// Mirrors JAX's `custom_vjp` / `custom_jvp` decorators: the
1912    /// caller wraps a forward computation and supplies its own
1913    /// reverse- and/or forward-mode AD bodies. Useful when:
1914    ///   * The forward is iterative (Newton, fixed-point) and
1915    ///     differentiating through the loop is wasteful — the
1916    ///     vjp_body computes the implicit-function gradient at the
1917    ///     converged point in one shot.
1918    ///   * The math has a closed-form gradient that's much cheaper
1919    ///     than autodiff.
1920    ///   * The forward op is non-differentiable by tracing
1921    ///     (sampling, argmax) and the user wants a smooth surrogate.
1922    ///
1923    /// **fwd_body**: `num_inputs` Op::Inputs in NodeId construction
1924    /// order, one Op::Output (the primal y). Forward execution
1925    /// inlines this body once.
1926    ///
1927    /// **vjp_body** (optional): Op::Inputs are `num_inputs` primal
1928    /// inputs in NodeId order, plus two special-named Inputs —
1929    /// `"primal_output"` (the y from forward) and `"d_output"` (the
1930    /// upstream gradient). Outputs: `num_inputs` tensors in
1931    /// `set_outputs` order, matching the gradients of each primal
1932    /// input. When `None`, reverse-mode AD recurses into fwd_body
1933    /// — same as if the op were inlined.
1934    ///
1935    /// **jvp_body** (optional): Op::Inputs are `num_inputs` primal
1936    /// inputs in NodeId order, `num_inputs` special-named Inputs
1937    /// `"tangent_0"..="tangent_{num_inputs-1}"` carrying each input's
1938    /// tangent, and an optional special-named `"primal_output"` Input
1939    /// (the y from forward, useful when the JVP must be evaluated at
1940    /// a converged / nonlinear point — e.g. IFT-style forward-mode AD
1941    /// of an iterative solver). Output: 1 tensor (the tangent of y).
1942    /// When `None`, forward-mode AD recurses into fwd_body.
1943    ///
1944    /// `num_inputs` is captured so [`Op::num_inputs`] stays
1945    /// infallible. Build via [`crate::Graph::custom_fn`].
1946    CustomFn {
1947        fwd_body: Box<crate::Graph>,
1948        vjp_body: Option<Box<crate::Graph>>,
1949        jvp_body: Option<Box<crate::Graph>>,
1950        num_inputs: u32,
1951    },
1952}
1953
1954impl Op {
1955    /// PLAN L4: discriminant for backend-supported-set checks.
1956    /// Stable, parameter-free identity per variant — `Op::Activation(_)`
1957    /// and `Op::Activation(Relu)` share the same `OpKind::Activation`.
1958    pub fn kind(&self) -> OpKind {
1959        match self {
1960            Op::Input { .. } => OpKind::Input,
1961            Op::Param { .. } => OpKind::Param,
1962            Op::Constant { .. } => OpKind::Constant,
1963            Op::Activation(_) => OpKind::Activation,
1964            Op::Cast { .. } => OpKind::Cast,
1965            Op::StopGradient => OpKind::StopGradient,
1966            Op::Quantize { .. } => OpKind::Quantize,
1967            Op::Dequantize { .. } => OpKind::Dequantize,
1968            Op::FakeQuantize { .. } => OpKind::FakeQuantize,
1969            Op::FakeQuantizeLSQ { .. } => OpKind::FakeQuantizeLSQ,
1970            Op::FakeQuantizeLSQBackwardX { .. } => OpKind::FakeQuantizeLSQBackwardX,
1971            Op::FakeQuantizeLSQBackwardScale { .. } => OpKind::FakeQuantizeLSQBackwardScale,
1972            Op::Binary(_) => OpKind::Binary,
1973            Op::Compare(_) => OpKind::Compare,
1974            Op::Where => OpKind::Where,
1975            Op::Fma => OpKind::Fma,
1976            Op::ElementwiseRegion { .. } => OpKind::ElementwiseRegion,
1977            Op::TransformRegion { .. } => OpKind::TransformRegion,
1978            Op::BatchElementwiseRegion { .. } => OpKind::BatchElementwiseRegion,
1979            Op::MatMul => OpKind::MatMul,
1980            Op::DotGeneral { .. } => OpKind::DotGeneral,
1981            Op::DenseSolve => OpKind::DenseSolve,
1982            Op::BatchedDenseSolve => OpKind::BatchedDenseSolve,
1983            Op::LayerNorm { .. } => OpKind::LayerNorm,
1984            Op::LayerNorm2d { .. } => OpKind::LayerNorm2d,
1985            Op::GroupNorm { .. } => OpKind::GroupNorm,
1986            Op::BatchNormInference { .. } => OpKind::BatchNormInference,
1987            Op::RmsNorm { .. } => OpKind::RmsNorm,
1988            Op::ResizeNearest2x => OpKind::ResizeNearest2x,
1989            Op::Attention { .. } => OpKind::Attention,
1990            Op::Rope { .. } => OpKind::Rope,
1991            Op::AxialRope2d { .. } => OpKind::AxialRope2d,
1992            Op::Reshape { .. } => OpKind::Reshape,
1993            Op::Transpose { .. } => OpKind::Transpose,
1994            Op::Narrow { .. } => OpKind::Narrow,
1995            Op::Concat { .. } => OpKind::Concat,
1996            Op::Expand { .. } => OpKind::Expand,
1997            Op::Gather { .. } => OpKind::Gather,
1998            Op::Reverse { .. } => OpKind::Reverse,
1999            Op::Reduce { .. } => OpKind::Reduce,
2000            Op::Softmax { .. } => OpKind::Softmax,
2001            Op::Cumsum { .. } => OpKind::Cumsum,
2002            Op::ArgMax { .. } => OpKind::ArgMax,
2003            Op::ArgMin { .. } => OpKind::ArgMin,
2004            Op::TopK { .. } => OpKind::TopK,
2005            Op::Sample { .. } => OpKind::Sample,
2006            Op::RngNormal { .. } => OpKind::RngNormal,
2007            Op::RngUniform { .. } => OpKind::RngUniform,
2008            Op::Conv { .. } => OpKind::Conv,
2009            Op::Im2Col { .. } => OpKind::Im2Col,
2010            Op::ConvTranspose2d { .. } => OpKind::ConvTranspose2d,
2011            Op::Conv3d { .. } => OpKind::Conv3d,
2012            Op::ConvTranspose3d { .. } => OpKind::ConvTranspose3d,
2013            Op::Pool { .. } => OpKind::Pool,
2014            Op::ReluBackward => OpKind::ReluBackward,
2015            Op::ActivationBackward { .. } => OpKind::ActivationBackward,
2016            Op::FakeQuantizeBackward { .. } => OpKind::FakeQuantizeBackward,
2017            Op::ComplexNormSq => OpKind::ComplexNormSq,
2018            Op::ComplexNormSqBackward => OpKind::ComplexNormSqBackward,
2019            Op::Conjugate => OpKind::Conjugate,
2020            Op::LayerNormBackwardInput { .. } => OpKind::LayerNormBackwardInput,
2021            Op::LayerNormBackwardGamma { .. } => OpKind::LayerNormBackwardGamma,
2022            Op::RmsNormBackwardInput { .. } => OpKind::RmsNormBackwardInput,
2023            Op::RmsNormBackwardGamma { .. } => OpKind::RmsNormBackwardGamma,
2024            Op::RmsNormBackwardBeta { .. } => OpKind::RmsNormBackwardBeta,
2025            Op::RopeBackward { .. } => OpKind::RopeBackward,
2026            Op::GroupNormBackwardInput { .. } => OpKind::GroupNormBackwardInput,
2027            Op::GroupNormBackwardGamma { .. } => OpKind::GroupNormBackwardGamma,
2028            Op::GroupNormBackwardBeta { .. } => OpKind::GroupNormBackwardBeta,
2029            Op::BatchNormInferenceBackwardInput { .. } => OpKind::BatchNormInferenceBackwardInput,
2030            Op::BatchNormInferenceBackwardGamma { .. } => OpKind::BatchNormInferenceBackwardGamma,
2031            Op::BatchNormInferenceBackwardBeta => OpKind::BatchNormInferenceBackwardBeta,
2032            Op::CumsumBackward { .. } => OpKind::CumsumBackward,
2033            Op::GatherBackward { .. } => OpKind::GatherBackward,
2034            Op::MaxPool2dBackward { .. } => OpKind::MaxPool2dBackward,
2035            Op::Conv2dBackwardInput { .. } => OpKind::Conv2dBackwardInput,
2036            Op::Conv2dBackwardWeight { .. } => OpKind::Conv2dBackwardWeight,
2037            Op::SoftmaxCrossEntropy => OpKind::SoftmaxCrossEntropy,
2038            Op::SoftmaxCrossEntropyWithLogits => OpKind::SoftmaxCrossEntropyWithLogits,
2039            Op::SoftmaxCrossEntropyBackward => OpKind::SoftmaxCrossEntropyBackward,
2040            Op::AttentionBackward { .. } => OpKind::AttentionBackward,
2041            Op::GroupedMatMul => OpKind::GroupedMatMul,
2042            Op::DequantGroupedMatMul { .. } => OpKind::DequantGroupedMatMul,
2043            Op::DequantMoEWeights { .. } => OpKind::DequantMoEWeights,
2044            Op::ScatterAdd => OpKind::ScatterAdd,
2045            Op::LoraMatMul { .. } => OpKind::LoraMatMul,
2046            Op::DequantMatMul { .. } => OpKind::DequantMatMul,
2047            Op::QMatMul { .. } => OpKind::QMatMul,
2048            Op::QConv2d { .. } => OpKind::QConv2d,
2049            Op::ScaledMatMul { .. } => OpKind::ScaledMatMul,
2050            Op::ScaledQuantize { .. } => OpKind::ScaledQuantize,
2051            Op::ScaledQuantScale { .. } => OpKind::ScaledQuantScale,
2052            Op::ScaledDequantize { .. } => OpKind::ScaledDequantize,
2053            Op::SelectiveScan { .. } => OpKind::SelectiveScan,
2054            Op::GatedDeltaNet { .. } => OpKind::GatedDeltaNet,
2055            Op::Lstm { .. } => OpKind::Lstm,
2056            Op::Gru { .. } => OpKind::Gru,
2057            Op::Rnn { .. } => OpKind::Rnn,
2058            Op::Mamba2 { .. } => OpKind::Mamba2,
2059            Op::FusedSwiGLU { .. } => OpKind::FusedSwiGLU,
2060            Op::FusedMatMulBiasAct { .. } => OpKind::FusedMatMulBiasAct,
2061            Op::FusedResidualLN { .. } => OpKind::FusedResidualLN,
2062            Op::FusedResidualRmsNorm { .. } => OpKind::FusedResidualRmsNorm,
2063            Op::FusedAttentionBlock { .. } => OpKind::FusedAttentionBlock,
2064            Op::FusedTransformerLayer { .. } => OpKind::FusedTransformerLayer,
2065            Op::If { .. } => OpKind::If,
2066            Op::While { .. } => OpKind::While,
2067            Op::Scan { .. } => OpKind::Scan,
2068            Op::ScanBackward { .. } => OpKind::ScanBackward,
2069            Op::ScanBackwardXs { .. } => OpKind::ScanBackwardXs,
2070            Op::GaussianSplatRender { .. } => OpKind::GaussianSplatRender,
2071            Op::GaussianSplatRenderBackward { .. } => OpKind::GaussianSplatRenderBackward,
2072            Op::GaussianSplatPrepare { .. } => OpKind::GaussianSplatPrepare,
2073            Op::GaussianSplatRasterize { .. } => OpKind::GaussianSplatRasterize,
2074            Op::Custom { .. } => OpKind::Custom,
2075            Op::CustomFn { .. } => OpKind::CustomFn,
2076            Op::Fft { .. } => OpKind::Fft,
2077            Op::FftButterflyStage { .. } => OpKind::FftButterflyStage,
2078            Op::LogMel => OpKind::LogMel,
2079            Op::LogMelBackward => OpKind::LogMelBackward,
2080            Op::WelchPeaks { .. } => OpKind::WelchPeaks,
2081        }
2082    }
2083
2084    /// True if this op is element-wise (same shape in, same shape out).
2085    /// Element-wise ops are prime fusion candidates.
2086    pub fn is_elementwise(&self) -> bool {
2087        matches!(
2088            self,
2089            Op::Activation(_)
2090                | Op::Cast { .. }
2091                | Op::StopGradient
2092                | Op::Binary(_)
2093                | Op::Compare(_)
2094                | Op::Where
2095                | Op::Fma
2096                | Op::ElementwiseRegion { .. }
2097                | Op::BatchElementwiseRegion { .. }
2098        )
2099    }
2100
2101    /// True if this op may appear in a [`Op::TransformRegion`] chain.
2102    pub fn is_transform_eligible(&self) -> bool {
2103        matches!(self, Op::ResizeNearest2x)
2104    }
2105
2106    /// True if this op is a BLAS/compute-intensive op that forms a fusion boundary.
2107    pub fn is_blas(&self) -> bool {
2108        matches!(
2109            self,
2110            Op::MatMul
2111                | Op::DotGeneral { .. }
2112                | Op::DenseSolve
2113                | Op::BatchedDenseSolve
2114                | Op::Conv { .. }
2115                | Op::Im2Col { .. }
2116                | Op::ConvTranspose2d { .. }
2117                | Op::Conv3d { .. }
2118                | Op::ConvTranspose3d { .. }
2119                | Op::FusedMatMulBiasAct { .. }
2120                | Op::GroupedMatMul
2121                | Op::DequantGroupedMatMul { .. }
2122                | Op::DequantMoEWeights { .. }
2123                | Op::LoraMatMul { .. }
2124                | Op::DequantMatMul { .. }
2125                | Op::QMatMul { .. }
2126                | Op::QConv2d { .. }
2127                | Op::ScaledMatMul { .. }
2128        )
2129    }
2130
2131    /// True if element-wise fusion must not span across this op.
2132    pub fn is_fusion_boundary(&self) -> bool {
2133        self.is_blas()
2134            || matches!(
2135                self,
2136                Op::GaussianSplatRender { .. }
2137                    | Op::GaussianSplatRenderBackward { .. }
2138                    | Op::GaussianSplatPrepare { .. }
2139                    | Op::GaussianSplatRasterize { .. }
2140            )
2141    }
2142
2143    /// True if this op is a reduction (drives loop iteration in fused kernels).
2144    pub fn is_reduction(&self) -> bool {
2145        matches!(
2146            self,
2147            Op::Reduce { .. } | Op::Softmax { .. } | Op::TopK { .. }
2148        )
2149    }
2150
2151    /// Number of tensor inputs this op expects.
2152    pub fn num_inputs(&self) -> usize {
2153        match self {
2154            Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => 0,
2155            Op::Activation(_)
2156            | Op::Cast { .. }
2157            | Op::StopGradient
2158            | Op::Reshape { .. }
2159            | Op::Quantize { .. }
2160            | Op::Dequantize { .. }
2161            | Op::Transpose { .. }
2162            | Op::Narrow { .. }
2163            | Op::Reverse { .. }
2164            | Op::Expand { .. }
2165            | Op::Reduce { .. }
2166            | Op::Softmax { .. }
2167            | Op::FusedSwiGLU { .. }
2168            | Op::TopK { .. }
2169            | Op::Cumsum { .. }
2170            | Op::ArgMax { .. }
2171            | Op::ArgMin { .. }
2172            | Op::Sample { .. }
2173            | Op::ResizeNearest2x => 1,
2174            Op::RngNormal { .. } | Op::RngUniform { .. } => 0, // 0 or 1 — see verify
2175            // EMA / Fixed scale modes carry a state tensor as a 2nd input;
2176            // PerBatch (default) doesn't need one.
2177            Op::FakeQuantize { scale_mode, .. } => match scale_mode {
2178                ScaleMode::PerBatch => 1,
2179                ScaleMode::EMA { .. } | ScaleMode::Fixed => 2,
2180            },
2181            Op::FakeQuantizeLSQ { .. } => 2, // x, scale (learned param)
2182            Op::FakeQuantizeLSQBackwardX { .. } | Op::FakeQuantizeLSQBackwardScale { .. } => 3, // x, scale, dy
2183            Op::Binary(_) | Op::Compare(_) | Op::Gather { .. } | Op::MatMul | Op::ScatterAdd => 2,
2184            Op::GroupedMatMul => 3,               // input, weight, expert_idx
2185            Op::DequantGroupedMatMul { .. } => 3, // input, packed_w, expert_idx
2186            Op::DequantMoEWeights { .. } => 1,    // packed_w
2187            Op::LoraMatMul { .. } => 4,           // x, w, a, b
2188            // x, w_q, scale, zp — or x, packed_w_bytes for GGUF
2189            // schemes (their scales/mins live inside the packed bytes,
2190            // see `QuantScheme::is_gguf`).
2191            Op::DequantMatMul { scheme } => {
2192                if scheme.is_gguf() {
2193                    2
2194                } else {
2195                    4
2196                }
2197            }
2198            Op::QMatMul { .. } => 3, // x, w, bias
2199            Op::QConv2d { .. } => 3, // x, w, bias
2200            // lhs_codes, rhs_codes, lhs_scale, rhs_scale (+ bias)
2201            Op::ScaledMatMul { has_bias, .. } => 4 + usize::from(*has_bias),
2202            Op::ScaledQuantize { .. } => 2,   // x, scale
2203            Op::ScaledQuantScale { .. } => 1, // x
2204            Op::ScaledDequantize { .. } => 2, // codes, scale
2205            Op::SelectiveScan { .. } => 5,    // x, delta, a, b, c
2206            Op::GatedDeltaNet { carry_state, .. } if *carry_state => 6, // + state in/out
2207            Op::GatedDeltaNet { .. } => 5,    // q, k, v, g, beta
2208            Op::Lstm { carry, .. } => {
2209                if *carry { 6 } else { 4 } // x, w_ih, w_hh, bias (+ h0, c0)
2210            }
2211            Op::Gru { carry, .. } => {
2212                if *carry { 6 } else { 5 } // x, w_ih, w_hh, b_ih, b_hh (+ h0)
2213            }
2214            Op::Rnn { carry, .. } => {
2215                if *carry { 5 } else { 4 } // x, w_ih, w_hh, bias (+ h0)
2216            }
2217            Op::Mamba2 { .. } => 5, // x, dt, a, b, c
2218            Op::Where => 3,         // cond, on_true, on_false
2219            Op::Fma => 3,           // a, b, c  (a*b + c)
2220            Op::Attention { mask_kind, .. } => match mask_kind {
2221                MaskKind::Custom | MaskKind::Bias => 4, // Q, K, V, mask
2222                _ => 3,                                 // Q, K, V (mask synthesized in-kernel)
2223            },
2224            Op::AttentionBackward { mask_kind, .. } => match mask_kind {
2225                MaskKind::Custom | MaskKind::Bias => 5, // q, k, v, dy, mask
2226                _ => 4,                                 // q, k, v, dy
2227            },
2228            Op::Rope { .. } => 3, // x, cos, sin
2229            Op::AxialRope2d { .. } => 1,
2230            Op::LayerNorm { .. }
2231            | Op::LayerNorm2d { .. }
2232            | Op::GroupNorm { .. }
2233            | Op::RmsNorm { .. } => 3, // input, gamma, beta
2234            Op::BatchNormInference { .. } => 5, // x, gamma, beta, mean, var
2235            Op::FusedMatMulBiasAct { .. } => 3, // input, weight, bias
2236            Op::FusedResidualLN { has_bias: true, .. } => 5, // x, residual, bias, gamma, beta
2237            Op::FusedResidualLN {
2238                has_bias: false, ..
2239            } => 4, // x, residual, gamma, beta
2240            Op::FusedResidualRmsNorm { has_bias: true, .. } => 5, // x, residual, bias, gamma, beta
2241            Op::FusedResidualRmsNorm {
2242                has_bias: false, ..
2243            } => 4, // x, residual, gamma, beta
2244            Op::Conv { .. } | Op::ConvTranspose2d { .. } => 2, // input, weight (bias via Add)
2245            Op::Conv3d { .. } | Op::ConvTranspose3d { .. } => 2, // input, weight (bias via Add)
2246            Op::Im2Col { .. } => 1,
2247            Op::Pool { .. } => 1,
2248            Op::ReluBackward => 2,                  // x, dy
2249            Op::ActivationBackward { .. } => 2,     // x, dy
2250            Op::FakeQuantizeBackward { .. } => 2,   // x, dy
2251            Op::ComplexNormSq => 1,                 // z (C64)
2252            Op::ComplexNormSqBackward => 2,         // z, g
2253            Op::Conjugate => 1,                     // z (C64)
2254            Op::LayerNormBackwardInput { .. } => 3, // x, gamma, dy
2255            Op::LayerNormBackwardGamma { .. } => 2, // x, dy
2256            Op::RmsNormBackwardInput { .. } => 4,   // x, gamma, beta, dy
2257            Op::RmsNormBackwardGamma { .. } => 4,
2258            Op::RmsNormBackwardBeta { .. } => 4,
2259            Op::RopeBackward { .. } => 3,           // dy, cos, sin
2260            Op::GroupNormBackwardInput { .. } => 4, // x, gamma, beta, dy
2261            Op::GroupNormBackwardGamma { .. } => 2, // x, dy
2262            Op::GroupNormBackwardBeta { .. } => 2,
2263            Op::BatchNormInferenceBackwardInput { .. } => 5, // x, gamma, mean, var, dy
2264            Op::BatchNormInferenceBackwardGamma { .. } => 4, // x, mean, var, dy
2265            Op::BatchNormInferenceBackwardBeta => 1,         // dy
2266            Op::CumsumBackward { .. } => 1,                  // dy
2267            Op::GatherBackward { .. } => 2,                  // dy, indices
2268            Op::MaxPool2dBackward { .. } => 2,               // x, dy
2269            Op::Conv2dBackwardInput { .. } => 2,             // dy, w
2270            Op::Conv2dBackwardWeight { .. } => 2,            // x, dy
2271            Op::SoftmaxCrossEntropy => 2,                    // logits, targets
2272            Op::SoftmaxCrossEntropyWithLogits => 2,          // logits, labels
2273            Op::SoftmaxCrossEntropyBackward => 3,            // logits, labels, d_loss
2274            Op::Concat { .. } => 0,                          // variadic — checked at graph level
2275            Op::DotGeneral { .. } => 2,
2276            Op::DenseSolve => 2,        // A, b
2277            Op::BatchedDenseSolve => 2, // A [B,N,N], b [B,N] or [B,N,K]
2278            Op::FusedAttentionBlock {
2279                has_bias, has_rope, ..
2280            } => 4 + if *has_bias { 2 } else { 0 } + if *has_rope { 2 } else { 0 },
2281            Op::If { .. } => 1,    // predicate (captures handled separately)
2282            Op::While { .. } => 0, // variadic loop-carried; checked at graph level
2283            Op::Scan {
2284                num_bcast, num_xs, ..
2285            } => 1 + *num_bcast as usize + *num_xs as usize,
2286            Op::ScanBackward { num_xs, .. } => 3 + *num_xs as usize, // init, trajectory, upstream, xs_0..
2287            Op::ScanBackwardXs { num_xs, .. } => 3 + *num_xs as usize, // same as ScanBackward
2288            Op::GaussianSplatRender { .. } => 7,
2289            Op::GaussianSplatRenderBackward { .. } => 8,
2290            Op::GaussianSplatPrepare { .. } => 7,
2291            Op::GaussianSplatRasterize { .. } => 2,
2292            Op::FusedTransformerLayer { has_bias, .. } => {
2293                // hidden + qkv_w + out_w + ln1_g + ln1_b + fc1_w + fc2_w + ln2_g + ln2_b + mask = 10
2294                // bias variant adds: qkv_b + out_b + fc1_b + fc2_b = 4 more
2295                10 + if *has_bias { 4 } else { 0 }
2296            }
2297            Op::ElementwiseRegion { num_inputs, .. } => *num_inputs as usize,
2298            Op::TransformRegion { num_inputs, .. } => *num_inputs as usize,
2299            Op::BatchElementwiseRegion {
2300                num_batch_inputs, ..
2301            } => *num_batch_inputs as usize,
2302            Op::Custom { num_inputs, .. } => *num_inputs as usize,
2303            Op::CustomFn { num_inputs, .. } => *num_inputs as usize,
2304            Op::Fft { .. } => 1,
2305            Op::FftButterflyStage { .. } => 5,
2306            Op::LogMel => 2,
2307            Op::LogMelBackward => 3,
2308            Op::WelchPeaks { .. } => 1,
2309        }
2310    }
2311}
2312
2313impl std::fmt::Display for Op {
2314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2315        match self {
2316            Op::Input { name } => write!(f, "input(\"{name}\")"),
2317            Op::Param { name } => write!(f, "param(\"{name}\")"),
2318            Op::Constant { data } => write!(f, "const({}B)", data.len()),
2319            Op::Activation(a) => write!(f, "{a:?}"),
2320            Op::Quantize { axis, scales, .. } => match axis {
2321                None => write!(f, "quantize(s={})", scales[0]),
2322                Some(d) => write!(f, "quantize(axis={d},nch={})", scales.len()),
2323            },
2324            Op::Dequantize { axis, scales, .. } => match axis {
2325                None => write!(f, "dequantize(s={})", scales[0]),
2326                Some(d) => write!(f, "dequantize(axis={d},nch={})", scales.len()),
2327            },
2328            Op::FakeQuantize {
2329                bits,
2330                axis,
2331                ste,
2332                scale_mode,
2333            } => match axis {
2334                None => write!(
2335                    f,
2336                    "fake_quant(bits={bits},ste={ste:?},scale={scale_mode:?})"
2337                ),
2338                Some(d) => write!(
2339                    f,
2340                    "fake_quant(bits={bits},axis={d},ste={ste:?},scale={scale_mode:?})"
2341                ),
2342            },
2343            Op::FakeQuantizeLSQ { bits, axis } => match axis {
2344                None => write!(f, "fake_quant_lsq(bits={bits})"),
2345                Some(d) => write!(f, "fake_quant_lsq(bits={bits},axis={d})"),
2346            },
2347            Op::FakeQuantizeLSQBackwardX { bits, .. } => {
2348                write!(f, "fake_quant_lsq_bwd_x(bits={bits})")
2349            }
2350            Op::FakeQuantizeLSQBackwardScale { bits, .. } => {
2351                write!(f, "fake_quant_lsq_bwd_s(bits={bits})")
2352            }
2353            Op::Cast { to } => write!(f, "cast({to})"),
2354            Op::StopGradient => write!(f, "stop_gradient"),
2355            Op::Binary(op) => write!(f, "{op:?}"),
2356            Op::Compare(op) => write!(f, "{op:?}"),
2357            Op::Where => write!(f, "where"),
2358            Op::Fma => write!(f, "fma"),
2359            Op::MatMul => write!(f, "matmul"),
2360            Op::DotGeneral { .. } => write!(f, "dot_general"),
2361            Op::DenseSolve => write!(f, "dense_solve"),
2362            Op::BatchedDenseSolve => write!(f, "batched_dense_solve"),
2363            Op::LayerNorm { eps, .. } => write!(f, "layer_norm(eps={eps})"),
2364            Op::GroupNorm { num_groups, eps } => {
2365                write!(f, "group_norm(groups={num_groups},eps={eps})")
2366            }
2367            Op::BatchNormInference { eps } => write!(f, "batch_norm_inference(eps={eps})"),
2368            Op::ResizeNearest2x => write!(f, "resize_nearest_2x"),
2369            Op::RmsNorm { eps, .. } => write!(f, "rms_norm(eps={eps})"),
2370            Op::Attention {
2371                num_heads,
2372                head_dim,
2373                mask_kind,
2374                score_scale,
2375                attn_logit_softcap,
2376            } => {
2377                let mut s = match mask_kind {
2378                    MaskKind::Custom => format!("attention(h={num_heads},d={head_dim})"),
2379                    MaskKind::None => format!("attention(h={num_heads},d={head_dim},nomask)"),
2380                    MaskKind::Causal => format!("attention(h={num_heads},d={head_dim},causal)"),
2381                    MaskKind::SlidingWindow(w) => {
2382                        format!("attention(h={num_heads},d={head_dim},sw={w})")
2383                    }
2384                    MaskKind::Bias => format!("attention(h={num_heads},d={head_dim},bias)"),
2385                };
2386                if let Some(sc) = score_scale {
2387                    s.push_str(&format!(",scale={sc}"));
2388                }
2389                if let Some(cap) = attn_logit_softcap {
2390                    s.push_str(&format!(",softcap={cap}"));
2391                }
2392                write!(f, "{s}")
2393            }
2394            Op::Rope {
2395                head_dim,
2396                n_rot,
2397                style,
2398            } => write!(f, "rope(d={head_dim}, n_rot={n_rot}, style={style:?})"),
2399            Op::AxialRope2d {
2400                end_x,
2401                end_y,
2402                head_dim,
2403                num_heads,
2404                theta,
2405                repeat_factor,
2406            } => write!(
2407                f,
2408                "axial_rope2d({end_x}x{end_y},h={num_heads},d={head_dim},θ={theta},r={repeat_factor})"
2409            ),
2410            Op::Reshape { new_shape } => write!(f, "reshape({new_shape:?})"),
2411            Op::Transpose { perm } => write!(f, "transpose({perm:?})"),
2412            Op::Narrow { axis, start, len } => write!(f, "narrow({axis},{start},{len})"),
2413            Op::Reverse { axes } => write!(f, "reverse({axes:?})"),
2414            Op::Concat { axis } => write!(f, "concat(axis={axis})"),
2415            Op::Expand { .. } => write!(f, "expand"),
2416            Op::Gather { axis } => write!(f, "gather(axis={axis})"),
2417            Op::Reduce { op, axes, .. } => write!(f, "reduce_{op:?}({axes:?})"),
2418            Op::Softmax { axis } => write!(f, "softmax(axis={axis})"),
2419            Op::Cumsum { axis, exclusive } => {
2420                if *exclusive {
2421                    write!(f, "cumsum(axis={axis},excl)")
2422                } else {
2423                    write!(f, "cumsum(axis={axis})")
2424                }
2425            }
2426            Op::ArgMax { axis, keep_dim } => write!(f, "argmax(axis={axis},keep={keep_dim})"),
2427            Op::ArgMin { axis, keep_dim } => write!(f, "argmin(axis={axis},keep={keep_dim})"),
2428            Op::Sample {
2429                top_k,
2430                top_p,
2431                temperature,
2432                ..
2433            } => {
2434                write!(f, "sample(t={temperature}")?;
2435                if *top_k > 0 {
2436                    write!(f, ",k={top_k}")?;
2437                }
2438                if *top_p < 1.0 {
2439                    write!(f, ",p={top_p}")?;
2440                }
2441                write!(f, ")")
2442            }
2443            Op::RngNormal {
2444                mean,
2445                scale,
2446                key,
2447                op_seed,
2448            } => {
2449                write!(f, "rng_normal({mean},{scale},key={key}")?;
2450                if let Some(s) = op_seed {
2451                    write!(f, ",seed={s}")?;
2452                }
2453                write!(f, ")")
2454            }
2455            Op::RngUniform {
2456                low,
2457                high,
2458                key,
2459                op_seed,
2460            } => {
2461                write!(f, "rng_uniform({low},{high},key={key}")?;
2462                if let Some(s) = op_seed {
2463                    write!(f, ",seed={s}")?;
2464                }
2465                write!(f, ")")
2466            }
2467            Op::TopK { k } => write!(f, "topk(k={k})"),
2468            Op::GroupedMatMul => write!(f, "grouped_matmul"),
2469            Op::DequantGroupedMatMul { scheme } => {
2470                write!(f, "dequant_grouped_matmul({scheme})")
2471            }
2472            Op::DequantMoEWeights { scheme } => write!(f, "dequant_moe_weights({scheme})"),
2473            Op::LoraMatMul { scale } => write!(f, "lora_matmul(scale={scale})"),
2474            Op::DequantMatMul { scheme } => write!(f, "dequant_matmul({scheme})"),
2475            Op::QMatMul {
2476                x_zp,
2477                w_zp,
2478                out_zp,
2479                mult,
2480            } => write!(
2481                f,
2482                "q_matmul(x_zp={x_zp},w_zp={w_zp},out_zp={out_zp},mult={mult})"
2483            ),
2484            Op::QConv2d { kernel_size, .. } => write!(f, "q_conv2d({kernel_size:?})"),
2485            Op::ScaledMatMul {
2486                lhs_format,
2487                rhs_format,
2488                scale_layout,
2489                has_bias,
2490            } => write!(
2491                f,
2492                "scaled_matmul({lhs_format}×{rhs_format},{scale_layout}{})",
2493                if *has_bias { ",bias" } else { "" }
2494            ),
2495            Op::ScaledQuantize {
2496                format,
2497                scale_layout,
2498            } => write!(f, "scaled_quantize({format},{scale_layout})"),
2499            Op::ScaledQuantScale {
2500                format,
2501                scale_layout,
2502            } => write!(f, "scaled_quant_scale({format},{scale_layout})"),
2503            Op::ScaledDequantize {
2504                format,
2505                scale_layout,
2506            } => write!(f, "scaled_dequantize({format},{scale_layout})"),
2507            Op::SelectiveScan { state_size } => write!(f, "ssm_scan(n={state_size})"),
2508            Op::GatedDeltaNet {
2509                state_size,
2510                carry_state,
2511            } => {
2512                if *carry_state {
2513                    write!(f, "gated_delta_net(n={state_size},carry)")
2514                } else {
2515                    write!(f, "gated_delta_net(n={state_size})")
2516                }
2517            }
2518            Op::Lstm {
2519                hidden_size,
2520                num_layers,
2521                bidirectional,
2522                carry,
2523            } => {
2524                let dir = if *bidirectional { "bi" } else { "uni" };
2525                let c = if *carry { ",carry" } else { "" };
2526                write!(f, "lstm(h={hidden_size},layers={num_layers},{dir}{c})")
2527            }
2528            Op::Gru {
2529                hidden_size,
2530                num_layers,
2531                bidirectional,
2532                carry,
2533            } => {
2534                let dir = if *bidirectional { "bi" } else { "uni" };
2535                let c = if *carry { ",carry" } else { "" };
2536                write!(f, "gru(h={hidden_size},layers={num_layers},{dir}{c})")
2537            }
2538            Op::Rnn {
2539                hidden_size,
2540                num_layers,
2541                bidirectional,
2542                carry,
2543                relu,
2544            } => {
2545                let dir = if *bidirectional { "bi" } else { "uni" };
2546                let act = if *relu { "relu" } else { "tanh" };
2547                let c = if *carry { ",carry" } else { "" };
2548                write!(f, "rnn(h={hidden_size},layers={num_layers},{dir},{act}{c})")
2549            }
2550            Op::Mamba2 {
2551                head_dim,
2552                state_size,
2553            } => write!(f, "mamba2(p={head_dim},n={state_size})"),
2554            Op::ScatterAdd => write!(f, "scatter_add"),
2555            Op::Conv { kernel_size, .. } => write!(f, "conv2d({kernel_size:?})"),
2556            Op::Im2Col { kernel_size, .. } => write!(f, "im2col({kernel_size:?})"),
2557            Op::ConvTranspose2d { kernel_size, .. } => {
2558                write!(f, "conv_transpose2d({kernel_size:?})")
2559            }
2560            Op::Conv3d { stride, .. } => write!(f, "conv3d(stride={stride:?})"),
2561            Op::ConvTranspose3d { stride, .. } => {
2562                write!(f, "conv_transpose3d(stride={stride:?})")
2563            }
2564            Op::LayerNorm2d { eps } => write!(f, "layer_norm2d(eps={eps})"),
2565            Op::Pool {
2566                kind, kernel_size, ..
2567            } => write!(f, "pool_{kind:?}({kernel_size:?})"),
2568            Op::ReluBackward => write!(f, "relu_backward"),
2569            Op::ActivationBackward { kind } => write!(f, "{kind:?}_backward"),
2570            Op::ComplexNormSq => write!(f, "complex_norm_sq"),
2571            Op::ComplexNormSqBackward => write!(f, "complex_norm_sq_backward"),
2572            Op::Conjugate => write!(f, "conjugate"),
2573            Op::FakeQuantizeBackward { bits, ste, .. } => {
2574                write!(f, "fake_quant_backward(bits={bits},ste={ste:?})")
2575            }
2576            Op::MaxPool2dBackward { kernel_size, .. } => {
2577                write!(f, "maxpool2d_backward({kernel_size:?})")
2578            }
2579            Op::Conv2dBackwardInput { kernel_size, .. } => {
2580                write!(f, "conv2d_backward_input({kernel_size:?})")
2581            }
2582            Op::Conv2dBackwardWeight { kernel_size, .. } => {
2583                write!(f, "conv2d_backward_weight({kernel_size:?})")
2584            }
2585            Op::SoftmaxCrossEntropy => write!(f, "sce"),
2586            Op::SoftmaxCrossEntropyWithLogits => write!(f, "sce_with_logits"),
2587            Op::SoftmaxCrossEntropyBackward => write!(f, "sce_backward"),
2588            Op::AttentionBackward {
2589                num_heads,
2590                head_dim,
2591                mask_kind,
2592                wrt,
2593            } => match mask_kind {
2594                MaskKind::None => write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},nomask)"),
2595                MaskKind::Causal => {
2596                    write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},causal)")
2597                }
2598                MaskKind::SlidingWindow(w) => {
2599                    write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},sw={w})")
2600                }
2601                MaskKind::Custom => {
2602                    write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},custom)")
2603                }
2604                MaskKind::Bias => write!(f, "attn_bwd_{wrt:?}(h={num_heads},d={head_dim},bias)"),
2605            },
2606            Op::FusedMatMulBiasAct { activation } => {
2607                write!(f, "fused_mm_bias")?;
2608                if let Some(a) = activation {
2609                    write!(f, "_{a:?}")?;
2610                }
2611                Ok(())
2612            }
2613            Op::FusedResidualLN { has_bias, eps } => {
2614                write!(f, "fused_residual")?;
2615                if *has_bias {
2616                    write!(f, "_bias")?;
2617                }
2618                write!(f, "_ln(eps={eps})")
2619            }
2620            Op::FusedResidualRmsNorm { has_bias, eps } => {
2621                write!(f, "fused_residual")?;
2622                if *has_bias {
2623                    write!(f, "_bias")?;
2624                }
2625                write!(f, "_rms(eps={eps})")
2626            }
2627            Op::FusedSwiGLU {
2628                cast_to,
2629                gate_first,
2630            } => {
2631                let mut s = match cast_to {
2632                    Some(dt) => format!("fused_swiglu(cast={dt}"),
2633                    None => "fused_swiglu(".to_string(),
2634                };
2635                if *gate_first {
2636                    s.push_str(",gate_first");
2637                }
2638                s.push(')');
2639                write!(f, "{s}")
2640            }
2641            Op::FusedAttentionBlock {
2642                num_heads,
2643                head_dim,
2644                has_bias,
2645                has_rope,
2646            } => {
2647                write!(f, "fused_attn(h={num_heads},d={head_dim}")?;
2648                if *has_bias {
2649                    write!(f, ",bias")?;
2650                }
2651                if *has_rope {
2652                    write!(f, ",rope")?;
2653                }
2654                write!(f, ")")
2655            }
2656            Op::If { .. } => write!(f, "if(...)"),
2657            Op::While { max_iterations, .. } => match max_iterations {
2658                Some(n) => write!(f, "while(...max={n})"),
2659                None => write!(f, "while(...)"),
2660            },
2661            Op::Scan {
2662                length,
2663                save_trajectory,
2664                num_xs,
2665                ..
2666            } => {
2667                let traj = if *save_trajectory { ",traj" } else { "" };
2668                let xs = if *num_xs > 0 {
2669                    format!(",xs={}", num_xs)
2670                } else {
2671                    String::new()
2672                };
2673                write!(f, "scan(len={length}{xs}{traj})")
2674            }
2675            Op::ScanBackward {
2676                length,
2677                save_trajectory,
2678                num_xs,
2679                ..
2680            } => {
2681                let traj = if *save_trajectory { ",traj" } else { "" };
2682                let xs = if *num_xs > 0 {
2683                    format!(",xs={}", num_xs)
2684                } else {
2685                    String::new()
2686                };
2687                write!(f, "scan_bwd(len={length}{xs}{traj})")
2688            }
2689            Op::ScanBackwardXs {
2690                length,
2691                save_trajectory,
2692                num_xs,
2693                xs_idx,
2694                ..
2695            } => {
2696                let traj = if *save_trajectory { ",traj" } else { "" };
2697                write!(
2698                    f,
2699                    "scan_bwd_xs(len={length},xs={num_xs},idx={xs_idx}{traj})"
2700                )
2701            }
2702            Op::FusedTransformerLayer {
2703                num_heads,
2704                head_dim,
2705                intermediate_size,
2706                has_bias,
2707                ..
2708            } => {
2709                write!(
2710                    f,
2711                    "fused_layer(h={num_heads},d={head_dim},int={intermediate_size}"
2712                )?;
2713                if *has_bias {
2714                    write!(f, ",bias")?;
2715                }
2716                write!(f, ")")
2717            }
2718            Op::ElementwiseRegion {
2719                chain,
2720                num_inputs,
2721                scalar_input_mask,
2722                input_modulus: _,
2723                prologue,
2724                prologue_input: _,
2725            } => {
2726                let pro = match prologue {
2727                    RegionPrologue::None => "",
2728                    RegionPrologue::ResizeNearest2x => ",prologue=resize2x",
2729                };
2730                if *scalar_input_mask != 0 {
2731                    write!(
2732                        f,
2733                        "ew_region(in={num_inputs},steps={},scalar_mask=0x{:x}{pro})",
2734                        chain.len(),
2735                        scalar_input_mask
2736                    )
2737                } else {
2738                    write!(f, "ew_region(in={num_inputs},steps={}{pro})", chain.len())
2739                }
2740            }
2741            Op::TransformRegion { steps, num_inputs } => {
2742                write!(f, "transform_region(in={num_inputs},steps={})", steps.len())
2743            }
2744            Op::BatchElementwiseRegion {
2745                chain,
2746                num_batch_inputs,
2747                scalar_input_mask,
2748                prologue,
2749                ..
2750            } => write!(
2751                f,
2752                "batch_ew_region(batch={num_batch_inputs},steps={},mask=0x{:x},prologue={prologue:?})",
2753                chain.len(),
2754                scalar_input_mask
2755            ),
2756            Op::LayerNormBackwardInput { eps, .. } => {
2757                write!(f, "layer_norm_backward_input(eps={eps})")
2758            }
2759            Op::LayerNormBackwardGamma { eps, .. } => {
2760                write!(f, "layer_norm_backward_gamma(eps={eps})")
2761            }
2762            Op::RmsNormBackwardInput { eps, .. } => write!(f, "rms_norm_backward_input(eps={eps})"),
2763            Op::RmsNormBackwardGamma { eps, .. } => write!(f, "rms_norm_backward_gamma(eps={eps})"),
2764            Op::RmsNormBackwardBeta { eps, .. } => write!(f, "rms_norm_backward_beta(eps={eps})"),
2765            Op::RopeBackward { head_dim, n_rot } => {
2766                write!(f, "rope_backward(d={head_dim},n_rot={n_rot})")
2767            }
2768            Op::GroupNormBackwardInput { num_groups, eps } => {
2769                write!(f, "group_norm_backward_input(g={num_groups},eps={eps})")
2770            }
2771            Op::GroupNormBackwardGamma { num_groups, eps } => {
2772                write!(f, "group_norm_backward_gamma(g={num_groups},eps={eps})")
2773            }
2774            Op::GroupNormBackwardBeta { num_groups, eps } => {
2775                write!(f, "group_norm_backward_beta(g={num_groups},eps={eps})")
2776            }
2777            Op::BatchNormInferenceBackwardInput { eps } => {
2778                write!(f, "batch_norm_inference_backward_input(eps={eps})")
2779            }
2780            Op::BatchNormInferenceBackwardGamma { eps } => {
2781                write!(f, "batch_norm_inference_backward_gamma(eps={eps})")
2782            }
2783            Op::BatchNormInferenceBackwardBeta => {
2784                write!(f, "batch_norm_inference_backward_beta")
2785            }
2786            Op::CumsumBackward { axis, exclusive } => {
2787                write!(f, "cumsum_backward(axis={axis},exclusive={exclusive})")
2788            }
2789            Op::GatherBackward { axis } => write!(f, "gather_backward(axis={axis})"),
2790            Op::GaussianSplatRender {
2791                width,
2792                height,
2793                tile_size,
2794                radius_scale,
2795                alpha_cutoff,
2796                max_splat_steps,
2797                transmittance_threshold,
2798                max_list_entries,
2799            } => write!(
2800                f,
2801                "gaussian_splat_render({width}x{height},tile={tile_size},r={radius_scale},a={alpha_cutoff},steps={max_splat_steps},t={transmittance_threshold},list={max_list_entries})"
2802            ),
2803            Op::GaussianSplatRenderBackward {
2804                width,
2805                height,
2806                loss_grad_clip,
2807                sh_band,
2808                ..
2809            } => write!(
2810                f,
2811                "gaussian_splat_render_bwd({width}x{height},clip={loss_grad_clip},sh={sh_band})"
2812            ),
2813            Op::GaussianSplatPrepare {
2814                width,
2815                height,
2816                tile_size,
2817                radius_scale,
2818                alpha_cutoff,
2819                max_splat_steps,
2820                transmittance_threshold,
2821                max_list_entries,
2822                ..
2823            } => write!(
2824                f,
2825                "gaussian_splat_prepare({width}x{height},tile={tile_size},r={radius_scale},a={alpha_cutoff},steps={max_splat_steps},t={transmittance_threshold},list={max_list_entries})"
2826            ),
2827            Op::GaussianSplatRasterize {
2828                width,
2829                height,
2830                tile_size,
2831                alpha_cutoff,
2832                max_splat_steps,
2833                transmittance_threshold,
2834                max_list_entries,
2835                ..
2836            } => write!(
2837                f,
2838                "gaussian_splat_rasterize({width}x{height},tile={tile_size},a={alpha_cutoff},steps={max_splat_steps},t={transmittance_threshold},list={max_list_entries})"
2839            ),
2840            Op::Custom {
2841                name,
2842                num_inputs,
2843                attrs,
2844            } => write!(f, "custom({name},in={num_inputs},attrs={}B)", attrs.len()),
2845            Op::CustomFn {
2846                num_inputs,
2847                vjp_body,
2848                jvp_body,
2849                ..
2850            } => {
2851                let v = if vjp_body.is_some() { ",vjp" } else { "" };
2852                let j = if jvp_body.is_some() { ",jvp" } else { "" };
2853                write!(f, "custom_fn(in={num_inputs}{v}{j})")
2854            }
2855            Op::Fft { inverse, norm } => {
2856                write!(f, "fft(inverse={inverse}, norm={norm:?})")
2857            }
2858            Op::FftButterflyStage { stage, n_fft } => {
2859                write!(f, "fft_butterfly_stage(stage={stage}, n_fft={n_fft})")
2860            }
2861            Op::LogMel => write!(f, "log_mel()"),
2862            Op::LogMelBackward => write!(f, "log_mel_backward()"),
2863            Op::WelchPeaks { k, n_segments } => {
2864                write!(f, "welch_peaks(k={k}, n_segments={n_segments})")
2865            }
2866        }
2867    }
2868}