Skip to content

Speed up the AVX-512 VBMI2 bitset decoders - #868

Merged
lemire merged 1 commit into
masterfrom
avx512-vbmi2-decode-branchy
Aug 27, 2026
Merged

Speed up the AVX-512 VBMI2 bitset decoders#868
lemire merged 1 commit into
masterfrom
avx512-vbmi2-decode-branchy

Conversation

@lemire

@lemire lemire commented Aug 27, 2026

Copy link
Copy Markdown
Member

What

bitset_extract_setbits_avx512 and bitset_extract_setbits_avx512_uint16 keep the VPCOMPRESSB kernel, but change how it stores.

Before, each word stored full 64-byte blocks unconditionally and let the next word overwrite the slack. That cost two things: the caller had to leave 64 values of padding ((out + 64) < safeout), so the tail of every container fell back to the scalar loop; and every word paid for all four blocks even when it produced a handful of values.

Now each block is stored under a mask derived from BZHI(-1, popcount), so a store writes exactly the values it produced, and only the ceil(popcount/16) blocks that carry a value are computed at all. Empty words are skipped, and the base is carried in a ZMM incremented by a constant rather than rebuilt per word.

The masked-store idea comes from RoaringBitmap/roaring#557, which applies it to the Go port's ToArray.

Kernel measurements

Xeon Gold 6548N (Emerald Rapids), taskset-pinned, ns per value, kernel only:

cardinality scalar before after
256 2.91 6.93 2.95
1024 1.29 1.95 1.02
2048 0.76 1.07 0.65
4096 0.54 0.54 0.37
8192 0.42 0.27 0.18

The two call sites

bitset_container_to_uint32_array — the AVX-512 kernel now beats the scalar loop at every cardinality measured from 16 to 65536, in both streaming and cache-resident modes, so its cardinality >= 8192 heuristic is gone. The AVX2 gate is untouched.

array_container_from_bitset — the opposite case. Every caller checks that the result fits an array container first, so the input is always under 6.25% density, and this call site had no heuristic at all. The old kernel was a net regression here: at 256 values it was 2.4x slower than the scalar loop (6.93 vs 2.91 ns/value). The new kernel is faster from about 1024 values up, so that is where the gate goes.

End to end

Using this repository's own benchmark:

benchmark before after
microbench/ToArray/census-income 6671 ns 4705 ns 1.42x
microbench/ToArray/weather_sept_85 25895 ns 11611 ns 2.23x
bitset_container/to_array_convert 2.08 ns 1.47 ns 1.42x

The other datasets contain no bitset containers and are unchanged.

Benchmarks outside these paths move by up to 1.5x in either direction, but that is code-layout noise, not this change: rebuilding the unmodified source into a second binary reproduces the same swings (synthetic/ContainsColdHigh is 1.496x on the rebuild control and 1.490x here; microbench/ComputeCardinality is 1.163x and 1.165x). The two benchmarks that do touch this code are 1.005x and 1.003x across rebuilds and 0.705x with the patch.

Feature detection

Unchanged, and already correct: CROARING_AVX512_REQUIRED includes CROARING_AVX512VBMI2 (CPUID leaf 7, ECX bit 6) with the xgetbv opmask/ZMM check, the compile-time fast path requires __AVX512VBMI2__, CROARING_TARGET_AVX512 targets avx512vbmi2, and CROARING_COMPILER_SUPPORTS_AVX512 keys off <avx512vbmi2intrin.h>. ROARING_SUPPORTS_AVX512 already implies VBMI2 is usable.

Testing

  • New avx512_extract_matches_scalar in tests/util_unit.c compares both decoders against the scalar ones across 64 densities, at exactly the cardinality and at truncated capacities.
  • Full suite on VBMI2 hardware: 27/27, and 27/27 again under ASan and under UBSan, so the masked stores are checked for out-of-bounds writes on real hardware.
  • Also ran the kernel source through a scalar emulation of the intrinsics (masked stores emulated lane by lane, guard padding on both sides) over all densities 0-64, every block boundary, exact and truncated capacity, and 400 random patterns. Mutation-checked: a wrong mask shift, wrong lane, wrong tail base, wrong BZHI width, a loosened or removed capacity guard, and a wrong branch bound are all caught.

Note for a follow-up

array_array_container_union (mixed_union.c) does the same bitset-to-array conversion on the scalar path with no dispatch. That branch is reachable only with cardinality in [2049, 4096], which is entirely inside the range where the vector kernel wins, so it looks like a free 1.2-1.4x on that step. Left out of this PR to keep it to the two decoders.

https://claude.ai/code/session_015iZSVG9KJ595ktzGqaK7qR

Both bitset_extract_setbits_avx512 and bitset_extract_setbits_avx512_uint16
kept the VPCOMPRESSB kernel but stored full 64-byte blocks unconditionally,
relying on the next word to overwrite the slack. That cost two things: the
caller had to leave 64 values of padding (so the last values always fell back
to the scalar tail), and every word paid for all four blocks even when it only
produced a handful of values.

Each block is now stored under a mask derived from BZHI(-1, popcount), so a
store writes exactly the values it produced, and only the ceil(popcount/16)
blocks that carry a value are computed at all. Empty words are skipped and the
base is carried in a ZMM incremented by a constant instead of being rebuilt
per word.

Measured on a Xeon Gold 6548N (Emerald Rapids), ns per value, kernel only:

  cardinality    scalar    before     after
          256     2.91      6.93      2.95
         1024     1.29      1.95      1.02
         2048     0.76      1.07      0.65
         4096     0.54      0.54      0.37
         8192     0.42      0.27      0.18

The uint32 decoder now beats the scalar loop at every cardinality measured
from 16 to 65536, so bitset_container_to_uint32_array no longer needs its
cardinality heuristic.

array_container_from_bitset is the opposite case: every caller checks that the
result fits an array container first, so its input is always under 6.25%
density, and it had no heuristic at all. The old kernel was a net regression
there -- 2.4x slower than the scalar loop at 256 values. The new one is faster
from about 1024 values up, so that is where the gate goes.

End to end, with the repository's own benchmark:

  microbench/ToArray/census-income      6671 ns -> 4705 ns   (1.42x)
  microbench/ToArray/weather_sept_85   25895 ns -> 11611 ns  (2.23x)
  bitset_container/to_array_convert     2.08 ns -> 1.47 ns   (1.42x)

The other datasets hold no bitset containers and are unchanged. Benchmarks
outside these paths move by up to 1.5x in either direction, but rebuilding the
unmodified source reproduces the same swings, so they are code layout noise.

The masked-store idea is from RoaringBitmap/roaring#557, which applies it to
the Go port's ToArray.

A new test compares both decoders against the scalar ones across 64 densities,
at exactly the cardinality and at truncated capacities.

Claude-Session: https://claude.ai/code/session_015iZSVG9KJ595ktzGqaK7qR
@lemire
lemire merged commit 1a3d613 into master Aug 27, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant