Skip to content
About
Main content

Many small reads against few large reads: the transfer floor

Measure your machineNeeds Python

Problem

A 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 size, b_min: below it, a read costs about as much time as a bigger one, so 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 , and about 250 KB for NVMe (fast flash drives).

Measure the minimum efficient transfer size on your own disk, then compute a shuffle from it.

(Use a file larger than RAM, or drop the page 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

With the page cache bypassed, at what read size does this SSD reach half its peak throughput?

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.

    small-reads-and-the-transfer-floor.py77 linesDownload
    View 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)
    
  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 small-reads-and-the-transfer-floor.py
    uv run small-reads-and-the-transfer-floor.py --help     # every knob
Knobs
Knob · defaultWhat 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

Random-read throughput, by read sizeApple M2 · 8 cores · 8 GB
Page cache bypassed (the disk)Through the page cache
Data table
Random-read throughput, by read size
Read sizeseriesMB/s
4 KBPage cache bypassed (the disk)48.3
16 KBPage cache bypassed (the disk)188
64 KBPage cache bypassed (the disk)520
256 KBPage cache bypassed (the disk)982
1 MBPage cache bypassed (the disk)1,350
4 MBPage cache bypassed (the disk)1,620
16 MBPage cache bypassed (the disk)1,700
4 KBThrough the page cache47.3
16 KBThrough the page cache164
64 KBThrough the page cache432
256 KBThrough the page cache991
1 MBThrough the page cache2,090
4 MBThrough the page cache4,860
16 MBThrough the page cache6,370
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20 · 2 runs

The floor · page cache bypassed

The floor · page cache bypassed
Peak MB/sb_min (half of peak)
1,700256 KB

Shuffle width ceiling

Shuffle width ceiling
Data (S)M = R ≤ √(S / b_min)
100 GB618
1 TB1,950
10 TB6,180
Output · uv run small-reads-and-the-transfer-floor.py --cache bypass
       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 KB

What 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 count.

When b falls below the floor, the shuffle is limited by operations per second, not by bytes per second. , file consolidation and remote shuffle services are the same fix at three levels: they make each transfer bigger.