Нередко при оптимизации приложений, написанных на языках со статической компиляцией (C, C++, Rust), наступает момент, когда стандартные методы оптимизации, такие как улучшение алгоритмов, подбор структур данных, флаги компиляции вроде -O3, перестают давать дополнительный прирост производительности.
В этот момент многие вспоминают про фундаментальное ограничение статических компиляторов. В отличие от JIT, они не знают, какой код будет горячим, а какой холодным. JIT-компиляторы (JVM, V8, .NET) получают эту информацию в runtime и адаптируют оптимизации под реальную нагрузку. Статические компиляторы генерируют машинный код заранее и лишены информации о поведении программы в runtime.
Для решения этой проблемы используется подход Profile Guided Optimization (PGO). Он позволяет собрать данные о выполнении программы и передать их компилятору для принятия более оптимальных решений при генерации кода. По сути, PGO - это способ дать статическому компилятору некоторые преимущества JIT, сохраняя при этом все преимущества ahead-of-time компиляции: отсутствие пауз на перекомпиляцию и полный контроль над билдом.
В этой статье поговорим о профилировании: методах сбора данных о выполнении программы, ключевых различиях между подходами Instrumentation и Sampling, а также о том, как LLVM использует профиль для оптимизаций на уровнях IR и машинного кода.
Важно: статья носит обзорный характер, примеры приведены в упрощенном виде, чтобы проиллюстрировать концепт. Чтобы глубоко разобраться в теме, рекомендую изучать исходный код LLVM, там кроются настоящие детали реализации. Для этого я буду оставлять ссылки на места в компиляторе, о которых идет речь.
Буду благодарен, если в комментариях укажете на неточности или дополните материал актуальными деталями.
Instrumentation против Sampling
Существует два фундаментально различных метода сбора метрик выполнения: Instrumentation (инструментирование) и Sampling (сэмплирование). Выбор метода зависит от задачи: классический PGO требует точных счётчиков, которые даёт инструментация, тогда как для анализа производительности работающей системы чаще используют сэмплирование.
Стоит отметить, что существует также SamplePGO - вариант PGO на основе сэмплирования. Однако этот подход требует использования специальных инструментов (AutoFDO, llvm-profgen), поэтому в данной статье мы сосредоточимся на PGO на базе инструментации.
Instrumentation
Метод предполагает модификацию исполняемого кода на этапе компиляции. Компилятор вставляет специальные встроенные в компилятор функции профилирования (интринсики) в критические точки программы такие как например вход в функцию или границы базовых блоков.
Концептуальное представление программы после инструментации примерно такое:
void foo(bool condition) { // Счетчик вызовов функции if (condition) { // Счетчик выполнения блока A processA(); } else { // Счетчик выполнения блока B processB(); } }
Сгенерированные компилятором интринсики накапливают данные о выполнении программы в течение всего запуска. И при ее завершении сериализуются в файл профиля, который затем используется при последующей компиляции.
Инструментация предоставляет детерминированные данные: фиксируется каждое выполнение инструментированной точки программы. Это ключевое преимущество перед сэмплированием, где короткие функции могут быть пропущены.
Однако за точность приходится платить. Вставка инструкций нарушает выравнивание кода, увеличивает размер бинарника и добавляет циклы процессора на обслуживание счётчиков. В зависимости от плотности кода замедление может составлять до нескольких раз, что делает инструментированные сборки непригодными для продакшена.
Полный цикл PGO с инструментацией выглядит примерно следующим образом:

Sampling (Сэмплирование)
В отличие от инструментации, сэмплирование не требует модификации целевого бинарника. Сбор данных осуществляется внешним агентом - профилировщиком, который периодически прерывает выполнение программы.
Механика работы основана на использовании аппаратных счётчиков процессора (PMU - Performance Monitoring Unit) или таймерных прерываний, например, SIGPROF. При возникновении прерывания фиксируется значение указателя инструкции и стек вызовов. Агрегация множества таких сэмплов позволяет построить статистику.

Что касается точности, сэмплирование предоставляет статистические данные. Вероятность попадания в сэмпл пропорциональна времени выполнения функции. Это означает, что короткие функции могут быть не зафиксированы, если время их выполнения меньше периода сэмплирования.
Ключевое преимущество данного подхода заключается в минимальных накладных расходах. Программа выполняется в нативном режиме, кэш-линии не загрязняются инструкциями счётчиков. Это делает сэмплирование идеальным выбором для анализа производительности в продакшене.
Как LLVM использует профиль
После сбора профиля начинается второй этап - это использование полученных данных для оптимизации. При компиляции с флагом -fprofile-use clang использует данные профиля на двух уровнях: до генерации кода (LLVM IR) и после генерации кода (Machine IR).
Уровень LLVM IR
На этом этапе профиль влияет на высокоуровневые трансформации кода.
Inliner принимает решения о встраивании функций на основе счётчиков вызовов. Функции с высоким счётчиком вызовов могут быть заинлайнены даже при превышении стандартного порога размера, так как экономия на overhead вызова перевешивает риск промаха в кэш инструкций. Функции, вызываемые редко, не инлайнятся, даже если они малы, чтобы избежать раздувания кода.
LoopUnroll определяет агрессивность развёртки циклов также на основе данных профиля. Он позволяет компилятору отличать горячие участки кода от холодных и применять развёртку избирательно. Если профиль показывает, что цикл выполняется миллионы раз, компилятор оправдывает увеличение размера бинарного файла ради скорости и разворачивает цикл агрессивно. Для редко выполняемых циклов развёртка ограничивается, чтобы не увеличивать размер кода без выгоды для производительности. При этом даже для горячих циклов компилятор оценивает размер тела цикла: если развёртка приведёт к выходу за пределы кэша инструкций, агрессивность снижается.
Уровень Machine IR
После генерации машинного кода профиль используется для низкоуровневых оптимизаций, которые напрямую влияют на использование ресурсов процессора.
MachineBlockPlacement размещает базовые блоки в памяти на основе их частоты выполнения. Горячие блоки располагаются последовательно (fall-through). Холодные блоки выносятся в отдельную секцию .text.unlikely. Это улучшает утилизацию кэша инструкций и работу предсказателя ветвлений процессора.
Пример работы MachineBlockPlacement:
; До оптимизации компилятор не знает, какая ветка горячая cmp rax, 0 je .Lelse .Lthen: call hot_path ; Выполняется в 99% случаев jmp .Lend .Lelse: call cold_path ; Выполняется в 1% случаев jmp .Lend .Lend:
С профилем компилятор уже знает, что .then — горячий блок, а .else - холодный.
; После MachineBlockPlacement: Секция .text (горячий код) cmp rax, 0 je .Lcold ; Прыжок только на редкий случай .Lhot: ; Горячий код идёт сразу call hot_path jmp .Lend ; Секция .text (продолжение) .Lend: ; продолжение кода ; Секция .text.unlikely (холодный код) .Lcold: call cold_path jmp .Lend
Таким образом, горячий путь выполняется без лишнего прыжка, холодный код не занимает место в кэше инструкций рядом с горячим, а процессор лучше предсказывает ветвления.
Register Allocation использует данные профиля для приоритизации переменных. Переменные, живые на горячих путях, получают приоритет для размещения в физических регистрах. Это снижает количество операций spill/reload (выгрузка в стек и загрузка обратно) в критических секциях кода.
Рассмотрим пример работы Register Allocation с профилем:
// Исходный код void compute(int* data, size_t n) { int sum = 0; // Вычисляется один раз int multiplier = get_multiplier(); //Горячий цикл for (size_t i = 0; i < n; i++) { // multiplier используется в каждой итерации sum += data[i] * multiplier; } store_result(sum); }
Без профиля аллокатор регистров применяет эвристики. Если регистров не хватает, он может выгрузить multiplier в стек, даже если эта переменная используется в горячем цикле.
С профилем компилятор знает, что цикл горячий. Переменная multiplier, живая на этом пути, получает приоритет для размещения в регистре.
; Без приоритета (multiplier может быть в стеке) mov eax, [rbp-8] ; Загрузка multiplier из стека (каждая итерация) imul eax, [rdi+rcx*4] ; Умножение add ebx, eax ; С приоритетом (multiplier в регистре r8d) imul eax, r8d, [rdi+rcx*4] ; Умножение прямо из регистра, нет загрузки из стека add ebx, eax
В результате мы получаем меньше обращений к памяти в критических секциях, выше IPC (instructions per cycle).

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