CLI · commit 7438749

benchdiff

Statistically rigorous benchmark comparison across git refs

Download benchdiff.zip
Quickstart
unzip benchdiff.zip && cd benchdiff
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

benchdiff

Is my branch actually faster, or is it noise? benchdiff runs your benchmarks for two or more git refs in isolated worktrees, interleaves the runs to cancel machine drift, and reports each change with a bootstrap confidence interval, a significance test and an effect size. Results are stored per commit so you can see trends, find the commit that introduced a regression, and gate CI on it.

Features

Install

Python 3.12+.

cd benchdiff
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
export PATH="$PWD/.venv/bin:$PATH"
benchdiff --version

Quickstart

Build the demo repository (6 commits; commit 4 makes word_freq about 30% slower, commit 5 makes normalize much faster):

examples/demo_repo_builder.sh /tmp/benchdiff-demo
cd /tmp/benchdiff-demo
cat benchdiff.toml

# 1. compare the first commit with HEAD
benchdiff run HEAD~5 HEAD

# 2. measure every commit in one interleaved run, then look at the trend
benchdiff run $(git rev-list --reverse main)
benchdiff history 'word_freq_bench[words=20000]'

# 3. find the culprit automatically
benchdiff bisect 'word_freq_bench[words=20000]' HEAD~5 HEAD --threshold 5%

Usage

Defining benchmarks

Shell commands, in benchdiff.toml (run with the worktree as working directory):

[settings]
threshold = "5%"
min_runs = 12
max_runs = 60

[setup]                       # optional, once per ref, inside its worktree
cmd = "pip install -e ."
venv = true                   # give every ref its own venv (default false)

[bench.cli_count]
cmd = "python -m textkit data/corpus.txt"
setup = "true"                # optional per-benchmark setup/teardown
teardown = "true"
warmup = 2                    # any [settings] key can be overridden per benchmark

