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 half::slice::HalfFloatSliceExt;
46use rlx_ir::DType;
47use std::sync::Arc;
48use std::time::{Duration, Instant};
49
50/// Tags `>= TAG_RESERVED_BASE` are reserved for the collective and
51/// barrier machinery. User point-to-point traffic (pipeline-parallel
52/// hidden states) must use tags below this.
53pub const TAG_RESERVED_BASE: u32 = 0xFFF0_0000;
54const TAG_BARRIER: u32 = TAG_RESERVED_BASE;
55const TAG_ALL_REDUCE: u32 = TAG_RESERVED_BASE + 1;
56const TAG_ALL_GATHER: u32 = TAG_RESERVED_BASE + 2;
57const TAG_BROADCAST: u32 = TAG_RESERVED_BASE + 3;
58
59/// Two-sided, tagged, byte-oriented point-to-point transport between
60/// `world_size` ranks.
61///
62/// Implementations must be safe to share across threads (`Send +
63/// Sync`) and must let a reader on one rank pull a message that a
64/// writer on another rank pushed with the same `(src, tag)` — the
65/// `send`/`recv` are *matched*, like MPI. `recv` learns the payload
66/// length from the wire, so callers need not know it in advance.
67pub trait Transport: Send + Sync {
68    /// This process's rank in `0..world_size`.
69    fn rank(&self) -> u32;
70    /// Total number of participating ranks.
71    fn world_size(&self) -> u32;
72
73    /// Send `bytes` to rank `to`, tagged `tag`. Returns once the bytes
74    /// are handed to the transport (not necessarily delivered).
75    fn send_bytes(&self, to: u32, tag: u32, bytes: &[u8]) -> Result<(), CollectiveError>;
76
77    /// Block until a message with matching `(from, tag)` arrives, and
78    /// return its payload.
79    fn recv_bytes(&self, from: u32, tag: u32) -> Result<Vec<u8>, CollectiveError>;
80
81    /// Like [`Self::recv_bytes`] but gives up after `timeout`, returning
82    /// `Ok(None)`. The default blocks (transports with no deadline support
83    /// ignore the timeout); [`crate::NetTransport`] overrides it with a real
84    /// `wait_timeout`. This is the basis for not letting a slow or offline
85    /// edge node stall a collective (bounded staleness).
86    fn recv_bytes_timeout(
87        &self,
88        from: u32,
89        tag: u32,
90        _timeout: Duration,
91    ) -> Result<Option<Vec<u8>>, CollectiveError> {
92        Ok(Some(self.recv_bytes(from, tag)?))
93    }
94
95    /// Block until every rank has reached this barrier.
96    ///
97    /// Default is a gather-to-root rendezvous over `send`/`recv`: every
98    /// non-root sends a token to rank 0 and waits for the release; rank
99    /// 0 collects all tokens then releases everyone. Lockstep is
100    /// preserved across repeated barriers because a non-root cannot
101    /// enter the next barrier until it observes the current release.
102    fn barrier(&self) -> Result<(), CollectiveError> {
103        default_barrier(self)
104    }
105}
106
107/// Gather-to-root barrier usable by any [`Transport`]. Pulled out as a
108/// free function so trait impls can reuse it from an overridden
109/// `barrier` if they want to wrap it.
110pub fn default_barrier<T: Transport + ?Sized>(t: &T) -> Result<(), CollectiveError> {
111    let n = t.world_size();
112    if n <= 1 {
113        return Ok(());
114    }
115    let me = t.rank();
116    if me == 0 {
117        for r in 1..n {
118            t.recv_bytes(r, TAG_BARRIER)?;
119        }
120        for r in 1..n {
121            t.send_bytes(r, TAG_BARRIER, &[1u8])?;
122        }
123    } else {
124        t.send_bytes(0, TAG_BARRIER, &[1u8])?;
125        t.recv_bytes(0, TAG_BARRIER)?;
126    }
127    Ok(())
128}
129
130/// Little-endian flatten of an `f32` slice. Apple Silicon and x86 are
131/// both little-endian; we fix LE on the wire so a future big-endian
132/// rank would still interoperate.
133fn f32_to_le_bytes(data: &[f32]) -> Vec<u8> {
134    let mut out = Vec::with_capacity(data.len() * 4);
135    for &x in data {
136        out.extend_from_slice(&x.to_le_bytes());
137    }
138    out
139}
140
141/// Inverse of [`f32_to_le_bytes`]. Errors if `bytes.len()` is not a
142/// multiple of 4.
143fn le_bytes_to_f32(bytes: &[u8]) -> Result<Vec<f32>, CollectiveError> {
144    if !bytes.len().is_multiple_of(4) {
145        return Err(CollectiveError::TransportError {
146            reason: format!("recv payload {} bytes is not a multiple of 4", bytes.len()),
147        });
148    }
149    Ok(bytes
150        .chunks_exact(4)
151        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
152        .collect())
153}
154
155fn combine(op: ReduceKind, a: f32, b: f32) -> f32 {
156    match op {
157        ReduceKind::Sum | ReduceKind::Mean => a + b,
158        ReduceKind::Max => a.max(b),
159        ReduceKind::Min => a.min(b),
160    }
161}
162
163fn finalize(op: ReduceKind, acc: f32, n: u32) -> f32 {
164    match op {
165        ReduceKind::Mean => acc / (n as f32),
166        _ => acc,
167    }
168}
169
170/// f64 reduction step — collectives accumulate in f64 regardless of the
171/// payload dtype, so f16/bf16 reductions don't lose range mid-sum.
172fn combine64(op: ReduceKind, a: f64, b: f64) -> f64 {
173    match op {
174        ReduceKind::Sum | ReduceKind::Mean => a + b,
175        ReduceKind::Max => a.max(b),
176        ReduceKind::Min => a.min(b),
177    }
178}
179
180/// The float dtypes a collective can reduce over the wire.
181fn is_reducible_float(d: DType) -> bool {
182    matches!(d, DType::F16 | DType::BF16 | DType::F32 | DType::F64)
183}
184
185/// The integer dtypes a collective can reduce — the edge/quantized tier
186/// (MCU/FPGA/ANE). Accumulated in i32 so a sum of 8-bit lanes can't overflow.
187fn is_reducible_int(d: DType) -> bool {
188    matches!(d, DType::I8 | DType::U8)
189}
190
191fn decode_to_i32(bytes: &[u8], dtype: DType) -> Vec<i32> {
192    match dtype {
193        DType::I8 => bytes.iter().map(|&b| b as i8 as i32).collect(),
194        DType::U8 => bytes.iter().map(|&b| b as i32).collect(),
195        _ => Vec::new(),
196    }
197}
198
199fn encode_into_i32(dst: &mut [u8], vals: &[i32], dtype: DType) {
200    match dtype {
201        DType::I8 => {
202            for (d, &v) in dst.iter_mut().zip(vals) {
203                *d = v.clamp(i8::MIN as i32, i8::MAX as i32) as i8 as u8;
204            }
205        }
206        DType::U8 => {
207            for (d, &v) in dst.iter_mut().zip(vals) {
208                *d = v.clamp(0, u8::MAX as i32) as u8;
209            }
210        }
211        _ => {}
212    }
213}
214
215fn combine_i32(op: ReduceKind, a: i32, b: i32) -> i32 {
216    match op {
217        ReduceKind::Sum | ReduceKind::Mean => a + b,
218        ReduceKind::Max => a.max(b),
219        ReduceKind::Min => a.min(b),
220    }
221}
222
223/// Decode `bytes` of `dtype` to f64 for reduction. Little-endian on the wire.
224fn decode_to_f64(bytes: &[u8], dtype: DType) -> Vec<f64> {
225    match dtype {
226        DType::F32 => bytes
227            .chunks_exact(4)
228            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]) as f64)
229            .collect(),
230        DType::F64 => bytes
231            .chunks_exact(8)
232            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
233            .collect(),
234        DType::F16 => bytes
235            .chunks_exact(2)
236            .map(|c| half::f16::from_le_bytes([c[0], c[1]]).to_f64())
237            .collect(),
238        DType::BF16 => bytes
239            .chunks_exact(2)
240            .map(|c| half::bf16::from_le_bytes([c[0], c[1]]).to_f64())
241            .collect(),
242        _ => Vec::new(),
243    }
244}
245
246/// Encode f64 reduction results back to `dtype` bytes (little-endian).
247fn encode_from_f64(vals: &[f64], dtype: DType) -> Vec<u8> {
248    let mut out = Vec::with_capacity(vals.len() * dtype.size_bytes().max(1));
249    match dtype {
250        DType::F32 => vals
251            .iter()
252            .for_each(|&v| out.extend_from_slice(&(v as f32).to_le_bytes())),
253        DType::F64 => vals
254            .iter()
255            .for_each(|&v| out.extend_from_slice(&v.to_le_bytes())),
256        DType::F16 => vals
257            .iter()
258            .for_each(|&v| out.extend_from_slice(&half::f16::from_f64(v).to_le_bytes())),
259        DType::BF16 => vals
260            .iter()
261            .for_each(|&v| out.extend_from_slice(&half::bf16::from_f64(v).to_le_bytes())),
262        _ => {}
263    }
264    out
265}
266
267/// f32 reduction step — the accumulator for f16/bf16/f32. f32 is exact for
268/// f16/bf16 inputs and matches rlx's native f32 collective bit-for-bit.
269fn combine32(op: ReduceKind, a: f32, b: f32) -> f32 {
270    match op {
271        ReduceKind::Sum | ReduceKind::Mean => a + b,
272        ReduceKind::Max => a.max(b),
273        ReduceKind::Min => a.min(b),
274    }
275}
276
277/// SIMD-batch decode of `dtype` bytes to f32. On little-endian targets (Apple
278/// Silicon + x86) f16/bf16 reinterpret in place and `half`'s vectorized
279/// `convert_to_f32_slice` runs ~an order of magnitude faster than the
280/// per-element scalar path; a scalar `from_le_bytes` fallback keeps big-endian
281/// / misaligned slices correct.
282fn decode_to_f32(bytes: &[u8], dtype: DType) -> Vec<f32> {
283    match dtype {
284        DType::F32 => {
285            #[cfg(target_endian = "little")]
286            {
287                let (head, mid, tail) = unsafe { bytes.align_to::<f32>() };
288                if head.is_empty() && tail.is_empty() {
289                    return mid.to_vec();
290                }
291            }
292            bytes
293                .chunks_exact(4)
294                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
295                .collect()
296        }
297        DType::F16 => {
298            let mut out = vec![0f32; bytes.len() / 2];
299            #[cfg(target_endian = "little")]
300            {
301                // SAFETY: any 2-byte pattern is a valid f16 (no invalid bits);
302                // LE in-memory layout equals the wire's LE f16.
303                let (head, mid, tail) = unsafe { bytes.align_to::<half::f16>() };
304                if head.is_empty() && tail.is_empty() {
305                    mid.convert_to_f32_slice(&mut out);
306                    return out;
307                }
308            }
309            for (o, c) in out.iter_mut().zip(bytes.chunks_exact(2)) {
310                *o = half::f16::from_le_bytes([c[0], c[1]]).to_f32();
311            }
312            out
313        }
314        DType::BF16 => {
315            let mut out = vec![0f32; bytes.len() / 2];
316            #[cfg(target_endian = "little")]
317            {
318                let (head, mid, tail) = unsafe { bytes.align_to::<half::bf16>() };
319                if head.is_empty() && tail.is_empty() {
320                    mid.convert_to_f32_slice(&mut out);
321                    return out;
322                }
323            }
324            for (o, c) in out.iter_mut().zip(bytes.chunks_exact(2)) {
325                *o = half::bf16::from_le_bytes([c[0], c[1]]).to_f32();
326            }
327            out
328        }
329        _ => Vec::new(),
330    }
331}
332
333/// SIMD-batch encode of f32 values **into** `dst` (raw `dtype` bytes), in
334/// place. On little-endian, f16/bf16 convert straight into the reinterpreted
335/// `dst` slice via `half`'s vectorized `convert_from_f32_slice` — no temporary
336/// `Vec<f16>` and no `Vec<u8>`, the two allocations the old encode paid per
337/// ring step. A scalar `to_le_bytes` path keeps BE / misaligned correct.
338fn encode_into(dst: &mut [u8], vals: &[f32], dtype: DType) {
339    match dtype {
340        DType::F32 => {
341            for (c, &v) in dst.chunks_exact_mut(4).zip(vals) {
342                c.copy_from_slice(&v.to_le_bytes());
343            }
344        }
345        DType::F16 => {
346            #[cfg(target_endian = "little")]
347            {
348                let (head, mid, tail) = unsafe { dst.align_to_mut::<half::f16>() };
349                if head.is_empty() && tail.is_empty() {
350                    mid.convert_from_f32_slice(vals);
351                    return;
352                }
353            }
354            for (c, &v) in dst.chunks_exact_mut(2).zip(vals) {
355                c.copy_from_slice(&half::f16::from_f32(v).to_le_bytes());
356            }
357        }
358        DType::BF16 => {
359            #[cfg(target_endian = "little")]
360            {
361                let (head, mid, tail) = unsafe { dst.align_to_mut::<half::bf16>() };
362                if head.is_empty() && tail.is_empty() {
363                    mid.convert_from_f32_slice(vals);
364                    return;
365                }
366            }
367            for (c, &v) in dst.chunks_exact_mut(2).zip(vals) {
368                c.copy_from_slice(&half::bf16::from_f32(v).to_le_bytes());
369            }
370        }
371        _ => {}
372    }
373}
374
375/// Reduce `incoming` into `dst` (both raw `dtype` bytes), elementwise with
376/// `op`. f16/bf16/f32 accumulate in f32 (SIMD codec); f64 stays in f64.
377fn reduce_into(dst: &mut [u8], incoming: &[u8], dtype: DType, op: ReduceKind) {
378    if is_reducible_int(dtype) {
379        let mut acc = decode_to_i32(dst, dtype);
380        let b = decode_to_i32(incoming, dtype);
381        for (x, &y) in acc.iter_mut().zip(&b) {
382            *x = combine_i32(op, *x, y);
383        }
384        encode_into_i32(dst, &acc, dtype);
385    } else if dtype == DType::F64 {
386        let a = decode_to_f64(dst, dtype);
387        let b = decode_to_f64(incoming, dtype);
388        let m: Vec<f64> = a
389            .iter()
390            .zip(&b)
391            .map(|(&x, &y)| combine64(op, x, y))
392            .collect();
393        dst.copy_from_slice(&encode_from_f64(&m, dtype));
394    } else {
395        let mut acc = decode_to_f32(dst, dtype);
396        let b = decode_to_f32(incoming, dtype);
397        for (x, &y) in acc.iter_mut().zip(&b) {
398            *x = combine32(op, *x, y);
399        }
400        encode_into(dst, &acc, dtype);
401    }
402}
403
404/// Divide every element of `data` (raw `dtype` bytes) by `n` — the `Mean`
405/// finalize, applied once over the whole reduced buffer.
406fn scale_mean(data: &mut [u8], dtype: DType, n: usize) {
407    if is_reducible_int(dtype) {
408        // Integer mean (federated averaging): round-to-nearest i32 divide.
409        let half = n as i32 / 2;
410        let mut acc = decode_to_i32(data, dtype);
411        for v in acc.iter_mut() {
412            *v = (*v + if *v >= 0 { half } else { -half }) / n as i32;
413        }
414        encode_into_i32(data, &acc, dtype);
415    } else if dtype == DType::F64 {
416        let scaled: Vec<f64> = decode_to_f64(data, dtype)
417            .iter()
418            .map(|&v| v / n as f64)
419            .collect();
420        data.copy_from_slice(&encode_from_f64(&scaled, dtype));
421    } else {
422        let inv = 1.0 / n as f32;
423        let mut acc = decode_to_f32(data, dtype);
424        for v in acc.iter_mut() {
425            *v *= inv;
426        }
427        encode_into(data, &acc, dtype);
428    }
429}
430
431/// Pick a common reduce dtype for a **mixed-precision** mesh. Reduction
432/// accumulates in f64 internally either way; this only decides the dtype the
433/// ranks agree to exchange when their *local* dtypes differ. F64 if any rank
434/// holds f64, else F32 — the safe accumulator (NCCL's default). Ranks that
435/// share a dtype should just reduce in it natively (smaller wire).
436pub fn negotiate_reduce_dtype(dtypes: &[DType]) -> DType {
437    if dtypes.contains(&DType::F64) {
438        DType::F64
439    } else {
440        DType::F32
441    }
442}
443
444/// A handle to the collective-communication world: a rank, a size, and
445/// the transport that connects them.
446///
447/// Wraps an `Arc<dyn Transport>` so the same group can be cloned into
448/// the generator loop, the weight loader, and (later) per-layer
449/// collective ops without re-establishing connections.
450#[derive(Clone)]
451pub struct ProcessGroup {
452    transport: Arc<dyn Transport>,
453}
454
455impl ProcessGroup {
456    pub fn new(transport: Arc<dyn Transport>) -> Self {
457        Self { transport }
458    }
459
460    pub fn rank(&self) -> u32 {
461        self.transport.rank()
462    }
463
464    pub fn world_size(&self) -> u32 {
465        self.transport.world_size()
466    }
467
468    /// `true` on the lowest rank — the canonical place to print, sample,
469    /// or own the final logits in a pipeline.
470    pub fn is_leader(&self) -> bool {
471        self.rank() == 0
472    }
473
474    pub fn transport(&self) -> &Arc<dyn Transport> {
475        &self.transport
476    }
477
478    pub fn barrier(&self) -> Result<(), CollectiveError> {
479        self.transport.barrier()
480    }
481
482    // ---- point-to-point (pipeline parallel) --------------------------
483
484    /// Send a hidden-state (or any) tensor to `to`. `tag` must be below
485    /// [`TAG_RESERVED_BASE`] (those tags are reserved for collectives).
486    pub fn send_f32(&self, to: u32, tag: u32, data: &[f32]) -> Result<(), CollectiveError> {
487        debug_assert!(tag < TAG_RESERVED_BASE, "tag collides with collective tags");
488        self.send_f32_tagged(to, tag, data)
489    }
490
491    /// Receive a tensor from `from`. Length is taken from the wire.
492    pub fn recv_f32(&self, from: u32, tag: u32) -> Result<Vec<f32>, CollectiveError> {
493        debug_assert!(tag < TAG_RESERVED_BASE, "tag collides with collective tags");
494        self.recv_f32_tagged(from, tag)
495    }
496
497    /// Internal f32 send with no reserved-tag guard — collectives use
498    /// the reserved tags legitimately.
499    fn send_f32_tagged(&self, to: u32, tag: u32, data: &[f32]) -> Result<(), CollectiveError> {
500        self.transport.send_bytes(to, tag, &f32_to_le_bytes(data))
501    }
502
503    fn recv_f32_tagged(&self, from: u32, tag: u32) -> Result<Vec<f32>, CollectiveError> {
504        le_bytes_to_f32(&self.transport.recv_bytes(from, tag)?)
505    }
506
507    // ---- collectives (tensor parallel) -------------------------------
508
509    /// In-place all-reduce: on return every rank holds
510    /// `op({each rank's input})`. All ranks must pass the same length.
511    ///
512    /// Bandwidth-optimal **ring** all-reduce (reduce-scatter then
513    /// all-gather): every rank moves only `~2·(n-1)/n · len` floats in and
514    /// out, independent of `n`, versus the old gather-to-root that funneled
515    /// `O(n)·len` through rank 0. Deadlock-free over [`NetTransport`]
516    /// because each rank's background reader thread always drains its
517    /// socket, so a `send` never blocks on a peer that is itself sending.
518    pub fn all_reduce(&self, data: &mut [f32], op: ReduceKind) -> Result<(), CollectiveError> {
519        let n = self.world_size();
520        if n <= 1 {
521            for v in data.iter_mut() {
522                *v = finalize(op, *v, n.max(1));
523            }
524            return Ok(());
525        }
526        let n = n as usize;
527        let me = self.rank() as usize;
528        let next = ((me + 1) % n) as u32;
529        let prev = ((me + n - 1) % n) as u32;
530
531        // Near-equal contiguous chunks; the first `rem` get one extra elem.
532        let len = data.len();
533        let base = len / n;
534        let rem = len % n;
535        let bound = |i: usize| i * base + i.min(rem);
536        let chunk = |i: usize| (bound(i), bound(i + 1));
537
538        // Single tag is safe: the inbox is FIFO per source, and `prev`
539        // sends its 2(n-1) frames in exactly the order this rank consumes
540        // them.
541        let expect = |incoming: &[f32], want: usize| -> Result<(), CollectiveError> {
542            if incoming.len() != want {
543                return Err(CollectiveError::LengthMismatch {
544                    expected: want,
545                    got: incoming.len(),
546                });
547            }
548            Ok(())
549        };
550
551        // reduce-scatter: rank `me` ends owning the fully-reduced chunk (me+1)%n.
552        for step in 0..n - 1 {
553            let (ss, se) = chunk((me + n - step) % n);
554            let (rs, re) = chunk((me + n - step - 1) % n);
555            self.send_f32_tagged(next, TAG_ALL_REDUCE, &data[ss..se])?;
556            let incoming = self.recv_f32_tagged(prev, TAG_ALL_REDUCE)?;
557            expect(&incoming, re - rs)?;
558            for (d, v) in data[rs..re].iter_mut().zip(incoming) {
559                *d = combine(op, *d, v);
560            }
561        }
562        // all-gather: walk each reduced chunk around the ring.
563        for step in 0..n - 1 {
564            let (ss, se) = chunk((me + n - step + 1) % n);
565            let (rs, re) = chunk((me + n - step) % n);
566            self.send_f32_tagged(next, TAG_ALL_REDUCE, &data[ss..se])?;
567            let incoming = self.recv_f32_tagged(prev, TAG_ALL_REDUCE)?;
568            expect(&incoming, re - rs)?;
569            data[rs..re].copy_from_slice(&incoming);
570        }
571        // Mean is sum-reduced above, divided here once over the whole vector.
572        if matches!(op, ReduceKind::Mean) {
573            let inv = 1.0 / n as f32;
574            for v in data.iter_mut() {
575                *v *= inv;
576            }
577        }
578        Ok(())
579    }
580
581    /// In-place all-reduce over **any float dtype** (`F16`/`BF16`/`F32`/`F64`),
582    /// operating on the raw little-endian bytes. The wire carries the *native*
583    /// dtype — an fp16 reduction moves half the bytes of the f32 path, the
584    /// direct bandwidth win of mixed precision — while the reduction itself
585    /// accumulates in f64 so range isn't lost mid-sum. Same bandwidth-optimal
586    /// ring as [`Self::all_reduce`]. `data.len()` must be a multiple of the
587    /// dtype's element size, and every rank must pass the same dtype + length.
588    pub fn all_reduce_typed(
589        &self,
590        data: &mut [u8],
591        dtype: DType,
592        op: ReduceKind,
593    ) -> Result<(), CollectiveError> {
594        if !is_reducible_float(dtype) && !is_reducible_int(dtype) {
595            return Err(CollectiveError::TransportError {
596                reason: format!("all_reduce_typed: unsupported dtype {dtype:?}"),
597            });
598        }
599        let esz = dtype.size_bytes();
600        if esz == 0 || !data.len().is_multiple_of(esz) {
601            return Err(CollectiveError::TransportError {
602                reason: format!(
603                    "all_reduce_typed: {} bytes not a multiple of {esz}",
604                    data.len()
605                ),
606            });
607        }
608        let n = self.world_size();
609        if n <= 1 {
610            return Ok(()); // Mean over 1 rank is identity
611        }
612        let n = n as usize;
613        let me = self.rank() as usize;
614        let next = ((me + 1) % n) as u32;
615        let prev = ((me + n - 1) % n) as u32;
616
617        let elems = data.len() / esz;
618        let base = elems / n;
619        let rem = elems % n;
620        let ebound = |i: usize| (i * base + i.min(rem)) * esz; // byte offset of element-chunk i
621        let chunk = |i: usize| (ebound(i), ebound(i + 1));
622
623        // reduce-scatter (decode→combine in f64→encode back into our chunk)
624        for step in 0..n - 1 {
625            let (ss, se) = chunk((me + n - step) % n);
626            let (rs, re) = chunk((me + n - step - 1) % n);
627            self.transport
628                .send_bytes(next, TAG_ALL_REDUCE, &data[ss..se])?;
629            let incoming = self.transport.recv_bytes(prev, TAG_ALL_REDUCE)?;
630            if incoming.len() != re - rs {
631                return Err(CollectiveError::LengthMismatch {
632                    expected: re - rs,
633                    got: incoming.len(),
634                });
635            }
636            reduce_into(&mut data[rs..re], &incoming, dtype, op);
637        }
638        // all-gather (raw byte copy — already reduced)
639        for step in 0..n - 1 {
640            let (ss, se) = chunk((me + n - step + 1) % n);
641            let (rs, re) = chunk((me + n - step) % n);
642            self.transport
643                .send_bytes(next, TAG_ALL_REDUCE, &data[ss..se])?;
644            let incoming = self.transport.recv_bytes(prev, TAG_ALL_REDUCE)?;
645            if incoming.len() != re - rs {
646                return Err(CollectiveError::LengthMismatch {
647                    expected: re - rs,
648                    got: incoming.len(),
649                });
650            }
651            data[rs..re].copy_from_slice(&incoming);
652        }
653        if matches!(op, ReduceKind::Mean) {
654            scale_mean(data, dtype, n);
655        }
656        Ok(())
657    }
658
659    /// Non-blocking all-reduce: takes ownership of `data`, runs the
660    /// collective on a background thread, and returns a handle whose
661    /// `join()` yields the reduced vector. This is the building block for
662    /// overlapping gradient communication with backward compute — fire it
663    /// per bucket as gradients are produced, keep computing, then join.
664    pub fn spawn_all_reduce(
665        self: &Arc<Self>,
666        mut data: Vec<f32>,
667        op: ReduceKind,
668    ) -> std::thread::JoinHandle<Vec<f32>> {
669        let g = self.clone();
670        std::thread::spawn(move || {
671            g.all_reduce(&mut data, op).expect("background all_reduce");
672            data
673        })
674    }
675
676    /// All-gather: every rank contributes `local` (same length on every
677    /// rank) and receives the concatenation in rank order, length
678    /// `world_size * local.len()`.
679    pub fn all_gather(&self, local: &[f32]) -> Result<Vec<f32>, CollectiveError> {
680        let n = self.world_size();
681        let len = local.len();
682        if n <= 1 {
683            return Ok(local.to_vec());
684        }
685        if self.rank() == 0 {
686            let mut out = vec![0f32; n as usize * len];
687            out[..len].copy_from_slice(local);
688            for r in 1..n {
689                let chunk = self.recv_f32_tagged(r, TAG_ALL_GATHER)?;
690                if chunk.len() != len {
691                    return Err(CollectiveError::LengthMismatch {
692                        expected: len,
693                        got: chunk.len(),
694                    });
695                }
696                let start = r as usize * len;
697                out[start..start + len].copy_from_slice(&chunk);
698            }
699            for r in 1..n {
700                self.send_f32_tagged(r, TAG_ALL_GATHER, &out)?;
701            }
702            Ok(out)
703        } else {
704            self.send_f32_tagged(0, TAG_ALL_GATHER, local)?;
705            let out = self.recv_f32_tagged(0, TAG_ALL_GATHER)?;
706            if out.len() != n as usize * len {
707                return Err(CollectiveError::LengthMismatch {
708                    expected: n as usize * len,
709                    got: out.len(),
710                });
711            }
712            Ok(out)
713        }
714    }
715
716    /// Broadcast `data` from `root` to every rank. On non-root ranks
717    /// `data` is overwritten; its length must already match the root's.
718    pub fn broadcast(&self, root: u32, data: &mut [f32]) -> Result<(), CollectiveError> {
719        let n = self.world_size();
720        if n <= 1 {
721            return Ok(());
722        }
723        if self.rank() == root {
724            for r in 0..n {
725                if r != root {
726                    self.send_f32_tagged(r, TAG_BROADCAST, data)?;
727                }
728            }
729        } else {
730            let res = self.recv_f32_tagged(root, TAG_BROADCAST)?;
731            if res.len() != data.len() {
732                return Err(CollectiveError::LengthMismatch {
733                    expected: data.len(),
734                    got: res.len(),
735                });
736            }
737            data.copy_from_slice(&res);
738        }
739        Ok(())
740    }
741
742    /// Bounded-staleness federated averaging. Rank 0 collects each rank's
743    /// vector but **skips any that miss `deadline`**, averages the arrivals
744    /// (always including its own), and broadcasts the result. Returns the
745    /// participant count on rank 0 (callers weight / log dropouts; non-roots
746    /// get the averaged `data` and a count of 0). This is how a slow or
747    /// offline edge client can't stall a training round.
748    pub fn federated_average(
749        &self,
750        data: &mut [f32],
751        deadline: Duration,
752    ) -> Result<usize, CollectiveError> {
753        let n = self.world_size();
754        if n <= 1 {
755            return Ok(1);
756        }
757        let elems = data.len();
758        if self.rank() == 0 {
759            let mut acc = data.to_vec();
760            let mut present = 1usize;
761            let end = Instant::now() + deadline;
762            for r in 1..n {
763                let remaining = end.saturating_duration_since(Instant::now());
764                if let Some(bytes) =
765                    self.transport
766                        .recv_bytes_timeout(r, TAG_ALL_REDUCE, remaining)?
767                {
768                    let other = le_bytes_to_f32(&bytes)?;
769                    if other.len() == elems {
770                        for i in 0..elems {
771                            acc[i] += other[i];
772                        }
773                        present += 1;
774                    }
775                }
776            }
777            let inv = 1.0 / present as f32;
778            for v in acc.iter_mut() {
779                *v *= inv;
780            }
781            data.copy_from_slice(&acc);
782            for r in 1..n {
783                self.send_f32_tagged(r, TAG_ALL_REDUCE, &acc)?;
784            }
785            Ok(present)
786        } else {
787            self.send_f32_tagged(0, TAG_ALL_REDUCE, data)?;
788            let res = self.recv_f32_tagged(0, TAG_ALL_REDUCE)?;
789            if res.len() == elems {
790                data.copy_from_slice(&res);
791            }
792            Ok(0)
793        }
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use std::collections::{HashMap, VecDeque};
801    use std::sync::{Condvar, Mutex};
802
803    /// In-process [`Transport`] over shared queues — exercises the
804    /// `ProcessGroup` collectives without sockets. One shared mailbox
805    /// keyed by `(dst, src, tag)`; `send` pushes, `recv` blocks-pops.
806    struct ChannelTransport {
807        rank: u32,
808        world: u32,
809        mailbox: Arc<(Mutex<HashMap<(u32, u32, u32), VecDeque<Vec<u8>>>>, Condvar)>,
810    }
811
812    impl ChannelTransport {
813        fn fan_out(world: u32) -> Vec<Arc<ChannelTransport>> {
814            let mailbox = Arc::new((Mutex::new(HashMap::new()), Condvar::new()));
815            (0..world)
816                .map(|r| {
817                    Arc::new(ChannelTransport {
818                        rank: r,
819                        world,
820                        mailbox: mailbox.clone(),
821                    })
822                })
823                .collect()
824        }
825    }
826
827    impl Transport for ChannelTransport {
828        fn rank(&self) -> u32 {
829            self.rank
830        }
831        fn world_size(&self) -> u32 {
832            self.world
833        }
834        fn send_bytes(&self, to: u32, tag: u32, bytes: &[u8]) -> Result<(), CollectiveError> {
835            let (m, cv) = &*self.mailbox;
836            m.lock()
837                .unwrap()
838                .entry((to, self.rank, tag))
839                .or_default()
840                .push_back(bytes.to_vec());
841            cv.notify_all();
842            Ok(())
843        }
844        fn recv_bytes(&self, from: u32, tag: u32) -> Result<Vec<u8>, CollectiveError> {
845            let (m, cv) = &*self.mailbox;
846            let mut guard = m.lock().unwrap();
847            loop {
848                if let Some(q) = guard.get_mut(&(self.rank, from, tag))
849                    && let Some(v) = q.pop_front()
850                {
851                    return Ok(v);
852                }
853                guard = cv.wait(guard).unwrap();
854            }
855        }
856    }
857
858    fn run_ranks<F>(world: u32, body: F) -> Vec<()>
859    where
860        F: Fn(ProcessGroup) + Send + Sync + 'static,
861    {
862        let ts = ChannelTransport::fan_out(world);
863        let body = Arc::new(body);
864        let handles: Vec<_> = ts
865            .into_iter()
866            .map(|t| {
867                let body = body.clone();
868                std::thread::spawn(move || body(ProcessGroup::new(t)))
869            })
870            .collect();
871        handles.into_iter().map(|h| h.join().unwrap()).collect()
872    }
873
874    #[test]
875    fn all_reduce_sum_matches_serial() {
876        run_ranks(4, |g| {
877            let r = g.rank() as f32;
878            let mut data = vec![r + 1.0; 3]; // ranks hold 1,2,3,4
879            g.all_reduce(&mut data, ReduceKind::Sum).unwrap();
880            assert_eq!(data, vec![10.0; 3], "rank {}", g.rank());
881        });
882    }
883
884    #[test]
885    fn all_gather_concatenates_in_rank_order() {
886        run_ranks(3, |g| {
887            let r = g.rank() as f32;
888            let out = g.all_gather(&[10.0 * r, 10.0 * r + 1.0]).unwrap();
889            assert_eq!(
890                out,
891                vec![0.0, 1.0, 10.0, 11.0, 20.0, 21.0],
892                "rank {}",
893                g.rank()
894            );
895        });
896    }
897
898    #[test]
899    fn broadcast_from_root_overwrites() {
900        run_ranks(4, |g| {
901            let mut data = if g.is_leader() {
902                vec![7.0, 8.0, 9.0]
903            } else {
904                vec![0.0, 0.0, 0.0]
905            };
906            g.broadcast(0, &mut data).unwrap();
907            assert_eq!(data, vec![7.0, 8.0, 9.0], "rank {}", g.rank());
908        });
909    }
910
911    #[test]
912    fn barrier_round_trips() {
913        run_ranks(4, |g| {
914            g.barrier().unwrap();
915            g.barrier().unwrap(); // repeated barriers stay in lockstep
916        });
917    }
918
919    #[test]
920    fn point_to_point_ring_handoff() {
921        // Pipeline-style: each rank sends its id to the next, lowest
922        // rank closes the ring. Verifies tagged matched send/recv.
923        run_ranks(3, |g| {
924            let n = g.world_size();
925            let me = g.rank();
926            let next = (me + 1) % n;
927            let prev = (me + n - 1) % n;
928            // Stagger to avoid all-send-then-all-recv lockstep issues:
929            // even ranks send first, odd ranks recv first.
930            if me % 2 == 0 {
931                g.send_f32(next, 1, &[me as f32]).unwrap();
932                let got = g.recv_f32(prev, 1).unwrap();
933                assert_eq!(got, vec![prev as f32]);
934            } else {
935                let got = g.recv_f32(prev, 1).unwrap();
936                assert_eq!(got, vec![prev as f32]);
937                g.send_f32(next, 1, &[me as f32]).unwrap();
938            }
939        });
940    }
941
942    fn f16_bytes(vals: &[f32]) -> Vec<u8> {
943        vals.iter()
944            .flat_map(|&v| half::f16::from_f32(v).to_le_bytes())
945            .collect()
946    }
947    fn f16_vals(bytes: &[u8]) -> Vec<f32> {
948        bytes
949            .chunks_exact(2)
950            .map(|c| half::f16::from_le_bytes([c[0], c[1]]).to_f32())
951            .collect()
952    }
953
954    #[test]
955    fn all_reduce_typed_f16_sums_across_ranks() {
956        run_ranks(4, |g| {
957            let mut bytes = f16_bytes(&[g.rank() as f32 + 1.0; 3]); // 1,2,3,4
958            g.all_reduce_typed(&mut bytes, DType::F16, ReduceKind::Sum)
959                .unwrap();
960            assert_eq!(f16_vals(&bytes), vec![10.0; 3], "rank {}", g.rank());
961        });
962    }
963
964    #[test]
965    fn all_reduce_typed_bf16_mean() {
966        run_ranks(4, |g| {
967            let mut bytes: Vec<u8> = [g.rank() as f32 + 1.0; 4]
968                .iter()
969                .flat_map(|&v| half::bf16::from_f32(v).to_le_bytes())
970                .collect();
971            g.all_reduce_typed(&mut bytes, DType::BF16, ReduceKind::Mean)
972                .unwrap();
973            let got: Vec<f32> = bytes
974                .chunks_exact(2)
975                .map(|c| half::bf16::from_le_bytes([c[0], c[1]]).to_f32())
976                .collect();
977            // mean(1,2,3,4) = 2.5, exactly representable in bf16
978            assert_eq!(got, vec![2.5; 4], "rank {}", g.rank());
979        });
980    }
981
982    #[test]
983    fn all_reduce_typed_uneven_length() {
984        // 5 elems over 4 ranks exercises the remainder chunking.
985        run_ranks(4, |g| {
986            let mut bytes = f16_bytes(&[g.rank() as f32 + 1.0; 5]);
987            g.all_reduce_typed(&mut bytes, DType::F16, ReduceKind::Sum)
988                .unwrap();
989            assert_eq!(f16_vals(&bytes), vec![10.0; 5], "rank {}", g.rank());
990        });
991    }
992
993    #[test]
994    fn all_reduce_typed_f16_large_buffer_simd_path() {
995        // 1000 elems is past the SIMD-batch threshold, so this exercises the
996        // vectorized `convert_*_f32_slice` path (small tests use it too, but
997        // this guards the batched conversion against off-by-one regressions).
998        run_ranks(3, |g| {
999            let mut bytes = f16_bytes(&vec![g.rank() as f32 + 1.0; 1000]); // 1,2,3
1000            g.all_reduce_typed(&mut bytes, DType::F16, ReduceKind::Sum)
1001                .unwrap();
1002            assert_eq!(f16_vals(&bytes), vec![6.0; 1000], "rank {}", g.rank());
1003        });
1004    }
1005
1006    #[test]
1007    fn all_reduce_typed_i8_federated_mean() {
1008        // The edge case: three int8 "clients" averaged (FedAvg-style).
1009        run_ranks(3, |g| {
1010            let mut bytes = vec![((g.rank() + 1) * 10) as i8 as u8; 4]; // 10,20,30
1011            g.all_reduce_typed(&mut bytes, DType::I8, ReduceKind::Mean)
1012                .unwrap();
1013            let got: Vec<i8> = bytes.iter().map(|&b| b as i8).collect();
1014            assert_eq!(got, vec![20i8; 4], "rank {}", g.rank()); // mean = 20
1015        });
1016    }
1017
1018    #[test]
1019    fn all_reduce_typed_i8_sum_saturates() {
1020        run_ranks(2, |g| {
1021            let mut bytes = vec![100i8 as u8; 2];
1022            g.all_reduce_typed(&mut bytes, DType::I8, ReduceKind::Sum)
1023                .unwrap();
1024            let got: Vec<i8> = bytes.iter().map(|&b| b as i8).collect();
1025            assert_eq!(got, vec![127i8; 2], "rank {}", g.rank()); // 200 clamps to i8::MAX
1026        });
1027    }
1028
1029    #[test]
1030    fn negotiate_reduce_dtype_rules() {
1031        assert_eq!(
1032            negotiate_reduce_dtype(&[DType::F16, DType::BF16]),
1033            DType::F32
1034        );
1035        assert_eq!(
1036            negotiate_reduce_dtype(&[DType::F16, DType::F64]),
1037            DType::F64
1038        );
1039        assert_eq!(negotiate_reduce_dtype(&[]), DType::F32);
1040    }
1041}