Skip to content
About
Main content

Fan-out tail and power-of-two-choices (about 45 minutes)

Simulate a modelNeeds Python with NumPy

Problem

Two lessons about choosing servers. First, a request that scatters to N shards and gathers all answers inherits the worst of N latencies: at N = 100, most requests contain one shard's p99 event. Second, routing by load information is only as good as that information. Load reports are always a little old, and if every client sends to the server that looked least loaded in the last snapshot, they all pile onto the same one until the next snapshot. Sampling two servers at random and taking the less loaded one (the power of two choices) is robust to stale information.

Claim to test: at N = 100 and per-shard p99, about 63% of requests contain a p99 event, and two random choices beat greedy choice on stale load information (see and Service discovery).

Scatter-gather: draw N latencies from a log-normal distribution, take the maximum, repeat 100,000 times. Count how often the maximum exceeds the single-shard p99. Run with N = 1, 10, 40 and 100.

Service discovery and load balancing: simulate 10 clients and 20 servers. Every 100 requests the clients refresh a shared load snapshot. Policy 1 sends each request to the server that was least loaded in the snapshot. Policy 2 samples two servers at random and picks the one with less snapshot load. Policy 3 picks one at random. Measure the maximum server load after 10,000 requests.

The script models load as requests in flight (servers finish requests at 90% utilization) and repeats with snapshots every 10, 500 and 2,000 requests.

The question. How often does a 100-shard request contain a p99 event, and which routing policy keeps the busiest server least loaded as the load snapshot gets staler?


Predict

With the load snapshot refreshed every 500 requests, which routing policy keeps the busiest server least loaded?

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.

    fan-out-tail-and-power-of.py60 linesDownload
    View the code
    fan-out-tail-and-power-of.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Fan-out tail and the power of two choices.
    
    (1) How often a request that fans out to N shards contains a p99 event.
    (2) Ten clients route 10,000 requests to 20 servers from a load snapshot
    refreshed every k requests: to the least-loaded server in the snapshot, to the
    lesser of two random servers, or to one random server.
    """
    import argparse, json, random
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--fanouts", type=lambda s: [int(v) for v in s.split(",")], default=[1, 10, 40, 100], help="Shards per request (N).")
    p.add_argument("--trials", type=int, default=100_000, help="Requests per N.")
    p.add_argument("--servers", type=int, default=20, help="Servers.")
    p.add_argument("--requests", type=int, default=10_000, help="Requests routed.")
    p.add_argument("--intervals", type=lambda s: [int(v) for v in s.split(",")], default=[10, 100, 500, 2000],
                   help="Requests between snapshot refreshes, comma-separated.")
    p.add_argument("--utilization", type=float, default=0.9, help="Arrival rate over total service capacity (in-flight load model).")
    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); random.seed(args.seed)
    
    fan = []
    p99 = float(np.exp(2.326))                                 # p99 of a standard log-normal
    for n in args.fanouts:
        worst = np.exp(rng.standard_normal((args.trials, n))).max(axis=1)
        fan.append({"N": n, "measured_pct": float(np.mean(worst > p99) * 100), "formula_pct": (1 - 0.99 ** n) * 100})
        print(f"N = {n:>3}: request contains a p99 event {fan[-1]['measured_pct']:5.1f}%  (1 - 0.99^N = {fan[-1]['formula_pct']:5.1f}%)")
    
    def route(policy, interval):
        """In-flight load: one request arrives per step; each server finishes one every servers/utilization steps."""
        load = [0] * args.servers; snap = load[:]; peak = 0
        for i in range(args.requests):
            if i % interval == 0: snap = load[:]
            if policy == "least loaded (snapshot)": s = min(range(args.servers), key=lambda j: snap[j])
            elif policy == "two random choices": a, b = random.sample(range(args.servers), 2); s = a if snap[a] <= snap[b] else b
            else: s = random.randrange(args.servers)
            load[s] += 1
            if i % max(1, round(args.servers / args.utilization)) == 0:     # every server finishes one in-flight request
                load = [max(0, x - 1) for x in load]
            peak = max(peak, max(load))
        return peak
    
    lb = []
    for interval in args.intervals:
        for policy in ("least loaded (snapshot)", "two random choices", "one random"):
            lb.append({"interval": interval, "policy": policy, "max_load": route(policy, interval)})
        print(f"snapshot every {interval:>5}: " + "   ".join(f"{r['policy']} {r['max_load']}" for r in lb[-3:]))
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"fanout": fan, "routing": lb}}, 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 fan-out-tail-and-power-of.py
    uv run fan-out-tail-and-power-of.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--fanouts1, 10, 40, 100 Shards per request (N).
--trials100,000 Requests per N.
--servers20 Servers.
--requests10,000 Requests routed.
--intervals10, 100, 500, 2000 Requests between snapshot refreshes, comma-separated.
--utilization0.9 Arrival rate over total service capacity (in-flight load model).
--seed0 Random seed.

Our run

Peak in-flight load on the busiest serverApple M2 · 8 cores · 8 GB
least loaded (snapshot)two random choicesone random
Data table
Peak in-flight load on the busiest server
Requests between snapshot refreshesseriesPeak load (requests)
10least loaded (snapshot)55
10two random choices49
10one random107
100least loaded (snapshot)132
100two random choices53
100one random76
500least loaded (snapshot)478
500two random choices72
500one random100
2Kleast loaded (snapshot)1,910
2Ktwo random choices135
2Kone random94
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

Requests that contain a p99 event

Requests that contain a p99 event
NSimulated1 − 0.99^N
11.02%1%
109.71%9.56%
4033.3%33.1%
10063.4%63.4%
Output · uv run fan-out-tail-and-power-of.py
N =   1: request contains a p99 event   1.0%  (1 - 0.99^N =   1.0%)
N =  10: request contains a p99 event   9.7%  (1 - 0.99^N =   9.6%)
N =  40: request contains a p99 event  33.3%  (1 - 0.99^N =  33.1%)
N = 100: request contains a p99 event  63.4%  (1 - 0.99^N =  63.4%)
snapshot every    10: least loaded (snapshot) 55   two random choices 49   one random 107
snapshot every   100: least loaded (snapshot) 132   two random choices 53   one random 76
snapshot every   500: least loaded (snapshot) 478   two random choices 72   one random 100
snapshot every  2000: least loaded (snapshot) 1909   two random choices 135   one random 94

What we found

The fan-out matched the formula: at N = 100, 63.4% of requests contained a p99 event.

Routing behaved as expected once the snapshot was stale. With a refresh every 100 requests the greedy policy's busiest server peaked at 132 requests, against 53 for two random choices and 76 for one random choice. The gap grew with staleness: at a refresh every 2,000 requests, greedy reached 1,910, nearly every request of the interval on one server. Only with very fresh information (every 10 requests) was greedy competitive (55 against 49).


Read it

Greedy routing on stale data is a herd: every client makes the same "best" choice from the same old picture. Randomness breaks the herd, and comparing just two candidates captures most of the benefit of perfect information; the maximum load of two random choices grows like log log n instead of log n / log log n for one random choice.

The fan-out lesson is the same one as in the fan-out arithmetic lab: past a few dozen shards, the p99 of a single shard is the median of the request, so tail work on the shards pays off at the request level many times over.