Skip to main content

vortex_buffer/bit/
pack.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Kernels for packing boolean values into bitmap words.
5//!
6//! `collect_bool` materializes each full 64-bit chunk as a `[bool; 64]` (a loop the
7//! auto-vectorizer turns into vector stores for simple predicates) and then packs the 64 bytes
8//! into a single `u64` with a byte→bit kernel:
9//!
10//! - x86-64 AVX-512BW: one `vptestmb` produces the full 64-bit mask.
11//! - x86-64 AVX2: two `vpmovmskb` halves.
12//! - x86-64 SSE2 (baseline): four `pmovmskb` quarters.
13//! - aarch64 NEON (baseline): per-lane `ushl` by the bit position, then an `addp` reduction tree.
14//! - elsewhere (and under Miri): a branch-free SWAR multiply.
15//!
16//! There are two tiers. The default ([`collect_bool_words_inline`]) compiles the loop once
17//! with the widest *statically-enabled* kernel (SSE2 / NEON / SWAR on stock targets) and
18//! inlines fully into the caller — safe for arbitrary predicates. The opt-in tier
19//! ([`collect_bool_words_multiversioned`]) compiles the loop — with `f` inside — once per CPU
20//! feature level and selects a clone by runtime detection; only for predicates small and
21//! simple enough that the per-level duplication and its `#[target_feature]` call boundary pay
22//! off.
23//!
24//! The bit-at-a-time loop lives on as [`collect_bool_word_scalar`], used for tail chunks and as
25//! the reference implementation for tests and benchmarks.
26
27/// Packs up to 64 boolean values into a little-endian `u64` word one bit at a time.
28///
29/// This is the scalar reference implementation behind
30/// [`collect_bool_word`](crate::bit::collect_bool_word); prefer calling that entry point, which
31/// takes the SIMD fast path for full 64-bit words.
32#[inline]
33pub fn collect_bool_word_scalar<F>(len: usize, mut f: F) -> u64
34where
35    F: FnMut(usize) -> bool,
36{
37    assert!(len <= 64, "cannot pack {len} bits into a u64 word");
38
39    let mut packed = 0;
40    for bit_idx in 0..len {
41        packed |= (f(bit_idx) as u64) << bit_idx;
42    }
43    packed
44}
45
46/// Body of [`collect_bool_words`](crate::bit::collect_bool_words) (and, via a one-word slice,
47/// of [`collect_bool_word`](crate::bit::collect_bool_word)): the word loop with the widest
48/// pack kernel enabled *at compile time* — SSE2 on stock x86-64 (AVX2/AVX-512BW when built
49/// with e.g. `-C target-cpu=native`), NEON on aarch64, SWAR elsewhere and under Miri.
50///
51/// Statically-enabled kernels are part of every function's feature set, so this loop
52/// (predicate, the `[bool; 64]` materialization, and the pack) inlines fully into the caller
53/// with no `#[target_feature]` boundary. That boundary is why *runtime*-detected wider kernels
54/// are not used here: hiding an expensive, non-vectorizable predicate (e.g. FSST's per-string
55/// DFA scan) behind a non-inlinable AVX-512 loop copy costs far more (~30% end to end) than
56/// the wider pack saves — and an indirect call per word is worse still (~4x on cheap
57/// predicates), since an opaque call target blocks fill/pack fusion regardless of how cheap
58/// the kernel *selection* is. For provably cheap predicates, use [`collect_bool_words_multiversioned`].
59#[allow(clippy::inline_always)]
60#[inline(always)]
61pub(crate) fn collect_bool_words_inline<F>(words: &mut [u64], len: usize, f: F)
62where
63    F: FnMut(usize) -> bool,
64{
65    #[cfg(all(
66        target_arch = "x86_64",
67        target_feature = "avx512f",
68        target_feature = "avx512bw",
69        not(miri)
70    ))]
71    {
72        // SAFETY: AVX-512F/BW are statically enabled for this build (e.g. -C
73        // target-cpu=native), so they are in every function's feature set and the kernel
74        // inlines here like any other function.
75        collect_bool_words_with(words, len, f, |bools| unsafe {
76            pack_bool_word_avx512(bools)
77        })
78    }
79    #[cfg(all(
80        target_arch = "x86_64",
81        target_feature = "avx2",
82        not(all(target_feature = "avx512f", target_feature = "avx512bw")),
83        not(miri)
84    ))]
85    {
86        // SAFETY: AVX2 is statically enabled for this build.
87        collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_avx2(bools) })
88    }
89    #[cfg(all(target_arch = "x86_64", not(target_feature = "avx2"), not(miri)))]
90    {
91        // SAFETY: SSE2 is part of the x86-64 baseline instruction set.
92        collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_sse2(bools) })
93    }
94    #[cfg(all(target_arch = "aarch64", not(miri)))]
95    {
96        // SAFETY: NEON is part of the aarch64 baseline instruction set.
97        collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_neon(bools) })
98    }
99    #[cfg(any(not(any(target_arch = "x86_64", target_arch = "aarch64")), miri))]
100    collect_bool_words_with(words, len, f, pack_bool_word_swar)
101}
102
103/// Word loop with the *widest* pack kernel the CPU offers (AVX-512BW, then AVX2, then the
104/// baseline), for predicates known to be cheap.
105///
106/// The wide loop copies live behind a `#[target_feature]` function boundary that cannot inline
107/// into the caller, which deoptimizes expensive predicates (see the module docs and
108/// `collect_bool_words_inline`). Only route a predicate here when its evaluation is trivial
109/// — e.g. the bounds-check-free slice gathers in the `From<&[bool]>` / `From<&[u8]>`
110/// conversions, or unchecked slice comparisons like the `between` kernels — where the fused
111/// AVX-512 loop is worth another ~2x over the baseline kernel.
112///
113/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`,
114/// exactly once per index in ascending order.
115///
116/// Panics if `words` is too short.
117#[inline]
118pub fn collect_bool_words_multiversioned<F>(words: &mut [u64], len: usize, f: F)
119where
120    F: FnMut(usize) -> bool,
121{
122    let num_words = len.div_ceil(64);
123    assert!(
124        words.len() >= num_words,
125        "words slice has {} entries, need at least {num_words}",
126        words.len(),
127    );
128
129    // Without a full 64-bit word only the scalar tail would run; skip feature detection and
130    // the `#[target_feature]` call boundary entirely.
131    if len < 64 {
132        return collect_bool_words_inline(words, len, f);
133    }
134
135    #[cfg(all(target_arch = "x86_64", not(miri)))]
136    {
137        if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw") {
138            // SAFETY: runtime detection guarantees the required target features.
139            return unsafe { collect_bool_words_avx512(words, len, f) };
140        }
141        if is_x86_feature_detected!("avx2") {
142            // SAFETY: runtime detection guarantees the required target features.
143            return unsafe { collect_bool_words_avx2(words, len, f) };
144        }
145    }
146    collect_bool_words_inline(words, len, f)
147}
148
149/// Shared word loop: materialize each full 64-bit chunk as a `[bool; 64]` and pack it with
150/// `pack`; the tail chunk goes through the scalar loop.
151///
152/// Marked `#[inline(always)]` so each `#[target_feature]` wrapper gets its own fully-inlined
153/// copy compiled with that feature set.
154#[allow(clippy::inline_always)]
155#[inline(always)]
156fn collect_bool_words_with<F, P>(words: &mut [u64], len: usize, mut f: F, pack: P)
157where
158    F: FnMut(usize) -> bool,
159    P: Fn(&[bool; 64]) -> u64,
160{
161    let full = len / 64;
162    let remainder = len % 64;
163
164    for (word_idx, word) in words[..full].iter_mut().enumerate() {
165        let offset = word_idx * 64;
166        let mut bools = [false; 64];
167        for (bit_idx, b) in bools.iter_mut().enumerate() {
168            *b = f(offset + bit_idx);
169        }
170        *word = pack(&bools);
171    }
172
173    if remainder != 0 {
174        let offset = full * 64;
175        words[full] = collect_bool_word_scalar(remainder, |bit_idx| f(offset + bit_idx));
176    }
177}
178
179/// SSE2 copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop.
180///
181/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`.
182///
183/// # Safety
184///
185/// The caller must ensure the CPU supports SSE2 (always true on x86-64).
186#[cfg(target_arch = "x86_64")]
187#[target_feature(enable = "sse2")]
188pub unsafe fn collect_bool_words_sse2<F: FnMut(usize) -> bool>(
189    words: &mut [u64],
190    len: usize,
191    f: F,
192) {
193    // SAFETY: the caller guarantees SSE2 support.
194    collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_sse2(bools) })
195}
196
197/// AVX2 copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop.
198///
199/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`.
200///
201/// # Safety
202///
203/// The caller must ensure the CPU supports AVX2.
204#[cfg(target_arch = "x86_64")]
205#[target_feature(enable = "avx2")]
206pub unsafe fn collect_bool_words_avx2<F: FnMut(usize) -> bool>(
207    words: &mut [u64],
208    len: usize,
209    f: F,
210) {
211    // SAFETY: the caller guarantees AVX2 support.
212    collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_avx2(bools) })
213}
214
215/// AVX-512BW copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop.
216///
217/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`.
218///
219/// # Safety
220///
221/// The caller must ensure the CPU supports AVX-512F and AVX-512BW.
222#[cfg(target_arch = "x86_64")]
223#[target_feature(enable = "avx512f,avx512bw")]
224pub unsafe fn collect_bool_words_avx512<F: FnMut(usize) -> bool>(
225    words: &mut [u64],
226    len: usize,
227    f: F,
228) {
229    // SAFETY: the caller guarantees AVX-512F and AVX-512BW support.
230    collect_bool_words_with(words, len, f, |bools| unsafe {
231        pack_bool_word_avx512(bools)
232    })
233}
234
235/// NEON copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop.
236///
237/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`.
238///
239/// # Safety
240///
241/// The caller must ensure the CPU supports NEON (always true on aarch64).
242#[cfg(target_arch = "aarch64")]
243#[target_feature(enable = "neon")]
244pub unsafe fn collect_bool_words_neon<F: FnMut(usize) -> bool>(
245    words: &mut [u64],
246    len: usize,
247    f: F,
248) {
249    // SAFETY: the caller guarantees NEON support.
250    collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_neon(bools) })
251}
252
253/// Portable branch-free byte→bit pack kernel, used when no SIMD kernel is available.
254///
255/// Reads the bools eight at a time as a `u64` and gathers the eight `0x00`/`0x01` bytes into
256/// eight contiguous bits with a single multiply: byte `i` contributes `2^(8i)`, and the magic
257/// constant `0x0102_0408_1020_4080 = Σ 2^(56-7i)` shifts each contribution to bit `56 + i`
258/// without any cross-term collisions, so the mask falls out of the top byte of the product.
259#[inline]
260pub fn pack_bool_word_swar(bools: &[bool; 64]) -> u64 {
261    const MAGIC: u64 = 0x0102_0408_1020_4080;
262
263    let (chunks, rest) = bools.as_chunks::<8>();
264    debug_assert!(rest.is_empty());
265
266    let mut packed = 0u64;
267    for (chunk_idx, chunk) in chunks.iter().enumerate() {
268        let word = u64::from_le_bytes(chunk.map(|b| b as u8));
269        packed |= (word.wrapping_mul(MAGIC) >> 56) << (8 * chunk_idx);
270    }
271    packed
272}
273
274/// SSE2 byte→bit pack kernel: four 16-byte `pcmpeqb`-against-zero + `pmovmskb` rounds.
275///
276/// # Safety
277///
278/// The caller must ensure the CPU supports SSE2 (always true on x86-64).
279#[cfg(target_arch = "x86_64")]
280#[inline]
281#[target_feature(enable = "sse2")]
282pub unsafe fn pack_bool_word_sse2(bools: &[bool; 64]) -> u64 {
283    use std::arch::x86_64::__m128i;
284    use std::arch::x86_64::_mm_cmpeq_epi8;
285    use std::arch::x86_64::_mm_loadu_si128;
286    use std::arch::x86_64::_mm_movemask_epi8;
287    use std::arch::x86_64::_mm_setzero_si128;
288
289    let ptr = bools.as_ptr().cast::<u8>();
290    let zero = _mm_setzero_si128();
291
292    let mut packed = 0u64;
293    for lane in 0..4 {
294        // SAFETY: `lane * 16 + 16 <= 64`, so the 16-byte load is in bounds.
295        let chunk = unsafe { _mm_loadu_si128(ptr.add(lane * 16).cast::<__m128i>()) };
296        // `cmpeq` against zero sets 0xFF for *false* bytes; invert to get the truthy mask.
297        let zero_mask = _mm_movemask_epi8(_mm_cmpeq_epi8(chunk, zero)) as u32 as u64;
298        packed |= (!zero_mask & 0xFFFF) << (16 * lane);
299    }
300    packed
301}
302
303/// AVX2 byte→bit pack kernel: two 32-byte `vpcmpeqb`-against-zero + `vpmovmskb` rounds.
304///
305/// # Safety
306///
307/// The caller must ensure the CPU supports AVX2.
308#[cfg(target_arch = "x86_64")]
309#[inline]
310#[target_feature(enable = "avx2")]
311pub unsafe fn pack_bool_word_avx2(bools: &[bool; 64]) -> u64 {
312    use std::arch::x86_64::__m256i;
313    use std::arch::x86_64::_mm256_cmpeq_epi8;
314    use std::arch::x86_64::_mm256_loadu_si256;
315    use std::arch::x86_64::_mm256_movemask_epi8;
316    use std::arch::x86_64::_mm256_setzero_si256;
317
318    let ptr = bools.as_ptr().cast::<u8>();
319    let zero = _mm256_setzero_si256();
320
321    // SAFETY: both 32-byte loads are within the 64-byte array.
322    let lo = unsafe { _mm256_loadu_si256(ptr.cast::<__m256i>()) };
323    // SAFETY: see above.
324    let hi = unsafe { _mm256_loadu_si256(ptr.add(32).cast::<__m256i>()) };
325
326    // `cmpeq` against zero sets 0xFF for *false* bytes; invert to get the truthy mask.
327    let lo_mask = !(_mm256_movemask_epi8(_mm256_cmpeq_epi8(lo, zero)) as u32) as u64;
328    let hi_mask = !(_mm256_movemask_epi8(_mm256_cmpeq_epi8(hi, zero)) as u32) as u64;
329    lo_mask | (hi_mask << 32)
330}
331
332/// AVX-512BW byte→bit pack kernel: a single 64-byte `vptestmb` produces the whole word.
333///
334/// # Safety
335///
336/// The caller must ensure the CPU supports AVX-512F and AVX-512BW.
337#[cfg(target_arch = "x86_64")]
338#[inline]
339#[target_feature(enable = "avx512f,avx512bw")]
340pub unsafe fn pack_bool_word_avx512(bools: &[bool; 64]) -> u64 {
341    use std::arch::x86_64::__m512i;
342    use std::arch::x86_64::_mm512_loadu_si512;
343    use std::arch::x86_64::_mm512_test_epi8_mask;
344
345    // SAFETY: the 64-byte load covers exactly the `[bool; 64]` array.
346    let chunk = unsafe { _mm512_loadu_si512(bools.as_ptr().cast::<__m512i>()) };
347    // Mask bit `i` is set iff byte `i` AND byte `i` is nonzero, i.e. iff `bools[i]`.
348    _mm512_test_epi8_mask(chunk, chunk)
349}
350
351/// NEON byte→bit pack kernel: shift each `0x00`/`0x01` byte left by its bit position
352/// (`ushl`), then fold the four vectors into one `u64` with a pairwise-add (`addp`) tree.
353///
354/// # Safety
355///
356/// The caller must ensure the CPU supports NEON (always true on aarch64).
357#[cfg(target_arch = "aarch64")]
358#[inline]
359#[target_feature(enable = "neon")]
360pub unsafe fn pack_bool_word_neon(bools: &[bool; 64]) -> u64 {
361    use std::arch::aarch64::vgetq_lane_u64;
362    use std::arch::aarch64::vld1q_s8;
363    use std::arch::aarch64::vld1q_u8;
364    use std::arch::aarch64::vpaddq_u8;
365    use std::arch::aarch64::vreinterpretq_u64_u8;
366    use std::arch::aarch64::vshlq_u8;
367
368    const BIT_SHIFTS: [i8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7];
369
370    let ptr = bools.as_ptr().cast::<u8>();
371    // SAFETY: loading 16 constant bytes from `BIT_SHIFTS`; the four 16-byte data loads below are
372    // all within the 64-byte array.
373    unsafe {
374        let shifts = vld1q_s8(BIT_SHIFTS.as_ptr());
375
376        // Byte j of each vector becomes `bools[16v + j] << (j % 8)`.
377        let m0 = vshlq_u8(vld1q_u8(ptr), shifts);
378        let m1 = vshlq_u8(vld1q_u8(ptr.add(16)), shifts);
379        let m2 = vshlq_u8(vld1q_u8(ptr.add(32)), shifts);
380        let m3 = vshlq_u8(vld1q_u8(ptr.add(48)), shifts);
381
382        // Three rounds of pairwise adds sum each group of 8 weighted bytes into one mask byte,
383        // yielding the 8 mask bytes in order in the low half of the final vector.
384        let sum01 = vpaddq_u8(m0, m1);
385        let sum23 = vpaddq_u8(m2, m3);
386        let sum = vpaddq_u8(sum01, sum23);
387        let sum = vpaddq_u8(sum, sum);
388        vgetq_lane_u64::<0>(vreinterpretq_u64_u8(sum))
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use rstest::rstest;
395
396    use super::collect_bool_word_scalar;
397    use super::pack_bool_word_swar;
398
399    fn patterns() -> Vec<[bool; 64]> {
400        let mut patterns = vec![
401            [false; 64],
402            [true; 64],
403            std::array::from_fn(|i| i % 2 == 0),
404            std::array::from_fn(|i| i % 3 == 0),
405            std::array::from_fn(|i| i < 32),
406            std::array::from_fn(|i| i == 0 || i == 63),
407        ];
408        // A few deterministic pseudo-random patterns.
409        let mut state = 0x9E37_79B9_7F4A_7C15u64;
410        for _ in 0..8 {
411            patterns.push(std::array::from_fn(|_| {
412                state = state
413                    .wrapping_mul(6364136223846793005)
414                    .wrapping_add(1442695040888963407);
415                (state >> 33) & 1 == 1
416            }));
417        }
418        patterns
419    }
420
421    fn reference(bools: &[bool; 64]) -> u64 {
422        collect_bool_word_scalar(64, |i| bools[i])
423    }
424
425    #[test]
426    fn swar_matches_scalar() {
427        for bools in patterns() {
428            assert_eq!(pack_bool_word_swar(&bools), reference(&bools));
429        }
430    }
431
432    #[test]
433    fn dispatch_matches_scalar() {
434        for bools in patterns() {
435            assert_eq!(
436                crate::bit::collect_bool_word(64, |i| bools[i]),
437                reference(&bools)
438            );
439        }
440    }
441
442    #[cfg(all(target_arch = "x86_64", not(miri)))]
443    #[test]
444    fn sse2_matches_scalar() {
445        for bools in patterns() {
446            // SAFETY: SSE2 is part of the x86-64 baseline instruction set.
447            assert_eq!(
448                unsafe { super::pack_bool_word_sse2(&bools) },
449                reference(&bools)
450            );
451        }
452    }
453
454    #[cfg(all(target_arch = "x86_64", not(miri)))]
455    #[test]
456    fn avx2_matches_scalar() {
457        if !is_x86_feature_detected!("avx2") {
458            return;
459        }
460        for bools in patterns() {
461            // SAFETY: runtime detection guarantees AVX2.
462            assert_eq!(
463                unsafe { super::pack_bool_word_avx2(&bools) },
464                reference(&bools)
465            );
466        }
467    }
468
469    #[cfg(all(target_arch = "x86_64", not(miri)))]
470    #[test]
471    fn avx512_matches_scalar() {
472        if !(is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw")) {
473            return;
474        }
475        for bools in patterns() {
476            // SAFETY: runtime detection guarantees AVX-512F and AVX-512BW.
477            assert_eq!(
478                unsafe { super::pack_bool_word_avx512(&bools) },
479                reference(&bools)
480            );
481        }
482    }
483
484    #[cfg(all(target_arch = "aarch64", not(miri)))]
485    #[test]
486    fn neon_matches_scalar() {
487        for bools in patterns() {
488            // SAFETY: NEON is part of the aarch64 baseline instruction set.
489            assert_eq!(
490                unsafe { super::pack_bool_word_neon(&bools) },
491                reference(&bools)
492            );
493        }
494    }
495
496    #[rstest]
497    #[case(0)]
498    #[case(1)]
499    #[case(63)]
500    #[case(64)]
501    #[case(65)]
502    #[case(200)]
503    fn multiversioned_matches_inline(#[case] len: usize) {
504        let pattern = |i: usize| i.is_multiple_of(3) || i.is_multiple_of(7);
505        let num_words = len.div_ceil(64);
506        let mut multiversioned = vec![0u64; num_words];
507        super::collect_bool_words_multiversioned(&mut multiversioned, len, pattern);
508        let mut inline = vec![0u64; num_words];
509        super::collect_bool_words_inline(&mut inline, len, pattern);
510        assert_eq!(multiversioned, inline);
511    }
512
513    #[rstest]
514    #[case(0)]
515    #[case(1)]
516    #[case(5)]
517    #[case(63)]
518    #[case(64)]
519    fn collect_bool_word_partial_lens_match(#[case] len: usize) {
520        let expected = collect_bool_word_scalar(len, |i| i % 3 == 0);
521        assert_eq!(crate::bit::collect_bool_word(len, |i| i % 3 == 0), expected);
522    }
523}