Merge remote-tracking branch 'origin/miner-eff' into release-0.3.5

This commit is contained in:
igneum-labs 2026-10-04 21:25:44 +00:00
commit 68f3221530
12 changed files with 1524 additions and 22 deletions

View file

@ -12,9 +12,23 @@ use std::path::{Path, PathBuf};
pub struct CardPref {
pub enabled: bool,
pub identities: u32,
/// NVIDIA power cap, percent of the card's default limit (60 to 100); 0 = the default 80
/// NVIDIA power cap, percent of the card's default limit (50 to 100); 0 = the default 80
#[serde(default)]
pub power_pct: u32,
/// the user moved the slider: this cap stays; the efficiency sweep (src/sweep.rs) records but does not change it
#[serde(default)]
pub pinned: bool,
/// the last efficiency sweep: when (unix s), the cap it chose, and that step's numbers
#[serde(default)]
pub sweep_at: u64,
#[serde(default)]
pub sweep_pct: u32,
#[serde(default)]
pub sweep_eff: f64,
#[serde(default)]
pub sweep_watts: f64,
#[serde(default)]
pub sweep_mhs: f64,
}
#[derive(Clone, Serialize, Deserialize)]
@ -55,6 +69,13 @@ pub struct Settings {
/// Prove the shards assigned to this machine's keys (src/prover.rs). Default off until the GPU numbers exist.
#[serde(default)]
pub prove: bool,
/// The efficiency sweep (src/sweep.rs): once after install, then weekly, each NVIDIA card's cap is stepped from
/// 100% to 50% on the live program and held at the best MH per watt. Default on. A pinned card is skipped.
#[serde(default = "yes")]
pub sweep: bool,
/// When this install first ran (unix s), for the "first hour after install" sweep.
#[serde(default)]
pub installed_at: u64,
}
fn one() -> u32 {
@ -66,13 +87,19 @@ fn yes() -> bool {
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, installed_at: 0 }
}
}
impl Settings {
pub fn load(path: &Path) -> Settings {
std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
let mut s: Settings = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
if s.installed_at == 0 {
// an install from before the sweep existed counts as installed now: it gets its first-hour sweep
s.installed_at = crate::platform::unix_now();
s.save(path);
}
s
}
pub fn save(&self, path: &Path) {
if let Some(d) = path.parent() {
@ -148,6 +175,9 @@ pub struct Runtime {
pub app_dir: PathBuf,
pub log_dir: PathBuf,
pub status_secs: u32,
/// `--sweep`: run the efficiency sweep on every supported card as soon as it mines, print the table on stdout,
/// leave the chosen caps in force and quit (the PC measurement job; docs/plans/miner-eff.md).
pub sweep_only: bool,
/// The machine's hostname: a friendly label only, never part of a key or a payout address.
pub host: String,
/// Per-install random id (16 hex, app data dir/machine-id, locked to the user). Identity labels, vote keys and
@ -181,7 +211,8 @@ impl Runtime {
let log_dir = crate::platform::log_root();
let status_secs = env("IGNEUM_APP_STATUS_SECS").and_then(|v| v.parse().ok()).unwrap_or(30);
let machine_id = machine_id(&app_dir);
Runtime { network, rpc_port, p2p_port, peers, unsynced_mining, devnet_suffix, node_dir, app_dir, log_dir, status_secs, host: crate::platform::host_label(), machine_id }
let sweep_only = env("IGNEUM_APP_SWEEP").map(|v| v == "1").unwrap_or(false);
Runtime { network, rpc_port, p2p_port, peers, unsynced_mining, devnet_suffix, node_dir, app_dir, log_dir, status_secs, sweep_only, host: crate::platform::host_label(), machine_id }
}
/// The first 8 hex of the machine id: what goes into labels.
pub fn id8(&self) -> String {

View file

@ -99,6 +99,34 @@ pub fn apply_defaults(c: &mut CardState) {
}
}
/// Marks whether the efficiency sweep (src/sweep.rs) can run on a card, with the reason when it cannot. Called
/// once the power limits are known.
pub fn mark_sweep_support(c: &mut CardState) {
match crate::sweep::unsupported_reason(&c.vendor, c.power_default_w, &c.device) {
None => {
c.sweep_supported = true;
if c.sweep_state.is_empty() || c.sweep_state == "unsupported" {
c.sweep_state = "idle".into();
c.sweep_note = String::new();
}
}
Some(why) => {
c.sweep_supported = false;
c.sweep_state = "unsupported".into();
c.sweep_note = why.into();
}
}
}
/// `nvidia-smi --query-gpu=index,power.draw`: index -> watts now (the one-shot form; the telemetry child streams it).
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
pub fn nvidia_power_draw() -> std::collections::HashMap<String, f64> {
run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,power.draw", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10))
.map(|t| crate::sweep::parse_power_draw(&t))
.unwrap_or_default()
}
/// 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)> {
@ -163,6 +191,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
c.vram_mb = mem.trim().parse::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
}
apply_defaults(&mut c);
mark_sweep_support(&mut c);
cards.push(c);
cards
}
@ -203,6 +232,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
c.power_min_w = *lo;
c.power_max_w = *hi;
}
mark_sweep_support(c);
}
}
None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
@ -233,6 +263,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
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);
}
}

View file

