Время компиляции в C++: как сократить в разы
Полная сборка проекта на 400 файлов занимала 11 минут, инкрементальная после правки одного заголовка — почти столько же. Разбираем, почему так, и как ужать время сборки на реальном C++-проекте.

Проект на 400 единиц трансляции, чистая сборка занимает 11 минут в 8 потоков. Меняете одну строчку в базовом заголовке — и снова ждёте почти столько же, потому что этот заголовок включён везде. Компилятор здесь ни при чём: он честно перекомпилирует каждый .cpp, который прямо или транзитивно тянет изменённый .h. Ниже — куда уходит время в C++-сборке и что реально его сокращает, с замерами и понятными критериями, когда какой приём применять.
Речь про C++17 и новее, компиляторы GCC 12+, Clang 15+, MSVC 19.3x. Система сборки — CMake 3.20+, потому что почти все приёмы завязаны на её флаги.
Куда уходит время: сначала измерьте
Оптимизировать сборку вслепую — верный способ потратить день и не сдвинуть цифру. C++-компиляция делится на несколько этапов, и узкое место у всех разное.
- Препроцессинг и парсинг заголовков. Каждая единица трансляции разворачивает все свои
#includeв один гигантский текст. Если проект тянет тяжёлые заголовки (Boost, часть стандартной библиотеки, ваши «god headers»), парсер съедает время именно здесь. - Инстанцирование шаблонов. Один и тот же
std::vector<MyType>инстанцируется в каждой единице трансляции, где встретился. На проектах с активным шаблонным кодом это доминирующая статья расходов. - Кодогенерация и оптимизация. С флагами
-O2/-O3бэкенд компилятора работает заметно дольше. В debug-сборке этот этап дешёвый. - Линковка. На финальном этапе линкер собирает все объектные файлы. При большом числе символов и включённом LTO может занять минуты.
Clang умеет показывать разбивку по этапам. Соберите один тяжёлый файл с флагом трассировки и откройте результат.
# Clang 15+: генерирует JSON-трассировку для одной единицы трансляции
clang++ -std=c++17 -ftime-trace -c heavy_module.cpp -o heavy_module.o
# рядом появится heavy_module.json — откройте его в chrome://tracing
# или на ui.perfetto.dev и посмотрите, где съедено времяДля GCC аналог — -ftime-report, он печатает сводку по фазам в stderr, но без пофайловой картины. На уровне всей сборки время каждой цели покажет ninja -t (см. ниже) или профилировщик самой системы сборки.
Первое правило ускорения сборки: не трогайте то, что не измерили. На одном проекте 70% времени — шаблоны, на другом — линковка с LTO, и приёмы для них не пересекаются.
Приёмы, которые реально сокращают время
1. Precompiled headers (PCH)
Если по всему проекту повторяются одни и те же стабильные заголовки — стандартная библиотека, части фреймворка, — компилятор парсит их сотни раз. PCH парсит их один раз и переиспользует.
CMake 3.16+ умеет это без ручной возни с флагами:
# CMakeLists.txt
target_precompile_headers(my_target PRIVATE
<vector>
<string>
<unordered_map>
"heavy_stable_header.h"
)Цена: в PCH кладут только то, что почти не меняется. Если сунуть туда часто редактируемый заголовок, каждая его правка будет пересобирать PCH и следом всё, что от него зависит, — станет только хуже. Выигрыш заметен на проектах с однородными включениями; на 300 файлов с тяжёлым STL мы видели сокращение чистой сборки на 20–35%, но цифра сильно зависит от кода.
2. Unity build (jumbo build)
Система сборки склеивает несколько .cpp в одну единицу трансляции. Меньше единиц — меньше повторного парсинга заголовков и повторного инстанцирования шаблонов.
# включает объединение исходников для цели
set_target_properties(my_target PROPERTIES UNITY_BUILD ON)
# сколько файлов склеивать в одну группу
set_target_properties(my_target PROPERTIES UNITY_BUILD_BATCH_SIZE 8)Цена: unity build ломает инкрементальность — правка одного файла пересобирает всю группу. Он выигрывает на чистых сборках (CI, релиз), но вредит при частых мелких правках. Плюс всплывают конфликты: два файла с одинаковыми static-функциями или using namespace в глобальной области теперь в одной единице трансляции и начинают конфликтовать. Готовьтесь чинить такие места.
3. Разорвать зависимости через forward declarations и pimpl
Самый недооценённый приём, и он про инкрементальную сборку, а не про чистую. Если заголовок a.h включает b.h только чтобы держать поле по указателю — замените включение на предварительное объявление.
// вместо #include "b.h" в заголовке
class B; // forward declaration достаточно для указателя/ссылки
class A {
B* impl_; // компилятору не нужно полное определение B здесь
};Полное определение B нужно только в a.cpp. Результат: правка b.h больше не тянет пересборку всех, кто включает a.h. Идиома pimpl доводит это до предела — весь внутренний состав класса прячется в .cpp, а заголовок стабилен.
Цена: лишняя аллокация под impl-объект и косвенность при каждом обращении. В горячем коде это может стоить производительности рантайма — тогда pimpl не для этого класса.
4. Кеш компиляции: ccache / sccache
Кеширует объектные файлы по хешу входных данных. Если препроцессированный исходник и флаги не изменились — компилятор не запускается вообще, объект берётся из кеша.
# подключение ccache через CMake (нужен установленный ccache)
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -S . -B buildНа CI, где ветки постоянно пересобираются с нуля, ccache с общим кешем даёт кратное ускорение повторных сборок. Локально помогает при переключении между ветками.
5. Быстрый линкер
Стандартный ld из binutils медленный. lld (Clang) и mold линкуют в разы быстрее, особенно на больших проектах.
# использовать mold вместо стандартного линкера (GCC 12+/Clang)
cmake -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=mold" -S . -B buildПроверьте, что mold установлен и что ваша версия компилятора понимает -fuse-ld=mold — синтаксис у старых версий отличался. Актуальные требования смотрите в README проекта mold.
Что выбирать под свою ситуацию
| Приём | Ускоряет что | Чем платите | Когда включать |
|---|---|---|---|
| PCH | Чистую сборку | Ломается при частых правках включённых заголовков | Однородные стабильные include по всему проекту |
| Unity build | Чистую сборку, CI | Инкрементальность, конфликты имён | Релизные и CI-сборки, редко правите |
| Forward decl / pimpl | Инкрементальную сборку | Косвенность в рантайме, аллокация | Часто правите базовые заголовки |
| ccache / sccache | Повторные сборки | Диск под кеш | CI, переключение веток локально |
| lld / mold | Линковку | Почти ничем | Всегда, если поддерживается |
Порядок внедрения по соотношению выгода/риск: сначала быстрый линкер и ccache (почти бесплатны), потом разрыв зависимостей в заголовках (даёт устойчивый выигрыш на ежедневной работе), и только затем PCH и unity build — они капризнее и требуют настройки под конкретный код.
Где это ломается
- Unity build и ODR. Склейка файлов вскрывает нарушения правила одного определения*, которые раньше молчали. Анонимные пространства имён в разных файлах, макросы с одинаковыми именами — всё это внезапно конфликтует.
- PCH и разные флаги. PCH привязан к набору флагов компиляции. Если часть целей собирается с другими дефайнами, PCH для них невалиден и молча не используется — вы думаете, что ускорились, а нет.
- ccache и относительные пути / __FILE__. Макросы вроде
__FILE__и абсолютные пути в отладочной информации могут снижать попадания в кеш. У ccache есть настройки нормализации путей — проверьтеhash_dirи базовый каталог. - Мнимое ускорение LTO. Link-time optimization ускоряет рантайм, но замедляет линковку иногда в разы. Не путайте цель: если жалуетесь на время сборки, LTO — ваш враг, а не друг, и в debug-конфигурации его быть не должно.
Что делать дальше
Начните с замера: -ftime-trace на паре самых тяжёлых файлов покажет, шаблоны у вас или заголовки. Дальше подключите ccache и mold — это минуты работы и нулевой риск. Затем найдите топ-10 самых включаемых заголовков (их можно вытащить из графа зависимостей ninja) и разорвите лишние include через forward declarations. PCH и unity build оставьте на потом и включайте на CI-профиле, измеряя до и после на своём железе — чужие проценты вам ничего не гарантируют.
* ODR (One Definition Rule) — правило C++, по которому у каждой сущности во всей программе должно быть ровно одно определение. Нарушение часто не диагностируется компилятором и приводит к неопределённому поведению, которое всплывает при изменении структуры сборки.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Clang Compiler User's Manual — Time Trace, CMake Documentation — target_precompile_headers
Частые вопросы
У меня GCC, а -ftime-trace был показан для Clang. Как измерить у меня?
Включил unity build, а проект перестал компилироваться. Что произошло?
PCH подключил, а время сборки не изменилось. Почему?
Насколько именно ускорится сборка — в два раза, в три?
Стоит ли включать LTO, если хочу ускорить сборку?
Помогает ли просто добавить процессорных ядер и параллельность?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.