Ускорение сборки C++-проекта

В этом блоге я много пишу о языке C++, о том, какие в нём есть примитивы, которые помогают нам сделать результирующий код лучше и быстрее. Но я никогда не затрагивал другой аспект разработки, а именно: улучшение качества жизни программиста. Одним из наиболее раздражающих аспектов любой разработки, на мой взгляд, является временной интервал между написанием кода и получением результата. Невероятно сложно разрабатывать программу, когда между применением гипотезы к коду и её проверкой в продукте проходит больше, чем N секунд, где N — это предельное время, после которого программист начинает испытывать раздражение (или наоборот). Конечно, N у всех разное (у меня это чаще всего в районе минуты), так как проекты разные и пределы терпения, соответственно, тоже. В цепочке гипотеза-результат есть немало звеньев, но в настоящей статье мы поговорим только об одном — времени сборки, а точнее: как можно ускорить время сборки C++-проекта.

Знай свои инструменты

Чтобы иметь некую нить повествования, все методы, описанные в данной статье, будут применяться к некоторому проекту, код которого здесь приведён не будет. Будут приведены лишь результаты измерений времени сборки этого проекта, после применения различных техник, меняющих процедуру сборки. За исключением некоторых особенностей, присущих только одному компилятору, все техники и методы, описанные далее, являются кроссплатформенными и кросскомпиляторными (по крайней мере в «большой тройке»). В этой связи будут использованы средства CMake, которые по большей части позволяют абстрагироваться от реализации того или иного метода в конкретной системе сборки/компиляторе. Предполагается использование версии CMake, которая поддерживает всё, что будет использовано далее. Я не буду специально останавливаться на том, в какой версии ввели поддержку того, или иного функционала, т. к. всё можно легко найти в официальной документации. Также я создал шаблон проекта, где все описанные ниже практики уже реализованы; его можно посмотреть в этой фиксации.

В статье будет несколько сравнений времени сборки на Windows (MSVS 2022 17.8.3) и Linux (clang 17, gcc 13), но сравнения будут проводиться только относительно своих прошлых результатов, т. е. Windows с Linux сравниваться не будут, потому что это разные машины (Windows: AMD Ryzen 5 3600X, 64 GB RAM; Linux: AMD Ryzen 3 4300GE, 16 GB RAM), и, вообще, см��сл статьи не в этом. Измерения в Windows проводятся штатными средствами MSVS (Tools → Options → Projects and Solutions → VC++ Project Settings → Build Timing), а в Linux — через команду time.

Покончив с затянувшейся преамбулой, давайте перейдём к созданию C++-проекта с нуля. Итак, у нас есть некий программист, который создаёт новый C++-проект. Для этих целей он выбирает CMake, потому что проект планируется кроссплатформенным. Работать он будет в Windows и периодически собирать в Linux, чтобы убедиться, что все три основных компилятора справляются с написанным кодом. CMake-файл не содержит никаких излишков: просто базовый функционал с минимумом флагов компилятора (последний стандарт, уровень ошибок, да и всё на этом).

Проект создан и над ним кипит работа: добавляется новый функционал, количество кода увеличивается, увеличивается и время сборки проекта, пока оно не начинает превышать двух минут в основной среде, Windows. Точные стартовые позиции таковы: Windows -> 02:03:677, Linux -> 6m36,797s (здесь и далее я привожу real часть вывода команды time).

Но программист смиряется с этим: «Что поделаешь?» Продолжает работать и обязательно проверять сборку на Linux. В Windows проект собирается в Microsoft Visual Studio последней версии, которая запрашивается у CMake через ключ -G, а в Linux всё используется по умолчанию, т. е. какая-то такая последовательность действий:

mkdir bin
cd bin
cmake ..
make

Однажды, читая статьи в обеденный перерыв, наш программист натыкается на «новый» способ сборки, который, по утверждению автора статьи, является безусловно лучшей альтернативой Make — Ninja Build. Вооружившись новым методом, он запускает сборку:

mkdir bin
cd bin
cmake -G Ninja ..
time ninja

И получает на выходе такой результат: 1m51,192s. Медленно его брови начинают ползти вверх: это не просто более чем в три раза быстрее, чем с Make, это ещё быстрее, чем в Windows! Нет, он, конечно, поверил автору, что Ninja быстрее Make, но не настолько же! Это какая-то магия, подумал программист. Потом подумал ещё и решил, что магии в программировании не бывает, а значит, нужно искать источник ускорения. Т. к. герой у нас попался въедливый, он находит причину: всё дело в том, что Make изначально использовался неэффективно — совершенно не задействовалась возможность распараллеливания сборки. Вернувшись обратно к Make и выполнив time make -j${nproc}, получает такой же результат, как с Ninja: 1m12,922s. Вот и «магии» конец.

Хотя в примере выше Ninja показала результат неотличимый от Make, это не означает, что Ninja не является более эффективным решением. Просто на этом проекте и с этим конкретным методом сборки («чистая» сборка всего проекта) они показали себя одинаково. Лично для меня Make давно перестал быть вариантом сборки, если я могу использовать Ninja.

Чувствуя себя остолопом, программист обновляет скрипты сборки и обращает свой взор на Windows: похоже, MSVS тоже не задействует все предоставленные ей ресурсы. Но для начала он решает убедиться, что «магия» Ninja работает и на Windows:

mkdir bin
cd bin
cmake -G Ninja ..
Measure-Command { ninja }

Результат ожидаемый, Ninja и тут справилась: 00:45:585.

