Skip to main content

flodl_hw/
gpu.rs

1//! Device identity and the top-level sweep entry points.
2
3use std::collections::HashSet;
4
5use crate::report::{GpuSurvey, NoteKind, SurveyNote};
6use crate::vendor::{GpuArch, GpuVendor};
7
8/// One GPU's identity, capability and VRAM.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct GpuInfo {
11    /// The vendor tool's device index (0-based). Matches the index
12    /// libtorch would assign with no visibility mask set. NVML and
13    /// amd-smi queries must use this too: neither honors a mask.
14    pub index: u8,
15    /// Which stack this device belongs to. Separate from the device
16    /// string libtorch is handed, which is `CUDA` for AMD as well as
17    /// NVIDIA. See [`GpuVendor`].
18    pub vendor: GpuVendor,
19    /// Marketing name (e.g. `"NVIDIA GeForce RTX 5060 Ti"`).
20    pub name: String,
21    /// Architecture, in the vendor's own shape. See [`GpuArch`].
22    pub arch: GpuArch,
23    /// Total VRAM in MiB.
24    ///
25    /// On a unified-memory part (an APU whose GPU and system RAM are one
26    /// pool) this is a slice of host RAM, not a separate budget. Nothing
27    /// in flodl reads it that way yet, which is a known gap.
28    pub total_memory_mb: u64,
29}
30
31impl GpuInfo {
32    /// Vendor-neutral architecture label: `"sm_120"`, `"gfx1030"`.
33    /// What display and report code wants.
34    pub fn arch_label(&self) -> String {
35        self.arch.to_string()
36    }
37
38    /// NVIDIA compute capability as `"sm_NN"`, or `None` on any other
39    /// vendor. Use [`GpuInfo::arch_label`] for display; this is for the
40    /// NVIDIA-specific consumers (nvcc gencode flags, CUDA variant
41    /// naming) that genuinely cannot proceed without the numeric pair.
42    pub fn sm_version(&self) -> Option<String> {
43        matches!(self.arch, GpuArch::Sm { .. }).then(|| self.arch.to_string())
44    }
45
46    /// NVIDIA compute-capability major, `None` on any other vendor.
47    pub fn sm_major(&self) -> Option<u32> {
48        self.arch.sm_major()
49    }
50
51    /// NVIDIA compute-capability minor, `None` on any other vendor.
52    pub fn sm_minor(&self) -> Option<u32> {
53        self.arch.sm_minor()
54    }
55
56    /// Total VRAM in bytes.
57    pub fn vram_bytes(&self) -> u64 {
58        self.total_memory_mb * 1024 * 1024
59    }
60
61    /// `name` with the common vendor prefixes stripped, which is kinder
62    /// on `eprintln!`-style banners.
63    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    /// Whether a libtorch variant compiled for `archs` covers this
72    /// device. Delegates to [`GpuArch::covered_by`], whose matching
73    /// rules are vendor-specific.
74    pub fn covered_by(&self, archs: &str) -> bool {
75        self.arch.covered_by(archs)
76    }
77}
78
79/// Sweep the machine for GPUs, ignoring visibility masks.
80///
81/// The full report: every vendor probed, every finding recorded. This is
82/// the **provisioning** view (which libtorch variant covers this box,
83/// what to print in a hardware report), so a container mask must not
84/// change its answer.
85///
86/// Probes are ordered cheap-first: each vendor is gated behind
87/// subprocess-free filesystem checks, so a pure-NVIDIA box never spawns
88/// an AMD tool and a CPU-only box spawns nothing at all.
89///
90/// [`crate::ENV_TESTING_GPU_JSON`] replaces the whole sweep when set,
91/// which is how a second vendor's detection and routing get tested on a
92/// machine that has none of that hardware.
93pub fn survey() -> GpuSurvey {
94    // Checked before any probe so a spoofed run never touches the real
95    // hardware it is standing in for.
96    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
105/// Sweep the machine and apply the visibility masks, reporting what the
106/// **runtime** will actually see.
107///
108/// Use for decisions that must agree with what libtorch would do: DDP
109/// auto-promotion, mode filtering, log banners, CLI-flag validation.
110///
111/// # Masks
112///
113/// Detection is mask-proof by construction (vendor tools report every
114/// physical GPU, and sysfs ignores masks entirely), so the mask is
115/// applied here instead -- and it is **per vendor**, because the
116/// vendors do not read the same variable.
117///
118/// | Vendor | Variable, in precedence order |
119/// |---|---|
120/// | NVIDIA | `CUDA_VISIBLE_DEVICES` |
121/// | AMD | `HIP_VISIBLE_DEVICES`, then `ROCR_VISIBLE_DEVICES`, then `CUDA_VISIBLE_DEVICES` |
122///
123/// HIP honours all three and the first one *set* wins, even when it is
124/// empty. Applying a single variable to every device would mis-count in
125/// both directions on a mixed box: `HIP_VISIBLE_DEVICES=0` would hide
126/// nothing, and `CUDA_VISIBLE_DEVICES=0` would wrongly override an AMD
127/// mask that HIP itself would have preferred.
128///
129/// `0,2` keeps those indices. An empty value, or `-1`, returns nothing
130/// (libtorch's "explicitly no devices", and HIP's convention for the
131/// same). Unset keeps everything. This lets tests scope down via
132/// `CUDA_VISIBLE_DEVICES=0 cargo test` and stops auto-promote
133/// surprising the harness on a multi-GPU box.
134///
135/// A mask that removes devices leaves a [`NoteKind::MaskApplied`] note,
136/// so a caller reporting "0 GPUs" can say whether that was the
137/// operator's own doing.
138pub fn survey_visible() -> GpuSurvey {
139    let mut out = survey();
140    apply_visibility_masks(&mut out);
141    out
142}
143
144/// [`survey_visible`], narrowed to the one vendor a caller can actually
145/// address.
146///
147/// The masks answer "what will the runtime see"; this answers the
148/// separate question "which of those can *this build* talk to". libtorch
149/// is built for exactly one GPU backend and both claim
150/// `DeviceType::CUDA`, so on a mixed box the other vendor's devices are
151/// present, healthy, and unusable. Counting them is not cosmetic: it
152/// feeds the `>= 2` DDP auto-promote decision, which would then hand a
153/// rank a device the build cannot talk to.
154///
155/// Filtering also disambiguates the index space. [`GpuInfo::index`] is
156/// the *vendor tool's* ordinal, so an unfiltered survey of a box with
157/// two NVIDIA cards and one AMD card carries indices `0, 1, 0` -- only
158/// meaningful once split by vendor.
159///
160/// Dropped devices leave a [`NoteKind::VendorMismatch`] note, and it
161/// counts as explaining an absence: a ROCm build on an NVIDIA-only box
162/// reports zero devices, and the note is what turns that into "you built
163/// for ROCm and this machine has NVIDIA hardware" instead of a bare
164/// "no GPUs found".
165pub fn survey_visible_for(vendor: GpuVendor) -> GpuSurvey {
166    let mut out = survey_visible();
167    retain_vendor(&mut out, vendor);
168    out
169}
170
171/// Enumerate the visible devices this build can address. Shorthand for
172/// [`survey_visible_for`] when the caller does not need the findings.
173pub fn detect_gpus_for(vendor: GpuVendor) -> Vec<GpuInfo> {
174    survey_visible_for(vendor).devices
175}
176
177/// Drop every device that is not `vendor`, in place. Split out so the
178/// semantics are testable without hardware of either vendor.
179fn 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
197/// The mask variable a vendor actually reads, and its value.
198///
199/// Returns the **first variable that is set**, not the first non-empty
200/// one: an explicitly empty `HIP_VISIBLE_DEVICES` means "no devices"
201/// and must not fall through to `CUDA_VISIBLE_DEVICES`.
202fn mask_for(vendor: GpuVendor) -> Option<(&'static str, String)> {
203    let order: &[&str] = match vendor {
204        GpuVendor::Nvidia => &["CUDA_VISIBLE_DEVICES"],
205        // HIP's documented precedence. `GPU_DEVICE_ORDINAL` also exists
206        // and is not handled: it is an OpenCL-era selector that HIP
207        // treats differently across versions, and guessing at it would
208        // be worse than the loud under-count a caller can see.
209        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
220/// Apply each vendor's own mask to its own devices.
221fn 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
229/// Enumerate the GPUs the runtime will see. Shorthand for
230/// [`survey_visible`] when the caller does not need the findings.
231pub fn detect_gpus() -> Vec<GpuInfo> {
232    survey_visible().devices
233}
234
235/// Enumerate every installed GPU, ignoring visibility masks. Shorthand
236/// for [`survey`] when the caller does not need the findings.
237pub fn detect_gpus_physical() -> Vec<GpuInfo> {
238    survey().devices
239}
240
241/// Filter one vendor's devices in place by a mask value. Split out so
242/// mask semantics are testable without a GPU.
243fn 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    // Empty and `-1` are the two spellings of "explicitly none": CUDA
251    // treats an empty value as zero devices, HIP accepts `-1` for the
252    // same, and both must beat "unset means everything".
253    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                // CUDA also accepts GPU-<uuid> / MIG-<...> forms this
275                // index filter cannot resolve. Silently dropping them
276                // reported "no GPUs" while libtorch would happily see
277                // one, which is exactly the runtime divergence
278                // detect_gpus exists to prevent.
279                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    // Env mutations must be serialized: cargo test runs in parallel and
309    // these variables are process-global.
310    static ENV_LOCK: Mutex<()> = Mutex::new(());
311
312    /// Acquire the env lock, **recovering from poison**.
313    ///
314    /// A `#[should_panic]` test that holds this lock poisons it, and a
315    /// plain `.unwrap()` then turns that one intentional panic into a
316    /// `PoisonError` cascade across every sibling test -- which is
317    /// exactly what happened here the moment
318    /// `a_malformed_spoof_panics_rather_than_using_real_hardware`
319    /// landed: nine failures, only one of them real. Each locker resets
320    /// the variables it cares about via `EnvGuard`, and those guards
321    /// still run their `Drop` during unwind, so recovering is safe.
322    /// Same reasoning, and same fix, as `flodl_cli::util::test_env`.
323    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
324        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
325    }
326
327    /// RAII helper that snapshots an env var on construction and
328    /// restores it on drop. Pair with `ENV_LOCK`.
329    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            // SAFETY: `ENV_LOCK` serializes env mutations across tests
338            // in this module, and nothing else in this crate reads
339            // these vars concurrently.
340            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            // SAFETY: as above.
346            unsafe { std::env::remove_var(key) };
347            Self { key, prev }
348        }
349    }
350
351    impl Drop for EnvGuard {
352        fn drop(&mut self) {
353            // SAFETY: as above.
354            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    // --- vendor filtering -------------------------------------------
377
378    #[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        // The case that matters: a ROCm build on an NVIDIA-only box.
392        // Zero devices must not read as "no GPU installed".
393        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        // Per-vendor ordinals mean an unfiltered mixed box carries
422        // indices 0, 1, 0 -- ambiguous until split by vendor.
423        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        // A real shape: AMD APU alongside a discrete NVIDIA card.
441        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        // The bug this guards: a CUDA build must not count the APU
450        // toward the >= 2 auto-promote threshold.
451        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        // On CI without GPUs: empty. On a GPU box: parseable info.
482        // Either is fine. Must NOT panic and must NOT touch libtorch.
483        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        // The whole point of Option here: a caller reaching for a
514        // compute capability on a gfx part gets None, not a fabricated
515        // pair that would silently produce a wrong gencode flag.
516        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        // An operator-caused zero must not read as a hardware fault.
537        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        // Listing every device is not a hazard worth a note.
566        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; // No GPUs on this box: nothing to filter.
599        }
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        // A single mask applied to every device mis-counts in BOTH
611        // directions on a mixed box, which is why the filter is
612        // per-vendor.
613        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        // An AMD mask exported on a pure-NVIDIA box must not produce a
653        // note about zero AMD devices.
654        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            // NVIDIA never reads the HIP variables.
672            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        // First variable SET wins, not first non-empty: an explicitly
686        // empty HIP_VISIBLE_DEVICES means "no AMD devices", and falling
687        // through to CUDA_VISIBLE_DEVICES would silently un-hide them.
688        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    // --- the FLODL_TESTING_GPU_JSON injection point -------------------
698    //
699    // These four were reported as landed in P1 and were not: the edit
700    // anchored on a function that had already moved to nvidia.rs, so
701    // the replace silently no-op'd and the test count still rose from
702    // testing.rs. Asserting on every anchor is now the rule.
703
704    #[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        // True on the NVIDIA dev rig too: the spoof is checked before
717        // any probe runs, so the real cards are never consulted.
718        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        // The spoof replaces the HARDWARE, not the mask policy, so the
725        // two layer. The docs promise this.
726        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        // Ties the two halves of P2 together: spoofed AMD hardware,
745        // filtered by HIP's variable while CUDA_VISIBLE_DEVICES says
746        // something else entirely.
747        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        // Exporting the var as "" is how a shell unsets-in-practice;
766        // treating it as an empty device list would silently claim the
767        // box has no GPUs.
768        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        // An empty mask zeroes the runtime view but must not touch the
794        // physical one: the whole reason the two are named apart.
795        let _g_set = EnvGuard::set(CVD, "");
796        assert!(detect_gpus().is_empty());
797        assert_eq!(detect_gpus_physical().len(), physical.len());
798    }
799}