Профилирование и бенчмаркинг в Go: как измерять без самообмана

Как в Go профилировать и бенчмаркить без самообмана: b.Loop и benchstat для честных замеров, чтение pprof (CPU/heap/allocs), execution trace и PGO, и типичные ошибки «оптимизации по ощущениям»

В Go очень легко быстро получить цифры и очень легко сделать из них неправильные выводы. Один удачный benchmark не означает, что код стал быстрее в реальной системе; один flame graph не означает, что найден главный bottleneck. Тема производительности полезна ровно тогда, когда измерения воспроизводимы и отвечают на конкретный инженерный вопрос.

Заключительная статья серии про Go в backend. Пройдём один сквозной пример от бенчмарка до профиля и трассы: когда писать benchmark, как читать pprof, чем полезны benchstat и go tool trace, что такое PGO — и какие ошибки чаще всего делают, когда «оптимизируют по ощущениям».

Профилирование Go: flame graph и измерительный прибор над машиной сервиса, подсвечена горячая точка

В статье

benchmark или profiler: разные вопросы

Это инструменты для разных вопросов:

  • benchmark отвечает «насколько быстра вот эта функция/реализация» и «стало ли быстрее после изменения». Локально, на одном куске кода.
  • profiler отвечает «куда уходит время/память во всём работающем сервисе» — то есть где вообще оптимизировать.

Порядок почти всегда такой: сначала profiler показывает горячую точку, потом benchmark меряет конкретную её замену. «Медленно» без сценария и цифры бесполезно — первый вопрос всегда «медленно где и насколько».

Сквозной пример

Возьмём одну функцию и доведём её от «медленно» до «быстро», меряя каждый шаг. Render собирает строку из событий — с классической ловушкой: конкатенация в цикле, где каждая итерация создаёт новую строку (O(n²) по памяти).

type Event struct{ User, Kind string }

// Медленно: конкатенация в цикле — каждая итерация создаёт новую строку (O(n^2) по памяти).
func Render(events []Event) string {
    out := ""
    for _, e := range events {
        out += e.User + ":" + e.Kind + ";"
    }
    return out
}

// Быстро: один буфер через strings.Builder + оценка итогового размера.
func RenderFast(events []Event) string {
    var b strings.Builder
    b.Grow(len(events) * 16)
    for _, e := range events {
        b.WriteString(e.User)
        b.WriteByte(':')
        b.WriteString(e.Kind)
        b.WriteByte(';')
    }
    return b.String()
}

Дальше — доказываем цифрами, что «быстро» действительно быстрее, а не «на глаз». (И осторожно с «оптимизациями по ощущениям»: скажем, make(map, len(events)) для счётчика с немногими уникальными ключами не ускоряет, а раздувает память — такое ловит именно замер, а не интуиция.)

Базовый benchmarking

Бенчмарк — функция BenchmarkXxx(*testing.B). С Go 1.24+ цикл пишется через b.Loop(): он не учитывает setup и не даёт компилятору выкинуть измеряемый вызов.

func BenchmarkRender(b *testing.B) {
    events := makeEvents(1000) // подготовка — вне измерения
    b.ReportAllocs()           // показать B/op и allocs/op
    for b.Loop() {             // Go 1.24+: корректный цикл
        _ = Render(events)
    }
}

// До Go 1.24 — классический цикл; результат кладут в пакетную
// переменную (sink), иначе компилятор удалит «бесполезный» вызов:
// for i := 0; i < b.N; i++ { sink = Render(events) }
go test -bench=Render -benchmem -run=^$ ./...

-benchmem (или b.ReportAllocs()) добавляет B/op и allocs/op — часто именно аллокации, а не «алгоритм», оказываются причиной. Тяжёлую общую подготовку выносите до цикла; setup внутри итерации оборачивайте в b.StopTimer()/b.StartTimer().

Конкурентные бенчмарки

Если код исполняется параллельно (хендлеры, пулы), меряйте его так же — через b.RunParallel: каждая горутина крутит pb.Next(), а флаг -cpu задаёт число процессоров.

