Skip to content

Chapter 11: Benchmarking & Profiling

Go provides excellent tools for measuring and improving performance. Performance optimization is a journey from measurement to understanding to improvement. Go’s tooling makes this journey straightforward and scientific.

Performance matters in Go applications, but premature optimization wastes time. The Go philosophy: write clear code first, measure to find real bottlenecks, then optimize what matters. Go’s runtime is already fast - most code doesn’t need optimization. But when performance counts (high-throughput services, real-time systems, resource-constrained environments), Go gives you the tools to understand and improve it.

This chapter covers the essential performance tools: benchmarks for measurement, pprof for profiling CPU and memory usage, and the trace tool for understanding concurrency. You’ll learn to write meaningful benchmarks, interpret profiling data, identify common performance bottlenecks, and apply targeted optimizations. The goal isn’t making everything fast - it’s making the right things fast enough.

Benchmarks measure code performance scientifically. Instead of guessing which approach is faster, you measure both and let data decide. Go’s testing package makes benchmarking as easy as writing tests - benchmarks live alongside tests in _test.go files with the same tooling.

A benchmark function accepts *testing.B and runs the code being measured b.N times. The testing framework automatically determines an appropriate N to get statistically significant results - it starts small and increases until the benchmark runs long enough to measure accurately. You don’t choose N; Go does.

The key insight: benchmarks measure relative performance, not absolute. Comparing two approaches in the same benchmark environment reveals which is faster, but the exact numbers depend on your hardware. Focus on ratios (2x faster, 50% fewer allocations) rather than absolute values (234ns per operation).

Benchmark functions start with Benchmark and use *testing.B.

The snippet below uses testing.AllocsPerRun - a plain function from the testing package that you can call from ordinary code - to compare four ways of building a string:

99 allocations versus 1. That gap is why += in a loop is the single most common performance mistake in Go, and why Grow is worth the two extra lines when you can compute the size up front.

Every benchmark you have ever read looks like for i := 0; i < b.N; i++. Go 1.24 added b.Loop(), and it is now the recommended form. It fixes three long-standing annoyances at once:

  1. Setup and teardown are excluded from the timing automatically - no more b.ResetTimer() / b.StopTimer() dance.
  2. The compiler is prevented from optimising the loop body away. Function calls inside b.Loop() are kept alive and are not inlined-then-eliminated.
  3. The body runs exactly once per iteration of the real measurement, so benchmarks that mutate shared state behave predictably.
// In a _test.go file:
// A package-level sink. Assigning the result to it stops the compiler
// from deleting the call as a dead store - the classic benchmarking bug.
var sink string
func BenchmarkConcat(b *testing.B) {
strs := []string{"hello", "world", "foo", "bar"} // setup: excluded automatically
b.ReportAllocs()
for b.Loop() {
sink = concatWithBuilder(strs)
}
}
// Pre-1.24 form. Still valid, still what you'll see in most codebases -
// note that it needs an explicit ResetTimer.
func BenchmarkConcatOldStyle(b *testing.B) {
strs := []string{"hello", "world", "foo", "bar"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
sink = concatWithBuilder(strs)
}
}
// Run with: go test -bench=. -benchmem
// BenchmarkConcat-8 16436317 73.61 ns/op 24 B/op 2 allocs/op

The mistake that invalidates benchmarks: discarding the result

Section titled “The mistake that invalidates benchmarks: discarding the result”
for i := 0; i < b.N; i++ {
concatWithBuilder(strs) // WRONG: result unused
}

This looks harmless and is the most common way to write a benchmark that measures nothing. If the compiler can prove the result is never observed and the function has no side effects, it is free to delete the call entirely - dead-store elimination - and you end up benchmarking an empty loop. The symptom is a suspiciously round sub-nanosecond ns/op.

Three defences, in order of preference:

  1. Assign to a package-level variable (a sink). Package-level means the compiler cannot prove the value is unobserved.
  2. Use for b.Loop(), which keeps calls in the loop body alive by construction.
  3. As a last resort, feed the result into b.ReportMetric or accumulate it into something you print.

A local _ = result does not work - the compiler sees straight through it.

b.ReportAllocs() turns on the B/op and allocs/op columns for that benchmark specifically; go test -benchmem turns them on for every benchmark in the run. Call b.ReportAllocs() in the benchmark itself so the numbers show up no matter how the test is invoked - allocation counts are usually the most actionable half of a benchmark result.

b.RunParallel - measuring under contention

Section titled “b.RunParallel - measuring under contention”

A single-goroutine benchmark cannot see lock contention, false sharing, or sync.Pool behaviour under load. b.RunParallel runs the body on GOMAXPROCS goroutines:

func BenchmarkConcatParallel(b *testing.B) {
strs := []string{"hello", "world", "foo", "bar"}
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
var local string
for pb.Next() {
local = concatWithBuilder(strs)
}
sink = local // publish once, outside the hot loop
})
}

Use it for anything touching a mutex, an atomic, a sync.Map, or a pool. Keep per-goroutine state local inside the closure - writing to a shared sink on every iteration would benchmark cache-line contention rather than your code.

