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