chore: initial plan

This commit is contained in:
claude
2026-07-24 16:23:24 +07:00
commit d47a07af46
32 changed files with 5724 additions and 0 deletions
@@ -0,0 +1,183 @@
# Caching Patterns
The fastest code is code that doesn't run. Caching pre-computed results, deduplicating concurrent requests, and avoiding unnecessary work are often the highest-leverage performance improvements.
## Compiled Pattern Caching
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for `regexp.Compile`, `regexp.MustCompile`, or `template.Parse` appearing in hot paths; their presence means patterns are being recompiled per call instead of once 2- `go test -bench -benchmem` — benchmark per-call compilation vs cached version; expect 10-12x improvement and allocs/op dropping to zero for the compilation step
### Regexp at package level
`regexp.Compile` parses a pattern into a state machine — ~5,700ns per compilation. Match operations on a compiled regexp cost ~450ns. Compiling per-call wastes 10-12x:
```go
// Bad — compiled on every call
func isValid(email string) bool {
re := regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)
return re.MatchString(email)
}
// Good — compiled once, safe for concurrent use
var emailRegex = regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)
func isValid(email string) bool { return emailRegex.MatchString(email) }
```
Note: `regexp.MustCompile` panics on invalid patterns — fine for package-level constants (caught at startup). Use `regexp.Compile` for user-provided patterns. Go's regexp uses linear-time matching (no backtracking).
### Template caching
`template.Parse` is equally expensive. Parse once at startup:
```go
var reportTmpl = template.Must(template.ParseFiles("templates/report.html"))
```
### Precomputed lookup tables
When a computation is pure (same input → same output) and the input space is small, replace calculation with array lookup:
```go
var hexDigit = [16]byte{'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'}
func byteToHex(b byte) (byte, byte) {
return hexDigit[b>>4], hexDigit[b&0x0f] // two array lookups vs branching logic
}
```
If the table fits in L1/L2 cache, lookup is faster than even simple computation.
## Request-Level Caching
**Diagnose:** 1- `go tool pprof` (goroutine profile) — look for many goroutines blocked on the same external call (HTTP fetch, DB query); this signals a cache stampede where N goroutines all miss the cache simultaneously 2- `fgprof` — shows off-CPU wait time; look for the same fetch function dominating wall-clock time across many goroutines, confirming duplicated concurrent work 3- `go tool pprof -alloc_objects` — check if cache miss handling allocates heavily; high alloc counts on fetch functions confirm the stampede is also generating GC pressure
### singleflight for cache stampede prevention
When a cache entry expires, many goroutines may simultaneously discover the miss and all request the same expensive computation. `singleflight` ensures only one goroutine fetches while others wait:
```go
import "golang.org/x/sync/singleflight"
var (
cache sync.Map
sf singleflight.Group
)
func GetWeather(city string) (string, error) {
if val, ok := cache.Load(city); ok {
return val.(string), nil
}
// Only one goroutine fetches; others block on the same key
result, err, _ := sf.Do(city, func() (any, error) {
data, err := fetchFromAPI(city)
if err == nil { cache.Store(city, data) }
return data, err
})
return result.(string), err
}
```
→ See `samber/cc-skills-golang@golang-concurrency` skill for `singleflight` API details and `sync.Map` vs `RWMutex` decision guidance. → **Generics alternative:** Use `github.com/samber/go-singleflightx` to avoid interface{} boxing overhead; expect 2-4x faster result retrieval compared to the standard library's `singleflight.Group`.
### LRU caches
For bounded caches with eviction, the standard library's `container/list` works but has poor cache locality (each node is a separate heap allocation). For high-performance LRU:
- **`github.com/hashicorp/golang-lru`** — thread-safe, simple API
- **`github.com/elastic/go-freelru`** — merges hashmap and ringbuffer into contiguous memory, ~37x faster than sharded implementations
When using third-party cache libraries, refer to the library's official documentation for current API signatures.
## Algorithmic Complexity
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for functions with high cumulative time that contain nested loops or repeated linear scans; these are algorithmic complexity bottlenecks 2- `go test -bench` — benchmark with different input sizes (100, 1K, 10K, 100K); if time grows quadratically (10x input → 100x time), the algorithm is O(n²) and needs replacement
Before micro-optimizing, check that the algorithm itself isn't the bottleneck. A constant-factor improvement on an O(n²) algorithm loses to a naive O(n log n) implementation at scale.
**Common complexity traps in Go:**
| Pattern | Complexity | Fix | Fixed complexity |
| --- | --- | --- | --- |
| `slices.Contains` in a loop | O(n·m) | Build `map[T]struct{}` first, then lookup | O(n+m) |
| Nested loops for matching | O(n²) | Index with a map, sort+binary search, or `slices.BinarySearch` | O(n log n) or O(n) |
| Repeated `append` without prealloc | O(n²) amortized copies | `make([]T, 0, n)` | O(n) |
| String concatenation with `+=` | O(n²) total copies | `strings.Builder` | O(n) |
| Linear scan for min/max/dedup | O(n) per query | Sort once, query many times | O(n log n) + O(log n) per query |
**Think in Big-O first, then optimize constants.** A 10x constant-factor improvement matters; switching from O(n²) to O(n) matters more.
## Work Avoidance
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for linear scan functions (`slices.Contains`, `slices.Index`) or iterator chains (`Filter`, `Map`) consuming CPU in hot paths 2- `go test -bench` — benchmark the current approach vs a map-based or early-return version; expect O(n) → O(1) for membership tests, significant improvement for short-circuit loops
### Map lookups over slice scanning
`Contains(slice, element)` is O(n). Map lookups are O(1). When doing multiple membership tests against the same collection, build a map once:
```go
// Bad — O(n*m), checking Contains per element
for _, item := range subset {
if !Contains(collection, item) { return false } // O(n) per check
}
// Good — O(n+m), build map once, O(1) lookups
seen := make(map[T]struct{}, len(collection))
for _, item := range collection { seen[item] = struct{}{} }
for _, item := range subset {
if _, ok := seen[item]; !ok { return false }
}
```
Use `struct{}` (0 bytes) instead of `bool` (1 byte) for set maps.
### Early returns and short-circuit loops
Return immediately when the answer is known. Finding the target on iteration 3 of 1000 saves 997 iterations:
```go
// Bad — always iterates full collection
found := false
for _, item := range collection {
if item == target { found = true }
}
return found
// Good — returns on first match
for i := range collection {
if collection[i] == target { return true }
}
return false
```
### Avoid iterator chains
Chaining iterator operations (`Filter → Map → First`) creates closures and intermediate machinery. A direct loop is simpler and faster:
```go
// Bad — creates 2 iterators with closures
result, ok := First(Filter(collection, predicate))
// Good — single pass, early return, no closures
for i := range collection {
if predicate(collection[i]) { return collection[i], true }
}
```
### Replace indirect function calls with direct loops
When a function wraps another function (e.g., `FromSlicePtr` calling `Map` with a closure), the closure indirection prevents inlining. Replace with a direct loop:
```go
// Bad — Map() with closure, per-element function call overhead
func FromSlicePtr(items []*T) []T {
return Map(items, func(p *T) T { return *p })
}
// Good — direct loop, inlineable, -13% to -17% time
func FromSlicePtr(items []*T) []T {
result := make([]T, len(items))
for i := range items { result[i] = *items[i] }
return result
}
```
@@ -0,0 +1,375 @@
# CPU Optimization
CPU-bound bottlenecks show up as functions dominating the CPU profile. The patterns below target the most common causes: missed inlining opportunities, poor cache utilization, and unnecessary computation.
## Function Inlining
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for hot functions with high cumulative CPU time; if a small helper dominates the profile, it's likely not being inlined 2- `go build -gcflags="-m"` — grep for `"cannot inline"` on your hot-path functions; the reason (e.g., `"function too complex"`, `"unhandled op"`) tells you what to simplify
The Go compiler inlines small functions, eliminating call overhead. Functions that are too complex (loops, many statements, or calls to non-inlineable functions) won't be inlined — this matters in tight loops called millions of times.
```go
// Bad — log call prevents inlining
func abs(x int) int {
if x < 0 {
log.Printf("negative: %d", x) // blocks inlining
return -x
}
return x
}
// Good — simple enough to inline
func abs(x int) int {
if x < 0 { return -x }
return x
}
```
**Check inlining decisions:**
```bash
go build -gcflags="-m" ./... 2>&1 | grep "can inline"
go build -gcflags="-m" ./... 2>&1 | grep "inlining call"
```
Move side effects (logging, metrics) outside hot-path functions or guard them with conditional checks.
### Value receivers enable inlining
Value receivers allow the compiler to fully inline fluent method chains. Pointer receivers add indirection that blocks inlining:
```go
// Pointer receiver — indirection prevents inlining, constant overhead per call
func (c *config) WithTimeout(d time.Duration) *config { c.timeout = d; return c }
// Value receiver — fully inlined, -80% time in fluent chains
func (c config) WithTimeout(d time.Duration) config { c.timeout = d; return c }
```
## Cache Locality
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for loops over slices/matrices consuming disproportionate CPU; cache-miss-heavy code shows high `runtime.memmove` or flat time in simple index operations 2- `go test -bench` — benchmark row-first vs column-first traversal; expect 10-50x difference on large matrices purely from cache effects
Modern CPUs fetch data in 64-byte cache lines. Sequential memory access is dramatically faster than random access because the prefetcher can load the next cache line before you need it.
### Row-major traversal
Go stores 2D arrays in row-major order. Column-first traversal jumps across memory, causing cache misses:
```go
// Bad — column-first, jumps across memory (~10M cache misses)
for col := 0; col < 1024; col++ {
for row := 0; row < 1024; row++ {
sum += matrix[row][col]
}
}
// Good — row-first, sequential access (~125K cache misses)
for row := 0; row < 1024; row++ {
for col := 0; col < 1024; col++ {
sum += matrix[row][col]
}
}
```
Performance difference: 10-50x purely from cache effects.
### Contiguous 2D allocation
Allocating each row separately scatters data across the heap:
```go
// Bad — N separate allocations, poor cache locality
matrix := make([][]float64, rows)
for i := range matrix { matrix[i] = make([]float64, cols) }
// Good — single contiguous allocation, cache-friendly
data := make([]float64, rows*cols)
matrix := make([][]float64, rows)
for i := range matrix { matrix[i] = data[i*cols : (i+1)*cols] }
```
### Struct of Arrays (SoA) vs Array of Structs (AoS)
When iterating over a single field of a struct, AoS wastes cache space loading unused fields:
```go
// AoS — loading each Point (24 bytes) to read only x (8 bytes) = 66% cache waste
type Point struct { x, y, z float64 }
points := make([]Point, n)
for i := range points { sum += points[i].x }
// SoA — all x values contiguous, 100% cache utilization
type Points struct { xs, ys, zs []float64 }
for i := range ps.xs { sum += ps.xs[i] }
```
Use SoA when iterating over a subset of fields (physics, graphics, analytics). AoS is fine when accessing all fields together or for small structs.
### Pointer-heavy vs value-heavy data
Index-based data structures (nodes stored in a contiguous array, referenced by index) beat pointer-based structures for cache locality:
```go
// Pointer-based tree — each node scattered in heap, random cache misses
type Node struct { value int; left, right *Node }
// Index-based tree — nodes in contiguous array, cache-friendly
type Tree struct { nodes []Node }
type Node struct { value int; left, right int } // indices into nodes
```
## False Sharing
**Diagnose:** 1- `go tool pprof` (CPU profile + mutex profile) — look for atomic operations or counter updates consuming unexpectedly high CPU; in the mutex profile, look for contention on variables that shouldn't need locking 2- `go test -bench` — benchmark concurrent counter increments; if adding goroutines makes it _slower_ instead of faster, false sharing is likely
When goroutines update variables that share the same 64-byte CPU cache line, each write invalidates the other core's cache, causing severe degradation:
```go
// Bad — a and b on same cache line, cores fight for it
type Counters struct { a, b int64 }
// Good — separate cache lines, no interference
type Counters struct {
a int64 // 8 bytes
_ [56]byte // 64 - 8 = 56 bytes padding
b int64 // 8 bytes
}
```
Only apply cache-line padding when profiling confirms contention on concurrent counters/flags.
## Instruction-Level Parallelism
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for tight arithmetic loops (sum, dot product) where the loop body itself dominates CPU; these are candidates for multi-accumulator optimization 2- `go test -bench` — benchmark single vs multi-accumulator versions; expect 2-4x improvement when the loop is truly CPU-bound with a dependency chain
Modern CPUs execute multiple independent instructions simultaneously. A single accumulator creates a dependency chain — each addition waits for the previous one:
```go
// Bad — sequential dependency, CPU pipeline stalls
var total int64
for _, v := range data { total += v }
// Good — 4 independent accumulators, CPU pipelines all 4 in parallel
var s0, s1, s2, s3 int64
limit := len(data) - len(data)%4
for i := 0; i < limit; i += 4 {
s0 += data[i]; s1 += data[i+1]; s2 += data[i+2]; s3 += data[i+3]
}
for i := limit; i < len(data); i++ { s0 += data[i] }
total := s0 + s1 + s2 + s3
```
Expect 2-4x improvement for tight arithmetic loops. Only use when profiling shows the loop is a bottleneck.
## SIMD (Single Instruction, Multiple Data)
**Diagnose:** 1- `go tool pprof` (CPU profile) — confirm a numeric inner loop consumes >20% of CPU; SIMD only helps CPU-bound numeric work, not allocation or I/O bottlenecks 2- `go test -bench` — measure the loop's baseline ns/op; provides the reference point to validate SIMD gains 3- `go build -gcflags="-d=ssa/prove/debug=2"` — check if the compiler already auto-vectorized the loop; look for `"Proved"` bounds-check eliminations that enable vectorization 4- `GOSSAFUNC=MyFunc go build` — generate SSA dump (`ssa.html`) to inspect whether the compiler produces vector instructions for the hot loop 5- `go tool objdump -s MyFunc ./binary` — verify the final assembly contains SIMD instructions (e.g., `VMOVAPD`, `VADDPD` on amd64) rather than scalar equivalents
Go 1.26+ includes an experimental `simd/archsimd` package (requires `GOEXPERIMENT=simd` flag) providing low-level SIMD intrinsics for amd64 with 128/256/512-bit vectors. For broader portability, the compiler auto-vectorizes simple loops, and several strategies exist.
**Options for explicit SIMD in Go:**
- **Experimental `simd/archsimd` (Go 1.26+, speculative)** — Direct SIMD intrinsics via vector types with CPU feature detection. Limited to AMD64. Use with caution: this is an experimental, in-progress API (`GOEXPERIMENT=simd`) whose package path and type names are subject to change before stabilization. Not covered by Go 1 compatibility guarantees, and should never be exposed in public APIs. Verify the actual import path and API against the Go toolchain you are using.
```go
// Requires: GOEXPERIMENT=simd go build
// WARNING: experimental API — package path and types may change
import "simd/archsimd"
v := archsimd.Int32x4{1, 2, 3, 4}
```
- **Let the compiler do it** — write simple, idiomatic loops on `[]float64`/`[]int32` slices. Check auto-vectorization: `go build -gcflags="-d=ssa/prove/debug=2" ./...`
- **`math/bits`** — operations like `OnesCount`, `LeadingZeros`, `RotateLeft` map directly to hardware instructions (POPCNT, CLZ, ROL)
- **Hand-written assembly** — `.s` files with AVX2/NEON instructions for critical inner loops. Libraries like `klauspost/compress` and `minio/sha256-simd` use this approach
- **Third-party vectorized libraries** — for common operations (hashing, compression, encoding), use libraries that already have optimized SIMD implementations rather than writing your own
### Handling CPU-specific instruction sets
Hand-written assembly unlocks higher performance but couples code to specific CPU features (AVX2, NEON, etc.). Three strategies exist:
**1. Compile on a production-similar machine**
Build binaries on hardware matching your deployment target, so the compiler generates code for the exact CPU instruction set available at runtime:
```bash
# Compiling on production hardware ensures optimal code generation
# for that specific CPU architecture and generation
ssh prod-server "cd /path && go build -o app ."
```
**Tradeoff:** Simplest approach, but requires access to production hardware and different binaries per CPU type (Intel vs AMD vs Apple Silicon). Breaks CI/CD portability.
**2. Runtime CPU feature detection + multiple implementations**
Implement the function multiple times — one for each CPU capability — and dispatch at runtime:
```go
// dispatch.go
var sumImpl func([]int64) int64
func init() {
if cpu.X86.HasAVX2 {
sumImpl = sumAVX2
} else {
sumImpl = sumGeneric
}
}
func Sum(data []int64) int64 {
return sumImpl(data)
}
// sum_generic.go
func sumGeneric(data []int64) int64 {
var total int64
for _, v := range data { total += v }
return total
}
// sum_amd64.s
TEXT ·sumAVX2(SB), NOSPLIT, $0-32
// AVX2 implementation
VMOVAPD (SI), Y0
// ...
```
**Tradeoff:** Single binary works everywhere; trades one function-call dispatch overhead for full CPU feature utilization. Libraries like `encoding/base64` and `sha256` use this pattern.
**3. Compile-time selection with `//go:build` tags**
Use conditional compilation to generate different code at build time for each target:
```go
// sum_fast.go
//go:build amd64 && !nosimd
package mylib
// AVX2 assembly via cgo or inline
func Sum(data []int64) int64 {
return sumAVX2(data) // or calls to .s file
}
// sum_generic.go
//go:build !amd64 || nosimd
package mylib
func Sum(data []int64) int64 {
var total int64
for _, v := range data { total += v }
return total
}
```
Build different binaries per target:
```bash
GOOS=linux GOARCH=amd64 go build -o app-avx2 . # Uses sum_fast.go
GOOS=darwin GOARCH=arm64 go build -o app-neon . # Uses sum_generic.go
go build -tags=nosimd -o app-safe . # Fallback everywhere
```
**Tradeoff:** Zero runtime overhead; each binary is fully optimized for its target. Requires shipping multiple binaries and coordinating which binary runs where.
**When SIMD is NOT worth pursuing:**
- Go's lack of intrinsics means SIMD requires assembly — high maintenance burden, platform-specific, and harder to debug
- Auto-vectorization covers the most common cases (simple numeric loops)
- If your bottleneck is allocations or I/O, SIMD won't help
**Recommendation:** Start with auto-vectorization. For Go 1.26+, evaluate `simd/archsimd` for AMD64-only workloads (remembering it's experimental). Move to runtime detection (option 2 above) if profiling shows a bottleneck and the code needs to run on heterogeneous hardware. Only use compile-time selection (option 3) if you control the deployment environment and can test each per-binary variant.
Only invest in hand-written SIMD when profiling shows a numeric inner loop consuming >20% of CPU and the compiler isn't auto-vectorizing it.
## Tight Loops and the Scheduler
**Diagnose:** 1- `go tool pprof` (goroutine profile) — look for many goroutines stuck in `"runnable"` state (waiting for CPU) while one goroutine monopolizes execution 2- `go tool trace` — visualize goroutine scheduling over time; look for long uninterrupted execution spans on one goroutine while others show scheduling gaps 3- `GODEBUG=schedtrace=1000` — print scheduler state every second; look for unbalanced `runqueue` counts across P's indicating one P is starved 4- `runtime/metrics` (`/sched/latencies:seconds`) — measure how long goroutines wait before getting CPU; high p99 latencies confirm starvation 5- Prometheus `rate(process_cpu_seconds_total[2m])` — monitor if CPU usage hits GOMAXPROCS ceiling; if saturated while other goroutines are starved, a tight loop is monopolizing P's
A goroutine running a CPU-intensive tight loop without function calls may not yield to the scheduler, starving other goroutines. Go 1.14+ added asynchronous preemption, but very tight loops with fully inlined operations can still cause issues:
```go
// Potential starvation — pure computation, no function calls
for { x = x*a + b }
// Safe — non-inlined call triggers preemption check
for item := range work {
processBatch(item) // function call = preemption point
}
```
**When to use non-inlined calls for scheduling:** Use non-inlined function calls when:
- The loop runs for a long time (hundreds of milliseconds or more of uninterrupted computation)
- Other goroutines are waiting to run (e.g., handling requests, I/O completion, channel operations)
- The loop contains only arithmetic or memory operations with no function calls
For short bursts of computation (< 10ms), preemption isn't critical and inlining for CPU efficiency takes priority.
**Detecting scheduler starvation:** Use these tools to confirm goroutines are being starved:
- **`go tool pprof` goroutine profile** — shows goroutines stuck in "runnable" state (waiting for CPU). If many goroutines are runnable while one dominates CPU, starvation is happening
- **`go tool trace`** — visualizes goroutine scheduling over time. Look for gaps where goroutines aren't running because one goroutine monopolized the scheduler
- **`runtime/metrics` (Go 1.19+)** — measure `/sched/latencies:seconds` to quantify how long goroutines wait for CPU
- **Observable symptoms** — high response latency, requests timing out, uneven request distribution, goroutine counts climbing
**Preventing inlining with `//go:noinline`:** If you have a function that's normally inlinable (small, hot) but you specifically want it to not inline to force scheduler preemption checks, use the `//go:noinline` compiler directive:
```go
//go:noinline
func processBatch(item WorkItem) {
// CPU-intensive work here
// This call site will NOT be inlined, even if the function is small
// The function call itself becomes a preemption point for the scheduler
}
// In tight loop
for item := range work {
processBatch(item) // Guaranteed preemption point
}
```
**Trade-off:** Using `//go:noinline` prevents inlining, which:
- **Pros:** Guarantees scheduler preemption checks; prevents goroutine starvation
- **Cons:** Adds function call overhead (~10-30 CPU cycles); reduces instruction-level parallelism (ILP) in the caller
Only use `//go:noinline` if profiling shows that scheduler preemption starvation is actually blocking other goroutines. Unnecessary `//go:noinline` directives penalize throughput and latency.
## Reflection and Type Assertions
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for `reflect.Value.*`, `reflect.DeepEqual`, or `fmt.Sprintf` (which uses reflect internally) appearing in hot paths 2- `go test -bench` — compare reflection-based vs typed versions; expect 10-200x difference depending on the reflection operation
- **`reflect` in hot paths** — 10-100x slower due to type introspection and boxing. Replace with generics or typed code
- **`reflect.DeepEqual`** — 50-200x slower than typed comparisons. Use `slices.Equal`, `maps.Equal`, `bytes.Equal` (Go 1.21+)
- **Type switch vs repeated assertions** — type switch dispatches in one evaluation:
```go
// Bad — evaluates interface multiple times
if s, ok := v.(string); ok { return s }
if i, ok := v.(int); ok { return strconv.Itoa(i) }
// Good — single dispatch
switch v := v.(type) {
case string: return v
case int: return strconv.Itoa(v)
}
```
## Monotonic Time
**Diagnose:** 1- `go test -bench` — benchmark `time.Since(start)` vs `time.Now().Sub(start)`; expect a small but consistent improvement from monotonic clock avoiding wall-clock syscall
`time.Since(start)` uses the monotonic clock, which is immune to wall-clock adjustments (NTP, DST) and slightly faster:
```go
var appStart = time.Now() // captures monotonic time + wall-clock on program start
func myFunc() {
// Compare durations, not wall-clock times
elapsed := time.Since(appStart)
if elapsed > threshold { ... }
}
```
@@ -0,0 +1,299 @@
# I/O & Networking Optimization
Network and I/O bottlenecks show up as goroutines blocked on syscalls or waiting for responses. The key levers are connection reuse, proper timeouts, and streaming instead of buffering.
## HTTP Transport Configuration
**Diagnose:** 1- `go tool pprof` (goroutine + block profile) — look for goroutines blocked on `net/http.(*Transport).dialConn` or `net/http.(*persistConn).readLoop`; many goroutines waiting here means connection pool exhaustion 2- `fgprof` — captures both on-CPU and off-CPU wait time; look for HTTP calls dominating wall-clock time even when CPU profile shows them as cheap 3- `go tool trace` — visualize goroutine lifecycles; look for long gaps where goroutines wait for network I/O instead of processing 4- Prometheus `go_goroutines` — monitor goroutine count in production; steadily rising under stable load suggests connection or goroutine leaks from misconfigured HTTP clients
### Connection pooling
The default `http.Transport` has conservative pool settings — `MaxIdleConnsPerHost` defaults to 2. Under high concurrency, requests queue waiting for connections instead of running in parallel:
```go
// Bad — default transport, only 2 idle connections per host
client := &http.Client{}
// Good — tuned for high-concurrency service-to-service calls
var apiClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100, // total idle connections across all hosts
MaxIdleConnsPerHost: 20, // per-host idle connections (default is 2!)
MaxConnsPerHost: 50, // cap total connections per host (0 = unlimited)
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
},
}
```
For web crawlers hitting many different hosts, disable keep-alive to avoid accumulating idle connections:
```go
crawlerClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
```
### Timeouts
The zero-value `http.Client` and `http.Server` have NO timeouts. A slow or malicious peer holds connections open indefinitely, exhausting file descriptors and memory:
```go
// Server — always set timeouts to prevent Slowloris attacks
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
```
### Drain response body for connection reuse
Connections are only returned to the pool when the body is fully read. Even if you don't need the body, drain it:
```go
resp, err := client.Get(url)
if err != nil { return err }
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body) // drain to enable connection reuse
```
## Streaming vs Buffering
**Diagnose:** 1- `go tool pprof -inuse_space` — look for large single allocations (MB-sized) from `io.ReadAll`, `bytes.Buffer.Grow`, or `json.Unmarshal`; these indicate buffering entire payloads instead of streaming
### Avoid io.ReadAll for large payloads
`io.ReadAll` loads the entire stream into memory. For large files or HTTP responses, this causes massive memory spikes:
```go
// Bad — 2GB file = 2GB allocation
data, _ := io.ReadAll(f)
// Good — process line by line, O(1) memory
scanner := bufio.NewScanner(f)
for scanner.Scan() { processLine(scanner.Bytes()) }
// Good — stream between reader and writer (32KB internal buffer)
io.Copy(w, resp.Body)
```
`io.ReadAll` is fine for small, bounded payloads (< 1MB) where the size is known.
### Streaming JSON
Use `json.NewDecoder` for large JSON payloads instead of `json.Unmarshal` (which buffers the entire body):
```go
dec := json.NewDecoder(r)
for dec.More() {
var item Item
if err := dec.Decode(&item); err != nil { return err }
process(item) // one item at a time
}
```
## JSON Performance
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for `encoding/json.(*Decoder).Decode`, `reflect.Value.*`, or `encoding/json.Marshal` consuming significant CPU; these indicate reflection-based JSON is the bottleneck 2- `go test -bench -benchmem` — measure ns/op and allocs/op for marshal/unmarshal; expect high alloc counts from reflection; code-gen alternatives should show 2-5x fewer allocs
The standard `encoding/json` package uses reflection to inspect struct fields at runtime. For high-throughput services, this creates significant CPU and allocation overhead.
**Options for faster JSON:**
- **Custom `MarshalJSON`/`UnmarshalJSON`** — hand-written methods for hot-path types eliminate reflection
- **Code-generation libraries** — `easyjson`, `ffjson` generate marshal/unmarshal methods at build time, no reflection at runtime
- **Drop-in replacements** — `github.com/goccy/go-json`, `github.com/json-iterator/go`, `github.com/bytedance/sonic` offer 2-5x better performance
- **`encoding/json/v2`** (experimental, behind `GOEXPERIMENT=jsonv2`) — evaluate deliberately; most production code should keep `encoding/json` unless the project explicitly opts into the experiment
When using third-party JSON libraries, refer to the library's official documentation for up-to-date API signatures.
## Cgo Overhead
**Diagnose:** 1- `go tool pprof` (CPU profile + threadcreate profile) — look for `runtime.cgocall` or `runtime.asmcgocall` consuming CPU; high threadcreate count means cgo calls are pinning goroutines to OS threads 2- `go test -bench` — benchmark the cgo call loop vs a pure Go equivalent; expect ~50-100ns overhead per cgo crossing
Each Go-to-C call via cgo costs ~50-100ns due to stack switching, signal mask manipulation, and scheduler coordination:
```go
// Bad — cgo overhead per element dominates for tight loops
for i, v := range values {
values[i] = float64(C.sqrt(C.double(v))) // ~100ns overhead PER CALL
}
// Good — use pure Go stdlib (math.Sqrt is as fast as C and inlineable)
for i, v := range values { values[i] = math.Sqrt(v) }
// Good — batch when C code is unavoidable
C.batch_sqrt((*C.double)(&values[0]), C.int(len(values))) // amortize overhead
```
Additional cgo costs: goroutine is pinned to an OS thread, C code cannot be preempted (may delay GC), and function inlining is blocked at the boundary.
## Buffered I/O
**Diagnose:** 1- `go test -bench` — benchmark buffered vs unbuffered I/O; expect 3-10x improvement from reducing syscall count 2- `go tool trace` — look for frequent short syscalls (`pread`, `pwrite`) in rapid succession; many tiny I/O operations indicate unbuffered access
Unbuffered file reads/writes issue a syscall per operation. `bufio.Reader` and `bufio.Writer` batch small operations, reducing syscalls by 10x or more:
```go
// Bad — syscall per line
for _, line := range lines { f.WriteString(line + "\n") }
// Good — buffered, batches writes into larger chunks
w := bufio.NewWriter(f)
for _, line := range lines { w.WriteString(line + "\n") }
w.Flush()
```
## Concurrent Multi-Stage Pipelines
**Diagnose:** 1- `go tool trace` — visualize resource utilization across stages; look for sequential idle gaps where CPU, disk, or network sit unused while another resource is busy 2- `go tool pprof` (CPU + goroutine profile) — confirm each stage saturates a _different_ resource; if multiple stages compete for the same resource (e.g., both CPU-bound), concurrency won't help
In rare scenarios where each pipeline stage saturates a _different_ resource (CPU, disk I/O, network), running stages concurrently instead of sequentially can improve throughput — even with batching between stages.
### The unusual scenario
Imagine processing records: Stage A compresses (CPU-bound), Stage B writes to disk (I/O-bound), Stage C uploads to network (network-bound). Sequential execution wastes resources:
```
Time: 0 10 20 30 40 50
CPU: AAAAAAAAAA|..........|..........|..........|
Disk: ..........|BBBBBBBBBB|..........|..........|
Network: ..........|..........|CCCCCCCCCC|..........|
```
Concurrent stages let resources work in parallel:
```
Time: 0 10 20 30 40 50
CPU: AAAAAAAAAA|AA........|
Disk: ..........|BBBBBBBBBB|BB........|
Network: ..........|..........|CCCCCCCCCC|CC........|
```
**Code pattern:**
```go
// Each stage runs in its own goroutine, bounded by channel buffers
compressedCh := make(chan []byte, 100) // A → B buffer
uploadedCh := make(chan bool, 100) // B → C buffer
// Stage A: CPU-bound compression
go func() {
for record := range inputCh {
compressed := compress(record) // saturates CPU
compressedCh <- compressed
}
close(compressedCh)
}()
// Stage B: I/O-bound disk writes
go func() {
for compressed := range compressedCh {
diskFile.Write(compressed) // saturates disk I/O
uploadedCh <- true
}
close(uploadedCh)
}()
// Stage C: network-bound uploads
go func() {
for <-uploadedCh {
client.Post(uploadURL, ...) // saturates network
}
}()
```
With batching per stage, total throughput = min(A_throughput, B_throughput, C_throughput). Without concurrency, throughput = sequential sum of stages. **Concurrent stages only help when bottlenecks don't overlap.**
### When to use this (and when NOT to)
**Use concurrent pipelines only when ALL of these are true:**
1. **Resource saturation is predictable and non-overlapping** — You measured that A saturates one resource (e.g., CPU = 95%), B saturates another (disk I/O = 90%), C saturates a third (network = 85%). Overlapping saturation means concurrency adds no benefit.
2. **Bottleneck shifts don't hurt latency** — Processing order doesn't matter, or records can flow out-of-order through stages.
3. **Buffering overhead is acceptable** — Inter-stage channels consume memory. For large records, channel buffers can overflow system limits.
4. **You've benchmarked the alternative** — Profile both sequential and concurrent versions. Sequential + batching often wins because it is simpler and avoids context-switching overhead.
**Avoid concurrent pipelines if:**
- **Records must be ordered** — Concurrent processing may reorder records; if downstream expects order, you need synchronization that kills the speedup.
- **Resources overlap** — If A and B both compete for CPU (e.g., both compress), concurrency causes context-switching overhead with no resource utilization gain.
- **Latency matters more than throughput** — A single record now travels through 3 stages in parallel, increasing per-record latency.
- **Memory is tight** — Each stage's channel buffer is a memory budget; deeply buffered channels can exhaust available RAM.
→ See `samber/cc-skills-golang@golang-concurrency` skill for detailed channel patterns and when to use worker pools instead.
## Batch Operations
**Diagnose:** 1- `go test -bench` — benchmark single-item vs batched operations; expect N-fold improvement in throughput when amortizing per-operation overhead (syscalls, round-trips) 2- `go tool trace` — look for repeated short network/disk operations with idle gaps between them; these gaps represent wasted round-trip time that batching eliminates
Batching amortizes per-operation overhead (syscalls, network round-trips, transaction costs) across many items. The pattern applies everywhere: I/O, database, network, and even in-memory processing.
### Database: batch inserts over row-by-row
Inserting 1,000 rows one at a time means 1,000 round-trips, 1,000 query parses, and 1,000 transaction commits. A single batch insert does it in one round-trip:
```go
// Bad — 1,000 round-trips, ~500ms
for _, user := range users {
db.Exec("INSERT INTO users (name, email) VALUES ($1, $2)", user.Name, user.Email)
}
// Good — 1 round-trip with multi-row VALUES, ~5ms
const batchSize = 1000
for i := 0; i < len(users); i += batchSize {
end := min(i+batchSize, len(users))
batch := users[i:end]
// Build multi-row INSERT or use COPY protocol
tx, _ := db.Begin()
stmt, _ := tx.Prepare(pq.CopyIn("users", "name", "email"))
for _, u := range batch { stmt.Exec(u.Name, u.Email) }
stmt.Exec()
tx.Commit()
}
```
→ See `samber/cc-skills-golang@golang-database` skill for detailed batch patterns and connection pool configuration.
### HTTP: batch API calls
Instead of N individual HTTP requests, send one request with N items when the API supports it:
```go
// Bad — 100 HTTP round-trips
for _, id := range ids {
resp, _ := client.Get(fmt.Sprintf("/api/users/%s", id))
// ...
}
// Good — 1 HTTP request with all IDs
resp, _ := client.Post("/api/users/batch", "application/json",
bytes.NewReader(marshalIDs(ids)))
```
### Channel: batch processing from a stream
Accumulate items from a channel and process in bulk to reduce per-item overhead:
```go
func batchProcessor(in <-chan Item, batchSize int) {
batch := make([]Item, 0, batchSize)
ticker := time.NewTicker(100 * time.Millisecond) // flush on timeout too
defer ticker.Stop()
for {
select {
case item, ok := <-in:
if !ok { flush(batch); return }
batch = append(batch, item)
if len(batch) >= batchSize { flush(batch); batch = batch[:0] }
case <-ticker.C:
if len(batch) > 0 { flush(batch); batch = batch[:0] }
}
}
}
```
@@ -0,0 +1,233 @@
# 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.
@@ -0,0 +1,101 @@
# Production Observability for Performance
Third-party monitoring tools complement local profiling (pprof, benchmarks) by providing continuous monitoring, historical trends, and regression detection in production.
## Prometheus Metrics for Go
**Setup:** `github.com/prometheus/client_golang` — expose `/metrics` endpoint with `promhttp.Handler()`. Default collectors automatically export Go runtime metrics (`go_goroutines`, `go_memstats_*`, `go_gc_duration_seconds`, `process_cpu_seconds_total`, etc.).
→ See `samber/cc-skills-golang@golang-benchmark` skill (investigation-session.md) for the full runtime metrics table, investigation session setup (scrape interval tuning, env-var toggling), and cost warnings for profiling tools.
### PromQL Queries for Performance Diagnosis
#### GC pressure
| PromQL | What to look for |
| --- | --- |
| `rate(go_gc_duration_seconds_count[5m])` | GC cycles/s — >2/s sustained suggests excessive allocation rate |
| `rate(go_gc_duration_seconds_sum[5m]) / rate(go_gc_duration_seconds_count[5m])` | Average GC pause — increasing trend means heap is growing or has too many pointers |
| `go_gc_duration_seconds{quantile="1"}` | Worst-case GC pause — spikes here cause tail latency |
#### Memory leaks
| PromQL | What to look for |
| --- | --- |
| `go_memstats_alloc_bytes` | Should be roughly stable under constant load; continuous increase = memory leak |
| `rate(go_memstats_alloc_bytes_total[5m])` | Allocation rate (bytes/s) — drives GC frequency; compare before/after deploy for regressions |
| `process_resident_memory_bytes - go_memstats_sys_bytes` | Gap = non-Go memory (cgo, mmap); growing gap = non-Go leak |
#### Goroutine leaks
| PromQL | What to look for |
| --- | --- |
| `go_goroutines` | Should correlate with load; growing independently of traffic = leak |
| `delta(go_goroutines[1h])` | Net goroutine change over 1h; positive without load increase = leak |
#### CPU saturation
| PromQL | What to look for |
| --- | --- |
| `rate(process_cpu_seconds_total[5m])` | CPU cores consumed; compare to GOMAXPROCS to detect saturation |
| `rate(process_cpu_seconds_total[5m]) / <GOMAXPROCS>` | CPU utilization ratio; >0.8 sustained = CPU-saturated |
#### Regression detection (after deploy)
| PromQL | What to look for |
| --- | --- |
| `rate(go_memstats_alloc_bytes_total[5m])` | Compare before/after deploy; significant increase = new allocation pattern introduced |
| `histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))` | p99 latency increase after deploy = regression (requires app-level histogram) |
### Alerting rules (examples)
[Example alerting rules](../assets/prometheus-alerts.yml) — adjust thresholds to your application; a high-throughput data pipeline will have different baselines than a lightweight API server.
→ See `samber/cc-skills@promql-cli` skill for interactively testing these PromQL expressions against your Prometheus instance from the CLI.
### Grafana Dashboards
→ See `samber/cc-skills-golang@golang-observability` skill for recommended community Grafana dashboards that visualize Go runtime metrics out of the box.
## Continuous Profiling
Continuous profiling collects low-overhead samples in production and stores them for historical comparison. Use it to detect regressions across deploys, compare flamegraphs over time, and feed PGO (see [Runtime Tuning](./runtime.md#profile-guided-optimization-pgo)).
| Tool | Model | Overhead | Best for |
| --- | --- | --- | --- |
| **Grafana Pyroscope** | push SDK or pull (via Alloy) | ~2-5% | Grafana ecosystem, historical flamegraph comparison |
| **Parca** (Polar Signals) | eBPF-based pull | <1% | Infrastructure-wide profiling, no code changes |
| **Datadog Continuous Profiler** | push (agent) | ~1-2% | Existing Datadog users |
| **Google Cloud Profiler** | push (agent) | ~1-2% | GCP-hosted Go services |
### Pyroscope push mode
```go
import "github.com/grafana/pyroscope-go"
pyroscope.Start(pyroscope.Config{
ApplicationName: "myapp",
ServerAddress: "http://pyroscope:4040",
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
},
})
```
### Pyroscope pull mode (via Grafana Alloy)
No code changes required — Alloy scrapes `/debug/pprof/*` endpoints periodically. Configure Alloy to target your service's pprof endpoint.
When using third-party profiling libraries, refer to the library's official documentation for current API signatures.
## Real-Time Visualization (Development)
| Tool | What it does |
| --- | --- |
| **statsviz** (`github.com/arl/statsviz`) | Real-time browser dashboard at `/debug/statsviz` — heap, GC pauses, goroutines, scheduler. Register with `statsviz.Register(mux)`. Great for local development |
| **expvar** (stdlib `expvar`) | JSON metrics at `/debug/vars` — lightweight, no dependencies. Integrates with Netdata, Telegraf, or custom dashboards |
@@ -0,0 +1,222 @@
# Runtime Tuning
Runtime settings control garbage collection frequency, memory limits, CPU scheduling, and compiler optimizations. Tune them after profiling — the defaults are well-chosen for most workloads.
## Garbage Collector Tuning
**Diagnose:** 1- `GODEBUG=gctrace=1` — print one line per GC cycle; look for high GC frequency (cycles/s), high CPU% (>5% means GC is competing for CPU), or heap growing faster than expected 2- `runtime.ReadMemStats` — inspect `Alloc`, `TotalAlloc`, `NumGC`, `PauseNs`; compare `Alloc` vs `Sys` to see how much memory the GC is reclaiming vs how much the OS allocated 3- `go tool trace` — visualize GC stop-the-world pauses and GC assist stealing CPU from application goroutines; look for long STW bars or frequent assist marks 4- `debug.ReadGCStats` — get pause time percentiles (p50, p95, p99); high p99 pauses indicate large heap scans or too many pointers 5- `runtime/metrics` — programmatic access to GC stats for dashboards; monitor `/gc/cycles/total`, `/gc/heap/allocs`, `/gc/pauses` 6- `GODEBUG=gcpacertrace=1` — trace the GC pacer's decisions; useful to understand why GC triggers earlier or later than expected 7- Prometheus `rate(go_gc_duration_seconds_count[5m])` — monitor GC frequency in production; >2 cycles/s sustained suggests excessive allocation rate
### GOGC (default: 100)
Controls the heap growth ratio that triggers the next GC cycle. `GOGC=100` means GC runs when the heap doubles since the last collection. Higher values reduce GC frequency but use more memory:
```bash
GOGC=50 ./myapp # latency-sensitive: more frequent, shorter GC pauses
GOGC=200 ./myapp # throughput-oriented: less frequent GC, more memory used
GOGC=off ./myapp # disable GC entirely (testing only!)
```
### GOMEMLIMIT (Go 1.19+)
Soft memory limit — the runtime increases GC frequency to stay under this limit. Essential for containerized applications where exceeding the container limit triggers an OOM kill:
```bash
# Container with 512MB limit: leave headroom for non-heap memory (goroutine stacks, OS buffers)
GOMEMLIMIT=450MiB ./myapp
# Container with 1GB limit
GOMEMLIMIT=900MiB ./myapp
```
The GC pacer adjusts collection timing based on both GOGC and GOMEMLIMIT. When the heap approaches the limit, the GC runs more aggressively regardless of GOGC.
### Programmatic control
```go
import "runtime/debug"
debug.SetGCPercent(200) // equivalent to GOGC=200
debug.SetMemoryLimit(450 * 1024 * 1024) // 450 MiB soft limit
```
Use programmatic control for dynamic tuning based on observed workload, or when environment variables cannot be set.
### Ballast pattern (pre-Go 1.19)
Before GOMEMLIMIT, teams allocated a large byte array at startup to inflate the live heap size, reducing GC frequency:
```go
var ballast [1 << 30]byte // 1 GB — obsolete pattern
```
**GOMEMLIMIT is strictly better** — it provides the same benefit (fewer GC cycles) without wasting physical memory. Use GOMEMLIMIT instead.
## GC Profiling and Diagnostics
### GODEBUG=gctrace=1
Prints a line per GC cycle to stderr:
```bash
GODEBUG=gctrace=1 ./myapp 2>&1 | head -20
```
Sample output:
```
gc 5 @1.234s 2%: 0.012+12+0.9 ms clock, 0.25+8.9/20+18 ms cpu, 45->92->50 MB, 200 MB goal, 8 P
```
Key fields:
- `gc 5` — 5th GC cycle
- `@1.234s` — time since program start
- `2%` — total CPU time spent in GC
- `45->92->50 MB` — heap before → peak during collection → after
- `200 MB goal` — target heap size (based on GOGC and GOMEMLIMIT)
- `8 P` — number of processors
Watch for: GC frequency (too often = too many allocations), pause times (high = large heap or many pointers), CPU% (high = tune GOGC or reduce allocations).
### runtime.ReadMemStats
Programmatic monitoring for dashboards and alerting:
```go
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024) // currently allocated
fmt.Printf("TotalAlloc: %d MB\n", m.TotalAlloc/1024/1024) // cumulative
fmt.Printf("Sys: %d MB\n", m.Sys/1024/1024) // requested from OS
fmt.Printf("NumGC: %d\n", m.NumGC) // completed collections
fmt.Printf("LastPause: %d ms\n", m.PauseNs[(m.NumGC+255)%256]/1_000_000)
```
### GC pacing
The GC pacer predicts when to start the next collection based on:
1. **Live heap size** after the last collection
2. **GOGC percentage** — how much growth to allow
3. **GOMEMLIMIT** — soft ceiling (if set)
4. **Current allocation rate** — how fast the heap is growing
The pacer starts collection early enough to finish before hitting the target. Fast allocation rates cause earlier starts.
## Allocation Rate Reduction
**Diagnose:** 1- `go tool pprof -alloc_objects` — rank functions by allocation count; the top allocators are where allocation reduction will have the biggest GC impact 2- `GODEBUG=gctrace=1` — monitor GC frequency before and after reducing allocations; expect fewer GC cycles per second as allocation rate drops 3- Prometheus `rate(go_memstats_alloc_bytes_total[5m])` — track allocation rate trend in production; compare before/after deploy to detect regressions
Reducing allocations helps more than tuning GOGC — it addresses the root cause instead of managing the symptom:
- **Value types over pointer types** where possible — values stay on the stack (no GC), pointers escape to the heap
- **Pool frequently allocated objects** with `sync.Pool` (see [memory.md](./memory.md))
- **Preallocate slices and maps** — → See `samber/cc-skills-golang@golang-data-structures` skill
- **Avoid interface boxing** in hot paths — use typed parameters or generics
## GOMAXPROCS in Containers
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for high `runtime.schedule` or `runtime.findRunnable` overhead; this indicates too many P's competing for work or too few P's starving goroutines 2- `go tool trace` — check if goroutines are evenly distributed across P's; uneven distribution suggests GOMAXPROCS is misconfigured for the container 3- `GODEBUG=schedtrace=1000` — print scheduler state every second; look for `runqueue` imbalances or idle P's when work is available 4- `runtime.GOMAXPROCS(0)` — query the current value; if it returns the host CPU count (e.g., 64) instead of the container limit (e.g., 2), the runtime is over-scheduling 5- Prometheus `rate(process_cpu_seconds_total[5m])` — monitor CPU cores consumed in production; if consistently near GOMAXPROCS value, the app is CPU-saturated
**Go 1.25+** improves container CPU detection, particularly for cgroup v2. The runtime sets `GOMAXPROCS` based on:
- Logical CPUs on the machine
- Process CPU affinity mask
- cgroup CPU quota limits (on Linux)
In a container with 2 CPU cores on a 64-core host running Go 1.25+ with **cgroup v2**, `GOMAXPROCS` is correctly set to 2 by default. For **cgroup v1** environments, validate the detected value at startup and consider using `go.uber.org/automaxprocs` to ensure correctness.
**For Go 1.24 and earlier**, use the `go.uber.org/automaxprocs` library to handle container CPU detection:
```go
// Pre-Go 1.25: explicit container-aware detection
import _ "go.uber.org/automaxprocs"
func main() {
// GOMAXPROCS is now correctly set to container CPU limit
startServer()
}
```
**Manual override** (if needed):
```bash
GOMAXPROCS=2 ./myapp
GODEBUG=updatemaxprocs=0 ./myapp # disable dynamic updates (Go 1.25+)
```
**Known limitations (Go 1.25)**: cgroup v1 on certain systems (Oracle OCPUs) may not properly detect Kubernetes CPU limits. Manually set `GOMAXPROCS` as a workaround in these cases.
## Profile-Guided Optimization (PGO)
**Diagnose:** 1- `go tool pprof` (CPU profile) — collect a representative production profile (30+ seconds); look for hot interface method calls and deep call chains that PGO can optimize via devirtualization and inlining 2- `go test -bench` — benchmark before and after placing `default.pgo`; expect 2-7% improvement on interface-heavy code, less on already-optimized paths
Go 1.21+ supports PGO — the compiler uses a production CPU profile to make better inlining and devirtualization decisions. Expected improvement: 2-7% for minimal effort.
**Workflow:**
1. Collect a production CPU profile (30+ seconds of representative load):
```bash
curl http://localhost:6060/debug/pprof/profile?seconds=60 > cpu.pprof
```
2. Place as `default.pgo` in the main package directory:
```bash
cp cpu.pprof ./cmd/myapp/default.pgo
```
3. Build — `go build` auto-detects `default.pgo`:
```bash
go build ./cmd/myapp
```
**What the compiler optimizes:**
- **Inlining** — hot function calls are inlined more aggressively
- **Devirtualization** — interface method calls with high probability of targeting specific types become direct calls
**When it helps most:** code with many interface calls, hot inlining opportunities, deep call stacks. **When it helps least:** already-optimized code, memory-bound workloads.
Rebuild profiles after significant code changes — stale profiles can mislead the compiler.
## Logging Overhead in Hot Paths
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for `fmt.Sprintf`, `log.Printf`, or `slog.(*Logger).log` appearing in hot paths; these indicate log formatting consuming CPU even when the log level filters the message 2- `go build -gcflags="-m"` — check if log arguments escape to the heap; expect `"moved to heap"` for arguments boxed into `any` interface by logging functions 3- `go test -bench -benchmem` — benchmark with logging enabled vs disabled; if allocs/op doesn't change, the logger is allocating even when the level is off
Log formatting allocates memory and consumes CPU even when the message is discarded because it's below the configured level:
```go
// Bad — fmt.Sprintf runs BEFORE the logger checks the level
logger.Debug(fmt.Sprintf("processing item %d with data %v", item.ID, item.Data))
// Good — slog defers formatting until level check passes (Go 1.21+)
slog.Debug("processing item", slog.Int("id", item.ID), slog.Any("data", item.Data))
// Best — LogAttrs: zero allocations when level is disabled
slog.LogAttrs(ctx, slog.LevelDebug, "processing item",
slog.Int("id", item.ID))
```
In hot paths, even `slog.Any` can allocate. Prefer typed attributes: `slog.Int`, `slog.String`, `slog.Bool`.
## Panic/Recover Cost
**Diagnose:** 1- `go tool pprof` (CPU profile) — look for `runtime.gopanic` or `runtime.gorecover` in the profile; their presence in hot paths means panic/recover is being used for control flow 2- `go test -bench` — benchmark panic/recover vs error-return versions; expect 10-100x overhead from stack unwinding and defer execution
`panic` triggers stack unwinding, running all deferred functions up the call stack. `recover` catches the panic but the unwinding itself is expensive. Never use panic/recover for control flow:
```go
// Bad — panic overhead for a normal condition
defer func() { recover() }()
v, _ := strconv.Atoi(s) // relies on panic for invalid input
// Good — explicit error check, no panic overhead
v, err := strconv.Atoi(s)
if err != nil { continue }
```
Panic is appropriate only for truly unrecoverable situations (programmer errors, corrupted state). Always convert panics to errors at package boundaries.