Benchmarks & Profiling

advanced go testing performance

Benchmarks tell us how fast our code is, and profiling tells us where the time and memory go. Both ship with the standard testing package — same go test command, no extra tooling.

In simple language: a benchmark times a snippet over and over to get a stable number, and a profile is a recording of where our program spent its time or memory.

Writing a benchmark

A benchmark is a function named BenchmarkXxx that takes *testing.B. The magic is b.N — the testing framework runs our loop b.N times, automatically cranking N up until it has a reliable measurement.

// math_test.go
package math

import "testing"

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ { // run b.N times — the framework picks N
		Add(2, 3)
	}
}

We never set b.N ourselves — Go does. It might run 100 times or 10 million times depending on how fast Add is.

If we have expensive setup before the loop that shouldn’t count, we call b.ResetTimer() right before the loop so the timer ignores the setup.

func BenchmarkParse(b *testing.B) {
	data := loadBigFile()  // expensive setup, don't time this
	b.ResetTimer()         // start the clock fresh here
	for i := 0; i < b.N; i++ {
		Parse(data)
	}
}

Running and reading benchmarks

go test -bench=.            # run all benchmarks in the package
go test -bench=. -benchmem  # also report memory allocations
go test -bench=Add          # run only benchmarks matching "Add"

The -benchmem flag is the one to remember. Output looks like this:

BenchmarkAdd-8    1000000000    0.30 ns/op    0 B/op    0 allocs/op

Reading it left to right:

  • -8 — number of CPU cores used (GOMAXPROCS).
  • 1000000000 — how many times it ran (b.N).
  • 0.30 ns/opnanoseconds per operation. Lower is faster.
  • 0 B/op — bytes allocated per operation.
  • 0 allocs/opnumber of heap allocations per operation. This is huge — allocations trigger garbage collection, so fewer allocs usually means faster, calmer code.

In interviews, ns/op (speed) and allocs/op (memory pressure) are the two numbers people care about.

Profiling with pprof

Benchmarks say “this is slow.” pprof says “this line is slow.” We capture a profile, then explore it with go tool pprof.

# capture CPU and memory profiles from a benchmark
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out

# explore interactively (try `top`, `list FuncName`, `web`)
go tool pprof cpu.out

Inside pprof, top shows the hottest functions, list Add shows line-by-line cost, and web opens a visual graph (needs Graphviz).

For a live server, we don’t use benchmarks — we import net/http/pprof, which auto-registers debug endpoints on our HTTP server.

import _ "net/http/pprof" // blank import wires up /debug/pprof/* handlers

// then, with a running server, profile it live:
// go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

That blank import (_) is the trick — we don’t call anything, the package’s init() registers the handlers for us.

The race detector

Concurrency bugs are sneaky — two goroutines touching the same memory without synchronization. The -race flag instruments our code to catch these at runtime.

go test -race ./...   # run tests with the race detector on
go run -race main.go  # or run a program with it

If two goroutines access the same variable and at least one writes, -race prints a loud report with both stack traces. It’s slower and uses more memory, so we use it in CI and testing, not production. Interviewers love asking “how do you find data races?” — the answer is -race.

Interview soundbite

Benchmarks are func BenchmarkXxx(b *testing.B) with a for i := 0; i < b.N; i++ loop; run them with go test -bench=. -benchmem and read ns/op for speed and allocs/op for memory pressure, using b.ResetTimer() to exclude setup. To find where the cost is, we profile with -cpuprofile/-memprofile and go tool pprof, or import net/http/pprof for live servers. And go test -race catches data races at runtime.