diff --git a/adlerbench.conf b/adlerbench.conf
index 2ebee21..4a07d04 100644
--- a/adlerbench.conf
+++ b/adlerbench.conf
@@ -6,10 +6,6 @@
poll_ms = 250
cool_secs = 15
-# Safety thresholds (flagged in the report if exceeded during a run).
-vcore_limit = 1.403
-temp_crit = 95.0
-
# Where to write JSON reports + find external binaries (y-cruncher).
report_dir = ./runs
bin_dir = ./bin
diff --git a/src/config.rs b/src/config.rs
index c660335..8cdfa5b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -28,13 +28,8 @@ pub struct Config {
pub workloads: Vec<WorkloadSpec>,
/// Sensor sample period, ms (drives the tick loop + TUI refresh).
pub poll_ms: u64,
- /// Cooldown between workloads, seconds (idle sensor baseline re-set).
+ /// Cooldown between workloads, seconds (idle baseline re-set).
pub cool_secs: u64,
- /// vCore safety limit, volts — shown as a marker on the hero panel
- /// and flagged in the report if exceeded during a run.
- pub vcore_limit: f64,
- /// Critical package temp, °C — flagged in the report if exceeded.
- pub temp_crit: f64,
/// Where to write JSON reports. Created on first run if missing.
pub report_dir: PathBuf,
/// Where external binaries live (y-cruncher). Defaults to ./bin.
@@ -49,8 +44,6 @@ impl Default for Config {
workloads: default_workloads(),
poll_ms: 250,
cool_secs: 15,
- vcore_limit: 1.403,
- temp_crit: 95.0,
report_dir: PathBuf::from("runs"),
bin_dir: PathBuf::from("bin"),
loaded_path: None,
@@ -99,8 +92,6 @@ fn apply_line(cfg: &mut Config, line: &str) {
match key {
"poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
"cool_secs" => cfg.cool_secs = val.parse().unwrap_or(cfg.cool_secs),
- "vcore_limit" => cfg.vcore_limit = val.parse().unwrap_or(cfg.vcore_limit),
- "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
"report_dir" => cfg.report_dir = PathBuf::from(val),
"bin_dir" => cfg.bin_dir = PathBuf::from(val),
"workloads" => cfg.workloads = parse_workload_list(val),
@@ -223,12 +214,8 @@ mod tests {
let mut cfg = Config::default();
apply_line(&mut cfg, "poll_ms = 500");
apply_line(&mut cfg, "cool_secs = 30");
- apply_line(&mut cfg, "vcore_limit = 1.35");
- apply_line(&mut cfg, "temp_crit = 90.0");
assert_eq!(cfg.poll_ms, 500);
assert_eq!(cfg.cool_secs, 30);
- assert_eq!(cfg.vcore_limit, 1.35);
- assert_eq!(cfg.temp_crit, 90.0);
}
#[test]
@@ -264,8 +251,8 @@ mod tests {
let mut cfg = Config::default();
apply_line(&mut cfg, "not a config line");
apply_line(&mut cfg, "unknown_key = 42");
- apply_line(&mut cfg, "vcore_limit = notanumber");
- assert_eq!(cfg.vcore_limit, 1.403); // unchanged
+ apply_line(&mut cfg, "poll_ms = notanumber");
+ assert_eq!(cfg.poll_ms, 250); // unchanged
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index c614516..843aac6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,16 +1,11 @@
mod config;
mod report;
mod run;
-mod sensors;
mod workload;
fn main() {
let cfg = config::load();
let args: Vec<String> = std::env::args().collect();
- if args.iter().any(|a| a == "--dump") {
- dump_sensors();
- return;
- }
if let Some(idx) = args.iter().position(|a| a == "--smoke-workload") {
if let Some(name) = args.get(idx + 1) {
smoke_workload(name, &cfg);
@@ -27,18 +22,8 @@ fn main() {
}
}
-fn dump_sensors() {
- let s = sensors::snapshot(None, 0.0);
- println!("vCore : {}", opt_volts(s.vcore));
- println!("Pkg temp : {}", opt_temp(s.pkg_temp));
- println!("Pkg power : {} (None on first tick — no delta yet)", opt_watts(s.pkg_power));
- println!("Peak clock : {}", opt_khz(s.peak_clock_khz));
- println!("RAPL energy: {}", opt_uj(s.energy_uj()));
- println!("Cores : {} logical", s.core_freqs.len());
-}
-
/// `--headless` mode: drive the run loop without a TUI. Prints one line
-/// per workload (the human summary) at the end. Useful for CI / scripts
+/// per workload (verdict + score) at the end. Useful for CI / scripts
/// and for verifying run.rs end-to-end before the TUI is wired.
fn headless_run(cfg: config::Config) {
use std::thread;
@@ -50,40 +35,22 @@ fn headless_run(cfg: config::Config) {
eprintln!("no workloads to run");
return;
}
- let poll_ms = {
- // Read poll_ms from the run's config via the cfg we still own —
- // simpler than exposing it on Run.
- // (We moved cfg into run::Run::new, so re-load to read poll_ms.
- // A bit awkward; if it matters, expose cfg on Run later.)
- config::load().poll_ms
- };
+ let poll_ms = config::load().poll_ms;
let poll = std::time::Duration::from_millis(poll_ms);
- let mut summaries: Vec<String> = Vec::new();
+ let mut count = 0usize;
loop {
match run.tick() {
Ok(run::TickOutcome::WorkloadStarted { idx, name, duration_secs }) => {
println!("[{idx}] starting {name} ({duration_secs}s)");
}
- Ok(run::TickOutcome::SampleTaken { latest }) => {
- // In headless mode we don't print every sample — too
- // noisy. Print one dot per tick to stderr so the user
- // sees progress.
- eprint!(".");
- let _ = latest;
- }
- Ok(run::TickOutcome::WorkloadFinished { idx, name, verdict, report_path }) => {
- eprintln!();
+ Ok(run::TickOutcome::WorkloadFinished { idx, name, verdict, score, report_path }) => {
println!("[{idx}] finished {name}: {:?}", verdict);
+ println!(" score: {}", format_score(&score));
println!(" report: {}", report_path.display());
- // Re-read the report we just wrote + print its summary.
- // (The run owns the Report; we don't have a borrow to it
- // here. For the summary we'd need Run to expose it. For
- // now, skip the summary in headless mode — the verdict +
- // path are the useful parts.)
- summaries.push(name);
+ count += 1;
}
Ok(run::TickOutcome::SweepDone) => {
- println!("sweep complete: {} workloads", summaries.len());
+ println!("sweep complete: {count} workloads");
break;
}
Ok(run::TickOutcome::Idle) => {}
@@ -96,6 +63,35 @@ fn headless_run(cfg: config::Config) {
}
}
+/// One-line score formatter for the headless + smoke modes. Mirrors what
+/// `report::human_summary` does for the score, but standalone so the
+/// headless loop can print it without reconstructing a Report.
+fn format_score(s: &workload::Score) -> String {
+ use workload::Score;
+ match s {
+ Score::None => "(no score)".to_string(),
+ Score::SevenZip { rating_mips, r_u_mips, usage_pct, compress_mips, decompress_mips } => {
+ format!(
+ "rating={rating_mips} MIPS r/u={r_u_mips} usage={usage_pct}% \
+ compress={compress_mips} MIPS decompress={decompress_mips} MIPS"
+ )
+ }
+ Score::YCruncher { tests } => {
+ if tests.is_empty() {
+ return "(no tests recorded)".to_string();
+ }
+ tests
+ .iter()
+ .map(|t| {
+ let gbps = t.bits_per_sec / 1e9;
+ format!("{}={} {:.2} Gb/s", t.tag, if t.passed { "pass" } else { "FAIL" }, gbps)
+ })
+ .collect::<Vec<_>>()
+ .join(" ")
+ }
+ }
+}
+
fn smoke_workload(name: &str, cfg: &config::Config) {
use std::thread;
use std::time::Duration;
@@ -132,39 +128,11 @@ fn smoke_workload(name: &str, cfg: &config::Config) {
let _ = w.stop();
}
let verdict = w.wait();
+ let score = w.score();
println!("smoke: verdict = {:?}", verdict);
+ println!("smoke: score = {}", format_score(&score));
}
Err(e) => println!("smoke: start failed: {e}"),
}
}
-fn opt_volts(v: Option<f64>) -> String {
- match v {
- Some(x) => format!("{:.3} V", x),
- None => "unreadable".to_string(),
- }
-}
-fn opt_temp(v: Option<f64>) -> String {
- match v {
- Some(x) => format!("{:.1} C", x),
- None => "unreadable".to_string(),
- }
-}
-fn opt_watts(v: Option<f64>) -> String {
- match v {
- Some(x) => format!("{:.1} W", x),
- None => "unreadable".to_string(),
- }
-}
-fn opt_khz(v: Option<u64>) -> String {
- match v {
- Some(x) => format!("{:.3} GHz", x as f64 / 1_000_000.0),
- None => "unreadable".to_string(),
- }
-}
-fn opt_uj(v: Option<u64>) -> String {
- match v {
- Some(x) => format!("{} uJ", x),
- None => "unreadable (root-only without adlermon udev rule + adm group)".to_string(),
- }
-}
\ No newline at end of file
diff --git a/src/report.rs b/src/report.rs
index 08cd224..4b040ab 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,16 +1,15 @@
//! JSON report writer. One file per workload run, under `report_dir`
-//! (`./runs/` by default). Schema v1: run metadata, per-tick sensor
-//! samples, workload verdict, observed peak clock, ISA class.
+//! (`./runs/` by default). Schema v2: run metadata, workload verdict,
+//! and the perf score. No sensor fields — adlermon owns sensors; this
+//! report only records what the workload itself produced.
//!
-//! Incremental flush: the whole file is rewritten on each `add_sample`
-//! call. For a 60s run at 250ms poll that's ~240 samples × ~200 bytes =
-//! ~48 KB per rewrite — cheap. A crash mid-run leaves the last
-//! successfully-flushed state on disk, which is a valid (partial) JSON
-//! document (the verdict + peak clock fields are filled with null until
-//! the run completes, so a partial file is still parseable).
+//! Incremental flush: the whole file is rewritten on each call. A v2
+//! report is small (no per-tick samples), so we only flush once at
+//! `finish()` — partial runs leave a JSON file with verdict=null +
+//! score=null, which is parseable.
//!
//! Zero-dep: hand-rolled JSON. The schema is flat enough that serde_json
-//! would be one dep for little gain; revisit if the schema nests.
+//! would be one dep for little gain; revisit if the schema nests deeper.
#![allow(dead_code)] // consumed by run.rs + main.rs — not yet wired
@@ -19,12 +18,11 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
-use crate::sensors::Snapshot;
-use crate::workload::Verdict;
+use crate::workload::{Score, Verdict};
/// The data captured for one workload run. The run loop owns this,
-/// feeds it samples each tick, and calls `finish()` with the verdict +
-/// observed peak clock when the workload completes.
+/// feeds it the verdict + score when the workload completes, and calls
+/// `finish()` to flush.
pub struct Report {
pub schema_version: u32,
pub workload: String,
@@ -34,19 +32,11 @@ pub struct Report {
/// ISA class — "integer", "sse", "avx2", "mixed", or "other".
/// Inferred from the workload name (see `isa_class`).
pub isa_class: &'static str,
- /// Per-tick sensor samples, in order.
- pub samples: Vec<Snapshot>,
/// Set by `finish()`. None until the workload completes.
pub verdict: Option<Verdict>,
- /// Session peak clock across all ticks, kHz. None until the first
- /// tick with a readable clock. The per-ISA offset measurement.
- pub peak_clock_khz: Option<u64>,
- /// Max vCore observed across all ticks, volts.
- pub peak_vcore: Option<f64>,
- /// Max package temp across all ticks, °C.
- pub peak_pkg_temp: Option<f64>,
- /// Max package power across all ticks, watts.
- pub peak_pkg_power: Option<f64>,
+ /// Set by `finish()`. None for stability-only workloads (stress-ng),
+ /// or until the workload completes.
+ pub score: Option<Score>,
/// Path the report will be written to (set at construction).
path: PathBuf,
}
@@ -65,43 +55,25 @@ impl Report {
let safe = workload.replace(['/', ' ', ':'], "_");
let path = report_dir.join(format!("{started}-{safe}.json"));
Ok(Report {
- schema_version: 1,
+ schema_version: 2,
workload: workload.to_string(),
params: params.to_string(),
started,
isa_class: isa_class(workload),
- samples: Vec::new(),
verdict: None,
- peak_clock_khz: None,
- peak_vcore: None,
- peak_pkg_temp: None,
- peak_pkg_power: None,
+ score: None,
path,
})
}
- /// Append a sample + update the running peaks, then flush the whole
- /// file. Called by the run loop each tick.
- pub fn add_sample(&mut self, s: Snapshot) -> io::Result<()> {
- if let Some(khz) = s.peak_clock_khz {
- self.peak_clock_khz = Some(self.peak_clock_khz.map_or(khz, |p| p.max(khz)));
- }
- if let Some(v) = s.vcore {
- self.peak_vcore = Some(self.peak_vcore.map_or(v, |p| p.max(v)));
- }
- if let Some(t) = s.pkg_temp {
- self.peak_pkg_temp = Some(self.peak_pkg_temp.map_or(t, |p| p.max(t)));
- }
- if let Some(w) = s.pkg_power {
- self.peak_pkg_power = Some(self.peak_pkg_power.map_or(w, |p| p.max(w)));
- }
- self.samples.push(s);
- self.flush()
- }
-
- /// Mark the run complete + flush the final state with the verdict.
- pub fn finish(&mut self, verdict: Verdict) -> io::Result<()> {
+ /// Mark the run complete + flush the final state with the verdict +
+ /// score. Called once, after the workload has exited and its score
+ /// has been parsed. The score is stored verbatim — `Score::None` is
+ /// kept (so `human_summary` can distinguish "stability-only workload"
+ /// from "not finished"), and `json_score` renders it as `null`.
+ pub fn finish(&mut self, verdict: Verdict, score: Score) -> io::Result<()> {
self.verdict = Some(verdict);
+ self.score = Some(score);
self.flush()
}
@@ -121,7 +93,7 @@ impl Report {
/// Serialize to a JSON string. Used by `flush()` and by the
/// `--report <path>` printer in main.rs.
pub fn to_json(&self) -> String {
- let mut out = String::with_capacity(4096);
+ let mut out = String::with_capacity(1024);
out.push('{');
out.push_str(&format!("\"schema_version\":{},", self.schema_version));
out.push_str(&format!("\"workload\":{},", json_str(&self.workload)));
@@ -129,21 +101,8 @@ impl Report {
out.push_str(&format!("\"started\":{},", self.started));
out.push_str(&format!("\"isa_class\":{},", json_str(self.isa_class)));
out.push_str(&format!("\"verdict\":{},", json_verdict(&self.verdict)));
- out.push_str(&format!(
- "\"peak_clock_khz\":{},",
- json_num(self.peak_clock_khz.map(|v| v as f64))
- ));
- out.push_str(&format!("\"peak_vcore\":{},", json_num(self.peak_vcore)));
- out.push_str(&format!("\"peak_pkg_temp\":{},", json_num(self.peak_pkg_temp)));
- out.push_str(&format!("\"peak_pkg_power\":{},", json_num(self.peak_pkg_power)));
- out.push_str("\"samples\":[");
- for (i, s) in self.samples.iter().enumerate() {
- if i > 0 {
- out.push(',');
- }
- out.push_str(&sample_json(s));
- }
- out.push_str("]}");
+ out.push_str(&format!("\"score\":{}", json_score(&self.score)));
+ out.push('}');
out
}
}
@@ -168,23 +127,6 @@ fn json_str(s: &str) -> String {
out
}
-/// JSON-encode an Option<f64> — None → `null`, Some(v) → the number.
-/// NaN/inf can't appear in JSON; clamp to null if they somehow do.
-/// Forces at least one decimal place so `0.0` renders as `0.0` not `0`
-/// (keeps the report readable + the tests honest).
-fn json_num(v: Option<f64>) -> String {
- match v {
- Some(x) if x.is_finite() => {
- if x.fract() == 0.0 {
- format!("{x:.1}")
- } else {
- format!("{x}")
- }
- }
- _ => "null".to_string(),
- }
-}
-
/// JSON-encode the verdict. None (run not finished) → `null`; otherwise
/// an object with `kind` + optional `detail`.
fn json_verdict(v: &Option<Verdict>) -> String {
@@ -197,22 +139,40 @@ fn json_verdict(v: &Option<Verdict>) -> String {
}
}
-/// JSON-encode one sample. Skips the `energy_uj` private field (that's
-/// for the run loop's delta math, not the report).
-fn sample_json(s: &Snapshot) -> String {
- format!(
- "{{\"t\":{},\"vcore\":{},\"pkg_temp\":{},\"pkg_power\":{},\"peak_clock_khz\":{},\"core_freqs\":[{}]}}",
- json_num(Some(s.t)),
- json_num(s.vcore),
- json_num(s.pkg_temp),
- json_num(s.pkg_power),
- json_num(s.peak_clock_khz.map(|v| v as f64)),
- s.core_freqs
- .iter()
- .map(|(cpu, khz)| format!("[{cpu},{khz}]"))
- .collect::<Vec<_>>()
- .join(",")
- )
+/// JSON-encode a Score. None (no score / not finished) → `null`.
+/// Otherwise an object with `kind` + the per-kind fields.
+fn json_score(s: &Option<Score>) -> String {
+ match s {
+ None => "null".to_string(),
+ Some(Score::None) => "null".to_string(),
+ Some(Score::SevenZip {
+ rating_mips,
+ r_u_mips,
+ usage_pct,
+ compress_mips,
+ decompress_mips,
+ }) => format!(
+ "{{\"kind\":\"7zip\",\"rating_mips\":{rating_mips},\"r_u_mips\":{r_u_mips},\
+ \"usage_pct\":{usage_pct},\"compress_mips\":{compress_mips},\
+ \"decompress_mips\":{decompress_mips}}}"
+ ),
+ Some(Score::YCruncher { tests }) => {
+ let mut out = String::from("{\"kind\":\"y-cruncher\",\"tests\":[");
+ for (i, t) in tests.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ out.push_str(&format!(
+ "{{\"tag\":{},\"passed\":{},\"bits_per_sec\":{}}}",
+ json_str(&t.tag),
+ t.passed,
+ t.bits_per_sec
+ ));
+ }
+ out.push_str("]}");
+ out
+ }
+ }
}
/// Infer the ISA class from the workload name. Used for the report's
@@ -238,31 +198,30 @@ pub fn human_summary(r: &Report) -> String {
Some(Verdict::Error(msg)) => format!("ERROR ({msg})"),
None => "(not finished)".to_string(),
};
- let peak_clk = r
- .peak_clock_khz
- .map(|khz| format!("{:.3} GHz", khz as f64 / 1_000_000.0))
- .unwrap_or_else(|| "unreadable".to_string());
- let peak_v = r
- .peak_vcore
- .map(|v| format!("{:.3} V", v))
- .unwrap_or_else(|| "unreadable".to_string());
- let peak_t = r
- .peak_pkg_temp
- .map(|t| format!("{:.1} C", t))
- .unwrap_or_else(|| "unreadable".to_string());
- let peak_w = r
- .peak_pkg_power
- .map(|w| format!("{:.1} W", w))
- .unwrap_or_else(|| "unreadable".to_string());
+ let score = match &r.score {
+ None => "(no score)".to_string(),
+ Some(Score::None) => "(stability only)".to_string(),
+ Some(Score::SevenZip { rating_mips, .. }) => format!("rating={rating_mips} MIPS"),
+ Some(Score::YCruncher { tests }) => {
+ if tests.len() == 1 {
+ let t = &tests[0];
+ let gbps = t.bits_per_sec / 1e9;
+ format!("{} {:.2} Gb/s", t.tag, gbps)
+ } else {
+ format!("{} tests", tests.len())
+ }
+ }
+ };
format!(
- "{:<18} {:<8} peak_clk={:<12} vCore={:<10} pkg={:<10} temp={:<10} verdict={}",
- r.workload, r.isa_class, peak_clk, peak_v, peak_w, peak_t, verdict
+ "{:<18} {:<8} {:<24} verdict={}",
+ r.workload, r.isa_class, score, verdict
)
}
#[cfg(test)]
mod tests {
use super::*;
+ use crate::workload::YCruncherTest;
/// Unique dir per test (by test name) so tests don't race on a shared
/// directory when one test's `remove_dir_all` wipes another's parent.
@@ -271,27 +230,18 @@ mod tests {
}
fn sample_report() -> Report {
- let mut r = Report::new(&test_dir("sample_report"), "y-cruncher-avx2", "60s cores=0-15")
+ let mut r = Report::new(&test_dir("sample_report"), "y-cruncher-avx2", "60s cores=0-15 FFTv4")
.unwrap();
- r.add_sample(Snapshot {
- t: 0.0,
- vcore: Some(1.1),
- pkg_temp: Some(50.0),
- pkg_power: None,
- peak_clock_khz: Some(4_000_000),
- core_freqs: vec![(0, 4_000_000)],
- energy_uj: None,
- })
- .unwrap();
- r.add_sample(Snapshot {
- t: 0.25,
- vcore: Some(1.15),
- pkg_temp: Some(55.0),
- pkg_power: Some(95.0),
- peak_clock_khz: Some(4_100_000),
- core_freqs: vec![(0, 4_100_000), (1, 4_000_000)],
- energy_uj: None,
- })
+ r.finish(
+ Verdict::Clean,
+ Score::YCruncher {
+ tests: vec![YCruncherTest {
+ tag: "FFTv4".to_string(),
+ passed: true,
+ bits_per_sec: 8.94e8,
+ }],
+ },
+ )
.unwrap();
r
}
@@ -315,14 +265,6 @@ mod tests {
assert_eq!(json_str(""), "\"\"");
}
- #[test]
- fn json_num_handles_none_and_finite() {
- assert_eq!(json_num(None), "null");
- assert_eq!(json_num(Some(1.5)), "1.5");
- assert_eq!(json_num(Some(f64::NAN)), "null");
- assert_eq!(json_num(Some(f64::INFINITY)), "null");
- }
-
#[test]
fn json_verdict_variants() {
assert_eq!(json_verdict(&None), "null");
@@ -333,94 +275,94 @@ mod tests {
}
#[test]
- fn add_sample_updates_peaks() {
- let mut r = Report::new(&test_dir("add_sample_updates_peaks"), "test", "test").unwrap();
- r.add_sample(Snapshot {
- t: 0.0,
- vcore: Some(1.1),
- pkg_temp: Some(50.0),
- pkg_power: Some(80.0),
- peak_clock_khz: Some(4_000_000),
- core_freqs: vec![],
- energy_uj: None,
- })
- .unwrap();
- r.add_sample(Snapshot {
- t: 0.25,
- vcore: Some(1.05), // lower than prev
- pkg_temp: Some(60.0), // higher
- pkg_power: Some(95.0), // higher
- peak_clock_khz: Some(3_900_000), // lower
- core_freqs: vec![],
- energy_uj: None,
- })
- .unwrap();
- assert_eq!(r.peak_vcore, Some(1.1)); // peak, not latest
- assert_eq!(r.peak_pkg_temp, Some(60.0));
- assert_eq!(r.peak_pkg_power, Some(95.0));
- assert_eq!(r.peak_clock_khz, Some(4_000_000));
+ fn json_score_null_for_none() {
+ assert_eq!(json_score(&None), "null");
+ assert_eq!(json_score(&Some(Score::None)), "null");
}
#[test]
- fn add_sample_keeps_peak_when_none() {
- let mut r = Report::new(&test_dir("add_sample_keeps_peak_when_none"), "test", "test").unwrap();
- r.add_sample(Snapshot {
- t: 0.0,
- vcore: Some(1.1),
- pkg_temp: Some(50.0),
- pkg_power: Some(80.0),
- peak_clock_khz: Some(4_000_000),
- core_freqs: vec![],
- energy_uj: None,
- })
- .unwrap();
- r.add_sample(Snapshot {
- t: 0.25,
- vcore: None, // sensor unreadable this tick
- pkg_temp: None,
- pkg_power: None,
- peak_clock_khz: None,
- core_freqs: vec![],
- energy_uj: None,
- })
+ fn json_score_7zip_round_trips() {
+ let s = Score::SevenZip {
+ rating_mips: 66259,
+ r_u_mips: 4653,
+ usage_pct: 1431,
+ compress_mips: 5346,
+ decompress_mips: 3961,
+ };
+ let j = json_score(&Some(s));
+ assert!(j.contains("\"kind\":\"7zip\""));
+ assert!(j.contains("\"rating_mips\":66259"));
+ assert!(j.contains("\"r_u_mips\":4653"));
+ assert!(j.contains("\"usage_pct\":1431"));
+ assert!(j.contains("\"compress_mips\":5346"));
+ assert!(j.contains("\"decompress_mips\":3961"));
+ }
+
+ #[test]
+ fn json_score_ycruncher_round_trips() {
+ let s = Score::YCruncher {
+ tests: vec![
+ YCruncherTest { tag: "FFTv4".to_string(), passed: true, bits_per_sec: 8.94e8 },
+ YCruncherTest { tag: "BKT".to_string(), passed: true, bits_per_sec: 9.49e9 },
+ ],
+ };
+ let j = json_score(&Some(s));
+ assert!(j.contains("\"kind\":\"y-cruncher\""));
+ assert!(j.contains("\"tag\":\"FFTv4\""));
+ assert!(j.contains("\"passed\":true"));
+ assert!(j.contains("\"bits_per_sec\":894000000"));
+ assert!(j.contains("\"tag\":\"BKT\""));
+ }
+
+ #[test]
+ fn finish_stores_score_and_verdict() {
+ let mut r = Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
+ r.finish(
+ Verdict::Clean,
+ Score::SevenZip {
+ rating_mips: 1000,
+ r_u_mips: 500,
+ usage_pct: 1500,
+ compress_mips: 700,
+ decompress_mips: 300,
+ },
+ )
.unwrap();
- // Peaks stay at the previous tick's values — None doesn't reset.
- assert_eq!(r.peak_vcore, Some(1.1));
- assert_eq!(r.peak_pkg_temp, Some(50.0));
- assert_eq!(r.peak_pkg_power, Some(80.0));
- assert_eq!(r.peak_clock_khz, Some(4_000_000));
+ assert_eq!(r.verdict, Some(Verdict::Clean));
+ assert!(matches!(r.score, Some(Score::SevenZip { .. })));
}
#[test]
- fn to_json_is_valid_structure() {
- let r = sample_report();
- let json = r.to_json();
- assert!(json.starts_with('{'));
- assert!(json.ends_with('}'));
- assert!(json.contains("\"schema_version\":1"));
- assert!(json.contains("\"workload\":\"y-cruncher-avx2\""));
- assert!(json.contains("\"isa_class\":\"avx2\""));
- assert!(json.contains("\"samples\":["));
- assert!(json.contains("\"t\":0.0"));
- assert!(json.contains("\"t\":0.25"));
- // core_freqs encoded as [[cpu,khz],...] — second sample has two
- // entries: cpu0 @ 4.1 GHz, cpu1 @ 4.0 GHz
- assert!(json.contains("[[0,4100000],[1,4000000]]"));
+ fn finish_keeps_none_score_for_summary() {
+ let mut r = Report::new(&test_dir("finish_keeps_none_score_for_summary"), "test", "test").unwrap();
+ r.finish(Verdict::Clean, Score::None).unwrap();
+ // Stored as Some(Score::None) so human_summary can distinguish
+ // "stability only" from "not finished". JSON still renders null.
+ assert_eq!(r.score, Some(Score::None));
+ assert!(r.to_json().contains("\"score\":null"));
}
#[test]
- fn to_json_handles_empty_samples() {
- let r = Report::new(&test_dir("to_json_handles_empty_samples"), "test", "test").unwrap();
- let json = r.to_json();
- assert!(json.contains("\"samples\":[]"));
+ fn to_json_has_schema_v2() {
+ let r = sample_report();
+ let j = r.to_json();
+ assert!(j.contains("\"schema_version\":2"));
+ assert!(!j.contains("\"samples\"")); // v1 field, gone
+ assert!(!j.contains("\"peak_clock_khz\"")); // v1 field, gone
}
#[test]
- fn to_json_with_verdict() {
- let mut r = sample_report();
- r.finish(Verdict::Clean).unwrap();
- let json = r.to_json();
- assert!(json.contains("\"verdict\":{\"kind\":\"clean\"}"));
+ fn to_json_is_valid_structure() {
+ let r = sample_report();
+ let j = r.to_json();
+ assert!(j.starts_with('{'));
+ assert!(j.ends_with('}'));
+ assert!(j.contains("\"workload\":\"y-cruncher-avx2\""));
+ assert!(j.contains("\"isa_class\":\"avx2\""));
+ assert!(j.contains("\"verdict\":{\"kind\":\"clean\"}"));
+ assert!(j.contains("\"kind\":\"y-cruncher\""));
+ assert!(j.contains("\"tag\":\"FFTv4\""));
+ assert!(j.contains("\"bits_per_sec\":894000000"));
}
#[test]
@@ -428,42 +370,52 @@ mod tests {
let dir = test_dir("flush_writes_readable_file");
let _ = fs::remove_dir_all(&dir);
let mut r = Report::new(&dir, "flush-test", "30s cores=0-15").unwrap();
- r.add_sample(Snapshot {
- t: 0.0,
- vcore: Some(1.2),
- pkg_temp: Some(45.0),
- pkg_power: Some(50.0),
- peak_clock_khz: Some(4_500_000),
- core_freqs: vec![],
- energy_uj: None,
- })
- .unwrap();
- r.finish(Verdict::Clean).unwrap();
+ r.finish(Verdict::Clean, Score::None).unwrap();
let written = fs::read_to_string(r.path()).unwrap();
assert!(written.contains("\"workload\":\"flush-test\""));
assert!(written.contains("\"verdict\":{\"kind\":\"clean\"}"));
- assert!(written.contains("\"t\":0.0"));
- // Path includes the started timestamp + sanitized name
+ assert!(written.contains("\"score\":null"));
assert!(r.path().file_name().unwrap().to_str().unwrap().contains("flush-test.json"));
}
#[test]
fn human_summary_formats_one_line() {
- let mut r = sample_report();
- r.finish(Verdict::Clean).unwrap();
+ let r = sample_report();
let s = human_summary(&r);
assert!(s.contains("y-cruncher-avx2"));
assert!(s.contains("avx2"));
assert!(s.contains("CLEAN"));
- assert!(s.contains("peak_clk="));
- assert!(s.contains("vCore="));
+ assert!(s.contains("FFTv4"));
+ assert!(s.contains("Gb/s"));
}
#[test]
- fn human_summary_handles_unreadable_sensors() {
- let r = Report::new(&test_dir("human_summary_handles_unreadable_sensors"), "test", "test").unwrap();
+ fn human_summary_for_7zip() {
+ let mut r = Report::new(&test_dir("human_summary_for_7zip"), "7zip-bench", "60s").unwrap();
+ r.finish(
+ Verdict::Clean,
+ Score::SevenZip {
+ rating_mips: 66259,
+ r_u_mips: 4653,
+ usage_pct: 1431,
+ compress_mips: 5346,
+ decompress_mips: 3961,
+ },
+ )
+ .unwrap();
let s = human_summary(&r);
- assert!(s.contains("unreadable"));
+ assert!(s.contains("66259 MIPS"));
+ assert!(s.contains("mixed"));
+ }
+
+ #[test]
+ fn human_summary_for_stability_only() {
+ let mut r = Report::new(&test_dir("human_summary_for_stability_only"), "stress-ng-cpu", "60s").unwrap();
+ r.finish(Verdict::Clean, Score::None).unwrap();
+ let s = human_summary(&r);
+ assert!(s.contains("(stability only)"));
+ assert!(s.contains("integer"));
+ assert!(s.contains("CLEAN"));
}
#[test]
diff --git a/src/run.rs b/src/run.rs
index 94be5b1..d4e1251 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -1,9 +1,10 @@
//! Run orchestration — the new core (adlermon has no equivalent). Owns
//! the sweep lifecycle: for each workload in the config, cooldown →
-//! workload.start() → tick loop (sample sensors, add to report) →
-//! workload.stop() / wait for exit → flush report. Front-end-agnostic:
-//! the TUI drives it by calling `tick()` every `poll_ms`; a future
-//! `--headless` mode does the same without rendering.
+//! workload.start() → wait for exit (polling is_running) → workload.stop()
+//! if needed → workload.wait() for verdict → workload.score() for the
+//! perf number → flush report. Front-end-agnostic: the TUI drives it by
+//! calling `tick()` every `poll_ms`; `--headless` does the same without
+//! rendering.
//!
//! State machine (poll-driven, no async):
//! Cooldown(remaining) ── elapsed → Running(idx)
@@ -18,8 +19,7 @@ use std::time::{Duration, Instant};
use crate::config::Config;
use crate::report::Report;
-use crate::sensors;
-use crate::workload::{self, Verdict, Workload};
+use crate::workload::{self, Score, Verdict, Workload};
/// What a tick produced. The caller (TUI or headless loop) uses this to
/// decide what to render + when to stop driving the run.
@@ -28,23 +28,20 @@ pub enum TickOutcome {
/// A workload was just started this tick. `idx` is the workload index,
/// `name` is its name, `duration_secs` is the planned run length.
WorkloadStarted { idx: usize, name: String, duration_secs: u64 },
- /// A sensor sample was taken and added to the report. The latest
- /// snapshot is included so the TUI can render it without borrowing
- /// into the run's history.
- SampleTaken { latest: sensors::Snapshot },
/// The workload exited (either it self-finished or the duration timer
- /// elapsed and we stopped it). The verdict + report path are included
- /// for the TUI's result table + stdout summary.
+ /// elapsed and we stopped it). The verdict + score + report path are
+ /// included for the TUI's result table + stdout summary.
WorkloadFinished {
idx: usize,
name: String,
verdict: Verdict,
+ score: Score,
report_path: std::path::PathBuf,
},
/// The sweep is complete (all workloads done, final cooldown elapsed).
SweepDone,
/// Nothing happened this tick (still in cooldown or still running and
- /// no sample was due). The caller can just render the existing state.
+ /// no exit was detected). The caller can just render the existing state.
Idle,
}
@@ -64,12 +61,6 @@ pub struct Run {
state: State,
/// When the current state (Cooldown or Running) started.
state_start: Instant,
- /// When the last sensor sample was taken (for the dt_secs in power
- /// delta math + pacing samples within a tick).
- last_sample: Instant,
- /// Previous RAPL energy reading, for the power-delta computation in
- /// `sensors::snapshot`. None on the first sample of a workload.
- prev_energy_uj: Option<u64>,
/// The report being written for the current workload.
report: Option<Report>,
}
@@ -112,8 +103,6 @@ impl Run {
remaining: 0,
},
state_start: Instant::now(),
- last_sample: Instant::now(),
- prev_energy_uj: None,
report: None,
}
}
@@ -128,8 +117,8 @@ impl Run {
self.workloads.iter().filter(|w| w.is_some()).count()
}
- /// One tick of the state machine. Call every `poll_ms`. Reads sensors
- /// (when running), advances the state, and returns what happened.
+ /// One tick of the state machine. Call every `poll_ms`. Polls the
+ /// workload's `is_running()` to detect completion + advances state.
pub fn tick(&mut self) -> io::Result<TickOutcome> {
let elapsed = self.state_start.elapsed();
match &mut self.state {
@@ -153,20 +142,7 @@ impl Run {
// Workload finished (on its own or we stop it now).
return self.finish_current_workload();
}
- // Still running — take a sensor sample.
- let now = Instant::now();
- let dt = now.duration_since(self.last_sample).as_secs_f64();
- self.last_sample = now;
- let snap = sensors::snapshot(self.prev_energy_uj, dt);
- self.prev_energy_uj = snap.energy_uj();
- // Stamp the sample with the workload's elapsed time.
- let t = elapsed.as_secs_f64();
- let mut snap = snap;
- snap.t = t;
- if let Some(r) = &mut self.report {
- r.add_sample(snap.clone())?;
- }
- Ok(TickOutcome::SampleTaken { latest: snap })
+ Ok(TickOutcome::Idle)
}
State::Done => Ok(TickOutcome::SweepDone),
}
@@ -190,8 +166,6 @@ impl Run {
let duration_secs = spec.duration_secs;
self.state = State::Running { duration_secs };
self.state_start = Instant::now();
- self.last_sample = Instant::now();
- self.prev_energy_uj = None;
Ok(TickOutcome::WorkloadStarted {
idx: self.current,
name: spec.name.clone(),
@@ -206,18 +180,20 @@ impl Run {
if w.is_running() {
let _ = w.stop();
}
- w.wait()
+ let v = w.wait();
+ (v, w.score())
} else {
- Verdict::Error("no workload".to_string())
+ (Verdict::Error("no workload".to_string()), Score::None)
}
};
+ let (verdict, score) = verdict;
let report_path = self
.report
.as_ref()
.map(|r| r.path().to_path_buf())
.unwrap_or_default();
if let Some(r) = &mut self.report {
- r.finish(verdict.clone())?;
+ r.finish(verdict.clone(), score.clone())?;
}
let idx = self.current;
let name = self.cfg.workloads[idx].name.clone();
@@ -239,6 +215,7 @@ impl Run {
idx,
name,
verdict,
+ score,
report_path,
})
}
@@ -295,6 +272,9 @@ mod tests {
Verdict::Clean
}
}
+ fn score(&mut self) -> Score {
+ Score::None
+ }
}
fn mock_cfg(workloads: Vec<WorkloadSpec>) -> Config {
@@ -302,8 +282,6 @@ mod tests {
workloads,
poll_ms: 10,
cool_secs: 0, // no cooldown in tests
- vcore_limit: 1.4,
- temp_crit: 95.0,
report_dir: std::path::PathBuf::from("/tmp/opencode/run-tests"),
bin_dir: std::path::PathBuf::from("bin"),
loaded_path: None,
@@ -322,8 +300,6 @@ mod tests {
current: 0,
state: State::Cooldown { remaining: 0 },
state_start: Instant::now(),
- last_sample: Instant::now(),
- prev_energy_uj: None,
report: None,
}
}
@@ -339,7 +315,6 @@ mod tests {
MockWorkload { name: "mock-a".to_string(), ticks_until_exit: 3, ticks_seen: 0, started: false, stopped: false },
MockWorkload { name: "mock-b".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
]);
- // Tick 1: cooldown (0s) elapses → start mock-a.
let mut started = 0;
let mut finished = 0;
let mut done = false;
@@ -349,7 +324,6 @@ mod tests {
started += 1;
assert!(name == "mock-a" || name == "mock-b");
}
- TickOutcome::SampleTaken { .. } => {}
TickOutcome::WorkloadFinished { name, verdict, .. } => {
finished += 1;
assert!(name == "mock-a" || name == "mock-b");
@@ -380,12 +354,9 @@ mod tests {
]);
let mut verdict = None;
for _ in 0..10 {
- match run.tick().unwrap() {
- TickOutcome::WorkloadFinished { verdict: v, .. } => {
- verdict = Some(v);
- break;
- }
- _ => {}
+ if let TickOutcome::WorkloadFinished { verdict: v, .. } = run.tick().unwrap() {
+ verdict = Some(v);
+ break;
}
}
// duration_secs=0 → first Running tick sees elapsed >= 0 → stops.
@@ -406,9 +377,6 @@ mod tests {
#[test]
fn skipped_workloads_reported() {
let run = Run::new(Config::default());
- // Can't easily inject skipped entries via the public ctor without
- // an unknown workload name in the config; just verify the accessor
- // exists + returns a slice.
let _ = run.skipped();
}
}
\ No newline at end of file
diff --git a/src/sensors.rs b/src/sensors.rs
deleted file mode 100644
index 1332f27..0000000
--- a/src/sensors.rs
+++ /dev/null
@@ -1,337 +0,0 @@
-//! Read-only access to Linux hardware monitoring sysfs: hwmon chips
-//! (voltages, temperatures), per-cpu frequency, and RAPL energy counters.
-//! This is the only module that touches the filesystem — front-ends (the
-//! run loop, the TUI) never read sysfs directly.
-//!
-//! Ported from adlermon/src/sensors.rs (verified 2026-08-29 on this box)
-//! with one addition: a `Snapshot` struct for atomic per-tick reads, and
-//! the Alder Lake topology map (P vs E cores) that adlermon keeps in
-//! ui.rs. Both belong here so run.rs and ui.rs can share them.
-
-#![allow(dead_code)] // consumed by run.rs + ui.rs + report.rs — not yet wired
-
-use std::fs;
-use std::path::{Path, PathBuf};
-
-/// A discovered hwmon chip, e.g. `/sys/class/hwmon/hwmon3` (nct6798).
-pub struct Chip {
- pub path: PathBuf,
- pub name: String,
-}
-
-pub fn discover_chips() -> Vec<Chip> {
- let mut chips = Vec::new();
- if let Ok(entries) = fs::read_dir("/sys/class/hwmon") {
- for entry in entries.flatten() {
- let path = entry.path();
- if let Some(name) = read_trimmed(&path.join("name")) {
- chips.push(Chip { path, name });
- }
- }
- }
- chips.sort_by_key(|chip| hwmon_number(&chip.path));
- chips
-}
-
-fn hwmon_number(path: &Path) -> u64 {
- path.file_name()
- .and_then(|n| n.to_str())
- .and_then(|n| n.rsplit_once("hwmon"))
- .and_then(|(_, digits)| digits.parse().ok())
- .unwrap_or(u64::MAX)
-}
-
-fn read_trimmed(path: &Path) -> Option<String> {
- fs::read_to_string(path).ok().map(|s| s.trim().to_string())
-}
-
-fn read_int(path: &Path) -> Option<i64> {
- read_trimmed(path)?.parse().ok()
-}
-
-/// All labeled numeric inputs matching `<prefix>N_input`, scaled by `scale`.
-/// Prefers the kernel-provided `<prefix>N_label` when one exists, so e.g.
-/// coretemp's "Core 0" / "Package id 0" names survive.
-fn numbered_inputs(dir: &Path, prefix: &str, scale: f64) -> Vec<(String, f64)> {
- let mut rows: Vec<(u64, String, f64)> = Vec::new();
- if let Ok(entries) = fs::read_dir(dir) {
- for entry in entries.flatten() {
- let file = entry.file_name();
- let file = match file.to_str() {
- Some(f) => f,
- None => continue,
- };
- let stem = match file.strip_suffix("_input") {
- Some(s) => s,
- None => continue,
- };
- let digits = match stem.strip_prefix(prefix) {
- Some(d) => d,
- None => continue,
- };
- let index = match digits.parse::<u64>() {
- Ok(i) => i,
- Err(_) => continue,
- };
- let raw = match read_int(&entry.path()) {
- Some(v) => v,
- None => continue,
- };
- let label = read_trimmed(&dir.join(format!("{stem}_label")))
- .unwrap_or_else(|| stem.to_string());
- rows.push((index, label, raw as f64 * scale));
- }
- }
- rows.sort_by_key(|row| row.0);
- rows.into_iter()
- .map(|(_, label, value)| (label, value))
- .collect()
-}
-
-/// Voltage inputs in volts (`in*_input` is millivolts).
-pub fn voltages(chip: &Chip) -> Vec<(String, f64)> {
- numbered_inputs(&chip.path, "in", 1.0 / 1000.0)
-}
-
-/// Temperature inputs in °C (`temp*_input` is millidegrees Celsius).
-pub fn temperatures(chip: &Chip) -> Vec<(String, f64)> {
- numbered_inputs(&chip.path, "temp", 1.0 / 1000.0)
-}
-
-/// Per-cpu current frequency in kHz from cpufreq sysfs (no root required).
-pub fn cpu_frequencies() -> Vec<(usize, u64)> {
- let mut out = Vec::new();
- if let Ok(entries) = fs::read_dir("/sys/devices/system/cpu") {
- for entry in entries.flatten() {
- let name = entry.file_name();
- let name = match name.to_str() {
- Some(n) => n,
- None => continue,
- };
- let cpu = match name
- .strip_prefix("cpu")
- .and_then(|d| d.parse::<usize>().ok())
- {
- Some(c) => c,
- None => continue,
- };
- if let Some(khz) = read_int(&entry.path().join("cpufreq/scaling_cur_freq")) {
- out.push((cpu, khz as u64));
- }
- }
- }
- out.sort();
- out
-}
-
-/// Max frequency (kHz) advertised for a logical cpu (its turbo ceiling).
-pub fn cpu_max_freq(cpu: usize) -> Option<u64> {
- read_int(
- &Path::new("/sys/devices/system/cpu")
- .join(format!("cpu{cpu}"))
- .join("cpufreq/cpuinfo_max_freq"),
- )
- .map(|v| v as u64)
-}
-
-/// A RAPL power domain with an `energy_uj` counter, e.g. package or core.
-pub struct RaplDomain {
- pub id: String, // sysfs dir name, e.g. "intel-rapl:0"
- pub name: String, // kernel label, e.g. "package-0"
-}
-
-pub fn rapl_domains() -> Vec<RaplDomain> {
- let mut out = Vec::new();
- if let Ok(entries) = fs::read_dir("/sys/class/powercap") {
- for entry in entries.flatten() {
- let id = match entry.file_name().to_str() {
- Some(i) => i.to_string(),
- None => continue,
- };
- if !id.starts_with("intel-rapl") {
- continue;
- }
- let path = entry.path();
- // energy_uj exists on all domains but is 0400 root-only on many
- // kernels — report the domain regardless and let reads fail loudly.
- if !path.join("energy_uj").exists() {
- continue;
- }
- let name = read_trimmed(&path.join("name")).unwrap_or_else(|| id.clone());
- out.push(RaplDomain { id, name });
- }
- }
- out.sort_by(|a, b| a.id.cmp(&b.id));
- out
-}
-
-/// Cumulative energy in microjoules; deltas over time give watts. Returns
-/// None if unreadable (root-only on this kernel without the adm group via
-/// adlermon's udev rule).
-pub fn rapl_energy_uj(domain: &str) -> Option<u64> {
- read_int(
- &Path::new("/sys/class/powercap")
- .join(domain)
- .join("energy_uj"),
- )
- .map(|e| e as u64)
-}
-
-/// Sampled vCore from the nct6798 SIO (hwmon in0). Find the chip by NAME,
-/// never by index — hwmon numbering shifts between boots.
-pub fn sio_vcore() -> Option<f64> {
- sio_input("in0")
-}
-
-/// Any SIO voltage input by sysfs stem ("in0", "in12", …).
-pub fn sio_input(input: &str) -> Option<f64> {
- let chip = discover_chips().into_iter().find(|c| c.name.starts_with("nct"))?;
- voltages(&chip)
- .into_iter()
- .find(|(label, _)| label == input)
- .map(|(_, v)| v)
-}
-
-/// Package temperature from coretemp (the "Package id 0" label).
-pub fn package_temp() -> Option<f64> {
- let chip = discover_chips().into_iter().find(|c| c.name == "coretemp")?;
- temperatures(&chip)
- .into_iter()
- .find(|(label, _)| label.starts_with("Package"))
- .map(|(_, v)| v)
-}
-
-/// Alder Lake i5-12600KF topology (verified 2026-08-29, mirrored from
-/// adlermon/project-memory.md). P-cores have HT (2 logical each); E-cores
-/// don't. Logical cpu 0-11 = P-cores, 12-15 = E-cores.
-pub fn is_e_core(cpu: usize) -> bool {
- cpu >= 12
-}
-
-/// All logical cpus that are P-cores.
-pub fn p_cpus() -> Vec<usize> {
- (0..12).collect()
-}
-
-/// All logical cpus that are E-cores.
-pub fn e_cpus() -> Vec<usize> {
- (12..16).collect()
-}
-
-/// One atomic sensor reading at a point in time. The run loop produces one
-/// per tick; the report writer serializes them; the TUI renders the latest.
-/// Fields are Option<> because any single sensor can be unreadable (root-
-/// gated, missing chip, etc.) — absent sensor ≠ error, same as adlermon.
-#[derive(Clone, Debug, Default)]
-pub struct Snapshot {
- /// Seconds since run start (set by the run loop, not sensors.rs).
- pub t: f64,
- pub vcore: Option<f64>,
- pub pkg_temp: Option<f64>,
- /// Package power, watts. Computed by the run loop from RAPL energy
- /// deltas between ticks (sensors.rs exposes the raw counter; the loop
- /// owns the delta math so it can handle counter wraps + first-tick).
- pub pkg_power: Option<f64>,
- /// Max frequency across all logical cpus at this tick, kHz. The "peak
- /// clock" — the per-ISA offset measurement, the whole point of the
- /// sweep. Run loop also tracks the session peak across ticks.
- pub peak_clock_khz: Option<u64>,
- /// Per-logical-cpu frequencies (for the TUI's core bars, eventually).
- pub core_freqs: Vec<(usize, u64)>,
- /// Raw RAPL energy counter from this tick (for delta math next tick).
- /// NOT serialized into the report — the report writer skips this field.
- pub(crate) energy_uj: Option<u64>,
-}
-
-/// Read a fresh Snapshot. `prev_energy_uj` is the previous RAPL reading
-/// for power delta computation — pass None on the first tick. `dt_secs`
-/// is the elapsed time since the previous tick (for the watts calculation).
-pub fn snapshot(prev_energy_uj: Option<u64>, dt_secs: f64) -> Snapshot {
- let vcore = sio_vcore();
- let pkg_temp = package_temp();
- let core_freqs = cpu_frequencies();
- let peak_clock_khz = core_freqs.iter().map(|(_, khz)| *khz).max();
-
- // RAPL: find the package domain (kernel name starts with "package"),
- // read its energy counter, and compute watts from the delta. Same fix
- // as adlermon 2b920f41 — the kernel label is "package-0" but we match
- // on starts_with("package") so the watts path actually runs.
- let mut pkg_power = None;
- let mut energy_now: Option<u64> = None;
- if let Some(dom) = rapl_domains()
- .into_iter()
- .find(|d| d.name.starts_with("package"))
- {
- if let Some(e_now) = rapl_energy_uj(&dom.id) {
- if let Some(e_prev) = prev_energy_uj {
- if dt_secs > 0.0 {
- let delta = e_now.saturating_sub(e_prev);
- pkg_power = Some(delta as f64 / 1_000_000.0 / dt_secs);
- }
- }
- energy_now = Some(e_now);
- }
- }
-
- Snapshot {
- t: 0.0,
- vcore,
- pkg_temp,
- pkg_power,
- peak_clock_khz,
- core_freqs,
- energy_uj: None,
- }
- .with_energy(energy_now)
-}
-
-impl Snapshot {
- /// Attach the raw RAPL energy counter so the run loop can feed it back
- /// as `prev_energy_uj` on the next tick. Stored on the Snapshot so it
- /// travels with the sample without polluting the report schema (the
- /// report writer skips this field).
- pub fn with_energy(mut self, energy_uj: Option<u64>) -> Self {
- self.energy_uj = energy_uj;
- self
- }
- /// The raw RAPL counter from this tick (for delta math next tick).
- pub fn energy_uj(&self) -> Option<u64> {
- self.energy_uj
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn e_core_classification() {
- assert!(!is_e_core(0));
- assert!(!is_e_core(11));
- assert!(is_e_core(12));
- assert!(is_e_core(15));
- }
-
- #[test]
- fn p_and_e_cpu_lists() {
- assert_eq!(p_cpus(), vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
- assert_eq!(e_cpus(), vec![12, 13, 14, 15]);
- }
-
- #[test]
- fn snapshot_first_tick_has_no_power() {
- // First tick (prev_energy_uj = None) can't compute watts yet.
- let s = snapshot(None, 0.25);
- // pkg_power should be None on the first tick regardless of RAPL
- // access — there's no previous reading to delta against.
- assert_eq!(s.pkg_power, None);
- }
-
- #[test]
- fn snapshot_energy_round_trips() {
- let s = snapshot(None, 0.25);
- // If RAPL is readable, energy_uj() matches what was stored; if not,
- // both are None. Either way the accessor round-trips.
- assert_eq!(s.energy_uj(), s.energy_uj);
- }
-}
\ No newline at end of file
diff --git a/src/workload.rs b/src/workload.rs
index 2b9f885..0441c61 100644
--- a/src/workload.rs
+++ b/src/workload.rs
@@ -1,14 +1,14 @@
-//! Workload abstraction for the ISA sweep. A Workload is something the run
-//! loop can start, let run for a duration while sensors sample around it,
-//! stop, and read a stability verdict from.
+//! Workload abstraction for the sweep. A Workload is something the run
+//! loop can start, let run for a duration, stop, and read a stability
+//! verdict + a perf score from.
//!
//! Two shapes live behind this trait:
//! - **Spawned** workloads (stress-ng, y-cruncher, 7zip) own a child
//! process. `start()` spawns, `stop()` signals + waits, the verdict
-//! comes from the exit code + parsed stdout.
+//! comes from the exit code, the score comes from parsed stdout.
//! - **In-process** workloads (c2c-latency, dram-latency, deferred) run
//! in a thread. `start()` spawns the thread, `stop()` sets a flag,
-//! the verdict comes from the measurement itself.
+//! the verdict + score come from the measurement itself.
//!
//! The trait abstracts over both so the run loop treats every workload
//! the same. v1 ships the spawned impls; in-process impls come next.
@@ -19,10 +19,11 @@ use std::io::{self, Read};
use std::path::PathBuf;
use std::process::{Child, Command, ExitStatus};
-/// What a workload reports when it's done. The stability verdict is the
-/// product — `Clean` means the workload's own self-check passed (y-cruncher
-/// "Passed", 7z exit 0, stress-ng exit 0); anything else is a failure mode
-/// the OC stability question cares about.
+/// What a workload reports when it's done. The stability verdict is one
+/// half of the product — `Clean` means the workload's own self-check
+/// passed (y-cruncher "Passed", 7z exit 0, stress-ng exit 0); anything
+/// else is a failure mode the stability question cares about. The other
+/// half is the [`Score`], returned by [`Workload::score`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
/// Workload self-check passed. The chip was stable for this run.
@@ -38,6 +39,43 @@ pub enum Verdict {
Error(String),
}
+/// The perf score a workload emits. Stability is the verdict's job; this
+/// is the comparative-perf number across configs. Per-kind so the TUI +
+/// report can render each workload's natural metrics without forcing a
+/// one-size-fits-all number. Stress tests (stress-ng-cpu) report `None`
+/// — they exist for stability, not for a score.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Score {
+ /// No score (stress-ng-cpu and other pure stability workloads).
+ None,
+ /// 7-Zip benchmark. `rating_mips` is the headline comparative number
+ /// (the `Tot: ... MIPS` column); the rest is the breakdown.
+ SevenZip {
+ rating_mips: u64,
+ r_u_mips: u64,
+ usage_pct: u64,
+ compress_mips: u64,
+ decompress_mips: u64,
+ },
+ /// y-cruncher stress. One entry per algorithm that ran. Each entry's
+ /// `bits_per_sec` is the test speed (e.g. 8.94e8); `passed` mirrors
+ /// the verdict but per-test so a partial run still records what
+ /// completed. For the configured FFTv4-only sweep there's one entry.
+ YCruncher { tests: Vec<YCruncherTest> },
+}
+
+/// One y-cruncher algorithm result.
+#[derive(Clone, Debug, PartialEq)]
+pub struct YCruncherTest {
+ /// Algorithm tag, e.g. "FFTv4", "BKT", "N63".
+ pub tag: String,
+ /// Whether this algorithm's self-check passed.
+ pub passed: bool,
+ /// Test speed in bits/sec (normalized from the `<m> * 10^<e>` form
+ /// y-cruncher prints).
+ pub bits_per_sec: f64,
+}
+
/// The common surface the run loop drives. See the module doc for the two
/// shapes behind this trait.
pub trait Workload {
@@ -49,7 +87,7 @@ pub trait Workload {
/// Start the workload. Called once per run.
fn start(&mut self) -> io::Result<()>;
/// True between `start()` and `stop()`. The run loop polls this each
- /// tick to decide whether to keep sampling or move to cooldown.
+ /// tick to decide whether to keep waiting or move to cooldown.
/// `&mut self` because `try_wait` mutates the Child's internal state.
fn is_running(&mut self) -> bool;
/// Stop the workload. May be called before the duration elapses (user
@@ -61,6 +99,9 @@ pub trait Workload {
/// done (either `is_running()` went false or the run loop's own
/// duration timer expired and it called `stop()`).
fn wait(&mut self) -> Verdict;
+ /// Parse the perf score from captured stdout. Called after `wait()`.
+ /// Implementations that don't produce a score return `Score::None`.
+ fn score(&mut self) -> Score;
}
// ---------------------------------------------------------------------------
@@ -242,6 +283,9 @@ impl Workload for StressNg {
Err(e) => Verdict::Error(format!("wait: {e}")),
}
}
+ fn score(&mut self) -> Score {
+ Score::None
+ }
}
// ---------------------------------------------------------------------------
@@ -391,6 +435,12 @@ impl Workload for YCruncher {
Err(e) => Verdict::Error(format!("wait: {e}")),
}
}
+ fn score(&mut self) -> Score {
+ let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
+ Score::YCruncher {
+ tests: parse_ycruncher_scores(&stdout),
+ }
+ }
}
// ---------------------------------------------------------------------------
@@ -486,6 +536,10 @@ impl Workload for SevenZip {
Err(e) => Verdict::Error(format!("wait: {e}")),
}
}
+ fn score(&mut self) -> Score {
+ let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
+ parse_7zip_score(&stdout).unwrap_or(Score::None)
+ }
}
// ---------------------------------------------------------------------------
@@ -516,6 +570,155 @@ pub fn from_spec(
}
}
+// ---------------------------------------------------------------------------
+// Score parsers
+// ---------------------------------------------------------------------------
+
+/// Strip ANSI CSI escape sequences (`\x1b[ ... m` and similar) + convert
+/// the `\r` that y-cruncher emits for live-console line overwrites into
+/// `\n` so the score parser can split lines cleanly. Not a full ANSI
+/// stripper — enough for y-cruncher's color codes, which are all SGR
+/// (`\x1b[...m`).
+fn strip_ansi(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ let mut chars = s.chars().peekable();
+ while let Some(c) = chars.next() {
+ if c == '\x1b' {
+ // Skip `\x1b[ ... <final byte>`. Final byte is in the range
+ // 0x40-0x7E per the CSI spec; SGR ends in `m`.
+ if chars.peek() == Some(&'[') {
+ chars.next();
+ for c in chars.by_ref() {
+ if (c as u32) >= 0x40 && (c as u32) <= 0x7E {
+ break;
+ }
+ }
+ continue;
+ }
+ // Lone ESC — drop it.
+ continue;
+ }
+ if c == '\r' {
+ // y-cruncher uses bare `\r` to overwrite the live console
+ // line for progress updates. Convert to `\n` so each
+ // overwriting pass becomes its own line for the parser.
+ out.push('\n');
+ continue;
+ }
+ out.push(c);
+ }
+ out
+}
+
+/// Parse y-cruncher's `Running <TAG>: Passed Test Speed: <m> * 10^<exp>
+/// bits / sec` lines. Each test prints twice (once padded for the live
+/// console, once clean) — dedup by tag, keep the first. Tests that
+/// didn't run (Disabled in the menu) don't appear, so the returned vec
+/// only has tests that actually executed.
+fn parse_ycruncher_scores(stdout: &str) -> Vec<YCruncherTest> {
+ let plain = strip_ansi(stdout);
+ let mut tests: Vec<YCruncherTest> = Vec::new();
+ for line in plain.lines() {
+ // Match the clean form: "Running FFTv4: Passed Test Speed: 8.94 * 10^08 bits / sec"
+ // Skip the "live console" form (has trailing whitespace padding
+ // before "Running" repeats) by requiring the line to END with
+ // "bits / sec" (no trailing spaces).
+ let trimmed = line.trim_end();
+ if !trimmed.ends_with("bits / sec") {
+ continue;
+ }
+ let Some(rest) = trimmed.strip_prefix("Running ") else {
+ continue;
+ };
+ let Some((tag_part, speed_part)) = rest.split_once(": ") else {
+ continue;
+ };
+ if !speed_part.starts_with("Passed") && !speed_part.starts_with("failed") {
+ continue;
+ }
+ let passed = speed_part.starts_with("Passed");
+ // Speed_part looks like: "Passed Test Speed: 8.94 * 10^08 bits / sec"
+ // "failed Test Speed: <m> * 10^<exp> bits / sec"
+ let Some(speed_idx) = speed_part.find("Test Speed:") else {
+ continue;
+ };
+ let after = speed_part[speed_idx + "Test Speed:".len()..].trim();
+ // after = "<m> * 10^<exp> bits / sec"
+ let Some((mantissa_exp, _)) = after.split_once("bits / sec") else {
+ continue;
+ };
+ let mantissa_exp = mantissa_exp.trim();
+ // mantissa_exp = "8.94 * 10^08"
+ let Some((m_str, e_str)) = mantissa_exp.split_once(" * 10^") else {
+ continue;
+ };
+ let Ok(m) = m_str.trim().parse::<f64>() else {
+ continue;
+ };
+ let Ok(e) = e_str.trim().parse::<i32>() else {
+ continue;
+ };
+ let bits_per_sec = m * 10f64.powi(e);
+ let tag = tag_part.trim().to_string();
+ // Dedup by tag — y-cruncher prints each test's result twice.
+ if tests.iter().any(|t| t.tag == tag) {
+ continue;
+ }
+ tests.push(YCruncherTest { tag, passed, bits_per_sec });
+ }
+ tests
+}
+
+/// 7z `Avr:` line tuple: (compress speed_kibs, usage, r_u_mips, rating,
+/// decompress speed_kibs, usage, r_u_mips, rating).
+type AvrTuple = (u64, u64, u64, u64, u64, u64, u64, u64);
+
+/// Parse 7-Zip's `7z b` stdout. The footer has:
+/// `Avr: 70556 1379 5346 73766 | 674420 1483 3961 58751`
+/// `Tot: 1431 4653 66259`
+/// `Tot:` = usage_pct, r_u_mips, rating_mips.
+/// `Avr:` = compress (speed_kibs, usage, r_u, rating) | decompress (same).
+/// We want compress r_u (5346) + decompress r_u (3961) from Avr, and all
+/// three from Tot. Returns None if either line is missing/malformed.
+fn parse_7zip_score(stdout: &str) -> Option<Score> {
+ let mut avr: Option<AvrTuple> = None;
+ let mut tot: Option<(u64, u64, u64)> = None;
+ for line in stdout.lines() {
+ let line = line.trim();
+ if line.starts_with("Avr:") {
+ // Avr: <c_speed> <c_usage> <c_ru> <c_rating> | <d_speed> <d_usage> <d_ru> <d_rating>
+ let rest = line.strip_prefix("Avr:").unwrap_or("").trim();
+ let Some((left, right)) = rest.split_once('|') else {
+ continue;
+ };
+ let lc: Vec<u64> = left.split_whitespace().filter_map(|s| s.parse().ok()).collect();
+ let rc: Vec<u64> = right.split_whitespace().filter_map(|s| s.parse().ok()).collect();
+ if lc.len() >= 4 && rc.len() >= 4 {
+ avr = Some((
+ lc[0], lc[1], lc[2], lc[3],
+ rc[0], rc[1], rc[2], rc[3],
+ ));
+ }
+ } else if line.starts_with("Tot:") {
+ // Tot: <usage> <r_u> <rating>
+ let rest = line.strip_prefix("Tot:").unwrap_or("").trim();
+ let nums: Vec<u64> = rest.split_whitespace().filter_map(|s| s.parse().ok()).collect();
+ if nums.len() >= 3 {
+ tot = Some((nums[0], nums[1], nums[2]));
+ }
+ }
+ }
+ let (_c_speed, _c_usage, c_ru, _c_rating, _d_speed, _d_usage, d_ru, _d_rating) = avr?;
+ let (usage_pct, r_u_mips, rating_mips) = tot?;
+ Some(Score::SevenZip {
+ rating_mips,
+ r_u_mips,
+ usage_pct,
+ compress_mips: c_ru,
+ decompress_mips: d_ru,
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -597,4 +800,92 @@ mod tests {
assert!(from_spec(&spec, std::path::Path::new("bin")).is_none(), "{name} should be None");
}
}
+
+ // --- score parser tests ---
+
+ /// Real y-cruncher FFTv4 stdout (single-algorithm run). Has ANSI
+ /// color codes + \r line noise + duplicate lines (live + log form).
+ #[test]
+ fn parse_ycruncher_fftv4_single_algorithm() {
+ let stdout = include_str!("../test-fixtures/yc-avx2-FFTv4.txt");
+ let tests = parse_ycruncher_scores(stdout);
+ assert_eq!(tests.len(), 1, "dedup by tag should leave one FFTv4 entry");
+ assert_eq!(tests[0].tag, "FFTv4");
+ assert!(tests[0].passed);
+ // 8.94 * 10^08 = 894_000_000
+ assert!((tests[0].bits_per_sec - 8.94e8).abs() < 1.0);
+ }
+
+ #[test]
+ fn parse_ycruncher_handles_plain_ascii() {
+ // No ANSI codes — just the clean log line.
+ let stdout = "Running FFTv4: Passed Test Speed: 8.55 * 10^08 bits / sec\n";
+ let tests = parse_ycruncher_scores(stdout);
+ assert_eq!(tests.len(), 1);
+ assert_eq!(tests[0].tag, "FFTv4");
+ assert!(tests[0].passed);
+ assert!((tests[0].bits_per_sec - 8.55e8).abs() < 1.0);
+ }
+
+ #[test]
+ fn parse_ycruncher_dedups_repeated_tags() {
+ // y-cruncher prints each test's result twice (live console + log).
+ let stdout = "Running FFTv4: Passed Test Speed: 1.01 * 10^09 bits / sec\n\
+ Running FFTv4: Passed Test Speed: 1.01 * 10^09 bits / sec\n";
+ let tests = parse_ycruncher_scores(stdout);
+ assert_eq!(tests.len(), 1);
+ }
+
+ #[test]
+ fn parse_ycruncher_skips_non_score_lines() {
+ let stdout = "\
+Running from console...
+Iteration: 0 Total Elapsed Time: 0.554 seconds ( 0.009 minutes )
+Running FFTv4:
+Running FFTv4: Passed Test Speed: 8.94 * 10^08 bits / sec
+Test Finished. Waiting for threads to terminate...
+";
+ let tests = parse_ycruncher_scores(stdout);
+ assert_eq!(tests.len(), 1);
+ assert_eq!(tests[0].tag, "FFTv4");
+ }
+
+ #[test]
+ fn parse_7zip_real_stdout() {
+ let stdout = include_str!("../test-fixtures/7z-default.txt");
+ let score = parse_7zip_score(stdout).expect("7z footer has Avr + Tot");
+ match score {
+ Score::SevenZip {
+ rating_mips,
+ r_u_mips,
+ usage_pct,
+ compress_mips,
+ decompress_mips,
+ } => {
+ assert_eq!(rating_mips, 66259, "Tot Rating MIPS");
+ assert_eq!(r_u_mips, 4653, "Tot R/U MIPS");
+ assert_eq!(usage_pct, 1431, "Tot Usage %");
+ assert_eq!(compress_mips, 5346, "Avr compress R/U MIPS");
+ assert_eq!(decompress_mips, 3961, "Avr decompress R/U MIPS");
+ }
+ other => panic!("expected SevenZip, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_7zip_returns_none_when_footer_missing() {
+ assert!(parse_7zip_score("no footer here\njust junk").is_none());
+ }
+
+ #[test]
+ fn strip_ansi_removes_sgr_codes() {
+ let s = "\x1b[01;32mPassed\x1b[01;37m Test Speed";
+ assert_eq!(strip_ansi(s), "Passed Test Speed");
+ }
+
+ #[test]
+ fn strip_ansi_passes_plain_text() {
+ assert_eq!(strip_ansi("plain text"), "plain text");
+ assert_eq!(strip_ansi("with\rcarriage"), "with\ncarriage");
+ }
}
\ No newline at end of file
diff --git a/test-fixtures/7z-default.txt b/test-fixtures/7z-default.txt
new file mode 100644
index 0000000..13900f8
--- /dev/null
+++ b/test-fixtures/7z-default.txt
@@ -0,0 +1,28 @@
+
+7-Zip (z) 26.02 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-06-25
+ 64-bit locale=en_US.utf8 Threads:16 OPEN_MAX:524288
+
+Compiler: ver:15.3.0 GCC 15.3.0 : AVX2
+Linux : 7.2.2-tkg-bore-llvm : #1 SMP PREEMPT_DYNAMIC TKG Sat Aug 29 11:21:27 CDT 2026 : x86_64
+PageSize:4KB HPS:2MB THPS:2MB THP:always hwcap:2 hwcap2:2
+12th Gen Intel(R) Core(TM) i5-12600KF
+(90672) (LP)
+
+1T CPU Freq (MHz): 2336 4470 4815 4833 4791 4830 4811
+8T CPU Freq (MHz): 673% 3827 677% 3832
+16T CPU Freq (MHz): 1056% 2846 1118% 3019
+
+RAM size: 31926 MB (LP), # CPU hardware threads: 16
+RAM usage: 3559 MB (LP), # Benchmark threads: 16
+
+ Compressing | Decompressing
+Dict Speed Usage R/U Rating | Speed Usage R/U Rating
+ KiB/s % MIPS MIPS | KiB/s % MIPS MIPS
+
+22: 82943 1408 5730 80688 | 666900 1422 4000 56866
+23: 71101 1363 5316 72444 | 694654 1523 3947 60093
+24: 66357 1367 5219 71348 | 676704 1498 3963 59375
+25: 61821 1379 5118 70585 | 659420 1491 3934 58669
+---------------------------------- | ------------------------------
+Avr: 70556 1379 5346 73766 | 674420 1483 3961 58751
+Tot: 1431 4653 66259
diff --git a/test-fixtures/yc-avx2-FFTv4.txt b/test-fixtures/yc-avx2-FFTv4.txt
new file mode 100644
index 0000000..a1872f8
--- /dev/null
+++ b/test-fixtures/yc-avx2-FFTv4.txt
@@ -0,0 +1,79 @@
+Running from console...
+
+
+Checking processor/OS features...
+
+Required Features:
+[01;36m x64, ABM, BMI1, BMI2,
+ SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2,
+ AVX, FMA3, AVX2
+
+[01;37m
+
+Reading Hardware Topology...
+
+Logical Cores:
+ 0-15
+
+Physical Cores:
+ 0-9
+
+[01;31m
+Unable to read or parse "/sys/devices/system/node/".
+Thread and node affinities may not function correctly.
+
+[01;37mPress ENTER to continue . . .[01;36m[39;49mNUMA Node 0:
+ 0-15
+
+Scheduling Group 0:
+ 0-15
+
+
+[01;35m
+Component Stress Tester
+
+[01;37m 1 Logical Cores: [01;36m16[01;37m
+[01;37m 2 Memory: [01;33m24.7 GiB[01;37m[01;37m ( [01;36m1.54 GiB[01;37m per thread )
+[01;37m 3 NUMA Mode: [01;31mLocal - Memory allocated from local thread.[01;37m
+ 4/5 Time Limit: [01;32m5[01;37m seconds per test / [01;32m5[01;37m seconds total
+[01;37m 6 Stop on Error: [01;32mEnabled[01;37m
+
+[01;37m 7/8 [22;32mEnable All Tests / Disable All Tests[01;37m
+[01;37m 9/10 [22;32mLoad/Save Configuration File[01;37m
+
+[01;37m # Tag Test Name Mem/Thread Component CPU------Mem
+ 11 [22;31mBKT [01;37m [22;36mBasecase + Karatsuba [22;31mDisabled [22;33mScalar Integer [01;37m-|--------
+ 12 [22;31mBBP [01;37m [22;36mBBP Digit Extraction [22;31mDisabled [22;33mAVX2 Float [01;37m|---------
+ 13 [22;31mSFTv4[01;37m [22;36mSmall In-Cache FFTv4 [22;31mDisabled [22;33mAVX2 Float [01;37m-|--------
+ 14 [22;31mSNT [01;37m [22;36mSmall In-Cache N63 [22;31mDisabled [22;33mAVX2 Integer [01;37m--|-------
+ 15 [22;31mSVT [01;37m [22;36mSmall In-Cache VT3 [22;31mDisabled [22;33mAVX2 Float [01;37m--|-------
+ 16 [01;31mFFTv4[01;37m [01;36mFast Fourier Transform (v4) [01;32m 246 MiB [01;33mAVX2 Float [01;37m---------|
+ 17 [22;31mN63 [01;37m [22;36mClassic NTT (v2) [22;31mDisabled [22;33mAVX2 Integer [01;37m---|------
+ 18 [22;31mVT3 [01;37m [22;36mVector Transform (v3) [22;31mDisabled [22;33mAVX2 Float [01;37m----|-----
+
+[01;37m 0 [01;32mStart Stress-Testing![01;37m
+
+
+Allocating Memory...
+[01;37m Core 0: [01;32m 246 MiB[01;33m
+[01;37m Core 1: [01;32m 246 MiB[01;33m
+[01;37m Core 2: [01;32m 246 MiB[01;33m
+[01;37m Core 3: [01;32m 246 MiB[01;33m
+[01;37m Core 5: [01;32m 246 MiB[01;33m
+[01;37m Core 4: [01;32m 246 MiB[01;33m
+[01;37m Core 6: [01;32m 246 MiB[01;33m
+[01;37m Core 7: [01;32m 246 MiB[01;33m
+[01;37m Core 8: [01;32m 246 MiB[01;33m
+[01;37m Core 9: [01;32m 246 MiB[01;33m
+[01;37m Core 11: [01;32m 246 MiB[01;33m
+[01;37m Core 10: [01;32m 246 MiB[01;33m
+[01;37m Core 15: [01;32m 246 MiB[01;33m
+[01;37m Core 12: [01;32m 246 MiB[01;33m
+[01;37m Core 13: [01;32m 246 MiB[01;33m
+[01;37m Core 14: [01;32m 246 MiB[01;33m
+
+[01;37mIteration: [01;33m0[01;37m[01;37m Total Elapsed Time: [01;32m0.554 seconds ( 0.009 minutes )[01;37m
+
Running FFTv4: [39;49m
Running FFTv4: [01;32mPassed[01;37m Test Speed: [01;36m8.94 * 10^08[01;37m bits / sec [39;49m
[01;37mRunning FFTv4: [01;32mPassed[01;37m Test Speed: [01;36m8.94 * 10^08[01;37m bits / sec[01;37m
+
+Test Finished. Waiting for threads to terminate...
+[39;49m
Reframe scope: record scores, not sensors (adlermon owns sensors)
- Deleted src/sensors.rs + --dump mode; adlerbench no longer reads sysfs.
adlermon watches clocks/temps/vCore concurrently; adlerbench drives
benchmarks and records scores.
- config.rs: dropped vcore_limit + temp_crit (no sensor thresholds to
flag when there are no sensor readings).
- workload.rs: added Score enum (None / SevenZip{rating_mips, r_u_mips,
usage_pct, compress_mips, decompress_mips} / YCruncher{tests:
Vec<YCruncherTest{tag, passed, bits_per_sec}>}) + score() method on the
Workload trait. StressNg returns Score::None (stability only, per user
direction). Added parse_7zip_score (Avr+Tot footer) + parse_ycruncher
scores (per-algorithm Test Speed lines) + strip_ansi helper that
CONVERTS \r to \n (y-cruncher uses bare \r for live-console line
overwrites; dropping collapses the progress block into one line).
- report.rs: schema v2. Dropped peak_clock_khz, peak_vcore, peak_pkg_temp,
peak_pkg_power, samples[] (the whole per-tick trace). Added per-kind
score field (JSON object, null for stress-ng). One flush at finish()
(no incremental per-tick rewrite — no samples to accumulate).
- run.rs: dropped per-tick sensors::snapshot() + prev_energy_uj +
last_sample + SampleTaken TickOutcome variant. finish_current_workload
calls w.score() alongside w.wait() and feeds both to report.finish().
- main.rs: --headless + --smoke-workload print the score via format_score.
- test-fixtures/: real captured 7z b + y-cruncher FFTv4 stdout committed;
parser tests include_str! them so the formats are pinned.
- 43/43 tests pass; clippy clean; live --headless verified (7z Clean
with score=65620 MIPS, all sub-fields populated).
369b7d34f6965094acb76fd9c404f85255db7e67
josie <administrator@josie-c.com> · 2026-09-01T21:24 ·
browse files at this commit
parents:
9ceada7