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, and AMD APUs their VRAM carveout plus GTT pool (see |
13 | | //! [`GpuInfo::total_bytes`]). AMD cards additionally report their `gfx` |
14 | | //! target from KFD sysfs — no `ROCm` install needed. |
15 | | //! - **Apple/macOS**: `system_profiler` + `sysctl` for the chip and its memory |
16 | | //! ceiling, plus `vm_stat` for the used/free split (Apple Silicon reports |
17 | | //! unified memory, so that split is system-wide). |
18 | | //! |
19 | | //! Host toolchain properties are reported separately from any one GPU: |
20 | | //! [`cuda_host`] for the CUDA driver, [`rocm_host`] for the `ROCm` install, |
21 | | //! [`oneapi_host`] for the Intel `oneAPI` install, and [`vulkan_host`] for the |
22 | | //! Vulkan runtime. |
23 | | //! |
24 | | //! Detection is best-effort: [`detect`] returns an empty `Vec` when no GPU is |
25 | | //! found or the platform is unsupported — never an error. |
26 | | //! |
27 | | //! ```no_run |
28 | | //! for gpu in gpu_probe::detect() { |
29 | | //! println!("{gpu}"); |
30 | | //! } |
31 | | //! ``` |
32 | | |
33 | | mod drm; |
34 | | mod intel; |
35 | | mod kfd; |
36 | | mod metal; |
37 | | mod nvidia; |
38 | | mod oneapi; |
39 | | mod rocm; |
40 | | mod vulkan; |
41 | | |
42 | | /// GPU hardware vendor. |
43 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
44 | | #[non_exhaustive] |
45 | | pub enum Vendor { |
46 | | Nvidia, |
47 | | Amd, |
48 | | Intel, |
49 | | Apple, |
50 | | Unknown, |
51 | | } |
52 | | |
53 | | impl std::fmt::Display for Vendor { |
54 | 11 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
55 | 11 | f.write_str(match self { |
56 | 4 | Vendor::Nvidia => "NVIDIA", |
57 | 3 | Vendor::Amd => "AMD", |
58 | 1 | Vendor::Intel => "Intel", |
59 | 2 | Vendor::Apple => "Apple", |
60 | 1 | Vendor::Unknown => "Unknown", |
61 | | }) |
62 | 11 | } |
63 | | } |
64 | | |
65 | | /// A single detected GPU and its memory. |
66 | | #[derive(Debug, Clone, PartialEq, Eq)] |
67 | | #[non_exhaustive] |
68 | | pub struct GpuInfo { |
69 | | /// Human-readable name (e.g. `"NVIDIA GeForce RTX 4090"`). |
70 | | pub name: String, |
71 | | /// Hardware vendor. |
72 | | pub vendor: Vendor, |
73 | | /// Total memory in bytes. For discrete GPUs this is dedicated VRAM; for |
74 | | /// integrated/unified GPUs (Intel iGPUs, AMD APUs, Apple Silicon) it is the |
75 | | /// shared system-memory ceiling available to the GPU, not a dedicated pool. |
76 | | /// |
77 | | /// An AMD APU reports its BIOS VRAM carveout plus the GTT pool the driver |
78 | | /// allocates from — the latter sized by the kernel's `ttm.pages_limit` — |
79 | | /// since the carveout alone is far below what the part can actually hand |
80 | | /// out (512 MiB of 14.5 GiB on one such part). |
81 | | pub total_bytes: u64, |
82 | | /// Free device memory in bytes, when known. |
83 | | pub free_bytes: Option<u64>, |
84 | | /// Used device memory in bytes, when known. |
85 | | pub used_bytes: Option<u64>, |
86 | | /// The architecture a prebuilt artifact must target to run on this GPU: |
87 | | /// [`ArchTarget::Gfx`] on AMD, from KFD sysfs, and [`ArchTarget::Sm`] on |
88 | | /// NVIDIA, from NVML. |
89 | | /// |
90 | | /// `None` when neither driver reports one — an Apple or Intel GPU, an AMD |
91 | | /// card on a kernel without KFD, or the `nvidia` feature disabled. The |
92 | | /// NVIDIA value also appears on [`CudaHost`], which reports device 0's |
93 | | /// alongside the host driver version; this field is per-GPU. |
94 | | pub arch_target: Option<ArchTarget>, |
95 | | } |
96 | | |
97 | | impl std::fmt::Display for GpuInfo { |
98 | 5 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
99 | 5 | write!(f, "{} ({}", self.name, self.vendor)?0 ; |
100 | 5 | if let Some(arch2 ) = self.arch_target { |
101 | 2 | write!(f, ", {arch}")?0 ; |
102 | 3 | } |
103 | 5 | write!(f, "): {:.1} GiB total", gib(self.total_bytes))?0 ; |
104 | 5 | if let Some(free1 ) = self.free_bytes { |
105 | 1 | write!(f, ", {:.1} GiB free", gib(free))?0 ; |
106 | 4 | } |
107 | 5 | Ok(()) |
108 | 5 | } |
109 | | } |
110 | | |
111 | | #[allow(clippy::cast_precision_loss)] // display-only; the imprecision is cosmetic |
112 | 9 | fn gib(bytes: u64) -> f64 { |
113 | 9 | bytes as f64 / (1024.0 * 1024.0 * 1024.0) |
114 | 9 | } |
115 | | |
116 | | /// CUDA compute capability, e.g. `8.6` for `sm_86`. |
117 | | /// |
118 | | /// Ordered `major` first, so a host can be checked against a minimum: |
119 | | /// |
120 | | /// ``` |
121 | | /// use gpu_probe::ComputeCapability; |
122 | | /// assert!(ComputeCapability::new(8, 6) >= ComputeCapability::new(8, 0)); |
123 | | /// assert!(ComputeCapability::new(9, 0) >= ComputeCapability::new(8, 9)); |
124 | | /// ``` |
125 | | /// |
126 | | /// Constructible so callers can express such a requirement. |
127 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
128 | | pub struct ComputeCapability { |
129 | | /// Major version — the `8` in `8.6`. |
130 | | pub major: u32, |
131 | | /// Minor version — the `6` in `8.6`. |
132 | | pub minor: u32, |
133 | | } |
134 | | |
135 | | impl ComputeCapability { |
136 | | /// Create a compute capability from its major and minor parts. |
137 | | #[must_use] |
138 | 15 | pub const fn new(major: u32, minor: u32) -> Self { |
139 | 15 | Self { major, minor } |
140 | 15 | } |
141 | | } |
142 | | |
143 | | impl std::fmt::Display for ComputeCapability { |
144 | | /// Renders as `8.6`, matching `nvidia-smi`'s `compute_cap`. |
145 | 2 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
146 | 2 | write!(f, "{}.{}", self.major, self.minor) |
147 | 2 | } |
148 | | } |
149 | | |
150 | | /// AMD GPU architecture target, e.g. `gfx1013` — the identifier a `ROCm`/HIP |
151 | | /// code object is built for (`--offload-arch=gfx1013`). |
152 | | /// |
153 | | /// The AMD counterpart of [`ComputeCapability`], and read the same way: to pick |
154 | | /// a prebuilt artifact the host can actually run. Ordered `major` first, so a |
155 | | /// host can be checked against a minimum: |
156 | | /// |
157 | | /// ``` |
158 | | /// use gpu_probe::GfxTarget; |
159 | | /// assert!(GfxTarget::new(10, 3, 0) >= GfxTarget::new(10, 1, 3)); |
160 | | /// assert!(GfxTarget::new(11, 0, 0) >= GfxTarget::new(10, 3, 0)); |
161 | | /// ``` |
162 | | /// |
163 | | /// Constructible so callers can express such a requirement. |
164 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
165 | | pub struct GfxTarget { |
166 | | /// Major version — the `10` in `gfx1013`. |
167 | | pub major: u32, |
168 | | /// Minor version — the `1` in `gfx1013`. |
169 | | pub minor: u32, |
170 | | /// Stepping — the `3` in `gfx1013`, and the `a` in `gfx90a`. |
171 | | pub step: u32, |
172 | | } |
173 | | |
174 | | impl GfxTarget { |
175 | | /// Create a target from its major, minor, and stepping parts. |
176 | | #[must_use] |
177 | 26 | pub const fn new(major: u32, minor: u32, step: u32) -> Self { |
178 | 26 | Self { major, minor, step } |
179 | 26 | } |
180 | | } |
181 | | |
182 | | impl std::fmt::Display for GfxTarget { |
183 | | /// Renders as `gfx1013`, matching `--offload-arch`. Minor and stepping are |
184 | | /// single hex digits there, so `9.0.10` renders as `gfx90a`. |
185 | 8 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
186 | 8 | write!(f, "gfx{}{:x}{:x}", self.major, self.minor, self.step) |
187 | 8 | } |
188 | | } |
189 | | |
190 | | /// Intel GPU architecture family, e.g. [`IntelArch::XeHpg`] for an Arc A-series |
191 | | /// card. |
192 | | /// |
193 | | /// Coarser than its AMD and NVIDIA counterparts by necessity. Neither `i915` |
194 | | /// nor `xe` publishes an architecture anywhere readable, so this is derived |
195 | | /// from the PCI device id, which identifies the family reliably but not the |
196 | | /// exact product. The `ocloc -device` value for an ahead-of-time build (`dg2`, |
197 | | /// `acm-g10`, …) is more specific than this; treat it as "which generation is |
198 | | /// this" rather than a literal compiler argument. |
199 | | /// |
200 | | /// Deliberately not ordered: "newer" across integrated and discrete lines is |
201 | | /// not a total order worth implying. |
202 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
203 | | #[non_exhaustive] |
204 | | pub enum IntelArch { |
205 | | /// Xe-LP — Tiger Lake, Rocket Lake, Alder Lake, Raptor Lake, DG1. |
206 | | XeLp, |
207 | | /// Xe-HPG — DG2, sold as Arc A-series (Alchemist). |
208 | | XeHpg, |
209 | | /// Xe-HPC — Ponte Vecchio, sold as Data Center GPU Max. |
210 | | XeHpc, |
211 | | /// Xe-LPG — Meteor Lake and Arrow Lake integrated graphics. |
212 | | XeLpg, |
213 | | /// Xe2 — Lunar Lake integrated graphics, and Arc B-series (Battlemage). |
214 | | Xe2, |
215 | | } |
216 | | |
217 | | impl std::fmt::Display for IntelArch { |
218 | | /// Renders as the lowercase family name — `xe-hpg`, `xe2`. |
219 | 2 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
220 | 2 | f.write_str(match self { |
221 | 0 | IntelArch::XeLp => "xe-lp", |
222 | 1 | IntelArch::XeHpg => "xe-hpg", |
223 | 0 | IntelArch::XeHpc => "xe-hpc", |
224 | 0 | IntelArch::XeLpg => "xe-lpg", |
225 | 1 | IntelArch::Xe2 => "xe2", |
226 | | }) |
227 | 2 | } |
228 | | } |
229 | | |
230 | | /// Apple GPU family, e.g. `apple8` for an M2. |
231 | | /// |
232 | | /// The Metal feature tier a shader can be compiled against |
233 | | /// (`MTLGPUFamily.apple8`). Ordered, so a minimum can be expressed: |
234 | | /// |
235 | | /// ``` |
236 | | /// use gpu_probe::AppleFamily; |
237 | | /// assert!(AppleFamily::new(9) >= AppleFamily::new(8)); |
238 | | /// ``` |
239 | | /// |
240 | | /// Unlike a `gfx` target or a compute capability, this does not select a build |
241 | | /// artifact — a `.metallib` is not per-family — so it reads as a capability |
242 | | /// tier. It is derived from the chip name `system_profiler` reports, since |
243 | | /// querying it properly means linking Metal. |
244 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
245 | | pub struct AppleFamily { |
246 | | /// Family generation — the `8` in `apple8`. |
247 | | pub generation: u32, |
248 | | } |
249 | | |
250 | | impl AppleFamily { |
251 | | /// Create a family from its generation number. |
252 | | #[must_use] |
253 | 24 | pub const fn new(generation: u32) -> Self { |
254 | 24 | Self { generation } |
255 | 24 | } |
256 | | } |
257 | | |
258 | | impl std::fmt::Display for AppleFamily { |
259 | | /// Renders as `apple8`, matching the `MTLGPUFamily` name. |
260 | 2 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
261 | 2 | write!(f, "apple{}", self.generation) |
262 | 2 | } |
263 | | } |
264 | | |
265 | | /// The architecture a prebuilt GPU artifact must target. |
266 | | /// |
267 | | /// Each vendor names this differently but uses it the same way — to select a |
268 | | /// build the device can actually run — so one field carries whichever form |
269 | | /// applies. A GPU has at most one, which the type enforces. |
270 | | /// |
271 | | /// ``` |
272 | | /// use gpu_probe::{ArchTarget, GfxTarget}; |
273 | | /// |
274 | | /// let target = ArchTarget::Gfx(GfxTarget::new(10, 1, 3)); |
275 | | /// assert_eq!(target.to_string(), "gfx1013"); |
276 | | /// assert_eq!(target.gfx(), Some(GfxTarget::new(10, 1, 3))); |
277 | | /// assert_eq!(target.sm(), None); |
278 | | /// ``` |
279 | | /// |
280 | | /// Deliberately not `Ord`: comparing an AMD target against an NVIDIA one has no |
281 | | /// meaning. Order within a vendor by matching out [`GfxTarget`] or |
282 | | /// [`ComputeCapability`], both of which are ordered. |
283 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
284 | | #[non_exhaustive] |
285 | | pub enum ArchTarget { |
286 | | /// AMD: the `--offload-arch` value a `ROCm`/HIP code object is built for. |
287 | | Gfx(GfxTarget), |
288 | | /// NVIDIA: the compute capability a CUDA artifact is built for. |
289 | | Sm(ComputeCapability), |
290 | | /// Intel: the GPU architecture family, from the PCI device id. |
291 | | Xe(IntelArch), |
292 | | /// Apple: the Metal GPU family. A capability tier rather than a build |
293 | | /// target — see [`AppleFamily`]. |
294 | | Apple(AppleFamily), |
295 | | } |
296 | | |
297 | | impl ArchTarget { |
298 | | /// The AMD target, or `None` when this names another vendor's. |
299 | | #[must_use] |
300 | 6 | pub const fn gfx(self) -> Option<GfxTarget> { |
301 | 6 | match self { |
302 | 2 | Self::Gfx(target) => Some(target), |
303 | 4 | _ => None, |
304 | | } |
305 | 6 | } |
306 | | |
307 | | /// The NVIDIA compute capability, or `None` when this names another |
308 | | /// vendor's. |
309 | | #[must_use] |
310 | 6 | pub const fn sm(self) -> Option<ComputeCapability> { |
311 | 6 | match self { |
312 | 2 | Self::Sm(capability) => Some(capability), |
313 | 4 | _ => None, |
314 | | } |
315 | 6 | } |
316 | | |
317 | | /// The Intel architecture family, or `None` when this names another |
318 | | /// vendor's. |
319 | | #[must_use] |
320 | 4 | pub const fn xe(self) -> Option<IntelArch> { |
321 | 4 | match self { |
322 | 1 | Self::Xe(arch) => Some(arch), |
323 | 3 | _ => None, |
324 | | } |
325 | 4 | } |
326 | | |
327 | | /// The Apple GPU family, or `None` when this names another vendor's. |
328 | | #[must_use] |
329 | 4 | pub const fn apple(self) -> Option<AppleFamily> { |
330 | 4 | match self { |
331 | 1 | Self::Apple(family) => Some(family), |
332 | 3 | _ => None, |
333 | | } |
334 | 4 | } |
335 | | } |
336 | | |
337 | | impl std::fmt::Display for ArchTarget { |
338 | | /// Renders in the form each vendor's toolchain expects: `gfx1013` for |
339 | | /// `--offload-arch`, `sm_89` for CUDA. |
340 | 7 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
341 | 7 | match self { |
342 | 2 | Self::Gfx(target) => write!(f, "{target}"), |
343 | 2 | Self::Sm(capability) => { |
344 | 2 | write!(f, "sm_{}{}", capability.major, capability.minor) |
345 | | } |
346 | 2 | Self::Xe(arch) => write!(f, "{arch}"), |
347 | 1 | Self::Apple(family) => write!(f, "{family}"), |
348 | | } |
349 | 7 | } |
350 | | } |
351 | | |
352 | | /// A CUDA version, e.g. `12.9`. |
353 | | /// |
354 | | /// Ordered `major` first, so a host can be checked against a minimum: |
355 | | /// |
356 | | /// ``` |
357 | | /// use gpu_probe::CudaVersion; |
358 | | /// assert!(CudaVersion::new(12, 9) >= CudaVersion::new(12, 0)); |
359 | | /// ``` |
360 | | /// |
361 | | /// Constructible so callers can express such a requirement. |
362 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
363 | | pub struct CudaVersion { |
364 | | /// Major version — the `12` in `12.9`. |
365 | | pub major: u32, |
366 | | /// Minor version — the `9` in `12.9`. |
367 | | pub minor: u32, |
368 | | } |
369 | | |
370 | | impl CudaVersion { |
371 | | /// Create a CUDA version from its major and minor parts. |
372 | | #[must_use] |
373 | 5 | pub const fn new(major: u32, minor: u32) -> Self { |
374 | 5 | Self { major, minor } |
375 | 5 | } |
376 | | } |
377 | | |
378 | | impl std::fmt::Display for CudaVersion { |
379 | | /// Renders as `12.9`. |
380 | 1 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
381 | 1 | write!(f, "{}.{}", self.major, self.minor) |
382 | 1 | } |
383 | | } |
384 | | |
385 | | /// Host-wide CUDA properties reported by the NVIDIA driver. |
386 | | /// |
387 | | /// These describe the host and its driver rather than any one GPU, which is why |
388 | | /// they are separate from the per-GPU [`GpuInfo`]. Consumers typically use them |
389 | | /// to select a prebuilt artifact compatible with the host. |
390 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
391 | | #[non_exhaustive] |
392 | | pub struct CudaHost { |
393 | | /// Compute capability of device 0. |
394 | | pub compute_capability: ComputeCapability, |
395 | | /// Version of the installed CUDA driver. |
396 | | pub driver_version: CudaVersion, |
397 | | } |
398 | | |
399 | | /// A `ROCm` release version, e.g. `6.2.4`. |
400 | | /// |
401 | | /// Ordered `major` first, so a host can be checked against a minimum: |
402 | | /// |
403 | | /// ``` |
404 | | /// use gpu_probe::RocmVersion; |
405 | | /// assert!(RocmVersion::new(6, 2, 4) >= RocmVersion::new(6, 0, 0)); |
406 | | /// ``` |
407 | | /// |
408 | | /// Constructible so callers can express such a requirement. |
409 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
410 | | pub struct RocmVersion { |
411 | | /// Major version — the `6` in `6.2.4`. |
412 | | pub major: u32, |
413 | | /// Minor version — the `2` in `6.2.4`. |
414 | | pub minor: u32, |
415 | | /// Patch version — the `4` in `6.2.4`. |
416 | | pub patch: u32, |
417 | | } |
418 | | |
419 | | impl RocmVersion { |
420 | | /// Create a version from its major, minor, and patch parts. |
421 | | #[must_use] |
422 | 16 | pub const fn new(major: u32, minor: u32, patch: u32) -> Self { |
423 | 16 | Self { |
424 | 16 | major, |
425 | 16 | minor, |
426 | 16 | patch, |
427 | 16 | } |
428 | 16 | } |
429 | | } |
430 | | |
431 | | impl std::fmt::Display for RocmVersion { |
432 | | /// Renders as `6.2.4`, matching the `.info/version` file it comes from. |
433 | 3 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
434 | 3 | write!(f, "{}.{}.{}", self.major, self.minor, self.patch) |
435 | 3 | } |
436 | | } |
437 | | |
438 | | /// The host's `ROCm` installation. |
439 | | /// |
440 | | /// The AMD counterpart of [`CudaHost`], but a narrower one: there is no |
441 | | /// driver-side version to report, so this describes the userspace install only. |
442 | | /// See [`rocm_host`] for what its absence does and does not imply. |
443 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
444 | | #[non_exhaustive] |
445 | | pub struct RocmHost { |
446 | | /// Installed `ROCm` release. |
447 | | pub version: RocmVersion, |
448 | | } |
449 | | |
450 | | /// Parse a dotted version — `6.2.4`, `2024.2` — into major, minor, and patch. |
451 | | /// |
452 | | /// A trailing build suffix (`6.2.4-123`) is dropped: it identifies a package |
453 | | /// build, not the release. Patch defaults to `0`, since some releases ship only |
454 | | /// `major.minor`. Shared by the `ROCm` and `oneAPI` probes, which read the same |
455 | | /// shape of version out of different places. |
456 | 41 | fn parse_dotted_version(text: &str) -> Option<(u32, u32, u32)> { |
457 | 41 | let version = text.trim().split(['-', '+']).next()?0 ; |
458 | 41 | let mut parts = version.split('.'); |
459 | 41 | let major31 = parts.next()?0 .trim().parse().ok()?10 ; |
460 | 31 | let minor27 = parts.next()?4 .trim27 ().parse27 ().ok27 ()?0 ; |
461 | 27 | let patch25 = match parts.next() { |
462 | 21 | Some(patch) => patch.trim().parse().ok()?2 , |
463 | 6 | None => 0, |
464 | | }; |
465 | 25 | Some((major, minor, patch)) |
466 | 41 | } |
467 | | |
468 | | /// An Intel `oneAPI` toolkit version, e.g. `2024.2.1`. |
469 | | /// |
470 | | /// Ordered `major` first — which for `oneAPI` is the release year — so a host |
471 | | /// can be checked against a minimum: |
472 | | /// |
473 | | /// ``` |
474 | | /// use gpu_probe::OneApiVersion; |
475 | | /// assert!(OneApiVersion::new(2025, 0, 0) >= OneApiVersion::new(2024, 2, 0)); |
476 | | /// ``` |
477 | | /// |
478 | | /// Constructible so callers can express such a requirement. |
479 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
480 | | pub struct OneApiVersion { |
481 | | /// Major version — the release year, the `2024` in `2024.2.1`. |
482 | | pub major: u32, |
483 | | /// Minor version — the `2` in `2024.2.1`. |
484 | | pub minor: u32, |
485 | | /// Patch version — the `1` in `2024.2.1`. |
486 | | pub patch: u32, |
487 | | } |
488 | | |
489 | | impl OneApiVersion { |
490 | | /// Create a version from its major, minor, and patch parts. |
491 | | #[must_use] |
492 | 13 | pub const fn new(major: u32, minor: u32, patch: u32) -> Self { |
493 | 13 | Self { |
494 | 13 | major, |
495 | 13 | minor, |
496 | 13 | patch, |
497 | 13 | } |
498 | 13 | } |
499 | | } |
500 | | |
501 | | impl std::fmt::Display for OneApiVersion { |
502 | | /// Renders as `2024.2.1`, matching the install directory it comes from. |
503 | 3 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
504 | 3 | write!(f, "{}.{}.{}", self.major, self.minor, self.patch) |
505 | 3 | } |
506 | | } |
507 | | |
508 | | /// The host's Intel `oneAPI` installation. |
509 | | /// |
510 | | /// The Intel counterpart of [`RocmHost`], and equally narrow: a userspace |
511 | | /// install, with no driver version behind it. See [`oneapi_host`]. |
512 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
513 | | #[non_exhaustive] |
514 | | pub struct OneApiHost { |
515 | | /// Installed `oneAPI` toolkit release. |
516 | | pub version: OneApiVersion, |
517 | | } |
518 | | |
519 | | /// A Vulkan API version, e.g. `1.3.280`. |
520 | | /// |
521 | | /// Ordered `major` first, so a host can be checked against a minimum: |
522 | | /// |
523 | | /// ``` |
524 | | /// use gpu_probe::VulkanVersion; |
525 | | /// assert!(VulkanVersion::new(1, 3, 280) >= VulkanVersion::new(1, 2, 0)); |
526 | | /// ``` |
527 | | /// |
528 | | /// Constructible so callers can express such a requirement. |
529 | | /// |
530 | | /// Gate on `major`/`minor`. Vulkan's patch number is the spec header revision |
531 | | /// and carries no feature guarantee — a 1.4.354 driver and a 1.4.357 loader |
532 | | /// are both Vulkan 1.4 — so a patch-sensitive comparison rejects builds that |
533 | | /// would have run. |
534 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
535 | | pub struct VulkanVersion { |
536 | | /// Major version — the `1` in `1.3.280`. |
537 | | pub major: u32, |
538 | | /// Minor version — the `3` in `1.3.280`. |
539 | | pub minor: u32, |
540 | | /// Patch version — the `280` in `1.3.280`. The spec header revision, not |
541 | | /// a feature level. |
542 | | pub patch: u32, |
543 | | } |
544 | | |
545 | | impl VulkanVersion { |
546 | | /// Create a version from its major, minor, and patch parts. |
547 | | #[must_use] |
548 | 22 | pub const fn new(major: u32, minor: u32, patch: u32) -> Self { |
549 | 22 | Self { |
550 | 22 | major, |
551 | 22 | minor, |
552 | 22 | patch, |
553 | 22 | } |
554 | 22 | } |
555 | | } |
556 | | |
557 | | impl std::fmt::Display for VulkanVersion { |
558 | | /// Renders as `1.3.280`. |
559 | 4 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
560 | 4 | write!(f, "{}.{}.{}", self.major, self.minor, self.patch) |
561 | 4 | } |
562 | | } |
563 | | |
564 | | /// The host's Vulkan runtime. |
565 | | /// |
566 | | /// Reported from the loader and the installed ICD manifests, so — unlike |
567 | | /// [`RocmHost`] and [`OneApiHost`] — this describes a runtime that is actually |
568 | | /// present rather than a toolkit that may be. There is no architecture field: |
569 | | /// SPIR-V is portable and driver-compiled, so a Vulkan build has no per-GPU |
570 | | /// target to match. |
571 | | /// |
572 | | /// Host-level, not per-device: see [`api_version`](Self::api_version) for what |
573 | | /// the number does and does not promise. |
574 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
575 | | #[non_exhaustive] |
576 | | pub struct VulkanHost { |
577 | | /// Highest API version any installed driver advertises in its ICD |
578 | | /// manifest. |
579 | | /// |
580 | | /// A driver's own static declaration on disk, which makes it two things it |
581 | | /// is easy to mistake it for: |
582 | | /// |
583 | | /// - **Not per-device.** With two drivers installed — an AMD iGPU beside |
584 | | /// an NVIDIA dGPU, say — this is the higher of the two and may describe |
585 | | /// neither card. A specific device's version comes from |
586 | | /// `vkGetPhysicalDeviceProperties`, which means linking the loader and |
587 | | /// creating an instance; this crate deliberately does neither. |
588 | | /// - **Not the loader's instance version.** That is what `vulkaninfo` and |
589 | | /// `vkEnumerateInstanceVersion` report, and it is usually the newer of |
590 | | /// the two, so the numbers routinely disagree. The driver's is the one |
591 | | /// that binds in practice: loaders track the current headers while |
592 | | /// drivers implement features on their own schedule. |
593 | | /// |
594 | | /// Compare on `major`/`minor` only — see [`VulkanVersion`]. |
595 | | pub api_version: VulkanVersion, |
596 | | } |
597 | | |
598 | | /// Detect all GPUs visible on the host. |
599 | | /// |
600 | | /// Best-effort: spawns only read-only platform queries (NVML, `system_profiler`, |
601 | | /// `sysctl`, `vm_stat`) and reads sysfs. Returns an empty `Vec` on unsupported |
602 | | /// platforms or when no GPU is found. |
603 | | #[must_use] |
604 | 219 | pub fn detect() -> Vec<GpuInfo> { |
605 | 219 | let mut gpus = Vec::new(); |
606 | 219 | gpus.extend(nvidia::detect()); |
607 | 219 | gpus.extend(drm::detect()); |
608 | 219 | gpus.extend(metal::detect()); |
609 | 219 | gpus |
610 | 219 | } |
611 | | |
612 | | /// Host-wide CUDA properties, or `None` when NVML is unavailable — no NVIDIA |
613 | | /// driver, the `nvidia` feature disabled, no device, or a driver reporting |
614 | | /// values that aren't usable. |
615 | | /// |
616 | | /// Shares the one process-wide NVML handle with [`detect`], so calling this on |
617 | | /// a timer does not accumulate resources. |
618 | | /// |
619 | | /// ```no_run |
620 | | /// use gpu_probe::ComputeCapability; |
621 | | /// |
622 | | /// if let Some(cuda) = gpu_probe::cuda_host() { |
623 | | /// println!("sm_{}{} on CUDA {}", |
624 | | /// cuda.compute_capability.major, |
625 | | /// cuda.compute_capability.minor, |
626 | | /// cuda.driver_version); |
627 | | /// |
628 | | /// if cuda.compute_capability >= ComputeCapability::new(8, 0) { |
629 | | /// // pick an Ampere-or-newer build |
630 | | /// } |
631 | | /// } |
632 | | /// ``` |
633 | | #[must_use] |
634 | 211 | pub fn cuda_host() -> Option<CudaHost> { |
635 | 211 | nvidia::cuda_host() |
636 | 211 | } |
637 | | |
638 | | /// The host's `ROCm` installation, or `None` when `ROCm` is not installed. |
639 | | /// |
640 | | /// Read from `$ROCM_PATH/.info/version`, falling back to `/opt/rocm` — the |
641 | | /// plain text file the `rocm-core` package writes. Nothing is linked or |
642 | | /// executed, so this costs one file read. |
643 | | /// |
644 | | /// `Some` is the signal that `ROCm` is installed and the host can run its |
645 | | /// builds. `None` is weaker: the install was not found at the prefixes above, |
646 | | /// which a distro shipping `ROCm` into `/usr` — or a container carrying only |
647 | | /// the runtime libraries — will trigger despite working. Treat `Some` as proof |
648 | | /// and `None` as "probably not, worth confirming". |
649 | | /// |
650 | | /// `None` does not mean the GPU is unusable for compute: the kernel side is a |
651 | | /// separate component, and what a build has to target is |
652 | | /// [`GpuInfo::arch_target`], reported with no `ROCm` installed at all. |
653 | | /// |
654 | | /// ```no_run |
655 | | /// use gpu_probe::RocmVersion; |
656 | | /// |
657 | | /// if let Some(rocm) = gpu_probe::rocm_host() |
658 | | /// && rocm.version >= RocmVersion::new(6, 0, 0) |
659 | | /// { |
660 | | /// // pick a `ROCm` 6 build |
661 | | /// } |
662 | | /// ``` |
663 | | #[must_use] |
664 | 3 | pub fn rocm_host() -> Option<RocmHost> { |
665 | 3 | rocm::host() |
666 | 3 | } |
667 | | |
668 | | /// The host's Intel `oneAPI` installation, or `None` when it is not installed. |
669 | | /// |
670 | | /// Read from the component layout under `$ONEAPI_ROOT`, falling back to |
671 | | /// `/opt/intel/oneapi`. Nothing is linked or executed. |
672 | | /// |
673 | | /// Narrower than it looks: this reports the **toolkit**, not the GPU runtime. |
674 | | /// A host running compute through a distro-packaged Level Zero driver with no |
675 | | /// toolkit installed reports `None`, because reading that runtime's version |
676 | | /// requires linking it rather than reading a file. So `Some` proves the |
677 | | /// toolkit is present, while `None` is the weakest negative of the three |
678 | | /// probes — it does not rule out a usable Level Zero runtime. |
679 | | /// |
680 | | /// ```no_run |
681 | | /// use gpu_probe::OneApiVersion; |
682 | | /// |
683 | | /// if let Some(oneapi) = gpu_probe::oneapi_host() |
684 | | /// && oneapi.version >= OneApiVersion::new(2024, 0, 0) |
685 | | /// { |
686 | | /// // pick a oneAPI 2024-or-newer build |
687 | | /// } |
688 | | /// ``` |
689 | | #[must_use] |
690 | 3 | pub fn oneapi_host() -> Option<OneApiHost> { |
691 | 3 | oneapi::host() |
692 | 3 | } |
693 | | |
694 | | /// The host's Vulkan runtime, or `None` when no loader is installed. |
695 | | /// |
696 | | /// Read from `libvulkan.so.1` plus the ICD manifests under |
697 | | /// `/usr/share/vulkan/icd.d`. Nothing is linked or executed, so this costs a |
698 | | /// handful of file reads. |
699 | | /// |
700 | | /// The version is the highest any installed *driver* advertises — neither the |
701 | | /// loader's instance version nor any one GPU's, both of which would require |
702 | | /// calling into the loader. See [`VulkanHost::api_version`]. |
703 | | /// |
704 | | /// ```no_run |
705 | | /// use gpu_probe::VulkanVersion; |
706 | | /// |
707 | | /// if let Some(vulkan) = gpu_probe::vulkan_host() |
708 | | /// && vulkan.api_version >= VulkanVersion::new(1, 2, 0) |
709 | | /// { |
710 | | /// // pick a Vulkan 1.2-or-newer build |
711 | | /// } |
712 | | /// ``` |
713 | | #[must_use] |
714 | 215 | pub fn vulkan_host() -> Option<VulkanHost> { |
715 | 215 | vulkan::host() |
716 | 215 | } |
717 | | |
718 | | #[cfg(test)] |
719 | | mod tests { |
720 | | use super::*; |
721 | | |
722 | | #[test] |
723 | 1 | fn parses_dotted_versions_in_both_shapes() { |
724 | 1 | assert_eq!(parse_dotted_version("6.2.4-123"), Some((6, 2, 4))); |
725 | 1 | assert_eq!(parse_dotted_version("2024.2"), Some((2024, 2, 0))); |
726 | 1 | assert_eq!(parse_dotted_version(" 5.7.1 "), Some((5, 7, 1))); |
727 | 1 | assert_eq!(parse_dotted_version("6"), None, "a bare major is not one"); |
728 | 1 | assert_eq!(parse_dotted_version("latest"), None); |
729 | 1 | assert_eq!(parse_dotted_version(""), None); |
730 | 1 | } |
731 | | |
732 | | #[test] |
733 | 1 | fn oneapi_version_renders_with_patch() { |
734 | 1 | assert_eq!(OneApiVersion::new(2024, 2, 1).to_string(), "2024.2.1"); |
735 | 1 | assert_eq!(OneApiVersion::new(2025, 0, 0).to_string(), "2025.0.0"); |
736 | 1 | } |
737 | | |
738 | | #[test] |
739 | 1 | fn oneapi_host_is_stable_across_calls() { |
740 | | // Environment-dependent: most hosts have no oneAPI, which is a valid, |
741 | | // passing environment. A filesystem read must not vary between calls. |
742 | 1 | assert_eq!(oneapi_host(), oneapi_host()); |
743 | 1 | } |
744 | | |
745 | | #[test] |
746 | 1 | fn rocm_version_renders_with_patch() { |
747 | 1 | assert_eq!(RocmVersion::new(6, 2, 4).to_string(), "6.2.4"); |
748 | 1 | assert_eq!(RocmVersion::new(6, 2, 0).to_string(), "6.2.0"); |
749 | 1 | } |
750 | | |
751 | | #[test] |
752 | 1 | fn rocm_host_is_stable_across_calls() { |
753 | | // Environment-dependent: most hosts have no ROCm, which is a valid, |
754 | | // passing environment. A filesystem read must not vary between calls. |
755 | 1 | assert_eq!(rocm_host(), rocm_host()); |
756 | 1 | } |
757 | | |
758 | | #[test] |
759 | 1 | fn vulkan_version_renders_with_patch() { |
760 | 1 | assert_eq!(VulkanVersion::new(1, 3, 280).to_string(), "1.3.280"); |
761 | 1 | assert_eq!(VulkanVersion::new(1, 0, 0).to_string(), "1.0.0"); |
762 | | // A three-digit patch is the norm for Vulkan headers, and must not be |
763 | | // packed or truncated the way `1.3` alone would be. |
764 | 1 | assert_eq!(VulkanVersion::new(1, 10, 5).to_string(), "1.10.5"); |
765 | 1 | } |
766 | | |
767 | | #[test] |
768 | 1 | fn vulkan_host_is_stable_across_calls() { |
769 | | // Environment-dependent: a host with no loader is a valid, passing |
770 | | // environment. A filesystem read must not vary between calls. |
771 | 1 | assert_eq!(vulkan_host(), vulkan_host()); |
772 | 1 | } |
773 | | |
774 | | #[test] |
775 | 1 | fn vulkan_host_carries_only_an_api_version() { |
776 | | // No `arch_target` counterpart, deliberately: SPIR-V is portable and |
777 | | // driver-compiled, so there is no per-GPU target to match. This pins |
778 | | // that shape, and the `Copy`/`Eq` derives callers rely on. |
779 | 1 | let host = VulkanHost { |
780 | 1 | api_version: VulkanVersion::new(1, 3, 280), |
781 | 1 | }; |
782 | 1 | let copied = host; |
783 | 1 | assert_eq!(copied, host); |
784 | 1 | assert_eq!(copied.api_version, VulkanVersion::new(1, 3, 280)); |
785 | 1 | assert_ne!( |
786 | | host, |
787 | 1 | VulkanHost { |
788 | 1 | api_version: VulkanVersion::new(1, 2, 0) |
789 | 1 | } |
790 | | ); |
791 | 1 | } |
792 | | |
793 | | #[test] |
794 | 1 | fn detect_never_panics() { |
795 | | // Environment-dependent (may be empty on headless CI); exercise the |
796 | | // full path plus the Display impl without asserting a GPU exists. |
797 | 1 | for gpu0 in detect() { |
798 | 0 | assert!(!gpu.name.is_empty()); |
799 | 0 | let _ = gpu.to_string(); |
800 | | } |
801 | 1 | } |
802 | | |
803 | | #[test] |
804 | 1 | fn display_includes_free_when_present() { |
805 | 1 | let gpu = GpuInfo { |
806 | 1 | name: "Test GPU".to_string(), |
807 | 1 | vendor: Vendor::Nvidia, |
808 | 1 | total_bytes: 24 * 1024 * 1024 * 1024, |
809 | 1 | free_bytes: Some(12 * 1024 * 1024 * 1024), |
810 | 1 | used_bytes: Some(12 * 1024 * 1024 * 1024), |
811 | 1 | arch_target: None, |
812 | 1 | }; |
813 | 1 | let shown = gpu.to_string(); |
814 | 1 | assert!(shown.contains("NVIDIA")); |
815 | 1 | assert!(shown.contains("24.0 GiB total")); |
816 | 1 | assert!(shown.contains("12.0 GiB free")); |
817 | 1 | } |
818 | | |
819 | | #[test] |
820 | 1 | fn display_omits_free_when_absent() { |
821 | 1 | let gpu = GpuInfo { |
822 | 1 | name: "AMD GPU (card0)".to_string(), |
823 | 1 | vendor: Vendor::Amd, |
824 | 1 | total_bytes: 8 * 1024 * 1024 * 1024, |
825 | 1 | free_bytes: None, |
826 | 1 | used_bytes: None, |
827 | 1 | arch_target: None, |
828 | 1 | }; |
829 | 1 | let shown = gpu.to_string(); |
830 | 1 | assert!(shown.contains("8.0 GiB total")); |
831 | 1 | assert!(!shown.contains("free")); |
832 | 1 | } |
833 | | |
834 | | #[test] |
835 | 1 | fn display_includes_gfx_target_when_present() { |
836 | 1 | let gpu = GpuInfo { |
837 | 1 | name: "AMD cyan_skillfish".to_string(), |
838 | 1 | vendor: Vendor::Amd, |
839 | 1 | total_bytes: 15 * 1024 * 1024 * 1024, |
840 | 1 | free_bytes: None, |
841 | 1 | used_bytes: None, |
842 | 1 | arch_target: Some(ArchTarget::Gfx(GfxTarget::new(10, 1, 3))), |
843 | 1 | }; |
844 | 1 | assert!(gpu.to_string().contains("(AMD, gfx1013)")); |
845 | 1 | } |
846 | | |
847 | | #[test] |
848 | 1 | fn display_includes_compute_capability_when_present() { |
849 | 1 | let gpu = GpuInfo { |
850 | 1 | name: "NVIDIA GeForce RTX 4090".to_string(), |
851 | 1 | vendor: Vendor::Nvidia, |
852 | 1 | total_bytes: 24 * 1024 * 1024 * 1024, |
853 | 1 | free_bytes: None, |
854 | 1 | used_bytes: None, |
855 | 1 | arch_target: Some(ArchTarget::Sm(ComputeCapability::new(8, 9))), |
856 | 1 | }; |
857 | 1 | assert!(gpu.to_string().contains("(NVIDIA, sm_89)")); |
858 | 1 | } |
859 | | |
860 | | #[test] |
861 | 1 | fn arch_target_unwraps_only_its_own_vendor() { |
862 | 1 | let amd = ArchTarget::Gfx(GfxTarget::new(10, 1, 3)); |
863 | 1 | assert_eq!(amd.gfx(), Some(GfxTarget::new(10, 1, 3))); |
864 | 1 | assert_eq!(amd.sm(), None); |
865 | | |
866 | 1 | let nvidia = ArchTarget::Sm(ComputeCapability::new(8, 9)); |
867 | 1 | assert_eq!(nvidia.sm(), Some(ComputeCapability::new(8, 9))); |
868 | 1 | assert_eq!(nvidia.gfx(), None); |
869 | 1 | } |
870 | | |
871 | | #[test] |
872 | 1 | fn arch_target_accessors_are_exclusive_across_all_vendors() { |
873 | 1 | let targets = [ |
874 | 1 | ArchTarget::Gfx(GfxTarget::new(10, 1, 3)), |
875 | 1 | ArchTarget::Sm(ComputeCapability::new(8, 9)), |
876 | 1 | ArchTarget::Xe(IntelArch::XeHpg), |
877 | 1 | ArchTarget::Apple(AppleFamily::new(8)), |
878 | 1 | ]; |
879 | 4 | for target in targets1 { |
880 | 4 | let hits = [ |
881 | 4 | target.gfx().is_some(), |
882 | 4 | target.sm().is_some(), |
883 | 4 | target.xe().is_some(), |
884 | 4 | target.apple().is_some(), |
885 | 4 | ]; |
886 | 4 | assert_eq!( |
887 | 4 | hits.iter().filter(|hit| **hit).count(), |
888 | | 1, |
889 | | "{target} must answer exactly one accessor", |
890 | | ); |
891 | | } |
892 | 1 | } |
893 | | |
894 | | #[test] |
895 | 1 | fn intel_and_apple_targets_render_by_family() { |
896 | 1 | assert_eq!(ArchTarget::Xe(IntelArch::XeHpg).to_string(), "xe-hpg"); |
897 | 1 | assert_eq!(ArchTarget::Xe(IntelArch::Xe2).to_string(), "xe2"); |
898 | 1 | assert_eq!(ArchTarget::Apple(AppleFamily::new(8)).to_string(), "apple8"); |
899 | 1 | } |
900 | | |
901 | | #[test] |
902 | 1 | fn apple_families_are_ordered() { |
903 | 1 | assert!(AppleFamily::new(9) > AppleFamily::new(8)); |
904 | 1 | assert!(AppleFamily::new(8) > AppleFamily::new(7)); |
905 | 1 | } |
906 | | |
907 | | #[test] |
908 | 1 | fn arch_target_renders_per_vendor_toolchain() { |
909 | | // `sm_89`, not the bare `8.9` `ComputeCapability` renders on its own, |
910 | | // which would read as a version number in this position. |
911 | 1 | assert_eq!( |
912 | 1 | ArchTarget::Sm(ComputeCapability::new(8, 9)).to_string(), |
913 | | "sm_89" |
914 | | ); |
915 | 1 | assert_eq!( |
916 | 1 | ArchTarget::Gfx(GfxTarget::new(10, 1, 3)).to_string(), |
917 | | "gfx1013" |
918 | | ); |
919 | 1 | } |
920 | | |
921 | | #[test] |
922 | 1 | fn gfx_target_renders_as_offload_arch() { |
923 | 1 | assert_eq!(GfxTarget::new(10, 1, 3).to_string(), "gfx1013"); |
924 | 1 | assert_eq!(GfxTarget::new(10, 3, 0).to_string(), "gfx1030"); |
925 | 1 | assert_eq!(GfxTarget::new(11, 0, 0).to_string(), "gfx1100"); |
926 | | // Stepping 10 is the `a` in `gfx90a`, not a literal "10". |
927 | 1 | assert_eq!(GfxTarget::new(9, 0, 10).to_string(), "gfx90a"); |
928 | 1 | assert_eq!(GfxTarget::new(9, 4, 2).to_string(), "gfx942"); |
929 | 1 | } |
930 | | |
931 | | #[test] |
932 | 1 | fn gfx_targets_order_major_first() { |
933 | 1 | assert!(GfxTarget::new(11, 0, 0) > GfxTarget::new(10, 3, 0)); |
934 | 1 | assert!(GfxTarget::new(10, 3, 0) > GfxTarget::new(10, 1, 3)); |
935 | 1 | assert!(GfxTarget::new(10, 1, 3) > GfxTarget::new(10, 1, 0)); |
936 | 1 | } |
937 | | |
938 | | #[test] |
939 | 1 | fn vendor_display_covers_every_variant() { |
940 | 1 | assert_eq!(Vendor::Nvidia.to_string(), "NVIDIA"); |
941 | 1 | assert_eq!(Vendor::Amd.to_string(), "AMD"); |
942 | 1 | assert_eq!(Vendor::Intel.to_string(), "Intel"); |
943 | 1 | assert_eq!(Vendor::Apple.to_string(), "Apple"); |
944 | 1 | assert_eq!(Vendor::Unknown.to_string(), "Unknown"); |
945 | 1 | } |
946 | | |
947 | | #[test] |
948 | 1 | fn gib_converts_using_binary_units() { |
949 | 1 | assert!((gib(0) - 0.0).abs() < f64::EPSILON); |
950 | 1 | assert!((gib(1024 * 1024 * 1024) - 1.0).abs() < f64::EPSILON); |
951 | | // 1.5 GiB exercises the fractional path the Display rounds to one place. |
952 | 1 | assert!((gib(3 * 1024 * 1024 * 1024 / 2) - 1.5).abs() < f64::EPSILON); |
953 | 1 | } |
954 | | |
955 | | #[test] |
956 | 1 | fn display_rounds_to_one_decimal_place() { |
957 | | // 25 GiB + 256 MiB -> 25.25 GiB, which "{:.1}" renders as "25.2". |
958 | 1 | let gpu = GpuInfo { |
959 | 1 | name: "Rounding".to_string(), |
960 | 1 | vendor: Vendor::Nvidia, |
961 | 1 | total_bytes: 25 * 1024 * 1024 * 1024 + 256 * 1024 * 1024, |
962 | 1 | free_bytes: None, |
963 | 1 | used_bytes: None, |
964 | 1 | arch_target: None, |
965 | 1 | }; |
966 | 1 | assert!(gpu.to_string().contains("25.2 GiB total")); |
967 | 1 | } |
968 | | |
969 | | #[test] |
970 | 1 | fn detect_results_have_consistent_memory_fields() { |
971 | | // Environment-dependent; asserts invariants only for whatever is present. |
972 | 1 | for gpu0 in detect() { |
973 | 0 | assert!(!gpu.name.is_empty()); |
974 | 0 | if let Some(free) = gpu.free_bytes { |
975 | 0 | assert!(free <= gpu.total_bytes, "free must not exceed total"); |
976 | 0 | } |
977 | 0 | if let (Some(free), Some(used)) = (gpu.free_bytes, gpu.used_bytes) { |
978 | 0 | assert!( |
979 | 0 | free.saturating_add(used) <= gpu.total_bytes.saturating_add(used), |
980 | | "free/used must be coherent", |
981 | | ); |
982 | 0 | } |
983 | | } |
984 | 1 | } |
985 | | |
986 | | #[test] |
987 | 1 | fn versions_display_as_major_dot_minor() { |
988 | 1 | assert_eq!(ComputeCapability::new(8, 6).to_string(), "8.6"); |
989 | 1 | assert_eq!(CudaVersion::new(12, 9).to_string(), "12.9"); |
990 | | // A two-digit minor stays unambiguous — the reason these aren't packed |
991 | | // into a single integer. |
992 | 1 | assert_eq!(ComputeCapability::new(8, 10).to_string(), "8.10"); |
993 | 1 | } |
994 | | |
995 | | #[test] |
996 | 1 | fn versions_order_by_major_then_minor() { |
997 | 1 | assert!(ComputeCapability::new(8, 6) > ComputeCapability::new(8, 0)); |
998 | 1 | assert!(ComputeCapability::new(9, 0) > ComputeCapability::new(8, 9)); |
999 | 1 | assert_eq!(ComputeCapability::new(8, 6), ComputeCapability::new(8, 6)); |
1000 | 1 | assert!(CudaVersion::new(12, 9) > CudaVersion::new(12, 0)); |
1001 | 1 | assert!(CudaVersion::new(13, 0) > CudaVersion::new(12, 9)); |
1002 | | // Packing as `major * 10 + minor` would collide here: 8.10 and 9.0 |
1003 | | // both pack to 90, which is why the parts are kept separate. |
1004 | 1 | assert!(ComputeCapability::new(9, 0) > ComputeCapability::new(8, 10)); |
1005 | 1 | } |
1006 | | |
1007 | | #[test] |
1008 | 1 | fn cuda_host_is_environment_dependent_but_coherent() { |
1009 | | // No NVIDIA driver is a valid, passing environment. |
1010 | 1 | if let Some(cuda0 ) = cuda_host() { |
1011 | 0 | assert!( |
1012 | 0 | cuda.compute_capability.major > 0, |
1013 | | "a real device has a nonzero major capability", |
1014 | | ); |
1015 | 0 | assert!(cuda.driver_version.major > 0, "a real driver has a version"); |
1016 | 0 | assert_eq!( |
1017 | 0 | cuda_host(), |
1018 | 0 | Some(cuda), |
1019 | | "host/driver properties must be stable across calls", |
1020 | | ); |
1021 | 1 | } |
1022 | 1 | } |
1023 | | |
1024 | | #[test] |
1025 | 1 | fn gpu_info_equality_compares_all_fields() { |
1026 | 1 | let base = GpuInfo { |
1027 | 1 | name: "G".to_string(), |
1028 | 1 | vendor: Vendor::Intel, |
1029 | 1 | total_bytes: 16 * 1024 * 1024 * 1024, |
1030 | 1 | free_bytes: None, |
1031 | 1 | used_bytes: None, |
1032 | 1 | arch_target: None, |
1033 | 1 | }; |
1034 | 1 | assert_eq!(base.clone(), base); |
1035 | 1 | let mut other = base.clone(); |
1036 | 1 | other.vendor = Vendor::Amd; |
1037 | 1 | assert_ne!(base, other); |
1038 | 1 | } |
1039 | | } |