Testing No. 126

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.

  • namebenchmark-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.

~/.claude/skills/benchmark-testing/SKILL.md

--- name: benchmark-testing description: Writes and runs micro and macro benchmarks that produce trustworthy numbers - JMH for Java, Go testing.B with b.Loop and benchstat,… license: MIT ---

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() or time.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, no NODE_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

LanguageMicro harnessCompare with
Java / JVMJMH (jmh-java-benchmark-archetype), run as java -jar target/benchmarks.jar, never from the IDEJMH JSON output (-rf json) plus compare_bench.py, or JMH’s error bars
Gotesting.B with for b.Loop() (Go 1.24+)benchstat old.txt new.txt
Pythonpytest-benchmark in test suites; pyperf for rigorous standalone runs--benchmark-compare, pyperf compare_to
RustCriterion 0.8 or divan; Gungraun (formerly iai-callgrind) for instruction countsCriterion baselines (--save-baseline, --baseline)
JS / TSVitest bench (fixture API in Vitest 5; top-level bench in Vitest 4 and earlier) or tinybenchVitest bench.compare, stored results via writeResult / bench.from
.NETBenchmarkDotNet, Release buildIts 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:
HarnessGuard
JMHReturn 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
Ruststd::hint::black_box(input) and return the output (criterion::black_box is deprecated)
JSUse 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
PythonLess 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 reset afterwards).
  • 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

ApproachSignal stabilityUse when
Instruction counts (Cachegrind-based: Gungraun for Rust, CodSpeed’s simulation mode)High; independent of runner noiseGating PRs on shared CI runners
Wall clock, base and head in the same job, interleavedMediumSelf-hosted or dedicated runners
Wall clock against stored history (benchmark-action/github-action-benchmark)Low on shared runnersTrend 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-benchmark defaults alert-threshold to 200%; lower it only as far as your measured noise allows, and set fail-on-alert only 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

  1. 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.
  2. A/A comparison shows no significant difference.
  3. The A/B comparison meets the decision rules above, with sample counts stated.
  4. 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 218 lines · 7.7 KB
#!/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.

esc