Skip to content

Commit e15657e

Browse files
committed
perf(mask): fuse true_count into boolean operators
`&`, `|`, `bitand_not` and `!` re-scanned the result with `true_count()` after building it. they now combine and count in a single pass; `!` reuses the cached count. Closes #1508 Signed-off-by: Han Damin <miniex@daminstudio.net>
1 parent 9b5447a commit e15657e

6 files changed

Lines changed: 307 additions & 12 deletions

File tree

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 = "bitbuffer_count"
54+
harness = false
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Benchmarks comparing strategies for computing a bitwise-op result *and* its `true_count`:
5+
//!
6+
//! * `*_two_pass`: current behaviour, materialise the result buffer then run the (vectorised)
7+
//! `count_ones` kernel as a separate pass (`&a & &b` followed by `.true_count()`).
8+
//! * `*_fused`: single pass that combines the words and accumulates `count_ones` together
9+
//! (`BitBuffer::bitand_with_true_count`).
10+
11+
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
12+
13+
use divan::Bencher;
14+
use divan::black_box;
15+
use vortex_buffer::BitBuffer;
16+
17+
fn main() {
18+
divan::main();
19+
}
20+
21+
// Lengths in bits, from L1-resident up to L2/L3.
22+
const SIZES: &[usize] = &[
23+
1_024, // 128 B
24+
8_192, // 1 KB
25+
65_536, // 8 KB
26+
524_288, // 64 KB
27+
];
28+
29+
fn inputs(len: usize) -> (BitBuffer, BitBuffer) {
30+
let lhs =
31+
BitBuffer::from_iter((0..len).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) & 1 == 0));
32+
let rhs = BitBuffer::from_iter((0..len).map(|i| (i.wrapping_mul(40_503) >> 7) & 1 == 0));
33+
(lhs, rhs)
34+
}
35+
36+
#[divan::bench(args = SIZES)]
37+
fn and_two_pass(bencher: Bencher, len: usize) {
38+
let (lhs, rhs) = inputs(len);
39+
bencher.bench_local(|| {
40+
let result = black_box(&lhs) & black_box(&rhs);
41+
let count = result.true_count();
42+
(result, count)
43+
});
44+
}
45+
46+
#[divan::bench(args = SIZES)]
47+
fn and_fused(bencher: Bencher, len: usize) {
48+
let (lhs, rhs) = inputs(len);
49+
bencher.bench_local(|| black_box(&lhs).bitand_with_true_count(black_box(&rhs)));
50+
}
51+
52+
// Owned-LHS scenario: the current code can combine in-place (no allocation) then count
53+
// separately; the fused path always allocates but does a single pass. A fresh owned, uniquely
54+
// owned LHS is cloned per iteration so the in-place fast path is actually taken.
55+
#[divan::bench(args = SIZES)]
56+
fn and_owned_two_pass(bencher: Bencher, len: usize) {
57+
let (lhs, rhs) = inputs(len);
58+
bencher
59+
.with_inputs(|| lhs.clone())
60+
.bench_local_values(|lhs| {
61+
let result = lhs & black_box(&rhs);
62+
let count = result.true_count();
63+
(result, count)
64+
});
65+
}
66+
67+
#[divan::bench(args = SIZES)]
68+
fn and_owned_fused(bencher: Bencher, len: usize) {
69+
let (lhs, rhs) = inputs(len);
70+
bencher
71+
.with_inputs(|| lhs.clone())
72+
.bench_local_values(|lhs| lhs.bitand_with_true_count(black_box(&rhs)));
73+
}

vortex-buffer/src/bit/buf.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::bit::collect_bool_word;
2525
use crate::bit::count_ones::count_ones;
2626
use crate::bit::get_bit_unchecked;
2727
use crate::bit::ops::bitwise_binary_op;
28+
use crate::bit::ops::bitwise_binary_op_counted;
2829
use crate::bit::ops::bitwise_binary_op_lhs_owned;
2930
use crate::bit::ops::bitwise_unary_op;
3031
use crate::bit::ops::bitwise_unary_op_copy;
@@ -541,6 +542,21 @@ impl BitBuffer {
541542
bitwise_binary_op_lhs_owned(self, rhs, |a, b| a & !b)
542543
}
543544

