profile-guided поиск по коду

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Если объединить структурный поиск по коду через gogrep и фильтрацию результатов через perf-heatmap, то мы получим profile-guided поиск по коду.


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


Через perf-heatmap также можно аннотировать файл с учётом того, насколько строка исходного кода "горячая".



Введение


Зачем вообще может потребоваться поиск кода с информацией из профилей исполнения, когда у нас уже есть утилиты типа pprof (тем более он доступен через go tool pprof)?


Через профили можно находить разные потенциально узкие места. Например, можно посмотреть топ по функциям. Но некоторые вещи трудно локализовать и агрегировать.


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


Иногда бывают и другие задачи. Например, вы хотите найти все вызовы функции fmt.Errorf, которые имеют только один аргумент. То есть что-то вроде fmt.Errorf("error occured"). Как раз, чтобы понять, есть ли смысл заменять эти вызовы на какой-нибудь errors.New().


Комбинация gogrep+heatmap позволяют делать как раз это: найти какие-то паттерны в коде, которые встречаются на горячих путях исполнения.


Но сначала нам нужно собрать CPU профиль для нашей программы.


Подготавливаем CPU профиль


Есть несколько наиболее популярных способов собрать профиль исполнения:


  1. Запустить бенчмарки с параметром -cpuprofile
  2. Запустить профилировку на старте программы, записать результат в конце исполнения
  3. Динамическое включение и выключение профилирования на лету через ручку
  4. На части машин (или на стейдже) всегда держать профилирование включенным

Для анализа сервисов или долго работающих программ лучше всего подходят 3 и 4. Они предоставляют более-менее реалистичные данные. Для некоторых CLI утилит подходит 2-ой вариант.


Собирать CPU-профили на бенчмарках можно, но вы получите очень своеобразные результаты. Написать идеальные бенчмарки невозможно, поэтому почти всегда они будут указывать на то место, которое будет "перегреваться" бенчмарком, то есть данными, которые вы будете использовать для бенчмарк-функции. А ещё нужно быть особо осторожным при их сборе, если вы делаете это на персональном компьютере.


Для простоты воспроизведения, мы будем собирать профили на основе бенчмарков из стандартной библиотеки Go.


# В текущей директории будет создан файл cpu.out,
# над ним мы и будем экспериментировать.
# Внимание! Эта команда займёт несколько минут.
go test -cpuprofile cpu.out -bench . -timeout 20m -count 2 bytes

Go экспортирует профили в формате profile.proto. Парсить эти файлы из Go можно пакетом github.com/google/pprof/profile.


perf-heatmap


Пакет perf-heatmap позволяет создать особый индекс на основе CPU-профиля в формате profile.proto.


Вместе с библиотекой в комплекте идёт утилита командной строки, которая имеет две основные операции:


  • perf-heatmap json cpu.out распечатать heatmap индекс в JSON формате
  • perf-heatmap stat cpu.out распечатать heatmap индекс для данного профиля

Если у вас установлен Go, поставить эту утилиту можно следующей командой:


go install github.com/quasilyte/perf-heatmap/cmd/perf-heatmap@latest

Все семплы, что есть в cpu.out, уже указывают на строки кода, которые точно исполнялись хотя бы раз, так что мёртвый код туда не входит. Параметр -threshold указывает какой процент семплов из топа мы берём. Например, -threshold=0.5 означает, что мы берём top50% всех результатов, -threshold=1.0 разметит все семплы, а -threshold=0.1 возьмёт только самые горячие 10%.


Далее строки, попавшие в topN% диапазон будут распределены на равномерные 5 категорий: от менее горячей к самой пылающей.


Все размеченные семплы (topN%) считаются горячими, разница лишь в их рейтинге (heat level от 1 до 5 включительно). Каждая строка имеет два уровня: локальный (по файлу) и глобальный (по всей программе). Чаще всего нас интересует именно глобальный уровень. Локальный уровень полезен при просмотре файла в редакторе, чтобы просто оценить какие куски кода более горячие относительно остальных в этом же файле.


perf-heatmap stat -filename buffer.go cpu.out

gogrep + heatmap


Установим gogrep:


go install github.com/quasilyte/gogrep/cmd/gogrep@latest

Выполним поиск по пакету bytes:


