targetModels
DeepSeek V4DeepSeek V3.2DeepSeek R1DeepSeek V3 FamilyFuture DeepSeek Models
name
go-performance
category
Performance
description
Making Go fast with evidence — pprof first, allocations and escape analysis, preallocation, strings.Builder, sync.Pool where it pays, GC tuning basics, and benchmarks before and after every change.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
#Task boundary
- Implement exactly the task as stated. Do not add abstractions, options, config, or files the task did not name.
- Comments, identifiers, commit messages and log strings are English only.
- Stop when the checklist at the end passes. Do not refactor or "improve" surrounding code.
- Every checklist item below is backed by an assertion in a test or by pasted command output, never by a sentence.
#Purpose
Rules for performance work in Go. Go is fast by default; most slow Go programs are slow because of allocations, not arithmetic. Every rule here starts with measurement — an optimisation without a profile is a guess that makes the code harder to read.
Caching is Performance/caching; concurrency is Backend/go-concurrency.
#Profile before touching anything
goimport _ "net/http/pprof" // registers /debug/pprof on http.DefaultServeMux
// Then, against a running service:
// go tool pprof -http=:6060 http://localhost:8080/debug/pprof/profile?seconds=30 (CPU)
// go tool pprof -http=:6060 http://localhost:8080/debug/pprof/heap (memory)
// go tool pprof -http=:6060 http://localhost:8080/debug/pprof/allocs (allocations)
- Mount
pprofon an internal port, never on the public listener. - Read the CPU profile's flame graph top-down; the widest frames are the
targets. If
runtime.mallocgcis wide, the problem is allocations, not your algorithm. - The
allocsprofile sorted byalloc_spaceis usually the highest-value view in a service. go test -cpuprofile cpu.out -bench .profiles a benchmark without a server.
#Benchmarks with benchstat
shgo test -bench BenchmarkParse -benchmem -count 10 > old.txt
# make the change
go test -bench BenchmarkParse -benchmem -count 10 > new.txt
benchstat old.txt new.txt
-benchmemaddsB/opandallocs/op— the numbers that predict GC cost.-count 10andbenchstatgive you a p-value. A 3% difference from a single run is noise on a laptop with a browser open.- Keep benchmark inputs realistic in size. A parser benchmarked on a 20-byte string tells you nothing about a 2 MB payload.
- Never optimise without a benchmark that reproduces the profile's hot spot. The benchmark is the regression test for the speed-up.
#Allocations and escape analysis
shgo build -gcflags=-m ./... 2>&1 | grep "escapes to heap"
go// Escapes: the pointer outlives the frame.
func newUser() *User { return &User{} }
// Stays on the stack: value returned, no pointer taken.
func newUser() User { return User{} }
- Heap allocations cost twice: once to allocate, once for the GC to trace them. Stack allocations are free.
- A value escapes when its address is stored beyond the function, passed
through an interface (
fmt.Println(x)boxesx), or captured by a closure that outlives the call. - Small structs returned by value are cheaper than pointers. Pointers are for mutation and large structs, not "efficiency".
-gcflags=-mon a hot package shows exactly which lines allocate; fix the ones theallocsprofile says matter.
#Slices, maps, strings
goout := make([]Item, 0, len(in)) // preallocate: one allocation, not log2(n)
for _, x := range in { out = append(out, convert(x)) }
m := make(map[string]int, expected) // maps grow in steps too
var sb strings.Builder
sb.Grow(estimate)
for _, p := range parts { sb.WriteString(p) }
s := sb.String() // one allocation for the result
appendon a nil slice reallocates at every power of two. When the size is known or estimable,makewith capacity.s += piecein a loop is O(n²) allocation.strings.Builderorbytes.Buffer.[]byte(s)andstring(b)copy. In hot paths, work in one representation;stringsandbytespackages mirror each other for this reason.- Convert with
strconv.Itoa, notfmt.Sprintf("%d").fmtreflects and boxes;strconvdoes not. - Prefer iterating with an index or
for i := rangeover copying large structs infor _, v := range— each iteration copiesv.
#sync.Pool, and when not to
govar bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func encode(v any) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
defer func() { buf.Reset(); bufPool.Put(buf) }()
if err := json.NewEncoder(buf).Encode(v); err != nil { return nil, err }
return append([]byte(nil), buf.Bytes()...), nil // copy out; the buffer is reused
}
- A pool pays for short-lived, uniformly sized, frequently allocated objects — buffers in a hot encoder, scratch slices in a parser.
- Reset before
Put. Never return the pooled object's memory to a caller. - The pool is cleared on every GC cycle; it is a cache, not storage.
- If the profile does not show the allocation as hot, a pool adds complexity for
nothing. Most code should never touch
sync.Pool.
#GC and runtime knobs
GOGC(default 100) trades memory for CPU:GOGC=200halves GC frequency at the cost of a larger heap. Set it per service from a measured heap profile, not by folklore.GOMEMLIMIT(1.19+) is a soft ceiling: set it just under the container's memory limit so the GC runs harder before the OOM killer does. Use it with a highGOGCin memory-constrained containers.GOMAXPROCSdefaults to the host's CPUs, which in a CPU-limited container is wrong;go.uber.org/automaxprocsor a manual value from the cgroup limit prevents throttling.- Reduce garbage before tuning the collector. A 30% allocation cut beats any
GOGCsetting.
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Optimising from intuition | Wrong target; slower, uglier code | Profile with pprof first |
| One benchmark run as proof | Noise masquerading as a result | -count 10 + benchstat |
pprof on the public port | Exposes internals; DoS vector | Internal listener only |
| Returning pointers "for speed" | Forces heap allocation | Values for small structs |
fmt.Sprintf in hot paths | Reflection and boxing | strconv, strings.Builder |
s += x in a loop | Quadratic allocation | strings.Builder |
append from nil for a known size | log2(n) reallocations | make with capacity |
for _, v := range bigStructs | Copies each element | Index or range by pointer |
sync.Pool everywhere | Complexity without measured gain | Only where the profile shows it |
| Pooled buffer's bytes returned to caller | Corruption on reuse | Copy out before Put |
GOGC tuned by folklore | Wrong trade for your heap | Measure; set GOMEMLIMIT in containers |
GOMAXPROCS = host CPUs in a limited container | CPU throttling, latency spikes | Match the cgroup limit |
#Checklist
- Every optimisation started from a
pprofprofile showing the hot spot -
pprofis served on an internal port, not the public listener - Before/after benchmarks with
-benchmem -count 10are compared viabenchstat - Benchmark inputs are realistic in size
- Hot-path allocations are identified with the
allocsprofile and-gcflags=-m - Slices and maps of known size are preallocated
- String building in loops uses
strings.Builder -
strconvreplacesfmt.Sprintffor scalar conversions in hot code -
sync.Poolis used only where profiling justified it, withResetbeforePut - Pooled memory is never handed to callers
-
GOMEMLIMITis set in memory-limited containers;GOGCis set from measurement -
GOMAXPROCSmatches the container's CPU limit