use crate::config::{Hostfile, TransportBackend};
use anyhow::{Context, Result};
use std::io::{BufRead, BufReader};
use std::net::{Ipv4Addr, TcpListener};
use std::process::{Command, Stdio};
pub fn free_loopback_ports(n: u32) -> Result<Vec<u16>> {
(0..n)
.map(|_| {
let l = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.context("binding an ephemeral loopback port")?;
Ok(l.local_addr().context("reading local_addr")?.port())
})
.collect()
}
#[derive(Debug, Clone)]
pub struct WorkerArgs {
pub rank: u32,
pub hostfile: String,
}
pub fn worker_args() -> Option<WorkerArgs> {
let mut rank: Option<u32> = None;
let mut hostfile: Option<String> = None;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"--rank" => rank = it.next().and_then(|v| v.parse().ok()),
"--hostfile" => hostfile = it.next(),
_ => {}
}
}
let rank = rank.or_else(|| rlx_ir::env::var("RLX_RANK").and_then(|v| v.parse().ok()))?;
Some(WorkerArgs {
rank,
hostfile: hostfile.unwrap_or_default(),
})
}
pub struct LocalCluster {
world: u32,
backend: TransportBackend,
extra_args: Vec<String>,
capture_rank: u32,
quiet: bool,
}
impl LocalCluster {
pub fn new(world: u32) -> Self {
Self {
world,
backend: TransportBackend::Tcp,
extra_args: Vec::new(),
capture_rank: 0,
quiet: false,
}
}
pub fn backend(mut self, backend: TransportBackend) -> Self {
self.backend = backend;
self
}
pub fn arg(mut self, a: impl Into<String>) -> Self {
self.extra_args.push(a.into());
self
}
pub fn args<I, S>(mut self, it: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_args.extend(it.into_iter().map(Into::into));
self
}
pub fn capture_rank(mut self, rank: u32) -> Self {
self.capture_rank = rank;
self
}
pub fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
pub fn run(self) -> Result<Vec<String>> {
let exe = std::env::current_exe().context("resolving current_exe")?;
let ports = free_loopback_ports(self.world)?;
let hostfile = Hostfile::loopback(&ports, self.backend).write_temp("cluster")?;
if !self.quiet {
eprintln!(
"LocalCluster: spawning {} ranks ({} backend), hostfile {}",
self.world,
self.backend.as_str(),
hostfile.display()
);
}
let mut children = Vec::with_capacity(self.world as usize);
for rank in 0..self.world {
let capture = rank == self.capture_rank;
let child = Command::new(&exe)
.arg("--rank")
.arg(rank.to_string())
.arg("--hostfile")
.arg(&hostfile)
.args(&self.extra_args)
.stdout(if capture {
Stdio::piped()
} else {
Stdio::null()
})
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("spawning worker rank {rank}"))?;
children.push((rank, child));
}
let mut lines = Vec::new();
if let Some((_, child)) = children.iter_mut().find(|(r, _)| *r == self.capture_rank)
&& let Some(out) = child.stdout.take()
{
lines.extend(BufReader::new(out).lines().map_while(Result::ok));
}
let mut failures = Vec::new();
for (rank, mut child) in children {
match child.wait() {
Ok(status) if status.success() => {}
Ok(status) => failures.push(format!("rank {rank} exited with {status}")),
Err(e) => failures.push(format!("rank {rank} wait() failed: {e}")),
}
}
let _ = std::fs::remove_file(&hostfile);
if !failures.is_empty() {
anyhow::bail!("LocalCluster worker failure: {}", failures.join("; "));
}
Ok(lines)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allocates_distinct_free_ports() {
let ports = free_loopback_ports(4).unwrap();
assert_eq!(ports.len(), 4);
let mut sorted = ports.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 4, "ports should be distinct: {ports:?}");
assert!(ports.iter().all(|&p| p != 0));
}
#[test]
fn worker_args_none_without_flag() {
assert!(rlx_ir::env::var("RLX_RANK").is_none());
assert!(worker_args().is_none());
}
#[test]
fn builder_collects_args() {
let c = LocalCluster::new(2)
.backend(TransportBackend::Thunderbolt)
.arg("--device")
.arg("mlx")
.args(["--decode", "--max-tokens", "8"]);
assert_eq!(c.world, 2);
assert_eq!(c.backend, TransportBackend::Thunderbolt);
assert_eq!(
c.extra_args,
["--device", "mlx", "--decode", "--max-tokens", "8"]
);
}
}