fuel_parallel/comm.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Communication abstraction for collective operations — **lazy-only**.
3//!
4//! Defines the [`Communicator`] trait that backends (NCCL, Gloo, etc.) implement
5//! to provide collective primitives. Code in [`tensor_parallel`](crate::tensor_parallel)
6//! and [`pipeline_parallel`](crate::pipeline_parallel) is generic over this trait.
7//!
8//! # Which shape do I want?
9//!
10//! This crate carries **two** collective surfaces, and they are not competitors:
11//!
12//! | | [`Communicator`] | [`DeviceGroup`](crate::device_group::DeviceGroup) |
13//! |---|---|---|
14//! | Model | SPMD — one process per rank | one process, N devices |
15//! | You pass | *your* shard | *every* shard |
16//! | Transport | external (NCCL/Gloo/MPI) | in-graph `Op::Copy` |
17//! | Works today | only at `world_size == 1` | yes |
18//!
19//! A single process driving four GPUs holds all four shards at once and has no
20//! peer to rendezvous with, so the per-rank signature does not fit it — use
21//! `DeviceGroup`. `Communicator` is the seam an out-of-process transport plugs
22//! into, and Fuel deliberately does not implement one: *which* process is rank 3,
23//! and how ranks meet, is consumer policy (see `docs/architecture/15-consumer-contract.md`).
24//!
25//! # Lazy-only
26//!
27//! These signatures take [`Tensor`]. The eager `fuel::Tensor` surface is
28//! being retired, and a collective written against it could not reduce lazy
29//! shards — which is the likeliest reason this crate never acquired a consumer.
30//!
31//! # Example
32//!
33//! ```rust
34//! use fuel_parallel::comm::{Communicator, ReduceOp, CommInfo};
35//!
36//! // A mock communicator for testing (passes data through unchanged).
37//! let info = CommInfo { rank: 0, world_size: 2 };
38//! assert_eq!(info.is_root(), true);
39//! assert_eq!(info.world_size, 2);
40//! ```
41
42use fuel::Result;
43use fuel::lazy::Tensor;
44
45/// Reduce operation type.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum ReduceOp {
48 /// Element-wise sum.
49 Sum,
50 /// Element-wise product.
51 Product,
52 /// Element-wise minimum.
53 Min,
54 /// Element-wise maximum.
55 Max,
56}
57
58/// Basic communicator metadata.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub struct CommInfo {
61 /// This process's rank (0-indexed).
62 pub rank: usize,
63 /// Total number of participating processes.
64 pub world_size: usize,
65}
66
67impl CommInfo {
68 /// Whether this is rank 0.
69 pub fn is_root(&self) -> bool {
70 self.rank == 0
71 }
72}
73
74/// Abstraction over collective communication backends.
75///
76/// Implementations wrap NCCL, Gloo, MPI, or mock backends. All operations
77/// are synchronous — they block until the collective is complete.
78///
79/// The trait is object-safe so it can be stored as `Box<dyn Communicator>`.
80///
81/// **Every shard handed to an implementation must belong to the same graph as
82/// the value it is combined with.** `Tensor` ops are graph-affine: two
83/// tensors can be combined iff their `graph_id`s match. An out-of-process
84/// implementation that receives bytes over the wire must therefore materialize
85/// them onto the *caller's* graph (`const_f32_like`, `from_f32_on`), not mint a
86/// fresh one.
87pub trait Communicator: Send {
88 /// Communicator info (rank, world size).
89 fn info(&self) -> CommInfo;
90
91 /// All-reduce: compute element-wise reduction across all ranks,
92 /// distributing the result to every rank.
93 fn all_reduce(&self, tensor: &Tensor, op: ReduceOp) -> Result<Tensor>;
94
95 /// All-gather: concatenate tensors from all ranks along `dim`.
96 fn all_gather(&self, tensor: &Tensor, dim: usize) -> Result<Tensor>;
97
98 /// Reduce-scatter: reduce across ranks then scatter equal chunks to each rank.
99 fn reduce_scatter(&self, tensor: &Tensor, op: ReduceOp, dim: usize) -> Result<Tensor>;
100
101 /// Broadcast tensor from `root` to all ranks.
102 fn broadcast(&self, tensor: &Tensor, root: usize) -> Result<Tensor>;
103
104 /// Barrier: block until all ranks reach this point.
105 fn barrier(&self) -> Result<()>;
106}
107
108/// A single-process "communicator" that passes tensors through unchanged.
109///
110/// Useful for testing parallel code on a single device.
111///
112/// **This reduces nothing, and that is correct.** With `world_size == 1` there
113/// is no peer to combine with, so the identity *is* the reduction. It is not a
114/// working multi-device collective and must not be read as one — for that, use
115/// [`DeviceGroup`](crate::device_group::DeviceGroup), which holds every shard
116/// and folds them in-graph.
117pub struct IdentityComm;
118
119impl Communicator for IdentityComm {
120 fn info(&self) -> CommInfo {
121 CommInfo {
122 rank: 0,
123 world_size: 1,
124 }
125 }
126
127 fn all_reduce(&self, tensor: &Tensor, _op: ReduceOp) -> Result<Tensor> {
128 Ok(tensor.clone())
129 }
130
131 fn all_gather(&self, tensor: &Tensor, _dim: usize) -> Result<Tensor> {
132 Ok(tensor.clone())
133 }
134
135 fn reduce_scatter(&self, tensor: &Tensor, _op: ReduceOp, _dim: usize) -> Result<Tensor> {
136 Ok(tensor.clone())
137 }
138
139 fn broadcast(&self, tensor: &Tensor, _root: usize) -> Result<Tensor> {
140 Ok(tensor.clone())
141 }
142
143 fn barrier(&self) -> Result<()> {
144 Ok(())
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use fuel::{Device, Shape};
152
153 /// A 1-D f32 lazy tensor on CPU, on a fresh graph.
154 fn t(data: &[f32]) -> Tensor {
155 Tensor::from_f32(
156 data.to_vec(),
157 Shape::from_dims(&[data.len()]),
158 &Device::cpu(),
159 )
160 .unwrap()
161 }
162
163 #[test]
164 fn identity_comm_info() {
165 let comm = IdentityComm;
166 let info = comm.info();
167 assert_eq!(info.rank, 0);
168 assert_eq!(info.world_size, 1);
169 assert!(info.is_root());
170 }
171
172 #[test]
173 fn identity_all_reduce() {
174 let comm = IdentityComm;
175 let result = comm
176 .all_reduce(&t(&[1.0, 2.0, 3.0]), ReduceOp::Sum)
177 .unwrap();
178 assert_eq!(result.realize_f32(), vec![1.0, 2.0, 3.0]);
179 }
180
181 #[test]
182 fn identity_all_gather() {
183 let comm = IdentityComm;
184 let result = comm.all_gather(&t(&[1.0, 2.0]), 0).unwrap();
185 assert_eq!(result.realize_f32(), vec![1.0, 2.0]);
186 }
187
188 // `Communicator` is the SPMD shape: each rank calls with its own tensor.
189 // A single process driving N devices holds ALL shards at once, so it wants
190 // a slice-shaped interface instead — see [`crate::device_group`].
191 // `IdentityComm` stays the correct degenerate impl here.
192
193 #[test]
194 fn identity_comm_reduces_nothing_and_that_is_correct() {
195 // Characterization guard. IdentityComm is world_size 1 returning its
196 // input — right for a single rank, and NOT a working collective. This
197 // test exists so the distinction stays visible if someone reads
198 // `all_reduce` and assumes it reduces.
199 let comm = IdentityComm;
200 let out = comm.all_reduce(&t(&[1.0, 2.0]), ReduceOp::Sum).unwrap();
201 assert_eq!(comm.info().world_size, 1);
202 assert_eq!(out.realize_f32(), vec![1.0, 2.0]);
203 }
204
205 #[test]
206 fn identity_preserves_graph_affinity() {
207 // The ported signature must not silently re-root a shard onto a new
208 // graph: a returned tensor that no longer shares the caller's graph
209 // could not be combined with anything, and the failure would surface
210 // far from here as an operand-graph assert.
211 let comm = IdentityComm;
212 let input = t(&[1.0, 2.0]);
213 let out = comm.all_reduce(&input, ReduceOp::Sum).unwrap();
214 assert_eq!(out.graph_id(), input.graph_id());
215 // ...and the proof that matters: it still composes.
216 assert_eq!(out.add(&input).unwrap().realize_f32(), vec![2.0, 4.0]);
217 }
218
219 #[test]
220 fn identity_broadcast() {
221 let comm = IdentityComm;
222 let result = comm.broadcast(&t(&[5.0]), 0).unwrap();
223 assert_eq!(result.realize_f32(), vec![5.0]);
224 }
225
226 #[test]
227 fn identity_reduce_scatter() {
228 let comm = IdentityComm;
229 let result = comm
230 .reduce_scatter(&t(&[1.0, 2.0, 3.0]), ReduceOp::Sum, 0)
231 .unwrap();
232 assert_eq!(result.realize_f32(), vec![1.0, 2.0, 3.0]);
233 }
234
235 #[test]
236 fn identity_barrier() {
237 let comm = IdentityComm;
238 assert!(comm.barrier().is_ok());
239 }
240
241 #[test]
242 fn comm_info_non_root() {
243 let info = CommInfo {
244 rank: 3,
245 world_size: 4,
246 };
247 assert!(!info.is_root());
248 }
249
250 #[test]
251 fn reduce_op_variants() {
252 // Ensure all variants exist and are distinct
253 let ops = [
254 ReduceOp::Sum,
255 ReduceOp::Product,
256 ReduceOp::Min,
257 ReduceOp::Max,
258 ];
259 for (i, a) in ops.iter().enumerate() {
260 for (j, b) in ops.iter().enumerate() {
261 assert_eq!(i == j, a == b);
262 }
263 }
264 }
265}