Skip to main content

nuvec_core/
distributed.rs

1//! Distributed groups and collective operations, as MLX's `mx.distributed`.
2//!
3//! A [`Group`] is a set of processes with a rank and a size. Collectives (`all_sum`,
4//! `all_max`, `all_min`, `all_gather`, `sum_scatter`, `send`, `recv`) are lazy array
5//! operations over a group: they run when their result is evaluated, compose with `grad`,
6//! `vmap` and `compile` as MLX defines, and run on the CPU over the arrays' shared memory.
7//! On a GPU stream they run on the group's worker thread once the GPU work producing their
8//! inputs completes, and the GPU work reading their outputs is committed once they are done,
9//! so neither the evaluating thread nor the GPU waits for the network. Without a configured group, the default group
10//! has size 1 and every collective is the identity, so single-process code runs unchanged.
11//!
12//! The transport is a [`Communicator`]; `nuvec-distributed` provides MLX's TCP ring and
13//! JACCL backends and installs one as the default group.
14
15use std::panic::Location;
16use std::sync::{Arc, Mutex, PoisonError, RwLock};
17
18use crate::array::Data;
19use crate::buffer::Buffer;
20use crate::kernels::Operand;
21use crate::primitive::Primitive;
22use crate::{Array, DType, Error, IntoShape, Result};
23
24mod worker;
25
26/// The reduction of [`Communicator::all_reduce`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum AllReduce {
30    /// Element-wise sum (booleans: or).
31    Sum,
32    /// Element-wise maximum (booleans: or).
33    Max,
34    /// Element-wise minimum (booleans: and).
35    Min,
36}
37
38/// The transport of a [`Group`]: every rank of the group calls the same operations in the
39/// same order. Buffers are row-contiguous elements; a failure names the peer.
40pub trait Communicator: Send + Sync {
41    /// This process's rank, `0..size`.
42    fn rank(&self) -> usize;
43    /// The number of processes.
44    fn size(&self) -> usize;
45    /// Reduces `data` (elements of `dtype`) with every rank's `data`, in place; every rank
46    /// gets the same bits.
47    fn all_reduce(&self, op: AllReduce, dtype: DType, data: &mut [u8]) -> Result<()>;
48    /// Every rank's `input` (equal lengths), concatenated in rank order into `output`.
49    fn all_gather(&self, input: &[u8], output: &mut [u8]) -> Result<()>;
50    /// Sends `data` to rank `dst`.
51    fn send(&self, dst: usize, data: &[u8]) -> Result<()>;
52    /// Receives exactly `data.len()` bytes from rank `src`.
53    fn recv(&self, src: usize, data: &mut [u8]) -> Result<()>;
54    /// Rank `r`'s `output` is the element-wise sum (elements of `dtype`) of every rank's
55    /// `r`-th chunk of `input` (`input.len() == size · output.len()`), as MLX's
56    /// `sum_scatter`.
57    fn sum_scatter(&self, dtype: DType, input: &[u8], output: &mut [u8]) -> Result<()> {
58        let _ = (dtype, input, output);
59        Err(Error::InvalidArgument(
60            "distributed: this backend has no sum_scatter".into(),
61        ))
62    }
63    /// Whether every peer is still reachable, checked without a collective and without
64    /// blocking; an error names a lost peer. Backends that cannot tell report `Ok`.
65    fn health(&self) -> Result<()> {
66        Ok(())
67    }
68    /// A subgroup of the ranks with the same `color`, ordered by `key` (MLX's `split`).
69    fn split(&self, color: i32, key: i32) -> Result<Arc<dyn Communicator>> {
70        let _ = (color, key);
71        Err(Error::InvalidArgument(
72            "distributed: this backend does not split groups (as MLX's ring)".into(),
73        ))
74    }
75}
76
77/// A collective operation's kind, with its parameters ([`Primitive::Collective`]).
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
79#[non_exhaustive]
80pub enum CollectiveOp {
81    /// [`AllReduce`] over the group.
82    Reduce(AllReduce),
83    /// Every rank's array, concatenated along the first axis in rank order.
84    Gather,
85    /// The sum over the group of every rank's rows of this rank's slice of the first axis.
86    SumScatter,
87    /// The input sent to rank `dst`; the output is the input.
88    Send {
89        /// The receiving rank.
90        dst: usize,
91    },
92    /// An array of `shape` and `dtype` received from rank `src`.
93    Recv {
94        /// The sending rank.
95        src: usize,
96        /// The received shape.
97        shape: Vec<usize>,
98        /// The received dtype.
99        dtype: DType,
100    },
101}
102
103impl CollectiveOp {
104    /// The operation's name (MLX's).
105    pub fn name(&self) -> &'static str {
106        match self {
107            Self::Reduce(AllReduce::Sum) => "all_sum",
108            Self::Reduce(AllReduce::Max) => "all_max",
109            Self::Reduce(AllReduce::Min) => "all_min",
110            Self::Gather => "all_gather",
111            Self::SumScatter => "sum_scatter",
112            Self::Send { .. } => "send",
113            Self::Recv { .. } => "recv",
114        }
115    }
116}
117
118/// A group's registered transport and the lock that keeps its operations in program
119/// order (one collective on the wire at a time).
120struct Registered {
121    comm: Arc<dyn Communicator>,
122    order: Mutex<()>,
123    /// Runs the collectives of GPU streams in the background, in program order.
124    worker: Arc<worker::Worker>,
125}
126
127impl Drop for Registered {
128    fn drop(&mut self) {
129        self.worker.shut_down();
130    }
131}
132
133/// Groups by id (`id - 1`; `None` once closed); id 0 is the size-1 group.
134static GROUPS: Mutex<Vec<Option<Arc<Registered>>>> = Mutex::new(Vec::new());
135static DEFAULT: RwLock<Option<Group>> = RwLock::new(None);
136
137fn registered(id: usize) -> Result<Arc<Registered>> {
138    let groups = GROUPS.lock().unwrap_or_else(PoisonError::into_inner);
139    match id.checked_sub(1).and_then(|i| groups.get(i)) {
140        Some(Some(g)) => Ok(g.clone()),
141        Some(None) => Err(Error::Backend("distributed: the group was closed".into())),
142        None => Err(Error::InvalidArgument(format!(
143            "distributed: no group {id}"
144        ))),
145    }
146}
147
148/// A group of processes (MLX's `Group`): cheap to clone.
149#[derive(Clone)]
150pub struct Group {
151    id: usize,
152    rank: usize,
153    size: usize,
154}
155
156impl std::fmt::Debug for Group {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        write!(f, "Group(rank {} of {})", self.rank, self.size)
159    }
160}
161
162impl Group {
163    /// The group of this process alone: every collective is the identity.
164    pub fn single() -> Self {
165        Self {
166            id: 0,
167            rank: 0,
168            size: 1,
169        }
170    }
171
172    /// A group over `comm` (a backend's transport).
173    pub fn new(comm: Arc<dyn Communicator>) -> Self {
174        let (rank, size) = (comm.rank(), comm.size());
175        if size <= 1 {
176            return Self::single();
177        }
178        let mut groups = GROUPS.lock().unwrap_or_else(PoisonError::into_inner);
179        groups.push(Some(Arc::new(Registered {
180            comm,
181            order: Mutex::new(()),
182            worker: Arc::default(),
183        })));
184        Self {
185            id: groups.len(),
186            rank,
187            size,
188        }
189    }
190
191    /// This process's rank in the group.
192    pub fn rank(&self) -> usize {
193        self.rank
194    }
195
196    /// The number of processes in the group.
197    pub fn size(&self) -> usize {
198        self.size
199    }
200
201    /// Closes the group: its transport (connections, buffers) is released once operations
202    /// in progress finish, and every later operation on it fails with an error saying it was
203    /// closed. A group of size 1 is unaffected.
204    pub fn close(&self) {
205        if let Some(i) = self.id.checked_sub(1) {
206            let mut groups = GROUPS.lock().unwrap_or_else(PoisonError::into_inner);
207            if let Some(slot) = groups.get_mut(i) {
208                *slot = None;
209            }
210        }
211    }
212
213    /// Whether every peer is still reachable, checked without running a collective: an
214    /// error names a lost peer (and the group is then failed), or says the group was closed.
215    /// It waits for a collective in progress on another thread, never interleaving with it.
216    pub fn health(&self) -> Result<()> {
217        if self.id == 0 {
218            return Ok(());
219        }
220        let entry = registered(self.id)?;
221        let _order = entry.worker.idle_order(&entry.order);
222        entry.comm.health()
223    }
224
225    /// The ranks with the same `color` as a new group, ordered by `key` (then by rank), as
226    /// MLX's `Group.split`. Backends may not support it (MLX's ring does not).
227    pub fn split(&self, color: i32, key: i32) -> Result<Group> {
228        if self.size == 1 {
229            return Ok(self.clone());
230        }
231        Ok(Group::new(registered(self.id)?.comm.split(color, key)?))
232    }
233}
234
235/// Makes `group` the default group of the collectives (what `nuvec-distributed`'s `init`
236/// does).
237pub fn set_default_group(group: Group) {
238    *DEFAULT.write().unwrap_or_else(PoisonError::into_inner) = Some(group);
239}
240
241/// The default group: the one set by [`set_default_group`], else this process alone.
242pub fn default_group() -> Group {
243    DEFAULT
244        .read()
245        .unwrap_or_else(PoisonError::into_inner)
246        .clone()
247        .unwrap_or_else(Group::single)
248}
249
250fn group_or_default(group: Option<&Group>) -> Group {
251    group.cloned().unwrap_or_else(default_group)
252}
253
254/// The size of the group with this id (1 for id 0).
255pub(crate) fn group_size(id: usize) -> Result<usize> {
256    match id {
257        0 => Ok(1),
258        id => Ok(registered(id)?.comm.size()),
259    }
260}
261
262/// The rank of this process in the group with this id.
263pub(crate) fn group_rank(id: usize) -> Result<usize> {
264    match id {
265        0 => Ok(0),
266        id => Ok(registered(id)?.comm.rank()),
267    }
268}
269
270fn record(op: CollectiveOp, group: &Group, inputs: Vec<Array>) -> Result<Array> {
271    let mut out = Array::from_op(
272        Primitive::Collective {
273            op,
274            group: group.id,
275        },
276        inputs,
277    )?;
278    Ok(out.swap_remove(0))
279}
280
281fn reduce(x: &Array, op: AllReduce, group: Option<&Group>) -> Result<Array> {
282    let group = group_or_default(group);
283    if group.size == 1 {
284        return Ok(x.clone());
285    }
286    record(CollectiveOp::Reduce(op), &group, vec![x.clone()])
287}
288
289fn gather(x: &Array, group: Option<&Group>) -> Result<Array> {
290    let group = group_or_default(group);
291    if group.size == 1 {
292        return Ok(x.clone());
293    }
294    record(CollectiveOp::Gather, &group, vec![x.clone()])
295}
296
297fn scatter(x: &Array, group: Option<&Group>) -> Result<Array> {
298    let group = group_or_default(group);
299    if group.size == 1 {
300        return Ok(x.clone());
301    }
302    if x.dims().first().is_none_or(|d| d % group.size != 0) {
303        return Err(Error::InvalidArgument(format!(
304            "sum_scatter: shape {:?} is invalid for a group of size {}: the first axis must \
305             exist and be divisible by the group size",
306            x.dims(),
307            group.size
308        )));
309    }
310    record(CollectiveOp::SumScatter, &group, vec![x.clone()])
311}
312
313fn peer(op: &str, group: &Group, peer: usize) -> Result<()> {
314    if group.size == 1 || peer >= group.size || peer == group.rank {
315        return Err(Error::InvalidArgument(format!(
316            "{op}: rank {peer} is not another rank of a group of {}",
317            group.size
318        )));
319    }
320    Ok(())
321}
322
323fn send_op(x: &Array, dst: usize, group: Option<&Group>) -> Result<Array> {
324    let group = group_or_default(group);
325    peer("send", &group, dst)?;
326    record(CollectiveOp::Send { dst }, &group, vec![x.clone()])
327}
328
329fn recv_op(shape: Vec<usize>, dtype: DType, src: usize, group: Option<&Group>) -> Result<Array> {
330    let group = group_or_default(group);
331    peer("recv", &group, src)?;
332    record(CollectiveOp::Recv { src, shape, dtype }, &group, Vec::new())
333}
334
335/// The fixed size of the header a `send` puts before its data.
336const HEADER: usize = 512;
337
338/// The header a `send` puts before its data, so a mismatched `recv` is an error rather
339/// than reinterpreted bytes: a tag, the payload's length, the dtype's name length and name,
340/// the rank and the dimensions, as little-endian `u64`s, zero-padded to [`HEADER`] bytes. One
341/// fixed-size message, so a transfer costs two messages (header, payload).
342fn header(dtype: DType, shape: &[usize]) -> Result<Vec<u8>> {
343    let name = dtype.name().as_bytes();
344    let n: usize = shape.iter().product();
345    let mut h = Vec::with_capacity(HEADER);
346    h.extend_from_slice(b"nuvecsnd");
347    h.extend_from_slice(&((n * dtype.size()) as u64).to_le_bytes());
348    h.extend_from_slice(&(name.len() as u64).to_le_bytes());
349    h.extend_from_slice(name);
350    h.extend_from_slice(&(shape.len() as u64).to_le_bytes());
351    for &d in shape {
352        h.extend_from_slice(&(d as u64).to_le_bytes());
353    }
354    if h.len() > HEADER {
355        return Err(Error::InvalidArgument(format!(
356            "send: {} dimensions do not fit the array header",
357            shape.len()
358        )));
359    }
360    h.resize(HEADER, 0);
361    Ok(h)
362}
363
364/// The bytes of a collective's output (`operands`: its inputs).
365fn output_len(op: &CollectiveOp, size: usize, operands: &[Operand<'_>]) -> usize {
366    match op {
367        CollectiveOp::Reduce(_) | CollectiveOp::Send { .. } => {
368            operands[0].0.size * operands[0].0.dtype.size()
369        }
370        CollectiveOp::Gather => operands[0].0.size * operands[0].0.dtype.size() * size,
371        CollectiveOp::SumScatter => operands[0].0.size * operands[0].0.dtype.size() / size,
372        CollectiveOp::Recv { shape, dtype, .. } => shape.iter().product::<usize>() * dtype.size(),
373    }
374}
375
376/// Runs a collective over `comm` on row-contiguous bytes: `input` is the input of
377/// `dtype` and `shape` (`None` for `Reduce` and `Send`: `out` holds it already, and is
378/// reduced or sent in place), `out` the output ([`output_len`] bytes).
379fn eval_bytes(
380    op: &CollectiveOp,
381    comm: &dyn Communicator,
382    dtype: DType,
383    shape: &[usize],
384    input: Option<&[u8]>,
385    out: &mut [u8],
386) -> Result<()> {
387    let input_or_out = |out: &mut [u8]| {
388        if let Some(input) = input {
389            out.copy_from_slice(input);
390        }
391    };
392    match op {
393        CollectiveOp::Reduce(reduce) => {
394            input_or_out(out);
395            comm.all_reduce(*reduce, dtype, out)
396        }
397        CollectiveOp::Gather => comm.all_gather(input.expect("an input"), out),
398        CollectiveOp::SumScatter => comm.sum_scatter(dtype, input.expect("an input"), out),
399        CollectiveOp::Send { dst } => {
400            input_or_out(out);
401            comm.send(*dst, &header(dtype, shape)?)?;
402            comm.send(*dst, out)
403        }
404        CollectiveOp::Recv { src, shape, dtype } => {
405            let want = header(*dtype, shape)?;
406            let mut got = vec![0u8; HEADER];
407            comm.recv(*src, &mut got)?;
408            if &got[..8] != b"nuvecsnd" {
409                return Err(Error::InvalidArgument(format!(
410                    "recv: rank {src} sent no array header"
411                )));
412            }
413            if got != want {
414                // The payload is read and dropped, so the connection stays aligned.
415                let bytes = u64::from_le_bytes(got[8..16].try_into().expect("8 bytes")) as usize;
416                let mut sink = vec![0u8; bytes.min(1 << 20)];
417                let mut left = bytes;
418                while left > 0 {
419                    let n = left.min(sink.len());
420                    comm.recv(*src, &mut sink[..n])?;
421                    left -= n;
422                }
423                return Err(Error::InvalidArgument(format!(
424                    "recv: rank {src} sent another dtype or shape than {} {shape:?}",
425                    dtype.name()
426                )));
427            }
428            comm.recv(*src, out)
429        }
430    }
431}
432
433/// [`eval_bytes`] on operands of any layout.
434fn eval_into(
435    op: &CollectiveOp,
436    comm: &dyn Communicator,
437    operands: &[Operand<'_>],
438    out: &mut [u8],
439) -> Result<()> {
440    let Some(&(meta, data)) = operands.first() else {
441        return eval_bytes(op, comm, DType::U8, &[], None, out);
442    };
443    // A row-contiguous input is read where it is (MLX's `all_sum` copies it once, into the
444    // output); any other layout is gathered first.
445    let elem = meta.dtype.size();
446    let bytes = usize::try_from(data.offset).ok().and_then(|at| {
447        let at = at * elem;
448        let bytes = data.buffer.bytes().get(at..at + meta.size * elem);
449        bytes.filter(|_| meta.flags.row_contiguous)
450    });
451    if let Some(bytes) = bytes {
452        return eval_bytes(op, comm, meta.dtype, &meta.shape, Some(bytes), out);
453    }
454    let input = crate::kernels::contiguous(meta, data)?;
455    eval_bytes(op, comm, meta.dtype, &meta.shape, Some(input.bytes()), out)
456}
457
458/// Evaluates a collective on the CPU: `operands` are the inputs' layouts and data. Runs
459/// after every collective of the group already queued for its worker.
460pub(crate) fn eval_cpu(
461    op: &CollectiveOp,
462    group: usize,
463    operands: &[Operand<'_>],
464) -> Result<Vec<Data>> {
465    let entry = registered(group)?;
466    let mut out = Buffer::try_alloc(output_len(op, entry.comm.size(), operands))?;
467    let _order = entry.worker.idle_order(&entry.order);
468    eval_into(op, &*entry.comm, operands, out.bytes_mut())?;
469    Ok(vec![Data::new(out)])
470}
471
472/// What a GPU stream's collective waits for before it reads its input.
473#[cfg(all(target_os = "macos", not(miri)))]
474pub(crate) enum Ready {
475    /// The input is in place already (or there is none).
476    Now,
477    /// The command buffer that copies (or orders) the input signals this value of its event.
478    Signal(Arc<crate::gpu::BufferSignal>, u64),
479    /// The committed command buffer producing the input completes.
480    Pending(Option<nuvec_gpu::Pending>),
481}
482
483/// The input of a GPU stream's collective.
484#[cfg(all(target_os = "macos", not(miri)))]
485pub(crate) enum Input {
486    /// None (`Recv`), or already in the output (`Reduce`, `Send`: copied there on the GPU,
487    /// or the donated input itself).
488    InOutput,
489    /// A row-contiguous copy made on the GPU.
490    Staged(Data),
491    /// The operand as it is, read once [`Ready`].
492    Operand(Array, Data),
493}
494
495/// A GPU stream's collective, as queued for its group's worker.
496#[cfg(all(target_os = "macos", not(miri)))]
497pub(crate) struct GpuJob {
498    pub(crate) op: CollectiveOp,
499    pub(crate) dtype: DType,
500    pub(crate) shape: Vec<usize>,
501    pub(crate) ready: Ready,
502    pub(crate) input: Input,
503    pub(crate) out: Data,
504    pub(crate) status: crate::array::ChunkStatus,
505}
506
507/// Queues `job` for its group's worker. The worker waits until the input is ready, runs the
508/// collective into the output (even if the GPU work failed, so the peers stay paired),
509/// records a failure in the job's status, and then, whatever happened, raises the returned
510/// event to the returned value: GPU work that reads the output waits for it.
511#[cfg(all(target_os = "macos", not(miri)))]
512pub(crate) fn submit_gpu(
513    group: usize,
514    job: GpuJob,
515    device: &nuvec_gpu::Device,
516) -> Result<(Arc<dyn std::any::Any + Send + Sync>, u64)> {
517    let entry = registered(group)?;
518    entry.worker.event(device)?;
519    let worker = entry.worker.clone();
520    let value = worker.submit(move |value| {
521        let GpuJob {
522            op,
523            dtype,
524            shape,
525            ready,
526            input,
527            out,
528            status,
529        } = job;
530        let run = || {
531            let ready = match ready {
532                Ready::Now => Ok(()),
533                Ready::Signal(signal, v) => signal.wait(v),
534                Ready::Pending(p) => p.map_or(Ok(()), |p| {
535                    p.wait_result().map_err(|e| Error::Backend(e.to_string()))
536                }),
537            };
538            let _order = entry.order.lock().unwrap_or_else(PoisonError::into_inner);
539            let ran = write_unsynchronized(&out, |bytes| match &input {
540                Input::InOutput => eval_bytes(&op, &*entry.comm, dtype, &shape, None, bytes),
541                Input::Staged(staged) => read_unsynchronized(staged, |i| {
542                    eval_bytes(&op, &*entry.comm, dtype, &shape, Some(i), bytes)
543                }),
544                Input::Operand(a, d) => eval_into(&op, &*entry.comm, &[(a.out(), d)], bytes),
545            });
546            ready.and(ran)
547        };
548        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run))
549            .unwrap_or_else(|_| Err(Error::Backend("distributed: a collective panicked".into())));
550        if let Err(e) = result {
551            let _ = status.set(e);
552        }
553        // The GPU work waiting for this value is committed first: it then waits on the GPU
554        // only until the signal just below.
555        entry.worker.finish(value);
556        if let Some(event) = entry.worker.event.get() {
557            event.signal(value);
558        }
559        drop((input, out));
560    });
561    Ok((worker, value))
562}
563
564/// Runs `f` once the job of `value` of a worker that [`submit_gpu`] returned is done (its
565/// event raised): at once if it is, otherwise on the worker's thread.
566#[cfg(all(target_os = "macos", not(miri)))]
567pub(crate) fn after_gpu(
568    worker: &(dyn std::any::Any + Send + Sync),
569    value: u64,
570    f: impl FnOnce() + Send + 'static,
571) {
572    worker
573        .downcast_ref::<worker::Worker>()
574        .expect("returned by submit_gpu")
575        .after(value, f);
576}
577
578/// The event of a worker that [`submit_gpu`] returned.
579#[cfg(all(target_os = "macos", not(miri)))]
580pub(crate) fn worker_event(worker: &(dyn std::any::Any + Send + Sync)) -> &nuvec_gpu::Event {
581    worker
582        .downcast_ref::<worker::Worker>()
583        .and_then(|w| w.event.get())
584        .expect("created by submit_gpu")
585}
586
587/// Runs `f` on the bytes of `staged`, which GPU work wrote before the input signal and no
588/// GPU work writes again.
589#[cfg(all(target_os = "macos", not(miri)))]
590#[allow(unsafe_code)]
591fn read_unsynchronized(staged: &Data, f: impl FnOnce(&[u8]) -> Result<()>) -> Result<()> {
592    let len = staged.buffer.len();
593    let Some(metal) = staged.buffer.metal() else {
594        return f(staged.buffer.bytes());
595    };
596    let (ptr, available) = metal
597        .unsynchronized_ptr()
598        .map_err(|e| Error::Backend(e.to_string()))?;
599    assert!(len <= available, "the staging buffer holds its bytes");
600    // SAFETY: the GPU copy that wrote `staged` completed before the input signal the worker
601    // waited for, nothing writes it afterwards (it belongs to this collective), and `ptr`
602    // addresses `available >= len` bytes that `staged` keeps alive.
603    f(unsafe { std::slice::from_raw_parts(ptr.as_ptr(), len) })
604}
605
606/// Runs `f` on `out`'s bytes while GPU work that reads them may already be submitted.
607#[cfg(all(target_os = "macos", not(miri)))]
608#[allow(unsafe_code)]
609fn write_unsynchronized(out: &Data, f: impl FnOnce(&mut [u8]) -> Result<()>) -> Result<()> {
610    let len = out.buffer.len();
611    let Some(metal) = out.buffer.metal() else {
612        return Err(Error::Backend(
613            "distributed: a GPU collective's output is not on the GPU".into(),
614        ));
615    };
616    let (ptr, available) = metal
617        .unsynchronized_ptr()
618        .map_err(|e| Error::Backend(e.to_string()))?;
619    assert!(len <= available, "the output buffer holds its bytes");
620    // SAFETY: `out` was allocated for this collective alone and is written only here; the
621    // GPU work that reads it waits for the event `submit_gpu` raises after this write, and
622    // the CPU reads it only once that work has completed and published it. `ptr` addresses
623    // `available >= len` bytes of shared storage that `out` keeps alive.
624    let bytes = unsafe { std::slice::from_raw_parts_mut(ptr.as_ptr(), len) };
625    f(bytes)
626}
627
628/// The element-wise sum of `x` over the ranks of `group` (the default group if `None`), as
629/// MLX's `all_sum`: every rank gets the same bits.
630///
631/// ```
632/// use nuvec_core::{array, distributed};
633///
634/// // Without a configured group, the group is this process alone.
635/// let x = array![1.0, 2.0];
636/// assert_eq!(distributed::all_sum(&x, None).to_vec::<f32>().unwrap(), [1.0, 2.0]);
637/// ```
638#[track_caller]
639pub fn all_sum(x: &Array, group: Option<&Group>) -> Array {
640    Array::sticky("all_sum", Location::caller(), &[x], || {
641        reduce(x, AllReduce::Sum, group)
642    })
643}
644
645/// The element-wise maximum of `x` over the ranks of `group` (MLX's `all_max`).
646#[track_caller]
647pub fn all_max(x: &Array, group: Option<&Group>) -> Array {
648    Array::sticky("all_max", Location::caller(), &[x], || {
649        reduce(x, AllReduce::Max, group)
650    })
651}
652
653/// The element-wise minimum of `x` over the ranks of `group` (MLX's `all_min`).
654#[track_caller]
655pub fn all_min(x: &Array, group: Option<&Group>) -> Array {
656    Array::sticky("all_min", Location::caller(), &[x], || {
657        reduce(x, AllReduce::Min, group)
658    })
659}
660
661/// Every rank's `x` concatenated along the first axis in rank order (a scalar gathers to a
662/// vector), as MLX's `all_gather`.
663#[track_caller]
664pub fn all_gather(x: &Array, group: Option<&Group>) -> Array {
665    Array::sticky("all_gather", Location::caller(), &[x], || gather(x, group))
666}
667
668/// Rank `r` gets the sum over `group` of every rank's rows `r·k..(r+1)·k` of `x`, whose
669/// first axis is `size·k`, as MLX's `sum_scatter` (the reduce-scatter half of an
670/// all-reduce). A group of size 1 returns `x`.
671///
672/// ```
673/// use nuvec_core::{array, distributed};
674///
675/// // Without a configured group, the group is this process alone.
676/// let x = array![1.0, 2.0];
677/// assert_eq!(distributed::sum_scatter(&x, None).to_vec::<f32>().unwrap(), [1.0, 2.0]);
678/// ```
679#[track_caller]
680pub fn sum_scatter(x: &Array, group: Option<&Group>) -> Array {
681    Array::sticky("sum_scatter", Location::caller(), &[x], || {
682        scatter(x, group)
683    })
684}
685
686/// Sends `x` to rank `dst` of `group` when the result (which is `x`) is evaluated, as
687/// MLX's `send`. MLX's ring backend sends to its neighbours only.
688#[track_caller]
689pub fn send(x: &Array, dst: usize, group: Option<&Group>) -> Array {
690    Array::sticky("send", Location::caller(), &[x], || send_op(x, dst, group))
691}
692
693/// An array of `shape` and `dtype` received from rank `src` of `group`, as MLX's `recv`. A
694/// sender's other dtype or shape is an error when it is evaluated.
695#[track_caller]
696pub fn recv(shape: impl IntoShape, dtype: DType, src: usize, group: Option<&Group>) -> Array {
697    let spec = shape.shape_spec();
698    Array::constructor("recv", Location::caller(), dtype, &spec, |shape| {
699        recv_op(shape.to_vec(), dtype, src, group)
700    })
701}
702
703/// [`recv`] of `x`'s shape and dtype (MLX's `recv_like`).
704#[track_caller]
705pub fn recv_like(x: &Array, src: usize, group: Option<&Group>) -> Array {
706    let at = Location::caller();
707    Array::sticky("recv_like", at, &[x], || {
708        recv_op(x.dims().to_vec(), x.dtype(), src, group)
709    })
710}
711
712/// `x` unchanged, whose gradient is summed over `group` (MLX's `sum_gradients`): the input
713/// of a layer whose output is sharded across the group, so each rank's gradient covers
714/// every rank's slice of the output.
715#[track_caller]
716pub fn sum_gradients(x: &Array, group: Option<&Group>) -> Array {
717    let group = group_or_default(group);
718    if group.size == 1 {
719        return x.clone();
720    }
721    Array::sticky("sum_gradients", Location::caller(), &[x], || {
722        let f = crate::custom_function_flat(|xs| Ok(xs.to_vec())).with_vjp(
723            move |_primals, _outputs, cotangents| {
724                cotangents
725                    .iter()
726                    .map(|c| reduce(c, AllReduce::Sum, Some(&group)))
727                    .collect()
728            },
729        );
730        Ok(f.call(std::slice::from_ref(x))?.remove(0))
731    })
732}
733
734impl crate::tree::Static for Group {
735    fn hash_static(&self, mut h: &mut dyn std::hash::Hasher) {
736        std::hash::Hash::hash(&(self.id, self.rank, self.size), &mut h);
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use std::sync::Mutex;
743    use std::time::Duration;
744
745    use super::*;
746
747    /// Rank 0 of two whose peer holds the same data (so a sum doubles it); logs every
748    /// operation, taking `delay` per reduction, and fails the reduction tagged with
749    /// `fail_at` elements.
750    struct Fake {
751        log: Mutex<Vec<String>>,
752        fail_at: Option<usize>,
753        delay: Duration,
754    }
755
756    impl Communicator for Fake {
757        fn rank(&self) -> usize {
758            0
759        }
760        fn size(&self) -> usize {
761            2
762        }
763        fn all_reduce(&self, op: AllReduce, dtype: DType, data: &mut [u8]) -> Result<()> {
764            std::thread::sleep(self.delay);
765            let n = data.len() / dtype.size();
766            self.log.lock().unwrap().push(format!("reduce {n}"));
767            if self.fail_at == Some(n) {
768                return Err(Error::Backend("distributed: rank 1 left".into()));
769            }
770            if op == AllReduce::Sum && dtype == DType::F32 {
771                for v in bytemuck::cast_slice_mut::<u8, f32>(data) {
772                    *v *= 2.0;
773                }
774            }
775            Ok(())
776        }
777        fn all_gather(&self, input: &[u8], output: &mut [u8]) -> Result<()> {
778            output[..input.len()].copy_from_slice(input);
779            output[input.len()..].copy_from_slice(input);
780            Ok(())
781        }
782        fn send(&self, _: usize, _: &[u8]) -> Result<()> {
783            Ok(())
784        }
785        fn recv(&self, _: usize, data: &mut [u8]) -> Result<()> {
786            data.fill(0);
787            Ok(())
788        }
789    }
790
791    fn fake(fail_at: Option<usize>) -> (Group, Arc<Fake>) {
792        slow(fail_at, Duration::from_millis(2))
793    }
794
795    fn slow(fail_at: Option<usize>, delay: Duration) -> (Group, Arc<Fake>) {
796        let comm = Arc::new(Fake {
797            log: Mutex::new(Vec::new()),
798            fail_at,
799            delay,
800        });
801        (Group::new(comm.clone()), comm)
802    }
803
804    fn gpu() -> bool {
805        if !crate::gpu::available() {
806            return false;
807        }
808        crate::set_default_stream(crate::Stream::default_for(crate::Device::gpu(0)));
809        true
810    }
811
812    /// Collectives run in the order they were issued, whichever path runs them: GPU
813    /// stream (background) and CPU stream (inline, after the queued ones).
814    #[test]
815    fn collectives_run_in_program_order_across_streams() {
816        if !gpu() {
817            return;
818        }
819        let (g, comm) = fake(None);
820        let x = |n: usize| crate::ops::ones([n as i32], DType::F32);
821        let a = all_sum(&x(1).multiply(3.0), Some(&g));
822        let b = all_sum(&x(2).multiply(3.0), Some(&g));
823        crate::async_eval(&a).unwrap();
824        crate::async_eval(&b).unwrap();
825        crate::set_default_stream(crate::Stream::default_for(crate::Device::cpu()));
826        let c = all_sum(&x(3), Some(&g));
827        assert_eq!(c.to_vec::<f32>().unwrap(), [2.0; 3]);
828        assert_eq!(a.to_vec::<f32>().unwrap(), [6.0]);
829        assert_eq!(b.to_vec::<f32>().unwrap(), [6.0; 2]);
830        assert_eq!(
831            *comm.log.lock().unwrap(),
832            ["reduce 1", "reduce 2", "reduce 3"]
833        );
834    }
835
836    /// A result nobody reads still runs (the peers expect it), and later ones follow it.
837    #[test]
838    fn a_dropped_result_still_runs() {
839        if !gpu() {
840            return;
841        }
842        let (g, comm) = fake(None);
843        let a = all_sum(&crate::ops::ones([4], DType::F32).multiply(2.0), Some(&g));
844        crate::async_eval(&a).unwrap();
845        drop(a);
846        let b = all_sum(&crate::ops::ones([5], DType::F32).add(1.0), Some(&g));
847        assert_eq!(b.to_vec::<f32>().unwrap(), [4.0; 5]);
848        assert_eq!(*comm.log.lock().unwrap(), ["reduce 4", "reduce 5"]);
849    }
850
851    /// A failed collective fails its output and the GPU work after it with its own error,
852    /// and nothing hangs.
853    #[test]
854    fn a_failed_collective_fails_what_follows() {
855        if !gpu() {
856            return;
857        }
858        let (g, _) = fake(Some(7));
859        let y = all_sum(&crate::ops::ones([7], DType::F32).multiply(2.0), Some(&g)).add(1.0);
860        let err = y.eval().unwrap_err().to_string();
861        assert!(err.contains("rank 1 left"), "{err}");
862    }
863
864    /// A GPU failure before a collective fails its output (the collective itself still runs).
865    #[cfg(all(target_os = "macos", not(miri)))]
866    #[test]
867    fn a_gpu_failure_before_a_collective_fails_it() {
868        if !gpu() {
869            return;
870        }
871        let (g, comm) = fake(None);
872        let x = crate::ops::ones([6], DType::F32).multiply(2.0);
873        crate::gpu::fault::fail_next_commit();
874        let y = all_sum(&x, Some(&g)).add(1.0);
875        assert!(y.eval().is_err());
876        assert_eq!(*comm.log.lock().unwrap(), ["reduce 6"]);
877    }
878
879    /// Decode's shape commits one command buffer per collective besides the dispatch
880    /// policy's (MLX's, which commits a command buffer that made new buffers after 50
881    /// dispatches).
882    #[cfg(all(target_os = "macos", not(miri)))]
883    #[test]
884    fn collectives_commit_one_command_buffer_each() {
885        if !gpu() {
886            return;
887        }
888        let (g, _) = fake(None);
889        let x = crate::ops::ones([1, 1024], DType::F32);
890        x.eval().unwrap();
891        let chain = |x: &Array, collectives: bool| {
892            let mut y = x.clone();
893            for _ in 0..56 {
894                y = y.multiply(0.5);
895                if collectives {
896                    y = all_sum(&y, Some(&g));
897                }
898            }
899            y
900        };
901        // Warm both shapes (pipelines, the worker), then count each.
902        chain(&x, true).eval().unwrap();
903        chain(&x, false).eval().unwrap();
904        let count = |collectives: bool| {
905            let before = crate::gpu::COMMITS.get();
906            let y = chain(&x, collectives);
907            y.eval().unwrap();
908            (crate::gpu::COMMITS.get() - before, y)
909        };
910        let (with, y) = count(true);
911        let (without, _) = count(false);
912        assert!(
913            with <= without + 56,
914            "{with} command buffers for 56 collectives, {without} without them"
915        );
916        assert_eq!(y.to_vec::<f32>().unwrap(), vec![1.0; 1024]);
917    }
918
919    /// A collective slower than Metal's GPU timeout (a peer still in a long prefill, or
920    /// starting late) neither fails the GPU work after it nor the GPU work after that.
921    #[cfg(all(target_os = "macos", not(miri)))]
922    #[test]
923    fn a_collective_slower_than_the_gpu_timeout() {
924        if !gpu() {
925            return;
926        }
927        let (g, _) = slow(None, Duration::from_secs(6));
928        let x = crate::ops::ones([4], DType::F32).multiply(2.0);
929        let y = all_sum(&x, Some(&g)).add(1.0).multiply(2.0);
930        assert_eq!(y.to_vec::<f32>().unwrap(), [10.0; 4]);
931        let z = crate::ops::ones([4], DType::F32).add(2.0);
932        assert_eq!(z.to_vec::<f32>().unwrap(), [3.0; 4]);
933    }
934
935    /// A CPU-stream collective after a GPU-stream one in the same evaluation runs after it
936    /// (the open command buffer is committed first, so nothing waits on unsubmitted work).
937    #[test]
938    fn a_cpu_collective_after_a_gpu_one_in_one_evaluation() {
939        if !gpu() {
940            return;
941        }
942        let (g, comm) = fake(None);
943        let a = all_sum(&crate::ops::ones([3], DType::F32).multiply(3.0), Some(&g));
944        crate::set_default_stream(crate::Stream::default_for(crate::Device::cpu()));
945        let c = all_sum(&a.add(1.0), Some(&g));
946        assert_eq!(c.to_vec::<f32>().unwrap(), [14.0; 3]);
947        assert_eq!(*comm.log.lock().unwrap(), ["reduce 3", "reduce 3"]);
948    }
949
950    /// An all-reduce writes its input's buffer only when nothing else holds the input (MLX's
951    /// donation): a held input keeps its values; an intermediate, or an evaluated array
952    /// whose last handle is gone, is reduced in place.
953    #[test]
954    fn only_inputs_nothing_else_holds_are_reduced_in_place() {
955        if !gpu() {
956            return;
957        }
958        let (g, _comm) = fake(None);
959        let held = crate::ops::ones([5], DType::F32).multiply(3.0);
960        held.eval().unwrap();
961        let sum = all_sum(&held, Some(&g));
962        assert_eq!(sum.to_vec::<f32>().unwrap(), [6.0; 5]);
963        assert_eq!(held.to_vec::<f32>().unwrap(), [3.0; 5]);
964
965        let intermediate = all_sum(&crate::ops::ones([5], DType::F32).multiply(2.0), Some(&g));
966        assert_eq!(intermediate.to_vec::<f32>().unwrap(), [4.0; 5]);
967
968        let done = crate::ops::ones([5], DType::F32).multiply(5.0);
969        done.eval().unwrap();
970        let sum = all_sum(&done, Some(&g));
971        drop(done);
972        assert_eq!(sum.to_vec::<f32>().unwrap(), [10.0; 5]);
973
974        // Read by another consumer in the same evaluation: not donated.
975        let x = crate::ops::ones([5], DType::F32).multiply(4.0);
976        let (sum, other) = (all_sum(&x, Some(&g)), x.add(1.0));
977        drop(x);
978        crate::eval(&[sum.clone(), other.clone()]).unwrap();
979        assert_eq!(sum.to_vec::<f32>().unwrap(), [8.0; 5]);
980        assert_eq!(other.to_vec::<f32>().unwrap(), [5.0; 5]);
981    }
982
983    /// A failed command buffer fails the outputs of every collective after it.
984    #[cfg(all(target_os = "macos", not(miri)))]
985    #[test]
986    fn a_failed_buffer_fails_the_collectives_after_it() {
987        if !gpu() {
988            return;
989        }
990        let (g, comm) = fake(None);
991        let mut y = crate::ops::ones([8], DType::F32);
992        for _ in 0..3 {
993            y = all_sum(&y.multiply(2.0), Some(&g));
994        }
995        crate::gpu::fault::fail_next_commit();
996        assert!(y.eval().is_err());
997        assert_eq!(comm.log.lock().unwrap().len(), 3);
998    }
999}