Eliminating Go bound checks with unsafe
Hot path optimization: unsafe pointer arithmetic to eliminate bound checks the Go compiler can't remove, given you can prove they are truly unnecessary.
Part of the Optimization catalog series:
- When float division beats integer division
- How 4 bytes of padding make array clearing 49% faster
- Eliminating Go bound checks with unsafe (this post)
Bound checks elimination (BCE) is probably one of the most robust, most productive optimization techniques in the Go world. This is my go-to technique, I think, when I'm starting to optimize any Go hot path. Why is it so robust? Because it reduces number of instructions and number of branches in a hot path. This alone is excellent because it reduces number of wasted cycles, but there are additional benefits on top of that. If your code already experiences cache capacity and/or conflict misses lowering number of instructions can help with those significantly. We are talking about L1 icache, the uop cache and, maybe, frontend branch prediction caches. Also register pressure, BCE can help with that too. While being so robust, bound checks are easy to detect and sometimes relatively easy to eliminate. Long story short, BCE is usually a quick win worth trying first. However sometimes it's not easy to eliminate bound checks with conventional methods and that's where the unsafe techniques enter and this is what this post is about.
First, what are the Bound Checks? Go is a safe language and provides some guarantees, e.g. the guarantee that you can't access out-of-range slice elements. To do that the compiler adds a bunch of assembly code that makes sure the runtime panics when the out-of-range index is accessed. Let me quickly illustrate it by this tiny example:
If I compile it with the -B flag which disables bound checks it produces this
concise assembly:
0x71d MOVQ AX, 0x8(SP)
0x722 MOVZX 0(AX)(DI*1), AX
0x726 RET
Assembly with the -B flag removed shows the overhead caused by bound checks:
+ 0x796 PUSHQ BP
+ 0x797 MOVQ SP, BP
0x79a MOVQ AX, 0x10(SP)
+ 0x79f CMPQ DI, BX
+ 0x7a2 JAE 0x7aa
0x7a4 MOVZX 0(AX)(DI*1), AX
+ 0x7a8 POPQ BP
0x7a9 RET
+ 0x7aa CALL 0x7af [1:5]R_CALL:runtime.panicBounds
+ 0x7af NOPL
Of course this assembly diff is over-dramatic. The function is tiny and it was a
leaf but enabling BC made it acquire a CALL which caused addition of the
PUSHQ/POPQ BP and MOVQ SP, BP. Any function that gets a CALL stops being a
leaf and gets those prologue instructions regardless of bound
checks. But even ignoring that, my point is that there is still an overhead. But
actually, you know what? As I think about it, it's not over-dramatic. If you
have tiny function and BCE converts it to leaf which removes the CALL overhead
then it's a legit BCE-related optimization.
By the way, you don't really need to search assembly to find bound checks. The compiler can list all the bound checks with this command:
go build -gcflags="-d=ssa/check_bce/debug=1" .
Before we switch to using unsafe let's take a look at a conventional way of dealing with BC. Go compiler often can eliminate bound checks if you "prove" that they are unnecessary. You prove it by accessing the upper/lower bound first before looping over the rest of the range. Here is an example from a real codebase:
func matchLen(a, b []byte, limit int) int {
+ a = a[:limit]
+ b = b[:len(a)]
i := 0
- for ; limit >= 8; limit -= 8 {
+ for ; i <= len(a)-8; i += 8 {
xor := loadU64(a[i:]) ^ loadU64(b[i:])
if xor != 0 {
return i + bits.TrailingZeros64(xor)/8
}
- i += 8
}
- for ; limit > 0 && a[i] == b[i]; limit-- {
- i++
+ for ; i < len(a) && a[i] == b[i]; i++ {
}
return i
}
Using i <= len(a)-8 in the loop condition makes the compiler eliminate the bound
check as it now can provably determine that all a accesses are within the
range. b = b[:len(a)] eliminates b-related bound checks in the loop.
With every Go version compiler becomes smarter and smarter about the bound checks elimination. There are often good ways to hint to the compiler about BCE (check out this post if you look for more conventional examples) but sometimes it's not possible (yet, maybe in the next version of Go it will be) to eliminate BC with conventional methods and that's where unsafe enters.
⚠️ Note that we are talking here about the cases where the compiler can't determine that a bound check is unnecessary but the programmer can. If you can't prove that a bound check is unnecessary then don't eliminate it, the compiler inserts it for a good reason. I think this is obvious but needed to note it just in case.
Ok, let's eliminate some bound checks with unsafe now, shall we? I'll use the best example I could find in my brotli library. There is one ubiquitous function used in there that unavoidably brings BC with itself. binary.LittleEndian.Uint32:
// Uint32 returns the uint32 representation of b[0:4].
This function reads 4 bytes in little endian order from a slice and is useful
wherever you store data bit-packed in byte slices. You see in the code that it
already tries to eliminate bound checks by giving the compiler a hint: _ = b[3]
which keeps one bound check for b[3] but eliminates bound checks for indexes
0-2. However, one bound check is left and any unnecessary code is waste in a
hot path. Here is an unsafe
example
that eliminates all the bound checks (which also converts the load function into
a leaf and removes the CALL overhead):
//go:build !purego && (amd64 || 386 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm)
package encoder
Let's note that function signature has changed. You would call the stdlib variant
like this: binary.LittleEndian.Uint32(data[offset:]). And the unsafe variant like
this: loadU32LE(data, offset). This is important because if you'd keep
(data[offset:]) you'd still have one bound check on the caller side which would
make sure data[offset] is in bounds. Also note the go:build instruction.
This trick works only on machines that put data into memory in little endian
order in the first place. None of this is novel, by the way. Perf-critical
libraries like klauspost/compress rely
on the very same unsafe little-endian loads; it's just a niche technique that
isn't widely known.
Let's go through the example:
unsafe.SliceData(b)returns exactly the same result as what&b[0]would return - a pointer to the first element of the slice. If we usedb[0]we'd introduce a bound check which we are trying to eliminate. The nice part of unsafe.SliceData is that, unlike&b[0], you can use it on empty slices.unsafe.Pointerconverts*bytereturned byunsafe.SliceDataintounsafe.Pointertype needed forunsafe.Add.unsafe.Add(ptr, i)returns unsafe.Pointer representation of&b[i],- which is cast to
*uint32and dereferenced in the end.
You might say that I have introduced 3 function calls to eliminate a single bound check, but if we check the assembly we can see that the Go compiler eliminates all the bound checks and inlines all the calls:
MOVQ AX, 0x8(SP)
MOVL 0(AX)(DI*1), AX
RET
So what is the performance difference between stdlib LE loader and hand-rolled unsafe one? Let's benchmark it.
Expand to see the benchmark code
package bce
goos: linux
goarch: amd64
pkg: bcetest
cpu: 12th Gen Intel(R) Core(TM) i5-12500
BenchmarkLoadU32LE 22644585 273.7 ns/op 14966.04 MB/s
BenchmarkStdUint32 9922156 600.7 ns/op 6818.58 MB/s
The unsafe version is more than 2x faster. You might say that you can't really trust microbenchmarks, the situation is completely different when you surround the benchmarked code with a real codebase. Alright, fair enough. Here are the benchmarks of a real-world compression matchfinder doing real-world work on a production-like workload before and after replacing the stdlib LE loaders with unsafe ones:
pkg: github.com/andybalholm/brotli/matchfinder
│ before.txt │ after.txt │
│ B/s │ B/s vs base │
Trio 90.39Mi ± 0% 99.99Mi ± 0% +10.62% (p=0.000 n=30)
The obvious downside: it's unsafe, d'oh. All the validations the compiler inserted
for you now have to be proved by the programmer to be truly unnecessary.
As I already
complained
I wish Go had the nobounds compiler hint but it doesn't, so the only viable
option we are left with is using unsafe pointer arithmetic.