@@ -47,6 +47,9 @@ type OptionalArrayFuture = BoxFuture<'static, VortexResult<Option<ArrayRef>>>;
4747/// and above which we evaluate the expression over all rows and intersect afterward.
4848const EXPR_EVAL_THRESHOLD : f64 = 0.2 ;
4949
50+ /// Maximum number of outer-row scan ranges contributed by one list layout.
51+ const MAX_LIST_SPLIT_COUNT : u64 = 64 ;
52+
5053/// Reader for [`ListLayout`].
5154#[ derive( Clone ) ]
5255pub struct ListReader {
@@ -138,8 +141,7 @@ impl ListReader {
138141 . boxed ( ) )
139142 }
140143
141- /// Projection for [`ListChildrenNeeded::All`] expressions. Materializes the list and applies
142- /// the expression.
144+ /// Projection for [`ListChildrenNeeded::All`] expressions.
143145 ///
144146 /// An all-true mask over the full local range reads every child concurrently. Otherwise, the
145147 /// read is bounded to the first and last selected list.
@@ -192,9 +194,11 @@ impl ListReader {
192194 . boxed ( ) )
193195 }
194196
195- /// Bounded read for a sub-range or selective mask. Crops leading and trailing unselected lists,
196- /// reads the element range they cover, and delegates any interior filtering to the reconstructed
197- /// list array.
197+ /// Bounded read for a sub-range or selective mask.
198+ ///
199+ /// Crops leading and trailing unselected lists, reads their offsets, and translates the first
200+ /// and last offset into the element-row range to fetch. Any holes in the selection are filtered
201+ /// after reconstructing the list array.
198202 fn project_all_bounded (
199203 & self ,
200204 row_range : & Range < u64 > ,
@@ -343,19 +347,63 @@ impl LayoutReader for ListReader {
343347
344348 fn register_splits (
345349 & self ,
346- field_mask : & [ FieldMask ] ,
350+ _field_mask : & [ FieldMask ] ,
347351 split_range : & SplitRange ,
348352 splits : & mut RowSplits ,
349353 ) -> VortexResult < ( ) > {
350- self . offsets
351- . register_splits ( field_mask, split_range, splits) ?;
352- if let Some ( validity) = & self . validity {
353- validity. register_splits ( field_mask, split_range, splits) ?;
354+ split_range. check_bounds ( self . layout . row_count ( ) ) ?;
355+
356+ // Splits are difficult to calculate because all children live in different row coordinate spaces.
357+ // List elements typically comprise the majority of the data in a list, and validity/offsets can be treated
358+ // as metadata. We therefore want to parallelize the scan based on element work.
359+ //
360+ // Scan splits must be expressed in the list layout's outer-row space, but the elements child
361+ // reports its natural boundaries in element-row space. So we translate the element splits using a
362+ // heuristic to outer-row space.
363+
364+ let element_row_count = self . elements . row_count ( ) ;
365+ if element_row_count != 0 {
366+ let mut element_splits = RowSplits :: new_capacity ( 128 ) ;
367+ self . elements . register_splits (
368+ & [ FieldMask :: All ] ,
369+ & SplitRange :: root ( 0 ..element_row_count) ?,
370+ & mut element_splits,
371+ ) ?;
372+
373+ let row_range = split_range. row_range ( ) ;
374+ let mut last_split = None ;
375+ for element_split in element_splits. into_sorted_deduped ( ) {
376+ let Some ( split) = map_element_split_to_outer_grid (
377+ element_split,
378+ element_row_count,
379+ self . layout . row_count ( ) ,
380+ MAX_LIST_SPLIT_COUNT ,
381+ ) else {
382+ continue ;
383+ } ;
384+ if split <= row_range. start {
385+ continue ;
386+ }
387+ if split >= row_range. end {
388+ break ;
389+ }
390+ if last_split == Some ( split) {
391+ continue ;
392+ }
393+ splits. push (
394+ split_range
395+ . row_offset ( )
396+ . checked_add ( split)
397+ . vortex_expect ( "List layout split offset overflow" ) ,
398+ ) ;
399+ last_split = Some ( split) ;
400+ }
354401 }
402+ splits. push ( split_range. root_row_range ( ) . end ) ;
355403 Ok ( ( ) )
356404 }
357405
358- // TODO(mk): handle zones for lists
406+ // TODO(mk): handle zones for list elements
359407 fn pruning_evaluation (
360408 & self ,
361409 _row_range : & Range < u64 > ,
@@ -400,6 +448,10 @@ impl LayoutReader for ListReader {
400448 } ) )
401449 }
402450
451+ /// Reads only the list children needed to evaluate `expr`.
452+ ///
453+ /// Validity-only expressions avoid offsets and elements, length expressions read offsets and
454+ /// validity, and general expressions reconstruct a list from all applicable children.
403455 fn projection_evaluation (
404456 & self ,
405457 row_range : & Range < u64 > ,
@@ -417,6 +469,52 @@ impl LayoutReader for ListReader {
417469 }
418470}
419471
472+ /// Converts a natural boundary from element-row space into an approximate outer-row scan split.
473+ ///
474+ /// Scan splits must be expressed in the list layout's outer-row space, but the elements child
475+ /// reports its natural boundaries in element-row space. Translating a boundary exactly would
476+ /// require consulting the list offsets, so this function instead preserves its relative position:
477+ ///
478+ /// ```text
479+ /// element_split / element_row_count ≈ outer_split / outer_row_count
480+ /// ```
481+ ///
482+ /// The relative position is first rounded onto a grid containing at most `max_split_count` scan
483+ /// ranges, then mapped into outer-row space. Multiple element boundaries may therefore map to the
484+ /// same outer split and are deduplicated by the caller. With a grid size of 64, at most 63 interior
485+ /// splits—and therefore 64 scan ranges—can be produced.
486+ ///
487+ /// The result is only a task-sizing hint. It is always between outer rows, but it is not guaranteed
488+ /// to correspond exactly to the original physical element boundary. Endpoint boundaries are
489+ /// omitted because they do not subdivide the scan
490+ fn map_element_split_to_outer_grid (
491+ element_split : u64 ,
492+ element_row_count : u64 ,
493+ outer_row_count : u64 ,
494+ max_split_count : u64 ,
495+ ) -> Option < u64 > {
496+ if element_split == 0
497+ || element_split >= element_row_count
498+ || outer_row_count == 0
499+ || max_split_count < 2
500+ {
501+ return None ;
502+ }
503+ debug_assert ! ( max_split_count. is_power_of_two( ) ) ;
504+
505+ let grid_index = ( u128:: from ( element_split) * u128:: from ( max_split_count)
506+ + u128:: from ( element_row_count / 2 ) )
507+ / u128:: from ( element_row_count) ;
508+ if grid_index == 0 || grid_index >= u128:: from ( max_split_count) {
509+ return None ;
510+ }
511+
512+ let outer_split = grid_index * u128:: from ( outer_row_count) / u128:: from ( max_split_count) ;
513+ let outer_split = u64:: try_from ( outer_split)
514+ . vortex_expect ( "Outer split is bounded by the list layout row count" ) ;
515+ ( outer_split != 0 && outer_split < outer_row_count) . then_some ( outer_split)
516+ }
517+
420518/// Fetch the validity child for `row_range` under `mask`, yielding `None` for a non-nullable list
421519/// (which has no validity child).
422520fn fetch_validity (
@@ -528,6 +626,7 @@ mod tests {
528626 use crate :: layouts:: list:: writer:: ListLayoutStrategy ;
529627 use crate :: layouts:: repartition:: RepartitionStrategy ;
530628 use crate :: layouts:: repartition:: RepartitionWriterOptions ;
629+ use crate :: scan:: split_by:: SplitBy ;
531630 use crate :: segments:: SegmentFuture ;
532631 use crate :: segments:: SegmentSource ;
533632 use crate :: segments:: TestSegments ;
@@ -917,19 +1016,71 @@ mod tests {
9171016 Ok ( ( ) )
9181017 }
9191018
1019+ #[ test]
1020+ fn maps_element_splits_to_outer_grid ( ) {
1021+ assert_eq ! ( map_element_split_to_outer_grid( 0 , 100 , 100 , 8 ) , None ) ;
1022+ assert_eq ! ( map_element_split_to_outer_grid( 20 , 100 , 100 , 8 ) , Some ( 25 ) ) ;
1023+ assert_eq ! ( map_element_split_to_outer_grid( 25 , 100 , 100 , 8 ) , Some ( 25 ) ) ;
1024+ assert_eq ! ( map_element_split_to_outer_grid( 50 , 100 , 100 , 8 ) , Some ( 50 ) ) ;
1025+ assert_eq ! ( map_element_split_to_outer_grid( 75 , 100 , 100 , 8 ) , Some ( 75 ) ) ;
1026+ assert_eq ! ( map_element_split_to_outer_grid( 100 , 100 , 100 , 8 ) , None ) ;
1027+
1028+ let mut splits = ( 1 ..1_000 )
1029+ . filter_map ( |split| {
1030+ map_element_split_to_outer_grid ( split, 1_000 , 100_000 , MAX_LIST_SPLIT_COUNT )
1031+ } )
1032+ . collect :: < Vec < _ > > ( ) ;
1033+ splits. dedup ( ) ;
1034+
1035+ let expected = ( 1 ..MAX_LIST_SPLIT_COUNT )
1036+ . map ( |grid_index| grid_index * 100_000 / MAX_LIST_SPLIT_COUNT )
1037+ . collect :: < Vec < _ > > ( ) ;
1038+ assert_eq ! ( splits, expected) ;
1039+ }
1040+
1041+ #[ tokio:: test]
1042+ async fn nested_list_propagates_element_splits ( ) -> VortexResult < ( ) > {
1043+ let inner = ListArray :: try_new (
1044+ PrimitiveArray :: from_iter ( 0 ..128_i32 ) . into_array ( ) ,
1045+ PrimitiveArray :: from_iter ( ( 0 ..=8_u32 ) . map ( |idx| idx * 16 ) ) . into_array ( ) ,
1046+ Validity :: NonNullable ,
1047+ ) ?
1048+ . into_array ( ) ;
1049+ let outer = ListArray :: try_new (
1050+ inner,
1051+ buffer ! [ 0u32 , 2 , 4 , 6 , 8 ] . into_array ( ) ,
1052+ Validity :: NonNullable ,
1053+ ) ?
1054+ . into_array ( ) ;
1055+
1056+ let inner_strategy =
1057+ ListLayoutStrategy :: default ( ) . with_elements ( chunked_elements_strategy ( ) ) ;
1058+ let strategy = ListLayoutStrategy :: default ( ) . with_elements ( Arc :: new ( inner_strategy) ) ;
1059+ let ( segments, layout, session) = write_layout ( & strategy, outer) . await ?;
1060+ let reader =
1061+ layout. new_reader ( "" . into ( ) , segments, & session, & LayoutReaderContext :: new ( ) ) ?;
1062+
1063+ let splits = SplitBy :: Layout . splits ( reader. as_ref ( ) , & ( 0 ..4 ) , & [ FieldMask :: All ] ) ?;
1064+ assert_eq ! ( splits, vec![ 0 , 1 , 2 , 3 , 4 ] ) ;
1065+ Ok ( ( ) )
1066+ }
1067+
9201068 /// A list strategy whose `elements` child is repartitioned into two-element chunks, so the
9211069 /// reader takes the bounded (chunk-skipping) path for strict sub-ranges. Offsets stay flat.
9221070 fn chunked_elements_list_strategy ( ) -> ListLayoutStrategy {
923- let chunked_elements: Arc < dyn LayoutStrategy > = Arc :: new ( RepartitionStrategy :: new (
1071+ ListLayoutStrategy :: default ( ) . with_elements ( chunked_elements_strategy ( ) )
1072+ }
1073+
1074+ fn chunked_elements_strategy ( ) -> Arc < dyn LayoutStrategy > {
1075+ Arc :: new ( RepartitionStrategy :: new (
9241076 ChunkedLayoutStrategy :: new ( FlatLayoutStrategy :: default ( ) ) ,
9251077 RepartitionWriterOptions {
9261078 block_size_minimum : 0 ,
9271079 block_len_multiple : 2 ,
9281080 block_size_target : None ,
9291081 canonicalize : true ,
9301082 } ,
931- ) ) ;
932- ListLayoutStrategy :: default ( ) . with_elements ( chunked_elements)
1083+ ) )
9331084 }
9341085
9351086 struct CountingSegmentSource {
0 commit comments