diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 1af73c031..cb7c1f625 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,38 @@ 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, + /// 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, + 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()); @@ -56,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(), @@ -68,14 +103,141 @@ 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, + /// "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 { + 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| { + // 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() +} + +/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer. +#[cfg(windows)] +pub fn adapters() -> Option> { + // 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..])) +} + +#[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 +249,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 +310,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 +338,280 @@ 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); + assign_keys(&mut d.cards); + d } -#[cfg(not(target_os = "macos"))] -pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { - let mut cards: Vec = Vec::new(); - // 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)); - match smi { +/// 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(); + 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() { 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() }; + // 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); - 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)) = inp.nvidia_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,55 +620,84 @@ 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(); - 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") { "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); - c.device = idx.to_string(); - c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() }; - c.path = "prebuilt".into(); - apply_defaults(&mut c); - mark_sweep_support(&mut c); - 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 cards.is_empty() { - notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); + } 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 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 (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); } } - 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 (i, a) in adapters.iter().enumerate() { + let Some(problem) = a.problem() else { continue }; + 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.bus.clone(); + c.kind = classify_kind(&a.name, Some(a)).into(); + 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(); + 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 +736,250 @@ 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"); + } + + // 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", "", ""); + 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 6d0c36f78..63318b4b9 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, @@ -470,6 +471,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, @@ -564,6 +571,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, @@ -669,62 +680,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), @@ -884,7 +867,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 { @@ -1005,6 +988,147 @@ 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, 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; + 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; + } + 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() { + 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(); + c.name = fresh.name.clone(); + c.platform = fresh.platform.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; @@ -1184,7 +1308,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() { @@ -1348,7 +1472,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; @@ -1389,6 +1513,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); @@ -1407,7 +1534,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; @@ -1463,7 +1590,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); @@ -1483,7 +1610,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())); @@ -1520,7 +1647,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); @@ -2060,6 +2187,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 @@ -2485,6 +2622,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..aeaaf46f1 --- /dev/null +++ b/app/igneum-app/src/hotplug.rs @@ -0,0 +1,494 @@ +//! 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: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. + +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 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.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 +/// 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 && bus_compat(o, f)) { + 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 (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); + 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). +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}:{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 + } + // 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(); + 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; + 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()]; + // 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]); + // 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] + 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(); + 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 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); + 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 (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); + 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"); + // 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] + 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 205867874..356b0cf8d 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -38,10 +38,12 @@ pub struct ConsensusSwitch { pub daa: u64, } -#[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 + 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 @@ -52,7 +54,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, @@ -69,6 +71,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) @@ -104,6 +115,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 ca9b2ccb4..49be864f0 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -187,6 +187,9 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .gpu-row .reason{font-size:var(--t-sm);color:var(--ash)} .gpu-row .msg{font-size:var(--t-sm);color:var(--ember)} .gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto} +/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */ +.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5} +.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)} /* the Mine page: the big switch and the three numbers */ .hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)} @@ -247,6 +250,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .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 bab140a26..92b396ea2 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) ---------- @@ -234,32 +256,40 @@ var View = (function () { function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); } function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; } function shortHex(h, head, tail) { h = String(h || ''); head = head || 8; tail = tail || 6; return h.length > head + tail + 2 ? h.slice(0, head) + '…' + h.slice(-tail) : h; } - 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; }); } + // listed, driver fine, not unplugged: a worker can run on it + function present(c) { return !(c.removed_at > 0) && !c.problem; } + // 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 !== undefined && 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(' · '); } // one GPU row on the Mine page: cd = state.mining.cards[i] function cardRow(cd) { - var on = !!cd.enabled, st = cd.state || 'off'; - var word = !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; - var tone = !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; + var removed = cd.removed_at > 0, unusable = !!cd.problem; + var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off'); + var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; + var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : ''; var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : ''; var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : ''; var cap = cd.power_default_w > 0 ? Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) : 0; - var sub = cd.message || (!on && cd.reason ? cd.reason : '') || ''; + var sub = unusable ? (cd.reason || '') : removed ? 'unplugged; its worker stopped. The row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || ''; var meta = []; if (cd.vram_mb) { var gb = cd.vram_mb / 1024; meta.push((gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : '')); } if (on && cd.accepted) meta.push(cd.accepted + ' block' + (cd.accepted === 1 ? '' : 's')); if (on && cd.prepared) meta.push('next program ready'); - return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, canToggle: cd.kind !== 'unknown', word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · ') }; + return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) }; } // the big button on Mine: m = state.mining, n = state.node, s = { quitting } function toggle(m, n, s) { - var on = (m.cards || []).filter(function (c) { return c.enabled; }).length; - var mining = (m.cards || []).filter(function (c) { return c.state === 'mining'; }).length; + var cards = shownCards(m.cards).filter(present); + var on = cards.filter(function (c) { return c.enabled; }).length; + var mining = cards.filter(function (c) { return c.state === 'mining'; }).length; if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' }; - if (!on) return { label: 'Start mining', sub: m.cards && m.cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' }; + if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' }; if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }; if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' }; if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' }; @@ -340,7 +370,7 @@ var View = (function () { else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.'); return parts.join(' '); } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; })(); if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; } @@ -379,7 +409,7 @@ if (typeof document !== 'undefined') (function () { return fetch(path, opt).then(function (r) { return r.json(); }); } function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } - var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord; + var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord, shownCards = View.shownCards, cardTitle = View.cardTitle; function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); } function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; } function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); } @@ -492,7 +522,7 @@ if (typeof document !== 'undefined') (function () { if (!e.target.matches('.gpu-line .switch input') || !state) return; var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked; pending[key] = { on: on, at: Date.now() }; - var choices = state.mining.cards.map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; }); + var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; }); api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Worker starting on that card' : 'Worker stopped on that card'); else { delete pending[key]; toast(r.error || 'could not change'); } }); }); @@ -852,15 +882,16 @@ if (typeof document !== 'undefined') (function () { // ---------- the first-run GPU rows: one per card, with a switch ---------- 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) + '
' : '') + '
' + + ''; container.appendChild(d); }); } @@ -868,6 +899,7 @@ if (typeof document !== 'undefined') (function () { function readCardRows(container) { var out = []; container.querySelectorAll('.gpu-row, .set-card').forEach(function (r) { + if (r.classList.contains('removed') || r.classList.contains('unusable')) return; var cd = cardOf(r.dataset.key) || {}; var idsInput = r.querySelector('.ids input'), n = idsInput ? parseInt(idsInput.value, 10) : (cd.identities || 1); if (!(n >= 1)) n = 1; if (n > 64) n = 64; @@ -886,8 +918,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.'; @@ -920,9 +952,9 @@ if (typeof document !== 'undefined') (function () { var mineRowsSig = '', lastEventsKey = ''; function gpuLineHtml(r) { var num = function (cls, k, v, unit, tone) { return '
' + k + '' + (v ? esc(v) + (unit ? '' + unit + '' : '') : 'n/a') + '
'; }; - return '
' + + return '
' + '
' + badgeHtml({ vendor: r.vendor }) + '
' + - '
' + esc(r.name) + ' ' + esc(r.kindWord) + '
' + + '
' + esc(r.name) + ' ' + esc(r.kindWord) + '
' + '' + esc(r.word) + (r.meta ? ' · ' + esc(r.meta) : '') + '' + (r.sub ? '
' + esc(r.sub) + '
' : '') + '
' + num('hash', 'hash rate', r.hash, 'MH/s', '') + num('temp', 'temperature', r.temp, '', r.tempTone) + num('power', 'power', r.power, '', '') + '
'; @@ -933,8 +965,9 @@ if (typeof document !== 'undefined') (function () { tb.className = 'toggle-big' + (t.cls ? ' ' + t.cls : ''); tb.disabled = t.disabled; setText('btn-toggle-text', t.label); setText('btn-toggle-sub', t.sub); setText('d-hash', 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; - setText('d-hash-sub', 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), presentCards = shown.filter(View.present); + var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length; + setText('d-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state); setText('d-blocks', withCommas(m.accepted_total)); setText('d-blocks-sub', m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '')); if (n.daa > 0 && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '')); } @@ -946,16 +979,16 @@ 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'; setText('d-chain-blocks', compact(n.blocks)); // the rows: every card, on or off; rebuilt only when their words change - var rows = m.cards.map(View.cardRow), now = Date.now(); + var rows = shown.map(View.cardRow), now = Date.now(); rows.forEach(function (r) { var pd = pending[r.key]; if (pd) { if (pd.on === r.on || now - pd.at > 8000) delete pending[r.key]; else { r.on = pd.on; r.word = pd.on ? 'starting' : 'stopping'; r.tone = ''; } } }); var on = rows.filter(function (r) { return r.on; }).length; - setText('d-cards-eyebrow', s.detecting ? 'detecting' : !m.cards.length ? 'none found' : on + ' of ' + m.cards.length + ' on'); + setText('d-cards-eyebrow', s.detecting ? 'detecting' : !shown.length ? 'none found' : on + ' of ' + presentCards.length + ' on'); var sig = JSON.stringify(rows); if (sig !== mineRowsSig) { mineRowsSig = sig; $('d-cards').innerHTML = rows.length ? rows.map(gpuLineHtml).join('') : '
' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No GPU this app can drive was found. The node runs on its own.') + '
'; } - var note = !m.cards.length && s.detect_message ? s.detect_message : m.cards.some(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0 && !c.power_applied && c.enabled; }) ? 'A power cap is not applied yet: Settings has a Retry for the administrator prompt.' : ''; + var note = !shown.length && s.detect_message ? s.detect_message : shown.some(function (c) { return c.problem; }) ? 'A card the OS reports a problem on gets no worker. Reboot with the card attached; if it persists, reinstall the driver with the card attached.' : presentCards.some(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0 && !c.power_applied && c.enabled; }) ? 'A power cap is not applied yet: Settings has a Retry for the administrator prompt.' : ''; $('d-cards-note').hidden = !note; setText('d-cards-note', note); var key = s.events.length ? s.events[0].t + ':' + s.events.length : ''; if (key !== lastEventsKey) { @@ -1073,8 +1106,10 @@ if (typeof document !== 'undefined') (function () { renderSetCards(s); } function setCardHtml(cd) { - var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80; - var h = '
' + esc(cd.name) + '' + kindWord(cd.kind) + '' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (cd.enabled ? '' : ' · off') + '
'; + var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, removed = cd.removed_at > 0, unusable = !!cd.problem; + var h = '
' + esc(cd.name) + '' + kindWord(cd.kind) + '' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (removed ? ' · removed' : unusable ? ' · not usable (' + esc(cd.problem) + ')' : cd.enabled ? '' : ' · off') + '
'; + if (removed) return h + '

