Merge histograms vs. average p99
Simulate a modelNeeds Python with NumPy
Problem
A p99 cannot be averaged across servers: the average gives a quiet server the same weight as a busy one, and the tail of the whole comes mostly from the servers with the worst tails. Histograms with shared bucket boundaries merge exactly: add the counts bucket by bucket, then read the quantile from the merged buckets, with an error bounded by the bucket width.
Generate latency values for 10 "servers" with different request counts (10,000 on one, 100 on another) drawn from log-normal distributions. Compute (a) the average of the ten p99 values, (b) the true p99 over all samples, (c) the p99 from merged bucket counts.
The question. How far from the true p99 are the averaged p99 and the p99 from merged buckets?
Predict
Expect (a) to be very different from (b), and (c) to be close to (b), within the bucket width.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
merge-histograms-vs-average-p99.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Merge histograms against the average p99. Ten servers with very different request counts (10,000 down to 100) draw latencies from log-normal distributions. Compares (a) the average of the ten p99 values, (b) the true p99 over all samples, and (c) the p99 read from the merged bucket counts of Prometheus-style histograms. """ import argparse, json import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--seed", type=int, default=1, help="Random seed.") p.add_argument("--buckets", default="5,10,25,50,75,100,150,200,300,500,750,1000,2500,5000", help="Bucket upper bounds (ms).") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) counts = [10_000, 5_000, 3_000, 2_000, 1_000, 800, 500, 300, 200, 100] servers = [rng.lognormal(np.log(20 + 15 * i), 0.4 + 0.08 * i, n) for i, n in enumerate(counts)] # small servers are slower and wider bounds = np.array([float(b) for b in args.buckets.split(",")] + [np.inf]) hist = sum(np.histogram(s, bins=np.concatenate([[0], bounds]))[0] for s in servers) # merge = add the counts def hist_quantile(q, bounds, counts): """Prometheus histogram_quantile: find the bucket, interpolate linearly inside it.""" cum = np.cumsum(counts); rank = q * cum[-1]; i = int(np.searchsorted(cum, rank)) lo = 0 if i == 0 else bounds[i - 1]; hi = bounds[i]; below = 0 if i == 0 else cum[i - 1] return float(lo + (hi - lo) * (rank - below) / counts[i]), float(lo), float(hi) per = [float(np.percentile(s, 99)) for s in servers] true = float(np.percentile(np.concatenate(servers), 99)); merged, lo, hi = hist_quantile(0.99, bounds, hist) rows = [{"method": "(a) average of the ten p99s", "p99_ms": float(np.mean(per))}, {"method": "(b) true p99 over all samples", "p99_ms": true}, {"method": "(c) p99 from merged buckets", "p99_ms": merged}] for r in rows: r["err_pct"] = (r["p99_ms"] / true - 1) * 100 shards = [{"server": i + 1, "requests": n, "p99_ms": v} for i, (n, v) in enumerate(zip(counts, per))] summary = {"bucket_lo": lo, "bucket_hi": hi} for r in rows: print(f"{r['method']:<32} {r['p99_ms']:7.1f} ms ({r['err_pct']:+.1f}%)") print(f"the true p99 falls in the bucket {lo:g}-{hi:g} ms") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"methods": rows, "servers": shards}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run merge-histograms-vs-average-p99.py uv run merge-histograms-vs-average-p99.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --seed1 | Random seed. |
| --buckets5, 10, 25, 50, 75, 100, 150, 200, 300, 500, 750, 1000, 2500, 5000 | Bucket upper bounds (ms). |
Our run
Three ways to get one p99
| Method | p99 (ms) | vs true |
|---|---|---|
| (a) average of the ten p99s | 623 | 82.9% |
| (b) true p99 over all samples | 341 | 0% |
| (c) p99 from merged buckets | 371 | 8.94% |
(a) average of the ten p99s 623.1 ms (+82.9%)
(b) true p99 over all samples 340.7 ms (+0.0%)
(c) p99 from merged buckets 371.2 ms (+8.9%)
the true p99 falls in the bucket 300-500 msWhat we found
The average of the ten p99s was 623 ms against a true p99 of 341 ms (82.9%): the small, slow servers pulled it up. The merged buckets gave 371 ms (8.94%), inside the bucket that holds the true value (300–500 ms).
Read it
Percentiles from many sources must be computed from mergeable data: histograms with shared boundaries, or sketches. The bucket layout sets the error, so put boundaries densely where your objectives are (around the SLO threshold), and the merged answer is accurate where it matters.