Если вы пишете на C++, то скорее всего сталкивались с тем, что компиляция, кодогенерация и компоновка проектов занимают время и с развитием проекта начинают мешать как CI конвейеру, так и итерации разработки на рабочем месте. Наша команда не понаслышке знает об этих проблемах, и в этой статье мы хотим поделиться опытом внедрения широко известного в узких кругах инструмента — сборочной системы FASTBuild.

График изменения времени сборки Renga
График изменения времени сборки Renga

Несмотря на то, что время компиляции продолжает расти с развитием языка, есть ощущение, что модель компиляции предлагаемая C++ сегодня:

  1. Универсально масштабируема — отсюда россыпь инструментов для распределенной сборки (icecream, distcc, Incredibuild).

  2. Относительно проста на первых этапах, что позволяет изобретать разные приемы ускорения как, например, Unity.

Надо сказать, что эти наблюдения не относятся к С++20 и модулям.

Способы ускорения сборки можно условно разделить на внутренние, структурные — pimpl, разбиение на модули, уменьшение связности кода, и внешние, инструментальные — распределённая сборка, Unity, кэширование. В этой статье поговорим о внешних способах ускорения.

Renga это программа для проектирования зданий, работающая под Windows и написанная на C++. 

Модель и чертежи здания в Renga
Модель и чертежи здания в Renga

Продолжительное время в компании Renga Software использовалось известное коммерческое решение для распределённой сборки. На рубеже очередного лицензионного платежа команда разработки Renga решила посмотреть в сторону других инструментов. Этому было несколько причин: хотелось ускорить сборку, свободно использовать офисное железо, не зависеть от лицензий, отвязать сборщика от IDE и заложить фундамент сборки под другие операционные системы.

К чему мы пришли с момента смены системы сборки

Переход

Для сборки Renga используются Windows C++ инструменты (cl, link, ..), Windows kit инструменты (rc, fxc, midl, ..), Qt инструменты (rcc, moc, ..). При переходе на новую систему сборки набор инструментов почти не изменился. Изменилась программа, которая эти инструменты вызывает, вместо MSBuild стал использоваться FASTBuild.

Преследуя цели ускорения компоновки, мы стали использовать компоновщик lld вместо link.exe и диспетчера lib.exe из поставки Microsoft. На наших проектах линковка с lld происходит заметно быстрее.

Результаты перехода на конвейере

  • Время сборки программы с чистого репозитория на CI уменьшилось с часа до 6-10 минут.

  • Агенты CI обмениваются кэшем, несколько модулей программы собираются в Unity. Код собирается распределённо в случае кэш промахов.

  • К распределённой сборке подключены все компьютеры компании.

  • Объём кэша растёт на 100Гб за двухнедельный спринт и подрезается по выходным.

Результаты перехода на рабочем месте

  • Время сборки программы на средней офисной машине уменьшилось с полутора часов до 10-12 минут и это не предел.

  • Пропала необходимость в болезненном слиянии vcxproj файлов. Функции FASTBuild позволяют подключать исходные файлы по шаблону. Например *.cpp.

Про wildcard globbing

Вообще, в системах сборки (о них подробнее ниже) глоббинг не рекомендуется к употреблению. Такую рекомендацию приводят в Meson, CMake. Объясняется это например тем, что при таком подходе в метасборочной системе у коллег может оказаться неактуальный бэкэнд, который может приводить к некорректной сборке. В нашем случае бэкэнд всегда актуален, разве что системе потребуется достроить дерево зависимостей после обновления из СКВ. Проблем с производительностью при рекурсивных wildcard шаблонах мы пока не заметили, в том числе для no-op сборок.

Выбор системы сборки

Так сложилось, что язык C++, да и каждый проект нетривиальной сложности на C++ ищет своё звучание в смысле сборки. Стандарта сборки или стандартного пакетного менеджера не существует. 

