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