Skip to content

NEON-vectorized intersection2by2 - #543

Open
gitRasheed wants to merge 1 commit into
RoaringBitmap:masterfrom
gitRasheed:neon-intersect2by2
Open

NEON-vectorized intersection2by2#543
gitRasheed wants to merge 1 commit into
RoaringBitmap:masterfrom
gitRasheed:neon-intersect2by2

Conversation

@gitRasheed

@gitRasheed gitRasheed commented Aug 9, 2026

Copy link
Copy Markdown

Description

NEON implementations of intersection2by2 and intersection2by2Cardinality for arm64. Arrays where both sides have at least 16 elements run through a 128-bit SIMD kernel; smaller or heavily skewed inputs keep the existing scalar code, and the galloping dispatch is untouched.

NEON has no cmpestrm (the gap noted in #288), so the kernel compares each set1 block against all eight rotations of set2's block, reduces the result with a two-multiply movemask, and compacts matched lanes through a 256-entry shuffle table. A range gate fast-forwards disjoint runs on scalar boundary loads. The in-place caller is safe: when output may have overwritten the current set1 block, the kernel hands it back in registers and the wrapper finishes from that copy. The boolean intersects2by2 stays scalar because its early exit beats fixed vector work.

Type of Change

  • Performance improvement
  • Test improvements

Changes Made

What was changed?

  • setutil_intersect_neon_arm64.s: the two kernels.
  • setutil_intersect_arm64.go: dispatch, shuffle table, spill drain.
  • setutil.go / setutil_generic.go: the generic dispatch moved beside the other non-arm64 dispatches, unchanged.
  • Differential tests: exact-capacity canaries, the iandArray in-place aliasing geometry, three-way self-intersection, duplicate-input no-panic bounds with a deserialize round trip, misalignment offsets.
  • BenchmarkIntersect2By2 and BenchmarkIntersectCard2By2, rotating eight seeded datasets between iterations.
  • TestAndSelfInPlace pins Bitmap.And with the same receiver.

Why was it changed?

Intersection had no vector path on any architecture here, and it is one of the top set-operation costs in real-roaring-datasets profiles.

How was it changed?

NEON assembly in Go's assembler syntax plus a small Go wrapper, as in popcnt_neon_arm64.s.

Testing

go test passes for all packages on Graviton 4; the kernel suite also passes on Graviton 2 and Apple M1.

Formatting

go fmt clean.

Fuzzing

smat fuzz (through the wrapper noted in #538): 585,864 executions, no failures.

Performance Impact

Measured on Graviton 4 (c8g.xlarge). BenchmarkIntersect2By2 from this PR; dispatch is the new path, scalar the old:

BenchmarkIntersect2By2/dense50/16/dispatch          19.3 ns/op
BenchmarkIntersect2By2/dense50/16/scalar            27.0 ns/op
BenchmarkIntersect2By2/dense50/64/dispatch          52.5 ns/op
BenchmarkIntersect2By2/dense50/64/scalar           104.7 ns/op
BenchmarkIntersect2By2/dense50/256/dispatch        182.6 ns/op
BenchmarkIntersect2By2/dense50/256/scalar          447.6 ns/op
BenchmarkIntersect2By2/dense50/4096/dispatch        2823 ns/op
BenchmarkIntersect2By2/dense50/4096/scalar         23762 ns/op
BenchmarkIntersect2By2/skew8/4096/dispatch         28382 ns/op
BenchmarkIntersect2By2/skew8/4096/scalar           52158 ns/op

1.4x at the dispatch floor, 8.4x at container size, no allocation changes. Existing benchmarks against master on the same box:

BenchmarkIntersectionLargeRoaring    3.89ms -> 1.00ms   (3.9x)
BenchmarkIntersectionLargeParallel   4.63ms -> 1.56ms   (3.0x)

The floor of 16 comes from a sweep of sizes 8 to 96 on Neoverse N1, Neoverse V2, and Apple M1: sizes 8 to 12 lose to scalar on every core, and from 16 up every measured shape wins on all three.

Trade-off: arrays with duplicate values pass Validate but break the sorted-set precondition the scalar code also assumes. They get a sorted result with every store inside the buffer and no panic; the reported size can over-count, the same wrong-answer class as the scalar path. Tests pin sortedness, bounds, and panic-freedom, including through the marshal, unmarshal, And route.

Breaking Changes

None.

Related Issues

Related to #538 (the union kernel, same approach).
Related to #288 (the cmpestrm limitation this kernel works around).

@gitRasheed
gitRasheed force-pushed the neon-intersect2by2 branch 2 times, most recently from 454f892 to b3938c9 Compare August 10, 2026 20:40
All-pairs 8x8 block compare with a two-way range gate that fast-forwards
disjoint runs on scalar boundary loads. The materializing kernel compacts
matched lanes through a mask-indexed shuffle table with dual store bounds
for the exact-capacity and in-place caller contracts, and spills a
retained block when output may alias unread input. Dispatch floor 16 from
a size sweep on three core families; the galloping paths and the boolean
variant keep the existing scalar code.
@gitRasheed

Copy link
Copy Markdown
Author

btw, while working on this kernel, I noticed that Go's Validate allows adjacent duplicates because it only checks previous > next; CRoaring, Java, and Rust all require strictly increasing values and reject them with previous >= next. I ended up having to bound the assembly stores to cope with those duplicates, so is Go's looser validation intentional, or should Validate reject them instead?

@lemire

lemire commented Aug 11, 2026

Copy link
Copy Markdown
Member

@gitRasheed Thanks. You are correct. I fixed this with a subsequent PR.

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