Системы сборки можно разделить на два класса: сборочные и метасборочные. Сборочные непосредственно собирают программу, вызывают инструменты компиляции, кодогенерации, компоновки. Метасборочные же порождают конфигурации для сборочных. Наиболее яркий пример метасборочной системы это CMake, но он не единственный, есть и другие: meson, waf, premake, gn. Из сборочных стоит отметить Bazel, ninja.

Сборочные системы
Сборочные системы

Изначально мы ставили перед собой цель внедрения именно FASTBuild, но решили сперва попробовать перейти на какую-нибудь из метасистем, чтобы получить больше возможностей. Оказалось, что работающих генераторов FASTBuild бэкэнда не существует. MR в CMake медленно продвигается, premake5/Genie нам не подошли, а Sharpmake мы просто упустили из виду ????.

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

Почему мы выбрали FASTbuild

"FASTBuild is a high performance, open-source build system for Windows, Linux and OS X. It supports highly scalable compilation, caching and network distribution."

FASTBuild — сборочная система родом из индустрии разработки видеоигр. Поскольку Renga — это большая и монолитная программа для Windows на C++, то с точки зрения сборки она похожа на видеоигры.

FASTBuild — не метасборочная система. Пользователь fastbuild конфигурирует сборщика с помощью встроенного языка, а затем параметризует вызов сборщика с помощью цели.

Пока мы изучали систему, отметили такие возможности:

  • Для описания конфигурации сборки используется собственный язык. Это одновременно и хорошо и плохо. Плохо, просто потому что нужно освоить ещё один язык. Хорошо, потому что язык не очень сложный.

О языках описания сборки

Дело в том, что судя по всему, в вселенной С++ пока не сложилось общего понимания, как все-таки правильно относиться, как мыслить сборку программ. Если мы говорим, что сборка программы это такая же программа, то почему же мы не пишем эту программу на языках общего назначения? Scons и Python, Premake и Lua, есть предложения о сборке на языке С++. Зачем человечество раз за разом придумывает новый dsl? На это обычно возражают тем, что универсальная гибкость и полнота языков общего назначения не позволяет нормально судить о сборке, анализировать и делать какие-то выводы, соблюдать требования (например нулевое время тривиальной no-op сборки, когда уже всё собрано). В общем, мы начитались этих споров и поехали дальше.

  • Для ускорения сборки FASTBuild использует: Unity, распределенную сборку и кэш. Более того, система устроена таким образом, что все три способа дополняют друг друга — Unity файлы могут кэшироваться или отправляться по сети помощникам.

  • Для получения статистики доступны встроенные инструменты: выдержки, отчёты, флеймграфы, отрисовка графа зависимостей цели или отдельного файла.

    Статистика сборки
    Статистика сборки
  • Возможна интеграция с Visual Studio.

  • Сompilation database генерируется в json, кроме того существует поддержка форвардинга команд сборки в wsl

  • Исходный код FASTbuild на C++ — открытый и понятный.

Подробнее о языке конфигурирования

Система предлагает описывать схему собираемой программы на языке bff файлов. В bff файлах конфигурации описываются цели сборки. Для этого используются функции языка. Набор целей определяет построение внутреннего графа зависимостей.

; =============================================================================
; Hello World
; =============================================================================
 
; Подключим объявления инструментов и среды
#include "config.bff"
 
; Объявим функцию или цель - скомпилировать всё в директории /
; и положить объектные файлы в _out/
ObjectList('HelloWorld-Cpp')
{
    .CompilerInputPath  = '/'
    .CompilerOutputPath = '_out/'
}
 
; Объявим цель, компоновка выполняемого файла
; В качестве зависимости укажем именованную цель объявленную выше
Executable('HelloWorld')
{
    .Libraries          = { 'HelloWorld-Cpp' }
    .LinkerOutput       = '_bin/helloworld.exe'
}
 
; После всех объявлений можно вызывать fbuild.exe HelloWorld
; Для удобства объявим псевдоним all, который позволит
; запускать fbuild без указания цели
Alias('all') { .Targets = { 'HelloWorld' } }
 

