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