Беглый осмотр свойств проекта показывает, что, действительно, никаких флагов параллельной сборки не выставлено. Покопавшись в документации, обнаружились два подходящих параметра: /maxcpucount флаг MSBuild (а именно эта система сборки используется в MSVS) и флаг /MP компилятора MSVC++ (cl.exe). Посмотрев в Tools -> Options -> Projects and Solutions -> Build And Run->maximum number of parallel project builds, он находит, что /maxcpucount уже выставлен в приемлемое значение (по умолчанию равно количеству CPU в системе). Т. е. по крайней мере эту часть параллелизации MSVS использует «из коробки», но всё равно выдаёт посредственный результат! Программист идёт дальше, добавляет /MP в CMake, собирает проект и получает такой результат: 00:44:230 — ура, догнали Ninja!

Ради интереса и полноты эксперимента пробует с /MP и /maxcpucount:1: 00:57:671, а также без /MP и с /maxcpucount:1: 04:35:203. Т. е. основной прирост нам даёт флаг /MP, но и про /maxcpucount забывать не стоит.

Нужно отметить, что флаг /MP никак на Ninja не повлиял. Я в Ninja разбираюсь не сильно, но могу предположить, что Ninja вызывает cl.exe для каждого файла отдельно, поэтому /MP просто ничего не делает.

Для удобства давайте приведём все результаты в одном месте, чтобы было легче оценить: в Linux при переходе от сборки в один поток к многопоточной был получен следующий результат: 6m36,797s -> 1m51,192s, т. е. сборка была ускорена в 3,5 раза!

Для Windows одной строкой не обойтись, задействуем таблицу:

Флаги

Время сборки

Ускорение (%)

/maxcpucount:1

04:35:203

44

/maxcpucount

02:03:677

100

/MP /maxcpucount:1

00:57:671

214

/MP /maxcpucount

00:44:230

279

За базу я использовал вариант с /maxcpucount, потому что показывать прирост относительно /maxcpucount:1 было бы красиво, но нечестно и контрпродуктивно: я этот вариант просто для полноты привёл, сам по себе он не интересен совершенно, потому что на современных системах его вряд ли можно встретить. В целом же на обеих системах мы получили очень хороший прирост в скорости сборки, но что особенно приятно — это не потребовало от нас никаких усилий!

Чем примечательная эта история? Тем, что она реальна, и я уверен, что происходит повсеместно. Вы, конечно, можете мне возразить, мол, «У меня такого произойти не могло, это же азы сборки!». Я могу лишь порадоваться, а потом пойти в интернет и немного взгрустнуть, потому что огромное количество инструкций сборки именно такие:

make
make install

Или то же самое, но сначала вызовут CMake для генерации make-файла, или более продвинутые, может, даже так напишут:

cd bin
cmake ..
cmake --build .

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

Возьмите две популярные библиотеки: {fmt} и Google Test (я выбрал эти две, но можно взять десятки других, просто смысла в этом не вижу) и посмотрите, как рекомендуется их собирать, а также попробуйте сгенерировать Solution-файл для MSVS. Следуя их рекомендациям, а также параметрам по умолчанию, без Ninja вы нигде не получите параллельную сборку. Поймите меня правильно, я авторов этих библиотек ни в чём не обвиняю: в конце концов, возможно, это было осознанное решение сделать именно так. Я просто говорю о том, что параллельную сборку по умолчанию не так-то легко получить.

Так может, есть какая-то причина, почему умолчания систем именно такие? Может, по умолчанию действительно не нужно собирать в несколько потоков? Действительно, если обратиться к документации флага /MP, то можно увидеть, что он несовместим с некоторыми другими флагами. Другим недостатком является факт полной загрузки системы, т. е. при задействовании всех мощностей компьютера вы рискуете потерять отзывчивость системы на время сборки.

Оба этих момента нужно принимать во внимание, но, на мой взгляд, умолчания всё-таки должны быть иными, как в Ninja, а вот под конкретные ситуации уже пусть программист адаптирует сборку. К примеру, мы рассмотрели вариант, где используются все доступные процессоры в системе при параллельной сборке. Но, возможно, для вашей задачи подойдёт меньшее количество процессов одновременной сборки, или, наоборот, большее! Тут уже нужно подбирать и смотреть, что больше подходит в вашем случае.

Кстати, если вы используете последнюю версию MSVS (на момент написания это MSVS 2022 17.10.5), то, возможно, имеет смысл рассмотреть использование некоторых экспериментальных опций, призванных улучшить распределение ресурсов при параллельной сборке. Для этого создайте файл Directory.Build.props в корне вашего проекта и добавьте туда следующее содержимое (пример есть в хранилище):

<Project>
    <PropertyGroup>
        <UseMultiToolTask>true</UseMultiToolTask>
        <EnforceProcessCountAcrossBuilds>true</EnforceProcessCountAcrossBuilds>
        <EnableClServerMode>true</EnableClServerMode>
    </PropertyGroup>
</Project>

Подробнее об этих параметрах можно посмотреть в этой статье (одна из наиболее полезных статей от Microsoft по C++ за последние годы, на мой взгляд), а также в этом отчёте об ошибке (крайне рекомендую ознакомиться с комментариями в этом отчёте, они проливают свет на существенные недостатки в MSBuild, а также проблемы взаимодействия флагов /MP и /maxcpucount). Я бы не стал использовать эти опции в проекте, где важна предсказуемость, потому что они крайне сырые и по ним практически отсутствует информация. Но в теории они могут дать прирост в сборке, а это уже будет являться весомым доводом для их использования. На проекте из статьи эти опции ничего не дали. В целом же, как мне кажется, если их не бросят, то за ними будущее MSBuild, потому что это очень похоже на попытку реализовать алгоритмы Ninja Build в MSBuild. Но этого будущего вряд ли стоит ожидать в рамках MSVS 2022.

