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