rlx_cpu/thunk/types.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4#![allow(unsafe_op_in_unsafe_fn)]
5use crate::thunk::*;
6
7/// Instrumentation: count of `FusedNomicLayer` thunks emitted by the full-layer
8/// fusion in this process. The fusion is otherwise invisible through the
9/// `Session` API, so out-of-tree model tests (rlx-models' Nomic parity test)
10/// load this to confirm the fusion actually fired (non-vacuous parity). Reset
11/// by storing 0 before a compile.
12pub static FUSED_NOMIC_LAYER_COUNT: std::sync::atomic::AtomicU64 =
13 std::sync::atomic::AtomicU64::new(0);
14/// Storage width of one lane of a fused region operand.
15///
16/// A region computes in `f32` scratch, but its operands are not always stored
17/// as `f32`: `plan_memory_native*` packs an F16/BF16 tensor at 2 bytes and an
18/// F64 one at 8, while `plan_memory_f32_uniform` widens everything to 4. The
19/// declared dtype alone therefore cannot say how to address a lane — a 4-byte
20/// slot holding an `F16`-typed node contains a widened `f32`. Resolved at
21/// compile time from `Arena::byte_size`, which knows what the plan actually
22/// did.
23///
24/// Only widths that are unambiguous are distinguished. Every 4-byte lane stays
25/// `F32`, including integer-typed ones: under the f32-uniform plan those slots
26/// really do hold floats, and the region has always written them that way.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum LaneKind {
29 F32,
30 F16,
31 Bf16,
32 F64,
33}
34
35impl LaneKind {
36 /// Pick the lane from the width the memory plan actually assigned.
37 pub fn resolve(dtype: rlx_ir::DType, elem_bytes: usize) -> Self {
38 match (dtype, elem_bytes) {
39 (rlx_ir::DType::F16, 2) => LaneKind::F16,
40 (rlx_ir::DType::BF16, 2) => LaneKind::Bf16,
41 (rlx_ir::DType::F64, 8) => LaneKind::F64,
42 _ => LaneKind::F32,
43 }
44 }
45
46 /// Read one lane and widen it to the region's `f32` working precision.
47 ///
48 /// # Safety
49 /// `base + off` must point at a slot of at least `(idx + 1)` lanes.
50 #[inline(always)]
51 pub unsafe fn load(self, base: *const u8, off: usize, idx: usize) -> f32 {
52 unsafe {
53 let p = base.add(off);
54 match self {
55 LaneKind::F32 => *(p as *const f32).add(idx),
56 LaneKind::F16 => half::f16::from_bits(*(p as *const u16).add(idx)).to_f32(),
57 LaneKind::Bf16 => half::bf16::from_bits(*(p as *const u16).add(idx)).to_f32(),
58 LaneKind::F64 => *(p as *const f64).add(idx) as f32,
59 }
60 }
61 }
62
63 /// Narrow the region's `f32` result back into one lane.
64 ///
65 /// # Safety
66 /// `base + off` must point at a writable slot of at least `(idx + 1)` lanes.
67 #[inline(always)]
68 pub unsafe fn store(self, base: *mut u8, off: usize, idx: usize, v: f32) {
69 unsafe {
70 let p = base.add(off);
71 match self {
72 LaneKind::F32 => *(p as *mut f32).add(idx) = v,
73 LaneKind::F16 => *(p as *mut u16).add(idx) = half::f16::from_f32(v).to_bits(),
74 LaneKind::Bf16 => *(p as *mut u16).add(idx) = half::bf16::from_f32(v).to_bits(),
75 LaneKind::F64 => *(p as *mut f64).add(idx) = v as f64,
76 }
77 }
78 }
79}
80
81/// A pre-compiled kernel call with all args resolved to arena offsets.
82///
83/// `IntoStaticStr` gives every variant a zero-alloc `&'static str` name (its
84/// identifier) via `<&'static str>::from(&thunk)`, used by the profiler and
85/// tracer (`thunk_kind_name`). Deriving it means a newly added variant is
86/// labeled automatically instead of silently landing in an `"Other"` bucket.
87#[derive(Clone, strum_macros::IntoStaticStr)]
88pub enum Thunk {
89 /// Skip (Input/Param already in arena)
90 Nop,
91 /// Fused element-wise region: ONE pass applies the whole `chain` of scalar
92 /// steps per output element (bias-add → relu → … collapse into a single
93 /// kernel, no intermediate tensors). This is RLX's XLA-style auto-fusion
94 /// path on CPU — the GPU backends already run the same `ElementwiseRegion`
95 /// IR via a generated kernel; this is the scalar interpreter twin so any
96 /// fused chain runs without a hand-written per-op kernel.
97 /// `dst`/`input_offs` are byte offsets; `input_modulus[i]` is input i's
98 /// broadcast period (0 ⇒ read element `gid`; scalar inputs read element 0).
99 ElementwiseRegion {
100 dst: usize,
101 len: u32,
102 input_offs: Vec<usize>,
103 chain: Vec<rlx_ir::op::ChainStep>,
104 scalar_input_mask: u32,
105 input_modulus: [u32; 16],
106 /// Storage lane of each input, parallel to `input_offs`.
107 in_lanes: Vec<LaneKind>,
108 /// Storage lane of `dst`.
109 out_lane: LaneKind,
110 },
111 /// C = A @ B (BLAS sgemm)
112 Sgemm {
113 a: usize,
114 b: usize,
115 c: usize,
116 m: u32,
117 k: u32,
118 n: u32,
119 },
120 /// `C[m,n] = A[m,k](f32) @ B[k,n](BF16)` — dequant-on-the-fly GEMM with a
121 /// BF16 right-hand (half the weight memory traffic; used for a bf16-resident
122 /// LM head). `b` is a BF16 arena buffer (`k*n` `u16`), `a`/`c` are f32.
123 /// `C[m,n] = A[m,k] @ B[k,n]` where any operand may live in the arena
124 /// PACKED at 2 B/elem. Packed sides are widened to f32, the GEMM runs in
125 /// f32, and a packed `c` is rounded back. This is the general fallback for
126 /// autocast graphs, where a half tensor is often a plain activation rather
127 /// than a weight and the planner may or may not have widened its slot.
128 SgemmHalf {
129 a: usize,
130 b: usize,
131 c: usize,
132 m: u32,
133 k: u32,
134 n: u32,
135 a_kind: HalfKind,
136 b_kind: HalfKind,
137 c_kind: HalfKind,
138 },
139 SgemmBf16 {
140 a: usize,
141 b: usize,
142 c: usize,
143 m: u32,
144 k: u32,
145 n: u32,
146 },
147 /// `C[m,n] = A[m,k](f32) @ B[k,n](F16)` — IEEE-half twin of [`Self::SgemmBf16`].
148 /// `b` is an F16 arena buffer (`k*n` `u16`), `a`/`c` are f32. Without this a
149 /// generic `Op::MatMul` with an F16 weight read the half bytes as f32 garbage.
150 SgemmF16 {
151 a: usize,
152 b: usize,
153 c: usize,
154 m: u32,
155 k: u32,
156 n: u32,
157 },
158 /// `C[m,n] = op(A) @ op(B)` with optional transpose of each operand,
159 /// done via cblas trans flags (no materialized transpose). Emitted when
160 /// the thunk compiler folds a last-two-axis `Op::Transpose` feeding a
161 /// matmul (the dominant cost in matmul backprop). `a`/`b` are the
162 /// *pre-transpose* operands. lda/ldb are derived from the trans flags.
163 SgemmT {
164 a: usize,
165 b: usize,
166 c: usize,
167 m: u32,
168 k: u32,
169 n: u32,
170 ta: bool,
171 tb: bool,
172 },
173 /// Fused SGD-with-momentum parameter update, one pass per param.
174 /// Computes `v' = mom·v + g ; p' = p − lr·v'` reading `param`/`vel`/
175 /// `grad` and writing `p_out`/`v_out`. Emitted when the thunk compiler
176 /// folds the in-graph SGD chain `Sub(p, Mul(Add(Mul(v,mom),g), lr))`
177 /// (4 `BinaryFull` ops + 2 full-size constants per param) into a single
178 /// kernel — the dominant cost of the fully-fused training step. All
179 /// fields are arena byte offsets except the scalar hyperparameters.
180 SgdMomentum {
181 param: usize,
182 vel: usize,
183 grad: usize,
184 p_out: usize,
185 v_out: usize,
186 lr: f32,
187 mom: f32,
188 len: u32,
189 },
190 /// Complex (C64) dense GEMM `C = A·B`. Operands are interleaved
191 /// `[re, im]` f32; `a`/`b`/`c` are byte offsets, `m`/`k`/`n` are
192 /// complex-element matrix dims (`A` `[m,k]`, `B` `[k,n]`, `C` `[m,n]`).
193 CgemmC64 {
194 a: usize,
195 b: usize,
196 c: usize,
197 m: u32,
198 k: u32,
199 n: u32,
200 },
201 /// f64 dense solve `x = A⁻¹·b` via LAPACK dgesv.
202 /// `a`, `b`, `x` are byte-offsets into the arena. `n` is the matrix
203 /// dimension; `nrhs` is 1 for a vector RHS or >1 for multi-RHS.
204 /// The kernel materializes scratch copies of A and b internally
205 /// (LAPACK overwrites both with LU factors and solution).
206 DenseSolveF64 {
207 a: usize,
208 b: usize,
209 x: usize,
210 n: u32,
211 nrhs: u32,
212 },
213 /// f32 twin of `DenseSolveF64`. Calls LAPACK `sgesv` (or the
214 /// no-blas Rust fallback). Same arena byte-offset contract.
215 DenseSolveF32 {
216 a: usize,
217 b: usize,
218 x: usize,
219 n: u32,
220 nrhs: u32,
221 },
222 /// Cholesky factorization `A = L·Lᵀ`. `a` input `[n,n]`, `l` output `[n,n]`
223 /// lower-triangular (upper zeroed). f32 arena; promoted to f64 for LAPACK
224 /// `dpotrf`.
225 CholeskyF32 {
226 a: usize,
227 l: usize,
228 n: u32,
229 },
230 /// Triangular solve `op(A)·X = B` (f32 arena; promoted to f64 for BLAS
231 /// `trsm`). `a` `[n,n]`, `b`/`x` `[n,nrhs]`.
232 TriangularSolveF32 {
233 a: usize,
234 b: usize,
235 x: usize,
236 n: u32,
237 nrhs: u32,
238 lower: bool,
239 transpose: bool,
240 },
241 /// Determinant (`log_abs=false`) or `log|det|` (`log_abs=true`) of `a`
242 /// `[n,n]` → scalar `out`. f32 arena; promoted to f64 for LAPACK LU.
243 DetF32 {
244 a: usize,
245 out: usize,
246 n: u32,
247 log_abs: bool,
248 },
249 /// Sort (`arg=false`, output values) or argsort (`arg=true`, output f32
250 /// indices) along an axis, viewed as `outer × axis_dim × inner`.
251 SortF32 {
252 src: usize,
253 dst: usize,
254 outer: u32,
255 axis_dim: u32,
256 inner: u32,
257 descending: bool,
258 arg: bool,
259 },
260 /// One factor of the thin SVD of `a` `[m,n]`. `part`: 0=U `[m,k]`, 1=S `[k]`,
261 /// 2=Vt `[k,n]` (`k=min(m,n)`). f32 arena; promoted to f64 for LAPACK `gesdd`.
262 SvdF32 {
263 a: usize,
264 out: usize,
265 m: u32,
266 n: u32,
267 part: u8,
268 },
269 /// One factor of the thin QR of `a` `[m,n]`. `part`: 0=Q `[m,k]`, 1=R `[k,n]`
270 /// (`k=min(m,n)`). f32 arena; promoted to f64 for LAPACK `geqrf`/`orgqr`.
271 QrF32 {
272 a: usize,
273 out: usize,
274 m: u32,
275 n: u32,
276 part: u8,
277 },
278 /// Batched f64 dense solve. `a`, `b`, `x` are byte-offsets to
279 /// the leading slice; `batch` is the number of independent
280 /// systems. Per slice the kernel calls `dgesv(A_i, b_i, n, nrhs)`
281 /// — LAPACK has no batched dgesv on Accelerate, so we loop.
282 BatchedDenseSolveF64 {
283 a: usize,
284 b: usize,
285 x: usize,
286 batch: u32,
287 n: u32,
288 nrhs: u32,
289 },
290 /// Batched f32 dense solve — loop of `sgesv` per batch slice.
291 BatchedDenseSolveF32 {
292 a: usize,
293 b: usize,
294 x: usize,
295 batch: u32,
296 n: u32,
297 nrhs: u32,
298 },
299 /// Batched f64 matmul. Both inputs and output have a leading
300 /// batch axis of size `batch`. Per-batch independent dgemm:
301 /// `C[i] = A[i] @ B[i]` for `i in 0..batch`. Used by VJP rules
302 /// that emit per-batch outer products (e.g., BatchedDenseSolve
303 /// VJP). The unbatched `Dgemm` thunk handles the rank-2 case.
304 BatchedDgemmF64 {
305 a: usize,
306 b: usize,
307 c: usize,
308 batch: u32,
309 m: u32,
310 k: u32,
311 n: u32,
312 /// `true` when operand `a`/`b` has batch dim 1 and is BROADCAST across
313 /// every output batch (per-matrix stride 0 — reuse matrix 0). Mirrors
314 /// [`Thunk::BatchedSgemm`]; without it the loop over-reads a batch-1
315 /// operand for `bi > 0`.
316 a_bcast: bool,
317 b_bcast: bool,
318 },
319 /// Batched f32 matmul — same loop-per-batch shape as
320 /// `BatchedDgemmF64` but calling `sgemm`. Needed for attention
321 /// patterns where both operands carry a batch dim (e.g. q@k^T
322 /// and attn@v in decomposed self-attention). The 2-D `Sgemm`
323 /// flatten trick is wrong in that case because it treats `b` as
324 /// a single shared RHS across every batch.
325 BatchedSgemm {
326 a: usize,
327 b: usize,
328 c: usize,
329 batch: u32,
330 m: u32,
331 k: u32,
332 n: u32,
333 /// `true` when operand `a`/`b` has batch dim 1 and is BROADCAST across
334 /// every output batch (its per-matrix stride is 0 — reuse matrix 0).
335 /// Without this the loop over-reads a batch-1 operand for `bi>0`.
336 a_bcast: bool,
337 b_bcast: bool,
338 },
339 /// C = A @ B via Accelerate cblas_dgemm. Mirror of `Sgemm` at f64.
340 Dgemm {
341 a: usize,
342 b: usize,
343 c: usize,
344 m: u32,
345 k: u32,
346 n: u32,
347 },
348 /// f64 N-D index walk used for both `Op::Transpose` and `Op::Expand`.
349 /// `in_strides` carries 0s on broadcast axes (Expand) or permuted
350 /// strides (Transpose). Mirror of `Thunk::Transpose` at f64.
351 TransposeF64 {
352 src: usize,
353 dst: usize,
354 in_total: u32,
355 out_dims: Vec<u32>,
356 in_strides: Vec<u32>,
357 },
358 /// f64 element-wise activation. Single-input, single-output. The
359 /// kernel always reads from `src` and writes to `dst`, so it works
360 /// whether or not the planner aliased the two slots.
361 ActivationF64 {
362 src: usize,
363 dst: usize,
364 len: u32,
365 kind: Activation,
366 },
367 /// Element-wise complex squared-magnitude: `|z|² = re² + im²`.
368 /// Reads the C64 input at `src` as `2·len` f32 (`re,im` pairs),
369 /// writes `len` f32 to `dst`.
370 ComplexNormSqF32 {
371 src: usize,
372 dst: usize,
373 /// Logical element count (number of complex values).
374 len: u32,
375 },
376 /// Wirtinger backward for `ComplexNormSqF32`: `dz = g · z` as
377 /// C64. Reads `z` at `2·len` f32 + `g` at `len` f32; writes
378 /// `2·len` f32 to `dz`.
379 ComplexNormSqBackwardF32 {
380 z: usize,
381 g: usize,
382 dz: usize,
383 len: u32,
384 },
385 /// Element-wise C64 conjugate: writes `[re_i, -im_i]` per element.
386 /// Layout matches the rest of C64 here (`re,im` interleaved f32).
387 ConjugateC64 {
388 src: usize,
389 dst: usize,
390 len: u32,
391 },
392 /// C64 element-wise activation. Only kinds with well-defined
393 /// complex extensions are supported: Neg, Exp, Log, Sqrt.
394 /// Everything else (Sigmoid, Tanh, Relu, Abs, Sin/Cos/Tan/Atan,
395 /// Round, GeLU family) is rejected at lowering — those don't have
396 /// single natural complex definitions. `len` is the **complex
397 /// element count** (the f32 buffer holds `2·len` floats).
398 ActivationC64 {
399 src: usize,
400 dst: usize,
401 len: u32,
402 kind: Activation,
403 },
404 /// f64 contiguous reduction along a single axis range. Layout
405 /// `[outer, reduced, inner]` in memory; output is `[outer, inner]`.
406 /// Sum only for now (Mean composes via 1/N multiply post-pass).
407 ReduceSumF64 {
408 src: usize,
409 dst: usize,
410 outer: u32,
411 reduced: u32,
412 inner: u32,
413 },
414 /// f64 plain copy (Reshape / Cast at the same dtype). Mirrors `Copy`
415 /// but at 8 bytes per element.
416 CopyF64 {
417 src: usize,
418 dst: usize,
419 len: u32,
420 },
421 /// i64 element copy (Reshape/Cast on i64 tensors).
422 CopyI64 {
423 src: usize,
424 dst: usize,
425 len: u32,
426 },
427 /// Truncating f32 → i64 (ONNX Cast; toward zero).
428 CastF32ToI64 {
429 src: usize,
430 dst: usize,
431 len: u32,
432 },
433 CastF32ToF64 {
434 src: usize,
435 dst: usize,
436 len: u32,
437 },
438 /// Narrowing f64 → f32 (`x as f32`, round-to-nearest). Without this the
439 /// generic Copy fallback byte-copies `len` f32-width elements out of the
440 /// 8-byte-per-element f64 buffer, so it reads each f64's two 4-byte halves
441 /// as two garbage f32s (round-trip F32→F64→F32 returned garbage).
442 CastF64ToF32 {
443 src: usize,
444 dst: usize,
445 len: u32,
446 },
447 /// Truncating f64 → i64 (ONNX Cast; toward zero). The generic 4-byte Copy
448 /// fallback reads/writes the wrong lane width against the 8-byte f64/i64
449 /// buffers (and would be a bit-copy, not a numeric convert).
450 CastF64ToI64 {
451 src: usize,
452 dst: usize,
453 len: u32,
454 },
455 /// Truncating f64 → i32 (ONNX Cast; toward zero). f64 is 8 bytes, i32 is 4;
456 /// the generic Copy reads only the low 4 bytes of each f64 as garbage.
457 CastF64ToI32 {
458 src: usize,
459 dst: usize,
460 len: u32,
461 },
462 /// f64 → Bool (`x != 0.0`). f64 is 8 bytes, Bool is 1; the generic Copy
463 /// misreads widths entirely.
464 CastF64ToBool {
465 src: usize,
466 dst: usize,
467 len: u32,
468 },
469 /// Narrowing i64 → i32 (`x as i32`, wrapping). 8-byte → 4-byte; the generic
470 /// Copy would keep only the low 4 bytes of each i64 (correct only for the
471 /// bit-truncation case, wrong as a Copy of `len` 4-byte lanes off an 8-byte
472 /// buffer). Mirror of the existing `CastI32ToI64` widening.
473 CastI64ToI32 {
474 src: usize,
475 dst: usize,
476 len: u32,
477 },
478 /// i64 → f64 (`x as f64`). Both are 8 bytes, but the generic 4-byte Copy
479 /// would move only half of each element AND bit-reinterpret (not a numeric
480 /// convert). Uses the existing i64/f64 accessors.
481 CastI64ToF64 {
482 src: usize,
483 dst: usize,
484 len: u32,
485 },
486 /// i32 → f64 (`x as f64`). 4-byte src → 8-byte dst; the generic Copy reads
487 /// too few source bytes and writes the wrong width.
488 CastI32ToF64 {
489 src: usize,
490 dst: usize,
491 len: u32,
492 },
493 /// f32 → f16 (`half::f16::from_f32`, round-to-nearest-even). Output is
494 /// 2-byte f16; there is no `sl_f16` accessor so the kernel writes the
495 /// little-endian f16 bytes straight into the arena.
496 CastF32ToF16 {
497 src: usize,
498 dst: usize,
499 len: u32,
500 },
501 /// f16 → f32 (`half::f16::to_f32`, exact). Input is 2-byte f16 read from the
502 /// arena bytes; output is native f32.
503 CastF16ToF32 {
504 src: usize,
505 dst: usize,
506 len: u32,
507 },
508 /// f32 → bf16 (`half::bf16::from_f32`, round-to-nearest-even). 2-byte bf16
509 /// output written as little-endian arena bytes.
510 CastF32ToBf16 {
511 src: usize,
512 dst: usize,
513 len: u32,
514 },
515 /// bf16 → f32 (`half::bf16::to_f32`, exact). 2-byte bf16 input read from the
516 /// arena bytes; output is native f32.
517 CastBf16ToF32 {
518 src: usize,
519 dst: usize,
520 len: u32,
521 },
522 /// Truncating f32 → i32 (ONNX Cast; toward zero).
523 CastF32ToI32 {
524 src: usize,
525 dst: usize,
526 len: u32,
527 },
528 /// i64 → f32 (ONNX Cast on shape scalars, e.g. Albert head-dim).
529 CastI64ToF32 {
530 src: usize,
531 dst: usize,
532 len: u32,
533 },
534 /// bool → i32 (BERT attention mask grid).
535 CastBoolToI32 {
536 src: usize,
537 dst: usize,
538 len: u32,
539 },
540 CastBoolToF32 {
541 src: usize,
542 dst: usize,
543 len: u32,
544 },
545 /// f32 → Bool (`x != 0.0`). Lets logical And/Or lower their {0,1} f32
546 /// arithmetic result straight back to a 1-byte Bool without a scalar
547 /// `Compare` (whose thunk has no broadcast and would read a scalar rhs
548 /// out of bounds).
549 CastF32ToBool {
550 src: usize,
551 dst: usize,
552 len: u32,
553 },
554 /// i32 → f32 (BERT attention mask cast before subtract).
555 CastI32ToF32 {
556 src: usize,
557 dst: usize,
558 len: u32,
559 },
560 /// i32 → i64 (widen integer indices/ids; e.g. an ONNX graph with I32
561 /// `context_tokens` feeding a codebook Gather whose kernel wants i64).
562 CastI32ToI64 {
563 src: usize,
564 dst: usize,
565 len: u32,
566 },
567 /// i32 → bool: `out[i] = (in[i] != 0) as u8`. Element-wise; the generic Copy
568 /// fallback byte-copies (4-byte i32 → 1-byte bool) and reads garbage. Needed by
569 /// MOSS-TTS's `Cast(attention_mask i32 → bool)` in the RoPE-position cumsum.
570 CastI32ToBool {
571 src: usize,
572 dst: usize,
573 len: u32,
574 },
575 /// i64 → bool: `out[i] = (in[i] != 0) as u8`. Element-wise; Copy would treat
576 /// 8-byte lanes as 1-byte bools. Soprano KV-backbone casts `attention_mask`
577 /// (I64 ones) to Bool before the causal Where(-inf) mask.
578 CastI64ToBool {
579 src: usize,
580 dst: usize,
581 len: u32,
582 },
583 /// bool → i64: `out[i] = in[i] as i64` (0/1). Widen a bool mask for CumSum/arith.
584 CastBoolToI64 {
585 src: usize,
586 dst: usize,
587 len: u32,
588 },
589 /// Generic scalar cast for any numeric dtype pair not covered by a
590 /// hand-written fast-path arm above (exotic widths: I8/I16/U8/U32, and
591 /// F16/BF16 against any non-F32 dtype). Converts element-by-element with
592 /// correct numeric semantics (float→int saturates, int→int wraps,
593 /// round-to-nearest for F16/BF16) instead of the corrupting generic 4-byte
594 /// `Copy` that silently mis-strides / bit-reinterprets. C64 is never routed
595 /// here (no scalar conversion is defined for complex).
596 CastGeneric {
597 src: usize,
598 dst: usize,
599 len: u32,
600 src_dtype: rlx_ir::DType,
601 dst_dtype: rlx_ir::DType,
602 },
603 /// f64 element-wise binary with broadcast. `len`/`lhs_len`/`rhs_len`
604 /// are element counts; kernel does `out[i] = lhs[i % lhs_len] OP rhs[i % rhs_len]`.
605 /// Mirror of `BinaryFull` at 8 bytes per element.
606 BinaryFullF64 {
607 lhs: usize,
608 rhs: usize,
609 dst: usize,
610 len: u32,
611 lhs_len: u32,
612 rhs_len: u32,
613 op: BinaryOp,
614 /// Output shape dims (row-major). Empty in the fast path. See
615 /// `BinaryFull` doc for the broadcast convention.
616 out_dims_bcast: Vec<u32>,
617 bcast_lhs_strides: Vec<u32>,
618 bcast_rhs_strides: Vec<u32>,
619 },
620 /// f64 concat — byte-for-byte mirror of `Concat` but copies
621 /// 8 bytes per element. Element-counted offsets/strides match
622 /// the f32 variant; the executor scales by elem_size internally.
623 ConcatF64 {
624 dst: usize,
625 outer: u32,
626 inner: u32,
627 total_axis: u32,
628 inputs: Vec<(usize, u32, u32)>,
629 },
630 /// C64 element-wise binary with broadcast. Same `len` /
631 /// `lhs_len` / `rhs_len` semantics as `BinaryFull` but each
632 /// "element" is one complex value (8 bytes = `[re, im]` as two
633 /// f32s). The executor reads the underlying f32 buffer at
634 /// `2·len` floats and walks element pairs. Supports Add / Sub /
635 /// Mul / Div; Max / Min / Pow have no single natural complex
636 /// definition and panic at lowering.
637 BinaryFullC64 {
638 lhs: usize,
639 rhs: usize,
640 dst: usize,
641 /// Complex element count (NOT f32 count). f32 buffer length
642 /// is `2·len`.
643 len: u32,
644 lhs_len: u32,
645 rhs_len: u32,
646 op: BinaryOp,
647 out_dims_bcast: Vec<u32>,
648 bcast_lhs_strides: Vec<u32>,
649 bcast_rhs_strides: Vec<u32>,
650 },
651 /// Bounded scan. Holds a recursively-compiled body schedule + a
652 /// pre-initialized body arena snapshot (constants filled). Each
653 /// outer execution clones the snapshot, copies the carry-in slot
654 /// from the outer arena, runs the body schedule `length` times,
655 /// then writes the final carry to the outer arena.
656 ///
657 /// Single-carry MVP — body has exactly one Input and one output,
658 /// both same shape and dtype.
659 Scan {
660 body: Arc<ThunkSchedule>,
661 body_init: Arc<Vec<u8>>, // pristine body arena bytes
662 body_input_off: usize, // byte offset of the body's carry-Input slot
663 body_output_off: usize, // byte offset of the body's output slot
664 outer_init_off: usize, // outer-arena offset of the initial carry
665 outer_final_off: usize, // outer-arena offset of the final carry / trajectory base
666 length: u32,
667 carry_bytes: u32, // carry size in bytes
668 /// When true, write each step's carry to the outer arena at
669 /// offset `outer_final_off + t * carry_bytes`, producing a
670 /// `[length, *carry]` stacked trajectory. When false, only the
671 /// final carry lands at `outer_final_off`.
672 save_trajectory: bool,
673 /// Per-step `xs` inputs. For each: (body_x_input_off,
674 /// outer_xs_base_off, per_step_bytes). Per iteration `t`, the
675 /// executor copies `outer_xs_base_off + t * per_step_bytes`
676 /// into `body_x_input_off`. Empty when the scan has no xs.
677 xs_inputs: Arc<Vec<(usize, usize, u32)>>,
678 /// Broadcast inputs — values constant across iterations. For
679 /// each: (body_bcast_input_off, outer_bcast_off, total_bytes).
680 /// Filled into `body_buf` ONCE before the scan loop starts
681 /// (xs in contrast are re-filled every iteration). Empty when
682 /// the scan has no bcasts.
683 bcast_inputs: Arc<Vec<(usize, usize, u32)>>,
684 /// Number of trajectory checkpoints (when `save_trajectory`).
685 /// `0` or `length` ⇒ save every iteration. Otherwise save only
686 /// `K` rows at indices `floor((k+1) * length / K) - 1` for
687 /// `k in 0..K`. Last index is always `length-1` so the final
688 /// carry is always cached.
689 num_checkpoints: u32,
690 },
691
692 /// Reverse-mode AD companion to `Thunk::Scan`. Walks `t = length-1
693 /// .. 0`, threading `dcarry` through the body's VJP. Per iteration:
694 /// writes `carry_t` (from outer init or trajectory), each `xs_i[t]`
695 /// slice, and the current `dcarry` into the body_vjp's Input
696 /// slots, runs body_vjp, reads new `dcarry` from its single output.
697 /// f64 carry only — the upstream-accumulation step in trajectory
698 /// mode does an element-wise f64 add.
699 ScanBackward {
700 body_vjp: Arc<ThunkSchedule>,
701 body_init: Arc<Vec<u8>>,
702 body_carry_in_off: usize, // body_vjp's mirrored body-carry-input slot
703 body_x_offs: Arc<Vec<usize>>, // body_vjp's mirrored x_t_i Input slots, in xs order
704 body_d_output_off: usize, // body_vjp's "d_output" Input slot
705 body_dcarry_out_off: usize, // body_vjp's gradient output
706 outer_init_off: usize, // original init carry
707 outer_traj_off: usize, // [length-or-K, *carry] trajectory base
708 outer_upstream_off: usize, // upstream gradient (carry shape, or [length, *carry])
709 /// Per-xs entries: (outer_xs_base_off, per_step_bytes). Read
710 /// `xs_i[t]` from `outer_xs_base_off + t * per_step_bytes`.
711 outer_xs_offs: Arc<Vec<(usize, u32)>>,
712 outer_dinit_off: usize, // output: dinit
713 length: u32,
714 carry_bytes: u32,
715 /// Bytes per element in the carry tensor: 4 for f32, 8 for f64.
716 /// Used to dispatch the trajectory-mode upstream accumulation
717 /// kernel (the dcarry += upstream\[t\] add must use the right
718 /// floating-point type — a hard-coded f64 add silently does
719 /// nothing for an f32 carry whose `cb` isn't divisible by 8).
720 carry_elem_size: u32,
721 save_trajectory: bool, // true → upstream is per-step; false → just final
722 /// Recursive checkpointing config. `0` or `length` ⇒ full
723 /// trajectory cached, no recompute (existing behavior).
724 /// `0 < K < length` ⇒ trajectory has only K rows; the executor
725 /// recomputes intermediate carries via `forward_body` between
726 /// checkpoints. Memory: O(K · carry_bytes); time: O(length).
727 num_checkpoints: u32,
728 /// Forward body schedule (same compiled body as the forward
729 /// Op::Scan), used for recompute when `num_checkpoints` is
730 /// active. `None` for the All strategy.
731 forward_body: Option<Arc<ThunkSchedule>>,
732 /// Pristine forward body arena bytes (constants filled).
733 forward_body_init: Option<Arc<Vec<u8>>>,
734 /// Forward body's carry-Input and output slot offsets — needed
735 /// to seed/read the body during recompute.
736 forward_body_carry_in_off: usize,
737 forward_body_output_off: usize,
738 /// Forward body's per-step xs Input slots (one per outer xs).
739 /// Same indexing convention as `body_x_offs`.
740 forward_body_x_offs: Arc<Vec<usize>>,
741 },
742
743 /// Companion to `ScanBackward` that materializes one stacked
744 /// `dxs_i`. Same backward loop; per iteration, after running
745 /// body_vjp, copies its `body_dxs_out_off` slot into the outer
746 /// arena at `outer_dxs_off + t * per_step_bytes`. dcarry threading
747 /// is identical — we still need it for the body_vjp recurrence
748 /// even though we don't write it back to the outer arena.
749 ScanBackwardXs {
750 body_vjp: Arc<ThunkSchedule>,
751 body_init: Arc<Vec<u8>>,
752 body_carry_in_off: usize,
753 body_x_offs: Arc<Vec<usize>>,
754 body_d_output_off: usize,
755 body_dcarry_out_off: usize,
756 body_dxs_out_off: usize, // the body_vjp output we extract per step
757 outer_init_off: usize,
758 outer_traj_off: usize,
759 outer_upstream_off: usize,
760 outer_xs_offs: Arc<Vec<(usize, u32)>>,
761 outer_dxs_off: usize, // base of the stacked [length, *per_step] output
762 length: u32,
763 carry_bytes: u32,
764 /// Same role as `Thunk::ScanBackward::carry_elem_size`.
765 carry_elem_size: u32,
766 per_step_bytes: u32, // bytes per row of the dxs output
767 save_trajectory: bool,
768 /// Recursive checkpointing config. Same semantics as
769 /// `Thunk::ScanBackward::num_checkpoints` — `0` or `length`
770 /// means "save every step's carry"; `0 < K < length` means
771 /// the trajectory has only K rows and the executor recomputes
772 /// intermediate carries via `forward_body` (which must be
773 /// `Some`). Implemented via segment-cached recompute,
774 /// mirroring the `ScanBackward` path.
775 num_checkpoints: u32,
776 forward_body: Option<Arc<ThunkSchedule>>,
777 forward_body_init: Option<Arc<Vec<u8>>>,
778 forward_body_carry_in_off: usize,
779 forward_body_output_off: usize,
780 forward_body_x_offs: Arc<Vec<usize>>,
781 },
782 /// User-defined sub-graph (`Op::CustomFn`) — runs `fwd_body` once.
783 /// Per execution: clone `body_init`, copy each primal input from the
784 /// outer arena into its body Input slot, run the body schedule,
785 /// copy the body's single output back to the outer arena.
786 CustomFn {
787 body: Arc<ThunkSchedule>,
788 body_init: Arc<Vec<u8>>,
789 /// Per primal input: (body_input_off, outer_input_off, bytes).
790 inputs: Arc<Vec<(usize, usize, u32)>>,
791 body_output_off: usize,
792 outer_output_off: usize,
793 out_bytes: u32,
794 },
795 /// C = A @ B; C += bias; C = act(C)
796 FusedMmBiasAct {
797 a: usize,
798 w: usize,
799 bias: usize,
800 c: usize,
801 m: u32,
802 k: u32,
803 n: u32,
804 act: Option<Activation>,
805 },
806 /// out = LN(x + residual + bias, gamma, beta)
807 FusedResidualLN {
808 x: usize,
809 res: usize,
810 bias: usize,
811 g: usize,
812 b: usize,
813 out: usize,
814 rows: u32,
815 h: u32,
816 eps: f32,
817 has_bias: bool,
818 },
819 /// out = RmsNorm(x + residual + bias, gamma, beta)
820 FusedResidualRmsNorm {
821 x: usize,
822 res: usize,
823 bias: usize,
824 g: usize,
825 b: usize,
826 out: usize,
827 rows: u32,
828 h: u32,
829 eps: f32,
830 has_bias: bool,
831 },
832 /// out = bias_add(data, bias, m, n) for Binary::Add with broadcast
833 BiasAdd {
834 src: usize,
835 bias: usize,
836 dst: usize,
837 m: u32,
838 n: u32,
839 },
840 /// Element-wise binary op with NumPy-style broadcast.
841 ///
842 /// Fast path (`lhs_len == rhs_len == len`): plain element-wise loop,
843 /// SIMD-vectorized on aarch64 for `Add`/`Mul`. `bcast_*` fields
844 /// are unused.
845 ///
846 /// Broadcast path: uses `out_dims_bcast` + `bcast_lhs_strides` +
847 /// `bcast_rhs_strides` to compute per-cell indices into each
848 /// operand. The strides are precomputed at thunk-construction
849 /// time from the operands' true shapes (with stride 0 on any axis
850 /// where the operand has size 1). This is the only correct way
851 /// to handle bidirectional broadcasts like `[N, 1] op [1, S]
852 /// → [N, S]`, which simple `i % lhs_len` modulo indexing maps to
853 /// wrong cells.
854 BinaryFull {
855 lhs: usize,
856 rhs: usize,
857 dst: usize,
858 len: u32,
859 lhs_len: u32,
860 rhs_len: u32,
861 op: BinaryOp,
862 /// Output shape dims (row-major). Empty in the fast path.
863 out_dims_bcast: Vec<u32>,
864 /// Per-dim stride into `lhs` (0 where lhs broadcasts).
865 bcast_lhs_strides: Vec<u32>,
866 /// Per-dim stride into `rhs`.
867 bcast_rhs_strides: Vec<u32>,
868 /// Element size (4 = F32, 8 = I64).
869 elem_bytes: u8,
870 },
871 /// Activation in-place
872 ActivationInPlace {
873 data: usize,
874 len: u32,
875 act: Activation,
876 },
877 /// Gather axis=0: table\[idx\] → out
878 Gather {
879 table: usize,
880 table_len: u32,
881 idx: usize,
882 dst: usize,
883 num_idx: u32,
884 trailing: u32,
885 /// 1 when the index tensor is i64 (ONNX Gather indices).
886 idx_i64: u8,
887 /// Element size of table/output (4 = f32, 8 = i64).
888 table_bytes: u8,
889 },
890 /// Narrow: copy slice (`elem_bytes` = source element size: 4 for f32, 8 for f64).
891 Narrow {
892 src: usize,
893 dst: usize,
894 outer: u32,
895 src_stride: u32,
896 dst_stride: u32,
897 inner: u32,
898 elem_bytes: u8,
899 },
900 /// Reverse (flip) element order along the axes in `rev_mask`. `dims` is the
901 /// row-major input shape; output shape is identical. Dtype-agnostic
902 /// (byte-copy of `elem_bytes` per element).
903 Reverse {
904 src: usize,
905 dst: usize,
906 dims: Vec<u32>,
907 rev_mask: Vec<bool>,
908 elem_bytes: u8,
909 },
910 /// Copy (reshape, expand)
911 Copy {
912 src: usize,
913 dst: usize,
914 len: u32,
915 },
916 /// Round f32 values to half precision **in f32 storage**.
917 ///
918 /// A half-typed *activation* gets a 4-byte slot holding f32
919 /// (`plan_memory_native_in_order` widens them because the thunks compute
920 /// in f32); only half *params* stay packed at 2 B. So a `Cast` to BF16/F16
921 /// whose destination is an activation must round the value and keep the
922 /// f32 layout — writing packed halves into a widened slot reads back as
923 /// pairs of halves and corrupts everything downstream.
924 RoundHalf {
925 src: usize,
926 dst: usize,
927 len: u32,
928 /// `true` = bf16, `false` = IEEE f16.
929 bf16: bool,
930 },
931 /// LayerNorm standalone
932 LayerNorm {
933 src: usize,
934 g: usize,
935 b: usize,
936 dst: usize,
937 rows: u32,
938 h: u32,
939 eps: f32,
940 },
941 /// GroupNorm on NCHW `[N,C,H,W]`.
942 GroupNorm {
943 src: usize,
944 g: usize,
945 b: usize,
946 dst: usize,
947 n: u32,
948 c: u32,
949 h: u32,
950 w: u32,
951 num_groups: u32,
952 eps: f32,
953 },
954 /// BatchNorm inference: frozen mean/var, feature axis last.
955 BatchNormInference {
956 src: usize,
957 g: usize,
958 b: usize,
959 mean: usize,
960 var: usize,
961 dst: usize,
962 count: u32,
963 channels: u32,
964 eps: f32,
965 },
966 BatchNormInferenceBackwardInput {
967 x: usize,
968 gamma: usize,
969 mean: usize,
970 var: usize,
971 dy: usize,
972 dx: usize,
973 count: u32,
974 channels: u32,
975 eps: f32,
976 },
977 BatchNormInferenceBackwardGamma {
978 x: usize,
979 mean: usize,
980 var: usize,
981 dy: usize,
982 dgamma: usize,
983 count: u32,
984 channels: u32,
985 eps: f32,
986 },
987 BatchNormInferenceBackwardBeta {
988 dy: usize,
989 dbeta: usize,
990 count: u32,
991 channels: u32,
992 },
993 /// LayerNorm2d on NCHW (SAM / candle semantics).
994 LayerNorm2d {
995 src: usize,
996 g: usize,
997 b: usize,
998 dst: usize,
999 n: u32,
1000 c: u32,
1001 h: u32,
1002 w: u32,
1003 eps: f32,
1004 },
1005 /// ConvTranspose2d on NCHW.
1006 ConvTranspose2d {
1007 src: usize,
1008 weight: usize,
1009 dst: usize,
1010 n: u32,
1011 c_in: u32,
1012 h: u32,
1013 w_in: u32,
1014 c_out: u32,
1015 h_out: u32,
1016 w_out: u32,
1017 kh: u32,
1018 kw: u32,
1019 sh: u32,
1020 sw: u32,
1021 ph: u32,
1022 pw: u32,
1023 dh: u32,
1024 dw: u32,
1025 groups: u32,
1026 },
1027 /// 3D convolution on NCDHW. Input [N, C_in, D, H, W], weight
1028 /// [C_out, C_in/groups, kD, kH, kW], output [N, C_out, D_out, H_out, W_out].
1029 /// Naive direct convolution; the depth-axis analogue of `Thunk::Conv2D`.
1030 Conv3d {
1031 src: usize,
1032 weight: usize,
1033 dst: usize,
1034 n: u32,
1035 c_in: u32,
1036 d: u32,
1037 h: u32,
1038 w: u32,
1039 c_out: u32,
1040 d_out: u32,
1041 h_out: u32,
1042 w_out: u32,
1043 kd: u32,
1044 kh: u32,
1045 kw: u32,
1046 sd: u32,
1047 sh: u32,
1048 sw: u32,
1049 pd: u32,
1050 ph: u32,
1051 pw: u32,
1052 dd: u32,
1053 dh: u32,
1054 dw: u32,
1055 groups: u32,
1056 },
1057 /// ConvTranspose3d on NCDHW. Weight [C_in, C_out/groups, kD, kH, kW].
1058 /// `output_padding` is already folded into `d_out/h_out/w_out`.
1059 ConvTranspose3d {
1060 src: usize,
1061 weight: usize,
1062 dst: usize,
1063 n: u32,
1064 c_in: u32,
1065 d: u32,
1066 h: u32,
1067 w_in: u32,
1068 c_out: u32,
1069 d_out: u32,
1070 h_out: u32,
1071 w_out: u32,
1072 kd: u32,
1073 kh: u32,
1074 kw: u32,
1075 sd: u32,
1076 sh: u32,
1077 sw: u32,
1078 pd: u32,
1079 ph: u32,
1080 pw: u32,
1081 dd: u32,
1082 dh: u32,
1083 dw: u32,
1084 groups: u32,
1085 },
1086 /// Nearest 2× upsample on NCHW (per-batch slice).
1087 ResizeNearest2x {
1088 src: usize,
1089 dst: usize,
1090 n: u32,
1091 c: u32,
1092 h: u32,
1093 w: u32,
1094 },
1095 /// Nearest NCDHW resample to an explicit spatial size.
1096 Interpolate3d {
1097 src: usize,
1098 dst: usize,
1099 n: u32,
1100 c: u32,
1101 d_in: u32,
1102 h_in: u32,
1103 w_in: u32,
1104 d_out: u32,
1105 h_out: u32,
1106 w_out: u32,
1107 },
1108 /// SAM2 axial 2-D RoPE on `[batch, seq, num_heads * head_dim]`.
1109 AxialRope2d {
1110 src: usize,
1111 dst: usize,
1112 batch: u32,
1113 seq: u32,
1114 hidden: u32,
1115 end_x: u32,
1116 end_y: u32,
1117 head_dim: u32,
1118 num_heads: u32,
1119 theta: f32,
1120 repeat_factor: u32,
1121 },
1122 /// RMSNorm: out = (x / sqrt(mean(x^2) + eps)) * gamma + beta. No mean
1123 /// subtraction, hence cheaper than LayerNorm. Used by Llama-class models.
1124 RmsNorm {
1125 src: usize,
1126 g: usize,
1127 b: usize,
1128 dst: usize,
1129 rows: u32,
1130 h: u32,
1131 eps: f32,
1132 },
1133 /// DiT adaLN-Zero modulation: `out = norm(x)·(1+scale)+shift`, one pass —
1134 /// mean/var (or RMS) and the per-conditioning affine fused, the pre-norm
1135 /// activation never materialized. `scale`/`shift` share the modulation
1136 /// shape (`[B,1,D]`); `mod_off[row]` is the element offset into each for
1137 /// output `row` (the built-in broadcast that avoids an `Op::Expand`).
1138 AdaLayerNorm {
1139 src: usize,
1140 scale: usize,
1141 shift: usize,
1142 dst: usize,
1143 rows: u32,
1144 h: u32,
1145 eps: f32,
1146 /// `true` ⇒ LayerNorm (mean+var); `false` ⇒ RMSNorm (rms only).
1147 layer_norm: bool,
1148 /// Element count of the `scale`/`shift` buffers (they share a shape).
1149 mod_len: u32,
1150 /// Per-row element offset into `scale`/`shift` (broadcast map).
1151 mod_off: Vec<u32>,
1152 },
1153 /// DiT gated residual: `out = x + gate·y`. `gate` carries the modulation
1154 /// shape (`[B,1,D]`); `gate_off[row]` is the element offset into `gate` for
1155 /// output `row`.
1156 GatedResidual {
1157 x: usize,
1158 y: usize,
1159 gate: usize,
1160 dst: usize,
1161 rows: u32,
1162 h: u32,
1163 /// Element count of the `gate` buffer.
1164 gate_len: u32,
1165 /// Per-row element offset into `gate` (broadcast map).
1166 gate_off: Vec<u32>,
1167 },
1168 /// Packed `[dx ∥ dscale ∥ dshift]` for [`Op::AdaLayerNormBackward`].
1169 AdaLayerNormBackward {
1170 x: usize,
1171 scale: usize,
1172 shift: usize,
1173 dy: usize,
1174 dst: usize,
1175 rows: u32,
1176 h: u32,
1177 eps: f32,
1178 layer_norm: bool,
1179 mod_len: u32,
1180 mod_off: Vec<u32>,
1181 },
1182 /// Packed `[dx ∥ dy ∥ dgate]` for [`Op::GatedResidualBackward`].
1183 GatedResidualBackward {
1184 x: usize,
1185 y: usize,
1186 gate: usize,
1187 dy: usize,
1188 dst: usize,
1189 rows: u32,
1190 h: u32,
1191 gate_len: u32,
1192 gate_off: Vec<u32>,
1193 },
1194 /// Softmax
1195 Softmax {
1196 data: usize,
1197 rows: u32,
1198 cols: u32,
1199 /// Distance between successive elements along the softmax axis.
1200 ///
1201 /// `1` for the usual last-axis softmax, where a row is contiguous.
1202 /// Anything else means the axis is *not* last — `[N,C,D,H,W]` with
1203 /// `axis=1` gives `inner = D·H·W` — and the contiguous kernels would
1204 /// silently normalise over the wrong elements.
1205 inner: u32,
1206 },
1207 /// Inclusive (or exclusive) cumulative sum along the last axis
1208 /// (callers pre-flatten higher-dim cumsums via reshape views).
1209 Cumsum {
1210 src: usize,
1211 dst: usize,
1212 rows: u32,
1213 cols: u32,
1214 exclusive: bool,
1215 /// Element dtype — the scan runs in f32 or i64 (integer position ids /
1216 /// ragged offsets, e.g. MOSS-TTS's `cumsum(attention_mask)-1` RoPE positions
1217 /// must NOT go through the f32 path, which reads the i64 buffer as garbage).
1218 dtype: rlx_ir::DType,
1219 },
1220 /// Inclusive (or exclusive) cumulative product / maximum along the last
1221 /// axis (f32). `is_max` selects max over product. Mirrors [`Thunk::Cumsum`]'s
1222 /// row/col flattening; the reference O(L) scan behind `Op::{CumProd, CumMax}`.
1223 CumScan {
1224 src: usize,
1225 dst: usize,
1226 rows: u32,
1227 cols: u32,
1228 exclusive: bool,
1229 is_max: bool,
1230 },
1231 /// Mamba-style selective scan (plan #15).
1232 /// Inputs: x, delta \[b,s,h\], a \[h,n\], b \[b,s,n\], c \[b,s,n\].
1233 /// Output: y \[b,s,h\]. State h carries through the seq.
1234 SelectiveScan {
1235 x: usize,
1236 delta: usize,
1237 a: usize,
1238 b: usize,
1239 c: usize,
1240 dst: usize,
1241 batch: u32,
1242 seq: u32,
1243 hidden: u32,
1244 state_size: u32,
1245 },
1246
1247 /// Gated DeltaNet linear-attention scan (Qwen3.5/3.6 trunk).
1248 /// Inputs: q, k, v `[b, s, h, n]`; g, beta `[b, s, h]`. Output:
1249 /// `[b, s, h, n]`. See `Op::GatedDeltaNet` for math.
1250 /// Fused backward of [`Thunk::GatedDeltaNet`]. `dst` is the packed
1251 /// gradient bundle described by `rlx_ir::GdnBackwardLayout`.
1252 GatedDeltaNetBackward {
1253 q: usize,
1254 k: usize,
1255 v: usize,
1256 g: usize,
1257 beta: usize,
1258 /// Initial `[b, h, n, n]` state; meaningful only when `carry_state`.
1259 state: usize,
1260 dy: usize,
1261 dst: usize,
1262 batch: u32,
1263 seq: u32,
1264 heads: u32,
1265 state_size: u32,
1266 gate_per_channel: bool,
1267 carry_state: bool,
1268 },
1269
1270 GatedDeltaNet {
1271 q: usize,
1272 k: usize,
1273 v: usize,
1274 g: usize,
1275 beta: usize,
1276 /// Arena offset of the initial `[b, h, n, n]` state (valid only when
1277 /// `carry_state`; may legitimately be 0). When carrying, the initial
1278 /// state is loaded from here and the final state written back in place.
1279 state: usize,
1280 dst: usize,
1281 batch: u32,
1282 seq: u32,
1283 heads: u32,
1284 state_size: u32,
1285 /// When true (Kimi-K3 KDA), `g` is `[b, s, h, n]` (per key channel).
1286 gate_per_channel: bool,
1287 /// When true, resume from / write back the external `state` (decode).
1288 /// An explicit flag — NOT `state != 0`, since offset 0 is a valid slot.
1289 carry_state: bool,
1290 },
1291
1292 /// Multi-layer (optionally bidirectional, optional carry) LSTM with
1293 /// packed weights. See `Op::Lstm`. `h0`/`c0` are valid only when
1294 /// `carry`; `dst` is `[b, s, D*h]`.
1295 Lstm {
1296 x: usize,
1297 w_ih: usize,
1298 w_hh: usize,
1299 bias: usize,
1300 h0: usize,
1301 c0: usize,
1302 dst: usize,
1303 batch: u32,
1304 seq: u32,
1305 input_size: u32,
1306 hidden: u32,
1307 num_layers: u32,
1308 bidirectional: bool,
1309 carry: bool,
1310 },
1311 /// GRU (gate order r, z, n) with separate `b_ih`/`b_hh`. See `Op::Gru`.
1312 Gru {
1313 x: usize,
1314 w_ih: usize,
1315 w_hh: usize,
1316 b_ih: usize,
1317 b_hh: usize,
1318 h0: usize,
1319 dst: usize,
1320 batch: u32,
1321 seq: u32,
1322 input_size: u32,
1323 hidden: u32,
1324 num_layers: u32,
1325 bidirectional: bool,
1326 carry: bool,
1327 },
1328 /// Elman RNN (`tanh`/`relu`), single merged bias. See `Op::Rnn`.
1329 Rnn {
1330 x: usize,
1331 w_ih: usize,
1332 w_hh: usize,
1333 bias: usize,
1334 h0: usize,
1335 dst: usize,
1336 batch: u32,
1337 seq: u32,
1338 input_size: u32,
1339 hidden: u32,
1340 num_layers: u32,
1341 bidirectional: bool,
1342 carry: bool,
1343 relu: bool,
1344 },
1345 /// Mamba-2 / SSD scalar-decay SSM scan. See `Op::Mamba2`.
1346 Mamba2 {
1347 x: usize,
1348 dt: usize,
1349 a: usize,
1350 b: usize,
1351 c: usize,
1352 dst: usize,
1353 batch: u32,
1354 seq: u32,
1355 heads: u32,
1356 head_dim: u32,
1357 state_size: u32,
1358 },
1359
1360 /// 1×1 conv fast path (plan #26). The general Conv2D thunk
1361 /// runs the textbook 7-deep loop; a 1×1 stride-1 padding-0
1362 /// groups-1 conv is mathematically a per-batch matmul, and
1363 /// dispatching it through BLAS is 3-10× faster than the
1364 /// scalar nest. Common case: ViT patch-projection follow-on,
1365 /// transformer "expert" reductions in some MoE designs.
1366 ///
1367 /// Per batch: weight `[c_out, c_in]` × input `[c_in, h*w]`
1368 /// = output `[c_out, h*w]`.
1369 Conv2D1x1 {
1370 src: usize,
1371 weight: usize,
1372 dst: usize,
1373 n: u32,
1374 c_in: u32,
1375 c_out: u32,
1376 hw: u32,
1377 },
1378
1379 /// Fused dequant + matmul (plan #5). Today supports
1380 /// `QuantScheme::Int8Block` (symmetric); other schemes panic
1381 /// at lowering time with a clear message until kernels are added.
1382 DequantMatMul {
1383 x: usize,
1384 w_q: usize, // packed i8 bytes for Int8 schemes
1385 scale: usize, // [k/block, n] f32 scale
1386 zp: usize, // [k/block, n] f32 zero-point (0 for sym)
1387 dst: usize,
1388 m: u32,
1389 k: u32,
1390 n: u32,
1391 block_size: u32,
1392 is_asymmetric: bool,
1393 },
1394
1395 /// Codebook weight-synthesis matmul (single-level vector quant).
1396 /// Weight is stored transposed `[n, k]`; each contiguous
1397 /// `entry_dim`-length sub-vector along `k` is replaced by a learned
1398 /// codebook centroid `codebook[index]`. The centroid is reconstructed
1399 /// inside the matmul inner loop — the weight is never materialized.
1400 SynthMatMul {
1401 x: usize, // [m, k] f32 activations
1402 indices: usize, // [n, k/entry_dim] u8 codebook indices
1403 codebook: usize, // [num_entries, entry_dim] f32 centroids
1404 dst: usize, // [m, n] f32 output
1405 m: u32,
1406 k: u32,
1407 n: u32,
1408 entry_dim: u32,
1409 num_entries: u32,
1410 },
1411
1412 /// KAN Gaussian-RBF spline activation (per-channel learned basis).
1413 /// Shape-preserving: `y[.., c] = Σ_g coeff[c,g]·exp(-((x−center_g)·inv_h)²)`.
1414 SplineActivation {
1415 x: usize, // [rows, channels] f32
1416 coeff: usize, // [channels, num_basis] f32
1417 dst: usize, // [rows, channels] f32
1418 rows: u32,
1419 channels: u32,
1420 num_basis: u32,
1421 grid_min: f32,
1422 grid_max: f32,
1423 },
1424
1425 /// GGUF-format dequant + matmul. Weight is a packed byte tensor
1426 /// in one of the K-quant super-block layouts (Q4_K, Q5_K, Q6_K,
1427 /// Q8_K). Scales / mins live inside the packed bytes — no
1428 /// side-channel scale tensor.
1429 ///
1430 /// Today this is a "dequant-to-scratch then sgemm" kernel — it
1431 /// keeps the *arena* memory footprint down (weights stay packed)
1432 /// but the dequant itself happens per matmul. A future fully
1433 /// fused tile-streaming kernel would close the compute gap.
1434 DequantMatMulGguf {
1435 x: usize, // f32 activations [m, k]
1436 w_q: usize, // packed weight bytes (k*n elements packed)
1437 dst: usize, // f32 output [m, n]
1438 m: u32,
1439 k: u32,
1440 n: u32,
1441 scheme: rlx_ir::quant::QuantScheme,
1442 },
1443
1444 /// Int4 block dequant + matmul (packed nibbles, side scale/zp).
1445 DequantMatMulInt4 {
1446 x: usize,
1447 w_q: usize,
1448 scale: usize,
1449 zp: usize,
1450 dst: usize,
1451 m: u32,
1452 k: u32,
1453 n: u32,
1454 block_size: u32,
1455 is_asymmetric: bool,
1456 },
1457
1458 /// FP8 dequant + matmul (per-tensor or per-column scale).
1459 DequantMatMulFp8 {
1460 x: usize,
1461 w_q: usize,
1462 scale: usize,
1463 dst: usize,
1464 m: u32,
1465 k: u32,
1466 n: u32,
1467 e5m2: bool,
1468 },
1469
1470 /// NVFP4 (E2M1) block dequant + matmul — 16-wide groups, FP8 scales.
1471 DequantMatMulNvfp4 {
1472 x: usize,
1473 w_q: usize,
1474 scale: usize,
1475 global_scale: usize,
1476 dst: usize,
1477 m: u32,
1478 k: u32,
1479 n: u32,
1480 },
1481
1482 /// MxFp4x2 two-level residual E2M1 DequantMatMul. `w_q` = `[plane0|plane1]`
1483 /// nibbles, `scale` = `[s0|s1]` f32 per (k/`group`, n) block.
1484 DequantMatMulMxFp4x2 {
1485 x: usize,
1486 w_q: usize,
1487 scale: usize,
1488 dst: usize,
1489 m: u32,
1490 k: u32,
1491 n: u32,
1492 group: u32,
1493 },
1494
1495 /// MLX affine / mxfp DequantMatMul — host dequant via `rlx-mlx-io`.
1496 DequantMatMulMlx {
1497 x: usize,
1498 w_q: usize,
1499 scale: usize,
1500 zp: usize,
1501 dst: usize,
1502 m: u32,
1503 k: u32,
1504 n: u32,
1505 scheme: rlx_ir::quant::QuantScheme,
1506 },
1507
1508 /// Native low-precision scaled GEMM (FP8/FP6/FP4) — CPU reference oracle.
1509 /// TN layout: lhs `m,k`, rhs `n,k` codes, out `m,n` f32.
1510 ScaledMatMul {
1511 lhs: usize,
1512 rhs: usize,
1513 lhs_scale: usize,
1514 rhs_scale: usize,
1515 bias: usize, // valid iff has_bias
1516 dst: usize,
1517 m: u32,
1518 k: u32,
1519 n: u32,
1520 lhs_fmt: rlx_ir::ScaledFormat,
1521 rhs_fmt: rlx_ir::ScaledFormat,
1522 layout: rlx_ir::ScaleLayout,
1523 has_bias: bool,
1524 },
1525
1526 /// Native low-precision *grouped* (MoE) TN GEMM — expert-indexed
1527 /// `ScaledMatMul`. `weight [E,n,k]` codes, per-expert scales/bias.
1528 ScaledGroupedMatMul {
1529 input: usize,
1530 weight: usize,
1531 input_scale: usize,
1532 weight_scale: usize,
1533 expert_idx: usize,
1534 bias: usize, // valid iff has_bias
1535 dst: usize,
1536 m: u32,
1537 k: u32,
1538 n: u32,
1539 num_experts: u32,
1540 lhs_fmt: rlx_ir::ScaledFormat,
1541 rhs_fmt: rlx_ir::ScaledFormat,
1542 layout: rlx_ir::ScaleLayout,
1543 has_bias: bool,
1544 },
1545
1546 /// Quantize f32 → packed low-precision codes (reads the scale tensor).
1547 ScaledQuantize {
1548 x: usize,
1549 scale: usize,
1550 dst: usize,
1551 rows: u32,
1552 cols: u32,
1553 fmt: rlx_ir::ScaledFormat,
1554 layout: rlx_ir::ScaleLayout,
1555 },
1556
1557 /// Compute the per-tensor / per-block scale tensor for `Op::ScaledQuantize`.
1558 ScaledQuantScale {
1559 x: usize,
1560 dst: usize,
1561 rows: u32,
1562 cols: u32,
1563 fmt: rlx_ir::ScaledFormat,
1564 layout: rlx_ir::ScaleLayout,
1565 },
1566
1567 /// Reconstruct f32 from packed codes (`Op::ScaledDequantize`).
1568 ScaledDequantize {
1569 codes: usize,
1570 scale: usize,
1571 dst: usize,
1572 rows: u32,
1573 cols: u32,
1574 fmt: rlx_ir::ScaledFormat,
1575 layout: rlx_ir::ScaleLayout,
1576 },
1577
1578 /// Fused LoRA matmul (plan #9): out = x·W + scale * (x·A)·B.
1579 /// `r` is the LoRA rank (typically 4-64) — the rank-r
1580 /// intermediate `x·A` lives in scratch, never on the arena.
1581 LoraMatMul {
1582 x: usize,
1583 w: usize,
1584 a: usize,
1585 b: usize,
1586 dst: usize,
1587 m: u32,
1588 k: u32,
1589 n: u32,
1590 r: u32,
1591 scale: f32,
1592 },
1593 /// Fused sample: logits [batch, vocab] → token ids \[batch\].
1594 /// See Op::Sample. Output values are f32-encoded usize indices
1595 /// (matches the rest of the IR's "ids as f32" convention).
1596 Sample {
1597 logits: usize,
1598 dst: usize,
1599 batch: u32,
1600 vocab: u32,
1601 top_k: u32, // 0 = disabled
1602 top_p: f32, // 1.0 = disabled
1603 temperature: f32, // 1.0 = neutral
1604 seed: u64,
1605 },
1606 /// ONNX `RandomNormalLike` fill.
1607 RngNormal {
1608 dst: usize,
1609 len: u32,
1610 mean: f32,
1611 scale: f32,
1612 key: u64,
1613 op_seed: Option<f32>,
1614 },
1615 /// ONNX `RandomUniformLike` fill.
1616 RngUniform {
1617 dst: usize,
1618 len: u32,
1619 low: f32,
1620 high: f32,
1621 key: u64,
1622 op_seed: Option<f32>,
1623 },
1624 /// Attention SDPA. `mask` is the offset of the optional mask tensor
1625 /// (only meaningful when `mask_kind == MaskKind::Custom`); other
1626 /// kinds synthesize the mask in-kernel.
1627 ///
1628 /// Q/K/V each carry a `_row_stride` (elements per source row).
1629 /// Defaults to `heads * head_dim` — matches the standalone
1630 /// "Q/K/V are their own contiguous buffers" case. The Narrow→
1631 /// Attention fusion below rewrites these to the parent QKV stride
1632 /// (typically `3 * heads * head_dim`) so the kernel reads QKV
1633 /// directly without materializing the per-head buffers (plan #46).
1634 Attention {
1635 q: usize,
1636 k: usize,
1637 v: usize,
1638 mask: usize,
1639 out: usize,
1640 batch: u32,
1641 /// Query sequence length.
1642 seq: u32,
1643 /// Key/value sequence length. Differs from `seq` during cached decode.
1644 kv_seq: u32,
1645 heads: u32,
1646 /// GQA/MQA: number of key/value heads that the query heads share.
1647 /// Equals `heads` for plain MHA (the common case); only `< heads` on
1648 /// the non-fused standalone `Op::Attention` path.
1649 kv_heads: u32,
1650 head_dim: u32,
1651 /// V/output per-head width. Equals `head_dim` for symmetric SDPA; only
1652 /// `!= head_dim` for MLA-style asymmetric attention (V read + output
1653 /// write use this; Q/K scores still use `head_dim`).
1654 v_head_dim: u32,
1655 mask_kind: rlx_ir::op::MaskKind,
1656 /// Softmax score scale (`Op::Attention::score_scale`). `head_dim^-0.5`
1657 /// when the op left it unset. Must be honored — Gemma 4 uses `1.0`
1658 /// (Q/K are per-head RMS-normed, so no `1/sqrt(d)` pre-scale).
1659 scale: f32,
1660 /// Attention logit soft-cap (`Op::Attention::attn_logit_softcap`).
1661 /// `cap*tanh(score/cap)` applied pre-softmax; 0 = disabled. Gemma 2
1662 /// uses 50.0 — must be honored to match HF / rlx-metal / rlx-cuda.
1663 softcap: f32,
1664 q_row_stride: u32,
1665 k_row_stride: u32,
1666 v_row_stride: u32,
1667 /// Memory layout flag. `false` (the historical default) →
1668 /// `[B, S, H, D]` row-major: per-head offset is
1669 /// `bi*S*H*D + si*H*D + hi*D`. `true` → `[B, H, S, D]`
1670 /// (head-major), matching the convention used by rlx-cuda /
1671 /// rlx-rocm / rlx-tpu: per-head offset is
1672 /// `bi*H*S*D + hi*S*D + si*D`. Detected at lowering time
1673 /// from the input shape vs `num_heads` / `head_dim`.
1674 bhsd: bool,
1675 },
1676 /// [`Op::AttentionBackward`] — emits dQ, dK, or dV (see `wrt`).
1677 AttentionBackward {
1678 q: usize,
1679 k: usize,
1680 v: usize,
1681 dy: usize,
1682 mask: usize,
1683 out: usize,
1684 batch: u32,
1685 seq: u32,
1686 kv_seq: u32,
1687 heads: u32,
1688 head_dim: u32,
1689 mask_kind: rlx_ir::op::MaskKind,
1690 wrt: rlx_ir::op::AttentionBwdWrt,
1691 bhsd: bool,
1692 },
1693 /// [`rlx_ir::Op::AttentionBackwardAll`] — computes dQ, dK and dV from ONE
1694 /// score+softmax recompute, writing them into three disjoint sub-ranges of
1695 /// this node's own packed `[3B,S,H,D]` output slot (`out_q`/`out_k`/`out_v`
1696 /// are byte offsets into the same slot, so the planner manages it as one
1697 /// buffer). Downstream axis-0 `Narrow`s (view ops) recover dQ/dK/dV.
1698 AttentionBackwardAll {
1699 q: usize,
1700 k: usize,
1701 v: usize,
1702 dy: usize,
1703 mask: usize,
1704 out_q: usize,
1705 out_k: usize,
1706 out_v: usize,
1707 batch: u32,
1708 seq: u32,
1709 kv_seq: u32,
1710 heads: u32,
1711 head_dim: u32,
1712 mask_kind: rlx_ir::op::MaskKind,
1713 bhsd: bool,
1714 },
1715 /// RoPE (rotary position embeddings).
1716 /// `src_row_stride` is elements per source row (defaults to `hidden`
1717 /// for the standalone case; set to `qkv_axis * inner` when the
1718 /// thunk fusion pass below rewires Rope to read directly from the
1719 /// fused QKV buffer — plan #45).
1720 Rope {
1721 src: usize,
1722 cos: usize,
1723 sin: usize,
1724 dst: usize,
1725 batch: u32,
1726 seq: u32,
1727 hidden: u32,
1728 head_dim: u32,
1729 n_rot: u32,
1730 cos_len: u32,
1731 /// Elements per position in the cos/sin tables — the table's own last
1732 /// dimension, NOT something derived from `head_dim` or `n_rot`.
1733 ///
1734 /// The two are not the same under partial rotation and the layout is a
1735 /// per-model choice: Qwen3.5 allocates `[max_pos, head_dim/2]` and uses
1736 /// the leading `n_rot/2` columns of each row, while DeepSeek-V4 MLA
1737 /// packs `[.., n_rot/2]` with no slack. Assuming either one reads the
1738 /// wrong row for every position past the first on the other, so take it
1739 /// from the shape instead of guessing.
1740 cos_row_stride: u32,
1741 src_row_stride: u32,
1742 /// `true` = GPT-J / llama.cpp-NORM interleaved pairs `(2i, 2i+1)`;
1743 /// `false` = HF / NeoX rotate-half pairs `(i, i+n_rot/2)`.
1744 interleaved: bool,
1745 },
1746 /// Fused attention block: QKV proj → split → \[RoPE\] → SDPA → output proj.
1747 /// All intermediates stay in L1 cache. Zero arena writes between ops.
1748 FusedAttnBlock {
1749 hidden: usize,
1750 qkv_w: usize,
1751 out_w: usize,
1752 mask: usize,
1753 /// How to mask attention scores. `Custom` reads the per-key `mask`
1754 /// buffer (BERT-style padding); `Causal` / `SlidingWindow` are
1755 /// synthesized in-kernel with no buffer; `None` applies no mask.
1756 /// Mirrors [`Thunk::Attention`]'s `mask_kind` — the attention-block
1757 /// fusion MUST carry it through, otherwise a causal decoder silently
1758 /// attends to future tokens (the fused kernel would only ever apply
1759 /// the per-key padding mask).
1760 mask_kind: rlx_ir::op::MaskKind,
1761 out: usize,
1762 qkv_b: usize,
1763 out_b: usize, // 0 = no bias
1764 cos: usize,
1765 sin: usize,
1766 cos_len: u32, // 0 = no RoPE
1767 batch: u32,
1768 seq: u32,
1769 hs: u32,
1770 nh: u32,
1771 dh: u32,
1772 /// QKV-projection input width (`in_dim`) and output-projection output
1773 /// width (`out_dim`). Both equal `hs` in the BERT case; MoonViT-style
1774 /// attention has `in_dim != hs` (input hidden ≠ nh·dh) and/or
1775 /// `out_dim != hs`. The kernel reads the input as `m·in_dim`, runs the
1776 /// QKV GEMM with K=in_dim, the out GEMM with N=out_dim, and writes
1777 /// `m·out_dim`; the attention core still works on the `hs`-wide qkv.
1778 in_dim: u32,
1779 out_dim: u32,
1780 has_bias: bool,
1781 has_rope: bool,
1782 /// RoPE pairing: `true` = GPT-J interleaved `(2i,2i+1)`, `false` = NeoX
1783 /// rotate-half `(i,i+d/2)`. Captured from the fused `Op::Rope` so the
1784 /// inline rope matches the standalone kernel (a GptJ model must not be
1785 /// silently rotated NeoX-style by the fused path).
1786 interleaved: bool,
1787 },
1788 /// Fused ENTIRE transformer layer: attention + residual + LN + FFN + residual + LN.
1789 /// Combines ~10 thunks into 1. All intermediates on stack. Zero arena traffic.
1790 FusedBertLayer {
1791 // attention
1792 hidden: usize,
1793 qkv_w: usize,
1794 qkv_b: usize,
1795 out_w: usize,
1796 out_b: usize,
1797 mask: usize,
1798 // LN1
1799 ln1_g: usize,
1800 ln1_b: usize,
1801 eps1: f32,
1802 // FFN (GELU)
1803 fc1_w: usize,
1804 fc1_b: usize,
1805 fc2_w: usize,
1806 fc2_b: usize,
1807 // LN2
1808 ln2_g: usize,
1809 ln2_b: usize,
1810 eps2: f32,
1811 // output
1812 out: usize,
1813 // dims
1814 batch: u32,
1815 seq: u32,
1816 hs: u32,
1817 nh: u32,
1818 dh: u32,
1819 int_dim: u32,
1820 },
1821 /// Fused Nomic transformer layer: attention+RoPE + residual + LN + SwiGLU FFN + residual + LN.
1822 FusedNomicLayer {
1823 hidden: usize,
1824 qkv_w: usize,
1825 out_w: usize,
1826 mask: usize,
1827 cos: usize,
1828 sin: usize,
1829 cos_len: u32,
1830 ln1_g: usize,
1831 ln1_b: usize,
1832 eps1: f32,
1833 fc11_w: usize,
1834 fc12_w: usize,
1835 fc2_w: usize,
1836 ln2_g: usize,
1837 ln2_b: usize,
1838 eps2: f32,
1839 out: usize,
1840 batch: u32,
1841 seq: u32,
1842 hs: u32,
1843 nh: u32,
1844 dh: u32,
1845 int_dim: u32,
1846 /// RoPE pairing, threaded from the consumed `FusedAttnBlock`
1847 /// (`true` = GPT-J interleaved, `false` = NeoX rotate-half).
1848 interleaved: bool,
1849 },
1850 /// Fused SwiGLU: out\[r,i\] = x\[r,i\] * silu(x[r, n_half+i]).
1851 /// Input: [outer, 2*n_half] — concatenated up||gate per row.
1852 /// Output: [outer, n_half].
1853 FusedSwiGLU {
1854 src: usize,
1855 dst: usize,
1856 n_half: u32,
1857 total: u32,
1858 gate_first: bool,
1859 },
1860 /// In-place KV-cache append: write one row into `dst` at sequence index
1861 /// `pos`. `dst` is the cache buffer (the op's output aliases it), so this
1862 /// touches `outer * inner` elements regardless of how long the context is,
1863 /// where the `concat(past, row)` it replaces re-copies the whole cache
1864 /// every step.
1865 ///
1866 /// Layout mirrors the GPU backends' thunk of the same name: byte-slice
1867 /// `o` of the row lands at `o * seq_cap * inner_bytes + pos * inner_bytes`.
1868 /// `outer == 1` (the usual `[1, seq, heads*dim]` cache with `axis = 1`)
1869 /// makes it a single contiguous `memcpy`.
1870 ///
1871 /// Sized in BYTES, not elements, and copied as raw bytes: an append moves
1872 /// data without touching its values, so it needs no dtype-specific variant
1873 /// the way `Concat`/`ConcatF64` do — and a `memcpy` beats an element loop.
1874 KvAppend {
1875 src: usize,
1876 dst: usize,
1877 outer: u32,
1878 seq_cap: u32,
1879 pos: u32,
1880 inner_bytes: u32,
1881 },
1882 /// Concat along an axis: output[outer, axis, inner] = inputs concatenated.
1883 /// Each entry of `inputs` is (src_offset, axis_len_for_that_input) in u32
1884 /// elements. `outer`, `inner`, and `total_axis_len` are pre-computed
1885 /// at compile time to avoid per-run shape work.
1886 Concat {
1887 dst: usize,
1888 outer: u32,
1889 inner: u32,
1890 total_axis: u32,
1891 /// `(src_offset, axis_extent, input_numel)` — `input_numel` enables
1892 /// outer-dim broadcast when rank-deficient inputs are concatenated.
1893 inputs: Vec<(usize, u32, u32)>,
1894 },
1895 /// Element-wise comparison: out = (lhs CMP rhs) ? 1 : 0 (Bool u8 or F32 0/1).
1896 /// `lhs_scalar` / `rhs_scalar` broadcast a 1-element operand across `len`
1897 /// (ONNX Less/Greater with a scalar Constant).
1898 Compare {
1899 lhs: usize,
1900 rhs: usize,
1901 dst: usize,
1902 len: u32,
1903 op: CmpOp,
1904 /// Nonzero when lhs/rhs are i64 (mask/range ops).
1905 inputs_i64: u8,
1906 /// Input element size (1 = Bool, 4 = F32, 8 = I64).
1907 inputs_elem_bytes: u8,
1908 /// Output element size (1 = Bool, 4 = F32).
1909 dst_elem_bytes: u8,
1910 lhs_scalar: bool,
1911 rhs_scalar: bool,
1912 /// TRUE element counts of each operand. Needed when an operand is a
1913 /// *partial* broadcast (`[.,L,1]` into `[.,L,L]`): its numel is neither
1914 /// 1 (scalar) nor `len` (full), so the flat fast path would read past
1915 /// it into adjacent arena memory.
1916 lhs_len: u32,
1917 rhs_len: u32,
1918 /// Shape-aware broadcast metadata (mirrors `BinaryFull`). Empty
1919 /// `out_dims_bcast` selects the flat scalar/full fast path; when
1920 /// non-empty, each output element indexes its operands via row-major
1921 /// coords · strides — the ONLY correct path for a trailing/partial
1922 /// size-1 broadcast, which the flat path silently miscomputes.
1923 out_dims_bcast: Vec<u32>,
1924 bcast_lhs_strides: Vec<u32>,
1925 bcast_rhs_strides: Vec<u32>,
1926 },
1927 /// Reduction along a contiguous range of axes. Input layout (after
1928 /// shape decomposition) is `[outer, reduced, inner]`; output is
1929 /// `[outer, inner]`. The single-axis cases (axis=0 → outer=1;
1930 /// axis=last → inner=1) and contiguous multi-axis (e.g. reduce over
1931 /// [0, 1] of an [N, C, H, W] tensor → outer=1, reduced=N*C, inner=H*W)
1932 /// all map onto this triplet. Non-contiguous axes are not supported
1933 /// and bail to Nop in the compile pass.
1934 Reduce {
1935 src: usize,
1936 dst: usize,
1937 outer: u32,
1938 reduced: u32,
1939 inner: u32,
1940 op: ReduceOp,
1941 },
1942 /// Histogram: bucket `n` input elements into `bins` equal-width buckets over
1943 /// `[min, max]` and write f32 counts into `dst[bins]`. Out-of-range elements
1944 /// are dropped; `x == max` lands in the last bin. See `Op::Histogram`.
1945 Histogram {
1946 src: usize,
1947 dst: usize,
1948 n: u32,
1949 bins: u32,
1950 min: f32,
1951 max: f32,
1952 },
1953 /// Index of the max (`is_max`) or min along the reduced axis; writes the
1954 /// winning index as an f32 into `dst`.
1955 ArgReduce {
1956 src: usize,
1957 dst: usize,
1958 outer: u32,
1959 reduced: u32,
1960 inner: u32,
1961 is_max: bool,
1962 },
1963 /// Top-K **indices** along the last axis. Input shape `[outer, axis_dim]`,
1964 /// output `[outer, k]` (f32 or i64 per `indices_i64`). Ties broken by
1965 /// smaller index. Used by MoE gating + beam search.
1966 TopK {
1967 src: usize,
1968 dst: usize,
1969 outer: u32,
1970 axis_dim: u32,
1971 k: u32,
1972 indices_i64: u8,
1973 },
1974 /// Indexed batched matmul: out\[i\] = input\[i\] @ weight[expert_idx\[i\]].
1975 /// Naive impl per token; for real MoE workloads, sort-by-expert + run
1976 /// segmented GEMM would amortize. Done when there's a workload.
1977 GroupedMatMul {
1978 input: usize,
1979 weight: usize,
1980 expert_idx: usize,
1981 dst: usize,
1982 m: u32,
1983 k_dim: u32,
1984 n: u32,
1985 num_experts: u32,
1986 /// `weight` holds `[E, N, K]` rather than `[E, K, N]`, so each expert's
1987 /// GEMM runs with B transposed instead of materializing the swap.
1988 ///
1989 /// Set when the compiler folds a last-two-axis `Transpose` feeding this
1990 /// node. See `compile_dispatch`'s fold pass for why it matters: GGUF
1991 /// stores expert banks as `[E, out, in]` and this op wants
1992 /// `[E, in, out]`, so a dense MoE layer transposes every bank on every
1993 /// forward — measured as ~99% of a paged GLM-5.3-Flash layer.
1994 w_transposed: bool,
1995 },
1996 /// GGUF K-quant packed expert stack + grouped matmul (MoE FFN).
1997 DequantGroupedMatMulGguf {
1998 input: usize,
1999 w_q: usize,
2000 expert_idx: usize,
2001 dst: usize,
2002 m: u32,
2003 k_dim: u32,
2004 n: u32,
2005 num_experts: u32,
2006 scheme: rlx_ir::quant::QuantScheme,
2007 },
2008 /// MLX-affine grouped expert matmul: per row, dequant expert `idx[row]`'s
2009 /// affine slab (codes+scales+biases) and matmul. `[M,K]×dequant([N,K])→[M,N]`.
2010 DequantGroupedMatMulMlx {
2011 input: usize,
2012 w_q: usize,
2013 scale: usize,
2014 zp: usize,
2015 expert_idx: usize,
2016 dst: usize,
2017 m: u32,
2018 k_dim: u32,
2019 n: u32,
2020 num_experts: u32,
2021 /// Packed-code bytes per expert (`total_w_q_bytes / num_experts`).
2022 slab_bytes: u32,
2023 scheme: rlx_ir::quant::QuantScheme,
2024 /// Scales/biases stored as BF16 (2 bytes) instead of F32 — halves the
2025 /// resident scale memory (the MoE scale slabs rival the packed codes at
2026 /// 2-bit). Decoded to f32 per-expert on the fly.
2027 scale_bf16: bool,
2028 },
2029 /// Materialize packed MoE weights to F32 `[E, K, N]` (autodiff helper).
2030 DequantMoEWeightsGguf {
2031 w_q: usize,
2032 dst: usize,
2033 k_dim: u32,
2034 n: u32,
2035 num_experts: u32,
2036 scheme: rlx_ir::quant::QuantScheme,
2037 },
2038 /// Scatter-add: dst[indices\[i\] * trailing + j] += updates[i * trailing + j].
2039 /// Output is zeroed first; multiple updates to the same row accumulate.
2040 ScatterAdd {
2041 updates: usize,
2042 indices: usize,
2043 dst: usize,
2044 num_updates: u32,
2045 out_dim: u32,
2046 trailing: u32,
2047 },
2048 /// ONNX ScatterND: copy `data`, write `updates` at multi-index locations.
2049 /// Inputs `[data, indices, updates]`; `indices_i64` selects I64 vs F32 index storage.
2050 ScatterNd {
2051 data: usize,
2052 indices: usize,
2053 updates: usize,
2054 dst: usize,
2055 data_shape: Vec<u32>,
2056 indices_shape: Vec<u32>,
2057 data_len: u32,
2058 updates_len: u32,
2059 indices_len: u32,
2060 indices_i64: u8,
2061 reduction: rlx_ir::ScatterNdReduction,
2062 },
2063 /// ONNX ScatterElements along `axis`.
2064 ScatterElements {
2065 data: usize,
2066 indices: usize,
2067 updates: usize,
2068 dst: usize,
2069 data_shape: Vec<u32>,
2070 /// Shape of `indices`. ONNX lets it be *smaller* than `data` along any
2071 /// axis, and then the flat position of an index decomposes by the
2072 /// indices' strides, not the data's — without this the kernel has to
2073 /// guess and gets every row but the first wrong.
2074 indices_shape: Vec<u32>,
2075 data_len: u32,
2076 updates_len: u32,
2077 indices_len: u32,
2078 indices_i64: u8,
2079 axis: i32,
2080 reduction: rlx_ir::ScatterNdReduction,
2081 },
2082 /// ONNX GatherND.
2083 GatherNd {
2084 data: usize,
2085 indices: usize,
2086 dst: usize,
2087 data_shape: Vec<u32>,
2088 indices_shape: Vec<u32>,
2089 data_len: u32,
2090 indices_len: u32,
2091 out_len: u32,
2092 indices_i64: u8,
2093 batch_dims: i32,
2094 },
2095 /// ONNX GatherElements / take_along_axis.
2096 GatherElements {
2097 data: usize,
2098 indices: usize,
2099 dst: usize,
2100 data_shape: Vec<u32>,
2101 indices_shape: Vec<u32>,
2102 data_len: u32,
2103 indices_len: u32,
2104 out_len: u32,
2105 indices_i64: u8,
2106 /// Byte width of a data/output element (GatherElements preserves dtype).
2107 /// Non-f32 data (e.g. int64 token ids) must be gathered at its true width.
2108 data_elem_bytes: u8,
2109 axis: i32,
2110 },
2111 /// Ternary select: out = cond != 0 ? on_true : on_false.
2112 /// `*_scalar` broadcast a 1-element operand across `len`.
2113 Where {
2114 cond: usize,
2115 on_true: usize,
2116 on_false: usize,
2117 dst: usize,
2118 len: u32,
2119 elem_bytes: u8,
2120 /// Element size for cond (1 = Bool mask, 4 = F32 0/1).
2121 cond_elem_bytes: u8,
2122 cond_scalar: bool,
2123 true_scalar: bool,
2124 false_scalar: bool,
2125 },
2126 /// Single-rounded fused multiply-add: `dst = a*b + c` (one rounding —
2127 /// enables error-free transforms / compensated arithmetic).
2128 Fma {
2129 a: usize,
2130 b: usize,
2131 c: usize,
2132 dst: usize,
2133 len: u32,
2134 elem_bytes: u8,
2135 },
2136 /// General N-D transpose / broadcast. `out_dims[i]` is the output's dim
2137 /// i length; `in_strides[i]` is the input stride (in elements) used to
2138 /// index that dim — 0 for broadcast dims (Expand). `in_total` is the
2139 /// total element count in the source buffer (≤ output total when
2140 /// broadcasting). Strides are pre-computed at compile time.
2141 Transpose {
2142 src: usize,
2143 dst: usize,
2144 in_total: u32,
2145 out_dims: Vec<u32>,
2146 in_strides: Vec<u32>,
2147 elem_bytes: u8,
2148 },
2149 /// Gather along an arbitrary axis. `outer = product(dims[..axis])`,
2150 /// `trailing = product(dims[axis+1..])`, `axis_dim` = the dimension
2151 /// being indexed into. Output: outer × num_idx × trailing.
2152 /// (axis=0 still routes to the simpler Thunk::Gather fast path.)
2153 GatherAxis {
2154 table: usize,
2155 idx: usize,
2156 dst: usize,
2157 outer: u32,
2158 axis_dim: u32,
2159 num_idx: u32,
2160 trailing: u32,
2161 idx_i64: u8,
2162 table_bytes: u8,
2163 },
2164 /// 2D pooling (Max or Mean). Input layout [N, C, H, W], output
2165 /// [N, C, H_out, W_out]. Padding is implicit-zero; Mean divides by
2166 /// the full kernel area (matches torch's `count_include_pad=True`).
2167 Pool2D {
2168 src: usize,
2169 dst: usize,
2170 n: u32,
2171 c: u32,
2172 h: u32,
2173 w: u32,
2174 h_out: u32,
2175 w_out: u32,
2176 kh: u32,
2177 kw: u32,
2178 sh: u32,
2179 sw: u32,
2180 ph: u32,
2181 pw: u32,
2182 kind: ReduceOp,
2183 },
2184 /// 3D pooling (Max or Mean). Input `[N, C, D, H, W]`, output
2185 /// `[N, C, D_out, H_out, W_out]`. Mean divides by the full kernel volume
2186 /// (`count_include_pad=True`).
2187 Pool3D {
2188 src: usize,
2189 dst: usize,
2190 n: u32,
2191 c: u32,
2192 d: u32,
2193 h: u32,
2194 w: u32,
2195 d_out: u32,
2196 h_out: u32,
2197 w_out: u32,
2198 kd: u32,
2199 kh: u32,
2200 kw: u32,
2201 sd: u32,
2202 sh: u32,
2203 sw: u32,
2204 pd: u32,
2205 ph: u32,
2206 pw: u32,
2207 kind: ReduceOp,
2208 },
2209 /// 2D convolution. Input [N, C_in, H, W], weight [C_out, C_in_per_group, kH, kW],
2210 /// output [N, C_out, H_out, W_out]. Bias is a separate Op::Binary::Add
2211 /// after the conv (matching the IR's input layout — Op::Conv has 2 inputs).
2212 /// Naive direct convolution; sufficient for correctness, not optimised.
2213 Conv2D {
2214 src: usize,
2215 weight: usize,
2216 dst: usize,
2217 n: u32,
2218 c_in: u32,
2219 h: u32,
2220 w: u32,
2221 c_out: u32,
2222 h_out: u32,
2223 w_out: u32,
2224 kh: u32,
2225 kw: u32,
2226 sh: u32,
2227 sw: u32,
2228 ph: u32,
2229 pw: u32,
2230 dh: u32,
2231 dw: u32,
2232 groups: u32,
2233 },
2234
2235 // ── Backward / training kernels ─────────────────────────────
2236 /// Real INT8 matmul with i32 accumulation.
2237 /// `out[m, n] = requantize(bias[n] + Σₖ (x[m,k]-x_zp)·(w[k,n]-w_zp), mult, out_zp)`
2238 /// Reads `x` and `w` as i8, `bias` as i32; writes `out` as i8.
2239 /// Same kernel shape as `rlx_cortexm::dense::dense_i8` — promoted
2240 /// to a desktop thunk so a quantized graph compiled here doesn't
2241 /// have to round-trip through fake-quant.
2242 QMatMul {
2243 x: usize,
2244 w: usize,
2245 bias: usize,
2246 out: usize,
2247 m: u32,
2248 k: u32,
2249 n: u32,
2250 x_zp: i32,
2251 w_zp: i32,
2252 out_zp: i32,
2253 mult: f32,
2254 },
2255
2256 /// Real INT8 conv2d, NCHW layout. Same loop shape as `Thunk::Conv2D`
2257 /// but with i8 reads, i32 accumulation, and per-output requantize
2258 /// to i8. Bias is i32 in the accumulator scale.
2259 QConv2d {
2260 x: usize,
2261 w: usize,
2262 bias: usize,
2263 out: usize,
2264 n: u32,
2265 c_in: u32,
2266 h: u32,
2267 w_in: u32,
2268 c_out: u32,
2269 h_out: u32,
2270 w_out: u32,
2271 kh: u32,
2272 kw: u32,
2273 sh: u32,
2274 sw: u32,
2275 ph: u32,
2276 pw: u32,
2277 dh: u32,
2278 dw: u32,
2279 groups: u32,
2280 x_zp: i32,
2281 w_zp: i32,
2282 out_zp: i32,
2283 mult: f32,
2284 },
2285
2286 /// INT8 quantize. Reads `x` as f32, writes `q` as i8.
2287 /// `chan = (i / inner) % chan_dim` selects the per-channel
2288 /// scale/zp; `chan_axis` is informational only (the kernel uses
2289 /// `chan_dim` and `inner` directly).
2290 /// For per-tensor, `chan_dim = 1` and `inner = len` so `chan` is
2291 /// always 0.
2292 Quantize {
2293 x: usize,
2294 q: usize,
2295 len: u32,
2296 chan_axis: u32,
2297 chan_dim: u32,
2298 inner: u32,
2299 scales: Vec<f32>,
2300 zero_points: Vec<i32>,
2301 },
2302
2303 /// INT8 dequantize — inverse of `Thunk::Quantize`.
2304 Dequantize {
2305 q: usize,
2306 x: usize,
2307 len: u32,
2308 chan_axis: u32,
2309 chan_dim: u32,
2310 inner: u32,
2311 scales: Vec<f32>,
2312 zero_points: Vec<i32>,
2313 },
2314
2315 /// QAT fake-quantize. Per-channel (or per-tensor) symmetric
2316 /// quantize-then-dequantize on the fly. Computes
2317 /// `s[c] = max(|x[..., c, ...]|) / q_max`
2318 /// then
2319 /// `out[i] = clamp(round(x[i]/s[c]), -q_max, q_max) * s[c]`
2320 /// with `q_max = {127, 7, 1}` for `bits = {8, 4, 2}`. Same
2321 /// channel-layout convention as `Thunk::Quantize`: every
2322 /// element's channel is `(i / inner) % chan_dim`. The kernel
2323 /// does two passes — one to scan max-abs per channel, one to
2324 /// quant-dequant per element.
2325 FakeQuantize {
2326 x: usize,
2327 out: usize,
2328 len: u32,
2329 chan_axis: u32,
2330 chan_dim: u32,
2331 inner: u32,
2332 bits: u8,
2333 /// STE variant — informational on the forward side (output is
2334 /// the same regardless), kernel-relevant in the matching
2335 /// `FakeQuantizeBackward` thunk.
2336 ste: rlx_ir::op::SteKind,
2337 /// Scale-tracking strategy. `PerBatch` recomputes
2338 /// `max_abs/q_max` every call (the original path). `EMA{decay}`
2339 /// blends per-batch max-abs into the `state_off` buffer; `Fixed`
2340 /// reads `state_off` and never updates it.
2341 scale_mode: rlx_ir::op::ScaleMode,
2342 /// `Some(off)` for `EMA` and `Fixed`; `None` for `PerBatch`.
2343 /// Points at a `[chan_dim]` f32 buffer holding the running scale
2344 /// per channel.
2345 state_off: Option<usize>,
2346 },
2347
2348 /// Backward pass for `Op::FakeQuantize` under one of four STE
2349 /// variants. Computes `dx[i]` from the f32 forward input `x` and
2350 /// the upstream gradient `dy`, using the same per-channel scale
2351 /// scheme as the forward.
2352 FakeQuantizeBackward {
2353 x: usize,
2354 dy: usize,
2355 dx: usize,
2356 len: u32,
2357 chan_axis: u32,
2358 chan_dim: u32,
2359 inner: u32,
2360 bits: u8,
2361 ste: rlx_ir::op::SteKind,
2362 },
2363
2364 /// LSQ forward — same kernel shape as `FakeQuantize` Fixed mode.
2365 /// Reads scale from `scale_off` (a `[chan_dim]` Param tensor).
2366 FakeQuantizeLSQ {
2367 x: usize,
2368 scale_off: usize,
2369 out: usize,
2370 len: u32,
2371 chan_axis: u32,
2372 chan_dim: u32,
2373 inner: u32,
2374 bits: u8,
2375 },
2376
2377 /// LSQ backward, x-gradient. STE-clipped: passes upstream
2378 /// through inside the quantization range, zeros outside.
2379 FakeQuantizeLSQBackwardX {
2380 x: usize,
2381 scale_off: usize,
2382 dy: usize,
2383 dx: usize,
2384 len: u32,
2385 chan_axis: u32,
2386 chan_dim: u32,
2387 inner: u32,
2388 bits: u8,
2389 },
2390
2391 /// LSQ backward, scale-gradient. Per-channel:
2392 /// `dscale[c] = sum_i ψ(x[i]/s[c]) · upstream[i]`
2393 /// where `ψ(z) = -z + round(z)` if `|z| ≤ q_max` else
2394 /// `sign(z) · q_max`. Output shape: `[chan_dim]`.
2395 FakeQuantizeLSQBackwardScale {
2396 x: usize,
2397 scale_off: usize,
2398 dy: usize,
2399 dscale: usize,
2400 len: u32,
2401 chan_axis: u32,
2402 chan_dim: u32,
2403 inner: u32,
2404 bits: u8,
2405 },
2406
2407 /// ReLU backward: `dx[i] = dy[i] if x[i] > 0 else 0`.
2408 ReluBackward {
2409 x: usize,
2410 dy: usize,
2411 dx: usize,
2412 len: u32,
2413 },
2414 /// f64 sibling of `ReluBackward` — same shape as the f32 variant
2415 /// but reads/writes 8 bytes per element. Required because
2416 /// `ReluBackward`'s `&[f32]` slot view returns half of every f64
2417 /// otherwise → backward silently produces 0 gradients on an f64
2418 /// graph. Mirrors the `ActivationBackwardF64` split.
2419 ReluBackwardF64 {
2420 x: usize,
2421 dy: usize,
2422 dx: usize,
2423 len: u32,
2424 },
2425
2426 /// Generic element-wise activation backward.
2427 /// `dx[i] = (d/dx act(x))[i] · dy[i]`. The closure dispatch is
2428 /// per-element; expensive activations (Gelu) recompute internals
2429 /// inline rather than threading an extra "saved y" tensor through.
2430 ActivationBackward {
2431 x: usize,
2432 dy: usize,
2433 dx: usize,
2434 len: u32,
2435 kind: Activation,
2436 },
2437 /// f64 sibling of `ActivationBackward` — slot offsets, len in
2438 /// elements; kernel reads/writes 8 bytes per element. Required
2439 /// because `ActivationBackward`'s `&[f32]` slot view silently
2440 /// returns garbage on an f64 graph (cb % 4 still works but every
2441 /// loaded value is half of an f64 → wrong gradient).
2442 ActivationBackwardF64 {
2443 x: usize,
2444 dy: usize,
2445 dx: usize,
2446 len: u32,
2447 kind: Activation,
2448 },
2449
2450 /// LayerNorm backward — input gradient. Recomputes mean/var/x̂ from
2451 /// `x` and emits the closed-form `d_x` per row.
2452 LayerNormBackwardInput {
2453 x: usize,
2454 gamma: usize,
2455 dy: usize,
2456 dx: usize,
2457 rows: u32,
2458 h: u32,
2459 eps: f32,
2460 },
2461
2462 /// LayerNorm backward — gamma gradient. `d_gamma[d] = Σ_row dy·x̂`.
2463 LayerNormBackwardGamma {
2464 x: usize,
2465 dy: usize,
2466 dgamma: usize,
2467 rows: u32,
2468 h: u32,
2469 eps: f32,
2470 },
2471
2472 RmsNormBackwardInput {
2473 x: usize,
2474 gamma: usize,
2475 beta: usize,
2476 dy: usize,
2477 dx: usize,
2478 rows: u32,
2479 h: u32,
2480 eps: f32,
2481 },
2482 RmsNormBackwardGamma {
2483 x: usize,
2484 gamma: usize,
2485 beta: usize,
2486 dy: usize,
2487 dgamma: usize,
2488 rows: u32,
2489 h: u32,
2490 eps: f32,
2491 },
2492 RmsNormBackwardBeta {
2493 x: usize,
2494 gamma: usize,
2495 beta: usize,
2496 dy: usize,
2497 dbeta: usize,
2498 rows: u32,
2499 h: u32,
2500 eps: f32,
2501 },
2502 RopeBackward {
2503 dy: usize,
2504 cos: usize,
2505 sin: usize,
2506 dx: usize,
2507 batch: u32,
2508 seq: u32,
2509 hidden: u32,
2510 head_dim: u32,
2511 n_rot: u32,
2512 cos_len: u32,
2513 /// Elements per position in the cos/sin tables — same meaning, and the
2514 /// same reason, as [`Thunk::Rope::cos_row_stride`].
2515 ///
2516 /// The backward must read the table with the *identical* stride to the
2517 /// forward or the adjoint is not the transpose of the forward: it picks
2518 /// up a different position's angles for every row past the first. The
2519 /// forward gained this field while the backward kept deriving the
2520 /// stride as `n_rot/2`, which the finite-difference check caught (the
2521 /// cross-backend parity tests could not — every backend that shared the
2522 /// wrong assumption agreed with itself).
2523 cos_row_stride: u32,
2524 /// GptJ pairing: rotate adjacent lanes `(2i, 2i+1)` rather than NeoX's
2525 /// `(i, i + n_rot/2)`. Mirrors [`Thunk::Rope::interleaved`] — and for
2526 /// the same reason as `cos_row_stride` above: the forward carried the
2527 /// distinction, the backward did not, so the adjoint stopped being the
2528 /// transpose of the forward for every GptJ (GGUF) rotation.
2529 interleaved: bool,
2530 },
2531 CumsumBackward {
2532 dy: usize,
2533 dx: usize,
2534 rows: u32,
2535 cols: u32,
2536 exclusive: bool,
2537 },
2538 GatherBackward {
2539 dy: usize,
2540 indices: usize,
2541 dst: usize,
2542 outer: u32,
2543 axis_dim: u32,
2544 num_idx: u32,
2545 trailing: u32,
2546 },
2547
2548 GroupNormBackwardInput {
2549 x: usize,
2550 gamma: usize,
2551 beta: usize,
2552 dy: usize,
2553 dx: usize,
2554 n: u32,
2555 c: u32,
2556 h: u32,
2557 w: u32,
2558 num_groups: u32,
2559 eps: f32,
2560 },
2561 GroupNormBackwardGamma {
2562 x: usize,
2563 dy: usize,
2564 dgamma: usize,
2565 n: u32,
2566 c: u32,
2567 h: u32,
2568 w: u32,
2569 num_groups: u32,
2570 eps: f32,
2571 },
2572 GroupNormBackwardBeta {
2573 dy: usize,
2574 dbeta: usize,
2575 n: u32,
2576 c: u32,
2577 h: u32,
2578 w: u32,
2579 },
2580
2581 /// 2D max-pool backward (NCHW). Recomputes the argmax position
2582 /// inside each window and accumulates `dy` into `dx` at that
2583 /// position. Output is zeroed first; ties resolve to the first
2584 /// hit (lowest (kh,kw) index), matching what the forward kernel
2585 /// does with `acc.max(v)`.
2586 MaxPool2dBackward {
2587 x: usize,
2588 dy: usize,
2589 dx: usize,
2590 n: u32,
2591 c: u32,
2592 h: u32,
2593 w: u32,
2594 h_out: u32,
2595 w_out: u32,
2596 kh: u32,
2597 kw: u32,
2598 sh: u32,
2599 sw: u32,
2600 ph: u32,
2601 pw: u32,
2602 },
2603
2604 /// 2D conv backward w.r.t. input (`dx = conv_transpose(dy, w)`).
2605 /// `dy [N, C_out, H_out, W_out]`, `w [C_out, C_in_per_group, kH, kW]`,
2606 /// `dx [N, C_in, H, W]`.
2607 Conv2dBackwardInput {
2608 dy: usize,
2609 w: usize,
2610 dx: usize,
2611 n: u32,
2612 c_in: u32,
2613 h: u32,
2614 w_in: u32,
2615 c_out: u32,
2616 h_out: u32,
2617 w_out: u32,
2618 kh: u32,
2619 kw: u32,
2620 sh: u32,
2621 sw: u32,
2622 ph: u32,
2623 pw: u32,
2624 dh: u32,
2625 dw: u32,
2626 groups: u32,
2627 },
2628
2629 /// 2D conv backward w.r.t. weight. `x [N, C_in, H, W]`,
2630 /// `dy [N, C_out, H_out, W_out]`, `dw [C_out, C_in_per_group, kH, kW]`.
2631 /// `dw` is zeroed before accumulation.
2632 Conv2dBackwardWeight {
2633 x: usize,
2634 dy: usize,
2635 dw: usize,
2636 n: u32,
2637 c_in: u32,
2638 h: u32,
2639 w: u32,
2640 c_out: u32,
2641 h_out: u32,
2642 w_out: u32,
2643 kh: u32,
2644 kw: u32,
2645 sh: u32,
2646 sw: u32,
2647 ph: u32,
2648 pw: u32,
2649 dh: u32,
2650 dw_dil: u32,
2651 groups: u32,
2652 },
2653
2654 MaxPool3dBackward {
2655 x: usize,
2656 dy: usize,
2657 dx: usize,
2658 n: u32,
2659 c: u32,
2660 d: u32,
2661 h: u32,
2662 w: u32,
2663 d_out: u32,
2664 h_out: u32,
2665 w_out: u32,
2666 kd: u32,
2667 kh: u32,
2668 kw: u32,
2669 sd: u32,
2670 sh: u32,
2671 sw: u32,
2672 pd: u32,
2673 ph: u32,
2674 pw: u32,
2675 },
2676
2677 Conv3dBackwardInput {
2678 dy: usize,
2679 w: usize,
2680 dx: usize,
2681 n: u32,
2682 c_in: u32,
2683 d: u32,
2684 h: u32,
2685 w_in: u32,
2686 c_out: u32,
2687 d_out: u32,
2688 h_out: u32,
2689 w_out: u32,
2690 kd: u32,
2691 kh: u32,
2692 kw: u32,
2693 sd: u32,
2694 sh: u32,
2695 sw: u32,
2696 pd: u32,
2697 ph: u32,
2698 pw: u32,
2699 dd: u32,
2700 dh: u32,
2701 dw: u32,
2702 groups: u32,
2703 },
2704
2705 Conv3dBackwardWeight {
2706 x: usize,
2707 dy: usize,
2708 dw: usize,
2709 n: u32,
2710 c_in: u32,
2711 d: u32,
2712 h: u32,
2713 w: u32,
2714 c_out: u32,
2715 d_out: u32,
2716 h_out: u32,
2717 w_out: u32,
2718 kd: u32,
2719 kh: u32,
2720 kw: u32,
2721 sd: u32,
2722 sh: u32,
2723 sw: u32,
2724 pd: u32,
2725 ph: u32,
2726 pw: u32,
2727 dd: u32,
2728 dh: u32,
2729 dw_dil: u32,
2730 groups: u32,
2731 },
2732
2733 /// NCHW im2col for conv backward-weight matmul. Output `[M, C·kH·kW]`
2734 /// with `M = N · H_out · W_out`. `n == 0` means infer batch from `x`.
2735 Im2Col {
2736 x: usize,
2737 col: usize,
2738 n: u32,
2739 c_in: u32,
2740 h: u32,
2741 w: u32,
2742 h_out: u32,
2743 w_out: u32,
2744 kh: u32,
2745 kw: u32,
2746 sh: u32,
2747 sw: u32,
2748 ph: u32,
2749 pw: u32,
2750 dh: u32,
2751 dw_dil: u32,
2752 },
2753
2754 /// Fused softmax + cross-entropy loss against a dense target
2755 /// distribution. `logits [N, C]`, `targets [N, C]`, output `[N]`
2756 /// per-row loss `lse(logits[n]) - Σ_c targets[n,c]·logits[n,c]`.
2757 /// Numerically stable (max-subtract before exp).
2758 SoftmaxCrossEntropyDense {
2759 logits: usize,
2760 targets: usize,
2761 dst: usize,
2762 n: u32,
2763 c: u32,
2764 },
2765
2766 /// Fused softmax + cross-entropy loss with f32-encoded integer
2767 /// labels. `logits [N, C]`, `labels [N]`, output `[N]` per-row loss.
2768 /// Numerically stable (max-subtract before exp).
2769 SoftmaxCrossEntropy {
2770 logits: usize,
2771 labels: usize,
2772 dst: usize,
2773 n: u32,
2774 c: u32,
2775 },
2776
2777 /// Backward of the fused loss above.
2778 /// `dlogits[n, k] = (softmax(logits[n])[k] - one_hot(labels[n])[k]) * d_loss[n]`.
2779 SoftmaxCrossEntropyBackward {
2780 logits: usize,
2781 labels: usize,
2782 d_loss: usize,
2783 dlogits: usize,
2784 n: u32,
2785 c: u32,
2786 },
2787
2788 /// User-registered custom op (CPU side). Lowered from `Op::Custom`.
2789 /// `kernel` is resolved against the global CPU kernel registry at
2790 /// compile time and stored as `Arc<dyn CpuKernel>` so execution
2791 /// avoids per-call lookups. v1: f32 contiguous only — see
2792 /// `op_registry::CpuKernel::execute_f32`.
2793 CustomOp {
2794 kernel: Arc<dyn CpuKernel>,
2795 inputs: Vec<(usize, u32, Shape)>, // (offset, len_elements, shape)
2796 output: (usize, u32, Shape), // (offset, len_elements, shape)
2797 attrs: Vec<u8>,
2798 },
2799
2800 /// 1D FFT along the last axis. Input/output are `[..., 2N]`
2801 /// real-block layout (first N real, second N imag along the
2802 /// transformed axis). `outer` is the product of all leading axes;
2803 /// `n_complex` is N (the number of complex points). Both halves
2804 /// of the real-block layout are read together by the kernel.
2805 /// `dtype` selects the f32 or f64 path; the two share structure
2806 /// but not buffers, so a flag at compile time avoids per-row
2807 /// dispatch.
2808 /// CPU reference 3D Gaussian splat render ([`rlx_ir::Op::GaussianSplatRender`]).
2809 GaussianSplatRender {
2810 positions_off: usize,
2811 positions_len: usize,
2812 scales_off: usize,
2813 scales_len: usize,
2814 rotations_off: usize,
2815 rotations_len: usize,
2816 opacities_off: usize,
2817 opacities_len: usize,
2818 colors_off: usize,
2819 colors_len: usize,
2820 sh_coeffs_off: usize,
2821 sh_coeffs_len: usize,
2822 meta_off: usize,
2823 dst_off: usize,
2824 dst_len: usize,
2825 width: u32,
2826 height: u32,
2827 tile_size: u32,
2828 radius_scale: f32,
2829 alpha_cutoff: f32,
2830 max_splat_steps: u32,
2831 transmittance_threshold: f32,
2832 max_list_entries: u32,
2833 },
2834 GaussianSplatRenderBackward {
2835 positions_off: usize,
2836 positions_len: usize,
2837 scales_off: usize,
2838 scales_len: usize,
2839 rotations_off: usize,
2840 rotations_len: usize,
2841 opacities_off: usize,
2842 opacities_len: usize,
2843 colors_off: usize,
2844 colors_len: usize,
2845 sh_coeffs_off: usize,
2846 sh_coeffs_len: usize,
2847 meta_off: usize,
2848 d_loss_off: usize,
2849 d_loss_len: usize,
2850 packed_off: usize,
2851 packed_len: usize,
2852 width: u32,
2853 height: u32,
2854 tile_size: u32,
2855 radius_scale: f32,
2856 alpha_cutoff: f32,
2857 max_splat_steps: u32,
2858 transmittance_threshold: f32,
2859 max_list_entries: u32,
2860 loss_grad_clip: f32,
2861 sh_band: u32,
2862 max_anisotropy: f32,
2863 },
2864 /// Strict IR stage 1 — project + bin + sort + rays ([`Op::GaussianSplatPrepare`]).
2865 GaussianSplatPrepare {
2866 positions_off: usize,
2867 positions_len: usize,
2868 scales_off: usize,
2869 scales_len: usize,
2870 rotations_off: usize,
2871 rotations_len: usize,
2872 opacities_off: usize,
2873 opacities_len: usize,
2874 colors_off: usize,
2875 colors_len: usize,
2876 sh_coeffs_off: usize,
2877 sh_coeffs_len: usize,
2878 meta_off: usize,
2879 meta_len: usize,
2880 prep_off: usize,
2881 prep_len: usize,
2882 width: u32,
2883 height: u32,
2884 tile_size: u32,
2885 radius_scale: f32,
2886 alpha_cutoff: f32,
2887 max_splat_steps: u32,
2888 transmittance_threshold: f32,
2889 max_list_entries: u32,
2890 },
2891 /// Strict IR stage 2 — tile raster from prepare buffer ([`Op::GaussianSplatRasterize`]).
2892 GaussianSplatRasterize {
2893 prep_off: usize,
2894 prep_len: usize,
2895 meta_off: usize,
2896 meta_len: usize,
2897 dst_off: usize,
2898 dst_len: usize,
2899 count: usize,
2900 width: u32,
2901 height: u32,
2902 tile_size: u32,
2903 alpha_cutoff: f32,
2904 max_splat_steps: u32,
2905 transmittance_threshold: f32,
2906 max_list_entries: u32,
2907 },
2908 Fft1d {
2909 src: usize,
2910 dst: usize,
2911 outer: u32,
2912 n_complex: u32,
2913 inverse: bool,
2914 norm_tag: u32,
2915 dtype: rlx_ir::DType,
2916 },
2917 /// Fixed-point 1D FFT — see `rlx_ir::Op::FftQ`.
2918 Fft1dQ {
2919 src: usize,
2920 dst: usize,
2921 outer: u32,
2922 n_complex: u32,
2923 inverse: bool,
2924 norm_tag: u32,
2925 scale_tag: u32,
2926 },
2927 FftButterflyStage {
2928 state_src: usize,
2929 state_dst: usize,
2930 gate_src: usize,
2931 rev_src: usize,
2932 tw_re_src: usize,
2933 tw_im_src: usize,
2934 batch: u32,
2935 n_fft: u32,
2936 stage: u32,
2937 },
2938 LogMel {
2939 spec: usize,
2940 filters: usize,
2941 dst: usize,
2942 outer: u32,
2943 n_fft: u32,
2944 n_bins: u32,
2945 n_mels: u32,
2946 },
2947 LogMelBackward {
2948 spec: usize,
2949 filters: usize,
2950 dy: usize,
2951 dst: usize,
2952 outer: u32,
2953 n_fft: u32,
2954 n_bins: u32,
2955 n_mels: u32,
2956 },
2957 WelchPeaks {
2958 spec: usize,
2959 dst: usize,
2960 welch_batch: u32,
2961 n_fft: u32,
2962 n_segments: u32,
2963 k: u32,
2964 },
2965}
2966
2967/// Compiled thunk schedule — the runtime hot path.
2968/// Nop thunks are filtered out at compile time for zero iteration overhead.
2969#[derive(Clone)]
2970pub struct ThunkSchedule {
2971 pub thunks: Vec<Thunk>,
2972 /// TIDE merged placement mask (union across layers).
2973 pub moe_resident: Option<std::sync::Arc<[bool]>>,
2974 /// Per MoE layer placement (`layer[e]`); preferred when set.
2975 pub moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
2976 /// MoE router TopK capture (per-layer refresh).
2977 pub moe_topk_capture: Option<std::sync::Arc<crate::moe_topk_capture::MoeTopkCapture>>,
2978 /// Cached config values.
2979 pub mask_threshold: f32,
2980 pub mask_neg_inf: f32,
2981 pub score_skip: f32,
2982 /// Pre-compiled closure dispatch (zero match overhead). `Arc` (not
2983 /// `Box`) so the schedule can be `Clone` — multiple parallel
2984 /// executors share the same compiled closures (they're read-only
2985 /// `Fn(*mut u8)` so concurrent dispatch is safe; the arena pointer
2986 /// they receive is the only mutable state and is per-executor).
2987 pub compiled_fns: Vec<Arc<dyn Fn(*mut u8) + Send + Sync>>,
2988 /// Runtime-mutable RNG policy for [`Thunk::RngNormal`] / [`Thunk::RngUniform`].
2989 pub rng: Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
2990}
2991
2992impl ThunkSchedule {
2993 pub fn strip_nops(&mut self) {
2994 self.thunks.retain(|t| !matches!(t, Thunk::Nop));
2995 // compiled_fns must be rebuilt after stripping — caller should
2996 // call strip_nops() before compile_closures().
2997 self.compiled_fns.clear();
2998 }
2999}
3000
3001/// Get the arena byte offset for a node.
3002pub(crate) fn node_offset(arena: &Arena, id: NodeId) -> usize {
3003 if arena.has_buffer(id) {
3004 arena.byte_offset(id)
3005 } else {
3006 usize::MAX
3007 }
3008}
3009
3010/// Every byte-offset that a thunk reads from. Used by the Narrow→Rope
3011/// fusion (#45) to verify a Narrow's dst has exactly one consumer
3012/// before eliding it. Conservative: when in doubt about reads (an op
3013/// not yet listed here), the fusion will skip — correctness over
3014/// completeness.
3015pub(crate) fn thunk_read_offsets(t: &Thunk) -> Vec<usize> {
3016 match t {
3017 Thunk::Sgemm { a, b, .. } => vec![*a, *b],
3018 Thunk::SgemmBf16 { a, b, .. } => vec![*a, *b],
3019 Thunk::SgemmF16 { a, b, .. } => vec![*a, *b],
3020 Thunk::SgemmT { a, b, .. } => vec![*a, *b],
3021 Thunk::SgdMomentum {
3022 param, vel, grad, ..
3023 } => vec![*param, *vel, *grad],
3024 Thunk::DenseSolveF64 { a, b, .. } => vec![*a, *b],
3025 Thunk::DenseSolveF32 { a, b, .. } => vec![*a, *b],
3026 Thunk::BatchedDenseSolveF64 { a, b, .. } => vec![*a, *b],
3027 Thunk::BatchedDgemmF64 { a, b, .. } => vec![*a, *b],
3028 Thunk::BatchedSgemm { a, b, .. } => vec![*a, *b],
3029 Thunk::FusedMmBiasAct { a, w, bias, .. } => vec![*a, *w, *bias],
3030 Thunk::ElementwiseRegion { input_offs, .. } => input_offs.clone(),
3031 Thunk::BiasAdd { src, bias, .. } => vec![*src, *bias],
3032 Thunk::BinaryFull { lhs, rhs, .. } => vec![*lhs, *rhs],
3033 Thunk::BinaryFullF64 { lhs, rhs, .. } => vec![*lhs, *rhs],
3034 Thunk::BinaryFullC64 { lhs, rhs, .. } => vec![*lhs, *rhs],
3035 Thunk::ComplexNormSqF32 { src, .. } => vec![*src],
3036 Thunk::ComplexNormSqBackwardF32 { z, g, .. } => vec![*z, *g],
3037 Thunk::ConjugateC64 { src, .. } => vec![*src],
3038 Thunk::Scan {
3039 outer_init_off,
3040 xs_inputs,
3041 ..
3042 } => {
3043 let mut v = vec![*outer_init_off];
3044 for (_, outer_xs_off, _) in xs_inputs.iter() {
3045 v.push(*outer_xs_off);
3046 }
3047 v
3048 }
3049 Thunk::ScanBackward {
3050 outer_init_off,
3051 outer_traj_off,
3052 outer_upstream_off,
3053 outer_xs_offs,
3054 ..
3055 } => {
3056 let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
3057 for (off, _) in outer_xs_offs.iter() {
3058 v.push(*off);
3059 }
3060 v
3061 }
3062 Thunk::ScanBackwardXs {
3063 outer_init_off,
3064 outer_traj_off,
3065 outer_upstream_off,
3066 outer_xs_offs,
3067 ..
3068 } => {
3069 let mut v = vec![*outer_init_off, *outer_traj_off, *outer_upstream_off];
3070 for (off, _) in outer_xs_offs.iter() {
3071 v.push(*off);
3072 }
3073 v
3074 }
3075 Thunk::CustomFn { inputs, .. } => {
3076 inputs.iter().map(|(_, outer_off, _)| *outer_off).collect()
3077 }
3078 Thunk::ActivationInPlace { data, .. } => vec![*data],
3079 Thunk::LayerNorm { src, g, b, .. } | Thunk::GroupNorm { src, g, b, .. } => {
3080 vec![*src, *g, *b]
3081 }
3082 Thunk::BatchNormInference {
3083 src,
3084 g,
3085 b,
3086 mean,
3087 var,
3088 ..
3089 } => vec![*src, *g, *b, *mean, *var],
3090 Thunk::ResizeNearest2x { src, .. } => vec![*src],
3091 Thunk::Interpolate3d { src, .. } => vec![*src],
3092 Thunk::AxialRope2d { src, .. } => vec![*src],
3093 Thunk::FusedResidualLN {
3094 x, res, bias, g, b, ..
3095 } => vec![*x, *res, *bias, *g, *b],
3096 Thunk::FusedResidualRmsNorm {
3097 x, res, bias, g, b, ..
3098 } => vec![*x, *res, *bias, *g, *b],
3099 Thunk::RmsNorm { src, g, b, .. } => vec![*src, *g, *b],
3100 Thunk::AdaLayerNorm {
3101 src, scale, shift, ..
3102 } => vec![*src, *scale, *shift],
3103 Thunk::GatedResidual { x, y, gate, .. } => vec![*x, *y, *gate],
3104 Thunk::AdaLayerNormBackward {
3105 x,
3106 scale,
3107 shift,
3108 dy,
3109 ..
3110 } => vec![*x, *scale, *shift, *dy],
3111 Thunk::GatedResidualBackward { x, y, gate, dy, .. } => vec![*x, *y, *gate, *dy],
3112 Thunk::Softmax { data, .. } => vec![*data],
3113 Thunk::Cumsum { src, .. } => vec![*src],
3114 Thunk::CumScan { src, .. } => vec![*src],
3115 Thunk::Sample { logits, .. } => vec![*logits],
3116 Thunk::RngNormal { .. } | Thunk::RngUniform { .. } => vec![],
3117 Thunk::LoraMatMul { x, w, a, b, .. } => vec![*x, *w, *a, *b],
3118 Thunk::DequantMatMul {
3119 x, w_q, scale, zp, ..
3120 } => vec![*x, *w_q, *scale, *zp],
3121 Thunk::SynthMatMul {
3122 x,
3123 indices,
3124 codebook,
3125 ..
3126 } => vec![*x, *indices, *codebook],
3127 Thunk::SplineActivation { x, coeff, .. } => vec![*x, *coeff],
3128 Thunk::DequantMatMulGguf { x, w_q, .. } => vec![*x, *w_q],
3129 Thunk::DequantMatMulInt4 {
3130 x, w_q, scale, zp, ..
3131 } => vec![*x, *w_q, *scale, *zp],
3132 Thunk::DequantMatMulFp8 { x, w_q, scale, .. } => vec![*x, *w_q, *scale],
3133 Thunk::DequantMatMulNvfp4 {
3134 x,
3135 w_q,
3136 scale,
3137 global_scale,
3138 ..
3139 } => vec![*x, *w_q, *scale, *global_scale],
3140 Thunk::DequantMatMulMxFp4x2 { x, w_q, scale, .. } => vec![*x, *w_q, *scale],
3141 Thunk::DequantMatMulMlx {
3142 x, w_q, scale, zp, ..
3143 } => vec![*x, *w_q, *scale, *zp],
3144 Thunk::ScaledMatMul {
3145 lhs,
3146 rhs,
3147 lhs_scale,
3148 rhs_scale,
3149 bias,
3150 has_bias,
3151 ..
3152 } => {
3153 let mut v = vec![*lhs, *rhs, *lhs_scale, *rhs_scale];
3154 if *has_bias {
3155 v.push(*bias);
3156 }
3157 v
3158 }
3159 Thunk::ScaledQuantize { x, scale, .. } => vec![*x, *scale],
3160 Thunk::ScaledQuantScale { x, .. } => vec![*x],
3161 Thunk::ScaledDequantize { codes, scale, .. } => vec![*codes, *scale],
3162 Thunk::Conv2D1x1 { src, weight, .. } => vec![*src, *weight],
3163 Thunk::SelectiveScan {
3164 x, delta, a, b, c, ..
3165 } => vec![*x, *delta, *a, *b, *c],
3166 Thunk::GatedDeltaNet {
3167 q,
3168 k,
3169 v,
3170 g,
3171 beta,
3172 state,
3173 carry_state,
3174 ..
3175 } => {
3176 let mut v = vec![*q, *k, *v, *g, *beta];
3177 if *carry_state {
3178 v.push(*state);
3179 }
3180 v
3181 }
3182 Thunk::Attention { q, k, v, mask, .. } => vec![*q, *k, *v, *mask],
3183 Thunk::AttentionBackward {
3184 q, k, v, dy, mask, ..
3185 } => {
3186 let mut v = vec![*q, *k, *v, *dy];
3187 if *mask != 0 {
3188 v.push(*mask);
3189 }
3190 v
3191 }
3192 Thunk::Fft1dQ { src, .. } => vec![*src],
3193 Thunk::Rope { src, cos, sin, .. } => vec![*src, *cos, *sin],
3194 Thunk::FusedAttnBlock {
3195 hidden,
3196 qkv_w,
3197 out_w,
3198 mask,
3199 qkv_b,
3200 out_b,
3201 cos,
3202 sin,
3203 ..
3204 } => vec![*hidden, *qkv_w, *out_w, *mask, *qkv_b, *out_b, *cos, *sin],
3205 Thunk::FusedSwiGLU { src, .. } => vec![*src],
3206 Thunk::Concat { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
3207 Thunk::ConcatF64 { inputs, .. } => inputs.iter().map(|(off, _, _)| *off).collect(),
3208 Thunk::Narrow { src, .. } => vec![*src],
3209 Thunk::Copy { src, .. } => vec![*src],
3210 Thunk::RoundHalf { src, .. } => vec![*src],
3211 Thunk::Gather { table, idx, .. } => vec![*table, *idx],
3212 Thunk::Histogram { src, .. } => vec![*src],
3213 // Anything not enumerated → return the dst as a "read" too,
3214 // forcing the fusion to bail (read_count >= 2 → skip). Keeps
3215 // this list safe to be incomplete.
3216 _ => vec![],
3217 }
3218}
3219
3220/// A scan body compiled into a self-contained CPU schedule plus the byte
3221/// offsets needed to drive it. Backends without a native `Op::Scan` kernel
3222/// (e.g. Metal via the unified-memory host fallback) build this once at compile
3223/// time and hand it to [`execute_scan_host`] at run time. Mirrors the inline
3224/// `Op::Scan` compilation used by the CPU executor.
3225pub struct ScanBodyPlan {
3226 pub body: ThunkSchedule,
3227 pub body_init: Vec<u8>,
3228 pub body_input_off: usize,
3229 pub body_output_off: usize,
3230 pub carry_bytes: usize,
3231 /// Body-arena offsets of the broadcast inputs, in declaration order.
3232 pub bcast_body_offs: Vec<usize>,
3233 /// Body-arena offsets of the per-step (`xs`) inputs, in declaration order.
3234 pub xs_body_offs: Vec<usize>,
3235}
3236
3237impl std::fmt::Debug for ScanBodyPlan {
3238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3239 f.debug_struct("ScanBodyPlan")
3240 .field("carry_bytes", &self.carry_bytes)
3241 .field("body_input_off", &self.body_input_off)
3242 .field("body_output_off", &self.body_output_off)
3243 .field("num_bcast", &self.bcast_body_offs.len())
3244 .field("num_xs", &self.xs_body_offs.len())
3245 .finish()
3246 }
3247}
3248
3249/// How one GEMM operand is laid out in the arena: full f32, or packed 2 B/elem.
3250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3251pub enum HalfKind {
3252 /// 4 B/elem f32 — either a genuine f32 tensor or a widened half slot.
3253 F32,
3254 /// 2 B/elem bfloat16 bits.
3255 Bf16,
3256 /// 2 B/elem IEEE binary16 bits.
3257 F16,
3258}
3259
3260impl HalfKind {
3261 /// Packed layouts need widening before an f32 kernel may read them.
3262 pub fn packed(self) -> bool {
3263 !matches!(self, HalfKind::F32)
3264 }
3265}