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 структур.
Содержание
Заголовок раздела «Содержание»- Концепция (heap-анализ + DOD)
- Глубже / production-практики
- Gotchas
- Real cases
- Вопросы (25)
- Practice
- Источники
1. Концепция
Заголовок раздела «1. Концепция»1.1 Heap profile в Go: 4 типа метрик
Заголовок раздела «1.1 Heap profile в Go: 4 типа метрик»runtime/pprof собирает heap profile с четырьмя «осями»:
| Профиль | Что измеряет | Когда использовать |
|---|---|---|
inuse_space | Размер живых объектов сейчас (байты) | Где «висит» память — leak hunting |
inuse_objects | Количество живых объектов | Много мелких объектов — GC pressure |
alloc_space | Cumulative bytes allocated (включая GC’d) | Аллокация-hotspots — GC overhead |
alloc_objects | Cumulative object count | Слишком много мелких аллок |
Доступ:
go tool pprof -inuse_space http://localhost:6060/debug/pprof/heapgo tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap⚠️ Default режим: inuse_space. Если не указать — будете смотреть live, не cumulative.
1.2 Sampling: MemProfileRate
Заголовок раздела «1.2 Sampling: MemProfileRate»// Каждый MemProfileRate-th byte вызывает запись stack trace.runtime.MemProfileRate = 512 * 1024 // default: ~512 KBЧем меньше — точнее, но дороже. Установка MemProfileRate = 1 даст полную картину, но overhead огромный.
В production обычно оставляют default 512 КБ. Для дебага локально можно опустить до 4096.
1.3 Heap dump (post-mortem)
Заголовок раздела «1.3 Heap dump (post-mortem)»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.
1.4 Data-Oriented Design — что это
Заголовок раздела «1.4 Data-Oriented Design — что это»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.»
Три принципа:
- Where there is one, there are many. Если у вас один объект — потом будет миллион. Думайте о массивах.
- Different problems → different solutions. Не «универсальный класс» — несколько узкоспециализированных layouts.
- If you don’t understand the cost of a solution — you don’t understand the problem. Cache misses, branch misprediction, allocation cost — измеряй.
В Go: ограничения GC, но принципы применимы.
1.5 Cache-friendly code
Заголовок раздела «1.5 Cache-friendly code»CPU cache hierarchy (типично, 2026, Intel/AMD x86_64):
| Уровень | Размер | Latency | На что влияет |
|---|---|---|---|
| L1d | 32–48 KB | 4–5 циклов | Tight loops, hot data |
| L2 | 256–1024 KB | 12–15 циклов | Inner loops |
| L3 | 8–32 MB | 40–75 циклов | Shared between cores |
| RAM | GBs | 200+ циклов | 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 D2. Глубже / production-практики
Заголовок раздела «2. Глубже / production-практики»2.1 Анализ heap: workflow
Заголовок раздела «2.1 Анализ heap: workflow»Шаг 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) top20Top функции, выделившие больше всего памяти между snapshots. Это leak candidates.
Шаг 3: Drill down.
(pprof) list parseJSONROUTINE ======================== 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.pprof2.2 Detecting memory leaks: паттерны
Заголовок раздела «2.2 Detecting memory leaks: паттерны»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 MBsmall := big[100:200] // ref на тот же массив, 10 MB не освобождаетсяkeepInCache(small)small это slice header который ссылается на 10 МБ. Решение: string(small) или bytes.Clone(small) — копирует только нужное.
2.3 viewcore tool
Заголовок раздела «2.3 viewcore tool»go install golang.org/x/debug/cmd/viewcore@latestviewcore /tmp/heap.dump> objects> reachable from main.cache> histogramПолезно для:
- Найти, кто держит большой объект (reverse path).
- Гистограмма размеров allocations.
- Reachability analysis.
⚠️ viewcore требует core dump того же ARCH/OS. Cross-platform не работает.
2.4 DWARF symbols в проде
Заголовок раздела «2.4 DWARF symbols в проде»Если стрипали (-ldflags="-s -w"), pprof покажет адреса, не имена. Решения:
- Не стрипать (Go binaries и так компактные).
- Хранить unstripped версию отдельно, использовать на стороне анализа.
# Compile со symbolsgo build -o app
# Strip для deploymentgo build -ldflags="-s -w" -o app-stripped
# Сохранить symbol файлobjcopy --only-keep-debug app app.debug2.5 Array of Structs vs Struct of Arrays (AoS vs SoA)
Заголовок раздела «2.5 Array of Structs vs Struct of Arrays (AoS vs SoA)»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.
2.6 Pointer chasing
Заголовок раздела «2.6 Pointer chasing»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.
2.7 Struct field ordering
Заголовок раздела «2.7 Struct field ordering»Go alignment правила:
int8/byte/bool: 1int16: 2int32/float32: 4int64/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@latestfieldalignment -fix ./...Автоматически переупорядочивает поля.
⚠️ Не упорядочивайте, если порядок важен для wire-format (encoding/binary, struct tags для DB).
⚠️ Поля mutex / atomic должны быть 8-byte aligned на 32-bit платформах. Размещайте их первыми.
2.8 Go GC и DOD ограничения
Заголовок раздела «2.8 Go GC и DOD ограничения»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.
2.9 Memory pools
Заголовок раздела «2.9 Memory pools»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.
2.10 Slab allocator pattern
Заголовок раздела «2.10 Slab allocator pattern»Для большого количества 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.
2.11 Stride access patterns
Заголовок раздела «2.11 Stride access patterns»Sequential vs random:
// FAST: sequentialtotal := 0for i := 0; i < len(arr); i++ { total += arr[i]}// L1 hit rate ~100%
// SLOW: stride 16 (skip)total := 0for i := 0; i < len(arr); i += 16 { total += arr[i]}// L1 hit rate низкий, prefetcher confused
// SLOWER: random accessindices := rand.Perm(len(arr))for _, i := range indices { total += arr[i]}// Almost guaranteed cache missesВ микро-бенчмарках различие 5–30x.
2.12 Prefetching
Заголовок раздела «2.12 Prefetching»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.
2.13 Branch prediction
Заголовок раздела «2.13 Branch prediction»Современные CPU предсказывают ветвления. Predictable branches (одно направление 99% времени) — нулевая цена. Unpredictable — 10–20 циклов misprediction penalty.
// Slow on random datafor _, x := range arr { if x > threshold { a += x } else { b += x }}Если arr отсортирован — branch predictor работает идеально, и loop в 5x быстрее.
Branch-free варианты:
// branchfulif x > 0 { y = a } else { y = b }
// branchless (compiler может сам сгенерировать cmov)y = b + (a-b) * btoi(x > 0)В Go компилятор не агрессивен в branchless rewrite. Прям сильно нужно — assembler.
2.14 Code locality
Заголовок раздела «2.14 Code locality»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.
2.15 Real-world DOD: pareser
Заголовок раздела «2.15 Real-world DOD: pareser»Допустим, парсер 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.
3. Gotchas
Заголовок раздела «3. Gotchas»⚠️ 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".
4. Real cases
Заголовок раздела «4. Real cases»Case 1: 50 МБ heap при 200 МБ RSS
Заголовок раздела «Case 1: 50 МБ heap при 200 МБ RSS»Симптом: 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.
Case 2: SoA даёт 8x ускорение в analytics service
Заголовок раздела «Case 2: SoA даёт 8x ускорение в analytics service»Контекст: in-memory analytics service агрегирует 50М событий по dimension.
Before:
type Event struct { UserID int64 Timestamp int64 Country uint16 EventType uint8 Value float32}// AoS: 32 байта, scatteredAggregation 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%.
Case 3: Map → slice конверсия
Заголовок раздела «Case 3: Map → slice конверсия»Симптом: hot loop с map[int]struct{} (set) — 500 нс/op.
Анализ: для < 1000 elements, slice + linear search быстрее (cache-friendly).
Решение: переписали на []int32 с linear scan. 60 нс/op. 8x speedup.
Case 4: Field ordering в request struct
Заголовок раздела «Case 4: Field ordering в request struct»Симптом: 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%.
Case 5: pprof -base нашёл leak в worker
Заголовок раздела «Case 5: pprof -base нашёл leak в worker»Симптом: 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 исчез.
5. Вопросы (25)
Заголовок раздела «5. Вопросы (25)»- Перечислите 4 типа heap-метрик в Go pprof и когда какую использовать.
- Что такое
MemProfileRateи как его регулирование влияет на overhead vs точность? - Опишите workflow поиска memory leak через
pprof -base. - Чем
debug.WriteHeapDumpотличается отruntime/pprof.Heap? - Когда
viewcoreполезнее, чем стандартный pprof? - Объясните паттерн утечки через goroutine с captured
time.Timer. - Что произойдёт с памятью, если
bytes.Bufferвзят из pool, но не возвращён? - Как
string(b[:N])помогает избежать удержания большого backing array? - Сформулируйте 3 принципа Data-Oriented Design (Mike Acton).
- AoS vs SoA: когда какой подход?
- Почему SoA даёт ускорение в hot loops на современных CPU?
- Что ограничивает SoA в Go (по сравнению с C++/Rust)?
- Объясните pointer chasing и почему linked list cache-unfriendly.
- Когда замена pointers на indexes в pool оправдана?
- Утилита
fieldalignmentи что она делает. - Что такое cache line false sharing и как с ним бороться padding-ом?
- Опишите slab allocator pattern.
sync.Poolгарантирует, что объект не будет GC’d?- Sequential vs random access: какая разница по cache?
- Что такое branch prediction и как sorted data может ускорить loop?
- Влияние pointers внутри struct на GC scan time.
- Как Discord описывал переход с Go на Rust в контексте GC pauses?
GOMEMLIMIT(Go 1.19+) — что регулирует, когда полезен?- Как escape analysis может «случайно» отправить значение в heap?
- Опишите кейс из вашей практики, где DOD-подход дал измеримое ускорение.
6. Practice
Заголовок раздела «6. Practice»Задача 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 не считается).
7. Источники
Заголовок раздела «7. Источники»- Mike Acton, “Data-Oriented Design and C++”, CppCon 2014 — каноническая речь.
- Richard Fabian, “Data-Oriented Design”, книга (бесплатно онлайн), 2018.
- The Go Blog, “Diagnostics”, https://go.dev/doc/diagnostics
- Bryan Cantrill, “Understanding modern CPU caches”, 2017.
- Ulrich Drepper, “What Every Programmer Should Know About Memory”, 2007 — классика.
- Discord Engineering Blog, “Why Discord is switching from Go to Rust”, 2020.
- The Go runtime source:
src/runtime/mheap.go,src/runtime/mprof.go. - Felix Geisendörfer, “Profiling Go programs”, talks 2022–2024.
- Dave Cheney, “Why is a Goroutine’s stack infinite?”, 2014.
- Andrei Alexandrescu, “Fastware”, talks о cache-friendly code.
- Daniel Lemire, “simdjson: Parsing gigabytes of JSON per second”, 2019.
- valyala/fastjson source — пример DOD в Go.
- Hashicorp, “Tuning Go GC for high-throughput services”, 2022.
- Russ Cox, “Profile-guided optimization”, Go Blog 2023.
- golang.org/x/tools/go/analysis/passes/fieldalignment.