Skip to content
Open
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
18 changes: 18 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ members = [
"encodings/bytebool",
"encodings/parquet-variant",
"encodings/onpair",
"encodings/elias-fano",
# Benchmarks
"benchmarks/bench-support",
"benchmarks/lance-bench",
Expand Down Expand Up @@ -312,6 +313,7 @@ vortex-datafusion = { version = "0.1.0", path = "./vortex-datafusion", default-f
vortex-datetime-parts = { version = "0.1.0", path = "./encodings/datetime-parts", default-features = false }
vortex-decimal-byte-parts = { version = "0.1.0", path = "encodings/decimal-byte-parts", default-features = false }
vortex-edition = { version = "0.1.0", path = "./vortex-edition", default-features = false }
vortex-elias-fano = { version = "0.1.0", path = "./encodings/elias-fano", default-features = false }
vortex-error = { version = "0.1.0", path = "./vortex-error", default-features = false }
vortex-fastlanes = { version = "0.1.0", path = "./encodings/fastlanes", default-features = false }
vortex-file = { version = "0.1.0", path = "./vortex-file", default-features = false }
Expand Down
38 changes: 38 additions & 0 deletions encodings/elias-fano/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[package]
name = "vortex-elias-fano"
authors = { workspace = true }
categories = { workspace = true }
description = "Vortex Elias-Fano encoded array for monotonic integer sequences"
edition = { workspace = true }
homepage = { workspace = true }
include = { workspace = true }
keywords = { workspace = true }
license = { workspace = true }
readme = { workspace = true }
repository = { workspace = true }
rust-version = { workspace = true }
version = { workspace = true }

[dependencies]
lending-iterator = { workspace = true }
num-traits = { workspace = true }
prost = { workspace = true }
smallvec = { workspace = true }
vortex-array = { workspace = true }
vortex-buffer = { workspace = true }
vortex-error = { workspace = true }
vortex-fastlanes = { workspace = true }
vortex-mask = { workspace = true }
vortex-session = { workspace = true }

[dev-dependencies]
divan = { workspace = true }
rstest = { workspace = true }
vortex-array = { path = "../../vortex-array", features = ["_test-harness"] }

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

[lints]
workspace = true
154 changes: 154 additions & 0 deletions encodings/elias-fano/benches/elias_fano.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors
//
//! Microbenchmarks for the Elias-Fano array's read and write paths.
//!
//! `scalar_at` is the per-element path and reads the low-bits child once per probe, so it is what a
//! change to that path has to be judged against. `encode` and `decode_bulk` cover the two batch
//! paths.
//!
//! Three shapes, because the layout behaves differently in each:
//!
//! * `Sparse` — a wide universe, so `lower_width` is large and nearly every read touches the child.
//! * `Dense` — a universe no wider than the row count, so `lower_width` is zero and the low-bits
//! child is never read at all. The difference against `Sparse` is the child's whole cost.
//! * `Duplicates` — few distinct values over a wide universe, so each occupied high-part bucket is
//! deep. Random data almost never produces this.

#![allow(
clippy::cast_possible_truncation,
clippy::expect_used,
clippy::tests_outside_test_module,
clippy::unwrap_used
)]

use std::sync::LazyLock;

use divan::Bencher;
use vortex_array::ArrayRef;
use vortex_array::IntoArray;
use vortex_array::VortexSessionExecute;
use vortex_array::arrays::Primitive;
use vortex_array::arrays::PrimitiveArray;
use vortex_elias_fano::EliasFanoArray;
use vortex_elias_fano::elias_fano_encode;
use vortex_session::VortexSession;

static SESSION: LazyLock<VortexSession> = LazyLock::new(|| {
let session = vortex_array::array_session();
vortex_fastlanes::initialize(&session);
vortex_elias_fano::initialize(&session);
session
});

/// Deterministic xorshift, so a run is reproducible without a `rand` dependency.
struct Rng(u64);

impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}