# Перейдём в директорию, где лежат исходные коды пакета bytes
cd $(go env GOROOT)/src/bytes

# Запустим поиск append() вызовов, которые лежат в горячих путях
# исполнения (фильтруя по нашему профилю, собранному на бенчмарках)
gogrep -heatmap cpu.out . 'append($*_)' '$$.IsHot()'
bytes.go:487:               spans = append(spans, span{start, i})
bytes.go:500:       spans = append(spans, span{start, len(s)})
bytes.go:626:           return append([]byte(""), s...)
bytes.go:656:           return append([]byte(""), s...)
bytes.go:702:           b = append(b, byte(c))
bytes.go:710:               b = append(b, replacement...)
bytes.go:715:       b = append(b, s[i:i+wid]...)
found 7 matches

Возможно, вы не знакомы с идиомами gogrep и ruleguard, поэтому вот разъяснения:


  • $*_ означает от 0 до N произвольных выражений
  • $$ — это особая переменная, которая означает "корневой матч", как $0 в регулярках

По умолчанию gogrep использует threshold=0.5. Это значение можно переопределить через параметр heatmap-threshold.


gogrep -heatmap cpu.out -heatmap-threshold 0.1 . 'append($*_)' '$$.IsHot()'
bytes.go:487:               spans = append(spans, span{start, i})
bytes.go:500:       spans = append(spans, span{start, len(s)})
bytes.go:626:           return append([]byte(""), s...)
bytes.go:656:           return append([]byte(""), s...)
found 4 matches

Поиск всех fmt.Errorf() от одного аргумента может выглядеть как-то так:


gogrep -heatmap cpu.out . 'fmt.Errorf($format)' '$format.IsHot()'

Здесь я намеренно использовал именованную gogrep переменную $format, а потом применил фильтр IsHot() именно к ней. В данном случае это почти одно и то же, как и $$.IsHot().


Более подробно язык шаблонов gogrep описан в статье gogrep: структурный поиск и замена Go кода.


Скринкаст с использованием gogrep в формате asciinema: asciinema.org/a/j8JM8prOFscPPCXJJPXpYwjil


Как heatmap интегрирован в gogrep


Будем считать, что <var>.IsHot() это предикат типа func isHot(var gogrepVar) bool.


gogrepVar захватывает некоторое AST дерево, которое совпало с шаблоном. В случае $$ это дерево целиком, для именованных переменных типа $x это подвыражение или какой-нибудь statement. На практике gogrepVar — это захваченный ast.Node объект плюс имя переменной шаблона.


isHot(v) получает диапазон строк [fromLine, toLine] через ast.Node. Далее выполняется Index.QueryLineRange(..., fromLine, toLine). Если хотя бы для одной из строк в диапазоне найдётся семпл, прошедший threshold, isHot(v) вернёт true.


На данным момент нельзя найти горячий код по диапазону, который шире одной функции. Замыкания (анонимные функции) считаются отдельными функциями.


Плагин VS Code


perf-heatmap on marketplace.visualstudio.com


Чтобы не требовать от пользователей плагина установки утилиты perf-heatmap, я использовал gopherjs для конвертации Go-кода в JS, который поставляется вместе с VS Code расширением.


Как использовать расширение:


  1. CPU профиль нужно преобразовать в heatmap индекс и оставить его в памяти. Это можно сделать командой perf-heatmap.loadProfile
  2. Когда индекс доступен, можно разметить файл локальным или глобальным рейтингом heat level'ов. Делается это командами perf-heatmap.annotateLocalLevels и perf-heatmap.annotateGlobalLevels

Найти эти команды можно через ctrl+shift+p (workbench.action.showCommands).



Создание плагина не было основной задачей. Это лишь способ наглядной демонстрации как ещё можно использовать perf-heatmap. Если вам понравилась идея, то можете помочь с улучшением расширения.

Маппинг символов из профиля


Перед тем, как мы перейдём к заключительной части, я хотел бы поделиться как именно heatmap сопоставляет символы при запросах.


В тривиальной ситуации, когда CPU профиль был собран на вашей машине, как и исполняющийся бинарник, то никаких проблем нет. Можно сопоставлять файла и строки кода хоть по абсолютным путям.


