spark_comm/nccl_backend.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! NCCL-based communication backend for expert parallelism.
4//!
5//! Uses TCP bootstrap: rank 0 generates a unique ID and sends it to
6//! all other ranks via a TCP listener. Then all ranks call
7//! `ncclCommInitRank` with the shared ID.
8//!
9//! Optimizations for 2-rank EP with small messages (4 KB):
10//! - Pre-registers buffers with NCCL (`ncclCommRegister`) to cache IB memory registration
11//! - Uses paired `ncclSend`/`ncclRecv` + local BF16 add instead of `ncclAllReduce`
12//!
13//! Health monitoring and recovery:
14//! - Checks `ncclCommGetAsyncError` after each collective
15//! - Detects broadcast timeouts (>30s) via stream sync + wall-clock check
16//! - Aborts dead communicators via `ncclCommAbort` and reconnects
17//!
18//! ## Safety contract for the `unsafe { ... }` calls below
19//!
20//! All unsafe blocks in this file wrap a single FFI call into either
21//! NCCL (`nccl*`) or the CUDA Driver API (`cu*`). The invariants are
22//! uniform:
23//!
24//! - **NCCL handles**: `NcclComm` instances are constructed via
25//! `nccl::comm_init_rank` after a successful TCP bootstrap and are
26//! `Drop`-cleaned via `ncclCommDestroy`. They are never aliased
27//! across threads without a `Mutex` guarding the comm.
28//! - **CUDA buffers** passed to NCCL come from a prior `cuMemAlloc_v2`
29//! on the same device that owns the comm; size in bytes matches the
30//! allocation.
31//! - **Streams** referenced via `u64` are owned by the caller and
32//! outlive the in-flight collective.
33//! - **`extern "C"` ABI**: matches the NCCL 2.20+ headers and the
34//! `cuMemAlloc_v2`/`cuLaunchKernel`/etc. shapes declared just below.
35//!
36//! Per-site `// SAFETY:` comments are omitted because the contract is
37//! identical for every call. Deviations get a per-site comment.
38
39use anyhow::{Context, Result};
40use parking_lot::Mutex;
41use std::ffi::c_void;
42use std::io::{Read, Write};
43use std::net::{TcpListener, TcpStream};
44use std::ptr;
45use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
46
47use crate::nccl::{self, NcclComm, NcclDataType, NcclResult, NcclUniqueId};
48
49// CUDA driver API for recv buffer allocation and kernel launch.
50unsafe extern "C" {
51 fn cuMemAlloc_v2(dptr: *mut u64, bytesize: usize) -> i32;
52 fn cuMemFree_v2(dptr: u64) -> i32;
53 fn cuLaunchKernel(
54 f: u64,
55 gridDimX: u32,
56 gridDimY: u32,
57 gridDimZ: u32,
58 blockDimX: u32,
59 blockDimY: u32,
60 blockDimZ: u32,
61 sharedMemBytes: u32,
62 hStream: u64,
63 kernelParams: *mut *mut c_void,
64 extra: *mut *mut c_void,
65 ) -> i32;
66}
67
68mod recv_buffer;
69use recv_buffer::ensure_payload_fits;
70pub use recv_buffer::{ALL_REDUCE_DTYPE_BYTES, required_recv_bytes};
71
72/// Timeout threshold for a single synchronous collective operation.
73/// If a broadcast + stream sync takes longer than this, mark the communicator unhealthy.
74pub(super) const COLLECTIVE_TIMEOUT_SECS: u64 = 30;
75
76/// NCCL communication backend for multi-GPU / multi-node EP.
77pub struct NcclBackend {
78 /// Protected for abort-and-reconnect. All NCCL calls acquire this lock.
79 comm: Mutex<NcclComm>,
80 rank: usize,
81 world_size: usize,
82 /// Dedicated stream for NCCL collectives (separate from compute).
83 comm_stream: u64,
84 /// Event: signals "MoE compute done" on the compute stream.
85 compute_done_event: u64,
86 /// Event: signals "all_reduce done" on the comm stream.
87 comm_done_event: u64,
88 /// Legacy compute stream for synchronous barrier/broadcast.
89 legacy_stream: u64,
90 /// Persistent receive buffer for 2-rank send/recv all-reduce.
91 recv_buffer: u64,
92 /// Allocated capacity of `recv_buffer`, in bytes (0 when `world_size != 2`).
93 ///
94 /// Every 2-rank all-reduce payload is checked against this before any NCCL
95 /// call or kernel launch. Derived from the configured maximum transfer at
96 /// construction — see [`required_recv_bytes`].
97 recv_capacity: usize,
98 /// Handles from ncclCommRegister (deregistered in Drop).
99 registered_handles: Mutex<Vec<*mut c_void>>,
100 /// Kernel handle for bf16_add_inplace (set via set_add_kernel).
101 add_kernel: AtomicU64,
102 /// Whether the communicator is in a degraded/unhealthy state.
103 unhealthy: AtomicBool,
104 /// Number of successful reconnections (for diagnostics).
105 reconnect_count: AtomicU64,
106 /// Bootstrap parameters stored for reconnection.
107 master_addr: String,
108 master_port: u16,
109}
110
111// SAFETY: `NcclComm` is an opaque NCCL handle. NCCL guarantees the handle
112// is thread-safe for non-overlapping operations on the same communicator;
113// Atlas serializes access through the inner `Mutex<NcclComm>` (one in-flight
114// collective per rank at a time) and the host-side completions are bound
115// to the user-supplied CUDA stream. The raw pointer never escapes the
116// backend, so any aliasing is bounded by the Mutex guard's lifetime.
117unsafe impl Send for NcclBackend {}
118unsafe impl Sync for NcclBackend {}
119
120impl NcclBackend {
121 /// Initialize NCCL with TCP bootstrap.
122 ///
123 /// Rank 0 listens on `master_addr:master_port`, generates a unique ID,
124 /// and sends it to all connecting ranks. All ranks then call
125 /// `ncclCommInitRank` which internally synchronizes.
126 /// `recv_capacity` is the largest all-reduce payload this backend will ever
127 /// be asked to carry, in bytes — compute it with [`required_recv_bytes`]
128 /// from the serve configuration. It is only consulted when
129 /// `world_size == 2` (the send/recv fast path); other world sizes reduce
130 /// in-place via `ncclAllReduce` and allocate no receive buffer.
131 pub fn new(
132 rank: usize,
133 world_size: usize,
134 master_addr: &str,
135 master_port: u16,
136 stream: u64,
137 recv_capacity: usize,
138 ) -> Result<Self> {
139 Self::log_nccl_env_vars();
140
141 let unique_id = if rank == 0 {
142 let id = Self::generate_unique_id()?;
143 Self::distribute_id(&id, master_addr, master_port, world_size)?;
144 id
145 } else {
146 Self::receive_id(master_addr, master_port)?
147 };
148
149 let mut comm: NcclComm = ptr::null_mut();
150 let result =
151 unsafe { nccl::ncclCommInitRank(&mut comm, world_size as i32, unique_id, rank as i32) };
152 nccl::check_nccl(result, "ncclCommInitRank")?;
153
154 let comm_stream = nccl::create_stream()?;
155 let compute_done_event = nccl::create_event()?;
156 let comm_done_event = nccl::create_event()?;
157
158 // Allocate persistent recv buffer for 2-rank send/recv all-reduce,
159 // sized to the caller's configured maximum transfer.
160 let mut recv_buffer: u64 = 0;
161 if world_size == 2 {
162 if recv_capacity == 0 {
163 anyhow::bail!(
164 "world_size == 2 requires a non-zero receive-buffer capacity; \
165 compute it with required_recv_bytes(max_batch_tokens, hidden_size, \
166 ALL_REDUCE_DTYPE_BYTES)"
167 );
168 }
169 let status = unsafe { cuMemAlloc_v2(&mut recv_buffer, recv_capacity) };
170 if status != 0 {
171 anyhow::bail!(
172 "cuMemAlloc_v2 for recv_buffer ({recv_capacity} bytes) failed: status {status}"
173 );
174 }
175 // Register recv buffer with NCCL for IB memory caching.
176 let mut handle: *mut c_void = ptr::null_mut();
177 let result = unsafe {
178 nccl::ncclCommRegister(comm, recv_buffer as *mut c_void, recv_capacity, &mut handle)
179 };
180 if result != NcclResult::Success {
181 tracing::warn!("ncclCommRegister for recv_buffer failed (non-fatal): {result:?}");
182 } else {
183 tracing::info!(
184 "Registered recv_buffer ({} KB) with NCCL",
185 recv_capacity / 1024
186 );
187 }
188 }
189
190 Ok(Self {
191 comm: Mutex::new(comm),
192 rank,
193 world_size,
194 comm_stream,
195 compute_done_event,
196 comm_done_event,
197 legacy_stream: stream,
198 recv_buffer,
199 recv_capacity: if world_size == 2 { recv_capacity } else { 0 },
200 registered_handles: Mutex::new(Vec::new()),
201 add_kernel: AtomicU64::new(0),
202 unhealthy: AtomicBool::new(false),
203 reconnect_count: AtomicU64::new(0),
204 master_addr: master_addr.to_owned(),
205 master_port,
206 })
207 }
208
209 /// Log NCCL-related environment variables at init time for diagnostics.
210 fn log_nccl_env_vars() {
211 let vars = [
212 "NCCL_TIMEOUT",
213 "NCCL_WATCHDOG_TIMEOUT",
214 "NCCL_IB_TIMEOUT",
215 "NCCL_IB_RETRY_CNT",
216 "NCCL_SOCKET_IFNAME",
217 "NCCL_DEBUG",
218 ];
219 for var in &vars {
220 match std::env::var(var) {
221 Ok(val) => tracing::info!("NCCL env: {var}={val}"),
222 Err(_) => tracing::debug!("NCCL env: {var} not set"),
223 }
224 }
225 }
226
227 /// Check the NCCL communicator for asynchronous errors.
228 ///
229 /// Returns `true` if the communicator is healthy (no async errors).
230 /// If an async error is detected, sets the unhealthy flag and returns `false`.
231 fn check_async_error(&self, comm: NcclComm) -> bool {
232 let mut async_err = NcclResult::Success;
233 let result = unsafe { nccl::ncclCommGetAsyncError(comm, &mut async_err) };
234 if result != NcclResult::Success {
235 tracing::error!(
236 "ncclCommGetAsyncError call itself failed: {result:?} \
237 — marking unhealthy"
238 );
239 self.unhealthy.store(true, Ordering::Release);
240 return false;
241 }
242 if async_err != NcclResult::Success {
243 tracing::error!("NCCL async error detected: {async_err:?} — marking unhealthy");
244 self.unhealthy.store(true, Ordering::Release);
245 return false;
246 }
247 true
248 }
249
250 /// Abort the current communicator and re-initialize via TCP bootstrap.
251 ///
252 /// Both ranks must call this concurrently (the reconnect protocol
253 /// mirrors the initial bootstrap). After reconnect, the recv_buffer
254 /// is re-registered with the new communicator.
255 fn reconnect_inner(&self) -> Result<()> {
256 let mut comm_guard = self.comm.lock();
257
258 // Double-check: another thread may have already reconnected.
259 if !self.unhealthy.load(Ordering::Acquire) {
260 tracing::info!("NCCL communicator already recovered by another thread");
261 return Ok(());
262 }
263
264 let old_comm = *comm_guard;
265 let attempt = self.reconnect_count.load(Ordering::Relaxed) + 1;
266 tracing::warn!(
267 "NCCL reconnect: aborting old communicator \
268 (rank={}, reconnect #{})",
269 self.rank,
270 attempt,
271 );
272
273 // Abort the dead communicator (non-blocking cleanup).
274 if !old_comm.is_null() {
275 let result = unsafe { nccl::ncclCommAbort(old_comm) };
276 if result != NcclResult::Success {
277 tracing::warn!("ncclCommAbort returned {result:?} (proceeding anyway)");
278 }
279 }
280
281 // Re-bootstrap: rank 0 distributes a new unique ID.
282 // Use master_port + 1 to avoid bind conflicts with a lingering listener.
283 let reconnect_port = self.master_port.wrapping_add(1);
284 let unique_id = if self.rank == 0 {
285 let id = Self::generate_unique_id()?;
286 Self::distribute_id(&id, &self.master_addr, reconnect_port, self.world_size)?;
287 id
288 } else {
289 Self::receive_id(&self.master_addr, reconnect_port)?
290 };
291
292 let mut new_comm: NcclComm = ptr::null_mut();
293 let result = unsafe {
294 nccl::ncclCommInitRank(
295 &mut new_comm,
296 self.world_size as i32,
297 unique_id,
298 self.rank as i32,
299 )
300 };
301 nccl::check_nccl(result, "ncclCommInitRank (reconnect)")?;
302
303 // Re-register recv buffer with the new communicator.
304 if self.world_size == 2 && self.recv_buffer != 0 {
305 let mut handle: *mut c_void = ptr::null_mut();
306 let result = unsafe {
307 nccl::ncclCommRegister(
308 new_comm,
309 self.recv_buffer as *mut c_void,
310 self.recv_capacity,
311 &mut handle,
312 )
313 };
314 if result != NcclResult::Success {
315 tracing::warn!(
316 "ncclCommRegister for recv_buffer after reconnect \
317 failed: {result:?}"
318 );
319 } else {
320 tracing::info!("Re-registered recv_buffer after reconnect");
321 }
322 }
323
324 // Clear stale registered buffer handles — the old handles are
325 // invalid after abort. We cannot re-register external buffers
326 // because we only stored handles, not (ptr, size) pairs.
327 let mut handles = self.registered_handles.lock();
328 if !handles.is_empty() {
329 tracing::warn!(
330 "Clearing {} stale registered buffer handles after reconnect",
331 handles.len()
332 );
333 handles.clear();
334 }
335 drop(handles);
336
337 *comm_guard = new_comm;
338 self.unhealthy.store(false, Ordering::Release);
339 self.reconnect_count.fetch_add(1, Ordering::Relaxed);
340
341 tracing::info!(
342 "NCCL reconnect successful (rank={}, total reconnects={})",
343 self.rank,
344 self.reconnect_count.load(Ordering::Relaxed),
345 );
346
347 Ok(())
348 }
349
350 /// 2-rank all-reduce using send/recv + local BF16 add.
351 ///
352 /// For world_size == 2:
353 /// 1. Bounds-check the payload against the receive buffer
354 /// 2. Group send+recv (one RDMA write each direction)
355 /// 3. Local BF16 add: `ptr[i] += recv_buffer[i]`
356 ///
357 /// # Capacity invariant
358 ///
359 /// `bytes <= self.recv_capacity`, always. `ncclRecv` writes `bytes` into a
360 /// buffer of exactly `recv_capacity` bytes, so a payload larger than the
361 /// allocation would write past it — device-heap corruption, or
362 /// `CUDA_ERROR_ILLEGAL_ADDRESS` if you are lucky enough for it to be fatal.
363 ///
364 /// The capacity is derived from the configured maximum transfer at
365 /// construction, so in a correctly-configured serve this check never fires.
366 /// It is retained as defense in depth: it is the only thing standing between
367 /// a future caller with a larger payload and a silent out-of-bounds write,
368 /// and the cost of being wrong here is not a bad number, it is corrupted
369 /// memory that looks plausible.
370 fn all_reduce_2rank(&self, ptr: u64, bytes: usize, stream: u64) -> Result<()> {
371 // Before ncclSend, before ncclRecv, before the add-kernel launch.
372 ensure_payload_fits(bytes, self.recv_capacity, self.rank, self.world_size)?;
373
374 // Nothing to reduce. Return before the kernel launch: `blocks` would be
375 // 0, which cuLaunchKernel rejects with CUDA_ERROR_INVALID_VALUE. Both
376 // ranks reduce the same shape, so this is symmetric and cannot desync
377 // the NCCL group.
378 if bytes == 0 {
379 return Ok(());
380 }
381
382 let count = bytes / ALL_REDUCE_DTYPE_BYTES; // BF16 element count
383 let partner = (1 - self.rank) as i32;
384 let comm = *self.comm.lock();
385
386 // Paired send/recv in a group (single NCCL launch).
387 let result = unsafe { nccl::ncclGroupStart() };
388 nccl::check_nccl(result, "ncclGroupStart")?;
389
390 let result = unsafe {
391 nccl::ncclSend(
392 ptr as *const c_void,
393 count,
394 NcclDataType::Bfloat16,
395 partner,
396 comm,
397 stream,
398 )
399 };
400 nccl::check_nccl(result, "ncclSend")?;
401
402 let result = unsafe {
403 nccl::ncclRecv(
404 self.recv_buffer as *mut c_void,
405 count,
406 NcclDataType::Bfloat16,
407 partner,
408 comm,
409 stream,
410 )
411 };
412 nccl::check_nccl(result, "ncclRecv")?;
413
414 let result = unsafe { nccl::ncclGroupEnd() };
415 nccl::check_nccl(result, "ncclGroupEnd")?;
416
417 // Check for async errors after the group operation.
418 self.check_async_error(comm);
419
420 // Local BF16 addition: ptr[i] += recv_buffer[i]
421 let kernel = self.add_kernel.load(Ordering::Relaxed);
422 if kernel != 0 {
423 let threads: u32 = 256;
424 let blocks: u32 = (count as u32).div_ceil(threads);
425 let mut p_dst = ptr;
426 let mut p_src = self.recv_buffer;
427 let mut p_n = count as i32;
428 let mut params: [*mut c_void; 3] = [
429 &mut p_dst as *mut u64 as *mut c_void,
430 &mut p_src as *mut u64 as *mut c_void,
431 &mut p_n as *mut i32 as *mut c_void,
432 ];
433 let status = unsafe {
434 cuLaunchKernel(
435 kernel,
436 blocks,
437 1,
438 1,
439 threads,
440 1,
441 1,
442 0,
443 stream,
444 params.as_mut_ptr(),
445 ptr::null_mut(),
446 )
447 };
448 if status != 0 {
449 anyhow::bail!("cuLaunchKernel (bf16_add_inplace) failed: status {status}");
450 }
451 } else {
452 anyhow::bail!("bf16_add_inplace kernel not set — call set_add_kernel() first");
453 }
454
455 Ok(())
456 }
457
458 fn generate_unique_id() -> Result<NcclUniqueId> {
459 let mut id = NcclUniqueId {
460 internal: [0u8; 128],
461 };
462 let result = unsafe { nccl::ncclGetUniqueId(&mut id) };
463 nccl::check_nccl(result, "ncclGetUniqueId")?;
464 Ok(id)
465 }
466
467 /// Rank 0: listen, accept (world_size - 1) connections, send the unique ID.
468 fn distribute_id(id: &NcclUniqueId, addr: &str, port: u16, world_size: usize) -> Result<()> {
469 let bind_addr = format!("0.0.0.0:{port}");
470 let listener = TcpListener::bind(&bind_addr)
471 .with_context(|| format!("Rank 0: failed to bind {bind_addr}"))?;
472 tracing::info!(
473 "Rank 0: waiting for {} worker(s) on {}",
474 world_size - 1,
475 bind_addr
476 );
477
478 for i in 0..(world_size - 1) {
479 let (mut stream, peer_addr) = listener.accept().context("Rank 0: accept failed")?;
480 stream
481 .write_all(&id.internal)
482 .context("Rank 0: failed to send unique ID")?;
483 tracing::info!("Rank 0: sent unique ID to worker {} ({})", i + 1, peer_addr);
484 }
485 let _ = addr; // master_addr not used on rank 0 (we bind 0.0.0.0)
486 Ok(())
487 }
488
489 /// Non-zero rank: connect to rank 0, receive the unique ID.
490 fn receive_id(addr: &str, port: u16) -> Result<NcclUniqueId> {
491 let target = format!("{addr}:{port}");
492 tracing::info!("Rank N: connecting to master at {target}");
493
494 // Retry with backoff — rank 0 may not be listening yet.
495 // Large models (>100B) take 3-5 minutes to load weights before opening
496 // the NCCL master port. The previous 30s ceiling consistently timed
497 // out on 122B-ep2 / nemotron-super-120B-ep2 sweep rounds. 10 minutes
498 // gives any model time to load shards on a Spark; if rank 0 actually
499 // crashed the worker still surfaces the failure — just later.
500 const MAX_ATTEMPTS: u32 = 600; // 10 minutes at 1s each
501 let mut stream = None;
502 for attempt in 0..MAX_ATTEMPTS {
503 match TcpStream::connect(&target) {
504 Ok(s) => {
505 stream = Some(s);
506 break;
507 }
508 Err(e) => {
509 if attempt + 1 < MAX_ATTEMPTS {
510 if attempt < 30 || attempt.is_multiple_of(30) {
511 tracing::info!(
512 "Connect attempt {}/{MAX_ATTEMPTS}: {e}, retrying in 1s (rank 0 may be loading weights)",
513 attempt + 1
514 );
515 }
516 std::thread::sleep(std::time::Duration::from_secs(1));
517 } else {
518 return Err(e).with_context(|| {
519 format!(
520 "Failed to connect to master at {target} \
521 after {MAX_ATTEMPTS} attempts (~{} minutes)",
522 MAX_ATTEMPTS / 60
523 )
524 });
525 }
526 }
527 }
528 }
529
530 let mut id = NcclUniqueId {
531 internal: [0u8; 128],
532 };
533 stream
534 .unwrap()
535 .read_exact(&mut id.internal)
536 .context("Failed to receive unique ID from rank 0")?;
537 tracing::info!("Received NCCL unique ID from master");
538 Ok(id)
539 }
540}
541
542impl Drop for NcclBackend {
543 fn drop(&mut self) {
544 let comm = *self.comm.lock();
545 // Deregister all NCCL-registered buffers.
546 let mut handles = self.registered_handles.lock();
547 for handle in handles.drain(..) {
548 unsafe { nccl::ncclCommDeregister(comm, handle) };
549 }
550 drop(handles);
551 // Free recv buffer.
552 if self.recv_buffer != 0 {
553 unsafe { cuMemFree_v2(self.recv_buffer) };
554 }
555 nccl::destroy_event(self.compute_done_event);
556 nccl::destroy_event(self.comm_done_event);
557 nccl::destroy_stream(self.comm_stream);
558 if !comm.is_null() {
559 unsafe { nccl::ncclCommDestroy(comm) };
560 }
561 }
562}
563
564mod comm_impl;