Настройка распределенной сборки

Для поддержки распределённой сборки в поставку FASTBuild входит отдельная программа FBuildWorker.exe. Сборка происходит с помощью хоста, помощников и маклера:

  • Хост — машина инициирующая сборку (машина разработчика или агент конвейера).

  • Помощники — машины, где запущен FBuildWorker.exe.

  • Маклер (FASTBUILD_BROKERAGE_PATH) — общая сетевая папка, куда запущенные FBuildWorker.exe сигнализируют о доступных мощностях, а именно кладут текстовый файл со своими идентификаторами.

Процесс распределённой сборки выглядит примерно так:

  1. Хост находит свободного помощника и синхронизирует с ним инструмент нужный для выполнения задачи. Например cl.exe и его dll зависимости для компиляции файла на C++.

  2. В случае C++ компиляции хост запускает препроцессор и отправляет .cpp файл помощнику после препроцессинга.

  3. Помощник запускает компилятор и отправляет результат обратно хосту.

Использовать сеть помощников можно не только для C++ компиляции. Любой инструмент, удовлетворяющий требованиям, может быть задан в специальную функцию .Compiler и выполняться на удалённых помощниках. Требование для такого инструмента одно: “компилятороподобность”, т.е. на вход должен подаваться ровно один исходный файл, на выходе должен получаться ровно один объектный файл, без побочных артефактов или множественных входных данных. Например, некоторые Qt инструменты (moc, uic, rcc), которые мы используем для разработки UI, работают распределённо без дополнительных настроек. Кроме того, пробуем использовать полученную сборочную инфраструктуру для ускорения прогона интеграционных тестов.

Устройство кэша

В процессе сборки система может кэшировать результаты на сетевом ресурсе, а именно объектные C++ файлы. Для этого используется составной ключ:

  • Хэш исходного файла после препроцессинга.
    Репозиторий с кодом должен быть расположен по одному и тому же пути на всех хостах.

  • Хэш опций вызова компилятора.

  • Хэш программы компилятора.
    Это означает, что для корректного использования кэша у всех хостов должен быть одинаковый компилятор.

  • Хэш precompiled header’a при наличии.

  • Версия кэша.

Устройство кэша
Устройство кэша

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

Кроме того, для использования кэша нужно соблюсти требование: компилировать код с /Z7, а не с /Zi. Для некоторых наших библиотек это обернулось тем, что COFF файлы стали выходить за предел в 4Гб. Это разумеется было воспринято как сигнал к тому, что эти библиотеки нужно немедленно архитектурно перерабатывать собрать с помощью Unity или механически нарезать с помощью возможностей системы сборки, что мы и сделали. А после этого собрали проблемные библиотеки с помощью Unity, что позволило ощутимо сократить объемы объектных файлов.

Хост может выступать в роли читателя и/или писателя кэша. Предельный случай использования распределённого кэша: агенты CI конвейера пишут кэш, а рабочие места только читают.

Страница про кэш из документации

Как решить проблемы кэширования

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

  1. Установить Windows и git

  2. Склонировать репозиторий

  3. Запустить fbuild.exe 

В клонируемом репозитории должны находиться:

  • Весь исходный код проекта.

  • Все внешние зависимости. Включая код, собранные библиотеки и отладочные символы (если возможно).

  • Программа сборки fbuild.exe.

  • Весь необходимый набор инструментов (компилятор, компоновщик, диспетчер библиотек, ..) и стандартные библиотеки C++.

  • Платформенные SDK.

  • Все необходимые файлы для сборки: ресурсы, шейдеры, картинки, ..

Преимущества такого подхода:

  • Очень простой. Не требуется привлекать другое ПО.

  • На всех рабочих местах и сборщиках используется одна и та же версия всего.

  • Тривиальные обновления и откаты — используется только система контроля версий.

  • Простое добавление новой машины, рабочего места или сборщика. Не нужно ничего устанавливать.

