Skip to main content

rlx_driver/
node.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//! One-call node bring-up.
17//!
18//! Joining a distributed job used to mean hand-wiring a transport (mesh vs.
19//! star), resolving peer addresses, and wrapping the result in an
20//! `Arc<ProcessGroup>`. [`Node`] collapses that to a builder:
21//!
22//! ```no_run
23//! # use rlx_driver::{Node, Topology};
24//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
25//! // From env (RANK/WORLD/PEERS or DISCOVER/TOPOLOGY):
26//! let group = Node::from_env()?.connect()?;
27//! // Or explicitly:
28//! let group = Node::new(/*rank*/ 1, /*world*/ 2)
29//!     .topology(Topology::Star)          // worker dials the coordinator
30//!     .peers(["10.0.0.1:29500"])?        // just the coordinator address
31//!     .connect()?;
32//! let _ = group;
33//! # Ok(()) }
34//! ```
35//!
36//! The returned [`ProcessGroup`] carries every collective (`all_reduce`,
37//! `all_reduce_typed`, `federated_average`, `broadcast`, …). This is the
38//! entry point a model runner or an edge worker calls to get on the mesh.
39
40use crate::net::{DEFAULT_HEAP_BYTES, NetTransport, TcpTransport};
41use crate::transport::ProcessGroup;
42use std::collections::BTreeMap;
43use std::io;
44use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, ToSocketAddrs, UdpSocket};
45use std::sync::Arc;
46use std::sync::atomic::{AtomicBool, Ordering};
47use std::time::Duration;
48
49/// Wire topology.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum Topology {
52    /// Full-mesh TCP — every rank connects to higher ranks and accepts from
53    /// lower. Needed for peer-to-peer collectives (ring all-reduce, etc.).
54    Mesh,
55    /// Star — the coordinator (rank 0) listens; workers dial in and need **no
56    /// inbound port**. NAT/mobile/Pi-behind-a-router friendly; the shape for
57    /// the coordinator/worker ship-graph model and federated averaging.
58    Star,
59}
60
61#[derive(Clone, Debug)]
62enum PeerSpec {
63    Static(Vec<SocketAddr>),
64    Discover { disc_port: u16, data_base: u16 },
65}
66
67/// Builder that turns minimal config into a connected [`ProcessGroup`].
68pub struct Node {
69    rank: u32,
70    world: u32,
71    peers: PeerSpec,
72    topology: Topology,
73    heap_bytes: usize,
74    /// For star discovery from behind NAT (e.g. a Docker/QEMU node): the host
75    /// to *unicast* the discovery query to, since broadcast can't cross the
76    /// bridge. `None` = LAN broadcast discovery.
77    discover_host: Option<String>,
78}
79
80impl Node {
81    /// A node at `rank` of `world`, full-mesh, default heap. Set peers with
82    /// [`Self::peers`] or [`Self::discover`] before [`Self::connect`].
83    pub fn new(rank: u32, world: u32) -> Self {
84        Self {
85            rank,
86            world,
87            peers: PeerSpec::Static(Vec::new()),
88            topology: Topology::Mesh,
89            heap_bytes: DEFAULT_HEAP_BYTES,
90            discover_host: None,
91        }
92    }
93
94    /// Unicast the discovery query to `host` instead of broadcasting — for a
95    /// worker behind NAT (Docker/QEMU) that can't hear the coordinator's LAN
96    /// broadcast. It learns the coordinator's port from the reply and dials
97    /// `host:port`. Only used with a discovering [`Topology::Star`] worker.
98    pub fn discover_via(mut self, host: impl Into<String>) -> Self {
99        self.discover_host = Some(host.into());
100        self
101    }
102
103    /// Static peer addresses, one per rank (mesh) or just the coordinator
104    /// (star, `peers[0]`). Accepts anything `ToSocketAddrs` (host:port or ip:port).
105    pub fn peers<A: ToSocketAddrs>(
106        mut self,
107        addrs: impl IntoIterator<Item = A>,
108    ) -> io::Result<Self> {
109        let mut v = Vec::new();
110        for a in addrs {
111            let sa = a.to_socket_addrs()?.next().ok_or_else(|| {
112                io::Error::new(io::ErrorKind::InvalidInput, "peer resolved to no address")
113            })?;
114            v.push(sa);
115        }
116        self.peers = PeerSpec::Static(v);
117        Ok(self)
118    }
119
120    /// Discover peers by UDP broadcast (zero-config on a shared LAN): each node
121    /// announces `rank → ip:(data_base+rank)` on `disc_port` until all `world`
122    /// addresses are known.
123    pub fn discover(mut self, disc_port: u16, data_base: u16) -> Self {
124        self.peers = PeerSpec::Discover {
125            disc_port,
126            data_base,
127        };
128        self
129    }
130
131    pub fn topology(mut self, t: Topology) -> Self {
132        self.topology = t;
133        self
134    }
135
136    /// Per-rank symmetric-heap size in bytes (default [`DEFAULT_HEAP_BYTES`]).
137    pub fn heap_bytes(mut self, n: usize) -> Self {
138        self.heap_bytes = n;
139        self
140    }
141
142    pub fn rank(&self) -> u32 {
143        self.rank
144    }
145    pub fn world(&self) -> u32 {
146        self.world
147    }
148
149    /// Read node config from the environment:
150    ///   `RANK`, `WORLD`                       — this rank / total ranks
151    ///   `PEERS=host:port,host:port,…`         — static addresses, or
152    ///   `DISCOVER=1` (+ `DISC_PORT`/`DATA_PORT`) — UDP auto-discovery
153    ///   `TOPOLOGY=mesh|star` (`DIAL_OUT=1` = star)
154    ///   `HEAP_MB`                             — per-rank heap (optional)
155    pub fn from_env() -> Result<Self, String> {
156        let var = |k: &str| std::env::var(k).ok();
157        let rank: u32 = var("RANK")
158            .as_deref()
159            .unwrap_or("0")
160            .parse()
161            .map_err(|_| "RANK must be an integer".to_string())?;
162        let world: u32 = var("WORLD")
163            .as_deref()
164            .unwrap_or("1")
165            .parse()
166            .map_err(|_| "WORLD must be an integer".to_string())?;
167        let mut node = Node::new(rank, world);
168
169        let star =
170            var("TOPOLOGY").as_deref() == Some("star") || var("DIAL_OUT").is_some_and(|v| v != "0");
171        node = node.topology(if star { Topology::Star } else { Topology::Mesh });
172
173        if let Some(mb) = var("HEAP_MB").and_then(|v| v.parse::<usize>().ok()) {
174            node = node.heap_bytes(mb << 20);
175        }
176
177        if var("DISCOVER").is_some_and(|v| v != "0") {
178            let dp = var("DISC_PORT")
179                .and_then(|v| v.parse().ok())
180                .unwrap_or(29600);
181            let db = var("DATA_PORT")
182                .and_then(|v| v.parse().ok())
183                .unwrap_or(29500);
184            node = node.discover(dp, db);
185            if let Some(h) = var("DISCOVER_HOST") {
186                node = node.discover_via(h);
187            }
188        } else {
189            let peers = var("PEERS").unwrap_or_else(|| "127.0.0.1:29500,127.0.0.1:29501".into());
190            let addrs: Vec<String> = peers.split(',').map(|s| s.trim().to_string()).collect();
191            node = node
192                .peers(addrs.iter().map(String::as_str))
193                .map_err(|e| format!("PEERS: {e}"))?;
194        }
195        Ok(node)
196    }
197
198    /// Establish the transport and return a ready [`ProcessGroup`].
199    pub fn connect(self) -> io::Result<Arc<ProcessGroup>> {
200        // Star + discovery: the coordinator *announces* itself and workers
201        // *find* it. Unlike mesh discovery (every rank must hear every other),
202        // the coordinator never waits to hear the workers — so a NAT'd node
203        // that can't broadcast (Docker/QEMU) doesn't stall the group; it just
204        // unicasts its query via `discover_via`.
205        if self.topology == Topology::Star
206            && let PeerSpec::Discover {
207                disc_port,
208                data_base,
209            } = &self.peers
210        {
211            let (disc_port, data_base) = (*disc_port, *data_base);
212            let transport = if self.rank == 0 {
213                let listener = TcpListener::bind(("0.0.0.0", data_base))?;
214                let stop = Arc::new(AtomicBool::new(false));
215                let ann = stop.clone();
216                std::thread::spawn(move || announce_coordinator(data_base, disc_port, &ann));
217                #[cfg(feature = "mdns")]
218                let _mdns = mdns_advertise(data_base); // advertise while we listen
219                let t = NetTransport::coordinator_listen(self.world, listener, self.heap_bytes);
220                stop.store(true, Ordering::SeqCst);
221                t?
222            } else {
223                let coord = discover_coordinator(disc_port, self.discover_host.as_deref())?;
224                NetTransport::worker_dial(self.rank, self.world, coord, self.heap_bytes)?
225            };
226            return Ok(Arc::new(ProcessGroup::new(Arc::new(transport))));
227        }
228
229        // Otherwise resolve a concrete peer list (mesh discovery collects all).
230        let peers = match self.peers {
231            PeerSpec::Static(v) => v,
232            PeerSpec::Discover {
233                disc_port,
234                data_base,
235            } => discover_peers(self.rank, self.world, disc_port, data_base),
236        };
237        let transport = match self.topology {
238            Topology::Mesh => {
239                if peers.len() != self.world as usize {
240                    return Err(io::Error::new(
241                        io::ErrorKind::InvalidInput,
242                        format!(
243                            "mesh needs WORLD={} peer addresses, got {}",
244                            self.world,
245                            peers.len()
246                        ),
247                    ));
248                }
249                TcpTransport::bind(self.rank, self.world, peers, self.heap_bytes)?
250            }
251            Topology::Star => {
252                let coord = *peers.first().ok_or_else(|| {
253                    io::Error::new(
254                        io::ErrorKind::InvalidInput,
255                        "star needs the coordinator address (peers[0])",
256                    )
257                })?;
258                if self.rank == 0 {
259                    let listener = TcpListener::bind(coord)?;
260                    NetTransport::coordinator_listen(self.world, listener, self.heap_bytes)?
261                } else {
262                    NetTransport::worker_dial(self.rank, self.world, coord, self.heap_bytes)?
263                }
264            }
265        };
266        Ok(Arc::new(ProcessGroup::new(Arc::new(transport))))
267    }
268}
269
270/// The default-route local IP (picks the outbound interface); loopback if the
271/// probe fails.
272pub fn local_ip() -> IpAddr {
273    UdpSocket::bind("0.0.0.0:0")
274        .and_then(|s| {
275            s.connect("8.8.8.8:80")?; // no packet sent — just selects the iface
276            s.local_addr()
277        })
278        .map(|a| a.ip())
279        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
280}
281
282/// UDP-broadcast rendezvous: announce `rank → ip:(data_base+rank)` on
283/// `disc_port` and collect until all `world` addresses are known; return them
284/// sorted by rank. Zero-config peer discovery on a shared LAN.
285pub fn discover_peers(rank: u32, world: u32, disc_port: u16, data_base: u16) -> Vec<SocketAddr> {
286    let my_addr = SocketAddr::new(local_ip(), data_base + rank as u16);
287    let sock = UdpSocket::bind(("0.0.0.0", disc_port)).expect("discovery bind");
288    sock.set_broadcast(true).ok();
289    sock.set_read_timeout(Some(Duration::from_millis(150))).ok();
290    let bcast = SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), disc_port);
291    let msg = format!("RLXDISC {rank} {my_addr}");
292
293    let mut peers: BTreeMap<u32, SocketAddr> = BTreeMap::new();
294    peers.insert(rank, my_addr);
295    let mut buf = [0u8; 256];
296    while (peers.len() as u32) < world {
297        let _ = sock.send_to(msg.as_bytes(), bcast);
298        if let Ok((n, _)) = sock.recv_from(&mut buf)
299            && let Ok(s) = std::str::from_utf8(&buf[..n])
300        {
301            let mut it = s.split_whitespace();
302            if it.next() == Some("RLXDISC")
303                && let (Some(r), Some(a)) = (it.next(), it.next())
304                && let (Ok(r), Ok(a)) = (r.parse::<u32>(), a.parse::<SocketAddr>())
305            {
306                peers.insert(r, a);
307            }
308        }
309    }
310    // Brief drain so late joiners still hear our announcement.
311    for _ in 0..5 {
312        let _ = sock.send_to(msg.as_bytes(), bcast);
313    }
314    peers.into_values().collect()
315}
316
317fn parse_rlxport(b: &[u8]) -> Option<u16> {
318    let s = std::str::from_utf8(b).ok()?;
319    let mut it = s.split_whitespace();
320    if it.next() != Some("RLXPORT") {
321        return None;
322    }
323    it.next()?.parse().ok()
324}
325
326/// Coordinator side of star discovery: a **pure unicast responder** on
327/// `disc_port` — a worker sends `RLXQ`, we reply `RLXPORT <data_port>`. Runs
328/// until `stop`. Deliberately does NOT broadcast on this socket: a coordinator
329/// that both beacons and receives on one socket starves incoming queries
330/// (its own zero-latency loopback beacons always win the recv race). LAN
331/// zero-config is handled by mDNS instead ([`mdns_advertise`]); this responder
332/// covers a worker that reaches us by name/IP (LAN, `host.docker.internal`, or
333/// a Tailscale MagicDNS name — anything routable).
334pub fn announce_coordinator(data_port: u16, disc_port: u16, stop: &AtomicBool) {
335    let Ok(sock) = UdpSocket::bind(("0.0.0.0", disc_port)) else {
336        return;
337    };
338    sock.set_read_timeout(Some(Duration::from_millis(200))).ok();
339    let msg = format!("RLXPORT {data_port}");
340    let mut buf = [0u8; 64];
341    while !stop.load(Ordering::SeqCst) {
342        if let Ok((n, from)) = sock.recv_from(&mut buf)
343            && buf[..n].starts_with(b"RLXQ")
344        {
345            let _ = sock.send_to(msg.as_bytes(), from); // reply to the querier
346        }
347    }
348}
349
350/// Worker side of star discovery: return the coordinator's dial address.
351/// `host = Some(h)` (NAT/Docker) unicasts a query to `h:disc_port` and dials
352/// `h:port` from the reply; `None` listens for the LAN broadcast and dials the
353/// announcing host's own IP.
354pub fn discover_coordinator(disc_port: u16, host: Option<&str>) -> io::Result<SocketAddr> {
355    let mut buf = [0u8; 64];
356    let no_addr = || {
357        io::Error::new(
358            io::ErrorKind::InvalidInput,
359            "discover host resolved to no address",
360        )
361    };
362    match host {
363        Some(h) => {
364            let target = (h, disc_port)
365                .to_socket_addrs()?
366                .next()
367                .ok_or_else(no_addr)?;
368            let sock = UdpSocket::bind(("0.0.0.0", 0))?;
369            sock.set_read_timeout(Some(Duration::from_millis(300))).ok();
370            loop {
371                sock.send_to(b"RLXQ", target)?;
372                if let Ok((n, _)) = sock.recv_from(&mut buf)
373                    && let Some(port) = parse_rlxport(&buf[..n])
374                {
375                    return (h, port).to_socket_addrs()?.next().ok_or_else(no_addr);
376                }
377            }
378        }
379        None => {
380            let _ = disc_port;
381            #[cfg(feature = "mdns")]
382            {
383                mdns_discover(Duration::from_secs(15)).ok_or_else(|| {
384                    io::Error::new(
385                        io::ErrorKind::NotFound,
386                        "mDNS: no _rlx-coord._udp coordinator found on the LAN",
387                    )
388                })
389            }
390            #[cfg(not(feature = "mdns"))]
391            Err(io::Error::new(
392                io::ErrorKind::InvalidInput,
393                "no coordinator address — set DISCOVER_HOST (LAN IP / host.docker.internal / \
394                 tailnet name) for unicast discovery, or build with the `mdns` feature for \
395                 zero-config LAN discovery",
396            ))
397        }
398    }
399}
400
401/// Advertise the coordinator as `_rlx-coord._udp.local.` so LAN workers can
402/// browse for it — zero-config, Bonjour/Avahi-compatible, and multicast-based
403/// (`224.0.0.251`), which switches/APs forward far more reliably than raw
404/// broadcast. Hold the returned daemon alive while listening.
405#[cfg(feature = "mdns")]
406pub fn mdns_advertise(data_port: u16) -> Option<mdns_sd::ServiceDaemon> {
407    use mdns_sd::{ServiceDaemon, ServiceInfo};
408    let mdns = ServiceDaemon::new().ok()?;
409    let ip = local_ip().to_string();
410    let props: &[(&str, &str)] = &[];
411    let info = ServiceInfo::new(
412        "_rlx-coord._udp.local.",
413        "coord",
414        "rlx-coord.local.",
415        ip.as_str(),
416        data_port,
417        props,
418    )
419    .ok()?;
420    mdns.register(info).ok()?;
421    Some(mdns)
422}
423
424/// Browse for a `_rlx-coord._udp.local.` coordinator and return its address.
425#[cfg(feature = "mdns")]
426pub fn mdns_discover(timeout: Duration) -> Option<SocketAddr> {
427    use mdns_sd::{ServiceDaemon, ServiceEvent};
428    use std::time::Instant;
429    let mdns = ServiceDaemon::new().ok()?;
430    let rx = mdns.browse("_rlx-coord._udp.local.").ok()?;
431    let deadline = Instant::now() + timeout;
432    while let Some(left) = deadline.checked_duration_since(Instant::now())
433        && let Ok(ev) = rx.recv_timeout(left)
434    {
435        if let ServiceEvent::ServiceResolved(info) = ev
436            && let Some(ip) = info.get_addresses().iter().next()
437        {
438            return Some(SocketAddr::new(*ip, info.get_port()));
439        }
440    }
441    None
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::collective::ReduceKind;
448
449    #[test]
450    fn node_mesh_connect_and_all_reduce() {
451        // Grab two free loopback ports, then bring up a 2-rank mesh via the
452        // builder and check a collective round-trips.
453        let free = || {
454            let l = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
455            l.local_addr().unwrap()
456        };
457        let (a0, a1) = (free(), free());
458        let peers = vec![a0.to_string(), a1.to_string()];
459
460        let p1 = peers.clone();
461        let h = std::thread::spawn(move || {
462            let g = Node::new(1, 2)
463                .peers(p1.iter().map(String::as_str))
464                .unwrap()
465                .connect()
466                .unwrap();
467            let mut d = vec![2.0f32; 3];
468            g.all_reduce(&mut d, ReduceKind::Sum).unwrap();
469            assert_eq!(d, vec![3.0; 3]);
470            g.barrier().unwrap();
471        });
472        let g = Node::new(0, 2)
473            .peers(peers.iter().map(String::as_str))
474            .unwrap()
475            .connect()
476            .unwrap();
477        let mut d = vec![1.0f32; 3];
478        g.all_reduce(&mut d, ReduceKind::Sum).unwrap();
479        assert_eq!(d, vec![3.0; 3]); // 1 + 2
480        g.barrier().unwrap();
481        h.join().unwrap();
482    }
483}