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
249 changes: 245 additions & 4 deletions datafusion/physical-plan/src/sorts/multi_level_merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::task::{Context, Poll};

use arrow::datatypes::SchemaRef;
use datafusion_common::{Result, internal_err, resources_err};
use datafusion_execution::memory_pool::{MemoryReservation, MergeMemoryPool};
use datafusion_execution::memory_pool::{MemoryPool, MemoryReservation, MergeMemoryPool};

use crate::sorts::builder::try_grow_reservation_to_at_least;
use crate::sorts::sort::get_reserved_bytes_for_record_batch_size;
Expand All @@ -28,6 +28,24 @@ use datafusion_physical_expr_common::sort_expr::LexOrdering;
use futures::TryStreamExt;
use futures::{Stream, StreamExt};

/// Leave memory for others sharing the execution pool when a merge seats
/// more than the minimum two runs.
#[derive(Debug, Clone)]
pub(crate) struct FanInHeadroom {
/// The execution pool: the parent of the merge pool when there is one.
pub(crate) pool: Arc<dyn MemoryPool>,
/// Other partitions share `pool`, even if they hold nothing right now.
pub(crate) siblings: bool,
}

impl FanInHeadroom {
/// Whether another consumer may need the pool: sibling partitions exist,
/// or the pool holds more than the `owned` bytes this merge holds in it.
fn is_contended(&self, owned: usize) -> bool {
self.siblings || self.pool.reserved() > owned
}
}

