Skip to content

Commit 020a962

Browse files
committed
perf(layout): bound list reads by projection mask
Signed-off-by: Matt Katz <mhkatz97@gmail.com>
1 parent 3292598 commit 020a962

1 file changed

Lines changed: 95 additions & 52 deletions

File tree

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

Lines changed: 95 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use futures::FutureExt;
88
use futures::future::BoxFuture;
99
use futures::try_join;
1010
use vortex_array::ArrayRef;
11+
use vortex_array::Canonical;
1112
use vortex_array::IntoArray;
1213
use vortex_array::MaskFuture;
1314
use vortex_array::VortexSessionExecute;
@@ -140,37 +141,36 @@ impl ListReader {
140141
/// Projection for [`ListChildrenNeeded::All`] expressions. Materializes the list and applies
141142
/// the expression.
142143
///
143-
/// Dispatches between a bounded read for a strict sub-range and a concurrent read for the full
144-
/// column. The bounded read is valid for any elements layout and allows transparent wrappers,
145-
/// such as zoned or structural layouts, to push the range down to their children.
144+
/// An all-true mask over the full local range reads every child concurrently. Otherwise, the
145+
/// read is bounded to the first and last selected list.
146146
fn project_all(
147147
&self,
148148
row_range: &Range<u64>,
149149
expr: &Expression,
150150
mask: MaskFuture,
151151
) -> VortexResult<ArrayFuture> {
152152
let is_full_range = row_range.start == 0 && row_range.end == self.layout.row_count();
153-
if is_full_range {
154-
self.project_all_concurrent(expr, mask)
155-
} else {
156-
self.project_all_elements_bounded(row_range, expr, mask)
153+
let reader = self.clone();
154+
let row_range = row_range.clone();
155+
let expr = expr.clone();
156+
Ok(async move {
157+
let mask = mask.await?;
158+
if is_full_range && mask.all_true() {
159+
reader.project_all_full(&expr)?.await
160+
} else {
161+
reader.project_all_bounded(&row_range, &expr, mask)?.await
162+
}
157163
}
164+
.boxed())
158165
}
159166

160-
/// Full-column read: fetches the entire `elements`, `offsets`, and `validity` children
161-
/// concurrently — no offsets→elements round-trip, since the elements bound is the whole buffer.
162-
/// The materialized list is filtered by the caller mask.
163-
fn project_all_concurrent(
164-
&self,
165-
expr: &Expression,
166-
mask: MaskFuture,
167-
) -> VortexResult<ArrayFuture> {
167+
/// Fetch the complete `elements`, `offsets`, and `validity` children concurrently.
168+
fn project_all_full(&self, expr: &Expression) -> VortexResult<ArrayFuture> {
168169
let row_count = self.layout.row_count();
169170
let elements_row_count = self.elements.row_count();
170171
let nullability = self.layout.dtype().nullability();
171172
let expr = expr.clone();
172173

173-
// Fire all three child reads up front so they run concurrently and overlap the mask await.
174174
let offsets_fut = self.fetch_raw_offsets(&(0..row_count))?;
175175
let elements_fut = self.fetch_raw_elements(&(0..elements_row_count))?;
176176
let validity_fut = fetch_validity(
@@ -187,65 +187,60 @@ impl ListReader {
187187
ListArray::new_unchecked(elements, offsets, create_validity(validity, nullability))
188188
}
189189
.into_array();
190-
191-
// Filter before applying the expression: the expression may depend on the filtered
192-
// rows being removed (e.g. `cast(a, u8) where a < 256`).
193-
let mask = mask.await?;
194-
let list = if mask.all_true() {
195-
list
196-
} else {
197-
list.filter(mask)?
198-
};
199190
list.apply(&expr)
200191
}
201192
.boxed())
202193
}
203194

204-
/// Bounded read for a strict sub-range. Reads `offsets[row_range]`, decodes the first and last
205-
/// offset to bound the elements read to `[first..last)`, then rebases the offsets to index into
206-
/// that sliced buffer. Range-aware elements layouts can skip non-overlapping segments at the
207-
/// cost of one offsets→elements round-trip.
208-
fn project_all_elements_bounded(
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.
198+
fn project_all_bounded(
209199
&self,
210200
row_range: &Range<u64>,
211201
expr: &Expression,
212-
mask: MaskFuture,
202+
mask: Mask,
213203
) -> VortexResult<ArrayFuture> {
204+
// Crop to the smallest contiguous row range containing every selected list.
205+
let Some(selected_rows) = selected_row_range(&mask) else {
206+
let empty = Canonical::empty(self.layout.dtype()).into_array();
207+
let expr = expr.clone();
208+
return Ok(async move { empty.apply(&expr) }.boxed());
209+
};
210+
211+
let selected_mask = mask.slice(selected_rows.clone());
212+
let selected_row_range = (row_range.start + u64::try_from(selected_rows.start)?)
213+
..(row_range.start + u64::try_from(selected_rows.end)?);
214+
214215
let nullability = self.layout.dtype().nullability();
215216
let expr = expr.clone();
216-
let row_count = usize::try_from(row_range.end - row_range.start)?;
217217
let reader = self.clone();
218-
219-
let offsets_fut = self.fetch_raw_offsets(row_range)?;
220-
let validity_fut = fetch_validity(
221-
self.validity.as_ref(),
222-
row_range,
223-
MaskFuture::new_true(row_count),
224-
)?;
218+
let offsets_fut = self.fetch_raw_offsets(&selected_row_range)?;
225219

226220
Ok(async move {
227-
let (offsets, validity) = try_join!(offsets_fut, validity_fut)?;
228-
let elements_range = elements_range_from_offsets(&offsets, &reader.session)?;
221+
let offsets = offsets_fut.await?;
229222

230-
// Read only the elements this range covers.
231-
let elements = reader.fetch_raw_elements(&elements_range)?.await?;
223+
let elements_range = elements_range_from_offsets(&offsets, &reader.session)?;
224+
let elements_fut = reader.fetch_raw_elements(&elements_range)?;
225+
let validity_fut = fetch_validity(
226+
reader.validity.as_ref(),
227+
&selected_row_range,
228+
MaskFuture::new_true(selected_mask.len()),
229+
)?;
230+
let (elements, validity) = try_join!(elements_fut, validity_fut)?;
232231

233-
// Rebase the offsets to index into the sliced elements buffer.
234232
let offsets = rebase_offsets(offsets, elements_range.start)?;
235-
// SAFETY: the ListLayout children were written from a valid ListArray. Slicing the
236-
// elements to the first and last offsets and rebasing every offset by the first one
237-
// preserves the list invariants.
233+
// SAFETY: the selected offsets remain monotonically increasing, rebasing them against
234+
// the selected element range preserves their lengths, and validity covers the same
235+
// cropped list rows.
238236
let list = unsafe {
239237
ListArray::new_unchecked(elements, offsets, create_validity(validity, nullability))
240238
}
241239
.into_array();
242-
243-
// Filter before applying the expression (see `project_all_concurrent`).
244-
let mask = mask.await?;
245-
let list = if mask.all_true() {
240+
let list = if selected_mask.all_true() {
246241
list
247242
} else {
248-
list.filter(mask)?
243+
list.filter(selected_mask)?
249244
};
250245
list.apply(&expr)
251246
}
@@ -315,6 +310,10 @@ impl ListReader {
315310
}
316311
}
317312

313+
fn selected_row_range(mask: &Mask) -> Option<Range<usize>> {
314+
Some(mask.first()?..mask.last()? + 1)
315+
}
316+
318317
fn create_validity(validity_array: Option<ArrayRef>, nullability: Nullability) -> Validity {
319318
match validity_array {
320319
Some(arr) => Validity::Array(arr),
@@ -502,6 +501,8 @@ fn predicate_array_to_mask(array: ArrayRef, session: &VortexSession) -> VortexRe
502501
#[cfg(test)]
503502
mod tests {
504503
use std::ops::Range;
504+
use std::sync::atomic::AtomicUsize;
505+
use std::sync::atomic::Ordering;
505506

506507
use rstest::rstest;
507508
use vortex_array::ArrayContext;
@@ -527,6 +528,7 @@ mod tests {
527528
use crate::layouts::list::writer::ListLayoutStrategy;
528529
use crate::layouts::repartition::RepartitionStrategy;
529530
use crate::layouts::repartition::RepartitionWriterOptions;
531+
use crate::segments::SegmentFuture;
530532
use crate::segments::SegmentSource;
531533
use crate::segments::TestSegments;
532534
use crate::sequence::SequenceId;
@@ -930,6 +932,18 @@ mod tests {
930932
ListLayoutStrategy::default().with_elements(chunked_elements)
931933
}
932934

935+
struct CountingSegmentSource {
936+
inner: Arc<dyn SegmentSource>,
937+
request_count: Arc<AtomicUsize>,
938+
}
939+
940+
impl SegmentSource for CountingSegmentSource {
941+
fn request(&self, id: crate::segments::SegmentId) -> SegmentFuture {
942+
self.request_count.fetch_add(1, Ordering::Relaxed);
943+
self.inner.request(id)
944+
}
945+
}
946+
933947
/// The chunked-elements strategy must actually produce a chunked `elements` layout, otherwise
934948
/// the reader would silently take the whole-chunk path and the bounded read would be untested.
935949
#[tokio::test]
@@ -945,6 +959,34 @@ mod tests {
945959
Ok(())
946960
}
947961

962+
/// A sparse mask must remain selective even when the requested row range covers the complete
963+
/// local list layout. The selected first list touches one element chunk plus the offsets
964+
/// segment; reading all five element chunks would make the count six.
965+
#[tokio::test]
966+
async fn full_range_sparse_mask_crops_element_read() -> VortexResult<()> {
967+
let list = create_wider_list_array(false);
968+
let ctx = LayoutReaderContext::new();
969+
let (segments, layout, session) =
970+
write_layout(&chunked_elements_list_strategy(), list.clone()).await?;
971+
let request_count = Arc::new(AtomicUsize::new(0));
972+
let source = Arc::new(CountingSegmentSource {
973+
inner: segments,
974+
request_count: Arc::clone(&request_count),
975+
});
976+
let reader = layout.new_reader("".into(), source, &session, &ctx)?;
977+
978+
let mask = Mask::from_iter([true, false, false, false, false]);
979+
let result = reader
980+
.projection_evaluation(&(0..5), &root(), MaskFuture::ready(mask.clone()))?
981+
.await?;
982+
983+
let expected = list.filter(mask)?;
984+
let mut exec_ctx = session.create_execution_ctx();
985+
assert_arrays_eq!(result, expected, &mut exec_ctx);
986+
assert_eq!(request_count.load(Ordering::Relaxed), 2);
987+
Ok(())
988+
}
989+
948990
/// With chunked elements, sub-range projections take the bounded read path. Every
949991
/// range/mask/nullability combination must match the same projection over the ground-truth
950992
/// array (`list.slice(range).filter(mask)`).
@@ -958,6 +1000,7 @@ mod tests {
9581000
#[case::single_empty_row(2..3, Mask::from_iter([true]), false)]
9591001
#[case::empty_range(2..2, Mask::new_true(0), false)]
9601002
#[case::subrange_all_false(1..4, Mask::new_false(3), false)]
1003+
#[case::subrange_single_interior(0..4, Mask::from_iter([false, true, false, false]), false)]
9611004
#[case::subrange_sparse_nullable(1..4, Mask::from_iter([true, false, true]), true)]
9621005
#[case::partial_end_nullable(2..5, Mask::new_true(3), true)]
9631006
#[tokio::test]

0 commit comments

Comments
 (0)