@ -58,6 +58,18 @@ pub enum Cmd {
ElevatedDone(Result<(), String>),
/// the user asked for the cap again (the Retry button)
ApplyPower,
/// the efficiency sweep (src/sweep.rs): start on one card (by key), stop, pin or unpin a card's cap, the toggle
SweepStart(String),
SweepStop,
SweepPin(String, bool),
SweepEnable(bool),
/// the probe that decides how caps are set during a sweep: Ok(true) = nvidia-smi -pl works from this process
/// (the engine runs elevated), Ok(false) = the elevated helper is needed; Err = nvidia-smi did not answer
SweepCapMode(Result<bool, String>),
/// the elevated helper process ended (Ok at `quit`, Err when the prompt was cancelled or it failed)
SweepHelperDone(Result<(), String>),
/// a direct `nvidia-smi -pl` for the sweep finished: what it printed
SweepCapSet(String),
Quit,
}
@ -97,7 +109,7 @@ impl Shared {
st.mining.paused = settings.paused;
st.mining.accepted_total = settings.accepted_total;
st.address = address_state(&settings, &wallet_path);
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove };
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep };
st.live_page = packaged.live_page.clone();
st.finality.message = "waiting for the miner".into();
st.clock.hint = crate::platform::clock_hint().into();
@ -416,6 +428,21 @@ pub struct Engine {
last_stability: Instant,
last_settings_save: Instant,
last_error_event: Instant,
// the efficiency sweep (src/sweep.rs): one card at a time
sweep: Option<crate::sweep::Run>,
/// a sweep waiting for the cap-mode probe: (card index, forced)
sweep_pending: Option<(usize, bool)>,
/// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet
sweep_direct: Option<bool>,
/// the elevated helper is running (its thread reports SweepHelperDone when it ends)
sweep_helper: bool,
sweep_seq: u64,
/// cards asked for by hand (Sweep now) or by --sweep, by key
sweep_queue: Vec<String>,
sweep_next_check: Instant,
/// after an abort, a card waits before the scheduler tries it again
sweep_retry: std::collections::HashMap<usize, Instant>,
sweep_attempts: std::collections::HashMap<usize, u32>,
}
impl Engine {
@ -487,6 +514,15 @@ impl Engine {
last_stability: now,
last_settings_save: now,
last_error_event: now - Duration::from_secs(600),
sweep: None,
sweep_pending: None,
sweep_direct: None,
sweep_helper: false,
sweep_seq: 0,
sweep_queue: Vec::new(),
sweep_next_check: now + Duration::from_secs(20),
sweep_retry: std::collections::HashMap::new(),
sweep_attempts: std::collections::HashMap::new(),
}
}
@ -530,7 +566,10 @@ impl Engine {
}
}
self.shared.send(Cmd::Detect);
if setup_done && !self.quitting {
if self.shared.runtime.sweep_only {
self.shared.log("--sweep: the efficiency sweep runs on every supported card as soon as it mines; the table goes to stdout and this log; the engine quits after it");
}
if (setup_done || self.shared.runtime.sweep_only) && !self.quitting {
self.shared.send(Cmd::Start);
}
loop {
@ -582,11 +621,30 @@ impl Engine {
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(60, 100);
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<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
let unsupported: Vec<String> = 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();
}
@ -768,11 +826,80 @@ impl Engine {
}
}
Cmd::ApplyPower => {
if self.sweep.is_some() || self.sweep_pending.is_some() {
self.shared.event("info", "a sweep is setting the caps right now; retry once it is done");
return;
}
for c in self.st().mining.cards.iter_mut().filter(|c| c.vendor == "nvidia") {
c.power_applied = false; // force the step again
}
self.apply_power_limits("retry");
}
Cmd::SweepStart(key) => {
let (name, ok, why) = {
let st = self.st();
match st.mining.cards.iter().find(|c| c.key == key) {
Some(c) if !c.sweep_supported => (c.name.clone(), false, c.sweep_note.clone()),
Some(c) if !c.enabled => (c.name.clone(), false, "the card is switched off".to_string()),
Some(c) => (c.name.clone(), true, String::new()),
None => (key.clone(), false, "no such card".to_string()),
}
};
if !ok {
self.shared.event("error", &format!("{name}: no sweep: {why}"));
} else if !self.sweep_queue.contains(&key) {
self.sweep_queue.push(key);
self.sweep_next_check = Instant::now();
self.shared.event("info", &format!("{name}: efficiency sweep queued; it starts once the card has mined for {} s with over {} s to the hour boundary", crate::sweep::STABLE_S, crate::sweep::NEEDS_S));
}
}
Cmd::SweepStop => {
self.sweep_queue.clear();
if self.sweep.is_some() || self.sweep_pending.is_some() {
self.sweep_abort("stopped from the dashboard");
}
}
Cmd::SweepPin(key, pinned) => {
let name = {
let mut settings = self.shared.settings.lock().unwrap();
let mut st = self.st();
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == key) else { return };
c.pinned = pinned;
let e = settings.cards.entry(key.clone()).or_insert_with(|| crate::config::CardPref { enabled: c.enabled, identities: c.identities, ..Default::default() });
e.pinned = pinned;
if pinned {
e.power_pct = c.power_pct;
}
c.name.clone()
};
self.shared.save_settings();
self.shared.event("info", &if pinned { format!("{name}: cap pinned; the sweep records but does not change it") } else { format!("{name}: the sweep chooses the cap again from its next run") });
}
Cmd::SweepEnable(on) => {
self.shared.settings.lock().unwrap().sweep = on;
self.shared.save_settings();
self.st().settings.sweep = on;
self.shared.event("info", if on { "efficiency sweep on: once after install, then weekly" } else { "efficiency sweep off; Sweep now on a card still runs one" });
}
Cmd::SweepCapMode(r) => self.sweep_mode_known(r),
Cmd::SweepHelperDone(r) => {
self.sweep_helper = false;
if let Err(e) = &r {
self.shared.log(&format!("sweep helper: {e}"));
if self.sweep.is_some() || self.sweep_pending.is_some() {
self.sweep_abort(&format!("the elevated helper did not run ({e})"));
}
} else if self.sweep.is_some() {
self.sweep_abort("the elevated helper exited early");
} else {
self.shared.log("sweep helper: exited");
}
}
Cmd::SweepCapSet(text) => {
if !text.contains("All done") {
self.shared.log(&format!("sweep: nvidia-smi -pl answered: {}", short(&text.replace('\n', " "), 200)));
}
}
Cmd::Quit => {
self.quitting = true;
self.st().quitting = true;
@ -1117,6 +1244,7 @@ impl Engine {
fn apply_cards(&mut self, choices: Vec<CardChoice>) {
let mut changed = Vec::new();
let mut power_changed = false;
let mut pin_aborts = false;
{
let mut settings = self.shared.settings.lock().unwrap();
let mut st = self.st();
@ -1124,13 +1252,19 @@ impl Engine {
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue };
let identities = ch.identities.clamp(1, 64);
let enabled = ch.enabled && c.kind != "unknown";
let power_pct = ch.power_pct.map(|p| p.clamp(60, 100)).unwrap_or(c.power_pct);
let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct);
if c.vendor == "nvidia" && power_pct != c.power_pct {
c.power_pct = power_pct;
c.power_applied = false;
power_changed = true;
// a cap set by hand is pinned: the sweep records MH per watt but leaves it (unpin on the tile)
c.pinned = true;
if self.sweep.as_ref().map(|r| r.card == c.index).unwrap_or(false) {
pin_aborts = true;
}
}
settings.cards.insert(ch.key.clone(), crate::config::CardPref { enabled, identities, power_pct: if c.vendor == "nvidia" { power_pct } else { 0 } });
let prev = settings.cards.get(&ch.key).cloned().unwrap_or_default();
settings.cards.insert(ch.key.clone(), crate::config::CardPref { enabled, identities, power_pct: if c.vendor == "nvidia" { power_pct } else { 0 }, pinned: c.pinned, ..prev });
if c.enabled != enabled || c.identities != identities {
c.enabled = enabled;
c.identities = identities;
@ -1163,6 +1297,9 @@ impl Engine {
self.shared.event("ok", &format!("{name}: switched on, {identities} identit{}", if identities == 1 { "y" } else { "ies" }));
}
}
if pin_aborts {
self.sweep_abort("the cap was set by hand");
}
if self.running {
self.plan_miners();
if power_changed {
@ -1177,12 +1314,17 @@ impl Engine {
if self.power_busy {
return;
}
if self.sweep.is_some() || self.sweep_pending.is_some() {
// the sweep owns the caps until it ends; it applies the chosen one itself
self.shared.log(&format!("power cap ({why}): deferred, a sweep is running"));
return;
}
let mut cmds = Vec::new();
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) {
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(60, 100) };
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);
if (c.power_limit_w - watts).abs() < 1.0 && c.power_applied {
@ -1300,6 +1442,7 @@ impl Engine {
}
let f = |s: &str| s.parse::<f64>().unwrap_or(0.0);
let (draw, tgpu, tmem, limit) = (f(p[1]), f(p[2]), f(p[3]), f(p[4]));
let sweeping = self.sweep.is_some() || self.sweep_pending.is_some();
let mut st = self.st();
let Some(c) = st.mining.cards.iter_mut().find(|c| c.vendor == "nvidia" && c.device == p[0]) else { return };
c.power_w = draw;
@ -1307,11 +1450,22 @@ impl Engine {
c.temp_mem = tmem;
if limit > 0.0 {
c.power_limit_w = limit;
// the readback is the truth: a cap the elevated step set after the judgement (or one set by hand
// outside the app) that matches what was asked counts as applied
if !sweeping && !c.power_applied && c.enabled && c.power_default_w > 0.0 && (limit - requested_watts(c)).abs() < 1.0 {
c.power_applied = true;
c.power_note = format!("power cap: {} W in force (read back)", limit as u64);
}
}
c.telemetry_at = crate::platform::unix_now_f();
let idx = c.index;
let mining = c.state == "mining";
drop(st);
if let Some(run) = self.sweep.as_mut() {
if run.card == idx {
run.sample_draw(draw);
}
}
if mining {
let s = self.stability.entry(idx).or_default();
s.draws.push(draw);
@ -1349,6 +1503,378 @@ impl Engine {
}
}
// ---- the efficiency sweep (src/sweep.rs) ---------------------------------------------------------------------
/// A SWEEP line: the app log, and stdout under --sweep (the PC job reads it there).
fn sweep_say(&self, line: &str) {
self.shared.log(line);
if self.shared.runtime.sweep_only {
println!("{line}");
let _ = std::io::stdout().flush();
}
}
fn sweep_dir(&self) -> PathBuf {
self.shared.runtime.app_dir.join("sweep")
}
/// Every 10 s: drive the running sweep, else start one that is due. One card at a time. Never while a remote
/// job holds the GPU, never under a pause, never inside the last 10 minutes before the hour boundary.
fn tick_sweep(&mut self, now: Instant) {
if self.sweep.is_some() {
self.sweep_drive(now);
return;
}
if self.sweep_pending.is_some() || now < self.sweep_next_check {
return;
}
self.sweep_next_check = now + Duration::from_secs(10);
if self.quitting || !self.running || self.power_busy || self.job_hold || self.jobs.holds_miners() {
return;
}
let (auto_on, paused) = {
let s = self.shared.settings.lock().unwrap();
let st = self.st();
(s.sweep, st.mining.paused)
};
if paused {
return;
}
let unix = crate::platform::unix_now();
let (eta_known, eta_s) = {
let st = self.st();
(st.node.daa > 0 && st.program.boundary_daa > 0, st.program.eta_s)
};
let boundary_ok = !eta_known || eta_s > crate::sweep::NEEDS_S;
let prefs = self.shared.settings.lock().unwrap().cards.clone();
let cards = self.st().mining.cards.clone();
let mut pick: Option<(usize, bool)> = None;
for c in cards.iter() {
if !(c.enabled && c.sweep_supported && c.vendor == "nvidia") {
continue;
}
let forced = self.sweep_queue.contains(&c.key);
let due = prefs.get(&c.key).map(|p| p.sweep_at == 0 || unix.saturating_sub(p.sweep_at) >= crate::sweep::PERIOD_S).unwrap_or(true);
if !forced && !(auto_on && !c.pinned && due) {
continue;
}
if self.sweep_retry.get(&c.index).map(|t| now < *t).unwrap_or(false) {
continue;
}
let stable = self.miners.iter().find(|m| m.card == c.index).map(|m| m.proc.as_ref().map(|p| p.started.elapsed() >= Duration::from_secs(crate::sweep::STABLE_S)).unwrap_or(false) && m.last_status.is_some()).unwrap_or(false);
let wait = if c.state != "mining" {
Some(format!("sweep: waits for the card to mine ({})", c.state))
} else if !stable {
Some(format!("sweep: waits for {} s of steady mining", crate::sweep::STABLE_S))
} else if !boundary_ok {
Some(format!("sweep: starts after the hour boundary ({eta_s} s)"))
} else {
None
};
match wait {
Some(w) => {
if forced {
if let Some(cc) = self.st().mining.cards.get_mut(c.index) {
cc.sweep_note = w;
}
}
}
None => {
pick = Some((c.index, forced));
break;
}
}
}
if let Some((idx, forced)) = pick {
self.sweep_begin(idx, forced);
} else if self.shared.runtime.sweep_only && self.sweep_queue.is_empty() && self.sweep_pending.is_none() {
self.shared.send(Cmd::Quit);
}
}
/// Step 1 of a sweep: find out how caps can be set from this process (one nvidia-smi -pl at the current limit,
/// a no-op). Elevated engines (the PC job runs with --elevated) set them directly; others need the helper.
fn sweep_begin(&mut self, idx: usize, forced: bool) {
let Some(c) = self.st().mining.cards.get(idx).cloned() else { return };
self.sweep_pending = Some((idx, forced));
self.sweep_queue.retain(|k| k != &c.key);
*self.sweep_attempts.entry(idx).or_default() += 1;
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
cc.sweep_state = "running".into();
cc.sweep_note = "sweep: checking how the cap is set".into();
}
if let Some(direct) = self.sweep_direct {
self.sweep_mode_known(Ok(direct));
return;
}
let smi = crate::platform::tool("nvidia-smi");
let device = c.device.clone();
let current = if c.power_limit_w > 0.0 { c.power_limit_w } else { c.power_default_w }.round() as u64;
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = match crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-pl", &current.to_string()]), None, Duration::from_secs(20)) {
Some(out) => Ok(out.contains("All done")),
None => Err("nvidia-smi did not answer".to_string()),
};
shared.send(Cmd::SweepCapMode(r));
});
}
/// Step 2: start the helper when needed, then the run.
fn sweep_mode_known(&mut self, r: Result<bool, String>) {
let Some((idx, forced)) = self.sweep_pending else { return };
let direct = match r {
Ok(d) => d,
Err(e) => {
self.sweep_abort(&format!("cannot set caps: {e}"));
return;
}
};
self.sweep_direct = Some(direct);
let Some(c) = self.st().mining.cards.get(idx).cloned() else {
self.sweep_pending = None;
return;
};
if !direct && !self.sweep_helper {
if let Err(e) = self.sweep_helper_start(&c) {
self.sweep_abort(&format!("the elevated helper could not start: {e}"));
return;
}
}
let steps = crate::sweep::plan_steps(c.power_default_w, c.power_min_w, c.power_max_w);
if steps.is_empty() {
self.sweep_abort("no steps: the card reported no default power limit");
return;
}
let label = self.miners.iter().find(|m| m.card == idx).map(|m| m.label.clone()).unwrap_or_else(|| format!("card-{idx}"));
let before_w = if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) };
let now = Instant::now();
let run = crate::sweep::Run::new(idx, &c.key, &c.device, &label, steps.clone(), c.power_pct, before_w, forced, crate::sweep::Timing::from_env(), now);
self.sweep_pending = None;
self.sweep_say(&format!(
"SWEEP start card={label} name={} steps={} default={:.0} min={:.0} max={:.0} before={:.0} mode={}",
c.name.replace(' ', "_"),
steps.iter().map(|s| s.pct.to_string()).collect::<Vec<_>>().join(","),
c.power_default_w,
c.power_min_w,
c.power_max_w,
before_w,
if direct { "direct" } else { "helper" }
));
self.shared.event("info", &format!("{}: efficiency sweep started: {} caps from {}% down, {} s each on the live program", c.name, steps.len(), steps[0].pct, (run.timing.settle + run.timing.hold).as_secs()));
self.sweep = Some(run);
self.sweep_drive(now);
}
/// The elevated helper (src/sweep.rs helper_script_*): one administrator prompt; it polls <app>/sweep/cmd.txt.
fn sweep_helper_start(&mut self, c: &CardState) -> Result<(), String> {
let dir = self.sweep_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let _ = std::fs::write(dir.join("cmd.txt"), "");
let restore = (if c.power_limit_w > 0.0 { c.power_limit_w } else { c.power_default_w }).round() as u64;
let smi = crate::platform::tool("nvidia-smi").display().to_string();
let line = if cfg!(windows) {
let script = dir.join("helper.ps1");
std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::sweep::helper_script_windows().replace('\n', "\r\n").as_bytes()].concat()).map_err(|e| e.to_string())?;
format!("\"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\" \"{}\" \"{}\" {} {}", crate::platform::tool("powershell").display(), script.display(), dir.display(), smi, c.device, restore)
} else {
let script = dir.join("helper.sh");
std::fs::write(&script, crate::sweep::helper_script_unix()).map_err(|e| e.to_string())?;
format!("sh \"{}\" \"{}\" \"{}\" {} {}", script.display(), dir.display(), smi, c.device, restore)
};
self.shared.log(&format!("sweep helper (administrator prompt): {line}"));
self.sweep_helper = true;
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::platform::run_elevated(&line);
shared.send(Cmd::SweepHelperDone(r));
});
Ok(())
}
fn sweep_helper_quit(&mut self) {
if self.sweep_helper {
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), "quit\n");
}
}
/// Sets a cap for the sweep: directly (this process is elevated) or through the helper's command file. The
/// telemetry readback (power.limit every 5 s) tells the run when it took.
fn sweep_set_cap(&mut self, device: &str, watts: f64) {
self.sweep_seq += 1;
let w = watts.round() as u64;
if self.sweep_direct == Some(true) {
let smi = crate::platform::tool("nvidia-smi");
let device = device.to_string();
let shared = self.shared.clone();
std::thread::spawn(move || {
let out = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-pl", &w.to_string()]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into());
shared.send(Cmd::SweepCapSet(out));
});
} else {
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), format!("{} {w}\n", self.sweep_seq));
}
self.shared.log(&format!("sweep: cap {w} W requested on device {device}"));
}
/// Faults and conditions first, then the state machine.
fn sweep_drive(&mut self, now: Instant) {
let Some((idx, device, started)) = self.sweep.as_ref().map(|r| (r.card, r.device.clone(), r.started)) else { return };
let card = self.st().mining.cards.get(idx).cloned();
let Some(c) = card else {
self.sweep_abort("the card disappeared");
return;
};
let slot_error = self.miners.iter().find(|m| m.card == idx).and_then(|m| m.error_at).map(|t| t > started).unwrap_or(false);
let fault = if self.quitting {
Some("the app is quitting".to_string())
} else if self.job_hold || self.jobs.holds_miners() {
Some("a remote job took the GPU".into())
} else if self.st().mining.paused {
Some("mining paused".into())
} else if c.state != "mining" {
Some(format!("the card left mining ({})", if c.state == "starting" || c.state == "restarting" { "worker restart, likely the hour boundary" } else { &c.state }))
} else if slot_error {
Some("the worker reported an error".into())
} else if c.temp_gpu >= 90.0 {
Some(format!("GPU at {:.0} C", c.temp_gpu))
} else {
None
};
if let Some(why) = fault {
self.sweep_abort(&why);
return;
}
let outs = self.sweep.as_mut().map(|r| r.tick(now, c.power_limit_w)).unwrap_or_default();
let label = self.sweep.as_ref().map(|r| r.label.clone()).unwrap_or_default();
for o in outs {
match o {
crate::sweep::Out::Apply(w) => self.sweep_set_cap(&device, w),
crate::sweep::Out::Row(row) => {
self.sweep_say(&row.line(&label));
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
if row.usable() {
cc.sweep_note = format!("sweep: {}% done · {:.3} MH/W", row.pct, row.eff);
}
}
}
crate::sweep::Out::Finished(row) => {
self.sweep_finish(row);
return;
}
crate::sweep::Out::Failed(e) => {
self.sweep_abort(&e);
return;
}
}
}
if let Some(words) = self.sweep.as_ref().map(|r| r.words(now)) {
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
cc.sweep_note = words;
}
}
}
/// The chosen cap is in force: record it on the card and in the settings, say so, let the helper go.
fn sweep_finish(&mut self, row: crate::sweep::Row) {
let Some(run) = self.sweep.take() else { return };
let idx = run.card;
let unix = crate::platform::unix_now();
let (name, pinned, pinned_w) = {
let mut settings = self.shared.settings.lock().unwrap();
let mut st = self.st();
let Some(c) = st.mining.cards.get_mut(idx) else { return };
c.sweep_pct = row.pct;
c.sweep_eff = row.eff;
c.sweep_watts = row.watts;
c.sweep_mhs = row.mhs;
c.sweep_at = unix as f64;
c.sweep_state = "idle".into();
let pinned_w = requested_watts(c);
if c.pinned {
c.sweep_note = format!("sweep: best {}% · {:.3} MH/W; cap pinned at {}%", row.pct, row.eff, c.power_pct);
} else {
c.power_pct = row.pct;
c.power_limit_w = row.limit;
c.power_applied = true;
c.power_note = format!("power cap: {} W held by the sweep (best MH per watt)", row.limit as u64);
c.sweep_note = format!("sweep: best {}% · {:.3} MH/W", row.pct, row.eff);
}
let e = settings.cards.entry(c.key.clone()).or_insert_with(|| crate::config::CardPref { enabled: c.enabled, identities: c.identities, ..Default::default() });
e.sweep_at = unix;
e.sweep_pct = row.pct;
e.sweep_eff = row.eff;
e.sweep_watts = row.watts;
e.sweep_mhs = row.mhs;
if !c.pinned {
e.power_pct = row.pct;
}
(c.name.clone(), c.pinned, pinned_w)
};
self.shared.save_settings();
self.sweep_say(&format!("SWEEP chosen card={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4}{}", run.label, row.pct, row.limit, row.watts, row.mhs, row.eff, if pinned { " pinned=1" } else { "" }));
if pinned {
self.sweep_set_cap(&run.device.clone(), pinned_w);
self.shared.event("ok", &format!("{name}: best efficiency at {}% ({:.0} W cap): {:.1} MH/s at {:.0} W = {:.3} MH/W. The cap stays pinned where you set it.", row.pct, row.limit, row.mhs, row.watts, row.eff));
} else {
self.shared.event("ok", &format!("{name}: best efficiency at {}% ({:.0} W cap): {:.1} MH/s at {:.0} W = {:.3} MH/W; cap held there", row.pct, row.limit, row.mhs, row.watts, row.eff));
}
self.sweep_helper_quit();
if self.shared.runtime.sweep_only && self.sweep_queue.is_empty() {
self.shared.send(Cmd::Quit);
}
}
/// Stops a sweep: the cap goes back to what it was, the log says why, the card waits an hour before the
/// scheduler tries again (a forced sweep under --sweep retries twice).
fn sweep_abort(&mut self, why: &str) {
let pending = self.sweep_pending.take();
let run = self.sweep.take();
let (idx, label, device, before_w, before_pct, forced) = match (&run, pending) {
(Some(r), _) => (r.card, r.label.clone(), r.device.clone(), r.before_w, r.before_pct, r.forced),
(None, Some((idx, forced))) => {
let c = self.st().mining.cards.get(idx).cloned();
let (label, device, w, pct) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, c.power_pct)).unwrap_or_default();
(idx, label, device, w, pct, forced)
}
_ => return,
};
self.sweep_say(&format!("SWEEP aborted card={label} reason={}", why.replace(' ', "_")));
let name = {
let mut st = self.st();
match st.mining.cards.get_mut(idx) {
Some(c) => {
c.sweep_state = "idle".into();
c.sweep_note = format!("sweep stopped: {why}");
c.power_pct = before_pct;
c.power_applied = false;
c.power_note = format!("cap restored to {} W after the sweep stopped", before_w as u64);
c.name.clone()
}
None => label.clone(),
}
};
if run.is_some() && before_w > 0.0 && !device.is_empty() {
self.sweep_set_cap(&device, before_w);
}
self.sweep_helper_quit();
self.shared.event("error", &format!("{name}: efficiency sweep stopped ({why}); cap back to {} W", before_w as u64));
let attempts = self.sweep_attempts.get(&idx).copied().unwrap_or(0);
if forced && self.shared.runtime.sweep_only && attempts < 3 && !self.quitting {
// the PC job: the hour boundary or a restart interrupted it; try again once the card is steady
let key = self.st().mining.cards.get(idx).map(|c| c.key.clone());
if let Some(key) = key {
self.sweep_queue.push(key);
}
self.sweep_retry.insert(idx, Instant::now() + Duration::from_secs(30));
} else {
self.sweep_retry.insert(idx, Instant::now() + Duration::from_secs(3600));
if self.shared.runtime.sweep_only && self.sweep_queue.is_empty() {
self.shared.send(Cmd::Quit);
}
}
}
// ---- the tick ----------------------------------------------------------------------------------------------
fn tick(&mut self) {
@ -1362,6 +1888,7 @@ impl Engine {
self.tick_watch(now);
self.tick_miners(now);
self.tick_telemetry(now);
self.tick_sweep(now);
}
self.derive(now);
if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) {
@ -1727,10 +2254,12 @@ impl Engine {
let mut st = self.st();
st.node.last_reading_age_s = self.node_last_reading.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(-1.0);
let mut total = 0.0;
for c in st.mining.cards.iter() {
for c in st.mining.cards.iter_mut() {
if c.state == "mining" {
total += c.hash_now;
}
// live hash per watt (the number next to the draw on the tile); needs a draw reading under a minute old
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;
let cut = unix - 3600.0;
@ -2014,6 +2543,11 @@ impl Engine {
let avg = kv_f64(text, "hash").unwrap_or(0.0);
// the v4 miner's interval rate: "now=12.34 MH/s wall (...)"; older miners: the average
let now_rate = kv_f64(text, "now").unwrap_or(avg);
if let Some(run) = self.sweep.as_mut() {
if run.card == card {
run.sample_rate(now_rate);
}
}
let mut st = self.st();
if let Some(c) = st.mining.cards.get_mut(card) {
c.hash_avg = avg;
@ -2155,12 +2689,21 @@ impl Engine {
fn shutdown(&mut self) {
self.shared.log("quit: stopping the miners, then the node");
self.jobs.abort(&self.shared, "the app is quitting");
if self.sweep.is_some() || self.sweep_pending.is_some() {
self.sweep_abort("the app is quitting");
}
self.sweep_helper_quit();
self.stop_miners("quit");
self.stability_line();
if let Some(mut t) = self.telemetry.take() {
t.stop(2);
}
self.restore_power_limits();
if self.shared.runtime.sweep_only {
// the measurement leaves the chosen cap in force for the app that takes the card back
self.shared.log("--sweep: the chosen caps stay in force (not restored)");
} else {
self.restore_power_limits();
}
if let Some(mut w) = self.watch.take() {
w.stop(2);
}
@ -2193,7 +2736,7 @@ impl Engine {
/// The watts a card's cap asks for: power_pct of the default limit, inside the card's min and max.
fn requested_watts(c: &CardState) -> f64 {
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(60, 100) };
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) };
let mut w = c.power_default_w * pct as f64 / 100.0;
if c.power_min_w > 0.0 {
w = w.max(c.power_min_w);

View file

@ -3,9 +3,11 @@
//! --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its stdin. Without a host
//! (--open) the dashboard opens in the default browser.
//!
//! igneum-app [--wrapper | --open | --no-open | --launch] [--print-url]
//! igneum-app [--wrapper | --open | --no-open | --launch | --sweep] [--print-url]
//! --launch is what the Start Menu entry runs on Windows: it hands over to "Igneum Miner.exe" (the window host) when
//! that is installed next to the engine, else runs the engine with the dashboard in the default browser.
//! --sweep runs the efficiency sweep (src/sweep.rs) on every supported card as soon as it mines, prints the SWEEP
//! table on stdout, leaves the chosen caps in force and quits; no browser opens (the PC measurement job).
//!
//! Environment (tests): IGNEUM_APP_NETWORK devnet|simnet, IGNEUM_APP_RPC_PORT, IGNEUM_APP_P2P_PORT, IGNEUM_APP_PEERS
//! (comma list, empty for none), IGNEUM_APP_UNSYNCED=1, IGNEUM_APP_DATA, IGNEUM_APP_LOGS, IGNEUM_APP_BIN, IGNEUM_APP_NODE_DIR.
@ -25,6 +27,7 @@ mod update;
mod jobs;
mod jobrun;
mod prover;
mod sweep;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;
@ -33,7 +36,12 @@ use std::sync::Arc;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let wrapper = args.iter().any(|a| a == "--wrapper");
let no_open = wrapper || args.iter().any(|a| a == "--no-open");
let sweep = args.iter().any(|a| a == "--sweep");
if sweep {
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep
std::env::set_var("IGNEUM_APP_SWEEP", "1");
}
let no_open = wrapper || sweep || args.iter().any(|a| a == "--no-open");
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("igneum-app {}", engine::VERSION);
return;

View file

@ -244,7 +244,7 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
key: c.get("key")?.as_str()?.to_string(),
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
identities: c.get("identities").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 64) as u32,
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|v| v.clamp(60, 100) as u32),
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|v| v.clamp(crate::sweep::MIN_PCT as u64, 100) as u32),
})
})
.collect();
@ -300,6 +300,28 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ApplyPower);
Ok(json!({ "ok": true }))
}
// the efficiency sweep (src/sweep.rs): start one card now, stop the running one, pin or unpin a card's cap,
// the weekly toggle
"/api/sweep/start" => {
let key = s("key").ok_or("key missing")?;
shared.send(Cmd::SweepStart(key));
Ok(json!({ "ok": true }))
}
"/api/sweep/stop" => {
shared.send(Cmd::SweepStop);
Ok(json!({ "ok": true }))
}
"/api/sweep/pin" => {
let key = s("key").ok_or("key missing")?;
let pinned = body.get("pinned").and_then(|v| v.as_bool()).ok_or("pinned missing")?;
shared.send(Cmd::SweepPin(key, pinned));
Ok(json!({ "ok": true }))
}
"/api/sweep/enable" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::SweepEnable(on));
Ok(json!({ "ok": true }))
}
"/api/clock/sync" => {
shared.send(Cmd::ClockSync);
Ok(json!({ "ok": true }))

View file

@ -64,12 +64,23 @@ pub struct CardState {
pub power_min_w: f64,
pub power_max_w: f64,
pub power_before_w: f64, // the limit before the app touched it (restored on quit)
pub power_pct: u32, // the chosen cap, percent of the default (60 to 100)
pub power_pct: u32, // the chosen cap, percent of the default (50 to 100)
pub power_applied: bool,
pub power_note: String,
pub temp_gpu: f64,
pub temp_mem: f64,
pub telemetry_at: f64,
// hash per watt (src/sweep.rs)
pub eff_mhw: f64, // live: hash_now over power_w, MH per watt; 0 = unknown
pub sweep_supported: bool, // NVIDIA with readable limits; the note says why not otherwise
pub sweep_state: String, // idle | running | unsupported
pub sweep_note: String, // the phase in words while running, else why unsupported or the last outcome
pub sweep_pct: u32, // the cap the last sweep chose (0 = never swept)
pub sweep_eff: f64, // MH per watt at that cap
pub sweep_watts: f64,
pub sweep_mhs: f64,
pub sweep_at: f64, // unix s of the last sweep
pub pinned: bool, // the user set the cap by hand; the sweep records but does not change it
}
#[derive(Clone, Serialize, Default)]
@ -158,6 +169,8 @@ pub struct SettingsState {
pub remote_jobs: bool,
/// the prover service (src/prover.rs)
pub prove: bool,
/// the efficiency sweep (src/sweep.rs): once after install, then weekly
pub sweep: bool,
}
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.

593
app/igneum-app/src/sweep.rs Normal file
View file

@ -0,0 +1,593 @@
//! The efficiency sweep: hash per watt, the number miners compare. For one NVIDIA card at a time the engine steps
//! the power cap from 100% of the card's default limit down to 50% in 10% steps, holds each step for 60 s after a
//! 15 s settle, averages the card's reported draw (nvidia-smi power.draw, the telemetry child) and the worker's
//! interval rate (the STATUS lines), computes MH per watt per step, picks the best step and leaves the cap there.
//! It runs on the live kernel: the worker is never restarted and the hour's program is never lost. It never starts
//! while a remote job holds the GPU, and it aborts the moment the card leaves "mining" (a fault, an hour boundary,
//! a pause, a job).
//!
//! This file is the logic: the step plan, the per-step rows, the choice, the parser for nvidia-smi's power query and
//! the state machine, all driven by an explicit clock so the tests run without a card. The engine (src/engine.rs,
//! `tick_sweep` and the `Cmd::Sweep*` commands) owns the processes: it sets the cap (directly when the engine runs
//! elevated, else through one elevated helper that polls a command file, so a sweep costs one administrator prompt
//! instead of six), reads the limit back through the telemetry child, feeds the samples in and writes the table to
//! the app log:
//! SWEEP start card=<label> name=<name> steps=100,90,80,70,60,50
//! SWEEP card=<label> cap=<pct> limit=<set W> watts=<mean draw W> mhs=<MH/s> eff=<MH per W>
//! SWEEP chosen card=<label> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH per W>
//! SWEEP aborted card=<label> reason=<text>
//! Apple silicon and AMD are reported unsupported with the reason (no cap to set; powermetrics needs root; no AMD
//! power reading in this app).
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// The cap steps, percent of the card's default limit, highest first.
pub const STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
/// The lowest cap the slider and the sweep may set (the former floor was 60; the sweep needs 50).
pub const MIN_PCT: u32 = 50;
/// A sweep repeats this often when the toggle is on.
pub const PERIOD_S: u64 = 7 * 86_400;
/// The sweep needs at least this long before the hour boundary (6 steps of 75 s plus the cap latencies).
pub const NEEDS_S: i64 = 600;
/// The worker must have been mining this long before a sweep starts (the first status lines are warm-up).
pub const STABLE_S: u64 = 120;
#[derive(Clone, Copy, Debug)]
pub struct Timing {
pub settle: Duration,
pub hold: Duration,
/// how long a cap may take to show in the readback before the sweep gives up
pub apply: Duration,
}
impl Timing {
pub fn standard() -> Timing {
Timing { settle: Duration::from_secs(15), hold: Duration::from_secs(60), apply: Duration::from_secs(30) }
}
/// IGNEUM_APP_SWEEP_FAST=1: a dry run in seconds (settle 2 s, hold 6 s), for a local check of the plumbing.
pub fn from_env() -> Timing {
if std::env::var("IGNEUM_APP_SWEEP_FAST").map(|v| v == "1").unwrap_or(false) {
Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(6), apply: Duration::from_secs(30) }
} else {
Timing::standard()
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Step {
pub pct: u32,
/// the limit to set, watts, inside the card's min and max
pub watts: f64,
}
/// The steps for a card: each percent of the default limit, clamped to the card's min and max, rounded to a watt;
/// a step whose watts equal the previous step's (the clamp bit) is dropped, so a card whose floor is 70% of its
/// default sweeps 100, 90, 80, 70 and once more at the floor.
pub fn plan_steps(default_w: f64, min_w: f64, max_w: f64) -> Vec<Step> {
let mut out: Vec<Step> = Vec::new();
if default_w <= 0.0 {
return out;
}
for pct in STEPS_PCT {
let mut w = default_w * pct as f64 / 100.0;
if min_w > 0.0 {
w = w.max(min_w);
}
if max_w > 0.0 {
w = w.min(max_w);
}
let w = w.round();
if out.last().map(|s| (s.watts - w).abs() < 0.5).unwrap_or(false) {
continue;
}
out.push(Step { pct, watts: w });
}
out
}
/// One step's result. `watts` is the mean draw the card reported during the hold (what the miner pays for),
/// `limit` the cap that was set. `eff` is MH per watt of draw; 0 when the step had no readings.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Row {
pub pct: u32,
pub limit: f64,
pub watts: f64,
pub mhs: f64,
pub eff: f64,
pub draws: usize,
pub rates: usize,
}
impl Row {
pub fn from_samples(step: &Step, draws: &[f64], rates: &[f64]) -> Row {
let mean = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
let watts = mean(draws);
let mhs = mean(rates);
let usable = draws.len() >= 3 && !rates.is_empty() && watts > 1.0;
Row { pct: step.pct, limit: step.watts, watts, mhs, eff: if usable { mhs / watts } else { 0.0 }, draws: draws.len(), rates: rates.len() }
}
pub fn usable(&self) -> bool {
self.eff > 0.0
}
/// `SWEEP card=<label> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH/W>`; a step without readings says so.
pub fn line(&self, card: &str) -> String {
if self.usable() {
format!("SWEEP card={card} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4}", self.pct, self.limit, self.watts, self.mhs, self.eff)
} else {
format!("SWEEP card={card} cap={} limit={:.0} watts={:.1} mhs={:.2} eff=0 reason=no_readings draws={} rates={}", self.pct, self.limit, self.watts, self.mhs, self.draws, self.rates)
}
}
}
/// The best step: the highest MH per watt. Within 1% of the best, the higher hash rate wins (more blocks for the
/// same efficiency); within 1% on both, the lower cap wins (the same output with more headroom from the limit).
pub fn choose(rows: &[Row]) -> Option<Row> {
let mut best: Option<&Row> = None;
for r in rows.iter().filter(|r| r.usable()) {
best = Some(match best {
None => r,
Some(b) => {
let eff_tie = (r.eff - b.eff).abs() <= 0.01 * b.eff.max(r.eff);
if !eff_tie {
if r.eff > b.eff { r } else { b }
} else {
let mhs_tie = (r.mhs - b.mhs).abs() <= 0.01 * b.mhs.max(r.mhs);
if !mhs_tie {
if r.mhs > b.mhs { r } else { b }
} else if r.pct < b.pct {
r
} else {
b
}
}
}
});
}
best.cloned()
}
/// Parses `nvidia-smi --query-gpu=index,power.draw --format=csv,noheader,nounits` (and the same query with units,
/// or with a header) into index -> watts. A card that reports `[N/A]` or `[Not Supported]` is left out.
pub fn parse_power_draw(text: &str) -> HashMap<String, f64> {
let mut out = HashMap::new();
for line in text.lines() {
let p: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if p.len() < 2 || p[0].is_empty() || !p[0].chars().all(|c| c.is_ascii_digit()) {
continue;
}
let w = p[1].trim_end_matches('W').trim();
if let Ok(v) = w.parse::<f64>() {
if v > 0.0 {
out.insert(p[0].to_string(), v);
}
}
}
out
}
/// Where the sweep stands, for the tile ("holding 70% · 41 s").
#[derive(Clone, Debug, PartialEq)]
pub enum Phase {
/// the cap for step `i` was (or is about to be) requested; waiting for the readback to match
Applying { since: Instant, sent: bool },
Settling { since: Instant },
Holding { since: Instant },
/// the chosen cap was requested; waiting for the readback
Finishing { since: Instant, sent: bool },
Done,
}
/// What the engine must do after a tick.
#[derive(Clone, Debug, PartialEq)]
pub enum Out {
/// set this cap (watts) on the card
Apply(f64),
/// a step finished: log its line
Row(Row),
/// the sweep finished and the chosen cap is in force
Finished(Row),
/// the sweep failed; the engine restores the cap from before
Failed(String),
}
pub struct Run {
pub card: usize,
#[allow(dead_code)]
pub key: String,
pub device: String,
pub label: String,
pub steps: Vec<Step>,
pub i: usize,
pub phase: Phase,
pub rows: Vec<Row>,
pub chosen: Option<Row>,
/// the cap percent and the limit in force before the sweep (restored on abort)
pub before_pct: u32,
pub before_w: f64,
pub started: Instant,
pub forced: bool,
pub timing: Timing,
draws: Vec<f64>,
rates: Vec<f64>,
}
impl Run {
pub fn new(card: usize, key: &str, device: &str, label: &str, steps: Vec<Step>, before_pct: u32, before_w: f64, forced: bool, timing: Timing, now: Instant) -> Run {
Run {
card,
key: key.into(),
device: device.into(),
label: label.into(),
steps,
i: 0,
phase: Phase::Applying { since: now, sent: false },
rows: Vec::new(),
chosen: None,
before_pct,
before_w,
started: now,
forced,
timing,
draws: Vec::new(),
rates: Vec::new(),
}
}
pub fn current(&self) -> Option<&Step> {
self.steps.get(self.i)
}
/// A worker STATUS interval rate (MH/s); counted while holding.
pub fn sample_rate(&mut self, mhs: f64) {
if matches!(self.phase, Phase::Holding { .. }) && mhs > 0.0 {
self.rates.push(mhs);
}
}
/// A telemetry draw reading (watts); counted while holding.
pub fn sample_draw(&mut self, w: f64) {
if matches!(self.phase, Phase::Holding { .. }) && w > 0.0 {
self.draws.push(w);
}
}
/// The phase in words for the tile.
pub fn words(&self, now: Instant) -> String {
let pct = self.current().map(|s| s.pct).unwrap_or(0);
match &self.phase {
Phase::Applying { .. } => format!("sweep: setting {pct}%"),
Phase::Settling { since } => format!("sweep: {pct}% settling · {} s", self.timing.settle.as_secs().saturating_sub(now.duration_since(*since).as_secs())),
Phase::Holding { since } => format!("sweep: holding {pct}% · {} s", self.timing.hold.as_secs().saturating_sub(now.duration_since(*since).as_secs())),
Phase::Finishing { .. } => format!("sweep: setting the best cap ({}%)", self.chosen.as_ref().map(|r| r.pct).unwrap_or(0)),
Phase::Done => "sweep: done".into(),
}
}
/// Drives the state machine. `limit_w` is the limit the card reports now (the telemetry readback; 0 = unknown).
pub fn tick(&mut self, now: Instant, limit_w: f64) -> Vec<Out> {
let mut out = Vec::new();
let settle = self.timing.settle;
let hold = self.timing.hold;
let apply = self.timing.apply;
match self.phase.clone() {
Phase::Applying { since, sent } => {
let Some(step) = self.current().cloned() else {
self.phase = Phase::Done;
out.push(Out::Failed("no steps".into()));
return out;
};
if !sent {
self.phase = Phase::Applying { since: now, sent: true };
out.push(Out::Apply(step.watts));
} else if limit_w > 0.0 && (limit_w - step.watts).abs() < 1.5 {
self.phase = Phase::Settling { since: now };
} else if now.duration_since(since) > apply {
self.phase = Phase::Done;
out.push(Out::Failed(format!("the {}% cap ({:.0} W) did not take within {} s (card reports {:.0} W)", step.pct, step.watts, apply.as_secs(), limit_w)));
}
}
Phase::Settling { since } => {
if now.duration_since(since) >= settle {
self.draws.clear();
self.rates.clear();
self.phase = Phase::Holding { since: now };
}
}
Phase::Holding { since } => {
if now.duration_since(since) >= hold {
let step = self.current().cloned().expect("a step while holding");
let row = Row::from_samples(&step, &self.draws, &self.rates);
self.rows.push(row.clone());
out.push(Out::Row(row));
self.i += 1;
if self.i < self.steps.len() {
let next = self.steps[self.i].watts;
self.phase = Phase::Applying { since: now, sent: true };
out.push(Out::Apply(next));
} else {
match choose(&self.rows) {
Some(best) => {
self.chosen = Some(best.clone());
self.phase = Phase::Finishing { since: now, sent: true };
out.push(Out::Apply(best.limit));
}
None => {
self.phase = Phase::Done;
out.push(Out::Failed("no step had readings (no draw from nvidia-smi or no STATUS line from the worker)".into()));
}
}
}
}
}
Phase::Finishing { since, sent } => {
let best = self.chosen.clone().expect("a chosen row while finishing");
if !sent {
self.phase = Phase::Finishing { since: now, sent: true };
out.push(Out::Apply(best.limit));
} else if limit_w > 0.0 && (limit_w - best.limit).abs() < 1.5 {
self.phase = Phase::Done;
out.push(Out::Finished(best));
} else if now.duration_since(since) > apply {
self.phase = Phase::Done;
out.push(Out::Failed(format!("the chosen cap ({:.0} W) did not take within {} s", best.limit, apply.as_secs())));
}
}
Phase::Done => {}
}
out
}
}
/// The elevated helper that sets caps for a sweep (one administrator prompt per sweep, not one per step). It polls
/// `<dir>/cmd.txt` twice a second: a line `<seq> <watts>` runs `nvidia-smi -i <device> -pl <watts>`, `quit` ends it.
/// After 20 minutes without a new command it restores `<restore watts>` and exits by itself, so an engine that died
/// mid-sweep leaves the card on its old limit. It writes what it ran to `<dir>/helper.log`.
pub fn helper_script_windows() -> &'static str {
r#"param([string]$Dir, [string]$Smi, [string]$Device, [string]$Restore)
$ErrorActionPreference = 'Continue'
$cmd = Join-Path $Dir 'cmd.txt'
$log = Join-Path $Dir 'helper.log'
$last = ''
$idle = Get-Date
"$(Get-Date -Format o) helper started: device $Device, restore $Restore W" | Out-File -FilePath $log -Append -Encoding utf8
while ($true) {
$c = ''
if (Test-Path -LiteralPath $cmd) { try { $c = (Get-Content -LiteralPath $cmd -Raw -ErrorAction Stop).Trim() } catch { $c = '' } }
if ($c -and $c -ne $last) {
$last = $c
$idle = Get-Date
if ($c -eq 'quit') { "$(Get-Date -Format o) quit" | Out-File -FilePath $log -Append -Encoding utf8; break }
$w = ($c -split ' ')[-1]
if ($w -match '^\d+$') {
$out = (& $Smi -i $Device -pl $w 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) -pl $w : $out" | Out-File -FilePath $log -Append -Encoding utf8
}
}
if (((Get-Date) - $idle).TotalMinutes -gt 20) {
$out = (& $Smi -i $Device -pl $Restore 2>&1 | Out-String).Trim()
"$(Get-Date -Format o) idle 20 min: restored $Restore W and quit: $out" | Out-File -FilePath $log -Append -Encoding utf8
break
}
Start-Sleep -Milliseconds 500
}
exit 0
"#
}
/// The same helper for Linux (run through pkexec sh).
pub fn helper_script_unix() -> &'static str {
r#"#!/bin/sh
# igneum sweep helper: $1 dir, $2 nvidia-smi, $3 device, $4 restore watts
dir="$1"; smi="$2"; dev="$3"; restore="$4"
last=""; idle=$(date +%s)
echo "$(date -u +%FT%TZ) helper started: device $dev, restore $restore W" >> "$dir/helper.log"
while true; do
c=""; [ -f "$dir/cmd.txt" ] && c=$(cat "$dir/cmd.txt" 2>/dev/null | tr -d '\r\n')
if [ -n "$c" ] && [ "$c" != "$last" ]; then
last="$c"; idle=$(date +%s)
if [ "$c" = "quit" ]; then echo "$(date -u +%FT%TZ) quit" >> "$dir/helper.log"; break; fi
w="${c##* }"
case "$w" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) -pl $w : $("$smi" -i "$dev" -pl "$w" 2>&1)" >> "$dir/helper.log";; esac
fi
if [ $(( $(date +%s) - idle )) -gt 1200 ]; then
echo "$(date -u +%FT%TZ) idle 20 min: restored $restore W: $("$smi" -i "$dev" -pl "$restore" 2>&1)" >> "$dir/helper.log"; break
fi
sleep 0.5
done
exit 0
"#
}
/// Why a card cannot be swept, or None when it can (NVIDIA with a readable default limit and a device index).
pub fn unsupported_reason(vendor: &str, power_default_w: f64, device: &str) -> Option<&'static str> {
match vendor {
"nvidia" if power_default_w > 0.0 && !device.is_empty() => None,
"nvidia" => Some("not available: nvidia-smi did not report this card's power limits"),
"apple" => Some("not available on Apple silicon: there is no power cap to set, and powermetrics needs administrator rights for the draw"),
"amd" => Some("not available for AMD in this version: the app has no power reading or cap for AMD cards (nothing like nvidia-smi ships with the driver)"),
_ => Some("not available: no power reading or cap for this card"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PC 1's RTX 5090 (4 October 2026 app log): default 575 W, the 80% cap 460 W, min 400 W, max 600 W. The
/// 60% and 50% steps clamp to the 400 W floor, so the plan ends at 70% plus one step at the floor.
#[test]
fn plan_clamps_to_the_card_floor_and_drops_duplicates() {
let s = plan_steps(575.0, 400.0, 600.0);
assert_eq!(s.iter().map(|s| s.pct).collect::<Vec<_>>(), vec![100, 90, 80, 70, 60]);
assert_eq!(s.iter().map(|s| s.watts).collect::<Vec<_>>(), vec![575.0, 518.0, 460.0, 403.0, 400.0]);
// a card with no floor reported: six steps
assert_eq!(plan_steps(320.0, 0.0, 0.0).len(), 6);
assert_eq!(plan_steps(320.0, 0.0, 0.0)[5], Step { pct: 50, watts: 160.0 });
// the max caps the 100% step: a card whose max is under its default (rare, but nvidia-smi allows it)
assert_eq!(plan_steps(300.0, 100.0, 250.0)[0], Step { pct: 100, watts: 250.0 });
assert!(plan_steps(0.0, 0.0, 0.0).is_empty());
}
/// Recorded on PC 1 (RTX 5090, 4 October 2026): the telemetry query answered "0, 290.12, 65, [N/A], 460.00"
/// (draw, GPU temperature, memory temperature not supported, limit). The draw query is the same shape.
#[test]
fn parses_nvidia_smi_power_draw() {
let nounits = "0, 290.12\r\n";
assert_eq!(parse_power_draw(nounits).get("0"), Some(&290.12));
// two cards, one without a reading
let two = "0, 290.12\n1, [N/A]\n";
let m = parse_power_draw(two);
assert_eq!(m.len(), 1);
assert_eq!(m.get("0"), Some(&290.12));
// with units and a header (the same query without noheader,nounits)
let units = "index, power.draw [W]\n0, 290.12 W\n1, 45.50 W\n";
let m = parse_power_draw(units);
assert_eq!(m.get("0"), Some(&290.12));
assert_eq!(m.get("1"), Some(&45.5));
// garbage and a warning line
assert!(parse_power_draw("NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver.\n").is_empty());
assert!(parse_power_draw("0, [Not Supported]\n").is_empty());
assert!(parse_power_draw("").is_empty());
}
fn row(pct: u32, watts: f64, mhs: f64) -> Row {
Row { pct, limit: watts, watts, mhs, eff: if watts > 0.0 { mhs / watts } else { 0.0 }, draws: 12, rates: 2 }
}
#[test]
fn chooses_the_best_mh_per_watt_then_rate_then_the_lower_cap() {
// a card that gets more efficient as the cap drops, until it collapses
let rows = vec![row(100, 560.0, 124.0), row(90, 510.0, 123.0), row(80, 455.0, 121.0), row(70, 400.0, 112.0), row(60, 345.0, 80.0)];
assert_eq!(choose(&rows).unwrap().pct, 70); // 0.280 beats 0.266, 0.241, 0.221, 0.232
// PC 1's case: the draw never reaches the cap (290 W under every limit), so every step reads the same;
// ties on efficiency and rate go to the lowest cap
let flat = vec![row(100, 290.0, 124.0), row(90, 290.5, 123.5), row(80, 289.8, 124.2), row(70, 290.2, 123.9)];
assert_eq!(choose(&flat).unwrap().pct, 70);
// within 1% on efficiency, the higher rate wins
let close = vec![row(100, 500.0, 125.0), row(80, 400.5, 100.3)];
assert_eq!(choose(&close).unwrap().pct, 100);
// a step without readings never wins; none usable = no choice
let mut r = row(60, 300.0, 90.0);
r.eff = 0.0;
assert_eq!(choose(&[r.clone(), row(100, 500.0, 120.0)]).unwrap().pct, 100);
assert!(choose(&[r]).is_none());
assert!(choose(&[]).is_none());
}
#[test]
fn row_lines_carry_the_table_fields() {
let s = Step { pct: 80, watts: 460.0 };
let r = Row::from_samples(&s, &[290.0, 291.0, 289.0, 290.0], &[124.0, 123.0]);
assert!((r.eff - 123.5 / 290.0).abs() < 1e-9);
assert_eq!(r.line("nvidia-ae432dc7-1"), "SWEEP card=nvidia-ae432dc7-1 cap=80 limit=460 watts=290.0 mhs=123.50 eff=0.4259");
// under 3 draws or no rate: no efficiency, and the line says why
let r = Row::from_samples(&s, &[290.0, 291.0], &[124.0]);
assert!(!r.usable());
assert!(r.line("c").contains("eff=0 reason=no_readings draws=2 rates=1"));
}
/// A full sweep with a fake clock: three steps, the readback follows each cap after 4 s, two draws and one rate
/// per hold. The engine's view is the sequence of Out values.
#[test]
fn state_machine_runs_a_sweep() {
let t0 = Instant::now();
let timing = Timing { settle: Duration::from_secs(15), hold: Duration::from_secs(60), apply: Duration::from_secs(30) };
let steps = plan_steps(300.0, 200.0, 300.0); // 300, 270, 240, 210, 200 (60% and 50% clamp to 200)
assert_eq!(steps.len(), 5);
let mut run = Run::new(0, "nvidia:0:x", "0", "nvidia-test-1", steps.clone(), 80, 240.0, false, timing, t0);
let mut limit = 240.0;
let mut t = t0;
let mut applied: Vec<f64> = Vec::new();
let mut pending: Option<(f64, Instant)> = None;
let mut rows: Vec<Row> = Vec::new();
let mut finished: Option<Row> = None;
// the draw at each cap: the card draws min(cap, 260); once the cap bites, the rate falls with the square of
// the cap (faster than the watts), so efficiency drops below the caps that do not bite
let draw_at = |cap: f64| cap.min(260.0);
let rate_at = |cap: f64| if cap >= 240.0 { 100.0 } else { 100.0 * (cap / 240.0) * (cap / 240.0) };
for _ in 0..2000 {
// the readback: the telemetry shows the new limit 4 s after the request
if let Some((w, at)) = pending {
if t.duration_since(at) >= Duration::from_secs(4) {
limit = w;
pending = None;
}
}
for o in run.tick(t, limit) {
match o {
Out::Apply(w) => {
applied.push(w);
pending = Some((w, t));
}
Out::Row(r) => rows.push(r),
Out::Finished(r) => finished = Some(r),
Out::Failed(e) => panic!("failed: {e}"),
}
}
if matches!(run.phase, Phase::Holding { .. }) {
run.sample_draw(draw_at(limit));
run.sample_draw(draw_at(limit) + 1.0);
run.sample_draw(draw_at(limit) - 1.0);
run.sample_rate(rate_at(limit));
}
if run.phase == Phase::Done {
break;
}
t += Duration::from_secs(1);
}
assert_eq!(rows.len(), 5, "one row per step");
assert_eq!(rows.iter().map(|r| r.pct).collect::<Vec<_>>(), vec![100, 90, 80, 70, 60]);
// the first three caps sit above the 260 W draw: the same draw, the same rate (0.385 MH/W), so the lowest
// of them (80%) wins the tie; 70% and 60% bite and lose rate faster than watts (0.337 and 0.347 MH/W)
let f = finished.expect("finished");
assert_eq!(f.pct, 80);
assert_eq!(applied, vec![300.0, 270.0, 240.0, 210.0, 200.0, 240.0], "every step's cap, then the chosen one");
assert!(run.rows.iter().all(|r| r.draws >= 3 && r.rates >= 1));
// timing: five steps of apply (4 s) + settle 15 + hold 60 = 79 s each, plus the final apply
let elapsed = t.duration_since(t0).as_secs();
assert!((395..=420).contains(&elapsed), "elapsed {elapsed} s");
}
#[test]
fn state_machine_fails_when_a_cap_never_takes() {
let t0 = Instant::now();
let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(1), apply: Duration::from_secs(30) };
let mut run = Run::new(0, "k", "0", "c", plan_steps(300.0, 0.0, 0.0), 80, 240.0, false, timing, t0);
assert_eq!(run.tick(t0, 240.0), vec![Out::Apply(300.0)]);
assert!(run.tick(t0 + Duration::from_secs(29), 240.0).is_empty());
match run.tick(t0 + Duration::from_secs(31), 240.0).as_slice() {
[Out::Failed(e)] => assert!(e.contains("did not take"), "{e}"),
other => panic!("{other:?}"),
}
assert_eq!(run.phase, Phase::Done);
}
#[test]
fn state_machine_fails_without_readings() {
let t0 = Instant::now();
let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(1), apply: Duration::from_secs(30) };
let mut run = Run::new(0, "k", "0", "c", vec![Step { pct: 100, watts: 300.0 }], 80, 240.0, false, timing, t0);
run.tick(t0, 0.0);
run.tick(t0 + Duration::from_secs(1), 300.0); // settling
run.tick(t0 + Duration::from_secs(3), 300.0); // holding
let out = run.tick(t0 + Duration::from_secs(5), 300.0);
assert!(matches!(out.as_slice(), [Out::Row(_), Out::Failed(_)]), "{out:?}");
}
#[test]
fn unsupported_reasons() {
assert!(unsupported_reason("nvidia", 575.0, "0").is_none());
assert!(unsupported_reason("nvidia", 0.0, "0").unwrap().contains("power limits"));
assert!(unsupported_reason("apple", 0.0, "").unwrap().contains("powermetrics"));
assert!(unsupported_reason("amd", 0.0, "1").unwrap().contains("AMD"));
}
#[test]
fn helper_scripts_carry_the_protocol() {
for s in [helper_script_windows(), helper_script_unix()] {
assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min"));
}
}
}

