Перевод/заметки Leveraging benchstat Projections in Go Benchmark Analysis!
Встроенный в Go фреймворк микробенчмаркинга чрезвычайно полезен и широко известен. Однако не многие разработчики знают о дополнительном, но очень важном инструменте benchstat
, позволяющем наглядно сравнивать результаты A/B бенчмарков Go в нескольких прогонах. В 2023 году benchstat
был полностью переработан и стал еще мощнее: появились проекции(projections), фильтрация и группировки, позволяющие проводить надежные сравнения по любому измерению, определяемому вашими суббенчмарками (они же «cases»), если вы придерживаетесь определенного формата именования.
benchstat
можно установить с помощью команды go install golang.org/x/perf/cmd/benchstat@latest
Old-school Flow: Сравнение эффективности разных версий
Начнем с объяснения наиболее популярного итеративного процесса бенчмаркинга, когда мы запускаем один и тот же бенчмарк на нескольких версиях вашего кода. Как правило, этот метод работает следующим образом:
-
Создание бенчмарков
Создать бенчмарк так же просто, как создать тест
func BenchmarkFoo(b *testing.B)
в вашем файлеbar_test.go
. Внутри функции вы можете использовать несколькоb.Run(...)
, чтобы проверить разные случаи на схожую функциональность. -
Запуск бенчмарка для версии A вашего кода
Чтобы быстро запустить
BenchmarkFoo
, по умолчанию на1с
, можно воспользоваться командойgo test -bench BenchmarkFoo
. Это хорошо подходит для тестирования, но обычно мы используем более продвинутые опции, такие как многократный запуск (-count
), CPU лимит (-cpu
), профилирование (-memprofile
) и другие. Я рекомендую использовать его в паре сtee
, чтобы вы передавали вывод как вstdout
, так и в файл для последующего использования, напримерv1.txt
:export bench=v1 && go test \ -run '^$' -bench '^BenchmarkFoo' \ -benchtime 5s -count 6 -cpu 2 -benchmem -timeout 999m \ | tee ${bench}.txt
В результате вы получите абсолютные результаты (аллокации, задержки, пользовательские метрики), полученные в ходе выполнения бенчмарка (бенчмарков).
-
Оптимизируйте код, который вы тестируете
Затем вы можете сделать
git commit
всего, что у вас было (просто чтобы не потеряться!), и изменить код, который вы тестируете (например, в попытке оптимизировать его на основе ранее собранных профилей). -
Запуск бенчмарка для версии B вашего кода
Теперь пришло время выполнить тот же бенчмарк, чтобы проверить, действительно ли ваша оптимизация лучше или хуже, изменив при этом имя вывода, например в файле
v2.txt.
-
Проанализируйте результаты A/B бенчмарка
Когда у нас есть старые и новые результаты (A и B), пришло время использовать
benchstat
! Запуститеbenchstat base=v1.txt new=v2.txt
, чтобы сравнить две версии. Вы по-прежнему увидите абсолютные показатели задержки, аллокации (и любые пользовательские метрики, которые вы указали в отчетах), но что более важно - относительные проценты улучшений/ухудшений для них, а также вероятность шума.
После этих шагов вы узнаете, улучшили ли новые изменения потребления процессора или памяти или ухудшили их, или же вам придется повторить (или исправить!) бенчмарк из-за шума.
Рассмотрим конкретный пример
Основная цель бенчмарка - сравнить эффективность кодирования протокола Remote Write 1.0 с версией 2.0 для разных размеров выборки, в идеале - для разных сжатий и двух разных кодировщиков (маршаллеров) Go protobuf. Если мы используем метод «разных версий», то это может выглядеть следующим образом:
package across_versions
// ...
/*
export bench=v2 && go test \
-run '^$' -bench '^BenchmarkEncode' \
-benchtime 5s -count 6 -cpu 2 -benchmem -timeout 999m \
| tee ${bench}.txt
*/
func BenchmarkEncode(b *testing.B) {
for _, sampleCase := range sampleCases {
b.Run(fmt.Sprintf("sample=%v", sampleCase.samples), func(b *testing.B) {
batch := utils.GeneratePrometheusMetricsBatch(sampleCase.config)
// Commenting out what we used in v1.txt
//msg := utils.ToV1(batch, true, true)
msg := utils.ToV2(utils.ConvertClassicToCustom(batch))
compr := newCompressor("zstd")
marsh := newMarshaller("protobuf")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
out, err := marsh.marshal(msg)
testutil.Ok(b, err)
out = compr.compress(out)
b.ReportMetric(float64(len(out)), "bytes/message")
}
})
}
}
После получения старых (v1) и новых (v2) результатов (в данном примере v1 означает Remote Write 1.0
, а v2 - 2.0), мы можем использовать benchstat
для их сравнения:
$ benchstat base=v1.txt new=v2.txt
goos: darwin
goarch: arm64
pkg: go-microbenchmarks-benchstat/across_versions
│ base │ new │
│ sec/op │ sec/op vs base │
Encode/sample=200-2 264.7µ ± 3% 107.0µ ± 4% -59.58% (p=0.002 n=6)
Encode/sample=2000-2 2672.9µ ± 3% 900.3µ ± 3% -66.32% (p=0.002 n=6)
Encode/sample=10000-2 13.335m ± 4% 3.299m ± 6% -75.26% (p=0.002 n=6)
geomean 2.113m 682.4µ -67.70%
│ base │ new │
│ bytes/message │ bytes/message vs base │
Encode/sample=200-2 5.964Ki ± 1% 5.534Ki ± 0% -7.21% (p=0.002 n=6)
Encode/sample=2000-2 45.88Ki ± 0% 33.45Ki ± 0% -27.08% (p=0.002 n=6)
Encode/sample=10000-2 227.4Ki ± 0% 122.0Ki ± 3% -46.33% (p=0.002 n=6)
geomean 39.62Ki 28.27Ki -28.66%
│ base │ new │
│ B/op │ B/op vs base │
Encode/sample=200-2 336.76Ki ± 0% 64.02Ki ± 0% -80.99% (p=0.002 n=6)
Encode/sample=2000-2 1807.7Ki ± 0% 370.8Ki ± 0% -79.49% (p=0.002 n=6)
Encode/sample=10000-2 9.053Mi ± 0% 1.322Mi ± 0% -85.40% (p=0.002 n=6)
geomean 1.739Mi 317.9Ki -82.14%
│ base │ new │
│ allocs/op │ allocs/op vs base │
Encode/sample=200-2 2.000 ± 0% 2.000 ± 0% ~ (p=1.000 n=6) ¹
Encode/sample=2000-2 10.000 ± 0% 2.000 ± 0% -80.00% (p=0.002 n=6)
Encode/sample=10000-2 16.000 ± 0% 2.000 ± 0% -87.50% (p=0.002 n=6)
geomean 6.840 2.000 -70.76%
¹ all samples are equal
Remote Write 2.0 меньше обменивается данными и использует меньше процессора и памяти для кодирования (и сжатия) с помощью компрессии zstd
.
Плюсы и минусы
Хотя традиционный подход к микробенчмаркингу Go (модификация кода и повторный запуск бенчмарков) прост для интерактивных и быстрых тестов, он имеет и существенные недостатки:
-
Сложно отслеживать изменения: Легко потерять контроль над тем, что именно вы проверяете, особенно если вы заметили, что текущие оптимизации не помогают, и вам нужно вернуться к какому-то предыдущему состоянию.
-
Случайные изменения бенчмарка: Случайные изменения в коде бенчмарка могут привести к неверным сравнениям, и их трудно заметить в этом процессе.
-
Ограниченное взаимодействие: Обмен и воспроизведение бенчмарков становится сложной задачей. Это является серьезным препятствием для крупных проектов, где при проверке необходимо убедиться в надежности авторских бенчмарков и заявленных результатов.
-
Противоречия в окружении: В микробенчмарках важны не абсолютные значения, а относительная разница. Это связано с тем, что мы хотим запускать их локально, для быстрой обратной связи при разработке и низкого риска, а не в реальной производственной среде со всеми производственными зависимостями. Однако даже относительные показатели могут быть ненадежными, например, если вы тестируете разные версии кода на разном оборудовании или на одном и том же оборудовании, но в разных условиях.
В результате, чем дольше вы работаете над следующей версией, тем более ненадежным на практике оказывается процесс бенчмаркинга. Это можно смягчить, вернувшись (например, в git) к старой версии, запустив бенчмарк, затем перейдя к новому коду и повторив его, чтобы минимизировать «временной» разрыв между запусками бенчмарка. Другое интересное смягчение, которое я видел, например, в работе Дэйва Чейни, - это компиляция двоичных файлов с тестами бенчмарков и хранение их где-нибудь с хорошим описанием, чтобы вы всегда могли выполнить бенчмарки один за другим. На практике оба способа немного болезненны.
-
Сложные сравнения: Вышеперечисленные проблемы становятся еще более серьезными, если вы хотите сравнить свой код в разных кейсах, как это было в нашем примере.
Newly Enabled Flow: Сравнение эффективности разных кейсов
Хотя мне всегда нравился benchstat
, мне не хватало одной важной функции - вместо сравнения результатов бенчмарков, хранящихся в разных файлах, я хотел сравнивать результаты работы всех подбенчмарков/кейсов b.Run(...)
. Такой поток сравнения становился все более удобным (по крайней мере, для моей ментальной модели), чем больше я занимался бенчмарками.
В январе 2023 года benchstat
был переписан. Austin Clements с помощью ревьюверов добавил новую гибкую фильтрацию и контроль над тем, что и с чем вы хотите сравнить. В ходе переработки были улучшены и другие вещи, например, предупреждения о недостаточном -count
(количестве прогонов бенчмарка) для обнаружения и отклонения промахов и общего обнаружения несопоставимых результатов.
Идея этого метода проста - для воспроизводимости, надежности и наглядности мы стараемся представить новый и старый «код» как разные кейсы. Любой человек может запустить этот бенчмарк один раз и получить один файл результатов. И наконец, любой может использовать новую проекцию benchstat
и функции фильтрации, чтобы сравнить результаты одного прогона по разным измерениям на лету.
Одна важная деталь, которую нужно помнить при работе с функцией проекции benchstat
, заключается в том, что все кейсы должны соответствовать предложенному формату.
В частности, нам нужно убедиться, что именование кейсов b.Run(...)
соответствует формату пары <case name>=<case value>
. Например, чтобы представить версии protobuf v1 и v2, мы можем сделать proto=prometheus.WriteRequest
(это официальное уникальное имя пакета для 1.0) и proto=io.prometheus.write.v2.Request
для 2.0.
Рассмотрим конкретный пример
Обратите внимание на очевидное увеличение сложности бенчмарк-теста и строгий синтаксис для подбенчмарков:
package across_cases
// ...
/*
export bench=allcases && go test \
-run '^$' -bench '^BenchmarkEncode' \
-benchtime 5s -count 6 -cpu 2 -benchmem -timeout 999m \
| tee ${bench}.txt
*/
func BenchmarkEncode(b *testing.B) {
for _, sampleCase := range sampleCases {
b.Run(fmt.Sprintf("sample=%v", sampleCase.samples), func(b *testing.B) {
for _, compr := range compressionCases {
b.Run(fmt.Sprintf("compression=%v", compr.name()), func(b *testing.B) {
for _, protoCase := range protoCases {
b.Run(fmt.Sprintf("proto=%v", protoCase.name), func(b *testing.B) {
for _, marshaller := range marshallers {
b.Run(fmt.Sprintf("encoder=%v", marshaller.name()), func(b *testing.B) {
msg := protoCase.msgFromConfigFn(sampleCase.config)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
out, err := marshaller.marshal(msg)
testutil.Ok(b, err)
out = compr.compress(out)
b.ReportMetric(float64(len(out)), "bytes/message")
}
})
}
})
}
})
}
})
}
}
var (
sampleCases = []struct {
samples int
config utils.GenerateConfig
}{
{samples: 200, config: generateConfig200samples},
{samples: 2000, config: generateConfig2000samples},
{samples: 10000, config: generateConfig10000samples},
}
compressionCases = []*compressor{
newCompressor(""),
newCompressor(remote.SnappyBlockCompression),
newCompressor("zstd"),
}
protoCases = []struct {
name string
msgFromConfigFn func(config utils.GenerateConfig) vtprotobufEnhancedMessage
}{
{
name: "prometheus.WriteRequest",
msgFromConfigFn: func(config utils.GenerateConfig) vtprotobufEnhancedMessage {
return utils.ToV1(utils.GeneratePrometheusMetricsBatch(config), true, true)
},
},
{
name: "io.prometheus.write.v2.Request",
msgFromConfigFn: func(config utils.GenerateConfig) vtprotobufEnhancedMessage {
return utils.ToV2(utils.ConvertClassicToCustom(utils.GeneratePrometheusMetricsBatch(config)))
},
},
}
marshallers = []*marshaller{
newMarshaller("protobuf"), newMarshaller("vtprotobuf"),
}
)
Мы можем просто выполнить export bench=allcases ...
один раз, чтобы создать файл allcases
.
Вот тут-то и проявляется магия проекции benchstat
. Мы можем использовать новый синтаксис для управления тем, какие измерения мы хотим сравнивать, что мы хотим отфильтровать или сгруппировать!
Например, для получения вывода, как в первом подходе, мы можем использовать -filter "/compression:zstd /encoder:protobuf" -col /proto
. Мы также можем (опционально здесь) указать -row ".name /sample /compression /encoder"
для явной группировки по оставшемуся измерению:
$ benchstat -row ".name /sample /compression /encoder" \
-filter "/compression:zstd /encoder:protobuf" \
-col /proto allcases.txt
goos: darwin
goarch: arm64
pkg: go-microbenchmarks-benchstat/across_cases
│ prometheus.WriteRequest │ io.prometheus.write.v2.Request │
│ sec/op │ sec/op vs base │
Encode 200 zstd protobuf 268.8µ ± 2% 103.3µ ± 7% -61.57% (p=0.002 n=6)
Encode 2000 zstd protobuf 2671.4µ ± 5% 877.4µ ± 4% -67.16% (p=0.002 n=6)
Encode 10000 zstd protobuf 12.834m ± 2% 3.059m ± 8% -76.16% (p=0.002 n=6)
geomean 2.097m 652.1µ -68.90%
│ prometheus.WriteRequest │ io.prometheus.write.v2.Request │
│ bytes/message │ bytes/message vs base │
Encode 200 zstd protobuf 5.949Ki ± 0% 5.548Ki ± 0% -6.73% (p=0.002 n=6)
Encode 2000 zstd protobuf 45.90Ki ± 0% 33.49Ki ± 0% -27.03% (p=0.002 n=6)
Encode 10000 zstd protobuf 227.8Ki ± 1% 121.4Ki ± 25% -46.70% (p=0.002 n=6)
geomean 39.62Ki 28.26Ki -28.68%
│ prometheus.WriteRequest │ io.prometheus.write.v2.Request │
│ B/op │ B/op vs base │
Encode 200 zstd protobuf 336.00Ki ± 0% 64.00Ki ± 0% -80.95% (p=0.002 n=6)
Encode 2000 zstd protobuf 1799.8Ki ± 1% 368.0Ki ± 0% -79.55% (p=0.002 n=6)
Encode 10000 zstd protobuf 9.015Mi ± 2% 1.312Mi ± 0% -85.44% (p=0.002 n=6)
geomean 1.732Mi 316.3Ki -82.17%
│ prometheus.WriteRequest │ io.prometheus.write.v2.Request │
│ allocs/op │ allocs/op vs base │
Encode 200 zstd protobuf 2.000 ± 0% 2.000 ± 0% ~ (p=1.000 n=6) ¹
Encode 2000 zstd protobuf 10.000 ± 0% 2.000 ± 0% -80.00% (p=0.002 n=6)
Encode 10000 zstd protobuf 16.000 ± 0% 2.000 ± 0% -87.50% (p=0.002 n=6)
geomean 6.840 2.000 -70.76%
¹ all samples are equal
Мы также можем легко выполнять сравнение между компрессиями (переключаясь на -col
и обновляя -row
), что дает нам удивительную гибкость при возникновении новых вопросов. Мы также можем повлиять на порядок сортировки, независимо от исходной сортировки в файле результатов allcases
, изменив порядок ключей в опции -row
:
$ benchstat -row ".name /proto /encoder /sample" \
-filter /encoder:protobuf \
-col /compression allcases.txt
Плюсы и минусы
В целом, этот подход делает результаты наших бенчмарков более воспроизводимыми и надежными, смягчая большинство недостатков. Однако он имеет и некоторые негативные последствия:
- Повторное выполнение бенчмарков с большим количеством примеров требует значительного времени (медленный цикл обратной связи!).
- В результате получается более сложный код бенчмарка, что затрудняет итерации и выявление мест, где вы сравниваете тестовый код с той частью кода, которую вы хотели проверить.
- Для непрерывного промышленного использования не имеет смысла коммитить этот бенчмарк со всеми кейсами, которые больше не используются. Лучше зафиксировать такой бенчмарк в какой-нибудь отдаленной ветке для использования в будущем.
Итог
Подводя итог, можно сказать, что новая версия benchstat
с функцией проекции позволяет проводить локальное исследование результатов бенчмаркинга с разных сторон.
Как и во всем, два представленных варианта представляют собой различные компромиссы. Ни один из них не является объективно лучшим или худшим. Я бы рекомендовал рассмотреть возможность использования обоих в гибридном подходе, в зависимости от ваших целей.
Для реального сценария бенчмарка протокола Remote Write более полезным оказался метод «разных кейсов
». Это связано с тем, что код Prometheus уже поддерживает несколько батчей сэмплов, и реализации для различных сжатий и кодировщиков были легко импортированы. Нам также необходимо поддерживать обе версии протокола 1.0 и 2.0, поэтому они уже сосуществуют в текущей кодовой базе. Кроме того, учитывая относительную популярность протокола, я хотел убедиться, что все желающие смогут воспроизвести бенчмарки и дать обратную связь. Все эти причины делают выбор понятным.
Надеюсь, теперь вы знаете, какой метод использовать для бенчмаркинга в ваших инженерных приключениях! Вы также можете проверить другие полезные опции benchstat
, например, я недавно использовал опцию -format csv
для создания графиков Google Sheets. Я также обнаружил, что обращение к Gemini
для рендеринга графиков довольно полезно и точно, но старый добрый способ все же дает немного больше детерминированного контроля над мелкими деталями.
Комментарии в Telegram-группе!