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