1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ParallelMode {
32 Pipeline,
33 Tensor,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum TransportBackend {
39 Tcp,
41 Thunderbolt,
43 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
72pub struct Hostfile {
73 #[serde(default = "default_backend")]
75 pub backend: String,
76 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 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 pub fn to_json(&self) -> String {
105 serde_json::to_string(self).expect("Hostfile serializes")
106 }
107
108 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 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#[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 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 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 => rlx_ir::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 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); 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 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}