Benchmark Testing skill for Claude Code
Faster means significant, not one lucky run. Claude stops quoting single-run timings. It uses the right harness per language, guards against dead-code elimination, quiets the machine, measures noise with an A/A run, and compares 10 or more samples per side with a significance test and confidence interval. CI gates get thresholds derived from measured noise, or instruction counts when shared runners are too jittery.
- name
benchmark-testing - size259 lines · ≈3,000 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and runs micro and macro benchmarks that produce trustworthy numbers - JMH for Java, Go testing.B with b.Loop and benchstat, pytest-benchmark and pyperf for Python, Criterion and divan for Rust, Vitest bench and tinybench for JavaScript, hyperfine for CLIs - with warmup, enough samples, dead-code-elimination guards, A/A noise calibration, statistical A/B comparison, and CI regression gates with noise thresholds. Use when asked whether a change made code faster, to benchmark two implementations, to add performance regression checks to CI, or when benchmark results look too good or swing between runs. Try asking
- Is the new JSON parser actually faster than the old one? Benchmark both properly
- Add Go benchmarks for the pricing package and compare this branch to main with benchstat
- Set up a CI check that fails PRs when our Rust hot path regresses more than 5%
Use it when
- Deciding whether a change or library swap made code faster
- Benchmark numbers swing between runs or look impossibly fast
- Adding performance regression checks to CI
- Writing JMH, testing.B, pytest-benchmark, Criterion or Vitest benchmarks
Skip it when
- You need requests per second or latency under concurrent load - use load-testing instead
- You need to find which function is slow in a Go service - use go-pprof instead
How to check the result
An A/A run shows no significant difference, a deliberately slowed build is flagged as a regression, and the reported change carries sample count, CI and p-value.
The common mistake it prevents
Leaving the benchmarked result unused or the input constant, so the compiler or JIT removes the work and the benchmark reports a miraculous speedup.
Done well means: each benchmark answers one stated question, measures only the code in question with realistic inputs, runs enough samples on a quiet machine to know its own noise, and compares A against B with a statistical test. A claim of “X% faster” comes with sample count, spread, and p-value or confidence interval. One run per side is an anecdote.
Ground rules
- Benchmarks answer “how long does this code take per operation”. “How many requests
per second can the service handle” is a load test (
load-testing); “where does the time go” is profiling (go-pprof,node-profiling,python-profiling). Profile first if you do not know what to benchmark. - Use the ecosystem’s harness. Never time a hand-written loop with
Date.now()ortime.time(): no warmup, no statistics, timer resolution errors, and dead-code risk. - Compare on the same machine, same build flags, same input, interleaved or back to back. Numbers from different machines or days are not comparable.
- At least 10 samples per side for comparisons (benchstat’s guidance is 10, ideally 20), decided before running. Rerunning until the result looks significant is p-hacking.
- Release/optimized builds only:
-O/--release, no debugger, no coverage instrumentation, noNODE_ENV=development. - Report medians with spread, not a single mean; timing distributions are right-skewed.
Workflow
- [ ] 1. Write the question: which operation, which inputs, what decision depends on it
- [ ] 2. Pick the harness for the language and the level (micro vs macro)
- [ ] 3. Write the benchmark with realistic inputs and dead-code guards
- [ ] 4. Quiet the machine; run an A/A comparison to measure noise
- [ ] 5. Run baseline and candidate with the same sample count
- [ ] 6. Compare statistically; check the change exceeds the noise
- [ ] 7. Confirm with a profile or a macro benchmark if the claim matters
- [ ] 8. Report, or wire into CI with a threshold above measured noise
2. Harness per language
| Language | Micro harness | Compare with |
|---|---|---|
| Java / JVM | JMH (jmh-java-benchmark-archetype), run as java -jar target/benchmarks.jar, never from the IDE | JMH JSON output (-rf json) plus compare_bench.py, or JMH’s error bars |
| Go | testing.B with for b.Loop() (Go 1.24+) | benchstat old.txt new.txt |
| Python | pytest-benchmark in test suites; pyperf for rigorous standalone runs | --benchmark-compare, pyperf compare_to |
| Rust | Criterion 0.8 or divan; Gungraun (formerly iai-callgrind) for instruction counts | Criterion baselines (--save-baseline, --baseline) |
| JS / TS | Vitest bench (fixture API in Vitest 5; top-level bench in Vitest 4 and earlier) or tinybench | Vitest bench.compare, stored results via writeResult / bench.from |
| .NET | BenchmarkDotNet, Release build | Its statistical summary |
| CLI / whole program (macro) | hyperfine --warmup 3 --runs 20 'old cmd' 'new cmd' | hyperfine’s relative summary, --export-json |
Per-tool code is in the sections below; pick the one in use.
3. Writing the benchmark
- Inputs: realistic size and shape (production-like payloads, sizes that cross cache
boundaries). Parameterize sizes (
args/@Param/sub-benchmarks) instead of guessing one. - Setup outside the timed region: build inputs before the loop or in a setup fixture.
- Dead-code elimination: the compiler or JIT removes work whose result is unused and folds constant inputs. Consume every result and make inputs opaque:
| Harness | Guard |
|---|---|
| JMH | Return the result from the @Benchmark method or pass it to Blackhole.consume; keep inputs in @State fields, not constants |
| Go 1.24+ | b.Loop() keeps call arguments and results alive; with the old b.N loop, assign to a package-level sink variable |
| Rust | std::hint::black_box(input) and return the output (criterion::black_box is deprecated) |
| JS | Use the result (accumulate into a variable checked after the run); vary inputs; JIT can still specialize, so benchmark at the macro level for important claims |
| Python | Less optimization risk, but keep the call identical to real usage (no cached results across rounds) |
- Results that are suspiciously fast (sub-nanosecond per op, identical for all sizes) mean the work was eliminated. Fix the guard before trusting anything.
4. Quiet machine and A/A calibration
- Linux benchmark host: performance CPU governor, turbo disabled, pinned cores
(
taskset -c 2), nothing else running. Python:python3 -m pyperf system tune(pyperf system resetafterwards). - Laptop: AC power, close browsers and IDE indexers, let it cool between long runs. Expect several percent noise regardless.
- A/A run: benchmark the same build twice and compare. The spread you see is the smallest difference you can detect. If A/A shows “significant” differences, the setup is too noisy for the question; fix the environment or switch to instruction counts.
5–6. Compare
Use the harness’s comparator, or the bundled script for any tool that exports raw samples:
python3 scripts/compare_bench.py base.txt new.txt --threshold 5 --alpha 0.01
python3 scripts/compare_bench.py old.json new.json --format pytest # pytest-benchmark --benchmark-json
python3 scripts/compare_bench.py old.txt new.txt --format go # go test -bench output
It prints median, change, bootstrap 95% CI, Mann-Whitney p-value, and relative spread per
benchmark, and exits 1 when a change is both significant and larger than the threshold in
the bad direction (--higher-is-better for throughput metrics). It flags fewer than 10
samples and spread above half the threshold.
Decision rules:
~in benchstat, or p >= alpha: no detectable difference. Say so; do not report the raw percentage as a win.- Significant but below the threshold you care about: real but not worth complexity.
- Significant and above threshold: confirm with a second independent run or a profile before claiming it in a PR.
Tool recipes
Go
func BenchmarkParse(b *testing.B) {
input := loadFixture(b, "testdata/orders.json") // outside the timed loop
for b.Loop() {
if _, err := Parse(input); err != nil {
b.Fatal(err)
}
}
}
go install golang.org/x/perf/cmd/benchstat@latest
git stash && go test -run '^$' -bench 'Parse' -benchmem -count 10 ./pkg/... > old.txt
git stash pop && go test -run '^$' -bench 'Parse' -benchmem -count 10 ./pkg/... > new.txt
benchstat old.txt new.txt
JMH
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS)
@Fork(3) @Warmup(iterations = 5, time = 1) @Measurement(iterations = 10, time = 1)
public class ParseBench {
@Param({"100", "10000"}) int size;
String payload;
@Setup public void setup() { payload = Fixtures.orders(size); }
@Benchmark public Order parse() { return Parser.parse(payload); } // returned -> not eliminated
}
Run with java -jar target/benchmarks.jar ParseBench -rf json -rff results.json; add
-prof gc for allocation rates. Never set @Fork(0) for real measurements.
pytest-benchmark and pyperf
def test_parse(benchmark, orders_payload):
result = benchmark(parse, orders_payload)
assert result.total > 0
pytest tests/bench --benchmark-only --benchmark-autosave # saves .benchmarks/...
pytest tests/bench --benchmark-only --benchmark-compare --benchmark-compare-fail=median:5%
pytest tests/bench --benchmark-only --benchmark-json=new.json # raw samples for compare_bench.py
python3 -m pyperf timeit -s 'from app import parse, P' 'parse(P)' -o new.json
python3 -m pyperf compare_to old.json new.json --table
Keep benchmarks out of the normal test run (--benchmark-skip in default addopts).
Rust (Criterion)
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn bench_parse(c: &mut Criterion) {
let input = std::fs::read_to_string("benches/orders.json").unwrap();
c.bench_function("parse orders", |b| b.iter(|| parse(black_box(&input))));
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);
Cargo.toml: [[bench]] name = "parse" harness = false. Compare branches with
cargo bench -- --save-baseline main on main, then cargo bench -- --baseline main on the
branch. divan is lighter (#[divan::bench(args = [...])], divan::main()); use it when
compile time and simple syntax matter more than Criterion’s reports.
Vitest
Vitest 5 benchmarks live in *.bench.ts files and use the bench fixture:
import { test, expect } from "vitest";
test("parser comparison", async ({ bench }) => {
const result = await bench.compare(
bench("JSON.parse", () => JSON.parse(payload)),
bench("customParse", () => customParse(payload)),
);
expect(result.get("customParse")).toBeFasterThan(result.get("JSON.parse"));
});
Run with vitest bench. On Vitest 4 or earlier, the API is import { bench, describe } from "vitest"
with vitest bench; check vitest --version before writing either form. Standalone:
tinybench new Bench({ time: 500 }), .add(name, fn), await bench.run(), console.table(bench.table()).
CI regression gates
| Approach | Signal stability | Use when |
|---|---|---|
| Instruction counts (Cachegrind-based: Gungraun for Rust, CodSpeed’s simulation mode) | High; independent of runner noise | Gating PRs on shared CI runners |
| Wall clock, base and head in the same job, interleaved | Medium | Self-hosted or dedicated runners |
Wall clock against stored history (benchmark-action/github-action-benchmark) | Low on shared runners | Trend dashboards; alert, do not block |
Rules:
- Set the threshold from A/A runs on the same runner type: at least 2 to 3 times the observed spread. Wall-clock noise on shared hosted runners is often larger than the regressions you care about; measure yours rather than assuming.
github-action-benchmarkdefaultsalert-thresholdto200%; lower it only as far as your measured noise allows, and setfail-on-alertonly for stable signals.- Run benchmarks on PRs that touch hot paths (path filters) and nightly on main.
- A failing gate posts the comparison table and the rerun command, so a human can confirm before blocking a merge.
Gotchas
- Benchmarking the cache: the same input every iteration hits warm CPU caches and memoization; real calls do not. Rotate inputs from a pool.
- Setup inside the timed loop (JSON fixture load, regex compile, DB connect).
- Comparing a debug build to a release build, or JIT-cold to JIT-warm runs.
- GC pauses landing in some samples: report allocations (
-benchmem,-prof gc) next to time; fewer allocations often matter more than the time delta. - Micro win, macro loss: a faster function that allocates more can slow the service. Confirm important wins with a macro benchmark or a load test.
- Thermal throttling on laptops in long runs makes later samples slower; interleave A and B.
- Mean of 3 runs reported as “12% faster” with no spread.
Verification
- Sanity: the benchmark result scales as expected with input size; a deliberately slowed version (add a sleep or extra loop) is detected as a regression.
- A/A comparison shows no significant difference.
- The A/B comparison meets the decision rules above, with sample counts stated.
- For CI gates, a synthetic regression commit trips the gate and a no-op commit does not.
Output contract
## Benchmark: <question>
- Harness: <tool + version>; machine: <CPU, OS, governor/power>; build: <flags>
- Inputs: <sizes/fixtures>
- Samples: <n> per side; A/A spread: <x>%
| Benchmark | Base median | New median | Change | 95% CI | p | Verdict |
|---|---|---|---|---|---|---|
- Allocations: <B/op, allocs/op before -> after>
- Conclusion: <significant improvement of x% | no detectable change | regression>; confirmed by <profile/macro run>
- Reproduce: <exact commands> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/compare_bench.py
#!/usr/bin/env python3
"""Compare two sets of benchmark samples and decide: regression, improvement, or noise.
Usage:
python3 scripts/compare_bench.py BASE NEW [--threshold 5] [--alpha 0.01]
[--format auto|lines|json|pytest|go]
[--higher-is-better] [--bench REGEX]
Inputs (BASE and NEW use the same format):
lines one number per line (blank lines and # comments ignored)
json a JSON list of numbers, or an object {"bench name": [numbers, ...]}
pytest pytest-benchmark output from --benchmark-json (uses stats.data per benchmark)
go `go test -bench . -count 10` text output (uses ns/op per line)
auto picks one of the above from the file contents (default)
Method: medians, Mann-Whitney U test (normal approximation with tie correction),
and a seeded bootstrap 95% confidence interval for the ratio of medians.
A benchmark is a regression only when the difference is statistically significant
(p < alpha) AND the median moved by more than --threshold percent in the bad direction.
Exit codes: 0 = no regression, 1 = at least one regression, 2 = bad input.
"""
import argparse
import json
import math
import random
import re
import statistics
import sys
GO_LINE = re.compile(r"^(Benchmark\S+)\s+\d+\s+([0-9.]+(?:e[+-]?\d+)?)\s+ns/op")
def fail(msg):
print(f"error: {msg}", file=sys.stderr)
sys.exit(2)
def read_text(path):
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError as exc:
fail(f"cannot read {path}: {exc}")
def parse_lines(text, path):
vals = []
for i, line in enumerate(text.splitlines(), 1):
line = line.split("#", 1)[0].strip()
if not line:
continue
try:
vals.append(float(line))
except ValueError:
fail(f"{path}:{i}: not a number: {line!r}")
return {"samples": vals}
def parse_json(data, path):
if isinstance(data, list):
return {"samples": [float(x) for x in data]}
if isinstance(data, dict) and "benchmarks" in data:
out = {}
for b in data["benchmarks"]:
name = b.get("fullname") or b.get("name")
samples = (b.get("stats") or {}).get("data")
if not samples:
fail(f"{path}: benchmark {name!r} has no stats.data; rerun with --benchmark-json")
out[name] = [float(x) for x in samples]
return out
if isinstance(data, dict):
try:
return {k: [float(x) for x in v] for k, v in data.items()}
except (TypeError, ValueError):
fail(f"{path}: expected an object of name -> list of numbers")
fail(f"{path}: unsupported JSON shape")
def parse_go(text, path):
out = {}
for line in text.splitlines():
m = GO_LINE.match(line.strip())
if m:
out.setdefault(m.group(1), []).append(float(m.group(2)))
if not out:
fail(f"{path}: no 'BenchmarkX N T ns/op' lines found")
return out
def load(path, fmt):
text = read_text(path)
if fmt == "auto":
stripped = text.lstrip()
if stripped.startswith(("[", "{")):
fmt = "json"
elif any(GO_LINE.match(l.strip()) for l in text.splitlines()):
fmt = "go"
else:
fmt = "lines"
if fmt in ("json", "pytest"):
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
fail(f"{path}: invalid JSON: {exc}")
return parse_json(data, path)
if fmt == "go":
return parse_go(text, path)
return parse_lines(text, path)
def phi(z):
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
def mann_whitney_p(a, b):
n1, n2 = len(a), len(b)
combined = sorted([(v, 0) for v in a] + [(v, 1) for v in b])
ranks = [0.0] * len(combined)
tie_term = 0.0
i = 0
while i < len(combined):
j = i
while j + 1 < len(combined) and combined[j + 1][0] == combined[i][0]:
j += 1
avg = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[k] = avg
t = j - i + 1
tie_term += t ** 3 - t
i = j + 1
r1 = sum(r for r, (_, g) in zip(ranks, combined) if g == 0)
u1 = r1 - n1 * (n1 + 1) / 2.0
mu = n1 * n2 / 2.0
n = n1 + n2
var = n1 * n2 / 12.0 * ((n + 1) - tie_term / (n * (n - 1)))
if var <= 0:
return 1.0
diff = u1 - mu
cc = 0.5 if diff > 0 else (-0.5 if diff < 0 else 0.0)
z = (diff - cc) / math.sqrt(var)
return max(0.0, min(1.0, 2.0 * (1.0 - phi(abs(z)))))
def bootstrap_ratio_ci(a, b, iters=2000, seed=0):
rng = random.Random(seed)
ratios = []
for _ in range(iters):
ma = statistics.median(rng.choices(a, k=len(a)))
mb = statistics.median(rng.choices(b, k=len(b)))
if ma != 0:
ratios.append(mb / ma)
ratios.sort()
if not ratios:
return (float("nan"), float("nan"))
return ratios[int(0.025 * len(ratios))], ratios[int(0.975 * len(ratios)) - 1]
def rel_spread(xs):
med = statistics.median(xs)
mad = statistics.median([abs(x - med) for x in xs])
return 100.0 * 1.4826 * mad / med if med else float("inf")
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("base")
ap.add_argument("new")
ap.add_argument("--threshold", type=float, default=5.0, help="minimum change in percent that counts (default 5)")
ap.add_argument("--alpha", type=float, default=0.01, help="significance level (default 0.01)")
ap.add_argument("--format", default="auto", choices=["auto", "lines", "json", "pytest", "go"])
ap.add_argument("--higher-is-better", action="store_true", help="samples are throughput, not time")
ap.add_argument("--bench", default=None, help="only compare benchmark names matching this regex")
args = ap.parse_args()
base, new = load(args.base, args.format), load(args.new, args.format)
names = [n for n in base if n in new]
if args.bench:
names = [n for n in names if re.search(args.bench, n)]
if not names:
fail("no benchmark names in common between the two inputs")
regressions = 0
print(f"{'benchmark':40} {'base med':>12} {'new med':>12} {'change':>8} {'95% CI':>17} {'p':>7} {'spread':>7} verdict")
for name in names:
a, b = base[name], new[name]
if len(a) < 2 or len(b) < 2:
fail(f"{name}: need at least 2 samples per side (10+ recommended)")
ma, mb = statistics.median(a), statistics.median(b)
change = 100.0 * (mb - ma) / ma if ma else float("inf")
lo, hi = bootstrap_ratio_ci(a, b)
p = mann_whitney_p(a, b)
spread = max(rel_spread(a), rel_spread(b))
worse = change < 0 if args.higher_is_better else change > 0
if p < args.alpha and abs(change) > args.threshold:
verdict = "REGRESSION" if worse else "improvement"
elif p < args.alpha:
verdict = "significant, below threshold"
else:
verdict = "no significant change"
notes = []
if min(len(a), len(b)) < 10:
notes.append(f"n={min(len(a), len(b))}<10")
if spread > args.threshold / 2:
notes.append("noisy: spread exceeds half the threshold")
if verdict == "REGRESSION":
regressions += 1
label = name if len(name) <= 40 else name[:37] + "..."
ci = f"[{(lo - 1) * 100:+.1f},{(hi - 1) * 100:+.1f}]%"
print(f"{label:40} {ma:12.4g} {mb:12.4g} {change:+7.1f}% {ci:>17} {p:7.4f} {spread:6.1f}% {verdict}"
+ (f" ({'; '.join(notes)})" if notes else ""))
print(f"\n{len(names)} compared, {regressions} regression(s) beyond {args.threshold}% at alpha={args.alpha}")
sys.exit(1 if regressions else 0)
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.