Компоновка

До этого момента, всё, что мы рассматривали, относилось к компиляции. Но компиляция - это только часть сборки, пусть и самая времязатратная; после компиляции происходит компоновка, а она тоже может занимать длительное время. Т. к. проект, который тестируется в рамках данной статьи, не обладает выдающимся временем компоновки, никаких измерений тут не будет: нечего улучшать. Но если у вас компоновка ощутимо влияет на время общей сборки, то, если используется MSVC, можете посмотреть в сторону флага /Zf, который позволяет ускорить генерацию PDB-файлов (т. е. для Release-сборки этот флаг бесполезен). В остальном же остаётся надеяться на то, что разработчики MSVC продолжат работу над улучшением компоновщика, и он будет становиться всё быстрее.

У GCC и Clang ситуация несколько лучше, т. к. там есть выбор (правда, он зависит от ОС): можно заменить стандартный компоновщик на LLD или mold, которые значительно быстрее стандартного. Есть и другие варианты, но, на мой взгляд, эти два наиболее интересны.

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

Усмири заголовки

Зачем?

Прежде чем мы перейдём к ещё одной технике ускорения, мы должны понять, почему она вообще работает. Для этого мы кратко и довольно грубо рассмотрим, как происходит сборка C++-проекта. Возьмём простейший C++-код:

#include <print>

int main()
{
    std::print("Hello, World!");
}

Мы включаем заголовок print с помощью директивы препроцессора #include, а что делает эта директива? Она просто вставляет полный текст файла, который передан ей в угловых скобках (или кавычках). Весь этот процесс вставки текста является транзитивным, и если заголовок print содержит в себе директивы #include, они тоже будут обработаны, а текст соответствующих файлов будет также вставлен. В результате наш маленький код выше, состоящий из 6 строк, для компилятора может выглядеть, как десятки и сотни тысяч строк кода!

А ведь каждую из этих строк компилятору нужно разобрать и проанализировать. Очевидно, что это не очень быстрый процесс, и чем больше строк ему придётся анализировать, тем дольше компиляция будет занимать. А теперь давайте экстраполируем наш пример на реальный код среднего проекта, в котором сотни файлов реализации, в каждый из которых включены десятки файлов заголовочных. Т. е. на выходе мы получаем сотни файлов, каждый из которых состоит из сотен тысяч строк, и компилятор должен обработать каждый. При этом большая часть этих миллионов строк кода повторяется, потому что основная часть включённых заголовков будет одной и той же среди файлов реализации: string, vector, algorithm, ranges и т. д. Интереса ради посмотрите статистику влияния заголовков на общее время сборки и результирующий исполняемый файл на этом сайте, там же представлено и количество строк в заголовках одной из реализаций стандартной библиотеки C++. Но только ради интереса, он не пригоден для принятия решений по тем или иным заголовкам; на этом я остановлюсь более подробно позже.

Подведя промежуточный итог, мы можем кратко изложить проблему следующим образом: из-за особенностей организации взаимодействия единиц трансляции, компилятор выполняет очень много работы над одним и тем же кодом.

Как?

Обрисовав проблему, можно приступать к её решению. Как программисты решают проблему дублирующегося кода? Дедубликацией! Впервые эту проблему решили в Microsoft ещё в 1998 году в Visual C++ 6.0 (помните stdafx.h?). Решение назвали Precompiled Headers (PCH, рус. предварительно откомпилированные заголовки). В GCC поддержка появилась в версии 3.4 в 2004 году, а в Clang — в версии 2.5 в 2009 году.

Я никогда не использовал PCH в GCC и Clang напрямую, т. е. без CMake, поэтому я не знаю, что из себя представляла их поддержка, когда они только были добавлены. Тем не менее к 2024 году мы имеем 26 лет самой старой реализации и 15 самой молодой. Казалось бы, за столько времени уже все C++-проекты должны были перейти на новую технологию, но это далеко не так! Лично я не помню ни одного проекта, в котором были бы использованы PCH, и это при том, что в Студии они включены по умолчанию. Т. е. люди не просто не хотят добавлять новый функционал в сборку, они часто отказываются использовать уже имеющийся! Для этого должна быть какая-то причина, так? Безусловно, но она мне достоверно не известна, поэтому буду спекулировать.

Скорее всего, есть несколько факторов, и, в конце концов, это зависит от осведомлённости разработчиков тех или иных проектов. Я вполне допускаю, что есть проекты, где использование PCH было рассмотрено, но из-за определённых недостатков было признано нецелесообразным. Полагаю, что таких проектов меньшинство, а большинство не используют PCH из-за того, что либо не знают об этой технологии, либо не умеют её использовать, не привыкли к ней, или же находятся в плену заблуждений, зиждущихся на существовавших ранее проблемах с PCH (коих было немало в различных версиях MSVS и, уверен, не меньше в других реализациях). В данном разделе я постараюсь убедить вас, что использование PCH на сегодняшний день не просто оправдано — это должно быть отраслевым стандартом. И это отказ от использования PCH должен быть обоснован, а не наоборот.

