diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..588abe1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+# LLM project memory — local only, never committed
+.LLM_Memory/
+
+# Rust build artifacts
+target/
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..14b9774
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,7 @@
+This is Slop License
+
+I made this with LLMs. As such, I claim no ownership and provide no warranty.
+
+Do whatever the fuck you want with this repo.
+
+
diff --git a/alderbench/Cargo.lock b/alderbench/Cargo.lock
index a2af345..1d1bf0e 100644
--- a/alderbench/Cargo.lock
+++ b/alderbench/Cargo.lock
@@ -2,14 +2,6 @@
# It is not intended for manual editing.
version = 4
-[[package]]
-name = "alderbench"
-version = "0.1.0"
-dependencies = [
- "crossterm",
- "ratatui",
-]
-
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -19,6 +11,14 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "alderbench"
+version = "1.0.0"
+dependencies = [
+ "crossterm",
+ "ratatui",
+]
+
[[package]]
name = "allocator-api2"
version = "0.2.21"
diff --git a/alderbench/Cargo.toml b/alderbench/Cargo.toml
index 16e8b50..255c8c4 100644
--- a/alderbench/Cargo.toml
+++ b/alderbench/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "alderbench"
-version = "0.1.0"
+version = "1.0.0"
edition = "2021"
[dependencies]
diff --git a/alderbench/Makefile b/alderbench/Makefile
new file mode 100644
index 0000000..b0de8cf
--- /dev/null
+++ b/alderbench/Makefile
@@ -0,0 +1,30 @@
+# alderbench — ISA-cycling benchmark + stress sweep. Gentoo-friendly:
+# respects PREFIX, DESTDIR. `make check` runs fmt+clippy+test.
+PREFIX ?= /usr/local
+SYSCONF ?= /etc
+
+BIN := $(DESTDIR)$(PREFIX)/bin/alderbench
+CONF := $(DESTDIR)$(SYSCONF)/alderbench/alderbench.conf
+
+.PHONY: all build check install uninstall clean
+
+all: build
+
+build:
+ cargo build --release
+
+check:
+ cargo fmt --check
+ cargo clippy --all-targets -- -D warnings
+ cargo test
+
+install: build
+ install -Dm755 target/release/alderbench $(BIN)
+ install -Dm644 alderbench.conf $(CONF)
+
+uninstall:
+ rm -f $(BIN) $(CONF)
+ rmdir -p --ignore-fail-on-non-empty $(CONF) 2>/dev/null || true
+
+clean:
+ cargo clean
diff --git a/alderbench/README.md b/alderbench/README.md
index 7b5e2c8..7c817d2 100644
--- a/alderbench/README.md
+++ b/alderbench/README.md
@@ -2,70 +2,111 @@
Sister app to [aldermon](../aldermon) for the same Alder Lake box
(i5-12600KF, ASRock Z690M ITX/ax). Runs **comparative benchmarks and CPU
-stress tests** across stock vs OC/UV configurations, with a live TUI and
-a JSON report file on exit.
+stress tests** across stock vs OC/UV configurations, cycling instruction
+sets so each clock rail (integer / SSE / AVX2) is tested separately —
+"OCCT but it cycles ISAs", with per-ISA scores written to JSON reports.
-Standalone — no shared code with aldermon. Sensor conventions and the
-config parser shape are mirrored, not linked.
+Standalone — no shared code with aldermon. aldermon owns the live
+sensors (clocks/temps/power); alderbench owns the workloads + scores.
+Run them side by side during a sweep to watch what the load is doing.
## Usage
```sh
-cargo run --release # run configured workloads, live TUI (default)
-cargo run -- --report runs/foo.json # print a past run's human summary
-cargo run -- --list # enumerate available workloads
+alderbench # TUI picker: Stability | Bench | Core-cycle | Custom
+alderbench --tui # TUI sweep straight from the config (no picker)
+alderbench --preset stability # 1-hour ISA-cycling stability sweep (TUI)
+alderbench --preset bench # comparative scores sweep (TUI)
+alderbench --preset core-cycle # per-core single-core stability cycle (TUI)
+alderbench --headless # scripted sweep, no TUI (CI / scripts)
+alderbench --smoke-workload 7zip-bench # run one workload 3s, print verdict + score
+alderbench --report runs/1756-c2c-latency.json # print a past run's summary
+alderbench --list # enumerate workload names
+alderbench --help # usage
```
-The TUI needs a real terminal (pty); it will not run on redirected output.
+The TUI needs a real terminal; `--headless` covers scripts.
## Workloads
-v1 drives **stress-ng** (`/usr/bin/stress-ng`, unprivileged for CPU
-classes) for stability testing — CPU stressors at a configurable core
-count and duration, with per-tick sensor traces (vCore, package power,
-package temp, peak clock) captured throughout. Stability verdict per run:
-clean / WHEA seen / throttle seen / crash.
-
-Additional workloads (mprime, y-cruncher, phoronix-test-suite) are
-pluggable via the `Workload` trait but not yet implemented — see
-`to-do.md`.
+| name | what it does | score |
+|------------------|-----------------------------------------------------|------------------|
+| `stress-ng-cpu` | integer stressor, all `--cpu-method`s (stability) | verdict only |
+| `y-cruncher-sse` | SSE4.2 FFT stress via the SNB variant binary | bits/sec per algo|
+| `y-cruncher-avx2`| AVX2 FFT stress via the HSW variant binary | bits/sec per algo|
+| `7zip-bench` | `7z b` compression benchmark | MIPS rating |
+| `c2c-latency` | cache-line ping-pong across every pinned cpu pair | ns/hop per pair |
+| `dram-latency` | pointer-chase at L1/L2/L3/DRAM-sized working sets | ns/access per size |
+
+Verdicts come from each workload's own self-check (y-cruncher "Passed",
+7z exit code, ping-pong sequence integrity, chase cycle integrity) —
+that's the stability signal per ISA rail. `c2c`/`dram` are in-process
+(zero-dep: raw `sched_setaffinity` + `Instant`), and double as
+coherency/memory stability checks.
+
+Requirements: `stress-ng` + `7z` on PATH. y-cruncher: download the
+static build and unpack it under `./bin/` (gitignored) — alderbench
+invokes the per-microarchitecture variant binaries (`Binaries/11-SNB ~
+Hina` for SSE, `13-HSW ~ Airi` for AVX2) directly, never the wrapper.
+`minecraft-server` is planned; the run loop skips it with a warning.
+
+## TUI keys
+
+Picker: `j/k`/arrows navigate, `Enter` starts, `q` quits. Editor: `j/k`
+rows, `←/→` fields, `Enter` on Name cycles workloads, on Cores cycles
+`0-15`/`0-11`/`12-15`, on Duration/Cooldown/Loops opens numeric input;
+`a`/`d` add/delete rows, `</>` reorder. During a sweep `q` cancels —
+while a workload runs the panel heartbeat-refreshes every few seconds
+(never at render-loop frequency, so it won't steal CPU from single-core
+runs); workloads that outrun their estimate (7z's fixed passes) say so
+explicitly. Watch aldermon for live load.
## Config
-`alderbench.conf` is read from the working directory, then
-`~/.config/alderbench/alderbench.conf`. Simple `KEY = VALUE` lines; `#`
-comments; unknown keys ignored. All values optional — defaults target the
-i5-12600KF / Z690M ITX box.
+`alderbench.conf` is searched at `./` → `~/.config/alderbench/` →
+`/etc/alderbench/` (first match wins). Simple `KEY = VALUE` lines; `#`
+comments; unknown keys ignored; out-of-range numerics rejected.
```ini
-workloads = stress-ng-cpu # comma list (v1: only stress-ng-cpu)
-duration_secs = 60 # per-workload run length
-poll_ms = 250 # sensor sample interval
-cool_secs = 15 # cooldown between workloads
-vcore_limit = 1.403 # shown as marker on the vCore hero
-temp_crit = 95.0 # throttle/crit flag threshold
-report_dir = ./runs # where JSON reports are written
+poll_ms = 250 # tick interval (50..60000)
+cool_secs = 15 # cooldown between workloads (0..3600)
+loop_count = 1 # sweep repeats; 0 = infinite
+report_dir = runs # where JSON reports go
+bin_dir = bin # where the y-cruncher bundle lives
+
+# Either a comma list (names inherit per-workload defaults)…
+workloads = stress-ng-cpu, y-cruncher-sse, 7zip-bench
+
+# …or per-entry blocks:
+workload.1.name = y-cruncher-avx2
+workload.1.duration_secs = 300 # estimate, not a hard stop
+workload.1.cores = 0-11 # taskset-style range: 0-11 P-only, 12-15 E-only
```
+`duration_secs` is a display estimate: workloads run until they finish
+themselves (self-terminating stressors) or you quit; `7z b`'s fixed
+passes may overrun it.
+
## Reports
-Each run writes `./runs/<timestamp>-<workload>.json` (`schema_version: 1`):
-run metadata, per-tick sensor samples, workload exit status, and a
-stability verdict. A human summary is also printed to stdout after the
-TUI exits. Incremental flush — a crash mid-run still leaves a partial
-report.
+One JSON file per finished workload:
+`runs/<unix-seconds>-<workload>.json`, schema version 2:
-## Package power (RAPL)
+```json
+{"schema_version":2,"workload":"c2c-latency","params":"30s cores=0-15",
+ "started":1758366463,"isa_class":"other","verdict":{"kind":"clean"},
+ "score":{"kind":"c2c","pairs":[{"a":0,"b":1,"ns":28.0}, ...]}}
+```
-`energy_uj` is root-only on this kernel. To read watts as a normal user,
-install aldermon's udev rule once (it covers both apps — same box, same
-device):
+`alderbench --report <path>` prints a one-line human summary of any of
+these. Diff report dirs across OC configs to compare scores.
+
+## Install
```sh
-doas ../aldermon/powercap-pl-rules.sh
-doas usermod -aG adm $USER # then re-login
+make check # fmt + clippy + tests
+make install # DESTDIR/PREFIX aware; bin → $(PREFIX)/bin,
+ # conf → /etc/alderbench/alderbench.conf
```
-## Status
-
-Scaffold. See `to-do.md` for the build order.
\ No newline at end of file
+Gentoo-friendly: `make install DESTDIR="$D" PREFIX=/usr` in an ebuild.
diff --git a/alderbench/src/config.rs b/alderbench/src/config.rs
index 19cb0a7..78c2f74 100644
--- a/alderbench/src/config.rs
+++ b/alderbench/src/config.rs
@@ -83,6 +83,16 @@ fn spec(name: &str, duration_secs: u64, cores: &str) -> WorkloadSpec {
}
}
+/// Parse an unsigned numeric value; out-of-range or junk keeps the
+/// current value. Mirrors aldermon's parse_in so a hand-edited conf
+/// can't produce degenerate values (zero-length polls, absurd durations).
+fn parse_u64_in(val: &str, cur: u64, range: std::ops::RangeInclusive<u64>) -> u64 {
+ match val.parse::<u64>() {
+ Ok(v) if range.contains(&v) => v,
+ _ => cur,
+ }
+}
+
/// Parse one config line over a Config (mutates in place). Shape matches
/// aldermon's apply_line: strip `#` comments, trim, split on `=`, match
/// the key. Unknown keys silently ignored.
@@ -97,8 +107,8 @@ fn apply_line(cfg: &mut Config, line: &str) {
let key = key.trim();
let val = val.trim();
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),
+ "poll_ms" => cfg.poll_ms = parse_u64_in(val, cfg.poll_ms, 50..=60_000),
+ "cool_secs" => cfg.cool_secs = parse_u64_in(val, cfg.cool_secs, 0..=3600),
"loop_count" => cfg.loop_count = val.parse().unwrap_or(cfg.loop_count),
"report_dir" => cfg.report_dir = PathBuf::from(val),
"bin_dir" => cfg.bin_dir = PathBuf::from(val),
@@ -137,18 +147,28 @@ fn parse_workload_list(val: &str) -> Vec<WorkloadSpec> {
.collect()
}
+/// Search order: ./alderbench.conf (dev tree) → ~/.config/alderbench/
+/// (user override) → /etc/alderbench/ (system default, installed).
+/// First readable file wins; no merging.
pub fn load() -> Config {
let mut cfg = Config::default();
let candidates = [
Some(PathBuf::from("alderbench.conf")),
dirs_home().map(|h| h.join(".config/alderbench/alderbench.conf")),
+ Some(PathBuf::from("/etc/alderbench/alderbench.conf")),
];
let mut indexed: Vec<WorkloadSpec> = Vec::new();
let mut saw_indexed = false;
for path in candidates.into_iter().flatten() {
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
- if let Some(rest) = line.split('#').next().unwrap_or("").trim().strip_prefix("workload.") {
+ if let Some(rest) = line
+ .split('#')
+ .next()
+ .unwrap_or("")
+ .trim()
+ .strip_prefix("workload.")
+ {
if apply_indexed(&mut indexed, rest) {
saw_indexed = true;
}
@@ -194,7 +214,7 @@ fn apply_indexed(list: &mut Vec<WorkloadSpec>, rest: &str) -> bool {
let w = &mut list[idx - 1];
match field {
"name" => w.name = val.to_string(),
- "duration_secs" => w.duration_secs = val.parse().unwrap_or(w.duration_secs),
+ "duration_secs" => w.duration_secs = parse_u64_in(val, w.duration_secs, 1..=86_400),
"cores" => w.cores = val.to_string(),
_ => return false,
}
@@ -229,10 +249,29 @@ mod tests {
}
#[test]
- fn poll_ms_floors_at_50() {
+ fn poll_ms_rejects_out_of_range() {
let mut cfg = Config::default();
apply_line(&mut cfg, "poll_ms = 1");
- assert_eq!(cfg.poll_ms, 50);
+ assert_eq!(cfg.poll_ms, 250); // below floor → keep current
+ apply_line(&mut cfg, "poll_ms = 99999999");
+ assert_eq!(cfg.poll_ms, 250); // above ceiling → keep current
+ apply_line(&mut cfg, "poll_ms = 100");
+ assert_eq!(cfg.poll_ms, 100);
+ }
+
+ #[test]
+ fn rejects_out_of_range() {
+ let mut cfg = Config::default();
+ apply_line(&mut cfg, "cool_secs = 999999");
+ assert_eq!(cfg.cool_secs, 15);
+ let mut indexed: Vec<WorkloadSpec> = Vec::new();
+ assert!(apply_indexed(&mut indexed, "1.name = stress-ng-cpu"));
+ assert!(apply_indexed(&mut indexed, "1.duration_secs = 0"));
+ assert_eq!(indexed[0].duration_secs, 60); // below floor → keep
+ assert!(apply_indexed(&mut indexed, "1.duration_secs = 99999999"));
+ assert_eq!(indexed[0].duration_secs, 60); // above ceiling → keep
+ assert!(apply_indexed(&mut indexed, "1.duration_secs = 90"));
+ assert_eq!(indexed[0].duration_secs, 90);
}
#[test]
@@ -298,4 +337,4 @@ mod tests {
assert!(!apply_indexed(&mut indexed, "0.name = bad"));
assert!(indexed.is_empty());
}
-}
\ No newline at end of file
+}
diff --git a/alderbench/src/inproc.rs b/alderbench/src/inproc.rs
new file mode 100644
index 0000000..dc9ab89
--- /dev/null
+++ b/alderbench/src/inproc.rs
@@ -0,0 +1,584 @@
+//! In-process workloads: c2c-latency (cache-line ping-pong between core
+//! pairs) and dram-latency (pointer-chase through the memory hierarchy).
+//! Zero-dep: CPU pinning goes through a raw `sched_setaffinity` syscall
+//! (x86_64 nr 203); timing uses `std::time::Instant` (clock_gettime via
+//! the vDSO — ns resolution, no TSC calibration needed).
+//!
+//! Both workloads measure while pinning threads, so they double as
+//! coherency/memory stability checks: the ping-pong verifies every
+//! cache-line handoff carries the exact expected value (a coherence or
+//! clock failure shows up as a skipped/duplicated sequence number =
+//! Verdict::Failed), and the pointer-chase verifies the chase walks one
+//! full cycle of the randomized chain (a flipped address bit breaks the
+//! cycle). Verdict is the stability signal; the latencies are the score.
+
+use std::io;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use crate::workload::{C2cPair, DramLevel, Score, Verdict, Workload};
+
+/// Parse a stress-ng `--taskset`-style cpu list ("0-11", "12-15",
+/// "0-15", "3,7", ""/all = every logical cpu). Out-of-range entries are
+/// clamped to the CPU count; invalid tokens are skipped. Sorted + deduped.
+pub fn parse_cpu_list(cores: &str) -> Vec<usize> {
+ let nproc = thread::available_parallelism()
+ .map(|n| n.get())
+ .unwrap_or(1);
+ let trimmed = cores.trim();
+ if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("all") {
+ return (0..nproc).collect();
+ }
+ let mut cpus = Vec::new();
+ for token in trimmed.split(',') {
+ let token = token.trim();
+ if let Some((a, b)) = token.split_once('-') {
+ if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
+ for c in a..=b {
+ if c < nproc {
+ cpus.push(c);
+ }
+ }
+ }
+ } else if let Ok(c) = token.parse::<usize>() {
+ if c < nproc {
+ cpus.push(c);
+ }
+ }
+ }
+ cpus.sort_unstable();
+ cpus.dedup();
+ cpus
+}
+
+/// Pin the CALLING thread to one logical cpu. Raw syscall — std doesn't
+/// expose sched_setaffinity and we don't want a libc dep. Returns false
+/// if pinning isn't available (non-x86_64) or the kernel rejected it.
+fn pin_self(cpu: usize) -> bool {
+ let words = cpu / 64 + 1;
+ let mut mask = vec![0u64; words];
+ mask[cpu / 64] |= 1u64 << (cpu % 64);
+ let ret =
+ unsafe { sched_setaffinity_syscall(0, words * std::mem::size_of::<u64>(), mask.as_ptr()) };
+ ret == 0
+}
+
+#[cfg(target_arch = "x86_64")]
+unsafe fn sched_setaffinity_syscall(pid: i32, len: usize, mask: *const u64) -> isize {
+ const SYS_SCHED_SETAFFINITY: isize = 203;
+ let ret: isize;
+ core::arch::asm!(
+ "syscall",
+ inlateout("rax") SYS_SCHED_SETAFFINITY => ret,
+ in("rdi") pid,
+ in("rsi") len,
+ in("rdx") mask,
+ lateout("rcx") _,
+ lateout("r11") _,
+ options(nostack)
+ );
+ ret
+}
+
+#[cfg(not(target_arch = "x86_64"))]
+unsafe fn sched_setaffinity_syscall(_pid: i32, _len: usize, _mask: *const u64) -> isize {
+ -1
+}
+
+// ---------------------------------------------------------------------------
+// Shared in-process runner skeleton
+// ---------------------------------------------------------------------------
+
+/// Measurement body: runs to completion, honors `cancel` between units of
+/// work, returns (verdict, score). `Stops early → (Stopped, None)`.
+type Worker = fn(duration_secs: u64, cpus: &[usize], cancel: &AtomicBool) -> (Verdict, Score);
+
+/// One struct for both in-process workloads: the trait plumbing (start /
+/// is_running / stop / wait / score) is identical; only the worker fn
+/// pointer + name differ.
+struct InProc {
+ name: &'static str,
+ duration_secs: u64,
+ cores: String,
+ worker: Worker,
+ handle: Option<thread::JoinHandle<()>>,
+ cancel: Arc<AtomicBool>,
+ done: Arc<AtomicBool>,
+ result: Arc<Mutex<Option<(Verdict, Score)>>>,
+}
+
+impl InProc {
+ fn new(name: &'static str, duration_secs: u64, cores: String, worker: Worker) -> Self {
+ InProc {
+ name,
+ duration_secs,
+ cores,
+ worker,
+ handle: None,
+ cancel: Arc::new(AtomicBool::new(false)),
+ done: Arc::new(AtomicBool::new(false)),
+ result: Arc::new(Mutex::new(None)),
+ }
+ }
+}
+
+impl Workload for InProc {
+ fn name(&self) -> &str {
+ self.name
+ }
+
+ fn params(&self) -> String {
+ format!("~{}s cores={}", self.duration_secs, self.cores)
+ }
+
+ fn start(&mut self) -> io::Result<()> {
+ self.cancel.store(false, Ordering::Relaxed);
+ self.done.store(false, Ordering::Relaxed);
+ *self.result.lock().unwrap() = None;
+ let duration = self.duration_secs;
+ let cores = self.cores.clone();
+ let cancel = Arc::clone(&self.cancel);
+ let done = Arc::clone(&self.done);
+ let result = Arc::clone(&self.result);
+ let worker = self.worker;
+ self.handle = Some(thread::spawn(move || {
+ let cpus = parse_cpu_list(&cores);
+ let out = worker(duration, &cpus, &cancel);
+ *result.lock().unwrap() = Some(out);
+ done.store(true, Ordering::Release);
+ }));
+ Ok(())
+ }
+
+ fn is_running(&mut self) -> bool {
+ !self.done.load(Ordering::Acquire)
+ }
+
+ fn stop(&mut self) -> io::Result<()> {
+ self.cancel.store(true, Ordering::Relaxed);
+ Ok(())
+ }
+
+ fn wait(&mut self) -> Verdict {
+ match self.handle.take() {
+ Some(handle) => {
+ if handle.join().is_err() {
+ return Verdict::Error("measurement thread panicked".to_string());
+ }
+ let guard = self.result.lock().unwrap();
+ match &*guard {
+ Some((v, _)) => v.clone(),
+ None => Verdict::Stopped,
+ }
+ }
+ None => Verdict::Stopped,
+ }
+ }
+
+ fn score(&mut self) -> Score {
+ let guard = self.result.lock().unwrap();
+ match &*guard {
+ Some((_, s)) => s.clone(),
+ None => Score::None,
+ }
+ }
+}
+
+/// Public constructors used by workload::from_spec.
+pub fn c2c_latency(duration_secs: u64, cores: String) -> Box<dyn Workload> {
+ Box::new(InProc::new(
+ "c2c-latency",
+ duration_secs,
+ cores,
+ measure_c2c,
+ ))
+}
+
+pub fn dram_latency(duration_secs: u64, cores: String) -> Box<dyn Workload> {
+ Box::new(InProc::new(
+ "dram-latency",
+ duration_secs,
+ cores,
+ measure_dram,
+ ))
+}
+
+// ---------------------------------------------------------------------------
+// c2c-latency: cache-line ping-pong between pinned core pairs
+// ---------------------------------------------------------------------------
+
+/// Round trips per pair. ~2 ms of transfers per pair on this chip; the
+/// full upper triangle of 16 logical cpus (120 pairs) lands well under
+/// the 30 s default duration.
+const C2C_ROUND_TRIPS: u64 = 20_000;
+
+fn measure_c2c(_duration: u64, cpus: &[usize], cancel: &AtomicBool) -> (Verdict, Score) {
+ if cpus.len() < 2 {
+ return (
+ Verdict::Error("c2c-latency needs at least 2 logical cpus".to_string()),
+ Score::None,
+ );
+ }
+ let mut pairs = Vec::new();
+ for (ia, &a) in cpus.iter().enumerate() {
+ for &b in &cpus[ia + 1..] {
+ if cancel.load(Ordering::Relaxed) {
+ return (Verdict::Stopped, Score::None);
+ }
+ match ping_pong(a, b) {
+ Ok(ns_per_hop) => pairs.push(C2cPair {
+ cpu_a: a,
+ cpu_b: b,
+ ns_per_hop,
+ }),
+ Err(v) => return (v, Score::None),
+ }
+ }
+ }
+ (Verdict::Clean, Score::C2c { pairs })
+}
+
+/// Ping-pong a cache line (one AtomicU64) between cpu `a` and cpu `b`
+/// for C2C_ROUND_TRIPS round trips. Each sequence value has exactly one
+/// writer, so a plain spin-load that sees a value past its expectation
+/// means the line was corrupted or the other side double-wrote — a
+/// coherency/clock failure, reported as Verdict::Failed.
+fn ping_pong(a: usize, b: usize) -> Result<f64, Verdict> {
+ let ball = Arc::new(AtomicU64::new(0));
+ let corrupt = Arc::new(AtomicBool::new(false));
+ let pin_fail = Arc::new(AtomicBool::new(false));
+
+ let ball_a = Arc::clone(&ball);
+ let ball_b = Arc::clone(&ball);
+ let corrupt_a = Arc::clone(&corrupt);
+ let corrupt_b = Arc::clone(&corrupt);
+ let pf_a = Arc::clone(&pin_fail);
+ let pf_b = Arc::clone(&pin_fail);
+
+ // Initiator: A. Stores 1, waits for 2, stores 3, … timing brackets
+ // the full 0 → 2N transfer sequence.
+ let tx = thread::spawn(move || {
+ if !pin_self(a) {
+ pf_a.store(true, Ordering::Relaxed);
+ }
+ let t0 = Instant::now();
+ for i in 0..C2C_ROUND_TRIPS {
+ let expect = 2 * i;
+ loop {
+ let v = ball_a.load(Ordering::Relaxed);
+ if v == expect {
+ break;
+ }
+ if v > expect {
+ corrupt_a.store(true, Ordering::Relaxed);
+ return Duration::ZERO;
+ }
+ std::hint::spin_loop();
+ }
+ ball_a.store(expect + 1, Ordering::Relaxed);
+ }
+ // Wait for B's final store so the timing covers 2N transfers.
+ let final_expect = 2 * C2C_ROUND_TRIPS;
+ loop {
+ let v = ball_a.load(Ordering::Relaxed);
+ if v == final_expect {
+ break;
+ }
+ if v > final_expect {
+ corrupt_a.store(true, Ordering::Relaxed);
+ return Duration::ZERO;
+ }
+ std::hint::spin_loop();
+ }
+ t0.elapsed()
+ });
+ // Responder: B. Waits for 1, stores 2, waits for 3, …
+ let rx = thread::spawn(move || {
+ if !pin_self(b) {
+ pf_b.store(true, Ordering::Relaxed);
+ }
+ for i in 0..C2C_ROUND_TRIPS {
+ let expect = 2 * i + 1;
+ loop {
+ let v = ball_b.load(Ordering::Relaxed);
+ if v == expect {
+ break;
+ }
+ if v > expect {
+ corrupt_b.store(true, Ordering::Relaxed);
+ return;
+ }
+ std::hint::spin_loop();
+ }
+ ball_b.store(expect + 1, Ordering::Relaxed);
+ }
+ });
+
+ let elapsed = match (tx.join(), rx.join()) {
+ (Ok(e), Ok(())) => e,
+ _ => return Err(Verdict::Error("ping-pong thread panicked".to_string())),
+ };
+ if pin_fail.load(Ordering::Relaxed) {
+ return Err(Verdict::Error(format!(
+ "can't pin cpus {a}/{b} (sched_setaffinity failed)"
+ )));
+ }
+ if corrupt.load(Ordering::Relaxed) {
+ return Err(Verdict::Failed(format!(
+ "cache-line handoff {a}<->{b} corrupted"
+ )));
+ }
+ let transfers = 2.0 * C2C_ROUND_TRIPS as f64;
+ Ok(elapsed.as_nanos() as f64 / transfers)
+}
+
+// ---------------------------------------------------------------------------
+// dram-latency: pointer-chase through the cache hierarchy
+// ---------------------------------------------------------------------------
+
+/// Chase buffers, bytes. Sizes straddle the 12600KF hierarchy: L1D 48K
+/// per P-core, L2 2MB per P-core, L3 20MB shared. 32K fits L1, 512K fits
+/// L2, 8M fits L3, 64M falls through to DRAM.
+const DRAM_SIZES: [u64; 4] = [32 * 1024, 512 * 1024, 8 * 1024 * 1024, 64 * 1024 * 1024];
+
+/// Minimum time per size so the slow levels (DRAM) get enough samples.
+const DRAM_MIN_PER_SIZE: Duration = Duration::from_millis(200);
+
+fn measure_dram(_duration: u64, cpus: &[usize], cancel: &AtomicBool) -> (Verdict, Score) {
+ let Some(&cpu) = cpus.first() else {
+ return (
+ Verdict::Error("dram-latency needs at least 1 cpu".to_string()),
+ Score::None,
+ );
+ };
+ // Runs on the InProc worker thread itself — pin it (the thread exits
+ // with the measurement, so no affinity restore needed).
+ if !pin_self(cpu) {
+ return (
+ Verdict::Error(format!("can't pin cpu {cpu} (sched_setaffinity failed)")),
+ Score::None,
+ );
+ }
+ let mut levels = Vec::new();
+ for &size in &DRAM_SIZES {
+ if cancel.load(Ordering::Relaxed) {
+ return (Verdict::Stopped, Score::None);
+ }
+ let lines = (size / 64) as usize;
+ let table = build_chase_table(lines);
+ match chase(&table, lines, cancel) {
+ Ok(ns) if ns.is_nan() => return (Verdict::Stopped, Score::None), // cancelled mid-size
+ Ok(ns) => levels.push(DramLevel {
+ size_kb: size / 1024,
+ ns_per_access: ns,
+ }),
+ Err(v) => return (v, Score::None),
+ }
+ }
+ (Verdict::Clean, Score::Dram { levels })
+}
+
+/// Random single cycle over `lines` cache lines, laid out in a u64 table
+/// with one u64 payload per 64-byte line. `table[line*8]` = next line.
+/// A cycle of length exactly `lines` starting at line 0.
+fn build_chase_table(lines: usize) -> Vec<u64> {
+ let mut order: Vec<u32> = (0..lines as u32).collect();
+ // Fisher-Yates with a xorshift RNG (deterministic per run — fine for
+ // a latency measurement; the chase only needs to be irregular).
+ let mut state: u64 = 0x9E3779B97F4A7C15 ^ lines as u64;
+ for i in (1..lines).rev() {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ let j = (state % (i as u64 + 1)) as usize;
+ order.swap(i, j);
+ }
+ // order[0] is the head (chase start). link[order[i]] = order[i+1].
+ let mut table = vec![0u64; lines * 8];
+ for i in 0..lines {
+ let line = order[i] as usize;
+ table[line * 8] = order[(i + 1) % lines] as u64;
+ }
+ table
+}
+
+/// Chase full laps of the cycle until DRAM_MIN_PER_SIZE elapsed (or
+/// cancel). Returns ns/access. The table is ONE cycle covering all
+/// `lines` nodes, so starting anywhere, exactly `lines` dependent loads
+/// must land back on the start — if a stored address is ever wrong
+/// (flipped bit), the lap check fails: that's the memory-stability
+/// self-check.
+fn chase(table: &[u64], lines: usize, cancel: &AtomicBool) -> Result<f64, Verdict> {
+ let mut idx = 0u64;
+ let mut laps = 0u64;
+ let t0 = Instant::now();
+ loop {
+ for _ in 0..lines {
+ idx = std::hint::black_box(table[idx as usize * 8]);
+ }
+ laps += 1;
+ if idx != 0 {
+ return Err(Verdict::Failed(
+ "pointer-chase left the cycle (memory fault?)".to_string(),
+ ));
+ }
+ if t0.elapsed() >= DRAM_MIN_PER_SIZE || laps > 100_000_000 {
+ break;
+ }
+ if cancel.load(Ordering::Relaxed) {
+ return Ok(f64::NAN); // ignored — caller checks cancel separately
+ }
+ }
+ let accesses = laps as f64 * lines as f64;
+ Ok(t0.elapsed().as_nanos() as f64 / accesses)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_cpu_list_forms() {
+ let n = thread::available_parallelism()
+ .map(|n| n.get())
+ .unwrap_or(1);
+ assert_eq!(parse_cpu_list("").len(), n);
+ assert_eq!(parse_cpu_list("all").len(), n);
+ assert_eq!(parse_cpu_list("2-4"), vec![2, 3, 4]);
+ assert_eq!(parse_cpu_list("3,1,2-2"), vec![1, 2, 3]); // sort + dedup
+ assert_eq!(parse_cpu_list("junk"), Vec::<usize>::new());
+ // Out-of-range clamped away (cpu id >= nproc dropped).
+ assert_eq!(parse_cpu_list("0-999").len(), n);
+ }
+
+ #[test]
+ fn chase_table_is_one_cycle() {
+ let lines = 4096;
+ let table = build_chase_table(lines);
+ let mut idx = 0u64;
+ for _ in 0..lines {
+ idx = table[idx as usize * 8];
+ assert!(idx < lines as u64);
+ }
+ assert_eq!(
+ idx, 0,
+ "single cycle must return to the start after `lines` hops"
+ );
+ }
+
+ #[test]
+ fn chase_small_table_measures_ns() {
+ let lines = 1024;
+ let table = build_chase_table(lines);
+ let cancel = AtomicBool::new(false);
+ let ns = chase(&table, lines, &cancel).unwrap();
+ // Only a floor + deadlock cap: absolute ns bounds are meaningless
+ // when the suite runs on a box under load (the dev machine's
+ // benchmarks), and the hierarchy assertions live in dram_e2e.
+ assert!(ns > 0.0 && ns < 1e6, "sanity range, got {ns}");
+ }
+
+ #[test]
+ fn chase_fails_on_desynced_lap() {
+ let lines = 1024;
+ let mut table = build_chase_table(lines);
+ // Rewire one edge to skip a node: the cycle splits, so a
+ // `lines`-hop lap from 0 no longer lands back on 0.
+ let a = table[0];
+ let b = table[a as usize * 8];
+ table[a as usize * 8] = table[b as usize * 8];
+ let cancel = AtomicBool::new(false);
+ let r = chase(&table, lines, &cancel);
+ assert!(matches!(r, Err(Verdict::Failed(_))), "got {r:?}");
+ }
+
+ #[test]
+ #[cfg_attr(not(target_arch = "x86_64"), ignore)] // pinning is x86_64-only here
+ fn ping_pong_between_two_cpus() {
+ let cpus = parse_cpu_list("0-15");
+ if cpus.len() < 2 {
+ return; // single-cpu box (container?) — nothing to test
+ }
+ let ns = ping_pong(cpus[0], cpus[1]).expect("clean handoff");
+ // Floor + deadlock cap only — a preempted responder (this test
+ // runs on a box that may be mid-benchmark) legitimately pushes
+ // handoffs way past the tens-of-ns idle range.
+ assert!(ns > 0.0 && ns < 1e7, "sanity range, got {ns}");
+ }
+
+ #[test]
+ #[cfg_attr(not(target_arch = "x86_64"), ignore)]
+ fn c2c_e2e_via_trait() {
+ use crate::config::WorkloadSpec;
+ let cpus = parse_cpu_list("0-15");
+ if cpus.len() < 2 {
+ return;
+ }
+ let spec = WorkloadSpec {
+ name: "c2c-latency".to_string(),
+ duration_secs: 1,
+ cores: "0-3".to_string(),
+ };
+ let mut w = crate::workload::from_spec(&spec, std::path::Path::new("bin")).unwrap();
+ assert_eq!(w.name(), "c2c-latency");
+ w.start().unwrap();
+ while w.is_running() {
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert_eq!(w.wait(), Verdict::Clean);
+ match w.score() {
+ Score::C2c { pairs } => {
+ // 4 cpus → 6 upper-triangle pairs.
+ assert_eq!(pairs.len(), 6);
+ assert!(pairs.iter().all(|p| p.ns_per_hop > 0.0));
+ }
+ other => panic!("expected C2c score, got {other:?}"),
+ }
+ }
+
+ #[test]
+ #[cfg_attr(not(target_arch = "x86_64"), ignore)]
+ fn dram_e2e_via_trait() {
+ use crate::config::WorkloadSpec;
+ let spec = WorkloadSpec {
+ name: "dram-latency".to_string(),
+ duration_secs: 1,
+ cores: "0".to_string(),
+ };
+ let mut w = crate::workload::from_spec(&spec, std::path::Path::new("bin")).unwrap();
+ w.start().unwrap();
+ while w.is_running() {
+ thread::sleep(Duration::from_millis(50));
+ }
+ assert_eq!(w.wait(), Verdict::Clean);
+ match w.score() {
+ Score::Dram { levels } => {
+ assert_eq!(levels.len(), 4);
+ // DRAM (64 MiB) must be far slower than the L1-sized set.
+ assert!(levels[0].ns_per_access < levels[3].ns_per_access);
+ assert!(
+ levels[3].ns_per_access > 30.0,
+ "DRAM tier too fast: {levels:?}"
+ );
+ }
+ other => panic!("expected Dram score, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn stop_mid_run_yields_stopped() {
+ let cpus = parse_cpu_list("0-15");
+ if cpus.len() < 2 {
+ return;
+ }
+ let mut w = c2c_latency(3600, "0-15".to_string());
+ w.start().unwrap();
+ w.stop().unwrap();
+ while w.is_running() {
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert_eq!(w.wait(), Verdict::Stopped);
+ }
+}
diff --git a/alderbench/src/main.rs b/alderbench/src/main.rs
index d75fc23..52a971e 100644
--- a/alderbench/src/main.rs
+++ b/alderbench/src/main.rs
@@ -1,51 +1,148 @@
+//! alderbench — CLI entry point + arg dispatch.
+//!
+//! Modes: no args = TUI picker (default); `--tui` = TUI with the loaded
+//! config; `--preset NAME` = TUI straight into a preset sweep;
+//! `--headless` = scripted sweep, stdout only; `--smoke-workload NAME` =
+//! run one workload 3s + print the verdict; `--report PATH` = print a
+//! past run's summary; `--list` = enumerate workloads.
+
mod config;
+mod inproc;
mod presets;
mod report;
mod run;
mod ui;
mod workload;
+use std::io::IsTerminal;
+
+const USAGE: &str = "\
+usage: alderbench [OPTION]...
+
+ (no option) pick a preset or edit a custom sweep (TUI)
+ --tui TUI with the config as loaded (no picker)
+ --preset NAME TUI straight into a preset: stability | bench |
+ core-cycle
+ --headless run the configured sweep without a TUI
+ --smoke-workload NAME run one workload for 3s + print verdict/score
+ --report PATH print the summary of a past run's JSON report
+ --list enumerate available workload names
+ --version print version and exit
+ --help show this help";
+
+const OPTION_FLAGS: &[&str] = &[
+ "--tui",
+ "--preset",
+ "--headless",
+ "--smoke-workload",
+ "--report",
+ "--list",
+ "--version",
+ "--help",
+];
+
fn main() {
- let cfg = config::load();
let args: Vec<String> = std::env::args().collect();
+ for a in &args[1..] {
+ if a.starts_with('-') && !OPTION_FLAGS.contains(&a.as_str()) {
+ // A leading-dash token that is a *value* (e.g. `--preset -x`)
+ // is caught here too — acceptable: our values never start
+ // with '-'.
+ eprintln!("alderbench: unknown option '{a}'\n{USAGE}");
+ std::process::exit(2);
+ }
+ }
+ if args.iter().any(|a| a == "--help") {
+ println!("{USAGE}");
+ return;
+ }
+ if args.iter().any(|a| a == "--version") {
+ println!("alderbench {}", env!("CARGO_PKG_VERSION"));
+ return;
+ }
+ if args.iter().any(|a| a == "--list") {
+ for name in presets::AVAILABLE_WORKLOADS {
+ println!("{name}");
+ }
+ return;
+ }
+ if let Some(idx) = args.iter().position(|a| a == "--report") {
+ match args.get(idx + 1) {
+ Some(path) => report_print(path),
+ None => {
+ eprintln!("alderbench: --report needs a path\n{USAGE}");
+ std::process::exit(2);
+ }
+ }
+ return;
+ }
if let Some(idx) = args.iter().position(|a| a == "--smoke-workload") {
- if let Some(name) = args.get(idx + 1) {
- smoke_workload(name, &cfg);
- return;
+ match args.get(idx + 1) {
+ Some(name) => smoke_workload(name, &config::load()),
+ None => {
+ eprintln!("alderbench: --smoke-workload needs a name\n{USAGE}");
+ std::process::exit(2);
+ }
}
+ return;
}
if args.iter().any(|a| a == "--headless") {
- headless_run(cfg);
+ headless_run(config::load());
return;
}
if let Some(idx) = args.iter().position(|a| a == "--preset") {
- if let Some(name) = args.get(idx + 1) {
- match name.as_str() {
- "stability" => {
- ui::run_tui(presets::stability());
- return;
- }
- "bench" => {
- ui::run_tui(presets::bench());
- return;
- }
- "core-cycle" => {
- ui::run_tui(presets::stability_core_cycle(60));
- return;
- }
- _ => {
- eprintln!("unknown preset: {name} (available: stability, bench, core-cycle)");
- std::process::exit(1);
- }
+ match args.get(idx + 1).map(|s| s.as_str()) {
+ Some("stability") => require_tty_then(|| ui::run_tui(presets::stability())),
+ Some("bench") => require_tty_then(|| ui::run_tui(presets::bench())),
+ Some("core-cycle") => {
+ require_tty_then(|| ui::run_tui(presets::stability_core_cycle(60)));
+ }
+ Some(other) => {
+ eprintln!("unknown preset: {other} (available: stability, bench, core-cycle)");
+ std::process::exit(1);
+ }
+ None => {
+ eprintln!("alderbench: --preset needs a name\n{USAGE}");
+ std::process::exit(2);
}
}
+ return;
}
if args.iter().any(|a| a == "--tui") {
- ui::run_tui(cfg);
+ require_tty_then(|| ui::run_tui(config::load()));
return;
}
// Default mode = TUI picker (lets the user choose a preset or custom).
- ui::run_tui(cfg);
+ require_tty_then(|| ui::run_tui(config::load()));
+}
+
+/// ratatui panics without a real pty — fail with a clear message instead.
+fn require_tty_then(run: impl FnOnce()) {
+ if !std::io::stdout().is_terminal() {
+ eprintln!(
+ "alderbench: the TUI needs a real terminal (stdout is not a tty) — try --headless"
+ );
+ std::process::exit(1);
+ }
+ run();
+}
+
+/// `--report PATH`: print the human summary of a past run's JSON report.
+fn report_print(path: &str) {
+ let text = match std::fs::read_to_string(path) {
+ Ok(t) => t,
+ Err(e) => {
+ eprintln!("alderbench: can't read {path}: {e}");
+ std::process::exit(1);
+ }
+ };
+ match report::summary_from_json(&text) {
+ Some(s) => println!("{s}"),
+ None => {
+ eprintln!("alderbench: {path} is not an alderbench report (schema v2)");
+ std::process::exit(1);
+ }
+ }
}
/// `--headless` mode: drive the run loop without a TUI. Prints one line
@@ -66,10 +163,20 @@ fn headless_run(cfg: config::Config) {
let mut count = 0usize;
loop {
match run.tick() {
- Ok(run::TickOutcome::WorkloadStarted { idx, name, duration_secs }) => {
+ Ok(run::TickOutcome::WorkloadStarted {
+ idx,
+ name,
+ duration_secs,
+ }) => {
println!("[{idx}] starting {name} ({duration_secs}s)");
}
- Ok(run::TickOutcome::WorkloadFinished { idx, name, verdict, score, report_path }) => {
+ 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());
@@ -96,7 +203,13 @@ 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 } => {
+ 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"
@@ -110,11 +223,44 @@ fn format_score(s: &workload::Score) -> String {
.iter()
.map(|t| {
let gbps = t.bits_per_sec / 1e9;
- format!("{}={} {:.2} Gb/s", t.tag, if t.passed { "pass" } else { "FAIL" }, gbps)
+ format!(
+ "{}={} {:.2} Gb/s",
+ t.tag,
+ if t.passed { "pass" } else { "FAIL" },
+ gbps
+ )
})
.collect::<Vec<_>>()
.join(" ")
}
+ Score::C2c { pairs } => {
+ if pairs.is_empty() {
+ return "(no pairs measured)".to_string();
+ }
+ let min = pairs
+ .iter()
+ .min_by(|x, y| x.ns_per_hop.total_cmp(&y.ns_per_hop))
+ .unwrap();
+ let max = pairs
+ .iter()
+ .max_by(|x, y| x.ns_per_hop.total_cmp(&y.ns_per_hop))
+ .unwrap();
+ format!(
+ "{} pairs min {:.1} ns (cpu {}<->{}), max {:.1} ns (cpu {}<->{})",
+ pairs.len(),
+ min.ns_per_hop,
+ min.cpu_a,
+ min.cpu_b,
+ max.ns_per_hop,
+ max.cpu_a,
+ max.cpu_b
+ )
+ }
+ Score::Dram { levels } => levels
+ .iter()
+ .map(|l| format!("{} KiB={:.1} ns", l.size_kb, l.ns_per_access))
+ .collect::<Vec<_>>()
+ .join(" "),
}
}
@@ -161,4 +307,3 @@ fn smoke_workload(name: &str, cfg: &config::Config) {
Err(e) => println!("smoke: start failed: {e}"),
}
}
-
diff --git a/alderbench/src/presets.rs b/alderbench/src/presets.rs
index 1c5d8ef..3e8f424 100644
--- a/alderbench/src/presets.rs
+++ b/alderbench/src/presets.rs
@@ -16,21 +16,20 @@
use crate::config::{Config, WorkloadSpec};
-/// Workload names available in v1 (the from_spec match arms in workload.rs).
+/// Workload names available (the from_spec match arms in workload.rs).
/// The custom editor iterates this list for its toggle rows.
pub const AVAILABLE_WORKLOADS: &[&str] = &[
"stress-ng-cpu",
"y-cruncher-sse",
"y-cruncher-avx2",
"7zip-bench",
+ "c2c-latency",
+ "dram-latency",
];
/// Cores range options for the custom editor's per-workload cores field.
/// P = performance cores only, E = efficiency cores only, All = both.
-/// (Reserved for the editor's cores-field cycling; not yet wired into
-/// the TUI's cores edit — the editor currently accepts free-text cores
-/// input via the numeric/string edit buffer.)
-#[allow(dead_code)]
+/// Enter on the Cores field cycles through this list (wired in ui.rs).
pub const CORES_OPTIONS: &[&str] = &["0-15", "0-11", "12-15"];
fn spec(name: &str, duration_secs: u64, cores: &str) -> WorkloadSpec {
@@ -82,7 +81,7 @@ pub fn stability_core_cycle(per_core_secs: u64) -> Config {
}
Config {
workloads,
- cool_secs: 5, // shorter cooldown — single-core load doesn't heat the package
+ cool_secs: 5, // shorter cooldown — single-core load doesn't heat the package
loop_count: 1, // the core-cycle IS the loop; don't loop the whole thing
..Config::default()
}
@@ -165,7 +164,9 @@ mod tests {
assert!(AVAILABLE_WORKLOADS.contains(&"y-cruncher-sse"));
assert!(AVAILABLE_WORKLOADS.contains(&"y-cruncher-avx2"));
assert!(AVAILABLE_WORKLOADS.contains(&"7zip-bench"));
- // Deferred impls NOT in the list (c2c/dram/minecraft).
- assert!(!AVAILABLE_WORKLOADS.contains(&"c2c-latency"));
+ assert!(AVAILABLE_WORKLOADS.contains(&"c2c-latency"));
+ assert!(AVAILABLE_WORKLOADS.contains(&"dram-latency"));
+ // Deferred impl NOT in the list (minecraft).
+ assert!(!AVAILABLE_WORKLOADS.contains(&"minecraft-server"));
}
-}
\ No newline at end of file
+}
diff --git a/alderbench/src/report.rs b/alderbench/src/report.rs
index 7926296..5e67230 100644
--- a/alderbench/src/report.rs
+++ b/alderbench/src/report.rs
@@ -133,7 +133,9 @@ fn json_verdict(v: &Option<Verdict>) -> String {
match v {
None => "null".to_string(),
Some(Verdict::Clean) => "{\"kind\":\"clean\"}".to_string(),
- Some(Verdict::Failed(msg)) => format!("{{\"kind\":\"failed\",\"detail\":{}}}", json_str(msg)),
+ Some(Verdict::Failed(msg)) => {
+ format!("{{\"kind\":\"failed\",\"detail\":{}}}", json_str(msg))
+ }
Some(Verdict::Stopped) => "{\"kind\":\"stopped\"}".to_string(),
Some(Verdict::Error(msg)) => format!("{{\"kind\":\"error\",\"detail\":{}}}", json_str(msg)),
}
@@ -172,6 +174,34 @@ fn json_score(s: &Option<Score>) -> String {
out.push_str("]}");
out
}
+ Some(Score::C2c { pairs }) => {
+ let mut out = String::from("{\"kind\":\"c2c\",\"pairs\":[");
+ for (i, p) in pairs.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ out.push_str(&format!(
+ "{{\"a\":{},\"b\":{},\"ns\":{}}}",
+ p.cpu_a, p.cpu_b, p.ns_per_hop
+ ));
+ }
+ out.push_str("]}");
+ out
+ }
+ Some(Score::Dram { levels }) => {
+ let mut out = String::from("{\"kind\":\"dram\",\"levels\":[");
+ for (i, l) in levels.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ out.push_str(&format!(
+ "{{\"kb\":{},\"ns\":{}}}",
+ l.size_kb, l.ns_per_access
+ ));
+ }
+ out.push_str("]}");
+ out
+ }
}
}
@@ -211,6 +241,12 @@ pub fn human_summary(r: &Report) -> String {
format!("{} tests", tests.len())
}
}
+ Some(Score::C2c { pairs }) => format!("{} pairs", pairs.len()),
+ Some(Score::Dram { levels }) => levels
+ .iter()
+ .map(|l| format!("{}K {:.0}ns", l.size_kb, l.ns_per_access))
+ .collect::<Vec<_>>()
+ .join(" "),
};
format!(
"{:<18} {:<8} {:<24} verdict={}",
@@ -218,6 +254,231 @@ pub fn human_summary(r: &Report) -> String {
)
}
+// ---------------------------------------------------------------------------
+// Report reader (`--report <path>`): parse back a JSON file written above
+// and print a human summary. Zero-dep: we only ever read our own output,
+// so a minimal depth-tracking object scanner replaces a JSON crate.
+// ---------------------------------------------------------------------------
+
+/// Split a JSON object body (`{...}`) into (key, raw value slice) pairs at
+/// depth 0. Handles nested objects/arrays and string escapes. Returns None
+/// if `obj` isn't a well-formed-enough `{...}` object.
+fn object_entries(obj: &str) -> Option<Vec<(&str, &str)>> {
+ let bytes = obj.as_bytes();
+ if bytes.len() < 2 || bytes[0] != b'{' || *bytes.last()? != b'}' {
+ return None;
+ }
+ let mut entries = Vec::new();
+ let mut depth = 0i32;
+ let mut in_str = false;
+ let mut esc = false;
+ let mut entry_start = 1usize;
+ for i in 1..bytes.len() - 1 {
+ let c = bytes[i];
+ if in_str {
+ if esc {
+ esc = false;
+ } else if c == b'\\' {
+ esc = true;
+ } else if c == b'"' {
+ in_str = false;
+ }
+ continue;
+ }
+ match c {
+ b'"' => in_str = true,
+ b'{' | b'[' => depth += 1,
+ b'}' | b']' => depth -= 1,
+ b',' if depth == 0 => {
+ entries.push(entry_pair(&obj[entry_start..i])?);
+ entry_start = i + 1;
+ }
+ _ => {}
+ }
+ }
+ let last = &obj[entry_start..obj.len() - 1];
+ if !last.trim().is_empty() {
+ entries.push(entry_pair(last)?);
+ }
+ Some(entries)
+}
+
+/// Turn one `"key": value` entry slice into a (key, value) pair. Our
+/// writer's keys never contain a colon, so the first `:` is the split.
+fn entry_pair(raw: &str) -> Option<(&str, &str)> {
+ let (key, val) = raw.split_once(':')?;
+ let key = key.trim();
+ let key = key.strip_prefix('"')?.strip_suffix('"')?;
+ Some((key, val.trim()))
+}
+
+/// Split a JSON array (`[...]`) into its element slices at depth 0.
+fn array_items(arr: &str) -> Option<Vec<&str>> {
+ let bytes = arr.as_bytes();
+ if bytes.len() < 2 || bytes[0] != b'[' || *bytes.last()? != b']' {
+ return None;
+ }
+ let mut items = Vec::new();
+ let mut depth = 0i32;
+ let mut in_str = false;
+ let mut esc = false;
+ let mut start = 1usize;
+ for i in 1..bytes.len() - 1 {
+ let c = bytes[i];
+ if in_str {
+ if esc {
+ esc = false;
+ } else if c == b'\\' {
+ esc = true;
+ } else if c == b'"' {
+ in_str = false;
+ }
+ continue;
+ }
+ match c {
+ b'"' => in_str = true,
+ b'{' | b'[' => depth += 1,
+ b'}' | b']' => depth -= 1,
+ b',' if depth == 0 => {
+ items.push(arr[start..i].trim());
+ start = i + 1;
+ }
+ _ => {}
+ }
+ }
+ let last = arr[start..arr.len() - 1].trim();
+ if !last.is_empty() {
+ items.push(last);
+ }
+ Some(items)
+}
+
+/// Unquote a JSON string value (our writer only emits the basic escapes).
+fn unquote(s: &str) -> Option<String> {
+ let inner = s.strip_prefix('"')?.strip_suffix('"')?;
+ let mut out = String::with_capacity(inner.len());
+ let mut chars = inner.chars();
+ while let Some(c) = chars.next() {
+ if c != '\\' {
+ out.push(c);
+ continue;
+ }
+ match chars.next()? {
+ '"' => out.push('"'),
+ '\\' => out.push('\\'),
+ 'n' => out.push('\n'),
+ 'r' => out.push('\r'),
+ 't' => out.push('\t'),
+ other => out.push(other),
+ }
+ }
+ Some(out)
+}
+
+fn get_str<'a>(entries: &[(&'a str, &'a str)], key: &str) -> Option<String> {
+ entries
+ .iter()
+ .find(|(k, _)| *k == key)
+ .and_then(|(_, v)| unquote(v))
+}
+
+fn get_raw<'a>(entries: &[(&'a str, &'a str)], key: &str) -> Option<&'a str> {
+ entries.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
+}
+
+/// Human-readable one-line summary from raw report JSON (the `--report`
+/// mode). Mirrors `human_summary`'s layout, reconstructed from the file.
+pub fn summary_from_json(json: &str) -> Option<String> {
+ let top = object_entries(json)?;
+ let workload = get_str(&top, "workload")?;
+ let isa = get_str(&top, "isa_class").unwrap_or_else(|| "other".to_string());
+ let verdict = match get_raw(&top, "verdict") {
+ None | Some("null") => "(not finished)".to_string(),
+ Some(v) => {
+ let e = object_entries(v)?;
+ match get_str(&e, "kind")?.as_str() {
+ "clean" => "CLEAN".to_string(),
+ "stopped" => "STOPPED".to_string(),
+ "failed" => format!("FAILED ({})", get_str(&e, "detail").unwrap_or_default()),
+ "error" => format!("ERROR ({})", get_str(&e, "detail").unwrap_or_default()),
+ other => format!("?{other}"),
+ }
+ }
+ };
+ let score = match get_raw(&top, "score") {
+ None | Some("null") => {
+ if workload == "stress-ng-cpu" {
+ "(stability only)".to_string()
+ } else {
+ "(no score)".to_string()
+ }
+ }
+ Some(s) => {
+ let e = object_entries(s)?;
+ match get_str(&e, "kind")?.as_str() {
+ "7zip" => format!("rating={} MIPS", get_raw(&e, "rating_mips").unwrap_or("?")),
+ "y-cruncher" => {
+ let tests = get_raw(&e, "tests").unwrap_or("[]");
+ let mut parts = Vec::new();
+ for item in array_items(tests).unwrap_or_default() {
+ let t = object_entries(item)?;
+ let tag = get_str(&t, "tag").unwrap_or_default();
+ let passed = get_raw(&t, "passed") == Some("true");
+ let bps: f64 = get_raw(&t, "bits_per_sec")
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(0.0);
+ parts.push(format!(
+ "{tag}={} {:.2} Gb/s",
+ if passed { "pass" } else { "FAIL" },
+ bps / 1e9
+ ));
+ }
+ parts.join(" ")
+ }
+ "c2c" => {
+ let pairs = get_raw(&e, "pairs").unwrap_or("[]");
+ let items = array_items(pairs).unwrap_or_default();
+ let mut ns_vals = Vec::new();
+ for item in &items {
+ let p = object_entries(item)?;
+ ns_vals.push(
+ get_raw(&p, "ns")
+ .and_then(|v| v.parse::<f64>().ok())
+ .unwrap_or(0.0),
+ );
+ }
+ if ns_vals.is_empty() {
+ "0 pairs".to_string()
+ } else {
+ let lo = ns_vals.iter().cloned().fold(f64::INFINITY, f64::min);
+ let hi = ns_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
+ format!("{} pairs min={:.1} max={:.1} ns", ns_vals.len(), lo, hi)
+ }
+ }
+ "dram" => {
+ let levels = get_raw(&e, "levels").unwrap_or("[]");
+ let items = array_items(levels).unwrap_or_default();
+ let mut parts = Vec::new();
+ for item in &items {
+ let l = object_entries(item)?;
+ let kb = get_raw(&l, "kb").unwrap_or("?");
+ let ns: f64 = get_raw(&l, "ns")
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(0.0);
+ parts.push(format!("{kb}K={ns:.1} ns"));
+ }
+ parts.join(" ")
+ }
+ other => format!("?{other}"),
+ }
+ }
+ };
+ Some(format!(
+ "{:<18} {:<8} {:<24} verdict={}",
+ workload, isa, score, verdict
+ ))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -230,8 +491,12 @@ mod tests {
}
fn sample_report() -> Report {
- let mut r = Report::new(&test_dir("sample_report"), "y-cruncher-avx2", "60s cores=0-15 FFTv4")
- .unwrap();
+ let mut r = Report::new(
+ &test_dir("sample_report"),
+ "y-cruncher-avx2",
+ "60s cores=0-15 FFTv4",
+ )
+ .unwrap();
r.finish(
Verdict::Clean,
Score::YCruncher {
@@ -269,9 +534,14 @@ mod tests {
fn json_verdict_variants() {
assert_eq!(json_verdict(&None), "null");
assert_eq!(json_verdict(&Some(Verdict::Clean)), "{\"kind\":\"clean\"}");
- assert_eq!(json_verdict(&Some(Verdict::Stopped)), "{\"kind\":\"stopped\"}");
- assert!(json_verdict(&Some(Verdict::Failed("oops".to_string()))).contains("\"kind\":\"failed\""));
- assert!(json_verdict(&Some(Verdict::Failed("oops".to_string()))).contains("\"detail\":\"oops\""));
+ assert_eq!(
+ json_verdict(&Some(Verdict::Stopped)),
+ "{\"kind\":\"stopped\"}"
+ );
+ assert!(json_verdict(&Some(Verdict::Failed("oops".to_string())))
+ .contains("\"kind\":\"failed\""));
+ assert!(json_verdict(&Some(Verdict::Failed("oops".to_string())))
+ .contains("\"detail\":\"oops\""));
}
#[test]
@@ -302,8 +572,16 @@ mod tests {
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 },
+ 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));
@@ -316,7 +594,8 @@ mod tests {
#[test]
fn finish_stores_score_and_verdict() {
- let mut r = Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
+ let mut r =
+ Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
r.finish(
Verdict::Clean,
Score::SevenZip {
@@ -334,7 +613,12 @@ mod tests {
#[test]
fn finish_keeps_none_score_for_summary() {
- let mut r = Report::new(&test_dir("finish_keeps_none_score_for_summary"), "test", "test").unwrap();
+ 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.
@@ -375,7 +659,13 @@ mod tests {
assert!(written.contains("\"workload\":\"flush-test\""));
assert!(written.contains("\"verdict\":{\"kind\":\"clean\"}"));
assert!(written.contains("\"score\":null"));
- assert!(r.path().file_name().unwrap().to_str().unwrap().contains("flush-test.json"));
+ assert!(r
+ .path()
+ .file_name()
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .contains("flush-test.json"));
}
#[test]
@@ -410,7 +700,12 @@ mod tests {
#[test]
fn human_summary_for_stability_only() {
- let mut r = Report::new(&test_dir("human_summary_for_stability_only"), "stress-ng-cpu", "60s").unwrap();
+ 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)"));
@@ -428,4 +723,115 @@ mod tests {
assert!(!name.contains(':'));
assert!(!name.contains(' '));
}
-}
\ No newline at end of file
+
+ #[test]
+ fn summary_from_json_round_trips_ycruncher() {
+ let r = sample_report();
+ let s = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("y-cruncher-avx2"), "{s}");
+ assert!(s.contains("avx2"), "{s}");
+ assert!(s.contains("CLEAN"), "{s}");
+ assert!(s.contains("FFTv4=pass 0.89 Gb/s"), "{s}");
+ }
+
+ #[test]
+ fn summary_from_json_round_trips_7zip() {
+ let mut r = Report::new(&test_dir("summary_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 = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("rating=66259 MIPS"), "{s}");
+ assert!(s.contains("mixed"), "{s}");
+ }
+
+ #[test]
+ fn summary_from_json_not_finished_and_stability_only() {
+ let r = Report::new(&test_dir("summary_not_finished"), "test", "60s").unwrap();
+ let s = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("(not finished)"), "{s}");
+ assert!(s.contains("(no score)"), "{s}");
+
+ let mut r = Report::new(&test_dir("summary_stability"), "stress-ng-cpu", "60s").unwrap();
+ r.finish(Verdict::Clean, Score::None).unwrap();
+ let s = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("(stability only)"), "{s}");
+ assert!(s.contains("CLEAN"), "{s}");
+ }
+
+ #[test]
+ fn summary_from_json_survives_special_chars_in_detail() {
+ let mut r = Report::new(&test_dir("summary_special"), "test", "60s").unwrap();
+ r.finish(
+ Verdict::Failed("bad: exit {1}, \"quoted\"".to_string()),
+ Score::None,
+ )
+ .unwrap();
+ let s = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("FAILED (bad: exit {1}, \"quoted\")"), "{s}");
+ }
+
+ #[test]
+ fn summary_from_json_rejects_junk() {
+ assert!(summary_from_json("not json").is_none());
+ assert!(summary_from_json("{}").is_none()); // no workload key
+ }
+
+ #[test]
+ fn summary_from_json_round_trips_c2c() {
+ let mut r = Report::new(&test_dir("summary_c2c"), "c2c-latency", "30s").unwrap();
+ r.finish(
+ Verdict::Clean,
+ Score::C2c {
+ pairs: vec![
+ crate::workload::C2cPair {
+ cpu_a: 0,
+ cpu_b: 1,
+ ns_per_hop: 28.02,
+ },
+ crate::workload::C2cPair {
+ cpu_a: 0,
+ cpu_b: 2,
+ ns_per_hop: 43.83,
+ },
+ ],
+ },
+ )
+ .unwrap();
+ let s = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("2 pairs min=28.0 max=43.8 ns"), "{s}");
+ assert!(s.contains("CLEAN"), "{s}");
+ }
+
+ #[test]
+ fn summary_from_json_round_trips_dram() {
+ let mut r = Report::new(&test_dir("summary_dram"), "dram-latency", "30s").unwrap();
+ r.finish(
+ Verdict::Clean,
+ Score::Dram {
+ levels: vec![
+ crate::workload::DramLevel {
+ size_kb: 32,
+ ns_per_access: 4.536,
+ },
+ crate::workload::DramLevel {
+ size_kb: 65536,
+ ns_per_access: 67.51,
+ },
+ ],
+ },
+ )
+ .unwrap();
+ let s = summary_from_json(&r.to_json()).unwrap();
+ assert!(s.contains("32K=4.5 ns"), "{s}");
+ assert!(s.contains("65536K=67.5 ns"), "{s}");
+ }
+}
diff --git a/alderbench/src/run.rs b/alderbench/src/run.rs
index 838bba3..4a8d36e 100644
--- a/alderbench/src/run.rs
+++ b/alderbench/src/run.rs
@@ -27,7 +27,11 @@ use crate::workload::{self, Score, Verdict, Workload};
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 },
+ WorkloadStarted {
+ idx: usize,
+ name: String,
+ duration_secs: u64,
+ },
/// The workload exited (either it self-finished or the duration timer
/// elapsed and we stopped it). The verdict + score + report path are
/// included for the TUI's result table + stdout summary.
@@ -109,9 +113,7 @@ impl Run {
workloads,
skipped,
current: 0,
- state: State::Cooldown {
- remaining: 0,
- },
+ state: State::Cooldown { remaining: 0 },
state_start: Instant::now(),
report: None,
loops_left,
@@ -270,7 +272,11 @@ impl Run {
return Ok(TickOutcome::SweepDone);
}
let spec = &self.cfg.workloads[self.current];
- let report = Report::new(&self.cfg.report_dir, &spec.name, &self.workload_params(spec))?;
+ let report = Report::new(
+ &self.cfg.report_dir,
+ &spec.name,
+ &self.workload_params(spec),
+ )?;
self.report = Some(report);
let w = self.workloads[self.current].as_mut().unwrap();
w.start()?;
@@ -329,7 +335,8 @@ impl Run {
// Skip any leading None entries (defensive — shouldn't
// happen since construction skips them, but the same
// loop the constructor relies on).
- while self.current < self.workloads.len() && self.workloads[self.current].is_none() {
+ while self.current < self.workloads.len() && self.workloads[self.current].is_none()
+ {
self.current += 1;
}
// If the whole workload list was None (shouldn't happen),
@@ -434,8 +441,10 @@ mod tests {
/// Build a Run with mock workloads in place of `from_spec`. Bypasses
/// the real constructor so we don't need real binaries.
fn run_with_mocks(cfg: Config, mocks: Vec<MockWorkload>) -> Run {
- let workloads: Vec<Option<Box<dyn Workload>>> =
- mocks.into_iter().map(|m| Some(Box::new(m) as Box<dyn Workload>)).collect();
+ let workloads: Vec<Option<Box<dyn Workload>>> = mocks
+ .into_iter()
+ .map(|m| Some(Box::new(m) as Box<dyn Workload>))
+ .collect();
let loops_left = cfg.loop_count;
Run {
cfg,
@@ -454,13 +463,36 @@ mod tests {
fn sweep_runs_all_workloads_then_done() {
let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
let cfg = mock_cfg(vec![
- WorkloadSpec { name: "mock-a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
- WorkloadSpec { name: "mock-b".to_string(), duration_secs: 60, cores: "0-15".to_string() },
- ]);
- let mut run = run_with_mocks(cfg, vec![
- 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 },
+ WorkloadSpec {
+ name: "mock-a".to_string(),
+ duration_secs: 60,
+ cores: "0-15".to_string(),
+ },
+ WorkloadSpec {
+ name: "mock-b".to_string(),
+ duration_secs: 60,
+ cores: "0-15".to_string(),
+ },
]);
+ let mut run = run_with_mocks(
+ cfg,
+ vec![
+ 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,
+ },
+ ],
+ );
let mut started = 0;
let mut finished = 0;
let mut done = false;
@@ -496,13 +528,22 @@ mod tests {
// Verify: a never-exiting mock + a long duration → the run loop
// never returns WorkloadFinished, no matter how many ticks.
let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
- let mut cfg = mock_cfg(vec![
- WorkloadSpec { name: "never-exits".to_string(), duration_secs: 0, cores: "0-15".to_string() },
- ]);
+ let mut cfg = mock_cfg(vec![WorkloadSpec {
+ name: "never-exits".to_string(),
+ duration_secs: 0,
+ cores: "0-15".to_string(),
+ }]);
cfg.loop_count = 1;
- let mut run = run_with_mocks(cfg, vec![
- MockWorkload { name: "never-exits".to_string(), ticks_until_exit: 1000, ticks_seen: 0, started: false, stopped: false },
- ]);
+ let mut run = run_with_mocks(
+ cfg,
+ vec![MockWorkload {
+ name: "never-exits".to_string(),
+ ticks_until_exit: 1000,
+ ticks_seen: 0,
+ started: false,
+ stopped: false,
+ }],
+ );
// Tick once to start (Cooldown → Running).
let _ = run.tick().unwrap();
// Manually rewind state_start so elapsed is huge (well past any
@@ -529,7 +570,11 @@ mod tests {
let mut run = run_with_mocks(cfg, vec![]);
run.workloads.push(None); // a skipped entry
run.workloads.push(Some(Box::new(MockWorkload {
- name: "x".to_string(), ticks_until_exit: 1, ticks_seen: 0, started: false, stopped: false,
+ name: "x".to_string(),
+ ticks_until_exit: 1,
+ ticks_seen: 0,
+ started: false,
+ stopped: false,
})));
assert_eq!(run.total_workloads(), 1);
}
@@ -546,14 +591,37 @@ mod tests {
// total) before SweepDone.
let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
let mut cfg = mock_cfg(vec![
- WorkloadSpec { name: "a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
- WorkloadSpec { name: "b".to_string(), duration_secs: 60, cores: "0-15".to_string() },
+ WorkloadSpec {
+ name: "a".to_string(),
+ duration_secs: 60,
+ cores: "0-15".to_string(),
+ },
+ WorkloadSpec {
+ name: "b".to_string(),
+ duration_secs: 60,
+ cores: "0-15".to_string(),
+ },
]);
cfg.loop_count = 2;
- let mut run = run_with_mocks(cfg, vec![
- MockWorkload { name: "a".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
- MockWorkload { name: "b".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
- ]);
+ let mut run = run_with_mocks(
+ cfg,
+ vec![
+ MockWorkload {
+ name: "a".to_string(),
+ ticks_until_exit: 2,
+ ticks_seen: 0,
+ started: false,
+ stopped: false,
+ },
+ MockWorkload {
+ name: "b".to_string(),
+ ticks_until_exit: 2,
+ ticks_seen: 0,
+ started: false,
+ stopped: false,
+ },
+ ],
+ );
let mut starts = 0;
let mut done = false;
for _ in 0..40 {
@@ -572,7 +640,11 @@ mod tests {
}
assert!(done, "should reach SweepDone");
assert_eq!(starts, 4, "two workloads × two loops = 4 starts");
- assert_eq!(run.loop_index(), 2, "loop_index should be 2 after one reset");
+ assert_eq!(
+ run.loop_index(),
+ 2,
+ "loop_index should be 2 after one reset"
+ );
}
#[test]
@@ -582,13 +654,22 @@ mod tests {
// be here forever in real life, but we cap the loop + check that
// it's still going after several sweep completions).
let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
- let mut cfg = mock_cfg(vec![
- WorkloadSpec { name: "a".to_string(), duration_secs: 60, cores: "0-15".to_string() },
- ]);
+ let mut cfg = mock_cfg(vec![WorkloadSpec {
+ name: "a".to_string(),
+ duration_secs: 60,
+ cores: "0-15".to_string(),
+ }]);
cfg.loop_count = 0; // infinite
- let mut run = run_with_mocks(cfg, vec![
- MockWorkload { name: "a".to_string(), ticks_until_exit: 2, ticks_seen: 0, started: false, stopped: false },
- ]);
+ let mut run = run_with_mocks(
+ cfg,
+ vec![MockWorkload {
+ name: "a".to_string(),
+ ticks_until_exit: 2,
+ ticks_seen: 0,
+ started: false,
+ stopped: false,
+ }],
+ );
let mut starts = 0;
let mut sweep_done = false;
for _ in 0..100 {
@@ -603,6 +684,9 @@ mod tests {
}
assert!(!sweep_done, "infinite loop should never reach SweepDone");
// Should have completed several loops (start count >> 1).
- assert!(starts > 5, "infinite loop should have started many times, got {starts}");
+ assert!(
+ starts > 5,
+ "infinite loop should have started many times, got {starts}"
+ );
}
-}
\ No newline at end of file
+}
diff --git a/alderbench/src/ui.rs b/alderbench/src/ui.rs
index 0282987..f5fe7ab 100644
--- a/alderbench/src/ui.rs
+++ b/alderbench/src/ui.rs
@@ -2,8 +2,11 @@
//! - **Picker** (home screen): choose Stability / Bench / Custom / q to quit.
//! - **Editor** (custom preset): toggle workloads, edit duration/cores/
//! cooldown/loop count, reorder, then Start to launch the sweep.
-//! - **Sweep**: consume `run::Run::tick()` every `poll_ms`, render the
-//! current workload + progress + a results table. q/Esc quits.
+//! - **Sweep**: consume `run::Run::tick()` + render the current
+//! workload + progress + a results table. While a workload runs the
+//! loop heartbeat-sleeps (≤15s, ~6s past the estimate) instead of
+//! ticking at poll_ms — keeps CPU off single-core workloads while the
+//! panel stays live. q/Esc quits.
//!
//! NO sensor meters — aldermon owns sensors. alderbench records scores.
@@ -12,11 +15,11 @@ use std::time::Duration;
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
- Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
+ Frame,
};
use crate::config::{Config, WorkloadSpec};
@@ -78,9 +81,15 @@ impl EditorField {
// ---------------------------------------------------------------------------
const PICKER_ROWS: &[(&str, &str)] = &[
- ("Stability", "stress-ng + y-cruncher SSE/AVX2, 5 min each, looped 1 hr"),
+ (
+ "Stability",
+ "stress-ng + y-cruncher SSE/AVX2, 5 min each, looped 1 hr",
+ ),
("Bench", "7z + y-cruncher SSE/AVX2, 60 s each, single pass"),
- ("Core-cycle", "single-core per-core stability, 16 cores x 2 passes"),
+ (
+ "Core-cycle",
+ "single-core per-core stability, 16 cores x 2 passes",
+ ),
("Custom", "tune the workload list + params yourself"),
];
@@ -112,21 +121,18 @@ pub fn run_tui(cfg: Config) {
}
};
// While a workload is RUNNING (not cooldown), don't re-draw
- // every tick — the render loop steals CPU from the workload,
- // which on single-core runs extends the wall time enough to
- // cause false Stopped verdicts. The user can see CPU load via
- // aldermon. We only re-draw on state transitions (workload
- // started / finished / sweep done) + during cooldown (when
- // the CPU is idle anyway).
+ // every tick — the render loop steals CPU from the workload.
+ // Session 6 killed the 2s poll cadence for this reason (it
+ // stretched a 10s single-core y-cruncher run past 60s). The
+ // compromise below: sleep on a heartbeat (compute_sleep_secs,
+ // capped at ~15s), draw once per wake. The panel's elapsed
+ // stays live + overrun states become visible, while the TUI
+ // spends ~0% CPU (one diff-draw per 15s).
let should_draw = match &outcome {
TickOutcome::WorkloadStarted { .. } => true,
TickOutcome::WorkloadFinished { .. } => true,
TickOutcome::SweepDone => true,
- TickOutcome::Idle => {
- // Draw during cooldown (CPU is idle) but NOT during
- // running (CPU is busy with the workload).
- app.run.is_cooldown() || app.run.is_done()
- }
+ TickOutcome::Idle => app.run.is_cooldown() || app.run.is_done(),
};
let is_done = matches!(outcome, TickOutcome::SweepDone);
// Compute the sleep duration BEFORE we potentially move `app`
@@ -142,10 +148,9 @@ pub fn run_tui(cfg: Config) {
mode = Mode::Done(finished);
}
if !should_draw {
- // While the workload is running, sleep for the ENTIRE
- // estimated duration + a generous margin. One long sleep
- // means the TUI process is genuinely idle (not scheduling)
- // while the workload runs — no CPU contention.
+ // Heartbeat sleep. Wake on timeout (then fall through to
+ // the draw below so elapsed updates) or on a key (q/Esc
+ // cancels; anything else also falls through to a draw).
if event::poll(Duration::from_secs(sleep_secs)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
if key.kind == crossterm::event::KeyEventKind::Press
@@ -155,7 +160,6 @@ pub fn run_tui(cfg: Config) {
}
}
}
- continue;
}
}
@@ -175,32 +179,26 @@ pub fn run_tui(cfg: Config) {
continue;
}
match &mut mode {
- Mode::Picker(state) => {
- match key.code {
- KeyCode::Up | KeyCode::Char('k') => state.cursor = state.cursor.saturating_sub(1),
- KeyCode::Down | KeyCode::Char('j') => {
- state.cursor = (state.cursor + 1).min(PICKER_ROWS.len() - 1)
- }
- KeyCode::Enter => {
- match state.cursor {
- 0 => mode = Mode::Sweep(App::new(presets::stability())),
- 1 => mode = Mode::Sweep(App::new(presets::bench())),
- 2 => mode = Mode::Sweep(App::new(presets::stability_core_cycle(60))),
- 3 => mode = Mode::Editor(EditorState::from_cfg(default_cfg.clone())),
- _ => {}
- }
- }
- KeyCode::Char('q') | KeyCode::Esc => break,
- _ => {}
+ Mode::Picker(state) => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => state.cursor = state.cursor.saturating_sub(1),
+ KeyCode::Down | KeyCode::Char('j') => {
+ state.cursor = (state.cursor + 1).min(PICKER_ROWS.len() - 1)
}
- }
- Mode::Editor(state) => {
- match editor_key(state, key.code) {
- EditorAction::Start => mode = Mode::Sweep(App::new(state.cfg.clone())),
- EditorAction::Cancel => mode = Mode::Picker(PickerState { cursor: 3 }),
- EditorAction::None => {}
- }
- }
+ KeyCode::Enter => match state.cursor {
+ 0 => mode = Mode::Sweep(App::new(presets::stability())),
+ 1 => mode = Mode::Sweep(App::new(presets::bench())),
+ 2 => mode = Mode::Sweep(App::new(presets::stability_core_cycle(60))),
+ 3 => mode = Mode::Editor(EditorState::from_cfg(default_cfg.clone())),
+ _ => {}
+ },
+ KeyCode::Char('q') | KeyCode::Esc => break,
+ _ => {}
+ },
+ Mode::Editor(state) => match editor_key(state, key.code) {
+ EditorAction::Start => mode = Mode::Sweep(App::new(state.cfg.clone())),
+ EditorAction::Cancel => mode = Mode::Picker(PickerState { cursor: 3 }),
+ EditorAction::None => {}
+ },
Mode::Sweep(app) => {
// The tick already ran at the top of the loop (before
// draw + poll) so the sweep advances even without input.
@@ -242,7 +240,11 @@ fn print_summary(app: &App) {
for r in &app.results {
println!(
"[{}] {} {:<8} {:<24} verdict={:?}",
- r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
+ r.idx,
+ r.name,
+ r.isa_class,
+ format_score_short(&r.score),
+ r.verdict
);
println!(" report: {}", r.report_path);
}
@@ -307,8 +309,15 @@ fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
KeyCode::Left | KeyCode::Right => {
// Cycle field within the current row (only for workload rows).
if state.cursor < state.cfg.workloads.len() {
- let idx = EditorField::PER_ROW.iter().position(|f| *f == state.field).unwrap_or(0);
- let dir = if matches!(key, KeyCode::Left) { -1i32 } else { 1 };
+ let idx = EditorField::PER_ROW
+ .iter()
+ .position(|f| *f == state.field)
+ .unwrap_or(0);
+ let dir = if matches!(key, KeyCode::Left) {
+ -1i32
+ } else {
+ 1
+ };
let n = EditorField::PER_ROW.len() as i32;
let next = ((idx as i32 + dir).rem_euclid(n)) as usize;
state.field = EditorField::PER_ROW[next];
@@ -316,7 +325,8 @@ fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
}
KeyCode::Enter => {
// Enter on a workload row's Name field toggles inclusion (cycles
- // through available workload names). On Duration/Cores/Cooldown/
+ // through available workload names). On Cores it cycles the
+ // preset ranges (0-15 / 0-11 / 12-15). On Duration/Cooldown/
// LoopCount it starts numeric editing. On a "Start" pseudo-row
// (cursor == workloads.len() + globals.len()) it launches.
let total_rows = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
@@ -335,7 +345,21 @@ fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
w.name = presets::AVAILABLE_WORKLOADS[next].to_string();
}
}
- EditorField::Duration | EditorField::Cores | EditorField::Cooldown | EditorField::LoopCount => {
+ EditorField::Cores => {
+ // Cycle through the preset core ranges instead of
+ // free-text editing. Unknown current values (e.g. a
+ // hand-edited conf range) restart at the first option.
+ if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
+ w.cores = match presets::CORES_OPTIONS.iter().position(|c| *c == w.cores) {
+ Some(idx) => {
+ presets::CORES_OPTIONS[(idx + 1) % presets::CORES_OPTIONS.len()]
+ }
+ None => presets::CORES_OPTIONS[0],
+ }
+ .to_string();
+ }
+ }
+ EditorField::Duration | EditorField::Cooldown | EditorField::LoopCount => {
let current = editor_current_value(state);
state.editing = Some(current);
}
@@ -367,8 +391,9 @@ fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
state.cfg.workloads.swap(state.cursor, state.cursor - 1);
state.cursor -= 1;
}
- KeyCode::Char('>') if state.cursor < state.cfg.workloads.len()
- && state.cursor + 1 < state.cfg.workloads.len() =>
+ KeyCode::Char('>')
+ if state.cursor < state.cfg.workloads.len()
+ && state.cursor + 1 < state.cfg.workloads.len() =>
{
state.cfg.workloads.swap(state.cursor, state.cursor + 1);
state.cursor += 1;
@@ -552,7 +577,9 @@ fn draw_picker(f: &mut Frame, state: &PickerState) {
.map(|(i, (name, desc))| {
let cursor = if i == state.cursor { ">" } else { " " };
let style = if i == state.cursor {
- Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(Color::Cyan)
} else {
Style::default()
};
@@ -564,22 +591,26 @@ fn draw_picker(f: &mut Frame, state: &PickerState) {
]))
})
.collect();
- let list = List::new(items)
- .block(
- Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::DarkGray))
- .title(Span::styled(" alderbench — choose a sweep ", Style::default().fg(Color::Cyan))),
- );
+ let list = List::new(items).block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray))
+ .title(Span::styled(
+ " alderbench — choose a sweep ",
+ Style::default().fg(Color::Cyan),
+ )),
+ );
f.render_widget(list, area);
// Footer with keybinds.
- let footer = Line::from(vec![
- Span::styled(
- "↑↓ select Enter start q quit",
- Style::default().fg(Color::DarkGray),
- ),
- ]);
- let footer_area = Rect { y: area.bottom().saturating_sub(1), height: 1, ..area };
+ let footer = Line::from(vec![Span::styled(
+ "↑↓ select Enter start q quit",
+ Style::default().fg(Color::DarkGray),
+ )]);
+ let footer_area = Rect {
+ y: area.bottom().saturating_sub(1),
+ height: 1,
+ ..area
+ };
f.render_widget(Paragraph::new(footer), footer_area);
}
@@ -625,7 +656,9 @@ fn draw_editor(f: &mut Frame, state: &EditorState) {
let editing = state.editing.as_deref().unwrap_or(&text);
Cell::from(Span::styled(
format!("[{editing}]"),
- Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
))
} else {
Cell::from(Span::styled(text, row_style))
@@ -659,22 +692,34 @@ fn draw_editor(f: &mut Frame, state: &EditorState) {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
- .title(Span::styled(" workloads ", Style::default().fg(Color::Cyan))),
+ .title(Span::styled(
+ " workloads ",
+ Style::default().fg(Color::Cyan),
+ )),
);
f.render_widget(table, chunks[1]);
// Globals (cooldown + loop count).
let global_cell = |field: EditorField, label: &str, value: String| -> Line {
- let cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.iter().position(|f| *f == field).unwrap() {
+ let cursor = if state.cursor
+ == state.cfg.workloads.len()
+ + EditorField::GLOBALS
+ .iter()
+ .position(|f| *f == field)
+ .unwrap()
+ {
">"
} else {
" "
};
- let val_span = if state.cursor >= state.cfg.workloads.len()
- && state.field == field
- {
+ let val_span = if state.cursor >= state.cfg.workloads.len() && state.field == field {
let editing = state.editing.as_deref().unwrap_or(&value);
- Span::styled(format!("[{editing}]"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
+ Span::styled(
+ format!("[{editing}]"),
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )
} else {
Span::raw(value)
};
@@ -684,12 +729,27 @@ fn draw_editor(f: &mut Frame, state: &EditorState) {
val_span,
])
};
- let globals = [global_cell(EditorField::Cooldown, "cooldown", format!("{}s", state.cfg.cool_secs)),
- global_cell(EditorField::LoopCount, "loop count", state.cfg.loop_count.to_string())];
+ let globals = [
+ global_cell(
+ EditorField::Cooldown,
+ "cooldown",
+ format!("{}s", state.cfg.cool_secs),
+ ),
+ global_cell(
+ EditorField::LoopCount,
+ "loop count",
+ state.cfg.loop_count.to_string(),
+ ),
+ ];
for (i, line) in globals.iter().enumerate() {
f.render_widget(
Paragraph::new(line.clone()),
- Rect { x: chunks[2].x, y: chunks[2].y + i as u16, width: chunks[2].width, height: 1 },
+ Rect {
+ x: chunks[2].x,
+ y: chunks[2].y + i as u16,
+ width: chunks[2].width,
+ height: 1,
+ },
);
}
@@ -700,7 +760,9 @@ fn draw_editor(f: &mut Frame, state: &EditorState) {
" "
};
let start_style = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
- Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
@@ -714,12 +776,10 @@ fn draw_editor(f: &mut Frame, state: &EditorState) {
// Footer with keybinds.
f.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(
- "j/k row ←→ field Enter edit/toggle a add d del </> reorder Esc back",
- Style::default().fg(Color::DarkGray),
- ),
- ])),
+ Paragraph::new(Line::from(vec![Span::styled(
+ "j/k row ←→ field Enter edit/toggle a add d del </> reorder Esc back",
+ Style::default().fg(Color::DarkGray),
+ )])),
chunks[4],
);
}
@@ -753,7 +813,10 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App) {
format!("loop {loop_idx}/{loop_total}")
};
let title = if app.run.is_done() {
- format!("alderbench — sweep complete: {} workloads", app.results.len())
+ format!(
+ "alderbench — sweep complete: {} workloads",
+ app.results.len()
+ )
} else if current == 0 {
format!("alderbench — starting (0/{total}) {loop_str}")
} else {
@@ -801,8 +864,16 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
};
let isa = report::isa_class(&spec.name);
- let state_label = if app.run.is_cooldown() { "cooldown" } else { "running" };
- let state_color = if app.run.is_cooldown() { Color::Blue } else { Color::Green };
+ let state_label = if app.run.is_cooldown() {
+ "cooldown"
+ } else {
+ "running"
+ };
+ let state_color = if app.run.is_cooldown() {
+ Color::Blue
+ } else {
+ Color::Green
+ };
let elapsed = app.run.elapsed_secs();
let inner = block.inner(area);
@@ -817,30 +888,38 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
} else {
0.0
};
- [
+ vec![
Line::from(vec![
- Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
+ Span::styled(
+ format!("{:<20}", spec.name),
+ Style::default().add_modifier(Modifier::BOLD),
+ ),
Span::raw(" "),
Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(state_label, Style::default().fg(state_color)),
]),
- Line::from(vec![
- Span::styled(
- format!("elapsed {:>5.1}s remaining {:>5.1}s", elapsed, remaining),
- Style::default().fg(Color::DarkGray),
- ),
- ]),
+ Line::from(vec![Span::styled(
+ format!("elapsed {:>5.1}s remaining {:>5.1}s", elapsed, remaining),
+ Style::default().fg(Color::DarkGray),
+ )]),
progress_bar(progress, inner.width),
]
} else {
// Running: the duration is a display-only estimate ("this will
// probably take X seconds"). No progress bar — the workload runs
- // until it self-exits or the user quits. The user can see CPU
- // load via aldermon.
- [
+ // until it self-exits or the user quits. Past the estimate the
+ // label goes yellow + says so (the 7z-overrun "is it hung?"
+ // UAT complaint): the sweep is not stalled, the workload just
+ // runs to the end of its fixed passes. The user can see CPU load
+ // via aldermon.
+ let overrun = elapsed >= spec.duration_secs as f64 + 2.0;
+ let mut lines = vec![
Line::from(vec![
- Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
+ Span::styled(
+ format!("{:<20}", spec.name),
+ Style::default().add_modifier(Modifier::BOLD),
+ ),
Span::raw(" "),
Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
Span::raw(" "),
@@ -852,16 +931,31 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
- Span::styled(
- format!("elapsed {:>5.1}s", elapsed),
- Style::default(),
- ),
+ Span::styled(format!("elapsed {:>5.1}s", elapsed), Style::default()),
]),
Line::from(Span::styled(
- "running... (watch CPU load in aldermon, press q to cancel)",
- Style::default().fg(Color::DarkGray),
+ if overrun {
+ format!(
+ "past ~{}s estimate — running to completion (self-terminating; q cancels)",
+ spec.duration_secs
+ )
+ } else {
+ "running... (watch CPU load in aldermon, press q to cancel)".to_string()
+ },
+ Style::default().fg(if overrun {
+ Color::Yellow
+ } else {
+ Color::DarkGray
+ }),
)),
- ]
+ ];
+ if overrun {
+ lines.push(Line::from(Span::styled(
+ "7z runs fixed benchmark passes — overrunning the estimate is normal.",
+ Style::default().fg(Color::DarkGray),
+ )));
+ }
+ lines
};
for (i, line) in lines.iter().enumerate() {
if (i as u16) >= inner.height {
@@ -869,7 +963,12 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
}
f.render_widget(
Paragraph::new(line.clone()),
- Rect { x: inner.x, y: inner.y + i as u16, width: inner.width, height: 1 },
+ Rect {
+ x: inner.x,
+ y: inner.y + i as u16,
+ width: inner.width,
+ height: 1,
+ },
);
}
}
@@ -884,11 +983,18 @@ fn progress_bar(ratio: f64, width: u16) -> Line<'static> {
let mut spans = vec![Span::raw("[")];
for i in 0..bar_w {
let ch = if i < fill { "█" } else { "·" };
- let color = if i < fill { Color::Green } else { Color::DarkGray };
+ let color = if i < fill {
+ Color::Green
+ } else {
+ Color::DarkGray
+ };
spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
}
spans.push(Span::raw("] "));
- spans.push(Span::styled(label, Style::default().add_modifier(Modifier::BOLD)));
+ spans.push(Span::styled(
+ label,
+ Style::default().add_modifier(Modifier::BOLD),
+ ));
Line::from(spans)
}
@@ -926,7 +1032,10 @@ fn draw_results(f: &mut Frame, area: Rect, app: &App) {
Cell::from(Span::raw(format!("{}", r.idx))),
Cell::from(Span::raw(r.name.clone())),
Cell::from(Span::raw(r.isa_class.to_string())),
- Cell::from(Span::styled(vlabel, Style::default().fg(vcolor).add_modifier(Modifier::BOLD))),
+ Cell::from(Span::styled(
+ vlabel,
+ Style::default().fg(vcolor).add_modifier(Modifier::BOLD),
+ )),
Cell::from(Span::raw(format_score_short(&r.score))),
])
});
@@ -960,16 +1069,23 @@ fn draw_footer(f: &mut Frame, area: Rect, _app: &App) {
// Helpers
// ---------------------------------------------------------------------------
-/// Compute the sleep duration for the "workload running, no re-draw"
-/// path. Sleeps for the remaining estimated time + a 5s margin, so the
-/// TUI process is genuinely idle (not scheduling) while the workload
-/// runs. After the sleep, the next tick will detect the workload has
-/// exited (or still running, in which case we sleep again).
+/// Compute the heartbeat sleep for the "workload running, don't poll at
+/// 250ms" path: remaining estimate + 5s margin, capped at 15s so the
+/// panel's elapsed refreshes even on long runs (session 6's freeze
+/// complaint), and settling at ~6s once the estimate is overrun (so
+/// self-exiting workloads like 7z get detected within a short window
+/// after their final pass). The 15s cap keeps the TUI at ~0% CPU — the
+/// failure mode that forced the original one-long-sleep design was the
+/// 2s poll cadence stealing CPU from single-core runs.
fn compute_sleep_secs(app: &mut App) -> u64 {
let elapsed = app.run.elapsed_secs();
- let duration = app.run.current_spec().map(|s| s.duration_secs as f64).unwrap_or(60.0);
+ let duration = app
+ .run
+ .current_spec()
+ .map(|s| s.duration_secs as f64)
+ .unwrap_or(60.0);
let remaining = (duration - elapsed).max(1.0);
- remaining as u64 + 5
+ (remaining as u64 + 5).min(15)
}
fn format_score_short(s: &Score) -> String {
@@ -986,6 +1102,24 @@ fn format_score_short(s: &Score) -> String {
format!("{} tests", tests.len())
}
}
+ Score::C2c { pairs } => {
+ let ns: Vec<f64> = pairs.iter().map(|p| p.ns_per_hop).collect();
+ ns_range(&ns)
+ }
+ Score::Dram { levels } => {
+ let ns: Vec<f64> = levels.iter().map(|l| l.ns_per_access).collect();
+ ns_range(&ns)
+ }
+ }
+}
+
+fn ns_range(ns: &[f64]) -> String {
+ if ns.is_empty() {
+ "(no samples)".to_string()
+ } else {
+ let lo = ns.iter().cloned().fold(f64::INFINITY, f64::min);
+ let hi = ns.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
+ format!("{lo:.0}-{hi:.0} ns")
}
}
@@ -996,4 +1130,37 @@ fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
Verdict::Stopped => ("STOPPED", Color::Yellow),
Verdict::Error(_) => ("ERROR", Color::Red),
}
-}
\ No newline at end of file
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn editor_at_cores() -> EditorState {
+ let mut state = EditorState::from_cfg(Config::default());
+ state.cursor = 0;
+ state.field = EditorField::Cores;
+ state
+ }
+
+ #[test]
+ fn enter_on_cores_cycles_options_not_free_text() {
+ let mut state = editor_at_cores();
+ assert_eq!(state.cfg.workloads[0].cores, "0-15");
+ editor_key(&mut state, KeyCode::Enter);
+ assert_eq!(state.cfg.workloads[0].cores, "0-11");
+ assert!(state.editing.is_none());
+ editor_key(&mut state, KeyCode::Enter);
+ assert_eq!(state.cfg.workloads[0].cores, "12-15");
+ editor_key(&mut state, KeyCode::Enter);
+ assert_eq!(state.cfg.workloads[0].cores, "0-15");
+ }
+
+ #[test]
+ fn enter_on_cores_unknown_value_restarts_at_first() {
+ let mut state = editor_at_cores();
+ state.cfg.workloads[0].cores = "3,7".to_string();
+ editor_key(&mut state, KeyCode::Enter);
+ assert_eq!(state.cfg.workloads[0].cores, "0-15");
+ }
+}
diff --git a/alderbench/src/workload.rs b/alderbench/src/workload.rs
index 92488c0..9db9a52 100644
--- a/alderbench/src/workload.rs
+++ b/alderbench/src/workload.rs
@@ -62,6 +62,30 @@ pub enum Score {
/// 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.
@@ -244,7 +268,10 @@ impl Workload for StressNg {
"stress-ng-cpu"
}
fn params(&self) -> String {
- format!("{}s cores={} --cpu --cpu-method all", self.duration_secs, self.cores)
+ 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");
@@ -430,9 +457,15 @@ impl Workload for YCruncher {
if status.success() && !failed_text {
Verdict::Clean
} else if failed_text {
- Verdict::Failed(format!("y-cruncher self-check failed (variant={})", self.variant))
+ Verdict::Failed(format!(
+ "y-cruncher self-check failed (variant={})",
+ self.variant
+ ))
} else {
- Verdict::Failed(format!("y-cruncher exit {} (variant={})", status, self.variant))
+ Verdict::Failed(format!(
+ "y-cruncher exit {} (variant={})",
+ status, self.variant
+ ))
}
}
Ok(None) => Verdict::Error("no child".to_string()),
@@ -575,25 +608,42 @@ fn taskset_command<P: AsRef<std::ffi::OsStr>>(cores: &str, program: P) -> Comman
}
/// Build a Workload from a config spec. Returns None for unknown names
-/// (the run loop skips them with a warning) or names that aren't wired
-/// yet (c2c-latency, dram-latency, minecraft-server — deferred).
+/// 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())))
- }
+ "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()))),
- // Deferred — hand-rolled in-process workloads, next chunk.
- "c2c-latency" | "dram-latency" | "minecraft-server" => None,
+ "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,
}
}
@@ -692,7 +742,11 @@ fn parse_ycruncher_scores(stdout: &str) -> Vec<YCruncherTest> {
if tests.iter().any(|t| t.tag == tag) {
continue;
}
- tests.push(YCruncherTest { tag, passed, bits_per_sec });
+ tests.push(YCruncherTest {
+ tag,
+ passed,
+ bits_per_sec,
+ });
}
tests
}
@@ -719,18 +773,24 @@ fn parse_7zip_score(stdout: &str) -> Option<Score> {
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();
+ 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],
- ));
+ 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();
+ 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]));
}
@@ -815,20 +875,32 @@ mod tests {
}
#[test]
- fn from_spec_rejects_deferred_workloads() {
- // c2c-latency / dram-latency / minecraft-server are in scope but
- // not yet implemented — from_spec returns None so the run loop
- // can skip them with a warning rather than panic.
- for name in ["c2c-latency", "dram-latency", "minecraft-server"] {
+ 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(),
};
- assert!(from_spec(&spec, std::path::Path::new("bin")).is_none(), "{name} should be None");
+ 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
@@ -927,7 +999,14 @@ Test Finished. Waiting for threads to terminate...
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")]);
+ 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);
@@ -940,7 +1019,11 @@ Test Finished. Waiting for threads to terminate...
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");
+ assert_eq!(
+ cmd.get_program(),
+ std::path::Path::new("/bin/true"),
+ "full={full:?} should skip wrapper"
+ );
}
}
-}
\ No newline at end of file
+}
diff --git a/aldermon/README.md b/aldermon/README.md
index efa956c..569c625 100644
--- a/aldermon/README.md
+++ b/aldermon/README.md
@@ -76,4 +76,4 @@ doas usermod -aG adm $USER # then re-login
## Status
Working: TUI, config (+ system /etc default), RAPL power. Remaining
-notes in `to-do.md`.
\ No newline at end of file
+notes live in the local-only `.LLM_Memory/` (gitignored).
\ No newline at end of file
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..04c6a4e
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,3 @@
+## Alder Lake Tools
+
+Simple vCore monitoring and bench utilities for Intel 12th gen. Probably works for other machines, but I have a 12600K so that's what it's made for.
chore: move LLM memory files to .LLM_Memory/ (ignored), commit current work
00a524b0474554cdcbb346dfbe4aeedd2f93f315
josie <josie@example.com> · 2026-10-05T21:04 ·
browse files at this commit
parents:
af309d6