Skip to content
About
Main content

Reproduce incast on a laptop

Measure your machineNeeds Python

Problem

is a many-to-one traffic burst: a request fans out to many servers, they all answer at once, and their replies converge on one switch port whose buffer overflows. TCP loses packets, and a sender that loses the tail of a short reply waits for its retransmission timeout, typically 200 ms or more, so the jumps by orders of magnitude at the point where the burst first overflows the buffer, instead of rising smoothly. Jittering the replies spreads the burst and moves the knee.

Send N concurrent requests for a 32 KB reply to one process; increase N from 1 to 500; plot p50 / / p999 . Then repeat with jittered sending (each reply delayed by 0 to 2 ms at random). The test runs on loopback, which has no switch buffer to overflow.

The question. Does the tail latency jump at some N, and does jitter move it?


Predict

As synchronized senders go from 1 to 500, how does p999 latency rise?

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.

    reproduce-incast-on-a-laptop.py72 linesDownload
    View the code
    reproduce-incast-on-a-laptop.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Reproduce incast on a laptop.
    
    One receiver asks N senders (here: N connections to a server process) for a
    32 KB response at the same instant, and waits for all of them, like a
    scatter-gather query. N grows from 1 to 500; p50, p99 and p999 of the
    per-response latency are recorded. Then the same with jittered sending: each
    sender waits a random 0-2 ms before answering. On loopback there is no switch
    buffer to overflow, so the drop-and-RTO behaviour of real incast may not
    appear: the run records what loopback does.
    """
    import argparse, asyncio, json, resource, subprocess, sys, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--senders", default="1,10,50,100,200,300,500", help="Values of N.")
    p.add_argument("--rounds", type=int, default=40, help="Queries per N.")
    p.add_argument("--kb", type=int, default=32, help="Response size (KB).")
    p.add_argument("--jitter-ms", type=float, default=2.0, help="Maximum jitter in the jittered run (ms).")
    p.add_argument("--port", type=int, default=58401, help="Local port.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
    resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 8192), hard))
    
    SERVER = r"""
    import asyncio, random, resource, sys
    soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE); resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 8192), hard))
    port, size, jitter = int(sys.argv[1]), int(sys.argv[2]) * 1024, float(sys.argv[3]) / 1000
    body = b"x" * size
    async def h(r, w):
        while await r.readline():
            if jitter: await asyncio.sleep(random.uniform(0, jitter))
            w.write(body); await w.drain()
    async def main():
        s = await asyncio.start_server(h, "127.0.0.1", port, backlog=4096); print("ready", flush=True)
        async with s: await s.serve_forever()
    asyncio.run(main())
    """
    
    async def query_rounds(n):
        conns = [await asyncio.open_connection("127.0.0.1", args.port) for _ in range(n)]
        lat = []; size = args.kb * 1024
        for _ in range(args.rounds):
            t0 = time.perf_counter()
            for r, w in conns: w.write(b"go\n")
            async def read(r):
                await r.readexactly(size); return time.perf_counter() - t0
            lat += await asyncio.gather(*(read(r) for r, _ in conns))
        for _, w in conns: w.close()
        return lat
    
    def pct(xs, q): xs = sorted(xs); return xs[min(len(xs) - 1, int(q * len(xs)))]
    rows = []
    for jitter in (0.0, args.jitter_ms):
        srv = subprocess.Popen([sys.executable, "-c", SERVER, str(args.port), str(args.kb), str(jitter)], stdout=subprocess.PIPE, text=True)
        srv.stdout.readline()
        try:
            for n in map(int, args.senders.split(",")):
                lat = asyncio.run(query_rounds(n))
                rows.append({"sending": "jittered" if jitter else "synchronized", "N": n, "p50_ms": pct(lat, .5) * 1000,
                             "p99_ms": pct(lat, .99) * 1000, "p999_ms": pct(lat, .999) * 1000, "max_ms": max(lat) * 1000})
        finally: srv.kill(); srv.wait()
    for r in rows: print(f"{r['sending']:<13} N={r['N']:>4}  p50 {r['p50_ms']:8.2f} ms  p99 {r['p99_ms']:8.2f} ms  p999 {r['p999_ms']:8.2f} ms")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"latency": rows}}, 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 reproduce-incast-on-a-laptop.py
    uv run reproduce-incast-on-a-laptop.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--senders1, 10, 50, 100, 200, 300, 500 Values of N.
--rounds40 Queries per N.
--kb32 Response size (KB).
--jitter-ms2 Maximum jitter in the jittered run (ms).
--port58,401 Local port.

Our run

Per-reply latency against the number of sendersApple M2 · 8 cores · 8 GB

synchronized

jittered

Data table
Per-reply latency against the number of senders
sendingSenders Np50p99p999
synchronized10.1360.2160.216
synchronized100.1740.3180.328
synchronized500.4511.11.25
synchronized1000.8761.932.2
synchronized2001.914.414.72
synchronized3002.884.544.86
synchronized5005.629.5210.1
jittered11.182.292.29
jittered101.222.132.17
jittered501.282.482.6
jittered1001.472.592.8
jittered2002.094.284.49
jittered3003.286.096.35
jittered5005.649.610.3
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run reproduce-incast-on-a-laptop.py
synchronized  N=   1  p50     0.14 ms  p99     0.22 ms  p999     0.22 ms
synchronized  N=  10  p50     0.17 ms  p99     0.32 ms  p999     0.33 ms
synchronized  N=  50  p50     0.45 ms  p99     1.10 ms  p999     1.25 ms
synchronized  N= 100  p50     0.88 ms  p99     1.93 ms  p999     2.20 ms
synchronized  N= 200  p50     1.91 ms  p99     4.41 ms  p999     4.72 ms
synchronized  N= 300  p50     2.88 ms  p99     4.54 ms  p999     4.86 ms
synchronized  N= 500  p50     5.62 ms  p99     9.52 ms  p999    10.10 ms
jittered      N=   1  p50     1.18 ms  p99     2.29 ms  p999     2.29 ms
jittered      N=  10  p50     1.22 ms  p99     2.13 ms  p999     2.17 ms
jittered      N=  50  p50     1.28 ms  p99     2.48 ms  p999     2.60 ms
jittered      N= 100  p50     1.47 ms  p99     2.59 ms  p999     2.80 ms
jittered      N= 200  p50     2.09 ms  p99     4.28 ms  p999     4.49 ms
jittered      N= 300  p50     3.28 ms  p99     6.09 ms  p999     6.35 ms
jittered      N= 500  p50     5.64 ms  p99     9.60 ms  p999    10.33 ms

What we found

There was no knee: p999 rose smoothly with N, from 0.216 ms with one sender to 10.1 ms with 500, the shape of a , not of a timeout. Jitter changed the low end (adding its own delay) and not the high end (10.3 ms at 500). This lab did not reproduce incast: loopback has no switch buffer and does not drop packets, so TCP never waits for a retransmission.


Read it

Incast needs the thing loopback lacks: a shallow buffer shared by many senders, where packets are dropped. On a laptop the closest you can get is a network namespace with a rate-limited, small-queue link (tc with tbf or netem), which needs Linux and root. What this run does show is the baseline: without drops, many-to-one latency grows linearly with the burst.