Skip to content
About
Main content

A sort produces a property, not an answer

What does an external sort really cost, and how does a distributed sort turn a global merge into a concatenation?

Sorting is the only operator here that computes nothing. It adds no information, removes no rows and changes no values. Its output is a permutation of its input. It exists for one purpose: to supply a physical property (order) that some other operator . That makes it the purest example of the idea that has run through this mechanism: operators are cheap; are the query; and sort is an enforcer with no operator attached.

Two consequences follow.

  1. The best implementation of a sort is not running it. That is why "sort avoidance through " is not the last item in this article but its conclusion.
  2. Because sorting computes nothing, its cost depends on nothing semantic. A 's cost depends on key frequencies. An 's cost depends on . These are the two least reliable statistics in any catalogue. A sort's cost depends only on N (data size), M (memory) and B (I/O block size). It is the only operator here whose cost you can compute in advance and be right. A plan with a sort is boring, and boring is the scarcest property in query execution.

The core tension: amortization across requirements

Hash gives you co-location (equal keys end up together) at linear cost and leaves nothing behind. Sort gives you co-location and order, at a log factor, and the order composes: one enforcement can satisfy a merge join, then an aggregation, then an ORDER BY.

So the trade is not "which algorithm is faster". It is amortization across requirements:

  • One consumer of the order: hash wins on even terms.
  • Three consumers: hash pays three times, while sort paid once.

"operators in this query" to "all future queries", and you get clustering, bucketing, LSM ordering (the sorted files of a log-structured merge tree) and indexes. It is the same arithmetic with a much larger base, paid at write time. This is where physical design either collects on its investment or finds that it bought the wrong order.


External merge sort: not a log problem

An sorts data larger than memory. It has two phases.

  1. Run generation. Fill M bytes of memory, sort them in place, write the sorted block (a run) to disk. Repeat. This produces N/M runs.
  2. Merge. Read one block from each of F runs, merge them, and write the output. F is bounded by memory divided by block size: F = M/B, because each input run needs a buffer of one block, and the output needs one too.

The number of passes over the data is 1 + ⌈log_F(N/M)⌉. A single merge pass is enough when N/M ≤ F, which means