/// Merges a stream of sorted cursors and record batches into a single sorted stream
///
/// This is a wrapper around [`SortPreservingMergeStream`](crate::sorts::merge::SortPreservingMergeStream)
Expand Down Expand Up @@ -146,6 +164,11 @@ pub(crate) struct MultiLevelMergeBuilder {
merge_pool: Option<Arc<MergeMemoryPool>>,
/// Leave memory for the aggregate consuming the merged spill rows.
reserve_replay_headroom: bool,
/// When set and the pool is shared, seat runs beyond the minimum two only
/// if the pool can admit their buffers plus as much again as they exceed
/// the retained workspace, so a merge does not take the memory that other
/// partitions sharing the pool need.
fan_in_headroom: Option<FanInHeadroom>,
/// Avoid merging more runs than needed before the final replay pass.
size_intermediate_merges: bool,
fetch: Option<usize>,
Expand Down Expand Up @@ -188,6 +211,7 @@ impl MultiLevelMergeBuilder {
reservation,
merge_pool: None,
reserve_replay_headroom: false,
fan_in_headroom: None,
size_intermediate_merges: false,
enable_round_robin_tie_breaker,
fetch,
Expand All @@ -206,6 +230,17 @@ impl MultiLevelMergeBuilder {
self
}

/// Leave headroom when seating runs beyond the minimum two while the pool
/// is shared. The minimum merge is admitted against the pool alone, as
/// without headroom.
pub(super) fn with_fan_in_headroom(
mut self,
headroom: Option<FanInHeadroom>,
) -> Self {
self.fan_in_headroom = headroom;
self
}

pub(super) fn with_intermediate_merge_sizing(
mut self,
min_spill_batch_rows: Option<usize>,
Expand Down Expand Up @@ -573,6 +608,20 @@ impl MultiLevelMergeBuilder {
// allocation, preventing starvation under memory pressure.
let mut total_needed: usize = 0;
let mut accepted_memory: usize = 0;
// What this merge already holds from the pool: its retained workspace
// when it has one. Seating runs within it takes nothing from others.
let owned = self
.merge_pool
.as_ref()
.map_or_else(|| reservation.size(), |pool| pool.reserved());
// Headroom is for others sharing the pool. With no sibling partitions
// and nobody else holding memory in it, seat every run that fits, as
// without headroom, rather than rewrite runs in an extra merge pass
// for no one.
let fan_in_contended = self
.fan_in_headroom
.as_ref()
.is_some_and(|headroom| headroom.is_contended(owned));

for (spill, _) in &self.sorted_spill_files {
if number_of_spills_to_read_for_current_phase >= max_spill_files
Expand All @@ -598,7 +647,20 @@ impl MultiLevelMergeBuilder {
&& number_of_spills_to_read_for_current_phase
< minimum_number_of_required_streams;
let check_headroom = self.reserve_replay_headroom && !skip_headroom;
let admission = if check_headroom {
let check_fan_in_headroom = !check_headroom
&& fan_in_contended
&& number_of_spills_to_read_for_current_phase
>= minimum_number_of_required_streams;
let admission = if check_fan_in_headroom {
// Ask the pool for what this merge takes beyond its own
// workspace, plus as much again, then return the spare bytes.
match total_needed.checked_add(total_needed.saturating_sub(owned)) {
Comment thread
MisterRaindrop marked this conversation as resolved.
Resolved
Some(with_headroom) => {
try_grow_reservation_to_at_least(reservation, with_headroom)
}
None => resources_err!("Spill merge headroom exceeds usize::MAX"),
}
} else if check_headroom {
// Ask the pool for merge buffers plus equal replay space, then
// return the spare bytes before exposing the merge stream.
match total_needed.checked_mul(2) {
Expand Down Expand Up @@ -666,7 +728,7 @@ impl MultiLevelMergeBuilder {
}
}

if self.reserve_replay_headroom {
if self.reserve_replay_headroom || fan_in_contended {
// `total_needed` may include a rejected candidate. Keep only the
// buffers that were admitted, releasing temporary replay headroom.
reservation.shrink(reservation.size() - accepted_memory);
Expand Down Expand Up @@ -966,7 +1028,7 @@ mod tests {
use arrow::compute::concat_batches;
use arrow::datatypes::{DataType, Field, Int64Type, Schema};
use datafusion_execution::memory_pool::{
GreedyMemoryPool, MemoryConsumer, MemoryPool,
GreedyMemoryPool, MemoryConsumer, MemoryPool, UnboundedMemoryPool,
};
use datafusion_execution::runtime_env::{RuntimeEnv, RuntimeEnvBuilder};
use datafusion_physical_expr::expressions::{Column, col};
Expand Down Expand Up @@ -1463,6 +1525,185 @@ mod tests {
Ok(())
}

Comment thread
MisterRaindrop marked this conversation as resolved.
Resolved
/// Registers a consumer holding `size` bytes of `pool`, so a merge sharing
/// it sees another user and leaves headroom.
fn hold(pool: &Arc<dyn MemoryPool>, size: usize) -> Result<MemoryReservation> {
let other = MemoryConsumer::new("other consumer").register(pool);
other.try_grow(size)?;
Ok(other)
}

/// Headroom for others sharing `pool`, with or without sibling partitions.
fn headroom(pool: &Arc<dyn MemoryPool>, siblings: bool) -> FanInHeadroom {
FanInHeadroom {
pool: Arc::clone(pool),
siblings,
}
}

#[rstest::rstest]
#[case::without_headroom(false, true, false, 4, 8)]
#[case::headroom_without_others(true, false, false, 4, 8)]
#[case::headroom_with_others(true, true, false, 3, 6)]
#[case::headroom_with_idle_siblings(true, false, true, 3, 6)]
fn fan_in_headroom_limits_runs_beyond_the_minimum(
#[case] with_headroom: bool,
#[case] others: bool,
#[case] siblings: bool,
#[case] seated: usize,
#[case] retained_batches: usize,
) -> Result<()> {
let env = Arc::new(RuntimeEnv::default());
let schema = test_schema();
let spill_manager = build_spill_manager(&env, &schema);
let spills = (0..4)
.map(|value| make_sorted_spill_file(&spill_manager, &schema, vec![value]))
.collect::<Vec<_>>();
let batch_memory = spills[0].max_record_batch_memory;
let pool: Arc<dyn MemoryPool> =
Arc::new(GreedyMemoryPool::new(13 * batch_memory));
let other = others.then(|| hold(&pool, 1)).transpose()?;
let mut builder = build_merge_builder(spill_manager, schema, spills, &pool, 1)
.with_fan_in_headroom(with_headroom.then(|| headroom(&pool, siblings)));
let mut reservation = builder.reservation.new_empty();
let SpillFilesToMerge::Ready(spills, buffer_len) =
builder.get_sorted_spill_files_to_merge(1, 2, &mut reservation, false)?
else {
panic!("the minimum merge should fit the pool");
};
assert_eq!(buffer_len, 1);
// Each run takes two batches. With headroom, the third run needs 12
// batches and is seated; the fourth needs 16 and is not. When nobody
// else holds memory and there are no sibling partitions, all four are
// seated, as without headroom.
assert_eq!(spills.len(), seated);
assert_eq!(reservation.size(), retained_batches * batch_memory);
drop((reservation, spills, builder, other));
assert_eq!(pool.reserved(), 0);
Ok(())
}

/// The minimum two runs are admitted against the pool alone, even when
/// their buffers plus headroom would not fit.
#[test]
fn fan_in_headroom_admits_the_minimum_merge_without_headroom() -> Result<()> {
let env = Arc::new(RuntimeEnv::default());
let schema = test_schema();
let spill_manager = build_spill_manager(&env, &schema);
let spills = (0..3)
.map(|value| make_sorted_spill_file(&spill_manager, &schema, vec![value]))
.collect::<Vec<_>>();
let batch_memory = spills[0].max_record_batch_memory;
let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(5 * batch_memory));
let other = hold(&pool, 1)?;
let mut builder = build_merge_builder(spill_manager, schema, spills, &pool, 1)
.with_fan_in_headroom(Some(headroom(&pool, false)));
let mut reservation = builder.reservation.new_empty();
let SpillFilesToMerge::Ready(spills, _) =
builder.get_sorted_spill_files_to_merge(1, 2, &mut reservation, false)?
else {
panic!("the minimum merge should fit the pool");
};
// Two runs take four batches; with headroom they would take eight. The
// third would take twelve.
assert_eq!(spills.len(), 2);
assert_eq!(reservation.size(), 4 * batch_memory);
drop((reservation, spills, builder, other));
assert_eq!(pool.reserved(), 0);
Ok(())
}

/// With a retained workspace `W`, seating the third run, for buffers `T`
/// in all, asks the parent for `2 * (T - W)` more, and nothing when
/// `T <= W`. Afterwards the child holds `T` and the parent is charged
/// `max(W, T)`.
#[rstest::rstest]
#[case::runs_within_workspace(6, 0, 0, 3)]
#[case::runs_below_workspace(8, 0, 0, 3)]
#[case::exactly_the_headroom(2, 8, 0, 3)]
#[case::just_below_the_headroom(2, 8, 1, 2)]
fn fan_in_headroom_counts_the_retained_workspace(
#[case] workspace_batches: usize,
#[case] free_batches: usize,
#[case] short_by_bytes: usize,
#[case] seated: usize,
) -> Result<()> {
let env = Arc::new(RuntimeEnv::default());
let schema = test_schema();
let spill_manager = build_spill_manager(&env, &schema);
let spills = (0..3)
.map(|value| make_sorted_spill_file(&spill_manager, &schema, vec![value]))
.collect::<Vec<_>>();
let batch_memory = spills[0].max_record_batch_memory;
let per_run =
get_reserved_bytes_for_record_batch_size(batch_memory, batch_memory);
assert_eq!(per_run, 2 * batch_memory);
let workspace = workspace_batches * batch_memory;
let free = free_batches * batch_memory - short_by_bytes;
let capacity = 32 * batch_memory;
let parent: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(capacity));
let merge_pool = Arc::new(MergeMemoryPool::new(
Arc::clone(&parent),
MemoryConsumer::new("merge workspace"),
));
let pool: Arc<dyn MemoryPool> = Arc::clone(&merge_pool) as Arc<dyn MemoryPool>;
let mut builder = build_merge_builder(spill_manager, schema, spills, &pool, 1)
.with_merge_pool(Some(Arc::clone(&merge_pool)))
.with_fan_in_headroom(Some(headroom(&parent, false)));
builder.reservation.try_grow(workspace)?;
merge_pool.retain(workspace);
builder.reservation.free();
assert_eq!(merge_pool.reserved(), workspace);
let other = hold(&parent, capacity - workspace - free)?;

let mut reservation = builder.reservation.new_empty();
let SpillFilesToMerge::Ready(spills, _) =
builder.get_sorted_spill_files_to_merge(1, 2, &mut reservation, false)?
else {
panic!("the minimum merge should fit the pool");
};
assert_eq!(spills.len(), seated);
let total = seated * per_run;
assert_eq!(reservation.size(), total);
assert_eq!(merge_pool.reserved(), workspace.max(total));
assert_eq!(parent.reserved(), other.size() + workspace.max(total));
drop((reservation, spills, builder, other));
merge_pool.release_unused();
assert_eq!(parent.reserved(), 0);
Ok(())
}

#[test]
fn fan_in_headroom_beyond_usize_stops_seating_runs() -> Result<()> {
let env = Arc::new(RuntimeEnv::default());
let schema = test_schema();
let spill_manager = build_spill_manager(&env, &schema);
// Three runs whose buffers fit in a usize, but whose headroom for the
// third does not.
let batch_memory = usize::MAX / 10;
let spills = (0..3)
.map(|value| SortedSpillFile {
max_record_batch_memory: batch_memory,
..make_sorted_spill_file(&spill_manager, &schema, vec![value])
})
.collect::<Vec<_>>();
let pool: Arc<dyn MemoryPool> = Arc::new(UnboundedMemoryPool::default());
let other = hold(&pool, 1)?;
let mut builder = build_merge_builder(spill_manager, schema, spills, &pool, 1)
.with_fan_in_headroom(Some(headroom(&pool, false)));
let mut reservation = builder.reservation.new_empty();
let SpillFilesToMerge::Ready(spills, _) =
builder.get_sorted_spill_files_to_merge(1, 2, &mut reservation, false)?
else {
panic!("the minimum merge should fit an unbounded pool");
};
assert_eq!(spills.len(), 2);
assert_eq!(reservation.size(), 4 * batch_memory);
drop((reservation, spills, builder, other));
assert_eq!(pool.reserved(), 0);
Ok(())
}

#[tokio::test]
async fn replay_headroom_still_enforces_the_actual_pool() -> Result<()> {
let env = Arc::new(RuntimeEnv::default());
Expand Down
Loading
Loading