1515// specific language governing permissions and limitations
1616// under the License.
1717
18- use std:: collections:: VecDeque ;
18+ use std:: collections:: { HashSet , VecDeque } ;
1919use std:: sync:: Arc ;
2020
21- use crate :: PartitionedFile ;
2221use crate :: file_groups:: FileGroup ;
2322use crate :: file_scan_config:: FileScanConfig ;
23+ use crate :: { FileRange , PartitionedFile } ;
24+ use datafusion_physical_expr:: utils:: collect_columns;
2425use 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 ) ]
7284pub ( 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
7699impl 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