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;