Skip to content

Commit 8f75dbe

Browse files
feat[buffer]: specialized collect_bool per arch (#8749)
Currently LLVM cannot create a `bitcast <i1 x W> -> iW` instruction (https://llvm.org/docs/LangRef.html#bitcast-to-instruction), this may be added going forwards to support byte->bit packing case `collect_bool`. This means we must add aarch specific collect_bool impls as done here. Gives between 10 and 25x improvements Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f742e8a commit 8f75dbe

10 files changed

Lines changed: 860 additions & 29 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ compile_commands.json
224224

225225
# AI Agents
226226
.agents/settings.local.json
227+
.agents/scheduled_tasks.lock
227228
.claude/settings.local.json
228229
.opencode
229230

encodings/bytebool/src/compute.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,12 @@ impl BooleanKernel for ByteBool {
158158

159159
fn truthy_bit_buffer(array: ArrayView<'_, ByteBool>) -> BitBuffer {
160160
let bytes = array.truthy_bytes();
161-
BitBuffer::collect_bool(bytes.len(), |idx| bytes[idx] != 0)
161+
// SAFETY: `collect_bool_multiversioned` invokes the predicate with indices `0..bytes.len()` only;
162+
// the trivially cheap unchecked gather vectorizes inside the wide pack loop.
163+
BitBuffer::collect_bool_multiversioned(
164+
bytes.len(),
165+
|idx| unsafe { *bytes.get_unchecked(idx) } != 0,
166+
)
162167
}
163168

164169
#[cfg(test)]

vortex-array/src/arrays/decimal/compute/between.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,10 @@ fn between_impl<T: NativeDecimalType>(
132132
) -> ArrayRef {
133133
let buffer = arr.buffer::<T>();
134134
BoolArray::new(
135-
BitBuffer::collect_bool(buffer.len(), |idx| {
136-
let value = buffer[idx];
135+
BitBuffer::collect_bool_multiversioned(buffer.len(), |idx| {
136+
// SAFETY: `collect_bool_multiversioned` invokes the predicate with indices
137+
// `0..buffer.len()` only.
138+
let value = unsafe { *buffer.get_unchecked(idx) };
137139
lower.is_none_or(|l| lower_op(l, value)) & upper.is_none_or(|u| upper_op(value, u))
138140
}),
139141
arr.validity()

vortex-array/src/arrays/primitive/compute/between.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ where
105105
{
106106
let slice = arr.as_slice::<T>();
107107
BoolArray::new(
108-
BitBuffer::collect_bool(slice.len(), |idx| {
108+
BitBuffer::collect_bool_multiversioned(slice.len(), |idx| {
109109
// We only iterate upto arr len and |arr| == |slice|.
110110
let i = unsafe { *slice.get_unchecked(idx) };
111111
lower_fn(lower, i) & upper_fn(i, upper)

vortex-buffer/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,7 @@ harness = false
4848
[[bench]]
4949
name = "vortex_bitbuffer"
5050
harness = false
51+
52+
[[bench]]
53+
name = "collect_bool"
54+
harness = false
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Benchmarks for `collect_bool` bit packing.
5+
//!
6+
//! Three groups, each with a scalar baseline that replicates the previous bit-at-a-time
7+
//! implementation. Under CodSpeed only the shipped entry points are tracked
8+
//! (`from_bool_slice`, `collect_bool_u32_gt`); the baselines, the dispatch loop,
9+
//! per-SIMD-level loops, and the portable SWAR kernel compile out and are for local A/B runs:
10+
//!
11+
//! - `pack_words_*`: the portable SWAR kernel vs the scalar loop, word by word. The SIMD
12+
//! kernels are covered by `words_gather_*` instead, where they can inline.
13+
//! - `words_gather_*`: each multiversioned word loop with a bounds-check-free bool gather,
14+
//! measuring the fused fill + pack pipeline per SIMD level.
15+
//! - `collect_bool_*` / `from_bool_slice`: the public entry points end to end, with a
16+
//! boolean-gather predicate and a `u32` comparison predicate.
17+
18+
use divan::Bencher;
19+
use vortex_buffer::BitBuffer;
20+
#[cfg(not(codspeed))]
21+
use vortex_buffer::collect_bool_word_scalar;
22+
#[cfg(not(codspeed))]
23+
use vortex_buffer::pack_bool_word_swar;
24+
25+
fn main() {
26+
// Pre-warm CPUID feature detection so the one-time probe cost is never
27+
// included in any benchmark iteration.
28+
#[cfg(target_arch = "x86_64")]
29+
{
30+
let _ = is_x86_feature_detected!("avx2");
31+
let _ = is_x86_feature_detected!("avx512f");
32+
let _ = is_x86_feature_detected!("avx512bw");
33+
}
34+
35+
divan::main();
36+
}
37+
38+
const INPUT_SIZE: &[usize] = &[1024, 65_536];
39+
40+
/// Deterministic pseudo-random words (LCG), the source for all benchmark inputs.
41+
fn make_words(len: usize) -> impl Iterator<Item = u64> {
42+
let mut state = 0x9E37_79B9_7F4A_7C15u64;
43+
(0..len).map(move |_| {
44+
state = state
45+
.wrapping_mul(6364136223846793005)
46+
.wrapping_add(1442695040888963407);
47+
state
48+
})
49+
}
50+
51+
fn make_bools(len: usize) -> Vec<bool> {
52+
make_words(len).map(|w| (w >> 33) & 1 == 1).collect()
53+
}
54+
55+
fn make_u32s(len: usize) -> Vec<u32> {
56+
make_words(len).map(|w| (w >> 32) as u32).collect()
57+
}
58+
59+
/// Benchmark a per-word packing kernel over `len` pre-materialized bools.
60+
#[cfg(not(codspeed))]
61+
fn bench_pack_words(bencher: Bencher, len: usize, pack: impl Fn(&[bool; 64]) -> u64 + Sync) {
62+
let bools = make_bools(len);
63+
bencher
64+
.with_inputs(|| vec![0u64; len / 64])
65+
.bench_refs(|out| {
66+
let (chunks, _) = bools.as_chunks::<64>();
67+
for (word, chunk) in out.iter_mut().zip(chunks) {
68+
*word = pack(chunk);
69+
}
70+
});
71+
}
72+
73+
#[cfg(not(codspeed))]
74+
#[divan::bench(args = INPUT_SIZE)]
75+
fn pack_words_scalar(bencher: Bencher, len: usize) {
76+
bench_pack_words(bencher, len, |chunk| {
77+
collect_bool_word_scalar(64, |i| chunk[i])
78+
});
79+
}
80+
81+
#[cfg(not(codspeed))]
82+
#[divan::bench(args = INPUT_SIZE)]
83+
fn pack_words_swar(bencher: Bencher, len: usize) {
84+
bench_pack_words(bencher, len, pack_bool_word_swar);
85+
}
86+
87+
/// Benchmark a full multiversioned word loop with a bounds-check-free bool gather, measuring
88+
/// the packing pipeline itself (fill + pack fully inlined) rather than predicate evaluation.
89+
#[cfg(not(codspeed))]
90+
fn bench_words_gather(
91+
bencher: Bencher,
92+
len: usize,
93+
collect: impl Fn(&mut [u64], usize, &[bool]) + Sync,
94+
) {
95+
let bools = make_bools(len);
96+
bencher
97+
.with_inputs(|| vec![0u64; len.div_ceil(64)])
98+
.bench_refs(|words| collect(words, len, &bools));
99+
}
100+
101+
#[cfg(not(codspeed))]
102+
#[divan::bench(args = INPUT_SIZE)]
103+
fn words_gather_dispatch(bencher: Bencher, len: usize) {
104+
bench_words_gather(bencher, len, |words, len, bools| {
105+
// SAFETY: `collect_bool_words` invokes the predicate with indices `0..len` only.
106+
vortex_buffer::collect_bool_words(words, len, |i| unsafe { *bools.get_unchecked(i) })
107+
});
108+
}
109+
110+
#[cfg(not(codspeed))]
111+
#[divan::bench(args = INPUT_SIZE)]
112+
fn words_gather_scalar(bencher: Bencher, len: usize) {
113+
bench_words_gather(bencher, len, |words, len, bools| {
114+
// SAFETY: `collect_bool_words_old` invokes the predicate with indices `0..len` only.
115+
collect_bool_words_old(words, len, |i| unsafe { *bools.get_unchecked(i) })
116+
});
117+
}
118+
119+
#[cfg(target_arch = "x86_64")]
120+
#[cfg(not(codspeed))]
121+
#[divan::bench(args = INPUT_SIZE)]
122+
fn words_gather_sse2(bencher: Bencher, len: usize) {
123+
bench_words_gather(bencher, len, |words, len, bools| {
124+
// SAFETY: SSE2 is part of the x86-64 baseline; indices passed are `0..len`.
125+
unsafe { vortex_buffer::collect_bool_words_sse2(words, len, |i| *bools.get_unchecked(i)) }
126+
});
127+
}
128+
129+
#[cfg(target_arch = "x86_64")]
130+
#[cfg(not(codspeed))]
131+
#[divan::bench(args = INPUT_SIZE)]
132+
fn words_gather_avx2(bencher: Bencher, len: usize) {
133+
if !is_x86_feature_detected!("avx2") {
134+
return;
135+
}
136+
bench_words_gather(bencher, len, |words, len, bools| {
137+
// SAFETY: runtime detection guarantees AVX2; indices passed are `0..len`.
138+
unsafe { vortex_buffer::collect_bool_words_avx2(words, len, |i| *bools.get_unchecked(i)) }
139+
});
140+
}
141+
142+
#[cfg(target_arch = "x86_64")]
143+
#[cfg(not(codspeed))]
144+
#[divan::bench(args = INPUT_SIZE)]
145+
fn words_gather_avx512(bencher: Bencher, len: usize) {
146+
if !(is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw")) {
147+
return;
148+
}
149+
bench_words_gather(bencher, len, |words, len, bools| {
150+
// SAFETY: runtime detection guarantees AVX-512F/BW; indices passed are `0..len`.
151+
unsafe { vortex_buffer::collect_bool_words_avx512(words, len, |i| *bools.get_unchecked(i)) }
152+
});
153+
}
154+
155+
#[cfg(target_arch = "aarch64")]
156+
#[cfg(not(codspeed))]
157+
#[divan::bench(args = INPUT_SIZE)]
158+
fn words_gather_neon(bencher: Bencher, len: usize) {
159+
bench_words_gather(bencher, len, |words, len, bools| {
160+
// SAFETY: NEON is part of the aarch64 baseline; indices passed are `0..len`.
161+
unsafe { vortex_buffer::collect_bool_words_neon(words, len, |i| *bools.get_unchecked(i)) }
162+
});
163+
}
164+
165+
/// Faithful copy of the previous scalar-only `collect_bool_words` word loop, used as the
166+
/// baseline for the end-to-end comparison.
167+
#[cfg(not(codspeed))]
168+
fn collect_bool_words_old(words: &mut [u64], len: usize, mut f: impl FnMut(usize) -> bool) {
169+
let full = len / 64;
170+
let remainder = len % 64;
171+
for word_idx in 0..full {
172+
let offset = word_idx * 64;
173+
words[word_idx] = collect_bool_word_scalar(64, |bit_idx| f(offset + bit_idx));
174+
}
175+
if remainder != 0 {
176+
let offset = full * 64;
177+
words[full] = collect_bool_word_scalar(remainder, |bit_idx| f(offset + bit_idx));
178+
}
179+
}
180+
181+
#[cfg(not(codspeed))]
182+
#[divan::bench(args = INPUT_SIZE)]
183+
fn from_bool_slice_old_scalar(bencher: Bencher, len: usize) {
184+
let bools = make_bools(len);
185+
bencher
186+
.with_inputs(|| vec![0u64; len.div_ceil(64)])
187+
.bench_refs(|words| collect_bool_words_old(words, len, |i| bools[i]));
188+
}
189+
190+
#[divan::bench(args = INPUT_SIZE)]
191+
fn from_bool_slice(bencher: Bencher, len: usize) {
192+
let bools = make_bools(len);
193+
bencher.bench(|| vortex_buffer::BitBufferMut::from(bools.as_slice()));
194+
}
195+
196+
#[cfg(not(codspeed))]
197+
#[divan::bench(args = INPUT_SIZE)]
198+
fn collect_bool_u32_gt_old_scalar(bencher: Bencher, len: usize) {
199+
let values = make_u32s(len);
200+
bencher
201+
.with_inputs(|| vec![0u64; len.div_ceil(64)])
202+
.bench_refs(|words| collect_bool_words_old(words, len, |i| values[i] > u32::MAX / 2));
203+
}
204+
205+
#[divan::bench(args = INPUT_SIZE)]
206+
fn collect_bool_u32_gt(bencher: Bencher, len: usize) {
207+
let values = make_u32s(len);
208+
bencher.bench(|| BitBuffer::collect_bool(len, |i| values[i] > u32::MAX / 2));
209+
}

vortex-buffer/src/bit/buf.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,43 @@ impl BitBuffer {
185185
}
186186

187187
/// Invokes `f` with indexes `0..len` collecting the boolean results into a new [`BitBuffer`].
188+
///
189+
/// `f` is invoked exactly once per index, in ascending order, and the results are packed
190+
/// with the baseline SIMD byte→bit instruction of the target.
191+
///
192+
/// # Performance
193+
///
194+
/// The packing is a few instructions per 64 bits, so evaluating `f` is usually the
195+
/// bottleneck. In particular, a bounds-checked slice access in `f` (`|i| values[i] > x`)
196+
/// blocks vectorization of the gather and can cost ~10x the packing itself. Since `f` only
197+
/// ever sees indices `0..len`, callers reading from a slice with `len <= values.len()` may
198+
/// soundly use `|i| unsafe { *values.get_unchecked(i) }`.
199+
///
200+
/// Prefer this entry point for every predicate. Only switch to
201+
/// [`Self::collect_bool_multiversioned`] after carefully checking that your specific `f`
202+
/// meets its contract (a trivially cheap, bounds-check-free gather or comparison) —
203+
/// ideally with a benchmark.
188204
#[inline]
189205
pub fn collect_bool<F: FnMut(usize) -> bool>(len: usize, f: F) -> Self {
190206
BitBufferMut::collect_bool(len, f).freeze()
191207
}
192208

209+
/// Like [`Self::collect_bool`], but compiles the packing loop — with `f` inside it — once
210+
/// per CPU feature level (AVX-512BW/AVX2/baseline) and selects a clone by runtime feature
211+
/// detection.
212+
///
213+
/// Calling this asserts that `f` is small and simple enough (e.g. a bounds-check-free slice
214+
/// gather or comparison) that duplicating it per feature level and paying a
215+
/// `#[target_feature]` call boundary beats inlining it once into your function. For any
216+
/// non-trivial `f` that assertion is false — the boundary deoptimizes the predicate — so
217+
/// unless you have carefully checked (ideally benchmarked) that your specific `f`
218+
/// qualifies, use [`Self::collect_bool`]. See
219+
/// [`collect_bool_words_multiversioned`](crate::bit::collect_bool_words_multiversioned).
220+
#[inline]
221+
pub fn collect_bool_multiversioned<F: FnMut(usize) -> bool>(len: usize, f: F) -> Self {
222+
BitBufferMut::collect_bool_multiversioned(len, f).freeze()
223+
}
224+
193225
/// Maps over each bit in this buffer, calling `f(index, bit_value)` and collecting results.
194226
///
195227
/// This is more efficient than `collect_bool` when you need to read the current bit value,

0 commit comments

Comments
 (0)