1use nuvec_core::{Error, Result};
5
6use super::mesh::MAX_PEERS;
7use super::ring::MAX_CABLES;
8use crate::json::{self, Value};
9
10#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct Config {
13 pub(crate) rank: usize,
14 pub(crate) devices: Vec<Vec<Vec<String>>>,
15 pub(crate) coordinator: Option<String>,
16 pub(crate) prefer_ring: bool,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum Topology {
23 Mesh {
25 wires: usize,
27 },
28 Ring {
30 cables: usize,
32 },
33}
34
35pub fn parse_devices(text: &str) -> Result<Vec<Vec<Vec<String>>>> {
39 let invalid = |what: String| Error::InvalidArgument(format!("jaccl: device file: {what}"));
40 let Value::Array(ranks) = json::parse(text, "jaccl: device file")? else {
41 return Err(invalid("expected an array of arrays".into()));
42 };
43 let size = ranks.len();
44 ranks
45 .into_iter()
46 .enumerate()
47 .map(|(rank, row)| {
48 let Value::Array(peers) = row else {
49 return Err(invalid(format!("rank {rank}: expected an array")));
50 };
51 if peers.len() != size {
52 return Err(invalid(format!(
53 "rank {rank} lists {} peers, the file has {size} ranks",
54 peers.len()
55 )));
56 }
57 peers
58 .into_iter()
59 .enumerate()
60 .map(|(peer, entry)| match entry {
61 Value::Null => Ok(Vec::new()),
62 Value::String(name) => Ok(vec![name]),
63 Value::Array(names) => names
64 .into_iter()
65 .map(|n| match n {
66 Value::String(n) => Ok(n),
67 _ => Err(invalid(format!(
68 "rank {rank} → {peer}: device names are strings"
69 ))),
70 })
71 .collect(),
72 _ => Err(invalid(format!(
73 "rank {rank} → {peer}: expected null, a device name or a list of names"
74 ))),
75 })
76 .collect()
77 })
78 .collect()
79}
80
81impl Config {
82 pub fn new(rank: usize, devices: Vec<Vec<Vec<String>>>) -> Result<Self> {
85 let size = devices.len();
86 for (r, row) in devices.iter().enumerate() {
87 if row.len() != size {
88 return Err(Error::InvalidArgument(format!(
89 "jaccl: the device matrix has {size} rows but row {r} has {} entries",
90 row.len()
91 )));
92 }
93 }
94 if rank >= size {
95 return Err(Error::InvalidArgument(format!(
96 "jaccl: rank {rank} is not a rank of a group of {size}"
97 )));
98 }
99 Ok(Self {
100 rank,
101 devices,
102 coordinator: None,
103 prefer_ring: false,
104 })
105 }
106
107 pub fn coordinator(mut self, address: impl Into<String>) -> Self {
109 self.coordinator = Some(address.into());
110 self
111 }
112
113 pub fn prefer_ring(mut self, prefer: bool) -> Self {
115 self.prefer_ring = prefer;
116 self
117 }
118
119 pub fn rank(&self) -> usize {
121 self.rank
122 }
123
124 pub fn size(&self) -> usize {
126 self.devices.len()
127 }
128
129 pub fn from_env() -> Result<Option<Self>> {
134 Self::from_vars(|name| std::env::var(name).ok())
135 }
136
137 fn from_vars(var: impl Fn(&str) -> Option<String>) -> Result<Option<Self>> {
138 let env = |names: &[&str]| names.iter().find_map(|n| var(n));
139 let (Some(file), Some(rank)) = (
140 env(&["JACCL_IBV_DEVICES", "MLX_IBV_DEVICES"]),
141 env(&["JACCL_RANK", "MLX_RANK"]),
142 ) else {
143 return Ok(None);
144 };
145 let text = std::fs::read_to_string(&file)
146 .map_err(|e| Error::Io(format!("jaccl: reading the device file {file}: {e}")))?;
147 let rank = rank
148 .trim()
149 .parse()
150 .map_err(|_| Error::InvalidArgument(format!("jaccl: rank {rank:?}")))?;
151 let mut config = Self::new(rank, parse_devices(&text)?)?
152 .prefer_ring(env(&["JACCL_RING", "MLX_JACCL_RING"]).is_some());
153 config.coordinator = env(&["JACCL_COORDINATOR", "MLX_JACCL_COORDINATOR"]);
154 Ok(Some(config))
155 }
156
157 fn mesh_error(&self) -> Option<String> {
159 let size = self.size();
160 if size < 2 {
161 return Some("a group needs at least 2 ranks".into());
162 }
163 for (src, row) in self.devices.iter().enumerate() {
164 for (dst, cables) in row.iter().enumerate() {
165 if src == dst && !cables.is_empty() {
166 return Some(format!("rank {src} lists a cable to itself"));
167 }
168 if src != dst && cables.is_empty() {
169 return Some(format!("rank {src} → {dst}: no cable in the device file"));
170 }
171 }
172 }
173 None
174 }
175
176 fn ring_error(&self) -> Option<String> {
178 let size = self.size();
179 if size < 2 {
180 return Some("a group needs at least 2 ranks".into());
181 }
182 let cables = self.devices[0][1].len();
183 if cables == 0 {
184 return Some("rank 0 → 1: no cable in the device file".into());
185 }
186 if cables > MAX_CABLES {
187 return Some(format!(
188 "the ring supports up to {MAX_CABLES} cables per direction, rank 0 → 1 has {cables}"
189 ));
190 }
191 for src in 0..size {
192 for dst in [(src + size - 1) % size, (src + 1) % size] {
193 let n = self.devices[src][dst].len();
194 if n != cables {
195 return Some(format!(
196 "rank {src} → {dst}: {n} cables, rank 0 → 1 has {cables}"
197 ));
198 }
199 }
200 }
201 None
202 }
203
204 pub fn topology(&self) -> Result<Topology> {
207 let (mesh, ring) = (self.mesh_error(), self.ring_error());
208 let as_ring = || Topology::Ring {
209 cables: self.devices[0][1].len(),
210 };
211 match (mesh, ring) {
212 (_, None) if self.prefer_ring => Ok(as_ring()),
213 (None, _) if self.size() > MAX_PEERS => Err(Error::InvalidArgument(format!(
215 "jaccl: the mesh supports up to {MAX_PEERS} ranks, the file has {}",
216 self.size()
217 ))),
218 (None, _) => Ok(Topology::Mesh {
219 wires: (0..self.size())
220 .flat_map(|s| {
221 (0..self.size())
222 .filter(move |&d| d != s)
223 .map(move |d| (s, d))
224 })
225 .map(|(s, d)| self.devices[s][d].len())
226 .min()
227 .unwrap_or(1),
228 }),
229 (Some(_), None) => Ok(as_ring()),
230 (Some(mesh), Some(ring)) => Err(Error::InvalidArgument(format!(
231 "jaccl: the device file defines neither a mesh ({mesh}) nor a ring ({ring})"
232 ))),
233 }
234 }
235
236 pub(crate) fn mesh_devices(&self, wires: usize) -> Vec<Vec<String>> {
238 (0..wires)
239 .map(|w| {
240 (0..self.size())
241 .map(|p| {
242 if p == self.rank {
243 String::new()
244 } else {
245 self.devices[self.rank][p][w].clone()
246 }
247 })
248 .collect()
249 })
250 .collect()
251 }
252
253 pub(crate) fn ring_devices(&self) -> (Vec<String>, Vec<String>) {
255 let size = self.size();
256 let (left, right) = ((self.rank + size - 1) % size, (self.rank + 1) % size);
257 (
258 self.devices[self.rank][left].clone(),
259 self.devices[self.rank][right].clone(),
260 )
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 const MESH4: &str = r#"[
269 [null, "rdma_en5", "rdma_en4", "rdma_en3"],
270 ["rdma_en5", null, "rdma_en3", "rdma_en4"],
271 ["rdma_en4", "rdma_en3", null, "rdma_en5"],
272 ["rdma_en3", "rdma_en4", "rdma_en5", null]
273 ]"#;
274
275 #[test]
276 fn reads_mlx_device_files() {
277 let d = parse_devices(MESH4).unwrap();
278 assert_eq!(d[1][2], ["rdma_en3"]);
279 assert!(d[2][2].is_empty());
280 let c = Config::new(2, d).unwrap();
281 assert_eq!(c.topology().unwrap(), Topology::Mesh { wires: 1 });
282 assert_eq!(
283 c.clone().prefer_ring(true).topology().unwrap(),
284 Topology::Ring { cables: 1 }
285 );
286 assert_eq!(
287 c.mesh_devices(1),
288 [vec!["rdma_en4", "rdma_en3", "", "rdma_en5"]]
289 );
290 assert_eq!(
291 c.ring_devices(),
292 (vec!["rdma_en3".into()], vec!["rdma_en5".into()])
293 );
294 let two = r#"[[null, ["rdma_en2", "rdma_en3"]], [["rdma_en2", "rdma_en3"], null]]"#;
296 let c = Config::new(0, parse_devices(two).unwrap()).unwrap();
297 assert_eq!(c.topology().unwrap(), Topology::Mesh { wires: 2 });
298 let ring = r#"[[null, "a", null, "b"], ["a", null, "c", null],
300 [null, "c", null, "d"], ["b", null, "d", null]]"#;
301 let c = Config::new(1, parse_devices(ring).unwrap()).unwrap();
302 assert_eq!(c.topology().unwrap(), Topology::Ring { cables: 1 });
303 }
304
305 #[test]
306 fn reads_mlx_and_jaccl_variables() {
307 let file = std::env::temp_dir().join(format!("nuvec-jaccl-{}.json", std::process::id()));
308 std::fs::write(&file, MESH4).unwrap();
309 let path = file.to_str().unwrap().to_string();
310 let vars = |pairs: &[(&str, &str)]| {
311 let pairs: Vec<(String, String)> = pairs
312 .iter()
313 .map(|(a, b)| (a.to_string(), b.to_string()))
314 .collect();
315 move |n: &str| pairs.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone())
316 };
317 assert_eq!(Config::from_vars(vars(&[])).unwrap(), None);
318 let c = Config::from_vars(vars(&[
319 ("MLX_IBV_DEVICES", &path),
320 ("MLX_RANK", "1"),
321 ("JACCL_RANK", "3"),
322 ("MLX_JACCL_COORDINATOR", "10.0.0.1:5000"),
323 ("MLX_JACCL_RING", "1"),
324 ]))
325 .unwrap()
326 .unwrap();
327 assert_eq!((c.rank(), c.size(), c.prefer_ring), (3, 4, true));
328 assert_eq!(c.coordinator.as_deref(), Some("10.0.0.1:5000"));
329 assert!(Config::from_vars(vars(&[("MLX_IBV_DEVICES", &path), ("MLX_RANK", "x")])).is_err());
330 std::fs::remove_file(file).unwrap();
331 }
332
333 #[test]
334 fn invalid_files_name_the_entry() {
335 for (text, want) in [
336 ("[[null, \"a\"]]", "rank 0 lists 2 peers"),
337 ("[[null, 3], [\"a\", null]]", "rank 0 → 1"),
338 ("{}", "array of arrays"),
339 ] {
340 let e = parse_devices(text).unwrap_err().to_string();
341 assert!(e.contains(want), "{text}: {e}");
342 }
343 let self_cable = parse_devices(r#"[["x", "a"], ["a", null]]"#).unwrap();
344 let c = Config::new(0, self_cable).unwrap();
346 assert!(
347 c.mesh_error()
348 .unwrap()
349 .contains("rank 0 lists a cable to itself")
350 );
351 assert_eq!(c.topology().unwrap(), Topology::Ring { cables: 1 });
352 let missing =
353 parse_devices(r#"[[null, "a", null], ["a", null, null], [null, null, null]]"#).unwrap();
354 let e = Config::new(0, missing)
355 .unwrap()
356 .topology()
357 .unwrap_err()
358 .to_string();
359 assert!(
360 e.contains("rank 0 → 2: no cable") && e.contains("rank 0 → 2: 0 cables"),
361 "{e}"
362 );
363 let nine: Vec<Vec<Vec<String>>> = (0..9)
364 .map(|s| {
365 (0..9)
366 .map(|d| if s == d { vec![] } else { vec!["x".into()] })
367 .collect()
368 })
369 .collect();
370 let e = Config::new(0, nine)
371 .unwrap()
372 .topology()
373 .unwrap_err()
374 .to_string();
375 assert!(e.contains("up to 8 ranks"), "{e}");
376 let five = r#"[[null, ["a","b","c","d","e"]], [["a","b","c","d","e"], null]]"#;
377 let c = Config::new(0, parse_devices(five).unwrap()).unwrap();
378 assert_eq!(c.topology().unwrap(), Topology::Mesh { wires: 5 });
379 let e = c.prefer_ring(true).ring_error().unwrap();
380 assert!(e.contains("up to 4 cables"), "{e}");
381 assert!(Config::new(3, parse_devices(MESH4).unwrap()).is_ok());
382 assert!(Config::new(4, parse_devices(MESH4).unwrap()).is_err());
383 }
384}