Skip to main content

rlx_cpu/thunk/
scan.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Shared `Op::Scan` host-fallback plumbing for GPU backends.
17//!
18//! Backends compile a scan node into a [`ScanHostDesc`] once (via
19//! [`scan_host_desc_from_node`] / [`rlx_scan_host_desc`]), then at run time
20//! stage arena bytes (unified memory, full D2H, or a rebated span) and call
21//! [`execute_scan_host_desc`]. Short Scans are preferentially unrolled by
22//! [`maybe_unroll_scans`] / [`rlx_maybe_unroll_scans`] so the body runs as
23//! ordinary device ops.
24
25use crate::thunk::{ScanBodyPlan, compile_scan_body, execute_scan_host};
26use rlx_ir::{Graph, NodeId, Op};
27use std::sync::Arc;
28
29/// Compiled scan + outer-arena layout — the fields formerly duplicated as
30/// `Step::ScanHost` / `Thunk::ScanHost` across Metal / CUDA / ROCm / wgpu.
31#[derive(Clone)]
32pub struct ScanHostDesc {
33    pub plan: Arc<ScanBodyPlan>,
34    pub outer_init_off: usize,
35    pub outer_final_off: usize,
36    pub length: u32,
37    pub save_trajectory: bool,
38    /// `(outer_off, per_step_bytes)` for each per-step `xs` input.
39    pub xs_outer: Vec<(usize, usize)>,
40    /// `(outer_off, total_bytes)` for each broadcast input.
41    pub bcast_outer: Vec<(usize, usize)>,
42}
43
44impl std::fmt::Debug for ScanHostDesc {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("ScanHostDesc")
47            .field("carry_bytes", &self.plan.carry_bytes)
48            .field("length", &self.length)
49            .field("save_trajectory", &self.save_trajectory)
50            .field("num_xs", &self.xs_outer.len())
51            .field("num_bcast", &self.bcast_outer.len())
52            .finish()
53    }
54}
55
56/// Build a [`ScanHostDesc`] from an `Op::Scan` node. `byte_off` returns the
57/// outer-arena byte offset of a [`NodeId`] (init / bcast / xs / output).
58pub fn scan_host_desc_from_node(
59    graph: &Graph,
60    node: &rlx_ir::Node,
61    mut byte_off: impl FnMut(NodeId) -> usize,
62) -> ScanHostDesc {
63    let (body, length, save_trajectory, num_bcast, num_xs) = match &node.op {
64        Op::Scan {
65            body,
66            length,
67            save_trajectory,
68            num_bcast,
69            num_xs,
70            ..
71        } => (
72            body,
73            *length,
74            *save_trajectory,
75            *num_bcast as usize,
76            *num_xs as usize,
77        ),
78        _ => panic!("scan_host_desc_from_node: expected Op::Scan"),
79    };
80    let plan = compile_scan_body(body, num_bcast, num_xs);
81    let bcast_outer: Vec<(usize, usize)> = (0..num_bcast)
82        .map(|i| {
83            let id = node.inputs[1 + i];
84            (byte_off(id), graph.node(id).shape.size_bytes().unwrap())
85        })
86        .collect();
87    let xs_outer: Vec<(usize, usize)> = (0..num_xs)
88        .map(|i| {
89            let id = node.inputs[1 + num_bcast + i];
90            let total = graph.node(id).shape.size_bytes().unwrap();
91            (byte_off(id), total / length as usize)
92        })
93        .collect();
94    ScanHostDesc {
95        plan: Arc::new(plan),
96        outer_init_off: byte_off(node.inputs[0]),
97        outer_final_off: byte_off(node.id),
98        length,
99        save_trajectory,
100        xs_outer,
101        bcast_outer,
102    }
103}
104
105/// Run [`execute_scan_host`] from a [`ScanHostDesc`].
106///
107/// # Safety
108/// `base` must span every outer offset referenced by `desc`.
109pub unsafe fn execute_scan_host_desc(base: *mut u8, desc: &ScanHostDesc) {
110    unsafe {
111        execute_scan_host(
112            base,
113            &desc.plan,
114            desc.outer_init_off,
115            desc.outer_final_off,
116            desc.length,
117            desc.save_trajectory,
118            &desc.xs_outer,
119            &desc.bcast_outer,
120        );
121    }
122}
123
124/// Contiguous host span covering every region a scan touches, with offsets
125/// rebased to the span start. Used by wgpu (no host-mapped arena).
126#[derive(Clone)]
127pub struct ScanHostSpan {
128    pub lo: usize,
129    pub hi: usize,
130    pub desc: ScanHostDesc,
131}
132
133impl ScanHostSpan {
134    pub fn from_desc(desc: ScanHostDesc) -> Self {
135        let cb = desc.plan.carry_bytes;
136        let out_len = if desc.save_trajectory {
137            desc.length as usize * cb
138        } else {
139            cb
140        };
141        let mut lo = desc.outer_init_off.min(desc.outer_final_off);
142        let mut hi = (desc.outer_init_off + cb).max(desc.outer_final_off + out_len);
143        for &(o, t) in &desc.bcast_outer {
144            lo = lo.min(o);
145            hi = hi.max(o + t);
146        }
147        for &(o, ps) in &desc.xs_outer {
148            lo = lo.min(o);
149            hi = hi.max(o + desc.length as usize * ps);
150        }
151        let rebated = ScanHostDesc {
152            plan: desc.plan,
153            outer_init_off: desc.outer_init_off - lo,
154            outer_final_off: desc.outer_final_off - lo,
155            length: desc.length,
156            save_trajectory: desc.save_trajectory,
157            xs_outer: desc.xs_outer.iter().map(|&(o, ps)| (o - lo, ps)).collect(),
158            bcast_outer: desc.bcast_outer.iter().map(|&(o, t)| (o - lo, t)).collect(),
159        };
160        Self {
161            lo,
162            hi,
163            desc: rebated,
164        }
165    }
166
167    pub fn len(&self) -> usize {
168        self.hi - self.lo
169    }
170
171    pub fn is_empty(&self) -> bool {
172        self.len() == 0
173    }
174}
175
176/// Evaluate a single op (including nested-body ops like `Op::ScanBackward`)
177/// via a one-op CPU graph + thunk executor. Used by GPU host-fallbacks for
178/// ops that are awkward to drive as raw `ScanHostDesc` layout.
179pub fn eval_single_op_f32(
180    op: &Op,
181    out_shape: &rlx_ir::Shape,
182    inputs: &[(rlx_ir::Shape, &[f32])],
183) -> Vec<f32> {
184    let mut g = Graph::new("scan_eval_single_op");
185    let ids: Vec<NodeId> = inputs
186        .iter()
187        .enumerate()
188        .map(|(i, (sh, _))| {
189            g.append_node(
190                Op::Input {
191                    name: format!("in{i}"),
192                },
193                vec![],
194                sh.clone(),
195                None,
196            )
197        })
198        .collect();
199    let out = g.append_node(op.clone(), ids.clone(), out_shape.clone(), None);
200    g.set_outputs(vec![out]);
201    let plan = rlx_opt::memory::plan_memory_aligned(&g, 16);
202    let mut arena = crate::arena::Arena::from_plan(plan);
203    for (i, (_, vals)) in inputs.iter().enumerate() {
204        let slot = arena.slice_mut(ids[i]);
205        let n = slot.len().min(vals.len());
206        slot[..n].copy_from_slice(&vals[..n]);
207    }
208    let schedule = crate::thunk::compile_thunks(&g, &arena);
209    crate::thunk::execute_thunks(&schedule, arena.raw_buf_mut());
210    let n = out_shape.num_elements().unwrap_or(0);
211    arena.slice_mut(out)[..n].to_vec()
212}
213
214/// Selectively unroll short Scans (see [`rlx_opt::control_flow::maybe_unroll_scans`]).
215pub fn maybe_unroll_scans(graph: Graph, max_length: u32) -> Graph {
216    rlx_opt::control_flow::maybe_unroll_scans(graph, max_length)
217}
218
219/// Run `Op::Scan` against packed f32 host buffers (value-map / CoreML /
220/// OneAPI paths). Packs `[init, bcasts…, xs…]` into a contiguous byte arena,
221/// executes [`execute_scan_host`], and returns the output floats.
222#[allow(clippy::too_many_arguments)]
223pub fn run_scan_packed_f32(
224    body: &Graph,
225    length: u32,
226    save_trajectory: bool,
227    num_bcast: usize,
228    num_xs: usize,
229    init: &[f32],
230    bcasts: &[Vec<f32>],
231    xs: &[Vec<f32>],
232    out_elems: usize,
233) -> Vec<f32> {
234    assert_eq!(bcasts.len(), num_bcast);
235    assert_eq!(xs.len(), num_xs);
236    let plan = compile_scan_body(body, num_bcast, num_xs);
237    let mut arena: Vec<u8> = Vec::new();
238    let init_off = arena.len();
239    arena.extend(init.iter().flat_map(|f| f.to_le_bytes()));
240    let mut bcast_outer: Vec<(usize, usize)> = Vec::with_capacity(num_bcast);
241    for v in bcasts {
242        let off = arena.len();
243        arena.extend(v.iter().flat_map(|f| f.to_le_bytes()));
244        bcast_outer.push((off, v.len() * 4));
245    }
246    let mut xs_outer: Vec<(usize, usize)> = Vec::with_capacity(num_xs);
247    for v in xs {
248        let total = v.len() * 4;
249        let off = arena.len();
250        arena.extend(v.iter().flat_map(|f| f.to_le_bytes()));
251        xs_outer.push((off, total / length as usize));
252    }
253    let final_off = arena.len();
254    arena.resize(final_off + out_elems * 4, 0);
255    let desc = ScanHostDesc {
256        plan: Arc::new(plan),
257        outer_init_off: init_off,
258        outer_final_off: final_off,
259        length,
260        save_trajectory,
261        xs_outer,
262        bcast_outer,
263    };
264    unsafe {
265        execute_scan_host_desc(arena.as_mut_ptr(), &desc);
266    }
267    arena[final_off..final_off + out_elems * 4]
268        .chunks_exact(4)
269        .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
270        .collect()
271}
272
273/// Build a [`ScanHostDesc`] from `(graph, node, |nid| byte_offset)`.
274#[macro_export]
275macro_rules! rlx_scan_host_desc {
276    ($graph:expr, $node:expr, $byte_off:expr) => {
277        $crate::thunk::scan_host_desc_from_node(&$graph, $node, $byte_off)
278    };
279}
280
281/// Execute a scan against a raw byte arena from a [`ScanHostDesc`].
282#[macro_export]
283macro_rules! rlx_execute_scan_on_bytes {
284    ($base:expr, $desc:expr) => {
285        $crate::thunk::execute_scan_host_desc($base, $desc)
286    };
287}
288
289/// Generic D2H → host-body → H2D staging for discrete-GPU f32 arenas.
290///
291/// Shared by Scan / HostOp / Spd host-fallbacks. `$on_host` receives
292/// `&mut [f32]` covering the full arena (`arena_size_bytes / 4` elems).
293#[macro_export]
294macro_rules! rlx_arena_stage_d2h {
295    (
296        arena_size_bytes = $nbytes:expr,
297        sync = $sync:block,
298        dtoh = |$host:ident| $dtoh:block,
299        on_host = |$host_mut:ident| $on_host:block,
300        htod = |$host_back:ident| $htod:block $(,)?
301    ) => {{
302        let n_f32 = ($nbytes) / 4;
303        $sync
304        let mut $host = vec![0f32; n_f32];
305        $dtoh
306        {
307            let $host_mut: &mut [f32] = &mut $host[..];
308            $on_host
309        }
310        let $host_back = $host;
311        $htod
312    }};
313}
314
315/// D2H → CPU scan → H2D staging for discrete-GPU arenas.
316#[macro_export]
317macro_rules! rlx_scan_stage_d2h {
318    (
319        arena_size_bytes = $nbytes:expr,
320        desc = $desc:expr,
321        sync = $sync:block,
322        dtoh = |$host:ident| $dtoh:block,
323        htod = |$host_back:ident| $htod:block $(,)?
324    ) => {
325        $crate::rlx_arena_stage_d2h! {
326            arena_size_bytes = $nbytes,
327            sync = $sync,
328            dtoh = |$host| $dtoh,
329            on_host = |host_mut| {
330                unsafe {
331                    $crate::thunk::execute_scan_host_desc(
332                        host_mut.as_mut_ptr() as *mut u8,
333                        $desc,
334                    );
335                }
336            },
337            htod = |$host_back| $htod,
338        }
339    };
340}
341
342/// Prefer short-Scan IR unroll; leave long / checkpointed Scans intact.
343#[macro_export]
344macro_rules! rlx_maybe_unroll_scans {
345    ($graph:expr, $max_length:expr) => {
346        $crate::thunk::maybe_unroll_scans($graph, $max_length)
347    };
348}