Repository navigation
fix: split multi-column sort runs into smaller batches under a memory limit #26953
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a5ba530
f21eacc
6eef7cb
8eb93eb
b29d5b0
0e71291
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,10 +30,12 @@ use crate::metrics::{ | |
| }; | ||
| use crate::projection::{ProjectionExec, make_with_child, update_ordering}; | ||
| use crate::sorts::IncrementalSortIterator; | ||
| use crate::sorts::streaming_merge::{SortedSpillFile, StreamingMergeBuilder}; | ||
| use crate::spill::get_record_batch_memory_size; | ||
| use crate::sorts::streaming_merge::{ | ||
| SortedSpillFile, StreamingMergeBuilder, merges_in_row_format, | ||
| }; | ||
| use crate::spill::in_progress_spill_file::InProgressSpillFile; | ||
| use crate::spill::spill_manager::{GetSlicedSize, SpillManager}; | ||
| use crate::spill::{gc_view_arrays, get_record_batch_memory_size, views_share_bytes}; | ||
| use crate::statistics::{ChildStats, StatisticsArgs, with_per_partition_fetch}; | ||
| use crate::stream::{ObservedStream, RecordBatchStreamAdapter}; | ||
| use crate::topk::TopK; | ||
|
|
@@ -59,12 +61,12 @@ use datafusion_common::{ | |
| use datafusion_common_runtime::SpawnedTask; | ||
| use datafusion_execution::TaskContext; | ||
| use datafusion_execution::memory_pool::{ | ||
| MemoryConsumer, MemoryPool, MemoryReservation, MergeMemoryPool, | ||
| MemoryConsumer, MemoryLimit, MemoryPool, MemoryReservation, MergeMemoryPool, | ||
| }; | ||
| use datafusion_execution::runtime_env::RuntimeEnv; | ||
| use datafusion_physical_expr::LexOrdering; | ||
| use datafusion_physical_expr::PhysicalExpr; | ||
| use datafusion_physical_expr::expressions::{DynamicFilterPhysicalExpr, lit}; | ||
| use datafusion_physical_expr::expressions::{Column, DynamicFilterPhysicalExpr, lit}; | ||
|
|
||
| use futures::{Stream, StreamExt, TryStreamExt}; | ||
| use log::{debug, trace}; | ||
|
|
@@ -707,7 +709,8 @@ impl ExternalSorter { | |
| if self.in_mem_batches.len() == 1 { | ||
| let batch = self.in_mem_batches.swap_remove(0); | ||
| let reservation = self.reservation.take(); | ||
| let sorted_stream = self.sort_batch_stream(batch, reservation, None)?; | ||
| let sorted_stream = | ||
| self.sort_batch_stream(batch, reservation, None, self.batch_size)?; | ||
| return Ok(self.observe_if_output(sorted_stream, is_output_stream)); | ||
| } | ||
|
|
||
|
|
@@ -724,7 +727,8 @@ impl ExternalSorter { | |
| .try_resize(get_reserved_bytes_for_record_batch(&batch)?) | ||
| .map_err(Self::err_with_oom_context)?; | ||
| let reservation = self.reservation.take(); | ||
| let sorted_stream = self.sort_batch_stream(batch, reservation, None)?; | ||
| let sorted_stream = | ||
| self.sort_batch_stream(batch, reservation, None, self.batch_size)?; | ||
| return Ok(self.observe_if_output(sorted_stream, is_output_stream)); | ||
| } | ||
|
|
||
|
|
@@ -786,7 +790,13 @@ impl ExternalSorter { | |
| .enumerate() | ||
| .map(move |(index, (batch, size))| { | ||
| let reservation = self.reservation.split(size); | ||
| let sort = self.sort_run(batch, reservation, shared_input.clone())?; | ||
| let batch_size = self.merge_run_batch_size(&batch)?; | ||
| let sort = self.sort_run( | ||
| batch, | ||
| reservation, | ||
| shared_input.clone(), | ||
| batch_size, | ||
| )?; | ||
| let permits = Arc::clone(&concurrency.permits); | ||
| Ok::<_, DataFusionError>(async move { | ||
| let stream = Self::prepare_sort_run(sort, permits).await?; | ||
|
|
@@ -972,7 +982,8 @@ impl ExternalSorter { | |
| .zip(sizes) | ||
| .map(|(run, size)| { | ||
| let reservation = self.reservation.split(size); | ||
| self.sort_batch_stream(run, reservation, shared_input.clone()) | ||
| let batch_size = self.merge_run_batch_size(&run)?; | ||
| self.sort_batch_stream(run, reservation, shared_input.clone(), batch_size) | ||
| }) | ||
| .collect() | ||
| } | ||
|
|
@@ -995,11 +1006,11 @@ impl ExternalSorter { | |
| batch: RecordBatch, | ||
| reservation: MemoryReservation, | ||
| shared_input: Option<Arc<MemoryReservation>>, | ||
| batch_size: usize, | ||
| ) -> Result<SendableRecordBatchStream> { | ||
| let schema = batch.schema(); | ||
| let stream = | ||
| futures::stream::once(self.sort_run(batch, reservation, shared_input)?) | ||
| .try_flatten(); | ||
| let sort = self.sort_run(batch, reservation, shared_input, batch_size)?; | ||
| let stream = futures::stream::once(sort).try_flatten(); | ||
| Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream))) | ||
| } | ||
|
|
||
|
|
@@ -1012,6 +1023,7 @@ impl ExternalSorter { | |
| batch: RecordBatch, | ||
| reservation: MemoryReservation, | ||
| shared_input: Option<Arc<MemoryReservation>>, | ||
| batch_size: usize, | ||
| ) -> Result<impl Future<Output = Result<SendableRecordBatchStream>> + Send + use<>> | ||
| { | ||
| let reserved_bytes = get_reserved_bytes_for_record_batch(&batch)?; | ||
|
|
@@ -1024,9 +1036,10 @@ impl ExternalSorter { | |
| } | ||
|
|
||
| let expressions = self.expr.clone(); | ||
| let batch_size = self.batch_size; | ||
| let merge_pool = Arc::clone(&self.merge_pool); | ||
| let elapsed_compute = self.metrics.baseline.elapsed_compute().clone(); | ||
| // Set when `merge_run_batch_size` split this run | ||
| let split = batch_size < self.batch_size; | ||
|
|
||
| Ok(async move { | ||
| // Held until `batch` is sorted, when this block ends. The last run to | ||
|
|
@@ -1037,7 +1050,16 @@ impl ExternalSorter { | |
| let schema = batch.schema(); | ||
|
|
||
| // Sort the batch immediately and get all output batches | ||
| let sorted_batches = sort_batch_chunked(&batch, &expressions, batch_size)?; | ||
| let mut sorted_batches = | ||
| sort_batch_chunked(&batch, &expressions, batch_size)?; | ||
| // `take` keeps the data buffers of view arrays, so splitting would | ||
| // charge every batch for all of them. | ||
| if split && sorted_batches.len() > 1 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The per-chunk COPY (SELECT value / 1000 AS a, value AS b,
concat(repeat('x', 60), value::varchar) AS c,
concat(repeat('y', 60), value::varchar) AS d,
concat(repeat('z', 60), value::varchar) AS e
FROM range(5000000)) TO 'clustered3.parquet' STORED AS PARQUET;
SET datafusion.execution.target_partitions = 1;
SELECT * FROM 'clustered3.parquet' ORDER BY a, b;Could spilling compact these arrays to one buffer? With this and the --- a/datafusion/physical-plan/src/spill/mod.rs
+++ b/datafusion/physical-plan/src/spill/mod.rs
@@ fn compact_view_array
- if should_gc_view_array(array, unique_size) {
+ // Several buffers read back as slices of one IPC message, each charged for all of it
+ if should_gc_view_array(array, unique_size)
+ || (unique.len() > 1 && array.total_buffer_bytes_used() <= unique_size)
+ {
return Some(array.gc());
}The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Applied as suggested in 0938167, and the doc comment of |
||
| sorted_batches = sorted_batches | ||
| .iter() | ||
| .map(gc_view_arrays) | ||
| .collect::<Result<_>>()?; | ||
| } | ||
|
|
||
| // Chunked output can retain shared buffers in every batch and | ||
| // exceed the input estimate. Borrow only already-reserved spill | ||
|
|
@@ -1068,6 +1090,67 @@ impl ExternalSorter { | |
| }) | ||
| } | ||
|
|
||
| /// Batch size of the sorted run `run` when it feeds a merge. A merge in the | ||
| /// row format holds the keys of every run's current batch at once. The sort | ||
| /// reserves about twice the run, so the keys fit next to the sorted copy if | ||
| /// they are at most as large as the run. Otherwise, with a memory limit, the | ||
| /// run uses an eighth of `batch_size`, but at least 1024 rows to keep the | ||
| /// per-batch merge cost small. The merge output still uses `batch_size`. | ||
| /// | ||
| /// Runs keep `batch_size` when their batches would share buffers other than | ||
| /// the data of top-level view columns, such as dictionary values, or when the | ||
| /// views of `run` share bytes, since every batch is charged for them. | ||
| fn merge_run_batch_size(&self, run: &RecordBatch) -> Result<usize> { | ||
| let limited = !matches!( | ||
| self.runtime.memory_pool.memory_limit(), | ||
| MemoryLimit::Infinite | ||
| ); | ||
| let shares_buffers = self.schema.fields().iter().any(|field| { | ||
| let data_type = field.data_type(); | ||
| sorted_copy_keeps_buffers(data_type) | ||
| && !matches!(data_type, DataType::Utf8View | DataType::BinaryView) | ||
| }); | ||
| if limited | ||
| && !shares_buffers | ||
| && merges_in_row_format(&self.expr, &self.schema) | ||
| && self.encoded_keys_may_exceed(run)? | ||
| && !views_share_bytes(run) | ||
| { | ||
| Ok((self.batch_size / 8).max(1024).min(self.batch_size)) | ||
| } else { | ||
| Ok(self.batch_size) | ||
| } | ||
| } | ||
|
|
||
| /// Returns whether the row-format keys of `run` may be larger than `run`. A | ||
| /// computed key is sized from its type if that has a fixed width, and | ||
| /// otherwise counts as larger, since sizing it would evaluate it again. | ||
| fn encoded_keys_may_exceed(&self, run: &RecordBatch) -> Result<bool> { | ||
| // An 8-byte offset per row, then a null byte and the bytes of each key | ||
| let mut encoded = run.num_rows() * size_of::<usize>(); | ||
| for sort_expr in self.expr.iter() { | ||
| let key_bytes = match sort_expr.expr.downcast_ref::<Column>() { | ||
| Some(column) => run | ||
| .column(column.index()) | ||
| .to_data() | ||
| .get_slice_memory_size()?, | ||
| None => { | ||
| let width = sort_expr | ||
| .expr | ||
| .data_type(self.schema.as_ref()) | ||
| .ok() | ||
| .and_then(|data_type| data_type.primitive_width()); | ||
| let Some(width) = width else { | ||
| return Ok(true); | ||
| }; | ||
| run.num_rows() * width | ||
| } | ||
| }; | ||
| encoded += run.num_rows() + key_bytes; | ||
| } | ||
| Ok(encoded > run.get_sliced_size()?) | ||
| } | ||
|
|
||
| /// If this sort may spill, pre-allocates | ||
| /// `sort_spill_reservation_bytes` of memory to guarantee memory | ||
| /// left for the in memory sort/merge. | ||
|
|
@@ -2601,7 +2684,8 @@ mod tests { | |
| let reservation = sorter.reservation.new_empty(); | ||
| reservation.try_grow(get_reserved_bytes_for_record_batch(&batch)?)?; | ||
| let before = elapsed_compute.value(); | ||
| let sort = sorter.sort_run(batch.clone(), reservation, None)?; | ||
| let sort = | ||
| sorter.sort_run(batch.clone(), reservation, None, sorter.batch_size)?; | ||
| assert_eq!(elapsed_compute.value(), before); | ||
|
|
||
| let stream = if spawned { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, my
gcsuggestion misses views that share bytes, which is what dictionary-encoded Parquet strings produce. A chunk that would write at least as much as the buffer holds skipsgc, so all 8 chunks keep the buffer and each is charged for it. This fails at-m 256M,512Mand1GwithNot enough memory to continue external sort, and passes onmain:Could these runs keep
batch_size? The helper below goes next togc_view_arraysinspill/mod.rs, andsort.rsneeds to import it.Nested views already keep
batch_sizethroughsorted_copy_keeps_buffers, so only these two top-level types need the check. Could you add a test with a payload ofcast(&dict, &DataType::Utf8View), using 8192 keys over 1024 values of 64 bytes? It should succeed with correct output. Skip theassert_releasedpeak check there, because thegrowintry_sort_and_spill_in_mem_batchesgoes over the pool onmaintoo. Please also unit test the boundary:views_share_bytesistruefor that array andfalsefor 8192 values each used once.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, applied in 0938167.
views_share_bytessits next togc_view_arrays, andsort_runonly checks it when the run would be split, so sorts without a memory limit don't scan the views. The dictionary-backedUtf8Viewpayload is a case oftest_merge_keys_larger_than_batches, without the pool checks as you suggested, and without the change it fails withNot enough memory to continue external sort. The boundary test istest_views_share_bytesinspill/mod.rs. Your query now passes at 256M, 512M and 1G with the same spill counts asmain.