docs: add bench graphs [skip ci]

This commit is contained in:
Loric ANDRE 2026-04-07 20:02:23 +02:00
parent 1eb51eecca
commit 797ca70da5
12 changed files with 581 additions and 112 deletions

5
.envrc
View file

@ -1 +1,4 @@
use flake .#full
export _skim_flake_profile="default"
# .envrc.local lets you override the _skim_flake_profile if needed: `export _skim_flake_profile="full"`
source_env_if_exists .envrc.local
use flake .#"$_skim_flake_profile"

1
.envrc.local Normal file
View file

@ -0,0 +1 @@
export _skim_flake_profile="full"

View file

@ -1,9 +1,16 @@
version: 2
updates:
- package-ecosystem: "cargo"
- package-ecosystem: cargo
directory: "/"
schedule:
interval: "weekly"
interval: weekly
commit-message:
prefix: chore(deps)
prefix-development: chore(dev-deps)
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly
commit-message:
prefix: chore(ci/deps)
prefix-development: chore(ci/dev-deps)

3
.gitignore vendored
View file

@ -13,7 +13,6 @@
/bin/sk
.idea/
.ropeproject/
bench_data.txt
.direnv
# Coverage
@ -21,8 +20,6 @@ lcov.info
codecov.json
*.profraw
benches/fixtures/*.txt
# Profiling
profile.json.gz
*.data

17
Cargo.lock generated
View file

@ -235,6 +235,12 @@ version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@ -1037,6 +1043,16 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "gnuplot"
version = "0.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b34e7ffbf21f5e89a35b321483b71e857680fca763bb3a982d9cf32a5acccf"
dependencies = [
"byteorder",
"tempfile",
]
[[package]]
name = "gungraun"
version = "0.17.2"
@ -2321,6 +2337,7 @@ dependencies = [
"env_logger",
"frizbee",
"futures",
"gnuplot",
"gungraun",
"indexmap",
"insta",

View file

@ -98,6 +98,7 @@ gungraun = ["dep:gungraun"]
[dev-dependencies]
criterion = { version = "0.8.2", features = ["async_tokio"] }
insta = "1.47"
gnuplot = "0.0.45"
serde_json = { version = "=1.0.149" }
serial_test = "=3.4.0"

View file

@ -599,42 +599,10 @@ Check out more examples under the [examples/](https://github.com/skim-rs/skim/tr
## Benchmarks
This benchmarks runs the interactive interface in a tmux session, and waits for the UI to stabilize.
It uses a 10 million path-like ASCII items input file, and the query test.
```
=== Results: sk v4.0.0 [baseline] ===
Completed runs: 50 / 50
Average items matched: 2895782 / 10000000 (min: 2895782, max: 2895782)
Average time: 3.827s (min: 3.576s, max: 4.090s)
Average items/second: 2615767 (min: 2445033, max: 2796365)
Average peak memory usage: 1589.2 MB (min: 1518.6 MB, max: 1661.2 MB)
Average peak CPU usage: 528.9% (min: 457.0%, max: 740.0%)
![benchmark graphs](./bench.png)
=== Results: sk v3.7.0 ===
Completed runs: 50 / 50
Average items matched: 2895782 / 10000000 (min: 2895782, max: 2895782) +0.0%
Average time: 3.930s (min: 3.565s, max: 4.226s) +2.7%
Average items/second: 2548674 (min: 2366263, max: 2804816) -2.6%
Average peak memory usage: 1618.8 MB (min: 1539.1 MB, max: 1680.6 MB) +1.9%
Average peak CPU usage: 696.8% (min: 608.0%, max: 875.0%) +31.7%
=== Results: fzf 0.70.0 ===
Completed runs: 50 / 50
Average items matched: 2895782 / 10000000 (min: 2895782, max: 2895782) +0.0%
Average time: 5.421s (min: 4.814s, max: 6.111s) +41.7%
Average items/second: 1848269 (min: 1636444, max: 2077385) -29.3%
Average peak memory usage: 2015.3 MB (min: 1860.7 MB, max: 2173.9 MB) +26.8%
Average peak CPU usage: 1301.1% (min: 1229.0%, max: 1431.0%) +146.0%
=== Comparison Summary (vs baseline: sk v4.0.0) ===
Binary Avg time Δ time Avg rate Δ rate
------------------------------------------------------------------------------
sk v4.0.0 3.827s baseline 2615767 baseline
sk v3.7.0 3.930s +2.7% 2548674 -2.6%
fzf 0.70.0 5.421s +41.7% 1848269 -29.3%
```
TL;DR: sk v4.0.0 is ~30% faster than fzf 0.70.0 with a third of the CPU usage and less memory usage
You can generate the graphs by using `just bench-plot` or running the recipe manually in GNU bash.
# FAQ
@ -748,16 +716,16 @@ export TERMINFO=/data/data/com.termux/files/usr/share/terminfo
The `cli` bench benchmarks skim (or any compatible binary) against other versions or fzf by running the interactive interface inside a tmux session and polling the status line until the matched count stabilises. This is by no means a precise or foolproof measurement, but it has the added benefit of benchmarking against `fzf` and of providing resource metrics (peak RSS and CPU).
```sh
cargo bench --bench cli # defaults: sk, 1 M items, query "test"
cargo bench --bench cli -- sk -n 500000 -q foo # bare name resolved via $PATH
cargo bench --bench cli -- ./old/sk ./new/sk -r 5 # compare two binaries, 5 runs each
cargo bench --bench cli -- sk -r 5 # 5 runs, show average
cargo bench --bench cli -- sk -f input.txt -q search # use an existing file
cargo bench --bench cli -- -g testdata.txt -n 2000000 # generate input file and exit
cargo bench --bench cli -- sk -p # record perf data (auto-named file)
cargo bench --bench cli -- sk -p perf.data # record perf data to perf.data
cargo bench --bench cli -- sk -j # JSON output
cargo bench --bench cli -- sk -r 3 -- --tiebreak=index # pass extra flags to sk
cargo bench --bench cli -- run # defaults: sk, 1 M items, query "test"
cargo bench --bench cli -- run sk -n 500000 -q foo # bare name resolved via $PATH
cargo bench --bench cli -- run ./old/sk ./new/sk -r 5 # compare two binaries, 5 runs each
cargo bench --bench cli -- run sk -f input.txt -q search # use an existing file
cargo bench --bench cli -- generate -f testdata.txt -n 2000000 # generate input file and exit
cargo bench --bench cli -- run sk --perf # record perf data (auto-named file)
cargo bench --bench cli -- run sk --strace # record strace data (auto-named file)
cargo bench --bench cli -- run sk -p perf.data # record perf data to perf.data
cargo bench --bench cli -- run sk -j # JSON output
cargo bench --bench cli -- run sk -r 3 -- --tiebreak=index # pass extra flags to sk
```
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`.

BIN
bench.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View file

@ -10,7 +10,7 @@
use clap::Parser;
use rand::RngExt as _;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{BufWriter, Result, Write};
use std::path::Path;
@ -35,7 +35,7 @@ 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 = 60.0;
const MAX_WAIT_S: f64 = 120.0;
/// Polling interval in milliseconds.
const CHECK_INTERVAL_MS: u64 = 1;
@ -85,10 +85,60 @@ const WORDS: &[&str] = &[
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")]
#[arg(value_name = "BINARY_PATH", default_value = "")]
binaries: Vec<String>,
/// Number of items to generate
@ -111,10 +161,6 @@ struct Args {
#[arg(short = 'f', long, value_name = "FILE")]
file: Option<String>,
/// Generate test data to file and exit
#[arg(short = 'g', long, value_name = "FILE")]
generate_file: Option<String>,
/// Output results as JSON
#[arg(short = 'j', long)]
json: bool,
@ -694,6 +740,57 @@ fn aggregate(results: &[RunResult]) -> AggResult {
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@ -843,8 +940,8 @@ fn pad_cell(s: &str, width: usize, right_align: bool) -> String {
///
/// When multiple binaries are provided the first is treated as the baseline and
/// delta (Δ) columns are added for every metric.
fn print_markdown_table(binaries: &[String], aggregates: &[AggResult]) {
let multi = binaries.len() > 1;
fn print_markdown_table(display_names: &[String], aggregates: &[AggResult]) {
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());
@ -882,8 +979,8 @@ fn print_markdown_table(binaries: &[String], aggregates: &[AggResult]) {
let baseline = &aggregates[0];
let mut rows: Vec<Vec<String>> = Vec::new();
for (i, (binary, agg)) in binaries.iter().zip(aggregates).enumerate() {
let name = shorten_binary(binary);
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 {
@ -1010,6 +1107,7 @@ struct JsonMinMaxAvg {
#[derive(Serialize)]
struct JsonEntry {
binary: String,
display_name: String,
runs: u32,
completed_runs: usize,
items_matched: JsonMinMaxAvg,
@ -1022,9 +1120,10 @@ struct JsonEntry {
startup_s: JsonMinMaxAvg,
}
fn build_json_entry(binary: &str, agg: &AggResult, runs: u32) -> JsonEntry {
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 {
@ -1061,11 +1160,12 @@ fn build_json_entry(binary: &str, agg: &AggResult, runs: u32) -> JsonEntry {
}
}
fn print_json(binaries: &[String], aggregates: &[AggResult], runs: u32) {
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, a)| build_json_entry(b, a, runs))
.map(|((b, dn), a)| build_json_entry(b, dn, a, runs))
.collect();
if entries.len() == 1 {
println!("{}", serde_json::to_string(&entries[0]).unwrap());
@ -1112,6 +1212,342 @@ fn strace_path_for(binary: &str, explicit: &str) -> String {
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::*, AutoOption::Fix, AxesCommon, BorderLocation2D::*, Caption, Color, Figure, FillAlpha,
LabelOption::TextColor, LegendOption::Placement, 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(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(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(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(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(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
// ---------------------------------------------------------------------------
@ -1121,15 +1557,34 @@ fn main() -> Result<()> {
// 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 mut args = Args::parse_from(raw);
let args = Args::parse_from(raw);
if args.binaries.is_empty() {
args.binaries.push(DEFAULT_BINARY.to_owned());
// ---- 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 args.binaries {
for binary in &mut binaries {
match which::which(&*binary) {
Ok(resolved) => *binary = resolved.to_string_lossy().into_owned(),
Err(e) => {
@ -1139,21 +1594,11 @@ fn main() -> Result<()> {
}
}
if args.file.is_some() && args.generate_file.is_some() {
eprintln!("error: cannot use both --file and --generate-file");
std::process::exit(1);
}
// ---- generate-file mode -----------------------------------------------
if let Some(ref path) = args.generate_file {
eprintln!("Generating {} items to {} ...", args.num_items, path);
generate_test_data(path, args.num_items).expect("failed to write test data");
eprintln!("Generated {} items successfully", args.num_items);
return Ok(());
}
// 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) = args.file {
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);
@ -1168,31 +1613,30 @@ fn main() -> Result<()> {
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, args.num_items).expect("failed to generate test data");
(path, Some(tmp), args.num_items)
generate_test_data(&path, run_args.num_items).expect("failed to generate test data");
(path, Some(tmp), run_args.num_items)
};
let binaries = &args.binaries;
let query = &args.query;
let runs = args.runs;
let warmup = args.warmup;
let extra_args = &args.extra_args;
let record_perf = args.perf.is_some();
let perf_explicit = args.perf.as_deref().unwrap_or("");
let record_strace = args.strace.is_some();
let strace_explicit = args.strace.as_deref().unwrap_or("");
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)",
binaries.join(", "),
display_names.join(", "),
num_items,
query,
warmup,
runs,
);
if args.file.is_some() {
if run_args.file.is_some() {
eprintln!("Input file: {}", tmp_file_path);
}
if !extra_args.is_empty() {
@ -1224,7 +1668,7 @@ fn main() -> Result<()> {
eprintln!("\n=== Warmup ({} run(s) per binary) ===", warmup);
for (bi, binary) in binaries.iter().enumerate() {
for wu in 1..=warmup {
eprintln!(" Warmup {}/{}{} ...", wu, warmup, binary);
eprintln!(" Warmup {}/{}{} ...", wu, warmup, display_names[bi]);
let _ = run_once(
binary,
query,
@ -1235,7 +1679,7 @@ fn main() -> Result<()> {
None,
None,
&tmux_server,
args.stable_secs,
run_args.stable_secs,
)?;
}
}
@ -1281,7 +1725,7 @@ fn main() -> Result<()> {
runs,
bi + 1,
binaries.len(),
binary
display_names[bi]
);
}
@ -1307,7 +1751,7 @@ fn main() -> Result<()> {
this_perf,
this_strace,
&tmux_server,
args.stable_secs,
run_args.stable_secs,
)?;
if runs > 1 || binaries.len() > 1 {
@ -1340,13 +1784,13 @@ fn main() -> Result<()> {
let aggregates: Vec<AggResult> = all_results.iter().map(|r| aggregate(r)).collect();
// ---- output ------------------------------------------------------------
if args.json {
print_json(binaries, &aggregates, runs);
if run_args.json {
print_json(&binaries, &display_names, &aggregates, runs);
} else {
let baseline_agg = &aggregates[0];
for (i, (binary, agg)) in binaries.iter().zip(&aggregates).enumerate() {
for (i, (display_name, agg)) in display_names.iter().zip(&aggregates).enumerate() {
print_human(
binary,
display_name,
agg,
if binaries.len() > 1 { Some(baseline_agg) } else { None },
i == 0,
@ -1355,22 +1799,22 @@ fn main() -> Result<()> {
// Summary table — always shown, markdown-formatted
if binaries.len() > 1 {
println!("\n## Comparison Summary (vs baseline: `{}`)\n", binaries[0]);
println!("\n## Comparison Summary (vs baseline: `{}`)\n", display_names[0]);
} else {
println!("\n## Results Summary\n");
}
print_markdown_table(binaries, &aggregates);
print_markdown_table(&display_names, &aggregates);
}
// ---- perf summary ------------------------------------------------------
if record_perf {
eprintln!("\n=== Perf recording output ===");
for (binary, path) in binaries.iter().zip(&perf_files) {
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: {}", binary, p);
eprintln!(" [{}] perf data: {}", display_name, p);
} else {
eprintln!(" [{}] perf data not found (perf may have failed)", binary);
eprintln!(" [{}] perf data not found (perf may have failed)", display_name);
}
}
}
@ -1379,12 +1823,12 @@ fn main() -> Result<()> {
// ---- strace summary ----------------------------------------------------
if record_strace {
eprintln!("\n=== Strace output ===");
for (binary, path) in binaries.iter().zip(&strace_files) {
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: {}", binary, p);
eprintln!(" [{}] strace output: {}", display_name, p);
} else {
eprintln!(" [{}] strace output not found (strace may have failed)", binary);
eprintln!(" [{}] strace output not found (strace may have failed)", display_name);
}
}
}

2
benches/fixtures/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
!.gitignore
*

View file

@ -36,6 +36,7 @@
cargo-dist
cargo-cross
cargo-xwin
gnuplot
];
gungraun = with pkgs; [
valgrind

View file

@ -22,11 +22,39 @@ release version: (bump-version version) generate-files (changelog version) test
git push --tags
auto-release:
git switch master
git pull
just release $(git cliff --bumped-version | sed 's/v\(.*\)/\1/')
test target="":
cargo test --doc
cargo nextest run {{ target }}
tmux kill-session -t skim_e2e
bench-plot bins="./target/release/sk sk fzf":
#!/usr/bin/env bash
set -euo pipefail
declare -A inputs=(
["1"]=1
["10"]=10
["100"]=100
["1K"]=1000
["10K"]=10000
["100K"]=100000
["1M"]=1000000
["10M"]=10000000
["100M"]=100000000
)
echo "" > /tmp/bench.json
for f in "${!inputs[@]}"; do
p="benches/fixtures/$f.txt"
n="${inputs[$f]}"
if [ ! -f "$p" ]; then
cargo bench --bench cli -- generate -n "$n" -f "$p"
fi
# The formula for `--stable-secs` might need adjusting if the number of results varies between runs with the same input
cargo bench --bench cli -- run {{ bins }} -f "$p" --runs 10 --stable-secs "$(( 3 * $n / 10000000 )).5" --json >> /tmp/bench.json
done
cargo bench --bench cli -- plot -i /tmp/bench.json