545+
/// Bitwise AND of two buffers, returning the result and its true count from a single pass.
546+
pub fn bitand_with_true_count(&self, rhs: &BitBuffer) -> (BitBuffer, usize) {
547+
bitwise_binary_op_counted(self, rhs, |a, b| a & b)
548+
}
549+
550+
/// Bitwise OR of two buffers, returning the result and its true count from a single pass.
551+
pub fn bitor_with_true_count(&self, rhs: &BitBuffer) -> (BitBuffer, usize) {
552+
bitwise_binary_op_counted(self, rhs, |a, b| a | b)
553+
}
554+
555+
/// Bitwise AND NOT (`self & !rhs`), returning the result and its true count from a single pass.
556+
pub fn bitand_not_with_true_count(&self, rhs: &BitBuffer) -> (BitBuffer, usize) {
557+
bitwise_binary_op_counted(self, rhs, |a, b| a & !b)
558+
}
559+
544560
/// Iterate through bits in a buffer.
545561
///
546562
/// # Arguments

vortex-buffer/src/bit/ops.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,113 @@ pub(super) fn bitwise_binary_op<F: FnMut(u64, u64) -> u64>(
235235
BitBuffer::new(bytes.freeze(), len)
236236
}
237237

238+
/// Combine `left` and `right` word-by-word with `op`, calling `comb` on each result word so a
239+
/// caller can fold a reduction (e.g. `count_ones`) into the same pass. The word given to `comb`
240+
/// has bits past `len` cleared; the buffer keeps `op`'s raw output in those positions.
241+
pub(super) fn bitwise_binary_op_with<F, C>(
242+
left: &BitBuffer,
243+
right: &BitBuffer,
244+
op: F,
245+
comb: C,
246+
) -> BitBuffer
247+
where
248+
F: FnMut(u64, u64) -> u64,
249+
C: FnMut(u64),
250+
{
251+
assert_eq!(left.len(), right.len());
252+
let len = left.len();
253+
if len == 0 {
254+
return BitBuffer::empty();
255+
}
256+
257+
// Byte-aligned operands: logical bits map onto physical `u64` words, so we can read the backing
258+
// bytes straight as words instead of shifting through `iter_padded`.
259+
if left.offset().is_multiple_of(8) && right.offset().is_multiple_of(8) {
260+
let n_bytes = len.div_ceil(8);
261+
let l_start = left.offset() / 8;
262+
let r_start = right.offset() / 8;
263+
let lhs = &left.inner().as_slice()[l_start..l_start + n_bytes];
264+
let rhs = &right.inner().as_slice()[r_start..r_start + n_bytes];
265+
266+
let (lhs_words, lhs_tail) = lhs.as_chunks::<8>();
267+
let (rhs_words, rhs_tail) = rhs.as_chunks::<8>();
268+
269+
let words = lhs_words
270+
.iter()
271+
.zip(rhs_words)
272+
.map(|(l, r)| (u64::from_le_bytes(*l), u64::from_le_bytes(*r)))
273+
.chain((!lhs_tail.is_empty()).then(|| (read_u64_le(lhs_tail), read_u64_le(rhs_tail))));
274+
return combine_words(words, len, op, comb);
275+
}
276+
277+
// Sub-byte offset: `iter_padded` realigns the bits and zero-pads the final word.
278+
let words = left
279+
.chunks()
280+
.iter_padded()
281+
.zip(right.chunks().iter_padded());
282+
combine_words(words, len, op, comb)
283+
}
284+
285+
/// Combine an iterator of `(left, right)` words via `op` into a `len`-bit [`BitBuffer`], folding
286+
/// `comb` over each in-range result word. Consumes exactly `ceil(len / 64)` words (any extra the
287+
/// producer yields, e.g. `iter_padded`'s trailing pad word, is dropped). Only the final word can
288+
/// hold bits past `len`, so it alone is masked for `comb`; the buffer keeps `op`'s raw output.
289+
#[inline]
290+
fn combine_words<I, F, C>(words: I, len: usize, mut op: F, mut comb: C) -> BitBuffer
291+
where
292+
I: Iterator<Item = (u64, u64)>,
293+
F: FnMut(u64, u64) -> u64,
294+
C: FnMut(u64),
295+
{
296+
let n_words = len.div_ceil(64);
297+
let mut out: BufferMut<u64> = BufferMut::with_capacity(n_words);
298+
let mut words = words.take(n_words);
299+
300+
// Words fully within `len` need no masking; handling the final word separately keeps this loop
301+
// uniform, with no per-word branch.
302+
for (l, r) in words.by_ref().take(n_words - 1) {
303+
let word = op(l, r);
304+
comb(word);
305+
out.push(word);
306+
}
307+
// Final word: clear bits past `len` for `comb` only.
308+
if let Some((l, r)) = words.next() {
309+
let word = op(l, r);
310+
comb(word & last_word_mask(len));
311+
out.push(word);
312+
}
313+
314+
let mut bytes = out.into_byte_buffer();
315+
bytes.truncate(len.div_ceil(8));
316+
BitBuffer::new(bytes.freeze(), len)
317+
}
318+
319+
/// Mask of the in-range bits in the final word: the low `len % 64` bits, or all 64 when `len` is
320+
/// a multiple of 64.
321+
#[inline]
322+
fn last_word_mask(len: usize) -> u64 {
323+
let rem = len % 64;
324+
if rem == 0 {
325+
u64::MAX
326+
} else {
327+
(1u64 << rem) - 1
328+
}
329+
}
330+
331+
/// Like [`bitwise_binary_op`], but also returns the result's set-bit count, folded into the same
332+
/// pass instead of re-scanning the buffer.
333+
pub(super) fn bitwise_binary_op_counted<F: FnMut(u64, u64) -> u64>(
334+
left: &BitBuffer,
335+
right: &BitBuffer,
336+
op: F,
337+
) -> (BitBuffer, usize) {
338+
let mut count = 0usize;
339+
let buffer = bitwise_binary_op_with(left, right, op, |word| {
340+
count += word.count_ones() as usize;
341+
});
342+
(buffer, count)
343+
}
344+
238345
#[cfg(test)]
239346
mod tests {
240347
use std::ops::Not;
@@ -367,6 +474,44 @@ mod tests {
367474
}
368475
}
369476