The card was unplugged; its worker stopped. The row goes in five minutes.

'; + if (unusable) return h + '

' + esc(cd.reason || 'No worker can drive this card.') + '

'; if (nv) { h += '
Power cap' + pct + '% · ' + Math.round(cd.power_default_w * pct / 100) + ' W' + '

Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the sweep leaves it.' : 'The sweep may move it.') + '

'; @@ -1095,9 +1130,9 @@ if (typeof document !== 'undefined') (function () { return h + '
'; } function renderSetCards(s) { - var cards = s.mining.cards, box = $('s-cards'); + var cards = shownCards(s.mining.cards), box = $('s-cards'); if (box.querySelector('input[type=range]:active') || box.contains(document.activeElement)) return; - var sig = JSON.stringify(cards.map(function (c) { return [c.key, c.enabled, c.identities, c.power_pct, c.pinned, c.power_applied, c.sweep_state, c.sweep_pct, c.sweep_note, Math.round(c.power_w || 0), Math.round(c.temp_gpu || 0), Math.round(c.temp_mem || 0)]; })); + var sig = JSON.stringify(cards.map(function (c) { return [c.key, c.enabled, c.identities, c.power_pct, c.pinned, c.power_applied, c.sweep_state, c.sweep_pct, c.sweep_note, Math.round(c.power_w || 0), Math.round(c.temp_gpu || 0), Math.round(c.temp_mem || 0), c.problem || '', c.removed_at > 0]; })); if (sig === setCardsSig) return; setCardsSig = sig; setText('s-cards-eyebrow', cards.length ? cards.length + ' card' + (cards.length === 1 ? '' : 's') : ''); 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/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 2f1979e49..e14a05ce1 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -149,3 +149,34 @@ test('the remote-jobs line and the helpers', () => { assert.equal(V.compact(2500000), '2.50M'); assert.equal(V.rel(90), '1 min ago'); }); + +test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as such, with no switch, and leave the counts', () => { + const gone = card({ key: 'amd:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: 1000, added_at: 0, gone: false, hash_now: 0 }); + const r = V.cardRow(gone); + assert.equal(r.removed, true); + assert.equal(r.on, false); + assert.equal(r.canToggle, false); + assert.equal(r.word, 'removed'); + assert.equal(r.tone, 'off'); + assert.equal(r.hash, ''); + assert.match(r.sub, /^unplugged; its worker stopped/); + const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', 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: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' }); + const b = V.cardRow(bad); + assert.equal(b.unusable, true); + assert.equal(b.canToggle, false); + assert.equal(b.word, 'not usable (Code 43)'); + assert.equal(b.tone, 'bad'); + assert.match(b.sub, /^reboot with the card attached/); + assert.equal(b.title, 'gfx1201 · CUDA device 1 · AMD Accelerated Parallel Processing · bus 0000:03:00.0'); + assert.equal(V.cardRow(card()).title, 'CUDA device undefined'.replace(' device undefined', '') === '' ? '' : V.cardRow(card()).title); + // a row that is gone (five minutes after removal) is not shown at all; the big button counts only present cards + assert.deepEqual(V.shownCards([card(), card({ key: 'x', gone: true })]).map((c) => c.key), ['nvidia:0:RTX 5090']); + assert.equal(V.present(card()), true); + assert.equal(V.present(gone), false); + assert.equal(V.present(bad), false); + const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {}); + assert.equal(t.disabled, true); + assert.equal(t.sub, 'no GPU this app can drive'); + const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {}); + assert.equal(t2.sub, 'mining on 1 of 1 card'); +}); diff --git a/app/windows/host.cpp b/app/windows/host.cpp index a05da8a3f..84bf99b5e 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -16,6 +16,7 @@ #endif #include #include +#include #include #include #include @@ -327,6 +328,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/packaging/windows/push-build-inputs.sh b/packaging/windows/push-build-inputs.sh index 273f80e25..17433c922 100755 --- a/packaging/windows/push-build-inputs.sh +++ b/packaging/windows/push-build-inputs.sh @@ -6,7 +6,9 @@ # signed hash is trusted, never the host. Nothing secret goes in: the jobs file is public. # # packaging/windows/push-build-inputs.sh [--node ] -# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-deploy] [--out ] +# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-node] [--no-deploy] [--out ] +# --no-node packs and builds the app engine only (a UI or engine change with no node change: miner-ui-2, 5 October +# 2026); the manifest's node block says "none" and the builds list has no node entry. # # What goes in (under igneum-build-inputs/): # manifest.json created_at, node {branch, commit, dirty, source}, repo {branch, commit, dirty}, app_version, @@ -38,13 +40,15 @@ while [ $# -gt 0 ]; do --node-tests) NODE_TESTS="$2"; shift 2 ;; --app-tests) APP_TESTS="$2"; shift 2 ;; --no-app) WITH_APP=0; shift ;; + --no-node) WITH_NODE=0; shift ;; --no-deploy) DEPLOY=0; shift ;; --out) OUT="$2"; shift 2 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done case "$NODE_SRC" in /*) ;; *) NODE_SRC="$ROOT/$NODE_SRC" ;; esac -[ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; } +[ "${WITH_NODE:-1}" = 0 ] || [ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; } +[ "${WITH_NODE:-1}" = 1 ] || [ "$WITH_APP" = 1 ] || { echo "--no-node with --no-app packs nothing" >&2; exit 2; } [ -f "$ROOT/app/igneum-app/Cargo.toml" ] || { echo "no app crate at $ROOT/app/igneum-app" >&2; exit 1; } [ -f "$ROOT/brand/icons/igneum.ico" ] || { echo "no $ROOT/brand/icons/igneum.ico (python3 brand/icons/make-icons.py)" >&2; exit 1; } command -v rsync >/dev/null || { echo "rsync is needed" >&2; exit 1; } @@ -77,8 +81,13 @@ APP_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.t TMP="$(mktemp -d)" STAGE="$TMP/igneum-build-inputs" mkdir -p "$STAGE/node" "$STAGE/app" "$STAGE/brand" -echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))" -rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/" +if [ "${WITH_NODE:-1}" = 1 ]; then + echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))" + rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/" +else + echo "no node (--no-node): the app engine only" + NODE_BRANCH=none; NODE_COMMIT=none; NODE_DIRTY=false +fi if [ "$WITH_APP" = 1 ]; then echo "packing app/igneum-app ($APP_VERSION) and brand/icons" rsync -a --exclude 'target' --exclude '.DS_Store' "$ROOT/app/igneum-app/" "$STAGE/app/igneum-app/" @@ -91,12 +100,12 @@ rsync -a --exclude 'target' --exclude 'target-*' --exclude '.DS_Store' "$ROOT/ig echo "packing proto-cuda (without nvrtc/redist)" rsync -a --exclude 'nvrtc/redist' --exclude '.DS_Store' --exclude '*.exe' --exclude '*.dll' "$ROOT/proto-cuda/" "$STAGE/proto-cuda/" -python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" <<'PY' +python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" "${WITH_NODE:-1}" <<'PY' import json, sys, datetime -out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests = sys.argv[1:13] -builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}] +out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests, with_node = sys.argv[1:14] +builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}] if with_node == "1" else [] tests = [] -if node_tests.strip(): +if node_tests.strip() and with_node == "1": tests.append({"dir": "node", "packages": node_tests.split()}) if with_app == "1": builds.append({"dir": "app/igneum-app", "packages": ["igneum-app"], "bins": ["igneum-app"], "targets": ["linux", "windows"], "optional_on": ["linux"]}) 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); 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/build-job.mjs b/tools/build-job.mjs index ebe71a67e..74bcefab5 100755 --- a/tools/build-job.mjs +++ b/tools/build-job.mjs @@ -5,7 +5,7 @@ // and puts them where the packaging scripts look. // node tools/build-job.mjs run [--node vendor/igneum-node-v4] [--target ae432dc7] [--budget-minutes 40] // [--stage-minutes '{"linux":20}'] [--targets linux,windows] [--no-tests] -// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app] +// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app] [--no-node] // [--title "..."] [--no-place] [--out dir] pack + publish + watch + fetch // node tools/build-job.mjs publish [the same flags] pack + publish, prints the id // node tools/build-job.mjs watch STAGE and RESULT lines as they land @@ -32,7 +32,7 @@ const RELAY_BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace( const argv = process.argv.slice(2); const flags = {}; const pos = []; -const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'help']); +const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'no-node', 'help']); for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (!BOOL.has(k) && next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; } @@ -213,6 +213,7 @@ function publish() { if (flags['node-tests']) pack.push('--node-tests', String(flags['node-tests'])); if (flags['app-tests']) pack.push('--app-tests', String(flags['app-tests'])); if (flags['no-app']) pack.push('--no-app'); + if (flags['no-node']) pack.push('--no-node'); if (flags['no-deploy']) pack.push('--no-deploy'); console.log(`$ ${pack.join(' ')}`); const r = spawnSync('bash', pack, { cwd: ROOT, stdio: 'inherit' }); 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') {