release 0.3.14: merge ember-tune 8be7339 (the engine commits bb2bbe6, 692ce53, acc8de6 and the helper repoint: task_exe prefers the installed exe; a power step on a card with no limit readback is skipped)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> # Conflicts: # app/igneum-app/ui/app.js # app/igneum-app/ui/index.html # docs/bench-log.md
This commit is contained in:
commit
2552163340
22 changed files with 1054 additions and 104 deletions
|
|
@ -39,6 +39,9 @@ pub struct CardPref {
|
|||
pub sweep_class: String,
|
||||
#[serde(default)]
|
||||
pub sweep_source: String,
|
||||
/// Ember 2: the memory clock the last tune chose (0 = the driver's default)
|
||||
#[serde(default)]
|
||||
pub sweep_mem_mhz: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
|
|
@ -90,6 +93,15 @@ pub struct Settings {
|
|||
/// unanswered prompt switches it back off with a notice, no retries.
|
||||
#[serde(default)]
|
||||
pub power_control: bool,
|
||||
/// Ember 2 (6 October 2026): the tune's goal ("efficiency" = most MH per watt within 10% of the top rate,
|
||||
/// "balanced" = within 1%, "rate" = the fastest point), the electricity price in pence per kWh for the £/day
|
||||
/// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders).
|
||||
#[serde(default = "balanced")]
|
||||
pub tune_goal: String,
|
||||
#[serde(default)]
|
||||
pub power_price_pence: f64,
|
||||
#[serde(default)]
|
||||
pub tune_climb: bool,
|
||||
/// When this install first ran (unix s), for the "first hour after install" sweep.
|
||||
#[serde(default)]
|
||||
pub installed_at: u64,
|
||||
|
|
@ -113,13 +125,16 @@ pub struct Settings {
|
|||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
fn balanced() -> String {
|
||||
"balanced".into()
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
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, sweep: true, power_control: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: 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, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ use std::time::{Duration, Instant};
|
|||
/// The power steps, percent of the card's default limit, highest first (the same ladder as src/sweep.rs).
|
||||
pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
|
||||
/// The clock steps, percent of the card's maximum core clock, highest first; 100 = unlocked (the card's own boost).
|
||||
pub const CLOCK_STEPS_PCT: [u32; 5] = [100, 90, 80, 70, 60];
|
||||
/// 6 October 2026, run 6: the 5090's best MH/W sat on the 60% floor (1,854 MHz: 0.563 MH/W, the rate within 0.15%),
|
||||
/// so the ladder and the floor go to 45% of the maximum; the 1% rate tolerance is the guard below that
|
||||
pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45];
|
||||
/// A card's clock floor when the vendor reports none: this share of its maximum core clock.
|
||||
pub const CLOCK_FLOOR_PCT: u32 = 60;
|
||||
pub const CLOCK_FLOOR_PCT: u32 = 45;
|
||||
/// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it).
|
||||
pub const RATE_TOLERANCE_PCT: f64 = 1.0;
|
||||
/// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90).
|
||||
|
|
@ -49,6 +51,10 @@ pub struct Limits {
|
|||
pub clock_max_mhz: u32,
|
||||
/// the lowest cap the vendor allows (the ADLX gmax_range floor); 0 = CLOCK_FLOOR_PCT of the maximum
|
||||
pub clock_min_mhz: u32,
|
||||
/// Ember 2: the memory clock the card runs at by default and the vendor's maximum (nvidia-smi clocks.mem and
|
||||
/// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none)
|
||||
pub mem_default_mhz: u32,
|
||||
pub mem_max_mhz: u32,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
|
|
@ -66,6 +72,13 @@ impl Limits {
|
|||
}
|
||||
mhz.clamp(self.clock_floor(), self.clock_max_mhz)
|
||||
}
|
||||
/// A memory clock inside the vendor's range; 0 stays 0 (the default).
|
||||
pub fn clamp_mem(&self, mhz: u32) -> u32 {
|
||||
if mhz == 0 || self.mem_max_mhz == 0 || self.mem_default_mhz == 0 {
|
||||
return 0;
|
||||
}
|
||||
mhz.clamp(self.mem_default_mhz, self.mem_max_mhz)
|
||||
}
|
||||
/// The watts a power percent asks for, inside the card's min and max, rounded to a watt.
|
||||
pub fn watts_for(&self, pct: u32) -> f64 {
|
||||
let mut w = self.power_default_w * pct as f64 / 100.0;
|
||||
|
|
@ -79,13 +92,15 @@ impl Limits {
|
|||
}
|
||||
}
|
||||
|
||||
/// One setting of the two knobs.
|
||||
/// One setting of the knobs (Ember 2 adds the memory clock: 0 = the driver's default).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Point {
|
||||
/// the core clock cap in MHz; 0 = unlocked
|
||||
pub clock_mhz: u32,
|
||||
/// the power limit, percent of the default
|
||||
pub power_pct: u32,
|
||||
/// the memory clock in MHz (NVIDIA `-lmc`, locked to one value); 0 = the driver's default
|
||||
pub mem_mhz: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -96,6 +111,8 @@ pub enum Kind {
|
|||
Clock,
|
||||
/// the fleet prior and one neighbour
|
||||
Confirm,
|
||||
/// Ember 2: a hill-climb probe
|
||||
Climb,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -106,11 +123,53 @@ pub struct Step {
|
|||
pub kind: Kind,
|
||||
}
|
||||
|
||||
/// What the tune is for (Settings > Ember Tune > goal). The rate floor is the share of the best rate seen a point
|
||||
/// must keep to win on MH per watt: efficiency keeps 90%, balanced 99% (the 1% rule of lever 3), maximum rate
|
||||
/// takes the fastest point and uses MH per watt only to break ties.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Goal {
|
||||
Efficiency,
|
||||
Balanced,
|
||||
MaxRate,
|
||||
}
|
||||
|
||||
impl Goal {
|
||||
pub fn parse(s: &str) -> Goal {
|
||||
match s {
|
||||
"efficiency" | "eff" => Goal::Efficiency,
|
||||
"rate" | "max_rate" | "maximum" => Goal::MaxRate,
|
||||
_ => Goal::Balanced,
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Goal::Efficiency => "efficiency",
|
||||
Goal::Balanced => "balanced",
|
||||
Goal::MaxRate => "rate",
|
||||
}
|
||||
}
|
||||
/// The rate tolerance the choice rule uses, percent under the best rate.
|
||||
pub fn tolerance_pct(&self, manifest_default: f64) -> f64 {
|
||||
match self {
|
||||
Goal::Efficiency => 10.0,
|
||||
Goal::Balanced => manifest_default,
|
||||
Goal::MaxRate => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pounds a day for a draw at a price in pence per kWh: watts × 24 h / 1000 × price / 100.
|
||||
pub fn pounds_per_day(watts: f64, pence_per_kwh: f64) -> f64 {
|
||||
watts * 24.0 / 1000.0 * pence_per_kwh / 100.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PlanKind {
|
||||
Full,
|
||||
Confirm,
|
||||
Baseline,
|
||||
/// Ember 2: the hill-climb over memory up and core down from the start point
|
||||
Climb,
|
||||
}
|
||||
|
||||
impl PlanKind {
|
||||
|
|
@ -119,6 +178,7 @@ impl PlanKind {
|
|||
PlanKind::Full => "full",
|
||||
PlanKind::Confirm => "confirm",
|
||||
PlanKind::Baseline => "baseline",
|
||||
PlanKind::Climb => "climb",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,9 +194,82 @@ pub struct Plan {
|
|||
power: Vec<Step>,
|
||||
clock_pcts: Vec<u32>,
|
||||
fixed: Vec<Step>,
|
||||
/// Ember 2 (Climb): the start point, the step sizes and the step budget
|
||||
climb: Option<Climb>,
|
||||
}
|
||||
|
||||
/// The hill-climb's shape: from `start`, each probe moves the memory clock up by `mem_step` or the core clock down
|
||||
/// by `core_step` (or both), keeps the move when the goal's score improves, else turns to the other knob; a
|
||||
/// refused step (a fault on it) backs that knob off for good. At most `budget` steps including the start.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Climb {
|
||||
pub start: Point,
|
||||
pub mem_step: u32,
|
||||
pub core_step: u32,
|
||||
pub budget: usize,
|
||||
pub goal: Goal,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// Ember 2: the hill-climb. The start is the fleet prior (or the card's current point); a probe step is 5% of
|
||||
/// the memory range above the default (0 when the card has no memory knob) and 5% of the maximum core clock;
|
||||
/// five steps of 60 s converge in under 10 minutes.
|
||||
pub fn climb(limits: &Limits, start: Point, goal: Goal, tolerance_pct: f64) -> Plan {
|
||||
let mem_step = if limits.mem_max_mhz > limits.mem_default_mhz { ((limits.mem_max_mhz - limits.mem_default_mhz) / 20).max(25) } else { 0 };
|
||||
let core_step = if limits.clock_max_mhz > 0 { (limits.clock_max_mhz / 20).max(25) } else { 0 };
|
||||
let start = Point { clock_mhz: limits.clamp_clock(start.clock_mhz), power_pct: start.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(start.mem_mhz) };
|
||||
Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), fixed: Vec::new(), climb: Some(Climb { start, mem_step, core_step, budget: 5, goal }) }
|
||||
}
|
||||
|
||||
/// The goal's score of a row: MH per watt for efficiency and balanced, the rate for maximum rate.
|
||||
pub fn score(&self, r: &Row) -> f64 {
|
||||
match self.climb.as_ref().map(|c| c.goal) {
|
||||
Some(Goal::MaxRate) => r.mhs,
|
||||
_ => r.eff,
|
||||
}
|
||||
}
|
||||
|
||||
/// The climb's next probe from the rows so far: None when the budget is spent or no move is left.
|
||||
fn climb_next(&self, rows: &[Row]) -> Option<Step> {
|
||||
let c = self.climb.as_ref()?;
|
||||
let step = |p: Point| Step { point: p, watts: self.limits.watts_for(p.power_pct), kind: Kind::Climb };
|
||||
if rows.is_empty() {
|
||||
return Some(step(c.start));
|
||||
}
|
||||
if rows.len() >= c.budget {
|
||||
return None;
|
||||
}
|
||||
// the best usable row so far is the hill's top; a knob that produced a marked (refused) row is backed off
|
||||
let best = rows.iter().filter(|r| r.usable()).max_by(|a, b| self.score(a).partial_cmp(&self.score(b)).unwrap_or(std::cmp::Ordering::Equal))?;
|
||||
let refused_mem = rows.iter().any(|r| !r.usable() && r.point.mem_mhz > best.point.mem_mhz);
|
||||
let refused_core = rows.iter().any(|r| !r.usable() && r.point.clock_mhz != 0 && (best.point.clock_mhz == 0 || r.point.clock_mhz < best.point.clock_mhz));
|
||||
let last = rows.last()?;
|
||||
let mem_up = |p: Point| -> Option<Point> {
|
||||
if c.mem_step == 0 || refused_mem { return None; }
|
||||
let base = if p.mem_mhz == 0 { self.limits.mem_default_mhz } else { p.mem_mhz };
|
||||
let m = self.limits.clamp_mem(base + c.mem_step);
|
||||
(m > 0 && m != p.mem_mhz && m != base).then_some(Point { mem_mhz: m, ..p })
|
||||
};
|
||||
let core_down = |p: Point| -> Option<Point> {
|
||||
if c.core_step == 0 || refused_core { return None; }
|
||||
let base = if p.clock_mhz == 0 { self.limits.clock_max_mhz } else { p.clock_mhz };
|
||||
let k = self.limits.clamp_clock(base.saturating_sub(c.core_step));
|
||||
(k > 0 && k != p.clock_mhz).then_some(Point { clock_mhz: k, ..p })
|
||||
};
|
||||
let tried = |p: Point| rows.iter().any(|r| r.point == p);
|
||||
// the last move improved: keep going the same way from the top; else turn: memory first, then core, then both
|
||||
let last_improved = last.usable() && last.point == best.point && rows.len() > 1;
|
||||
let last_was_mem = rows.len() > 1 && last.point.mem_mhz != rows[rows.len() - 2].point.mem_mhz;
|
||||
let candidates: Vec<Option<Point>> = if last_improved && last_was_mem {
|
||||
vec![mem_up(best.point), core_down(best.point)]
|
||||
} else if last_improved {
|
||||
vec![core_down(best.point), mem_up(best.point)]
|
||||
} else {
|
||||
vec![mem_up(best.point), core_down(best.point), mem_up(best.point).and_then(core_down)]
|
||||
};
|
||||
candidates.into_iter().flatten().find(|p| !tried(*p)).map(step)
|
||||
}
|
||||
|
||||
/// Power 100% to 50% at the unlocked clock (duplicate watts dropped, as the card's floor clamps them), then the
|
||||
/// clock ladder 90% to the floor of the maximum core clock at the chosen power. A card without a readable
|
||||
/// maximum clock gets the power ladder only; a card without a default limit gets the clock ladder only.
|
||||
|
|
@ -148,41 +281,44 @@ impl Plan {
|
|||
if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
power.push(Step { point: Point { clock_mhz: 0, power_pct: pct }, watts: w, kind: Kind::Power });
|
||||
power.push(Step { point: Point { clock_mhz: 0, power_pct: pct, mem_mhz: 0 }, watts: w, kind: Kind::Power });
|
||||
}
|
||||
}
|
||||
let clock_pcts = if limits.clock_max_mhz > 0 { CLOCK_STEPS_PCT[1..].to_vec() } else { Vec::new() };
|
||||
Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new() }
|
||||
Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new(), climb: None }
|
||||
}
|
||||
|
||||
/// The prior's point, then one neighbour: the next clock step up when the prior caps the clock (is the cap
|
||||
/// costing rate?), else one power step down (is there efficiency left?).
|
||||
pub fn confirm(limits: &Limits, prior: Point, before: Point, tolerance_pct: f64) -> Plan {
|
||||
let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100) };
|
||||
let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(prior.mem_mhz) };
|
||||
let first = Step { point: p, watts: limits.watts_for(p.power_pct), kind: Kind::Confirm };
|
||||
let neighbour = if p.clock_mhz > 0 && limits.clock_max_mhz > 0 {
|
||||
let up = p.clock_mhz + limits.clock_max_mhz / 10;
|
||||
let clock = if up >= limits.clock_max_mhz { 0 } else { limits.clamp_clock(up) };
|
||||
Point { clock_mhz: clock, power_pct: p.power_pct }
|
||||
Point { clock_mhz: clock, power_pct: p.power_pct, mem_mhz: p.mem_mhz }
|
||||
} else {
|
||||
Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50) }
|
||||
Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50), mem_mhz: p.mem_mhz }
|
||||
};
|
||||
let mut fixed = vec![first];
|
||||
if neighbour != p {
|
||||
fixed.push(Step { point: neighbour, watts: limits.watts_for(neighbour.power_pct), kind: Kind::Confirm });
|
||||
}
|
||||
Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed }
|
||||
Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None }
|
||||
}
|
||||
|
||||
/// One step at the card's current point: the before number, and all a measure-only card (Apple, or NVIDIA
|
||||
/// with Power control off) reports.
|
||||
pub fn baseline(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan {
|
||||
let fixed = vec![Step { point: before, watts: limits.watts_for(before.power_pct), kind: Kind::Baseline }];
|
||||
Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed }
|
||||
Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None }
|
||||
}
|
||||
|
||||
/// How many steps the plan has at most (the clock ladder counts whether or not it runs).
|
||||
pub fn len(&self) -> usize {
|
||||
if let Some(c) = &self.climb {
|
||||
return c.budget;
|
||||
}
|
||||
self.fixed.len() + self.power.len() + self.clock_pcts.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
|
|
@ -191,6 +327,9 @@ impl Plan {
|
|||
|
||||
/// The next step after `rows` (one row per step done so far), or None when the plan is complete.
|
||||
pub fn next(&self, rows: &[Row]) -> Option<Step> {
|
||||
if self.climb.is_some() {
|
||||
return self.climb_next(rows);
|
||||
}
|
||||
let i = rows.len();
|
||||
if !self.fixed.is_empty() {
|
||||
return self.fixed.get(i).cloned();
|
||||
|
|
@ -207,7 +346,7 @@ impl Plan {
|
|||
if rows.last().map(|r| r.point.clock_mhz == clock).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
Some(Step { point: Point { clock_mhz: clock, power_pct }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock })
|
||||
Some(Step { point: Point { clock_mhz: clock, power_pct, mem_mhz: 0 }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,9 +444,10 @@ impl Row {
|
|||
/// `TUNE card=<label> step=<i> clock=<MHz> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH/W> gclk=<MHz> mclk=<MHz> tmax=<C> mark=<m>`
|
||||
pub fn line(&self, card: &str, i: usize) -> String {
|
||||
format!(
|
||||
"TUNE card={card} step={i} clock={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} gclk={:.0} mclk={:.0} tmax={:.0} mark={}{}",
|
||||
"TUNE card={card} step={i} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} gclk={:.0} mclk={:.0} tmax={:.0} mark={}{}",
|
||||
self.point.clock_mhz,
|
||||
self.point.power_pct,
|
||||
self.point.mem_mhz,
|
||||
self.limit,
|
||||
self.watts,
|
||||
self.mhs,
|
||||
|
|
@ -320,7 +460,7 @@ impl Row {
|
|||
)
|
||||
}
|
||||
pub fn json(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "clock_mhz": self.point.clock_mhz, "power_pct": self.point.power_pct, "limit_w": self.limit, "watts": r1(self.watts), "mhs": r2(self.mhs), "eff": r4(self.eff), "gclk": self.gclk.round(), "mclk": self.mclk.round(), "tmax": self.tmax.round(), "faults": self.faults, "mark": self.mark.unwrap_or(Mark::NoReadings).name() })
|
||||
serde_json::json!({ "clock_mhz": self.point.clock_mhz, "power_pct": self.point.power_pct, "mem_mhz": self.point.mem_mhz, "limit_w": self.limit, "watts": r1(self.watts), "mhs": r2(self.mhs), "eff": r4(self.eff), "gclk": self.gclk.round(), "mclk": self.mclk.round(), "tmax": self.tmax.round(), "faults": self.faults, "mark": self.mark.unwrap_or(Mark::NoReadings).name() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +594,7 @@ pub fn prior_of(tuning: Option<&serde_json::Value>, key: &str, min_samples: u32)
|
|||
if samples < min_samples.max(1) {
|
||||
return None;
|
||||
}
|
||||
let point = Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100) };
|
||||
let point = Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100), mem_mhz: n("mem_mhz") as u32 };
|
||||
if point.power_pct == 0 && point.clock_mhz == 0 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -463,7 +603,7 @@ pub fn prior_of(tuning: Option<&serde_json::Value>, key: &str, min_samples: u32)
|
|||
|
||||
/// The fleet record: one `TUNE {json}` line. `machine` is a hash of the install id (never the id, never an address).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn record_json(ts: f64, machine_hash: &str, app: &str, os: &str, card: &str, vendor: &str, driver: &str, class: &str, plan: PlanKind, rows: &[Row], chosen: Option<&Row>, before: Option<&Row>) -> serde_json::Value {
|
||||
pub fn record_json(ts: f64, machine_hash: &str, app: &str, os: &str, card: &str, vendor: &str, driver: &str, class: &str, plan: PlanKind, rows: &[Row], chosen: Option<&Row>, before: Option<&Row>, floor: bool) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"ts": ts.round(),
|
||||
"machine": machine_hash,
|
||||
|
|
@ -482,6 +622,8 @@ pub fn record_json(ts: f64, machine_hash: &str, app: &str, os: &str, card: &str,
|
|||
"eff": chosen.map(|r| r4(r.eff)).unwrap_or(0.0),
|
||||
"mhs": chosen.map(|r| r2(r.mhs)).unwrap_or(0.0),
|
||||
"watts": chosen.map(|r| r1(r.watts)).unwrap_or(0.0),
|
||||
"floor": floor,
|
||||
"note": if floor { "floor, not optimum" } else { "" },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -591,9 +733,28 @@ impl Run {
|
|||
self.rows.first().map(|r| r.mclk).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Seconds left in the plan from here: the rest of this step plus (settle + hold + a 10 s apply) per step to come.
|
||||
pub fn eta_s(&self, now: Instant) -> i64 {
|
||||
let per = (self.timing.settle + self.timing.hold).as_secs() as i64 + 10;
|
||||
let this = match &self.phase {
|
||||
Phase::Applying { .. } => per,
|
||||
Phase::Settling { since } => per - 10 - now.duration_since(*since).as_secs().min(per as u64) as i64,
|
||||
Phase::Holding { since } => (self.timing.hold.as_secs() as i64 - now.duration_since(*since).as_secs() as i64).max(0),
|
||||
Phase::Finishing { .. } | Phase::Done => 0,
|
||||
};
|
||||
let left = self.plan.len().saturating_sub(self.rows.len() + 1) as i64;
|
||||
this.max(0) + left * per
|
||||
}
|
||||
|
||||
/// The phase in words for the card row.
|
||||
pub fn words(&self, now: Instant) -> String {
|
||||
let what = |p: &Point| if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) };
|
||||
let what = |p: &Point| {
|
||||
let mut w = if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) };
|
||||
if p.mem_mhz > 0 {
|
||||
w.push_str(&format!(" · mem {}", p.mem_mhz));
|
||||
}
|
||||
w
|
||||
};
|
||||
let cur = self.current.as_ref().map(|s| what(&s.point)).unwrap_or_default();
|
||||
let n = self.rows.len() + 1;
|
||||
let of = self.plan.len();
|
||||
|
|
@ -611,10 +772,15 @@ impl Run {
|
|||
fn applied(step: &Step, rb: Readback, before_w: f64) -> bool {
|
||||
let power_ok = rb.limit_w <= 0.0 || (rb.limit_w - step.watts).abs() < 1.5;
|
||||
let power_changed = (step.watts - before_w).abs() >= 1.5;
|
||||
if step.point.clock_mhz > 0 || !power_changed {
|
||||
if step.point.clock_mhz > 0 || step.point.mem_mhz > 0 || !power_changed {
|
||||
rb.acked && power_ok
|
||||
} else if rb.limit_w <= 0.0 {
|
||||
// a card with no limit readback at all (AMD through ADLX reports an offset, never watts; run 6 on
|
||||
// 6 October 2026 aborted the 9070 XT's ladder at its first step on "card reports 0 W"): the
|
||||
// acknowledgement is the proof
|
||||
rb.acked
|
||||
} else {
|
||||
rb.limit_w > 0.0 && power_ok
|
||||
power_ok
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -719,6 +885,13 @@ impl Run {
|
|||
Verdict::NoReadings => None,
|
||||
},
|
||||
PlanKind::Full => choose(&self.rows, self.plan.tolerance_pct),
|
||||
PlanKind::Climb => {
|
||||
if self.plan.climb.as_ref().map(|c| c.goal) == Some(Goal::MaxRate) {
|
||||
self.rows.iter().filter(|r| r.usable()).max_by(|a, b| a.mhs.partial_cmp(&b.mhs).unwrap_or(std::cmp::Ordering::Equal)).cloned()
|
||||
} else {
|
||||
choose(&self.rows, self.plan.tolerance_pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -754,7 +927,7 @@ mod tests {
|
|||
/// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is
|
||||
/// read at the first tune (3,090 MHz is the shape used here, not a measurement).
|
||||
fn l5090() -> Limits {
|
||||
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0 }
|
||||
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001 }
|
||||
}
|
||||
|
||||
fn row_at(p: Point, watts: f64, mhs: f64) -> Row {
|
||||
|
|
@ -763,10 +936,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_full_plan_is_the_power_ladder_then_the_clock_ladder_at_the_chosen_power() {
|
||||
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80 }, 1.0);
|
||||
assert_eq!(plan.len(), 5 + 4, "five power steps (60% and 50% clamp to 400 W; one kept) and four clock steps");
|
||||
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
|
||||
assert_eq!(plan.len(), 5 + 6, "five power steps (60% and 50% clamp to 400 W; one kept) and six clock steps (90% down to 45%)");
|
||||
let first = plan.next(&[]).unwrap();
|
||||
assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100 }, 575.0, Kind::Power));
|
||||
assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 575.0, Kind::Power));
|
||||
// the power ladder: 575, 518, 460, 403, 400
|
||||
let mut rows = Vec::new();
|
||||
let mut watts_seen = Vec::new();
|
||||
|
|
@ -781,35 +954,52 @@ mod tests {
|
|||
// the clock ladder rides the chosen power point: a flat ladder ties on efficiency and rate, the lowest draw wins (100%)
|
||||
let s = plan.next(&rows).unwrap();
|
||||
assert_eq!(s.kind, Kind::Clock);
|
||||
assert_eq!(s.point, Point { clock_mhz: 2781, power_pct: 100 });
|
||||
assert_eq!(s.point, Point { clock_mhz: 2781, power_pct: 100, mem_mhz: 0 });
|
||||
rows.push(row_at(s.point, 250.0, 123.8));
|
||||
let s = plan.next(&rows).unwrap();
|
||||
assert_eq!(s.point.clock_mhz, 2472);
|
||||
rows.push(row_at(s.point, 220.0, 123.5));
|
||||
rows.push(row_at(plan.next(&rows).unwrap().point, 200.0, 118.0));
|
||||
let s = plan.next(&rows).unwrap();
|
||||
assert_eq!(s.point.clock_mhz, 1854, "60% of 3,090 is the floor");
|
||||
assert_eq!(s.point.clock_mhz, 1854, "60% of 3,090");
|
||||
rows.push(row_at(s.point, 180.0, 100.0));
|
||||
let s = plan.next(&rows).unwrap();
|
||||
assert_eq!(s.point.clock_mhz, 1545, "50%");
|
||||
rows.push(row_at(s.point, 170.0, 90.0));
|
||||
let s = plan.next(&rows).unwrap();
|
||||
assert_eq!(s.point.clock_mhz, 1390, "45% of 3,090 is the floor (6 October 2026)");
|
||||
rows.push(row_at(s.point, 160.0, 80.0));
|
||||
assert_eq!(plan.next(&rows), None);
|
||||
// the choice: 2,472 MHz keeps 99.6% of the top rate at 220 W = 0.561 MH/W; 2,163 MHz (118 MH/s) is outside the 1% tolerance
|
||||
let best = choose(&rows, 1.0).unwrap();
|
||||
assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100 });
|
||||
assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 });
|
||||
// a wider tolerance lets the 2,163 MHz step (0.590 MH/W, 4.8% slower) win
|
||||
assert_eq!(choose(&rows, 5.0).unwrap().point.clock_mhz, 2163);
|
||||
// no power limits, clocks only; no clocks, power only; nothing, empty
|
||||
assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 4);
|
||||
assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 6);
|
||||
assert_eq!(Plan::full(&Limits { power_default_w: 300.0, ..Default::default() }, Point::default(), 1.0).len(), 6);
|
||||
assert!(Plan::full(&Limits::default(), Point::default(), 1.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_power_step_on_a_card_without_a_limit_readback_is_applied_on_the_acknowledgement() {
|
||||
// AMD through ADLX: the limit reads back as an offset, never watts (run 6, 6 October 2026)
|
||||
let step = Step { point: Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, watts: 90.0, kind: Kind::Power };
|
||||
assert!(Run::applied(&step, Readback { limit_w: 0.0, acked: true }, 100.0));
|
||||
assert!(!Run::applied(&step, Readback { limit_w: 0.0, acked: false }, 100.0));
|
||||
// NVIDIA: the watts must read back
|
||||
assert!(!Run::applied(&step, Readback { limit_w: 100.0, acked: true }, 100.0));
|
||||
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_never_exceed_the_vendor_or_undercut_the_floor() {
|
||||
let l = l5090();
|
||||
assert_eq!(l.clock_floor(), 1854);
|
||||
assert_eq!(l.clamp_clock(1000), 1854);
|
||||
assert_eq!(l.clock_floor(), 1390);
|
||||
assert_eq!(l.clamp_clock(1000), 1390);
|
||||
assert_eq!(l.clamp_clock(5000), 3090);
|
||||
assert_eq!(l.clamp_clock(0), 0, "unlocked stays unlocked");
|
||||
assert_eq!(Limits { clock_max_mhz: 3000, clock_min_mhz: 2100, ..Default::default() }.clamp_clock(1500), 2100, "the vendor's floor wins over the 60% rule");
|
||||
assert_eq!(Limits { clock_max_mhz: 3000, clock_min_mhz: 2100, ..Default::default() }.clamp_clock(1500), 2100, "the vendor's floor wins over the 45% rule");
|
||||
assert_eq!(l.watts_for(100), 575.0);
|
||||
assert_eq!(l.watts_for(50), 400.0);
|
||||
assert_eq!(Limits { power_default_w: 300.0, power_max_w: 250.0, ..Default::default() }.watts_for(100), 250.0);
|
||||
|
|
@ -817,7 +1007,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_choice_keeps_the_best_mh_per_watt_within_the_rate_tolerance() {
|
||||
let p = |c: u32, pct: u32| Point { clock_mhz: c, power_pct: pct };
|
||||
let p = |c: u32, pct: u32| Point { clock_mhz: c, power_pct: pct, mem_mhz: 0 };
|
||||
// a card that gets more efficient as the cap drops until it collapses: 70% wins (0.280 MH/W), 60% is 35% slower
|
||||
let rows = vec![row_at(p(0, 100), 560.0, 124.0), row_at(p(0, 90), 510.0, 123.5), row_at(p(0, 80), 455.0, 123.2), row_at(p(0, 70), 400.0, 122.9), row_at(p(0, 60), 345.0, 80.0)];
|
||||
assert_eq!(choose(&rows, 1.0).unwrap().point, p(0, 70));
|
||||
|
|
@ -844,7 +1034,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_guards_mark_a_step_so_it_cannot_win() {
|
||||
let step = Step { point: Point { clock_mhz: 2000, power_pct: 100 }, watts: 575.0, kind: Kind::Clock };
|
||||
let step = Step { point: Point { clock_mhz: 2000, power_pct: 100, mem_mhz: 0 }, watts: 575.0, kind: Kind::Clock };
|
||||
let good = Samples { draws: vec![250.0, 251.0, 249.0], rates: vec![123.0], gclks: vec![1998.0], mclks: vec![2505.0], tmax: 70.0, faults: 0, unapplied: false };
|
||||
assert_eq!(Row::from_samples(&step, &good, 2505.0).mark, Some(Mark::Ok));
|
||||
// one rejected or mismatched hash: faulted
|
||||
|
|
@ -862,15 +1052,15 @@ mod tests {
|
|||
assert!(!r.usable());
|
||||
assert!(r.line("c", 3).contains("mark=no_readings"), "{}", r.line("c", 3));
|
||||
let r = Row::from_samples(&step, &good, 0.0);
|
||||
assert_eq!(r.line("nvidia-ae432dc7-1", 7), "TUNE card=nvidia-ae432dc7-1 step=7 clock=2000 cap=100 limit=575 watts=250.0 mhs=123.00 eff=0.4920 gclk=1998 mclk=2505 tmax=70 mark=ok");
|
||||
assert_eq!(r.line("nvidia-ae432dc7-1", 7), "TUNE card=nvidia-ae432dc7-1 step=7 clock=2000 cap=100 mem=0 limit=575 watts=250.0 mhs=123.00 eff=0.4920 gclk=1998 mclk=2505 tmax=70 mark=ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fault_during_a_step_reverts_it_and_the_run_goes_on() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(4), apply: Duration::from_secs(30) };
|
||||
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0 };
|
||||
let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80 }, 1.0);
|
||||
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0 };
|
||||
let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
|
||||
let mut run = Run::new(0, "0", "c", plan, 240.0, false, timing, t0);
|
||||
let mut t = t0;
|
||||
let mut limit = 240.0;
|
||||
|
|
@ -919,13 +1109,13 @@ mod tests {
|
|||
#[test]
|
||||
fn the_confirm_plan_checks_the_prior_and_its_neighbour() {
|
||||
let l = l5090();
|
||||
let prior = Point { clock_mhz: 2472, power_pct: 100 };
|
||||
let plan = Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 80 }, 1.0);
|
||||
let prior = Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 };
|
||||
let plan = Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
|
||||
assert_eq!(plan.len(), 2);
|
||||
let a = plan.next(&[]).unwrap();
|
||||
assert_eq!((a.point, a.kind), (prior, Kind::Confirm));
|
||||
let b = plan.next(&[row_at(prior, 220.0, 123.5)]).unwrap();
|
||||
assert_eq!(b.point, Point { clock_mhz: 2781, power_pct: 100 }, "one clock step up");
|
||||
assert_eq!(b.point, Point { clock_mhz: 2781, power_pct: 100, mem_mhz: 0 }, "one clock step up");
|
||||
// the neighbour within 1%: the prior stands
|
||||
let rows = vec![row_at(prior, 220.0, 123.5), row_at(b.point, 250.0, 123.8)];
|
||||
assert!(matches!(confirm_verdict(&rows, 1.0), Verdict::Keep(r) if r.point == prior));
|
||||
|
|
@ -934,20 +1124,20 @@ mod tests {
|
|||
assert!(matches!(confirm_verdict(&rows, 1.0), Verdict::FullDue { .. }));
|
||||
assert_eq!(confirm_verdict(&[], 1.0), Verdict::NoReadings);
|
||||
// an unlocked prior at 100%: the neighbour is one power step down; at the top clock the neighbour is unlocked
|
||||
let plan = Plan::confirm(&l, Point { clock_mhz: 0, power_pct: 100 }, Point::default(), 1.0);
|
||||
assert_eq!(plan.next(&[row_at(Point { clock_mhz: 0, power_pct: 100 }, 1.0, 1.0)]).unwrap().point, Point { clock_mhz: 0, power_pct: 90 });
|
||||
let plan = Plan::confirm(&l, Point { clock_mhz: 2900, power_pct: 100 }, Point::default(), 1.0);
|
||||
assert_eq!(plan.next(&[row_at(Point { clock_mhz: 2900, power_pct: 100 }, 1.0, 1.0)]).unwrap().point.clock_mhz, 0);
|
||||
let plan = Plan::confirm(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Point::default(), 1.0);
|
||||
assert_eq!(plan.next(&[row_at(Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0, 1.0)]).unwrap().point, Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 });
|
||||
let plan = Plan::confirm(&l, Point { clock_mhz: 2900, power_pct: 100, mem_mhz: 0 }, Point::default(), 1.0);
|
||||
assert_eq!(plan.next(&[row_at(Point { clock_mhz: 2900, power_pct: 100, mem_mhz: 0 }, 1.0, 1.0)]).unwrap().point.clock_mhz, 0);
|
||||
// a prior outside the vendor's range is clamped, never applied as is
|
||||
let plan = Plan::confirm(&l, Point { clock_mhz: 9000, power_pct: 30 }, Point::default(), 1.0);
|
||||
assert_eq!(plan.next(&[]).unwrap().point, Point { clock_mhz: 3090, power_pct: 50 });
|
||||
let plan = Plan::confirm(&l, Point { clock_mhz: 9000, power_pct: 30, mem_mhz: 0 }, Point::default(), 1.0);
|
||||
assert_eq!(plan.next(&[]).unwrap().point, Point { clock_mhz: 3090, power_pct: 50, mem_mhz: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_plan_measures_the_card_as_it_runs() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(2), apply: Duration::from_secs(3) };
|
||||
let before = Point { clock_mhz: 0, power_pct: 80 };
|
||||
let before = Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 };
|
||||
let mut run = Run::new(0, "0", "c", Plan::baseline(&l5090(), before, 1.0), 460.0, false, timing, t0);
|
||||
// nothing acknowledges the request (Power control is off): the apply window passes and the hold starts anyway
|
||||
let mut t = t0;
|
||||
|
|
@ -979,8 +1169,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_record_and_the_prior_round_trip_through_the_manifest_shape() {
|
||||
let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100 }, 220.0, 123.5);
|
||||
let rec = record_json(1_791_230_000.0, "8f3a2c1d", "0.3.10", "windows", "NVIDIA GeForce RTX 5090", "nvidia", "581.57", "l128w16", PlanKind::Full, &[chosen.clone()], Some(&chosen), None);
|
||||
let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }, 220.0, 123.5);
|
||||
let rec = record_json(1_791_230_000.0, "8f3a2c1d", "0.3.10", "windows", "NVIDIA GeForce RTX 5090", "nvidia", "581.57", "l128w16", PlanKind::Full, &[chosen.clone()], Some(&chosen), None, false);
|
||||
assert_eq!(rec["floor"], false);
|
||||
assert_eq!(rec["card"], "NVIDIA_GeForce_RTX_5090");
|
||||
assert_eq!(rec["key"], "NVIDIA_GeForce_RTX_5090|581|l128w16");
|
||||
assert_eq!(rec["driver_major"], "581");
|
||||
|
|
@ -999,7 +1190,7 @@ mod tests {
|
|||
let s = settings_of(Some(&tuning));
|
||||
assert_eq!(s, Settings { enabled: true, min_samples: 5, tolerance_pct: 1.0, period_s: PERIOD_S });
|
||||
let p = prior_of(Some(&tuning), "NVIDIA_GeForce_RTX_5090|581|l128w16", s.min_samples).unwrap();
|
||||
assert_eq!(p.point, Point { clock_mhz: 2472, power_pct: 100 });
|
||||
assert_eq!(p.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 });
|
||||
assert_eq!(p.samples, 7);
|
||||
assert!(prior_of(Some(&tuning), "AMD_Radeon_RX_9070_XT|32|l128w16", 5).is_none(), "3 samples are under the floor");
|
||||
assert!(prior_of(Some(&tuning), "AMD_Radeon_RX_9070_XT|32|l128w16", 3).is_some());
|
||||
|
|
@ -1016,6 +1207,84 @@ mod tests {
|
|||
assert_eq!(program_class(0, 0), "v2");
|
||||
}
|
||||
|
||||
/// Ember 2: a synthetic memory-latency-bound card. Rate rises 1% per 100 MHz of memory above the default and
|
||||
/// falls only 0.3% per 100 MHz of core below the maximum; draw falls 8 W per 100 MHz of core and rises 2 W per
|
||||
/// 100 MHz of memory. The climb must walk memory up and core down and converge in under five probes.
|
||||
fn synthetic(p: Point) -> Row {
|
||||
let mem = if p.mem_mhz == 0 { 13801.0 } else { p.mem_mhz as f64 };
|
||||
let core = if p.clock_mhz == 0 { 3090.0 } else { p.clock_mhz as f64 };
|
||||
let mhs = 127.0 * (1.0 + 0.015 * (mem - 13801.0) / 100.0) * (1.0 - 0.003 * (3090.0 - core) / 100.0);
|
||||
let watts = 310.0 - 8.0 * (3090.0 - core) / 100.0 + 2.0 * (mem - 13801.0) / 100.0;
|
||||
Row { point: p, limit: 575.0, watts, mhs, eff: mhs / watts, draws: 12, rates: 6, gclk: core, mclk: mem, tmax: 65.0, faults: 0, mark: Some(Mark::Ok) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_climb_walks_memory_up_and_core_down_and_converges_in_five_probes() {
|
||||
// a 2 GHz memory range above the default (the headroom a 5090 has in practice; PC 1's driver reports 14,001)
|
||||
let l = Limits { mem_max_mhz: 15801, ..l5090() };
|
||||
let plan = Plan::climb(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Goal::Balanced, 1.0);
|
||||
assert_eq!(plan.len(), 5);
|
||||
let mut rows = Vec::new();
|
||||
while let Some(step) = plan.next(&rows) {
|
||||
assert_eq!(step.kind, Kind::Climb);
|
||||
rows.push(synthetic(step.point));
|
||||
}
|
||||
assert_eq!(rows.len(), 5, "the budget");
|
||||
assert_eq!(rows[0].point, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, "it starts at the start point");
|
||||
// every probe moved one knob the right way: memory never down, core never up, and both inside the vendor's range
|
||||
for w in rows.windows(2) {
|
||||
let (a, b) = (w[0].point, w[1].point);
|
||||
assert!(b.mem_mhz >= a.mem_mhz || b.mem_mhz == 0, "{a:?} -> {b:?}");
|
||||
assert!(b.mem_mhz <= 15801 && (b.clock_mhz == 0 || b.clock_mhz >= 1854), "{b:?}");
|
||||
}
|
||||
let best = choose(&rows, plan.tolerance_pct).unwrap();
|
||||
assert!(best.eff > rows[0].eff, "the chosen point beats the start: {:.4} > {:.4}", best.eff, rows[0].eff);
|
||||
assert!(best.point.mem_mhz > 13801 || (best.point.clock_mhz > 0 && best.point.clock_mhz < 3090), "it moved a knob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_probe_backs_that_knob_off_for_good() {
|
||||
let l = Limits { mem_max_mhz: 15801, ..l5090() };
|
||||
let plan = Plan::climb(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Goal::Balanced, 1.0);
|
||||
let mut rows = vec![synthetic(plan.next(&[]).unwrap().point)];
|
||||
// the first probe (memory up) draws a mismatched hash: refused
|
||||
let probe = plan.next(&rows).unwrap();
|
||||
assert!(probe.point.mem_mhz > 13801, "memory first: {:?}", probe.point);
|
||||
let mut bad = synthetic(probe.point);
|
||||
bad.faults = 1;
|
||||
bad.mark = Some(Mark::Faulted);
|
||||
rows.push(bad);
|
||||
// from here every probe leaves the memory clock alone
|
||||
while let Some(step) = plan.next(&rows) {
|
||||
assert_eq!(step.point.mem_mhz, 0, "memory backed off: {:?}", step.point);
|
||||
rows.push(synthetic(step.point));
|
||||
}
|
||||
assert!(rows.len() >= 3 && rows.len() <= 5);
|
||||
assert!(choose(&rows, 1.0).unwrap().point.mem_mhz == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_goal_picks_its_point() {
|
||||
let l = l5090();
|
||||
let p = |c: u32, m: u32| Point { clock_mhz: c, power_pct: 100, mem_mhz: m };
|
||||
// three points: the fastest (slightly less efficient), the most efficient (4% slower), and the start
|
||||
let rows = vec![synthetic(p(0, 0)), synthetic(p(0, 14001)), synthetic(p(1854, 0))];
|
||||
let eff_plan = Plan::climb(&l, p(0, 0), Goal::Efficiency, 1.0);
|
||||
let bal_plan = Plan::climb(&l, p(0, 0), Goal::Balanced, 1.0);
|
||||
let rate_plan = Plan::climb(&l, p(0, 0), Goal::MaxRate, 1.0);
|
||||
assert_eq!(eff_plan.tolerance_pct, 10.0);
|
||||
assert_eq!(bal_plan.tolerance_pct, 1.0);
|
||||
assert_eq!(rate_plan.tolerance_pct, 0.0);
|
||||
let by = |plan: &Plan| rows.iter().filter(|r| r.usable() && r.mhs >= rows.iter().map(|x| x.mhs).fold(0.0, f64::max) * (1.0 - plan.tolerance_pct / 100.0)).max_by(|a, b| plan.score(a).partial_cmp(&plan.score(b)).unwrap()).unwrap().point;
|
||||
assert_eq!(by(&rate_plan), p(0, 14001), "maximum rate takes the fastest point");
|
||||
assert_eq!(by(&eff_plan), p(1854, 0), "efficiency takes the most MH/W inside 10%");
|
||||
assert_eq!(by(&bal_plan), p(0, 14001), "balanced keeps within 1% of the top rate");
|
||||
assert_eq!(Goal::parse("efficiency"), Goal::Efficiency);
|
||||
assert_eq!(Goal::parse("rate"), Goal::MaxRate);
|
||||
assert_eq!(Goal::parse("anything"), Goal::Balanced);
|
||||
assert!((pounds_per_day(310.0, 28.5) - 2.1204).abs() < 1e-3, "310 W a day at 28.5 p/kWh = £2.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_reasons_per_vendor() {
|
||||
let l = l5090();
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ pub enum Cmd {
|
|||
SweepHelperDone(Result<(), String>),
|
||||
/// a tune request (by number) was carried out: what the vendor tool printed, or why it refused
|
||||
TuneSet(u64, Result<String, String>),
|
||||
/// POST /api/tune-progress from a measurement engine beside this app: the card's live tune state
|
||||
TuneProgress(Value),
|
||||
/// Ember 2: Settings > goal, electricity price, the hill-climb switch
|
||||
TuneGoal(Option<String>, Option<f64>, Option<bool>),
|
||||
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
|
||||
/// prover found a host that was not there when the node started
|
||||
RestartNode(String),
|
||||
|
|
@ -119,7 +123,7 @@ impl Shared {
|
|||
st.mining.accepted_total = settings.accepted_total;
|
||||
st.mining.fee_total = settings.fee_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, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust };
|
||||
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, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust };
|
||||
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
|
||||
st.live_page = packaged.live_page.clone();
|
||||
st.finality.message = "waiting for the miner".into();
|
||||
|
|
@ -1142,6 +1146,29 @@ impl Engine {
|
|||
self.shared.log("sweep helper: exited");
|
||||
}
|
||||
}
|
||||
Cmd::TuneProgress(v) => self.tune_progress(&v),
|
||||
Cmd::TuneGoal(goal, price, climb) => {
|
||||
{
|
||||
let mut s = self.shared.settings.lock().unwrap();
|
||||
if let Some(g) = &goal {
|
||||
s.tune_goal = g.clone();
|
||||
}
|
||||
if let Some(p) = price {
|
||||
s.power_price_pence = p;
|
||||
}
|
||||
if let Some(c) = climb {
|
||||
s.tune_climb = c;
|
||||
}
|
||||
}
|
||||
self.shared.save_settings();
|
||||
let s = self.shared.settings.lock().unwrap().clone();
|
||||
let mut st = self.st();
|
||||
st.settings.tune_goal = s.tune_goal.clone();
|
||||
st.settings.power_price_pence = s.power_price_pence;
|
||||
st.settings.tune_climb = s.tune_climb;
|
||||
drop(st);
|
||||
self.shared.event("info", &format!("tune goal: {}{}{}", s.tune_goal, if s.power_price_pence > 0.0 { format!(", electricity {:.1} p/kWh", s.power_price_pence) } else { String::new() }, if s.tune_climb { ", hill-climb on" } else { "" }));
|
||||
}
|
||||
Cmd::TuneSet(seq, r) => match r {
|
||||
Ok(text) => {
|
||||
self.tune_acked = Some(seq);
|
||||
|
|
@ -1818,7 +1845,8 @@ impl Engine {
|
|||
let dir = self.sweep_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let script = dir.join("register-power-task.ps1");
|
||||
match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&exe).as_bytes()].concat())) {
|
||||
let installed: Vec<PathBuf> = crate::powertask::install_candidates();
|
||||
match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)).as_bytes()].concat())) {
|
||||
Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()),
|
||||
_ => cmds.join(" & "),
|
||||
}
|
||||
|
|
@ -2148,6 +2176,7 @@ impl Engine {
|
|||
let mut st = self.st();
|
||||
st.settings.tuning_off = !ember.enabled;
|
||||
st.settings.tuning_note = if ember.enabled { String::new() } else { "tuning paused fleet-wide by the signed manifest".into() };
|
||||
st.settings.tune_period_s = ember.period_s;
|
||||
}
|
||||
if paused || !ember.enabled {
|
||||
return;
|
||||
|
|
@ -2235,16 +2264,19 @@ impl Engine {
|
|||
let current = if c.power_limit_w > 0.0 { c.power_limit_w } else { c.power_default_w }.round() as u64;
|
||||
let known_direct = self.sweep_direct;
|
||||
std::thread::spawn(move || {
|
||||
let q = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "--query-gpu=clocks.max.gr,driver_version", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)).unwrap_or_default();
|
||||
let q = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "--query-gpu=clocks.max.gr,driver_version,clocks.mem,clocks.max.mem", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)).unwrap_or_default();
|
||||
let p: Vec<&str> = q.trim().split(',').map(|s| s.trim()).collect();
|
||||
let clock_max = p.first().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32;
|
||||
let driver = p.get(1).map(|s| s.to_string()).unwrap_or_default();
|
||||
// Ember 2: the memory clock now (the default under load) and the vendor's maximum
|
||||
let mem_default = p.get(2).and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32;
|
||||
let mem_max = p.get(3).and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32;
|
||||
let direct = match known_direct {
|
||||
Some(d) => Some(d),
|
||||
None if allowed || current > 0 => crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-pl", ¤t.to_string()]), None, Duration::from_secs(20)).map(|out| out.contains("All done")),
|
||||
None => Some(false),
|
||||
};
|
||||
shared.send(Cmd::TuneProbe(idx, Ok(TuneProbe { clock_max_mhz: clock_max, clock_min_mhz: 0, driver, direct: direct.unwrap_or(false), amd_ordinal: -1, ..Default::default() })));
|
||||
shared.send(Cmd::TuneProbe(idx, Ok(TuneProbe { clock_max_mhz: clock_max, clock_min_mhz: 0, driver, direct: direct.unwrap_or(false), amd_ordinal: -1, mem_default_mhz: mem_default, mem_max_mhz: mem_max, ..Default::default() })));
|
||||
});
|
||||
}
|
||||
"amd" => {
|
||||
|
|
@ -2263,7 +2295,7 @@ impl Engine {
|
|||
// the stock clock, not MHz; a range with a negative floor is an offset range and the clock
|
||||
// knob stays closed until the stock clock is known (the power limit is the AMD lever), and
|
||||
// `plimit_range -30 10` bounds the power ladder (the percent scale rides power_* below)
|
||||
Some(t) if t.ok => TuneProbe { clock_max_mhz: if t.gmax_min >= 0.0 && t.gmax_max > 0.0 { t.gmax_max as u32 } else { 0 }, clock_min_mhz: if t.gmax_min > 0.0 { t.gmax_min as u32 } else { 0 }, driver, direct: true, amd_ordinal: t.ordinal as i64, plimit_min: t.plimit_min, plimit_max: t.plimit_max },
|
||||
Some(t) if t.ok => TuneProbe { clock_max_mhz: if t.gmax_min >= 0.0 && t.gmax_max > 0.0 { t.gmax_max as u32 } else { 0 }, clock_min_mhz: if t.gmax_min > 0.0 { t.gmax_min as u32 } else { 0 }, driver, direct: true, amd_ordinal: t.ordinal as i64, plimit_min: t.plimit_min, plimit_max: t.plimit_max, mem_default_mhz: 0, mem_max_mhz: 0 },
|
||||
Some(t) => TuneProbe { clock_max_mhz: 0, clock_min_mhz: 0, driver, direct: false, amd_ordinal: t.ordinal as i64, ..Default::default() },
|
||||
None => TuneProbe { clock_max_mhz: 0, clock_min_mhz: 0, driver, direct: false, amd_ordinal: -1, ..Default::default() },
|
||||
})));
|
||||
|
|
@ -2295,13 +2327,15 @@ impl Engine {
|
|||
};
|
||||
// NVIDIA control: this process is elevated (direct), or Power control is on so the one-prompt helper may run.
|
||||
// The --sweep job alone never counts: it must not raise a prompt on a PC with nobody there (5 October 2026).
|
||||
let power_control = probe.direct || self.shared.settings.lock().unwrap().power_control;
|
||||
// a registered Igneum Power Helper task (src/powertask.rs) is permission too: it sets limits with no prompt,
|
||||
// so an unelevated measurement engine (Power control forced off) still controls NVIDIA through it
|
||||
let power_control = probe.direct || self.shared.settings.lock().unwrap().power_control || (cfg!(windows) && self.power_task_registered());
|
||||
// AMD's power limit is a percent offset from the default (ADLX): the plan's watts scale becomes a percent
|
||||
// scale (default 100, floor 100 + plimit_min, ceiling 100 + plimit_max), tune_apply sends pct - 100
|
||||
let limits = if c.vendor == "amd" && probe.direct && probe.plimit_max >= probe.plimit_min && probe.plimit_min > -100.0 {
|
||||
crate::ember::Limits { power_default_w: 100.0, power_min_w: 100.0 + probe.plimit_min, power_max_w: 100.0 + probe.plimit_max, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz }
|
||||
crate::ember::Limits { power_default_w: 100.0, power_min_w: 100.0 + probe.plimit_min, power_max_w: 100.0 + probe.plimit_max, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz, mem_default_mhz: 0, mem_max_mhz: 0 }
|
||||
} else {
|
||||
crate::ember::Limits { power_default_w: c.power_default_w, power_min_w: c.power_min_w, power_max_w: c.power_max_w, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz }
|
||||
crate::ember::Limits { power_default_w: c.power_default_w, power_min_w: c.power_min_w, power_max_w: c.power_max_w, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz, mem_default_mhz: probe.mem_default_mhz, mem_max_mhz: probe.mem_max_mhz }
|
||||
};
|
||||
let control = match c.vendor.as_str() {
|
||||
"nvidia" => crate::ember::control_reason("nvidia", &limits, &c.device, power_control, false),
|
||||
|
|
@ -2310,6 +2344,26 @@ impl Engine {
|
|||
};
|
||||
if c.vendor == "nvidia" {
|
||||
self.sweep_direct = Some(probe.direct);
|
||||
// the approved step that made this engine elevated registers the Igneum Power Helper here too (run 5,
|
||||
// 6 October 2026: a --sweep engine skipped the cap path where the registration lived, so the one
|
||||
// approved click registered nothing); no prompt: this process already holds the rights
|
||||
if probe.direct && cfg!(windows) && !self.power_task_registered() {
|
||||
let dir = self.sweep_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let script = dir.join("register-power-task.ps1");
|
||||
let installed: Vec<PathBuf> = crate::powertask::install_candidates();
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let target = crate::powertask::task_exe(&exe, &installed);
|
||||
if std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&target).as_bytes()].concat()).is_ok() {
|
||||
let mut p = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]);
|
||||
crate::platform::quiet(&mut p);
|
||||
let ok = p.status().map(|s| s.success()).unwrap_or(false);
|
||||
self.power_task = None;
|
||||
self.sweep_say(&format!("TUNE helper registered={} action={}", ok, target.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut st = self.st();
|
||||
|
|
@ -2327,16 +2381,24 @@ impl Engine {
|
|||
}
|
||||
let tuning = self.tuning_object();
|
||||
let ember = crate::ember::settings_of(tuning.as_ref());
|
||||
let before = crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: if c.power_pct == 0 { 80 } else { c.power_pct } };
|
||||
let before = crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: if c.power_pct == 0 { 80 } else { c.power_pct }, mem_mhz: c.mem_cap_mhz };
|
||||
let before_w = if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) };
|
||||
let key = crate::ember::prior_key(&c.name, &probe.driver, &c.program_class);
|
||||
let prior = crate::ember::prior_of(tuning.as_ref(), &key, ember.min_samples);
|
||||
let full_due = self.tune_full_due.remove(&idx);
|
||||
let (goal, climb_on) = {
|
||||
let s = self.shared.settings.lock().unwrap();
|
||||
(crate::ember::Goal::parse(&s.tune_goal), s.tune_climb)
|
||||
};
|
||||
let plan = if let Some(why) = control.as_ref() {
|
||||
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
|
||||
cc.sweep_note = why.clone();
|
||||
}
|
||||
crate::ember::Plan::baseline(&limits, before, ember.tolerance_pct)
|
||||
} else if climb_on || prior.as_ref().map(|p| p.point.mem_mhz > 0).unwrap_or(false) {
|
||||
// Ember 2: the hill-climb from the fleet prior (or the card's point) toward the goal
|
||||
let start = prior.as_ref().map(|p| p.point).unwrap_or(before);
|
||||
crate::ember::Plan::climb(&limits, start, goal, ember.tolerance_pct)
|
||||
} else if let (Some(p), false, 0) = (prior.as_ref(), full_due, c.sweep_pct) {
|
||||
crate::ember::Plan::confirm(&limits, p.point, before, ember.tolerance_pct)
|
||||
} else {
|
||||
|
|
@ -2380,6 +2442,7 @@ impl Engine {
|
|||
crate::ember::PlanKind::Full => format!("{steps} steps over the power limit and the core clock, {} s each on the live program", (run.timing.settle + run.timing.hold).as_secs()),
|
||||
crate::ember::PlanKind::Confirm => format!("the fleet prior ({} samples) and one neighbour, {} s each", prior.as_ref().map(|p| p.samples).unwrap_or(0), (run.timing.settle + run.timing.hold).as_secs()),
|
||||
crate::ember::PlanKind::Baseline => format!("measuring the card as it runs ({})", control.clone().unwrap_or_default()),
|
||||
crate::ember::PlanKind::Climb => format!("the hill-climb: memory up, core down, up to {steps} probes of {} s", (run.timing.settle + run.timing.hold).as_secs()),
|
||||
};
|
||||
self.shared.event("info", &format!("{}: tuning started: {what}", c.name));
|
||||
self.sweep = Some(run);
|
||||
|
|
@ -2388,7 +2451,8 @@ impl Engine {
|
|||
|
||||
/// 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> {
|
||||
if !self.shared.settings.lock().unwrap().power_control {
|
||||
let task = cfg!(windows) && self.power_task_registered();
|
||||
if !task && !self.shared.settings.lock().unwrap().power_control {
|
||||
return Err("Power control is off in Settings".into());
|
||||
}
|
||||
let dir = self.sweep_dir();
|
||||
|
|
@ -2446,6 +2510,7 @@ impl Engine {
|
|||
let Some(c) = card else { return };
|
||||
let w = step.watts.round() as u64;
|
||||
let clock = step.point.clock_mhz;
|
||||
let mem = step.point.mem_mhz;
|
||||
let shared = self.shared.clone();
|
||||
self.tune_acked = None;
|
||||
match c.vendor.as_str() {
|
||||
|
|
@ -2469,6 +2534,15 @@ impl Engine {
|
|||
ok &= out.contains("All done") || out.to_ascii_lowercase().contains("clocks set") || out.to_ascii_lowercase().contains("reset");
|
||||
text.push(' ');
|
||||
text.push_str(out.trim());
|
||||
// Ember 2: the memory clock, locked to one value (`-lmc m,m`) or reset (`-rmc`)
|
||||
let out = if mem > 0 {
|
||||
crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-lmc", &format!("{mem},{mem}")]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into())
|
||||
} else {
|
||||
crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-rmc"]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into())
|
||||
};
|
||||
ok &= out.contains("All done") || out.to_ascii_lowercase().contains("clocks set") || out.to_ascii_lowercase().contains("reset") || out.to_ascii_lowercase().contains("not supported");
|
||||
text.push(' ');
|
||||
text.push_str(out.trim());
|
||||
shared.send(Cmd::TuneSet(seq, if ok { Ok(text) } else { Err(text) }));
|
||||
});
|
||||
}
|
||||
|
|
@ -2478,7 +2552,7 @@ impl Engine {
|
|||
return;
|
||||
}
|
||||
let dev = device.to_string();
|
||||
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() });
|
||||
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
|
||||
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd);
|
||||
// the helper polls twice a second and nvidia-smi answers within a second or two
|
||||
std::thread::spawn(move || {
|
||||
|
|
@ -2520,7 +2594,50 @@ impl Engine {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
self.shared.log(&format!("tune: {} MHz, {}% ({w} W) requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct));
|
||||
self.shared.log(&format!("tune: {} MHz, {}% ({w} W), memory {} requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct, if mem > 0 { format!("{mem} MHz") } else { "default".into() }));
|
||||
}
|
||||
|
||||
/// A measurement engine's progress for one card (POST /api/tune-progress, forwarded by the job playbook from the
|
||||
/// engine's `TUNE progress` lines): the installed app shows the step, the live rate and draw and the time left
|
||||
/// instead of a bare "off" while a job holds its miners. `done` ends it: the row reads the tuned line.
|
||||
fn tune_progress(&mut self, v: &Value) {
|
||||
let key = v.get("key").and_then(|k| k.as_str()).unwrap_or("");
|
||||
let mut st = self.st();
|
||||
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == key || (!key.is_empty() && c.name.replace(' ', "_") == key)) else { return };
|
||||
let n = |k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||
if v.get("done").and_then(|d| d.as_bool()).unwrap_or(false) {
|
||||
c.sweep_state = "idle".into();
|
||||
c.state = if self.job_hold { "held".into() } else { c.state.clone() };
|
||||
c.tune_step = 0;
|
||||
c.tune_steps = 0;
|
||||
c.tune_eta_s = 0;
|
||||
if n("mhs") > 0.0 && n("watts") > 0.0 {
|
||||
c.sweep_mhs = n("mhs");
|
||||
c.sweep_watts = n("watts");
|
||||
c.sweep_eff = n("mhs") / n("watts");
|
||||
c.sweep_pct = n("power_pct") as u32;
|
||||
c.tune_clock_mhz = n("clock_mhz") as u32;
|
||||
c.tune_source = v.get("plan").and_then(|p| p.as_str()).unwrap_or("full").into();
|
||||
c.tune_line = crate::ember::tuned_line(n("mhs"), n("watts"), n("mhs") / n("watts"));
|
||||
c.sweep_at = crate::platform::unix_now_f();
|
||||
}
|
||||
c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
return;
|
||||
}
|
||||
c.state = "tuning".into();
|
||||
c.sweep_state = "running".into();
|
||||
c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("tuning").to_string();
|
||||
c.tune_step = n("step") as u32;
|
||||
c.tune_steps = n("of") as u32;
|
||||
c.tune_eta_s = n("eta_s") as i64;
|
||||
c.tune_plan = v.get("plan").and_then(|p| p.as_str()).unwrap_or("").into();
|
||||
if n("mhs") > 0.0 {
|
||||
c.hash_now = n("mhs");
|
||||
}
|
||||
if n("watts") > 0.0 {
|
||||
c.power_w = n("watts");
|
||||
}
|
||||
c.message = String::new();
|
||||
}
|
||||
|
||||
/// Faults and conditions first, then the state machine.
|
||||
|
|
@ -2538,7 +2655,7 @@ impl Engine {
|
|||
Some("a remote job took the GPU".into())
|
||||
} else if self.st().mining.paused {
|
||||
Some("mining paused".into())
|
||||
} else if c.state != "mining" {
|
||||
} else if c.state != "mining" && c.state != "tuning" {
|
||||
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())
|
||||
|
|
@ -2587,9 +2704,20 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(words) = self.sweep.as_ref().map(|r| r.words(now)) {
|
||||
if let Some((words, step, of, eta, plan)) = self.sweep.as_ref().map(|r| (r.words(now), r.rows.len() as u32 + 1, r.plan.len() as u32, r.eta_s(now), r.plan.kind.name())) {
|
||||
let (key, mhs, watts) = self.st().mining.cards.get(idx).map(|c| (c.key.clone(), c.hash_now, c.power_w)).unwrap_or_default();
|
||||
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
|
||||
cc.sweep_note = words;
|
||||
cc.sweep_note = words.clone();
|
||||
cc.state = if cc.state == "mining" { "tuning".into() } else { cc.state.clone() };
|
||||
cc.tune_step = step;
|
||||
cc.tune_steps = of;
|
||||
cc.tune_eta_s = eta;
|
||||
cc.tune_plan = plan.into();
|
||||
}
|
||||
if self.shared.runtime.sweep_only {
|
||||
// the job playbook forwards this to the installed app's /api/tune-progress
|
||||
println!("TUNE progress key={} step={step} of={of} eta_s={eta} mhs={mhs:.2} watts={watts:.1} plan={plan} note={}", key.replace(' ', "_"), words.replace(' ', "_"));
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2612,9 +2740,17 @@ impl Engine {
|
|||
c.sweep_mhs = row.mhs;
|
||||
c.sweep_at = unix as f64;
|
||||
c.sweep_state = "idle".into();
|
||||
if c.state == "tuning" {
|
||||
c.state = "mining".into();
|
||||
}
|
||||
c.tune_step = 0;
|
||||
c.tune_steps = 0;
|
||||
c.tune_eta_s = 0;
|
||||
c.tune_clock_mhz = row.point.clock_mhz;
|
||||
c.tune_mem_mhz = row.point.mem_mhz;
|
||||
c.tune_source = kind.name().into();
|
||||
c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff);
|
||||
c.tune_curve = run.rows.iter().map(|r| r.json()).collect();
|
||||
let control = c.tune_control;
|
||||
if kind == crate::ember::PlanKind::Baseline {
|
||||
c.sweep_note = if control { String::new() } else { c.sweep_note.clone() };
|
||||
|
|
@ -2623,6 +2759,7 @@ impl Engine {
|
|||
} else {
|
||||
c.power_pct = row.point.power_pct;
|
||||
c.clock_cap_mhz = row.point.clock_mhz;
|
||||
c.mem_cap_mhz = row.point.mem_mhz;
|
||||
if c.vendor == "nvidia" {
|
||||
c.power_limit_w = row.limit;
|
||||
c.power_applied = true;
|
||||
|
|
@ -2637,6 +2774,7 @@ impl Engine {
|
|||
e.sweep_watts = row.watts;
|
||||
e.sweep_mhs = row.mhs;
|
||||
e.sweep_clock_mhz = row.point.clock_mhz;
|
||||
e.sweep_mem_mhz = row.point.mem_mhz;
|
||||
e.sweep_driver = c.driver.clone();
|
||||
e.sweep_class = c.program_class.clone();
|
||||
e.sweep_source = kind.name().into();
|
||||
|
|
@ -2647,9 +2785,12 @@ impl Engine {
|
|||
};
|
||||
let _ = key;
|
||||
self.shared.save_settings();
|
||||
self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" }));
|
||||
// 6 October 2026, run 6: the 5090's chosen clock sat on the ladder's floor (1,854 MHz = the old 60%), so a
|
||||
// chosen point at the floor says so: it is the lowest step measured, not the optimum (Ember 2's climb walks on)
|
||||
let floor = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor();
|
||||
self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.point.mem_mhz, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" }, if floor { " floor=1 note=floor,_not_optimum" } else { "" }));
|
||||
let before = run.rows.first().filter(|_| kind == crate::ember::PlanKind::Full).cloned();
|
||||
let record = crate::ember::record_json(crate::platform::unix_now_f(), &crate::config::fingerprint8(&self.shared.runtime.machine_id), VERSION, crate::manifest::platform_name(), &name, &vendor, &driver, &class, kind, &run.rows, Some(&row), before.as_ref());
|
||||
let record = crate::ember::record_json(crate::platform::unix_now_f(), &crate::config::fingerprint8(&self.shared.runtime.machine_id), VERSION, crate::manifest::platform_name(), &name, &vendor, &driver, &class, kind, &run.rows, Some(&row), before.as_ref(), floor);
|
||||
self.sweep_say(&format!("TUNE {record}"));
|
||||
let line = crate::ember::tuned_line(row.mhs, row.watts, row.eff);
|
||||
match kind {
|
||||
|
|
@ -2658,7 +2799,7 @@ impl Engine {
|
|||
_ => self.shared.event("ok", &format!("{name}: {line}, {} held", point_words(&row.point))),
|
||||
}
|
||||
if pinned && kind != crate::ember::PlanKind::Baseline {
|
||||
let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct }).unwrap_or(run.before);
|
||||
let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz }).unwrap_or(run.before);
|
||||
let w = self.st().mining.cards.get(idx).map(requested_watts).unwrap_or(run.before_w);
|
||||
self.tune_apply(idx, &run.device, &crate::ember::Step { point: p, watts: w, kind: crate::ember::Kind::Confirm });
|
||||
}
|
||||
|
|
@ -2686,7 +2827,7 @@ impl Engine {
|
|||
(Some(r), _) => (r.card, r.label.clone(), r.device.clone(), r.before, r.before_w, r.forced, r.seq > 0 && r.plan.kind != crate::ember::PlanKind::Baseline),
|
||||
(None, Some((idx, forced))) => {
|
||||
let c = self.st().mining.cards.get(idx).cloned();
|
||||
let (label, device, w, p) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct })).unwrap_or_default();
|
||||
let (label, device, w, p) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz })).unwrap_or_default();
|
||||
(idx, label, device, p, w, forced, false)
|
||||
}
|
||||
_ => return,
|
||||
|
|
@ -2697,6 +2838,12 @@ impl Engine {
|
|||
match st.mining.cards.get_mut(idx) {
|
||||
Some(c) => {
|
||||
c.sweep_state = "idle".into();
|
||||
if c.state == "tuning" {
|
||||
c.state = "mining".into();
|
||||
}
|
||||
c.tune_step = 0;
|
||||
c.tune_steps = 0;
|
||||
c.tune_eta_s = 0;
|
||||
c.sweep_note = format!("tuning stopped: {why}");
|
||||
if touched {
|
||||
c.power_pct = before.power_pct;
|
||||
|
|
@ -2870,6 +3017,13 @@ impl Engine {
|
|||
}
|
||||
if self.job_hold && !self.jobs.holds_miners() {
|
||||
self.job_hold = false;
|
||||
for c in self.st().mining.cards.iter_mut().filter(|c| c.state == "held" || c.state == "tuning") {
|
||||
c.state = "off".into();
|
||||
c.sweep_state = if c.sweep_state == "running" { "idle".into() } else { c.sweep_state.clone() };
|
||||
c.tune_step = 0;
|
||||
c.tune_steps = 0;
|
||||
c.tune_eta_s = 0;
|
||||
}
|
||||
if self.running {
|
||||
self.shared.event("info", "job finished; the miners restart");
|
||||
for m in self.miners.iter_mut() {
|
||||
|
|
@ -2886,7 +3040,9 @@ impl Engine {
|
|||
self.job_hold = true;
|
||||
self.stop_miners(&why);
|
||||
for c in self.st().mining.cards.iter_mut().filter(|c| c.enabled) {
|
||||
c.message = "stopped for a remote job".into();
|
||||
// never a bare "off" at 0 MH/s: the row says why (a job holds the card)
|
||||
c.state = "held".into();
|
||||
c.message = format!("held for a remote job: {}", short(&why, 80));
|
||||
}
|
||||
self.jobs.miners_stopped(&self.shared);
|
||||
}
|
||||
|
|
@ -3948,6 +4104,9 @@ pub struct TuneProbe {
|
|||
/// AMD: the power offset range in percent from the `tune` line (PC 1's 9070 XT: -30 to 10)
|
||||
pub plimit_min: f64,
|
||||
pub plimit_max: f64,
|
||||
/// Ember 2, NVIDIA: the memory clock under load and the vendor's maximum (nvidia-smi clocks.mem, clocks.max.mem)
|
||||
pub mem_default_mhz: u32,
|
||||
pub mem_max_mhz: u32,
|
||||
}
|
||||
|
||||
/// One `tune` line of igneum-gpu-telemetry --tune:
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
|||
c.sweep_at = p.sweep_at as f64;
|
||||
// Ember Tune (src/ember.rs): the clock cap the last tune chose, its plan, and the row's Tuned line
|
||||
c.tune_clock_mhz = p.sweep_clock_mhz;
|
||||
c.tune_mem_mhz = p.sweep_mem_mhz;
|
||||
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
|
||||
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
|
||||
c.tune_source = p.sweep_source.clone();
|
||||
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
|
||||
|
|
|
|||
|
|
@ -430,11 +430,21 @@ pub fn sync_clock() -> Result<String, String> {
|
|||
|
||||
/// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows.
|
||||
/// Blocking; call from a thread.
|
||||
/// The cmd.exe argument for one elevated line. cmd's documented rule: when the text after /c starts with a quote
|
||||
/// and holds more than two quotes (two cards, or a cap plus a script), it strips the FIRST and LAST quote and runs
|
||||
/// the broken remainder (PC 1, 6 October 2026, 15:45Z: the one approved Power control step ran
|
||||
/// `"...\nvidia-smi.exe" -i 0 -pl 460 & "...\nvidia-smi.exe" -i 1 -pl 160 & "...\powershell.exe" ... -File "...ps1"`,
|
||||
/// exit 1, no cap applied, no task registered; a single-card PC, two quotes, was fine, which is why PC 2 never
|
||||
/// showed it). Wrapping the whole line in one outer pair makes cmd strip exactly those.
|
||||
pub fn cmd_c_args(cmdline: &str) -> String {
|
||||
format!("/c \"{cmdline}\"")
|
||||
}
|
||||
|
||||
pub fn run_elevated(cmdline: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let mut c = elevated_command(&cmd, &format!("/c {cmdline}"));
|
||||
let mut c = elevated_command(&cmd, &cmd_c_args(cmdline));
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
|
|
@ -511,6 +521,20 @@ pub fn quiet(cmd: &mut Command) -> &mut Command {
|
|||
cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cmd_tests {
|
||||
#[test]
|
||||
fn an_elevated_line_is_wrapped_so_cmd_keeps_every_inner_quote() {
|
||||
let line = r#""C:\WINDOWS\System32\nvidia-smi.exe" -i 0 -pl 460 & "C:\WINDOWS\System32\nvidia-smi.exe" -i 1 -pl 160 & "C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "C:\x\register-power-task.ps1""#;
|
||||
let a = super::cmd_c_args(line);
|
||||
assert!(a.starts_with("/c \"\"C:\\WINDOWS"), "{a}");
|
||||
assert!(a.ends_with("register-power-task.ps1\"\""), "{a}");
|
||||
// the inner line is intact between the outer pair
|
||||
assert_eq!(&a[4..a.len() - 1], line);
|
||||
assert_eq!(super::cmd_c_args("echo hi"), "/c \"echo hi\"");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_tests {
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -43,8 +43,14 @@ pub enum HelperCmd {
|
|||
PowerLimit(u64),
|
||||
ClockCap(u64),
|
||||
ClockReset,
|
||||
/// Ember 2: the memory clock locked to one value (`-lmc m,m`), or reset (`-rmc`)
|
||||
MemClock(u64),
|
||||
MemReset,
|
||||
Quit,
|
||||
Remove,
|
||||
/// re-point the task at the installed exe (no path argument: the helper finds the install folder itself, so a
|
||||
/// writer of cmd.txt can never choose what runs elevated); 6 October 2026, run 6 registered a scratch copy
|
||||
Reregister,
|
||||
}
|
||||
|
||||
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
|
||||
|
|
@ -65,7 +71,10 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
|
|||
[_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
|
||||
[_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))),
|
||||
[_, "rgc"] => Some((seq, HelperCmd::ClockReset)),
|
||||
[_, "lmc", m] if digits(m) => Some((seq, HelperCmd::MemClock(m.parse().ok()?))),
|
||||
[_, "rmc"] => Some((seq, HelperCmd::MemReset)),
|
||||
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
|
||||
[_, "reregister"] => Some((seq, HelperCmd::Reregister)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -76,10 +85,47 @@ pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
|||
HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]),
|
||||
HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]),
|
||||
HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]),
|
||||
HelperCmd::MemClock(m) => Some(vec!["-i".into(), dev.into(), "-lmc".into(), format!("{m},{m}")]),
|
||||
HelperCmd::MemReset => Some(vec!["-i".into(), dev.into(), "-rmc".into()]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable the task should point at: the running one, unless it runs out of a jobs folder (a measurement
|
||||
/// kit a job fetched: `<app data>\app\jobs\<id>\igneum-app-ember.exe`), in which case the installed app's own
|
||||
/// exe beside it is the durable target (6 October 2026: the first registration on PC 1 came from such a kit).
|
||||
pub fn task_exe(running: &Path, install_candidates: &[PathBuf]) -> PathBuf {
|
||||
// the running exe stands only when it is itself an install candidate (either separator, any case: the path
|
||||
// comes from Windows, the tests run on the Mac too); a kit under jobs\ or a measurement run's scratch copy
|
||||
// (run 6, 6 October 2026: igneum-tune-<stamp>\bin\igneum-app.exe) never becomes the task's action when an
|
||||
// installed exe exists
|
||||
let norm = |p: &Path| p.to_string_lossy().replace('/', "\\").to_ascii_lowercase();
|
||||
let me = norm(running);
|
||||
if install_candidates.iter().any(|p| norm(p) == me) {
|
||||
return running.to_path_buf();
|
||||
}
|
||||
if let Some(p) = install_candidates.iter().find(|p| p.is_file()) {
|
||||
return p.clone();
|
||||
}
|
||||
running.to_path_buf()
|
||||
}
|
||||
|
||||
/// Where the installed exe lives: the per-user install, then the old administrator install.
|
||||
pub fn install_candidates() -> Vec<PathBuf> {
|
||||
[
|
||||
std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")),
|
||||
std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The PowerShell that prints the task's action (what `reregister` is proven by).
|
||||
pub fn readback_command() -> String {
|
||||
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t) {{ Write-Output ($t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments) }}; exit 0")
|
||||
}
|
||||
|
||||
/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this
|
||||
/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no
|
||||
/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs.
|
||||
|
|
@ -174,6 +220,26 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
return if ok { 0 } else { 1 };
|
||||
}
|
||||
_ if seq <= last_seq => continue,
|
||||
HelperCmd::Reregister => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
let Some(target) = install_candidates().into_iter().find(|p| p.is_file()) else {
|
||||
log(&format!("{seq} reregister: no installed exe found"));
|
||||
continue;
|
||||
};
|
||||
let script = dir.join("register-power-task.ps1");
|
||||
let ok = std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), register_script(&target).as_bytes()].concat()).is_ok() && {
|
||||
let mut p = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]);
|
||||
crate::platform::quiet(&mut p);
|
||||
p.status().map(|s| s.success()).unwrap_or(false)
|
||||
};
|
||||
let mut q = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
q.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &readback_command()]);
|
||||
crate::platform::quiet(&mut q);
|
||||
let now = q.output().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
||||
log(&format!("{seq} reregister {}: the task now runs {now}", if ok { "ok" } else { "failed" }));
|
||||
}
|
||||
HelperCmd::Dev(d) => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
|
|
@ -215,10 +281,18 @@ mod tests {
|
|||
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
|
||||
assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472))));
|
||||
assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset)));
|
||||
assert_eq!(parse_line("10 lmc 14001"), Some((10, HelperCmd::MemClock(14001))));
|
||||
assert_eq!(parse_line("11 rmc"), Some((11, HelperCmd::MemReset)));
|
||||
assert_eq!(smi_args("0", &HelperCmd::MemClock(14001)).unwrap(), vec!["-i", "0", "-lmc", "14001,14001"]);
|
||||
assert_eq!(smi_args("0", &HelperCmd::MemReset).unwrap(), vec!["-i", "0", "-rmc"]);
|
||||
assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into()))));
|
||||
assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form");
|
||||
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
||||
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
||||
assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister)));
|
||||
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
|
||||
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
|
||||
assert!(readback_command().contains("Actions[0].Execute"));
|
||||
// nothing else: no shell, no path, no string argument, no oversized number
|
||||
for bad in ["7 pl 460; calc", "7 pl -460", "7 pl 4.60", "7 lgc 0,2472", "7 rm C:\\x", "x pl 460", "7 pl", "7 lgc 12345678", "7 dev ../1", "", "7 pl 460 extra"] {
|
||||
assert_eq!(parse_line(bad), None, "{bad:?}");
|
||||
|
|
@ -253,6 +327,24 @@ mod tests {
|
|||
assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kit_run_from_a_jobs_folder_registers_the_installed_exe() {
|
||||
let kit = Path::new(r"C:\Users\Admin\AppData\Local\igneum\app\jobs\ember-kit-5\igneum-app-ember.exe");
|
||||
let own = Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe");
|
||||
// no installed candidate exists on this machine: the running exe stands
|
||||
assert_eq!(task_exe(kit, &[PathBuf::from(r"C:\nonexistent\igneum-app.exe")]), kit.to_path_buf());
|
||||
assert_eq!(task_exe(own, &[]), own.to_path_buf());
|
||||
// an installed candidate that exists (the test binary stands in for it) wins for a kit, not for the installed exe
|
||||
let here = std::env::current_exe().unwrap();
|
||||
assert!(here.is_file());
|
||||
assert_eq!(task_exe(kit, &[here.clone()]), here);
|
||||
// a measurement run's scratch copy (run 6, 6 October 2026) is not under jobs\ and still yields
|
||||
let scratch = Path::new(r"C:\Users\Admin\AppData\Local\igneum-tune-20261006-170105\bin\igneum-app.exe");
|
||||
assert_eq!(task_exe(scratch, &[here.clone()]), here);
|
||||
// the installed exe itself stands (matched by path, any case or separator)
|
||||
assert_eq!(task_exe(own, &[PathBuf::from(r"c:/users/admin/appdata/local/programs/igneum miner/IGNEUM-APP.EXE"), here]), own.to_path_buf());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_command_file_does_not_run_at_start() {
|
||||
// the helper's start reads the highest sequence already in the file and runs nothing below or at it
|
||||
|
|
|
|||
|
|
@ -251,6 +251,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::ApplyCards(choices));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/tune/goal" => {
|
||||
// Ember 2: the goal, the electricity price (pence per kWh) and the hill-climb switch
|
||||
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
|
||||
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
|
||||
let climb = body.get("climb").and_then(|v| v.as_bool());
|
||||
shared.send(Cmd::TuneGoal(goal, price, climb));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/settings" => {
|
||||
let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32);
|
||||
let vote = body.get("vote").and_then(|v| v.as_bool());
|
||||
|
|
@ -325,6 +333,11 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::PowerControl(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// a measurement engine's live tune state for one card (forwarded by its job playbook): the row shows the step
|
||||
"/api/tune-progress" => {
|
||||
shared.send(Cmd::TuneProgress(body));
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ pub struct CardState {
|
|||
pub clock_min_mhz: u32, // the vendor's floor for a cap (0 = 60% of the maximum)
|
||||
pub gclk_mhz: f64, // core clock now
|
||||
pub clock_cap_mhz: u32, // the cap in force (0 = unlocked)
|
||||
pub mem_cap_mhz: u32, // Ember 2: the memory clock set by the tune (0 = the driver's default)
|
||||
pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown)
|
||||
pub driver: String, // the driver version (nvidia-smi, or the worker's race line)
|
||||
pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown
|
||||
|
|
@ -125,6 +126,15 @@ pub struct CardState {
|
|||
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
|
||||
pub tune_source: String, // full | confirm | baseline
|
||||
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
|
||||
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
|
||||
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
|
||||
pub tune_step: u32,
|
||||
pub tune_steps: u32,
|
||||
pub tune_eta_s: i64,
|
||||
pub tune_plan: String,
|
||||
// Ember 2: the memory clock the last tune chose and the measured curve (every row of the last plan)
|
||||
pub tune_mem_mhz: u32,
|
||||
pub tune_curve: Vec<serde_json::Value>,
|
||||
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
|
||||
pub variant: String,
|
||||
pub race_mhs: f64,
|
||||
|
|
@ -283,6 +293,13 @@ pub struct SettingsState {
|
|||
/// Ember Tune is paused fleet-wide by the signed manifest's kill switch (tuning.ember.enabled = false)
|
||||
pub tuning_off: bool,
|
||||
pub tuning_note: String,
|
||||
/// Ember 2: the goal (efficiency | balanced | rate), the electricity price in pence per kWh, the hill-climb switch
|
||||
pub tune_goal: String,
|
||||
pub power_price_pence: f64,
|
||||
pub tune_climb: bool,
|
||||
/// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at
|
||||
/// sweep_at + tune_period_s, or at once after a driver major or program-class change
|
||||
pub tune_period_s: u64,
|
||||
/// the miner software's dev fee switch (settings; `--dev-fee 0` when off)
|
||||
pub dev_fee: bool,
|
||||
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
|
||||
|
|
|
|||
|
|
@ -375,6 +375,12 @@ while ($true) {
|
|||
} elseif ($op -eq 'rgc') {
|
||||
$out = (& $Smi -i $Device -rgc 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) $($p[0]) -rgc : $out" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
} elseif ($op -eq 'lmc' -and $v -match '^\d+$') {
|
||||
$out = (& $Smi -i $Device -lmc "$v,$v" 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) $($p[0]) -lmc $v,$v : $out" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
} elseif ($op -eq 'rmc') {
|
||||
$out = (& $Smi -i $Device -rmc 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) $($p[0]) -rmc : $out" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -411,6 +417,8 @@ while true; do
|
|||
pl) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -pl $v : $("$smi" -i "$dev" -pl "$v" 2>&1)" >> "$dir/helper.log";; esac ;;
|
||||
lgc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lgc 0,$v : $("$smi" -i "$dev" -lgc "0,$v" 2>&1)" >> "$dir/helper.log";; esac ;;
|
||||
rgc) echo "$(date -u +%FT%TZ) $1 -rgc : $("$smi" -i "$dev" -rgc 2>&1)" >> "$dir/helper.log" ;;
|
||||
lmc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lmc $v,$v : $("$smi" -i "$dev" -lmc "$v,$v" 2>&1)" >> "$dir/helper.log";; esac ;;
|
||||
rmc) echo "$(date -u +%FT%TZ) $1 -rmc : $("$smi" -i "$dev" -rmc 2>&1)" >> "$dir/helper.log" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
|
@ -614,6 +622,7 @@ mod tests {
|
|||
for s in [helper_script_windows(), helper_script_unix()] {
|
||||
assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min"));
|
||||
assert!(s.contains("-lgc") && s.contains("-rgc"), "the clock cap and its reset");
|
||||
assert!(s.contains("-lmc") && s.contains("-rmc"), "Ember 2: the memory clock and its reset");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,9 @@ static void applyState(const std::string& j) {
|
|||
// Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin.
|
||||
static void runElevated(std::wstring line) {
|
||||
std::thread([line] {
|
||||
std::wstring params = L"/c " + line;
|
||||
// cmd strips the first and last quote of a line that starts with one and holds more than two (two cards, or a
|
||||
// cap plus a script): one outer pair around the whole line is what it may strip (PC 1, 6 October 2026)
|
||||
std::wstring params = L"/c \"" + line + L"\"";
|
||||
wchar_t sysdir[MAX_PATH];
|
||||
GetSystemDirectoryW(sysdir, MAX_PATH);
|
||||
std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3)
|
||||
|
|
|
|||
|
|
@ -1669,6 +1669,18 @@ Branch `ember-tune` (54ff1bc), docs/plans/ember-tune.md. Every card tuned for MH
|
|||
|
||||
Nothing set; the installed app's miners back after 350 s. Why every earlier run (5 and 6 October, runs 1 to 4 and dry runs 1 and 2) read its copied settings as defaults, measured on PC 1 (collect ember-acl-2): the engine's own start locks its app folder with `icacls /inheritance:r /grant:r <user>:F`; cutting the folder's inheritance propagates down, the non-inheritable grant gives the children nothing, so a file COPIED in before the start (settings.json, machine-id, wallet.json) is left with no access entry and its owner cannot read it (`ReadAllText`: access denied), while the engine's own files written after the lock inherit fine, which hid it for a day. A first fix with `(OI)(CI)F /T` left the file empty too: `/T` re-applies `/inheritance:r` to each file after the propagation and an `(OI)(CI)` entry on a file is inherit-only. The right form is the inheritable grant without `/T` (07d5a72). Consequence for every tier on Windows: nothing changes for the installed app (its files were always its own); any tool that drops files into the app folder before the app starts (an installer's seed, a migration, a support script) was unreadable to the app until now and is readable from 0.3.13 on.
|
||||
|
||||
**Run 5, 6 October 2026, 15:28 to 15:39Z (job ember-tune-pc1-5, elevated on the project lead's click, PC 1 on 0.3.13, the tune engine = kit ember-kit-5 from 07d5a72, mode=direct):** the first run that set limits. The 5090's power ladder, 75 s a step, the clock unlocked (2,850 MHz core, 13,801 MHz memory), the rate = the worker's STATUS wall rate, the draw = nvidia-smi every 5 s:
|
||||
|
||||
| Cap | Limit | MH/s | W | MH/W | GPU C |
|
||||
|---|---|---|---|---|---|
|
||||
| 100% | 575 W | 127.38 | 309.9 | 0.411 | 64 |
|
||||
| 90% | 518 W | 99.32 | 313.6 | 0.317 | 65 |
|
||||
| 80% | 460 W | 123.11 | 312.2 | 0.394 | 65 |
|
||||
| 70% | 403 W | 127.38 | 310.9 | 0.410 | 65 |
|
||||
| 60% (floor 400 W) | 400 W | 127.38 | 311.3 | 0.409 | 65 |
|
||||
|
||||
Reading: the cap does not bind on this hash (310 to 314 W under every limit, as the 4 October stability line said), so the power knob is flat at 0.41 MH/W on the 5090 and the saving must come from the clocks; the 90% and 80% rows' rate dips at the same draw are stalls inside those holds (a worker restart or a template wait), not the cap. The clock ladder's first step (2,781 MHz at 575 W) was requested at 15:37:47Z and never measured: the playbook's own watchdog killed the live engine at 15:39:03Z (all three cards mining at 177 MH/s) because its idle clause sampled one log line and read "idle" from a missing match; the 4070's and the 9070 XT's plans never ran. Consequences: the 5090 is probably left with its core clock locked at 2,781 MHz (an `-lgc` lock persists until `-rgc` or a reboot) under the 575 W cap, which costs little rate; freeing it needs administrator rights; and the Power Helper was NOT registered by this run (the registration lived only in the installed app's cap path, which a `--sweep` engine skips). Fixed the same hour: the watchdog's idle rule (three consecutive status lines reading 0.00 MH/s and 300 s, never a missing match), the `after` snapshot and the engine-log dump on every exit, and an elevated tune engine registering the task itself before its first step. The decided way out: the project lead switches Power control ON in the 0.3.13 app (its one prompt registers the task from the install folder), a job frees the clock through the task (`rgc`), the tune runs unelevated through the task.
|
||||
|
||||
Consequence for the tiers: an AMD card is tuned on its power limit alone until its stock core clock is read (a 9070 XT at -30% is the floor the driver allows, 4 steps, 5 minutes); every NVIDIA card's two-knob plan waits on the user's one click on Power control; the re-run on PC 1 is held until the quit's source is named (the event-log collect) and follows the 0.3.11 rollout (the update clears the jobs folder, so the engine and the helper are fetched again), with the scheduler's slot.
|
||||
## 5 October 2026 (night), read width of the lottery hash: 4, 16 and 64-byte loads, a per-load mix, a written scratch; three cards (gate 1 experiment, cryptographer)
|
||||
|
||||
|
|
@ -2271,3 +2283,21 @@ a 50 blocks/s flood from one peer, honest template p50/p95/max 0.4/0.6/1.4 ms, r
|
|||
same once; a pool user nothing; a fleet operator gets a node that keeps its only peer, and a seed that stops amplifying old certificates to
|
||||
every peer (13,354 lines of work it did not need in seven minutes). Owed: the fleet agent's synced-node reading; a receiver-side limit on
|
||||
certificates per index per minute as a second belt once the seed is fixed; the formatter's reflow of `finality.rs` (taken out of the commit).
|
||||
|
||||
## 6 October 2026, 16:01Z: Ember run 6 on PC 1 (ember-tune-pc1-6, 0.3.13 + kit-6 = 564bdea, elevated, one click)
|
||||
|
||||
The helper registered inside the run but on the scratch copy (fixed: task_exe, the `reregister` verb; see the plan's
|
||||
run 6 notes). RTX 5090: chosen 1854 MHz at 100% = 127.71 MH/s at 226.8 W, 0.563 MH/W, against 127.9 at 311.0 W
|
||||
(0.411) untuned: 84 W saved for 0.15% of rate. Every cap step 60 to 100% read 311 to 313 W (the cap never binds).
|
||||
The clock ladder: 2781 MHz 298.8 W 0.428; 2472 MHz 262.0 W 0.488; 2163 MHz 239.9 W 0.533; 1854 MHz 226.8 W 0.563
|
||||
(the floor, not the optimum: the next cut's ladder goes to 45%). RTX 4070: caps 100 to 60% all 106.0 W 28.71 MH/s
|
||||
(0.271); 50% 99.1 W 28.70 (0.290); clock 2794 MHz at 50% 99.1 W (0.290); 2484 MHz 81.1 W 28.73 (0.354); further rows
|
||||
and the 9070 XT ladder below once the run closes.
|
||||
|
||||
Run 6 closed 16:39:37Z, exit 0, 2317 s, 19 rows. RTX 4070 chosen 1863 MHz at 50% = 28.78 MH/s at 75.6 W (0.381) against
|
||||
28.72 at 106.0 W (0.271): 30 W saved for no rate lost; its clock ladder at 50%: 2794 MHz 99.1 W 0.290; 2484 MHz 81.1 W
|
||||
0.354; 2173 MHz 77.7 W 0.370; 1863 MHz 75.6 W 0.381 (the floor). RX 9070 XT: aborted at step 1, "card reports 0 W,
|
||||
acknowledged true" = the applied rule demanded watts from a card that reports offsets (fixed bd7fcf4); the draw itself
|
||||
was read on every tick (amd_watts_source=engine_telemetry, 363 samples). The helper registered on the scratch copy
|
||||
(fixed 200362a: task_exe, the reregister verb). PC 1 mined through the installed app again by 16:44Z: 170.6 MH/s over
|
||||
the three cards, 0 faults.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ prompt) for both knobs; off, it measures only.
|
|||
| Vendor | Power limit | Core clock cap | Memory clock | How | Rights |
|
||||
|---|---|---|---|---|---|
|
||||
| NVIDIA | `nvidia-smi -pl <W>`, percent of the default, inside `power.min_limit` and `power.max_limit` | `nvidia-smi -lgc 0,<MHz>`, percent of `clocks.max.gr`; `-rgc` = unlocked | never touched (`-lmc` is not used); read back as `clocks.mem` | directly when the engine is elevated, else the one-prompt helper (`<seq> pl <W>`, `<seq> lgc <MHz>`, `<seq> rgc` in `sweep/cmd.txt`) | administrator, so only with Power control on |
|
||||
| AMD | `igneum-gpu-telemetry --card N --set-plimit <offset>` (0 = default, -20 = 80%), inside the `tune` line's `plimit_range` (PC 1's 9070 XT: -30 to 10, so 70% is the floor) | `--set-gmax` only when the `tune` line's `gmax_range` is absolute MHz (floor 0 or above); on RDNA 4 the range is an offset from stock (-500 to 1000 on PC 1) and the clock knob stays closed until the stock clock is known; `--reset` for the default point | not settable through ADLX on RDNA 4; read back as `mclk_mhz`, and a step whose mean memory clock falls under 95% of the baseline's is marked and cannot win | the helper, one process per request, exit 0 and a `tune ... ok` line | none on Windows (ADLX manual tuning); root on Linux, so measure only there |
|
||||
| AMD | `igneum-gpu-telemetry --card N --set-plimit <offset>` (0 = default, -20 = 80%), inside the `tune` line's `plimit_range` (PC 1's 9070 XT: -30 to 10, so 70% is the floor). RULE (run 6, 6 October 2026): ADLX reports the limit as an OFFSET, never watts, so an AMD step is applied on the helper's acknowledgement (`Run::applied`: a card with no limit readback confirms on `acked`), and the draw comes from the telemetry stream (`sample_telemetry`), never from a limit readback; `limit_w` is 0 for every AMD card by design | `--set-gmax` only when the `tune` line's `gmax_range` is absolute MHz (floor 0 or above); on RDNA 4 the range is an offset from stock (-500 to 1000 on PC 1) and the clock knob stays closed until the stock clock is known; `--reset` for the default point | not settable through ADLX on RDNA 4; read back as `mclk_mhz`, and a step whose mean memory clock falls under 95% of the baseline's is marked and cannot win | the helper, one process per request, exit 0 and a `tune ... ok` line | none on Windows (ADLX manual tuning); root on Linux, so measure only there |
|
||||
| Apple | none | none | none | measure only | none |
|
||||
|
||||
Vendor limits are never exceeded and the floor is never undercut: the plan clamps every point (`Limits::clamp_clock`,
|
||||
|
|
@ -161,7 +161,54 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r
|
|||
10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power
|
||||
ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout.
|
||||
|
||||
## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC)
|
||||
## 6a. A card never shows 0 MH/s without a reason word (the project lead, 6 October 2026, watching PC 1 during run 5)
|
||||
|
||||
Rule: the Mine page's rate column is blank, never "0", when a card is not mining, and the row's word says why:
|
||||
`mining`, `tuning: step k of n · measuring W W` (the live rate stays in the rate column), `held for a remote job:
|
||||
<title>`, `paused`, `waiting for the node`, `restart in N s`, `starting`, `ready`, `stopped after repeated faults`,
|
||||
`failed`, `off`, `removed`, `not usable (<problem>)`. Checked against `View.cardRow`: every state the engine can set
|
||||
has a word; `held` and `tuning` were the two that read "off" before (a job's `--stop-miners` left `state = off`).
|
||||
During a tune the big button reads "Tuning, mining again in about N min" (disabled) and the strip carries one line
|
||||
("Tuning <card>: the full tune, step k of n. Mining again in about N min."). A tune run by a job beside the installed
|
||||
app reaches the installed app's rows through `POST /api/tune-progress` (the playbook forwards the measurement
|
||||
engine's `TUNE progress` lines every 10 s and its `TUNE chosen` line as `done`): the posted state is the step, the
|
||||
live MH/s and W, the seconds left and the plan; a POST of state, never a quit, pause or resume. At the end the row
|
||||
reads `Tuned: <MH/s> at <W> W (<MH/W>)` with the point and the plan. Tests: `ui/tune-line.test.mjs` (tuning, held,
|
||||
tuned rows; the button; the strip line).
|
||||
|
||||
## 6b. Ember 2: the clocks, the hill-climb, the goal (6 October 2026)
|
||||
|
||||
Why: run 5 measured the 5090's power ladder flat (310 to 314 W under every cap from 575 to 400 W, 0.41 MH/W), so
|
||||
on this memory-latency-bound hash the power cap is a safety, not a lever; the pair that matters is the memory
|
||||
clock UP and the core clock DOWN. Ember 2 adds both and replaces the fixed ladder with a hill-climb.
|
||||
|
||||
| Piece | What | Where |
|
||||
|---|---|---|
|
||||
| The memory knob | NVIDIA: `nvidia-smi -lmc <m>,<m>` locks the memory clock to one value, `-rmc` resets; the range is the driver's own (`clocks.mem` under load = the floor, `clocks.max.mem` = the ceiling; PC 1's 5090: 13,801 to 14,001 MHz); directly from an elevated engine or through the Power Helper's `lmc`/`rmc` verbs. AMD: ADLX on RDNA 4 exposes no memory setter through the helper's path, so an AMD card climbs on core and power only (the row says so). Linux NVIDIA: `-lmc` through pkexec as the cap is | `Point.mem_mhz`, `Limits.mem_default_mhz`, `Limits.mem_max_mhz`, `tune_apply`, `powertask::HelperCmd::MemClock`, `sweep::helper_script_*` |
|
||||
| The hill-climb | from the start point (the fleet prior for the model, else the card's point): each probe moves memory up by 5% of the range above the default (at least 25 MHz) or core down by 5% of the maximum (at least 25 MHz); a probe that improves the goal's score keeps that direction, else the climb turns to the other knob, then to both; 5 probes of 60 s (10 s settle) converge in under 10 minutes | `Plan::climb`, `Climb`, `Plan::climb_next` |
|
||||
| The stability guard | a rejected or mismatched hash during a probe marks it `faulted`; a faulted probe backs that knob off for good in this climb (memory never goes above, core never below, the last good point); the hash fingerprint is the worker's own self-test at every pack (a variant that is not bit-exact never serves), so a step that keeps mining with 0 mismatches is stable by the only test that matters | `Row::from_samples`, `climb_next`'s `refused_mem` / `refused_core`, docs/design/miner-tuning.md |
|
||||
| The goal | Settings > goal: `efficiency` (the most MH/W within 10% of the top rate), `balanced` (within 1%, lever 3's rule), `rate` (the fastest point; MH/W breaks ties); one goal for every card | `Goal`, `Settings.tune_goal`, `POST /api/tune/goal {goal, price_pence, climb}` |
|
||||
| The price | Settings > electricity, pence per kWh: the card's row reads the chosen point as £ a day (`watts × 24 / 1000 × price / 100`: 310 W at 28.5 p = £2.12) | `pounds_per_day`, `Settings.power_price_pence`, the UI |
|
||||
| The curve | every row of the last plan on the card (`tune_curve`: point, MH/s, W, MH/W, clocks, hottest reading, mark) so a manual tuner sees the points the climb measured | `CardState.tune_curve`, the card detail |
|
||||
| The fleet | the record's `steps` already carry the curve; the prior gains `mem_mhz` (the median memory clock, 10 MHz steps); a stranger's card of a known model starts its climb at the prior's point | `relay/lib/ember.mjs`, `ember::prior_of` |
|
||||
| Re-check | weekly (the manifest's period), after a driver major or program-class change, and when the card's temperature band changes (owed: the band trigger) | `tick_sweep` |
|
||||
|
||||
Tests: `ember::tests::the_climb_walks_memory_up_and_core_down_and_converges_in_five_probes` (a synthetic
|
||||
memory-bound card: every probe moves memory up or core down inside the range, the chosen point beats the start in
|
||||
five probes), `a_refused_probe_backs_that_knob_off_for_good`, `each_goal_picks_its_point` (and the £/day formula),
|
||||
`powertask::tests` (the `lmc`/`rmc` verbs reach nvidia-smi as `-lmc m,m` and `-rmc`), `sweep::tests` (the helper
|
||||
scripts carry them), `relay/test/ember.test.mjs` (climb records aggregate into a prior with the memory clock).
|
||||
|
||||
Owed: the measured comparison on PC 1 (one climb per card against run 5's ladder rows, no prompt through the
|
||||
Power Helper), the temperature-band trigger, NVAPI/nvidia-settings for cards whose driver refuses `-lmc`.
|
||||
|
||||
## 7a. One administrator approval, ever
|
||||
|
||||
Threat note, `reregister` (6 October 2026): the verb takes no path. The helper, already elevated, picks the installed
|
||||
exe from the two install folders itself (`powertask::install_candidates`), so the most a writer of `cmd.txt` can do
|
||||
is point the task back at the installed app. The exposure that remains is the one every per-user install with a
|
||||
highest-run task has: the install folder is the user's own, so a process running as the user can replace the exe the
|
||||
task runs. The installer's code signature and the OTA's hash check are the answer to that, not the task. (0.3.13; the project lead, 6 October 2026, 11:50 UTC)
|
||||
|
||||
What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a
|
||||
reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. Not "once, ever".
|
||||
|
|
@ -184,8 +231,69 @@ process, registry key or other binary is reachable through it. Tests: `powertask
|
|||
non-digit or extra argument, the arguments reach nvidia-smi as a list, the registration is per-user, highest,
|
||||
trigger-less and quote-safe, a stale command file runs nothing).
|
||||
|
||||
### Run 6 (6 October 2026, 16:01Z, PC 1 on 0.3.13 with kit-6 = 564bdea, elevated, the project lead's one click)
|
||||
|
||||
The scheduled task registered inside the run ("helper registered=true"), but its action was the run's scratch copy
|
||||
(`igneum-tune-20261006-170105\bin\igneum-app.exe`): `task_exe` preferred the installed exe only for a path under
|
||||
`jobs\`. Fixed the same hour: the running exe stands only when it is itself an install candidate; and the helper
|
||||
gained a `reregister` verb (no path argument, so a writer of cmd.txt can never choose what runs elevated: the helper
|
||||
finds the install folder itself, re-registers, and logs the task's action read back). The scratch root stays until
|
||||
that read-back shows the installed path.
|
||||
|
||||
RTX 5090 (driver 617.14, class l128w0, no prior), full plan, every row ok, 65 C at most:
|
||||
|
||||
| Step | Clock cap | Power | Draw | Rate | MH/W |
|
||||
|---|---|---|---|---|---|
|
||||
| 0 (before) | unlocked | 100% 575 W | 311.0 W | 127.90 | 0.411 |
|
||||
| 1 to 4 | unlocked | 90, 80, 70, 60% | 313 W | 127.9 | 0.408 to 0.409 (the cap never binds) |
|
||||
| 5 | 2781 MHz | 100% | 298.8 W | 127.89 | 0.428 |
|
||||
| 6 | 2472 MHz | 100% | 262.0 W | 127.86 | 0.488 |
|
||||
| 7 | 2163 MHz | 100% | 239.9 W | 127.82 | 0.533 |
|
||||
| 8 (chosen) | 1854 MHz | 100% | 226.8 W | 127.71 | 0.563 |
|
||||
|
||||
The per-user number: 84 W saved for 0.15% of rate, 37% more hashes per watt. The chosen clock is the ladder's floor
|
||||
(60% of 3,090), not the optimum: `CLOCK_STEPS_PCT` now ends 60, 50, 45 and `CLOCK_FLOOR_PCT` is 45, with the 1% rate
|
||||
tolerance as the guard; a chosen point on the floor carries `floor=1 note=floor, not optimum` in the chosen line and
|
||||
the record. Ember 2's climb starts from the chosen point.
|
||||
|
||||
RTX 4070 (same driver and class), 10 rows, every one ok, 55 C at most:
|
||||
|
||||
| Step | Clock cap | Power | Draw | Rate | MH/W |
|
||||
|---|---|---|---|---|---|
|
||||
| 0 (before) | unlocked | 100% 200 W | 106.0 W | 28.72 | 0.271 |
|
||||
| 1 to 4 | unlocked | 90, 80, 70, 60% | 106.0 W | 28.71 | 0.271 (the cap never binds) |
|
||||
| 5 | unlocked | 50% 100 W | 99.1 W | 28.70 | 0.290 |
|
||||
| 6 | 2794 MHz | 50% | 99.1 W | 28.70 | 0.290 |
|
||||
| 7 | 2484 MHz | 50% | 81.1 W | 28.73 | 0.354 |
|
||||
| 8 | 2173 MHz | 50% | 77.7 W | 28.74 | 0.370 |
|
||||
| 9 (chosen) | 1863 MHz | 50% | 75.6 W | 28.78 | 0.381 |
|
||||
|
||||
Per user: 30 W saved for no rate lost (+0.2%), 41% more hashes per watt; the floor again.
|
||||
|
||||
RX 9070 XT: no row. "TUNE aborted: the setting (0 MHz, 100% = 100 W) did not take within 30 s (card reports 0 W,
|
||||
acknowledged true)", twice. The cause was `Run::applied`: a power step demanded the limit read back in watts, and
|
||||
ADLX reports an offset, never watts, so no AMD power step could ever confirm. Fixed (bd7fcf4): a card with no limit
|
||||
readback is applied on the acknowledgement. The watts themselves were read on every tick (`amd_watts_source=
|
||||
engine_telemetry`, 363 nonzero engine samples, 456 nonzero app-state samples). The ladder reruns with kit-7. The
|
||||
"100 W" in that line is the percent scale printed as watts (owed: a unit word for AMD).
|
||||
|
||||
After the run (nvidia-smi, the engine gone, the installed miners not yet back): 5090 1845 MHz locked at 575 W;
|
||||
4070 at the 100 W limit. The installed app mined again within a minute: 5090 122.9 MH/s, 4070 28.8, 9070 XT 18.9,
|
||||
170.6 MH/s, 0 faults (console, 16:44Z). The 9070 XT's ADLX state read "gmax 0 plimit 0 factory 0" (before: factory
|
||||
1): zero offsets, the factory flag cleared by the engine's set of 0; the 3.0 coordinator's reset job puts it back.
|
||||
|
||||
## 8a. Next-cut notes (for the 0.3.12 shipper)
|
||||
|
||||
For the 0.3.14 cut (6 October 2026, after run 6), on ember-tune:
|
||||
|
||||
| Commit | What | Where |
|
||||
|---|---|---|
|
||||
| bd7fcf4 | AMD: a power step with no limit readback is applied on the acknowledgement (run 6 aborted the 9070 XT's ladder at step 1 on "card reports 0 W") | ember.rs `Run::applied` + test |
|
||||
| 200362a | `task_exe` prefers the installed exe for every path that is not itself an install candidate; the helper's `reregister` verb; the clock ladder and floor to 45%; a chosen point on the floor says so; the fleet page's team rows | powertask.rs, ember.rs, engine.rs, site |
|
||||
| 5165de7, 9fba504, 5b0968f | Ember 2's Settings (goal, price, climb), £/day and the curve on the card; the playbook's AMD watts source and kit-7 preference; run 6 notes | ui, relay/playbooks, docs |
|
||||
|
||||
Earlier (shipped in 0.3.12 and 0.3.13 unless marked):
|
||||
|
||||
| Commit | What | Where |
|
||||
|---|---|---|
|
||||
| b671c8b | every `quit:` names its source; Power control alone decides; no cap at start under `--sweep` | main.rs, server.rs, engine.rs (separable) |
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function spreadPct(xs) {
|
|||
return Number((median(xs.map(x => Math.abs(x - m))) / m * 100).toFixed(2));
|
||||
}
|
||||
|
||||
const usable = r => r && r.chosen && r.chosen.mark === 'ok' && r.chosen.eff > 0 && (r.plan === 'full' || r.plan === 'confirm');
|
||||
const usable = r => r && r.chosen && r.chosen.mark === 'ok' && r.chosen.eff > 0 && (r.plan === 'full' || r.plan === 'confirm' || r.plan === 'climb');
|
||||
|
||||
/// Folds records into priors: one per key, from the full and confirm records with a usable chosen point. The point
|
||||
/// is the median clock cap and the median power percent (each rounded to the step the apps use: 10 MHz, 1%), the
|
||||
|
|
@ -80,6 +80,8 @@ export function aggregate(records, { minSamples = 1 } = {}) {
|
|||
const prior = {
|
||||
clock_mhz: Math.round(median(t.map(r => r.chosen.clock_mhz)) / 10) * 10,
|
||||
power_pct: Math.round(median(t.map(r => r.chosen.power_pct))),
|
||||
// Ember 2: the memory clock (0 = the driver's default); older records carry none
|
||||
mem_mhz: Math.round(median(t.map(r => r.chosen.mem_mhz || 0)) / 10) * 10,
|
||||
eff: Number(median(effs).toFixed(4)),
|
||||
mhs: Number(median(t.map(r => r.chosen.mhs)).toFixed(2)),
|
||||
watts: Number(median(t.map(r => r.chosen.watts)).toFixed(1)),
|
||||
|
|
@ -120,7 +122,7 @@ export function priorFor(tuning, key, minSamples) {
|
|||
const p = tuning && tuning.priors && tuning.priors[key];
|
||||
const floor = Number.isFinite(minSamples) ? minSamples : (tuning && tuning.ember && tuning.ember.min_samples) || 5;
|
||||
if (!p || !(p.samples >= floor)) return null;
|
||||
return { clock_mhz: p.clock_mhz || 0, power_pct: Math.min(100, Math.max(50, p.power_pct || 100)), eff: p.eff, samples: p.samples };
|
||||
return { clock_mhz: p.clock_mhz || 0, power_pct: Math.min(100, Math.max(50, p.power_pct || 100)), mem_mhz: p.mem_mhz || 0, eff: p.eff, samples: p.samples };
|
||||
}
|
||||
|
||||
/// One text line per prior for the console and the CLI.
|
||||
|
|
|
|||
69
relay/playbooks/ember-helper-repoint-pc1.ps1
Normal file
69
relay/playbooks/ember-helper-repoint-pc1.ps1
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Ember: re-point the Igneum Power Helper at the installed exe and prove the no-prompt cap (6 October 2026)
|
||||
# Unelevated. Run 6 registered the task on the run's scratch copy of the engine (igneum-tune-<stamp>\bin\igneum-app.exe).
|
||||
# This script: (1) reads the task's action back; (2) puts the kit-7 engine (carries the `reregister` verb) in place of
|
||||
# that scratch copy, which is the user's own file; (3) starts the task (no prompt: it is registered) and writes
|
||||
# `<seq> reregister` to the installed app's sweep\cmd.txt; the elevated helper picks the install folder itself and
|
||||
# re-registers; (4) reads the action back again and prints it; (5) the proof: asks the installed app to apply its caps
|
||||
# through the task and prints the "power cap: through the Igneum Power Helper task (no prompt)" line from its log.
|
||||
# Never quits, pauses or resumes the installed app (the 5 October rule). The scratch root is left in place.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Say([string] $s) { Write-Output ('[' + (Get-Date -Format 'HH:mm:ss') + '] ' + $s) }
|
||||
$appDir = Join-Path $env:LOCALAPPDATA 'igneum\app'
|
||||
$sweep = Join-Path $appDir 'sweep'
|
||||
$cmd = Join-Path $sweep 'cmd.txt'
|
||||
$hlog = Join-Path $sweep 'helper.log'
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner\igneum-app.exe'
|
||||
$kit = Join-Path $appDir 'jobs\ember-kit-7\igneum-app-ember.exe'
|
||||
function Action() { $t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue; if ($t) { return ($t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments) } else { return '' } }
|
||||
$before = Action
|
||||
Write-Output ('RESULT TUNE task_action_before=' + $(if ($before) { $before } else { 'none' }))
|
||||
if (-not $before) { Write-Output 'RESULT TUNE error=task_not_registered'; exit 1 }
|
||||
$exe = ($before -split ' --power-helper')[0].Trim()
|
||||
if ($exe -like '*Programs\Igneum Miner\igneum-app.exe') { Say 'the task already runs the installed exe' }
|
||||
else {
|
||||
if (-not (Test-Path -LiteralPath $kit)) { Write-Output ('RESULT TUNE error=no_kit_7 at ' + $kit); exit 1 }
|
||||
if (-not (Test-Path -LiteralPath $exe)) { Write-Output ('RESULT TUNE error=task_exe_missing ' + $exe); exit 1 }
|
||||
# a helper still running from an earlier start would hold the exe: ask it to quit through its own file, then wait
|
||||
Add-Content -LiteralPath $cmd -Value 'quit' -Encoding ascii
|
||||
Start-Sleep -Seconds 6
|
||||
Set-Content -LiteralPath $cmd -Value '' -Encoding ascii
|
||||
$sha0 = (Get-FileHash -LiteralPath $kit -Algorithm SHA256).Hash.ToLower()
|
||||
Copy-Item -LiteralPath $kit -Destination $exe -Force
|
||||
$sha1 = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower()
|
||||
Write-Output ('RESULT TUNE task_exe_replaced=' + ($sha0 -eq $sha1) + ' sha256=' + $sha1 + ' path=' + $exe)
|
||||
if ($sha0 -ne $sha1) { Write-Output 'RESULT TUNE error=copy_mismatch'; exit 1 }
|
||||
$seq = 1
|
||||
if (Test-Path -LiteralPath $cmd) { foreach ($l in (Get-Content -LiteralPath $cmd)) { if ($l -match '^(\d+) ') { $n = [int]$Matches[1]; if ($n -ge $seq) { $seq = $n + 1 } } } }
|
||||
$mark = 0; if (Test-Path -LiteralPath $hlog) { $mark = (Get-Content -LiteralPath $hlog).Count }
|
||||
Start-ScheduledTask -TaskName 'Igneum Power Helper'
|
||||
Start-Sleep -Seconds 3
|
||||
Add-Content -LiteralPath $cmd -Value ($seq.ToString() + ' reregister') -Encoding ascii
|
||||
$got = ''
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
Start-Sleep -Seconds 2
|
||||
if (Test-Path -LiteralPath $hlog) { $lines = @(Get-Content -LiteralPath $hlog); if ($lines.Count -gt $mark) { $new = @($lines[$mark..($lines.Count - 1)] | Where-Object { $_ -match 'reregister' }); if ($new.Count -gt 0) { $got = [string]$new[$new.Count - 1]; break } } }
|
||||
}
|
||||
Write-Output ('RESULT TUNE helper_log=' + $(if ($got) { $got } else { 'no reregister line within 60 s' }))
|
||||
}
|
||||
$after = Action
|
||||
Write-Output ('RESULT TUNE task_action_after=' + $(if ($after) { $after } else { 'none' }))
|
||||
if ($after -notlike '*Programs\Igneum Miner\igneum-app.exe --power-helper*') { Write-Output 'RESULT TUNE error=task_not_on_installed_exe'; exit 1 }
|
||||
# the proof: the installed app applies its caps through the task; its log says so without a prompt
|
||||
$u = Join-Path $appDir 'app.url'
|
||||
if (Test-Path -LiteralPath $u) {
|
||||
$base = (Get-Content -LiteralPath $u -Raw).Trim()
|
||||
$logs = Get-ChildItem -Path (Join-Path $appDir 'logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
$mark2 = 0; if ($logs) { $mark2 = (Get-Content -LiteralPath $logs.FullName).Count }
|
||||
try { Invoke-WebRequest -Uri ($base + 'api/power/control') -Method POST -Body '{"on":true}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null } catch { Say ('power/control: ' + $_.Exception.Message) }
|
||||
$line = ''
|
||||
for ($i = 0; $i -lt 20; $i++) {
|
||||
Start-Sleep -Seconds 3
|
||||
$logs = Get-ChildItem -Path (Join-Path $appDir 'logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($logs) { $all = @(Get-Content -LiteralPath $logs.FullName); if ($all.Count -gt $mark2) { $hit = $all[$mark2..($all.Count - 1)] | Where-Object { $_ -match 'power cap' }; if ($hit) { $line = ($hit | ForEach-Object { [string]$_ }) -join ' | '; if ($line -match 'Power Helper task') { break } } } }
|
||||
}
|
||||
Write-Output ('RESULT TUNE cap_proof=' + $(if ($line) { $line } else { 'no power cap line within 60 s' }))
|
||||
} else { Write-Output 'RESULT TUNE cap_proof=no app.url (installed app not running)' }
|
||||
$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) { Write-Output ('RESULT TUNE nvidia_now ' + ((& $smi --query-gpu=index,name,power.draw,power.limit,clocks.gr --format=csv,noheader 2>&1 | Out-String) -replace "`r?`n", ' | ')) }
|
||||
exit 0
|
||||
30
relay/playbooks/ember-proof-collect-pc1.ps1
Normal file
30
relay/playbooks/ember-proof-collect-pc1.ps1
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Ember: the no-prompt proof read from PC 1 (6 October 2026). Reads only: the installed app's power cap lines, the
|
||||
# helper's log, the task's action and state, nvidia-smi's limits, and the count of consent.exe (UAC) starts in the
|
||||
# last two hours from the Security log. Never writes, quits, pauses or resumes anything.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$a = Join-Path $env:LOCALAPPDATA 'igneum\app'
|
||||
$l = Get-ChildItem (Join-Path $a 'logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($l) {
|
||||
Write-Output ('RESULT PROOF log=' + $l.FullName)
|
||||
Get-Content -LiteralPath $l.FullName | Where-Object { $_ -match 'power cap|Power Helper|elevat|UAC|runas|tune' } | Select-Object -Last 24 | ForEach-Object { Write-Output ('RESULT PROOF applog ' + $_) }
|
||||
}
|
||||
$h = Join-Path $a 'sweep\helper.log'
|
||||
if (Test-Path -LiteralPath $h) { Get-Content -LiteralPath $h | Select-Object -Last 15 | ForEach-Object { Write-Output ('RESULT PROOF helper ' + $_) } } else { Write-Output 'RESULT PROOF helper none' }
|
||||
$t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue
|
||||
if ($t) { Write-Output ('RESULT PROOF task action=' + $t.Actions[0].Execute + ' ' + $t.Actions[0].Arguments + ' state=' + $t.State + ' runlevel=' + $t.Principal.RunLevel + ' user=' + $t.Principal.UserId) } else { Write-Output 'RESULT PROOF task none' }
|
||||
$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) { Write-Output ('RESULT PROOF nvidia ' + ((& $smi --query-gpu=index,name,power.draw,power.limit,clocks.gr,clocks.mem --format=csv,noheader 2>&1 | Out-String) -replace "`r?`n", ' | ')) }
|
||||
try {
|
||||
$ev = @(Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4688; StartTime = (Get-Date).AddHours(-2) } -ErrorAction Stop | Where-Object { $_.Message -match 'consent\.exe' })
|
||||
Write-Output ('RESULT PROOF consent_exe_starts_last_2h=' + $ev.Count + ' ' + (($ev | ForEach-Object { $_.TimeCreated.ToUniversalTime().ToString('HH:mm:ss') }) -join ','))
|
||||
} catch { Write-Output ('RESULT PROOF consent_exe_starts_last_2h=unreadable (' + $_.Exception.Message + ')') }
|
||||
$u = Join-Path $a 'app.url'
|
||||
if (Test-Path -LiteralPath $u) {
|
||||
try {
|
||||
$js = (Invoke-WebRequest -Uri ((Get-Content -LiteralPath $u -Raw).Trim() + 'api/state') -UseBasicParsing -TimeoutSec 5).Content | ConvertFrom-Json
|
||||
foreach ($c in @($js.mining.cards)) { Write-Output ('RESULT PROOF card ' + $c.key + ' ' + $c.name + ' mhs=' + $c.mhs + ' power_w=' + $c.power_w + ' power_pct=' + $c.power_pct + ' note=' + $c.power_note + ' tune=' + $c.tune_line) }
|
||||
Write-Output ('RESULT PROOF settings power_control=' + $js.settings.power_control + ' note=' + $js.settings.power_note)
|
||||
} catch { Write-Output ('RESULT PROOF state unreadable: ' + $_.Exception.Message) }
|
||||
}
|
||||
exit 0
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
# therefore measure only tonight unless the engine finds itself elevated.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$resultTag = 'TUNE'
|
||||
$budgetMinutes = 45
|
||||
$budgetMinutes = 55
|
||||
if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35)
|
||||
$started = Get-Date
|
||||
$deadline = $started.AddMinutes($budgetMinutes)
|
||||
|
|
@ -47,10 +47,13 @@ $installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out
|
|||
$installedHasEmber = $false
|
||||
if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) }
|
||||
$ember = $null
|
||||
# ember-kit-3 (the engine with Settings::for_measurement) is preferred when present, whatever the installed version;
|
||||
# older kits only when the installed app predates Ember Tune
|
||||
$k3 = Join-Path $appDir 'jobs\ember-kit-5\igneum-app-ember.exe'
|
||||
if (Test-Path $k3) { $ember = $k3 }
|
||||
# a kit is used when the installed app predates Ember Tune (0.3.13), or when a run carries fixes the installed app
|
||||
# lacks (ember-kit-6: the cmd quote fix and the self-registering elevated engine of 6 October 2026); a kit engine
|
||||
# registers the Power Helper on the INSTALLED exe's path (powertask::task_exe), never its own
|
||||
$k7 = Join-Path $appDir 'jobs\ember-kit-7\igneum-app-ember.exe'
|
||||
$k6 = Join-Path $appDir 'jobs\ember-kit-6\igneum-app-ember.exe'
|
||||
if (Test-Path $k7) { $ember = $k7 }
|
||||
elseif (Test-Path $k6) { $ember = $k6 }
|
||||
elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } }
|
||||
if ($ember) {
|
||||
Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force
|
||||
|
|
@ -127,12 +130,33 @@ function EndTree([int] $procId, [string] $why) {
|
|||
}
|
||||
$seen = 0
|
||||
$rows = 0
|
||||
# the installed app shows the tune instead of a bare "off" (the project lead, 6 October 2026): every `TUNE progress` line and the
|
||||
# `TUNE chosen` line go to its /api/tune-progress (a POST of state, never quit, pause or resume: the 5 October rule)
|
||||
$installedUrl = ''
|
||||
try { $installedUrl = (Get-Content -LiteralPath (Join-Path $appDir 'app.url') -Raw -ErrorAction Stop).Trim() } catch { }
|
||||
function Forward([string] $line) {
|
||||
if (-not $installedUrl) { return }
|
||||
$kv = @{}
|
||||
foreach ($tok in ($line -split ' ')) { if ($tok -match '^([a-z_]+)=(.*)$') { $kv[$Matches[1]] = $Matches[2] } }
|
||||
$body = @{ key = ($kv['card'] -replace '_', ' '); done = ($line -match '^TUNE chosen ') }
|
||||
if ($kv['key']) { $body.key = ($kv['key'] -replace '_', ' ') }
|
||||
foreach ($n in @('step', 'of', 'eta_s', 'mhs', 'watts', 'clock', 'cap')) { if ($kv[$n] -ne $null) { $body[$n] = [double]$kv[$n] } }
|
||||
if ($kv['cap'] -ne $null) { $body['power_pct'] = [double]$kv['cap'] }
|
||||
if ($kv['clock'] -ne $null) { $body['clock_mhz'] = [double]$kv['clock'] }
|
||||
if ($kv['plan']) { $body.plan = $kv['plan'] }
|
||||
if ($kv['note']) { $body.note = ($kv['note'] -replace '_', ' ') }
|
||||
if ($body.done) { $body.note = ('tuned: ' + $kv['mhs'] + ' MH/s at ' + $kv['watts'] + ' W') }
|
||||
try { Invoke-WebRequest -Uri ($installedUrl + 'api/tune-progress') -Method POST -Body ($body | ConvertTo-Json -Compress) -ContentType 'application/json' -UseBasicParsing -TimeoutSec 3 | Out-Null } catch { }
|
||||
}
|
||||
# the watchdog (coordinator, 6 October 2026): a tune engine that mines nothing for 120 s after its first status line
|
||||
# (every card "waiting" or 0.00 MH/s: no payout address, no worker, no node) fails the job at once with the engine's
|
||||
# last log line in the RESULT, its tree ended, mining restored by the job runner; a job that cannot mine never burns
|
||||
# its budget silently again
|
||||
$firstStatusAt = $null
|
||||
$lastMining = $null
|
||||
$idleSince = $null
|
||||
$idleSamples = 0
|
||||
$lastIdleStatus = ''
|
||||
$lastEngineLine = ''
|
||||
function EngineLogDump([string] $why) {
|
||||
Write-Output ('===== engine log tail (' + $why + ')')
|
||||
|
|
@ -140,33 +164,55 @@ function EngineLogDump([string] $why) {
|
|||
if ($t) { Get-Content -LiteralPath $t.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'status: accepted 0 blocks' } | Select-Object -Last 80 | ForEach-Object { Write-Output (' ' + $_) } } else { Write-Output ' (no app-*.log in the scratch logs folder)' }
|
||||
if (Test-Path -LiteralPath $errFile) { Write-Output '===== engine stderr'; Get-Content -LiteralPath $errFile -ErrorAction SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Output (' ' + $_) } }
|
||||
}
|
||||
# the newest `status:` line among the engine log's last 60 lines (run 5, 15:39Z: a single sampled tail line missed the
|
||||
# status lines between TUNE and telemetry lines for 300 s and the watchdog killed a live three-card tune)
|
||||
function EngineStatus() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $ls = @(Get-Content -LiteralPath $t.FullName -Tail 60 -ErrorAction SilentlyContinue); for ($i = $ls.Count - 1; $i -ge 0; $i--) { if ([string]$ls[$i] -match ' status: ') { return [string]$ls[$i] } } }; return '' }
|
||||
function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' }
|
||||
function Exit3([string] $why, [string] $reason) {
|
||||
Write-Output ('RESULT TUNE error=' + $reason + ' last_status_line=' + ((EngineStatus) -replace '\s+', '_') + ' last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
|
||||
EngineLogDump $why
|
||||
EndTree $p.Id $why
|
||||
Snapshot 'after'
|
||||
Write-Output 'RESULT TUNE error=no_rows'
|
||||
exit 3
|
||||
}
|
||||
# the AMD watts source (coordinator, 6 October 2026 16:00Z): the 3.0 coordinator's job read ZERO watts for the 9070 XT
|
||||
# from igneum-gpu-telemetry.exe while the installed app had read 198.9 W the day before. The tune engine samples its
|
||||
# own helper; this script also samples the installed app's /api/state power_w for the AMD card every 10 s (a GET,
|
||||
# never a write) and, when the engine's AMD progress lines carry no watts, prints a fallback ladder from those
|
||||
# samples (median per step) and names the source it used
|
||||
$amdApp = @(); $amdStep = 0; $amdMhs = 0.0; $amdEngNZ = 0; $amdEngZ = 0; $tick = 0; $amdMhsByStep = @{}
|
||||
while (-not $p.HasExited) {
|
||||
Start-Sleep -Seconds 5
|
||||
$tick++
|
||||
if ($installedUrl -and ($tick % 2 -eq 0)) {
|
||||
try {
|
||||
$js = (Invoke-WebRequest -Uri ($installedUrl + 'api/state') -UseBasicParsing -TimeoutSec 3).Content | ConvertFrom-Json
|
||||
foreach ($c in @($js.mining.cards)) { if ($c.vendor -eq 'amd') { $amdApp += ,@{ step = $amdStep; w = [double]$c.power_w } } }
|
||||
} catch { }
|
||||
}
|
||||
$tailLine = EngineTail
|
||||
if ($tailLine) { $lastEngineLine = $tailLine }
|
||||
if ($lastEngineLine -match ' status: ') {
|
||||
$statusLine = EngineStatus
|
||||
if ($statusLine) {
|
||||
if (-not $firstStatusAt) { $firstStatusAt = Get-Date }
|
||||
if ($lastEngineLine -match ', mining \|' -or ($lastEngineLine -match '(\d+\.\d+) MH/s' -and [double]$Matches[1] -gt 0)) { $lastMining = Get-Date }
|
||||
}
|
||||
if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) {
|
||||
Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
|
||||
EngineLogDump 'watchdog: not mining'
|
||||
EndTree $p.Id 'watchdog: not mining'
|
||||
Write-Output 'RESULT TUNE error=no_rows'
|
||||
exit 3
|
||||
}
|
||||
if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) {
|
||||
Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
|
||||
EngineLogDump 'watchdog: stopped mining'
|
||||
EndTree $p.Id 'watchdog: stopped mining'
|
||||
Write-Output 'RESULT TUNE error=no_rows'
|
||||
exit 3
|
||||
$rate = 0.0; if ($statusLine -match '(\d+\.\d+) MH/s') { $rate = [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) }
|
||||
# idle = three consecutive 10-s samples whose newest status line reads 0.00 MH/s (never a missing match)
|
||||
if ($rate -gt 0) { $lastMining = Get-Date; $idleSamples = 0; $idleSince = $null }
|
||||
elseif ($statusLine -ne $lastIdleStatus) { $lastIdleStatus = $statusLine; $idleSamples++; if (-not $idleSince) { $idleSince = Get-Date } }
|
||||
}
|
||||
if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { Exit3 'watchdog: not mining' 'not_mining reason=no_card_mined_within_120_s_of_the_first_status_line' }
|
||||
if ($lastMining -and $idleSamples -ge 3 -and $idleSince -and ((Get-Date) - $idleSince).TotalSeconds -gt 300) { Exit3 'watchdog: stopped mining' 'stopped_mining reason=three_status_lines_read_0.00_MH/s_and_300_s_passed' }
|
||||
$all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) }
|
||||
while ($seen -lt $all.Count) {
|
||||
$l = [string]$all[$seen]; $seen++
|
||||
if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } }
|
||||
if ($l -match '^TUNE progress ') {
|
||||
Forward $l
|
||||
if ($l -match ' key=amd' -and $l -match ' step=(\d+)') { $amdStep = [int]$Matches[1]
|
||||
if ($l -match ' mhs=([\d.]+)') { $amdMhsByStep[$amdStep] = [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) }
|
||||
if ($l -match ' watts=([\d.]+)' -and [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) -gt 0) { $amdEngNZ++ } else { $amdEngZ++ } }
|
||||
}
|
||||
elseif ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ }; if ($l -match '^TUNE chosen ') { Forward $l } }
|
||||
elseif ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l) }
|
||||
elseif ($l -match '^(URL|STATE) ') { }
|
||||
else { Say $l }
|
||||
|
|
@ -185,11 +231,26 @@ while (-not $p.HasExited) {
|
|||
} else { Write-Output ('RESULT TUNE quit not sent: no URL file at ' + $u + '; killing pid ' + $p.Id) }
|
||||
Start-Sleep -Seconds 20
|
||||
if (-not $p.HasExited) { EndTree $p.Id 'budget' }
|
||||
EngineLogDump 'budget'
|
||||
Write-Output 'RESULT TUNE error=budget_exceeded'
|
||||
}
|
||||
}
|
||||
$all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) }
|
||||
while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++; if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } } }
|
||||
$amdNZ = @($amdApp | Where-Object { $_.w -gt 0 })
|
||||
Write-Output ('RESULT TUNE amd_watts engine_progress nonzero=' + $amdEngNZ + ' zero=' + $amdEngZ + ' app_state samples=' + $amdApp.Count + ' nonzero=' + $amdNZ.Count)
|
||||
$amdSource = 'none'
|
||||
if ($amdEngNZ -gt 0) { $amdSource = 'engine_telemetry' }
|
||||
elseif ($amdNZ.Count -gt 0) {
|
||||
$amdSource = 'app_state_fallback'
|
||||
foreach ($g in ($amdNZ | Group-Object { $_.step } | Sort-Object { [int]$_.Name })) {
|
||||
$ws = @($g.Group | ForEach-Object { $_.w } | Sort-Object); $med = $ws[[int][math]::Floor($ws.Count / 2)]
|
||||
$mh = 0.0; if ($amdMhsByStep.ContainsKey([int]$g.Name)) { $mh = $amdMhsByStep[[int]$g.Name] }
|
||||
$eff = 0.0; if ($med -gt 0) { $eff = $mh / $med }
|
||||
Write-Output ('RESULT TUNE amd_fallback source=app_state step=' + $g.Name + ' watts=' + ('{0:F1}' -f $med) + ' mhs=' + ('{0:F2}' -f $mh) + ' eff=' + ('{0:F4}' -f $eff) + ' samples=' + $ws.Count)
|
||||
}
|
||||
}
|
||||
Write-Output ('RESULT TUNE amd_watts_source=' + $amdSource)
|
||||
Say ("tune engine exited " + $p.ExitCode + " after " + [int]((Get-Date) - $started).TotalSeconds + " s, " + $rows + " table rows")
|
||||
EndTree $p.Id 'end of run'
|
||||
# the project lead, 6 October 2026, 07:25Z: both cards stay on their best MH/W points (the tune pins them); no factory reset here.
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ test('the manifest merge keeps the kernel-variant cards and carries the settings
|
|||
assert.deepEqual(mergeTuning(null, {}).cards, {});
|
||||
// the round trip: canonical JSON (what publish-manifest.sh signs) parses back to the same prior
|
||||
const back = JSON.parse(JSON.stringify(t));
|
||||
assert.deepEqual(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16'), { clock_mhz: 2470, power_pct: 100, eff: priors['NVIDIA_GeForce_RTX_5090|581|l128w16'].eff, samples: 5 });
|
||||
assert.deepEqual(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16'), { clock_mhz: 2470, power_pct: 100, mem_mhz: 0, eff: priors['NVIDIA_GeForce_RTX_5090|581|l128w16'].eff, samples: 5 });
|
||||
assert.equal(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16', 6), null, 'six wanted, five there');
|
||||
assert.equal(priorFor(back, 'nothing|0|v2'), null);
|
||||
assert.equal(priorFor(null, 'x'), null);
|
||||
|
|
@ -100,6 +100,15 @@ test('AMD confirm records aggregate by their own key, and the line reads', () =>
|
|||
assert.equal(table.length, 1);
|
||||
});
|
||||
|
||||
test('Ember 2: climb records carry the memory clock into the prior', () => {
|
||||
const climb = (m, ts, mem) => ({ ...rec(m, ts, { ...step(2472, 100, 220, 123.5), mem_mhz: mem }), plan: 'climb' });
|
||||
const { priors } = aggregate([climb('a1', 1, 14001), climb('b2', 2, 14001), climb('c3', 3, 13901), climb('d4', 4, 14001), climb('e5', 5, 14001)], { minSamples: 5 });
|
||||
const p = priors['NVIDIA_GeForce_RTX_5090|581|l128w16'];
|
||||
assert.equal(p.mem_mhz, 14000, 'the median memory clock, rounded to 10 MHz');
|
||||
assert.equal(p.clock_mhz, 2470);
|
||||
assert.equal(priorFor({ priors, ember: { min_samples: 5 } }, 'NVIDIA_GeForce_RTX_5090|581|l128w16').mem_mhz, 14000);
|
||||
});
|
||||
|
||||
test('median and spread', () => {
|
||||
assert.equal(median([3, 1, 2]), 2);
|
||||
assert.equal(median([4, 1, 2, 3]), 2.5);
|
||||
|
|
|
|||
|
|
@ -404,6 +404,8 @@ for (const [file, active] of PAGES) {
|
|||
'<h2 id="priors">Fleet tuning priors</h2>',
|
||||
'<p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet\'s median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs ' + pj.min_samples + ' reports before its prior is used.</p>',
|
||||
ptable,
|
||||
// the team's own measured rows (site/miner-priors.json "team"): the per-user sentence per card, with its job
|
||||
...((pj.team || []).length ? ['<h3 id="team">Measured by the team</h3>', '<ul>' + pj.team.map(t => `<li><b>${esc(t.card)}</b> (driver ${esc(t.driver_major)}, class ${esc(t.class)}, ${esc(t.date)}, job <code>${esc(t.job)}</code>): ${esc(t.point)}. ${esc(t.sentence)}${t.note ? ' ' + esc(t.note) : ''}</li>`).join('') + '</ul>'] : []),
|
||||
`<p>Rows: ${prows.length}${pj.generated ? ', generated ' + pj.generated : ''}. Source file: <code>site/miner-priors.json</code> in the repository, written from the fleet records by <code>tools/tuning.mjs --priors --site</code>.</p>`,
|
||||
].join('\n'));
|
||||
const toc = [{ lvl: 2, t: 'The table', id: 'table' }, { lvl: 2, t: 'How a row gets here', id: 'how' }, { lvl: 2, t: 'Fleet tuning priors', id: 'priors' }];
|
||||
|
|
|
|||
|
|
@ -2,5 +2,40 @@
|
|||
"_about": "Rows of the fleet priors table at /miners (site/build.mjs), written by tools/tuning.mjs --priors --site from the TUNE records every Igneum Miner uploads. One row per card model, driver major and program class: the median tuned point, MH per watt, the spread and the sample count. No machine names, no addresses.",
|
||||
"generated": null,
|
||||
"min_samples": 5,
|
||||
"rows": []
|
||||
"rows": [],
|
||||
"_team": "Rows measured on the team's own hardware, from the job log named in each row; they stand until the fleet's prior for the model reaches five samples.",
|
||||
"team": [
|
||||
{
|
||||
"card": "NVIDIA GeForce RTX 5090",
|
||||
"driver_major": "617",
|
||||
"class": "l128w0",
|
||||
"date": "2026-10-06",
|
||||
"job": "ember-tune-pc1-6",
|
||||
"point": "1854 MHz core cap, power limit 100% (575 W)",
|
||||
"mh_s": 127.71,
|
||||
"watts": 226.8,
|
||||
"mh_per_w": 0.563,
|
||||
"untuned_mh_s": 127.9,
|
||||
"untuned_watts": 311.0,
|
||||
"untuned_mh_per_w": 0.411,
|
||||
"sentence": "84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt.",
|
||||
"note": "The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here."
|
||||
},
|
||||
{
|
||||
"card": "NVIDIA GeForce RTX 4070",
|
||||
"driver_major": "617",
|
||||
"class": "l128w0",
|
||||
"date": "2026-10-06",
|
||||
"job": "ember-tune-pc1-6",
|
||||
"point": "1863 MHz core cap, power limit 50% (100 W)",
|
||||
"mh_s": 28.78,
|
||||
"watts": 75.6,
|
||||
"mh_per_w": 0.381,
|
||||
"untuned_mh_s": 28.72,
|
||||
"untuned_watts": 106.0,
|
||||
"untuned_mh_per_w": 0.271,
|
||||
"sentence": "30 W saved for no rate lost: 28.78 MH/s at 75.6 W (0.381 MH/W) against 28.72 MH/s at 106 W untuned (0.271 MH/W), 41% more hashes per watt.",
|
||||
"note": "The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45%."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,6 +189,8 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
|
|||
<h2 id="priors">Fleet tuning priors</h2>
|
||||
<p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet's median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs 5 reports before its prior is used.</p>
|
||||
<p>No tune reports yet. The first rows appear once five machines with the same card model have reported.</p>
|
||||
<h3 id="team">Measured by the team</h3>
|
||||
<ul><li><b>NVIDIA GeForce RTX 5090</b> (driver 617, class l128w0, 2026-10-06, job <code>ember-tune-pc1-6</code>): 1854 MHz core cap, power limit 100% (575 W). 84 W saved for 0.15% of rate: 127.71 MH/s at 226.8 W (0.563 MH/W) against 127.9 MH/s at 311 W untuned (0.411 MH/W), 37% more hashes per watt. The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45% and the climb walks on from here.</li><li><b>NVIDIA GeForce RTX 4070</b> (driver 617, class l128w0, 2026-10-06, job <code>ember-tune-pc1-6</code>): 1863 MHz core cap, power limit 50% (100 W). 30 W saved for no rate lost: 28.78 MH/s at 75.6 W (0.381 MH/W) against 28.72 MH/s at 106 W untuned (0.271 MH/W), 41% more hashes per watt. The chosen clock is the ladder's floor (60% of the maximum), not the optimum; the next cut's ladder goes to 45%.</li></ul>
|
||||
<p>Rows: 0. Source file: <code>site/miner-priors.json</code> in the repository, written from the fleet records by <code>tools/tuning.mjs --priors --site</code>.</p></article>
|
||||
</div>
|
||||
<p class="gen">Generated from the repository at build time. Times are UTC. Machine names are model names.</p>
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ if [ "${1:-}" = "--self-test" ]; then
|
|||
if ! check_file "$t/good.ps1"; then echo "self-test FAILED: the good playbook failed"; exit 1; fi
|
||||
rm -rf "$t"; echo "self-test passed: the installed app's URL file with a quit fails, a scratch URL file passes"; exit 0
|
||||
fi
|
||||
# allowed senders: the installer's own stop step (the update-now path the rule names), and, pending the rule owner's
|
||||
# word (6 October 2026, 15:10 UTC): tools/proving-v1/pc2-agg-cost.ps1 and its restore step pc2-agg-cost-restore.ps1, which switch the 5090 off through /api/cards
|
||||
# and falls back to /api/pause with /api/resume in its finally block (the aggregation-cost agent's measurement; the
|
||||
# rule's letter forbids pause and resume of the installed app, its owner decides whether a card switch's fallback is one)
|
||||
ALLOW='^(packaging/windows/stop-igneum\.ps1|tools/proving-v1/pc2-agg-cost\.ps1|tools/proving-v1/pc2-agg-cost-restore\.ps1)$'
|
||||
# the one allowed sender is the installer's own stop step (the update-now path the rule names). Ruled 6 October 2026,
|
||||
# 15:30 UTC: a job script never pauses or resumes the installed app's miners through /api/pause or /api/resume, not even
|
||||
# as a fallback with a finally block (a script that dies before its finally leaves the box paused unattended); the
|
||||
# job runner's --stop-miners is the sanctioned way (it stops them around the job and restarts them on any exit).
|
||||
ALLOW='^packaging/windows/stop-igneum\.ps1$'
|
||||
fail=0
|
||||
while IFS= read -r f; do [[ "$f" =~ $ALLOW ]] && continue; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/windows/**' 'tools/proving-v1/**' 'packaging/**' | grep -E '\.(ps1|sh)$')
|
||||
[ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses or resumes the installed app"
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ while IFS= read -r f; do
|
|||
if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then
|
||||
echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1
|
||||
fi
|
||||
if grep -vE '^\s*#' "$f" | grep -qE 'ConvertTo-Json' && grep -qE 'settings\.json' "$f"; then
|
||||
if grep -vE '^\s*#' "$f" | grep -E 'ConvertTo-Json' | grep -qE 'settings\.json|Set-Content|WriteAllText|Out-File'; then
|
||||
echo "second-engine: $f rewrites settings.json through ConvertTo-Json (a lossy round trip: big integers become doubles and the engine reads the whole file as defaults; run 4, 6 October 2026); copy the file verbatim, the engine applies Settings::for_measurement under --sweep"; fail=1
|
||||
fi
|
||||
if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then
|
||||
|
|
|
|||
Loading…
Reference in a new issue