Benchmarks and Profiling
Go makes performance work measurable. Benchmarks, written alongside tests, time a piece of code and count its memory allocations; profiles show where a whole program spends its CPU time and memory. This lesson covers writing and comparing benchmarks, reading the results, CPU and memory profiles with pprof, and the habits that keep optimisation honest: measure first, change one thing, measure again.
Writing a benchmark
A benchmark is a function BenchmarkXxx(b *testing.B) in a _test.go file. Put the code to measure inside a for b.Loop() loop; the framework repeats it until the timing is reliable. Here are two ways of joining strings, benchmarked against each other:
package main import ( "strings" "testing" ) var words = strings.Fields(strings.Repeat("the quick brown fox jumps over the lazy dog ", 50)) func joinPlus(ws []string) string { s := "" for _, w := range ws { s += w + " " } return s } func joinBuilder(ws []string) string { var b strings.Builder for _, w := range ws { b.WriteString(w) b.WriteByte(' ') } return b.String() } func BenchmarkJoinPlus(b *testing.B) { for b.Loop() { joinPlus(words) } } func BenchmarkJoinBuilder(b *testing.B) { for b.Loop() { joinBuilder(words) } }
go test -bench=. -benchmem
goos: darwin goarch: arm64 pkg: example cpu: Apple M4 BenchmarkJoinPlus-10 26858 44782 ns/op 527820 B/op 450 allocs/op BenchmarkJoinBuilder-10 491800 2430 ns/op 8440 B/op 11 allocs/op PASS ok example 2.752s
Each line shows the benchmark name (with -10, the number of CPUs used), how many iterations ran, the time per operation, and — thanks to -benchmem — bytes and allocations per operation. The builder version is almost twenty times faster and allocates about 1/60 of the memory, because += copies the growing string on every step (lesson 22).
b.Loop (Go 1.24) also stops the compiler from optimising away the call whose result is unused, and excludes setup done before the loop from the timing. Older benchmarks use for i := 0; i < b.N; i++, which still works.
Comparing results reliably
Single benchmark runs are noisy. For decisions that matter, run each benchmark several times (-count=10) before and after a change, save the outputs, and compare them with the benchstat tool from golang.org/x/perf, which reports the change and whether it's statistically significant:
go test -bench=JoinBuilder -count=10 > old.txt
# ...make your change...
go test -bench=JoinBuilder -count=10 > new.txt
go run golang.org/x/perf/cmd/benchstat@latest old.txt new.txt
Sub-benchmarks
b.Run creates sub-benchmarks, just like t.Run for tests — handy for measuring across input sizes:
package main import ( "fmt" "testing" ) func sum(nums []int) int { total := 0 for _, n := range nums { total += n } return total } func BenchmarkSum(b *testing.B) { for _, size := range []int{10, 1000, 100_000} { nums := make([]int, size) b.Run(fmt.Sprint(size), func(b *testing.B) { for b.Loop() { sum(nums) } }) } }
goos: darwin goarch: arm64 pkg: example cpu: Apple M4 BenchmarkSum/10-10 346033026 3.499 ns/op BenchmarkSum/1000-10 4398684 269.6 ns/op BenchmarkSum/100000-10 45858 25999 ns/op PASS ok example 4.293s
The time grows in proportion to the input size, as you'd expect from a single loop.
CPU and memory profiles
A benchmark tells you how fast one function is; a profile tells you which functions a whole workload spends its time in. go test can write profiles while running benchmarks, and go tool pprof reads them:
go test -bench=JoinPlus -cpuprofile=cpu.out -memprofile=mem.out go tool pprof -top cpu.out # functions ranked by CPU time go tool pprof -http=:8081 cpu.out # interactive flame graph in the browser
In a running program, the runtime/pprof package writes the same profiles, and importing net/http/pprof into a server adds live profiling endpoints under /debug/pprof/ — including, since Go 1.27, /debug/pprof/goroutineleak, which reports goroutines blocked forever on channels or locks nobody can reach anymore. Expose those endpoints only on an internal port.
package main import ( "log" "os" "runtime/pprof" "strings" ) func main() { f, err := os.Create("cpu.out") if err != nil { log.Fatal(err) } defer f.Close() if err := pprof.StartCPUProfile(f); err != nil { log.Fatal(err) } defer pprof.StopCPUProfile() s := "" for range 20_000 { // deliberately slow work to profile s += strings.Repeat("x", 3) } log.Println(len(s)) }
Seeing what the compiler does
Allocations are often what makes Go code slow. go build -gcflags=-m prints the compiler's escape analysis decisions — which values it keeps on the stack and which "escape" to the garbage-collected heap — and which calls it inlines:
package main import "fmt" type point struct{ x, y int } func newPoint(x, y int) *point { p := point{x, y} return &p // p must outlive the call: it escapes to the heap } func add(a, b int) int { return a + b } func main() { p := newPoint(1, 2) fmt.Println(add(p.x, p.y)) }
# example ./main.go:7:6: can inline newPoint ./main.go:12:6: can inline add ./main.go:15:15: inlining call to newPoint ./main.go:16:17: inlining call to add ./main.go:16:13: inlining call to fmt.Println ./main.go:8:2: moved to heap: p ./main.go:16:13: ... argument does not escape ./main.go:16:17: ~r0 escapes to heap
Optimisation habits
- Measure first. Profile the real workload before changing anything; the slow part is rarely where you'd guess.
- Fix algorithms before micro-optimising. A map lookup instead of a linear search beats any amount of tuning.
- Reduce allocations in hot paths: preallocate slices, reuse buffers, use
strings.Builder. - Keep the benchmark next to the code, so future changes can be checked against it.
- Stop when it's fast enough. Clear code is worth more than the last few percent.
Try it yourself
Benchmark two ways of checking whether a word is in a list — a slice search and a map lookup — with 1,000 words. Run it with go test -bench=. -benchmem.