Skip to content
AgileLogics
8 min read

Benchmarking & Profiling

Prerequisites: Testing strategies: httptest, fakes & fuzzing

Performance work starts with measurement. Go ships a benchmark runner in go test and a profiler, pprof, so you rarely need anything else.

Writing a benchmark

Benchmarks live in _test.go files, start with Benchmark, and take a *testing.B. The body runs b.N times; the framework picks b.N so the benchmark runs long enough to measure:

// concat_test.go
package concat

import (
    "strings"
    "testing"
)

var parts = strings.Fields("the quick brown fox jumps over the lazy dog")

func concatPlus(p []string) string {
    s := ""
    for _, x := range p {
        s += x
    }
    return s
}

func concatBuilder(p []string) string {
    var b strings.Builder
    for _, x := range p {
        b.WriteString(x)
    }
    return b.String()
}

var sink string

func BenchmarkPlus(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = concatPlus(parts)
    }
}

func BenchmarkBuilder(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = concatBuilder(parts)
    }
}
go test -bench=. -benchmem

One run on a 12-thread laptop printed (your timings will differ):

BenchmarkPlus-12       4092812     292.9 ns/op     208 B/op     8 allocs/op
BenchmarkBuilder-12    8290458     148.1 ns/op     120 B/op     4 allocs/op

Columns: iterations run, time per operation, bytes allocated per operation, and allocations per operation. The -12 suffix is GOMAXPROCS. += makes a new string for every word after the first; the builder only reallocates when its buffer fills up. b.ReportAllocs() (or the -benchmem flag) adds the memory columns.

Go 1.24 adds b.Loop() as a simpler replacement for the b.N loop; on Go 1.23 use the loop shown here.

Benchmark hygiene

  • Assign results to a package-level variable (sink) so the compiler can't eliminate the work.
  • Do setup before the loop and call b.ResetTimer() after it.
  • Use b.Run for sub-benchmarks across input sizes.
  • Run several times (-count=10) and compare with benchstat (go install golang.org/x/perf/cmd/benchstat@latest) — a single run is noisy, and a 3% difference can be pure chance.
  • Benchmark on a quiet machine; laptops on battery throttle.
func BenchmarkBuilderSizes(b *testing.B) {
    for _, n := range []int{10, 100, 1000} {
        input := make([]string, n)
        for i := range input {
            input[i] = "x"
        }
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                sink = concatBuilder(input)
            }
        })
    }
}

CPU and memory profiles

A benchmark tells you how slow; a profile tells you where. go test can write profiles directly:

go test -bench=BenchmarkPlus -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.out
(pprof) top 10
(pprof) list concatPlus

top lists the functions using the most CPU; list shows per-line cost in a function. go tool pprof -http=:8081 cpu.out opens a browser UI with a flame graph, usually the fastest way to read a profile. For memory profiles, -sample_index=alloc_space shows total bytes allocated rather than bytes still in use.

Profiling a running server

Importing net/http/pprof registers handlers under /debug/pprof/ on http.DefaultServeMux. Serve that on a separate, internal-only port:

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        // Localhost only — never expose pprof publicly.
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    appMux := http.NewServeMux()
    appMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hello"))
    })
    log.Fatal(http.ListenAndServe(":8080", appMux))
}

Then capture 30 seconds of CPU profile while the server is under load, or a heap snapshot:

go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=30"
go tool pprof http://localhost:6060/debug/pprof/heap

/debug/pprof/goroutine?debug=2 dumps every goroutine's stack — invaluable for finding goroutine leaks. For command-line programs, use runtime/pprof.StartCPUProfile / StopCPUProfile instead.

Common mistakes

  • Optimising without a profile. The slow part is rarely where you guess.
  • Comparing single benchmark runs instead of using -count and benchstat.
  • Serving net/http/pprof on the public port — it leaks internals and lets anyone trigger expensive profiling.
  • Benchmarking with setup inside the loop, measuring the setup instead of the code.

Practice

Write benchmarks comparing strconv.Itoa(n) with fmt.Sprintf("%d", n) for n = 12345, reporting allocations. Which would you expect to be faster, and why?

Show solution
package conv

import (
    "fmt"
    "strconv"
    "testing"
)

var (
    sink string
    n    = 12345
)

func BenchmarkItoa(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = strconv.Itoa(n)
    }
}

func BenchmarkSprintf(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = fmt.Sprintf("%d", n)
    }
}

strconv.Itoa is typically a few times faster and allocates less: it formats one integer directly, while fmt.Sprintf parses the format string and boxes its argument into an interface. Reading n from a variable keeps the compiler from treating the input as a constant. Run go test -bench=. -count=5 to confirm on your machine.