Skip to content

Commit 95185fa

Browse files
Dandandanclaude
andcommitted
perf: split the tail of a shared scan queue into ~1MiB projected-size morsels
When a pop would leave the shared work queue empty, split the final byte range into small morsels (halving down to a floor) and push the excess back, so sibling streams that finish their pieces early steal a share of the last piece instead of idling behind one straggler. Work items are returned exactly as the planner sized them while the queue is deep. The morsel floor is ~1MiB of data the scan actually reads: the file-range floor is scaled by the fraction of file columns referenced by the projection and filter, so narrow projections produce proportionally larger byte ranges and the fixed per-piece open cost stays amortized. TPC-DS SF=1 (single-row-group files): 4.4% faster overall (7.53s -> 7.21s), 28/99 queries faster, up to 1.14x, no meaningful regressions. TPC-H SF=1: 6.1% faster overall (1084ms -> 1022ms), 9/22 faster, 0 slower. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 6d1a25a commit 95185fa

1 file changed

Lines changed: 226 additions & 8 deletions

File tree

‎datafusion/datasource/src/file_stream/work_source.rs‎

Lines changed: 226 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,26 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::collections::VecDeque;
18+
use std::collections::{HashSet, VecDeque};
1919
use std::sync::Arc;
2020

21-
use crate::PartitionedFile;
2221
use crate::file_groups::FileGroup;
2322
use crate::file_scan_config::FileScanConfig;
23+
use crate::{FileRange, PartitionedFile};
24+
use datafusion_physical_expr::utils::collect_columns;
2425
use parking_lot::Mutex;
2526