Python functions, in benchmarks/*.py of each ref:

from benchdiff import bench

@bench(params={"words": [5000, 20000]})      # -> word_freq_bench[words=5000], ...
def word_freq_bench(words):
    word_freq(TEXTS[words])

@bench(disable_gc=True, warmup=3)
def normalize_bench():
    normalize(TEXTS[20000])

Each Python sample runs in a fresh subprocess using time.perf_counter_ns. Benchmarks missing in some ref are skipped with a warning. In every command python resolves to the interpreter running benchdiff (or the per-ref venv) and these variables are set: BENCHDIFF_REF, BENCHDIFF_SHA, BENCHDIFF_WORKTREE, BENCHDIFF_PYTHON. PYTHONPATH gets the worktree (configurable with [python] pythonpath = ["src"]).

benchdiff run

The first ref is the baseline. Output from the demo:

benchdiff (wall time, base: HEAD~5, threshold 5.0%)                                                                                                                         
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Benchmark                    ┃ Ref    ┃           Median ┃ Change ┃           95% CI ┃ p (Holm) ┃          Cliff d ┃      RSS ┃ Verdict   ┃ Distribution                 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ cli_count                    │ HEAD~5 │ 18.5 ms +/- 0.9% │        │                  │          │                  │ 98.7 MiB │ baseline  │                 ├─▒┃▒─┤ •  • │
│                              │ HEAD   │ 12.3 ms +/- 1.4% │ -33.8% │ [-34.9%, -32.6%] │   <0.001 │      -1.00 large │ 98.7 MiB │ faster    │ ├─▒┃▒─┤••                    │
│ word_freq_bench[words=20000] │ HEAD~5 │ 1.15 ms +/- 0.6% │        │                  │          │                  │ 98.9 MiB │ baseline  │ ├┃▒┤         •               │
│                              │ HEAD   │ 1.55 ms +/- 1.5% │ +34.9% │ [+32.1%, +36.7%] │   <0.001 │      +1.00 large │ 98.9 MiB │ slower    │             ├─▒┃▒▒───┤ •   • │
│ normalize_bench              │ HEAD~5 │  7.4 ms +/- 1.6% │        │                  │          │                  │ 98.9 MiB │ baseline  │                        ├┃▒─┤ │
│                              │ HEAD   │  189 us +/- 7.5% │ -97.4% │ [-97.7%, -97.2%] │   <0.001 │      -1.00 large │ 98.9 MiB │ faster    │ ┃•                           │
│ checksum_bench               │ HEAD~5 │ 6.35 ms +/- 0.4% │        │                  │          │                  │ 99.0 MiB │ baseline  │ ├┃▒─┤ •••                  • │
│                              │ HEAD   │ 6.36 ms +/- 1.0% │  +0.1% │   [-0.9%, +1.5%] │    0.815 │ -0.03 negligible │ 99.0 MiB │ no change │ ├┃▒──•• •                    │
└──────────────────────────────┴────────┴──────────────────┴────────┴──────────────────┴──────────┴──────────────────┴──────────┴───────────┴──────────────────────────────┘
note: outliers in cli_count (kept in the statistics): HEAD~5: 3/60, HEAD: 2/60
note: cli_count: stopped at max_runs before reaching the CI target
note: outliers in word_freq_bench[words=20000] (kept in the statistics): HEAD~5: 1/60 (1 severe), HEAD: 3/60 (1 severe)
note: word_freq_bench[words=20000]: stopped at max_runs before reaching the CI target
note: outliers in normalize_bench (kept in the statistics): HEAD: 1/60
note: normalize_bench: stopped at max_runs before reaching the CI target
note: outliers in checksum_bench (kept in the statistics): HEAD~5: 5/60 (3 severe), HEAD: 3/60
note: checksum_bench: stopped at max_runs before reaching the CI target
warning: CPU governor is 'powersave': frequency scaling can add noise (use 'performance' for stable runs)
AMD Ryzen 5 7600X 6-Core Processor x12, python 3.12.12, load 1.62, governor powersave

Useful options: -f table|markdown|json|html, -o FILE, -b NAME_OR_GLOB (repeatable), --threshold 2%, --fail-on-regression 5%, --metric wall|cpu, --min-runs, --max-runs, --min-time, --max-time, --target-ci 1%, --warmup, --seed, --keep-worktrees, --no-store, -C REPO, -c CONFIG.

CI gate (exit status 1 when a benchmark is significantly slower by at least 5%, 0 otherwise, 2 on errors):

$ benchdiff run HEAD~5 HEAD -b 'word_freq_bench*' --fail-on-regression 5%; echo $?
regression: word_freq_bench[words=5000] HEAD +32.4% (limit 5.0%)
regression: word_freq_bench[words=20000] HEAD +33.1% (limit 5.0%)
1

An HTML report with SVG violin/box plots from the same demo is in examples/sample-report.html.

benchdiff compare

Save results with -f json and compare later (no rerun; each file contributes its last ref unless --ref-a/--ref-b is given):

$ benchdiff run HEAD~5 -b cli_count -f json -o a.json
$ benchdiff run HEAD -b cli_count -f json -o b.json
$ benchdiff compare a.json b.json -f markdown
### benchdiff: `HEAD~5`, `HEAD`

wall time, base `HEAD~5`, threshold 2.0%, Holm-adjusted p-values.

| Benchmark | Ref | Median | Change | 95% CI | p (Holm) | Cliff's d | Verdict |
|---|---|---:|---:|---:|---:|---:|---|
| cli_count | HEAD~5 | 18.9 ms | | | | | baseline |
| cli_count | HEAD | 11.8 ms | -37.6% | [-38.9%, -36.7%] | <0.001 | -1.00 (large) | **faster** |

- outliers in `cli_count` (kept): HEAD~5: 4/60, HEAD: 3/60
- warning: environments may differ between a.json and b.json; runs were not interleaved

Runs that were not interleaved carry a warning, because drift between them is not cancelled.

benchdiff history

$ benchdiff history 'word_freq_bench[words=20000]'
word_freq_bench[words=20000] (wall time, 6 commits)  ▁▁▁█▇▇
commit    date            median   vs prev  subject
eb507ac3  2026-01-01     1.16 ms            feat: initial textkit with word_freq, normalize an
bdd886d9  2026-01-02     1.16 ms     +0.4%  feat: add top_words helper
cdf6998e  2026-01-03     1.15 ms     -1.0%  docs: add README
6a324704  2026-01-04     1.62 ms    +40.5%  fix: count words case-insensitively  <-- change point +37.4%
f9f42e2b  2026-01-05      1.6 ms     -1.1%  perf: normalize with str.translate
d8fa0174  2026-01-06     1.55 ms     -3.0%  docs: document the command line

Change points come from binary segmentation (see docs/statistics.md); --min-change sets the smallest level shift that counts.

benchdiff bisect

$ benchdiff bisect 'word_freq_bench[words=20000]' HEAD~5 HEAD --threshold 5%
verifying that HEAD is slower than HEAD~5
preparing HEAD~5 (eb507ac3)
preparing d8fa0174 (d8fa0174)
benchmarking word_freq_bench[words=20000]
  60 samples per ref (max_runs)
  d8fa0174 +34.1% -> slower
preparing HEAD~5 (eb507ac3)
preparing cdf6998e (cdf6998e)
benchmarking word_freq_bench[words=20000]
  60 samples per ref (max_runs)
  cdf6998e +0.2% -> no change
preparing HEAD~5 (eb507ac3)
preparing 6a324704 (6a324704)
benchmarking word_freq_bench[words=20000]
  60 samples per ref (max_runs)
  6a324704 +34.7% -> slower
first bad commit: 6a324704e75e  fix: count words case-insensitively
word_freq_bench is +34.1% slower at HEAD than at HEAD~5; probes:
  d8fa0174    +34.1%  slower
  cdf6998e     +0.2%  no change
  6a324704    +34.7%  slower

Every probe is measured interleaved with the good commit. Your checkout and git bisect state are never touched.

Configuration

benchdiff.toml in the repo (or -c FILE). All percentages accept "2%", "2" or 2.

Key Default Meaning
[settings] warmup 1 discarded runs per ref
min_runs / max_runs 10 / 200 samples per ref
min_time / max_time 1 / 30 seconds spent per benchmark
target_ci 1% stop when the median's CI half-width is below this
threshold 2% smallest change reported as faster/slower
metric wall wall or cpu
repeat 3 timed calls per subprocess for Python benchmarks
[setup] cmd, venv none, false per-ref setup command; per-ref venv
[python] dir, pythonpath, disable_gc benchmarks, ["."], false Python benchmark discovery
[bench.NAME] cmd, setup, teardown a shell benchmark (plus any [settings] key)

Precedence: command line, then [bench.NAME], then [settings].

Architecture

Module Role
cli.py click commands: run, compare, history, bisect; exit codes
config.py benchdiff.toml parsing, settings merge
engine.py worktree preparation, ABBA scheduling, sampling loop, run_refs
pyrunner.py, registry.py @bench decorator and the per-ref subprocess that discovers and times it
gitutil.py ref resolution, Workspace (temporary worktrees), commit ranges
stats.py outliers, BCa/percentile bootstrap, Mann-Whitney, Cliff's delta, Holm, verdict
analysis.py raw samples -> summaries, comparisons, verdicts
report.py, html_report.py, sketch.py terminal/Markdown/JSON, HTML+SVG, box plot and sparkline text
store.py, history.py SQLite results, change-point detection
bisect.py verdict-driven binary search over a commit range
envinfo.py CPU/governor/load metadata and noise warnings

Tests

.venv/bin/pytest -q                    # everything (56 tests, ~20 s)
.venv/bin/pytest -q -m "not slow"      # skip the end-to-end tests that benchmark the demo repo

The slow tests build the demo repo, run real benchmarks, check the verdicts, the history change point, the CI exit code and that bisect finds commit 4.

Limitations