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