Skip to content

Commit 9cf954f

Browse files
joseph-isaacsasubiottorobert3005
authored
fix[vortex-dict]: faster min_max for dict array (#5288)
Signed-off-by: Alfonso Subiotto Marques <[email protected]> Signed-off-by: Joe Isaacs <[email protected]> Signed-off-by: Robert Kruszewski <[email protected]> Co-authored-by: Alfonso Subiotto Marques <[email protected]> Co-authored-by: Robert Kruszewski <[email protected]>
1 parent 3c5d9fd commit 9cf954f

File tree

5 files changed

+160
-23
lines changed

5 files changed

+160
-23
lines changed

vortex-array/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,7 @@ required-features = ["test-harness"]
159159
[[bench]]
160160
name = "dict_mask"
161161
harness = false
162+
163+
[[bench]]
164+
name = "dict_unreferenced_mask"
165+
harness = false
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
#![allow(clippy::unwrap_used)]
5+
6+
use divan::Bencher;
7+
use rand::rngs::StdRng;
8+
use rand::{Rng, SeedableRng};
9+
use vortex_array::IntoArray;
10+
use vortex_array::arrays::{DictArray, PrimitiveArray};
11+
use vortex_array::compute::warm_up_vtables;
12+
13+
fn main() {
14+
warm_up_vtables();
15+
divan::main();
16+
}
17+
18+
/// Benchmark with many codes (65K) relative to 1024 values.
19+
/// This tests performance when the values dictionary is small but many codes reference it.
20+
#[divan::bench(args = [
21+
1024, // Small dictionary
22+
2048, // Medium dictionary
23+
4096, // Larger dictionary
24+
])]
25+
fn bench_many_codes_few_values(bencher: Bencher, num_values: i32) {
26+
let mut rng = StdRng::seed_from_u64(0);
27+
28+
let num_codes = 65_536;
29+
30+
// Create values array with the specified number of unique values
31+
let values = PrimitiveArray::from_iter(0..num_values).into_array();
32+
33+
// Create codes that randomly reference the values
34+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
35+
let codes = PrimitiveArray::from_iter(
36+
(0..num_codes).map(|_| rng.random_range(0..num_values as usize) as u32),
37+
)
38+
.into_array();
39+
40+
let array = DictArray::try_new(codes, values).unwrap();
41+
42+
bencher
43+
.with_inputs(|| array.clone())
44+
.bench_values(|array| array.compute_unreferenced_values_mask().unwrap());
45+
}
46+
47+
/// Benchmark with many nulls in the codes array.
48+
/// This tests performance when most codes are null and thus don't reference values.
49+
#[divan::bench(args = [
50+
0.01, // 1% valid codes
51+
0.1, // 10% valid codes
52+
0.5, // 50% valid codes
53+
0.9, // 90% valid codes
54+
])]
55+
fn bench_many_nulls(bencher: Bencher, fraction_valid: f64) {
56+
let mut rng = StdRng::seed_from_u64(0);
57+
58+
let num_codes = 65_536;
59+
let num_values = 1024i32;
60+
61+
// Create values array
62+
let values = PrimitiveArray::from_iter(0..num_values).into_array();
63+
64+
// Create codes with many nulls based on fraction_valid
65+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
66+
let codes = PrimitiveArray::from_option_iter((0..num_codes).map(|_| {
67+
rng.random_bool(fraction_valid)
68+
.then(|| rng.random_range(0..num_values as usize) as u32)
69+
}))
70+
.into_array();
71+
72+
let array = DictArray::try_new(codes, values).unwrap();
73+
74+
bencher
75+
.with_inputs(|| array.clone())
76+
.bench_values(|array| array.compute_unreferenced_values_mask().unwrap());
77+
}
78+
79+
/// Benchmark with sparse code coverage (many unreferenced values).
80+
/// This tests when only a small subset of values are actually referenced.
81+
#[divan::bench(args = [
82+
0.01, // Only 1% of values are referenced
83+
0.1, // 10% of values referenced
84+
0.5, // 50% of values referenced
85+
])]
86+
fn bench_sparse_coverage(bencher: Bencher, fraction_coverage: f64) {
87+
let mut rng = StdRng::seed_from_u64(0);
88+
89+
let num_codes = 65_536;
90+
let num_values = 1024i32;
91+
92+
// Create values array
93+
let values = PrimitiveArray::from_iter(0..num_values).into_array();
94+
95+
// Calculate how many unique values we'll actually reference
96+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
97+
let num_referenced = (num_values as f64 * fraction_coverage).max(1.0) as usize;
98+
99+
// Create codes that only reference a subset of values
100+
#[allow(clippy::cast_possible_truncation)]
101+
let codes = PrimitiveArray::from_iter(
102+
(0..num_codes).map(|_| rng.random_range(0..num_referenced) as u32),
103+
)
104+
.into_array();
105+
106+
let array = DictArray::try_new(codes, values).unwrap();
107+
108+
bencher
109+
.with_inputs(|| array.clone())
110+
.bench_values(|array| array.compute_unreferenced_values_mask().unwrap());
111+
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,45 @@ impl DictArray {
106106
pub fn values(&self) -> &ArrayRef {
107107
&self.values
108108
}
109+
110+
/// Compute a mask indicating which values in the dictionary are referenced by at least one code.
111+
///
112+
/// Returns a `BitBuffer` where unset bits (false) correspond to values that are referenced
113+
/// by at least one valid code, and set bits (true) correspond to unreferenced values.
114+
///
115+
/// This is useful for operations like min/max that need to ignore unreferenced values.
116+
pub fn compute_unreferenced_values_mask(&self) -> VortexResult<BitBuffer> {
117+
let codes_validity = self.codes().validity_mask();
118+
let codes_primitive = self.codes().to_primitive();
119+
let values_len = self.values().len();
120+
121+
let mut unreferenced_vec = vec![true; values_len];
122+
match codes_validity.bit_buffer() {
123+
AllOr::All => {
124+
match_each_integer_ptype!(codes_primitive.ptype(), |P| {
125+
#[allow(clippy::cast_possible_truncation)]
126+
for &code in codes_primitive.as_slice::<P>().iter() {
127+
unreferenced_vec[code as usize] = false;
128+
}
129+
});
130+
}
131+
AllOr::None => {}
132+
AllOr::Some(buf) => {
133+
match_each_integer_ptype!(codes_primitive.ptype(), |P| {
134+
let codes = codes_primitive.as_slice::<P>();
135+
136+
#[allow(clippy::cast_possible_truncation)]
137+
buf.set_indices().for_each(|idx| {
138+
unreferenced_vec[codes[idx] as usize] = false;
139+
})
140+
});
141+
}
142+
}
143+
144+
Ok(BitBuffer::collect_bool(values_len, |idx| {
145+
unreferenced_vec[idx]
146+
}))
147+
}
109148
}
110149

111150
impl ArrayVTable<DictVTable> for DictVTable {

vortex-array/src/arrays/dict/compute/min_max.rs

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

4-
use vortex_buffer::BitBuffer;
5-
use vortex_dtype::match_each_unsigned_integer_ptype;
64
use vortex_error::VortexResult;
75
use vortex_mask::Mask;
86

97
use super::{DictArray, DictVTable};
108
use crate::compute::{MinMaxKernel, MinMaxKernelAdapter, MinMaxResult, mask, min_max};
11-
use crate::{Array as _, ToCanonical, register_kernel};
9+
use crate::{Array as _, register_kernel};
1210

1311
impl MinMaxKernel for DictVTable {
1412
fn min_max(&self, array: &DictArray) -> VortexResult<Option<MinMaxResult>> {
@@ -17,25 +15,8 @@ impl MinMaxKernel for DictVTable {
1715
return Ok(None);
1816
}
1917

20-
let codes_primitive = array.codes().to_primitive();
21-
let values_len = array.values().len();
22-
match_each_unsigned_integer_ptype!(codes_primitive.ptype(), |P| {
23-
codes_validity.iter_bools(|validity_iter| {
24-
// mask() sets values to null where the mask is true, so we start
25-
// with a fully-set bool buffer.
26-
let mut unreferenced = vec![true; values_len];
27-
#[allow(clippy::cast_possible_truncation)]
28-
for (&code, is_valid) in codes_primitive.as_slice::<P>().iter().zip(validity_iter) {
29-
if is_valid {
30-
unreferenced[code as usize] = false;
31-
}
32-
}
33-
34-
let unreferenced_mask =
35-
Mask::from_buffer(BitBuffer::collect_bool(values_len, |i| unreferenced[i]));
36-
min_max(&mask(array.values(), &unreferenced_mask)?)
37-
})
38-
})
18+
let unreferenced_mask = Mask::from_buffer(array.compute_unreferenced_values_mask()?);
19+
min_max(&mask(array.values(), &unreferenced_mask)?)
3920
}
4021
}
4122

vortex-buffer/src/bit/buf_mut.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ impl BitBufferMut {
268268
/// Set a position to `false`.
269269
///
270270
/// This operation is checked so if `index` exceeds the buffer length, this will panic.
271+
#[inline]
271272
pub fn unset(&mut self, index: usize) {
272273
assert!(index < self.len, "index {index} exceeds len {}", self.len);
273274

@@ -294,7 +295,8 @@ impl BitBufferMut {
294295
/// # Safety
295296
///
296297
/// The caller must ensure that `index` does not exceed the largest bit index in the backing buffer.
297-
unsafe fn unset_unchecked(&mut self, index: usize) {
298+
#[inline]
299+
pub unsafe fn unset_unchecked(&mut self, index: usize) {
298300
// SAFETY: checked by caller
299301
unsafe { unset_bit_unchecked(self.buffer.as_mut_ptr(), self.offset + index) }
300302
}

0 commit comments

Comments
 (0)