app: one card row per physical GPU across OpenCL platforms, Windows names on the rows, index-free card keys; the OpenCL worker prints the PCI address

PC 1 after Adrenalin 26.9.2 (5 October 2026): two AMD ICDs each listed the iGPU and the RX 9070 XT, so the 0.3.9
app showed five rows for three GPUs (gfx1036, gfx1201, gfx1036, gfx1201 plus the 5090), ran two workers on the one
9070 XT at 9 MH/s each, called the iGPU discrete, and re-enabled it when its key moved from amd:1:gfx1036 to
amd:0:gfx1036.

detect.rs: the Windows path is a pure assemble(Inputs) over nvidia-smi, the worker's --list and the adapter list.
dedupe_platforms keeps one entry per card per vendor: the fuller platform wins (most GPUs, then the newest driver,
then the first listed) and another platform's device survives only at a PCI address the winner lacks (without
addresses: the same code and ordinal); the dropped entries go to the log, never to a worker. resolve_name puts the
Windows adapter name on the row: by PCI bus (the adapter list now carries DEVPKEY_Device_BusNumber and Address),
else by the gfx code's PCI device ids, else by the card names in a gfx table (gfx1201, 1200, 1100, 1101, 1102,
1030, 1031, 1032, 1036, 1035, 1103, 1150, 90c, approximate), else the table's words, else the code. The code stays
in `code`, the key and the row's tooltip; the key is vendor:code with "#2" for a twin, no index. hotplug::pref_for
reads a 0.3.9 key (vendor:index:code) when exactly one matches; the diff matches by key and PCI address, then by
vendor and name or code. The iGPU is integrated and off by default; the choice survives a moved index.

proto-opencl/host.c: --list prints ", pci bb:dd.f" on the info line from CL_DEVICE_TOPOLOGY_AMD or the NVIDIA bus
and slot ids (needs a worker rebuild through proto-cuda/nvrtc/build-windows.sh and push-inputs; an old worker
still works, by code and ordinal).

Tests: detect::tests has the five-row PC 1 list as a fixture (two platforms, the three adapters with DEV_13C0,
DEV_2B85 and DEV_7550) and asserts three rows named NVIDIA GeForce RTX 5090, AMD Radeon(TM) Graphics (integrated,
off) and AMD Radeon RX 9070 XT (discrete, on, 8 identities), the keys, the console line, and the diff against the
one-platform list before the eGPU (iGPU moved, 9070 XT added, nothing removed); plus the list parser with and
without pci, dedupe with and without addresses, name resolution, twins' keys. cargo test -p igneum-app: 96 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 18:20:44 +00:00
parent d7049fdfda
commit bd21f2a680
6 changed files with 547 additions and 97 deletions

View file

