golang

Как работает PGO-оптимизация в Go «на пальцах»

  • среда, 17 мая 2023 г. в 00:04:52
https://habr.com/ru/companies/yandex_praktikum/articles/729570/

В феврале появилась новая версия Golang — 1.20. Разработчики представили в предварительной версии инструмент оптимизации — profile-guided optimization, или коротко PGO. Этот подход позволяет оптимизировать процесс компиляции приложения за счет использования информации, собранной при профилировании сборки в момент выполнения программы в рабочем окружении.

В итоге, опираясь на собранные данные, компилятор в состоянии принимать более качественные решения оптимизации при очередном запуске программы на основании частоты использования блоков кода. По заверениям разработчиков, использование PGO позволяет увеличить производительность приложения на 2-4%. Так ли это на самом деле? Разберёмся в статье.

Гофера ждут новые горизонты
Гофера ждут новые горизонты

Концепция profile-guided optimization не нова. Она давно используется в компиляторах С-подобных языков — GCC и Clang/LLVM. А благодаря тому, что LLVM используется как бэкенд компиляторов прочих ЯП, то PGO доступна и для Rust. К слову, Java, судя по документации, тоже обладает возможностью такой оптимизации в GraalVM и HotSpot JVM. На сегодняшний день технику оптимизации через сбор профилей программы внедрили в свои процессы разработчики браузеров: Chrome, Firefox, Opera. Судя по всему, Microsoft использует эту технику в Windows.

Какие же изменения позволили реализовать этот инструмент в компиляторе Golang? Основополагающим моментом является определение «горячих» вызовов функций из переданного при сборке бинарного файла профиля CPU. Соответствующий метод расположен перед логикой определения реальной возможности встраивания функций.

func InlineDecls(p *pgo.Profile, decls []ir.Node, doInline bool) {
		if p != nil {
			pgoInlinePrologue(p, decls)
		}
	…
}

Сначала мы проверяем, передали ли нам файл профилирования или нет. Далее pgoInlinePrologue() использует внутри себя вероятностную функцию распределения, работающую с отсортированным по весу списком IR-нод — результатом профилирования из соответствующего файла. Вес и является мерилом частоты вызова. Логика проста: мы обходим список, набирая из него «горячие» вызовы до тех пор, пока не пройдём его полностью или пока не наберём какое-то количество «горячих» нод, чей суммарный вес будет составлять по умолчанию 99% от общего веса элементов всего списка.

Далее таким вызовам присваивается специальный максимальный бюджет для дальнейшего расчёта компилятором возможности встраивания, равный 2000 единиц вместо стандартных 80, а затем компилятор работает, по сути, с тем же алгоритмом, что и раньше. Прочие вызовы изменения не затронут. То есть в данной реализации PGO работает только на этапе встраивания функций. При этом не каждая функция будет встраиваться компилятором. Для определения подходящих для процесса экземпляров в исходном коде существуют определённые правила. Например, функция не встроится, если в её теле присутствуют ключевые слова go и defer. Также встраивание не работает там, где имеется паника и функция её обработки — recover(). Рекурсия тоже считается ограничением для встраивания. И, конечно, компилятор не встроит функции с директивами go:{noinline, norace с флагом -race, nocheckptr, cgo_unsafe_args, uintptrkeepalive, uintptrescapes, yeswritebarrier}. В этой статье мы будем проверять, какое влияние оказывает PGO на работу приложения, поэтому не будем рассматривать код, где используются перечисленные ограничения.

Еще одним нюансом является сам процесс профилирования. Разработчики предупреждают, что профилирование CPU должно происходить так, чтобы в итоге получились репрезентативные данные. Значит у нас будут иметься граничные случаи:

  1. экземпляр сервиса может быть не нагружен в момент профилирования, соответственно, ничего полезного в это время мы не узнаем;

  2. трафик может быть разным в течение дня, что влияет на определение «горячих» вызовов;

  3. сервис может обрабатывать очень долгие запросы, которые гипотетически могут не уложиться в рекомендуемый интервал для сбора профиля CPU, равный 30 секундам;

  4. разные экземпляры сервиса могут иметь различную нагрузку, что также отражается на вычленении важных функций.

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

PGO на практике

