Перевод/заметки 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: Сравнение эффективности разных версий

Начнем с объяснения наиболее популярного итеративного процесса бенчмаркинга, когда мы запускаем один и тот же бенчмарк на нескольких версиях вашего кода. Как правило, этот метод работает следующим образом:

  1. Создание бенчмарков

    Создать бенчмарк так же просто, как создать тест func BenchmarkFoo(b *testing.B) в вашем файле bar_test.go. Внутри функции вы можете использовать несколько b.Run(...), чтобы проверить разные случаи на схожую функциональность.

  2. Запуск бенчмарка для версии A вашего кода

    Чтобы быстро запустить BenchmarkFoo, по умолчанию на , можно воспользоваться командой 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 
    

    В результате вы получите абсолютные результаты (аллокации, задержки, пользовательские метрики), полученные в ходе выполнения бенчмарка (бенчмарков).

  3. Оптимизируйте код, который вы тестируете

    Затем вы можете сделать git commit всего, что у вас было (просто чтобы не потеряться!), и изменить код, который вы тестируете (например, в попытке оптимизировать его на основе ранее собранных профилей).

  4. Запуск бенчмарка для версии B вашего кода

    Теперь пришло время выполнить тот же бенчмарк, чтобы проверить, действительно ли ваша оптимизация лучше или хуже, изменив при этом имя вывода, например в файле v2.txt.

  5. Проанализируйте результаты 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-группе!