Use VBIT for the arm64 NEON AndNot popcount kernel - #549
Open
lemire wants to merge 1 commit into
Open
Conversation
_popcntMaskSliceNEON computes popcount(s &^ m). It was forming s &^ m as
an invert followed by an AND: a vector of all-ones was materialised once
in V15, and each iteration did four VEOR to produce ~m and then four VAND
to apply it. That is eight vector operations per 64-byte block to express
what the hardware can do in four.
arm64 has BIC (and-not) for exactly this, but the Go assembler has no
mnemonic for the vector form. VBIT does the same job in the same single
instruction. BIT ("bitwise insert if true") selects bit by bit between
its destination and one source under the control of the other:
Vd<i> = Vm<i> ? Vn<i> : Vd<i> i.e. Vd = (Vm & Vn) | (^Vm & Vd)
Holding Vn at zero reduces that to "clear every bit of Vd that is set in
Vm", which is Vd &^= Vm. So with Vd = s and Vm = m the mask is applied in
place, and V15 is now simply kept at zero to serve as Vn instead of
holding all-ones for the invert.
The inner loop drops from eight ops to four. The routine was ALU-bound
rather than load-bound, so this shows up directly:
popcntMaskSlice, 1024 words 137.7ns -> 107.3ns 1.28x
62 GB/s -> 80 GB/s
Measured on an Apple M4 Max, Go 1.24.4. The And/Or/Xor/Slice kernels are
untouched and unchanged. Container-level AndNot gains far less (~1%),
because andNotBitmap's cost is dominated by the scalar loop that writes
the result, not by this cardinality pass.
The observation that arm64 can do this in one instruction is due to
David Sparks, who also prompted the UADALP note now recorded in the file
header: folding byte lanes with UADALP would save an instruction per
iteration, but it has no Go assembler mnemonic and issues at roughly 2.4
per cycle on an M4 against 4 per cycle for VADD/VCNT/VUADDW, making it a
~20% loss for the plain popcount kernel.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Speeds up the arm64 NEON
AndNotcardinality kernel (_popcntMaskSliceNEON) by 1.28x, by expressings &^ min one instruction per vector instead of two.Credit for the observation goes to David Sparks, who pointed out that arm64 has a dedicated and-not in Advanced SIMD and that the kernel was not using it.
The problem
_popcntMaskSliceNEONcomputessum(popcount(s[i] &^ m[i])). Every other kernel in the file (And,Or,Xor) combines its two inputs with a single vector op before counting. TheMaskkernel could not, because it was written on the assumption that NEON has no and-not, so it synthesised one:Eight vector operations per 64-byte block, plus a register tied up holding all-ones, to express something the hardware does in four.
The fix
arm64 does have a vector and-not —
BIC— but the Go assembler has no mnemonic for the vector form (its arm64 mnemonic table hasVBIT,VBIF,VBSLandVBCAX, but noVBIC).VBITfits just as well and is spelled:What VBIT does
BITis "bitwise insert if true". It is a bit-by-bit select between the destination register and one source, under the control of the other source:Wherever the control register
Vmhas a 1 bit, the corresponding bit ofVnis inserted intoVd; whereverVmhas a 0 bit,Vdkeeps what it had. It is normally used for merging two vectors under a mask.Hold
Vnat zero and it degenerates into something more useful here: every bit set inVmis cleared inVd, and every other bit ofVdsurvives. That is preciselyVd &^= Vm. So withVd = sandVm = m, oneVBITapplies the mask in place.(Note that in Go assembly the destination is written last, so
VBIT Vm, Vn, Vdcorresponds to ARM'sBIT Vd, Vn, Vm.)V15 is still reserved, but it now holds zero — as the
Vnoperand — rather than all-ones for the invert. It is zeroed withVEORinstead ofVMOVI.Why this shows up as a real speedup
The inner loop goes from 8 combining ops to 4, i.e. 13 vector ALU ops per 64-byte block down to 9.
That matters because this kernel was ALU-bound, not load-bound. On an Apple M4 the NEON unit retires 4 vector ALU ops per cycle, and the loop's two
VLD1s (8 uops of 16 bytes) can issue in fewer cycles than the arithmetic needs, so removing four ops removes four ops' worth of wall clock rather than being absorbed by spare issue slots. The measured throughput lands almost exactly where the op count predicts.Benchmarks
Apple M4 Max, Go 1.24.4,
darwin/arm64. Medians of 6 runs.popcntMaskSlicepopcntAndSlicepopcntOrSlicepopcntXorSlicepopcntSlice62 GB/s → 80 GB/s on the Mask kernel. The gain holds down to short slices (1.16x at 64 words) and is neutral, not negative, on the sub-8-word tail path.
Container-level
AndNotgains far less — around 1%.andNotBitmapandiandNotBitmapSurelycall this kernel for the cardinality and then make a second pass, a scalar Go loop writingbc.bitmap[k] &^ value2.bitmap[k]over 1024 words. That write loop, not the cardinality pass, is where the time goes. Worth knowing before anyone reads too much into the 1.28x; a vectorised write loop is the larger prize and is not attempted here.Correctness
darwin/arm64.bits.OnesCount64over lengths 0–33000 (including every boundary at 1, 7, 8, 9, 15, 16, 17, 63, 64, 65, 1023, 1024, 1025, 8191, 8192, 8193) with both random and all-ones inputs, and stress-tested past theINNERMAXdrain boundary where the 16-bit lane accumulators would overflow if mis-sized.BITis baseline ARMv8-A Advanced SIMD — no feature detection needed, same as everything else in this file. On other implementations (Neoverse, Cortex-A)BITis rate-matched withAND, so the win should carry, though I have only measured M4.Alternatives measured and rejected
Recorded here so they don't get re-proposed:
UADALP(pairwise-add-long-and-accumulate) to fold byte lanes directly into the 16-bit accumulator would save one instruction per iteration. It has no Go assembler mnemonic, and hand-encoding it measures slower: an isolated throughput probe puts it at ~2.4 instructions/cycle against 4.0 forVADD/VCNT/VUADDW/VBIT. It costs the plain popcount kernel ~20%, and the regression is independent of how many accumulator chains it is split across (tested with 1, 2 and 4). A note to this effect is added to the file header. It is a small win (~4%) on the two-input kernels, which are load-bound, but not worth a raw instruction-word encoding.VUADDLVhorizontal reductions on the critical path where the current code pipelines one per word. Short slices are reachable in practice viapopcntSlice(bc.bitmap[:(x+1)/64])inrank.CSEL-based block-count setup in place of the compare-and-branch: correct and one instruction shorter, but perf-neutral (0.99–1.02x) since it runs once per call. Not worth churning hand-written assembly for.INNERMAXfrom 1024 to 2047: safe (2047x32 = 65504 fits a 16-bit lane), but changes nothing — a bitmap container is 128 blocks, so the drain runs once per call either way.