Skip to main content

optirs_core/distributed/
ring_allreduce.rs

1// Bandwidth-optimal ring all-reduce and all-gather collectives.
2//
3// This module implements the classic segmented ring all-reduce algorithm
4// (popularised by Baidu and Horovod) as a pure-Rust, in-process simulation.
5// There is no real network: every "worker" lives in the same process and the
6// `CollectiveTransport` abstraction stands in for the neighbour send/recv that a
7// real fabric (MPI / NCCL / gRPC) would perform. This makes the file a faithful
8// CPU reference implementation that a real transport could later be plugged into.
9//
10// # Algorithm
11// For a logical ring of `N` workers, each holding a vector of equal length, the
12// reduction proceeds in two bandwidth-optimal phases, each of `N - 1` steps:
13//
14// 1. **Reduce-scatter.** Each vector is split into `N` contiguous segments. At
15//    every step each worker sends one segment to its ring successor and folds the
16//    segment it receives from its predecessor into the matching local segment
17//    using the reduction operator. After `N - 1` steps worker `r` holds the fully
18//    reduced value for exactly one distinct segment (segment `(r + 1) mod N`).
19//
20// 2. **All-gather.** The fully reduced segments are circulated around the ring so
21//    that after a further `N - 1` steps every worker holds every reduced segment,
22//    i.e. the complete result.
23//
24// # Bandwidth optimality
25// Each worker sends and receives roughly `2 * (N - 1) / N * size` elements in
26// total, independent of `N` for large `N`. This is why the segmented two-phase
27// structure is preserved rather than collapsing to a trivial global sum: the
28// communication volume per worker does not grow with the ring size.
29//
30// # Uneven segmentation
31// When the vector length `L` is not divisible by `N` the first `L mod N` segments
32// receive one extra element, so segment lengths differ by at most one. Segments of
33// length zero (when `L < N`) are handled transparently.
34
35use crate::error::{OptimError, Result};
36use scirs2_core::ndarray::{Array1, ScalarOperand};
37use scirs2_core::numeric::Float;
38use std::fmt::Debug;
39
40/// Per-rank working state: one inner buffer per ring segment (or per gather slot).
41type RankSegments<A> = Vec<Vec<A>>;
42
43/// Reduction operators supported by the ring all-reduce.
44///
45/// The associative combine used during the reduce-scatter phase is exposed via
46/// [`ReduceOp::apply`], and the neutral element of that combine via
47/// [`ReduceOp::identity`]. [`ReduceOp::Mean`] reuses the additive combine and is
48/// finalised by dividing the accumulated sum by the world size.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ReduceOp {
51    /// Elementwise sum across all workers.
52    Sum,
53    /// Elementwise arithmetic mean across all workers (sum divided by world size).
54    Mean,
55    /// Elementwise maximum across all workers.
56    Max,
57    /// Elementwise minimum across all workers.
58    Min,
59    /// Elementwise product across all workers.
60    Product,
61}
62
63impl ReduceOp {
64    /// Combine two values with this operator.
65    ///
66    /// `Mean` uses the same additive combine as `Sum`; the division by the world
67    /// size is applied once at the end of the reduction (see
68    /// [`ReduceOp::needs_mean_finalize`]).
69    pub fn apply<A: Float>(self, a: A, b: A) -> A {
70        match self {
71            ReduceOp::Sum | ReduceOp::Mean => a + b,
72            ReduceOp::Product => a * b,
73            ReduceOp::Max => a.max(b),
74            ReduceOp::Min => a.min(b),
75        }
76    }
77
78    /// Neutral element of the combine operator.
79    ///
80    /// Folding `identity` with any value `v` via [`ReduceOp::apply`] yields `v`.
81    pub fn identity<A: Float>(self) -> A {
82        match self {
83            ReduceOp::Sum | ReduceOp::Mean => A::zero(),
84            ReduceOp::Product => A::one(),
85            ReduceOp::Max => A::neg_infinity(),
86            ReduceOp::Min => A::infinity(),
87        }
88    }
89
90    /// Whether the accumulated result must be divided by the world size.
91    ///
92    /// This is true only for [`ReduceOp::Mean`].
93    pub fn needs_mean_finalize(self) -> bool {
94        matches!(self, ReduceOp::Mean)
95    }
96}
97
98/// Abstraction over the neighbour send/recv used by a single ring step.
99///
100/// A ring step is synchronous: every rank `r` posts the segment it wants to send
101/// to its successor `(r + 1) mod world_size` and then collects the segment posted
102/// to it by its predecessor `(r + world_size - 1) mod world_size`. Implementors
103/// are free to back this with real network transfers; [`LocalTransport`] backs it
104/// with in-process mailboxes.
105///
106/// The collective driver always posts *all* sends for a step before collecting
107/// *any* receives, which preserves the snapshot semantics of a true simultaneous
108/// ring exchange (a worker transmits the value it held at the start of the step).
109pub trait CollectiveTransport<A> {
110    /// Number of ranks participating in the ring.
111    fn world_size(&self) -> usize;
112
113    /// Post `segment` to be delivered from `src_rank` to its ring successor.
114    fn send_to_successor(&mut self, src_rank: usize, segment: Vec<A>) -> Result<()>;
115
116    /// Collect the segment delivered to `dst_rank` by its ring predecessor.
117    fn recv_from_predecessor(&mut self, dst_rank: usize) -> Result<Vec<A>>;
118}
119
120/// In-process implementation of [`CollectiveTransport`] using per-rank mailboxes.
121///
122/// `mailboxes[r]` holds the single segment that has been delivered to rank `r`
123/// and not yet collected. Because each rank in a ring step sends to a distinct
124/// successor, at most one message is ever in flight per mailbox at a time.
125#[derive(Debug)]
126pub struct LocalTransport<A> {
127    world_size: usize,
128    mailboxes: Vec<Option<Vec<A>>>,
129}
130
131impl<A> LocalTransport<A> {
132    /// Create a transport for a ring of `world_size` ranks.
133    ///
134    /// Returns an error when `world_size` is zero.
135    pub fn new(world_size: usize) -> Result<Self> {
136        if world_size == 0 {
137            return Err(OptimError::InvalidConfig(
138                "world_size must be at least 1".to_string(),
139            ));
140        }
141        let mut mailboxes = Vec::with_capacity(world_size);
142        for _ in 0..world_size {
143            mailboxes.push(None);
144        }
145        Ok(Self {
146            world_size,
147            mailboxes,
148        })
149    }
150}
151
152impl<A> CollectiveTransport<A> for LocalTransport<A> {
153    fn world_size(&self) -> usize {
154        self.world_size
155    }
156
157    fn send_to_successor(&mut self, src_rank: usize, segment: Vec<A>) -> Result<()> {
158        if src_rank >= self.world_size {
159            return Err(OptimError::InvalidConfig(format!(
160                "src_rank {src_rank} out of range for world_size {}",
161                self.world_size
162            )));
163        }
164        let dst = (src_rank + 1) % self.world_size;
165        if self.mailboxes[dst].is_some() {
166            return Err(OptimError::InvalidState(format!(
167                "mailbox for rank {dst} already holds an uncollected message"
168            )));
169        }
170        self.mailboxes[dst] = Some(segment);
171        Ok(())
172    }
173
174    fn recv_from_predecessor(&mut self, dst_rank: usize) -> Result<Vec<A>> {
175        if dst_rank >= self.world_size {
176            return Err(OptimError::InvalidConfig(format!(
177                "dst_rank {dst_rank} out of range for world_size {}",
178                self.world_size
179            )));
180        }
181        self.mailboxes[dst_rank].take().ok_or_else(|| {
182            OptimError::InvalidState(format!("no message waiting in mailbox for rank {dst_rank}"))
183        })
184    }
185}
186
187/// Non-negative remainder of `value` modulo `n`, returned as a `usize`.
188#[inline]
189fn modn(value: isize, n: usize) -> usize {
190    let m = n as isize;
191    (((value % m) + m) % m) as usize
192}
193
194/// Compute the `n + 1` segment boundary offsets for splitting a length-`l` vector
195/// into `n` contiguous segments. The first `l % n` segments receive one extra
196/// element so that segment lengths differ by at most one.
197fn compute_segment_offsets(l: usize, n: usize) -> Vec<usize> {
198    let base = l / n;
199    let rem = l % n;
200    let mut offsets = Vec::with_capacity(n + 1);
201    let mut acc = 0usize;
202    offsets.push(acc);
203    for k in 0..n {
204        let len = if k < rem { base + 1 } else { base };
205        acc += len;
206        offsets.push(acc);
207    }
208    offsets
209}
210
211/// Execute one synchronous ring step.
212///
213/// At `step`, rank `r` sends its segment indexed `(r - step + send_offset) mod n`
214/// to its successor and writes the segment received from its predecessor into the
215/// slot indexed `(r - step + recv_offset) mod n`. When `op` is `Some`, the
216/// received segment is folded into the destination slot; when `None`, it
217/// overwrites the destination slot (used by the all-gather phases).
218fn ring_step<A, T>(
219    transport: &mut T,
220    states: &mut [RankSegments<A>],
221    step: usize,
222    send_offset: isize,
223    recv_offset: isize,
224    op: Option<ReduceOp>,
225) -> Result<()>
226where
227    A: Float,
228    T: CollectiveTransport<A>,
229{
230    let n = states.len();
231    let step_i = step as isize;
232
233    // Phase 1: post every send. Snapshotting the outgoing segments before any
234    // receive mutates `states` preserves simultaneous-exchange semantics.
235    for (r, segments) in states.iter().enumerate() {
236        let send_chunk = modn(r as isize - step_i + send_offset, n);
237        let segment = segments[send_chunk].clone();
238        transport.send_to_successor(r, segment)?;
239    }
240
241    // Phase 2: collect every receive and combine into the destination slot.
242    for (r, segments) in states.iter_mut().enumerate() {
243        let recv_chunk = modn(r as isize - step_i + recv_offset, n);
244        let incoming = transport.recv_from_predecessor(r)?;
245        match op {
246            Some(reduce_op) => {
247                let slot = &mut segments[recv_chunk];
248                if slot.len() != incoming.len() {
249                    return Err(OptimError::DimensionMismatch(format!(
250                        "segment length mismatch at rank {r}, chunk {recv_chunk}: \
251                         local {} vs received {}",
252                        slot.len(),
253                        incoming.len()
254                    )));
255                }
256                for (dst, src) in slot.iter_mut().zip(incoming.iter()) {
257                    *dst = reduce_op.apply(*dst, *src);
258                }
259            }
260            None => {
261                segments[recv_chunk] = incoming;
262            }
263        }
264    }
265
266    Ok(())
267}
268
269/// Reduce-scatter phase: `n - 1` steps folding received segments with `op`.
270///
271/// After completion, rank `r` holds the fully reduced segment `(r + 1) mod n`.
272fn reduce_scatter<A, T>(
273    transport: &mut T,
274    states: &mut [RankSegments<A>],
275    op: ReduceOp,
276) -> Result<()>
277where
278    A: Float,
279    T: CollectiveTransport<A>,
280{
281    let n = states.len();
282    for step in 0..(n - 1) {
283        ring_step(transport, states, step, 0, -1, Some(op))?;
284    }
285    Ok(())
286}
287
288/// All-gather phase over the already-reduced segments (the second half of a full
289/// all-reduce). Worker `r` starts circulating from segment `(r + 1) mod n`.
290fn all_gather_reduced<A, T>(transport: &mut T, states: &mut [RankSegments<A>]) -> Result<()>
291where
292    A: Float,
293    T: CollectiveTransport<A>,
294{
295    let n = states.len();
296    for step in 0..(n - 1) {
297        ring_step(transport, states, step, 1, 0, None)?;
298    }
299    Ok(())
300}
301
302/// Standalone ring all-gather over `n` owner slots. Worker `r` starts circulating
303/// from its own slot `r`; after `n - 1` steps every worker holds every slot.
304fn all_gather_slots<A, T>(transport: &mut T, states: &mut [RankSegments<A>]) -> Result<()>
305where
306    A: Float,
307    T: CollectiveTransport<A>,
308{
309    let n = states.len();
310    for step in 0..(n - 1) {
311        ring_step(transport, states, step, 0, -1, None)?;
312    }
313    Ok(())
314}
315
316/// Flatten a rank's per-segment buffers into a single contiguous vector, in
317/// segment order.
318fn flatten_segments<A: Float>(segments: RankSegments<A>, capacity: usize) -> Array1<A> {
319    let mut flat = Vec::with_capacity(capacity);
320    for segment in segments {
321        flat.extend(segment);
322    }
323    Array1::from_vec(flat)
324}
325
326/// Bandwidth-optimal ring all-reduce / all-gather driver.
327///
328/// A single [`RingAllReduce`] simulates the whole ring in-process: each public
329/// method takes the per-rank inputs, drives all `world_size` ranks through the
330/// collective using a [`LocalTransport`], and returns each rank's result (all
331/// ranks end up holding identical data).
332#[derive(Debug, Clone, Copy)]
333pub struct RingAllReduce {
334    world_size: usize,
335}
336
337impl RingAllReduce {
338    /// Create a driver for a ring of `world_size` workers.
339    ///
340    /// Returns an error when `world_size` is zero.
341    pub fn new(world_size: usize) -> Result<Self> {
342        if world_size == 0 {
343            return Err(OptimError::InvalidConfig(
344                "world_size must be at least 1".to_string(),
345            ));
346        }
347        Ok(Self { world_size })
348    }
349
350    /// Number of workers in the ring.
351    pub fn world_size(&self) -> usize {
352        self.world_size
353    }
354
355    /// Validate that `inputs` provides exactly one equal-length, non-empty vector
356    /// per rank, returning the common length on success.
357    fn validate_equal_length(&self, inputs: &[Array1<impl Float>]) -> Result<usize> {
358        if inputs.is_empty() {
359            return Err(OptimError::InvalidConfig(
360                "inputs must not be empty".to_string(),
361            ));
362        }
363        if inputs.len() != self.world_size {
364            return Err(OptimError::DimensionMismatch(format!(
365                "expected {} inputs (one per rank), got {}",
366                self.world_size,
367                inputs.len()
368            )));
369        }
370        let len = inputs[0].len();
371        if len == 0 {
372            return Err(OptimError::InvalidConfig(
373                "input vectors must have non-zero length".to_string(),
374            ));
375        }
376        for (rank, vector) in inputs.iter().enumerate() {
377            if vector.len() != len {
378                return Err(OptimError::DimensionMismatch(format!(
379                    "rank {rank} length {} does not match rank 0 length {len}",
380                    vector.len()
381                )));
382            }
383        }
384        Ok(len)
385    }
386
387    /// Run the full simulated ring all-reduce across every rank at once.
388    ///
389    /// `inputs[r]` is rank `r`'s vector; all vectors must be non-empty and share
390    /// the same length. The returned vector holds each rank's result, which are
391    /// all identical and equal to the elementwise reduction under `op`.
392    pub fn all_reduce_all<A>(&self, inputs: &[Array1<A>], op: ReduceOp) -> Result<Vec<Array1<A>>>
393    where
394        A: Float + ScalarOperand + Debug,
395    {
396        let len = self.validate_equal_length(inputs)?;
397        let n = self.world_size;
398
399        // A ring of one worker is the identity reduction (a reduction over a
400        // single contributor is that contributor's own value, for every op).
401        if n == 1 {
402            return Ok(vec![inputs[0].clone()]);
403        }
404
405        // Split each rank's vector into `n` contiguous segments.
406        let offsets = compute_segment_offsets(len, n);
407        let mut states: Vec<RankSegments<A>> = Vec::with_capacity(n);
408        for vector in inputs {
409            let slice = vector.as_slice().ok_or_else(|| {
410                OptimError::InvalidConfig("input vector must be contiguous".to_string())
411            })?;
412            let segments: RankSegments<A> = (0..n)
413                .map(|k| slice[offsets[k]..offsets[k + 1]].to_vec())
414                .collect();
415            states.push(segments);
416        }
417
418        let mut transport = LocalTransport::<A>::new(n)?;
419        reduce_scatter(&mut transport, &mut states, op)?;
420        all_gather_reduced(&mut transport, &mut states)?;
421
422        // Finalise the mean by dividing the accumulated sum by the world size.
423        if op.needs_mean_finalize() {
424            let denom = A::from(n).ok_or_else(|| {
425                OptimError::InvalidConfig("cannot represent world_size as scalar".to_string())
426            })?;
427            for segments in states.iter_mut() {
428                for segment in segments.iter_mut() {
429                    for value in segment.iter_mut() {
430                        *value = *value / denom;
431                    }
432                }
433            }
434        }
435
436        let results = states
437            .into_iter()
438            .map(|segments| flatten_segments(segments, len))
439            .collect();
440        Ok(results)
441    }
442
443    /// Run the simulated ring all-gather across every rank at once.
444    ///
445    /// `inputs[r]` is rank `r`'s contribution; contributions may differ in length
446    /// (this is a gather, not a reduction) but each must be non-empty. The result
447    /// holds, for every rank, the concatenation `inputs[0] || inputs[1] || ... ||
448    /// inputs[N - 1]` in rank order.
449    pub fn all_gather<A>(&self, inputs: &[Array1<A>]) -> Result<Vec<Array1<A>>>
450    where
451        A: Float + ScalarOperand + Debug,
452    {
453        if inputs.is_empty() {
454            return Err(OptimError::InvalidConfig(
455                "inputs must not be empty".to_string(),
456            ));
457        }
458        if inputs.len() != self.world_size {
459            return Err(OptimError::DimensionMismatch(format!(
460                "expected {} inputs (one per rank), got {}",
461                self.world_size,
462                inputs.len()
463            )));
464        }
465        for (rank, vector) in inputs.iter().enumerate() {
466            if vector.is_empty() {
467                return Err(OptimError::InvalidConfig(format!(
468                    "rank {rank} contribution must have non-zero length"
469                )));
470            }
471        }
472
473        let total: usize = inputs.iter().map(|vector| vector.len()).sum();
474        let n = self.world_size;
475
476        // A ring of one worker already holds the entire concatenation.
477        if n == 1 {
478            return Ok(vec![inputs[0].clone()]);
479        }
480
481        // Each rank starts owning only its own slot; the others are placeholders
482        // that get filled as the data circulates around the ring.
483        let mut states: Vec<RankSegments<A>> = Vec::with_capacity(n);
484        for (rank, vector) in inputs.iter().enumerate() {
485            let slice = vector.as_slice().ok_or_else(|| {
486                OptimError::InvalidConfig("input vector must be contiguous".to_string())
487            })?;
488            let mut segments: RankSegments<A> = vec![Vec::new(); n];
489            segments[rank] = slice.to_vec();
490            states.push(segments);
491        }
492
493        let mut transport = LocalTransport::<A>::new(n)?;
494        all_gather_slots(&mut transport, &mut states)?;
495
496        let results = states
497            .into_iter()
498            .map(|segments| flatten_segments(segments, total))
499            .collect();
500        Ok(results)
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use scirs2_core::ndarray::Array1;
508
509    /// Independent, naive reference reduction used to validate the ring result.
510    fn naive_reduce(inputs: &[Array1<f64>], op: ReduceOp) -> Array1<f64> {
511        let len = inputs[0].len();
512        let mut out = vec![op.identity::<f64>(); len];
513        for vector in inputs {
514            for (acc, &value) in out.iter_mut().zip(vector.iter()) {
515                *acc = op.apply(*acc, value);
516            }
517        }
518        if op.needs_mean_finalize() {
519            let denom = inputs.len() as f64;
520            for acc in out.iter_mut() {
521                *acc /= denom;
522            }
523        }
524        Array1::from_vec(out)
525    }
526
527    fn assert_all_ranks_eq(results: &[Array1<f64>], expected: &Array1<f64>) {
528        for (rank, result) in results.iter().enumerate() {
529            assert_eq!(result.len(), expected.len(), "rank {rank} length mismatch");
530            for (index, (&got, &want)) in result.iter().zip(expected.iter()).enumerate() {
531                assert!(
532                    (got - want).abs() < 1e-9,
533                    "rank {rank} coordinate {index}: got {got}, want {want}"
534                );
535            }
536        }
537    }
538
539    #[test]
540    fn test_new_rejects_zero_world_size() {
541        assert!(RingAllReduce::new(0).is_err());
542        assert!(RingAllReduce::new(1).is_ok());
543        assert!(RingAllReduce::new(8).is_ok());
544    }
545
546    #[test]
547    fn test_ring_all_reduce_sum_matches_naive() {
548        // N = 4, L = 10 -> uneven segmentation [3, 3, 2, 2].
549        let n = 4;
550        let l = 10;
551        let inputs: Vec<Array1<f64>> = (0..n)
552            .map(|r| Array1::from_vec((0..l).map(|i| (r * 10 + i) as f64).collect()))
553            .collect();
554
555        let ring = RingAllReduce::new(n).unwrap();
556        let results = ring.all_reduce_all(&inputs, ReduceOp::Sum).unwrap();
557
558        // Closed form: sum_r (r*10 + i) = 60 + 4*i.
559        let expected = Array1::from_vec((0..l).map(|i| 60.0 + 4.0 * i as f64).collect());
560        assert_eq!(results.len(), n);
561        assert_all_ranks_eq(&results, &expected);
562        assert_all_ranks_eq(&results, &naive_reduce(&inputs, ReduceOp::Sum));
563    }
564
565    #[test]
566    fn test_ring_all_reduce_mean() {
567        // N = 3, L = 9 (evenly divisible).
568        let n = 3;
569        let l = 9;
570        let inputs: Vec<Array1<f64>> = (0..n)
571            .map(|r| Array1::from_vec((0..l).map(|i| (r + i) as f64).collect()))
572            .collect();
573
574        let ring = RingAllReduce::new(n).unwrap();
575        let results = ring.all_reduce_all(&inputs, ReduceOp::Mean).unwrap();
576
577        // Closed form: mean_r (r + i) = i + 1.
578        let expected = Array1::from_vec((0..l).map(|i| (i + 1) as f64).collect());
579        assert_all_ranks_eq(&results, &expected);
580        assert_all_ranks_eq(&results, &naive_reduce(&inputs, ReduceOp::Mean));
581    }
582
583    #[test]
584    fn test_ring_all_reduce_max() {
585        // N = 4, L = 7 -> uneven segmentation [2, 2, 2, 1].
586        let n = 4;
587        let l = 7;
588        let inputs: Vec<Array1<f64>> = (0..n)
589            .map(|r| Array1::from_vec((0..l).map(|i| (i * 4 + r) as f64).collect()))
590            .collect();
591
592        let ring = RingAllReduce::new(n).unwrap();
593        let results = ring.all_reduce_all(&inputs, ReduceOp::Max).unwrap();
594
595        // Closed form: max_r (i*4 + r) = i*4 + 3.
596        let expected = Array1::from_vec((0..l).map(|i| (i * 4 + 3) as f64).collect());
597        assert_all_ranks_eq(&results, &expected);
598        assert_all_ranks_eq(&results, &naive_reduce(&inputs, ReduceOp::Max));
599    }
600
601    #[test]
602    fn test_ring_all_reduce_min() {
603        let n = 4;
604        let l = 7;
605        let inputs: Vec<Array1<f64>> = (0..n)
606            .map(|r| Array1::from_vec((0..l).map(|i| (i * 4 + r) as f64).collect()))
607            .collect();
608
609        let ring = RingAllReduce::new(n).unwrap();
610        let results = ring.all_reduce_all(&inputs, ReduceOp::Min).unwrap();
611
612        // Closed form: min_r (i*4 + r) = i*4.
613        let expected = Array1::from_vec((0..l).map(|i| (i * 4) as f64).collect());
614        assert_all_ranks_eq(&results, &expected);
615        assert_all_ranks_eq(&results, &naive_reduce(&inputs, ReduceOp::Min));
616    }
617
618    #[test]
619    fn test_ring_all_reduce_product() {
620        // N = 3, L = 5. Per-element product 2 * 3 * 0.5 = 3.
621        let n = 3;
622        let l = 5;
623        let values = [2.0f64, 3.0, 0.5];
624        let inputs: Vec<Array1<f64>> = (0..n)
625            .map(|r| Array1::from_vec(vec![values[r]; l]))
626            .collect();
627
628        let ring = RingAllReduce::new(n).unwrap();
629        let results = ring.all_reduce_all(&inputs, ReduceOp::Product).unwrap();
630
631        let expected = Array1::from_vec(vec![3.0; l]);
632        assert_all_ranks_eq(&results, &expected);
633        assert_all_ranks_eq(&results, &naive_reduce(&inputs, ReduceOp::Product));
634    }
635
636    #[test]
637    fn test_world_size_one_identity() {
638        let ring = RingAllReduce::new(1).unwrap();
639        let inputs = vec![Array1::from_vec(vec![1.0f64, 2.0, 3.0])];
640
641        let sum = ring.all_reduce_all(&inputs, ReduceOp::Sum).unwrap();
642        assert_eq!(sum.len(), 1);
643        assert_all_ranks_eq(&sum, &inputs[0]);
644
645        let mean = ring.all_reduce_all(&inputs, ReduceOp::Mean).unwrap();
646        assert_all_ranks_eq(&mean, &inputs[0]);
647
648        let gathered = ring.all_gather(&inputs).unwrap();
649        assert_all_ranks_eq(&gathered, &inputs[0]);
650    }
651
652    #[test]
653    fn test_length_not_divisible_by_world_size() {
654        // N = 3, L = 7 -> uneven segmentation [3, 2, 2].
655        let n = 3;
656        let l = 7;
657        let inputs: Vec<Array1<f64>> = (0..n)
658            .map(|r| Array1::from_vec((0..l).map(|i| (r * 100 + i) as f64).collect()))
659            .collect();
660
661        let ring = RingAllReduce::new(n).unwrap();
662        let results = ring.all_reduce_all(&inputs, ReduceOp::Sum).unwrap();
663
664        // Closed form: sum_r (r*100 + i) = 300 + 3*i.
665        let expected = Array1::from_vec((0..l).map(|i| 300.0 + 3.0 * i as f64).collect());
666        assert_all_ranks_eq(&results, &expected);
667    }
668
669    #[test]
670    fn test_length_smaller_than_world_size() {
671        // L < N forces zero-length segments; the collective must still be correct.
672        let n = 4;
673        let inputs: Vec<Array1<f64>> = (0..n)
674            .map(|r| Array1::from_vec(vec![r as f64, (r + 1) as f64]))
675            .collect();
676
677        let ring = RingAllReduce::new(n).unwrap();
678        let results = ring.all_reduce_all(&inputs, ReduceOp::Sum).unwrap();
679
680        // sum_r [r, r+1] = [0+1+2+3, 1+2+3+4] = [6, 10].
681        let expected = Array1::from_vec(vec![6.0, 10.0]);
682        assert_all_ranks_eq(&results, &expected);
683    }
684
685    #[test]
686    fn test_all_gather_round_trip() {
687        // Contributions of different lengths concatenated in rank order.
688        let inputs = vec![
689            Array1::from_vec(vec![1.0f64, 2.0]),
690            Array1::from_vec(vec![3.0, 4.0, 5.0]),
691            Array1::from_vec(vec![6.0]),
692            Array1::from_vec(vec![7.0, 8.0, 9.0, 10.0]),
693        ];
694
695        let ring = RingAllReduce::new(4).unwrap();
696        let results = ring.all_gather(&inputs).unwrap();
697
698        let expected = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]);
699        assert_eq!(results.len(), 4);
700        assert_all_ranks_eq(&results, &expected);
701    }
702
703    #[test]
704    fn test_invalid_inputs_are_rejected() {
705        let ring = RingAllReduce::new(3).unwrap();
706
707        // Empty input slice.
708        let empty: Vec<Array1<f64>> = vec![];
709        assert!(ring.all_reduce_all(&empty, ReduceOp::Sum).is_err());
710        assert!(ring.all_gather(&empty).is_err());
711
712        // Wrong number of contributions for the ring size.
713        let wrong_count = vec![Array1::from_vec(vec![1.0f64]), Array1::from_vec(vec![2.0])];
714        assert!(ring.all_reduce_all(&wrong_count, ReduceOp::Sum).is_err());
715        assert!(ring.all_gather(&wrong_count).is_err());
716
717        // Inconsistent lengths for all-reduce.
718        let inconsistent = vec![
719            Array1::from_vec(vec![1.0f64, 2.0]),
720            Array1::from_vec(vec![3.0, 4.0]),
721            Array1::from_vec(vec![5.0]),
722        ];
723        assert!(ring.all_reduce_all(&inconsistent, ReduceOp::Sum).is_err());
724
725        // Zero-length contributions.
726        let zero_len = vec![
727            Array1::<f64>::from_vec(vec![]),
728            Array1::from_vec(vec![]),
729            Array1::from_vec(vec![]),
730        ];
731        assert!(ring.all_reduce_all(&zero_len, ReduceOp::Sum).is_err());
732        assert!(ring.all_gather(&zero_len).is_err());
733    }
734
735    #[test]
736    fn test_compute_segment_offsets() {
737        assert_eq!(compute_segment_offsets(7, 3), vec![0, 3, 5, 7]);
738        assert_eq!(compute_segment_offsets(10, 4), vec![0, 3, 6, 8, 10]);
739        assert_eq!(compute_segment_offsets(6, 3), vec![0, 2, 4, 6]);
740        // L < N produces trailing zero-length segments.
741        assert_eq!(compute_segment_offsets(2, 4), vec![0, 1, 2, 2, 2]);
742
743        // Adjacent segment lengths differ by at most one.
744        let offsets = compute_segment_offsets(10, 4);
745        let lengths: Vec<usize> = offsets.windows(2).map(|w| w[1] - w[0]).collect();
746        let max_len = *lengths.iter().max().unwrap();
747        let min_len = *lengths.iter().min().unwrap();
748        assert!(max_len - min_len <= 1);
749        assert_eq!(lengths.iter().sum::<usize>(), 10);
750    }
751
752    #[test]
753    fn test_local_transport_mailbox_semantics() {
754        let mut transport = LocalTransport::<f64>::new(3).unwrap();
755        assert_eq!(transport.world_size(), 3);
756
757        // A send from rank 0 lands in rank 1's mailbox.
758        transport.send_to_successor(0, vec![1.0, 2.0]).unwrap();
759        // Rank 0's own mailbox is empty.
760        assert!(transport.recv_from_predecessor(0).is_err());
761        // Rank 1 collects the delivered segment, exactly once.
762        assert_eq!(transport.recv_from_predecessor(1).unwrap(), vec![1.0, 2.0]);
763        assert!(transport.recv_from_predecessor(1).is_err());
764
765        // Posting twice without an intervening receive is rejected.
766        transport.send_to_successor(0, vec![3.0]).unwrap();
767        assert!(transport.send_to_successor(0, vec![4.0]).is_err());
768
769        // Out-of-range ranks are rejected.
770        assert!(transport.send_to_successor(3, vec![0.0]).is_err());
771        assert!(transport.recv_from_predecessor(3).is_err());
772    }
773
774    #[test]
775    fn test_reduce_op_identity_and_apply() {
776        assert_eq!(ReduceOp::Sum.identity::<f64>(), 0.0);
777        assert_eq!(ReduceOp::Product.identity::<f64>(), 1.0);
778        assert_eq!(ReduceOp::Max.identity::<f64>(), f64::NEG_INFINITY);
779        assert_eq!(ReduceOp::Min.identity::<f64>(), f64::INFINITY);
780
781        assert_eq!(ReduceOp::Sum.apply(2.0, 3.0), 5.0);
782        assert_eq!(ReduceOp::Product.apply(2.0, 3.0), 6.0);
783        assert_eq!(ReduceOp::Max.apply(2.0, 3.0), 3.0);
784        assert_eq!(ReduceOp::Min.apply(2.0, 3.0), 2.0);
785        // Mean reuses the additive combine; division happens at finalisation.
786        assert_eq!(ReduceOp::Mean.apply(2.0, 3.0), 5.0);
787        assert!(ReduceOp::Mean.needs_mean_finalize());
788        assert!(!ReduceOp::Sum.needs_mean_finalize());
789    }
790}