@ -27,6 +27,8 @@ pub struct Bins {
pub struct Detection {
pub cards: Vec<CardState>,
pub notes: Vec<String>,
/// duplicate OpenCL platform entries left out (one line each, for the log)
pub dropped: Vec<String>,
pub nvidia_listed: bool,
pub opencl_listed: bool,
pub adapters_listed: bool,
@ -88,7 +90,8 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration)
fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState {
CardState {
index,
key: format!("{vendor}:{device}:{name}"),
key: format!("{vendor}:{name}"),
code: name.to_string(),
name: name.to_string(),
vendor: vendor.into(),
worker: worker.into(),
@ -129,9 +132,16 @@ pub struct Adapter {
pub ram_mb: u64,
pub processor: String,
pub pnp_id: String,
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
pub bus: String,
}
impl Adapter {
/// The PCI device id from the PnP id ("PCI\\VEN_1002&DEV_7550&..." gives 0x7550); 0 when there is none.
pub fn device_id(&self) -> u16 {
let up = self.pnp_id.to_ascii_uppercase();
up.find("DEV_").and_then(|i| u16::from_str_radix(up.get(i + 4..i + 8)?, 16).ok()).unwrap_or(0)
}
/// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine.
pub fn problem(&self) -> Option<String> {
if self.code != 0 {
@ -189,7 +199,14 @@ pub fn parse_adapters(json: &str) -> Vec<Adapter> {
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::<u64>().ok()))).unwrap_or(0);
rows.iter()
.map(|r| Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID") })
.map(|r| {
// DEVPKEY_Device_Address on PCI is (device << 16) | function
let bus = match (r.get("BusNumber").and_then(|x| x.as_u64()), r.get("Address").and_then(|x| x.as_u64())) {
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
_ => String::new(),
};
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
})
.filter(|a| !a.name.is_empty())
.collect()
}
@ -197,7 +214,9 @@ pub fn parse_adapters(json: &str) -> Vec<Adapter> {
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
#[cfg(windows)]
pub fn adapters() -> Option<Vec<Adapter>> {
let script = "Get-CimInstance Win32_VideoController | Select-Object Name,Status,ConfigManagerErrorCode,AdapterRAM,VideoProcessor,PNPDeviceID | ConvertTo-Json -Compress";
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
let start = out.find(|c| c == '[' || c == '{')?;
Some(parse_adapters(&out[start..]))
@ -334,20 +353,236 @@ pub fn detect(bins: &Bins) -> Detection {
apply_defaults(&mut c);
mark_sweep_support(&mut c);
d.cards.push(c);
assign_keys(&mut d.cards);
d
}
#[cfg(not(target_os = "macos"))]
pub fn detect(bins: &Bins) -> Detection {
/// AMD gfx codes the OpenCL runtime reports as the device name, with the card names Windows uses, the words for
/// the row when no adapter matches, and the PCI device ids (approximate, from AMD's public ROCm and Linux driver
/// tables; add a line when a card is seen). gfx1036 is the Ryzen desktop iGPU (PC 1: DEV_13C0, 5 October 2026).
const GFX: &[(&str, &str, &[&str], &[u16])] = &[
("gfx1201", "Radeon RX 9070 XT / 9070", &["Radeon RX 9070 XT", "Radeon RX 9070"], &[0x7550]),
("gfx1200", "Radeon RX 9060 XT", &["Radeon RX 9060 XT", "Radeon RX 9060"], &[0x7590]),
("gfx1100", "Radeon RX 7900 XTX / XT", &["Radeon RX 7900 XTX", "Radeon RX 7900 XT", "Radeon RX 7900 GRE"], &[0x744C]),
("gfx1101", "Radeon RX 7800 XT / 7700 XT", &["Radeon RX 7800 XT", "Radeon RX 7700 XT"], &[0x747E]),
("gfx1102", "Radeon RX 7600", &["Radeon RX 7600 XT", "Radeon RX 7600"], &[0x7480]),
("gfx1030", "Radeon RX 6800 / 6900", &["Radeon RX 6900 XT", "Radeon RX 6950 XT", "Radeon RX 6800 XT", "Radeon RX 6800"], &[0x73BF]),
("gfx1031", "Radeon RX 6700 XT", &["Radeon RX 6750 XT", "Radeon RX 6700 XT", "Radeon RX 6700"], &[0x73DF]),
("gfx1032", "Radeon RX 6600", &["Radeon RX 6650 XT", "Radeon RX 6600 XT", "Radeon RX 6600"], &[0x73FF]),
("gfx1036", "Ryzen integrated Radeon Graphics", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x164E, 0x13C0]),
("gfx1035", "Radeon 680M (integrated)", &["Radeon 680M", "Radeon 660M"], &[0x1681]),
("gfx1103", "Radeon 780M (integrated)", &["Radeon 780M", "Radeon 760M"], &[0x15BF, 0x15C8]),
("gfx1150", "Radeon 890M (integrated)", &["Radeon 890M", "Radeon 880M"], &[0x150E]),
("gfx90c", "Radeon Graphics (Renoir / Cezanne, integrated)", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x1636, 0x1638]),
];
fn gfx_entry(code: &str) -> Option<&'static (&'static str, &'static str, &'static [&'static str], &'static [u16])> {
let c = code.trim().to_ascii_lowercase();
let c = c.split(|ch: char| ch == ':' || ch == ' ').next().unwrap_or("");
GFX.iter().find(|e| e.0 == c)
}
/// The name on the row for a device the tool knows by `code`: Windows' adapter name when one matches (by PCI bus,
/// else by the gfx code's device ids, else by the card names in the table), else the table's words, else the code.
/// `used` holds the adapters already given to another card, so two gfx1036 entries never share one.
pub fn resolve_name(code: &str, vendor: &str, bus: &str, adapters: &[Adapter], used: &mut Vec<usize>) -> (String, Option<usize>) {
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<usize> = (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<usize> = (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<usize> = (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<ClDevice>, 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::<u64>().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<u64> {
s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).map(|p| p.parse::<u64>().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<ClDevice>) -> (Vec<ClDevice>, Vec<String>) {
let gpus: Vec<ClDevice> = devs.into_iter().filter(|d| d.is_gpu).collect();
let mut kept: Vec<ClDevice> = 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<String> = 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<String> = Vec::new();
let mut seen_codes: std::collections::HashMap<String, usize> = 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::<u64>().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<String, usize> = 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<String>,
pub nvidia_limits: std::collections::HashMap<String, (f64, f64, f64, f64)>,
pub cuda_worker: bool,
/// the OpenCL worker's --list; None = no worker installed or it did not answer
pub opencl: Option<String>,
pub opencl_installed: bool,
/// Windows' adapter list; None = PowerShell did not answer
pub adapters: Option<Vec<Adapter>>,
}
pub fn assemble(inp: Inputs) -> Detection {
let mut d = Detection::default();
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
// processor for the integrated call, names for the last resort
let adapters = adapters();
d.adapters_listed = adapters.is_some();
let adapters = adapters.unwrap_or_default();
// NVIDIA: nvidia-smi ships with the driver
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
match smi {
d.adapters_listed = inp.adapters.is_some();
let adapters = inp.adapters.unwrap_or_default();
let mut used: Vec<usize> = Vec::new();
match inp.nvidia {
Some(out) => {
d.nvidia_listed = true;
for line in out.lines() {
@ -355,13 +590,16 @@ pub fn detect(bins: &Bins) -> Detection {
if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::<f64>().ok()).map(|v| v as u64).unwrap_or(0);
let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() };
let worker_ok = bins.cuda.is_some();
let mut c = card(d.cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
c.bus = parts.get(3).map(|b| b.to_string()).unwrap_or_default();
c.kind = classify_kind(parts[1], adapter_for(parts[1], &adapters)).into();
// nvidia-smi prints 00000000:01:00.0; the worker and Windows say 01:00.0
let bus = parts.get(3).map(|b| b.trim().to_ascii_lowercase()).map(|b| b.rsplit_once(':').map(|(d, r)| format!("{}:{r}", d.rsplit(':').next().unwrap_or(d))).unwrap_or(b)).unwrap_or_default();
let (name, adapter) = resolve_name(parts[1], "nvidia", &bus, &adapters, &mut used);
let mut c = card(d.cards.len(), &name, "nvidia", "CUDA", &detail, parts[0]);
c.code = parts[1].to_string();
c.bus = bus;
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
c.vram_mb = mem_mb;
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
if !worker_ok {
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
if !inp.cuda_worker {
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
}
apply_defaults(&mut c);
@ -371,9 +609,8 @@ pub fn detect(bins: &Bins) -> Detection {
if d.cards.is_empty() {
d.notes.push("nvidia-smi ran but listed no card".into());
}
let limits = nvidia_power_limits();
for c in d.cards.iter_mut() {
if let Some((dflt, cur, lo, hi)) = limits.get(&c.device) {
if let Some((dflt, cur, lo, hi)) = inp.nvidia_limits.get(&c.device) {
c.power_default_w = *dflt;
c.power_limit_w = *cur;
c.power_before_w = *cur;
@ -385,68 +622,71 @@ pub fn detect(bins: &Bins) -> Detection {
}
None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
}
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
if let Some(cl) = bins.opencl.as_ref() {
if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) {
let lines: Vec<&str> = out.lines().collect();
d.opencl_listed = lines.iter().any(|l| l.starts_with("OpenCL devices"));
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
if !t.starts_with('[') {
continue;
}
let Some(close) = t.find(']') else { continue };
let idx = &t[1..close];
let rest = &t[close + 1..];
let name = rest.split(" |").next().unwrap_or("").trim();
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
let is_gpu = info.starts_with("GPU");
let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim();
if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") {
continue;
}
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") || name.starts_with("gfx") { "amd" } else { "other" };
let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string();
let mut c = card(d.cards.len(), name, vendor, "OpenCL", &units, idx);
c.device = idx.to_string();
c.kind = classify_kind(name, adapter_for(name, &adapters)).into();
c.path = "prebuilt".into();
apply_defaults(&mut c);
mark_sweep_support(&mut c);
d.cards.push(c);
}
if let Some(out) = inp.opencl {
let (devs, listed) = parse_opencl_list(&out);
d.opencl_listed = listed;
let (kept, dropped) = dedupe_platforms(devs.into_iter().filter(|dv| dv.vendor_word() != "nvidia").collect());
d.dropped = dropped;
for dv in kept {
let vendor = dv.vendor_word();
let (name, adapter) = resolve_name(&dv.name, vendor, &dv.bus, &adapters, &mut used);
let mut c = card(d.cards.len(), &name, vendor, "OpenCL", &dv.units, &dv.index);
c.code = dv.name.clone();
c.bus = dv.bus.clone();
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
c.path = "prebuilt".into();
apply_defaults(&mut c);
mark_sweep_support(&mut c);
d.cards.push(c);
}
} else if d.cards.is_empty() {
} else if !inp.opencl_installed && d.cards.is_empty() {
d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
}
if d.cards.is_empty() {
// last resort: the names Windows knows, so the screen can at least say what is in the PC
for a in adapters.iter().filter(|a| a.problem().is_none()) {
for (i, a) in adapters.iter().enumerate().filter(|(_, a)| a.problem().is_none()) {
let vendor = vendor_of(&a.name);
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.bus.clone();
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
c.enabled = false;
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
used.push(i);
d.cards.push(c);
}
}
// the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven
for a in adapters.iter() {
for (i, a) in adapters.iter().enumerate() {
let Some(problem) = a.problem() else { continue };
if d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
if used.contains(&i) || d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
continue;
}
let vendor = vendor_of(&a.name);
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
c.bus = a.pnp_id.clone();
c.bus = a.bus.clone();
c.kind = classify_kind(&a.name, Some(a)).into();
c.vram_mb = a.ram_mb;
mark_unusable(&mut c, &problem);
d.cards.push(c);
}
assign_keys(&mut d.cards);
d
}
#[cfg(not(target_os = "macos"))]
pub fn detect(bins: &Bins) -> Detection {
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
// processor for the integrated call, the PCI address and the names for the rows
let adapters = adapters();
// NVIDIA: nvidia-smi ships with the driver
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
assemble(Inputs { nvidia, nvidia_limits, cuda_worker: bins.cuda.is_some(), opencl, opencl_installed: bins.opencl.is_some(), adapters })
}
/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it.
pub fn mark_unusable(c: &mut CardState, problem: &str) {
c.problem = problem.to_string();
@ -571,6 +811,151 @@ mod tests {
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
}
// PC 1 after Adrenalin 26.9.2 and a reboot (5 October 2026, evening): all three adapters OK, the Windows names,
// DEV ids and PCI addresses as the coordinator read them (the 5090's bus 01:00.0; the two AMD cards' addresses are
// not in that reading, so this fixture leaves them empty, as an old worker's --list would)
fn pc1_rebooted() -> Vec<Adapter> {
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<_>>(), 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<_>>(), 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<_>>(), 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", "", "");

View file

@ -957,12 +957,15 @@ impl Engine {
/// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes.
fn first_detection(&mut self, d: crate::detect::Detection) {
self.detected = true;
let crate::detect::Detection { cards, notes, .. } = d;
let names: Vec<String> = cards.iter().map(|c| format!("{} ({}{})", c.name, c.worker, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect();
let crate::detect::Detection { cards, notes, dropped, .. } = d;
let names: Vec<String> = 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 &notes {
self.shared.log(&format!("detection: {n}"));
}
for n in &dropped {
self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}"));
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut st = self.st();
st.detecting = false;
@ -1004,6 +1007,9 @@ impl Engine {
if diff.is_quiet() {
return;
}
for n in &d.dropped {
self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}"));
}
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let mut new_nvidia = false;
for i in diff.removed.iter().copied() {
@ -1029,6 +1035,8 @@ impl Engine {
c.device = fresh.device.clone();
c.key = fresh.key.clone();
c.bus = fresh.bus.clone();
c.name = fresh.name.clone();
c.platform = fresh.platform.clone();
if fresh.vram_mb > 0 {
c.vram_mb = fresh.vram_mb;
}

View file

@ -3,8 +3,9 @@
//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went
//! into PC 1 through an eGPU box while the app ran and nothing noticed.
//!
//! Rules: a card is the same card when its key (vendor:device:name) matches, or, failing that, when vendor and
//! name match and that pair is unique on both sides (a device index that moved because another card arrived). A
//! Rules: a card is the same card when its key (vendor:code, "#2" for a twin) matches and the PCI addresses do not
//! disagree, or, failing that, when vendor and name match and that pair is unique on both sides (a twin whose
//! ordinal moved because the first one left). The device index is never part of the identity: it moves. A
//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards
//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a
//! removed row after five minutes.
@ -43,8 +44,12 @@ impl Diff {
}
}
fn bus_compat(a: &CardState, b: &CardState) -> bool {
a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus
}
fn same_identity(a: &CardState, b: &CardState) -> bool {
a.vendor == b.vendor && a.name.trim().eq_ignore_ascii_case(b.name.trim()) && (a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus)
a.vendor == b.vendor && (a.name.trim().eq_ignore_ascii_case(b.name.trim()) || (!a.code.is_empty() && a.code.eq_ignore_ascii_case(&b.code))) && bus_compat(a, b)
}
/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could
@ -55,7 +60,7 @@ pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState)
let mut pair: Vec<Option<usize>> = vec![None; old.len()];
// exact keys first
for (i, o) in old.iter().enumerate() {
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key) {
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key && bus_compat(o, f)) {
used[j] = true;
pair[i] = Some(j);
}
@ -115,15 +120,26 @@ pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState)
out
}
/// The saved choice for a card: by its key, else by vendor and name (the index moved since the choice was saved).
/// The saved choice for a card: by its key (vendor:code), else a key saved by an app before 0.3.11 (vendor:index:code,
/// vendor:index:name) when exactly one matches; an index that moved never changes the answer.
pub fn pref_for<'a>(prefs: &'a std::collections::HashMap<String, CardPref>, c: &CardState) -> Option<&'a CardPref> {
if let Some(p) = prefs.get(&c.key) {
return Some(p);
}
if c.key.contains('#') {
return None; // a twin's choice is its own
}
let head = format!("{}:", c.vendor);
let tail = format!(":{}", c.name);
let mut found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail)).map(|(_, p)| p).collect();
if found.len() == 1 { found.pop() } else { None }
for tail in [format!(":{}", c.code), format!(":{}", c.name)] {
if tail.len() <= 1 {
continue;
}
let found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail) && !k.contains('#')).map(|(_, p)| p).collect();
if found.len() == 1 {
return Some(found[0]);
}
}
None
}
/// Applies a saved choice to a freshly detected card (the first detection and every later one use this).
@ -227,7 +243,7 @@ mod tests {
use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON};
fn card(name: &str, vendor: &str, device: &str) -> CardState {
let mut c = CardState { index: 0, key: format!("{vendor}:{device}:{name}"), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
let mut c = CardState { index: 0, key: format!("{vendor}:{name}"), code: name.into(), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
c.kind = classify_kind(name, None).into();
crate::detect::apply_defaults(&mut c);
c
@ -312,7 +328,6 @@ mod tests {
let old = pc1_start();
let mut fresh = pc1_start();
fresh[1].device = "1".into();
fresh[1].key = "amd:1:gfx1036".into();
let mut e = card("gfx1201", "amd", "0");
e.index = 2;
fresh.push(e);
@ -329,26 +344,27 @@ mod tests {
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1");
b.index = 1;
a.bus = "00000000:01:00.0".into();
b.bus = "00000000:02:00.0".into();
b.key = "nvidia:NVIDIA GeForce RTX 5090#2".into();
a.bus = "01:00.0".into();
b.bus = "02:00.0".into();
let old = vec![a.clone(), b.clone()];
// one of the twins leaves: the other keeps its slot by key; the missing one is removed, not "moved"
// the second twin leaves: the first keeps its slot by key; the missing one is removed, not "moved"
let d = diff(&old, &[a.clone()], &all_listed);
assert_eq!(d.unchanged, vec![0]);
assert_eq!(d.removed, vec![1]);
// both twins present, both unmatched by key (indices swapped): the vendor+name rule refuses to guess
let mut a2 = a.clone();
a2.device = "1".into();
a2.key = "nvidia:1:NVIDIA GeForce RTX 5090".into();
a2.bus = "00000000:03:00.0".into();
let mut b2 = b.clone();
b2.device = "0".into();
b2.key = "nvidia:0:NVIDIA GeForce RTX 5090".into();
b2.bus = "00000000:04:00.0".into();
let d2 = diff(&old, &[a2, b2], &all_listed);
// the keys are the identity for twins: slot 0 is nvidia index 0 whichever bus it sits on; nothing restarts
assert!(d2.is_quiet(), "{d2:?}");
assert_eq!(d2.unchanged, vec![0, 1]);
// the FIRST twin leaves: the survivor is now index 0 with the unsuffixed key, but its bus says which card it
// is, so slot 1 is "moved" (new key and device) and slot 0 is removed; no worker is swapped between cards
let mut survivor = b.clone();
survivor.device = "0".into();
survivor.key = "nvidia:NVIDIA GeForce RTX 5090".into();
let d2 = diff(&old, &[survivor], &all_listed);
assert_eq!(d2.removed, vec![0]);
assert_eq!(d2.moved.len(), 1);
assert_eq!(d2.moved[0].0, 1);
assert_eq!(d2.moved[0].1.key, "nvidia:NVIDIA GeForce RTX 5090");
// the same two cards again, nothing changed: quiet
let d3 = diff(&old, &old, &all_listed);
assert!(d3.is_quiet(), "{d3:?}");
}
#[test]
@ -366,7 +382,6 @@ mod tests {
let mut faulty = old.clone();
mark_unusable(&mut faulty[2], "Code 43");
faulty[2].device = String::new();
faulty[2].key = "amd::gfx1201".into();
let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed);
assert_eq!(d2.recovered.len(), 1);
assert_eq!(d2.recovered[0].0, 2);
@ -406,7 +421,7 @@ mod tests {
let mut prefs = std::collections::HashMap::new();
// the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() });
// the iGPU comes back as device 1 (the eGPU took index 0): the choice is found by vendor and name
// the iGPU comes back as device 1 (the eGPU took index 0): the 0.3.9 key still answers, by vendor and code
let mut igpu = card("gfx1036", "amd", "1");
assert_eq!(igpu.kind, "integrated");
assert!(!igpu.enabled);
@ -418,8 +433,8 @@ mod tests {
assert_eq!(igpu.identities, 2);
assert_eq!(igpu.reason, "");
assert_eq!(igpu.state, "waiting");
// the user switched it off: the row keeps the integrated words
prefs.insert("amd:1:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
// the user switched it off (saved under the index-free key): the row keeps the integrated words
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
let mut igpu2 = card("gfx1036", "amd", "1");
let p2 = pref_for(&prefs, &igpu2).cloned();
settle_new(&mut igpu2, 1, p2.as_ref(), 6.0);
@ -440,10 +455,25 @@ mod tests {
settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0);
assert!(!bad.enabled);
assert_eq!(bad.state, "unusable");
// two saved choices with the same vendor and name but different indices: ambiguous, so none is used
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
// old keys only, two of them for the same code (the five-row PC 1 list had amd:0:gfx1036 and amd:2:gfx1036):
// ambiguous, so the default applies; the index-free key, once saved, always wins
prefs.remove("amd:gfx1036");
prefs.insert("amd:2:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
let other = card("gfx1036", "amd", "7");
assert!(pref_for(&prefs, &other).is_none());
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: true, identities: 4, ..Default::default() });
assert_eq!(pref_for(&prefs, &other).map(|p| p.identities), Some(4));
// a twin never borrows the first card's choice
let mut twin = card("gfx1201", "amd", "3");
twin.key = "amd:gfx1201#2".into();
prefs.insert("amd:gfx1201".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
assert!(pref_for(&prefs, &twin).is_none());
// the name on the row is the Windows name while the key keeps the code: the 0.3.9 key by code still answers
let mut named = card("gfx1201", "amd", "1");
named.name = "AMD Radeon RX 9070 XT".into();
prefs.clear();
prefs.insert("amd:1:gfx1201".to_string(), CardPref { enabled: false, identities: 2, ..Default::default() });
assert_eq!(pref_for(&prefs, &named).map(|p| p.identities), Some(2));
}
#[test]

View file

@ -29,7 +29,9 @@ pub struct NodeState {
#[derive(Clone, Serialize, Default, Debug)]
pub struct CardState {
pub index: usize,
pub key: String, // stable id for the saved preference: vendor:device:name
pub key: String, // stable id for the saved preference: vendor:code, "#2" and up for a second identical card (no index: it moves)
pub code: String, // the tool's own device name (AMD's OpenCL runtime says "gfx1201"); the key and the tooltip carry it
pub platform: String, // the OpenCL platform and driver the worker opens it through (the tooltip)
pub kind: String, // apple | discrete | integrated | external | unknown
pub vram_mb: u64, // 0 when unknown
pub reason: String, // why it is off by default, if it is

View file

@ -706,6 +706,8 @@ if (typeof document !== 'undefined') (function () {
var kindWord = Notices.kindWord;
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); }
// the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address
function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); }
function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); }
function renderCardRows(container, cards) {
container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); });
@ -716,7 +718,7 @@ if (typeof document !== 'undefined') (function () {
d.dataset.key = cd.key;
var meta = '<span>worker <b>' + esc(cd.worker) + '</b></span>' + (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : '');
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
'<div class="info"><div class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
(cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '<div class="power"><span class="k">power cap</span><input type="range" min="50" max="100" step="5" value="' + (cd.power_pct || 80) + '"><span class="pv mono">' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W</span>' + (cd.pinned ? '<span class="pin" title="set by hand; the sweep records but does not change it">pinned</span>' : cd.sweep_pct ? '<span class="pin" title="chosen by the efficiency sweep">sweep ' + cd.sweep_pct + '%</span>' : '') + '</div>' : '') +
'<div class="ids"><span class="k">identities</span><button type="button" data-d="-1" aria-label="fewer">&minus;</button><input type="number" min="1" max="64" value="' + cd.identities + '"><button type="button" data-d="1" aria-label="more">+</button></div>' +
@ -861,7 +863,7 @@ if (typeof document !== 'undefined') (function () {
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : cd.state === 'removed' ? 'removed' : '';
var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state;
var extra = cd.ids.length ? '<span>id <b>' + esc(cd.ids.join(' ')) + '</b></span>' : '';
return '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
return '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
'<div class="h">' + cd.hash_now.toFixed(1) + '<span class="unit">MH/s</span></div>' +
'<div class="m"><span>blocks <b>' + cd.accepted + '</b>' + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + ' rejected</span>' : '') + '</span><span>avg <b>' + cd.hash_avg.toFixed(1) + '</b></span><span>identities <b>' + cd.identities + '</b></span>' + extra + (cd.faults ? '<span class="dim">worker faults <b>' + cd.faults + '</b></span>' : '') + (cd.mismatched ? '<span class="dim">re-check misses <b>' + cd.mismatched + '</b></span>' : '') + '</div>' +
telemetryHtml(cd) +

View file

@ -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);