Skip to main content

rlx_distributed/
launch.rs

1// RLX models — distributed inference.
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//! Local multi-process launcher — run an N-rank cluster on one host over
17//! loopback TCP, with **no hand-written `hosts.json` and no N terminals**.
18//!
19//! A distributed binary has two roles. When launched with `--rank R` it is a
20//! **worker**: it forms the process group and runs its block. With no
21//! `--rank`, it is the **launcher**: it picks free loopback ports, generates
22//! a hostfile, and re-spawns itself once per rank as a separate OS process.
23//! The same shape scales to a real cluster — only the hostfile IPs change.
24//!
25//! ```no_run
26//! use rlx_distributed::launch::{worker_args, LocalCluster};
27//!
28//! # fn run_worker(_rank: u32, _hostfile: &str) {}
29//! fn main() -> anyhow::Result<()> {
30//!     match worker_args() {
31//!         Some(w) => run_worker(w.rank, &w.hostfile),  // spawned worker
32//!         None => {                                     // top-level launcher
33//!             let out = LocalCluster::new(3).arg("--device").arg("cpu").run()?;
34//!             for line in out {
35//!                 println!("{line}");
36//!             }
37//!         }
38//!     }
39//!     Ok(())
40//! }
41//! ```
42
43use crate::config::{Hostfile, TransportBackend};
44use anyhow::{Context, Result};
45use std::io::{BufRead, BufReader};
46use std::net::{Ipv4Addr, TcpListener};
47use std::process::{Command, Stdio};
48
49/// Reserve `n` free loopback TCP ports by binding ephemeral sockets and
50/// immediately releasing them. There is an inherent (small) race: the OS
51/// may hand a freed port to another process before the worker rebinds it.
52/// For local launches this is negligible and matches what mlx-lm / torchrun
53/// do for single-host runs.
54pub fn free_loopback_ports(n: u32) -> Result<Vec<u16>> {
55    (0..n)
56        .map(|_| {
57            let l = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
58                .context("binding an ephemeral loopback port")?;
59            // The listener drops at the end of this closure, freeing the port
60            // for the worker process to bind.
61            Ok(l.local_addr().context("reading local_addr")?.port())
62        })
63        .collect()
64}
65
66/// The launch-level arguments every spawned worker receives. Model-specific
67/// flags (e.g. `--device`) stay in argv for the binary to parse itself.
68#[derive(Debug, Clone)]
69pub struct WorkerArgs {
70    pub rank: u32,
71    pub hostfile: String,
72}
73
74/// Parse this process's worker invocation: `--rank N --hostfile PATH` (rank
75/// falls back to `RLX_RANK`). Returns `None` when neither `--rank` nor
76/// `RLX_RANK` is present — meaning this is the top-level launcher, which
77/// should build a [`LocalCluster`].
78pub fn worker_args() -> Option<WorkerArgs> {
79    let mut rank: Option<u32> = None;
80    let mut hostfile: Option<String> = None;
81    let mut it = std::env::args().skip(1);
82    while let Some(a) = it.next() {
83        match a.as_str() {
84            "--rank" => rank = it.next().and_then(|v| v.parse().ok()),
85            "--hostfile" => hostfile = it.next(),
86            _ => {}
87        }
88    }
89    let rank = rank.or_else(|| std::env::var("RLX_RANK").ok().and_then(|v| v.parse().ok()))?;
90    Some(WorkerArgs {
91        rank,
92        hostfile: hostfile.unwrap_or_default(),
93    })
94}
95
96/// A self-spawning local cluster: re-runs the current executable once per
97/// rank as a separate OS process, each pointed at a generated loopback
98/// hostfile, capturing one rank's stdout.
99///
100/// This exercises the real `DistConfig::load` / `connect` path over real TCP
101/// sockets in separate address spaces — the genuine deployment shape minus
102/// the physical wire.
103pub struct LocalCluster {
104    world: u32,
105    backend: TransportBackend,
106    extra_args: Vec<String>,
107    capture_rank: u32,
108    quiet: bool,
109}
110
111impl LocalCluster {
112    /// A `world`-rank cluster over loopback TCP, capturing rank 0's stdout.
113    pub fn new(world: u32) -> Self {
114        Self {
115            world,
116            backend: TransportBackend::Tcp,
117            extra_args: Vec::new(),
118            capture_rank: 0,
119            quiet: false,
120        }
121    }
122
123    /// Override the transport backend written into the hostfile.
124    pub fn backend(mut self, backend: TransportBackend) -> Self {
125        self.backend = backend;
126        self
127    }
128
129    /// Forward an extra CLI argument to every spawned worker (e.g.
130    /// `--device mlx`). Workers also receive `--rank i --hostfile <path>`.
131    pub fn arg(mut self, a: impl Into<String>) -> Self {
132        self.extra_args.push(a.into());
133        self
134    }
135
136    /// Forward several extra arguments at once.
137    pub fn args<I, S>(mut self, it: I) -> Self
138    where
139        I: IntoIterator<Item = S>,
140        S: Into<String>,
141    {
142        self.extra_args.extend(it.into_iter().map(Into::into));
143        self
144    }
145
146    /// Which rank's stdout to capture and return from [`run`](Self::run).
147    pub fn capture_rank(mut self, rank: u32) -> Self {
148        self.capture_rank = rank;
149        self
150    }
151
152    /// Suppress the one-line "spawning N ranks" banner on stderr.
153    pub fn quiet(mut self, quiet: bool) -> Self {
154        self.quiet = quiet;
155        self
156    }
157
158    /// Spawn `world` workers, block until all exit, and return the captured
159    /// rank's stdout line by line. Errors if any worker exits non-zero.
160    pub fn run(self) -> Result<Vec<String>> {
161        let exe = std::env::current_exe().context("resolving current_exe")?;
162        let ports = free_loopback_ports(self.world)?;
163        let hostfile = Hostfile::loopback(&ports, self.backend).write_temp("cluster")?;
164
165        if !self.quiet {
166            eprintln!(
167                "LocalCluster: spawning {} ranks ({} backend), hostfile {}",
168                self.world,
169                self.backend.as_str(),
170                hostfile.display()
171            );
172        }
173
174        let mut children = Vec::with_capacity(self.world as usize);
175        for rank in 0..self.world {
176            let capture = rank == self.capture_rank;
177            let child = Command::new(&exe)
178                .arg("--rank")
179                .arg(rank.to_string())
180                .arg("--hostfile")
181                .arg(&hostfile)
182                .args(&self.extra_args)
183                .stdout(if capture {
184                    Stdio::piped()
185                } else {
186                    Stdio::null()
187                })
188                .stderr(Stdio::inherit())
189                .spawn()
190                .with_context(|| format!("spawning worker rank {rank}"))?;
191            children.push((rank, child));
192        }
193
194        // Drain the captured rank's stdout to EOF (it closes when that worker
195        // exits); the other ranks run concurrently meanwhile.
196        let mut lines = Vec::new();
197        if let Some((_, child)) = children.iter_mut().find(|(r, _)| *r == self.capture_rank) {
198            if let Some(out) = child.stdout.take() {
199                lines.extend(BufReader::new(out).lines().map_while(Result::ok));
200            }
201        }
202
203        let mut failures = Vec::new();
204        for (rank, mut child) in children {
205            match child.wait() {
206                Ok(status) if status.success() => {}
207                Ok(status) => failures.push(format!("rank {rank} exited with {status}")),
208                Err(e) => failures.push(format!("rank {rank} wait() failed: {e}")),
209            }
210        }
211        let _ = std::fs::remove_file(&hostfile);
212
213        if !failures.is_empty() {
214            anyhow::bail!("LocalCluster worker failure: {}", failures.join("; "));
215        }
216        Ok(lines)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn allocates_distinct_free_ports() {
226        let ports = free_loopback_ports(4).unwrap();
227        assert_eq!(ports.len(), 4);
228        // Distinct and non-zero.
229        let mut sorted = ports.clone();
230        sorted.sort_unstable();
231        sorted.dedup();
232        assert_eq!(sorted.len(), 4, "ports should be distinct: {ports:?}");
233        assert!(ports.iter().all(|&p| p != 0));
234    }
235
236    #[test]
237    fn worker_args_none_without_flag() {
238        // The test harness runs with no `--rank` and no RLX_RANK.
239        assert!(std::env::var("RLX_RANK").is_err());
240        assert!(worker_args().is_none());
241    }
242
243    #[test]
244    fn builder_collects_args() {
245        let c = LocalCluster::new(2)
246            .backend(TransportBackend::Thunderbolt)
247            .arg("--device")
248            .arg("mlx")
249            .args(["--decode", "--max-tokens", "8"]);
250        assert_eq!(c.world, 2);
251        assert_eq!(c.backend, TransportBackend::Thunderbolt);
252        assert_eq!(
253            c.extra_args,
254            ["--device", "mlx", "--decode", "--max-tokens", "8"]
255        );
256    }
257}