Skip to content

Commit 71f0f00

Browse files
authored
Fix: incorrect offset for nulls + don't chunk elements (#5423)
Potentially fixes? #5412 A continuation of #5417 # Notes There was an issue where the offset being appended for null lists was the previous offset instead of the previous offset + previous size which broke the invariant of no overlaps. The `validate_zctl` failed to catch this edge case. This then broke `rebuild_trim_elements` since it assumes that everything is in order, including the last offset. This PR fixes that bug. Additionally, this PR cleans up several things in the `ListView` implementation and improves the performance of a `naive_rebuild` and `list_from_list_view`. On develop on my AMD 7950X: ``` Timer precision: 10 ns listview_rebuild fastest │ slowest │ median │ mean │ samples │ iters ╰─ rebuild_naive 158.8 µs │ 175.3 µs │ 160.7 µs │ 162.6 µs │ 100 │ 100000 ``` On this branch: ``` Timer precision: 10 ns listview_rebuild fastest │ slowest │ median │ mean │ samples │ iters ╰─ rebuild_naive 110 µs │ 149.1 µs │ 110.7 µs │ 111.8 µs │ 100 │ 100000 ``` Signed-off-by: Connor Tsui <[email protected]>
1 parent aaf5245 commit 71f0f00

File tree

6 files changed

+234
-90
lines changed

6 files changed

+234
-90
lines changed

encodings/zstd/benches/listview_rebuild.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use vortex_array::validity::Validity;
1010
use vortex_buffer::Buffer;
1111
use vortex_zstd::ZstdArray;
1212

