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