293 lines
14 KiB
Rust
293 lines
14 KiB
Rust
//! 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.
|
|
|
|
use crate::state::CardState;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[derive(Clone)]
|
|
pub struct Bins {
|
|
pub node: std::path::PathBuf,
|
|
pub miner: std::path::PathBuf,
|
|
pub metal: Option<std::path::PathBuf>,
|
|
pub cuda: Option<std::path::PathBuf>,
|
|
pub opencl: Option<std::path::PathBuf>,
|
|
pub dir: std::path::PathBuf,
|
|
}
|
|
|
|
/// 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<String> {
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
cmd.stdin(if stdin_text.is_some() { Stdio::piped() } else { Stdio::null() });
|
|
crate::platform::quiet(cmd);
|
|
let mut child = cmd.spawn().ok()?;
|
|
if let (Some(text), Some(mut stdin)) = (stdin_text, child.stdin.take()) {
|
|
let _ = stdin.write_all(text.as_bytes());
|
|
drop(stdin);
|
|
}
|
|
let out = child.stdout.take()?;
|
|
let err = child.stderr.take()?;
|
|
let reader = std::thread::spawn(move || {
|
|
let mut s = String::new();
|
|
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(out), &mut s);
|
|
let mut e = String::new();
|
|
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(err), &mut e);
|
|
(s, e)
|
|
});
|
|
let deadline = Instant::now() + limit;
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => break,
|
|
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
|
_ => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let (s, e) = reader.join().ok()?;
|
|
Some(if e.is_empty() { s } else { format!("{s}\n{e}") })
|
|
}
|
|
|
|
fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState {
|
|
CardState {
|
|
index,
|
|
key: format!("{vendor}:{device}:{name}"),
|
|
name: name.to_string(),
|
|
vendor: vendor.into(),
|
|
worker: worker.into(),
|
|
detail: detail.into(),
|
|
device: device.into(),
|
|
enabled: true,
|
|
state: "off".into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete).
|
|
#[allow(dead_code)]
|
|
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 ")
|
|
}
|
|
|
|
/// 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) {
|
|
match c.kind.as_str() {
|
|
"apple" => {
|
|
c.enabled = true;
|
|
c.identities = 1;
|
|
}
|
|
"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.enabled = true;
|
|
c.identities = if c.vram_mb >= 8192 { 8 } else { 2 };
|
|
}
|
|
}
|
|
if c.vendor == "nvidia" && c.power_pct == 0 {
|
|
c.power_pct = 80; // the default cap: 80% of the card's default limit (PC 2's 5090 hard-crashed at full power)
|
|
}
|
|
}
|
|
|
|
/// NVIDIA power limits per card index: (default, current, min, max) in watts.
|
|
#[cfg(target_os = "macos")]
|
|
pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64, f64)> {
|
|
std::collections::HashMap::new()
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64, f64)> {
|
|
let mut out = std::collections::HashMap::new();
|
|
let Some(text) = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,power.default_limit,power.limit,power.min_limit,power.max_limit", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)) else { return out };
|
|
for line in text.lines() {
|
|
let p: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
|
if p.len() >= 5 {
|
|
let f = |s: &str| s.parse::<f64>().unwrap_or(0.0);
|
|
out.insert(p[0].to_string(), (f(p[1]), f(p[2]), f(p[3]), f(p[4])));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|
let mut cards = Vec::new();
|
|
let Some(metal) = bins.metal.as_ref() else {
|
|
notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
|
return cards;
|
|
};
|
|
// 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;
|
|
};
|
|
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");
|
|
// cores and memory from system_profiler (a second or two); optional
|
|
let mut detail = String::new();
|
|
if let Some(sp) = run_timeout(Command::new(crate::platform::tool("system_profiler")).args(["SPDisplaysDataType", "-json"]), None, Duration::from_secs(8)) {
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&sp) {
|
|
if let Some(g) = v.get("SPDisplaysDataType").and_then(|a| a.as_array()).and_then(|a| a.first()) {
|
|
if let Some(c) = g.get("sppci_cores").and_then(|c| c.as_str()) {
|
|
detail = format!("{c} GPU cores");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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::<u64>() {
|
|
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());
|
|
}
|
|
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::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
|
|
}
|
|
apply_defaults(&mut c);
|
|
cards.push(c);
|
|
cards
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|
let mut cards: Vec<CardState> = 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 {
|
|
Some(out) => {
|
|
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::<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(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
|
|
c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() };
|
|
c.vram_mb = mem_mb;
|
|
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
|
|
if !worker_ok {
|
|
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
|
|
}
|
|
apply_defaults(&mut c);
|
|
cards.push(c);
|
|
}
|
|
}
|
|
if cards.is_empty() {
|
|
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;
|
|
c.power_limit_w = *cur;
|
|
c.power_before_w = *cur;
|
|
c.power_min_w = *lo;
|
|
c.power_max_w = *hi;
|
|
}
|
|
}
|
|
}
|
|
None => 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);
|
|
cards.push(c);
|
|
}
|
|
}
|
|
} else if cards.is_empty() {
|
|
notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
|
}
|
|
if 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);
|
|
}
|
|
}
|
|
}
|
|
cards
|
|
}
|
|
|
|
/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle).
|
|
pub fn find_bins() -> Result<Bins, String> {
|
|
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
|
let here = exe.parent().ok_or("no exe dir")?.to_path_buf();
|
|
let mut candidates = vec![];
|
|
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
|
|
candidates.push(std::path::PathBuf::from(d));
|
|
}
|
|
candidates.push(here.clone());
|
|
candidates.push(here.join("bin"));
|
|
if let Some(c) = here.parent() {
|
|
candidates.push(c.join("Resources").join("bin"));
|
|
}
|
|
let ext = if cfg!(windows) { ".exe" } else { "" };
|
|
for dir in &candidates {
|
|
let node = dir.join(format!("igneumd{ext}"));
|
|
let miner = dir.join(format!("igneum-miner{ext}"));
|
|
if node.exists() && miner.exists() {
|
|
let opt = |n: &str| {
|
|
let p = dir.join(format!("{n}{ext}"));
|
|
if p.exists() { Some(p) } else { None }
|
|
};
|
|
// the prebuilt CUDA worker needs NVIDIA's nvrtc64_*_0.dll next to it (as igneum-common.ps1 checks)
|
|
let nvrtc = std::fs::read_dir(dir).ok().map(|rd| rd.flatten().any(|e| { let n = e.file_name().to_string_lossy().to_ascii_lowercase(); n.starts_with("nvrtc64_") && n.ends_with("_0.dll") })).unwrap_or(false);
|
|
let cuda = opt("igneum-worker-cuda").filter(|_| nvrtc || cfg!(not(windows)));
|
|
return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda, opencl: opt("igneum-worker-opencl"), dir: dir.clone() });
|
|
}
|
|
}
|
|
Err(format!("igneumd and igneum-miner were not found next to the app (looked in {})", candidates.iter().map(|c| c.display().to_string()).collect::<Vec<_>>().join(", ")))
|
|
}
|
|
|
|
pub fn node_version(node: &Path) -> String {
|
|
run_timeout(Command::new(node).arg("--version"), None, Duration::from_secs(10))
|
|
.and_then(|o| o.lines().next().map(|l| l.trim().to_string()))
|
|
.unwrap_or_default()
|
|
}
|