Предлагаю теперь попробовать PGO на практике. В исследовании мы условно принимаем, что нагрузка на приложение постоянна во времени, экземпляр сервиса всегда один, длительность запросов укладывается в обозначенный интервал времени сбора профиля, мы находимся в контуре production. Поставим себе цель: добиться прироста производительности и, по возможности, найти пример, для которого оптимизация окажется условно бесполезной. Для этого будем придерживаться следующих шагов в нашем небольшом исследовании:

  • определяем идею приложения, с которым будем работать;

  • пишем код приложения. Используем net/http/pprof для удобства профилирования;

  • пишем небольшое приложение для создания нагрузки на целевое приложение;

  • создаём бинарный файл целевого приложения, соответственно, без использования pgo (профиля же у нас пока нет);

  • запускаем целевое приложение и бенчмарк приложения нагрузки;

  • запускаем профилирование CPU на 60 секунд с помощью pprof;

  • файл профилирования переименовываем в default.pgo и отправляем в папку с main.go;

  • создаём бинарный файл приложения с учётом собранного профиля — флаг -pgo=auto (в таком состоянии при компиляции подтянется файл с профилем по умолчанию из директории с main.go);

  • запускаем бинарный файл с pgo и бенчмарк приложения нагрузки;

  • сравниваем результаты бенчмарков между собой, делаем выводы.

В сугубо исследовательских целях создаём синтетический пример http-приложения, имеющего только одну ручку, принимающую из нагрузочного сервиса сгенерированный слайс из структур с тремя полями типа int, чтобы затем отсортировать его. Зачем? Во имя исследования, конечно. И чтобы с помощью простого кода продемонстрировать, что при применении PGO существует условная граница его полезности и применимости. Для этого и подходит подобная, чисто расчётная задача. Выбор алгоритма сортировки пал на сортировку пузырьком. Так как она довольно затратна, с ростом количества элементов слайса работа занимает всё больше времени (O(n^2)), но её код лёгок в реализации, не имея рекурсии. Такая сортировка кажется хорошим кандидатом для «учебного» примера.

func bubbleSort(sl []example) {
		for i := 0; i < len(sl)-1; i++ {
			for j := i + 1; j < len(sl); j++ {
				if compare(sl[i], sl[j]) {
					sl[i], sl[j] = sl[j], sl[i]
				}
			}
		}	
}

Производим сравнение структур по следующему признаку: если любые два из трех полей структуры слева численно больше, чем такие же поля структуры справа, то левый элемент оказывается сам по себе больше. Логика в функции compare() выглядит немного громоздкой: внутри имеется целых три функции сравнения для каждого из полей, а на следующем уровне вложенности мы не просто сравниваем числа, но и производим дополнительные манипуляции над ними, симулируя операции, которые могут быть возможны в настоящем приложении. Но давайте еще раз условимся, что пример «учебный», и наша задача — получить результат. Поэтому для увеличения стоимости встраивания данной функции все вышеописанное применяется специально.

func compare(left, right example) bool {
		comparing := firstFieldCompare(left.FirstField, right.FirstField)
		comparing = secondFieldCompare(left.SecondField, right.SecondField, comparing)
		if comparing > 1 {
			return true
		}
		return thirdFieldCompare(left.ThirdField, right.ThirdField, comparing)
}

Две stub-функции просто-напросто добавляют и затем сразу же убирают разряд у рассматриваемого числа. Таким образом мы производим работу, но никак не влияем на элементы структур.

func stubIncrease(i int) int {
	return i * 10
}

func stubDecrease(i int) int {
	return i / 10
}

Полный код приложения под спойлером:

Hidden text
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	_ "net/http/pprof"
)

type example struct {
	FirstField  int `json:"first"`
	SecondField int `json:"second"`
	ThirdField  int `json:"third"`
}

func main() {
	http.HandleFunc("/sort", sorting)
	log.Printf("Serving on port 8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func sorting(w http.ResponseWriter, r *http.Request) {
	var sl []example
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	err = json.Unmarshal(body, &sl)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	bubbleSort(sl)
	w.WriteHeader(http.StatusOK)
}

func bubbleSort(sl []example) {
	for i := 0; i < len(sl)-1; i++ {
		for j := i + 1; j < len(sl); j++ {
			if compare(sl[i], sl[j]) {
				sl[i], sl[j] = sl[j], sl[i]
			}
		}
	}
}

func compare(left, right example) bool {
	comparing := firstFieldCompare(left.FirstField, right.FirstField)
	comparing = secondFieldCompare(left.SecondField, right.SecondField, comparing)
	if comparing > 1 {
		return true
	}
	return thirdFieldCompare(left.ThirdField, right.ThirdField, comparing)
}

func firstFieldCompare(left, right int) int {
	if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) {
		return 1
	}

	return 0
}

