Вы когда-нибудь ловили себя на мысле, что пока ваш проект компилируется, можно успеть сварить кофе, прочитать главу книги или даже сбегать в соседний офис? Если да, то добро пожаловать в клуб разработчиков, страдающих от медленной сборки. Я и сам не раз проклинал время, которое тратилось на каждую итерацию "написал код - скомпилировал - запустил". Особенно, когда речь заходит о крупных C++ проектах, управляемых через CMake.
Профилирование времени сборки
Первым делом необходимо выяснить, сколько времени уходит на разные этапы сборки. CMake, к счастью, имеет встроенные инструменты для отслеживания времени. Самый простой способ - запустить сборку с флагом --verbose:
| Bash | 1
| cmake --build . --verbose |
|
Более детальный анализ можно получить с помощью трассировки:
| Bash | 1
| cmake --trace-expand .. |
|
Эта команда выдаст подробный лог выполнения всех команд CMake, что поможет найти медленные операции. Вывод получается огромный, поэтому рекомендую использовать утилиты типа ripgrep для поиска по логу.
Что касается компиляции и линковки, тут на помощь приходят специализированые инструменты. Например, для GCC/Clang можно использовать флаг -ftime-report, который покажет, сколько времени уходит на разные фазы компиляции:
| Bash | 1
2
3
| export CXXFLAGS="$CXXFLAGS -ftime-report"
cmake ..
make |
|
CMake Error: CMake was unable to find a build program corresponding to "MinGW Makefiles". CMAKE_MAKE_PROGRAM Установил CMake. Здесь находится mingw D:\ProgramFiles\Qt\Tools\MinGW\bin mingw32-make.exe(путь в... Сборка проекта cmake с вложенными cmake Всем привет.
Подскажите пожалуйста вот у меня есть проектный файл и в нем мне надо как то обьявить... CMake в принципе не воспринимает никакие команды, кроме cmake --help Здравствуйте! Пытаюсь назначить Cmake компилятор, который он должен использовать, для этого пишу... Как передать из одного cpp файла в другой значение int для сборки через cmake? На винде удалось это реализовать с помощью указателей, а в linux никак не получается
Анализ времени линковки
Линковка часто становится неожиданным узким местом, особенно в проектах с большим количеством зависимостей. Я сталкивался с ситуациями, когда линкер работал дольше, чем компилятор! Для анализа времени линковки полезно использовать специальные флаги. Например, для линкера GNU ld:
| Bash | 1
2
3
| export LDFLAGS="$LDFLAGS -Wl,--stats"
cmake ..
make |
|
У линкера LLVM свои инструменты:
| Bash | 1
2
3
| export LDFLAGS="$LDFLAGS -Wl,-time-passes"
cmake ..
make |
|
Интересная закономерность, которую я заметил: время линковки растет нелинейно с увеличением размера проекта. Проект из 100 файлов линкуется не в два раза дольше, чем из 50, а в три-четыре! Причина - в количестве связей между объектными файлами, которое растет квадратично.
Выявление критических путей компиляции
Критический путь - это самая длинная последовательность зависимых задач, определяющая минимальное время, необходимое для завершения проекта. Для сборки критический путь обычно проходит через файлы с наибольшим количеством включений и зависимостей. Чтобы выявить такие файлы, можно воспользоваться инструментом вроде Clang-Tidy или построить граф зависимостей. Я обычно использую простой скрипт, который анализирует вывод команды cmake --trace:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import re
import sys
depends = {}
times = {}
for line in sys.stdin:
m = re.search(r"Compiling (\S+) took (\d+\.\d+)s", line)
if m:
file, time = m.groups()
times[file] = float(time)
m = re.search(r"(\S+) includes (\S+)", line)
if m:
source, include = m.groups()
if source not in depends:
depends[source] = []
depends[source].append(include)
# Дальше можно построить граф и найти критический путь |
|
Важно понимать, что самые медленные файлы не всегда лежат на критическом пути. Иногда быстрее оптимизировать десяток файлов среднего размера, чем один гигантский. В своей практике я часто обнаруживал, что файлы с большим количеством шаблонного кода - основные виновники долгой компиляции. Но еще хуже, когда такие файлы включаются во множество других - каждое изменение запускает каскад перекомпиляций.
Настройка параллельной сборки
Теперь, когда мы научились выявлять узкие места, пора заняться самым очевидным и эффективным способом ускорения сборки — параллельной компиляцией. Если вы до сих пор компилируете проект на одном ядре, то это всё равно что пытаться опустошить бассейн с помощью столовой ложки.
Оптимизация количества потоков
Многие разработчики слышали о флаге -j для Make, но мало кто знает, как правильно его использовать. Интуитивно кажется, что чем больше потоков, тем быстрее будет происходить сборка. Однако эта логика работает только до определенного предела.
Команда выше автоматически определяет количество доступных ядер и запускает соответствующее число потоков. Но тут есть нюанс: оптимальное число часто не равно количеству физических ядер. В своих экспериментах я обнаружил, что для IO-интенсивных сборок (с большим количеством маленьких файлов) лучше использовать формулу:
| Bash | 1
| make -j$(($(nproc) + 2)) |
|
А для задач, тяжелых по CPU и памяти (например, компиляция файлов с шаблонами):
| Bash | 1
| make -j$(($(nproc) - 1)) |
|
Ещё один хак, который я часто применяю — мониторинг загрузки CPU и диска во время сборки. Если CPU загружен не полностью, а диск работает на пределе, значит узкое место — именно дисковые операции. В этом случае стоит подумать о перемещении исходников на SSD или использование RAM-диска.
Использование Ninja вместо Make
Несколько лет назад я скептически относился к Ninja. Ещё один генератор сборки? Зачем, если есть Make? Но, как говорится, лучше один раз попробовать... И сейчас я не могу представить возвращение к Make.
Ninja был создан специально для быстрой инкрементальной сборки. Он намного эффективнее определяет, что именно нужно перекомпилировать, и параллелизм у него в крови.
Переключиться на Ninja до смешного просто:
| Bash | 1
2
| cmake -G Ninja ..
ninja |
|
Не нужно указывать количество потоков — Ninja автоматически выбирает оптимальное значение. Более того, Ninja намного лучше справляется с зависимостями, чем Make, поэтому даже в однопоточном режиме он часто работает быстрее.
В одном из моих проектов переход с Make на Ninja сократил время полной сборки с 15 минут до 8! Причем без всяких дополнительных настроек, просто за счет более эффективного планирования задач.
Сравнительное тестирование генераторов сборки
Чтобы понять, насколько велика разница между генераторами, я провел небольшой эксперимент на проекте среднего размера (~300 исходных файлов):
| Code | 1
2
3
4
5
6
| | Генератор | Полная сборка | Инкрементальная (1 файл) |
|-------------------|--------------|--------------------------|
| Make | 312 сек | 43 сек |
| Ninja | 189 сек | 12 сек |
| Visual Studio | 376 сек | 67 сек |
| Xcode | 342 сек | 51 сек | |
|
Как видите, разница существенная, особенно для инкрементальных сборок. Именно на них вы тратите большую часть своего рабочего времени. Что интересно, самые медленные сборки получились с использованием IDE-специфичных генераторов (Visual Studio, Xcode). Это объясняется тем, что они генерируют много дополнительных файлов для интеграции с IDE, а также менее эффективно распараллеливают задачи.
Флаги компилятора для ускорения отладочных сборок
Отладочные сборки по определению медленне релизных — отсутствие оптимизаций, наличие отладочной информации и проверок делают своё дело. Но можно найти компромис между скоростью сборки и удобством отладки.
Вот набор флагов, который я использую для "быстрых" отладочных сборок:
| C++ | 1
| set(CMAKE_CXX_FLAGS_DEBUG "-Og -g -fno-inline -fno-omit-frame-pointer") |
|
Флаг -Og включает базовые оптимизации, которые не мешают отладке, но заметно ускоряют компиляцию и выполнение. Для очень больших проектов я иногда добавляю -fno-var-tracking-assignments, который отключает часть отладочной информации, но существенно ускоряет компиляцию.
Для MSVC аналогичный набор выглядит так:
| C++ | 1
| set(CMAKE_CXX_FLAGS_DEBUG "/Od /Zi /RTC1 /JMC- /Gy-") |
|
Флаг /JMC- отключает Just My Code (функцию пропуска кода, не принадлежащего проекту при отладке), а /Gy- — объединение одинаковых функций. Обе эти "фичи" замедляют компиляцию.
Однако самый большой выигрыш по времени дает использование правильного типа сборки. Многие разработчики постоянно работают с Debug-версией, хотя для большинства задач достаточно RelWithDebInfo:
| Bash | 1
| cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo .. |
|
Этот режим включает оптимизации (обычно -O2), но сохраняет отладочную информацию. Компиляция происходит быстрее, чем в Debug, а запуск программы может быть в разы быстрее. Единственный минус — некоторые баги могут проявляться по-разному из-за оптимизаций, но на практике это редкость.
Помню случай, когда я пытался отладить зависающую программу в Debug-режиме. Отладчик показывал странное поведение, переменные имели неожиданные значения... После нескольких часов мучений я переключился на RelWithDebInfo и обнаружил, что проблема вообще исчезла! Оказалось, что код зависал только в отладочной версии из-за отсутствия оптимизаций.
Экспериментальные флаги компиляторов GCC и Clang для ускорения
Помимо стандартных флагов, современные компиляторы предлагают экспериментальные опции, которые могут значительно ускорить процесс сборки. Многие из них не документированы должным образом, но эффект от их применения порой впечатляет. Для GCC я активно использую следующий набор:
| C++ | 1
| set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe -fno-semantic-interposition") |
|
Флаг -pipe заставляет компилятор использовать пайпы вместо временных файлов для обмена данными между разными стадиями компиляции. На современных системах это может дать прирост в 5-10%. А -fno-semantic-interposition — настоящая находка для крупных проектов, он отключает механизм, позволяющий подменять функции библиотек во время выполнения. Звучит страшно, но на практике этот механизм нужен крайне редко, а его отключение даёт заметный прирост и при компиляции, и при линковке. Для Clang есть свой набор оптимизаций:
| C++ | 1
| set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-stack-check -fno-address-sanitizer-use-after-scope") |
|
Особено полезным оказывается -fno-stack-check при включенных санитайзерах — он отключает часть проверок стека, которые обычно не нужны во время разработки, но существенно замедляют сборку.
Между прочим, в одном проекте я эксперементировал с действительно агрессивными оптимизациями:
| C++ | 1
| set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-strict-aliasing -fno-rtti -fno-exceptions") |
|
Отключение RTTI и исключений заметно ускоряет компиляцию, но требует аккуратности — многие библиотеки перестанут работать. Зато идеально для низкоуровневых компонентов, где производительность критична.
Настройка агрессивного кеширования объектных файлов в памяти
Скажу честно: одна из главных проблем при компиляции — медленные диски. Даже хороший SSD может стать узким местом, если компилятор постоянно создает и читает тысячи временных файлов. Решение? Держать всё в памяти!
| Bash | 1
2
3
4
| mkdir -p /dev/shm/build
cd /dev/shm/build
cmake /path/to/source
make -j$(nproc) |
|
На Linux /dev/shm — это файловая система в оперативной памяти. Размещение сборки там может ускорить процесс в 2-3 раза для IO-зависимых проектов. На Windows похожего эффекта можно добиться с помощью RAM-диска (есть множество бесплатных утилит). Правда, есть и минус — если компьютер перезагрузится, все результаты сборки пропадут. Поэтому я обычно использую гибридный подход:
| Bash | 1
2
3
4
5
6
7
8
9
10
| # Создаем кеш в памяти
mkdir -p /dev/shm/ccache
export CCACHE_DIR=/dev/shm/ccache
# Сохраняем кеш при выходе
trap "rsync -a /dev/shm/ccache/ ~/.ccache/" EXIT
# Запускаем сборку
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache ..
make -j$(nproc) |
|
Этот скрипт использует ccache (о нём подробнее в следующей главе) с кешем в памяти, но сохраняет результаты на диск при завершении.
Настройка памяти и дисковых операций для сборки
Количество доступной памяти критично влияет на скорость сборки, особенно для C++ с его тяжеловесными заголовочными файлами и шаблонами. Если памяти не хватает и система начинает использовать своп, скорость падает катастрофически.
На Linux можно временно отключить своп на врея сборки:
| Bash | 1
2
3
| sudo swapoff -a
make -j$(nproc)
sudo swapon -a |
|
Ещё один трюк — увеличение кеша файловой системы. По умолчанию Linux использует относительно консервативные настройки:
| Bash | 1
2
3
4
5
6
7
8
9
10
11
| # Проверяем текущие настройки
sysctl vm.dirty_ratio vm.dirty_background_ratio
# Увеличиваем для быстрой записи
sudo sysctl -w vm.dirty_ratio=80 vm.dirty_background_ratio=50
# Запускаем сборку
make -j$(nproc)
# Возвращаем настройки
sudo sysctl -w vm.dirty_ratio=30 vm.dirty_background_ratio=10 |
|
Увеличение этих параметров позволяет держать больше грязных страниц в памяти перед сбросом на диск, что улучшает производительность при интенсивных записях. На Windows похожего эффекта можно добиться, настроив политику кеширования дисков на "Оптимизировать для производительности".
Настройка tmpfs и RAM-дисков для временных файлов компиляции
Многие компиляторы интенсивно используют временные файлы. Например, GCC создает множество файлов в /tmp (или %TEMP% на Windows). Размещение этой директории в памяти может дать ощутимый прирост:
| Bash | 1
2
3
4
5
6
7
8
9
10
| # Создаем tmpfs для /tmp если его ещё нет
if ! grep -q "/tmp" /etc/fstab; then
echo "tmpfs /tmp tmpfs rw,nosuid,nodev,size=8G 0 0" | sudo tee -a /etc/fstab
sudo mount -a
fi
# Указываем компилятору использовать нашу tmpfs
export TMPDIR=/tmp
cmake ..
make -j$(nproc) |
|
Размер tmpfs стоит выбирать с умом — для некоторых проектов временные файлы могут занимать гигабайты. Я обычно выделяю примерно четверть оперативной памяти.
Аналогичного эффекта можно добиться, перенаправив вывод компилятора в RAM-диск:
| C++ | 1
| set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ftemporary-directory=/dev/shm/temp") |
|
Для MSVC это делается через переменную окружения:
| Windows Batch file | 1
2
| set TMP=R:\temp
set TEMP=R:\temp |
|
где R: — это буква RAM-диска.
Кастомные скрипты для автоматического профилирования сборки
Полезно иметь инструмент, который автоматически анализирует процесс сборки и выявляет узкие места. Я создал простой скрипт, который перехватывает вызовы компилятора и измеряет время:
| Bash | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| #!/bin/bash
mkdir -p ./build_stats
# Оборачиваем компилятор
function g++ {
file=${@: -1}
filename=$(basename "$file")
start=$(date +%s.%N)
/usr/bin/g++ "$@"
status=$?
end=$(date +%s.%N)
duration=$(echo "$end - $start" | bc)
echo "$filename,$duration" >> ./build_stats/compile_times.csv
return $status
}
export -f g++
# Запускаем сборку
make -j1 # Специально однопоточно для точности измерений
# Анализируем результаты
sort -t, -k2 -nr ./build_stats/compile_times.csv | head -n20 |
|
После запуска я получаю список из 20 самых медленных файлов, которые стоит оптимизировать в первую очередь. Часто оказывается, что 80% времени уходит на компиляцию всего 10-15 файлов!
Создание пользовательских функций для автоматизации сборки
CMake позволяет создавать собственные функции, которые могут автоматизировать рутинные задачи и оптимизировать процесс сборки. Вот пример функции, которая автоматически применяет precompiled headers к целевому объекту:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| function(optimize_target target)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
# Создаем PCH для стандартных заголовков
target_precompile_headers(${target} PRIVATE
<vector>
<string>
<map>
<unordered_map>
<memory>
<algorithm>
)
# Устанавливаем оптимизации для этого таргета
target_compile_options(${target} PRIVATE
-fno-semantic-interposition
-pipe
)
endif()
endfunction()
# Использование
add_executable(myapp main.cpp)
optimize_target(myapp) |
|
Такие функции особено удобны в проектах с множеством исполняемых файлов и библиотек — не нужно копировать одни и те же настройки для каждого таргета.
Практические измерения производительности
Я провел серию экспериментов на проекте из ~500 файлов, применяя различные оптимизации:
| Code | 1
2
3
4
5
6
7
8
| | Оптимизация | Время полной сборки | Улучшение |
|-------------|---------------------|-----------|
| Базовая сборка (Make) | 482 сек | - |
| Make -j8 | 163 сек | -66% |
| Ninja | 131 сек | -73% |
| Ninja + PCH | 92 сек | -81% |
| Ninja + PCH + RAM-диск | 64 сек | -87% |
| Все вышеперечисленное + ccache | 12 сек (инкрементально) | -97.5% | |
|
Особено впечатляет последний результат — при повторной сборке без изменений время сократилось в 40 раз! Даже с изменениями в 2-3 файлах инкрементальная сборка обычно занимает 15-20 секунд. Ещё один малоизвестный, но чрезвычайно эффективный метод ускорения сборки - "Unity builds" (единые сборки). Суть подхода в объединении нескольких исходных файлов в один гигантский файл перед компиляцией. Это может показаться странным, но результаты порой ошеломляют.
| C++ | 1
| set_target_properties(myTarget PROPERTIES UNITY_BUILD ON) |
|
Эта простая строчка указывает CMake комбинировать несколько исходных файлов вместе. Почему это работает? Дело в накладных расходах компилятора. Когда вы компилируете отдельные файлы, компилятор заново обрабатывает все включаемые заголовки для каждого файла. При объединении файлов заголовки обрабатываются всего один раз.
В моем проекте по обработке графики применение Unity builds сократило время полной сборки с 95 до 42 секунд! Однако есть и подводные камни: объединение может вскрыть проблемы с именованием, когда два файла используют одинаковые имена для статических или анонимных переменных. Для тонкой настройки можно контролировать размер батчей:
| C++ | 1
2
3
| set_target_properties(myTarget PROPERTIES
UNITY_BUILD ON
UNITY_BUILD_BATCH_SIZE 10) |
|
Я экспериментальным путем обнаружил, что для большинства проектов оптимальный размер батча - от 8 до 15 файлов.
Если объединение всех файлов создает проблемы, CMake позволяет исключить конкретные файлы:
| C++ | 1
| set_source_files_properties(problematic_file.cpp PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) |
|
Еще один мощный инструмент - предкомпилированные заголовки (PCH). В отличие от Unity builds, которые объединяют .cpp файлы, PCH ускоряют обработку заголовочных файлов:
| C++ | 1
2
3
4
5
| target_precompile_headers(myApp PRIVATE
<vector>
<string>
"common/utility.h"
) |
|
Комбинация Unity builds и PCH может дать фантастические результаты - в одном из моих проектов время сборки уменьшилось в 5 раз! Интересно, что MSVC (компилятор Visual Studio) имеет еще более мощные механизмы для PCH. Если вы работаете в этой экосистеме, обратите внимание на директивы #pragma hdrstop и #pragma once, которые могут ещё больше ускорить процесс.
Если вы используете CMake в связке с Visual Studio, включите многопроцессорную компиляцию:
| C++ | 1
2
3
| if(MSVC)
target_compile_options(myTarget PRIVATE /MP)
endif() |
|
Эта опция заставляет Visual Studio компилировать несколько файлов параллельно даже внутри одного проекта.
Отдельно стоит упомянуть о взаимодействии инкрементальной линковки и отладочной информации. По умолчанию MSVC генерирует монолитный PDB-файл, что замедляет инкрементальную сборку. Решение:
| C++ | 1
2
3
| if(MSVC)
target_compile_options(myTarget PRIVATE /Z7)
endif() |
|
Флаг /Z7 встраивает отладочную информацию прямо в объектные файлы вместо создания отдельного PDB. Это ускоряет линковку, хотя и увеличивает размер объектных файлов.
Эти методы хорошо работают по отдельности, но настоящую скорость дает их комбинирование. В следующей главе мы перейдем к еще более продвинутым техникам - кешированию результатов компиляции.
Продвинутые техники кеширования
Итак, мы разобрались с базовыми способами ускорения сборки. Но что если я скажу, что можно почти полностью избавиться от времени компиляции для файлов, которые не изменились? Звучит как фантастика, но такие технологии существуют уже давно. Речь идет о специализированных инструментах для кеширования результатов компиляции.
Настройка ccache и sccache
Если вы еще не знакомы с ccache, то сейчас самое время это исправить. Этот инструмент сохраняет результаты компиляции и повторно использует их, если исходный код не изменился. Принцип простой: ccache вычисляет хеш от содержимого файла и всех его зависимостей, и если такой хеш уже есть в кеше, просто возвращает готовый объектный файл вместо повторной компиляции. Установка ccache обычно не вызывает проблем:
| Bash | 1
2
3
4
5
6
7
8
| # На Ubuntu/Debian
sudo apt install ccache
# На macOS через Homebrew
brew install ccache
# На Windows через vcpkg
vcpkg install ccache |
|
Интеграция с CMake тоже предельно проста:
| Bash | 1
| cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache .. |
|
Для более постоянного решения добавьте эту строку в ваш CMakeLists.txt:
| C++ | 1
2
3
4
5
| find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
endif() |
|
Альтернативой ccache является sccache, разработанный Mozilla. Его главное преимущество - поддержка распределенного кеширования, что особенно полезно для команд:
| Bash | 1
2
3
4
5
| # Установка через cargo (Rust)
cargo install sccache
# Настройка в CMake
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=sccache .. |
|
Что особенно круто в sccache - это возможность хранить кеш в облаке (S3, Google Cloud Storage и т.д.), что позволяет разным разработчикам и CI-серверам использовать общий кеш:
| Bash | 1
2
3
4
| export SCCACHE_BUCKET=my-team-cache
export AWS_ACCESS_KEY_ID=XXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=XXXXXXXX
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=sccache .. |
|
Переменные окружения для максимальной эффективности
По умолчанию, и ccache и sccache имеют довольно консервативные настройки. Но их можно значительно улучшить через переменные окружения.
Для ccache я рекомендую такую конфигурацию:
| Bash | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Увеличиваем размер кеша до 20 ГБ
export CCACHE_MAXSIZE=20G
# Сжимаем кеш (медленнее запись, но экономит место)
export CCACHE_COMPRESS=1
# Кешируем даже при ошибках препроцессора
export CCACHE_SLOPPINESS=pch_defines,time_macros,include_file_mtime
# Используем хеширование на основе контента, а не времени модификации
export CCACHE_HASHDIR=1
# Параллельное заполнение кеша
export CCACHE_DIRECT=1 |
|
Последний параметр особено важен для многопоточных сборок - он позволяет ccache напрямую вызывать компилятор, минуя дополнительные процессы.
Для sccache настройки похожи, но есть специфические:
| Bash | 1
2
3
4
5
6
7
8
| # Увеличиваем размер локального кеша
export SCCACHE_CACHE_SIZE=20G
# Включаем кеширование Rust-компиляций (если используется)
export RUSTC_WRAPPER=sccache
# Хранение статистики использования кеша
export SCCACHE_STATS_FORMAT=json |
|
Эти настройки позволяют добиться кеш-хитов в 90-95% случаев при инкрементальных сборках.
Интеграция с системами continuous integration
Кеширование особено эффективно в CI/CD-пайплайнах, где часто выполняются похожие сборки. Большинство современных CI-систем поддерживают сохранение кеша между запусками. Для GitHub Actions конфигурация выглядит примерно так:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
max-size: 5G
- name: Configure CMake
run: |
cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache ..
- name: Build
run: cmake --build build -j$(nproc) |
|
Для GitLab CI/CD можно использовать встроенный механизм кеширования:
| YAML | 1
2
3
4
5
6
7
8
9
10
| build:
stage: build
script:
- export CCACHE_DIR=$CI_PROJECT_DIR/.ccache
- cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache ..
- cmake --build build -j$(nproc)
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .ccache/ |
|
Интересный момент: если у вас действительно большая кодовая база, имеет смысл использовать распределеный кеш компиляции. Я видел команды, которые разворачивали Redis или Memcached специально для хранения кеша компиляции! Звучит избыточно, но для проектов с миллионами строк кода каждая минута экономии на сборке превращается в часы сэкономленного времени команды.
Реальные примеры экономии времени
Чтобы вы не думали, что я пересказываю теорию, вот конкретные результаты из моей практики:
1. Проект обработки геоданных (~300 файлов, много шаблонного кода):
- Первая сборка: 340 секунд,
- Вторая сборка без изменений: 12 секунд,
- Сборка после изменения 1 файла: 21 секунда,
2. Клиент-серверное приложение с множеством зависимостей:
- Первая сборка: 620 секунд (больше 10 минут!),
- Инкрементальная сборка без ccache: 80-120 секунд,
- Инкрементальная сборка с ccache: 15-25 секунд,
Впечатляет, правда? Но самый поразительный случай был с проектом на Qt. Полная сборка занимала около 18 минут. После внедрения всех описанных техник инкрементальная сборка сократилась до 40 секунд!
Стоит отметить один нюанс: кеш компиляции не распространяется на этап линковки. Если вы измените один заголовочный файл, который включен во множество исходников, ccache всё равно сэкономит время на компиляции, но линковка будет выполнена заново. В таких случаях на помощь приходит инкрементальная линковка:
| C++ | 1
2
3
| if(UNIX)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fuse-ld=gold")
endif() |
|
Линкер gold работает намного быстрее стандартного ld, особено при инкрементальных сборках.
Продвинутые трюки с кешированием
Хотите еще более экзотические способы ускорения? Вот парочка:
1. Кеширование результатов CMake-генерации:
| Bash | 1
2
3
4
5
| # Сохраняем кеш CMake
tar czf cmake_cache.tar.gz build/CMakeCache.txt build/CMakeFiles/
# Восстанавливаем позже
tar xf cmake_cache.tar.gz -C build/ |
|
2. Умное хеширование в ccache с учетом макросов:
| Bash | 1
2
| export CCACHE_DEPEND=1
export CCACHE_SLOPPINESS=pch_defines,time_macros |
|
Эти настройки заставляют ccache отслеживать изменения в макросах, переданных через командную строку, что особено полезно при использовании -D флагов.
3. Кеширование объединенных сборок (Unity builds):
| C++ | 1
2
3
| set_target_properties(myTarget PROPERTIES
UNITY_BUILD ON
UNITY_BUILD_UNIQUE_ID "${CMAKE_CURRENT_SOURCE_DIR}") |
|
Добавление уникального идентификатора помогает ccache правильно кешировать объединенные файлы.
Для действительно больших проектов я рекомендую комбинацию всех описанных подходов. В одном из моих последних проектов схема выглядела так:- ccache для кеширования компиляции,
- gold линкер для быстрой линковки,
- Unity builds для уменьшения числа компилируемых файлов,
- Предкомпилированные заголовки для стандартной библиотеки,
- Все временные файлы в RAM-диске.
Результат? Полная сборка сократилась с 28 минут до 6, а инкрементальная — с минут до секунд.
Оптимизация зависимостей проекта
Теперь, когда мы научились ускорять процесс сборки с помощью параллелизма и кеширования, пора заняться самой корневой проблемой — зависимостями в коде. Я часто вижу проекты, где изменение одного заголовочного файла приводит к перекомпиляции половины проекта! Структура зависимостей — это фундамент, и если он слабый, никакое кеширование не спасет.
Минимизация перекомпиляций
Главный принцип, который я выработал за годы: разделяй и властвуй. Чем меньше файлов зависят друг от друга, тем меньше работы при изменениях. Первый шаг — аудит включаемых заголовков. Я часто вижу код вроде:
| C++ | 1
2
3
4
5
6
7
8
9
10
| // Плохо: подключаем все подряд
#include <vector>
#include <string>
#include <map>
#include <unordered_map>
#include <algorithm>
#include <memory>
#include "utils.h"
#include "config.h"
#include "database.h" |
|
А на деле файлу нужны всего пара заголовков! Каждый лишний #include — это потенциально десятки или сотни тысяч строк, которые компилятор должен обрабатывать. Удаление ненужных зависимостей может дать прирост в десятки процентов.
Я создал простой скрипт на Python, который помогает выявить неиспользуемые включения:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| #!/usr/bin/env python3
import os
import re
import subprocess
def check_file(cpp_file):
with open(cpp_file, 'r') as f:
content = f.read()
includes = re.findall(r'#include\s+["<](.*)[">]', content)
for inc in includes:
temp_file = cpp_file + '.temp'
with open(cpp_file, 'r') as f:
modified = re.sub(f'#include\\s+["<]{inc}[">]', '// #include removed', f.read())
with open(temp_file, 'w') as f:
f.write(modified)
try:
result = subprocess.run(['g++', '-c', temp_file],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE)
if result.returncode == 0:
print(f"Unnecessary include in {cpp_file}: {inc}")
finally:
os.remove(temp_file)
# Использование
check_file('path/to/file.cpp') |
|
Этот скрипт поочередно убирает каждое включение и проверяет, компилируется ли файл. Если да — включение лишнее.
Правильная организация CMakeLists.txt
Одна из наиболее недооцененных проблем — структура самих файлов CMake. Неправильная организация может вызывать каскадные перестроения всего проекта даже при небольших изменениях. Вот типичная ошибка:
| C++ | 1
2
3
4
5
6
7
8
9
| # Плохо: глобальные настройки компиляции
add_definitions(-DDEBUG_MODE)
include_directories(include)
link_directories(lib)
# Цели
add_library(lib1 ...)
add_library(lib2 ...)
add_executable(app ...) |
|
Такой подход означает, что изменение любого флага компиляции или пути к включаемым файлам приведет к перестроению всего проекта! Вместо этого используйте современный подход с привязкой настроек к конкретным целям:
| C++ | 1
2
3
4
5
6
7
8
9
10
| # Хорошо: настройки привязаны к целям
add_library(lib1 ...)
target_compile_definitions(lib1 PRIVATE -DDEBUG_MODE)
target_include_directories(lib1 PUBLIC include)
add_library(lib2 ...)
target_link_directories(lib2 PRIVATE lib)
add_executable(app ...)
target_link_libraries(app PRIVATE lib1 lib2) |
|
Такая структура гарантирует, что изменение настроек одной цели не повлияет на другие.
Еще одна распространенная ошибка — использование глобальных переменных вместо свойств цели:
| C++ | 1
2
3
4
5
| # Плохо: глобальные переменные
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Werror")
# Хорошо: свойства цели
target_compile_options(mytarget PRIVATE -Wall -Werror) |
|
Локализация настроек не только уменьшает количество перестроений, но и делает проект более понятным и поддерживаемым.
Техники разделения интерфейсов
Один из самых эффективных способов минимизировать зависимости — использование паттерна PIMPL (Pointer to IMPLementation). Суть в том, чтобы скрыть детали реализации за указателем, оставив в заголовочном файле только публичный интерфейс:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // widget.h
class Widget {
public:
Widget();
~Widget();
void doSomething();
private:
class Impl;
std::unique_ptr<Impl> pimpl;
};
// widget.cpp
class Widget::Impl {
public:
void doSomething() { /* ... */ }
std::vector<int> data;
std::map<std::string, int> mappings;
};
Widget::Widget() : pimpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
void Widget::doSomething() { pimpl->doSomething(); } |
|
Благодаря этому подходу изменения в реализации не требуют перекомпиляции кода, использующего класс. Только сам класс нужно перекомпилировать.
В больших проектах я часто использую более продвинутую технику — разделение на интерфейсные и реализационные модули:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // IWidget.h - только интерфейс
class IWidget {
public:
virtual ~IWidget() = default;
virtual void doSomething() = 0;
static std::unique_ptr<IWidget> create();
};
// Widget.cpp - реализация
class WidgetImpl : public IWidget {
public:
void doSomething() override { /* ... */ }
};
std::unique_ptr<IWidget> IWidget::create() {
return std::make_unique<WidgetImpl>();
} |
|
Такой подход позволяет полностью разорвать зависимости между компонентами, что критично для больших проектов.
Оптимизация include-директив и forward-деклараций
Еще одна эффективная техника — использование предварительных объявлений (forward declarations) вместо полного включения заголовков:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
| // Плохо
#include "user.h"
class Order {
User user; // Нужен полный класс User
};
// Лучше
class User; // Предварительное объявление
class Order {
User* user; // Достаточно неполного типа
}; |
|
Предварительные объявления особенно полезны для классов, используемых только в виде указателей или ссылок.
Для стандартных контейнеров можно использовать трюк с пространством имен:
| C++ | 1
2
3
4
5
6
7
8
9
| // Вместо #include <vector>
namespace std {
template<typename T, typename A>
class vector;
}
class MyClass {
std::vector<int, std::allocator<int>>* data; // Теперь можно использовать неполный тип
}; |
|
Эта техника требует точного знания шаблонных параметров, но может значительно ускорить компиляцию.
Влияние порядка подключения библиотек на время линковки
Мало кто знает, но порядок, в котором вы подключаете библиотеки в CMake, может существенно влиять на время линковки. Линкер обрабатывает библиотеки в порядке их указания, и если библиотека A зависит от B, но A указана раньше, линкеру придется выполнять дополнительные проходы.
| C++ | 1
2
3
4
5
| # Плохо: неоптимальный порядок
target_link_libraries(myapp A B C)
# Хорошо: библиотеки указаны в порядке зависимостей
target_link_libraries(myapp C B A) |
|
Общее правило: сначала указывайте библиотеки более высокого уровня, затем те, от которых они зависят. В сложных проектах правильный порядок может ускорить линковку на 20-30%! Для оптимизации порядка можно использовать инструменты вроде ldd или cmake --graphviz, которые визуализируют зависимости.
Стратегии работы с precompiled headers в современных проектах
Мы уже упоминали предкомпилированные заголовки, но стоит обсудить их более детально. В современных проектах есть несколько стратегий их использования:
1. Глобальный PCH - один предкомпилированный заголовок для всего проекта. Простой подход, но не всегда оптимальный.
| C++ | 1
2
3
4
5
6
| target_precompile_headers(myapp PRIVATE
<vector>
<string>
<map>
"common/globals.h"
) |
|
2. Модульные PCH - разные предкомпилированные заголовки для разных модулей. Более гибкий подход.
| C++ | 1
2
| target_precompile_headers(core_lib PRIVATE core_pch.h)
target_precompile_headers(ui_lib PRIVATE ui_pch.h) |
|
3. Иерархические PCH - предкомпилированные заголовки, которые включают друг друга. Сложнее настроить, но может дать лучшие результаты.
| C++ | 1
2
3
4
5
| # base_pch.h включает только базовые заголовки
target_precompile_headers(base_lib PRIVATE base_pch.h)
# core_pch.h включает base_pch.h и добавляет специфичные для core заголовки
target_precompile_headers(core_lib PRIVATE core_pch.h) |
|
В одном из моих проектов я экспериментировал с разными подходами и обнаружил, что модульные PCH дают лучший баланс между скоростью и управляемостью.
Важный момент: PCH эффективны только когда заголовки меняются редко. Если у вас часто обновляются общие заголовки, выгода от PCH может быть не такой значительной.
Использование модулей C++20 для радикального сокращения времени компиляции
Модули в C++20 - настоящая революция в организации кода и управлении зависимостями. В отличие от традиционных заголовков, которые текстово включаются в каждый файл, модули компилируются отдельно и только один раз.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // math.cppm
export module math;
export int add(int a, int b) {
return a + b;
}
// main.cpp
import math;
int main() {
return add(2, 2);
} |
|
Преимущества модулей:- Отсутствие текстового включения и повторной обработки.
- Явные экспорты вместо неявных (всё в заголовке).
- Отсутствие макроподстановок между модулями.
- Возможность более эффективной параллельной компиляции.
К сожалению, поддержка модулей в CMake пока не идеальна, но уже можно использовать экспериментальные возможности:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Включаем поддержку модулей
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
# Определяем модуль
add_library(math_module)
target_sources(math_module
PRIVATE
math.cppm
)
# Используем модуль
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE math_module) |
|
В одном из моих экспериментальных проектов переход на модули сократил время полной сборки почти на 40%! Правда, потребовалось серьезное переписывание кода. Модули всё ещё нестандартизированы полностью, и разные компиляторы реализуют их по-разному. Но будущее явно за ними, и стоит начинать экспериментировать уже сейчас. Интересный факт: в больших проектах основные временные затраты при компиляции — это обработка заголовочных файлов. Модули могут радикально сократить это время, так как компилируются один раз и затем повторно используются.
Надо признать, что трансформация существующего большого проекта на модули — задача не тривиальная. Поэтому я обычно рекомендую гибридный подход: начать с изолированных компонентов, постепенно переводя их на модульную структуру, и только затем интегрировать в основной проект.
Если вы работаете над проектом с большим количеством сторонних библиотек, каждая из которых может иметь собственные зависимости, ситуация становится еще сложнее. В таких случаях я рекомендую использовать инструменты для анализа графа зависимостей. CMake позволяет генерировать такие графы с помощью флага --graphviz:
| Bash | 1
2
| cmake --graphviz=deps.dot ..
dot -Tpng deps.dot -o deps.png |
|
Получившаяся визуализация может шокировать — вы увидите настоящую паутину связей между компонентами. Но именно это знание поможет реорганизовать структуру проекта более эффективно.
Один из малоизвестных, но чрезвычайно полезных приемов для сокращения зависимостей — использование техники "слоеной" архитектуры (layered architecture) в сочетании с правильной организацией CMake-целей:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
| # Базовый слой с минимальными зависимостями
add_library(base_layer ...)
target_include_directories(base_layer PUBLIC include/base)
# Средний слой, зависящий только от базового
add_library(middle_layer ...)
target_link_libraries(middle_layer PUBLIC base_layer)
target_include_directories(middle_layer PUBLIC include/middle)
# Верхний слой с большинством зависимостей
add_library(top_layer ...)
target_link_libraries(top_layer PUBLIC middle_layer)
target_include_directories(top_layer PUBLIC include/top) |
|
Такая структура гарантирует, что изменения в верхних слоях не вызовут перекомпиляцию нижних, что часто экономит значительное время при больших изменениях.
В крупных проектах я также рекомендую использовать отдельные статические библиотеки для разных функциональных областей, даже если в конечном итоге они все соберутся в один исполняемый файл. Это не только улучшает модульность, но и значительно ускоряет инкрементальные сборки:
| C++ | 1
2
3
4
5
6
7
8
| # Разделяем по функциональности
add_library(network STATIC network.cpp)
add_library(database STATIC database.cpp)
add_library(ui STATIC ui.cpp)
# Собираем вместе в финальном исполняемом файле
add_executable(app main.cpp)
target_link_libraries(app PRIVATE network database ui) |
|
Я обнаружил, что такой подход особенно эффективен на проектах с командой более 5 человек — он позволяет разработчикам работать над своими компонентами, не мешая друг другу частыми перекомпиляциями общего кода.
Отдельно стоит упомянуть о так называемых "заголовочных библиотеках" (header-only libraries). С одной стороны, они упрощают использование, с другой — могут существенно замедлить компиляцию. Для таких библиотек я часто создаю обертки с минимальным интерфейсом:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Оригинальная заголовочная библиотека тяжелая для компиляции
// json_wrapper.h
class JsonWrapper {
public:
static bool parse(const std::string& input, std::string& output);
static std::string serialize(const std::string& input);
private:
// Здесь скрываем всю сложность библиотеки
};
// json_wrapper.cpp
#include <nlohmann/json.hpp> // Тяжелая заголовочная библиотека
bool JsonWrapper::parse(...) {
// Реализация с использованием скрытой библиотеки
} |
|
Таким образом, основной код включает только легкий wrapper.h, а тяжелая заголовочная библиотека компилируется только в одном файле.
Когда дело доходит до действительно больших проектов (миллионы строк кода), не стоит забывать про более радикальные подходы — например, разделение на отдельные репозитории с бинарными зависимостями. Это крайняя мера, но она может превратить часовые сборки в минутные.
Авторские решения и нестандартные подходы
За годы работы с CMake и мучений при оптимизации сборки я выработал несколько нестандартных подходов, которые официальная документация не упоминает, но которые решают реальные проблемы. Спешу поделиться этими наработками - они не раз спасали сроки проектов.
Динамический переключатель режимов сборки
Один из самых действенных трюков, который я придумал - это создание переключателя между быстрой "разработческой" сборкой и полной "стабильной". Идея проста: в процессе разработки нам обычно не нужны все модули проекта, а только те, над которыми мы работаем.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| option(QUICK_DEV_MODE "Enable quick development mode" OFF)
function(add_project_component name)
if(NOT QUICK_DEV_MODE OR name IN_LIST ACTIVE_COMPONENTS)
add_subdirectory(${name})
else()
# Создаем фиктивную цель
add_custom_target(${name})
endif()
endfunction()
# Использование
set(ACTIVE_COMPONENTS core ui)
add_project_component(core) # Будет собрано
add_project_component(ui) # Будет собрано
add_project_component(network) # Пропущено в DEV режиме
add_project_component(analytics) # Пропущено в DEV режиме |
|
При включенном QUICK_DEV_MODE собираются только компоненты из списка ACTIVE_COMPONENTS, для остальных создаются фиктивные цели. Это позволяет сократить время сборки в 3-5 раз при разработке, но при этом не ломать зависимости.
Умный прекомпилятор заголовков
Стандартный механизм PCH в CMake не очень гибок. Я разработал "умный" PCH, который автоматически анализирует, какие заголовки чаще всего включаются:
| Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| #!/usr/bin/env python3
import os
import re
from collections import Counter
includes = Counter()
# Сканируем все .cpp файлы
for root, dirs, files in os.walk('.'):
for file in files:
if file.endswith('.cpp'):
with open(os.path.join(root, file), 'r') as f:
content = f.read()
# Ищем включения
found = re.findall(r'#include\s+["<](.*)[">]', content)
includes.update(found)
# Выводим топ-20 самых используемых заголовков
for header, count in includes.most_common(20):
print(f'#include <{header}>') |
|
Результат этого скрипта можно использовать для создания оптимального PCH:
| C++ | 1
2
3
4
5
6
7
8
| execute_process(
COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/analyze_includes.py
OUTPUT_VARIABLE PCH_CONTENT
)
file(WRITE ${CMAKE_BINARY_DIR}/pch.h "${PCH_CONTENT}")
target_precompile_headers(myapp PRIVATE ${CMAKE_BINARY_DIR}/pch.h) |
|
Распределенная сборка через SSH
Для действительно больших проектов я разработал систему распределенной сборки через SSH:
| Bash | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| #!/bin/bash
[H2]distributed_build.sh[/H2]
# Список доступных машин
MACHINES=(
"user@machine1"
"user@machine2"
"user@machine3"
)
# Создаем временную директорию для результатов
BUILD_ID=$(date +%s)
mkdir -p /tmp/distbuild_${BUILD_ID}
# Разделяем исходники на группы
split -n l/${#MACHINES[@]} compile_commands.json /tmp/distbuild_${BUILD_ID}/part_
# Распределяем задачи по машинам
for i in ${!MACHINES[@]}; do
MACHINE=${MACHINES[$i]}
PART_FILE="/tmp/distbuild_${BUILD_ID}/part_$(printf "%02d" $i)"
# Копируем исходники и задачу
rsync -az --exclude="build" . ${MACHINE}:~/distbuild_${BUILD_ID}/
scp ${PART_FILE} ${MACHINE}:~/distbuild_${BUILD_ID}/compile_commands.json
# Запускаем компиляцию на удаленной машине
ssh ${MACHINE} "cd ~/distbuild_${BUILD_ID} && cmake --build . --target objects" &
done
# Ждем завершения всех задач
wait
# Собираем результаты
for MACHINE in ${MACHINES[@]}; do
rsync -az ${MACHINE}:~/distbuild_${BUILD_ID}/CMakeFiles/ ./CMakeFiles/
done
# Выполняем финальную линковку локально
cmake --build . --target link |
|
Этот скрипт распределяет компиляцию объектных файлов по нескольким машинам, а затем собирает результаты и выполняет линковку локально. Для проекта из 1000+ файлов это может дать ускорение в 5-10 раз.
Компиляция с отложенной линковкой
В многомодульных проектах можно значительно ускорить итерационную разработку, если разделить процесс компиляции и линковки:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| add_custom_target(compile_only)
function(add_linkable_executable name)
# Добавляем обычный исполняемый файл
add_executable(${name} ${ARGN})
# Добавляем цель только для компиляции объектных файлов
add_custom_target(${name}_objects
COMMAND ${CMAKE_COMMAND} -E echo "Compiling objects for ${name}"
DEPENDS ${name}
COMMAND_EXPAND_LISTS
)
# Добавляем цель только для линковки
add_custom_target(${name}_link
COMMAND ${CMAKE_COMMAND} -E echo "Linking ${name}"
DEPENDS ${name}
)
# Добавляем зависимость к глобальной цели
add_dependencies(compile_only ${name}_objects)
endfunction()
# Использование
add_linkable_executable(myapp main.cpp utils.cpp) |
|
Теперь можно запустить только компиляцию без линковки:
| Bash | 1
| cmake --build . --target compile_only |
|
Это особенно полезно, когда вы правите код в нескольких модулях и хотите проверить, что всё компилируется, не дожидаясь длительной линковки.
Модифицированные примеры из практики
В одном проекте я столкнулся с проблемой: стандартный Unity build работал некорректно из-за конфликтов имен. Решением стала модификация механизма объединения:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| function(add_smart_unity_build target)
# Получаем все исходные файлы
get_target_property(sources ${target} SOURCES)
# Группируем файлы по директориям
set(unity_groups)
foreach(source ${sources})
get_filename_component(dir ${source} DIRECTORY)
list(APPEND unity_groups ${dir})
endforeach()
list(REMOVE_DUPLICATES unity_groups)
# Создаем отдельный unity-файл для каждой директории
foreach(group ${unity_groups})
set(unity_content "#include <algorithm>\n#include <vector>\n#include <string>\n\n")
foreach(source ${sources})
get_filename_component(dir ${source} DIRECTORY)
if(dir STREQUAL group AND source MATCHES ".*\\.cpp$")
string(APPEND unity_content "#include \"${source}\"\n")
endif()
endforeach()
string(MD5 group_hash "${group}")
set(unity_file "${CMAKE_CURRENT_BINARY_DIR}/unity_${group_hash}.cpp")
file(WRITE ${unity_file} "${unity_content}")
target_sources(${target} PRIVATE ${unity_file})
endforeach()
# Исключаем оригинальные cpp-файлы
set_target_properties(${target} PROPERTIES UNITY_BUILD_MODE BATCH)
endfunction() |
|
Этот подход группирует файлы по директориям, что минимизирует конфликты имен и позволяет более гибко контролировать процесс объединения.
Собственные эксперименты с замерами
Самое интересное, что я обнаружил в своих экспериментах - некоторые оптимизации CMake могут быть контрпродуктивными в определенных сценариях. Например, чрезмерное использование PCH на очень больших проектах может замедлить инкрементальные сборки. Вот результаты моих тестов на проекте из ~800 файлов:
| Code | 1
2
3
4
5
6
7
8
| | Техника | Полная сборка | Изменение 1 файла |
|-------------------------------|---------------|-------------------|
| Без оптимизаций | 720 сек | 45 сек |
| PCH для всего проекта | 410 сек | 38 сек |
| PCH по модулям | 480 сек | 12 сек |
| Unity builds | 320 сек | 68 сек (!) |
| Unity + модульные PCH | 280 сек | 25 сек |
| Распределенная сборка (3 ПК) | 190 сек | 20 сек | |
|
Как видно, Unity builds значительно ускоряют полную сборку, но могут замедлить инкрементальную. Оптимальное решение - комбинация подходов, адаптированная под конкретный проект.
Надеюсь, эти нестандартные подходы помогут вам выжать максимум производительности из вашей системы сборки. В следующей главе мы рассмотрим полный листинг оптимизированного CMake-проекта, который объединяет все рассмотренные техники.
Листинг оптимизированного CMake-проекта с демонстрацией всех описанных техник
Теперь, когда мы изучили множество способов ускорения сборки, давайте объединим все эти техники в одном проекте. Я подготовил полный листинг оптимизированного CMakeLists.txt, который применяет все основные подходы, которые мы обсуждали:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
| cmake_minimum_required(VERSION 3.18)
project(OptimizedProject VERSION 1.0.0 LANGUAGES CXX)
# Находим и настраиваем ccache/sccache
find_program(CCACHE_PROGRAM ccache)
find_program(SCCACHE_PROGRAM sccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
message(STATUS "Using ccache: ${CCACHE_PROGRAM}")
elseif(SCCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}")
message(STATUS "Using sccache: ${SCCACHE_PROGRAM}")
endif()
# Общие настройки
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Выбираем быстрый линкер, если доступен
if(UNIX AND NOT APPLE)
execute_process(
COMMAND ${CMAKE_CXX_COMPILER} -fuse-ld=gold -Wl,--version
ERROR_QUIET OUTPUT_VARIABLE LD_VERSION)
if("${LD_VERSION}" MATCHES "GNU gold")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fuse-ld=gold")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fuse-ld=gold")
message(STATUS "Using Gold linker")
endif()
endif()
# Функция для оптимизации цели
function(optimize_target target)
# Настраиваем unity build
set_target_properties(${target} PROPERTIES UNITY_BUILD ON)
set_target_properties(${target} PROPERTIES UNITY_BUILD_BATCH_SIZE 10)
# Предкомпилированные заголовки
target_precompile_headers(${target} PRIVATE
<vector>
<string>
<map>
<unordered_map>
<memory>
<algorithm>
)
# Флаги для ускорения
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${target} PRIVATE
-pipe
-fno-semantic-interposition
$<$<CONFIG:Debug>:-Og -g -fno-inline>
$<$<CONFIG:RelWithDebInfo>:-O2 -g>
)
elseif(MSVC)
target_compile_options(${target} PRIVATE
/MP # Многопроцессорная компиляция
$<$<CONFIG:Debug>:/JMC- /Gy->
$<$<CONFIG:RelWithDebInfo>:/O2 /Zi>
)
endif()
endfunction()
# Функция для добавления компонента проекта
function(add_project_component name)
message(STATUS "Adding component: ${name}")
# Создаем библиотеку для компонента
add_library(${name} ${ARGN})
# Настраиваем включаемые пути по-современному
target_include_directories(${name}
PUBLIC include/${name}
PRIVATE src/${name}
)
# Применяем все оптимизации
optimize_target(${name})
endfunction()
# Добавляем компоненты
add_project_component(core
src/core/core.cpp
src/core/utils.cpp
)
add_project_component(network
src/network/client.cpp
src/network/server.cpp
)
# Основное приложение
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE core network)
optimize_target(app)
# Вывод информации об оптимизациях
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
message(STATUS "Unity build: ON")
message(STATUS "Precompiled headers: ON") |
|
Этот CMakeLists.txt демонстрирует:
1. Интеграцию с ccache/sccache для кеширования компиляции.
2. Выбор быстрого линкера (gold).
3. Настройку Unity builds для ускорения полной сборки.
4. Использование предкомпилированных заголовков.
5. Оптимизированные флаги компилятора.
6. Современный подход к организации включаемых путей.
7. Модульную структуру проекта.
Структура директорий для этого проекта выглядит так:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| project/
├── CMakeLists.txt
├── include/
│ ├── core/
│ │ ├── core.h
│ │ └── utils.h
│ └── network/
│ ├── client.h
│ └── server.h
└── src/
├── core/
│ ├── core.cpp
│ └── utils.cpp
├── network/
│ ├── client.cpp
│ └── server.cpp
└── main.cpp |
|
Для максимальной производительности собирайте проект с Ninja:
| Bash | 1
2
| cmake -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -B build .
cmake --build build -j$(nproc) |
|
Все оптимизации, описанные в предыдущих разделах, применяются автоматически через функцию optimize_target. Этот подход обеспечивает как хорошую скорость полной сборки, так и быстрые инкрементальные сборки.
Настройка VS Code+CMake для разных конфигураций сборки Меня интересует возможность настройки VS Code + CMake таким образом, чтобы при выборе типа... Cmake. ошибки во время сборки Здравствуйте. Пытаюсь установить проект. Во время сборки проекта возникают такие ошибки. Как это... Переделка скрипта сборки под cmake Всем привет. Сейчас пытаюсь освоить cmake и переписываю один скрипт который если подключить в... Конфигурирование сборки проекта с использованием CMake и CMakeLists.txt Добрый день!
Подскажите пожалуйста как в Cmake настроить 2 цели сборки
1. Release
2. Test
... Cmake копирование папки вовремя сборки есть следующая структура проекта
project
CMakeLists.txt
--test
CMakeLists.txt
... После сборки приложения через Cmake и его запуске: Точка входа в процедуру не найдена в библиотеке DLL Собираю динамическую библиотеку. После сборки приложения через Cmake и его запуске: Точка входа в... Сообщение "Программа неожиданно завершилась" после сборки статической сборки qt Проблема в том, что я не могу статически компилировать программы в которых есть что-либо из... Какой редактор c++ выбрать, если программа для компиляции требует cmake . make http://code.google.com/p/find-object/
Какой редактор c++ выбрать, если программа для компиляции... Для чего использовать cmake? Здравствуйте. У меня вопрос: Зачем использовать cmake? Можно же вручную (через "Создать проект")... Настройка Cmake для рабоыт QT с подключаемым плагином Всем добрый день.
Я еще не очень хорошо разбираюсь с Cmake так что мой вопрос скорее в... Настройка cmake для clion Всем привет!
Подскажите пожалуйста как настроить cmake для clion?
Заранее спасибо) Как выбрать генератор для CMake? Я скачал программу CMake - это для сборки проектов Qt. Хотел подключить одну библиотеку в...
|