Skip to content

Optimize scattered BatchEqual with bounded container decoding - #589

Open
perfloop-agent wants to merge 3 commits into
RoaringBitmap:masterfrom
perfloop-oss:perfloop-pr-open-d5x6x7arbk
Open

perfloop-agent wants to merge 3 commits into
RoaringBitmap:masterfrom
perfloop-oss:perfloop-pr-open-d5x6x7arbk

Conversation

@perfloop-agent

Copy link
Copy Markdown
Contributor

Description

Our earlier PR #533 (perfloop-agent, merged as 7900bdc) made scattered BatchEqual queries that reach its scan slower than the trie it replaced, because the scan called GetValue, which probes every bit plane, for every existing column. In the AgeRandom p2-w2 row below, elapsed time rose from 521 ms with the trie to 3,257 ms with #533's scan, while allocated bytes fell from 230 MB to 158 MB; about 112 MB of the scan's allocation is the materialized column-ID array (4 bytes for each of 28,043,123 columns).

This replaces the general scan with bounded container decoding. A bit plane is a bitmap of columns whose value has one particular bit set. The scan first applies the bits shared by all query values, then decodes up to sixteen varying planes into small keys and tests those keys against a query bitset. If fewer than 100,000 columns remain after the fixed planes, the unchanged trie finishes the query on those columns. Otherwise, a key that covers every varying plane matches directly; queries with more than sixteen varying bits, and containers with fewer than 1,024 remaining columns, still receive an exact GetValue check.

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?

  • Reuse the normalized query map, and decode planes in reusable batches rather than materializing all existing column IDs.
  • Filter fixed planes before testing varying bits. If that leaves fewer columns than the existing scan threshold, use the unchanged trie on the narrowed set, starting below its shared upper bits.
  • Keep the complete-cube shortcut: when the query covers every combination of its varying bits, only fixed planes constrain the result.
  • Add general scattered and composite-query shapes in BitSliceIndexing/bsi_scattered_benchmark_test.go, and worker subbenchmarks for the existing M128Scattered, M128 and M200 benchmarks in BitSliceIndexing/bsi_benchmark_test.go.

Why was it changed?

The old fallback traded lower allocations for repeated full-value reconstruction. Removing the column-ID array alone does not remove those bit-plane lookups. Without a fixed-plane filter, columns from other categories also reach value checks.

How was it changed?

  • Each worker owns a container-sized key buffer, its iterator batches and its result. Only columns from the authoritative existence bitmap can be emitted.
  • Small, sparse column containers keep direct value checks rather than decoding a full container.
  • Worker count is capped by requested parallelism, available CPUs, GOMAXPROCS and the scratch budget. Nonpositive arguments select the CPU count before those caps. Inputs retain BSI's existing caller-provided concurrency guards.

Testing

The Go tests passed for the BitSliceIndexing package. The committed differentials compare against a GetValue scan on the deserialized age fixture, narrow and wide indexes, composite values, run-optimized bitmaps, signed cubes, extra planes above bit 63, stray plane bits, sparse IDs, prefix collisions, the maximum column ID and worker argument categories. They also check result isolation. The age differentials use fewer worker arguments and skip with testing.Short(); the synthetic shapes retain negative, default, explicit and large worker arguments.

We also compared results with the pre-#533 trie locally; that check is not part of this PR.

Formatting

The changed Go files are formatted, and the package's type-conversion check passes.

Fuzzing

Not run. The template's smat fuzzer exercises the root roaring package, not BitSliceIndexing.

Performance Impact

These are local runs, not production measurements. All arms used Go 1.24.4 and the same benchmark inputs. Each row has three interleaved triplets, with varied arm order and a fresh process per invocation. Values are separate arm medians and median paired percentage changes; negative changes mean lower cost. M128 and M200 are controls, not improvement claims.

The scan comparator is base 33586ce, which contains #533's scan, plus this PR's benchmark files and BitSliceIndexing/bsi_scattered_test.go. Both comparison binaries compile the same added native tests; those tests are not run during timing. The trie comparator is a modified build of that same base using the BatchEqual body from public commit 873a1e356cf5dd8688f43c7fcd82dccd159a9b63. It is not a measurement of that entire historical checkout.

The Go runtime reported four available CPUs. p2-w2 means GOMAXPROCS=2 and worker argument 2; p2-w0 means GOMAXPROCS=2 and worker argument 0; default-w0 leaves GOMAXPROCS at its default of four and passes worker argument 0. For the candidate, p2-w0 and p2-w2 run the same two workers: worker argument 0 selects four CPUs, and GOMAXPROCS caps that at two. Cube shortcuts and narrowed tries do not launch scan workers.

