I am working on a p2p watch party application in my free time. Currently it's incomplete prototype with a lot of scope of improvement (webRTC support for NAT traversal, control panel, stats for nerds, browser support via wasm, etc).
To get it up and running quickly, I've been pair-coding it with Antigravity. I usually review what it generates function by function and try to think how I would implement same thing in hopes to sometimes come up with something either more performant or more intuitive
I also sometimes look at assembly code for different implementation(I don't know if it's a good idea or bad or useful but I have free time and I like doing it ¯\_(ツ)_/¯ ).
During this review I became aware of many golang's standard library functions which I wasn't aware of before. I also check how library implements them since they are written by people who know the language best. One such function was to copy unsigned 32bit integer to a byte array in big endian format. If I were to implement it (not being aware of the function), this is how I would:
func encodeUint32Manual(buf []byte, offset int, v uint32) {
buf[offset+0] = byte(v >> 24)
buf[offset+1] = byte(v >> 16)
buf[offset+2] = byte(v >> 8)
buf[offset+3] = byte(v)
}It is very simple, just shift bits down and write them at offset with most significant bytes at lower index. I would generally expect standard library functions to be more performant than mine if implementation is complex by using some language primitive or algorithm I am not aware of but for something this simple I was hoping for similar perf.
I benchmarked both using (marked both functions with //go:noinline so the compiler wouldn't merge them into the benchmark loop and skew the numbers)
package main
import (
"encoding/binary"
"testing"
)
//go:noinline
func encodeUint32Std(buf []byte, offset int, v uint32) {
binary.BigEndian.PutUint32(buf[offset:offset+4], v)
}
//go:noinline
func encodeUint32Manual(buf []byte, offset int, v uint32) {
buf[offset+0] = byte(v >> 24)
buf[offset+1] = byte(v >> 16)
buf[offset+2] = byte(v >> 8)
buf[offset+3] = byte(v)
}
func BenchmarkEncodeUint32Std(b *testing.B) {
buf := make([]byte, 1024)
b.ResetTimer()
for i := 0; i < b.N; i++ {
encodeUint32Std(buf, 0, uint32(i))
}
}
func BenchmarkEncodeUint32Manual(b *testing.B) {
buf := make([]byte, 1024)
b.ResetTimer()
for i := 0; i < b.N; i++ {
encodeUint32Manual(buf, 0, uint32(i))
}
}And to my disappointment, my implementation performed a bit slow (~45% (¬_¬") )
$ go test -bench=BenchmarkEncodeUint32 -benchmem uint32_test.go
goos: linux
goarch: amd64
cpu: 12th Gen Intel(R) Core(TM) i5-12450H
BenchmarkEncodeUint32Std-12 1000000000 0.9412 ns/op 0 B/op 0 allocs/op
BenchmarkEncodeUint32Manual-12 861998977 1.387 ns/op 0 B/op 0 allocs/op
PASS
ok command-line-arguments 2.383sTo find out why I was doing worse than std lib(or why std lib was doing better than me), I looked at std lib's implementation of that function(at go/src/encoding/binary/binary.go).
// PutUint32 stores v into b[0:4].
func (bigEndian) PutUint32(b []byte, v uint32) {
_ = b[3] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 24)
b[1] = byte(v >> 16)
b[2] = byte(v >> 8)
b[3] = byte(v)
}Only 1 line is different and that line too does nothing but access an element and throw it away!
To see what this line is doing, I took a look at asm generated by both the functions (I cleaned it bit below so that we can focus on good parts, you can get entire asm via go test -c -gcflags="-S" uint32_test.go 2> uint32.s).
encodeUint32Manual:
# --- Write Byte 0: buf[offset+0] = byte(v >> 24) ---
CMPQ BX, DI # Bounds Check 0: is offset < len(buf)?
JLS panic_0
MOVL SI, CX # Copy v
SHRL $24, SI # Shift right 24 bits
MOVB SIB, (AX)(DI*1) # MOVB: Store byte 0
# --- Write Byte 1: buf[offset+1] = byte(v >> 16) ---
LEAQ 1(DI), DX # DX = offset + 1
CMPQ BX, DX # Bounds Check 1
JLS panic_1
MOVL CX, DX
SHRL $16, CX # Shift right 16 bits
MOVB CL, 1(DI)(AX*1) # MOVB: Store byte 1
# --- Write Byte 2: buf[offset+2] = byte(v >> 8) ---
LEAQ 2(DI), CX # CX = offset + 2
CMPQ BX, CX # Bounds Check 2
JLS panic_2
MOVL DX, CX
SHRL $8, DX # Shift right 8 bits
MOVB DL, 2(DI)(AX*1) # MOVB: Store byte 2
# --- Write Byte 3: buf[offset+3] = byte(v) ---
LEAQ 3(DI), DX # DX = offset + 3
CMPQ BX, DX # Bounds Check 3
JLS panic_3
MOVB CL, 3(DI)(AX*1) # MOVB: Store byte 3encodeUint32Std:
# Registers: CX = len(buf), DI = offset, SI = v (value to write)
LEAQ 4(DI), DX # DX = offset + 4
CMPQ CX, DX # Compare len(buf) with offset + 4
JCS out_of_bounds_1 # If len(buf) < offset + 4, panic
CMPQ DI, DX # Compare offset with offset + 4 (overflow check)
JHI out_of_bounds_2 # If offset > offset + 4, panic
# --- The Core Store Combined Operation ---
BSWAPL SI # Byte Swap Long: reverse byte order of SI (Big-Endian)
MOVL SI, (AX)(DI*1) # MOVL: Write all 32 bits (4 bytes) to memory at onceWell, my code's asm performs a bounds check on every write to the slice, and that also seems to be coming in the way of other optimizations. Like mine is four bounds checks, four shifts, and four single-byte writes (MOVB), compared to the standard library's one bounds check, one byte-swap, and one 4-byte write (MOVL).
Doing _ = b[3] triggers a bounds check right away. If the program doesn't panic at that line, it is guaranteed that all previous indices (b[0], b[1], b[2]) must inherently be valid. Because the compiler proves they are safe, it completely eliminates the bounds checks for the subsequent writes.
What if I rearrange my function such that write to last offset is done first. Something like
func encodeUint32ManualReversed(buf []byte, offset int, v uint32) {
buf[offset+3] = byte(v)
buf[offset+2] = byte(v >> 8)
buf[offset+1] = byte(v >> 16)
buf[offset+0] = byte(v >> 24)
}And... it didn't work. My best guess is that offset might be negative, so offset+3 being positive doesn't guarantee the same for offset+2. But I am not sure why I am not able to do this even with offset type being uint. I tried bunch of other ways like manually checking for offset being > 0 and _ = buf[offset+3] but all either did not prove that previous index access would not be illegal or compiler was not able to prove that.
After some googling and chatgpting and claudeing I found that what standard library is using is a standard, well known optimization trick in Golang called bound check elimination(you can print where your code is doing BCE via -gcflags="-d=ssa/check_bce/debug=1"). I won't talk about that in detail since I am not currently an expert on the topic and others have much better blogs/writeups on them(maybe how compiler internally does BCE is worth looking at in future •ᴗ• ).
None of this makes much difference to my project(like the nanosecond gain/loss is dwarfed by network overhead) but I think it's a good habit I picked up unknowingly, and it helped me write better Go code(I still write important code by hand (ᵕ—ᴗ—) )