Skip to content

runcontainer: count or test before allocating in run-bitmap intersections - #566

Open
tamirms wants to merge 1 commit into
RoaringBitmap:masterfrom
tamirms:pr1-run-and-count-first
Open

tamirms wants to merge 1 commit into
RoaringBitmap:masterfrom
tamirms:pr1-run-and-count-first

Conversation

@tamirms

@tamirms tamirms commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Description

Three run-container paths build an 8 KiB bitmap container before they know whether the result is empty, an array, or a bitmap. This counts or tests first, then allocates only what is returned.

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?

  • runContainer16.andBitmapContainer counts the intersection with the existing andBitmapContainerCardinality, then builds an empty array container, an exact-size array, or a bitmap, whichever the count calls for.
  • bitmapContainer.iandRun16 counts the same way, returns a small result as an array built from the run's intervals, and otherwise clears the gaps between intervals in place.
  • runContainer16.intersects walks the run's intervals against the other container and returns at the first shared value, rather than building a container to answer a boolean.
  • Added clearBitmapGaps and containerFromWords for the shared pieces, and runbitmap_and_test.go with tests and BenchmarkRunAndBitmap.

Why was it changed?

  • andBitmapContainer converted the run to a bitmap container and ANDed, so an empty or array-sized result paid for a bitmap it never returned. iandRun16 did the same conversion on the argument side.
  • An empty result is the common case when a run meets a sparse bitmap.
  • intersects computed rc.and(a) and tested the result for emptiness, allocating a container to answer a boolean. Every mixed pairing with a run reaches it through the array and bitmap dispatchers, so this sits on the path of every Intersects call that touches a run.

How was it changed?

  • The result is sized by its cardinality before it is built, the same shape as bitmapContainer.andBitmap, which already does this.
  • A run with more than 64 intervals is ORed into a scratch bitmap on the stack, counted there, and extracted in one pass. Past that count iandRun16 also builds the run in stack scratch and ANDs in one flat pass, since clearing gap by gap costs a call per interval.
  • intersects uses a masked word scan for bitmaps, a galloping merge for arrays or a probe per value when the array is much smaller than the interval list, and an interval overlap test for runs.
  • No API change.

Testing

runbitmap_and_test.go checks each path against its old definition, newBitmapContainerFromRun(rc).andBitmap(bc) and !rc.and(c).isEmpty(), over random container pairs and the edges: empty run, full run, intervals on word boundaries, results at the array and bitmap threshold, runs with hundreds of intervals, and a few values against many intervals. It also asserts that the empty and boolean paths allocate no container.

go test ./...
go test -tags appengine ./...
GOOS=linux GOARCH=386 go build ./...    # also arm, arm64, ppc64

All pass on linux/amd64 and darwin/arm64.

Formatting

gofmt -s -l . reports nothing. go vet ./... reports only the pre-existing ReadFrom signature complaint on master.

Fuzzing

The step above cannot be run as written: there is no FuzzSmat target on master. smat.go refers to a smat_fuzz_test.go that is not in the repository, so go test -fuzz=FuzzSmat matches nothing and simply runs the ordinary tests. What does run is the smat corpus, which passes:

go test -tags=gofuzz -run 'TestGenerateSmatCorpus|TestSmatHits'
ok  github.com/RoaringBitmap/roaring/v2

Performance Impact

BenchmarkRunAndBitmap is added: And, in-place And and Intersects between a run-optimized bitmap and a dense one over a single key. Xeon 8375C.

                                        before                          after
  And, empty result                     3.0 µs   8328 B/op  4 allocs    594 ns   104 B/op  2 allocs
  And, array result                     8.3 µs  14504 B/op  8 allocs    4.6 µs  6280 B/op  6 allocs
  And, bitmap result                    4.6 µs  16560 B/op  8 allocs    3.6 µs  8336 B/op  6 allocs
  And, 1024-interval run, array        11.9 µs  12456 B/op  8 allocs   10.0 µs  4232 B/op  6 allocs
  And, 1024-interval run, bitmap        9.5 µs  16560 B/op  8 allocs    7.6 µs  8336 B/op  6 allocs
  in-place And, bitmap result           2.4 µs   8224 B/op  2 allocs    290 ns     0 B/op  0 allocs
  in-place And, 1024-interval run       7.3 µs   8224 B/op  2 allocs    4.6 µs     0 B/op  0 allocs
  Intersects, disjoint                  3.0 µs   8248 B/op  3 allocs    121 ns     0 B/op  0 allocs
  Intersects, overlapping               8.3 µs  14392 B/op  4 allocs      8 ns     0 B/op  0 allocs
  Intersects, few values, 1024 intervals 1.3 µs    32 B/op  2 allocs     62 ns     0 B/op  0 allocs