benchstat - the missing half of go test -bench

Section titled “benchstat - the missing half of go test -bench”

A single benchmark run is a sample of one, and Go benchmarks routinely vary by 5-10% between runs on the same machine. Comparing two numbers by eye is guessing with extra steps. benchstat does the statistics for you: run each benchmark several times with -count, then let it report the median difference and whether it is significant.

Terminal window
go install golang.org/x/perf/cmd/benchstat@latest
# Measure the old code, then the new code, several times each.
git stash
go test -bench=Concat -benchmem -count=10 > old.txt
git stash pop
go test -bench=Concat -benchmem -count=10 > new.txt
benchstat old.txt new.txt
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Concat-8 1.412µ ± 2% 0.834µ ± 1% -40.93% (p=0.000 n=10)
│ old.txt │ new.txt │
│ B/op │ B/op vs base │
Concat-8 1.808Ki ± 0% 0.508Ki ± 0% -71.90% (p=0.000 n=10)

If benchstat prints ~ instead of a percentage, the difference is not statistically significant - your “optimisation” did nothing measurable. That verdict is the whole point of the tool: it is the thing that stops you from shipping a change that is really just scheduler noise.

Rules of thumb: use -count=10 or more, keep the machine idle, disable CPU frequency scaling if you can, and never compare numbers taken on different machines.

PGO went GA in Go 1.21 and is the largest free performance win available in modern Go: typically 2-7% across the whole binary for zero code changes. Almost no tutorials mention it.

The idea: collect a CPU profile from your program running a representative workload, commit it next to main.go as default.pgo, and the compiler will use it on every subsequent build. With real call-frequency data the compiler inlines hot functions more aggressively, devirtualises interface calls whose concrete type is overwhelmingly one type, and lays out basic blocks so the common path falls through.

Terminal window
# 1. Collect a CPU profile from production (or a realistic load test).
curl -o cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30
# 2. Commit it as default.pgo in the main package's directory.
mv cpu.pprof ./cmd/api/default.pgo
# 3. That's it. go build picks it up automatically.
go build ./cmd/api
# Confirm it was applied:
go build -pgo=auto -x ./cmd/api 2>&1 | grep pgo

Practical notes:

  • A stale profile is not dangerous, just less useful. PGO never changes program semantics, so a profile from last month’s traffic can only make the optimisation less targeted.
  • Profile the real workload. A profile collected from your benchmark suite optimises for your benchmark suite.
  • Merge profiles from several instances with go tool pprof -proto a.pprof b.pprof > default.pgo so you are not tuning for one machine’s traffic mix.
  • Build times increase somewhat, because more functions get inlined.

Memory allocations are often the hidden performance killer in Go programs. Every allocation means work for the garbage collector. Frequent allocations in hot code paths can trigger GC more often, causing latency spikes and reducing throughput. Understanding allocation patterns is as important as understanding CPU usage.

The -benchmem flag adds allocation statistics to benchmark output: bytes allocated per operation and number of allocations. These numbers reveal optimization opportunities. An algorithm might seem fast but allocate heavily - reducing allocations often speeds up the algorithm and reduces GC pressure simultaneously.

Allocation-free code is the gold standard for performance-critical paths. This doesn’t mean avoiding all allocations - it means being intentional about them. Preallocate buffers, reuse objects with sync.Pool, work with []byte instead of strings, and return values instead of pointers for small types. These techniques dramatically reduce allocation rates.

pprof is Go’s built-in profiler for identifying CPU hotspots and memory bottlenecks. Unlike benchmarks that measure specific functions in isolation, pprof analyzes entire programs to show where time is spent and memory is allocated. It answers the critical question: “What should I optimize?”

CPU profiling samples your program periodically (100 times per second by default) to record which functions are executing. After collection, pprof aggregates the data to show time spent per function, including time spent in called functions (cumulative) versus time in the function itself (flat). The top functions by cumulative time are your optimization targets.

Memory profiling tracks allocations, showing which functions allocate the most bytes and objects. This reveals unexpected allocation patterns - maybe a function called rarely allocates huge amounts, or a frequently-called function has small but numerous allocations. Both problems have different solutions, and pprof helps you identify them.

Terminal window
# CPU profiling
go test -cpuprofile=cpu.prof -bench=.
go tool pprof cpu.prof
# Memory profiling
go test -memprofile=mem.prof -bench=.
go tool pprof mem.prof
# Common pprof commands:
# top10 - show top 10 functions
# list funcName - show source with annotations
# web - open interactive graph in browser

Importing net/http/pprof for its side effects exposes live profiling endpoints over HTTP - the fastest way to profile a running service.

package main
import (
"log"
"net/http"
"net/http/pprof" // NOT a blank import - we register the routes ourselves
)
func startDebugServer() {
// A private mux on a loopback-only listener. Nothing here is
// reachable from outside the machine.
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
go func() {
// 127.0.0.1, never ":6060" - the latter binds every interface.
log.Println(http.ListenAndServe("127.0.0.1:6060", mux))
}()
}
func main() {
startDebugServer()
// Your application listens on its own mux, on its own port.
app := http.NewServeMux()
app.HandleFunc("/", handleRoot)
log.Fatal(http.ListenAndServe(":8080", app))
}

