Performance and Benchmarking
"Blazing fast" in a README is worth nothing. A reproducible benchmark with a stated
methodology is worth a great deal — and a benchmark that gets caught being unfair
costs more than never publishing one.
Profile before you optimize
Non-negotiable order: measure → find the actual hot spot → change one thing →
measure again. Programmer intuition about hot paths is wrong most of the time, and
optimizing cold code adds complexity for zero benefit.
# Sampling profilers — start here
py-spy record -o profile.svg -- python app.py # Python, no code changes
node --cpu-prof app.js # Node
cargo flamegraph --bench my_bench # Rust
go test -cpuprofile cpu.out -bench . && go tool pprof -http=: cpu.out
perf record -g ./binary && perf report # anything on Linux
Read the flame graph for width, not height. Deep stacks are normal; wide frames
are where time goes. And check allocation profiles too — in managed languages, GC
pressure is frequently the real answer and never appears as an obvious hot function.
Benchmark methodology
A benchmark nobody can reproduce is marketing. Requirements:
- Warm up. JIT compilation, caches, connection pools. Discard the first N runs.
- Repeat and report distribution. Minimum, median, p95, p99 — and the shape.
A single number hides bimodality, and the mean is the least useful statistic on a
long-tailed distribution.
- Report variance. ±2% and ±40% are completely different claims.
- Control the environment. Pin CPU frequency where possible, disable turbo, close
everything else, use a quiet machine. Never benchmark on a shared CI runner and
present the numbers as authoritative.
- Publish the hardware and versions. CPU model, RAM, OS, kernel, runtime version,
library versions, dataset. Without these the number is meaningless.
- Realistic workload. Benchmarking a parser on a 12-byte input measures function
call overhead, not parsing.
- Verify correctness in the benchmark. A fast wrong answer is easy. Assert on the
output — this also prevents dead-code elimination silently removing your workload.
Use a harness that handles the statistics for you rather than hand-rolled timing loops:
hyperfine --warmup 3 --runs 50 'old-bin input.json' 'new-bin input.json' # CLI tools
cargo bench # criterion: statistics + regression detection
pytest-benchmark / asv # Python
node --expose-gc + tinybench / mitata
go test -bench . -benchmem -count=10 | benchstat # benchstat gives significance
benchstat and criterion do the thing most hand-written benchmarks skip: telling you
whether the difference is statistically significant at all.
Microbenchmark traps
Microbenchmarks lie more often than they inform.
- Dead code elimination. The compiler notices you discard the result and removes
the work. Use
std::hint::black_box, benchmark.Keep, or assert on the output.
- Constant folding. Benchmarking with a literal input computes at compile time.
- Branch predictor training. The same input 10,000 times is unrealistically
predictable; real workloads are not.
- Everything in L1 cache. A 1 KB dataset benchmark tells you nothing about the
100 MB case, and cache behavior is often the entire story.
- Measuring the harness. If the operation takes 40 ns and your loop overhead is
30 ns, you are benchmarking the loop.
- Alignment and layout luck. Unrelated code changes can shift performance several
percent by moving code across cache lines. Treat sub-5% microbenchmark deltas as
noise unless you can explain the mechanism.
When in doubt, benchmark at a level users can perceive: a whole request, a whole file
parse, a whole build.
Comparing against competitors
The highest-risk content you can publish. Do it fairly or not at all — the maintainers
of the project you benchmarked will read it, and being publicly corrected on an
unfair benchmark is a reputational event that outlives the blog post.
Rules:
- Use their recommended configuration. Benchmarking a competitor in debug mode,
without their cache enabled, or with default settings they explicitly tell users to
change, is the single most common form of benchmark dishonesty.
- Ask them to review it. Open an issue on their repo with your methodology before
publishing. This is unusual, cheap, and enormously credibility-enhancing.
- Publish the full harness — scripts, datasets, versions, raw output — in a repo
anyone can run.
- Report where you lose. A benchmark table with no losses reads as fabricated and
is treated as such by exactly the readers you are trying to convince.
- Compare like for like. If you skip a feature they provide (validation, retries,
cross-platform support), say so. The honest framing is "X% faster, and here is the
tradeoff", not a bare number.
- Date and version everything. They will ship a fix; your table will not.
State the workload in the claim itself: not "3× faster" but "parses a 50 MB NDJSON
file 3.1× faster than X v4.2 on an M3 Pro (median of 50 runs, ±3%)."
Regression detection in CI
Continuous benchmarking is valuable, but shared CI runners have 10–30% variance —
naive thresholds produce constant false alarms and get muted within a month.
Approaches that survive contact with reality:
- Track counters, not wall time. Instruction counts, allocations, syscalls, bytes
allocated. These are nearly deterministic on shared hardware.
cachegrind /
iai-callgrind / valgrind --tool=callgrind make this practical, and a 2% change
in instruction count is real signal.
- Compare within the same job, base vs PR, on the same runner, interleaved. Never
compare against a number recorded on a different machine last week.
- Alert on trend, not on a single run. Publish to a dashboard
(
github-action-benchmark) and investigate sustained slopes.
- Gate only on large regressions (>10%), and always allow an override label. A
performance gate that blocks a correctness fix is a broken process.
- Run the real benchmark suite on dedicated hardware, nightly, not per-PR.
Publishing results
In the README, keep it to one line with a link. In docs/benchmarks.md, give the full
picture: methodology, hardware, versions, dataset, raw numbers, the harness link, and
the caveats. Include a "when this project is slower" section — it is the most credible
paragraph you will write.
Re-run before every major release. A benchmark from three versions ago is a claim you
are no longer making truthfully.
Reviewing someone else's benchmark
Questions that expose most bad benchmarks in under a minute:
- What hardware, what versions, what dataset size?
- How many runs, and what is the variance?
- Is the harness public and runnable?
- Was the competitor configured as its own docs recommend?
- Is the workload representative, or chosen to favor one implementation?
- Does the benchmark verify correctness of the output?
- Who paid for it, and does the conclusion match their interest?
If a benchmark cannot answer 1–3, it is not evidence. Say so plainly and without
hostility.
Anti-patterns
- "Blazing fast" with no number.
- A number with no methodology.
- Benchmarking on a laptop on battery, or on a shared CI runner, and presenting it
as authoritative.
- Comparing your optimized build to their debug build.
- Cherry-picking the one workload where you win.
- Optimizing without profiling. You will make the code worse and no faster.
- Trading correctness or safety for speed silently. If you skip validation to win
a benchmark, that belongs in the claim.
- Never re-running published benchmarks. They become false over time on their own.
1---2name: performance-benchmarking3description: Build credible benchmarks and make defensible performance claims. Use when a project claims to be fast, when adding a benchmark suite, when comparing against competing projects, when setting up performance regression detection in CI, or when the user wants to put a speed number in the README. Covers measurement methodology, statistical rigor, fair competitor comparisons, profiling before optimizing, microbenchmark pitfalls, and publishing results honestly. Also use when reviewing someone else's benchmark claims.4---56# Performance and Benchmarking78"Blazing fast" in a README is worth nothing. A reproducible benchmark with a stated9methodology is worth a great deal — and a benchmark that gets caught being unfair10costs more than never publishing one.1112## Profile before you optimize1314Non-negotiable order: **measure → find the actual hot spot → change one thing →15measure again.** Programmer intuition about hot paths is wrong most of the time, and16optimizing cold code adds complexity for zero benefit.1718```bash19# Sampling profilers — start here20py-spy record -o profile.svg -- python app.py # Python, no code changes21node --cpu-prof app.js # Node22cargo flamegraph --bench my_bench # Rust23go test -cpuprofile cpu.out -bench . && go tool pprof -http=: cpu.out24perf record -g ./binary && perf report # anything on Linux25```2627Read the flame graph for **width, not height**. Deep stacks are normal; wide frames28are where time goes. And check allocation profiles too — in managed languages, GC29pressure is frequently the real answer and never appears as an obvious hot function.3031## Benchmark methodology3233A benchmark nobody can reproduce is marketing. Requirements:34351. **Warm up.** JIT compilation, caches, connection pools. Discard the first N runs.362. **Repeat and report distribution.** Minimum, median, p95, p99 — and the shape.37 A single number hides bimodality, and the mean is the least useful statistic on a38 long-tailed distribution.393. **Report variance.** ±2% and ±40% are completely different claims.404. **Control the environment.** Pin CPU frequency where possible, disable turbo, close41 everything else, use a quiet machine. Never benchmark on a shared CI runner and42 present the numbers as authoritative.435. **Publish the hardware and versions.** CPU model, RAM, OS, kernel, runtime version,44 library versions, dataset. Without these the number is meaningless.456. **Realistic workload.** Benchmarking a parser on a 12-byte input measures function46 call overhead, not parsing.477. **Verify correctness in the benchmark.** A fast wrong answer is easy. Assert on the48 output — this also prevents dead-code elimination silently removing your workload.4950Use a harness that handles the statistics for you rather than hand-rolled timing loops:5152```bash53hyperfine --warmup 3 --runs 50 'old-bin input.json' 'new-bin input.json' # CLI tools54cargo bench # criterion: statistics + regression detection55pytest-benchmark / asv # Python56node --expose-gc + tinybench / mitata57go test -bench . -benchmem -count=10 | benchstat # benchstat gives significance58```5960`benchstat` and criterion do the thing most hand-written benchmarks skip: telling you61whether the difference is statistically significant at all.6263## Microbenchmark traps6465Microbenchmarks lie more often than they inform.6667- **Dead code elimination.** The compiler notices you discard the result and removes68 the work. Use `std::hint::black_box`, `benchmark.Keep`, or assert on the output.69- **Constant folding.** Benchmarking with a literal input computes at compile time.70- **Branch predictor training.** The same input 10,000 times is unrealistically71 predictable; real workloads are not.72- **Everything in L1 cache.** A 1 KB dataset benchmark tells you nothing about the73 100 MB case, and cache behavior is often the entire story.74- **Measuring the harness.** If the operation takes 40 ns and your loop overhead is75 30 ns, you are benchmarking the loop.76- **Alignment and layout luck.** Unrelated code changes can shift performance several77 percent by moving code across cache lines. Treat sub-5% microbenchmark deltas as78 noise unless you can explain the mechanism.7980When in doubt, benchmark at a level users can perceive: a whole request, a whole file81parse, a whole build.8283## Comparing against competitors8485The highest-risk content you can publish. Do it fairly or not at all — the maintainers86of the project you benchmarked *will* read it, and being publicly corrected on an87unfair benchmark is a reputational event that outlives the blog post.8889Rules:9091- **Use their recommended configuration.** Benchmarking a competitor in debug mode,92 without their cache enabled, or with default settings they explicitly tell users to93 change, is the single most common form of benchmark dishonesty.94- **Ask them to review it.** Open an issue on their repo with your methodology before95 publishing. This is unusual, cheap, and enormously credibility-enhancing.96- **Publish the full harness** — scripts, datasets, versions, raw output — in a repo97 anyone can run.98- **Report where you lose.** A benchmark table with no losses reads as fabricated and99 is treated as such by exactly the readers you are trying to convince.100- **Compare like for like.** If you skip a feature they provide (validation, retries,101 cross-platform support), say so. The honest framing is "X% faster, and here is the102 tradeoff", not a bare number.103- **Date and version everything.** They will ship a fix; your table will not.104105State the workload in the claim itself: not "3× faster" but "parses a 50 MB NDJSON106file 3.1× faster than X v4.2 on an M3 Pro (median of 50 runs, ±3%)."107108## Regression detection in CI109110Continuous benchmarking is valuable, but shared CI runners have 10–30% variance —111naive thresholds produce constant false alarms and get muted within a month.112113Approaches that survive contact with reality:114115- **Track counters, not wall time.** Instruction counts, allocations, syscalls, bytes116 allocated. These are nearly deterministic on shared hardware. `cachegrind` /117 `iai-callgrind` / `valgrind --tool=callgrind` make this practical, and a 2% change118 in instruction count is real signal.119- **Compare within the same job**, base vs PR, on the same runner, interleaved. Never120 compare against a number recorded on a different machine last week.121- **Alert on trend, not on a single run.** Publish to a dashboard122 (`github-action-benchmark`) and investigate sustained slopes.123- **Gate only on large regressions** (>10%), and always allow an override label. A124 performance gate that blocks a correctness fix is a broken process.125- **Run the real benchmark suite on dedicated hardware**, nightly, not per-PR.126127## Publishing results128129In the README, keep it to one line with a link. In `docs/benchmarks.md`, give the full130picture: methodology, hardware, versions, dataset, raw numbers, the harness link, and131the caveats. Include a "when this project is slower" section — it is the most credible132paragraph you will write.133134Re-run before every major release. A benchmark from three versions ago is a claim you135are no longer making truthfully.136137## Reviewing someone else's benchmark138139Questions that expose most bad benchmarks in under a minute:1401411. What hardware, what versions, what dataset size?1422. How many runs, and what is the variance?1433. Is the harness public and runnable?1444. Was the competitor configured as its own docs recommend?1455. Is the workload representative, or chosen to favor one implementation?1466. Does the benchmark verify correctness of the output?1477. Who paid for it, and does the conclusion match their interest?148149If a benchmark cannot answer 1–3, it is not evidence. Say so plainly and without150hostility.151152## Anti-patterns153154- **"Blazing fast" with no number.**155- **A number with no methodology.**156- **Benchmarking on a laptop on battery**, or on a shared CI runner, and presenting it157 as authoritative.158- **Comparing your optimized build to their debug build.**159- **Cherry-picking the one workload where you win.**160- **Optimizing without profiling.** You will make the code worse and no faster.161- **Trading correctness or safety for speed silently.** If you skip validation to win162 a benchmark, that belongs in the claim.163- **Never re-running published benchmarks.** They become false over time on their own.