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 =
107            unsafe { std::slice::from_raw_parts(scratch_bytes.as_ptr() as *const f32, elems) };
108        for (i, &v) in scratch.iter().enumerate() {
109            acc[i] = op.fold(acc[i], v);
110        }
111    }
112    for v in acc.iter_mut() {
113        *v = op.finalize(*v, n as usize);
114    }
115    local.copy_from_slice(&acc);
116    Ok(())
117}
118
119/// AllGather: every rank ends up with the concatenation of all
120/// per-rank `local` slices, in rank order.
121///
122/// `local.len()` is the per-rank chunk size; `output.len()` must
123/// equal `num_ranks * local.len()`. Output rank `r`'s
124/// contribution lands at `output[r*local.len()..(r+1)*local.len()]`.
125pub fn all_gather<T: SymmetricTransport>(
126    transport: &T,
127    buf: SymmetricBuffer, // per-rank slot
128    local: &[f32],
129    output: &mut [f32],
130) -> Result<(), CollectiveError> {
131    let elems_per_rank = buf.len / 4;
132    let n = transport.num_ranks() as usize;
133    if local.len() != elems_per_rank {
134        return Err(CollectiveError::LengthMismatch {
135            expected: elems_per_rank,
136            got: local.len(),
137        });
138    }
139    if output.len() != n * elems_per_rank {
140        return Err(CollectiveError::LengthMismatch {
141            expected: n * elems_per_rank,
142            got: output.len(),
143        });
144    }
145
146    let me = transport.this_rank();
147    let our_buf = SymmetricBuffer {
148        rank: me,
149        offset: buf.offset,
150        len: buf.len,
151    };
152    let bytes = unsafe { std::slice::from_raw_parts(local.as_ptr() as *const u8, buf.len) };
153    transport.put(our_buf, bytes)?;
154    transport.barrier()?;
155
156    let mut scratch_bytes = vec![0u8; buf.len];
157    for r in 0..n {
158        let src = SymmetricBuffer {
159            rank: Rank(r as u32),
160            offset: buf.offset,
161            len: buf.len,
162        };
163        transport.get(src, &mut scratch_bytes)?;
164        let chunk = unsafe {
165            std::slice::from_raw_parts(scratch_bytes.as_ptr() as *const f32, elems_per_rank)
166        };
167        let dst_start = r * elems_per_rank;
168        output[dst_start..dst_start + elems_per_rank].copy_from_slice(chunk);
169    }
170    Ok(())
171}
172
173/// ReduceScatter: equivalent to AllReduce followed by partition
174/// — every rank ends up with one `chunk_size`-element slice of
175/// the reduced result. Rank `r` gets element indices
176/// `[r*chunk_size, (r+1)*chunk_size)`.
177///
178/// `local.len()` is the full vector (`num_ranks * chunk_size`);
179/// `output.len()` is `chunk_size`.
180pub fn reduce_scatter<T: SymmetricTransport>(
181    transport: &T,
182    buf: SymmetricBuffer,
183    local: &[f32],
184    output: &mut [f32],
185    op: ReduceKind,
186) -> Result<(), CollectiveError> {
187    let total = buf.len / 4;
188    let n = transport.num_ranks() as usize;
189    if !total.is_multiple_of(n) {
190        return Err(CollectiveError::TransportError {
191            reason: format!("reduce_scatter: total elements {total} not divisible by {n} ranks"),
192        });
193    }
194    let chunk = total / n;
195    if local.len() != total {
196        return Err(CollectiveError::LengthMismatch {
197            expected: total,
198            got: local.len(),
199        });
200    }
201    if output.len() != chunk {
202        return Err(CollectiveError::LengthMismatch {
203            expected: chunk,
204            got: output.len(),
205        });
206    }
207
208    // Reuse all_reduce — works on a scratch copy of `local`,
209    // then this rank picks its slice.
210    let me = transport.this_rank().0 as usize;
211    let mut full = local.to_vec();
212    all_reduce(transport, buf, &mut full, op)?;
213    output.copy_from_slice(&full[me * chunk..(me + 1) * chunk]);
214    Ok(())
215}
216
217/// Bandwidth-optimal **ring** all-reduce over a symmetric heap — the standard
218/// RDMA collective. Each rank moves only ~`2·(N-1)/N` of the vector (vs the
219/// naïve [`all_reduce`]'s `O(N²)`), so it scales to large gradient tensors on
220/// real RDMA fabrics (Thunderbolt/jaccl, InfiniBand). The algorithm is a
221/// reduce-scatter ring followed by an all-gather ring (Baidu / NCCL pattern),
222/// expressed entirely with one-sided `put` + `barrier`.
223///
224/// Requires `local.len() % num_ranks == 0` (pad upstream otherwise).
225/// `mailbox_offset` names a `chunk`-sized scratch region present in every
226/// rank's heap, where `chunk = local.len() / num_ranks` elements; the heap
227/// must hold at least `mailbox_offset + chunk*4` bytes. `local` carries this
228/// rank's contribution on entry and the reduced result on exit.
229pub fn ring_all_reduce<T: SymmetricTransport>(
230    transport: &T,
231    mailbox_offset: usize,
232    local: &mut [f32],
233    op: ReduceKind,
234) -> Result<(), CollectiveError> {
235    let n = transport.num_ranks() as usize;
236    let total = local.len();
237    if n <= 1 {
238        return Ok(());
239    }
240    if !total.is_multiple_of(n) {
241        return Err(CollectiveError::TransportError {
242            reason: format!("ring_all_reduce: len {total} not divisible by {n} ranks"),
243        });
244    }
245    let chunk = total / n;
246    let chunk_bytes = chunk * 4;
247    let me = transport.this_rank().0 as usize;
248    let right = (me + 1) % n;
249    let mailbox = |rank: usize| SymmetricBuffer {
250        rank: Rank(rank as u32),
251        offset: mailbox_offset,
252        len: chunk_bytes,
253    };
254    let mut recv = vec![0u8; chunk_bytes];
255
256    let send_chunk = |local: &[f32], idx: usize| -> *const u8 {
257        local[idx * chunk..idx * chunk + chunk].as_ptr() as *const u8
258    };
259
260    // Reduce-scatter: after N-1 steps, rank `me` owns the fully-reduced
261    // (summed) chunk at index (me + 1) % n.
262    for step in 0..n - 1 {
263        let send_idx = (me + n - step) % n;
264        let recv_idx = (me + n - step - 1) % n;
265        let src = unsafe { std::slice::from_raw_parts(send_chunk(local, send_idx), chunk_bytes) };
266        transport.put(mailbox(right), src)?;
267        transport.barrier()?;
268        transport.get(mailbox(me), &mut recv)?;
269        let incoming = unsafe { std::slice::from_raw_parts(recv.as_ptr() as *const f32, chunk) };
270        let d = recv_idx * chunk;
271        for i in 0..chunk {
272            local[d + i] = op.fold(local[d + i], incoming[i]);
273        }
274        transport.barrier()?;
275    }
276
277    // All-gather: propagate the reduced chunks around the ring (overwrite).
278    for step in 0..n - 1 {
279        let send_idx = (me + n + 1 - step) % n;
280        let recv_idx = (me + n - step) % n;
281        let src = unsafe { std::slice::from_raw_parts(send_chunk(local, send_idx), chunk_bytes) };
282        transport.put(mailbox(right), src)?;
283        transport.barrier()?;
284        transport.get(mailbox(me), &mut recv)?;
285        let incoming = unsafe { std::slice::from_raw_parts(recv.as_ptr() as *const f32, chunk) };
286        let d = recv_idx * chunk;
287        local[d..d + chunk].copy_from_slice(incoming);
288        transport.barrier()?;
289    }
290
291    if op == ReduceKind::Mean {
292        for v in local.iter_mut() {
293            *v /= n as f32;
294        }
295    }
296    Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::symmetric::LocalTransport;
303
304    /// 4 ranks, each contributes [r+1, r+1, r+1, r+1] (so ranks
305    /// hold 1, 2, 3, 4). After AllReduce::Sum each rank should
306    /// see [10, 10, 10, 10].
307    #[test]
308    fn all_reduce_sum_across_4_ranks() {
309        let n_ranks = 4u32;
310        let elems = 4usize;
311        let bytes = elems * 4;
312        let ts = LocalTransport::fan_out(n_ranks, bytes);
313        let _buf = SymmetricBuffer {
314            rank: Rank(0),
315            offset: 0,
316            len: bytes,
317        };
318
319        // Each rank's local data + reduced output.
320        let mut state: Vec<Vec<f32>> = (0..n_ranks).map(|r| vec![(r + 1) as f32; elems]).collect();
321
322        // Run all_reduce sequentially; LocalTransport's barrier
323        // counter accumulates across calls, so n_ranks calls
324        // satisfy each barrier. We pre-write contributions for
325        // every rank so the barrier-then-get phase sees data.
326        // Step 1: each rank puts its contribution.
327        for (r, t) in ts.iter().enumerate() {
328            let our_buf = SymmetricBuffer {
329                rank: Rank(r as u32),
330                offset: 0,
331                len: bytes,
332            };
333            let raw = unsafe { std::slice::from_raw_parts(state[r].as_ptr() as *const u8, bytes) };
334            t.put(our_buf, raw).unwrap();
335        }
336        // Step 2: each rank reduces. We can't use the public
337        // all_reduce since it does its own put + barrier (which
338        // double-counts after our manual put above). Inline the
339        // reduce step instead.
340        for (r, t) in ts.iter().enumerate() {
341            let mut acc = vec![0f32; elems];
342            let mut scratch = vec![0u8; bytes];
343            for src_r in 0..n_ranks {
344                let src = SymmetricBuffer {
345                    rank: Rank(src_r),
346                    offset: 0,
347                    len: bytes,
348                };
349                t.get(src, &mut scratch).unwrap();
350                let view =
351                    unsafe { std::slice::from_raw_parts(scratch.as_ptr() as *const f32, elems) };
352                for (i, &v) in view.iter().enumerate() {
353                    acc[i] += v;
354                }
355            }
356            state[r] = acc;
357        }
358
359        for (r, slot) in state.iter().enumerate() {
360            assert_eq!(slot, &vec![10.0; elems], "rank {r} after all-reduce");
361        }
362    }
363
364    #[test]
365    fn all_gather_concatenates_in_rank_order() {
366        let n_ranks = 3u32;
367        let chunk = 2usize;
368        let bytes = chunk * 4;
369        let ts = LocalTransport::fan_out(n_ranks, bytes);
370        let _buf = SymmetricBuffer {
371            rank: Rank(0),
372            offset: 0,
373            len: bytes,
374        };
375
376        // Each rank contributes [10*r, 10*r+1].
377        let local: Vec<Vec<f32>> = (0..n_ranks)
378            .map(|r| {
379                let r = r as f32;
380                vec![10.0 * r, 10.0 * r + 1.0]
381            })
382            .collect();
383
384        // Step 1: each rank puts.
385        for (r, t) in ts.iter().enumerate() {
386            let our_buf = SymmetricBuffer {
387                rank: Rank(r as u32),
388                offset: 0,
389                len: bytes,
390            };
391            let raw = unsafe { std::slice::from_raw_parts(local[r].as_ptr() as *const u8, bytes) };
392            t.put(our_buf, raw).unwrap();
393        }
394        // Step 2: each rank gathers.
395        for (r_idx, t) in ts.iter().enumerate() {
396            let mut output = vec![0f32; n_ranks as usize * chunk];
397            let mut scratch = vec![0u8; bytes];
398            for src_r in 0..n_ranks {
399                let src = SymmetricBuffer {
400                    rank: Rank(src_r),
401                    offset: 0,
402                    len: bytes,
403                };
404                t.get(src, &mut scratch).unwrap();
405                let view =
406                    unsafe { std::slice::from_raw_parts(scratch.as_ptr() as *const f32, chunk) };
407                let dst_start = src_r as usize * chunk;
408                output[dst_start..dst_start + chunk].copy_from_slice(view);
409            }
410            assert_eq!(
411                output,
412                vec![0.0, 1.0, 10.0, 11.0, 20.0, 21.0],
413                "rank {r_idx} after all-gather"
414            );
415        }
416    }
417
418    #[test]
419    fn ring_all_reduce_sum_matches_expected_multithread() {
420        // N threads, each a rank sharing one heap + barrier; rank r holds
421        // [r*10 + i]. After ring all-reduce(Sum) every rank holds the
422        // element-wise sum. Exercises the reusable barrier across all the
423        // ring's reduce-scatter + all-gather steps.
424        for &n in &[2u32, 4] {
425            let total = 8usize; // divisible by 2 and 4
426            let chunk_bytes = (total / n as usize) * 4;
427            let ts = LocalTransport::fan_out(n, chunk_bytes);
428
429            let expected: Vec<f32> = (0..total)
430                .map(|i| (0..n).map(|r| r as f32 * 10.0 + i as f32).sum())
431                .collect();
432
433            let handles: Vec<_> = ts
434                .into_iter()
435                .enumerate()
436                .map(|(r, t)| {
437                    std::thread::spawn(move || {
438                        let mut local: Vec<f32> =
439                            (0..total).map(|i| r as f32 * 10.0 + i as f32).collect();
440                        ring_all_reduce(&t, 0, &mut local, ReduceKind::Sum).unwrap();
441                        local
442                    })
443                })
444                .collect();
445            for h in handles {
446                assert_eq!(h.join().unwrap(), expected, "n={n}");
447            }
448        }
449    }
450
451    #[test]
452    fn ring_all_reduce_mean_divides() {
453        let n = 4u32;
454        let total = 8usize;
455        let chunk_bytes = (total / n as usize) * 4;
456        let ts = LocalTransport::fan_out(n, chunk_bytes);
457        let handles: Vec<_> = ts
458            .into_iter()
459            .enumerate()
460            .map(|(r, t)| {
461                std::thread::spawn(move || {
462                    let mut local = vec![r as f32 + 1.0; total]; // 1,2,3,4
463                    ring_all_reduce(&t, 0, &mut local, ReduceKind::Mean).unwrap();
464                    local
465                })
466            })
467            .collect();
468        for h in handles {
469            assert_eq!(h.join().unwrap(), vec![2.5f32; total]); // mean(1,2,3,4)
470        }
471    }
472
473    #[test]
474    fn reduce_kind_max_takes_pointwise_max() {
475        let mut acc = ReduceKind::Max.identity();
476        for v in [3.0, 1.0, 7.0, -2.0] {
477            acc = ReduceKind::Max.fold(acc, v);
478        }
479        assert_eq!(acc, 7.0);
480    }
481
482    #[test]
483    fn reduce_kind_mean_divides_at_finalize() {
484        let mut acc = ReduceKind::Mean.identity();
485        for v in [2.0, 4.0, 6.0, 8.0] {
486            acc = ReduceKind::Mean.fold(acc, v);
487        }
488        assert_eq!(ReduceKind::Mean.finalize(acc, 4), 5.0);
489    }
490}