Перейти к содержимому

Heap-анализ, Data-Oriented Design и cache-friendly код

Зачем знать на Middle 3: Когда сервис «странно медленный», классические fixes (больше CPU, больше RAM) не работают. Реальный bottleneck — это память: GC pressure, pointer chasing, cache misses. Data-Oriented Design — мышление, которое game-devs и HFT-инженеры используют десятилетиями, а в Go-сервисах оно даёт реальные 2–5x ускорения на hot path. Senior должен уметь читать heap dump, понимать AoS vs SoA, осознанно проектировать layout структур.

  1. Концепция (heap-анализ + DOD)
  2. Глубже / production-практики
  3. Gotchas
  4. Real cases
  5. Вопросы (25)
  6. Practice
  7. Источники

runtime/pprof собирает heap profile с четырьмя «осями»:

ПрофильЧто измеряетКогда использовать
inuse_spaceРазмер живых объектов сейчас (байты)Где «висит» память — leak hunting
inuse_objectsКоличество живых объектовМного мелких объектов — GC pressure
alloc_spaceCumulative bytes allocated (включая GC’d)Аллокация-hotspots — GC overhead
alloc_objectsCumulative object countСлишком много мелких аллок

Доступ:

Окно терминала
go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap

⚠️ Default режим: inuse_space. Если не указать — будете смотреть live, не cumulative.

runtime/pprof
// Каждый MemProfileRate-th byte вызывает запись stack trace.
runtime.MemProfileRate = 512 * 1024 // default: ~512 KB

Чем меньше — точнее, но дороже. Установка MemProfileRate = 1 даст полную картину, но overhead огромный.

В production обычно оставляют default 512 КБ. Для дебага локально можно опустить до 4096.

debug.WriteHeapDump(fd uintptr) пишет полный snapshot heap в файл. Это не pprof — это binary формат, читаемый утилитой viewcore (часть golang.org/x/debug).

import "runtime/debug"
f, _ := os.Create("/tmp/heap.dump")
debug.WriteHeapDump(f.Fd())
f.Close()

⚠️ STW (stop-the-world) на всё время dump. Может быть секунды для multi-GB heap. Никогда не делайте в production handler. Только в debug endpoint.

viewcore позволяет ходить по графу: «кто держит этот объект», «какие fields указывают на N». Это инструмент уровня «sherlock holmes» — нужен когда pprof не показывает root cause leak.

Mike Acton (engine lead Insomniac Games, потом Unity) сформулировал в 2014:

«Software exists to transform data. Code is a result of that, not a starting point. Design the data first, code follows.»

Три принципа:

  1. Where there is one, there are many. Если у вас один объект — потом будет миллион. Думайте о массивах.
  2. Different problems → different solutions. Не «универсальный класс» — несколько узкоспециализированных layouts.
  3. If you don’t understand the cost of a solution — you don’t understand the problem. Cache misses, branch misprediction, allocation cost — измеряй.

В Go: ограничения GC, но принципы применимы.

CPU cache hierarchy (типично, 2026, Intel/AMD x86_64):

УровеньРазмерLatencyНа что влияет
L1d32–48 KB4–5 цикловTight loops, hot data
L2256–1024 KB12–15 цикловInner loops
L38–32 MB40–75 цикловShared between cores
RAMGBs200+ цикловCold data

Cache line: 64 байта (на современных x86_64 и ARM). Когда CPU читает байт, грузится вся линия.

Sequential access = prefetcher работает = L1 hit. Random access = cache miss = 50–200 циклов на каждый.

Sequential: [A][B][C][D][E] — prefetcher предсказывает следующее
Random: A C E — cache misses
B D

Шаг 1: Снять snapshot.

Окно терминала
curl -s http://service:6060/debug/pprof/heap > heap1.pprof
# подождать N минут
curl -s http://service:6060/debug/pprof/heap > heap2.pprof

Шаг 2: Diff.

Окно терминала
go tool pprof -base heap1.pprof heap2.pprof
(pprof) top20

Top функции, выделившие больше всего памяти между snapshots. Это leak candidates.

Шаг 3: Drill down.

(pprof) list parseJSON
ROUTINE ======================== json.parseJSON in /go/src/json/parse.go
0 4.2GB (flat, cum)
12: map[string]interface{}{} ← 80% leaks тут

Шаг 4: SVG/web visualization.

Окно терминала
go tool pprof -http=:8080 heap2.pprof

