rlx_cpu/thunk/exec_dispatch.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/// Execute a thunk schedule on a raw arena buffer.
8/// Fastest executor: call pre-compiled closures sequentially.
9/// Zero match dispatch — each closure is a direct kernel call.
10pub fn execute_compiled(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
11 let base = arena_buf.as_mut_ptr();
12 // `RLX_CPU_WATCH=<byte_offset>:<n_floats>` reports every thunk that changes
13 // the watched region. A node read by one thunk and written by a LATER one
14 // is a scheduling hazard the post-run arena dump cannot see: it shows the
15 // final value, so the reader looks like it was fed data it never got.
16 if let Some(spec) = rlx_ir::env::var("RLX_CPU_WATCH") {
17 let mut it = spec
18 .split(':')
19 .filter_map(|t| t.trim().parse::<usize>().ok());
20 if let (Some(off), Some(len)) = (it.next(), it.next()) {
21 let mut prev: Option<Vec<f32>> = None;
22 for (i, f) in schedule.compiled_fns.iter().enumerate() {
23 f(base);
24 let cur: Vec<f32> =
25 unsafe { std::slice::from_raw_parts(base.add(off) as *const f32, len) }
26 .to_vec();
27 if prev.as_deref() != Some(cur.as_slice()) {
28 let tabs: f64 = cur.iter().map(|&v| v.abs() as f64).sum();
29 eprintln!(
30 "[rlx-cpu-watch] thunk {i} {:?} changed watch: tabs={tabs:.6}",
31 schedule.thunks.get(i).map(std::mem::discriminant)
32 );
33 prev = Some(cur);
34 }
35 }
36 return;
37 }
38 }
39 for f in &schedule.compiled_fns {
40 f(base);
41 }
42}
43
44/// Active-extent execution stub. The runtime calls this when it has an
45/// active-extent hint set. CPU doesn't implement per-thunk active-extent
46/// scaling yet — return false so the caller falls back to the full
47/// `execute_thunks` path.
48pub fn execute_thunks_active(
49 schedule: &ThunkSchedule,
50 _arena_buf: &mut [u8],
51 _actual: usize,
52 _upper: usize,
53) -> bool {
54 let _ = schedule;
55 false
56}
57
58/// Match-based executor (fallback, used by tests).
59pub(crate) struct MoeResidencyGuard;
60impl Drop for MoeResidencyGuard {
61 fn drop(&mut self) {
62 if let Some(stats) = crate::moe_residency::take_stats() {
63 crate::moe_residency::stash_last_forward_stats(stats);
64 } else {
65 crate::moe_residency::clear_mask();
66 }
67 }
68}
69
70/// Contiguous AVX2 Add/Mul/Sub. Large buffers are split across Rayon so we
71/// keep SIMD *and* multi-core (serial AVX2 alone lost to 16-wide scalar rayon
72/// on F5-TTS DiT residuals).
73#[cfg(target_arch = "x86_64")]
74#[inline]
75fn binary_contig_f32(l: &[f32], r: &[f32], o: &mut [f32], op: BinaryOp) -> bool {
76 if !std::arch::is_x86_feature_detected!("avx2") {
77 return false;
78 }
79 if !matches!(op, BinaryOp::Add | BinaryOp::Mul | BinaryOp::Sub) {
80 return false;
81 }
82 let len = o.len();
83 let l_ptr = l.as_ptr() as usize;
84 let r_ptr = r.as_ptr() as usize;
85 let o_ptr = o.as_mut_ptr() as usize;
86 let run = |i0: usize, i1: usize| unsafe {
87 // SAFETY: AVX2 checked above, `op` is Add/Sub/Mul, and `[i0, i1)` is
88 // within `len` — the bound every caller below is derived from.
89 binary_contig_avx2(
90 l_ptr as *const f32,
91 r_ptr as *const f32,
92 o_ptr as *mut f32,
93 i0,
94 i1,
95 op,
96 )
97 };
98 if len >= 8192 && crate::pool::num_threads() > 1 {
99 crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
100 run(off, off + cnt);
101 });
102 } else {
103 run(0, len);
104 }
105 true
106}
107
108/// 8-wide Add/Sub/Mul over `[i0, i1)` of three raw f32 buffers.
109///
110/// Carries `#[target_feature(enable = "avx2")]` for a reason that is invisible
111/// in the source and obvious in the disassembly: `_mm256_*` are themselves
112/// `#[target_feature]` fns, so a caller that merely *checks* AVX2 at runtime
113/// cannot inline them — each becomes a `call` plus a `vzeroupper`, four per 8
114/// lanes. Measured that way this path ran at ~7-9 GB/s against ~70-124 GB/s
115/// for the plain scalar loop it exists to beat, i.e. the "fast path" was an
116/// 8-18x pessimization. With the attribute here the intrinsics inline and it
117/// matches the hand-written-SIMD number. Same shape as every other AVX2
118/// kernel in this crate (`silu_inplace_avx2`, `softmax_rows_avx2`, …): tiny
119/// runtime-gated wrapper, `#[target_feature]` body.
120///
121/// # Safety
122/// Caller has checked `is_x86_feature_detected!("avx2")`, `op` is one of
123/// Add/Sub/Mul, and `[i0, i1)` is in bounds of all three buffers.
124#[cfg(target_arch = "x86_64")]
125#[target_feature(enable = "avx2")]
126#[allow(unsafe_op_in_unsafe_fn)]
127unsafe fn binary_contig_avx2(
128 l: *const f32,
129 r: *const f32,
130 o: *mut f32,
131 i0: usize,
132 i1: usize,
133 op: BinaryOp,
134) {
135 use std::arch::x86_64::*;
136 let mut i = i0;
137 // Align to 8-wide when possible within the chunk.
138 while i + 8 <= i1 {
139 let a = _mm256_loadu_ps(l.add(i));
140 let b = _mm256_loadu_ps(r.add(i));
141 let res = match op {
142 BinaryOp::Add => _mm256_add_ps(a, b),
143 BinaryOp::Sub => _mm256_sub_ps(a, b),
144 BinaryOp::Mul => _mm256_mul_ps(a, b),
145 _ => unreachable!(),
146 };
147 _mm256_storeu_ps(o.add(i), res);
148 i += 8;
149 }
150 while i < i1 {
151 let a = *l.add(i);
152 let b = *r.add(i);
153 *o.add(i) = match op {
154 BinaryOp::Add => a + b,
155 BinaryOp::Sub => a - b,
156 BinaryOp::Mul => a * b,
157 _ => unreachable!(),
158 };
159 i += 1;
160 }
161}
162
163#[cfg(not(target_arch = "x86_64"))]
164#[inline]
165#[allow(dead_code)]
166fn binary_contig_f32(_l: &[f32], _r: &[f32], _o: &mut [f32], _op: BinaryOp) -> bool {
167 false
168}
169
170/// Row-tiled rhs broadcast: `o[i] = op(l[i], r[i % rl])` with optional AVX2.
171#[inline]
172fn binary_row_bcast_f32(l: &[f32], r: &[f32], o: &mut [f32], op: BinaryOp, rl: usize) -> bool {
173 let len = o.len();
174 if rl == 0 || rl >= len || !len.is_multiple_of(rl) || l.len() < len || r.len() < rl {
175 return false;
176 }
177 let rows = len / rl;
178 let l_ptr = l.as_ptr() as usize;
179 let r_ptr = r.as_ptr() as usize;
180 let o_ptr = o.as_mut_ptr() as usize;
181
182 #[cfg(target_arch = "x86_64")]
183 let use_avx2 = rl >= 8
184 && rl.is_multiple_of(8)
185 && matches!(op, BinaryOp::Add | BinaryOp::Mul | BinaryOp::Sub)
186 && std::arch::is_x86_feature_detected!("avx2");
187
188 let run_rows = |row0: usize, row1: usize| unsafe {
189 let l = l_ptr as *const f32;
190 let r = r_ptr as *const f32;
191 let o = o_ptr as *mut f32;
192 #[cfg(target_arch = "x86_64")]
193 if use_avx2 {
194 // SAFETY: AVX2 + `rl % 8 == 0` + Add/Sub/Mul all checked in
195 // `use_avx2`; rows `[row0, row1)` are within `rows`.
196 binary_row_bcast_avx2(l, r, o, row0, row1, rl, op);
197 return;
198 }
199 for row in row0..row1 {
200 let base = row * rl;
201 for j in 0..rl {
202 let i = base + j;
203 let a = *l.add(i);
204 let b = *r.add(j);
205 *o.add(i) = match op {
206 BinaryOp::Add => a + b,
207 BinaryOp::Sub => a - b,
208 BinaryOp::Mul => a * b,
209 BinaryOp::Div => a / b,
210 BinaryOp::Max => a.max(b),
211 BinaryOp::Min => a.min(b),
212 BinaryOp::Pow => a.powf(b),
213 BinaryOp::Mod => a % b,
214 BinaryOp::Atan2 => a.atan2(b),
215 BinaryOp::BitAnd => ((a as i64) & (b as i64)) as f32,
216 BinaryOp::BitOr => ((a as i64) | (b as i64)) as f32,
217 BinaryOp::BitXor => ((a as i64) ^ (b as i64)) as f32,
218 BinaryOp::Shl => (a as i64).wrapping_shl(b as u32) as f32,
219 BinaryOp::Shr => (a as i64).wrapping_shr(b as u32) as f32,
220 };
221 }
222 }
223 };
224 if rows >= 4 && crate::pool::num_threads() > 1 && len >= 8192 {
225 crate::pool::par_for(rows, 1, &|off, cnt| run_rows(off, off + cnt));
226 } else {
227 run_rows(0, rows);
228 }
229 true
230}
231
232/// Row-broadcast `o[row*rl + j] = op(l[row*rl + j], r[j])`, 8 lanes at a time.
233///
234/// Split out for the same reason as [`binary_contig_avx2`] — the intrinsics
235/// only inline inside a fn that actually has AVX2 enabled.
236///
237/// # Safety
238/// Caller has checked `is_x86_feature_detected!("avx2")`, that `rl` is a
239/// non-zero multiple of 8, that `op` is one of Add/Sub/Mul, and that rows
240/// `[row0, row1)` are in bounds of all three buffers.
241#[cfg(target_arch = "x86_64")]
242#[target_feature(enable = "avx2")]
243#[allow(unsafe_op_in_unsafe_fn)]
244unsafe fn binary_row_bcast_avx2(
245 l: *const f32,
246 r: *const f32,
247 o: *mut f32,
248 row0: usize,
249 row1: usize,
250 rl: usize,
251 op: BinaryOp,
252) {
253 use std::arch::x86_64::*;
254 let chunks = rl / 8;
255 for row in row0..row1 {
256 let base = row * rl;
257 for c in 0..chunks {
258 let off = base + c * 8;
259 let roff = c * 8;
260 let a = _mm256_loadu_ps(l.add(off));
261 let b = _mm256_loadu_ps(r.add(roff));
262 let res = match op {
263 BinaryOp::Add => _mm256_add_ps(a, b),
264 BinaryOp::Sub => _mm256_sub_ps(a, b),
265 BinaryOp::Mul => _mm256_mul_ps(a, b),
266 _ => unreachable!(),
267 };
268 _mm256_storeu_ps(o.add(off), res);
269 }
270 }
271}
272
273pub(crate) fn thunk_kind_name(t: &Thunk) -> &'static str {
274 // Zero-alloc variant name from the derived `IntoStaticStr` on `Thunk`
275 // (see its definition). Covers EVERY variant automatically, so a newly
276 // added thunk is labeled by name instead of silently bucketing into
277 // "Other" — which had hidden the pathologically slow AttentionBackward.
278 <&'static str>::from(t)
279}
280
281/// Per-thunk-kind wall-time accumulator, populated only when the env var
282/// `RLX_PROFILE_THUNKS` is set. Used to see which ops dominate a step so the
283/// optimizer/kernels can target the real hotspots rather than guesses.
284pub(crate) static THUNK_PROFILE: std::sync::Mutex<
285 Option<std::collections::BTreeMap<&'static str, (u128, u64)>>,
286> = std::sync::Mutex::new(None);
287
288#[inline]
289pub(crate) fn profile_record(name: &'static str, d: std::time::Duration) {
290 let mut g = THUNK_PROFILE.lock().unwrap();
291 let map = g.get_or_insert_with(std::collections::BTreeMap::new);
292 let e = map.entry(name).or_insert((0, 0));
293 e.0 += d.as_nanos();
294 e.1 += 1;
295}
296
297/// Print and clear the per-thunk-kind time profile gathered under
298/// `RLX_PROFILE_THUNKS`. Call after a run to see where the time went.
299pub fn dump_thunk_profile() {
300 let mut g = THUNK_PROFILE.lock().unwrap();
301 if let Some(map) = g.take() {
302 let mut v: Vec<_> = map.into_iter().collect();
303 v.sort_by_key(|b| std::cmp::Reverse(b.1.0));
304 let total: u128 = v.iter().map(|(_, (ns, _))| *ns).sum();
305 eprintln!(
306 "[thunk-profile] total {:.1}ms across kinds:",
307 total as f64 / 1e6
308 );
309 for (name, (ns, c)) in v.iter().take(25) {
310 eprintln!(" {name:<28} {:>8.1}ms ({c} calls)", *ns as f64 / 1e6);
311 }
312 }
313}
314
315/// Do the arena byte ranges `[a, a + a_elems*4)` and `[c, c + c_elems*4)`
316/// overlap at all?
317///
318/// The BLAS aliasing guard has to compare *ranges*, not pointers. When a
319/// fusion folds a `Transpose` into a matmul, the surviving operand is the
320/// transpose's *source* buffer, which the planner scored as dead at the
321/// transpose — so its slot is free to be reused for the matmul's own output.
322/// That leaves an operand sitting strictly inside the destination (WavTokenizer
323/// attention: `probs @ V` reads a 256 B `probs` from the middle of its own
324/// 24 KB output slot). `ptr::eq` sees two different addresses and waves it
325/// through, after which cblas streams `probs` while overwriting it. Accelerate
326/// happens to pack its operands up front and survives; OpenBLAS does not, which
327/// is why this only ever corrupted x86_64.
328#[inline]
329fn arena_ranges_overlap(a: usize, a_elems: usize, c: usize, c_elems: usize) -> bool {
330 a < c + c_elems * 4 && c < a + a_elems * 4
331}
332
333/// The same range test where only the slices are in hand.
334///
335/// The batched matmul paths build their per-item slices from raw pointers, so
336/// they have no arena offsets to compare — and they were left on `ptr::eq` when
337/// the non-batched paths were fixed. A batched attention (`probs @ V` per head)
338/// is exactly where an operand ends up strictly inside its own output slot, so
339/// the weaker guard let cblas stream an operand it was simultaneously
340/// overwriting: a band of query rows corrupted, varying run to run with the
341/// order BLAS happened to pack in, and cured by `RLX_ARENA_NO_REUSE=1`.
342#[inline]
343fn slices_overlap(a: &[f32], c: &[f32]) -> bool {
344 let (a0, a1) = (a.as_ptr() as usize, a.as_ptr() as usize + a.len() * 4);
345 let (c0, c1) = (c.as_ptr() as usize, c.as_ptr() as usize + c.len() * 4);
346 a0 < c1 && c0 < a1
347}
348
349pub fn execute_thunks(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
350 // `RLX_CPU_EXEC_IN_POOL`: run the whole schedule on a pool worker. Called
351 // from outside the pool, every parallel kernel parks this thread until the
352 // workers finish and then waits for it to be woken — per GEMV, per
353 // attention, ~225 times per decoded token. On a worker the kernels' joins
354 // spin instead, and this thread's share of the work is done by the worker
355 // it runs on. Thread-local kernel state (MoE residency masks, counters)
356 // then lives on that worker.
357 #[cfg(not(target_arch = "wasm32"))]
358 if exec_in_pool() && rayon::current_thread_index().is_none() && crate::pool::num_threads() > 1 {
359 return rayon::scope(|_| execute_thunks_here(schedule, arena_buf));
360 }
361 execute_thunks_here(schedule, arena_buf)
362}
363
364#[cfg(not(target_arch = "wasm32"))]
365fn exec_in_pool() -> bool {
366 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
367 *ON.get_or_init(|| rlx_ir::env::flag("RLX_CPU_EXEC_IN_POOL"))
368}
369
370fn execute_thunks_here(schedule: &ThunkSchedule, arena_buf: &mut [u8]) {
371 crate::moe_residency::reset_gmm_counters();
372 if let Some(layers) = schedule.moe_resident_layers.clone() {
373 crate::moe_residency::set_per_layer_masks(Some(layers));
374 } else {
375 crate::moe_residency::set_mask(schedule.moe_resident.clone());
376 }
377 if let Some(cap) = schedule.moe_topk_capture.as_ref() {
378 cap.clear();
379 }
380 let _moe_guard = MoeResidencyGuard;
381 let base = arena_buf.as_mut_ptr();
382 let mask_thr = schedule.mask_threshold;
383 let mask_neg = schedule.mask_neg_inf;
384 let score_thr = schedule.score_skip;
385 let thunks = &schedule.thunks;
386 let len = thunks.len();
387
388 // Pre-allocate ALL reusable buffers once (zero per-call allocation)
389 let max_h = thunks
390 .iter()
391 .filter_map(|t| match t {
392 Thunk::FusedResidualLN { h, .. }
393 | Thunk::FusedResidualRmsNorm { h, .. }
394 | Thunk::LayerNorm { h, .. } => Some(*h as usize),
395 _ => None,
396 })
397 .max()
398 .unwrap_or(0);
399 let zero_bias = vec![0f32; max_h];
400
401 // Pre-allocate per-(batch,head) score buffers for parallel SDPA.
402 // Q/K/V/out are accessed via strided BLAS — no deinterleave copy needed.
403 let max_sdpa = thunks
404 .iter()
405 .filter_map(|t| match t {
406 Thunk::Attention {
407 batch,
408 seq,
409 kv_seq,
410 heads,
411 head_dim,
412 ..
413 } => Some((
414 *batch as usize,
415 (*seq as usize).max(*kv_seq as usize),
416 *heads as usize,
417 *head_dim as usize,
418 )),
419 _ => None,
420 })
421 .fold((0, 0, 0, 0), |(mb, ms, mh, md), (b, s, h, d)| {
422 (mb.max(b), ms.max(s), mh.max(h), md.max(d))
423 });
424 let (max_batch, max_seq, max_heads, _max_dh) = max_sdpa;
425 let max_units = max_batch * max_heads;
426 let mut sdpa_scores = vec![0f32; max_units * max_seq * max_seq];
427
428 // Pre-allocate fused layer buffers (reused across all 12+ layers — zero malloc per layer)
429 let fl = thunks
430 .iter()
431 .filter_map(|t| match t {
432 Thunk::FusedBertLayer {
433 batch,
434 seq,
435 hs,
436 int_dim,
437 ..
438 } => {
439 let m = (*batch as usize) * (*seq as usize);
440 let h = *hs as usize;
441 let id = *int_dim as usize;
442 Some((m, h, id, m * (*seq as usize)))
443 }
444 Thunk::FusedNomicLayer {
445 batch,
446 seq,
447 hs,
448 int_dim,
449 ..
450 } => {
451 let m = (*batch as usize) * (*seq as usize);
452 let h = *hs as usize;
453 let id = *int_dim as usize;
454 Some((m, h, id, m * (*seq as usize)))
455 }
456 _ => None,
457 })
458 .fold((0, 0, 0, 0), |(mm, mh, mi, ms), (m, h, id, ss)| {
459 (mm.max(m), mh.max(h), mi.max(id), ms.max(ss))
460 });
461 let (fl_m, fl_h, fl_int, fl_ss) = fl;
462 let mut fl_qkv = vec![0f32; fl_m * 3 * fl_h];
463 let mut fl_attn = vec![0f32; fl_m * fl_h];
464 let mut fl_res = vec![0f32; fl_m * fl_h];
465 let mut fl_normed = vec![0f32; fl_m * fl_h];
466 let mut fl_ffn = vec![0f32; fl_m * fl_int.max(2 * fl_int)]; // Nomic needs 2×int for fused fc11+fc12
467 let mut fl_sc = vec![0f32; fl_ss.max(1)];
468
469 let trace_thunks = rlx_ir::env::var_os("RLX_TRACE_THUNK").is_some();
470 if trace_thunks {
471 eprintln!(
472 "[thunk] prealloc max_h={max_h} sdpa={} fl_m={fl_m} fl_h={fl_h} fl_int={fl_int}",
473 max_units * max_seq * max_seq
474 );
475 }
476 let profile = rlx_ir::env::var_os("RLX_PROFILE_THUNKS").is_some();
477 // Time the previous thunk at the top of each iteration (avoids touching the
478 // giant match's many arms). The last thunk's tail is folded into the next
479 // step's first sample — negligible over a training run.
480 let mut prof_prev: Option<(&'static str, std::time::Instant)> = None;
481 // Byte-range write tracer (`RLX_ARENA_WRITE_TRACE=1`). Snapshots the arena
482 // around every thunk and reports the exact range each one dirtied, so a
483 // kernel that writes past its planned slot — the classic SIMD-tail overrun
484 // that corrupts whichever buffer the planner reused next — is named
485 // directly instead of inferred. O(arena) per thunk, so diagnostics only.
486 let write_trace = rlx_ir::env::var_os("RLX_ARENA_WRITE_TRACE").is_some();
487 let mut wt_before: Vec<u8> = if write_trace {
488 arena_buf.to_vec()
489 } else {
490 Vec::new()
491 };
492 // `RLX_CPU_WATCH=<byte_offset>:<n_floats>` reports the watched region's
493 // contents whenever they change, naming the thunk about to run — so the
494 // writer is the thunk *before* the one printed. A node written after its
495 // reader is a scheduling hazard invisible to the post-run arena dump,
496 // which only ever shows the final value.
497 let watch = rlx_ir::env::var("RLX_CPU_WATCH").and_then(|spec| {
498 let mut it = spec
499 .split(':')
500 .filter_map(|t| t.trim().parse::<usize>().ok());
501 Some((it.next()?, it.next()?))
502 });
503 let mut watch_prev: Option<Vec<f32>> = None;
504 // `RLX_CPU_DUMP_THUNKS=<lo>:<hi>` prints the compiled thunks in that index
505 // range, with their arena offsets and dimensions — the arguments a kernel
506 // actually receives, as opposed to the ones the graph implies.
507 if let Some(spec) = rlx_ir::env::var("RLX_CPU_DUMP_THUNKS") {
508 let mut it = spec
509 .split(':')
510 .filter_map(|t| t.trim().parse::<usize>().ok());
511 if let (Some(lo), Some(hi)) = (it.next(), it.next()) {
512 for (i, t) in thunks.iter().enumerate().take(hi.min(len)).skip(lo) {
513 let detail = match t {
514 Thunk::FusedMmBiasAct {
515 a,
516 w,
517 bias,
518 c,
519 m,
520 k,
521 n,
522 act,
523 } => format!(" a={a} w={w} bias={bias} c={c} m={m} k={k} n={n} act={act:?}"),
524 Thunk::Sgemm { a, b, c, m, k, n } => {
525 format!(" a={a} b={b} c={c} m={m} k={k} n={n}")
526 }
527 _ => String::new(),
528 };
529 eprintln!("[rlx-cpu-thunk] {i} {}{detail}", thunk_kind_name(t));
530 }
531 }
532 }
533 for i in 0..len {
534 if profile {
535 if let Some((pn, pt)) = prof_prev.take() {
536 profile_record(pn, pt.elapsed());
537 }
538 }
539 let thunk = unsafe { thunks.get_unchecked(i) };
540 if let Some((woff, wlen)) = watch {
541 let cur: Vec<f32> =
542 unsafe { std::slice::from_raw_parts(base.add(woff) as *const f32, wlen) }.to_vec();
543 if watch_prev.as_deref() != Some(cur.as_slice()) {
544 let tabs: f64 = cur.iter().map(|&v| v.abs() as f64).sum();
545 eprintln!(
546 "[rlx-cpu-watch] before thunk {i} ({}) tabs={tabs:.6}",
547 thunk_kind_name(thunk)
548 );
549 watch_prev = Some(cur);
550 }
551 }
552 if trace_thunks && (i < 120 || i % 200 == 0 || i + 1 == len) {
553 eprintln!("[thunk {i}/{len}] {}", thunk_kind_name(thunk));
554 }
555 let trace_done = trace_thunks && i < 120;
556 if profile {
557 prof_prev = Some((thunk_kind_name(thunk), std::time::Instant::now()));
558 }
559 match thunk {
560 Thunk::Nop => exec_nop(thunk),
561 Thunk::ElementwiseRegion { .. } => exec_elementwise_region(thunk, base),
562 Thunk::GaussianSplatRender { .. } => exec_gaussian_splat_render(thunk, base),
563 Thunk::GaussianSplatRenderBackward { .. } => {
564 exec_gaussian_splat_render_backward(thunk, base)
565 }
566 Thunk::GaussianSplatPrepare { .. } => exec_gaussian_splat_prepare(thunk, base),
567 Thunk::GaussianSplatRasterize { .. } => exec_gaussian_splat_rasterize(thunk, base),
568 Thunk::Fft1d { .. } => exec_fft1d(thunk, base),
569 Thunk::Fft1dQ { .. } => exec_fft1d_q(thunk, base),
570 Thunk::FftButterflyStage { .. } => exec_fft_butterfly_stage(thunk, base),
571 Thunk::LogMel { .. } => exec_log_mel(thunk, base),
572 Thunk::LogMelBackward { .. } => exec_log_mel_backward(thunk, base),
573 Thunk::WelchPeaks { .. } => exec_welch_peaks(thunk, base),
574 Thunk::CustomFn { .. } => exec_custom_fn(thunk, base),
575 Thunk::Sgemm { a, b, c, m, k, n } => {
576 let (m, k, n) = (*m as usize, *k as usize, *n as usize);
577 if trace_thunks {
578 eprintln!("[sgemm] m={m} k={k} n={n} a={} b={} c={}", *a, *b, *c);
579 }
580 let c_len = m.saturating_mul(n);
581 let a_len = m.saturating_mul(k);
582 let b_len = k.saturating_mul(n);
583 let arena_len = arena_buf.len();
584 let max_a = (arena_len.saturating_sub(*a)) / 4;
585 let max_b = (arena_len.saturating_sub(*b)) / 4;
586 let max_c = (arena_len.saturating_sub(*c)) / 4;
587 let a_len = a_len.min(max_a);
588 let b_len = b_len.min(max_b);
589 let c_len = c_len.min(max_c);
590 unsafe {
591 let a_sl = sl(*a, base, a_len);
592 let b_sl = sl(*b, base, b_len);
593 let c_sl = sl_mut(*c, base, c_len);
594 if arena_ranges_overlap(*a, a_len, *c, c_len)
595 || arena_ranges_overlap(*b, b_len, *c, c_len)
596 {
597 let mut tmp = vec![0.0f32; c_len];
598 crate::blas::sgemm_auto(a_sl, b_sl, &mut tmp, m, k, n);
599 c_sl.copy_from_slice(&tmp);
600 } else {
601 crate::blas::sgemm_auto(a_sl, b_sl, c_sl, m, k, n);
602 }
603 }
604 }
605
606 Thunk::SgemmHalf {
607 a,
608 b,
609 c,
610 m,
611 k,
612 n,
613 a_kind,
614 b_kind,
615 c_kind,
616 } => {
617 let (m, k, n) = (*m as usize, *k as usize, *n as usize);
618 let (a_len, b_len, c_len) = (m * k, k * n, m * n);
619 unsafe {
620 let a_buf;
621 let a_sl: &[f32] = if a_kind.packed() {
622 a_buf = crate::blas::widen_half_vec(
623 sl_typed::<u16>(*a, base, a_len),
624 *a_kind == crate::thunk::HalfKind::Bf16,
625 );
626 &a_buf
627 } else {
628 sl(*a, base, a_len)
629 };
630 let b_buf;
631 let b_sl: &[f32] = if b_kind.packed() {
632 b_buf = crate::blas::widen_half_vec(
633 sl_typed::<u16>(*b, base, b_len),
634 *b_kind == crate::thunk::HalfKind::Bf16,
635 );
636 &b_buf
637 } else {
638 sl(*b, base, b_len)
639 };
640 if c_kind.packed() {
641 let mut tmp = vec![0f32; c_len];
642 crate::blas::sgemm_auto(a_sl, b_sl, &mut tmp, m, k, n);
643 let dst = sl_mut_typed::<u16>(*c, base, c_len);
644 crate::blas::round_half_into(
645 &tmp,
646 dst,
647 *c_kind == crate::thunk::HalfKind::Bf16,
648 );
649 } else {
650 let c_sl = sl_mut(*c, base, c_len);
651 crate::blas::sgemm_auto(a_sl, b_sl, c_sl, m, k, n);
652 }
653 }
654 }
655
656 Thunk::SgemmBf16 { a, b, c, m, k, n } => {
657 let (m, k, n) = (*m as usize, *k as usize, *n as usize);
658 let c_len = m.saturating_mul(n);
659 let a_len = m.saturating_mul(k);
660 let bn = k.saturating_mul(n);
661 unsafe {
662 let a_sl = sl(*a, base, a_len);
663 let b16 = sl_typed::<u16>(*b, base, bn);
664 let c_sl = sl_mut(*c, base, c_len);
665 crate::blas::sgemm_bf16_rhs(a_sl, b16, c_sl, m, k, n);
666 }
667 }
668
669 Thunk::SgemmF16 { a, b, c, m, k, n } => {
670 let (m, k, n) = (*m as usize, *k as usize, *n as usize);
671 let c_len = m.saturating_mul(n);
672 let a_len = m.saturating_mul(k);
673 let bn = k.saturating_mul(n);
674 unsafe {
675 let a_sl = sl(*a, base, a_len);
676 let b16 = sl_typed::<u16>(*b, base, bn);
677 let c_sl = sl_mut(*c, base, c_len);
678 crate::blas::sgemm_f16_rhs(a_sl, b16, c_sl, m, k, n);
679 }
680 }
681
682 Thunk::SgemmT {
683 a,
684 b,
685 c,
686 m,
687 k,
688 n,
689 ta,
690 tb,
691 } => {
692 // C[m,n] = op(A) @ op(B). RowMajor cblas: lda/ldb = stored
693 // row-length of each operand → m if A is transposed else k;
694 // k if B is transposed else n. Element counts are m*k / k*n
695 // regardless of layout.
696 let (m, k, n) = (*m as usize, *k as usize, *n as usize);
697 let lda = if *ta { m } else { k };
698 let ldb = if *tb { k } else { n };
699 if trace_thunks {
700 eprintln!(
701 "[sgemmT] m={m} k={k} n={n} ta={ta} tb={tb} a={a}..{} b={b}..{} c={c}..{}",
702 a + m * k * 4,
703 b + k * n * 4,
704 c + m * n * 4
705 );
706 }
707 let arena_len = arena_buf.len();
708 let a_len = (m * k).min((arena_len.saturating_sub(*a)) / 4);
709 let b_len = (k * n).min((arena_len.saturating_sub(*b)) / 4);
710 let c_len = (m * n).min((arena_len.saturating_sub(*c)) / 4);
711 unsafe {
712 let a_sl = sl(*a, base, a_len);
713 let b_sl = sl(*b, base, b_len);
714 let c_sl = sl_mut(*c, base, c_len);
715 let (ap, bp) = (a_sl.as_ptr(), b_sl.as_ptr());
716 if arena_ranges_overlap(*a, a_len, *c, c_len)
717 || arena_ranges_overlap(*b, b_len, *c, c_len)
718 {
719 let mut tmp = vec![0.0f32; c_len];
720 crate::blas::sgemm_general(
721 ap,
722 bp,
723 tmp.as_mut_ptr(),
724 m,
725 n,
726 k,
727 1.0,
728 0.0,
729 lda,
730 ldb,
731 n,
732 *ta,
733 *tb,
734 );
735 c_sl.copy_from_slice(&tmp);
736 } else {
737 crate::blas::sgemm_general(
738 ap,
739 bp,
740 c_sl.as_mut_ptr(),
741 m,
742 n,
743 k,
744 1.0,
745 0.0,
746 lda,
747 ldb,
748 n,
749 *ta,
750 *tb,
751 );
752 }
753 }
754 }
755
756 Thunk::SgdMomentum { .. } => exec_sgd_momentum(thunk, base),
757 Thunk::CgemmC64 { .. } => exec_cgemm_c64(thunk, base),
758 Thunk::DenseSolveF64 { .. } => exec_dense_solve_f64(thunk, base),
759 Thunk::DenseSolveF32 { .. } => exec_dense_solve_f32(thunk, base),
760 Thunk::CholeskyF32 { .. } => exec_cholesky(thunk, base),
761 Thunk::TriangularSolveF32 { .. } => exec_triangular_solve(thunk, base),
762 Thunk::DetF32 { .. } => exec_det(thunk, base),
763 Thunk::SortF32 { .. } => exec_sort(thunk, base),
764 Thunk::SvdF32 { .. } => exec_svd(thunk, base),
765 Thunk::QrF32 { .. } => exec_qr(thunk, base),
766 Thunk::BatchedDenseSolveF64 { .. } => exec_batched_dense_solve_f64(thunk, base),
767 Thunk::BatchedDenseSolveF32 { .. } => exec_batched_dense_solve_f32(thunk, base),
768 Thunk::BatchedDgemmF64 { .. } => exec_batched_dgemm_f64(thunk, base),
769 Thunk::BatchedSgemm {
770 a,
771 b,
772 c,
773 batch,
774 m,
775 k,
776 n,
777 a_bcast,
778 b_bcast,
779 } => {
780 let (b_, m_, k_, n_) = (*batch as usize, *m as usize, *k as usize, *n as usize);
781 if trace_thunks {
782 eprintln!(
783 "[batched-sgemm] batch={b_} m={m_} k={k_} n={n_} a_bcast={a_bcast} b_bcast={b_bcast} a={} b={} c={}",
784 *a, *b, *c
785 );
786 }
787 let a_mat = m_.saturating_mul(k_); // per-matrix element count
788 let b_mat = k_.saturating_mul(n_);
789 let c_mat = m_.saturating_mul(n_);
790 // A broadcast operand (batch dim 1) has batch stride 0 → reuse
791 // matrix 0 for every output batch; else stride by its matrix size.
792 let a_bstride = if *a_bcast { 0 } else { a_mat };
793 let b_bstride = if *b_bcast { 0 } else { b_mat };
794 let arena_len = arena_buf.len();
795 let a_cap = (arena_len.saturating_sub(*a)) / 4;
796 let b_cap = (arena_len.saturating_sub(*b)) / 4;
797 let c_cap = (arena_len.saturating_sub(*c)) / 4;
798 let a_count = if *a_bcast { 1 } else { b_ };
799 let b_count = if *b_bcast { 1 } else { b_ };
800 let a_elems = (a_count * a_mat).min(a_cap);
801 let b_elems = (b_count * b_mat).min(b_cap);
802 let c_elems = (b_ * c_mat).min(c_cap);
803 // Whole-span aliasing, decided once for the batch.
804 //
805 // A per-item check cannot see the real hazard here: with an
806 // operand stack parked inside the output slot, writing `C[0]`
807 // destroys `B[1]`, and item 1's own guard compares only its own
808 // `B[1]` against its own `C[1]` — which do not overlap. The
809 // damage lands on whichever items ran after the one that
810 // clobbered them, so it moves with the worker schedule.
811 //
812 // So the operand *stacks* are compared against the output
813 // *stack*, and if they touch at all the whole batch goes through
814 // a scratch buffer.
815 let aliased = arena_ranges_overlap(*a, a_elems, *c, c_elems)
816 || arena_ranges_overlap(*b, b_elems, *c, c_elems);
817 if aliased {
818 unsafe {
819 let a_full = sl(*a, base, a_elems).to_vec();
820 let b_full = sl(*b, base, b_elems).to_vec();
821 let mut out = vec![0f32; c_elems];
822 for bi in 0..b_ {
823 let (a0, b0, c0) = (bi * a_bstride, bi * b_bstride, bi * c_mat);
824 if a0 + a_mat > a_full.len()
825 || b0 + b_mat > b_full.len()
826 || c0 + c_mat > out.len()
827 {
828 break;
829 }
830 crate::blas::sgemm_auto(
831 &a_full[a0..a0 + a_mat],
832 &b_full[b0..b0 + b_mat],
833 &mut out[c0..c0 + c_mat],
834 m_,
835 k_,
836 n_,
837 );
838 }
839 sl_mut(*c, base, c_elems).copy_from_slice(&out);
840 }
841 continue;
842 }
843 unsafe {
844 let a_full = sl(*a, base, a_elems);
845 let b_full = sl(*b, base, b_elems);
846 let c_full = sl_mut(*c, base, c_elems);
847 // Parallelize independent batch GEMMs with Rayon (BLAS
848 // stays 1-thread per worker). Serial for tiny batches.
849 if b_ >= 2 && crate::pool::num_threads() > 1 {
850 let a_ptr = a_full.as_ptr() as usize;
851 let b_ptr = b_full.as_ptr() as usize;
852 let c_ptr = c_full.as_mut_ptr() as usize;
853 let a_len = a_full.len();
854 let b_len = b_full.len();
855 let c_len = c_full.len();
856 crate::pool::par_for(b_, 1, &|off, cnt| {
857 for bi in off..off + cnt {
858 let a0 = bi * a_bstride;
859 let b0 = bi * b_bstride;
860 let c0 = bi * c_mat;
861 if a0 + a_mat > a_len || b0 + b_mat > b_len || c0 + c_mat > c_len {
862 break;
863 }
864 // SAFETY: pointers from disjoint batch slices; c
865 // ranges don't overlap across workers.
866 let a_slice = std::slice::from_raw_parts(
867 (a_ptr as *const f32).add(a0),
868 a_mat,
869 );
870 let b_slice = std::slice::from_raw_parts(
871 (b_ptr as *const f32).add(b0),
872 b_mat,
873 );
874 let c_slice = std::slice::from_raw_parts_mut(
875 (c_ptr as *mut f32).add(c0),
876 c_mat,
877 );
878 if slices_overlap(a_slice, c_slice)
879 || slices_overlap(b_slice, c_slice)
880 {
881 let mut tmp = vec![0.0f32; c_mat];
882 crate::blas::sgemm(a_slice, b_slice, &mut tmp, m_, k_, n_);
883 c_slice.copy_from_slice(&tmp);
884 } else {
885 crate::blas::sgemm(a_slice, b_slice, c_slice, m_, k_, n_);
886 }
887 }
888 });
889 } else {
890 for bi in 0..b_ {
891 let a0 = bi * a_bstride;
892 let b0 = bi * b_bstride;
893 let c0 = bi * c_mat;
894 if a0 + a_mat > a_full.len()
895 || b0 + b_mat > b_full.len()
896 || c0 + c_mat > c_full.len()
897 {
898 break;
899 }
900 let a_slice = &a_full[a0..a0 + a_mat];
901 let b_slice = &b_full[b0..b0 + b_mat];
902 let c_slice = &mut c_full[c0..c0 + c_mat];
903 if slices_overlap(a_slice, c_slice) || slices_overlap(b_slice, c_slice)
904 {
905 let mut tmp = vec![0.0f32; c_mat];
906 crate::blas::sgemm_auto(a_slice, b_slice, &mut tmp, m_, k_, n_);
907 c_slice.copy_from_slice(&tmp);
908 } else {
909 crate::blas::sgemm_auto(a_slice, b_slice, c_slice, m_, k_, n_);
910 }
911 }
912 }
913 }
914 }
915
916 Thunk::Dgemm { .. } => exec_dgemm(thunk, base),
917 Thunk::TransposeF64 { .. } => exec_transpose_f64(thunk, base),
918 Thunk::ActivationF64 { .. } => exec_activation_f64(thunk, base),
919 Thunk::ReduceSumF64 { .. } => exec_reduce_sum_f64(thunk, base),
920 Thunk::CopyF64 { src, dst, len } => {
921 let mut len = *len as usize;
922 if *src == *dst || len == 0 {
923 continue;
924 }
925 let arena_len = arena_buf.len();
926 let max_from_src = (arena_len.saturating_sub(*src)) / 8;
927 let max_from_dst = (arena_len.saturating_sub(*dst)) / 8;
928 len = len.min(max_from_src).min(max_from_dst);
929 if len == 0 {
930 continue;
931 }
932 let byte_len = len.saturating_mul(8);
933 unsafe {
934 std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
935 }
936 }
937
938 Thunk::CopyI64 { src, dst, len } => {
939 let mut len = *len as usize;
940 if *src == *dst || len == 0 {
941 continue;
942 }
943 let arena_len = arena_buf.len();
944 let max_from_src = (arena_len.saturating_sub(*src)) / 8;
945 let max_from_dst = (arena_len.saturating_sub(*dst)) / 8;
946 len = len.min(max_from_src).min(max_from_dst);
947 if len == 0 {
948 continue;
949 }
950 let byte_len = len.saturating_mul(8);
951 unsafe {
952 std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
953 }
954 }
955
956 Thunk::CastF32ToI64 { src, dst, len } => {
957 let len = *len as usize;
958 if len == 0 {
959 continue;
960 }
961 unsafe {
962 let inp = sl(*src, base, len);
963 let out = sl_mut_i64(*dst, base, len);
964 // ONNX Cast float→int truncates toward zero (not round-half).
965 for i in 0..len {
966 out[i] = inp[i] as i64;
967 }
968 }
969 }
970
971 Thunk::CastF32ToF64 { src, dst, len } => {
972 let len = *len as usize;
973 if len == 0 {
974 continue;
975 }
976 unsafe {
977 let inp = sl(*src, base, len);
978 let out = sl_mut_f64(*dst, base, len);
979 for i in 0..len {
980 out[i] = inp[i] as f64;
981 }
982 }
983 }
984
985 Thunk::CastF64ToF32 { src, dst, len } => {
986 let len = *len as usize;
987 if len == 0 {
988 continue;
989 }
990 unsafe {
991 let inp = sl_f64(*src, base, len);
992 let out = sl_mut(*dst, base, len);
993 // f64 → f32 narrowing (round-to-nearest), not a bitcast.
994 for i in 0..len {
995 out[i] = inp[i] as f32;
996 }
997 }
998 }
999
1000 Thunk::CastF64ToI64 { src, dst, len } => {
1001 let len = *len as usize;
1002 if len == 0 {
1003 continue;
1004 }
1005 unsafe {
1006 let inp = sl_f64(*src, base, len);
1007 let out = sl_mut_i64(*dst, base, len);
1008 // ONNX Cast float→int truncates toward zero.
1009 for i in 0..len {
1010 out[i] = inp[i] as i64;
1011 }
1012 }
1013 }
1014
1015 Thunk::CastF64ToI32 { src, dst, len } => {
1016 let len = *len as usize;
1017 if len == 0 {
1018 continue;
1019 }
1020 unsafe {
1021 let inp = sl_f64(*src, base, len);
1022 let out = sl_mut_i32(*dst, base, len);
1023 // ONNX Cast float→int truncates toward zero.
1024 for i in 0..len {
1025 out[i] = inp[i] as i32;
1026 }
1027 }
1028 }
1029
1030 Thunk::CastF64ToBool { src, dst, len } => {
1031 let len = *len as usize;
1032 if len == 0 {
1033 continue;
1034 }
1035 // f64 = 8 bytes, bool = 1 byte — copy the input out first to
1036 // avoid aliasing the arena while writing the narrower output.
1037 let inp = unsafe { sl_f64(*src, base, len).to_vec() };
1038 for i in 0..len {
1039 arena_buf[*dst + i] = u8::from(inp[i] != 0.0);
1040 }
1041 }
1042
1043 Thunk::CastI64ToI32 { src, dst, len } => {
1044 let len = *len as usize;
1045 if len == 0 {
1046 continue;
1047 }
1048 unsafe {
1049 let inp = sl_i64(*src, base, len).to_vec();
1050 let out = sl_mut_i32(*dst, base, len);
1051 // Wrapping narrowing (matches Rust `as`, ONNX int→int).
1052 for i in 0..len {
1053 out[i] = inp[i] as i32;
1054 }
1055 }
1056 }
1057
1058 Thunk::CastI64ToF64 { src, dst, len } => {
1059 let len = *len as usize;
1060 if len == 0 {
1061 continue;
1062 }
1063 unsafe {
1064 let inp = sl_i64(*src, base, len).to_vec();
1065 let out = sl_mut_f64(*dst, base, len);
1066 for i in 0..len {
1067 out[i] = inp[i] as f64;
1068 }
1069 }
1070 }
1071
1072 Thunk::CastI32ToF64 { src, dst, len } => {
1073 let len = *len as usize;
1074 if len == 0 {
1075 continue;
1076 }
1077 unsafe {
1078 let inp = sl_i32(*src, base, len).to_vec();
1079 let out = sl_mut_f64(*dst, base, len);
1080 for i in 0..len {
1081 out[i] = inp[i] as f64;
1082 }
1083 }
1084 }
1085
1086 Thunk::CastF32ToF16 { src, dst, len } => {
1087 let len = *len as usize;
1088 if len == 0 {
1089 continue;
1090 }
1091 // f32 (4 bytes) → f16 (2 bytes). No sl_f16 accessor; copy the
1092 // input out then write the LE f16 bytes into the arena.
1093 let inp = unsafe { sl(*src, base, len).to_vec() };
1094 for i in 0..len {
1095 let h = half::f16::from_f32(inp[i]);
1096 arena_buf[*dst + i * 2..*dst + i * 2 + 2].copy_from_slice(&h.to_le_bytes());
1097 }
1098 }
1099
1100 Thunk::CastF16ToF32 { src, dst, len } => {
1101 let len = *len as usize;
1102 if len == 0 {
1103 continue;
1104 }
1105 // f16 (2 bytes) → f32 (4 bytes). Read the LE f16 bytes first,
1106 // then write the widened f32 output.
1107 let bytes: Vec<u8> = arena_buf[*src..*src + len * 2].to_vec();
1108 unsafe {
1109 let out = sl_mut(*dst, base, len);
1110 for i in 0..len {
1111 out[i] =
1112 half::f16::from_le_bytes([bytes[i * 2], bytes[i * 2 + 1]]).to_f32();
1113 }
1114 }
1115 }
1116
1117 Thunk::CastF32ToBf16 { src, dst, len } => {
1118 let len = *len as usize;
1119 if len == 0 {
1120 continue;
1121 }
1122 let inp = unsafe { sl(*src, base, len).to_vec() };
1123 for i in 0..len {
1124 let h = half::bf16::from_f32(inp[i]);
1125 arena_buf[*dst + i * 2..*dst + i * 2 + 2].copy_from_slice(&h.to_le_bytes());
1126 }
1127 }
1128
1129 Thunk::CastBf16ToF32 { src, dst, len } => {
1130 let len = *len as usize;
1131 if len == 0 {
1132 continue;
1133 }
1134 let bytes: Vec<u8> = arena_buf[*src..*src + len * 2].to_vec();
1135 unsafe {
1136 let out = sl_mut(*dst, base, len);
1137 for i in 0..len {
1138 out[i] =
1139 half::bf16::from_le_bytes([bytes[i * 2], bytes[i * 2 + 1]]).to_f32();
1140 }
1141 }
1142 }
1143
1144 Thunk::CastF32ToI32 { src, dst, len } => {
1145 let len = *len as usize;
1146 if len == 0 {
1147 continue;
1148 }
1149 unsafe {
1150 let inp = sl(*src, base, len);
1151 let out = sl_mut_i32(*dst, base, len);
1152 // ONNX Cast float→int truncates toward zero (not round-half).
1153 for i in 0..len {
1154 out[i] = inp[i] as i32;
1155 }
1156 }
1157 }
1158
1159 Thunk::CastI64ToF32 { src, dst, len } => {
1160 let len = *len as usize;
1161 if len == 0 {
1162 continue;
1163 }
1164 unsafe {
1165 let inp = sl_i64(*src, base, len);
1166 let out = sl_mut(*dst, base, len);
1167 for i in 0..len {
1168 out[i] = inp[i] as f32;
1169 }
1170 }
1171 }
1172
1173 Thunk::CastBoolToI32 { src, dst, len } => {
1174 let len = *len as usize;
1175 if len == 0 {
1176 continue;
1177 }
1178 unsafe {
1179 let inp = &arena_buf[*src..*src + len];
1180 let out = sl_mut_i32(*dst, base, len);
1181 for i in 0..len {
1182 out[i] = i32::from(inp[i] != 0);
1183 }
1184 }
1185 }
1186
1187 Thunk::CastI32ToF32 { src, dst, len } => {
1188 let len = *len as usize;
1189 if len == 0 {
1190 continue;
1191 }
1192 unsafe {
1193 let inp = sl_i32(*src, base, len);
1194 let out = sl_mut(*dst, base, len);
1195 for i in 0..len {
1196 out[i] = inp[i] as f32;
1197 }
1198 }
1199 }
1200
1201 Thunk::CastI32ToI64 { src, dst, len } => {
1202 let len = *len as usize;
1203 if len == 0 {
1204 continue;
1205 }
1206 unsafe {
1207 let inp = sl_i32(*src, base, len);
1208 let out = sl_mut_i64(*dst, base, len);
1209 for i in 0..len {
1210 out[i] = inp[i] as i64;
1211 }
1212 }
1213 }
1214
1215 Thunk::CastI32ToBool { src, dst, len } => {
1216 let len = *len as usize;
1217 if len == 0 {
1218 continue;
1219 }
1220 // src/dst are byte offsets; i32 = 4 bytes, bool = 1 byte. Copy the
1221 // input first to avoid aliasing the arena while writing the output.
1222 let bytes: Vec<u8> = arena_buf[*src..*src + len * 4].to_vec();
1223 for i in 0..len {
1224 let v = i32::from_le_bytes([
1225 bytes[i * 4],
1226 bytes[i * 4 + 1],
1227 bytes[i * 4 + 2],
1228 bytes[i * 4 + 3],
1229 ]);
1230 arena_buf[*dst + i] = u8::from(v != 0);
1231 }
1232 }
1233
1234 Thunk::CastI64ToBool { src, dst, len } => {
1235 let len = *len as usize;
1236 if len == 0 {
1237 continue;
1238 }
1239 // i64 = 8 bytes, bool = 1 byte — copy first to avoid aliasing.
1240 let bytes: Vec<u8> = arena_buf[*src..*src + len * 8].to_vec();
1241 for i in 0..len {
1242 let v = i64::from_le_bytes([
1243 bytes[i * 8],
1244 bytes[i * 8 + 1],
1245 bytes[i * 8 + 2],
1246 bytes[i * 8 + 3],
1247 bytes[i * 8 + 4],
1248 bytes[i * 8 + 5],
1249 bytes[i * 8 + 6],
1250 bytes[i * 8 + 7],
1251 ]);
1252 arena_buf[*dst + i] = u8::from(v != 0);
1253 }
1254 }
1255
1256 Thunk::CastBoolToI64 { src, dst, len } => {
1257 let len = *len as usize;
1258 if len == 0 {
1259 continue;
1260 }
1261 let bools: Vec<u8> = arena_buf[*src..*src + len].to_vec();
1262 for i in 0..len {
1263 let v = (bools[i] != 0) as i64;
1264 arena_buf[*dst + i * 8..*dst + i * 8 + 8].copy_from_slice(&v.to_le_bytes());
1265 }
1266 }
1267
1268 Thunk::CastBoolToF32 { src, dst, len } => {
1269 let len = *len as usize;
1270 if len == 0 {
1271 continue;
1272 }
1273 unsafe {
1274 let inp = &arena_buf[*src..*src + len];
1275 let out = sl_mut(*dst, base, len);
1276 for i in 0..len {
1277 out[i] = if inp[i] != 0 { 1.0 } else { 0.0 };
1278 }
1279 }
1280 }
1281
1282 Thunk::CastGeneric {
1283 src,
1284 dst,
1285 len,
1286 src_dtype,
1287 dst_dtype,
1288 } => {
1289 let len = *len as usize;
1290 if len == 0 {
1291 continue;
1292 }
1293 exec_cast_generic(*src, *dst, len, *src_dtype, *dst_dtype, base);
1294 }
1295
1296 Thunk::CastF32ToBool { src, dst, len } => {
1297 let len = *len as usize;
1298 if len == 0 {
1299 continue;
1300 }
1301 unsafe {
1302 let inp = sl(*src, base, len).to_vec();
1303 let out = &mut arena_buf[*dst..*dst + len];
1304 for i in 0..len {
1305 out[i] = u8::from(inp[i] != 0.0);
1306 }
1307 }
1308 }
1309
1310 Thunk::BinaryFullF64 { .. } => exec_binary_full_f64(thunk, base),
1311 Thunk::BinaryFullC64 { .. } => exec_binary_full_c64(thunk, base),
1312 Thunk::ComplexNormSqF32 { .. } => exec_complex_norm_sq_f32(thunk, base),
1313 Thunk::ComplexNormSqBackwardF32 { .. } => {
1314 exec_complex_norm_sq_backward_f32(thunk, base)
1315 }
1316 Thunk::ConjugateC64 { .. } => exec_conjugate_c64(thunk, base),
1317 Thunk::ActivationC64 { .. } => exec_activation_c64(thunk, base),
1318 Thunk::Scan { .. } => exec_scan(thunk, base),
1319 Thunk::ScanBackward {
1320 body_vjp,
1321 body_init,
1322 body_carry_in_off,
1323 body_x_offs,
1324 body_d_output_off,
1325 body_dcarry_out_off,
1326 outer_init_off,
1327 outer_traj_off,
1328 outer_upstream_off,
1329 outer_xs_offs,
1330 outer_dinit_off,
1331 length,
1332 carry_bytes,
1333 save_trajectory,
1334 num_checkpoints,
1335 forward_body,
1336 forward_body_init,
1337 forward_body_carry_in_off,
1338 forward_body_output_off,
1339 forward_body_x_offs,
1340 carry_elem_size,
1341 } => {
1342 // Two backward paths share the same per-iteration body
1343 // (body_vjp run + dcarry threading). The "All" path
1344 // reads the carry directly from the saved trajectory
1345 // each step. The "Recursive checkpointing" path stores
1346 // only K saved checkpoints and reconstructs intermediate
1347 // carries via Griewank-style recursive subdivision —
1348 // see [`griewank_process_segment`]. Auxiliary memory
1349 // is `O(log(segment_size) · carry_bytes)` for the
1350 // recursion stack, vs the old segment-cache scheme's
1351 // `O(segment_size · carry_bytes)`. Total recompute work
1352 // grows from `O(length)` to `O(length · log)`, which
1353 // is the canonical Griewank trade.
1354 let cb = *carry_bytes as usize;
1355 let n_steps = *length as usize;
1356 let k_total = *num_checkpoints as usize;
1357 let is_recursive = k_total != 0 && k_total != n_steps;
1358 let checkpoint_t_for_k = |k: usize| -> usize {
1359 ((k + 1) * n_steps)
1360 .div_ceil(k_total)
1361 .saturating_sub(1)
1362 .min(n_steps - 1)
1363 };
1364
1365 let mut fwd_buf: Vec<u8> = if is_recursive {
1366 (**forward_body_init.as_ref().unwrap()).clone()
1367 } else {
1368 Vec::new()
1369 };
1370
1371 let mut dcarry: Vec<u8> = vec![0u8; cb];
1372 if !*save_trajectory {
1373 unsafe {
1374 std::ptr::copy_nonoverlapping(
1375 base.add(*outer_upstream_off),
1376 dcarry.as_mut_ptr(),
1377 cb,
1378 );
1379 }
1380 }
1381
1382 let mut body_buf: Vec<u8> = (**body_init).clone();
1383
1384 // Per-iteration backward action — shared between the
1385 // direct-trajectory (All) and Griewank (Recursive) paths.
1386 // Both feed the same body_vjp run with carry-at-t,
1387 // x_t_i, and d_output, then thread dcarry backward.
1388 let process_iter =
1389 |t: usize, carry_in: &[u8], dcarry: &mut Vec<u8>, body_buf: &mut Vec<u8>| {
1390 if *save_trajectory {
1391 unsafe {
1392 let up_off = *outer_upstream_off + t * cb;
1393 match *carry_elem_size {
1394 4 => {
1395 let up_ptr = base.add(up_off) as *const f32;
1396 let dc_ptr = dcarry.as_mut_ptr() as *mut f32;
1397 let n_elems = cb / 4;
1398 for i in 0..n_elems {
1399 *dc_ptr.add(i) += *up_ptr.add(i);
1400 }
1401 }
1402 8 => {
1403 let up_ptr = base.add(up_off) as *const f64;
1404 let dc_ptr = dcarry.as_mut_ptr() as *mut f64;
1405 let n_elems = cb / 8;
1406 for i in 0..n_elems {
1407 *dc_ptr.add(i) += *up_ptr.add(i);
1408 }
1409 }
1410 other => panic!(
1411 "ScanBackward: unsupported carry elem size {other} \
1412 (only f32/f64 carries are supported today)"
1413 ),
1414 }
1415 }
1416 }
1417 body_buf[*body_carry_in_off..*body_carry_in_off + cb]
1418 .copy_from_slice(carry_in);
1419 unsafe {
1420 for (i, body_x_off) in body_x_offs.iter().enumerate() {
1421 let (outer_xs_off, per_step_bytes) = outer_xs_offs[i];
1422 let psb = per_step_bytes as usize;
1423 std::ptr::copy_nonoverlapping(
1424 base.add(outer_xs_off + t * psb),
1425 body_buf.as_mut_ptr().add(*body_x_off),
1426 psb,
1427 );
1428 }
1429 std::ptr::copy_nonoverlapping(
1430 dcarry.as_ptr(),
1431 body_buf.as_mut_ptr().add(*body_d_output_off),
1432 cb,
1433 );
1434 }
1435 execute_thunks(body_vjp, body_buf);
1436 unsafe {
1437 std::ptr::copy_nonoverlapping(
1438 body_buf.as_ptr().add(*body_dcarry_out_off),
1439 dcarry.as_mut_ptr(),
1440 cb,
1441 );
1442 }
1443 };
1444
1445 if is_recursive {
1446 // Griewank treeverse path. Process saved-checkpoint
1447 // segments from highest-t to lowest-t; within each,
1448 // recursive binary subdivision via
1449 // `griewank_process_segment`. Auxiliary memory:
1450 // O(log(seg_size) · cb) for the recursion stack
1451 // (vs O(seg_size · cb) for the older segment-cache
1452 // scheme); recompute work: O(seg_size · log).
1453 let leaf_threshold = 4usize;
1454 let fb_sched = forward_body.as_ref().unwrap();
1455 let fb_init = forward_body_init.as_ref().unwrap().as_slice();
1456 let mut segment_end = n_steps - 1;
1457 for seg_k in (0..k_total).rev() {
1458 let segment_start = if seg_k == 0 {
1459 0
1460 } else {
1461 checkpoint_t_for_k(seg_k - 1) + 1
1462 };
1463 let mut anchor: Vec<u8> = vec![0u8; cb];
1464 unsafe {
1465 let src = if seg_k == 0 {
1466 base.add(*outer_init_off)
1467 } else {
1468 base.add(*outer_traj_off + (seg_k - 1) * cb)
1469 };
1470 std::ptr::copy_nonoverlapping(src, anchor.as_mut_ptr(), cb);
1471 }
1472 // Closure adapter for the helper's signature
1473 // (mutably re-borrows dcarry / body_buf each call).
1474 let mut leaf_action = |t: usize, carry_in: &[u8]| {
1475 process_iter(t, carry_in, &mut dcarry, &mut body_buf);
1476 };
1477 unsafe {
1478 griewank_process_segment(
1479 segment_start,
1480 segment_end,
1481 &anchor,
1482 cb,
1483 fb_sched,
1484 fb_init,
1485 *forward_body_carry_in_off,
1486 *forward_body_output_off,
1487 forward_body_x_offs,
1488 base,
1489 outer_xs_offs,
1490 &mut fwd_buf,
1491 leaf_threshold,
1492 &mut leaf_action,
1493 );
1494 }
1495 if seg_k == 0 {
1496 break;
1497 }
1498 segment_end = segment_start - 1;
1499 }
1500 } else {
1501 // All-trajectory path: read each carry directly
1502 // from the saved trajectory buffer.
1503 let mut carry_buf: Vec<u8> = vec![0u8; cb];
1504 for t in (0..n_steps).rev() {
1505 unsafe {
1506 let src = if t == 0 {
1507 base.add(*outer_init_off)
1508 } else {
1509 base.add(*outer_traj_off + (t - 1) * cb)
1510 };
1511 std::ptr::copy_nonoverlapping(src, carry_buf.as_mut_ptr(), cb);
1512 }
1513 process_iter(t, &carry_buf, &mut dcarry, &mut body_buf);
1514 }
1515 }
1516
1517 unsafe {
1518 std::ptr::copy_nonoverlapping(dcarry.as_ptr(), base.add(*outer_dinit_off), cb);
1519 }
1520 }
1521
1522 Thunk::ScanBackwardXs { .. } => exec_scan_backward_xs(thunk, base),
1523 Thunk::FusedMmBiasAct { .. } => exec_fused_mm_bias_act(thunk, base),
1524 Thunk::FusedResidualLN {
1525 x,
1526 res,
1527 bias,
1528 g,
1529 b,
1530 out,
1531 rows,
1532 h,
1533 eps,
1534 has_bias,
1535 } => {
1536 let (rows, h) = (*rows as usize, *h as usize);
1537 unsafe {
1538 let zero = &zero_bias[..h];
1539 let bi = if *has_bias { sl(*bias, base, h) } else { zero };
1540 let x_ptr = sl(*x, base, rows * h).as_ptr() as usize;
1541 let r_ptr = sl(*res, base, rows * h).as_ptr() as usize;
1542 let o_ptr = sl_mut(*out, base, rows * h).as_mut_ptr() as usize;
1543 let bi_ptr = bi.as_ptr() as usize;
1544 let g_ptr = sl(*g, base, h).as_ptr() as usize;
1545 let b_ptr = sl(*b, base, h).as_ptr() as usize;
1546 let e = *eps;
1547 crate::pool::par_for(rows, 4, &|off, cnt| {
1548 let xs =
1549 std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
1550 let rs =
1551 std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
1552 let os = std::slice::from_raw_parts_mut(
1553 (o_ptr as *mut f32).add(off * h),
1554 cnt * h,
1555 );
1556 let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
1557 let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
1558 let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
1559 crate::kernels::residual_bias_layer_norm(xs, rs, bi, g, b, os, cnt, h, e);
1560 });
1561 }
1562 }
1563
1564 Thunk::FusedResidualRmsNorm {
1565 x,
1566 res,
1567 bias,
1568 g,
1569 b,
1570 out,
1571 rows,
1572 h,
1573 eps,
1574 has_bias,
1575 } => {
1576 let (rows, h) = (*rows as usize, *h as usize);
1577 unsafe {
1578 let zero = &zero_bias[..h];
1579 let bi = if *has_bias { sl(*bias, base, h) } else { zero };
1580 let x_ptr = sl(*x, base, rows * h).as_ptr() as usize;
1581 let r_ptr = sl(*res, base, rows * h).as_ptr() as usize;
1582 let o_ptr = sl_mut(*out, base, rows * h).as_mut_ptr() as usize;
1583 let bi_ptr = bi.as_ptr() as usize;
1584 let g_ptr = sl(*g, base, h).as_ptr() as usize;
1585 let b_ptr = sl(*b, base, h).as_ptr() as usize;
1586 let e = *eps;
1587 crate::pool::par_for(rows, 4, &|off, cnt| {
1588 let xs =
1589 std::slice::from_raw_parts((x_ptr as *const f32).add(off * h), cnt * h);
1590 let rs =
1591 std::slice::from_raw_parts((r_ptr as *const f32).add(off * h), cnt * h);
1592 let os = std::slice::from_raw_parts_mut(
1593 (o_ptr as *mut f32).add(off * h),
1594 cnt * h,
1595 );
1596 let bi = std::slice::from_raw_parts(bi_ptr as *const f32, h);
1597 let g = std::slice::from_raw_parts(g_ptr as *const f32, h);
1598 let b = std::slice::from_raw_parts(b_ptr as *const f32, h);
1599 crate::kernels::residual_bias_rms_norm(xs, rs, bi, g, b, os, cnt, h, e);
1600 });
1601 }
1602 }
1603
1604 Thunk::BiasAdd { .. } => exec_bias_add(thunk, base),
1605 Thunk::BinaryFull {
1606 lhs,
1607 rhs,
1608 dst,
1609 len,
1610 lhs_len,
1611 rhs_len,
1612 op,
1613 out_dims_bcast,
1614 bcast_lhs_strides,
1615 bcast_rhs_strides,
1616 elem_bytes,
1617 } => {
1618 let len = *len as usize;
1619 let ll = (*lhs_len as usize).max(1);
1620 let rl = (*rhs_len as usize).max(1);
1621 let eb = (*elem_bytes).max(1) as usize;
1622 let arena_len = arena_buf.len();
1623 let ll = ll.min((arena_len.saturating_sub(*lhs)) / eb);
1624 let rl = rl.min((arena_len.saturating_sub(*rhs)) / eb);
1625 let len = len.min((arena_len.saturating_sub(*dst)) / eb);
1626 unsafe {
1627 if eb == 8 {
1628 let l = sl_i64(*lhs, base, ll);
1629 let r = sl_i64(*rhs, base, rl);
1630 let o = sl_mut_i64(*dst, base, len);
1631 // Same fused-index + hoisted-op + parallel treatment as
1632 // the f32 path below (bit-exact; elements independent).
1633 let rank = out_dims_bcast.len();
1634 let odb = &out_dims_bcast[..];
1635 let lstr = &bcast_lhs_strides[..];
1636 let rstr = &bcast_rhs_strides[..];
1637 let idx = |i: usize| -> (usize, usize) {
1638 if rank == 0 {
1639 let li = if ll == 1 { 0 } else { i % ll };
1640 let ri = if rl == 1 { 0 } else { i % rl };
1641 (li, ri)
1642 } else {
1643 let mut rem = i;
1644 let (mut li, mut ri) = (0usize, 0usize);
1645 for ax in (0..rank).rev() {
1646 let sz = odb[ax] as usize;
1647 let c = rem % sz;
1648 rem /= sz;
1649 li += c * lstr[ax] as usize;
1650 ri += c * rstr[ax] as usize;
1651 }
1652 (li, ri)
1653 }
1654 };
1655 macro_rules! bini64 {
1656 ($f:expr) => {{
1657 let f = $f;
1658 if len >= 8192 {
1659 use rayon::prelude::*;
1660 o.par_iter_mut().enumerate().for_each(|(i, out)| {
1661 let (li, ri) = idx(i);
1662 *out = f(l[li], r[ri]);
1663 });
1664 } else {
1665 for i in 0..len {
1666 let (li, ri) = idx(i);
1667 o[i] = f(l[li], r[ri]);
1668 }
1669 }
1670 }};
1671 }
1672 match op {
1673 BinaryOp::Add => bini64!(|a: i64, b: i64| a.wrapping_add(b)),
1674 BinaryOp::Sub => bini64!(|a: i64, b: i64| a.wrapping_sub(b)),
1675 BinaryOp::Mul => bini64!(|a: i64, b: i64| a.wrapping_mul(b)),
1676 BinaryOp::Div => {
1677 bini64!(|a: i64, b: i64| if b == 0 { 0 } else { a / b })
1678 }
1679 BinaryOp::Max => bini64!(|a: i64, b: i64| a.max(b)),
1680 BinaryOp::Min => bini64!(|a: i64, b: i64| a.min(b)),
1681 BinaryOp::Pow => bini64!(|a: i64, b: i64| a.pow(b.max(0) as u32)),
1682 BinaryOp::Mod => bini64!(|a: i64, b: i64| a % b),
1683 // atan2 is float-only; unreachable on integer tensors.
1684 BinaryOp::Atan2 => {
1685 bini64!(|a: i64, b: i64| (a as f64).atan2(b as f64) as i64)
1686 }
1687 BinaryOp::BitAnd => bini64!(|a: i64, b: i64| a & b),
1688 BinaryOp::BitOr => bini64!(|a: i64, b: i64| a | b),
1689 BinaryOp::BitXor => bini64!(|a: i64, b: i64| a ^ b),
1690 BinaryOp::Shl => bini64!(|a: i64, b: i64| a.wrapping_shl(b as u32)),
1691 BinaryOp::Shr => bini64!(|a: i64, b: i64| a.wrapping_shr(b as u32)),
1692 }
1693 } else {
1694 let l = sl(*lhs, base, ll);
1695 let r = sl(*rhs, base, rl);
1696 let o = sl_mut(*dst, base, len);
1697 if ll == len && rl == len {
1698 #[cfg(target_arch = "aarch64")]
1699 if matches!(op, BinaryOp::Add | BinaryOp::Mul) {
1700 use std::arch::aarch64::*;
1701 let chunks = len / 4;
1702 for c in 0..chunks {
1703 let off = c * 4;
1704 let vl = vld1q_f32(l.as_ptr().add(off));
1705 let vr = vld1q_f32(r.as_ptr().add(off));
1706 let res = match op {
1707 BinaryOp::Add => vaddq_f32(vl, vr),
1708 BinaryOp::Mul => vmulq_f32(vl, vr),
1709 _ => unreachable!(),
1710 };
1711 vst1q_f32(o.as_mut_ptr().add(off), res);
1712 }
1713 for i in (chunks * 4)..len {
1714 o[i] = match op {
1715 BinaryOp::Add => l[i] + r[i],
1716 BinaryOp::Mul => l[i] * r[i],
1717 _ => unreachable!(),
1718 };
1719 }
1720 continue;
1721 }
1722 // x86: contiguous same-shape Add/Mul/Sub — AVX2 when
1723 // available, else a plain parallel loop (no N-D index walk).
1724 #[cfg(target_arch = "x86_64")]
1725 if matches!(op, BinaryOp::Add | BinaryOp::Mul | BinaryOp::Sub) {
1726 let used = binary_contig_f32(l, r, o, *op);
1727 if used {
1728 continue;
1729 }
1730 }
1731 // Contiguous fallback for other ops / arches: skip
1732 // broadcast index math entirely.
1733 macro_rules! bin_contig {
1734 ($f:expr) => {{
1735 let f = $f;
1736 if len >= 8192 {
1737 use rayon::prelude::*;
1738 o.par_iter_mut()
1739 .zip(l.par_iter())
1740 .zip(r.par_iter())
1741 .for_each(|((out, a), b)| *out = f(*a, *b));
1742 } else {
1743 for i in 0..len {
1744 o[i] = f(l[i], r[i]);
1745 }
1746 }
1747 }};
1748 }
1749 match op {
1750 BinaryOp::Add => bin_contig!(|a: f32, b: f32| a + b),
1751 BinaryOp::Sub => bin_contig!(|a: f32, b: f32| a - b),
1752 BinaryOp::Mul => bin_contig!(|a: f32, b: f32| a * b),
1753 BinaryOp::Div => bin_contig!(|a: f32, b: f32| a / b),
1754 BinaryOp::Max => bin_contig!(|a: f32, b: f32| a.max(b)),
1755 BinaryOp::Min => bin_contig!(|a: f32, b: f32| a.min(b)),
1756 BinaryOp::Pow => bin_contig!(|a: f32, b: f32| a.powf(b)),
1757 BinaryOp::Mod => bin_contig!(|a: f32, b: f32| a % b),
1758 BinaryOp::Atan2 => bin_contig!(|a: f32, b: f32| a.atan2(b)),
1759 BinaryOp::BitAnd => {
1760 bin_contig!(|a: f32, b: f32| ((a as i64) & (b as i64)) as f32)
1761 }
1762 BinaryOp::BitOr => {
1763 bin_contig!(|a: f32, b: f32| ((a as i64) | (b as i64)) as f32)
1764 }
1765 BinaryOp::BitXor => {
1766 bin_contig!(|a: f32, b: f32| ((a as i64) ^ (b as i64)) as f32)
1767 }
1768 BinaryOp::Shl => bin_contig!(|a: f32, b: f32| (a as i64)
1769 .wrapping_shl(b as u32)
1770 as f32),
1771 BinaryOp::Shr => bin_contig!(|a: f32, b: f32| (a as i64)
1772 .wrapping_shr(b as u32)
1773 as f32),
1774 }
1775 continue;
1776 }
1777 // Trailing-row broadcast: rhs tiles every `rl` elements
1778 // (bias/scale along last dim). Covers empty dims and the
1779 // common `[…, D]` rhs with leading broadcast strides 0.
1780 if ll == len && rl > 0 && rl < len && len.is_multiple_of(rl) {
1781 let rhs_tile = out_dims_bcast.is_empty()
1782 || (bcast_rhs_strides.len() == out_dims_bcast.len()
1783 && bcast_rhs_strides.last() == Some(&1)
1784 && bcast_rhs_strides
1785 [..bcast_rhs_strides.len().saturating_sub(1)]
1786 .iter()
1787 .all(|&s| s == 0));
1788 if rhs_tile {
1789 let used = binary_row_bcast_f32(l, r, o, *op, rl);
1790 if used {
1791 continue;
1792 }
1793 }
1794 }
1795 // Broadcast / small-operand path. This scalar loop was
1796 // ~88% of a supertonic subgraph's CPU time. Three fixes,
1797 // all bit-exact (each output element is independent):
1798 // 1) fuse the coord decomposition with the stride
1799 // dot-product — one pass, no per-call `coords` Vec,
1800 // no second loop;
1801 // 2) hoist the op-match out of the per-element loop
1802 // (one branch per call, not per element);
1803 // 3) parallelize across cores for large outputs.
1804 let rank = out_dims_bcast.len();
1805 let odb = &out_dims_bcast[..];
1806 let lstr = &bcast_lhs_strides[..];
1807 let rstr = &bcast_rhs_strides[..];
1808 let idx = |i: usize| -> (usize, usize) {
1809 if rank == 0 {
1810 let li = if ll == 1 { 0 } else { i % ll };
1811 let ri = if rl == 1 { 0 } else { i % rl };
1812 (li, ri)
1813 } else {
1814 let mut rem = i;
1815 let (mut li, mut ri) = (0usize, 0usize);
1816 for ax in (0..rank).rev() {
1817 let sz = odb[ax] as usize;
1818 let c = rem % sz;
1819 rem /= sz;
1820 li += c * lstr[ax] as usize;
1821 ri += c * rstr[ax] as usize;
1822 }
1823 (li, ri)
1824 }
1825 };
1826 macro_rules! binf32 {
1827 ($f:expr) => {{
1828 let f = $f;
1829 if len >= 8192 {
1830 use rayon::prelude::*;
1831 o.par_iter_mut().enumerate().for_each(|(i, out)| {
1832 let (li, ri) = idx(i);
1833 *out = f(l[li], r[ri]);
1834 });
1835 } else {
1836 for i in 0..len {
1837 let (li, ri) = idx(i);
1838 o[i] = f(l[li], r[ri]);
1839 }
1840 }
1841 }};
1842 }
1843 match op {
1844 BinaryOp::Add => binf32!(|a: f32, b: f32| a + b),
1845 BinaryOp::Sub => binf32!(|a: f32, b: f32| a - b),
1846 BinaryOp::Mul => binf32!(|a: f32, b: f32| a * b),
1847 BinaryOp::Div => binf32!(|a: f32, b: f32| a / b),
1848 BinaryOp::Max => binf32!(|a: f32, b: f32| a.max(b)),
1849 BinaryOp::Min => binf32!(|a: f32, b: f32| a.min(b)),
1850 BinaryOp::Pow => binf32!(|a: f32, b: f32| a.powf(b)),
1851 BinaryOp::Mod => binf32!(|a: f32, b: f32| a % b),
1852 BinaryOp::Atan2 => binf32!(|a: f32, b: f32| a.atan2(b)),
1853 BinaryOp::BitAnd => {
1854 binf32!(|a: f32, b: f32| ((a as i64) & (b as i64)) as f32)
1855 }
1856 BinaryOp::BitOr => {
1857 binf32!(|a: f32, b: f32| ((a as i64) | (b as i64)) as f32)
1858 }
1859 BinaryOp::BitXor => {
1860 binf32!(|a: f32, b: f32| ((a as i64) ^ (b as i64)) as f32)
1861 }
1862 BinaryOp::Shl => {
1863 binf32!(|a: f32, b: f32| (a as i64).wrapping_shl(b as u32) as f32)
1864 }
1865 BinaryOp::Shr => {
1866 binf32!(|a: f32, b: f32| (a as i64).wrapping_shr(b as u32) as f32)
1867 }
1868 }
1869 }
1870 }
1871 }
1872
1873 Thunk::Gather { .. } => exec_gather(thunk, base),
1874 Thunk::Narrow {
1875 src,
1876 dst,
1877 outer,
1878 src_stride,
1879 dst_stride,
1880 inner,
1881 elem_bytes,
1882 } => {
1883 let (outer, ss, ds, inner, eb) = (
1884 *outer as usize,
1885 *src_stride as usize,
1886 *dst_stride as usize,
1887 *inner as usize,
1888 *elem_bytes as usize,
1889 );
1890 let row_bytes = inner.saturating_mul(eb);
1891 let src_row_stride = ss.saturating_mul(eb);
1892 let dst_row_stride = ds.saturating_mul(eb);
1893 if trace_thunks {
1894 eprintln!(
1895 "[narrow] src={} dst={} outer={outer} ss={ss} ds={ds} inner={inner} eb={eb} row={row_bytes} arena={}",
1896 *src,
1897 *dst,
1898 arena_buf.len()
1899 );
1900 }
1901 if row_bytes > 0 && *src != *dst {
1902 let arena_len = arena_buf.len();
1903 // Parallelize independent row copies for large narrows
1904 // (attention head splits, DiT reshape slices).
1905 if outer >= 4
1906 && row_bytes >= 64
1907 && crate::pool::num_threads() > 1
1908 && crate::pool::should_parallelize(outer.saturating_mul(row_bytes / 4))
1909 {
1910 let base_addr = base as usize;
1911 let src0 = *src;
1912 let dst0 = *dst;
1913 crate::pool::par_for(outer, 1, &|off, cnt| {
1914 for o in off..off + cnt {
1915 let s_off = src0 + o * src_row_stride;
1916 let d_off = dst0 + o * dst_row_stride;
1917 if s_off == d_off {
1918 continue;
1919 }
1920 if s_off.saturating_add(row_bytes) > arena_len
1921 || d_off.saturating_add(row_bytes) > arena_len
1922 {
1923 break;
1924 }
1925 unsafe {
1926 std::ptr::copy_nonoverlapping(
1927 (base_addr as *const u8).add(s_off),
1928 (base_addr as *mut u8).add(d_off),
1929 row_bytes,
1930 );
1931 }
1932 }
1933 });
1934 } else {
1935 for o in 0..outer {
1936 let s_off = *src + o * src_row_stride;
1937 let d_off = *dst + o * dst_row_stride;
1938 if s_off == d_off {
1939 continue;
1940 }
1941 if s_off.saturating_add(row_bytes) > arena_len
1942 || d_off.saturating_add(row_bytes) > arena_len
1943 {
1944 break;
1945 }
1946 unsafe {
1947 std::ptr::copy_nonoverlapping(
1948 base.add(s_off),
1949 base.add(d_off),
1950 row_bytes,
1951 );
1952 }
1953 }
1954 }
1955 }
1956 }
1957
1958 Thunk::RoundHalf {
1959 src,
1960 dst,
1961 len,
1962 bf16,
1963 } => {
1964 let len = *len as usize;
1965 if len == 0 {
1966 continue;
1967 }
1968 let inp = unsafe { sl(*src, base, len).to_vec() };
1969 let out = unsafe { sl_mut(*dst, base, len) };
1970 for (o, v) in out.iter_mut().zip(&inp) {
1971 *o = if *bf16 {
1972 half::bf16::from_f32(*v).to_f32()
1973 } else {
1974 half::f16::from_f32(*v).to_f32()
1975 };
1976 }
1977 }
1978
1979 Thunk::Copy { src, dst, len } => {
1980 let mut len = *len as usize;
1981 if *src == *dst || len == 0 {
1982 continue;
1983 }
1984 let arena_len = arena_buf.len();
1985 let max_from_src = (arena_len.saturating_sub(*src)) / 4;
1986 let max_from_dst = (arena_len.saturating_sub(*dst)) / 4;
1987 len = len.min(max_from_src).min(max_from_dst);
1988 if len == 0 {
1989 continue;
1990 }
1991 let byte_len = len.saturating_mul(4);
1992 // Parallel memcpy for huge arena moves (DiT residual buffers).
1993 if len >= 262_144 && crate::pool::num_threads() > 1 {
1994 let base_addr = base as usize;
1995 let src0 = *src;
1996 let dst0 = *dst;
1997 crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
1998 let n = cnt.saturating_mul(4);
1999 unsafe {
2000 std::ptr::copy(
2001 (base_addr as *const u8).add(src0 + off * 4),
2002 (base_addr as *mut u8).add(dst0 + off * 4),
2003 n,
2004 );
2005 }
2006 });
2007 } else {
2008 unsafe {
2009 std::ptr::copy(base.add(*src), base.add(*dst), byte_len);
2010 }
2011 }
2012 }
2013
2014 Thunk::LayerNorm { .. } => exec_layer_norm(thunk, base),
2015 Thunk::GroupNorm { .. } => exec_group_norm(thunk, base),
2016 Thunk::BatchNormInference { .. } => exec_batch_norm_inference(thunk, base),
2017 Thunk::LayerNorm2d { .. } => exec_layer_norm2d(thunk, base),
2018 Thunk::ConvTranspose2d { .. } => exec_conv_transpose2d(thunk, base),
2019 Thunk::ResizeNearest2x { .. } => exec_resize_nearest2x(thunk, base),
2020 Thunk::Interpolate3d { .. } => exec_interpolate3d(thunk, base),
2021 Thunk::AxialRope2d { .. } => exec_axial_rope2d(thunk, base),
2022 Thunk::RmsNorm { .. } => exec_rms_norm(thunk, base),
2023 Thunk::AdaLayerNorm { .. } => exec_ada_layer_norm(thunk, base),
2024 Thunk::GatedResidual { .. } => exec_gated_residual(thunk, base),
2025 Thunk::AdaLayerNormBackward { .. } => exec_ada_layer_norm_backward(thunk, base),
2026 Thunk::GatedResidualBackward { .. } => exec_gated_residual_backward(thunk, base),
2027 Thunk::Softmax { .. } => exec_softmax(thunk, base),
2028 Thunk::Cumsum { .. } => exec_cumsum(thunk, base),
2029 Thunk::CumScan { .. } => exec_cum_scan(thunk, base),
2030 Thunk::Sample { .. } => exec_sample(thunk, base),
2031 Thunk::RngNormal {
2032 dst,
2033 len,
2034 mean,
2035 scale,
2036 key,
2037 op_seed,
2038 } => {
2039 let n = *len as usize;
2040 unsafe {
2041 let out = sl_mut(*dst, base, n);
2042 let opts = *schedule.rng.read().unwrap();
2043 rlx_ir::fill_normal_like(out, *mean, *scale, opts, *key, *op_seed);
2044 }
2045 }
2046
2047 Thunk::RngUniform {
2048 dst,
2049 len,
2050 low,
2051 high,
2052 key,
2053 op_seed,
2054 } => {
2055 let n = *len as usize;
2056 unsafe {
2057 let out = sl_mut(*dst, base, n);
2058 let opts = *schedule.rng.read().unwrap();
2059 rlx_ir::fill_uniform_like(out, *low, *high, opts, *key, *op_seed);
2060 }
2061 }
2062
2063 Thunk::GatedDeltaNet { .. } => exec_gated_delta_net(thunk, base),
2064 Thunk::GatedDeltaNetBackward { .. } => exec_gated_delta_net_backward(thunk, base),
2065 Thunk::Lstm { .. } => exec_lstm(thunk, base),
2066 Thunk::Gru { .. } => exec_gru(thunk, base),
2067 Thunk::Rnn { .. } => exec_rnn(thunk, base),
2068 Thunk::Mamba2 { .. } => exec_mamba2(thunk, base),
2069 Thunk::SelectiveScan { .. } => exec_selective_scan(thunk, base),
2070 Thunk::DequantMatMul { .. } => exec_dequant_mat_mul(thunk, base),
2071 Thunk::SynthMatMul { .. } => exec_synth_mat_mul(thunk, base),
2072 Thunk::SplineActivation { .. } => exec_spline_activation(thunk, base),
2073 Thunk::DequantMatMulGguf { .. } => exec_dequant_mat_mul_gguf(thunk, base),
2074 Thunk::DequantMatMulInt4 { .. } => exec_dequant_mat_mul_int4(thunk, base),
2075 Thunk::DequantMatMulFp8 { .. } => exec_dequant_mat_mul_fp8(thunk, base),
2076 Thunk::DequantMatMulNvfp4 { .. } => exec_dequant_mat_mul_nvfp4(thunk, base),
2077 Thunk::DequantMatMulMxFp4x2 { .. } => exec_dequant_mat_mul_mxfp4x2(thunk, base),
2078 Thunk::DequantMatMulMlx { .. } => exec_dequant_mat_mul_mlx(thunk, base),
2079 Thunk::ScaledMatMul { .. } => exec_scaled_mat_mul(thunk, base),
2080 Thunk::ScaledGroupedMatMul { .. } => exec_scaled_grouped_mat_mul(thunk, base),
2081 Thunk::ScaledQuantize { .. } => exec_scaled_quantize(thunk, base),
2082 Thunk::ScaledQuantScale { .. } => exec_scaled_quant_scale(thunk, base),
2083 Thunk::ScaledDequantize { .. } => exec_scaled_dequantize(thunk, base),
2084 Thunk::LoraMatMul { .. } => exec_lora_mat_mul(thunk, base),
2085 Thunk::Attention {
2086 q,
2087 k,
2088 v,
2089 mask,
2090 out,
2091 batch,
2092 seq,
2093 kv_seq,
2094 heads,
2095 head_dim,
2096 v_head_dim,
2097 mask_kind,
2098 scale,
2099 softcap,
2100 q_row_stride,
2101 k_row_stride,
2102 v_row_stride,
2103 bhsd,
2104 kv_heads,
2105 } => {
2106 let (b, q_s, k_s, nh, dh) = (
2107 *batch as usize,
2108 *seq as usize,
2109 *kv_seq as usize,
2110 *heads as usize,
2111 *head_dim as usize,
2112 );
2113 // V/output per-head width (== dh for symmetric SDPA; MLA differs).
2114 let dh_v = *v_head_dim as usize;
2115 let nkv = (*kv_heads as usize).max(1);
2116 let group = (nh / nkv).max(1); // query heads per KV head (GQA/MQA)
2117 let hs_v = nh * dh_v; // output row stride (V-width)
2118 // For [B, H, S, D] layout each (b, h) tile is dense
2119 // contiguous; the qrs/krs/vrs strides are not used.
2120 let (qrs, krs, vrs) = if *bhsd {
2121 (dh, dh, dh_v)
2122 } else {
2123 (
2124 *q_row_stride as usize,
2125 *k_row_stride as usize,
2126 *v_row_stride as usize,
2127 )
2128 };
2129 let bhsd = *bhsd;
2130 let _ = (q_row_stride, k_row_stride, v_row_stride);
2131 let scale = *scale;
2132 let ss = q_s * k_s;
2133 let cfg = crate::config::RuntimeConfig::global();
2134 unsafe {
2135 // Slice lengths cover the strided span. When Q/K/V
2136 // alias the parent QKV (post-#46-fusion), the same
2137 // bytes back all three slices — compiler bounds
2138 // checks see the right size. For [B, H, S, D] the
2139 // buffer is densely B*H*S*D elements; the row
2140 // strides aren't used.
2141 let q_len = if bhsd {
2142 b * nh * q_s * dh
2143 } else {
2144 b * q_s * qrs
2145 };
2146 let k_len = if bhsd {
2147 b * nkv * k_s * dh
2148 } else {
2149 b * k_s * krs
2150 };
2151 let v_len = if bhsd {
2152 b * nkv * k_s * dh_v
2153 } else {
2154 b * k_s * vrs
2155 };
2156 let q_data = sl(*q, base, q_len);
2157 let k_data = sl(*k, base, k_len);
2158 let v_data = sl(*v, base, v_len);
2159 let mask_data: &[f32] = match mask_kind {
2160 rlx_ir::op::MaskKind::Custom => sl(*mask, base, b * k_s),
2161 rlx_ir::op::MaskKind::Bias => sl(*mask, base, b * nh * q_s * k_s),
2162 _ => &[],
2163 };
2164 let out_len = if bhsd {
2165 b * nh * q_s * dh_v
2166 } else {
2167 b * q_s * hs_v
2168 };
2169 let out_data = sl_mut(*out, base, out_len);
2170
2171 // ── [B, H, S, D] fallback ──────────────────────
2172 // The NEON / strided-BLAS specializations below
2173 // are written for the [B, S, H, D] layout. When
2174 // the input is head-major ([B, H, S, D] —
2175 // matching rlx-cuda / rlx-rocm / rlx-tpu), bypass
2176 // them and run a simple (correct but slower)
2177 // scalar implementation. Production-CPU inference
2178 // graphs use [B, S, H, D] so they still hit the
2179 // hot path; cross-backend parity tests use
2180 // [B, H, S, D] and land here.
2181 if bhsd {
2182 let scores = &mut sdpa_scores[..ss];
2183 for bi in 0..b {
2184 for hi in 0..nh {
2185 let kv_hi = hi / group; // GQA/MQA: shared KV head
2186 let q_head_base = bi * nh * q_s * dh + hi * q_s * dh;
2187 let k_head_base = bi * nkv * k_s * dh + kv_hi * k_s * dh;
2188 // Q@K^T
2189 for qi in 0..q_s {
2190 let q_base = q_head_base + qi * dh;
2191 for ki in 0..k_s {
2192 let k_base = k_head_base + ki * dh;
2193 let mut dot = 0f32;
2194 for d in 0..dh {
2195 dot += q_data[q_base + d] * k_data[k_base + d];
2196 }
2197 scores[qi * k_s + ki] = dot * scale;
2198 if matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
2199 && !mask_data.is_empty()
2200 && mask_data[bi * k_s + ki] < mask_thr
2201 {
2202 scores[qi * k_s + ki] = mask_neg;
2203 }
2204 }
2205 }
2206 if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) {
2207 let off = (bi * nh + hi) * q_s * k_s;
2208 for i in 0..q_s * k_s {
2209 scores[i] += mask_data[off + i];
2210 }
2211 }
2212 apply_synthetic_mask(scores, q_s, k_s, *mask_kind);
2213 // Gemma 2 attention logit soft-cap (post-mask, pre-softmax).
2214 if *softcap > 0.0 {
2215 for s in scores.iter_mut() {
2216 *s = *softcap * (*s / *softcap).tanh();
2217 }
2218 }
2219 crate::kernels::neon_softmax(scores, q_s, k_s);
2220 // score @ V — V and output are dh_v-wide per head.
2221 let o_head_base = bi * nh * q_s * dh_v + hi * q_s * dh_v;
2222 let v_head_base = bi * nkv * k_s * dh_v + kv_hi * k_s * dh_v;
2223 for qi in 0..q_s {
2224 let o_base = o_head_base + qi * dh_v;
2225 for d in 0..dh_v {
2226 out_data[o_base + d] = 0.0;
2227 }
2228 for ki in 0..k_s {
2229 let sc = scores[qi * k_s + ki];
2230 if sc > score_thr {
2231 let v_base = v_head_base + ki * dh_v;
2232 for d in 0..dh_v {
2233 out_data[o_base + d] += sc * v_data[v_base + d];
2234 }
2235 }
2236 }
2237 }
2238 }
2239 }
2240 continue;
2241 }
2242
2243 // ── Decode (one query row) on x86-64: a task per head ──
2244 // Short rows take the sequential path below, which runs
2245 // every head on one thread; long ones take the strided
2246 // path, which without a BLAS is two loop-nest GEMMs. Both
2247 // compute the Q·K dot for padded keys whose score the
2248 // Custom mask then overwrites. With one query row the
2249 // heads are independent, so split them over the pool and
2250 // skip the dot for masked keys — the bulk of a decode
2251 // bucket until the context fills it. Per element this is
2252 // the sequential path's arithmetic (bit-identical there),
2253 // with eight keys' dots run side by side in AVX lanes
2254 // (`dots_by_key_lanes`); past the threshold it is taken
2255 // only without a BLAS, at a 512-row bucket ~11 ms of a
2256 // ~30 ms step on three cores.
2257 #[cfg(target_arch = "x86_64")]
2258 if b == 1
2259 && q_s == 1
2260 && nh > 1
2261 && (k_s <= cfg.sdpa_seq_threshold || cfg!(not(rlx_cpu_blas)))
2262 && *softcap == 0.0
2263 && matches!(
2264 mask_kind,
2265 rlx_ir::op::MaskKind::Custom | rlx_ir::op::MaskKind::None
2266 )
2267 {
2268 let custom = matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
2269 && !mask_data.is_empty();
2270 let (q_d, k_d, v_d) = (q_data, k_data, v_data);
2271 let live: Vec<usize> = (0..k_s)
2272 .filter(|&ki| !custom || mask_data[ki] >= mask_thr)
2273 .collect();
2274 // 8-wide softmax chunks holding a live key (all of
2275 // them without a mask): the rest are masked only.
2276 let mut live_chunks: Vec<usize> = live.iter().map(|&ki| ki / 8).collect();
2277 live_chunks.dedup();
2278 live_chunks.retain(|&c| c < k_s / 8);
2279 let sparse_softmax = !live.is_empty()
2280 && std::is_x86_feature_detected!("avx2")
2281 && std::is_x86_feature_detected!("fma");
2282 let o_addr = out_data.as_mut_ptr() as usize;
2283 crate::pool::par_for(nh, 1, &|off, cnt| {
2284 let mut scores = vec![0f32; k_s];
2285 let mut dots = vec![0f32; live.len()];
2286 for hi in off..off + cnt {
2287 let kv_hi = hi / group;
2288 let q = &q_d[hi * dh..(hi + 1) * dh];
2289 dots_by_key_lanes(q, k_d, &live, krs, kv_hi * dh, &mut dots);
2290 scores.fill(mask_neg);
2291 for (&ki, &dot) in live.iter().zip(&dots) {
2292 scores[ki] = dot * scale;
2293 }
2294 if sparse_softmax {
2295 // SAFETY: AVX2 + FMA detected above.
2296 crate::kernels::softmax_row_avx2_chunks(
2297 &mut scores,
2298 &live_chunks,
2299 );
2300 } else {
2301 crate::kernels::neon_softmax(&mut scores, 1, k_s);
2302 }
2303 // SAFETY: each head writes only its own
2304 // `dh_v` output columns.
2305 let o = std::slice::from_raw_parts_mut(
2306 (o_addr as *mut f32).add(hi * dh_v),
2307 dh_v,
2308 );
2309 o.fill(0.0);
2310 for &ki in &live {
2311 let sc = scores[ki];
2312 if sc > score_thr {
2313 let v_off = ki * vrs + kv_hi * dh_v;
2314 for d in 0..dh_v {
2315 o[d] += sc * v_d[v_off + d];
2316 }
2317 }
2318 }
2319 }
2320 });
2321 continue;
2322 }
2323
2324 // ── Auto-select kernel: NEON dots vs strided BLAS ───
2325 // For tiny inputs (batch=1, short seq), per-head BLAS call
2326 // overhead (~0.5µs × 2 calls × num_heads × num_layers)
2327 // exceeds the NEON compute cost. Use direct strided NEON
2328 // with zero dispatch overhead.
2329 // For batch≥2: always BLAS + par_for (parallelism wins).
2330 if b == 1 && q_s.max(k_s) <= cfg.sdpa_seq_threshold {
2331 // ── Sequential NEON path (zero overhead) ──
2332 let scores = &mut sdpa_scores[..ss];
2333 #[cfg(target_arch = "aarch64")]
2334 let neon_chunks = dh / 4;
2335 #[cfg(target_arch = "aarch64")]
2336 let neon_chunks_v = dh_v / 4;
2337
2338 for bi in 0..b {
2339 for hi in 0..nh {
2340 let kv_hi = hi / group; // GQA/MQA: shared KV head
2341 // Q@K^T via strided NEON dot products
2342 for qi in 0..q_s {
2343 let q_off = bi * q_s * qrs + qi * qrs + hi * dh;
2344 for ki in 0..k_s {
2345 let k_off = bi * k_s * krs + ki * krs + kv_hi * dh;
2346 #[cfg(target_arch = "aarch64")]
2347 let mut dot;
2348 #[cfg(not(target_arch = "aarch64"))]
2349 let mut dot = 0f32;
2350 #[cfg(target_arch = "aarch64")]
2351 {
2352 use std::arch::aarch64::*;
2353 let mut acc = vdupq_n_f32(0.0);
2354 for c in 0..neon_chunks {
2355 let vq =
2356 vld1q_f32(q_data.as_ptr().add(q_off + c * 4));
2357 let vk =
2358 vld1q_f32(k_data.as_ptr().add(k_off + c * 4));
2359 acc = vfmaq_f32(acc, vq, vk);
2360 }
2361 dot = vaddvq_f32(acc);
2362 for d in (neon_chunks * 4)..dh {
2363 dot += q_data[q_off + d] * k_data[k_off + d];
2364 }
2365 }
2366 #[cfg(not(target_arch = "aarch64"))]
2367 for d in 0..dh {
2368 dot += q_data[q_off + d] * k_data[k_off + d];
2369 }
2370 scores[qi * k_s + ki] = dot * scale;
2371 // Inner-loop Custom mask check —
2372 // Causal / SlidingWindow / None
2373 // apply outside the loop below.
2374 // Skip for Bias — that mask is a
2375 // per-head additive tensor, not a
2376 // 0/1 key-padding mask.
2377 if matches!(mask_kind, rlx_ir::op::MaskKind::Custom)
2378 && !mask_data.is_empty()
2379 && mask_data[bi * k_s + ki] < mask_thr
2380 {
2381 scores[qi * k_s + ki] = mask_neg;
2382 }
2383 }
2384 }
2385
2386 if matches!(mask_kind, rlx_ir::op::MaskKind::Bias) {
2387 let off = (bi * nh + hi) * q_s * k_s;
2388 for i in 0..q_s * k_s {
2389 scores[i] += mask_data[off + i];
2390 }
2391 }
2392 apply_synthetic_mask(scores, q_s, k_s, *mask_kind);
2393 crate::kernels::neon_softmax(scores, q_s, k_s);
2394
2395 // Score@V via strided NEON accumulation (zero-copy).
2396 // V/output are dh_v-wide per head (== dh unless MLA).
2397 for qi in 0..q_s {
2398 let o_off = bi * q_s * hs_v + qi * hs_v + hi * dh_v;
2399 // Zero output for this head position
2400 for d in 0..dh_v {
2401 out_data[o_off + d] = 0.0;
2402 }
2403 for ki in 0..k_s {
2404 let sc = scores[qi * k_s + ki];
2405 if sc > score_thr {
2406 let v_off = bi * k_s * vrs + ki * vrs + kv_hi * dh_v;
2407 #[cfg(target_arch = "aarch64")]
2408 {
2409 use std::arch::aarch64::*;
2410 let vsc = vdupq_n_f32(sc);
2411 for c in 0..neon_chunks_v {
2412 let off = c * 4;
2413 let vo = vld1q_f32(
2414 out_data.as_ptr().add(o_off + off),
2415 );
2416 let vv =
2417 vld1q_f32(v_data.as_ptr().add(v_off + off));
2418 vst1q_f32(
2419 out_data.as_mut_ptr().add(o_off + off),
2420 vfmaq_f32(vo, vsc, vv),
2421 );
2422 }
2423 // scalar tail if dh_v not a multiple of 4
2424 for d in (neon_chunks_v * 4)..dh_v {
2425 out_data[o_off + d] += sc * v_data[v_off + d];
2426 }
2427 }
2428 #[cfg(not(target_arch = "aarch64"))]
2429 for d in 0..dh_v {
2430 out_data[o_off + d] += sc * v_data[v_off + d];
2431 }
2432 }
2433 }
2434 }
2435 }
2436 }
2437 } else {
2438 // ── Parallel strided BLAS path (high throughput) ──
2439 let total_work = b * nh;
2440 let q_addr = q_data.as_ptr() as usize;
2441 let k_addr = k_data.as_ptr() as usize;
2442 let v_addr = v_data.as_ptr() as usize;
2443 let m_addr = mask_data.as_ptr() as usize;
2444 let o_addr = out_data.as_mut_ptr() as usize;
2445 let sc_addr = sdpa_scores.as_mut_ptr() as usize;
2446
2447 crate::pool::par_for(total_work, 1, &|off, cnt| {
2448 for idx in off..off + cnt {
2449 let bi = idx / nh;
2450 let hi = idx % nh;
2451 let kv_hi = hi / group; // GQA/MQA: shared KV head
2452
2453 let q_start = (q_addr as *const f32).add(bi * q_s * qrs + hi * dh);
2454 let k_start =
2455 (k_addr as *const f32).add(bi * k_s * krs + kv_hi * dh);
2456 // V/output are dh_v-wide per head (== dh unless MLA).
2457 let v_start =
2458 (v_addr as *const f32).add(bi * k_s * vrs + kv_hi * dh_v);
2459 let o_start = (o_addr as *mut f32).add(bi * q_s * hs_v + hi * dh_v);
2460 let sc = std::slice::from_raw_parts_mut(
2461 (sc_addr as *mut f32).add(idx * ss),
2462 ss,
2463 );
2464
2465 // LDA = qrs, LDB = krs (parent row strides
2466 // when fused; hs otherwise).
2467 crate::blas::sgemm_general(
2468 q_start,
2469 k_start,
2470 sc.as_mut_ptr(),
2471 q_s,
2472 k_s,
2473 dh,
2474 scale,
2475 0.0,
2476 qrs,
2477 krs,
2478 k_s,
2479 false,
2480 true,
2481 );
2482
2483 match mask_kind {
2484 rlx_ir::op::MaskKind::Custom => {
2485 let mask_bi = std::slice::from_raw_parts(
2486 (m_addr as *const f32).add(bi * k_s),
2487 k_s,
2488 );
2489 for ki in 0..k_s {
2490 if mask_bi[ki] < mask_thr {
2491 for qi in 0..q_s {
2492 sc[qi * k_s + ki] = mask_neg;
2493 }
2494 }
2495 }
2496 }
2497 rlx_ir::op::MaskKind::Bias => {
2498 // Per-head additive bias slice.
2499 let bias = std::slice::from_raw_parts(
2500 (m_addr as *const f32).add((bi * nh + hi) * q_s * k_s),
2501 q_s * k_s,
2502 );
2503 for i in 0..q_s * k_s {
2504 sc[i] += bias[i];
2505 }
2506 }
2507 _ => apply_synthetic_mask(sc, q_s, k_s, *mask_kind),
2508 }
2509
2510 crate::kernels::neon_softmax(sc, q_s, k_s);
2511
2512 // score@V: N = dh_v (output/V width), LDB = vrs
2513 // (V row stride), LDC = hs_v (output row stride).
2514 crate::blas::sgemm_general(
2515 sc.as_ptr(),
2516 v_start,
2517 o_start,
2518 q_s,
2519 dh_v,
2520 k_s,
2521 1.0,
2522 0.0,
2523 k_s,
2524 vrs,
2525 hs_v,
2526 false,
2527 false,
2528 );
2529 }
2530 });
2531 }
2532 }
2533 }
2534
2535 Thunk::AttentionBackward { .. } => exec_attention_backward(thunk, base),
2536 Thunk::AttentionBackwardAll { .. } => exec_attention_backward_all(thunk, base),
2537 Thunk::ActivationInPlace { .. } => exec_activation_in_place(thunk, base),
2538 Thunk::FusedAttnBlock {
2539 hidden,
2540 qkv_w,
2541 out_w,
2542 mask,
2543 mask_kind,
2544 out,
2545 qkv_b,
2546 out_b,
2547 cos,
2548 sin,
2549 cos_len,
2550 batch,
2551 seq,
2552 hs,
2553 nh,
2554 dh,
2555 in_dim,
2556 out_dim,
2557 has_bias,
2558 has_rope,
2559 interleaved,
2560 } => {
2561 let (b, s) = (*batch as usize, *seq as usize);
2562 let (h, n_h, d_h) = (*hs as usize, *nh as usize, *dh as usize);
2563 // QKV-proj input width and out-proj output width — differ from
2564 // `h`(=hs) for MoonViT-style attention (hidden ≠ nh·dh).
2565 let (in_dim, out_dim) = (*in_dim as usize, *out_dim as usize);
2566 let interleaved = *interleaved;
2567 let m = b * s;
2568 let scale = (d_h as f32).powf(-0.5);
2569 let half = d_h / 2;
2570 // Only `Custom` consumes the per-key padding buffer; `Causal` /
2571 // `SlidingWindow` are synthesized from (qi, ki) below, and have
2572 // no mask buffer (so reading one would touch unrelated arena
2573 // bytes). q_seq == kv_seq here (guaranteed at fusion time), so
2574 // the absolute query position is just `qi`.
2575 let use_custom_mask = matches!(mask_kind, rlx_ir::op::MaskKind::Custom);
2576 unsafe {
2577 let inp = sl(*hidden, base, m * in_dim);
2578 let wq = sl(*qkv_w, base, in_dim * 3 * h);
2579 let wo = sl(*out_w, base, h * out_dim);
2580 let mk = if use_custom_mask {
2581 sl(*mask, base, b * s)
2582 } else {
2583 &[]
2584 };
2585 let dst = sl_mut(*out, base, m * out_dim);
2586
2587 // Stack-allocated intermediates — all fit in L1 cache for small batch
2588 let mut qkv = vec![0f32; m * 3 * h];
2589 let mut attn_out = vec![0f32; m * h];
2590
2591 // 1. QKV projection: [m, in_dim] @ [in_dim, 3h] → [m, 3h]
2592 crate::blas::par_sgemm(inp, wq, &mut qkv, m, in_dim, 3 * h);
2593 if *has_bias {
2594 let bias = sl(*qkv_b, base, 3 * h);
2595 crate::blas::bias_add(&mut qkv, bias, m, 3 * h);
2596 }
2597
2598 // 2. Multi-head SDPA — PARALLEL across (batch, head). Each
2599 // task owns its scores buffer and writes a DISJOINT
2600 // head-slice of attn_out (`o_base` is unique per (bi,hi)),
2601 // so the raw-pointer writes never alias; Q/K/V/cos/sin/mask
2602 // are read-only. Serial per-head SDPA lost ~3× to the
2603 // unfused Attention thunk (which is per-head parallel) on
2604 // wide heads — e.g. MoonViT 12×128 @ seq 64.
2605 #[cfg(target_arch = "aarch64")]
2606 let neon_chunks = d_h / 4;
2607 let qkv_ptr = qkv.as_ptr() as usize;
2608 let out_ptr = attn_out.as_mut_ptr() as usize;
2609 let mk_ptr = mk.as_ptr() as usize;
2610 let mk_len = mk.len();
2611 let base_addr = base as usize;
2612 let (cos_off, sin_off, cl) = (*cos, *sin, *cos_len as usize);
2613 let has_rope_c = *has_rope;
2614 let mask_kind_c = *mask_kind;
2615 let n_tasks = b * n_h;
2616 crate::pool::par_for(n_tasks, 1, &|off, cnt| {
2617 // Read-only views (multiple immutable aliases are fine).
2618 let qkv = std::slice::from_raw_parts(qkv_ptr as *const f32, m * 3 * h);
2619 let out = out_ptr as *mut f32;
2620 let base = base_addr as *mut u8;
2621 let (cos_tab, sin_tab): (&[f32], &[f32]) = if has_rope_c {
2622 (sl(cos_off, base, cl), sl(sin_off, base, cl))
2623 } else {
2624 (&[], &[])
2625 };
2626 let mk = std::slice::from_raw_parts(mk_ptr as *const f32, mk_len);
2627 let mut scores = vec![0f32; s * s];
2628 for t in off..off + cnt {
2629 let bi = t / n_h;
2630 let hi = t % n_h;
2631 for qi in 0..s {
2632 let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
2633 for ki in 0..s {
2634 let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
2635 let mut dot = 0f32;
2636 if has_rope_c {
2637 // Inline RoPE per pair, then dot. Pair offsets:
2638 // NeoX (i, i+half) GPT-J (2i, 2i+1).
2639 let q_cos = qi * half;
2640 let k_cos = ki * half;
2641 for i in 0..half {
2642 let (qo1, qo2, ko1, ko2) = if interleaved {
2643 (2 * i, 2 * i + 1, 2 * i, 2 * i + 1)
2644 } else {
2645 (i, half + i, i, half + i)
2646 };
2647 let q1 = qkv[q_base + qo1];
2648 let q2 = qkv[q_base + qo2];
2649 let k1 = qkv[k_base + ko1];
2650 let k2 = qkv[k_base + ko2];
2651 let c_q = cos_tab[q_cos + i];
2652 let s_q = sin_tab[q_cos + i];
2653 let c_k = cos_tab[k_cos + i];
2654 let s_k = sin_tab[k_cos + i];
2655 let qr1 = q1 * c_q - q2 * s_q;
2656 let kr1 = k1 * c_k - k2 * s_k;
2657 let qr2 = q2 * c_q + q1 * s_q;
2658 let kr2 = k2 * c_k + k1 * s_k;
2659 dot += qr1 * kr1 + qr2 * kr2;
2660 }
2661 } else {
2662 #[cfg(target_arch = "aarch64")]
2663 {
2664 use std::arch::aarch64::*;
2665 let mut acc = vdupq_n_f32(0.0);
2666 for c in 0..neon_chunks {
2667 let vq =
2668 vld1q_f32(qkv.as_ptr().add(q_base + c * 4));
2669 let vk =
2670 vld1q_f32(qkv.as_ptr().add(k_base + c * 4));
2671 acc = vfmaq_f32(acc, vq, vk);
2672 }
2673 dot = vaddvq_f32(acc);
2674 for d in (neon_chunks * 4)..d_h {
2675 dot += qkv[q_base + d] * qkv[k_base + d];
2676 }
2677 }
2678 #[cfg(not(target_arch = "aarch64"))]
2679 for d in 0..d_h {
2680 dot += qkv[q_base + d] * qkv[k_base + d];
2681 }
2682 }
2683 let mut sv = dot * scale;
2684 let pos_masked = match mask_kind_c {
2685 rlx_ir::op::MaskKind::Causal => ki > qi,
2686 rlx_ir::op::MaskKind::SlidingWindow(w) => {
2687 ki > qi || ki + w < qi
2688 }
2689 _ => false,
2690 };
2691 if pos_masked || (use_custom_mask && mk[bi * s + ki] < mask_thr)
2692 {
2693 sv = mask_neg;
2694 }
2695 scores[qi * s + ki] = sv;
2696 }
2697 }
2698 crate::kernels::neon_softmax(&mut scores[..s * s], s, s);
2699 // Score @ V accumulation (V at offset 2h in QKV).
2700 for qi in 0..s {
2701 let o_base = bi * s * h + qi * h + hi * d_h;
2702 for d in 0..d_h {
2703 *out.add(o_base + d) = 0.0;
2704 }
2705 for ki in 0..s {
2706 let sc = scores[qi * s + ki];
2707 if sc > score_thr {
2708 let v_base = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
2709 #[cfg(target_arch = "aarch64")]
2710 {
2711 use std::arch::aarch64::*;
2712 let vsc = vdupq_n_f32(sc);
2713 for c in 0..neon_chunks {
2714 let o = c * 4;
2715 let vo = vld1q_f32(out.add(o_base + o));
2716 let vv = vld1q_f32(qkv.as_ptr().add(v_base + o));
2717 vst1q_f32(
2718 out.add(o_base + o),
2719 vfmaq_f32(vo, vsc, vv),
2720 );
2721 }
2722 }
2723 #[cfg(not(target_arch = "aarch64"))]
2724 for d in 0..d_h {
2725 *out.add(o_base + d) += sc * qkv[v_base + d];
2726 }
2727 }
2728 }
2729 }
2730 }
2731 });
2732
2733 // 3. Output projection: [m, h] @ [h, out_dim] → dst
2734 crate::blas::par_sgemm(&attn_out, wo, dst, m, h, out_dim);
2735 if *has_bias {
2736 let bias = sl(*out_b, base, out_dim);
2737 crate::blas::bias_add(dst, bias, m, out_dim);
2738 }
2739 }
2740 }
2741
2742 Thunk::Rope { .. } => exec_rope(thunk, base),
2743 Thunk::FusedBertLayer {
2744 hidden,
2745 qkv_w,
2746 qkv_b,
2747 out_w,
2748 out_b,
2749 mask,
2750 ln1_g,
2751 ln1_b,
2752 eps1,
2753 fc1_w,
2754 fc1_b,
2755 fc2_w,
2756 fc2_b,
2757 ln2_g,
2758 ln2_b,
2759 eps2,
2760 out,
2761 batch,
2762 seq,
2763 hs,
2764 nh,
2765 dh,
2766 int_dim,
2767 } => {
2768 let (b, s, h, n_h, d_h) = (
2769 *batch as usize,
2770 *seq as usize,
2771 *hs as usize,
2772 *nh as usize,
2773 *dh as usize,
2774 );
2775 let m = b * s;
2776 let id = *int_dim as usize;
2777 let scale = (d_h as f32).powf(-0.5);
2778 let _half = d_h / 2;
2779 #[cfg(target_arch = "aarch64")]
2780 let neon_chunks = d_h / 4;
2781 unsafe {
2782 let inp = sl(*hidden, base, m * h);
2783 let dst = sl_mut(*out, base, m * h);
2784 let mk = sl(*mask, base, b * s);
2785
2786 // Pre-allocated buffers (zero malloc per layer — allocated once before thunk loop)
2787 let qkv = std::slice::from_raw_parts_mut(fl_qkv.as_mut_ptr(), m * 3 * h);
2788 let attn = std::slice::from_raw_parts_mut(fl_attn.as_mut_ptr(), m * h);
2789 let res = std::slice::from_raw_parts_mut(fl_res.as_mut_ptr(), m * h);
2790 let normed = std::slice::from_raw_parts_mut(fl_normed.as_mut_ptr(), m * h);
2791 let ffn = std::slice::from_raw_parts_mut(fl_ffn.as_mut_ptr(), m * id);
2792 let sc = std::slice::from_raw_parts_mut(fl_sc.as_mut_ptr(), s * s);
2793
2794 // QKV (parallelized across cores — multiple AMX coprocessors)
2795 crate::blas::par_sgemm_bias(
2796 inp,
2797 sl(*qkv_w, base, h * 3 * h),
2798 sl(*qkv_b, base, 3 * h),
2799 qkv,
2800 m,
2801 h,
2802 3 * h,
2803 );
2804
2805 // SDPA per head (sequential NEON, inline — zero overhead)
2806 for bi in 0..b {
2807 for hi in 0..n_h {
2808 for qi in 0..s {
2809 for ki in 0..s {
2810 let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
2811 let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
2812 #[cfg(target_arch = "aarch64")]
2813 let dot;
2814 #[cfg(not(target_arch = "aarch64"))]
2815 let mut dot = 0f32;
2816 #[cfg(target_arch = "aarch64")]
2817 {
2818 use std::arch::aarch64::*;
2819 let mut acc = vdupq_n_f32(0.0);
2820 for c in 0..neon_chunks {
2821 acc = vfmaq_f32(
2822 acc,
2823 vld1q_f32(qkv.as_ptr().add(q_base + c * 4)),
2824 vld1q_f32(qkv.as_ptr().add(k_base + c * 4)),
2825 );
2826 }
2827 dot = vaddvq_f32(acc);
2828 }
2829 #[cfg(not(target_arch = "aarch64"))]
2830 for d in 0..d_h {
2831 dot += qkv[q_base + d] * qkv[k_base + d];
2832 }
2833 sc[qi * s + ki] = dot * scale;
2834 if mk[bi * s + ki] < mask_thr {
2835 sc[qi * s + ki] = mask_neg;
2836 }
2837 }
2838 }
2839 crate::kernels::neon_softmax(&mut sc[..s * s], s, s);
2840 for qi in 0..s {
2841 let o = bi * s * h + qi * h + hi * d_h;
2842 for d in 0..d_h {
2843 attn[o + d] = 0.0;
2844 }
2845 for ki in 0..s {
2846 let w = sc[qi * s + ki];
2847 if w > score_thr {
2848 let v = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
2849 #[cfg(target_arch = "aarch64")]
2850 {
2851 use std::arch::aarch64::*;
2852 let vw = vdupq_n_f32(w);
2853 for c in 0..neon_chunks {
2854 let off = c * 4;
2855 vst1q_f32(
2856 attn.as_mut_ptr().add(o + off),
2857 vfmaq_f32(
2858 vld1q_f32(attn.as_ptr().add(o + off)),
2859 vw,
2860 vld1q_f32(qkv.as_ptr().add(v + off)),
2861 ),
2862 );
2863 }
2864 }
2865 #[cfg(not(target_arch = "aarch64"))]
2866 for d in 0..d_h {
2867 attn[o + d] += w * qkv[v + d];
2868 }
2869 }
2870 }
2871 }
2872 }
2873 }
2874
2875 // Out proj (sgemm + bias fused) + residual add with NEON
2876 crate::blas::sgemm_bias(
2877 attn,
2878 sl(*out_w, base, h * h),
2879 sl(*out_b, base, h),
2880 res,
2881 m,
2882 h,
2883 h,
2884 );
2885 #[cfg(target_arch = "aarch64")]
2886 {
2887 use std::arch::aarch64::*;
2888 let chunks_h = (m * h) / 4;
2889 for c in 0..chunks_h {
2890 let off = c * 4;
2891 vst1q_f32(
2892 res.as_mut_ptr().add(off),
2893 vaddq_f32(
2894 vld1q_f32(res.as_ptr().add(off)),
2895 vld1q_f32(inp.as_ptr().add(off)),
2896 ),
2897 );
2898 }
2899 for i in (chunks_h * 4)..(m * h) {
2900 res[i] += inp[i];
2901 }
2902 }
2903 #[cfg(not(target_arch = "aarch64"))]
2904 for i in 0..m * h {
2905 res[i] += inp[i];
2906 }
2907
2908 // LN1 (fused residual already done above — just normalize)
2909 let g1 = sl(*ln1_g, base, h);
2910 let b1 = sl(*ln1_b, base, h);
2911 crate::kernels::layer_norm_rows(&res[..], g1, b1, &mut normed[..], m, h, *eps1);
2912
2913 // FFN: fc1 (parallel across cores) + GELU
2914 crate::blas::par_sgemm_bias(
2915 normed,
2916 sl(*fc1_w, base, h * id),
2917 sl(*fc1_b, base, id),
2918 ffn,
2919 m,
2920 h,
2921 id,
2922 );
2923 crate::kernels::par_gelu_inplace(ffn);
2924
2925 // fc2 + bias (parallel across cores) + residual with NEON
2926 crate::blas::par_sgemm_bias(
2927 ffn,
2928 sl(*fc2_w, base, id * h),
2929 sl(*fc2_b, base, h),
2930 res,
2931 m,
2932 id,
2933 h,
2934 );
2935 #[cfg(target_arch = "aarch64")]
2936 {
2937 use std::arch::aarch64::*;
2938 let chunks_h = (m * h) / 4;
2939 for c in 0..chunks_h {
2940 let off = c * 4;
2941 vst1q_f32(
2942 res.as_mut_ptr().add(off),
2943 vaddq_f32(
2944 vld1q_f32(res.as_ptr().add(off)),
2945 vld1q_f32(normed.as_ptr().add(off)),
2946 ),
2947 );
2948 }
2949 for i in (chunks_h * 4)..(m * h) {
2950 res[i] += normed[i];
2951 }
2952 }
2953 #[cfg(not(target_arch = "aarch64"))]
2954 for i in 0..m * h {
2955 res[i] += normed[i];
2956 }
2957
2958 // LN2 → output
2959 let g2 = sl(*ln2_g, base, h);
2960 let b2 = sl(*ln2_b, base, h);
2961 crate::kernels::layer_norm_rows(&res[..], g2, b2, &mut dst[..], m, h, *eps2);
2962 }
2963 }
2964
2965 Thunk::FusedNomicLayer {
2966 hidden,
2967 qkv_w,
2968 out_w,
2969 mask,
2970 cos,
2971 sin,
2972 cos_len,
2973 ln1_g,
2974 ln1_b,
2975 eps1,
2976 fc11_w,
2977 fc12_w: _,
2978 fc2_w,
2979 ln2_g,
2980 ln2_b,
2981 eps2,
2982 out,
2983 batch,
2984 seq,
2985 hs,
2986 nh,
2987 dh,
2988 int_dim,
2989 interleaved,
2990 } => {
2991 let interleaved = *interleaved;
2992 let (b, s, h, n_h, d_h) = (
2993 *batch as usize,
2994 *seq as usize,
2995 *hs as usize,
2996 *nh as usize,
2997 *dh as usize,
2998 );
2999 let m = b * s;
3000 let id = *int_dim as usize;
3001 let scale = (d_h as f32).powf(-0.5);
3002 let half_dh = d_h / 2;
3003 #[cfg(target_arch = "aarch64")]
3004 let neon_chunks = d_h / 4;
3005 unsafe {
3006 let inp = sl(*hidden, base, m * h);
3007 let dst = sl_mut(*out, base, m * h);
3008 let mk = sl(*mask, base, b * s);
3009 let cos_tab = sl(*cos, base, *cos_len as usize);
3010 let sin_tab = sl(*sin, base, *cos_len as usize);
3011 // fc11_w is the fused [h, 2*int_dim] weight (fc11 || fc12 concatenated)
3012 let fused_fc_w = sl(*fc11_w, base, h * 2 * id);
3013
3014 let mut qkv = vec![0f32; m * 3 * h];
3015 let mut attn = vec![0f32; m * h];
3016 let mut res = vec![0f32; m * h];
3017 let mut normed = vec![0f32; m * h];
3018 let mut ffn_concat = vec![0f32; m * 2 * id]; // fc11||fc12 output
3019 let mut sc = vec![0f32; s * s];
3020
3021 // QKV (no bias)
3022 crate::blas::sgemm(inp, sl(*qkv_w, base, h * 3 * h), &mut qkv, m, h, 3 * h);
3023
3024 // SDPA with inline RoPE
3025 for bi in 0..b {
3026 for hi in 0..n_h {
3027 for qi in 0..s {
3028 for ki in 0..s {
3029 let q_base = bi * s * 3 * h + qi * 3 * h + hi * d_h;
3030 let k_base = bi * s * 3 * h + ki * 3 * h + h + hi * d_h;
3031 let mut dot = 0f32;
3032 for i in 0..half_dh {
3033 // NeoX pairs (i, i+half); GPT-J pairs (2i, 2i+1).
3034 let (o1, o2) = if interleaved {
3035 (2 * i, 2 * i + 1)
3036 } else {
3037 (i, half_dh + i)
3038 };
3039 let q1 = qkv[q_base + o1];
3040 let q2 = qkv[q_base + o2];
3041 let k1 = qkv[k_base + o1];
3042 let k2 = qkv[k_base + o2];
3043 let cq = cos_tab[qi * half_dh + i];
3044 let sq = sin_tab[qi * half_dh + i];
3045 let ck = cos_tab[ki * half_dh + i];
3046 let sk = sin_tab[ki * half_dh + i];
3047 dot += (q1 * cq - q2 * sq) * (k1 * ck - k2 * sk)
3048 + (q2 * cq + q1 * sq) * (k2 * ck + k1 * sk);
3049 }
3050 sc[qi * s + ki] = dot * scale;
3051 if mk[bi * s + ki] < mask_thr {
3052 sc[qi * s + ki] = mask_neg;
3053 }
3054 }
3055 }
3056 crate::kernels::neon_softmax(&mut sc[..s * s], s, s);
3057 for qi in 0..s {
3058 let o = bi * s * h + qi * h + hi * d_h;
3059 for d in 0..d_h {
3060 attn[o + d] = 0.0;
3061 }
3062 for ki in 0..s {
3063 let w = sc[qi * s + ki];
3064 if w > score_thr {
3065 let v = bi * s * 3 * h + ki * 3 * h + 2 * h + hi * d_h;
3066 #[cfg(target_arch = "aarch64")]
3067 {
3068 use std::arch::aarch64::*;
3069 let vw = vdupq_n_f32(w);
3070 for c in 0..neon_chunks {
3071 let off = c * 4;
3072 vst1q_f32(
3073 attn.as_mut_ptr().add(o + off),
3074 vfmaq_f32(
3075 vld1q_f32(attn.as_ptr().add(o + off)),
3076 vw,
3077 vld1q_f32(qkv.as_ptr().add(v + off)),
3078 ),
3079 );
3080 }
3081 }
3082 #[cfg(not(target_arch = "aarch64"))]
3083 for d in 0..d_h {
3084 attn[o + d] += w * qkv[v + d];
3085 }
3086 }
3087 }
3088 }
3089 }
3090 }
3091
3092 // Out proj (no bias) + residual
3093 crate::blas::sgemm(&attn, sl(*out_w, base, h * h), &mut res, m, h, h);
3094 for i in 0..m * h {
3095 res[i] += inp[i];
3096 }
3097
3098 // LN1
3099 let g1 = sl(*ln1_g, base, h);
3100 let b1 = sl(*ln1_b, base, h);
3101 crate::kernels::layer_norm_rows(&res[..], g1, b1, &mut normed[..], m, h, *eps1);
3102
3103 // SwiGLU: fused fc11+fc12 sgemm, then split, silu, mul
3104 crate::blas::sgemm(&normed, fused_fc_w, &mut ffn_concat, m, h, 2 * id);
3105 // Split: first id cols = fc11 (up), second id cols = fc12 (gate)
3106 // SiLU on gate, then multiply up * gate → store in up region
3107 for row in 0..m {
3108 let bo = row * 2 * id;
3109 // SiLU in-place on gate portion
3110 for j in 0..id {
3111 let x = ffn_concat[bo + id + j];
3112 ffn_concat[bo + id + j] = x / (1.0 + (-x).exp());
3113 }
3114 // Multiply: up[j] *= gate[j]
3115 for j in 0..id {
3116 ffn_concat[bo + j] *= ffn_concat[bo + id + j];
3117 }
3118 }
3119
3120 // fc2 (no bias) + residual. The up*silu(gate) product lives in
3121 // the FIRST `id` cols of each 2*id-wide ffn_concat row; gather
3122 // it contiguous and run the SAME `sgemm` dispatch the unfused
3123 // path uses (a strided `sgemm_general` here would force the
3124 // BLAS/scalar path and diverge from the unfused NEON sgemm).
3125 let mut swiglu_contig = vec![0f32; m * id];
3126 for row in 0..m {
3127 let bo = row * 2 * id;
3128 swiglu_contig[row * id..(row + 1) * id]
3129 .copy_from_slice(&ffn_concat[bo..bo + id]);
3130 }
3131 crate::blas::sgemm(
3132 &swiglu_contig,
3133 sl(*fc2_w, base, id * h),
3134 &mut res,
3135 m,
3136 id,
3137 h,
3138 );
3139 for i in 0..m * h {
3140 res[i] += normed[i];
3141 }
3142
3143 // LN2 → output
3144 let g2 = sl(*ln2_g, base, h);
3145 let b2 = sl(*ln2_b, base, h);
3146 crate::kernels::layer_norm_rows(&res[..], g2, b2, &mut dst[..], m, h, *eps2);
3147 }
3148 }
3149
3150 Thunk::FusedSwiGLU { .. } => exec_fused_swi_g_l_u(thunk, base),
3151 Thunk::Concat { .. } => exec_concat(thunk, base),
3152 Thunk::KvAppend { .. } => exec_kv_append(thunk, base),
3153 Thunk::ConcatF64 { .. } => exec_concat_f64(thunk, base),
3154 Thunk::Compare {
3155 lhs,
3156 rhs,
3157 dst,
3158 len,
3159 op,
3160 inputs_i64,
3161 inputs_elem_bytes,
3162 dst_elem_bytes,
3163 lhs_scalar,
3164 rhs_scalar,
3165 lhs_len,
3166 rhs_len,
3167 out_dims_bcast,
3168 bcast_lhs_strides,
3169 bcast_rhs_strides,
3170 } => {
3171 let len = *len as usize;
3172 let arena_len = arena_buf.len();
3173 let elem = (*inputs_elem_bytes).max(1) as usize;
3174 let dst_eb = (*dst_elem_bytes).max(1) as usize;
3175 let max_l = (arena_len.saturating_sub(*lhs)) / elem;
3176 let max_r = (arena_len.saturating_sub(*rhs)) / elem;
3177 let max_d = (arena_len.saturating_sub(*dst)) / dst_eb;
3178 if !out_dims_bcast.is_empty() {
3179 // Shape-aware broadcast (partial size-1 axes, e.g. `[.,L,1]`
3180 // vs `[.,L,L]`). Read each operand at its TRUE element count
3181 // and index via row-major coords · strides — the flat path
3182 // below would read past a partial operand into adjacent
3183 // arena memory. Mirrors `BinaryFull`'s broadcast loop.
3184 let len = len.min(max_d);
3185 let l_read = (*lhs_len as usize).min(max_l).max(1);
3186 let r_read = (*rhs_len as usize).min(max_r).max(1);
3187 let rank = out_dims_bcast.len();
3188 let bidx = |i: usize| -> (usize, usize) {
3189 let mut rem = i;
3190 let (mut li, mut ri) = (0usize, 0usize);
3191 for ax in (0..rank).rev() {
3192 let sz = out_dims_bcast[ax] as usize;
3193 let c = rem % sz;
3194 rem /= sz;
3195 li += c * bcast_lhs_strides[ax] as usize;
3196 ri += c * bcast_rhs_strides[ax] as usize;
3197 }
3198 (li, ri)
3199 };
3200 macro_rules! do_cmp {
3201 ($a:expr, $b:expr) => {
3202 match op {
3203 CmpOp::Eq => $a == $b,
3204 CmpOp::Ne => $a != $b,
3205 CmpOp::Lt => $a < $b,
3206 CmpOp::Le => $a <= $b,
3207 CmpOp::Gt => $a > $b,
3208 CmpOp::Ge => $a >= $b,
3209 }
3210 };
3211 }
3212 macro_rules! put {
3213 ($i:expr, $v:expr) => {
3214 if *dst_elem_bytes == 1 {
3215 arena_buf[*dst + $i] = u8::from($v);
3216 } else {
3217 let o = sl_mut(*dst, base, len);
3218 o[$i] = if $v { 1.0 } else { 0.0 };
3219 }
3220 };
3221 }
3222 if elem == 1 {
3223 let l = arena_buf[*lhs..*lhs + l_read].to_vec();
3224 let r = arena_buf[*rhs..*rhs + r_read].to_vec();
3225 for i in 0..len {
3226 let (li, ri) = bidx(i);
3227 let v = do_cmp!(l[li], r[ri]);
3228 unsafe { put!(i, v) }
3229 }
3230 } else if *inputs_i64 != 0 {
3231 unsafe {
3232 let l = sl_i64(*lhs, base, l_read);
3233 let r = sl_i64(*rhs, base, r_read);
3234 for i in 0..len {
3235 let (li, ri) = bidx(i);
3236 let v = do_cmp!(l[li], r[ri]);
3237 put!(i, v)
3238 }
3239 }
3240 } else {
3241 unsafe {
3242 let l = sl(*lhs, base, l_read);
3243 let r = sl(*rhs, base, r_read);
3244 for i in 0..len {
3245 let (li, ri) = bidx(i);
3246 let v = do_cmp!(l[li], r[ri]);
3247 put!(i, v)
3248 }
3249 }
3250 }
3251 } else {
3252 let l_n = if *lhs_scalar { 1 } else { len };
3253 let r_n = if *rhs_scalar { 1 } else { len };
3254 // Keep full `len` when broadcasting scalars — only the
3255 // non-scalar operands (and dst) may shrink the loop.
3256 let mut len = len.min(max_d);
3257 if *lhs_scalar {
3258 if max_l < 1 {
3259 len = 0;
3260 }
3261 } else {
3262 len = len.min(max_l);
3263 }
3264 if *rhs_scalar {
3265 if max_r < 1 {
3266 len = 0;
3267 }
3268 } else {
3269 len = len.min(max_r);
3270 }
3271 if trace_thunks && len > 0 {
3272 eprintln!(
3273 "[compare] len={len} lhs={} rhs={} dst={} ls={} rs={}",
3274 *lhs, *rhs, *dst, *lhs_scalar, *rhs_scalar
3275 );
3276 }
3277 if elem == 1 {
3278 let l = arena_buf[*lhs..*lhs + l_n.min(max_l).max(1)].to_vec();
3279 let r = arena_buf[*rhs..*rhs + r_n.min(max_r).max(1)].to_vec();
3280 for i in 0..len {
3281 let li = if *lhs_scalar { 0 } else { i };
3282 let ri = if *rhs_scalar { 0 } else { i };
3283 let v = match op {
3284 CmpOp::Eq => l[li] == r[ri],
3285 CmpOp::Ne => l[li] != r[ri],
3286 CmpOp::Lt => l[li] < r[ri],
3287 CmpOp::Le => l[li] <= r[ri],
3288 CmpOp::Gt => l[li] > r[ri],
3289 CmpOp::Ge => l[li] >= r[ri],
3290 };
3291 if *dst_elem_bytes == 1 {
3292 arena_buf[*dst + i] = u8::from(v);
3293 } else {
3294 unsafe {
3295 let o = sl_mut(*dst, base, len);
3296 o[i] = if v { 1.0 } else { 0.0 };
3297 }
3298 }
3299 }
3300 } else if *inputs_i64 != 0 {
3301 unsafe {
3302 let l = sl_i64(*lhs, base, l_n.min(max_l).max(1));
3303 let r = sl_i64(*rhs, base, r_n.min(max_r).max(1));
3304 for i in 0..len {
3305 let li = if *lhs_scalar { 0 } else { i };
3306 let ri = if *rhs_scalar { 0 } else { i };
3307 let v = match op {
3308 CmpOp::Eq => l[li] == r[ri],
3309 CmpOp::Ne => l[li] != r[ri],
3310 CmpOp::Lt => l[li] < r[ri],
3311 CmpOp::Le => l[li] <= r[ri],
3312 CmpOp::Gt => l[li] > r[ri],
3313 CmpOp::Ge => l[li] >= r[ri],
3314 };
3315 if *dst_elem_bytes == 1 {
3316 arena_buf[*dst + i] = u8::from(v);
3317 } else {
3318 let o = sl_mut(*dst, base, len);
3319 o[i] = if v { 1.0 } else { 0.0 };
3320 }
3321 }
3322 }
3323 } else {
3324 unsafe {
3325 let l = sl(*lhs, base, l_n.min(max_l).max(1));
3326 let r = sl(*rhs, base, r_n.min(max_r).max(1));
3327 for i in 0..len {
3328 let li = if *lhs_scalar { 0 } else { i };
3329 let ri = if *rhs_scalar { 0 } else { i };
3330 let v = match op {
3331 CmpOp::Eq => l[li] == r[ri],
3332 CmpOp::Ne => l[li] != r[ri],
3333 CmpOp::Lt => l[li] < r[ri],
3334 CmpOp::Le => l[li] <= r[ri],
3335 CmpOp::Gt => l[li] > r[ri],
3336 CmpOp::Ge => l[li] >= r[ri],
3337 };
3338 if *dst_elem_bytes == 1 {
3339 arena_buf[*dst + i] = u8::from(v);
3340 } else {
3341 let o = sl_mut(*dst, base, len);
3342 o[i] = if v { 1.0 } else { 0.0 };
3343 }
3344 }
3345 }
3346 }
3347 }
3348 }
3349
3350 Thunk::Where {
3351 cond,
3352 on_true,
3353 on_false,
3354 dst,
3355 len,
3356 elem_bytes,
3357 cond_elem_bytes,
3358 cond_scalar,
3359 true_scalar,
3360 false_scalar,
3361 } => {
3362 let len = *len as usize;
3363 let eb = *elem_bytes as usize;
3364 let cond_eb = (*cond_elem_bytes).max(1) as usize;
3365 let arena_len = arena_buf.len();
3366 let c_n = if *cond_scalar { 1 } else { len };
3367 let t_n = if *true_scalar { 1 } else { len };
3368 let f_n = if *false_scalar { 1 } else { len };
3369 let max_c = (arena_len.saturating_sub(*cond)) / cond_eb;
3370 let max_t = (arena_len.saturating_sub(*on_true)) / eb;
3371 let max_f = (arena_len.saturating_sub(*on_false)) / eb;
3372 let max_d = (arena_len.saturating_sub(*dst)) / eb;
3373 let mut len = len.min(max_d);
3374 if *cond_scalar {
3375 if max_c < 1 {
3376 len = 0;
3377 }
3378 } else {
3379 len = len.min(max_c);
3380 }
3381 if *true_scalar {
3382 if max_t < 1 {
3383 len = 0;
3384 }
3385 } else {
3386 len = len.min(max_t);
3387 }
3388 if *false_scalar {
3389 if max_f < 1 {
3390 len = 0;
3391 }
3392 } else {
3393 len = len.min(max_f);
3394 }
3395 unsafe {
3396 if *elem_bytes == 8 {
3397 let t = sl_i64(*on_true, base, t_n.min(max_t).max(1));
3398 let e = sl_i64(*on_false, base, f_n.min(max_f).max(1));
3399 let o = sl_mut_i64(*dst, base, len);
3400 if *cond_elem_bytes == 1 {
3401 let c = &arena_buf[*cond..*cond + c_n.min(max_c).max(1)];
3402 for i in 0..len {
3403 let ci = if *cond_scalar { 0 } else { i };
3404 let ti = if *true_scalar { 0 } else { i };
3405 let ei = if *false_scalar { 0 } else { i };
3406 o[i] = if c[ci] != 0 { t[ti] } else { e[ei] };
3407 }
3408 } else if *cond_elem_bytes == 4 {
3409 // Bool-as-f32 masks (f32-uniform arena).
3410 let c = sl(*cond, base, c_n.min(max_c).max(1));
3411 for i in 0..len {
3412 let ci = if *cond_scalar { 0 } else { i };
3413 let ti = if *true_scalar { 0 } else { i };
3414 let ei = if *false_scalar { 0 } else { i };
3415 o[i] = if c[ci] != 0.0 { t[ti] } else { e[ei] };
3416 }
3417 } else {
3418 let c = sl_i64(*cond, base, c_n.min(max_c).max(1));
3419 for i in 0..len {
3420 let ci = if *cond_scalar { 0 } else { i };
3421 let ti = if *true_scalar { 0 } else { i };
3422 let ei = if *false_scalar { 0 } else { i };
3423 o[i] = if c[ci] != 0 { t[ti] } else { e[ei] };
3424 }
3425 }
3426 } else if *cond_elem_bytes == 1 {
3427 let c = &arena_buf[*cond..*cond + c_n.min(max_c).max(1)];
3428 let t = sl(*on_true, base, t_n.min(max_t).max(1));
3429 let e = sl(*on_false, base, f_n.min(max_f).max(1));
3430 let o = sl_mut(*dst, base, len);
3431 for i in 0..len {
3432 let ci = if *cond_scalar { 0 } else { i };
3433 let ti = if *true_scalar { 0 } else { i };
3434 let ei = if *false_scalar { 0 } else { i };
3435 o[i] = if c[ci] != 0 { t[ti] } else { e[ei] };
3436 }
3437 } else {
3438 let c = sl(*cond, base, c_n.min(max_c).max(1));
3439 let t = sl(*on_true, base, t_n.min(max_t).max(1));
3440 let e = sl(*on_false, base, f_n.min(max_f).max(1));
3441 let o = sl_mut(*dst, base, len);
3442 for i in 0..len {
3443 let ci = if *cond_scalar { 0 } else { i };
3444 let ti = if *true_scalar { 0 } else { i };
3445 let ei = if *false_scalar { 0 } else { i };
3446 o[i] = if c[ci] != 0.0 { t[ti] } else { e[ei] };
3447 }
3448 }
3449 }
3450 }
3451
3452 Thunk::Fma {
3453 a,
3454 b,
3455 c,
3456 dst,
3457 len,
3458 elem_bytes,
3459 } => {
3460 let len = *len as usize;
3461 let eb = (*elem_bytes).max(1) as usize;
3462 let arena_len = arena_buf.len();
3463 let len = len
3464 .min(arena_len.saturating_sub(*a) / eb)
3465 .min(arena_len.saturating_sub(*b) / eb)
3466 .min(arena_len.saturating_sub(*c) / eb)
3467 .min(arena_len.saturating_sub(*dst) / eb);
3468 unsafe {
3469 if *elem_bytes == 8 {
3470 let av = sl_f64(*a, base, len);
3471 let bv = sl_f64(*b, base, len);
3472 let cv = sl_f64(*c, base, len);
3473 let o = sl_mut_f64(*dst, base, len);
3474 for i in 0..len {
3475 o[i] = av[i].mul_add(bv[i], cv[i]);
3476 }
3477 } else {
3478 let av = sl(*a, base, len);
3479 let bv = sl(*b, base, len);
3480 let cv = sl(*c, base, len);
3481 let o = sl_mut(*dst, base, len);
3482 for i in 0..len {
3483 o[i] = av[i].mul_add(bv[i], cv[i]);
3484 }
3485 }
3486 }
3487 }
3488
3489 Thunk::ScatterAdd { .. } => exec_scatter_add(thunk, base),
3490 Thunk::ScatterNd { .. } => exec_scatter_nd(thunk, base),
3491 Thunk::ScatterElements { .. } => exec_scatter_elements(thunk, base),
3492 Thunk::GatherNd { .. } => exec_gather_nd(thunk, base),
3493 Thunk::GatherElements { .. } => exec_gather_elements(thunk, base),
3494 Thunk::GroupedMatMul {
3495 input,
3496 weight,
3497 expert_idx,
3498 dst,
3499 m,
3500 k_dim,
3501 n,
3502 num_experts,
3503 w_transposed,
3504 } => {
3505 let m = *m as usize;
3506 let k_dim = *k_dim as usize;
3507 let n = *n as usize;
3508 let num_experts = *num_experts as usize;
3509 let w_transposed = *w_transposed;
3510 unsafe {
3511 let inp = sl(*input, base, m * k_dim);
3512 let wt = sl(*weight, base, num_experts * k_dim * n);
3513 let ids = sl(*expert_idx, base, m);
3514 let out = sl_mut(*dst, base, m * n);
3515
3516 // Counting-sort tokens by their assigned expert.
3517 // counts[e] = how many tokens routed to expert e.
3518 let mut counts = vec![0usize; num_experts];
3519 for i in 0..m {
3520 let e = ids[i] as usize;
3521 debug_assert!(
3522 e < num_experts,
3523 "expert_idx out of range: {e} >= {num_experts}"
3524 );
3525 counts[e] += 1;
3526 }
3527 // Cumulative offsets into the packed buffer.
3528 let mut offsets = vec![0usize; num_experts + 1];
3529 for e in 0..num_experts {
3530 offsets[e + 1] = offsets[e] + counts[e];
3531 }
3532 // Pack: each expert's rows land contiguously in `packed_in`.
3533 // `original_pos[packed_idx] = original_token_idx` for the
3534 // unpermute step at the end.
3535 let mut packed_in = vec![0f32; m * k_dim];
3536 let mut original_pos = vec![0usize; m];
3537 let mut write_idx = vec![0usize; num_experts];
3538 for i in 0..m {
3539 let e = ids[i] as usize;
3540 let dst_row = offsets[e] + write_idx[e];
3541 packed_in[dst_row * k_dim..(dst_row + 1) * k_dim]
3542 .copy_from_slice(&inp[i * k_dim..(i + 1) * k_dim]);
3543 original_pos[dst_row] = i;
3544 write_idx[e] += 1;
3545 }
3546
3547 // One BLAS sgemm per expert. Skip experts with no
3548 // tokens — common at the tail when M is much smaller
3549 // than num_experts × k.
3550 let mut packed_out = vec![0f32; m * n];
3551 let expert_stride = k_dim * n;
3552 let gmm_ord = crate::moe_residency::next_gmm_ord();
3553 let moe_layer = gmm_ord / 3;
3554 for e in 0..num_experts {
3555 let count = counts[e];
3556 if count == 0 {
3557 continue;
3558 }
3559 crate::moe_residency::record_expert_tokens(moe_layer, e, count);
3560 let in_start = offsets[e];
3561 let in_slice = &packed_in[in_start * k_dim..(in_start + count) * k_dim];
3562 let w_slab: &[f32] =
3563 if !crate::moe_residency::expert_on_device_for_layer(moe_layer, e) {
3564 if let Some(ptr) =
3565 crate::moe_residency::host_expert_weight_ptr(gmm_ord, e)
3566 {
3567 // Residency hands over `[K, N]` slabs by
3568 // construction. A folded transpose means the
3569 // bank is `[N, K]`, so the two do not compose
3570 // — and silently using one as the other reads
3571 // a correctly-sized block of the wrong values,
3572 // which no shape check would catch. Refuse.
3573 assert!(
3574 !w_transposed,
3575 "rlx-cpu: MoE host residency supplies [K, N] expert \
3576 slabs, but this GroupedMatMul folded a bank transpose \
3577 and reads [N, K]. The two are not composed; unbind \
3578 residency or disable the fold."
3579 );
3580 std::slice::from_raw_parts(ptr, expert_stride)
3581 } else {
3582 &wt[e * expert_stride..(e + 1) * expert_stride]
3583 }
3584 } else {
3585 &wt[e * expert_stride..(e + 1) * expert_stride]
3586 };
3587 let out_slice = &mut packed_out[in_start * n..(in_start + count) * n];
3588 // Measured: routing this through `par_sgemm` REGRESSES decode
3589 // (0.069 -> 0.085 s/token). At m == 1 the per-call work is a
3590 // 1.5 MFLOP matvec, and decode issues 368 of these per token,
3591 // so ~368 Rayon hand-offs cost more than the extra memory
3592 // parallelism buys. Keep the serial call.
3593 if w_transposed {
3594 // Bank is `[E, N, K]`: B-transposed GEMM, no copy.
3595 crate::blas::sgemm_bt(
3596 in_slice, w_slab, out_slice, count, k_dim, n, 1.0,
3597 );
3598 } else {
3599 crate::blas::sgemm(in_slice, w_slab, out_slice, count, k_dim, n);
3600 }
3601 }
3602
3603 // Unpermute back to original token order.
3604 for packed_idx in 0..m {
3605 let i = original_pos[packed_idx];
3606 out[i * n..(i + 1) * n]
3607 .copy_from_slice(&packed_out[packed_idx * n..(packed_idx + 1) * n]);
3608 }
3609 }
3610 }
3611
3612 Thunk::DequantGroupedMatMulGguf { .. } => {
3613 exec_dequant_grouped_mat_mul_gguf(thunk, base)
3614 }
3615 Thunk::DequantGroupedMatMulMlx { .. } => exec_dequant_grouped_mat_mul_mlx(thunk, base),
3616 Thunk::DequantMoEWeightsGguf { .. } => exec_dequant_mo_e_weights_gguf(thunk, base),
3617 Thunk::TopK {
3618 src,
3619 dst,
3620 outer,
3621 axis_dim,
3622 k,
3623 indices_i64,
3624 } => {
3625 let outer = *outer as usize;
3626 let axis_dim = *axis_dim as usize;
3627 let k = *k as usize;
3628 unsafe {
3629 let inp = sl(*src, base, outer * axis_dim);
3630 // Repeated argmax with masking. O(k * axis_dim) per row;
3631 // good enough for small k (MoE typical k=2–8). For larger
3632 // k a partial heap would win.
3633 let mut row_buf: Vec<f32> = vec![0.0; axis_dim];
3634 if *indices_i64 != 0 {
3635 let out = sl_mut_i64(*dst, base, outer * k);
3636 for o in 0..outer {
3637 row_buf.copy_from_slice(&inp[o * axis_dim..(o + 1) * axis_dim]);
3638 for ki in 0..k {
3639 let mut best_i = 0usize;
3640 let mut best_v = row_buf[0];
3641 for i in 1..axis_dim {
3642 let v = row_buf[i];
3643 if v > best_v {
3644 best_v = v;
3645 best_i = i;
3646 }
3647 }
3648 out[o * k + ki] = best_i as i64;
3649 row_buf[best_i] = f32::NEG_INFINITY;
3650 }
3651 }
3652 } else {
3653 let out = sl_mut(*dst, base, outer * k);
3654 for o in 0..outer {
3655 row_buf.copy_from_slice(&inp[o * axis_dim..(o + 1) * axis_dim]);
3656 for ki in 0..k {
3657 let mut best_i = 0usize;
3658 let mut best_v = row_buf[0];
3659 for i in 1..axis_dim {
3660 let v = row_buf[i];
3661 if v > best_v {
3662 best_v = v;
3663 best_i = i;
3664 }
3665 }
3666 out[o * k + ki] = best_i as f32;
3667 row_buf[best_i] = f32::NEG_INFINITY;
3668 }
3669 }
3670 if let Some(cap) = schedule.moe_topk_capture.as_ref() {
3671 cap.push_topk_f32(&out[..outer * k], axis_dim);
3672 }
3673 }
3674 }
3675 }
3676
3677 Thunk::Reduce { .. } => exec_reduce(thunk, base),
3678 Thunk::Histogram { .. } => exec_histogram(thunk, base),
3679 Thunk::ArgReduce { .. } => exec_arg_reduce(thunk, base),
3680 Thunk::Conv2D1x1 { .. } => exec_conv2_d1x1(thunk, base),
3681 Thunk::Conv2D { .. } => exec_conv2_d(thunk, base),
3682 Thunk::Conv3d { .. } => exec_conv3d(thunk, base),
3683 Thunk::ConvTranspose3d { .. } => exec_conv_transpose3d(thunk, base),
3684 Thunk::Pool2D {
3685 src,
3686 dst,
3687 n,
3688 c,
3689 h,
3690 w,
3691 h_out,
3692 w_out,
3693 kh,
3694 kw,
3695 sh,
3696 sw,
3697 ph,
3698 pw,
3699 kind,
3700 } => {
3701 let n = *n as usize;
3702 let c = *c as usize;
3703 let h = *h as usize;
3704 let w = *w as usize;
3705 let h_out = *h_out as usize;
3706 let w_out = *w_out as usize;
3707 let kh = *kh as usize;
3708 let kw = *kw as usize;
3709 let sh = *sh as usize;
3710 let sw = *sw as usize;
3711 let ph = *ph as usize;
3712 let pw = *pw as usize;
3713 let kernel_area = (kh * kw) as f32;
3714 unsafe {
3715 let inp = sl(*src, base, n * c * h * w);
3716 let out = sl_mut(*dst, base, n * c * h_out * w_out);
3717 // Each (n, c) plane is independent and writes a disjoint
3718 // output region, so pooling fans out over the channel-batch
3719 // when RLX_FAST_CONV is set.
3720 let out_addr = out.as_mut_ptr() as usize;
3721 let is_max = matches!(kind, ReduceOp::Max);
3722 let is_mean = matches!(kind, ReduceOp::Mean);
3723 // No-padding windows are *usually* fully in-bounds (the
3724 // conv-net case), so the hot path drops the per-element
3725 // bounds branches and hoists the reduce-op choice out of the
3726 // loop. "Usually" is not "always": under ONNX `ceil_mode=1`
3727 // the final window may start inside the input and overhang
3728 // its end (259 frames, kernel/stride 100 -> 3 windows, the
3729 // last spanning 200..300), and the fast path then indexes
3730 // past the buffer. Require every window to fit before taking
3731 // it; the general path below already treats the overhang as
3732 // padding, which is what ONNX specifies.
3733 let windows_fit = h_out.saturating_sub(1) * sh + kh <= h
3734 && w_out.saturating_sub(1) * sw + kw <= w;
3735 let nopad = ph == 0 && pw == 0 && windows_fit;
3736 let pool_plane = |nc: usize| {
3737 let ni = nc / c;
3738 let ci = nc % c;
3739 let in_chan = ni * c * h * w + ci * h * w;
3740 let out_chan = ni * c * h_out * w_out + ci * h_out * w_out;
3741 let op = out_addr as *mut f32;
3742 for ho in 0..h_out {
3743 for wo in 0..w_out {
3744 let mut valid = 0.0f32;
3745 let acc = if nopad {
3746 let row0 = in_chan + (ho * sh) * w + wo * sw;
3747 let mut a = if is_max { f32::NEG_INFINITY } else { 0.0 };
3748 for ki in 0..kh {
3749 let row = row0 + ki * w;
3750 if is_max {
3751 for kj in 0..kw {
3752 a = a.max(inp[row + kj]);
3753 }
3754 } else {
3755 for kj in 0..kw {
3756 a += inp[row + kj];
3757 }
3758 }
3759 }
3760 a
3761 } else {
3762 let mut a = if is_max { f32::NEG_INFINITY } else { 0.0 };
3763 for ki in 0..kh {
3764 for kj in 0..kw {
3765 let hi = ho * sh + ki;
3766 let wi = wo * sw + kj;
3767 if hi < ph || wi < pw {
3768 continue;
3769 }
3770 let hi = hi - ph;
3771 let wi = wi - pw;
3772 if hi >= h || wi >= w {
3773 continue;
3774 }
3775 let v = inp[in_chan + hi * w + wi];
3776 if is_max {
3777 a = a.max(v);
3778 } else {
3779 a += v;
3780 valid += 1.0;
3781 }
3782 }
3783 }
3784 a
3785 };
3786 // Average over the elements that actually exist,
3787 // not over the nominal window. ONNX's default is
3788 // `count_include_pad = 0`, and a `ceil_mode`
3789 // window that overhangs the input is not padding
3790 // at all — those positions simply are not there.
3791 // ChatterBox's speaker encoder pools 259 frames
3792 // with kernel/stride 100: the third window holds
3793 // 59 real frames, and dividing it by 100 scaled
3794 // it by 0.59 against the reference.
3795 let denom = if nopad { kernel_area } else { valid.max(1.0) };
3796 let acc = if is_mean { acc / denom } else { acc };
3797 *op.add(out_chan + ho * w_out + wo) = acc;
3798 }
3799 }
3800 };
3801 if fast_conv_enabled() && crate::pool::should_parallelize(n * c * h_out * w_out)
3802 {
3803 crate::pool::par_for(
3804 n * c,
3805 crate::pool::outer_chunk(n * c),
3806 &|off, cnt| {
3807 for nc in off..off + cnt {
3808 pool_plane(nc);
3809 }
3810 },
3811 );
3812 } else {
3813 for nc in 0..n * c {
3814 pool_plane(nc);
3815 }
3816 }
3817 }
3818 }
3819
3820 Thunk::Pool3D {
3821 src,
3822 dst,
3823 n,
3824 c,
3825 d,
3826 h,
3827 w,
3828 d_out,
3829 h_out,
3830 w_out,
3831 kd,
3832 kh,
3833 kw,
3834 sd,
3835 sh,
3836 sw,
3837 pd,
3838 ph,
3839 pw,
3840 kind,
3841 } => {
3842 let n = *n as usize;
3843 let c = *c as usize;
3844 let d = *d as usize;
3845 let h = *h as usize;
3846 let w = *w as usize;
3847 let d_out = *d_out as usize;
3848 let h_out = *h_out as usize;
3849 let w_out = *w_out as usize;
3850 let kd = *kd as usize;
3851 let kh = *kh as usize;
3852 let kw = *kw as usize;
3853 let sd = *sd as usize;
3854 let sh = *sh as usize;
3855 let sw = *sw as usize;
3856 let pd = *pd as usize;
3857 let ph = *ph as usize;
3858 let pw = *pw as usize;
3859 let kernel_vol = (kd * kh * kw) as f32;
3860 let is_max = matches!(kind, ReduceOp::Max);
3861 let is_mean = matches!(kind, ReduceOp::Mean);
3862 unsafe {
3863 let inp = sl(*src, base, n * c * d * h * w);
3864 let out = sl_mut(*dst, base, n * c * d_out * h_out * w_out);
3865 for ni in 0..n {
3866 for ci in 0..c {
3867 let in_base = (ni * c + ci) * d * h * w;
3868 let out_base = (ni * c + ci) * d_out * h_out * w_out;
3869 for od in 0..d_out {
3870 for ho in 0..h_out {
3871 for wo in 0..w_out {
3872 let mut acc = if is_max { f32::NEG_INFINITY } else { 0.0 };
3873 for kz in 0..kd {
3874 for ki in 0..kh {
3875 for kj in 0..kw {
3876 let di = od * sd + kz;
3877 let hi = ho * sh + ki;
3878 let wi = wo * sw + kj;
3879 if di < pd || hi < ph || wi < pw {
3880 continue;
3881 }
3882 let di = di - pd;
3883 let hi = hi - ph;
3884 let wi = wi - pw;
3885 if di >= d || hi >= h || wi >= w {
3886 continue;
3887 }
3888 let v = inp[in_base + (di * h + hi) * w + wi];
3889 if is_max {
3890 acc = acc.max(v);
3891 } else {
3892 acc += v;
3893 }
3894 }
3895 }
3896 }
3897 if is_mean {
3898 acc /= kernel_vol;
3899 }
3900 out[out_base + (od * h_out + ho) * w_out + wo] = acc;
3901 }
3902 }
3903 }
3904 }
3905 }
3906 }
3907 }
3908
3909 Thunk::ReluBackward { .. } => exec_relu_backward(thunk, base),
3910 Thunk::ReluBackwardF64 { .. } => exec_relu_backward_f64(thunk, base),
3911 Thunk::QMatMul { .. } => exec_q_mat_mul(thunk, base),
3912 Thunk::QConv2d {
3913 x,
3914 w,
3915 bias,
3916 out,
3917 n,
3918 c_in,
3919 h,
3920 w_in,
3921 c_out,
3922 h_out,
3923 w_out,
3924 kh,
3925 kw,
3926 sh,
3927 sw,
3928 ph,
3929 pw,
3930 dh,
3931 dw,
3932 groups,
3933 x_zp,
3934 w_zp,
3935 out_zp,
3936 mult,
3937 } => {
3938 let n = *n as usize;
3939 let c_in = *c_in as usize;
3940 let h = *h as usize;
3941 let w_in = *w_in as usize;
3942 let c_out = *c_out as usize;
3943 let h_out = *h_out as usize;
3944 let w_out = *w_out as usize;
3945 let kh = *kh as usize;
3946 let kw = *kw as usize;
3947 let sh = *sh as usize;
3948 let sw = *sw as usize;
3949 let ph = *ph as usize;
3950 let pw = *pw as usize;
3951 let dh = *dh as usize;
3952 let dw = *dw as usize;
3953 let groups = *groups as usize;
3954 let c_in_per_g = c_in / groups;
3955 let c_out_per_g = c_out / groups;
3956 unsafe {
3957 let x_ptr = base.add(*x) as *const i8;
3958 let w_ptr = base.add(*w) as *const i8;
3959 let bias_ptr = base.add(*bias) as *const i32;
3960 let out_ptr = base.add(*out) as *mut i8;
3961 for ni in 0..n {
3962 for co in 0..c_out {
3963 let g = co / c_out_per_g;
3964 let ci_start = g * c_in_per_g;
3965 for ho in 0..h_out {
3966 for wo in 0..w_out {
3967 let mut acc: i32 = *bias_ptr.add(co);
3968 for ci_off in 0..c_in_per_g {
3969 let ci = ci_start + ci_off;
3970 let in_chan = ((ni * c_in) + ci) * h * w_in;
3971 let wt_chan = ((co * c_in_per_g) + ci_off) * kh * kw;
3972 for ki in 0..kh {
3973 for kj in 0..kw {
3974 let hi = ho * sh + ki * dh;
3975 let wi = wo * sw + kj * dw;
3976 if hi < ph || wi < pw {
3977 continue;
3978 }
3979 let hi = hi - ph;
3980 let wi = wi - pw;
3981 if hi >= h || wi >= w_in {
3982 continue;
3983 }
3984 let xv = *x_ptr.add(in_chan + hi * w_in + wi)
3985 as i32
3986 - *x_zp;
3987 let wv = *w_ptr.add(wt_chan + ki * kw + kj) as i32
3988 - *w_zp;
3989 acc += xv * wv;
3990 }
3991 }
3992 }
3993 let r = (acc as f32 * *mult).round() as i32 + *out_zp;
3994 let r = r.clamp(-128, 127) as i8;
3995 let dst = ((ni * c_out) + co) * h_out * w_out + ho * w_out + wo;
3996 *out_ptr.add(dst) = r;
3997 }
3998 }
3999 }
4000 }
4001 }
4002 }
4003
4004 Thunk::Quantize { .. } => exec_quantize(thunk, base),
4005 Thunk::Dequantize { .. } => exec_dequantize(thunk, base),
4006 Thunk::FakeQuantize { .. } => exec_fake_quantize(thunk, base),
4007 Thunk::ActivationBackward { .. } => exec_activation_backward(thunk, base),
4008 Thunk::ActivationBackwardF64 { .. } => exec_activation_backward_f64(thunk, base),
4009 Thunk::FakeQuantizeLSQ { .. } => exec_fake_quantize_l_s_q(thunk, base),
4010 Thunk::FakeQuantizeLSQBackwardX { .. } => {
4011 exec_fake_quantize_l_s_q_backward_x(thunk, base)
4012 }
4013 Thunk::FakeQuantizeLSQBackwardScale { .. } => {
4014 exec_fake_quantize_l_s_q_backward_scale(thunk, base)
4015 }
4016 Thunk::FakeQuantizeBackward { .. } => exec_fake_quantize_backward(thunk, base),
4017 Thunk::LayerNormBackwardInput { .. } => exec_layer_norm_backward_input(thunk, base),
4018 Thunk::BatchNormInferenceBackwardInput { .. } => {
4019 exec_batch_norm_inference_backward_input(thunk, base)
4020 }
4021 Thunk::BatchNormInferenceBackwardGamma { .. } => {
4022 exec_batch_norm_inference_backward_gamma(thunk, base)
4023 }
4024 Thunk::BatchNormInferenceBackwardBeta { .. } => {
4025 exec_batch_norm_inference_backward_beta(thunk, base)
4026 }
4027 Thunk::LayerNormBackwardGamma { .. } => exec_layer_norm_backward_gamma(thunk, base),
4028 Thunk::RmsNormBackwardInput { .. } => exec_rms_norm_backward_input(thunk, base),
4029 Thunk::RmsNormBackwardGamma { .. } => exec_rms_norm_backward_gamma(thunk, base),
4030 Thunk::RmsNormBackwardBeta { .. } => exec_rms_norm_backward_beta(thunk, base),
4031 Thunk::RopeBackward { .. } => exec_rope_backward(thunk, base),
4032 Thunk::CumsumBackward { .. } => exec_cumsum_backward(thunk, base),
4033 Thunk::GroupNormBackwardInput { .. } => exec_group_norm_backward_input(thunk, base),
4034 Thunk::GroupNormBackwardGamma { .. } => exec_group_norm_backward_gamma(thunk, base),
4035 Thunk::GroupNormBackwardBeta { .. } => exec_group_norm_backward_beta(thunk, base),
4036 Thunk::GatherBackward { .. } => exec_gather_backward(thunk, base),
4037 Thunk::MaxPool2dBackward { .. } => exec_max_pool2d_backward(thunk, base),
4038 Thunk::Conv2dBackwardInput { .. } => exec_conv2d_backward_input(thunk, base),
4039 Thunk::Conv2dBackwardWeight { .. } => exec_conv2d_backward_weight(thunk, base),
4040 Thunk::MaxPool3dBackward { .. } => exec_max_pool3d_backward(thunk, base),
4041 Thunk::Conv3dBackwardInput { .. } => exec_conv3d_backward_input(thunk, base),
4042 Thunk::Conv3dBackwardWeight { .. } => exec_conv3d_backward_weight(thunk, base),
4043 Thunk::Im2Col { .. } => exec_im2_col(thunk, base),
4044 Thunk::SoftmaxCrossEntropyDense { .. } => exec_softmax_cross_entropy_dense(thunk, base),
4045 Thunk::SoftmaxCrossEntropy { .. } => exec_softmax_cross_entropy(thunk, base),
4046 Thunk::SoftmaxCrossEntropyBackward { .. } => {
4047 exec_softmax_cross_entropy_backward(thunk, base)
4048 }
4049 Thunk::GatherAxis { .. } => exec_gather_axis(thunk, base),
4050 Thunk::Transpose {
4051 src,
4052 dst,
4053 in_total,
4054 out_dims,
4055 in_strides,
4056 elem_bytes,
4057 } => {
4058 // N-D index walk: for each output flat index, decompose into
4059 // multi-dim coords using out_dims, then dot with in_strides
4060 // to find the source flat index. Stride 0 = broadcast (read
4061 // the same input element repeatedly along that dim).
4062 let rank = out_dims.len();
4063 let total: usize = out_dims.iter().map(|&d| d as usize).product();
4064 // Empty output (e.g. Zipformer downsample pad `Expand` → `[0,1,C]`
4065 // when T is already a multiple of the stride) — nothing to write.
4066 if total == 0 {
4067 // fall through
4068 } else {
4069 let in_total = *in_total as usize;
4070 unsafe {
4071 if *elem_bytes == 1 {
4072 // 1-byte dtypes (Bool / I8 / U8). Without this branch the
4073 // `else` path below reads/writes 4 bytes per element via the
4074 // f32 slice, corrupting e.g. a broadcast of the VITS attention
4075 // mask (Bool, expanded over heads) — masking wrong positions.
4076 let inp = arena_buf[*src..*src + in_total].to_vec();
4077 let out = &mut arena_buf[*dst..*dst + total];
4078 let mut idx = vec![0usize; rank];
4079 for o in 0..total {
4080 let mut src_idx = 0usize;
4081 for d in 0..rank {
4082 src_idx += idx[d] * in_strides[d] as usize;
4083 }
4084 out[o] = inp[broadcast_src_index(src_idx, in_total)];
4085 for d in (0..rank).rev() {
4086 idx[d] += 1;
4087 if idx[d] < out_dims[d] as usize {
4088 break;
4089 }
4090 idx[d] = 0;
4091 }
4092 }
4093 } else if *elem_bytes == 8 {
4094 let inp = sl_i64(*src, base, in_total);
4095 let out = sl_mut_i64(*dst, base, total);
4096 let mut idx = vec![0usize; rank];
4097 for o in 0..total {
4098 let mut src_idx = 0usize;
4099 for d in 0..rank {
4100 src_idx += idx[d] * in_strides[d] as usize;
4101 }
4102 out[o] = inp[broadcast_src_index(src_idx, in_total)];
4103 for d in (0..rank).rev() {
4104 idx[d] += 1;
4105 if idx[d] < out_dims[d] as usize {
4106 break;
4107 }
4108 idx[d] = 0;
4109 }
4110 }
4111 } else if *elem_bytes == 16 {
4112 // 16-byte dtypes (C128 = 2×f64 native / 4×f32 lanes).
4113 // Copy each element as two i64 words; without this
4114 // branch the `else` f32 path below reads 4 bytes per
4115 // element and shatters the complex element (keeps only
4116 // the first f32, drops the other 12 bytes).
4117 let inp = sl_i64(*src, base, in_total * 2);
4118 let out = sl_mut_i64(*dst, base, total * 2);
4119 let mut idx = vec![0usize; rank];
4120 for o in 0..total {
4121 let mut src_idx = 0usize;
4122 for d in 0..rank {
4123 src_idx += idx[d] * in_strides[d] as usize;
4124 }
4125 let s = broadcast_src_index(src_idx, in_total);
4126 out[o * 2] = inp[s * 2];
4127 out[o * 2 + 1] = inp[s * 2 + 1];
4128 for d in (0..rank).rev() {
4129 idx[d] += 1;
4130 if idx[d] < out_dims[d] as usize {
4131 break;
4132 }
4133 idx[d] = 0;
4134 }
4135 }
4136 } else {
4137 let inp = sl(*src, base, in_total);
4138 let out = sl_mut(*dst, base, total);
4139 if rank == 4
4140 && in_strides[0] == 0
4141 && in_strides[2] == 0
4142 && in_strides[3] == 0
4143 && in_strides[1] != 0
4144 {
4145 // Per-channel broadcast out[n,c,h,w] = in[c*sc] — how
4146 // a conv bias `[C]` reaches `[N,C,H,W]` (and its
4147 // recompute in the backward graph). Fill each (n,c)
4148 // plane with its scalar instead of an N-D index walk
4149 // over every element; parallel over the channel-batch.
4150 let d1 = out_dims[1] as usize;
4151 let sc = in_strides[1] as usize;
4152 let plane = (out_dims[2] as usize) * (out_dims[3] as usize);
4153 let nc_total = (out_dims[0] as usize) * d1;
4154 let out_addr = out.as_mut_ptr() as usize;
4155 let fill = |nc0: usize, nc1: usize| {
4156 let op = out_addr as *mut f32;
4157 for nc in nc0..nc1 {
4158 let v = inp[(nc % d1) * sc];
4159 let base_off = nc * plane;
4160 for k in 0..plane {
4161 *op.add(base_off + k) = v;
4162 }
4163 }
4164 };
4165 if fast_conv_enabled() && crate::pool::should_parallelize(total) {
4166 crate::pool::par_for(
4167 nc_total,
4168 crate::pool::outer_chunk(nc_total),
4169 &|off, cnt| fill(off, off + cnt),
4170 );
4171 } else {
4172 fill(0, nc_total);
4173 }
4174 } else if rank == 2 && in_strides[0] != 0 && in_strides[1] != 0 {
4175 // Fast 2D transpose (the common matmul-backward case:
4176 // xᵀ, wᵀ). out[i,j] = in[i*s0 + j*s1]; tiled over
4177 // columns for write-locality, parallel over rows. Far
4178 // cheaper than the general per-element index walk.
4179 let d0 = out_dims[0] as usize;
4180 let d1 = out_dims[1] as usize;
4181 let s0 = in_strides[0] as usize;
4182 let s1 = in_strides[1] as usize;
4183 let out_addr = out.as_mut_ptr() as usize;
4184 let tile = |i0: usize, i1: usize| {
4185 let op = out_addr as *mut f32;
4186 const T: usize = 32;
4187 let mut j0 = 0;
4188 while j0 < d1 {
4189 let j1 = (j0 + T).min(d1);
4190 for i in i0..i1 {
4191 let inb = i * s0;
4192 let outb = i * d1;
4193 for j in j0..j1 {
4194 *op.add(outb + j) = inp[inb + j * s1];
4195 }
4196 }
4197 j0 = j1;
4198 }
4199 };
4200 if fast_conv_enabled() && crate::pool::should_parallelize(total) {
4201 crate::pool::par_for(
4202 d0,
4203 crate::pool::outer_chunk(d0),
4204 &|off, cnt| tile(off, off + cnt),
4205 );
4206 } else {
4207 tile(0, d0);
4208 }
4209 } else if rank >= 3
4210 && in_strides[rank - 2] == 1
4211 && in_strides[rank - 1] as usize == out_dims[rank - 2] as usize
4212 && {
4213 // Leading axes untouched and contiguous, so
4214 // each batch plane is one dense 2-D block.
4215 let plane = (out_dims[rank - 2] as usize)
4216 * (out_dims[rank - 1] as usize);
4217 let mut expect = plane;
4218 let mut ok = true;
4219 for d in (0..rank - 2).rev() {
4220 if in_strides[d] as usize != expect {
4221 ok = false;
4222 break;
4223 }
4224 expect *= out_dims[d] as usize;
4225 }
4226 ok
4227 }
4228 {
4229 // BATCHED 2-D transpose: the permutation swaps
4230 // only the last two axes.
4231 //
4232 // Neither branch above catches this. The
4233 // innermost stride is not 1 (it is the input's
4234 // inner extent), and the rank is not 2, so it
4235 // used to fall through to the general
4236 // per-element index walk — measured at
4237 // **103 MB/s** on an M4 Pro, against ~20 GB/s of
4238 // memory bandwidth. A `[8, 2048, 1024]` f32
4239 // bank took 653 ms.
4240 //
4241 // It is not a rare shape: `GroupedMatMul` wants
4242 // `[E, in, out]` while GGUF stores expert banks
4243 // as `[E, out, in]`, so every dense MoE layer
4244 // transposes all three of its banks, and
4245 // attention permutes `[B, H, S, D]` the same
4246 // way. Reuse the tiling the rank-2 branch uses,
4247 // once per plane.
4248 let d_i = out_dims[rank - 2] as usize;
4249 let d_o = out_dims[rank - 1] as usize;
4250 let plane = d_i * d_o;
4251 let batches = total / plane.max(1);
4252 let out_addr = out.as_mut_ptr() as usize;
4253 let in_addr = inp.as_ptr() as usize;
4254 let plane_tp = |b0: usize, b1: usize| {
4255 const T: usize = 32;
4256 for b in b0..b1 {
4257 let ip = (in_addr as *const f32).add(b * plane);
4258 let op = (out_addr as *mut f32).add(b * plane);
4259 let mut i0 = 0;
4260 while i0 < d_i {
4261 let i1 = (i0 + T).min(d_i);
4262 let mut o0 = 0;
4263 while o0 < d_o {
4264 let o1 = (o0 + T).min(d_o);
4265 for i in i0..i1 {
4266 for o in o0..o1 {
4267 *op.add(i * d_o + o) = *ip.add(o * d_i + i);
4268 }
4269 }
4270 o0 = o1;
4271 }
4272 i0 = i1;
4273 }
4274 }
4275 };
4276 if crate::pool::should_parallelize(total) && batches >= 2 {
4277 crate::pool::par_for(
4278 batches,
4279 crate::pool::outer_chunk(batches),
4280 &|off, cnt| plane_tp(off, off + cnt),
4281 );
4282 } else {
4283 plane_tp(0, batches);
4284 }
4285 } else if rank >= 3
4286 && *in_strides.last().unwrap_or(&0) == 1
4287 && out_dims[rank - 1] as usize >= 8
4288 {
4289 // Innermost dim contiguous in both layouts: copy one
4290 // row at a time (memcpy) instead of per-element index
4291 // walk. Covers attention BSHD↔BHSD and most Expand/
4292 // permute patterns in DiT.
4293 let row = out_dims[rank - 1] as usize;
4294 let planes = total / row;
4295 let s_last = in_strides[rank - 1] as usize; // 1
4296 let _ = s_last;
4297 let out_addr = out.as_mut_ptr() as usize;
4298 let in_addr = inp.as_ptr() as usize;
4299 let dims = out_dims.to_vec();
4300 let strides = in_strides.to_vec();
4301 let copy_planes = |p0: usize, p1: usize| {
4302 let mut idx = vec![0usize; rank];
4303 let mut rem = p0;
4304 // Decode plane index into leading coords (exclude last dim).
4305 for d in (0..rank - 1).rev() {
4306 let dim = dims[d] as usize;
4307 idx[d] = rem % dim;
4308 rem /= dim;
4309 }
4310 for p in p0..p1 {
4311 let mut src = 0usize;
4312 for d in 0..rank - 1 {
4313 src += idx[d] * strides[d] as usize;
4314 }
4315 std::ptr::copy_nonoverlapping(
4316 (in_addr as *const f32).add(src),
4317 (out_addr as *mut f32).add(p * row),
4318 row,
4319 );
4320 for d in (0..rank - 1).rev() {
4321 idx[d] += 1;
4322 if idx[d] < dims[d] as usize {
4323 break;
4324 }
4325 idx[d] = 0;
4326 }
4327 }
4328 };
4329 if crate::pool::should_parallelize(total) && planes >= 4 {
4330 crate::pool::par_for(
4331 planes,
4332 crate::pool::outer_chunk(planes),
4333 &|off, cnt| copy_planes(off, off + cnt),
4334 );
4335 } else {
4336 copy_planes(0, planes);
4337 }
4338 } else if fast_conv_enabled() && crate::pool::should_parallelize(total)
4339 {
4340 // Parallel: each chunk seeds its starting multi-index
4341 // from `off`, then walks incrementally. Output writes
4342 // are disjoint per `o`.
4343 let out_addr = out.as_mut_ptr() as usize;
4344 crate::pool::par_for(
4345 total,
4346 crate::pool::chunk_floor(total),
4347 &|off, cnt| {
4348 let mut idx = vec![0usize; rank];
4349 let mut rem = off;
4350 for d in (0..rank).rev() {
4351 let dim = out_dims[d] as usize;
4352 idx[d] = rem % dim;
4353 rem /= dim;
4354 }
4355 for o in off..off + cnt {
4356 let mut src_idx = 0usize;
4357 for d in 0..rank {
4358 src_idx += idx[d] * in_strides[d] as usize;
4359 }
4360 let v = inp[broadcast_src_index(src_idx, in_total)];
4361 *((out_addr as *mut f32).add(o)) = v;
4362 for d in (0..rank).rev() {
4363 idx[d] += 1;
4364 if idx[d] < out_dims[d] as usize {
4365 break;
4366 }
4367 idx[d] = 0;
4368 }
4369 }
4370 },
4371 );
4372 } else {
4373 let mut idx = vec![0usize; rank];
4374 for o in 0..total {
4375 let mut src_idx = 0usize;
4376 for d in 0..rank {
4377 src_idx += idx[d] * in_strides[d] as usize;
4378 }
4379 out[o] = inp[broadcast_src_index(src_idx, in_total)];
4380 for d in (0..rank).rev() {
4381 idx[d] += 1;
4382 if idx[d] < out_dims[d] as usize {
4383 break;
4384 }
4385 idx[d] = 0;
4386 }
4387 }
4388 }
4389 }
4390 }
4391 } // total != 0
4392 }
4393
4394 Thunk::CustomOp { .. } => exec_custom_op(thunk, base, arena_buf.len()),
4395 Thunk::Reverse { .. } => exec_reverse(thunk, base),
4396 }
4397 if trace_done {
4398 eprintln!("[thunk {i} done]");
4399 }
4400 if write_trace {
4401 let lo = wt_before
4402 .iter()
4403 .zip(arena_buf.iter())
4404 .position(|(a, b)| a != b);
4405 match lo {
4406 None => eprintln!("[wt {i}] {} wrote nothing", thunk_kind_name(thunk)),
4407 Some(lo) => {
4408 let hi = wt_before.len()
4409 - wt_before
4410 .iter()
4411 .zip(arena_buf.iter())
4412 .rev()
4413 .position(|(a, b)| a != b)
4414 .unwrap();
4415 // Hash the CONTENT, not the offsets: under
4416 // `RLX_ARENA_NO_REUSE` every slot moves, so only a
4417 // content checksum lines up thunk-for-thunk between the
4418 // two layouts and pins down the first op that diverges.
4419 let h = arena_buf[lo..hi]
4420 .iter()
4421 .fold(0xcbf2_9ce4_8422_2325u64, |a, b| {
4422 (a ^ *b as u64).wrapping_mul(0x1000_0000_01b3)
4423 });
4424 eprintln!(
4425 "[wt {i}] {} wrote [{lo},{hi}) len={} hash={h:016x}",
4426 thunk_kind_name(thunk),
4427 hi - lo
4428 );
4429 wt_before.copy_from_slice(arena_buf);
4430 }
4431 }
4432 }
4433 }
4434 if profile {
4435 if let Some((pn, pt)) = prof_prev.take() {
4436 profile_record(pn, pt.elapsed());
4437 }
4438 // Auto-dump under RLX_PROFILE_THUNKS so any binary shows where the run's
4439 // time went (per execute_thunks call — the hot subgraph stands out).
4440 dump_thunk_profile();
4441 }
4442}
4443
4444/// `dots[j] = Σ_d q[d]·k[keys[j]·stride + off + d]`, summed in `d` order with
4445/// one multiply and one add per term — exactly the scalar loop
4446/// `dot += q[d] * k[d]`, so bit-identical to it. The scalar loop is a serial
4447/// dependency on one accumulator (an add latency per element); here eight keys
4448/// run in the eight lanes of one AVX register, fed by an 8×8 transpose of
4449/// their rows.
4450#[cfg(target_arch = "x86_64")]
4451pub(super) fn dots_by_key_lanes(
4452 q: &[f32],
4453 k: &[f32],
4454 keys: &[usize],
4455 stride: usize,
4456 off: usize,
4457 dots: &mut [f32],
4458) {
4459 let n = q.len();
4460 let avx = {
4461 static AVX: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4462 *AVX.get_or_init(|| std::is_x86_feature_detected!("avx"))
4463 };
4464 let lanes = if avx && n.is_multiple_of(8) {
4465 keys.len()
4466 } else {
4467 0
4468 };
4469 for (j, group) in keys[..lanes].chunks(8).enumerate() {
4470 // A short last group repeats its last key in the spare lanes.
4471 let rows: [&[f32]; 8] = std::array::from_fn(|l| {
4472 let ki = group[l.min(group.len() - 1)];
4473 &k[ki * stride + off..ki * stride + off + n]
4474 });
4475 // SAFETY: AVX was detected; every row holds `n` floats.
4476 let out = unsafe { dot8_avx(q, rows) };
4477 dots[8 * j..8 * j + group.len()].copy_from_slice(&out[..group.len()]);
4478 }
4479 for (o, &ki) in dots[lanes..].iter_mut().zip(&keys[lanes..]) {
4480 let row = &k[ki * stride + off..ki * stride + off + n];
4481 let mut dot = 0f32;
4482 for d in 0..n {
4483 dot += q[d] * row[d];
4484 }
4485 *o = dot;
4486 }
4487}
4488
4489/// Eight [`dots_by_key_lanes`] dots, one per lane. `q.len()` is a multiple
4490/// of 8.
4491#[cfg(target_arch = "x86_64")]
4492#[target_feature(enable = "avx")]
4493unsafe fn dot8_avx(q: &[f32], rows: [&[f32]; 8]) -> [f32; 8] {
4494 use std::arch::x86_64::*;
4495 let mut acc = _mm256_setzero_ps();
4496 for d0 in (0..q.len()).step_by(8) {
4497 // SAFETY: `d0 + 8 <= n` for `q` and every row. (Not `array::from_fn`:
4498 // a closure does not inherit `#[target_feature]`.)
4499 let mut r = [_mm256_setzero_ps(); 8];
4500 for (rl, row) in r.iter_mut().zip(rows) {
4501 *rl = unsafe { _mm256_loadu_ps(row[d0..].as_ptr()) };
4502 }
4503 // 8×8 transpose: `t[d]` holds element `d0 + d` of the eight rows.
4504 let a0 = _mm256_unpacklo_ps(r[0], r[1]);
4505 let a1 = _mm256_unpackhi_ps(r[0], r[1]);
4506 let a2 = _mm256_unpacklo_ps(r[2], r[3]);
4507 let a3 = _mm256_unpackhi_ps(r[2], r[3]);
4508 let a4 = _mm256_unpacklo_ps(r[4], r[5]);
4509 let a5 = _mm256_unpackhi_ps(r[4], r[5]);
4510 let a6 = _mm256_unpacklo_ps(r[6], r[7]);
4511 let a7 = _mm256_unpackhi_ps(r[6], r[7]);
4512 let b0 = _mm256_shuffle_ps::<0x44>(a0, a2);
4513 let b1 = _mm256_shuffle_ps::<0xEE>(a0, a2);
4514 let b2 = _mm256_shuffle_ps::<0x44>(a1, a3);
4515 let b3 = _mm256_shuffle_ps::<0xEE>(a1, a3);
4516 let b4 = _mm256_shuffle_ps::<0x44>(a4, a6);
4517 let b5 = _mm256_shuffle_ps::<0xEE>(a4, a6);
4518 let b6 = _mm256_shuffle_ps::<0x44>(a5, a7);
4519 let b7 = _mm256_shuffle_ps::<0xEE>(a5, a7);
4520 let t = [
4521 _mm256_permute2f128_ps::<0x20>(b0, b4),
4522 _mm256_permute2f128_ps::<0x20>(b1, b5),
4523 _mm256_permute2f128_ps::<0x20>(b2, b6),
4524 _mm256_permute2f128_ps::<0x20>(b3, b7),
4525 _mm256_permute2f128_ps::<0x31>(b0, b4),
4526 _mm256_permute2f128_ps::<0x31>(b1, b5),
4527 _mm256_permute2f128_ps::<0x31>(b2, b6),
4528 _mm256_permute2f128_ps::<0x31>(b3, b7),
4529 ];
4530 for (d, td) in t.into_iter().enumerate() {
4531 acc = _mm256_add_ps(acc, _mm256_mul_ps(_mm256_set1_ps(q[d0 + d]), td));
4532 }
4533 }
4534 let mut out = [0f32; 8];
4535 // SAFETY: `out` holds 8 floats.
4536 unsafe { _mm256_storeu_ps(out.as_mut_ptr(), acc) };
4537 out
4538}
4539
4540#[inline(always)]
4541pub(crate) fn exec_nop(t: &Thunk) {
4542 let Thunk::Nop = t else { unreachable!() };
4543 {}
4544}