Skip to main content

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}