Skip to content

Commit a40d1c2

Browse files
committed
Speed up scans with a roaring row selection
Index-driven scans pass the matching rows as a `Selection`. With `IncludeRoaring`, every split built a one-range treemap, intersected it with a clone of the whole selection, collected the result into a `Vec<usize>` and only then built the mask. Sparse roaring selections also never got the exact split ranges that `IncludeByIndex` gets, so they were planned over natural splits. - `Selection::row_mask` now counts the selected rows in the split with `range_cardinality`, returning all-false/all-true masks directly, and otherwise seeks the treemap iterator to the split and sets bits in place. - `attempt_split_ranges` plans exact ranges for `IncludeRoaring` the same way as for `IncludeByIndex`. On a 14M-row file with 840K selected rows, a count over the selection drops from 29.5 ms to 20.6 ms of CPU. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JRTKSkM9xUFT8WHRyS97aR
1 parent 46d518b commit a40d1c2

2 files changed

Lines changed: 74 additions & 57 deletions

File tree

‎vortex-layout/src/scan/splits.rs‎

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,39 +34,45 @@ pub fn attempt_split_ranges(
3434
selection: &Selection,
3535
row_range: Option<&Range<u64>>,
3636
) -> Option<Vec<Range<u64>>> {
37-
let Selection::IncludeByIndex(buffer) = selection else {
38-
return None;
39-
};
40-
41-
let indices = buffer.as_slice();
42-
let indices = if let Some(row_range) = row_range {
43-
if row_range.is_empty() {
44-
return Some(Vec::new());
45-
}
46-
47-
let start = indices.partition_point(|&index| index < row_range.start);
48-
let end = indices.partition_point(|&index| index < row_range.end);
49-
&indices[start..end]
50-
} else {
51-
indices
52-
};
53-
54-
if indices.is_empty() {
37+
if row_range.is_some_and(|row_range| row_range.is_empty()) {
5538
return Some(Vec::new());
5639
}
40+
let row_range = row_range.cloned().unwrap_or(0..u64::MAX);
41+
42+
match selection {
43+
Selection::IncludeByIndex(buffer) => {
44+
let indices = buffer.as_slice();
45+
let start = indices.partition_point(|&index| index < row_range.start);
46+
let end = indices.partition_point(|&index| index < row_range.end);
47+
debug_assert!(indices[start..end].is_sorted());
48+
sparse_ranges(indices[start..end].iter().copied())
49+
}
50+
Selection::IncludeRoaring(roaring) => {
51+
let mut iter = roaring.iter();
52+
iter.advance_to(row_range.start);
53+
sparse_ranges(iter.take_while(|&index| index < row_range.end))
54+
}
55+
_ => None,
56+
}
57+
}
5758

58-
debug_assert!(indices.is_sorted());
59+
/// Builds ranges covering the sorted, unique `indices`, or returns `None` if they are too dense
60+
/// for exact ranges to beat the natural splits.
61+
fn sparse_ranges(mut indices: impl Iterator<Item = u64>) -> Option<Vec<Range<u64>>> {
62+
let Some(first) = indices.next() else {
63+
return Some(Vec::new());
64+
};
5965

6066
// We need to create ranges that will represent splits that cover our indices.
6167
// We want to make sure that we do not create too many splits. We also want to make sure our
6268
// splits do not cover too much as they would overlap column chunk boundaries.
6369

64-
let mut ranges = Vec::with_capacity((indices.len() as u64 / MAX_RANGE_SIZE) as usize);
65-
let mut curr_start = indices[0];
66-
let mut curr_end = indices[0] + 1; // Ranges are exclusive at the end.
70+
let mut ranges = Vec::new();
71+
let mut curr_start = first;
72+
let mut curr_end = first + 1; // Ranges are exclusive at the end.
6773

6874
// Build the ranges by iterating over the indices and attempting to extend the current range.
69-
for &idx in &indices[1..] {
75+
for idx in indices {
7076
// Check what the new range size would be if we extend the current range.
7177
let new_range_size = (idx + 1) - curr_start;
7278
let gap = (idx + 1) - curr_end;
@@ -117,6 +123,26 @@ mod tests {
117123
assert_eq!(ranges[0], 3..8);
118124
}
119125

126+
#[test]
127+
fn roaring_split_ranges_match_index_split_ranges() {
128+
let indices = [1u64, 3, 5, MAX_RANGE_SIZE * 2 + MIN_GAP_BETWEEN_RANGES];
129+
let roaring = Selection::IncludeRoaring(indices.into_iter().collect());
130+
131+
for row_range in [None, Some(3..9), Some(6..9), Some(0..u64::MAX)] {
132+
assert_eq!(
133+
attempt_split_ranges(&roaring, row_range.as_ref()),
134+
attempt_split_ranges(&include(indices), row_range.as_ref()),
135+
"row range {row_range:?}"
136+
);
137+
}
138+
}
139+
140+
#[test]
141+
fn dense_roaring_selection_uses_natural_splits() {
142+
let roaring = Selection::IncludeRoaring((0..MAX_RANGE_SIZE * 2).collect());
143+
assert_eq!(attempt_split_ranges(&roaring, None), None);
144+
}
145+
120146
#[test]
121147
fn split_ranges_empty_intersection() {
122148
assert_eq!(

‎vortex-scan/src/selection.rs‎

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use std::ops::Not;
77
use std::ops::Range;
88

9+
use vortex_buffer::BitBufferMut;
910
use vortex_error::vortex_panic;
1011
use vortex_mask::Mask;
1112

@@ -68,47 +69,37 @@ impl Selection {
6869
RowMask::new(range.start, index_mask(range, range_len, exclude).not())
6970
}
7071
Selection::IncludeRoaring(roaring) => {
71-
use std::ops::BitAnd;
72-
73-
// First we perform a cheap is_disjoint check
74-
let mut range_treemap = roaring::RoaringTreemap::new();
75-
range_treemap.insert_range(range.clone());
76-
77-
if roaring.is_disjoint(&range_treemap) {
78-
return RowMask::new(range.start, Mask::new_false(range_len));
79-
}
80-
81-
// Otherwise, intersect with the selected range and shift to relativize.
82-
let roaring = roaring.bitand(range_treemap);
83-
let mask =
84-
Mask::from_indices(range_len, roaring.iter().map(|idx| relativize(range, idx)));
85-
86-
RowMask::new(range.start, mask)
72+
RowMask::new(range.start, roaring_mask(roaring, range, range_len))
8773
}
8874
Selection::ExcludeRoaring(roaring) => {
89-
use std::ops::BitAnd;
90-
91-
let mut range_treemap = roaring::RoaringTreemap::new();
92-
range_treemap.insert_range(range.clone());
93-
94-
// If all indices in range are excluded, return all false mask
95-
if roaring.intersection_len(&range_treemap) == range_len as u64 {
96-
return RowMask::new(range.start, Mask::new_false(range_len));
97-
}
98-
99-
// Otherwise, intersect with the selected range and shift to relativize.
100-
let roaring = roaring.bitand(range_treemap);
101-
let mask = Mask::from_excluded_indices(
102-
range_len,
103-
roaring.iter().map(|idx| relativize(range, idx)),
104-
);
105-
106-
RowMask::new(range.start, mask)
75+
RowMask::new(range.start, roaring_mask(roaring, range, range_len).not())
10776
}
10877
}
10978
}
11079
}
11180

81+
/// Build the mask of positions within `range` that are set in `roaring`.
82+
///
83+
/// Seeks straight to `range` rather than intersecting the whole treemap, and short-circuits splits
84+
/// that are entirely outside or inside the selection.
85+
fn roaring_mask(roaring: &roaring::RoaringTreemap, range: &Range<u64>, range_len: usize) -> Mask {
86+
let selected = roaring.range_cardinality(range.clone());
87+
if selected == 0 {
88+
return Mask::new_false(range_len);
89+
}
90+
if selected == range_len as u64 {
91+
return Mask::new_true(range_len);
92+
}
93+
94+
let mut bits = BitBufferMut::new_unset(range_len);
95+
let mut iter = roaring.iter();
96+
iter.advance_to(range.start);
97+
for idx in iter.take_while(|&idx| idx < range.end) {
98+
bits.set(relativize(range, idx));
99+
}
100+
Mask::from_buffer(bits.freeze())
101+
}
102+
112103
/// Build the mask of positions within `range` that are named by the given sorted row indices.
113104
fn index_mask(range: &Range<u64>, range_len: usize, row_indices: &[u64]) -> Mask {
114105
indices_range(range, row_indices)

0 commit comments

Comments
 (0)