lotabout.skim/benches/cli.rs
LoricAndre 5f4798325a
tests: improve coverage on matchers & algos (#1100)
* test(engine,fuzzy_matcher): add unit tests for branch coverage; fix use_cache(false) double-borrow

Add targeted unit tests to exercise every reachable branch in the `engine`
and `fuzzy_matcher` modules, measured with cargo-llvm-cov's branch coverage
on nightly. Tests use realistic inputs and assert concrete behaviour:

- engine: empty/offset/byte-range matching ranges in the fuzzy engine, the
  Frizbee and typo Arinae build paths, AND/OR empty-term filtering, and
  split-engine byte-range char exclusion.
- arinae: typo substitutions and deletions, non-ASCII dispatch, prefilter
  rejection paths, and direct kernel tests for the DP guards / band-skip /
  dead-row pruning that compute_banding makes unreachable through the API.
- clangd/fzy/skim/util: typo-DP substitution, deletion, gap and length-guard
  paths; ASCII/non-ASCII dispatch; single-char and score-only paths; and the
  assert_order failure diagnostics.

Fix a latent double-borrow bug: `use_cache(false)` in the clangd, skim and
fzy matchers called `RefCell::replace` on cache cells whose `RefMut` guards
were still alive, panicking on every match. Drop the guards before clearing
the caches so the option works (and is now covered by tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q93ttrw4JoXCjBezV2Skmm

* test(fuzzy_matcher): thread guard paths by isolating callees

Cover branches that are reachable only when the inner helper is invoked
directly with inputs the public matchers can never produce:

- clangd `match_bonus` with `Action::Miss` (callers always pass `Match`) —
  asserts the 30-point in-segment-after-miss penalty.
- fzy `internal_to_skim_score(SCORE_MIN)` sentinel mapping; the empty-pattern
  slow-path `n == 0` guard; and `fzy_score` driven with a non-subsequence
  needle so the position backtrace hits the column-0 fallback.

The branches that remain uncovered are now confirmed structurally
unreachable even via direct callee calls: const-generic monomorphization
artifacts, M-cell `!= SCORE_MIN` checks (an M-cell is never exactly the
sentinel after gap accumulation), a match cell at (i>0, j==0) that is always
SCORE_MIN, and short-circuit operands excluded by upstream invariants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q93ttrw4JoXCjBezV2Skmm

* test(fuzzy_matcher/skim): thread arg-reachable guards in skim helpers

build_in_place_bonus's `b.len() > 1` and calculate_score_with_pos's
`op.is_none()` are unreachable through the public matcher (the real caller
never passes an empty choice or an over-wide column range), but they ARE
reachable by calling the private helpers directly with such arguments.
Cover both, leaving only genuinely argument-independent dead branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q93ttrw4JoXCjBezV2Skmm

* chore: misc checks & fixes

* fix: default bench arg

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore: remove magic number

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-27 00:16:15 +02:00

1854 lines
65 KiB
Rust

#![allow(missing_docs, clippy::pedantic)]
//! Interactive benchmark binary for skim.
//!
//! Measures ingestion + matching rate by running sk (or any compatible binary)
//! inside a tmux session, streaming generated (or pre-existing) test data into
//! it, and polling the status line until the matched count stabilises.
//!
//! Binary names are resolved to absolute paths via `which` before use, so bare
//! names like `sk` or `fzf` work as long as they are on `$PATH`.
//! ```
use clap::Parser;
use rand::RngExt as _;
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{BufWriter, Result, Write};
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_BINARY: &str = "./target/release/sk";
const DEFAULT_NUM_ITEMS: u64 = 1_000_000;
const DEFAULT_QUERY: &str = "test";
/// Prompt string injected via `--prompt` so we can detect first render in the tmux buffer.
const BENCH_PROMPT: &str = "BENCH> ";
/// Timeout for pre-measurement phases (waiting for shell prompt / command echo).
const PRE_MEASUREMENT_TIMEOUT_S: f64 = 15.0;
/// Seconds the matched count must be unchanged before declaring completion.
const REQUIRED_STABLE_S: f64 = 5.0;
/// Hard timeout per run.
const MAX_WAIT_S: f64 = 120.0;
/// Polling interval in milliseconds.
const CHECK_INTERVAL_MS: u64 = 1;
const WORDS: &[&str] = &[
"home",
"usr",
"etc",
"var",
"opt",
"tmp",
"dev",
"proc",
"sys",
"lib",
"bin",
"sbin",
"boot",
"mnt",
"media",
"src",
"test",
"config",
"data",
"logs",
"cache",
"backup",
"docs",
"images",
"videos",
"audio",
"downloads",
"uploads",
"temp",
"shared",
];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
#[derive(Parser, Debug)]
#[command(
name = "bench",
about = "Benchmark skim ingestion + matching rate in interactive mode",
long_about = "Measures how fast skim can ingest items and display matched results \
by running sk inside a tmux session and polling the status line.",
ignore_errors(true)
)]
struct Args {
#[command(subcommand)]
command: Subcommand,
}
#[derive(clap::Subcommand, Debug)]
enum Subcommand {
/// Generate test data to a file and exit
#[command(alias = "gen", alias = "g")]
Generate(GenerateArgs),
/// Run the benchmark
#[command(alias = "r")]
Run(RunArgs),
/// Plot results from a JSON file produced by one (or multiple concatenated) `run --json` calls
Plot(PlotArgs),
}
#[derive(clap::Args, Debug)]
struct PlotArgs {
/// JSON file produced by `run --json` (entries may be concatenated across runs)
#[arg(short = 'i', long, default_value = "/tmp/bench.json", value_name = "FILE")]
input: String,
/// Output image path
#[arg(short = 'o', long, default_value = "bench.png", value_name = "FILE")]
output: String,
/// Image width in pixels
#[arg(long, default_value_t = 1600u32, value_name = "PX")]
width: u32,
/// Image height in pixels
#[arg(long, default_value_t = 1200u32, value_name = "PX")]
height: u32,
}
#[derive(clap::Args, Debug)]
struct GenerateArgs {
/// Output file to write generated items to
#[arg(short = 'f', long, value_name = "FILE", required = true)]
file: String,
/// Number of items to generate
#[arg(short = 'n', long, default_value_t = DEFAULT_NUM_ITEMS, value_name = "NUM")]
num_items: u64,
}
#[derive(clap::Args, Debug)]
struct RunArgs {
/// One or more paths to binaries (default: ./target/release/sk).
/// When multiple are given they run in round-robin and the first is used
/// as the baseline for +/- comparisons.
#[arg(value_name = "BINARY_PATH", default_value = "")]
binaries: Vec<String>,
/// Number of items to generate
#[arg(short = 'n', long, default_value_t = DEFAULT_NUM_ITEMS, value_name = "NUM")]
num_items: u64,
/// Query string to search
#[arg(short = 'q', long, default_value = DEFAULT_QUERY)]
query: String,
/// Number of benchmark runs per binary
#[arg(short = 'r', long, default_value_t = 1u32, value_name = "RUNS")]
runs: u32,
/// Number of warmup runs per binary
#[arg(short = 'w', long, default_value_t = 1u32, value_name = "N")]
warmup: u32,
/// Use existing file as input instead of generating
#[arg(short = 'f', long, value_name = "FILE")]
file: Option<String>,
/// Output results as JSON
#[arg(short = 'j', long)]
json: bool,
/// Record perf data for the final benchmark run.
/// Optionally specify the output file (default: auto-named
/// perf-<binary>-<timestamp>.data).
#[arg(
short = 'p',
long,
num_args = 0..=1,
default_missing_value = "",
value_name = "FILE"
)]
perf: Option<String>,
/// Run the final benchmark run under strace and write the trace to FILE.
/// Optionally specify the output file (default: auto-named
/// strace-<binary>-<timestamp>.out).
#[arg(
short = 't',
long,
num_args = 0..=1,
default_missing_value = "",
value_name = "FILE"
)]
strace: Option<String>,
/// Seconds the matched count must remain unchanged before a run is declared
/// complete (default: 5.0).
#[arg(short = 's', long, default_value_t = REQUIRED_STABLE_S, value_name = "SECS")]
stable_secs: f64,
/// Only output the table
#[arg(long, default_value_t = false)]
quiet: bool,
/// Hide the header table, usually used with `quiet`
#[arg(long, default_value_t = false)]
no_header: bool,
/// Pass remaining arguments to the benchmarked binary
#[arg(last = true)]
extra_args: Vec<String>,
}
// ---------------------------------------------------------------------------
// Test-data generation
// ---------------------------------------------------------------------------
fn generate_test_data(output_file: &str, num_items: u64) -> std::io::Result<()> {
let file = File::create(output_file)?;
let mut writer = BufWriter::new(file);
let mut rng = rand::rng();
for i in 1..=num_items {
let depth = rng.random_range(2..=10usize);
let parts: Vec<&str> = (0..depth).map(|_| WORDS[rng.random_range(0..WORDS.len())]).collect();
writeln!(writer, "{}_{}", parts.join("/"), i)?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Resource monitor
// ---------------------------------------------------------------------------
struct ResourcePeak {
peak_mem_kb: u64,
peak_cpu: f64,
}
struct ResourceMonitor {
stats: Arc<Mutex<ResourcePeak>>,
handle: thread::JoinHandle<()>,
}
impl ResourceMonitor {
fn start(pid: u32) -> Self {
let stats = Arc::new(Mutex::new(ResourcePeak {
peak_mem_kb: 0,
peak_cpu: 0.0,
}));
let stats_clone = Arc::clone(&stats);
let handle = thread::spawn(move || {
loop {
match Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "rss=,%cpu="])
.output()
{
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout);
let line = text.trim();
if line.is_empty() {
break;
}
let mut parts = line.split_whitespace();
if let (Some(rss), Some(cpu)) = (parts.next(), parts.next())
&& let (Ok(mem), Ok(cpu)) = (rss.parse::<u64>(), cpu.parse::<f64>())
{
let mut s = stats_clone.lock().unwrap();
s.peak_mem_kb = s.peak_mem_kb.max(mem);
s.peak_cpu = s.peak_cpu.max(cpu);
}
}
Err(_) => break,
}
thread::sleep(Duration::from_millis(50));
}
});
ResourceMonitor { stats, handle }
}
fn join(self) -> (Option<u64>, Option<f64>) {
let _ = self.handle.join();
let s = self.stats.lock().unwrap();
let mem = if s.peak_mem_kb > 0 { Some(s.peak_mem_kb) } else { None };
let cpu = if s.peak_cpu > 0.0 { Some(s.peak_cpu) } else { None };
(mem, cpu)
}
}
// ---------------------------------------------------------------------------
// Single run
// ---------------------------------------------------------------------------
struct RunResult {
elapsed_s: f64,
rate: f64,
matched: u64,
/// Total items fed to the binary, as reported by the status line.
total_count: u64,
peak_mem_kb: Option<u64>,
peak_cpu: Option<f64>,
completed: bool,
perf_file: Option<String>,
strace_file: Option<String>,
/// Time from launch until both the prompt+query and the `N/M` status counts
/// are visible — i.e. `max(prompt_appeared, status_appeared)`.
startup_s: Option<f64>,
}
/// Try for up to 2 s to find the sk child PID under `pane_pid`.
/// Checks immediately, then sleeps 5 ms between retries (sleep-after pattern
/// so the first successful check adds no artificial delay).
fn find_sk_pid(pane_pid: u32, binary_path: &str) -> u32 {
for _ in 0..400 {
if let Ok(o) = Command::new("pgrep")
.args(["-P", &pane_pid.to_string(), "-f", binary_path])
.output()
{
let text = String::from_utf8_lossy(&o.stdout);
if let Some(first) = text.trim().lines().next()
&& let Ok(pid) = first.trim().parse::<u32>()
{
return pid;
}
}
thread::sleep(Duration::from_millis(5));
}
0
}
/// Return true if the process with the given PID is still alive.
fn process_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{}", pid)).exists()
}
// ---------------------------------------------------------------------------
// Dedicated tmux server
// ---------------------------------------------------------------------------
/// A handle to a private tmux server identified by a unique socket name.
///
/// The server is started with a minimal, clean environment so that nothing
/// from the caller (SKIM_DEFAULT_OPTIONS, FZF_DEFAULT_OPTS, HISTFILE, …)
/// can reach the benchmark panes. The server is killed automatically when
/// this value is dropped.
struct TmuxServer {
socket: String,
capture_buf: String,
}
impl TmuxServer {
fn start() -> Self {
let socket = format!("skim_bench_{}", std::process::id());
let capture_buf = NamedTempFile::new()
.expect("failed to capture temp file")
.path()
.to_string_lossy()
.into_owned();
let _ = Command::new("tmux")
.args(["-L", &socket, "start-server"])
.env_clear()
.envs(env_vars())
.output();
Self { socket, capture_buf }
}
fn capture(&self, session_name: &str) -> Result<String> {
let buf_name = format!("status-{}", session_name);
let _ = Command::new("tmux")
.args(["-L", &self.socket, "capture-pane", "-b", &buf_name, "-t", session_name])
.output();
let _ = Command::new("tmux")
.args(["-L", &self.socket, "save-buffer", "-b", &buf_name, &self.capture_buf])
.output();
fs::read_to_string(&self.capture_buf)
}
fn new_session(&self, name: &str) -> Result<()> {
Command::new("tmux")
.args(["-L", &self.socket, "new-session", "-s", name, "-d"])
.env_clear()
.envs(env_vars())
.status()
.and(Ok(()))
}
fn send_keys(&self, session_name: &str, keys: &str) -> Result<()> {
Command::new("tmux")
.args(["-L", &self.socket, "send-keys", "-t", session_name, keys])
.status()
.and(Ok(()))
}
fn pane_pid(&self, session_name: &str) -> Result<u32> {
Command::new("tmux")
.args([
"-L",
&self.socket,
"list-panes",
"-t",
session_name,
"-F",
"#{pane_pid}",
])
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.lines()
.next()
.unwrap_or_default()
.trim()
.parse::<u32>()
.unwrap_or(0u32)
})
}
fn kill_session(&self, session_name: &str) -> Result<()> {
Command::new("tmux")
.args(["-L", &self.socket, "kill-session", "-t", session_name])
.status()
.and(Ok(()))
}
}
impl Drop for TmuxServer {
fn drop(&mut self) {
let _ = Command::new("tmux").args(["-L", &self.socket, "kill-server"]).output();
}
}
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
fn run_once(
binary_path: &str,
query: &str,
tmp_file: &str,
extra_args: &[String],
run_index: u32,
session_suffix: &str,
perf_output: Option<&str>,
strace_output: Option<&str>,
tmux_server: &TmuxServer,
stable_secs: f64,
) -> Result<RunResult> {
let session_name = format!("skim_bench_{}_{}_{}", std::process::id(), session_suffix, run_index);
// Create a detached session in the dedicated bench server.
tmux_server.new_session(&session_name)?;
// Build the command string (will be typed into the shell, not executed yet).
let extra_str = extra_args.join(" ");
let perf_prefix = match perf_output {
Some(path) => format!("perf record -o {} -- ", path),
None => String::new(),
};
let strace_prefix = match strace_output {
Some(path) => format!("strace -C -ttt -o {} -- ", path),
None => String::new(),
};
let cmd_str = format!(
"cat {} | {}{}{} --prompt '{}' {}",
tmp_file, perf_prefix, strace_prefix, binary_path, BENCH_PROMPT, extra_str
);
// --- Phase 1: wait for the shell to be ready (any pane content appears) --
// We don't rely on PS1: whatever prompt the shell shows, any non-blank
// content means the shell is alive and accepting input.
{
let phase_start = Instant::now();
loop {
thread::sleep(Duration::from_millis(CHECK_INTERVAL_MS));
if phase_start.elapsed().as_secs_f64() >= PRE_MEASUREMENT_TIMEOUT_S {
break;
}
if tmux_server.capture(&session_name).is_ok_and(|c| !c.trim().is_empty()) {
break;
}
}
}
// Type the command into the shell — no Enter yet.
tmux_server.send_keys(&session_name, &cmd_str)?;
// --- Phase 2: wait until the typed command is echoed in the pane ---------
// `--prompt '` is a short, unique substring of cmd_str that never appears
// in the binary's TUI output, so it reliably signals the command is ready.
{
let cmd_marker = "--prompt '";
let phase_start = Instant::now();
loop {
thread::sleep(Duration::from_millis(CHECK_INTERVAL_MS));
if phase_start.elapsed().as_secs_f64() >= PRE_MEASUREMENT_TIMEOUT_S {
break;
}
if tmux_server.capture(&session_name).is_ok_and(|c| c.contains(cmd_marker)) {
break;
}
}
}
// --- Pre-launch setup (before starting the measurement clock) ------------
// Get the pane PID now — the session exists and the shell is ready, so
// this is available without sk running yet.
let pane_pid: u32 = tmux_server.pane_pid(&session_name)?;
// Spawn a background thread to find the sk child PID and start the resource
// monitor. This must not run on the hot path after Enter because
// find_sk_pid's pgrep polling would inflate the measured startup time.
let binary_path_owned = binary_path.to_owned();
let monitor_cell: Arc<Mutex<Option<ResourceMonitor>>> = Arc::new(Mutex::new(None));
let sk_pid_cell: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
{
let monitor_cell = Arc::clone(&monitor_cell);
let sk_pid_cell = Arc::clone(&sk_pid_cell);
thread::spawn(move || {
let pid = find_sk_pid(pane_pid, &binary_path_owned);
*sk_pid_cell.lock().unwrap() = pid;
if pid > 0 {
*monitor_cell.lock().unwrap() = Some(ResourceMonitor::start(pid));
}
});
}
// Compile the status-line regex once, outside the hot polling loop.
let re = regex::Regex::new(r"(\d+)/(\d+)").expect("valid regex");
let prompt_with_query = format!("{}{}", BENCH_PROMPT, query);
// --- Launch: press Return, queue the query, start the measurement clock --
tmux_server.send_keys(&session_name, "Enter")?;
if !query.is_empty() {
tmux_server.send_keys(&session_name, query)?;
}
let start = Instant::now();
// Poll the tmux pane until both matched and total counts stabilise.
// We do NOT compare against a pre-counted `num_items`; instead we treat
// any change in either counter as proof that loading is still in progress.
let mut completed = false;
let mut matched_count: u64 = 0;
let mut total_count: u64 = 0;
let mut prev_matched: u64 = u64::MAX;
let mut prev_total: u64 = u64::MAX;
let mut stable_since: Option<Instant> = None;
let mut last_change_elapsed: Option<Duration> = None;
let loop_start = Instant::now();
// Startup measurements: recorded on first observation, relative to `start`.
let mut startup_prompt_s: Option<f64> = None;
let mut startup_status_s: Option<f64> = None;
loop {
thread::sleep(Duration::from_millis(CHECK_INTERVAL_MS));
if loop_start.elapsed().as_secs_f64() >= MAX_WAIT_S {
break;
}
// Check whether sk has exited (non-blocking, via /proc).
let sk_pid = *sk_pid_cell.lock().unwrap();
if sk_pid > 0 && !process_alive(sk_pid) {
break;
}
let content = match tmux_server.capture(&session_name) {
Ok(c) => c,
Err(_) => continue,
};
// Startup event: prompt with typed query rendered
if startup_prompt_s.is_none() && content.contains(&prompt_with_query) {
startup_prompt_s = Some(start.elapsed().as_secs_f64());
}
if let Some(caps) = re.captures(&content) {
let mc: u64 = caps[1].parse().unwrap_or(0);
let total: u64 = caps[2].parse().unwrap_or(0);
// Startup event: status counts visible for the first time
if startup_status_s.is_none() {
startup_status_s = Some(start.elapsed().as_secs_f64());
}
// Only start the stability clock once we have at least one item.
if total > 0 {
total_count = total;
matched_count = mc;
if mc != prev_matched || total != prev_total {
prev_matched = mc;
prev_total = total;
stable_since = Some(Instant::now());
last_change_elapsed = Some(start.elapsed());
} else if stable_since.is_some_and(|t| t.elapsed().as_secs_f64() >= stable_secs) {
completed = true;
break;
}
}
}
}
let elapsed_s = last_change_elapsed.unwrap_or_else(|| start.elapsed()).as_secs_f64();
// Send Escape to exit sk
tmux_server.send_keys(&session_name, "Escape")?;
thread::sleep(Duration::from_millis(100));
// Wait for perf record to finish writing before killing the session
if perf_output.is_some() && pane_pid > 0 {
let perf_wait = Instant::now();
loop {
if perf_wait.elapsed().as_secs_f64() >= 15.0 {
eprintln!("Warning: perf record did not exit within 15 s; perf data may be incomplete.");
break;
}
let still_running = Command::new("pgrep")
.args(["-P", &pane_pid.to_string(), "-f", "perf record"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !still_running {
break;
}
thread::sleep(Duration::from_millis(100));
}
}
// Wait for strace to finish writing before killing the session
if strace_output.is_some() && pane_pid > 0 {
let strace_wait = Instant::now();
loop {
if strace_wait.elapsed().as_secs_f64() >= 15.0 {
eprintln!("Warning: strace did not exit within 15 s; trace data may be incomplete.");
break;
}
let still_running = Command::new("pgrep")
.args(["-P", &pane_pid.to_string(), "-f", "strace"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !still_running {
break;
}
thread::sleep(Duration::from_millis(100));
}
}
let monitor = monitor_cell.lock().unwrap().take();
let (peak_mem_kb, peak_cpu) = monitor.map(ResourceMonitor::join).unwrap_or((None, None));
let rate = if elapsed_s > 0.0 && total_count > 0 {
total_count as f64 / elapsed_s
} else {
0.0
};
let _ = tmux_server.kill_session(&session_name);
Ok(RunResult {
elapsed_s,
rate,
matched: matched_count,
total_count,
peak_mem_kb,
peak_cpu,
completed,
perf_file: perf_output.map(str::to_owned),
strace_file: strace_output.map(str::to_owned),
startup_s: match (startup_prompt_s, startup_status_s) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
},
})
}
fn env_vars() -> Vec<(String, String)> {
std::env::vars()
.filter(|(k, _)| k != "HISTFILE" && !(k.starts_with("FZF") || k.starts_with("SKIM")))
.chain([("SHELL".into(), "/bin/sh".into())])
.collect()
}
// ---------------------------------------------------------------------------
// Aggregate statistics
// ---------------------------------------------------------------------------
struct AggResult {
completed: usize,
runs: usize,
avg_time: Option<f64>,
min_time: Option<f64>,
max_time: Option<f64>,
avg_rate: Option<f64>,
min_rate: Option<f64>,
max_rate: Option<f64>,
avg_matched: Option<f64>,
avg_total_count: Option<f64>,
// startup: time until both prompt+query and status counts are visible
avg_startup_s: Option<f64>,
min_startup_s: Option<f64>,
max_startup_s: Option<f64>,
min_matched: Option<f64>,
max_matched: Option<f64>,
avg_mem: Option<f64>,
min_mem: Option<f64>,
max_mem: Option<f64>,
avg_cpu: Option<f64>,
min_cpu: Option<f64>,
max_cpu: Option<f64>,
}
fn avg(vals: &[f64]) -> Option<f64> {
if vals.is_empty() {
None
} else {
Some(vals.iter().sum::<f64>() / vals.len() as f64)
}
}
fn aggregate(results: &[RunResult]) -> AggResult {
let done: Vec<&RunResult> = results.iter().filter(|r| r.completed).collect();
let times: Vec<f64> = done.iter().map(|r| r.elapsed_s).collect();
let rates: Vec<f64> = done.iter().map(|r| r.rate).collect();
let matched: Vec<f64> = done.iter().map(|r| r.matched as f64).collect();
let totals: Vec<f64> = done.iter().map(|r| r.total_count as f64).collect();
let mems: Vec<f64> = done.iter().filter_map(|r| r.peak_mem_kb.map(|v| v as f64)).collect();
let cpus: Vec<f64> = done.iter().filter_map(|r| r.peak_cpu).collect();
// Startup metrics are collected from all runs (not just completed ones)
let startup: Vec<f64> = results.iter().filter_map(|r| r.startup_s).collect();
AggResult {
completed: done.len(),
runs: results.len(),
avg_time: avg(&times),
min_time: times.iter().copied().reduce(f64::min),
max_time: times.iter().copied().reduce(f64::max),
avg_rate: avg(&rates),
min_rate: rates.iter().copied().reduce(f64::min),
max_rate: rates.iter().copied().reduce(f64::max),
avg_matched: avg(&matched),
min_matched: matched.iter().copied().reduce(f64::min),
max_matched: matched.iter().copied().reduce(f64::max),
avg_total_count: avg(&totals),
avg_mem: avg(&mems),
min_mem: mems.iter().copied().reduce(f64::min),
max_mem: mems.iter().copied().reduce(f64::max),
avg_cpu: avg(&cpus),
min_cpu: cpus.iter().copied().reduce(f64::min),
max_cpu: cpus.iter().copied().reduce(f64::max),
avg_startup_s: avg(&startup),
min_startup_s: startup.iter().copied().reduce(f64::min),
max_startup_s: startup.iter().copied().reduce(f64::max),
}
}
// ---------------------------------------------------------------------------
// Binary display name
// ---------------------------------------------------------------------------
/// Return a human-readable `"<name> <version>"` label for a binary.
///
/// * If the resolved path lives under `$PWD/target/` the version is `HEAD`.
/// * Otherwise `binary --version` is executed; its first line is parsed:
/// the executable name is stripped from the front (if present), then
/// everything up to the first whitespace is taken as the version token.
fn binary_display_name(binary_path: &str) -> String {
let exe_name = Path::new(binary_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(binary_path)
.to_owned();
// Binaries built locally (in $PWD/target/) are labelled as HEAD.
let in_target = std::env::current_dir()
.ok()
.map(|cwd| Path::new(binary_path).starts_with(cwd.join("target")))
.unwrap_or(false);
if in_target {
return format!("{} HEAD", exe_name);
}
// Run `binary --version` and parse the first line.
let version = Command::new(binary_path)
.arg("--version")
.output()
.ok()
.and_then(|o| {
// Some tools write the version to stderr instead of stdout.
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
let output = if stdout.trim().is_empty() { stderr } else { stdout };
let first_line = output.lines().next()?.trim().to_owned();
// Strip the executable name prefix if present, then take the first token.
let rest = if let Some(stripped) = first_line.strip_prefix(&exe_name) {
stripped.trim_start().to_owned()
} else {
first_line
};
Some(rest.split_whitespace().next()?.to_owned())
})
.unwrap_or_else(|| "unknown".into());
format!("{} {}", exe_name, version)
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
fn pct(baseline: Option<f64>, value: Option<f64>) -> String {
match (baseline, value) {
(Some(b), Some(v)) if b != 0.0 => {
let diff = (v - b) / b.abs() * 100.0;
if diff >= 0.0 {
format!("+{:.1}%", diff)
} else {
format!("{:.1}%", diff)
}
}
_ => String::new(),
}
}
fn fmt_opt(value: Option<f64>, fmt: impl Fn(f64) -> String) -> String {
value.map(fmt).unwrap_or_else(|| "N/A".into())
}
// ---------------------------------------------------------------------------
// Human-readable output
// ---------------------------------------------------------------------------
fn print_human(binary_label: &str, agg: &AggResult, baseline: Option<&AggResult>, is_baseline: bool) {
let tag = if is_baseline { " [baseline]" } else { "" };
println!("\n=== Results: {}{} ===", binary_label, tag);
println!("Completed runs: {} / {}", agg.completed, agg.runs);
// Comparison helper: empty when there's no baseline or this IS the baseline
let cmp = |val: Option<f64>, base_val: Option<f64>| -> String {
if baseline.is_none() || is_baseline {
String::new()
} else {
format!(" {}", pct(base_val, val))
}
};
// Matched / total
println!(
"Average items matched: {} (min: {}, max: {}) / {}{}",
fmt_opt(agg.avg_matched, |v| format!("{:.0}", v)),
fmt_opt(agg.min_matched, |v| format!("{:.0}", v)),
fmt_opt(agg.max_matched, |v| format!("{:.0}", v)),
fmt_opt(agg.avg_total_count, |v| format!("{:.0}", v)),
cmp(agg.avg_matched, baseline.and_then(|b| b.avg_matched)),
);
// Time (lower is better — preserve Python's sign convention: positive means slower)
let time_cmp = if let (Some(b), Some(v), false) = (baseline.and_then(|b| b.avg_time), agg.avg_time, is_baseline) {
let diff = (v - b) / b.abs() * 100.0;
if diff >= 0.0 {
format!(" +{:.1}%", diff)
} else {
format!(" {:.1}%", diff)
}
} else {
String::new()
};
println!(
"Average time: {} (min: {}, max: {}){}",
fmt_opt(agg.avg_time, |v| format!("{:.3}s", v)),
fmt_opt(agg.min_time, |v| format!("{:.3}s", v)),
fmt_opt(agg.max_time, |v| format!("{:.3}s", v)),
time_cmp,
);
// Rate
println!(
"Average items/second: {} (min: {}, max: {}){}",
fmt_opt(agg.avg_rate, |v| format!("{:.0}", v)),
fmt_opt(agg.min_rate, |v| format!("{:.0}", v)),
fmt_opt(agg.max_rate, |v| format!("{:.0}", v)),
cmp(agg.avg_rate, baseline.and_then(|b| b.avg_rate)),
);
// Memory (optional)
if agg.avg_mem.is_some() {
let mb = |kb: Option<f64>| fmt_opt(kb, |v| format!("{:.1} MB", v / 1024.0));
println!(
"Average peak memory usage: {} (min: {}, max: {}){}",
mb(agg.avg_mem),
mb(agg.min_mem),
mb(agg.max_mem),
cmp(agg.avg_mem, baseline.and_then(|b| b.avg_mem)),
);
}
// CPU (optional)
if agg.avg_cpu.is_some() {
println!(
"Average peak CPU usage: {} (min: {}, max: {}){}",
fmt_opt(agg.avg_cpu, |v| format!("{:.1}%", v)),
fmt_opt(agg.min_cpu, |v| format!("{:.1}%", v)),
fmt_opt(agg.max_cpu, |v| format!("{:.1}%", v)),
cmp(agg.avg_cpu, baseline.and_then(|b| b.avg_cpu)),
);
}
// Startup: time until both prompt+query and status counts are visible
if agg.avg_startup_s.is_some() {
println!(
"Startup time (UI ready): {} (min: {}, max: {}){}",
fmt_opt(agg.avg_startup_s, |v| format!("{:.3}s", v)),
fmt_opt(agg.min_startup_s, |v| format!("{:.3}s", v)),
fmt_opt(agg.max_startup_s, |v| format!("{:.3}s", v)),
cmp(agg.avg_startup_s, baseline.and_then(|b| b.avg_startup_s)),
);
}
}
// ---------------------------------------------------------------------------
// Markdown table output
// ---------------------------------------------------------------------------
fn shorten_binary(binary: &str) -> String {
if binary.len() > 40 {
Path::new(binary)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(binary)
.to_owned()
} else {
binary.to_owned()
}
}
/// Pad `s` to a display width of `width` characters (measured in Unicode scalar
/// values, not bytes — important for multi-byte chars like `Δ` and `—`).
/// Right-aligns when `right_align` is `true`, otherwise left-aligns.
fn pad_cell(s: &str, width: usize, right_align: bool) -> String {
let n = s.chars().count();
let extra = width.saturating_sub(n);
if right_align {
format!("{}{}", " ".repeat(extra), s)
} else {
format!("{}{}", s, " ".repeat(extra))
}
}
/// Print a GFM-compatible markdown table that is also column-aligned for
/// terminal readability. All cell values are pre-rendered, per-column widths
/// are computed from the actual content (in chars, not bytes), and every cell
/// is padded to that width before printing.
///
/// When multiple binaries are provided the first is treated as the baseline and
/// delta (Δ) columns are added for every metric.
fn print_markdown_table(display_names: &[String], aggregates: &[AggResult], no_header: bool) {
let multi = display_names.len() > 1;
let has_mem = aggregates.iter().any(|a| a.avg_mem.is_some());
let has_cpu = aggregates.iter().any(|a| a.avg_cpu.is_some());
let has_startup = aggregates.iter().any(|a| a.avg_startup_s.is_some());
// ---- column definitions: (header, right_align) -------------------------
let mut col_defs: Vec<(&str, bool)> =
vec![("Binary", false), ("Runs", true), ("Matched", true), ("Avg time", true)];
if multi {
col_defs.push(("Δ time", true));
}
col_defs.push(("Avg rate", true));
if multi {
col_defs.push(("Δ rate", true));
}
if has_mem {
col_defs.push(("Avg mem (MB)", true));
if multi {
col_defs.push(("Δ mem", true));
}
}
if has_cpu {
col_defs.push(("Avg CPU (%)", true));
if multi {
col_defs.push(("Δ CPU", true));
}
}
if has_startup {
col_defs.push(("Startup (s)", true));
if multi {
col_defs.push(("Δ startup", true));
}
}
// ---- pre-render all data cells -----------------------------------------
let baseline = &aggregates[0];
let mut rows: Vec<Vec<String>> = Vec::new();
for (i, (display_name, agg)) in display_names.iter().zip(aggregates).enumerate() {
let name = shorten_binary(display_name);
let name = if i == 0 && multi {
format!("**{}** *(baseline)*", name)
} else {
name
};
let mut row: Vec<String> = vec![
name,
format!("{}/{}", agg.completed, agg.runs),
fmt_opt(agg.avg_matched, |v| format!("{:.0}", v)),
fmt_opt(agg.avg_time, |v| format!("{:.3}s", v)),
];
if multi {
row.push(if i == 0 {
"".into()
} else {
pct(baseline.avg_time, agg.avg_time)
});
}
row.push(fmt_opt(agg.avg_rate, |v| format!("{:.0}", v)));
if multi {
row.push(if i == 0 {
"".into()
} else {
pct(baseline.avg_rate, agg.avg_rate)
});
}
if has_mem {
row.push(fmt_opt(agg.avg_mem, |v| format!("{:.1}", v / 1024.0)));
if multi {
row.push(if i == 0 {
"".into()
} else {
pct(baseline.avg_mem, agg.avg_mem)
});
}
}
if has_cpu {
row.push(fmt_opt(agg.avg_cpu, |v| format!("{:.1}%", v)));
if multi {
row.push(if i == 0 {
"".into()
} else {
pct(baseline.avg_cpu, agg.avg_cpu)
});
}
}
if has_startup {
row.push(fmt_opt(agg.avg_startup_s, |v| format!("{:.3}s", v)));
if multi {
row.push(if i == 0 {
"".into()
} else {
pct(baseline.avg_startup_s, agg.avg_startup_s)
});
}
}
rows.push(row);
}
// ---- compute per-column display widths (chars, not bytes) --------------
let mut widths: Vec<usize> = col_defs.iter().map(|(h, _)| h.chars().count()).collect();
for row in &rows {
for (j, cell) in row.iter().enumerate() {
widths[j] = widths[j].max(cell.chars().count());
}
}
// ---- render a padded table row from a slice of cell strings ------------
let render_row = |cells: &[String]| -> String {
let padded: Vec<String> = cells
.iter()
.zip(&col_defs)
.zip(&widths)
.map(|((cell, &(_, right)), &w)| pad_cell(cell, w, right))
.collect();
format!("| {} |", padded.join(" | "))
};
// ---- header ------------------------------------------------------------
if !no_header {
let headers: Vec<String> = col_defs.iter().map(|(h, _)| h.to_string()).collect();
println!("{}", render_row(&headers));
// ---- separator (dashes sized to column width, alignment markers) -------
let seps: Vec<String> = col_defs
.iter()
.zip(&widths)
.map(|(&(_, right), &w)| {
// Each separator cell is exactly `w` chars wide so it lines up
// with the padded header and data cells above and below it.
if right {
format!("{}:", "-".repeat(w.saturating_sub(1)))
} else {
format!(":{}", "-".repeat(w.saturating_sub(1)))
}
})
.collect();
println!("| {} |", seps.join(" | "));
}
// ---- data rows ---------------------------------------------------------
for row in &rows {
println!("{}", render_row(row));
}
}
// ---------------------------------------------------------------------------
// JSON output
// ---------------------------------------------------------------------------
#[derive(Serialize)]
struct JsonMinMaxAvg {
avg: Option<f64>,
min: Option<f64>,
max: Option<f64>,
}
#[derive(Serialize)]
struct JsonEntry {
binary: String,
display_name: String,
runs: u32,
completed_runs: usize,
items_matched: JsonMinMaxAvg,
items_total: Option<f64>,
time_s: JsonMinMaxAvg,
items_per_second: JsonMinMaxAvg,
peak_memory_kb: JsonMinMaxAvg,
peak_cpu: JsonMinMaxAvg,
/// Time from launch until both prompt+query and N/M status counts are visible.
startup_s: JsonMinMaxAvg,
}
fn build_json_entry(binary: &str, display_name: &str, agg: &AggResult, runs: u32) -> JsonEntry {
JsonEntry {
binary: binary.to_owned(),
display_name: display_name.to_owned(),
runs,
completed_runs: agg.completed,
items_matched: JsonMinMaxAvg {
avg: agg.avg_matched,
min: agg.min_matched,
max: agg.max_matched,
},
items_total: agg.avg_total_count,
time_s: JsonMinMaxAvg {
avg: agg.avg_time,
min: agg.min_time,
max: agg.max_time,
},
items_per_second: JsonMinMaxAvg {
avg: agg.avg_rate,
min: agg.min_rate,
max: agg.max_rate,
},
peak_memory_kb: JsonMinMaxAvg {
avg: agg.avg_mem,
min: agg.min_mem,
max: agg.max_mem,
},
peak_cpu: JsonMinMaxAvg {
avg: agg.avg_cpu,
min: agg.min_cpu,
max: agg.max_cpu,
},
startup_s: JsonMinMaxAvg {
avg: agg.avg_startup_s,
min: agg.min_startup_s,
max: agg.max_startup_s,
},
}
}
fn print_json(binaries: &[String], display_names: &[String], aggregates: &[AggResult], runs: u32) {
let entries: Vec<JsonEntry> = binaries
.iter()
.zip(display_names)
.zip(aggregates)
.map(|((b, dn), a)| build_json_entry(b, dn, a, runs))
.collect();
if entries.len() == 1 {
println!("{}", serde_json::to_string(&entries[0]).unwrap());
} else {
println!("{}", serde_json::to_string(&entries).unwrap());
}
}
// ---------------------------------------------------------------------------
// Perf file path helper
// ---------------------------------------------------------------------------
fn perf_path_for(binary: &str, explicit: &str) -> String {
if !explicit.is_empty() {
return explicit.to_owned();
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let base = Path::new(binary)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.replace(' ', "_"))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "sk".into());
format!("perf-{}-{}.data", base, ts)
}
fn strace_path_for(binary: &str, explicit: &str) -> String {
if !explicit.is_empty() {
return explicit.to_owned();
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let base = Path::new(binary)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.replace(' ', "_"))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "sk".into());
format!("strace-{}-{}.out", base, ts)
}
// ---------------------------------------------------------------------------
// Plot subcommand
// ---------------------------------------------------------------------------
/// Deserialised subset of the JSON entries written by `print_json`.
#[derive(Deserialize)]
struct PlotMinMaxAvg {
avg: Option<f64>,
min: Option<f64>,
max: Option<f64>,
}
#[derive(Deserialize)]
struct PlotEntry {
display_name: String,
items_total: Option<f64>,
time_s: PlotMinMaxAvg,
peak_memory_kb: PlotMinMaxAvg,
peak_cpu: PlotMinMaxAvg,
startup_s: PlotMinMaxAvg,
}
/// A single x-point in a min/avg/max band.
struct BandPoint {
x: f64,
min: f64,
avg: f64,
max: f64,
}
/// All band points for one binary / metric combination.
struct BandSeries {
name: String,
points: Vec<BandPoint>,
}
/// Parse all `PlotEntry` values from a file containing one or more concatenated
/// JSON objects or arrays (as produced by repeated `run --json >>` calls).
fn load_plot_entries(path: &str) -> std::io::Result<Vec<PlotEntry>> {
let content = fs::read_to_string(path)?;
let mut entries: Vec<PlotEntry> = Vec::new();
for val in serde_json::Deserializer::from_str(&content).into_iter::<serde_json::Value>() {
match val.ok() {
Some(serde_json::Value::Array(arr)) => {
entries.extend(arr.into_iter().filter_map(|v| serde_json::from_value(v).ok()));
}
Some(obj @ serde_json::Value::Object(_)) => {
if let Ok(e) = serde_json::from_value(obj) {
entries.push(e);
}
}
_ => {}
}
}
Ok(entries)
}
/// Group entries into per-binary band series for one metric.
/// `extract` returns `(min, avg, max)` or `None` to skip the entry.
fn collect_bands<F>(entries: &[PlotEntry], extract: F) -> Vec<BandSeries>
where
F: Fn(&PlotEntry) -> Option<(f64, f64, f64)>,
{
use std::collections::{BTreeMap, HashMap};
let mut map: HashMap<String, BTreeMap<u64, BandPoint>> = HashMap::new();
for e in entries {
let x = match e.items_total {
Some(v) if v > 0.0 => v,
_ => continue,
};
let (mn, avg, mx) = match extract(e) {
Some(t) => t,
None => continue,
};
map.entry(e.display_name.clone()).or_default().insert(
x as u64,
BandPoint {
x,
min: mn,
avg,
max: mx,
},
);
}
let mut series: Vec<BandSeries> = map
.into_iter()
.map(|(name, pts)| BandSeries {
name,
points: pts.into_values().collect(),
})
.collect();
series.sort_by(|a, b| a.name.cmp(&b.name));
series
}
fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Error>> {
use gnuplot::AlignType::*;
use gnuplot::AutoOption::Fix;
use gnuplot::BorderLocation2D::*;
use gnuplot::LabelOption::TextColor;
use gnuplot::LegendOption::Placement;
use gnuplot::{AxesCommon, Caption, Color, Figure, FillAlpha, LineWidth};
// ── Catppuccin Mocha dark palette ─────────────────────────────────────────
// Colours are referenced as HTML hex strings throughout.
const BG: &str = "#1e1e2e"; // base
const SURFACE: &str = "#31324c"; // approximate surface0 (#313244) — used for grid/border
const TEXT: &str = "#cdd6f4"; // text
const PALETTE: &[&str] = &[
"#89b4fa", // blue
"#f38ba8", // red
"#a6e3a1", // green
"#f9e2af", // yellow
"#cba6f7", // mauve
"#94e2d5", // teal
"#fab387", // peach
];
let entries = load_plot_entries(&args.input).map_err(|e| format!("cannot read '{}': {}", args.input, e))?;
if entries.is_empty() {
return Err(format!("no valid benchmark entries found in '{}'", args.input).into());
}
// ── Build per-metric band series ─────────────────────────────────────────
let time_bands = collect_bands(&entries, |e| Some((e.time_s.min?, e.time_s.avg?, e.time_s.max?)));
let cpu_bands = collect_bands(&entries, |e| Some((e.peak_cpu.min?, e.peak_cpu.avg?, e.peak_cpu.max?)));
let mem_bands = collect_bands(&entries, |e| {
Some((
e.peak_memory_kb.min? / 1024.0,
e.peak_memory_kb.avg? / 1024.0,
e.peak_memory_kb.max? / 1024.0,
))
});
let startup_bands = collect_bands(&entries, |e| {
Some((e.startup_s.min?, e.startup_s.avg?, e.startup_s.max?))
});
// ── x range ──────────────────────────────────────────────────────────────
let all_x: Vec<f64> = entries
.iter()
.filter_map(|e| e.items_total)
.filter(|&v| v > 0.0)
.collect();
if all_x.is_empty() {
return Err("no item-count data".into());
}
let x_min = all_x.iter().copied().fold(f64::INFINITY, f64::min);
let x_max = all_x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let x_lo = x_min * 0.5;
let x_hi = x_max * 2.0;
// Helper: compute (min, max) across all points in a set of band series.
let y_extent = |bands: &[BandSeries]| -> (f64, f64) {
let mn = bands
.iter()
.flat_map(|s| s.points.iter())
.map(|p| p.min)
.fold(f64::INFINITY, f64::min);
let mx = bands
.iter()
.flat_map(|s| s.points.iter())
.map(|p| p.max)
.fold(f64::NEG_INFINITY, f64::max);
(mn, mx)
};
// ── Helper: draw band series onto an axes object ─────────────────────────
// Draws a translucent filled region between min and max, plus a solid avg
// line for each BandSeries.
let draw_bands = |axes: &mut gnuplot::Axes2D, bands: &[BandSeries]| {
for (band, &color) in bands.iter().zip(PALETTE.iter().cycle()) {
if band.points.len() < 2 {
continue;
}
let xs: Vec<f64> = band.points.iter().map(|p| p.x).collect();
let ys_lo: Vec<f64> = band.points.iter().map(|p| p.min).collect();
let ys_hi: Vec<f64> = band.points.iter().map(|p| p.max).collect();
// Filled band (min → max), no legend entry.
axes.fill_between(
xs.iter().copied(),
ys_lo.iter().copied(),
ys_hi.iter().copied(),
&[Color(color.into()), FillAlpha(0.22), Caption("")],
);
// Average line with legend entry.
let ys_avg: Vec<f64> = band.points.iter().map(|p| p.avg).collect();
axes.lines(
xs.iter().copied(),
ys_avg.iter().copied(),
&[Color(color.into()), LineWidth(2.0), Caption(band.name.as_str())],
);
}
};
// ── Dark-theme gnuplot pre-commands ───────────────────────────────────────
// These raw gnuplot commands run before any plot-specific commands and apply
// the Catppuccin Mocha colour scheme globally:
// • canvas/plot background
// • border, axes and tick colours
// • grid line colours
// • legend (key) box fill and border
// • default text colour
let pre = format!(
"set border lc rgb '{surf}'\n\
set tics textcolor rgb '{text}'\n\
set xlabel textcolor rgb '{text}'\n\
set ylabel textcolor rgb '{text}'\n\
set title textcolor rgb '{text}'\n\
set grid lc rgb '{surf}'\n\
set key opaque fc rgb '{bg}'\n\
set key box lc rgb '{surf}'\n\
set key textcolor rgb '{text}'",
bg = BG,
surf = SURFACE,
text = TEXT,
);
// ── Build the figure ─────────────────────────────────────────────────────
let mut fg = Figure::new();
// The pngcairo terminal lets us set a background color via the `background`
// keyword; we embed the pixel dimensions here as well.
fg.set_terminal(
&format!("pngcairo size {},{} background '{}'", args.width, args.height, BG),
&args.output,
);
fg.set_pre_commands(&pre);
fg.set_multiplot_layout(2, 2).set_title("Benchmark Results");
// Shared label options: text in TEXT colour.
let lbl = &[TextColor(gnuplot::RGBString(TEXT))];
// ── Panel 0: Total Time — log x, log y ───────────────────────────────────
{
let (mn, mx) = y_extent(&time_bands);
let y_lo = (mn * 0.5).max(1e-9);
let y_hi = mx * 2.0;
let axes = fg.axes2d();
axes.set_title("Total Time", lbl)
.set_x_label("Items", lbl)
.set_y_label("Time (s)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_y_log(Some(10.0))
.set_x_range(Fix(x_lo), Fix(x_hi))
.set_y_range(Fix(y_lo), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
// Legend at upper-left inside the graph, matching the original.
.set_legend(
gnuplot::Coordinate::Graph(0.02),
gnuplot::Coordinate::Graph(0.98),
&[Placement(AlignLeft, AlignTop)],
lbl,
);
draw_bands(axes, &time_bands);
}
// ── Panel 1: Peak CPU — log x, linear y ──────────────────────────────────
{
let (_, mx) = y_extent(&cpu_bands);
let y_hi = (mx * 1.25).max(100.0);
let axes = fg.axes2d();
axes.set_title("Peak CPU", lbl)
.set_x_label("Items", lbl)
.set_y_label("CPU (%)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_x_range(Fix(x_lo), Fix(x_hi))
.set_y_range(Fix(0.0), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
.set_legend(
gnuplot::Coordinate::Graph(0.02),
gnuplot::Coordinate::Graph(0.98),
&[Placement(AlignLeft, AlignTop)],
lbl,
);
draw_bands(axes, &cpu_bands);
}
// ── Panel 2: Peak Memory — log x, linear y ───────────────────────────────
{
let (_, mx) = y_extent(&mem_bands);
let y_hi = (mx * 1.25).max(1.0);
let axes = fg.axes2d();
axes.set_title("Peak Memory", lbl)
.set_x_label("Items", lbl)
.set_y_label("Memory (MB)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_x_range(Fix(x_lo), Fix(x_hi))
.set_y_range(Fix(0.0), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
.set_legend(
gnuplot::Coordinate::Graph(0.02),
gnuplot::Coordinate::Graph(0.98),
&[Placement(AlignLeft, AlignTop)],
lbl,
);
draw_bands(axes, &mem_bands);
}
// ── Panel 3: Startup Time — log x, linear y ──────────────────────────────
{
let (_, mx) = y_extent(&startup_bands);
let y_hi = (mx * 1.25).max(0.01);
let axes = fg.axes2d();
axes.set_title("Startup Time", lbl)
.set_x_label("Items", lbl)
.set_y_label("Time (s)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_x_range(Fix(x_lo), Fix(x_hi))
.set_y_range(Fix(0.0), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
.set_legend(
gnuplot::Coordinate::Graph(0.02),
gnuplot::Coordinate::Graph(0.98),
&[Placement(AlignLeft, AlignTop)],
lbl,
);
draw_bands(axes, &startup_bands);
}
// Send all commands to gnuplot and wait for it to finish writing the PNG.
// `show_and_keep_running` spawns gnuplot (if not yet spawned), pipes all
// the plot commands, and returns. The subsequent `close()` sends "quit"
// and waits for the process to exit (ensuring the output file is flushed).
fg.show_and_keep_running().map_err(|e| {
format!("gnuplot not found or failed to start: {e}\nMake sure gnuplot is installed and available in PATH.")
})?;
fg.close();
eprintln!("Plot written to '{}'", args.output);
Ok(())
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() -> Result<()> {
// `cargo bench` injects `--bench` into argv for harness=false benches;
// strip it before clap sees it so it doesn't land in `extra_args` or
// cause an "unexpected argument" error.
let raw: Vec<String> = std::env::args().filter(|a| a != "--bench").collect();
let args = Args::parse_from(raw);
// ---- generate / plot subcommands (exit early) --------------------------
let run_args = match args.command {
Subcommand::Generate(ref g) => {
eprintln!("Generating {} items to {} ...", g.num_items, g.file);
generate_test_data(&g.file, g.num_items).expect("failed to write test data");
eprintln!("Generated {} items successfully", g.num_items);
return Ok(());
}
Subcommand::Plot(ref p) => {
if let Err(e) = cmd_plot(p) {
eprintln!("Error: {}", e);
std::process::exit(1);
}
return Ok(());
}
Subcommand::Run(ref r) => r,
};
let mut binaries = run_args.binaries.clone();
if binaries.is_empty() || (binaries.len() == 1 && binaries[0].is_empty()) {
binaries = vec![DEFAULT_BINARY.to_owned()];
}
// Resolve every binary to an absolute path, the same as replacing
// `<name>` with `$(which <name>)` at the call site.
for binary in &mut binaries {
match which::which(&*binary) {
Ok(resolved) => *binary = resolved.to_string_lossy().into_owned(),
Err(e) => {
eprintln!("Error: cannot resolve binary '{}': {}", binary, e);
std::process::exit(1);
}
}
}
// Compute a human-readable display name for each binary once, up front.
let display_names: Vec<String> = binaries.iter().map(|b| binary_display_name(b)).collect();
// ---- prepare input data -----------------------------------------------
let (tmp_file_path, _tmp_file_handle, num_items) = if let Some(ref path) = run_args.file {
if !Path::new(path).is_file() {
eprintln!("Error: Input file '{}' not found", path);
std::process::exit(1);
}
let count = fs::read_to_string(path)
.expect("failed to read input file")
.lines()
.count() as u64;
eprintln!("Using input file with {} items", count);
(path.clone(), None::<NamedTempFile>, count)
} else {
let tmp = NamedTempFile::new().expect("failed to create temp input file");
let path = tmp.path().to_string_lossy().into_owned();
eprintln!("Generating test data...");
generate_test_data(&path, run_args.num_items).expect("failed to generate test data");
(path, Some(tmp), run_args.num_items)
};
let query = &run_args.query;
let runs = run_args.runs;
let warmup = run_args.warmup;
let extra_args = &run_args.extra_args;
let record_perf = run_args.perf.is_some();
let perf_explicit = run_args.perf.as_deref().unwrap_or("");
let record_strace = run_args.strace.is_some();
let strace_explicit = run_args.strace.as_deref().unwrap_or("");
// ---- header ------------------------------------------------------------
eprintln!("=== Skim Ingestion + Matching Benchmark ===");
eprintln!(
"Binaries: {} | Items: {} | Query: '{}' | Warmup: {} | Runs: {} (per binary)",
display_names.join(", "),
num_items,
query,
warmup,
runs,
);
if run_args.file.is_some() {
eprintln!("Input file: {}", tmp_file_path);
}
if !extra_args.is_empty() {
eprintln!(
"Extra args: {}",
extra_args
.iter()
.map(|arg| String::from_utf8(shell_quote::Sh::quote_vec(arg)).unwrap())
.collect::<Vec<_>>()
.join(" ")
);
}
if record_perf {
eprintln!("Perf recording: enabled (final measured run only)");
}
if record_strace {
eprintln!("Strace recording: enabled (final measured run only)");
}
// ---- dedicated tmux server ---------------------------------------------
// Started once with a clean environment; all benchmark panes run inside
// it so no ambient SKIM_DEFAULT_OPTIONS / FZF_DEFAULT_OPTS / etc. can
// affect the results. Killed automatically when `_tmux_server` is dropped
// at the end of main.
let tmux_server = TmuxServer::start();
// ---- warmup (results discarded) ----------------------------------------
if warmup > 0 {
eprintln!("\n=== Warmup ({} run(s) per binary) ===", warmup);
for (bi, binary) in binaries.iter().enumerate() {
for wu in 1..=warmup {
eprintln!(" Warmup {}/{}{} ...", wu, warmup, display_names[bi]);
let _ = run_once(
binary,
query,
&tmp_file_path,
extra_args,
wu,
&format!("warmup_b{}", bi),
None,
None,
&tmux_server,
run_args.stable_secs,
)?;
}
}
}
// ---- measured runs in round-robin ---------------------------------------
let mut all_results: Vec<Vec<RunResult>> = (0..binaries.len()).map(|_| Vec::new()).collect();
// Determine perf output paths (one per binary, recorded only on last run)
let perf_files: Vec<Option<String>> = if record_perf {
binaries
.iter()
.enumerate()
.map(|(bi, binary)| {
let explicit = if binaries.len() == 1 { perf_explicit } else { "" };
let _ = bi;
Some(perf_path_for(binary, explicit))
})
.collect()
} else {
vec![None; binaries.len()]
};
// Determine strace output paths (one per binary, recorded only on last run)
let strace_files: Vec<Option<String>> = if record_strace {
binaries
.iter()
.map(|binary| {
let explicit = if binaries.len() == 1 { strace_explicit } else { "" };
Some(strace_path_for(binary, explicit))
})
.collect()
} else {
vec![None; binaries.len()]
};
for run_num in 1..=runs {
for (bi, binary) in binaries.iter().enumerate() {
if runs > 1 || binaries.len() > 1 {
eprintln!(
"\n=== Run {}/{} — binary {}/{}: {} ===",
run_num,
runs,
bi + 1,
binaries.len(),
display_names[bi]
);
}
// Attach perf/strace only on the final run for this binary
let this_perf = if run_num == runs {
perf_files[bi].as_deref()
} else {
None
};
let this_strace = if run_num == runs {
strace_files[bi].as_deref()
} else {
None
};
let result = run_once(
binary,
query,
&tmp_file_path,
extra_args,
run_num,
&format!("b{}", bi),
this_perf,
this_strace,
&tmux_server,
run_args.stable_secs,
)?;
if runs > 1 || binaries.len() > 1 {
eprintln!("Status: {}", if result.completed { "COMPLETED" } else { "TIMEOUT" });
eprintln!("Items matched: {} / {}", result.matched, result.total_count);
eprintln!("Total time: {:.3}s", result.elapsed_s);
eprintln!("Items/second: {:.0}", result.rate);
if let Some(kb) = result.peak_mem_kb {
eprintln!("Peak memory usage: {:.1} MB", kb as f64 / 1024.0);
}
if let Some(cpu) = result.peak_cpu {
eprintln!("Peak CPU usage: {:.1}%", cpu);
}
if let Some(s) = result.startup_s {
eprintln!("Startup time (UI ready): {:.3}s", s);
}
if let Some(ref pf) = result.perf_file {
eprintln!("Perf data: {}", pf);
}
if let Some(ref sf) = result.strace_file {
eprintln!("Strace output: {}", sf);
}
}
all_results[bi].push(result);
}
}
// ---- aggregate ---------------------------------------------------------
let aggregates: Vec<AggResult> = all_results.iter().map(|r| aggregate(r)).collect();
// ---- output ------------------------------------------------------------
if run_args.json {
print_json(&binaries, &display_names, &aggregates, runs);
} else {
if !run_args.quiet {
let baseline_agg = &aggregates[0];
for (i, (display_name, agg)) in display_names.iter().zip(&aggregates).enumerate() {
print_human(
display_name,
agg,
if binaries.len() > 1 { Some(baseline_agg) } else { None },
i == 0,
);
}
// Summary table — always shown, markdown-formatted
if binaries.len() > 1 {
println!("\n## Comparison Summary (vs baseline: `{}`)\n", display_names[0]);
} else {
println!("\n## Results Summary\n");
}
}
print_markdown_table(&display_names, &aggregates, run_args.no_header);
}
// ---- perf summary ------------------------------------------------------
if record_perf {
eprintln!("\n=== Perf recording output ===");
for (display_name, path) in display_names.iter().zip(&perf_files) {
if let Some(p) = path {
if Path::new(p).is_file() {
eprintln!(" [{}] perf data: {}", display_name, p);
} else {
eprintln!(" [{}] perf data not found (perf may have failed)", display_name);
}
}
}
}
// ---- strace summary ----------------------------------------------------
if record_strace {
eprintln!("\n=== Strace output ===");
for (display_name, path) in display_names.iter().zip(&strace_files) {
if let Some(p) = path {
if Path::new(p).is_file() {
eprintln!(" [{}] strace output: {}", display_name, p);
} else {
eprintln!(" [{}] strace output not found (strace may have failed)", display_name);
}
}
}
}
Ok(())
}