Then reach it locally:

Terminal window
# Tunnel to the box, then point pprof at the loopback endpoint
ssh -L 6060:127.0.0.1:6060 prod-host
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

Once profiling reveals bottlenecks, you apply targeted optimizations. The following patterns appear repeatedly in performance-critical Go code. They’re not appropriate everywhere - use them where profiling shows they matter, not preemptively.

These optimizations share a theme: reduce allocations, minimize copying, and leverage Go’s efficient primitives. sync.Pool reuses temporary objects. Preallocation eliminates growth overhead. Value receivers avoid pointer indirection for small types. Working with bytes instead of strings avoids conversions. Each technique has specific use cases where it shines.

The art of optimization is knowing when to apply these patterns. A function called once per request doesn’t need sync.Pool. A slice of 10 items doesn’t need preallocation. A 64-byte struct passed by value is fine. Profile first, understand the bottleneck, then apply the appropriate technique.

The trace tool visualizes program execution over time, showing goroutine scheduling, GC activity, and system interactions. Unlike pprof which aggregates data, traces show the timeline of events - you can see exactly when goroutines run, block, and communicate. This is invaluable for understanding concurrency issues.

Traces excel at revealing concurrency problems that profiling misses. Is your program underutilizing CPUs because goroutines block on channels? Are goroutines creating contention for locks? Is the GC pausing your application at critical moments? The trace timeline makes these patterns visible.

The interactive trace viewer shows multiple timelines: per-processor goroutine execution, heap size, GC events, and goroutine creation/blocking. Click events to see details, zoom in on interesting periods, and correlate across timelines. Common insights: goroutines spending too much time blocked, inadequate parallelism, or GC triggering too frequently. The trace points to root causes that profiling data alone can’t reveal.

Terminal window
# Generate trace
go test -trace=trace.out -bench=.
# View trace
go tool trace trace.out

The trace shows:

  • Goroutine execution timeline
  • GC events
  • Syscalls
  • Network blocking
  1. Benchmark first - measure before optimizing, and be willing to find out you were wrong
  2. Use for b.Loop() (Go 1.24+) - it excludes setup and stops the loop body being optimized away
  3. Assign results to a package-level sink - a benchmark that discards its result may be measuring an empty loop
  4. Use -benchmem and b.ReportAllocs() - track allocations, not just time; they are the more reproducible signal
  5. Compare with benchstat - a single run is a sample of one; ~ means “no significant difference”
  6. Profile safely - pprof over HTTP is invaluable, but on a separate, loopback-only mux. Never ListenAndServe(":6060", nil) on a public interface
  7. Turn on PGO - a default.pgo next to main.go buys 2-7% for free
  8. Preallocate - slices and maps when the size is known; strings.Builder.Grow when the length is computable
  9. Avoid allocations - sync.Pool (of pointers), strconv, []byte
  10. Trace for concurrency - go tool trace shows what pprof’s aggregates hide

The original version of this exercise asked you to make a word-counter “at least 3x faster” and claimed a Speedup: 3.33x. Both were fiction. The shipped answer used strings.FieldsFunc with a unicode.IsLetter callback, which is slower than the naive version it replaced (measured: 63µs vs 40µs per op, and 5x more memory), and the wall-clock numbers could never appear at all, because this playground’s clock does not advance during CPU work.

So the goal here is the honest one: allocate less. Allocation counts are exact, reproducible, and identical on every machine - including this one.

Cut the Allocations Out of a Word Counter

hard

countWordsSlower allocates a fresh string for every word it sees. Rewrite it as a single-pass byte scanner that allocates a constant number of times regardless of the input's word count. Target: 106 allocs/op down to single digits.

What it actually measures on a real machine

Section titled “What it actually measures on a real machine”

The playground can only report allocations. Here is the same code under go test -bench . -benchmem -count=5 on a real box, including the version this chapter used to ship as “3.33x faster”:

BenchmarkSlower-4 40532 ns/op 17896 B/op 106 allocs/op <- baseline
BenchmarkShipped-4 65193 ns/op 93944 B/op 111 allocs/op <- the old "fast" answer: 1.6x SLOWER
BenchmarkFaster-4 24523 ns/op 8408 B/op 5 allocs/op <- the byte scanner

Two lessons, both of which are the point of this chapter:

  1. The old answer was slower than the code it replaced. strings.FieldsFunc with a unicode.IsLetter callback decodes every byte as a rune and makes an indirect function call per rune; strings.Split + strings.Trim just slices. “Use the fancier stdlib function” is a guess, not a measurement.
  2. Allocations track the speed-up better than intuition does. 106 -> 5 allocs/op is a 21x reduction, and the wall clock followed it down by 1.65x. The remaining 5 allocations are the lower-cased buffer plus map growth - a constant, no matter how many words you feed it.

Chapter in progress
0 / 14 chapters completed

Next up: Chapter 12: Patterns & Gotchas