Производительность систем Ускорение без правки кода: флаги сборки, инлайнинг, PGO, LTO и JIT
0%

Ускорение без правки кода: флаги сборки, инлайнинг, PGO, LTO и JIT

Ускорение без правки кода: флаги сборки, инлайнинг, PGO, LTO и JIT

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

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

Отсюда тезис статьи: тулчейн — единственное место в треке, где ускорение не оплачивается сложностью кода. Значит, проверять его нужно до того, как вы начали усложнять код, а не после. Практика обратная: люди неделями выжимают проценты из горячей функции на сервисе, который собран без PGO, запущен с настройками JIT по умолчанию и линкуется без LTO — а там лежат те же проценты, доступные одной строкой в CI.

Оговорка, без которой тезис превращается в карго-культ. «Бесплатно» здесь означает «без усложнения кода», а не «без цены вообще». Каждый рычаг из этой статьи что-то ломает: время сборки, размер бинаря, воспроизводимость сборки, отладку, портируемость между моделями процессоров или скорость холодного старта. И почти каждый умеет сделать хуже — -O3, включённый «на всякий случай», регулярно проигрывает -O2, а PGO на нерепрезентативном профиле замедляет ровно те пути, ради которых его ставили. Поэтому дисциплина трека здесь не отменяется, а обостряется: тулчейн-правка меняет раскладку кода целиком, а раскладка сама по себе двигает время на единицы процентов («Бенчмаркинг честно»), и отличить эффект оптимизации от эффекта случайного сдвига адресов сложнее, чем в любой другой главе.

Устройство самих оптимизаций — тема трека «Компиляторы»; здесь мы смотрим на них глазами того, кто эксплуатирует сервис: какие ручки есть, что каждая даёт по числам, чем платит и как проверить результат.

1. Три слоя между исходником и инструкциями

Разделите путь кода на четыре момента времени — станет видно, какой рычаг когда работает.

Ключевое наблюдение из схемы: все крупные выигрыши идут по пунктирным стрелкам. Компилятор без профиля вынужден гадать, какая ветка вероятнее и какой вызов горячий; эвристики угадывают средний код, а не ваш. Профиль превращает гадание в знание, и потому 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

Три вещи, которые стоит завести привычку смотреть, прежде чем спорить:

  1. Встроилась ли горячая функция. Разница между встроенной и невстроенной — не стоимость call, а весь набор оптимизаций, который после встраивания становится возможен.
  2. Осталась ли проверка границ / нулевого указателя внутри горячего цикла. Одна лишняя проверка на итерацию — это ветвление, которое мешает векторизации.
  3. Векторизован ли цикл. Если в дизассемблере только скалярные 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).

Ключевая деталь схемы: профиль берётся из прода, а не из бенчмарка. Профиль синтетической нагрузки описывает синтетическую нагрузку — и 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 — то есть поверх того, что считалось потолком.

Раскладка горячего кода до и после PGO и посткомпоновочной оптимизации

Почему перекладка вообще что-то даёт, видно на картинке: инструкций столько же, работа та же, меняются только адреса. Но рабочий набор страниц кода сжимается втрое, 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 старт и первые секунды после деплоя

Три реальных инцидента этого уровня, которые стоит узнавать в лицо:

  1. CodeCache is full. Compiler has been disabled. В логах JVM одна строка, в метриках — деградация в разы через несколько часов после старта. Лечится увеличением ReservedCodeCacheSize; ловится алертом на jvm_memory_used_bytes с тегом code cache.
  2. Деоптимизационный шторм. Приехала новая реализация интерфейса, мономорфные call sites стали полиморфными, JIT выбросил оптимизированный код и начал компилировать заново — под нагрузкой. Диагностика: -XX:+PrintCompilation и всплеск времени в компиляторных потоках; в .NET — счётчики Method Jitting.
  3. Метод не компилируется никогда. Гигантский метод (обычно сгенерированный кодогенератором) перешагнул 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, рандомизирующий раскладку между прогонами, чтобы эффект раскладки превратился из систематической ошибки в шум.

Отсюда правила, которых нет в обычном бенчмарке:

  1. Сравнивать бинари, а не запуски. Соберите обе версии, гоняйте их вперемешку на одной машине, чередуя, — а не «сначала десять прогонов старого, потом десять нового».
  2. Несколько независимых сборок каждой версии, если правка малозаметная. Иначе вы измеряете одну конкретную удачную раскладку.
  3. Смотреть на инструкции и 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. Порядок действий

  1. Убедиться, что релизная сборка — действительно релизная. -O2/--release, символы включены, фреймовые указатели включены, отладочные флаги (санитайзеры, -fstack-protector-all, детекторы гонок) выключены. Ноль усилий, регулярно — десятки процентов.
  2. Снять базовую линию по протоколу из «Бенчмаркинг честно» и записать: время, инструкции, IPC, промахи L1i, время старта, размер бинаря.
  3. Включить PGO на профиле из прода. Наибольший выигрыш на единицу усилий среди всего перечисленного.
  4. Добавить ThinLTO, если язык это поддерживает, и измерить отдельно — эффект PGO и LTO не складывается арифметически.
  5. Проверить -march/GOAMD64 ровно до того уровня, который гарантирован на всех узлах парка.
  6. Посмотреть на горячий путь глазами компилятора: встроилось ли, остались ли проверки границ, векторизовалось ли. Здесь появляются точечные правки кода — outlining холодного пути, срезы фиксированной длины, restrict.
  7. Только теперь — BOLT и другие экзотические шаги, и только если бинарь крупный, а профиль показывает промахи по инструкциям. Отдельным заходом — холодный старт, если он значим: AppCDS/ReadyToRun/прогрев перед ротацией.
  8. Зафиксировать результат в 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-рантаймах настраивается не «что оптимизировать», а режим и границы: размер кэша кода, уровни компиляции, стратегия разогрева. Холодный старт и пиковая скорость — прямой конфликт, и выбор в нём зависит от времени жизни процесса.
  • Тулчейн-правка меняет раскладку всего кода, поэтому измерять её нужно строже обычного: чередующиеся прогоны обоих бинарей, несколько сборок, подтверждение через число инструкций и промахи по инструкциям.
  • «Бесплатно» здесь значит «без усложнения кода». Заплатить придётся временем сборки, воспроизводимостью, отладкой или портируемостью — просто это обычно более выгодный обмен, чем усложнение логики.

Источники

Что дальше

Тулчейн выжат: флаги выставлены, PGO обновляется из прода, LTO включён, JIT прогревается. Остался последний источник задержек, который не лечится ни правкой кода, ни флагами компилятора, — среда, в которой всё это исполняется. Ваш процесс живёт в cgroup с квотой, cgroup — в виртуальной машине с чужими соседями, виртуальная машина — на железе с общим L3 и общей шиной памяти. На каждом из этих уровней есть механизм, который снимает ваши потоки с процессора так, что ни профайлер, ни ассемблерный листинг этого не покажут: снимать нечего, кода на CPU просто не было.

Заключительная статья трека — про то, как диагностировать эти паузы: механика CFS-квоты и троттлинга в контейнерах, steal time и кредиты burstable-инстансов, шумные соседи по кэшу, PSI как индикатор дефицита и цикл right-sizing лимитов.

Производительность в общей среде: cgroup-троттлинг, steal time и шумные соседи

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов