Benchmarking discipline, and what microbenchmarks lie about
What it is
A benchmark is an experiment, and benchmarking discipline is the set of habits that keep the experiment honest: measuring the thing you actually care about, under conditions that resemble production, with enough repetitions to separate signal from noise, and reporting the distribution rather than a single number.
The thing it is confused with is profiling. A profile tells you where time goes inside one execution; a benchmark tells you how long the execution takes and whether one version is faster than another. They answer different questions and fail in different ways. A profile can be perfectly accurate and still point you at code that does not matter, because the profile has no notion of "how often does this path run in production." A benchmark can show a real 30 percent improvement that evaporates on deploy, because the benchmark measured a warm, single-threaded, cache-resident version of a system that in production is cold, concurrent and memory-bound.
The second confusion is benchmark versus load test. A microbenchmark measures one function or one operation in isolation, usually in nanoseconds to microseconds. A load test drives the whole system at a target request rate and measures the response distribution, usually in milliseconds. Most of the disappointing "it was faster in the benchmark" stories come from using the first where the second was required.
The problem it solves
Without discipline, benchmarks produce confident numbers that are wrong, which is strictly worse than no numbers, because a wrong number ends the discussion. The specific failures are mechanical and repeat across languages and teams:
Dead code elimination. You benchmark a pure function whose result you discard. A modern JIT or optimising compiler proves the result is unused, deletes the call, and reports an operation that takes 0.3 nanoseconds. That is roughly one CPU cycle, which is a good hint you measured nothing.
Constant folding. You benchmark hash(input) with input a compile-time
constant. The compiler computes the hash once and the loop measures a load from a
register.
Warmup and the JIT. On the JVM, a method runs interpreted first, gets profiled, then gets compiled at tier 4 with inlining and loop optimisations informed by the profile it collected. Measurements taken before the transition are measuring the interpreter. The same effect exists in different form for Python (no JIT in CPython, but import and first-call costs dominate short runs), Go (no warmup, but the first allocations grow the heap), and any system with a cache.
Profile pollution. This one is subtle and specific to the JVM. If the same generic method is called from your benchmark with two different concrete types, the JIT sees a bimorphic call site and refuses to inline it. Your benchmark now measures a slower version than production, where the call site is monomorphic. The reverse also happens: your benchmark is monomorphic and production is not.
Coordinated omission. The most damaging one, because it silently deletes exactly the measurements you care about. If your load generator sends a request, waits for the response, and only then sends the next one, then during a 2-second stall it sends no requests at all. The requests that would have been slow were never issued, so they never appear in the histogram. The result is a latency distribution that looks fine while the system is visibly stalling.
Mechanics
The fixes are individually simple and mostly consist of removing the compiler's ability to cheat and the harness's ability to lie.
Consume your results. JMH provides a Blackhole; Go has runtime.KeepAlive
and the convention of assigning to a package-level variable. The idea is the same:
make the result observable so it cannot be optimised away.
// JMH: the return value is implicitly consumed by the harness.
@Benchmark
public long hashOne(BenchState s) {
return s.hasher.hash(s.key); // returned, so not dead
}
// When you produce several values, use the Blackhole explicitly.
@Benchmark
public void hashMany(BenchState s, Blackhole bh) {
for (String k : s.keys) {
bh.consume(s.hasher.hash(k));
}
}
@State(Scope.Benchmark)
public class BenchState {
// Not final, not a literal: the JIT cannot fold it.
public Hasher hasher;
public String key;
public String[] keys;
@Setup(Level.Trial)
public void setup() {
hasher = new Murmur3Hasher();
key = "user:" + ThreadLocalRandom.current().nextInt();
keys = IntStream.range(0, 1024)
.mapToObj(i -> "user:" + i)
.toArray(String[]::new);
}
}
Warm up, then measure. The JMH defaults are 5 warmup iterations and 5 measurement iterations, each 10 seconds, in a forked JVM. Forking matters: it isolates each benchmark from profile pollution caused by benchmarks that ran before it in the same JVM.
@Warmup(iterations = 5, time = 10, timeUnit = SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = SECONDS)
@Fork(value = 3) // 3 JVMs; variance across forks is real signal
@BenchmarkMode(Mode.SampleTime) // gives you a distribution, not just a mean
@OutputTimeUnit(TimeUnit.MICROSECONDS)
Mode.SampleTime deserves emphasis. Mode.AverageTime reports one number, and a
mean is nearly useless for latency because the distribution is right-skewed. Sample
mode gives percentiles.
Fix coordinated omission by using an open model. An open-model load generator
issues requests on a schedule that does not depend on when responses arrive. If
the target is 1,000 requests per second, it issues one every millisecond whether
or not the previous one came back. wrk2, Gatling and k6 (with arrival-rate
executors) work this way; ab and naive closed loops do not.
The correction, if you are stuck with a closed-loop harness, is to record the intended start time rather than the actual one:
# Open-model timing: schedule is fixed in advance.
start = time.perf_counter()
interval = 1.0 / target_rps
for i in range(n):
intended = start + i * interval
now = time.perf_counter()
if now < intended:
time.sleep(intended - now)
# If now > intended we are behind: do NOT skip, and measure from `intended`.
t0 = intended # not time.perf_counter()
send_request()
record(time.perf_counter() - t0) # includes the queueing we caused
The difference between t0 = intended and t0 = now is the entire coordinated
omission correction. It is two lines and it routinely changes a reported p99 by an
order of magnitude.
A worked example: a 4x speedup that was 1.06x
A team replaced a JSON serialiser in a service's response path. The JMH benchmark was clean by most standards: warmed up, forked, results consumed, realistic payloads.
Benchmark Mode Cnt Score Error Units
SerBench.jackson avgt 15 14.213 ± 0.402 us/op
SerBench.newSerialiser avgt 15 3.556 ± 0.111 us/op
Four times faster, tight error bars, reproducible. They shipped it behind a flag and measured the service:
p50 p95 p99
before 18.4ms 61.2ms 142ms
after 17.9ms 59.8ms 139ms
A 2.7 percent improvement at p50. The benchmark was not wrong about serialisation; it was wrong about the share. Serialisation was 14 microseconds out of an 18,400 microsecond request. Amdahl's law caps the achievable speedup at
$$S = \frac{1}{(1-p) + p/s} = \frac{1}{(1 - 0.00077) + 0.00077/4} = 1.0006$$
The measured 2.7 percent was mostly allocation pressure reduction, which the microbenchmark had not measured at all. The lesson is not that the benchmark lied about its own scope. It is that the benchmark answered a question nobody had asked: nobody had first measured what fraction of the request serialisation represented. Five minutes with a profiler would have killed the project before it started, and the correct order is always profile first, then benchmark the thing the profile identified.
There is a second half to this story worth carrying into interviews. The same team later found that the p99 of 142ms was dominated by a synchronous call to an authorisation service. Fixing that took the p99 to 71ms. The lever was never in the code that was easiest to benchmark.
Production evidence
JMH exists because Sun and later Oracle engineers could not trust JVM
microbenchmarks, including their own. Aleksey Shipilev's talk "JMH vs. the
Benchmarking Traps" walks through dead-code elimination, constant folding and loop
unrolling with concrete disassembly, showing benchmarks that report timings for
code the JIT deleted. JMH's design (forking, blackholes, @State objects,
-prof perfasm to dump the compiled assembly) is a direct response to specific
observed failures rather than general caution.
Coordinated omission was named and popularised by Gil Tene (Azul Systems) in
"How NOT to Measure Latency," where he showed standard load-testing tools
reporting healthy percentiles while the system under test was pausing for seconds.
HdrHistogram, his library, includes recordValueWithExpectedInterval()
specifically to synthesise the missing samples when a closed-loop harness is
unavoidable. wrk2 was written by Tene as a corrected fork of wrk for the same
reason.
Go's testing package hard-codes some of this discipline. go test -bench
chooses b.N adaptively so each benchmark runs for a minimum duration rather than
a fixed iteration count, and benchstat (from golang.org/x/perf) reports
geometric means with confidence intervals across repeated runs, refusing to
declare a difference that is not statistically distinguishable. -benchmem is on
by default in most Go teams' habits because allocation count is more stable than
wall time.
Databases publish benchmark reproduction kits for the same reason. ClickHouse's public benchmark suite ships the dataset, the queries and the hardware specification, because the community's experience was that unqualified "X is faster than Y" numbers were unreproducible often enough to be worthless.
The debate
The strongest argument against microbenchmarking discipline is cost. A properly forked JMH suite with 3 forks and 5+5 iterations takes minutes per benchmark, and a suite of 40 benchmarks becomes a nightly job rather than something you run before a commit. Teams respond by cutting warmup, dropping to one fork, or running in-process, and each cut reintroduces a specific failure mode.
The counter-argument, and my position, is that the choice is not between an expensive benchmark and a cheap one, it is between an expensive benchmark and a misleading one. A benchmark you do not trust has negative value: it will be cited in a design review by someone who was not in the room when it was run.
Where I would genuinely skip the discipline: when you are looking for an order of magnitude, not a percentage. If the question is "is this 10 microseconds or 10 milliseconds," a sloppy loop answers it correctly and the traps do not matter at that resolution. The traps matter when you are comparing two implementations that are within 2x of each other, which is exactly when teams most want an answer.
The position to hold as a staff engineer: microbenchmarks are for validating a hypothesis that a profile has already produced, and load tests are for validating that a change survives contact with concurrency, cold caches and real payload distributions. Reversing that order is the single most common way teams spend a quarter optimising something that does not matter. And any latency number reported without saying which percentile it is, and whether the generator was open or closed, should be treated as unmeasured.
Follow-up Q&A
"Your benchmark shows 3.5 microseconds and production shows 14. What are the suspects, in order?"
First, what else is running: the benchmark had a whole core, production shares one with 40 other threads, so you are paying context switches and cache eviction. Second, cache residency: the benchmark's working set was 1,024 keys resident in L2, production's is millions and every access is a memory reference at roughly 100 nanoseconds. Third, monomorphic versus polymorphic call sites: the benchmark had one implementation, production has three behind an interface, so the JIT stopped inlining. Fourth, allocation and GC: the benchmark's garbage fit in the young gen and never got promoted. Fifth, input distribution: benchmark inputs are usually uniform and production's are Zipfian, which changes branch prediction and cache hit rates. I would attack them in that order because that is roughly the order of expected magnitude.
"How many runs do you need?"
Enough for the confidence interval to be narrower than the effect you are claiming. Concretely: if you claim a 5 percent improvement and your run-to-run standard deviation is 4 percent, you need enough samples that the standard error is well under 5 percent, which for a 4 percent SD is roughly 10 or more independent runs. The practical rule I use is that the error bars must not overlap, and if they do, the honest report is "no measurable difference," not "slightly faster." Run the two variants interleaved rather than all of A then all of B, because machine state drifts (thermal throttling, background jobs, page cache) and interleaving turns a systematic bias into noise.
"What is coordinated omission, and how do you tell if you have it?"
It is the systematic loss of the slowest measurements because the load generator
stops issuing requests while it waits. The tell is that your reported latency
distribution is much narrower than your throughput dips would imply. Concretely:
if throughput dropped to zero for 2 seconds and your p99.9 is 40 milliseconds,
those numbers are inconsistent, because during the stall there must have been
requests that would have taken up to 2 seconds. The fix is an open-model generator
(wrk2, Gatling, k6 with constant-arrival-rate) or HdrHistogram's
recordValueWithExpectedInterval.
"Why fork the JVM for each benchmark?"
Because the JIT's decisions are made from profile data collected across the whole
process lifetime. If benchmark A runs process(String) and benchmark B runs
process(Integer) in the same JVM, the call site inside the shared helper becomes
bimorphic and neither gets inlined. Whichever runs second (or both) reports a
slower number for reasons that have nothing to do with the code under test. Forking
gives each benchmark a fresh JVM. Running multiple forks additionally exposes
run-to-run variance from things like different heap layouts and different
tiered-compilation decisions, which is real variance that a single fork hides.
"When would you not benchmark at all, and just ship it?"
When the change is obviously correct and the risk of the change is lower than the cost of measuring it. Removing an N+1 query that fires 300 times per request does not need a benchmark, it needs a code review. Benchmarks are for the cases where your intuition could plausibly be wrong, and a large class of performance work (removing work entirely) is not in that class.
Common misconceptions
"The mean is the number." Latency distributions are right-skewed and often multi-modal (cache hit versus miss, JIT-compiled versus interpreted, GC pause or not). The mean sits in a valley between modes and describes no actual request. Two systems with identical means can have p99s that differ by 10x. Report percentiles, and be aware that percentiles do not average: you cannot take the mean of two shards' p99s and get the fleet p99. You need the merged histogram, which is why HdrHistogram and DDSketch support mergeable representations.
"I ran it twice and got the same number, so it's stable." Two consecutive runs in the same process share JIT state, page cache and thermal conditions. Stability across runs in the same process is nearly meaningless; stability across forks and across days is the thing to check.
"The benchmark is 4x faster, so the service will be 4x faster." This is Amdahl's law and it is worth doing the arithmetic explicitly before starting the work. See the worked example: a genuine 4x on 0.08 percent of the request is a 1.0006x speedup. Related: the Universal Scalability Law page covers the other direction, where adding capacity makes things worse.
"Production traffic is the best benchmark." It is the best validation, but it is a poor experiment: you cannot control the variables, you cannot repeat it, and the confounders (deploy time, traffic mix, a neighbouring service's incident) are unbounded. Shadow traffic gives you production's input distribution with experimental control, which is the actual best of both.
Interview delivery note
The line to say verbatim: "Before I benchmark anything, I want to know what fraction of the request the thing represents, because Amdahl's law caps the payoff and that arithmetic takes thirty seconds." It signals that you treat performance work as a budgeting problem rather than a craft exercise.
The senior-versus-staff separator on this topic is coordinated omission. A senior engineer will talk about warmup, JIT and consuming results, which is the standard microbenchmark checklist. A staff engineer notices that the load test harness deletes the slow requests, because that is the failure that survives all the way to a customer-visible incident while every dashboard says the service is healthy. If you get a chance to say "was the generator open or closed model," take it.
The second signal is willingness to report a null result. Saying "we measured it, the difference was inside the error bars, so we did not ship it" is a stronger answer than any speedup number, because it demonstrates you were running an experiment rather than building a case.
Further reading
- Aleksey Shipilev, "JMH vs. the Benchmarking Traps" and the JMH samples in the
OpenJDK repository (
jmh-samples), which are annotated failure demonstrations. - Gil Tene, "How NOT to Measure Latency" (Strange Loop), and the HdrHistogram
documentation on
recordValueWithExpectedInterval. - Brendan Gregg, Systems Performance, chapter on methodology (USE method, workload characterisation) for the profile-before-benchmark ordering.
- The Go
testingpackage documentation onb.Nandbenchstat's README on statistical significance in benchmark comparison.