What spilling actually costs
Why does spilling 5% of the data make a query several times slower?
There are four components. They are ordered by how often people forget them.
1. The write. N bytes, sequential, predictable. At about 1 GB/s of local NVMe, 100 GB takes 100 seconds. This is the only term most people budget.
2. The read-back. Another N bytes, read later, competing with whatever else is spilling by then. The cost is 2N, not N.
3. The pipeline break. A pipeline is a chain of operators where each row flows to the next operator immediately, so the operators work at the same time. A spilling operator breaks this. The partitioning pass must complete before any output is produced. So the operator now blocks where it may not have blocked before, and its full duration lands on the critical path (the chain of steps that determines total time) instead of overlapping with downstream work.
This has a consequence that is not obvious and that closes a loop back to Rationing. The operator's residence time W (how long a query stays in the system) jumps. By Little's Law (average number in the system = arrival rate × average time in the system), with arrival rate λ unchanged, the number of queries in flight L = λW rises in the same proportion. Every one of those queries holds a memory grant.
The relief mechanism feeds the condition it relieves. Whole-system overload behaviour is a topic of its own, but the mechanism belongs here, and the important fact is that it is structural, not a tuning error.
4. The resource shift. This is where the cliff comes from. A spilling operator stops being memory-bandwidth-bound and becomes I/O-bound. These two resources differ by roughly 50× per node: of the order of 100+ GB/s of memory bandwidth against 1–2 GB/s of local NVMe. So the slowdown is not proportional to the fraction spilled. It is governed by the bandwidth ratio applied to that fraction.
Let f be the spill fraction, and r = B_mem / B_disk the bandwidth ratio. Counting the read-back:
- the share of the data that goes to disk
- memory bandwidth ÷ disk bandwidth (about 50 per node)
Data table
| Spill fraction f | series | Slowdown (× in-memory) |
|---|---|---|
| 0 | local NVMe (r = 50) | 1× |
| 0.013 | local NVMe (r = 50) | 2.24× |
| 0.025 | local NVMe (r = 50) | 3.48× |
| 0.038 | local NVMe (r = 50) | 4.71× |
| 0.05 | local NVMe (r = 50) | 5.95× |
| 0.063 | local NVMe (r = 50) | 7.19× |
| 0.075 | local NVMe (r = 50) | 8.43× |
| 0.088 | local NVMe (r = 50) | 9.66× |
| 0.1 | local NVMe (r = 50) | 10.9× |
| 0.113 | local NVMe (r = 50) | 12.1× |
| 0.125 | local NVMe (r = 50) | 13.4× |
| 0.138 | local NVMe (r = 50) | 14.6× |
| 0.15 | local NVMe (r = 50) | 15.8× |
| 0.163 | local NVMe (r = 50) | 17.1× |
| 0.175 | local NVMe (r = 50) | 18.3× |
| 0.188 | local NVMe (r = 50) | 19.6× |
| 0.2 | local NVMe (r = 50) | 20.8× |
| 0.212 | local NVMe (r = 50) | 22× |
| 0.225 | local NVMe (r = 50) | 23.3× |
| 0.237 | local NVMe (r = 50) | 24.5× |
| 0.25 | local NVMe (r = 50) | 25.8× |
| 0.263 | local NVMe (r = 50) | 27× |
| 0.275 | local NVMe (r = 50) | 28.2× |
| 0.287 | local NVMe (r = 50) | 29.5× |
| 0.3 | local NVMe (r = 50) | 30.7× |
| 0.313 | local NVMe (r = 50) | 31.9× |
| 0.325 | local NVMe (r = 50) | 33.2× |
| 0.338 | local NVMe (r = 50) | 34.4× |
| 0.35 | local NVMe (r = 50) | 35.6× |
| 0.362 | local NVMe (r = 50) | 36.9× |
| 0.375 | local NVMe (r = 50) | 38.1× |
| 0.388 | local NVMe (r = 50) | 39.4× |
| 0.4 | local NVMe (r = 50) | 40.6× |
| 0.412 | local NVMe (r = 50) | 41.8× |
| 0.425 | local NVMe (r = 50) | 43.1× |
| 0.438 | local NVMe (r = 50) | 44.3× |
| 0.45 | local NVMe (r = 50) | 45.5× |
| 0.463 | local NVMe (r = 50) | 46.8× |
| 0.475 | local NVMe (r = 50) | 48× |
| 0.487 | local NVMe (r = 50) | 49.3× |
| 0.5 | local NVMe (r = 50) | 50.5× |
| 0.512 | local NVMe (r = 50) | 51.7× |
| 0.525 | local NVMe (r = 50) | 53× |
| 0.537 | local NVMe (r = 50) | 54.2× |
| 0.55 | local NVMe (r = 50) | 55.5× |
| 0.563 | local NVMe (r = 50) | 56.7× |
| 0.575 | local NVMe (r = 50) | 57.9× |
| 0.588 | local NVMe (r = 50) | 59.2× |
| 0.6 | local NVMe (r = 50) | 60.4× |
| 0.613 | local NVMe (r = 50) | 61.6× |
| 0.625 | local NVMe (r = 50) | 62.9× |
| 0.637 | local NVMe (r = 50) | 64.1× |
| 0.65 | local NVMe (r = 50) | 65.3× |
| 0.662 | local NVMe (r = 50) | 66.6× |
| 0.675 | local NVMe (r = 50) | 67.8× |
| 0.688 | local NVMe (r = 50) | 69.1× |
| 0.7 | local NVMe (r = 50) | 70.3× |
| 0.713 | local NVMe (r = 50) | 71.5× |
| 0.725 | local NVMe (r = 50) | 72.8× |
| 0.738 | local NVMe (r = 50) | 74× |
| 0.75 | local NVMe (r = 50) | 75.3× |
| 0.762 | local NVMe (r = 50) | 76.5× |
| 0.775 | local NVMe (r = 50) | 77.7× |
| 0.787 | local NVMe (r = 50) | 79× |
| 0.8 | local NVMe (r = 50) | 80.2× |
| 0.813 | local NVMe (r = 50) | 81.4× |
| 0.825 | local NVMe (r = 50) | 82.7× |
| 0.838 | local NVMe (r = 50) | 83.9× |
| 0.85 | local NVMe (r = 50) | 85.2× |
| 0.863 | local NVMe (r = 50) | 86.4× |
| 0.875 | local NVMe (r = 50) | 87.6× |
| 0.887 | local NVMe (r = 50) | 88.9× |
| 0.9 | local NVMe (r = 50) | 90.1× |
| 0.912 | local NVMe (r = 50) | 91.3× |
| 0.925 | local NVMe (r = 50) | 92.6× |
| 0.938 | local NVMe (r = 50) | 93.8× |
| 0.95 | local NVMe (r = 50) | 95× |
| 0.963 | local NVMe (r = 50) | 96.3× |
| 0.975 | local NVMe (r = 50) | 97.5× |
| 0.988 | local NVMe (r = 50) | 98.8× |
| 1 | local NVMe (r = 50) | 100× |
| 0 | network-attached disk (r = 500) | 1× |
| 0.013 | network-attached disk (r = 500) | 13.5× |
| 0.025 | network-attached disk (r = 500) | 26× |
| 0.038 | network-attached disk (r = 500) | 38.5× |
| 0.05 | network-attached disk (r = 500) | 51× |
| 0.063 | network-attached disk (r = 500) | 63.4× |
| 0.075 | network-attached disk (r = 500) | 75.9× |
| 0.088 | network-attached disk (r = 500) | 88.4× |
| 0.1 | network-attached disk (r = 500) | 101× |
| 0.113 | network-attached disk (r = 500) | 113× |
| 0.125 | network-attached disk (r = 500) | 126× |
| 0.138 | network-attached disk (r = 500) | 138× |
| 0.15 | network-attached disk (r = 500) | 151× |
| 0.163 | network-attached disk (r = 500) | 163× |
| 0.175 | network-attached disk (r = 500) | 176× |
| 0.188 | network-attached disk (r = 500) | 188× |
| 0.2 | network-attached disk (r = 500) | 201× |
| 0.212 | network-attached disk (r = 500) | 213× |
| 0.225 | network-attached disk (r = 500) | 226× |
| 0.237 | network-attached disk (r = 500) | 238× |
| 0.25 | network-attached disk (r = 500) | 251× |
| 0.263 | network-attached disk (r = 500) | 263× |
| 0.275 | network-attached disk (r = 500) | 276× |
| 0.287 | network-attached disk (r = 500) | 288× |
| 0.3 | network-attached disk (r = 500) | 301× |
| 0.313 | network-attached disk (r = 500) | 313× |
| 0.325 | network-attached disk (r = 500) | 326× |
| 0.338 | network-attached disk (r = 500) | 338× |
| 0.35 | network-attached disk (r = 500) | 351× |
| 0.362 | network-attached disk (r = 500) | 363× |
| 0.375 | network-attached disk (r = 500) | 376× |
| 0.388 | network-attached disk (r = 500) | 388× |
| 0.4 | network-attached disk (r = 500) | 401× |
| 0.412 | network-attached disk (r = 500) | 413× |
| 0.425 | network-attached disk (r = 500) | 426× |
| 0.438 | network-attached disk (r = 500) | 438× |
| 0.45 | network-attached disk (r = 500) | 451× |
| 0.463 | network-attached disk (r = 500) | 463× |
| 0.475 | network-attached disk (r = 500) | 476× |
| 0.487 | network-attached disk (r = 500) | 488× |
| 0.5 | network-attached disk (r = 500) | 501× |
| 0.512 | network-attached disk (r = 500) | 513× |
| 0.525 | network-attached disk (r = 500) | 525× |
| 0.537 | network-attached disk (r = 500) | 538× |
| 0.55 | network-attached disk (r = 500) | 550× |
| 0.563 | network-attached disk (r = 500) | 563× |
| 0.575 | network-attached disk (r = 500) | 575× |
| 0.588 | network-attached disk (r = 500) | 588× |
| 0.6 | network-attached disk (r = 500) | 600× |
| 0.613 | network-attached disk (r = 500) | 613× |
| 0.625 | network-attached disk (r = 500) | 625× |
| 0.637 | network-attached disk (r = 500) | 638× |
| 0.65 | network-attached disk (r = 500) | 650× |
| 0.662 | network-attached disk (r = 500) | 663× |
| 0.675 | network-attached disk (r = 500) | 675× |
| 0.688 | network-attached disk (r = 500) | 688× |
| 0.7 | network-attached disk (r = 500) | 700× |
| 0.713 | network-attached disk (r = 500) | 713× |
| 0.725 | network-attached disk (r = 500) | 725× |
| 0.738 | network-attached disk (r = 500) | 738× |
| 0.75 | network-attached disk (r = 500) | 750× |
| 0.762 | network-attached disk (r = 500) | 763× |
| 0.775 | network-attached disk (r = 500) | 775× |
| 0.787 | network-attached disk (r = 500) | 788× |
| 0.8 | network-attached disk (r = 500) | 800× |
| 0.813 | network-attached disk (r = 500) | 813× |
| 0.825 | network-attached disk (r = 500) | 825× |
| 0.838 | network-attached disk (r = 500) | 838× |
| 0.85 | network-attached disk (r = 500) | 850× |
| 0.863 | network-attached disk (r = 500) | 863× |
| 0.875 | network-attached disk (r = 500) | 875× |
| 0.887 | network-attached disk (r = 500) | 888× |
| 0.9 | network-attached disk (r = 500) | 900× |
| 0.912 | network-attached disk (r = 500) | 913× |
| 0.925 | network-attached disk (r = 500) | 925× |
| 0.938 | network-attached disk (r = 500) | 938× |
| 0.95 | network-attached disk (r = 500) | 950× |
| 0.963 | network-attached disk (r = 500) | 963× |
| 0.975 | network-attached disk (r = 500) | 975× |
| 0.988 | network-attached disk (r = 500) | 988× |
| 1 | network-attached disk (r = 500) | 1,000× |
(The (1 − f) part is the data that stays in memory. The 2·f·r part is the data that goes to disk and back, each byte costing r times more, twice.)
At r = 50:
This one calculation explains every "it was fine and then it fell off a cliff" report. Nobody spills 100% and is surprised. People spill 5% and are astonished.
Honest caveat. Real engines overlap I/O with computation, so this formula is an upper bound, and well-tuned systems do better. But the shape is right, and the shape is what matters: the penalty is set by the ratio of two bandwidths, not by the fraction of the data that crossed between them.
This is not a contradiction of hybrid hash's "ramp, not a cliff". Hybrid hash makes the extra I/O grow in proportion to the spilled fraction, a ramp when measured against the cost of a fully spilled join; measured against the in-memory join, each spilled byte costs r times more, which is the cliff.
One-line compression: the slowdown from spilling is governed by the memory-to-disk bandwidth ratio, not the spill fraction, which is why 5% spilled feels like a cliff.