This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 21986cf76d fix: Batch size limit in re-spill compounds (#23286)
21986cf76d is described below

commit 21986cf76d35cab60a6b7d0c334689486ddb2492
Author: Emily Matheys <[email protected]>
AuthorDate: Mon Jul 13 16:42:15 2026 +0300

    fix: Batch size limit in re-spill compounds (#23286)
    
    This fixes an issue in my previous design for the re-spill in sort -
    whenever we half a stream, we half self.batch_size, even if its not the
    same stream being halved, so for each re-spill we get a smaller and
    smaller output batch, which really hurts performance in the merge.
    We can avoid it by just keeping the batch_size_limit in the spilled file
    struct. then we just use the lowest limit whenever we merge N streams,
    making it dynamic and much more robust.
    
    Has tests.
    
    Does not break API
    
    ---------
    
    Co-authored-by: Raz Luvaton <[email protected]>
---
 .../physical-plan/src/sorts/multi_level_merge.rs   | 265 ++++++++++++++++-----
 1 file changed, 200 insertions(+), 65 deletions(-)

diff --git a/datafusion/physical-plan/src/sorts/multi_level_merge.rs 
b/datafusion/physical-plan/src/sorts/multi_level_merge.rs
index 4d108ac046..3ec52cc70c 100644
--- a/datafusion/physical-plan/src/sorts/multi_level_merge.rs
+++ b/datafusion/physical-plan/src/sorts/multi_level_merge.rs
@@ -131,14 +131,23 @@ use futures::{Stream, StreamExt};
 ///    reserve memory for the minimum of 2 streams - because a single run's 
largest batch is so
 ///    wide that two streams' worth of reservation exceeds the budget - the 
larger of the two
 ///    runs is re-spilled with each batch sliced in half. This shrinks its 
largest batch,
-///    lowering the per-stream reservation, and the merge pass is retried. The 
merge output
-///    batch size is halved as well so the merged run cannot rebuild a 
full-size batch and
-///    reintroduce the skew. If a batch cannot be split any further (a single 
row wider than the
-///    budget), the merge surfaces `ResourcesExhausted` instead of looping 
forever.
+///    lowering the per-stream reservation, and the merge pass is retried. The 
re-spilled run
+///    is tracked alongside a per-run batch-size limit equal to half the batch 
size it was
+///    written with, so any later merge that includes it caps its output batch 
size to match -
+///    otherwise the merged run could rebuild a full-size batch and 
reintroduce the skew.
+///    Crucially the global merge batch size is *not* lowered, so re-spilling 
more than one run
+///    does not compound the reduction. If a batch cannot be split any further 
(a single row
+///    wider than the budget), the merge surfaces `ResourcesExhausted` instead 
of looping
+///    forever.
 pub(crate) struct MultiLevelMergeBuilder {
     spill_manager: SpillManager,
     schema: SchemaRef,
-    sorted_spill_files: Vec<SortedSpillFile>,
+    /// Sorted runs still to be merged. Each run is paired with the batch-size 
limit a
+    /// merge consuming it must cap its output at. Runs written at the full 
batch size
+    /// carry `batch_size`. A run re-spilled smaller to resolve skew carries 
its halved
+    /// limit (see [`Self::split_spill_file_in_half`]). Tracking it here keeps 
this limit
+    /// out of the public [`SortedSpillFile`], so no external caller has to 
set it.
+    sorted_spill_files: Vec<(SortedSpillFile, usize)>,
     sorted_streams: Vec<SendableRecordBatchStream>,
     expr: LexOrdering,
     metrics: BaselineMetrics,
@@ -171,7 +180,12 @@ impl MultiLevelMergeBuilder {
         Self {
             spill_manager,
             schema,
-            sorted_spill_files,
+            // Initial runs are written at the full batch size, so they impose 
no cap
+            // on later merges - record `batch_size` as their (unconstrained) 
limit.
+            sorted_spill_files: sorted_spill_files
+                .into_iter()
+                .map(|file| (file, batch_size))
+                .collect(),
             sorted_streams,
             expr,
             metrics,
@@ -191,17 +205,22 @@ impl MultiLevelMergeBuilder {
 
     async fn create_stream(mut self) -> Result<SendableRecordBatchStream> {
         loop {
-            let mut stream = match self.merge_sorted_runs_within_mem_limit()? {
-                MergeStep::Stream(stream) => stream,
-                MergeStep::SplitThenRetry(index) => {
-                    // Couldn't reserve memory for the minimum of 2 streams. 
Re-spill the
-                    // larger of the two we're trying to merge with half its 
batch size so
-                    // its largest batch shrinks, lowering the per-stream 
reservation, then
-                    // retry. Makes the merge resilient to skewed (very wide) 
rows.
-                    self.split_spill_file_in_half(index).await?;
-                    continue;
-                }
-            };
+            let (mut stream, batch_size_limit) =
+                match self.merge_sorted_runs_within_mem_limit()? {
+                    MergeStep::Stream {
+                        stream,
+                        batch_size_limit,
+                    } => (stream, batch_size_limit),
+                    MergeStep::SplitThenRetry(index) => {
+                        // Couldn't reserve memory for the minimum of 2 
streams. Re-spill
+                        // the larger of the two we're trying to merge with 
half its batch
+                        // size so its largest batch shrinks, lowering the 
per-stream
+                        // reservation, then retry. Makes the merge resilient 
to skewed
+                        // (very wide) rows.
+                        self.split_spill_file_in_half(index).await?;
+                        continue;
+                    }
+                };
 
             // TODO - add a threshold for number of files to disk even if 
empty and reading from disk so
             //        we can avoid the memory reservation
@@ -229,11 +248,17 @@ impl MultiLevelMergeBuilder {
                 continue;
             };
 
-            // Add the spill file
-            self.sorted_spill_files.push(SortedSpillFile {
-                file: spill_file,
-                max_record_batch_memory,
-            });
+            // Add the spill file paired with the batch-size limit of the 
merge that
+            // produced it: if that merge consumed a shrunk (skew-resolved) 
run, its
+            // output was capped and this intermediate run is likewise capped, 
so a
+            // later pass that re-merges it won't rebuild an oversized batch.
+            self.sorted_spill_files.push((
+                SortedSpillFile {
+                    file: spill_file,
+                    max_record_batch_memory,
+                },
+                batch_size_limit,
+            ));
         }
     }
 
@@ -245,37 +270,52 @@ impl MultiLevelMergeBuilder {
             (0, 0) => {
                 let empty_stream =
                     
Box::pin(EmptyRecordBatchStream::new(Arc::clone(&self.schema)));
-                Ok(MergeStep::Stream(self.observe_output(empty_stream)))
+                Ok(MergeStep::Stream {
+                    stream: self.observe_output(empty_stream),
+                    batch_size_limit: self.batch_size,
+                })
             }
 
             // Only in-memory stream, return that
             (0, 1) => {
                 let output_stream = self.sorted_streams.remove(0);
-                Ok(MergeStep::Stream(self.observe_output(output_stream)))
+                Ok(MergeStep::Stream {
+                    stream: self.observe_output(output_stream),
+                    batch_size_limit: self.batch_size,
+                })
             }
 
             // Only single sorted spill file so return it
             (1, 0) => {
-                let spill_file = self.sorted_spill_files.remove(0);
+                let (spill_file, batch_size) = 
self.sorted_spill_files.remove(0);
 
                 // Not reserving any memory for this disk as we are not 
holding it in memory
                 let output_stream = self
                     .spill_manager
                     .read_spill_as_stream(spill_file.file, None)?;
 
-                Ok(MergeStep::Stream(self.observe_output(output_stream)))
+                Ok(MergeStep::Stream {
+                    stream: self.observe_output(output_stream),
+                    batch_size_limit: batch_size,
+                })
             }
 
-            // Only in memory streams, so merge them all in a single pass
+            // Only in memory streams, so merge them all in a single pass. 
In-memory
+            // runs are never shrunk for skew, so this merge runs at the full 
batch
+            // size and its output carries no limit.
             (0, _) => {
                 let sorted_stream = mem::take(&mut self.sorted_streams);
                 // No need to wrap with observed stream since merge sort will 
update the observed metrics
-                Ok(MergeStep::Stream(self.create_new_merge_sort(
-                    sorted_stream,
-                    // If we have no sorted spill files left, this is the last 
run
-                    true,
-                    true,
-                )?))
+                Ok(MergeStep::Stream {
+                    stream: self.create_new_merge_sort(
+                        sorted_stream,
+                        // If we have no sorted spill files left, this is the 
last run
+                        true,
+                        true,
+                        self.batch_size,
+                    )?,
+                    batch_size_limit: self.batch_size,
+                })
             }
 
             // Need to merge multiple streams
@@ -326,7 +366,15 @@ impl MultiLevelMergeBuilder {
                     mem::swap(&mut self.reservation, &mut memory_reservation);
                 }
 
-                for spill in sorted_spill_files {
+                // Cap the merge output at the smallest limit among the runs 
we're
+                // about to merge. Runs that were shrunk for skew carry a 
smaller limit,
+                // if none do, every run carries `self.batch_size` and the 
merge runs at
+                // the full batch size. The output stream is tagged with the 
same limit
+                // (see the `MergeStep::Stream` returns below) so a re-spilled
+                // intermediate run stays shrunk and won't rebuild an 
oversized batch on
+                // a later pass.
+                let mut output_batch_size = self.batch_size;
+                for (spill, batch_size_limit) in sorted_spill_files {
                     let stream = self
                         .spill_manager
                         .clone()
@@ -335,6 +383,7 @@ impl MultiLevelMergeBuilder {
                             spill.file,
                             Some(spill.max_record_batch_memory),
                         )?;
+                    output_batch_size = 
output_batch_size.min(batch_size_limit);
                     sorted_streams.push(stream);
                 }
                 let merge_sort_stream = self.create_new_merge_sort(
@@ -342,6 +391,7 @@ impl MultiLevelMergeBuilder {
                     // If we have no sorted spill files left, this is the last 
run
                     self.sorted_spill_files.is_empty(),
                     is_only_merging_memory_streams,
+                    output_batch_size,
                 )?;
 
                 // If we're only merging memory streams, we don't need to 
attach the memory reservation
@@ -353,14 +403,20 @@ impl MultiLevelMergeBuilder {
                         "when only merging memory streams, we should not have 
any memory reservation and let the merge sort handle the memory"
                     );
 
-                    Ok(MergeStep::Stream(merge_sort_stream))
+                    Ok(MergeStep::Stream {
+                        stream: merge_sort_stream,
+                        batch_size_limit: output_batch_size,
+                    })
                 } else {
                     // Attach the memory reservation to the stream to make 
sure we have enough memory
                     // throughout the merge process as we bypassed the memory 
pool for the merge sort stream
-                    
Ok(MergeStep::Stream(Box::pin(StreamAttachedReservation::new(
-                        merge_sort_stream,
-                        memory_reservation,
-                    ))))
+                    Ok(MergeStep::Stream {
+                        stream: Box::pin(StreamAttachedReservation::new(
+                            merge_sort_stream,
+                            memory_reservation,
+                        )),
+                        batch_size_limit: output_batch_size,
+                    })
                 }
             }
         }
@@ -371,11 +427,12 @@ impl MultiLevelMergeBuilder {
         streams: Vec<SendableRecordBatchStream>,
         is_output: bool,
         all_in_memory: bool,
+        output_batch_size: usize,
     ) -> Result<SendableRecordBatchStream> {
         let mut builder = StreamingMergeBuilder::new()
             .with_schema(Arc::clone(&self.schema))
             .with_expressions(&self.expr)
-            .with_batch_size(self.batch_size)
+            .with_batch_size(output_batch_size)
             .with_fetch(self.fetch)
             .with_metrics(if is_output {
                 // Only add the metrics to the last run
@@ -427,7 +484,7 @@ impl MultiLevelMergeBuilder {
         // allocation, preventing starvation under memory pressure.
         let mut total_needed: usize = 0;
 
-        for spill in &self.sorted_spill_files {
+        for (spill, _) in &self.sorted_spill_files {
             if number_of_spills_to_read_for_current_phase >= max_spill_files {
                 break;
             }
@@ -478,8 +535,8 @@ impl MultiLevelMergeBuilder {
                         // of them with a smaller batch size and retry, the 
smaller max
                         // batch lowers the per-stream reservation enough to 
seat both.
                         let split_index = usize::from(
-                            self.sorted_spill_files[1].max_record_batch_memory
-                                > 
self.sorted_spill_files[0].max_record_batch_memory,
+                            
self.sorted_spill_files[1].0.max_record_batch_memory
+                                > 
self.sorted_spill_files[0].0.max_record_batch_memory,
                         );
                         return 
Ok(SpillFilesToMerge::SplitThenRetry(split_index));
                     }
@@ -501,26 +558,33 @@ impl MultiLevelMergeBuilder {
 
     /// Re-spill the spill file at `index` with half its batch size, putting 
it back
     /// at the same position. We read the file back and re-spill it through 
the normal
-    /// spill API (which owns batch layout).
-    /// Slicing each batch in two halves the largest written batch,
-    /// which lowers the per-stream merge reservation so the
-    /// next attempt can seat both streams. One stream's worth of memory is 
reserved
-    /// for the duration and freed afterwards. Makes the merge resilient to 
skew.
+    /// spill API (which owns batch layout), slicing every batch in two, which 
halves
+    /// the largest written batch and so lowers the per-stream merge 
reservation enough
+    /// for the next attempt to seat both streams. One stream's worth of 
memory is
+    /// reserved for the duration and freed afterwards. Makes the merge 
resilient to skew.
+    ///
+    /// Instead of halving the *global* merge batch size (which would compound 
when more
+    /// than one run is re-spilled), the shrunk run records its own smaller 
batch-size
+    /// limit (tracked alongside the run in `sorted_spill_files`), so only 
merges that
+    /// actually consume it pay the reduced batch size.
     async fn split_spill_file_in_half(&mut self, index: usize) -> Result<()> {
         log::debug!(
             "2 spilled streams could not be loaded into memory for merge \
         (requires 2x of the largest batch from both), re-spilling the larger 
of the two with half \
-        the batch size to reduce memory needs for the next merge attempt, \
-        setting batch_size to half to proceed with merge"
+        the batch size to reduce memory needs for the next merge attempt. the 
shrunk run carries \
+        a halved batch-size limit so only merges consuming it use the smaller 
batch size"
         );
 
         // Extract the target in O(1) instead of `remove(index)`, which would 
shift
         // every following spill file. Swap it to the back and pop it; the 
matching
         // swap after re-spilling restores the original order, so the vec ends 
up
         // exactly as it started, just with the target file shrunk.
+        // `old_batch_size` is the batch size this run was written with (the 
full merge
+        // batch size unless it was already shrunk once). Halving it caps the 
next merge
+        // that reads this run so the merged output can't rebuild a full-size 
batch.
         let last = self.sorted_spill_files.len() - 1;
         self.sorted_spill_files.swap(index, last);
-        let target = self
+        let (target, old_batch_size) = self
             .sorted_spill_files
             .pop()
             .expect("index is in bounds, so the vec is non-empty");
@@ -574,18 +638,21 @@ impl MultiLevelMergeBuilder {
             );
         }
 
-        // Also halve the merge output batch size so the next merge pass emits
-        // narrower batches. Otherwise the merged stream would rebuild a 
full-size
-        // (potentially giant) batch and, when spilled back as an intermediate 
run,
-        // reintroduce the exact skew we just resolved.
-        self.batch_size = (self.batch_size / 2).max(1);
+        // Record the halved batch size as a *per-run* limit rather than 
lowering the
+        // global batch size. Merges that don't touch this run keep the full 
batch
+        // size. a merge that reads it caps its output at this limit so the 
merged run
+        // can't rebuild a full-size batch and reintroduce the skew.
+        let new_batch_size_limit = (old_batch_size / 2).max(1);
 
         // Push the re-spilled (smaller) file and swap it back into `index`, 
undoing
         // the swap-to-back above so the order is preserved.
-        self.sorted_spill_files.push(SortedSpillFile {
-            file,
-            max_record_batch_memory: new_max,
-        });
+        self.sorted_spill_files.push((
+            SortedSpillFile {
+                file,
+                max_record_batch_memory: new_max,
+            },
+            new_batch_size_limit,
+        ));
         let last = self.sorted_spill_files.len() - 1;
         self.sorted_spill_files.swap(index, last);
 
@@ -602,8 +669,9 @@ impl MultiLevelMergeBuilder {
 
 /// Outcome of trying to reserve memory for one multi-level merge pass.
 enum SpillFilesToMerge {
-    /// Enough memory: the spill files to read this pass and the read-ahead 
buffer size.
-    Ready(Vec<SortedSpillFile>, usize),
+    /// Enough memory: the spill files to read this pass (each paired with its
+    /// batch-size limit) and the read-ahead buffer size.
+    Ready(Vec<(SortedSpillFile, usize)>, usize),
     /// Could not seat the minimum of 2 streams. Re-spill the spill file at 
this index
     /// with a smaller (halved) batch size, then retry the pass.
     SplitThenRetry(usize),
@@ -612,7 +680,15 @@ enum SpillFilesToMerge {
 /// What one iteration of the multi-level merge loop should do next.
 enum MergeStep {
     /// A merged stream is ready to be consumed (and possibly spilled back).
-    Stream(SendableRecordBatchStream),
+    Stream {
+        stream: SendableRecordBatchStream,
+        /// The batch-size limit to stamp on the run if this stream is 
re-spilled as an
+        /// intermediate result: the batch size its merge ran at. It equals 
the full
+        /// merge batch size unless the merge consumed a skew-resolved run, in 
which
+        /// case it is that run's smaller limit so the re-spilled result stays 
capped
+        /// and can't rebuild an oversized batch.
+        batch_size_limit: usize,
+    },
     /// Re-spill the spill file at this index smaller, then retry the merge 
step.
     SplitThenRetry(usize),
 }
@@ -879,8 +955,10 @@ mod tests {
         let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
         assert_eq!(total_rows, (2 * n) as usize);
 
-        // The largest emitted batch is the halved size, not the original 8192 
—
-        // without halving `self.batch_size` the merge would rebuild 8192-row 
batches.
+        // The largest emitted batch is the halved size, not the original 
8192: the
+        // shrunk run carries a halved batch-size limit, and the final pass 
consumes
+        // it, so the merge output is capped there. Without the per-run limit 
the merge
+        // would rebuild 8192-row batches.
         let expected_batch_size = initial_batch_size / 2;
         let max_batch_rows = batches.iter().map(|b| 
b.num_rows()).max().unwrap_or(0);
         assert_eq!(
@@ -891,6 +969,63 @@ mod tests {
 
         Ok(())
     }
+
+    /// Same as [`respill_halves_the_merge_output_batch_size`], but under a 
budget tight
+    /// enough that *both* runs must be re-spilled before the merge fits - the 
scenario
+    /// where the batch-size reduction could compound. Because the reduction 
is tracked
+    /// per-run (each run capped at half) rather than by halving the global 
batch size on
+    /// every split, the merged output is emitted in 4096-row batches - half, 
not a
+    /// quarter. A global-halving implementation would have halved once per 
re-spill and
+    /// emitted 2048-row batches.
+    #[tokio::test]
+    async fn respilling_two_skewed_runs_halves_the_output_without_compounding()
+    -> Result<()> {
+        let env = Arc::new(RuntimeEnv::default());
+        let schema = test_schema();
+        let spill_manager = build_spill_manager(&env, &schema);
+
+        let n: i64 = 16384;
+        let f0 = make_sorted_spill_file(&spill_manager, &schema, 
(0..n).collect());
+        let f1 = make_sorted_spill_file(&spill_manager, &schema, 
(0..n).collect());
+        let m = f0.max_record_batch_memory.max(f1.max_record_batch_memory);
+
+        // 2.5*m is tight enough that even after halving one run the two still 
don't
+        // fit, so *both* runs are re-spilled once before the merge succeeds. 
(3.5*m,
+        // as in the single-split test, would let the pair fit after one 
split.) This
+        // is exactly the scenario where a compounding, global-halving 
implementation
+        // would drive the output batch size down to a quarter.
+        let initial_batch_size = 8192;
+        let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(m * 5 / 
2));
+
+        let builder = build_merge_builder(
+            spill_manager,
+            Arc::clone(&schema),
+            vec![f0, f1],
+            &pool,
+            initial_batch_size,
+        );
+        let stream = builder.create_spillable_merge_stream();
+        let batches: Vec<RecordBatch> = stream.try_collect().await?;
+
+        // All rows are still present.
+        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+        assert_eq!(total_rows, (2 * n) as usize);
+
+        // Each run was re-spilled once, so each is capped at half the 
original batch
+        // size and the merge caps its output at that half - NOT a quarter. A 
global
+        // halving-per-split implementation would have emitted 2048-row 
batches here.
+        let expected_batch_size = initial_batch_size / 2;
+        let max_batch_rows = batches.iter().map(|b| 
b.num_rows()).max().unwrap_or(0);
+        assert_eq!(
+            max_batch_rows, expected_batch_size,
+            "two re-spills must halve (not quarter) the output: expected \
+             {expected_batch_size}-row batches, got a largest batch of \
+             {max_batch_rows} rows"
+        );
+
+        Ok(())
+    }
+
     #[test]
     fn spill_merge_fan_in_is_unlimited_by_default() {
         assert_eq!(effective_spill_merge_fan_in(0), usize::MAX);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to