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