376 lines
17 KiB
Markdown
376 lines
17 KiB
Markdown
# 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 { ... }
|
|
}
|
|
```
|