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;