-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix: reduce peak memory usage when round robin tiebreaker is disabled #23606
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
Changes from all commits
3109bdb
d0518a7
4ec6618
c05609d
ed55e65
72eb210
80bec04
4bf99ec
fa82f5a
4aaf8dd
a5f0136
c1d3bf5
79bda67
4e5ba44
468f0da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ use std::sync::Arc; | |
| use crate::fuzz_cases::aggregate_fuzz::assert_spill_count_metric; | ||
| use crate::fuzz_cases::once_exec::OnceExec; | ||
| use arrow::array::UInt64Array; | ||
| use arrow::row::{RowConverter, SortField}; | ||
| use arrow::{array::StringArray, compute::SortOptions, record_batch::RecordBatch}; | ||
| use arrow_schema::{DataType, Field, Schema}; | ||
| use datafusion::common::Result; | ||
|
|
@@ -45,9 +46,19 @@ use datafusion_physical_plan::aggregates::{ | |
| AggregateExec, AggregateMode, PhysicalGroupBy, | ||
| }; | ||
| use datafusion_physical_plan::metrics::MetricValue; | ||
| use datafusion_physical_plan::spill::get_record_batch_memory_size; | ||
| use datafusion_physical_plan::stream::RecordBatchStreamAdapter; | ||
| use futures::StreamExt; | ||
|
|
||
| use arrow::array::Int32Array; | ||
| use datafusion::datasource::memory::MemorySourceConfig; | ||
| use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec; | ||
| use datafusion_execution::memory_pool::{ | ||
| MemoryPool, PeakRecordingPool, UnboundedMemoryPool, | ||
| }; | ||
| use datafusion_physical_plan::metrics::{ExecutionPlanMetricsSet, SpillMetrics}; | ||
| use datafusion_physical_plan::spill::SpillManager; | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_with_limited_memory() -> Result<()> { | ||
| let record_batch_size = 8192; | ||
|
|
@@ -290,6 +301,250 @@ async fn test_sort_with_limited_memory_and_oversized_record_batch() -> Result<() | |
| Ok(()) | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_preserving_merge_peak_memory_with_spilled_input_round_robin() | ||
| -> Result<()> { | ||
| run_sort_preserving_merge_peak_memory_with_spilled_input(true, false, false).await | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_preserving_merge_peak_memory_with_spilled_input_no_round_robin() | ||
| -> Result<()> { | ||
| run_sort_preserving_merge_peak_memory_with_spilled_input(false, false, false).await | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_preserving_merge_peak_memory_with_spilled_input_round_robin_multi_column() | ||
| -> Result<()> { | ||
| run_sort_preserving_merge_peak_memory_with_spilled_input(true, true, false).await | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_preserving_merge_peak_memory_with_spilled_input_no_round_robin_multi_column() | ||
| -> Result<()> { | ||
| run_sort_preserving_merge_peak_memory_with_spilled_input(false, true, false).await | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_preserving_merge_peak_memory_with_spilled_input_round_robin_tied_values() | ||
| -> Result<()> { | ||
| run_sort_preserving_merge_peak_memory_with_spilled_input(true, false, true).await | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_sort_preserving_merge_peak_memory_with_spilled_input_no_round_robin_tied_values() | ||
| -> Result<()> { | ||
| run_sort_preserving_merge_peak_memory_with_spilled_input(false, false, true).await | ||
| } | ||
|
|
||
| /// Intended to measure the maximum number of record batches held in memory by | ||
| /// the SortPreservingMergeStream in a convoluted way by measuring the peak | ||
| /// memory reservation. Relevant for merging spilled streams, where the produced | ||
| /// record batches suffer from the following issue: | ||
| /// https://github.com/apache/arrow-rs/issues/6363 | ||
| /// | ||
| /// After an IPC roundtrip, all columns in a [`RecordBatch`] share a single | ||
| /// parent buffer. It causes the memory reservation to be inflated, but the | ||
| /// bigger issue is the increase in the peak allocated memory caused by | ||
| /// prev_cursors in SortPreservingMergeExec. The increase is caused by the fact | ||
| /// that the FieldCursor inside prev_cursors holds a reference for the entire | ||
| /// Buffer allocated for the input record batch, preventing it from being | ||
| /// dropped and thus increasing the number of concomitent input record batches | ||
| /// living during the merging phase | ||
| async fn run_sort_preserving_merge_peak_memory_with_spilled_input( | ||
| round_robin: bool, | ||
| multi_column_sort: bool, | ||
| tied_values: bool, | ||
| ) -> Result<()> { | ||
| let num_batches = 10usize; | ||
| let num_rows_per_batch = 100usize; | ||
| // payload is ~100x larger than the sort key (i32 = 4 bytes, string ≈ 400 bytes) | ||
| let large_string = "x".repeat(400); | ||
|
|
||
| let schema = Arc::new(Schema::new(vec![ | ||
| Field::new("sort_key", DataType::Int32, false), | ||
| Field::new("payload", DataType::Utf8, false), | ||
| ])); | ||
|
|
||
| // Unbounded env used only for spilling the input; the merge runs under its | ||
| // own pool below. | ||
| let spill_env = Arc::new(RuntimeEnvBuilder::new().build()?); | ||
|
|
||
| let mut partition_batches: Vec<Vec<RecordBatch>> = Vec::new(); | ||
|
|
||
| for stream_idx in 0..2usize { | ||
| // Each stream covers a non-overlapping key range so both are individually | ||
| // sorted: stream 0 → [0, 1000), stream 1 → [1000, 2000). When | ||
| // `tied_values` is set, every row of every batch in both streams | ||
| // instead carries the same sort key, so every comparison between the | ||
| // two streams is a tie. | ||
| let batches: Vec<RecordBatch> = (0..num_batches) | ||
| .map(|b| { | ||
| // Interleave streams: stream 0 → even slots [0,200,400,...], | ||
| // stream 1 → odd slots [100,300,500,...] so the merge | ||
| // alternates between them on every batch. | ||
| let base = ((b * 2 + stream_idx) * num_rows_per_batch) as i32; | ||
|
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. Could we add a small test where both input streams contain the same sort values across multiple batches?
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. added a test where the sort column is a constant value across all batches |
||
| let sort_col: Int32Array = if tied_values { | ||
| std::iter::repeat_n(0, num_rows_per_batch).collect() | ||
| } else { | ||
| (base..base + num_rows_per_batch as i32).collect() | ||
| }; | ||
| let payload_col: StringArray = | ||
| std::iter::repeat_n(large_string.as_str(), num_rows_per_batch) | ||
| .map(Some) | ||
| .collect(); | ||
| RecordBatch::try_new( | ||
| Arc::clone(&schema), | ||
| vec![Arc::new(sort_col), Arc::new(payload_col)], | ||
| ) | ||
| .unwrap() | ||
| }) | ||
| .collect(); | ||
|
|
||
| // Spill to disk then read back: each RecordBatch is now IPC-backed, | ||
| // meaning all columns share a single parent buffer. As a result, | ||
| // get_buffer_memory_size() on the sort_key column returns the full | ||
| // parent-buffer capacity (≈ batch size of both columns combined) rather | ||
| // than just the key data (num_rows * 4 bytes). | ||
| let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0); | ||
| let manager = | ||
| SpillManager::new(Arc::clone(&spill_env), metrics, Arc::clone(&schema)); | ||
| let spill_file = manager | ||
| .spill_record_batch_and_finish(&batches, "stream")? | ||
| .expect("non-empty input should produce a spill file"); | ||
|
|
||
| let mut stream = manager.read_spill_as_stream(spill_file, None)?; | ||
| let mut ipc_batches: Vec<RecordBatch> = Vec::new(); | ||
| while let Some(batch) = stream.next().await { | ||
| ipc_batches.push(batch?); | ||
| } | ||
| partition_batches.push(ipc_batches); | ||
| } | ||
|
|
||
| let ipc_batch_size = get_record_batch_memory_size(&partition_batches[0][0]); | ||
|
|
||
| // Build a 2-partition plan from the IPC-recovered batches. | ||
| let input = | ||
| MemorySourceConfig::try_new_exec(&partition_batches, Arc::clone(&schema), None)?; | ||
|
|
||
| let sort_key_expr = PhysicalSortExpr { | ||
| expr: col("sort_key", &schema)?, | ||
| options: SortOptions { | ||
| descending: false, | ||
| nulls_first: true, | ||
| }, | ||
| }; | ||
| // `payload` has the same value in every row, so adding it as a secondary | ||
| // sort key doesn't change the resulting order — it only forces the merge | ||
| // onto the row-oriented (`RowValues`/`RowCursorStream`) comparison path | ||
| // used whenever more than one sort expression is present. | ||
| let mut sort_exprs = vec![sort_key_expr]; | ||
| if multi_column_sort { | ||
| sort_exprs.push(PhysicalSortExpr { | ||
| expr: col("payload", &schema)?, | ||
| options: SortOptions { | ||
| descending: false, | ||
| nulls_first: true, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // When sorting by more than one column, the merge switches to the | ||
| // row-oriented `RowValues`/`RowCursorStream` path | ||
| // | ||
| // `RowCursorStream` also tracks one *shared* (not per-partition) | ||
| // reservation sized to `converter.size()` (`stream.rs`: | ||
| // `self.reservation.try_resize(self.converter.size())`) — the | ||
| // `RowConverter`'s own fixed internal state, separate from the `Rows` | ||
| // it produces per batch. | ||
| let (row_batch_size, converter_size) = if multi_column_sort { | ||
| let sort_fields = sort_exprs | ||
| .iter() | ||
| .map(|s| { | ||
| let data_type = s.expr.data_type(&schema)?; | ||
| Ok(SortField::new_with_options(data_type, s.options)) | ||
| }) | ||
| .collect::<Result<Vec<_>>>()?; | ||
| let converter = RowConverter::new(sort_fields)?; | ||
| let cols = sort_exprs | ||
| .iter() | ||
| .map(|s| { | ||
| s.expr | ||
| .evaluate(&partition_batches[0][0])? | ||
| .into_array(partition_batches[0][0].num_rows()) | ||
| }) | ||
| .collect::<Result<Vec<_>>>()?; | ||
| let rows = converter.convert_columns(&cols)?; | ||
| (rows.size(), converter.size()) | ||
| } else { | ||
| (0, 0) | ||
| }; | ||
|
|
||
| let merge = Arc::new( | ||
| SortPreservingMergeExec::new(LexOrdering::new(sort_exprs).unwrap(), input) | ||
| .with_round_robin_repartition(round_robin), | ||
| ); | ||
|
|
||
| // PeakRecordingPool records peak reserved bytes as a running high-water mark | ||
| // (via grow/shrink deltas), independent of any per-consumer registration | ||
| // bookkeeping - unlike TrackConsumersPool, whose tracked-consumer entry (and | ||
| // its peak) gets discarded the moment the consumer unregisters, which now | ||
| // happens mid-poll (inside the drain loop below) rather than when the | ||
| // caller eventually drops the returned stream. | ||
| let tracking_pool = Arc::new(PeakRecordingPool::new(Arc::new( | ||
| UnboundedMemoryPool::default(), | ||
| ))); | ||
| let runtime = RuntimeEnvBuilder::new() | ||
| .with_memory_pool(Arc::clone(&tracking_pool) as Arc<dyn MemoryPool>) | ||
| .build()?; | ||
| let task_ctx = Arc::new( | ||
| TaskContext::default() | ||
| .with_session_config(SessionConfig::new().with_batch_size(num_rows_per_batch)) | ||
| .with_runtime(Arc::new(runtime)), | ||
| ); | ||
|
|
||
| let mut output = merge.execute(0, task_ctx)?; | ||
| let mut total_rows = 0usize; | ||
| while let Some(batch) = output.next().await { | ||
| total_rows += batch?.num_rows(); | ||
| } | ||
| assert_eq!(total_rows, 2 * num_batches * num_rows_per_batch); | ||
|
|
||
| let peak_bytes = tracking_pool.peak_reserved(); | ||
|
|
||
| // in the single column case, the cursor takes up an ipc_batch_size worth of memory due to the | ||
| // IPC roundtrip issue | ||
| // for the multi-column case, we've calculated row_batch_size above | ||
| let cursor_unit = if multi_column_sort { | ||
| row_batch_size | ||
| } else { | ||
| ipc_batch_size | ||
| }; | ||
|
|
||
| // BatchBuilder needs to hold 3 Record batches simultaneously to merge two | ||
| // streams (because a stream can cross a record batch boundary) | ||
| // there is also one cursor needed per stream | ||
| let mut max_peak = 3 * ipc_batch_size + 2 * cursor_unit + converter_size; | ||
|
|
||
| // with round robin enabled, 2 extra cursors live in memory | ||
| // see https://github.com/apache/datafusion/issues/23604 | ||
| if round_robin { | ||
| max_peak += 2 * cursor_unit; | ||
| }; | ||
|
|
||
| assert!( | ||
| peak_bytes > 0, | ||
| "peak reservation {peak_bytes} should be greater than 0" | ||
| ); | ||
| assert!( | ||
| peak_bytes <= max_peak, | ||
| "peak reservation {peak_bytes} bytes exceeds max_peak ({max_peak} bytes); \ | ||
| round_robin={round_robin}, multi_column_sort={multi_column_sort}", | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| struct RunTestWithLimitedMemoryArgs { | ||
| pool_size: usize, | ||
| task_ctx: Arc<TaskContext>, | ||
|
|
||
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.
what about alternating ranges, stream 0 uses the even slots and stream 1 uses the odd slots?
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.
what's the rationale for adding all these edge cases? is it trying to figure out whether SortPreservingMerge over-reserves memory with certain data inputs?
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.
My bad, I should have been clear what I meant. I wasn’t asking for another test case. I was saying that the comment above says stream 0 covers [0,1000) and stream 1 covers [1000,2000). But the code does
stream 0 → [0, 100), [200, 300), ...
stream 1 → [100, 200), [300, 400), ...
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.
I'll fix the comment in the next PR, since it'll touch these tests anyway.