Skip to content

Commit 611030b

Browse files
Dandandanclaude
andcommitted
feat: parallel sort-preserving merge (PSRS) for >2x merge speedup
Add `ParallelSortPreservingMergeExec` (`sorts/parallel_merge.rs`), a parallel order-preserving merge of sorted partitions using Parallel Sorting by Regular Sampling (PSRS): materialize each locally-sorted input, pick `P-1` pivots by regular sampling, cut every run by the same pivots (binary search on byte-comparable row encodings), then merge the `P` key-range buckets concurrently (one `SpawnedTask` each, reusing the loser-tree `StreamingMergeBuilder`) and concatenate the buckets in order. Output is a single globally-sorted partition — same contract as `SortPreservingMergeExec`, computed with `target_partitions`-way parallelism. A new `JoinSelection`-style physical-optimizer rule `ParallelSortMerge` (`parallel_sort_merge.rs`, run after `PushdownSort`) replaces an eligible `SortPreservingMergeExec` with the parallel exec, gated by `datafusion.optimizer.enable_parallel_sort_merge` (default true). Eligibility: no fetch/limit, bounded input, >1 input partition, and a *known* row count >= `batch_size * target_partitions` (unknown-size inputs keep the serial merge to avoid regressing small data). It is pipeline-breaking (materializes all input, no early-stop), so it never honors a pushed-down limit and is restricted to bounded inputs. Measured: ~2.36x on the `sort_preserving_merge` bench (1M rows x 3 partitions, u64); ~2-2.5x on sort_tpch (Q1 2.54x, Q2 2.31x, Q10 1.93x). A `--parallel-merge` A/B flag is added to the sort_tpch benchmark. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent dc92bb8 commit 611030b

14 files changed

Lines changed: 1130 additions & 20 deletions

File tree

‎benchmarks/src/sort_tpch.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ pub struct RunOpt {
7272
/// Append a `LIMIT n` clause to the query
7373
#[arg(short = 'l', long = "limit")]
7474
limit: Option<usize>,
75+
76+
/// Override `datafusion.optimizer.enable_parallel_sort_merge`.
77+
/// When unset, the config default is used. Pass `--parallel-merge false`
78+
/// to benchmark the single-threaded `SortPreservingMergeExec` baseline.
79+
#[arg(long = "parallel-merge")]
80+
parallel_merge: Option<bool>,
7581
}
7682

