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