perf: interleave retained rows at emit instead of a take per group entry - #25468
SubhamSinghal wants to merge 3 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25468 +/- ##
==========================================
+ Coverage 82.35% 82.38% +0.03%
==========================================
Files 1137 1138 +1
Lines 432746 433834 +1088
Branches 432746 433834 +1088
==========================================
+ Hits 356375 357410 +1035
- Misses 54843 54857 +14
- Partials 21528 21567 +39 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @SubhamSinghal , some suggestions
| @@ -2314,26 +2361,60 @@ impl PartitionedTopKDenseRank { | |||
|
|
|||
| let mut coalescer = BatchCoalescer::new(Arc::clone(&schema), batch_size); | |||
There was a problem hiding this comment.
Chunks are already exactly batch_size rows (last one ≤), so BatchCoalescer only re-copies full batches. Optional simplification; I measured no perf difference either way, and it drops the coalescer's view-buffer GC, so feel free to ignore.
- let mut coalescer = BatchCoalescer::new(Arc::clone(&schema), batch_size);
+ let mut out: Vec<Result<RecordBatch>> = Vec::new();
@@
if indices.len() == batch_size {
- coalescer.push_batch(interleave_record_batch(
- &batch_refs,
- &indices,
- )?)?;
+ let b = interleave_record_batch(&batch_refs, &indices)?;
+ (&b).record_output(&metrics.baseline);
+ out.push(Ok(b));
indices.clear();
}
@@
if !indices.is_empty() {
- coalescer.push_batch(interleave_record_batch(&batch_refs, &indices)?)?;
- }
- coalescer.finish_buffered_batch()?;
-
- let mut out: Vec<Result<RecordBatch>> = Vec::new();
- while let Some(b) = coalescer.next_completed_batch() {
+ let b = interleave_record_batch(&batch_refs, &indices)?;
(&b).record_output(&metrics.baseline);
out.push(Ok(b));
}| /// | ||
| /// INVARIANT: equals `recompute_contents_bytes` (test-only, so not | ||
| /// linkable from rustdoc). Every mutation of `groups` or `keys` must | ||
| /// adjust it; the `dense_rank_contents_bytes_tracks_recompute` test |
There was a problem hiding this comment.
| /// adjust it; the `dense_rank_contents_bytes_tracks_recompute` test | |
| /// adjust it; `test_partitioned_topk_dense_rank_contents_bytes_tracks_recompute` |
| // Chunk at `batch_size` so the operator emits the same batch sizes | ||
| // as before and never materializes all retained rows at once. |
There was a problem hiding this comment.
| // Chunk at `batch_size` so the operator emits the same batch sizes | |
| // as before and never materializes all retained rows at once. | |
| // Chunk at `batch_size` so the operator emits the same batch sizes | |
| // as before and no single `interleave` output exceeds `batch_size`. |
Emit is O(chunks × store batches × batch_size) for dictionary columns
Measured (release, 8192-row batches,
Fix: build the refs per chunk from only the batches it touches (neutral on the tiny-entry shape: 1.29s vs 1.29s dict, 0.58s vs 0.58s utf8). - let mut batch_refs = Vec::with_capacity(store.len());
- let mut batch_id_pos = HashMap::with_capacity(store.len());
- for (array_pos, (batch_id, entry)) in store.batches.iter().enumerate() {
- batch_refs.push(&entry.batch);
- batch_id_pos.insert(*batch_id, array_pos);
- }
+ // Rebuilt per chunk: `interleave` does per-input-array work for
+ // some types (dictionaries), so only pass batches the chunk uses.
+ let mut batch_refs: Vec<&RecordBatch> = Vec::new();
+ let mut batch_id_pos: HashMap<u32, usize> = HashMap::new();- let array_pos = batch_id_pos[&entry.batch_id];
+ let batch = &store
+ .get(entry.batch_id)
+ .expect("retained batch_id present in store")
+ .batch;
+ let mut array_pos = None;
for row in entry.row_indices {
- indices.push((array_pos, row as usize));
+ let pos = *array_pos.get_or_insert_with(|| {
+ *batch_id_pos.entry(entry.batch_id).or_insert_with(|| {
+ batch_refs.push(batch);
+ batch_refs.len() - 1
+ })
+ });
+ indices.push((pos, row as usize));
if indices.len() == batch_size {
let b = interleave_record_batch(&batch_refs, &indices)?;
(&b).record_output(&metrics.baseline);
out.push(Ok(b));
indices.clear();
+ batch_refs.clear();
+ batch_id_pos.clear();
+ array_pos = None;
}Please also add an emit test with a Dictionary column spanning ≥2 chunks. |
|
@jayzhan211 Thanks for review, addressed latest feedback in 2c76116 |
Which issue does this PR close?
Part of the
enable_window_topnwork (#24060).Rationale for this change
Profiling
PartitionedTopKDenseRank(/usr/bin/sample, 10M-row fixture,100K partitions) showed two costs that dominate the operator, neither of
which is inherent to DENSE_RANK semantics:
Emit was 61% of busy samples, 87% of it in one tiny
take_record_batchperGroupEntryplus the coalescer copy thatfollows. A
GroupEntryholds only the rows one source batchcontributed at one ob value, so with P partitions, K distinct ob
values and B contributing batches there are up to P x K x B of them.
TopKHeap::emit_with_statealready solves this exact shape with asingle
interleave_record_batch; DENSE_RANK just wasn't using it.size()was 8.3% self-time.DenseRankPartitionState::size()walked every group and every entry in every partition, and
size()is called once per
insert_batchto resize the reservation — so thewalk costs O(entries) per batch.
TopKHeapalready avoids this witha running
owned_bytestotal.What changes are included in this PR?
Two commits, both internal to
PartitionedTopKDenseRank:interleave_record_batch, chunkedat
batch_sizeso the operator emits the same batch sizes as beforeand never materializes all retained rows at once. Pairs are pushed in
emit order (partitions sorted, ob ascending, entries in insertion
order), so the output needs no post-sort.
DenseRankPartitionStategains acontents_bytesrunningtotal, maintained at the four mutation sites (append to an existing ob
group, insert with room, evict-then-insert, and the buffer growth each
can trigger).
No signature, plan-shape, config or semantic change.
PartitionedTopKand
PartitionedTopKRankare untouched.Are these changes tested?
Yes.
topk+ 42partitioned_topkunit tests,window_topn.slt,dev/rust_lint.sh(fmt + clippy) all pass.test_partitioned_topk_dense_rank_contents_bytes_tracks_recomputeasserts the incremental total against a full recompute after every
batch across 64 randomized shapes, reusing the
DiffShapeharnessfrom the correctness differential test. It asserts the workload
actually evicted, so the eviction case can't silently go unchecked.
Mutation-checked: zeroing one term in the eviction charge fails this
test and no other.
target_partitions = 1(this matters —
PartitionedTopKExecadvertises its output ordering,and breaking it would either produce wrong ranks or force a SortExec
back into the plan).
count(*)/sum(ob)/sum(rn)/sum(pk)digests unchanged.Are there any user-facing changes?
Faster
DENSE_RANK() OVER (PARTITION BY ...)top-N whendatafusion.optimizer.enable_window_topnis enabled. No API change.Benchmarks
benchmarks/queries/h2o/window.sqlq24–q29 (the DENSE_RANK sweep), 10M-rowJ1_1e7_1e7_NAparquet,dfbench h2o, 3 reps round-robin, medians in ms.Both binaries
--releasefrom the same base in one session;enable_window_topnviaDATAFUSION_OPTIMIZER_ENABLE_WINDOW_TOPN.