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