Draw a miss-ratio curve with SHARDS-style sampling
Simulate a modelNeeds Python with NumPy, numba
Problem
A miss-ratio curve (MRC) gives the miss ratio of a cache at every size, which is what you need to size it. You can get the curve for LRU from one pass over a request trace. For each request, its reuse (stack) distance is the number of distinct keys used since the last request for the same key. An LRU cache of size C hits exactly the requests whose distance is below C, so MRC(C) = P(distance ≥ C). SHARDS makes this cheap: compute distances only for a hash-sampled share R of the keys, and scale each distance by 1/R.
Check that one pass over a trace gives the miss ratio for every LRU size, and that key sampling is accurate.
- Generate a trace of 2 million requests over 200,000 keys with Zipf alpha = 0.8.
- Write an exact stack-distance calculator (a list in LRU order is fine for a small trace, or use a Fenwick tree). Compute MRC(C) = P(reuse distance >= C) for C = 100, 1,000, 10,000, 50,000.
- Check it: run a real LRU simulator at the same sizes and compare. They must match exactly.
- Now keep only keys with
hash(key) % 100 < 1(sampling rate R = 0.01). Compute reuse distances on the sample, divide each distance by R, and compare the curve with the exact one.
The script uses a Fenwick tree (compiled with numba) and a well-mixed hash for the sample, so the most popular key is not always in it.
The question. Does one pass of stack distances reproduce the LRU miss ratio exactly, and how close is a 1% key sample to the exact curve?
Predict
The exact curve and the LRU simulation agree. The 1% sampled curve is close (a few points of error) while it processes 1% of the data.
For a Zipf distribution the share of requests that go to the top C of N keys is about (C/N)^(1−alpha), so a hit rate h needs a cache of about C/N = h^(1/(1−alpha)) of the keys.
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
draw-a-miss-ratio-curve-with.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "numba"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Draw a miss-ratio curve with SHARDS-style sampling. One pass over a trace computes every request's reuse (stack) distance, which gives the LRU miss ratio for every cache size at once. Checked against a real LRU simulator, then repeated on a 1% sample of the keys. """ import argparse, json, time from collections import OrderedDict import numpy as np from numba import njit p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--requests", type=int, default=2_000_000, help="Requests in the trace.") p.add_argument("--keys", type=int, default=200_000, help="Distinct keys.") p.add_argument("--alpha", type=float, default=0.8, help="Zipf exponent of key popularity.") p.add_argument("--sizes", type=lambda s: [int(v) for v in s.split(",")], default=[100, 1_000, 10_000, 50_000], help="Cache sizes checked against the LRU simulator, comma-separated.") p.add_argument("--rate", type=float, default=0.01, help="Sampling rate R for the SHARDS curve.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) w = 1.0 / np.arange(1, args.keys + 1) ** args.alpha trace = rng.choice(args.keys, size=args.requests, p=w / w.sum()).astype(np.int64) @njit(cache=True) def stack_distances(trace, n_keys): """Reuse distance of every request (distinct keys since its last use; -1 = first use). Fenwick tree over time: a 1 marks the latest access of each key.""" n = len(trace) tree = np.zeros(n + 1, np.int64) last = np.full(n_keys, -1, np.int64) out = np.empty(n, np.int64) for t in range(n): k = trace[t] lt = last[k] if lt < 0: out[t] = -1 else: s, i = 0, t # markers in (lt, t) = prefix(t) - prefix(lt + 1) while i > 0: s += tree[i]; i -= i & -i i = lt + 1 while i > 0: s -= tree[i]; i -= i & -i out[t] = s i = lt + 1 # remove the old marker while i <= n: tree[i] -= 1; i += i & -i i = t + 1 # add the new marker while i <= n: tree[i] += 1; i += i & -i last[k] = t return out def mrc(dist, sizes, scale=1.0): """Miss ratio at each size: P(distance >= C), first uses count as misses.""" d = np.where(dist < 0, np.inf, dist * scale) return [float(np.mean(d >= c)) for c in sizes] def lru_miss(trace, c): cache, miss = OrderedDict(), 0 for k in trace.tolist(): if k in cache: cache.move_to_end(k) else: miss += 1; cache[k] = True if len(cache) > c: cache.popitem(last=False) return miss / len(trace) t = time.perf_counter(); exact = stack_distances(trace, args.keys); t_exact = time.perf_counter() - t sim = [lru_miss(trace, c) for c in args.sizes] exact_at = mrc(exact, args.sizes) # SHARDS: keep keys whose (well-mixed) hash falls under R; scale their distances by 1/R def mix(k): """splitmix64: a stable hash that spreads every key, including key 0, evenly.""" z = (k.astype(np.uint64) + np.uint64(0x9E3779B97F4A7C15)) z = (z ^ (z >> np.uint64(30))) * np.uint64(0xBF58476D1CE4E5B9) z = (z ^ (z >> np.uint64(27))) * np.uint64(0x94D049BB133111EB) return z ^ (z >> np.uint64(31)) keep = (mix(trace) % np.uint64(10_000)) < np.uint64(int(args.rate * 10_000)) # keep a key if its hash falls under R t = time.perf_counter(); sampled = stack_distances(trace[keep], args.keys); t_sample = time.perf_counter() - t print(f"trace: {args.requests:,} requests over {args.keys:,} keys, alpha {args.alpha}") print(f"{'size':>8} {'exact MRC':>10} {'LRU sim':>9} {'sampled':>9}") for c, e, s, m in zip(args.sizes, exact_at, sim, mrc(sampled, args.sizes, 1 / args.rate)): print(f"{c:>8,} {e:10.4f} {s:9.4f} {m:9.4f}") print(f"sample: {keep.sum():,} of {args.requests:,} requests ({keep.mean():.2%}); " f"distance pass {t_sample:.2f} s against {t_exact:.2f} s exact (incl. compile on first call)") # The whole curve on a log grid of sizes, exact and sampled grid = np.unique(np.logspace(1, np.log10(args.keys), 40).astype(int)).tolist() curve = [{"size": c, "curve": name, "miss": m} for name, dist, sc in (("exact", exact, 1.0), ("sampled", sampled, 1 / args.rate)) for c, m in zip(grid, mrc(dist, grid, sc))] checks = [{"size": c, "exact": e, "lru_sim": s} for c, e, s in zip(args.sizes, exact_at, sim)] # C/N for h = 50% and 90%, from the exact curve, against h = (C/N)^(1 - alpha) hit = dict(zip(grid, (1 - m for m in mrc(exact, grid)))) fit = [] for target in (0.5, 0.9): c = next((c for c in grid if hit[c] >= target), grid[-1]) fit.append({"h": target, "measured_C_over_N": c / args.keys, "formula_C_over_N": target ** (1 / (1 - args.alpha))}) print(f"h = {target:.0%}: measured C/N = {c / args.keys:.3f} formula (C/N)^(1-alpha) gives {fit[-1]['formula_C_over_N']:.3f}") err = max(abs(a - b) for a, b in zip(mrc(exact, grid), mrc(sampled, grid, 1 / args.rate))) print(f"largest gap between the sampled and the exact curve: {err:.3f}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"curve": curve, "checks": checks, "fit": fit, "summary": [{ "max_sim_gap": max(abs(e - s) for e, s in zip(exact_at, sim)), "max_sample_gap": err, "sample_share": float(keep.mean())}]}}, 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 draw-a-miss-ratio-curve-with.py uv run draw-a-miss-ratio-curve-with.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --requests2,000,000 | Requests in the trace. |
| --keys200,000 | Distinct keys. |
| --alpha0.8 | Zipf exponent of key popularity. |
| --sizes100, 1000, 10,000, 50,000 | Cache sizes checked against the LRU simulator, comma-separated. |
| --rate0.01 | Sampling rate R for the SHARDS curve. |
| --seed0 | Random seed. |
Our run
Data table
| Cache size (entries) | series | Miss ratio |
|---|---|---|
| 10 | Exact (every request) | 0.992 |
| 12 | Exact (every request) | 0.991 |
| 16 | Exact (every request) | 0.988 |
| 21 | Exact (every request) | 0.985 |
| 27 | Exact (every request) | 0.981 |
| 35 | Exact (every request) | 0.976 |
| 45 | Exact (every request) | 0.971 |
| 59 | Exact (every request) | 0.964 |
| 76 | Exact (every request) | 0.956 |
| 98 | Exact (every request) | 0.948 |
| 126 | Exact (every request) | 0.939 |
| 163 | Exact (every request) | 0.929 |
| 210 | Exact (every request) | 0.919 |
| 271 | Exact (every request) | 0.907 |
| 349 | Exact (every request) | 0.896 |
| 451 | Exact (every request) | 0.883 |
| 581 | Exact (every request) | 0.869 |
| 749 | Exact (every request) | 0.854 |
| 966 | Exact (every request) | 0.838 |
| 1.25K | Exact (every request) | 0.821 |
| 1.6K | Exact (every request) | 0.803 |
| 2.07K | Exact (every request) | 0.783 |
| 2.67K | Exact (every request) | 0.762 |
| 3.44K | Exact (every request) | 0.74 |
| 4.43K | Exact (every request) | 0.715 |
| 5.71K | Exact (every request) | 0.689 |
| 7.37K | Exact (every request) | 0.661 |
| 9.5K | Exact (every request) | 0.63 |
| 12.2K | Exact (every request) | 0.598 |
| 15.8K | Exact (every request) | 0.562 |
| 20.3K | Exact (every request) | 0.524 |
| 26.2K | Exact (every request) | 0.483 |
| 33.8K | Exact (every request) | 0.439 |
| 43.6K | Exact (every request) | 0.391 |
| 56.2K | Exact (every request) | 0.34 |
| 72.4K | Exact (every request) | 0.287 |
| 93.4K | Exact (every request) | 0.231 |
| 120K | Exact (every request) | 0.175 |
| 155K | Exact (every request) | 0.123 |
| 200K | Exact (every request) | 0.096 |
| 10 | 1% key sample (SHARDS) | 0.979 |
| 12 | 1% key sample (SHARDS) | 0.979 |
| 16 | 1% key sample (SHARDS) | 0.979 |
| 21 | 1% key sample (SHARDS) | 0.979 |
| 27 | 1% key sample (SHARDS) | 0.979 |
| 35 | 1% key sample (SHARDS) | 0.979 |
| 45 | 1% key sample (SHARDS) | 0.979 |
| 59 | 1% key sample (SHARDS) | 0.979 |
| 76 | 1% key sample (SHARDS) | 0.979 |
| 98 | 1% key sample (SHARDS) | 0.979 |
| 126 | 1% key sample (SHARDS) | 0.961 |
| 163 | 1% key sample (SHARDS) | 0.961 |
| 210 | 1% key sample (SHARDS) | 0.946 |
| 271 | 1% key sample (SHARDS) | 0.946 |
| 349 | 1% key sample (SHARDS) | 0.931 |
| 451 | 1% key sample (SHARDS) | 0.918 |
| 581 | 1% key sample (SHARDS) | 0.906 |
| 749 | 1% key sample (SHARDS) | 0.883 |
| 966 | 1% key sample (SHARDS) | 0.867 |
| 1.25K | 1% key sample (SHARDS) | 0.844 |
| 1.6K | 1% key sample (SHARDS) | 0.819 |
| 2.07K | 1% key sample (SHARDS) | 0.799 |
| 2.67K | 1% key sample (SHARDS) | 0.775 |
| 3.44K | 1% key sample (SHARDS) | 0.748 |
| 4.43K | 1% key sample (SHARDS) | 0.722 |
| 5.71K | 1% key sample (SHARDS) | 0.694 |
| 7.37K | 1% key sample (SHARDS) | 0.663 |
| 9.5K | 1% key sample (SHARDS) | 0.631 |
| 12.2K | 1% key sample (SHARDS) | 0.597 |
| 15.8K | 1% key sample (SHARDS) | 0.559 |
| 20.3K | 1% key sample (SHARDS) | 0.522 |
| 26.2K | 1% key sample (SHARDS) | 0.481 |
| 33.8K | 1% key sample (SHARDS) | 0.435 |
| 43.6K | 1% key sample (SHARDS) | 0.39 |
| 56.2K | 1% key sample (SHARDS) | 0.339 |
| 72.4K | 1% key sample (SHARDS) | 0.284 |
| 93.4K | 1% key sample (SHARDS) | 0.227 |
| 120K | 1% key sample (SHARDS) | 0.17 |
| 155K | 1% key sample (SHARDS) | 0.121 |
| 200K | 1% key sample (SHARDS) | 0.095 |
Exact curve against an LRU simulator
| Cache size | Stack-distance MRC | LRU simulator |
|---|---|---|
| 100 | 0.947 | 0.947 |
| 1K | 0.836 | 0.836 |
| 10K | 0.624 | 0.624 |
| 50K | 0.364 | 0.364 |
Cache size for a hit rate, against (C/N)^(1−α)
| Hit rate h | Measured C/N (LRU) | Formula C/N |
|---|---|---|
| 0.5 | 0.131 | 0.031 |
| 0.9 | 1 | 0.59 |
trace: 2,000,000 requests over 200,000 keys, alpha 0.8
size exact MRC LRU sim sampled
100 0.9472 0.9472 0.9788
1,000 0.8359 0.8359 0.8665
10,000 0.6240 0.6240 0.6238
50,000 0.3642 0.3642 0.3632
sample: 20,226 of 2,000,000 requests (1.01%); distance pass 0.00 s against 0.15 s exact (incl. compile on first call)
h = 50%: measured C/N = 0.131 formula (C/N)^(1-alpha) gives 0.031
h = 90%: measured C/N = 1.000 formula (C/N)^(1-alpha) gives 0.590
largest gap between the sampled and the exact curve: 0.038What we found
The stack-distance curve and the LRU simulator agree at every size, to the last digit: the largest difference is 0. One pass over the trace really does give the whole curve. The 1% sample processed 1.01% of the requests and stayed within 0.038 of the exact curve; the error is largest at the smallest sizes, where a scaled distance of 1/R is coarse.
The formula is the optimistic case. A 50% hit rate needed 0.131 of the keys under LRU against the formula's 0.031, and 90% was never reached: 200,000 keys each miss once on first use, which caps this trace's hit rate near 90%. The formula describes a cache that holds exactly the top C keys and has seen them before; LRU holds the most recent ones, and a finite trace pays its first-use misses.
Read it
Sizing a cache from its miss-ratio curve turns "how much memory?" into a reading off a graph, and SHARDS makes the curve cheap enough to compute in production. Sample 1% of the keys by hash, not 1% of the requests: a key is either fully in the sample or fully out, so its reuse distances stay intact, and they only need scaling by 1/R.
The curve also tells you where memory does nothing. Where the curve is flat, more cache buys no hits; where it is steep, a little more buys a lot. The next lab builds a trace whose curve has both.