Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 98 additions & 14 deletions datafusion/physical-plan/src/sorts/sort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -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));
}

Expand All @@ -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));
}

Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)))
}

Expand All @@ -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)?;
Expand All @@ -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;
Comment on lines +1041 to +1042

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, my gc suggestion 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 skips gc, so all 8 chunks keep the buffer and each is charged for it. This fails at -m 256M, 512M and 1G with Not enough memory to continue external sort, and passes on main:

COPY (SELECT value % 1000003 AS a, value AS b,
             concat(repeat('x', 240), (value % 1000)::varchar) AS c
      FROM range(5000000)) TO 'shared.parquet' STORED AS PARQUET;
SET datafusion.execution.target_partitions = 1;
SELECT * FROM 'shared.parquet' ORDER BY a, b;

Could these runs keep batch_size? The helper below goes next to gc_view_arrays in spill/mod.rs, and sort.rs needs to import it.

Suggested change
// Set when `merge_run_batch_size` split this run
let split = batch_size < self.batch_size;
// Split batches of views sharing bytes would each be charged for them.
let batch_size = if views_share_bytes(&batch) {
self.batch_size
} else {
batch_size
};
// Set when `merge_run_batch_size` split this run
let split = batch_size < self.batch_size;
pub(crate) fn views_share_bytes(batch: &RecordBatch) -> bool {
    use arrow::array::AsArray;
    fn shares<T: ByteViewType>(array: &GenericByteViewArray<T>) -> bool {
        let (_, unique) = dedup_buffer_entries(array.data_buffers());
        array.total_buffer_bytes_used() > unique.iter().map(|b| b.len()).sum::<usize>()
    }
    batch.columns().iter().any(|array| match array.data_type() {
        DataType::Utf8View => shares(array.as_string_view()),
        DataType::BinaryView => shares(array.as_binary_view()),
        _ => false,
    })
}

Nested views already keep batch_size through sorted_copy_keeps_buffers, so only these two top-level types need the check. Could you add a test with a payload of cast(&dict, &DataType::Utf8View), using 8192 keys over 1024 values of 64 bytes? It should succeed with correct output. Skip the assert_released peak check there, because the grow in try_sort_and_spill_in_mem_batches goes over the pool on main too. Please also unit test the boundary: views_share_bytes is true for that array and false for 8192 values each used once.

Copy link
Copy Markdown
Contributor Author

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_bytes sits next to gc_view_arrays, and sort_run only checks it when the run would be split, so sorts without a memory limit don't scan the views. The dictionary-backed Utf8View payload is a case of test_merge_keys_larger_than_batches, without the pool checks as you suggested, and without the change it fails with Not enough memory to continue external sort. The boundary test is test_views_share_bytes in spill/mod.rs. Your query now passes at 256M, 512M and 1G with the same spill counts as main.


Ok(async move {
// Held until `batch` is sorted, when this block ends. The last run to
Expand All @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-chunk gc also causes a second failure, on clustered input. An output batch taken from one run holds 8 chunk buffers with no unused bytes, so gc skips it. After the spill is read back, each buffer is a slice of one IPC message and is charged for the whole message, so a batch's max_record_batch_memory reached 22.1 MB against 3.0 MB on main. This fails at -m 32M and 64M, and passes on main:

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 views_share_bytes change, all of my probes match or beat main, and all sorts:: and spill:: tests pass, including yours. The issue's reproducer still passes too.

--- 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 <= keeps gc away from views that share bytes, where it would copy once per row (#23564). Could you add a test with two Utf8View keys of unique 66-byte strings, prefixed by batch number so the runs don't overlap, in a pool of 8 buffered batches? It should succeed; today it fails in ExternalSorterMerge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied as suggested in 0938167, and the doc comment of compact_view_array now covers it. The two clustered Utf8View keys with room for 8 batches fail without it and pass now. Your query passes at 32M and 64M with the same 507 and 113 spills as main. Since this also changes spilling for other operators, I checked a GROUP BY on a Utf8View column at 64M and 128M, which spills the same as on main. A BinaryView payload, Utf8View keys with overlapping runs, shared and unique views in one table, nine Int64 keys and a single FixedSizeBinary(16) key also match or beat main.

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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading