Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions encodings/runend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,7 @@ harness = false
[[bench]]
name = "run_end_take"
harness = false

[[bench]]
name = "run_end_filter"
harness = false
113 changes: 113 additions & 0 deletions encodings/runend/benches/run_end_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Benchmarks for the run-end filter inner loop (`filter_run_end_primitive`).
//!
//! This measures the kernel directly rather than going through the lazy
//! `ArrayRef::filter` (which only builds a `FilterArray` node and does not run
//! the kernel). The hot work is a per-run popcount of the predicate mask, which
//! now uses `BitBuffer::count_range` (SIMD) instead of a bit-by-bit walk.

#![expect(clippy::cast_possible_truncation)]
#![expect(clippy::cast_precision_loss)]
#![expect(clippy::cast_sign_loss)]
#![expect(clippy::expect_used)]

use std::fmt;

use divan::Bencher;
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use vortex_buffer::BitBuffer;
use vortex_runend::_benchmarking::filter_run_end_primitive;

fn main() {
divan::main();
}

#[derive(Clone, Copy)]
struct FilterBenchArgs {
/// Total logical length of the decoded array.
length: usize,
/// Average run length used when building the run-end array.
run_length: usize,
/// Fraction of mask bits that are set to `true`.
density: f64,
}

impl fmt::Display for FilterBenchArgs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"len={}_run={}_density={:.1}",
self.length, self.run_length, self.density
)
}
}

const FILTER_ARGS: &[FilterBenchArgs] = &[
FilterBenchArgs {
length: 4_096,
run_length: 16,
density: 0.1,
},
FilterBenchArgs {
length: 4_096,
run_length: 16,
density: 0.5,
},
FilterBenchArgs {
length: 4_096,
run_length: 16,
density: 0.9,
},
FilterBenchArgs {
length: 16_384,
run_length: 16,
density: 0.1,
},
FilterBenchArgs {
length: 16_384,
run_length: 16,
density: 0.5,
},
FilterBenchArgs {
length: 16_384,
run_length: 16,
density: 0.9,
},
];

/// Build the run-end boundaries (cumulative run lengths) for `length` rows.
fn build_run_ends(length: usize, run_length: usize) -> Vec<u32> {
let n_runs = length.div_ceil(run_length);
(0..n_runs)
.map(|r| (((r + 1) * run_length).min(length)) as u32)
.collect()
}

/// Build a predicate mask of `length` bits with approximately `density` set bits,
/// shuffled so the set bits are spread across runs.
fn build_mask(length: usize, density: f64) -> BitBuffer {
let n_true = (length as f64 * density).round() as usize;
let mut bits = vec![false; length];
for b in bits.iter_mut().take(n_true) {
*b = true;
}
let mut rng = StdRng::seed_from_u64(0x5eed);
bits.shuffle(&mut rng);
BitBuffer::from(bits)
}

#[divan::bench(args = FILTER_ARGS)]
fn filter_run_end(bencher: Bencher, args: FilterBenchArgs) {
let run_ends = build_run_ends(args.length, args.run_length);
let mask = build_mask(args.length, args.density);
let length = args.length as u64;
bencher
.with_inputs(|| (run_ends.clone(), mask.clone()))
.bench_refs(|(run_ends, mask)| {
filter_run_end_primitive::<u32>(run_ends, 0, length, mask).expect("filter")
});
}
2 changes: 1 addition & 1 deletion encodings/runend/src/compute/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ impl FilterKernel for RunEnd {
}

