Skip to main content

rlx_runtime/
hetero.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// Licensed under the GNU General Public License, version 3.
5
6//! Heterogeneous **split** execution — run one graph across several devices.
7//!
8//! Unlike [`crate::graph_devices::GraphDevices`] (compile the *whole* graph to
9//! each device and pick one), this partitions a single graph into per-device
10//! **segments** and runs them as a pipeline, materializing the tensors that
11//! cross a device boundary on the host between segments. Two uses:
12//!
13//!   - **Op fallback:** [`DeviceMap::auto_fallback`] places every op the primary
14//!     backend can't lower (per [`crate::backend::Backend::supported_ops`]) on a
15//!     fallback device (usually CPU), so a graph runs on a GPU that only covers
16//!     part of it.
17//!   - **Explicit placement:** [`DeviceMap::from_fn`] assigns each node a device
18//!     (e.g. compute-heavy convs on the GPU, a cheap head on the CPU).
19//!
20//! Segments are maximal topological runs of same-device compute nodes, so a
21//! graph that's mostly one device produces few segments (few host round-trips).
22//! A single-device map yields one segment — identical to a plain
23//! [`CompiledGraph`], just via the value-map indirection.
24
25use std::collections::HashMap;
26
27use rlx_driver::Device;
28use rlx_ir::{Graph, Node, NodeId, Op, OpKind};
29
30use crate::CompileOptions;
31use crate::compiled::CompiledGraph;
32use crate::registry::backend_for;
33use crate::session::Session;
34
35/// Per-node device assignment for a graph. Indexed by `NodeId`. Source nodes
36/// (`Op::Input` / `Op::Param`) are external, so their entry is unused.
37#[derive(Debug, Clone)]
38pub struct DeviceMap {
39    devices: Vec<Device>,
40}
41
42fn is_source(op: &Op) -> bool {
43    matches!(op, Op::Input { .. } | Op::Param { .. })
44}
45
46impl DeviceMap {
47    /// Every node on `device` (one segment — the degenerate case).
48    pub fn single(graph: &Graph, device: Device) -> Self {
49        Self {
50            devices: vec![device; graph.len()],
51        }
52    }
53
54    /// Assign each node a device from `f`.
55    pub fn from_fn(graph: &Graph, f: impl Fn(&Node) -> Device) -> Self {
56        Self {
57            devices: graph.nodes().iter().map(&f).collect(),
58        }
59    }
60
61    /// Run each op on `primary`, except ops `primary`'s backend can't lower —
62    /// those go to `fallback`. Uses [`Backend::supported_ops`]; an empty list
63    /// means "accepts everything" (no split). `primary`'s backend must be
64    /// registered (its feature enabled), else everything falls back.
65    ///
66    /// [`Backend::supported_ops`]: crate::backend::Backend::supported_ops
67    pub fn auto_fallback(graph: &Graph, primary: Device, fallback: Device) -> Self {
68        let supported: Vec<OpKind> = backend_for(primary)
69            .map(|b| b.supported_ops().to_vec())
70            .unwrap_or_default();
71        let accepts = |op: &Op| supported.is_empty() || supported.contains(&op.kind());
72        let devices = graph
73            .nodes()
74            .iter()
75            .map(|n| {
76                if is_source(&n.op) || accepts(&n.op) {
77                    primary
78                } else {
79                    fallback
80                }
81            })
82            .collect();
83        Self { devices }
84    }
85
86    #[inline]
87    fn get(&self, id: NodeId) -> Device {
88        self.devices[id.0 as usize]
89    }
90}
91
92/// One compiled per-device segment plus the boundary tensors it consumes /
93/// produces (named by their original `NodeId`).
94struct Segment {
95    device: Device,
96    compiled: CompiledGraph,
97    /// Original NodeIds this segment needs as inputs (boundary values).
98    input_ids: Vec<NodeId>,
99    /// The subgraph input name for each `input_ids` entry (`"v<id>"`).
100    input_names: Vec<String>,
101    /// Original NodeIds this segment produces for later segments / outputs,
102    /// in the segment subgraph's output order.
103    output_ids: Vec<NodeId>,
104}
105
106/// A graph compiled across multiple devices. Feed inputs / params by name and
107/// [`run`](Self::run); intermediates that cross a device boundary round-trip
108/// through host memory.
109pub struct HeteroExecutable {
110    segments: Vec<Segment>,
111    input_name_to_id: HashMap<String, NodeId>,
112    param_name_to_id: HashMap<String, NodeId>,
113    graph_outputs: Vec<NodeId>,
114    params: HashMap<NodeId, Vec<f32>>,
115}
116
117impl HeteroExecutable {
118    /// Partition `graph` per `map` and compile each segment to its device.
119    pub fn compile(graph: &Graph, map: &DeviceMap, options: &CompileOptions) -> Self {
120        // 1. Assign each compute node to a segment: a new segment starts
121        //    whenever the device changes from the previous compute node (nodes
122        //    are in topological order, so a segment only depends on earlier
123        //    segments / source nodes).
124        let n = graph.len();
125        let mut seg_of: Vec<Option<usize>> = vec![None; n];
126        let mut seg_device: Vec<Device> = Vec::new();
127        let mut cur_dev: Option<Device> = None;
128        for node in graph.nodes() {
129            if is_source(&node.op) {
130                continue;
131            }
132            let d = map.get(node.id);
133            if cur_dev != Some(d) {
134                seg_device.push(d);
135                cur_dev = Some(d);
136            }
137            seg_of[node.id.0 as usize] = Some(seg_device.len() - 1);
138        }
139
140        // 2. Mark segment outputs: a compute node is an output of its segment
141        //    if it's a graph output or consumed by a *different* segment.
142        let mut is_seg_output: Vec<bool> = vec![false; n];
143        for node in graph.nodes() {
144            let Some(ns) = seg_of[node.id.0 as usize] else {
145                continue;
146            };
147            for &inp in &node.inputs {
148                if let Some(ps) = seg_of[inp.0 as usize]
149                    && ps != ns
150                {
151                    is_seg_output[inp.0 as usize] = true;
152                }
153            }
154        }
155        for &out in &graph.outputs {
156            is_seg_output[out.0 as usize] = true;
157        }
158
159        // 3. Build + compile each segment's subgraph.
160        let mut segments = Vec::with_capacity(seg_device.len());
161        for (s, &device) in seg_device.iter().enumerate() {
162            let mut sub = Graph::new(format!("{}#seg{s}", graph.name));
163            let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
164            let mut input_ids: Vec<NodeId> = Vec::new();
165            let mut input_names: Vec<String> = Vec::new();
166
167            for node in graph.nodes() {
168                if seg_of[node.id.0 as usize] != Some(s) {
169                    continue;
170                }
171                let mut new_inputs = Vec::with_capacity(node.inputs.len());
172                for &inp in &node.inputs {
173                    let nid = if let Some(&existing) = remap.get(&inp) {
174                        existing
175                    } else {
176                        // Boundary value produced outside this segment → a
177                        // subgraph Input fed by the executor.
178                        let name = format!("v{}", inp.0);
179                        let id = sub.append_node(
180                            Op::Input { name: name.clone() },
181                            Vec::new(),
182                            graph.shape(inp).clone(),
183                            Some(name.clone()),
184                        );
185                        remap.insert(inp, id);
186                        input_ids.push(inp);
187                        input_names.push(name);
188                        id
189                    };
190                    new_inputs.push(nid);
191                }
192                let new_id = sub.append_node(
193                    node.op.clone(),
194                    new_inputs,
195                    node.shape.clone(),
196                    node.name.clone(),
197                );
198                remap.insert(node.id, new_id);
199            }
200
201            // Outputs in stable node order.
202            let mut output_ids: Vec<NodeId> = Vec::new();
203            let mut sub_outputs: Vec<NodeId> = Vec::new();
204            for node in graph.nodes() {
205                if seg_of[node.id.0 as usize] == Some(s) && is_seg_output[node.id.0 as usize] {
206                    output_ids.push(node.id);
207                    sub_outputs.push(remap[&node.id]);
208                }
209            }
210            sub.set_outputs(sub_outputs);
211
212            let compiled = Session::new(device).compile_with(sub, options);
213            segments.push(Segment {
214                device,
215                compiled,
216                input_ids,
217                input_names,
218                output_ids,
219            });
220        }
221
222        // 4. Name → id maps for feeding inputs / params, and the final outputs.
223        let mut input_name_to_id = HashMap::new();
224        let mut param_name_to_id = HashMap::new();
225        for node in graph.nodes() {
226            match &node.op {
227                Op::Input { name } => {
228                    input_name_to_id.insert(name.clone(), node.id);
229                }
230                Op::Param { name } => {
231                    param_name_to_id.insert(name.clone(), node.id);
232                }
233                _ => {}
234            }
235        }
236
237        Self {
238            segments,
239            input_name_to_id,
240            param_name_to_id,
241            graph_outputs: graph.outputs.clone(),
242            params: HashMap::new(),
243        }
244    }
245
246    /// [`compile`](Self::compile) with the [`DeviceMap::auto_fallback`] policy —
247    /// run `graph` on `primary`, spilling unsupported ops to `fallback`.
248    pub fn with_fallback(
249        graph: &Graph,
250        primary: Device,
251        fallback: Device,
252        options: &CompileOptions,
253    ) -> Self {
254        let map = DeviceMap::auto_fallback(graph, primary, fallback);
255        Self::compile(graph, &map, options)
256    }
257
258    /// Set a parameter value (routed to whichever segment(s) consume it).
259    pub fn set_param(&mut self, name: &str, data: &[f32]) {
260        if let Some(&id) = self.param_name_to_id.get(name) {
261            self.params.insert(id, data.to_vec());
262        }
263    }
264
265    /// Run the pipeline: seed inputs + params, execute each segment on its
266    /// device (transferring boundary tensors through host memory), return the
267    /// graph outputs in declaration order.
268    pub fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
269        let mut values: HashMap<NodeId, Vec<f32>> = HashMap::new();
270        for (name, data) in inputs {
271            if let Some(&id) = self.input_name_to_id.get(*name) {
272                values.insert(id, data.to_vec());
273            }
274        }
275        for (id, data) in &self.params {
276            values.insert(*id, data.clone());
277        }
278
279        for seg in &mut self.segments {
280            let run_inputs: Vec<(&str, &[f32])> = seg
281                .input_ids
282                .iter()
283                .zip(&seg.input_names)
284                .map(|(id, name)| {
285                    (
286                        name.as_str(),
287                        values.get(id).map(Vec::as_slice).unwrap_or(&[]),
288                    )
289                })
290                .collect();
291            let outs = seg.compiled.run(&run_inputs);
292            for (i, &oid) in seg.output_ids.iter().enumerate() {
293                values.insert(oid, outs.get(i).cloned().unwrap_or_default());
294            }
295        }
296
297        self.graph_outputs
298            .iter()
299            .map(|id| values.get(id).cloned().unwrap_or_default())
300            .collect()
301    }
302
303    /// Number of device segments the graph was split into.
304    pub fn num_segments(&self) -> usize {
305        self.segments.len()
306    }
307
308    /// The device of each segment, in execution order.
309    pub fn segment_devices(&self) -> Vec<Device> {
310        self.segments.iter().map(|s| s.device).collect()
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use rlx_ir::infer::GraphExt;
318    use rlx_ir::{DType, Shape};
319
320    /// Register the CPU backend under a second device so a "heterogeneous"
321    /// split can be exercised (and its result checked) on one machine.
322    fn alias_cpu_to(device: Device) {
323        crate::registry::register_backend(device, || {
324            backend_for(Device::Cpu).expect("cpu backend (enable feature `cpu`)")
325        });
326    }
327
328    // y = relu(x·w + b), then y·2. A few compute nodes to split.
329    fn build() -> (Graph, NodeId, NodeId, NodeId) {
330        let f = DType::F32;
331        let mut g = Graph::new("hetero_test");
332        let x = g.input("x", Shape::new(&[2, 3], f));
333        let w = g.param("w", Shape::new(&[3, 4], f));
334        let b = g.param("b", Shape::new(&[2, 4], f));
335        let mm = g.matmul(x, w, Shape::new(&[2, 4], f));
336        let z = g.add(mm, b);
337        let two = g.add(z, z); // stand-in for a second stage
338        g.set_outputs(vec![two]);
339        (g, w, b, two)
340    }
341
342    fn inputs() -> (Vec<f32>, Vec<f32>, Vec<f32>) {
343        let x = (0..6).map(|i| i as f32 * 0.1).collect();
344        let w = (0..12).map(|i| (i as f32 * 0.05) - 0.3).collect();
345        let b = vec![0.1; 8];
346        (x, w, b)
347    }
348
349    #[test]
350    fn single_segment_matches_plain_compile() {
351        let (g, _w, _b, _) = build();
352        let (x, w, b) = inputs();
353        // Reference: plain CPU compile.
354        let mut plain = Session::new(Device::Cpu).compile_with(g.clone(), &CompileOptions::new());
355        plain.set_param("w", &w);
356        plain.set_param("b", &b);
357        let want = plain.run(&[("x", &x)]);
358
359        // Hetero, all CPU → one segment.
360        let map = DeviceMap::single(&g, Device::Cpu);
361        let mut hx = HeteroExecutable::compile(&g, &map, &CompileOptions::new());
362        assert_eq!(hx.num_segments(), 1);
363        hx.set_param("w", &w);
364        hx.set_param("b", &b);
365        let got = hx.run(&[("x", &x)]);
366        assert_eq!(got, want);
367    }
368
369    #[test]
370    fn multi_device_split_matches_single() {
371        // Second CPU-backed device so the split really compiles + runs two
372        // segments, transferring the boundary tensor through host memory.
373        alias_cpu_to(Device::Vulkan);
374        let (g, _w, _b, two) = build();
375        let (x, w, b) = inputs();
376
377        let mut plain = Session::new(Device::Cpu).compile_with(g.clone(), &CompileOptions::new());
378        plain.set_param("w", &w);
379        plain.set_param("b", &b);
380        let want = plain.run(&[("x", &x)]);
381
382        // Put the last node (`two`) on the aliased device, the rest on CPU →
383        // two segments with a CPU→Vulkan boundary.
384        let map = DeviceMap::from_fn(&g, |node| {
385            if node.id == two {
386                Device::Vulkan
387            } else {
388                Device::Cpu
389            }
390        });
391        let mut hx = HeteroExecutable::compile(&g, &map, &CompileOptions::new());
392        assert_eq!(hx.num_segments(), 2, "expected a CPU + aliased split");
393        assert_eq!(hx.segment_devices(), vec![Device::Cpu, Device::Vulkan]);
394        hx.set_param("w", &w);
395        hx.set_param("b", &b);
396        let got = hx.run(&[("x", &x)]);
397        for (a, e) in got[0].iter().zip(&want[0]) {
398            assert!((a - e).abs() < 1e-5, "split mismatch: {a} vs {e}");
399        }
400    }
401
402    // A genuine CPU + GPU split: the matmul+add prefix runs on the real CUDA
403    // device, the final node on CPU — the boundary tensor crosses GPU→host→CPU.
404    // Only built/run with `--features cuda` (i.e. on a CUDA machine).
405    #[cfg(feature = "cuda")]
406    #[test]
407    fn real_cpu_cuda_split_matches_single() {
408        // The `cuda` feature can be compiled (cudarc dynamic-loading) on a host
409        // with no CUDA device (e.g. macOS CI); skip rather than panic there.
410        if !crate::device_ext::is_available(Device::Cuda) {
411            eprintln!("skipping real_cpu_cuda_split_matches_single: no CUDA device");
412            return;
413        }
414        let (g, _w, _b, two) = build();
415        let (x, w, b) = inputs();
416
417        let mut plain = Session::new(Device::Cpu).compile_with(g.clone(), &CompileOptions::new());
418        plain.set_param("w", &w);
419        plain.set_param("b", &b);
420        let want = plain.run(&[("x", &x)]);
421
422        let map = DeviceMap::from_fn(&g, |node| {
423            if node.id == two {
424                Device::Cpu
425            } else {
426                Device::Cuda
427            }
428        });
429        let mut hx = HeteroExecutable::compile(&g, &map, &CompileOptions::new());
430        assert_eq!(hx.num_segments(), 2);
431        assert_eq!(hx.segment_devices(), vec![Device::Cuda, Device::Cpu]);
432        hx.set_param("w", &w);
433        hx.set_param("b", &b);
434        let got = hx.run(&[("x", &x)]);
435        for (a, e) in got[0].iter().zip(&want[0]) {
436            assert!((a - e).abs() < 1e-4, "cpu+cuda split mismatch: {a} vs {e}");
437        }
438    }
439
440    #[test]
441    fn auto_fallback_all_cpu_is_single_segment() {
442        // CPU claims to support everything (empty supported_ops) → no split.
443        let (g, _, _, _) = build();
444        let map = DeviceMap::auto_fallback(&g, Device::Cpu, Device::Cpu);
445        let hx = HeteroExecutable::compile(&g, &map, &CompileOptions::new());
446        assert_eq!(hx.num_segments(), 1);
447    }
448}