477+
/// The fused path must yield the same buffer and true count as the reference
478+
/// (`bitwise_binary_op` + a separate `true_count`), across all ops, offsets, and lengths,
479+
/// including lengths like 127 whose final word holds out-of-range bits.
480+
#[rstest]
481+
#[case::aligned(0, 0)]
482+
#[case::equal_nonzero(3, 3)]
483+
#[case::byte_aligned(8, 16)]
484+
#[case::mismatch(0, 5)]
485+
fn counted_matches_reference(#[case] left_offset: usize, #[case] right_offset: usize) {
486+
#[allow(clippy::cast_possible_truncation)]
487+
let make = |offset: usize, len: usize, salt: u8| -> BitBuffer {
488+
let bytes: ByteBufferMut = (0..(offset + len).div_ceil(8).max(1))
489+
.map(|i| (i as u8).wrapping_mul(31).wrapping_add(salt))
490+
.collect();
491+
BitBufferMut::from_buffer(bytes, offset, len).freeze()
492+
};
493+
let ops: [fn(u64, u64) -> u64; 4] =
494+
[|a, b| a & b, |a, b| a | b, |a, b| a ^ b, |a, b| a & !b];
495+
496+
for len in [1usize, 5, 8, 63, 64, 65, 127, 128, 129, 200, 256] {
497+
let left = make(left_offset, len, 0xC3);
498+
let right = make(right_offset, len, 0x5A);
499+
for op in ops {
500+
let (got_buf, got_count) = bitwise_binary_op_counted(&left, &right, op);
501+
let expected_buf = bitwise_binary_op(&left, &right, op);
502+
assert_eq!(
503+
got_buf, expected_buf,
504+
"buffer mismatch loff={left_offset} roff={right_offset} len={len}"
505+
);
506+
assert_eq!(
507+
got_count,
508+
expected_buf.true_count(),
509+
"count mismatch loff={left_offset} roff={right_offset} len={len}"
510+
);
511+
}
512+
}
513+
}
514+
370515
/// Regression test for a bug where [`bitwise_unary_op`] produced corrupt results when
371516
/// the [`BitBuffer`]'s underlying byte pointer was not u64-aligned. Slicing a buffer by
372517
/// a non-multiple-of-8 number of bytes can cause this misalignment. The bug only

0 commit comments

Comments
 (0)