Теперь давайте рассмотрим, что же PCH из себя представляет и как удалось решить проблему дублирующегося кода. Краеугольным камнем технологии предварительно откомпилированных заголовков является набор заголовочных файлов, которые включается в некотором Едином Заголовке. К примеру, у нас может быть такой заголовок pch.h:

#include <string>
#include <string_view>
#include <memory>

Далее нам нужно создать фиктивный файл реализации, куда мы его включим, pch.cpp:

#include "pch.h"

Файл необязательно должен быть фиктивным, т. к. компилятор не оперирует такими понятиями. Однако принято, что для создания PCH используется пустой, фиктивный файл, который специально для этого предназначен, а не какой-то из уже существующих файлов проекта. Принцип единственной ответственности, если хотите.

Наконец, мы используем компилятор со специальным ключом и компилируем pch.cpp, получая на выходе файл PCH, который содержит необходимую для компилятора информацию из предоставленных для него заголовков, т. е. это файл для компилятора, а не для нас.

Чтобы использовать получившийся PCH-файл, нам нужно включить ранее заготовленный pch.h в каждый файл реализации в проекте. Существует два метода включения: явный и неявный. При использовании явного метода, если у нас есть условный Compositor.cpp, то он должен начинаться как-то так:

#include "pch.h"
//...

Т. е. для включения PCH в наш проект мы должны модифицировать каждый файл реализации, причём изменения никакого отношения непосредственно к C++-коду не имеют, а являются частью интеграции внешней, по отношению к коду, сущности. Для кого-то это приемлемо, но я всегда испытываю дискомфорт, когда мне приходится менять код из-за каких-то внешних факторов. Поэтому я предпочитаю использовать подход неявный.

Для этого подхода используются ключи компиляции, которые позволяют «насильно» включить заголовочный файл в файл реализации; это флаги /FI и -include для MSVC и gcc/clang соответственно. При сборке с такими флагами каждый файл реализации будет собираться так, как будто его первой строкой является переданный заголовочный файл. Т. е. полное повторение явного метода, но здесь мы переложили ответственность с кода на сборочный процесс, где ему самое место.

Один файл реализации может использовать только один PCH-файл, но разные файлы могут использовать разные, т. е. технология PCH является пофайловой. Когда компилятор встречает #include "pch.h", он просто опускает его содержимое и вместо него использует заранее откомпилированный PCH-файл. Весь код, находящийся после него, разбирается как обычно. Т. е. мы добились решения описанной выше проблемы: дублирующийся код заголовков больше не собирается множество раз, он компилируется один раз и используется в дальнейшем.

Обычно в проектах используется один PCH-файл для всех файлов реализации. Если проект состоит из множества подпроектов, то вполне обосновано использование нескольких разных PCH-файлов. К примеру, предположим, что у вас есть проект разделённый на части, которые собираются в статические библиотеки, и для каждой части есть проект, который её тестирует. Некоторые проекты используют Boost, но не все, тесты пишутся с использованием Google Test. При такой конфигурации имеет смысл иметь не менее трёх разных PCH-файлов: некий базовый, базовый + Boost, базовый + Google Test. Таким образом для каждого подпроекта будет использоваться тот набор заранее откомпилированных заголовков, который имеет для него смысл.

Применяется два подхода к использованию PCH-файлов в проектах с большим количеством подпроектов: компиляция отдельного PCH-файла для каждого проекта или единая сборка различных PCH-файлов с последующим использованием уже готовых файлов в соответствующих проектах. Казалось бы, второй подход должен использоваться всегда, ведь он требует выполнения куда меньшего объёма работы (если количество разных PCH не равно количеству проектов). И действительно, при отсутствии причин использовать первый подход, второй, безусловно, предпочтителен.

Однако есть причины и для использования первого подхода. Точнее, причина, конечно же, одна, но причин у этой причины может быть множество. Заключается она в том, что PCH-файлы крайне уязвимы к среде, в которой происходит их сборка, т. е. чтобы использовать файл Essentials.pch в проекте MyComplexProject, они должны быть собраны в абсолютно идентичных условиях: компилятор, флаги, макросы, влияющие на заголовки внутри PCH-файла, и т. д. Для правильно организованного монолитного проекта эти условия обычно соблюдаются, и мы можем переиспользовать PCH-файлы. А вот другие проекты должны собирать их отдельно для каждого «уникального» проекта.

С помощью CMake оба варианта довольно легко использовать. Вот как вы можете создать новый PCH-файл:

target_precompile_headers(TargetName PRIVATE pch.h)

Где TargetName — это имя нашего проекта (target — в терминах CMake), а pch.h — это файл содержащий набор заголовков, актуальных для данного проекта. Теперь, имея другой проект AnotherTargetName с теми же «запросами», мы можем использовать ранее созданный нами PCH-файл:

target_precompile_headers(AnotherTargetName REUSE_FROM TargetName)

И это всё, что нам нужно сделать, чтобы добавить поддержку PCH в проект!

В приложении к этой статье вы можете наблюдать несколько более сложную организацию создания и использования PCH-файлов. Я не буду разбирать подобную структуру в рамках настоящей статьи, но, возможно, рассмотрю в одной из будущих статей по CMake. Т. к. это больше относится к предпочтениям организации проекта, чем непосредственно к PCH.

Кстати, CMake использует неявный метод внедрения PCH-заголовка.

Теперь, поняв зачем и как, предлагаю разобраться с тем, какие же заголовки нужно размещать в условном pch.h.

Какие заголовки?