С помощью описанного подхода и стандартизации пути к репозиторию нам удалось подключить систему кэширования на CI конвейере.

Ускорение сборки с помощью Unity*

*Unity, jumbo, cake, SCU, munge build

jumbo

В Google однажды предложили jumbo из-за перегруженности термина Unity.

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

Пример сгенерированного Unity файла:

// Auto-generated Unity file - do not modify
 
#pragma message( "C:\Project\1.cpp" )
#include "C:\Project\1.cpp"
 
#pragma message( "C:\Project\2.cpp" )
#include "C:\Project\2.cpp"
 
#pragma message( "C:\Project\3.cpp" )
#include "C:\Project\3.cpp"

В системе FASTBuild для создания Unity используется функция Unity. Unity функционально встроены в язык конфигурирования и интегрированы с другими возможностями системы: Unity это такие же задачи для помощников и элементы кэша как и обычные исходные файлы. Их следует считать артефактами сборки и не хранить в СКВ.

Ясно, что этот способ в пределе ставит точку на параллелизме вызова компилятора даже на одной машине. Задача пользователя — подобрать подходящее количество Unity файлов на модуль программы.

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

Как система интегрируется с IDE

В системе есть возможность генерации проектов для Visual Studio, для этого используется функция языка VCXProject. Такие проекты существенно отличаются от типичных бэкэндов, порождаемых метасборочными системами, и не приводят к дополнительному звену в процессе сборки. Формально полученные таким образом .vcxproj файлы используют MSBuild в качестве бэкэнда, но они не содержат настроек сборки, кроме FASTBuild вызовов в NMAKE тэгах. Фактически это просто другая реализация возможности Open Folder. Из этого в частности следует, что сборку проекта можно настроить для любого современного редактора кода, у нас это получалось с VS code. С другой стороны подобное устройство VS проекта может означать отсутствие каких-то возможностей IDE. Например, Ctrl+F7 перестаёт работать на таких проектах. Нам частично удалось с этим справится с помощью обходного решения - внешнего скрипта на Powershell.

Проект состоит из трёх частей:

  • Перечисление исходных файлов для доступа к ним из Solution Explorer. Все файлы цели можно получить, например, рекурсивным образом, задав такой же путь, как у цели.

  • Команды вызова сборщика fbuild.exe для сборки, пересборки, очистки проекта (т.е. цели или набора целей заданных с помощью Alias) в тэгах NMakeBuildCommand/NMakeRebuildCommand/NMakeReBuildCommandLine.

  • Набор путей к заголовочным файлам для поддержки работы Intellisense.

Такой проект это артефакт сборки и не хранится в репозитории.

Препроцессор — ключевой инструмент системы

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

Из этого есть следствия: 

  • Система отчасти настолько производительна насколько эффективен препроцессор используемого компилятора. Т.е. свежие выпуски компиляторов с оптимизированными препроцессорами могут ускорять сборку на FASTBuild.

  • Хост распределённой сборки всё равно остается узким местом в процессе сборки.

  • Тяжёлые Precompiled Header'ы могут даже замедлить распределённую сборку, потому как трактуются как обычные заголовочные файлы при отправке на помощников.

  • Встроенная система кэширования не поможет, если основная часть времени компиляции файла проходит в препроцессинге.

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

Заключение

Проект собирается системой сборки FASTBuild уже больше года, целей перехода которые мы перед собой ставили удалось добиться: ускорение сборки, независимость от платформы, IDE и лицензий. 

Неудобства, замеченные при ежедневной эксплуатации:

  • Поддержка сборки проекта без Unity ради соблюдения корректного набора директив #include в исходных файлах. 

  • Проблема мультипроцессной сборки и директивы #import несколько усугубилась. Компиляция модуля, где встречаются директивы #import в разных единицах трансляции, не допускает параллелизма, потому что #import создает общий ресурс в виде побочного артефакта обработки таких файлов. Обычно это решается с помощью флага /MP- в Visual Studio. С FASTBuild так не выходит и приходится складывать артефакты препроцессора в СКВ.

  • Некоторые инструменты не интегрированы с FASTBuild в полной мере. Так, например, у ресурсного компилятора rc.exe и у шейдерного компилятора fxc.exe есть собственные системы включения заголовочных файлов. Дерево зависимостей не отслеживается системой сборки, это иногда приводит к неправильной сборке.

  • Не получилось удобно работать с MIDL компилятором. Дело в том, что при работе с этим инструментом в общем случае может быть много артефактов. А сборщик полагается на то, что у компилятора должен быть ровно один выходной файл для расчёта зависимостей и инкрементальной сборки.

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

Федя Зенцев

Автор статьи, программист, Renga Software

Опрос про Unity

Мы попробовали использовать Unity для набора больших библиотек в составе Renga, в целом считаем опыт положительным: плюсы для сборки нашего проекта перевешивают минусы. 

Unity на протяжении лет остаётся спорным приёмом. С одной стороны такой способ ведёт к огромному выигрышу в скорости сборки и размере объектных файлов. С другой — рассматривается как обходное решение, накладывает ограничения на валидный C++ и требует поддержки. Например, в системе сборки gn для Chromium какое-то время существовала поддержка Unity и, более того, была попытка доработать плагин для clang, который бы упрощал поддержку Unity, но с недавних пор разработчики её убрали, что вызвало понятное недовольство разработчиков вне Google, у которых нет доступа к мощной офисной инфраструктуре распределённой сборки, кэша.

Периодически споры о Unity возобновляются и в команде Renga, поэтому хочется узнать ваше мнение.

Комментарии (8)


  1. Whiteha
    27.10.2021 13:33
    +1

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



    1. vines
      28.10.2021 09:34
      +1

      Объёмы запоминающих устройств и битрейт каналов связи уже сейчас позволяют не слишком сильно переживать по поводу этих "лишних" данных, и продолжают расти и дешеветь. Объектные файлы при сборке требуют десятки гигабайт хранилища, при распределённой сборке всё это прокачивается по сети туда-обратно, и на таком фоне сэкономить даже 1ГБ бинарников для сборки выглядит просто незначительным)


  1. droptable
    27.10.2021 14:00

    Очередной лицензионный платеж всегда заставляет пересмотреть свои взгляды =)


    1. Rengabim Автор
      27.10.2021 14:18

      Есть такое :)


  1. Punk_Joker
    28.10.2021 11:13
    +1

    Рассматриваете переход на С++ с модулями? Наколько я понимаю, модули существенно ускоряют и упрощают компиляцияю из коробки.


    1. Rengabim Автор
      28.10.2021 15:22
      +1

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

      >Наколько я понимаю, модули существенно ускоряют и упрощают компиляцияю из коробки.

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

      Кстати занятно, что первый же комментарий под докладом про jumbo в chromium ????


  1. V_oron
    10.11.2021 15:37

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


    1. Rengabim Автор
      12.11.2021 19:19
      +1

      Правильно ли я понимаю, что если не рассматривать распределенную сборку, то и кратного ускорения не получится?

      Зависит от кодовой базы, но кажется что скорее Unity приводит к кратному ускорению в большинстве случаев.

      Распределенная сборка усилила другие возможности после того как мы стали их использовать. Само по себе распределение задач по сети на нашей кодовой базе давало небольшой выигрыш (или даже замедление в патологических случаях) из-за особенностей PCH и других нюансов. Однако же на других проектах в компании одной распределённой сборки хватило для ускорения в 10 раз.

      • Для CI конвейера наиболее заметное ускорение произошло после настройки распределённого кэша. В этом случае распределённая сборка помогает при кэш промахах.

      • Для рабочего места наибольшего прироста добиваемся с помощью Unity. В этом случае сеть помощников помогает собирать тяжелые Unity файлы