Skip to content

Commit 45bc880

Browse files
LuQQiuclaude
andauthored
perf(filtered-read): consolidate take-shaped masked reads off the consumer (#7783)
## Problem A masked (row-set) read whose plan touches many fragments with only a few rows each — the shape a take-style workload produces — emits one tiny batch per fragment (a batch never spans fragments). The per-batch pipeline driving executes inline in whichever task polls the node's output, so concurrent small reads serialize on their consumers. A flamegraph of 64 concurrent take-100 masked reads shows the polling thread saturated by the `task_stream` unfold/buffer machinery while workers sit idle. ## Fix For take-shaped plans only — a row-set input, at least 8 fragments planned non-empty, and fewer than 1024 planned rows per fragment on average — pump the read on a spawned task and hand the consumer consolidated batches (merge-only: order preserved, any batch already at the target passes through whole). Everything else is byte-for-byte unchanged: plain scans, single-fragment reads, dense masked reads (including filtered scans, whose planned rows are a pre-refine upper bound) and the byte-based rechunk path keep their batch boundaries, first-batch latency and backpressure. ## Benchmarks Single node, 100M rows / 100 fragments (NVMe, warm), fixed-seed scattered row sets, identical physical I/O between arms: | cell | before | after | |---|---|---| | masked take-100, concurrency 64 | 291 QPS | **634 QPS** (TakeExec: 485) | | masked take-100K, concurrency 32 | 5.4 QPS | **12.1 QPS** | | masked take-100 / take-10K / take-100K, concurrency 1 | 339 / 12.9 / 11.4 | 341 / 13.9 / 10.3 (within noise) | | scan 2M rows, concurrency 1 / 8 | 287 / 175 | 256 / 158 (untouched code path, run-to-run variance) | ## Testing - New `test_take_shaped_mask_consolidation` covers the consolidated shape (single output batch, fragment order preserved), the few-fragment counterexample and the dense counterexample (per-fragment boundaries kept). It also pins the gate to count only fragments planned non-empty. - Existing `filtered_read` and `scanner` suites pass (the intermittent suite-mode failures reproduce on main without this change). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Performance Improvements** * Improved masked read execution by consolidating many small per-fragment results into fewer larger batches when appropriate. * Reduced batch count for “take-shaped” masked reads to speed up downstream processing. * Preserved existing behavior for dense masked reads and for configurations that rely on byte-based rechunking or do not meet consolidation conditions. * **Tests** * Added a new test to validate batch consolidation for take-shaped masked reads and to confirm batch boundaries are maintained for dense/too-few-fragment scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 98b7111 commit 45bc880

1 file changed

Lines changed: 230 additions & 5 deletions

File tree

rust/lance/src/io/exec/filtered_read.rs

Lines changed: 230 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use datafusion::common::stats::Precision;
1313
use datafusion::error::{DataFusionError, Result as DataFusionResult};
1414
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
1515
use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
16-
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
16+
use datafusion::physical_plan::stream::{RecordBatchReceiverStream, RecordBatchStreamAdapter};
1717
use datafusion::physical_plan::{
1818
DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties,
1919
execution_plan::{Boundedness, EmissionType},
@@ -334,6 +334,116 @@ struct FilteredReadStream {
334334
threading_mode: FilteredReadThreadingMode,
335335
/// Range to apply to the result stream if not already pushed down in planning phase
336336
scan_range_after_filter: Option<Range<u64>>,
337+
/// Fragments planned non-empty, and their total planned rows; the output
338+
/// side uses these to detect take-shaped plans (batch size resolves at
339+
/// execute time, so the detection lives there too)
340+
touched_fragments: usize,
341+
planned_rows: u64,
342+
}
343+
344+
/// Below this many fragments there are too few handoffs to be worth
345+
/// consolidating
346+
const CONSOLIDATE_MIN_FRAGMENTS: usize = 8;
347+
348+
/// Above this per-fragment average, batches are big enough to amortize
349+
/// their handoff
350+
const CONSOLIDATE_MAX_AVG_PLANNED_ROWS_PER_FRAGMENT: u64 = 1024;
351+
352+
/// Pump a take-shaped read on a spawned task, handing the consumer
353+
/// consolidated batches. Inline polling would otherwise execute the
354+
/// per-batch pipeline work on the consumer, which serializes concurrent
355+
/// small reads.
356+
fn consolidated_stream(
357+
inner: SendableRecordBatchStream,
358+
target: usize,
359+
) -> SendableRecordBatchStream {
360+
let mut builder = RecordBatchReceiverStream::builder(inner.schema(), 4);
361+
let tx = builder.tx();
362+
builder.spawn(async move {
363+
let mut stream = coalesce_batches(inner, target).boxed();
364+
while let Some(item) = stream.next().await {
365+
if tx.send(item).await.is_err() {
366+
// Receiver dropped: the query was cancelled
367+
break;
368+
}
369+
}
370+
Ok(())
371+
});
372+
builder.build()
373+
}
374+
375+
/// Merge batches up to `target` rows; batches already at the target pass
376+
/// through whole (never split). Order is preserved.
377+
fn coalesce_batches(
378+
input: SendableRecordBatchStream,
379+
target: usize,
380+
) -> impl Stream<Item = DataFusionResult<RecordBatch>> {
381+
struct Coalescer {
382+
input: SendableRecordBatchStream,
383+
schema: SchemaRef,
384+
target: usize,
385+
buffered: Vec<RecordBatch>,
386+
buffered_rows: usize,
387+
exhausted: bool,
388+
}
389+
390+
impl Coalescer {
391+
fn ready_to_emit(&self) -> bool {
392+
self.buffered_rows >= self.target || (self.exhausted && !self.buffered.is_empty())
393+
}
394+
395+
fn buffer(&mut self, batch: RecordBatch) {
396+
self.buffered_rows += batch.num_rows();
397+
self.buffered.push(batch);
398+
}
399+
400+
fn emit(&mut self) -> DataFusionResult<RecordBatch> {
401+
self.buffered_rows = 0;
402+
if self.buffered.len() > 1 {
403+
let batch = arrow::compute::concat_batches(&self.schema, self.buffered.iter())?;
404+
self.buffered.clear();
405+
Ok(batch)
406+
} else {
407+
self.buffered.pop().ok_or_else(|| {
408+
DataFusionError::Internal(
409+
"coalesce_batches emitted with an empty buffer".to_string(),
410+
)
411+
})
412+
}
413+
}
414+
}
415+
416+
let schema = input.schema();
417+
let coalescer = Coalescer {
418+
input,
419+
schema,
420+
target,
421+
buffered: Vec::new(),
422+
buffered_rows: 0,
423+
exhausted: false,
424+
};
425+
futures::stream::try_unfold(coalescer, |mut this| async move {
426+
loop {
427+
if this.ready_to_emit() {
428+
return Ok(Some((this.emit()?, this)));
429+
}
430+
if this.exhausted {
431+
return Ok(None);
432+
}
433+
match this.input.try_next().await? {
434+
Some(batch) if batch.num_rows() >= this.target && !this.buffered.is_empty() => {
435+
// Emit the partial buffer on its own; the large batch
436+
// then passes through whole on the next iteration
437+
let out = this.emit()?;
438+
this.buffer(batch);
439+
return Ok(Some((out, this)));
440+
}
441+
Some(batch) if batch.num_rows() > 0 => this.buffer(batch),
442+
Some(_) => {}
443+
None => this.exhausted = true,
444+
}
445+
}
446+
})
337447
}
338448

339449
impl std::fmt::Debug for FilteredReadStream {
@@ -438,6 +548,22 @@ impl FilteredReadStream {
438548
.buffered(fragment_readahead);
439549
let task_stream = fragment_streams.try_flatten().boxed();
440550

551+
// A batch never spans fragments, so a plan touching many fragments
552+
// with few rows each emits one tiny batch per fragment. Fragments
553+
// planned empty produce no batch and don't count. Filtered scans
554+
// stay dense here: their planned rows are a pre-refine upper bound.
555+
let (touched_fragments, planned_rows) =
556+
plan.rows
557+
.values()
558+
.fold((0usize, 0u64), |(fragments, rows), ranges| {
559+
let fragment_rows: u64 =
560+
ranges.iter().map(|range| range.end - range.start).sum();
561+
if fragment_rows > 0 {
562+
(fragments + 1, rows + fragment_rows)
563+
} else {
564+
(fragments, rows)
565+
}
566+
});
441567
Ok(Self {
442568
output_schema,
443569
task_stream: Arc::new(AsyncMutex::new(task_stream)),
@@ -446,6 +572,8 @@ impl FilteredReadStream {
446572
active_partitions_counter: Arc::new(AtomicUsize::new(0)),
447573
threading_mode,
448574
scan_range_after_filter,
575+
touched_fragments,
576+
planned_rows,
449577
})
450578
}
451579

@@ -1815,6 +1943,7 @@ impl FilteredReadExec {
18151943
n.min(target_partitions).max(1),
18161944
);
18171945
}
1946+
let batch_size_rows = options.batch_size;
18181947
let batch_size_bytes = options
18191948
.file_reader_options
18201949
.as_ref()
@@ -1846,20 +1975,42 @@ impl FilteredReadExec {
18461975
*running_stream = Some(new_running_stream);
18471976
first_stream
18481977
};
1849-
let stream: SendableRecordBatchStream = match batch_size_bytes {
1850-
Some(target) => {
1978+
// Only masked reads consolidate; plain scans keep their batch
1979+
// boundaries, and the byte-based rechunk merges on its own
1980+
let consolidate = if index_input.is_some() && batch_size_bytes.is_none() {
1981+
running_stream.as_ref().and_then(|running| {
1982+
// Explicit option → lance env default → session batch size
1983+
let batch_target_rows = batch_size_rows
1984+
.map(|batch_size| batch_size as usize)
1985+
.or_else(get_default_batch_size)
1986+
.unwrap_or_else(|| context.session_config().batch_size());
1987+
let is_sparse_plan = batch_target_rows > 0
1988+
&& running.touched_fragments >= CONSOLIDATE_MIN_FRAGMENTS
1989+
&& running.planned_rows
1990+
< running.touched_fragments as u64
1991+
* CONSOLIDATE_MAX_AVG_PLANNED_ROWS_PER_FRAGMENT;
1992+
is_sparse_plan.then_some(batch_target_rows)
1993+
})
1994+
} else {
1995+
None
1996+
};
1997+
drop(running_stream);
1998+
1999+
let stream = match (consolidate, batch_size_bytes) {
2000+
(Some(target), _) => consolidated_stream(inner, target),
2001+
(None, Some(bytes)) => {
18512002
let schema = inner.schema();
18522003
Box::pin(RecordBatchStreamAdapter::new(
18532004
schema.clone(),
18542005
lance_arrow::stream::rechunk_stream_by_size(
18552006
inner,
18562007
schema,
18572008
0,
1858-
target as usize,
2009+
bytes as usize,
18592010
),
18602011
))
18612012
}
1862-
None => inner,
2013+
(None, None) => inner,
18632014
};
18642015
DataFusionResult::<SendableRecordBatchStream>::Ok(stream)
18652016
})
@@ -2211,14 +2362,17 @@ mod tests {
22112362
};
22122363
use itertools::Itertools;
22132364
use lance_core::datatypes::OnMissing;
2365+
use lance_core::utils::address::RowAddress;
22142366
use lance_core::utils::tempfile::TempStrDir;
2367+
use lance_datafusion::exec::OneShotExec;
22152368
use lance_datagen::{BatchCount, Dimension, RowCount, array, gen_batch};
22162369
use lance_index::{
22172370
IndexType,
22182371
optimize::OptimizeOptions,
22192372
scalar::{ScalarIndexParams, expression::PlannerIndexExt},
22202373
};
22212374
use lance_select::result::IndexExprResultWireFormat;
2375+
use lance_select::{RowAddrMask, RowAddrTreeMap};
22222376

22232377
use crate::{
22242378
dataset::{InsertBuilder, WriteDestination, WriteMode, WriteParams},
@@ -2445,6 +2599,77 @@ mod tests {
24452599
))
24462600
}
24472601

2602+
/// Take-shaped masked reads consolidate their tiny per-fragment batches;
2603+
/// few-fragment and dense masked reads keep per-fragment boundaries.
2604+
#[test_log::test(tokio::test)]
2605+
async fn test_take_shaped_mask_consolidation() {
2606+
// 20 fragments x 2000 rows, value = global row number
2607+
let tmp_path = TempStrDir::default();
2608+
let data = gen_batch()
2609+
.col("value", array::step::<UInt32Type>())
2610+
.into_reader_rows(RowCount::from(2000), BatchCount::from(20));
2611+
let dataset = Arc::new(
2612+
Dataset::write(
2613+
data,
2614+
tmp_path.as_str(),
2615+
Some(WriteParams {
2616+
max_rows_per_file: 2000,
2617+
..Default::default()
2618+
}),
2619+
)
2620+
.await
2621+
.unwrap(),
2622+
);
2623+
2624+
let mask_input = |addrs: Vec<u64>| -> Arc<dyn ExecutionPlan> {
2625+
let covered: RoaringBitmap = dataset.fragments().iter().map(|f| f.id as u32).collect();
2626+
let batch =
2627+
IndexExprResult::exact(RowAddrMask::from_allowed(RowAddrTreeMap::from_iter(addrs)))
2628+
.serialize(&covered, IndexExprResultWireFormat::default())
2629+
.unwrap();
2630+
let schema = batch.schema();
2631+
let stream = futures::stream::once(async move { Ok(batch) });
2632+
Arc::new(OneShotExec::new(Box::pin(RecordBatchStreamAdapter::new(
2633+
schema, stream,
2634+
))))
2635+
};
2636+
let run = |input: Arc<dyn ExecutionPlan>| {
2637+
let dataset = dataset.clone();
2638+
async move {
2639+
// Pin the batch size so batch-count assertions don't depend
2640+
// on LANCE_DEFAULT_BATCH_SIZE
2641+
let options = FilteredReadOptions::basic_full_read(&dataset).with_batch_size(2000);
2642+
let plan =
2643+
FilteredReadExec::try_new(dataset.clone(), options, Some(input)).unwrap();
2644+
let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
2645+
stream.try_collect::<Vec<_>>().await.unwrap()
2646+
}
2647+
};
2648+
let addr = |frag: u32, offset: u32| u64::from(RowAddress::new_from_parts(frag, offset));
2649+
2650+
// Take shape: 20 fragments, 2 rows each -> one consolidated batch,
2651+
// rows in fragment order
2652+
let addrs: Vec<u64> = (0..20u32).flat_map(|f| [addr(f, 3), addr(f, 7)]).collect();
2653+
let batches = run(mask_input(addrs)).await;
2654+
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 40);
2655+
assert_eq!(batches.len(), 1);
2656+
let expected =
2657+
UInt32Array::from_iter_values((0..20u32).flat_map(|f| [f * 2000 + 3, f * 2000 + 7]));
2658+
assert_eq!(batches[0].column(0).as_ref(), &expected);
2659+
2660+
// Too few fragments -> inline path, one batch per fragment
2661+
let batches = run(mask_input(vec![addr(0, 3), addr(1, 7)])).await;
2662+
assert_eq!(batches.len(), 2);
2663+
2664+
// Dense (2000 planned rows per fragment) -> inline path
2665+
let addrs: Vec<u64> = (0..8u32)
2666+
.flat_map(|f| (0..2000u32).map(move |o| addr(f, o)))
2667+
.collect();
2668+
let batches = run(mask_input(addrs)).await;
2669+
assert_eq!(batches.len(), 8);
2670+
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 16000);
2671+
}
2672+
24482673
/// Round-trip every interval shape through the arrow wire format and
24492674
/// confirm the endpoints survive. Exercises both
24502675
/// `IndexExprResult::serialize` and `EvaluatedIndex::try_from_arrow`

0 commit comments

Comments
 (0)