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}