Benchmarks and pprof profiling

Go · Advanced · 15 min

Optimising without measuring wastes time; Go's built-in benchmarks and pprof make measuring easy.

A benchmark is a function func BenchmarkXxx(b *testing.B) in a _test.go file. Run the code under test b.N times; the framework increases b.N until timing is stable:

for range b.N { Parse(input) }

(Go 1.24 adds for b.Loop() { ... }, which also prevents the compiler from optimising the work away and excludes setup automatically.)

Run with go test -bench=. -benchmem. Output shows ns/op, and with -benchmem also B/op and allocs/op — allocations are often the real cost. Use b.ReportAllocs(), b.ResetTimer() after expensive setup, and sub-benchmarks with b.Run to compare sizes. Results are noisy: run with -count=10 before and after a change and compare with benchstat (golang.org/x/perf/cmd/benchstat), which tells you whether a difference is statistically significant.

To find where time goes, profile:

  • go test -bench=Parse -cpuprofile cpu.out -memprofile mem.out
  • go tool pprof -http=:8081 cpu.out opens a web UI with flame graphs, top functions and annotated source.
  • In a running service, import _ "net/http/pprof" registers /debug/pprof/ handlers. Then go tool pprof http://host:port/debug/pprof/profile?seconds=30 grabs a CPU profile; /heap, /goroutine, /mutex and /block show other views. Don't expose these endpoints publicly.

go tool trace shows scheduler, GC and goroutine timelines when profiles aren't enough. And profile-guided optimisation: put a representative CPU profile at default.pgo in the main package and go build uses it to inline and devirtualise hot paths, typically gaining a few percent.

Example

// join_test.go — go test -bench=. -benchmem
package join

import (
	"strings"
	"testing"
)

var parts = strings.Fields("a b c d e f g h i j k l m n o p")

func concat(xs []string) string {
	s := ""
	for _, x := range xs {
		s += x
	}
	return s
}

func BenchmarkConcat(b *testing.B) {
	b.ReportAllocs()
	for range b.N {
		_ = concat(parts) // ~15 allocs/op
	}
}

func BenchmarkJoin(b *testing.B) {
	b.ReportAllocs()
	for range b.N {
		_ = strings.Join(parts, "") // 1 alloc/op
	}
}

Remember

  • BenchmarkXxx(b *testing.B) + go test -bench=. -benchmem
  • Compare runs with benchstat, not by eyeballing one result
  • pprof (files or net/http/pprof) shows where CPU and memory go

Watch out for

  • Benchmarking code whose result is unused, letting the compiler eliminate the work you meant to measure.

Check yourself

  1. Which flag adds allocation statistics (B/op, allocs/op) to benchmark output?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pkg.go.dev/testing#hdr-Benchmarks