The age rows use the checked-in fixture and its authoritative existence bitmap. Synthetic rows have 300,000 columns: fourteen-bit values, random wide values with 200 or 1,000 query values, stride-two column IDs with 1,000 query values, and wide values sharing their top sixteen bits with 200 query values. Composite22 and CompositeWide store a category and a sixteen-bit code, with the category starting at bit 16 or bit 40 respectively. They query 200 codes within one category, so filtering the fixed category bits matters. About 4,700 columns remain after the category filter, so these rows measure the narrowed trie, not the scan workers.

Go benchmark-format per-metric medians for AgeRandom with two-way Go parallelism:

p2-w2 trie (per-metric medians of three local runs)
BenchmarkBatchEqualScattered/AgeRandom/Workers2-2 1 520940698 ns/op 229682224 B/op 181804 allocs/op
p2-w2 scan (per-metric medians of three local runs)
BenchmarkBatchEqualScattered/AgeRandom/Workers2-2 1 3257327252 ns/op 157836496 B/op 23767 allocs/op
p2-w2 candidate (per-metric medians of three local runs)
BenchmarkBatchEqualScattered/AgeRandom/Workers2-2 1 390700493 ns/op 46242816 B/op 23804 allocs/op
Complete local comparison matrix
Mode / shape Trie ns/op Scan ns/op After ns/op Trie B/op Scan B/op After B/op Paired time vs trie Paired time vs scan Paired B/op vs trie Paired B/op vs scan Pairs
p2-w2 / AgeRandom 520940698 3257327252 390700493 229682224 157836496 46242816 -25.06% -88.08% -79.87% -70.70% 3
p2-w2 / AgeNearCube 835197007 3132835853 321751765 285047440 158533584 46940352 -61.48% -89.69% -83.53% -70.39% 3
p2-w2 / Narrow14 4862695 25439890 3689854 2893582 1273684 391946 -24.12% -85.50% -86.45% -69.23% 3
p2-w2 / Wide200 5198428 89125006 4552523 2873578 1226872 359476 -12.43% -94.98% -87.49% -70.70% 3
p2-w2 / Wide1000 10638726 85994921 5519044 4721672 1304540 404940 -47.56% -93.48% -91.42% -68.96% 3
p2-w2 / WideStride2 22448891 122448706 7346449 5710358 1304402 405044 -66.80% -94.00% -92.91% -68.95% 3
p2-w2 / WideSharedPrefix 5318325 86766755 5420248 2844400 1226812 368502 -8.95% -93.80% -87.04% -69.97% 3
p2-w2 / Composite22 544074 35977701 551897 276159 1243452 276215 +0.04% -98.47% +0.02% -77.79% 3
p2-w2 / CompositeWide 547489 56286970 561961 276159 1243456 276214 +2.64% -98.99% +0.02% -77.79% 3
p2-w2 / M128Scattered 832008011 3150746246 5996331 285024128 158533664 9343392 -99.28% -99.81% -96.72% -94.11% 3
p2-w2 / M128 2228052 2259503 2197774 9343343 9343343 9343391 -1.94% -2.73% +0.00% +0.00% 3
p2-w2 / M200 5710773 5926728 5664774 9566336 9566336 9566384 -1.31% -3.39% +0.00% +0.00% 3
p2-w0 / AgeRandom 528500986 3247568447 397721528 229682320 155624088 46243280 -23.59% -87.75% -79.87% -70.29% 3
p2-w0 / AgeNearCube 829835623 3145991537 313628920 285047632 158596464 46939904 -62.21% -89.96% -83.53% -70.40% 3
p2-w0 / Narrow14 4936329 24980030 4540824 2893582 1286522 391288 -12.63% -84.18% -86.48% -69.59% 3
p2-w0 / Wide200 5343757 86458700 4115493 2874262 1227636 359308 -23.48% -95.04% -87.50% -70.73% 3
p2-w0 / Wide1000 11008305 82800260 5459475 4721676 1305260 404828 -50.41% -93.44% -91.43% -68.98% 3
p2-w0 / WideStride2 22592926 111653755 7316714 5710350 1305302 405044 -68.83% -93.45% -92.91% -68.97% 3
p2-w0 / WideSharedPrefix 5670280 84607624 4786843 2844428 1227602 368560 -14.36% -94.30% -87.04% -69.97% 3
p2-w0 / Composite22 553484 35909760 535037 276158 1252406 276215 -3.85% -98.49% +0.02% -77.95% 3
p2-w0 / CompositeWide 561635 58948811 539868 276158 1252883 276215 -2.58% -99.08% +0.02% -77.95% 3
p2-w0 / M128Scattered 856520862 3165936931 4995638 285024048 158596256 9343392 -99.42% -99.85% -96.72% -94.11% 3
p2-w0 / M128 2274539 2254326 2161111 9343343 9343343 9343391 -3.34% -3.78% +0.00% +0.00% 3
p2-w0 / M200 5764053 5735300 5651956 9566336 9566335 9566383 -1.10% -0.95% +0.00% +0.00% 3
default-w0 / AgeRandom 542122198 1854664736 252708485 229682256 155624192 46881160 -52.84% -86.37% -79.59% -69.88% 3
default-w0 / AgeNearCube 834850775 1764061054 217863952 285047504 156328040 47580064 -73.90% -87.65% -83.31% -69.56% 3
default-w0 / Narrow14 4885837 17765052 4148767 2893636 1289201 743376 -14.99% -76.41% -74.31% -42.34% 3
default-w0 / Wide200 5220556 53902357 4978953 2874288 1227738 701980 -2.04% -91.12% -75.57% -42.82% 3
default-w0 / Wide1000 10896450 56740894 5842259 4721736 1305570 747332 -54.28% -89.99% -84.17% -42.75% 3
default-w0 / WideStride2 22588440 82386927 6406745 5710384 1305528 747766 -71.65% -92.23% -86.91% -42.72% 3
default-w0 / WideSharedPrefix 5176891 51634279 4829835 2845092 1227712 712128 -7.49% -90.95% -74.97% -41.99% 3
default-w0 / Composite22 529491 24206546 571187 276160 1257875 276216 +8.06% -97.61% +0.02% -78.04% 3
default-w0 / CompositeWide 541691 39061703 544811 276159 1258024 276216 +0.85% -98.61% +0.02% -78.04% 3
default-w0 / M128Scattered 831863302 1761692502 4949624 285024192 156328664 9343392 -99.41% -99.72% -96.72% -94.02% 3
default-w0 / M128 2165618 2208770 2200969 9343344 9343343 9343391 +2.15% +0.46% +0.00% +0.00% 3
default-w0 / M200 5721451 5874074 5771733 9566337 9566338 9566384 +0.17% -1.65% +0.00% +0.00% 3

