| name | go-performance |
| description | Guides Go performance work as a measure-first discipline — never optimize from intuition; profile or benchmark first, then optimize only what the data proves is hot, without sacrificing clarity for unmeasured speed. Covers benchmarking with go test -bench -benchmem and comparing runs with benchstat (not eyeballing one noisy run); profiling with pprof (CPU -cpuprofile, heap -memprofile, net/http/pprof for live servers; top/list/web); escape analysis via go build -gcflags=-m (stack vs heap); allocation reduction (preallocate make([]T,0,n), reuse buffers, sync.Pool, strings.Builder, avoid []byte<->string copies); PGO (default.pgo); and the free runtime wins (container-aware GOMAXPROCS 1.25, Green Tea GC 1.26). Auto-invokes when optimizing Go performance, reducing allocations, profiling with pprof, reading escape analysis, or enabling PGO, and on "make this faster" / "why is this allocating" / "profile this" requests. Routes benchmark mechanics to go-testing-advanced, sync.Pool to go-sync-primitives. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Performance
"You can't tell where a program is going to spend its time. Bottlenecks occur in surprising places, so don't try to second guess and put in a speed hack until you've proven that's where the bottleneck is."
"Measure. Don't tune for speed until you've measured, and even then don't unless one part of the code overwhelms the rest."
— Rob Pike, Notes on Programming in C
"We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil."
— Donald Knuth, Structured Programming with go to Statements (Computing Surveys, 1974)
Performance work in Go is a measure-first discipline, not a bag of tricks. The single most expensive mistake is optimizing code that was never hot — paying readability to speed up a path the program barely runs. This skill makes the loop explicit: profile or benchmark, find the real hot path, change one thing, measure again. Everything below serves that loop.
All Go below builds clean under gofmt, go vet, and go test on Go 1.26.
1. The Discipline: Measure First, Never Guess
The order is non-negotiable, because "bottlenecks occur in surprising places" (Pike):
- Profile or benchmark to find where the time and allocations actually go.
- Change one thing that the data points at.
- Measure again — and compare statistically, not by eyeball.
This is the same dual-axis judgment as the policy root (go-idiomatic-discipline): "clear AND correct." An unmeasured micro-optimization that trades clarity for speed you never proved is the ceiling failure — it ships, it's correct, and the next reader pays for cleverness that bought nothing. The bar for sacrificing readability is a profile or a benchmark that names the cost. No profile, no trade. "Clear is better than clever" (Go Proverbs) loses only to evidence.
| Tool | Question it answers | Command |
|---|
Benchmark (-benchmem) | How fast / how many allocs is this function? | go test -bench=. -benchmem |
| benchstat | Is the change real or noise? | benchstat old.txt new.txt |
| CPU profile | Where does wall/CPU time go? | go test -cpuprofile=cpu.prof |
| Heap profile | Where do allocations come from? | go test -memprofile=mem.prof |
| Escape analysis | What is forced onto the heap? | go build -gcflags=-m |
| PGO | Free inlining of measured hot funcs | default.pgo in the main package |
2. Benchmark with -benchmem, Compare with benchstat
A benchmark is the unit of evidence. go test -bench=. -benchmem adds the allocation columns; b.ReportAllocs() turns them on per-function. The benchmark mechanics — for b.Loop() (Go 1.24), the dead-code/sink trap, b.ResetTimer, b.RunParallel — are owned by go-testing-advanced §2–3; read it for how to write the loop. This skill owns what you do with the numbers.
BenchmarkJoinNaive-32 6748058 179.3 ns/op 216 B/op 7 allocs/op
BenchmarkJoinBuilder-32 24888777 49.11 ns/op 48 B/op 1 allocs/op
The allocs/op column is the lever: 7 → 1 allocations is a real, durable win (string += in a loop reallocates every iteration; strings.Builder with one Grow does not). But one run is not a measurement — CPU scaling, GC, and noise swamp small deltas. To know a change is real, run each side ≥10 times and let benchstat do the statistics:
go test -run='^$' -bench=. -count=10 > old.txt
go test -run='^$' -bench=. -count=10 > new.txt
benchstat old.txt new.txt
"Benchstat computes statistical summaries and A/B comparisons of Go benchmarks" and "Each benchmark should be run at least 10 times to gather a statistically significant sample" (benchstat). It reports the delta with a p-value: a ~ "indicates no statistically significant difference," while -17.20% (p=0.000 n=10) is a change you can trust (benchstat). Reporting a single before/after pair as proof is the classic eyeball error.
3. Profiling with pprof — Find the Real Hot Path
When you don't already know which function is hot, profile; don't guess. "Profiling tools analyze the complexity and costs of a Go program such as its memory usage and frequently called functions to identify the expensive sections" (Diagnostics). The two profiles you reach for most:
- CPU — "determines where a program spends its time while actively consuming CPU cycles (as opposed to while sleeping or waiting for I/O)" (Diagnostics). When enabled, "the Go program stops about 100 times per second and records a sample consisting of the program counters on the currently executing goroutine's stack" (Profiling Go Programs).
- heap — "reports memory allocation samples; used to monitor current and historical memory usage, and to check for memory leaks" (Diagnostics).
The fastest way to get both is straight from a benchmark — no code changes:
go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof
go tool pprof -top cpu.prof
go tool pprof cpu.prof
Inside go tool pprof, three commands carry the work: "The most important command is topN, which shows the top N samples"; list Fn shows per-line counts where "the first three columns are the number of samples taken while running that line, the number … in code called from that line, and the line number"; and "the web command writes a graph of the profile data in SVG format and opens it in a web browser" (needs graphviz) (Profiling Go Programs). Use -cum to sort by cumulative cost up the call tree.
Live servers: import net/http/pprof for its side effect — "Package pprof serves via its HTTP server runtime profiling data" under paths that "all begin with /debug/pprof/" (net/http/pprof):
import _ "net/http/pprof"
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
It is "safe to profile programs in production, but enabling some profiles (e.g. the CPU profile) adds cost" (Diagnostics). One caveat: "Use tools in isolation to get more precise info" — precise memory profiling skews CPU profiles (Diagnostics). block and mutex profiles are off by default (runtime.SetBlockProfileRate, runtime.SetMutexProfileFraction).
4. Escape Analysis — Stack Is Free, Heap Costs GC
Stack allocations are reclaimed for free when the function returns; heap allocations create GC work. The compiler decides per value, and it will show you its reasoning: -m is documented as "Print optimization decisions" (cmd/compile).
go build -gcflags=-m ./...
The lines that matter read like moved to heap: x, ... escapes to heap, or the reassuring ... does not escape. A verified example — returning the address of a local forces it onto the heap:
func NewPointHeap(x, y int) *Point { p := Point{x, y}; return &p }
func SumPointValue(p Point) int { return p.X + p.Y }
The recurring escape triggers to recognize in a hot path:
- Returning a pointer to a local (above) — return the value if it's small; the copy is cheaper than the GC.
- Interface boxing — assigning a concrete value to an
any/interface{} (and fmt.Sprint(x), whose ...any boxes every argument) heap-allocates it.
- Closures capturing by reference — a variable captured by a closure that outlives the frame escapes.
- Slices/maps that outlive the call or grow past a size the compiler can't bound.
Escape analysis tells you why the heap profile looks the way it does — read them together.
5. Reduce Allocations Once a Profile Points There
Only after a heap profile or -benchmem names the cost. The high-leverage moves, each verified by an alloc delta:
- Preallocate when the size is known.
make([]T, 0, n) gives append its backing array once instead of growing-and-copying. Measured: appending 1024 ints with no prealloc was 25208 B/op, 12 allocs/op; with make([]int, 0, n) it was 0 B/op, 0 allocs/op. Same for maps: make(map[K]V, n). (Slice/map mechanics: go-slices-and-maps.)
- Build strings with
strings.Builder, not +=. s += p in a loop reallocates the whole string every iteration. Measured: naive += was 216 B/op, 7 allocs/op; strings.Builder with one Grow(n) was 48 B/op, 1 allocs/op. (Builder + conversion cost: go-strings-bytes-runes.)
- Avoid
[]byte↔string conversions in hot loops — each conversion copies. Work in one representation; reach for strconv over fmt for number formatting.
- Reuse buffers —
buf = buf[:0] keeps the capacity and resets the length.
sync.Pool for transient objects. "A Pool is a set of temporary objects that may be individually saved and retrieved"; its "purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector" (sync.Pool). The caveat: "Any item stored in the Pool may be removed automatically at any time without notification" — so it's for fungible scratch (byte buffers), never for objects that must persist. Mechanics and copylock rules: go-sync-primitives.
6. PGO — Free Inlining of Functions a Profile Proves Hot
Profile-Guided Optimization feeds a real CPU profile back into the compiler. "The Go toolchain will automatically enable PGO when it finds a profile named default.pgo in the main package directory" (PGO blog); "By default, go build will detect default.pgo files automatically and enable PGO" (PGO guide). Profiles "from runtime/pprof and net/http/pprof can be used directly as the compiler input" — so the CPU profile you already collected is the input.
go tool pprof -proto ... > default.pgo
go build ./...
With it, "the compiler may decide to more aggressively inline functions which the profile indicates are called frequently" (PGO guide). The payoff is modest and free: "benchmarks for a representative set of Go programs show that building with PGO improves performance by around 2-14%" (PGO guide). Commit default.pgo to the repo — it "ensures that users automatically have access to the profile … and that builds remain reproducible" (PGO blog). Collect it from production, not a synthetic micro-benchmark, and refresh it occasionally as the code drifts.
7. Runtime Wins You Get for Free
Two recent releases cut overhead with no code change — they raise the floor before you optimize anything:
- Container-aware
GOMAXPROCS (Go 1.25). "On Linux, the runtime considers the CPU bandwidth limit of the cgroup containing the process … If the CPU bandwidth limit is lower than the number of logical CPUs available, GOMAXPROCS will default to the lower limit" (Go 1.25). This stops a containerized binary from spawning a thread per host core and thrashing under a Kubernetes CPU limit. It "periodically updates GOMAXPROCS if … the cgroup CPU bandwidth limit change[s]," and is disabled if you set GOMAXPROCS manually.
- Green Tea GC default (Go 1.26). The collector that "improves the performance of marking and scanning small objects through better locality and CPU scalability," previously a 1.25 experiment, "is now enabled by default" — "we expect somewhere between a 10–40% reduction in garbage collection overhead in real-world programs that heavily use the garbage collector" (Go 1.26). Opt out with
GOEXPERIMENT=nogreenteagc (expected removed in 1.27).
Gate any version-specific idiom on the module's go directive — see go-version-feature-map.
8. Don't
- Don't optimize without a profile. Guessing the hot spot is the root error; "bottlenecks occur in surprising places" (Pike).
- Don't micro-optimize cold code. A 50% speedup on a path that runs 0.1% of the time is noise you paid clarity for.
- Don't trade readability for unproven speed. That's the axis-2 failure of
go-idiomatic-discipline. The price of clever is a profile that names the win.
- Don't eyeball one benchmark run. Use
-count and benchstat; a single delta is within the noise.
- Don't tune the GC (
GOGC, GOMEMLIMIT) before measuring that GC is your bottleneck — a mutex or a syscall usually isn't fixed by GC knobs.
- Don't
sync.Pool objects that must persist — pooled items can vanish at any time.
9. Routing to Related Skills
go-idiomatic-discipline — the policy root; unmeasured micro-opt vs clarity is its axis-2 ("clear AND correct") failure.
go-testing-advanced — benchmark mechanics: for b.Loop(), the sink/dead-code trap, b.ReportAllocs, b.RunParallel. This skill interprets the numbers it produces.
go-sync-primitives — sync.Pool mechanics, copylock, and the rest of shared-memory synchronization.
go-slices-and-maps — preallocation (make([]T,0,n)), capacity, append/aliasing semantics.
go-strings-bytes-runes — strings.Builder and the cost of []byte↔string conversion.
go-version-feature-map — version floors: PGO GA 1.21, container-aware GOMAXPROCS 1.25, Green Tea GC default 1.26.
10. Reference Files
High-frequency performance anti-patterns in LLM-generated Go, each with wrong/right code and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml