Line | Count | Source |
1 | | // SPDX-License-Identifier: Apache-2.0 |
2 | | //! Cross-platform GPU memory (VRAM) detection with **no vendor SDKs**. |
3 | | //! |
4 | | //! `gpu_probe` reports the GPUs visible on the host and how much memory each |
5 | | //! has, using only facilities the OS or driver already ship: |
6 | | //! |
7 | | //! - **NVIDIA** (Linux, Windows): NVML (`libnvidia-ml`) via `nvml-wrapper`, |
8 | | //! loaded at runtime. The CUDA toolkit is not required and nothing links at |
9 | | //! build time. Behind the default `nvidia` feature. |
10 | | //! - **AMD & Intel** (Linux): DRM sysfs under `/sys/class/drm`. Discrete cards |
11 | | //! report dedicated VRAM; integrated GPUs report the shared system-memory |
12 | | //! ceiling (see [`GpuInfo::total_bytes`]). |
13 | | //! - **Apple/macOS**: `system_profiler` + `sysctl` (Apple Silicon reports |
14 | | //! unified memory). |
15 | | //! |
16 | | //! Detection is best-effort: [`detect`] returns an empty `Vec` when no GPU is |
17 | | //! found or the platform is unsupported — never an error. |
18 | | //! |
19 | | //! ```no_run |
20 | | //! for gpu in gpu_probe::detect() { |
21 | | //! println!("{gpu}"); |
22 | | //! } |
23 | | //! ``` |
24 | | |
25 | | mod drm; |
26 | | mod metal; |
27 | | mod nvidia; |
28 | | |
29 | | /// GPU hardware vendor. |
30 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
31 | | #[non_exhaustive] |
32 | | pub enum Vendor { |
33 | | Nvidia, |
34 | | Amd, |
35 | | Intel, |
36 | | Apple, |
37 | | Unknown, |
38 | | } |
39 | | |
40 | | impl std::fmt::Display for Vendor { |
41 | 9 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
42 | 9 | f.write_str(match self { |
43 | 3 | Vendor::Nvidia => "NVIDIA", |
44 | 2 | Vendor::Amd => "AMD", |
45 | 1 | Vendor::Intel => "Intel", |
46 | 2 | Vendor::Apple => "Apple", |
47 | 1 | Vendor::Unknown => "Unknown", |
48 | | }) |
49 | 9 | } |
50 | | } |
51 | | |
52 | | /// A single detected GPU and its memory. |
53 | | #[derive(Debug, Clone, PartialEq, Eq)] |
54 | | #[non_exhaustive] |
55 | | pub struct GpuInfo { |
56 | | /// Human-readable name (e.g. `"NVIDIA GeForce RTX 4090"`). |
57 | | pub name: String, |
58 | | /// Hardware vendor. |
59 | | pub vendor: Vendor, |
60 | | /// Total memory in bytes. For discrete GPUs this is dedicated VRAM; for |
61 | | /// integrated/unified GPUs (Intel iGPUs, AMD APUs, Apple Silicon) it is the |
62 | | /// shared system-memory ceiling available to the GPU, not a dedicated pool. |
63 | | pub total_bytes: u64, |
64 | | /// Free device memory in bytes, when known. |
65 | | pub free_bytes: Option<u64>, |
66 | | /// Used device memory in bytes, when known. |
67 | | pub used_bytes: Option<u64>, |
68 | | } |
69 | | |
70 | | impl std::fmt::Display for GpuInfo { |
71 | 3 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
72 | 3 | write!( |
73 | 3 | f, |
74 | | "{} ({}): {:.1} GiB total", |
75 | | self.name, |
76 | | self.vendor, |
77 | 3 | gib(self.total_bytes) |
78 | 0 | )?; |
79 | 3 | if let Some(free1 ) = self.free_bytes { |
80 | 1 | write!(f, ", {:.1} GiB free", gib(free))?0 ; |
81 | 2 | } |
82 | 3 | Ok(()) |
83 | 3 | } |
84 | | } |
85 | | |
86 | | #[allow(clippy::cast_precision_loss)] // display-only; the imprecision is cosmetic |
87 | 7 | fn gib(bytes: u64) -> f64 { |
88 | 7 | bytes as f64 / (1024.0 * 1024.0 * 1024.0) |
89 | 7 | } |
90 | | |
91 | | /// CUDA compute capability, e.g. `8.6` for `sm_86`. |
92 | | /// |
93 | | /// Ordered `major` first, so a host can be checked against a minimum: |
94 | | /// |
95 | | /// ``` |
96 | | /// use gpu_probe::ComputeCapability; |
97 | | /// assert!(ComputeCapability::new(8, 6) >= ComputeCapability::new(8, 0)); |
98 | | /// assert!(ComputeCapability::new(9, 0) >= ComputeCapability::new(8, 9)); |
99 | | /// ``` |
100 | | /// |
101 | | /// Constructible so callers can express such a requirement. |
102 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
103 | | pub struct ComputeCapability { |
104 | | /// Major version — the `8` in `8.6`. |
105 | | pub major: u32, |
106 | | /// Minor version — the `6` in `8.6`. |
107 | | pub minor: u32, |
108 | | } |
109 | | |
110 | | impl ComputeCapability { |
111 | | /// Create a compute capability from its major and minor parts. |
112 | | #[must_use] |
113 | 10 | pub const fn new(major: u32, minor: u32) -> Self { |
114 | 10 | Self { major, minor } |
115 | 10 | } |
116 | | } |
117 | | |
118 | | impl std::fmt::Display for ComputeCapability { |
119 | | /// Renders as `8.6`, matching `nvidia-smi`'s `compute_cap`. |
120 | 2 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
121 | 2 | write!(f, "{}.{}", self.major, self.minor) |
122 | 2 | } |
123 | | } |
124 | | |
125 | | /// A CUDA version, e.g. `12.9`. |
126 | | /// |
127 | | /// Ordered `major` first, so a host can be checked against a minimum: |
128 | | /// |
129 | | /// ``` |
130 | | /// use gpu_probe::CudaVersion; |
131 | | /// assert!(CudaVersion::new(12, 9) >= CudaVersion::new(12, 0)); |
132 | | /// ``` |
133 | | /// |
134 | | /// Constructible so callers can express such a requirement. |
135 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
136 | | pub struct CudaVersion { |
137 | | /// Major version — the `12` in `12.9`. |
138 | | pub major: u32, |
139 | | /// Minor version — the `9` in `12.9`. |
140 | | pub minor: u32, |
141 | | } |
142 | | |
143 | | impl CudaVersion { |
144 | | /// Create a CUDA version from its major and minor parts. |
145 | | #[must_use] |
146 | 5 | pub const fn new(major: u32, minor: u32) -> Self { |
147 | 5 | Self { major, minor } |
148 | 5 | } |
149 | | } |
150 | | |
151 | | impl std::fmt::Display for CudaVersion { |
152 | | /// Renders as `12.9`. |
153 | 1 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
154 | 1 | write!(f, "{}.{}", self.major, self.minor) |
155 | 1 | } |
156 | | } |
157 | | |
158 | | /// Host-wide CUDA properties reported by the NVIDIA driver. |
159 | | /// |
160 | | /// These describe the host and its driver rather than any one GPU, which is why |
161 | | /// they are separate from the per-GPU [`GpuInfo`]. Consumers typically use them |
162 | | /// to select a prebuilt artifact compatible with the host. |
163 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
164 | | #[non_exhaustive] |
165 | | pub struct CudaHost { |
166 | | /// Compute capability of device 0. |
167 | | pub compute_capability: ComputeCapability, |
168 | | /// Version of the installed CUDA driver. |
169 | | pub driver_version: CudaVersion, |
170 | | } |
171 | | |
172 | | /// Detect all GPUs visible on the host. |
173 | | /// |
174 | | /// Best-effort: spawns only read-only platform queries (NVML, `system_profiler`, |
175 | | /// `sysctl`) and reads sysfs. Returns an empty `Vec` on unsupported platforms |
176 | | /// or when no GPU is found. |
177 | | #[must_use] |
178 | 215 | pub fn detect() -> Vec<GpuInfo> { |
179 | 215 | let mut gpus = Vec::new(); |
180 | 215 | gpus.extend(nvidia::detect()); |
181 | 215 | gpus.extend(drm::detect()); |
182 | 215 | gpus.extend(metal::detect()); |
183 | 215 | gpus |
184 | 215 | } |
185 | | |
186 | | /// Host-wide CUDA properties, or `None` when NVML is unavailable — no NVIDIA |
187 | | /// driver, the `nvidia` feature disabled, no device, or a driver reporting |
188 | | /// values that aren't usable. |
189 | | /// |
190 | | /// Shares the one process-wide NVML handle with [`detect`], so calling this on |
191 | | /// a timer does not accumulate resources. |
192 | | /// |
193 | | /// ```no_run |
194 | | /// use gpu_probe::ComputeCapability; |
195 | | /// |
196 | | /// if let Some(cuda) = gpu_probe::cuda_host() { |
197 | | /// println!("sm_{}{} on CUDA {}", |
198 | | /// cuda.compute_capability.major, |
199 | | /// cuda.compute_capability.minor, |
200 | | /// cuda.driver_version); |
201 | | /// |
202 | | /// if cuda.compute_capability >= ComputeCapability::new(8, 0) { |
203 | | /// // pick an Ampere-or-newer build |
204 | | /// } |
205 | | /// } |
206 | | /// ``` |
207 | | #[must_use] |
208 | 211 | pub fn cuda_host() -> Option<CudaHost> { |
209 | 211 | nvidia::cuda_host() |
210 | 211 | } |
211 | | |
212 | | #[cfg(test)] |
213 | | mod tests { |
214 | | use super::*; |
215 | | |
216 | | #[test] |
217 | 1 | fn detect_never_panics() { |
218 | | // Environment-dependent (may be empty on headless CI); exercise the |
219 | | // full path plus the Display impl without asserting a GPU exists. |
220 | 1 | for gpu0 in detect() { |
221 | 0 | assert!(!gpu.name.is_empty()); |
222 | 0 | let _ = gpu.to_string(); |
223 | | } |
224 | 1 | } |
225 | | |
226 | | #[test] |
227 | 1 | fn display_includes_free_when_present() { |
228 | 1 | let gpu = GpuInfo { |
229 | 1 | name: "Test GPU".to_string(), |
230 | 1 | vendor: Vendor::Nvidia, |
231 | 1 | total_bytes: 24 * 1024 * 1024 * 1024, |
232 | 1 | free_bytes: Some(12 * 1024 * 1024 * 1024), |
233 | 1 | used_bytes: Some(12 * 1024 * 1024 * 1024), |
234 | 1 | }; |
235 | 1 | let shown = gpu.to_string(); |
236 | 1 | assert!(shown.contains("NVIDIA")); |
237 | 1 | assert!(shown.contains("24.0 GiB total")); |
238 | 1 | assert!(shown.contains("12.0 GiB free")); |
239 | 1 | } |
240 | | |
241 | | #[test] |
242 | 1 | fn display_omits_free_when_absent() { |
243 | 1 | let gpu = GpuInfo { |
244 | 1 | name: "AMD GPU (card0)".to_string(), |
245 | 1 | vendor: Vendor::Amd, |
246 | 1 | total_bytes: 8 * 1024 * 1024 * 1024, |
247 | 1 | free_bytes: None, |
248 | 1 | used_bytes: None, |
249 | 1 | }; |
250 | 1 | let shown = gpu.to_string(); |
251 | 1 | assert!(shown.contains("8.0 GiB total")); |
252 | 1 | assert!(!shown.contains("free")); |
253 | 1 | } |
254 | | |
255 | | #[test] |
256 | 1 | fn vendor_display_covers_every_variant() { |
257 | 1 | assert_eq!(Vendor::Nvidia.to_string(), "NVIDIA"); |
258 | 1 | assert_eq!(Vendor::Amd.to_string(), "AMD"); |
259 | 1 | assert_eq!(Vendor::Intel.to_string(), "Intel"); |
260 | 1 | assert_eq!(Vendor::Apple.to_string(), "Apple"); |
261 | 1 | assert_eq!(Vendor::Unknown.to_string(), "Unknown"); |
262 | 1 | } |
263 | | |
264 | | #[test] |
265 | 1 | fn gib_converts_using_binary_units() { |
266 | 1 | assert!((gib(0) - 0.0).abs() < f64::EPSILON); |
267 | 1 | assert!((gib(1024 * 1024 * 1024) - 1.0).abs() < f64::EPSILON); |
268 | | // 1.5 GiB exercises the fractional path the Display rounds to one place. |
269 | 1 | assert!((gib(3 * 1024 * 1024 * 1024 / 2) - 1.5).abs() < f64::EPSILON); |
270 | 1 | } |
271 | | |
272 | | #[test] |
273 | 1 | fn display_rounds_to_one_decimal_place() { |
274 | | // 25 GiB + 256 MiB -> 25.25 GiB, which "{:.1}" renders as "25.2". |
275 | 1 | let gpu = GpuInfo { |
276 | 1 | name: "Rounding".to_string(), |
277 | 1 | vendor: Vendor::Nvidia, |
278 | 1 | total_bytes: 25 * 1024 * 1024 * 1024 + 256 * 1024 * 1024, |
279 | 1 | free_bytes: None, |
280 | 1 | used_bytes: None, |
281 | 1 | }; |
282 | 1 | assert!(gpu.to_string().contains("25.2 GiB total")); |
283 | 1 | } |
284 | | |
285 | | #[test] |
286 | 1 | fn detect_results_have_consistent_memory_fields() { |
287 | | // Environment-dependent; asserts invariants only for whatever is present. |
288 | 1 | for gpu0 in detect() { |
289 | 0 | assert!(!gpu.name.is_empty()); |
290 | 0 | if let Some(free) = gpu.free_bytes { |
291 | 0 | assert!(free <= gpu.total_bytes, "free must not exceed total"); |
292 | 0 | } |
293 | 0 | if let (Some(free), Some(used)) = (gpu.free_bytes, gpu.used_bytes) { |
294 | 0 | assert!( |
295 | 0 | free.saturating_add(used) <= gpu.total_bytes.saturating_add(used), |
296 | | "free/used must be coherent", |
297 | | ); |
298 | 0 | } |
299 | | } |
300 | 1 | } |
301 | | |
302 | | #[test] |
303 | 1 | fn versions_display_as_major_dot_minor() { |
304 | 1 | assert_eq!(ComputeCapability::new(8, 6).to_string(), "8.6"); |
305 | 1 | assert_eq!(CudaVersion::new(12, 9).to_string(), "12.9"); |
306 | | // A two-digit minor stays unambiguous — the reason these aren't packed |
307 | | // into a single integer. |
308 | 1 | assert_eq!(ComputeCapability::new(8, 10).to_string(), "8.10"); |
309 | 1 | } |
310 | | |
311 | | #[test] |
312 | 1 | fn versions_order_by_major_then_minor() { |
313 | 1 | assert!(ComputeCapability::new(8, 6) > ComputeCapability::new(8, 0)); |
314 | 1 | assert!(ComputeCapability::new(9, 0) > ComputeCapability::new(8, 9)); |
315 | 1 | assert_eq!(ComputeCapability::new(8, 6), ComputeCapability::new(8, 6)); |
316 | 1 | assert!(CudaVersion::new(12, 9) > CudaVersion::new(12, 0)); |
317 | 1 | assert!(CudaVersion::new(13, 0) > CudaVersion::new(12, 9)); |
318 | | // Packing as `major * 10 + minor` would collide here: 8.10 and 9.0 |
319 | | // both pack to 90, which is why the parts are kept separate. |
320 | 1 | assert!(ComputeCapability::new(9, 0) > ComputeCapability::new(8, 10)); |
321 | 1 | } |
322 | | |
323 | | #[test] |
324 | 1 | fn cuda_host_is_environment_dependent_but_coherent() { |
325 | | // No NVIDIA driver is a valid, passing environment. |
326 | 1 | if let Some(cuda0 ) = cuda_host() { |
327 | 0 | assert!( |
328 | 0 | cuda.compute_capability.major > 0, |
329 | | "a real device has a nonzero major capability", |
330 | | ); |
331 | 0 | assert!(cuda.driver_version.major > 0, "a real driver has a version"); |
332 | 0 | assert_eq!( |
333 | 0 | cuda_host(), |
334 | 0 | Some(cuda), |
335 | | "host/driver properties must be stable across calls", |
336 | | ); |
337 | 1 | } |
338 | 1 | } |
339 | | |
340 | | #[test] |
341 | 1 | fn gpu_info_equality_compares_all_fields() { |
342 | 1 | let base = GpuInfo { |
343 | 1 | name: "G".to_string(), |
344 | 1 | vendor: Vendor::Intel, |
345 | 1 | total_bytes: 16 * 1024 * 1024 * 1024, |
346 | 1 | free_bytes: None, |
347 | 1 | used_bytes: None, |
348 | 1 | }; |
349 | 1 | assert_eq!(base.clone(), base); |
350 | 1 | let mut other = base.clone(); |
351 | 1 | other.vendor = Vendor::Amd; |
352 | 1 | assert_ne!(base, other); |
353 | 1 | } |
354 | | } |