Benchmarks, trace и базовая оптимизация Go-кода
Зачем знать на Middle 1. Прямой компаньон к pprof. pprof говорит «где», benchmark говорит на сколько лучше стало, trace говорит что именно происходило по событиям. Без бенчмарков любая оптимизация — гадание; с ними вы доказываете, что версия 2 быстрее версии 1 на 35% при 95%-ой confidence. Без trace вы видите только агрегаты; с trace — точный таймлайн scheduler/GC/syscall, и понимаете, что p99 ваш виноват в 50 ms STW. Этот файл закрывает мост между профилированием и реальной оптимизацией: что мерить, как мерить, как не обмануться, и какие микро-оптимизации Go действительно работают.
Содержание
Заголовок раздела «Содержание»- Базовая концепция: testing.B и go tool trace
- Под капотом: b.N calibration, sub/parallel benchmarks, trace events
- Gotchas: deadcode, cache state, microbench-ловушки
- Production-практики: benchstat, allocation elimination, base optimization
- Вопросы на собесе (20–25)
- Practice
- Источники
1. Базовая концепция: testing.B и go tool trace
Заголовок раздела «1. Базовая концепция: testing.B и go tool trace»1.1. Зачем бенчмарки
Заголовок раздела «1.1. Зачем бенчмарки»Профайлер говорит «90% времени в parseLine». Вы написали parseLineFast. Без бенчмарка вы не знаете:
- Действительно ли быстрее?
- На сколько?
- Не ломает ли это что-то на других входах?
- Стабильно ли улучшение, или иногда хуже?
Бенчмарк = воспроизводимый эксперимент. Без него optimization превращается в карго-культ.
1.2. Базовая форма benchmark
Заголовок раздела «1.2. Базовая форма benchmark»package mypkg
import "testing"
func BenchmarkParseLine(b *testing.B) { line := "foo,bar,baz,42" for i := 0; i < b.N; i++ { _ = parseLine(line) }}Запуск:
go test -bench=. -benchmemВывод:
goos: linuxgoarch: amd64pkg: mypkgcpu: AMD EPYC 7B12BenchmarkParseLine-8 5000000 245 ns/op 48 B/op 2 allocs/opPASSok mypkg 1.467sРасшифровка:
BenchmarkParseLine-8— имя и GOMAXPROCS.5000000—b.N(сколько итераций было сделано).245 ns/op— среднее время на операцию.48 B/op— байт аллокаций на операцию.2 allocs/op— количество аллокаций.
1.3. b.N и автокалибровка
Заголовок раздела «1.3. b.N и автокалибровка»testing.B подбирает b.N автоматически:
- Запускает с
b.N = 1. - Измеряет время. Если < 1 секунды, увеличивает
b.N(по геометрической прогрессии). - Достигает цели «benchtime» (по умолчанию 1 сек).
- Финальный замер — на этом
b.N.
Можно зафиксировать:
go test -bench=. -benchtime=5s # 5 секундgo test -bench=. -benchtime=10x # ровно 10 итераций (для очень дорогих)go test -bench=. -count=10 # 10 повторов для статистики1.4. ResetTimer, StartTimer, StopTimer
Заголовок раздела «1.4. ResetTimer, StartTimer, StopTimer»Если в бенчмарке есть expensive setup:
func BenchmarkSearch(b *testing.B) { data := loadBigDataset() // 5 секунд — НЕ должно входить в timing b.ResetTimer() // сбрасываем счётчик
for i := 0; i < b.N; i++ { _ = search(data, "key") }}ResetTimer обнуляет timer и memory counters. Используется один раз после setup.
Если setup нужен для каждой итерации:
func BenchmarkInsert(b *testing.B) { for i := 0; i < b.N; i++ { b.StopTimer() tree := newTree() // не учитываем в timing b.StartTimer()
tree.Insert(rand.Int()) }}StopTimer/StartTimer дорогие (атомарные операции), не злоупотребляйте — лучше переписать через batch.
1.5. ReportAllocs
Заголовок раздела «1.5. ReportAllocs»func BenchmarkXxx(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { _ = process() }}Эквивалентно -benchmem на CLI. Если хотите всегда мерить аллокации для этого бенчмарка — поставьте в коде.
1.6. trace в одном предложении
Заголовок раздела «1.6. trace в одном предложении»go tool trace записывает все scheduler/GC/syscall события за период (несколько мс или секунд) и визуализирует их в браузере. В отличие от pprof (sampling), trace — exact event recording.
# Из benchmark:go test -bench=BenchmarkXxx -trace=trace.out
# Из running app:curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
# Анализ:go tool trace trace.out# Откроется в браузере, ~5 разделов.1.7. ASCII-схема: pprof vs trace
Заголовок раздела «1.7. ASCII-схема: pprof vs trace»pprof (sampling): ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐ │ S │ │ S │ │ S │ │ S │ │ S │ S = sample (раз в 10ms) └────┴────┴────┴────┴────┴────┴────┴────┴────┘ Что: статистика, агрегаты, % времени. Cost: ~1-5%.
trace (exact events): ┌──────────────────────────────────────────────┐ │ G1: run ─ block ── run ── syscall ── run │ │ G2: run ── block ──── run ──── block │ │ GC: ───── mark ────── sweep ───── │ └──────────────────────────────────────────────┘ Что: точный таймлайн, что когда происходило. Cost: 10-20%, файл огромный.2. Под капотом: b.N calibration, sub/parallel benchmarks, trace events
Заголовок раздела «2. Под капотом: b.N calibration, sub/parallel benchmarks, trace events»2.1. Sub-benchmarks: b.Run
Заголовок раздела «2.1. Sub-benchmarks: b.Run»Для tables / parameterization:
func BenchmarkSearch(b *testing.B) { sizes := []int{10, 100, 1000, 10000} for _, size := range sizes { data := makeData(size) b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) { for i := 0; i < b.N; i++ { _ = search(data, 42) } }) }}Вывод:
BenchmarkSearch/n=10-8 50000000 30 ns/opBenchmarkSearch/n=100-8 5000000 280 ns/opBenchmarkSearch/n=1000-8 500000 3100 ns/opBenchmarkSearch/n=10000-8 50000 30500 ns/opВидно линейность.
Запуск конкретного:
go test -bench='BenchmarkSearch/n=1000$'2.2. Parallel benchmarks: b.RunParallel
Заголовок раздела «2.2. Parallel benchmarks: b.RunParallel»Для concurrent code:
func BenchmarkCounter(b *testing.B) { var counter atomic.Int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { counter.Add(1) } })}b.RunParallel:
- Запускает
GOMAXPROCSгорутин. - Каждая горутина крутит цикл, пока
pb.Next()(это аналог проверкиi < b.N, но per-goroutine). - Итого все горутины суммарно делают
b.Nитераций.
Полезно для:
- Проверки lock contention (atomic vs Mutex).
- Throughput-замеров на multi-core.
b.SetParallelism(2) — увеличить количество горутин в 2 раза от GOMAXPROCS.
2.3. b.SetBytes
Заголовок раздела «2.3. b.SetBytes»Для throughput замеров (bytes/sec):
func BenchmarkCopy(b *testing.B) { src := make([]byte, 1<<20) // 1 MB dst := make([]byte, 1<<20) b.SetBytes(int64(len(src))) b.ResetTimer() for i := 0; i < b.N; i++ { copy(dst, src) }}Вывод добавит MB/s:
BenchmarkCopy-8 50000 25000 ns/op 41943 MB/s2.4. Sink для deadcode elimination
Заголовок раздела «2.4. Sink для deadcode elimination»// Bad: компилятор может удалить вычисление "Hash(x)":func BenchmarkHash(b *testing.B) { for i := 0; i < b.N; i++ { Hash("foo") // результат не используется → может быть deadcode! }}Если Hash чистая функция (нет side effects), Go компилятор (с inlining) может удалить вызов. Бенчмарк покажет 0 ns.
Правильно:
var sink uint64
func BenchmarkHash(b *testing.B) { var local uint64 for i := 0; i < b.N; i++ { local = Hash("foo") } sink = local // используем результат вне цикла}sink — пакетная переменная, компилятор не может доказать, что её никто не читает (она экспортная или передаётся в noinline).
В Go 1.21+ есть runtime.KeepAlive, но он не отменяет deadcode elimination. Лучший способ — sink или прямая запись в b.Logf (хотя это медленно).
2.5. Benchmark на разных входах: t.Run + параметризация
Заголовок раздела «2.5. Benchmark на разных входах: t.Run + параметризация»type tc struct { name string input string}
func BenchmarkParse(b *testing.B) { cases := []tc{ {"short", "a,b,c"}, {"medium", strings.Repeat("a,", 100)}, {"long", strings.Repeat("a,", 10000)}, } for _, c := range cases { b.Run(c.name, func(b *testing.B) { for i := 0; i < b.N; i++ { _ = parse(c.input) } }) }}Полезно для catching algorithmic regressions.
2.6. trace tool: что показывает
Заголовок раздела «2.6. trace tool: что показывает»Открыв go tool trace trace.out, увидите главное меню:
| Раздел | Что показывает |
|---|---|
| View trace | Главный таймлайн (Chrome-style). Все горутины, GC, syscalls. |
| Goroutine analysis | По каждому виду горутины — статистика. |
| Network blocking profile (pprof) | Где блокировались на network. |
| Synchronization blocking profile (pprof) | Где блокировались на mutex/chan. |
| Syscall blocking profile (pprof) | Где блокировались на syscall. |
| Scheduler latency profile (pprof) | Latency: _Grunnable → _Grunning. |
| User-defined tasks | Если используете runtime/trace.NewTask. |
| User-defined regions | Если используете runtime/trace.WithRegion. |
2.7. View trace — главный таймлайн
Заголовок раздела «2.7. View trace — главный таймлайн»Goroutines: G1: ─ Running ──── Block ──── Running ── Wait ────── End G2: ──── Running ──── Block ─ Running ──── End G3: ──── Syscall ──── Running ── Block G4: ──── Running ──── Block ─────── Running ── End
GC: ──── Mark ────── Sweep ──
Procs: P0: G1 ──── G3 ── G2 ──── G1 ──── G2 ──── G1 P1: G2 ─── G4 ─── G3 ─── G4 ──── G3 ─── G2
Timeline: →→→→→→→→→→→→→→→→→→ →→→→→→→→→→→→→Управление в браузере:
- w / s — zoom in/out.
- a / d — pan left/right.
- Click — детали события.
- Stats panel — состояние горутины в этот момент.
2.8. Goroutine analysis
Заголовок раздела «2.8. Goroutine analysis»Если у вас есть main.worker, кликнув на него увидите:
- Total time.
- Time spent in: Execution / Network / Sync block / Syscall / Block / Schedule wait.
- Per-goroutine breakdown.
Полезно: если sync block большой → mutex contention. Если schedule wait большой → CPU starvation.
2.9. Scheduler latency profile
Заголовок раздела «2.9. Scheduler latency profile»Распределение задержки _Grunnable → _Grunning. Высокий p99 здесь → горутины долго ждут CPU. Возможные причины:
- Слишком мало P (GOMAXPROCS низкий).
- Tight loops занимают P (в Go 1.13- это была проблема).
- Длинный syscall блокирует P (handoff не сработал быстро).
2.10. Annotations: runtime/trace.WithRegion, Log
Заголовок раздела «2.10. Annotations: runtime/trace.WithRegion, Log»import "runtime/trace"
func handle(ctx context.Context, req *Request) { ctx, task := trace.NewTask(ctx, "handleRequest") defer task.End()
trace.WithRegion(ctx, "parse", func() { parse(req) })
trace.WithRegion(ctx, "process", func() { process(req) })
trace.Log(ctx, "user", req.UserID)}В trace view увидите эти регионы как coloured bars. Очень полезно для прикладных сервисов: «где конкретно мой handler проводит время?».
2.11. Flight Recorder (Go 1.23+)
Заголовок раздела «2.11. Flight Recorder (Go 1.23+)»В Go 1.23 появился flight recorder — это rolling trace buffer:
- Постоянно записывает trace в кольцевой буфер (последние N секунд).
- При триггере (panic, p99 spike, manual call) — сохраняем буфер.
- Cost: ~1% overhead (намного меньше, чем full trace).
import "runtime/trace"
// pre-1.23 примерно так концептуально:// в Go 1.23+:fr := trace.NewFlightRecorder()fr.Start()defer fr.Stop()
// Когда что-то плохое случилось:buf := bytes.NewBuffer(nil)fr.WriteTo(buf)saveToS3(buf.Bytes())API стабилизируется в 1.24+. Это game changer для post-mortem analysis.
2.12. trace vs pprof — когда что
Заголовок раздела «2.12. trace vs pprof — когда что»| Вопрос | Инструмент |
|---|---|
| Где CPU тратится? | pprof CPU |
| Куда уходит память? | pprof heap |
| Goroutine leak | pprof goroutine |
| Почему p99 latency = 100 ms иногда? | trace |
| Сколько STW pause? | trace, gctrace |
| Lock contention? | pprof mutex / trace sync block |
| Кто блокирует scheduler? | trace view |
| Что в моём handler по фазам? | trace + runtime/trace.WithRegion |
3. Gotchas
Заголовок раздела «3. Gotchas»3.1. Deadcode elimination
Заголовок раздела «3.1. Deadcode elimination»Самая частая ошибка микробенчмарка:
// BAD:func BenchmarkHash(b *testing.B) { for i := 0; i < b.N; i++ { Hash(input) // если результат не используется → может быть удалён }}Реальный пример: однажды я писал бенчмарк Hash, и Go показывал 0.5 ns/op (быстрее, чем CMP инструкция!). Оказалось, компилятор inline-нул Hash, увидел, что результат deadcode, и удалил весь цикл.
Fix: sink variable.
3.2. Cache state влияет
Заголовок раздела «3.2. Cache state влияет»func BenchmarkSearch(b *testing.B) { data := makeArray(1e7) // 80 MB for i := 0; i < b.N; i++ { _ = binSearch(data, i % len(data)) }}- На первой итерации
dataне в кэше → cache miss. - На последующих — L2/L3 уже горячий.
- Бенчмарк замерит «warm cache scenario».
Если в проде каждый запрос идёт с холодным кэшем (например, шардирование), бенчмарк врёт.
Fix: в бенчмарке делать “trash cache” между итерациями (например, читать большой неиспользуемый массив).
3.3. Branch prediction
Заголовок раздела «3.3. Branch prediction»// Sorted vs unsorted массив:for _, v := range arr { if v > 100 { sum += v }}- Если arr отсортирован → branch predictor работает идеально → быстро.
- Если случайный порядок → mispredictions → медленнее в 3 раза.
В микробенчмарке b.N итераций по одинаковому массиву → branch predictor «выучит» паттерн → результат не репрезентативен.
Fix: перемешивать input между итерациями (но осторожно с timing).
3.4. Allocations в b.N цикле
Заголовок раздела «3.4. Allocations в b.N цикле»func BenchmarkProcess(b *testing.B) { for i := 0; i < b.N; i++ { result := make([]int, 1000) // аллокация в каждой итерации process(result) }}Это нормально, если в проде так и есть. Но если вы хотите измерить именно process, аллокация добавит шум:
func BenchmarkProcess(b *testing.B) { result := make([]int, 1000) // один раз b.ResetTimer() for i := 0; i < b.N; i++ { process(result) }}3.5. NUMA, CPU affinity
Заголовок раздела «3.5. NUMA, CPU affinity»На многосокетных серверах latency может скакать в зависимости от того, где запустился benchmark.
Для стабильности:
taskset -c 0-7 go test -bench=. -count=10И запускать на dedicated machine, не в shared CI.
3.6. Не доверяйте одному запуску
Заголовок раздела «3.6. Не доверяйте одному запуску»go test -bench=. -count=10 > result.txtbenchstat result.txt# Покажет stddev, p-value.benchstat — must-have утилита.
go install golang.org/x/perf/cmd/benchstat@latest3.7. GC во время бенчмарка
Заголовок раздела «3.7. GC во время бенчмарка»GC может «запутать» замер. Микро-бенчмарки на >5 ns/op обычно включают GC time.
Для стабильности:
runtime.GC()передb.ResetTimer().- Или
GOGC=off(но тогда heap растёт).
3.8. b.Loop (Go 1.24+) — новый API
Заголовок раздела «3.8. b.Loop (Go 1.24+) — новый API»Go 1.24 добавил b.Loop():
func BenchmarkXxx(b *testing.B) { for b.Loop() { // hot code }}Преимущества над for i := 0; i < b.N; i++:
b.Loopгарантирует, что компилятор не оптимизирует содержимое (нет deadcode).- Внутренне делает ResetTimer на первой итерации.
- Не нужно вручную считать b.N.
С Go 1.24 рекомендуется именно b.Loop.
3.9. trace переполнение
Заголовок раздела «3.9. trace переполнение»Если включить trace на большой нагрузке на 60 секунд — файл будет gigabytes. go tool trace загружает его в браузер, который потребует много RAM.
Совет: для prod использовать flight recorder (Go 1.23+) — кольцевой буфер.
3.10. Trace не показывает CGO-вызовы
Заголовок раздела «3.10. Trace не показывает CGO-вызовы»Если вы залезли в CGO, runtime не управляет потоком, и trace не видит, что там происходит. Будет «syscall» гэп.
4. Production-практики
Заголовок раздела «4. Production-практики»4.1. benchstat — стандарт сравнения
Заголовок раздела «4.1. benchstat — стандарт сравнения»# Базовый:go test -bench=. -count=10 > old.txt# Изменения в коде...go test -bench=. -count=10 > new.txtbenchstat old.txt new.txtВывод:
goos: linuxgoarch: amd64 │ old.txt │ new.txt │ │ sec/op │ sec/op vs base │ParseLine-8 245.0n ± 2% 180.0n ± 1% -26.53% (p=0.000 n=10) │ B/op │ B/op vs base │ParseLine-8 48.00 ± 0% 16.00 ± 0% -66.67% (p=0.000 n=10) │ allocs/op │ allocs/op vs base │ParseLine-8 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.000 n=10)±2%— стандартное отклонение.p=0.000— p-value Mann-Whitney U-test. < 0.05 = статистически значимо.
Без benchstat бенчмарки бесполезны для серьёзной оптимизации.
4.2. CI integration
Заголовок раздела «4.2. CI integration»- name: Bench main run: | git checkout main go test -bench=. -count=10 -benchmem ./pkg/critical > old.txt- name: Bench PR run: | git checkout pr-branch go test -bench=. -count=10 -benchmem ./pkg/critical > new.txt- name: Compare run: | go install golang.org/x/perf/cmd/benchstat@latest benchstat old.txt new.txtМожно автоматически блокировать PR при регрессии > 5%.
4.3. Базовая оптимизация Go-кода
Заголовок раздела «4.3. Базовая оптимизация Go-кода»4.3.1. Preallocate slices
Заголовок раздела «4.3.1. Preallocate slices»// Bad — 14 аллокаций для 10000 элементов (growslice):xs := []int{}for i := 0; i < 10000; i++ { xs = append(xs, i)}
// Good — 1 аллокация:xs := make([]int, 0, 10000)for i := 0; i < 10000; i++ { xs = append(xs, i)}growslice doubles capacity, потом на больших — растёт в 1.25×.
4.3.2. Preallocate maps
Заголовок раздела «4.3.2. Preallocate maps»// Bad:m := map[string]int{}for _, x := range data { m[x.Key] = x.Value // rehashing}
// Good:m := make(map[string]int, len(data))4.3.3. strings.Builder
Заголовок раздела «4.3.3. strings.Builder»// Bad O(n²):s := ""for _, w := range words { s += w}
// Good O(n):var b strings.Builderb.Grow(estimatedSize) // optional preallocatefor _, w := range words { b.WriteString(w)}s := b.String()4.3.4. bytes.Buffer для byte concat
Заголовок раздела «4.3.4. bytes.Buffer для byte concat»То же, но с []byte. Полезно для io.Writer-style API.
4.3.5. io.Copy vs bufio
Заголовок раздела «4.3.5. io.Copy vs bufio»// io.Copy уже использует 32KB buffer внутри:io.Copy(dst, src)
// Если src — небуферизированный io.Reader, и читает мелкими порциями,// добавление bufio.Reader/Writer ускоряет:br := bufio.NewReader(src)bw := bufio.NewWriter(dst)defer bw.Flush()io.Copy(bw, br)4.3.6. Избегайте interface boxing
Заголовок раздела «4.3.6. Избегайте interface boxing»// Bad (boxing int в interface):m := map[string]any{"x": 42}
// Good (typed):m := map[string]int{"x": 42}Boxing = heap allocation + escape.
4.3.7. defer overhead
Заголовок раздела «4.3.7. defer overhead»В Go 1.14+ defer open-coded (компилятор inline-ит) для типичных случаев. Overhead ~0.5 ns/op.
Но если defer в горячем цикле:
// Bad (миллионы defer'ов):for i := 0; i < 1e7; i++ { func() { f, _ := os.Open(...) defer f.Close() // ... }()}
// Better (один defer вне цикла):files := make([]*os.File, 0, 1e7)defer func() { for _, f := range files { f.Close() }}()for i := 0; i < 1e7; i++ { f, _ := os.Open(...) files = append(files, f) // ...}4.3.8. Map vs slice для маленьких N
Заголовок раздела «4.3.8. Map vs slice для маленьких N»// Если N < ~10 — slice + linear search быстрее:type kv struct { k string; v int }var s []kvfor i := range s { if s[i].k == key { return s[i].v } }
// Если N > 10 — map.Map имеет overhead (~50 ns на lookup). Linear scan на 10 элементах = ~20 ns.
4.3.9. Avoid string→[]byte→string conversion
Заголовок раздела «4.3.9. Avoid string→[]byte→string conversion»// Bad:s := "hello"b := []byte(s)n := process(b)s2 := string(b)
// Good (если можно использовать []byte везде):b := []byte(s)n := process(b)В Go 1.20+ unsafe.String / unsafe.StringData дают zero-copy конверсию, но только если уверены, что не модифицируете.
4.3.10. Compile flags
Заголовок раздела «4.3.10. Compile flags»# Отключить inline для дебага:go build -gcflags='-l'
# Увеличить агрессивность inline:go build -gcflags='-l=4'
# Disable bounds check (опасно):go build -gcflags='-B'В проде НЕ отключайте bounds check — это catch для багов.
4.3.11. Bounds check elimination
Заголовок раздела «4.3.11. Bounds check elimination»Если компилятор может доказать, что индекс безопасен, он удалит проверку. Помогите ему:
// Bad (bounds check каждый раз):for i := 0; i < len(a); i++ { x[i] = a[i] + b[i] // 2 bounds check (a, b)}
// Good (compiler видит, что b того же размера):_ = b[len(a)-1] // hint: b достаточно длинныйfor i := 0; i < len(a); i++ { x[i] = a[i] + b[i]}Это эзотерика, но в horse-race-code (DSP, crypto) даёт 10–20%.
4.4. sync.Pool снова
Заголовок раздела «4.4. sync.Pool снова»Я повторюсь, потому что это самая частая оптимизация:
var bufPool = sync.Pool{ New: func() any { return make([]byte, 4096) },}
func handle() { buf := bufPool.Get().([]byte) defer bufPool.Put(buf[:0]) // reset slice length, оставляем capacity // ...}Бенчмарк pool vs no pool — обычно 5–10× ускорение на горячем пути.
4.5. Структуру памяти учитывайте
Заголовок раздела «4.5. Структуру памяти учитывайте»// Cache-unfriendly:type Bad struct { a byte // 1 b int64 // 8 — но из-за padding отделён от a c byte // 1 d int64 // 8 — padding опять}// Size: 32 bytes (3× int64 alignment + padding)
// Cache-friendly:type Good struct { b int64 // 8 d int64 // 8 a byte // 1 c byte // 1}// Size: 24 bytesИспользуйте golang.org/x/tools/go/analysis/passes/fieldalignment для автообнаружения:
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latestfieldalignment ./...4.6. Real-world кейс: parsing JSON
Заголовок раздела «4.6. Real-world кейс: parsing JSON»Допустим, ваш сервис тратит 40% CPU на encoding/json.Unmarshal.
Замеры (benchstat):
encoding/json goccy/go-json sonicParseRequest-8 8.5 µs ± 1% 3.2 µs ± 2% 1.1 µs ± 3% - -62% -87%goccy/go-json — pure Go, drop-in. sonic — байткод-генерация, на ~3× быстрее, но cgo и assembly. Trade-off — портируемость.
4.7. Trace в production-debug
Заголовок раздела «4.7. Trace в production-debug»Сценарий: «у нас раз в час p99 latency 500 ms».
Подход:
- Поставьте flight recorder (Go 1.23+) с буфером в 60 секунд.
- На триггер (latency > 200 ms) сохраните trace.
- Загрузите в
go tool traceи смотрите вокруг события.
Чаще всего обнаруживаются:
- Длинный GC pause (большой heap, малый GOMEMLIMIT).
- Lock contention пик.
- Syscall спайк (file IO, DNS lookup).
- Schedule starvation (одна горутина забрала P).
4.8. Pretty trace output
Заголовок раздела «4.8. Pretty trace output»go tool trace -http=:8080 trace.out# Откроется в браузере.
# Только PNG/SVG для отчёта:go tool trace -pprof=sched trace.out > sched.pprofgo tool pprof -svg sched.pprof > sched.svg4.9. Не оптимизируйте «на всякий случай»
Заголовок раздела «4.9. Не оптимизируйте «на всякий случай»»90% оптимизаций — премативная (Donald Knuth: «root of all evil»). Перед началом:
- Снимите pprof.
- Найдите 1–3 функции, занимающие ≥ 10%.
- Оптимизируйте их (с benchmark + benchstat).
- Повторите.
Не пытайтесь сразу делать unsafe.Pointer magic — обычно достаточно make([]T, 0, n).
4.10. Чеклист перед commit оптимизации
Заголовок раздела «4.10. Чеклист перед commit оптимизации»- Есть benchmark с
b.ResetTimer(),b.ReportAllocs(). - Запущено
-count=10(или больше). - benchstat vs main показывает значимое улучшение (p < 0.05).
- Аллокации не выросли (
B/op,allocs/op). - Unit tests проходят (тесты на корректность важнее).
- Race detector чист (
go test -race). - Профиль (pprof) подтверждает, что hot path изменился.
5. Вопросы на собесе
Заголовок раздела «5. Вопросы на собесе»- Что такое b.N в testing.B? Как он подбирается?
- Зачем нужен b.ResetTimer? Когда вызывать?
- Чем отличается b.StopTimer/b.StartTimer от ResetTimer?
- Что показывает b.ReportAllocs?
- Как параметризовать benchmark? (sub-benchmarks).
- Что такое b.RunParallel? Когда нужен?
- Что такое sink variable, зачем?
- Почему микробенчмарк может показать 0 ns/op? (Deadcode.)
- Что такое benchstat и зачем?
- Какой p-value считается значимым? (< 0.05.)
- Чем go tool trace отличается от pprof?
- Какой overhead у trace? (10-20%.)
- Что показывает раздел Goroutine analysis в trace?
- Что такое scheduler latency profile?
- Как добавить annotations в trace? (
runtime/trace.WithRegion.) - Что такое flight recorder? (Go 1.23+, кольцевой буфер.)
- Чем strings.Builder отличается от s += ""? (O(n) vs O(n²).)
- Как preallocate slice / map?
- Когда defer дорогой? (В горячем цикле; обычно почти бесплатный с Go 1.14+.)
- Slice vs map для N=10 — что быстрее? (Slice + linear scan.)
- Что такое bounds check elimination?
- Какие compile flags для disable inline? (
-gcflags='-l'.) - Что такое interface boxing и когда это плохо?
- Как избежать string ↔ []byte allocation? (
unsafe.String/unsafe.SliceDataв Go 1.20+.) - Чем sync.Pool полезен в hot path?
6. Practice
Заголовок раздела «6. Practice»6.1. Парадокс deadcode
Заголовок раздела «6.1. Парадокс deadcode»package mypkg
import "testing"
func Hash(s string) uint64 { var h uint64 = 14695981039346656037 for i := 0; i < len(s); i++ { h ^= uint64(s[i]) h *= 1099511628211 } return h}
// BAD: result not used → may be deadcodedfunc BenchmarkHashBad(b *testing.B) { for i := 0; i < b.N; i++ { Hash("hello, world") }}
// GOOD: sink prevents deadcodevar sink uint64
func BenchmarkHashGood(b *testing.B) { var s uint64 for i := 0; i < b.N; i++ { s = Hash("hello, world") } sink = s}Запустить:
go test -bench=. -benchmemBenchmarkHashBad может показать неправдоподобно быстро (или одинаково с GoodHash в зависимости от компилятора).
6.2. Preallocate vs append
Заголовок раздела «6.2. Preallocate vs append»package mypkg
import "testing"
func BenchmarkAppendNoPrealloc(b *testing.B) { for i := 0; i < b.N; i++ { xs := []int{} for j := 0; j < 1000; j++ { xs = append(xs, j) } _ = xs }}
func BenchmarkAppendPrealloc(b *testing.B) { for i := 0; i < b.N; i++ { xs := make([]int, 0, 1000) for j := 0; j < 1000; j++ { xs = append(xs, j) } _ = xs }}BenchmarkAppendNoPrealloc-8 200000 6200 ns/op 16312 B/op 9 allocs/opBenchmarkAppendPrealloc-8 400000 2800 ns/op 8192 B/op 1 alloc/op6.3. strings.Builder vs +=
Заголовок раздела «6.3. strings.Builder vs +=»package mypkg
import ( "strings" "testing")
const N = 1000
func BenchmarkStringConcat(b *testing.B) { for i := 0; i < b.N; i++ { s := "" for j := 0; j < N; j++ { s += "x" } _ = s }}
func BenchmarkStringBuilder(b *testing.B) { for i := 0; i < b.N; i++ { var sb strings.Builder for j := 0; j < N; j++ { sb.WriteByte('x') } _ = sb.String() }}
func BenchmarkStringBuilderGrow(b *testing.B) { for i := 0; i < b.N; i++ { var sb strings.Builder sb.Grow(N) for j := 0; j < N; j++ { sb.WriteByte('x') } _ = sb.String() }}BenchmarkStringConcat-8 1000 1500000 ns/op 522184 B/op 999 allocs/opBenchmarkStringBuilder-8 1000000 1200 ns/op 2096 B/op 5 allocs/opBenchmarkStringBuilderGrow-8 2000000 800 ns/op 1024 B/op 1 allocs/opВидно: += создаёт квадратичное количество B/op.
6.4. Parallel atomic vs Mutex
Заголовок раздела «6.4. Parallel atomic vs Mutex»package mypkg
import ( "sync" "sync/atomic" "testing")
func BenchmarkMutexCounter(b *testing.B) { var mu sync.Mutex var counter int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { mu.Lock() counter++ mu.Unlock() } })}
func BenchmarkAtomicCounter(b *testing.B) { var counter atomic.Int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { counter.Add(1) } })}На многоядерной машине:
BenchmarkMutexCounter-8 20000000 120 ns/opBenchmarkAtomicCounter-8 200000000 8 ns/op15× разница.
6.5. sync.Pool benchmark
Заголовок раздела «6.5. sync.Pool benchmark»package mypkg
import ( "sync" "testing")
var pool = sync.Pool{ New: func() any { return make([]byte, 4096) },}
func BenchmarkAllocBuf(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { buf := make([]byte, 4096) _ = buf }}
func BenchmarkPoolBuf(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { buf := pool.Get().([]byte) pool.Put(buf) }}6.6. Trace с регионами
Заголовок раздела «6.6. Trace с регионами»package main
import ( "context" "os" "runtime/trace" "time")
func parse(ctx context.Context) { defer trace.StartRegion(ctx, "parse").End() time.Sleep(10 * time.Millisecond)}
func process(ctx context.Context) { defer trace.StartRegion(ctx, "process").End() time.Sleep(20 * time.Millisecond)}
func handle(ctx context.Context, id int) { ctx, task := trace.NewTask(ctx, "handle") defer task.End()
trace.Log(ctx, "request_id", string(rune(id+'0'))) parse(ctx) process(ctx)}
func main() { f, _ := os.Create("trace.out") defer f.Close()
trace.Start(f) defer trace.Stop()
ctx := context.Background() for i := 0; i < 5; i++ { handle(ctx, i) }}go run main.gogo tool trace trace.out# Откроется в браузере. "User-defined tasks" покажет 5 задач,# каждая с регионами parse и process.6.7. Field alignment
Заголовок раздела «6.7. Field alignment»package mypkg
import "testing"import "unsafe"
type Bad struct { a byte b int64 c byte d int64}
type Good struct { b int64 d int64 a byte c byte}
func TestSizes(t *testing.T) { t.Logf("Bad: %d bytes", unsafe.Sizeof(Bad{})) t.Logf("Good: %d bytes", unsafe.Sizeof(Good{}))}Запустите:
go test -v -run TestSizes# Bad: 32 bytes# Good: 24 bytes6.8. b.SetBytes для throughput
Заголовок раздела «6.8. b.SetBytes для throughput»package mypkg
import "testing"
func BenchmarkCopy(b *testing.B) { src := make([]byte, 1<<20) // 1 MB dst := make([]byte, 1<<20) b.SetBytes(int64(len(src))) b.ResetTimer() for i := 0; i < b.N; i++ { copy(dst, src) }}BenchmarkCopy-8 50000 25000 ns/op 41943 MB/s6.9. b.Loop (Go 1.24+)
Заголовок раздела «6.9. b.Loop (Go 1.24+)»package mypkg
import "testing"
func BenchmarkLoopNew(b *testing.B) { for b.Loop() { // hot code; не выпиливается компилятором }}7. Источники
Заголовок раздела «7. Источники»- testing package: https://pkg.go.dev/testing
- Benchmarking in Go: How To Write Better Benchmarks (Dave Cheney): https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go
- High Performance Go Workshop (Dave Cheney): https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html
- golang.org/x/perf/cmd/benchstat: https://pkg.go.dev/golang.org/x/perf/cmd/benchstat
- runtime/trace package: https://pkg.go.dev/runtime/trace
- go tool trace docs: https://github.com/golang/go/wiki/Performance#tracing
- Diagnostics guide: https://go.dev/doc/diagnostics
- GopherCon 2017: Understanding Go Trace (Rhys Hiltner): https://www.youtube.com/watch?v=ySy3sR1LFCQ
- Go 1.23 Flight Recorder proposal: https://github.com/golang/go/issues/63185
- fieldalignment tool: https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment
- goccy/go-json: https://github.com/goccy/go-json
- bytedance/sonic: https://github.com/bytedance/sonic
- Inside the Go Playground / compile flags: https://go.dev/doc/go1.compile
- The Go Compiler optimization notes: https://github.com/golang/go/wiki/CompilerOptimizations
- Awesome Go performance: https://github.com/golang/go/wiki/Performance