Pattern 1: Long-lived map without TTL

var cache = make(map[string][]byte) // никогда не очищается
func set(k string, v []byte) {
cache[k] = v
}

Heap show: runtime.mapassign_faststr в top. Решение: TTL (например, ristretto, freecache, или ручной cleaner).

Pattern 2: Goroutine leak с captured variables

func process(items []Item) {
for _, item := range items {
go func() {
<-time.After(time.Hour) // никогда не cancel
handle(item)
}()
}
}

Heap show: тысячи time.Timer, runtime.gopark. Решение: context.

Pattern 3: bytes.Buffer не возвращается в pool

var bufPool = sync.Pool{New: func() any { return &bytes.Buffer{} }}
func handler(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().(*bytes.Buffer)
// забыли buf.Reset() и defer bufPool.Put(buf)
process(buf, r)
}

Pool помогает только если объекты возвращаются. Heap show: linearly растёт количество bytes.Buffer.

Pattern 4: Strings sharing the same backing array

big := readFile() // 10 MB
small := big[100:200] // ref на тот же массив, 10 MB не освобождается
keepInCache(small)

small это slice header который ссылается на 10 МБ. Решение: string(small) или bytes.Clone(small) — копирует только нужное.

Окно терминала
go install golang.org/x/debug/cmd/viewcore@latest
viewcore /tmp/heap.dump
> objects
> reachable from main.cache
> histogram

Полезно для:

  • Найти, кто держит большой объект (reverse path).
  • Гистограмма размеров allocations.
  • Reachability analysis.

⚠️ viewcore требует core dump того же ARCH/OS. Cross-platform не работает.

Если стрипали (-ldflags="-s -w"), pprof покажет адреса, не имена. Решения:

  1. Не стрипать (Go binaries и так компактные).
  2. Хранить unstripped версию отдельно, использовать на стороне анализа.
Окно терминала
# Compile со symbols
go build -o app
# Strip для deployment
go build -ldflags="-s -w" -o app-stripped
# Сохранить symbol файл
objcopy --only-keep-debug app app.debug

Array of Structs (AoS) — классический подход:

type Particle struct {
PosX, PosY, PosZ float32 // 12 байт
VelX, VelY, VelZ float32 // 12 байт
Color uint32 // 4 байта
Mass float32 // 4 байта
}
// total: 32 байта = половина cache line
particles := make([]Particle, 1_000_000)
for i := range particles {
particles[i].PosX += particles[i].VelX * dt
}

Cache: для каждой particle грузим 32 байта. Прочитали PosX и VelX (8 байт), но cache load был на 64 байта. Если используем 8 — это 8/64 = 12.5% utilization.

Struct of Arrays (SoA):

type Particles struct {
PosX, PosY, PosZ []float32
VelX, VelY, VelZ []float32
Color []uint32
Mass []float32
}
func update(p *Particles, dt float32) {
n := len(p.PosX)
for i := 0; i < n; i++ {
p.PosX[i] += p.VelX[i] * dt
}
}

Cache load: 64 байта PosX = 16 float32. Затем 16 VelX. Затем процессорный SIMD может векторизовать. Utilization: 100%. Ускорение на physics-like workload: 3–10x.

⚠️ В Go: компилятор не векторизует автоматически (нет auto-SIMD). Но prefetcher и cache работают, что даёт 2–4x даже без SIMD.

⚠️ Trade-off: API менее «объектно-ориентированный». particle.PosX теряется. Используется в perf-critical компонентах: physics simulation, batch processing, in-memory analytics.

Linked list иллюстрирует худший случай:

type Node struct {
Value int
Next *Node
}
// 16 байт + GC scan overhead
// Каждый Next — random allocation в heap
// Walk through list = cache miss каждый раз

Альтернатива: arena / pool с stable indexes:

type NodePool struct {
Values []int
Nexts []int32 // index в Values, -1 для nil
}

Все Values живут в одном array, prefetcher эффективен. Indexes (int32) меньше pointer (int64) — больше fit в cache.

Go alignment правила:

  • int8/byte/bool: 1
  • int16: 2
  • int32/float32: 4
  • int64/float64/pointer: 8

Hidden padding между полями для alignment.

Bad:

type S struct {
a bool // 1 байт + 7 padding
b int64 // 8 байт
c bool // 1 байт + 7 padding
d int64 // 8 байт
}
// sizeof = 32 байта

