Бенчмарки и оптимизация в Go

При улучшении производительности кода кто-то полагается на интуицию вместо измерений. Это приводит к преждевременной оптимизации, либо к тому, что реальные проблемы остаются незамеченными. В 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:

  1. Go запускает бенчмарк с b.N = 1
  2. Если выполнение заняло < 1 секунды, увеличивает b.N (обычно удваивает)
  3. Повторяет, пока не наберёт достаточно данных
  4. Вычисляет среднее время на операцию

Запуск:

go test -bench=BenchmarkStringConcatJoin -benchmem

Вывод:

BenchmarkStringConcatJoin-12    6094417    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/opB/opallocs/opСкорость относительно Join
Join1944811.0x
Builder36212040.54x (в 1.9 раза медленнее)
Plus73119280.27x (в 3.8 раза медленнее)
Sprintf2610432230.07x (в 13.5 раза медленнее)

Из результатов видно:

  1. strings.Join самый быстрый (оптимизирован в стандартной библиотеке)
  2. strings.Builder хороший универсальный выбор
  3. Оператор + в 3.8 раза медленнее из-за множественных аллокаций
  4. 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/opB/opallocs/opСкорость
Без предаллокации934425208121.0x
С предаллокацией4980018.8x быстрее
Точный размер5210017.9x быстрее

Объяснение:

Примечание: 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 B120 B8% меньше
Аллокации6433% меньше

Что означает ±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Рост времениТеоретическая сложность
101401xO(n log n)
100276819.8x~10x log 10 ≈ 23x
100044444317x~100x log 100 ≈ 664x
100009159146542x~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
Без флага -benchmemgo 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/opB/opallocs/opВывод
Marshal12011922Базовый вариант
Encoder11302403Сопоставимо по скорости, +1 аллокация
EncoderReuse68264135% быстрее, в 3 раза меньше памяти

Практический вывод:

Для одиночных операций используйте json.Marshal, это просто и достаточно быстро. Для цикла или потока переиспользуйте Encoder, это в 1.5 раза быстрее.

Пример расчёта:

Если API обрабатывает 10 000 RPS, переход с Marshal на EncoderReuse экономит (1201 − 682) ns × 10,000 = 5.19 ms CPU в секунду. На первый взгляд мало, но на нагруженном сервисе это снижает аллокации вдвое и уменьшает давление на сборщик мусора.

Заключение

Основные рекомендации:

  1. Измеряйте, не гадайте. Интуиция часто ошибается, бенчмарки дают объективные данные.
  2. Используйте -benchmem или b.ReportAllocs(). Аллокации часто важнее времени выполнения.
  3. Запускайте -count=10. Одиночные измерения ненадёжны из-за разброса.
  4. Применяйте benchstat. Статистический анализ отделяет значимые изменения от шума.
  5. Используйте b.ResetTimer(). Если есть дорогая инициализация, исключайте её из измерений.
  6. Оптимизируйте горячие точки. Обычно 80% времени выполнения в 20% кода.
  7. Проверяйте на проде. Синтетические бенчмарки не всегда соответствуют реальной нагрузке.

Дальнейшие шаги:

Связанные статьи:

FAQ

Когда бенчмарки не нужны?

Если код выполняется редко (меньше 100 раз в секунду) и не критичен для пользователя. Например:

Простое правило: если разница в 10 раз не влияет на продукт, не оптимизируйте.

Сколько запусков нужно для надёжности?

Минимум 5, рекомендуется 10. Если разброс высокий (±20% и больше):

  1. Закройте браузер и другие приложения
  2. Отключите фоновые обновления
  3. Запускайте на сервере, а не на ноутбуке
  4. Увеличьте -count=20 или -benchtime=10s

Проверка: если benchstat показывает тильду ~ вместо процентов, разница между двумя наборами результатов статистически незначима (шум, а не реальное изменение).

Что если бенчмарк показывает 0.5 ns/op?

Компилятор удалил код (dead code elimination). Проверьте:

  1. Результат присваивается в переменную?
  2. Переменная используется после цикла (_ = result)?
  3. Функция имеет побочные эффекты (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:

Пример:

              │   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%, фейлить сборку

Теги: