сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Веб и тесты на Go

Бенчмарки и детектор гонок в 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, остальные разбираются в тренажёре.

  1. #go_bench_race1 / 5
    Что делает go test -race и почему его держат в CI?
    A)Прогоняет все тесты параллельно на всех доступных ядрах, заметно ускоряя прогон в CI
    B)Повторяет каждый тест много раз подряд, отсеивая нестабильные флаки-тесты по итогам
    C)Включает детектор гонок: ловит незащищённый конкурентный доступ на этом прогоне
    D)Проверяет наличие гонок статически, ещё до фактического запуска тестов
    показать ответ и разбор
    +C)Включает детектор гонок: ловит незащищённый конкурентный доступ на этом прогоне

    // разбор: go test -race собирает тесты с рантайм-детектором гонок (ThreadSanitizer): во время выполнения он ловит незащищённый конкурентный доступ к памяти и печатает обе стороны конфликта. Ловит только то, что РЕАЛЬНО случилось на прогоне, поэтому держат в CI на тестах с конкурентностью — чтобы код с горутинами прогонялся под ним регулярно. Замедляет в разы, зато находит то, что глазами не видно.

  2. #go_bench_race2 / 5
    В бенчмарке перед циклом идёт дорогая подготовка данных. Что с ней сделать?
    A)Оставить подготовку как есть: в измерение времени она всё равно никак не попадёт
    B)Вызвать b.ResetTimer() после подготовки, чтобы она не попала в замер
    C)Перенести всю подготовку прямо внутрь измеряемого цикла, чтобы быть до конца честным
    D)Вручную уменьшить b.N на глаз, как бы компенсируя потраченное на подготовку время
    показать ответ и разбор
    +B)Вызвать b.ResetTimer() после подготовки, чтобы она не попала в замер

    // разбор: Таймер бенчмарка стартует до входа в функцию, поэтому дорогая подготовка ДО цикла искажает ns/op. b.ResetTimer() (или b.StopTimer/StartTimer вокруг разовой подготовки) обнуляет замер после сетапа — в измерение попадёт только тело цикла. Переносить подготовку в цикл нельзя (замеришь не то), а b.N руками не трогают — его подбирает фреймворк.

  3. #go_bench_race3 / 5
    Что показывает флаг -benchmem (или вызов b.ReportAllocs) в бенчмарке?
    A)Пиковое потребление памяти процессом за время прогона
    B)Размер, до которого вырос стек горутины в ходе измерения
    C)Долю времени, потраченную сборщиком мусора во время бенчмарка
    D)Число аллокаций и байт, выделенных в среднем на одну операцию
    показать ответ и разбор
    +D)Число аллокаций и байт, выделенных в среднем на одну операцию

    // разбор: К времени операции добавляются две колонки: B/op и allocs/op. Часто именно они объясняют результат — код «медленный» не из-за вычислений, а из-за мусора, который потом собирает сборщик. Оптимизацию так и меряют: убрали боксинг в интерфейс, задали вместимость слайсу — и смотрят, упало ли число аллокаций на операцию.

  4. #go_bench_race4 / 5
    Почему результат одного прогона бенчмарка не стоит принимать за истину?
    A)Бенчмарк занижает время из-за оптимизаций компилятора
    B)Разброс между прогонами велик, и сравнивать нужно несколько замеров
    C)Первый прогон измеряет только время прогрева и отбрасывается
    D)Пакет testing округляет результат до целых микросекунд
    показать ответ и разбор
    +B)Разброс между прогонами велик, и сравнивать нужно несколько замеров

    // разбор: Соседние процессы, тепловой троттлинг и виртуализация дают разброс в единицы, а то и десятки процентов, — на этом фоне «улучшение на 5%» ничего не значит. Поэтому гоняют с -count=10 и сравнивают распределения утилитой benchstat, которая покажет медиану и разброс. Отдельная забота — не дать компилятору выбросить измеряемое: результат присваивают пакетной переменной.

  5. #go_bench_race5 / 5
    Почему детектор гонок не включают в продовой сборке?
    A)Он требует пересборки всех зависимостей из исходников при каждом запуске
    B)Он замедляет работу в разы и заметно увеличивает потребление памяти
    C)Он подменяет планировщик, из-за чего меняется поведение горутин
    D)Он пишет отчёт в стандартный вывод, засоряя логи сервиса
    показать ответ и разбор
    +B)Он замедляет работу в разы и заметно увеличивает потребление памяти

    // разбор: Детектор инструментирует каждое обращение к памяти и ведёт историю доступов: замедление обычно в 5–10 раз, память — в несколько раз больше обычного. Для тестов и нагрузочных прогонов это приемлемая цена, для прода — нет. Отсюда практика: -race держат в CI и в отдельном стенде под нагрузкой, где выше шанс, что конфликтующие обращения реально совпадут.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.