Модуль: Тестирование · Уровень: Middle+/Senior

TL;DR#

Бенчмарк — функция BenchmarkXxx(b *testing.B), в которой измеряемый код крутится b.N раз; рантайм сам подбирает b.N, увеличивая его, пока время прогона не станет статистически осмысленным (по умолчанию ~1s, регулируется -benchtime). b.ResetTimer() отсекает дорогой setup, b.ReportAllocs() (или флаг -benchmem) добавляет аллокации/op и байты/op. Результаты нестабильны от прогона к прогону — сравнивать релизы нужно через benchstat по нескольким прогонам (-count), а не по одному числу. Главные ошибки измерений: мёртвый код, который выкидывает компилятор (нужен sink), setup внутри измеряемого цикла, шумная машина, и интерпретация одного прогона как истины.

Теория#

Анатомия бенчмарка#

func BenchmarkFib(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fib(20)
    }
}

Запуск: go test -bench=. -benchmem. Вывод:

BenchmarkFib-10    	  254679	      4712 ns/op	       0 B/op	       0 allocs/op
  • -10GOMAXPROCS.
  • 254679 — итоговое b.N.
  • ns/op — наносекунд на операцию (общее время / b.N).
  • B/op, allocs/op — при -benchmem или b.ReportAllocs().

Как подбирается b.N#

Рантайм запускает бенчмарк с b.N=1, замеряет, и итеративно увеличивает b.N (округляя вверх до «красивых» чисел), пока суммарное время не достигнет -benchtime (дефолт 1s). Поэтому тело должно быть идемпотентно относительно b.N — нельзя полагаться на конкретное значение N. Можно задать число итераций явно: -benchtime=1000x (ровно 1000 раз) или -benchtime=5s (по времени).

ResetTimer / StopTimer / StartTimer#

Таймер включён с начала функции. Дорогой setup исказит результат:

func BenchmarkProcess(b *testing.B) {
    data := generateLargeDataset() // дорого, НЕ должно попасть в замер
    b.ResetTimer()                  // обнуляем таймер и счётчики аллокаций
    for i := 0; i < b.N; i++ {
        Process(data)
    }
}

Если setup нужен на каждой итерации:

for i := 0; i < b.N; i++ {
    b.StopTimer()
    input := freshInput()
    b.StartTimer()
    Process(input)
}

StopTimer/StartTimer в горячем цикле дороги сами по себе и шумят — по возможности готовьте все входы заранее (срез из b.N элементов) и ResetTimer один раз.

ReportAllocs и память#

func BenchmarkConcat(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < 100; j++ {
            s += "x"
        }
        _ = s
    }
}

allocs/op часто важнее ns/op: аллокации создают давление на GC, которое проявляется под нагрузкой, а не в микробенче. Снижение аллокаций — типичная цель senior-оптимизации (sync.Pool, переиспользование буферов, strings.Builder, предвыделение слайсов с make([]T, 0, n)).

Sink: борьба с устранением мёртвого кода#

Компилятор может выкинуть вычисление, результат которого не используется, или заинлайнить и соптимизировать так, что замеряется ничто:

var sink int // пакетный уровень — компилятор не докажет, что не используется

func BenchmarkParse(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = Parse(input) // присваиваем
    }
    sink = r // выносим наружу — мешаем DCE
}

В Go 1.24+ есть b.Loop(), который решает это идиоматичнее:

func BenchmarkParse(b *testing.B) {
    for b.Loop() {
        Parse(input) // результат не выкинется, setup до Loop не замеряется
    }
}

b.Loop() гарантирует, что аргументы и результаты функции не будут устранены оптимизатором, и сам отбивает таймер: код до первого b.Loop() не измеряется, что снимает нужду в ResetTimer для setup.

Параллельные бенчмарки#

func BenchmarkCacheGet(b *testing.B) {
    c := NewCache()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            c.Get("key") // измеряем под конкуренцией
        }
    })
}

RunParallel распределяет b.N итераций по GOMAXPROCS горутинам — для измерения contention (мьютексы, sync.Map, атомики). Регулируется b.SetParallelism.

Custom metrics#

b.ReportMetric(float64(bytesProcessed)/b.Elapsed().Seconds(), "B/s")
b.ReportMetric(float64(rows)/float64(b.N), "rows/op")

benchstat: правильное сравнение#

Один прогон ничего не доказывает — шум CPU, частоты, GC. Алгоритм:

go test -bench=BenchmarkX -benchmem -count=10 ./... > old.txt
# внесли изменение
go test -bench=BenchmarkX -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt

benchstat (golang.org/x/perf/cmd/benchstat) считает медиану/среднее, разброс (±%) и p-value (значимо ли отличие). Если в колонке стоит ~, разница статистически не значима — «оптимизация» в пределах шума.

Профилирование через бенчмарки#

go test -bench=BenchmarkX -cpuprofile=cpu.prof -memprofile=mem.prof -benchmem
go tool pprof cpu.prof   # top, list Func, web
go tool pprof -alloc_space mem.prof

