При улучшении производительности кода кто-то полагается на интуицию вместо измерений. Это приводит к преждевременной оптимизации, либо к тому, что реальные проблемы остаются незамеченными. В Go есть встроенные инструменты для измерения производительности. Разберём, как ими пользоваться.
Почему интуиция обманывает
Дональд Кнут отлично отразил это в своей работе “Structured Programming with go to Statements” (1974) написал:
Premature optimization is the root of all evil, or at least most evil in programming; the true issue is that programmers have worried about efficiency much too much, in the wrong places, and at the wrong times.
Перевод: “Преждевременная оптимизация — корень всех зол, или по крайней мере большинства зол в программировании. Настоящая проблема в том, что программисты слишком много беспокоятся об эффективности не в тех местах и не в то время.”
То есть зло в предварительной оптимизации того, что не требуется оптимизировать. Пример из практики. Разработчик тратит день на оптимизацию парсинга JSON, ускоряя его в 2 раза, но не замечает, что 90% времени уходит на сетевые запросы.
Когда стоит оптимизировать:
| Ситуация | Оптимизировать? | Почему |
|---|---|---|
| Код работает медленно по ощущениям | Нет | Нужны измерения, не ощущения |
| Профайлер показал горячую точку | Да | Данные указывают на проблему |
| «Этот цикл явно медленный» | Нет | Интуиция часто ошибается |
| Бенчмарк показал 10x разницу | Да | Статистически значимая разница |
| Клиенты жалуются на скорость | Осторожно | Сначала измерить, где проблема |
Решение простое: измеряйте до оптимизации, измеряйте после оптимизации, сравнивайте статистически.
Анатомия бенчмарка в Go
Базовый синтаксис и b.N
Бенчмарком в Go считается любая функция с префиксом Benchmark и параметром *testing.B:
package benchmark
import (
"strings"
"testing"
)
func BenchmarkStringConcatJoin(b *testing.B) {
words := []string{"Never", "gonna", "give", "you", "up"}
// b.N это количество итераций, которое Go автоматически подбирает
for i := 0; i < b.N; i++ {
result := strings.Join(words, " ")
_ = result // Предотвращаем оптимизацию компилятора
}
}
Как работает b.N:
- Go запускает бенчмарк с
b.N = 1 - Если выполнение заняло < 1 секунды, увеличивает
b.N(обычно удваивает) - Повторяет, пока не наберёт достаточно данных
- Вычисляет среднее время на операцию
Запуск:
go test -bench=BenchmarkStringConcatJoin -benchmem
Вывод:
BenchmarkStringConcatJoin-12 6094417 196.1 ns/op 24 B/op 1 allocs/op
Расшифровка вывода:
6094417это количество итераций (b.N)196.1 ns/opэто среднее время одной операции24 B/opэто байт выделено на операцию1 allocs/opэто количество аллокаций на операцию
Управление таймерами
b.ResetTimer для исключения инициализации
Если у вас дорогая инициализация (генерация тестовых данных, подключение к БД), её время не должно входить в измерения:
package benchmark
import (
"crypto/rand"
"crypto/sha256"
"testing"
"time"
)
func BenchmarkWithResetTimer(b *testing.B) {
// Дорогая инициализация
data := make([]byte, 1024*1024) // 1 MB
rand.Read(data)
time.Sleep(10 * time.Millisecond) // Имитация долгой подготовки
// Сбрасываем таймер, всё что выше не учитывается
b.ResetTimer()
// Измеряется только это
for i := 0; i < b.N; i++ {
hash := sha256.Sum256(data)
_ = hash
}
}
Разница в результатах (без ResetTimer vs с ним):
| Вариант | Время на операцию | Почему |
|---|---|---|
Без ResetTimer | ~10ms | Включает инициализацию |
С ResetTimer | ~500µs | Только полезная работа |
b.StopTimer и b.StartTimer для исключения cleanup
Иногда нужно приостановить измерения внутри цикла:
func BenchmarkWithStopStart(b *testing.B) {
data := make([]byte, 1024*1024)
rand.Read(data)
b.ResetTimer()
for i := 0; i < b.N; i++ {
hash := sha256.Sum256(data)
// Останавливаем таймер для валидации
b.StopTimer()
if hash[0] == 0 { // Проверка результата
b.Fatal("unexpected hash")
}
b.StartTimer() // Возобновляем измерения
}
}
Когда использовать:
- Валидация результатов внутри цикла
- Логирование (в отладке)
- Сброс внешних ресурсов между итерациями
Важно: не злоупотребляйте этим подходом, частые вызовы StopTimer/StartTimer сами добавляют накладные расходы.
Sub-benchmarks через b.Run()
Вложенные бенчмарки позволяют сравнить несколько вариантов в одном тесте. Сравним 4 способа конкатенации строк:
package benchmark
import (
"fmt"
"strings"
"testing"
)
func BenchmarkStringConcat(b *testing.B) {
words := []string{"Go", "is", "a", "great", "language", "for", "backend", "development"}
b.Run("Plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
result := ""
for _, word := range words {
result += word + " "
}
_ = result
}
})
b.Run("Sprintf", func(b *testing.B) {
for i := 0; i < b.N; i++ {
result := ""
for _, word := range words {
result = fmt.Sprintf("%s%s ", result, word)
}
_ = result
}
})
b.Run("Builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var builder strings.Builder
for _, word := range words {
builder.WriteString(word)
builder.WriteString(" ")
}
result := builder.String()
_ = result
}
})
b.Run("Join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
result := strings.Join(words, " ")
_ = result
}
})
}
Реальные результаты:
| Метод | ns/op | B/op | allocs/op | Скорость относительно Join |
|---|---|---|---|---|
Join | 194 | 48 | 1 | 1.0x |
Builder | 362 | 120 | 4 | 0.54x (в 1.9 раза медленнее) |
Plus | 731 | 192 | 8 | 0.27x (в 3.8 раза медленнее) |
Sprintf | 2610 | 432 | 23 | 0.07x (в 13.5 раза медленнее) |
Из результатов видно:
strings.Joinсамый быстрый (оптимизирован в стандартной библиотеке)strings.Builderхороший универсальный выбор- Оператор
+в 3.8 раза медленнее из-за множественных аллокаций fmt.Sprintfв 13.5 раза медленнее, не стоит использовать для конкатенации
Запуск отдельного sub-benchmark:
go test -bench=BenchmarkStringConcat/Join -benchmem
Отслеживание аллокаций через b.ReportAllocs()
В Go аллокации на куче дороже, чем на стеке. Метод b.ReportAllocs() показывает, сколько аллоцируется байт и сколько аллокаций на один запуск функции.
Сравним слайсы без и с предаллокацией:
package benchmark
import "testing"
const size = 1000
func BenchmarkSliceWithoutPrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var data []int // capacity = 0
for j := 0; j < size; j++ {
data = append(data, j) // При переполнении новая аллокация
}
_ = data
}
}
func BenchmarkSliceWithPrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
data := make([]int, 0, size) // Сразу выделяем capacity
for j := 0; j < size; j++ {
data = append(data, j) // Аллокаций нет
}
_ = data
}
}
func BenchmarkSliceWithExactSize(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
data := make([]int, size) // Длина равна capacity
for j := 0; j < size; j++ {
data[j] = j // Прямое присваивание
}
_ = data
}
}
Реальные результаты:
| Вариант | ns/op | B/op | allocs/op | Скорость |
|---|---|---|---|---|
| Без предаллокации | 9344 | 25208 | 12 | 1.0x |
| С предаллокацией | 498 | 0 | 0 | 18.8x быстрее |
| Точный размер | 521 | 0 | 0 | 17.9x быстрее |
Объяснение:
- Без предаллокации слайс расширяется от capacity=0 до 1024, делая примерно 12 реаллокаций (каждая удваивает размер)
- С предаллокацией одна аллокация в начале, дальше только запись
- Точный размер работает так же, но без
append(чуть быстрее)
Примечание: B/op считает только аллокации в куче. Значение 0 B/op означает, что компилятор разместил слайс на стеке благодаря escape analysis. Если слайс передаётся за пределы функции, он попадёт на кучу, и вы увидите ненулевой B/op.
Важно: предаллокация имеет смысл только если вы знаете размер заранее. Иначе можете выделить слишком много памяти и только ухудшить ситуацию.
benchstat для статистического анализа
Одиночные запуски бенчмарков имеют разброс (±5-20%) из-за фоновых процессов, сборщика мусора, CPU throttling. Инструмент benchstat даёт статистически корректные сравнения.
Установка:
go install golang.org/x/perf/cmd/benchstat@latest
Сравнение «до» и «после»
Идея benchstat в том, чтобы сравнить одну и ту же функцию бенчмарка до и после изменения кода. Имя бенчмарка должно совпадать в обоих файлах.
Допустим, у нас есть бенчмарк BenchmarkConcat:
func BenchmarkConcat(b *testing.B) {
words := []string{"Go", "is", "awesome", "for", "backend", "microservices"}
for i := 0; i < b.N; i++ {
result := concat(words)
_ = result
}
}
Сначала реализуем concat через оператор +, запускаем бенчмарк:
go test -bench=BenchmarkConcat -count=10 -benchmem > old.txt
Затем меняем реализацию concat на strings.Builder и запускаем тот же бенчмарк:
go test -bench=BenchmarkConcat -count=10 -benchmem > new.txt
benchstat old.txt new.txt
Реальный вывод benchstat:
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Concat-12 582.9n ± 14% 406.9n ± 8% -30.20% (p=0.000 n=10)
│ old.txt │ new.txt │
│ B/op │ B/op vs base │
Concat-12 130.0 ± 0% 120.0 ± 0% -7.69% (p=0.000 n=10)
│ old.txt │ new.txt │
│ allocs/op │ allocs/op vs base │
Concat-12 6.000 ± 0% 4.000 ± 0% -33.33% (p=0.000 n=10)
Как читать результаты:
| Метрика | Старая | Новая | Вывод |
|---|---|---|---|
| Время | 582.9 ns ± 14% | 406.9 ns ± 8% | 30% быстрее, меньше разброс |
| Память | 130 B | 120 B | 8% меньше |
| Аллокации | 6 | 4 | 33% меньше |
Что означает ±14%?
Это коэффициент вариации (coefficient of variation). Значение ±14% означает, что результаты разбросаны на 14% от среднего. Чем меньше процент, тем стабильнее измерения.
Интерпретация результатов
| Ситуация | Значение | Что делать |
|---|---|---|
| Разница < 5% | Статистически незначима | Оставить как есть |
| Разница 5-20% | Пограничная зона | Проверить на проде, может не окупиться |
| Разница > 20% | Значимая | Применять оптимизацию |
| Высокий разброс (±30%+) | Нестабильные измерения | Увеличить -count, закрыть фоновые процессы |
Масштабируемость через бенчмарки с разными размерами
Часто важна не абсолютная скорость, а то, как растёт время с увеличением данных. Посмотрим на примере сортировки.
package benchmark
import (
"fmt"
"math/rand"
"sort"
"testing"
)
func BenchmarkSort(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("Size_%d", size), func(b *testing.B) {
// Генерируем данные один раз
original := make([]int, size)
for i := 0; i < size; i++ {
original[i] = rand.Intn(10000)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Копируем перед сортировкой
data := make([]int, len(original))
copy(data, original)
sort.Ints(data)
}
})
}
}
Реальные результаты:
| Размер | ns/op | Рост времени | Теоретическая сложность |
|---|---|---|---|
| 10 | 140 | 1x | O(n log n) |
| 100 | 2768 | 19.8x | ~10x log 10 ≈ 23x |
| 1000 | 44444 | 317x | ~100x log 100 ≈ 664x |
| 10000 | 915914 | 6542x | ~1000x log 1000 ≈ 9966x |
Вывод: сортировка ведёт себя как O(n log n), то есть время растёт быстрее, чем линейно, но медленнее, чем квадратично.
Типичные ошибки
| Ошибка | Пример | Проблема | Решение |
|---|---|---|---|
| Игнорирование результата | strings.Join(words, " ") | Компилятор может удалить код | result := strings.Join(...); _ = result |
| Инициализация в цикле | for i := 0; i < b.N; i++ { data := make([]byte, 1MB); ... } | Измеряется аллокация, а не работа | Вынести make до цикла и использовать b.ResetTimer() |
| Одиночный запуск | go test -bench=. | Высокий разброс результатов | go test -bench=. -count=10 и анализ через benchstat |
Без флага -benchmem | go test -bench=. | Не видно аллокаций памяти | Добавить -benchmem для отслеживания памяти |
| Оптимизация без измерений | «Давайте заменим map на slice» | Можно ухудшить производительность | Сначала измерить, потом оптимизировать |
Dead Code Elimination
Компилятор Go применяет различные оптимизации, включая удаление мёртвого кода (DCE). Если результат вычисления не используется, компилятор может удалить код целиком, и бенчмарк покажет неправдоподобно низкие значения (например, 0.5 ns/op). На практике DCE зависит от конкретного кода: вызовы с аллокациями (вроде strings.Join) обычно не удаляются, а чистые вычисления (вроде math.Sqrt) — удаляются.
Классический способ защиты — присвоить результат в переменную:
func BenchmarkWithSink(b *testing.B) {
var result string
for i := 0; i < b.N; i++ {
result = strings.Join([]string{"a", "b"}, " ")
}
_ = result // Предотвращаем удаление
}
В Go 1.24 добавлен удобный метод b.Loop():
func BenchmarkStringConcatJoinNew(b *testing.B) {
words := []string{"Go", "is", "a", "great", "language"}
// b.Loop() автоматически управляет итерациями
for b.Loop() {
strings.Join(words, " ")
}
}
Главное преимущество b.Loop() — встроенная защита от Dead Code Elimination (DCE). Компилятор не удалит вычисления внутри цикла b.Loop(), поэтому не нужно писать _ = result или использовать другие приёмы для сохранения результата. Кроме того, код становится чище: не нужен for i := 0; i < b.N; i++.
Реальный кейс: JSON encoding
Рассмотрим три способа кодирования JSON из пакета encoding/json:
package benchmark
import (
"bytes"
"encoding/json"
"testing"
)
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
Address string `json:"address"`
}
func BenchmarkJSONEncoding(b *testing.B) {
person := Person{
Name: "Иван Петров",
Age: 30,
Email: "ivan@example.com",
Address: "Москва, ул. Ленина, 1",
}
b.Run("Marshal", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(person)
}
})
b.Run("Encoder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
_ = enc.Encode(person)
}
})
b.Run("EncoderReuse", func(b *testing.B) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf.Reset()
_ = enc.Encode(person)
}
})
}
Реальные результаты:
| Метод | ns/op | B/op | allocs/op | Вывод |
|---|---|---|---|---|
Marshal | 1201 | 192 | 2 | Базовый вариант |
Encoder | 1130 | 240 | 3 | Сопоставимо по скорости, +1 аллокация |
EncoderReuse | 682 | 64 | 1 | 35% быстрее, в 3 раза меньше памяти |
Практический вывод:
Для одиночных операций используйте json.Marshal, это просто и достаточно быстро. Для цикла или потока переиспользуйте Encoder, это в 1.5 раза быстрее.
Пример расчёта:
Если API обрабатывает 10 000 RPS, переход с Marshal на EncoderReuse экономит (1201 − 682) ns × 10,000 = 5.19 ms CPU в секунду. На первый взгляд мало, но на нагруженном сервисе это снижает аллокации вдвое и уменьшает давление на сборщик мусора.
Заключение
Основные рекомендации:
- Измеряйте, не гадайте. Интуиция часто ошибается, бенчмарки дают объективные данные.
- Используйте
-benchmemилиb.ReportAllocs(). Аллокации часто важнее времени выполнения. - Запускайте
-count=10. Одиночные измерения ненадёжны из-за разброса. - Применяйте
benchstat. Статистический анализ отделяет значимые изменения от шума. - Используйте
b.ResetTimer(). Если есть дорогая инициализация, исключайте её из измерений. - Оптимизируйте горячие точки. Обычно 80% времени выполнения в 20% кода.
- Проверяйте на проде. Синтетические бенчмарки не всегда соответствуют реальной нагрузке.
Дальнейшие шаги:
- Профилирование с pprof для поиска узких мест в реальном коде
- Execution tracer для анализа параллелизма и latency
- How to write benchmarks in Go от Dave Cheney
- Документация testing с дополнительными флагами и методами
- Диагностика производительности в официальной документации Go
Связанные статьи:
- История и философия Go
- Форматирование дат в Go
- Конвертация int в string (с бенчмарками разных подходов)
FAQ
Когда бенчмарки не нужны?
Если код выполняется редко (меньше 100 раз в секунду) и не критичен для пользователя. Например:
- Загрузка конфигурации при старте приложения
- Обработка webhook’ов (1-10 в минуту)
- Административные скрипты
Простое правило: если разница в 10 раз не влияет на продукт, не оптимизируйте.
Сколько запусков нужно для надёжности?
Минимум 5, рекомендуется 10. Если разброс высокий (±20% и больше):
- Закройте браузер и другие приложения
- Отключите фоновые обновления
- Запускайте на сервере, а не на ноутбуке
- Увеличьте
-count=20или-benchtime=10s
Проверка: если benchstat показывает тильду ~ вместо процентов, разница между двумя наборами результатов статистически незначима (шум, а не реальное изменение).
Что если бенчмарк показывает 0.5 ns/op?
Компилятор удалил код (dead code elimination). Проверьте:
- Результат присваивается в переменную?
- Переменная используется после цикла (
_ = result)? - Функция имеет побочные эффекты (I/O, мутация глобальных переменных)?
Типичная ошибка:
// Компилятор удалит вызов
for i := 0; i < b.N; i++ {
math.Sqrt(42)
}
// Правильно
var result float64
for i := 0; i < b.N; i++ {
result = math.Sqrt(42)
}
_ = result
Как бенчмаркить код с I/O (БД, сеть)?
Вариант 1: Mock-объекты
func BenchmarkDatabaseQuery(b *testing.B) {
db := setupMockDB() // Быстрый in-memory mock
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = db.Query("SELECT * FROM users")
}
}
Вариант 2: Реальная БД с ограничением итераций
func BenchmarkRealDB(b *testing.B) {
db := connectRealDB()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = db.Query("SELECT * FROM users WHERE id = ?", i)
}
}
Запуск с фиксированным количеством итераций (например, 100):
go test -bench=BenchmarkRealDB -benchtime=100x
Флаг -benchtime=100x устанавливает b.N = 100. Не присваивайте b.N напрямую в коде — это приводит к непредсказуемому поведению.
Важно: реальный I/O в бенчмарках нестабилен. Используйте для сравнения подходов, а не абсолютных цифр.
В чём разница между -benchtime и -count?
Флаг -benchtime=3s заставляет каждый бенчмарк работать минимум 3 секунды (больше итераций b.N). Флаг -count=10 запускает бенчмарк 10 раз для статистики.
Пример:
# Один запуск, долгий (для тяжёлых операций)
go test -bench=. -benchtime=10s
# Много запусков, по 1 секунде (для статистики)
go test -bench=. -count=10 | benchstat
Рекомендация: -count=10 в связке с benchstat для большинства случаев.
Можно ли бенчмаркить конкурентный код?
Да, через b.RunParallel():
func BenchmarkMapConcurrent(b *testing.B) {
m := sync.Map{}
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
m.Store(i, i*2)
i++
}
})
}
Запуск с 4 горутинами:
go test -bench=BenchmarkMapConcurrent -cpu=4
Важно: результаты зависят от количества ядер. Используйте -cpu=1,2,4,8 для сравнения.
Как интерпретировать результаты benchstat с p-value?
Если benchstat показывает p=0.023:
- p < 0.05 означает, что различия статистически значимы (не случайность)
- p > 0.05 означает, что различия могут быть шумом, нужно больше данных
Пример:
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Benchmark-12 500.0n ± 10% 450.0n ± 8% -10.00% (p=0.001)
Вывод: ускорение на 10% реально, а не шум (p=0.001 намного меньше 0.05).
Нужно ли коммитить результаты бенчмарков в git?
Да, для отслеживания регрессий:
benchmarks/
├── baseline.txt # Эталонные результаты
└── comparison/
├── v1.2.0.txt
└── v1.3.0.txt
Пример CI/CD:
# В пре-коммит хуке
go test -bench=. -count=10 > new.txt
benchstat baseline.txt new.txt
# Если деградация больше 20%, фейлить сборку
Теги: