Skip to main content

rlx_driver/
transport.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//! Two-sided point-to-point transport + process group (plan #12/#49,
17//! multi-node extension).
18//!
19//! [`SymmetricTransport`](crate::symmetric::SymmetricTransport) models
20//! *one-sided* `put`/`get` — the right shape for RDMA and for the
21//! tensor-parallel all-reduce that lives *inside* a compiled graph.
22//! Pipeline parallelism wants the complementary *two-sided* surface:
23//! rank `r` hands its hidden state to rank `r-1` with a matched
24//! `send`/`recv`. This module adds that surface ([`Transport`]) plus a
25//! [`ProcessGroup`] that layers tensor-shaped collectives
26//! (`all_reduce` / `all_gather` / `broadcast`) on top.
27//!
28//! The collectives here are the *gather-to-root* family: simple,
29//! deadlock-free over any correct [`Transport`], and good enough for
30//! the small rank counts (≤ 8) a few Thunderbolt-linked machines hit.
31//! Bandwidth-optimal ring variants are a later optimization — the
32//! `ProcessGroup` API does not change when they land.
33//!
34//! Three transports implement [`Transport`]:
35//!   - [`TcpTransport`](crate::net::TcpTransport) — portable TCP, works
36//!     over Ethernet and the Thunderbolt Bridge IP link.
37//!   - [`ThunderboltTransport`](crate::net::ThunderboltTransport) —
38//!     TCP pinned to the Thunderbolt interface, also exposing the
39//!     symmetric one-sided heap.
40//!   - `MlxTransport` (in `rlx-mlx`) — delegates to MLX's `jaccl`
41//!     distributed backend over FFI.
42
43use crate::collective::ReduceKind;
44use crate::symmetric::CollectiveError;
45use std::sync::Arc;
46
47/// Tags `>= TAG_RESERVED_BASE` are reserved for the collective and
48/// barrier machinery. User point-to-point traffic (pipeline-parallel
49/// hidden states) must use tags below this.
50pub const TAG_RESERVED_BASE: u32 = 0xFFF0_0000;
51const TAG_BARRIER: u32 = TAG_RESERVED_BASE;
52const TAG_ALL_REDUCE: u32 = TAG_RESERVED_BASE + 1;
53const TAG_ALL_GATHER: u32 = TAG_RESERVED_BASE + 2;
54const TAG_BROADCAST: u32 = TAG_RESERVED_BASE + 3;
55
56/// Two-sided, tagged, byte-oriented point-to-point transport between
57/// `world_size` ranks.
58///
59/// Implementations must be safe to share across threads (`Send +
60/// Sync`) and must let a reader on one rank pull a message that a
61/// writer on another rank pushed with the same `(src, tag)` — the
62/// `send`/`recv` are *matched*, like MPI. `recv` learns the payload
63/// length from the wire, so callers need not know it in advance.
64pub trait Transport: Send + Sync {
65    /// This process's rank in `0..world_size`.
66    fn rank(&self) -> u32;
67    /// Total number of participating ranks.
68    fn world_size(&self) -> u32;
69
70    /// Send `bytes` to rank `to`, tagged `tag`. Returns once the bytes
71    /// are handed to the transport (not necessarily delivered).
72    fn send_bytes(&self, to: u32, tag: u32, bytes: &[u8]) -> Result<(), CollectiveError>;
73
74    /// Block until a message with matching `(from, tag)` arrives, and
75    /// return its payload.
76    fn recv_bytes(&self, from: u32, tag: u32) -> Result<Vec<u8>, CollectiveError>;
77
78    /// Block until every rank has reached this barrier.
79    ///
80    /// Default is a gather-to-root rendezvous over `send`/`recv`: every
81    /// non-root sends a token to rank 0 and waits for the release; rank
82    /// 0 collects all tokens then releases everyone. Lockstep is
83    /// preserved across repeated barriers because a non-root cannot
84    /// enter the next barrier until it observes the current release.
85    fn barrier(&self) -> Result<(), CollectiveError> {
86        default_barrier(self)
87    }
88}
89
90/// Gather-to-root barrier usable by any [`Transport`]. Pulled out as a
91/// free function so trait impls can reuse it from an overridden
92/// `barrier` if they want to wrap it.
93pub fn default_barrier<T: Transport + ?Sized>(t: &T) -> Result<(), CollectiveError> {
94    let n = t.world_size();
95    if n <= 1 {
96        return Ok(());
97    }
98    let me = t.rank();
99    if me == 0 {
100        for r in 1..n {
101            t.recv_bytes(r, TAG_BARRIER)?;
102        }
103        for r in 1..n {
104            t.send_bytes(r, TAG_BARRIER, &[1u8])?;
105        }
106    } else {
107        t.send_bytes(0, TAG_BARRIER, &[1u8])?;
108        t.recv_bytes(0, TAG_BARRIER)?;
109    }
110    Ok(())
111}
112
113/// Little-endian flatten of an `f32` slice. Apple Silicon and x86 are
114/// both little-endian; we fix LE on the wire so a future big-endian
115/// rank would still interoperate.
116fn f32_to_le_bytes(data: &[f32]) -> Vec<u8> {
117    let mut out = Vec::with_capacity(data.len() * 4);
118    for &x in data {
119        out.extend_from_slice(&x.to_le_bytes());
120    }
121    out
122}
123
124/// Inverse of [`f32_to_le_bytes`]. Errors if `bytes.len()` is not a
125/// multiple of 4.
126fn le_bytes_to_f32(bytes: &[u8]) -> Result<Vec<f32>, CollectiveError> {
127    if !bytes.len().is_multiple_of(4) {
128        return Err(CollectiveError::TransportError {
129            reason: format!("recv payload {} bytes is not a multiple of 4", bytes.len()),
130        });
131    }
132    Ok(bytes
133        .chunks_exact(4)
134        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
135        .collect())
136}
137
138fn combine(op: ReduceKind, a: f32, b: f32) -> f32 {
139    match op {
140        ReduceKind::Sum | ReduceKind::Mean => a + b,
141        ReduceKind::Max => a.max(b),
142        ReduceKind::Min => a.min(b),
143    }
144}
145
146fn finalize(op: ReduceKind, acc: f32, n: u32) -> f32 {
147    match op {
148        ReduceKind::Mean => acc / (n as f32),
149        _ => acc,
150    }
151}
152
153/// A handle to the collective-communication world: a rank, a size, and
154/// the transport that connects them.
155///
156/// Wraps an `Arc<dyn Transport>` so the same group can be cloned into
157/// the generator loop, the weight loader, and (later) per-layer
158/// collective ops without re-establishing connections.
159#[derive(Clone)]
160pub struct ProcessGroup {
161    transport: Arc<dyn Transport>,
162}
163
164impl ProcessGroup {
165    pub fn new(transport: Arc<dyn Transport>) -> Self {
166        Self { transport }
167    }
168
169    pub fn rank(&self) -> u32 {
170        self.transport.rank()
171    }
172
173    pub fn world_size(&self) -> u32 {
174        self.transport.world_size()
175    }
176
177    /// `true` on the lowest rank — the canonical place to print, sample,
178    /// or own the final logits in a pipeline.
179    pub fn is_leader(&self) -> bool {
180        self.rank() == 0
181    }
182
183    pub fn transport(&self) -> &Arc<dyn Transport> {
184        &self.transport
185    }
186
187    pub fn barrier(&self) -> Result<(), CollectiveError> {
188        self.transport.barrier()
189    }
190
191    // ---- point-to-point (pipeline parallel) --------------------------
192
193    /// Send a hidden-state (or any) tensor to `to`. `tag` must be below
194    /// [`TAG_RESERVED_BASE`] (those tags are reserved for collectives).
195    pub fn send_f32(&self, to: u32, tag: u32, data: &[f32]) -> Result<(), CollectiveError> {
196        debug_assert!(tag < TAG_RESERVED_BASE, "tag collides with collective tags");
197        self.send_f32_tagged(to, tag, data)
198    }
199
200    /// Receive a tensor from `from`. Length is taken from the wire.
201    pub fn recv_f32(&self, from: u32, tag: u32) -> Result<Vec<f32>, CollectiveError> {
202        debug_assert!(tag < TAG_RESERVED_BASE, "tag collides with collective tags");
203        self.recv_f32_tagged(from, tag)
204    }
205
206    /// Internal f32 send with no reserved-tag guard — collectives use
207    /// the reserved tags legitimately.
208    fn send_f32_tagged(&self, to: u32, tag: u32, data: &[f32]) -> Result<(), CollectiveError> {
209        self.transport.send_bytes(to, tag, &f32_to_le_bytes(data))
210    }
211
212    fn recv_f32_tagged(&self, from: u32, tag: u32) -> Result<Vec<f32>, CollectiveError> {
213        le_bytes_to_f32(&self.transport.recv_bytes(from, tag)?)
214    }
215
216    // ---- collectives (tensor parallel) -------------------------------
217
218    /// In-place all-reduce: on return every rank holds
219    /// `op({each rank's input})`. All ranks must pass the same length.
220    ///
221    /// Gather-to-root: non-roots ship their vector to rank 0, rank 0
222    /// reduces and ships the result back. O(n) at the root — fine for a
223    /// handful of nodes; swap in ring-reduce later without touching
224    /// callers.
225    pub fn all_reduce(&self, data: &mut [f32], op: ReduceKind) -> Result<(), CollectiveError> {
226        let n = self.world_size();
227        if n <= 1 {
228            for v in data.iter_mut() {
229                *v = finalize(op, *v, n.max(1));
230            }
231            return Ok(());
232        }
233        let elems = data.len();
234        if self.rank() == 0 {
235            let mut acc = data.to_vec();
236            for r in 1..n {
237                let other = self.recv_f32_tagged(r, TAG_ALL_REDUCE)?;
238                if other.len() != elems {
239                    return Err(CollectiveError::LengthMismatch {
240                        expected: elems,
241                        got: other.len(),
242                    });
243                }
244                for i in 0..elems {
245                    acc[i] = combine(op, acc[i], other[i]);
246                }
247            }
248            for v in acc.iter_mut() {
249                *v = finalize(op, *v, n);
250            }
251            data.copy_from_slice(&acc);
252            for r in 1..n {
253                self.send_f32_tagged(r, TAG_ALL_REDUCE, &acc)?;
254            }
255        } else {
256            self.send_f32_tagged(0, TAG_ALL_REDUCE, data)?;
257            let res = self.recv_f32_tagged(0, TAG_ALL_REDUCE)?;
258            if res.len() != elems {
259                return Err(CollectiveError::LengthMismatch {
260                    expected: elems,
261                    got: res.len(),
262                });
263            }
264            data.copy_from_slice(&res);
265        }
266        Ok(())
267    }
268
269    /// All-gather: every rank contributes `local` (same length on every
270    /// rank) and receives the concatenation in rank order, length
271    /// `world_size * local.len()`.
272    pub fn all_gather(&self, local: &[f32]) -> Result<Vec<f32>, CollectiveError> {
273        let n = self.world_size();
274        let len = local.len();
275        if n <= 1 {
276            return Ok(local.to_vec());
277        }
278        if self.rank() == 0 {
279            let mut out = vec![0f32; n as usize * len];
280            out[..len].copy_from_slice(local);
281            for r in 1..n {
282                let chunk = self.recv_f32_tagged(r, TAG_ALL_GATHER)?;
283                if chunk.len() != len {
284                    return Err(CollectiveError::LengthMismatch {
285                        expected: len,
286                        got: chunk.len(),
287                    });
288                }
289                let start = r as usize * len;
290                out[start..start + len].copy_from_slice(&chunk);
291            }
292            for r in 1..n {
293                self.send_f32_tagged(r, TAG_ALL_GATHER, &out)?;
294            }
295            Ok(out)
296        } else {
297            self.send_f32_tagged(0, TAG_ALL_GATHER, local)?;
298            let out = self.recv_f32_tagged(0, TAG_ALL_GATHER)?;
299            if out.len() != n as usize * len {
300                return Err(CollectiveError::LengthMismatch {
301                    expected: n as usize * len,
302                    got: out.len(),
303                });
304            }
305            Ok(out)
306        }
307    }
308
309    /// Broadcast `data` from `root` to every rank. On non-root ranks
310    /// `data` is overwritten; its length must already match the root's.
311    pub fn broadcast(&self, root: u32, data: &mut [f32]) -> Result<(), CollectiveError> {
312        let n = self.world_size();
313        if n <= 1 {
314            return Ok(());
315        }
316        if self.rank() == root {
317            for r in 0..n {
318                if r != root {
319                    self.send_f32_tagged(r, TAG_BROADCAST, data)?;
320                }
321            }
322        } else {
323            let res = self.recv_f32_tagged(root, TAG_BROADCAST)?;
324            if res.len() != data.len() {
325                return Err(CollectiveError::LengthMismatch {
326                    expected: data.len(),
327                    got: res.len(),
328                });
329            }
330            data.copy_from_slice(&res);
331        }
332        Ok(())
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::collections::{HashMap, VecDeque};
340    use std::sync::{Condvar, Mutex};
341
342    /// In-process [`Transport`] over shared queues — exercises the
343    /// `ProcessGroup` collectives without sockets. One shared mailbox
344    /// keyed by `(dst, src, tag)`; `send` pushes, `recv` blocks-pops.
345    struct ChannelTransport {
346        rank: u32,
347        world: u32,
348        mailbox: Arc<(Mutex<HashMap<(u32, u32, u32), VecDeque<Vec<u8>>>>, Condvar)>,
349    }
350
351    impl ChannelTransport {
352        fn fan_out(world: u32) -> Vec<Arc<ChannelTransport>> {
353            let mailbox = Arc::new((Mutex::new(HashMap::new()), Condvar::new()));
354            (0..world)
355                .map(|r| {
356                    Arc::new(ChannelTransport {
357                        rank: r,
358                        world,
359                        mailbox: mailbox.clone(),
360                    })
361                })
362                .collect()
363        }
364    }
365
366    impl Transport for ChannelTransport {
367        fn rank(&self) -> u32 {
368            self.rank
369        }
370        fn world_size(&self) -> u32 {
371            self.world
372        }
373        fn send_bytes(&self, to: u32, tag: u32, bytes: &[u8]) -> Result<(), CollectiveError> {
374            let (m, cv) = &*self.mailbox;
375            m.lock()
376                .unwrap()
377                .entry((to, self.rank, tag))
378                .or_default()
379                .push_back(bytes.to_vec());
380            cv.notify_all();
381            Ok(())
382        }
383        fn recv_bytes(&self, from: u32, tag: u32) -> Result<Vec<u8>, CollectiveError> {
384            let (m, cv) = &*self.mailbox;
385            let mut guard = m.lock().unwrap();
386            loop {
387                if let Some(q) = guard.get_mut(&(self.rank, from, tag))
388                    && let Some(v) = q.pop_front()
389                {
390                    return Ok(v);
391                }
392                guard = cv.wait(guard).unwrap();
393            }
394        }
395    }
396
397    fn run_ranks<F>(world: u32, body: F) -> Vec<()>
398    where
399        F: Fn(ProcessGroup) + Send + Sync + 'static,
400    {
401        let ts = ChannelTransport::fan_out(world);
402        let body = Arc::new(body);
403        let handles: Vec<_> = ts
404            .into_iter()
405            .map(|t| {
406                let body = body.clone();
407                std::thread::spawn(move || body(ProcessGroup::new(t)))
408            })
409            .collect();
410        handles.into_iter().map(|h| h.join().unwrap()).collect()
411    }
412
413    #[test]
414    fn all_reduce_sum_matches_serial() {
415        run_ranks(4, |g| {
416            let r = g.rank() as f32;
417            let mut data = vec![r + 1.0; 3]; // ranks hold 1,2,3,4
418            g.all_reduce(&mut data, ReduceKind::Sum).unwrap();
419            assert_eq!(data, vec![10.0; 3], "rank {}", g.rank());
420        });
421    }
422
423    #[test]
424    fn all_gather_concatenates_in_rank_order() {
425        run_ranks(3, |g| {
426            let r = g.rank() as f32;
427            let out = g.all_gather(&[10.0 * r, 10.0 * r + 1.0]).unwrap();
428            assert_eq!(
429                out,
430                vec![0.0, 1.0, 10.0, 11.0, 20.0, 21.0],
431                "rank {}",
432                g.rank()
433            );
434        });
435    }
436
437    #[test]
438    fn broadcast_from_root_overwrites() {
439        run_ranks(4, |g| {
440            let mut data = if g.is_leader() {
441                vec![7.0, 8.0, 9.0]
442            } else {
443                vec![0.0, 0.0, 0.0]
444            };
445            g.broadcast(0, &mut data).unwrap();
446            assert_eq!(data, vec![7.0, 8.0, 9.0], "rank {}", g.rank());
447        });
448    }
449
450    #[test]
451    fn barrier_round_trips() {
452        run_ranks(4, |g| {
453            g.barrier().unwrap();
454            g.barrier().unwrap(); // repeated barriers stay in lockstep
455        });
456    }
457
458    #[test]
459    fn point_to_point_ring_handoff() {
460        // Pipeline-style: each rank sends its id to the next, lowest
461        // rank closes the ring. Verifies tagged matched send/recv.
462        run_ranks(3, |g| {
463            let n = g.world_size();
464            let me = g.rank();
465            let next = (me + 1) % n;
466            let prev = (me + n - 1) % n;
467            // Stagger to avoid all-send-then-all-recv lockstep issues:
468            // even ranks send first, odd ranks recv first.
469            if me % 2 == 0 {
470                g.send_f32(next, 1, &[me as f32]).unwrap();
471                let got = g.recv_f32(prev, 1).unwrap();
472                assert_eq!(got, vec![prev as f32]);
473            } else {
474                let got = g.recv_f32(prev, 1).unwrap();
475                assert_eq!(got, vec![prev as f32]);
476                g.send_f32(next, 1, &[me as f32]).unwrap();
477            }
478        });
479    }
480}