Skip to main content

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