Бенчмарк — удобный воспроизводимый драйвер для pprof: даёт стабильную нагрузку. -blockprofile, -mutexprofile — для contention.

Подводные камни / gotchas#

  • Один прогон ≠ результат. Используйте -count + benchstat. Без статистики «стало на 3% быстрее» — самообман.
  • Мёртвый код / DCE. Без sink или b.Loop() компилятор может удалить измеряемую работу → нереально маленькие ns/op (доли наносекунды).
  • Setup в цикле. Аллокация входных данных внутри for i:=0; i<b.N замеряется вместе с кодом. Готовьте заранее или b.Loop().
  • StopTimer/StartTimer overhead. В тесном цикле эти вызовы искажают. Избегайте per-iteration.
  • Шумная машина. Турбобуст, троттлинг, фоновые процессы, виртуализация (CI!). Бенчи на ноутбуке на батарее или в шумном CI ненадёжны. Закрепляйте частоту, делайте больше прогонов.
  • b.N-зависимый код. Логика, опирающаяся на конкретное N (например, заранее аллоцированный буфер ровно на N) ломается при ауторесайзе N.
  • Микробенч ≠ продакшн. Кэш горячий, нет contention, аллокатор не под давлением. Микробенч показывает относительную разницу алгоритмов, а не абсолютную латентность системы.
  • Сравнение бенчей с разной структурой. benchstat сравнивает по имени бенчмарка; переименование/изменение sub-бенчей ломает сравнение.
  • Inlining. Маленькая функция может заинлайниться и в бенче, и в проде по-разному. -gcflags=-m показывает решения по инлайну.

Вопросы на собеседовании#

В: Что такое b.N и почему нельзя на него закладываться? О: Это число итераций, которое рантайм подбирает автоматически, наращивая, пока прогон не достигнет -benchtime (~1s). Значение меняется между запусками и зависит от скорости кода, поэтому тело бенчмарка должно быть корректно при любом N и не опираться на конкретное число.

В: Зачем b.ResetTimer() и когда StopTimer/StartTimer? О: ResetTimer обнуляет таймер и счётчики после дорогого одноразового setup, чтобы он не вошёл в измерение. StopTimer/StartTimer исключают setup, выполняемый на каждой итерации, но сами имеют overhead и шумят в тесном цикле — лучше готовить входы заранее.

В: Как не дать компилятору выкинуть измеряемый код? О: Присвоить результат пакетной переменной-sink вне цикла (мешает dead-code elimination), или в Go 1.24+ использовать for b.Loop(), который гарантирует сохранение аргументов и результатов и сам исключает setup из замера.

В: Почему результаты одного go test -bench ненадёжны и как сравнивать корректно? О: Из-за шума: частоты CPU, турбобуст, GC, фон. Нужно -count=N для нескольких прогонов до и после изменения и benchstat old.txt new.txt, который даёт разброс и p-value. Если показан ~, отличие незначимо.

В: Чем allocs/op важнее ns/op? О: Аллокации создают давление на GC, которое в микробенче почти не видно (мало живёт), но под продакшн-нагрузкой выливается в паузы и рост латентности. Снижение аллокаций (sync.Pool, предвыделение, strings.Builder) часто даёт больший выигрыш в реальной системе, чем шлифовка CPU-времени.

В: Как профилировать через бенчмарк? О: go test -bench=X -cpuprofile=cpu.prof -memprofile=mem.prof, затем go tool pprof cpu.prof (top/list/web). Бенчмарк даёт воспроизводимую нагрузку как драйвер для pprof. Для contention — -mutexprofile, -blockprofile.

В: Что измеряет b.RunParallel? О: Поведение под конкуренцией: распределяет b.N по GOMAXPROCS горутинам, чтобы померить contention на мьютексах/атомиках/sync.Map. Обычный бенч однопоточный и contention не видит.

В: Почему микробенчмарк может врать про реальную производительность? О: Горячий кэш, отсутствие contention, аллокатор не под давлением, инлайнинг отличается от прода, мёртвый код может выкидываться. Микробенч валиден для сравнения алгоритмов в одинаковых условиях, но не для абсолютной латентности системы — для этого нужны нагрузочные/end-to-end замеры.

На что копают на senior+#

  • Методология: дисциплина -count+benchstat, понимание p-value, изоляция машины, повторяемость в CI (или сознательный отказ от бенчей в CI как gate).
  • DCE/инлайнинг: знание, как компилятор устраняет мёртвый код и инлайнит, чтение -gcflags='-m', эскейп-анализ (почему аллоцируется на куче).
  • GC и аллокации: связь allocs/op с GC-паузами, GOGC/GOMEMLIMIT, sync.Pool и его подводные камни.
  • Профили: уверенное чтение pprof (cpu/heap/mutex/block), flame graphs, дифф профилей, -alloc_space vs -inuse_space.
  • Honest reporting: способность сказать «разница в пределах шума», а не натянуть улучшение; понимание, что bench — инструмент сравнения, а не абсолютная метрика.
  • Continuous benchmarking: инфраструктура для отлова регрессий (perf-боты), нестабильность бенчей в облачном CI.