13-
#[divan::bench]
13+
#[divan::bench(sample_size = 1000)]
1414
fn rebuild_naive(bencher: Bencher) {
1515
let dudes = VarBinViewArray::from_iter_str(["Washington", "Adams", "Jefferson", "Madison"])
1616
.into_array();

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

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ pub struct ListViewArray {
9999
///
100100
/// We use this information to help us more efficiently rebuild / compact our data.
101101
///
102-
/// When this flag is true (indicating sorted offsets with no gaps and no overlaps), conversions
103-
/// can bypass the very expensive rebuild process (which just calls `append_scalar` in a loop).
102+
/// When this flag is true (indicating sorted offsets with no gaps and no overlaps and all
103+
/// `offsets[i] + sizes[i]` are in order), conversions can bypass the very expensive rebuild
104+
/// process which must rebuild the array from scratch.
104105
is_zero_copy_to_list: bool,
105106

106107
/// The validity / null map of the array.
@@ -264,6 +265,7 @@ impl ListViewArray {
264265
/// actually zero-copyable to a [`ListArray`]. This means:
265266
///
266267
/// - Offsets must be sorted (but not strictly sorted, zero-length lists are allowed).
268+
/// - `offsets[i] + sizes[i] == offsets[i + 1]` for all `i`.
267269
/// - No gaps in elements between first and last referenced elements.
268270
/// - No overlapping list views (each element referenced at most once).
269271
///
@@ -425,7 +427,8 @@ where
425427
if offset_u64 == elements_len {
426428
vortex_ensure!(
427429
size_u64 == 0,
428-
"views to the end of the elements array (length {elements_len}) must have size 0"
430+
"views to the end of the elements array (length {elements_len}) must have size 0 \
431+
(had size {size_u64})"
429432
);
430433
}
431434

@@ -454,6 +457,51 @@ fn validate_zctl(
454457
vortex_bail!("offsets must report is_sorted statistic");
455458
}
456459

460+
// Validate that offset[i] + size[i] <= offset[i+1] for all items
461+
// This ensures views are non-overlapping and properly ordered for zero-copy-to-list
462+
fn validate_monotonic_ends<O: IntegerPType, S: IntegerPType>(
463+
offsets_slice: &[O],
464+
sizes_slice: &[S],
465+
len: usize,
466+
) -> VortexResult<()> {
467+
let mut max_end = 0usize;
468+
469+
for i in 0..len {
470+
let offset = offsets_slice[i].to_usize().unwrap_or(usize::MAX);
471+
let size = sizes_slice[i].to_usize().unwrap_or(usize::MAX);
472+
473+
// Check that this view starts at or after the previous view ended
474+
vortex_ensure!(
475+
offset >= max_end,
476+
"Zero-copy-to-list requires views to be non-overlapping and ordered: \
477+
view[{}] starts at {} but previous views extend to {}",
478+
i,
479+
offset,
480+
max_end
481+
);
482+
483+
// Update max_end for the next iteration
484+
let end = offset.saturating_add(size);
485+
max_end = max_end.max(end);
486+
}
487+
488+
Ok(())
489+
}
490+
491+
let offsets_dtype = offsets_primitive.dtype();
492+
let sizes_dtype = sizes_primitive.dtype();
493+
let len = offsets_primitive.len();
494+
495+
// Check that offset + size values are monotonic (no overlaps)
496+
match_each_integer_ptype!(offsets_dtype.as_ptype(), |O| {
497+
match_each_integer_ptype!(sizes_dtype.as_ptype(), |S| {
498+
let offsets_slice = offsets_primitive.as_slice::<O>();
499+
let sizes_slice = sizes_primitive.as_slice::<S>();
500+
501+
validate_monotonic_ends(offsets_slice, sizes_slice, len)?;
502+
})
503+
});
504+
457505
// TODO(connor)[ListView]: Making this allocation is expensive, but the more efficient
458506
// implementation would be even more complicated than this. We could use a bit buffer denoting
459507
// if positions in `elements` are used, and then additionally store a separate flag that tells

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

Lines changed: 23 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ use std::sync::Arc;
66
use vortex_dtype::{IntegerPType, Nullability, match_each_integer_ptype};
77
use vortex_error::VortexExpect;
88

9-
use crate::arrays::{ExtensionArray, FixedSizeListArray, ListArray, ListViewArray, StructArray};
10-
use crate::builders::{ArrayBuilder, ListBuilder, PrimitiveBuilder};
9+
use crate::arrays::{
10+
ExtensionArray, FixedSizeListArray, ListArray, ListViewArray, ListViewRebuildMode, StructArray,
11+
};
12+
use crate::builders::PrimitiveBuilder;
1113
use crate::vtable::ValidityHelper;
1214
use crate::{Array, ArrayRef, Canonical, IntoArray, ToCanonical};
1315

@@ -92,56 +94,26 @@ fn build_sizes_from_offsets<O: IntegerPType>(list: &ListArray) -> ArrayRef {
9294
/// Otherwise, this function fall back to the (very) expensive path and will rebuild the
9395
/// [`ListArray`] from scratch.
9496
pub fn list_from_list_view(list_view: ListViewArray) -> ListArray {
95-
// Fast path if the array is zero-copyable to a `ListArray`.
96-
if list_view.is_zero_copy_to_list() {
97-
let list_offsets = match_each_integer_ptype!(list_view.offsets().dtype().as_ptype(), |O| {
98-
// SAFETY: We checked that the array is zero-copyable to `ListArray`, so the safety
99-
// contract is upheld.
100-
unsafe { build_list_offsets_from_list_view::<O>(&list_view) }
101-
});
102-
103-
// SAFETY: Because the shape of the `ListViewArray` is zero-copyable to a `ListArray`, we
104-
// can simply reuse all of the data (besides the offsets).
105-
let new_array = unsafe {
106-
ListArray::new_unchecked(
107-
list_view.elements().clone(),
108-
list_offsets,
109-
list_view.validity().clone(),
110-
)
111-
};
112-
113-
let new_array = new_array
114-
.reset_offsets(false)
115-
.vortex_expect("TODO(connor)[ListView]: This can't fail");
97+
// Rebuild as zero-copyable to list array and also trim all leading and trailing elements.
98+
let zctl_array = list_view.rebuild(ListViewRebuildMode::MakeExact);
99+
debug_assert!(zctl_array.is_zero_copy_to_list());
100+
101+
let list_offsets = match_each_integer_ptype!(zctl_array.offsets().dtype().as_ptype(), |O| {
102+
// SAFETY: We just made the array zero-copyable to `ListArray`, so the safety contract is
103+
// upheld.
104+
unsafe { build_list_offsets_from_list_view::<O>(&zctl_array) }
105+
});
116106

117-
return new_array;
107+
// SAFETY: Because the shape of the `ListViewArray` is zero-copyable to a `ListArray`, we
108+
// can simply reuse all of the data (besides the offsets). We also trim all of the elements to
109+
// make it easier for the caller to use the `ListArray`.
110+
unsafe {
111+
ListArray::new_unchecked(
112+
zctl_array.elements().clone(),
113+
list_offsets,
114+
zctl_array.validity().clone(),
115+
)
118116
}
119-
120-
let elements_dtype = list_view
121-
.dtype()
122-
.as_list_element_opt()
123-
.vortex_expect("`DType` of `ListView` was somehow not a `List`");
124-
let nullability = list_view.dtype().nullability();
125-
let len = list_view.len();
126-
127-
match_each_integer_ptype!(list_view.offsets().dtype().as_ptype(), |O| {
128-
let mut builder = ListBuilder::<O>::with_capacity(
129-
elements_dtype.clone(),
130-
nullability,
131-
list_view.elements().len(),
132-
len,
133-
);
134-
135-
for i in 0..len {
136-
builder
137-
.append_scalar(&list_view.scalar_at(i))
138-
.vortex_expect(
139-
"The `ListView` scalars are `ListScalar`, which the `ListBuilder` must accept",
140-
)
141-
}
142-
143-
builder.finish_into_list()
144-
})
145117
}
146118

147119
// TODO(connor)[ListView]: We can optimize this by always keeping extra memory in `ListViewArray`
@@ -165,6 +137,7 @@ unsafe fn build_list_offsets_from_list_view<O: IntegerPType>(
165137

166138
let offsets = list_view.offsets().to_primitive();
167139
let offsets_slice = offsets.as_slice::<O>();
140+
debug_assert!(offsets_slice.is_sorted());
168141

169142
// Copy the existing n offsets.
170143
offsets_range.copy_from_slice(0, offsets_slice);

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

Lines changed: 107 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use vortex_dtype::{IntegerPType, Nullability, match_each_integer_ptype};
77
use vortex_error::VortexExpect;
88
use vortex_scalar::Scalar;
99

10-
use crate::arrays::{ChunkedArray, ListViewArray};
10+
use crate::arrays::ListViewArray;
11+
use crate::builders::builder_with_capacity;
1112
use crate::vtable::ValidityHelper;
1213
use crate::{Array, IntoArray, ToCanonical, compute};
1314

@@ -74,6 +75,13 @@ impl ListViewArray {
7475
let offsets_ptype = self.offsets().dtype().as_ptype();
7576
let sizes_ptype = self.sizes().dtype().as_ptype();
7677

78+
// One of the main purposes behind adding this "zero-copyable to `ListArray`" optimization
79+
// is that we want to pass data to systems that expect Arrow data.
80+
// The arrow specification only allows for `i32` and `i64` offset and sizes types, so in
81+
// order to also make `ListView` zero-copyable to **Arrow**'s `ListArray` (not just Vortex's
82+
// `ListArray`), we rebuild the offsets as 32-bit or 64-bit integer types.
83+
// TODO(connor)[ListView]: This is true for `sizes` as well, we should do this conversion
84+
// for sizes as well.
7785
match_each_integer_ptype!(sizes_ptype, |S| {
7886
match offsets_ptype {
7987
PType::U8 => self.naive_rebuild::<u8, u32, S>(),
@@ -89,8 +97,10 @@ impl ListViewArray {
8997
})
9098
}
9199

92-
/// The inner function for `rebuild_zero_copy_to_list`, which naively rebuilds a `ListViewArray`
93-
/// via `append_scalar`.
100+
// TODO(connor)[ListView]: We should benchmark if it is faster to use `take` on the elements
101+
// instead of using a builder.
102+
/// The inner function for `rebuild_zero_copy_to_list`, which rebuilds a `ListViewArray` piece
103+
/// by piece.
94104
fn naive_rebuild<O: IntegerPType, NewOffset: IntegerPType, S: IntegerPType>(
95105
&self,
96106
) -> ListViewArray {
@@ -99,59 +109,72 @@ impl ListViewArray {
99109
.as_list_element_opt()
100110
.vortex_expect("somehow had a canonical list that was not a list");
101111

102-
// Upfront canonicalize the list elements, we're going to be doing a lot of
103-
// slicing with them.
104-
let elements_canonical = self.elements().to_canonical().into_array();
105112
let offsets_canonical = self.offsets().to_primitive();
113+
let offsets_slice = offsets_canonical.as_slice::<O>();
106114
let sizes_canonical = self.sizes().to_primitive();
115+
let sizes_slice = sizes_canonical.as_slice::<S>();
107116

108-
let offsets_canonical = offsets_canonical.as_slice::<O>();
109-
let sizes_canonical = sizes_canonical.as_slice::<S>();
117+
let len = offsets_slice.len();
110118

111-
let mut offsets = BufferMut::<NewOffset>::with_capacity(self.len());
112-
let mut sizes = BufferMut::<S>::with_capacity(self.len());
119+
let mut new_offsets = BufferMut::<NewOffset>::with_capacity(len);
120+
// TODO(connor)[ListView]: Do we really need to do this?
121+
// The only reason we need to rebuild the sizes here is that the validity may indicate that
122+
// a list is null even though it has a non-zero size. This rebuild will set the size of all
123+
// null lists to 0.
124+
let mut new_sizes = BufferMut::<S>::with_capacity(len);
113125

114-
let mut chunks = Vec::with_capacity(self.len());
126+
// Canonicalize the elements up front as we will be slicing the elements quite a lot.
127+
let elements_canonical = self.elements().to_canonical().into_array();
115128

116-
let mut n_elements = NewOffset::zero();
129+
// Note that we do not know what the exact capacity should be of the new elements since
130+
// there could be overlaps in the existing `ListViewArray`.
131+
let mut new_elements_builder =
132+
builder_with_capacity(element_dtype.as_ref(), self.elements().len());
117133

118-
for index in 0..self.len() {
134+
let mut n_elements = NewOffset::zero();
135+
for index in 0..len {
119136
if !self.is_valid(index) {
120-
offsets.push(offsets.last().copied().unwrap_or_default());
121-
sizes.push(S::zero());
137+
// For NULL lists, place them after the previous item's data to maintain the
138+
// no-overlap invariant for zero-copy to `ListArray` arrays.
139+
new_offsets.push(n_elements);
140+
new_sizes.push(S::zero());
122141
continue;
123142
}
124143

125-
let offset = offsets_canonical[index];
126-
let size = sizes_canonical[index];
144+
let offset = offsets_slice[index];
145+
let size = sizes_slice[index];
127146

128147
let start = offset.as_();
129148
let stop = start + size.as_();
130149

131-
chunks.push(elements_canonical.slice(start..stop));
132-
offsets.push(n_elements);
133-
sizes.push(size);
150+
new_offsets.push(n_elements);
151+
new_sizes.push(size);
152+
new_elements_builder.extend_from_array(&elements_canonical.slice(start..stop));
134153

135-
n_elements += num_traits::cast(size).vortex_expect("cast");
154+
n_elements += num_traits::cast(size).vortex_expect("Cast failed");
136155
}
137156

138-
let offsets = offsets.into_array();
139-
let sizes = sizes.into_array();
157+
let offsets = new_offsets.into_array();
158+
let sizes = new_sizes.into_array();
159+
let elements = new_elements_builder.finish();
140160

141-
// SAFETY: all chunks were sliced from the same array so have same DType.
142-
let elements =
143-
unsafe { ChunkedArray::new_unchecked(chunks, element_dtype.as_ref().clone()) };
161+
debug_assert_eq!(
162+
n_elements.as_(),
163+
elements.len(),
164+
"The accumulated elements somehow had the wrong length"
165+
);
144166

145-
// SAFETY: elements are contiguous, offsets and sizes hand-built to be zero copy
146-
// to list.
167+
// SAFETY:
168+
// - All offsets are sequential and non-overlapping (`n_elements` tracks running total).
169+
// - Each `offset[i] + size[i]` equals `offset[i+1]` for all valid indices (including null
170+
// lists).
171+
// - All elements referenced by (offset, size) pairs exist within the new `elements` array.
172+
// - The validity array is preserved from the original array unchanged
173+
// - The array satisfies the zero-copy-to-list property by having sorted offsets, no gaps,
174+
// and no overlaps.
147175
unsafe {
148-
ListViewArray::new_unchecked(
149-
elements.to_canonical().into_array(),
150-
offsets,
151-
sizes,
152-
self.validity.clone(),
153-
)
154-
.with_zero_copy_to_list(true)
176+
ListViewArray::new_unchecked(elements, offsets, sizes, self.validity.clone())
177+
.with_zero_copy_to_list(true)
155178
}
156179
}
157180

@@ -342,4 +365,53 @@ mod tests {
342365
let all_elements = trimmed.elements().to_primitive();
343366
assert_eq!(all_elements.scalar_at(2), 97i32.into());
344367
}
368+
369+
#[test]
370+
fn test_rebuild_with_trailing_nulls_regression() {
371+
// Regression test for issue #5412
372+
// Tests that zero-copy-to-list arrays with trailing NULLs correctly calculate
373+
// offsets for NULL items to maintain no-overlap invariant
374+
375+
// Create a ListViewArray with trailing NULLs
376+
let elements = PrimitiveArray::from_iter(vec![1i32, 2, 3, 4]).into_array();
377+
let offsets = PrimitiveArray::from_iter(vec![0u32, 2, 0, 0]).into_array();
378+
let sizes = PrimitiveArray::from_iter(vec![2u32, 2, 0, 0]).into_array();
379+
let validity = Validity::from_iter(vec![true, true, false, false]);
380+
381+
let listview = ListViewArray::new(elements, offsets, sizes, validity);
382+
383+
// First rebuild to make it zero-copy-to-list
384+
let rebuilt = listview.rebuild(ListViewRebuildMode::MakeZeroCopyToList);
385+
assert!(rebuilt.is_zero_copy_to_list());
386+
387+
// Verify NULL items have correct offsets (should not reuse previous offsets)
388+
// After rebuild: offsets should be [0, 2, 4, 4] for zero-copy-to-list
389+
assert_eq!(rebuilt.offset_at(0), 0);
390+
assert_eq!(rebuilt.offset_at(1), 2);
391+
assert_eq!(rebuilt.offset_at(2), 4); // NULL should be at position 4
392+
assert_eq!(rebuilt.offset_at(3), 4); // Second NULL also at position 4
393+
394+
// All sizes should be correct
395+
assert_eq!(rebuilt.size_at(0), 2);
396+
assert_eq!(rebuilt.size_at(1), 2);
397+
assert_eq!(rebuilt.size_at(2), 0); // NULL has size 0
398+
assert_eq!(rebuilt.size_at(3), 0); // NULL has size 0
399+
400+
// Now rebuild with MakeExact (which calls naive_rebuild then trim_elements)
401+
// This should not panic (issue #5412)
402+
let exact = rebuilt.rebuild(ListViewRebuildMode::MakeExact);
403+
404+
// Verify the result is still valid
405+
assert!(exact.is_valid(0));
406+
assert!(exact.is_valid(1));
407+
assert!(!exact.is_valid(2));
408+
assert!(!exact.is_valid(3));
409+
410+
// Verify data is preserved
411+
let list0 = exact.list_elements_at(0).to_primitive();
412+
assert_eq!(list0.as_slice::<i32>(), &[1, 2]);
413+
414+
let list1 = exact.list_elements_at(1).to_primitive();
415+
assert_eq!(list1.as_slice::<i32>(), &[3, 4]);
416+
}
345417
}

0 commit comments

Comments
 (0)