Skip to main content

rlx_driver/
collective.rs

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