View file

@ -173,6 +173,10 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
.power input[type=range]{width:110px;accent-color:var(--ember)}
.power .pv{font-size:12px;color:var(--ink-2);min-width:84px}
.power .pin{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
.gpu-tile .msg.sweep{color:var(--ash)}
.gpu-tile .msg.sweep b{color:var(--ink-2);font-weight:500}
.gpu-tile .msg.sweep.on{color:var(--molten)}
.cards.compact .power .k{display:none}
.cards.compact .power input[type=range]{width:80px}
.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)}

View file

@ -150,6 +150,7 @@
$('s-prove').addEventListener('change', function () { api('api/prove', { on: this.checked }).then(function (r) { if (r.ok) toast(r.ok && $('s-prove').checked ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); setTimeout(fillSettings, 800); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); });
$('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); $('s-jobs-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
// ---------- bottom bar ----------
@ -242,7 +243,7 @@
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + ' <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 ? '<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="60" 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></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>' +
'<label class="switch" title="' + (cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled ? ' checked' : '') + (cd.kind === 'unknown' ? ' disabled' : '') + '><span class="track"></span></label>';
container.appendChild(d);
@ -300,15 +301,34 @@
var memCls = cd.temp_mem > 95 ? 'hot' : cd.temp_mem > 90 ? 'warm' : '';
var h = '<div class="m tele"><span>draw <b>' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '</b>' + (cd.power_limit_w ? ' <span class="dim">/ cap ' + Math.round(cd.power_limit_w) + ' W</span>' : '') + '</span>' +
'<span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span>' +
'<span class="' + memCls + '">memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span></div>';
'<span class="' + memCls + '">memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span>' +
'<span>eff <b>' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '</b></span></div>';
if (memCls) h += '<div class="msg ' + memCls + '">memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk</div>';
if (cd.power_default_w > 0) {
var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100);
if (cd.power_applied) h += '<div class="msg ok">power cap: ' + want + ' W applied</div>';
if (cd.power_applied) h += '<div class="msg ok">power cap: ' + want + ' W (' + (cd.power_pct || 80) + '%)' + (cd.pinned ? ' pinned' : cd.sweep_pct === (cd.power_pct || 80) && cd.sweep_pct ? ' chosen by the sweep' : '') + '</div>';
else h += '<div class="msg hot">power cap NOT applied (needs the administrator prompt)' + (cd.power_note && cd.power_note.indexOf('wanted') > 0 ? ' · ' + esc(cd.power_note.replace(/^.*: card reports/, 'card reports')) : '') + ' <button class="btn tiny" data-power-retry="1">Retry</button></div>';
}
return h;
return h + sweepHtml(cd);
}
// the efficiency sweep line: running (the phase), the last result (best cap and MH/W), or why it cannot run
function sweepHtml(cd) {
if (!cd.sweep_supported) return cd.sweep_note ? '<div class="msg dim sweep">' + esc(cd.sweep_note) + '</div>' : '';
var running = cd.sweep_state === 'running';
var t = '';
if (running) t = esc(cd.sweep_note || 'sweep: running');
else if (cd.sweep_pct) t = 'sweep: best <b>' + cd.sweep_pct + '%</b> · <b>' + cd.sweep_eff.toFixed(3) + ' MH/W</b> (' + cd.sweep_mhs.toFixed(1) + ' MH/s at ' + Math.round(cd.sweep_watts) + ' W' + (cd.sweep_at ? ', ' + rel(state.now - cd.sweep_at) + ' ago' : '') + ')' + (cd.sweep_note && cd.sweep_note.indexOf('stopped') === 0 ? ' · ' + esc(cd.sweep_note) : '');
else t = esc(cd.sweep_note || 'sweep: not run yet');
var btn = running ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Sweep now</button>';
if (!running && cd.pinned) btn += ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0" title="let the sweep choose the cap again">Unpin</button>';
return '<div class="msg sweep' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>';
}
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-sweep-start],[data-sweep-stop],[data-sweep-pin]'); if (!b) return;
if (b.dataset.sweepStart) api('api/sweep/start', { key: b.dataset.sweepStart }).then(function () { toast('Sweep queued: 100% down to 50%, 75 s a step'); });
else if (b.dataset.sweepStop) api('api/sweep/stop', {}).then(function () { toast('Sweep stopped; cap restored'); });
else api('api/sweep/pin', { key: b.dataset.sweepPin, pinned: b.dataset.pinned === '1' }).then(function () { toast(b.dataset.pinned === '1' ? 'Cap pinned' : 'The sweep chooses the cap again'); });
});
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-power-retry]'); if (!b) return;
api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); });
@ -512,6 +532,7 @@
function fillJobsSettings(j) {
$('s-jobs-allow').checked = !!j.allowed;
$('s-prove').checked = !!(state.settings && state.settings.prove);
$('s-sweep').checked = !!(state.settings && state.settings.sweep);
$('s-jobs-key').textContent = j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : '';
var parts = [];
if (j.account) parts.push(j.account);

View file

@ -90,7 +90,7 @@
</div>
</div>
<p class="note" id="cards-note" hidden></p>
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable (an RTX 5090 at full power hard-crashed in the field). This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. The slider sets the cap per card.</p>
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable (an RTX 5090 at full power hard-crashed in the field). This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. The slider sets the cap per card. In the first hour, and then weekly, the efficiency sweep steps the cap from 100% down to 50% on the live program and holds the best MH per watt; a cap you set by hand stays pinned.</p>
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. Identities take turns on the card and each one votes and pays separately; 8 suits a big card, 2 a small one, 1 an integrated GPU.</p>
<div class="cta">
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
@ -278,6 +278,11 @@
<label class="switch"><input type="checkbox" id="s-prove"><span class="track"></span><span>Prove assigned shards (proving v0; on a Mac the CPU prover is slow)</span></label>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
</div>
<div class="field">
<div class="k">efficiency sweep</div>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Find each NVIDIA card's best MH per watt (once after install, then weekly)</span></label>
<p class="note">On the live program, never restarting the worker: the cap steps from 100% of the card's default limit down to 50%, 15 s to settle and 60 s to measure per step, then holds the step with the most MH per watt. One administrator prompt per sweep. It stops at once if the card faults, a remote job takes the GPU, or the hour boundary is near. A cap you set with the slider is pinned: the sweep records, but leaves it. "Sweep now" on a card's tile runs one at any time; the table is in the log (SWEEP lines).</p>
</div>
<div class="field">
<div class="k">version</div>
<div class="row between"><span class="mono" id="s-version"></span><span class="row"><button class="btn small primary" id="s-install" hidden>Install now</button><button class="btn small" id="s-update">Check now</button></span></div>

120
docs/plans/miner-eff.md Normal file
View file

@ -0,0 +1,120 @@
# Hash per watt: the efficiency sweep (lever 3)
4 October 2026, evening. the project lead: "make our miner better than anything else can be". Miners pay for electricity; the
number they compare is MH per watt. The app already caps NVIDIA cards (default 80% of the default limit, `nvidia-smi
-pl`, readback, Retry). This adds the sweep that finds the best cap per card, the live MH/W on every tile, a `--sweep`
mode for the engine, and the PC 1 measurement job. Branch `miner-eff`, worktree `../igneum-wt-eff`.
## What is built
| Piece | Where | State |
|---|---|---|
| Sweep logic: step plan (100% to 50% in 10% steps, clamped to the card's min and max, duplicate watts dropped), per-step rows, the choice (best MH/W; within 1% the higher rate; within 1% on both the lower cap), the nvidia-smi power parser, the state machine on an explicit clock, the elevated helper scripts, the unsupported reasons | `app/igneum-app/src/sweep.rs` | 9 unit tests pass on the Mac (36 in the crate) |
| Engine: scheduler (one card at a time, once after install then weekly, never under a remote job hold, a pause, or inside 600 s of the hour boundary; the card must have mined 120 s), the cap-mode probe (direct when the engine is elevated, else one elevated helper polling a command file: one administrator prompt per sweep, not six), fault abort (card leaves "mining", worker error line, GPU 90 C, job, pause, quit), restore on abort, record and hold on finish, `--sweep` mode, the table in the app log and on stdout | `app/igneum-app/src/engine.rs` (`tick_sweep`, `sweep_*`, `Cmd::Sweep*`) | compiles; not run on a card |
| Settings: `sweep` (default on), `installed_at`, per card `pinned`, `sweep_at`, `sweep_pct`, `sweep_eff`, `sweep_watts`, `sweep_mhs`; cap floor 50% (was 60) | `config.rs`, `detect.rs`, `state.rs`, `server.rs` (`/api/sweep/start`, `stop`, `pin`, `enable`) | |
| Dashboard: `eff <MH/W>` on the tile's telemetry line (live hash over draw), the sweep line under it (phase while running, else "best N% · x MH/W (rate at watts, when)", or why unsupported), Sweep now / Stop / Unpin, the cap line says "pinned" or "chosen by the sweep"; Settings toggle "Find each NVIDIA card's best MH per watt (once after install, then weekly)"; the cards page note; slider min 50 with a "pinned" / "sweep N%" tag | `app/igneum-app/ui/` | |
| Readback self-heal: a cap that matches what was asked counts as applied when the telemetry reads it back (PC 1's log said "cap NOT applied" for hours while the limit read 460 W) | `engine.rs` `telemetry_line` | |
| The PC 1 job script | `relay/playbooks/sweep-5090.ps1` | parse-checked by `windows.yml`; UNTESTED on a PC |
The window hosts (Swift, WebView2) are untouched: no host string changes.
## The log lines
SWEEP start card=nvidia-ae432dc7-1 name=NVIDIA_GeForce_RTX_5090 steps=100,90,80,70,60 default=575 min=400 max=600 before=460 mode=direct
SWEEP card=nvidia-ae432dc7-1 cap=100 limit=575 watts=290.4 mhs=124.10 eff=0.4273
SWEEP card=nvidia-ae432dc7-1 cap=90 limit=518 watts=290.1 mhs=124.00 eff=0.4274
...
SWEEP chosen card=nvidia-ae432dc7-1 cap=70 limit=403 watts=289.9 mhs=123.90 eff=0.4274
SWEEP aborted card=nvidia-ae432dc7-1 reason=the_card_left_mining_(worker_restart,_likely_the_hour_boundary)
`watts` is the mean draw during the 60 s hold (what the miner pays for), `limit` the cap set, `mhs` the mean of the
worker's interval rates (`now=` in the STATUS lines) during the hold, `eff` = mhs / watts. A step with under 3 draw
readings or no rate prints `eff=0 reason=no_readings` and cannot win. The numbers above are the shape, not a
measurement.
## How a sweep runs
1. Scheduler (every 10 s): a card is due when `sweep` is on, it is not pinned and its last sweep is older than 7 days
(or never). "Sweep now" and `--sweep` queue a card regardless. It starts only while the card is `mining`, its
worker has run 120 s, no remote job holds the GPU, nothing is paused, and over 600 s remain to the hour boundary
(the tile says what it waits for).
2. Probe: one `nvidia-smi -i <dev> -pl <current limit>` from the engine. "All done" means this process may set caps
(it runs elevated, as the PC job does); otherwise the helper starts: `powershell -File <app>\sweep\helper.ps1`
through the existing `run_elevated` (one UAC prompt), polling `<app>\sweep\cmd.txt` twice a second for
`<seq> <watts>` lines, `quit` to end, and restoring the entry limit by itself after 20 idle minutes.
3. Each step: set the cap, wait for the telemetry readback (`power.limit`, every 5 s) to match (30 s limit), settle
15 s, hold 60 s collecting draws and rates, write the row. Then the chosen cap is set and read back, the card and
the settings record it (`power_pct` becomes the chosen percent unless the card is pinned), the helper is told to
quit. The worker is never restarted; the hour's program is never lost.
4. Abort: the cap goes back to the limit in force before the sweep, `SWEEP aborted` is logged, the card waits an
hour before the scheduler tries again (under `--sweep`: 30 s, up to 3 attempts).
`IGNEUM_APP_SWEEP_FAST=1` makes a step 2 s settle + 6 s hold (plumbing check only; its numbers mean nothing).
## The PC 1 measurement (do not publish from this branch; the main session schedules PC jobs)
The job runs `relay/playbooks/sweep-5090.ps1` elevated with the installed app's miners stopped and held. The script
starts a second engine from the same install with `--sweep` in `%LOCALAPPDATA%\igneum-sweep` (the real
`settings.json`, `machine-id` and `wallet.json` copied in; remote jobs, auto-update and proving switched off in the
copy so the second engine cannot run this job again or update itself). That engine finds the installed app's node on
127.0.0.1:26610 (external), exports the pack, mines on the 5090 with `--status-secs 10` (6 rate samples per hold),
sweeps, prints the table on stdout, leaves the chosen cap in force and quits. The script re-emits every `SWEEP` line
as `RESULT SWEEP ...`, adds `RESULT SWEEP before ...` / `after ...` (the nvidia-smi limits around the run) and the
sweep engine's log tail. Budget 40 minutes (worst case: 10 minutes waiting for the hour boundary, 2 minutes steady,
5 steps of about 80 s). The installed app's miners restart when the job ends.
Needs: the build that carries `src/sweep.rs` installed on PC 1 (ship it with `tools/ship-app.mjs`; the OTA applies it,
or `publish-jobs.sh add --kind update-now --target ae432dc7`). An older engine ignores `--sweep` and the script
reports `no_rows`.
Publish command (from the repo root, the main session, after the version is on PC 1):
packaging/ota/publish-jobs.sh add --kind run --target ae432dc7 --platform windows --requires nvidia \
--script relay/playbooks/sweep-5090.ps1 --elevated --stop-miners --timeout-minutes 45 \
--title "Efficiency sweep on the RTX 5090 (PC 1)" --deploy
Reading it back:
node tools/jobs.mjs watch <job id> every 30 s until final; the RESULT SWEEP lines are the table
node tools/jobs.mjs <job id> the latest report
## What PC 1's own log already says (4 October 2026, run win-ae432dc7-20261004-164723)
stability: NVIDIA GeForce RTX 5090: draw p95 290 W, max 295 W, limit 460 W (cap NOT applied), max GPU 65 C, max memory 0 C
The 5090 draws 290 W under a 460 W limit (80% of 575 W). The cap does not bind at any step down to the card's floor
(the 5090's `power.min_limit` is expected near 400 W; the sweep reads the real value and clamps). Expect a flat table:
the same draw and rate at every step, and the choice falling to the lowest cap by the tie rule. That is a result in
itself: for this kernel the power cap is a safety, not an efficiency lever. The lever would be the clocks
(`nvidia-smi -lgc` / `-lmc`, core and memory offsets), which this sweep does not touch; a clock sweep is the next
step once the table confirms the flat line. "memory 0 C" is `temperature.memory` reading `[N/A]` on this driver.
## Unit tests (`cargo test -p igneum-app sweep`)
- `plan_clamps_to_the_card_floor_and_drops_duplicates`: 575 / 400 / 600 W plans 100, 90, 80, 70, 60 (60% and 50%
both clamp to 400 W; one kept).
- `parses_nvidia_smi_power_draw`: the recorded telemetry shape from PC 1 (`0, 290.12`), CRLF, `[N/A]`, with units and
a header, the driver-failure text, empty.
- `chooses_the_best_mh_per_watt_then_rate_then_the_lower_cap`: a card that improves then collapses; PC 1's flat case;
the 1% ties; unusable rows never win.
- `row_lines_carry_the_table_fields`, `state_machine_runs_a_sweep` (five steps with a 4 s readback lag, 395 to 420 s),
`state_machine_fails_when_a_cap_never_takes`, `state_machine_fails_without_readings`, `unsupported_reasons`,
`helper_scripts_carry_the_protocol`.
## Untested
- The real sweep on a card: the probe, the helper and its UAC prompt, the readback timing, the table. Only the PC job
above measures it.
- The second-engine arrangement of the job (two engines from one install, one holding its miners).
- The dashboard rendering (the UI was edited by hand; `node --check` passes; the Rust side serialises the new fields).
- The Windows build: checked on macOS only (`cargo test` in the crate); the Windows-only paths are runtime `cfg!`
branches, so they compile here too, but the GitHub runner has not built this branch.
- AMD on Windows: reported unsupported with the reason (no power reading or cap in the app). Apple silicon: unsupported
(no cap to set; powermetrics needs root).
## Merge notes for the other miner agents
Touched outside the new module: `engine.rs` (new `Cmd` variants, engine fields, the sweep block before the tick, one
line each in `miner_line` STATUS and `telemetry_line`, `derive`, `shutdown`, the `apply_power_limits` guard, the
50% clamps), `config.rs`, `state.rs`, `detect.rs`, `server.rs`, `main.rs`, `ui/`. Nothing in the workers, the manifest,
the payout, the template path or the fault guards.

View file

@ -0,0 +1,111 @@
# Igneum run job: the efficiency sweep on PC 1's RTX 5090 (machine ae432dc7), unattended. 4 October 2026.
# Published as a `run` job with --elevated --stop-miners (docs/plans/miner-eff.md): the installed app stops its
# miners and holds them, this script starts a SECOND engine from the same install with `--sweep` in a scratch data
# folder (the real settings.json and machine-id copied in, remote jobs and auto-update switched off there), that
# engine finds the installed app's node on 127.0.0.1:26610, mines on the 5090 with the live program, steps the cap
# 100% -> 50% (75 s a step), prints the SWEEP table on stdout, leaves the chosen cap in force and quits. Every SWEEP
# line is re-emitted as a RESULT line, so `node tools/jobs.mjs <job id>` shows the table. The installed app's
# miners restart when the job ends. Elevated, so nvidia-smi -pl needs no prompt (the engine detects that: mode=direct).
# UNTESTED on a PC as of 4 Oct 2026 (parse-checked only).
$ErrorActionPreference = 'Continue'
$budgetMinutes = 40
$started = Get-Date
$deadline = $started.AddMinutes($budgetMinutes)
function Say([string] $m) { Write-Host ("[" + (Get-Date -Format 'HH:mm:ss') + "] " + $m) }
# the engine: the per-user install (0.3.3+), else Program Files (0.3.0 to 0.3.2)
$exe = $null
foreach ($d in @((Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'), (Join-Path $env:ProgramFiles 'Igneum Miner'))) {
$p = Join-Path $d 'igneum-app.exe'
if (Test-Path $p) { $exe = $p; break }
}
if (-not $exe) { Write-Output 'RESULT SWEEP error=no_engine reason=igneum-app.exe_not_found'; exit 2 }
$ver = (& $exe --version 2>&1 | Out-String).Trim()
Say ("engine: " + $exe + " (" + $ver + ")")
if ($ver -notmatch 'igneum-app (\d+)\.(\d+)\.(\d+)') { Write-Output 'RESULT SWEEP error=version_unknown'; exit 2 }
# --sweep exists from the build that carries src/sweep.rs; an older engine would just start mining in the browser
$appDir = $env:IGNEUM_APP_DIR
if (-not $appDir) { $appDir = Join-Path $env:LOCALAPPDATA 'igneum\app' }
# the scratch data root: its own app dir, node dir (unused: the node is external) and logs
$root = Join-Path $env:LOCALAPPDATA 'igneum-sweep'
$sApp = Join-Path $root 'app'
$sLogs = Join-Path $root 'logs'
New-Item -ItemType Directory -Force -Path $sApp, $sLogs | Out-Null
foreach ($f in @('settings.json', 'machine-id', 'wallet.json')) {
$src = Join-Path $appDir $f
if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force }
}
# the second engine must not poll jobs (it would see this one), update itself, or prove
$sj = Join-Path $sApp 'settings.json'
if (Test-Path $sj) {
try {
$j = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json
$j.remote_jobs = $false; $j.auto_update = $false; $j.prove = $false; $j.paused = $false; $j.setup_done = $true
$j | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $sj -Encoding utf8
} catch { Say ("settings.json: " + $_.Exception.Message) }
} else { Write-Output 'RESULT SWEEP error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue
# the cap state before, for the report
$smi = Join-Path $env:ProgramFiles 'NVIDIA Corporation\NVSMI\nvidia-smi.exe'
if (-not (Test-Path $smi)) { $smi = Join-Path $env:SystemRoot 'System32\nvidia-smi.exe' }
if (Test-Path $smi) {
$q = (& $smi --query-gpu=index,name,power.draw,power.limit,power.default_limit,power.min_limit,power.max_limit --format=csv,noheader 2>&1 | Out-String).Trim()
Write-Output ("RESULT SWEEP before " + ($q -replace "`r?`n", ' | '))
}
# the sweep engine: status every 10 s (6 rate samples per 60 s hold instead of 2)
$env:IGNEUM_APP_DATA = $root
$env:IGNEUM_APP_LOGS = $sLogs
$env:IGNEUM_APP_STATUS_SECS = '10'
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $exe
$psi.Arguments = '--sweep'
$psi.WorkingDirectory = Split-Path $exe
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
$lines = New-Object System.Collections.ArrayList
$h = { if ($EventArgs.Data) { [void]$Event.MessageData.Add($EventArgs.Data) } }
Register-ObjectEvent -InputObject $p -EventName OutputDataReceived -Action $h -MessageData $lines | Out-Null
Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $h -MessageData $lines | Out-Null
[void]$p.Start()
$p.BeginOutputReadLine(); $p.BeginErrorReadLine()
Say ("sweep engine started, pid " + $p.Id + ", data " + $root)
$seen = 0
$rows = 0
while (-not $p.HasExited) {
Start-Sleep -Seconds 5
while ($seen -lt $lines.Count) {
$l = [string]$lines[$seen]; $seen++
if ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l); if ($l -match '^SWEEP card=') { $rows++ } }
elseif ($l -match '^(URL|STATE) ') { }
else { Say $l }
}
if ((Get-Date) -gt $deadline) {
Say ("budget of " + $budgetMinutes + " min spent; asking the sweep engine to quit")
$u = Join-Path $sApp 'app.url'
if (Test-Path $u) { try { Invoke-WebRequest -Uri ((Get-Content -LiteralPath $u -Raw).Trim() + 'api/quit') -Method POST -Body '{}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null } catch { } }
Start-Sleep -Seconds 20
if (-not $p.HasExited) { $p.Kill() }
Write-Output 'RESULT SWEEP error=budget_exceeded'
}
}
while ($seen -lt $lines.Count) { $l = [string]$lines[$seen]; $seen++; if ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l); if ($l -match '^SWEEP card=') { $rows++ } } }
Say ("sweep engine exited " + $p.ExitCode + " after " + [int]((Get-Date) - $started).TotalSeconds + " s, " + $rows + " table rows")
if (Test-Path $smi) {
$q = (& $smi --query-gpu=index,power.draw,power.limit --format=csv,noheader 2>&1 | Out-String).Trim()
Write-Output ("RESULT SWEEP after " + ($q -replace "`r?`n", ' | '))
}
# the sweep engine's own log: the SWEEP lines and what happened around them
$log = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($log) {
Say ("engine log " + $log.FullName + ":")
Get-Content -LiteralPath $log.FullName | Where-Object { $_ -match 'SWEEP|sweep|power cap|GPUs:|worker ready|STATUS|exited' } | Select-Object -Last 80 | ForEach-Object { Say (' ' + $_) }
}
if ($rows -eq 0) { Write-Output 'RESULT SWEEP error=no_rows'; exit 1 }
exit 0