Skip to main content

bv_core/
hardware.rs

1use std::fmt;
2use std::path::Path;
3use std::process::Command;
4
5use sysinfo::{Disks, System};
6
7use crate::cache::CacheLayout;
8use crate::manifest::CudaVersion;
9
10// Detected hardware
11
12#[derive(Debug, Clone)]
13pub struct GpuInfo {
14    pub name: String,
15    pub vram_mb: u64,
16    pub driver_version: Option<String>,
17    pub cuda_version: Option<CudaVersion>,
18}
19
20#[derive(Debug, Clone)]
21pub struct DetectedHardware {
22    pub cpu_cores: u32,
23    pub ram_mb: u64,
24    pub disk_free_mb: u64,
25    pub gpus: Vec<GpuInfo>,
26}
27
28impl DetectedHardware {
29    pub fn detect() -> Self {
30        let mut sys = System::new();
31        sys.refresh_cpu_all();
32        sys.refresh_memory();
33
34        let cpu_cores = sys.cpus().len() as u32;
35        let ram_mb = sys.total_memory() / (1024 * 1024);
36
37        // Disk free is reported for the disk that backs the bv cache root,
38        // since that's where pulled images / fetched datasets actually land.
39        // Using `max(available_space)` across all mounted disks (the previous
40        // behaviour) gave false-positives on multi-volume systems where the
41        // cache disk was small but a large external drive existed.
42        let disk_free_mb = disk_free_for(&CacheLayout::new().root().clone());
43
44        let gpus = detect_gpus();
45
46        Self {
47            cpu_cores,
48            ram_mb,
49            disk_free_mb,
50            gpus,
51        }
52    }
53
54    pub fn ram_gb(&self) -> f64 {
55        self.ram_mb as f64 / 1024.0
56    }
57
58    pub fn disk_free_gb(&self) -> f64 {
59        self.disk_free_mb as f64 / 1024.0
60    }
61}
62
63// Hardware mismatch
64
65#[derive(Debug, Clone)]
66pub enum HardwareMismatch {
67    NoGpu,
68    InsufficientVram {
69        required_gb: u32,
70        available_gb: u32,
71    },
72    CudaTooOld {
73        required: CudaVersion,
74        available: CudaVersion,
75    },
76    NoCuda {
77        required: CudaVersion,
78    },
79    InsufficientRam {
80        required_gb: f64,
81        available_gb: f64,
82    },
83    InsufficientDisk {
84        required_gb: f64,
85        available_gb: f64,
86    },
87}
88
89impl fmt::Display for HardwareMismatch {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            HardwareMismatch::NoGpu => {
93                write!(f, "NVIDIA GPU required but none detected")
94            }
95            HardwareMismatch::InsufficientVram {
96                required_gb,
97                available_gb,
98            } => {
99                write!(
100                    f,
101                    "GPU requires ≥{required_gb} GB VRAM, but best available is {available_gb} GB"
102                )
103            }
104            HardwareMismatch::CudaTooOld {
105                required,
106                available,
107            } => {
108                write!(f, "CUDA ≥{required} required, driver supports {available}")
109            }
110            HardwareMismatch::NoCuda { required } => {
111                write!(f, "CUDA ≥{required} required but no CUDA driver detected")
112            }
113            HardwareMismatch::InsufficientRam {
114                required_gb,
115                available_gb,
116            } => {
117                write!(
118                    f,
119                    "{required_gb:.0} GB RAM required, only {available_gb:.1} GB available"
120                )
121            }
122            HardwareMismatch::InsufficientDisk {
123                required_gb,
124                available_gb,
125            } => {
126                write!(
127                    f,
128                    "{required_gb:.0} GB free disk required, only {available_gb:.1} GB available"
129                )
130            }
131        }
132    }
133}
134
135// GPU detection
136
137fn detect_gpus() -> Vec<GpuInfo> {
138    let output = match Command::new("nvidia-smi")
139        .args([
140            "--query-gpu=name,memory.total,driver_version",
141            "--format=csv,noheader,nounits",
142        ])
143        .output()
144    {
145        Ok(o) if o.status.success() => o,
146        _ => return vec![],
147    };
148
149    let cuda_ver = detect_cuda_version();
150    let stdout = String::from_utf8_lossy(&output.stdout);
151
152    stdout
153        .lines()
154        .filter_map(|line| parse_gpu_csv(line, cuda_ver.clone()))
155        .collect()
156}
157
158fn parse_gpu_csv(line: &str, cuda_version: Option<CudaVersion>) -> Option<GpuInfo> {
159    let parts: Vec<&str> = line.splitn(3, ',').map(str::trim).collect();
160    if parts.len() < 2 {
161        return None;
162    }
163    let name = parts[0].to_string();
164    let vram_mb = parts[1].parse::<u64>().ok()?;
165    let driver_version = parts.get(2).map(|s| s.to_string());
166
167    Some(GpuInfo {
168        name,
169        vram_mb,
170        driver_version,
171        cuda_version,
172    })
173}
174
175/// Parse "CUDA Version: X.Y" from the `nvidia-smi` plain-text header.
176fn detect_cuda_version() -> Option<CudaVersion> {
177    let output = Command::new("nvidia-smi").output().ok()?;
178    if !output.status.success() {
179        return None;
180    }
181    let stdout = String::from_utf8_lossy(&output.stdout);
182    for line in stdout.lines() {
183        if let Some(rest) = line.find("CUDA Version:").map(|i| &line[i + 13..])
184            && let Some(ver_str) = rest.split_whitespace().next()
185        {
186            return ver_str.parse().ok();
187        }
188    }
189    None
190}
191
192/// Return free space (MiB) on the filesystem hosting `target`. Picks the
193/// mounted disk whose mount-point is the longest prefix of `target`'s
194/// canonical path. Falls back to the largest disk if `target` can't be
195/// resolved (e.g. directory doesn't exist yet).
196fn disk_free_for(target: &Path) -> u64 {
197    let disks = Disks::new_with_refreshed_list();
198    if disks.is_empty() {
199        return 0;
200    }
201
202    // Resolve target's canonical path so symlinks / relative paths don't
203    // throw off the longest-prefix match. Walk up to the nearest existing
204    // ancestor — the cache root may not exist on first run.
205    let canonical = ancestor_canonical(target);
206
207    let pick = canonical
208        .as_ref()
209        .and_then(|t| {
210            disks
211                .iter()
212                .filter(|d| t.starts_with(d.mount_point()))
213                .max_by_key(|d| d.mount_point().as_os_str().len())
214        });
215
216    let bytes = match pick {
217        Some(d) => d.available_space(),
218        None => disks
219            .iter()
220            .map(|d| d.available_space())
221            .max()
222            .unwrap_or(0),
223    };
224    bytes / (1024 * 1024)
225}
226
227fn ancestor_canonical(path: &Path) -> Option<std::path::PathBuf> {
228    let mut p = path;
229    loop {
230        if let Ok(c) = p.canonicalize() {
231            return Some(c);
232        }
233        p = p.parent()?;
234    }
235}