С Go 1.20 компилятор Go начал поддерживать механизм Profile Guided Optimization (далее будет называться PGO) для оптимизации сборок. В этой статье я покажу вам свой опыт работы с PGO и то, как вы можете использовать его в своих проектах.

Введение

Перед дальнейшим объяснением того, как включить PGO в наших проектах, я хочу кратко представить, что такое PGO.

Есть много оптимизаций, которые компилятор не может или не будет выполнять, потому что у компилятора нет необходимого контекста того, что происходит во время выполнения. Компилятор не знает, какими будут значения во время компиляции. Это может быть известно только во время выполнения. Например, дорогостоящая инструкция деления не может быть заменена гораздо менее затратной операцией записи-сдвига.

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

Функция расчета BuyBox

Чтобы увидеть реальное влияние PGO на производительность, я использовал функцию, в которой много математических вычислений — другими словами, операций процессора.

При расчете системы buybox* используются определенные показатели, и каждый показатель имеет определенную ставку. В этой функции мы рассчитываем оценку продукта при покупке, вычисляя каждый множитель в соответствии с заданными ставками.

Как использовать

  • Компилятор использует профили ЦП в качестве входных данных для оптимизации. Итак, прежде всего нам нужно включить pprof в нашем приложении. Вы можете использовать любую библиотеку для сбора профилей, например runtime/pprof и net/http/pprof. В своих испытаниях я использовал gin-contrib/pprof.
pprof.Register(router)
  • Мы должны собрать наши первые профили из исходного бинарника без PGO. Вы можете собирать эти профили из рабочей среды, тестовых сред или из репрезентативного эталонного теста. Поскольку микробенчмарки представляют собой лишь небольшую часть вашего приложения, оптимизация также невелика. Самое главное, чтобы профили отображали реальное поведение вашего приложения. Для сбора точных данных профиля; мы должны собирать профили из разных экземпляров вашего производственного приложения в разное время и объединять их в один профиль.
// to collect profiles for 30 seconds
http://url_of_your_application.com/debug/pprof/profile?seconds=30 

// to merge multiple pprof files
go tool pprof -proto profile1 profile2 > merged  
  • Далее мы будем использовать собранные профили на этапе сборки нашего приложения. Go нужен один файл pgo для каждого основного пакета, поэтому прежде всего нам нужно преобразовать наш файл pprof в файл .gpo и назвать его default.pgo. После подготовки файла pgo нам нужно поместить его в исходный каталог основного пакета. Мы должны передать этот файл в репозиторий, потому что он будет вводом нашей сборки.
  • Чтобы включить pgo, мы должны добавить флаг -pgo в команду сборки go. В Go 1.20 флаг pgo по умолчанию отключен, но в Go 1.21 значением по умолчанию будет -pgo=auto. Если вам интересно, что еще есть в Go 1.21, вы можете прочитать статью моего друга. Если у вас есть сложный сценарий, например, если вам нужно использовать разные профили для разных сценариев в одном двоичном файле, вы можете установить пути к файлам pgo вместо использования авто.
go build -pgo=off   // disables pgo. default in go 1.20
go build -pgo=auto  // enables pgo and uses default.pgo file under source directory. default in go 1.21
go build -pgo=%path_of_pgo_file/name_of_pgo_file.pgo%  // enables pgo and uses the given pgo file at the specified path
  • После выполнения описанных выше шагов мы, наконец, можем собрать наше приложение и выпустить его. На этом этапе мы наконец можем сравнить производительность текущей и предыдущей сборок.
  • Для непрерывной оптимизации мы должны продолжать собирать профили с производства после включения pgo. Когда нам нужно выпустить другой двоичный файл, мы можем использовать эти новые профили в качестве входных данных.

Go называет этот рабочий процесс итеративным жизненным циклом, и мы можем обобщить его в четырех пунктах:

  1. Собрать и выпустить бинарник без PGO
  2. Собрать профили с производства (желательно)
  3. Если вам нужно выпустить другой двоичный файл, соберите последний исходный код с профилем prod.
  4. Продолжить с шага 2

Полученные результаты

Я провел свои эксперименты в нашей тестовой среде, поэтому использование ЦП и использование памяти не отражают нашу реальную нагрузку. Нагрузка составляет небольшую часть использования производственного приложения.

Без PGO

Моя первоначальная сборка приложения была без pgo. У меня был 1 модуль в тестовой среде. Его средняя загрузка ЦП составляла 0,0529, а использование памяти — 39,6 МБ.

Я также хотел сравнить скорость выполнения функции, когда я задаю ей одинаковые параметры. Я использовал приведенный ниже код для создания нагрузки в своей локальной среде и рассчитал затраченное время для 1000 вызовов функций.Среднее затраченное время составило 475,81 мс.

Elapsed Times:
  average = 475.81522404 ms 
  max = 498.559668 ms 
  min = 468.904482 ms
for i := 0; i < 25; i++ {
 start := time.Now()
 for i := 0; i < 1000; i++ {
  _, _ = service.CalculateBuyBoxScores(request)
 }
 elapsed := time.Since(start)
 fmt.Println("Elapsed time: ", elapsed)
}

С PGO

Я собрал несколько профилей на первом этапе и использовал их на втором этапе. Используя профили из сборки без pgo, я собрал новый бинарник с включенным pgo и выпустил его. Среднее использование ЦП новым двоичным файлом составило 0,0597, а использование памяти — 35,5 МБ. Я рассчитал затраченное время для 1000 вызовов функций, используя блок кода на первом шаге, и среднее затраченное время составило 474,04 мс.

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

Elapsed Times:
  average = 474.03999344 ms 
  max = 488.723976 ms 
  min = 464.657134 ms

С PGO — вторая версия

Чтобы иметь возможность оптимизировать производительность, я собрал профили из бинарного файла с помощью pgo, чтобы компилятор мог учиться на уже оптимизированной версии. Собрав профили, я снова собрал приложение с помощью pgo. Среднее использование ЦП новым двоичным файлом составило 0,0489, а использование памяти — 35,1 МБ. Среднее время, затраченное на 1000 вызовов функций, составило 471,66 мс.

В этой второй итерации я наконец увидел ожидаемое улучшение. Улучшено время выполнения функции с использованием памяти и процессора.

Elapsed Times:
  average = 471.65862048 ms 
  max = 491.186735 ms 
  min = 467.712773 ms

Заключение

В документации Go указано, что ожидаемый прирост производительности составил 2–7%. По моему опыту, время работы функции уменьшилось с 475,81 мс до 471,66 мс (улучшение на 4,11%).

Нам нужно зафиксировать файл pgo (размером около 12 КБ). Как упоминается в документации Go, размеры двоичных файлов могут увеличиваться из-за встраивания дополнительных функций.

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

Подводя итог, я заметил некоторое улучшение производительности даже в тестовой среде, и я хотел бы попробовать PGO и увидеть его влияние в производственной среде.

(*) Buybox: Когда несколько продавцов продают один и тот же продукт, система, которая выбирает продавца, который принесет максимальную выгоду покупателям с определенными показателями алгоритма, и перемещает продукт этого продавца вверх, называется «buybox». ".