Performance Tuning
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 ./joinExample 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 ./joinThen a text summary (no interactive UI):
go tool pprof -top cpu.pprofExample 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.pprofRead 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 ./allocTypical 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.goOutput:
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=. -benchmemis the first instrument.go tool pprof -topis the second. Do not guess.strings.Builder(orbytes.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.Printfof three lines.
Try this
- Grow
idsin the test to 1_000 elements. Re-run-benchmem. WatchJoinPlusget worse faster thanJoinBuilder. - Add
b.ResetTimer()after building a largeidsslice inside the benchmark (move setup above the loop). Confirm the ns/op still compares the join, not the slice make. - Record
-memprofileforBenchmarkJoinBuilderand-top -alloc_space. See that the remaining alloc is mostly the result string. - Leave
tickets.goas it is. Do not convert it to a builder.