Skip to content
About
Main content

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 of the whole comes mostly from the servers with the worst tails. 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 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

Averaging the ten servers' p99s: how far is the result from the true p99?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    merge-histograms-vs-average-p99.py49 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Three ways to get one p99
Methodp99 (ms)vs true
(a) average of the ten p99s62382.9%
(b) true p99 over all samples3410%
(c) p99 from merged buckets3718.94%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run merge-histograms-vs-average-p99.py
(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 ms
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What 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

from many sources must be computed from mergeable data: histograms with shared boundaries, or . The bucket layout sets the error, so put boundaries densely where your objectives are (around the threshold), and the merged answer is accurate where it matters.