The spill cliff: 5% spilled, how much slower?
Measure your machineNeeds Python with NumPy
Problem
Spilling part of a working set 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 bandwidth ratio 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 spill 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 page cache (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 r around 50 the ratio at f = 0.05 is near 6 and at f = 0.2 near 20 (use the formula as an upper bound). With r = 500 the formula gives about 51× at 5%.
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
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)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
| Knob · default | What 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
| Spilled f | Measured | Formula, measured r | Formula, r = 500 |
|---|---|---|---|
| 0 | 3.46× | 1× | 1× |
| 0.05 | 2.57× | 2.68× | 51× |
| 0.2 | 36.7× | 7.72× | 201× |
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.8xWhat 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.