Skip to content

Commit e01487c

Browse files
authored
Add take-based rebuild strategy for ListView with small lists (vortex-data#6492)
- Split `naive_rebuild` into two strategies: `rebuild_with_take` (bulk take) and `rebuild_list_by_list` (per-list slice + builder) - Add a simple heuristic (`should_use_take`) that picks take when average list size < 128, defaulting to list-by-list otherwise - Take avoids per-list builder overhead (slice + extend) and is 10-100× faster for small lists - Arrow exporter uses the same codepath --------- Signed-off-by: Baris Palaska <barispalaska@gmail.com>
1 parent 8e94ee6 commit e01487c

File tree

2 files changed

+155
-95
lines changed

2 files changed

+155
-95
lines changed

vortex-array/src/arrays/listview/rebuild.rs

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,92 @@ impl ListViewArray {
106106
})
107107
}
108108

109-
// TODO(connor)[ListView]: We should benchmark if it is faster to use `take` on the elements
110-
// instead of using a builder.
111-
/// The inner function for `rebuild_zero_copy_to_list`, which rebuilds a `ListViewArray` piece
112-
/// by piece.
109+
/// Picks between [`rebuild_with_take`](Self::rebuild_with_take) and
110+
/// [`rebuild_list_by_list`](Self::rebuild_list_by_list) based on element dtype and average
111+
/// list size.
113112
fn naive_rebuild<O: IntegerPType, NewOffset: IntegerPType, S: IntegerPType>(
114113
&self,
114+
) -> VortexResult<ListViewArray> {
115+
let sizes_canonical = self.sizes().to_primitive();
116+
let total: u64 = sizes_canonical
117+
.as_slice::<S>()
118+
.iter()
119+
.map(|s| (*s).as_() as u64)
120+
.sum();
121+
if Self::should_use_take(total, self.len()) {
122+
self.rebuild_with_take::<O, NewOffset, S>()
123+
} else {
124+
self.rebuild_list_by_list::<O, NewOffset, S>()
125+
}
126+
}
127+
128+
/// Returns `true` when we are confident that `rebuild_with_take` will
129+
/// outperform `rebuild_list_by_list`.
130+
///
131+
/// Take is dramatically faster for small lists (often 10-100×) because it
132+
/// avoids per-list builder overhead. LBL is the safer default for larger
133+
/// lists since its sequential memcpy scales well. We only choose take when
134+
/// the average list size is small enough that take clearly dominates.
135+
fn should_use_take(total_output_elements: u64, num_lists: usize) -> bool {
136+
if num_lists == 0 {
137+
return true;
138+
}
139+
let avg = total_output_elements / num_lists as u64;
140+
avg < 128
141+
}
142+
143+
/// Rebuilds elements using a single bulk `take`: collect all element indices into a flat
144+
/// `BufferMut<u64>`, perform a single `take`.
145+
fn rebuild_with_take<O: IntegerPType, NewOffset: IntegerPType, S: IntegerPType>(
146+
&self,
147+
) -> VortexResult<ListViewArray> {
148+
let offsets_canonical = self.offsets().to_primitive();
149+
let offsets_slice = offsets_canonical.as_slice::<O>();
150+
let sizes_canonical = self.sizes().to_primitive();
151+
let sizes_slice = sizes_canonical.as_slice::<S>();
152+
153+
let len = offsets_slice.len();
154+
155+
let mut new_offsets = BufferMut::<NewOffset>::with_capacity(len);
156+
let mut new_sizes = BufferMut::<S>::with_capacity(len);
157+
let mut take_indices = BufferMut::<u64>::with_capacity(self.elements().len());
158+
159+
let mut n_elements = NewOffset::zero();
160+
for index in 0..len {
161+
if !self.is_valid(index)? {
162+
new_offsets.push(n_elements);
163+
new_sizes.push(S::zero());
164+
continue;
165+
}
166+
167+
let offset = offsets_slice[index];
168+
let size = sizes_slice[index];
169+
let start = offset.as_();
170+
let stop = start + size.as_();
171+
172+
new_offsets.push(n_elements);
173+
new_sizes.push(size);
174+
take_indices.extend(start as u64..stop as u64);
175+
n_elements += num_traits::cast(size).vortex_expect("Cast failed");
176+
}
177+
178+
let elements = self.elements().take(take_indices.into_array())?;
179+
let offsets = new_offsets.into_array();
180+
let sizes = new_sizes.into_array();
181+
182+
// SAFETY: same invariants as `rebuild_list_by_list` — offsets are sequential and
183+
// non-overlapping, all (offset, size) pairs reference valid elements, and the validity
184+
// array is preserved from the original.
185+
Ok(unsafe {
186+
ListViewArray::new_unchecked(elements, offsets, sizes, self.validity.clone())
187+
.with_zero_copy_to_list(true)
188+
})
189+
}
190+
191+
/// Rebuilds elements list-by-list: canonicalize elements upfront, then for each list `slice`
192+
/// the relevant range and `extend_from_array` into a typed builder.
193+
fn rebuild_list_by_list<O: IntegerPType, NewOffset: IntegerPType, S: IntegerPType>(
194+
&self,
115195
) -> VortexResult<ListViewArray> {
116196
let element_dtype = self
117197
.dtype()
@@ -273,6 +353,7 @@ impl ListViewArray {
273353
}
274354

275355
#[cfg(test)]
356+
#[allow(clippy::cast_possible_truncation)]
276357
mod tests {
277358
use vortex_buffer::BitBuffer;
278359
use vortex_error::VortexResult;
@@ -459,4 +540,19 @@ mod tests {
459540
);
460541
Ok(())
461542
}
543+
544+
// ── should_use_take heuristic tests ────────────────────────────────────
545+
546+
#[test]
547+
fn heuristic_zero_lists_uses_take() {
548+
assert!(ListViewArray::should_use_take(0, 0));
549+
}
550+
551+
#[test]
552+
fn heuristic_small_lists_use_take() {
553+
// avg = 127 → take
554+
assert!(ListViewArray::should_use_take(127_000, 1_000));
555+
// avg = 128 → LBL
556+
assert!(!ListViewArray::should_use_take(128_000, 1_000));
557+
}
462558
}

