Skip to main content

rlx_cpu/thunk/
scan.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
5//! Shared `Op::Scan` host-fallback plumbing for GPU backends.
6//!
7//! Backends compile a scan node into a [`ScanHostDesc`] once (via
8//! [`scan_host_desc_from_node`] / [`rlx_scan_host_desc`]), then at run time
9//! stage arena bytes (unified memory, full D2H, or a rebated span) and call
10//! [`execute_scan_host_desc`]. Short Scans are preferentially unrolled by
11//! [`maybe_unroll_scans`] / [`rlx_maybe_unroll_scans`] so the body runs as
12//! ordinary device ops.
13
14use crate::thunk::{ScanBodyPlan, compile_scan_body, execute_scan_host};
15use rlx_ir::{Graph, NodeId, Op};
16use std::sync::Arc;
17
18/// Compiled scan + outer-arena layout — the fields formerly duplicated as
19/// `Step::ScanHost` / `Thunk::ScanHost` across Metal / CUDA / ROCm / wgpu.
20#[derive(Clone)]
21pub struct ScanHostDesc {
22    pub plan: Arc<ScanBodyPlan>,
23    pub outer_init_off: usize,
24    pub outer_final_off: usize,
25    pub length: u32,
26    pub save_trajectory: bool,
27    /// `(outer_off, per_step_bytes)` for each per-step `xs` input.
28    pub xs_outer: Vec<(usize, usize)>,
29    /// `(outer_off, total_bytes)` for each broadcast input.
30    pub bcast_outer: Vec<(usize, usize)>,
31}
32
33impl std::fmt::Debug for ScanHostDesc {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("ScanHostDesc")
36            .field("carry_bytes", &self.plan.carry_bytes)
37            .field("length", &self.length)
38            .field("save_trajectory", &self.save_trajectory)
39            .field("num_xs", &self.xs_outer.len())
40            .field("num_bcast", &self.bcast_outer.len())
41            .finish()
42    }
43}
44
45/// Build a [`ScanHostDesc`] from an `Op::Scan` node. `byte_off` returns the
46/// outer-arena byte offset of a [`NodeId`] (init / bcast / xs / output).
47pub fn scan_host_desc_from_node(
48    graph: &Graph,
49    node: &rlx_ir::Node,
50    mut byte_off: impl FnMut(NodeId) -> usize,
51) -> ScanHostDesc {
52    let (body, length, save_trajectory, num_bcast, num_xs) = match &node.op {
53        Op::Scan {
54            body,
55            length,
56            save_trajectory,
57            num_bcast,
58            num_xs,
59            ..
60        } => (
61            body,
62            *length,
63            *save_trajectory,
64            *num_bcast as usize,
65            *num_xs as usize,
66        ),
67        _ => panic!("scan_host_desc_from_node: expected Op::Scan"),
68    };
69    let plan = compile_scan_body(body, num_bcast, num_xs);
70    let bcast_outer: Vec<(usize, usize)> = (0..num_bcast)
71        .map(|i| {
72            let id = node.inputs[1 + i];
73            (byte_off(id), graph.node(id).shape.size_bytes().unwrap())
74        })
75        .collect();
76    let xs_outer: Vec<(usize, usize)> = (0..num_xs)
77        .map(|i| {
78            let id = node.inputs[1 + num_bcast + i];
79            let total = graph.node(id).shape.size_bytes().unwrap();
80            (byte_off(id), total / length as usize)
81        })
82        .collect();
83    ScanHostDesc {
84        plan: Arc::new(plan),
85        outer_init_off: byte_off(node.inputs[0]),
86        outer_final_off: byte_off(node.id),
87        length,
88        save_trajectory,
89        xs_outer,
90        bcast_outer,
91    }
92}
93
94/// Run [`execute_scan_host`] from a [`ScanHostDesc`].
95///
96/// # Safety
97/// `base` must span every outer offset referenced by `desc`.
98pub unsafe fn execute_scan_host_desc(base: *mut u8, desc: &ScanHostDesc) {
99    unsafe {
100        execute_scan_host(
101            base,
102            &desc.plan,
103            desc.outer_init_off,
104            desc.outer_final_off,
105            desc.length,
106            desc.save_trajectory,
107            &desc.xs_outer,
108            &desc.bcast_outer,
109        );
110    }
111}
112
113/// Contiguous host span covering every region a scan touches, with offsets
114/// rebased to the span start. Used by wgpu (no host-mapped arena).
115#[derive(Clone)]
116pub struct ScanHostSpan {
117    pub lo: usize,
118    pub hi: usize,
119    pub desc: ScanHostDesc,
120}
121
122impl ScanHostSpan {
123    pub fn from_desc(desc: ScanHostDesc) -> Self {
124        let cb = desc.plan.carry_bytes;
125        let out_len = if desc.save_trajectory {
126            desc.length as usize * cb
127        } else {
128            cb
129        };
130        let mut lo = desc.outer_init_off.min(desc.outer_final_off);
131        let mut hi = (desc.outer_init_off + cb).max(desc.outer_final_off + out_len);
132        for &(o, t) in &desc.bcast_outer {
133            lo = lo.min(o);
134            hi = hi.max(o + t);
135        }
136        for &(o, ps) in &desc.xs_outer {
137            lo = lo.min(o);
138            hi = hi.max(o + desc.length as usize * ps);
139        }
140        let rebated = ScanHostDesc {
141            plan: desc.plan,
142            outer_init_off: desc.outer_init_off - lo,
143            outer_final_off: desc.outer_final_off - lo,
144            length: desc.length,
145            save_trajectory: desc.save_trajectory,
146            xs_outer: desc.xs_outer.iter().map(|&(o, ps)| (o - lo, ps)).collect(),
147            bcast_outer: desc.bcast_outer.iter().map(|&(o, t)| (o - lo, t)).collect(),
148        };
149        Self {
150            lo,
151            hi,
152            desc: rebated,
153        }
154    }
155
156    pub fn len(&self) -> usize {
157        self.hi - self.lo
158    }
159
160    pub fn is_empty(&self) -> bool {
161        self.len() == 0
162    }
163}
164
165/// Evaluate a single op (including nested-body ops like `Op::ScanBackward`)
166/// via a one-op CPU graph + thunk executor. Used by GPU host-fallbacks for
167/// ops that are awkward to drive as raw `ScanHostDesc` layout.
168pub fn eval_single_op_f32(
169    op: &Op,
170    out_shape: &rlx_ir::Shape,
171    inputs: &[(rlx_ir::Shape, &[f32])],
172) -> Vec<f32> {
173    let n = out_shape
174        .num_elements()
175        .unwrap_or(0)
176        .max(out_shape.size_bytes().unwrap_or(0).div_ceil(4));
177    // Fast path: Kitten discrete wgpu packs ~1000 Binary/Activation HostOps per
178    // infer. Building a mini-graph + plan_memory + compile_thunks each time was
179    // the dominant cost (~17s hello). Direct kernels keep numerics identical.
180    if let Some(y) = eval_elementwise_fast(op, n, inputs) {
181        return y;
182    }
183    // wgpu (and HostOp packed staging) store every tensor as f32-uniform,
184    // including Bool masks as 0.0/1.0. Building the mini-graph with the IR's
185    // native Bool/I64 dtypes makes `plan_memory` allocate 1-byte Bool slots;
186    // `slice_mut` then returns `size/4` f32 lanes and
187    // `arena.slice_mut(out)[..num_elements]` panics
188    // (`range end index N out of range for slice of length N/4`).
189    //
190    // Wide dtypes (C64/C128/F64) keep their IR dtype so CPU thunks that read
191    // multi-lane elements still see the right layout; lane count is
192    // `size_bytes/4`.
193    fn host_shape(sh: &rlx_ir::Shape) -> rlx_ir::Shape {
194        match sh.dtype() {
195            rlx_ir::DType::C64 | rlx_ir::DType::C128 | rlx_ir::DType::F64 => sh.clone(),
196            _ => sh.clone().with_dtype(rlx_ir::DType::F32),
197        }
198    }
199    let mut g = Graph::new("scan_eval_single_op");
200    let ids: Vec<NodeId> = inputs
201        .iter()
202        .enumerate()
203        .map(|(i, (sh, _))| {
204            let sh_use = host_shape(sh);
205            g.append_node(
206                Op::Input {
207                    name: format!("in{i}"),
208                },
209                vec![],
210                sh_use,
211                None,
212            )
213        })
214        .collect();
215    let out_use = host_shape(out_shape);
216    let out = g.append_node(op.clone(), ids.clone(), out_use.clone(), None);
217    g.set_outputs(vec![out]);
218    let plan = rlx_opt::memory::plan_memory_aligned(&g, 16);
219    let mut arena = crate::arena::Arena::from_plan(plan);
220    for (i, (_, vals)) in inputs.iter().enumerate() {
221        let slot = arena.slice_mut(ids[i]);
222        let n = slot.len().min(vals.len());
223        slot[..n].copy_from_slice(&vals[..n]);
224    }
225    let schedule = crate::thunk::compile_thunks(&g, &arena);
226    crate::thunk::execute_thunks(&schedule, arena.raw_buf_mut());
227    let n = out_use
228        .size_bytes()
229        .unwrap_or(0)
230        .div_ceil(4)
231        .max(out_use.num_elements().unwrap_or(0));
232    arena.slice_mut(out)[..n].to_vec()
233}
234
235fn eval_elementwise_fast(
236    op: &Op,
237    n: usize,
238    inputs: &[(rlx_ir::Shape, &[f32])],
239) -> Option<Vec<f32>> {
240    use crate::thunk::{apply_activation_inplace, region_binary_scalar};
241    use rayon::prelude::*;
242    if n == 0 {
243        return Some(Vec::new());
244    }
245    match op {
246        Op::Binary(bop) if inputs.len() == 2 => {
247            let a = inputs[0].1;
248            let b = inputs[1].1;
249            let a_n = a.len().max(1);
250            let b_n = b.len().max(1);
251            let mut y = vec![0f32; n];
252            if n < 4096 {
253                if a_n == n && b_n == n {
254                    for i in 0..n {
255                        y[i] = region_binary_scalar(*bop, a[i], b[i]);
256                    }
257                } else if a_n == 1 && b_n == n {
258                    let av = a[0];
259                    for i in 0..n {
260                        y[i] = region_binary_scalar(*bop, av, b[i]);
261                    }
262                } else if a_n == n && b_n == 1 {
263                    let bv = b[0];
264                    for i in 0..n {
265                        y[i] = region_binary_scalar(*bop, a[i], bv);
266                    }
267                } else {
268                    for i in 0..n {
269                        y[i] = region_binary_scalar(*bop, a[i % a_n], b[i % b_n]);
270                    }
271                }
272            } else if a_n == n && b_n == n {
273                y.par_iter_mut().enumerate().for_each(|(i, out)| {
274                    *out = region_binary_scalar(*bop, a[i], b[i]);
275                });
276            } else if a_n == 1 && b_n == n {
277                let av = a[0];
278                y.par_iter_mut().enumerate().for_each(|(i, out)| {
279                    *out = region_binary_scalar(*bop, av, b[i]);
280                });
281            } else if a_n == n && b_n == 1 {
282                let bv = b[0];
283                y.par_iter_mut().enumerate().for_each(|(i, out)| {
284                    *out = region_binary_scalar(*bop, a[i], bv);
285                });
286            } else {
287                // General broadcast via modulus (equal-rank trailing).
288                y.par_iter_mut().enumerate().for_each(|(i, out)| {
289                    *out = region_binary_scalar(*bop, a[i % a_n], b[i % b_n]);
290                });
291            }
292            Some(y)
293        }
294        Op::Activation(act) if inputs.len() == 1 => {
295            let src = inputs[0].1;
296            let mut y = if src.len() >= n {
297                src[..n].to_vec()
298            } else if src.len() == 1 {
299                vec![src[0]; n]
300            } else {
301                (0..n).map(|i| src[i % src.len().max(1)]).collect()
302            };
303            apply_activation_inplace(&mut y, *act);
304            Some(y)
305        }
306        Op::Where if inputs.len() == 3 => {
307            let c = inputs[0].1;
308            let t = inputs[1].1;
309            let f = inputs[2].1;
310            let cn = c.len().max(1);
311            let tn = t.len().max(1);
312            let fn_ = f.len().max(1);
313            let mut y = vec![0f32; n];
314            y.par_iter_mut().enumerate().for_each(|(i, out)| {
315                *out = if c[i % cn] != 0.0 {
316                    t[i % tn]
317                } else {
318                    f[i % fn_]
319                };
320            });
321            Some(y)
322        }
323        Op::Fma if inputs.len() == 3 => {
324            let a = inputs[0].1;
325            let b = inputs[1].1;
326            let c = inputs[2].1;
327            let an = a.len().max(1);
328            let bn = b.len().max(1);
329            let cn = c.len().max(1);
330            let mut y = vec![0f32; n];
331            y.par_iter_mut().enumerate().for_each(|(i, out)| {
332                *out = a[i % an].mul_add(b[i % bn], c[i % cn]);
333            });
334            Some(y)
335        }
336        _ => None,
337    }
338}
339
340/// Selectively unroll short Scans (see [`rlx_opt::control_flow::maybe_unroll_scans`]).
341pub fn maybe_unroll_scans(graph: Graph, max_length: u32) -> Graph {
342    rlx_opt::control_flow::maybe_unroll_scans(graph, max_length)
343}
344
345/// Run `Op::Scan` against packed f32 host buffers (value-map / CoreML /
346/// OneAPI paths). Packs `[init, bcasts…, xs…]` into a contiguous byte arena,
347/// executes [`execute_scan_host`], and returns the output floats.
348#[allow(clippy::too_many_arguments)]
349pub fn run_scan_packed_f32(
350    body: &Graph,
351    length: u32,
352    save_trajectory: bool,
353    num_bcast: usize,
354    num_xs: usize,
355    init: &[f32],
356    bcasts: &[Vec<f32>],
357    xs: &[Vec<f32>],
358    out_elems: usize,
359) -> Vec<f32> {
360    assert_eq!(bcasts.len(), num_bcast);
361    assert_eq!(xs.len(), num_xs);
362    let plan = compile_scan_body(body, num_bcast, num_xs);
363    let mut arena: Vec<u8> = Vec::new();
364    let init_off = arena.len();
365    arena.extend(init.iter().flat_map(|f| f.to_le_bytes()));
366    let mut bcast_outer: Vec<(usize, usize)> = Vec::with_capacity(num_bcast);
367    for v in bcasts {
368        let off = arena.len();
369        arena.extend(v.iter().flat_map(|f| f.to_le_bytes()));
370        bcast_outer.push((off, v.len() * 4));
371    }
372    let mut xs_outer: Vec<(usize, usize)> = Vec::with_capacity(num_xs);
373    for v in xs {
374        let total = v.len() * 4;
375        let off = arena.len();
376        arena.extend(v.iter().flat_map(|f| f.to_le_bytes()));
377        xs_outer.push((off, total / length as usize));
378    }
379    let final_off = arena.len();
380    arena.resize(final_off + out_elems * 4, 0);
381    let desc = ScanHostDesc {
382        plan: Arc::new(plan),
383        outer_init_off: init_off,
384        outer_final_off: final_off,
385        length,
386        save_trajectory,
387        xs_outer,
388        bcast_outer,
389    };
390    unsafe {
391        execute_scan_host_desc(arena.as_mut_ptr(), &desc);
392    }
393    arena[final_off..final_off + out_elems * 4]
394        .chunks_exact(4)
395        .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
396        .collect()
397}
398
399/// Build a [`ScanHostDesc`] from `(graph, node, |nid| byte_offset)`.
400#[macro_export]
401macro_rules! rlx_scan_host_desc {
402    ($graph:expr, $node:expr, $byte_off:expr) => {
403        $crate::thunk::scan_host_desc_from_node(&$graph, $node, $byte_off)
404    };
405}
406
407/// Execute a scan against a raw byte arena from a [`ScanHostDesc`].
408#[macro_export]
409macro_rules! rlx_execute_scan_on_bytes {
410    ($base:expr, $desc:expr) => {
411        $crate::thunk::execute_scan_host_desc($base, $desc)
412    };
413}
414
415/// Generic D2H → host-body → H2D staging for discrete-GPU f32 arenas.
416///
417/// Shared by Scan / HostOp / Spd host-fallbacks. `$on_host` receives
418/// `&mut [f32]` covering the full arena (`arena_size_bytes / 4` elems).
419#[macro_export]
420macro_rules! rlx_arena_stage_d2h {
421    (
422        arena_size_bytes = $nbytes:expr,
423        sync = $sync:block,
424        dtoh = |$host:ident| $dtoh:block,
425        on_host = |$host_mut:ident| $on_host:block,
426        htod = |$host_back:ident| $htod:block $(,)?
427    ) => {{
428        let n_f32 = ($nbytes) / 4;
429        $sync
430        let mut $host = vec![0f32; n_f32];
431        $dtoh
432        {
433            let $host_mut: &mut [f32] = &mut $host[..];
434            $on_host
435        }
436        let $host_back = $host;
437        $htod
438    }};
439}
440
441/// D2H → CPU scan → H2D staging for discrete-GPU arenas.
442#[macro_export]
443macro_rules! rlx_scan_stage_d2h {
444    (
445        arena_size_bytes = $nbytes:expr,
446        desc = $desc:expr,
447        sync = $sync:block,
448        dtoh = |$host:ident| $dtoh:block,
449        htod = |$host_back:ident| $htod:block $(,)?
450    ) => {
451        $crate::rlx_arena_stage_d2h! {
452            arena_size_bytes = $nbytes,
453            sync = $sync,
454            dtoh = |$host| $dtoh,
455            on_host = |host_mut| {
456                unsafe {
457                    $crate::thunk::execute_scan_host_desc(
458                        host_mut.as_mut_ptr() as *mut u8,
459                        $desc,
460                    );
461                }
462            },
463            htod = |$host_back| $htod,
464        }
465    };
466}
467
468/// Prefer short-Scan IR unroll; leave long / checkpointed Scans intact.
469#[macro_export]
470macro_rules! rlx_maybe_unroll_scans {
471    ($graph:expr, $max_length:expr) => {
472        $crate::thunk::maybe_unroll_scans($graph, $max_length)
473    };
474}