AVX2 popcount#530
Merged
Merged
Conversation
What
----
Adds an AVX2 implementation of the five slice population-count routines
(popcntSlice and the And/Or/Xor/Mask variants) in popcnt_avx2_amd64.s,
selected at runtime by a hand-rolled CPUID/XGETBV feature check
(_hasAVX2 -> useAVX2). popcnt_generic.go is retargeted to cover only
the non-AVX2 paths, and the dead pre-Go-1.9 scalar assembly
(popcnt_asm.go, popcnt_amd64.s) is removed.
Why
---
The old amd64 assembly was guarded by the build tag "!go1.9", so on
every modern toolchain it was never compiled. All population counting
fell through to popcnt_generic.go -> popcnt_slices.go, a scalar loop
calling math/bits.OnesCount64 one 64-bit word at a time. In other words
amd64 had no SIMD acceleration at all. These routines are hot: a bitmap
container is a fixed 1024-word (8 KB) array, and a popcnt*Slice runs
after essentially every bitmap/bitmap AND/OR/XOR/ANDNOT to recompute
cardinality, plus in rank(), getCardinalityInRange() and
computeCardinality().
How / kernel
------------
The kernel is the Mula/Lemire VPSHUFB nibble-lookup popcount: each
256-bit lane is split into low/high nibbles, counted via a 16-entry
table lookup, summed byte-wise, then folded to quadwords with VPSADBW
and accumulated. Loads are unaligned (VMOVDQU) because the bitmap slices
are only 8-byte aligned; VZEROUPPER precedes every return; a scalar
POPCNTQ tail handles the final len%4 words so the routines are correct
for any length. Detection checks OSXSAVE+AVX (CPUID leaf 1), YMM OS
support (XGETBV/XCR0) and AVX2 (CPUID leaf 7) before enabling.
Fallback behavior is preserved exactly: amd64 CPUs without AVX2 take the
scalar path at runtime, and appengine / non-amd64 builds compile
popcnt_generic.go. A differential test checks the AVX2 output against
the Go reference across edge lengths {0,1,2,3,4,5,7,8,15,16,17,31,63,
64,65,1023,1024,1025}, and a dispatch test forces useAVX2=false to
exercise the fallback on amd64 too.
Speed
-----
On a full 1024-word container (AMD Ryzen AI 7 PRO 350):
popcntSlice 599 ns -> 331 ns (~1.8x)
popcntAndSlice 1108 ns -> 408 ns (~2.7x)
Document the AVX2 popcount assembly more thoroughly. Comments only -- no instruction was changed; the build, go vet's asm checker, and the differential tests all still pass. - Header split into an algorithm overview (the Mula/Lemire VPSHUFB nibble-lookup popcount) and a Go-assembler conventions section (operand order, Yn/Xn registers, FP argument layout, NOSPLIT/$0 leaf frames, and why VMOVDQU and VZEROUPPER are used). - lutmask: decode the constant blob -- how the bytes map to nibble popcounts, why the 16-entry table is duplicated per 128-bit lane, and what the 0x0F mask is for. - COUNTBLOCK: line-by-line walkthrough of the eight instructions plus the no-overflow reasoning; SETUP and HSUM expanded similarly. - _popcntSliceAVX2 fully annotated as the canonical template; the And/Or/Xor/Mask variants note they mirror it and differ only in the combine op (including the VPANDN operand-order detail) and explain the +48 return slot. - _hasAVX2: explain why each CPUID/XGETBV check matters.
Member
|
Merging. |
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.
Description
Add an AVX2 SIMD implementation of the five slice population-count routines (
popcntSliceand theAnd/Or/Xor/Maskvariants) on amd64, selected at runtime via a CPUID/XGETBV feature check, with the scalar Go path kept as a fallback. It also removes the old scalar assembly, which was more or less dead code: that code was gated behind a!go1.9build tag, meaning it stopped being compiled as of Go 1.9 - released in August 2017, roughly nine years ago - and has not been built by any supported toolchain since. The build tags are reworked accordingly.Type of Change
Changes Made
What was changed?
popcnt_avx2_amd64.s/popcnt_avx2_amd64.go: AVX2 kernels plus the_hasAVX2detection and thepopcnt*Slicedispatch wrappers.popcnt_generic.gobuild tag retargeted to!amd64 || appengine.popcnt_asm.goandpopcnt_amd64.s.Why was it changed?
The old amd64 assembly was guarded by
!go1.9, so on every modern toolchain it was never compiled and all population counting fell through to a scalar loop callingmath/bits.OnesCount64one 64-bit word at a time. That intrinsic already lowers to the hardwarePOPCNTinstruction, butPOPCNTis scalar - nothing counted bits using the wide vector (SIMD) registers, which is the speedup this change adds (the removed assembly was itself just a scalarPOPCNTQloop, so it would not have helped either). These routines are hot: a bitmap container is a fixed 1024-word (8 KB) array, and apopcnt*Sliceruns after essentially every bitmap/bitmap AND/OR/XOR/ANDNOT, plus inrank(),getCardinalityInRange()andcomputeCardinality().How was it changed?
VPSHUFBdoes all 32 lookups at once), then add the per-byte counts together (VPSADBW). When a slice length isn't a multiple of four, the leftover 1-3 words are finished with the CPU's ordinary one-word popcount instruction (POPCNTQ), so the result is correct for any length.CPUIDandXGETBVinstructions). If either check fails, the SIMD path is never taken.Testing
Passes (incl.
go test -tags appengine .for the fallback). A differential test checks the AVX2 output against the Go reference across edge lengths{0,1,2,3,4,5,7,8,15,16,17,31,63,64,65,1023,1024,1025}, and a dispatch test forces the scalar path to exercise it on amd64 too.Fuzzing
No serialization/parsing surface is touched; the existing fuzz harness continues to build and run.
Performance Impact
Significant speedup on the popcount functions.
Running Benchmarks
go test -bench=BenchmarkPopcnt -run=^$Performance Analysis
Full 1024-word container (AMD Ryzen AI 7 PRO 350):
benchstatreports a 13.0% speed improvement over the entire benchmark suite (geomean).Breaking Changes
None. Public API and behavior are unchanged.
AI Disclosure
This change was developed with AI assistance (Claude). I reviewed code carefully, understand how it works, and take responsibility for it. Correctness was verified independently of the model: a differential test checks the AVX2 output against the scalar Go reference across many slice lengths (including the non-vector tail),
go testandgo test -tags appenginepass,go vet's asm checker is clean, and the speedups were confirmed with benchmarks.