7783
pub const SORT_TPCH_QUERY_START_ID: usize = 1;
@@ -208,7 +214,10 @@ impl RunOpt {
208214

209215
/// Benchmark query `query_id` in `SORT_QUERIES`
210216
async fn benchmark_query(&self, query_id: usize) -> Result<Vec<QueryResult>> {
211-
let config = self.common.config()?;
217+
let mut config = self.common.config()?;
218+
if let Some(parallel_merge) = self.parallel_merge {
219+
config.options_mut().optimizer.enable_parallel_sort_merge = parallel_merge;
220+
}
212221
let rt = self.common.build_runtime()?;
213222
let state = SessionStateBuilder::new()
214223
.with_config(config)

‎datafusion/common/src/config.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,19 @@ config_namespace! {
16361636
/// Default: true
16371637
pub enable_sort_pushdown: bool, default = true
16381638

1639+
/// When set to true, eligible `SortPreservingMergeExec` operators are
1640+
/// replaced with a parallel merge that splits the locally sorted inputs
1641+
/// into key ranges (via regular sampling of the sorted runs) and merges
1642+
/// those ranges concurrently across `target_partitions` threads, before
1643+
/// concatenating them in order into a single sorted stream.
1644+
///
1645+
/// This trades extra memory (the sorted inputs are materialized) for
1646+
/// parallelism in the otherwise single-threaded merge. It only applies
1647+
/// to merges without a fetch/limit over bounded, multi-partition inputs.
1648+
///
1649+
/// Default: true
1650+
pub enable_parallel_sort_merge: bool, default = true
1651+
16391652
/// When set to true, the optimizer will extract leaf expressions
16401653
/// (such as `get_field`) from filter/sort/join nodes into projections
16411654
/// closer to the leaf table scans, and push those projections down

‎datafusion/physical-optimizer/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub mod limit_pushdown_past_window;
4040
pub mod limited_distinct_aggregation;
4141
pub mod optimizer;
4242
pub mod output_requirements;
43+
pub mod parallel_sort_merge;
4344
pub mod projection_pushdown;
4445
pub use datafusion_pruning as pruning;
4546
pub mod hash_join_buffering;

‎datafusion/physical-optimizer/src/optimizer.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use crate::update_aggr_exprs::OptimizeAggregateOrder;
3737

3838
use crate::hash_join_buffering::HashJoinBuffering;
3939
use crate::limit_pushdown_past_window::LimitPushPastWindows;
40+
use crate::parallel_sort_merge::ParallelSortMerge;
4041
use crate::pushdown_sort::PushdownSort;
4142
use crate::window_topn::WindowTopN;
4243
use datafusion_common::Result;
@@ -233,6 +234,11 @@ impl PhysicalOptimizer {
233234
Arc::new(ProjectionPushdown::new()),
234235
// PushdownSort: Detect sorts that can be pushed down to data sources.
235236
Arc::new(PushdownSort::new()),
237+
// ParallelSortMerge: optionally replace single-threaded
238+
// `SortPreservingMergeExec` with a parallel merge. Gated by
239+
// `datafusion.optimizer.enable_parallel_sort_merge`. Runs after sort
240+
// placement is finalized so the merges it targets already exist.
241+
Arc::new(ParallelSortMerge::new()),
236242
Arc::new(EnsureCooperative::new()),
237243
// This FilterPushdown handles dynamic filters that may have references to the source ExecutionPlan.
238244
// Therefore, it should be run at the end of the optimization process since any changes to the plan may break the dynamic filter's references.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Replace eligible [`SortPreservingMergeExec`] operators with the parallel
19+
//! [`ParallelSortPreservingMergeExec`].
20+
//!
21+
//! Gated by the `datafusion.optimizer.enable_parallel_sort_merge` config flag.
22+
//! See [`ParallelSortPreservingMergeExec`] for the algorithm.
23+
24+
use std::sync::Arc;
25+
26+
use crate::PhysicalOptimizerRule;
27+
use datafusion_common::Result;
28+
use datafusion_common::config::ConfigOptions;
29+
use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
30+
use datafusion_physical_plan::execution_plan::Boundedness;
31+
use datafusion_physical_plan::sorts::parallel_merge::ParallelSortPreservingMergeExec;
32+
use datafusion_physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
33+
use datafusion_physical_plan::statistics::StatisticsArgs;
34+
use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
35+
36+
/// Optimizer rule that swaps [`SortPreservingMergeExec`] for the parallel
37+
/// [`ParallelSortPreservingMergeExec`] when it is both safe and beneficial.
38+
#[derive(Debug, Clone, Default)]
39+
pub struct ParallelSortMerge {}
40+
41+
impl ParallelSortMerge {
42+
pub fn new() -> Self {
43+
Self {}
44+
}
45+
}
46+
47+
impl PhysicalOptimizerRule for ParallelSortMerge {
48+
fn optimize(
49+
&self,
50+
plan: Arc<dyn ExecutionPlan>,
51+
config: &ConfigOptions,
52+
) -> Result<Arc<dyn ExecutionPlan>> {
53+
if !config.optimizer.enable_parallel_sort_merge {
54+
return Ok(plan);
55+
}
56+
// With a single target partition there is nothing to parallelize.
57+
if config.execution.target_partitions <= 1 {
58+
return Ok(plan);
59+
}
60+
61+
// Only parallelize merges whose input is large enough to split into
62+
// more than one bucket; below this the materialization + sampling
63+
// overhead outweighs the benefit and a single-threaded merge is faster.
64+
let min_rows = config
65+
.execution
66+
.batch_size
67+
.get()
68+
.saturating_mul(config.execution.target_partitions);
69+
70+
plan.transform_up(|plan: Arc<dyn ExecutionPlan>| {
71+
let Some(spm) = plan.downcast_ref::<SortPreservingMergeExec>() else {
72+
return Ok(Transformed::no(plan));
73+
};
74+
let input = spm.input();
75+
76+
// The parallel merge materializes its input and emits no rows until
77+
// it has merged everything, so it cannot satisfy a fetch/limit
78+
// (which `SortPreservingMergeExec` streams and stops early on) and
79+
// must not be used on unbounded input. It also needs more than one
80+
// input partition to be worth parallelizing.
81+
let eligible = spm.fetch().is_none()
82+
&& matches!(input.boundedness(), Boundedness::Bounded)
83+
&& input.output_partitioning().partition_count() > 1;
84+
85+
if !eligible {
86+
return Ok(Transformed::no(plan));
87+
}
88+
89+
// Only parallelize when the input is *known* to be large enough to
90+
// benefit. The parallel merge materializes its input and pays a
91+
// sampling + task-spawn overhead, so for small (or unknown-size)
92+
// inputs the single-threaded merge is faster; when the row count is
93+
// unavailable we conservatively keep the serial merge.
94+
let Some(rows) = input
95+
.statistics_with_args(&StatisticsArgs::new())
96+
.ok()
97+
.and_then(|stats| stats.num_rows.get_value().copied())
98+
else {
99+
return Ok(Transformed::no(plan));
100+
};
101+
if rows < min_rows {
102+
return Ok(Transformed::no(plan));
103+
}
104+
105+
let parallel = ParallelSortPreservingMergeExec::new(
106+
spm.expr().clone(),
107+
Arc::clone(input),
108+
);
109+
Ok(Transformed::yes(Arc::new(parallel)))
110+
})
111+
.data()
112+
}
113+
114+
fn name(&self) -> &str {
115+
"ParallelSortMerge"
116+
}
117+
118+
fn schema_check(&self) -> bool {
119+
true
120+
}
121+
}

‎datafusion/physical-plan/benches/sort_preserving_merge.rs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ use datafusion_execution::TaskContext;
2525
use datafusion_physical_expr::{LexOrdering, PhysicalSortExpr, expressions::col};
2626
use datafusion_physical_plan::test::TestMemoryExec;
2727
use datafusion_physical_plan::{
28-
collect, sorts::sort_preserving_merge::SortPreservingMergeExec,
28+
collect, sorts::parallel_merge::ParallelSortPreservingMergeExec,
29+
sorts::sort_preserving_merge::SortPreservingMergeExec,
2930
};
3031

3132
use std::sync::Arc;
@@ -190,6 +191,26 @@ fn bench_merge_sorted_preserving(c: &mut Criterion) {
190191
)
191192
},
192193
);
194+
c.bench_function(&format!("bench_merge_sorted_parallel/{bench_name}"), |b| {
195+
b.iter_batched(
196+
|| {
197+
let exec =
198+
TestMemoryExec::try_new_exec(&partitions, schema.clone(), None)
199+
.unwrap();
200+
Arc::new(ParallelSortPreservingMergeExec::new(
201+
sort_order.clone(),
202+
exec,
203+
))
204+
},
205+
|merge_exec| {
206+
let rt = tokio::runtime::Runtime::new().unwrap();
207+
rt.block_on(async {
208+
collect(merge_exec, task_ctx.clone()).await.unwrap();
209+
});
210+
},
211+
BatchSize::LargeInput,
212+
)
213+
});
193214
}
194215
}
195216

‎datafusion/physical-plan/src/sorts/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod builder;
2121
mod cursor;
2222
mod merge;
2323
mod multi_level_merge;
24+
pub mod parallel_merge;
2425
pub mod partial_sort;
2526
pub mod partitioned_topk;
2627
pub mod sort;

0 commit comments

Comments
 (0)