Running Benchmarks

Compile one test binary per arm. For each row, run the three arms once each, in a different order each time:

go test -c -o bsi.test ./BitSliceIndexing
cd BitSliceIndexing
../bsi.test -test.run='^$' -test.bench='^BenchmarkBatchEqualScattered$/^AgeRandom$/^Workers2$' -test.benchtime=1x -test.benchmem -test.count=1

Repeat that triplet three times per row. For the p2 rows, set GOMAXPROCS to 2 before starting the process; leave it unset for the default rows. Select DefaultWorkers for zero-worker-argument cases. Use one shape per invocation: AgeRandom and AgeNearCube use 1x; Narrow14, Wide200, Wide1000, WideStride2 and WideSharedPrefix use 8x; Composite22 and CompositeWide use 64x. The existing BenchmarkBatchEqualM128Scattered uses 1x, and BenchmarkBatchEqualM128/BenchmarkBatchEqualM200 use 256x, with the same worker subbenchmarks.

For both comparison arms, first copy BitSliceIndexing/bsi_scattered_test.go from this PR so that the same native test coverage is compiled in. For the trie comparison, start from base 33586ce without any of this PR's bsi.go changes, add a benchmark-only method with the BatchEqual body from 873a1e3 (which calls the unchanged matchTrie), and route the benchmark call to it. For the scan comparison, add this PR's benchmark files to base 33586ce without any of this PR's bsi.go changes.

Performance Analysis

All measured scattered shapes allocated fewer bytes than the old scan. The p2-w0 Wide200 row has an after median of 4115493 ns/op versus 5343757 ns/op with the trie, with a median paired change of -23.48%. Composite rows and the contiguous controls contain estimates in both directions. These short local comparisons do not by themselves establish a statistical no-regression guarantee. In some rows the arm medians and the median paired change differ in sign; for example, p2-w2 WideSharedPrefix has an after median of 5420248 ns/op against 5318325 ns/op for the trie, but a median paired change of -8.95%. With three pairs, both estimates are within run-to-run noise.

Limits: B/op measures Go allocated bytes, not peak resident memory.
The measurements do not establish production traffic frequency or impact.

Breaking Changes

No API signature or query-result changes. The documented worker caps can select fewer workers than requested.

Related Issues

Follow-up to #533.

Additional Notes

The existing scan/trie entry thresholds and result run-optimization behavior are unchanged. The trie implementation is unchanged. Because the scan now receives the normalized query map, queries with eight or fewer values allocate that map on the heap (two allocations per call).

Case and evidence


Made by Perfloop. Human sponsor: Tomás Senart. To ask for a change, comment or review in plain words. How Perfloop handles pull requests.

Signed-off-by: Perfloop Agent <agent@perfloop.ai>
Signed-off-by: Tomás Senart <ts@perfloop.ai>
Signed-off-by: Perfloop Agent <agent@perfloop.ai>
Signed-off-by: Tomás Senart <ts@perfloop.ai>
Signed-off-by: Perfloop Agent <agent@perfloop.ai>
Signed-off-by: Tomás Senart <ts@perfloop.ai>
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