Performance Tuning

Updated

September 13, 2026

Performance Tuning

The boring default is measure first. Write the desk so it is correct and readable. If it is slow, use testing.B, then pprof, then change the line the profile names. Do not micro-optimise ticket labels because a blog post mentioned allocs.

Mental model

A benchmark is a test named BenchmarkXxx with argument *testing.B. The tooling runs it until the number is stable and reports ns/op. -benchmem adds allocations per operation.

pprof is the profiler that ships with Go. You record a CPU or memory profile while a test (or a program) runs, then go tool pprof shows where time or bytes went.

Allocation-aware code means you notice when a hot loop builds strings with += and copies the whole buffer every time. It does not mean object pools for a CLI that prints twenty tickets.

for b.Loop() { ... } is the Go 1.24+ way to write the benchmark body. The compiler keeps the loop from being erased. Prefer it over a manual b.N loop on Go 1.27.

Worked examples

Directory (library in a subfolder so package main can import it):

desk/
  go.mod
  join/join.go
  join/join_test.go
  main.go

Save go.mod at desk/:

module example.com/desk

go 1.27

Case 1: Two ways to join ticket ids, one of them copies too much

Save as join/join.go:

// join.go
package join

import (
    "fmt"
    "strings"
)

func JoinPlus(ids []int) string {
    s := ""
    for _, id := range ids {
        s += fmt.Sprintf("#%d ", id)
    }
    return s
}

func JoinBuilder(ids []int) string {
    var b strings.Builder
    for _, id := range ids {
        fmt.Fprintf(&b, "#%d ", id)
    }
    return b.String()
}

join/join_test.go:

// join_test.go
package join

import "testing"

var ids = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}

func BenchmarkJoinPlus(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        _ = JoinPlus(ids)
    }
}

func BenchmarkJoinBuilder(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        _ = JoinBuilder(ids)
    }
}

From the module root:

go test -bench=. -benchmem ./join

Example output (numbers move with the machine; the shape is the lesson):

goos: linux
goarch: amd64
pkg: example.com/desk/join
BenchmarkJoinPlus-8           300000          3800 ns/op         496 B/op         22 allocs/op
BenchmarkJoinBuilder-8        500000          2400 ns/op         160 B/op          4 allocs/op
PASS
ok      example.com/desk/join   2.100s

JoinPlus allocates more because each += may copy the string so far. JoinBuilder grows a buffer. You only rewrite the desk to use the builder when this function is on the hot path and the numbers bother a real user.

Case 2: The program you actually ship

Save as main.go at the module root. The CLI uses the builder because we already measured. It does not use a pool, a cache, or unsafe.

// main.go
package main

import (
    "fmt"

    "example.com/desk/join"
)

func main() {
    fmt.Println(join.JoinBuilder([]int{7, 8, 9}))
}

Run:

go run .

Output:

#7 #8 #9 

Case 3: pprof when a number is not enough

CPU profile of one benchmark:

go test -bench=BenchmarkJoinPlus -cpuprofile=cpu.pprof ./join

Then a text summary (no interactive UI):

go tool pprof -top cpu.pprof

Example top lines (names and percents will not match yours exactly):

File: join.test
Duration: 2.10s, Total samples = 1.80s (85.71%)
Showing nodes accounting for 1.80s, 100% of 1.80s
      flat  flat%   sum%        cum   cum%
     0.90s 50.00% 50.00%      0.90s 50.00%  runtime.mallocgc
     0.40s 22.22% 72.22%      1.20s 66.67%  example.com/desk/join.JoinPlus
     0.20s 11.11% 83.33%      0.50s 27.78%  fmt.Sprintf

If mallocgc and JoinPlus dominate, the allocation story from -benchmem is confirmed. Memory profile:

go test -bench=BenchmarkJoinPlus -memprofile=mem.pprof ./join
go tool pprof -top -alloc_space mem.pprof

Read the function names. Change those. Delete the *.pprof files when you are done; they are not source.

Case 4: Zero-cost speedup: preallocating slice capacity

Save as alloc/alloc_test.go. The simplest, highest-return optimization in Go services is giving make a capacity hint when the item count is known ahead of time. Growing a slice without a capacity hint triggers repeated reallocation and memory copying.

// alloc_test.go
package alloc

import "testing"

func BenchmarkAppendNoCap(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        var s []int
        for i := range 1000 {
            s = append(s, i)
        }
        _ = s
    }
}

func BenchmarkAppendWithCap(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        s := make([]int, 0, 1000)
        for i := range 1000 {
            s = append(s, i)
        }
        _ = s
    }
}

Run:

go test -bench=. -benchmem ./alloc

Typical output:

BenchmarkAppendNoCap-4        100320         14379 ns/op       25208 B/op         12 allocs/op
BenchmarkAppendWithCap-4      429908          2633 ns/op           0 B/op          0 allocs/op

By providing make([]int, 0, 1000), allocations dropped from 12 per operation to 0 (the slice backing array is sized in one shot), and execution speed improved by over 5x. This requires no complex caching or object pools.

The trap

Rewriting the desk to avoid allocations before a user has waited on anything. This program is already fast enough:

// tickets.go
package main

import "fmt"

func main() {
    for _, id := range []int{7, 8, 9} {
        fmt.Printf("ticket %d\n", id)
    }
}

Run:

go run tickets.go

Output:

ticket 7
ticket 8
ticket 9

A pool of []byte, a custom formatter, and a blog about “zero alloc” would not make this more correct. They would make Monday morning slower for the next reader. Benchmark when someone can feel a delay, or when a profile of a service says so. Not because twelve tickets allocate.

The boring rule

  • Make it right. Then measure. Then change the hot function.
  • go test -bench=. -benchmem is the first instrument.
  • go tool pprof -top is the second. Do not guess.
  • strings.Builder (or bytes.Buffer) beats += in a loop. That is the usual win. Stop there.
  • Do not pool, intern, or unsafe-cast the desk. Do not “optimise” fmt.Printf of three lines.

Try this

  1. Grow ids in the test to 1_000 elements. Re-run -benchmem. Watch JoinPlus get worse faster than JoinBuilder.
  2. Add b.ResetTimer() after building a large ids slice inside the benchmark (move setup above the loop). Confirm the ns/op still compares the join, not the slice make.
  3. Record -memprofile for BenchmarkJoinBuilder and -top -alloc_space. See that the remaining alloc is mostly the result string.
  4. Leave tickets.go as it is. Do not convert it to a builder.