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}