Skip to content

Commit adeecae

Browse files
committed
Add bit iteration benchmarks
Signed-off-by: Robert Kruszewski <github@robertk.io>
1 parent 5e656ee commit adeecae

12 files changed

Lines changed: 458 additions & 1 deletion

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎encodings/runend/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,7 @@ harness = false
5656
[[bench]]
5757
name = "run_end_take"
5858
harness = false
59+
60+
[[bench]]
61+
name = "run_end_filter"
62+
harness = false
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Benchmarks for the run-end filter inner loop (`filter_run_end_primitive`).
5+
//!
6+
//! This measures the kernel directly rather than going through the lazy
7+
//! `ArrayRef::filter` (which only builds a `FilterArray` node and does not run
8+
//! the kernel). The hot work is a per-run popcount of the predicate mask, which
9+
//! now uses `BitBuffer::count_range` (SIMD) instead of a bit-by-bit walk.
10+
11+
#![expect(clippy::cast_possible_truncation)]
12+
#![expect(clippy::cast_precision_loss)]
13+
#![expect(clippy::cast_sign_loss)]
14+
#![expect(clippy::expect_used)]
15+
16+
use std::fmt;
17+
18+
use divan::Bencher;
19+
use rand::SeedableRng;
20+
use rand::rngs::StdRng;
21+
use rand::seq::SliceRandom;
22+
use vortex_buffer::BitBuffer;
23+
use vortex_runend::_benchmarking::filter_run_end_primitive;
24+
25+
fn main() {
26+
divan::main();
27+
}
28+
29+
#[derive(Clone, Copy)]
30+
struct FilterBenchArgs {
31+
/// Total logical length of the decoded array.
32+
length: usize,
33+
/// Average run length used when building the run-end array.
34+
run_length: usize,
35+
/// Fraction of mask bits that are set to `true`.
36+
density: f64,
37+
}
38+
39+
impl fmt::Display for FilterBenchArgs {
40+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41+
write!(
42+
f,
43+
"len={}_run={}_density={:.1}",
44+
self.length, self.run_length, self.density
45+
)
46+
}
47+
}
48+
49+
const FILTER_ARGS: &[FilterBenchArgs] = &[
50+
FilterBenchArgs {
51+
length: 4_096,
52+
run_length: 16,
53+
density: 0.1,
54+
},
55+
FilterBenchArgs {
56+
length: 4_096,
57+
run_length: 16,
58+
density: 0.5,
59+
},
60+
FilterBenchArgs {
61+
length: 4_096,
62+
run_length: 16,
63+
density: 0.9,
64+
},
65+
FilterBenchArgs {
66+
length: 16_384,
67+
run_length: 16,
68+
density: 0.1,
69+
},
70+
FilterBenchArgs {
71+
length: 16_384,
72+
run_length: 16,
73+
density: 0.5,
74+
},
75+
FilterBenchArgs {
76+
length: 16_384,
77+
run_length: 16,
78+
density: 0.9,
79+
},
80+
];
81+
82+
/// Build the run-end boundaries (cumulative run lengths) for `length` rows.
83+
fn build_run_ends(length: usize, run_length: usize) -> Vec<u32> {
84+
let n_runs = length.div_ceil(run_length);
85+
(0..n_runs)
86+
.map(|r| (((r + 1) * run_length).min(length)) as u32)
87+
.collect()
88+
}
89+
90+
/// Build a predicate mask of `length` bits with approximately `density` set bits,
91+
/// shuffled so the set bits are spread across runs.
92+
fn build_mask(length: usize, density: f64) -> BitBuffer {
93+
let n_true = (length as f64 * density).round() as usize;
94+
let mut bits = vec![false; length];
95+
for b in bits.iter_mut().take(n_true) {
96+
*b = true;
97+
}
98+
let mut rng = StdRng::seed_from_u64(0x5eed);
99+
bits.shuffle(&mut rng);
100+
BitBuffer::from(bits)
101+
}
102+
103+
#[divan::bench(args = FILTER_ARGS)]
104+
fn filter_run_end(bencher: Bencher, args: FilterBenchArgs) {
105+
let run_ends = build_run_ends(args.length, args.run_length);
106+
let mask = build_mask(args.length, args.density);
107+
let length = args.length as u64;
108+
bencher
109+
.with_inputs(|| (run_ends.clone(), mask.clone()))
110+
.bench_refs(|(run_ends, mask)| {
111+
filter_run_end_primitive::<u32>(run_ends, 0, length, mask).expect("filter")
112+
});
113+
}

