Go 1.27 SIMD Beats C: Pure Go TurboPFor Rewrite Hits 7 IPC with AVX-512 Positional Popcount

Debian Code Search replaced its 7-year-old C TurboPFor library with pure Go using Go 1.26/1.27's experimental simd/archsimd package, achieving 3x speedup via AVX2 vertical layout and a further 2x via AI-suggested AVX-512 positional popcount (GF2P8AFFINEQB + VPOPCNTB), reaching 7 IPC near hardware limits.

TonyBai
TonyBai
TonyBai
Go 1.27 SIMD Beats C: Pure Go TurboPFor Rewrite Hits 7 IPC with AVX-512 Positional Popcount

A 7-Year cgo Knot Finally Untied

Debian Code Search (DCS) is a search engine indexing all Debian source code. Its inverted index stores massive integer lists compressed with the TurboPFor format. For seven years DCS relied on the C library powturbo/TurboPFor via cgo, but the project was designed as pure Go and the author disliked the C dependency. SIMD-level performance was previously unreachable in Go.

Three Paths to SIMD Before Go 1.26

Hand-written Go assembly : Only viable for tiny functions (e.g., bytes.IndexByte), poor readability and maintainability.

Assembly generators : Tools like Avo (https://github.com/mmcloughlin/avo) (used for crypto/internal/fips140/sha256), still essentially assembly programming.

cgo calling C libraries : Let gcc/clang compile real SIMD code. DCS took this route for seven years.

Each path has drawbacks: assembly is hard to maintain, generators have high learning curves, cgo introduces a foreign language complicating cross-compilation and builds.

Go 1.26/1.27 Experimental simd/archsimd Package

Go 1.26 introduced the experimental simd/archsimd package, enabled with GOEXPERIMENT=simd . It provides architecture-specific SIMD operations for amd64: 128/256/512-bit vector types (e.g., Int8x16 , Float64x8 ) and operations like Int8x16.Add . API is not yet stable.

By Go 1.27 the package was mature enough for production validation.

Starting Point: A Naive But Working Encoder

The author first defined a clean API for three scenarios: local indexing (incremental encoding), index merging (bulk encoding), and query decoding (high-concurrency decoding). The initial encoder simply wrote all values as 32-bit little-endian with a 1-byte block header per 256 values — terrible compression but a working baseline. Then he implemented the real TurboPFor block types: bitpacking, bitpacking with exceptions, bitpacking with VB exceptions, and constant blocks.

A key discovery emerged: the encoder's real cost was scanning input values to choose block types , not the encoding itself — this insight later enabled the 2x "AI bonus".

The naive Go encoder already reached 76% of the C version's speed.

Measurement Setup

GOAMD64 : Set microarchitecture level. v1=baseline, v2=+POPCNT/SSE4.2, v3=+AVX/AVX2/BMI/FMA, v4=+AVX512. Author recommends v3 minimum; DCS uses v4 on AMD Zen 4/5.

Benchmarks + benchstat : Go's testing package with golang.org/x/perf/cmd/benchstat for statistical comparison; taskset pins to fixed cores.

perf hardware counters : Beyond pprof's "where", perf's Top-Down Analysis reveals "why" (branch mispredictions, instruction throughput, etc.).

First Wave: Scalar Optimizations (No SIMD Yet)

1. PGO: A 13% Regression from Unlucky Alignment

Profile-Guided Optimization (Go 1.21+) should help, but enabling PGO slowed code by 13%. The compiler added PCALIGNMAX(64,31) to align hot loops to 64-byte cache lines. On AMD Zen 5 this is usually good, but a macro-fused CMPQ+JGE pair landed on a 32-byte boundary, triggering the Intel SKX102 erratum workaround: the compiler inserted extra NOP s, hurting an already instruction-fetch-bound loop. Lesson: compiler optimizations are not monotonic; side effects matter.

2. Reduce Allocations: +11%

The teaching decoder allocated temporary buffers via make() each call, hitting runtime.makeslice. Moving buffers to pre-allocated struct fields (reuse) lifted debian-mix from 773 to 858 Mval/s (+11%) and stabilized benchmarks by removing GC from the hot path.

3. Generic Bit-Width Specialization: +40–64%

TurboPFor's bitpacking core depends only on value count and bit-width. If both are compile-time constants, the compiler can fully unroll loops into branch-free shift/mask/store sequences. The author fixed input count at 32 and used Go generics with array types whose length encodes bit-width:

type bitWidthT interface {
    [1]byte | [2]byte | [3]byte | [4]byte | [5]byte | [6]byte | [7]byte | [8]byte | [9]byte | [10]byte |
    [11]byte | [12]byte | [13]byte | [14]byte | [15]byte | [16]byte | [17]byte | [18]byte | [19]byte | [20]byte |
    [21]byte | [22]byte | [23]byte | [24]byte | [25]byte | [26]byte | [27]byte | [28]byte | [29]byte | [30]byte |
    [31]byte | [32]byte
}

func bitpack32Unrolled[T bitWidthT](dest []byte, vals *[32]uint32) {
    var zero T
    bitWidth := len(zero)          // compile-time constant
    dest = dest[:4*bitWidth : 4*bitWidth] // capacity also constant
    mask := uint32(1<<bitWidth - 1)
    var acc uint64
    var have, pos int
    // loop body manually unrolled for vals[0]..vals[31]
    acc |= uint64(vals[0]&mask) << have
    have += bitWidth
    if have >= 32 {
        binary.LittleEndian.PutUint32(dest[pos:pos+4], uint32(acc))
        pos += 4
        acc >>= 32
        have -= 32
    }
    // ...
}

The compiler instantiates a separate function per bit-width, generating near-optimal machine code. Remainder blocks (<256 values) saw 40–64% gains:

vals=bitpacking-bw1   751.2 → 1120.5 Mval/s (+49.15%)
vals=bitpacking-bw2   716.8 → 1176.0 Mval/s (+64.07%)
vals=bitpacking-bw7   700.0 → 1078.5 Mval/s (+54.08%)
vals=debian-mix       559.5 →  783.8 Mval/s (+40.09%)

Binary size grew ~20 KB in .text — deemed worthwhile.

Second Wave: Real SIMD

1. SIMD Build Tags: Runtime Detection + Compile-Time Switch

Not all CPUs support AVX2/AVX512. The standard three-file pattern:

// constant_nosimd.go — fallback
//go:build !goexperiment.simd || !amd64
func fillConstant(output []uint32, val uint32) { fillConstantScalar(output, val) }
// constant_amd64.go — SIMD impl with runtime AVX2 check
//go:build goexperiment.simd && amd64
import "simd/archsimd"
var hasAVX2 = archsimd.X86.AVX2()
func fillConstant(output []uint32, val uint32) {
    if !hasAVX2 { fillConstantScalar(output, val); return }
    val8 := archsimd.BroadcastUint32x8(val)
    for i := 0; i+8 <= len(output); i += 8 {
        val8.StoreArray((*[8]uint32)(output[i:i+8]))
    }
    fillConstantScalar(output[i:], val)
}

With GOAMD64=v3+, hasAVX2 can be a compile-time constant true, eliminating the runtime branch. DCS used AVX2, AVX512, and finer subsets (AVX512+VBMI+GFNI+BITALG) per function.

2. 256-Value Vertical Layout: AVX2 Delivers 3x

TurboPFor defines a "vertical layout" for 256 uint32 values, processing 8 at a time to match AVX2 register width. Scalar version uses eight uint64 accumulators with an inner for i := range 8 loop. SIMD version packs 8 values into one Uint32x8 vector, eliminating that inner loop. Because AVX2 registers hold only 8 uint32 (not uint64), the accumulator splits into rest8 and

cur8
Uint32x8

vectors.

Scalar decoder (simplified):

func bitunpack256v32(input []byte, dest []uint32, bitWidth int) (read int) {
    mask := uint64(1)<<bitWidth - 1
    var bits uint
    var acc [8]uint64
    for op := 0; op < len(dest); {
        if bits < uint(bitWidth) {
            for i := range 8 {
                acc[i] |= uint64(binary.LittleEndian.Uint32(input)) << bits
                input = input[4:]
            }
            bits += 32
        }
        for i := range 8 {
            dest[op] = uint32(acc[i] & mask)
            op++
            acc[i] >>= bitWidth
        }
        bits -= uint(bitWidth)
    }
    return len(dest)
}

SIMD decoder (AVX2):

func bitunpack256v32(fullinput []byte, fulldest []uint32, bitWidth int) (read int) {
    dest := fulldest[:256]
    n := 32 * bitWidth
    input := fullinput[:n]
    mask8 := archsimd.BroadcastUint32x8(uint32(1<<bitWidth - 1))
    bitWidth8 := archsimd.BroadcastUint32x8(uint32(bitWidth))
    var bits uint
    pos := 0
    var rest8, cur8 archsimd.Uint32x8
    for op := 0; op < 256; op += 8 {
        if bits < uint(bitWidth) {
            next := archsimd.LoadUint8x32(input[pos:pos+32]).ReshapeToUint32s()
            pos += 32
            cur8 = rest8.Or(next.ShiftAllLeft(uint64(bits)))
            rest8 = next.ShiftAllRight(uint64(bitWidth) - bits)
            bits += 32
        } else {
            cur8 = rest8
            rest8 = rest8.ShiftRight(bitWidth8)
        }
        cur8.And(mask8).Store(dest[op:op+8])
        bits -= uint(bitWidth)
    }
    return n
}

Benchmark: ~3x faster than scalar. Combined with generic bit-width specialization (making bitWidth compile-time constant), further gains.

3. Positional Popcount: AI-Discovered 2x "Black Magic"

After SIMD packing and AVX512 exception collection, encoder performance matched cgo. Then Claude Fable 5 identified the remaining bottleneck: the scan phase that builds a histogram of exception counts per bit-width.

type stats struct {
    cnt [32+24]uint32 // cnt[n] = how many values have bits.Len32(val) > n
}

func scan(output *stats, vals []uint32) {
    for _, val := range vals {
        for b := range bits.Len32(val) {
            output.cnt[b]++
        }
    }
}

For each value, the inner loop increments counters for all bit-widths below its actual bit-length. Example: value 23 (binary 0000010111, needs 5 bits) increments cnt[0]..cnt[4]. This is a positional popcount — counting 1s column-wise across all values' "smear masks" (highest 1 propagated to all lower bits). Standard POPCNT counts row-wise (per value), not column-wise.

Author surveyed three papers:

Klarqvist, Muła, Lemire (2019): AVX-512 carry-save adder approach.

Harold Aptroot (2024): GF2P8AFFINEQB -based implementation.

Clausecker, Lemire, Schintke (2025): Improved version for AVX2/AVX-512/ASIMD.

Chose the GF2P8AFFINEQB path (also used in Go's 2025 Green Tea GC).

Go SIMD implementation (processes 16 values per iteration):

func scanSIMD(output *stats, vals []uint32) {
    ones16 := archsimd.BroadcastUint32x16(^uint32(0))
    shuffle := archsimd.LoadUint8x64Array(&scanShuffle)
    units := archsimd.LoadUint8x64Array(&scanUnits)
    var acc archsimd.Uint8x64
    idx := 0
    for ; idx+16 <= len(vals); idx += 16 {
        v := archsimd.LoadUint32x16(vals[idx:idx+16])
        // replace each value with its smear mask
        smear := ones16.ShiftRight(v.LeadingZeros()).ReshapeToUint8s()
        // transpose bytes then bits
        matrices := smear.Permute(shuffle).ReshapeToUint64s()
        transposed := units.GaloisFieldAffineTransform(matrices, 0)
        // popcount 64 bytes at once and accumulate
        acc = acc.Add(transposed.OnesCount())
    }
    sum := acc.GetLo().ExtendToUint16().Add(acc.GetHi().ExtendToUint16())
    sum.GetLo().ExtendToUint32().Store(output.cnt[0:16])
    sum.GetHi().ExtendToUint32().Store(output.cnt[16:32])
    // scalar tail
    for _, val := range vals[idx:] {
        for b := range bits.Len32(val) { output.cnt[b]++ }
    }
}

Scalar fast-scan: ~12 instructions per value. SIMD orthogonal transform: ~1.5 instructions per value — 8x faster on the scan kernel, translating to a full 2x overall encoding speedup .

Performance Evolution Summary

(See article's summary chart.) The final Go encoder surpasses the historical cgo version. Decoder reaches 7 IPC (instructions per cycle) on a CPU with 8 IPC theoretical max — extremely close to hardware limits.

How Far Is Go From C Now?

If the same AVX-512 kernels and positional popcount were ported back to C, Go still lags ~1.4x. Five reasons:

Remaining scalar paths : VB exception handling, multi-bit-width pricing logic — further SIMD possible but increases complexity.

Bounds checking : Go's safety tax; future hope is smarter compiler prove passes.

Mid-stack inlining NOP padding : Inline markers insert NOPs, hurting fetch-bound loops.

No per-CPU tuning : GOAMD64 only goes to v3/v4, not specific models (e.g., AMD Zen 4). Go emits XORL CX,CX before every POPCNT to avoid Intel Sandy Bridge–Skylake false dependency, unnecessary on AMD.

Local codegen differences : e.g., loop increment takes 3 instructions (Go) vs 2 (clang).

Takeaways

Go's SIMD support lets developers harness modern vector hardware without cgo or hand-written assembly — orders-of-magnitude speedups for compute-intensive tasks like TurboPFor.

AI assistants (Claude Code with Opus 5/Fable 5) excel at tedious perf work: reading objdump faster than humans, spotting subtle patterns, tirelessly iterating given measurable goals. Author emphasizes he did not "vibe code" — he reviewed, understood, and approved every AI suggestion.

Result: pure Go retains memory safety, maintainability, and cross-compilation simplicity while matching or beating C performance.

References

Original: Michael Stapelberg, Debian Code Search: Fast TurboPFor with Go SIMD (https://michael.stapelberg.ch/posts/2026-09-06-dcs-fast-turbopfor-go-simd/)

Go 1.26 release notes on simd/archsimd: https://go.dev/doc/go1.26#simd

Author's 2019 TurboPFor analysis: https://michael.stapelberg.ch/posts/2019-02-05-turbopfor-analysis/

DCS repo: https://github.com/Debian/dcs

Positional popcount papers:

Klarqvist et al. (2019): arXiv:1911.02696 (https://arxiv.org/abs/1911.02696)

Aptroot (2024): Histogramming bytes with positional popcount (GF2P8AFFINEQB edition) (https://bitmath.blogspot.com/2024/11/histogramming-bytes-with-positional.html)

Clausecker et al. (2025): arXiv:2412.16370 (https://arxiv.org/abs/2412.16370)

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

performance optimizationGoSIMDcgoAVX-512AVX2integer compressionTurboPFor
TonyBai
Written by

TonyBai

Tony Bai's tech world (tonybai.com). Not satisfied with just "knowing how", we strive for mastery. Focused on Go language internals, high-quality engineering practices, and cloud‑native architecture, exploring cutting‑edge intersections of Go and AI. Gophers who pursue technology are welcome—follow me and evolve with Go.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.