<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Spilling on Opensource internals</title><link>https://articles.9j.nz/tags/spilling/</link><description>Posts filed under Spilling on Opensource internals.</description><generator>Hugo</generator><language>en-US</language><managingEditor>Jay Hubbert</managingEditor><lastBuildDate>Wed, 21 Oct 2026 03:41:00 +0000</lastBuildDate><atom:link href="https://articles.9j.nz/tags/spilling/index.xml" rel="self" type="application/rss+xml"/><item><title>Multi-Level Merge: How DataFusion Sorts More Data Than It Can Hold, One Pass at a Time</title><link>https://articles.9j.nz/posts/how-datafusion-merges-more-sorted-runs-than-it-can-hold/</link><pubDate>Wed, 21 Oct 2026 03:41:00 +0000</pubDate><dc:creator>Jay Hubbert</dc:creator><guid isPermaLink="true">https://articles.9j.nz/posts/how-datafusion-merges-more-sorted-runs-than-it-can-hold/</guid><category>datafusion</category><category>sorting</category><category>spilling</category><category>performance</category><category>internals</category><description>DataFusion's multi-level merge merges as many sorted runs as memory allows, spills the result, and repeats — with a clever trick for skewed runs whose widest batch alone exceeds the budget.</description><content:encoded>&lt;blockquote&gt;&#10;&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Sorting more data than fits in memory is usually done by&#10;merging runs. But a k-way merge needs memory for &lt;em&gt;k&lt;/em&gt; streams at once, and&#10;if you have 1000 runs, you cannot merge them all in one pass. DataFusion&amp;rsquo;s&#10;&lt;code&gt;MultiLevelMerge&lt;/code&gt; merges as many as it can afford, writes the result to a&#10;spill file, adds that file back to the set, and repeats. The really&#10;clever part is the skew handling: if a single run&amp;rsquo;s widest batch is so&#10;wide that even two streams will not fit, the run is re-spilled with its&#10;batches sliced in half, and the merge is retried.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;External merge sort is the standard answer to &amp;ldquo;sort more than fits in&#10;memory&amp;rdquo;. You sort chunks that fit, write them out as &lt;em&gt;runs&lt;/em&gt;, then merge&#10;the runs. The merge is a k-way merge: one cursor per run, always taking&#10;the smallest head.&lt;/p&gt;&#10;&lt;p&gt;The k is the problem. A k-way merge needs one buffered batch per run. If&#10;you have 1,000 runs of 10 MB each, merging them all at once needs 10 GB&#10;of buffers — which is exactly the memory you were trying to avoid.&lt;/p&gt;&#10;&lt;h2 id="the-algorithm"&gt;The algorithm&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;datafusion/physical-plan/src/sorts/multi_level_merge.rs&lt;/code&gt; (1,897 lines)&#10;describes the approach:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Get the maximum amount of sorted in-memory streams and spill files we&#10;can merge with the available memory&lt;/li&gt;&#10;&lt;li&gt;Sort them to a sorted stream&lt;/li&gt;&#10;&lt;li&gt;Do we have more spill files to merge?&#10;&lt;ul&gt;&#10;&lt;li&gt;Yes: write that sorted stream to a spill file, add that spill file&#10;back to the spill files to merge and repeat the process&lt;/li&gt;&#10;&lt;li&gt;No: return that sorted stream as the final output stream&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;So it is a &lt;em&gt;tree&lt;/em&gt; of merges, but built lazily. The ASCII diagram in the&#10;source shows three phases:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;text&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Initial State: Multiple sorted streams + spill files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ┌───────────┐&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ Phase 1 │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └───────────┘&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ┌──Can hold in memory─┐&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ ┌──────────────┐ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ In-memory │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │sorted stream │──┼────────┐&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ 1 │ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └──────────────┘ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ ┌──────────────┐ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ In-memory │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │sorted stream │──┼────────┤&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ 2 │ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └──────────────┘ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ ┌──────────────┐ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ In-memory │ │ ┌───────────┐&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │sorted stream │──┼────────┤ │ Phase 2 │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ 3 │ │ │ └───────────┘&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └──────────────┘ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ ┌──────────────┐ │ │ ┌──Can hold in memory─┐&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ Sorted Spill │ │ │ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ │ file 1 │──┼────────┤ │ ┌──────────────┐ │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Phase 1 merges the three in-memory streams and the first two spill files&#10;into one sorted stream. That stream is written to a spill file. Phase 2&#10;merges the remaining spill files plus the new one. And so on until one&#10;pass remains.&lt;/p&gt;&#10;&lt;p&gt;Note that the &lt;em&gt;output&lt;/em&gt; is not produced until the final pass. All the&#10;intermediate passes are pure overhead in terms of I/O — and the code has&#10;a flag to avoid some of it:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Avoid merging more runs than needed before the final replay pass.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;size_intermediate_merges&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;h2 id="the-memory-strategy"&gt;The memory strategy&lt;/h2&gt;&#10;&lt;p&gt;The doc is unusually explicit about the memory management, and it reads&#10;like a list of hard-won lessons:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;This multi-level merge make sure that we can handle any amount of data to&#10;sort as long as we have enough memory to merge at least 2 streams at a&#10;time, even when individual record batches are skewed (very wide).&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Worst-Case Memory Reservation&lt;/strong&gt;: Reserves memory based on the largest&#10;batch size encountered in each spill file to merge, ensuring sufficient&#10;memory is always available during merge operations.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Adaptive Buffer Sizing&lt;/strong&gt;: Reduces buffer sizes when memory is&#10;constrained&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Spill-to-Disk&lt;/strong&gt;: Spill to disk when we cannot merge all files in&#10;memory&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Re-spilling Skewed Runs&lt;/strong&gt;: &amp;hellip;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Four strategies, and the fourth is the interesting one.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Worst-case reservation.&lt;/strong&gt; The merge must buffer one batch from each&#10;run. If a run&amp;rsquo;s batches vary in width, you must reserve for the &lt;em&gt;widest&lt;/em&gt;&#10;one, or the reservation will be wrong at the worst possible moment. So&#10;the reservation is computed from the maximum batch size in each file.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Adaptive buffer sizing.&lt;/strong&gt; When memory is tight, the merge reads&#10;&lt;em&gt;smaller&lt;/em&gt; buffers from each run. Smaller buffers mean a smaller&#10;per-stream reservation, which means more streams fit. It is a knob that&#10;trades I/O (more, smaller reads) for fan-in (more runs merged per pass).&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Spill to disk.&lt;/strong&gt; The obvious fallback.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Re-spilling skewed runs.&lt;/strong&gt; And then the case that none of the above&#10;solves.&lt;/p&gt;&#10;&lt;h2 id="the-skew-problem"&gt;The skew problem&lt;/h2&gt;&#10;&lt;p&gt;Consider a run whose batches are usually 8 MB, but which contains one&#10;batch of 400 MB — a single row with a huge string, or a wide&#10;non-compressible payload.&lt;/p&gt;&#10;&lt;p&gt;The merge must be able to hold &lt;em&gt;one batch from each run&lt;/em&gt;. If it is&#10;merging 2 runs, and one of them has a 400 MB batch, the reservation is&#10;400 MB + 8 MB. If the budget is 100 MB, the merge cannot even start with&#10;two streams.&lt;/p&gt;&#10;&lt;p&gt;And you cannot fix it by shrinking the read buffer: the batch is already&#10;&lt;em&gt;that&lt;/em&gt; wide on disk. Reading it in pieces would require slicing it, and&#10;the widest batch is a single row that cannot be split.&lt;/p&gt;&#10;&lt;p&gt;The solution in the source is remarkable:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&lt;strong&gt;Re-spilling Skewed Runs&lt;/strong&gt;: If even at the smallest read-buffer size we&#10;still cannot reserve memory for the minimum of 2 streams - because a&#10;single run&amp;rsquo;s largest batch is so wide that two streams&amp;rsquo; worth of&#10;reservation exceeds the budget - the larger of the two runs is re-spilled&#10;with each batch sliced in half. This shrinks its largest batch, lowering&#10;the per-stream reservation, and the merge pass is retried.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;So the operator &lt;em&gt;rewrites the offending run on disk&lt;/em&gt;, slicing its batches&#10;in half. A 400 MB batch becomes two 200 MB batches. The widest batch&#10;shrinks, the per-stream reservation shrinks, and the merge can proceed.&lt;/p&gt;&#10;&lt;p&gt;And the re-spilled run is tracked so the problem does not come back:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;The re-spilled run is tracked alongside a per-run batch-size limit equal&#10;to half the batch size it was written with, so any later merge that&#10;includes it caps its output batch size to match - otherwise the merged&#10;run could rebuild a full-size batch and reintroduce the skew.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;That is the subtle part. If you re-spill a run with halved batches but do&#10;not remember that, a &lt;em&gt;later&lt;/em&gt; merge pass could re-merge those halves back&#10;into a full-size batch, and the skew would return — possibly at a point&#10;where there is no memory left to re-spill it again. So the limit travels&#10;with the run:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Sorted runs still to be merged. Each run is paired with the batch-size limit a&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// merge consuming it must cap its output at. Runs written at the full batch size&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// carry `batch_size`. A run re-spilled smaller to resolve skew carries its halved&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// limit (see `Self::split_spill_file_in_half`). Tracking it here keeps this limit&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// out of the public `SortedSpillFile`, so no external caller has to set it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sorted_spill_files&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SortedSpillFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;And the last sentence of the doc explains the termination condition:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Crucially the global merge batch size is &lt;em&gt;not&lt;/em&gt; lowered, so re-spilling&#10;more than one run does not compound the reduction. If a batch cannot be&#10;split any further (a single row wider than the budget), the merge&#10;surfaces &lt;code&gt;ResourcesExhausted&lt;/code&gt; instead of looping forever.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Two guarantees:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The &lt;em&gt;global&lt;/em&gt; batch size is not reduced, so the halving does not&#10;compound across multiple runs. Each re-spill is relative to the&#10;original batch size, not to the current one.&lt;/li&gt;&#10;&lt;li&gt;If a batch cannot be split further — a single row that is wider than&#10;the whole budget — the merge gives up with a clear error rather than&#10;looping forever trying to halve a 1-row batch.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;That second point is the difference between a clever fallback and an&#10;infinite loop. Halving is only possible down to the row level, and the&#10;code knows where the floor is.&lt;/p&gt;&#10;&lt;h2 id="the-round-robin-tie-breaker"&gt;The round-robin tie-breaker&lt;/h2&gt;&#10;&lt;p&gt;One more detail in the struct:&lt;/p&gt;&#10;&lt;div class="code-block"&gt;&#10; &lt;div class="code-block-head"&gt;&#10; &lt;span class="code-lang"&gt;rust&lt;/span&gt;&#10; &lt;button class="code-copy" type="button" data-copy&gt;Copy&lt;/button&gt;&#10; &lt;/div&gt;&#10; &lt;div class="code-block-body"&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;enable_round_robin_tie_breaker&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;When two runs have rows that compare &lt;em&gt;equal&lt;/em&gt; under the sort ordering,&#10;which one comes first is arbitrary. A round-robin tie-breaker makes the&#10;output deterministic — a stable merge. It is off by default, presumably&#10;because it costs something and most queries do not care.&lt;/p&gt;&#10;&lt;h2 id="the-catch"&gt;The catch&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;The intermediate passes are pure I/O.&lt;/strong&gt; Every extra phase writes the&#10;whole data set to disk and reads it back. The &lt;code&gt;size_intermediate_merges&lt;/code&gt;&#10;flag exists to limit how many runs get merged before the final pass.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Re-spilling is expensive.&lt;/strong&gt; Rewriting a run to disk to halve its&#10;batches costs a full read and write of that run. It is a last resort,&#10;used only when the adaptive buffer sizing has run out of room.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The memory model is pessimistic.&lt;/strong&gt; Reserving for the &lt;em&gt;widest&lt;/em&gt; batch&#10;in each file means a single outlier batch makes every merge pass&#10;conservative. That is the safe choice, but it costs.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;The whole thing only matters at scale.&lt;/strong&gt; For a dataset that fits in&#10;memory, the plain &lt;code&gt;SortPreservingMerge&lt;/code&gt; is used and none of this code&#10;runs.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="why-it-matters"&gt;Why it matters&lt;/h2&gt;&#10;&lt;p&gt;This is the part of DataFusion that nobody sees until it saves them. A&#10;sort that does not fit in memory is a common failure mode in real&#10;deployments, and the difference between &amp;ldquo;the query fails with OOM&amp;rdquo; and&#10;&amp;ldquo;the query takes four times longer&amp;rdquo; is a lot of careful work.&lt;/p&gt;&#10;&lt;p&gt;The re-spill trick is the part I would point at. It is a genuinely&#10;creative answer to a genuinely awkward problem — one pathological batch&#10;can make an entire merge impossible — and the answer is to go back to&#10;disk and rewrite the data so the problem goes away. And the way it avoids&#10;the obvious follow-up bug (the skew coming back in a later pass) shows&#10;how carefully it was thought through.&lt;/p&gt;&#10;</content:encoded></item></channel></rss>