Skip to content

Commit b8fba56

Browse files
Dandandanclaude
andcommitted
perf(repartition): drop redundant reservation Mutex and halve per-batch Instant::now() calls
`SharedMemoryReservation` wraps `MemoryReservation` in an `Arc<Mutex<_>>`, but every method on `MemoryReservation` (`try_grow`, `shrink`, `try_resize`, ...) already takes `&self` and mutates an `AtomicUsize` internally. The extra `Mutex` is dead weight on the hot path: both the producer (`try_grow` in `pull_from_input`) and the consumer (`shrink` in `PerPartitionStream`) paid a lock acquire per batch for no correctness benefit. Type the three fields in `repartition/mod.rs` as `Arc<MemoryReservation>` and drop the three `.lock()` callsites. The shared-channel alias `SharedMemoryReservation` in `common.rs` stays as-is for now — it is still used by `symmetric_hash_join`. Independently, the per-sub-batch `send_time[partition].timer()` idiom calls `Instant::now()` twice per sub-batch (once at `timer()`, once at `done()`/drop). Replace with a single advancing `Instant` that tracks elapsed-since-last-partition, giving one `Instant::now()` per sub-batch while preserving the per-partition `send_time` metric. Also add a criterion microbench (`benches/repartition.rs`) that drives a 1M-row, 16-input / 16-output hash repartition, a round-robin variant, and a 16→1 coalesce. Before / after on the new bench (macOS ARM): hash_16_to_16 1.62 ms -> 1.55 ms (~ -2.5%) round_robin_16_to_16 303 us -> 268 us (~ -10%) hash_16_to_1_coalesce 932 us -> 917 us (~ -5%) The hash case is dominated by `BatchPartitioner` hashing itself, so the plumbing wins show up most clearly on the round-robin variant. All 41 existing `repartition` unit tests pass, including spill and memory-pool paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 43d32a8 commit b8fba56

3 files changed

Lines changed: 192 additions & 16 deletions

File tree

