Skip to content
About
Main content

Draw a miss-ratio curve with SHARDS-style sampling

Simulate a modelNeeds Python with NumPy, numba

Problem

A (MRC) gives the miss ratio of a at every size, which is what you need to size it. You can get the curve for 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.

  1. Generate a trace of 2 million requests over 200,000 keys with alpha = 0.8.
  2. 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( >= C) for C = 100, 1,000, 10,000, 50,000.
  3. Check it: run a real LRU simulator at the same sizes and compare. They must match exactly.
  4. 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

Under LRU, what share of the keys does a 50% hit rate need?

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.

    draw-a-miss-ratio-curve-with.py121 linesDownload
    View 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)
    
  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 draw-a-miss-ratio-curve-with.py
    uv run draw-a-miss-ratio-curve-with.py --help     # every knob
Knobs
Knob · defaultWhat 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

LRU miss ratio by cache size, from stack distancesApple M2 · 8 cores · 8 GB
Exact (every request)1% key sample (SHARDS)
Data table
LRU miss ratio by cache size, from stack distances
Cache size (entries)seriesMiss ratio
10Exact (every request)0.992
12Exact (every request)0.991
16Exact (every request)0.988
21Exact (every request)0.985
27Exact (every request)0.981
35Exact (every request)0.976
45Exact (every request)0.971
59Exact (every request)0.964
76Exact (every request)0.956
98Exact (every request)0.948
126Exact (every request)0.939
163Exact (every request)0.929
210Exact (every request)0.919
271Exact (every request)0.907
349Exact (every request)0.896
451Exact (every request)0.883
581Exact (every request)0.869
749Exact (every request)0.854
966Exact (every request)0.838
1.25KExact (every request)0.821
1.6KExact (every request)0.803
2.07KExact (every request)0.783
2.67KExact (every request)0.762
3.44KExact (every request)0.74
4.43KExact (every request)0.715
5.71KExact (every request)0.689
7.37KExact (every request)0.661
9.5KExact (every request)0.63
12.2KExact (every request)0.598
15.8KExact (every request)0.562
20.3KExact (every request)0.524
26.2KExact (every request)0.483
33.8KExact (every request)0.439
43.6KExact (every request)0.391
56.2KExact (every request)0.34
72.4KExact (every request)0.287
93.4KExact (every request)0.231
120KExact (every request)0.175
155KExact (every request)0.123
200KExact (every request)0.096
101% key sample (SHARDS)0.979
121% key sample (SHARDS)0.979
161% key sample (SHARDS)0.979
211% key sample (SHARDS)0.979
271% key sample (SHARDS)0.979
351% key sample (SHARDS)0.979
451% key sample (SHARDS)0.979
591% key sample (SHARDS)0.979
761% key sample (SHARDS)0.979
981% key sample (SHARDS)0.979
1261% key sample (SHARDS)0.961
1631% key sample (SHARDS)0.961
2101% key sample (SHARDS)0.946
2711% key sample (SHARDS)0.946
3491% key sample (SHARDS)0.931
4511% key sample (SHARDS)0.918
5811% key sample (SHARDS)0.906
7491% key sample (SHARDS)0.883
9661% key sample (SHARDS)0.867
1.25K1% key sample (SHARDS)0.844
1.6K1% key sample (SHARDS)0.819
2.07K1% key sample (SHARDS)0.799
2.67K1% key sample (SHARDS)0.775
3.44K1% key sample (SHARDS)0.748
4.43K1% key sample (SHARDS)0.722
5.71K1% key sample (SHARDS)0.694
7.37K1% key sample (SHARDS)0.663
9.5K1% key sample (SHARDS)0.631
12.2K1% key sample (SHARDS)0.597
15.8K1% key sample (SHARDS)0.559
20.3K1% key sample (SHARDS)0.522
26.2K1% key sample (SHARDS)0.481
33.8K1% key sample (SHARDS)0.435
43.6K1% key sample (SHARDS)0.39
56.2K1% key sample (SHARDS)0.339
72.4K1% key sample (SHARDS)0.284
93.4K1% key sample (SHARDS)0.227
120K1% key sample (SHARDS)0.17
155K1% key sample (SHARDS)0.121
200K1% key sample (SHARDS)0.095
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · numba 0.68.0 · run 2026-10-20

Exact curve against an LRU simulator

Exact curve against an LRU simulator
Cache sizeStack-distance MRCLRU simulator
1000.9470.947
1K0.8360.836
10K0.6240.624
50K0.3640.364

Cache size for a hit rate, against (C/N)^(1−α)

Cache size for a hit rate, against (C/N)^(1−α)
Hit rate hMeasured C/N (LRU)Formula C/N
0.50.1310.031
0.910.59
Output · uv run draw-a-miss-ratio-curve-with.py
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.038

What 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.