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