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}