comphead commented on code in PR #25428:
URL: https://github.com/apache/datafusion/pull/25428#discussion_r4088645393


##########
datafusion/physical-plan/src/sorts/multi_level_merge.rs:
##########
@@ -373,95 +424,163 @@ impl MultiLevelMergeBuilder {
                 let minimum_number_of_required_streams =
                     2_usize.saturating_sub(self.sorted_streams.len());
 
-                let (sorted_spill_files, buffer_size) = match self
-                    .get_sorted_spill_files_to_merge(
-                        2,
-                        // we must have at least 2 streams to merge
-                        minimum_number_of_required_streams,
-                        &mut memory_reservation,
-                        allow_minimum_without_headroom,
-                    )? {
-                    SpillFilesToMerge::Ready(sorted_spill_files, buffer_size) 
=> {
-                        (sorted_spill_files, buffer_size)
+                let selection = self.get_sorted_spill_files_to_merge(
+                    2,
+                    minimum_number_of_required_streams,
+                    &mut memory_reservation,
+                    allow_minimum_without_headroom,
+                )?;
+                let (mut spills, mut buffer_size) = match selection {
+                    SpillFilesToMerge::Ready(spills, buffer_size) => {
+                        (spills, buffer_size)
                     }
-                    // Not enough memory to seat 2 streams. Re-spill the 
blocking file
-                    // smaller and retry. `get_sorted_spill_files_to_merge` 
already freed
-                    // the reservation and `self.sorted_streams` is untouched, 
so the
-                    // retry starts clean.
                     SpillFilesToMerge::SplitThenRetry(index) => {
                         return Ok(MergeStep::SplitThenRetry(index));
                     }
                 };
 
-                // Don't account for existing streams memory
-                // as we are not holding the memory for them
-                let mut sorted_streams = mem::take(&mut self.sorted_streams);
-
-                let is_only_merging_memory_streams = 
sorted_spill_files.is_empty();
-
-                // If no spill files were selected (e.g. all too large for
-                // available memory but enough in-memory streams exist),
-                // return the pre-reserved bytes to self.reservation so
-                // create_new_merge_sort can transfer them to the merge
-                // stream's BatchBuilder.
-                if is_only_merging_memory_streams {
-                    mem::swap(&mut self.reservation, &mut memory_reservation);
+                let original_count = spills.len();
+                let original_buffer_size = buffer_size;
+                let original_memory = memory_reservation.size();
+                if self.reserve_replay_headroom
+                    && self.widen_intermediate_merges
+                    && !allow_minimum_without_headroom
+                    && buffer_size > 1
+                    && self.sorted_streams.is_empty()
+                    && !spills.is_empty()
+                {
+                    // Trade read-ahead for fan-in without taking any more pool
+                    // memory. Other partitions retain exactly the space left 
by
+                    // the original admission, even while they replay 
aggregates.
+                    // Keep one run for the final merge and its replay 
headroom.
+                    let candidates = spills
+                        .iter()
+                        .chain(&self.sorted_spill_files)
+                        .take(spills.len() + self.sorted_spill_files.len() - 1)
+                        .map(|(spill, _)| spill);
+                    let widened_count = spill_merge_memory_requirements(
+                        candidates,
+                        1,
+                        self.max_spill_merge_fan_in(),
+                    )
+                    .take_while(|needed| *needed <= original_memory)
+                    .count();
+                    if widened_count > original_count {
+                        buffer_size = 1;
+                        spills.extend(
+                            self.sorted_spill_files
+                                .drain(..widened_count - original_count),
+                        );
+                    }
                 }
+                let reservation = Arc::new(memory_reservation);
+                let widened = spills.len() > original_count;
+                let retry_reservation = widened.then(|| 
Arc::clone(&reservation));
+                let (stream, batch_size_limit) =
+                    self.merge_selected_runs(&spills, buffer_size, 
reservation, widened)?;
+                let retry = retry_reservation.map(|reservation| 
IntermediateMergeRetry {
+                    spills,
+                    original_count,
+                    buffer_size: original_buffer_size,
+                    reservation,
+                });
+                Ok(MergeStep::Stream {
+                    stream,
+                    batch_size_limit,
+                    retry,
+                })
+            }
+        }
+    }
 
-                // 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()
-                        .with_batch_read_buffer_capacity(buffer_size)
-                        .read_spill_as_stream(
-                            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(
+    /// Build a stream from an already admitted selection. The reservation can
+    /// also be held by a retry guard until an intermediate writer finishes.
+    /// `bound_batch_memory` requires spill-only inputs and a one-batch read 
buffer.
+    fn merge_selected_runs(
+        &mut self,
+        sorted_spill_files: &[(SortedSpillFile, usize)],
+        buffer_size: usize,
+        memory_reservation: Arc<MemoryReservation>,
+        bound_batch_memory: bool,
+    ) -> Result<(SendableRecordBatchStream, usize)> {
+        // Don't account for existing streams memory
+        // as we are not holding the memory for them
+        let mut sorted_streams = mem::take(&mut self.sorted_streams);
+        debug_assert!(!bound_batch_memory || sorted_streams.is_empty());
+        debug_assert!(!bound_batch_memory || buffer_size == 1);
+
+        let is_only_merging_memory_streams = sorted_spill_files.is_empty();
+
+        // If no spill files were selected (e.g. all too large for
+        // available memory but enough in-memory streams exist),
+        // return the pre-reserved bytes to self.reservation so
+        // create_new_merge_sort can transfer them to the merge
+        // stream's BatchBuilder.
+        if is_only_merging_memory_streams {
+            self.reservation = Arc::try_unwrap(memory_reservation)

Review Comment:
   `Arc<MemoryReservation>` here and `Option` in `StreamAttachedReservation` 
exist only for the retry, and they bring this `expect` into library code. A 
widened stream is always intermediate and is dropped at line 293 before the 
retry takes the grant. So `IntermediateMergeRetry` can own a plain 
`MemoryReservation`, and only non-widened streams need main's 
`StreamAttachedReservation`. The minimal fix is to handle `spills.is_empty()` 
in the caller before wrapping, as main did. That also removes the always-false 
`is_only_merging_memory_streams` argument at line 572.



##########
datafusion/physical-plan/src/sorts/multi_level_merge.rs:
##########
@@ -373,95 +424,163 @@ impl MultiLevelMergeBuilder {
                 let minimum_number_of_required_streams =
                     2_usize.saturating_sub(self.sorted_streams.len());
 
-                let (sorted_spill_files, buffer_size) = match self
-                    .get_sorted_spill_files_to_merge(
-                        2,
-                        // we must have at least 2 streams to merge
-                        minimum_number_of_required_streams,
-                        &mut memory_reservation,
-                        allow_minimum_without_headroom,
-                    )? {
-                    SpillFilesToMerge::Ready(sorted_spill_files, buffer_size) 
=> {
-                        (sorted_spill_files, buffer_size)
+                let selection = self.get_sorted_spill_files_to_merge(
+                    2,
+                    minimum_number_of_required_streams,
+                    &mut memory_reservation,
+                    allow_minimum_without_headroom,
+                )?;
+                let (mut spills, mut buffer_size) = match selection {
+                    SpillFilesToMerge::Ready(spills, buffer_size) => {
+                        (spills, buffer_size)
                     }
-                    // Not enough memory to seat 2 streams. Re-spill the 
blocking file
-                    // smaller and retry. `get_sorted_spill_files_to_merge` 
already freed
-                    // the reservation and `self.sorted_streams` is untouched, 
so the
-                    // retry starts clean.
                     SpillFilesToMerge::SplitThenRetry(index) => {
                         return Ok(MergeStep::SplitThenRetry(index));
                     }
                 };
 
-                // Don't account for existing streams memory
-                // as we are not holding the memory for them
-                let mut sorted_streams = mem::take(&mut self.sorted_streams);
-
-                let is_only_merging_memory_streams = 
sorted_spill_files.is_empty();
-
-                // If no spill files were selected (e.g. all too large for
-                // available memory but enough in-memory streams exist),
-                // return the pre-reserved bytes to self.reservation so
-                // create_new_merge_sort can transfer them to the merge
-                // stream's BatchBuilder.
-                if is_only_merging_memory_streams {
-                    mem::swap(&mut self.reservation, &mut memory_reservation);
+                let original_count = spills.len();
+                let original_buffer_size = buffer_size;
+                let original_memory = memory_reservation.size();
+                if self.reserve_replay_headroom
+                    && self.widen_intermediate_merges
+                    && !allow_minimum_without_headroom
+                    && buffer_size > 1
+                    && self.sorted_streams.is_empty()
+                    && !spills.is_empty()
+                {
+                    // Trade read-ahead for fan-in without taking any more pool
+                    // memory. Other partitions retain exactly the space left 
by
+                    // the original admission, even while they replay 
aggregates.
+                    // Keep one run for the final merge and its replay 
headroom.
+                    let candidates = spills
+                        .iter()
+                        .chain(&self.sorted_spill_files)
+                        .take(spills.len() + self.sorted_spill_files.len() - 1)
+                        .map(|(spill, _)| spill);
+                    let widened_count = spill_merge_memory_requirements(

Review Comment:
   This picks the widest selection that fits the grant, but rows rewritten only 
fall when widening removes a merge level. The 8 MiB trace in the description is 
the counterexample: 17 initial runs (19 to 18 files), one 14-way pass instead 
of two 7-way passes, the same 14 runs (1,720,320 rows) rewritten, at `log2(14)` 
versus `log2(7)` comparisons per row with half the read-ahead.
   
   Sizing the pass to leave `original_count` runs for the final merge needs 
only `17 - 7 + 1 = 11` inputs, roughly 21% fewer rewritten rows if runs are 
equal-sized (estimate, not measured). Velox uses the same rule in 
[`SpillPartition::createOrderedReader`](https://github.com/facebookincubator/velox/blob/1949993321536285fe3e23e346105b3f403edbbb/velox/exec/Spill.cpp#L519-L523):
 each round merges `min(numMaxMergeFiles, files + 1 - numMaxMergeFiles)` of the 
smallest files "to minimize IO". Capping the selection at `spills.len() + 
self.sorted_spill_files.len() - original_count + 1` also subsumes the `take(len 
- 1)` holdback, and keeps the 32-run fixture at four intermediate files. A 
benchmark with at least two intermediate levels would show whether widening 
pays off beyond that fixture.



##########
datafusion/physical-plan/src/sorts/merge.rs:
##########
@@ -284,6 +295,13 @@ impl<C: CursorValues> SortPreservingMergeStream<C> {
                         );
 
                         drop(timer);
+                        if self.flush_on_input_batch_boundary {

Review Comment:
   Re-checked at `7078ef7a7`. The conditional flush restores full batches for 
primitive and `Utf8` payloads, but not for `Utf8View` or `List`. Same 8 runs x 
10 batches x 8192 rows, `Int64` key plus one payload column, budget as 
`merge_selected_runs` builds it. Output batches, with full 8192-row batches in 
parentheses:
   
   | payload | in-memory inputs | spill-read inputs |
   | --- | ---: | ---: |
   | `Int64` | 81 (79) | 81 (79) |
   | `Utf8` | 89 (71) | 89 (71) |
   | `Utf8View` | 144 (70) | 147 (70) |
   | `List<Int64>` | 144 (70) | 147 (70) |
   
   The fallback arm of `should_flush_before_input` (`builder.rs:286-297`) 
charges each retained batch's whole column as future output on top of 
`batches_mem_used`. With one full batch retained per input, the check exceeds 
`2 * sum(max)` at nearly every boundary. Arrow 60 materializes much less. 
`interleave_views` allocates only the 16-byte views and reuses the input data 
buffers, `interleave_list` copies only the selected child ranges, and the spill 
writer's `gc_view_arrays` then keeps only referenced bytes. Spill-read inputs 
add a second overcount. Every buffer is a slice of one IPC body, so 
`get_buffer_memory_size()` reports the body `capacity()` once per buffer (2x 
the batch here), while `get_record_batch_memory_size` counts that allocation 
once.
   
   `Utf8View` keys (the Parquet default through `schema_force_view_types`) and 
`List` states (`ARRAY_AGG`) are the usual aggregate spill schemas. 
`intermediate_merge_keeps_full_output_batches` only covers one in-memory 
`Int64` column. The admission-side option in the `memory_limit` thread avoids 
this estimator entirely.
   



##########
datafusion/physical-plan/src/sorts/multi_level_merge.rs:
##########
@@ -373,95 +424,158 @@ impl MultiLevelMergeBuilder {
                 let minimum_number_of_required_streams =
                     2_usize.saturating_sub(self.sorted_streams.len());
 
-                let (sorted_spill_files, buffer_size) = match self
-                    .get_sorted_spill_files_to_merge(
-                        2,
-                        // we must have at least 2 streams to merge
-                        minimum_number_of_required_streams,
-                        &mut memory_reservation,
-                        allow_minimum_without_headroom,
-                    )? {
-                    SpillFilesToMerge::Ready(sorted_spill_files, buffer_size) 
=> {
-                        (sorted_spill_files, buffer_size)
+                let selection = self.get_sorted_spill_files_to_merge(
+                    2,
+                    minimum_number_of_required_streams,
+                    &mut memory_reservation,
+                    allow_minimum_without_headroom,
+                )?;
+                let (mut spills, mut buffer_size) = match selection {
+                    SpillFilesToMerge::Ready(spills, buffer_size) => {
+                        (spills, buffer_size)
                     }
-                    // Not enough memory to seat 2 streams. Re-spill the 
blocking file
-                    // smaller and retry. `get_sorted_spill_files_to_merge` 
already freed
-                    // the reservation and `self.sorted_streams` is untouched, 
so the
-                    // retry starts clean.
                     SpillFilesToMerge::SplitThenRetry(index) => {
                         return Ok(MergeStep::SplitThenRetry(index));
                     }
                 };
 
-                // Don't account for existing streams memory
-                // as we are not holding the memory for them
-                let mut sorted_streams = mem::take(&mut self.sorted_streams);
-
-                let is_only_merging_memory_streams = 
sorted_spill_files.is_empty();
-
-                // If no spill files were selected (e.g. all too large for
-                // available memory but enough in-memory streams exist),
-                // return the pre-reserved bytes to self.reservation so
-                // create_new_merge_sort can transfer them to the merge
-                // stream's BatchBuilder.
-                if is_only_merging_memory_streams {
-                    mem::swap(&mut self.reservation, &mut memory_reservation);
+                let original_count = spills.len();
+                let original_buffer_size = buffer_size;
+                let original_memory = memory_reservation.size();
+                if self.reserve_replay_headroom
+                    && self.widen_intermediate_merges
+                    && !allow_minimum_without_headroom
+                    && buffer_size > 1
+                    && self.sorted_streams.is_empty()
+                    && !spills.is_empty()
+                {
+                    // Trade read-ahead for fan-in without taking any more pool
+                    // memory. Other partitions retain exactly the space left 
by
+                    // the original admission, even while they replay 
aggregates.
+                    // Keep one run for the final merge and its replay 
headroom.
+                    let candidates = spills
+                        .iter()
+                        .chain(&self.sorted_spill_files)
+                        .take(spills.len() + self.sorted_spill_files.len() - 1)
+                        .map(|(spill, _)| spill);
+                    let widened_count = spill_merge_memory_requirements(
+                        candidates,
+                        1,
+                        self.max_spill_merge_fan_in(),
+                    )
+                    .take_while(|needed| *needed <= original_memory)
+                    .count();
+                    if widened_count > original_count {
+                        buffer_size = 1;
+                        spills.extend(
+                            self.sorted_spill_files
+                                .drain(..widened_count - original_count),
+                        );
+                    }
                 }
+                let reservation = Arc::new(memory_reservation);
+                let widened = spills.len() > original_count;
+                let retry_reservation = widened.then(|| 
Arc::clone(&reservation));
+                let (stream, batch_size_limit) =
+                    self.merge_selected_runs(&spills, buffer_size, 
reservation, widened)?;
+                let retry = retry_reservation.map(|reservation| 
IntermediateMergeRetry {
+                    spills,
+                    original_count,
+                    buffer_size: original_buffer_size,
+                    reservation,
+                });
+                Ok(MergeStep::Stream {
+                    stream,
+                    batch_size_limit,
+                    retry,
+                })
+            }
+        }
+    }
 
-                // 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()
-                        .with_batch_read_buffer_capacity(buffer_size)
-                        .read_spill_as_stream(
-                            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(
+    /// Build a stream from an already admitted selection. The reservation can
+    /// also be held by a retry guard until an intermediate writer finishes.
+    fn merge_selected_runs(
+        &mut self,
+        sorted_spill_files: &[(SortedSpillFile, usize)],
+        buffer_size: usize,
+        memory_reservation: Arc<MemoryReservation>,
+        bound_batch_memory: bool,
+    ) -> Result<(SendableRecordBatchStream, usize)> {
+        // Don't account for existing streams memory
+        // as we are not holding the memory for them
+        let mut sorted_streams = mem::take(&mut self.sorted_streams);
+
+        let is_only_merging_memory_streams = sorted_spill_files.is_empty();
+
+        // If no spill files were selected (e.g. all too large for
+        // available memory but enough in-memory streams exist),
+        // return the pre-reserved bytes to self.reservation so
+        // create_new_merge_sort can transfer them to the merge
+        // stream's BatchBuilder.
+        if is_only_merging_memory_streams {
+            self.reservation = Arc::try_unwrap(memory_reservation)
+                .expect("in-memory merges do not retain a spill retry 
reservation");
+            return Ok((
+                self.create_new_merge_sort(
                     sorted_streams,
-                    // 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
-                // as it's empty
-                if is_only_merging_memory_streams {
-                    assert_eq!(
-                        memory_reservation.size(),
-                        0,
-                        "when only merging memory streams, we should not have 
any memory reservation and let the merge sort handle the memory"
-                    );
+                    true,
+                    self.batch_size,
+                    None,
+                )?,
+                self.batch_size,
+            ));
+        }
 
-                    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 {
-                        stream: Box::pin(StreamAttachedReservation::new(
-                            merge_sort_stream,
-                            memory_reservation,
-                        )),
-                        batch_size_limit: output_batch_size,
-                    })
-                }
-            }
+        // 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()
+                .with_batch_read_buffer_capacity(buffer_size)
+                .read_spill_as_stream(
+                    Arc::clone(&spill.file),
+                    Some(spill.max_record_batch_memory),
+                )?;
+            output_batch_size = output_batch_size.min(*batch_size_limit);
+            sorted_streams.push(stream);
         }
+        let batch_memory_budget = bound_batch_memory.then(|| {

Review Comment:
   This equivalence also assumes the read buffer is empty. On a multi-thread 
runtime `read_spill_as_stream` goes through `spawn_buffered(stream, 1)`, which 
keeps one batch in the channel besides the one `BatchBuilder` holds. At read 
buffer 1 the 2x per run is already spent on read-ahead plus the source batch, 
so the output share this budget assumes is unfunded. At read buffer 2 the same 
model leaves a batch of slack per run.
   
   Peak live bytes under a counting global allocator. 10 runs x 10 batches x 
65,536 `Int64` rows, pool 40x, `multi_thread`, 20x grant in every row:
   
   | selection | inputs | peak / grant |
   | --- | ---: | ---: |
   | narrow, read buffer 2 (main) | 5 | 1.29 |
   | widened (this PR) | 9 | 1.74 |
   | widened, no batch budget | 9 | 1.80 |
   | widened at 3x per run, no batch budget | 6 | 1.23 |
   | widened unbuffered, no batch budget | 9 | 1.24 |
   
   The pool reservation is unchanged, but live memory is about 34% above the 
narrow pass, and the pool cannot see the difference. Pricing the read-ahead 
slot at admission (3x per run at read buffer 1), or reading widened inputs with 
`read_spill_as_stream_unbuffered` at 2x, keeps the pass under main's footprint 
with plain `batch_size` flushing and full output batches. 
`MergeBatchMemoryBudget`, `should_flush_before_input` and 
`discard_consumed_batches` could then go, together with the `Utf8View`/`List` 
fragmentation. The unbuffered option gives up all read-ahead, and I have not 
measured its timing cost. For comparison, Velox allocates its spill read 
buffers, read-ahead included, from the memory pool 
([`FileInputStream`](https://github.com/facebookincubator/velox/blob/1949993321536285fe3e23e346105b3f403edbbb/velox/common/file/FileInputStream.cpp#L33-L35)),
 and StarRocks divides a fixed read-buffer budget by the merge fan-in and turns 
buffering off below `spill_read_buffer_min_bytes` ([`
 
build_ordered_stream`](https://github.com/StarRocks/starrocks/blob/1f855238747001d0c37d9b0c53c45f81dd7612c2/be/src/compute_env/spill/block_group.cpp#L112-L121)).
   
   The tests cannot observe this. They assert pool reservations, which are 
unchanged, and `replay_merge_fixture` runs are single batches from 
`make_sorted_spill_file` on a current-thread runtime, where `spawn_buffered` 
adds no channel.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to