Skip to main content

rlx_driver/
collective.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Collective ops as algorithms over [`SymmetricTransport`]
6//! (plan #12).
7//!
8//! Borrowed from MAX's `kernels/src/comm/{allgather, allreduce,
9//! reducescatter, allreduce_residual_rmsnorm_fp8, rms_norm_fp8}.mojo`.
10//! Each collective is a small algorithm that uses
11//! [`SymmetricTransport::put`] / `get` / `barrier` to move
12//! tensors between ranks. Pure data layer — the transport is
13//! pluggable, so the same algorithm runs against
14//! `LocalTransport` (single-machine emulation) today and a
15//! future MPI / NVSHMEM transport on a real cluster.
16//!
17//! Element-wise reductions parameterized by [`ReduceKind`].
18//! All algorithms operate on `f32` slices today; quantized fp8
19//! variants from MAX (`allreduce_residual_rmsnorm_fp8`) are
20//! per-precision impls that slot in once a quantized model
21//! lands.
22
23use crate::symmetric::{CollectiveError, Rank, SymmetricBuffer, SymmetricTransport};
24
25/// Element-wise reduction operator for collectives.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ReduceKind {
28    Sum,
29    Mean,
30    Max,
31    Min,
32}
33
34impl ReduceKind {
35    pub(crate) fn fold(self, acc: f32, x: f32) -> f32 {
36        match self {
37            Self::Sum => acc + x,
38            Self::Mean => acc + x, // divide at the end
39            Self::Max => acc.max(x),
40            Self::Min => acc.min(x),
41        }
42    }
43    pub(crate) fn finalize(self, acc: f32, n: usize) -> f32 {
44        match self {
45            Self::Mean => acc / (n as f32),
46            _ => acc,
47        }
48    }
49    fn identity(self) -> f32 {
50        match self {
51            Self::Sum | Self::Mean => 0.0,
52            Self::Max => f32::NEG_INFINITY,
53            Self::Min => f32::INFINITY,
54        }
55    }
56}
57
58/// AllReduce: every rank ends up with `op({values from every rank})`.
59///
60/// Naïve algorithm — every rank reads every other rank's slot
61/// and combines. O(n_ranks²) communications, fine for small
62/// rank counts. Real impls use ring-reduce / tree-reduce; we
63/// pick simplicity since LocalTransport's "comm" is memcpy.
64///
65/// `local` carries this rank's contribution on entry; on exit
66/// it carries the reduced result. Element count must match the
67/// per-rank `len` of `buf` (in bytes: 4 * elements).
68pub fn all_reduce<T: SymmetricTransport>(
69    transport: &T,
70    buf: SymmetricBuffer, // shape (offset, len) shared across ranks
71    local: &mut [f32],
72    op: ReduceKind,
73) -> Result<(), CollectiveError> {
74    let elems = buf.len / 4;
75    if local.len() != elems {
76        return Err(CollectiveError::LengthMismatch {
77            expected: elems,
78            got: local.len(),
79        });
80    }
81    let me = transport.this_rank();
82    let n = transport.num_ranks();
83
84    // Step 1: write our contribution into our slot.
85    let our_buf = SymmetricBuffer {
86        rank: me,
87        offset: buf.offset,
88        len: buf.len,
89    };
90    let bytes = unsafe { std::slice::from_raw_parts(local.as_ptr() as *const u8, buf.len) };
91    transport.put(our_buf, bytes)?;
92
93    // Step 2: barrier so every rank has written its slot.
94    transport.barrier()?;
95
96    // Step 3: read every rank's slot and reduce.
97    let mut acc: Vec<f32> = vec![op.identity(); elems];
98    let mut scratch_bytes = vec![0u8; buf.len];
99    for r in 0..n {
100        let src = SymmetricBuffer {
101            rank: Rank(r),
102            offset: buf.offset,
103            len: buf.len,
104        };
105        transport.get(src, &mut scratch_bytes)?;
106        let scratch = rlx_ir::bytes::decode_le::<f32>(&scratch_bytes[..elems * 4]);
107        for (i, &v) in scratch.iter().enumerate() {
108            acc[i] = op.fold(acc[i], v);
109        }
110    }
111    for v in acc.iter_mut() {
112        *v = op.finalize(*v, n as usize);
113    }
114    local.copy_from_slice(&acc);
115    Ok(())
116}
117
118/// AllGather: every rank ends up with the concatenation of all
119/// per-rank `local` slices, in rank order.
120///
121/// `local.len()` is the per-rank chunk size; `output.len()` must
122/// equal `num_ranks * local.len()`. Output rank `r`'s
123/// contribution lands at `output[r*local.len()..(r+1)*local.len()]`.
124pub fn all_gather<T: SymmetricTransport>(
125    transport: &T,
126    buf: SymmetricBuffer, // per-rank slot
127    local: &[f32],
128    output: &mut [f32],
129) -> Result<(), CollectiveError> {
130    let elems_per_rank = buf.len / 4;
131    let n = transport.num_ranks() as usize;
132    if local.len() != elems_per_rank {
133        return Err(CollectiveError::LengthMismatch {
134            expected: elems_per_rank,
135            got: local.len(),
136        });
137    }
138    if output.len() != n * elems_per_rank {
139        return Err(CollectiveError::LengthMismatch {
140            expected: n * elems_per_rank,
141            got: output.len(),
142        });
143    }
144
145    let me = transport.this_rank();
146    let our_buf = SymmetricBuffer {
147        rank: me,
148        offset: buf.offset,
149        len: buf.len,
150    };
151    let bytes = unsafe { std::slice::from_raw_parts(local.as_ptr() as *const u8, buf.len) };
152    transport.put(our_buf, bytes)?;
153    transport.barrier()?;
154
155    let mut scratch_bytes = vec![0u8; buf.len];
156    for r in 0..n {
157        let src = SymmetricBuffer {
158            rank: Rank(r as u32),
159            offset: buf.offset,
160            len: buf.len,
161        };
162        transport.get(src, &mut scratch_bytes)?;
163        let chunk = rlx_ir::bytes::decode_le::<f32>(&scratch_bytes[..elems_per_rank * 4]);
164        let dst_start = r * elems_per_rank;
165        output[dst_start..dst_start + elems_per_rank].copy_from_slice(chunk.as_ref());
166    }
167    Ok(())
168}
169
170/// ReduceScatter: equivalent to AllReduce followed by partition
171/// — every rank ends up with one `chunk_size`-element slice of
172/// the reduced result. Rank `r` gets element indices
173/// `[r*chunk_size, (r+1)*chunk_size)`.
174///
175/// `local.len()` is the full vector (`num_ranks * chunk_size`);
176/// `output.len()` is `chunk_size`.
177pub fn reduce_scatter<T: SymmetricTransport>(
178    transport: &T,
179    buf: SymmetricBuffer,
180    local: &[f32],
181    output: &mut [f32],
182    op: ReduceKind,
183) -> Result<(), CollectiveError> {
184    let total = buf.len / 4;
185    let n = transport.num_ranks() as usize;
186    if !total.is_multiple_of(n) {
187        return Err(CollectiveError::TransportError {
188            reason: format!("reduce_scatter: total elements {total} not divisible by {n} ranks"),
189        });
190    }
191    let chunk = total / n;
192    if local.len() != total {
193        return Err(CollectiveError::LengthMismatch {
194            expected: total,
195            got: local.len(),
196        });
197    }
198    if output.len() != chunk {
199        return Err(CollectiveError::LengthMismatch {
200            expected: chunk,
201            got: output.len(),
202        });
203    }
204
205    // Reuse all_reduce — works on a scratch copy of `local`,
206    // then this rank picks its slice.
207    let me = transport.this_rank().0 as usize;
208    let mut full = local.to_vec();
209    all_reduce(transport, buf, &mut full, op)?;
210    output.copy_from_slice(&full[me * chunk..(me + 1) * chunk]);
211    Ok(())
212}
213
214/// Bandwidth-optimal **ring** all-reduce over a symmetric heap — the standard
215/// RDMA collective. Each rank moves only ~`2·(N-1)/N` of the vector (vs the
216/// naïve [`all_reduce`]'s `O(N²)`), so it scales to large gradient tensors on
217/// real RDMA fabrics (Thunderbolt/jaccl, InfiniBand). The algorithm is a
218/// reduce-scatter ring followed by an all-gather ring (Baidu / NCCL pattern),
219/// expressed entirely with one-sided `put` + `barrier`.
220///
221/// Requires `local.len() % num_ranks == 0` (pad upstream otherwise).
222/// `mailbox_offset` names a `chunk`-sized scratch region present in every
223/// rank's heap, where `chunk = local.len() / num_ranks` elements; the heap
224/// must hold at least `mailbox_offset + chunk*4` bytes. `local` carries this
225/// rank's contribution on entry and the reduced result on exit.
226pub fn ring_all_reduce<T: SymmetricTransport>(
227    transport: &T,
228    mailbox_offset: usize,
229    local: &mut [f32],
230    op: ReduceKind,
231) -> Result<(), CollectiveError> {
232    let n = transport.num_ranks() as usize;
233    let total = local.len();
234    if n <= 1 {
235        return Ok(());
236    }
237    if !total.is_multiple_of(n) {
238        return Err(CollectiveError::TransportError {
239            reason: format!("ring_all_reduce: len {total} not divisible by {n} ranks"),
240        });
241    }
242    let chunk = total / n;
243    let chunk_bytes = chunk * 4;
244    let me = transport.this_rank().0 as usize;
245    let right = (me + 1) % n;
246    let mailbox = |rank: usize| SymmetricBuffer {
247        rank: Rank(rank as u32),
248        offset: mailbox_offset,
249        len: chunk_bytes,
250    };
251    let mut recv = vec![0u8; chunk_bytes];
252
253    let send_chunk = |local: &[f32], idx: usize| -> *const u8 {
254        local[idx * chunk..idx * chunk + chunk].as_ptr() as *const u8
255    };
256
257    // Reduce-scatter: after N-1 steps, rank `me` owns the fully-reduced
258    // (summed) chunk at index (me + 1) % n.
259    for step in 0..n - 1 {
260        let send_idx = (me + n - step) % n;
261        let recv_idx = (me + n - step - 1) % n;
262        let src = unsafe { std::slice::from_raw_parts(send_chunk(local, send_idx), chunk_bytes) };
263        transport.put(mailbox(right), src)?;
264        transport.barrier()?;
265        transport.get(mailbox(me), &mut recv)?;
266        let incoming = rlx_ir::bytes::decode_le::<f32>(&recv[..chunk * 4]);
267        let d = recv_idx * chunk;
268        for i in 0..chunk {
269            local[d + i] = op.fold(local[d + i], incoming[i]);
270        }
271        transport.barrier()?;
272    }
273
274    // All-gather: propagate the reduced chunks around the ring (overwrite).
275    for step in 0..n - 1 {
276        let send_idx = (me + n + 1 - step) % n;
277        let recv_idx = (me + n - step) % n;
278        let src = unsafe { std::slice::from_raw_parts(send_chunk(local, send_idx), chunk_bytes) };
279        transport.put(mailbox(right), src)?;
280        transport.barrier()?;
281        transport.get(mailbox(me), &mut recv)?;
282        let incoming = rlx_ir::bytes::decode_le::<f32>(&recv[..chunk * 4]);
283        let d = recv_idx * chunk;
284        local[d..d + chunk].copy_from_slice(incoming.as_ref());
285        transport.barrier()?;
286    }
287
288    if op == ReduceKind::Mean {
289        for v in local.iter_mut() {
290            *v /= n as f32;
291        }
292    }
293    Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::symmetric::LocalTransport;
300
301    /// 4 ranks, each contributes [r+1, r+1, r+1, r+1] (so ranks
302    /// hold 1, 2, 3, 4). After AllReduce::Sum each rank should
303    /// see [10, 10, 10, 10].
304    #[test]
305    fn all_reduce_sum_across_4_ranks() {
306        let n_ranks = 4u32;
307        let elems = 4usize;
308        let bytes = elems * 4;
309        let ts = LocalTransport::fan_out(n_ranks, bytes);
310        let _buf = SymmetricBuffer {
311            rank: Rank(0),
312            offset: 0,
313            len: bytes,
314        };
315
316        // Each rank's local data + reduced output.
317        let mut state: Vec<Vec<f32>> = (0..n_ranks).map(|r| vec![(r + 1) as f32; elems]).collect();
318
319        // Run all_reduce sequentially; LocalTransport's barrier
320        // counter accumulates across calls, so n_ranks calls
321        // satisfy each barrier. We pre-write contributions for
322        // every rank so the barrier-then-get phase sees data.
323        // Step 1: each rank puts its contribution.
324        for (r, t) in ts.iter().enumerate() {
325            let our_buf = SymmetricBuffer {
326                rank: Rank(r as u32),
327                offset: 0,
328                len: bytes,
329            };
330            let raw = unsafe { std::slice::from_raw_parts(state[r].as_ptr() as *const u8, bytes) };
331            t.put(our_buf, raw).unwrap();
332        }
333        // Step 2: each rank reduces. We can't use the public
334        // all_reduce since it does its own put + barrier (which
335        // double-counts after our manual put above). Inline the
336        // reduce step instead.
337        for (r, t) in ts.iter().enumerate() {
338            let mut acc = vec![0f32; elems];
339            let mut scratch = vec![0u8; bytes];
340            for src_r in 0..n_ranks {
341                let src = SymmetricBuffer {
342                    rank: Rank(src_r),
343                    offset: 0,
344                    len: bytes,
345                };
346                t.get(src, &mut scratch).unwrap();
347                let view = rlx_ir::bytes::decode_le::<f32>(&scratch[..elems * 4]);
348                for (i, &v) in view.iter().enumerate() {
349                    acc[i] += v;
350                }
351            }
352            state[r] = acc;
353        }
354
355        for (r, slot) in state.iter().enumerate() {
356            assert_eq!(slot, &vec![10.0; elems], "rank {r} after all-reduce");
357        }
358    }
359
360    #[test]
361    fn all_gather_concatenates_in_rank_order() {
362        let n_ranks = 3u32;
363        let chunk = 2usize;
364        let bytes = chunk * 4;
365        let ts = LocalTransport::fan_out(n_ranks, bytes);
366        let _buf = SymmetricBuffer {
367            rank: Rank(0),
368            offset: 0,
369            len: bytes,
370        };
371
372        // Each rank contributes [10*r, 10*r+1].
373        let local: Vec<Vec<f32>> = (0..n_ranks)
374            .map(|r| {
375                let r = r as f32;
376                vec![10.0 * r, 10.0 * r + 1.0]
377            })
378            .collect();
379
380        // Step 1: each rank puts.
381        for (r, t) in ts.iter().enumerate() {
382            let our_buf = SymmetricBuffer {
383                rank: Rank(r as u32),
384                offset: 0,
385                len: bytes,
386            };
387            let raw = unsafe { std::slice::from_raw_parts(local[r].as_ptr() as *const u8, bytes) };
388            t.put(our_buf, raw).unwrap();
389        }
390        // Step 2: each rank gathers.
391        for (r_idx, t) in ts.iter().enumerate() {
392            let mut output = vec![0f32; n_ranks as usize * chunk];
393            let mut scratch = vec![0u8; bytes];
394            for src_r in 0..n_ranks {
395                let src = SymmetricBuffer {
396                    rank: Rank(src_r),
397                    offset: 0,
398                    len: bytes,
399                };
400                t.get(src, &mut scratch).unwrap();
401                let view = rlx_ir::bytes::decode_le::<f32>(&scratch[..chunk * 4]);
402                let dst_start = src_r as usize * chunk;
403                output[dst_start..dst_start + chunk].copy_from_slice(view.as_ref());
404            }
405            assert_eq!(
406                output,
407                vec![0.0, 1.0, 10.0, 11.0, 20.0, 21.0],
408                "rank {r_idx} after all-gather"
409            );
410        }
411    }
412
413    #[test]
414    fn ring_all_reduce_sum_matches_expected_multithread() {
415        // N threads, each a rank sharing one heap + barrier; rank r holds
416        // [r*10 + i]. After ring all-reduce(Sum) every rank holds the
417        // element-wise sum. Exercises the reusable barrier across all the
418        // ring's reduce-scatter + all-gather steps.
419        for &n in &[2u32, 4] {
420            let total = 8usize; // divisible by 2 and 4
421            let chunk_bytes = (total / n as usize) * 4;
422            let ts = LocalTransport::fan_out(n, chunk_bytes);
423
424            let expected: Vec<f32> = (0..total)
425                .map(|i| (0..n).map(|r| r as f32 * 10.0 + i as f32).sum())
426                .collect();
427
428            let handles: Vec<_> = ts
429                .into_iter()
430                .enumerate()
431                .map(|(r, t)| {
432                    std::thread::spawn(move || {
433                        let mut local: Vec<f32> =
434                            (0..total).map(|i| r as f32 * 10.0 + i as f32).collect();
435                        ring_all_reduce(&t, 0, &mut local, ReduceKind::Sum).unwrap();
436                        local
437                    })
438                })
439                .collect();
440            for h in handles {
441                assert_eq!(h.join().unwrap(), expected, "n={n}");
442            }
443        }
444    }
445
446    #[test]
447    fn ring_all_reduce_mean_divides() {
448        let n = 4u32;
449        let total = 8usize;
450        let chunk_bytes = (total / n as usize) * 4;
451        let ts = LocalTransport::fan_out(n, chunk_bytes);
452        let handles: Vec<_> = ts
453            .into_iter()
454            .enumerate()
455            .map(|(r, t)| {
456                std::thread::spawn(move || {
457                    let mut local = vec![r as f32 + 1.0; total]; // 1,2,3,4
458                    ring_all_reduce(&t, 0, &mut local, ReduceKind::Mean).unwrap();
459                    local
460                })
461            })
462            .collect();
463        for h in handles {
464            assert_eq!(h.join().unwrap(), vec![2.5f32; total]); // mean(1,2,3,4)
465        }
466    }
467
468    #[test]
469    fn reduce_kind_max_takes_pointwise_max() {
470        let mut acc = ReduceKind::Max.identity();
471        for v in [3.0, 1.0, 7.0, -2.0] {
472            acc = ReduceKind::Max.fold(acc, v);
473        }
474        assert_eq!(acc, 7.0);
475    }
476
477    #[test]
478    fn reduce_kind_mean_divides_at_finalize() {
479        let mut acc = ReduceKind::Mean.identity();
480        for v in [2.0, 4.0, 6.0, 8.0] {
481            acc = ReduceKind::Mean.fold(acc, v);
482        }
483        assert_eq!(ReduceKind::Mean.finalize(acc, 4), 5.0);
484    }
485}