Существует заблуждение, что есть некий список заголовков, который всегда стоит включать в pch.h. Одним из проявлений этого заблуждения является убеждение, что различные ресурсы, типа упомянутого ранее сайта, содержащего анализ влияния на сборку различных заголовков, являются хорошим подспорьем в составлении pch.h. Происхождение этих заблуждений понятно: люди не знают, с чего начать, а потому начинают с того, что берут наиболее «тяжёлые» заголовки и помещают их в pch.h. Это вполне логичное начинание; да вот только логика — это не тот инструмент, с которого стоит начинать оптимизацию чего-либо.

Любая оптимизация требует применения базового научного подхода, который заключается в следующем (адаптированный к нашему случаю):

  1. Измеряем общее время сборки всего проекта.

  2. Измеряем влияния каждого заголовка на общее время сборки.

  3. Выбираем набор заголовков, имеющих наибольшее влияние на время сборки.

  4. Помещаем выбранные заголовки в pch.h и возвращаемся в пункт первый.

Этот процесс заканчивается тогда, когда добавление новых заголовков в pch.h перестаёт приводить к уменьшению времени компиляции. У читателя может возникнуть резонный вопрос: «Почему бы просто не поместить все заголовки в pch.h, зачем заниматься всей этой тонкой настройкой списка?» Дело в том, что, как и всё в жизни, технология PCH не лишена недостатков. Так, чем больше заголовков содержится в pch.h, тем дольше будет компилироваться выходной PCH-файл, а его размер будет больше. Согласно завуалированной рекомендации Microsoft, если размер PCH-файла превышает 250 Мб, то пора работать над его уменьшением. Из-за этих условий может возникнуть ситуация, когда проект, собираемый с PCH, будет компилироваться дольше, чем без них. Или вообще собираться перестанет.

Именно поэтому мы должны проводить измерения и тонкую настройку списка заголовков. Мы не можем взять и поместить туда, скажем, vector и string, просто потому, что это известные заголовки, и они «уж точно там должны быть!». Да, скорее всего, они туда действительно попадут, потому что так наверняка покажут измерения, но сначала это нужно доказать! Нельзя оптимизировать вслепую — это чаще всего кончается впустую потраченным временем.

Настоящей причиной помещения того или иного заголовка в pch.h является комбинация «тяжести» оного с количеством его включений. Т. е. если у вас в проекте используется filesystem, который, безусловно, является «тяжёлым», но включается он всего в одном месте, помещение его в pch.h никак не ускорит сборку, а скорее может иметь обратный эффект. Почему мы можем получить замедление сборки? Это важная часть того, как работает PCH, и с этим мы сейчас разберёмся.

Из-за того, что PCH используется в каждом файле проекта (это может быть не так, но я рассматриваю базовый, самый распространённый сценарий), у нас формируется зависимость: каждый файл реализации зависит от PCH. А это, в свою очередь, означает, что при параллельной сборке все файлы должны ждать, пока компилируется PCH-файл, и только по завершении оной компилятор может приступать к работе с остальными. Т. е. на время сборки PCH-файла параллельная компиляция файлов внутри проекта не работает.

Теперь вернёмся к нашему примеру с filesystem. Если он находится в одном из, скажем, сотни файлов реализации, то время разбора этого заголовка будет частью сборки этого файла. Если же мы поместим его в pch.h, то он уже станет частью сборки PCH-файла, а как мы выяснили, это время повлияет на время начала сборки всех остальных файлов! Вот вам и бездумное помещение заголовков в pch.h.

Интересно, что из-за этой особенности (компиляция PCH-файла до всего остального) немало людей пребывают в заблуждении, что PCH помогают только в инкрементной сборке. Т. е. в сборке начисто PCH либо не помогает, либо вообще увеличивает общее время сборки. Я не буду утверждать, что это заблуждение ни на чём не основано, в конце концов, я работал над ограниченным набором проектов, но я буду утверждать, что это не обязательно должно быть так. Технология PCH может и должна уменьшать общее время сборки как впервые, так и впоследствии.

Ещё одним моментом, который я бы хотел затронуть, является точка зрения, что в pch.h можно включать только файлы внешних библиотек, т. е. заголовки, являющиеся частью нашего проекта (условно <> против ""), там быть не должны. Я считаю, что подобная точка зрения граничит с заблуждением: в pch.h можно и нужно помещать заголовки из проекта, если проведённые измерения показали полезность подобного шага. Но при этом нужно помнить, что при любом изменении pch.h происходит полная перекомпиляция PCH-файла, а pch.h считается изменённым, если любой из включённых в него заголовков изменился. Поэтому, размышляя помещать туда ваш заголовок или нет, нужно руководствоваться здравым смыслом и неким усреднённым интервалом, в течение которого заголовок не меняется. К примеру, если у вас есть заголовок ImportantHeader.h, который занимает ощутимое время в сборке, а меняется не чаще раза в неделю, тогда, если пересборка PCH-файла раз в неделю вас устраивает, вы помещаете его в pch.h.

Разобравшись с тем, зачем нам надо измерять степень влияния отдельных заголовков, надо теперь определиться с тем, как это время измерить. Мне известно два удобных метода: с помощью Clang Build Analyzer для Clang, и Build Insights (можно ещё Include Diagnostics добавить) для MSVS 2022. Решение для MSVS является относительно новым и, на мой взгляд, наиболее удобным.

