From d7049fdfda0da859a542f2c95c7a69378b094dd3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:45:05 +0000 Subject: [PATCH 1/2] app: GPU hot-plug (re-detection every minute, WM_DEVICECHANGE on Windows), faulty cards listed with the problem code, integrated GPUs off by default, the card list in the console 5 October 2026: an RX 9070 XT went into PC 1 through a Sonnet USB4 box while the app ran and nothing noticed; the app detected cards once at start. Now src/hotplug.rs compares every enumeration with the list (key, else vendor + name when unique; a card whose tool did not answer is never called removed): a new usable card starts a worker, enabled by default like a card at start, with "New card: , mining" on the strip and in the log; a card Windows lists with a problem code (Win32_VideoController Status / ConfigManagerErrorCode) is shown as ": not usable (Code 43)" with the reboot-or-reinstall hint and no worker; a card that disappears has its worker stopped (quit, 8 s) and its row says removed for five minutes, then hides; an unchanged list touches nothing. The Windows host sends "detect" on WM_DEVICECHANGE; the engine polls every 60 s (300 s on macOS, no GPU hot-plug there). detect.rs: the Ryzen iGPU is "gfx1036" to the OpenCL worker, so the APU gfx codes count as integrated, plus the adapter row's Intel processor string and a dedicated memory under 1 GB; integrated defaults to off with "integrated GPU, off by default (2 to 3 MH/s for 30 W)" on the row, and the user's choice is kept across re-detections and restarts (settings, found by key or by vendor + name when the index moved). Console: the engine logs "cards: [, ] | ..." at start, on every change and every 10 minutes; relay/lib/parse.mjs reads it and the hot-plug events, the machines API and tools/console.mjs machines show them. Tests: hotplug.rs (added, removed, moved, errored, recovered, revived, unchanged, twins, user override kept, the console line), detect.rs (PC 1's adapter lines, the Mac, kind classification, the unusable row), notices.test.mjs (card notices), relay parse.test.mjs (cards line). cargo test -p igneum-app: 91 passed. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/detect.rs | 378 +++++++++++++++++++---- app/igneum-app/src/engine.rs | 242 +++++++++++---- app/igneum-app/src/hotplug.rs | 464 +++++++++++++++++++++++++++++ app/igneum-app/src/main.rs | 3 + app/igneum-app/src/state.rs | 21 +- app/igneum-app/ui/app.css | 5 + app/igneum-app/ui/app.js | 71 +++-- app/igneum-app/ui/notices.test.mjs | 35 ++- app/windows/host.cpp | 6 + relay/api/console.mjs | 3 +- relay/lib/parse.mjs | 11 + relay/test/parse.test.mjs | 22 +- relay/ui.html | 2 +- tools/console.mjs | 3 + 14 files changed, 1133 insertions(+), 133 deletions(-) create mode 100644 app/igneum-app/src/hotplug.rs diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 1af73c031..adbc52329 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -1,6 +1,8 @@ //! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core //! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest -//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs. +//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no +//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither +//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists). use crate::state::CardState; use std::io::Write; @@ -18,6 +20,36 @@ pub struct Bins { pub dir: std::path::PathBuf, } +/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer +/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather +/// than calling them removed (src/hotplug.rs). +#[derive(Clone, Default)] +pub struct Detection { + pub cards: Vec, + pub notes: Vec, + pub nvidia_listed: bool, + pub opencl_listed: bool, + pub adapters_listed: bool, + pub metal_listed: bool, +} + +impl Detection { + /// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered. + pub fn listed(&self, c: &CardState) -> bool { + if !c.problem.is_empty() { + return self.adapters_listed; + } + match c.vendor.as_str() { + "apple" => self.metal_listed, + "nvidia" => self.nvidia_listed, + _ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed), + } + } +} + +/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends). +pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached"; + /// Runs a command with a time limit; returns stdout (and stderr appended) or None. pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option { cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -68,14 +100,125 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi } } -/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete). -#[allow(dead_code)] +/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is +/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints +/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026). pub fn looks_integrated(name: &str) -> bool { let n = name.to_ascii_lowercase(); let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"]; - integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") + if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") { + return true; + } + // AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and + // gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036, + // Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here. + let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"]; + let code = n.trim(); + apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} "))) } +/// One row of Windows' adapter list (Win32_VideoController), the part this app reads. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Adapter { + pub name: String, + /// "OK", "Error", "Degraded", ... (the Status property) + pub status: String, + /// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded + pub code: u32, + /// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values) + pub ram_mb: u64, + pub processor: String, + pub pnp_id: String, +} + +impl Adapter { + /// "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 { + return Some(format!("Code {}", self.code)); + } + let st = self.status.trim(); + if !st.is_empty() && !st.eq_ignore_ascii_case("ok") { + return Some(format!("status {st}")); + } + None + } +} + +/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is +/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing). +pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str { + if looks_integrated(name) { + return "integrated"; + } + if let Some(a) = adapter { + // the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon + // Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here + let proc_ = a.processor.to_ascii_lowercase(); + if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) { + return "integrated"; + } + if a.ram_mb > 0 && a.ram_mb < 1024 { + return "integrated"; + } + } + "discrete" +} + +pub fn vendor_of(name: &str) -> &'static str { + let n = name.to_ascii_lowercase(); + if n.contains("nvidia") || n.contains("geforce") { + "nvidia" + } else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") { + "amd" + } else if n.contains("apple") { + "apple" + } else { + "other" + } +} + +/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array). +pub fn parse_adapters(json: &str) -> Vec { + let Ok(v) = serde_json::from_str::(json.trim()) else { return Vec::new() }; + let rows: Vec = match v { + serde_json::Value::Array(a) => a, + o @ serde_json::Value::Object(_) => vec![o], + _ => Vec::new(), + }; + 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") }) + .filter(|a| !a.name.is_empty()) + .collect() +} + +/// 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"; + 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..])) +} + +#[cfg(not(windows))] +#[allow(dead_code)] +pub fn adapters() -> Option> { + None +} + +/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime +/// reports gfx codes, which match nothing here and fall back to the name rules). +pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> { + let n = name.trim().to_ascii_lowercase(); + adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n) +} + +/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept). +pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)"; + /// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off /// (1 identity), Apple silicon on with 1. pub fn apply_defaults(c: &mut CardState) { @@ -87,7 +230,7 @@ pub fn apply_defaults(c: &mut CardState) { "integrated" => { c.enabled = false; c.identities = 1; - c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into(); + c.reason = INTEGRATED_REASON.into(); } _ => { c.enabled = true; @@ -148,19 +291,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap) -> Vec { - let mut cards = Vec::new(); +pub fn detect(bins: &Bins) -> Detection { + let mut d = Detection::default(); let Some(metal) = bins.metal.as_ref() else { - notes.push("the Metal worker (igneum-bench) is missing from the app".into()); - return cards; + d.notes.push("the Metal worker (igneum-bench) is missing from the app".into()); + return d; }; // the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1" let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default(); let ready = out.lines().find(|l| l.starts_with("ready ")); let Some(ready) = ready else { - notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output"))); - return cards; + d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output"))); + return d; }; + d.metal_listed = true; let fields: Vec<&str> = ready.split_whitespace().collect(); let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into()); let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1"); @@ -175,58 +319,62 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { } } } - if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { - if let Ok(b) = mem.trim().parse::() { - let gb = b / (1024 * 1024 * 1024); - detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") }; - } + let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::().ok()); + if let Some(b) = mem { + let gb = b / (1024 * 1024 * 1024); + detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") }; } if !prepare { - notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into()); + d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into()); } let mut c = card(0, &name, "apple", "Metal", &detail, ""); c.kind = "apple".into(); c.path = "prebuilt".into(); - if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { - c.vram_mb = mem.trim().parse::().map(|b| b / (1024 * 1024)).unwrap_or(0); - } + c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0); apply_defaults(&mut c); mark_sweep_support(&mut c); - cards.push(c); - cards + d.cards.push(c); + d } #[cfg(not(target_os = "macos"))] -pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { - let mut cards: Vec = Vec::new(); +pub fn detect(bins: &Bins) -> 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", "--format=csv,noheader"]), None, Duration::from_secs(10)); + 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 { Some(out) => { + d.nvidia_listed = true; for line in out.lines() { let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); 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(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]); - c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() }; + 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(); c.vram_mb = mem_mb; c.path = if worker_ok { "prebuilt".into() } else { "build".into() }; if !worker_ok { 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); - cards.push(c); + d.cards.push(c); } } - if cards.is_empty() { - notes.push("nvidia-smi ran but listed no card".into()); + if d.cards.is_empty() { + d.notes.push("nvidia-smi ran but listed no card".into()); } let limits = nvidia_power_limits(); - for c in cards.iter_mut() { - if let Some((d, cur, lo, hi)) = limits.get(&c.device) { - c.power_default_w = *d; + for c in d.cards.iter_mut() { + if let Some((dflt, cur, lo, hi)) = limits.get(&c.device) { + c.power_default_w = *dflt; c.power_limit_w = *cur; c.power_before_w = *cur; c.power_min_w = *lo; @@ -235,12 +383,13 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { mark_sweep_support(c); } } - None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()), + 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('[') { @@ -256,34 +405,59 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { 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") { "amd" } else { "other" }; + 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(cards.len(), name, vendor, "OpenCL", &units, idx); + let mut c = card(d.cards.len(), name, vendor, "OpenCL", &units, idx); c.device = idx.to_string(); - c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() }; + c.kind = classify_kind(name, adapter_for(name, &adapters)).into(); c.path = "prebuilt".into(); apply_defaults(&mut c); mark_sweep_support(&mut c); - cards.push(c); + d.cards.push(c); } } - } else if cards.is_empty() { - notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); + } else if d.cards.is_empty() { + d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); } - if cards.is_empty() { + if d.cards.is_empty() { // last resort: the names Windows knows, so the screen can at least say what is in the PC - if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) { - for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { - let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" }; - let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0"); - c.kind = if looks_integrated(n) { "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(); - cards.push(c); - } + for a in adapters.iter().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.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(); + d.cards.push(c); } } - cards + // 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() { + let Some(problem) = a.problem() else { continue }; + if 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.kind = classify_kind(&a.name, Some(a)).into(); + c.vram_mb = a.ram_mb; + mark_unusable(&mut c, &problem); + d.cards.push(c); + } + d +} + +/// 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(); + c.enabled = false; + c.identities = 1; + c.state = "unusable".into(); + c.message = format!("not usable ({problem})"); + c.reason = PROBLEM_HINT.into(); + c.sweep_supported = false; + c.sweep_state = "unsupported".into(); + c.sweep_note = c.message.clone(); } /// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle). @@ -322,3 +496,105 @@ pub fn node_version(node: &Path) -> String { .and_then(|o| o.lines().next().map(|l| l.trim().to_string())) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + + // PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box + // while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK | + // ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK". + fn pc1() -> Vec { + parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"}, + {"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"}, + {"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"}]"#) + } + + #[test] + fn adapters_parse_with_status_code_and_memory() { + let a = pc1(); + assert_eq!(a.len(), 3); + assert_eq!(a[0].name, "AMD Radeon RX 9070 XT"); + assert_eq!(a[0].status, "Error"); + assert_eq!(a[0].code, 43); + assert_eq!(a[0].ram_mb, 0); + assert_eq!(a[0].problem().as_deref(), Some("Code 43")); + assert_eq!(a[1].ram_mb, 2048); + assert_eq!(a[1].problem(), None); + assert_eq!(a[2].problem(), None); + // a single adapter: ConvertTo-Json gives one object, not an array + let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#); + assert_eq!(one.len(), 1); + assert!(parse_adapters("not json").is_empty()); + } + + #[test] + fn problem_without_a_code_is_the_status_word() { + let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() }; + assert_eq!(a.problem().as_deref(), Some("status Degraded")); + let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() }; + assert_eq!(fine.problem(), None); + let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() }; + assert_eq!(code12.problem().as_deref(), Some("Code 12")); + } + + #[test] + fn kind_from_pc1_lines_and_the_mac() { + let a = pc1(); + // the discrete cards, with and without their adapter row + assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete"); + assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete"); + assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete"); + // the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code) + assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated"); + assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated"); + assert_eq!(classify_kind("gfx1036", None), "integrated"); + assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated"); + // a discrete gfx code stays discrete; an Arc card is discrete despite "Intel" + assert_eq!(classify_kind("gfx1201", None), "discrete"); + assert_eq!(classify_kind("gfx1030", None), "discrete"); + assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete"); + // an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count + let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() }; + assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated"); + assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)"); + // shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing + let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() }; + assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated"); + let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() }; + assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete"); + // the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated + assert!(!looks_integrated("Apple M5 Max")); + assert_eq!(vendor_of("Apple M5 Max"), "apple"); + assert_eq!(vendor_of("gfx1036"), "amd"); + assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd"); + assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia"); + } + + #[test] + fn unusable_card_row() { + let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", ""); + mark_unusable(&mut c, "Code 43"); + assert!(!c.enabled); + assert_eq!(c.state, "unusable"); + assert_eq!(c.message, "not usable (Code 43)"); + assert_eq!(c.reason, PROBLEM_HINT); + assert!(!c.present()); + } + + #[test] + fn integrated_default_is_off_with_the_row_words() { + let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1"); + c.kind = classify_kind("gfx1036", None).into(); + apply_defaults(&mut c); + assert!(!c.enabled); + assert_eq!(c.identities, 1); + assert_eq!(c.reason, INTEGRATED_REASON); + let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0"); + big.kind = "discrete".into(); + big.vram_mb = 32768; + apply_defaults(&mut big); + assert!(big.enabled); + assert_eq!(big.identities, 8); + } +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 850754324..ff2b34adc 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -29,7 +29,8 @@ pub struct CardChoice { pub enum Cmd { Detect, ApplyCards(Vec), - Detected(Vec, Vec), + /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) + Detected(crate::detect::Detection), Start, Pause, Resume, @@ -436,6 +437,12 @@ pub struct Engine { wrapper: bool, last_state_print: Instant, detected: bool, + /// hot-plug (src/hotplug.rs): an enumeration thread is running; one more was asked for meanwhile; when the + /// next periodic one is due; when the app log last carried the card list + detect_busy: bool, + detect_again: bool, + detect_next: Instant, + last_cards_line: Instant, clock_samples: Vec, clock_node_at: Option, clock_node_behind: f64, @@ -530,6 +537,10 @@ impl Engine { wrapper, last_state_print: now, detected: false, + detect_busy: false, + detect_again: false, + detect_next: now + Duration::from_secs(crate::hotplug::POLL_S), + last_cards_line: now, clock_samples: Vec::new(), clock_node_at: None, clock_node_behind: 0.0, @@ -635,62 +646,34 @@ impl Engine { fn command(&mut self, c: Cmd) { match c { Cmd::Detect => { - if self.detected { + // the first run, /api/detect, the host's "detect" on a device change, and the minute poll all land + // here; one enumeration at a time, and a request during one runs once more after it + if self.detect_busy { + self.detect_again = true; return; } - self.st().detecting = true; + self.detect_busy = true; + if !self.detected { + self.st().detecting = true; + } let bins = self.bins.clone(); let shared = self.shared.clone(); std::thread::spawn(move || { - let mut notes = Vec::new(); - let cards = crate::detect::detect(&bins, &mut notes); - shared.send(Cmd::Detected(cards, notes)); + let d = crate::detect::detect(&bins); + shared.send(Cmd::Detected(d)); }); } - Cmd::Detected(cards, notes) => { - self.detected = true; - let names: Vec = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).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}")); + Cmd::Detected(d) => { + self.detect_busy = false; + self.detect_next = Instant::now() + Duration::from_secs(crate::hotplug::POLL_S); + if self.detected { + self.merge_detection(d); + } else { + self.first_detection(d); } - let prefs = self.shared.settings.lock().unwrap().cards.clone(); - let mut st = self.st(); - st.detecting = false; - st.detect_message = notes.join(". "); - st.mining.cards = cards; - for c in st.mining.cards.iter_mut() { - if let Some(p) = prefs.get(&c.key) { - c.enabled = p.enabled && c.kind != "unknown"; - c.identities = p.identities.clamp(1, 64); - c.reason = String::new(); - if c.vendor == "nvidia" && p.power_pct > 0 { - c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100); - } - c.pinned = p.pinned; - c.sweep_pct = p.sweep_pct; - c.sweep_eff = p.sweep_eff; - c.sweep_watts = p.sweep_watts; - c.sweep_mhs = p.sweep_mhs; - c.sweep_at = p.sweep_at as f64; - } - } - let supported: Vec = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect(); - let unsupported: Vec = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect(); - drop(st); - for u in &unsupported { - self.shared.log(&format!("efficiency sweep {u}")); - } - if self.shared.runtime.sweep_only { - if supported.is_empty() { - self.sweep_say("SWEEP none reason=no_supported_card"); - self.shared.send(Cmd::Quit); - } else { - self.sweep_queue = supported; - } - } - if self.running { - self.plan_miners(); + if self.detect_again { + self.detect_again = false; + self.shared.send(Cmd::Detect); } } Cmd::ApplyCards(choices) => self.apply_cards(choices), @@ -850,7 +833,7 @@ impl Engine { let mut missing = Vec::new(); { let mut st = self.st(); - for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) { + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) { let want = requested_watts(c); let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w); if got > 0.0 { @@ -971,6 +954,139 @@ impl Engine { // ---- start, node ------------------------------------------------------------------------------------------ + /// 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(); + 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}")); + } + let prefs = self.shared.settings.lock().unwrap().cards.clone(); + let mut st = self.st(); + st.detecting = false; + st.detect_message = notes.join(". "); + st.mining.cards = cards; + for c in st.mining.cards.iter_mut() { + if let Some(p) = crate::hotplug::pref_for(&prefs, c) { + crate::hotplug::apply_pref(c, p); + } + } + let supported: Vec = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect(); + let unsupported: Vec = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect(); + let line = crate::hotplug::cards_line(&st.mining.cards); + drop(st); + self.shared.log(&line); + self.last_cards_line = Instant::now(); + for u in &unsupported { + self.shared.log(&format!("efficiency sweep {u}")); + } + if self.shared.runtime.sweep_only { + if supported.is_empty() { + self.sweep_say("SWEEP none reason=no_supported_card"); + self.shared.send(Cmd::Quit); + } else { + self.sweep_queue = supported; + } + } + if self.running { + self.plan_miners(); + } + } + + /// A later enumeration (src/hotplug.rs): new cards start, lost cards stop, faulty cards are marked; a list + /// that changed nothing touches nothing (no running worker ever restarts because of a re-detection). + fn merge_detection(&mut self, d: crate::detect::Detection) { + let now = crate::platform::unix_now_f(); + let old = self.st().mining.cards.clone(); + let diff = crate::hotplug::diff(&old, &d.cards, &|c| d.listed(c)); + if diff.is_quiet() { + return; + } + let prefs = self.shared.settings.lock().unwrap().cards.clone(); + let mut new_nvidia = false; + for i in diff.removed.iter().copied() { + let name = old[i].name.clone(); + self.drop_worker(i, "the card was removed"); + if let Some(c) = self.st().mining.cards.get_mut(i) { + crate::hotplug::mark_removed(c, now); + } + self.shared.event("warn", &format!("Card removed: {name}; its worker stopped")); + } + for (i, problem) in diff.errored.iter() { + let name = old[*i].name.clone(); + self.drop_worker(*i, &format!("the card reports {problem}")); + if let Some(c) = self.st().mining.cards.get_mut(*i) { + crate::detect::mark_unusable(c, problem); + c.added_at = now; + } + self.shared.event("warn", &format!("{name}: not usable ({problem}); its worker stopped. {}", crate::detect::PROBLEM_HINT)); + } + for (i, fresh) in diff.moved.iter() { + if let Some(c) = self.st().mining.cards.get_mut(*i) { + self.shared.log(&format!("{}: device {} is now {} (the running worker keeps its device; the next start uses the new one)", c.name, c.device, fresh.device)); + c.device = fresh.device.clone(); + c.key = fresh.key.clone(); + c.bus = fresh.bus.clone(); + if fresh.vram_mb > 0 { + c.vram_mb = fresh.vram_mb; + } + if !fresh.detail.is_empty() { + c.detail = fresh.detail.clone(); + } + } + } + let mut placed: Vec<(usize, CardState)> = Vec::new(); + for (i, fresh) in diff.recovered.into_iter().chain(diff.revived.into_iter()) { + placed.push((i, fresh)); + } + let mut next = self.st().mining.cards.len(); + for fresh in diff.added.into_iter() { + placed.push((next, fresh)); + next += 1; + } + for (i, mut fresh) in placed { + let pref = crate::hotplug::pref_for(&prefs, &fresh).cloned(); + crate::hotplug::settle_new(&mut fresh, i, pref.as_ref(), now); + let (kind, text) = crate::hotplug::added_words(&fresh); + new_nvidia |= fresh.vendor == "nvidia" && fresh.enabled; + let mut st = self.st(); + if i < st.mining.cards.len() { + st.mining.cards[i] = fresh; + } else { + st.mining.cards.push(fresh); + } + drop(st); + self.shared.event(kind, &text); + } + let line = crate::hotplug::cards_line(&self.st().mining.cards); + self.shared.log(&line); + self.last_cards_line = Instant::now(); + if self.running { + self.plan_miners(); + if new_nvidia { + self.apply_power_limits("new card"); + } + } + } + + /// Stops the worker on card `idx` (quit, then up to 8 s) and drops its slot; the sweep on it, if any, is aborted. + fn drop_worker(&mut self, idx: usize, why: &str) { + if self.sweep.as_ref().map(|r| r.card == idx).unwrap_or(false) { + self.sweep_abort(why); + } + let Some(pos) = self.miners.iter().position(|m| m.card == idx) else { return }; + let label = self.miners[pos].label.clone(); + if let Some(mut p) = self.miners[pos].proc.take() { + self.shared.log(&format!("stopping miner {label} ({why})")); + p.write_stdin("quit\n"); + p.stop(8); + } + self.miners.remove(pos); + self.stability.remove(&idx); + } + fn start(&mut self) { if self.running { return; @@ -1143,7 +1259,7 @@ impl Engine { let cards = self.st().mining.cards.clone(); let id8 = self.shared.runtime.id8(); for c in cards.iter() { - if !c.enabled || self.miners.iter().any(|m| m.card == c.index) { + if !c.enabled || !c.present() || self.miners.iter().any(|m| m.card == c.index) { continue; } let prefix = match c.vendor.as_str() { @@ -1307,7 +1423,7 @@ impl Engine { let mut st = self.st(); let paused = st.mining.paused; for c in st.mining.cards.iter_mut() { - if c.enabled && c.state != "faulted" { + if c.enabled && c.present() && c.state != "faulted" { c.state = if paused { "off".into() } else { "waiting".into() }; c.hash_now = 0.0; c.pid = 0; @@ -1348,6 +1464,9 @@ impl Engine { let mut st = self.st(); for ch in &choices { let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue }; + if !c.present() { + continue; // a removed or faulty card has no switch; its saved choice waits for it + } let identities = ch.identities.clamp(1, 64); let enabled = ch.enabled && c.kind != "unknown"; let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct); @@ -1366,7 +1485,7 @@ impl Engine { if c.enabled != enabled || c.identities != identities { c.enabled = enabled; c.identities = identities; - c.reason = String::new(); + c.reason = if !enabled && c.kind == "integrated" { crate::detect::INTEGRATED_REASON.into() } else { String::new() }; if !enabled { c.state = "off".into(); c.hash_now = 0.0; @@ -1422,7 +1541,7 @@ impl Engine { let mut what = Vec::new(); { let mut st = self.st(); - for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) { + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) { let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) }; c.power_pct = pct; let watts = requested_watts(c); @@ -1442,7 +1561,7 @@ impl Engine { self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & "))); let line = cmds.join(" & "); let what = what.join(", "); - let want: std::collections::HashMap = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect(); + let want: std::collections::HashMap = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect(); if self.wrapper && cfg!(windows) { // the window host has a UI context: it shows the administrator prompt and reports back on stdin self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now())); @@ -1479,7 +1598,7 @@ impl Engine { .mining .cards .iter() - .filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0) + .filter(|c| c.vendor == "nvidia" && c.present() && c.power_applied && c.power_before_w > 0.0) .map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64)) .collect(); drop(st); @@ -2019,6 +2138,16 @@ impl Engine { self.last_settings_save = now; self.shared.save_settings(); } + // hot-plug (src/hotplug.rs): enumerate again every POLL_S, and keep the card list in the log for the console + if self.detected && !self.detect_busy && !self.quitting && now >= self.detect_next { + self.detect_next = now + Duration::from_secs(crate::hotplug::POLL_S); + self.shared.send(Cmd::Detect); + } + if self.detected && now.duration_since(self.last_cards_line) >= Duration::from_secs(crate::hotplug::CARDS_LINE_S) { + self.last_cards_line = now; + let line = crate::hotplug::cards_line(&self.st().mining.cards); + self.shared.log(&line); + } } /// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what @@ -2444,6 +2573,7 @@ impl Engine { c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 }; } st.mining.hash_total = total; + crate::hotplug::age(&mut st.mining.cards, unix); let cut = unix - 3600.0; st.mining.found.retain(|t| *t > cut); if st.node.daa > 0 { diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs new file mode 100644 index 000000000..2f05e672f --- /dev/null +++ b/app/igneum-app/src/hotplug.rs @@ -0,0 +1,464 @@ +//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every +//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the +//! 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 +//! 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. + +use crate::config::CardPref; +use crate::state::CardState; + +/// How long a removed card's row says "removed" before it hides. +pub const REMOVED_SHOWN_S: f64 = 300.0; +/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there). +pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 }; +/// How often the app log carries the full card list, seconds (the console reads it from the log tail). +pub const CARDS_LINE_S: u64 = 600; + +#[derive(Default, Debug)] +pub struct Diff { + /// new cards (usable or with a problem), to be appended + pub added: Vec, + /// cards that were marked removed earlier and are listed again: (slot, the fresh entry) + pub revived: Vec<(usize, CardState)>, + /// slots whose card is gone + pub removed: Vec, + /// slots whose card now reports a problem: (slot, "Code 43") + pub errored: Vec<(usize, String)>, + /// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry) + pub recovered: Vec<(usize, CardState)>, + /// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry) + pub moved: Vec<(usize, CardState)>, + pub unchanged: Vec, +} + +impl Diff { + /// Nothing to do: every present card is where it was. + pub fn is_quiet(&self) -> bool { + self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty() + } +} + +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) +} + +/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could +/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed. +pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff { + let mut out = Diff::default(); + let mut used = vec![false; fresh.len()]; + 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) { + used[j] = true; + pair[i] = Some(j); + } + } + // then vendor + name, when that pair is unique among what is still unmatched on both sides + for (i, o) in old.iter().enumerate() { + if pair[i].is_some() { + continue; + } + let cands: Vec = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect(); + let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count(); + if cands.len() == 1 && twins == 0 { + used[cands[0]] = true; + pair[i] = Some(cands[0]); + } + } + for (i, o) in old.iter().enumerate() { + match pair[i] { + Some(j) => { + let f = &fresh[j]; + if o.removed_at > 0.0 { + out.revived.push((i, f.clone())); + } else if o.problem.is_empty() && !f.problem.is_empty() { + out.errored.push((i, f.problem.clone())); + } else if !o.problem.is_empty() && f.problem.is_empty() { + out.recovered.push((i, f.clone())); + } else if !o.problem.is_empty() { + // still faulty: the same problem or a new code, nothing to start or stop + if o.problem != f.problem { + out.errored.push((i, f.problem.clone())); + } else { + out.unchanged.push(i); + } + } else if o.device != f.device || o.key != f.key { + out.moved.push((i, f.clone())); + } else { + out.unchanged.push(i); + } + } + None => { + if o.removed_at > 0.0 { + // already removed: stays hidden or shown as removed + out.unchanged.push(i); + } else if listed(o) { + out.removed.push(i); + } else { + out.unchanged.push(i); + } + } + } + } + for (j, f) in fresh.iter().enumerate() { + if !used[j] { + out.added.push(f.clone()); + } + } + out +} + +/// The saved choice for a card: by its key, else by vendor and name (the index moved since the choice was saved). +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); + } + 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 } +} + +/// Applies a saved choice to a freshly detected card (the first detection and every later one use this). +pub fn apply_pref(c: &mut CardState, p: &CardPref) { + if !c.problem.is_empty() { + return; + } + c.enabled = p.enabled && c.kind != "unknown"; + c.identities = p.identities.clamp(1, 64); + if c.enabled || c.kind != "integrated" { + c.reason = String::new(); + } + if c.vendor == "nvidia" && p.power_pct > 0 { + c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100); + } + c.pinned = p.pinned; + c.sweep_pct = p.sweep_pct; + c.sweep_eff = p.sweep_eff; + c.sweep_watts = p.sweep_watts; + c.sweep_mhs = p.sweep_mhs; + c.sweep_at = p.sweep_at as f64; +} + +/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it +/// came with; its slot and the time it appeared. +pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) { + c.index = index; + c.added_at = now; + c.removed_at = 0.0; + c.gone = false; + if let Some(p) = pref { + apply_pref(c, p); + } + if c.problem.is_empty() { + c.state = if c.enabled { "waiting".into() } else { "off".into() }; + } +} + +/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched. +pub fn mark_removed(c: &mut CardState, now: f64) { + c.removed_at = now; + c.gone = false; + c.state = "removed".into(); + c.message = "removed".into(); + c.hash_now = 0.0; + c.pid = 0; + c.restart_in_s = 0; + c.ready = false; + c.prepared = false; +} + +/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed. +pub fn age(cards: &mut [CardState], now: f64) -> bool { + let mut changed = false; + for c in cards.iter_mut() { + let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S; + if gone != c.gone { + c.gone = gone; + changed = true; + } + } + changed +} + +/// The event line for a card that appeared: "New card: , mining" and its kind (ok | info | warn). +pub fn added_words(c: &CardState) -> (&'static str, String) { + if !c.problem.is_empty() { + ("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT)) + } else if c.enabled { + ("ok", format!("New card: {}, mining", c.name)) + } else if c.kind == "integrated" { + ("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON)) + } else { + ("info", format!("New card: {}, off (switched off in settings)", c.name)) + } +} + +/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43). +pub fn state_word(c: &CardState) -> String { + if c.removed_at > 0.0 { + "removed".into() + } else if !c.problem.is_empty() { + format!("not usable ({})", c.problem) + } else if !c.enabled { + "off".into() + } else { + c.state.clone() + } +} + +/// The app-log line the console reads (relay/lib/parse.mjs): `cards: [, ] | ...`, hidden rows +/// left out, `cards: none` when nothing is listed. +pub fn cards_line(cards: &[CardState]) -> String { + let parts: Vec = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect(); + if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) } +} + +#[cfg(test)] +mod tests { + use super::*; + 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() }; + c.kind = classify_kind(name, None).into(); + crate::detect::apply_defaults(&mut c); + c + } + // PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036) + fn pc1_start() -> Vec { + let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0"); + a.bus = "00000000:01:00.0".into(); + a.state = "mining".into(); + let mut b = card("gfx1036", "amd", "0"); + b.index = 1; + vec![a, b] + } + fn all_listed(_: &CardState) -> bool { + true + } + fn fresh_pc1_with_egpu() -> Vec { + let mut v = pc1_start(); + v[0].state = "off".into(); + let mut e = card("gfx1201", "amd", "1"); + e.index = 2; + v.push(e); + v + } + + #[test] + fn unchanged_list_is_quiet() { + let old = pc1_start(); + let mut fresh = pc1_start(); + fresh[0].state = "off".into(); // runtime state in the fresh list means nothing + let d = diff(&old, &fresh, &all_listed); + assert!(d.is_quiet(), "{d:?}"); + assert_eq!(d.unchanged, vec![0, 1]); + } + + #[test] + fn a_new_usable_card_is_added_and_nothing_else_moves() { + let old = pc1_start(); + let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed); + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].name, "gfx1201"); + assert_eq!(d.added[0].kind, "discrete"); + assert!(d.added[0].enabled); + assert_eq!(d.unchanged, vec![0, 1]); + assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty()); + } + + #[test] + fn a_new_card_with_a_problem_is_added_as_unusable() { + let old = pc1_start(); + let mut fresh = pc1_start(); + let mut bad = card("AMD Radeon RX 9070 XT", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + fresh.push(bad); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].problem, "Code 43"); + assert!(!d.added[0].enabled); + let (kind, text) = added_words(&d.added[0]); + assert_eq!(kind, "warn"); + assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}"); + assert_eq!(state_word(&d.added[0]), "not usable (Code 43)"); + } + + #[test] + fn an_unplugged_card_is_removed_only_when_its_tool_answered() { + let old = fresh_pc1_with_egpu(); + let fresh = pc1_start(); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.removed, vec![2]); + assert_eq!(d.unchanged, vec![0, 1]); + // the OpenCL worker did not answer this round: nothing is called removed + let opencl_dead = |c: &CardState| c.vendor == "nvidia"; + let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::>(), &opencl_dead); + assert!(d2.removed.is_empty(), "{d2:?}"); + assert!(d2.is_quiet()); + } + + #[test] + fn a_card_whose_index_moved_keeps_its_slot() { + // the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0 + 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); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.moved.len(), 1); + assert_eq!(d.moved[0].0, 1); + assert_eq!(d.moved[0].1.device, "1"); + assert_eq!(d.added.len(), 1); + assert!(d.removed.is_empty()); + } + + #[test] + fn two_identical_cards_are_told_apart_by_key_and_never_swapped() { + 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(); + 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" + 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]); + } + + #[test] + fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() { + let old = fresh_pc1_with_egpu(); + // the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL + let mut fresh = pc1_start(); + let mut bad = card("gfx1201", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + fresh.push(bad); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.errored, vec![(2, "Code 43".to_string())]); + assert!(d.added.is_empty() && d.removed.is_empty()); + // after a reboot with the card attached it is driven again: recovered, same slot + 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); + assert!(d2.recovered[0].1.problem.is_empty()); + // the same problem again next minute: quiet + let d3 = diff(&faulty, &fresh, &all_listed); + assert!(d3.is_quiet(), "{d3:?}"); + } + + #[test] + fn a_removed_card_that_comes_back_is_revived_in_its_slot() { + let mut old = fresh_pc1_with_egpu(); + mark_removed(&mut old[2], 1000.0); + assert_eq!(state_word(&old[2]), "removed"); + // still absent: quiet (and hidden after five minutes) + let d = diff(&old, &pc1_start(), &all_listed); + assert!(d.is_quiet(), "{d:?}"); + assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S)); + assert!(old[2].gone); + assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0)); + // back: revived in slot 2 + let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed); + assert_eq!(d2.revived.len(), 1); + assert_eq!(d2.revived[0].0, 2); + assert!(d2.added.is_empty()); + let mut back = d2.revived[0].1.clone(); + settle_new(&mut back, 2, None, 2000.0); + assert_eq!(back.index, 2); + assert_eq!(back.removed_at, 0.0); + assert!(!back.gone); + assert_eq!(back.added_at, 2000.0); + assert_eq!(back.state, "waiting"); + } + + #[test] + fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() { + 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 + let mut igpu = card("gfx1036", "amd", "1"); + assert_eq!(igpu.kind, "integrated"); + assert!(!igpu.enabled); + assert_eq!(igpu.reason, INTEGRATED_REASON); + let p = pref_for(&prefs, &igpu).cloned(); + assert!(p.is_some()); + settle_new(&mut igpu, 1, p.as_ref(), 5.0); + assert!(igpu.enabled); + 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() }); + let mut igpu2 = card("gfx1036", "amd", "1"); + let p2 = pref_for(&prefs, &igpu2).cloned(); + settle_new(&mut igpu2, 1, p2.as_ref(), 6.0); + assert!(!igpu2.enabled); + assert_eq!(igpu2.reason, INTEGRATED_REASON); + assert_eq!(igpu2.state, "off"); + let (kind, text) = added_words(&igpu2); + assert_eq!(kind, "info"); + assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})")); + // no saved choice: the detect default (integrated off, discrete on) + let mut egpu = card("gfx1201", "amd", "0"); + settle_new(&mut egpu, 2, None, 7.0); + assert!(egpu.enabled); + assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string())); + // a pref never switches on a card with a problem + let mut bad = card("AMD Radeon RX 9070 XT", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + 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() }); + let other = card("gfx1036", "amd", "7"); + assert!(pref_for(&prefs, &other).is_none()); + } + + #[test] + fn the_console_line_lists_every_shown_card_with_kind_and_state() { + let mut cards = fresh_pc1_with_egpu(); + cards[0].state = "mining".into(); + cards[2].state = "starting".into(); + let mut bad = card("AMD Radeon RX 9070 XT", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + cards.push(bad); + assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]"); + mark_removed(&mut cards[2], 10.0); + assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]")); + age(&mut cards, 10.0 + REMOVED_SHOWN_S); + assert!(!cards_line(&cards).contains("gfx1201")); + assert_eq!(cards_line(&[]), "cards: none"); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 126291f01..dc37f2bb3 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -16,6 +16,7 @@ mod config; mod detect; mod engine; +mod hotplug; mod keys; mod platform; mod procs; @@ -135,6 +136,8 @@ fn main() { "quit" => shared.send(engine::Cmd::Quit), "pause" => shared.send(engine::Cmd::Pause), "resume" => shared.send(engine::Cmd::Resume), + // the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute + "detect" => shared.send(engine::Cmd::Detect), "elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))), _ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))), _ => {} diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index faaa8199c..c5784c67e 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -26,7 +26,7 @@ pub struct NodeState { pub override_restart_wait: String, } -#[derive(Clone, Serialize, Default)] +#[derive(Clone, Serialize, Default, Debug)] pub struct CardState { pub index: usize, pub key: String, // stable id for the saved preference: vendor:device:name @@ -40,7 +40,7 @@ pub struct CardState { pub detail: String, // memory, cores pub device: String, // the worker's --device value (Windows) pub enabled: bool, - pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) + pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged) pub hash_now: f64, // MH/s, the last interval pub hash_avg: f64, // MH/s since the start pub accepted: u64, @@ -57,6 +57,15 @@ pub struct CardState { pub pid: u32, pub last_status_age_s: f64, pub message: String, + /// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it + pub problem: String, + /// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved + pub bus: String, + /// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it + /// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides) + pub added_at: f64, + pub removed_at: f64, + pub gone: bool, /// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count pub faults: u64, /// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line) @@ -92,6 +101,14 @@ pub struct CardState { pub race_variants: u32, } +impl CardState { + /// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the + /// list so the other cards' indices stay put). + pub fn present(&self) -> bool { + self.removed_at == 0.0 && self.problem.is_empty() + } +} + #[derive(Clone, Serialize, Default)] pub struct MiningState { pub state: String, // idle | waiting | mining | paused | stopped diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 07eb252e1..4dc6820ac 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -175,6 +175,10 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .gpu-row .meta b{color:var(--ink-2);font-weight:500} .gpu-row .reason{font-size:var(--t-sm);color:var(--ash)} .gpu-row .msg{font-size:var(--t-sm);color:var(--ember)} +.gpu-row.removed{opacity:.5} +.gpu-row.removed .ids,.gpu-row.unusable .ids{opacity:.3;pointer-events:none} +.gpu-row.unusable .name{color:var(--ember)} +.gpu-tile .st.removed{color:var(--ash)} .ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} .ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)} .ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} @@ -296,6 +300,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(-- .feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)} .feed .k{color:var(--molten);margin-right:6px} .feed .k.error{color:var(--ember)} +.feed .k.warn{color:var(--ember)} .feed .k.block{color:var(--ember)} .feed .k.build{color:var(--ink-2)} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 3d9b91285..8964aa25d 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -9,11 +9,12 @@ 4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job - notice is for the dashboard. */ + notice is for the dashboard. A card that appeared or went (hot-plug, src/hotplug.rs) is a level 5 notice for + 5 minutes on every screen: "New card: , mining", "New card: , not usable (Code 43)", "Card removed". */ var Notices = (function () { 'use strict'; - var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 }; - var JOB_DONE_S = 300, INSTALLED_S = 60; + var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; + var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300; function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; } function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; } @@ -91,6 +92,26 @@ var Notices = (function () { return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] }); } + // cards = state.mining.cards; now = state.now: every card a re-detection added or lost in the last 5 minutes + // (added_at / removed_at are 0 for the cards found at start). The newest first, so pick() shows it. + function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; } + function cardNotices(cards, now) { + var out = []; + (cards || []).forEach(function (c) { + if (c.removed_at > 0) { + if (now - c.removed_at <= CARD_S) out.push(notice('card-removed', 'card:removed:' + c.key + ':' + Math.floor(c.removed_at), 'Card removed: ' + c.name + '. Its worker stopped.', { tone: 'warn', at: c.removed_at })); + return; + } + if (!(c.added_at > 0) || now - c.added_at > CARD_S) return; + // a new card with a problem, or a card that was mining and now reports one (both carry added_at): the row's words + if (c.problem) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), c.name + ': not usable (' + c.problem + '). No worker runs on it.', { tone: 'warn', detail: c.reason || '', at: c.added_at })); + else if (c.enabled) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', mining.', { at: c.added_at })); + else out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', off (' + kindWord(c.kind).toLowerCase() + '). Settings switches it on.', { at: c.added_at })); + }); + out.sort(function (a, b) { return b.at - a.at; }); + return out; + } + // every notice the state carries for this screen; pick() chooses the one to show function gather(s, where) { where = where || {}; @@ -98,6 +119,7 @@ var Notices = (function () { if (u) out.push(u); if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); } else { var c = clockNotice(s.clock); if (c) out.push(c); } + if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0)); return out; } // the lowest level wins; a dismissed key is skipped, so the next one shows in its place @@ -110,7 +132,7 @@ var Notices = (function () { } return best; } - return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot }; + return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; })(); /* ---------- the update card (pure; update-card.test.mjs loads this block) ---------- @@ -328,9 +350,11 @@ if (typeof document !== 'undefined') (function () { $('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; }); $('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; }); $('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); }); + var settingsCardsSig = ''; function fillSettings() { if (!state) return; $('s-address').textContent = state.address.display || 'not set'; + settingsCardsSig = shownCards(state.mining.cards).map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : ''); }).join('|'); renderCardRows($('s-cards'), state.mining.cards); state.mining.cards.forEach(function (cd) { if (cd.vendor !== 'nvidia' || !(cd.power_default_w > 0)) return; @@ -679,27 +703,31 @@ if (typeof document !== 'undefined') (function () { if (cd.vendor === 'apple') return ''; return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU'; } - function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; } + 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; }); } 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(); }); - cards.forEach(function (cd) { + shownCards(cards).forEach(function (cd) { var d = document.createElement('div'); - d.className = 'gpu-row' + (cd.enabled ? '' : ' off'); + var removed = cd.removed_at > 0, unusable = !!cd.problem; + d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : ''); 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) + ' ' + kindWord(cd.kind) + '
' + meta + '
' + - (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
' + + '
' + 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
' + - ''; + ''; container.appendChild(d); }); } function readCardRows(container) { var out = []; container.querySelectorAll('.gpu-row').forEach(function (r) { + if (r.classList.contains('removed') || r.classList.contains('unusable')) return; var n = parseInt(r.querySelector('.ids input').value, 10); if (!(n >= 1)) n = 1; if (n > 64) n = 64; var pr = r.querySelector('.power input'), item = { key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n }; if (pr) item.power_pct = parseInt(pr.value, 10); @@ -726,8 +754,8 @@ if (typeof document !== 'undefined') (function () { var list = $('cards-list'), det = $('cards-detecting'); if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; } det.hidden = true; - var cards = s.mining.cards; - var sig = cards.map(function (c) { return c.key; }).join('|'); + var cards = shownCards(s.mining.cards); + var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|'); if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); } if (!cards.length) { $('cards-sub').textContent = 'No GPU this app can drive was found.'; @@ -798,8 +826,9 @@ if (typeof document !== 'undefined') (function () { function renderDashboard(s) { var m = s.mining, n = s.node, p = s.program, f = s.finality; $('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1); - var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length; - $('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state; + var shown = shownCards(m.cards), present = shown.filter(function (c) { return !(c.removed_at > 0) && !c.problem; }); + var mining = present.filter(function (c) { return c.state === 'mining'; }).length; + $('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + present.length + ' card' + (present.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state; $('d-blocks').textContent = withCommas(m.accepted_total); $('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''); var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state; @@ -822,22 +851,24 @@ if (typeof document !== 'undefined') (function () { $('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee'; $('d-chain-blocks').textContent = compact(n.blocks); // per-card tiles (enabled cards), the rest in one muted line - var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; }); - $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : ''); + // a card that was removed keeps its tile saying "removed" for five minutes (then the row goes); a faulty one is in the off line + var on = shown.filter(function (c) { return c.enabled && !c.problem; }), off = shown.filter(function (c) { return !c.enabled || c.problem; }); + var workers = on.filter(function (c) { return !(c.removed_at > 0); }).length; + $('d-cards-eyebrow').textContent = workers + ' worker' + (workers === 1 ? '' : 's') + (present.length > workers ? ' of ' + present.length + ' GPU' + (present.length === 1 ? '' : 's') : ''); var tb = $('d-cards'); - if (!on.length) tb.innerHTML = '
' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; + if (!on.length) tb.innerHTML = '
' + (shown.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; else tb.innerHTML = on.map(function (cd) { - var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : ''; + 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) + '
' + '
' + 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) + - '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
'; + '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message && cd.state !== 'removed' ? '
' + esc(cd.message) + '
' : '') + ''; }).join(''); $('d-cards-off').hidden = !off.length; - $('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. Settings switches them on.' : ''; + $('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.problem ? c.name + ': not usable (' + c.problem + ')' : c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. ' + (off.some(function (c) { return !c.problem; }) ? 'Settings switches them on.' : 'Reboot with the card attached; if it persists, reinstall the driver with the card attached.') : ''; // node card $('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : ''); $('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers; diff --git a/app/igneum-app/ui/notices.test.mjs b/app/igneum-app/ui/notices.test.mjs index 251ae8f29..22cd67ab3 100644 --- a/app/igneum-app/ui/notices.test.mjs +++ b/app/igneum-app/ui/notices.test.mjs @@ -11,7 +11,7 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js') const mod = { exports: {} }; new Function('module', src)(mod); const N = mod.exports; -const { updateNotice, jobNotice, clockNotice, gather, pick } = N; +const { updateNotice, jobNotice, clockNotice, cardNotices, gather, pick } = N; const NOW = 1_800_000_000; const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over }); @@ -139,3 +139,36 @@ test('clock: the engine words, Sync clock, the hint; gather() keeps it off the d assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock'); assert.deepEqual(gather({}, { dashboard: true }), []); }); + +// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen +const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over }); +test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => { + const start = cardOf(); + assert.deepEqual(cardNotices([start], NOW), []); + const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' }); + const [a] = cardNotices([start, added], NOW); + assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30)); + const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 }); + const [b] = cardNotices([bad], NOW); + assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/); + const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 }); + assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.'); + const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 }); + const [r] = cardNotices([removed], NOW); + assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn'); + // the newest first; both go after CARD_S + const two = cardNotices([added, removed], NOW); + assert.equal(two[0].kind, 'card-added'); + assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []); +}); + +test('card notices sit under a running job and show on the setup screens too', () => { + const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 }); + const withJob = s({ jobs: run(), mining: { cards: [added] } }); + assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running'); + const quiet = s({ mining: { cards: [added] } }); + assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added'); + assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added'); + // closed: stays closed for that key; a later event on the same card has a new key + assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null); +}); diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 83d081786..946809b45 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -16,6 +16,7 @@ #endif #include #include +#include #include #include #include @@ -324,6 +325,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } } return 0; + case WM_DEVICECHANGE: + // a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates + // the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration + if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect"); + return TRUE; case WM_TRAY: if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); } else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu(); diff --git a/relay/api/console.mjs b/relay/api/console.mjs index 7dcb53589..832058bd9 100644 --- a/relay/api/console.mjs +++ b/relay/api/console.mjs @@ -136,6 +136,7 @@ async function machines(sql) { accepted_lifetime: g.app && g.app.status ? g.app.status.accepted_total ?? null : null, faults: cards.reduce((a, c) => a + (c.faults || 0), 0), telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [], + gpus: g.app && g.app.gpus ? g.app.gpus : [], gpus_at: g.app && g.app.gpus_at ? g.app.gpus_at : null, hotplug: g.app && g.app.hotplug ? g.app.hotplug : [], labels: g.labels.sort((a, b) => (b.at > a.at ? 1 : -1)), runs: [...g.runs], }; }); @@ -145,7 +146,7 @@ async function machines(sql) { // several Mac installs (test runs): the newest is "Mac", the others keep their id let macSeen = false; for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; } - for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], labels: [], runs: [], never: true }); + for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true }); out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || '')); return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S }; } diff --git a/relay/lib/parse.mjs b/relay/lib/parse.mjs index 5cc2602dc..a8134f61e 100644 --- a/relay/lib/parse.mjs +++ b/relay/lib/parse.mjs @@ -70,6 +70,12 @@ export function parseHeader(text) { const m = HEADER.exec(text || ''); return m ? { version: m[1], id8: m[2], platform: m[3], node: m[4].replace(/^igneumd[_\/]/, '') } : null; } +/// "NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]" +export function parseCardsLine(text) { + if (!text || text.trim() === 'none') return []; + return text.split(' | ').map(p => { const m = /^(.*) \[([^,\]]+), ([^\]]+)\]$/.exec(p.trim()); return m ? { name: m[1], kind: m[2], state: m[3] } : { name: p.trim(), kind: 'unknown', state: '' }; }); +} + export function parseAppTail(tail) { const lines = tail.split('\n'); const a = { telemetry: [], events: [], jobs: [] }; @@ -113,6 +119,11 @@ export function parseAppTail(tail) { if (e && e[2] !== 'block') a.events.push({ at: new Date(Number(e[1]) * 1000).toISOString(), kind: e[2], text: e[3].slice(0, 200) }); } a.events = a.events.slice(-6); + // the card list (app/igneum-app/src/hotplug.rs cards_line): the newest "cards: [, ] | ..." line, + // written at start, on every hot-plug change and every 10 minutes + const cl = lastMatch(lines, /^(\d+)(?:\.\d+)? cards: (.+)$/); + if (cl) { a.gpus = parseCardsLine(cl[2]); a.gpus_at = new Date(Number(cl[1]) * 1000).toISOString(); } + a.hotplug = lines.filter(l => /^\d+(?:\.\d+)? \[(ok|warn|info)\] (New card: |Card removed: |.+: not usable \()/.test(l)).slice(-4).map(l => ({ at: stampOf(l), text: strip(l).replace(/^\[\w+\] /, '').slice(0, 200) })); // A clean stop: the app logs "quit: ..." (Quit, Stop, or an update: "[info] installing Igneum Miner ...") and uploads // once more before it exits; when no status line follows, the machine was stopped on purpose, not lost (4 October // 2026: a Mac stopped at 14:47 UTC read "silent 4h" for the whole afternoon, the same as a crash or a lost network). diff --git a/relay/test/parse.test.mjs b/relay/test/parse.test.mjs index 8c852f5df..f5aa00494 100644 --- a/relay/test/parse.test.mjs +++ b/relay/test/parse.test.mjs @@ -1,7 +1,7 @@ // node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job) import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseLabel, parseMinerTail, markStale, STALE_S } from '../lib/parse.mjs'; +import { parseLabel, parseMinerTail, parseAppTail, parseCardsLine, markStale, STALE_S } from '../lib/parse.mjs'; test('labels: every vendor the app names, the app log, the node log, the legacy launchers', () => { assert.deepEqual(parseLabel('miner-nvidia-ae432dc7-1'), { id: 'ae432dc7', platform: 'win', stream: 'miner', vendor: 'nvidia', card: 1 }); @@ -84,3 +84,23 @@ test('app tail: the newest update line decides the OTA state: staged beats an ol const inst = parseAppTail(mac + '\n1791146700 [info] installing Igneum Miner 0.3.4: the miners stop, then the node, then the app opens again'); assert.deepEqual([inst.ota.state, inst.ota.version], ['installing', '0.3.4']); }); + +test('app tail: the cards line gives kind and state per card, and the hot-plug events', () => { + assert.deepEqual(parseCardsLine('NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]'), + [{ name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', state: 'mining' }, { name: 'gfx1036', kind: 'integrated', state: 'off' }, { name: 'AMD Radeon RX 9070 XT', kind: 'discrete', state: 'not usable (Code 43)' }]); + assert.deepEqual(parseCardsLine('none'), []); + const tail = [ + '1791140500 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off]', + '1791140560 [ok] New card: gfx1201, mining', + '1791140561 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, waiting]', + '1791140800 [warn] Card removed: gfx1201; its worker stopped', + '1791140801 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, removed]', + '1791140830 status: accepted 5 blocks (1 this run, dev fee 0), 120.00 MH/s, mining | node 100 blocks, 3 peers, synced | up 1h', + ].join('\n'); + const a = parseAppTail(tail); + assert.equal(a.gpus.length, 3); + assert.equal(a.gpus[2].state, 'removed'); + assert.equal(a.gpus_at, new Date(1791140801000).toISOString()); + assert.deepEqual(a.hotplug.map(h => h.text), ['New card: gfx1201, mining', 'Card removed: gfx1201; its worker stopped']); + assert.equal(parseAppTail('1791140830 status: x | node y | up 1h').gpus, undefined); +}); diff --git a/relay/ui.html b/relay/ui.html index 6946df10b..3d394493c 100644 --- a/relay/ui.html +++ b/relay/ui.html @@ -287,7 +287,7 @@ input[type=text]{width:100%} ['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'], ['run', `${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}`], ])} - ${cardTable(mc.cards)}${tele} + ${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `
GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} [${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `${esc(g.state)}` : esc(g.state)}]`).join(' | ')}
` : ''}${(mc.hotplug || []).map(h => `
hot-plug ${ago(h.at)} ago: ${esc(h.text)}
`).join('')}${tele} ${mc.app && (mc.app.ota || mc.app.power_cap || mc.app.jobs_file || (mc.app.jobs && mc.app.jobs.length) || mc.app.status) ? `
${mc.app.status ? `
${when(mc.app.status.at)}status${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}
` : ''} ${mc.app.ota ? `
${when(mc.app.ota.at)}ota${esc(mc.app.ota.text)}
` : ''} diff --git a/tools/console.mjs b/tools/console.mjs index 40feee60d..3a4e1448c 100644 --- a/tools/console.mjs +++ b/tools/console.mjs @@ -113,6 +113,9 @@ try { console.log(`${m.name.padEnd(8)} ${m.id8 || '-'} ${m.stopped ? `STOPPED (${m.stopped.reason}) ${ago(m.stopped.at)} ago,` : m.silent ? 'SILENT' : 'live'} seen ${ago(m.last_seen)} ago | app ${m.app_version || '?'} node ${m.node.version || '?'} daa ${m.node.daa ?? '?'} peers ${m.node.peers ?? '?'} ${m.node.synced ? 'synced' : 'not synced'} | ${m.hash_total.toFixed(1)} MH/s, ${m.accepted_total} accepted, ${m.faults} faults`); for (const c of m.cards) console.log(` ${c.label.padEnd(22)} ${c.stale ? 'last ' + c.hash_now.toFixed(1) + ' MH/s (stale, not in the total)' : c.hash_now.toFixed(1) + ' MH/s now'}, ${c.accepted} accepted, ${c.rejected} rejected, ${c.mismatched} mismatched, ${c.restarts} restarts, status ${ago(c.status_at)} ago${c.fault ? ' | ' + c.fault : ''}`); for (const t of m.telemetry) console.log(` ${t.name}: p95 ${t.p95_w} W, max ${t.max_w} W (cap ${t.cap_w} W), GPU ${t.max_gpu_c} C, memory ${t.max_mem_c} C`); + // the engine's own card list (kind and state per card, hot-plug included: app/igneum-app/src/hotplug.rs), as of its last "cards:" line + if (m.gpus && m.gpus.length) console.log(` GPUs (${ago(m.gpus_at)} ago): ${m.gpus.map(g => `${g.name} [${g.kind}, ${g.state}]`).join(' | ')}`); + for (const h of m.hotplug || []) console.log(` hot-plug ${ago(h.at)} ago: ${h.text}`); } } else if (cmd === 'chain') { From bd21f2a68015350bbf577723182e7593a767ff47 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:20:44 +0000 Subject: [PATCH 2/2] app: one card row per physical GPU across OpenCL platforms, Windows names on the rows, index-free card keys; the OpenCL worker prints the PCI address PC 1 after Adrenalin 26.9.2 (5 October 2026): two AMD ICDs each listed the iGPU and the RX 9070 XT, so the 0.3.9 app showed five rows for three GPUs (gfx1036, gfx1201, gfx1036, gfx1201 plus the 5090), ran two workers on the one 9070 XT at 9 MH/s each, called the iGPU discrete, and re-enabled it when its key moved from amd:1:gfx1036 to amd:0:gfx1036. detect.rs: the Windows path is a pure assemble(Inputs) over nvidia-smi, the worker's --list and the adapter list. dedupe_platforms keeps one entry per card per vendor: the fuller platform wins (most GPUs, then the newest driver, then the first listed) and another platform's device survives only at a PCI address the winner lacks (without addresses: the same code and ordinal); the dropped entries go to the log, never to a worker. resolve_name puts the Windows adapter name on the row: by PCI bus (the adapter list now carries DEVPKEY_Device_BusNumber and Address), else by the gfx code's PCI device ids, else by the card names in a gfx table (gfx1201, 1200, 1100, 1101, 1102, 1030, 1031, 1032, 1036, 1035, 1103, 1150, 90c, approximate), else the table's words, else the code. The code stays in `code`, the key and the row's tooltip; the key is vendor:code with "#2" for a twin, no index. hotplug::pref_for reads a 0.3.9 key (vendor:index:code) when exactly one matches; the diff matches by key and PCI address, then by vendor and name or code. The iGPU is integrated and off by default; the choice survives a moved index. proto-opencl/host.c: --list prints ", pci bb:dd.f" on the info line from CL_DEVICE_TOPOLOGY_AMD or the NVIDIA bus and slot ids (needs a worker rebuild through proto-cuda/nvrtc/build-windows.sh and push-inputs; an old worker still works, by code and ordinal). Tests: detect::tests has the five-row PC 1 list as a fixture (two platforms, the three adapters with DEV_13C0, DEV_2B85 and DEV_7550) and asserts three rows named NVIDIA GeForce RTX 5090, AMD Radeon(TM) Graphics (integrated, off) and AMD Radeon RX 9070 XT (discrete, on, 8 identities), the keys, the console line, and the diff against the one-platform list before the eGPU (iGPU moved, 9070 XT added, nothing removed); plus the list parser with and without pci, dedupe with and without addresses, name resolution, twins' keys. cargo test -p igneum-app: 96 passed. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/detect.rs | 499 ++++++++++++++++++++++++++++++---- app/igneum-app/src/engine.rs | 12 +- app/igneum-app/src/hotplug.rs | 94 ++++--- app/igneum-app/src/state.rs | 4 +- app/igneum-app/ui/app.js | 6 +- proto-opencl/host.c | 29 +- 6 files changed, 547 insertions(+), 97 deletions(-) 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);