func secondFieldCompare(left, right, comparing int) int {
	if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) {
		comparing++
	}

	return comparing
}

func thirdFieldCompare(left, right, comparing int) bool {
	if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) {
		comparing++
	}

	if comparing > 1 {
		return true
	}

	return false
}

func stubIncrease(i int) int {
	return i * 10
}

func stubDecrease(i int) int {
	return i / 10
}

Пишем самое простое приложение для создания нагрузки на наше приложение с сортировкой и бенчмарк к нему. Выбираем количество элементов в слайсе, равное 1000. Кажется, что это достаточно большой объём слайса для сортировки пузырьком.

Hidden text
package main

import (
	"bytes"
	"encoding/json"
	"log"
	"math/rand"
	"net/http"
	"time"
)

type example struct {
	FirstField  int `json:"first"`
	SecondField int `json:"second"`
	ThirdField  int `json:"third"`
}

func main() {
	for {
		err := load()
		if err != nil {
			log.Fatalf("we have an error: %v", err)
		}
	}
}

func load() error {
	// каждый раз создаем новый слайс, чтобы симулировать различные варианты приходящих данных
	s := seed()
	sl := createForBubble(s)

	b, err := json.Marshal(sl)
	if err != nil {
		return err
	}

	client := http.Client{}
	request, err := http.NewRequest("POST", "http://localhost:8080/sort", bytes.NewBuffer(b))
	if err != nil {
		return err
	}

	_, err = client.Do(request)
	if err != nil {
		return err
	}

	return nil
}

func seed() rand.Source {
	return rand.NewSource(time.Now().UnixNano())
}

func createForBubble(s rand.Source) []example {
	newSl := make([]example, 0)

	for i := 0; i < 1000; i++ {
		newSl = append(newSl, example{
			FirstField:  rand.New(s).Int(),
			SecondField: rand.New(s).Int(),
			ThirdField:  rand.New(s).Int(),
		})
	}
	return newSl
}

Поехали! Собираем бинарный файл вместе с включённым дебагом компиляции: go build -gcflags -m=2 -o withoutpgo main.go. Получаем следующие логи (здесь и далее опустим информацию о непосредственном встраивании функций, рассматриваем только сообщения о возможности/невозможности этой процедуры)::

./main.go:87:6: can inline stubIncrease with cost 4 as: func(int) int { return i * 10 }
./main.go:91:6: can inline stubDecrease with cost 4 as: func(int) int { return i / 10 }
./main.go:59:6: can inline firstFieldCompare with cost 32 as: func(int, int) int { if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) { return 1 }; return 0 }
./main.go:67:6: can inline secondFieldCompare with cost 33 as: func(int, int, int) int { if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) { comparing++ }; return comparing }
./main.go:75:6: can inline thirdFieldCompare with cost 39 as: func(int, int, int) bool { if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) { comparing++ }; if comparing > 1 { return true }; return false }
./main.go:50:6: cannot inline compare: function too complex: cost 137 exceeds budget 80
./main.go:40:6: cannot inline bubbleSort: function too complex: cost 109 exceeds budget 80
./main.go:23:6: cannot inline sorting: function too complex: cost 391 exceeds budget 80
./main.go:17:6: cannot inline main: function too complex: cost 274 exceeds budget 80

Оценим логи, являющиеся отправной точкой для исследования. На этом этапе компилятор высчитал текущие стоимости всех функций и решил, что может встроить только те из них, которые не превышают выделенный для этого бюджет в 80 единиц. Остальные функции излишне нагружены нами за счёт наличия в их телах вызовов функций следующего уровня вложенности. При этом, если функция встраивается, то у вызывающей её функции дополнительно увеличивается стоимость. На данном этапе встроенные функции вряд ли позволяют нам крупно выиграть по производительности: их вес действительно не очень большой за счёт той работы, что они производят. Нас больше интересует функция compare(), вызываемая во время каждой итерации пузырька. Теоретически, если её встроить, то возможно получить прирост в производительности.

Стартуем бенчмарк для сбора данных о производительности приложения, запустив перед этим созданный ранее бинарный файл: go test -bench=. -benchmem -count=20 | tee without.txt. Действуем далее по нашему плану: запускаем профилирование командой curl -o profile.pprof "http://localhost:8080/debug/pprof/profile?seconds=60", собираем новый бинарный файл с включёнными в него результатами профилирования CPU (go build -pgo=auto -gcflags -m=2 -o withpgo main.go) и снова смотрим логи:

