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 waits for them on the GPU, so
9//! the evaluating thread never waits there. 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        if let Some(event) = entry.worker.event.get() {
554            event.signal(value);
555        }
556        drop((input, out));
557    });
558    Ok((worker, value))
559}
560
561/// The event of a worker that [`submit_gpu`] returned.
562#[cfg(all(target_os = "macos", not(miri)))]
563pub(crate) fn worker_event(worker: &(dyn std::any::Any + Send + Sync)) -> &nuvec_gpu::Event {
564    worker
565        .downcast_ref::<worker::Worker>()
566        .and_then(|w| w.event.get())
567        .expect("created by submit_gpu")
568}
569
570/// Runs `f` on the bytes of `staged`, which GPU work wrote before the input signal and no
571/// GPU work writes again.
572#[cfg(all(target_os = "macos", not(miri)))]
573#[allow(unsafe_code)]
574fn read_unsynchronized(staged: &Data, f: impl FnOnce(&[u8]) -> Result<()>) -> Result<()> {
575    let len = staged.buffer.len();
576    let Some(metal) = staged.buffer.metal() else {
577        return f(staged.buffer.bytes());
578    };
579    let (ptr, available) = metal
580        .unsynchronized_ptr()
581        .map_err(|e| Error::Backend(e.to_string()))?;
582    assert!(len <= available, "the staging buffer holds its bytes");
583    // SAFETY: the GPU copy that wrote `staged` completed before the input signal the worker
584    // waited for, nothing writes it afterwards (it belongs to this collective), and `ptr`
585    // addresses `available >= len` bytes that `staged` keeps alive.
586    f(unsafe { std::slice::from_raw_parts(ptr.as_ptr(), len) })
587}
588
589/// Runs `f` on `out`'s bytes while GPU work that reads them may already be submitted.
590#[cfg(all(target_os = "macos", not(miri)))]
591#[allow(unsafe_code)]
592fn write_unsynchronized(out: &Data, f: impl FnOnce(&mut [u8]) -> Result<()>) -> Result<()> {
593    let len = out.buffer.len();
594    let Some(metal) = out.buffer.metal() else {
595        return Err(Error::Backend(
596            "distributed: a GPU collective's output is not on the GPU".into(),
597        ));
598    };
599    let (ptr, available) = metal
600        .unsynchronized_ptr()
601        .map_err(|e| Error::Backend(e.to_string()))?;
602    assert!(len <= available, "the output buffer holds its bytes");
603    // SAFETY: `out` was allocated for this collective alone and is written only here; the
604    // GPU work that reads it waits for the event `submit_gpu` raises after this write, and
605    // the CPU reads it only once that work has completed and published it. `ptr` addresses
606    // `available >= len` bytes of shared storage that `out` keeps alive.
607    let bytes = unsafe { std::slice::from_raw_parts_mut(ptr.as_ptr(), len) };
608    f(bytes)
609}
610
611/// The element-wise sum of `x` over the ranks of `group` (the default group if `None`), as
612/// MLX's `all_sum`: every rank gets the same bits.
613///
614/// ```
615/// use nuvec_core::{array, distributed};
616///
617/// // Without a configured group, the group is this process alone.
618/// let x = array![1.0, 2.0];
619/// assert_eq!(distributed::all_sum(&x, None).to_vec::<f32>().unwrap(), [1.0, 2.0]);
620/// ```
621#[track_caller]
622pub fn all_sum(x: &Array, group: Option<&Group>) -> Array {
623    Array::sticky("all_sum", Location::caller(), &[x], || {
624        reduce(x, AllReduce::Sum, group)
625    })
626}
627
628/// The element-wise maximum of `x` over the ranks of `group` (MLX's `all_max`).
629#[track_caller]
630pub fn all_max(x: &Array, group: Option<&Group>) -> Array {
631    Array::sticky("all_max", Location::caller(), &[x], || {
632        reduce(x, AllReduce::Max, group)
633    })
634}
635
636/// The element-wise minimum of `x` over the ranks of `group` (MLX's `all_min`).
637#[track_caller]
638pub fn all_min(x: &Array, group: Option<&Group>) -> Array {
639    Array::sticky("all_min", Location::caller(), &[x], || {
640        reduce(x, AllReduce::Min, group)
641    })
642}
643
644/// Every rank's `x` concatenated along the first axis in rank order (a scalar gathers to a
645/// vector), as MLX's `all_gather`.
646#[track_caller]
647pub fn all_gather(x: &Array, group: Option<&Group>) -> Array {
648    Array::sticky("all_gather", Location::caller(), &[x], || gather(x, group))
649}
650
651/// Rank `r` gets the sum over `group` of every rank's rows `r·k..(r+1)·k` of `x`, whose
652/// first axis is `size·k`, as MLX's `sum_scatter` (the reduce-scatter half of an
653/// all-reduce). A group of size 1 returns `x`.
654///
655/// ```
656/// use nuvec_core::{array, distributed};
657///
658/// // Without a configured group, the group is this process alone.
659/// let x = array![1.0, 2.0];
660/// assert_eq!(distributed::sum_scatter(&x, None).to_vec::<f32>().unwrap(), [1.0, 2.0]);
661/// ```
662#[track_caller]
663pub fn sum_scatter(x: &Array, group: Option<&Group>) -> Array {
664    Array::sticky("sum_scatter", Location::caller(), &[x], || {
665        scatter(x, group)
666    })
667}
668
669/// Sends `x` to rank `dst` of `group` when the result (which is `x`) is evaluated, as
670/// MLX's `send`. MLX's ring backend sends to its neighbours only.
671#[track_caller]
672pub fn send(x: &Array, dst: usize, group: Option<&Group>) -> Array {
673    Array::sticky("send", Location::caller(), &[x], || send_op(x, dst, group))
674}
675
676/// An array of `shape` and `dtype` received from rank `src` of `group`, as MLX's `recv`. A
677/// sender's other dtype or shape is an error when it is evaluated.
678#[track_caller]
679pub fn recv(shape: impl IntoShape, dtype: DType, src: usize, group: Option<&Group>) -> Array {
680    let spec = shape.shape_spec();
681    Array::constructor("recv", Location::caller(), dtype, &spec, |shape| {
682        recv_op(shape.to_vec(), dtype, src, group)
683    })
684}
685
686/// [`recv`] of `x`'s shape and dtype (MLX's `recv_like`).
687#[track_caller]
688pub fn recv_like(x: &Array, src: usize, group: Option<&Group>) -> Array {
689    let at = Location::caller();
690    Array::sticky("recv_like", at, &[x], || {
691        recv_op(x.dims().to_vec(), x.dtype(), src, group)
692    })
693}
694
695/// `x` unchanged, whose gradient is summed over `group` (MLX's `sum_gradients`): the input
696/// of a layer whose output is sharded across the group, so each rank's gradient covers
697/// every rank's slice of the output.
698#[track_caller]
699pub fn sum_gradients(x: &Array, group: Option<&Group>) -> Array {
700    let group = group_or_default(group);
701    if group.size == 1 {
702        return x.clone();
703    }
704    Array::sticky("sum_gradients", Location::caller(), &[x], || {
705        let f = crate::custom_function_flat(|xs| Ok(xs.to_vec())).with_vjp(
706            move |_primals, _outputs, cotangents| {
707                cotangents
708                    .iter()
709                    .map(|c| reduce(c, AllReduce::Sum, Some(&group)))
710                    .collect()
711            },
712        );
713        Ok(f.call(std::slice::from_ref(x))?.remove(0))
714    })
715}
716
717impl crate::tree::Static for Group {
718    fn hash_static(&self, mut h: &mut dyn std::hash::Hasher) {
719        std::hash::Hash::hash(&(self.id, self.rank, self.size), &mut h);
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use std::sync::Mutex;
726    use std::time::Duration;
727
728    use super::*;
729
730    /// Rank 0 of two whose peer holds the same data (so a sum doubles it); logs every
731    /// operation, slowly, and fails the reduction tagged with `fail_at` elements.
732    struct Fake {
733        log: Mutex<Vec<String>>,
734        fail_at: Option<usize>,
735    }
736
737    impl Communicator for Fake {
738        fn rank(&self) -> usize {
739            0
740        }
741        fn size(&self) -> usize {
742            2
743        }
744        fn all_reduce(&self, op: AllReduce, dtype: DType, data: &mut [u8]) -> Result<()> {
745            std::thread::sleep(Duration::from_millis(2));
746            let n = data.len() / dtype.size();
747            self.log.lock().unwrap().push(format!("reduce {n}"));
748            if self.fail_at == Some(n) {
749                return Err(Error::Backend("distributed: rank 1 left".into()));
750            }
751            if op == AllReduce::Sum && dtype == DType::F32 {
752                for v in bytemuck::cast_slice_mut::<u8, f32>(data) {
753                    *v *= 2.0;
754                }
755            }
756            Ok(())
757        }
758        fn all_gather(&self, input: &[u8], output: &mut [u8]) -> Result<()> {
759            output[..input.len()].copy_from_slice(input);
760            output[input.len()..].copy_from_slice(input);
761            Ok(())
762        }
763        fn send(&self, _: usize, _: &[u8]) -> Result<()> {
764            Ok(())
765        }
766        fn recv(&self, _: usize, data: &mut [u8]) -> Result<()> {
767            data.fill(0);
768            Ok(())
769        }
770    }
771
772    fn fake(fail_at: Option<usize>) -> (Group, Arc<Fake>) {
773        let comm = Arc::new(Fake {
774            log: Mutex::new(Vec::new()),
775            fail_at,
776        });
777        (Group::new(comm.clone()), comm)
778    }
779
780    fn gpu() -> bool {
781        if !crate::gpu::available() {
782            return false;
783        }
784        crate::set_default_stream(crate::Stream::default_for(crate::Device::gpu(0)));
785        true
786    }
787
788    /// Collectives run in the order they were issued, whichever path runs them: GPU
789    /// stream (background) and CPU stream (inline, after the queued ones).
790    #[test]
791    fn collectives_run_in_program_order_across_streams() {
792        if !gpu() {
793            return;
794        }
795        let (g, comm) = fake(None);
796        let x = |n: usize| crate::ops::ones([n as i32], DType::F32);
797        let a = all_sum(&x(1).multiply(3.0), Some(&g));
798        let b = all_sum(&x(2).multiply(3.0), Some(&g));
799        crate::async_eval(&a).unwrap();
800        crate::async_eval(&b).unwrap();
801        crate::set_default_stream(crate::Stream::default_for(crate::Device::cpu()));
802        let c = all_sum(&x(3), Some(&g));
803        assert_eq!(c.to_vec::<f32>().unwrap(), [2.0; 3]);
804        assert_eq!(a.to_vec::<f32>().unwrap(), [6.0]);
805        assert_eq!(b.to_vec::<f32>().unwrap(), [6.0; 2]);
806        assert_eq!(
807            *comm.log.lock().unwrap(),
808            ["reduce 1", "reduce 2", "reduce 3"]
809        );
810    }
811
812    /// A result nobody reads still runs (the peers expect it), and later ones follow it.
813    #[test]
814    fn a_dropped_result_still_runs() {
815        if !gpu() {
816            return;
817        }
818        let (g, comm) = fake(None);
819        let a = all_sum(&crate::ops::ones([4], DType::F32).multiply(2.0), Some(&g));
820        crate::async_eval(&a).unwrap();
821        drop(a);
822        let b = all_sum(&crate::ops::ones([5], DType::F32).add(1.0), Some(&g));
823        assert_eq!(b.to_vec::<f32>().unwrap(), [4.0; 5]);
824        assert_eq!(*comm.log.lock().unwrap(), ["reduce 4", "reduce 5"]);
825    }
826
827    /// A failed collective fails its output and the GPU work after it with its own error,
828    /// and nothing hangs.
829    #[test]
830    fn a_failed_collective_fails_what_follows() {
831        if !gpu() {
832            return;
833        }
834        let (g, _) = fake(Some(7));
835        let y = all_sum(&crate::ops::ones([7], DType::F32).multiply(2.0), Some(&g)).add(1.0);
836        let err = y.eval().unwrap_err().to_string();
837        assert!(err.contains("rank 1 left"), "{err}");
838    }
839
840    /// A GPU failure before a collective fails its output (the collective itself still runs).
841    #[cfg(all(target_os = "macos", not(miri)))]
842    #[test]
843    fn a_gpu_failure_before_a_collective_fails_it() {
844        if !gpu() {
845            return;
846        }
847        let (g, comm) = fake(None);
848        let x = crate::ops::ones([6], DType::F32).multiply(2.0);
849        crate::gpu::fault::fail_next_commit();
850        let y = all_sum(&x, Some(&g)).add(1.0);
851        assert!(y.eval().is_err());
852        assert_eq!(*comm.log.lock().unwrap(), ["reduce 6"]);
853    }
854
855    /// Decode's shape commits by the dispatch policy (MLX's, which commits a command buffer
856    /// that made new buffers after 50 dispatches), not once per collective.
857    #[cfg(all(target_os = "macos", not(miri)))]
858    #[test]
859    fn collectives_do_not_commit_command_buffers() {
860        if !gpu() {
861            return;
862        }
863        let (g, _) = fake(None);
864        let x = crate::ops::ones([1, 1024], DType::F32);
865        x.eval().unwrap();
866        let chain = |x: &Array, collectives: bool| {
867            let mut y = x.clone();
868            for _ in 0..56 {
869                y = y.multiply(0.5);
870                if collectives {
871                    y = all_sum(&y, Some(&g));
872                }
873            }
874            y
875        };
876        // Warm both shapes (pipelines, the worker), then count each.
877        chain(&x, true).eval().unwrap();
878        chain(&x, false).eval().unwrap();
879        let count = |collectives: bool| {
880            let before = crate::gpu::COMMITS.get();
881            let y = chain(&x, collectives);
882            y.eval().unwrap();
883            (crate::gpu::COMMITS.get() - before, y)
884        };
885        let (with, y) = count(true);
886        let (without, _) = count(false);
887        assert!(
888            with <= without + 56 / 8,
889            "{with} command buffers for 56 collectives, {without} without them"
890        );
891        assert_eq!(y.to_vec::<f32>().unwrap(), vec![1.0; 1024]);
892    }
893
894    /// A CPU-stream collective after a GPU-stream one in the same evaluation runs after it
895    /// (the open command buffer is committed first, so nothing waits on unsubmitted work).
896    #[test]
897    fn a_cpu_collective_after_a_gpu_one_in_one_evaluation() {
898        if !gpu() {
899            return;
900        }
901        let (g, comm) = fake(None);
902        let a = all_sum(&crate::ops::ones([3], DType::F32).multiply(3.0), Some(&g));
903        crate::set_default_stream(crate::Stream::default_for(crate::Device::cpu()));
904        let c = all_sum(&a.add(1.0), Some(&g));
905        assert_eq!(c.to_vec::<f32>().unwrap(), [14.0; 3]);
906        assert_eq!(*comm.log.lock().unwrap(), ["reduce 3", "reduce 3"]);
907    }
908
909    /// An all-reduce writes its input's buffer only when nothing else holds the input (MLX's
910    /// donation): a held input keeps its values; an intermediate, or an evaluated array
911    /// whose last handle is gone, is reduced in place.
912    #[test]
913    fn only_inputs_nothing_else_holds_are_reduced_in_place() {
914        if !gpu() {
915            return;
916        }
917        let (g, _comm) = fake(None);
918        let held = crate::ops::ones([5], DType::F32).multiply(3.0);
919        held.eval().unwrap();
920        let sum = all_sum(&held, Some(&g));
921        assert_eq!(sum.to_vec::<f32>().unwrap(), [6.0; 5]);
922        assert_eq!(held.to_vec::<f32>().unwrap(), [3.0; 5]);
923
924        let intermediate = all_sum(&crate::ops::ones([5], DType::F32).multiply(2.0), Some(&g));
925        assert_eq!(intermediate.to_vec::<f32>().unwrap(), [4.0; 5]);
926
927        let done = crate::ops::ones([5], DType::F32).multiply(5.0);
928        done.eval().unwrap();
929        let sum = all_sum(&done, Some(&g));
930        drop(done);
931        assert_eq!(sum.to_vec::<f32>().unwrap(), [10.0; 5]);
932
933        // Read by another consumer in the same evaluation: not donated.
934        let x = crate::ops::ones([5], DType::F32).multiply(4.0);
935        let (sum, other) = (all_sum(&x, Some(&g)), x.add(1.0));
936        drop(x);
937        crate::eval(&[sum.clone(), other.clone()]).unwrap();
938        assert_eq!(sum.to_vec::<f32>().unwrap(), [8.0; 5]);
939        assert_eq!(other.to_vec::<f32>().unwrap(), [5.0; 5]);
940    }
941
942    /// A failed command buffer holding several collectives fails all of their outputs.
943    #[cfg(all(target_os = "macos", not(miri)))]
944    #[test]
945    fn a_failed_buffer_fails_every_collective_in_it() {
946        if !gpu() {
947            return;
948        }
949        let (g, comm) = fake(None);
950        let mut y = crate::ops::ones([8], DType::F32);
951        for _ in 0..3 {
952            y = all_sum(&y.multiply(2.0), Some(&g));
953        }
954        crate::gpu::fault::fail_next_commit();
955        assert!(y.eval().is_err());
956        assert_eq!(comm.log.lock().unwrap().len(), 3);
957    }
958}