Skip to content

Commit c45aa5d

Browse files
authored
Add a naive Unary encoding in a new vortex-elias-fano crate (#10609)
## Summary - Tracking Issue: #10605 This is the first step towards Elias-Fano: the `Unary` encoding, which will hold the bucket counts of EF's upper bits. It is deliberately naive. There is no index, no slice kernel and no compute kernel, so the PR is mostly boilerplate, and the benchmarks set the baselines that later work is measured against. It is in no file edition and `vortex-file` does not register it, so the default writers reject it. , so the bits can stay on a device Next up is slicing for `Unary`, then the naive `EliasFano` parent with `upper` and `lower` children. ## Changes **Layout.** One bit buffer, stored the way Bool stores its bits: a `BufferHandle` with a `BitBufferMeta` that holds the bit offset and length. Each value `v` is written as `v` set bits, then one unset bit that closes it. The array takes `len + num_ones` bits, where `num_ones` is the sum of the values. The value at index `k` is the number of set bits between the `k`-th unset bit and the one before it. For `[2, 0, 3]`, the bits are `110 0 1110`. The metadata holds two fields. `num_ones` gives the exact bit length together with the array's length. The bit offset, below 8, lets a slice share its parent's buffer. The dtype must be a non-nullable unsigned integer, and the input need not be sorted. Equality and hashing compare the buffer and the bit offset, as Bool's do. **Validation.** `validate` checks the dtype and the bit length. When the buffer is on the host, it also checks three things about the bits: - they end with an unset bit; - they hold exactly `num_ones` set bits; - each value fits the dtype. This check runs only when the values could sum past the type's maximum. Bits on a device can't be read there, so they aren't checked, as in VarBin and FSST. **As EF's upper child.** `Unary` will hold one count per bucket, so its length is the number of buckets, not the parent's length. EF will read those bits through an extension trait, the way FoR reads BitPacked. --------- Signed-off-by: rapour <[email protected]>
1 parent 8c58e9e commit c45aa5d

14 files changed

Lines changed: 1271 additions & 1 deletion

File tree

‎.github/workflows/codspeed.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
- { shard: 5, name: "Core & encodings", packages: "vortex-buffer vortex-mask vortex-compute vortex-file vortex-alp vortex-datetime-parts" }
5959
- { shard: 6, name: "Main library & storage", packages: "vortex vortex-compressor vortex-row" }
6060
- { shard: 7, name: "FastLanes & decimals", packages: "vortex-decimal-byte-parts vortex-fastlanes", features: "--features _test-harness" }
61-
- { shard: 8, name: "Encodings & layout", packages: "vortex-pco vortex-sparse vortex-layout" }
61+
- { shard: 8, name: "Encodings & layout", packages: "vortex-elias-fano vortex-pco vortex-sparse vortex-layout" }
6262
- { shard: 9, name: "Strings, tensor & spatial", packages: "vortex-fsst vortex-tensor vortex-spatial", features: "--features _test-harness" }
6363
name: "Benchmark with Codspeed (Shard #${{ matrix.shard }} - ${{ matrix.name }})"
6464
timeout-minutes: 30

‎Cargo.lock‎

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

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ members = [
6262
"encodings/parquet-variant",
6363
"encodings/onpair",
6464
"encodings/uuid",
65+
"encodings/elias-fano",
6566
# Benchmarks
6667
"benchmarks/bench-support",
6768
"benchmarks/lance-bench",
@@ -315,6 +316,7 @@ vortex-datafusion = { version = "0.1.0", path = "./vortex-datafusion", default-f
315316
vortex-datetime-parts = { version = "0.1.0", path = "./encodings/datetime-parts", default-features = false }
316317
vortex-decimal-byte-parts = { version = "0.1.0", path = "encodings/decimal-byte-parts", default-features = false }
317318
vortex-edition = { version = "0.1.0", path = "./vortex-edition", default-features = false }
319+
vortex-elias-fano = { version = "0.1.0", path = "./encodings/elias-fano", default-features = false }
318320
vortex-error = { version = "0.1.0", path = "./vortex-error", default-features = false }
319321
vortex-expr = { version = "0.1.0", path = "./vortex-expr", default-features = false }
320322
vortex-fastlanes = { version = "0.1.0", path = "./encodings/fastlanes", default-features = false }

‎encodings/elias-fano/Cargo.toml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "vortex-elias-fano"
3+
authors = { workspace = true }
4+
categories = { workspace = true }
5+
description = "Vortex Elias-Fano and unary integer encodings"
6+
edition = { workspace = true }
7+
include = { workspace = true }
8+
keywords = { workspace = true }
9+
license = { workspace = true }
10+
readme = { workspace = true }
11+
repository = { workspace = true }
12+
rust-version = { workspace = true }
13+
version = { workspace = true }
14+
15+
[dependencies]
16+
num-traits = { workspace = true }
17+
prost = { workspace = true }
18+
vortex-array = { workspace = true }
19+
vortex-buffer = { workspace = true }
20+
vortex-error = { workspace = true }
21+
vortex-session = { workspace = true }
22+
23+
[dev-dependencies]
24+
divan = { workspace = true }
25+
mimalloc = { workspace = true }
26+
rand = { workspace = true }
27+
rstest = { workspace = true }
28+
vortex-array = { workspace = true, features = ["_test-harness"] }
29+
30+
[[bench]]
31+
name = "unary"
32+
harness = false
33+
34+
[lints]
35+
workspace = true
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Baselines for the naive `Unary` encoding.
5+
//!
6+
//! Later work on `Unary`, such as an index, a slice kernel, or a cursor, will be measured against
7+
//! these numbers. CodSpeed matches results by name, so keep the names, shapes, and sizes stable.
8+
//!
9+
//! Each input is the array that an Elias-Fano upper child holds for `LEN` sorted values.
10+
//! Elias-Fano puts each value in a _bucket_ by its high bits, and the upper child stores one count
11+
//! per bucket. The array under test therefore has one element per bucket, and its elements sum to
12+
//! `LEN`. The fixtures use buckets that are one value wide:
13+
//!
14+
//! - `Dense`: values in `0..LEN`, so `LEN` buckets with about one value each.
15+
//! - `Spread`: values in `0..2 * LEN`, so `2 * LEN` buckets with about half a value each.
16+
//! - `Runs`: every count is 32, which is the duplicate-heavy case with fewer buckets than values.
17+
//! - `Gap`: two dense halves with `LEN / 2` empty buckets between them.
18+
//!
19+
//! Elias-Fano chooses its low bits so that there are between `LEN` and `2 * LEN` buckets, and
20+
//! `Dense` and `Spread` are the two ends of that range. Encoding and decoding cost about one step
21+
//! per bucket, so `Spread` decides how large `LEN` can be within the 1 ms per-iteration budget.
22+
23+
#![expect(clippy::unwrap_used)]
24+
25+
use std::sync::LazyLock;
26+
27+
use divan::Bencher;
28+
use mimalloc::MiMalloc;
29+
use rand::RngExt;
30+
use rand::SeedableRng;
31+
use rand::rngs::StdRng;
32+
use vortex_array::ArrayRef;
33+
use vortex_array::IntoArray;
34+
use vortex_array::VortexSessionExecute;
35+
use vortex_array::arrays::Primitive;
36+
use vortex_array::arrays::PrimitiveArray;
37+
use vortex_array::validity::Validity;
38+
use vortex_buffer::Buffer;
39+
use vortex_elias_fano::unary_encode;
40+
use vortex_session::VortexSession;
41+
42+
#[global_allocator]
43+
static GLOBAL: MiMalloc = MiMalloc;
44+
45+
static SESSION: LazyLock<VortexSession> = LazyLock::new(|| {
46+
let session = VortexSession::empty();
47+
vortex_elias_fano::initialize(&session);
48+
session
49+
});
50+
51+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52+
enum Shape {
53+
Dense,
54+
Spread,
55+
Runs,
56+
Gap,
57+
}
58+
59+
const SHAPES: &[Shape] = &[Shape::Dense, Shape::Spread, Shape::Runs, Shape::Gap];
60+
61+
/// Sorted values behind each shape. Encoding and decoding cost about one step per bucket, and
62+
/// `Spread`, the largest shape, has `2 * LEN` buckets, so this keeps every iteration inside the
63+
/// 1 ms per-iteration budget.
64+
const LEN: usize = 1 << 14;
65+
66+
/// Where the second half of `Gap` starts, in values.
67+
const GAP: usize = LEN;
68+
69+
/// Rows per value in `Runs`. At 64, each count would take 65 bits, past the size rule.
70+
const ROWS_PER_VALUE: usize = 32;
71+
72+
/// Probes per iteration, as in `vortex-array`'s probe benchmark. Each is a linear scan from the
73+
/// first bit, so 256 keep an iteration inside the budget.
74+
const PROBES: usize = 256;
75+
76+
/// How many of `values` fall in each of `buckets` buckets, one value wide.
77+
fn bucket_counts(values: impl Iterator<Item = usize>, buckets: usize) -> Vec<u64> {
78+
let mut counts = vec![0; buckets];
79+
for value in values {
80+
counts[value] += 1;
81+
}
82+
counts
83+
}
84+
85+
fn counts(shape: Shape) -> Vec<u64> {
86+
let mut rng = StdRng::seed_from_u64(0);
87+
match shape {
88+
Shape::Dense => bucket_counts((0..LEN).map(|_| rng.random_range(0..LEN)), LEN),
89+
Shape::Spread => bucket_counts((0..LEN).map(|_| rng.random_range(0..2 * LEN)), 2 * LEN),
90+
Shape::Runs => bucket_counts(
91+
(0..LEN).map(|row| row / ROWS_PER_VALUE),
92+
LEN / ROWS_PER_VALUE,
93+
),
94+
Shape::Gap => bucket_counts(
95+
(0..LEN).map(|row| {
96+
let offset = if row < LEN / 2 { 0 } else { GAP };
97+
offset + rng.random_range(0..LEN / 2)
98+
}),
99+
GAP + LEN / 2,
100+
),
101+
}
102+
}
103+
104+
fn primitive(shape: Shape) -> PrimitiveArray {
105+
PrimitiveArray::from_iter(counts(shape))
106+
}
107+
108+
fn encoded(shape: Shape) -> ArrayRef {
109+
unary_encode(
110+
primitive(shape).as_ref().as_::<Primitive>(),
111+
&mut SESSION.create_execution_ctx(),
112+
)
113+
.unwrap()
114+
.unwrap()
115+
.into_array()
116+
}
117+
118+
#[divan::bench(args = SHAPES)]
119+
fn encode(bencher: Bencher, shape: Shape) {
120+
let values = Buffer::from(counts(shape));
121+
// A fresh array each time, so `sum` cannot reuse the statistic cached on the last one.
122+
bencher
123+
.with_inputs(|| {
124+
(
125+
PrimitiveArray::new(values.clone(), Validity::NonNullable),
126+
SESSION.create_execution_ctx(),
127+
)
128+
})
129+
.bench_local_values(|(array, mut ctx)| {
130+
divan::black_box(unary_encode(array.as_ref().as_::<Primitive>(), &mut ctx).unwrap());
131+
});
132+
}
133+
134+
/// The whole array, in one walk over the unset bits.
135+
#[divan::bench(args = SHAPES)]
136+
fn decode(bencher: Bencher, shape: Shape) {
137+
let array = encoded(shape);
138+
bencher
139+
.with_inputs(|| (array.clone(), SESSION.create_execution_ctx()))
140+
.bench_local_values(|(array, mut ctx)| {
141+
divan::black_box(array.execute::<PrimitiveArray>(&mut ctx).unwrap());
142+
});
143+
}
144+
145+
/// Point reads in a random order, each a scan from the first bit.
146+
#[divan::bench(args = SHAPES)]
147+
fn scalar_at_random(bencher: Bencher, shape: Shape) {
148+
let array = encoded(shape);
149+
let mut rng = StdRng::seed_from_u64(1);
150+
let indices: Vec<usize> = (0..PROBES)
151+
.map(|_| rng.random_range(0..array.len()))
152+
.collect();
153+
bencher
154+
.with_inputs(|| SESSION.create_execution_ctx())
155+
.bench_local_values(|mut ctx| {
156+
for &index in &indices {
157+
divan::black_box(array.execute_scalar(index, &mut ctx).unwrap());
158+
}
159+
});
160+
}
161+
162+
/// Consecutive point reads from the middle through one repeated probe, so a cursor kept in its
163+
/// state could answer each from the last position.
164+
#[divan::bench(args = SHAPES)]
165+
fn scalar_at_sequential(bencher: Bencher, shape: Shape) {
166+
let array = encoded(shape);
167+
let start = array.len() / 2;
168+
bencher
169+
.with_inputs(|| (SESSION.create_execution_ctx(), array.repeated_probe()))
170+
.bench_local_refs(|(ctx, probe)| {
171+
for index in start..start + PROBES {
172+
divan::black_box(probe.execute_scalar(index, ctx).unwrap());
173+
}
174+
});
175+
}
176+
177+
/// The middle half, through the default slice path, which decodes the whole array first.
178+
#[divan::bench(args = SHAPES)]
179+
fn slice_decode(bencher: Bencher, shape: Shape) {
180+
let array = encoded(shape);
181+
let len = array.len();
182+
bencher
183+
.with_inputs(|| (array.clone(), SESSION.create_execution_ctx()))
184+
.bench_local_values(|(array, mut ctx)| {
185+
let sliced = array.slice(len / 4..3 * len / 4).unwrap();
186+
divan::black_box(sliced.execute::<PrimitiveArray>(&mut ctx).unwrap());
187+
});
188+
}
189+
190+
fn main() {
191+
divan::main();
192+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
�������������

‎encodings/elias-fano/src/lib.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Elias-Fano and unary encodings for integers.
5+
//!
6+
//! [`Unary`] stores a non-nullable unsigned column as one bit array, writing each value in unary.
7+
//! It takes `len + num_ones` bits, where `num_ones` is the sum of the values, so it suits small
8+
//! values in any order. It is also meant to hold the bucket counts of the high bits of the
9+
//! Elias-Fano encoding this crate is being built towards.
10+
//!
11+
//! None of these encodings is in a file edition or registered by `vortex-file`, so the default
12+
//! writers reject them for now. [`initialize`] registers them in a session.
13+
#![deny(missing_docs)]
14+
15+
mod unary;
16+
17+
pub use unary::Unary;
18+
pub use unary::UnaryArray;
19+
pub use unary::UnaryData;
20+
pub use unary::unary_encode;
21+
use vortex_array::session::ArraySessionExt;
22+
use vortex_session::VortexSession;
23+
24+
/// Register this crate's encodings in `session`.
25+
pub fn initialize(session: &VortexSession) {
26+
session.arrays().register(Unary);
27+
}

0 commit comments

Comments
 (0)