Skip to content

Commit cbbe407

Browse files
authored
Performance: ListViewArray zero-copyable to ListArray optimizations (#5129)
Tracking Issue: #4699 Closes: #5053 This PR introduces a performance optimization for `ListViewArray` by adding an `is_zero_copy_to_list` flag that tracks whether the `ListViewArray` can be (near) constant-time converted to a `ListArray` without copying data. When this flag is true (indicating sorted offsets with no gaps and no overlaps), conversions can bypass the very expensive rebuild process (which just calls `append_scalar` in a loop). Additionally, this PR refactors all `ListViewArray` constructor call sites and compute operations throughout the codebase to properly maintain this new invariant. The only time you can set this flag is via the `new_unchecked` constructor which is unsafe, which means the caller better be careful to know what they are doing. Some things that are not included in this PR: - Making conversion _actually_ zero-copy would require implementing logic to always allocate an additional `offsets` slot for an `n+1`th offset in `ListViewArray`. This is because `ListArray` has `n+1` offsets, and from a plain `ListViewArray` we have to reallocate the offsets to make space. - `reset_offsets` right now is probably more expensive than it could be since it uses the `sub_scalar` compute function. We know in advance that `reset_offsets` should _never_ fail in the non-recursive case. - Validation of this `is_zctl` flag is **SUPER** expensive, but it can be made a bit less expensive by using a `BitBuffer` to track references + a flag for overlaps. - It would probably be nice to have an `check_is_zero_copy_to_list` function on `ListViewArray` that sets the flag if it detects it is zero-copyable to a `ListArray`. This would be nice when we are reading from disk or converting from an Arrow `GenericListView`. - This PR **does not** change the on-disk format, which means we lose this metadata when writing to disk. I think that this flag is somewhat similar to the `is_sorted` statistic, but at the same time I feel like it is not correct to store this as a statistic? Not sure. --------- Signed-off-by: Connor Tsui <[email protected]>
1 parent 7634f36 commit cbbe407

File tree

27 files changed

+1024
-619
lines changed

27 files changed

+1024
-619
lines changed

encodings/sparse/src/canonical.rs

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -931,9 +931,11 @@ mod test {
931931
// List 3: [2] at offset 3, size 1
932932
let offsets = buffer![0u32, 1, 2, 3].into_array();
933933
let sizes = buffer![1u32, 1, 1, 1].into_array();
934-
let lists = ListViewArray::try_new(elements, offsets, sizes, Validity::AllValid)
935-
.unwrap()
936-
.into_array();
934+
let lists = unsafe {
935+
ListViewArray::new_unchecked(elements, offsets, sizes, Validity::AllValid)
936+
.with_zero_copy_to_list(true)
937+
}
938+
.into_array();
937939

938940
let indices = buffer![0u8, 3u8, 4u8, 5u8].into_array();
939941
let fill_value = Scalar::null(lists.dtype().clone());
@@ -978,9 +980,11 @@ mod test {
978980
let elements = buffer![1i32, 2, 1, 2, 1, 2, 1, 2].into_array();
979981
let offsets = buffer![0u32, 1, 2, 3, 4, 5, 6, 7].into_array();
980982
let sizes = buffer![1u32, 1, 1, 1, 1, 1, 1, 1].into_array();
981-
let lists = ListViewArray::try_new(elements, offsets, sizes, Validity::AllValid)
982-
.unwrap()
983-
.into_array();
983+
let lists = unsafe {
984+
ListViewArray::new_unchecked(elements, offsets, sizes, Validity::AllValid)
985+
.with_zero_copy_to_list(true)
986+
}
987+
.into_array();
984988

985989
// Slice to get lists 2..6, which are: [1], [2], [1], [2]
986990
let lists = lists.slice(2..6);
@@ -1022,9 +1026,11 @@ mod test {
10221026
let elements = buffer![1i32, 2, 1, 2].into_array();
10231027
let offsets = buffer![0u32, 1, 2, 3].into_array();
10241028
let sizes = buffer![1u32, 1, 1, 1].into_array();
1025-
let lists = ListViewArray::try_new(elements, offsets, sizes, Validity::AllValid)
1026-
.unwrap()
1027-
.into_array();
1029+
let lists = unsafe {
1030+
ListViewArray::new_unchecked(elements, offsets, sizes, Validity::AllValid)
1031+
.with_zero_copy_to_list(true)
1032+
}
1033+
.into_array();
10281034

10291035
let indices = buffer![0u8, 3u8, 4u8, 5u8].into_array();
10301036
let fill_value = Scalar::from(Some(vec![5i32, 6, 7, 8]));
@@ -1384,8 +1390,10 @@ mod test {
13841390
let offsets = buffer![0u32, 3, 5].into_array();
13851391
let sizes = buffer![3u32, 2, 4].into_array();
13861392

1387-
let list_view =
1388-
ListViewArray::try_new(elements.clone(), offsets, sizes, Validity::AllValid).unwrap();
1393+
let list_view = unsafe {
1394+
ListViewArray::new_unchecked(elements.clone(), offsets, sizes, Validity::AllValid)
1395+
.with_zero_copy_to_list(true)
1396+
};
13891397

13901398
let list_dtype = list_view.dtype().clone();
13911399

@@ -1460,9 +1468,11 @@ mod test {
14601468
let offsets = buffer![0u32, 2, 5, 6, 8].into_array();
14611469
let sizes = buffer![2u32, 3, 1, 2, 2].into_array();
14621470

1463-
let full_listview = ListViewArray::try_new(elements, offsets, sizes, Validity::AllValid)
1464-
.unwrap()
1465-
.into_array();
1471+
let full_listview = unsafe {
1472+
ListViewArray::new_unchecked(elements, offsets, sizes, Validity::AllValid)
1473+
.with_zero_copy_to_list(true)
1474+
}
1475+
.into_array();
14661476

14671477
// Slice to get lists 1, 2, 3 (indices 1..4)
14681478
// This gives us lists with elements:

fuzz/src/array/mask.rs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,18 @@ pub fn mask_canonical_array(canonical: Canonical, mask: &Mask) -> VortexResult<A
5757
}
5858
Canonical::List(array) => {
5959
let new_validity = apply_mask_to_validity(array.validity(), mask);
60-
ListViewArray::try_new(
61-
array.elements().clone(),
62-
array.offsets().clone(),
63-
array.sizes().clone(),
64-
new_validity,
65-
)
66-
.vortex_unwrap()
60+
61+
// SAFETY: Since we are only masking the validity and everything else comes from an
62+
// already valid `ListViewArray`, all of the invariants are still upheld.
63+
unsafe {
64+
ListViewArray::new_unchecked(
65+
array.elements().clone(),
66+
array.offsets().clone(),
67+
array.sizes().clone(),
68+
new_validity,
69+
)
70+
.with_zero_copy_to_list(array.is_zero_copy_to_list())
71+
}
6772
.into_array()
6873
}
6974
Canonical::FixedSizeList(array) => {
@@ -243,9 +248,10 @@ mod tests {
243248
let elements = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6]).into_array();
244249
let offsets = PrimitiveArray::from_iter([0i32, 2, 4]).into_array();
245250
let sizes = PrimitiveArray::from_iter([2i32, 2, 2]).into_array();
246-
let array =
247-
ListViewArray::try_new(elements, offsets, sizes, Nullability::NonNullable.into())
248-
.unwrap();
251+
let array = unsafe {
252+
ListViewArray::new_unchecked(elements, offsets, sizes, Nullability::NonNullable.into())
253+
.with_zero_copy_to_list(true)
254+
};
249255

250256
let mask = Mask::from_iter([false, true, false]);
251257

fuzz/src/array/slice.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ pub fn slice_canonical_array(
7474
// Since the list view elements can be stored out of order, we cannot slice it.
7575
let elements = list_array.elements().clone();
7676

77-
ListViewArray::try_new(elements, offsets, sizes, validity).map(|a| a.into_array())
77+
// SAFETY: If the array was already zero-copyable to list, slicing the offsets and sizes
78+
// only causes there to be leading and trailing garbage data, which is still
79+
// zero-copyable to a `ListArray`.
80+
Ok(unsafe {
81+
ListViewArray::new_unchecked(elements, offsets, sizes, validity)
82+
.with_zero_copy_to_list(list_array.is_zero_copy_to_list())
83+
}
84+
.into_array())
7885
}
7986
DType::FixedSizeList(..) => {
8087
let fsl_array = array.to_fixed_size_list();

vortex-array/src/arrays/chunked/vtable/canonical.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ use vortex_buffer::BufferMut;
55
use vortex_dtype::{DType, Nullability, PType, StructFields};
66
use vortex_error::VortexExpect;
77

8-
use crate::arrays::{ChunkedArray, ChunkedVTable, ListViewArray, PrimitiveArray, StructArray};
8+
use crate::arrays::{
9+
ChunkedArray, ChunkedVTable, ListViewArray, ListViewRebuildMode, PrimitiveArray, StructArray,
10+
};
911
use crate::builders::{ArrayBuilder, builder_with_capacity};
1012
use crate::compute::cast;
1113
use crate::validity::Validity;
@@ -121,6 +123,9 @@ fn swizzle_list_chunks(
121123

122124
for chunk in chunks {
123125
let chunk_array = chunk.to_listview();
126+
// By rebuilding as zero-copy to `List` and trimming all elements (to prevent gaps), we make
127+
// the final output `ListView` also zero-copyable to `List`.
128+
let chunk_array = chunk_array.rebuild(ListViewRebuildMode::MakeExact);
124129

125130
// Add the `elements` of the current array as a new chunk.
126131
list_elements_chunks.push(chunk_array.elements().clone());
@@ -163,7 +168,12 @@ fn swizzle_list_chunks(
163168
// - Each `offset[i] + size[i]` list view is within bounds of elements array because it came
164169
// from valid chunks
165170
// - Validity came from the outer chunked array so it must have the same length
166-
unsafe { ListViewArray::new_unchecked(chunked_elements, offsets, sizes, validity) }
171+
// - Since we made sure that all chunks were zero-copyable to a list above, we know that the
172+
// final concatenated output is also zero-copyable to a list.
173+
unsafe {
174+
ListViewArray::new_unchecked(chunked_elements, offsets, sizes, validity)
175+
.with_zero_copy_to_list(true)
176+
}
167177
}
168178

169179
#[cfg(test)]

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
use std::sync::Arc;
55

66
use num_traits::AsPrimitive;
7-
use vortex_dtype::{DType, match_each_integer_ptype, match_each_native_ptype};
7+
use vortex_dtype::{DType, NativePType, match_each_integer_ptype, match_each_native_ptype};
88
use vortex_error::{VortexExpect, VortexResult, vortex_bail, vortex_ensure};
99

1010
use crate::arrays::{ListVTable, PrimitiveVTable};
@@ -191,8 +191,11 @@ impl ListArray {
191191

192192
vortex_ensure!(
193193
max_offset
194-
<= P::try_from(elements.len())
195-
.vortex_expect("Offsets type must be able to fit elements length"),
194+
<= P::try_from(elements.len()).vortex_expect(&format!(
195+
"Offsets type {} must be able to fit elements length {}",
196+
<P as NativePType>::PTYPE,
197+
elements.len()
198+
)),
196199
"Max offset {max_offset} is beyond the length of the elements array {}",
197200
elements.len()
198201
);
@@ -266,6 +269,11 @@ impl ListArray {
266269
&self.elements
267270
}
268271

272+
// TODO(connor)[ListView]: Create 2 functions `reset_offsets` and `recursive_reset_offsets`,
273+
// where `reset_offsets` is infallible.
274+
// Also, `reset_offsets` can be made more efficient by replacing `sub_scalar` with a match on
275+
// the offset type and manual subtraction and fast path where `offsets[0] == 0`.
276+
269277
/// Create a copy of this array by adjusting `offsets` to start at `0` and removing elements not
270278
/// referenced by the `offsets`.
271279
pub fn reset_offsets(&self, recurse: bool) -> VortexResult<Self> {

0 commit comments

Comments
 (0)