spark_comm/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3#![deny(warnings)]
4#![deny(clippy::all)]
5
6//! Communication backend abstraction (SBIO IORouter for collective ops).
7//!
8//! All distributed communication flows through [`CommBackend`]. Business
9//! logic never calls NCCL or MPI directly.
10//!
11//! - [`SingleGpuBackend`] — all ops are no-ops (single GPU).
12//! - `NcclBackend` — real multi-GPU via NCCL (expert parallelism; available
13//!   with the `nccl` feature).
14
15use anyhow::Result;
16
17// NCCL FFI + the multi-GPU `NcclBackend` are gated on the `nccl`
18// feature because they `#[link(name = "nccl")]`. `nccl` is separate
19// from `cuda` so SCALE/AMD (gfx1151) builds can use the CUDA compute
20// backend without an NCCL library. On metal builds (single Apple
21// Silicon device) only `SingleGpuBackend` below is needed.
22#[cfg(feature = "nccl")]
23pub mod nccl;
24#[cfg(feature = "nccl")]
25pub mod nccl_backend;
26#[cfg(feature = "nccl")]
27pub use nccl_backend::NcclBackend;
28
29/// Communication backend trait for distributed operations.
30///
31/// All collective operations take raw device pointer + byte count.
32/// The pointer type is u64 (matching CUDA CUdeviceptr) to avoid
33/// coupling this crate to spark-runtime's DevicePtr.
34pub trait CommBackend: Send + Sync {
35    /// All-reduce: sum across all ranks, result on all ranks.
36    fn all_reduce(&self, ptr: u64, bytes: usize) -> Result<()>;
37
38    /// All-gather: each rank contributes a chunk, all ranks get full buffer.
39    fn all_gather(&self, send_ptr: u64, recv_ptr: u64, bytes: usize) -> Result<()>;
40
41    /// Reduce-scatter: reduce + scatter (inverse of all-gather).
42    fn reduce_scatter(&self, send_ptr: u64, recv_ptr: u64, bytes: usize) -> Result<()>;
43
44    /// Broadcast from root rank to all ranks.
45    fn broadcast(&self, ptr: u64, bytes: usize, root: usize) -> Result<()>;
46
47    /// Barrier: block until all ranks reach this point.
48    fn barrier(&self) -> Result<()>;
49
50    /// Async all-reduce using GPU-side event synchronization.
51    ///
52    /// Replaces `gpu.synchronize(stream) + all_reduce(ptr, bytes)`.
53    /// Uses a dedicated comm stream + CUDA events so the CPU never blocks.
54    /// `compute_stream` is where MoE kernels ran and where residual_add will run.
55    fn all_reduce_async(&self, ptr: u64, bytes: usize, compute_stream: u64) -> Result<()> {
56        let _ = compute_stream;
57        self.all_reduce(ptr, bytes)
58    }
59
60    /// Pre-register a GPU buffer with the communication backend.
61    ///
62    /// For NCCL over IB/RoCE, this caches the IB memory registration
63    /// (`ibv_reg_mr`), avoiding per-call overhead in all_reduce.
64    /// Returns an opaque handle for deregistration.
65    fn register_buffer(&self, _ptr: u64, _bytes: usize) -> Result<u64> {
66        Ok(0)
67    }
68
69    /// Deregister a previously registered buffer.
70    fn deregister_buffer(&self, _handle: u64) -> Result<()> {
71        Ok(())
72    }
73
74    /// Allocate a GPU buffer in NCCL's symmetric-memory window
75    /// (NCCL ≥ 2.28 / `ncclMemAlloc`). Returns the device pointer as `u64`.
76    ///
77    /// Symmetric-memory allocations are the substrate for:
78    ///   1. Copy-engine offload of NVLink collectives (frees SMs for compute).
79    ///   2. Device-side communication API (kernels invoke collectives in-kernel),
80    ///      which TokenWeave-style fused AR+RMSNorm+Residual builds on.
81    ///
82    /// On Atlas's 2-rank Spark over RoCE, the copy-engine offload itself does
83    /// not apply (RoCE is not NVLink), but the symmetric windows are still
84    /// required for future device-API fusions and to reduce per-call setup.
85    /// Returns an error if the linked NCCL is < 2.28; backends that don't
86    /// support symmetric memory return `Err` and callers must fall back.
87    fn symmetric_alloc(&self, _bytes: usize) -> Result<u64> {
88        anyhow::bail!("symmetric_alloc not supported by this CommBackend");
89    }
90
91    /// Free a buffer previously returned by `symmetric_alloc`.
92    fn symmetric_free(&self, _ptr: u64) -> Result<()> {
93        anyhow::bail!("symmetric_free not supported by this CommBackend");
94    }
95
96    /// Provide a kernel handle for the BF16 in-place addition kernel.
97    ///
98    /// Used by the 2-rank send/recv all-reduce path. The kernel is loaded
99    /// by the model layer (which has access to AtlasRegistry) and passed
100    /// to the comm backend at init time.
101    fn set_add_kernel(&self, _handle: u64) {
102        // Default: no-op (single GPU or backends that don't need it)
103    }
104
105    /// Send tokens to a specific rank (for EP token dispatch).
106    ///
107    /// Sends `bytes` from `ptr` on this rank to `dest_rank`.
108    /// Must be paired with a matching `recv_from` on the destination rank.
109    /// `stream` is the CUDA stream on which the operation is enqueued.
110    fn send_to(&self, ptr: u64, bytes: usize, dest_rank: usize, stream: u64) -> Result<()>;
111
112    /// Receive tokens from a specific rank (for EP token combine).
113    ///
114    /// Receives `bytes` into `ptr` on this rank from `src_rank`.
115    /// Must be paired with a matching `send_to` on the source rank.
116    /// `stream` is the CUDA stream on which the operation is enqueued.
117    fn recv_from(&self, ptr: u64, bytes: usize, src_rank: usize, stream: u64) -> Result<()>;
118
119    /// Begin a group of point-to-point operations (send_to/recv_from).
120    ///
121    /// All send_to/recv_from calls between group_start and group_end are
122    /// batched into a single NCCL launch for efficiency.
123    fn group_start(&self) -> Result<()> {
124        Ok(())
125    }
126
127    /// End a group of point-to-point operations.
128    fn group_end(&self) -> Result<()> {
129        Ok(())
130    }
131
132    /// Check if the communicator is healthy (no async errors, no timeouts).
133    ///
134    /// Returns `true` if the communicator is operational. Implementations
135    /// may actively probe the underlying transport (e.g., `ncclCommGetAsyncError`).
136    fn is_healthy(&self) -> bool {
137        true
138    }
139
140    /// Attempt to recover a degraded communicator.
141    ///
142    /// For NCCL, this aborts the dead communicator and re-initializes
143    /// via TCP bootstrap. Both ranks must call this concurrently.
144    /// Returns `Ok(())` on successful recovery, `Err` if recovery failed.
145    fn attempt_reconnect(&self) -> Result<()> {
146        Ok(())
147    }
148
149    /// This rank's index (0-based).
150    fn rank(&self) -> usize;
151
152    /// Total number of ranks.
153    fn world_size(&self) -> usize;
154}
155
156/// Single-GPU backend: all collective ops are no-ops.
157///
158/// Used in Phase 1 where the entire model fits on one GPU.
159pub struct SingleGpuBackend;
160
161impl CommBackend for SingleGpuBackend {
162    fn all_reduce(&self, _ptr: u64, _bytes: usize) -> Result<()> {
163        Ok(())
164    }
165
166    fn all_gather(&self, _send_ptr: u64, _recv_ptr: u64, _bytes: usize) -> Result<()> {
167        Ok(())
168    }
169
170    fn reduce_scatter(&self, _send_ptr: u64, _recv_ptr: u64, _bytes: usize) -> Result<()> {
171        Ok(())
172    }
173
174    fn broadcast(&self, _ptr: u64, _bytes: usize, _root: usize) -> Result<()> {
175        Ok(())
176    }
177
178    fn barrier(&self) -> Result<()> {
179        Ok(())
180    }
181
182    fn send_to(&self, _ptr: u64, _bytes: usize, _dest_rank: usize, _stream: u64) -> Result<()> {
183        Ok(())
184    }
185
186    fn recv_from(&self, _ptr: u64, _bytes: usize, _src_rank: usize, _stream: u64) -> Result<()> {
187        Ok(())
188    }
189
190    fn rank(&self) -> usize {
191        0
192    }
193
194    fn world_size(&self) -> usize {
195        1
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_single_gpu_noop() {
205        let comm = SingleGpuBackend;
206        assert_eq!(comm.rank(), 0);
207        assert_eq!(comm.world_size(), 1);
208        comm.all_reduce(0x1000, 1024).unwrap();
209        comm.all_reduce_async(0x1000, 1024, 0x3000).unwrap();
210        comm.all_gather(0x1000, 0x2000, 512).unwrap();
211        comm.reduce_scatter(0x1000, 0x2000, 512).unwrap();
212        comm.broadcast(0x1000, 256, 0).unwrap();
213        comm.barrier().unwrap();
214        comm.send_to(0x1000, 256, 0, 0).unwrap();
215        comm.recv_from(0x2000, 256, 0, 0).unwrap();
216        comm.group_start().unwrap();
217        comm.group_end().unwrap();
218        let registration = comm.register_buffer(0x1000, 1024).unwrap();
219        assert_eq!(registration, 0, "single-GPU registration is a no-op handle");
220        comm.deregister_buffer(registration).unwrap();
221        comm.set_add_kernel(0x4000);
222        assert!(comm.is_healthy());
223        comm.attempt_reconnect().unwrap();
224    }
225
226    #[test]
227    fn test_single_gpu_symmetric_alloc_unsupported() {
228        // SingleGpuBackend doesn't override symmetric_alloc/free — it must
229        // fall back to the trait default which returns an error. This is
230        // the contract callers depend on for fallback paths.
231        let comm = SingleGpuBackend;
232        assert!(comm.symmetric_alloc(1024).is_err());
233        assert!(comm.symmetric_free(0x1000).is_err());
234    }
235}