И вот мы на финишной прямой, собрали все самые времязатратные заголовки в pch.h, добавили нужные строки в CMake; всё, работа окончена? Пока да, но нужно понимать, что работать с PCH нужно на протяжении жизни всего проекта. Мы внедряем их на старте, а потом раз за разом возвращаемся и обновляем их. Т. к. проект растёт, заголовочные файлы добавляются и удаляются, мы не можем один раз составить pch.h и забыть о нём. Нет, чем интенсивнее разработка, тем чаще кто-то должен возвращаться и проводить измерения, проверяя, не надо ли добавить или убрать заголовки из pch.h. Из этого следует важный вывод: чтобы такие измерения можно было проводить, проект должен иметь возможность собираться без PCH. Иначе просто невозможно определить, что какие-то из заголовков можно из pch.h убрать. Т. е. в проекте должен быть флаг, с помощью которого мы легко можем включить или выключить PCH.

Но даже если бы нам не нужно было постоянно обновлять pch.h, мы всё равно должны были бы добавить этот флаг, потому что в противном случае мы могли бы легко начать писать некорректный (без PCH) C++-код. Ведь очень легко забыть включить заголовок в файл реализации, если туда включается pch.h, его содержащий. Я знаю, что есть те, для кого «это не баг, а фича», потому что «можно не писать заголовки, всё само работает!». Правда, иногда неявное включение может сломать IntelliSense, но и это решаемо: «Просто добавим pch.h в каждый файл явно, и всё!» К сожалению, людей, так мыслящих, немало, но, как мне кажется, в большинстве своём это не со зла, а просто от непонимания: «Разве PCH не для этого существует?» Нет, PCH является технологией-костылём компиляторов, она не имеет никакого отношения непосредственно к C++-коду, а следовательно, при наличии возможности её внедрение на коде не должно отражаться никак.

В настоящий момент такая возможность существует, поэтому зависимость кода проекта от присутствия PCH ничем нельзя оправдать. Если из проекта будет удалён pch.h, ваш код должен продолжить собираться как ни в чём не бывало. Это правильно как с точки зрения поддержки самих PCH, так и с чисто организационной — код должен быть самодостаточен. Кроме того, когда придёт время переходить на модули, это будет сделать относительно просто, если все зависимости указаны в каждом файле. Относительно того кошмара, который придётся пережить тем, кто просто включает pch.h и не беспокоится о явном включении всех зависимостей.

Имея правильно выстроенную организацию PCH, важно время от времени собирать проект без них, чтобы убедиться в корректности кода. Наиболее рациональным решением здесь будет компиляция без PCH при автоматической сборке на сервере, по крайней мере в момент интеграции кода в одну из основных ветвей разработки. В проекте-приложении PCH отключаются простым заданием флага ENABLE_PCH=false при генерации проектных файлов с помощью CMake.

Ну и раз уж я упомянул модули, то давайте и о них пару слов скажу. Появившиеся в C++20 модули решают ту же самую проблему, что и PCH. Конечно, модули — это не просто стандартизация PCH, это куда более объёмный и важный функционал, но мы поговорим о них отдельно в одной из будущих статей. Модули должны сделать PCH ненужными, т. е. это решение полностью вытесняет костыль. Тогда зачем этот большой раздел по PCH в статье 2024 года, когда модули вышли в 2020-м? Всё дело в том, что спустя четыре года после стандартизации модулей их до сих пор невозможно нормально использовать: слишком сложно, много проблем и багов, реализации неполноценны и т. п. То есть модули до сих пор сырые. Я бы предположил, что года через три после устранения всех проблем с модулями PCH станут неактуальными. Откуда такой срок? Я не верю в быстрый переход массы C++-кода на модули сразу после их стабилизации, поэтому три года — это ещё оптимистично.

Применение

Прочитав всю теоретическую основу, описанную выше, наш программист взялся за внедрение PCH в проект. После тщательного анализа и составления различных PCH-заголовков у него получились интересные результаты, но прежде чем мы к ним перейдём, необходимо сказать несколько слов касательно особенностей тестирования, т. к. оно будет немного отличаться от представленного в первом разделе.

В силу того, что PCH являются технологией компилятора, будет интересно посмотреть на то, как они справляются по отдельности. Поэтому здесь не будет обобщения, названного «Linux», а результаты будут представлены для Clang и GCC отдельно (хотя сборка и будет проводиться на Linux-машине, указанной ранее). Проект, для которого проводились измерения, состоит из десяти подпроектов, половину из которых составляют проекты-тесты. Таким образом, проект использует два различных pch.h: один для проектов-тестов, второй для всего остального. Следовательно, измерялись две конфигурации: отдельный PCH-файл на каждый проект и два разных PCH-файла на все проекты. Измерялось только влияние PCH на чистую сборку. Поскольку нас интересует воздействие PCH на время компиляции в конкретной среде, а не сравнение сред, результаты будут представлены в трёх таблицах, а не в одной сводной. Итак, результаты:

MSVS 2022:

Тип

Время

Ускорение (%)

Без PCH

00:44:230

100

Отдельные PCH

00:29:725*

152

Общие PCH

00:17:398

259

* По невыясненным причинам время сборки с PCH было крайне нестабильным, т. е. от запуска к запуску время существенно менялось как с MSBuild, так и с Ninja Build.

GCC 13:

Тип

Время

Ускорение (%)

Без PCH

1:51:192

100

Отдельные PCH

1:21:371

137

Общие PCH

1:14:293

150

Clang 17:

Тип

Время

Ускорение (%)

Без PCH

1:38:817

100

Отдельные PCH

0:53:069

185

