skip to content
everything.performance.measuringPerformance

measuring performance

averages lie. percentiles, and coordinated omission.

chapter
chapter 15.01
reading time
9 min read

latency isn't a number, it's a distribution, and the mean is the least useful thing you can say about it. measure a histogram and report percentiles: p50 for the typical case, p99 and p99.9 for the tail, and the max.

then check that the tool doing the measuring isn't lying: coordinated omission is when a load generator stops sending requests exactly while the system is slow, so the slowness never gets recorded.

the picture

your commute is "25 minutes on average". once a month there's an accident and it's two hours. when you have a flight to catch, which number do you plan around? the average doesn't even hint that the two-hour day exists. that's the whole problem with averages: they're dominated by the boring majority and they smear the rare disaster into a rounding error.

servers have accident days constantly. a GC pause, a cache miss storm, a lock convoy, a slow disk. each one is rare per request, and each one is exactly what a user remembers.

a real distribution

here's a tiny "request" (build a HashMap of 40 entries, walk it), called two million times in a row, each call timed on its own:

Latency.java
import java.util.*;
 
public class Latency {
    static long sink;
 
    static long handle(int id) {
        HashMap<String, Integer> m = new HashMap<>();
        for (int i = 0; i < 40; i++) m.put("item-" + (id + i), i);
        long sum = 0;
        for (Map.Entry<String, Integer> e : m.entrySet()) sum += e.getKey().hashCode() ^ e.getValue();
        return sum;
    }
 
    public static void main(String[] args) {
        int warm = 300_000, n = 2_000_000;
        for (int i = 0; i < warm; i++) sink += handle(i);   // let the JIT finish first
        long[] lat = new long[n];
        for (int i = 0; i < n; i++) {
            long t0 = System.nanoTime();
            sink += handle(i);
            lat[i] = System.nanoTime() - t0;
        }
        long[] s = lat.clone();
        Arrays.sort(s);
        System.out.printf("mean %.0f ns%n", Arrays.stream(lat).average().orElse(0));
        for (double p : new double[] {50, 90, 99, 99.9, 99.99})
            System.out.printf("p%s %d ns%n", p, s[(int) Math.ceil(p / 100 * n) - 1]);
        System.out.printf("max %d ns  (sink %d)%n", s[n - 1], sink & 1);
    }
}

no output shown because it changes every run. here's one run, with -Xmx64m, as a histogram:

1101001k10k100k512ns1µs2µs5µs10µs20µs50µs100µs200µsmean
x: latency, log scale. y: how many requests, log scale.

the average: 855 ns. it sits right next to the median, looks perfectly healthy, and tells you nothing about the right-hand half of this picture.

two million requests, jdk 25 on an apple M4 Pro. click the statistics. both axes are logarithmic, or the tail would be invisible. the red bump on the right is a couple of hundred requests that ran into a GC pause.

look at where the mean is: 855 ns, right next to the median. by the mean, this code is fine. and it mostly is. but one request in ten thousand took over 100 µs, and the slowest took 225 µs, and the mean has no way of telling you. those requests barely move it: the worst one shifted it by about a tenth of a nanosecond.

the bump is the garbage collector. in a rerun with -Xlog:gc (reading GC logs), G1 logged 244 young pauses of 0.1 to 0.4 ms while the loop ran, which is about how many requests landed out there. that rerun also had a p99.99 of 155 µs and a max of 2.3 ms. the tail of a distribution is noisy from run to run, which is one more reason to look at the whole shape instead of one number.

why the tail matters more than it looks

"p99" sounds like an edge case: one percent of requests, who cares. but a user doesn't make one request. loading one page in a typical app fans out into dozens of calls to backends, caches and databases, and the page is as slow as its slowest call.

calls per page =40
slow means =

33% of page loads include at least one call slower than its p99. the “1 in 100” tail is what a big chunk of your users see.

drag the number of calls. each square is one page load; red ones include at least one call slower than the chosen percentile. this is just 1 − 0.99n, assuming the calls are independent.

forty calls per page, and a third of all page loads include a p99 call. that's why big services set their targets on p99 and p99.9, and why "our average latency is 20 ms" is not a defence when users say the site feels slow.

quick check
three servers report their p99 latency: 10 ms, 12 ms and 50 ms, each handling the same traffic. what's the p99 across all three?

coordinated omission

now the subtle one. here's how most hand-written load tests work: send a request, wait for the response, record how long it took, wait for the next tick, repeat. it looks completely reasonable.

i wrote exactly that: a loop that wants one request every 10 ms for 5 seconds, against a "server" that answers in 1 ms, except that request #200 hits a one-second stall (think GC pause). here's what the loop recorded, next to what callers would actually have experienced:

text
requests the loop actually sent: 401 of 500
p50.0  naive     1000 us   corrected     1000 us
p90.0  naive     1000 us   corrected   551000 us
p99.0  naive     1001 us   corrected   956000 us
p99.9  naive  1008072 us   corrected  1001000 us
p100.0 naive  1008072 us   corrected  1001000 us

the naive loop's p99 is one millisecond. the honest p99 is almost a second. same system, same stall.

stall =
show =
1ms10ms100ms1sserver stalled0s1s2s3s4s5s
one line per intended request, height = latency (log scale). the 99 tiny ticks at the bottom were never sent, so never measured.
p50p90p99p99.9max
recorded (401)1 ms1 ms1 ms1.00 s1.00 s
experienced (500)1 ms551 ms956 ms1.00 s1.00 s

the loop saw one slow request and 400 fast ones. its p99 says 1 ms. it didn't send anything while it was stuck waiting, so the stall barely shows up in the stats.

switch between the two views and change the stall. the loop only measures requests it sends, and it can't send while it's stuck waiting for the slow one. callers in real life don't wait for each other: every request that should have been sent during the stall was queued behind it.

the name comes from Gil Tene: the load generator coordinates with the system under test (it backs off whenever the system is slow) and so omits exactly the samples that would have shown the problem. the system effectively decides which of its own bad moments get recorded.

the fixes:

  • measure from the intended start time, not the moment you actually managed to send. that's what the corrected column does: a request scheduled at 2.01 s that could only start at 3.0 s waited 990 ms, and it's recorded that way.
  • use an open-model load generator that keeps sending on schedule no matter what (wrk2, Gatling's open injection profiles, k6's arrival-rate executors), instead of a fixed pool of users that each wait for their own response.
  • record into a histogram that can do the correction for you: HdrHistogram's recordValueWithExpectedInterval back-fills the samples a stalled loop missed.

the short list

  1. look at the distribution. a histogram, on log scales, before any single number.
  2. report percentiles and the max, with the sample count. p50, p90, p99, p99.9 are a good default.
  3. never average percentiles. merge histograms, then compute.
  4. measure from the intended start time, and drive load open-loop.
  5. warm up and know your environment. the JIT changes the numbers for the first seconds (the JIT), GC changes the tail (choosing a collector), and a laptop is not a server.
  6. for tiny pieces of code, don't use a loop and nanoTime at all. the JIT will happily delete the code you're timing. that's the next chapter.
keylines
  • latency is a distribution. the mean is its least useful summary.
  • report p50, p99, p99.9 and max, from a histogram, with a sample count.
  • tails compound: a page with 40 calls hits a p99 call a third of the time.
  • percentiles can't be averaged. merge histograms instead.
  • a load tool that waits for responses stops measuring exactly when things are slow. measure from the intended start.