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}