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.
Writing Benchmarks
Section titled “Writing Benchmarks”Understanding Benchmarks
Section titled “Understanding Benchmarks”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.
Writing Real Benchmarks
Section titled “Writing Real Benchmarks”for b.Loop() - the modern benchmark loop
Section titled “for b.Loop() - the modern benchmark loop”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:
- Setup and teardown are excluded from the timing automatically - no more
b.ResetTimer()/b.StopTimer()dance. - The compiler is prevented from optimising the loop body away. Function calls inside
b.Loop()are kept alive and are not inlined-then-eliminated. - 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/opThe 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:
- Assign to a package-level variable (a
sink). Package-level means the compiler cannot prove the value is unobserved. - Use
for b.Loop(), which keeps calls in the loop body alive by construction. - As a last resort, feed the result into
b.ReportMetricor accumulate it into something you print.
A local _ = result does not work - the compiler sees straight through it.
b.ReportAllocs() and -benchmem
Section titled “b.ReportAllocs() and -benchmem”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.
go install golang.org/x/perf/cmd/benchstat@latest
# Measure the old code, then the new code, several times each.git stashgo test -bench=Concat -benchmem -count=10 > old.txtgit stash popgo 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.
Profile-Guided Optimization (PGO)
Section titled “Profile-Guided Optimization (PGO)”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.
# 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 pgoPractical 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.pgoso you are not tuning for one machine’s traffic mix. - Build times increase somewhat, because more functions get inlined.
Memory Benchmarking
Section titled “Memory Benchmarking”Why Memory Matters
Section titled “Why Memory Matters”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.
Using pprof
Section titled “Using pprof”Understanding pprof
Section titled “Understanding pprof”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.
# CPU profilinggo test -cpuprofile=cpu.prof -bench=.go tool pprof cpu.prof
# Memory profilinggo 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 browserHTTP pprof
Section titled “HTTP pprof”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:
# Tunnel to the box, then point pprof at the loopback endpointssh -L 6060:127.0.0.1:6060 prod-hostgo tool pprof http://localhost:6060/debug/pprof/heapgo tool pprof http://localhost:6060/debug/pprof/profile?seconds=30Common Optimizations
Section titled “Common Optimizations”Applying Targeted Improvements
Section titled “Applying Targeted Improvements”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.
Avoiding Allocations
Section titled “Avoiding Allocations”Trace Tool
Section titled “Trace Tool”Understanding Execution Traces
Section titled “Understanding Execution Traces”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.
# Generate tracego test -trace=trace.out -bench=.
# View tracego tool trace trace.outThe trace shows:
- Goroutine execution timeline
- GC events
- Syscalls
- Network blocking
Key Takeaways
Section titled “Key Takeaways”- Benchmark first - measure before optimizing, and be willing to find out you were wrong
- Use
for b.Loop()(Go 1.24+) - it excludes setup and stops the loop body being optimized away - Assign results to a package-level sink - a benchmark that discards its result may be measuring an empty loop
- Use
-benchmemandb.ReportAllocs()- track allocations, not just time; they are the more reproducible signal - Compare with
benchstat- a single run is a sample of one;~means “no significant difference” - Profile safely - pprof over HTTP is invaluable, but on a separate, loopback-only mux. Never
ListenAndServe(":6060", nil)on a public interface - Turn on PGO - a
default.pgonext tomain.gobuys 2-7% for free - Preallocate - slices and maps when the size is known;
strings.Builder.Growwhen the length is computable - Avoid allocations -
sync.Pool(of pointers),strconv,[]byte - Trace for concurrency -
go tool traceshows what pprof’s aggregates hide
Exercise
Section titled “Exercise”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
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 <- baselineBenchmarkShipped-4 65193 ns/op 93944 B/op 111 allocs/op <- the old "fast" answer: 1.6x SLOWERBenchmarkFaster-4 24523 ns/op 8408 B/op 5 allocs/op <- the byte scannerTwo lessons, both of which are the point of this chapter:
- The old answer was slower than the code it replaced.
strings.FieldsFuncwith aunicode.IsLettercallback decodes every byte as a rune and makes an indirect function call per rune;strings.Split+strings.Trimjust slices. “Use the fancier stdlib function” is a guess, not a measurement. - 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.
Practice
Section titled “Practice”- Worker Pool - profile it with
b.RunParalleland watch contention appear - Rate Limiter - a hot path where allocation behaviour actually matters
- Pipeline Processor -
go tool traceterritory - Or browse the full exercise battery
Next up: Chapter 12: Patterns & Gotchas