Общие PCH

0:49:267

200

В целом результаты предсказуемые. Если не по цифрам, то по тому, что проект с использованием PCH собирается быстрее, а с переиспользованием общих PCH — ещё быстрее. Это справедливо для всех трёх компиляторов, пусть разница в ускорении у них и существенная. Программист-оптимизатор получил ускорение сборки минимум в 1,5 раза, а максимум — в 2,5! �� считаю, что это великолепный результат, тем более что внедрение PCH является сравнительно простой задачей. Вот и программист наш тоже очень рад таким результатам и собирается уже идти выбивать себе премию, но мы его пока не отпускаем, потому что есть ещё одна техника сокращения времени компиляции.

Кучно бери

Итак, в предыдущих разделах мы увидели, что наша теория даёт плоды, и время сборки конкретного проекта продолжает сокращаться. Мы убедились в том, что компиляция в несколько процессоров существенно ускоряет сборку. Кроме того, уменьшение частоты разбора части заголовков тоже позволяет сократить общее время. Одним из побочных эффектов параллельной сборки является порождение большого количества процессов, по одному на каждый файл реализации. А результатом внедрения PCH — уменьшение времени обработки каждого файла и увеличение использования памяти каждым процессом за счёт загрузки в память процесса PCH-файла. Принимая во внимание, что в хорошо написанном C++-коде файлы реализации стараются делать относительно небольшими, мы можем получить ситуацию, при которой время компиляции файла не сильно превышает время создания процесса для компиляции оного.

В результате мы кучу времени тратим на создание процессов, тогда как компиляция у нас довольно шустрая. Эта ситуация часто встречается в многопоточном программировании: затраты на создание потока превышают вычисления, производимые в оном, что приводит к общему замедлению программы, вместо её ускорения. Очевидно, что в нашем случае это не так, поскольку мы получили общее ускорение. Но это не значит, что нельзя добиться ещё большего ускорения, разобравшись с тем, что может являться узким местом. В мире многопоточного программирования есть много различных подходов к решению этой задачи, но я бы выделил два основных: пул потоков (англ. thread pool) и пакетная обработка, т. е. увеличение нагрузки на каждый поток, путём увеличения задачи. Подходы не взаимоисключающие, а, скорее, дополняющие.

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

Хотя тонкая настройка представляет собой действительно сложную задачу, можно обойтись и без неё. Мы можем пойти по простому и грубому пути, который никогда не даст того же результата, но если будет хоть какой-то результат — уже хорошо. Для упрощения понимания идеи предлагаю рассмотреть такой пример: пусть у нас есть многоядерный процессор, который имеет, скажем, двадцать исполнительных ядер, и средней руки проект, в котором около тысячи файлов реализации. В базовом сценарии мы запускаем по 20 процессов за раз, и каждый процессор выполнит 50 разных процессов (для упрощения я сделал допущение, что сборка различных файлов занимает одинаковое время). Чтобы ускорить сборку, мы можем просто взять и объединить файлы реализации. Скажем, по пять файлов. В результате у нас получится 200 файлов вместо 1000, для обработки которых понадобится уже всего по 10 процессов на каждое ядро. В идеальной ситуации мы можем объединить файлы по 50-в-1, и тогда у нас все 20 процессоров будут полностью загружены, давая наилучший прирост!

«Что значит объединить файлы пачками? Нельзя просто взять и объединить файлы реализации!» — может сказать читатель и будет абсолютно прав, но мы к этому ещё вернёмся.

К сожалению, в реальности подобного достичь невозможно, потому что время сборки разных файлов отличается, а значит, когда мы объединим файлы 50-в-1, получится так, что какая-то часть в поте лица трудится над своей задачей, а другая уже всё выполнила и умирает от скуки. Это типичная проблема неправильного распределения задач между исполнителями. Поэтому нужно искать золотую середину между «50-в-1» и «все файлы по отдельности». Но т. к. даже золотую середину придётся искать от проекта к проекту, да ещё и менять её при развитии проекта, этим мало кто занимается.

Но если забыть про «тонкие настройки» и «золотые середины», мы можем найти уже готовое решение, и имя ему: Unity Build (оно известно и под другими именами). Это совершенно «дубовое» решение, которое предлагает некоторые умолчания, которые вы можете настроить (или не можете — на всё воля реализации). К примеру, чтобы включить unity build в CMake, нужно просто установить одну переменную: CMAKE_UNITY_BUILD=TRUE, и всё, ваш проект будет собираться специальным образом. Каким? Зависит от настроек, которые вы можете найти в документации по опции UNITY_BUILD. По умолчанию CMake просто возьмёт список всех ваших файлов реализации у одной цели, поделит весь список на корзины размером 8 файлов и объединит все файлы в каждой корзине. Так он поступит для всех целей, у которых будет выставлена опция UNITY_BUILD. Всё это можно несколько тоньше настроить, но, как я уже говорил ранее, чем тоньше нужна настройка, тем больше времени это отнимет. Тем не менее поэкспериментировать с размерами корзин может быть полезно, тем более что это делается достаточно просто.

Теперь предлагаю дать нашему программисту задание: пусть выставит этот флаг и посмотрит, что это изменит в скорости сборки его проекта (за базу возьмём параллельную сборку с общими PCH). Получил и передал нам такие результаты:

Компилятор

База

Unity build

Ускорение (%)

MSVS

00:17:398

00:14:940

121

GCC

1:14:293

0:41:305

180

Clang

0:49:267

0:29:790

169

