Queue depth and the 0.7% reader
Measure your machineNeeds Python
Problem
An SSD is a parallel device: it has many flash channels and serves several requests at once. One request in flight leaves most of it idle, so throughput at depth 1 is 1 ÷ latency; adding requests in flight raises throughput almost linearly until the device is saturated, at a depth of about its latency times its bandwidth divided by the request size. A program that reads one block at a time sees only the depth-1 number.
On a laptop, create a 2 GB file and read random 4 KB blocks with 1, 4, 16, 64 and 128 requests in flight (one thread per request, page cache bypassed), and plot IOPS against the number in flight. Then try a plain mmap read of the same file in random order with one thread.
The question. How does IOPS grow with requests in flight, and where does it flatten?
Predict
Expected: at depth 1 you get about 1 ÷ latency (for an 80 µs device, about 12,500 IOPS). The curve rises almost linearly until it hits the device limit, then flattens.
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
queue-depth-and-the-0-7.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Queue depth and the device limit. A 2 GB file read in random 4 KB blocks, bypassing the page cache (F_NOCACHE on macOS, posix_fadvise elsewhere), by 1, 4, 16, 64 and 128 threads each with one request in flight: IOPS against requests in flight. Then the same random reads through mmap with one thread. """ import argparse, json, mmap, os, random, sys, tempfile, threading, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--file-mb", type=int, default=2048, help="Test file (MB).") p.add_argument("--depths", default="1,4,16,64,128", help="Requests in flight.") p.add_argument("--seconds", type=float, default=5, help="Seconds per depth.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() path = os.path.join(tempfile.mkdtemp(), "qd.bin") with open(path, "wb") as f: for _ in range(args.file_mb): f.write(os.urandom(1 << 20)) blocks = args.file_mb * 256 def open_nocache(): fd = os.open(path, os.O_RDONLY) if sys.platform == "darwin": import fcntl; fcntl.fcntl(fd, fcntl.F_NOCACHE, 1) return fd def run(depth): counts = [0] * depth; stop = time.perf_counter() + args.seconds def worker(i): fd = open_nocache(); rng = random.Random(i); n = 0 while time.perf_counter() < stop: os.pread(fd, 4096, rng.randrange(blocks) * 4096); n += 1 counts[i] = n; os.close(fd) ts = [threading.Thread(target=worker, args=(i,)) for i in range(depth)] for t in ts: t.start() for t in ts: t.join() iops = sum(counts) / args.seconds return {"in_flight": depth, "iops": iops, "mb_s": iops * 4096 / 2**20, "latency_us": depth / iops * 1e6} if hasattr(os, "posix_fadvise"): fd = os.open(path, os.O_RDONLY); os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED); os.close(fd) rows = [run(int(d)) for d in args.depths.split(",")] with open(path, "rb") as f: m = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ); rng = random.Random(9); n = 0; stop = time.perf_counter() + args.seconds while time.perf_counter() < stop: m[rng.randrange(blocks) * 4096]; n += 1 m.close() os.remove(path) summary = {"mmap_iops": n / args.seconds, "depth1_iops": rows[0]["iops"], "depth1_latency_us": rows[0]["latency_us"], "peak_iops": max(r["iops"] for r in rows)} for r in rows: print(f"{r['in_flight']:>4} in flight: {r['iops']:>9,.0f} IOPS ({r['mb_s']:7.1f} MB/s, {r['latency_us']:7.1f} µs per read)") print(f"mmap, one thread: {summary['mmap_iops']:,.0f} reads/s (pages may come from the cache after the first touch)") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"depths": 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 queue-depth-and-the-0-7.py uv run queue-depth-and-the-0-7.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --file-mb2048 | Test file (MB). |
| --depths1, 4, 16, 64, 128 | Requests in flight. |
| --seconds5 | Seconds per depth. |
Our run
Data table
| Requests in flight | Random 4 KB reads per second |
|---|---|
| 1 | 11.8K |
| 4 | 37.6K |
| 16 | 72.4K |
| 64 | 75.4K |
| 128 | 75.2K |
Random 4 KB reads
| In flight | IOPS | MB/s | µs per read |
|---|---|---|---|
| 1 | 11.8K | 46.2 | 84.6 |
| 4 | 37.6K | 147 | 106 |
| 16 | 72.4K | 283 | 221 |
| 64 | 75.4K | 294 | 849 |
| 128 | 75.2K | 294 | 1,700 |
1 in flight: 11,817 IOPS ( 46.2 MB/s, 84.6 µs per read)
4 in flight: 37,608 IOPS ( 146.9 MB/s, 106.4 µs per read)
16 in flight: 72,445 IOPS ( 283.0 MB/s, 220.9 µs per read)
64 in flight: 75,357 IOPS ( 294.4 MB/s, 849.3 µs per read)
128 in flight: 75,165 IOPS ( 293.6 MB/s, 1702.9 µs per read)
mmap, one thread: 12,822 reads/s (pages may come from the cache after the first touch)What we found
With one read in flight the SSD delivered 11.8K IOPS, 84.6 µs per read, almost exactly the expected 1 ÷ latency for an 80 µs device. Four in flight gave 37.6K and sixteen 72.4K; from there it flattened (75.2K at 128) while each read's latency kept growing, to 1,700 µs. Random reads through mmap with one thread ran at 12.8K per second, about the depth-1 rate: each page fault is one read in flight.
Read it
A single-threaded random reader measures the device's latency, not its capacity. Engines that issue reads asynchronously or in parallel (prefetching, io_uring, many concurrent queries) get the rest; mmap gets only what page faults, one at a time per thread, can drive.