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