Skip to main content

flodl_cli/util/
system.rs

1//! Cross-platform system detection (CPU, RAM, OS, Docker, GPU).
2
3#[cfg(target_os = "linux")]
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8// ---------------------------------------------------------------------------
9// GPU detection via nvidia-smi
10// ---------------------------------------------------------------------------
11//
12// NOTE: `GpuInfo` + `detect_gpus` + `parse_gpu_csv_row` are intentionally
13// duplicated in `flodl::sys` (flodl/src/sys.rs). flodl-cli must NOT depend on
14// flodl (that would pull flodl-sys → libtorch into the CLI, which has to build
15// and run before libtorch is installed). Keep the nvidia-smi query column
16// order + parse in sync with the copy there.
17
18pub struct GpuInfo {
19    pub index: u8,
20    pub name: String,
21    pub sm_major: u32,
22    pub sm_minor: u32,
23    pub total_memory_mb: u64,
24}
25
26impl GpuInfo {
27    pub fn sm_version(&self) -> String {
28        format!("sm_{}{}", self.sm_major, self.sm_minor)
29    }
30
31    pub fn vram_bytes(&self) -> u64 {
32        self.total_memory_mb * 1024 * 1024
33    }
34
35    pub fn short_name(&self) -> String {
36        self.name.replace("NVIDIA ", "").replace("GeForce ", "")
37    }
38}
39
40pub fn detect_gpus() -> Vec<GpuInfo> {
41    let output = match Command::new("nvidia-smi")
42        // `name` is queried LAST on purpose: it is the only field that can
43        // contain the `", "` field separator (e.g. a name with an embedded
44        // ", "). With it last, `splitn(4, ", ")` captures the whole name
45        // (commas and all) as the final cell — no CSV parser needed, since
46        // index / compute_cap / memory.total are comma-free by construction.
47        .args([
48            "--query-gpu=index,compute_cap,memory.total,name",
49            "--format=csv,noheader,nounits",
50        ])
51        .output()
52    {
53        Ok(o) if o.status.success() => o,
54        // nvidia-smi ran but FAILED (driver/permission issue). Distinct from
55        // "not installed": tooling is present, yet the query errored. Warn —
56        // a silent empty here makes a real GPU rig look GPU-less (e.g.
57        // `Trainer::run` auto-promote silently falling back to single-device)
58        // with no clue why.
59        Ok(o) => {
60            eprintln!(
61                "flodl: nvidia-smi exited {} — treating as no GPUs (stderr: {})",
62                o.status,
63                String::from_utf8_lossy(&o.stderr).trim(),
64            );
65            return Vec::new();
66        }
67        // nvidia-smi not found / not runnable: no NVIDIA tooling. Normal on
68        // CPU-only hosts, so stay silent.
69        Err(_) => return Vec::new(),
70    };
71
72    let stdout = String::from_utf8_lossy(&output.stdout);
73    stdout
74        .lines()
75        .filter(|line| !line.trim().is_empty())
76        .filter_map(|line| {
77            let parsed = parse_gpu_csv_row(line);
78            if parsed.is_none() {
79                // A malformed row = a GPU silently dropped. Warn rather than
80                // vanish it.
81                eprintln!("flodl: could not parse an nvidia-smi GPU row, skipping: {line:?}");
82            }
83            parsed
84        })
85        .collect()
86}
87
88/// Parse one `index, compute_cap, memory.total, name` CSV row (nvidia-smi
89/// `--format=csv,noheader,nounits`; see the query in [`detect_gpus`] for why
90/// `name` is last). Returns None on any malformed field.
91fn parse_gpu_csv_row(line: &str) -> Option<GpuInfo> {
92    // `name` is last, so `splitn(4)` leaves any embedded ", " intact in the
93    // final cell — the first three cells are comma-free.
94    let parts: Vec<&str> = line.splitn(4, ", ").collect();
95    if parts.len() < 4 {
96        return None;
97    }
98    let index: u8 = parts[0].trim().parse().ok()?;
99    let cap_parts: Vec<&str> = parts[1].trim().split('.').collect();
100    let sm_major: u32 = cap_parts.first()?.parse().ok()?;
101    let sm_minor: u32 = cap_parts.get(1)?.parse().ok()?;
102    let total_memory_mb: u64 = parts[2].trim().parse().ok()?;
103    let name = parts[3].trim().to_string();
104    Some(GpuInfo {
105        index,
106        name,
107        sm_major,
108        sm_minor,
109        total_memory_mb,
110    })
111}
112
113pub fn nvidia_driver_version() -> Option<String> {
114    let output = Command::new("nvidia-smi")
115        .args(["--query-gpu=driver_version", "--format=csv,noheader"])
116        .output()
117        .ok()?;
118    if !output.status.success() {
119        return None;
120    }
121    let s = String::from_utf8_lossy(&output.stdout);
122    Some(s.lines().next()?.trim().to_string())
123}
124
125// ---------------------------------------------------------------------------
126// CPU
127// ---------------------------------------------------------------------------
128
129#[cfg(target_os = "linux")]
130pub fn cpu_model() -> Option<String> {
131    let info = fs::read_to_string("/proc/cpuinfo").ok()?;
132    for line in info.lines() {
133        if let Some(rest) = line.strip_prefix("model name") {
134            if let Some(val) = rest.split(':').nth(1) {
135                return Some(val.trim().to_string());
136            }
137        }
138    }
139    None
140}
141
142#[cfg(target_os = "macos")]
143pub fn cpu_model() -> Option<String> {
144    let out = Command::new("sysctl")
145        .args(["-n", "machdep.cpu.brand_string"])
146        .output()
147        .ok()?;
148    if !out.status.success() {
149        return None;
150    }
151    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
152    if s.is_empty() { None } else { Some(s) }
153}
154
155#[cfg(target_os = "windows")]
156pub fn cpu_model() -> Option<String> {
157    let out = Command::new("wmic")
158        .args(["cpu", "get", "Name", "/value"])
159        .output()
160        .ok()?;
161    let s = String::from_utf8_lossy(&out.stdout);
162    for line in s.lines() {
163        if let Some(val) = line.strip_prefix("Name=") {
164            let v = val.trim();
165            if !v.is_empty() {
166                return Some(v.to_string());
167            }
168        }
169    }
170    None
171}
172
173#[cfg(target_os = "linux")]
174pub fn cpu_threads() -> usize {
175    fs::read_to_string("/proc/cpuinfo")
176        .ok()
177        .map(|s| s.lines().filter(|l| l.starts_with("processor")).count())
178        .unwrap_or(1)
179}
180
181#[cfg(target_os = "macos")]
182pub fn cpu_threads() -> usize {
183    Command::new("sysctl")
184        .args(["-n", "hw.logicalcpu"])
185        .output()
186        .ok()
187        .and_then(|o| {
188            String::from_utf8_lossy(&o.stdout)
189                .trim()
190                .parse()
191                .ok()
192        })
193        .unwrap_or(1)
194}
195
196#[cfg(target_os = "windows")]
197pub fn cpu_threads() -> usize {
198    std::env::var("NUMBER_OF_PROCESSORS")
199        .ok()
200        .and_then(|v| v.parse().ok())
201        .unwrap_or(1)
202}
203
204// ---------------------------------------------------------------------------
205// RAM
206// ---------------------------------------------------------------------------
207
208#[cfg(target_os = "linux")]
209pub fn ram_total_gb() -> u64 {
210    fs::read_to_string("/proc/meminfo")
211        .ok()
212        .and_then(|s| {
213            for line in s.lines() {
214                if let Some(rest) = line.strip_prefix("MemTotal:") {
215                    let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
216                    return Some(kb / (1024 * 1024));
217                }
218            }
219            None
220        })
221        .unwrap_or(0)
222}
223
224#[cfg(target_os = "macos")]
225pub fn ram_total_gb() -> u64 {
226    Command::new("sysctl")
227        .args(["-n", "hw.memsize"])
228        .output()
229        .ok()
230        .and_then(|o| {
231            let bytes: u64 = String::from_utf8_lossy(&o.stdout)
232                .trim()
233                .parse()
234                .ok()?;
235            Some(bytes / (1024 * 1024 * 1024))
236        })
237        .unwrap_or(0)
238}
239
240#[cfg(target_os = "windows")]
241pub fn ram_total_gb() -> u64 {
242    Command::new("wmic")
243        .args(["os", "get", "TotalVisibleMemorySize", "/value"])
244        .output()
245        .ok()
246        .and_then(|o| {
247            let s = String::from_utf8_lossy(&o.stdout);
248            for line in s.lines() {
249                if let Some(val) = line.strip_prefix("TotalVisibleMemorySize=") {
250                    let kb: u64 = val.trim().parse().ok()?;
251                    return Some(kb / (1024 * 1024));
252                }
253            }
254            None
255        })
256        .unwrap_or(0)
257}
258
259// ---------------------------------------------------------------------------
260// OS
261// ---------------------------------------------------------------------------
262
263#[cfg(target_os = "linux")]
264pub fn os_version() -> Option<String> {
265    let uname = Command::new("uname").arg("-r").output().ok()?;
266    let kernel = String::from_utf8_lossy(&uname.stdout).trim().to_string();
267    let wsl = if kernel.contains("WSL") || kernel.contains("microsoft") {
268        " (WSL2)"
269    } else {
270        ""
271    };
272    Some(format!("Linux {}{}", kernel, wsl))
273}
274
275#[cfg(target_os = "macos")]
276pub fn os_version() -> Option<String> {
277    let out = Command::new("sw_vers")
278        .args(["-productVersion"])
279        .output()
280        .ok()?;
281    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
282    if ver.is_empty() {
283        return None;
284    }
285    let arch = Command::new("uname")
286        .arg("-m")
287        .output()
288        .ok()
289        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
290        .unwrap_or_default();
291    if arch.is_empty() {
292        Some(format!("macOS {}", ver))
293    } else {
294        Some(format!("macOS {} ({})", ver, arch))
295    }
296}
297
298#[cfg(target_os = "windows")]
299pub fn os_version() -> Option<String> {
300    let out = Command::new("cmd")
301        .args(["/C", "ver"])
302        .output()
303        .ok()?;
304    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
305    if s.is_empty() { None } else { Some(s) }
306}
307
308// ---------------------------------------------------------------------------
309// Docker
310// ---------------------------------------------------------------------------
311
312pub fn is_inside_docker() -> bool {
313    Path::new("/.dockerenv").exists()
314}
315
316pub fn docker_version() -> Option<String> {
317    let out = Command::new("docker").arg("--version").output().ok()?;
318    if !out.status.success() {
319        return None;
320    }
321    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
322    s.split("version ")
323        .nth(1)
324        .and_then(|v| v.split(',').next())
325        .map(|v| v.trim().to_string())
326}
327
328/// Check whether cargo is available on the host.
329#[allow(dead_code)]
330pub fn has_cargo() -> bool {
331    Command::new("cargo")
332        .arg("--version")
333        .output()
334        .is_ok_and(|o| o.status.success())
335}
336
337// ---------------------------------------------------------------------------
338// Helpers
339// ---------------------------------------------------------------------------
340
341/// Check whether a command exists on PATH.
342#[allow(dead_code)]
343pub fn has_command(name: &str) -> bool {
344    Command::new(name)
345        .arg("--version")
346        .stdout(std::process::Stdio::null())
347        .stderr(std::process::Stdio::null())
348        .status()
349        .is_ok()
350}
351
352/// Platform string for download URLs (e.g. "linux-x86_64", "macos-arm64").
353#[allow(dead_code)]
354pub fn platform_tag() -> Option<String> {
355    let os = std::env::consts::OS;
356    let arch = std::env::consts::ARCH;
357    match (os, arch) {
358        ("linux", "x86_64") => Some("linux-x86_64".into()),
359        ("macos", "aarch64") => Some("macos-arm64".into()),
360        ("windows", "x86_64") => Some("windows-x86_64".into()),
361        _ => None,
362    }
363}
364
365/// Escape a string for embedding in a JSON string literal. Complete per
366/// RFC 8259: backslash, quote, and every control char below 0x20. The
367/// hand-rolled predecessors missed `\t` / `\r` — one control character in
368/// a GPU name or mount path produced invalid JSON, which broke cluster
369/// probe fan-in.
370pub fn escape_json(s: &str) -> String {
371    let mut out = String::with_capacity(s.len());
372    for c in s.chars() {
373        match c {
374            '\\' => out.push_str("\\\\"),
375            '"' => out.push_str("\\\""),
376            '\n' => out.push_str("\\n"),
377            '\r' => out.push_str("\\r"),
378            '\t' => out.push_str("\\t"),
379            '\u{08}' => out.push_str("\\b"),
380            '\u{0C}' => out.push_str("\\f"),
381            c if (c as u32) < 0x20 => {
382                let _ = std::fmt::Write::write_fmt(
383                    &mut out,
384                    format_args!("\\u{:04x}", c as u32),
385                );
386            }
387            c => out.push(c),
388        }
389    }
390    out
391}
392
393/// Convert a `;`-separated CUDA capability list into a variant directory
394/// name: `"6.1;12.0"` -> `"sm61-sm120"`, `"12.0"` -> `"sm120"`. Shared by
395/// the libtorch and NCCL source builders so their variant paths cannot drift.
396pub fn arch_dir_name(archs: &str) -> String {
397    archs
398        .split(';')
399        .map(|cap| {
400            let clean = cap.replace('.', "");
401            format!("sm{}", clean)
402        })
403        .collect::<Vec<_>>()
404        .join("-")
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{arch_dir_name, escape_json, parse_gpu_csv_row};
410
411    #[test]
412    fn escape_json_passes_through_plain_ascii() {
413        assert_eq!(escape_json("hello world 123"), "hello world 123");
414    }
415
416    #[test]
417    fn escape_json_escapes_quotes_and_backslashes() {
418        // A Windows-style path with quotes is the realistic hazard for the
419        // probe/diagnose JSON output this feeds.
420        assert_eq!(escape_json(r#"C:\a\b"#), r#"C:\\a\\b"#);
421        assert_eq!(escape_json(r#"say "hi""#), r#"say \"hi\""#);
422    }
423
424    #[test]
425    fn escape_json_escapes_named_control_chars() {
426        assert_eq!(escape_json("a\nb\rc\td"), "a\\nb\\rc\\td");
427        assert_eq!(escape_json("\u{08}\u{0C}"), "\\b\\f");
428    }
429
430    #[test]
431    fn escape_json_uescapes_other_control_chars() {
432        // < 0x20 with no short form -> \uXXXX (lowercase, 4 hex digits).
433        assert_eq!(escape_json("\u{01}"), "\\u0001");
434        assert_eq!(escape_json("\u{1f}"), "\\u001f");
435    }
436
437    #[test]
438    fn escape_json_leaves_non_ascii_unescaped() {
439        // >= 0x20 passes through verbatim, including multibyte UTF-8.
440        assert_eq!(escape_json("café — 日本"), "café — 日本");
441    }
442
443    #[test]
444    fn arch_dir_name_single() {
445        assert_eq!(arch_dir_name("12.0"), "sm120");
446    }
447
448    #[test]
449    fn arch_dir_name_multi() {
450        assert_eq!(arch_dir_name("6.1;12.0"), "sm61-sm120");
451    }
452
453    #[test]
454    fn arch_dir_name_strips_all_dots() {
455        // A three-component cap and a two-digit minor both flatten correctly.
456        assert_eq!(arch_dir_name("7.5"), "sm75");
457        assert_eq!(arch_dir_name("8.0;8.6;9.0"), "sm80-sm86-sm90");
458    }
459
460    #[test]
461    fn parse_gpu_csv_row_parses_a_well_formed_row() {
462        // Column order: index, compute_cap, memory.total, name.
463        let g = parse_gpu_csv_row("0, 8.9, 24564, NVIDIA GeForce RTX 4090").unwrap();
464        assert_eq!(g.index, 0);
465        assert_eq!(g.name, "NVIDIA GeForce RTX 4090");
466        assert_eq!(g.sm_major, 8);
467        assert_eq!(g.sm_minor, 9);
468        assert_eq!(g.total_memory_mb, 24564);
469    }
470
471    #[test]
472    fn parse_gpu_csv_row_rejects_malformed() {
473        // Too few fields, and non-numeric where numbers are required.
474        assert!(parse_gpu_csv_row("0, 8.9, three").is_none());
475        assert!(parse_gpu_csv_row("x, 8.9, 24564, name").is_none());
476        assert!(parse_gpu_csv_row("0, notacap, 24564, name").is_none());
477    }
478
479    #[test]
480    fn parse_gpu_csv_row_keeps_comma_in_name() {
481        // `name` is queried last, so an embedded ", " stays in the final cell
482        // instead of truncating the row. splitn(4) stops after 3 separators.
483        let g = parse_gpu_csv_row("0, 8.0, 81920, NVIDIA A100, 80GB").unwrap();
484        assert_eq!(g.name, "NVIDIA A100, 80GB");
485        assert_eq!(g.index, 0);
486        assert_eq!(g.sm_major, 8);
487        assert_eq!(g.sm_minor, 0);
488        assert_eq!(g.total_memory_mb, 81920);
489    }
490}