Skip to content

Commit 4c0e543

Browse files
authored
Rebuild lists over only the elements valid lists reference (#10743)
## Summary Adds `ListArray::compact` and a list view rebuild mode, `ListViewRebuildMode::MakeExactWithEmptyNulls`. Both return the same lists over only the elements that valid lists reference: nothing before the first list, after the last, between lists or under a null list. A list column can hold elements that no valid list shows. Slicing keeps the parent's elements on both sides, and a null list may still cover elements. A function that only reads values does not care, but a function that can fail on an element, such as a checked `x + 1`, must not see them: an overflow in an element under a null list would fail the whole batch. `list_transform` (#10503, stacked on this PR) is the first user; it applies its lambda to the rebuilt elements whenever the lambda can fail. For example: ```text elements [9, 1, 2, 9, 9, 3] offsets [1, 3, 5, 6] validity [valid, null, valid] lists [1, 2], null over [9, 9], [3] rebuilt elements [1, 2, 3] offsets [0, 2, 2, 3] ``` ## How it works **`ListArray`** - Non-nullable or all-valid lists with nothing at the ends are returned as they are, after reading two offsets. - Otherwise it looks for null lists that hold elements: one pass marks the lists whose next offset is larger, and one bit operation clears the valid ones. Only when bits are left does it collect them into runs. - With no such run, only the ends go: the elements are sliced to `[first offset, last offset)` and the offsets are shifted down, without copying an element. - With some, the elements between those runs are kept, by a slice if one range is left and otherwise by one filter whose mask carries the ranges, so it copies range by range. The offsets are copied once and each one moves down by the elements dropped before it, so the null lists in those runs become empty. - The offsets keep their type and the validity is unchanged. **`ListViewRebuildMode::MakeExactWithEmptyNulls`** - `MakeExact` only trims the ends of a view that is already zero-copy to a list, so its null lists keep their elements. The new mode drops those too. - Such a view has its lists back to back, so it goes through the same two steps on its offsets, with the last list's end as the final boundary. It keeps its layout and its offsets' and sizes' types. Only the sizes of the null lists that held elements are set to 0; otherwise the sizes are kept as they are. - Any other view is rebuilt with `MakeExact`, which already copies only the valid lists' elements and leaves null lists empty. `MakeExact`'s documentation now says both. - The result is always zero-copy to a list. ## Benchmark `list_transform` with a checked `x + 1` over 100k lists (~800k `i32` elements, ~1/8 of the lists null), through #10503, exported to Arrow, median µs over three alternating runs of a local divan benchmark. "Before" is #10503 without this PR, which used the existing list view rebuild instead. | Lists | Before | This PR | DataFusion 54.1 `array_transform` | |---|---|---|---| | Null lists holding elements | 1,272 | 253 | 224 | | Null lists already empty | 97 | 73 | 60 | | Sliced, with elements on both sides | 78 | 81 | 55 | ## Testing - `rstest` cases for both types: ends to drop, null lists holding elements or not, every list null, no lists, an empty valid list between null lists, overlapping and out-of-order views, views with and without the zero-copy flag. - Exact arrays come back as the same arrays (`ArrayRef::ptr_eq`), and a sliced `ListArray` whose validity starts mid-byte is rebuilt correctly. - A seeded random test compares `ListArray` and both kinds of view against a reference that gathers each valid list's elements, over five offset types, with non-nullable validity and validity arrays that start mid-byte. --------- Signed-off-by: Matt Katz <mhkatz97@gmail.com>
1 parent a59294e commit 4c0e543

4 files changed

Lines changed: 662 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ pub use vtable::ListArray;
1212

1313
pub(crate) mod compute;
1414

15+
mod rebuild;
16+
pub(crate) use rebuild::drop_unreferenced_elements;
17+
pub(crate) use rebuild::null_runs_holding_elements;
18+
1519
mod vtable;
1620
pub use vtable::List;
1721

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
use vortex_buffer::BitBuffer;
5+
use vortex_buffer::BitBufferMut;
6+
use vortex_buffer::BufferAllocatorRef;
7+
use vortex_buffer::BufferMut;
8+
use vortex_error::VortexExpect;
9+
use vortex_error::VortexResult;
10+
use vortex_mask::AllOr;
11+
use vortex_mask::Mask;
12+
13+
use crate::ArrayRef;
14+
use crate::ExecutionCtx;
15+
use crate::IntoArray;
16+
use crate::arrays::ListArray;
17+
use crate::arrays::PrimitiveArray;
18+
use crate::arrays::list::ListArrayExt;
19+
use crate::arrays::list::ListArraySlotsExt;
20+
use crate::arrays::primitive::PrimitiveArrayExt;
21+
use crate::dtype::IntegerPType;
22+
use crate::dtype::PType;
23+
use crate::match_each_unsigned_integer_ptype;
24+
use crate::validity::Validity;
25+
26+
impl ListArray {
27+
/// Compacts the [`ListArray`] to only the elements that valid lists reference: nothing before
28+
/// the first list, after the last or under a null list.
29+
///
30+
/// Null lists come out empty, the offsets start at 0, end at the length of the elements and
31+
/// keep their type, and the validity is kept. An array with nothing to drop is returned as it
32+
/// is.
33+
pub fn compact(&self, ctx: &mut ExecutionCtx) -> VortexResult<ListArray> {
34+
let validity = self.list_validity();
35+
if validity.definitely_no_nulls()
36+
&& self.offset_at(0, ctx)? == 0
37+
&& self.offset_at(self.len(), ctx)? == self.elements().len()
38+
{
39+
return Ok(self.clone());
40+
}
41+
42+
let offsets = self.offsets().clone().execute::<PrimitiveArray>(ctx)?;
43+
let offsets_ptype = offsets.ptype();
44+
// Offsets are never negative, so they are rebuilt unsigned and reinterpreted back.
45+
let offsets = offsets.reinterpret_cast(offsets_ptype.to_unsigned());
46+
let valid = validity.execute_mask(self.len(), ctx)?;
47+
match_each_unsigned_integer_ptype!(offsets.ptype(), |O| {
48+
self.rebuild_with_valid(offsets.as_slice::<O>(), offsets_ptype, &valid, ctx)
49+
})
50+
}
51+
52+
fn rebuild_with_valid<O: IntegerPType>(
53+
&self,
54+
offsets: &[O],
55+
offsets_ptype: PType,
56+
valid: &Mask,
57+
ctx: &mut ExecutionCtx,
58+
) -> VortexResult<ListArray> {
59+
let (starts, end) = (&offsets[..self.len()], offsets[self.len()].as_());
60+
let null_runs =
61+
null_runs_holding_elements(valid, |index| offsets[index] != offsets[index + 1]);
62+
if null_runs.is_empty() && offsets[0].as_() == 0 && end == self.elements().len() {
63+
return Ok(self.clone());
64+
}
65+
66+
let (elements, mut offsets) =
67+
drop_unreferenced_elements(self.elements(), starts, end, &null_runs, ctx.allocator())?;
68+
offsets.push(
69+
num_traits::cast(elements.len())
70+
.vortex_expect("the elements kept fit the offsets' type"),
71+
);
72+
let offsets = PrimitiveArray::new(offsets.freeze(), Validity::NonNullable)
73+
.reinterpret_cast(offsets_ptype)
74+
.into_array();
75+
// SAFETY: the offsets keep their type, start at 0, never decrease and end at the length of
76+
// the elements, which are the valid lists' elements in order.
77+
Ok(unsafe { ListArray::new_unchecked(elements, offsets, self.list_validity()) })
78+
}
79+
}
80+
81+
/// The runs of null lists that hold elements, given which lists hold elements.
82+
pub(crate) fn null_runs_holding_elements(
83+
valid: &Mask,
84+
mut holds_elements: impl FnMut(usize) -> bool,
85+
) -> Vec<(usize, usize)> {
86+
match valid.bit_buffer() {
87+
AllOr::All => Vec::new(),
88+
AllOr::None if (0..valid.len()).any(&mut holds_elements) => vec![(0, valid.len())],
89+
AllOr::None => Vec::new(),
90+
AllOr::Some(valid) => BitBuffer::collect_bool(valid.len(), &mut holds_elements)
91+
.bitand_not(valid)
92+
.set_slices()
93+
.collect(),
94+
}
95+
}
96+
97+
/// Drops the elements before the first of the lists laid out back to back, after the last, and
98+
/// under the runs of null lists from [`null_runs_holding_elements`], which come out empty.
99+
///
100+
/// `end` is where the last list ends. It is a `usize` rather than an `O` because a list view's last
101+
/// list can end past what its offsets' type holds: with `u8` offsets `[1, 5, 105]` and sizes
102+
/// `[4, 100, 200]`, it ends at 305.
103+
///
104+
/// Returns the elements kept and where each list now starts. A [`ListArray`] needs one more offset,
105+
/// where its last list ends, so the buffer has room to push it without reallocating.
106+
pub(crate) fn drop_unreferenced_elements<O: IntegerPType>(
107+
elements: &ArrayRef,
108+
starts: &[O],
109+
end: usize,
110+
null_runs: &[(usize, usize)],
111+
allocator: &BufferAllocatorRef,
112+
) -> VortexResult<(ArrayRef, BufferMut<O>)> {
113+
let start_of = |index: usize| starts.get(index).map_or(end, |start| start.as_());
114+
let first = start_of(0);
115+
// The ranges of elements kept, counted from the first list's start.
116+
let mut kept = Vec::with_capacity(null_runs.len() + 1);
117+
let mut kept_start = 0;
118+
let mut offsets = BufferMut::with_capacity_in(starts.len() + 1, allocator.clone());
119+
offsets.extend_from_slice(starts);
120+
let mut dropped = starts.first().copied().unwrap_or_else(O::zero);
121+
let mut from = 0;
122+
for &(run_start, run_end) in null_runs {
123+
if kept_start < start_of(run_start) - first {
124+
kept.push((kept_start, start_of(run_start) - first));
125+
}
126+
kept_start = start_of(run_end) - first;
127+
128+
for offset in &mut offsets[from..run_start] {
129+
*offset = *offset - dropped;
130+
}
131+
offsets[run_start..run_end].fill(starts[run_start] - dropped);
132+
// A run at the end leaves no list after it to move down.
133+
if let Some(&next) = starts.get(run_end) {
134+
dropped += next - starts[run_start];
135+
}
136+
from = run_end;
137+
}
138+
if kept_start < end - first {
139+
kept.push((kept_start, end - first));
140+
}
141+
for offset in &mut offsets[from..] {
142+
*offset = *offset - dropped;
143+
}
144+
145+
let referenced = elements.slice(first..end)?;
146+
let elements = match kept[..] {
147+
[] => referenced.slice(0..0)?,
148+
[(start, stop)] => referenced.slice(start..stop)?,
149+
_ => {
150+
let mut bits = BitBufferMut::new_unset(end - first);
151+
for &(start, stop) in &kept {
152+
bits.fill_range(start, stop, true);
153+
}
154+
// With the ranges cached on the mask, the filter copies the elements range by range.
155+
referenced.filter(Mask::from_buffer_with_slices(bits.freeze(), kept))?
156+
}
157+
};
158+
Ok((elements, offsets))
159+
}

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

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

4+
use std::ops::Range;
45
use std::sync::Arc;
56
use std::sync::LazyLock;
67

8+
use rstest::rstest;
79
use vortex_buffer::BitBuffer;
810
use vortex_buffer::buffer;
911
use vortex_error::VortexExpect;
@@ -12,6 +14,7 @@ use vortex_mask::Mask;
1214
use vortex_session::VortexSession;
1315

1416
use super::*;
17+
use crate::ArrayRef;
1518
use crate::IntoArray;
1619
use crate::VortexSessionExecute;
1720
use crate::arrays::FilterArray;
@@ -1058,3 +1061,133 @@ fn test_filter_sliced_list_array() -> VortexResult<()> {
10581061
assert_arrays_eq!(result, expected, &mut ctx);
10591062
Ok(())
10601063
}
1064+
1065+
#[rstest]
1066+
#[case::nothing_to_drop(&[1, 2, 3], &[0, 1, 1, 3], &[true, false, true], &[1, 2, 3], &[0, 1, 1, 3])]
1067+
#[case::exact(&[1, 2, 3], &[0, 1, 3], &[true, true], &[1, 2, 3], &[0, 1, 3])]
1068+
#[case::leftovers_at_the_ends(&[9, 1, 2, 9], &[1, 2, 3], &[true, true], &[1, 2], &[0, 1, 2])]
1069+
#[case::leftover_at_the_end(&[1, 2, 9], &[0, 1, 2], &[true, true], &[1, 2], &[0, 1, 2])]
1070+
#[case::null_lists_holding_elements(
1071+
&[9, 9, 9, 1, 9, 9, 2, 3, 9, 9],
1072+
&[1, 3, 4, 6, 8, 9],
1073+
&[false, true, false, true, false],
1074+
&[1, 2, 3],
1075+
&[0, 0, 1, 1, 3, 3]
1076+
)]
1077+
#[case::only_the_last_null_list_holds_elements(
1078+
&[1, 2, 9],
1079+
&[0, 1, 1, 2, 3],
1080+
&[true, false, true, false],
1081+
&[1, 2],
1082+
&[0, 1, 1, 2, 2]
1083+
)]
1084+
#[case::an_empty_valid_list_between_null_lists(
1085+
&[9, 9, 1],
1086+
&[0, 1, 1, 2, 3],
1087+
&[false, true, false, true],
1088+
&[1],
1089+
&[0, 0, 0, 0, 1]
1090+
)]
1091+
#[case::every_list_null(&[9, 9], &[0, 1, 2], &[false, false], &[], &[0, 0, 0])]
1092+
#[case::empty_null_list_with_leftovers_at_the_ends(
1093+
&[9, 1, 2, 9],
1094+
&[1, 2, 2, 3],
1095+
&[true, false, true],
1096+
&[1, 2],
1097+
&[0, 1, 1, 2]
1098+
)]
1099+
#[case::every_list_null_and_empty_with_leftovers(
1100+
&[9, 9],
1101+
&[1, 1, 1],
1102+
&[false, false],
1103+
&[],
1104+
&[0, 0, 0]
1105+
)]
1106+
#[case::no_lists_with_a_leftover(&[9], &[1], &[], &[], &[0])]
1107+
fn test_compact(
1108+
#[case] elements: &[i32],
1109+
#[case] offsets: &[u32],
1110+
#[case] validity: &[bool],
1111+
#[case] expected_elements: &[i32],
1112+
#[case] expected_offsets: &[u32],
1113+
) -> VortexResult<()> {
1114+
let mut ctx = SESSION.create_execution_ctx();
1115+
let list = ListArray::try_new(
1116+
PrimitiveArray::from_iter(elements.iter().copied()).into_array(),
1117+
PrimitiveArray::from_iter(offsets.iter().copied()).into_array(),
1118+
if validity.iter().all(|valid| *valid) {
1119+
Validity::NonNullable
1120+
} else {
1121+
Validity::from_iter(validity.iter().copied())
1122+
},
1123+
&mut ctx,
1124+
)?;
1125+
let rebuilt = list.compact(&mut ctx)?;
1126+
assert_arrays_eq!(
1127+
rebuilt.elements().clone(),
1128+
PrimitiveArray::from_iter(expected_elements.iter().copied()),
1129+
&mut ctx
1130+
);
1131+
assert_arrays_eq!(
1132+
rebuilt.offsets().clone(),
1133+
PrimitiveArray::from_iter(expected_offsets.iter().copied()),
1134+
&mut ctx
1135+
);
1136+
assert_arrays_eq!(rebuilt, list, &mut ctx);
1137+
Ok(())
1138+
}
1139+
1140+
#[rstest]
1141+
#[case::non_nullable(Validity::NonNullable)]
1142+
#[case::all_valid(Validity::AllValid)]
1143+
#[case::empty_null_list(Validity::from_iter([true, false, true]))]
1144+
fn test_compact_returns_an_exact_list_as_is(#[case] validity: Validity) -> VortexResult<()> {
1145+
let mut ctx = SESSION.create_execution_ctx();
1146+
let list = ListArray::try_new(
1147+
buffer![1i32, 2, 3].into_array(),
1148+
buffer![0u32, 1, 1, 3].into_array(),
1149+
validity,
1150+
&mut ctx,
1151+
)?;
1152+
let rebuilt = list.compact(&mut ctx)?;
1153+
assert!(ArrayRef::ptr_eq(rebuilt.elements(), list.elements()));
1154+
assert!(ArrayRef::ptr_eq(rebuilt.offsets(), list.offsets()));
1155+
Ok(())
1156+
}
1157+
1158+
/// A slice keeps all of its parent's elements, and its validity can start mid-byte.
1159+
#[rstest]
1160+
#[case::no_null_list(2..4, &[3, 4, 5, 6], &[0, 2, 4])]
1161+
#[case::a_null_list_holding_elements_first(1..4, &[3, 4, 5, 6], &[0, 0, 2, 4])]
1162+
#[case::a_null_list_holding_elements_last(3..5, &[5, 6], &[0, 2, 2])]
1163+
fn test_compact_sliced(
1164+
#[case] range: Range<usize>,
1165+
#[case] expected_elements: &[i32],
1166+
#[case] expected_offsets: &[u32],
1167+
) -> VortexResult<()> {
1168+
let mut ctx = SESSION.create_execution_ctx();
1169+
// [[0, 1], null over [2], [3, 4], [5, 6], null over [7, 8, 9]]
1170+
let list = ListArray::try_new(
1171+
PrimitiveArray::from_iter(0i32..10).into_array(),
1172+
buffer![0u32, 2, 3, 5, 7, 10].into_array(),
1173+
Validity::from_iter([true, false, true, true, false]),
1174+
&mut ctx,
1175+
)?
1176+
.into_array()
1177+
.slice(range)?
1178+
.as_::<List>()
1179+
.into_owned();
1180+
let rebuilt = list.compact(&mut ctx)?;
1181+
assert_arrays_eq!(
1182+
rebuilt.elements().clone(),
1183+
PrimitiveArray::from_iter(expected_elements.iter().copied()),
1184+
&mut ctx
1185+
);
1186+
assert_arrays_eq!(
1187+
rebuilt.offsets().clone(),
1188+
PrimitiveArray::from_iter(expected_offsets.iter().copied()),
1189+
&mut ctx
1190+
);
1191+
assert_arrays_eq!(rebuilt, list, &mut ctx);
1192+
Ok(())
1193+
}

0 commit comments

Comments
 (0)