Performance Analysis

  • The empty result, the common case when a run meets a sparse bitmap, drops from 3.0 µs and an 8 KiB allocation to 594 ns and 104 bytes.
  • The boolean paths no longer allocate at all.
  • Memory falls on every row, since nothing larger than the returned container is built.
  • BenchmarkIntersection*, BenchmarkUnion* and BenchmarkAndAny/* do not reach these paths and are unchanged.

Breaking Changes

None. No API change.

Related Issues

None.

Additional Notes

bitmapContainer.iandArray has the same conversion for arrays and is left for a separate change.

🤖 Generated with Claude Code

tamirms added a commit to stellar/stellar-rpc that referenced this pull request Sep 12, 2026
eval narrowed a per-filter accumulator through the filter's groups one
AndAny at a time, so every filter paid an 8 KiB accumulator on every slab
even when its groups had nothing in common there, and a topic-count range
paid its union on top. A filter now yields plans that are conjunctions of
single terms, one per bucket of a range, since A and (b or c) is (A and b)
or (A and c) and the union across plans keeps the or; the planner drops a
plan that repeats an earlier one. The slab engine then holds one bitmap
per term, runs one FastAnd over the slab's id range and the terms per
plan, keeps its bounds as the searches return them, and counts each term's
cardinality once. The slab bitmap is built once per slab and shared by
every plan.

With the pinned roaring, FastAnd intersects pairwise and the cost is
unchanged or lower. With roaring's count-first intersections
(RoaringBitmap/roaring#566 and the FastAnd follow-up built on it) an empty
result allocates nothing, which is what bounds the slab walk on filter
lists that match nothing; the version bump will add the contract test for
that.

roaring_contract_test.go now pins that FastAnd leaves its inputs untouched,
returns fresh containers on the two- and many-input paths over whole and
partial slab ranges, and agrees with the pairwise And chain for every
subset of the container kinds; that a slab range built with AddRange is
stored as run containers; and runs FastAnd in the concurrent-readers race
gate. The AndAny pins go, since the engine no longer calls it. The shaped
differential matrix gains a filter with a term and a count range, and a
planner test covers the fan-out and the dedup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M23ZvEkyrFjyUUbm7zLwob
@tamirms
tamirms force-pushed the pr1-run-and-count-first branch from 0ffe5f3 to 2a85570 Compare September 14, 2026 20:47
…ions

runContainer16.andBitmapContainer materialized the run as an 8 KiB bitmap
container and then intersected, so an empty or array-sized result paid
for a bitmap it never returned. It now counts the result with the existing
andBitmapContainerCardinality and builds only that: an empty array
container when the intersection is empty, an array of the right size when
it is small, and the bitmap only when the result is one. A run with more
than 64 intervals is ORed into a scratch bitmap on the stack, counted
there, and extracted in one pass instead.

bitmapContainer.iandRun16 did the same conversion on the argument side. It
now counts the same way, returns a small result as an array built from
the run's intervals, and otherwise clears the gaps between intervals in
place; past 64 intervals the run is built in stack scratch and ANDed in
one flat pass, since clearing gap by gap costs a call per interval.

runContainer16.intersects computed rc.and(a) and tested the result for
emptiness, allocating a container to answer a boolean; every mixed
pairing with a run reached it through the array and bitmap dispatchers.
It now walks the run's intervals against the other container, a masked
word scan for bitmaps, a galloping merge for arrays or a probe per value
when the array is much smaller than the interval list, an interval
overlap test for runs, each returning at the first shared value.

BenchmarkRunAndBitmap (added): And, in-place And and Intersects between a
run-optimized bitmap and a dense one over a single key. Xeon 8375C:
                                        before                          after
  And, empty result                     3.0 µs   8328 B/op  4 allocs    594 ns   104 B/op  2 allocs
  And, array result                     8.3 µs  14504 B/op  8 allocs    4.6 µs  6280 B/op  6 allocs
  And, bitmap result                    4.6 µs  16560 B/op  8 allocs    3.6 µs  8336 B/op  6 allocs
  And, 1024-interval run, array        11.9 µs  12456 B/op  8 allocs   10.0 µs  4232 B/op  6 allocs
  And, 1024-interval run, bitmap        9.5 µs  16560 B/op  8 allocs    7.6 µs  8336 B/op  6 allocs
  in-place And, bitmap result           2.4 µs   8224 B/op  2 allocs    290 ns     0 B/op  0 allocs
  in-place And, 1024-interval run       7.3 µs   8224 B/op  2 allocs    4.6 µs     0 B/op  0 allocs
  Intersects, disjoint                  3.0 µs   8248 B/op  3 allocs    121 ns     0 B/op  0 allocs
  Intersects, overlapping               8.3 µs  14392 B/op  4 allocs      8 ns     0 B/op  0 allocs
  Intersects, few values, 1024 intervals 1.3 µs    32 B/op  2 allocs     62 ns     0 B/op  0 allocs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tamirms
tamirms force-pushed the pr1-run-and-count-first branch from 2a85570 to 3fe980c Compare September 15, 2026 05:44
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