Skip to main content

rusty_stack/
gpu.rs

1//! GPU classification — the SINGLE source of truth for distinguishing AMD
2//! discrete GPUs (dGPUs) from integrated GPUs (iGPUs).
3//!
4//! # Tenet (v0.3.0 remediation)
5//!
6//! "The iGPU is CONSISTENTLY filtered from env vars and is NEVER included —
7//! EVER. iGPUs are NOT supported and will ONLY cause issues. Intelligent
8//! filtering must avoid including the iGPU from env vars or ANY component,
9//! while ensuring dGPUs are never missed."
10//!
11//! This module exists because the crate previously had **three divergent**
12//! iGPU classifiers (`installer.rs::is_igpu_name`, `bootstrap/env_setup.rs::
13//! is_integrated_gpu_name`, and inline checks in `hardware.rs` /
14//! `platform/linux.rs` / `benchmarks`). They disagreed, and three consumers
15//! bypassed filtering entirely (`textgen`/`comfyui` hardcoded `"0,1"`;
16//! `migraphx_python` trusted inherited `HIP_VISIBLE_DEVICES`). The leaking
17//! iGPU broke the MIGraphX/ONNX worker. Everything now routes through here.
18//!
19//! # Classification rule
20//!
21//! A device is **integrated** if ANY of:
22//! 1. Its PCI device id is in [`INTEGRATED_PCI_DEVICE_IDS`] (structural,
23//!    definitive — e.g. Raphael `0x164e`).
24//! 2. Its marketing/lspci name matches [`is_integrated_gpu_name`] (rich
25//!    curated heuristic: APU codenames, "Ryzen && !RX", APU model suffixes).
26//!
27//! [`device_is_integrated`] additionally treats a low-VRAM device
28//! (`< 4 GiB`) as integrated when the name is ambiguous — but only as a
29//! **confirmation**, never as the sole reason to drop a device whose VRAM is
30//! unreadable (that would risk missing a real dGPU on kernels without
31//! `mem_info_vram_total`). dGPUs (Navi 31/32, 24/16 GB) are never caught.
32
33/// PCI device IDs of known AMD integrated GPUs (APU/iGPU).
34///
35/// Conservative — only IDs that are *definitively* integrated, so the denylist
36/// can never accidentally exclude a discrete card. Extend as new APU families
37/// ship.
38pub const INTEGRATED_PCI_DEVICE_IDS: &[&str] = &[
39    "0x164e", // Raphael — Ryzen 7000 desktop iGPU (e.g. 7800X3D)
40    "0x15c8", // Phoenix — Ryzen 7040 mobile APU
41    "0x15e0", // Phoenix-Hawk
42    "0x1681", // Strix Point (Ryzen AI 300)
43    "0x1506", // Renoir — Ryzen 4000/5000 APU
44    "0x1638", // Rembrandt — Ryzen 6000/7040 APU
45    "0x15d8", // Raven Ridge — Ryzen 2000/3000 APU
46    "0x1640", // Cezanne/Barcelo — Ryzen 5000 APU
47];
48
49/// VRAM threshold below which a device is treated as integrated (4 GiB).
50pub const DISCRETE_MIN_VRAM_BYTES: u64 = 4 * 1024 * 1024 * 1024;
51
52/// AMD gfx architectures that are **unambiguously integrated** (iGPU-only).
53///
54/// These are the structural fallback when a device's marketing name is empty
55/// (rocminfo/lspci sometimes report only `Name: gfxNNNN` for the iGPU agent).
56/// Every entry is an iGPU-only architecture — no discrete card ships with one —
57/// so this list can never cause a dGPU to be missed. Extend cautiously: only
58/// add a gfx id once confirmed iGPU-exclusive.
59pub const INTEGRATED_GFX_ARCHS: &[&str] = &[
60    "gfx1036", // Raphael — Ryzen 7000 desktop iGPU (e.g. 7800X3D)
61    "gfx1103", // Phoenix — Ryzen 7040 mobile APU iGPU
62];
63
64/// Is this gfx architecture string an iGPU-only architecture?
65pub fn is_integrated_by_gfx_arch(gfx: &str) -> bool {
66    let gfx = gfx.trim().to_ascii_lowercase();
67    INTEGRATED_GFX_ARCHS
68        .iter()
69        .any(|a| a.eq_ignore_ascii_case(&gfx))
70}
71
72/// Curated marketing/lspci name fragments indicating an AMD integrated GPU.
73pub const INTEGRATED_NAME_PATTERNS: &[&str] = &[
74    // APU codenames (internal names from rocminfo / lspci)
75    "Cezanne",
76    "Rembrandt",
77    "Phoenix",
78    "Raphael",
79    "Barcelo",
80    "Pink Sardine",
81    "Yellow Carp",
82    "Green Sardine",
83    "Hawk Point",
84    "Dragon Range",
85    "Fire Range",
86    "Stella",
87    "Krackan",
88    "Strix Point",
89    "Mendocino",
90    "Raven",
91    "Picasso",
92    "Renoir",
93    // Marketing patterns
94    "Integrated",
95    "iGPU",
96    "APU",
97    "AMD Radeon Graphics",
98    "Radeon Graphics",
99    // APU model suffixes
100    " 5600G",
101    " 5700G",
102    " 5600GE",
103    " 5700GE",
104    " 5700G",
105    " 5600GT",
106    " 5700GT",
107    " 8600G",
108    " 8700G",
109    " 8500G",
110    " 8300G",
111    // X3D APUs with integrated graphics (Raphael/Phoenix-based)
112    "7800X3D",
113    "7950X3D",
114    "7900X3D",
115    "7700X3D",
116    "7600X3D",
117    // Mobile APU series
118    "Ryzen 7 7735",
119    "Ryzen 7 7840",
120    "Ryzen 7 8840",
121    "Ryzen 5 7535",
122    "Ryzen 5 7640",
123    "Ryzen 5 8640",
124    "Ryzen 9 7945",
125    "Ryzen 9 8945",
126];
127
128/// Pre-lowercased snapshot of [`INTEGRATED_NAME_PATTERNS`], computed once.
129///
130/// Avoids re-allocating `pattern.to_lowercase()` on every call to
131/// [`is_integrated_gpu_name`] (the patterns are a `const`, so they cannot be
132/// stored pre-lowercased at const-eval time).
133fn integrated_name_patterns_lower() -> &'static [String] {
134    use std::sync::OnceLock;
135    static LOWER: OnceLock<Vec<String>> = OnceLock::new();
136    LOWER.get_or_init(|| {
137        INTEGRATED_NAME_PATTERNS
138            .iter()
139            .map(|p| p.to_lowercase())
140            .collect()
141    })
142}
143
144/// Does a marketing/lspci/rocminfo name indicate an AMD **integrated** GPU?
145///
146/// This is the canonical name classifier — the single replacement for the
147/// three former divergent copies. Returns `false` for discrete cards
148/// ("Radeon RX 7900 XTX", "RX 7800 XT", …) and `true` for APUs/iGPUs.
149pub fn is_integrated_gpu_name(marketing_name: &str) -> bool {
150    let name_lower = marketing_name.to_lowercase();
151    let name_upper = marketing_name.to_uppercase();
152
153    // Case-insensitive match — rocminfo/lspci casing varies ("raphael",
154    // "Raphael", "RAPHAEL"). Patterns are compared against the lowercased name
155    // via a pre-lowercased snapshot (computed once, not per iteration).
156    for pattern in integrated_name_patterns_lower() {
157        if name_lower.contains(pattern) {
158            return true;
159        }
160    }
161
162    // "Ryzen" without "RX" ⇒ APU/iGPU. Discrete cards never contain "Ryzen".
163    if name_upper.contains("RYZEN") && !name_upper.contains("RX") {
164        return true;
165    }
166
167    // APU model suffixes: "Ryzen 5 5600G", "Ryzen 7 8700G", "Ryzen 5 5600GE",
168    // "Ryzen 5 5600GT" (but not RX/Radeon/XT/XTX). Iterate ALL words after
169    // "RYZEN" — standard AMD branding inserts a generation/tier number ("5"/"7")
170    // between "Ryzen" and the model, so the suffix is NOT the first word.
171    if let Some(pos) = name_upper.find("RYZEN") {
172        let after_ryzen = &name_upper[pos + 5..];
173        for word in after_ryzen.split_whitespace() {
174            if (word.ends_with('G') || word.ends_with("GE") || word.ends_with("GT"))
175                && word.len() >= 4
176                && !word.starts_with("RX")
177                && !word.starts_with("RADEON")
178                && !word.contains("XT")
179                && !word.contains("XTX")
180            {
181                return true;
182            }
183        }
184    }
185
186    // Generic "Radeon Graphics" without a model ⇒ iGPU.
187    if name_upper.contains("RADEON GRAPHICS")
188        && !name_upper.contains("RX")
189        && (!marketing_name.chars().any(|c| c.is_ascii_digit())
190            || marketing_name.contains("AMD Radeon Graphics"))
191    {
192        return true;
193    }
194
195    false
196}
197
198/// Is this PCI device id a known integrated GPU?
199///
200/// `pci_device_id` may be lowercase/uppercase and with or without a `0x`
201/// prefix (e.g. `"0x164e"`, `"164E"`).
202pub fn is_integrated_by_pci_id(pci_device_id: &str) -> bool {
203    let normalized = pci_device_id.trim().to_ascii_lowercase();
204    let normalized = normalized.trim_start_matches("0x");
205    INTEGRATED_PCI_DEVICE_IDS
206        .iter()
207        .any(|id| id.trim_start_matches("0x").eq_ignore_ascii_case(normalized))
208}
209
210/// Combined, fail-safe classification of a single device.
211///
212/// Inputs are all optional — provide whatever the caller has. Returns `true`
213/// (integrated) when ANY of:
214/// - the PCI device id is a known iGPU (`pci_device_id`), OR
215/// - the gfx architecture is iGPU-only (`gfx_arch`, e.g. `gfx1036` Raphael) —
216///   the structural fallback when the marketing name is empty, OR
217/// - the name classifies as integrated (`name`), OR
218/// - the name is ambiguous AND the device's VRAM is readable and below
219///   [`DISCRETE_MIN_VRAM_BYTES`] (confirmation only).
220///
221/// Unreadable VRAM alone NEVER classifies a device as integrated (that would
222/// risk dropping a real dGPU on kernels lacking `mem_info_vram_total`); the
223/// PCI id, gfx arch, and name are the authoritative signals. A dGPU is never
224/// missed: every exclusion requires a positive iGPU signal.
225pub fn device_is_integrated(
226    name: Option<&str>,
227    pci_device_id: Option<&str>,
228    gfx_arch: Option<&str>,
229    vram_bytes: Option<u64>,
230) -> bool {
231    if let Some(id) = pci_device_id {
232        if is_integrated_by_pci_id(id) {
233            return true;
234        }
235    }
236    if let Some(g) = gfx_arch {
237        if !g.trim().is_empty() && is_integrated_by_gfx_arch(g) {
238            return true;
239        }
240    }
241    if let Some(name) = name {
242        if !name.trim().is_empty() && is_integrated_gpu_name(name) {
243            return true;
244        }
245        // VRAM confirmation: only when the name is ambiguous (carries NO
246        // positive dGPU signal) AND VRAM is concretely readable + low. Never
247        // the sole reason, and never VRAM-rejects an explicitly-named dGPU
248        // (e.g. a <4 GiB Radeon RX / Radeon Pro / Instinct / FirePro card) —
249        // "dGPUs are never missed."
250        //
251        // The marker is an EXPLICIT dGPU qualifier only ("Radeon RX", " RX ",
252        // "Radeon Pro", "Instinct", "FirePro"). A bare brand name ("Radeon",
253        // "AMD Radeon") with no model qualifier is ambiguous — that is the APU
254        // reporting style — and must fall through to the VRAM gate rather than
255        // being treated as authoritative discrete. (Matching on "RADEON" alone
256        // would shield bare iGPU marketing names from the gate, leaking the
257        // iGPU.) RDNA iGPUs with model numbers ("Radeon 780M", "Radeon 680M")
258        // carry no qualifier either, so they too fall through and are gated by
259        // VRAM — correct, since they are APUs. The only cards lost are
260        // pre-RDNA "Radeon HD" dGPUs, which no ROCm release supports.
261        if let Some(vram) = vram_bytes {
262            let upper = name.to_ascii_uppercase();
263            let has_discrete_marker = upper.contains("RADEON RX")
264                || upper.contains(" RX ")
265                || upper.contains("RADEON PRO")
266                || upper.contains("INSTINCT")
267                || upper.contains("FIREPRO");
268            if !name.trim().is_empty() && !has_discrete_marker && vram < DISCRETE_MIN_VRAM_BYTES {
269                return true;
270            }
271        }
272    }
273    false
274}
275
276/// Read a PCI device's `mem_info_vram_total` (bytes) from sysfs, if available.
277///
278/// `pci_slot` is the `BB:DD.F` form (with or without a `0000:` domain prefix).
279pub fn read_vram_bytes(pci_slot: &str) -> Option<u64> {
280    let slot = pci_slot.trim().trim_start_matches("0000:");
281    let path = format!("/sys/bus/pci/devices/0000:{slot}/mem_info_vram_total");
282    let value = std::fs::read_to_string(&path).ok()?;
283    value.trim().parse::<u64>().ok()
284}
285
286// ===========================================================================
287// AMD GPU detection from sysfs — PCI class filter, VRAM, gfx arch resolution
288// ===========================================================================
289
290/// AMD discrete GPU discovered via sysfs.
291///
292/// All fields optional except `pci_slot` and `is_integrated`. Detectors
293/// populate from `/sys/class/drm/card*/device` and PCI tables.
294#[derive(Debug, Clone)]
295pub struct AmdGpu {
296    /// PCI slot in `BB:DD.F` form (canonicalized, no `0000:` prefix).
297    pub pci_slot: String,
298    /// PCI device ID (hex string, e.g. `"0x744c"` for Navi 31).
299    pub pci_device_id: String,
300    /// Marketing name from lspci/rocminfo if available.
301    pub marketing_name: Option<String>,
302    /// gfx architecture (e.g. `"gfx1100"` for Navi 31).
303    pub gfx_arch: Option<String>,
304    /// Total VRAM in bytes from `mem_info_vram_total`.
305    pub vram_bytes: Option<u64>,
306    /// `true` if classified as integrated by [`device_is_integrated`].
307    pub is_integrated: bool,
308    /// Real ROCm (HSA) agent index from `rocminfo`, when available.
309    ///
310    /// This is the authoritative device index for `ROCR_VISIBLE_DEVICES` /
311    /// `HIP_VISIBLE_DEVICES`: those vars filter by the *original* enumeration
312    /// order (CPU + iGPU + dGPU agents as `rocminfo` prints them), NOT a
313    /// re-numbered post-filter sequence. On an APU+dGPU host where the iGPU is
314    /// ROCr agent 2 and the dGPU is agent 3, the visibility mask must be `3`,
315    /// not the compacted `0` produced by enumerating the filtered dGPU list.
316    ///
317    /// `None` when rocminfo is unavailable; callers fall back to compacted
318    /// indices (the historical sysfs-only behavior) in that case.
319    pub rocm_index: Option<u32>,
320}
321
322/// Authoritative PCI device ID → gfx arch mapping for AMD discrete GPUs.
323///
324/// Sourced from `/usr/share/hwdata/pci.ids`. ONLY dGPUs — iGPU IDs handled
325/// separately by [`INTEGRATED_PCI_DEVICE_IDS`].
326pub const DISCRETE_PCI_ID_TO_GFX: &[(&str, &str)] = &[
327    // RDNA4
328    ("0x7550", "gfx1201"),
329    ("0x7551", "gfx1201"),
330    ("0x7590", "gfx1200"),
331    // RDNA3 Navi31
332    ("0x744c", "gfx1100"),
333    ("0x7448", "gfx1100"),
334    ("0x7449", "gfx1100"),
335    ("0x744a", "gfx1100"),
336    ("0x744b", "gfx1100"),
337    ("0x745e", "gfx1100"),
338    // RDNA3 Navi32
339    ("0x747e", "gfx1101"),
340    ("0x7470", "gfx1101"),
341    ("0x7460", "gfx1101"),
342    ("0x7461", "gfx1101"),
343    // RDNA3 Navi33
344    ("0x7480", "gfx1102"),
345    ("0x7483", "gfx1102"),
346    ("0x7489", "gfx1102"),
347    ("0x749f", "gfx1102"),
348    ("0x73f0", "gfx1102"),
349    // RDNA2 Navi21
350    ("0x73bf", "gfx1030"),
351    ("0x73af", "gfx1030"),
352    ("0x73a5", "gfx1030"),
353    ("0x73a1", "gfx1030"),
354    ("0x73a2", "gfx1030"),
355    ("0x73a3", "gfx1030"),
356    // RDNA2 Navi22
357    ("0x73df", "gfx1031"),
358    ("0x73c3", "gfx1031"),
359    // RDNA2 Navi23
360    ("0x73ff", "gfx1032"),
361    ("0x73ef", "gfx1032"),
362    ("0x73e0", "gfx1032"),
363    ("0x73e1", "gfx1032"),
364    ("0x73e3", "gfx1032"),
365    // RDNA2 Navi24
366    ("0x743f", "gfx1034"),
367    ("0x7424", "gfx1034"),
368    ("0x7421", "gfx1034"),
369    ("0x7422", "gfx1034"),
370    ("0x7423", "gfx1034"),
371];
372
373/// Normalize PCI device ID (lowercase, strip `0x`).
374fn normalize_pci_id(id: &str) -> String {
375    id.trim()
376        .to_ascii_lowercase()
377        .trim_start_matches("0x")
378        .to_string()
379}
380
381/// Look up gfx arch by PCI device ID (normalized, case-insensitive).
382pub fn pci_id_to_gfx(pci_device_id: &str) -> Option<&'static str> {
383    let normalized = normalize_pci_id(pci_device_id);
384    DISCRETE_PCI_ID_TO_GFX
385        .iter()
386        .find(|(id, _)| normalize_pci_id(id) == normalized)
387        .map(|(_, gfx)| *gfx)
388}
389
390/// Resolve gfx arch from marketing name (canonical fallback).
391///
392/// Copies logic from `platform/linux.rs::get_correct_gfx_from_marketing_name`
393/// and extends with generic "Navi NN" patterns. Returns `None` if unknown.
394pub fn gfx_from_marketing_name(name: &str) -> Option<&'static str> {
395    let name_lower = name.to_lowercase();
396
397    // Generic "Navi NN" patterns
398    if name_lower.contains("navi 48") || name_lower.contains("navi48") {
399        return Some("gfx1201");
400    }
401    if name_lower.contains("navi 44") || name_lower.contains("navi44") {
402        return Some("gfx1200");
403    }
404    if name_lower.contains("navi 31") || name_lower.contains("navi31") {
405        return Some("gfx1100");
406    }
407    if name_lower.contains("navi 32") || name_lower.contains("navi32") {
408        return Some("gfx1101");
409    }
410    if name_lower.contains("navi 33") || name_lower.contains("navi33") {
411        return Some("gfx1102");
412    }
413    if name_lower.contains("navi 21") || name_lower.contains("navi21") {
414        return Some("gfx1030");
415    }
416    if name_lower.contains("navi 22") || name_lower.contains("navi22") {
417        return Some("gfx1031");
418    }
419    if name_lower.contains("navi 23") || name_lower.contains("navi23") {
420        return Some("gfx1032");
421    }
422    if name_lower.contains("navi 24") || name_lower.contains("navi24") {
423        return Some("gfx1034");
424    }
425
426    // RDNA 3 (Navi 3x) — gfx1100/gfx1101/gfx1102
427    if name_lower.contains("7900 xtx") || name_lower.contains("7900xtx") {
428        return Some("gfx1100");
429    }
430    if name_lower.contains("7900 gre") || name_lower.contains("7900gre") {
431        return Some("gfx1100");
432    }
433    if name_lower.contains("7900 xt") || name_lower.contains("7900xt") {
434        return Some("gfx1100");
435    }
436    if name_lower.contains("7800 xt") || name_lower.contains("7800xt") {
437        return Some("gfx1101");
438    }
439    if name_lower.contains("7800 gre") || name_lower.contains("7800gre") {
440        return Some("gfx1101");
441    }
442    if name_lower.contains("7700 xt") || name_lower.contains("7700xt") {
443        return Some("gfx1101");
444    }
445    if name_lower.contains("7600 xt")
446        || name_lower.contains("7600xt")
447        || name_lower.contains("7600")
448    {
449        return Some("gfx1102");
450    }
451
452    // RDNA 4 (Navi 4x) — gfx1200/gfx1201
453    if name_lower.contains("9070 xt")
454        || name_lower.contains("9070xt")
455        || name_lower.contains("9070 gre")
456        || name_lower.contains("9070gre")
457    {
458        return Some("gfx1201");
459    }
460    if name_lower.contains("9060") {
461        return Some("gfx1200");
462    }
463
464    // RDNA 2 (Navi 2x) — gfx1030/gfx1031/gfx1032/gfx1034
465    if name_lower.contains("6950 xt")
466        || name_lower.contains("6950xt")
467        || name_lower.contains("6900 xt")
468        || name_lower.contains("6900xt")
469    {
470        return Some("gfx1030");
471    }
472    if name_lower.contains("6800 xt")
473        || name_lower.contains("6800xt")
474        || name_lower.contains("6800")
475        || name_lower.contains("6900")
476    {
477        return Some("gfx1030");
478    }
479    if name_lower.contains("6700 xt")
480        || name_lower.contains("6700xt")
481        || name_lower.contains("6750 xt")
482        || name_lower.contains("6750xt")
483    {
484        return Some("gfx1031");
485    }
486    if name_lower.contains("6600 xt")
487        || name_lower.contains("6600xt")
488        || name_lower.contains("6600")
489        || name_lower.contains("6650")
490    {
491        return Some("gfx1032");
492    }
493    if name_lower.contains("6500 xt") || name_lower.contains("6500xt") {
494        return Some("gfx1034");
495    }
496
497    // Unknown name → no override
498    None
499}
500
501/// Parsed lspci line: (pci_slot, name, pci_id)
502#[derive(Debug, Clone)]
503struct LspciDevice {
504    pci_slot: String,
505    name: String,
506    pci_id: String,
507}
508
509/// Parse `lspci -nn` for AMD VGA/3D controllers.
510///
511/// Returns vec of (pci_slot, marketing_name, pci_id). lspci output format:
512/// `BB:DD.F VGA compatible controller [0300]: Vendor Name [Device Name] [vvvv:dddd]`
513fn parse_lspci_amd() -> Vec<LspciDevice> {
514    let output = match std::process::Command::new("lspci").arg("-nn").output() {
515        Ok(o) if o.status.success() => o.stdout,
516        _ => return Vec::new(),
517    };
518
519    let mut devices = Vec::new();
520    let text = String::from_utf8_lossy(&output);
521
522    for line in text.lines() {
523        let line = line.trim();
524        // Must contain AMD and VGA [0300] or 3D [0302]
525        if !line.contains("AMD") && !line.contains("Advanced Micro Devices") {
526            continue;
527        }
528        if !line.contains("[0300]") && !line.contains("[0302]") {
529            continue;
530        }
531
532        // Extract PCI slot (BB:DD.F format, strip domain prefix if present)
533        // lspci format: "0000:BB:DD.F ..." or "BB:DD.F ..."
534        let pci_slot = line
535            .split_whitespace()
536            .next()
537            .unwrap_or("")
538            .trim_start_matches("0000:")
539            .to_string();
540        if pci_slot.is_empty() {
541            continue;
542        }
543
544        // Find PCI ID bracket: [vvvv:dddd]
545        let pci_id = if let Some(bracket_start) = line.rfind('[') {
546            let bracket_content = &line[bracket_start + 1..];
547            let bracket_end = bracket_content.find(']').unwrap_or(bracket_content.len());
548            let id_str = &bracket_content[..bracket_end];
549            id_str
550                .split(':')
551                .nth(1)
552                .map(|id| format!("0x{}", id.to_lowercase()))
553                .unwrap_or_else(|| "unknown".to_string())
554        } else {
555            "unknown".to_string()
556        };
557
558        // Extract device name: between [0300]: and [vvvv:dddd]
559        // Format: "... [0300]: Vendor Name [Device Name] [vvvv:dddd] ..."
560        let name = if let Some(class_end) = line.find("]:") {
561            let after_class = &line[class_end + 2..];
562            if let Some(dev_bracket) = after_class.rfind('[') {
563                after_class[..dev_bracket].trim().to_string()
564            } else {
565                "AMD GPU".to_string()
566            }
567        } else {
568            "AMD GPU".to_string()
569        };
570
571        devices.push(LspciDevice {
572            pci_slot,
573            name,
574            pci_id,
575        });
576    }
577
578    devices
579}
580
581/// Check if rocminfo is available and runnable.
582fn rocminfo_available() -> bool {
583    std::process::Command::new("rocminfo")
584        .arg("--version")
585        .output()
586        .map(|o| o.status.success())
587        .unwrap_or(false)
588}
589
590/// Parse rocminfo agent list for ROCm device ordering (optional enrichment).
591///
592/// Returns vec of (index, name, gfx_arch) in ROCm device index order. The
593/// `index` is the literal "Agent N" counter from rocminfo (the same value
594/// `ROCR_VISIBLE_DEVICES` / `HIP_VISIBLE_DEVICES` must use to filter). GPU
595/// agents that lack a numbered "Agent N" header fall back to the running GPU
596/// counter. Cross-verified against lspci/sysfs devices by the caller.
597fn parse_rocminfo_agents() -> Vec<(u32, String, Option<String>)> {
598    let output = match std::process::Command::new("rocminfo").output() {
599        Ok(o) if o.status.success() => o.stdout,
600        _ => return Vec::new(),
601    };
602
603    let mut agents = Vec::new();
604    let text = String::from_utf8_lossy(&output);
605
606    // rocminfo sections: "Agent 1" ... "Agent 2" ...
607    for agent_block in text.split("****") {
608        let mut name = None;
609        let mut gfx_arch = None;
610        let mut index: Option<u32> = None;
611
612        for line in agent_block.lines() {
613            let line = line.trim();
614            // Capture the "Agent N" header index — this is the canonical ROCr
615            // agent index that ROCR_VISIBLE_DEVICES filters against.
616            if line.starts_with("Agent ") && index.is_none() {
617                if let Some(rest) = line.strip_prefix("Agent ") {
618                    if let Ok(n) = rest.trim().parse::<u32>() {
619                        index = Some(n);
620                    }
621                }
622            }
623            if let Some(rest) = line.strip_prefix("Name:") {
624                name = Some(rest.trim().to_string());
625            }
626            if let Some(rest) = line.strip_prefix("gfx version:") {
627                gfx_arch = Some(rest.trim().to_string());
628            }
629        }
630
631        if let Some(n) = name {
632            // Fall back to the running agent count if no "Agent N" header parsed
633            // (some rocminfo builds omit the numbered header). The count is still
634            // a real enumeration index, not a compacted post-filter value.
635            let idx = index.unwrap_or(agents.len() as u32);
636            agents.push((idx, n, gfx_arch));
637        }
638    }
639
640    agents
641}
642
643/// Detect ALL AMD GPUs via lspci + sysfs (includes iGPU; use
644/// [`detect_discrete_amd_gpus`] to filter).
645///
646/// **Primary source:** `lspci -nn` (universally available, no rocminfo needed).
647/// Parses AMD VGA `[0300]` / 3D `[0302]` lines to extract PCI slot, device NAME,
648/// and PCI ID `[1002:xxxx]`. This provides reliable device names without rocminfo.
649///
650/// **Per-device enrichment:**
651/// - PCI slot: from lspci
652/// - Marketing name: from lspci (e.g. "Navi 31 [Radeon RX 7900 XT/...]")
653/// - PCI ID: from lspci `[1002:xxxx]` format
654/// - VRAM: from sysfs `mem_info_vram_total` via [`read_vram_bytes`]
655/// - gfx arch: from PCI ID table ([`DISCRETE_PCI_ID_TO_GFX`]), or marketing
656///   name fallback ([`gfx_from_marketing_name`]), or None for unknown
657///
658/// **iGPU classification:** Uses name+ID tandem via [`device_is_integrated`]
659/// with BOTH marketing_name (from lspci) AND pci_id. A device is integrated only
660/// if all signals agree (PCI ID, name, gfx arch, VRAM). Verified dGPU iff
661/// `!is_integrated`.
662///
663/// **rocminfo enrichment (optional):** If `rocminfo` is on PATH and runs,
664/// parses its agent list for authoritative ROCm device names + gfx archs and
665/// cross-verifies against lspci/sysfs devices (match by name/PCI). Uses rocminfo's
666/// device ordering to inform `HIP_VISIBLE_DEVICES` when available; else falls
667/// back to verified lspci/sysfs dGPU list in pci-slot order. rocminfo NEVER
668/// required — detection works fully without it.
669///
670/// Returns all AMD GPUs (iGPU + dGPU), each with verified name+PCI_ID+gfx+VRAM.
671pub fn detect_amd_gpus() -> Vec<AmdGpu> {
672    use std::collections::{HashMap, HashSet};
673
674    let mut gpus = Vec::new();
675    let mut seen_slots = HashSet::new();
676
677    // Step 1: Parse lspci for AMD GPU names + PCI IDs (PRIMARY source)
678    let lspci_devices = parse_lspci_amd();
679    let mut lspci_map: HashMap<String, LspciDevice> = HashMap::new();
680    for dev in lspci_devices {
681        lspci_map.insert(dev.pci_slot.clone(), dev);
682    }
683
684    // Step 2: Walk sysfs /sys/class/drm/card*/device for VRAM + cross-verification
685    let entries = match std::fs::read_dir("/sys/class/drm") {
686        Ok(e) => e,
687        Err(_) => return gpus,
688    };
689
690    for entry in entries.flatten() {
691        let name = entry.file_name().to_string_lossy().to_string();
692        if !name.starts_with("card") || name.contains('-') {
693            continue;
694        }
695
696        let device_path = entry.path().join("device");
697        let canonical = match device_path.canonicalize() {
698            Ok(p) => p,
699            Err(_) => continue,
700        };
701
702        if !seen_slots.insert(canonical.clone()) {
703            continue;
704        }
705
706        // Read vendor (must be AMD 0x1002)
707        let vendor_path = canonical.join("vendor");
708        let vendor = match std::fs::read_to_string(&vendor_path) {
709            Ok(v) => v.trim().to_lowercase(),
710            Err(_) => continue,
711        };
712        if !vendor.starts_with("0x1002") {
713            continue;
714        }
715
716        // Read class — VGA (0x030000) or 3D (0x030200) ONLY
717        let class_path = canonical.join("class");
718        let class = match std::fs::read_to_string(&class_path) {
719            Ok(c) => c.trim().to_lowercase(),
720            Err(_) => continue,
721        };
722        let class_val = match class.strip_prefix("0x") {
723            Some(hex) => match u32::from_str_radix(hex, 16) {
724                Ok(v) => v,
725                Err(_) => continue,
726            },
727            None => continue,
728        };
729        const PCI_CLASS_VGA: u32 = 0x030000;
730        const PCI_CLASS_3D: u32 = 0x030200;
731        const CLASS_MASK: u32 = 0xFFFF00;
732        let masked_class = class_val & CLASS_MASK;
733        if masked_class != PCI_CLASS_VGA && masked_class != PCI_CLASS_3D {
734            continue;
735        }
736
737        // Read PCI device ID from sysfs
738        let device_path_id = canonical.join("device");
739        let sysfs_pci_id = match std::fs::read_to_string(&device_path_id) {
740            Ok(d) => d.trim().to_string(),
741            Err(_) => continue,
742        };
743
744        // Read uevent for PCI_SLOT_NAME
745        let uevent_path = canonical.join("uevent");
746        let pci_slot = match std::fs::read_to_string(&uevent_path) {
747            Ok(content) => content
748                .lines()
749                .find(|line| line.starts_with("PCI_SLOT_NAME="))
750                .and_then(|line| line.split('=').nth(1))
751                .map(|s| s.trim_start_matches("0000:").to_string())
752                .unwrap_or_else(|| "unknown".to_string()),
753            Err(_) => "unknown".to_string(),
754        };
755
756        // Read VRAM total from sysfs
757        let vram_bytes = read_vram_bytes(&pci_slot);
758
759        // Get marketing name + PCI ID from lspci (primary source)
760        let lspci_dev = lspci_map.get(&pci_slot);
761        let marketing_name = lspci_dev
762            .as_ref()
763            .map(|d| d.name.clone())
764            .filter(|n| !n.is_empty());
765        let lspci_pci_id = lspci_dev
766            .as_ref()
767            .map(|d| d.pci_id.clone())
768            .filter(|i| i != "unknown");
769
770        // Prefer lspci PCI ID (parsed from [1002:xxxx]), fall back to sysfs
771        let pci_device_id = lspci_pci_id.unwrap_or_else(|| sysfs_pci_id.clone());
772
773        // Resolve gfx arch: PCI table first, marketing name fallback
774        let mut gfx_arch = pci_id_to_gfx(&pci_device_id).map(|s| s.to_string());
775        if gfx_arch.is_none() {
776            if let Some(ref name) = marketing_name {
777                gfx_arch = gfx_from_marketing_name(name).map(|s| s.to_string());
778            }
779        }
780
781        // Classify integrated/discrete using NAME + PCI_ID tandem (never ID alone)
782        let is_integrated = device_is_integrated(
783            marketing_name.as_deref(),
784            Some(&pci_device_id),
785            gfx_arch.as_deref(),
786            vram_bytes,
787        );
788
789        gpus.push(AmdGpu {
790            pci_slot,
791            pci_device_id,
792            marketing_name,
793            gfx_arch,
794            vram_bytes,
795            is_integrated,
796            rocm_index: None,
797        });
798    }
799
800    // Step 3 (optional): rocminfo enrichment for gfx arch + ROCm device ordering
801    if rocminfo_available() {
802        let rocminfo_agents = parse_rocminfo_agents();
803        if !rocminfo_agents.is_empty() {
804            // Cross-verify rocminfo agents against detected devices by name/PCI.
805            // Enrich gfx arch AND capture the real ROCm agent index (the value
806            // ROCR_VISIBLE_DEVICES must use). Matching is by name substring
807            // (rocminfo marketing names are usually shorter than lspci strings).
808            for (rocm_idx, rocm_name, rocm_gfx) in rocminfo_agents {
809                for gpu in &mut gpus {
810                    if let Some(ref name) = gpu.marketing_name {
811                        if name.contains(&rocm_name) || rocm_name.contains(name) {
812                            if gpu.rocm_index.is_none() {
813                                gpu.rocm_index = Some(rocm_idx);
814                            }
815                            if let Some(ref rg) = rocm_gfx {
816                                gpu.gfx_arch = Some(rg.clone());
817                            }
818                        }
819                    }
820                }
821            }
822        }
823    }
824
825    gpus
826}
827
828/// Detect ONLY discrete AMD GPUs (iGPU filtered out).
829///
830/// Filters [`detect_amd_gpus`] to `!is_integrated`, sorted by `pci_slot` for
831/// stable indexing (e.g. `HIP_VISIBLE_DEVICES` order).
832pub fn detect_discrete_amd_gpus() -> Vec<AmdGpu> {
833    let mut dgpus = detect_amd_gpus()
834        .into_iter()
835        .filter(|gpu| !gpu.is_integrated)
836        .collect::<Vec<_>>();
837    dgpus.sort_by(|a, b| a.pci_slot.cmp(&b.pci_slot));
838    dgpus
839}
840
841// ===========================================================================
842// Tests
843// ===========================================================================
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    #[test]
850    fn discrete_cards_are_not_integrated() {
851        assert!(!is_integrated_gpu_name("AMD Radeon RX 7900 XTX"));
852        assert!(!is_integrated_gpu_name("Radeon RX 7800 XT"));
853        assert!(!is_integrated_gpu_name("AMD Radeon RX 6900 XT"));
854        assert!(!is_integrated_gpu_name("AMD Radeon RX 9070 XT"));
855    }
856
857    #[test]
858    fn apu_codenames_are_integrated() {
859        assert!(is_integrated_gpu_name("Raphael"));
860        assert!(is_integrated_gpu_name("raphael")); // case-insensitive (rocminfo/lspci varies)
861        assert!(is_integrated_gpu_name("RAPHAEL"));
862        assert!(!is_integrated_gpu_name("gfx1036")); // raw gfx arch is NOT a name signal
863        assert!(is_integrated_gpu_name("AMD Cezanne (Radeon Graphics)"));
864        assert!(is_integrated_gpu_name("Phoenix"));
865        assert!(is_integrated_gpu_name("Rembrandt"));
866        assert!(is_integrated_gpu_name("Strix Point")); // Ryzen AI 300 APU
867    }
868
869    #[test]
870    fn asus_strix_dgpu_is_not_integrated() {
871        // "Strix Point" is an APU codename, but "ROG Strix" is an Asus dGPU
872        // board line — must NOT be filtered. (Regression guard.)
873        assert!(!is_integrated_gpu_name("ASUS ROG Strix Radeon RX 7900 XTX"));
874        assert!(!is_integrated_gpu_name("ROG Strix LC Radeon RX 7800 XT"));
875    }
876
877    #[test]
878    fn ryzen_cpus_with_igpu_are_integrated() {
879        assert!(is_integrated_gpu_name(
880            "AMD Ryzen 7 7800X3D 8-Core Processor"
881        ));
882        assert!(is_integrated_gpu_name(
883            "AMD Ryzen 9 7950X3D 16-Core Processor"
884        ));
885        assert!(is_integrated_gpu_name("AMD Ryzen 5 8600G 6-Core Processor"));
886        assert!(is_integrated_gpu_name("AMD Ryzen 7 8700G"));
887    }
888
889    #[test]
890    fn ryzen_apu_suffix_without_amd_prefix_is_integrated() {
891        // Regression: the old " RYZEN " search + first-word check never matched
892        // "Ryzen 5 5600G" (no leading space, model number is the 2nd word).
893        assert!(is_integrated_gpu_name("Ryzen 5 5600G"));
894        assert!(is_integrated_gpu_name("Ryzen 7 5700G"));
895        assert!(is_integrated_gpu_name("Ryzen 5 5600GE"));
896        assert!(is_integrated_gpu_name("Ryzen 5 5600GT"));
897        assert!(is_integrated_gpu_name("Ryzen 7 8700G"));
898    }
899
900    #[test]
901    fn low_vram_discrete_cards_are_not_vram_rejected() {
902        // Regression: <4 GiB must NOT drop an explicitly-named dGPU
903        // ("dGPUs are never missed"). Older Radeon RX/Pro cards <4 GB.
904        let low = 2 * 1024 * 1024 * 1024u64;
905        assert!(!device_is_integrated(
906            Some("AMD Radeon RX 550"),
907            None,
908            None,
909            Some(low)
910        ));
911        assert!(!device_is_integrated(
912            Some("AMD Radeon Pro WX 2100"),
913            None,
914            None,
915            Some(low)
916        ));
917        assert!(!device_is_integrated(
918            Some("AMD Instinct MI50"),
919            None,
920            None,
921            Some(low)
922        ));
923        // Ambiguous name + low VRAM is still treated as integrated.
924        assert!(device_is_integrated(
925            Some("AMD Device"),
926            Some("0x9999"),
927            None,
928            Some(512 * 1024 * 1024)
929        ));
930    }
931
932    #[test]
933    fn bare_radeon_marketing_name_is_vram_gated() {
934        // Regression (PR #21 re-review): a bare brand string with no model
935        // qualifier, gfx arch, or PCI id at <4 GiB is the iGPU reporting style
936        // and must be VRAM-gated to integrated — NOT shielded by a "RADEON"
937        // substring. The marker list matches only explicit qualifiers.
938        let low = 2 * 1024 * 1024 * 1024u64;
939        assert!(device_is_integrated(Some("Radeon"), None, None, Some(low)));
940        assert!(device_is_integrated(
941            Some("AMD Radeon"),
942            None,
943            None,
944            Some(low)
945        ));
946
947        // RDNA iGPU models with no qualifier ("Radeon 780M" = Phoenix/Strix APU)
948        // also fall through to the VRAM gate and are correctly integrated — a
949        // naive "has-digit ⇒ discrete" rule would misclassify these as dGPUs.
950        assert!(device_is_integrated(
951            Some("AMD Radeon 780M"),
952            None,
953            None,
954            Some(low)
955        ));
956        assert!(device_is_integrated(
957            Some("AMD Radeon 680M"),
958            None,
959            None,
960            Some(low)
961        ));
962
963        // Same bare names with ample VRAM are NOT integrated (fail-safe: a
964        // bare name is never an authoritative dGPU signal, but ample VRAM
965        // declines to reclassify).
966        let high = 16 * 1024 * 1024 * 1024u64;
967        assert!(!device_is_integrated(
968            Some("Radeon"),
969            None,
970            None,
971            Some(high)
972        ));
973
974        // Explicitly-named dGPUs survive the gate regardless of VRAM.
975        assert!(!device_is_integrated(
976            Some("AMD Radeon RX 7900 XTX"),
977            None,
978            None,
979            Some(low)
980        ));
981    }
982
983    #[test]
984    fn generic_radeon_graphics_is_integrated() {
985        assert!(is_integrated_gpu_name("AMD Radeon Graphics"));
986        assert!(!is_integrated_gpu_name("AMD Radeon RX Graphics 7900"));
987    }
988
989    #[test]
990    fn pci_id_denylist_catches_raphael() {
991        assert!(is_integrated_by_pci_id("0x164e"));
992        assert!(is_integrated_by_pci_id("164E"));
993        assert!(is_integrated_by_pci_id("0x164E"));
994        assert!(!is_integrated_by_pci_id("0x744c")); // Navi 31 dGPU (7900 XTX)
995        assert!(!is_integrated_by_pci_id("0x747e")); // Navi 32 dGPU (7800 XT)
996    }
997
998    #[test]
999    fn device_is_integrated_combines_signals() {
1000        // Name-only iGPU.
1001        assert!(device_is_integrated(Some("Raphael"), None, None, None));
1002        // PCI id-only iGPU.
1003        assert!(device_is_integrated(None, Some("0x164e"), None, None));
1004        // gfx-arch-only iGPU (nameless iGPU agent fallback).
1005        assert!(device_is_integrated(None, None, Some("gfx1036"), None));
1006        assert!(device_is_integrated(None, None, Some("gfx1103"), None));
1007        // dGPU by name with large VRAM ⇒ not integrated.
1008        assert!(!device_is_integrated(
1009            Some("AMD Radeon RX 7900 XTX"),
1010            Some("0x744c"),
1011            Some("gfx1100"),
1012            Some(24 * 1024 * 1024 * 1024)
1013        ));
1014        // Ambiguous name + low readable VRAM ⇒ integrated (confirmation).
1015        assert!(device_is_integrated(
1016            Some("AMD Device"),
1017            Some("0x9999"),
1018            None,
1019            Some(512 * 1024 * 1024)
1020        ));
1021        // Ambiguous name + UNREADABLE VRAM ⇒ NOT integrated (never miss a dGPU).
1022        assert!(!device_is_integrated(
1023            Some("AMD Device"),
1024            Some("0x9999"),
1025            None,
1026            None
1027        ));
1028    }
1029
1030    #[test]
1031    fn gfx_arch_signal_is_safe_for_dgpus() {
1032        // iGPU-only archs are caught.
1033        assert!(is_integrated_by_gfx_arch("gfx1036"));
1034        assert!(is_integrated_by_gfx_arch("GFX1103"));
1035        // dGPU archs are NEVER caught (no false exclusions).
1036        assert!(!is_integrated_by_gfx_arch("gfx1100"));
1037        assert!(!is_integrated_by_gfx_arch("gfx1101"));
1038        assert!(!is_integrated_by_gfx_arch("gfx1030"));
1039    }
1040
1041    // ===========================================================================
1042    // Detector tests (PCI ID → gfx, name → gfx, class filter)
1043    // ===========================================================================
1044
1045    #[test]
1046    fn pci_id_to_gfx_maps_all_table_entries() {
1047        // RDNA4
1048        assert_eq!(pci_id_to_gfx("0x7550"), Some("gfx1201"));
1049        assert_eq!(pci_id_to_gfx("0x7551"), Some("gfx1201"));
1050        assert_eq!(pci_id_to_gfx("0x7590"), Some("gfx1200"));
1051        // RDNA3 Navi31
1052        assert_eq!(pci_id_to_gfx("0x744c"), Some("gfx1100"));
1053        assert_eq!(pci_id_to_gfx("0x7448"), Some("gfx1100"));
1054        assert_eq!(pci_id_to_gfx("0x745e"), Some("gfx1100"));
1055        // RDNA3 Navi32
1056        assert_eq!(pci_id_to_gfx("0x747e"), Some("gfx1101"));
1057        assert_eq!(pci_id_to_gfx("0x7460"), Some("gfx1101"));
1058        // RDNA3 Navi33
1059        assert_eq!(pci_id_to_gfx("0x7480"), Some("gfx1102"));
1060        assert_eq!(pci_id_to_gfx("0x73f0"), Some("gfx1102"));
1061        // RDNA2 Navi21
1062        assert_eq!(pci_id_to_gfx("0x73bf"), Some("gfx1030"));
1063        assert_eq!(pci_id_to_gfx("0x73a1"), Some("gfx1030"));
1064        // RDNA2 Navi22
1065        assert_eq!(pci_id_to_gfx("0x73df"), Some("gfx1031"));
1066        assert_eq!(pci_id_to_gfx("0x73c3"), Some("gfx1031"));
1067        // RDNA2 Navi23
1068        assert_eq!(pci_id_to_gfx("0x73ff"), Some("gfx1032"));
1069        assert_eq!(pci_id_to_gfx("0x73e3"), Some("gfx1032"));
1070        // RDNA2 Navi24
1071        assert_eq!(pci_id_to_gfx("0x743f"), Some("gfx1034"));
1072        assert_eq!(pci_id_to_gfx("0x7421"), Some("gfx1034"));
1073        // Unknown ID → None
1074        assert_eq!(pci_id_to_gfx("0x9999"), None);
1075        assert_eq!(pci_id_to_gfx("0x164e"), None); // iGPU ID not in dGPU table
1076    }
1077
1078    #[test]
1079    fn pci_id_to_gfx_normalizes_case_and_prefix() {
1080        assert_eq!(pci_id_to_gfx("0x744c"), Some("gfx1100"));
1081        assert_eq!(pci_id_to_gfx("744C"), Some("gfx1100"));
1082        assert_eq!(pci_id_to_gfx("744c"), Some("gfx1100"));
1083        assert_eq!(pci_id_to_gfx("0X744C"), Some("gfx1100"));
1084    }
1085
1086    #[test]
1087    fn gfx_from_marketing_name_handles_navi_patterns() {
1088        // Generic "Navi NN"
1089        assert_eq!(gfx_from_marketing_name("Navi 48"), Some("gfx1201"));
1090        assert_eq!(gfx_from_marketing_name("navi44"), Some("gfx1200"));
1091        assert_eq!(gfx_from_marketing_name("Navi 31"), Some("gfx1100"));
1092        assert_eq!(gfx_from_marketing_name("Navi 32"), Some("gfx1101"));
1093        assert_eq!(gfx_from_marketing_name("Navi 33"), Some("gfx1102"));
1094        assert_eq!(gfx_from_marketing_name("Navi 21"), Some("gfx1030"));
1095        assert_eq!(gfx_from_marketing_name("Navi 22"), Some("gfx1031"));
1096        assert_eq!(gfx_from_marketing_name("Navi 23"), Some("gfx1032"));
1097        assert_eq!(gfx_from_marketing_name("Navi 24"), Some("gfx1034"));
1098        // Unknown name → None (no fallback)
1099        assert_eq!(gfx_from_marketing_name("Generic GPU"), None);
1100    }
1101
1102    #[test]
1103    fn gfx_from_marketing_name_handles_rdnax_names() {
1104        // RDNA3
1105        assert_eq!(
1106            gfx_from_marketing_name("Radeon RX 7900 XTX"),
1107            Some("gfx1100")
1108        );
1109        assert_eq!(
1110            gfx_from_marketing_name("Radeon RX 7900 GRE"),
1111            Some("gfx1100")
1112        );
1113        assert_eq!(
1114            gfx_from_marketing_name("Radeon RX 7800 XT"),
1115            Some("gfx1101")
1116        );
1117        assert_eq!(
1118            gfx_from_marketing_name("Radeon RX 7700 XT"),
1119            Some("gfx1101")
1120        );
1121        assert_eq!(
1122            gfx_from_marketing_name("Radeon RX 7600 XT"),
1123            Some("gfx1102")
1124        );
1125        // RDNA4
1126        assert_eq!(
1127            gfx_from_marketing_name("Radeon RX 9070 XT"),
1128            Some("gfx1201")
1129        );
1130        assert_eq!(gfx_from_marketing_name("Radeon RX 9060"), Some("gfx1200"));
1131        // RDNA2
1132        assert_eq!(
1133            gfx_from_marketing_name("Radeon RX 6900 XT"),
1134            Some("gfx1030")
1135        );
1136        assert_eq!(
1137            gfx_from_marketing_name("Radeon RX 6800 XT"),
1138            Some("gfx1030")
1139        );
1140        assert_eq!(
1141            gfx_from_marketing_name("Radeon RX 6700 XT"),
1142            Some("gfx1031")
1143        );
1144        assert_eq!(
1145            gfx_from_marketing_name("Radeon RX 6600 XT"),
1146            Some("gfx1032")
1147        );
1148        assert_eq!(
1149            gfx_from_marketing_name("Radeon RX 6500 XT"),
1150            Some("gfx1034")
1151        );
1152    }
1153
1154    #[test]
1155    fn class_filter_excludes_non_gpu_classes() {
1156        // Mock class strings → should be filtered
1157        // Bridge (0x0604) → NOT a GPU
1158        let class_bridge = "0x060400";
1159        assert_eq!(
1160            u32::from_str_radix(class_bridge.strip_prefix("0x").unwrap(), 16).unwrap() & 0xFFFF00,
1161            0x060400
1162        );
1163        // Audio (0x0403) → NOT a GPU
1164        let class_audio = "0x040300";
1165        assert_eq!(
1166            u32::from_str_radix(class_audio.strip_prefix("0x").unwrap(), 16).unwrap() & 0xFFFF00,
1167            0x040300
1168        );
1169        // VGA (0x030000) → IS a GPU
1170        let class_vga = "0x030000";
1171        assert_eq!(
1172            u32::from_str_radix(class_vga.strip_prefix("0x").unwrap(), 16).unwrap() & 0xFFFF00,
1173            0x030000
1174        );
1175        // 3D (0x030200) → IS a GPU
1176        let class_3d = "0x030200";
1177        assert_eq!(
1178            u32::from_str_radix(class_3d.strip_prefix("0x").unwrap(), 16).unwrap() & 0xFFFF00,
1179            0x030200
1180        );
1181        // Detectors ONLY accept VGA/3D — bridge/audio are excluded
1182    }
1183}