1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ParallelMode {
43 Pipeline,
44 Tensor,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum TransportBackend {
50 Tcp,
52 Thunderbolt,
54 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct Hostfile {
84 #[serde(default = "default_backend")]
86 pub backend: String,
87 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 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 pub fn to_json(&self) -> String {
116 serde_json::to_string(self).expect("Hostfile serializes")
117 }
118
119 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 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#[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 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 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 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); 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 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}