Skip to main content

rlx_cpu/thunk/ops/
rnn.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4#![allow(unsafe_op_in_unsafe_fn)]
5use crate::thunk::*;
6
7/// Execute a compiled scan body over `length` steps against a raw arena buffer
8/// `base`. `outer_init_off` / `outer_final_off` are byte offsets into that
9/// arena; `xs_outer[i] = (outer_off, per_step_bytes)` and
10/// `bcast_outer[i] = (outer_off, total_bytes)` align with the plan's
11/// `xs_body_offs` / `bcast_body_offs`. When `save_trajectory`, row `t` is
12/// written to `outer_final_off + t * carry_bytes` (every step; checkpointed
13/// scans are not supported via this path).
14///
15/// # Safety
16/// `base` must point to a valid arena spanning every referenced offset.
17pub unsafe fn execute_scan_host(
18    base: *mut u8,
19    plan: &ScanBodyPlan,
20    outer_init_off: usize,
21    outer_final_off: usize,
22    length: u32,
23    save_trajectory: bool,
24    xs_outer: &[(usize, usize)],
25    bcast_outer: &[(usize, usize)],
26) {
27    let cb = plan.carry_bytes;
28    let mut body_buf: Vec<u8> = plan.body_init.clone();
29    unsafe {
30        std::ptr::copy_nonoverlapping(
31            base.add(outer_init_off),
32            body_buf.as_mut_ptr().add(plan.body_input_off),
33            cb,
34        );
35        for (i, &(outer_b_off, total)) in bcast_outer.iter().enumerate() {
36            std::ptr::copy_nonoverlapping(
37                base.add(outer_b_off),
38                body_buf.as_mut_ptr().add(plan.bcast_body_offs[i]),
39                total,
40            );
41        }
42    }
43    for t in 0..length as usize {
44        for (i, &(outer_xs_off, psb)) in xs_outer.iter().enumerate() {
45            unsafe {
46                std::ptr::copy_nonoverlapping(
47                    base.add(outer_xs_off + t * psb),
48                    body_buf.as_mut_ptr().add(plan.xs_body_offs[i]),
49                    psb,
50                );
51            }
52        }
53        execute_thunks(&plan.body, &mut body_buf);
54        if save_trajectory {
55            unsafe {
56                std::ptr::copy_nonoverlapping(
57                    body_buf.as_ptr().add(plan.body_output_off),
58                    base.add(outer_final_off + t * cb),
59                    cb,
60                );
61            }
62        }
63        if plan.body_output_off != plan.body_input_off {
64            body_buf.copy_within(
65                plan.body_output_off..plan.body_output_off + cb,
66                plan.body_input_off,
67            );
68        }
69    }
70    if !save_trajectory {
71        unsafe {
72            std::ptr::copy_nonoverlapping(
73                body_buf.as_ptr().add(plan.body_output_off),
74                base.add(outer_final_off),
75                cb,
76            );
77        }
78    }
79}
80
81#[allow(unused_variables)]
82pub(crate) fn compile_selective_scan(
83    node: &rlx_ir::Node,
84    graph: &Graph,
85    arena: &crate::arena::Arena,
86    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
87    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
88    rng: rlx_ir::RngOptions,
89) -> Thunk {
90    let Op::SelectiveScan { state_size } = &node.op else {
91        unreachable!()
92    };
93    {
94        let in_shape = &graph.node(node.inputs[0]).shape;
95        let (batch, seq, hidden) = (
96            in_shape.dim(0).unwrap_static(),
97            in_shape.dim(1).unwrap_static(),
98            in_shape.dim(2).unwrap_static(),
99        );
100        Thunk::SelectiveScan {
101            x: node_offset(arena, node.inputs[0]),
102            delta: node_offset(arena, node.inputs[1]),
103            a: node_offset(arena, node.inputs[2]),
104            b: node_offset(arena, node.inputs[3]),
105            c: node_offset(arena, node.inputs[4]),
106            dst: node_offset(arena, node.id),
107            batch: batch as u32,
108            seq: seq as u32,
109            hidden: hidden as u32,
110            state_size: *state_size as u32,
111        }
112    }
113}
114
115#[allow(unused_variables)]
116pub(crate) fn compile_gated_delta_net(
117    node: &rlx_ir::Node,
118    graph: &Graph,
119    arena: &crate::arena::Arena,
120    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
121    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
122    rng: rlx_ir::RngOptions,
123) -> Thunk {
124    let Op::GatedDeltaNet {
125        state_size,
126        carry_state,
127        gate_per_channel,
128    } = &node.op
129    else {
130        unreachable!()
131    };
132    {
133        let q_shape = &graph.node(node.inputs[0]).shape;
134        let (batch, seq, heads) = (
135            q_shape.dim(0).unwrap_static(),
136            q_shape.dim(1).unwrap_static(),
137            q_shape.dim(2).unwrap_static(),
138        );
139        let state_off = if *carry_state {
140            node_offset(arena, node.inputs[5])
141        } else {
142            0
143        };
144        Thunk::GatedDeltaNet {
145            q: node_offset(arena, node.inputs[0]),
146            k: node_offset(arena, node.inputs[1]),
147            v: node_offset(arena, node.inputs[2]),
148            g: node_offset(arena, node.inputs[3]),
149            beta: node_offset(arena, node.inputs[4]),
150            state: state_off,
151            dst: node_offset(arena, node.id),
152            batch: batch as u32,
153            seq: seq as u32,
154            heads: heads as u32,
155            state_size: *state_size as u32,
156            gate_per_channel: *gate_per_channel,
157            carry_state: *carry_state,
158        }
159    }
160}
161
162#[allow(unused_variables)]
163pub(crate) fn compile_lstm(
164    node: &rlx_ir::Node,
165    graph: &Graph,
166    arena: &crate::arena::Arena,
167    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
168    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
169    rng: rlx_ir::RngOptions,
170) -> Thunk {
171    let Op::Lstm {
172        hidden_size,
173        num_layers,
174        bidirectional,
175        carry,
176    } = &node.op
177    else {
178        unreachable!()
179    };
180    {
181        let x_shape = &graph.node(node.inputs[0]).shape;
182        let (batch, seq, input_size) = (
183            x_shape.dim(0).unwrap_static(),
184            x_shape.dim(1).unwrap_static(),
185            x_shape.dim(2).unwrap_static(),
186        );
187        let ex = rnn_expected_lens(
188            4,
189            batch,
190            seq,
191            input_size,
192            *hidden_size,
193            *num_layers,
194            *bidirectional,
195        );
196        let nelem = |i: usize| graph.node(node.inputs[i]).shape.num_elements().unwrap_or(0);
197        check_rnn_lens(
198            "lstm",
199            &[
200                ("w_ih", nelem(1), ex.w_ih),
201                ("w_hh", nelem(2), ex.w_hh),
202                ("bias", nelem(3), ex.bias),
203            ],
204        )
205        .unwrap_or_else(|e| panic!("{e}"));
206        let (h0, c0) = if *carry {
207            (
208                node_offset(arena, node.inputs[4]),
209                node_offset(arena, node.inputs[5]),
210            )
211        } else {
212            (0, 0)
213        };
214        Thunk::Lstm {
215            x: node_offset(arena, node.inputs[0]),
216            w_ih: node_offset(arena, node.inputs[1]),
217            w_hh: node_offset(arena, node.inputs[2]),
218            bias: node_offset(arena, node.inputs[3]),
219            h0,
220            c0,
221            dst: node_offset(arena, node.id),
222            batch: batch as u32,
223            seq: seq as u32,
224            input_size: input_size as u32,
225            hidden: *hidden_size as u32,
226            num_layers: *num_layers as u32,
227            bidirectional: *bidirectional,
228            carry: *carry,
229        }
230    }
231}
232
233#[allow(unused_variables)]
234pub(crate) fn compile_gru(
235    node: &rlx_ir::Node,
236    graph: &Graph,
237    arena: &crate::arena::Arena,
238    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
239    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
240    rng: rlx_ir::RngOptions,
241) -> Thunk {
242    let Op::Gru {
243        hidden_size,
244        num_layers,
245        bidirectional,
246        carry,
247    } = &node.op
248    else {
249        unreachable!()
250    };
251    {
252        let x_shape = &graph.node(node.inputs[0]).shape;
253        let (batch, seq, input_size) = (
254            x_shape.dim(0).unwrap_static(),
255            x_shape.dim(1).unwrap_static(),
256            x_shape.dim(2).unwrap_static(),
257        );
258        // Inputs: x, w_ih, w_hh, b_ih, b_hh (+ h0 when carry).
259        let ex = rnn_expected_lens(
260            3,
261            batch,
262            seq,
263            input_size,
264            *hidden_size,
265            *num_layers,
266            *bidirectional,
267        );
268        let nelem = |i: usize| graph.node(node.inputs[i]).shape.num_elements().unwrap_or(0);
269        check_rnn_lens(
270            "gru",
271            &[
272                ("w_ih", nelem(1), ex.w_ih),
273                ("w_hh", nelem(2), ex.w_hh),
274                ("b_ih", nelem(3), ex.bias),
275                ("b_hh", nelem(4), ex.bias),
276            ],
277        )
278        .unwrap_or_else(|e| panic!("{e}"));
279        let h0 = if *carry {
280            node_offset(arena, node.inputs[5])
281        } else {
282            0
283        };
284        Thunk::Gru {
285            x: node_offset(arena, node.inputs[0]),
286            w_ih: node_offset(arena, node.inputs[1]),
287            w_hh: node_offset(arena, node.inputs[2]),
288            b_ih: node_offset(arena, node.inputs[3]),
289            b_hh: node_offset(arena, node.inputs[4]),
290            h0,
291            dst: node_offset(arena, node.id),
292            batch: batch as u32,
293            seq: seq as u32,
294            input_size: input_size as u32,
295            hidden: *hidden_size as u32,
296            num_layers: *num_layers as u32,
297            bidirectional: *bidirectional,
298            carry: *carry,
299        }
300    }
301}
302
303#[allow(unused_variables)]
304pub(crate) fn compile_rnn(
305    node: &rlx_ir::Node,
306    graph: &Graph,
307    arena: &crate::arena::Arena,
308    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
309    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
310    rng: rlx_ir::RngOptions,
311) -> Thunk {
312    let Op::Rnn {
313        hidden_size,
314        num_layers,
315        bidirectional,
316        carry,
317        relu,
318    } = &node.op
319    else {
320        unreachable!()
321    };
322    {
323        let x_shape = &graph.node(node.inputs[0]).shape;
324        let (batch, seq, input_size) = (
325            x_shape.dim(0).unwrap_static(),
326            x_shape.dim(1).unwrap_static(),
327            x_shape.dim(2).unwrap_static(),
328        );
329        // Inputs: x, w_ih, w_hh, bias (+ h0 when carry).
330        let ex = rnn_expected_lens(
331            1,
332            batch,
333            seq,
334            input_size,
335            *hidden_size,
336            *num_layers,
337            *bidirectional,
338        );
339        let nelem = |i: usize| graph.node(node.inputs[i]).shape.num_elements().unwrap_or(0);
340        check_rnn_lens(
341            "rnn",
342            &[
343                ("w_ih", nelem(1), ex.w_ih),
344                ("w_hh", nelem(2), ex.w_hh),
345                ("bias", nelem(3), ex.bias),
346            ],
347        )
348        .unwrap_or_else(|e| panic!("{e}"));
349        let h0 = if *carry {
350            node_offset(arena, node.inputs[4])
351        } else {
352            0
353        };
354        Thunk::Rnn {
355            x: node_offset(arena, node.inputs[0]),
356            w_ih: node_offset(arena, node.inputs[1]),
357            w_hh: node_offset(arena, node.inputs[2]),
358            bias: node_offset(arena, node.inputs[3]),
359            h0,
360            dst: node_offset(arena, node.id),
361            batch: batch as u32,
362            seq: seq as u32,
363            input_size: input_size as u32,
364            hidden: *hidden_size as u32,
365            num_layers: *num_layers as u32,
366            bidirectional: *bidirectional,
367            carry: *carry,
368            relu: *relu,
369        }
370    }
371}
372
373#[allow(unused_variables)]
374pub(crate) fn compile_mamba2(
375    node: &rlx_ir::Node,
376    graph: &Graph,
377    arena: &crate::arena::Arena,
378    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
379    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
380    rng: rlx_ir::RngOptions,
381) -> Thunk {
382    let Op::Mamba2 {
383        head_dim,
384        state_size,
385    } = &node.op
386    else {
387        unreachable!()
388    };
389    {
390        // x [B,S,H,P]; dt [B,S,H]; a [H]; b,c [B,S,H,N].
391        let x_shape = &graph.node(node.inputs[0]).shape;
392        Thunk::Mamba2 {
393            x: node_offset(arena, node.inputs[0]),
394            dt: node_offset(arena, node.inputs[1]),
395            a: node_offset(arena, node.inputs[2]),
396            b: node_offset(arena, node.inputs[3]),
397            c: node_offset(arena, node.inputs[4]),
398            dst: node_offset(arena, node.id),
399            batch: x_shape.dim(0).unwrap_static() as u32,
400            seq: x_shape.dim(1).unwrap_static() as u32,
401            heads: x_shape.dim(2).unwrap_static() as u32,
402            head_dim: *head_dim as u32,
403            state_size: *state_size as u32,
404        }
405    }
406}
407
408#[allow(unused_variables)]
409pub(crate) fn compile_scan(
410    node: &rlx_ir::Node,
411    graph: &Graph,
412    arena: &crate::arena::Arena,
413    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
414    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
415    rng: rlx_ir::RngOptions,
416) -> Thunk {
417    let Op::Scan {
418        body,
419        length,
420        save_trajectory,
421        num_bcast,
422        num_xs,
423        num_checkpoints,
424    } = &node.op
425    else {
426        unreachable!()
427    };
428    {
429        assert!(
430            *num_checkpoints == 0 || *num_checkpoints <= *length,
431            "Op::Scan: num_checkpoints={} must be 0 or ≤ length={}",
432            *num_checkpoints,
433            *length
434        );
435        if *num_checkpoints != 0 && *num_checkpoints != *length {
436            assert!(
437                *save_trajectory,
438                "Op::Scan: num_checkpoints<length only meaningful when save_trajectory=true"
439            );
440        }
441        // Plan + compile the body sub-graph standalone. The body
442        // gets its own Arena; per execution we clone its
443        // pristine bytes, copy the outer carry (and per-step xs
444        // slices, if any) into the body's Input slots, run the
445        // body schedule N times, then copy the body's output
446        // back to the outer arena.
447        //
448        // Body invariants: 1 + num_xs Op::Inputs in NodeId order
449        // — first declared is the carry, rest are x_t_i. Single
450        // graph output (the next carry), same shape as carry.
451        let body_plan = rlx_opt::memory::plan_memory(body);
452        let _body_arena_size = body_plan.arena_size;
453        // Snapshot per-input byte offsets before plan_memory
454        // moves into the Arena below.
455        let body_offsets: HashMap<NodeId, usize> = body_plan
456            .assignments
457            .iter()
458            .map(|(id, slot)| (*id, slot.offset))
459            .collect();
460
461        // Collect body Input nodes in NodeId order; first is
462        // carry, rest are per-step xs in matching order.
463        let mut body_inputs: Vec<NodeId> = body
464            .nodes()
465            .iter()
466            .filter(|n| matches!(n.op, Op::Input { .. }))
467            .map(|n| n.id)
468            .collect();
469        body_inputs.sort();
470        let n_body_inputs = body_inputs.len();
471        let expected = 1 + *num_bcast as usize + *num_xs as usize;
472        if n_body_inputs != expected {
473            let names: Vec<String> = body
474                .nodes()
475                .iter()
476                .filter_map(|n| match &n.op {
477                    Op::Input { name } => Some(format!("{}={}", n.id, name)),
478                    _ => None,
479                })
480                .collect();
481            panic!(
482                "Op::Scan body has {} Op::Input nodes; expected {} \
483                            (1 carry + {} bcast + {} xs). Inputs by NodeId: [{}]",
484                n_body_inputs,
485                expected,
486                *num_bcast,
487                *num_xs,
488                names.join(", ")
489            );
490        }
491
492        let body_input_id = body_inputs[0];
493        let body_input_off = body_offsets[&body_input_id];
494        let body_output_id = body
495            .outputs
496            .first()
497            .copied()
498            .expect("Op::Scan body must declare one output");
499        let body_output_off = body_offsets[&body_output_id];
500
501        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
502        // Fill body Constant nodes — mirror the outer-graph logic
503        // in rlx-runtime/src/backend.rs (dtype-aware).
504        for n in body.nodes() {
505            if let Op::Constant { data } = &n.op
506                && body_arena.has_buffer(n.id)
507                && !data.is_empty()
508            {
509                match n.shape.dtype() {
510                    rlx_ir::DType::F64 => {
511                        let off = body_arena.byte_offset(n.id);
512                        let buf = body_arena.raw_buf_mut();
513                        let nbytes = (buf.len() - off).min(data.len());
514                        buf[off..off + nbytes].copy_from_slice(&data[..nbytes]);
515                    }
516                    _ => {
517                        let buf = body_arena.slice_mut(n.id);
518                        let n_floats = data.len() / 4;
519                        let n_lim = buf.len().min(n_floats);
520                        for i in 0..n_lim {
521                            let bytes = [
522                                data[i * 4],
523                                data[i * 4 + 1],
524                                data[i * 4 + 2],
525                                data[i * 4 + 3],
526                            ];
527                            buf[i] = f32::from_le_bytes(bytes);
528                        }
529                    }
530                }
531            }
532        }
533        let body_init = body_arena.raw_buf().to_vec();
534        let body_schedule = compile_thunks_with_rng(body, &body_arena, rng);
535
536        // Carry bytes — for trajectory mode, the outer node's
537        // shape is [length, *carry_shape], so dividing by length
538        // gives one row's bytes; the body's input slot still
539        // holds carry_shape bytes.
540        let carry_bytes = if *save_trajectory {
541            let total = node
542                .shape
543                .size_bytes()
544                .expect("Op::Scan trajectory output must have static shape");
545            total / *length as usize
546        } else {
547            node.shape
548                .size_bytes()
549                .expect("Op::Scan carry must have static shape")
550        };
551
552        // Bcast inputs occupy body_inputs[1..1+num_bcast] and
553        // outer node.inputs[1..1+num_bcast]. They keep their
554        // natural shape (no [length, ...] prefix) and are
555        // copied into body_buf ONCE before the scan loop.
556        let mut bcast_inputs: Vec<(usize, usize, u32)> = Vec::with_capacity(*num_bcast as usize);
557        for i in 0..*num_bcast as usize {
558            let body_b_id = body_inputs[1 + i];
559            let body_b_off = body_offsets[&body_b_id];
560            let outer_b_id = node.inputs[1 + i];
561            let outer_b_off = node_offset(arena, outer_b_id);
562            let outer_b_shape = &graph.node(outer_b_id).shape;
563            let total = outer_b_shape
564                .size_bytes()
565                .expect("Op::Scan bcast must have static shape");
566            bcast_inputs.push((body_b_off, outer_b_off, total as u32));
567        }
568
569        // xs occupy body_inputs[1+num_bcast..] and node.inputs
570        // [1+num_bcast..]. Each has shape [length, *per_step];
571        // per-step bytes = total / length.
572        let mut xs_inputs: Vec<(usize, usize, u32)> = Vec::with_capacity(*num_xs as usize);
573        let xs_base = 1 + *num_bcast as usize;
574        for i in 0..*num_xs as usize {
575            let body_x_id = body_inputs[xs_base + i];
576            let body_x_off = body_offsets[&body_x_id];
577            let outer_xs_id = node.inputs[xs_base + i];
578            let outer_xs_off = node_offset(arena, outer_xs_id);
579            let outer_xs_shape = &graph.node(outer_xs_id).shape;
580            let total = outer_xs_shape
581                .size_bytes()
582                .expect("Op::Scan xs must have static shape");
583            let per_step = total / *length as usize;
584            xs_inputs.push((body_x_off, outer_xs_off, per_step as u32));
585        }
586
587        Thunk::Scan {
588            body: Arc::new(body_schedule),
589            body_init: Arc::new(body_init),
590            body_input_off,
591            body_output_off,
592            outer_init_off: node_offset(arena, node.inputs[0]),
593            outer_final_off: node_offset(arena, node.id),
594            length: *length,
595            carry_bytes: carry_bytes as u32,
596            save_trajectory: *save_trajectory,
597            xs_inputs: Arc::new(xs_inputs),
598            bcast_inputs: Arc::new(bcast_inputs),
599            num_checkpoints: *num_checkpoints,
600        }
601    }
602}
603
604#[allow(unused_variables)]
605pub(crate) fn compile_scan_backward(
606    node: &rlx_ir::Node,
607    graph: &Graph,
608    arena: &crate::arena::Arena,
609    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
610    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
611    rng: rlx_ir::RngOptions,
612) -> Thunk {
613    let Op::ScanBackward {
614        body_vjp,
615        length,
616        save_trajectory,
617        num_xs,
618        num_checkpoints,
619        forward_body,
620    } = &node.op
621    else {
622        unreachable!()
623    };
624    {
625        let is_recursive = *num_checkpoints != 0 && *num_checkpoints != *length;
626        if is_recursive {
627            assert!(
628                forward_body.is_some(),
629                "Op::ScanBackward with num_checkpoints<length requires forward_body"
630            );
631        }
632        // body_vjp has signature
633        //   (carry, x_t_0, ..., x_t_{num_xs-1}, d_output) → dcarry
634        // Identify slots:
635        //   * "d_output" by exact name (AD-introduced seed Input).
636        //   * Remaining Inputs sorted by NodeId — first is the
637        //     carry mirror, rest are x_t_i mirrors in body's
638        //     original Op::Input declaration order.
639        let body_plan = rlx_opt::memory::plan_memory(body_vjp);
640        let body_offsets: HashMap<NodeId, usize> = body_plan
641            .assignments
642            .iter()
643            .map(|(id, slot)| (*id, slot.offset))
644            .collect();
645        let mut body_d_output_off: Option<usize> = None;
646        let mut body_other_inputs: Vec<(NodeId, usize)> = Vec::new();
647        for n in body_vjp.nodes() {
648            if let Op::Input { name } = &n.op {
649                let off = body_offsets[&n.id];
650                if name == "d_output" {
651                    body_d_output_off = Some(off);
652                } else {
653                    body_other_inputs.push((n.id, off));
654                }
655            }
656        }
657        body_other_inputs.sort_by_key(|(id, _)| *id);
658        let body_d_output_off =
659            body_d_output_off.expect("ScanBackward body_vjp missing 'd_output' Input");
660        let expected_others = 1 + *num_xs as usize;
661        assert_eq!(
662            body_other_inputs.len(),
663            expected_others,
664            "ScanBackward body_vjp has {} non-d_output Inputs; \
665                     expected {} (1 carry + {} xs)",
666            body_other_inputs.len(),
667            expected_others,
668            num_xs
669        );
670        let body_carry_in_off = body_other_inputs[0].1;
671        let body_x_offs: Vec<usize> = body_other_inputs
672            .iter()
673            .skip(1)
674            .map(|(_, off)| *off)
675            .collect();
676        let body_dcarry_out_off = body_offsets[&body_vjp.outputs[0]];
677
678        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
679        // Fill body_vjp's Constants (mirrors the Scan lowering).
680        for n in body_vjp.nodes() {
681            if let Op::Constant { data } = &n.op
682                && body_arena.has_buffer(n.id)
683                && !data.is_empty()
684            {
685                match n.shape.dtype() {
686                    rlx_ir::DType::F64 => {
687                        let off = body_arena.byte_offset(n.id);
688                        let buf = body_arena.raw_buf_mut();
689                        let nb = (buf.len() - off).min(data.len());
690                        buf[off..off + nb].copy_from_slice(&data[..nb]);
691                    }
692                    _ => {
693                        let buf = body_arena.slice_mut(n.id);
694                        let nf = data.len() / 4;
695                        let nl = buf.len().min(nf);
696                        for i in 0..nl {
697                            let bytes = [
698                                data[i * 4],
699                                data[i * 4 + 1],
700                                data[i * 4 + 2],
701                                data[i * 4 + 3],
702                            ];
703                            buf[i] = f32::from_le_bytes(bytes);
704                        }
705                    }
706                }
707            }
708        }
709        let body_init = body_arena.raw_buf().to_vec();
710        let body_schedule = compile_thunks_with_rng(body_vjp, &body_arena, rng);
711
712        // Carry bytes from the dcarry output node (== carry shape).
713        let carry_bytes = body_vjp
714            .node(body_vjp.outputs[0])
715            .shape
716            .size_bytes()
717            .expect("ScanBackward dcarry must be statically shaped");
718        let carry_elem_size = body_vjp
719            .node(body_vjp.outputs[0])
720            .shape
721            .dtype()
722            .size_bytes() as u32;
723
724        // For each xs input on the outer node:
725        // (outer_xs_base, per_step_bytes).
726        let mut outer_xs_offs: Vec<(usize, u32)> = Vec::with_capacity(*num_xs as usize);
727        for i in 0..*num_xs as usize {
728            let outer_xs_id = node.inputs[3 + i];
729            let outer_xs_off = node_offset(arena, outer_xs_id);
730            let outer_xs_shape = &graph.node(outer_xs_id).shape;
731            let total = outer_xs_shape
732                .size_bytes()
733                .expect("ScanBackward xs must have static shape");
734            let per_step = total / *length as usize;
735            outer_xs_offs.push((outer_xs_off, per_step as u32));
736        }
737
738        // If recursive checkpointing is active, we also compile
739        // the forward body so the executor can recompute
740        // intermediate carries. The forward body is supplied
741        // by the AD pass via `forward_body: Some(_)`.
742        let (fb_schedule, fb_init, fb_carry_in_off, fb_output_off, fb_x_offs) = if is_recursive {
743            let fb = forward_body.as_ref().unwrap();
744            let fb_plan = rlx_opt::memory::plan_memory(fb);
745            let fb_offsets: HashMap<NodeId, usize> = fb_plan
746                .assignments
747                .iter()
748                .map(|(id, slot)| (*id, slot.offset))
749                .collect();
750            let mut fb_inputs: Vec<NodeId> = fb
751                .nodes()
752                .iter()
753                .filter(|n| matches!(n.op, Op::Input { .. }))
754                .map(|n| n.id)
755                .collect();
756            fb_inputs.sort();
757            let fb_carry = fb_offsets[&fb_inputs[0]];
758            let fb_xs: Vec<usize> = (1..fb_inputs.len())
759                .map(|i| fb_offsets[&fb_inputs[i]])
760                .collect();
761            let fb_out = fb_offsets[&fb.outputs[0]];
762            let mut fb_arena = crate::arena::Arena::from_plan(fb_plan);
763            for n in fb.nodes() {
764                if let Op::Constant { data } = &n.op
765                    && fb_arena.has_buffer(n.id)
766                    && !data.is_empty()
767                {
768                    // Byte-copy works for any
769                    // numeric dtype as long as the
770                    // arena slot is sized to hold
771                    // it — the Constant's `data`
772                    // already encodes the right
773                    // bytes per element.
774                    let off = fb_arena.byte_offset(n.id);
775                    let buf = fb_arena.raw_buf_mut();
776                    let nb = (buf.len() - off).min(data.len());
777                    buf[off..off + nb].copy_from_slice(&data[..nb]);
778                }
779            }
780            let fb_init_bytes = fb_arena.raw_buf().to_vec();
781            let fb_sched = compile_thunks_with_rng(fb, &fb_arena, rng);
782            (
783                Some(Arc::new(fb_sched)),
784                Some(Arc::new(fb_init_bytes)),
785                fb_carry,
786                fb_out,
787                fb_xs,
788            )
789        } else {
790            (None, None, 0, 0, Vec::new())
791        };
792
793        Thunk::ScanBackward {
794            body_vjp: Arc::new(body_schedule),
795            body_init: Arc::new(body_init),
796            body_carry_in_off,
797            body_x_offs: Arc::new(body_x_offs),
798            body_d_output_off,
799            body_dcarry_out_off,
800            outer_init_off: node_offset(arena, node.inputs[0]),
801            outer_traj_off: node_offset(arena, node.inputs[1]),
802            outer_upstream_off: node_offset(arena, node.inputs[2]),
803            outer_xs_offs: Arc::new(outer_xs_offs),
804            outer_dinit_off: node_offset(arena, node.id),
805            length: *length,
806            carry_bytes: carry_bytes as u32,
807            carry_elem_size,
808            save_trajectory: *save_trajectory,
809            num_checkpoints: *num_checkpoints,
810            forward_body: fb_schedule,
811            forward_body_init: fb_init,
812            forward_body_carry_in_off: fb_carry_in_off,
813            forward_body_output_off: fb_output_off,
814            forward_body_x_offs: Arc::new(fb_x_offs),
815        }
816    }
817}
818
819#[allow(unused_variables)]
820pub(crate) fn compile_scan_backward_xs(
821    node: &rlx_ir::Node,
822    graph: &Graph,
823    arena: &crate::arena::Arena,
824    matmul_fold: &std::collections::HashMap<NodeId, (NodeId, bool, NodeId, bool)>,
825    rng_shared: &std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
826    rng: rlx_ir::RngOptions,
827) -> Thunk {
828    let Op::ScanBackwardXs {
829        body_vjp,
830        length,
831        save_trajectory,
832        num_xs,
833        xs_idx,
834        num_checkpoints,
835        forward_body,
836    } = &node.op
837    else {
838        unreachable!()
839    };
840    {
841        assert!(
842            *num_checkpoints == 0 || *num_checkpoints <= *length,
843            "Op::ScanBackwardXs: num_checkpoints={} must be 0 or ≤ length={}",
844            *num_checkpoints,
845            *length
846        );
847        let is_recursive = *num_checkpoints != 0 && *num_checkpoints != *length;
848        if is_recursive {
849            assert!(
850                forward_body.is_some(),
851                "Op::ScanBackwardXs with num_checkpoints<length \
852                         requires forward_body"
853            );
854        }
855        // Mirror ScanBackward's body_vjp slot identification +
856        // arena prep, then add: per-iteration extraction of the
857        // body_vjp output that corresponds to the chosen xs.
858        //
859        // body_vjp's outputs (from `grad(body, [carry, xs_0, ..., xs_{num_xs-1}])`):
860        //   outputs[0]      = dcarry
861        //   outputs[1 + i]  = dx_t_i
862        let body_plan = rlx_opt::memory::plan_memory(body_vjp);
863        let body_offsets: HashMap<NodeId, usize> = body_plan
864            .assignments
865            .iter()
866            .map(|(id, slot)| (*id, slot.offset))
867            .collect();
868        let mut body_d_output_off: Option<usize> = None;
869        let mut body_other_inputs: Vec<(NodeId, usize)> = Vec::new();
870        for n in body_vjp.nodes() {
871            if let Op::Input { name } = &n.op {
872                let off = body_offsets[&n.id];
873                if name == "d_output" {
874                    body_d_output_off = Some(off);
875                } else {
876                    body_other_inputs.push((n.id, off));
877                }
878            }
879        }
880        body_other_inputs.sort_by_key(|(id, _)| *id);
881        let body_d_output_off =
882            body_d_output_off.expect("ScanBackwardXs body_vjp missing 'd_output' Input");
883        let expected_others = 1 + *num_xs as usize;
884        assert_eq!(
885            body_other_inputs.len(),
886            expected_others,
887            "ScanBackwardXs body_vjp has {} non-d_output Inputs; expected {}",
888            body_other_inputs.len(),
889            expected_others
890        );
891        let body_carry_in_off = body_other_inputs[0].1;
892        let body_x_offs: Vec<usize> = body_other_inputs
893            .iter()
894            .skip(1)
895            .map(|(_, off)| *off)
896            .collect();
897        let body_dcarry_out_off = body_offsets[&body_vjp.outputs[0]];
898        let dxs_out_node = body_vjp.outputs[1 + *xs_idx as usize];
899        let body_dxs_out_off = body_offsets[&dxs_out_node];
900
901        let mut body_arena = crate::arena::Arena::from_plan(body_plan);
902        for n in body_vjp.nodes() {
903            if let Op::Constant { data } = &n.op
904                && body_arena.has_buffer(n.id)
905                && !data.is_empty()
906            {
907                match n.shape.dtype() {
908                    rlx_ir::DType::F64 => {
909                        let off = body_arena.byte_offset(n.id);
910                        let buf = body_arena.raw_buf_mut();
911                        let nb = (buf.len() - off).min(data.len());
912                        buf[off..off + nb].copy_from_slice(&data[..nb]);
913                    }
914                    _ => {
915                        let buf = body_arena.slice_mut(n.id);
916                        let nf = data.len() / 4;
917                        let nl = buf.len().min(nf);
918                        for i in 0..nl {
919                            let bytes = [
920                                data[i * 4],
921                                data[i * 4 + 1],
922                                data[i * 4 + 2],
923                                data[i * 4 + 3],
924                            ];
925                            buf[i] = f32::from_le_bytes(bytes);
926                        }
927                    }
928                }
929            }
930        }
931        let body_init = body_arena.raw_buf().to_vec();
932        let body_schedule = compile_thunks_with_rng(body_vjp, &body_arena, rng);
933
934        let carry_bytes = body_vjp
935            .node(body_vjp.outputs[0])
936            .shape
937            .size_bytes()
938            .expect("ScanBackwardXs dcarry must be statically shaped");
939        let carry_elem_size = body_vjp
940            .node(body_vjp.outputs[0])
941            .shape
942            .dtype()
943            .size_bytes() as u32;
944        let per_step_bytes = body_vjp
945            .node(dxs_out_node)
946            .shape
947            .size_bytes()
948            .expect("ScanBackwardXs dxs body output must be statically shaped");
949
950        let mut outer_xs_offs: Vec<(usize, u32)> = Vec::with_capacity(*num_xs as usize);
951        for i in 0..*num_xs as usize {
952            let outer_xs_id = node.inputs[3 + i];
953            let outer_xs_off = node_offset(arena, outer_xs_id);
954            let outer_xs_shape = &graph.node(outer_xs_id).shape;
955            let total = outer_xs_shape
956                .size_bytes()
957                .expect("ScanBackwardXs xs must have static shape");
958            let per_step = total / *length as usize;
959            outer_xs_offs.push((outer_xs_off, per_step as u32));
960        }
961
962        // Compile forward_body for recompute when checkpointed.
963        // Mirrors the same code path in the ScanBackward arm.
964        let (fb_schedule, fb_init, fb_carry_in_off, fb_output_off, fb_x_offs) = if is_recursive {
965            let fb = forward_body.as_ref().unwrap();
966            let fb_plan = rlx_opt::memory::plan_memory(fb);
967            let fb_offsets: HashMap<NodeId, usize> = fb_plan
968                .assignments
969                .iter()
970                .map(|(id, slot)| (*id, slot.offset))
971                .collect();
972            let mut fb_inputs: Vec<NodeId> = fb
973                .nodes()
974                .iter()
975                .filter(|n| matches!(n.op, Op::Input { .. }))
976                .map(|n| n.id)
977                .collect();
978            fb_inputs.sort();
979            let fb_carry = fb_offsets[&fb_inputs[0]];
980            let fb_xs: Vec<usize> = (1..fb_inputs.len())
981                .map(|i| fb_offsets[&fb_inputs[i]])
982                .collect();
983            let fb_out = fb_offsets[&fb.outputs[0]];
984            let mut fb_arena = crate::arena::Arena::from_plan(fb_plan);
985            for n in fb.nodes() {
986                if let Op::Constant { data } = &n.op
987                    && fb_arena.has_buffer(n.id)
988                    && !data.is_empty()
989                {
990                    // Byte-copy works for any
991                    // numeric dtype as long as the
992                    // arena slot is sized to hold
993                    // it — the Constant's `data`
994                    // already encodes the right
995                    // bytes per element.
996                    let off = fb_arena.byte_offset(n.id);
997                    let buf = fb_arena.raw_buf_mut();
998                    let nb = (buf.len() - off).min(data.len());
999                    buf[off..off + nb].copy_from_slice(&data[..nb]);
1000                }
1001            }
1002            let fb_init_bytes = fb_arena.raw_buf().to_vec();
1003            let fb_sched = compile_thunks_with_rng(fb, &fb_arena, rng);
1004            (
1005                Some(Arc::new(fb_sched)),
1006                Some(Arc::new(fb_init_bytes)),
1007                fb_carry,
1008                fb_out,
1009                fb_xs,
1010            )
1011        } else {
1012            (None, None, 0, 0, Vec::new())
1013        };
1014
1015        Thunk::ScanBackwardXs {
1016            body_vjp: Arc::new(body_schedule),
1017            body_init: Arc::new(body_init),
1018            body_carry_in_off,
1019            body_x_offs: Arc::new(body_x_offs),
1020            body_d_output_off,
1021            body_dcarry_out_off,
1022            body_dxs_out_off,
1023            outer_init_off: node_offset(arena, node.inputs[0]),
1024            outer_traj_off: node_offset(arena, node.inputs[1]),
1025            outer_upstream_off: node_offset(arena, node.inputs[2]),
1026            outer_xs_offs: Arc::new(outer_xs_offs),
1027            outer_dxs_off: node_offset(arena, node.id),
1028            length: *length,
1029            carry_bytes: carry_bytes as u32,
1030            carry_elem_size,
1031            per_step_bytes: per_step_bytes as u32,
1032            save_trajectory: *save_trajectory,
1033            num_checkpoints: *num_checkpoints,
1034            forward_body: fb_schedule,
1035            forward_body_init: fb_init,
1036            forward_body_carry_in_off: fb_carry_in_off,
1037            forward_body_output_off: fb_output_off,
1038            forward_body_x_offs: Arc::new(fb_x_offs),
1039        }
1040    }
1041}
1042
1043#[inline(always)]
1044pub(crate) fn exec_scan(t: &Thunk, base: *mut u8) {
1045    let Thunk::Scan {
1046        body,
1047        body_init,
1048        body_input_off,
1049        body_output_off,
1050        outer_init_off,
1051        outer_final_off,
1052        length,
1053        carry_bytes,
1054        save_trajectory,
1055        xs_inputs,
1056        bcast_inputs,
1057        num_checkpoints,
1058    } = t
1059    else {
1060        unreachable!()
1061    };
1062    {
1063        let cb = *carry_bytes as usize;
1064        let n_steps = *length as usize;
1065        // Checkpoint mode: when 0 < K < length, save trajectory[k]
1066        // only when t == c_k = floor((k+1) * length / K) - 1.
1067        // The last index c_{K-1} = length - 1 always.
1068        let k_total = if *num_checkpoints == 0 || *num_checkpoints == *length {
1069            n_steps // save every step
1070        } else {
1071            *num_checkpoints as usize
1072        };
1073        let checkpoint_t_for_k = |k: usize| -> usize {
1074            if k_total == n_steps {
1075                k
1076            } else {
1077                ((k + 1) * n_steps)
1078                    .div_ceil(k_total)
1079                    .saturating_sub(1)
1080                    .min(n_steps - 1)
1081            }
1082        };
1083        let mut next_k = 0usize;
1084
1085        let mut body_buf: Vec<u8> = (**body_init).clone();
1086        unsafe {
1087            std::ptr::copy_nonoverlapping(
1088                base.add(*outer_init_off),
1089                body_buf.as_mut_ptr().add(*body_input_off),
1090                cb,
1091            );
1092            // Broadcast inputs: copy each one into the body's
1093            // input slot ONCE. They aren't touched in the
1094            // iteration loop below (in contrast to xs).
1095            for (body_b_off, outer_b_off, total_bytes) in bcast_inputs.iter() {
1096                std::ptr::copy_nonoverlapping(
1097                    base.add(*outer_b_off),
1098                    body_buf.as_mut_ptr().add(*body_b_off),
1099                    *total_bytes as usize,
1100                );
1101            }
1102        }
1103        for t in 0..n_steps {
1104            for (body_x_off, outer_xs_off, per_step_bytes) in xs_inputs.iter() {
1105                let psb = *per_step_bytes as usize;
1106                unsafe {
1107                    std::ptr::copy_nonoverlapping(
1108                        base.add(*outer_xs_off + t * psb),
1109                        body_buf.as_mut_ptr().add(*body_x_off),
1110                        psb,
1111                    );
1112                }
1113            }
1114
1115            execute_thunks(body, &mut body_buf);
1116
1117            if *save_trajectory && next_k < k_total && t == checkpoint_t_for_k(next_k) {
1118                unsafe {
1119                    std::ptr::copy_nonoverlapping(
1120                        body_buf.as_ptr().add(*body_output_off),
1121                        base.add(*outer_final_off + next_k * cb),
1122                        cb,
1123                    );
1124                }
1125                next_k += 1;
1126            }
1127
1128            if *body_output_off != *body_input_off {
1129                body_buf.copy_within(*body_output_off..*body_output_off + cb, *body_input_off);
1130            }
1131        }
1132
1133        if !*save_trajectory {
1134            // Single final-carry write.
1135            unsafe {
1136                std::ptr::copy_nonoverlapping(
1137                    body_buf.as_ptr().add(*body_output_off),
1138                    base.add(*outer_final_off),
1139                    cb,
1140                );
1141            }
1142        }
1143    }
1144}
1145
1146#[inline(always)]
1147pub(crate) fn exec_scan_backward_xs(t: &Thunk, base: *mut u8) {
1148    let Thunk::ScanBackwardXs {
1149        body_vjp,
1150        body_init,
1151        body_carry_in_off,
1152        body_x_offs,
1153        body_d_output_off,
1154        body_dcarry_out_off,
1155        body_dxs_out_off,
1156        outer_init_off,
1157        outer_traj_off,
1158        outer_upstream_off,
1159        outer_xs_offs,
1160        outer_dxs_off,
1161        length,
1162        carry_bytes,
1163        carry_elem_size,
1164        per_step_bytes,
1165        save_trajectory,
1166        num_checkpoints,
1167        forward_body,
1168        forward_body_init,
1169        forward_body_carry_in_off,
1170        forward_body_output_off,
1171        forward_body_x_offs,
1172    } = t
1173    else {
1174        unreachable!()
1175    };
1176    {
1177        let cb = *carry_bytes as usize;
1178        let psb = *per_step_bytes as usize;
1179        let n_steps = *length as usize;
1180        let k_total = *num_checkpoints as usize;
1181        let is_recursive = k_total != 0 && k_total != n_steps;
1182        let checkpoint_t_for_k = |k: usize| -> usize {
1183            ((k + 1) * n_steps)
1184                .div_ceil(k_total)
1185                .saturating_sub(1)
1186                .min(n_steps - 1)
1187        };
1188
1189        // Forward-body recompute scratch + segment cache —
1190        // exact mirror of the ScanBackward path. With ≈√length
1191        // checkpoints, total recompute work is O(length).
1192        let mut fwd_buf: Vec<u8> = if is_recursive {
1193            (**forward_body_init.as_ref().unwrap()).clone()
1194        } else {
1195            Vec::new()
1196        };
1197        let mut seg_cache: Vec<u8> = Vec::new();
1198        let mut seg_start_t: usize = usize::MAX;
1199        let mut seg_count: usize = 0;
1200        let recompute_carry_t = |t: usize,
1201                                 dst: &mut [u8],
1202                                 fwd_buf: &mut Vec<u8>,
1203                                 seg_cache: &mut Vec<u8>,
1204                                 seg_start_t: &mut usize,
1205                                 seg_count: &mut usize| {
1206            if !is_recursive {
1207                unsafe {
1208                    let src = if t == 0 {
1209                        base.add(*outer_init_off)
1210                    } else {
1211                        base.add(*outer_traj_off + (t - 1) * cb)
1212                    };
1213                    std::ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), cb);
1214                }
1215                return;
1216            }
1217            if *seg_start_t != usize::MAX && t >= *seg_start_t && t < *seg_start_t + *seg_count {
1218                let off = (t - *seg_start_t) * cb;
1219                dst.copy_from_slice(&seg_cache[off..off + cb]);
1220                return;
1221            }
1222            let seg_k = (0..k_total)
1223                .find(|&k| t <= checkpoint_t_for_k(k))
1224                .unwrap_or(k_total - 1);
1225            let (anchor_t, anchor_ptr): (usize, *const u8) = if seg_k == 0 {
1226                (0, unsafe { base.add(*outer_init_off) as *const u8 })
1227            } else {
1228                let prev_ck = checkpoint_t_for_k(seg_k - 1);
1229                (prev_ck + 1, unsafe {
1230                    base.add(*outer_traj_off + (seg_k - 1) * cb) as *const u8
1231                })
1232            };
1233            let seg_end_t = checkpoint_t_for_k(seg_k);
1234            let seg_size = seg_end_t - anchor_t + 1;
1235
1236            fwd_buf.copy_from_slice(forward_body_init.as_ref().unwrap());
1237            unsafe {
1238                std::ptr::copy_nonoverlapping(
1239                    anchor_ptr,
1240                    fwd_buf.as_mut_ptr().add(*forward_body_carry_in_off),
1241                    cb,
1242                );
1243            }
1244            seg_cache.resize(seg_size * cb, 0u8);
1245            seg_cache[0..cb].copy_from_slice(
1246                &fwd_buf[*forward_body_carry_in_off..*forward_body_carry_in_off + cb],
1247            );
1248            let fb_sched = forward_body.as_ref().unwrap();
1249            for i in 1..seg_size {
1250                let cur_iter = anchor_t + i - 1;
1251                for (idx, fb_x_off) in forward_body_x_offs.iter().enumerate() {
1252                    let (outer_xs_off, x_psb) = outer_xs_offs[idx];
1253                    let xb = x_psb as usize;
1254                    unsafe {
1255                        std::ptr::copy_nonoverlapping(
1256                            base.add(outer_xs_off + cur_iter * xb),
1257                            fwd_buf.as_mut_ptr().add(*fb_x_off),
1258                            xb,
1259                        );
1260                    }
1261                }
1262                execute_thunks(fb_sched, fwd_buf);
1263                if *forward_body_output_off != *forward_body_carry_in_off {
1264                    fwd_buf.copy_within(
1265                        *forward_body_output_off..*forward_body_output_off + cb,
1266                        *forward_body_carry_in_off,
1267                    );
1268                }
1269                let cache_off = i * cb;
1270                seg_cache[cache_off..cache_off + cb].copy_from_slice(
1271                    &fwd_buf[*forward_body_carry_in_off..*forward_body_carry_in_off + cb],
1272                );
1273            }
1274            *seg_start_t = anchor_t;
1275            *seg_count = seg_size;
1276
1277            let off = (t - anchor_t) * cb;
1278            dst.copy_from_slice(&seg_cache[off..off + cb]);
1279        };
1280
1281        let mut dcarry: Vec<u8> = vec![0u8; cb];
1282        if !*save_trajectory {
1283            unsafe {
1284                std::ptr::copy_nonoverlapping(
1285                    base.add(*outer_upstream_off),
1286                    dcarry.as_mut_ptr(),
1287                    cb,
1288                );
1289            }
1290        }
1291
1292        let mut body_buf: Vec<u8> = (**body_init).clone();
1293
1294        for t in (0..n_steps).rev() {
1295            if *save_trajectory {
1296                unsafe {
1297                    let up_off = *outer_upstream_off + t * cb;
1298                    match *carry_elem_size {
1299                        4 => {
1300                            let up_ptr = base.add(up_off) as *const f32;
1301                            let dc_ptr = dcarry.as_mut_ptr() as *mut f32;
1302                            let n_elems = cb / 4;
1303                            for i in 0..n_elems {
1304                                *dc_ptr.add(i) += *up_ptr.add(i);
1305                            }
1306                        }
1307                        8 => {
1308                            let up_ptr = base.add(up_off) as *const f64;
1309                            let dc_ptr = dcarry.as_mut_ptr() as *mut f64;
1310                            let n_elems = cb / 8;
1311                            for i in 0..n_elems {
1312                                *dc_ptr.add(i) += *up_ptr.add(i);
1313                            }
1314                        }
1315                        other => panic!(
1316                            "ScanBackwardXs: unsupported carry elem size {other} \
1317                                     (only f32/f64 carries are supported today)"
1318                        ),
1319                    }
1320                }
1321            }
1322
1323            // Seed body_vjp's carry input via the recompute
1324            // helper (works for both All and Recursive modes),
1325            // then x_t_i + d_output.
1326            let carry_dst_start = *body_carry_in_off;
1327            {
1328                let carry_slice = &mut body_buf[carry_dst_start..carry_dst_start + cb];
1329                recompute_carry_t(
1330                    t,
1331                    carry_slice,
1332                    &mut fwd_buf,
1333                    &mut seg_cache,
1334                    &mut seg_start_t,
1335                    &mut seg_count,
1336                );
1337            }
1338            unsafe {
1339                for (i, body_x_off) in body_x_offs.iter().enumerate() {
1340                    let (outer_xs_off, x_psb) = outer_xs_offs[i];
1341                    let xb = x_psb as usize;
1342                    std::ptr::copy_nonoverlapping(
1343                        base.add(outer_xs_off + t * xb),
1344                        body_buf.as_mut_ptr().add(*body_x_off),
1345                        xb,
1346                    );
1347                }
1348                std::ptr::copy_nonoverlapping(
1349                    dcarry.as_ptr(),
1350                    body_buf.as_mut_ptr().add(*body_d_output_off),
1351                    cb,
1352                );
1353            }
1354
1355            execute_thunks(body_vjp, &mut body_buf);
1356
1357            // Stash this step's dxs into row `t` of the outer
1358            // [length, *per_step_xs] output.
1359            unsafe {
1360                std::ptr::copy_nonoverlapping(
1361                    body_buf.as_ptr().add(*body_dxs_out_off),
1362                    base.add(*outer_dxs_off + t * psb),
1363                    psb,
1364                );
1365            }
1366
1367            // Update dcarry for next backward iteration.
1368            unsafe {
1369                std::ptr::copy_nonoverlapping(
1370                    body_buf.as_ptr().add(*body_dcarry_out_off),
1371                    dcarry.as_mut_ptr(),
1372                    cb,
1373                );
1374            }
1375        }
1376    }
1377}
1378
1379#[inline(always)]
1380pub(crate) fn exec_gated_delta_net(t: &Thunk, base: *mut u8) {
1381    let Thunk::GatedDeltaNet {
1382        q,
1383        k,
1384        v,
1385        g,
1386        beta,
1387        state,
1388        dst,
1389        batch,
1390        seq,
1391        heads,
1392        state_size,
1393        gate_per_channel,
1394        carry_state,
1395    } = t
1396    else {
1397        unreachable!()
1398    };
1399    unsafe {
1400        execute_gated_delta_net_f32(
1401            *q,
1402            *k,
1403            *v,
1404            *g,
1405            *beta,
1406            *state,
1407            *dst,
1408            *batch as usize,
1409            *seq as usize,
1410            *heads as usize,
1411            *state_size as usize,
1412            *gate_per_channel,
1413            *carry_state,
1414            base,
1415        );
1416    }
1417}
1418
1419#[inline(always)]
1420pub(crate) fn exec_lstm(t: &Thunk, base: *mut u8) {
1421    let Thunk::Lstm {
1422        x,
1423        w_ih,
1424        w_hh,
1425        bias,
1426        h0,
1427        c0,
1428        dst,
1429        batch,
1430        seq,
1431        input_size,
1432        hidden,
1433        num_layers,
1434        bidirectional,
1435        carry,
1436    } = t
1437    else {
1438        unreachable!()
1439    };
1440    unsafe {
1441        execute_lstm_f32(
1442            *x,
1443            *w_ih,
1444            *w_hh,
1445            *bias,
1446            *h0,
1447            *c0,
1448            *dst,
1449            *batch as usize,
1450            *seq as usize,
1451            *input_size as usize,
1452            *hidden as usize,
1453            *num_layers as usize,
1454            *bidirectional,
1455            *carry,
1456            base,
1457        );
1458    }
1459}
1460
1461#[inline(always)]
1462pub(crate) fn exec_gru(t: &Thunk, base: *mut u8) {
1463    let Thunk::Gru {
1464        x,
1465        w_ih,
1466        w_hh,
1467        b_ih,
1468        b_hh,
1469        h0,
1470        dst,
1471        batch,
1472        seq,
1473        input_size,
1474        hidden,
1475        num_layers,
1476        bidirectional,
1477        carry,
1478    } = t
1479    else {
1480        unreachable!()
1481    };
1482    unsafe {
1483        execute_gru_f32(
1484            *x,
1485            *w_ih,
1486            *w_hh,
1487            *b_ih,
1488            *b_hh,
1489            *h0,
1490            *dst,
1491            *batch as usize,
1492            *seq as usize,
1493            *input_size as usize,
1494            *hidden as usize,
1495            *num_layers as usize,
1496            *bidirectional,
1497            *carry,
1498            base,
1499        );
1500    }
1501}
1502
1503#[inline(always)]
1504pub(crate) fn exec_rnn(t: &Thunk, base: *mut u8) {
1505    let Thunk::Rnn {
1506        x,
1507        w_ih,
1508        w_hh,
1509        bias,
1510        h0,
1511        dst,
1512        batch,
1513        seq,
1514        input_size,
1515        hidden,
1516        num_layers,
1517        bidirectional,
1518        carry,
1519        relu,
1520    } = t
1521    else {
1522        unreachable!()
1523    };
1524    unsafe {
1525        execute_rnn_f32(
1526            *x,
1527            *w_ih,
1528            *w_hh,
1529            *bias,
1530            *h0,
1531            *dst,
1532            *batch as usize,
1533            *seq as usize,
1534            *input_size as usize,
1535            *hidden as usize,
1536            *num_layers as usize,
1537            *bidirectional,
1538            *carry,
1539            *relu,
1540            base,
1541        );
1542    }
1543}
1544
1545#[inline(always)]
1546pub(crate) fn exec_mamba2(t: &Thunk, base: *mut u8) {
1547    let Thunk::Mamba2 {
1548        x,
1549        dt,
1550        a,
1551        b,
1552        c,
1553        dst,
1554        batch,
1555        seq,
1556        heads,
1557        head_dim,
1558        state_size,
1559    } = t
1560    else {
1561        unreachable!()
1562    };
1563    unsafe {
1564        execute_mamba2_f32(
1565            *x,
1566            *dt,
1567            *a,
1568            *b,
1569            *c,
1570            *dst,
1571            *batch as usize,
1572            *seq as usize,
1573            *heads as usize,
1574            *head_dim as usize,
1575            *state_size as usize,
1576            base,
1577        );
1578    }
1579}
1580
1581#[inline(always)]
1582pub(crate) fn exec_selective_scan(t: &Thunk, base: *mut u8) {
1583    let Thunk::SelectiveScan {
1584        x,
1585        delta,
1586        a,
1587        b: bp,
1588        c: cp,
1589        dst,
1590        batch,
1591        seq,
1592        hidden,
1593        state_size,
1594    } = t
1595    else {
1596        unreachable!()
1597    };
1598    unsafe {
1599        execute_selective_scan_f32(
1600            *x,
1601            *delta,
1602            *a,
1603            *bp,
1604            *cp,
1605            *dst,
1606            *batch as usize,
1607            *seq as usize,
1608            *hidden as usize,
1609            *state_size as usize,
1610            base,
1611        );
1612    }
1613}
1614
1615/// Griewank treeverse: process backward iterations `[t_lo..=t_hi]` (with
1616/// the carry entering iteration `t_lo` supplied as `anchor_carry`) by
1617/// recursive binary subdivision. Total work `O((t_hi-t_lo+1) · log)`,
1618/// auxiliary memory `O(log · carry_bytes)` for the recursion stack.
1619///
1620/// Compared to the iterative segment-cached scheme, this trades extra
1621/// recompute for less working memory — each level of recursion holds
1622/// one `cb`-sized intermediate carry on the stack but never the whole
1623/// segment at once. With K saved outer checkpoints, the outer driver
1624/// invokes this helper once per segment.
1625///
1626/// `process_iter(t, carry_at_t)` is the per-iteration leaf action: it
1627/// runs `body_vjp` at iteration `t` with the supplied carry, threads
1628/// `dcarry` backward, and (for ScanBackwardXs) writes `dxs[t]`.
1629#[allow(clippy::too_many_arguments)]
1630pub(crate) unsafe fn griewank_process_segment(
1631    t_lo: usize,
1632    t_hi: usize,
1633    anchor_carry: &[u8],
1634    cb: usize,
1635    fwd_sched: &ThunkSchedule,
1636    fwd_init: &[u8],
1637    fwd_carry_in_off: usize,
1638    fwd_output_off: usize,
1639    fwd_x_offs: &[usize],
1640    base: *mut u8,
1641    outer_xs_offs: &[(usize, u32)],
1642    fwd_buf: &mut Vec<u8>,
1643    leaf_threshold: usize,
1644    process_iter: &mut dyn FnMut(usize, &[u8]),
1645) {
1646    unsafe {
1647        let size = t_hi - t_lo + 1;
1648        if size == 1 {
1649            process_iter(t_lo, anchor_carry);
1650            return;
1651        }
1652        if size <= leaf_threshold {
1653            // Walk forward, cache each carry, run backward in reverse.
1654            let mut cache: Vec<u8> = Vec::with_capacity(size * cb);
1655            cache.extend_from_slice(anchor_carry);
1656            fwd_buf.copy_from_slice(fwd_init);
1657            std::ptr::copy_nonoverlapping(
1658                anchor_carry.as_ptr(),
1659                fwd_buf.as_mut_ptr().add(fwd_carry_in_off),
1660                cb,
1661            );
1662            for i in 1..size {
1663                let cur_iter = t_lo + i - 1;
1664                for (idx, fb_x_off) in fwd_x_offs.iter().enumerate() {
1665                    let (outer_xs_off, x_psb) = outer_xs_offs[idx];
1666                    let xb = x_psb as usize;
1667                    std::ptr::copy_nonoverlapping(
1668                        base.add(outer_xs_off + cur_iter * xb),
1669                        fwd_buf.as_mut_ptr().add(*fb_x_off),
1670                        xb,
1671                    );
1672                }
1673                execute_thunks(fwd_sched, fwd_buf);
1674                if fwd_output_off != fwd_carry_in_off {
1675                    fwd_buf.copy_within(fwd_output_off..fwd_output_off + cb, fwd_carry_in_off);
1676                }
1677                cache.extend_from_slice(&fwd_buf[fwd_carry_in_off..fwd_carry_in_off + cb]);
1678            }
1679            // Process backward.
1680            for t in (t_lo..=t_hi).rev() {
1681                let idx = t - t_lo;
1682                let carry = &cache[idx * cb..(idx + 1) * cb];
1683                process_iter(t, carry);
1684            }
1685            return;
1686        }
1687
1688        // Split: walk forward from anchor to compute carry entering `mid`.
1689        // (We need `mid - t_lo` body executions: one per iteration in
1690        // [t_lo, mid).)
1691        let mid = t_lo + size / 2;
1692        fwd_buf.copy_from_slice(fwd_init);
1693        std::ptr::copy_nonoverlapping(
1694            anchor_carry.as_ptr(),
1695            fwd_buf.as_mut_ptr().add(fwd_carry_in_off),
1696            cb,
1697        );
1698        for cur_iter in t_lo..mid {
1699            for (idx, fb_x_off) in fwd_x_offs.iter().enumerate() {
1700                let (outer_xs_off, x_psb) = outer_xs_offs[idx];
1701                let xb = x_psb as usize;
1702                std::ptr::copy_nonoverlapping(
1703                    base.add(outer_xs_off + cur_iter * xb),
1704                    fwd_buf.as_mut_ptr().add(*fb_x_off),
1705                    xb,
1706                );
1707            }
1708            execute_thunks(fwd_sched, fwd_buf);
1709            if fwd_output_off != fwd_carry_in_off {
1710                fwd_buf.copy_within(fwd_output_off..fwd_output_off + cb, fwd_carry_in_off);
1711            }
1712        }
1713        let mid_carry: Vec<u8> = fwd_buf[fwd_carry_in_off..fwd_carry_in_off + cb].to_vec();
1714
1715        // Right half first (higher t values processed first to match the
1716        // canonical reverse-mode iteration order: dcarry threads from
1717        // t=length-1 down to t=0).
1718        griewank_process_segment(
1719            mid,
1720            t_hi,
1721            &mid_carry,
1722            cb,
1723            fwd_sched,
1724            fwd_init,
1725            fwd_carry_in_off,
1726            fwd_output_off,
1727            fwd_x_offs,
1728            base,
1729            outer_xs_offs,
1730            fwd_buf,
1731            leaf_threshold,
1732            process_iter,
1733        );
1734        // Then left half with original anchor.
1735        griewank_process_segment(
1736            t_lo,
1737            mid - 1,
1738            anchor_carry,
1739            cb,
1740            fwd_sched,
1741            fwd_init,
1742            fwd_carry_in_off,
1743            fwd_output_off,
1744            fwd_x_offs,
1745            base,
1746            outer_xs_offs,
1747            fwd_buf,
1748            leaf_threshold,
1749            process_iter,
1750        );
1751    }
1752}
1753
1754/// Expected packed-buffer element counts for the RNN-family ops, derived
1755/// purely from the op parameters. `gates` = 4 (LSTM), 3 (GRU), 1 (RNN).
1756///
1757/// The `execute_{lstm,gru,rnn}_f32` kernels read their weights through raw
1758/// pointers sized from these same parameters, so a caller that supplies a
1759/// mis-shaped buffer (e.g. a `w_hh` missing the `×hidden` factor) reads out of
1760/// bounds and returns silent garbage instead of erroring. Callers should
1761/// validate their buffers against this up front — see [`check_rnn_lens`].
1762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1763pub struct RnnLens {
1764    /// `x`: `batch·seq·input_size`.
1765    pub x: usize,
1766    /// `w_ih`: Σ over layers of `dirs·gates·hidden·in_l(l)` (`in_l` grows to `dirs·hidden`).
1767    pub w_ih: usize,
1768    /// `w_hh`: `layers·dirs·gates·hidden·hidden`.
1769    pub w_hh: usize,
1770    /// One bias stream: `layers·dirs·gates·hidden` (LSTM/RNN merge into one; GRU has `b_ih` and `b_hh`).
1771    pub bias: usize,
1772    /// Per-state carry buffer (`h0` and, for LSTM, `c0`): `layers·dirs·batch·hidden`.
1773    pub state: usize,
1774    /// Output `dst`: `batch·seq·dirs·hidden`.
1775    pub out: usize,
1776}
1777
1778/// Expected buffer sizes for an RNN-family op with the given parameters.
1779pub fn rnn_expected_lens(
1780    gates: usize,
1781    batch: usize,
1782    seq: usize,
1783    input_size: usize,
1784    hidden: usize,
1785    num_layers: usize,
1786    bidirectional: bool,
1787) -> RnnLens {
1788    let dirs = if bidirectional { 2 } else { 1 };
1789    let w_ih: usize = (0..num_layers)
1790        .map(|l| dirs * gates * hidden * if l == 0 { input_size } else { dirs * hidden })
1791        .sum();
1792    RnnLens {
1793        x: batch * seq * input_size,
1794        w_ih,
1795        w_hh: num_layers * dirs * gates * hidden * hidden,
1796        bias: num_layers * dirs * gates * hidden,
1797        state: num_layers * dirs * batch * hidden,
1798        out: batch * seq * dirs * hidden,
1799    }
1800}
1801
1802/// Validate a labeled `(actual, expected)` list, returning a clear error on the
1803/// first mismatch. Turns an out-of-bounds kernel read into a loud failure.
1804pub fn check_rnn_lens(op: &str, checks: &[(&str, usize, usize)]) -> Result<(), String> {
1805    for &(name, actual, expected) in checks {
1806        if actual != expected {
1807            return Err(format!(
1808                "{op}: input '{name}' has {actual} elements, expected {expected} \
1809                 (mismatch would read out of bounds in the f32 kernel)"
1810            ));
1811        }
1812    }
1813    Ok(())
1814}
1815
1816/// Reference / host-fallback entry for `Op::Lstm` (multi-layer,
1817/// optionally bidirectional, optional decode carry). Gate order i, f, g,
1818/// o. Shared by the CPU backend and the CUDA / ROCm / wgpu / Metal host
1819/// fallbacks. Tensors are `f32` in the arena; weights are packed (see
1820/// `Op::Lstm`). `h0`/`c0` are byte offsets when `carry`, else ignored;
1821/// the final `hn`/`cn` are written back into them in place. `dst` is
1822/// `[batch, seq, D*hidden]`. Batch items run in parallel per direction.
1823#[allow(clippy::too_many_arguments)]
1824pub unsafe fn execute_lstm_f32(
1825    x: usize,
1826    w_ih: usize,
1827    w_hh: usize,
1828    bias: usize,
1829    h0: usize,
1830    c0: usize,
1831    dst: usize,
1832    batch: usize,
1833    seq: usize,
1834    input_size: usize,
1835    hidden: usize,
1836    num_layers: usize,
1837    bidirectional: bool,
1838    carry: bool,
1839    base: *mut u8,
1840) {
1841    #[inline]
1842    fn sigmoid(z: f32) -> f32 {
1843        1.0 / (1.0 + (-z).exp())
1844    }
1845
1846    let bptr = base as usize;
1847    let four_h = 4 * hidden;
1848    let dirs = if bidirectional { 2 } else { 1 };
1849
1850    unsafe {
1851        let f32s = |off: usize, n: usize| -> &[f32] {
1852            std::slice::from_raw_parts((bptr + off) as *const f32, n)
1853        };
1854
1855        // Layer 0 reads x; later layers read the previous layer's output.
1856        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
1857        let mut in_l = input_size;
1858        // Running element cursor into the packed `w_ih` buffer (block width
1859        // `4h * in_l` varies per layer; `w_hh`/`bias` blocks are uniform).
1860        let mut wih_cursor = 0usize;
1861
1862        for l in 0..num_layers {
1863            let out_width = dirs * hidden;
1864            let mut layer_out = vec![0f32; batch * seq * out_width];
1865            let lo_ptr = layer_out.as_mut_ptr() as usize;
1866            let li_ref: &[f32] = &layer_in;
1867            let wih_block = four_h * in_l;
1868
1869            for dir in 0..dirs {
1870                let ld = l * dirs + dir;
1871                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
1872                let whh = f32s(w_hh + ld * four_h * hidden * 4, four_h * hidden);
1873                let bs = f32s(bias + ld * four_h * 4, four_h);
1874                let h0p = bptr + h0 + ld * batch * hidden * 4;
1875                let c0p = bptr + c0 + ld * batch * hidden * 4;
1876
1877                crate::pool::par_range(batch, |b| {
1878                    let lo = lo_ptr as *mut f32;
1879                    let mut h = vec![0f32; hidden];
1880                    let mut c = vec![0f32; hidden];
1881                    if carry {
1882                        let hin = std::slice::from_raw_parts(
1883                            (h0p + b * hidden * 4) as *const f32,
1884                            hidden,
1885                        );
1886                        let cin = std::slice::from_raw_parts(
1887                            (c0p + b * hidden * 4) as *const f32,
1888                            hidden,
1889                        );
1890                        h.copy_from_slice(hin);
1891                        c.copy_from_slice(cin);
1892                    }
1893                    let mut z = vec![0f32; four_h];
1894                    for step in 0..seq {
1895                        let t = if dir == 0 { step } else { seq - 1 - step };
1896                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
1897                        for r in 0..four_h {
1898                            let wr = &wih[r * in_l..(r + 1) * in_l];
1899                            let mut acc = bs[r];
1900                            for j in 0..in_l {
1901                                acc += wr[j] * x_t[j];
1902                            }
1903                            let hr = &whh[r * hidden..(r + 1) * hidden];
1904                            for (j, &hj) in h.iter().enumerate() {
1905                                acc += hr[j] * hj;
1906                            }
1907                            z[r] = acc;
1908                        }
1909                        for k in 0..hidden {
1910                            let i_g = sigmoid(z[k]);
1911                            let f_g = sigmoid(z[hidden + k]);
1912                            let g_g = z[2 * hidden + k].tanh();
1913                            let o_g = sigmoid(z[3 * hidden + k]);
1914                            let c_new = f_g * c[k] + i_g * g_g;
1915                            c[k] = c_new;
1916                            let h_new = o_g * c_new.tanh();
1917                            h[k] = h_new;
1918                            // [batch, seq, D*hidden]; this direction owns the
1919                            // `dir*hidden .. dir*hidden+hidden` feature slice.
1920                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new;
1921                        }
1922                    }
1923                    if carry {
1924                        let hout = std::slice::from_raw_parts_mut(
1925                            (h0p + b * hidden * 4) as *mut f32,
1926                            hidden,
1927                        );
1928                        let cout = std::slice::from_raw_parts_mut(
1929                            (c0p + b * hidden * 4) as *mut f32,
1930                            hidden,
1931                        );
1932                        hout.copy_from_slice(&h);
1933                        cout.copy_from_slice(&c);
1934                    }
1935                });
1936            }
1937
1938            wih_cursor += dirs * wih_block;
1939            layer_in = layer_out;
1940            in_l = out_width;
1941        }
1942
1943        // Final layer output → dst [batch, seq, D*hidden].
1944        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
1945        dst_slice.copy_from_slice(&layer_in);
1946    }
1947}
1948
1949/// Reference / host-fallback for `Op::Gru` (PyTorch GRU; gate order r, z, n;
1950/// multi-layer / bidirectional / carry). Separate `b_ih`/`b_hh` because the
1951/// reset gate is applied to the hidden term *after* its bias:
1952/// `n = tanh(x·W_inᵀ+b_in + r ⊙ (h·W_hnᵀ+b_hn))`,
1953/// `h' = (1-z)⊙n + z⊙h`. Packing mirrors `Op::Lstm` with `3*hidden` gate rows.
1954#[allow(clippy::too_many_arguments)]
1955pub unsafe fn execute_gru_f32(
1956    x: usize,
1957    w_ih: usize,
1958    w_hh: usize,
1959    b_ih: usize,
1960    b_hh: usize,
1961    h0: usize,
1962    dst: usize,
1963    batch: usize,
1964    seq: usize,
1965    input_size: usize,
1966    hidden: usize,
1967    num_layers: usize,
1968    bidirectional: bool,
1969    carry: bool,
1970    base: *mut u8,
1971) {
1972    #[inline]
1973    fn sigmoid(z: f32) -> f32 {
1974        1.0 / (1.0 + (-z).exp())
1975    }
1976
1977    let bptr = base as usize;
1978    let three_h = 3 * hidden;
1979    let dirs = if bidirectional { 2 } else { 1 };
1980
1981    unsafe {
1982        let f32s = |off: usize, n: usize| -> &[f32] {
1983            std::slice::from_raw_parts((bptr + off) as *const f32, n)
1984        };
1985
1986        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
1987        let mut in_l = input_size;
1988        let mut wih_cursor = 0usize;
1989
1990        for l in 0..num_layers {
1991            let out_width = dirs * hidden;
1992            let mut layer_out = vec![0f32; batch * seq * out_width];
1993            let lo_ptr = layer_out.as_mut_ptr() as usize;
1994            let li_ref: &[f32] = &layer_in;
1995            let wih_block = three_h * in_l;
1996
1997            for dir in 0..dirs {
1998                let ld = l * dirs + dir;
1999                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
2000                let whh = f32s(w_hh + ld * three_h * hidden * 4, three_h * hidden);
2001                let bih = f32s(b_ih + ld * three_h * 4, three_h);
2002                let bhh = f32s(b_hh + ld * three_h * 4, three_h);
2003                let h0p = bptr + h0 + ld * batch * hidden * 4;
2004
2005                crate::pool::par_range(batch, |b| {
2006                    let lo = lo_ptr as *mut f32;
2007                    let mut h = vec![0f32; hidden];
2008                    if carry {
2009                        let hin = std::slice::from_raw_parts(
2010                            (h0p + b * hidden * 4) as *const f32,
2011                            hidden,
2012                        );
2013                        h.copy_from_slice(hin);
2014                    }
2015                    let mut xi = vec![0f32; three_h]; // x·W_ihᵀ + b_ih
2016                    let mut hi = vec![0f32; three_h]; // h·W_hhᵀ + b_hh
2017                    for step in 0..seq {
2018                        let t = if dir == 0 { step } else { seq - 1 - step };
2019                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
2020                        for r in 0..three_h {
2021                            let wr = &wih[r * in_l..(r + 1) * in_l];
2022                            let mut a = bih[r];
2023                            for j in 0..in_l {
2024                                a += wr[j] * x_t[j];
2025                            }
2026                            xi[r] = a;
2027                            let hr = &whh[r * hidden..(r + 1) * hidden];
2028                            let mut bb = bhh[r];
2029                            for (j, &hj) in h.iter().enumerate() {
2030                                bb += hr[j] * hj;
2031                            }
2032                            hi[r] = bb;
2033                        }
2034                        for k in 0..hidden {
2035                            let rg = sigmoid(xi[k] + hi[k]);
2036                            let zg = sigmoid(xi[hidden + k] + hi[hidden + k]);
2037                            // Reset applied to hidden term after its bias.
2038                            let ng = (xi[2 * hidden + k] + rg * hi[2 * hidden + k]).tanh();
2039                            let h_new = (1.0 - zg) * ng + zg * h[k];
2040                            h[k] = h_new;
2041                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new;
2042                        }
2043                    }
2044                    if carry {
2045                        let hout = std::slice::from_raw_parts_mut(
2046                            (h0p + b * hidden * 4) as *mut f32,
2047                            hidden,
2048                        );
2049                        hout.copy_from_slice(&h);
2050                    }
2051                });
2052            }
2053
2054            wih_cursor += dirs * wih_block;
2055            layer_in = layer_out;
2056            in_l = out_width;
2057        }
2058
2059        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
2060        dst_slice.copy_from_slice(&layer_in);
2061    }
2062}
2063
2064/// Reference / host-fallback for `Op::Rnn` (Elman; `act = relu` when `relu`
2065/// else `tanh`; multi-layer / bidirectional / carry). Single merged bias.
2066/// `h' = act(x·W_ihᵀ + h·W_hhᵀ + bias)`. Packing mirrors `Op::Lstm` with
2067/// `hidden` gate rows.
2068#[allow(clippy::too_many_arguments)]
2069pub unsafe fn execute_rnn_f32(
2070    x: usize,
2071    w_ih: usize,
2072    w_hh: usize,
2073    bias: usize,
2074    h0: usize,
2075    dst: usize,
2076    batch: usize,
2077    seq: usize,
2078    input_size: usize,
2079    hidden: usize,
2080    num_layers: usize,
2081    bidirectional: bool,
2082    carry: bool,
2083    relu: bool,
2084    base: *mut u8,
2085) {
2086    let bptr = base as usize;
2087    let dirs = if bidirectional { 2 } else { 1 };
2088
2089    unsafe {
2090        let f32s = |off: usize, n: usize| -> &[f32] {
2091            std::slice::from_raw_parts((bptr + off) as *const f32, n)
2092        };
2093
2094        let mut layer_in: Vec<f32> = f32s(x, batch * seq * input_size).to_vec();
2095        let mut in_l = input_size;
2096        let mut wih_cursor = 0usize;
2097
2098        for l in 0..num_layers {
2099            let out_width = dirs * hidden;
2100            let mut layer_out = vec![0f32; batch * seq * out_width];
2101            let lo_ptr = layer_out.as_mut_ptr() as usize;
2102            let li_ref: &[f32] = &layer_in;
2103            let wih_block = hidden * in_l;
2104
2105            for dir in 0..dirs {
2106                let ld = l * dirs + dir;
2107                let wih = f32s((w_ih / 4 + wih_cursor + dir * wih_block) * 4, wih_block);
2108                let whh = f32s(w_hh + ld * hidden * hidden * 4, hidden * hidden);
2109                let bs = f32s(bias + ld * hidden * 4, hidden);
2110                let h0p = bptr + h0 + ld * batch * hidden * 4;
2111
2112                crate::pool::par_range(batch, |b| {
2113                    let lo = lo_ptr as *mut f32;
2114                    let mut h = vec![0f32; hidden];
2115                    if carry {
2116                        let hin = std::slice::from_raw_parts(
2117                            (h0p + b * hidden * 4) as *const f32,
2118                            hidden,
2119                        );
2120                        h.copy_from_slice(hin);
2121                    }
2122                    for step in 0..seq {
2123                        let t = if dir == 0 { step } else { seq - 1 - step };
2124                        let x_t = &li_ref[(b * seq + t) * in_l..(b * seq + t + 1) * in_l];
2125                        let mut h_new = vec![0f32; hidden];
2126                        for k in 0..hidden {
2127                            let wr = &wih[k * in_l..(k + 1) * in_l];
2128                            let mut acc = bs[k];
2129                            for j in 0..in_l {
2130                                acc += wr[j] * x_t[j];
2131                            }
2132                            let hr = &whh[k * hidden..(k + 1) * hidden];
2133                            for (j, &hj) in h.iter().enumerate() {
2134                                acc += hr[j] * hj;
2135                            }
2136                            h_new[k] = if relu { acc.max(0.0) } else { acc.tanh() };
2137                        }
2138                        for k in 0..hidden {
2139                            h[k] = h_new[k];
2140                            *lo.add((b * seq + t) * out_width + dir * hidden + k) = h_new[k];
2141                        }
2142                    }
2143                    if carry {
2144                        let hout = std::slice::from_raw_parts_mut(
2145                            (h0p + b * hidden * 4) as *mut f32,
2146                            hidden,
2147                        );
2148                        hout.copy_from_slice(&h);
2149                    }
2150                });
2151            }
2152
2153            wih_cursor += dirs * wih_block;
2154            layer_in = layer_out;
2155            in_l = out_width;
2156        }
2157
2158        let dst_slice = std::slice::from_raw_parts_mut((bptr + dst) as *mut f32, layer_in.len());
2159        dst_slice.copy_from_slice(&layer_in);
2160    }
2161}
2162
2163/// Reference for `Op::Mamba2` — SSD scalar-decay SSM scan. Inputs (f32):
2164/// `x [B,S,H,P]`, `dt [B,S,H]`, `a [H]`, `b/c [B,S,H,N]`. Per `(batch, head)`
2165/// the state `S[P,N]` is zero-init: `dA=exp(dt·a)`, `S=dA·S + (dt·x)⊗b`,
2166/// `y=Σ_n S[:,n]·c[n]`. Output `[B,S,H,P]`.
2167#[allow(clippy::too_many_arguments)]
2168pub unsafe fn execute_mamba2_f32(
2169    x: usize,
2170    dt: usize,
2171    a: usize,
2172    b: usize,
2173    c: usize,
2174    dst: usize,
2175    batch: usize,
2176    seq: usize,
2177    heads: usize,
2178    head_dim: usize,
2179    state_size: usize,
2180    base: *mut u8,
2181) {
2182    let (bn, s, h, p, n) = (batch, seq, heads, head_dim, state_size);
2183    let bptr = base as usize;
2184    unsafe {
2185        let f32s = |off: usize, len: usize| -> &[f32] {
2186            std::slice::from_raw_parts((bptr + off) as *const f32, len)
2187        };
2188        let xs = f32s(x, bn * s * h * p);
2189        let dts = f32s(dt, bn * s * h);
2190        let am = f32s(a, h);
2191        let bm = f32s(b, bn * s * h * n);
2192        let cm = f32s(c, bn * s * h * n);
2193        let out_ptr = bptr + dst;
2194
2195        // Independent per (batch, head).
2196        crate::pool::par_range(bn * h, |bh| {
2197            let bi = bh / h;
2198            let hi = bh % h;
2199            let out = out_ptr as *mut f32;
2200            let mut state = vec![0f32; p * n];
2201            for t in 0..s {
2202                let dt_t = dts[(bi * s + t) * h + hi];
2203                let da = (dt_t * am[hi]).exp();
2204                let x_off = ((bi * s + t) * h + hi) * p;
2205                let bc_off = ((bi * s + t) * h + hi) * n;
2206                for pi in 0..p {
2207                    let dtx = dt_t * xs[x_off + pi];
2208                    for ni in 0..n {
2209                        state[pi * n + ni] = da * state[pi * n + ni] + dtx * bm[bc_off + ni];
2210                    }
2211                }
2212                for pi in 0..p {
2213                    let mut acc = 0f32;
2214                    for ni in 0..n {
2215                        acc += state[pi * n + ni] * cm[bc_off + ni];
2216                    }
2217                    *out.add(x_off + pi) = acc;
2218                }
2219            }
2220        });
2221    }
2222}
2223
2224/// Mamba selective-scan reference (f32). Shared by the CPU `Thunk::SelectiveScan`
2225/// arm and the Metal unified-memory host fallback. `base` is the arena byte
2226/// pointer; the five inputs (`x`, `delta`, `a`, `b`, `c`) and `dst` are byte
2227/// offsets into it. Recurrence per `(batch, seq, channel, state)`:
2228/// `h[t] = exp(Δ·A)·h[t-1] + Δ·B·x;  y = Σ_n C·h`. State resets per batch row.
2229pub unsafe fn execute_selective_scan_f32(
2230    x: usize,
2231    delta: usize,
2232    a: usize,
2233    b: usize,
2234    c: usize,
2235    dst: usize,
2236    batch: usize,
2237    seq: usize,
2238    hidden: usize,
2239    state_size: usize,
2240    base: *mut u8,
2241) {
2242    let (bn, s, h, n) = (batch, seq, hidden, state_size);
2243    unsafe {
2244        let xs = sl(x, base, bn * s * h);
2245        let dt = sl(delta, base, bn * s * h);
2246        let am = sl(a, base, h * n);
2247        let bm = sl(b, base, bn * s * n);
2248        let cm = sl(c, base, bn * s * n);
2249        let out = sl_mut(dst, base, bn * s * h);
2250
2251        // State buffer per-batch: h channels × n state. Sequential along
2252        // the seq dimension; reset at the start of each batch row.
2253        let mut state = vec![0f32; h * n];
2254        for bi in 0..bn {
2255            for v in state.iter_mut() {
2256                *v = 0.0;
2257            }
2258            for si in 0..s {
2259                let x_row = &xs[bi * s * h + si * h..bi * s * h + (si + 1) * h];
2260                let dt_row = &dt[bi * s * h + si * h..bi * s * h + (si + 1) * h];
2261                let b_row = &bm[bi * s * n + si * n..bi * s * n + (si + 1) * n];
2262                let c_row = &cm[bi * s * n + si * n..bi * s * n + (si + 1) * n];
2263                let out_row = &mut out[bi * s * h + si * h..bi * s * h + (si + 1) * h];
2264
2265                for ci in 0..h {
2266                    let d = dt_row[ci];
2267                    let xv = x_row[ci];
2268                    let mut acc = 0f32;
2269                    for ni in 0..n {
2270                        // Discretize: exp(d * a) and d * b.
2271                        let da = (d * am[ci * n + ni]).exp();
2272                        state[ci * n + ni] = da * state[ci * n + ni] + d * b_row[ni] * xv;
2273                        acc += c_row[ni] * state[ci * n + ni];
2274                    }
2275                    out_row[ci] = acc;
2276                }
2277            }
2278        }
2279    }
2280}
2281
2282#[allow(clippy::too_many_arguments)]
2283pub unsafe fn execute_gated_delta_net_f32(
2284    q: usize,
2285    k: usize,
2286    v: usize,
2287    g: usize,
2288    beta: usize,
2289    state: usize,
2290    dst: usize,
2291    batch: usize,
2292    seq: usize,
2293    heads: usize,
2294    state_size: usize,
2295    gate_per_channel: bool,
2296    carry_state: bool,
2297    base: *mut u8,
2298) {
2299    #[derive(Copy, Clone)]
2300    struct ArenaPtr(usize);
2301    unsafe impl Send for ArenaPtr {}
2302    unsafe impl Sync for ArenaPtr {}
2303    impl ArenaPtr {
2304        #[inline]
2305        fn get(self) -> *mut u8 {
2306            self.0 as *mut u8
2307        }
2308    }
2309
2310    unsafe {
2311        let arena = ArenaPtr(base as usize);
2312        let (b, s, h, n) = (batch, seq, heads, state_size);
2313        let scale = 1.0f32 / (n as f32).sqrt();
2314        let use_external = carry_state;
2315        let mut owned_state = vec![0f32; h * n * n];
2316
2317        crate::pool::num_threads();
2318
2319        assert!(
2320            n <= crate::gdn::GDN_MAX_STATE,
2321            "GatedDeltaNet state_size={n} exceeds stack scratch ({})",
2322            crate::gdn::GDN_MAX_STATE
2323        );
2324
2325        let qs = sl(q, arena.get(), b * s * h * n);
2326        let ks = sl(k, arena.get(), b * s * h * n);
2327        let vs = sl(v, arena.get(), b * s * h * n);
2328        // Per-channel (KDA) gate is [b,s,h,n]; per-head gate is [b,s,h].
2329        let gs = sl(
2330            g,
2331            arena.get(),
2332            if gate_per_channel {
2333                b * s * h * n
2334            } else {
2335                b * s * h
2336            },
2337        );
2338        let betas = sl(beta, arena.get(), b * s * h);
2339        let _out = sl_mut(dst, arena.get(), b * s * h * n);
2340        let hs_n = h * n;
2341
2342        let run_head = |bi: usize, hi: usize, s_mat: &mut [f32], sk: &mut [f32]| {
2343            for ti in 0..s {
2344                let qkv_step = bi * s * hs_n + ti * hs_n + hi * n;
2345                let gb_step = bi * s * h + ti * h + hi;
2346                let out_row = sl_mut(dst + qkv_step * std::mem::size_of::<f32>(), arena.get(), n);
2347                let (q_r, k_r, v_r) = (
2348                    &qs[qkv_step..qkv_step + n],
2349                    &ks[qkv_step..qkv_step + n],
2350                    &vs[qkv_step..qkv_step + n],
2351                );
2352                if gate_per_channel {
2353                    crate::gdn::gdn_step_blas_pc(
2354                        s_mat,
2355                        q_r,
2356                        k_r,
2357                        v_r,
2358                        &gs[gb_step * n..gb_step * n + n],
2359                        betas[gb_step],
2360                        out_row,
2361                        sk,
2362                        n,
2363                        scale,
2364                    );
2365                } else {
2366                    crate::gdn::gdn_step_blas(
2367                        s_mat,
2368                        q_r,
2369                        k_r,
2370                        v_r,
2371                        gs[gb_step],
2372                        betas[gb_step],
2373                        out_row,
2374                        sk,
2375                        n,
2376                        scale,
2377                    );
2378                }
2379            }
2380        };
2381
2382        // Prefill (seq>1, ephemeral state): time-outer, parallel over heads —
2383        // better occupancy than head-outer when prompt length dominates.
2384        if !use_external && s > 1 {
2385            for bi in 0..b {
2386                crate::pool::par_range(h, |hi| {
2387                    let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
2388                    let sk = &mut sk_buf[..n];
2389                    let mut local_state =
2390                        [0f32; crate::gdn::GDN_MAX_STATE * crate::gdn::GDN_MAX_STATE];
2391                    let s_mat = &mut local_state[..n * n];
2392                    s_mat.fill(0.0);
2393                    run_head(bi, hi, s_mat, sk);
2394                });
2395            }
2396            return;
2397        }
2398
2399        if use_external {
2400            let state_bytes = state;
2401            crate::pool::par_range(b * h, |bhi| {
2402                let bi = bhi / h;
2403                let hi = bhi % h;
2404                let elem_off = bi * h * n * n + hi * n * n;
2405                let s_mat = sl_mut(
2406                    state_bytes + elem_off * std::mem::size_of::<f32>(),
2407                    arena.get(),
2408                    n * n,
2409                );
2410                let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
2411                run_head(bi, hi, s_mat, &mut sk_buf[..n]);
2412            });
2413        } else {
2414            for bi in 0..b {
2415                owned_state.fill(0.0);
2416                #[cfg(not(target_arch = "wasm32"))]
2417                {
2418                    use rayon::prelude::*;
2419                    owned_state
2420                        .par_chunks_mut(n * n)
2421                        .enumerate()
2422                        .for_each(|(hi, s_mat)| {
2423                            let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
2424                            run_head(bi, hi, s_mat, &mut sk_buf[..n]);
2425                        });
2426                }
2427                #[cfg(target_arch = "wasm32")]
2428                {
2429                    owned_state
2430                        .chunks_mut(n * n)
2431                        .enumerate()
2432                        .for_each(|(hi, s_mat)| {
2433                            let mut sk_buf = [0f32; crate::gdn::GDN_MAX_STATE];
2434                            run_head(bi, hi, s_mat, &mut sk_buf[..n]);
2435                        });
2436                }
2437            }
2438        }
2439    }
2440}
2441
2442/// Host-fallback entry for f16 `Op::GatedDeltaNet` tensors on Metal.
2443pub unsafe fn execute_gated_delta_net_f16(
2444    q: usize,
2445    k: usize,
2446    v: usize,
2447    g: usize,
2448    beta: usize,
2449    state: usize,
2450    dst: usize,
2451    batch: usize,
2452    seq: usize,
2453    heads: usize,
2454    state_size: usize,
2455    base: *mut u8,
2456) {
2457    use half::f16;
2458    unsafe {
2459        let read_f16 = |off: usize, len: usize| -> Vec<f32> {
2460            let raw = std::slice::from_raw_parts(base.add(off) as *const u8, len * 2);
2461            raw.chunks_exact(2)
2462                .map(|c| f16::from_le_bytes([c[0], c[1]]).to_f32())
2463                .collect()
2464        };
2465        let write_f16 = |off: usize, data: &[f32]| {
2466            let out = std::slice::from_raw_parts_mut(base.add(off), data.len() * 2);
2467            for (i, &v) in data.iter().enumerate() {
2468                let le = f16::from_f32(v).to_le_bytes();
2469                out[i * 2] = le[0];
2470                out[i * 2 + 1] = le[1];
2471            }
2472        };
2473
2474        let (b, s, h, n) = (batch, seq, heads, state_size);
2475        let q_f = read_f16(q, b * s * h * n);
2476        let k_f = read_f16(k, b * s * h * n);
2477        let v_f = read_f16(v, b * s * h * n);
2478        let g_f = read_f16(g, b * s * h);
2479        let b_f = read_f16(beta, b * s * h);
2480        let mut state_f = if state != 0 {
2481            read_f16(state, b * h * n * n)
2482        } else {
2483            vec![0f32; b * h * n * n]
2484        };
2485        let mut out_f = vec![0f32; b * s * h * n];
2486        let scale = 1.0f32 / (n as f32).sqrt();
2487        let mut sk_buf = vec![0f32; n];
2488        let mut owned_state = vec![0f32; h * n * n];
2489
2490        for bi in 0..b {
2491            let state_slice: &mut [f32] = if state != 0 {
2492                let start = bi * h * n * n;
2493                &mut state_f[start..start + h * n * n]
2494            } else {
2495                owned_state.fill(0.0);
2496                &mut owned_state
2497            };
2498
2499            for ti in 0..s {
2500                let qkv_step_base = bi * s * h * n + ti * h * n;
2501                let gb_step_base = bi * s * h + ti * h;
2502
2503                for hi in 0..h {
2504                    let q_row = &q_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
2505                    let k_row = &k_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
2506                    let v_row = &v_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
2507                    let g_t = g_f[gb_step_base + hi];
2508                    let beta_t = b_f[gb_step_base + hi];
2509
2510                    let s_base = hi * n * n;
2511                    let s_mat = &mut state_slice[s_base..s_base + n * n];
2512
2513                    let g_exp = g_t.exp();
2514                    for st in s_mat.iter_mut() {
2515                        *st *= g_exp;
2516                    }
2517
2518                    for j in 0..n {
2519                        let mut acc = 0f32;
2520                        for i in 0..n {
2521                            acc += s_mat[i * n + j] * k_row[i];
2522                        }
2523                        sk_buf[j] = acc;
2524                    }
2525
2526                    for j in 0..n {
2527                        sk_buf[j] = (v_row[j] - sk_buf[j]) * beta_t;
2528                    }
2529
2530                    for i in 0..n {
2531                        let ki = k_row[i];
2532                        for j in 0..n {
2533                            s_mat[i * n + j] += ki * sk_buf[j];
2534                        }
2535                    }
2536
2537                    let out_row = &mut out_f[qkv_step_base + hi * n..qkv_step_base + (hi + 1) * n];
2538                    for j in 0..n {
2539                        let mut acc = 0f32;
2540                        for i in 0..n {
2541                            acc += s_mat[i * n + j] * q_row[i];
2542                        }
2543                        out_row[j] = acc * scale;
2544                    }
2545                }
2546            }
2547        }
2548
2549        write_f16(dst, &out_f);
2550        if state != 0 {
2551            write_f16(state, &state_f);
2552        }
2553    }
2554}