torchrun-style process-per-GPU: launch_processes spawns one worker process per
GPU (re-exec current_exe with XTRAIN_{RANK,WORLD,LOCAL_RANK,NCCL_ID} env),
mints the ncclUniqueId once in the launcher and hex-injects it via env (no
shared FS/TCP, race-free). worker_env/run_worker read the env, bind the device
(own CUDA context), DdpContext::init + build_model + train_rank reused from T8
UNCHANGED. hex_encode/decode_unique_id are host-testable pure fns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
6.1 KiB
Rust
159 lines
6.1 KiB
Rust
//! Distributed data-parallel (DDP) primitives for xtrain (Phase T8).
|
|
//!
|
|
//! Launch model: **one OS thread per GPU** (same as xserv-distributed). Each
|
|
//! rank thread binds its device, builds its own model (xtrain's `Var` graph is
|
|
//! `Rc`-based and not `Send`, so it must be constructed thread-locally — only the
|
|
//! `UniqueId` and scalar config cross the thread boundary), processes a disjoint
|
|
//! shard of the global batch, then AllReduces every parameter's `.grad()` device
|
|
//! buffer in place, averages by world size, and runs its own `GpuAdamW.step`.
|
|
//! Identical init + identical optimizer state across ranks keeps the parameters
|
|
//! consistent without ever re-syncing the weights.
|
|
//!
|
|
//! NCCL is issued on the legacy null stream — every xtrain kernel launches on the
|
|
//! null stream (`std::ptr::null_mut()`), so the AllReduce stays correctly ordered
|
|
//! after the producing backward kernels and before the consuming optimizer step,
|
|
//! with no extra synchronization.
|
|
|
|
#![cfg(not(no_cuda))]
|
|
|
|
pub mod ddp;
|
|
pub mod ffi;
|
|
pub mod proc;
|
|
|
|
pub use ddp::{DdpConfig, DdpResult, build_model, launch, train_rank};
|
|
pub use proc::{
|
|
ModelOpts, WorkerEnv, build_worker_model, hex_decode_unique_id, hex_encode_unique_id,
|
|
launch_processes, run_worker, worker_env,
|
|
};
|
|
|
|
use std::ffi::c_void;
|
|
|
|
use ffi::{NcclComm, NcclUniqueId};
|
|
use xtrain_autodiff::tape::Var;
|
|
use xtrain_cuda::device;
|
|
|
|
pub use ffi::NcclUniqueId as UniqueId;
|
|
|
|
/// Generate a unique id on one rank (rank 0) and share the raw bytes to every
|
|
/// other rank out-of-band — across threads it is just a `Copy` struct moved into
|
|
/// each rank closure; across processes it would be written to a file/env.
|
|
pub fn get_unique_id() -> NcclUniqueId {
|
|
let mut id = NcclUniqueId::default();
|
|
ffi::check(unsafe { ffi::ncclGetUniqueId(&mut id) }, "ncclGetUniqueId");
|
|
id
|
|
}
|
|
|
|
/// Per-rank data-parallel context: the NCCL communicator plus this rank's
|
|
/// identity. AllReduce is in-place on the null stream.
|
|
pub struct DdpContext {
|
|
pub rank: usize,
|
|
pub world: usize,
|
|
pub device: u32,
|
|
comm: NcclComm,
|
|
}
|
|
|
|
// The communicator is owned by exactly one rank thread.
|
|
unsafe impl Send for DdpContext {}
|
|
|
|
impl DdpContext {
|
|
/// Initialize this rank. Must run on the thread that will own this rank's GPU
|
|
/// work; binds the thread to `device` first. All ranks call this concurrently
|
|
/// with the same `id` and `world` — the group wrapper lets the concurrent
|
|
/// inits rendezvous without deadlock.
|
|
pub fn init(rank: usize, world: usize, id: NcclUniqueId, device: u32) -> Self {
|
|
device::set_device(device).expect("set_device");
|
|
let mut comm: NcclComm = std::ptr::null_mut();
|
|
ffi::check(unsafe { ffi::ncclGroupStart() }, "ncclGroupStart(init)");
|
|
ffi::check(
|
|
unsafe { ffi::ncclCommInitRank(&mut comm, world as i32, id, rank as i32) },
|
|
"ncclCommInitRank",
|
|
);
|
|
ffi::check(unsafe { ffi::ncclGroupEnd() }, "ncclGroupEnd(init)");
|
|
Self {
|
|
rank,
|
|
world,
|
|
device,
|
|
comm,
|
|
}
|
|
}
|
|
|
|
/// In-place AllReduce(sum) over `count` F32 elements at a raw device pointer,
|
|
/// issued on the null stream (so it orders with this rank's kernels). The
|
|
/// reduction is asynchronous; a later sync (the caller's, or the next null-
|
|
/// stream kernel) completes it.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must point to at least `count` valid F32 device elements on this
|
|
/// rank's device. The reduction is in-place (send == recv).
|
|
pub fn all_reduce_sum_f32_ptr(&self, ptr: *mut c_void, count: usize) {
|
|
if self.world == 1 {
|
|
return; // nothing to reduce
|
|
}
|
|
ffi::check(
|
|
unsafe {
|
|
ffi::ncclAllReduce(
|
|
ptr as *const c_void,
|
|
ptr,
|
|
count,
|
|
ffi::NCCL_FLOAT32,
|
|
ffi::NCCL_SUM,
|
|
self.comm,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
"ncclAllReduce",
|
|
);
|
|
}
|
|
|
|
/// AllReduce every parameter's `.grad()` across ranks and divide by `world`,
|
|
/// the one collective DDP needs per step.
|
|
///
|
|
/// Each rank ran forward+backward on its own shard of `b` sequences, so
|
|
/// `.grad()` holds the SUM over that shard (the tape's fan-out rule). After
|
|
/// `AllReduce(sum)` every rank holds `Σ_global` (the sum over all `world·b`
|
|
/// sequences); dividing by `world` leaves `Σ_global / world`. The DDP train
|
|
/// loop's clip pass then applies the remaining `1/b` (`pre_scale = 1/b_local`),
|
|
/// giving `Σ_global / (world·b) = Σ_global / B_global` — bit-for-bit the same
|
|
/// mean gradient the single-GPU loop computes from a batch of `B_global`.
|
|
/// Params without a grad are skipped.
|
|
///
|
|
/// A single-process group barrier is unnecessary: the all-reduces serialize
|
|
/// on the comm, and the in-place scale runs on the same null stream after.
|
|
pub fn all_reduce_average_grads(&self, params: &[Var]) {
|
|
if self.world == 1 {
|
|
return;
|
|
}
|
|
// 1. Sum every grad across ranks (in place, on the null stream).
|
|
for p in params {
|
|
if let Some(g) = p.grad() {
|
|
let n = g.numel();
|
|
self.all_reduce_sum_f32_ptr(g.data_ptr() as *mut c_void, n);
|
|
}
|
|
}
|
|
// 2. Average: scale each summed grad by 1/world (null-stream kernel,
|
|
// ordered after the AllReduce that produced it).
|
|
let inv_world = 1.0 / self.world as f32;
|
|
for p in params {
|
|
if let Some(g) = p.grad() {
|
|
unsafe {
|
|
xtrain_cuda::ffi::launch_scale_inplace_f32(
|
|
g.data_ptr() as *mut f32,
|
|
inv_world,
|
|
g.numel() as i32,
|
|
std::ptr::null_mut(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
device::synchronize().expect("grad all-reduce sync failed");
|
|
}
|
|
}
|
|
|
|
impl Drop for DdpContext {
|
|
fn drop(&mut self) {
|
|
if !self.comm.is_null() {
|
|
unsafe { ffi::ncclCommDestroy(self.comm) };
|
|
}
|
|
}
|
|
}
|