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
10// ---------------------------------------------------------------------------
11//
12// The implementation lives in the dependency-free `flodl-hw` crate, which
13// `flodl` depends on too. It does NOT pull libtorch, so fdl still builds and
14// runs before libtorch is installed. `GpuInfo` + the nvidia-smi parse used to
15// be hand-copied between here and `flodl::sys`, kept aligned by a comment;
16// there is now one source.
17//
18// Note the mapping: fdl's `detect_gpus` never honored `CUDA_VISIBLE_DEVICES`,
19// and that is correct for the questions fdl asks ("which libtorch variant
20// covers this box"), which a container mask must not change the answer to. It
21// is therefore `detect_gpus_physical` upstream. `flodl_hw::detect_gpus` is the
22// mask-honoring runtime view, used by `flodl`.
23
24pub use flodl_hw::{
25    GpuInfo, GpuVendor, detect_gpus_physical as detect_gpus, nvidia_driver_version,
26};
27
28// ---------------------------------------------------------------------------
29// CPU
30// ---------------------------------------------------------------------------
31
32#[cfg(target_os = "linux")]
33pub fn cpu_model() -> Option<String> {
34    let info = fs::read_to_string("/proc/cpuinfo").ok()?;
35    for line in info.lines() {
36        if let Some(rest) = line.strip_prefix("model name")
37            && let Some(val) = rest.split(':').nth(1)
38        {
39            return Some(val.trim().to_string());
40        }
41    }
42    None
43}
44
45#[cfg(target_os = "macos")]
46pub fn cpu_model() -> Option<String> {
47    let out = Command::new("sysctl")
48        .args(["-n", "machdep.cpu.brand_string"])
49        .output()
50        .ok()?;
51    if !out.status.success() {
52        return None;
53    }
54    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
55    if s.is_empty() { None } else { Some(s) }
56}
57
58#[cfg(target_os = "windows")]
59pub fn cpu_model() -> Option<String> {
60    let out = Command::new("wmic")
61        .args(["cpu", "get", "Name", "/value"])
62        .output()
63        .ok()?;
64    let s = String::from_utf8_lossy(&out.stdout);
65    for line in s.lines() {
66        if let Some(val) = line.strip_prefix("Name=") {
67            let v = val.trim();
68            if !v.is_empty() {
69                return Some(v.to_string());
70            }
71        }
72    }
73    None
74}
75
76#[cfg(target_os = "linux")]
77pub fn cpu_threads() -> usize {
78    fs::read_to_string("/proc/cpuinfo")
79        .ok()
80        .map(|s| s.lines().filter(|l| l.starts_with("processor")).count())
81        .unwrap_or(1)
82}
83
84#[cfg(target_os = "macos")]
85pub fn cpu_threads() -> usize {
86    Command::new("sysctl")
87        .args(["-n", "hw.logicalcpu"])
88        .output()
89        .ok()
90        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
91        .unwrap_or(1)
92}
93
94#[cfg(target_os = "windows")]
95pub fn cpu_threads() -> usize {
96    std::env::var("NUMBER_OF_PROCESSORS")
97        .ok()
98        .and_then(|v| v.parse().ok())
99        .unwrap_or(1)
100}
101
102// ---------------------------------------------------------------------------
103// RAM
104// ---------------------------------------------------------------------------
105
106#[cfg(target_os = "linux")]
107pub fn ram_total_gb() -> u64 {
108    fs::read_to_string("/proc/meminfo")
109        .ok()
110        .and_then(|s| {
111            for line in s.lines() {
112                if let Some(rest) = line.strip_prefix("MemTotal:") {
113                    let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
114                    return Some(kb / (1024 * 1024));
115                }
116            }
117            None
118        })
119        .unwrap_or(0)
120}
121
122#[cfg(target_os = "macos")]
123pub fn ram_total_gb() -> u64 {
124    Command::new("sysctl")
125        .args(["-n", "hw.memsize"])
126        .output()
127        .ok()
128        .and_then(|o| {
129            let bytes: u64 = String::from_utf8_lossy(&o.stdout).trim().parse().ok()?;
130            Some(bytes / (1024 * 1024 * 1024))
131        })
132        .unwrap_or(0)
133}
134
135#[cfg(target_os = "windows")]
136pub fn ram_total_gb() -> u64 {
137    Command::new("wmic")
138        .args(["os", "get", "TotalVisibleMemorySize", "/value"])
139        .output()
140        .ok()
141        .and_then(|o| {
142            let s = String::from_utf8_lossy(&o.stdout);
143            for line in s.lines() {
144                if let Some(val) = line.strip_prefix("TotalVisibleMemorySize=") {
145                    let kb: u64 = val.trim().parse().ok()?;
146                    return Some(kb / (1024 * 1024));
147                }
148            }
149            None
150        })
151        .unwrap_or(0)
152}
153
154// ---------------------------------------------------------------------------
155// OS
156// ---------------------------------------------------------------------------
157
158#[cfg(target_os = "linux")]
159pub fn os_version() -> Option<String> {
160    let uname = Command::new("uname").arg("-r").output().ok()?;
161    let kernel = String::from_utf8_lossy(&uname.stdout).trim().to_string();
162    let wsl = if kernel.contains("WSL") || kernel.contains("microsoft") {
163        " (WSL2)"
164    } else {
165        ""
166    };
167    Some(format!("Linux {}{}", kernel, wsl))
168}
169
170#[cfg(target_os = "macos")]
171pub fn os_version() -> Option<String> {
172    let out = Command::new("sw_vers")
173        .args(["-productVersion"])
174        .output()
175        .ok()?;
176    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
177    if ver.is_empty() {
178        return None;
179    }
180    let arch = Command::new("uname")
181        .arg("-m")
182        .output()
183        .ok()
184        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
185        .unwrap_or_default();
186    if arch.is_empty() {
187        Some(format!("macOS {}", ver))
188    } else {
189        Some(format!("macOS {} ({})", ver, arch))
190    }
191}
192
193#[cfg(target_os = "windows")]
194pub fn os_version() -> Option<String> {
195    let out = Command::new("cmd").args(["/C", "ver"]).output().ok()?;
196    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
197    if s.is_empty() { None } else { Some(s) }
198}
199
200// ---------------------------------------------------------------------------
201// Docker
202// ---------------------------------------------------------------------------
203
204pub fn is_inside_docker() -> bool {
205    Path::new("/.dockerenv").exists()
206}
207
208pub fn docker_version() -> Option<String> {
209    let out = Command::new("docker").arg("--version").output().ok()?;
210    if !out.status.success() {
211        return None;
212    }
213    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
214    s.split("version ")
215        .nth(1)
216        .and_then(|v| v.split(',').next())
217        .map(|v| v.trim().to_string())
218}
219
220/// Check whether cargo is available on the host.
221#[allow(dead_code)]
222pub fn has_cargo() -> bool {
223    Command::new("cargo")
224        .arg("--version")
225        .output()
226        .is_ok_and(|o| o.status.success())
227}
228
229// ---------------------------------------------------------------------------
230// Helpers
231// ---------------------------------------------------------------------------
232
233/// Check whether a command exists on PATH.
234#[allow(dead_code)]
235pub fn has_command(name: &str) -> bool {
236    Command::new(name)
237        .arg("--version")
238        .stdout(std::process::Stdio::null())
239        .stderr(std::process::Stdio::null())
240        .status()
241        .is_ok()
242}
243
244/// Platform string for download URLs (e.g. "linux-x86_64", "macos-arm64").
245#[allow(dead_code)]
246pub fn platform_tag() -> Option<String> {
247    let os = std::env::consts::OS;
248    let arch = std::env::consts::ARCH;
249    match (os, arch) {
250        ("linux", "x86_64") => Some("linux-x86_64".into()),
251        ("macos", "aarch64") => Some("macos-arm64".into()),
252        ("windows", "x86_64") => Some("windows-x86_64".into()),
253        _ => None,
254    }
255}
256
257/// Escape a string for embedding in a JSON string literal. Complete per
258/// RFC 8259: backslash, quote, and every control char below 0x20. The
259/// hand-rolled predecessors missed `\t` / `\r` — one control character in
260/// a GPU name or mount path produced invalid JSON, which broke cluster
261/// probe fan-in.
262pub fn escape_json(s: &str) -> String {
263    let mut out = String::with_capacity(s.len());
264    for c in s.chars() {
265        match c {
266            '\\' => out.push_str("\\\\"),
267            '"' => out.push_str("\\\""),
268            '\n' => out.push_str("\\n"),
269            '\r' => out.push_str("\\r"),
270            '\t' => out.push_str("\\t"),
271            '\u{08}' => out.push_str("\\b"),
272            '\u{0C}' => out.push_str("\\f"),
273            c if (c as u32) < 0x20 => {
274                let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32));
275            }
276            c => out.push(c),
277        }
278    }
279    out
280}
281
282/// Convert a `;`-separated arch list into a variant directory name.
283/// Shared by the libtorch and NCCL source builders so their variant
284/// paths cannot drift.
285///
286/// NVIDIA capabilities take the `sm` prefix and lose their dot:
287/// `"6.1;12.0"` -> `"sm61-sm120"`. AMD tokens are already their own
288/// name and pass through: `"gfx1030;gfx1100"` -> `"gfx1030-gfx1100"`.
289/// Prefixing those would produce `smgfx1030`, which
290/// `detect::variant_vendor` would then fail to recognise as AMD.
291pub fn arch_dir_name(archs: &str) -> String {
292    archs
293        .split(';')
294        .map(|tok| {
295            let tok = tok.trim();
296            // Only the AMD parse accepts a `gfx…` token, so it doubles
297            // as the discriminator (and normalises case + any
298            // `:sramecc±:xnack±` suffix on the way through).
299            match flodl_hw::GpuArch::parse(flodl_hw::GpuVendor::Amd, tok) {
300                Some(arch) => arch.to_string(),
301                None => format!("sm{}", tok.replace('.', "")),
302            }
303        })
304        .collect::<Vec<_>>()
305        .join("-")
306}
307
308#[cfg(test)]
309mod tests {
310    use super::{arch_dir_name, escape_json};
311
312    #[test]
313    fn escape_json_passes_through_plain_ascii() {
314        assert_eq!(escape_json("hello world 123"), "hello world 123");
315    }
316
317    #[test]
318    fn escape_json_escapes_quotes_and_backslashes() {
319        // A Windows-style path with quotes is the realistic hazard for the
320        // probe/diagnose JSON output this feeds.
321        assert_eq!(escape_json(r#"C:\a\b"#), r#"C:\\a\\b"#);
322        assert_eq!(escape_json(r#"say "hi""#), r#"say \"hi\""#);
323    }
324
325    #[test]
326    fn escape_json_escapes_named_control_chars() {
327        assert_eq!(escape_json("a\nb\rc\td"), "a\\nb\\rc\\td");
328        assert_eq!(escape_json("\u{08}\u{0C}"), "\\b\\f");
329    }
330
331    #[test]
332    fn escape_json_uescapes_other_control_chars() {
333        // < 0x20 with no short form -> \uXXXX (lowercase, 4 hex digits).
334        assert_eq!(escape_json("\u{01}"), "\\u0001");
335        assert_eq!(escape_json("\u{1f}"), "\\u001f");
336    }
337
338    #[test]
339    fn escape_json_leaves_non_ascii_unescaped() {
340        // >= 0x20 passes through verbatim, including multibyte UTF-8.
341        assert_eq!(escape_json("café — 日本"), "café — 日本");
342    }
343
344    #[test]
345    fn arch_dir_name_single() {
346        assert_eq!(arch_dir_name("12.0"), "sm120");
347    }
348
349    #[test]
350    fn arch_dir_name_multi() {
351        assert_eq!(arch_dir_name("6.1;12.0"), "sm61-sm120");
352    }
353
354    #[test]
355    fn arch_dir_name_strips_all_dots() {
356        // A three-component cap and a two-digit minor both flatten correctly.
357        assert_eq!(arch_dir_name("7.5"), "sm75");
358        assert_eq!(arch_dir_name("8.0;8.6;9.0"), "sm80-sm86-sm90");
359    }
360}