fn below(&mut self, bound: u64) -> u64 {
self.next_u64() % bound
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Shape {
Sparse,
Dense,
Duplicates,
}

/// The universe every shape draws from, wide enough that `Sparse` gets ten low bits at 2^20 rows.
const UNIVERSE: u64 = 1 << 30;

/// Distinct values in the `Duplicates` shape: at 2^20 rows that is ~64 rows per value, so an
/// occupied bucket is deep enough for `search_bucket` to bisect rather than walk.
const LEVELS: u64 = 1 << 14;

fn values(n: usize, shape: Shape) -> Vec<u64> {
let mut rng = Rng(0x5EED_1234_ABCD_0001);
let mut out: Vec<u64> = match shape {
Shape::Sparse => (0..n).map(|_| rng.below(UNIVERSE)).collect(),
// Universe == row count, which drives `lower_width` to zero.
Shape::Dense => (0..n).map(|_| rng.below(n as u64)).collect(),
Shape::Duplicates => {
let step = UNIVERSE / LEVELS;
(0..n).map(|_| rng.below(LEVELS) * step).collect()
}
};
out.sort_unstable();
out
}

fn encoded(n: usize, shape: Shape) -> EliasFanoArray {
let array = PrimitiveArray::from_iter(values(n, shape));
let mut ctx = SESSION.create_execution_ctx();
elias_fano_encode(array.as_ref().as_::<Primitive>(), &mut ctx).expect("encode")
}

/// Probe count held fixed across shapes and row counts, so the reported figure is comparable.
const PROBES: usize = 4096;

const CASES: &[(Shape, usize)] = &[
(Shape::Sparse, 1 << 16),
(Shape::Sparse, 1 << 20),
(Shape::Dense, 1 << 20),
(Shape::Duplicates, 1 << 20),
];

fn random_indices(n: usize) -> Vec<usize> {
let mut rng = Rng(0xA11C_E000_0000_0001);
(0..PROBES).map(|_| rng.below(n as u64) as usize).collect()
}

/// Point lookups through `OperationsVTable::scalar_at`: one sampled `select1` and one low-bits read
/// apiece, in a random order so nothing about locality is being measured by accident.
#[divan::bench(args = CASES)]
fn scalar_at(bencher: Bencher, case: (Shape, usize)) {
let (shape, n) = case;
let array: ArrayRef = encoded(n, shape).into_array();
let indices = random_indices(n);
bencher
.with_inputs(|| SESSION.create_execution_ctx())
.bench_local_values(|mut ctx| {
for &index in &indices {
divan::black_box(array.execute_scalar(index, &mut ctx).unwrap());
}
});
}

/// Whole-array decode, which walks the upper array once and reads the low bits a FastLanes block at
/// a time rather than one element at a time.
#[divan::bench(args = CASES)]
fn decode_bulk(bencher: Bencher, case: (Shape, usize)) {
let (shape, n) = case;
let array = encoded(n, shape);
bencher
.with_inputs(|| (array.clone().into_array(), SESSION.create_execution_ctx()))
.bench_local_values(|(array, mut ctx)| {
divan::black_box(array.execute::<PrimitiveArray>(&mut ctx).unwrap());
});
}

#[divan::bench(args = CASES)]
fn encode(bencher: Bencher, case: (Shape, usize)) {
let (shape, n) = case;
let array = PrimitiveArray::from_iter(values(n, shape));
bencher
.with_inputs(|| SESSION.create_execution_ctx())
.bench_local_values(|mut ctx| {
divan::black_box(
elias_fano_encode(array.as_ref().as_::<Primitive>(), &mut ctx).unwrap(),
);
});
}

fn main() {
divan::main();
}
2 changes: 2 additions & 0 deletions encodings/elias-fano/goldenfiles/elias_fano.metadata
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

ÿÿÿÿÿÿÿÿÿ þÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿ(ÿÿÿÿÿÿÿÿÿ0ÿÿÿÿÿÿÿÿÿ
94 changes: 94 additions & 0 deletions encodings/elias-fano/src/access.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Binding the codec's random access to Vortex: describing an array's buffers as a layout,
//! supplying the low bits out of the child array, and turning elements back into scalars.

use vortex_array::ArrayRef;
use vortex_array::ArrayView;
use vortex_array::ExecutionCtx;
use vortex_array::scalar::Scalar;
use vortex_error::VortexError;
use vortex_error::VortexResult;
use vortex_error::vortex_bail;
use vortex_error::vortex_err;

use crate::EliasFano;
use crate::array::EliasFanoSlotsView;
use crate::array::scalar_from_bits;
use crate::ef;
use crate::ef::LowBits;
use crate::malformed;

/// The low-bits child, as a source the codec can pull from.
///
/// Built per call rather than held: it borrows the execution context.
pub(crate) struct LowerSource<'a, 'c> {
pub(crate) lower: &'a ArrayRef,
pub(crate) ctx: &'c mut ExecutionCtx,
}

impl LowBits for LowerSource<'_, '_> {
type Error = VortexError;

/// One low part, through the child's own `scalar_at`, so a rewritten slot needs no case here.
fn get(&mut self, rank: u64) -> VortexResult<u64> {
self.lower
.execute_scalar(usize::try_from(rank)?, self.ctx)?
.as_primitive()
.typed_value::<u64>()
.ok_or_else(|| vortex_err!("Elias-Fano low-bits child holds no value at rank {rank}"))
}
}

/// Carry a read's failure into Vortex's error type. A function rather than a `From` impl, for the
/// reason [`crate::malformed`] gives.
pub(crate) fn read_error(error: ef::ReadError<VortexError>) -> VortexError {
match error {
ef::ReadError::Malformed(error) => malformed(error),
ef::ReadError::LowBits(error) => error,
}
}

/// The layout an array describes, borrowed from its buffers.
///
/// The upper array is taken as raw bytes rather than a `BitBuffer`, which would strip alignment and
/// can reallocate to carry the three numbers the codec wants.
pub(crate) fn layout<'a>(array: ArrayView<'a, EliasFano>) -> VortexResult<ef::Layout<'a>> {
let data = array.data();
let (_, samples1) = data.sample_bytes()?;
let upper = ef::Bits::new(
data.upper_buffer().as_slice(),
0,
usize::try_from(data.upper_len())?,
);
Ok(ef::Layout::new(
upper,
samples1,
data.lower_width(),
data.first_rank(),
array.len(),
))
}

/// The value at logical `index`.
pub(crate) fn access_at(
array: ArrayView<'_, EliasFano>,
index: usize,
ctx: &mut ExecutionCtx,
) -> VortexResult<Scalar> {
let len = array.len();
if index >= len {
vortex_bail!(OutOfBounds: index, 0usize, len);
}
let reference_bits = array.data().reference_bits();
let layout = layout(array)?;

// The slots view borrows the array behind the `ArrayView`; the `lower()` accessor would borrow
// the (`Copy`, stack-local) view itself.
let lower = EliasFanoSlotsView::from_slots(array.slots()).lower;
let mut source = LowerSource { lower, ctx };

let element = ef::element_at(layout, index, &mut source).map_err(read_error)?;
scalar_from_bits(array.dtype(), reference_bits.wrapping_add(element))
}
Loading
Loading