234 lines
8.9 KiB
Markdown
234 lines
8.9 KiB
Markdown
# Memory Optimization
|
|
|
|
Allocation reduction is the single highest-ROI optimization in most Go programs. Every allocation eventually requires garbage collection — reducing allocation count and size directly reduces GC pauses and CPU overhead.
|
|
|
|
## Allocation Patterns
|
|
|
|
**Diagnose:** 1- `go tool pprof -alloc_objects` — rank functions by number of heap allocations; expect hot-path functions (request handlers, serializers) near the top with thousands of alloc/op 2- `go build -gcflags="-m -m"` — verbose escape analysis showing _why_ variables escape; look for `"leaking param"`, `"too large for stack"`, or `"captured by closure"` on variables you expect to stay on the stack 3- `go test -bench -benchmem` — measure allocs/op and B/op per benchmark; expect the target function to show >0 allocs/op that can be eliminated
|
|
|
|
### Reuse slices via append(s[:0], ...)
|
|
|
|
Reslicing to zero length retains the backing array, turning what would be a new allocation into a no-op:
|
|
|
|
```go
|
|
// Bad — allocates new slice, old one becomes garbage
|
|
mode = []T{item}
|
|
|
|
// Good — reuses existing backing array (0 allocations)
|
|
mode = append(mode[:0], item)
|
|
```
|
|
|
|
### Direct indexing vs append
|
|
|
|
When the output size equals the input size, use `make([]T, len(input))` with direct assignment instead of `make([]T, 0, len(input))` with `append`. Direct assignment avoids per-element bounds checking and length increment:
|
|
|
|
```go
|
|
// Slower — append overhead per element
|
|
result := make([]T, 0, len(input))
|
|
for i := range input { result = append(result, transform(input[i])) }
|
|
|
|
// Faster — direct assignment
|
|
result := make([]T, len(input))
|
|
for i := range input { result[i] = transform(input[i]) }
|
|
```
|
|
|
|
Use append when the result might be smaller (filtering) or when early error return could discard partial results.
|
|
|
|
### Eliminate redundant map lookups
|
|
|
|
`for k := range m { use(m[k]) }` does two lookups per iteration. Capture the value from range:
|
|
|
|
```go
|
|
// Bad — two lookups per iteration
|
|
for k := range in { result[k] = fn(in[k]) }
|
|
|
|
// Good — single lookup
|
|
for k, v := range in { result[k] = fn(v) }
|
|
```
|
|
|
|
### Map size hints
|
|
|
|
`make(map[K]V)` starts with a small number of buckets and rehashes as it grows. Providing a size hint avoids rehashing:
|
|
|
|
```go
|
|
m := make(map[string]int, len(items)) // single allocation, no rehashing
|
|
```
|
|
|
|
### Sentinel errors vs fmt.Errorf
|
|
|
|
`fmt.Errorf` allocates on every call. For predictable errors in hot paths, use preallocated sentinels:
|
|
|
|
```go
|
|
var ErrNegative = errors.New("value is negative") // allocated once
|
|
|
|
func validate(x int) error {
|
|
if x < 0 { return ErrNegative } // zero allocation
|
|
return nil
|
|
}
|
|
```
|
|
|
|
Only use `fmt.Errorf` when you need dynamic context (field names, values).
|
|
|
|
### Interface boxing
|
|
|
|
Passing concrete types through `any`/`interface{}` forces heap allocation for boxing. In hot paths, use typed parameters or generics:
|
|
|
|
```go
|
|
// Bad — boxes each int, allocates
|
|
func sum(values []any) int { ... }
|
|
|
|
// Good — no boxing, no allocation
|
|
func sum(values []int) int { ... }
|
|
|
|
// Good — generic, still no boxing
|
|
func sum[T ~int | ~int64](values []T) T { ... }
|
|
```
|
|
|
|
## Backing Array Leaks
|
|
|
|
**Diagnose:** 1- `go tool pprof -inuse_space` — show currently live heap memory by allocation site; look for unexpectedly large live objects (MB-sized) that should have been GC'd — a sign of backing array retention 2- `go tool pprof -alloc_space` — show cumulative bytes allocated over time; look for allocation sites producing far more bytes than the final data they hold (e.g., 100MB allocated for 16-byte results)
|
|
|
|
### Slice reslicing retains the entire backing array
|
|
|
|
A small reslice of a large slice keeps the entire original array in memory:
|
|
|
|
```go
|
|
// Bad — retains entire megabyte-sized backing array
|
|
func getHeader(data []byte) []byte { return data[:16] }
|
|
|
|
// Good — independent copy, original can be GC'd
|
|
func getHeader(data []byte) []byte {
|
|
header := make([]byte, 16)
|
|
copy(header, data[:16])
|
|
return header
|
|
}
|
|
```
|
|
|
|
### Substring memory leaks
|
|
|
|
Substrings share the backing array of the original string:
|
|
|
|
```go
|
|
// Bad — keeps entire longMsg in memory
|
|
func extractID(msg string) string { return msg[:8] }
|
|
|
|
// Good — independent copy (Go 1.20+)
|
|
func extractID(msg string) string { return strings.Clone(msg[:8]) }
|
|
```
|
|
|
|
### Map never shrinks
|
|
|
|
Go maps grow but never release bucket memory when entries are deleted. A map that once held millions of entries retains its allocation forever:
|
|
|
|
```go
|
|
// Recreate periodically to reclaim memory
|
|
func compact(old map[string]Data) map[string]Data {
|
|
m := make(map[string]Data, len(old))
|
|
for k, v := range old { m[k] = v }
|
|
return m // old map becomes eligible for GC
|
|
}
|
|
```
|
|
|
|
## String and Byte Optimization
|
|
|
|
**Diagnose:** 1- `go tool pprof -alloc_objects` — look for string/byte conversion functions (`runtime.stringtoslicebyte`, `runtime.slicebytetostring`) appearing as top allocators 2- `go test -bench -benchmem` — measure allocs/op; expect repeated conversions to show 1+ alloc/op per conversion that can be reduced to zero by caching
|
|
|
|
**Cache string-to-byte conversions** — converting between `string` and `[]byte` allocates a copy each time. Convert once and reuse the result.
|
|
|
|
**Use `bytes` package directly** — `bytes.Contains`, `bytes.HasPrefix`, `bytes.Split`, `bytes.ToUpper` etc. operate on `[]byte` without string conversion. The `bytes` package mirrors most of `strings`.
|
|
|
|
## sync.Pool Hot-Path Patterns
|
|
|
|
**Diagnose:** 1- `go tool pprof -alloc_objects` — identify hot allocation sites creating the same object type repeatedly (e.g., `[]byte` buffers, temp structs); expect one site with thousands of allocs/s that can be pooled
|
|
|
|
`sync.Pool` recycles objects across GC cycles, reducing allocation pressure. Use it for frequently allocated, short-lived objects in hot paths (HTTP handlers, serialization, logging):
|
|
|
|
```go
|
|
var bufPool = sync.Pool{
|
|
New: func() any {
|
|
buf := make([]byte, 0, 4096)
|
|
return &buf
|
|
},
|
|
}
|
|
|
|
func handleRequest(data []byte) []byte {
|
|
bp := bufPool.Get().(*[]byte)
|
|
buf := (*bp)[:0] // reset length, keep capacity
|
|
defer func() { *bp = buf; bufPool.Put(bp) }()
|
|
|
|
// ... process data into buf ...
|
|
|
|
result := make([]byte, len(buf))
|
|
copy(result, buf) // return a copy — buf goes back to pool
|
|
return result
|
|
}
|
|
```
|
|
|
|
**Rules:**
|
|
|
|
- Reset state before `Put()` — clear references to avoid retaining large object graphs across GC cycles
|
|
- Return copies, not pooled buffers — callers must not hold references to pooled memory
|
|
- Don't pool objects >32KB — large allocations bypass the pool's size classes and GC already handles them efficiently
|
|
- Don't pool infrequently used objects — pool overhead exceeds benefit when allocations are rare
|
|
|
|
→ See `samber/cc-skills-golang@golang-concurrency` skill for `sync.Pool` API reference and basic usage patterns.
|
|
|
|
## Memory Layout
|
|
|
|
**Diagnose:** 1- `fieldalignment ./...` — detect structs with wasted padding bytes; expect warnings like `"struct of size 40 could be 24"` listing which structs benefit from reordering 2- `unsafe.Sizeof`/`Alignof`/`Offsetof` — measure exact byte sizes and field offsets; use to confirm savings before/after and document them in code comments
|
|
|
|
### Struct field alignment
|
|
|
|
Go adds padding between fields to satisfy alignment requirements. Reorder fields from largest to smallest:
|
|
|
|
```go
|
|
// Bad — 24 bytes (7 + 3 bytes padding)
|
|
type Bad struct {
|
|
a bool // 1 byte + 7 padding
|
|
b int64 // 8 bytes
|
|
c bool // 1 byte + 3 padding
|
|
d int32 // 4 bytes
|
|
}
|
|
|
|
// Good — 16 bytes (2 bytes padding)
|
|
type Good struct {
|
|
b int64 // 8 bytes
|
|
d int32 // 4 bytes
|
|
a bool // 1 byte
|
|
c bool // 1 byte + 2 padding
|
|
}
|
|
```
|
|
|
|
**Alignment requirements:** `bool`/`byte` = 1, `int16` = 2, `int32`/`float32` = 4, `int64`/`float64`/`string`/`[]T`/`*T` = 8.
|
|
|
|
**Inspect layout:** `unsafe.Sizeof(T{})`, `unsafe.Alignof(T{})`, `unsafe.Offsetof(T{}.field)`
|
|
|
|
### Zero-size field at end of struct
|
|
|
|
If the last field has zero size (`struct{}`), the compiler adds word-sized padding to prevent a pointer to that field from overlapping the next memory block:
|
|
|
|
```go
|
|
// Bad — 16 bytes (8 for Value + 8 padding for Flag)
|
|
type Entry struct { Value int64; Flag struct{} }
|
|
|
|
// Good — 8 bytes (0 for Flag + 8 for Value)
|
|
type Entry struct { Flag struct{}; Value int64 }
|
|
```
|
|
|
|
Having a `struct{}` field in a struct is rare and almost useless.
|
|
|
|
### Pointer receivers for large structs
|
|
|
|
Value receivers copy the entire struct on every method call. Use pointer receivers for structs larger than ~128 bytes. If any method uses a pointer receiver, all methods should for consistency.
|
|
|
|
### Map of pointers for large, frequently updated structs
|
|
|
|
Map values are not addressable — you cannot modify a field in place. For large structs with frequent updates, `map[K]*V` avoids the copy-modify-reassign pattern:
|
|
|
|
```go
|
|
players := map[string]*Player{"alice": {Score: 100}}
|
|
players["alice"].Score += 10 // direct modification, no copy
|
|
```
|
|
|
|
Trade-off: each pointer is a separate heap allocation, adding GC pressure. For small, mostly-read structs, `map[K]V` (value) is better.
|