Skip to main content

rlx_distributed/
config.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Launch configuration: hostfile parsing + process-group construction.
6//!
7//! A run is described by a `hosts.json` (mirroring mlx-lm's) plus this
8//! process's rank:
9//!
10//! ```json
11//! {
12//!   "backend": "tcp",
13//!   "hosts": ["10.0.0.1:9000", "10.0.0.2:9000", "10.0.0.3:9000"]
14//! }
15//! ```
16//!
17//! `hosts[r]` is rank `r`'s `ip:port` listen address. Every rank reads the
18//! same file and is told its own rank (CLI flag or env `RLX_RANK`).
19
20use anyhow::{Context, Result, bail};
21use rlx_driver::{DEFAULT_HEAP_BYTES, ProcessGroup, TcpTransport, ThunderboltTransport, Transport};
22use serde::{Deserialize, Serialize};
23use std::net::{SocketAddr, ToSocketAddrs};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27/// Which parallelism strategy the run uses. Pipeline splits the model by
28/// layer; tensor splits within each layer. (Only pipeline is wired into
29/// the coordinator today; tensor parallel is future work.)
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ParallelMode {
32    Pipeline,
33    Tensor,
34}
35
36/// Network backend for the process group.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum TransportBackend {
39    /// Portable TCP (Ethernet or the Thunderbolt Bridge IP link).
40    Tcp,
41    /// TCP pinned to the Thunderbolt interface (one-sided heap exposed).
42    Thunderbolt,
43    /// MLX's distributed backend (jaccl/ring). Construct `MlxTransport`
44    /// from `rlx-mlx` directly and launch with `mlx.launch`; not built
45    /// here because it needs the MLX runtime.
46    MlxJaccl,
47}
48
49impl TransportBackend {
50    fn parse(s: &str) -> Result<Self> {
51        match s.to_ascii_lowercase().as_str() {
52            "tcp" | "ring" => Ok(Self::Tcp),
53            "thunderbolt" | "tb" => Ok(Self::Thunderbolt),
54            "mlx" | "jaccl" | "mlx-jaccl" => Ok(Self::MlxJaccl),
55            other => bail!("unknown transport backend {other:?} (tcp|thunderbolt|mlx-jaccl)"),
56        }
57    }
58
59    /// Canonical lowercase name — the inverse of [`parse`](Self::parse),
60    /// suitable for writing back into a `hosts.json`.
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Tcp => "tcp",
64            Self::Thunderbolt => "thunderbolt",
65            Self::MlxJaccl => "mlx-jaccl",
66        }
67    }
68}
69
70/// Parsed `hosts.json`.
71#[derive(Debug, Clone, Deserialize, Serialize)]
72pub struct Hostfile {
73    /// Backend name; defaults to "tcp" when absent.
74    #[serde(default = "default_backend")]
75    pub backend: String,
76    /// `ip:port` per rank, in rank order.
77    pub hosts: Vec<String>,
78}
79
80fn default_backend() -> String {
81    "tcp".to_string()
82}
83
84impl Hostfile {
85    pub fn from_json_str(s: &str) -> Result<Self> {
86        let hf: Hostfile = serde_json::from_str(s).context("parsing hosts.json")?;
87        if hf.hosts.is_empty() {
88            bail!("hosts.json has an empty `hosts` list");
89        }
90        Ok(hf)
91    }
92
93    /// Build a loopback hostfile: rank `r` listens on `127.0.0.1:ports[r]`.
94    /// This is the local-run analogue of hand-writing a `hosts.json` — used
95    /// by [`crate::launch::LocalCluster`].
96    pub fn loopback(ports: &[u16], backend: TransportBackend) -> Self {
97        Self {
98            backend: backend.as_str().to_string(),
99            hosts: ports.iter().map(|p| format!("127.0.0.1:{p}")).collect(),
100        }
101    }
102
103    /// Serialize back to the `hosts.json` wire form.
104    pub fn to_json(&self) -> String {
105        serde_json::to_string(self).expect("Hostfile serializes")
106    }
107
108    /// Write this hostfile to a uniquely-named file in the temp dir and
109    /// return its path. `tag` distinguishes concurrent runs.
110    pub fn write_temp(&self, tag: &str) -> Result<PathBuf> {
111        let path =
112            std::env::temp_dir().join(format!("rlx_hosts_{tag}_{}.json", std::process::id()));
113        std::fs::write(&path, self.to_json())
114            .with_context(|| format!("writing hostfile {}", path.display()))?;
115        Ok(path)
116    }
117
118    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
119        let path = path.as_ref();
120        let text = std::fs::read_to_string(path)
121            .with_context(|| format!("reading hostfile {}", path.display()))?;
122        Self::from_json_str(&text)
123    }
124
125    pub fn world_size(&self) -> u32 {
126        self.hosts.len() as u32
127    }
128
129    pub fn backend(&self) -> Result<TransportBackend> {
130        TransportBackend::parse(&self.backend)
131    }
132
133    /// Resolve every `hosts[r]` entry to a single `SocketAddr`, in rank
134    /// order.
135    pub fn peers(&self) -> Result<Vec<SocketAddr>> {
136        self.hosts
137            .iter()
138            .enumerate()
139            .map(|(r, h)| {
140                h.to_socket_addrs()
141                    .with_context(|| format!("resolving host[{r}] = {h:?}"))?
142                    .next()
143                    .with_context(|| format!("host[{r}] = {h:?} resolved to no address"))
144            })
145            .collect()
146    }
147}
148
149/// A fully-resolved launch configuration for one process.
150#[derive(Debug, Clone)]
151pub struct DistConfig {
152    pub rank: u32,
153    pub world_size: u32,
154    pub mode: ParallelMode,
155    pub backend: TransportBackend,
156    pub peers: Vec<SocketAddr>,
157}
158
159impl DistConfig {
160    /// Build a config from a hostfile + this process's rank.
161    pub fn from_hostfile(hostfile: &Hostfile, rank: u32, mode: ParallelMode) -> Result<Self> {
162        let world_size = hostfile.world_size();
163        if rank >= world_size {
164            bail!("rank {rank} >= world_size {world_size}");
165        }
166        Ok(Self {
167            rank,
168            world_size,
169            mode,
170            backend: hostfile.backend()?,
171            peers: hostfile.peers()?,
172        })
173    }
174
175    /// Load `hosts.json` and build the config in one step. `rank` falls
176    /// back to the `RLX_RANK` env var when `None`.
177    pub fn load(
178        hostfile_path: impl AsRef<Path>,
179        rank: Option<u32>,
180        mode: ParallelMode,
181    ) -> Result<Self> {
182        let hostfile = Hostfile::from_path(hostfile_path)?;
183        let rank = match rank {
184            Some(r) => r,
185            None => std::env::var("RLX_RANK")
186                .context("rank not given and RLX_RANK unset")?
187                .parse()
188                .context("RLX_RANK is not a u32")?,
189        };
190        Self::from_hostfile(&hostfile, rank, mode)
191    }
192
193    /// Establish the network mesh and return the process group. Blocks
194    /// until every peer has connected.
195    pub fn connect(&self) -> Result<ProcessGroup> {
196        let transport: Arc<dyn Transport> = match self.backend {
197            TransportBackend::Tcp => Arc::new(
198                TcpTransport::bind(
199                    self.rank,
200                    self.world_size,
201                    self.peers.clone(),
202                    DEFAULT_HEAP_BYTES,
203                )
204                .context("binding TCP transport")?,
205            ),
206            TransportBackend::Thunderbolt => Arc::new(
207                ThunderboltTransport::bind(
208                    self.rank,
209                    self.world_size,
210                    self.peers.clone(),
211                    DEFAULT_HEAP_BYTES,
212                )
213                .context("binding Thunderbolt transport")?,
214            ),
215            TransportBackend::MlxJaccl => {
216                bail!(
217                    "mlx-jaccl backend is provided by rlx_mlx::MlxTransport \
218                     (launch with mlx.launch); construct it directly and pass \
219                     to PipelineCoordinator::new"
220                )
221            }
222        };
223        Ok(ProcessGroup::new(transport))
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn parses_minimal_hostfile() {
233        let hf = Hostfile::from_json_str(r#"{ "hosts": ["127.0.0.1:9000", "127.0.0.1:9001"] }"#)
234            .unwrap();
235        assert_eq!(hf.world_size(), 2);
236        assert_eq!(hf.backend().unwrap(), TransportBackend::Tcp); // default
237        let peers = hf.peers().unwrap();
238        assert_eq!(peers.len(), 2);
239        assert_eq!(peers[1].port(), 9001);
240    }
241
242    #[test]
243    fn parses_backend_field() {
244        let hf = Hostfile::from_json_str(
245            r#"{ "backend": "thunderbolt", "hosts": ["10.0.0.1:5000", "10.0.0.2:5000"] }"#,
246        )
247        .unwrap();
248        assert_eq!(hf.backend().unwrap(), TransportBackend::Thunderbolt);
249    }
250
251    #[test]
252    fn empty_hosts_rejected() {
253        assert!(Hostfile::from_json_str(r#"{ "hosts": [] }"#).is_err());
254    }
255
256    #[test]
257    fn rank_out_of_range_rejected() {
258        let hf = Hostfile::from_json_str(r#"{ "hosts": ["127.0.0.1:9000"] }"#).unwrap();
259        assert!(DistConfig::from_hostfile(&hf, 3, ParallelMode::Pipeline).is_err());
260    }
261
262    #[test]
263    fn loopback_roundtrips_through_json() {
264        let hf = Hostfile::loopback(&[9000, 9001, 9002], TransportBackend::Tcp);
265        assert_eq!(hf.world_size(), 3);
266        assert_eq!(hf.hosts[2], "127.0.0.1:9002");
267        // to_json → from_json_str is a faithful round-trip.
268        let back = Hostfile::from_json_str(&hf.to_json()).unwrap();
269        assert_eq!(back.hosts, hf.hosts);
270        assert_eq!(back.backend().unwrap(), TransportBackend::Tcp);
271    }
272
273    #[test]
274    fn backend_as_str_inverts_parse() {
275        for b in [
276            TransportBackend::Tcp,
277            TransportBackend::Thunderbolt,
278            TransportBackend::MlxJaccl,
279        ] {
280            assert_eq!(TransportBackend::parse(b.as_str()).unwrap(), b);
281        }
282    }
283
284    #[test]
285    fn config_from_hostfile_resolves_peers() {
286        let hf = Hostfile::from_json_str(
287            r#"{ "backend": "tcp", "hosts": ["127.0.0.1:9000", "127.0.0.1:9001"] }"#,
288        )
289        .unwrap();
290        let cfg = DistConfig::from_hostfile(&hf, 1, ParallelMode::Pipeline).unwrap();
291        assert_eq!(cfg.rank, 1);
292        assert_eq!(cfg.world_size, 2);
293        assert_eq!(cfg.backend, TransportBackend::Tcp);
294        assert_eq!(cfg.peers.len(), 2);
295    }
296}