// Code adapted from apache arrow-rs https://github.com/apache/arrow-rs/blob/b1f5c250ebb6c1252b4e7c51d15b8e77f4c361fa/arrow-select/src/filter.rs#L425
fn filter_run_end_primitive<R: NativePType + AddAssign + From<bool> + AsPrimitive<u64>>(
pub fn filter_run_end_primitive<R: NativePType + AddAssign + From<bool> + AsPrimitive<u64>>(
run_ends: &[R],
offset: u64,
length: u64,
Expand Down
1 change: 1 addition & 0 deletions encodings/runend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod rules;

#[doc(hidden)]
pub mod _benchmarking {
pub use compute::filter::filter_run_end_primitive;
pub use compute::take::take_indices_unchecked;

use super::*;
Expand Down
4 changes: 4 additions & 0 deletions vortex-array/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ required-features = ["_test-harness"]
name = "dict_mask"
harness = false

[[bench]]
name = "validity_is_valid"
harness = false

[[bench]]
name = "dict_unreferenced_mask"
harness = false
Expand Down
71 changes: 71 additions & 0 deletions vortex-array/benches/validity_is_valid.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Benchmarks the cost of checking array-backed [`Validity`] per element.
//!
//! For the `Validity::Array` variant, `Validity::execute_is_valid` runs a scalar lookup through
//! the compute stack on *every* call, and the (now deprecated) `is_valid` additionally spins up a
//! fresh `ExecutionCtx` per call. Either way, calling it in a `for i in 0..n` loop is
//! pathologically slow. The fix used by callers is to materialize the validity into a `Mask` once
//! (`execute_mask`) and then do cheap O(1) bit reads via `Mask::value`. This bench contrasts the two.
//!
//! Sizes are kept small because the per-element variant is intentionally the slow one we are
//! measuring.

#![allow(clippy::unwrap_used)]

use std::sync::LazyLock;

use divan::Bencher;
use vortex_array::VortexSessionExecute;
use vortex_array::array_session;
use vortex_array::validity::Validity;
use vortex_buffer::BitBuffer;
use vortex_session::VortexSession;

fn main() {
divan::main();
}

const SIZES: &[usize] = &[256, 1024];

/// Build an array-backed validity (~10% nulls so it is `Validity::Array`).
fn array_validity(len: usize) -> Validity {
Validity::from(BitBuffer::from_iter(
(0..len).map(|i| !i.is_multiple_of(10)),
))
}

static SESSION: LazyLock<VortexSession> = LazyLock::new(array_session);

/// Per-element validity check over array-backed validity (the antipattern). This mirrors the
/// deprecated `Validity::is_valid(i)`: a fresh `ExecutionCtx` plus a scalar lookup on every call.
#[divan::bench(args = SIZES)]
fn is_valid_per_element(bencher: Bencher, len: usize) {
let validity = array_validity(len);
bencher
.with_inputs(|| (&validity, SESSION.create_execution_ctx()))
.bench_refs(|(validity, ctx)| {
let mut count = 0usize;
for i in 0..len {
count += validity.execute_is_valid(i, ctx).unwrap() as usize;
}
count
});
}

/// Materialize the validity into a `Mask` once, then read bits (the fix).
#[divan::bench(args = SIZES)]
fn execute_mask_then_value(bencher: Bencher, len: usize) {
let validity = array_validity(len);
bencher
.with_inputs(|| (&validity, SESSION.create_execution_ctx()))
.bench_refs(|(validity, ctx)| {
let mask = validity.execute_mask(len, ctx).unwrap();
let mut count = 0usize;
for i in 0..len {
count += mask.value(i) as usize;
}
count
});
}
5 changes: 5 additions & 0 deletions vortex-duckdb/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vortex-utils = { workspace = true, features = ["dashmap"] }

[dev-dependencies]
anyhow = { workspace = true }
divan = { workspace = true }
geo-types = { workspace = true }
jiff = { workspace = true }
rstest = { workspace = true }
Expand All @@ -54,6 +55,10 @@ vortex-runend = { workspace = true }
vortex-sequence = { workspace = true }
wkb = { workspace = true }

[[bench]]
name = "bool_export"
harness = false

[lints]
workspace = true

Expand Down
58 changes: 58 additions & 0 deletions vortex-duckdb/benches/bool_export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Microbenchmark for the bit -> byte-bool unpack performed by `BoolExporter::export`.
//!
//! DuckDB stores booleans as one byte per value (`&mut [bool]`), while Vortex stores them
//! bit-packed in a [`BitBuffer`]. On every boolean column export we unpack a slice of the
//! bit buffer into the destination byte slice.
//!
//! This bench isolates that pure unpacking logic so it can run without a live DuckDB
//! vector: a reused `Vec<bool>` stands in for the DuckDB byte-bool destination. It compares
//! the previous implementation (`.iter().collect::<Vec<bool>>()` followed by
//! `copy_from_slice`) against the new allocation-free direct zip-write.

use divan::Bencher;
use vortex::buffer::BitBuffer;

fn main() {
divan::main();
}

const SIZES: &[usize] = &[1024, 16_384];

/// Bit densities (true ratio), expressed as `(numerator, denominator)`.
const DENSITIES: &[(usize, usize)] = &[(1, 2), (1, 10), (9, 10)];

fn make_buffer(len: usize, density: (usize, usize)) -> BitBuffer {
let (num, den) = density;
// `+ 8` so we can slice off a non-byte-aligned offset, matching real exports.
BitBuffer::from_iter((0..len + 8).map(|i| (i % den) < num))
}

/// Previous implementation: allocate a throwaway `Vec<bool>` then copy into the destination.
#[divan::bench(args = SIZES, consts = [0usize, 1usize, 2usize])]
fn old_collect_copy<const DENSITY_IDX: usize>(bencher: Bencher, len: usize) {
let buffer = make_buffer(len, DENSITIES[DENSITY_IDX]);
bencher
.with_inputs(|| vec![false; len])
.bench_refs(|dst: &mut Vec<bool>| {
// Offset by 1 to exercise a non-byte-aligned slice like the export hot path.
dst.copy_from_slice(&buffer.slice(1..(1 + len)).iter().collect::<Vec<bool>>());
divan::black_box(&dst);
});
}

/// New implementation: zip the sliced bit iterator directly into the destination slice.
#[divan::bench(args = SIZES, consts = [0usize, 1usize, 2usize])]
fn new_zip_write<const DENSITY_IDX: usize>(bencher: Bencher, len: usize) {
let buffer = make_buffer(len, DENSITIES[DENSITY_IDX]);
bencher
.with_inputs(|| vec![false; len])
.bench_refs(|dst: &mut Vec<bool>| {
for (slot, bit) in dst.iter_mut().zip(buffer.slice(1..(1 + len)).iter()) {
*slot = bit;
}
divan::black_box(&dst);
});
}
8 changes: 8 additions & 0 deletions vortex-mask/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,13 @@ harness = false
name = "rank"
harness = false

[[bench]]
name = "valid_counts"
harness = false

[[bench]]
name = "mask_iteration"
harness = false

[lints]
workspace = true
Loading
Loading