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


##########
datafusion/physical-plan/src/sorts/sort.rs:
##########
@@ -852,60 +851,93 @@ impl ExternalSorter {
         Box::pin(RecordBatchStreamAdapter::new(schema, batches))
     }
 
-    /// Concatenates `batches` into fewer, larger runs, each bounded by
-    /// `sort_in_place_threshold_bytes`, to reduce merge fan-in. 
`self.reservation`
-    /// is resized to the coalesced footprint so the caller's per-run splits 
stay
-    /// exact.
+    /// Tries to concatenate `batches` into runs of at least `batch_size` rows,
+    /// except for the final run. With `coalesce_large_runs`,
+    /// runs also grow toward `sort_in_place_threshold_bytes` to reduce merge 
fan-in.
+    /// `self.reservation` is resized to the coalesced footprint so the 
caller's
+    /// per-run splits stay exact.
+    /// Keeps the original batches if concatenation fails or the memory pool
+    /// cannot accommodate the combined footprint.
     fn coalesce_in_mem_batches_into_runs(
         &mut self,
         batches: Vec<RecordBatch>,
+        coalesce_large_runs: bool,
     ) -> Result<Vec<RecordBatch>> {
+        if !coalesce_large_runs
+            && batches
+                .iter()
+                .all(|batch| batch.num_rows() >= self.batch_size)
+        {
+            return Ok(batches);
+        }
         let target = self.sort_in_place_threshold_bytes.max(1);
         let mut runs: Vec<RecordBatch> = Vec::new();
-        let mut group: Vec<RecordBatch> = Vec::new();
+        let mut group: Vec<&RecordBatch> = Vec::new();

Review Comment:
   `group` now borrows from `batches` so that the whole input can be returned 
on failure. That keeps every original batch alive until the last group has been 
copied. On `main`, each group's originals were dropped right after its 
`concat_batches`, so the transient peak was one group, not the whole buffer.
   
   The copies are also not reserved before they are made. On the spill path the 
pool is full by construction. Any growth in footprint (for example the aliased 
columns in `test_small_run_coalescing_falls_back_when_memory_is_full`) is only 
caught by the final `try_resize`. By then the whole buffer has been copied, and 
all of it is thrown away.
   
   Owning the group and falling back per group bounds both:
   
   ```rust
   let flush = |group: &mut Vec<RecordBatch>, runs: &mut Vec<RecordBatch>| {
       if group.len() > 1
           && let Ok(run) = concat_batches(&schema, group.iter())
       {
           group.clear();
           runs.push(run);
       } else {
           runs.append(group);
       }
   };
   ```
   
   If the group's copy is reserved with `try_grow` before concatenating, and 
the group is kept as is when that fails, the final realign becomes a shrink. 
This also removes both `return Ok(batches)` paths and the 
`pop().cloned().unwrap()`.



##########
datafusion/physical-plan/src/sorts/sort.rs:
##########
@@ -852,60 +851,93 @@ impl ExternalSorter {
         Box::pin(RecordBatchStreamAdapter::new(schema, batches))
     }
 
-    /// Concatenates `batches` into fewer, larger runs, each bounded by
-    /// `sort_in_place_threshold_bytes`, to reduce merge fan-in. 
`self.reservation`
-    /// is resized to the coalesced footprint so the caller's per-run splits 
stay
-    /// exact.
+    /// Tries to concatenate `batches` into runs of at least `batch_size` rows,
+    /// except for the final run. With `coalesce_large_runs`,
+    /// runs also grow toward `sort_in_place_threshold_bytes` to reduce merge 
fan-in.
+    /// `self.reservation` is resized to the coalesced footprint so the 
caller's
+    /// per-run splits stay exact.
+    /// Keeps the original batches if concatenation fails or the memory pool
+    /// cannot accommodate the combined footprint.
     fn coalesce_in_mem_batches_into_runs(
         &mut self,
         batches: Vec<RecordBatch>,
+        coalesce_large_runs: bool,
     ) -> Result<Vec<RecordBatch>> {
+        if !coalesce_large_runs
+            && batches
+                .iter()
+                .all(|batch| batch.num_rows() >= self.batch_size)
+        {
+            return Ok(batches);
+        }
         let target = self.sort_in_place_threshold_bytes.max(1);
         let mut runs: Vec<RecordBatch> = Vec::new();
-        let mut group: Vec<RecordBatch> = Vec::new();
+        let mut group: Vec<&RecordBatch> = Vec::new();
+        let mut group_rows = 0usize;
         let mut group_bytes = 0usize;
         // Count the buffers the batches share as when they were buffered
         let mut counter = self.new_buffered_batches_counter();
 
         // Flush a group into a run, skipping the copy for a single-batch 
group.
-        let flush = |group: &mut Vec<RecordBatch>,
+        let flush = |group: &mut Vec<&RecordBatch>,
                      runs: &mut Vec<RecordBatch>,
                      schema: &SchemaRef|
          -> Result<()> {
             match group.len() {
                 0 => {}
-                1 => runs.push(group.pop().unwrap()),
+                1 => runs.push(group.pop().cloned().unwrap()),
                 _ => {
-                    runs.push(concat_batches(schema, group.iter())?);
+                    runs.push(concat_batches(schema, group.iter().copied())?);
                     group.clear();
                 }
             }
             Ok(())
         };
 
-        for batch in batches {
-            let bytes =
-                get_reserved_bytes_for_next_record_batch(&batch, 
counter.as_mut())?;
-            if !group.is_empty() && group_bytes.saturating_add(bytes) > target 
{
-                flush(&mut group, &mut runs, &self.schema)?;
+        // Keep the input batches until their replacement fits the reservation.
+        // Concatenation can need more memory when it breaks shared input 
buffers.
+        // Array offset and dictionary-key limits can also reject a combined 
run.
+        for batch in &batches {
+            let bytes = if coalesce_large_runs {
+                get_reserved_bytes_for_next_record_batch(batch, 
counter.as_mut())?
+            } else {
+                0
+            };
+            if group_rows >= self.batch_size
+                && (!coalesce_large_runs || group_bytes.saturating_add(bytes) 
> target)
+            {
+                if flush(&mut group, &mut runs, &self.schema).is_err() {

Review Comment:
   Two points on this fallback:
   
   1. `is_err()` swallows every error, not only the offset and dictionary-key 
limits named in the comment above. Matching the Arrow overflow variants 
(`DictionaryKeyOverflowError`, `OffsetOverflowError`) would keep real failures 
visible.
   2. The in-place path in `in_mem_sort_stream` still calls `concat_batches` on 
all buffered batches. With the default `sort_in_place_threshold_bytes` (1 MiB), 
the input from `test_small_run_coalescing_falls_back_on_dictionary_overflow` 
takes that path and fails with `Arrow error: Dictionary key bigger than the key 
type`. I checked this on `main`, and this PR doesn't change that path. The test 
only passes because it sets the threshold to 0. Either fall through to the run 
path when that concat fails, or narrow the dictionary claim in the description 
and leave the rest as a follow-up.



##########
datafusion/physical-plan/src/sorts/sort.rs:
##########
@@ -852,60 +851,93 @@ impl ExternalSorter {
         Box::pin(RecordBatchStreamAdapter::new(schema, batches))
     }
 
-    /// Concatenates `batches` into fewer, larger runs, each bounded by
-    /// `sort_in_place_threshold_bytes`, to reduce merge fan-in. 
`self.reservation`
-    /// is resized to the coalesced footprint so the caller's per-run splits 
stay
-    /// exact.
+    /// Tries to concatenate `batches` into runs of at least `batch_size` rows,
+    /// except for the final run. With `coalesce_large_runs`,
+    /// runs also grow toward `sort_in_place_threshold_bytes` to reduce merge 
fan-in.
+    /// `self.reservation` is resized to the coalesced footprint so the 
caller's
+    /// per-run splits stay exact.
+    /// Keeps the original batches if concatenation fails or the memory pool
+    /// cannot accommodate the combined footprint.
     fn coalesce_in_mem_batches_into_runs(
         &mut self,
         batches: Vec<RecordBatch>,
+        coalesce_large_runs: bool,
     ) -> Result<Vec<RecordBatch>> {
+        if !coalesce_large_runs
+            && batches
+                .iter()
+                .all(|batch| batch.num_rows() >= self.batch_size)
+        {
+            return Ok(batches);
+        }
         let target = self.sort_in_place_threshold_bytes.max(1);
         let mut runs: Vec<RecordBatch> = Vec::new();
-        let mut group: Vec<RecordBatch> = Vec::new();
+        let mut group: Vec<&RecordBatch> = Vec::new();
+        let mut group_rows = 0usize;
         let mut group_bytes = 0usize;
         // Count the buffers the batches share as when they were buffered
         let mut counter = self.new_buffered_batches_counter();
 
         // Flush a group into a run, skipping the copy for a single-batch 
group.
-        let flush = |group: &mut Vec<RecordBatch>,
+        let flush = |group: &mut Vec<&RecordBatch>,
                      runs: &mut Vec<RecordBatch>,
                      schema: &SchemaRef|
          -> Result<()> {
             match group.len() {
                 0 => {}
-                1 => runs.push(group.pop().unwrap()),
+                1 => runs.push(group.pop().cloned().unwrap()),
                 _ => {
-                    runs.push(concat_batches(schema, group.iter())?);
+                    runs.push(concat_batches(schema, group.iter().copied())?);
                     group.clear();
                 }
             }
             Ok(())
         };
 
-        for batch in batches {
-            let bytes =
-                get_reserved_bytes_for_next_record_batch(&batch, 
counter.as_mut())?;
-            if !group.is_empty() && group_bytes.saturating_add(bytes) > target 
{
-                flush(&mut group, &mut runs, &self.schema)?;
+        // Keep the input batches until their replacement fits the reservation.
+        // Concatenation can need more memory when it breaks shared input 
buffers.
+        // Array offset and dictionary-key limits can also reject a combined 
run.
+        for batch in &batches {
+            let bytes = if coalesce_large_runs {
+                get_reserved_bytes_for_next_record_batch(batch, 
counter.as_mut())?
+            } else {
+                0
+            };
+            if group_rows >= self.batch_size
+                && (!coalesce_large_runs || group_bytes.saturating_add(bytes) 
> target)

Review Comment:
   A full batch that follows a short group gets absorbed and copied. With 
`batch_size = 4`, `[2] [4]` becomes one 6-row run, built by copying the 4-row 
batch. Outside large-run mode, pushing batches with `num_rows() >= batch_size` 
straight to `runs` avoids that copy. Run order doesn't matter, since equal keys 
have no ordering guarantee.
   
   With that change, the `all(..)` pre-scan at the top is no longer needed 
either. An all-full input copies nothing, so a `copied` flag that skips the 
realign pass at the end covers the same fast path in one traversal.



-- 
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