Skip to main content

rlx_cpu/thunk/ops/
elementwise.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#[allow(unused_variables)]
8pub(crate) fn compile_gather(
9    node: &rlx_ir::Node,
10    graph: &Graph,
11    arena: &crate::arena::Arena,
12    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
13    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
14    rng: rlx_ir::RngOptions,
15) -> Thunk {
16    let Op::Gather { axis } = &node.op else {
17        unreachable!()
18    };
19    {
20        // Non-zero axis: outer × num_idx × trailing layout.
21        let table_shape = &graph.node(node.inputs[0]).shape;
22        let rank = table_shape.rank();
23        let outer: usize = (0..*axis)
24            .map(|i| table_shape.dim(i).unwrap_static())
25            .product::<usize>()
26            .max(1);
27        let trailing: usize = (*axis + 1..rank)
28            .map(|i| table_shape.dim(i).unwrap_static())
29            .product::<usize>()
30            .max(1);
31        let axis_dim = table_shape.dim(*axis).unwrap_static();
32        let idx_len = get_len(graph, node.inputs[1]);
33        let idx_i64 = u8::from(graph.node(node.inputs[1]).shape.dtype() == rlx_ir::DType::I64);
34        let table_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
35        Thunk::GatherAxis {
36            table: node_offset(arena, node.inputs[0]),
37            idx: node_offset(arena, node.inputs[1]),
38            dst: node_offset(arena, node.id),
39            outer: outer as u32,
40            axis_dim: axis_dim as u32,
41            num_idx: idx_len as u32,
42            trailing: trailing as u32,
43            idx_i64,
44            table_bytes,
45        }
46    }
47}
48
49#[allow(unused_variables)]
50pub(crate) fn compile_narrow(
51    node: &rlx_ir::Node,
52    graph: &Graph,
53    arena: &crate::arena::Arena,
54    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
55    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
56    rng: rlx_ir::RngOptions,
57) -> Thunk {
58    let Op::Narrow { axis, start, len } = &node.op else {
59        unreachable!()
60    };
61    {
62        let in_shape = &graph.node(node.inputs[0]).shape;
63        let elem_bytes = in_shape.dtype().size_bytes() as u8;
64        let rank = in_shape.rank();
65        let outer: usize = (0..*axis)
66            .map(|i| in_shape.dim(i).unwrap_static())
67            .product::<usize>()
68            .max(1);
69        let inner: usize = (*axis + 1..rank)
70            .map(|i| in_shape.dim(i).unwrap_static())
71            .product::<usize>()
72            .max(1);
73        let in_axis = in_shape.dim(*axis).unwrap_static();
74        let src_byte_offset =
75            node_offset(arena, node.inputs[0]) + start * inner * elem_bytes as usize;
76        Thunk::Narrow {
77            src: src_byte_offset,
78            dst: node_offset(arena, node.id),
79            outer: outer as u32,
80            src_stride: (in_axis * inner) as u32, // elements per outer step in source
81            dst_stride: (*len * inner) as u32,    // elements per outer step in dest
82            inner: (*len * inner) as u32,         // elements to copy per outer step
83            elem_bytes,
84        }
85    }
86}
87
88#[allow(unused_variables)]
89pub(crate) fn compile_reverse(
90    node: &rlx_ir::Node,
91    graph: &Graph,
92    arena: &crate::arena::Arena,
93    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
94    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
95    rng: rlx_ir::RngOptions,
96) -> Thunk {
97    let Op::Reverse { axes } = &node.op else {
98        unreachable!()
99    };
100    {
101        let in_shape = &graph.node(node.inputs[0]).shape;
102        let rank = in_shape.rank();
103        let dims: Vec<u32> = (0..rank)
104            .map(|i| in_shape.dim(i).unwrap_static() as u32)
105            .collect();
106        let mut rev_mask = vec![false; rank];
107        for &a in axes {
108            if a < rank {
109                rev_mask[a] = true;
110            }
111        }
112        Thunk::Reverse {
113            src: node_offset(arena, node.inputs[0]),
114            dst: node_offset(arena, node.id),
115            dims,
116            rev_mask,
117            elem_bytes: in_shape.dtype().size_bytes() as u8,
118        }
119    }
120}
121
122#[allow(unused_variables)]
123pub(crate) fn compile_cast(
124    node: &rlx_ir::Node,
125    graph: &Graph,
126    arena: &crate::arena::Arena,
127    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
128    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
129    rng: rlx_ir::RngOptions,
130) -> Thunk {
131    let Op::Cast { to } = &node.op else {
132        unreachable!()
133    };
134    {
135        let in_node = graph.node(node.inputs[0]);
136        let in_dtype = in_node.shape.dtype();
137        let out_dtype = *to;
138        let len = node.shape.num_elements().unwrap();
139        let src = node_offset(arena, node.inputs[0]);
140        let dst = node_offset(arena, node.id);
141        if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::I64 {
142            Thunk::CastF32ToI64 {
143                src,
144                dst,
145                len: len as u32,
146            }
147        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::F64 {
148            Thunk::CastF32ToF64 {
149                src,
150                dst,
151                len: len as u32,
152            }
153        } else if in_dtype == rlx_ir::DType::F64 && out_dtype == rlx_ir::DType::F32 {
154            // f64 → f32 narrowing. Input is 8 bytes/element; the generic Copy
155            // below would misread the f64 buffer as 4-byte f32 lanes (two
156            // garbage f32s per f64).
157            Thunk::CastF64ToF32 {
158                src,
159                dst,
160                len: len as u32,
161            }
162        } else if in_dtype == rlx_ir::DType::F64 && out_dtype == rlx_ir::DType::I64 {
163            // f64 → i64, truncating toward zero. Generic Copy bit-copies.
164            Thunk::CastF64ToI64 {
165                src,
166                dst,
167                len: len as u32,
168            }
169        } else if in_dtype == rlx_ir::DType::F64 && out_dtype == rlx_ir::DType::I32 {
170            // f64 → i32, truncating toward zero. 8-byte src → 4-byte dst.
171            Thunk::CastF64ToI32 {
172                src,
173                dst,
174                len: len as u32,
175            }
176        } else if in_dtype == rlx_ir::DType::F64 && out_dtype == rlx_ir::DType::Bool {
177            // f64 → Bool (`x != 0.0`). 8-byte src → 1-byte dst.
178            Thunk::CastF64ToBool {
179                src,
180                dst,
181                len: len as u32,
182            }
183        } else if in_dtype == rlx_ir::DType::I64 && out_dtype == rlx_ir::DType::I32 {
184            // i64 → i32 narrowing (mirror of the CastI32ToI64 widening below).
185            // 8-byte src → 4-byte dst; generic Copy keeps only low 4 bytes.
186            Thunk::CastI64ToI32 {
187                src,
188                dst,
189                len: len as u32,
190            }
191        } else if in_dtype == rlx_ir::DType::I64 && out_dtype == rlx_ir::DType::F64 {
192            // i64 → f64. Both 8 bytes; generic Copy moves 4 and bit-reinterprets.
193            Thunk::CastI64ToF64 {
194                src,
195                dst,
196                len: len as u32,
197            }
198        } else if in_dtype == rlx_ir::DType::I32 && out_dtype == rlx_ir::DType::F64 {
199            // i32 → f64 widening (4-byte src → 8-byte dst).
200            Thunk::CastI32ToF64 {
201                src,
202                dst,
203                len: len as u32,
204            }
205        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::F16 {
206            // f32 → f16 narrowing (4-byte src → 2-byte f16 dst).
207            Thunk::CastF32ToF16 {
208                src,
209                dst,
210                len: len as u32,
211            }
212        } else if in_dtype == rlx_ir::DType::F16 && out_dtype == rlx_ir::DType::F32 {
213            // f16 → f32 widening (2-byte src → 4-byte dst).
214            Thunk::CastF16ToF32 {
215                src,
216                dst,
217                len: len as u32,
218            }
219        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::BF16 {
220            // f32 → bf16 narrowing (4-byte src → 2-byte bf16 dst).
221            Thunk::CastF32ToBf16 {
222                src,
223                dst,
224                len: len as u32,
225            }
226        } else if in_dtype == rlx_ir::DType::BF16 && out_dtype == rlx_ir::DType::F32 {
227            // bf16 → f32 widening (2-byte src → 4-byte dst).
228            Thunk::CastBf16ToF32 {
229                src,
230                dst,
231                len: len as u32,
232            }
233        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::I32 {
234            Thunk::CastF32ToI32 {
235                src,
236                dst,
237                len: len as u32,
238            }
239        } else if in_dtype == rlx_ir::DType::I64 && out_dtype == rlx_ir::DType::F32 {
240            Thunk::CastI64ToF32 {
241                src,
242                dst,
243                len: len as u32,
244            }
245        } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::I32 {
246            Thunk::CastBoolToI32 {
247                src,
248                dst,
249                len: len as u32,
250            }
251        } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::F32 {
252            // Bool is 1 byte; the generic f32 Copy below would misread it as
253            // 4-byte f32. VITS sequence masks are `Cast(Less(...), f32)`.
254            Thunk::CastBoolToF32 {
255                src,
256                dst,
257                len: len as u32,
258            }
259        } else if in_dtype == rlx_ir::DType::F32 && out_dtype == rlx_ir::DType::Bool {
260            // f32 → 1-byte Bool. Output is native-width Bool (1 byte); the
261            // generic Copy would write 4 bytes. Used by logical And/Or.
262            Thunk::CastF32ToBool {
263                src,
264                dst,
265                len: len as u32,
266            }
267        } else if in_dtype == rlx_ir::DType::I32 && out_dtype == rlx_ir::DType::F32 {
268            Thunk::CastI32ToF32 {
269                src,
270                dst,
271                len: len as u32,
272            }
273        } else if in_dtype == rlx_ir::DType::I32 && out_dtype == rlx_ir::DType::I64 {
274            Thunk::CastI32ToI64 {
275                src,
276                dst,
277                len: len as u32,
278            }
279        } else if in_dtype == rlx_ir::DType::I32 && out_dtype == rlx_ir::DType::Bool {
280            Thunk::CastI32ToBool {
281                src,
282                dst,
283                len: len as u32,
284            }
285        } else if in_dtype == rlx_ir::DType::I64 && out_dtype == rlx_ir::DType::Bool {
286            Thunk::CastI64ToBool {
287                src,
288                dst,
289                len: len as u32,
290            }
291        } else if in_dtype == rlx_ir::DType::Bool && out_dtype == rlx_ir::DType::I64 {
292            Thunk::CastBoolToI64 {
293                src,
294                dst,
295                len: len as u32,
296            }
297        } else if in_dtype == out_dtype {
298            match out_dtype {
299                rlx_ir::DType::F64 => Thunk::CopyF64 {
300                    src,
301                    dst,
302                    len: len as u32,
303                },
304                rlx_ir::DType::I64 => Thunk::CopyI64 {
305                    src,
306                    dst,
307                    len: len as u32,
308                },
309                // C128 is 16 bytes/element; the generic `Copy` (4-byte
310                // f32 lanes) would move only a quarter of each element.
311                // Route through the element-wise cast kernel — a
312                // C128→C128 decode/write is bit-identical.
313                rlx_ir::DType::C128 => Thunk::CastGeneric {
314                    src,
315                    dst,
316                    len: len as u32,
317                    src_dtype: in_dtype,
318                    dst_dtype: out_dtype,
319                },
320                _ => Thunk::Copy {
321                    src,
322                    dst,
323                    len: len as u32,
324                },
325            }
326        } else {
327            // Any remaining dtype pair (exotic widths I8/I16/U8/U32, F16/BF16
328            // against non-F32, and C64 real↔complex). The generic 4-byte Copy
329            // that used to live here silently corrupted these (wrong element
330            // width and/or a raw bit-reinterpret instead of a numeric convert);
331            // route them through the correct element-by-element kernel instead.
332            Thunk::CastGeneric {
333                src,
334                dst,
335                len: len as u32,
336                src_dtype: in_dtype,
337                dst_dtype: out_dtype,
338            }
339        }
340    }
341}
342
343/// Intermediate scalar for [`exec_cast_generic`]: a source element decoded into
344/// a backend-neutral form, then re-encoded to the destination dtype. Floats
345/// (incl. F16/BF16) decode to `F`; ints/bool to `I` (preserving full integer
346/// precision — the only lossy step is `i64 → f64` when the DEST is a float);
347/// complex to `C(re, im)`.
348#[derive(Clone, Copy)]
349enum CastScalar {
350    F(f64),
351    I(i64),
352    C(f64, f64),
353}
354
355impl CastScalar {
356    /// Real magnitude for a real (float/int) destination — complex takes the
357    /// real part (numpy/torch convention).
358    #[inline]
359    fn real(self) -> f64 {
360        match self {
361            CastScalar::F(f) => f,
362            CastScalar::I(i) => i as f64,
363            CastScalar::C(re, _) => re,
364        }
365    }
366
367    /// Truthiness for a Bool destination. Complex is nonzero if EITHER
368    /// component is nonzero (numpy convention).
369    #[inline]
370    fn truthy(self) -> bool {
371        match self {
372            CastScalar::F(f) => f != 0.0,
373            CastScalar::I(i) => i != 0,
374            CastScalar::C(re, im) => re != 0.0 || im != 0.0,
375        }
376    }
377
378    /// `(re, im)` for a C64 (f32-component) destination. Non-complex sources
379    /// map to `(value, 0.0)`.
380    #[inline]
381    fn complex(self) -> (f32, f32) {
382        match self {
383            CastScalar::F(f) => (f as f32, 0.0),
384            CastScalar::I(i) => (i as f32, 0.0),
385            CastScalar::C(re, im) => (re as f32, im as f32),
386        }
387    }
388
389    /// `(re, im)` for a C128 (f64-component) destination. Non-complex sources
390    /// map to `(value, 0.0)`; C64/C128 sources keep full f64 precision (a
391    /// C64→C128 widen is exact, a C128→C128 copy is bit-identical).
392    #[inline]
393    fn complex_f64(self) -> (f64, f64) {
394        match self {
395            CastScalar::F(f) => (f, 0.0),
396            CastScalar::I(i) => (i as f64, 0.0),
397            CastScalar::C(re, im) => (re, im),
398        }
399    }
400}
401
402/// Generic element-wise scalar cast for any dtype pair (see
403/// [`Thunk::CastGeneric`]). Decodes each source element into a `CastScalar`,
404/// then re-encodes into the destination dtype with correct numeric semantics:
405///   * float dest (F16/BF16/F32/F64): value-as-f64 rounded to the target
406///     (round-to-nearest for F16/BF16).
407///   * int dest (I8/I16/I32/I64/U8/U32): from a float use `as` (Rust SATURATES
408///     on overflow — the ONNX/torch behavior); from an int use `as`
409///     (wrapping/narrowing).
410///   * Bool dest: `!= 0` (int) / `!= 0.0` (float) / `re||im != 0` (complex).
411///   * C64 dest: `(value as f32, 0.0)` from a scalar, `(re, im)` from complex.
412///   * C128 dest: same as C64 but f64 components (C64→C128 widens exactly,
413///     C128→C64 narrows f64→f32, C128→C128 is bit-identical).
414///
415/// Reused by the Metal backend (`rlx_cpu::thunk::exec_cast_generic`): Apple
416/// Silicon's unified memory lets the GPU arena pointer be handed straight to
417/// this host kernel, so every `Op::Cast` dtype pair converts with identical
418/// numeric semantics on CPU and Metal — hence `pub`.
419#[allow(clippy::not_unsafe_ptr_arg_deref)] // arena `base`; Metal/CPU share this host kernel
420pub fn exec_cast_generic(
421    src: usize,
422    dst: usize,
423    len: usize,
424    src_dtype: rlx_ir::DType,
425    dst_dtype: rlx_ir::DType,
426    base: *mut u8,
427) {
428    use rlx_ir::DType as DT;
429    if len == 0 {
430        return;
431    }
432    // Decode all source elements into an owned buffer FIRST so the source and
433    // destination arena views never alias during the write pass (they are
434    // disjoint arena buffers, but this also keeps the borrow trivially sound).
435    let mut vals: Vec<CastScalar> = Vec::with_capacity(len);
436    unsafe {
437        match src_dtype {
438            DT::F32 => {
439                let s = sl(src, base, len);
440                for i in 0..len {
441                    vals.push(CastScalar::F(s[i] as f64));
442                }
443            }
444            DT::F64 => {
445                let s = sl_f64(src, base, len);
446                for i in 0..len {
447                    vals.push(CastScalar::F(s[i]));
448                }
449            }
450            DT::F16 => {
451                let s = sl_f16(src, base, len);
452                for i in 0..len {
453                    vals.push(CastScalar::F(s[i].to_f32() as f64));
454                }
455            }
456            DT::BF16 => {
457                let s = sl_bf16(src, base, len);
458                for i in 0..len {
459                    vals.push(CastScalar::F(s[i].to_f32() as f64));
460                }
461            }
462            DT::I8 => {
463                let s = sl_i8(src, base, len);
464                for i in 0..len {
465                    vals.push(CastScalar::I(s[i] as i64));
466                }
467            }
468            DT::I16 => {
469                let s = sl_i16(src, base, len);
470                for i in 0..len {
471                    vals.push(CastScalar::I(s[i] as i64));
472                }
473            }
474            DT::I32 => {
475                let s = sl_i32(src, base, len);
476                for i in 0..len {
477                    vals.push(CastScalar::I(s[i] as i64));
478                }
479            }
480            DT::I64 => {
481                let s = sl_i64(src, base, len);
482                for i in 0..len {
483                    vals.push(CastScalar::I(s[i]));
484                }
485            }
486            DT::U8 => {
487                let s = sl_u8(src, base, len);
488                for i in 0..len {
489                    vals.push(CastScalar::I(s[i] as i64));
490                }
491            }
492            DT::U32 => {
493                let s = sl_u32(src, base, len);
494                for i in 0..len {
495                    vals.push(CastScalar::I(s[i] as i64));
496                }
497            }
498            DT::Bool => {
499                let s = sl_u8(src, base, len);
500                for i in 0..len {
501                    vals.push(CastScalar::I(i64::from(s[i] != 0)));
502                }
503            }
504            DT::C64 => {
505                // Interleaved [re, im] f32 pairs → 2·len f32 lanes.
506                let s = sl(src, base, 2 * len);
507                for i in 0..len {
508                    vals.push(CastScalar::C(s[2 * i] as f64, s[2 * i + 1] as f64));
509                }
510            }
511            DT::C128 => {
512                // Interleaved [re, im] f64 pairs → 2·len f64 lanes.
513                let s = sl_f64(src, base, 2 * len);
514                for i in 0..len {
515                    vals.push(CastScalar::C(s[2 * i], s[2 * i + 1]));
516                }
517            }
518        }
519
520        // Int destinations: from a float source `as` SATURATES (ONNX/torch);
521        // from an int source `as` wraps/narrows. Complex takes the real part.
522        macro_rules! write_int {
523            ($accessor:ident, $t:ty) => {{
524                let o = $accessor(dst, base, len);
525                for i in 0..len {
526                    o[i] = match vals[i] {
527                        CastScalar::I(n) => n as $t,
528                        CastScalar::F(f) => f as $t,
529                        CastScalar::C(re, _) => re as $t,
530                    };
531                }
532            }};
533        }
534        match dst_dtype {
535            DT::F32 => {
536                let o = sl_mut(dst, base, len);
537                for i in 0..len {
538                    o[i] = vals[i].real() as f32;
539                }
540            }
541            DT::F64 => {
542                let o = sl_mut_f64(dst, base, len);
543                for i in 0..len {
544                    o[i] = vals[i].real();
545                }
546            }
547            DT::F16 => {
548                let o = sl_mut_f16(dst, base, len);
549                for i in 0..len {
550                    o[i] = half::f16::from_f32(vals[i].real() as f32);
551                }
552            }
553            DT::BF16 => {
554                let o = sl_mut_bf16(dst, base, len);
555                for i in 0..len {
556                    o[i] = half::bf16::from_f32(vals[i].real() as f32);
557                }
558            }
559            DT::I8 => write_int!(sl_mut_i8, i8),
560            DT::I16 => write_int!(sl_mut_i16, i16),
561            DT::I32 => write_int!(sl_mut_i32, i32),
562            DT::I64 => write_int!(sl_mut_i64, i64),
563            DT::U8 => write_int!(sl_mut_u8, u8),
564            DT::U32 => write_int!(sl_mut_u32, u32),
565            DT::Bool => {
566                let o = sl_mut_u8(dst, base, len);
567                for i in 0..len {
568                    o[i] = u8::from(vals[i].truthy());
569                }
570            }
571            DT::C64 => {
572                // Interleaved [re, im] f32 pairs → 2·len f32 lanes.
573                let o = sl_mut(dst, base, 2 * len);
574                for i in 0..len {
575                    let (re, im) = vals[i].complex();
576                    o[2 * i] = re;
577                    o[2 * i + 1] = im;
578                }
579            }
580            DT::C128 => {
581                // Interleaved [re, im] f64 pairs → 2·len f64 lanes.
582                let o = sl_mut_f64(dst, base, 2 * len);
583                for i in 0..len {
584                    let (re, im) = vals[i].complex_f64();
585                    o[2 * i] = re;
586                    o[2 * i + 1] = im;
587                }
588            }
589        }
590    }
591}
592
593#[allow(unused_variables)]
594pub(crate) fn compile_expand(
595    node: &rlx_ir::Node,
596    graph: &Graph,
597    arena: &crate::arena::Arena,
598    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
599    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
600    rng: rlx_ir::RngOptions,
601) -> Thunk {
602    let Op::Expand { .. } = &node.op else {
603        unreachable!()
604    };
605    {
606        // Broadcast: build per-output-dim strides where any input dim
607        // of size 1 has stride 0 (read the same element repeatedly).
608        // Reuses the Thunk::Transpose runtime — N-D walk with strides
609        // is identical; only the strides differ.
610        let in_shape = &graph.node(node.inputs[0]).shape;
611        let out_shape = &node.shape;
612        let in_rank = in_shape.rank();
613        let out_rank = out_shape.rank();
614        // Implicit leading 1s if input has lower rank.
615        let pad = out_rank.saturating_sub(in_rank);
616        let in_dims: Vec<usize> = (0..out_rank)
617            .map(|i| {
618                if i < pad {
619                    1
620                } else {
621                    in_shape.dim(i - pad).unwrap_static()
622                }
623            })
624            .collect();
625        // Row-major input strides (over the padded shape).
626        let mut in_strides_full = vec![1usize; out_rank];
627        for d in (0..out_rank.saturating_sub(1)).rev() {
628            in_strides_full[d] = in_strides_full[d + 1] * in_dims[d + 1];
629        }
630        let out_dims: Vec<u32> = (0..out_rank)
631            .map(|i| out_shape.dim(i).unwrap_static() as u32)
632            .collect();
633        // Stride is 0 for broadcast dims (in_dim == 1 && out_dim > 1).
634        let in_strides: Vec<u32> = (0..out_rank)
635            .map(|i| {
636                if in_dims[i] == 1 && (out_dims[i] as usize) > 1 {
637                    0
638                } else {
639                    in_strides_full[i] as u32
640                }
641            })
642            .collect();
643        let in_total = in_dims.iter().product::<usize>() as u32;
644        let src = node_offset(arena, node.inputs[0]);
645        let dst = node_offset(arena, node.id);
646        let elem_bytes = node.shape.dtype().size_bytes() as u8;
647        match node.shape.dtype() {
648            rlx_ir::DType::F64 => Thunk::TransposeF64 {
649                src,
650                dst,
651                in_total,
652                out_dims,
653                in_strides,
654            },
655            _ => Thunk::Transpose {
656                src,
657                dst,
658                in_total,
659                out_dims,
660                in_strides,
661                elem_bytes,
662            },
663        }
664    }
665}
666
667#[allow(unused_variables)]
668pub(crate) fn compile_transpose(
669    node: &rlx_ir::Node,
670    graph: &Graph,
671    arena: &crate::arena::Arena,
672    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
673    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
674    rng: rlx_ir::RngOptions,
675) -> Thunk {
676    let Op::Transpose { perm } = &node.op else {
677        unreachable!()
678    };
679    {
680        // Pre-compute (out_dims, in_strides_for_each_out_dim) so the
681        // runtime loop is just an N-D index walk + scatter.
682        let in_shape = &graph.node(node.inputs[0]).shape;
683        let in_rank = in_shape.rank();
684        if perm.iter().any(|&p| p >= in_rank) {
685            Thunk::Nop
686        } else {
687            let in_dims: Vec<usize> = (0..in_rank)
688                .map(|i| in_shape.dim(i).unwrap_static())
689                .collect();
690            // Row-major input strides: stride[d] = product of dims[d+1..].
691            let mut in_strides_full = vec![1usize; in_rank];
692            for d in (0..in_rank.saturating_sub(1)).rev() {
693                in_strides_full[d] = in_strides_full[d + 1] * in_dims[d + 1];
694            }
695            let out_dims: Vec<u32> = perm.iter().map(|&p| in_dims[p] as u32).collect();
696            let in_strides: Vec<u32> = perm.iter().map(|&p| in_strides_full[p] as u32).collect();
697            let in_total = in_dims.iter().product::<usize>() as u32;
698            let src = node_offset(arena, node.inputs[0]);
699            let dst = node_offset(arena, node.id);
700            let elem_bytes = node.shape.dtype().size_bytes() as u8;
701            match node.shape.dtype() {
702                rlx_ir::DType::F64 => Thunk::TransposeF64 {
703                    src,
704                    dst,
705                    in_total,
706                    out_dims,
707                    in_strides,
708                },
709                _ => Thunk::Transpose {
710                    src,
711                    dst,
712                    in_total,
713                    out_dims,
714                    in_strides,
715                    elem_bytes,
716                },
717            }
718        }
719    }
720}
721
722#[allow(unused_variables)]
723pub(crate) fn compile_scatter_add(
724    node: &rlx_ir::Node,
725    graph: &Graph,
726    arena: &crate::arena::Arena,
727    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
728    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
729    rng: rlx_ir::RngOptions,
730) -> Thunk {
731    let Op::ScatterAdd { axis } = &node.op else {
732        unreachable!()
733    };
734    // Kernels implement the axis-0 form only. `rlx_fusion::LowerScatterAddAxis`
735    // rewrites every other axis to transpose/scatter/transpose before a backend
736    // sees it, so reaching here with axis != 0 means that pass did not run —
737    // which would otherwise scatter along the wrong dimension in silence.
738    assert_eq!(
739        *axis, 0,
740        "rlx-cpu: ScatterAdd axis {axis} reached the backend; run LowerScatterAddAxis first"
741    );
742    {
743        // updates: [num_updates, ...trailing], indices: [num_updates],
744        // output: [out_dim, ...trailing]
745        let upd_shape = &graph.node(node.inputs[0]).shape;
746        let out_shape = &node.shape;
747        let num_updates = upd_shape.dim(0).unwrap_static();
748        let out_dim = out_shape.dim(0).unwrap_static();
749        let trailing: usize = (1..out_shape.rank())
750            .map(|i| out_shape.dim(i).unwrap_static())
751            .product::<usize>()
752            .max(1);
753        Thunk::ScatterAdd {
754            updates: node_offset(arena, node.inputs[0]),
755            indices: node_offset(arena, node.inputs[1]),
756            dst: node_offset(arena, node.id),
757            num_updates: num_updates as u32,
758            out_dim: out_dim as u32,
759            trailing: trailing as u32,
760        }
761    }
762}
763
764#[allow(unused_variables)]
765pub(crate) fn compile_scatter_nd(
766    node: &rlx_ir::Node,
767    graph: &Graph,
768    arena: &crate::arena::Arena,
769    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
770    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
771    rng: rlx_ir::RngOptions,
772) -> Thunk {
773    let Op::ScatterNd { reduction } = &node.op else {
774        unreachable!()
775    };
776    let data_shape = &graph.node(node.inputs[0]).shape;
777    let indices_shape = &graph.node(node.inputs[1]).shape;
778    let updates_shape = &graph.node(node.inputs[2]).shape;
779    let data_len = data_shape.num_elements().unwrap_or(0);
780    let updates_len = updates_shape.num_elements().unwrap_or(0);
781    let indices_len = indices_shape.num_elements().unwrap_or(0);
782    let indices_i64 = u8::from(indices_shape.dtype() == rlx_ir::DType::I64);
783    Thunk::ScatterNd {
784        data: node_offset(arena, node.inputs[0]),
785        indices: node_offset(arena, node.inputs[1]),
786        updates: node_offset(arena, node.inputs[2]),
787        dst: node_offset(arena, node.id),
788        data_shape: (0..data_shape.rank())
789            .map(|i| data_shape.dim(i).unwrap_static() as u32)
790            .collect(),
791        indices_shape: (0..indices_shape.rank())
792            .map(|i| indices_shape.dim(i).unwrap_static() as u32)
793            .collect(),
794        data_len: data_len as u32,
795        updates_len: updates_len as u32,
796        indices_len: indices_len as u32,
797        indices_i64,
798        reduction: *reduction,
799    }
800}
801
802fn shape_u32s(shape: &rlx_ir::Shape) -> Vec<u32> {
803    (0..shape.rank())
804        .map(|i| shape.dim(i).unwrap_static() as u32)
805        .collect()
806}
807
808#[allow(unused_variables)]
809pub(crate) fn compile_scatter_elements(
810    node: &rlx_ir::Node,
811    graph: &Graph,
812    arena: &crate::arena::Arena,
813    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
814    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
815    rng: rlx_ir::RngOptions,
816) -> Thunk {
817    let Op::ScatterElements { axis, reduction } = &node.op else {
818        unreachable!()
819    };
820    let data_shape = &graph.node(node.inputs[0]).shape;
821    let indices_shape = &graph.node(node.inputs[1]).shape;
822    let updates_shape = &graph.node(node.inputs[2]).shape;
823    Thunk::ScatterElements {
824        data: node_offset(arena, node.inputs[0]),
825        indices: node_offset(arena, node.inputs[1]),
826        updates: node_offset(arena, node.inputs[2]),
827        dst: node_offset(arena, node.id),
828        data_shape: shape_u32s(data_shape),
829        indices_shape: shape_u32s(indices_shape),
830        data_len: data_shape.num_elements().unwrap_or(0) as u32,
831        updates_len: updates_shape.num_elements().unwrap_or(0) as u32,
832        indices_len: indices_shape.num_elements().unwrap_or(0) as u32,
833        indices_i64: u8::from(indices_shape.dtype() == rlx_ir::DType::I64),
834        axis: *axis,
835        reduction: *reduction,
836    }
837}
838
839#[allow(unused_variables)]
840pub(crate) fn compile_gather_nd(
841    node: &rlx_ir::Node,
842    graph: &Graph,
843    arena: &crate::arena::Arena,
844    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
845    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
846    rng: rlx_ir::RngOptions,
847) -> Thunk {
848    let Op::GatherNd { batch_dims } = &node.op else {
849        unreachable!()
850    };
851    let data_shape = &graph.node(node.inputs[0]).shape;
852    let indices_shape = &graph.node(node.inputs[1]).shape;
853    Thunk::GatherNd {
854        data: node_offset(arena, node.inputs[0]),
855        indices: node_offset(arena, node.inputs[1]),
856        dst: node_offset(arena, node.id),
857        data_shape: shape_u32s(data_shape),
858        indices_shape: shape_u32s(indices_shape),
859        data_len: data_shape.num_elements().unwrap_or(0) as u32,
860        indices_len: indices_shape.num_elements().unwrap_or(0) as u32,
861        out_len: node.shape.num_elements().unwrap_or(0) as u32,
862        indices_i64: u8::from(indices_shape.dtype() == rlx_ir::DType::I64),
863        batch_dims: *batch_dims,
864    }
865}
866
867#[allow(unused_variables)]
868pub(crate) fn compile_gather_elements(
869    node: &rlx_ir::Node,
870    graph: &Graph,
871    arena: &crate::arena::Arena,
872    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
873    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
874    rng: rlx_ir::RngOptions,
875) -> Thunk {
876    let Op::GatherElements { axis } = &node.op else {
877        unreachable!()
878    };
879    let data_shape = &graph.node(node.inputs[0]).shape;
880    let indices_shape = &graph.node(node.inputs[1]).shape;
881    Thunk::GatherElements {
882        data: node_offset(arena, node.inputs[0]),
883        indices: node_offset(arena, node.inputs[1]),
884        dst: node_offset(arena, node.id),
885        data_shape: shape_u32s(data_shape),
886        indices_shape: shape_u32s(indices_shape),
887        data_len: data_shape.num_elements().unwrap_or(0) as u32,
888        indices_len: indices_shape.num_elements().unwrap_or(0) as u32,
889        out_len: node.shape.num_elements().unwrap_or(0) as u32,
890        indices_i64: u8::from(indices_shape.dtype() == rlx_ir::DType::I64),
891        data_elem_bytes: data_shape.dtype().size_bytes() as u8,
892        axis: *axis,
893    }
894}
895
896#[allow(unused_variables)]
897pub(crate) fn compile_compare(
898    node: &rlx_ir::Node,
899    graph: &Graph,
900    arena: &crate::arena::Arena,
901    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
902    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
903    rng: rlx_ir::RngOptions,
904) -> Thunk {
905    let Op::Compare(cmp) = &node.op else {
906        unreachable!()
907    };
908    {
909        let len = node.shape.num_elements().unwrap();
910        let lhs_n = graph.node(node.inputs[0]).shape.num_elements().unwrap();
911        let rhs_n = graph.node(node.inputs[1]).shape.num_elements().unwrap();
912        let lhs_scalar = lhs_n == 1 && len > 1;
913        let rhs_scalar = rhs_n == 1 && len > 1;
914        let in_dtype = graph.node(node.inputs[0]).shape.dtype();
915        let inputs_i64 = u8::from(in_dtype == rlx_ir::DType::I64);
916        // A *partial* broadcast operand (numel neither 1 nor `len`) — e.g. rhs
917        // `[.,L,1]` against `[.,L,L]` — cannot be indexed by the flat fast path,
918        // which would read `len` elements from a shorter operand (past its end,
919        // into adjacent arena memory → wrong mask). Emit shape-aware broadcast
920        // strides for BOTH operands so exec indexes via row-major coords, just
921        // like `BinaryFull`. Needs static dims; a dynamic shape falls back to
922        // the fast path (behavior unchanged there).
923        let lhs_partial = lhs_n != len && lhs_n != 1;
924        let rhs_partial = rhs_n != len && rhs_n != 1;
925        let out_dims_s = get_static_dims(graph, node.id);
926        let lhs_dims_s = get_static_dims(graph, node.inputs[0]);
927        let rhs_dims_s = get_static_dims(graph, node.inputs[1]);
928        let (out_dims_bcast, bcast_lhs_strides, bcast_rhs_strides) = if (lhs_partial || rhs_partial)
929            && !out_dims_s.is_empty()
930            && !lhs_dims_s.is_empty()
931            && !rhs_dims_s.is_empty()
932        {
933            // `broadcast_strides` asserts rank-in <= rank-out with no idea which node it is
934            // compiling, which makes an inconsistent graph very hard to localize. Broadcasting
935            // can never *reduce* rank, so name the node here instead.
936            assert!(
937                lhs_dims_s.len() <= out_dims_s.len() && rhs_dims_s.len() <= out_dims_s.len(),
938                "Compare {:?} at {}: operand rank exceeds output rank \
939                 (lhs {lhs_dims_s:?}, rhs {rhs_dims_s:?}, out {out_dims_s:?}) — \
940                 broadcasting cannot reduce rank, so the graph's shape metadata is inconsistent",
941                cmp,
942                node.name.as_deref().unwrap_or("<unnamed>"),
943            );
944            (
945                out_dims_s.iter().map(|&d| d as u32).collect::<Vec<u32>>(),
946                broadcast_strides(&lhs_dims_s, &out_dims_s),
947                broadcast_strides(&rhs_dims_s, &out_dims_s),
948            )
949        } else {
950            (Vec::new(), Vec::new(), Vec::new())
951        };
952        Thunk::Compare {
953            lhs: node_offset(arena, node.inputs[0]),
954            rhs: node_offset(arena, node.inputs[1]),
955            dst: node_offset(arena, node.id),
956            len: len as u32,
957            op: *cmp,
958            inputs_i64,
959            inputs_elem_bytes: in_dtype.size_bytes() as u8,
960            dst_elem_bytes: node.shape.dtype().size_bytes() as u8,
961            lhs_scalar,
962            rhs_scalar,
963            lhs_len: lhs_n as u32,
964            rhs_len: rhs_n as u32,
965            out_dims_bcast,
966            bcast_lhs_strides,
967            bcast_rhs_strides,
968        }
969    }
970}
971
972#[allow(unused_variables)]
973pub(crate) fn compile_where(
974    node: &rlx_ir::Node,
975    graph: &Graph,
976    arena: &crate::arena::Arena,
977    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
978    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
979    rng: rlx_ir::RngOptions,
980) -> Thunk {
981    let Op::Where = &node.op else { unreachable!() };
982    {
983        let len = node.shape.num_elements().unwrap();
984        let cn = graph.node(node.inputs[0]).shape.num_elements().unwrap();
985        let tn = graph.node(node.inputs[1]).shape.num_elements().unwrap();
986        let fn_ = graph.node(node.inputs[2]).shape.num_elements().unwrap();
987        let cond_scalar = cn == 1 && len > 1;
988        let true_scalar = tn == 1 && len > 1;
989        let false_scalar = fn_ == 1 && len > 1;
990        let elem_bytes = node.shape.dtype().size_bytes() as u8;
991        let cond_elem_bytes = graph.node(node.inputs[0]).shape.dtype().size_bytes() as u8;
992        Thunk::Where {
993            cond: node_offset(arena, node.inputs[0]),
994            on_true: node_offset(arena, node.inputs[1]),
995            on_false: node_offset(arena, node.inputs[2]),
996            dst: node_offset(arena, node.id),
997            len: len as u32,
998            elem_bytes,
999            cond_elem_bytes,
1000            cond_scalar,
1001            true_scalar,
1002            false_scalar,
1003        }
1004    }
1005}
1006
1007#[allow(unused_variables)]
1008pub(crate) fn compile_fma(
1009    node: &rlx_ir::Node,
1010    graph: &Graph,
1011    arena: &crate::arena::Arena,
1012    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1013    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1014    rng: rlx_ir::RngOptions,
1015) -> Thunk {
1016    let Op::Fma = &node.op else { unreachable!() };
1017    {
1018        let len = node.shape.num_elements().unwrap();
1019        Thunk::Fma {
1020            a: node_offset(arena, node.inputs[0]),
1021            b: node_offset(arena, node.inputs[1]),
1022            c: node_offset(arena, node.inputs[2]),
1023            dst: node_offset(arena, node.id),
1024            len: len as u32,
1025            elem_bytes: node.shape.dtype().size_bytes() as u8,
1026        }
1027    }
1028}
1029
1030#[allow(unused_variables)]
1031pub(crate) fn compile_relu_backward(
1032    node: &rlx_ir::Node,
1033    graph: &Graph,
1034    arena: &crate::arena::Arena,
1035    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1036    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1037    rng: rlx_ir::RngOptions,
1038) -> Thunk {
1039    let Op::ReluBackward = &node.op else {
1040        unreachable!()
1041    };
1042    {
1043        let len: usize = (0..node.shape.rank())
1044            .map(|i| node.shape.dim(i).unwrap_static())
1045            .product();
1046        let x = node_offset(arena, node.inputs[0]);
1047        let dy = node_offset(arena, node.inputs[1]);
1048        let dx = node_offset(arena, node.id);
1049        match node.shape.dtype() {
1050            rlx_ir::DType::F64 => Thunk::ReluBackwardF64 {
1051                x,
1052                dy,
1053                dx,
1054                len: len as u32,
1055            },
1056            _ => Thunk::ReluBackward {
1057                x,
1058                dy,
1059                dx,
1060                len: len as u32,
1061            },
1062        }
1063    }
1064}
1065
1066#[allow(unused_variables)]
1067pub(crate) fn compile_activation_backward(
1068    node: &rlx_ir::Node,
1069    graph: &Graph,
1070    arena: &crate::arena::Arena,
1071    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1072    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1073    rng: rlx_ir::RngOptions,
1074) -> Thunk {
1075    let Op::ActivationBackward { kind } = &node.op else {
1076        unreachable!()
1077    };
1078    {
1079        let len: usize = (0..node.shape.rank())
1080            .map(|i| node.shape.dim(i).unwrap_static())
1081            .product();
1082        let x = node_offset(arena, node.inputs[0]);
1083        let dy = node_offset(arena, node.inputs[1]);
1084        let dx = node_offset(arena, node.id);
1085        match node.shape.dtype() {
1086            rlx_ir::DType::F64 => Thunk::ActivationBackwardF64 {
1087                x,
1088                dy,
1089                dx,
1090                len: len as u32,
1091                kind: *kind,
1092            },
1093            _ => Thunk::ActivationBackward {
1094                x,
1095                dy,
1096                dx,
1097                len: len as u32,
1098                kind: *kind,
1099            },
1100        }
1101    }
1102}
1103
1104#[allow(unused_variables)]
1105pub(crate) fn compile_gather_backward(
1106    node: &rlx_ir::Node,
1107    graph: &Graph,
1108    arena: &crate::arena::Arena,
1109    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1110    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1111    rng: rlx_ir::RngOptions,
1112) -> Thunk {
1113    let Op::GatherBackward { .. } = &node.op else {
1114        unreachable!()
1115    };
1116    {
1117        let dy_shape = &graph.node(node.inputs[0]).shape;
1118        let idx_shape = &graph.node(node.inputs[1]).shape;
1119        let out_shape = &node.shape;
1120        let rank = out_shape.rank();
1121        let axis = match &node.op {
1122            Op::GatherBackward { axis } => *axis,
1123            _ => 0,
1124        };
1125        let axis_u = if axis < 0 {
1126            (rank as i32 + axis) as usize
1127        } else {
1128            axis as usize
1129        };
1130        let outer: usize = (0..axis_u)
1131            .map(|i| dy_shape.dim(i).unwrap_static())
1132            .product::<usize>()
1133            .max(1);
1134        let num_idx = idx_shape.gather_index_count(axis_u);
1135        let trailing: usize = (axis_u + 1..dy_shape.rank())
1136            .map(|i| dy_shape.dim(i).unwrap_static())
1137            .product::<usize>()
1138            .max(1);
1139        let axis_dim = out_shape.dim(axis_u).unwrap_static();
1140        Thunk::GatherBackward {
1141            dy: node_offset(arena, node.inputs[0]),
1142            indices: node_offset(arena, node.inputs[1]),
1143            dst: node_offset(arena, node.id),
1144            outer: outer as u32,
1145            axis_dim: axis_dim as u32,
1146            num_idx: num_idx as u32,
1147            trailing: trailing as u32,
1148        }
1149    }
1150}
1151
1152#[allow(unused_variables)]
1153pub(crate) fn compile_concat(
1154    node: &rlx_ir::Node,
1155    graph: &Graph,
1156    arena: &crate::arena::Arena,
1157    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1158    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1159    rng: rlx_ir::RngOptions,
1160) -> Thunk {
1161    let Op::Concat { axis } = &node.op else {
1162        unreachable!()
1163    };
1164    {
1165        // Compute outer/inner from the OUTPUT shape: all inputs share
1166        // the same shape except along `axis`. The output's leading
1167        // and trailing dims match.
1168        let out_shape = &node.shape;
1169        let rank = out_shape.rank();
1170        let outer: usize = (0..*axis)
1171            .map(|i| out_shape.dim(i).unwrap_static())
1172            .product::<usize>()
1173            .max(1);
1174        let inner: usize = (*axis + 1..rank)
1175            .map(|i| out_shape.dim(i).unwrap_static())
1176            .product::<usize>()
1177            .max(1);
1178        let total_axis = out_shape.dim(*axis).unwrap_static();
1179        let inputs: Vec<(usize, u32, u32)> = node
1180            .inputs
1181            .iter()
1182            .map(|&in_id| {
1183                let in_shape = &graph.node(in_id).shape;
1184                let in_axis = concat_axis_extent(in_shape, *axis, rank);
1185                let in_numel = in_shape.num_elements().unwrap_or(0) as u32;
1186                (node_offset(arena, in_id), in_axis as u32, in_numel)
1187            })
1188            .collect();
1189        let dst = node_offset(arena, node.id);
1190        // The `Concat` thunk copies 4-byte (f32-sized) elements; `ConcatF64`
1191        // copies 8-byte elements. Route by element SIZE, not just F64 — an I64
1192        // tensor (8-byte) sent through the 4-byte path reads/writes half the
1193        // bytes per element, so it copies only the first half of each input and
1194        // leaves the tail zero (e.g. `Tile([9..0], 3)` on i64 rel-shift indices
1195        // → `[9,8,7,6,5]×3 + zeros`). The 8-byte copy is a pure byte move, so
1196        // reinterpreting i64 as f64 for the copy is bit-preserving.
1197        match out_shape.dtype().size_bytes() {
1198            8 => Thunk::ConcatF64 {
1199                dst,
1200                outer: outer as u32,
1201                inner: inner as u32,
1202                total_axis: total_axis as u32,
1203                inputs,
1204            },
1205            _ => Thunk::Concat {
1206                dst,
1207                outer: outer as u32,
1208                inner: inner as u32,
1209                total_axis: total_axis as u32,
1210                inputs,
1211            },
1212        }
1213    }
1214}
1215
1216#[allow(unused_variables)]
1217pub(crate) fn compile_elementwise_region(
1218    node: &rlx_ir::Node,
1219    graph: &Graph,
1220    arena: &crate::arena::Arena,
1221    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
1222    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1223    rng: rlx_ir::RngOptions,
1224) -> Thunk {
1225    let Op::ElementwiseRegion {
1226        chain,
1227        scalar_input_mask,
1228        input_modulus,
1229        prologue,
1230        ..
1231    } = &node.op
1232    else {
1233        unreachable!()
1234    };
1235    {
1236        // The scalar interpreter handles plain chains; prologue (resize)
1237        // regions are GPU-only and never reach here on the CPU path
1238        // (the graphfused fusion options disable prologue/FK fusion).
1239        if *prologue != rlx_ir::op::RegionPrologue::None {
1240            Thunk::Nop
1241        } else {
1242            let input_offs: Vec<usize> = node
1243                .inputs
1244                .iter()
1245                .map(|&id| node_offset(arena, id))
1246                .collect();
1247            // Resolve each operand's storage lane from the width the memory
1248            // plan gave it. Without this the region reads and writes 4-byte
1249            // f32 lanes unconditionally, so a packed F16 tensor is misread
1250            // (and a half result is silently kept at full f32 precision).
1251            let lane_of = |id: NodeId| {
1252                let n = graph.node(id).shape.num_elements().unwrap_or(0);
1253                let bytes = arena.byte_size(id);
1254                let elem = bytes.checked_div(n).unwrap_or(4);
1255                crate::thunk::types::LaneKind::resolve(graph.node(id).shape.dtype(), elem)
1256            };
1257            let in_lanes: Vec<crate::thunk::types::LaneKind> =
1258                node.inputs.iter().map(|&id| lane_of(id)).collect();
1259            Thunk::ElementwiseRegion {
1260                dst: node_offset(arena, node.id),
1261                len: node.shape.num_elements().unwrap_or(0) as u32,
1262                input_offs,
1263                chain: chain.clone(),
1264                scalar_input_mask: *scalar_input_mask,
1265                input_modulus: *input_modulus,
1266                in_lanes,
1267                out_lane: lane_of(node.id),
1268            }
1269        }
1270    }
1271}
1272
1273pub(crate) fn get_len(graph: &Graph, id: NodeId) -> usize {
1274    graph.node(id).shape.num_elements().unwrap_or(0)
1275}
1276
1277/// Static `usize` dims of a node's shape, or empty if any dim is dynamic.
1278pub(crate) fn get_static_dims(graph: &Graph, id: NodeId) -> Vec<usize> {
1279    let dims = graph.node(id).shape.dims();
1280    let mut out = Vec::with_capacity(dims.len());
1281    for d in dims {
1282        if let Some(s) = match d {
1283            rlx_ir::Dim::Static(s) => Some(*s),
1284            _ => None,
1285        } {
1286            out.push(s);
1287        } else {
1288            return Vec::new();
1289        }
1290    }
1291    out
1292}
1293
1294/// Extent along `axis` for a concat input, treating leading implicit 1s when
1295/// `input.rank() < output.rank()` (ONNX / numpy concat broadcast rules).
1296pub(crate) fn concat_axis_extent(input: &rlx_ir::Shape, axis: usize, out_rank: usize) -> usize {
1297    let in_rank = input.rank();
1298    if axis >= out_rank {
1299        return 1;
1300    }
1301    if axis < in_rank {
1302        input.dim(axis).unwrap_static()
1303    } else {
1304        1
1305    }
1306}
1307
1308pub(crate) fn broadcast_src_index(src_idx: usize, in_len: usize) -> usize {
1309    if in_len == 0 { 0 } else { src_idx % in_len }
1310}
1311
1312/// Copy `inp` into row-strided `out` for a Concat input, with outer-dim
1313/// broadcasting. The body is dtype-agnostic (only `Copy` + slice ops), so the
1314/// f32/f64 variants are generated from one template rather than copy-pasted.
1315macro_rules! concat_copy_rows {
1316    ($name:ident, $t:ty) => {
1317        pub(crate) fn $name(
1318            out: &mut [$t],
1319            inp: &[$t],
1320            outer: usize,
1321            copy_per_row: usize,
1322            row_stride: usize,
1323            dst_col_off: usize,
1324            in_numel: usize,
1325        ) {
1326            let need = outer.saturating_mul(copy_per_row.max(1));
1327            let broadcast_outer = in_numel < need;
1328            let out_ptr = out.as_mut_ptr() as usize;
1329            let inp_ptr = inp.as_ptr() as usize;
1330            let inp_len = inp.len();
1331            let run = |o0: usize, o1: usize| {
1332                let out = unsafe { std::slice::from_raw_parts_mut(out_ptr as *mut $t, out.len()) };
1333                let inp = unsafe { std::slice::from_raw_parts(inp_ptr as *const $t, inp_len) };
1334                for o in o0..o1 {
1335                    let dst_row_start = o * row_stride + dst_col_off;
1336                    if broadcast_outer {
1337                        if in_numel == 1 {
1338                            if copy_per_row == 1 {
1339                                out[dst_row_start] = inp[0];
1340                            } else {
1341                                out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
1342                            }
1343                        } else if copy_per_row <= inp.len() {
1344                            out[dst_row_start..dst_row_start + copy_per_row]
1345                                .copy_from_slice(&inp[..copy_per_row]);
1346                        } else if !inp.is_empty() {
1347                            out[dst_row_start..dst_row_start + copy_per_row].fill(inp[0]);
1348                        }
1349                    } else {
1350                        let src_row_start = o * copy_per_row;
1351                        out[dst_row_start..dst_row_start + copy_per_row]
1352                            .copy_from_slice(&inp[src_row_start..src_row_start + copy_per_row]);
1353                    }
1354                }
1355            };
1356            // F5 DiT concatenates large mel/text streams — parallelize outer rows.
1357            if outer >= 8
1358                && copy_per_row >= 16
1359                && crate::pool::num_threads() > 1
1360                && crate::pool::should_parallelize(outer.saturating_mul(copy_per_row))
1361            {
1362                crate::pool::par_for(outer, 1, &|off, cnt| run(off, off + cnt));
1363            } else {
1364                run(0, outer);
1365            }
1366        }
1367    };
1368}
1369
1370concat_copy_rows!(concat_copy_rows_f32, f32);
1371concat_copy_rows!(concat_copy_rows_f64, f64);
1372
1373/// NumPy-style broadcast strides for one operand into the flat output
1374/// buffer. Returns a length-`out_dims.len()` `Vec<u32>` where entry
1375/// `d` is `0` if the input is size-1 (broadcast) at output dim `d`
1376/// (after left-padding with size-1 to match ranks), otherwise the
1377/// natural row-major stride into the *input* buffer.
1378///
1379/// Caller iterates output flat index `i` → output coords (row-major)
1380/// → input flat index = dot(coords, strides). The result is correct
1381/// for any broadcast pattern (scalar, last-axis, middle-axis,
1382/// bidirectional).
1383/// True when `rhs_dims` describes a *trailing* broadcast of `out_dims`
1384/// — i.e. every rhs dim either equals the corresponding output dim
1385/// (counting from the right) or rhs is shorter (left-padded with 1s).
1386/// Mid-shape singletons (e.g. rhs `[a, b, 1, d]` into out `[a, b, c, d]`
1387/// where `c > 1`) are NOT trailing broadcasts and require the
1388/// shape-aware `BinaryFull` slow path — `BiasAdd`'s linear bias-replicated
1389/// kernel silently miscomputes them.
1390pub(crate) fn is_trailing_bias_broadcast(
1391    rhs_dims: &[rlx_ir::Dim],
1392    out_dims: &[rlx_ir::Dim],
1393) -> bool {
1394    if rhs_dims.len() > out_dims.len() {
1395        return false;
1396    }
1397    let off = out_dims.len() - rhs_dims.len();
1398    for i in 0..rhs_dims.len() {
1399        let r = match rhs_dims[i] {
1400            rlx_ir::Dim::Static(n) => n,
1401            _ => return false,
1402        };
1403        let o = match out_dims[off + i] {
1404            rlx_ir::Dim::Static(n) => n,
1405            _ => return false,
1406        };
1407        if r != o {
1408            return false;
1409        }
1410    }
1411    true
1412}
1413
1414pub(crate) fn broadcast_strides(in_dims: &[usize], out_dims: &[usize]) -> Vec<u32> {
1415    let r_out = out_dims.len();
1416    let r_in = in_dims.len();
1417    assert!(
1418        r_in <= r_out,
1419        "broadcast: input rank {r_in} > output rank {r_out}"
1420    );
1421    let pad = r_out - r_in;
1422    let mut strides = vec![0u32; r_out];
1423    let mut acc: usize = 1;
1424    for d in (0..r_out).rev() {
1425        let in_size = if d < pad { 1 } else { in_dims[d - pad] };
1426        if in_size == 1 {
1427            strides[d] = 0;
1428        } else {
1429            assert_eq!(
1430                in_size, out_dims[d],
1431                "broadcast: input dim {in_size} doesn't match output dim {} at axis {d}",
1432                out_dims[d]
1433            );
1434            strides[d] = acc as u32;
1435            acc *= in_size;
1436        }
1437    }
1438    strides
1439}
1440
1441#[inline(always)]
1442pub(crate) fn exec_elementwise_region(t: &Thunk, base: *mut u8) {
1443    let Thunk::ElementwiseRegion {
1444        dst,
1445        len,
1446        input_offs,
1447        chain,
1448        scalar_input_mask,
1449        input_modulus,
1450        in_lanes,
1451        out_lane,
1452    } = t
1453    else {
1454        unreachable!()
1455    };
1456    {
1457        let len = *len as usize;
1458        if !chain.is_empty() && len > 0 {
1459            let base_addr = base as usize;
1460            let dst = *dst;
1461            let scalar_mask = *scalar_input_mask;
1462            // Each output element is independent → fan over the range.
1463            let out_lane = *out_lane;
1464            let eval = |gid: usize| {
1465                let v = region_eval_elem(
1466                    gid,
1467                    base_addr as *const u8,
1468                    input_offs,
1469                    in_lanes,
1470                    chain,
1471                    scalar_mask,
1472                    input_modulus,
1473                );
1474                // Narrow back into the destination's real lane width: an
1475                // f32 store into a packed F16 slot would write two lanes.
1476                unsafe {
1477                    out_lane.store(base_addr as *mut u8, dst, gid, v);
1478                }
1479            };
1480            if fast_conv_enabled() && crate::pool::should_parallelize(len) {
1481                crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
1482                    for gid in off..off + cnt {
1483                        eval(gid);
1484                    }
1485                });
1486            } else {
1487                for gid in 0..len {
1488                    eval(gid);
1489                }
1490            }
1491        }
1492    }
1493}
1494
1495#[inline(always)]
1496pub(crate) fn exec_transpose_f64(t: &Thunk, base: *mut u8) {
1497    let Thunk::TransposeF64 {
1498        src,
1499        dst,
1500        in_total,
1501        out_dims,
1502        in_strides,
1503    } = t
1504    else {
1505        unreachable!()
1506    };
1507    unsafe {
1508        let inp = sl_f64(*src, base, *in_total as usize);
1509        let out_total: usize = out_dims.iter().map(|d| *d as usize).product();
1510        let out = sl_mut_f64(*dst, base, out_total);
1511        transpose_walk_f64(inp, out, out_dims, in_strides);
1512    }
1513}
1514
1515#[inline(always)]
1516pub(crate) fn exec_activation_f64(t: &Thunk, base: *mut u8) {
1517    let Thunk::ActivationF64 {
1518        src,
1519        dst,
1520        len,
1521        kind,
1522    } = t
1523    else {
1524        unreachable!()
1525    };
1526    {
1527        let len = *len as usize;
1528        unsafe {
1529            let inp = sl_f64(*src, base, len);
1530            let out = sl_mut_f64(*dst, base, len);
1531            apply_activation_f64(inp, out, *kind);
1532        }
1533    }
1534}
1535
1536#[inline(always)]
1537pub(crate) fn exec_binary_full_f64(t: &Thunk, base: *mut u8) {
1538    let Thunk::BinaryFullF64 {
1539        lhs,
1540        rhs,
1541        dst,
1542        len,
1543        lhs_len,
1544        rhs_len,
1545        op,
1546        out_dims_bcast,
1547        bcast_lhs_strides,
1548        bcast_rhs_strides,
1549    } = t
1550    else {
1551        unreachable!()
1552    };
1553    {
1554        let len = *len as usize;
1555        let lhs_len = *lhs_len as usize;
1556        let rhs_len = *rhs_len as usize;
1557        unsafe {
1558            let l = sl_f64(*lhs, base, lhs_len);
1559            let r = sl_f64(*rhs, base, rhs_len);
1560            let d = sl_mut_f64(*dst, base, len);
1561            if lhs_len == len && rhs_len == len {
1562                for i in 0..len {
1563                    d[i] = binary_op_f64(*op, l[i], r[i]);
1564                }
1565            } else if !out_dims_bcast.is_empty() {
1566                // Shape-aware broadcast path: correct for
1567                // arbitrary NumPy-style broadcasts including
1568                // bidirectional `[N,1] op [1,S]`.
1569                let rank = out_dims_bcast.len();
1570                let mut coords = vec![0u32; rank];
1571                for i in 0..len {
1572                    let mut rem = i;
1573                    for ax in (0..rank).rev() {
1574                        let sz = out_dims_bcast[ax] as usize;
1575                        coords[ax] = (rem % sz) as u32;
1576                        rem /= sz;
1577                    }
1578                    let mut li: usize = 0;
1579                    let mut ri: usize = 0;
1580                    for ax in 0..rank {
1581                        li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
1582                        ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
1583                    }
1584                    d[i] = binary_op_f64(*op, l[li], r[ri]);
1585                }
1586            } else {
1587                // Fallback: legacy modulo path (preserved for
1588                // dynamic-shape graphs where strides can't be
1589                // precomputed). Only correct for scalar /
1590                // last-axis broadcast.
1591                for i in 0..len {
1592                    d[i] = binary_op_f64(*op, l[i % lhs_len], r[i % rhs_len]);
1593                }
1594            }
1595        }
1596    }
1597}
1598
1599#[inline(always)]
1600pub(crate) fn exec_binary_full_c64(t: &Thunk, base: *mut u8) {
1601    let Thunk::BinaryFullC64 {
1602        lhs,
1603        rhs,
1604        dst,
1605        len,
1606        lhs_len,
1607        rhs_len,
1608        op,
1609        out_dims_bcast,
1610        bcast_lhs_strides,
1611        bcast_rhs_strides,
1612    } = t
1613    else {
1614        unreachable!()
1615    };
1616    {
1617        // Complex element layout: [re_0, im_0, re_1, im_1, ...]
1618        // Underlying f32 buffer length is 2·N (N = complex
1619        // element count). All offsets are byte offsets; the
1620        // `sl` helper reads as f32 starting at the byte
1621        // offset, so f32-length = 2·complex-len.
1622        let n_out = *len as usize;
1623        let n_l = *lhs_len as usize;
1624        let n_r = *rhs_len as usize;
1625        unsafe {
1626            let l = sl(*lhs, base, 2 * n_l);
1627            let r = sl(*rhs, base, 2 * n_r);
1628            let d = sl_mut(*dst, base, 2 * n_out);
1629            let do_c64 = |a_re: f32, a_im: f32, b_re: f32, b_im: f32| -> (f32, f32) {
1630                match op {
1631                    BinaryOp::Add => (a_re + b_re, a_im + b_im),
1632                    BinaryOp::Sub => (a_re - b_re, a_im - b_im),
1633                    BinaryOp::Mul => (a_re * b_re - a_im * b_im, a_re * b_im + a_im * b_re),
1634                    BinaryOp::Div => {
1635                        let denom = b_re * b_re + b_im * b_im;
1636                        (
1637                            (a_re * b_re + a_im * b_im) / denom,
1638                            (a_im * b_re - a_re * b_im) / denom,
1639                        )
1640                    }
1641                    BinaryOp::Max
1642                    | BinaryOp::Min
1643                    | BinaryOp::Pow
1644                    | BinaryOp::Mod
1645                    | BinaryOp::BitAnd
1646                    | BinaryOp::BitOr
1647                    | BinaryOp::BitXor
1648                    | BinaryOp::Shl
1649                    | BinaryOp::Shr
1650                    | BinaryOp::Atan2 => {
1651                        unreachable!("C64 max/min/pow/mod/bitwise/atan2 rejected at lowering")
1652                    }
1653                }
1654            };
1655            if n_l == n_out && n_r == n_out {
1656                for i in 0..n_out {
1657                    let (re, im) = do_c64(l[2 * i], l[2 * i + 1], r[2 * i], r[2 * i + 1]);
1658                    d[2 * i] = re;
1659                    d[2 * i + 1] = im;
1660                }
1661            } else if !out_dims_bcast.is_empty() {
1662                // Strided complex broadcast: strides are in
1663                // *complex element* units; multiply by 2 when
1664                // indexing into the f32 buffer.
1665                let rank = out_dims_bcast.len();
1666                let mut coords = vec![0u32; rank];
1667                for i in 0..n_out {
1668                    let mut rem = i;
1669                    for ax in (0..rank).rev() {
1670                        let sz = out_dims_bcast[ax] as usize;
1671                        coords[ax] = (rem % sz) as u32;
1672                        rem /= sz;
1673                    }
1674                    let mut li: usize = 0;
1675                    let mut ri: usize = 0;
1676                    for ax in 0..rank {
1677                        li += coords[ax] as usize * bcast_lhs_strides[ax] as usize;
1678                        ri += coords[ax] as usize * bcast_rhs_strides[ax] as usize;
1679                    }
1680                    let (re, im) = do_c64(l[2 * li], l[2 * li + 1], r[2 * ri], r[2 * ri + 1]);
1681                    d[2 * i] = re;
1682                    d[2 * i + 1] = im;
1683                }
1684            } else {
1685                // Modulo fallback (scalar / last-axis broadcast).
1686                for i in 0..n_out {
1687                    let li = if n_l == 1 { 0 } else { i % n_l };
1688                    let ri = if n_r == 1 { 0 } else { i % n_r };
1689                    let (re, im) = do_c64(l[2 * li], l[2 * li + 1], r[2 * ri], r[2 * ri + 1]);
1690                    d[2 * i] = re;
1691                    d[2 * i + 1] = im;
1692                }
1693            }
1694        }
1695    }
1696}
1697
1698#[inline(always)]
1699pub(crate) fn exec_activation_c64(t: &Thunk, base: *mut u8) {
1700    let Thunk::ActivationC64 {
1701        src,
1702        dst,
1703        len,
1704        kind,
1705    } = t
1706    else {
1707        unreachable!()
1708    };
1709    {
1710        let n = *len as usize;
1711        unsafe {
1712            let s = sl(*src, base, 2 * n);
1713            let d = sl_mut(*dst, base, 2 * n);
1714            for i in 0..n {
1715                let a = s[2 * i];
1716                let b = s[2 * i + 1];
1717                let (re, im) = match kind {
1718                    Activation::Neg => (-a, -b),
1719                    Activation::Exp => {
1720                        // exp(a + bi) = e^a · (cos b + i·sin b)
1721                        let ea = a.exp();
1722                        (ea * b.cos(), ea * b.sin())
1723                    }
1724                    Activation::Log => {
1725                        // log(z) = log|z| + i·arg(z), principal branch
1726                        let r = (a * a + b * b).sqrt();
1727                        (r.ln(), b.atan2(a))
1728                    }
1729                    Activation::Sqrt => {
1730                        // sqrt(a+bi) = sqrt((|z|+a)/2) + sign(b)·i·sqrt((|z|-a)/2)
1731                        // Principal branch; for b == 0 and a < 0 returns +i·sqrt(|a|).
1732                        let r = (a * a + b * b).sqrt();
1733                        let re = ((r + a) * 0.5).max(0.0).sqrt();
1734                        let im_mag = ((r - a) * 0.5).max(0.0).sqrt();
1735                        let im = if b >= 0.0 { im_mag } else { -im_mag };
1736                        (re, im)
1737                    }
1738                    _ => unreachable!("non-C64 activation kind survived lowering"),
1739                };
1740                d[2 * i] = re;
1741                d[2 * i + 1] = im;
1742            }
1743        }
1744    }
1745}
1746
1747#[inline(always)]
1748pub(crate) fn exec_gather(t: &Thunk, base: *mut u8) {
1749    let Thunk::Gather {
1750        table,
1751        table_len,
1752        idx,
1753        dst,
1754        num_idx,
1755        trailing,
1756        idx_i64,
1757        table_bytes,
1758    } = t
1759    else {
1760        unreachable!()
1761    };
1762    {
1763        let (ni, tr) = (*num_idx as usize, *trailing as usize);
1764        let rows = *table_len as usize / tr.max(1);
1765        unsafe {
1766            if *table_bytes == 8 {
1767                let tab = sl_i64(*table, base, *table_len as usize);
1768                let out = sl_mut_i64(*dst, base, ni * tr);
1769                if *idx_i64 != 0 {
1770                    let ids = sl_i64(*idx, base, ni);
1771                    for i in 0..ni {
1772                        let row = ids[i].max(0) as usize;
1773                        if row < rows {
1774                            out[i * tr..(i + 1) * tr]
1775                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
1776                        }
1777                    }
1778                } else {
1779                    let ids = sl(*idx, base, ni);
1780                    for i in 0..ni {
1781                        let row = ids[i] as usize;
1782                        if row < rows {
1783                            out[i * tr..(i + 1) * tr]
1784                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
1785                        }
1786                    }
1787                }
1788            } else {
1789                let tab = sl(*table, base, *table_len as usize);
1790                let out = sl_mut(*dst, base, ni * tr);
1791                if *idx_i64 != 0 {
1792                    let ids = sl_i64(*idx, base, ni);
1793                    for i in 0..ni {
1794                        let row = ids[i].max(0) as usize;
1795                        if row < rows {
1796                            out[i * tr..(i + 1) * tr]
1797                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
1798                        }
1799                    }
1800                } else {
1801                    let ids = sl(*idx, base, ni);
1802                    for i in 0..ni {
1803                        let row = ids[i] as usize;
1804                        if row < rows {
1805                            out[i * tr..(i + 1) * tr]
1806                                .copy_from_slice(&tab[row * tr..(row + 1) * tr]);
1807                        }
1808                    }
1809                }
1810            }
1811        }
1812    }
1813}
1814
1815#[inline(always)]
1816pub(crate) fn exec_activation_in_place(t: &Thunk, base: *mut u8) {
1817    let Thunk::ActivationInPlace { data, len, act } = t else {
1818        unreachable!()
1819    };
1820    {
1821        let len = *len as usize;
1822        unsafe {
1823            let d = sl_mut(*data, base, len);
1824            // Serial scalar transcendentals were a per-op cost for models heavy in
1825            // tanh/sigmoid/exp (LSTM/GRU gates, vocoders). Apply elementwise in
1826            // parallel for large tensors (bit-exact — elements independent), mirroring
1827            // the already-parallel Gelu/Silu kernels. `should_parallelize` guards the
1828            // small case so tiny activations stay serial (no dispatch overhead).
1829            let par = crate::pool::should_parallelize(len);
1830            macro_rules! apply {
1831                ($f:expr) => {{
1832                    let f = $f;
1833                    if par {
1834                        use rayon::prelude::*;
1835                        d.par_iter_mut().for_each(|v| *v = f(*v));
1836                    } else {
1837                        for v in d.iter_mut() {
1838                            *v = f(*v);
1839                        }
1840                    }
1841                }};
1842            }
1843            match act {
1844                Activation::Gelu => crate::kernels::par_gelu_inplace(d),
1845                Activation::GeluApprox => crate::kernels::par_gelu_approx_inplace(d),
1846                Activation::Silu => crate::kernels::par_silu_inplace(d),
1847                Activation::Relu => apply!(|x: f32| x.max(0.0)),
1848                Activation::Sigmoid => apply!(|x: f32| 1.0 / (1.0 + (-x).exp())),
1849                Activation::Tanh => crate::vmath::vvtanhf_hot_inplace(d),
1850                Activation::Exp => crate::vmath::vvexpf_hot_inplace(d),
1851                Activation::Log => crate::vmath::vvlogf_inplace(d),
1852                Activation::Sqrt => crate::vmath::vvsqrtf_inplace(d),
1853                Activation::Rsqrt => crate::vmath::vvrsqrtf_inplace(d),
1854                Activation::Neg => apply!(|x: f32| -x),
1855                Activation::Abs => apply!(|x: f32| x.abs()),
1856                Activation::Round => apply!(|x: f32| x.round()),
1857                Activation::Sin => apply!(|x: f32| x.sin()),
1858                Activation::Cos => apply!(|x: f32| x.cos()),
1859                Activation::Tan => apply!(|x: f32| x.tan()),
1860                Activation::Atan => apply!(|x: f32| x.atan()),
1861                Activation::Recip => crate::vmath::vvrecf_inplace(d),
1862                Activation::Floor => apply!(|x: f32| x.floor()),
1863                Activation::Ceil => apply!(|x: f32| x.ceil()),
1864                Activation::Sign => {
1865                    apply!(|x: f32| if x > 0.0 {
1866                        1.0
1867                    } else if x < 0.0 {
1868                        -1.0
1869                    } else {
1870                        0.0
1871                    })
1872                }
1873                Activation::Softplus => apply!(|x: f32| x.max(0.0) + (-(x.abs())).exp().ln_1p()),
1874                Activation::Elu => apply!(|x: f32| if x > 0.0 { x } else { x.exp() - 1.0 }),
1875                Activation::Erf => apply!(|x: f32| erf_f32(x)),
1876                Activation::HardSwish => apply!(|x: f32| x * (x + 3.0).clamp(0.0, 6.0) / 6.0),
1877                Activation::HardSigmoid => apply!(|x: f32| (x / 6.0 + 0.5).clamp(0.0, 1.0)),
1878                Activation::Mish => {
1879                    apply!(|x: f32| x * (x.max(0.0) + (-(x.abs())).exp().ln_1p()).tanh())
1880                }
1881                Activation::Softsign => apply!(|x: f32| x / (1.0 + x.abs())),
1882                Activation::LogSigmoid => {
1883                    apply!(|x: f32| x.min(0.0) - (-(x.abs())).exp().ln_1p())
1884                }
1885            }
1886        }
1887    }
1888}
1889
1890#[inline(always)]
1891/// In-place KV-cache append — one row write, independent of context length.
1892///
1893/// The output aliases `dst` (the planner gives it the cache's slot), so there
1894/// is nothing to copy but the row itself. `outer == 1` is the common
1895/// `[1, seq, heads*dim]` cache, which reduces to a single `copy_from_slice`.
1896pub(crate) fn exec_kv_append(t: &Thunk, base: *mut u8) {
1897    let Thunk::KvAppend {
1898        src,
1899        dst,
1900        outer,
1901        seq_cap,
1902        pos,
1903        inner_bytes,
1904    } = t
1905    else {
1906        unreachable!()
1907    };
1908    let (outer, seq_cap, pos, inner_bytes) = (
1909        *outer as usize,
1910        *seq_cap as usize,
1911        *pos as usize,
1912        *inner_bytes as usize,
1913    );
1914    if inner_bytes == 0 || outer == 0 || pos >= seq_cap {
1915        // `pos >= seq_cap` would write past the cache. Refuse rather than
1916        // corrupt a neighbouring tensor: the row is dropped and the model
1917        // degrades visibly, instead of silently scribbling on the arena.
1918        return;
1919    }
1920    unsafe {
1921        let row = base.add(*src);
1922        let cache = base.add(*dst);
1923        if outer == 1 {
1924            // The common `[1, seq, heads*dim]` cache: one contiguous memcpy.
1925            std::ptr::copy_nonoverlapping(row, cache.add(pos * inner_bytes), inner_bytes);
1926        } else {
1927            let stride = seq_cap * inner_bytes;
1928            for o in 0..outer {
1929                std::ptr::copy_nonoverlapping(
1930                    row.add(o * inner_bytes),
1931                    cache.add(o * stride + pos * inner_bytes),
1932                    inner_bytes,
1933                );
1934            }
1935        }
1936    }
1937}
1938
1939pub(crate) fn exec_concat(t: &Thunk, base: *mut u8) {
1940    let Thunk::Concat {
1941        dst,
1942        outer,
1943        inner,
1944        total_axis,
1945        inputs,
1946    } = t
1947    else {
1948        unreachable!()
1949    };
1950    {
1951        let outer = *outer as usize;
1952        let inner = *inner as usize;
1953        let total_axis = *total_axis as usize;
1954        let row_stride = total_axis * inner;
1955        let out_total = outer * row_stride;
1956        unsafe {
1957            let out = sl_mut(*dst, base, out_total);
1958            let mut cum: usize = 0;
1959            for (src_off, in_axis, in_numel) in inputs {
1960                let in_axis = *in_axis as usize;
1961                let copy_per_row = in_axis * inner;
1962                let dst_col_off = cum * inner;
1963                let inp = sl(*src_off, base, (*in_numel as usize).max(1));
1964                concat_copy_rows_f32(
1965                    out,
1966                    inp,
1967                    outer,
1968                    copy_per_row,
1969                    row_stride,
1970                    dst_col_off,
1971                    *in_numel as usize,
1972                );
1973                cum += in_axis;
1974            }
1975        }
1976    }
1977}
1978
1979#[inline(always)]
1980pub(crate) fn exec_concat_f64(t: &Thunk, base: *mut u8) {
1981    let Thunk::ConcatF64 {
1982        dst,
1983        outer,
1984        inner,
1985        total_axis,
1986        inputs,
1987    } = t
1988    else {
1989        unreachable!()
1990    };
1991    {
1992        let outer = *outer as usize;
1993        let inner = *inner as usize;
1994        let total_axis = *total_axis as usize;
1995        let row_stride = total_axis * inner;
1996        let out_total = outer * row_stride;
1997        unsafe {
1998            let out = sl_mut_f64(*dst, base, out_total);
1999            let mut cum: usize = 0;
2000            for (src_off, in_axis, in_numel) in inputs {
2001                let in_axis = *in_axis as usize;
2002                let copy_per_row = in_axis * inner;
2003                let dst_col_off = cum * inner;
2004                let inp = sl_f64(*src_off, base, (*in_numel as usize).max(1));
2005                concat_copy_rows_f64(
2006                    out,
2007                    inp,
2008                    outer,
2009                    copy_per_row,
2010                    row_stride,
2011                    dst_col_off,
2012                    *in_numel as usize,
2013                );
2014                cum += in_axis;
2015            }
2016        }
2017    }
2018}
2019
2020#[inline(always)]
2021pub(crate) fn exec_scatter_add(t: &Thunk, base: *mut u8) {
2022    let Thunk::ScatterAdd {
2023        updates,
2024        indices,
2025        dst,
2026        num_updates,
2027        out_dim,
2028        trailing,
2029    } = t
2030    else {
2031        unreachable!()
2032    };
2033    {
2034        let num_updates = *num_updates as usize;
2035        let out_dim = *out_dim as usize;
2036        let trailing = *trailing as usize;
2037        unsafe {
2038            let upd = sl(*updates, base, num_updates * trailing);
2039            let ids = sl(*indices, base, num_updates);
2040            let out = sl_mut(*dst, base, out_dim * trailing);
2041            // Zero the output first — semantics are accumulate-into-zeros.
2042            for v in out.iter_mut() {
2043                *v = 0.0;
2044            }
2045            for i in 0..num_updates {
2046                let row = ids[i] as usize;
2047                debug_assert!(row < out_dim, "ScatterAdd index out of range");
2048                let src_off = i * trailing;
2049                let dst_off = row * trailing;
2050                for j in 0..trailing {
2051                    out[dst_off + j] += upd[src_off + j];
2052                }
2053            }
2054        }
2055    }
2056}
2057
2058#[inline(always)]
2059pub(crate) fn exec_scatter_nd(t: &Thunk, base: *mut u8) {
2060    let Thunk::ScatterNd {
2061        data,
2062        indices,
2063        updates,
2064        dst,
2065        data_shape,
2066        indices_shape,
2067        data_len,
2068        updates_len,
2069        indices_len,
2070        indices_i64,
2071        reduction,
2072    } = t
2073    else {
2074        unreachable!()
2075    };
2076    let data_shape: Vec<usize> = data_shape.iter().map(|&d| d as usize).collect();
2077    let indices_shape: Vec<usize> = indices_shape.iter().map(|&d| d as usize).collect();
2078    let data_len = *data_len as usize;
2079    let updates_len = *updates_len as usize;
2080    let indices_len = *indices_len as usize;
2081    unsafe {
2082        let data_s = sl(*data, base, data_len);
2083        let updates_s = sl(*updates, base, updates_len);
2084        let out = sl_mut(*dst, base, data_len);
2085        let idx_i64: Vec<i64> = if *indices_i64 != 0 {
2086            let ptr = base.add(*indices) as *const i64;
2087            std::slice::from_raw_parts(ptr, indices_len).to_vec()
2088        } else {
2089            sl(*indices, base, indices_len)
2090                .iter()
2091                .map(|&x| x as i64)
2092                .collect()
2093        };
2094        let (offsets, slice) =
2095            crate::onnx_indexing::scatter_nd_dst_offsets(&data_shape, &idx_i64, &indices_shape);
2096        crate::onnx_indexing::scatter_nd_into_f32(
2097            data_s, updates_s, out, &offsets, slice, *reduction,
2098        );
2099    }
2100}
2101
2102fn load_indices_i64(base: *mut u8, off: usize, len: usize, as_i64: u8) -> Vec<i64> {
2103    unsafe {
2104        if as_i64 != 0 {
2105            let ptr = base.add(off) as *const i64;
2106            std::slice::from_raw_parts(ptr, len).to_vec()
2107        } else {
2108            sl(off, base, len).iter().map(|&x| x as i64).collect()
2109        }
2110    }
2111}
2112
2113#[inline(always)]
2114pub(crate) fn exec_scatter_elements(t: &Thunk, base: *mut u8) {
2115    let Thunk::ScatterElements {
2116        data,
2117        indices,
2118        updates,
2119        dst,
2120        data_shape,
2121        indices_shape,
2122        data_len,
2123        updates_len,
2124        indices_len,
2125        indices_i64,
2126        axis,
2127        reduction,
2128    } = t
2129    else {
2130        unreachable!()
2131    };
2132    let data_shape: Vec<usize> = data_shape.iter().map(|&d| d as usize).collect();
2133    let idx_shape: Vec<usize> = indices_shape.iter().map(|&d| d as usize).collect();
2134    unsafe {
2135        let data_s = sl(*data, base, *data_len as usize);
2136        let updates_s = sl(*updates, base, *updates_len as usize);
2137        let out = sl_mut(*dst, base, *data_len as usize);
2138        let idx = load_indices_i64(base, *indices, *indices_len as usize, *indices_i64);
2139        crate::onnx_indexing::scatter_elements_f32(
2140            data_s,
2141            updates_s,
2142            &idx,
2143            out,
2144            &data_shape,
2145            &idx_shape,
2146            *axis,
2147            *reduction,
2148        );
2149    }
2150}
2151
2152#[inline(always)]
2153pub(crate) fn exec_gather_nd(t: &Thunk, base: *mut u8) {
2154    let Thunk::GatherNd {
2155        data,
2156        indices,
2157        dst,
2158        data_shape,
2159        indices_shape,
2160        data_len,
2161        indices_len,
2162        out_len,
2163        indices_i64,
2164        batch_dims,
2165    } = t
2166    else {
2167        unreachable!()
2168    };
2169    let data_shape: Vec<usize> = data_shape.iter().map(|&d| d as usize).collect();
2170    let indices_shape: Vec<usize> = indices_shape.iter().map(|&d| d as usize).collect();
2171    unsafe {
2172        let data_s = sl(*data, base, *data_len as usize);
2173        let out = sl_mut(*dst, base, *out_len as usize);
2174        let idx = load_indices_i64(base, *indices, *indices_len as usize, *indices_i64);
2175        let (offsets, slice) = crate::onnx_indexing::gather_nd_src_offsets(
2176            &data_shape,
2177            &idx,
2178            &indices_shape,
2179            (*batch_dims).max(0) as usize,
2180        );
2181        for (t, &off) in offsets.iter().enumerate() {
2182            let d0 = t * slice;
2183            for j in 0..slice {
2184                if let (Some(&s), Some(d)) = (data_s.get(off + j), out.get_mut(d0 + j)) {
2185                    *d = s;
2186                }
2187            }
2188        }
2189    }
2190}
2191
2192#[inline(always)]
2193pub(crate) fn exec_gather_elements(t: &Thunk, base: *mut u8) {
2194    let Thunk::GatherElements {
2195        data,
2196        indices,
2197        dst,
2198        data_shape,
2199        indices_shape,
2200        data_len,
2201        indices_len,
2202        out_len,
2203        indices_i64,
2204        data_elem_bytes,
2205        axis,
2206    } = t
2207    else {
2208        unreachable!()
2209    };
2210    let data_shape: Vec<usize> = data_shape.iter().map(|&d| d as usize).collect();
2211    let indices_shape: Vec<usize> = indices_shape.iter().map(|&d| d as usize).collect();
2212    let elem_bytes = (*data_elem_bytes as usize).max(1);
2213    unsafe {
2214        let idx = load_indices_i64(base, *indices, *indices_len as usize, *indices_i64);
2215        if elem_bytes == 4 {
2216            // Fast path preserves the (well-tested) f32 element copy.
2217            let data_s = sl(*data, base, *data_len as usize);
2218            let out = sl_mut(*dst, base, *out_len as usize);
2219            crate::onnx_indexing::gather_elements_f32(
2220                data_s,
2221                &idx,
2222                out,
2223                &data_shape,
2224                &indices_shape,
2225                *axis,
2226            );
2227        } else {
2228            // Dtype-agnostic byte copy for I64/I8/… data (e.g. int64 token ids).
2229            let data_b =
2230                std::slice::from_raw_parts(base.add(*data), *data_len as usize * elem_bytes);
2231            let out_b =
2232                std::slice::from_raw_parts_mut(base.add(*dst), *out_len as usize * elem_bytes);
2233            crate::onnx_indexing::gather_elements_bytes(
2234                data_b,
2235                &idx,
2236                out_b,
2237                &data_shape,
2238                &indices_shape,
2239                *axis,
2240                elem_bytes,
2241            );
2242        }
2243    }
2244}
2245
2246#[inline(always)]
2247pub(crate) fn exec_relu_backward(t: &Thunk, base: *mut u8) {
2248    let Thunk::ReluBackward { x, dy, dx, len } = t else {
2249        unreachable!()
2250    };
2251    {
2252        let len = *len as usize;
2253        unsafe {
2254            let xs = sl(*x, base, len);
2255            let dys = sl(*dy, base, len);
2256            let out = sl_mut(*dx, base, len);
2257            if fast_conv_enabled() && crate::pool::should_parallelize(len) {
2258                let oa = out.as_mut_ptr() as usize;
2259                crate::pool::par_for(len, crate::pool::chunk_floor(len), &|off, cnt| {
2260                    for i in off..off + cnt {
2261                        *((oa as *mut f32).add(i)) = if xs[i] > 0.0 { dys[i] } else { 0.0 };
2262                    }
2263                });
2264            } else {
2265                for i in 0..len {
2266                    out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
2267                }
2268            }
2269        }
2270    }
2271}
2272
2273#[inline(always)]
2274pub(crate) fn exec_relu_backward_f64(t: &Thunk, base: *mut u8) {
2275    let Thunk::ReluBackwardF64 { x, dy, dx, len } = t else {
2276        unreachable!()
2277    };
2278    {
2279        let len = *len as usize;
2280        unsafe {
2281            let xs = sl_f64(*x, base, len);
2282            let dys = sl_f64(*dy, base, len);
2283            let out = sl_mut_f64(*dx, base, len);
2284            for i in 0..len {
2285                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
2286            }
2287        }
2288    }
2289}
2290
2291#[inline(always)]
2292pub(crate) fn exec_activation_backward(t: &Thunk, base: *mut u8) {
2293    let Thunk::ActivationBackward {
2294        x,
2295        dy,
2296        dx,
2297        len,
2298        kind,
2299    } = t
2300    else {
2301        unreachable!()
2302    };
2303    {
2304        let len = *len as usize;
2305        unsafe {
2306            let xs = sl(*x, base, len);
2307            let dys = sl(*dy, base, len);
2308            let out = sl_mut(*dx, base, len);
2309            activation_backward_kernel(*kind, xs, dys, out);
2310        }
2311    }
2312}
2313
2314#[inline(always)]
2315pub(crate) fn exec_activation_backward_f64(t: &Thunk, base: *mut u8) {
2316    let Thunk::ActivationBackwardF64 {
2317        x,
2318        dy,
2319        dx,
2320        len,
2321        kind,
2322    } = t
2323    else {
2324        unreachable!()
2325    };
2326    {
2327        let len = *len as usize;
2328        unsafe {
2329            let xs = sl_f64(*x, base, len);
2330            let dys = sl_f64(*dy, base, len);
2331            let out = sl_mut_f64(*dx, base, len);
2332            activation_backward_kernel_f64(*kind, xs, dys, out);
2333        }
2334    }
2335}
2336
2337#[inline(always)]
2338pub(crate) fn exec_gather_backward(t: &Thunk, base: *mut u8) {
2339    let Thunk::GatherBackward {
2340        dy,
2341        indices,
2342        dst,
2343        outer,
2344        axis_dim,
2345        num_idx,
2346        trailing,
2347    } = t
2348    else {
2349        unreachable!()
2350    };
2351    {
2352        let (outer, axis_dim, num_idx, trailing) = (
2353            *outer as usize,
2354            *axis_dim as usize,
2355            *num_idx as usize,
2356            *trailing as usize,
2357        );
2358        unsafe {
2359            let dys = sl(*dy, base, outer * num_idx * trailing);
2360            let ids = sl(*indices, base, num_idx);
2361            let out = sl_mut(*dst, base, outer * axis_dim * trailing);
2362            for v in out.iter_mut() {
2363                *v = 0.0;
2364            }
2365            crate::training_bwd::gather_axis_backward(
2366                dys, ids, out, outer, axis_dim, num_idx, trailing,
2367            );
2368        }
2369    }
2370}
2371
2372#[inline(always)]
2373pub(crate) fn exec_gather_axis(t: &Thunk, base: *mut u8) {
2374    let Thunk::GatherAxis {
2375        table,
2376        idx,
2377        dst,
2378        outer,
2379        axis_dim,
2380        num_idx,
2381        trailing,
2382        idx_i64,
2383        table_bytes,
2384    } = t
2385    else {
2386        unreachable!()
2387    };
2388    {
2389        let outer = *outer as usize;
2390        let axis_dim = *axis_dim as usize;
2391        let num_idx = *num_idx as usize;
2392        let trailing = *trailing as usize;
2393        unsafe {
2394            if *table_bytes == 8 {
2395                let tab = sl_i64(*table, base, outer * axis_dim * trailing);
2396                let out = sl_mut_i64(*dst, base, outer * num_idx * trailing);
2397                for o in 0..outer {
2398                    let tab_outer = o * axis_dim * trailing;
2399                    let out_outer = o * num_idx * trailing;
2400                    if *idx_i64 != 0 {
2401                        let ids = sl_i64(*idx, base, num_idx);
2402                        for k in 0..num_idx {
2403                            let row = ids[k].max(0) as usize;
2404                            if row < axis_dim {
2405                                let tab_row = tab_outer + row * trailing;
2406                                let out_row = out_outer + k * trailing;
2407                                out[out_row..out_row + trailing]
2408                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
2409                            }
2410                        }
2411                    } else {
2412                        let ids = sl(*idx, base, num_idx);
2413                        for k in 0..num_idx {
2414                            let row = ids[k] as usize;
2415                            if row < axis_dim {
2416                                let tab_row = tab_outer + row * trailing;
2417                                let out_row = out_outer + k * trailing;
2418                                out[out_row..out_row + trailing]
2419                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
2420                            }
2421                        }
2422                    }
2423                }
2424            } else {
2425                let tab = sl(*table, base, outer * axis_dim * trailing);
2426                let out = sl_mut(*dst, base, outer * num_idx * trailing);
2427                for o in 0..outer {
2428                    let tab_outer = o * axis_dim * trailing;
2429                    let out_outer = o * num_idx * trailing;
2430                    if *idx_i64 != 0 {
2431                        let ids = sl_i64(*idx, base, num_idx);
2432                        for k in 0..num_idx {
2433                            let row = ids[k].max(0) as usize;
2434                            if row < axis_dim {
2435                                let tab_row = tab_outer + row * trailing;
2436                                let out_row = out_outer + k * trailing;
2437                                out[out_row..out_row + trailing]
2438                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
2439                            }
2440                        }
2441                    } else {
2442                        let ids = sl(*idx, base, num_idx);
2443                        for k in 0..num_idx {
2444                            let row = ids[k] as usize;
2445                            if row < axis_dim {
2446                                let tab_row = tab_outer + row * trailing;
2447                                let out_row = out_outer + k * trailing;
2448                                out[out_row..out_row + trailing]
2449                                    .copy_from_slice(&tab[tab_row..tab_row + trailing]);
2450                            }
2451                        }
2452                    }
2453                }
2454            }
2455        }
2456    }
2457}
2458
2459#[inline(always)]
2460pub(crate) fn exec_reverse(t: &Thunk, base: *mut u8) {
2461    let Thunk::Reverse {
2462        src,
2463        dst,
2464        dims,
2465        rev_mask,
2466        elem_bytes,
2467    } = t
2468    else {
2469        unreachable!()
2470    };
2471    {
2472        let eb = *elem_bytes as usize;
2473        let rank = dims.len();
2474        let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
2475        let mut strides = vec![1usize; rank];
2476        for i in (0..rank.saturating_sub(1)).rev() {
2477            strides[i] = strides[i + 1] * dims[i + 1] as usize;
2478        }
2479        unsafe {
2480            let src_base = base.add(*src);
2481            let dst_base = base.add(*dst);
2482            for o in 0..total {
2483                let mut rem = o;
2484                let mut in_flat = 0usize;
2485                for ax in 0..rank {
2486                    let idx = rem / strides[ax];
2487                    rem %= strides[ax];
2488                    let in_idx = if rev_mask[ax] {
2489                        dims[ax] as usize - 1 - idx
2490                    } else {
2491                        idx
2492                    };
2493                    in_flat += in_idx * strides[ax];
2494                }
2495                std::ptr::copy_nonoverlapping(src_base.add(in_flat * eb), dst_base.add(o * eb), eb);
2496            }
2497        }
2498    }
2499}
2500
2501/// Host-fallback entry for `Op::GatedDeltaNet` (Metal / unified memory).
2502/// When `state == 0`, uses a zero-initialized scratch state per batch item.
2503/// Batch-general reverse/flip (dtype-agnostic, byte-copy). Shared by the CPU
2504/// `Thunk::Reverse` arm and the Metal/WGPU host paths. `dims` is the row-major
2505/// input shape; `rev_mask[a]` flips axis `a`. `src`/`dst` are byte offsets.
2506pub unsafe fn execute_reverse(
2507    src: usize,
2508    dst: usize,
2509    dims: &[u32],
2510    rev_mask: &[bool],
2511    elem_bytes: usize,
2512    base: *mut u8,
2513) {
2514    let rank = dims.len();
2515    let total: usize = dims.iter().map(|&d| d as usize).product::<usize>().max(1);
2516    let mut strides = vec![1usize; rank];
2517    for i in (0..rank.saturating_sub(1)).rev() {
2518        strides[i] = strides[i + 1] * dims[i + 1] as usize;
2519    }
2520    unsafe {
2521        let src_base = base.add(src);
2522        let dst_base = base.add(dst);
2523        for o in 0..total {
2524            let mut rem = o;
2525            let mut in_flat = 0usize;
2526            for ax in 0..rank {
2527                let idx = rem / strides[ax];
2528                rem %= strides[ax];
2529                let in_idx = if rev_mask[ax] {
2530                    dims[ax] as usize - 1 - idx
2531                } else {
2532                    idx
2533                };
2534                in_flat += in_idx * strides[ax];
2535            }
2536            std::ptr::copy_nonoverlapping(
2537                src_base.add(in_flat * elem_bytes),
2538                dst_base.add(o * elem_bytes),
2539                elem_bytes,
2540            );
2541        }
2542    }
2543}
2544
2545/// Output-indexed pad over the shared arena (mirrors [`execute_reverse`]).
2546/// Every mode is a gather from `src` to `dst`; only `Constant` positions —
2547/// where some axis coordinate falls outside the input — write `fill` (the
2548/// constant value pre-encoded in the output dtype, `elem_bytes` long).
2549///
2550/// `before[i]`/`after[i]` are the pad widths on axis `i`; the output extent is
2551/// `in_dims[i] + before[i] + after[i]`. Byte-generic like `execute_reverse`.
2552#[allow(clippy::too_many_arguments)]
2553pub unsafe fn execute_pad(
2554    src: usize,
2555    dst: usize,
2556    in_dims: &[u32],
2557    before: &[u32],
2558    after: &[u32],
2559    mode: rlx_ir::PadMode,
2560    fill: &[u8],
2561    elem_bytes: usize,
2562    base: *mut u8,
2563) {
2564    use rlx_ir::PadMode;
2565    let rank = in_dims.len();
2566    let in_dims: Vec<usize> = in_dims.iter().map(|&d| d as usize).collect();
2567    let out_dims: Vec<usize> = (0..rank)
2568        .map(|i| in_dims[i] + before[i] as usize + after[i] as usize)
2569        .collect();
2570    let total: usize = out_dims.iter().product::<usize>().max(1);
2571    let mut in_strides = vec![1usize; rank];
2572    let mut out_strides = vec![1usize; rank];
2573    for i in (0..rank.saturating_sub(1)).rev() {
2574        in_strides[i] = in_strides[i + 1] * in_dims[i + 1];
2575        out_strides[i] = out_strides[i + 1] * out_dims[i + 1];
2576    }
2577    unsafe {
2578        let src_base = base.add(src);
2579        let dst_base = base.add(dst);
2580        for o in 0..total {
2581            let mut rem = o;
2582            let mut in_flat = 0usize;
2583            let mut is_fill = false;
2584            for ax in 0..rank {
2585                let oc = (rem / out_strides[ax]) as isize;
2586                rem %= out_strides[ax];
2587                // Unpadded axis: identity (also avoids the reflect period=0 case).
2588                if before[ax] == 0 && after[ax] == 0 {
2589                    in_flat += oc as usize * in_strides[ax];
2590                    continue;
2591                }
2592                let n = in_dims[ax] as isize;
2593                let p = oc - before[ax] as isize;
2594                let ic: isize = match mode {
2595                    PadMode::Constant(_) => {
2596                        if p < 0 || p >= n {
2597                            is_fill = true;
2598                            break;
2599                        }
2600                        p
2601                    }
2602                    PadMode::Replicate => p.clamp(0, n - 1),
2603                    PadMode::Circular => ((p % n) + n) % n,
2604                    PadMode::Reflect => {
2605                        let period = 2 * (n - 1);
2606                        let mut i = ((p % period) + period) % period;
2607                        if i >= n {
2608                            i = period - i;
2609                        }
2610                        i
2611                    }
2612                };
2613                in_flat += ic as usize * in_strides[ax];
2614            }
2615            let dptr = dst_base.add(o * elem_bytes);
2616            if is_fill {
2617                std::ptr::copy_nonoverlapping(fill.as_ptr(), dptr, elem_bytes);
2618            } else {
2619                std::ptr::copy_nonoverlapping(src_base.add(in_flat * elem_bytes), dptr, elem_bytes);
2620            }
2621        }
2622    }
2623}
2624
2625/// Output-indexed strided slice over the shared arena (mirrors
2626/// [`execute_reverse`]). `out[..,j,..] = in[.., start + j*step, ..]` along
2627/// `axis` (`step` may be negative). Byte-generic.
2628#[allow(clippy::too_many_arguments)]
2629pub unsafe fn execute_slice(
2630    src: usize,
2631    dst: usize,
2632    in_dims: &[u32],
2633    axis: usize,
2634    start: usize,
2635    len: usize,
2636    step: i64,
2637    elem_bytes: usize,
2638    base: *mut u8,
2639) {
2640    let rank = in_dims.len();
2641    let in_dims: Vec<usize> = in_dims.iter().map(|&d| d as usize).collect();
2642    let mut out_dims = in_dims.clone();
2643    out_dims[axis] = len;
2644    let total: usize = out_dims.iter().product::<usize>().max(1);
2645    let mut in_strides = vec![1usize; rank];
2646    let mut out_strides = vec![1usize; rank];
2647    for i in (0..rank.saturating_sub(1)).rev() {
2648        in_strides[i] = in_strides[i + 1] * in_dims[i + 1];
2649        out_strides[i] = out_strides[i + 1] * out_dims[i + 1];
2650    }
2651    unsafe {
2652        let src_base = base.add(src);
2653        let dst_base = base.add(dst);
2654        for o in 0..total {
2655            let mut rem = o;
2656            let mut in_flat = 0usize;
2657            for ax in 0..rank {
2658                let oc = rem / out_strides[ax];
2659                rem %= out_strides[ax];
2660                let ic = if ax == axis {
2661                    (start as i64 + oc as i64 * step) as usize
2662                } else {
2663                    oc
2664                };
2665                in_flat += ic * in_strides[ax];
2666            }
2667            std::ptr::copy_nonoverlapping(
2668                src_base.add(in_flat * elem_bytes),
2669                dst_base.add(o * elem_bytes),
2670                elem_bytes,
2671            );
2672        }
2673    }
2674}
2675
2676pub unsafe fn execute_gather_backward_f32(
2677    dy: usize,
2678    indices: usize,
2679    dst: usize,
2680    outer: u32,
2681    axis_dim: u32,
2682    num_idx: u32,
2683    trailing: u32,
2684    base: *mut u8,
2685) {
2686    let (outer, axis_dim, num_idx, trailing) = (
2687        outer as usize,
2688        axis_dim as usize,
2689        num_idx as usize,
2690        trailing as usize,
2691    );
2692    let out = sl_mut(dst, base, outer * axis_dim * trailing);
2693    out.fill(0.0);
2694    crate::training_bwd::gather_axis_backward(
2695        sl(dy, base, outer * num_idx * trailing),
2696        sl(indices, base, num_idx),
2697        out,
2698        outer,
2699        axis_dim,
2700        num_idx,
2701        trailing,
2702    );
2703}
2704
2705/// Scalar activation for the fused-region interpreter. Matches the GPU region
2706/// kernel's math (e.g. tanh-approx GELU) so CPU/GPU region results agree.
2707#[inline]
2708pub(crate) fn region_activation_scalar(act: rlx_ir::op::Activation, x: f32) -> f32 {
2709    use rlx_ir::op::Activation as A;
2710    const GC: f32 = 0.797_884_6; // sqrt(2/pi)
2711    match act {
2712        A::Relu => x.max(0.0),
2713        A::Gelu | A::GeluApprox => 0.5 * x * (1.0 + (GC * (x + 0.044715 * x * x * x)).tanh()),
2714        A::Silu => x / (1.0 + (-x).exp()),
2715        A::Sigmoid => 1.0 / (1.0 + (-x).exp()),
2716        A::Tanh => x.tanh(),
2717        A::Exp => x.exp(),
2718        A::Log => x.ln(),
2719        A::Sqrt => x.sqrt(),
2720        A::Rsqrt => 1.0 / x.sqrt(),
2721        A::Neg => -x,
2722        A::Abs => x.abs(),
2723        A::Sin => x.sin(),
2724        A::Cos => x.cos(),
2725        A::Tan => x.tan(),
2726        A::Atan => x.atan(),
2727        A::Recip => 1.0 / x,
2728        A::Round => x.round(),
2729        A::Floor => x.floor(),
2730        A::Ceil => x.ceil(),
2731        A::Sign => {
2732            if x > 0.0 {
2733                1.0
2734            } else if x < 0.0 {
2735                -1.0
2736            } else {
2737                0.0
2738            }
2739        }
2740        A::Softplus => x.max(0.0) + (-(x.abs())).exp().ln_1p(),
2741        A::Elu => {
2742            if x > 0.0 {
2743                x
2744            } else {
2745                x.exp() - 1.0
2746            }
2747        }
2748        A::Erf => erf_f32(x),
2749        A::HardSwish => x * (x + 3.0).clamp(0.0, 6.0) / 6.0,
2750        A::HardSigmoid => (x / 6.0 + 0.5).clamp(0.0, 1.0),
2751        A::Mish => x * (x.max(0.0) + (-(x.abs())).exp().ln_1p()).tanh(),
2752        A::Softsign => x / (1.0 + x.abs()),
2753        A::LogSigmoid => x.min(0.0) - (-(x.abs())).exp().ln_1p(),
2754    }
2755}
2756
2757#[inline]
2758pub(crate) fn region_binary_scalar(op: rlx_ir::op::BinaryOp, l: f32, r: f32) -> f32 {
2759    use rlx_ir::op::BinaryOp as B;
2760    match op {
2761        B::Add => l + r,
2762        B::Sub => l - r,
2763        B::Mul => l * r,
2764        B::Div => l / r,
2765        B::Max => l.max(r),
2766        B::Min => l.min(r),
2767        B::Pow => l.powf(r),
2768        B::Mod => l % r,
2769        B::Atan2 => l.atan2(r),
2770        B::BitAnd => ((l as i64) & (r as i64)) as f32,
2771        B::BitOr => ((l as i64) | (r as i64)) as f32,
2772        B::BitXor => ((l as i64) ^ (r as i64)) as f32,
2773        B::Shl => (l as i64).wrapping_shl(r as u32) as f32,
2774        B::Shr => (l as i64).wrapping_shr(r as u32) as f32,
2775    }
2776}
2777
2778/// Value semantics of a fused `ChainStep::Cast` on the f32-uniform region
2779/// arena.
2780///
2781/// A fused chain carries every intermediate as `f32`, so a cast inside one
2782/// cannot change storage width — but it must still change the *value*, or
2783/// `Cast(F16)` silently keeps f32 precision and `Cast(I32)` silently keeps
2784/// the fraction. Rules mirror [`exec_cast_generic`]'s destination table so a
2785/// cast means the same thing fused and unfused: floats round to the target
2786/// (`F64` widens exactly from f32, so it is the identity here), int targets
2787/// truncate toward zero and saturate, `Bool` is `!= 0`, and complex targets
2788/// keep the real part (the chain has no imaginary lane).
2789pub(crate) fn region_cast_scalar(to: rlx_ir::DType, v: f32) -> f32 {
2790    use rlx_ir::DType as DT;
2791    match to {
2792        DT::F32 | DT::F64 | DT::C64 | DT::C128 => v,
2793        DT::F16 => half::f16::from_f32(v).to_f32(),
2794        DT::BF16 => half::bf16::from_f32(v).to_f32(),
2795        DT::I8 => v as i8 as f32,
2796        DT::I16 => v as i16 as f32,
2797        DT::I32 => v as i32 as f32,
2798        DT::I64 => v as i64 as f32,
2799        DT::U8 => v as u8 as f32,
2800        DT::U32 => v as u32 as f32,
2801        DT::Bool => f32::from(u8::from(v != 0.0)),
2802    }
2803}
2804
2805#[inline]
2806pub(crate) fn region_compare_scalar(op: rlx_ir::op::CmpOp, l: f32, r: f32) -> bool {
2807    use rlx_ir::op::CmpOp as C;
2808    match op {
2809        C::Eq => l == r,
2810        C::Ne => l != r,
2811        C::Lt => l < r,
2812        C::Le => l <= r,
2813        C::Gt => l > r,
2814        C::Ge => l >= r,
2815    }
2816}
2817
2818/// Resolve one chain operand for output element `gid`: a previous step result
2819/// (`scratch[s]`) or an external input read with broadcast (`input[gid % mod]`,
2820/// scalar inputs read element 0). `base` is the arena byte base.
2821#[inline]
2822pub(crate) fn region_resolve_operand(
2823    op: &rlx_ir::op::ChainOperand,
2824    gid: usize,
2825    base: *const u8,
2826    input_offs: &[usize],
2827    in_lanes: &[crate::thunk::types::LaneKind],
2828    scalar_mask: u32,
2829    modulus: &[u32; 16],
2830    scratch: &[f32; 32],
2831) -> f32 {
2832    use rlx_ir::op::ChainOperand as O;
2833    match op {
2834        O::Step(s) => scratch[*s as usize],
2835        O::Input(i) => {
2836            let i = *i as usize;
2837            let row = if (scalar_mask >> i) & 1 == 1 {
2838                0
2839            } else if modulus[i] != 0 {
2840                gid % modulus[i] as usize
2841            } else {
2842                gid
2843            };
2844            // Lane-aware: a packed F16/BF16 operand is 2 bytes wide and an
2845            // F64 one is 8, so a fixed f32 stride would read the wrong bytes.
2846            let lane = in_lanes
2847                .get(i)
2848                .copied()
2849                .unwrap_or(crate::thunk::types::LaneKind::F32);
2850            unsafe { lane.load(base, input_offs[i], row) }
2851        }
2852    }
2853}
2854
2855/// Evaluate a fused element-wise chain for output element `gid`, returning the
2856/// final step's value (what gets written to `dst[gid]`).
2857#[inline]
2858pub(crate) fn region_eval_elem(
2859    gid: usize,
2860    base: *const u8,
2861    input_offs: &[usize],
2862    in_lanes: &[crate::thunk::types::LaneKind],
2863    chain: &[rlx_ir::op::ChainStep],
2864    scalar_mask: u32,
2865    modulus: &[u32; 16],
2866) -> f32 {
2867    use rlx_ir::op::ChainStep as S;
2868    let mut scratch = [0f32; 32];
2869    let r = |o: &rlx_ir::op::ChainOperand, sc: &[f32; 32]| {
2870        region_resolve_operand(o, gid, base, input_offs, in_lanes, scalar_mask, modulus, sc)
2871    };
2872    for (k, step) in chain.iter().enumerate() {
2873        scratch[k] = match step {
2874            S::Activation(a, x) => region_activation_scalar(*a, r(x, &scratch)),
2875            S::Cast(to, x) => region_cast_scalar(*to, r(x, &scratch)),
2876            S::Binary(op, l, rr) => region_binary_scalar(*op, r(l, &scratch), r(rr, &scratch)),
2877            S::Compare(op, l, rr) => {
2878                if region_compare_scalar(*op, r(l, &scratch), r(rr, &scratch)) {
2879                    1.0
2880                } else {
2881                    0.0
2882                }
2883            }
2884            S::Where(c, t, f) => {
2885                if r(c, &scratch) != 0.0 {
2886                    r(t, &scratch)
2887                } else {
2888                    r(f, &scratch)
2889                }
2890            }
2891        };
2892    }
2893    scratch[chain.len() - 1]
2894}
2895
2896// Unsafe helpers to create slices from arena base + offset
2897/// In-place per-element activation. Mirrors the dispatch in
2898/// `Thunk::ActivationInPlace`. Used by `Thunk::FusedMmBiasAct` to
2899/// apply the activation after `bias_add` for all non-Gelu cases.
2900#[inline(always)]
2901pub(crate) fn apply_activation_inplace(d: &mut [f32], act: rlx_ir::op::Activation) {
2902    use rlx_ir::op::Activation;
2903    match act {
2904        Activation::Gelu => crate::kernels::par_gelu_inplace(d),
2905        Activation::GeluApprox => crate::kernels::par_gelu_approx_inplace(d),
2906        Activation::Silu => crate::kernels::par_silu_inplace(d),
2907        Activation::Relu => {
2908            for v in d.iter_mut() {
2909                *v = v.max(0.0);
2910            }
2911        }
2912        Activation::Sigmoid => {
2913            let x = d.to_vec();
2914            crate::vmath::vvsigmoidf(d, &x);
2915        }
2916        Activation::Tanh => crate::vmath::vvtanhf_hot_inplace(d),
2917        Activation::Exp => crate::vmath::vvexpf_hot_inplace(d),
2918        Activation::Log => crate::vmath::vvlogf_inplace(d),
2919        Activation::Sqrt => crate::vmath::vvsqrtf_inplace(d),
2920        Activation::Rsqrt => crate::vmath::vvrsqrtf_inplace(d),
2921        Activation::Neg => {
2922            for v in d.iter_mut() {
2923                *v = -*v;
2924            }
2925        }
2926        Activation::Abs => {
2927            for v in d.iter_mut() {
2928                *v = v.abs();
2929            }
2930        }
2931        Activation::Round => {
2932            for v in d.iter_mut() {
2933                *v = v.round();
2934            }
2935        }
2936        Activation::Sin => {
2937            for v in d.iter_mut() {
2938                *v = v.sin();
2939            }
2940        }
2941        Activation::Cos => {
2942            for v in d.iter_mut() {
2943                *v = v.cos();
2944            }
2945        }
2946        Activation::Tan => {
2947            for v in d.iter_mut() {
2948                *v = v.tan();
2949            }
2950        }
2951        Activation::Atan => {
2952            for v in d.iter_mut() {
2953                *v = v.atan();
2954            }
2955        }
2956        Activation::Recip => crate::vmath::vvrecf_inplace(d),
2957        Activation::Floor => {
2958            for v in d.iter_mut() {
2959                *v = v.floor();
2960            }
2961        }
2962        Activation::Ceil => {
2963            for v in d.iter_mut() {
2964                *v = v.ceil();
2965            }
2966        }
2967        Activation::Sign => {
2968            for v in d.iter_mut() {
2969                *v = if *v > 0.0 {
2970                    1.0
2971                } else if *v < 0.0 {
2972                    -1.0
2973                } else {
2974                    0.0
2975                };
2976            }
2977        }
2978        Activation::Softplus => {
2979            for v in d.iter_mut() {
2980                *v = v.max(0.0) + (-(v.abs())).exp().ln_1p();
2981            }
2982        }
2983        Activation::Elu => {
2984            for v in d.iter_mut() {
2985                *v = if *v > 0.0 { *v } else { v.exp() - 1.0 };
2986            }
2987        }
2988        Activation::Erf => {
2989            for v in d.iter_mut() {
2990                *v = erf_f32(*v);
2991            }
2992        }
2993        Activation::HardSwish => {
2994            for v in d.iter_mut() {
2995                let x = *v;
2996                *v = x * (x + 3.0).clamp(0.0, 6.0) / 6.0;
2997            }
2998        }
2999        Activation::HardSigmoid => {
3000            for v in d.iter_mut() {
3001                *v = (*v / 6.0 + 0.5).clamp(0.0, 1.0);
3002            }
3003        }
3004        Activation::Mish => {
3005            for v in d.iter_mut() {
3006                let x = *v;
3007                *v = x * (x.max(0.0) + (-(x.abs())).exp().ln_1p()).tanh();
3008            }
3009        }
3010        Activation::Softsign => {
3011            for v in d.iter_mut() {
3012                let x = *v;
3013                *v = x / (1.0 + x.abs());
3014            }
3015        }
3016        Activation::LogSigmoid => {
3017            for v in d.iter_mut() {
3018                let x = *v;
3019                *v = x.min(0.0) - (-(x.abs())).exp().ln_1p();
3020            }
3021        }
3022    }
3023}
3024
3025pub(crate) fn activation_backward_kernel(
3026    act: rlx_ir::op::Activation,
3027    xs: &[f32],
3028    dys: &[f32],
3029    out: &mut [f32],
3030) {
3031    use rlx_ir::op::Activation;
3032    let n = xs.len();
3033    debug_assert_eq!(dys.len(), n);
3034    debug_assert_eq!(out.len(), n);
3035    match act {
3036        Activation::Relu => {
3037            for i in 0..n {
3038                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
3039            }
3040        }
3041        Activation::Sigmoid => {
3042            for i in 0..n {
3043                let s = 1.0 / (1.0 + (-xs[i]).exp());
3044                out[i] = s * (1.0 - s) * dys[i];
3045            }
3046        }
3047        Activation::Tanh => {
3048            for i in 0..n {
3049                let t = xs[i].tanh();
3050                out[i] = (1.0 - t * t) * dys[i];
3051            }
3052        }
3053        Activation::Silu => {
3054            // y = x * σ(x);  dy/dx = σ(x) * (1 + x * (1 - σ(x))).
3055            for i in 0..n {
3056                let s = 1.0 / (1.0 + (-xs[i]).exp());
3057                out[i] = s * (1.0 + xs[i] * (1.0 - s)) * dys[i];
3058            }
3059        }
3060        Activation::Gelu => {
3061            // Exact erf-based GELU:  y = 0.5 x (1 + erf(x / √2)).
3062            //   dy/dx = 0.5 (1 + erf(x/√2)) + (x / √(2π)) · exp(-x²/2)
3063            const INV_SQRT2: f32 = 0.707_106_77;
3064            const INV_SQRT_2PI: f32 = 0.398_942_3;
3065            for i in 0..n {
3066                let x = xs[i];
3067                let phi = 0.5 * (1.0 + erf_f32(x * INV_SQRT2));
3068                let pdf = INV_SQRT_2PI * (-(x * x) * 0.5).exp();
3069                out[i] = (phi + x * pdf) * dys[i];
3070            }
3071        }
3072        Activation::GeluApprox => {
3073            // Tanh-approximation:
3074            //   y = 0.5 x (1 + tanh(c · (x + 0.044715 x³))) where c = √(2/π).
3075            const C: f32 = 0.797_884_6; // √(2/π)
3076            const A: f32 = 0.044_715;
3077            for i in 0..n {
3078                let x = xs[i];
3079                let inner = C * (x + A * x * x * x);
3080                let t = inner.tanh();
3081                let dinner = C * (1.0 + 3.0 * A * x * x);
3082                let d = 0.5 * (1.0 + t) + 0.5 * x * (1.0 - t * t) * dinner;
3083                out[i] = d * dys[i];
3084            }
3085        }
3086        Activation::Exp => {
3087            for i in 0..n {
3088                out[i] = xs[i].exp() * dys[i];
3089            }
3090        }
3091        Activation::Log => {
3092            for i in 0..n {
3093                out[i] = dys[i] / xs[i];
3094            }
3095        }
3096        Activation::Sqrt => {
3097            // d/dx √x = 0.5 / √x — undefined at x=0; clamp to 0.
3098            for i in 0..n {
3099                let s = xs[i].sqrt();
3100                out[i] = if s > 0.0 { 0.5 * dys[i] / s } else { 0.0 };
3101            }
3102        }
3103        Activation::Rsqrt => {
3104            // d/dx (1/√x) = -0.5 · x^(-3/2).
3105            for i in 0..n {
3106                let s = xs[i].sqrt();
3107                out[i] = if s > 0.0 {
3108                    -0.5 * dys[i] / (xs[i] * s)
3109                } else {
3110                    0.0
3111                };
3112            }
3113        }
3114        Activation::Neg => {
3115            for i in 0..n {
3116                out[i] = -dys[i];
3117            }
3118        }
3119        Activation::Abs => {
3120            // sign(x); 0 at x=0.
3121            for i in 0..n {
3122                let x = xs[i];
3123                let s = if x > 0.0 {
3124                    1.0
3125                } else if x < 0.0 {
3126                    -1.0
3127                } else {
3128                    0.0
3129                };
3130                out[i] = s * dys[i];
3131            }
3132        }
3133        Activation::Round => {
3134            // STE: pretend the round was identity in the backward
3135            // pass. The round step has zero gradient almost
3136            // everywhere, so without this trick the optimizer can't
3137            // learn through it.
3138            out.copy_from_slice(dys);
3139        }
3140        Activation::Sin => {
3141            // d/dx sin(x) = cos(x).
3142            for i in 0..n {
3143                out[i] = xs[i].cos() * dys[i];
3144            }
3145        }
3146        Activation::Cos => {
3147            for i in 0..n {
3148                out[i] = -xs[i].sin() * dys[i];
3149            }
3150        }
3151        Activation::Tan => {
3152            // d/dx tan(x) = sec²(x) = 1 + tan²(x)
3153            for i in 0..n {
3154                let t = xs[i].tan();
3155                out[i] = (1.0 + t * t) * dys[i];
3156            }
3157        }
3158        Activation::Atan => {
3159            // d/dx atan(x) = 1 / (1 + x²)
3160            for i in 0..n {
3161                let x = xs[i];
3162                out[i] = dys[i] / (1.0 + x * x);
3163            }
3164        }
3165        Activation::Recip => {
3166            for i in 0..n {
3167                out[i] = -dys[i] / (xs[i] * xs[i]);
3168            }
3169        }
3170        // Piecewise-constant: zero gradient.
3171        Activation::Floor | Activation::Ceil | Activation::Sign => {
3172            for o in out.iter_mut() {
3173                *o = 0.0;
3174            }
3175        }
3176        Activation::Softplus => {
3177            for i in 0..n {
3178                out[i] = dys[i] / (1.0 + (-xs[i]).exp());
3179            }
3180        }
3181        Activation::Elu => {
3182            for i in 0..n {
3183                out[i] = if xs[i] > 0.0 {
3184                    dys[i]
3185                } else {
3186                    dys[i] * xs[i].exp()
3187                };
3188            }
3189        }
3190        // Decomposed at the AD level (vjp_activation) — never reach this kernel.
3191        Activation::Erf
3192        | Activation::HardSwish
3193        | Activation::HardSigmoid
3194        | Activation::Mish
3195        | Activation::Softsign
3196        | Activation::LogSigmoid => {
3197            panic!("activation_backward_kernel: {act:?} is decomposed at the AD level")
3198        }
3199    }
3200}
3201
3202/// f64 sibling of `activation_backward_kernel`. Same math, twice the
3203/// precision — used by f64 graphs where the f32 kernel reading bytes
3204/// as `&[f32]` would silently discard half of every f64 value.
3205pub(crate) fn activation_backward_kernel_f64(
3206    act: rlx_ir::op::Activation,
3207    xs: &[f64],
3208    dys: &[f64],
3209    out: &mut [f64],
3210) {
3211    use rlx_ir::op::Activation;
3212    let n = xs.len();
3213    debug_assert_eq!(dys.len(), n);
3214    debug_assert_eq!(out.len(), n);
3215    match act {
3216        Activation::Relu => {
3217            for i in 0..n {
3218                out[i] = if xs[i] > 0.0 { dys[i] } else { 0.0 };
3219            }
3220        }
3221        Activation::Sigmoid => {
3222            for i in 0..n {
3223                let s = 1.0 / (1.0 + (-xs[i]).exp());
3224                out[i] = s * (1.0 - s) * dys[i];
3225            }
3226        }
3227        Activation::Tanh => {
3228            for i in 0..n {
3229                let t = xs[i].tanh();
3230                out[i] = (1.0 - t * t) * dys[i];
3231            }
3232        }
3233        Activation::Silu => {
3234            for i in 0..n {
3235                let s = 1.0 / (1.0 + (-xs[i]).exp());
3236                out[i] = s * (1.0 + xs[i] * (1.0 - s)) * dys[i];
3237            }
3238        }
3239        Activation::Gelu | Activation::GeluApprox => {
3240            // Both rare on f64 paths; use the high-quality libm erf.
3241            const INV_SQRT2: f64 = std::f64::consts::FRAC_1_SQRT_2;
3242            const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
3243            for i in 0..n {
3244                let x = xs[i];
3245                let phi = 0.5 * (1.0 + erf_f64(x * INV_SQRT2));
3246                let pdf = INV_SQRT_2PI * (-(x * x) * 0.5).exp();
3247                out[i] = (phi + x * pdf) * dys[i];
3248            }
3249        }
3250        Activation::Exp => {
3251            for i in 0..n {
3252                out[i] = xs[i].exp() * dys[i];
3253            }
3254        }
3255        Activation::Log => {
3256            for i in 0..n {
3257                out[i] = dys[i] / xs[i];
3258            }
3259        }
3260        Activation::Sqrt => {
3261            for i in 0..n {
3262                let s = xs[i].sqrt();
3263                out[i] = if s > 0.0 { 0.5 * dys[i] / s } else { 0.0 };
3264            }
3265        }
3266        Activation::Rsqrt => {
3267            for i in 0..n {
3268                let s = xs[i].sqrt();
3269                out[i] = if s > 0.0 {
3270                    -0.5 * dys[i] / (xs[i] * s)
3271                } else {
3272                    0.0
3273                };
3274            }
3275        }
3276        Activation::Neg => {
3277            for i in 0..n {
3278                out[i] = -dys[i];
3279            }
3280        }
3281        Activation::Abs => {
3282            for i in 0..n {
3283                let x = xs[i];
3284                let s = if x > 0.0 {
3285                    1.0
3286                } else if x < 0.0 {
3287                    -1.0
3288                } else {
3289                    0.0
3290                };
3291                out[i] = s * dys[i];
3292            }
3293        }
3294        Activation::Round => {
3295            out.copy_from_slice(dys);
3296        }
3297        Activation::Sin => {
3298            for i in 0..n {
3299                out[i] = xs[i].cos() * dys[i];
3300            }
3301        }
3302        Activation::Cos => {
3303            for i in 0..n {
3304                out[i] = -xs[i].sin() * dys[i];
3305            }
3306        }
3307        Activation::Tan => {
3308            for i in 0..n {
3309                let t = xs[i].tan();
3310                out[i] = (1.0 + t * t) * dys[i];
3311            }
3312        }
3313        Activation::Atan => {
3314            for i in 0..n {
3315                let x = xs[i];
3316                out[i] = dys[i] / (1.0 + x * x);
3317            }
3318        }
3319        Activation::Recip => {
3320            for i in 0..n {
3321                out[i] = -dys[i] / (xs[i] * xs[i]);
3322            }
3323        }
3324        // Piecewise-constant: zero gradient.
3325        Activation::Floor | Activation::Ceil | Activation::Sign => {
3326            for o in out.iter_mut() {
3327                *o = 0.0;
3328            }
3329        }
3330        Activation::Softplus => {
3331            for i in 0..n {
3332                out[i] = dys[i] / (1.0 + (-xs[i]).exp());
3333            }
3334        }
3335        Activation::Elu => {
3336            for i in 0..n {
3337                out[i] = if xs[i] > 0.0 {
3338                    dys[i]
3339                } else {
3340                    dys[i] * xs[i].exp()
3341                };
3342            }
3343        }
3344        // Decomposed at the AD level (vjp_activation) — never reach this kernel.
3345        Activation::Erf
3346        | Activation::HardSwish
3347        | Activation::HardSigmoid
3348        | Activation::Mish
3349        | Activation::Softsign
3350        | Activation::LogSigmoid => {
3351            panic!("activation_backward_kernel: {act:?} is decomposed at the AD level")
3352        }
3353    }
3354}
3355
3356/// f64 erf via A&S 7.1.26 — same coefficients as `erf_f32`, computed
3357/// at f64 width. Max error ~1.5e-7 (limited by the polynomial, not the
3358/// arithmetic). Adequate for gradient kernels; if higher precision is
3359/// needed, swap in a libm dependency.
3360#[inline(always)]
3361pub(crate) fn erf_f64(x: f64) -> f64 {
3362    let s = x.signum();
3363    let x = x.abs();
3364    let t = 1.0 / (1.0 + 0.327_591_1 * x);
3365    let y = 1.0
3366        - (((((1.061_405_43 * t - 1.453_152_03) * t) + 1.421_413_75) * t - 0.284_496_74) * t
3367            + 0.254_829_59)
3368            * t
3369            * (-x * x).exp();
3370    s * y
3371}
3372
3373/// Cheap erf approximation (Abramowitz & Stegun 7.1.26, max error ~1.5e-7
3374/// over all of ℝ — plenty for f32 gradient kernels).
3375#[inline(always)]
3376pub(crate) fn erf_f32(x: f32) -> f32 {
3377    let s = x.signum();
3378    let x = x.abs();
3379    let t = 1.0 / (1.0 + 0.327_591_1 * x);
3380    let y = 1.0
3381        - (((((1.061_405_4 * t - 1.453_152_1) * t) + 1.421_413_8) * t - 0.284_496_74) * t
3382            + 0.254_829_6)
3383            * t
3384            * (-x * x).exp();
3385    s * y
3386}
3387
3388pub(crate) fn narrow_thunk_closure(
3389    src: usize,
3390    dst: usize,
3391    outer: u32,
3392    src_stride: u32,
3393    dst_stride: u32,
3394    inner: u32,
3395    elem_bytes: u8,
3396) -> Arc<dyn Fn(*mut u8) + Send + Sync> {
3397    let (outer, ss, ds, inner, eb) = (
3398        outer as usize,
3399        src_stride as usize,
3400        dst_stride as usize,
3401        inner as usize,
3402        elem_bytes as usize,
3403    );
3404    let row_bytes = inner.saturating_mul(eb);
3405    let src_row_stride = ss.saturating_mul(eb);
3406    let dst_row_stride = ds.saturating_mul(eb);
3407    Arc::new(move |base: *mut u8| unsafe {
3408        if row_bytes == 0 || src == dst {
3409            return;
3410        }
3411        // Compiled-fn path has no arena length; skip if offsets look bogus.
3412        let arena_len = usize::MAX;
3413        for o in 0..outer {
3414            let s_off = src + o * src_row_stride;
3415            let d_off = dst + o * dst_row_stride;
3416            if s_off == d_off {
3417                continue;
3418            }
3419            if s_off.saturating_add(row_bytes) > arena_len
3420                || d_off.saturating_add(row_bytes) > arena_len
3421            {
3422                break;
3423            }
3424            std::ptr::copy_nonoverlapping(base.add(s_off), base.add(d_off), row_bytes);
3425        }
3426    })
3427}
3428
3429/// f64 N-D index walk used by Transpose and Expand. `out_dims` gives
3430/// the output shape; `in_strides` gives the source stride for each
3431/// output dim (broadcast axes have stride 0).
3432pub(crate) fn transpose_walk_f64(
3433    inp: &[f64],
3434    out: &mut [f64],
3435    out_dims: &[u32],
3436    in_strides: &[u32],
3437) {
3438    let rank = out_dims.len();
3439    let mut idx = vec![0u32; rank];
3440    for o in 0..out.len() {
3441        let mut src_off = 0usize;
3442        for d in 0..rank {
3443            src_off += idx[d] as usize * in_strides[d] as usize;
3444        }
3445        out[o] = inp[broadcast_src_index(src_off, inp.len())];
3446        // Increment index — last dim varies fastest.
3447        for d in (0..rank).rev() {
3448            idx[d] += 1;
3449            if idx[d] < out_dims[d] {
3450                break;
3451            }
3452            idx[d] = 0;
3453        }
3454    }
3455}
3456
3457/// f64 elementwise activation. Reads `inp`, writes `out`. For now
3458/// covers what the autodiff-emitted gradient graph needs (Neg, Exp,
3459/// Log, Sqrt, Rsqrt, Abs, Tanh, Sigmoid, Relu — the
3460/// transcendental-free subset). Approximate Gelu/Silu deferred until a
3461/// workload demands them at f64.
3462pub(crate) fn apply_activation_f64(inp: &[f64], out: &mut [f64], kind: Activation) {
3463    match kind {
3464        Activation::Neg => {
3465            for (o, &v) in out.iter_mut().zip(inp) {
3466                *o = -v;
3467            }
3468        }
3469        Activation::Exp => {
3470            for (o, &v) in out.iter_mut().zip(inp) {
3471                *o = v.exp();
3472            }
3473        }
3474        Activation::Log => {
3475            for (o, &v) in out.iter_mut().zip(inp) {
3476                *o = v.ln();
3477            }
3478        }
3479        Activation::Sqrt => {
3480            for (o, &v) in out.iter_mut().zip(inp) {
3481                *o = v.sqrt();
3482            }
3483        }
3484        Activation::Rsqrt => {
3485            for (o, &v) in out.iter_mut().zip(inp) {
3486                *o = 1.0 / v.sqrt();
3487            }
3488        }
3489        Activation::Abs => {
3490            for (o, &v) in out.iter_mut().zip(inp) {
3491                *o = v.abs();
3492            }
3493        }
3494        Activation::Tanh => {
3495            for (o, &v) in out.iter_mut().zip(inp) {
3496                *o = v.tanh();
3497            }
3498        }
3499        Activation::Sigmoid => {
3500            for (o, &v) in out.iter_mut().zip(inp) {
3501                *o = 1.0 / (1.0 + (-v).exp());
3502            }
3503        }
3504        Activation::Relu => {
3505            for (o, &v) in out.iter_mut().zip(inp) {
3506                *o = v.max(0.0);
3507            }
3508        }
3509        Activation::Round => {
3510            for (o, &v) in out.iter_mut().zip(inp) {
3511                *o = v.round_ties_even();
3512            }
3513        }
3514        Activation::Sin => {
3515            for (o, &v) in out.iter_mut().zip(inp) {
3516                *o = v.sin();
3517            }
3518        }
3519        Activation::Cos => {
3520            for (o, &v) in out.iter_mut().zip(inp) {
3521                *o = v.cos();
3522            }
3523        }
3524        Activation::Tan => {
3525            for (o, &v) in out.iter_mut().zip(inp) {
3526                *o = v.tan();
3527            }
3528        }
3529        Activation::Atan => {
3530            for (o, &v) in out.iter_mut().zip(inp) {
3531                *o = v.atan();
3532            }
3533        }
3534        Activation::Recip => {
3535            for (o, &v) in out.iter_mut().zip(inp) {
3536                *o = 1.0 / v;
3537            }
3538        }
3539        Activation::Floor => {
3540            for (o, &v) in out.iter_mut().zip(inp) {
3541                *o = v.floor();
3542            }
3543        }
3544        Activation::Ceil => {
3545            for (o, &v) in out.iter_mut().zip(inp) {
3546                *o = v.ceil();
3547            }
3548        }
3549        Activation::Sign => {
3550            for (o, &v) in out.iter_mut().zip(inp) {
3551                *o = if v > 0.0 {
3552                    1.0
3553                } else if v < 0.0 {
3554                    -1.0
3555                } else {
3556                    0.0
3557                };
3558            }
3559        }
3560        Activation::Softplus => {
3561            for (o, &v) in out.iter_mut().zip(inp) {
3562                *o = v.max(0.0) + (-(v.abs())).exp().ln_1p();
3563            }
3564        }
3565        Activation::Elu => {
3566            for (o, &v) in out.iter_mut().zip(inp) {
3567                *o = if v > 0.0 { v } else { v.exp() - 1.0 };
3568            }
3569        }
3570        Activation::Erf => {
3571            for (o, &v) in out.iter_mut().zip(inp) {
3572                *o = erf_f64(v);
3573            }
3574        }
3575        Activation::HardSwish => {
3576            for (o, &v) in out.iter_mut().zip(inp) {
3577                *o = v * (v + 3.0).clamp(0.0, 6.0) / 6.0;
3578            }
3579        }
3580        Activation::HardSigmoid => {
3581            for (o, &v) in out.iter_mut().zip(inp) {
3582                *o = (v / 6.0 + 0.5).clamp(0.0, 1.0);
3583            }
3584        }
3585        Activation::Mish => {
3586            for (o, &v) in out.iter_mut().zip(inp) {
3587                *o = v * (v.max(0.0) + (-(v.abs())).exp().ln_1p()).tanh();
3588            }
3589        }
3590        Activation::Softsign => {
3591            for (o, &v) in out.iter_mut().zip(inp) {
3592                *o = v / (1.0 + v.abs());
3593            }
3594        }
3595        Activation::LogSigmoid => {
3596            for (o, &v) in out.iter_mut().zip(inp) {
3597                *o = v.min(0.0) - (-(v.abs())).exp().ln_1p();
3598            }
3599        }
3600        Activation::Gelu | Activation::GeluApprox | Activation::Silu => {
3601            panic!(
3602                "apply_activation_f64: {kind:?} not yet implemented at f64. \
3603                    Add when a workload needs it."
3604            );
3605        }
3606    }
3607}
3608
3609#[inline]
3610pub(crate) fn binary_op_f64(op: BinaryOp, a: f64, b: f64) -> f64 {
3611    match op {
3612        BinaryOp::Add => a + b,
3613        BinaryOp::Sub => a - b,
3614        BinaryOp::Mul => a * b,
3615        BinaryOp::Div => a / b,
3616        BinaryOp::Max => a.max(b),
3617        BinaryOp::Min => a.min(b),
3618        BinaryOp::Pow => a.powf(b),
3619        BinaryOp::Mod => a % b,
3620        BinaryOp::Atan2 => a.atan2(b),
3621        BinaryOp::BitAnd => ((a as i64) & (b as i64)) as f64,
3622        BinaryOp::BitOr => ((a as i64) | (b as i64)) as f64,
3623        BinaryOp::BitXor => ((a as i64) ^ (b as i64)) as f64,
3624        BinaryOp::Shl => ((a as i64) << (b as i64)) as f64,
3625        BinaryOp::Shr => ((a as i64) >> (b as i64)) as f64,
3626    }
3627}