M / B (blocks of memory)
— how many runs are merged at once
F = 10 (old disks)F = 100F = 8,192 (NVMe, 1 MB buffers)
Data table
Passes over the data by data-to-memory ratio
Data ÷ memory N/MseriesPasses
1F = 10 (old disks)1
1.26F = 10 (old disks)2
1.58F = 10 (old disks)2
2F = 10 (old disks)2
2.51F = 10 (old disks)2
3.16F = 10 (old disks)2
3.98F = 10 (old disks)2
5.01F = 10 (old disks)2
6.31F = 10 (old disks)2
7.94F = 10 (old disks)2
10F = 10 (old disks)2
12.6F = 10 (old disks)3
15.8F = 10 (old disks)3
20F = 10 (old disks)3
25.1F = 10 (old disks)3
31.6F = 10 (old disks)3
39.8F = 10 (old disks)3
50.1F = 10 (old disks)3
63.1F = 10 (old disks)3
79.4F = 10 (old disks)3
100F = 10 (old disks)3
126F = 10 (old disks)4
158F = 10 (old disks)4
200F = 10 (old disks)4
251F = 10 (old disks)4
316F = 10 (old disks)4
398F = 10 (old disks)4
501F = 10 (old disks)4
631F = 10 (old disks)4
794F = 10 (old disks)4
1KF = 10 (old disks)4
1.26KF = 10 (old disks)5
1.58KF = 10 (old disks)5
2KF = 10 (old disks)5
2.51KF = 10 (old disks)5
3.16KF = 10 (old disks)5
3.98KF = 10 (old disks)5
5.01KF = 10 (old disks)5
6.31KF = 10 (old disks)5
7.94KF = 10 (old disks)5
10KF = 10 (old disks)5
12.6KF = 10 (old disks)6
15.8KF = 10 (old disks)6
20KF = 10 (old disks)6
25.1KF = 10 (old disks)6
31.6KF = 10 (old disks)6
39.8KF = 10 (old disks)6
50.1KF = 10 (old disks)6
63.1KF = 10 (old disks)6
79.4KF = 10 (old disks)6
100KF = 10 (old disks)6
126KF = 10 (old disks)7
158KF = 10 (old disks)7
200KF = 10 (old disks)7
251KF = 10 (old disks)7
316KF = 10 (old disks)7
398KF = 10 (old disks)7
501KF = 10 (old disks)7
631KF = 10 (old disks)7
794KF = 10 (old disks)7
1MF = 10 (old disks)7
1.26MF = 10 (old disks)8
1.58MF = 10 (old disks)8
2MF = 10 (old disks)8
2.51MF = 10 (old disks)8
3.16MF = 10 (old disks)8
3.98MF = 10 (old disks)8
5.01MF = 10 (old disks)8
6.31MF = 10 (old disks)8
7.94MF = 10 (old disks)8
10MF = 10 (old disks)8
12.6MF = 10 (old disks)9
15.8MF = 10 (old disks)9
20MF = 10 (old disks)9
25.1MF = 10 (old disks)9
31.6MF = 10 (old disks)9
39.8MF = 10 (old disks)9
50.1MF = 10 (old disks)9
63.1MF = 10 (old disks)9
79.4MF = 10 (old disks)9
100MF = 10 (old disks)9
1F = 1001
1.26F = 1002
1.58F = 1002
2F = 1002
2.51F = 1002
3.16F = 1002
3.98F = 1002
5.01F = 1002
6.31F = 1002
7.94F = 1002
10F = 1002
12.6F = 1002
15.8F = 1002
20F = 1002
25.1F = 1002
31.6F = 1002
39.8F = 1002
50.1F = 1002
63.1F = 1002
79.4F = 1002
100F = 1002
126F = 1003
158F = 1003
200F = 1003
251F = 1003
316F = 1003
398F = 1003
501F = 1003
631F = 1003
794F = 1003
1KF = 1003
1.26KF = 1003
1.58KF = 1003
2KF = 1003
2.51KF = 1003
3.16KF = 1003
3.98KF = 1003
5.01KF = 1003
6.31KF = 1003
7.94KF = 1003
10KF = 1003
12.6KF = 1004
15.8KF = 1004
20KF = 1004
25.1KF = 1004
31.6KF = 1004
39.8KF = 1004
50.1KF = 1004
63.1KF = 1004
79.4KF = 1004
100KF = 1004
126KF = 1004
158KF = 1004
200KF = 1004
251KF = 1004
316KF = 1004
398KF = 1004
501KF = 1004
631KF = 1004
794KF = 1004
1MF = 1004
1.26MF = 1005
1.58MF = 1005
2MF = 1005
2.51MF = 1005
3.16MF = 1005
3.98MF = 1005
5.01MF = 1005
6.31MF = 1005
7.94MF = 1005
10MF = 1005
12.6MF = 1005
15.8MF = 1005
20MF = 1005
25.1MF = 1005
31.6MF = 1005
39.8MF = 1005
50.1MF = 1005
63.1MF = 1005
79.4MF = 1005
100MF = 1005
1F = 8,192 (NVMe, 1 MB buffers)1
1.26F = 8,192 (NVMe, 1 MB buffers)2
1.58F = 8,192 (NVMe, 1 MB buffers)2
2F = 8,192 (NVMe, 1 MB buffers)2
2.51F = 8,192 (NVMe, 1 MB buffers)2
3.16F = 8,192 (NVMe, 1 MB buffers)2
3.98F = 8,192 (NVMe, 1 MB buffers)2
5.01F = 8,192 (NVMe, 1 MB buffers)2
6.31F = 8,192 (NVMe, 1 MB buffers)2
7.94F = 8,192 (NVMe, 1 MB buffers)2
10F = 8,192 (NVMe, 1 MB buffers)2
12.6F = 8,192 (NVMe, 1 MB buffers)2
15.8F = 8,192 (NVMe, 1 MB buffers)2
20F = 8,192 (NVMe, 1 MB buffers)2
25.1F = 8,192 (NVMe, 1 MB buffers)2
31.6F = 8,192 (NVMe, 1 MB buffers)2
39.8F = 8,192 (NVMe, 1 MB buffers)2
50.1F = 8,192 (NVMe, 1 MB buffers)2
63.1F = 8,192 (NVMe, 1 MB buffers)2
79.4F = 8,192 (NVMe, 1 MB buffers)2
100F = 8,192 (NVMe, 1 MB buffers)2
126F = 8,192 (NVMe, 1 MB buffers)2
158F = 8,192 (NVMe, 1 MB buffers)2
200F = 8,192 (NVMe, 1 MB buffers)2
251F = 8,192 (NVMe, 1 MB buffers)2
316F = 8,192 (NVMe, 1 MB buffers)2
398F = 8,192 (NVMe, 1 MB buffers)2
501F = 8,192 (NVMe, 1 MB buffers)2
631F = 8,192 (NVMe, 1 MB buffers)2
794F = 8,192 (NVMe, 1 MB buffers)2
1KF = 8,192 (NVMe, 1 MB buffers)2
1.26KF = 8,192 (NVMe, 1 MB buffers)2
1.58KF = 8,192 (NVMe, 1 MB buffers)2
2KF = 8,192 (NVMe, 1 MB buffers)2
2.51KF = 8,192 (NVMe, 1 MB buffers)2
3.16KF = 8,192 (NVMe, 1 MB buffers)2
3.98KF = 8,192 (NVMe, 1 MB buffers)2
5.01KF = 8,192 (NVMe, 1 MB buffers)2
6.31KF = 8,192 (NVMe, 1 MB buffers)2
7.94KF = 8,192 (NVMe, 1 MB buffers)2
10KF = 8,192 (NVMe, 1 MB buffers)3
12.6KF = 8,192 (NVMe, 1 MB buffers)3
15.8KF = 8,192 (NVMe, 1 MB buffers)3
20KF = 8,192 (NVMe, 1 MB buffers)3
25.1KF = 8,192 (NVMe, 1 MB buffers)3
31.6KF = 8,192 (NVMe, 1 MB buffers)3
39.8KF = 8,192 (NVMe, 1 MB buffers)3
50.1KF = 8,192 (NVMe, 1 MB buffers)3
63.1KF = 8,192 (NVMe, 1 MB buffers)3
79.4KF = 8,192 (NVMe, 1 MB buffers)3
100KF = 8,192 (NVMe, 1 MB buffers)3
126KF = 8,192 (NVMe, 1 MB buffers)3
158KF = 8,192 (NVMe, 1 MB buffers)3
200KF = 8,192 (NVMe, 1 MB buffers)3
251KF = 8,192 (NVMe, 1 MB buffers)3
316KF = 8,192 (NVMe, 1 MB buffers)3
398KF = 8,192 (NVMe, 1 MB buffers)3
501KF = 8,192 (NVMe, 1 MB buffers)3
631KF = 8,192 (NVMe, 1 MB buffers)3
794KF = 8,192 (NVMe, 1 MB buffers)3
1MF = 8,192 (NVMe, 1 MB buffers)3
1.26MF = 8,192 (NVMe, 1 MB buffers)3
1.58MF = 8,192 (NVMe, 1 MB buffers)3
2MF = 8,192 (NVMe, 1 MB buffers)3
2.51MF = 8,192 (NVMe, 1 MB buffers)3
3.16MF = 8,192 (NVMe, 1 MB buffers)3
3.98MF = 8,192 (NVMe, 1 MB buffers)3
5.01MF = 8,192 (NVMe, 1 MB buffers)3
6.31MF = 8,192 (NVMe, 1 MB buffers)3
7.94MF = 8,192 (NVMe, 1 MB buffers)3
10MF = 8,192 (NVMe, 1 MB buffers)3
12.6MF = 8,192 (NVMe, 1 MB buffers)3
15.8MF = 8,192 (NVMe, 1 MB buffers)3
20MF = 8,192 (NVMe, 1 MB buffers)3
25.1MF = 8,192 (NVMe, 1 MB buffers)3
31.6MF = 8,192 (NVMe, 1 MB buffers)3
39.8MF = 8,192 (NVMe, 1 MB buffers)3
50.1MF = 8,192 (NVMe, 1 MB buffers)3
63.1MF = 8,192 (NVMe, 1 MB buffers)3
79.4MF = 8,192 (NVMe, 1 MB buffers)4
100MF = 8,192 (NVMe, 1 MB buffers)4

