Skip to content
About
Main content

Queueing on the spill device

Measure your machineNeeds Python

Problem

A device shared by several spilling operators is a : each write waits for the ones ahead of it. With ρ, the mean response time is about S/(1 − ρ), where S is the service time: flat at low load, then sharply rising, and unbounded at full load. A gate on the number of concurrent spillers keeps ρ in the flat part, which is worth more than a faster disk.

Measure the device's sequential write throughput with one unthrottled writer, then run 1 to 5 rate-limited writers (each like fio --rw=write --bs=1m --rate=...) at 20% of that throughput each, and record the mean completion latency of a 1 MB write, measured from when it was due. Compare with S/(1 − ρ).

The question. How does write latency grow as the writers' combined rate approaches the device's throughput?


Predict

At ρ = 0.8, with four writers, how many times one writer's latency does a write take?

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.

    queueing-on-the-spill-device.py74 linesDownload
    View the code
    queueing-on-the-spill-device.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Queueing on the spill device: R = S / (1 − ρ).
    
    Measures the device's sequential write throughput with one unthrottled
    writer, then runs 1 to 5 rate-limited writers (each like fio --rw=write
    --bs=1m --rate=...) at a fixed share of that throughput and records the mean
    completion latency of a 1 MB write, measured from when it was due. Writes
    bypass the page cache (F_NOCACHE on macOS, O_DIRECT is not used; on Linux
    each write is followed by fdatasync).
    """
    import argparse, fcntl, json, os, statistics, sys, tempfile, threading, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--share", type=float, default=0.2, help="Each job's rate as a share of the measured throughput.")
    p.add_argument("--jobs", default="1,2,3,4,5", help="Numbers of jobs to run.")
    p.add_argument("--seconds", type=float, default=15, help="Seconds per run.")
    p.add_argument("--size-mb", type=int, default=1024, help="File size per job (the job wraps around).")
    p.add_argument("--dir", default=tempfile.gettempdir(), help="Directory for the job files.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    BLOCK = os.urandom(1 << 20)
    def opener(i):
        fd = os.open(os.path.join(args.dir, f"spill-job-{i}.bin"), os.O_WRONLY | os.O_CREAT)
        if sys.platform == "darwin": fcntl.fcntl(fd, fcntl.F_NOCACHE, 1)
        return fd
    def write(fd, off):
        os.pwrite(fd, BLOCK, off)
        if sys.platform != "darwin": os.fdatasync(fd)
    
    def job(i, rate_mb, end, lat):
        fd = opener(i); n = 0; t0 = time.perf_counter()
        while True:
            due = t0 + n / rate_mb if rate_mb else time.perf_counter()
            if due >= end: break
            delay = due - time.perf_counter()
            if delay > 0: time.sleep(delay)
            write(fd, (n % args.size_mb) << 20); lat.append(time.perf_counter() - due); n += 1
        os.close(fd)
    
    def run(k, rate_mb):
        lats = [[] for _ in range(k)]; end = time.perf_counter() + args.seconds
        th = [threading.Thread(target=job, args=(i, rate_mb, end, lats[i])) for i in range(k)]
        for t in th: t.start()
        for t in th: t.join()
        allv = [x for l in lats for x in l]
        return allv, sum(len(l) for l in lats) / args.seconds
    
    _, peak = run(1, 0)                                                    # unthrottled: the device's throughput
    service = 1 / peak                                                     # seconds per 1 MB at full speed
    rate = peak * args.share; rows = []
    for k in map(int, args.jobs.split(",")):
        lat, got = run(k, rate)
        rho = k * args.share
        rows.append({"jobs": k, "rho": rho, "mb_per_s": got, "mean_ms": statistics.mean(lat) * 1000,
                     "p99_ms": sorted(lat)[int(0.99 * len(lat))] * 1000, "model_ms": service / (1 - rho) * 1000 if rho < 1 else None})
    for r in rows: r["vs_one_job"] = r["mean_ms"] / rows[0]["mean_ms"]
    for i in range(5): 
        try: os.remove(os.path.join(args.dir, f"spill-job-{i}.bin"))
        except FileNotFoundError: pass
    summary = {"device_mb_s": peak, "service_ms": service * 1000, "rate_mb_s": rate}
    print(f"device: {peak:.0f} MB/s sequential writes (1 MB takes {service * 1000:.2f} ms); each job writes {rate:.0f} MB/s")
    for r in rows:
        m = f"{r['model_ms']:.2f} ms" if r["model_ms"] else "unbounded"
        print(f"{r['jobs']} job(s), ρ = {r['rho']:.1f}: mean latency {r['mean_ms']:8.2f} ms ({r['vs_one_job']:6.1f}x one job), p99 {r['p99_ms']:8.1f} ms, achieved {r['mb_per_s']:.0f} MB/s   S/(1-ρ) = {m}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"jobs": 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 queueing-on-the-spill-device.py
    uv run queueing-on-the-spill-device.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--share0.2 Each job's rate as a share of the measured throughput.
--jobs1, 2, 3, 4, 5 Numbers of jobs to run.
--seconds15 Seconds per run.
--size-mb1024 File size per job (the job wraps around).
--dirtempfile.gettempdir() Directory for the job files.

Our run

Writers sharing one device

Writers sharing one device
JobsρMean latency (ms)p99 (ms)vs one jobS/(1 − ρ) (ms)
10.21.783.591×1.82
20.41.925.721.08×2.42
30.62.187.111.23×3.63
40.84,72015.3K2,650×7.27
518,62016.3K4,850×—
Reference runApple M2 · 8 cores · 8 GB
Output · uv run queueing-on-the-spill-device.py
device: 688 MB/s sequential writes (1 MB takes 1.45 ms); each job writes 138 MB/s
1 job(s), ρ = 0.2: mean latency     1.78 ms (   1.0x one job), p99      3.6 ms, achieved 138 MB/s   S/(1-ρ) = 1.82 ms
2 job(s), ρ = 0.4: mean latency     1.92 ms (   1.1x one job), p99      5.7 ms, achieved 275 MB/s   S/(1-ρ) = 2.42 ms
3 job(s), ρ = 0.6: mean latency     2.18 ms (   1.2x one job), p99      7.1 ms, achieved 413 MB/s   S/(1-ρ) = 3.63 ms
4 job(s), ρ = 0.8: mean latency  4721.03 ms (2654.7x one job), p99  15263.6 ms, achieved 550 MB/s   S/(1-ρ) = 7.27 ms
5 job(s), ρ = 1.0: mean latency  8616.46 ms (4845.2x one job), p99  16327.1 ms, achieved 688 MB/s   S/(1-ρ) = unbounded
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

The device wrote 688 MB/s, so a 1 MB write took 1.45 ms. Latency stayed flat through ρ = 0.6 (1.78 ms with one writer, 2.18 ms with three), then fell off a cliff earlier and harder than S/(1 − ρ) predicts: 4.72K ms at ρ = 0.8 (2,650× one writer's, against a model value of 7.27 ms) and 8.62K ms at ρ = 1. The throughput measured in a short burst was more than the SSD could sustain once its write cache filled, so the real utilization at "0.8" was already above 1.


Read it

A gate on concurrent spillers is worth more than a faster disk: the response time depends on how close to saturation the device runs, and keeps it away from the wall.