Модуль: Тестирование · Уровень: 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-10—GOMAXPROCS.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.txtbenchstat (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/StartTimeroverhead. В тесном цикле эти вызовы искажают. Избегайте 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_spacevs-inuse_space. - Honest reporting: способность сказать «разница в пределах шума», а не натянуть улучшение; понимание, что bench — инструмент сравнения, а не абсолютная метрика.
- Continuous benchmarking: инфраструктура для отлова регрессий (perf-боты), нестабильность бенчей в облачном CI.