‎encodings/runend/src/compute/filter.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ impl FilterKernel for RunEnd {
7474
}
7575

7676
// Code adapted from apache arrow-rs https://github.com/apache/arrow-rs/blob/b1f5c250ebb6c1252b4e7c51d15b8e77f4c361fa/arrow-select/src/filter.rs#L425
77-
fn filter_run_end_primitive<R: NativePType + AddAssign + From<bool> + AsPrimitive<u64>>(
77+
pub fn filter_run_end_primitive<R: NativePType + AddAssign + From<bool> + AsPrimitive<u64>>(
7878
run_ends: &[R],
7979
offset: u64,
8080
length: u64,

‎encodings/runend/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod rules;
2121

2222
#[doc(hidden)]
2323
pub mod _benchmarking {
24+
pub use compute::filter::filter_run_end_primitive;
2425
pub use compute::take::take_indices_unchecked;
2526

2627
use super::*;

‎vortex-array/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ required-features = ["_test-harness"]
200200
name = "dict_mask"
201201
harness = false
202202

203+
[[bench]]
204+
name = "validity_is_valid"
205+
harness = false
206+
203207
[[bench]]
204208
name = "dict_unreferenced_mask"
205209
harness = false
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Benchmarks the cost of checking array-backed [`Validity`] per element.
5+
//!
6+
//! For the `Validity::Array` variant, `Validity::execute_is_valid` runs a scalar lookup through
7+
//! the compute stack on *every* call, and the (now deprecated) `is_valid` additionally spins up a
8+
//! fresh `ExecutionCtx` per call. Either way, calling it in a `for i in 0..n` loop is
9+
//! pathologically slow. The fix used by callers is to materialize the validity into a `Mask` once
10+
//! (`execute_mask`) and then do cheap O(1) bit reads via `Mask::value`. This bench contrasts the two.
11+
//!
12+
//! Sizes are kept small because the per-element variant is intentionally the slow one we are
13+
//! measuring.
14+
15+
#![allow(clippy::unwrap_used)]
16+
17+
use std::sync::LazyLock;
18+
19+
use divan::Bencher;
20+
use vortex_array::VortexSessionExecute;
21+
use vortex_array::array_session;
22+
use vortex_array::validity::Validity;
23+
use vortex_buffer::BitBuffer;
24+
use vortex_session::VortexSession;
25+
26+
fn main() {
27+
divan::main();
28+
}
29+
30+
const SIZES: &[usize] = &[256, 1024];
31+
32+
/// Build an array-backed validity (~10% nulls so it is `Validity::Array`).
33+
fn array_validity(len: usize) -> Validity {
34+
Validity::from(BitBuffer::from_iter(
35+
(0..len).map(|i| !i.is_multiple_of(10)),
36+
))
37+
}
38+
39+
static SESSION: LazyLock<VortexSession> = LazyLock::new(array_session);
40+
41+
/// Per-element validity check over array-backed validity (the antipattern). This mirrors the
42+
/// deprecated `Validity::is_valid(i)`: a fresh `ExecutionCtx` plus a scalar lookup on every call.
43+
#[divan::bench(args = SIZES)]
44+
fn is_valid_per_element(bencher: Bencher, len: usize) {
45+
let validity = array_validity(len);
46+
bencher
47+
.with_inputs(|| (&validity, SESSION.create_execution_ctx()))
48+
.bench_refs(|(validity, ctx)| {
49+
let mut count = 0usize;
50+
for i in 0..len {
51+
count += validity.execute_is_valid(i, ctx).unwrap() as usize;
52+
}
53+
count
54+
});
55+
}
56+
57+
/// Materialize the validity into a `Mask` once, then read bits (the fix).
58+
#[divan::bench(args = SIZES)]
59+
fn execute_mask_then_value(bencher: Bencher, len: usize) {
60+
let validity = array_validity(len);
61+
bencher
62+
.with_inputs(|| (&validity, SESSION.create_execution_ctx()))
63+
.bench_refs(|(validity, ctx)| {
64+
let mask = validity.execute_mask(len, ctx).unwrap();
65+
let mut count = 0usize;
66+
for i in 0..len {
67+
count += mask.value(i) as usize;
68+
}
69+
count
70+
});
71+
}

‎vortex-duckdb/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ vortex-utils = { workspace = true, features = ["dashmap"] }
4545

4646
[dev-dependencies]
4747
anyhow = { workspace = true }
48+
divan = { workspace = true }
4849
geo-types = { workspace = true }
4950
jiff = { workspace = true }
5051
rstest = { workspace = true }
@@ -54,6 +55,10 @@ vortex-runend = { workspace = true }
5455
vortex-sequence = { workspace = true }
5556
wkb = { workspace = true }
5657

58+
[[bench]]
59+
name = "bool_export"
60+
harness = false
61+
5762
[lints]
5863
workspace = true
5964

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Microbenchmark for the bit -> byte-bool unpack performed by `BoolExporter::export`.
5+
//!
6+
//! DuckDB stores booleans as one byte per value (`&mut [bool]`), while Vortex stores them
7+
//! bit-packed in a [`BitBuffer`]. On every boolean column export we unpack a slice of the
8+
//! bit buffer into the destination byte slice.
9+
//!
10+
//! This bench isolates that pure unpacking logic so it can run without a live DuckDB
11+
//! vector: a reused `Vec<bool>` stands in for the DuckDB byte-bool destination. It compares
12+
//! the previous implementation (`.iter().collect::<Vec<bool>>()` followed by
13+
//! `copy_from_slice`) against the new allocation-free direct zip-write.
14+
15+
use divan::Bencher;
16+
use vortex::buffer::BitBuffer;
17+
18+
fn main() {
19+
divan::main();
20+
}
21+
22+
const SIZES: &[usize] = &[1024, 16_384];
23+
24+
/// Bit densities (true ratio), expressed as `(numerator, denominator)`.
25+
const DENSITIES: &[(usize, usize)] = &[(1, 2), (1, 10), (9, 10)];
26+
27+
fn make_buffer(len: usize, density: (usize, usize)) -> BitBuffer {
28+
let (num, den) = density;
29+
// `+ 8` so we can slice off a non-byte-aligned offset, matching real exports.
30+
BitBuffer::from_iter((0..len + 8).map(|i| (i % den) < num))
31+
}
32+
33+
/// Previous implementation: allocate a throwaway `Vec<bool>` then copy into the destination.
34+
#[divan::bench(args = SIZES, consts = [0usize, 1usize, 2usize])]
35+
fn old_collect_copy<const DENSITY_IDX: usize>(bencher: Bencher, len: usize) {
36+
let buffer = make_buffer(len, DENSITIES[DENSITY_IDX]);
37+
bencher
38+
.with_inputs(|| vec![false; len])
39+
.bench_refs(|dst: &mut Vec<bool>| {
40+
// Offset by 1 to exercise a non-byte-aligned slice like the export hot path.
41+
dst.copy_from_slice(&buffer.slice(1..(1 + len)).iter().collect::<Vec<bool>>());
42+
divan::black_box(&dst);
43+
});
44+
}
45+
46+
/// New implementation: zip the sliced bit iterator directly into the destination slice.
47+
#[divan::bench(args = SIZES, consts = [0usize, 1usize, 2usize])]
48+
fn new_zip_write<const DENSITY_IDX: usize>(bencher: Bencher, len: usize) {
49+
let buffer = make_buffer(len, DENSITIES[DENSITY_IDX]);
50+
bencher
51+
.with_inputs(|| vec![false; len])
52+
.bench_refs(|dst: &mut Vec<bool>| {
53+
for (slot, bit) in dst.iter_mut().zip(buffer.slice(1..(1 + len)).iter()) {
54+
*slot = bit;
55+
}
56+
divan::black_box(&dst);
57+
});
58+
}

‎vortex-mask/Cargo.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,13 @@ harness = false
3737
name = "rank"
3838
harness = false
3939

40+
[[bench]]
41+
name = "valid_counts"
42+
harness = false
43+
44+
[[bench]]
45+
name = "mask_iteration"
46+
harness = false
47+
4048
[lints]
4149
workspace = true

0 commit comments

Comments
 (0)