Skip to content

Commit 22b0504

Browse files
committed
add element splits and documentation
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
1 parent e35b176 commit 22b0504

4 files changed

Lines changed: 189 additions & 17 deletions

File tree

vortex-file/src/strategy.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ impl WriteStrategyBuilder {
193193

194194
/// Enable writing list fields with [`ListLayoutStrategy`].
195195
///
196+
/// **Note**: this is an unstable and experimental layout that is expected to change.
197+
/// Using it may lead to unreadable files in the future.
198+
///
196199
/// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
197200
pub fn with_list_layout(mut self) -> Self {
198201
self.use_list_layout = true;

vortex-layout/src/layouts/list/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4+
//! An experimental structural layout for list-typed columns. Note that this is expected to change.
5+
//!
6+
//! [`ListLayout`] decomposes a list column into independently configurable child layouts:
7+
//! `elements`, `offsets`, and, for nullable lists, `validity`. Keeping the children independent allows
8+
//! each child to use its own configurable layout and lets nested list elements (e.g. with List<List>) be decomposed recursively.
9+
//!
10+
//! This provides benefits such as:
11+
//! * Reading only the children needed to evaluate an expression. For example, `ListLength` does
12+
//! not need to read list elements.
13+
//! * Restricting element reads to the range covered by the selected outer rows, avoiding elements
14+
//! belonging exclusively to unselected leading or trailing lists.
15+
//! * Allowing each child to use its own compression, chunking, and pruning strategy.
16+
417
mod expr;
518
mod reader;
619
pub mod writer;

vortex-layout/src/layouts/list/reader.rs

Lines changed: 165 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
4848
const 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)]
5255
pub 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).
422520
fn 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 {

vortex-layout/src/layouts/table.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,18 @@ impl TableStrategy {
169169
}
170170

171171
/// Enable writing list fields with [`ListLayoutStrategy`].
172+
///
173+
/// **Note**: this is an unstable and experimental layout that is expected to change.
174+
/// Using it may lead to unreadable files in the future.
172175
pub fn with_list_layout(self) -> Self {
173176
self.with_list_layout_factory(|strategy| Arc::new(strategy))
174177
}
175178

176-
/// Enable list layouts and transform each structural list writer into a physical strategy.
177-
/// The factory receives a fully configured writer and is invoked independently for every list,
178-
/// including nested lists.
179+
/// Enable writing list fields with [`ListLayoutStrategy`] and wrap each list writer. This
180+
/// allows repartitioning or zoning to operate in the list's outer-row space before shredding.
181+
///
182+
/// **Note**: this is an unstable and experimental layout that is expected to change.
183+
/// Using it may lead to unreadable files in the future.
179184
pub fn with_list_layout_factory(
180185
mut self,
181186
factory: impl Fn(ListLayoutStrategy) -> Arc<dyn LayoutStrategy> + Send + Sync + 'static,

0 commit comments

Comments
 (0)