Учитывая, что измерения проводились над уже оптимизированной сборкой, считаю это отличным результатом. А на более крупных проектах он должен быть ещё лучше. Но за счёт чего такой прирост: неужели создание процессов так дорого? Конечно нет: я вообще покривил душой, заводя разговор о стоимости создания процессов. Она, безусловно, существует. Но я ничего не измерял и использовал этот аргумент просто для сплетения нити повествования. Тем не менее никакое уменьшение накладных расходов по созданию процессов не может единолично объяснить увиденный нами прирост. Вот несколько возможных объяснений:

  • Как и в случае с PCH, склеивание файлов уменьшает количество раз, когда заголовки анализируются компилятором. Представьте, что из восьми объединённых файлов в шести используется один и тот же заголовок, которого нет в PCH. Вместо шести раз он будет разобран лишь один.

  • То же самое с инстанциацией шаблонов: один условный std::vector<int> вместо, скажем, пяти.

  • Видя больше кода одновременно, компилятор мог внедрить (англ. inline) код вместо вставки ссылки на неизвестную функцию. Это больше про оптимизацию скорости выполнения, но упомянуть всё равно стоит.

  • Меньше работы для компоновщика в силу уменьшения количества объектных файлов и неразрешённых сущностей.

И это вряд ли полный список, а просто то, что первым приходит в голову. Всё это выглядит очень интересно и вдохновляюще, но не бывает так, чтобы были одни сплошные плюсы. У всего есть минусы:

  1. Нельзя просто брать и объединять два файла реализации и ожидать, что код будет работать. Это может быть не так.

  2. Бездумное склеивание файлов просто по списку может создать перекос, когда у вас одна корзина значительно больше других, и, соответственно, ресурсы простаивают. Решается тонкой настройкой, но кто ей занимается?

  3. Инкрементные сборки значительно ухудшаются, ведь теперь изменения в одном файле заставляют пересобирать восемь файлов вместо одного.

  4. Подобные сборки могут долго скрывать ошибки. Поскольку компиляция происходит восьмёрками, включение одного заголовочного файла может повлиять на другой файл, в котором он нужен, но не включён. Ситуация схожа с PCH. Только в отличие от PCH, где это в целом надёжно, здесь любая смена порядка файлов может привести к появлению ошибки.

Исходя из этих минусов, как и в случае с PCH, вы обязательно должны иметь сборку, проверяющую корректность, с выключенным unity build.

Хочется подробнее остановиться на первом минусе, потому что я считаю его наиболее весомым. Единица трансляции в C++ всегда являлась автономной. В файле реализации позволено куда больше, чем в заголовках, потому что внутренности реализации зачастую не видны вовне. В результате есть очень много кода, который содержит неквалифицированные обращения к сущностям из пространств имен, различные using-директивы и объявления, или же внутренние функции с довольно распространёнными именами, которые не выходят за область видимости файла реализации.

Всё это рабочий и хороший C++-код, но он легко может сломаться в unity build, потому что склеивание двух «хороших» файлов реализации на выходе даёт «плохой». Учтите, что если вы вручную не составляете списки файлов на склеивание, они являются динамическими, т. е., добавив один файл в проект, вы получаете другую конфигурацию сборки, и, соответственно, то, что работало вчера, может сломаться сегодня, потому что произошло объединения файлов, в которых есть конфликты. Всё это делает такие сборки крайне хрупкими.

В результате вы стоите перед выбором: адаптировать код, или отключать unity build. И здесь нет правильного ответа: всё зависит от того, что для вас более важно, и о каком уменьшении скорости сборки мы говорим. К примеру, наш программист, посмотрев, что получает от 20% до 80% ускорения, решает, что бо́льшая премия сейчас лучше, чем головная боль когда-то потом (а может и не его головная боль). Я же выбираю отказ от unity builds по умолчанию, потому что считаю их плохим костылём последней надежды: я крайне не люблю менять код, просто потому, что какая-то внешняя сущность этого от меня требует, да и стабильность сборки дорогого стоит. Но, возможно, пересмотрю свою позицию в отношении конкретного проекта, если увижу, что результат действительно того стоит, и его не получается добиться другими средствами.

Заключение

В настоящей статье мы рассмотрели три метода ускорения сборки C++-проекта, но их, разумеется, существует значительно больше. Начиная от техники написания непосредственно кода, при которой максимально разгружаются заголовки (см. PImpl-идиому), и заканчивая разнообразными сторонними приложениями типа IncrediBuild, ccache, FASTBuild и т. д., и т. п.

На рассмотрение всех методов не хватит никакой статьи, но такой цели я перед собой и не ставил. Отличительной чертой рассмотренных в статье методов является простота их внедрения, а также отсутствие необходимости модификации кода и системы (ничего не нужно устанавливать дополнительно). Если же вы знаете какие-то ещё методы ускорения сборки, которые бы отвечали этим критериям, будет здорово, если вы ими поделитесь в комментариях.

Ещё одним объединяющим фактором для описанных методов является то, что C++-модули должны их полностью вытеснить (за исключением параллельной сборки, разумеется). И если с PCH это должно быть чистым выигрышем, то unity build просто исчезает из-за особенностей модулей (как можно склеить два произвольных модуля?). Но модули вообще сильно ландшафт должны поменять, и кто знает, какие ещё методы ускорения сборки появятся уже на их основе? Может, и новая итерация unity build появится. Но всё это мы уже увидим в светлом будущем, которое, надеюсь, скоро всё-таки наступит.