Становится сложнее, когда бинарник мог собираться на отдельном build агенте, а профиль собирался в неизвестном окружении. У вас локально путь может вести к модулю (go modules), а на build агенте сборка могла производиться с использованием вендоринга.


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


Автоматически этот префикс вычислять не очень просто. А если у нас только 1 семпл, то это вовсе не возможно, ведь на момент построения индекса у нас есть только информация из профиля. Было бы не очень красиво дополнять метаданные индекса по мере исполнения запросов. Нам пришлось бы добавлять синхронизацию на методы запросов (ведь они начинают менять состояние индекса, а он может разделяться между горутинами).


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


type Key struct {
    // TypeName is a receiver type name for methods.
    // For functions it should be empty.
    TypeName string

    // FuncName is a Go function name.
    // For methods, TypeName+FuncName compose a full method name.
    FuncName string

    // Filename is a base part of the full file path.
    Filename string

    // PkgName is the name of the package that defines this symbol.
    PkgName string
}

Допустим, наш ключ равен Key{"Buffer", "Write", "buffer.go", "bytes"}, тогда мы можем сделать следующие запросы к индексу:


  • QueryLine(key, 10) => HeatLevel
  • QueryLineRange(key, 20, 40) => []HeatLevel

HeatLevel — это пара {LocalHeatLevel, GlobalHeatLevel}.


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


Заключительные слова



У меня нет готового универсального корпуса лучших шаблонов. Часть из performance диагностик реализована в go-critic (хотя он не использует CPU профили). gogrep+heatmap уникален именно как инструмент для поиска ваших собственных паттернов.


Это так же не заменяет тщательное изучение профилей из pprof и других инструментов.


gogrep+heatmap может расширить ваш арсенал. Эта связка пригодится вам когда остальных средств будет недостаточно.


Когда у меня нет хороших готовых формулировок, я пытаюсь найти какие-то примеры. Поэтому вот вам ещё один пример.


$ gogrep --heatmap cpu.out . \
  'var $b bytes.Buffer; $*_; return $b.$m()' \
  '$m.Text() == "String" && $m.IsHot()'

Этим запросом находятся локализованные использования bytes.Buffer, где мы в конце возвращаем результат как строку. В этих случаях иногда может быть полезно заменить буфер на strings.Builder.


Вместо $b.String() я использовал переменную $m которую потом мы проверяем в фильтре, сопоставляя со String. Сделано это для того, чтобы у нас была дополнительная переменная для привязки IsHot().


Использование $$.IsHot() означало бы, что любой семпл из профиля на пути от var $b bytes.Buffer до return $b.String() сделал бы матч успешный. Использование $b.IsHot() было бы привязано к первой декларации, так как позиция матч переменной всегда связывается с первым совпадением.


Если мы будем смотреть в профиль, то можем найти там bytes.(*Buffer).String() и понять, что это горячая функция. Далее мы можем посмотреть стеки вызовов и найти пути, на которых мы проводим больше всего времени (pprof может даже графы построить прямо в браузере). Но что сделать довольно сложно — это соединить информацию из профиля с какими-то более сложными кейсами. В случае выше по шаблону поиска мы понимаем, что для этого случая есть вполне понятное решение — взять strings.Builder.

Источник: https://habr.com/ru/post/596755/


Интересные статьи

Интересные статьи

Только что мы представили новую версию поиска Y1. Она включает в себя комплекс технологических изменений. В том числе улучшения в ранжировании за счёт более глубокого применения трансформ...
Множество (Set) — структура данных, которая позволяет достаточно быстро (в зависимости от реализации) применить операции add, erase и is_in_set. Но иногда этого не достаточно: например,...
Привет, Хабр! Время от времени на любимом мною ресурсе проскакивают темы а-ля "Как я выгорел на своей первой работе", "Жизнь — боль" и т.п., зачастую преисполненные разочарования и юношеского ма...
Я уже рассказывал тут пару раз об источниках поиска работы, о том, какие каналы смотреть при подготовке к собеседованиям и вот это всё. Сейчас решил поделиться своими наблюдениями за рынком и под...
Материал, первую часть перевода которого мы публикуем сегодня, посвящён масштабной проблеме, которая возникла в gitlab.com. Здесь пойдёт речь о том, как её обнаружили, как с ней боролись, и как, ...