func BenchmarkRenderParallel(b *testing.B) {
    events := makeEvents(1000)
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            _ = RenderFast(events)
        }
    })
}
// go test -bench=Parallel -cpu=1,2,4 -run=^$ ./...

Полезные флаги: -benchtime=5s (или -benchtime=100000x — фиксированное число итераций для воспроизводимости), -count=N (несколько прогонов для статистики, см. ниже).

Как сравнивать честно: benchstat

Один прогон «до» и один «после» врут: соседний процесс, троттлинг CPU, GC дают разброс. Правильно — много прогонов и статистика через benchstat:

go install golang.org/x/perf/cmd/benchstat@latest  # не входит в стандартный toolchain
go test -bench=Render -benchmem -run=^$ -count=10 > old.txt
# ... переключаемся на RenderFast (или вносим изменение) ...
go test -bench=Render -benchmem -run=^$ -count=10 > new.txt
benchstat old.txt new.txt

benchstat показывает не только дельту, но и разброс со значимостью:

          │    old.txt    │              new.txt               │
          │    sec/op     │    sec/op      vs base             │
Render-8    1171.6µ ± 10%   12.0µ ± 11%   -98.98% (p=0.000 n=8)
          │    old.txt    │              new.txt               │
          │     B/op      │     B/op       vs base             │
Render-8    6115Ki ± 0%    16.0Ki ± 0%   -99.74% (p=0.000 n=8)
          │    old.txt    │              new.txt               │
          │   allocs/op   │  allocs/op     vs base             │
Render-8    1000.0 ± 0%    1.000 ± 0%    -99.90% (p=0.000 n=8)

Читаем: столбец ± — разброс (здесь ± 10% по времени, ± 0% по allocs), p=0.000 — отличие значимо. Если стоит ~ или p большой — разницы, по сути, нет, как бы ни хотелось. Сравнивайте на одинаковых входных данных и без внешних зависимостей в цикле.

Профилирование: pprof

Benchmark говорит «стало быстрее», profiler — «куда уходит время/память». Профиль снимают из бенчмарка или с живого сервиса.

go test -bench=Render -run=^$ -cpuprofile=cpu.out -memprofile=mem.out .   # один пакет: не ./...
go tool pprof -http=:8080 mem.out   # веб-UI: flame graph, top, source

Виды профилей и на что отвечают:

  • CPU — где тратится процессорное время (hot path);
  • heap — что занимает память сейчас (inuse_space) и сколько аллоцировано всего (alloc_space);
  • allocs — источники аллокаций (частый корень нагрузки на GC);
  • goroutine — сколько и где «висят» горутины (утечки);
  • mutex / block — конкуренция за блокировки и ожидание (включить: runtime.SetMutexProfileFraction, runtime.SetBlockProfileRate).

Чтение профиля: flat/cum, list, escape

В pprof начинают с top, но ключ — понимать две колонки:

  • flat — время/память в самой функции;
  • cum — вместе со всем, что она вызвала.

Высокий cum при низком flat — функция сама дешёвая, но тянет дорогих потомков; высокий flat — тратит она сама.

go tool pprof mem.out
(pprof) top                 # главные потребители
(pprof) top -cum            # по кумулятивному
(pprof) list Render        # построчно: какая строка аллоцирует

В нашем примере list Render укажет прямо на строку out += .... Почему именно там аллокации — покажет escape analysis: компилятор объясняет, что уехало в heap (для нашей строки — ... escapes to heap).

go build -gcflags="-m" ./...   # "escapes to heap", "moved to heap", решения по inlining

Дальше — приёмы: преаллокация (make(map, n), slice = make([]T, 0, n)), strings.Builder вместо +=, переиспользование буферов через sync.Pool, отказ от лишнего боксинга в interface{}. Но применять их стоит только по профилю, а не «везде на всякий случай».

Execution tracer