datafusion/physical-plan/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ tokio = { workspace = true, features = [
9191
harness = false
9292
name = "partial_ordering"
9393

94+
[[bench]]
95+
harness = false
96+
name = "repartition"
97+
9498
[[bench]]
9599
harness = false
96100
name = "spill_io"
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
//! Microbenchmark for `RepartitionExec` in isolation.
19+
//!
20+
//! Stages a pre-built in-memory set of batches across N input partitions and
21+
//! runs them through `RepartitionExec` to N output partitions. Drains all
22+
//! output partitions concurrently so the measurement reflects the true
23+
//! per-batch cost of the breaker: mpsc `send().await`, reservation lock,
24+
//! metric timers, and `SpawnedTask`-per-input scheduling.
25+
//!
26+
//! Run with:
27+
//! ```sh
28+
//! cargo bench --bench repartition -- --sample-size=20
29+
//! ```
30+
31+
use std::sync::Arc;
32+
33+
use arrow::array::{ArrayRef, UInt64Array};
34+
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
35+
use arrow::record_batch::RecordBatch;
36+
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
37+
use datafusion_execution::TaskContext;
38+
use datafusion_physical_expr::Partitioning;
39+
use datafusion_physical_expr::expressions::col;
40+
use datafusion_physical_plan::repartition::RepartitionExec;
41+
use datafusion_physical_plan::test::TestMemoryExec;
42+
use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
43+
use futures::StreamExt;
44+
45+
const TOTAL_ROWS: usize = 1_000_000;
46+
const BATCH_SIZE: usize = 8_192;
47+
const PARTITIONS: usize = 16;
48+
49+
fn schema() -> SchemaRef {
50+
Arc::new(Schema::new(vec![Field::new("c0", DataType::UInt64, false)]))
51+
}
52+
53+
/// Build `PARTITIONS` input partitions, each containing
54+
/// `TOTAL_ROWS / PARTITIONS` rows split into `BATCH_SIZE`-sized batches.
55+
fn make_partitions(schema: &SchemaRef) -> Vec<Vec<RecordBatch>> {
56+
let per_partition = TOTAL_ROWS / PARTITIONS;
57+
(0..PARTITIONS)
58+
.map(|p| {
59+
let mut batches = Vec::new();
60+
let mut offset: u64 = (p * per_partition) as u64;
61+
let end: u64 = offset + per_partition as u64;
62+
while offset < end {
63+
let n = std::cmp::min(BATCH_SIZE as u64, end - offset) as usize;
64+
let arr: ArrayRef = Arc::new(UInt64Array::from_iter_values(
65+
(offset..offset + n as u64).collect::<Vec<_>>(),
66+
));
67+
batches
68+
.push(RecordBatch::try_new(Arc::clone(schema), vec![arr]).unwrap());
69+
offset += n as u64;
70+
}
71+
batches
72+
})
73+
.collect()
74+
}
75+
76+
fn build_plan(
77+
partitions: &[Vec<RecordBatch>],
78+
schema: &SchemaRef,
79+
partitioning: Partitioning,
80+
) -> Arc<dyn ExecutionPlan> {
81+
let src = TestMemoryExec::try_new_exec(partitions, Arc::clone(schema), None).unwrap();
82+
Arc::new(RepartitionExec::try_new(src, partitioning).unwrap())
83+
}
84+
85+
fn drain_all(
86+
rt: &tokio::runtime::Runtime,
87+
plan: Arc<dyn ExecutionPlan>,
88+
task_ctx: Arc<TaskContext>,
89+
) {
90+
rt.block_on(async move {
91+
let out = plan.output_partitioning().partition_count();
92+
let mut handles = Vec::with_capacity(out);
93+
for p in 0..out {
94+
let mut stream = plan.execute(p, Arc::clone(&task_ctx)).unwrap();
95+
handles.push(tokio::spawn(async move {
96+
let mut rows = 0usize;
97+
while let Some(batch) = stream.next().await {
98+
rows += batch.unwrap().num_rows();
99+
}
100+
rows
101+
}));
102+
}
103+
for h in handles {
104+
let _ = h.await.unwrap();
105+
}
106+
});
107+
}
108+
109+
fn bench_repartition(c: &mut Criterion) {
110+
let schema = schema();
111+
let partitions = make_partitions(&schema);
112+
let hash_expr = vec![col("c0", &schema).unwrap()];
113+
114+
let rt = tokio::runtime::Builder::new_multi_thread()
115+
.worker_threads(PARTITIONS)
116+
.enable_all()
117+
.build()
118+
.unwrap();
119+
let task_ctx = Arc::new(TaskContext::default());
120+
121+
c.bench_function("repartition/hash_16_to_16", |b| {
122+
b.iter_batched(
123+
|| {
124+
build_plan(
125+
&partitions,
126+
&schema,
127+
Partitioning::Hash(hash_expr.clone(), PARTITIONS),
128+
)
129+
},
130+
|plan| drain_all(&rt, plan, Arc::clone(&task_ctx)),
131+
BatchSize::LargeInput,
132+
)
133+
});
134+
135+
c.bench_function("repartition/round_robin_16_to_16", |b| {
136+
b.iter_batched(
137+
|| {
138+
build_plan(
139+
&partitions,
140+
&schema,
141+
Partitioning::RoundRobinBatch(PARTITIONS),
142+
)
143+
},
144+
|plan| drain_all(&rt, plan, Arc::clone(&task_ctx)),
145+
BatchSize::LargeInput,
146+
)
147+
});
148+
149+
c.bench_function("repartition/hash_16_to_1_coalesce", |b| {
150+
b.iter_batched(
151+
|| {
152+
build_plan(
153+
&partitions,
154+
&schema,
155+
Partitioning::Hash(hash_expr.clone(), 1),
156+
)
157+
},
158+
|plan| drain_all(&rt, plan, Arc::clone(&task_ctx)),
159+
BatchSize::LargeInput,
160+
)
161+
});
162+
}
163+
164+
criterion_group!(benches, bench_repartition);
165+
criterion_main!(benches);

datafusion/physical-plan/src/repartition/mod.rs

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ use std::sync::Arc;
2525
use std::task::{Context, Poll};
2626
use std::vec;
2727

28-
use super::common::SharedMemoryReservation;
2928
use super::metrics::{self, ExecutionPlanMetricsSet, MetricBuilder, MetricsSet};
3029
use super::{
3130
DisplayAs, ExecutionPlanProperties, RecordBatchStream, SendableRecordBatchStream,
@@ -58,7 +57,7 @@ use datafusion_common::{
5857
use datafusion_common::{Result, not_impl_err};
5958
use datafusion_common_runtime::SpawnedTask;
6059
use datafusion_execution::TaskContext;
61-
use datafusion_execution::memory_pool::MemoryConsumer;
60+
use datafusion_execution::memory_pool::{MemoryConsumer, MemoryReservation};
6261
use datafusion_physical_expr::{EquivalenceProperties, PhysicalExpr};
6362
use datafusion_physical_expr_common::sort_expr::LexOrdering;
6463

@@ -143,10 +142,15 @@ type MaybeBatch = Option<Result<RepartitionBatch>>;
143142
type InputPartitionsToCurrentPartitionSender = Vec<DistributionSender<MaybeBatch>>;
144143
type InputPartitionsToCurrentPartitionReceiver = Vec<DistributionReceiver<MaybeBatch>>;
145144

146-
/// Output channel with its associated memory reservation and spill writer
145+
/// Output channel with its associated memory reservation and spill writer.
146+
///
147+
/// Every method on [`MemoryReservation`] takes `&self` and mutates atomic
148+
/// counters internally, so the shared reservation needs no extra lock on the
149+
/// hot path — both the producer (`try_grow`) and the consumer (`shrink`)
150+
/// operate concurrently via atomics.
147151
struct OutputChannel {
148152
sender: DistributionSender<MaybeBatch>,
149-
reservation: SharedMemoryReservation,
153+
reservation: Arc<MemoryReservation>,
150154
spill_writer: SpillPoolWriter,
151155
}
152156

@@ -177,7 +181,7 @@ struct PartitionChannels {
177181
/// Receivers for each input partition sending data to this output partition
178182
rx: InputPartitionsToCurrentPartitionReceiver,
179183
/// Memory reservation for this output partition
180-
reservation: SharedMemoryReservation,
184+
reservation: Arc<MemoryReservation>,
181185
/// Spill writers for writing spilled data.
182186
/// SpillPoolWriter is Clone, so multiple writers can share state in non-preserve-order mode.
183187
spill_writers: Vec<SpillPoolWriter>,
@@ -322,11 +326,11 @@ impl RepartitionExecState {
322326

323327
let mut channels = HashMap::with_capacity(txs.len());
324328
for (partition, (tx, rx)) in txs.into_iter().zip(rxs).enumerate() {
325-
let reservation = Arc::new(Mutex::new(
329+
let reservation = Arc::new(
326330
MemoryConsumer::new(format!("{name}[{partition}]"))
327331
.with_can_spill(true)
328332
.register(context.memory_pool()),
329-
));
333+
);
330334

331335
// Create spill channels based on mode:
332336
// - preserve_order: one spill channel per (input, output) pair for proper FIFO ordering
@@ -1393,15 +1397,18 @@ impl RepartitionExec {
13931397
continue;
13941398
}
13951399

1400+
// Track per-partition send time by advancing a single `Instant`
1401+
// through the inner loop — one `Instant::now()` per sub-batch,
1402+
// instead of two via `ScopedTimerGuard::{timer, done}`.
1403+
let mut last = datafusion_common::instant::Instant::now();
13961404
for res in partitioner.partition_iter(batch)? {
13971405
let (partition, batch) = res?;
13981406
let size = batch.get_array_memory_size();
13991407

1400-
let timer = metrics.send_time[partition].timer();
14011408
// if there is still a receiver, send to it
14021409
if let Some(channel) = output_channels.get_mut(&partition) {
14031410
let (batch_to_send, is_memory_batch) =
1404-
match channel.reservation.lock().try_grow(size) {
1411+
match channel.reservation.try_grow(size) {
14051412
Ok(_) => {
14061413
// Memory available - send in-memory batch
14071414
(RepartitionBatch::Memory(batch), true)
@@ -1419,12 +1426,14 @@ impl RepartitionExec {
14191426
// If the other end has hung up, it was an early shutdown (e.g. LIMIT)
14201427
// Only shrink memory if it was a memory batch
14211428
if is_memory_batch {
1422-
channel.reservation.lock().shrink(size);
1429+
channel.reservation.shrink(size);
14231430
}
14241431
output_channels.remove(&partition);
14251432
}
14261433
}
1427-
timer.done();
1434+
let now = datafusion_common::instant::Instant::now();
1435+
metrics.send_time[partition].add_duration(now - last);
1436+
last = now;
14281437
}
14291438

14301439
// If the input stream is endless, we may spin forever and
@@ -1567,7 +1576,7 @@ struct PerPartitionStream {
15671576
_drop_helper: Arc<Vec<SpawnedTask<()>>>,
15681577

15691578
/// Memory reservation.
1570-
reservation: SharedMemoryReservation,
1579+
reservation: Arc<MemoryReservation>,
15711580

15721581
/// Infinite stream for reading from the spill pool
15731582
spill_stream: SendableRecordBatchStream,
@@ -1593,7 +1602,7 @@ impl PerPartitionStream {
15931602
schema: SchemaRef,
15941603
receiver: DistributionReceiver<MaybeBatch>,
15951604
drop_helper: Arc<Vec<SpawnedTask<()>>>,
1596-
reservation: SharedMemoryReservation,
1605+
reservation: Arc<MemoryReservation>,
15971606
spill_stream: SendableRecordBatchStream,
15981607
num_input_partitions: usize,
15991608
baseline_metrics: BaselineMetrics,
@@ -1638,9 +1647,7 @@ impl PerPartitionStream {
16381647
Some(Some(v)) => match v {
16391648
Ok(RepartitionBatch::Memory(batch)) => {
16401649
// Release memory and return batch
1641-
self.reservation
1642-
.lock()
1643-
.shrink(batch.get_array_memory_size());
1650+
self.reservation.shrink(batch.get_array_memory_size());
16441651
return Poll::Ready(Some(Ok(batch)));
16451652
}
16461653
Ok(RepartitionBatch::Spilled) => {

0 commit comments

Comments
 (0)