27+
/// Minimum size of a morsel produced by lazy splitting, in bytes of data the
28+
/// scan actually reads (i.e. projected, compressed bytes).
29+
///
30+
/// Each piece pays a fixed cost to open (metadata load, pruning, stream
31+
/// setup), which smaller pieces amortize poorly. Since work items are split
32+
/// by whole-file byte ranges, this floor is translated into file-range bytes
33+
/// by dividing by the estimated fraction of columns the scan reads (see
34+
/// [`projected_fraction`]): reading 1 of 10 columns makes the file-range
35+
/// floor 10x larger so a morsel still carries ~this many bytes of real work.
36+
const MORSEL_MIN_PROJECTED_SIZE: i64 = 1024 * 1024;
37+
2638
/// Source of work for `ScanState`.
2739
///
2840
/// Streams that may share work across siblings use [`WorkSource::Shared`],
@@ -70,16 +82,53 @@ pub(crate) struct SharedWorkSource {
7082

7183
#[derive(Debug, Default)]
7284
pub(super) struct SharedWorkSourceInner {
73-
files: Mutex<VecDeque<PartitionedFile>>,
85+
state: Mutex<VecDeque<PartitionedFile>>,
86+
/// Number of sibling streams consuming from this queue. `0` disables
87+
/// tail splitting of popped byte ranges.
88+
consumers: usize,
89+
/// Minimum byte-range size of a tail-split morsel
90+
/// ([`MORSEL_MIN_PROJECTED_SIZE`] scaled by the projected column
91+
/// fraction).
92+
min_morsel_size: i64,
93+
}
94+
95+
fn range_size(file: &PartitionedFile) -> i64 {
96+
file.range.as_ref().map_or(0, |r| r.end - r.start)
7497
}
7598

7699
impl SharedWorkSource {
77-
/// Create a shared work source containing the provided unopened files.
100+
/// Create a shared work source containing the provided unopened files,
101+
/// without lazy morsel splitting.
102+
#[cfg(test)]
78103
pub(crate) fn new(files: impl IntoIterator<Item = PartitionedFile>) -> Self {
79-
let files = files.into_iter().collect();
104+
Self::with_consumers(files, 0)
105+
}
106+
107+
/// Create a shared work source consumed by `consumers` sibling streams
108+
/// that lazily splits popped byte ranges into morsels sized to the
109+
/// remaining work.
110+
#[cfg(test)]
111+
pub(crate) fn with_consumers(
112+
files: impl IntoIterator<Item = PartitionedFile>,
113+
consumers: usize,
114+
) -> Self {
115+
Self::with_consumers_and_min_morsel_size(
116+
files,
117+
consumers,
118+
MORSEL_MIN_PROJECTED_SIZE,
119+
)
120+
}
121+
122+
fn with_consumers_and_min_morsel_size(
123+
files: impl IntoIterator<Item = PartitionedFile>,
124+
consumers: usize,
125+
min_morsel_size: i64,
126+
) -> Self {
80127
Self {
81128
inner: Arc::new(SharedWorkSourceInner {
82-
files: Mutex::new(files),
129+
state: Mutex::new(files.into_iter().collect()),
130+
consumers,
131+
min_morsel_size,
83132
}),
84133
}
85134
}
@@ -97,13 +146,182 @@ impl SharedWorkSource {
97146
.cloned()
98147
.collect();
99148
let files = config.file_source.reorder_files(files);
100-
Self::new(files)
149+
// A morsel should carry ~MORSEL_MIN_PROJECTED_SIZE bytes of data the
150+
// scan actually reads; scale the file-range floor by the fraction of
151+
// columns referenced.
152+
let min_morsel_size =
153+
(MORSEL_MIN_PROJECTED_SIZE as f64 / projected_fraction(config)) as i64;
154+
Self::with_consumers_and_min_morsel_size(
155+
files,
156+
config.file_groups.len(),
157+
min_morsel_size,
158+
)
101159
}
102160

103161
/// Pop the next file from the shared work queue.
104162
///
105163
/// Returns `None` if the queue is empty
164+
///
165+
/// # Tail morsel splitting
166+
///
167+
/// While the queue is deep, popped work items are returned exactly as
168+
/// the planner sized them. Only at the tail of the scan — when a pop
169+
/// would leave the queue empty — is the final byte range split into
170+
/// `min_morsel_size` morsels (the excess halves are pushed back), so
171+
/// sibling streams that finish their pieces early steal a share of the
172+
/// last piece instead of idling behind one straggler.
106173
fn pop_front(&self) -> Option<PartitionedFile> {
107-
self.inner.files.lock().pop_front()
174+
let mut files = self.inner.state.lock();
175+
let mut file = files.pop_front()?;
176+
177+
if self.inner.consumers > 0 && files.is_empty() {
178+
while range_size(&file) >= 2 * self.inner.min_morsel_size {
179+
let second_half = split_off_second_half(&mut file)
180+
.expect("file with a non-empty byte range is splittable");
181+
files.push_front(second_half);
182+
}
183+
}
184+
Some(file)
185+
}
186+
}
187+
188+
/// Estimate the fraction of a file's bytes this scan reads, as the number of
189+
/// distinct file columns referenced by its projection and filter over the
190+
/// file schema width. This is a column-count approximation (columns are
191+
/// assumed equally wide), used to translate the projected-byte morsel floor
192+
/// into file-range bytes.
193+
fn projected_fraction(config: &FileScanConfig) -> f64 {
194+
let num_fields = config
195+
.file_source
196+
.table_schema()
197+
.file_schema()
198+
.fields()
199+
.len();
200+
if num_fields == 0 {
201+
return 1.0;
202+
}
203+
let Some(projection) = config.file_source.projection() else {
204+
return 1.0;
205+
};
206+
let mut columns = HashSet::new();
207+
for expr in projection.iter() {
208+
columns.extend(collect_columns(&expr.expr));
209+
}
210+
if let Some(filter) = config.file_source.filter() {
211+
columns.extend(collect_columns(&filter));
212+
}
213+
// Count at least one column (a bare `count(*)` still reads something)
214+
// and at most the file schema width (the projection may also reference
215+
// partition or virtual columns).
216+
(columns.len().clamp(1, num_fields) as f64) / num_fields as f64
217+
}
218+
219+
/// Shrink `file` to the first half of its byte range and return a clone
220+
/// covering the second half. Returns `None` (leaving `file` untouched) for
221+
/// unranged files, which the planner has not marked as byte-splittable.
222+
fn split_off_second_half(file: &mut PartitionedFile) -> Option<PartitionedFile> {
223+
let FileRange { start, end } = *file.range.as_ref()?;
224+
let mid = start + (end - start) / 2;
225+
let mut second_half = file.clone();
226+
second_half.range = Some(FileRange { start: mid, end });
227+
file.range = Some(FileRange { start, end: mid });
228+
Some(second_half)
229+
}
230+
231+
#[cfg(test)]
232+
mod tests {
233+
use super::*;
234+
235+
const MIB: i64 = 1024 * 1024;
236+
237+
fn ranged_file(size: i64) -> PartitionedFile {
238+
PartitionedFile::new("test.parquet".to_string(), size as u64).with_range(0, size)
239+
}
240+
241+
/// Pop everything and return the byte ranges in pop order.
242+
fn drain(source: &SharedWorkSource) -> Vec<FileRange> {
243+
std::iter::from_fn(|| source.pop_front().map(|f| f.range.unwrap())).collect()
244+
}
245+
246+
#[test]
247+
fn lazy_split_tiles_range_into_morsels() {
248+
let source = SharedWorkSource::with_consumers([ranged_file(8 * MIB)], 2);
249+
let ranges = drain(&source);
250+
251+
// The single range was split into several morsels
252+
assert!(ranges.len() > 1, "expected splitting, got {ranges:?}");
253+
// Morsels are contiguous, ascending, and tile [0, 8MiB) exactly
254+
assert_eq!(ranges.first().unwrap().start, 0);
255+
assert_eq!(ranges.last().unwrap().end, 8 * MIB);
256+
for pair in ranges.windows(2) {
257+
assert_eq!(pair[0].end, pair[1].start, "gap or overlap: {ranges:?}");
258+
}
259+
// No morsel is smaller than the floor
260+
for r in &ranges {
261+
assert!(
262+
r.end - r.start >= MORSEL_MIN_PROJECTED_SIZE,
263+
"too small: {ranges:?}"
264+
);
265+
}
266+
}
267+
268+
#[test]
269+
fn no_split_without_consumers() {
270+
let source = SharedWorkSource::new([ranged_file(8 * MIB)]);
271+
assert_eq!(
272+
drain(&source),
273+
vec![FileRange {
274+
start: 0,
275+
end: 8 * MIB
276+
}]
277+
);
278+
}
279+
280+
#[test]
281+
fn deep_backlog_pops_whole_ranges() {
282+
// With plenty of remaining work relative to the consumer count, the
283+
// fair share exceeds the range size and pieces are not split.
284+
let files = (0..8).map(|_| ranged_file(8 * MIB));
285+
let source = SharedWorkSource::with_consumers(files, 1);
286+
let first = source.pop_front().unwrap().range.unwrap();
287+
assert_eq!(
288+
first,
289+
FileRange {
290+
start: 0,
291+
end: 8 * MIB
292+
}
293+
);
294+
}
295+
296+
#[test]
297+
fn morsels_shrink_toward_the_tail() {
298+
let files = (0..4).map(|i| {
299+
PartitionedFile::new("test.parquet".to_string(), 32 * MIB as u64)
300+
.with_range(i * 8 * MIB, (i + 1) * 8 * MIB)
301+
});
302+
let source = SharedWorkSource::with_consumers(files, 4);
303+
let ranges = drain(&source);
304+
assert!(ranges.len() > 4, "expected splitting, got {ranges:?}");
305+
let first = ranges.first().unwrap();
306+
let last = ranges.last().unwrap();
307+
assert!(
308+
last.end - last.start < first.end - first.start,
309+
"tail morsels should be smaller: {ranges:?}"
310+
);
311+
}
312+
313+
#[test]
314+
fn unranged_files_are_not_split() {
315+
let file = PartitionedFile::new("test.parquet".to_string(), 8 * MIB as u64);
316+
let source = SharedWorkSource::with_consumers([file], 4);
317+
let popped = source.pop_front().unwrap();
318+
assert_eq!(popped.range, None);
319+
assert!(source.pop_front().is_none());
320+
}
321+
322+
#[test]
323+
fn small_ranges_are_not_split() {
324+
let source = SharedWorkSource::with_consumers([ranged_file(MIB)], 4);
325+
assert_eq!(drain(&source), vec![FileRange { start: 0, end: MIB }]);
108326
}
109327
}

0 commit comments

Comments
 (0)