В Go 1.20 была выпущена предварительная версия profile-guided optimization (PGO), которую пользователи могли протестировать. После устранения известных ограничений в предварительной версии и дополнительных доработок благодаря отзывам и вкладу сообщества, PGO в Go 1.21 готова к использованию! Полная документация приведена в руководстве пользователя по profile-guided optimization.

Ниже мы рассмотрим пример использования PGO для повышения производительности приложения. Прежде чем мы перейдем к рассмотрению этого вопроса, необходимо выяснить, что же такое "profile-guided optimization"?

Когда вы собираете двоичный файл Go, компилятор Go выполняет оптимизацию, пытаясь использовать все доступные ему методы. Например, распространение констант(constant propagation) может вычислять константные выражения во время компиляции, избегая затрат на вычисление во время выполнения. Escape-анализ позволяет избежать выделения памяти на кучи для объектов с локальной областью видимости, уменьшая накладные расходы GC. Инлайнинг(inlining) копирует тело простых функций в вызывающие, часто позволяя проводить дальнейшую оптимизацию в вызывающей функции (например, дополнительное распространение констант или лучший escape-анализ). Девиртуализация(devirtualization) преобразует косвенные(indirect) вызовы значений интерфейса, тип которых может быть определен статически, в прямые вызовы конкретного метода (что часто позволяет инлайнить вызов).

Go улучшает оптимизацию от версии к версии, но это не простая задача. Некоторые оптимизации можно настраивать, но компилятор не может просто “увеличить значение до 11”, потому что чрезмерно агрессивная оптимизация может на самом деле снизить производительность или сильно увеличить время сборки.

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

Или может?

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

Процесс оптимизации компилятора с использованием информации о поведении приложений называется Profile-Guided Optimization (PGO) (также известный как Feedback-Directed Optimization (FDO)).

Пример

Создадим сервис, который преобразует Markdown в HTML: пользователь загружает исходник Markdown в /render, который возвращает преобразованый HTML. Мы можем использовать gitlab.com/golang-commonmark/markdown, чтобы легко это реализовать.

Настройка

$ go mod init example.com/markdown
$ go get gitlab.com/golang-commonmark/markdown@bf3e522c626a

В main.go:

package main

import (
    "bytes"
    "io"
    "log"
    "net/http"
    _ "net/http/pprof"

    "gitlab.com/golang-commonmark/markdown"
)

