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:

GO join_test.go
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
Output (timings vary by machine)
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:

GO sizes_test.go
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)
			}
		})
	}
}
Output (timings vary by machine)
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.

GO
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:

GO
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))
}
Output
# 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.

Try it yourself · lookup_test.go
Edit the code, copy it into lookup_test.go in a new folder and run it with go run lookup_test.go (Go 1.27 or later). How to install Go
Next: what's new in Go 1.27.