Benchmarking and Profiling Workflow
Benchmarking and Profiling Workflow
Optimization should be a controlled loop, not guesswork. Go’s toolchain makes the loop tight: benchmarks, pprof, and execution traces.
Why / Overview
Without measurement you will:
- Optimize cold code
- Add complexity for noise-level gains
- Miss regressions until customers report them
baseline bench/load -> profile hotspot -> focused change -> remeasure -> benchstat keep/revert
Writing Good Benchmarks
func BenchmarkParse(b *testing.B) {
in := fixedInput() // stable, realistic
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := Parse(in); err != nil {
b.Fatal(err)
}
}
}Rules:
- Realistic inputs — tiny synthetic data lies.
- Avoid measuring setup — use
ResetTimer, or do setup outside the loop. - Prevent dead-code elimination — assign to
sinkor use results. - Report allocs —
b.ReportAllocs()or-benchmem. - Sub-benchmarks for sizes:
b.Run("1k", ...).
var sink int
func BenchmarkSum(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = Sum(data)
}
}Parallel benchmarks
func BenchmarkHandlerParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// concurrent path
}
})
}Useful for mutex/contention issues; pair with CPU counts.
Running and Comparing
go test -bench=BenchmarkParse -benchmem -count=10 ./... | tee old.txt
# make change
go test -bench=BenchmarkParse -benchmem -count=10 ./... | tee new.txt
benchstat old.txt new.txtbenchstat shows geometric mean differences and statistical confidence. Do not trust single runs.
Flags:
-benchtime=3sor-benchtime=1000xfor stability-cpu=1,4for scaling behavior-cfor concurrent package tests carefully
Profiling from Benchmarks
go test -bench=BenchmarkParse -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof -http=:8080 cpu.out
go tool pprof -http=:8081 mem.outLook at:
- CPU flame graph — widest plateaus
- alloc_space — bytes allocated (retained vs ephemeral)
- alloc_objects — allocation count (GC pressure)
Profiling a Running Service
import _ "net/http/pprof"
// admin listener only
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/profile?seconds=30
curl -o heap.pb.gz http://127.0.0.1:6060/debug/pprof/heap
go tool pprof -http=:8082 heap.pb.gzMutex and block profiles
runtime.SetMutexProfileFraction(1) // sample all mutex events; lower in prod if needed
runtime.SetBlockProfileRate(1) // 1 ns; too heavy for always-on prod — sample carefullygo tool pprof http://127.0.0.1:6060/debug/pprof/mutex
go tool pprof http://127.0.0.1:6060/debug/pprof/blockExecution Tracer
curl -o trace.out 'http://127.0.0.1:6060/debug/pprof/trace?seconds=5'
go tool trace trace.outUse when:
- Latency spikes with low CPU
- Suspect GC STW or scheduler delays
- Goroutines stuck in syscall or channel ops
Trace shows timeline, not aggregated hotspots — complementary to pprof.
Load Testing Complements Microbenches
Microbenchmarks miss:
- Network
- Real JSON sizes
- Multi-tenant cache effects
- GC under steady heap
Use hey, vegeta, k6, or custom Go load clients for end-to-end. Still attach pprof during the load.
Workflow Playbook
- Define success: e.g. p99 < 50ms at 2k RPS, or bench ns/op −20%.
- Baseline with count≥10 and production-like data.
- Profile under that baseline load.
- Change one hotspot (algorithm, alloc, lock scope).
- Remeasure with benchstat or load test.
- Document why the change is correct (not only faster).
- Guard with a regression benchmark in CI if critical.
CI Integration
# Example: fail on large regression using benchstat + golden
go test -bench=BenchmarkCritical -count=5 ./internal/hotpath | tee ci.txt
# compare against stored baseline with thresholdsKeep CI benches short and stable; flaky benches train people to ignore them.
Production Checklist
- Critical paths have benchmarks with
-benchmem - pprof available on admin interface only
- Know how to capture CPU, heap, mutex, trace
- Use benchstat for claims
- Load test environment approximates prod limits (CPU, GOMAXPROCS)
- Profiles stored for major incidents (artifact)
- Optimization PRs include before/after numbers
Common Pitfalls
- Benchmarking with
-raceonly — useful but not speed truth. - Tiny inputs that stay on stack and never show heap costs.
- Compiler optimizing away work.
- Comparing different machines without noting noise.
- Always-on block profiling at rate 1 in prod — heavy.
- Optimizing before algorithmic rewrite (O(n²) → map).
- Ignoring variance — one lucky run.
Exercises
- Write a bad benchmark that the compiler optimizes away; fix with sink.
- Benchmark two JSON libraries on the same payload; benchstat them.
- Capture CPU profile of a deliberately slow function; identify it in the flame graph.
- Add
ReportAllocs; reduce allocs with a sync.Pool; remeasure. - Run parallel benchmark; show scaling from 1 to 4 CPUs.
- Enable mutex profile on a contended lock demo; find the holder.
- Capture a 5s trace during GC-heavy load; find STW-ish gaps.
- Load-test a HTTP handler; profile mid-test; fix top hotspot.
- Introduce a 5% performance regression; see if your CI would catch it.
- Write a one-page playbook for your team’s pprof ports and commands.
Interpreting Flame Graphs Quickly
When the CPU profile UI opens:
- Sort by cum (cumulative) to see time including callees.
- Look for wide plateaus in your package paths — not only
runtime.*. - If most time is
runtime.mallocgc/scanobject, switch to heap/alloc profiles (next chapter). - If most time is
sync.(*Mutex).Lock, switch to mutex profile (contention chapter). - If time is in
syscall.Read/ network, you may have an IO or dependency problem, not a Go micro-opt.
Always keep a copy of the profile file (cpu.out) next to the PR description for reviewers.
Production Capture Safety
- Bind pprof to localhost or a private admin network.
- Prefer short profiles (15–30s) under representative load.
- Do not leave
SetBlockProfileRate(1)on permanently in large fleets. - Redact profile upload paths if they include customer identifiers in function args (rare but possible with bad instrumentation).
More examples
Mini benchmark harness (stdlib testing style)
mkdir -p /tmp/go-bench-mini && cd /tmp/go-bench-mini
go mod init example.com/bench-miniSave as main.go (illustrates the measurement pattern; real benches use testing.B):
package main
import (
"fmt"
"strings"
"time"
)
func concatPlus(n int) string {
var s string
for i := 0; i < n; i++ {
s += "x"
}
return s
}
func concatBuilder(n int) string {
var b strings.Builder
b.Grow(n)
for i := 0; i < n; i++ {
b.WriteByte('x')
}
return b.String()
}
func timeIt(name string, n int, fn func(int) string) {
start := time.Now()
_ = fn(n)
fmt.Printf("%s n=%d %s\n", name, n, time.Since(start))
}
func main() {
timeIt("plus", 5000, concatPlus)
timeIt("builder", 5000, concatBuilder)
}go run .Expected output (durations vary; builder should not be slower by orders of magnitude):
plus n=5000 ...
builder n=5000 ...
pprof HTTP handler registration (httptest)
mkdir -p /tmp/go-pprof-reg && cd /tmp/go-pprof-reg
go mod init example.com/pprof-regSave as main.go:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/pprof"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Get(ts.URL + "/debug/pprof/")
if err != nil {
panic(err)
}
resp.Body.Close()
fmt.Println("pprof index:", resp.StatusCode)
}go run .Expected output:
pprof index: 200
Runnable example
A real go test benchmark file plus runtime/pprof heap profile written to a buffer—stdlib profiling workflow without a long-lived server.
mkdir -p /tmp/go-bench-pprof && cd /tmp/go-bench-pprof
go mod init example.com/bench-pprofSave as work.go:
package benchpprof
import "strings"
func BuildReport(n int) string {
var b strings.Builder
for i := 0; i < n; i++ {
b.WriteString("line\n")
}
return b.String()
}Save as work_test.go:
package benchpprof
import "testing"
func BenchmarkBuildReport(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = BuildReport(1000)
}
}
func BenchmarkBuildReportAlloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = BuildReport(1000)
}
}Save as cmd/heapdemo/main.go:
package main
import (
"bytes"
"fmt"
"runtime"
"runtime/pprof"
benchpprof "example.com/bench-pprof"
)
func main() {
// Create some heap traffic
var hold []string
for i := 0; i < 100; i++ {
hold = append(hold, benchpprof.BuildReport(500))
}
runtime.GC()
var buf bytes.Buffer
if err := pprof.WriteHeapProfile(&buf); err != nil {
panic(err)
}
fmt.Println("heap_profile_bytes:", buf.Len())
fmt.Println("held_reports:", len(hold))
fmt.Println("tip: go test -bench=BenchmarkBuildReport -benchmem -count=5")
}go test -bench=BenchmarkBuildReport -benchmem -count=3 .
go run ./cmd/heapdemoExpected output (illustrative):
BenchmarkBuildReport-8 xxxx yyyy ns/op zzzz B/op w allocs/op
heap_profile_bytes: <nonzero>
held_reports: 100
tip: go test -bench=BenchmarkBuildReport -benchmem -count=5
What to notice
-benchmemreports B/op and allocs/op—the first filter before guessing.pprof.WriteHeapProfileworks without binding HTTP; usenet/http/pprofon an admin port in services.- Run benchmarks multiple
-counttimes before believing a regression.
Try next
go test -cpuprofile=cpu.out -bench=.thengo tool pprof -http=:8080 cpu.out.- Compare two commits with
benchstat old.txt new.txt.
Further Reading
- Go blog: profiling Go programs
benchstatdocumentation- Next: Memory, Allocations, and GC