Инлайнинг в C++: почему он не всегда ускоряет код
Пометка inline и агрессивный инлайнинг компилятора не всегда дают выигрыш. Разбираем, когда инлайн раздувает бинарник, ломает I-cache и почему замеры важнее интуиции.

На проекте с горячим циклом обработки пакетов принудительный инлайн через __attribute__((always_inline)) дал не ускорение, а замедление на 8% в бенчмарке. Причина не в самом инлайнинге как приёме, а в том, что раздутое тело функции перестало помещаться в L1 инструкционного кэша, и процессор начал чаще ходить в L2. Инлайнинг — не бесплатная оптимизация «всегда включить», а размен: вы экономите на вызове, но платите размером кода. Разберём, где этот размен выгоден, а где он вредит, и что показывают замеры на GCC 13 и Clang 17.
Что делает инлайнинг и что он не делает
Инлайнинг — это подстановка тела функции в место вызова вместо генерации инструкции call. Компилятор убирает пролог/эпилог, сохранение регистров, передачу аргументов через стек или регистры и переход. Но главная выгода часто не в экономии на самом вызове, а в том, что после подстановки открываются другие оптимизации: константное распространение через границу функции, устранение мёртвого кода, векторизация цикла, который раньше прерывался непрозрачным вызовом.
Ключевое заблуждение: ключевое слово inline в современном C++ почти не влияет на решение компилятора об инлайнинге. Его основное назначение сегодня — разрешить множественные определения функции в разных единицах трансляции (ODR), чтобы линковщик не ругался. Компилятор принимает решение сам, опираясь на эвристики размера и профиль вызовов.
Пометка функции как inline — это про правило одного определения, а не про производительность. Реальное решение принимает оптимизатор, и он может проигнорировать вашу подсказку в обе стороны.
Три уровня, где решается судьба вызова
Инлайнинг случается на нескольких этапах, и понимать это важно при отладке того, «почему функция не инлайнится».
- Внутри единицы трансляции. Компилятор видит тело функции (она в заголовке или в том же .cpp) и решает по эвристике: размер тела, число вызовов, есть ли рекурсия, вызывается ли в цикле.
- При LTO (link-time optimization). Тело в другом .cpp становится видимым на этапе линковки, и инлайнинг возможен через границы модулей. Без LTO вызов функции из чужого объектного файла останется реальным
call. - PGO (profile-guided optimization). Компилятор использует данные о частоте вызовов из прогонов и агрессивнее инлайнит горячие пути, оставляя холодные как есть.
Что мешает инлайнингу
Даже с always_inline есть случаи, когда подстановка невозможна или бессмысленна: рекурсия без ограничения глубины, вызов через указатель на функцию или виртуальный вызов без девиртуализации, взятие адреса функции, слишком большое тело относительно порога. Виртуальный вызов инлайнится только если компилятор доказал точный тип (девиртуализация) — например, через final на классе или методе.
Где инлайнинг вредит
Приём становится минусом в трёх сценариях.
- Раздувание кода (code bloat). Большую функцию, вызываемую из десятков мест, при инлайне размножают по всем этим местам. Бинарник растёт, а вместе с ним падает плотность горячего кода в I-cache. Для функций размером в сотни инструкций это почти всегда проигрыш.
- Давление на инструкционный кэш. L1i на большинстве x86-64 — 32 КБ на ядро. Если горячий цикл после инлайна перестал в него влезать, каждая итерация начинает промахиваться, и выигрыш от убранного
callтонет в задержках памяти. - Рост времени компиляции. Агрессивный инлайн плюс LTO раздувают время сборки в разы. На большом проекте это стоит минут ожидания на каждой сборке.
Обратная сторона: маленькие функции-аксессоры, обёртки, тривиальные геттеры инлайнить почти всегда выгодно — их тело меньше, чем накладные расходы на вызов.
Сравнение подходов к управлению инлайнингом
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Довериться компилятору (по умолчанию) | Разумные эвристики, минимум ручной работы | Компилятор не знает ваш профиль без PGO |
inline в заголовке | Тело видно везде, ODR соблюдён | Не гарантирует инлайн, раздувает заголовки |
always_inline | Форсирует подстановку горячей мелкой функции | Легко получить bloat, если функция крупная |
noinline | Держит холодный код вне горячего пути | Явный call на каждом вызове |
| LTO + PGO | Инлайн через модули по реальному профилю | Долгая сборка, нужна инфраструктура профилирования |
Как форсировать и как запретить
Синтаксис атрибутов различается между компиляторами. На GCC 13 и Clang 17:
// Форсировать инлайн (GCC/Clang)
__attribute__((always_inline)) inline int fast_clamp(int value, int lo, int hi) {
// Тривиальное тело — кандидат на инлайн без раздувания
if (value < lo) return lo;
if (value > hi) return hi;
return value;
}
// Запретить инлайн холодной ветки, чтобы не засорять I-cache
__attribute__((noinline)) void handle_rare_error(int code) {
// Крупный обработчик, вызывается редко — держим вне горячего пути
log_error(code);
dump_diagnostics();
}
На MSVC эквиваленты — __forceinline и __declspec(noinline). Атрибут C++11 [[gnu::always_inline]] работает на GCC/Clang как переносимая форма записи. Важно: always_inline без inline у GCC выдаёт предупреждение, поэтому их обычно пишут вместе.
Как проверить, что инлайн случился
Не гадайте — смотрите. Для GCC/Clang запросите отчёт оптимизатора:
# Clang 17: отчёт о том, что заинлайнилось, а что нет и почему
clang++ -O2 -Rpass=inline -Rpass-missed=inline main.cpp
# GCC 13: детальный дамп решений инлайнера
g++ -O2 -fopt-info-inline-optimized main.cpp
Clang с -Rpass-missed=inline для каждого пропущенного вызова печатает причину: превышен порог, рекурсия, вызов через указатель. Это точнее любых догадок по дизассемблеру, хотя и его стоит проверить через objdump -d на горячей функции.
Когда что выбирать
- Мелкая функция в горячем цикле, вызываемая часто — доверьтесь компилятору, при
-O2он её и так заинлайнит;always_inlineтолько если замер показал, что порог не сработал. - Крупная функция, вызываемая из многих мест — не форсируйте инлайн; если она в горячем пути и мешает, выносите холодные ветки в
noinline. - Обработка ошибок и логирование —
noinline, чтобы редкий код не раздувал I-cache горячей функции. - Кросс-модульная критичная функция — включайте LTO вместо ручных атрибутов; это масштабируется лучше пометок в каждом файле.
Что ломается на практике
Типичная ошибка — переносить решение из микробенчмарка в реальный код. В изолированном бенчмарке функция и её вызов помещаются в кэш целиком, инлайн всегда выглядит выигрышем. В большом сервисе тот же инлайн вытесняет из I-cache соседний горячий код, и общая производительность падает, хотя микротест «доказал» ускорение. Мерьте на реалистичной нагрузке, а не на функции в вакууме.
Вторая ловушка — always_inline на функции, которая со временем обросла кодом. Атрибут остался, тело выросло до сотен строк, и вы принудительно размножаете это по всем вызовам. Атрибуты форсирования требуют ревизии при росте функции.
Что делать дальше
Перед тем как расставлять атрибуты, снимите профиль под perf или аналогом и найдите функции, где реально теряется время; расстановка always_inline вслепую чаще вредит. Смотрите на счётчики промахов I-cache (L1-icache-load-misses в perf stat) до и после изменений — именно они показывают, не привёл ли инлайн к раздуванию. Если проект большой и вызовы идут через границы модулей, начните с включения LTO: часто это даёт больше, чем ручные пометки, и без риска зафиксировать атрибут на функции, которая потом разрастётся.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: GCC 13 Manual: Function Attributes (always_inline, noinline), Clang Documentation: Optimization Remarks
Частые вопросы
Влияет ли ключевое слово inline на производительность?
У меня Clang вместо GCC — атрибуты те же?
Почему функция не инлайнится, хотя я пометил её inline?
always_inline не ускорил, а замедлил — почему?
Стоит ли включать LTO вместо ручных атрибутов?
Как понять, что инлайн реально произошёл в релизной сборке?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.