Eliminating Golang bounds checks with unsafe

title
Eliminating Golang bounds checks with unsafe
type
summary
summary
unsafe.SliceData + unsafe.Add drops a bounds check the compiler won't β€” 2x faster LE loads
tags
go, performance, compilers, microarchitecture
created
2026-07-23
updated
2026-07-29

Andrii (blog.andr2i.com), writing an optimization-catalog series, on the last resort for bounds check elimination in Golang: when you can prove an index is in range but the compiler can't, unsafe pointer arithmetic removes the check the compiler insists on keeping.

What a bounds check costs

Compile func load(src []byte, i int) byte { return src[i] } with -B (bounds checks off) and you get three instructions:

MOVQ  AX, 0x8(SP)
MOVZX 0(AX)(DI*1), AX
RET

Without -B, the compiler adds a CMPQ DI, BX / JAE pair and a CALL runtime.panicBounds. The CALL is the expensive part in a small function: it stops the function being a leaf, which pulls in PUSHQ BP / MOVQ SP, BP / POPQ BP as well. So on a tiny hot function, removing the check removes more than the compare-and-branch.

The author's argument for why bounds check elimination is worth reaching for first is not just the branch. Fewer instructions means less L1 icache and uop-cache pressure, fewer entries in the frontend's branch prediction structures, and lower register pressure. On a hot path already suffering capacity or conflict misses, shrinking the instruction stream helps twice.

You don't have to read assembly to find them:

go build -gcflags="-d=ssa/check_bce/debug=1" .

The conventional fix first

The compiler will drop a check if you prove the range to it, usually by touching the upper bound before the loop or by reshaping the loop condition. A real example from a matchLen function:

a = a[:limit]
b = b[:len(a)]
i := 0
for ; i <= len(a)-8; i += 8 {
    xor := loadU64(a[i:]) ^ loadU64(b[i:])
    ...
}

i <= len(a)-8 lets the compiler prove every a access is in range; b = b[:len(a)] transfers that proof to b. This gets better with each Golang release, and go101's BCE page collects the standard tricks. Reach for unsafe only after this fails.

The unsafe load

The motivating case is binary.LittleEndian.Uint32, which already carries the well-known hint:

func (littleEndian) Uint32(b []byte) uint32 {
    _ = b[3] // bounds check hint to compiler
    return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
}

The hint collapses four checks into one. The one that survives is the one unsafe removes:

//go:build !purego && (amd64 || 386 || arm64 || loong64 || ppc64le || wasm)

func loadU32LE(b []byte, i uint) uint32 {
    return *(*uint32)(unsafe.Add(unsafe.Pointer(unsafe.SliceData(b)), i))
}

unsafe.SliceData(b) gives the same pointer &b[0] would, without the check b[0] would introduce, and unlike &b[0] it is legal on an empty slice. unsafe.Add does the offset arithmetic that &b[i] would, and the result is cast to *uint32 and dereferenced. Three calls to remove one check, all of which the compiler inlines away β€” the emitted code is MOVQ AX, 0x8(SP) / MOVL 0(AX)(DI*1), AX / RET.

The signature change matters as much as the body. The stdlib call site is binary.LittleEndian.Uint32(data[offset:]), and that slicing expression carries its own bounds check on the caller's side. Passing (data, offset) as two arguments is what moves the check out of the caller too.

Where it is and isn't valid

The build tag is load-bearing twice over. Little-endian is the obvious constraint. The subtler one, raised by ncruces on HN after publication, is unaligned access: a *uint32 dereference at an arbitrary byte offset is only safe on platforms that permit unaligned loads. The correct platform list is the intersection of little-endian targets with unalignedOK in cmd/compile/internal/ssa/config.go β€” not "every little-endian platform I can think of".

And the check is only removable if you can actually prove it unnecessary. The compiler inserted it for a reason; unsafe transfers that proof obligation to you and offers nothing in return if you're wrong.

Numbers

Microbenchmark, summing 4096 bytes as uint32 on an i5-12500:

BenchmarkLoadU32LE   273.7 ns/op   14966.04 MB/s
BenchmarkStdUint32   600.7 ns/op    6818.58 MB/s

More than 2x, which is what you'd expect from a benchmark that does nothing but the load. The real-world number is the interesting one: swapping stdlib LE loads for unsafe ones in andybalholm/brotli's matchfinder moved a production-like workload from 90.39 MiB/s to 99.99 MiB/s, +10.62% (n=30, p=0.000). The technique is not novel β€” klauspost/compress has done this for years β€” just not widely written down.

The author's closing complaint is that Golang has no opt-in nobounds hint at the statement or function level, so unsafe pointer arithmetic is the only available way to say "I checked this already".

Same family as compiler-codegen-luck: the performance lives in generated code that the source doesn't show, and the fix is a rewrite that reads as cosmetic. The argument for writing the win down by hand rather than trusting the optimizer to keep finding it is made at length in everyone-should-know-simd, where the objection to auto-vectorization is not that it fails but that an unrelated edit can silently undo it. pointer-provenance is the formal machinery that says why unsafe.Add on a slice's data pointer is the well-defined way to do this rather than casting an integer. For the other end of the same hot-path tuning spectrum, where the problem is the hardware rather than the instruction count, see false-sharing-alignment-128.