From bd712c37e68775f27d108904dfb37e7759a15ece Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 16:55:40 +0000 Subject: [PATCH 1/3] Benchmark bool run-end decode over fixed corpora `run_end_decode`'s bool cases give every run the same length and derive its value from the run index, then decode that one array over and over. Both properties flatter the decoder: the run length is a loop-invariant constant, and the per-run branch on the run's value -- the branch an adaptive decode strategy turns on -- is perfectly learnable after one pass. What the numbers then measure is partly the branch predictor's memory of one array. Add cases that decode a set of arrays per sample, each array independently generated from a fixed seed, with every run length drawn at random around the target average. No run boundary or run value sequence repeats within a sample, and the working set is too large to sit in L1. Seeds are fixed, so the corpus is byte-identical on every run and in CI. Two shapes bracket what a scan sees -- 128 arrays of 16,384 elements, and 4 of 100,000 -- across average run lengths of 4, 16 and 64, which span shorter than a machine word, around a word, and longer than a word. Each is run with 50/50 values, 90/10 values, and 50/50 values over 90% validity. Benchmarks only; no library code changes. Signed-off-by: "Claude" --- encodings/runend/Cargo.toml | 4 + .../benches/run_end_decode_bool_corpus.rs | 245 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 encodings/runend/benches/run_end_decode_bool_corpus.rs diff --git a/encodings/runend/Cargo.toml b/encodings/runend/Cargo.toml index 7ec149b9dd9..cb902fa2f19 100644 --- a/encodings/runend/Cargo.toml +++ b/encodings/runend/Cargo.toml @@ -50,6 +50,10 @@ harness = false name = "run_end_decode" harness = false +[[bench]] +name = "run_end_decode_bool_corpus" +harness = false + [[bench]] name = "run_end_take" harness = false diff --git a/encodings/runend/benches/run_end_decode_bool_corpus.rs b/encodings/runend/benches/run_end_decode_bool_corpus.rs new file mode 100644 index 00000000000..b2d7e0ce644 --- /dev/null +++ b/encodings/runend/benches/run_end_decode_bool_corpus.rs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Run-end bool decode over fixed corpora of arrays. +//! +//! `run_end_decode`'s bool cases give every run the same length and derive its value from the +//! run index, then decode that one array over and over. Both properties flatter a decoder: the +//! run length is a loop-invariant constant, and the per-run branch on the run's value — the +//! branch an adaptive decode strategy turns on — is perfectly learnable after one pass. Real +//! columns are neither. +//! +//! So these cases decode a *set* of arrays per sample, each array independently generated from +//! a fixed seed, with every run length drawn at random around the target average. Nothing about +//! run boundaries or run values repeats across a sample, and the working set is large enough +//! that the input does not sit in L1. The seeds are fixed, so the corpus is identical on every +//! run and in CI. +//! +//! Two shapes, sized to bracket what a scan sees: +//! +//! * `128x16k` — 128 arrays of 16,384 elements (2M elements per sample), the many-chunks case +//! * `4x100k` — 4 arrays of 100,000 elements (400k elements per sample), the few-large case +//! +//! Average run lengths of 4, 16 and 64 span the regimes a bool decoder cares about: shorter than +//! a machine word, around a word, and longer than a word. + +#![expect(clippy::cast_possible_truncation)] +#![expect(clippy::unwrap_used)] + +use std::fmt; +use std::sync::LazyLock; + +use divan::Bencher; +use divan::counter::ItemsCount; +use rand::RngExt; +use rand::SeedableRng; +use rand::rngs::StdRng; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::validity::Validity; +use vortex_buffer::BitBuffer; +use vortex_buffer::BufferMut; +use vortex_runend::decompress_bool::runend_decode_bools; +use vortex_session::VortexSession; + +fn main() { + divan::main(); +} + +static SESSION: LazyLock = LazyLock::new(|| { + let session = vortex_array::array_session(); + vortex_runend::initialize(&session); + session +}); + +/// Base seed for every corpus. Fixed so a given case is byte-identical on every run. +const SEED: u64 = 0x5EED_A55E_7B1E_0001; + +/// Odd multiplier used to spread per-array seeds. +const SEED_STRIDE: u64 = 0x9E37_79B9_7F4A_7C15; + +/// Fraction of runs whose value is `true`. +const EVEN: f64 = 0.5; +const SKEWED: f64 = 0.9; + +/// Fraction of runs that are valid, in the nullable cases. +const VALID: f64 = 0.9; + +/// How many arrays of what length make up one sample. +#[derive(Clone, Copy)] +struct Shape { + arrays: usize, + length: usize, + name: &'static str, +} + +const MANY_SMALL: Shape = Shape { + arrays: 128, + length: 16 * 1024, + name: "128x16k", +}; + +const FEW_LARGE: Shape = Shape { + arrays: 4, + length: 100_000, + name: "4x100k", +}; + +/// The mix of run values and validity a corpus is generated with. +#[derive(Clone, Copy)] +enum Mix { + /// 50/50 true/false runs, all valid. No majority for a decoder to exploit. + Even, + /// 90/10 true/false runs, all valid. The shape a majority-prefill strategy is built for. + Skewed, + /// 50/50 true/false runs, 90% valid, so validity has to be decoded alongside the values. + EvenNullable, +} + +impl Mix { + fn true_probability(self) -> f64 { + match self { + Mix::Even | Mix::EvenNullable => EVEN, + Mix::Skewed => SKEWED, + } + } + + fn valid_probability(self) -> Option { + match self { + Mix::Even | Mix::Skewed => None, + Mix::EvenNullable => Some(VALID), + } + } +} + +impl fmt::Display for Mix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Mix::Even => write!(f, "even"), + Mix::Skewed => write!(f, "skewed"), + Mix::EvenNullable => write!(f, "even_nullable"), + } + } +} + +#[derive(Clone, Copy)] +struct Case { + shape: Shape, + avg_run_length: usize, + mix: Mix, +} + +impl fmt::Display for Case { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}_run{}_{}", + self.shape.name, self.avg_run_length, self.mix + ) + } +} + +/// Every combination of shape, average run length and value mix. +const CASES: &[Case] = &{ + let mut cases = [Case { + shape: MANY_SMALL, + avg_run_length: 4, + mix: Mix::Even, + }; 18]; + + let shapes = [MANY_SMALL, FEW_LARGE]; + let run_lengths = [4usize, 16, 64]; + let mixes = [Mix::Even, Mix::Skewed, Mix::EvenNullable]; + + let mut i = 0; + let mut s = 0; + while s < shapes.len() { + let mut r = 0; + while r < run_lengths.len() { + let mut m = 0; + while m < mixes.len() { + cases[i] = Case { + shape: shapes[s], + avg_run_length: run_lengths[r], + mix: mixes[m], + }; + i += 1; + m += 1; + } + r += 1; + } + s += 1; + } + cases +}; + +/// One run-end encoded bool array: run ends plus the value (and validity) of each run. +type Encoded = (PrimitiveArray, BoolArray); + +/// Builds one array whose runs average `avg_run_length` but are individually random. +fn encoded_array(seed: u64, length: usize, avg_run_length: usize, mix: Mix) -> Encoded { + let mut rng = StdRng::seed_from_u64(seed); + // Uniform over `1..=2*avg - 1`, so the mean is `avg` but no two runs need be alike and the + // decoder cannot hoist the run length out of its loop. + let max_run = (2 * avg_run_length - 1).max(1); + let true_probability = mix.true_probability(); + + let mut ends = BufferMut::::with_capacity(length / avg_run_length + 1); + let mut values = Vec::with_capacity(length / avg_run_length + 1); + let mut validity = Vec::new(); + + let mut pos = 0usize; + while pos < length { + pos += rng.random_range(1..=max_run).min(length - pos); + ends.push(pos as u32); + values.push(rng.random_bool(true_probability)); + if let Some(valid) = mix.valid_probability() { + validity.push(rng.random_bool(valid)); + } + } + + let values = match mix.valid_probability() { + Some(_) => BoolArray::new( + BitBuffer::from(values), + Validity::from(BitBuffer::from(validity)), + ), + None => BoolArray::from(BitBuffer::from(values)), + }; + ( + PrimitiveArray::new(ends.freeze(), Validity::NonNullable), + values, + ) +} + +/// Builds the whole corpus for a case. Each array gets its own seed, so no two arrays in a +/// sample share a run-boundary or value sequence. +fn corpus(case: Case) -> Vec { + (0..case.shape.arrays as u64) + .map(|k| { + encoded_array( + SEED.wrapping_add(k.wrapping_mul(SEED_STRIDE)), + case.shape.length, + case.avg_run_length, + case.mix, + ) + }) + .collect() +} + +#[divan::bench(args = CASES)] +fn decode_bool_corpus(bencher: Bencher, case: Case) { + let corpus = corpus(case); + let length = case.shape.length; + + bencher + .counter(ItemsCount::new(case.shape.arrays * length)) + .with_inputs(|| SESSION.create_execution_ctx()) + .bench_local_refs(|ctx| { + for (ends, values) in &corpus { + divan::black_box( + runend_decode_bools(ends.clone(), values.clone(), 0, length, ctx).unwrap(), + ); + } + }); +} From 8d67e7f21a7512865162fe75b4dbd3e825ab5962 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:14:10 +0000 Subject: [PATCH 2/3] Keep the decoded arrays out of the timed region The corpus loop black-boxed each decoded array and dropped it on the spot, so every sample measured one deallocation per array alongside the decode. Decode kernels differ in how many buffers they allocate and at what sizes, so that is not a neutral constant folded into every arm -- it is a term that moves with the thing being measured. Collect into a vector instead and return it, so divan drops it after the timer stops. The vector is allocated with capacity by `with_inputs`, which also runs outside the timed region, leaving a push per array as the only addition -- a store and a length bump. Signed-off-by: "Claude" --- .../benches/run_end_decode_bool_corpus.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/encodings/runend/benches/run_end_decode_bool_corpus.rs b/encodings/runend/benches/run_end_decode_bool_corpus.rs index b2d7e0ce644..5b15dc283a3 100644 --- a/encodings/runend/benches/run_end_decode_bool_corpus.rs +++ b/encodings/runend/benches/run_end_decode_bool_corpus.rs @@ -34,6 +34,7 @@ use divan::counter::ItemsCount; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; +use vortex_array::ArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::PrimitiveArray; @@ -232,14 +233,24 @@ fn decode_bool_corpus(bencher: Bencher, case: Case) { let corpus = corpus(case); let length = case.shape.length; + // Decoded arrays are collected rather than dropped in the loop, and the vector is returned + // so divan drops it after the timer stops. Dropping each array as it is produced would put a + // deallocation per array inside the measurement, which is not what this benchmark is for. + // The vector is allocated by `with_inputs`, outside the timed region, with capacity reserved. bencher .counter(ItemsCount::new(case.shape.arrays * length)) - .with_inputs(|| SESSION.create_execution_ctx()) - .bench_local_refs(|ctx| { + .with_inputs(|| { + ( + SESSION.create_execution_ctx(), + Vec::with_capacity(case.shape.arrays), + ) + }) + .bench_local_values(|(mut ctx, mut decoded): (_, Vec)| { for (ends, values) in &corpus { - divan::black_box( - runend_decode_bools(ends.clone(), values.clone(), 0, length, ctx).unwrap(), + decoded.push( + runend_decode_bools(ends.clone(), values.clone(), 0, length, &mut ctx).unwrap(), ); } + decoded }); } From bd674b8ca30cd6c8d69183cd40736c13e3e91ab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 11:18:10 +0000 Subject: [PATCH 3/3] Draw corpus run lengths from a normal distribution Run lengths were drawn uniformly from `1..=2*avg - 1`. That gives the right mean, but a flat band with a hard cap: for an average of 64, no run in the case ever exceeds 127, and every length in the band is equally likely. The spread of run lengths, not the mean, is what sets how often a run crosses a 64-bit word boundary, so the shape of this distribution is not incidental to what the benchmark measures. Draw from a normal about the mean instead, with a standard deviation of a third of it, which puts the clamp at one element three standard deviations out so the truncated tail is negligible and the realised mean stays close to the nominal one. Signed-off-by: "Claude" --- Cargo.lock | 1 + encodings/runend/Cargo.toml | 1 + .../benches/run_end_decode_bool_corpus.rs | 26 +++++++++++++------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16b0a83f9ac..69c3a2d8a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10237,6 +10237,7 @@ dependencies = [ "num-traits", "prost 0.14.4", "rand 0.10.2", + "rand_distr 0.6.0", "rstest", "vortex-array", "vortex-buffer", diff --git a/encodings/runend/Cargo.toml b/encodings/runend/Cargo.toml index cb902fa2f19..320504c9dd2 100644 --- a/encodings/runend/Cargo.toml +++ b/encodings/runend/Cargo.toml @@ -32,6 +32,7 @@ divan = { workspace = true } itertools = { workspace = true } mimalloc = { workspace = true } rand = { workspace = true } +rand_distr = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } diff --git a/encodings/runend/benches/run_end_decode_bool_corpus.rs b/encodings/runend/benches/run_end_decode_bool_corpus.rs index 5b15dc283a3..48454340ee9 100644 --- a/encodings/runend/benches/run_end_decode_bool_corpus.rs +++ b/encodings/runend/benches/run_end_decode_bool_corpus.rs @@ -10,10 +10,10 @@ //! columns are neither. //! //! So these cases decode a *set* of arrays per sample, each array independently generated from -//! a fixed seed, with every run length drawn at random around the target average. Nothing about -//! run boundaries or run values repeats across a sample, and the working set is large enough -//! that the input does not sit in L1. The seeds are fixed, so the corpus is identical on every -//! run and in CI. +//! a fixed seed, with every run length drawn from a normal distribution about the target +//! average. Nothing about run boundaries or run values repeats across a sample, and the working +//! set is large enough that the input does not sit in L1. The seeds are fixed, so the corpus is +//! identical on every run and in CI. //! //! Two shapes, sized to bracket what a scan sees: //! @@ -34,6 +34,8 @@ use divan::counter::ItemsCount; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; +use rand_distr::Distribution; +use rand_distr::Normal; use vortex_array::ArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; @@ -67,6 +69,12 @@ const SKEWED: f64 = 0.9; /// Fraction of runs that are valid, in the nullable cases. const VALID: f64 = 0.9; +/// Standard deviation of the run length, as a fraction of its mean. +/// +/// A third of the mean puts the clamp at one element three standard deviations out, so it +/// truncates a negligible tail and the realised mean stays close to the nominal one. +const RUN_LENGTH_SIGMA: f64 = 1.0 / 3.0; + /// How many arrays of what length make up one sample. #[derive(Clone, Copy)] struct Shape { @@ -181,9 +189,10 @@ type Encoded = (PrimitiveArray, BoolArray); /// Builds one array whose runs average `avg_run_length` but are individually random. fn encoded_array(seed: u64, length: usize, avg_run_length: usize, mix: Mix) -> Encoded { let mut rng = StdRng::seed_from_u64(seed); - // Uniform over `1..=2*avg - 1`, so the mean is `avg` but no two runs need be alike and the - // decoder cannot hoist the run length out of its loop. - let max_run = (2 * avg_run_length - 1).max(1); + // Normal about the target average, so runs vary continuously rather than sitting in a flat + // band, and the decoder can neither hoist the run length nor learn a bounded one. + let mean = avg_run_length as f64; + let run_lengths = Normal::new(mean, (mean * RUN_LENGTH_SIGMA).max(f64::MIN_POSITIVE)).unwrap(); let true_probability = mix.true_probability(); let mut ends = BufferMut::::with_capacity(length / avg_run_length + 1); @@ -192,7 +201,8 @@ fn encoded_array(seed: u64, length: usize, avg_run_length: usize, mix: Mix) -> E let mut pos = 0usize; while pos < length { - pos += rng.random_range(1..=max_run).min(length - pos); + let run = run_lengths.sample(&mut rng).round().max(1.0) as usize; + pos += run.min(length - pos); ends.push(pos as u32); values.push(rng.random_bool(true_probability)); if let Some(valid) = mix.valid_probability() {