1use std::collections::HashSet;
4
5use crate::report::{GpuSurvey, NoteKind, SurveyNote};
6use crate::vendor::{GpuArch, GpuVendor};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct GpuInfo {
11 pub index: u8,
15 pub vendor: GpuVendor,
19 pub name: String,
21 pub arch: GpuArch,
23 pub total_memory_mb: u64,
29}
30
31impl GpuInfo {
32 pub fn arch_label(&self) -> String {
35 self.arch.to_string()
36 }
37
38 pub fn sm_version(&self) -> Option<String> {
43 matches!(self.arch, GpuArch::Sm { .. }).then(|| self.arch.to_string())
44 }
45
46 pub fn sm_major(&self) -> Option<u32> {
48 self.arch.sm_major()
49 }
50
51 pub fn sm_minor(&self) -> Option<u32> {
53 self.arch.sm_minor()
54 }
55
56 pub fn vram_bytes(&self) -> u64 {
58 self.total_memory_mb * 1024 * 1024
59 }
60
61 pub fn short_name(&self) -> String {
64 self.name
65 .replace("NVIDIA ", "")
66 .replace("GeForce ", "")
67 .replace("AMD ", "")
68 .replace("Advanced Micro Devices, Inc. ", "")
69 }
70
71 pub fn covered_by(&self, archs: &str) -> bool {
75 self.arch.covered_by(archs)
76 }
77}
78
79pub fn survey() -> GpuSurvey {
94 if let Some(spoofed) = crate::testing::spoofed_survey() {
97 return spoofed;
98 }
99 let mut out = GpuSurvey::default();
100 crate::nvidia::probe(&mut out);
101 crate::amd::probe(&mut out);
102 out
103}
104
105pub fn survey_visible() -> GpuSurvey {
139 let mut out = survey();
140 apply_visibility_masks(&mut out);
141 out
142}
143
144pub fn survey_visible_for(vendor: GpuVendor) -> GpuSurvey {
166 let mut out = survey_visible();
167 retain_vendor(&mut out, vendor);
168 out
169}
170
171pub fn detect_gpus_for(vendor: GpuVendor) -> Vec<GpuInfo> {
174 survey_visible_for(vendor).devices
175}
176
177fn retain_vendor(out: &mut GpuSurvey, vendor: GpuVendor) {
180 let before = out.devices.len();
181 out.devices.retain(|g| g.vendor == vendor);
182 let dropped = before - out.devices.len();
183 if dropped == 0 {
184 return;
185 }
186 out.notes.push(SurveyNote {
187 vendor,
188 kind: NoteKind::VendorMismatch,
189 message: format!(
190 "{dropped} device(s) of another vendor are installed and ignored: \
191 this build targets {vendor} and libtorch can only address one \
192 GPU backend per process."
193 ),
194 });
195}
196
197fn mask_for(vendor: GpuVendor) -> Option<(&'static str, String)> {
203 let order: &[&str] = match vendor {
204 GpuVendor::Nvidia => &["CUDA_VISIBLE_DEVICES"],
205 GpuVendor::Amd => &[
210 "HIP_VISIBLE_DEVICES",
211 "ROCR_VISIBLE_DEVICES",
212 "CUDA_VISIBLE_DEVICES",
213 ],
214 };
215 order
216 .iter()
217 .find_map(|k| std::env::var(k).ok().map(|v| (*k, v)))
218}
219
220fn apply_visibility_masks(out: &mut GpuSurvey) {
222 for vendor in out.vendors() {
223 if let Some((var, value)) = mask_for(vendor) {
224 apply_visibility_mask(out, vendor, var, &value);
225 }
226 }
227}
228
229pub fn detect_gpus() -> Vec<GpuInfo> {
232 survey_visible().devices
233}
234
235pub fn detect_gpus_physical() -> Vec<GpuInfo> {
238 survey().devices
239}
240
241fn apply_visibility_mask(out: &mut GpuSurvey, vendor: GpuVendor, var: &str, mask: &str) {
244 let trimmed = mask.trim();
245 let before = out.devices.iter().filter(|g| g.vendor == vendor).count();
246 if before == 0 {
247 return;
248 }
249
250 if trimmed.is_empty() || trimmed == "-1" {
254 out.devices.retain(|g| g.vendor != vendor);
255 out.note(
256 vendor,
257 NoteKind::MaskApplied,
258 format!(
259 "{var}={trimmed:?} hides all {before} {vendor} device(s). \
260 Unset it to use them."
261 ),
262 );
263 return;
264 }
265
266 let mut allowed: HashSet<u8> = HashSet::new();
267 for entry in trimmed.split(',') {
268 let entry = entry.trim();
269 match entry.parse::<u8>() {
270 Ok(idx) => {
271 allowed.insert(idx);
272 }
273 Err(_) => {
274 out.note(
280 vendor,
281 NoteKind::MaskApplied,
282 format!(
283 "{var} entry {entry:?} is not a numeric index \
284 (UUID / MIG forms are not resolved here); {vendor} device \
285 detection may under-count."
286 ),
287 );
288 }
289 }
290 }
291 out.devices
292 .retain(|g| g.vendor != vendor || allowed.contains(&g.index));
293 let hidden = before - out.devices.iter().filter(|g| g.vendor == vendor).count();
294 if hidden > 0 {
295 out.note(
296 vendor,
297 NoteKind::MaskApplied,
298 format!("{var}={trimmed:?} hides {hidden} of {before} {vendor} device(s)."),
299 );
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use std::sync::Mutex;
307
308 static ENV_LOCK: Mutex<()> = Mutex::new(());
311
312 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
324 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
325 }
326
327 struct EnvGuard {
330 key: &'static str,
331 prev: Option<String>,
332 }
333
334 impl EnvGuard {
335 fn set(key: &'static str, value: &str) -> Self {
336 let prev = std::env::var(key).ok();
337 unsafe { std::env::set_var(key, value) };
341 Self { key, prev }
342 }
343 fn unset(key: &'static str) -> Self {
344 let prev = std::env::var(key).ok();
345 unsafe { std::env::remove_var(key) };
347 Self { key, prev }
348 }
349 }
350
351 impl Drop for EnvGuard {
352 fn drop(&mut self) {
353 unsafe {
355 match &self.prev {
356 Some(v) => std::env::set_var(self.key, v),
357 None => std::env::remove_var(self.key),
358 }
359 }
360 }
361 }
362
363 const CVD: &str = "CUDA_VISIBLE_DEVICES";
364 const SPOOF: &str = crate::ENV_TESTING_GPU_JSON;
365
366 fn gpu(index: u8, major: u32, minor: u32) -> GpuInfo {
367 GpuInfo {
368 index,
369 vendor: GpuVendor::Nvidia,
370 name: format!("NVIDIA Test {index}"),
371 arch: GpuArch::Sm { major, minor },
372 total_memory_mb: 8192,
373 }
374 }
375
376 #[test]
379 fn retain_vendor_keeps_only_that_vendors_devices() {
380 let mut sur = GpuSurvey {
381 devices: vec![gpu(0, 12, 0), amd(0, "gfx1036"), gpu(1, 12, 0)],
382 ..Default::default()
383 };
384 retain_vendor(&mut sur, GpuVendor::Nvidia);
385 assert_eq!(sur.devices.len(), 2);
386 assert!(sur.devices.iter().all(|g| g.vendor == GpuVendor::Nvidia));
387 }
388
389 #[test]
390 fn retain_vendor_notes_what_it_dropped_and_explains_absence() {
391 let mut sur = GpuSurvey {
394 devices: vec![gpu(0, 12, 0)],
395 ..Default::default()
396 };
397 retain_vendor(&mut sur, GpuVendor::Amd);
398 assert!(sur.devices.is_empty());
399 let note = sur
400 .notes
401 .iter()
402 .find(|n| n.kind == NoteKind::VendorMismatch)
403 .expect("a dropped device must leave a note");
404 assert!(note.kind.explains_absence());
405 assert!(note.message.contains('1'), "note should say how many");
406 }
407
408 #[test]
409 fn retain_vendor_is_silent_when_nothing_is_dropped() {
410 let mut sur = GpuSurvey {
411 devices: vec![gpu(0, 12, 0)],
412 ..Default::default()
413 };
414 retain_vendor(&mut sur, GpuVendor::Nvidia);
415 assert_eq!(sur.devices.len(), 1);
416 assert!(!sur.notes.iter().any(|n| n.kind == NoteKind::VendorMismatch));
417 }
418
419 #[test]
420 fn vendor_filter_resolves_the_duplicate_index_space() {
421 let mut sur = GpuSurvey {
424 devices: vec![gpu(0, 12, 0), gpu(1, 12, 0), amd(0, "gfx1036")],
425 ..Default::default()
426 };
427 let all: Vec<u8> = sur.devices.iter().map(|g| g.index).collect();
428 assert_eq!(all, vec![0, 1, 0], "precondition: indices collide");
429 retain_vendor(&mut sur, GpuVendor::Amd);
430 assert_eq!(
431 sur.devices.iter().map(|g| g.index).collect::<Vec<_>>(),
432 vec![0]
433 );
434 }
435
436 #[test]
437 fn detect_gpus_for_filters_a_spoofed_mixed_host() {
438 let _lock = env_lock();
439 let _cvd = EnvGuard::unset(CVD);
440 let _spoof = EnvGuard::set(
442 SPOOF,
443 r#"[{"vendor":"nvidia","arch":"sm_120","vram_mb":16384},
444 {"vendor":"amd","arch":"gfx1036","vram_mb":512}]"#,
445 );
446 assert_eq!(detect_gpus().len(), 2, "unfiltered sees both vendors");
447 assert_eq!(detect_gpus_for(GpuVendor::Nvidia).len(), 1);
448 assert_eq!(detect_gpus_for(GpuVendor::Amd).len(), 1);
449 assert!(
452 detect_gpus_for(GpuVendor::Nvidia).len() < 2,
453 "a CUDA build must not see 2 GPUs on this box"
454 );
455 }
456
457 fn amd(index: u8, gfx: &str) -> GpuInfo {
458 GpuInfo {
459 index,
460 vendor: GpuVendor::Amd,
461 name: format!("AMD Test {index}"),
462 arch: GpuArch::Gfx(gfx.into()),
463 total_memory_mb: 16384,
464 }
465 }
466
467 fn masked(devices: Vec<GpuInfo>, mask: &str) -> GpuSurvey {
468 let mut s = GpuSurvey {
469 devices,
470 notes: vec![],
471 };
472 apply_visibility_mask(&mut s, GpuVendor::Nvidia, CVD, mask);
473 s
474 }
475
476 #[test]
477 fn survey_never_panics_and_agrees_with_itself() {
478 let _lock = env_lock();
479 let _g = EnvGuard::unset(CVD);
480 let _s = EnvGuard::unset(SPOOF);
481 let s = survey();
484 for g in &s.devices {
485 assert!(!g.name.is_empty(), "name parsed");
486 assert!(g.total_memory_mb > 0, "VRAM parsed");
487 assert!(!g.arch_label().is_empty(), "arch rendered");
488 }
489 assert_eq!(s.devices.len(), detect_gpus_physical().len());
490 }
491
492 #[test]
493 fn gpu_info_projects_identity_and_capacity() {
494 let g = GpuInfo {
495 index: 0,
496 vendor: GpuVendor::Nvidia,
497 name: "NVIDIA GeForce Test".into(),
498 arch: GpuArch::Sm {
499 major: 12,
500 minor: 0,
501 },
502 total_memory_mb: 16000,
503 };
504 assert_eq!(g.arch_label(), "sm_120");
505 assert_eq!(g.sm_version().as_deref(), Some("sm_120"));
506 assert_eq!(g.sm_major(), Some(12));
507 assert_eq!(g.short_name(), "Test");
508 assert_eq!(g.vram_bytes(), 16000 * 1024 * 1024);
509 }
510
511 #[test]
512 fn an_amd_device_has_no_sm_version() {
513 let g = GpuInfo {
517 index: 0,
518 vendor: GpuVendor::Amd,
519 name: "AMD Radeon RX 6800".into(),
520 arch: GpuArch::Gfx("gfx1030".into()),
521 total_memory_mb: 16384,
522 };
523 assert_eq!(g.arch_label(), "gfx1030");
524 assert_eq!(g.sm_version(), None);
525 assert_eq!(g.sm_major(), None);
526 assert_eq!(g.short_name(), "Radeon RX 6800");
527 assert!(g.covered_by("gfx1030;gfx1100"));
528 }
529
530 #[test]
531 fn empty_mask_hides_everything_and_says_so() {
532 let s = masked(vec![gpu(0, 8, 6), gpu(1, 8, 6)], "");
533 assert!(s.devices.is_empty());
534 assert_eq!(s.notes.len(), 1);
535 assert_eq!(s.notes[0].kind, NoteKind::MaskApplied);
536 assert!(
538 s.require_devices()
539 .unwrap_err()
540 .contains("no GPUs detected")
541 );
542 }
543
544 #[test]
545 fn empty_mask_on_a_gpuless_box_is_not_worth_a_note() {
546 let s = masked(vec![], "");
547 assert!(
548 s.notes.is_empty(),
549 "nothing was hidden, so nothing to report"
550 );
551 }
552
553 #[test]
554 fn mask_filters_by_index_and_reports_the_hidden_count() {
555 let s = masked(vec![gpu(0, 8, 6), gpu(1, 6, 1), gpu(2, 8, 6)], "0,2");
556 assert_eq!(
557 s.devices.iter().map(|g| g.index).collect::<Vec<_>>(),
558 vec![0, 2]
559 );
560 assert!(s.notes.iter().any(|n| n.message.contains("hides 1 of 3")));
561 }
562
563 #[test]
564 fn a_full_mask_is_silent() {
565 let s = masked(vec![gpu(0, 8, 6), gpu(1, 8, 6)], "0,1");
567 assert_eq!(s.devices.len(), 2);
568 assert!(s.notes.is_empty());
569 }
570
571 #[test]
572 fn mask_tolerates_whitespace_and_ignores_unknown_indices() {
573 let s = masked(vec![gpu(0, 8, 6), gpu(1, 8, 6)], " 1 , 99 ");
574 assert_eq!(
575 s.devices.iter().map(|g| g.index).collect::<Vec<_>>(),
576 vec![1]
577 );
578 }
579
580 #[test]
581 fn mask_drops_uuid_forms_rather_than_inventing_devices() {
582 let s = masked(vec![gpu(0, 8, 6)], "GPU-deadbeef");
583 assert!(s.devices.is_empty());
584 assert!(
585 s.notes
586 .iter()
587 .any(|n| n.message.contains("not a numeric index"))
588 );
589 }
590
591 #[test]
592 fn detect_gpus_honors_the_live_mask() {
593 let _lock = env_lock();
594 let _s = EnvGuard::unset(SPOOF);
595 let _g_unset = EnvGuard::unset(CVD);
596 let physical = detect_gpus();
597 if physical.is_empty() {
598 return; }
600 let pick = physical[0].index;
601 drop(_g_unset);
602 let _g_set = EnvGuard::set(CVD, &pick.to_string());
603 let filtered = detect_gpus();
604 assert_eq!(filtered.len(), 1, "single-index filter narrows to one");
605 assert_eq!(filtered[0].index, pick);
606 }
607
608 #[test]
609 fn each_vendor_is_filtered_by_its_own_mask() {
610 let mut s = GpuSurvey {
614 devices: vec![
615 gpu(0, 8, 6),
616 gpu(1, 8, 6),
617 amd(0, "gfx1030"),
618 amd(1, "gfx1100"),
619 ],
620 notes: vec![],
621 };
622 apply_visibility_mask(&mut s, GpuVendor::Nvidia, CVD, "1");
623 apply_visibility_mask(&mut s, GpuVendor::Amd, "HIP_VISIBLE_DEVICES", "0");
624 let kept: Vec<(GpuVendor, u8)> = s.devices.iter().map(|g| (g.vendor, g.index)).collect();
625 assert_eq!(kept, vec![(GpuVendor::Nvidia, 1), (GpuVendor::Amd, 0)]);
626 }
627
628 #[test]
629 fn a_mask_for_one_vendor_leaves_the_other_alone() {
630 let mut s = GpuSurvey {
631 devices: vec![gpu(0, 8, 6), amd(0, "gfx1030")],
632 notes: vec![],
633 };
634 apply_visibility_mask(&mut s, GpuVendor::Amd, "HIP_VISIBLE_DEVICES", "");
635 assert_eq!(s.devices.len(), 1, "the NVIDIA device survives an AMD mask");
636 assert_eq!(s.devices[0].vendor, GpuVendor::Nvidia);
637 }
638
639 #[test]
640 fn minus_one_means_none_for_hip() {
641 let mut s = GpuSurvey {
642 devices: vec![amd(0, "gfx1030")],
643 notes: vec![],
644 };
645 apply_visibility_mask(&mut s, GpuVendor::Amd, "HIP_VISIBLE_DEVICES", "-1");
646 assert!(s.devices.is_empty());
647 assert!(s.notes[0].message.contains("hides all 1"), "{:?}", s.notes);
648 }
649
650 #[test]
651 fn masking_a_vendor_with_no_devices_is_silent() {
652 let mut s = GpuSurvey {
655 devices: vec![gpu(0, 8, 6)],
656 notes: vec![],
657 };
658 apply_visibility_mask(&mut s, GpuVendor::Amd, "HIP_VISIBLE_DEVICES", "");
659 assert_eq!(s.devices.len(), 1);
660 assert!(s.notes.is_empty());
661 }
662
663 #[test]
664 fn hip_mask_precedence_prefers_the_first_variable_that_is_set() {
665 let _lock = env_lock();
666 let _c = EnvGuard::set(CVD, "9");
667 let _r = EnvGuard::set("ROCR_VISIBLE_DEVICES", "5");
668 {
669 let _h = EnvGuard::set("HIP_VISIBLE_DEVICES", "1");
670 assert_eq!(mask_for(GpuVendor::Amd).unwrap().0, "HIP_VISIBLE_DEVICES");
671 assert_eq!(
673 mask_for(GpuVendor::Nvidia).unwrap(),
674 ("CUDA_VISIBLE_DEVICES", "9".to_string()),
675 );
676 }
677 let _h = EnvGuard::unset("HIP_VISIBLE_DEVICES");
678 assert_eq!(mask_for(GpuVendor::Amd).unwrap().0, "ROCR_VISIBLE_DEVICES");
679 let _r2 = EnvGuard::unset("ROCR_VISIBLE_DEVICES");
680 assert_eq!(mask_for(GpuVendor::Amd).unwrap().0, "CUDA_VISIBLE_DEVICES");
681 }
682
683 #[test]
684 fn an_empty_hip_mask_does_not_fall_through_to_cuda() {
685 let _lock = env_lock();
689 let _c = EnvGuard::set(CVD, "0");
690 let _h = EnvGuard::set("HIP_VISIBLE_DEVICES", "");
691 assert_eq!(
692 mask_for(GpuVendor::Amd).unwrap(),
693 ("HIP_VISIBLE_DEVICES", String::new()),
694 );
695 }
696
697 #[test]
705 fn spoof_replaces_the_whole_sweep() {
706 let _lock = env_lock();
707 let _cvd = EnvGuard::unset(CVD);
708 let _s = EnvGuard::set(
709 SPOOF,
710 r#"[{"vendor":"amd","arch":"gfx1030","vram_mb":16384},
711 {"vendor":"amd","arch":"gfx1100","vram_mb":24576}]"#,
712 );
713 let s = survey();
714 assert_eq!(s.devices.len(), 2, "spoof stands in for real hardware");
715 assert!(s.devices.iter().all(|g| g.vendor == GpuVendor::Amd));
716 assert!(!s.has_vendor(GpuVendor::Nvidia));
719 assert_eq!(detect_gpus_physical().len(), 2);
720 }
721
722 #[test]
723 fn spoof_composes_with_the_visibility_mask() {
724 let _lock = env_lock();
727 let _s = EnvGuard::set(
728 SPOOF,
729 r#"[{"arch":"sm_86"},{"arch":"sm_86"},{"arch":"sm_86"},{"arch":"sm_86"}]"#,
730 );
731 let _cvd = EnvGuard::set(CVD, "2");
732 assert_eq!(
733 detect_gpus_physical().len(),
734 4,
735 "physical view ignores the mask"
736 );
737 let visible = detect_gpus();
738 assert_eq!(visible.len(), 1);
739 assert_eq!(visible[0].index, 2);
740 }
741
742 #[test]
743 fn a_spoofed_amd_device_obeys_the_hip_mask_not_the_cuda_one() {
744 let _lock = env_lock();
748 let _s = EnvGuard::set(
749 SPOOF,
750 r#"[{"vendor":"amd","arch":"gfx1030"},{"vendor":"amd","arch":"gfx1100"}]"#,
751 );
752 let _cvd = EnvGuard::set(CVD, "0,1");
753 let _hip = EnvGuard::set("HIP_VISIBLE_DEVICES", "1");
754 let visible = detect_gpus();
755 assert_eq!(
756 visible.len(),
757 1,
758 "HIP_VISIBLE_DEVICES wins over CUDA_VISIBLE_DEVICES"
759 );
760 assert_eq!(visible[0].arch, GpuArch::Gfx("gfx1100".into()));
761 }
762
763 #[test]
764 fn an_empty_spoof_falls_through_to_real_detection() {
765 let _lock = env_lock();
769 let _cvd = EnvGuard::unset(CVD);
770 let real = {
771 let _s = EnvGuard::unset(SPOOF);
772 detect_gpus_physical().len()
773 };
774 let _s = EnvGuard::set(SPOOF, " ");
775 assert_eq!(detect_gpus_physical().len(), real);
776 }
777
778 #[test]
779 #[should_panic(expected = "could not be parsed")]
780 fn a_malformed_spoof_panics_rather_than_using_real_hardware() {
781 let _lock = env_lock();
782 let _s = EnvGuard::set(SPOOF, "{ not json");
783 let _ = survey();
784 }
785
786 #[test]
787 fn detect_gpus_physical_ignores_the_mask() {
788 let _lock = env_lock();
789 let _s = EnvGuard::unset(SPOOF);
790 let _g_unset = EnvGuard::unset(CVD);
791 let physical = detect_gpus_physical();
792 drop(_g_unset);
793 let _g_set = EnvGuard::set(CVD, "");
796 assert!(detect_gpus().is_empty());
797 assert_eq!(detect_gpus_physical().len(), physical.len());
798 }
799}