Good:

type S struct {
b int64 // 8
d int64 // 8
a bool // 1
c bool // 1
// 6 байт padding в конце
}
// sizeof = 24 байта (33% экономия)

Утилита fieldalignment от Go team:

Окно терминала
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment -fix ./...

Автоматически переупорядочивает поля.

⚠️ Не упорядочивайте, если порядок важен для wire-format (encoding/binary, struct tags для DB).

⚠️ Поля mutex / atomic должны быть 8-byte aligned на 32-bit платформах. Размещайте их первыми.

GC scanner проходит по pointers. Чем меньше pointers — тем быстрее. Это даёт DOD-friendly паттерн:

// Pointer-heavy (медленный GC scan)
type T struct {
Name *string
Tags []*Tag
Children []*T
}
// Pointer-free (быстрый scan)
type T struct {
NameIdx int32 // index в interned strings table
TagIDs []int32
ChildIDs []int32
}

Reference: Discord’s Go-to-Rust migration в 2020 показала, что GC pauses на Go-сервисе с большим heap (~50 ГБ объектов) были ~2 секунд. После переписывания на Rust — 0.

Это не обязательно «Go плохой», это плата за GC. DOD-friendly Go может конкурировать с Rust на multi-GB heaps.

sync.Pool для same-size allocations:

var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func handle() {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
// используем buf
}

⚠️ sync.Pool объекты могут быть очищены GC (нет гарантий). Для critical paths не подходит как primary cache.

⚠️ Pool per-P (per GOMAXPROCS): scaling хороший, но миграция между P может «потерять» объект.

⚠️ Не возвращайте в pool объекты с pointers, которые могут удерживать GC. Pool — для plain memory.

Для большого количества same-size structs:

type Slab[T any] struct {
chunks [][]T
free []*T // free list
perChunk int
}
func (s *Slab[T]) Alloc() *T {
if len(s.free) > 0 {
t := s.free[len(s.free)-1]
s.free = s.free[:len(s.free)-1]
return t
}
// выделить новую chunk
chunk := make([]T, s.perChunk)
s.chunks = append(s.chunks, chunk)
for i := range chunk[1:] {
s.free = append(s.free, &chunk[i+1])
}
return &chunk[0]
}
func (s *Slab[T]) Free(t *T) {
*t = *new(T) // zero out
s.free = append(s.free, t)
}

Преимущества: no GC pressure (Memory не возвращается в Go heap, переиспользуется), high allocation throughput.

Trade-off: ручной memory management, сложнее debug.

Sequential vs random:

// FAST: sequential
total := 0
for i := 0; i < len(arr); i++ {
total += arr[i]
}
// L1 hit rate ~100%
// SLOW: stride 16 (skip)
total := 0
for i := 0; i < len(arr); i += 16 {
total += arr[i]
}
// L1 hit rate низкий, prefetcher confused
// SLOWER: random access
indices := rand.Perm(len(arr))
for _, i := range indices {
total += arr[i]
}
// Almost guaranteed cache misses

В микро-бенчмарках различие 5–30x.

CPU делает hardware prefetch для sequential patterns. Для non-sequential — можно подсказывать через assembler intrinsics (Go это поддерживает ограниченно).

// Псевдокод (не работает в Go напрямую без asm)
for i := 0; i < n; i++ {
if i+16 < n {
// prefetch arr[i+16]
}
process(arr[i])
}

Реально в Go: используют runtime/internal/atomic или CGO для prefetch. Чаще проще перепроектировать data layout, чтобы access был sequential.

Современные CPU предсказывают ветвления. Predictable branches (одно направление 99% времени) — нулевая цена. Unpredictable — 10–20 циклов misprediction penalty.

// Slow on random data
for _, x := range arr {
if x > threshold {
a += x
} else {
b += x
}
}

Если arr отсортирован — branch predictor работает идеально, и loop в 5x быстрее.

Branch-free варианты:

// branchful
if x > 0 { y = a } else { y = b }
// branchless (compiler может сам сгенерировать cmov)
y = b + (a-b) * btoi(x > 0)

В Go компилятор не агрессивен в branchless rewrite. Прям сильно нужно — assembler.

Hot функции должны быть inlined или близко в памяти (icache).

//go:inline // hint компилятору
func hot(x int) int { return x * 2 }

Go компилятор inline-ит маленькие функции автоматически. Для observability:

Окно терминала
go build -gcflags="-m" ./...
# выводит: "can inline myFunc"

PGO (Go 1.21+) уточняет inlining на основе runtime profile.

Допустим, парсер JSON. Naive:

type Token struct {
Type TokenType // 1 байт
StrVal string // 16 байт
NumVal float64 // 8 байт
Line int // 8
Col int // 8
}
// sizeof ~ 48

Десериализация 100 МБ JSON → миллионы Token-ов → GB heap.

DOD:

type Tokens struct {
Types []TokenType // 1 byte * N
StrStart []uint32 // start offset в общем []byte buffer
StrLen []uint32
NumVals []float64
Lines []uint32
Cols []uint32
}

Это базовая идея в simdjson-go, valyala/fastjson — они хранят данные в плоских массивах + offset table. 5–10x быстрее стандартной encoding/json.


⚠️ inuse_space показывает live HEAP, не RSS. RSS включает stack, mmap, runtime overhead. inuse_space может быть 100 МБ при RSS 500 МБ.

⚠️ alloc_space не = leak. Если функция аллоцирует 1 ГБ, но всё GC-итс, leak нет. Смотрите inuse_space через 5 минут.

⚠️ GC может не вернуть память в OS сразу. Параметр runtime.GOMEMLIMIT (Go 1.19+) контролирует, но default — agressive holding. Для аккуратного освобождения: debug.FreeOSMemory().

⚠️ debug.WriteHeapDump блокирует мир. Не вызывать в hot path. Только из debug-handler с rate limit.

⚠️ SoA снижает читаемость. Используйте только для perf-critical компонентов. Для бизнес-логики AoS лучше.

⚠️ fieldalignment -fix не учитывает encoding tags. Перепроверьте JSON/protobuf/SQL serialization.

⚠️ Cache line false sharing. Два core пишут в разные поля одного 64-байтного struct → cache coherency traffic. Mutex/atomic fields разносите padding-ом:

type Counter struct {
a atomic.Int64
_ [56]byte // padding to next cache line
b atomic.Int64
}

⚠️ sync.Pool в Go 1.21+ улучшен (per-P optimizations), но всё ещё не гарантирует object preservation across GC.

⚠️ Pointer chasing в Go map iteration. for k,v := range map обращается в hash buckets — random pattern. Если перформанс критичен — конвертируйте в slice.

⚠️ String interning может leak, если interning-таблица растёт без bound. Используйте LRU или statically-known strings only.

⚠️ escape analysis surprises. Передача указателя в interface escape-ит в heap, даже если значение «должно» быть на stack. Проверяйте go build -gcflags="-m".


Симптом: pprof inuse_space = 50 МБ, но Kubernetes показывает RSS 200 МБ.

Анализ:

  • runtime.MemStats: HeapAlloc=50, HeapIdle=80, HeapReleased=20, HeapInuse=130.
  • 80 МБ idle = GC не вернул в OS.
  • GOMEMLIMIT=180MiB помог: Go стал агрессивнее возвращать.

Решение: установить GOMEMLIMIT чуть ниже Kubernetes memory limit, добавить debug.SetGCPercent(50) для более частого GC.

Контекст: in-memory analytics service агрегирует 50М событий по dimension.

Before:

type Event struct {
UserID int64
Timestamp int64
Country uint16
EventType uint8
Value float32
}
// AoS: 32 байта, scattered

Aggregation pass: 800 мс на 50М событий.

After:

type Events struct {
UserIDs []int64
Timestamps []int64
Countries []uint16
EventTypes []uint8
Values []float32
}

Aggregation: 100 мс. 8x speedup. Cache misses снизились с 30% до 3%.

Симптом: hot loop с map[int]struct{} (set) — 500 нс/op.

Анализ: для < 1000 elements, slice + linear search быстрее (cache-friendly).

Решение: переписали на []int32 с linear scan. 60 нс/op. 8x speedup.

Симптом: gRPC service с request struct содержит 30 полей разного типа. Profile показывает много памяти в proto.Unmarshal.

Анализ: fieldalignment нашёл 24 байта padding per struct. На 50K RPS это +1 МБ/sec allocations.

Решение: применили fieldalignment -fix. Allocations упали на 15%, GC pause на 8%.

Симптом: pod рестартует каждые 4 часа по OOM. Все «обычные» места проверены.