vortex-array/src/arrow/executor/list.rs

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

4-
use std::any::type_name;
54
use std::sync::Arc;
65

76
use arrow_array::ArrayRef as ArrowArrayRef;
@@ -13,26 +12,23 @@ use vortex_buffer::BufferMut;
1312
use vortex_error::VortexExpect;
1413
use vortex_error::VortexResult;
1514
use vortex_error::vortex_ensure;
16-
use vortex_error::vortex_err;
1715

1816
use crate::Array;
1917
use crate::ArrayRef;
2018
use crate::Canonical;
2119
use crate::ExecutionCtx;
22-
use crate::IntoArray;
2320
use crate::arrays::ListArray;
2421
use crate::arrays::ListVTable;
2522
use crate::arrays::ListViewArray;
2623
use crate::arrays::ListViewArrayParts;
24+
use crate::arrays::ListViewRebuildMode;
2725
use crate::arrays::ListViewVTable;
28-
use crate::arrays::PrimitiveArray;
2926
use crate::arrow::ArrowArrayExecutor;
3027
use crate::arrow::executor::validity::to_arrow_null_buffer;
3128
use crate::builtins::ArrayBuiltins;
3229
use crate::dtype::DType;
3330
use crate::dtype::NativePType;
3431
use crate::dtype::Nullability;
35-
use crate::validity::Validity;
3632
use crate::vtable::ValidityHelper;
3733

3834
/// Convert a Vortex array into an Arrow GenericBinaryArray.
@@ -46,37 +42,30 @@ pub(super) fn to_arrow_list<O: OffsetSizeTrait + NativePType>(
4642
return list_to_list::<O>(array, elements_field, ctx);
4743
}
4844

49-
// If the Vortex array is a ListViewArray, we check for our magic cheap conversion flag.
45+
// If the Vortex array is a ListViewArray, rebuild to ZCTL if needed and convert.
5046
let array = match array.try_into::<ListViewVTable>() {
5147
Ok(array) => {
52-
if array.is_zero_copy_to_list() {
53-
return list_view_zctl::<O>(array, elements_field, ctx);
48+
let zctl = if array.is_zero_copy_to_list() {
49+
array
5450
} else {
55-
return list_view_to_list::<O>(array, elements_field, ctx);
56-
}
51+
array.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?
52+
};
53+
return list_view_zctl::<O>(zctl, elements_field, ctx);
5754
}
5855
Err(a) => a,
5956
};
6057

61-
// TODO(ngates): we should do the slightly more expensive thing which is to verify ZCTL.
62-
// In other words, check that offsets + sizes are monotonically increasing.
63-
64-
// Otherwise, we execute the array to become a ListViewArray.
58+
// Otherwise, we execute the array to become a ListViewArray, then rebuild to ZCTL.
59+
// Note: arrow_cast::cast supports ListView → List (apache/arrow-rs#8735), but it
60+
// unconditionally uses take. Our rebuild uses a heuristic that picks list-by-list
61+
// for large lists, which avoids materializing a large index buffer.
6562
let list_view = array.execute::<ListViewArray>(ctx)?;
66-
if list_view.is_zero_copy_to_list() {
67-
list_view_zctl::<O>(list_view, elements_field, ctx)
63+
let zctl = if list_view.is_zero_copy_to_list() {
64+
list_view
6865
} else {
69-
list_view_to_list::<O>(list_view, elements_field, ctx)
70-
}
71-
72-
// FIXME(ngates): we need this PR from arrow-rs:
73-
// https://github.com/apache/arrow-rs/pull/8735
74-
// let list_view = array.execute(session)?.into_arrow()?;
75-
// match O::IS_LARGE {
76-
// true => arrow_cast::cast(&list_view, &DataType::LargeList(elements_field.clone())),
77-
// false => arrow_cast::cast(&list_view, &DataType::List(elements_field.clone())),
78-
// }
79-
// .map_err(VortexError::from)
66+
list_view.rebuild(ListViewRebuildMode::MakeZeroCopyToList)?
67+
};
68+
list_view_zctl::<O>(zctl, elements_field, ctx)
8069
}
8170

