Skip to content

NEON-vectorized union2by2 - #538

Open
gitRasheed wants to merge 2 commits into
RoaringBitmap:masterfrom
gitRasheed:neon-union2by2-fastforward
Open

NEON-vectorized union2by2#538
gitRasheed wants to merge 2 commits into
RoaringBitmap:masterfrom
gitRasheed:neon-union2by2-fastforward

Conversation

@gitRasheed

Copy link
Copy Markdown

Description

NEON implementation of union2by2 for arm64. When both arrays have at least 384 elements, unions run through a 128-bit SIMD kernel that processes eight values per step; smaller inputs keep using the scalar assembly from #287.

The kernel is based on CRoaring's SSE union_vector16, adapted for ARM: a transposed bitonic merge network (the SSE rotate network is latency-bound on modern ARM cores and measured slower than the scalar code), a two-multiply replacement for PMOVMSKB, and a fast path that skips the merge network while incoming blocks do not interleave with the carried values.

Type of Change

  • Performance improvement
  • Test improvements

Changes Made

What was changed?

  • setutil_neon_arm64.s: the NEON kernel.
  • setutil_arm64.go: threshold dispatch, the shuffle table, and scalar tails that keep the existing caller contracts (iorArray aliasing, lazyorArray's zero-length buffer).
  • setutil_arm64.s: Union2by2 arm64 assembly #287's routine renamed union2by2scalar, unchanged.
  • Differential tests against the scalar path: aliasing geometry, buffer guard words, block-boundary duplicates, zero-length buffers.
  • BenchmarkUnion2By2, following the popcnt_bench_test.go layout. It rotates 16 fixed-seed datasets; a single fixed pair lets the branch predictor memorize the scalar path and understates the difference.

Why was it changed?

Closes #288.

How was it changed?

Hand-written NEON assembly in Go's assembler syntax plus a small Go wrapper, same approach as popcnt_neon_arm64.s.

Testing

go test passes.

Formatting

go fmt clean.

Fuzzing

-fuzz=FuzzSmat cannot run as written: the function is referenced by this template, the README, and smat.go, but its definition is not in the tree (it seems to have been lost in the October 2025 fuzzer rework). I ran the smat state machine through a small smat.Fuzz wrapper instead: 300 seconds, 1,373,418 executions on Graviton 4, no failures. Happy to send the wrapper as a follow-up PR.

Performance Impact

Measured on Graviton 4 (c8g.xlarge), Go 1.26.5, medians of repeated runs.

BenchmarkUnion2By2 from this PR; scalar rows are the #287 code:

BenchmarkUnion2By2/dense50/1024/dispatch-4     1374 ns/op    0 B/op   0 allocs/op
BenchmarkUnion2By2/dense50/1024/scalar-4       3412 ns/op    0 B/op   0 allocs/op
BenchmarkUnion2By2/dense50/4096/dispatch-4     5469 ns/op
BenchmarkUnion2By2/dense50/4096/scalar-4      22554 ns/op
BenchmarkUnion2By2/sparse6/4096/dispatch-4     5666 ns/op
BenchmarkUnion2By2/sparse6/4096/scalar-4      24693 ns/op
BenchmarkUnion2By2/runs16/1024/dispatch-4       396 ns/op
BenchmarkUnion2By2/runs16/1024/scalar-4        1476 ns/op
BenchmarkUnion2By2/runs16/4096/dispatch-4      1441 ns/op
BenchmarkUnion2By2/runs16/4096/scalar-4        5889 ns/op
BenchmarkUnion2By2/spread/4096/dispatch-4      3465 ns/op
BenchmarkUnion2By2/spread/4096/scalar-4        5016 ns/op

2.5x to 4x on random and run-structured shapes, no allocation changes.

On the real-roaring-datasets corpus (BENCH_REAL_DATA=1 go test -bench=BenchmarkRealDataFastOr), census1881 improves 1.25x. It is the one dataset dominated by large, mostly disjoint array unions, which is the shape this kernel targets. The other eleven datasets stay within a few percent of master; their unions are small, dense, or rare, so the kernel barely runs.

Trade-off: when two arrays are nearly identical, the scalar loop consumes both elements on every equal pair and its branch becomes predictable, so it does about half the work. The kernel does the same fixed work per block regardless, so that one shape favors scalar. None of the existing datasets that I benchmarked on have this shape.

Breaking Changes

None. arm64 only; output is identical to the scalar path.

Related Issues

Closes #288.
Related to #287.

128-bit SIMD kernel for array-container unions, dispatched when both
inputs have at least 384 elements; smaller inputs keep the scalar
assembly. Based on CRoaring's union_vector16, with a transposed bitonic
merge network (shorter dependency chain than the SSE rotate network,
which measured slower than the scalar code on Neoverse cores), a
two-multiply movemask replacement, and a skip over the merge network
when the incoming block cannot interleave with the carried values.
Differential tests cover the caller contracts: iorArray's aliased
buffer, lazyorArray's zero-length buffer, block-boundary duplicates,
and store overrun guards.
When the disjoint check passes, stay in a small loop that selects
blocks with a predicted branch instead of returning to the main loop's
CSEL select. On run-structured data the branch predicts and the
untaken cursor stays off the loop-carried dependency chain; the first
interleaving block merges and rejoins the main loop. Run-structured
unions improve about 2.5x over the base kernel; random data never
enters the loop.
@gitRasheed gitRasheed changed the title arm64: NEON-vectorized union2by2 NEON-vectorized union2by2 Jul 31, 2026
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.

Use vector instructions for union2by2

1 participant