Бенчмарки и детектор гонок в Go
Прогнал один и тот же бенчмарк восемь раз подряд: 0,616 · 0,663 · 0,696 · 0,715 · 0,725 · 0,734 · 0,734 · 0,749 наносекунды. Между худшим и лучшим 21 процента разницы, а «оптимизация», которую обычно приходят показывать, даёт как раз десять. Спрашивают, как мерить производительность и зачем -race.
Стержень: бенчмарк меряет с разбросом, поэтому сравнивают серии; детектор динамический, поэтому видит только случившееся.
// Формулировки: «как выглядит бенчмарк?», «что показывает -benchmem?», «зачем -race в CI?»
Как устроен бенчмарк
Функция BenchmarkXxx(b *testing.B) прогоняет измеряемый код b.N раз, а пакет testing сам подбирает N: гоняет функцию заново со всё большим числом итераций, пока суммарное время не дотянет до заданного. Запуск - go test -bench=.
Отсюда неочевидное следствие про подготовку. Я сравнил два бенчмарка: в одном заполнение массива на двадцать миллионов элементов попало в замер, в другом было вырезано через b.ResetTimer. Разница вышла всего 2 процента, 78 946 против 77 268 наносекунд, потому что подготовка поделилась почти на четыре тысячи итераций.
// ResetTimer поэтому важен там, где операция медленная и b.N остаётся маленьким, или когда подготовку приходится делать внутри цикла - тогда её закрывают парой b.StopTimer и b.StartTimer.
- b.N
- число итераций, пакет testing подбирает его сам
- b.ResetTimer
- исключает подготовку из замера
Компилятор выбрасывает то, что не нужно
Два одинаковых бенчмарка, разница в одной строке. В первом результат функции никуда не девается, во втором уезжает в пакетную переменную. Замер: 0,395 наносекунды против 0,789. Ровно вдвое быстрее - потому что в первом случае компилятор выкинул вызов целиком, и меряли пустой цикл.
Ноль целых четыре десятых наносекунды на операцию это примерно один такт процессора. Такое число само по себе сигнал: если бенчмарк показывает единицы десятых наносекунды, скорее всего меряется не то, что задумано.
// Лечится присваиванием результата пакетной переменной. Локальная не спасёт - компилятор увидит, что она никому не нужна, и всё равно выбросит вычисление.
- мёртвый код
- вычисление без потребителя, компилятор его убирает
- пакетная переменная
- приёмник результата, который нельзя выбросить
Аллокации и цена детектора
Флаг -benchmem (или b.ReportAllocs в коде) добавляет колонки B/op и allocs/op - байты и число аллокаций на операцию. Часто именно они объясняют результат: код медленный из-за мусора, который потом убирает сборщик, а не из-за вычислений. Так и меряют оптимизацию - убрали боксинг, задали вместимость слайсу, смотрим, упало ли allocs/op.
Флаг -race решает другую задачу: инструментирует обращения к памяти и ловит гонки, которые реально случились в этом запуске. Анализ динамический, поэтому непройденная ветка останется непроверенной, а зелёный прогон отсутствия гонок не доказывает.
// Цена детектора: в документации говорят о типичном замедлении около десяти раз, у меня на микробенчмарке блокировки вышло тридцать семь - 14,9 наносекунды превратились в 548,6. Микробенчмарк - худший случай, но вывод один: -race живёт в CI и на нагрузочном стенде, не в проде.
- allocs/op
- число аллокаций на операцию, часто главный виновник
- -race
- динамический детектор гонок, дорогой по времени
Как отвечать: «Как меряете производительность и зачем -race?»
Бенчмарком из стандартного пакета: функция BenchmarkXxx получает *testing.B и прогоняет измеряемый код b.N раз, причём число итераций пакет подбирает сам, наращивая его до нужного суммарного времени. Первое, за чем слежу - чтобы компилятор не выбросил измеряемое. Я это проверял: два одинаковых бенчмарка, в одном результат никуда не идёт, в другом уезжает в пакетную переменную, и первый вышел ровно вдвое быстрее, 0,395 наносекунды против 0,789, потому что мерил пустой цикл. Второе - обязательно смотрю аллокации через -benchmem, колонки B/op и allocs/op, они часто и объясняют разницу: код тормозит из-за мусора, а не из-за вычислений. Третье - один прогон за истину не принимаю. Восемь прогонов одного и того же бенчмарка у меня разошлись на 21 процент, от 0,616 до 0,749, поэтому гоняю с -count и сравниваю через benchstat. Подготовку вырезаю через b.ResetTimer, хотя тут стоит понимать механику: пакет прогоняет функцию заново с растущим b.N, так что подготовка делится на все итерации, и заметна она в основном на медленных операциях. Детектор -race про другое: он ловит гонки данных, инструментируя обращения к памяти, работает динамически и потому зелёным результатом ничего не доказывает. Замедление у меня доходило до тридцати семи раз, поэтому его место в CI, а не в проде.
Сильный ответ: назван механизм b.N, показано главное практическое требование - следить за мёртвым кодом, аллокации подняты как объясняющий фактор, учтён статистический разброс с конкретным процентом и корректно очерчено, что доказывает и чего не доказывает -race.
На чём валят
- −Принимают один прогон за истину. У меня восемь прогонов разошлись на 21 процент.
- −Забывают присвоить результат, и компилятор выбрасывает измеряемый код: 0,395 против 0,789 наносекунды.
- −Ставят b.ResetTimer как ритуал, не понимая, что подготовка делится на b.N.
- −Смотрят только на время и не замечают allocs/op в -benchmem.
- −Считают, что зелёный прогон с -race доказывает отсутствие гонок.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.
- Что делает go test -race и почему его держат в CI?A)Прогоняет все тесты параллельно на всех доступных ядрах, заметно ускоряя прогон в CIB)Повторяет каждый тест много раз подряд, отсеивая нестабильные флаки-тесты по итогамC)Включает детектор гонок: ловит незащищённый конкурентный доступ на этом прогонеD)Проверяет наличие гонок статически, ещё до фактического запуска тестов
показать ответ и разбор
+C)Включает детектор гонок: ловит незащищённый конкурентный доступ на этом прогоне// разбор: go test -race собирает тесты с рантайм-детектором гонок (ThreadSanitizer): во время выполнения он ловит незащищённый конкурентный доступ к памяти и печатает обе стороны конфликта. Ловит только то, что РЕАЛЬНО случилось на прогоне, поэтому держат в CI на тестах с конкурентностью — чтобы код с горутинами прогонялся под ним регулярно. Замедляет в разы, зато находит то, что глазами не видно.
- В бенчмарке перед циклом идёт дорогая подготовка данных. Что с ней сделать?A)Оставить подготовку как есть: в измерение времени она всё равно никак не попадётB)Вызвать b.ResetTimer() после подготовки, чтобы она не попала в замерC)Перенести всю подготовку прямо внутрь измеряемого цикла, чтобы быть до конца честнымD)Вручную уменьшить b.N на глаз, как бы компенсируя потраченное на подготовку время
показать ответ и разбор
+B)Вызвать b.ResetTimer() после подготовки, чтобы она не попала в замер// разбор: Таймер бенчмарка стартует до входа в функцию, поэтому дорогая подготовка ДО цикла искажает ns/op. b.ResetTimer() (или b.StopTimer/StartTimer вокруг разовой подготовки) обнуляет замер после сетапа — в измерение попадёт только тело цикла. Переносить подготовку в цикл нельзя (замеришь не то), а b.N руками не трогают — его подбирает фреймворк.
- Что показывает флаг -benchmem (или вызов b.ReportAllocs) в бенчмарке?A)Пиковое потребление памяти процессом за время прогонаB)Размер, до которого вырос стек горутины в ходе измеренияC)Долю времени, потраченную сборщиком мусора во время бенчмаркаD)Число аллокаций и байт, выделенных в среднем на одну операцию
показать ответ и разбор
+D)Число аллокаций и байт, выделенных в среднем на одну операцию// разбор: К времени операции добавляются две колонки: B/op и allocs/op. Часто именно они объясняют результат — код «медленный» не из-за вычислений, а из-за мусора, который потом собирает сборщик. Оптимизацию так и меряют: убрали боксинг в интерфейс, задали вместимость слайсу — и смотрят, упало ли число аллокаций на операцию.
- Почему результат одного прогона бенчмарка не стоит принимать за истину?A)Бенчмарк занижает время из-за оптимизаций компилятораB)Разброс между прогонами велик, и сравнивать нужно несколько замеровC)Первый прогон измеряет только время прогрева и отбрасываетсяD)Пакет testing округляет результат до целых микросекунд
показать ответ и разбор
+B)Разброс между прогонами велик, и сравнивать нужно несколько замеров// разбор: Соседние процессы, тепловой троттлинг и виртуализация дают разброс в единицы, а то и десятки процентов, — на этом фоне «улучшение на 5%» ничего не значит. Поэтому гоняют с -count=10 и сравнивают распределения утилитой benchstat, которая покажет медиану и разброс. Отдельная забота — не дать компилятору выбросить измеряемое: результат присваивают пакетной переменной.
- Почему детектор гонок не включают в продовой сборке?A)Он требует пересборки всех зависимостей из исходников при каждом запускеB)Он замедляет работу в разы и заметно увеличивает потребление памятиC)Он подменяет планировщик, из-за чего меняется поведение горутинD)Он пишет отчёт в стандартный вывод, засоряя логи сервиса
показать ответ и разбор
+B)Он замедляет работу в разы и заметно увеличивает потребление памяти// разбор: Детектор инструментирует каждое обращение к памяти и ведёт историю доступов: замедление обычно в 5–10 раз, память — в несколько раз больше обычного. Для тестов и нагрузочных прогонов это приемлемая цена, для прода — нет. Отсюда практика: -race держат в CI и в отдельном стенде под нагрузкой, где выше шанс, что конфликтующие обращения реально совпадут.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.