Знай свои инструменты
Чтобы иметь некую нить повествования, все методы, описанные в данной статье, будут применяться к некоторому проекту, код которого здесь приведён не будет. Будут приведены лишь результаты измерений времени сборки этого проекта, после применения различных техник, меняющих процедуру сборки. За исключением некоторых особенностей, присущих только одному компилятору, все техники и методы, описанные далее, являются кроссплатформенными и кросскомпиляторными (по крайней мере в «большой тройке»). В этой связи будут использованы средства 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, которые значительно быстрее стандартного. Есть и другие варианты, но, на мой взгляд, эти два наиболее интересны.
В целом же, подводя промежуточный итог, мне кажется, что наш программист достиг прекрасных результатов по ускорению времени сборки проекта, приложив минимум усилий. Он просто включил нужные опции — и вуаля! Мне больше никакие опции не известны, но если кто-то может поделиться дополнительными флагами, пожалуйста, пишите в комментарии. Мы же перейдём к следующему этапу ускорения.
Усмири заголовки
Применение
Прочитав всю теоретическую основу, описанную выше, наш программист взялся за внедрение 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) код вместо вставки ссылки на неизвестную функцию. Это больше про оптимизацию скорости выполнения, но упомянуть всё равно стоит.
-
Меньше работы для компоновщика в силу уменьшения количества объектных файлов и неразрешённых сущностей.
И это вряд ли полный список, а просто то, что первым приходит в голову. Всё это выглядит очень интересно и вдохновляюще, но не бывает так, чтобы были одни сплошные плюсы. У всего есть минусы:
-
Нельзя просто брать и объединять два файла реализации и ожидать, что код будет работать. Это может быть не так.
-
Бездумное склеивание файлов просто по списку может создать перекос, когда у вас одна корзина значительно больше других, и, соответственно, ресурсы простаивают. Решается тонкой настройкой, но кто ей занимается?
-
Инкрементные сборки значительно ухудшаются, ведь теперь изменения в одном файле заставляют пересобирать восемь файлов вместо одного.
-
Подобные сборки могут долго скрывать ошибки. Поскольку компиляция происходит восьмёрками, включение одного заголовочного файла может повлиять на другой файл, в котором он нужен, но не включён. Ситуация схожа с PCH. Только в отличие от PCH, где это в целом надёжно, здесь любая смена порядка файлов может привести к появлению ошибки.
Исходя из этих минусов, как и в случае с PCH, вы обязательно должны иметь сборку, проверяющую корректность, с выключенным unity build.
Хочется подробнее остановиться на первом минусе, потому что я считаю его наиболее весомым. Единица трансляции в C++ всегда являлась автономной. В файле реализации позволено куда больше, чем в заголовках, потому что внутренности реализации зачастую не видны вовне. В результате есть очень много кода, который содержит неквалифицированные обращения к сущностям из пространств имен, различные using-директивы и объявления, или же внутренние функции с довольно распространёнными именами, которые не выходят за область видимости файла реализации.
Всё это рабочий и хороший C++-код, но он легко может сломаться в unity build, потому что склеивание двух «хороших» файлов реализации на выходе даёт «плохой». Учтите, что если вы вручную не составляете списки файлов на склеивание, они являются динамическими, т. е., добавив один файл в проект, вы получаете другую конфигурацию сборки, и, соответственно, то, что работало вчера, может сломаться сегодня, потому что произошло объединения файлов, в которых есть конфликты. Всё это делает такие сборки крайне хрупкими.
В результате вы стоите перед выбором: адаптировать код, или отключать unity build. И здесь нет правильного ответа: всё зависит от того, что для вас более важно, и о каком уменьшении скорости сборки мы говорим. К примеру, наш программист, посмотрев, что получает от 20% до 80% ускорения, решает, что бо́льшая премия сейчас лучше, чем головная боль когда-то потом (а может и не его головная боль). Я же выбираю отказ от unity builds по умолчанию, потому что считаю их плохим костылём последней надежды: я крайне не люблю менять код, просто потому, что какая-то внешняя сущность этого от меня требует, да и стабильность сборки дорогого стоит. Но, возможно, пересмотрю свою позицию в отношении конкретного проекта, если увижу, что результат действительно того стоит, и его не получается добиться другими средствами.