Skip to content

Vectorize dense bitmap decoding in ToArray with AVX-512 VBMI2 - #557

Merged
lemire merged 1 commit into
masterfrom
vbmi2-toarray
Aug 27, 2026
Merged

Vectorize dense bitmap decoding in ToArray with AVX-512 VBMI2#557
lemire merged 1 commit into
masterfrom
vbmi2-toarray

Conversation

@lemire

@lemire lemire commented Aug 26, 2026

Copy link
Copy Markdown
Member

Description

Speeds up Bitmap.ToArray for dense bitmap containers on amd64 CPUs with AVX512_VBMI2.

This is an alternative to #556, which targets the same bottleneck. Same idea, different kernel and a lower density gate; measurements comparing the two are below.

Changes Made

bitmapContainer.fillLeastSignificant16bits walks set bits with a scalar TZCNT/BLSR loop. With VBMI2, a single VPCOMPRESSB turns a whole 64-bit word into 64 byte-sized bit positions in one shot; those are widened 16 at a time with VPMOVZXBD, added to a running base vector, and written with masked stores.

The compress-then-widen kernel follows simdjson's bit_indexer::write. It differs in the stores: simdjson writes whole 64-byte blocks and relies on its output buffer having up to 64 uint32 of slack. ToArray allocates exactly GetCardinality() values with no padding, so each block is written with a mask derived from BZHI(-1, popcount). That keeps writes exactly popcount(word) wide — and it is also faster on dense containers, because no store bandwidth goes to values nobody asked for (up to 35% faster than the unmasked form once the output leaves cache).

Feature detection goes through golang.org/x/sys/cpu, which already verifies OSXSAVE and the XCR0 opmask/ZMM state, and honors GODEBUG=cpu.avx512vbmi2=off to disable the path at run time without rebuilding. This adds golang.org/x/sys as a direct dependency; it was already in the module graph.

Containers below 4096 values, CPUs without VBMI2, appengine builds and non-amd64 targets keep the scalar loop.

Performance Impact

All numbers on a Xeon Gold 6548N (Emerald Rapids), pinned with taskset, Go 1.26.3.

ToArray over the RoaringBitmap real-data corpus, best of 5 x 20 iterations:

dataset master #556 this PR
census-income 8.48 ms 5.06 ms (1.67x) 4.60 ms (1.84x)
weather_sept_85 16.19 ms 11.58 ms (1.40x) 7.83 ms (2.07x)

The other ten datasets in the corpus contain no bitmap containers at all, so neither change affects them.

Kernel only, ns per set bit by container density:

density scalar #556 this PR
6.25% 0.890 1.206 0.376
12.5% 0.758 0.584 0.183
25% 0.819 0.286 0.114
37.5% 0.773 0.194 0.107
50% 0.755 0.144 0.100
75% 0.715 0.099 0.098
87.5% 0.697 0.097 0.103

On the density gate

The threshold is 4096 rather than 25% density. Measured break-even against the scalar loop is near 2000 values, and 4096 is the array-to-bitmap conversion point, so this covers essentially every bitmap container with margin.

That band is not empty in practice. Of weather_sept_85's 561 bitmap containers, 349 (62%), holding 2.48M of 10.37M values, sit below 25% density; for census-income it is 42 containers and 285K values.

Testing

go test ./..., roaring64, the smat corpus and smat-hit checks, go tool unconvert, gofmt, go vet, and cross-builds for 386/arm/arm64 and -tags appengine, all on VBMI2 hardware so the vector path actually executes.

TestBitmapContainerFillLeastSignificant16bitsVector compares the vector decoder against the scalar one at cardinalities 4095/4096/4097 (the crossover), 20000, 40000, 60000, 65535 and 65536, with mask = 0xFFFF0000 and sentinel padding on both sides. It was mutation-checked: perturbing a single branch bound in the kernel makes it fail.

Breaking Changes

None. Output ordering, the return value and the allocation contract are unchanged; this only swaps how the values are produced.

Credits

Based on the approach explored in #556 by Perfloop. The kernel follows simdjson's bit_indexer::write.

https://claude.ai/code/session_0123ePBefqPjrCvhxdwWFakj

bitmapContainer.fillLeastSignificant16bits walks set bits with a scalar
TZCNT/BLSR loop. On CPUs with AVX512_VBMI2 a single VPCOMPRESSB turns a
whole 64-bit word into 64 byte-sized bit positions; those are widened
with VPMOVZXBD and written with masked stores, so exactly popcount(word)
values are written and ToArray's exactly-sized output slice needs no
padding.

ToArray over the real-data corpus (Xeon Gold 6548N, Emerald Rapids):

    census-income      8.48 ms -> 4.60 ms  (1.84x)
    weather_sept_85   16.19 ms -> 7.83 ms  (2.07x)

The other ten datasets hold no bitmap containers and are unchanged.

Feature detection goes through golang.org/x/sys/cpu, so the OSXSAVE and
XCR0 opmask/ZMM checks are handled upstream and GODEBUG=cpu.avx512vbmi2=off
disables the path at run time. Containers below 4096 values, CPUs without
VBMI2, appengine builds and non-amd64 targets keep the scalar loop.

The compress-then-widen kernel follows simdjson's bit_indexer::write.
Based on the approach explored in #556 by Perfloop.

Claude-Session: https://claude.ai/code/session_0123ePBefqPjrCvhxdwWFakj
@lemire
lemire merged commit f2e068c into master Aug 27, 2026
14 checks passed
lemire added a commit to RoaringBitmap/CRoaring that referenced this pull request Aug 27, 2026
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
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