Worked examples

Memory MBlock size BOne merge pass handles up to M²/B
1 GB1 MB1 GB × 1 GB / 1 MB = 1 GB × 1,000 = 1 TB
16 GB8 MB16 GB × 16 GB / 8 MB = 16 GB × 2,000 = 32 TB

(Check the second line: 16 GB / 8 MB = 2,000, so 16 GB × 2,000 = 32,000 GB = 32 TB.) In a distributed sort each node handles only N/P, so the per-node input is smaller again.

The honest statement about external sorting is therefore: the logarithm is a constant 2, and it has been 2 for thirty years, because memory M and block size B grew together. External sort is not a complexity problem. It is a bandwidth problem of exactly 4N: read N, write N as runs, read N, write N as output. Discussions of its asymptotics discuss a term that never changes.

Where it does change: the block size. On object storage (cloud storage where each request has a latency of tens of milliseconds and a price), B must be 8 to 16 MB to amortize the per-request fixed cost. A larger B reduces the F for the same memory, which lowers M²/B and brings the third pass closer. The fixed cost of an I/O operation determines the block size; the block size determines the fan-in; the fan-in determines the number of passes.

Two corrections to the standard account

Fan-in should not be maximized. An F-way merge costs about log₂ F comparisons per record in the merge phase. With F = 1,000 that is about ten comparisons per record, comparable to the entire in-memory sort's per-record cost. Raising the fan-in to remove a pass saves I/O but spends CPU. The optimum is not at the maximum. (A loser tree, a tournament structure for picking the next smallest record, improves the constant but not the log.)

