Queueing on the spill device
Measure your machineNeeds Python
Problem
A device shared by several spilling operators is a queue: each write waits for the ones ahead of it. With utilization ρ, 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
Expected result: latency stays flat at low utilization and then rises sharply. Four jobs at ρ = 0.8 should give about 5 times the idle value, and the fifth job should push it far beyond.
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
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)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
| Knob · default | What 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
| Jobs | ρ | Mean latency (ms) | p99 (ms) | vs one job | S/(1 − ρ) (ms) |
|---|---|---|---|---|---|
| 1 | 0.2 | 1.78 | 3.59 | 1× | 1.82 |
| 2 | 0.4 | 1.92 | 5.72 | 1.08× | 2.42 |
| 3 | 0.6 | 2.18 | 7.11 | 1.23× | 3.63 |
| 4 | 0.8 | 4,720 | 15.3K | 2,650× | 7.27 |
| 5 | 1 | 8,620 | 16.3K | 4,850× | — |
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-ρ) = unboundedWhat 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 admission control keeps it away from the wall.