pprof отвечает «где горит CPU/память», но не «почему запрос завис на 200 мс, пока CPU простаивал». Это уже про планирование горутин, блокировки, GC-паузы, сетевые ожидания — их видит execution tracer.

go test -bench=Render -run=^$ -trace=trace.out .   # профильные флаги — по одному пакету
go tool trace trace.out   # таймлайн горутин, GC, syscalls, scheduler latency
# с живого сервиса: go tool trace http://localhost:6060/debug/pprof/trace?seconds=5

Правило выбора: CPU-bound и «жрёт процессор»pprof; latency, «висит», неравномерность, GC-паузыgo tool trace.

PGO: profile-guided optimization

С Go 1.21 компилятор умеет использовать реальный CPU-профиль для оптимизации сборки (агрессивнее инлайнит горячие пути). Механизм простой: положить CPU-профиль как default.pgo рядом с main-пакетом — go build подхватит его автоматически.

# 1. снять репрезентативный CPU-профиль с прода
curl -o default.pgo 'http://prod:6060/debug/pprof/profile?seconds=30'
# 2. положить рядом с main и собрать — сборка сама применит PGO
go build ./...

Выигрыш обычно единицы процентов и зависит от того, насколько профиль репрезентативен. Это не замена ручной оптимизации горячих мест, а бесплатная добавка сверху — при условии, что профиль снят с реальной нагрузки, а не с синтетики.

Профилирование в проде

Локальный бенчмарк не покажет, что медленно на реальном трафике. Для живого сервиса — net/http/pprof:

import _ "net/http/pprof" // регистрирует /debug/pprof на DefaultServeMux
// go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
// затем: go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

Что важно в проде:

  • Не вешайте pprof на публичный порт. Отдельный внутренний listener (localhost:6060) или защита — профили выдают внутренности.
  • Метки профиля (labels) — привязывают участки CPU-профиля к бизнес-контексту (endpoint, tenant), чтобы видеть, чей трафик горячий:
pprof.Do(ctx, pprof.Labels("endpoint", "/render"), func(ctx context.Context) {
    handle(ctx) // работа под этой меткой видна в профиле отдельно
})
  • Оверхед. CPU-профиль сэмплирует ~100 Гц — стоит дёшево, можно снимать в проде короткими окнами. Heap-профиль управляется runtime.MemProfileRate. Continuous profiling (Pyroscope, Grafana, Datadog) — тот же pprof на постоянной основе, чтобы ловить регрессии, а не бегать с профайлером после инцидента.

Типичные ловушки

  • Бенчмарк меряет не то. Компилятор выкинул вызов (нет b.Loop()/sink) или в измерение попал setup. Результат «0.3 ns/op» — почти всегда ошибка измерения.
  • Один прогон вместо статистики. Кэш, GC, прогрев искажают; всегда -count=N + benchstat, а не «один раз до/после».
  • I/O, сеть, БД нельзя честно оценить локальным микробенчмарком — там доминируют латентность и контеншн, а не CPU функции; это к go tool trace и профилю с прода.
  • Microbenchmark ≠ производительность системы. Ускорили функцию на 40%, а она и так была 1% профиля — выигрыша нет. Сначала profiler (где), потом benchmark (насколько).
  • Оптимизация без профиля. «Тут наверняка медленно» — самая дорогая догадка. Профиль почти всегда указывает не туда, куда показывала интуиция.

Выводы

  • Сначала измерять, потом оптимизировать. Интуиция о «горячих местах» ошибается чаще, чем кажется.
  • Инструменты дополняют друг друга: profiler находит где, benchmark подтверждает насколько, benchstatзначимо ли, go tool traceпочему latency, PGO — бесплатная добавка от реального профиля.
  • Цифра без сценария ничего не значит — фиксируйте входные данные, окружение и вопрос, на который отвечаете.
  • Это перекликается с принципом из статьи про async: не угадывать узкое место, а измерять.

На этом серия про Go в backend завершена: от конфигурации и graceful shutdown через работу с БД до измеримой производительности.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии