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