Skip to content
About
Main content

The spill cliff: 5% spilled, how much slower?

Measure your machineNeeds Python with NumPy

Problem

Spilling part of a to disk costs the spilled fraction a write and a read at disk speed, which is many times slower than memory. The slowdown is therefore governed by the r = memory ÷ disk, not by how small the spilled fraction is: time ∝ (1 − f) + 2·f·r. With r around 50, spilling 5% makes a job about six times slower.

Claim to test: the slowdown from spilling is governed by the bandwidth ratio, not the fraction. First measure r on your machine. Then measure the cost of writing and re-reading a fraction f of a data set against processing it in memory, for f = 0, 0.05 and 0.2. The spill file bypasses the (F_NOCACHE on macOS), so the read-back comes from the device. Then recompute with r = 500 (a network disk).

The question. What is r on this machine, and does the measured slowdown follow (1 − f) + 2·f·r?


Predict

With 20% of the data spilled to this laptop's SSD, how many times slower is the job than fully in memory?

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.

    the-spill-cliff-5-spilled-how.py67 linesDownload
    View the code
    the-spill-cliff-5-spilled-how.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """The spill cliff: 5% spilled, how much slower?
    
    Measures memory bandwidth (a sum over an int64 array) and the disk's
    write-then-read bandwidth, giving r = memory / disk. Then spills a fraction f
    of the array to a file (written and read back) while the rest is summed in
    memory, and compares the slowdown with time ∝ (1 − f) + 2·f·r. Bypasses the
    OS page cache for the spill file (F_NOCACHE on macOS, fadvise DONTNEED on
    Linux) so the read-back comes from the device.
    """
    import argparse, fcntl, json, os, sys, tempfile, time
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--n", type=int, default=100_000_000, help="int64 values (800 MB at the default).")
    p.add_argument("--fractions", default="0,0.05,0.2", help="Spilled fractions f.")
    p.add_argument("--r-network", type=float, default=500, help="A second r to compare with (a network disk).")
    p.add_argument("--dir", default=tempfile.gettempdir(), help="Directory for the spill file.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    a = np.arange(args.n, dtype=np.int64)
    a.sum(); t = time.perf_counter(); a.sum(); t_mem = time.perf_counter() - t
    bw_mem = a.nbytes / t_mem / 1e9
    path = os.path.join(args.dir, "spill.bin")
    
    def nocache(fd):
        if sys.platform == "darwin": fcntl.fcntl(fd, fcntl.F_NOCACHE, 1)
    
    def spill(k):
        """Write a[:k] and read it back, bypassing the page cache. Returns (write s, read s, data)."""
        fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC); nocache(fd)
        t = time.perf_counter(); out = memoryview(a[:k]).cast("B"); sent = 0
        while sent < len(out): sent += os.write(fd, out[sent:sent + (64 << 20)])
        os.fsync(fd); w = time.perf_counter() - t
        if hasattr(os, "posix_fadvise"): os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
        os.close(fd)
        b = np.empty(k, dtype=np.int64); fd = os.open(path, os.O_RDONLY); nocache(fd)
        t = time.perf_counter(); view = memoryview(b).cast("B"); got = 0
        while got < len(view): got += os.readv(fd, [view[got:]])
        r = time.perf_counter() - t; os.close(fd)
        return w, r, b
    
    k_cal = args.n // 5; w, rd, _ = spill(k_cal)
    bw_disk = 2 * k_cal * 8 / (w + rd) / 1e9                               # bytes moved once out and once in
    r_ratio = bw_mem / bw_disk
    rows = []
    for f in map(float, args.fractions.split(",")):
        k = int(args.n * f)
        t = time.perf_counter(); w, rd, b = spill(k); total = a[k:].sum() + b.sum(); el = time.perf_counter() - t
        assert total == a.sum()
        rows.append({"f": f, "measured_x": el / t_mem, "formula_x": (1 - f) + 2 * f * r_ratio, "formula_network_x": (1 - f) + 2 * f * args.r_network,
                     "write_s": w, "read_s": rd})
    os.remove(path)
    summary = {"mem_gb_s": bw_mem, "disk_gb_s": bw_disk, "r": r_ratio, "r_network": args.r_network}
    print(f"memory {bw_mem:.1f} GB/s, disk (write + read) {bw_disk:.2f} GB/s, r = {r_ratio:.0f}")
    for r in rows: print(f"f = {r['f']:<5g} measured {r['measured_x']:6.1f}x   formula {r['formula_x']:6.1f}x   with r = {args.r_network:g}: {r['formula_network_x']:6.1f}x")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"fractions": 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 the-spill-cliff-5-spilled-how.py
    uv run the-spill-cliff-5-spilled-how.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n100,000,000 int64 values (800 MB at the default).
--fractions0, 0.05, 0.2 Spilled fractions f.
--r-network500 A second r to compare with (a network disk).
--dirtempfile.gettempdir() Directory for the spill file.

Our run

Slowdown against an in-memory pass

Slowdown against an in-memory pass
Spilled fMeasuredFormula, measured rFormula, r = 500
03.46×1×1×
0.052.57×2.68×51×
0.236.7×7.72×201×
Reference runApple M2 · 8 cores · 8 GB
Output · uv run the-spill-cliff-5-spilled-how.py
memory 59.2 GB/s, disk (write + read) 3.42 GB/s, r = 17
f = 0     measured    3.5x   formula    1.0x   with r = 500:    1.0x
f = 0.05  measured    2.6x   formula    2.7x   with r = 500:   51.0x
f = 0.2   measured   36.7x   formula    7.7x   with r = 500:  200.8x
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What we found

Memory ran at 59.2 GB/s and the laptop's SSD at 3.42 GB/s, so r = 17.3, far below the 50 assumed: this machine's disk is unusually fast relative to memory. At f = 0.05 the measured slowdown was 2.57× against a formula value of 2.68×; at f = 0.2, 36.7× against 7.72×, above the formula, which the chapter offers as an upper bound. Even f = 0 measured 3.46×: opening, flushing and reading back a file costs a fixed amount that an 800 MB in-memory pass does not amortize. With r = 500, the formula gives 51× at 5%.


Read it

The measured values are noisy and the formula is only a first-order model, but the lesson holds: the price of spilling is set by r, and r depends on where the spill goes. On a local NVMe drive a small spill is tolerable; on network storage the same 5% makes the job tens of times slower, which is why memory grants that avoid spilling are worth more than they look.