//! 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 }, /// 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 }, /// 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 }, } /// 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 ` * 10^` 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, stdout: Vec, /// 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, } impl SpawnedWorkload { fn new() -> Self { SpawnedWorkload { child: None, stdout: Vec::new(), stderr: Vec::new(), stopped: false, } } fn spawn(cmd: &mut Command) -> io::Result { let child = cmd .stdin(std::process::Stdio::null()) .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> { 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 --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: ` 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 { 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}"), ) })?; // NOTE: y-cruncher pins its worker threads itself (verified on // 12600KF: taskset -c 0 still spread threads across cpu0-15), and // stress mode rejects -TD/-PF/-noSMT entirely — so a y-cruncher // stress leg ALWAYS occupies every logical core. The taskset // wrapper only constrains the initial thread; it does NOT make // the leg desktop-safe. `cores` is decorative for yc legs. 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` 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`. Returns None if the range is malformed /// — 7z's default (all cpus) is fine in that case. fn thread_count(&self) -> Option { 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 ` when the cores range is a subset // (not all 0-15). 7z's `-mmt` 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` 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` 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 ` 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 SevenZip (honors it: taskset pins + `-mmt` /// caps threads) + YCruncher (starts under it but then RE-PINS its own /// threads to its own core table — all cores, see YCruncher::start; /// 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>(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> { 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 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 : Passed Test Speed: * 10^ /// 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 { let plain = strip_ansi(stdout); let mut tests: Vec = 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: * 10^ bits / sec" let Some(speed_idx) = speed_part.find("Test Speed:") else { continue; }; let after = speed_part[speed_idx + "Test Speed:".len()..].trim(); // after = " * 10^ 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::() else { continue; }; let Ok(e) = e_str.trim().parse::() 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 { let mut avr: Option = None; let mut tot: Option<(u64, u64, u64)> = None; for line in stdout.lines() { let line = line.trim(); if line.starts_with("Avr:") { // Avr: | let rest = line.strip_prefix("Avr:").unwrap_or("").trim(); let Some((left, right)) = rest.split_once('|') else { continue; }; let lc: Vec = left .split_whitespace() .filter_map(|s| s.parse().ok()) .collect(); let rc: Vec = 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: let rest = line.strip_prefix("Tot:").unwrap_or("").trim(); let nums: Vec = 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 . 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" ); } } }