Replacement selection is a correct optimization for a cost model that no longer binds. keeps a in memory and emits the smallest key that is not smaller than the last key emitted. On random input it makes runs of average length 2M instead of M, which halves the number of runs. But it destroys sequential locality: a heap of M elements is a random-access structure, whereas a modern in-memory sort is cache-friendly and can use vector instructions. And halving the number of runs almost never changes a pass count that is already two. Engines mostly abandoned it, correctly. What survives is its adaptive property: on nearly-sorted input it produces arbitrarily long runs, and the sort collapses toward a single pass. The technique is obsolete as a general optimization and alive as an adaptivity mechanism. Remember this whenever an old algorithm looks replaced.

The bottleneck moved

With a 4N I/O budget against NVMe drives that deliver several GB/s, and in-memory comparison sorting of string keys with collation (language-aware ordering rules) running at tens of MB/s per core, a sort is now often CPU-bound instead of I/O-bound. This is the reverse of the picture in every textbook that teaches the pass formula. That inversion is the reason for the section on below.


Sample-based range-partitioned sort

A distributed sort must produce a global order across P machines. There are two shapes.

  1. Sort locally, then merge globally. The global merge either goes through one node or pushes all data through a merge tree.
  2. Range-partition first, then sort locally. Each record goes to the machine that owns its key range. Each machine sorts its share. The global result is the concatenation of the parts.

The second wins for a structural reason: turns a global merge into a concatenation. Hash partitioning cannot do this. Hash gives co-location without order, so the parts do not compose. This is the sharpest demonstration that hashing and ordering are not two interchangeable ways to group things: one produces a result that composes and the other does not.

Choosing the splitters

The difficulty is choosing the (the key values that divide the ranges). Equal need knowledge of the key distribution, and knowing it needs reading the data. The solution is to sample: draw s records, sort the sample, and take its quantiles as splitters.

  • The sample size needed to keep partition imbalance within a factor (1+ε) grows roughly like P/ε². Tightening the balance tolerance is quadratically expensive in sample size, the same square-root law as before. In practice, a few hundred samples per partition is the working rule.
  • . Exact splitters would need a full pass and a global reduction. A random sample replaces that coordination entirely and pays with a bounded imbalance.

Three failure modes

1. It fixes distributional , a key space where values cluster unevenly, because splitters can be placed densely where the data is dense. It does nothing for a . If one key occurs more than N/P times, no splitters can balance the partitions: a splitter can only fall between distinct values, and one value is an atom of the partitioning. This is the same dead end that hash partitioning reached in the join article, approached from the other side. "Switch to range partitioning" is not an answer to a .

2. A biased sample gives correct-looking splitters and a broken sort. If the input is already ordered (time-partitioned files read in order are the normal case), then taking the first s records samples one end of the key space. All splitters are wrong in the same direction. The failure is a badly unbalanced sort, not an error message. Systematic or random sampling is not a refinement here; it is the correctness condition.

3. Sampling introduces a barrier. The partition boundaries must exist before any record can be routed. A distributed sort is therefore at least two passes over the data (or one pass over a sampled subset plus one full pass), and the second cannot start before the first ends. This is the same structural cost that paid in the join article: converting independent work into dependent work.