d7832fa9ebbe5d90d271a0476963d120b63755b1 / alderbench/src/workload.rs · 39044 bytes · raw
//! 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, 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 + 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.
#![allow(dead_code)] // consumed by run.rs + ui.rs — not yet wired
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 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.
Clean,
/// Workload self-check failed (y-cruncher "failed", 7z nonzero exit,
/// stress-ng nonzero exit). Instability at this ISA rail.
Failed(String),
/// Workload was stopped early (user quit, timeout, sweep cancelled).
/// Not a stability signal — the run didn't complete.
Stopped,
/// Workload couldn't run (binary missing, spawn error, etc.). Not a
/// stability signal — infrastructure failure.
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> },
/// Core-to-core cache-line latency. One entry per pinned logical-cpu
/// pair (upper triangle of the configured cores range);
/// `ns_per_hop` is the average time for one cache-line handoff.
C2c { pairs: Vec<C2cPair> },
/// Memory latency via pointer-chase. One entry per buffer size
/// (L1/L2/L3/DRAM buckets); `ns_per_access` is the average
/// dependent-load latency at that working-set size.
Dram { levels: Vec<DramLevel> },
}
/// One c2c pair measurement.
#[derive(Clone, Debug, PartialEq)]
pub struct C2cPair {
pub cpu_a: usize,
pub cpu_b: usize,
pub ns_per_hop: f64,
}
/// One dram-latency working-set measurement.
#[derive(Clone, Debug, PartialEq)]
pub struct DramLevel {
/// Chase buffer size, KiB.
pub size_kb: u64,
pub ns_per_access: f64,
}
/// 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 {
/// Human-readable name (e.g. "y-cruncher-avx2"). Used in the TUI and
/// the report.
fn name(&self) -> &str;
/// One-line params string (e.g. "60s cores=0-15 FFTv4"). For the report.
fn params(&self) -> String;
/// 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 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
/// quit, sweep cancelled). Must be safe to call on an already-stopped
/// workload.
fn stop(&mut self) -> io::Result<()>;
/// Block until the workload finishes (or was already stopped). Returns
/// the verdict. Called once, after the run loop decides the workload is
/// 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;
}
// ---------------------------------------------------------------------------
// Spawned-workload helper
// ---------------------------------------------------------------------------
/// Common machinery for workloads that spawn a child process. Owns the
/// Child, captures stdout (for verdict parsing), and provides a generic
/// `stop()` that tries SIGTERM then SIGKILL. Subtypes build the Command in
/// their `start()` and parse stdout in their `wait()`.
struct SpawnedWorkload {
child: Option<Child>,
stdout: Vec<u8>,
/// Set when `stop()` was called by the run loop (vs. the process
/// exiting on its own). Distinguishes `Stopped` from `Clean`/`Failed`.
stopped: bool,
/// Captured stderr (kept for diagnostics; not parsed for verdict).
stderr: Vec<u8>,
}
impl SpawnedWorkload {
fn new() -> Self {
SpawnedWorkload {
child: None,
stdout: Vec::new(),
stderr: Vec::new(),
stopped: false,
}
}
fn spawn(cmd: &mut Command) -> io::Result<Self> {
let child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
Ok(SpawnedWorkload {
child: Some(child),
stdout: Vec::new(),
stderr: Vec::new(),
stopped: false,
})
}
fn is_running(&mut self) -> bool {
match &mut self.child {
Some(child) => child.try_wait().ok().flatten().is_none(),
None => false,
}
}
fn stop(&mut self) -> io::Result<()> {
if let Some(child) = &mut self.child {
self.stopped = true;
// Try a polite SIGTERM first; the run loop will call wait()
// next which reaps. If TERM doesn't take in a couple seconds,
// escalate to SIGKILL — but we don't block here (the run loop
// owns timing). For now just SIGTERM and let wait() handle the
// rest. On Unix, `start_kill` sends SIGKILL; for SIGTERM we
// use the raw pid + nix-less libc. Keep it simple: SIGKILL
// directly. Stress workloads don't have cleanup that warrants
// a graceful TERM, and a stuck child would hang the sweep.
let _ = child.kill();
}
Ok(())
}
/// Drain stdout/stderr from the child into our buffers. Called by
/// subtypes' `wait()` before parsing the verdict. Reads as much as is
/// available without blocking.
fn drain(&mut self) -> io::Result<()> {
let Some(child) = &mut self.child else {
return Ok(());
};
if let Some(stdout) = &mut child.stdout {
let mut buf = [0u8; 4096];
loop {
let n = stdout.read(&mut buf)?;
if n == 0 {
break;
}
self.stdout.extend_from_slice(&buf[..n]);
}
}
if let Some(stderr) = &mut child.stderr {
let mut buf = [0u8; 4096];
loop {
let n = stderr.read(&mut buf)?;
if n == 0 {
break;
}
self.stderr.extend_from_slice(&buf[..n]);
}
}
Ok(())
}
/// Block until the child exits, drain stdout/stderr, and return the
/// raw ExitStatus. Called by subtypes' `wait()`.
fn wait_raw(&mut self) -> io::Result<Option<ExitStatus>> {
let Some(child) = &mut self.child else {
return Ok(None);
};
let status = child.wait()?;
// After wait() returns, the pipes are at EOF — drain anything we
// missed while we weren't polling.
self.drain()?;
Ok(Some(status))
}
fn _take_stdout(&mut self) -> String {
String::from_utf8_lossy(&self.stdout).to_string()
}
}
// ---------------------------------------------------------------------------
// stress-ng --cpu (integer rail)
// ---------------------------------------------------------------------------
/// Integer rail. `stress-ng --cpu N --taskset <range> --timeout T --cpu-method all`.
/// Stress-ng exits 0 on success, nonzero on failure (including bogo-op
/// self-check mismatches).
pub struct StressNg {
duration_secs: u64,
cores: String,
inner: SpawnedWorkload,
}
impl StressNg {
pub fn new(duration_secs: u64, cores: String) -> Self {
StressNg {
duration_secs,
cores,
inner: SpawnedWorkload::new(),
}
}
}
impl Workload for StressNg {
fn name(&self) -> &str {
"stress-ng-cpu"
}
fn params(&self) -> String {
format!(
"{}s cores={} --cpu --cpu-method all",
self.duration_secs, self.cores
)
}
fn start(&mut self) -> io::Result<()> {
let mut cmd = Command::new("stress-ng");
cmd.arg("--cpu")
.arg("15") // all logical cpus; --taskset below pins to the range
.arg("--taskset")
.arg(&self.cores)
.arg("--timeout")
.arg(format!("{}s", self.duration_secs))
.arg("--cpu-method")
.arg("all")
.arg("--metrics-brief");
self.inner = SpawnedWorkload::spawn(&mut cmd)?;
Ok(())
}
fn is_running(&mut self) -> bool {
self.inner.is_running()
}
fn stop(&mut self) -> io::Result<()> {
self.inner.stop()
}
fn wait(&mut self) -> Verdict {
let stopped = self.inner.stopped;
match self.inner.wait_raw() {
Ok(Some(status)) => {
if stopped {
return Verdict::Stopped;
}
if status.success() {
Verdict::Clean
} else {
Verdict::Failed(format!("stress-ng exit {}", status))
}
}
Ok(None) => Verdict::Error("no child".to_string()),
Err(e) => Verdict::Error(format!("wait: {e}")),
}
}
fn score(&mut self) -> Score {
Score::None
}
}
// ---------------------------------------------------------------------------
// y-cruncher (SSE or AVX2 rail, via per-microarch variant binary)
// ---------------------------------------------------------------------------
/// SSE or AVX2 rail. y-cruncher has NO CLI flag for ISA selection — we
/// force the ISA by invoking the per-microarch VARIANT BINARY directly
/// (NOT the wrapper, which auto-selects). Verified 2026-09-01:
/// SSE = Binaries/11-SNB ~ Hina (Sandy Bridge, SSE4.2 only)
/// AVX2 = Binaries/13-HSW ~ Airi (Haswell, AVX2)
/// Both run standalone, report "Passed"/"failed" per FFT round, "Stop on
/// Error: Enabled" by default. CLI: `<variant> skip-warnings stress -D:s
/// -TL:s [algorithm]`.
pub struct YCruncher {
duration_secs: u64,
cores: String,
variant: &'static str, // "11-SNB ~ Hina" or "13-HSW ~ Airi"
label: &'static str, // "sse" or "avx2"
bin_dir: PathBuf,
inner: SpawnedWorkload,
}
impl YCruncher {
pub fn sse(duration_secs: u64, cores: String, bin_dir: PathBuf) -> Self {
YCruncher {
duration_secs,
cores,
variant: "11-SNB ~ Hina",
label: "sse",
bin_dir,
inner: SpawnedWorkload::new(),
}
}
pub fn avx2(duration_secs: u64, cores: String, bin_dir: PathBuf) -> Self {
YCruncher {
duration_secs,
cores,
variant: "13-HSW ~ Airi",
label: "avx2",
bin_dir,
inner: SpawnedWorkload::new(),
}
}
/// Resolve the variant binary path. y-cruncher ships as a versioned
/// subdir under bin_dir (e.g. `bin/y-cruncher v0.8.7.9547-static/`),
/// so we glob for the first match rather than hardcode the version.
fn variant_path(&self) -> Option<PathBuf> {
let entries = std::fs::read_dir(&self.bin_dir).ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_str()?;
if name.starts_with("y-cruncher") {
let variant = entry.path().join("Binaries").join(self.variant);
if variant.exists() {
return Some(variant);
}
}
}
None
}
}
impl Workload for YCruncher {
fn name(&self) -> &str {
match self.label {
"sse" => "y-cruncher-sse",
"avx2" => "y-cruncher-avx2",
_ => "y-cruncher",
}
}
fn params(&self) -> String {
format!(
"{}s cores={} variant={} stress FFTv4",
self.duration_secs, self.cores, self.variant
)
}
fn start(&mut self) -> io::Result<()> {
let variant_path = self.variant_path().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"y-cruncher variant '{}' not found under bin_dir {:?} \
(expected bin/y-cruncher v*/Binaries/{})",
self.variant, self.bin_dir, self.variant,
),
)
})?;
// Canonicalize before setting current_dir — when current_dir is
// set, the kernel resolves the executable path RELATIVE TO THE NEW
// cwd, so a relative variant path would be looked up inside the
// y-cruncher bundle dir and not found. Absolute path survives the
// cwd change.
let variant_path = variant_path.canonicalize().map_err(|e| {
io::Error::new(
io::ErrorKind::NotFound,
format!("y-cruncher variant path canonicalize failed: {e}"),
)
})?;
// Wrap in `taskset -c <cores>` when the cores range is a subset
// (not all 0-15). y-cruncher spawns 16 threads by default + has no
// CLI flag for affinity; taskset pins it at the kernel level. The
// full-range case skips the wrapper (no point constraining to all).
let mut cmd = taskset_command(&self.cores, &variant_path);
cmd.arg("skip-warnings")
.arg("stress")
// y-cruncher expects `-D:3` as ONE arg (not `-D:` + `3`).
.arg(format!("-D:{}", self.duration_secs))
.arg(format!("-TL:{}", self.duration_secs))
.arg("FFTv4");
// y-cruncher reads its Libraries.txt etc. from CWD — run from the
// variant's parent directory (the static bundle dir).
if let Some(dir) = variant_path.parent().and_then(|p| p.parent()) {
cmd.current_dir(dir);
}
self.inner = SpawnedWorkload::spawn(&mut cmd)?;
Ok(())
}
fn is_running(&mut self) -> bool {
self.inner.is_running()
}
fn stop(&mut self) -> io::Result<()> {
self.inner.stop()
}
fn wait(&mut self) -> Verdict {
let stopped = self.inner.stopped;
match self.inner.wait_raw() {
Ok(Some(status)) => {
if stopped {
return Verdict::Stopped;
}
let stdout = String::from_utf8_lossy(&self.inner.stdout).to_string();
// y-cruncher prints "Running FFTv4: Passed" per round and
// "failed" on mismatch. The definitive signal is the exit
// code (nonzero on self-check failure since "Stop on Error"
// is enabled by default), but we also scan stdout for the
// explicit "failed" string in case of partial output.
let failed_text = stdout
.lines()
.any(|l| l.contains("failed") && !l.contains("Running"));
if status.success() && !failed_text {
Verdict::Clean
} else if failed_text {
Verdict::Failed(format!(
"y-cruncher self-check failed (variant={})",
self.variant
))
} else {
Verdict::Failed(format!(
"y-cruncher exit {} (variant={})",
status, self.variant
))
}
}
Ok(None) => Verdict::Error("no child".to_string()),
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),
}
}
}
// ---------------------------------------------------------------------------
// 7-Zip benchmark (the z-7ip bench — NOT y-cruncher)
// ---------------------------------------------------------------------------
/// z-7ip bench. `7z b` runs 7-Zip's built-in compression/decompression
/// benchmark; CPU + memory bandwidth throughput. Reports MIPS + total
/// score (the comparative-perf metric across configs). Exits 0 on success.
/// `7z b -mmt<N>` sets thread count; we pass cores count translated from
/// the range string (0-15 → 16 threads). Defaults to all logical cpus if
/// the range is "0-15".
pub struct SevenZip {
duration_secs: u64,
cores: String,
inner: SpawnedWorkload,
}
impl SevenZip {
pub fn new(duration_secs: u64, cores: String) -> Self {
SevenZip {
duration_secs,
cores,
inner: SpawnedWorkload::new(),
}
}
/// Translate a cores range ("0-15", "0-11", "12-15") into a thread
/// count for `7z b -mmt<N>`. Returns None if the range is malformed
/// — 7z's default (all cpus) is fine in that case.
fn thread_count(&self) -> Option<u32> {
let s = self.cores.trim();
if let Some((a, b)) = s.split_once('-') {
let a: u32 = a.trim().parse().ok()?;
let b: u32 = b.trim().parse().ok()?;
if b >= a {
return Some(b - a + 1);
}
}
None
}
}
impl Workload for SevenZip {
fn name(&self) -> &str {
"7zip-bench"
}
fn params(&self) -> String {
format!(
"{}s cores={} (7z b{})",
self.duration_secs,
self.cores,
match self.thread_count() {
Some(n) => format!(" -mmt{n}"),
None => String::new(),
}
)
}
fn start(&mut self) -> io::Result<()> {
// Wrap in `taskset -c <cores>` when the cores range is a subset
// (not all 0-15). 7z's `-mmt<N>` only sets THREAD count, not
// affinity — taskset actually pins the process to the range. For
// the full-range case we skip the wrapper (no point constraining
// to all) + keep `-mmt<N>` for thread count.
let mut cmd = taskset_command(&self.cores, "7z");
cmd.arg("b");
if let Some(n) = self.thread_count() {
cmd.arg(format!("-mmt{n}"));
}
// 7z b runs a fixed number of passes by default, not time-bound.
// For a sweep we want a comparable run length across configs —
// pass `-mmt<N>` for thread control and let the duration_secs be
// a soft target (7z finishes when it finishes; the run loop will
// stop() if it overruns). Document this in the report.
self.inner = SpawnedWorkload::spawn(&mut cmd)?;
Ok(())
}
fn is_running(&mut self) -> bool {
self.inner.is_running()
}
fn stop(&mut self) -> io::Result<()> {
self.inner.stop()
}
fn wait(&mut self) -> Verdict {
let stopped = self.inner.stopped;
match self.inner.wait_raw() {
Ok(Some(status)) => {
if stopped {
return Verdict::Stopped;
}
if status.success() {
Verdict::Clean
} else {
Verdict::Failed(format!("7z exit {}", status))
}
}
Ok(None) => Verdict::Error("no child".to_string()),
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)
}
}
// ---------------------------------------------------------------------------
// Public constructor: map a WorkloadSpec name to an impl
// ---------------------------------------------------------------------------
/// Build a `Command` for `program`, wrapped in `taskset -c <cores>` when
/// `cores` is a subset of the available CPUs (not the full "0-15" range).
/// The full-range case skips the wrapper — there's no point constraining
/// to all CPUs. Used by YCruncher + SevenZip (StressNg has its own
/// `--taskset` flag, so it doesn't go through here). Returns a `Command`
/// pointing at either `taskset` (with program as an arg) or `program`
/// directly. `program` accepts `OsStr` so it works with both `&str`
/// (7z) and `&PathBuf` (y-cruncher's canonicalized variant path).
fn taskset_command<P: AsRef<std::ffi::OsStr>>(cores: &str, program: P) -> Command {
// Full-range patterns that mean "all CPUs" — skip the wrapper.
let is_full = matches!(cores.trim(), "0-15" | "all" | "");
if is_full {
return Command::new(program);
}
let mut cmd = Command::new("taskset");
cmd.arg("-c").arg(cores).arg(program);
cmd
}
/// Build a Workload from a config spec. Returns None for unknown names
/// or names that aren't wired yet (minecraft-server — deferred), so the
/// run loop skips them with a warning.
pub fn from_spec(
spec: &crate::config::WorkloadSpec,
bin_dir: &std::path::Path,
) -> Option<Box<dyn Workload>> {
match spec.name.as_str() {
"stress-ng-cpu" => Some(Box::new(StressNg::new(
spec.duration_secs,
spec.cores.clone(),
))),
"y-cruncher-sse" => Some(Box::new(YCruncher::sse(
spec.duration_secs,
spec.cores.clone(),
bin_dir.to_path_buf(),
))),
"y-cruncher-avx2" => Some(Box::new(YCruncher::avx2(
spec.duration_secs,
spec.cores.clone(),
bin_dir.to_path_buf(),
))),
"7zip-bench" => Some(Box::new(SevenZip::new(
spec.duration_secs,
spec.cores.clone(),
))),
// In-process hand-rolled workloads (see crate::inproc).
"c2c-latency" => Some(crate::inproc::c2c_latency(
spec.duration_secs,
spec.cores.clone(),
)),
"dram-latency" => Some(crate::inproc::dram_latency(
spec.duration_secs,
spec.cores.clone(),
)),
// Still deferred — needs PaperMC API verification.
"minecraft-server" => None,
_ => None,
}
}
// ---------------------------------------------------------------------------
// 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::*;
#[test]
fn stressng_params_include_range() {
let w = StressNg::new(60, "0-11".to_string());
assert!(w.params().contains("0-11"));
assert!(w.params().contains("60s"));
}
#[test]
fn ycruncher_variant_paths_resolve() {
let bin_dir = PathBuf::from("bin");
let sse = YCruncher::sse(60, "0-15".to_string(), bin_dir.clone());
let avx2 = YCruncher::avx2(60, "0-15".to_string(), bin_dir);
assert_eq!(sse.variant, "11-SNB ~ Hina");
assert_eq!(avx2.variant, "13-HSW ~ Airi");
// variant_path() should find the actual bundle if present in ./bin
// (smoke-tested below; skip assertion here so tests pass without
// the binary).
let _ = sse.variant_path();
let _ = avx2.variant_path();
}
#[test]
fn ycruncher_names_distinguish_rails() {
let sse = YCruncher::sse(60, "0-15".to_string(), PathBuf::from("bin"));
let avx2 = YCruncher::avx2(60, "0-15".to_string(), PathBuf::from("bin"));
assert_eq!(sse.name(), "y-cruncher-sse");
assert_eq!(avx2.name(), "y-cruncher-avx2");
}
#[test]
fn sevenzip_thread_count_parses_ranges() {
let w = SevenZip::new(60, "0-15".to_string());
assert_eq!(w.thread_count(), Some(16));
let w = SevenZip::new(60, "0-11".to_string());
assert_eq!(w.thread_count(), Some(12));
let w = SevenZip::new(60, "12-15".to_string());
assert_eq!(w.thread_count(), Some(4));
let w = SevenZip::new(60, "junk".to_string());
assert_eq!(w.thread_count(), None);
}
#[test]
fn from_spec_wires_known_names() {
let spec = crate::config::WorkloadSpec {
name: "stress-ng-cpu".to_string(),
duration_secs: 30,
cores: "0-15".to_string(),
};
let w = from_spec(&spec, std::path::Path::new("bin"));
assert!(w.is_some());
assert_eq!(w.unwrap().name(), "stress-ng-cpu");
}
#[test]
fn from_spec_rejects_unknown_names() {
let spec = crate::config::WorkloadSpec {
name: "no-such-workload".to_string(),
duration_secs: 30,
cores: "0-15".to_string(),
};
assert!(from_spec(&spec, std::path::Path::new("bin")).is_none());
}
#[test]
fn from_spec_wires_in_process_names() {
for name in ["c2c-latency", "dram-latency"] {
let spec = crate::config::WorkloadSpec {
name: name.to_string(),
duration_secs: 30,
cores: "0-15".to_string(),
};
let w = from_spec(&spec, std::path::Path::new("bin"));
assert!(w.is_some(), "{name} should be wired");
assert_eq!(w.unwrap().name(), name);
}
}
#[test]
fn from_spec_rejects_deferred_workloads() {
// minecraft-server is in scope but not yet implemented (needs
// PaperMC API verification) — from_spec returns None so the run
// loop can skip it with a warning rather than panic.
let spec = crate::config::WorkloadSpec {
name: "minecraft-server".to_string(),
duration_secs: 30,
cores: "0-15".to_string(),
};
assert!(from_spec(&spec, std::path::Path::new("bin")).is_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");
}
#[test]
fn taskset_command_wraps_subsets() {
// Full range → bare program, no taskset wrapper.
let cmd = taskset_command("0-15", "/bin/true");
let prog = cmd.get_program();
assert_eq!(prog, std::path::Path::new("/bin/true"));
// Subset → taskset -c <cores> <program>.
let cmd = taskset_command("0", "/bin/true");
assert_eq!(cmd.get_program(), std::path::Path::new("taskset"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(
args,
[
std::ffi::OsStr::new("-c"),
std::ffi::OsStr::new("0"),
std::ffi::OsStr::new("/bin/true")
]
);
// Also accepts a PathBuf (y-cruncher's canonicalized variant path).
let path = std::path::PathBuf::from("/bin/true");
let cmd = taskset_command("3", &path);
assert_eq!(cmd.get_program(), std::path::Path::new("taskset"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args.last(), Some(&std::ffi::OsStr::new("/bin/true")));
}
#[test]
fn taskset_command_skips_wrapper_for_all_and_empty() {
for full in ["0-15", "all", ""] {
let cmd = taskset_command(full, "/bin/true");
assert_eq!(
cmd.get_program(),
std::path::Path::new("/bin/true"),
"full={full:?} should skip wrapper"
);
}
}
}