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