Skip to content

Always use the AVX-512 decoder in array_container_from_bitset - #952

Merged
lemire merged 2 commits into
masterfrom
fix-sparse-bitset-to-array
Nov 3, 2026
Merged

lemire merged 2 commits into
masterfrom
fix-sparse-bitset-to-array

Conversation

@lemire

@lemire lemire commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Problem

Since #868 (v5.1.1), array_container_from_bitset uses the AVX-512 VBMI2 decoder only when the bitset holds at least 1024 values, and falls back to the scalar loop below that. The scalar loop is slower at every cardinality, so this gate only slows down bitsets with fewer than 1024 values.

That case is common. roaring_bitmap_or_many (lazy union with bitset conversion) turns every container of its accumulator into a bitset, and roaring_bitmap_repair_after_lazy turns each one back into an array when the union is sparse.

This came up while updating gocroaring from CRoaring 5.1.0 to 5.4.0. FastOr (roaring_bitmap_or_many over 200 sparse bitmaps, about 1000 values per container) became 1.42× slower (9.25 ms → 13.14 ms, n=15, ±1%). perf showed 40% of the time in the scalar bitset_extract_setbits_uint16, where 5.1.0 ran bitset_extract_setbits_avx512_uint16. Removing the gate, and changing nothing else, brings FastOr back to the 5.1.0 time. None of the other 60 gocroaring benchmarks moved.

Fix

  • Drop the cardinality >= 1024 condition, so the AVX-512 decoder is used whenever the hardware supports it.
  • Correct the comments in convert.c and bitset_util.c that claimed the scalar loop was as fast or faster on sparse input.

Benchmarks (so this does not regress again)

This PR adds microbenchmarks/bitset_to_array_bench:

  • Decode: the dispatching array_container_from_bitset.
  • DecodeScalar and DecodeAvx512: each decoder forced.
  • OrMany: roaring_bitmap_or_many over many sparse bitmaps whose union stays sparse.

The Decode* benchmarks cover cardinalities from 16 to 4096 (including 1000, 1023 and 1024, around the old gate) and three layouts: uniform, clustered and runs. Decode includes allocation, so it is the before/after of array_container_from_bitset. Compare DecodeScalar with DecodeAvx512 for the decoders themselves: on x64 with AVX-512 the vector decoder is faster at every cardinality.

Inputs cycle across 64 bitsets, and across 1024 chunks for OrMany. A first version used only 16 chunks; there the branch predictor learned the input and hid most of the regression.

cmake -B build -D ENABLE_ROARING_MICROBENCHMARKS=ON && cmake --build build
./build/microbenchmarks/bitset_to_array_bench

Results

Measured on a Xeon Gold 6548N (Emerald Rapids), pinned to one core, medians of 5 runs. Times are in ns.

Scalar vs. AVX-512 decoder (forced), uniform layout:

card scalar AVX-512
16 792 671
256 2873 2705
512 4234 3938
1000 5648 3742
2048 7108 2135
4096 8456 1537

The AVX-512 decoder also wins at every cardinality in the clustered layout (1.36× to 5.2×) and the runs layout (1.27× to 1.54×).

Decode (the dispatch), before → after this PR:

card uniform clustered runs
16 1.15× 1.36× 1.19×
256 1.06× 1.25× 1.23×
512 1.07× 1.58× 1.35×
1000 1.44× 2.22× 1.36×
1023 1.51× 2.32× 1.31×
≥ 1024 1.00–1.02× 1.01–1.02× 1.00–1.02×

OrMany (1024 chunks, 5 values per chunk per bitmap), before → after, 10 samples, spread about 3%:

bitmaps ≈ union per container before (µs) after (µs) speedup
40 200 6307 5896 1.07×
100 500 9077 8463 1.07×
200 1000 12585 10420 1.21×
400 2000 12219 12211 1.00×
800 4000 20019 20180 0.99×

All 27 tests pass, and all changed files are clean under clang-format 17. Non-x64 builds are unaffected; the new benchmark builds and runs on arm64 macOS, where DecodeAvx512 is compiled out.

Not in this PR

The array/array union in src/containers/mixed_union.c (around line 237) also converts a sparse bitset back to an array, and calls the scalar bitset_extract_setbits_uint16 unconditionally, as it did before #868. It could likely use the AVX-512 decoder too. That is not a regression, so I left it for a separate change.

Since #868 (v5.1.1), array_container_from_bitset only uses the AVX-512
VBMI2 decoder when the bitset holds at least 1024 values, and the scalar
loop below that. The vector decoder is in fact faster at every
cardinality, so the gate only slows down the conversion of bitsets with
fewer than 1024 values. Such bitsets are common: roaring_bitmap_or_many
converts every container of its accumulator to a bitset, and
roaring_bitmap_repair_after_lazy converts each one back to an array
when the union is sparse. gocroaring's FastOr, over 200 sparse bitmaps,
became 1.42x slower between CRoaring 5.1.0 and 5.4.0, and removing the
gate alone recovers all of it.

Add microbenchmarks/bitset_to_array_bench, which times the conversion
through the dispatch and through each decoder forced, over cardinalities
from 16 to 4096 and three layouts (uniform, clustered, runs), as well as
roaring_bitmap_or_many over many sparse bitmaps. Inputs cycle across many
containers so that the branch predictor cannot learn them.
Decode calls array_container_from_bitset and pays for allocation, so it
is the before/after of the real conversion and not a lower bound on the
forced kernels. DecodeScalar and DecodeAvx512 now write into one array of
exactly the cardinality, the buffer the library allocates.
@lemire
lemire merged commit 9e54f9d into master Nov 3, 2026
43 checks passed
lemire added a commit that referenced this pull request Nov 4, 2026
…953)

array_container_from_bitset decodes sparse bitsets (at most 4096 values)
with bitset_extract_setbits_avx512_uint16. Since #868, that kernel tests
each word ("if (v != 0)") to skip zero words. When zero and nonzero words
alternate, as when about 1000 values are spread out, that branch is
mispredicted about once per value. Even after #952, gocroaring's FastOr
(roaring_bitmap_or_many over 200 sparse bitmaps) remained 1.23x slower
than with CRoaring 5.1.0, whose kernel had no such branch.

Neither extreme works across inputs: decoding every word without testing
it is fastest when most words are nonzero, but wastes time on very sparse
bitsets or runs. So the kernel now picks by the capacity, which
array_container_from_bitset sets to the cardinality:

- from 2048 values (two per word on average), it decodes every word with
  masked stores, four words at a time without capacity checks while there
  is room for 256 values;
- below 2048 values, it tests eight words at a time (VPTESTMQ) and visits
  only the nonzero ones, so that a run of zero words costs little and a
  group costs about one misprediction.

Both stop once "capacity" values are written, instead of scanning all the
words. Against master on a Xeon Gold 6548N, with
microbenchmarks/bitset_to_array_bench (medians of 10 runs), the
conversion is 1.02x to 22x faster at every cardinality from 16 to 4096
in the uniform, clustered and runs layouts, and roaring_bitmap_or_many is
1.01x to 1.43x faster. gocroaring's FastOr is now 3.6% faster than with
CRoaring 5.1.0.

Add a test that checks the decoder against the scalar loop on sparse
bitsets with zero words, spread out or in runs, on both sides of 2048
values, with truncated capacities: the existing test only had nonzero
words.
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