Many small reads against few large reads: the transfer floor
Measure your machineNeeds Python
Problem
A shuffle moves data from M producer tasks to R consumer tasks, so it creates M × R pieces, and each consumer reads its piece from every producer. With S bytes shuffled, each piece holds about b = S / (M × R) bytes. Storage has a minimum efficient transfer size, b_min: below it, a read costs about as much time as a bigger one, so throughput collapses. That floor caps how wide a shuffle can usefully be. The chapter puts it at roughly 2 MB for spinning disk, about 4 MB for object storage, and about 250 KB for NVMe (fast flash drives).
Measure the minimum efficient transfer size on your own disk, then compute a shuffle width ceiling from it.
(Use a file larger than RAM, or drop the page cache between runs, otherwise you measure memory.) Find the chunk size at which you reach about half of the peak MB/s: that is your b_min. Then compute the ceiling M = R ≤ √(S / b_min) for S = 100 GB, 1 TB and 10 TB, and check that ten times the data gives about 3.2 times the width. Finally compute b = S/(M × R) for 1 TB at 10,000 × 10,000: 1 TB ÷ 10⁸ = 10 KB.
The script reads around the page cache for you, so it measures the disk. You will measure random-read throughput for read sizes from 4 KB to 16 MB, the size that reaches half of the peak (b_min), and the width ceiling it implies. The chart also holds a run through the page cache, for comparison.
The question. At what read size does your disk reach half of its peak throughput, and how wide a shuffle does that allow for 1 TB of data?
Predict
Tiny random reads reach a small fraction of the sequential rate, and the curve flattens at some size between a few hundred KB and a few MB on SSD.
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
small-reads-and-the-transfer-floor.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Many small reads against few large reads: the transfer floor. Random reads of one chunk size at a time from a large file, with the page cache bypassed (otherwise you measure memory, not the disk); then the shuffle width ceiling that follows from the floor. """ import argparse, json, math, mmap, os, platform, random, tempfile, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "transfer-floor.bin"), help="Test file (removed at exit).") p.add_argument("--size-mb", type=int, default=1024, help="Size of the test file, in MB.") p.add_argument("--chunks", type=lambda s: [int(v) for v in s.split(",")], default=[4_096, 16_384, 65_536, 262_144, 1 << 20, 4 << 20, 16 << 20], help="Read sizes in bytes, comma-separated (multiples of 4096).") p.add_argument("--reads", type=int, default=2000, help="Random reads per chunk size (capped at file size / chunk).") p.add_argument("--cache", choices=["bypass", "page"], default="bypass", help="bypass: read around the page cache (measures the disk); page: normal reads (measures memory once cached).") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() import atexit, shutil atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path)) # leave nothing behind SIZE = args.size_mb << 20 DARWIN, LINUX = platform.system() == "Darwin", platform.system() == "Linux" F_NOCACHE = 48 # macOS fcntl: do not keep this file's pages in the cache def no_cache(fd): if DARWIN: import fcntl; fcntl.fcntl(fd, F_NOCACHE, 1) elif LINUX: os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) if not os.path.exists(args.path) or os.path.getsize(args.path) != SIZE: fd = os.open(args.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) if args.cache == "bypass": no_cache(fd) for _ in range(args.size_mb): os.write(fd, os.urandom(1 << 20)) os.fsync(fd); os.close(fd) flags = os.O_RDONLY | (os.O_DIRECT if LINUX and args.cache == "bypass" else 0) fd = os.open(args.path, flags) if args.cache == "bypass": no_cache(fd) rows = [] for chunk in args.chunks: buf = mmap.mmap(-1, chunk) # page-aligned buffer, as O_DIRECT requires n = min(args.reads, SIZE // chunk); total = 0 t = time.perf_counter() for _ in range(n): off = random.randrange(0, SIZE - chunk) // 4096 * 4096 total += os.preadv(fd, [buf], off) dt = time.perf_counter() - t rows.append({"chunk_bytes": chunk, "MBps": total / dt / 1e6}) print(f"{chunk:>12,} B reads: {total / dt / 1e6:8.1f} MB/s") os.close(fd) # b_min: the smallest chunk that reaches about half of the peak rate peak = max(r["MBps"] for r in rows) b_min = next(r["chunk_bytes"] for r in rows if r["MBps"] >= peak / 2) print(f"\npeak {peak:,.0f} MB/s · b_min (half of peak) = {b_min:,} B") ceilings = [] for label, S in (("100 GB", 100e9), ("1 TB", 1e12), ("10 TB", 10e12)): m = math.sqrt(S / b_min) ceilings.append({"data": label, "max_width": m}) print(f"S = {label:>6}: M = R <= sqrt(S / b_min) = {m:,.0f}") print(f"10x the data -> {ceilings[1]['max_width'] / ceilings[0]['max_width']:.1f}x the width") print(f"b = S / (M x R) for 1 TB at 10,000 x 10,000: {1e12 / (10_000 * 10_000) / 1e3:.0f} KB") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"reads": rows, "ceilings": ceilings, "floor": [{"peak_MBps": peak, "b_min_bytes": b_min}]}}, 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 small-reads-and-the-transfer-floor.py uv run small-reads-and-the-transfer-floor.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --pathos.path.join(tempfile.gettempdir(), 'transfer-floor.bin') | Test file (created once, reused). |
| --size-mb1024 | Size of the test file, in MB. |
| --chunks[4096, 16384, 65536, 262144, 1 << 20, 4 << 20, 16 << 20] | Read sizes in bytes, comma-separated (multiples of 4096). |
| --reads2000 | Random reads per chunk size (capped at file size / chunk). |
| --cachebypass | page (bypass) | bypass: read around the page cache (measures the disk); page: normal reads (measures memory once cached). |
Our run
Data table
| Read size | series | MB/s |
|---|---|---|
| 4 KB | Page cache bypassed (the disk) | 48.3 |
| 16 KB | Page cache bypassed (the disk) | 188 |
| 64 KB | Page cache bypassed (the disk) | 520 |
| 256 KB | Page cache bypassed (the disk) | 982 |
| 1 MB | Page cache bypassed (the disk) | 1,350 |
| 4 MB | Page cache bypassed (the disk) | 1,620 |
| 16 MB | Page cache bypassed (the disk) | 1,700 |
| 4 KB | Through the page cache | 47.3 |
| 16 KB | Through the page cache | 164 |
| 64 KB | Through the page cache | 432 |
| 256 KB | Through the page cache | 991 |
| 1 MB | Through the page cache | 2,090 |
| 4 MB | Through the page cache | 4,860 |
| 16 MB | Through the page cache | 6,370 |
The floor · page cache bypassed
| Peak MB/s | b_min (half of peak) |
|---|---|
| 1,700 | 256 KB |
Shuffle width ceiling
| Data (S) | M = R ≤ √(S / b_min) |
|---|---|
| 100 GB | 618 |
| 1 TB | 1,950 |
| 10 TB | 6,180 |
4,096 B reads: 48.3 MB/s
16,384 B reads: 188.0 MB/s
65,536 B reads: 520.3 MB/s
262,144 B reads: 982.1 MB/s
1,048,576 B reads: 1348.8 MB/s
4,194,304 B reads: 1620.5 MB/s
16,777,216 B reads: 1698.8 MB/s
peak 1,699 MB/s · b_min (half of peak) = 262,144 B
S = 100 GB: M = R <= sqrt(S / b_min) = 618
S = 1 TB: M = R <= sqrt(S / b_min) = 1,953
S = 10 TB: M = R <= sqrt(S / b_min) = 6,176
10x the data -> 3.2x the width
b = S / (M x R) for 1 TB at 10,000 x 10,000: 10 KBWhat we found
With the page cache bypassed, 4 KB random reads ran at 48.3 MB/s and the curve flattened near 1,700 MB/s, as expected. Half of the peak came at 256 KB, close to the chapter's 250 KB for NVMe. For 1 TB that allows a shuffle about 1,950 wide; the common 10,000 × 10,000 would make each piece 10 KB, far below the floor.
Through the page cache, the same reads kept climbing to 6,370 MB/s and gave a b_min many times larger, because they were served from memory, not from the disk. The gap between the two curves is the lab's warning made visible.
Read it
b = S/(M·R) against the substrate floor is the diagnostic; M = R ≤ √(S/b_min) is the consequence, and it means shuffle width grows as the square root of data size. A 10× smaller S raises the width ceiling by √10 ≈ 3.2, which is why shrinking the shuffled data (partial aggregation, dropping unused columns) comes before tuning the partition count.
When b falls below the floor, the shuffle is limited by operations per second, not by bytes per second. Sort shuffle, file consolidation and remote shuffle services are the same fix at three levels: they make each transfer bigger.