./main.go:87:6: can inline stubIncrease with cost 4 as: func(int) int { return i * 10 }
./main.go:91:6: can inline stubDecrease with cost 4 as: func(int) int { return i / 10 }
./main.go:59:6: can inline firstFieldCompare with cost 32 as: func(int, int) int { if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) { return 1 }; return 0 }
./main.go:67:6: can inline secondFieldCompare with cost 33 as: func(int, int, int) int { if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) { comparing++ }; return comparing }
./main.go:75:6: can inline thirdFieldCompare with cost 39 as: func(int, int, int) bool { if stubIncrease(stubDecrease(left)) > stubIncrease(stubDecrease(right)) { comparing++ }; if comparing > 1 { return true }; return false }
./main.go:50:6: can inline compare with cost 137 as: func(example, example) bool { comparing := firstFieldCompare(left.FirstField, right.FirstField); comparing = secondFieldCompare(left.SecondField, right.SecondField, comparing); if comparing > 1 { return true }; return thirdFieldCompare(left.ThirdField, right.ThirdField, comparing) }
./main.go:40:6: can inline bubbleSort with cost 189 as: func([]example) { for loop }
./main.go:23:6: cannot inline sorting: function too complex: cost 523 exceeds budget 80
./main.go:17:6: cannot inline main: function too complex: cost 274 exceeds budget 80

Два интересных наблюдения:

  1. встроенных функций стало больше — сработала оптимизация. Ее суть, как я уже упоминал, состоит в том, что из файла default.pgo вытягивается взвешенный список вызовов функций. Список сортируется по весу каждого вызова и компилятор помечает те из них, что входят по умолчанию в 99% самых увесистых, как «горячие», т.е. самые часто вызываемые по результатам профилирования. «Горячие» вызовы соотносятся с IR-графом самого приложения и вот теперь… Что? А теперь компилятор присваивает «горячим» вызовам новый бюджет для встраивания. Те самые 2000 единиц. Вот и pgo-оптимизация. Теперь у функции расширяются границы, в рамках которых компилятором принимается решение о встраивании. Таким образом compare(), которая должна больше всех влиять на производительность, теперь в числе избранных, как и ее вызывающая функция bubbleSort();

  2. заметили, что у нас выросли стоимости встраивания? BubbleSort(), к примеру, встроилась со значением в 189 единицу, хотя раньше имела стоимость в 109 единиц. Объяснение вытекает из первого пункта: какие-то функции помечены, как «горячие», значит им увеличили бюджет на встраивание, да еще и стоимость внутреннего наполнения выросла за счет того, что в теле таких функций появились встраиваемые функции, напрямую влияющие на своих верхнеуровневых сородичей. В нашем случае функция main() вызывается только при старте приложения, поэтому в число «горячих» она не попадает. Зато вся остальная цепочка вызовов, не считая sorting(), встроилась, по идее, сократив нам издержки при выполнении заложенной программы.

Отлично, теперь у нас есть доказательства того, что pgo-файл непосредственно влияет на компиляцию. Можно запускать бенчмарк нагрузочного приложения ещё раз, но уже используя бинарный файл с оптимизацией, и сравнивать результаты, надеясь подтвердить наше предположение о сокращении издержек.

goos: linux
goarch: amd64
cpu: Intel(R) Core(TM) i5-4200M CPU @ 2.50GHz
│ with.txt │ without.txt │
│ sec/op │ sec/op vs base │
Load-4 8.982m ± 1% 9.282m ± 1% +3.35% (p=0.000 n=20)

И производительность действительно улучшилась — целых +3,35% при одном проценте погрешности измерения. Это означает, что оптимизация с использованием CPU профиля работает, позволяя улучшать изначально написанный код. За счёт чего? Вызов функции, передача в неё параметров, завершение вызова — всё это отнимает процессорное время при работе сервиса. И несмотря на то что вызов функции — очень дешёвая операция, в нашем случае этих вызовов получается настолько много, что даже небольшие накладные расходы начинают иметь значение, занимая ресурсы и время. В первоначальном варианте компилятору не хватило смелости встроить вызов функции, заменив его по факту на тело compare() и встроенных далее по уровням вложенности функций.