func render(w http.ResponseWriter, r *http.Request) {
    if r.Method != "POST" {
        http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
        return
    }

    src, err := io.ReadAll(r.Body)
    if err != nil {
        log.Printf("error reading body: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }

    md := markdown.New(
        markdown.XHTMLOutput(true),
        markdown.Typographer(true),
        markdown.Linkify(true),
        markdown.Tables(true),
    )

    var buf bytes.Buffer
    if err := md.Render(&buf, src); err != nil {
        log.Printf("error converting markdown: %v", err)
        http.Error(w, "Malformed markdown", http.StatusBadRequest)
        return
    }

    if _, err := io.Copy(w, &buf); err != nil {
        log.Printf("error writing response: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }
}

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

Сборка и запуск сервера:

$ go build -o markdown.nopgo.exe
$ ./markdown.nopgo.exe

2023/08/23 03:55:51 Serving on port 8080...

Попробуем отправить несколько Markdown из другого терминала. В качестве примера документа мы можем использовать README.md из проекта Go:

$ curl -o README.md -L "https://raw.githubusercontent.com/golang/go/c16c2c49e2fa98ae551fc6335215fadd62d33542/README.md"
$ curl --data-binary @README.md http://localhost:8080/render
<h1>The Go Programming Language</h1>
<p>Go is an open source programming language that makes it easy to build simple,
reliable, and efficient software.</p>
...

Профилирование

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

В main.go мы импортировали net/http/pprof, который автоматически добавляет эндпоинт(endpoint) /debug/pprof/profile на сервер для получения CPU профиля.

Обычно требуется собрать профиль из вашего продакшн окружения(production environment), чтобы компилятор получил репрезентативное представление о поведении в рабочей среде. Поскольку в этом примере нет “production” окружения, я создал простую программу для генерации нагрузки, пока мы собираем профиль. Получите и запустите генератор нагрузки (убедитесь, что сервер все еще работает!):

$ go run github.com/prattmic/markdown-pgo/load@latest

Пока он работает, загрузите профиль с сервера:

$ curl -o cpu.pprof "http://localhost:8080/debug/pprof/profile?seconds=30"

По завершению отключите генератор нагрузки и сервер.

Использование профиля

Инструментарий Go автоматически включит PGO, если найдет профиль с именем default.pgo в главном каталоге пакета. В качестве альтернативы, флаг -pgo в go build принимает путь к профилю.

Хранение профиля вместе с исходным кодом обеспечивает то, что пользователи без дополнительных усилий получают доступ к профилю, как только загружают репозиторий (посредством системы управления версиями или через go get). Это также позволяет сохранять повторяемость сборок.

Давайте собирем:

$ mv cpu.pprof default.pgoМы можем проверить, что PGO был включен в сборку с `go version`:
$ go build -o markdown.withpgo.exe

Мы можем проверить, что PGO был включен в сборку с помощью go version:

$ go version -m markdown.withpgo.exe
./markdown.withpgo.exe: go1.21.0
...
        build   -pgo=/tmp/pgo121/default.pgo

Оценка

Мы будем использовать GO бенчмарк версию генератора нагрузок для оценки влияния PGO на производительность.

Сначала мы проведем бенчмаркинг сервера без PGO. Запустите этот сервер:

$ ./markdown.nopgo.exe

Пока он работает, выполните несколько итераций бенчмарка:

$ go get github.com/prattmic/markdown-pgo@latest
$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > nopgo.txt

После этого остановите исходный сервер и запустите версию с PGO:

$ ./markdown.withpgo.exe

Пока он работает, выполните несколько итераций бенчмарка:

$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > withpgo.txt

После этого сравним полученные результаты:

$ go install golang.org/x/perf/cmd/benchstat@latest
$ benchstat nopgo.txt withpgo.txt
goos: linux
goarch: amd64
pkg: github.com/prattmic/markdown-pgo/load
cpu: Intel(R) Xeon(R) W-2135 CPU @ 3.70GHz
        │  nopgo.txt  │            withpgo.txt             │
        │   sec/op    │   sec/op     vs base               │
Load-12   374.5µ ± 1%   360.2µ ± 0%  -3.83% (p=0.000 n=40)

Новая версия стала быстрее примерно на 3,8%! В Go 1.21 рабочие нагрузки обычно получают от 2% до 7% прироста CPU за счет включения PGO. Профили содержат огромное количество информации о поведении приложения, и Go 1.21 только начинает пробивать себе дорогу, используя эту информацию для ограниченного набора оптимизаций. Будущие версии будут продолжать повышать производительность по мере того, как все больше частей компилятора будут использовать преимущества PGO.

Следующие шаги

В этом примере, после сбора профиля, мы перестроили наш сервер, используя точно такой же исходный код, который использовался в исходной сборке. В реальных условиях всегда идет непрерывная разработка. Таким образом, мы можем получить профиль из рабочей среды, в которой выполняется код прошлой недели, и использовать его для сборки с сегодняшним исходным кодом. Это совершенно нормально! PGO в Go может обрабатывать незначительные изменения в исходном коде без проблем. Конечно, со временем исходный код будет меняться все больше и больше, поэтому по-прежнему важно время от времени обновлять профиль.

Для получения более подробной информации об использовании PGO, рекомендациях и предостережениях, о которых следует знать, пожалуйста, ознакомьтесь с profile-guided optimization user guide. Если вам интересно, что происходит под капотом, продолжайте читать!

Под капотом

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

Инлайнинг

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

Я буду сравнивать с помощью методики дифференциального профилирования, когда мы собираем два профиля (один с PGO, другой без) и сравниваем их. Для дифференциального профилирования важно, чтобы оба профиля представляли одинаковый объем работы, а не одинаковое количество времени, поэтому я настроил сервер на автоматический сбор профилей, а генератор нагрузки - на отправку фиксированного количества запросов и последующее завершение работы сервера.

Изменения, которые я внес на сервер, а также собранные профили можно найти на сайте https://github.com/prattmic/markdown-pgo. Генератор нагрузки был запущен c -count=300000 -quit.

В качестве быстрой проверки согласованности давайте взглянем на общее CPU время, необходимое для обработки всех 300к запросов:

$ go tool pprof -top cpu.nopgo.pprof | grep "Total samples"
Duration: 116.92s, Total samples = 118.73s (101.55%)
$ go tool pprof -top cpu.withpgo.pprof | grep "Total samples"
Duration: 113.91s, Total samples = 115.03s (100.99%)

CPU время сократилось с ~118с до ~115с, или примерно на 3%. Это соответствует результатам наших тестов, что является хорошим признаком того, что эти профили репрезентативны.

Теперь мы можем открыть дифференцированный профиль для поиска экономии:

$ go tool pprof -diff_base cpu.nopgo.pprof cpu.withpgo.pprof
File: markdown.profile.withpgo.exe
Type: cpu
Time: Aug 28, 2023 at 10:26pm (EDT)
Duration: 230.82s, Total samples = 118.73s (51.44%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top -cum
Showing nodes accounting for -0.10s, 0.084% of 118.73s total
Dropped 268 nodes (cum <= 0.59s)
Showing top 10 nodes out of 668
      flat  flat%   sum%        cum   cum%
    -0.03s 0.025% 0.025%     -2.56s  2.16%  gitlab.com/golang-commonmark/markdown.ruleLinkify
     0.04s 0.034% 0.0084%     -2.19s  1.84%  net/http.(*conn).serve
     0.02s 0.017% 0.025%     -1.82s  1.53%  gitlab.com/golang-commonmark/markdown.(*Markdown).Render
     0.02s 0.017% 0.042%     -1.80s  1.52%  gitlab.com/golang-commonmark/markdown.(*Markdown).Parse
    -0.03s 0.025% 0.017%     -1.71s  1.44%  runtime.mallocgc
    -0.07s 0.059% 0.042%     -1.62s  1.36%  net/http.(*ServeMux).ServeHTTP
     0.04s 0.034% 0.0084%     -1.58s  1.33%  net/http.serverHandler.ServeHTTP
    -0.01s 0.0084% 0.017%     -1.57s  1.32%  main.render
     0.01s 0.0084% 0.0084%     -1.56s  1.31%  net/http.HandlerFunc.ServeHTTP
    -0.09s 0.076% 0.084%     -1.25s  1.05%  runtime.newobject
(pprof) top
Showing nodes accounting for -1.41s, 1.19% of 118.73s total
Dropped 268 nodes (cum <= 0.59s)
Showing top 10 nodes out of 668
      flat  flat%   sum%        cum   cum%
    -0.46s  0.39%  0.39%     -0.91s  0.77%  runtime.scanobject
    -0.40s  0.34%  0.72%     -0.40s  0.34%  runtime.nextFreeFast (inline)
     0.36s   0.3%  0.42%      0.36s   0.3%  gitlab.com/golang-commonmark/markdown.performReplacements
    -0.35s  0.29%  0.72%     -0.37s  0.31%  runtime.writeHeapBits.flush
     0.32s  0.27%  0.45%      0.67s  0.56%  gitlab.com/golang-commonmark/markdown.ruleReplacements
    -0.31s  0.26%  0.71%     -0.29s  0.24%  runtime.writeHeapBits.write
    -0.30s  0.25%  0.96%     -0.37s  0.31%  runtime.deductAssistCredit
     0.29s  0.24%  0.72%      0.10s 0.084%  gitlab.com/golang-commonmark/markdown.ruleText
    -0.29s  0.24%  0.96%     -0.29s  0.24%  runtime.(*mspan).base (inline)
    -0.27s  0.23%  1.19%     -0.42s  0.35%  bytes.(*Buffer).WriteRune

При указании pprof -diff_base значения, отображаемые в pprof, представляют собой разницу между двумя профилями. Так, например, runtime.scanobject использовал на 0,46с меньше CPU времени с PGO, чем без него. С другой стороны, gitlab.com/golang-commonmark/markdown.performReplacements использовалось на 0,36с больше CPU времени. В дифференциальном профиле мы обычно хотим посмотреть на абсолютные значения (столбцы flat и cum), поскольку проценты не имеют смысла.

top -cum показывает основные различия по совокупному изменению. То есть разность CPU функции и всех транзитивных вызовов этой функции. Как правило, это показывают самые внешние фреймы в графе вызовов нашей программы, такие как main или другая точка входа в goroutine. Здесь мы видим, что большая часть экономии приходится на часть ruleLinkify, связанную с обработкой HTTP-запросов.

top показывает основные отличия, ограниченные только изменениями в самой функции. Как правило, это показывает внутренние фреймы в графе вызовов нашей программы, где происходит большая часть фактической работы. Здесь мы видим, что индивидуальная экономия достигается в основном за счет runtime функций.

Что это такое? Давайте заглянем в стек вызовов и посмотрим, откуда они берутся:

(pprof) peek scanobject$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.86s 94.51% |   runtime.gcDrain
                                            -0.09s  9.89% |   runtime.gcDrainN
                                             0.04s  4.40% |   runtime.markrootSpans
    -0.46s  0.39%  0.39%     -0.91s  0.77%                | runtime.scanobject
                                            -0.19s 20.88% |   runtime.greyobject
                                            -0.13s 14.29% |   runtime.heapBits.nextFast (inline)
                                            -0.08s  8.79% |   runtime.heapBits.next
                                            -0.08s  8.79% |   runtime.spanOfUnchecked (inline)
                                             0.04s  4.40% |   runtime.heapBitsForAddr
                                            -0.01s  1.10% |   runtime.findObject
----------------------------------------------------------+-------------
(pprof) peek gcDrain$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                               -1s   100% |   runtime.gcBgMarkWorker.func2
     0.15s  0.13%  0.13%        -1s  0.84%                | runtime.gcDrain
                                            -0.86s 86.00% |   runtime.scanobject
                                            -0.18s 18.00% |   runtime.(*gcWork).balance
                                            -0.11s 11.00% |   runtime.(*gcWork).tryGet
                                             0.09s  9.00% |   runtime.pollWork
                                            -0.03s  3.00% |   runtime.(*gcWork).tryGetFast (inline)
                                            -0.03s  3.00% |   runtime.markroot
                                            -0.02s  2.00% |   runtime.wbBufFlush
                                             0.01s  1.00% |   runtime/internal/atomic.(*Bool).Load (inline)
                                            -0.01s  1.00% |   runtime.gcFlushBgCredit
                                            -0.01s  1.00% |   runtime/internal/atomic.(*Int64).Add (inline)
----------------------------------------------------------+-------------

Таким образом, runtime.scanobject в конечном счете происходит из runtime.gcBgMarkWorker. В Go GC Guide говорится, что runtime.gcBgMarkWorker является частью сборщика мусора, поэтому экономия на runtime.scanobject должна быть экономией на GC. А как насчет nextFreeFast и других runtime функций?

(pprof) peek nextFreeFast$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.40s   100% |   runtime.mallocgc (inline)
    -0.40s  0.34%  0.34%     -0.40s  0.34%                | runtime.nextFreeFast
----------------------------------------------------------+-------------
(pprof) peek writeHeapBits
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.37s   100% |   runtime.heapBitsSetType
                                                 0     0% |   runtime.(*mspan).initHeapBits
    -0.35s  0.29%  0.29%     -0.37s  0.31%                | runtime.writeHeapBits.flush
                                            -0.02s  5.41% |   runtime.arenaIndex (inline)
----------------------------------------------------------+-------------
                                            -0.29s   100% |   runtime.heapBitsSetType
    -0.31s  0.26%  0.56%     -0.29s  0.24%                | runtime.writeHeapBits.write
                                             0.02s  6.90% |   runtime.arenaIndex (inline)
----------------------------------------------------------+-------------
(pprof) peek heapBitsSetType$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.82s   100% |   runtime.mallocgc
    -0.12s   0.1%   0.1%     -0.82s  0.69%                | runtime.heapBitsSetType
                                            -0.37s 45.12% |   runtime.writeHeapBits.flush
                                            -0.29s 35.37% |   runtime.writeHeapBits.write
                                            -0.03s  3.66% |   runtime.readUintptr (inline)
                                            -0.01s  1.22% |   runtime.writeHeapBitsForAddr (inline)
----------------------------------------------------------+-------------
(pprof) peek deductAssistCredit$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.37s   100% |   runtime.mallocgc
    -0.30s  0.25%  0.25%     -0.37s  0.31%                | runtime.deductAssistCredit
                                            -0.07s 18.92% |   runtime.gcAssistAlloc
----------------------------------------------------------+-------------

Похоже, что nextFreeFast и некоторые другие в первой десятке в конечном итоге происходят из runtime.mallocgc, который, согласно GC Guide, является memory allocator.

Уменьшение затрат на GC и аллокатор означает, что в целом мы выделяем меньше. Для понимания этого посмотрим на профили кучи:

$ go tool pprof -sample_index=alloc_objects -diff_base heap.nopgo.pprof heap.withpgo.pprof
File: markdown.profile.withpgo.exe
Type: alloc_objects
Time: Aug 28, 2023 at 10:28pm (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for -12044903, 8.29% of 145309950 total
Dropped 60 nodes (cum <= 726549)
Showing top 10 nodes out of 58
      flat  flat%   sum%        cum   cum%
  -4974135  3.42%  3.42%   -4974135  3.42%  gitlab.com/golang-commonmark/mdurl.Parse
  -4249044  2.92%  6.35%   -4249044  2.92%  gitlab.com/golang-commonmark/mdurl.(*URL).String
   -901135  0.62%  6.97%    -977596  0.67%  gitlab.com/golang-commonmark/puny.mapLabels
   -653998  0.45%  7.42%    -482491  0.33%  gitlab.com/golang-commonmark/markdown.(*StateInline).PushPending
   -557073  0.38%  7.80%    -557073  0.38%  gitlab.com/golang-commonmark/linkify.Links
   -557073  0.38%  8.18%    -557073  0.38%  strings.genSplit
   -436919   0.3%  8.48%    -232152  0.16%  gitlab.com/golang-commonmark/markdown.(*StateBlock).Lines
   -408617  0.28%  8.77%    -408617  0.28%  net/textproto.readMIMEHeader
    401432  0.28%  8.49%     499610  0.34%  bytes.(*Buffer).grow
    291659   0.2%  8.29%     291659   0.2%  bytes.(*Buffer).String (inline)

Опция -sample_index=alloc_objects показывает нам количество выделений памяти, независимо от размера. Это полезно, поскольку мы исследуем снижение использования процессора, которое, как правило, больше коррелирует с количеством выделений, а не с их размером. Здесь достаточно много снижений, но остановимся на самом большом - mdurl.Parse.

Для сравнения посмотрим на общее количество аллокаций для этой функции без PGO:

$ go tool pprof -sample_index=alloc_objects -top heap.nopgo.pprof | grep mdurl.Parse
  
   4974135  3.42% 68.60%    4974135  3.42%  gitlab.com/golang-commonmark/mdurl.Parse

Общий подсчет до этого составлял 4974135, что означает, что mdurl.Parse устранил 100% аллокаций!

Возвращаясь к дифференциальному профилю, давайте соберем еще немного контекста:

(pprof) peek mdurl.Parse
Showing nodes accounting for -12257184, 8.44% of 145309950 total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                          -2956806 59.44% |   gitlab.com/golang-commonmark/markdown.normalizeLink
                                          -2017329 40.56% |   gitlab.com/golang-commonmark/markdown.normalizeLinkText
  -4974135  3.42%  3.42%   -4974135  3.42%                | gitlab.com/golang-commonmark/mdurl.Parse
----------------------------------------------------------+-------------

Вызовы mdurl.Parse происходят из markdown.normalizeLink и markdown.normalizeLinkText.

(pprof) list mdurl.Parse
Total: 145309950
ROUTINE ======================== gitlab.com/golang-commonmark/mdurl.Parse in /usr/local/google/home/mpratt/go/pkg/mod/gitlab.com/golang-commonmark/mdurl@v0.0.0-20191124015652-932350d1cb84/parse
.go
  -4974135   -4974135 (flat, cum)  3.42% of Total
         .          .     60:func Parse(rawurl string) (*URL, error) {
         .          .     61:   n, err := findScheme(rawurl)
         .          .     62:   if err != nil {
         .          .     63:           return nil, err
         .          .     64:   }
         .          .     65:
  -4974135   -4974135     66:   var url URL
         .          .     67:   rest := rawurl
         .          .     68:   hostless := false
         .          .     69:   if n > 0 {
         .          .     70:           url.RawScheme = rest[:n]
         .          .     71:           url.Scheme, rest = strings.ToLower(rest[:n]), rest[n+1:]

Полные исходные тексты этих функций можно найти на сайте:

Так что же здесь произошло? В сборке без PGO, mdurl.Parse считается слишком большим, чтобы его можно было инлайнить. Однако, поскольку наш профиль PGO указывал на то, что вызовы этой функции являются "горячими", компилятор инлайнил их.

Это видно по аннотации "(inline)" в профилях:

$ go tool pprof -top cpu.nopgo.pprof | grep mdurl.Parse
     0.36s   0.3% 63.76%      2.75s  2.32%  gitlab.com/golang-commonmark/mdurl.Parse
$ go tool pprof -top cpu.withpgo.pprof | grep mdurl.Parse
     0.55s  0.48% 58.12%      2.03s  1.76%  gitlab.com/golang-commonmark/mdurl.Parse (inline)

mdurl.Parse создает URL в качестве локальной переменной в строке 66 (var url URL), а затем возвращает указатель на эту переменную в строке 145 (return &url, nil). Обычно для этого требуется, чтобы переменная была размещена на куче, поскольку ссылка на нее существует после возврата функции. Однако, когда mdurl.Parse инлайнится в markdown.normalizeLink, компилятор может заметить, что переменная не выходит из normalizeLink, что позволяет компилятору выделить ее на стеке. markdown.normalizeLinkText аналогичен markdown.normalizeLink.

Второе по величине сокращение, показанное в профиле, из mdurl.(*URL).String, представляет собой аналогичный случай устранения эскейпа после инлайнинга.

В этих примерах мы получили улучшение производительности за счет меньшего количества аллокаций на кучи. Единственное изменение, внесенное PGO, заключалось в том, что горячие вызовы функций были заменены inline-вызовами. Все эффекты, связанные с escape-анализом и аллокациями на кучи, были стандартными оптимизациями, применимыми к любой сборке. Улучшенное поведение escape-анализа - отличный эффект инлайнинга, но это не единственный. Многие оптимизации могут использовать преимущества инлайнинга. Например, распространение констант(constant propagation) может упростить код в функции после инлайнинга, когда некоторые входные данные являются константами.

Девиртуализация

Помимо инлайнинга, PGO может осуществлять условную девиртуализацию интерфейсных вызовов.

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

f, _ := os.Open("foo.txt")
var r io.Reader = f
r.Read(b)

Здесь мы имеем вызов метода Read интерфейса io.Reader. Поскольку интерфейсы могут иметь несколько реализаций, компилятор генерирует косвенный(indirect) вызов функции, то есть ищет нужный метод для вызова во время выполнения по типу, указанному в значении интерфейса. Косвенные вызовы имеют небольшие дополнительные затраты времени выполнения по сравнению с прямыми вызовами, но, что более важно, они исключают некоторые оптимизации компилятора. Например, компилятор не может выполнить escape-анализ косвенного вызова, поскольку ему неизвестна реализация конкретного метода.

Но в приведенном примере мы знаем конкретную реализацию метода. Read, поскольку *os.File - единственный тип, который может быть присвоен r. В этом случае компилятор выполнит девиртуализацию, заменив косвенный вызов io.Reader.Read на прямой вызов os.(*File).Read, что позволит выполнить другие оптимизации.

(Вы, вероятно, думаете: "Этот код бесполезен, зачем кому-то писать его таким образом?". Это хорошее замечание, но обратите внимание, что код, подобный приведенному выше, может быть результатом инлайнинга. Предположим, что f передается в функцию, которая принимает аргумент io.Reader. После инлайнинга функции, теперь io.Reader становится конкретным типом.)

PGO девиртуализация распространяет эту концепцию на ситуации, когда конкретный тип не известен статически, но профилирование может показать, что, например, вызов io.Reader.Read чаще всего нацелен на os.(*File).Read. В этом случае PGO может заменить r.Read(b) на что-то вроде:

if f, ok := r.(*os.File); ok {
    f.Read(b)
} else {
    r.Read(b)
}

То есть мы добавляем проверку во время выполнения на конкретный тип, который, скорее всего, появится, и в этом случае используем конкретный вызов или возвращаемся к стандартному косвенному вызову. Преимущество здесь в том, что общий путь (с использованием *os.File) может быть инлайнингом и дополнительной оптимизацией, но при этом мы сохраняем запасной путь, поскольку профиль не является гарантией того, что так будет всегда.

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

Инлайнинг и девиртуализация - это две оптимизации на основе PGO, доступные в Go 1.21, но, как мы видели, они часто открывают дополнительные возможности. Кроме того, в будущих версиях Go будет продолжено совершенствование PGO с помощью дополнительных оптимизаций.

Комментарии (4)


  1. HoneyHeat
    10.10.2023 16:07
    +1

    Мы начали у себя использовать pgo. На некоторых кейсах прирост был 5-7%. Очень неплохо.


  1. Demacr
    10.10.2023 16:07
    +1

    Меня интересует вопрос: а вы как-то встраивали PGO в свои сборочные пайплайны? (другими словами, автоматизировали ли?)


    1. justwack Автор
      10.10.2023 16:07

      Наибольшая сложность это создать релевантную нагрузку и снять профиль. Дальше достаточно добавить профиль с именем default.pgo в корень проекта.

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

      Если есть нагрузочное тестирование или перф регрессия тогда можно брать от них.


      1. Demacr
        10.10.2023 16:07

        А в случае если добавляются новые функции, которых нет в профиле, то не будет ли ухудшения производительности для них?
        P.S. Не сразу заметил что это перевод.