Skip to content

Vectorize the bitmap-container word operations, fusing the cardinality pass - #559

Merged
lemire merged 1 commit into
masterfrom
avx512-bitset-ops
Aug 27, 2026
Merged

Vectorize the bitmap-container word operations, fusing the cardinality pass#559
lemire merged 1 commit into
masterfrom
avx512-bitset-ops

Conversation

@lemire

@lemire lemire commented Aug 27, 2026

Copy link
Copy Markdown
Member

Description

The bitmap-container boolean operations wrote their result with a plain Go loop over 1024 words and then made a second pass to count it. This vectorizes the write and, where the shape allows, fuses the population count into the same pass.

Type of Change

  • Performance improvement
  • Code refactoring

Changes Made

Adds six word-level helpers with the usual three-file layout (bitsetops.go portable, bitsetops_generic.go, bitsetops_avx512_amd64.go + .s):

  • orSlice, andSlice, xorSlice, andNotSlice - dst[i] = a[i] op b[i]
  • orCardSlice, andCardSlice - the same, also returning the population count of the result via VPOPCNTQ

bitmapcontainer.go then loses eight hand-written word loops:

  • orBitmap, iorBitmap wrote the result and then called computeCardinality(). Both now make one fused pass.
  • iandBitmap counted first and then wrote unconditionally; also one fused pass now.
  • andBitmap, xorBitmap, ixorBitmap, andNotBitmap, iandNotBitmapSurely need the cardinality before deciding whether to produce a bitmap or an array container, so they keep the existing count-first shape and only their write loop is vectorized.
  • lazyIORBitmap and lazyORBitmap were manually unrolled by four with bounds-check hints; they become a single orSlice call.

dst may alias either source: sources are loaded before the destination is stored within an iteration, and iterations never touch each other's words. This matters because the in-place operations pass the same slice as destination and first source.

Detection is cpu.X86.HasAVX512VPOPCNTDQ. The plain writes only need AVX512F, but they are gated together so one flag governs the file. CPUs without it, appengine builds and non-amd64 targets use the portable helpers, which keep exactly the previous two-pass behaviour (popcntSlice there may itself be AVX2, so a fused scalar loop would not obviously win).

Performance Impact

Xeon Gold 6548N (Emerald Rapids), pinned with taskset, Go 1.26.3.

One 8 KB container:

before after
write only (orSlice) 599 ns 52 ns 11.5x
write + cardinality 631 ns 115 ns 5.5x

End to end on two bitmaps of 64 dense bitmap containers each:

master this PR
Or 152985 ns 90806 ns 1.68x
AndNot 153366 ns 105304 ns 1.46x
And 152147 ns 105404 ns 1.44x
Xor 151482 ns 105158 ns 1.44x
Or in place 149240 ns 109695 ns 1.36x

The gap between 5-11x on the kernel and 1.4-1.7x end to end is allocation: each of these operations allocates 64 fresh 8 KB containers.

On the real-data corpus the effect is smaller still - FastOr 383->348 us on census-income (1.10x) and 1799->1728 us on weather_sept_85 (1.04x), ParOr 1.08x and 1.05x - because those aggregations are dominated by array and run containers and by the priority-queue machinery, not by bitmap-container words. Reporting it so the kernel numbers are not read as end-to-end ones.

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 AVX-512 hardware so the new path executes.

TestBitsetOpsMatchGo compares all six helpers against the portable versions at lengths 0,1,3,8,31,32,33,64,65,1023,1024,1025 - the empty case, sub-block cases, exact multiples of the 32-word main loop, and the scalar tail. TestBitsetOpsAliasing covers dst == a and dst == b, which is how the in-place container operations call them.

Breaking Changes

None.

Additional Notes

Part of a series applying AVX-512 to roaring. Overlaps with #557 and #558 only in go.mod/go.sum; worth deciding as a group whether these should share one AVX-512 feature gate instead of one flag per feature.

https://claude.ai/code/session_0123ePBefqPjrCvhxdwWFakj

…y pass

The bitmap-container boolean operations wrote their result with a plain Go
loop over 1024 words and then made a second pass to count it. Add six word
helpers -- orSlice, andSlice, xorSlice, andNotSlice, and the orCardSlice and
andCardSlice variants that also return the population count via VPOPCNTQ --
and use them to replace eight hand-written loops in bitmapcontainer.go.

orBitmap, iorBitmap and iandBitmap now make a single fused pass. andBitmap,
xorBitmap, ixorBitmap, andNotBitmap and iandNotBitmapSurely need the
cardinality before choosing a bitmap or array result, so they keep the
count-first shape and only their write loop is vectorized. lazyIORBitmap and
lazyORBitmap lose their manual four-way unroll.

dst may alias either source: sources are loaded before the destination is
stored within an iteration, which is what the in-place operations rely on.

One 8 KB container on a Xeon Gold 6548N:

    write only          599 ns -> 52 ns   (11.5x)
    write + cardinality 631 ns -> 115 ns  (5.5x)

Two bitmaps of 64 dense bitmap containers:

    Or      152985 ns -> 90806 ns  (1.68x)
    AndNot  153366 ns -> 105304 ns (1.46x)
    And     152147 ns -> 105404 ns (1.44x)
    Xor     151482 ns -> 105158 ns (1.44x)

The end-to-end gap is allocation: each operation allocates 64 fresh 8 KB
containers.

Claude-Session: https://claude.ai/code/session_0123ePBefqPjrCvhxdwWFakj
@lemire
lemire merged commit 5d3db85 into master Aug 27, 2026
14 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