Skip to main content

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