Skip to main content

fuel_parallel/
device_group.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Single-process, multi-device collectives — **lazy-only**.
3//!
4//! # Why this exists alongside [`crate::comm::Communicator`]
5//!
6//! `Communicator` is the **SPMD** shape: each rank calls `all_reduce` with *its
7//! own* tensor and the implementation rendezvouses with the other ranks. That is
8//! the right shape for multi-process / multi-node.
9//!
10//! A **single process driving N devices holds every shard at once**. There is no
11//! other rank to meet, so the per-rank signature does not fit — which is why
12//! `IdentityComm` (the only `Communicator` impl today) returns its input: with
13//! `world_size == 1` there is genuinely nothing to reduce. That is correct for a
14//! single rank and is *not* a working collective.
15//!
16//! `DeviceGroup` is the slice-shaped counterpart: you hand it all the shards.
17//! "One process, four GPUs" — the configuration most inference deployments
18//! actually run — needs cross-device copies, not a multi-node transport.
19//!
20//! # Why this module is lazy-only
21//!
22//! Everything tensor-touching in this crate targets [`Tensor`], and every
23//! model in Fuel is built on the lazy graph. A collective written against the
24//! old eager `Tensor` could not have reduced lazy shards, which is the likeliest
25//! reason this crate has no consumers. B6 deleted that eager `Tensor` outright
26//! (`fuel-core/src/tensor.rs` is gone), so lazy-only is now the only option
27//! rather than a preference.
28//!
29//! # Transport, and why cross-vendor hops are authored here
30//!
31//! Movement is [`Tensor::copy_to_device`] → `Op::Copy`, resolved by the
32//! executor's Copy arm. **Every hop with CPU on one end is a single `Op::Copy`**
33//! — CPU↔CUDA, CPU↔Vulkan, and same-device. A **direct cross-vendor GPU↔GPU copy
34//! is not implemented**: both GPU wrappers reject a foreign-GPU output, and
35//! `fuel-graph/src/opt.rs:2590` says so outright — "a cross-VENDOR GPU↔GPU edge
36//! (e.g. CUDA↔Vulkan) can't be done as one `Op::Copy`".
37//!
38//! The optimizer *does* split such an edge into two hops, but
39//! `insert_cross_device_copies` **deliberately skips any consumer that is itself
40//! an `Op::Copy`/`Op::Move`** (`opt.rs:2571-2577`) — otherwise inserting a copy on
41//! a copy's input would regress infinitely. That carve-out is correct, and it
42//! means a **user-authored** copy across vendors is never split for you.
43//!
44//! So this module **stages through CPU itself** when source and leader are
45//! different non-CPU backends: `src → CPU → leader`, two explicit `Op::Copy`
46//! nodes. A group may therefore genuinely span CPU + CUDA + Vulkan.
47//!
48//! *(An earlier version of this comment claimed the path was "backend-agnostic …
49//! without special-casing". That was inferred from `copy_to_device`'s doc comment
50//! rather than read from the pass, and it was wrong.)*
51
52use crate::comm::ReduceOp;
53use fuel::lazy::Tensor;
54use fuel::{Device, DeviceLocation, Error, Result};
55
56/// A set of devices participating in single-process collectives.
57///
58/// The first device is the **leader**: reductions land there, because a reduced
59/// value has to live somewhere and the caller needs to know where without asking.
60#[derive(Debug, Clone)]
61pub struct DeviceGroup {
62    devices: Vec<Device>,
63}
64
65impl DeviceGroup {
66    /// Build a group over `devices`. The first is the leader.
67    ///
68    /// Returns `Err` for an empty list rather than panicking — a group with no
69    /// devices has no leader, so every collective on it would be undefined.
70    pub fn new(devices: Vec<Device>) -> Result<Self> {
71        if devices.is_empty() {
72            return Err(Error::Msg(
73                "DeviceGroup::new: at least one device is required (the first is the leader)"
74                    .into(),
75            ));
76        }
77        Ok(Self { devices })
78    }
79
80    /// Number of devices in the group.
81    pub fn size(&self) -> usize {
82        self.devices.len()
83    }
84
85    /// The leader device — where reductions land.
86    pub fn leader(&self) -> &Device {
87        &self.devices[0]
88    }
89
90    /// The devices in this group, in rank order.
91    pub fn devices(&self) -> &[Device] {
92        &self.devices
93    }
94
95    /// Reduce `shards` element-wise onto the leader device.
96    ///
97    /// **Shard `i` is taken to live on device `i`** — the group's device list is
98    /// the residency map, so `shards.len()` must not exceed [`size`](Self::size).
99    /// Each shard is brought to the leader (staging through CPU when it must
100    /// cross vendors) and folded
101    /// with `op`.
102    pub fn all_reduce(&self, shards: &[Tensor], op: ReduceOp) -> Result<Tensor> {
103        let shards = self.check_shards(shards, "all_reduce")?;
104
105        let mut acc = self.bring_to_leader(&shards[0], 0);
106        for (i, shard) in shards.iter().enumerate().skip(1) {
107            let here = self.bring_to_leader(shard, i);
108            acc = match op {
109                ReduceOp::Sum => acc.add(&here),
110                ReduceOp::Product => acc.mul(&here),
111                ReduceOp::Max => acc.maximum(&here),
112                ReduceOp::Min => acc.minimum(&here),
113            }
114            .map_err(|e| Error::Msg(format!("DeviceGroup::all_reduce: {op:?} failed: {e}")))?;
115        }
116        Ok(acc)
117    }
118
119    /// Concatenate `shards` along `dim` onto the leader device.
120    ///
121    /// The gather counterpart of [`all_reduce`](Self::all_reduce): where reduce
122    /// combines values element-wise, this preserves every shard's contribution.
123    pub fn all_gather(&self, shards: &[Tensor], dim: usize) -> Result<Tensor> {
124        let shards = self.check_shards(shards, "all_gather")?;
125
126        let mut acc = self.bring_to_leader(&shards[0], 0);
127        for (i, shard) in shards.iter().enumerate().skip(1) {
128            let here = self.bring_to_leader(shard, i);
129            acc = acc.concat(&here, dim).map_err(|e| {
130                Error::Msg(format!(
131                    "DeviceGroup::all_gather: concat on dim {dim} failed: {e}"
132                ))
133            })?;
134        }
135        Ok(acc)
136    }
137
138    /// Realize `t` as `f32`, seeding a device handle for **every** device in the
139    /// group.
140    ///
141    /// A plain `Tensor::realize_f32` seeds only the *primary* device, so a
142    /// graph whose nodes span several backends cannot be executed through it —
143    /// the non-primary backends have no handle to allocate against. This routes
144    /// through `pipelined_bridge::realize_one_as_multi_device` with the leader as
145    /// primary and the remaining group devices as extras, which is the entry the
146    /// existing live multi-vendor tests use.
147    ///
148    /// Callers should not have to know that: the group already owns the device
149    /// list, so it is the natural place for the seeding to live.
150    pub fn realize_f32(&self, t: &Tensor) -> Result<Vec<f32>> {
151        let extras: Vec<&Device> = self.devices[1..].iter().collect();
152        let graph = t.graph().clone();
153        fuel::pipelined_bridge::realize_one_as_multi_device::<f32>(
154            &graph,
155            t.node_id(),
156            self.leader(),
157            &extras,
158        )
159    }
160
161    /// Bring `shard`, which lives on `devices[rank]`, onto the leader device —
162    /// **authoring the CPU hop ourselves when the move crosses vendors.**
163    ///
164    /// Three cases:
165    /// - already on the leader → a same-device `Op::Copy`, which the backend can
166    ///   elide;
167    /// - one end is CPU → a single `Op::Copy` (the implemented direct hops);
168    /// - **two different non-CPU backends** → `src → CPU → leader`, two explicit
169    ///   `Op::Copy` nodes, because a direct cross-vendor GPU↔GPU copy does not
170    ///   exist and the optimizer will not split a Copy's own input
171    ///   (`opt.rs:2571-2577`). Emitting one hop here would fail at execute time
172    ///   with a foreign-GPU-output rejection.
173    ///
174    /// A `rank` past the device list falls back to the leader unchanged: the
175    /// count mismatch is already reported by [`check_shards`](Self::check_shards),
176    /// and this must not panic on the way there.
177    fn bring_to_leader(&self, shard: &Tensor, rank: usize) -> Tensor {
178        let leader = self.leader();
179        let Some(src) = self.devices.get(rank) else {
180            return shard.copy_to_device(leader);
181        };
182
183        let (src_loc, leader_loc) = (src.location(), leader.location());
184        if src_loc == leader_loc {
185            return shard.copy_to_device(leader);
186        }
187
188        let cpu = DeviceLocation::Cpu;
189        if src_loc == cpu || leader_loc == cpu {
190            // One end is the host: a single implemented hop.
191            return shard.copy_to_device(leader);
192        }
193
194        // Cross-vendor: author both hops.
195        shard.copy_to_device(&Device::cpu()).copy_to_device(leader)
196    }
197
198    /// Shared precondition check for the collectives.
199    ///
200    /// Both failures below would otherwise surface as a **panic** from deeper in
201    /// the graph builder — empty slice as an index panic, mixed graphs as
202    /// `Op`-level `Arc::ptr_eq` assertion. Catching them here keeps the
203    /// never-panic contract at the collective boundary, and the graph case can
204    /// name the fix because every tensor reports its
205    /// [`graph_id`](Tensor::graph_id).
206    fn check_shards<'a>(&self, shards: &'a [Tensor], who: &str) -> Result<&'a [Tensor]> {
207        if shards.is_empty() {
208            return Err(Error::Msg(format!(
209                "DeviceGroup::{who}: no shards to combine (expected at least one)"
210            )));
211        }
212        if shards.len() > self.devices.len() {
213            return Err(Error::Msg(format!(
214                "DeviceGroup::{who}: {} shards for a group of {} devices — shard i is taken to \
215                 live on device i, so there cannot be more shards than devices",
216                shards.len(),
217                self.devices.len(),
218            )));
219        }
220        let g0 = shards[0].graph_id();
221        if let Some(bad) = shards.iter().position(|s| s.graph_id() != g0) {
222            return Err(Error::Msg(format!(
223                "DeviceGroup::{who}: shards must live on the same graph — shard 0 is on graph \
224                 #{g0}, shard {bad} on graph #{}; each `from_*` constructor mints a NEW graph, so \
225                 build the shards with `from_*_on(first.graph(), ..)` or `const_*_like`",
226                shards[bad].graph_id(),
227            )));
228        }
229        Ok(shards)
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use fuel::Shape;
237
238    fn shard(anchor: Option<&Tensor>, data: Vec<f32>, dev: &Device) -> Tensor {
239        let shape = Shape::from_dims(&[data.len()]);
240        match anchor {
241            Some(a) => Tensor::from_f32_on(a.graph(), data, shape, dev).unwrap(),
242            None => Tensor::from_f32(data, shape, dev).unwrap(),
243        }
244    }
245
246    #[test]
247    fn new_rejects_an_empty_device_list() {
248        assert!(
249            DeviceGroup::new(vec![]).is_err(),
250            "a group with no devices has no leader, so collectives on it are undefined",
251        );
252    }
253
254    #[test]
255    fn leader_is_the_first_device() {
256        let g = DeviceGroup::new(vec![Device::cpu()]).unwrap();
257        assert_eq!(g.size(), 1);
258        assert_eq!(g.leader().location(), Device::cpu().location());
259    }
260
261    /// A group of `n` CPU devices — the degenerate all-same-location case, which
262    /// is what a host-only test can construct. `shard i` still corresponds to
263    /// `device i`, so `n` shards need `n` devices.
264    fn cpu_group(n: usize) -> (DeviceGroup, Device) {
265        let dev = Device::cpu();
266        (DeviceGroup::new(vec![dev.clone(); n]).unwrap(), dev)
267    }
268
269    #[test]
270    fn all_reduce_sums_across_shards() {
271        let (g, dev) = cpu_group(2);
272        let a = shard(None, vec![1.0, 2.0], &dev);
273        let b = shard(Some(&a), vec![10.0, 20.0], &dev);
274        let out = g.all_reduce(&[a, b], ReduceOp::Sum).unwrap();
275        assert_eq!(out.realize_f32(), vec![11.0, 22.0]);
276    }
277
278    #[test]
279    fn all_reduce_takes_the_elementwise_max() {
280        let (g, dev) = cpu_group(2);
281        let a = shard(None, vec![1.0, 50.0], &dev);
282        let b = shard(Some(&a), vec![10.0, 20.0], &dev);
283        let out = g.all_reduce(&[a, b], ReduceOp::Max).unwrap();
284        assert_eq!(out.realize_f32(), vec![10.0, 50.0]);
285    }
286
287    #[test]
288    fn all_reduce_of_one_shard_is_that_shard() {
289        let dev = Device::cpu();
290        let g = DeviceGroup::new(vec![dev.clone()]).unwrap();
291        let a = shard(None, vec![3.0, 4.0], &dev);
292        let out = g
293            .all_reduce(std::slice::from_ref(&a), ReduceOp::Sum)
294            .unwrap();
295        assert_eq!(out.realize_f32(), vec![3.0, 4.0]);
296    }
297
298    #[test]
299    fn all_reduce_rejects_empty_shards() {
300        let g = DeviceGroup::new(vec![Device::cpu()]).unwrap();
301        assert!(
302            g.all_reduce(&[], ReduceOp::Sum).is_err(),
303            "zero shards is an error, not a panic (never-panic contract)",
304        );
305    }
306
307    #[test]
308    fn all_reduce_rejects_cross_graph_shards_and_names_the_fix() {
309        let (g, dev) = cpu_group(2);
310        // Two INDEPENDENT constructors => two graphs. Combining them would trip
311        // the Op-level affinity assert; the group must catch it first.
312        let a = shard(None, vec![1.0, 2.0], &dev);
313        let b = shard(None, vec![3.0, 4.0], &dev);
314        let err = g
315            .all_reduce(&[a, b], ReduceOp::Sum)
316            .unwrap_err()
317            .to_string();
318        assert!(err.contains("same graph"), "got: {err}");
319        assert!(
320            err.contains("const_*_like") || err.contains("from_*_on"),
321            "must name the fix; got: {err}"
322        );
323    }
324
325    #[test]
326    fn all_reduce_rejects_more_shards_than_devices() {
327        // shard i lives on device i, so more shards than devices is incoherent
328        // — and must be an error rather than an out-of-range fallback.
329        let dev = Device::cpu();
330        let g = DeviceGroup::new(vec![dev.clone()]).unwrap();
331        let a = shard(None, vec![1.0], &dev);
332        let b = shard(Some(&a), vec![2.0], &dev);
333        let err = g
334            .all_reduce(&[a, b], ReduceOp::Sum)
335            .unwrap_err()
336            .to_string();
337        assert!(err.contains("2 shards"), "got: {err}");
338        assert!(err.contains("1 devices"), "got: {err}");
339    }
340
341    #[test]
342    fn same_device_ranks_take_the_single_hop_path() {
343        // Regression guard for the staging logic: when every device in the group
344        // is the same location, no rank should be routed through a CPU stage.
345        // Correctness is observable (values), hop count is not from here — this
346        // pins the behaviour that the all-CPU group still reduces correctly
347        // after `bring_to_leader` gained the cross-vendor branch.
348        let dev = Device::cpu();
349        let g = DeviceGroup::new(vec![dev.clone(), dev.clone(), dev.clone()]).unwrap();
350        let a = shard(None, vec![1.0, 1.0], &dev);
351        let b = shard(Some(&a), vec![2.0, 2.0], &dev);
352        let c = shard(Some(&a), vec![3.0, 3.0], &dev);
353        let out = g.all_reduce(&[a, b, c], ReduceOp::Sum).unwrap();
354        assert_eq!(out.realize_f32(), vec![6.0, 6.0]);
355    }
356
357    #[test]
358    fn all_gather_concatenates_shards() {
359        let (g, dev) = cpu_group(2);
360        let a = shard(None, vec![1.0, 2.0], &dev);
361        let b = shard(Some(&a), vec![3.0, 4.0], &dev);
362        let out = g.all_gather(&[a, b], 0).unwrap();
363        assert_eq!(out.realize_f32(), vec![1.0, 2.0, 3.0, 4.0]);
364    }
365}