8271
/// Convert a Vortex VarBinArray into an Arrow GenericBinaryArray.
@@ -190,70 +179,6 @@ fn list_view_zctl<O: OffsetSizeTrait + NativePType>(
190179
)))
191180
}
192181

193-
fn list_view_to_list<O: OffsetSizeTrait + NativePType>(
194-
array: ListViewArray,
195-
elements_field: &FieldRef,
196-
ctx: &mut ExecutionCtx,
197-
) -> VortexResult<ArrowArrayRef> {
198-
let ListViewArrayParts {
199-
elements,
200-
offsets,
201-
sizes,
202-
validity,
203-
..
204-
} = array.into_parts();
205-
206-
let offsets = offsets
207-
.cast(DType::Primitive(O::PTYPE, Nullability::NonNullable))?
208-
.execute::<Canonical>(ctx)?
209-
.into_primitive()
210-
.to_buffer::<O>();
211-
let sizes = sizes
212-
.cast(DType::Primitive(O::PTYPE, Nullability::NonNullable))?
213-
.execute::<Canonical>(ctx)?
214-
.into_primitive()
215-
.to_buffer::<O>();
216-
217-
// We create a new offsets buffer for the final list array.
218-
// And we also create an `indices` buffer for taking the elements.
219-
let mut new_offsets = BufferMut::<O>::with_capacity(offsets.len() + 1);
220-
let mut take_indices = BufferMut::<u32>::with_capacity(elements.len());
221-
222-
// Add the offset for the first subarray
223-
new_offsets.push(O::zero());
224-
for (offset, size) in offsets.iter().zip(sizes.iter()) {
225-
let offset = offset.as_usize();
226-
let size = size.as_usize();
227-
let end = offset + size;
228-
for j in offset..end {
229-
take_indices.push(u32::try_from(j).map_err(|_| {
230-
vortex_err!("List array too large for {} indices", type_name::<O>())
231-
})?);
232-
}
233-
new_offsets.push(O::usize_as(take_indices.len()));
234-
}
235-
assert_eq!(new_offsets.len(), offsets.len() + 1);
236-
237-
// Now we can "take" the elements using the computed indices.
238-
let elements =
239-
elements.take(PrimitiveArray::new(take_indices, Validity::NonNullable).into_array())?;
240-
241-
let elements = elements.execute_arrow(Some(elements_field.data_type()), ctx)?;
242-
vortex_ensure!(
243-
elements_field.is_nullable() || elements.null_count() == 0,
244-
"Cannot convert to non-nullable Arrow array with null elements"
245-
);
246-
247-
let null_buffer = to_arrow_null_buffer(validity, sizes.len(), ctx)?;
248-
249-
Ok(Arc::new(GenericListArray::<O>::new(
250-
elements_field.clone(),
251-
new_offsets.freeze().into_arrow_offset_buffer(),
252-
elements,
253-
null_buffer,
254-
)))
255-
}
256-
257182
#[cfg(test)]
258183
mod tests {
259184
use std::sync::Arc;
@@ -364,6 +289,45 @@ mod tests {
364289
Ok(())
365290
}
366291

292+
#[test]
293+
fn test_to_arrow_list_non_zctl() -> VortexResult<()> {
294+
// Overlapping lists are NOT zero-copy-to-list, so this exercises the rebuild path.
295+
// Elements: [1, 2, 3, 4], List 0: [1,2,3], List 1: [2,3,4] (overlap at indices 1-2)
296+
let elements = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::NonNullable);
297+
let offsets = PrimitiveArray::new(buffer![0i32, 1], Validity::NonNullable);
298+
let sizes = PrimitiveArray::new(buffer![3i32, 3], Validity::NonNullable);
299+
300+
let list_array = ListViewArray::new(
301+
elements.into_array(),
302+
offsets.into_array(),
303+
sizes.into_array(),
304+
Validity::NonNullable,
305+
);
306+
assert!(!list_array.is_zero_copy_to_list());
307+
308+
let field = Field::new("item", DataType::Int32, false);
309+
let arrow_dt = DataType::List(field.into());
310+
let arrow_array = list_array.into_array().into_arrow(&arrow_dt)?;
311+
312+
let list = arrow_array
313+
.as_any()
314+
.downcast_ref::<GenericListArray<i32>>()
315+
.unwrap();
316+
317+
assert_eq!(list.len(), 2);
318+
319+
let first = list.value(0);
320+
assert_eq!(first.len(), 3);
321+
let first_vals = first.as_any().downcast_ref::<Int32Array>().unwrap();
322+
assert_eq!(first_vals.values(), &[1, 2, 3]);
323+
324+
let second = list.value(1);
325+
assert_eq!(second.len(), 3);
326+
let second_vals = second.as_any().downcast_ref::<Int32Array>().unwrap();
327+
assert_eq!(second_vals.values(), &[2, 3, 4]);
328+
Ok(())
329+
}
330+
367331
#[test]
368332
fn test_to_arrow_list_empty_zctl() -> VortexResult<()> {
369333
let dtype = DType::List(

0 commit comments

Comments
 (0)