Ускорение без правки кода: флаги сборки, инлайнинг, PGO, LTO и JIT
Двенадцать предыдущих статей учили находить узкие места и чинить их. У всех этих починок общее свойство: они меняли код. Вы переставляли поля структуры, убирали аллокацию, добавляли индекс, батчили запросы. Каждая правка стоила чего-то помимо времени инженера — читаемости, гибкости, лишнего инварианта, который через полгода кто-то нарушит. Это нормальная цена, и рабочий процесс как раз про то, чтобы платить её осознанно.
Но между вашим исходником и инструкциями, которые реально исполняет процессор, лежат ещё три слоя, и каждый принимает за вас сотни решений: какую функцию встроить, в каком порядке разложить базовые блоки, развернуть ли цикл, векторизовать ли, скомпилировать метод или продолжать интерпретировать. Все три слоя настраиваются, и ни один из них не требует трогать ни строчки логики.
Отсюда тезис статьи: тулчейн — единственное место в треке, где ускорение не оплачивается сложностью кода. Значит, проверять его нужно до того, как вы начали усложнять код, а не после. Практика обратная: люди неделями выжимают проценты из горячей функции на сервисе, который собран без PGO, запущен с настройками JIT по умолчанию и линкуется без LTO — а там лежат те же проценты, доступные одной строкой в CI.
Оговорка, без которой тезис превращается в карго-культ. «Бесплатно» здесь означает «без усложнения кода», а не «без цены вообще». Каждый рычаг из этой статьи что-то ломает: время сборки, размер бинаря, воспроизводимость сборки, отладку, портируемость между моделями процессоров или скорость холодного старта. И почти каждый умеет сделать хуже — -O3, включённый «на всякий случай», регулярно проигрывает -O2, а PGO на нерепрезентативном профиле замедляет ровно те пути, ради которых его ставили. Поэтому дисциплина трека здесь не отменяется, а обостряется: тулчейн-правка меняет раскладку кода целиком, а раскладка сама по себе двигает время на единицы процентов («Бенчмаркинг честно»), и отличить эффект оптимизации от эффекта случайного сдвига адресов сложнее, чем в любой другой главе.
Устройство самих оптимизаций — тема трека «Компиляторы»; здесь мы смотрим на них глазами того, кто эксплуатирует сервис: какие ручки есть, что каждая даёт по числам, чем платит и как проверить результат.
1. Три слоя между исходником и инструкциями
Разделите путь кода на четыре момента времени — станет видно, какой рычаг когда работает.
Рычагов почти нет"] FE --> IR["Оптимизации над IR
инлайнинг, свёртка констант,
развёртка циклов, векторизация"] IR --> CG["Кодогенерация
выбор инструкций, регистры,
раскладка базовых блоков"] CG --> OBJ["Объектные файлы"] OBJ --> LNK["Компоновка
LTO: межмодульный инлайнинг
и devirtualization"] LNK --> BIN["Бинарь / байткод"] BIN --> POST["Посткомпоновка
BOLT, Propeller:
перекладка горячего кода"] POST --> RUN["Запуск"] RUN --> JIT["Рантайм с JIT
уровни компиляции, OSR,
деоптимизация, инлайн-кэши"] JIT --> CPU["Инструкции на конвейере"] PROF["Профиль исполнения"] -. "PGO: -fprofile-use, default.pgo" .-> IR PROF -. "AutoFDO / BOLT" .-> POST PROF -. "собирается автоматически" .-> JIT style PROF stroke-dasharray: 4 4
Ключевое наблюдение из схемы: все крупные выигрыши идут по пунктирным стрелкам. Компилятор без профиля вынужден гадать, какая ветка вероятнее и какой вызов горячий; эвристики угадывают средний код, а не ваш. Профиль превращает гадание в знание, и потому PGO даёт больше, чем любая перестановка флагов оптимизации.
| Слой | Когда решает | Что решает | Рычаги | Порядок выигрыша |
|---|---|---|---|---|
| AOT-компилятор | сборка | инлайнинг, циклы, векторизация | -O2/-O3, -march, PGO |
единицы–десятки процентов |
| Компоновщик | сборка | межмодульные оптимизации | LTO (-flto=thin) |
2–10 % |
| Посткомпоновка | после сборки | физическая укладка кода | BOLT, Propeller | 2–8 % поверх PGO |
| Рантайм с JIT | исполнение | что и когда компилировать | флаги VM, AOT-кэш | старт: разы; пик: проценты |
Языки распределены по этим слоям по-разному. C, C++, Rust, Go платят всё на сборке и ничего в рантайме. Java, C#, JavaScript почти ничего не решают на сборке и всё — в JIT. Python и Ruby до недавнего времени не имели ни того ни другого, а сейчас обзавелись специализирующим интерпретатором и экспериментальным JIT. Ручки у вас есть в любом случае — вопрос только, на каком слое их искать.
2. Прибор первый: посмотреть, что получилось
Правило трека — сначала измерение, потом гипотеза — здесь принимает форму «сначала посмотреть на сгенерированный код, потом крутить флаги». Иначе вы спорите о том, встроилась функция или нет, вместо того чтобы прочитать ответ.
# C/C++/Rust: что сгенерировал компилятор для конкретной функции
objdump -d --no-show-raw-insn -M intel ./service | less
perf annotate --stdio -s hot_function # с привязкой сэмплов к инструкциям
# Онлайн: https://godbolt.org — два компилятора и два набора флагов рядом, без сборки проекта
# Go: решения инлайнера и escape-анализа, самое информативное в экосистеме
go build -gcflags='-m -m' ./... 2>&1 | grep -E 'inlining call|cannot inline|escapes'
go build -gcflags='-S' ./internal/hot 2>&1 | head -60 # ассемблер функции
# Rust: что вышло по инструкциям (пакет cargo-show-asm)
cargo asm --lib 'crate::module::hot_fn'
# JVM: решения JIT по инлайнингу и уровням компиляции
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining -jar app.jar
# .NET: дизассемблер конкретного метода прямо из рантайма
DOTNET_JitDisasm='HotClass:Process' dotnet run -c Release
# V8/Node: что оптимизировано и что деоптимизировано
node --trace-opt --trace-deopt server.js 2>&1 | grep -i deopt
Три вещи, которые стоит завести привычку смотреть, прежде чем спорить:
- Встроилась ли горячая функция. Разница между встроенной и невстроенной — не стоимость
call, а весь набор оптимизаций, который после встраивания становится возможен. - Осталась ли проверка границ / нулевого указателя внутри горячего цикла. Одна лишняя проверка на итерацию — это ветвление, которое мешает векторизации.
- Векторизован ли цикл. Если в дизассемблере только скалярные
mulsd, а вы ждалиvmulpd— компилятор от векторизации отказался, и обычно у него была причина.
3. Уровни оптимизации: почему -O3 бывает медленнее -O2
Про уровни существует устойчивое суеверие: чем больше цифра, тем быстрее. На деле уровни различаются не «силой», а готовностью раздувать код ради скорости.
| Уровень | Что включает | Когда осмыслен |
|---|---|---|
-O0 |
ничего, прямая трансляция | только отладка; мерить на нём нельзя в принципе |
-O1 |
дешёвые локальные оптимизации | быстрая сборка, отладка с оптимизациями |
-O2 |
полный набор без раздувания кода | разумный дефолт для сервисов |
-O3 |
агрессивная развёртка циклов и векторизация | численный код, циклы по большим массивам |
-Os / -Oz |
оптимизация по размеру | код, упирающийся в кэш инструкций, embedded |
Механизм, из-за которого -O3 проигрывает: развёртка циклов и агрессивный инлайнинг увеличивают объём горячего кода, а L1i — это обычно 32 КиБ, и он такой же конечный ресурс, как L1d («Кэши и локальность»). Если рабочий набор инструкций перестал помещаться, вы обменяли сэкономленные ветвления на промахи по инструкциям и по iTLB. Именно поэтому существует практика собирать серверные приложения с -O2, а -O3 включать точечно для конкретных численных модулей, замерив каждый.
Вторая причина осторожности: -O3 не меняет семантику, а вот -ffast-math меняет. Он разрешает переассоциацию операций с плавающей точкой, объявляет NaN и бесконечности невозможными, включает режимы FTZ/DAZ на весь процесс — в том числе для чужих библиотек в этом же процессе. Итог: цифры перестают сходиться с эталоном, а баг воспроизводится только в релизной сборке. Если нужна переассоциация конкретного цикла — включайте её локально (#pragma omp simd reduction, -fassociative-math на один файл), а не глобально.
Отдельный флаг, который стоит держать включённым вопреки соблазну: -fno-omit-frame-pointer. Он стоит порядка процента, а без него сэмплирующий профайлер не соберёт стек («Профилирование CPU»). Профилируемость прода дороже процента; ровно поэтому Fedora и Ubuntu включили фреймовые указатели в дистрибутивных сборках.
4. Микроархитектурный уровень: -march, baseline и SIGILL в проде
По умолчанию компилятор целится в самый старый процессор архитектуры: для x86-64 это машина 2003 года без SSE4, POPCNT, AVX и FMA. Ваш серверный Xeon умеет втрое больше, и -march разрешает этим пользоваться.
gcc -O2 -march=x86-64-v3 -mtune=native app.c -o app # переносимый уровень
rustc -C target-cpu=x86-64-v3 # то же для Rust
GOAMD64=v3 go build ./cmd/service # Go: уровни v1..v4
Уровни psABI — способ не привязываться к конкретной модели: x86-64-v2 — примерно Nehalem (SSE4.2, POPCNT), v3 — Haswell (AVX2, BMI, FMA), v4 — AVX-512. Современные дистрибутивы уже подняли базовую планку: RHEL 10 собран под v3. Разница v1 → v3 на коде с циклами по массивам бывает в десятки процентов, на типичном бизнес-коде с указателями и строками — единицы процентов.
Три способа выстрелить себе в ногу:
-march=nativeв контейнере. Собрали на машине с AVX-512, поехали на узел без него —SIGILLв проде на редком пути, который выполняется раз в час. В гетерогенном паркеnativeдопустим только при сборке на самом узле.- Забыть про AVX-downclock. Тяжёлые 512-битные инструкции на части серверных ядер снижают частоту всего ядра. Микробенчмарк цикла ускорился, а сервис в целом замедлился, потому что просели все остальные потоки («Бенчмаркинг честно»).
- Считать, что
-marchзаменяет векторизацию. Он только разрешает инструкции. Если цикл не векторизуется из-за зависимостей по данным, разрешение ничего не меняет — подробности в «SIMD и векторы».
5. Инлайнинг: мать всех оптимизаций и её счёт
Инлайнинг называют матерью оптимизаций не из-за экономии на инструкции call — она стоит единицы тактов и хорошо предсказывается. Ценность в другом: после встраивания тело функции оказывается в контексте вызывающего, и все остальные оптимизации получают то, чего им не хватало: константы аргументов, известные типы, отсутствие альясинга, возможность вынести инвариант из цикла и сцепить две проверки в одну.
Поэтому решение «встроить или нет» — самое влиятельное решение компилятора, и принимается оно по бюджету.
| Тулчейн | Бюджет по умолчанию | Как посмотреть |
|---|---|---|
| Go | стоимость тела до 80 условных единиц | go build -gcflags='-m -m' |
| LLVM (Clang, Rust) | порог около 225 единиц, для горячих по профилю выше | -Rpass=inline, remarks |
| HotSpot C2 | MaxInlineSize=35 байт байткода, для горячих FreqInlineSize=325 |
-XX:+PrintInlining |
| RyuJIT (.NET) | эвристика по размеру IL плюс профиль | DOTNET_JitDisasm, [MethodImpl(AggressiveInlining)] |
Что срывает инлайнинг чаще всего:
- Виртуальный или интерфейсный вызов. Пока цель вызова неизвестна, встраивать нечего. Здесь помогает devirtualization — по профилю (JIT видит, что 99 % вызовов идут в одну реализацию) или по PGO в AOT-компиляторе.
- Мегаморфный call site. Инлайн-кэш JIT рассчитан на одну-две цели; на пятой реализации интерфейса он вырождается в обычный виртуальный вызов. Классическая ловушка бенчмарков: в тесте одна реализация, в проде пять.
- Размер тела. Функция с логированием ошибок, метриками и тремя
ifдля граничных случаев не влезает в бюджет — притом что горячий путь в ней короткий. Лечение — вынести холодную часть в отдельную невстраиваемую функцию, оставив в горячей только основной сценарий. - Конструкции, которые инлайнер отказывается брать. В Go долгое время это были
defer,recover,select,go, рекурсия; часть ограничений снята в поздних версиях. Проверять надо флагом, а не памятью.
// Было: горячий путь не встраивается — тело раздуто холодной обработкой ошибки.
func lookupSlow(m map[string]int, k string) (int, error) {
v, ok := m[k]
if !ok {
// холодная ветка: форматирование, аллокации, стоимость в бюджете инлайнера
return 0, fmt.Errorf("ключ %q не найден среди %d записей", k, len(m))
}
return v, nil
}
// Стало: холодное вынесено, горячее укладывается в бюджет и встраивается.
func lookupFast(m map[string]int, k string) (int, error) {
if v, ok := m[k]; ok {
return v, nil
}
return 0, missingKey(k, len(m))
}
//go:noinline
func missingKey(k string, n int) error {
return fmt.Errorf("ключ %q не найден среди %d записей", k, n)
}
Приём называется outlining холодного пути, и он же — самая частая полезная правка кода в этой главе. Обратите внимание: логика не изменилась, читаемость не пострадала, а решение компилятора изменилось.
Цена инлайнинга — размер кода. Агрессивное встраивание раздувает горячий путь, вытесняет L1i и увеличивает давление на iTLB. Поэтому [MethodImpl(AggressiveInlining)] и #[inline(always)] — инструменты точечные: поставленные «на всякий случай» на всё подряд, они регулярно дают замедление. Ключевое слово inline в C и C++, вопреки названию, вообще не про инлайнинг, а про правила компоновки.
6. Чего компилятор не сделает без вас
Оптимизатор обязан сохранять семантику. Всё, что он не может доказать, он обязан считать возможным — и отказаться от оптимизации. Три классических случая, где вы дёшево даёте ему доказательство.
Альясинг. Если два указателя могут указывать на одну память, значение нельзя держать в регистре между записями.
// Компилятор обязан перечитывать *len на каждой итерации: вдруг dst и len пересекаются.
void scale(double *dst, const double *src, const int *len) {
for (int i = 0; i < *len; i++) dst[i] = src[i] * 2.0;
}
// restrict — обещание «эти области не пересекаются». Цикл векторизуется.
void scale_fast(double *restrict dst, const double *restrict src, int len) {
for (int i = 0; i < len; i++) dst[i] = src[i] * 2.0;
}
Обещание нарушать нельзя: restrict — это контракт, и его нарушение попадает в область неопределённого поведения («Неопределённое поведение») со всеми последствиями. В Rust та же информация даётся системой типов бесплатно: &mut по определению уникален, и компилятор помечает такие указатели как noalias.
Проверки границ. В Go и Rust каждый доступ по индексу проверяется. Проверку можно устранить, если границы очевидны компилятору.
// Проверка границ на каждой из четырёх записей.
func encodeSlow(b []byte, v uint32) {
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
}
// Одна проверка вместо четырёх: срез фиксированной длины доказывает границы.
func encodeFast(b []byte, v uint32) {
b = b[:4]
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
}
Проверить результат: go build -gcflags='-d=ssa/check_bce/debug=1' печатает каждую оставшуюся проверку. Сложность алгоритма не изменилась — O(1) по времени и памяти в обоих случаях; изменилось число ветвлений на вызов. Именно так и выглядит большинство честных выигрышей этого уровня: не «в разы», а «минус три ветвления в цикле, который крутится миллиард раз в секунду».
Векторизация. Компилятор откажется от неё, если между итерациями есть зависимость по данным, если число итераций неизвестно и слишком мало для окупаемости пролога, или если операция — сложение чисел с плавающей точкой (оно не ассоциативно, а переассоциация меняет результат). Причину отказа Clang печатает прямым текстом: clang -O2 -Rpass-missed=loop-vectorize -Rpass-analysis=loop-vectorize -c hot.c выдаст что-нибудь вроде remark: loop not vectorized: value that could not be identified as reduction is used outside the loop — и дальше это уже задача с известным условием, а не гадание.
7. PGO: главный дешёвый рычаг последних лет
Все эвристики компилятора — это ответ на вопрос «что здесь горячее?», данный без данных. Profile-guided optimization заменяет догадку измерением, и работает это ровно так же, как весь остальной трек: сначала профиль, потом решение.
Что именно меняется, когда компилятор знает частоты:
- Инлайнинг по факту, а не по размеру. Горячий вызов встраивается, даже если тело крупное; холодный не встраивается, даже если тело крошечное.
- Devirtualization. Если 97 % интерфейсных вызовов в этой точке идут в одну реализацию, компилятор ставит прямой вызов с проверкой типа и запасным путём — и после этого может его встроить.
- Раскладка базовых блоков и функций. Горячие блоки укладываются подряд, холодные уезжают в конец секции; выигрыш живёт в iTLB и L1i.
- Форма ветвлений. Вероятная ветка становится «проваливающейся», предсказатель переходов получает более удобный код («Предсказание переходов»).
- Развёртка и unswitching только там, где окупятся — то есть без общего раздувания кода, за которое ругают
-O3.
Порядок величин по официальным данным: Go на репрезентативном наборе серверных программ показывает 2–7 %, отдельные случаи — до 14 %; документация Go по PGO прямо приводит эти числа. Для C++ инструментированный PGO в LLVM обычно даёт больше, а sampled-вариант AutoFDO восстанавливает около 85 % выигрыша инструментированного профиля, не требуя отдельной сборки (AutoFDO, CGO 2016).
по всем инстансам, а не с одного пода Prof->>Repo: merged.pprof → cmd/service/default.pgo Repo->>CI: коммит профиля как обычный артефакт CI->>CI: go build (профиль подхватывается автоматически) CI->>CI: сравнить бенчмарки с базовой линией CI->>Can: выкатить 5 % трафика Can->>Prof: RED-метрики и новый профиль Note over Can,Prof: цикл замкнулся: следующий профиль
снят уже с PGO-сборки
Ключевая деталь схемы: профиль берётся из прода, а не из бенчмарка. Профиль синтетической нагрузки описывает синтетическую нагрузку — и PGO честно оптимизирует именно её.
# Go: положить профиль рядом с main-пакетом, больше ничего не нужно
cp merged.pprof ./cmd/service/default.pgo
go build ./cmd/service # PGO включается сам
go build -pgo=off ./cmd/service # базовая линия для сравнения
# Clang: инструментированный профиль (точнее, но нужна отдельная сборка)
clang -O2 -fprofile-generate=/tmp/prof -o app app.c
./app < representative_workload
llvm-profdata merge -output=app.profdata /tmp/prof
clang -O2 -fprofile-use=app.profdata -o app-pgo app.c
# Clang: sampled-профиль прямо из прода, без инструментирования
perf record -b -e cycles:u -- ./app # -b: last branch records
create_llvm_prof --binary=./app --out=app.afdo
clang -O2 -fprofile-sample-use=app.afdo -o app-fdo app.c
В управляемых рантаймах PGO встроен и работает сам: HotSpot собирает профиль в интерпретаторе и на уровне C1, а C2 компилирует по нему; .NET начиная с восьмой версии включает динамический PGO по умолчанию. Смысл настройки там другой — не «включить профиль», а «дать рантайму дожить до момента, когда профиль накоплен» (см. раздел про холодный старт).
8. Жизненный цикл профиля: почему PGO ломается через полгода
Профиль — это данные, а у данных есть срок годности. Ошибка, которую совершают почти все, кто ставит PGO впервые: снять профиль один раз, закоммитить и забыть.
Устаревание переносится мягко: компилятор просто не находит части символов и применяет обычные эвристики — выигрыш тает, вреда нет. По-настоящему опасно состояние «вредный»: профиль, снятый с инстанса, обслуживающего нетипичный шард, или с ночного часа, когда идёт батч, а не пользовательский трафик. Тогда компилятор аккуратно оптимизирует не тот код, и p99 дневного трафика ухудшается.
Практика, которая это снимает:
- профиль обновляется по расписанию (еженедельно) отдельной задачей, а не руками;
- профиль агрегируется по всему парку и по достаточному окну, чтобы усреднить суточный и недельный сезоны;
- обновление профиля проходит через ту же канареечную выкатку, что и код: это изменение бинаря, а не «данных»;
- профиль хранится как версионированный артефакт, привязанный к коммиту, — иначе теряется воспроизводимость сборки.
# .github/workflows/refresh-pgo.yml — регенерация профиля раз в неделю
name: refresh-pgo
on:
schedule:
- cron: "0 3 * * 1"
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Скачать профиль, агрегированный по всему парку за неделю
run: |
# Pyroscope/Parca отдают merged pprof по окну и набору инстансов
curl -sS --fail "$PROFILE_API/merge?service=api&window=7d" -o cmd/service/default.pgo
go tool pprof -top -nodecount=5 cmd/service/default.pgo # профиль не пустой и парсится
- name: Сравнить с базовой линией и открыть PR
run: |
go test -bench=. -count=10 ./internal/hot > new.txt
benchstat baseline.txt new.txt | tee delta.txt
gh pr create --title "chore: обновить PGO-профиль" --body-file delta.txt
9. LTO и посткомпоновочная укладка кода
LTO (link-time optimization) убирает границу между единицами трансляции: компилятор откладывает часть работы до компоновки, когда виден весь код целиком, и получает возможность встроить функцию из другого модуля, доказать, что виртуальный вызов имеет единственную реализацию, и выкинуть недостижимый код. Полный LTO держит в памяти всю программу и на крупных проектах становится узким местом сборки; ThinLTO решает это, работая параллельно и опираясь на сводный индекс, и потому применим к продакшн-проектам почти всегда.
clang -O2 -flto=thin -c *.c && clang -O2 -flto=thin *.o -o app # C/C++
# Rust: в Cargo.toml профиля release
# [profile.release]
# lto = "thin"
# codegen-units = 1 # меньше параллелизма при сборке, лучше код
Тонкость, о которой забывают: codegen-units в Rust по умолчанию равен 16 — это ускоряет сборку ценой качества кода, потому что каждый блок оптимизируется отдельно. Единица даёт лучший результат и заметно более долгую сборку. Это ровно тот компромисс, который нужно измерить, а не выбрать по вкусу.
Посткомпоновочная оптимизация идёт дальше: BOLT берёт уже слинкованный бинарь и профиль из perf и физически переупорядочивает базовые блоки и функции. Компилятор тоже пытается это делать, но он видит код до компоновки и не знает окончательных адресов; BOLT видит финальную картину. Авторы статьи на CGO 2019 сообщают об ускорении реальных приложений до 8 % поверх уже применённых PGO и LTO — то есть поверх того, что считалось потолком.
Почему перекладка вообще что-то даёт, видно на картинке: инструкций столько же, работа та же, меняются только адреса. Но рабочий набор страниц кода сжимается втрое, iTLB перестаёт промахиваться, а в каждой линии L1i едет только полезное. На крупных серверных бинарях с сотнями мегабайт кода промахи iTLB — измеримая статья расхода, и в профиле она выглядит хуже всего: не жирным кадром, а равномерно размазанным временем («Профилирование CPU»).
Порядок применения важен, и он же — порядок убывания выигрыша на единицу усилий: -O2 → PGO → ThinLTO → BOLT. Каждый следующий шаг работает поверх предыдущего и добавляет меньше; начинать с BOLT на бинаре без PGO — почти всегда пустая трата инженерного времени.
10. Рантаймы с JIT: что там реально крутится
В управляемых рантаймах компилятор работает во время исполнения, и настраивается не «что оптимизировать», а режим и границы его работы. Устройство JIT разобрано в статье «JIT-компиляция»; здесь — эксплуатационная сторона.
| Ручка | JVM | .NET | Что делает и когда трогать |
|---|---|---|---|
| Уровни компиляции | -XX:-TieredCompilation |
DOTNET_TieredCompilation=0 |
выключение уровней ускоряет выход на пик, замедляет старт; трогать только для долгоживущих батчей |
| Кэш скомпилированного кода | -XX:ReservedCodeCacheSize=512m |
— | при переполнении JIT отключается целиком, и сервис незаметно уезжает на интерпретатор |
| Пороги компиляции | -XX:CompileThreshold |
DOTNET_TC_CallCountThreshold |
почти никогда; дефолты выверены лучше вашей интуиции |
| Инлайнинг | -XX:MaxInlineSize, -XX:FreqInlineSize |
AggressiveInlining |
точечно, после того как PrintInlining показал отказ на горячем пути |
| Компиляция на замену стека | всегда | DOTNET_TC_QuickJitForLoops |
длинный цикл в методе, вызванном один раз: без OSR он останется в интерпретаторе |
| AOT-предкомпиляция | AppCDS, -XX:AOTCache |
ReadyToRun, NativeAOT | старт и первые секунды после деплоя |
Три реальных инцидента этого уровня, которые стоит узнавать в лицо:
CodeCache is full. Compiler has been disabled.В логах JVM одна строка, в метриках — деградация в разы через несколько часов после старта. Лечится увеличениемReservedCodeCacheSize; ловится алертом наjvm_memory_used_bytesс тегом code cache.- Деоптимизационный шторм. Приехала новая реализация интерфейса, мономорфные call sites стали полиморфными, JIT выбросил оптимизированный код и начал компилировать заново — под нагрузкой. Диагностика:
-XX:+PrintCompilationи всплеск времени в компиляторных потоках; в .NET — счётчикиMethod Jitting. - Метод не компилируется никогда. Гигантский метод (обычно сгенерированный кодогенератором) перешагнул
HugeMethodLimit— 8000 байткодов, — и HotSpot его просто не берёт, хотя метод горячий. Отключается флагом-XX:-DontCompileHugeMethods, но правильное лечение — разбить метод. В профиле выглядит как «интерпретатор в топе», и это единственная верная подсказка.
Динамические языки идут тем же путём с отставанием: CPython 3.11 получил специализирующий адаптивный интерпретатор (PEP 659) и вместе с остальными изменениями релиза стал примерно на четверть быстрее без единой правки в коде пользователя, а 3.13 обзавёлся экспериментальным copy-and-patch JIT (PEP 744). Для эксплуатации это означает то же самое, что для JVM: числа с холодного старта и числа на плато отличаются, и мерить надо на плато.
11. Холодный старт против пиковой скорости
Между «быстро стартует» и «быстро работает на плато» существует прямой конфликт: любая оптимизация во время исполнения требует времени и профиля, а профиль требует, чтобы код успел поработать. Для сервиса, который живёт неделями, важен пик. Для функции в serverless, живущей 200 мс, важен только старт. Для сервиса, который выкатывается двадцать раз в день, важно и то и другое, потому что разогрев — это заметная часть его жизни («Нагрузочное тестирование»).
Инструменты для левой части конфликта: AppCDS и AOT-кэш в JVM сокращают загрузку и линковку классов; ReadyToRun в .NET даёт предкомпилированный, но заменяемый JIT код; NativeAOT и GraalVM Native Image убирают JIT полностью — старт в миллисекундах и предсказуемая латентность ценой пиковой производительности и части динамических возможностей (рефлексия, генерация кода). CRaC и checkpoint-restore идут с другой стороны: сохраняют образ уже прогретого процесса и восстанавливают его за десятки миллисекунд.
Практический приём, не требующий ни одного из этих механизмов: прогрев перед вводом в ротацию. Новый инстанс поднимается, получает синтетический трафик по основным маршрутам, и только после того, как время компиляции стабилизировалось, проходит readiness-проверку и получает реальные запросы. Это дешевле смены рантайма и снимает классический пилообразный p99 после каждого деплоя.
12. Как честно измерить эффект тулчейн-правки
Здесь тулчейн-эксперименты отличаются от всех прочих, и отличие коварное. Когда вы меняете флаг сборки, вы меняете адреса всего кода в бинаре. А раскладка сама по себе влияет на время: выравнивание циклов относительно линий кэша, попадание горячих функций в разные наборы L1i, размер окружения процесса, порядок объектных файлов. Mytkowicz и соавторы в «Producing Wrong Data Without Doing Anything Obviously Wrong!» показали, что эти факторы дают эффект, сопоставимый с включением -O3. Curtsinger и Berger предложили лечение — Stabilizer, рандомизирующий раскладку между прогонами, чтобы эффект раскладки превратился из систематической ошибки в шум.
Отсюда правила, которых нет в обычном бенчмарке:
- Сравнивать бинари, а не запуски. Соберите обе версии, гоняйте их вперемешку на одной машине, чередуя, — а не «сначала десять прогонов старого, потом десять нового».
- Несколько независимых сборок каждой версии, если правка малозаметная. Иначе вы измеряете одну конкретную удачную раскладку.
- Смотреть на инструкции и IPC, а не только на время.
perf statпоказывает, изменилось ли количество работы. Если инструкций стало меньше на 8 %, а время — на 8 %, эффект реален. Если инструкций столько же, а время «улучшилось» — вы поймали раскладку или шум.
# Честное A/B двух бинарей на одной машине
hyperfine --warmup 3 --runs 30 './app-base --bench' './app-pgo --bench'
# Что именно изменилось: объём работы или её стоимость
for b in app-base app-pgo; do
perf stat -r 10 -e instructions,cycles,branch-misses,L1-icache-load-misses,iTLB-load-misses "./$b" --bench
done
# Go: сравнение с оценкой значимости, а не «на глаз»
go test -bench=. -count=12 -pgo=off ./... > base.txt
go test -bench=. -count=12 ./... > pgo.txt
benchstat base.txt pgo.txt
Для PGO и BOLT именно L1-icache-load-misses и iTLB-load-misses — подтверждающие метрики: если они не упали, укладка не сработала, и наблюдаемое ускорение объясняется чем-то другим. Полный протокол честного замера — в «Бенчмаркинг честно», а метрика, которую стоит вынести в дашборд для тулчейн-изменений, — CPU-секунды на запрос: она не зависит от текущего RPS и прямо конвертируется в деньги на инфраструктуру.
13. Цена каждого процента
| Рычаг | Типичный выигрыш | Чем платим |
|---|---|---|
-O2 вместо отладочной сборки |
разы | ничем; забывать про это стыдно, но случается |
-march/GOAMD64 до v3 |
0–15 % | портируемостью между моделями процессоров |
| PGO | 2–14 % | инфраструктурой профиля, воспроизводимостью сборки |
| ThinLTO | 2–10 % | временем и памятью компоновки, сложностью отладки |
| BOLT | до 8 % поверх PGO | ещё одним шагом сборки и вторым артефактом профиля |
codegen-units=1 (Rust) |
1–5 % | заметно более долгой сборкой |
| AppCDS / ReadyToRun | старт в разы | размером артефакта, шагом сборки |
| NativeAOT / Native Image | старт в десятки раз | пиковой производительностью, рефлексией, зрелостью экосистемы |
Три статьи расхода, которые обычно недооценивают:
- Воспроизводимость сборки. С момента появления PGO бинарь зависит не только от исходников, но и от файла профиля. Профиль обязан быть версионированным входом сборки, иначе «та же версия» перестаёт означать «тот же бинарь», и расследование инцидента усложняется.
- Отладка. Инлайнинг и перестановка блоков делают шаг отладчика нелинейным, а стеки — неполными. Символы (
-g) на скорость не влияют и должны быть включены всегда — просто храните их отдельно (objcopy --only-keep-debug), а не выбрасывайте. - Время сборки в CI. LTO плюс
codegen-units=1плюс PGO легко превращают пятиминутную сборку в двадцатиминутную, а это налог на каждый PR. Разумный компромисс — полный набор оптимизаций только для релизных сборок, обычный-O2для сборок в PR («DevOps»).
14. Порядок действий
- Убедиться, что релизная сборка — действительно релизная.
-O2/--release, символы включены, фреймовые указатели включены, отладочные флаги (санитайзеры,-fstack-protector-all, детекторы гонок) выключены. Ноль усилий, регулярно — десятки процентов. - Снять базовую линию по протоколу из «Бенчмаркинг честно» и записать: время, инструкции, IPC, промахи L1i, время старта, размер бинаря.
- Включить PGO на профиле из прода. Наибольший выигрыш на единицу усилий среди всего перечисленного.
- Добавить ThinLTO, если язык это поддерживает, и измерить отдельно — эффект PGO и LTO не складывается арифметически.
- Проверить
-march/GOAMD64ровно до того уровня, который гарантирован на всех узлах парка. - Посмотреть на горячий путь глазами компилятора: встроилось ли, остались ли проверки границ, векторизовалось ли. Здесь появляются точечные правки кода — outlining холодного пути, срезы фиксированной длины,
restrict. - Только теперь — BOLT и другие экзотические шаги, и только если бинарь крупный, а профиль показывает промахи по инструкциям. Отдельным заходом — холодный старт, если он значим: AppCDS/ReadyToRun/прогрев перед ротацией.
- Зафиксировать результат в CI: профиль обновляется по расписанию, бенчмарки сравниваются с базовой линией, регресс останавливает мердж («Рабочий процесс оптимизации»).
15. Типичные ошибки
- Мерить отладочную сборку и делать выводы про прод. Самая частая и самая дорогая: без оптимизаций это буквально другой код — без инлайнинга, без векторизации, с другими аллокациями.
- Считать, что
-O3быстрее-O2по определению. Раздувание кода реально, промахи L1i реальны. Проверяется за полчаса, спорится годами. - Ставить PGO один раз или снимать профиль с бенчмарка. В первом случае через полгода рефакторинга профиль перестанет совпадать с кодом и выигрыш растает; во втором PGO аккуратно оптимизирует нагрузку, которой у вас нет.
-march=nativeв образе для гетерогенного кластера. Работает месяцами, падает при добавлении узлов другого поколения.-ffast-mathради процента. Меняет семантику вычислений во всём процессе, включая чужие библиотеки.- Крутить пороги JIT по советам из интернета. Дефолты выверены на огромном корпусе программ; ваш случай почти наверняка не тот, ради которого стоит от них отходить. Исключение —
ReservedCodeCacheSize, где дефолт действительно бывает мал. - Сравнивать бинари, собранные на разных машинах или разными версиями компилятора. Вы измеряете разницу тулчейнов, а не своей правки.
- Отключать фреймовые указатели ради процента — обмен процента на способность понять, что происходит в проде.
- Забыть про размер бинаря и время старта. Оптимизировали пик, ухудшили деплой: сорок инстансов, стартующих на полминуты дольше, — это отдельная проблема выкатки.
Мини-итог
- Между исходником и процессором четыре слоя решений: AOT-компилятор, компоновщик, посткомпоновочный шаг и рантайм с JIT. Каждый настраивается и ни один не требует менять логику.
- Уровни оптимизации различаются готовностью раздувать код, а не «силой».
-O2— разумный дефолт;-O3включается точечно и с замером. - Инлайнинг ценен не экономией на вызове, а тем, что открывает дорогу остальным оптимизациям. Смотреть решения инлайнера — базовая гигиена; выносить холодный путь из горячей функции — самая частая полезная правка.
- PGO переносит принцип трека «сначала измерь» внутрь компилятора и даёт больше любой перестановки флагов: 2–7 % на типичных серверных программах Go, до 14 % в отдельных случаях. Профиль обязан браться из прода и обновляться по расписанию. LTO снимает границы модулей, посткомпоновочная укладка чинит промахи iTLB и L1i. Порядок:
-O2→ PGO → ThinLTO → BOLT, каждый следующий добавляет меньше. - В JIT-рантаймах настраивается не «что оптимизировать», а режим и границы: размер кэша кода, уровни компиляции, стратегия разогрева. Холодный старт и пиковая скорость — прямой конфликт, и выбор в нём зависит от времени жизни процесса.
- Тулчейн-правка меняет раскладку всего кода, поэтому измерять её нужно строже обычного: чередующиеся прогоны обоих бинарей, несколько сборок, подтверждение через число инструкций и промахи по инструкциям.
- «Бесплатно» здесь значит «без усложнения кода». Заплатить придётся временем сборки, воспроизводимостью, отладкой или портируемостью — просто это обычно более выгодный обмен, чем усложнение логики.
Источники
- Go. Profile-guided optimization — https://go.dev/doc/pgo ; блог: https://go.dev/blog/pgo
- D. Chen, D. X. Li, T. Moseley. AutoFDO: Automatic Feedback-Directed Optimization for Warehouse-Scale Applications. CGO 2016 — https://research.google/pubs/pub45290/
- M. Panchenko et al. BOLT: A Practical Binary Optimizer for Data Centers and Beyond. CGO 2019 — https://research.facebook.com/publications/bolt-a-practical-binary-optimizer-for-data-centers-and-beyond/
- LLVM. ThinLTO — https://clang.llvm.org/docs/ThinLTO.html ; Clang optimization remarks — https://llvm.org/docs/Remarks.html
- T. Mytkowicz et al. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS 2009 — https://dl.acm.org/doi/10.1145/1508284.1508275
- C. Curtsinger, E. Berger. Stabilizer: Statistically Sound Performance Evaluation. ASPLOS 2013 — https://emeryberger.com/research/stabilizer/
- OpenJDK. HotSpot VM Options — https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html ; Class Data Sharing — https://docs.oracle.com/en/java/javase/21/vm/class-data-sharing.html ; .NET: Dynamic PGO — https://learn.microsoft.com/dotnet/core/runtime-config/compilation ; Native AOT — https://learn.microsoft.com/dotnet/core/deploying/native-aot/
- PEP 659 Specializing Adaptive Interpreter — https://peps.python.org/pep-0659/ ; PEP 744 JIT Compilation — https://peps.python.org/pep-0744/
- Инструменты: Compiler Explorer, cargo-show-asm, hyperfine, benchstat, Parca, Pyroscope
Что дальше
Тулчейн выжат: флаги выставлены, PGO обновляется из прода, LTO включён, JIT прогревается. Остался последний источник задержек, который не лечится ни правкой кода, ни флагами компилятора, — среда, в которой всё это исполняется. Ваш процесс живёт в cgroup с квотой, cgroup — в виртуальной машине с чужими соседями, виртуальная машина — на железе с общим L3 и общей шиной памяти. На каждом из этих уровней есть механизм, который снимает ваши потоки с процессора так, что ни профайлер, ни ассемблерный листинг этого не покажут: снимать нечего, кода на CPU просто не было.
Заключительная статья трека — про то, как диагностировать эти паузы: механика CFS-квоты и троттлинга в контейнерах, steal time и кредиты burstable-инстансов, шумные соседи по кэшу, PSI как индикатор дефицита и цикл right-sizing лимитов.
Производительность в общей среде: cgroup-троттлинг, steal time и шумные соседи