Reducing Allocations and GC Pressure¶
Why allocations are the first thing to look at¶
In Go, CPU cost and memory cost meet in the allocator. Each heap
allocation costs time directly (runtime.mallocgc, typically 10–50 ns
for small objects), and indirectly: allocation rate decides how often
the GC runs, every cycle costs CPU proportional to the live heap, and
goroutines that allocate during a cycle pay mark assists inline,
which turns GC cost into request latency
(Garbage Collector). In a hot
path, removing allocations is usually the single most effective
optimization, and allocs/op is one of the most stable benchmark
metrics — it does not vary with machine noise.
Measurements: Go 1.26.4, Apple M1 Pro. All results are from
-benchmem benchmarks in the same package.
Find them first¶
- Benchmarks with
-benchmem/b.ReportAllocs(). - Heap profile,
alloc_space/alloc_objects— where the bytes and objects are allocated over time (not what is live):go tool pprof -sample_index=alloc_objects -top mem.out. - CPU profile:
runtime.mallocgc,runtime.gcAssistAlloc,runtime.gcBgMarkWorker, andruntime.memclrNoHeapPointershigh in the profile mean allocation is a CPU cost worth attacking. - Escape analysis (
-gcflags=-m) for why a specific value is on the heap (Escape Analysis).
Technique 1: pre-size slices and maps¶
Collecting 10,000 int64 values:
| Variant | ns/op | B/op | allocs/op |
|---|---|---|---|
var s []int64 + append |
50,519 | 357,626 | 19 |
make([]int64, 0, n) + append |
25,815 | 81,920 | 1 |
map[int64]int64{} + insert |
340,533 | 591,482 | 79 |
make(map[int64]int64, n) + insert |
103,829 | 295,554 | 33 |
Growing a slice by appending reallocates and copies about 19 times on the way to 10,000 elements and allocates 4.4x the final size in total. Growing a map is worse: every growth step rehashes entries into new tables. When the size is known — or can be estimated — pass it.
Technique 2: values instead of pointers¶
Building a slice of 1,000 small records (two int64s and a 16-byte
name) that escapes to the caller:
| Variant | ns/op | B/op | allocs/op |
|---|---|---|---|
[]*Rec, each &Rec{...} |
14,269 | 40,192 | 1,001 |
[]Rec |
4,735 | 32,768 | 1 |
One allocation instead of a thousand, contiguous memory (better cache
behavior when iterating), and — because Rec contains no pointers —
the GC never needs to scan the backing array at all. Use []*T when
elements must be shared or individually referenced, or are large and
moved around often; default to []T.
The same principle at larger scale: millions of small objects with
pointers between them (trees, graphs, maps of structs with string
fields) are the most expensive thing you can ask the GC to mark.
Alternatives: indices into a slice instead of pointers; one big
[]byte arena with offsets instead of many small strings; a
map[uint64]uint32 from hash to index instead of map[string]*Entry.
Technique 3: append-style APIs and buffer reuse¶
Encoding a small event struct to JSON three ways:
| Variant | ns/op | B/op | allocs/op |
|---|---|---|---|
json.Marshal(&ev) |
146 | 64 | 1 |
json.NewEncoder(pooledBuf).Encode(&ev) with sync.Pool |
137 | 0 | 0 |
Hand-written appendEvent(buf[:0], &ev) with strconv.Append* |
74 | 0 | 0 |
func appendEvent(dst []byte, e *Event) []byte {
dst = append(dst, `{"id":`...)
dst = strconv.AppendInt(dst, e.ID, 10)
dst = append(dst, `,"user":`...)
dst = strconv.AppendQuote(dst, e.User)
// ...
return append(dst, '}')
}
var buf []byte
for _, ev := range events {
buf = appendEvent(buf[:0], &ev) // reuses buf's capacity after the first call
w.Write(buf)
}
The pattern — the caller owns the buffer, the function appends to it
— moves the allocation decision out of the hot function. It is how
strconv, encoding/binary, time.AppendFormat, and high-performance
encoders are built. (Note the hand-written encoder skips JSON string
escaping rules that strconv.AppendQuote only approximates; in real
code use a generated or well-tested encoder.)
Technique 4: sync.Pool, correctly¶
sync.Pool caches temporary objects for reuse across goroutines. It
keeps a private slot and a lock-free shared list per P, so Get/Put
are cheap and mostly contention-free. Since Go 1.13, objects survive
one GC cycle in a victim cache and are dropped after the second,
so a pool never holds memory forever.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func render(w io.Writer, v *Page) error {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // always reset: you get someone else's leftovers
defer func() {
if buf.Cap() <= 64<<10 { // don't keep giant buffers alive
bufPool.Put(buf)
}
}()
if err := tmpl.Execute(buf, v); err != nil {
return err
}
_, err := w.Write(buf.Bytes()) // finish using buf BEFORE Put
return err
}
Rules:
- Pool pointers, not values:
Put([]byte)converts the slice header toanyand allocates on every call; pool*bytes.Bufferor*[]byte. - Reset every object after
Get. - Cap what you return. One request that grows a buffer to 50 MB would otherwise keep a 50 MB buffer in the pool for every P.
- Never use a buffer after
Put— another goroutine may already be writing to it. This is a data race the race detector may only catch under the right interleaving. - Pools are for cheap-to-recreate temporaries. Connections, file handles, and anything needing cleanup belong in a purpose-built pool with explicit lifetime management.
- Measure: for small objects, the allocator is fast, and a pool can be slower than allocating. Pools pay off for objects that are large (≥ a few KB) or expensive to initialize.
Technique 5: avoid hidden allocations¶
| Source | Fix |
|---|---|
Passing values as any (fmt, loggers, errors.New with formatting) |
Typed APIs (slog.Int), level checks before building log args, strconv instead of fmt.Sprintf for numbers |
fmt.Sprintf("%s:%d", host, port) in a hot path |
net.JoinHostPort, strconv.AppendInt, strings.Builder with Grow |
string(b) / []byte(s) conversions that escape |
Keep one representation through the hot path; rely on compiler-optimized cases (map lookups, comparisons) |
| Closures capturing variables that escape | Pass values as arguments; avoid storing closures in long-lived structures in hot paths |
time.After in a loop |
time.NewTimer + Reset (and since Go 1.23 unreferenced timers are collected, but each call still allocates) |
defer in a loop body |
Move the body into a function |
Converting method values (f := obj.Method) |
Call directly in hot loops |
Growing bytes.Buffer / strings.Builder repeatedly |
Grow(n) once |
Technique 6: reduce what is live, not only what is allocated¶
The cost of each GC cycle is proportional to the live heap that must be marked. Halving the live heap of a cache-heavy service can cut GC CPU as much as halving allocations:
- Store compact representations (fixed-size arrays instead of strings
for IDs, integer timestamps instead of
time.Time— 8 bytes instead of 24 including a pointer). - Keep pointer-free data in separate allocations from pointer data so the GC skips it.
- Bound caches by size, and remember maps never shrink (Memory Layout of Go Values).
When to stop¶
Allocation optimization makes code longer and easier to break (pool
misuse, aliasing bugs). Apply it where profiles say allocation
matters: the per-request or per-record path of a hot service. Outside
that, clarity wins. When you do optimize, pin the result with
testing.AllocsPerRun so it cannot regress silently.
Common pitfall¶
Returning a pooled buffer while a slice of it is still in use. An HTTP
handler gets a *bytes.Buffer from a pool, encodes the response into
it, defers bufPool.Put(buf), and returns buf.Bytes() to a
caller that writes it to the connection after the handler returns.
By then another goroutine has taken the buffer from the pool and is
encoding a different response into the same backing array, and
clients occasionally receive another user's data. The bug shows up
only under concurrency, intermittently, and sometimes as a security
incident. Pooled memory must be fully consumed before Put; if bytes
need to outlive the function, copy them out or don't pool.
Review questions¶
- Why does allocation rate affect request latency, not only CPU usage?
- Why did
[]Recneed 1 allocation where[]*Recneeded 1,001, and what does the GC skip in the[]Reccase? - What makes an
Append-style API cheaper than one that returns a new[]byte? - Why should you pool
*bytes.Bufferrather than[]byte, and why cap the size of buffers you put back? - A pool made a benchmark slower. How is that possible?