diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index adbc52329..cb7c1f625 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -27,6 +27,8 @@ pub struct Bins { pub struct Detection { pub cards: Vec, pub notes: Vec, + /// duplicate OpenCL platform entries left out (one line each, for the log) + pub dropped: Vec, pub nvidia_listed: bool, pub opencl_listed: bool, pub adapters_listed: bool, @@ -88,7 +90,8 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState { CardState { index, - key: format!("{vendor}:{device}:{name}"), + key: format!("{vendor}:{name}"), + code: name.to_string(), name: name.to_string(), vendor: vendor.into(), worker: worker.into(), @@ -129,9 +132,16 @@ pub struct Adapter { pub ram_mb: u64, pub processor: String, pub pnp_id: String, + /// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them + pub bus: String, } impl Adapter { + /// The PCI device id from the PnP id ("PCI\\VEN_1002&DEV_7550&..." gives 0x7550); 0 when there is none. + pub fn device_id(&self) -> u16 { + let up = self.pnp_id.to_ascii_uppercase(); + up.find("DEV_").and_then(|i| u16::from_str_radix(up.get(i + 4..i + 8)?, 16).ok()).unwrap_or(0) + } /// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine. pub fn problem(&self) -> Option { if self.code != 0 { @@ -189,7 +199,14 @@ pub fn parse_adapters(json: &str) -> Vec { let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string(); let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::().ok()))).unwrap_or(0); rows.iter() - .map(|r| Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID") }) + .map(|r| { + // DEVPKEY_Device_Address on PCI is (device << 16) | function + let bus = match (r.get("BusNumber").and_then(|x| x.as_u64()), r.get("Address").and_then(|x| x.as_u64())) { + (Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff), + _ => String::new(), + }; + Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus } + }) .filter(|a| !a.name.is_empty()) .collect() } @@ -197,7 +214,9 @@ pub fn parse_adapters(json: &str) -> Vec { /// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer. #[cfg(windows)] pub fn adapters() -> Option> { - let script = "Get-CimInstance Win32_VideoController | Select-Object Name,Status,ConfigManagerErrorCode,AdapterRAM,VideoProcessor,PNPDeviceID | ConvertTo-Json -Compress"; + // one object per adapter, with the PCI bus number and address from the PnP properties (they name the card + // the OpenCL worker's "pci" field names); @() keeps a single adapter an array + let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress"; let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?; let start = out.find(|c| c == '[' || c == '{')?; Some(parse_adapters(&out[start..])) @@ -334,20 +353,236 @@ pub fn detect(bins: &Bins) -> Detection { apply_defaults(&mut c); mark_sweep_support(&mut c); d.cards.push(c); + assign_keys(&mut d.cards); d } -#[cfg(not(target_os = "macos"))] -pub fn detect(bins: &Bins) -> Detection { +/// AMD gfx codes the OpenCL runtime reports as the device name, with the card names Windows uses, the words for +/// the row when no adapter matches, and the PCI device ids (approximate, from AMD's public ROCm and Linux driver +/// tables; add a line when a card is seen). gfx1036 is the Ryzen desktop iGPU (PC 1: DEV_13C0, 5 October 2026). +const GFX: &[(&str, &str, &[&str], &[u16])] = &[ + ("gfx1201", "Radeon RX 9070 XT / 9070", &["Radeon RX 9070 XT", "Radeon RX 9070"], &[0x7550]), + ("gfx1200", "Radeon RX 9060 XT", &["Radeon RX 9060 XT", "Radeon RX 9060"], &[0x7590]), + ("gfx1100", "Radeon RX 7900 XTX / XT", &["Radeon RX 7900 XTX", "Radeon RX 7900 XT", "Radeon RX 7900 GRE"], &[0x744C]), + ("gfx1101", "Radeon RX 7800 XT / 7700 XT", &["Radeon RX 7800 XT", "Radeon RX 7700 XT"], &[0x747E]), + ("gfx1102", "Radeon RX 7600", &["Radeon RX 7600 XT", "Radeon RX 7600"], &[0x7480]), + ("gfx1030", "Radeon RX 6800 / 6900", &["Radeon RX 6900 XT", "Radeon RX 6950 XT", "Radeon RX 6800 XT", "Radeon RX 6800"], &[0x73BF]), + ("gfx1031", "Radeon RX 6700 XT", &["Radeon RX 6750 XT", "Radeon RX 6700 XT", "Radeon RX 6700"], &[0x73DF]), + ("gfx1032", "Radeon RX 6600", &["Radeon RX 6650 XT", "Radeon RX 6600 XT", "Radeon RX 6600"], &[0x73FF]), + ("gfx1036", "Ryzen integrated Radeon Graphics", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x164E, 0x13C0]), + ("gfx1035", "Radeon 680M (integrated)", &["Radeon 680M", "Radeon 660M"], &[0x1681]), + ("gfx1103", "Radeon 780M (integrated)", &["Radeon 780M", "Radeon 760M"], &[0x15BF, 0x15C8]), + ("gfx1150", "Radeon 890M (integrated)", &["Radeon 890M", "Radeon 880M"], &[0x150E]), + ("gfx90c", "Radeon Graphics (Renoir / Cezanne, integrated)", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x1636, 0x1638]), +]; + +fn gfx_entry(code: &str) -> Option<&'static (&'static str, &'static str, &'static [&'static str], &'static [u16])> { + let c = code.trim().to_ascii_lowercase(); + let c = c.split(|ch: char| ch == ':' || ch == ' ').next().unwrap_or(""); + GFX.iter().find(|e| e.0 == c) +} + +/// The name on the row for a device the tool knows by `code`: Windows' adapter name when one matches (by PCI bus, +/// else by the gfx code's device ids, else by the card names in the table), else the table's words, else the code. +/// `used` holds the adapters already given to another card, so two gfx1036 entries never share one. +pub fn resolve_name(code: &str, vendor: &str, bus: &str, adapters: &[Adapter], used: &mut Vec) -> (String, Option) { + let free = |i: &usize| !used.contains(i); + let fine = |a: &Adapter| a.problem().is_none(); + let vendor_ok = |a: &Adapter| vendor == "other" || vendor_of(&a.name) == vendor; + if !bus.is_empty() { + if let Some(i) = (0..adapters.len()).filter(free).find(|&i| adapters[i].bus == bus && vendor_ok(&adapters[i])) { + used.push(i); + return (adapters[i].name.clone(), Some(i)); + } + } + // the name is already a marketing name (nvidia-smi, Windows): the adapter with the same name + let same: Vec = (0..adapters.len()).filter(free).filter(|&i| adapters[i].name.trim().eq_ignore_ascii_case(code.trim())).collect(); + if same.len() == 1 { + used.push(same[0]); + return (adapters[same[0]].name.clone(), Some(same[0])); + } + let Some(entry) = gfx_entry(code) else { return (code.to_string(), None) }; + let by_id: Vec = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && entry.3.contains(&adapters[i].device_id())).collect(); + if by_id.len() == 1 { + used.push(by_id[0]); + return (adapters[by_id[0]].name.clone(), Some(by_id[0])); + } + let by_name: Vec = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && vendor_ok(&adapters[i]) && { let n = adapters[i].name.to_ascii_lowercase(); entry.2.iter().any(|m| n.contains(&m.to_ascii_lowercase())) }).collect(); + if by_name.len() == 1 { + used.push(by_name[0]); + return (adapters[by_name[0]].name.clone(), Some(by_name[0])); + } + (entry.1.to_string(), None) +} + +/// One device line pair of the OpenCL worker's --list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ClDevice { + pub index: String, + pub name: String, + pub platform: String, + pub platform_version: String, + pub is_gpu: bool, + pub vendor: String, + pub driver: String, + pub units: String, + /// "01:00.0" when the worker printed `pci` (workers from 5 October 2026 on), else empty + pub bus: String, + pub mem_mb: u64, +} + +impl ClDevice { + pub fn vendor_word(&self) -> &'static str { + if self.vendor.contains("NVIDIA") || self.name.contains("NVIDIA") { + "nvidia" + } else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") { + "amd" + } else { + "other" + } + } + fn platform_key(&self) -> String { + format!("{} ({}) driver {}", self.platform, self.platform_version, self.driver) + } +} + +/// Parses `igneum-worker-opencl --list`: `[idx] name | platform (version)` then `GPU, vendor V, driver D, OpenCL C +/// x.y, N compute units, M MHz[, pci bb:dd.f]` then `global N MiB, ...`. The bool says the worker printed its header. +pub fn parse_opencl_list(text: &str) -> (Vec, bool) { + let lines: Vec<&str> = text.lines().collect(); + let listed = lines.iter().any(|l| l.starts_with("OpenCL devices")); + let mut out = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim(); + if !t.starts_with('[') { + continue; + } + let Some(close) = t.find(']') else { continue }; + let rest = &t[close + 1..]; + let mut halves = rest.splitn(2, " |"); + let name = halves.next().unwrap_or("").trim().to_string(); + let plat = halves.next().unwrap_or("").trim(); + // the first " (" opens the version: AMD's version string carries brackets of its own, "OpenCL 2.1 AMD-APP (3617.0)" + let (platform, platform_version) = match plat.find(" (") { + Some(p) if plat.ends_with(')') => (plat[..p].to_string(), plat[p + 2..plat.len() - 1].to_string()), + _ => (plat.to_string(), String::new()), + }; + let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or(""); + let parts: Vec<&str> = info.split(", ").collect(); + let mem_mb = lines.get(i + 2).map(|l| l.trim()).and_then(|l| l.strip_prefix("global ")).and_then(|l| l.split_whitespace().next()).and_then(|n| n.parse::().ok()).unwrap_or(0); + out.push(ClDevice { + index: t[1..close].to_string(), + name, + platform, + platform_version, + is_gpu: info.starts_with("GPU"), + vendor: parts.iter().find_map(|p| p.strip_prefix("vendor ")).unwrap_or("").trim().to_string(), + driver: parts.iter().find_map(|p| p.strip_prefix("driver ")).unwrap_or("").trim().to_string(), + units: parts.iter().find(|p| p.contains("compute units")).unwrap_or(&"").to_string(), + bus: parts.iter().find_map(|p| p.strip_prefix("pci ")).unwrap_or("").trim().to_string(), + mem_mb, + }); + } + (out, listed) +} + +fn version_tuple(s: &str) -> Vec { + s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).map(|p| p.parse::().unwrap_or(0)).collect() +} + +/// One entry per physical card across OpenCL platforms. Two AMD ICDs after a driver upgrade each list every AMD +/// card (PC 1, 5 October 2026: gfx1036 and gfx1201 twice, two workers on one 9070 XT). Per vendor, the fuller +/// platform wins (most GPUs, then the newest driver, then the first listed); a device on another platform is kept +/// only when the winner has no device at the same PCI address (or, without addresses, the same code and ordinal). +/// Returns the kept devices and one note per dropped duplicate. +pub fn dedupe_platforms(devs: Vec) -> (Vec, Vec) { + let gpus: Vec = devs.into_iter().filter(|d| d.is_gpu).collect(); + let mut kept: Vec = Vec::new(); + let mut dropped = Vec::new(); + let mut vendors: Vec<&'static str> = Vec::new(); + for d in &gpus { + let v = d.vendor_word(); + if !vendors.contains(&v) { + vendors.push(v); + } + } + for v in vendors { + let mine: Vec<&ClDevice> = gpus.iter().filter(|d| d.vendor_word() == v).collect(); + let mut plats: Vec = Vec::new(); + for d in &mine { + let k = d.platform_key(); + if !plats.contains(&k) { + plats.push(k); + } + } + let score = |k: &String| { + let count = mine.iter().filter(|d| &d.platform_key() == k).count(); + let driver = mine.iter().find(|d| &d.platform_key() == k).map(|d| version_tuple(&d.driver)).unwrap_or_default(); + (count, driver) + }; + let winner = plats.iter().max_by(|a, b| score(a).cmp(&score(b))).cloned().unwrap_or_default(); + let identity = |d: &ClDevice, ordinal: usize| if d.bus.is_empty() { format!("{}#{ordinal}", d.name.to_ascii_lowercase()) } else { d.bus.clone() }; + let mut have: Vec = Vec::new(); + let mut seen_codes: std::collections::HashMap = std::collections::HashMap::new(); + let mut ordinal = |d: &ClDevice| { + let n = seen_codes.entry(format!("{}|{}", d.platform_key(), d.name.to_ascii_lowercase())).or_insert(0); + *n += 1; + *n + }; + for d in mine.iter().filter(|d| d.platform_key() == winner) { + let o = ordinal(d); + have.push(identity(d, o)); + kept.push((*d).clone()); + } + for d in mine.iter().filter(|d| d.platform_key() != winner) { + let o = ordinal(d); + let id = identity(d, o); + if have.contains(&id) { + dropped.push(format!("[{}] {} on {} ({}) is the same card as the one on {}: no worker", d.index, d.name, d.platform, d.platform_version, winner)); + } else { + have.push(id); + kept.push((*d).clone()); + } + } + } + kept.sort_by_key(|d| d.index.parse::().unwrap_or(u64::MAX)); + (kept, dropped) +} + +/// Keys without an index (it moves when a card arrives): vendor:code, "#2" and up for identical cards in list order. +pub fn assign_keys(cards: &mut [CardState]) { + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for c in cards.iter_mut() { + if c.code.is_empty() { + c.code = c.name.clone(); + } + let base = format!("{}:{}", c.vendor, c.code); + let n = seen.entry(base.clone()).or_insert(0); + *n += 1; + c.key = if *n == 1 { base } else { format!("{base}#{n}") }; + } +} + +/// What one Windows enumeration gathered; `assemble` turns it into the list (pure, so the PC 1 cases are tests). +#[derive(Default)] +pub struct Inputs { + /// `nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id --format=csv,noheader`; None = nvidia-smi did not run + pub nvidia: Option, + pub nvidia_limits: std::collections::HashMap, + pub cuda_worker: bool, + /// the OpenCL worker's --list; None = no worker installed or it did not answer + pub opencl: Option, + pub opencl_installed: bool, + /// Windows' adapter list; None = PowerShell did not answer + pub adapters: Option>, +} + +pub fn assemble(inp: Inputs) -> Detection { let mut d = Detection::default(); - // Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and - // processor for the integrated call, names for the last resort - let adapters = adapters(); - d.adapters_listed = adapters.is_some(); - let adapters = adapters.unwrap_or_default(); - // NVIDIA: nvidia-smi ships with the driver - let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10)); - match smi { + d.adapters_listed = inp.adapters.is_some(); + let adapters = inp.adapters.unwrap_or_default(); + let mut used: Vec = Vec::new(); + match inp.nvidia { Some(out) => { d.nvidia_listed = true; for line in out.lines() { @@ -355,13 +590,16 @@ pub fn detect(bins: &Bins) -> Detection { if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() { let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::().ok()).map(|v| v as u64).unwrap_or(0); let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() }; - let worker_ok = bins.cuda.is_some(); - let mut c = card(d.cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]); - c.bus = parts.get(3).map(|b| b.to_string()).unwrap_or_default(); - c.kind = classify_kind(parts[1], adapter_for(parts[1], &adapters)).into(); + // nvidia-smi prints 00000000:01:00.0; the worker and Windows say 01:00.0 + let bus = parts.get(3).map(|b| b.trim().to_ascii_lowercase()).map(|b| b.rsplit_once(':').map(|(d, r)| format!("{}:{r}", d.rsplit(':').next().unwrap_or(d))).unwrap_or(b)).unwrap_or_default(); + let (name, adapter) = resolve_name(parts[1], "nvidia", &bus, &adapters, &mut used); + let mut c = card(d.cards.len(), &name, "nvidia", "CUDA", &detail, parts[0]); + c.code = parts[1].to_string(); + c.bus = bus; + c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into(); c.vram_mb = mem_mb; - c.path = if worker_ok { "prebuilt".into() } else { "build".into() }; - if !worker_ok { + c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() }; + if !inp.cuda_worker { c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into(); } apply_defaults(&mut c); @@ -371,9 +609,8 @@ pub fn detect(bins: &Bins) -> Detection { if d.cards.is_empty() { d.notes.push("nvidia-smi ran but listed no card".into()); } - let limits = nvidia_power_limits(); for c in d.cards.iter_mut() { - if let Some((dflt, cur, lo, hi)) = limits.get(&c.device) { + if let Some((dflt, cur, lo, hi)) = inp.nvidia_limits.get(&c.device) { c.power_default_w = *dflt; c.power_limit_w = *cur; c.power_before_w = *cur; @@ -385,68 +622,71 @@ pub fn detect(bins: &Bins) -> Detection { } None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()), } - // OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped) - if let Some(cl) = bins.opencl.as_ref() { - if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) { - let lines: Vec<&str> = out.lines().collect(); - d.opencl_listed = lines.iter().any(|l| l.starts_with("OpenCL devices")); - for (i, line) in lines.iter().enumerate() { - let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim(); - if !t.starts_with('[') { - continue; - } - let Some(close) = t.find(']') else { continue }; - let idx = &t[1..close]; - let rest = &t[close + 1..]; - let name = rest.split(" |").next().unwrap_or("").trim(); - let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or(""); - let is_gpu = info.starts_with("GPU"); - let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim(); - if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") { - continue; - } - let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") || name.starts_with("gfx") { "amd" } else { "other" }; - let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string(); - let mut c = card(d.cards.len(), name, vendor, "OpenCL", &units, idx); - c.device = idx.to_string(); - c.kind = classify_kind(name, adapter_for(name, &adapters)).into(); - c.path = "prebuilt".into(); - apply_defaults(&mut c); - mark_sweep_support(&mut c); - d.cards.push(c); - } + if let Some(out) = inp.opencl { + let (devs, listed) = parse_opencl_list(&out); + d.opencl_listed = listed; + let (kept, dropped) = dedupe_platforms(devs.into_iter().filter(|dv| dv.vendor_word() != "nvidia").collect()); + d.dropped = dropped; + for dv in kept { + let vendor = dv.vendor_word(); + let (name, adapter) = resolve_name(&dv.name, vendor, &dv.bus, &adapters, &mut used); + let mut c = card(d.cards.len(), &name, vendor, "OpenCL", &dv.units, &dv.index); + c.code = dv.name.clone(); + c.bus = dv.bus.clone(); + c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver); + c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into(); + c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb }; + c.path = "prebuilt".into(); + apply_defaults(&mut c); + mark_sweep_support(&mut c); + d.cards.push(c); } - } else if d.cards.is_empty() { + } else if !inp.opencl_installed && d.cards.is_empty() { d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); } if d.cards.is_empty() { // last resort: the names Windows knows, so the screen can at least say what is in the PC - for a in adapters.iter().filter(|a| a.problem().is_none()) { + for (i, a) in adapters.iter().enumerate().filter(|(_, a)| a.problem().is_none()) { let vendor = vendor_of(&a.name); let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", ""); + c.bus = a.bus.clone(); c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() }; c.enabled = false; c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into(); + used.push(i); d.cards.push(c); } } // the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven - for a in adapters.iter() { + for (i, a) in adapters.iter().enumerate() { let Some(problem) = a.problem() else { continue }; - if d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) { + if used.contains(&i) || d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) { continue; } let vendor = vendor_of(&a.name); let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", ""); - c.bus = a.pnp_id.clone(); + c.bus = a.bus.clone(); c.kind = classify_kind(&a.name, Some(a)).into(); - c.vram_mb = a.ram_mb; mark_unusable(&mut c, &problem); d.cards.push(c); } + assign_keys(&mut d.cards); d } +#[cfg(not(target_os = "macos"))] +pub fn detect(bins: &Bins) -> Detection { + // Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and + // processor for the integrated call, the PCI address and the names for the rows + let adapters = adapters(); + // NVIDIA: nvidia-smi ships with the driver + let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10)); + let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() }; + // OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped) + let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15))); + assemble(Inputs { nvidia, nvidia_limits, cuda_worker: bins.cuda.is_some(), opencl, opencl_installed: bins.opencl.is_some(), adapters }) +} + /// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it. pub fn mark_unusable(c: &mut CardState, problem: &str) { c.problem = problem.to_string(); @@ -571,6 +811,151 @@ mod tests { assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia"); } + // PC 1 after Adrenalin 26.9.2 and a reboot (5 October 2026, evening): all three adapters OK, the Windows names, + // DEV ids and PCI addresses as the coordinator read them (the 5090's bus 01:00.0; the two AMD cards' addresses are + // not in that reading, so this fixture leaves them empty, as an old worker's --list would) + fn pc1_rebooted() -> Vec { + parse_adapters(r#"[{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x13C0)","PNPDeviceID":"PCI\\VEN_1002&DEV_13C0&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041","BusNumber":null,"Address":null}, + {"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019","BusNumber":1,"Address":0}, + {"Name":"AMD Radeon RX 9070 XT","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008","BusNumber":null,"Address":null}]"#) + } + // the 0.3.9 app's five rows came from this shape of --list: two AMD platforms, each listing both AMD GPUs (the old + // 32.0.21042 ICD and the new 32.0.32015 one); the driver strings are the shape AMD's runtime prints, the numbers + // are the Windows driver builds (approximate: the OpenCL CL_DRIVER_VERSION was not captured) + const PC1_LIST: &str = "OpenCL devices (4):\n\ + [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\ + global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\ + [1] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\ + global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\ + [2] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\ + global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\ + [3] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\ + global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n"; + const PC1_SMI: &str = "0, NVIDIA GeForce RTX 5090, 32607 MiB, 00000000:01:00.0\n"; + + fn pc1_inputs(list: &str) -> Inputs { + Inputs { nvidia: Some(PC1_SMI.into()), nvidia_limits: Default::default(), cuda_worker: true, opencl: Some(list.into()), opencl_installed: true, adapters: Some(pc1_rebooted()) } + } + + #[test] + fn opencl_list_parses_both_platforms_and_the_pci_field() { + let (devs, listed) = parse_opencl_list(PC1_LIST); + assert!(listed); + assert_eq!(devs.len(), 4); + assert_eq!(devs[1].index, "1"); + assert_eq!(devs[1].name, "gfx1201"); + assert_eq!(devs[1].platform, "AMD Accelerated Parallel Processing"); + assert_eq!(devs[1].platform_version, "OpenCL 2.1 AMD-APP (3617.0)"); + assert_eq!(devs[1].driver, "3617.0 (PAL,HSAIL)"); + assert_eq!(devs[1].units, "32 compute units"); + assert_eq!(devs[1].mem_mb, 16368); + assert_eq!(devs[1].bus, ""); + assert!(devs[1].is_gpu); + assert_eq!(devs[1].vendor_word(), "amd"); + let with_pci = PC1_LIST.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n"); + let (devs, _) = parse_opencl_list(&with_pci); + assert_eq!(devs[1].bus, "05:00.0"); + assert_eq!(devs[3].bus, "05:00.0"); + assert!(!parse_opencl_list("").1); + } + + #[test] + fn two_amd_platforms_give_one_entry_per_card() { + // without PCI addresses: by code and ordinal, the newer driver wins + let (devs, _) = parse_opencl_list(PC1_LIST); + let (kept, dropped) = dedupe_platforms(devs); + assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::>(), vec!["2", "3"]); + assert_eq!(dropped.len(), 2); + assert!(dropped[0].starts_with("[0] gfx1036 on AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0)) is the same card as"), "{}", dropped[0]); + // with PCI addresses: by address; a card the winner does not list (the old ICD still serving a third card) is kept + let text = PC1_LIST + .replace("2 compute units, 2200 MHz\n", "2 compute units, 2200 MHz, pci 0c:00.0\n") + .replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n") + + " [4] gfx1100 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 96 compute units, 2500 MHz, pci 09:00.0\n global 24560 MiB\n"; + let (devs, _) = parse_opencl_list(&text); + let (kept, dropped) = dedupe_platforms(devs); + // the old platform now lists three and wins on count: its three stay, the new platform's two are duplicates + assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::>(), vec!["0", "1", "4"]); + assert_eq!(dropped.len(), 2); + // two real twins on one platform keep both entries (same code, different ordinal or address) + let twins = "OpenCL devices (2):\n [0] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n [1] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n"; + let (kept, dropped) = dedupe_platforms(parse_opencl_list(twins).0); + assert_eq!(kept.len(), 2); + assert!(dropped.is_empty()); + } + + #[test] + fn names_come_from_windows_by_bus_then_device_id_then_the_table() { + let a = pc1_rebooted(); + let mut used = Vec::new(); + assert_eq!(resolve_name("NVIDIA GeForce RTX 5090", "nvidia", "01:00.0", &a, &mut used).0, "NVIDIA GeForce RTX 5090"); + assert_eq!(resolve_name("gfx1201", "amd", "", &a, &mut used).0, "AMD Radeon RX 9070 XT"); + assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "AMD Radeon(TM) Graphics"); + assert_eq!(used.len(), 3); + // every adapter is taken: a second gfx1036 gets the table's words, a code the table lacks stays a code + assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "Ryzen integrated Radeon Graphics"); + assert_eq!(resolve_name("gfx9999", "amd", "", &a, &mut used).0, "gfx9999"); + assert_eq!(resolve_name("gfx1100", "amd", "", &[], &mut Vec::new()).0, "Radeon RX 7900 XTX / XT"); + // by PCI address when both sides have one, before any table + let mut b = pc1_rebooted(); + b[2].bus = "05:00.0".into(); + let mut used = Vec::new(); + assert_eq!(resolve_name("gfx1201", "amd", "05:00.0", &b, &mut used), ("AMD Radeon RX 9070 XT".to_string(), Some(2))); + // the device id alone names a card whose adapter name the table does not know + let mut c = pc1_rebooted(); + c[2].name = "AMD Radeon RX 9070 XT OC Edition".into(); + assert_eq!(resolve_name("gfx1201", "amd", "", &c, &mut Vec::new()).0, "AMD Radeon RX 9070 XT OC Edition"); + assert_eq!(a[2].device_id(), 0x7550); + assert_eq!(a[0].device_id(), 0x13C0); + assert_eq!(a[1].bus, "01:00.0"); + } + + #[test] + fn pc1_five_rows_become_three_cards_named_properly() { + let d = assemble(pc1_inputs(PC1_LIST)); + assert!(d.nvidia_listed && d.opencl_listed && d.adapters_listed); + let rows: Vec<(String, String, String, String, bool, String)> = d.cards.iter().map(|c| (c.name.clone(), c.key.clone(), c.kind.clone(), c.device.clone(), c.enabled, c.code.clone())).collect(); + assert_eq!(rows, vec![ + ("NVIDIA GeForce RTX 5090".into(), "nvidia:NVIDIA GeForce RTX 5090".into(), "discrete".into(), "0".into(), true, "NVIDIA GeForce RTX 5090".into()), + ("AMD Radeon(TM) Graphics".into(), "amd:gfx1036".into(), "integrated".into(), "2".into(), false, "gfx1036".into()), + ("AMD Radeon RX 9070 XT".into(), "amd:gfx1201".into(), "discrete".into(), "3".into(), true, "gfx1201".into()), + ]); + assert_eq!(d.cards[0].bus, "01:00.0"); + assert_eq!(d.cards[1].reason, INTEGRATED_REASON); + assert_eq!(d.cards[1].identities, 1); + assert_eq!(d.cards[2].identities, 8, "16 GB: 8 identities"); + assert_eq!(d.cards[2].vram_mb, 16368); + assert!(d.cards[2].platform.contains("3649.0")); + assert_eq!(d.dropped.len(), 2); + assert!(d.notes.is_empty(), "{:?}", d.notes); + assert_eq!(crate::hotplug::cards_line(&d.cards), "cards: NVIDIA GeForce RTX 5090 [discrete, off] | AMD Radeon(TM) Graphics [integrated, off] | AMD Radeon RX 9070 XT [discrete, off]"); + // the same machine before the eGPU: one platform, the iGPU alone; the keys do not depend on the index + let before = "OpenCL devices (1):\n [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n global 16384 MiB\n"; + let d0 = assemble(pc1_inputs(before)); + assert_eq!(d0.cards[1].key, "amd:gfx1036"); + assert_eq!(d0.cards[1].device, "0"); + // and the diff between the two lists: the iGPU moved (device 0 to 2), the 9070 XT is new, nothing is removed + let diff = crate::hotplug::diff(&d0.cards, &d.cards, &|c| d.listed(c)); + assert_eq!(diff.unchanged, vec![0]); + assert_eq!(diff.moved.len(), 1); + assert_eq!(diff.moved[0].0, 1); + assert_eq!(diff.added.len(), 1); + assert_eq!(diff.added[0].name, "AMD Radeon RX 9070 XT"); + assert!(diff.removed.is_empty()); + } + + #[test] + fn keys_number_identical_cards() { + let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")]; + cards[2].name = "AMD Radeon RX 9070 XT".into(); + assign_keys(&mut cards); + assert_eq!(cards.iter().map(|c| c.key.as_str()).collect::>(), vec!["nvidia:NVIDIA GeForce RTX 5090", "nvidia:NVIDIA GeForce RTX 5090#2", "amd:gfx1201"]); + } + #[test] fn unusable_card_row() { let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", ""); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index ff2b34adc..d1d9a4e62 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -957,12 +957,15 @@ impl Engine { /// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes. fn first_detection(&mut self, d: crate::detect::Detection) { self.detected = true; - let crate::detect::Detection { cards, notes, .. } = d; - let names: Vec = cards.iter().map(|c| format!("{} ({}{})", c.name, c.worker, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect(); + let crate::detect::Detection { cards, notes, dropped, .. } = d; + let names: Vec = cards.iter().map(|c| format!("{} ({}{}{})", c.name, c.worker, if c.code != c.name { format!(", {}", c.code) } else { String::new() }, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect(); self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") })); for n in ¬es { self.shared.log(&format!("detection: {n}")); } + for n in &dropped { + self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}")); + } let prefs = self.shared.settings.lock().unwrap().cards.clone(); let mut st = self.st(); st.detecting = false; @@ -1004,6 +1007,9 @@ impl Engine { if diff.is_quiet() { return; } + for n in &d.dropped { + self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}")); + } let prefs = self.shared.settings.lock().unwrap().cards.clone(); let mut new_nvidia = false; for i in diff.removed.iter().copied() { @@ -1029,6 +1035,8 @@ impl Engine { c.device = fresh.device.clone(); c.key = fresh.key.clone(); c.bus = fresh.bus.clone(); + c.name = fresh.name.clone(); + c.platform = fresh.platform.clone(); if fresh.vram_mb > 0 { c.vram_mb = fresh.vram_mb; } diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs index 2f05e672f..aeaaf46f1 100644 --- a/app/igneum-app/src/hotplug.rs +++ b/app/igneum-app/src/hotplug.rs @@ -3,8 +3,9 @@ //! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went //! into PC 1 through an eGPU box while the app ran and nothing noticed. //! -//! Rules: a card is the same card when its key (vendor:device:name) matches, or, failing that, when vendor and -//! name match and that pair is unique on both sides (a device index that moved because another card arrived). A +//! Rules: a card is the same card when its key (vendor:code, "#2" for a twin) matches and the PCI addresses do not +//! disagree, or, failing that, when vendor and name match and that pair is unique on both sides (a twin whose +//! ordinal moved because the first one left). The device index is never part of the identity: it moves. A //! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards //! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a //! removed row after five minutes. @@ -43,8 +44,12 @@ impl Diff { } } +fn bus_compat(a: &CardState, b: &CardState) -> bool { + a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus +} + fn same_identity(a: &CardState, b: &CardState) -> bool { - a.vendor == b.vendor && a.name.trim().eq_ignore_ascii_case(b.name.trim()) && (a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus) + a.vendor == b.vendor && (a.name.trim().eq_ignore_ascii_case(b.name.trim()) || (!a.code.is_empty() && a.code.eq_ignore_ascii_case(&b.code))) && bus_compat(a, b) } /// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could @@ -55,7 +60,7 @@ pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) let mut pair: Vec> = vec![None; old.len()]; // exact keys first for (i, o) in old.iter().enumerate() { - if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key) { + if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key && bus_compat(o, f)) { used[j] = true; pair[i] = Some(j); } @@ -115,15 +120,26 @@ pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) out } -/// The saved choice for a card: by its key, else by vendor and name (the index moved since the choice was saved). +/// The saved choice for a card: by its key (vendor:code), else a key saved by an app before 0.3.11 (vendor:index:code, +/// vendor:index:name) when exactly one matches; an index that moved never changes the answer. pub fn pref_for<'a>(prefs: &'a std::collections::HashMap, c: &CardState) -> Option<&'a CardPref> { if let Some(p) = prefs.get(&c.key) { return Some(p); } + if c.key.contains('#') { + return None; // a twin's choice is its own + } let head = format!("{}:", c.vendor); - let tail = format!(":{}", c.name); - let mut found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail)).map(|(_, p)| p).collect(); - if found.len() == 1 { found.pop() } else { None } + for tail in [format!(":{}", c.code), format!(":{}", c.name)] { + if tail.len() <= 1 { + continue; + } + let found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail) && !k.contains('#')).map(|(_, p)| p).collect(); + if found.len() == 1 { + return Some(found[0]); + } + } + None } /// Applies a saved choice to a freshly detected card (the first detection and every later one use this). @@ -227,7 +243,7 @@ mod tests { use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON}; fn card(name: &str, vendor: &str, device: &str) -> CardState { - let mut c = CardState { index: 0, key: format!("{vendor}:{device}:{name}"), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() }; + let mut c = CardState { index: 0, key: format!("{vendor}:{name}"), code: name.into(), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() }; c.kind = classify_kind(name, None).into(); crate::detect::apply_defaults(&mut c); c @@ -312,7 +328,6 @@ mod tests { let old = pc1_start(); let mut fresh = pc1_start(); fresh[1].device = "1".into(); - fresh[1].key = "amd:1:gfx1036".into(); let mut e = card("gfx1201", "amd", "0"); e.index = 2; fresh.push(e); @@ -329,26 +344,27 @@ mod tests { let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0"); let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1"); b.index = 1; - a.bus = "00000000:01:00.0".into(); - b.bus = "00000000:02:00.0".into(); + b.key = "nvidia:NVIDIA GeForce RTX 5090#2".into(); + a.bus = "01:00.0".into(); + b.bus = "02:00.0".into(); let old = vec![a.clone(), b.clone()]; - // one of the twins leaves: the other keeps its slot by key; the missing one is removed, not "moved" + // the second twin leaves: the first keeps its slot by key; the missing one is removed, not "moved" let d = diff(&old, &[a.clone()], &all_listed); assert_eq!(d.unchanged, vec![0]); assert_eq!(d.removed, vec![1]); - // both twins present, both unmatched by key (indices swapped): the vendor+name rule refuses to guess - let mut a2 = a.clone(); - a2.device = "1".into(); - a2.key = "nvidia:1:NVIDIA GeForce RTX 5090".into(); - a2.bus = "00000000:03:00.0".into(); - let mut b2 = b.clone(); - b2.device = "0".into(); - b2.key = "nvidia:0:NVIDIA GeForce RTX 5090".into(); - b2.bus = "00000000:04:00.0".into(); - let d2 = diff(&old, &[a2, b2], &all_listed); - // the keys are the identity for twins: slot 0 is nvidia index 0 whichever bus it sits on; nothing restarts - assert!(d2.is_quiet(), "{d2:?}"); - assert_eq!(d2.unchanged, vec![0, 1]); + // the FIRST twin leaves: the survivor is now index 0 with the unsuffixed key, but its bus says which card it + // is, so slot 1 is "moved" (new key and device) and slot 0 is removed; no worker is swapped between cards + let mut survivor = b.clone(); + survivor.device = "0".into(); + survivor.key = "nvidia:NVIDIA GeForce RTX 5090".into(); + let d2 = diff(&old, &[survivor], &all_listed); + assert_eq!(d2.removed, vec![0]); + assert_eq!(d2.moved.len(), 1); + assert_eq!(d2.moved[0].0, 1); + assert_eq!(d2.moved[0].1.key, "nvidia:NVIDIA GeForce RTX 5090"); + // the same two cards again, nothing changed: quiet + let d3 = diff(&old, &old, &all_listed); + assert!(d3.is_quiet(), "{d3:?}"); } #[test] @@ -366,7 +382,6 @@ mod tests { let mut faulty = old.clone(); mark_unusable(&mut faulty[2], "Code 43"); faulty[2].device = String::new(); - faulty[2].key = "amd::gfx1201".into(); let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed); assert_eq!(d2.recovered.len(), 1); assert_eq!(d2.recovered[0].0, 2); @@ -406,7 +421,7 @@ mod tests { let mut prefs = std::collections::HashMap::new(); // the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0 prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() }); - // the iGPU comes back as device 1 (the eGPU took index 0): the choice is found by vendor and name + // the iGPU comes back as device 1 (the eGPU took index 0): the 0.3.9 key still answers, by vendor and code let mut igpu = card("gfx1036", "amd", "1"); assert_eq!(igpu.kind, "integrated"); assert!(!igpu.enabled); @@ -418,8 +433,8 @@ mod tests { assert_eq!(igpu.identities, 2); assert_eq!(igpu.reason, ""); assert_eq!(igpu.state, "waiting"); - // the user switched it off: the row keeps the integrated words - prefs.insert("amd:1:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() }); + // the user switched it off (saved under the index-free key): the row keeps the integrated words + prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() }); let mut igpu2 = card("gfx1036", "amd", "1"); let p2 = pref_for(&prefs, &igpu2).cloned(); settle_new(&mut igpu2, 1, p2.as_ref(), 6.0); @@ -440,10 +455,25 @@ mod tests { settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0); assert!(!bad.enabled); assert_eq!(bad.state, "unusable"); - // two saved choices with the same vendor and name but different indices: ambiguous, so none is used - prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() }); + // old keys only, two of them for the same code (the five-row PC 1 list had amd:0:gfx1036 and amd:2:gfx1036): + // ambiguous, so the default applies; the index-free key, once saved, always wins + prefs.remove("amd:gfx1036"); + prefs.insert("amd:2:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() }); let other = card("gfx1036", "amd", "7"); assert!(pref_for(&prefs, &other).is_none()); + prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: true, identities: 4, ..Default::default() }); + assert_eq!(pref_for(&prefs, &other).map(|p| p.identities), Some(4)); + // a twin never borrows the first card's choice + let mut twin = card("gfx1201", "amd", "3"); + twin.key = "amd:gfx1201#2".into(); + prefs.insert("amd:gfx1201".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() }); + assert!(pref_for(&prefs, &twin).is_none()); + // the name on the row is the Windows name while the key keeps the code: the 0.3.9 key by code still answers + let mut named = card("gfx1201", "amd", "1"); + named.name = "AMD Radeon RX 9070 XT".into(); + prefs.clear(); + prefs.insert("amd:1:gfx1201".to_string(), CardPref { enabled: false, identities: 2, ..Default::default() }); + assert_eq!(pref_for(&prefs, &named).map(|p| p.identities), Some(2)); } #[test] diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index c5784c67e..0267c5337 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -29,7 +29,9 @@ pub struct NodeState { #[derive(Clone, Serialize, Default, Debug)] pub struct CardState { pub index: usize, - pub key: String, // stable id for the saved preference: vendor:device:name + pub key: String, // stable id for the saved preference: vendor:code, "#2" and up for a second identical card (no index: it moves) + pub code: String, // the tool's own device name (AMD's OpenCL runtime says "gfx1201"); the key and the tooltip carry it + pub platform: String, // the OpenCL platform and driver the worker opens it through (the tooltip) pub kind: String, // apple | discrete | integrated | external | unknown pub vram_mb: u64, // 0 when unknown pub reason: String, // why it is off by default, if it is diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 8964aa25d..25ba11940 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -706,6 +706,8 @@ if (typeof document !== 'undefined') (function () { var kindWord = Notices.kindWord; // hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); } + // the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address + function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); } function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); } function renderCardRows(container, cards) { container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); }); @@ -716,7 +718,7 @@ if (typeof document !== 'undefined') (function () { d.dataset.key = cd.key; var meta = 'worker ' + esc(cd.worker) + '' + (vramText(cd) ? '' + esc(vramText(cd)) + '' : '') + (cd.detail && cd.kind !== 'apple' ? '' + esc(cd.detail) + '' : '') + (cd.detail && cd.kind === 'apple' ? '' + esc(cd.detail.split(',')[0]) + '' : '') + (cd.device ? 'device ' + esc(cd.device) + '' : ''); d.innerHTML = '
' + badgeHtml(cd) + '
' + - '
' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' ' + kindWord(cd.kind) + '
' + meta + '
' + + '
' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' ' + kindWord(cd.kind) + '
' + meta + '
' + (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message && !unusable && !removed ? '
' + esc(cd.message) + '
' : '') + '
' + (cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '
power cap' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W' + (cd.pinned ? 'pinned' : cd.sweep_pct ? 'sweep ' + cd.sweep_pct + '%' : '') + '
' : '') + '
identities
' + @@ -861,7 +863,7 @@ if (typeof document !== 'undefined') (function () { var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : cd.state === 'removed' ? 'removed' : ''; var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state; var extra = cd.ids.length ? 'id ' + esc(cd.ids.join(' ')) + '' : ''; - return '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + + return '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + '
' + cd.hash_now.toFixed(1) + 'MH/s
' + '
blocks ' + cd.accepted + '' + (cd.rejected ? ' / ' + cd.rejected + ' rejected' : '') + 'avg ' + cd.hash_avg.toFixed(1) + 'identities ' + cd.identities + '' + extra + (cd.faults ? 'worker faults ' + cd.faults + '' : '') + (cd.mismatched ? 're-check misses ' + cd.mismatched + '' : '') + '
' + telemetryHtml(cd) + diff --git a/proto-opencl/host.c b/proto-opencl/host.c index 457ec2090..4ec8f6f6f 100644 --- a/proto-opencl/host.c +++ b/proto-opencl/host.c @@ -58,6 +58,16 @@ // Vendor device attributes (cl_amd_device_attribute_query, cl_nv_device_attribute_query). #define IG_CL_DEVICE_WAVEFRONT_WIDTH_AMD 0x4043 #define IG_CL_DEVICE_WARP_SIZE_NV 0x4003 +// Where the card sits on the PCI bus, so the app can tell one physical card listed by two OpenCL platforms (two +// AMD ICDs after a driver upgrade, PC 1 on 5 October 2026) from two cards: CL_DEVICE_TOPOLOGY_AMD and the NVIDIA pair. +#define IG_CL_DEVICE_TOPOLOGY_AMD 0x4037 +#define IG_CL_DEVICE_TOPOLOGY_TYPE_PCIE_AMD 1 +#define IG_CL_DEVICE_PCI_BUS_ID_NV 0x4008 +#define IG_CL_DEVICE_PCI_SLOT_ID_NV 0x4009 +typedef union { + struct { cl_uint type; cl_uint data[5]; } raw; + struct { cl_uint type; cl_char unused[17]; cl_char bus; cl_char device; cl_char function; } pcie; +} ig_topology_amd; typedef cl_int (CL_API_CALL *ig_pfn_subgroup_info)(cl_kernel, cl_device_id, cl_uint, size_t, const void*, size_t, void*, size_t*); @@ -297,6 +307,7 @@ typedef struct { int cMajor, cMinor; // OpenCL C version int dMajor, dMinor; // device (platform profile) version cl_uint amdWavefront, nvWarp; // 0 if not reported + char pci[32]; // "01:00.0" (bus:device.function) when the vendor extension reports it, else "" } DeviceInfo; static void devStr(cl_device_id d, cl_device_info what, char* out, size_t n) { @@ -349,10 +360,19 @@ static int enumerateDevices(DeviceInfo** outList) { clGetDeviceInfo(devs[d], CL_DEVICE_MAX_WORK_GROUP_SIZE, sizeof(di.maxWorkGroup), &di.maxWorkGroup, NULL); if (sscanf(di.cVersion, "OpenCL C %d.%d", &di.cMajor, &di.cMinor) != 2) { di.cMajor = 1; di.cMinor = 2; } if (sscanf(di.version, "OpenCL %d.%d", &di.dMajor, &di.dMinor) != 2) { di.dMajor = 1; di.dMinor = 2; } - if (strstr(di.extensions, "cl_amd_device_attribute_query")) + if (strstr(di.extensions, "cl_amd_device_attribute_query")) { + ig_topology_amd topo; + memset(&topo, 0, sizeof(topo)); clGetDeviceInfo(devs[d], IG_CL_DEVICE_WAVEFRONT_WIDTH_AMD, sizeof(di.amdWavefront), &di.amdWavefront, NULL); - if (strstr(di.extensions, "cl_nv_device_attribute_query")) + if (clGetDeviceInfo(devs[d], IG_CL_DEVICE_TOPOLOGY_AMD, sizeof(topo), &topo, NULL) == CL_SUCCESS && topo.raw.type == IG_CL_DEVICE_TOPOLOGY_TYPE_PCIE_AMD) + snprintf(di.pci, sizeof(di.pci), "%02x:%02x.%x", (unsigned)(unsigned char)topo.pcie.bus, (unsigned)(unsigned char)topo.pcie.device, (unsigned)(unsigned char)topo.pcie.function); + } + if (strstr(di.extensions, "cl_nv_device_attribute_query")) { + cl_uint bus = 0, slot = 0; clGetDeviceInfo(devs[d], IG_CL_DEVICE_WARP_SIZE_NV, sizeof(di.nvWarp), &di.nvWarp, NULL); + if (clGetDeviceInfo(devs[d], IG_CL_DEVICE_PCI_BUS_ID_NV, sizeof(bus), &bus, NULL) == CL_SUCCESS && clGetDeviceInfo(devs[d], IG_CL_DEVICE_PCI_SLOT_ID_NV, sizeof(slot), &slot, NULL) == CL_SUCCESS) + snprintf(di.pci, sizeof(di.pci), "%02x:%02x.0", (unsigned)(bus & 0xff), (unsigned)(slot & 0xff)); + } list = (DeviceInfo*)realloc(list, sizeof(DeviceInfo) * (size_t)(n + 1)); list[n++] = di; } @@ -366,7 +386,10 @@ static void printDevice(int idx, const DeviceInfo* d, int chosen) { strstr(d->extensions, "cl_intel_subgroups") ? "cl_intel_subgroups" : strstr(d->extensions, "cl_khr_subgroups") ? "cl_khr_subgroups (no shuffle extension)" : "none"; printf("%s[%d] %s | %s (%s)\n", chosen ? "*" : " ", idx, d->name, d->platformName, d->platformVersion); - printf(" %s, vendor %s, driver %s, %s, %u compute units, %u MHz\n", typeName(d->type), d->vendor, d->driver, d->cVersion, d->computeUnits, d->clockMHz); + // the app's detect.rs reads this line: type, vendor, driver, the compute units, and the PCI address when known + printf(" %s, vendor %s, driver %s, %s, %u compute units, %u MHz", typeName(d->type), d->vendor, d->driver, d->cVersion, d->computeUnits, d->clockMHz); + if (d->pci[0]) printf(", pci %s", d->pci); + printf("\n"); printf(" global %llu MiB, max alloc %llu MiB, local %llu KiB, max work-group %llu, sub-group extension: %s", (unsigned long long)(d->globalMem >> 20), (unsigned long long)(d->maxAlloc >> 20), (unsigned long long)(d->localMem >> 10), (unsigned long long)d->maxWorkGroup, subExt);