Бенчмарки в Go: как мерить правильно и не обмануть себя
Встроенный пакет testing в Go даёт инструменты для бенчмаркинга из коробки, но получить воспроизводимые и осмысленные результаты сложнее, чем кажется. Разбираем типичные ловушки, правильную методологию и инструменты, которые превращают цифры в решения.

Почему бенчмарки врут (и как перестать им верить на слово)
Вы написали два варианта сериализации, прогнали go test -bench, увидели разницу в 15 % и выбрали «быстрый». Через неделю коллега получил противоположный результат на той же машине. Проблема не в Go и не в процессоре — проблема в методологии. Компилятор может выбросить ваш «горячий путь» целиком, планировщик ОС — подбросить фоновую нагрузку именно во время замера, а аллокатор — выдать память из уже прогретого пула.
Эта статья — про рабочие приёмы: как писать бенчмарки, которые измеряют то, что вы хотите измерить, как читать результат и как сравнивать два прогона так, чтобы разница была статистически значимой. Материал для Go 1.22+, хотя базовые принципы работают начиная с Go 1.17.
Что нужно до старта
- Go 1.22 или новее (
go versionдля проверки). - Утилита
benchstat— ставится командойgo install golang.org/x/perf/cmd/benchstat@latest. - Машина без фоновой нагрузки. Ноутбук на батарее с троттлингом CPU — худший стенд для бенчмарков. Если другого нет, хотя бы зафиксируйте governor на
performance(Linux) или закройте всё лишнее.
Анатомия правильного бенчмарка
Минимальный скелет
// marshal_bench_test.go
package marshal_test
import (
"encoding/json"
"testing"
)
type Event struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
var sinkBytes []byte // глобальная переменная-приёмник, не даёт компилятору выбросить результат
func BenchmarkMarshalEvent(b *testing.B) {
event := Event{ID: 42, Name: "deploy", Tags: []string{"prod", "canary"}}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
data, err := json.Marshal(&event)
if err != nil {
b.Fatal(err) // ошибка в бенчмарке — не игнорируем
}
sinkBytes = data
}
}
Ключевые моменты:
- sink-переменная объявлена на уровне пакета. Без неё компилятор вправе заметить, что результат
json.Marshalникуда не уходит, и удалить вызов. Вы будете мерить пустой цикл. b.ReportAllocs()— показывает аллокации на операцию. Без этого вы видите только время, а аллокации часто важнее.b.ResetTimer()— обнуляет таймер после подготовительной работы. Здесь подготовка тривиальна, но привычка сбрасывать таймер после setup спасает в более сложных случаях.- Ошибки обрабатываются через
b.Fatal, а не замалчиваются.
Ловушка номер один: компилятор умнее вас
Go-компилятор активно инлайнит и удаляет мёртвый код. Паттерн «результат в локальную переменную» — недостаточен: локальная переменная, не покидающая функцию, может быть оптимизирована. Sink-переменная на уровне пакета — простейший и надёжный способ это предотвратить.
Бенчмарк, который не борется с dead code elimination, измеряет способность компилятора удалять код, а не скорость вашего алгоритма.
Ловушка номер два: забытый ResetTimer
Если перед циклом for i := 0; i < b.N; i++ вы читаете файл, открываете соединение к базе или создаёте большой объект, время подготовки войдёт в результат. b.ResetTimer() обнуляет и таймер, и счётчики аллокаций. Для параллельных бенчмарков (b.RunParallel) есть аналогичный b.ResetTimer(), вызываемый до запуска горутин.
Как запускать и сколько раз
Один прогон — это анекдот, не данные. Минимум — десять прогонов для каждого варианта:
# десять прогонов, результат в файл
go test -bench=BenchmarkMarshalEvent -benchmem -count=10 ./... | tee old.txt
Флаг -count=10 заставляет go test повторить весь набор бенчмарков десять раз. Флаг -benchmem дублирует информацию об аллокациях в выводе (наряду с b.ReportAllocs() в коде — это перестраховка).
После оптимизации прогоняете то же самое и сохраняете в new.txt. Сравниваете:
benchstat old.txt new.txt
benchstat покажет медиану, дельту и p-value. Если p-value больше 0.05, разница статистически незначима — не выдавайте шум за победу.
Сноска: p-value — вероятность получить наблюдаемую (или большую) разницу случайно, при условии что реальной разницы нет. Порог 0.05 означает: мы соглашаемся считать разницу реальной, только если вероятность случайности ниже 5 %.
Типичные ошибки и как их избежать
| Ошибка | Что происходит | Как исправить |
|---|---|---|
| Нет sink-переменной | Компилятор удаляет вызов, бенчмарк показывает ~0.3 ns/op | Присваивать результат переменной на уровне пакета |
| Один прогон | Результат зависит от фазы луны и фоновых процессов | -count=10 и benchstat |
| Подготовка внутри таймера | Время setup включено в результат | b.ResetTimer() или b.StopTimer() / b.StartTimer() |
| StopTimer/StartTimer в каждой итерации | Сами вызовы Stop/Start дорогие, шум растёт | Выносить setup за цикл целиком, а не останавливать таймер на каждую итерацию |
| Бенчмарк одного размера | Линейная сложность выглядит как O(1) | Sub-бенчмарки с разными размерами входных данных |
Sub-бенчмарки: как увидеть рост сложности
Если вы сериализуете структуру с десятью тегами и со ста тегами, характер роста важен не меньше абсолютного времени. Go позволяет параметризовать бенчмарки через b.Run:
func BenchmarkMarshalEvent(b *testing.B) {
sizes := []int{1, 10, 100, 1000}
for _, n := range sizes {
tags := make([]string, n)
for i := range tags {
tags[i] = "tag"
}
event := Event{ID: 1, Name: "deploy", Tags: tags}
b.Run(fmt.Sprintf("tags_%d", n), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
data, err := json.Marshal(&event)
if err != nil {
b.Fatal(err)
}
sinkBytes = data
}
})
}
}
Вывод покажет отдельную строку для каждого размера. Если при переходе от 100 к 1000 тегов время растёт в 20 раз, а не в 10, — у вас нелинейность, и это важнее абсолютных наносекунд.
Когда бенчмарк не помогает
Микробенчмарк измеряет изолированный вызов. В реальном сервисе этот вызов живёт внутри HTTP-хендлера, рядом с GC-паузами, под конкурентной нагрузкой. Несколько случаев, когда результат бенчмарка бесполезен:
- GC-давление. Бенчмарк аллоцирует мало, но в проде функция вызывается миллион раз в секунду — суммарные аллокации вызывают частые паузы GC. Смотрите на
allocs/opне менее внимательно, чем наns/op. - Кеш-эффекты. В бенчмарке данные помещаются в L1. В проде рабочий набор — гигабайт, и каждый вызов промахивается мимо кеша.
- Конкурентность. Мьютекс, который не виден в однопоточном бенчмарке, становится узким местом при 200 горутинах. Для таких случаев используйте
b.RunParallel.
Бенчмарки — это скрининг, а не диагноз. Для окончательных выводов нужен профилирование под нагрузкой: pprof, трейсинг, метрики прода.
Чек-лист перед коммитом бенчмарка
- Результат вычисления присвоен sink-переменной уровня пакета.
b.ReportAllocs()вызван.- Setup вынесен перед
b.ResetTimer(). - Нет
b.StopTimer()/b.StartTimer()внутри каждой итерации цикла. - Есть sub-бенчмарки для разных размеров входных данных, если размер влияет на результат.
- В CI или локально запуск идёт с
-count=10, а сравнение — черезbenchstat. - В комментарии к бенчмарку указаны версия Go и условия прогона (или скрипт, который это фиксирует).
Что дальше
После того как бенчмарк написан правильно и показал узкое место, следующий шаг — профилирование. Запускайте бенчмарк с флагами -cpuprofile=cpu.out и -memprofile=mem.out, а затем анализируйте через go tool pprof. Бенчмарк находит «где медленно», pprof показывает «почему медленно» — строку за строкой. Отдельно стоит посмотреть на execution tracer (go test -trace=trace.out), если вы подозреваете проблемы с планировщиком или GC-паузами. Это — отдельная тема, здесь мы рассматривали только корректные замеры.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Package testing — Go standard library documentation, benchstat — Go performance measurement tools
Частые вопросы
Обязательно ли использовать sink-переменную на уровне пакета?
Сколько прогонов (-count) нужно для достоверного результата?
А если у меня другая версия Go — результаты будут отличаться?
benchstat показывает 'no significant difference' — что делать?
Можно ли доверять бенчмарку, запущенному в CI (GitHub Actions, GitLab CI)?
Как бенчмаркить функцию, которая работает с базой данных или сетью?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.