Skip to content

Optimize bitmap container batch iteration - #553

Closed
perfloop-agent wants to merge 2 commits into
RoaringBitmap:masterfrom
perfloop-oss:perfloop-pr-open-reb2j0nt52
Closed

perfloop-agent wants to merge 2 commits into
RoaringBitmap:masterfrom
perfloop-oss:perfloop-pr-open-reb2j0nt52

Conversation

@perfloop-agent

Copy link
Copy Markdown
Contributor

Description

Optimize bitmap-container batch decoding for ManyIterator and ToArray.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Performance improvement
  • Code refactoring
  • Documentation update
  • Test improvements
  • Build/CI changes

Changes Made

What was changed?

  • Replaced popcount-based bit extraction in bitmapContainerManyIterator.nextMany and nextMany64 with bits.TrailingZeros64 and lowest-set-bit clearing.
  • Added benchmarks for public 64-bit ToArray and ManyIterator decoding paths.

Why was it changed?

  • Batch decoding did extra work to locate each set bit and rechecked output capacity for every value.

How was it changed?

  • A bitmap word is drained through a tight loop when at least 64 output slots remain. Partial buffers keep the bounded path.

Testing

  • go test -v
  • go test -v ./roaring64
  • go test -v ./BitSliceIndexing
  • go tool unconvert ./...

Checks: 4 passed.

Formatting

  • Ran gofmt on the changed Go files.

Fuzzing

  • Not run; the change is confined to in-memory bitmap iteration.

Performance Impact

  • The benchmarks decode 32,768 values from one bitmap container and verify the returned values.
  • The fast path is used only with at least 64 remaining output slots; partial buffers retain the bounded path.

Workload: roaring64ToArray

Metric Before After Change
ns/op 92718 61471 33.7% lower

Workload: roaring64ManyIterator

Metric Before After Change
ns/op 60844 31408 48.4% lower

Breaking Changes

  • None.

Related Issues

  • None.

Additional Notes

  • No API changes.

Generated by Perfloop. Measurements and checks.

@lizthegrey

Copy link
Copy Markdown
Contributor

Negative result on amd64: at the default GOAMD64=v1, this change makes nextMany and nextMany64 slower on AMD Zen 3 in most of the cases I measured.

sec/op change against master (33586ce), with this PR's bitmapcontainer.go loop applied to it. go1.27.3, taskset-pinned, 10 rounds interleaved with master, benchstat, p=0.000 for each cell:

Benchmark EPYC 7R13 (m6a), v1 Ryzen 5 5600X, v1
BitmapContainerNextMany/card=4096/buf=4096 −6.7% −6.5%
BitmapContainerNextMany/card=16384/buf=4096 +20.1% +15.1%
BitmapContainerNextMany/card=32768/buf=4096 +16.2% +12.1%
BitmapContainerNextMany/card=65536/buf=4096 +14.2% +10.4%
BitmapContainerNextMany/card=32768/buf=512 +17.5% +15.4%
BitmapContainerNextMany/card=32768/buf=64 +30.4% +30.3%
nextMany64 (card=32768, buf=4096) +14.4% +12.4%

Other amd64 results from the same runs, for balance:

  • Zen 3 at GOAMD64=v3 is faster: −10% to −45%.
  • Cascade Lake at v1 is faster: −20% to −50%.
  • Ice Lake and Zen 4 at v1 are faster or flat, by up to −41% and −36% respectively.
  • Zen 4 at v3 is mixed: nextMany ranges from −8% to +12%, and nextMany64 is −27%.

Treat the amd64 numbers with some caution, because they look sensitive to code shape as well as to the instruction chosen. A functionally equivalent loop that did not use TrailingZeros64 at all at v1 was still 12–20% slower than master on most nextMany cases on Zen 3. I don't have a cause I can defend.

On arm64 this change is a clear win, at 20–50% with buffers of 512 or more across Neoverse N1, V1 and V2. #591 applies the same loop to arm64 only and leaves amd64 machine code byte-identical to master, with the full arm64 numbers there.

@perfloop-agent

Copy link
Copy Markdown
Contributor Author

Thanks for the careful numbers. I re-ran this on an AMD EPYC 7B13 (Zen 3), go1.27.3, 20 interleaved rounds against master 33586ce, with the loop moved behind //go:build amd64.v3 so that v1 compiles the master loop. At v1 nothing changes. At v3, nextMany at card 16384 and above is 7–29% faster and the roaring64 ToArray and ManyIterator benchmarks are 17–32% faster, but card=4096/buf=4096 is 5.4% slower, and your Zen 4 v3 results were mixed. Since most builds use the default v1, a separate amd64 path is not worth it here. #591 covers the clear arm64 win, so I'm closing this in favour of it.

lemire pushed a commit that referenced this pull request Nov 12, 2026
arm64 has no scalar popcount, so bits.OnesCount64 goes through the vector
unit for every set bit. On arm64 only, use bits.TrailingZeros64 and, when
the buffer has room, drain a whole word at a time, the approach from #553.

Other targets keep the existing loop. A compile-time constant removes the
new code there, and the amd64 machine code of nextMany and nextMany64 is
byte-identical to master at GOAMD64=v1 and v3. On amd64 the effect of this
rewrite varied with the microarchitecture and with small code-shape changes.

Add a buffer-size test and a benchmark for nextMany64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants