Skip to content

Decode bitmap containers into array containers with VPCOMPRESSB - #561

Merged
lemire merged 1 commit into
vbmi2-toarrayfrom
vbmi2-fillarray
Aug 27, 2026
Merged

Decode bitmap containers into array containers with VPCOMPRESSB#561
lemire merged 1 commit into
vbmi2-toarrayfrom
vbmi2-fillarray

Conversation

@lemire

@lemire lemire commented Aug 27, 2026

Copy link
Copy Markdown
Member

Description

bitmapContainer.fillArray and the fillArrayAND / fillArrayANDNOT / fillArrayXOR helpers all carried a // TODO: rewrite in assembly. This does that, with the uint16 counterpart of the kernel in #557.

Stacked on #557 - it reuses the useVectorFill gate from that PR, so the base branch is vbmi2-toarray, not master.

Type of Change

  • Performance improvement

Changes Made

Five kernels in one new assembly file, same shape as fillbits_vbmi2_amd64.s: one VPCOMPRESSB per 64-bit word turns the word into 64 byte-sized bit positions, widened 32 at a time with VPMOVZXBW and written with a masked store, so exactly popcount(word) uint16s are written and the output needs no slack. The AND/ANDNOT/XOR variants combine two containers word by word before emitting.

fillArraySkipVector additionally tests eight words at a time with VPTESTMQ and skips wholly empty groups. It wins only when the container is very sparse; bitmapContainer.fillArray picks between the two on the container's cardinality, with a crossover measured just under 768.

The scalar loops in util.go keep their exact previous bodies - only an early-out is added in front of each - so the non-AVX-512 path is textually unchanged.

Performance Impact

Xeon Gold 6548N (Emerald Rapids), pinned with taskset, -benchtime=2s, ns per value.

bitmapContainer.fillArray:

cardinality master this PR
64 10.21 2.567 3.98x
256 3.548 2.002 1.77x
512 2.545 1.703 1.49x
1024 1.797 1.118 1.61x
2048 1.292 0.673 1.92x
4096 0.911 0.372 2.45x

fillArrayAND, sized so the result stays at or below arrayDefaultMaxSize (the only regime it is reached in):

result values master this PR
249 2.932 3.215 0.91x
1044 1.678 1.076 1.56x
2281 1.127 0.590 1.91x
4076 0.903 0.392 2.30x

The sparsest two-input case is about 9% slower and I could not remove it. At that density the cost is scanning both 8 KB containers, not emitting the ~250 values that survive, so the vector kernel has nothing to win. Routing it back to the scalar loop on a size threshold made it worse (3.60), not better: inserting the guard shifts the scalar inner loop's alignment, and that loop is extremely alignment-sensitive here - in one build I measured byte-identical code 51% apart. Always taking the vector path was the best of the options I measured. A VPTESTMQ group-skip variant for the two-input kernels should fix it properly, since roughly 78% of the AND words are zero at that density; that is a follow-up.

It does not show end to end. Whole container operations, ns/op:

master this PR
andBitmap src=4096 1425 1405 1.01x
andBitmap src=8192 2208 1813 1.22x
andBitmap src=16384 4077 3000 1.36x
andNotBitmap src=4096 3975 2811 1.41x

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, on VBMI2 hardware so the kernels execute.

TestFillArrayMatchesScalar checks all four entry points at cardinalities 0, 1, 31, 32, 33, 63, 64, 65, 511, 512, 513, 4096, 30000, 65535 and 65536 - straddling the skip/compress crossover, the 32-values-per-store boundary and the 64-values-per-word boundary - against values derived independently from the source words rather than from the scalar implementation.

Breaking Changes

None.

Additional Notes

A note on the benchmarks: at these sizes the scalar inner loop is dominated by branch prediction and is very sensitive to code alignment. Short -benchtime=Nx runs moved identical code by up to 51%. All the numbers above use -benchtime=2s with the benchmark run alone in its own process, which was stable to under 1%.

https://claude.ai/code/session_0123ePBefqPjrCvhxdwWFakj

fillArray and the fillArrayAND/ANDNOT/XOR helpers all carried a
"TODO: rewrite in assembly". Add the uint16 counterpart of the kernel
introduced for ToArray: one VPCOMPRESSB per word, widened 32 at a time with
VPMOVZXBW and written with a masked store, so exactly popcount(word) values
are written and the output needs no slack.

fillArraySkipVector additionally tests eight words at a time with VPTESTMQ
and skips empty groups, which wins only on very sparse containers;
fillArray picks between the two on cardinality, crossover just under 768.

The scalar loops keep their exact previous bodies, with only an early-out
added in front, so the non-AVX-512 path is textually unchanged.

Xeon Gold 6548N, ns per value:

    fillArray card=64      10.21 -> 2.567  (3.98x)
    fillArray card=1024     1.797 -> 1.118 (1.61x)
    fillArray card=4096     0.911 -> 0.372 (2.45x)
    fillArrayAND out=1044   1.678 -> 1.076 (1.56x)
    fillArrayAND out=4076   0.903 -> 0.392 (2.30x)

The sparsest two-input case (out=249) is about 9% slower: at that density
the cost is scanning both containers, not emitting the values, so the
kernel has nothing to win. A size threshold made it worse rather than
better because inserting the guard shifts the scalar loop's alignment. It
does not show end to end -- andBitmap at that size is 1.01x -- and a
VPTESTMQ group-skip variant should fix it properly as a follow-up.

Claude-Session: https://claude.ai/code/session_0123ePBefqPjrCvhxdwWFakj
@lemire
lemire merged commit e42d83f into vbmi2-toarray Aug 27, 2026
12 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