Анализ:

  • heap1.pprof в 10:00, heap2.pprof в 11:00.
  • pprof -base heap1.pprof heap2.pprof -inuse_space.
  • top1: regexp.MustCompile — 200 МБ накопилось за час.
  • Source: workers вызывают regexp.MustCompile(pattern) в обработке каждой задачи, не кешируют.

Решение: global var re = regexp.MustCompile(pattern). Leak исчез.


  1. Перечислите 4 типа heap-метрик в Go pprof и когда какую использовать.
  2. Что такое MemProfileRate и как его регулирование влияет на overhead vs точность?
  3. Опишите workflow поиска memory leak через pprof -base.
  4. Чем debug.WriteHeapDump отличается от runtime/pprof.Heap?
  5. Когда viewcore полезнее, чем стандартный pprof?
  6. Объясните паттерн утечки через goroutine с captured time.Timer.
  7. Что произойдёт с памятью, если bytes.Buffer взят из pool, но не возвращён?
  8. Как string(b[:N]) помогает избежать удержания большого backing array?
  9. Сформулируйте 3 принципа Data-Oriented Design (Mike Acton).
  10. AoS vs SoA: когда какой подход?
  11. Почему SoA даёт ускорение в hot loops на современных CPU?
  12. Что ограничивает SoA в Go (по сравнению с C++/Rust)?
  13. Объясните pointer chasing и почему linked list cache-unfriendly.
  14. Когда замена pointers на indexes в pool оправдана?
  15. Утилита fieldalignment и что она делает.
  16. Что такое cache line false sharing и как с ним бороться padding-ом?
  17. Опишите slab allocator pattern.
  18. sync.Pool гарантирует, что объект не будет GC’d?
  19. Sequential vs random access: какая разница по cache?
  20. Что такое branch prediction и как sorted data может ускорить loop?
  21. Влияние pointers внутри struct на GC scan time.
  22. Как Discord описывал переход с Go на Rust в контексте GC pauses?
  23. GOMEMLIMIT (Go 1.19+) — что регулирует, когда полезен?
  24. Как escape analysis может «случайно» отправить значение в heap?
  25. Опишите кейс из вашей практики, где DOD-подход дал измеримое ускорение.

Задача 1: Поставить программу с известным leak (например, growing map[int]string). Снять два heap snapshot, найти leak через pprof -base.

Задача 2: Сравнить AoS и SoA на физической симуляции: 1М particles, update position. Замерить cache misses через perf stat -e cache-misses.

Задача 3: Применить fieldalignment к существующему репо с 50+ структурами, посмотреть, сколько байт экономится.

Задача 4: Реализовать slab allocator для фиксированного размера struct, сравнить throughput с обычным new().

Задача 5: Написать benchmark, который демонстрирует branch prediction (loop по sorted vs unsorted slice с условием).

Задача 6: Симулировать false sharing с двумя counters в одной cache line, замерить scaling по cores с и без padding.

Задача 7: Создать виртуальный leak через goroutine pool, отдебажить через goroutine profile.

Задача 8 (advanced): Заполнить большой []byte через mmap, проверить, как это влияет на heap profile (mmap не считается).


  1. Mike Acton, “Data-Oriented Design and C++”, CppCon 2014 — каноническая речь.
  2. Richard Fabian, “Data-Oriented Design”, книга (бесплатно онлайн), 2018.
  3. The Go Blog, “Diagnostics”, https://go.dev/doc/diagnostics
  4. Bryan Cantrill, “Understanding modern CPU caches”, 2017.
  5. Ulrich Drepper, “What Every Programmer Should Know About Memory”, 2007 — классика.
  6. Discord Engineering Blog, “Why Discord is switching from Go to Rust”, 2020.
  7. The Go runtime source: src/runtime/mheap.go, src/runtime/mprof.go.
  8. Felix Geisendörfer, “Profiling Go programs”, talks 2022–2024.
  9. Dave Cheney, “Why is a Goroutine’s stack infinite?”, 2014.
  10. Andrei Alexandrescu, “Fastware”, talks о cache-friendly code.
  11. Daniel Lemire, “simdjson: Parsing gigabytes of JSON per second”, 2019.
  12. valyala/fastjson source — пример DOD в Go.
  13. Hashicorp, “Tuning Go GC for high-throughput services”, 2022.
  14. Russ Cox, “Profile-guided optimization”, Go Blog 2023.
  15. golang.org/x/tools/go/analysis/passes/fieldalignment.