Skip to main content

rlx_ir/
op.rs

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