Оптимизация с профилированием помогла компилятору переступить черту и, пожертвовав временем компиляции бинарного файла, встроить функцию, которая выполняется максимально часто. Теперь программе нет необходимости тратить ресурсы на переходы в точках вызовов. При условии частого вызова compare() это изменение оказалось существенным за счёт уменьшения влияния накладных расходов при обходе слайса. Вместе с этим мы попали в указанный разработчиками интервал в 2–4% улучшения производительности. Результат довольно неплохой.

А теперь всё поменяем
А теперь всё поменяем

Но что, если мы поменяем логику работы нашего приложения? Давайте представим, что теперь структуры состоят из строковых полей, а за критерий сортировки примем следующее положение: если сумма символов у любых двух строк левой структуры по отдельности больше, чем сумма символов у двух таких же строк правой структуры, то признаем левый элемент слайса наибольшим в этой паре. Уберём stub-функции и реализуем простой цикл обхода строки:

func strSum(one string) (sum int) {
		for s := range one {
			sum += s
		}

		return
}

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

Hidden text
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	_ "net/http/pprof"
)

type example struct {
	FirstField  string `json:"first"`
	SecondField string `json:"second"`
	ThirdField  string `json:"third"`
}

func main() {
	http.HandleFunc("/sort", sorting)
	log.Printf("Serving on port 8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func sorting(w http.ResponseWriter, r *http.Request) {
	var sl []example
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	err = json.Unmarshal(body, &sl)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	bubbleSort(sl)
	w.WriteHeader(http.StatusOK)
}

func bubbleSort(sl []example) {
	for i := 0; i < len(sl)-1; i++ {
		for j := i + 1; j < len(sl); j++ {
			if compare(sl[i], sl[j]) {
				sl[i], sl[j] = sl[j], sl[i]
			}
		}
	}
}

func compare(left, right example) bool {
	comparing := firstFieldCompare(left.FirstField, right.FirstField)
	comparing = secondFieldCompare(left.SecondField, right.SecondField, comparing)
	if comparing > 1 {
		return true
	}
	return thirdFieldCompare(left.ThirdField, right.ThirdField, comparing)
}

func firstFieldCompare(left, right string) int {
	if strSum(left) > strSum(right) {
		return 1
	}

	return 0
}

func secondFieldCompare(left, right string, comparing int) int {
	if strSum(left) > strSum(right) {
		comparing++
	}

	return comparing
}

func thirdFieldCompare(left, right string, comparing int) bool {
	if strSum(left) > strSum(right) {
		comparing++
	}

	if comparing > 1 {
		return true
	}

	return false
}

func strSum(one string) (sum int) {
	for s := range one {
		sum += s
	}

	return
}

Продолжим следовать нашему алгоритму и выполним сборку бинарного файла без дополнительной оптимизации:

./main.go:87:6: can inline strSum with cost 9 as: func(string) int { for loop; return }
./main.go:59:6: can inline firstFieldCompare with cost 30 as: func(string, string) int { if strSum(left) > strSum(right) { return 1 }; return 0 }
./main.go:67:6: can inline secondFieldCompare with cost 31 as: func(string, string, int) int { if strSum(left) > strSum(right) { comparing++ }; return comparing }
./main.go:75:6: can inline thirdFieldCompare with cost 37 as: func(string, string, int) bool { if strSum(left) > strSum(right) { comparing++ }; if comparing > 1 { return true }; return false }
./main.go:50:6: cannot inline compare: function too complex: cost 131 exceeds budget 80
./main.go:40:6: cannot inline bubbleSort: function too complex: cost 109 exceeds budget 80
./main.go:23:6: cannot inline sorting: function too complex: cost 391 exceeds budget 80
./main.go:17:6: cannot inline main: function too complex: cost 274 exceeds budget 80

Наблюдаем знакомую по прошлому примеру картину: встраиваются функции, находящиеся на нижнем уровне вложенности, имеющие небольшие стоимости этого самого встраивания. И вновь compare() оказалась в списке аутсайдеров, хотя именно она должна быть ответственной за нагрузку в приложении. Теперь вновь соберём профиль и создадим новый бинарный файл:

./main.go:87:6: can inline strSum with cost 9 as: func(string) int { for loop; return }
./main.go:59:6: can inline firstFieldCompare with cost 30 as: func(string, string) int { if strSum(left) > strSum(right) { return 1 }; return 0 }
./main.go:67:6: can inline secondFieldCompare with cost 31 as: func(string, string, int) int { if strSum(left) > strSum(right) { comparing++ }; return comparing }
./main.go:75:6: can inline thirdFieldCompare with cost 37 as: func(string, string, int) bool { if strSum(left) > strSum(right) { comparing++ }; if comparing > 1 { return true }; return false }
./main.go:50:6: can inline compare with cost 131 as: func(example, example) bool { comparing := firstFieldCompare(left.FirstField, right.FirstField); comparing = secondFieldCompare(left.SecondField, right.SecondField, comparing); if comparing > 1 { return true }; return thirdFieldCompare(left.ThirdField, right.ThirdField, comparing) }
./main.go:40:6: can inline bubbleSort with cost 183 as: func([]example) { for loop }
./main.go:23:6: cannot inline sorting: function too complex: cost 517 exceeds budget 80
./main.go:17:6: cannot inline main: function too complex: cost 274 exceeds budget 80

По уже описанной логике снова встраивается всё, вплоть до sorting(). Настало время проверить разницу в производительности между двумя сборками приложения.

goos: linux
goarch: amd64
cpu: Intel(R) Core(TM) i5-4200M CPU @ 2.50GHz
│ with.txt │ without.txt │
│ sec/op │ sec/op vs base │
Load-4 36.10m ± 1% 36.27m ± 1% +0.48% (p=0.017 n=20)

А вот это уже интересно. Мы получили прирост всего в +0.48%, что спокойно нивелируется погрешностью измерений. Возникает закономерный вопрос: почему здесь мы получаем такую небольшую разницу в количестве операций в секунду? В принципе, по сравнению с прошлым примером, у нас изменились лишь типы полей и совсем чуть-чуть логика сравнения структур. Но именно тут, как мне кажется, и кроется ответ.

Для медленного пузырька появление множества вызовов функции подсчёта суммы строки оказывается неприятным. Программе приходится прыгать по строкам, чтобы получить сумму их элементов. А это снижает эффективность кода. Да, стоимость функции compare() небольшая, но и количество операций, происходящих в её теле, возросло по сравнению с первым примером. Можно также сравнить количество операций в секунду в этом и в прошлом примере. Заметная разница. К тому же strSum() вызывается минимум 4 раза в теле compare().

Оказывается, что такая конфигурация структуры с выбранной логикой сортировки имеет небольшие шансы на улучшение сортировки с помощью оптимизации, хотя PGO пометила нам все функции, как и в первом примере, «горячими». Значит, можно сделать вывод, что PGO не способна сильно влиять на сам процесс выполнения программы — иногда сложность вычислений возрастает независимо от стоимости и бюджета на встраивание.

Какие можно сделать выводы на основе проведённой работы?

  1. Мы доказали, что PGO в Golang работает. Работает за счёт взаимодействия с операцией встраивания функций на этапе компиляции, что экономит нам процессорное время больше, чем при дефолтном поведении компилятора. Но такая оптимизация не панацея;

  2. Встраивание, улучшенное с помощью PGO, даёт эффект на всём пути «горячих», то есть многократных вызовов функций, изначально имеющих стоимость бОльшую, чем стандартные 80 единиц;

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

  4. Если не получается упростить программу в лоб, то можно не только использовать PGO по прямому назначению при сборке бинарного файла, но и с помощью анализа «горячих» функций полученного файла помочь себе в определении мест ручной оптимизации. Правда, встаёт вопрос, а нужно ли оно при таком небольшом проценте улучшения производительности от PGO, использовать его по назначению, а не только как инструмент диагностики? Пока что выглядит, что на данный момент программист способен провести куда более весомую оптимизацию своими руками, нежели с помощью нового инструмента;

  5. PGO влияет и на вес бинарного файла. В моих примерах бинарный файл без оптимизации весил 8 Мб, с оптимизацией уже 8.1 Мб. Код довольно простой, поэтому разница в 100 Кб не страшна, однако рост веса конечного бинарного файла всё же стоит учитывать в работе, так как оптимизация влияет и на скорость сборки приложения. Для сервисов побольше это может быть не самым приятным следствием. Но и улучшение производительности на 2–4% для них куда более значимое событие.


PGO действительно способен немного помогать с ускорением приложений и сглаживанием разбиения логики по функциям. На этом оставим наш исследовательский код и будем ждать дальнейшего улучшения PGO со стороны разработчиков языка. Кто знает, быть может, через пару версий мы сможем увидеть примеры двузначных чисел при сравнении производительности до и после, а не скромные 2–4%. Спасибо за то, что дочитали до конца! Всем гофера!

P.S. За помощь с технической редактурой статьи большое спасибо Тимофею Кулину!