Всегда хотел написать о чем-нибудь легком и воздушном, как пишет например @antoshkka про userver или о том, как легко и непринужденно обернуть какую-нибудь хрень алгоритм в десяток шаблонов, полить это все std::optional и попивая кофе ждать, когда компилятор соизволит это всё пережевать. Но судьба (а не тимлид, нет, как вы могли такое подумать) постоянно подкидывает задачки, где суровые объятия отладчика не отпускают мечтательную душу программера до поздней ночи, да вечная борьба с компилятором рушит все попытки обернуть результат хрени алгоритма в другой десяток шаблонов. На этот раз судьба ясным июньским утром подкинула забавную задачу - время полной сборки бандла подбиралось к двум часам, да собирать бандлы нынче удовольствие не из быстрых, но посмотрев статистику стало понятно, что ~55% процентов времени тратится на сборку ресурсов: текстур, моделей, локализацию, и тд. Там есть что чинить, но это царство билд-инженеров. Еще 30% или сорок минут тратится на тесты, теперь все что мы насобирали и переконвертили надо проверить, загрузить, пострелять, побегать, монстров поубивать, BT-шки погонять, с этим пусть QA разбираются. А вот оставшиеся 15% или около 15 минут мы занимались "настоящей работой", собирали сердце проекта - бинарь. Да норм, у нас всегда так, даже на пустом проекте UE - сказали наши мобильщики и ушли пить кофе на терассу . Но мы же не мобильщики, мы серьезные AAA ребята, у нас свой движок и кастомный пайплайн на билдферме. И потом 15 минут это всё равно много, даже если у тебя 27к файлов в проекте, айда смотреть куда время потратили.
Хитрый тимлид
Но сначала с этими вопросами я пошел к архитекту и техлиду. 15 минут - ну реально много. Техлид ничем не помог ибо майлстоун и вообще фичи пилить нада, а от архитекта были только вопросы вроде «Репа на SSD?», «А на рам диске смотрели?» «А что насчет [вставьте здесь что-то свое]?». И да в принципе он прав, и нет, я не тестировал ничего из этого, потому что было лень возиться с новой погремушкой, и потому что мой домашний питомец из 2.5к файлов собирается за 35+- секунд с нуля. Экстраполируя эти данные -
27к должны собираться примерно 35 * 10 = 6 минут. А вообще старожилы на проекте говорили, что несколько лет назад проект собирался за 3 минуты, а файлов там было не так, чтобы сильно меньше.
15 минут 32 секунды (Чиним паразита)
Наверное можно попробовать обвинить «жЫрные» шаблоны, компиляторы, медленные компьютеры или Луну не в той фазе. Но самый простой способ выяснить, в чем заключается основная проблема — это не сразу запускать любимый (procmon, vcperf, и clang-trace, выбери своё) или проводить какие-то безумные научные тесты, как я. Для начала достаточно просто посмотреть на процессы с наибольшей нагрузкой и их время работы на ЦП.
Итак прежде чем открыть vcperf я решил заглянуть в task manager, и вижу там очень странную картину, студия отжирает себе 17% времени cpu, а еще браузер, антивирус и explorer. Что-то тут не так, с какого перепугу там вообще светятся все эти товарищи?
Не смотрите, что старенькая студия - проект прогоняется на нескольких компиляторах.
Chrome. Ладно браузер, это известный пожиратель всего, до чего сможет дотянуться, но тут ничего не поделаешь, либо документация и котики, либо время компиляции, так и быть пусть живёт. Обратите внимание, это пока всё очень не научно, я просто исхожу из того, что чем меньше других процессов, тем больше времени достанется компилятору.
Проводник?! Что ты тут вообще делаешь проводник? Ну во первых это не только инструмент для управления файлами и каталогами, это ещё и оболочка. Если его кикнуть, то исчезнут рабочий стол, пуск и, по сути, всё остальное. Тут только ругать криворуких индусов, которые его так написали. Во-вторых наш билд поднимал отдельное консольное окно, куда кидал всякое разное во время сборки из пайпа студии и куда могли цепляться другие тулы для анализа, например сборщик ассертов или других внутренних предупреждений о ходе сборки. И судя по ProcessExplorer консоль тоже управляется через explorer. Но это не причина, просто выключив эту консоль получили прирост что-то около 15с, да 15с это много, но это явно не причина.
Visual Studio?! Родная, ты же должна была себе забрать 100% ресурсов, и компилить-компилить-компилить.
Ииииииии... Я скомпилиииииииииииила!
> Build succeeded.
> 0 Error(s)
> Time Elapsed 00:15:32.530
Antimalware Service Executable, антивирус, и как любой антивирус, он будет серьезно смотреть что мы делаем - а мы там плюшками балуемся, перебираем 27к файлов, и создаем еще порядка 60к файлов объектников и временных tmp файлов. Похоже доблестные DevOps забыли внести репу в список исключений. Поправим это упущенеие. Результат: получили примерно 10% ускорение общего времени компиляции. Было 15:32с, стало 14:12с. Приятно, но это явно не серебрянная пуля, хотя и маленькая победа.
> Build succeeded.
> 0 Error(s)
> Time Elapsed 00:14:12.240
Так, вроде бы ничего больше танцору не мешает, но время компиляции все равно 14 минут. Открываем vcperf
и смотрим куда уходит время, а уходит оно на компиляцию и парсинг хедеров. Так появилась идея посмотреть, сколько конкретно времени мы тратим на обработку каждого файла. Полем проверки для последующих изменений стал рабочий проект, целью было сделать время сборки как можно меньше. Возможно, какое-то время тратится на запуск компилятора, но не будем углубляться в этот момент. Так как у нас довольно большая кодовая база, то найденные зависимости помогли неплохо снизить время сборки.
! Извините, пути пришлось закрасить. CM не пропустил :(
В результате этой работы получился набор тестов и как результат - сводная таблица времени сборки заголовочных файлов. Я стараюсь избегать обобщения результатов, потому что эти тесты проводились на одной машине, с одной конкретной целью. Поэтому выложу сырые данные для сравнения на нескольких компиляторах MS и Clang (Sony PS5). Это позволяет видеть зависимости в результатах, и надеюсь будет, как мне кажется, интересно большинству хабрачитателей.
Результаты по компиляторам
Header |
VS17 |
VS19 |
clang (16.0) |
algorithm |
0.169 |
0.191 |
0.316 |
array |
0.184 |
0.253 |
0.106 |
atomic |
0.326 |
0.405 |
0.659 |
bitset |
0.329 |
0.415 |
0.527 |
cassert |
0.01 |
0.009 |
0.017 |
chrono |
0.144 |
0.195 |
0.306 |
Другие хедеры
Header VS17 VS19 clang
climits 0.01 0.009 0.018
cmath 0.036 0.045 0.084
condition_variable 0.286 0.372 0.51
cstdint 0.011 0.009 0.018
cstdio 0.143 0.143 0.173
deque 0.183 0.216 0.439
filesystem 0.383 0.516 0.289
forward_list 0.183 0.214 0.338
fstream 1.271 1.331 1.476
functional 0.287 0.222 0.561
future 1.059 1.317 2.474
initializer_list 0.011 0.013 0.022
ios 0.259 0.318 0.456
iosfwd 0.08 0.094 0.172
iostream 2.264 2.325 3.464
istream 1.264 1.324 1.463
iterator 0.265 0.327 0.468
list 0.183 0.214 0.338
map 0.194 0.231 0.863
memory 0.173 0.2 0.324
mutex 0.28 0.364 0.598
ostream 1.261 1.321 0.459
queue 0.198 0.235 0.374
random 0.305 0.394 0.553
regex 2.392 1.505 1.634
set 0.185 0.217 0.341
sstream 0.329 0.416 0.528
stack 0.186 0.216 0.341
string 0.327 0.413 0.523
thread 0.227 0.289 0.448
tuple 0.123 0.163 0.263
type_traits 0.043 0.06 0.096
typeinfo 0.051 0.068 0.107
unordered_map 0.204 0.445 0.184
utility 0.098 0.127 0.212
vector 0.285 0.217 0.244
windows.h 4.423 4.517 7.038
Вы наверное заметили, что большинство тяжелых для компиляции хедеров (за исключением windows.h и iostream), а они реально тяжелые если время на их обработку больше 100мс, это шаблоны.
Header VS17 VS19 clang
algorithm 0.169 0.191 0.316
array 0.184 0.253 0.106
atomic 0.326 0.405 0.659
bitset 0.329 0.415 0.527
iterator 0.265 0.327 0.468
vector 0.285 0.217 0.244
Шаблоны — одна из самых любимых мной возможностей C++, позволяющая мне писать так как я хочу, а не так, как этого требует стандарт. Шутка! Но, блин, почему они настолько медленные? Что там в array
такого, что оно компилится 200мс, там же просто обертка над массивом. Заголовочный файл с шаблонами может быть включен только один раз, но реализация компилируется для каждой комбинации аргументов для каждого юнита компиляции. И дорогие шаблоны могут значительно увеличить время компиляции, что мы собственно и получили у себя. Возможно, там какие-то проблемы с инстанцированием, память там стеке разместить, проверки разные. Или вот вектор, тоже вроде ничего сложного должно быть, но тоже 200 с лишним мс на всех компиляторах.
Я посмотрел на godbolt (/d1reportTime) (https://godbolt.org/z/WEqx7zW8r), clang такого к сожалению не умеет, сколько занимает время компиляции каждой функции vector
.
std::vector<int,class std::allocator<int> >::_Construct_n: 0.000566s
std::vector<int,class std::allocator<int> >::vector: 0.000564s
std::vector<int,class std::allocator<int> >::_Tidy: 0.000341s
std::vector<int,class std::allocator<int> >::_Buy_raw: 0.000262s
std::_Compressed_pair<class std::allocator<int>,class std::_Vector_val<struct std::_Simple_types<int> >,1>::_Compressed_pair: 0.000248s
std::vector<int,class std::allocator<int> >::max_size: 0.000244s
std::_Vector_const_iterator<`template-type-parameter-1'>: 0.000295s
std::_Vector_iterator<`template-type-parameter-1'>: 0.000184s
std::_Vector_val<`template-type-parameter-1'>: 0.000096s
std::vector<`template-type-parameter-1',`template-type-parameter-2'>: 0.001980s
std::vector<int,class std::allocator<int> >: 0.002384s
std::allocator_traits<class std::allocator<int> >: 0.000518s
std::_Default_allocator_traits<class std::allocator<int> >: 0.000451s
std::allocator<int>: 0.000265s
std::_Compressed_pair<class std::allocator<int>,class std::_Vector_val<struct std::_Simple_types<int> >,1>: 0.000274s
std::_Vector_val<struct std::_Simple_types<int> >: 0.000145s
std::_Simple_types<int>: 0.000035s
std::vector<int,class std::allocator<int> >: 0.002384s
std::vector<`template-type-parameter-1',`template-type-parameter-2'>: 0.001980s
std::vector<bool,`template-type-parameter-1'>: 0.000774s
std::_Vector_const_iterator<`template-type-parameter-1'>: 0.000295s
std::_Vector_iterator<`template-type-parameter-1'>: 0.000184s
std::_Vector_val<`template-type-parameter-1'>: 0.000096s
std::vector<int,class std::allocator<int> >::operator []: 0.000081s
std::vector<int,class std::allocator<int> >::~vector: 0.000029s
std::vector<int,class std::allocator<int> >::vector: 0.000387s
std::allocator<int>::allocator: 0.000017s
std::vector<int,class std::allocator<int> >::_Tidy: 0.000232s
std::vector<int,class std::allocator<int> >::_Getal: 0.000028s
std::_Compressed_pair<class std::allocator<int>,class std::_Vector_val<struct std::_Simple_types<int> >,1>::_Compressed_pair: 0.000172s
std::vector<int,class std::allocator<int> >::_Construct_n: 0.000404s
std::_Vector_val<struct std::_Simple_types<int> >::_Vector_val: 0.000045s
std::vector<int,class std::allocator<int> >::_Buy_nonzero: 0.000047s
std::vector<int,class std::allocator<int> >::_Xlength: 0.000024s
std::vector<int,class std::allocator<int> >::_Buy_raw: 0.000177s
std::vector<int,class std::allocator<int> >::max_size: 0.000169s
std::vector<int,class std::allocator<int> >::_Getal: 0.000029s
std::vector<int,class std::allocator<int> >::_Construct_n: 0.000404s
std::vector<int,class std::allocator<int> >::vector: 0.000387s
В сумме набралось: 0.326898s
Z:\compilers\msvc\14.41.33923-14.41.33923.0\include\vector: 0.326898s
Это крошечные времена. Они даже особо не важны, правда? Кому важен процесс компиляцииstd::vector::<>max_size -> 0.000169s
если он занимает две десятых миллисекунды? Это настолько несущественно, чтобы волноваться...
14 минут 12 секунд (Мучаем шаблоны)
> Build succeeded.
> 0 Error(s)
> Time Elapsed 00:14:12.240
Волноваться стоит если у вас 27к файлов в проекте. 27k * 0.326 = 8 880 секунд - почти три часа :) Хорошо, что у нас вектор не в каждом файле используется. 14 минут, конечно не три часа, но тоже много, давайте смотреть как уменьшить это время. Стоимость использования шаблона состоит из двух вещей, время на парсинг заголовочного файла #include
и время на инстанцирование шаблона для заданного типа. Когда обрабатывается юнит компиляции (файл .cpp), препроцессор загружает каждый заголовочный файл, который он находит в директивах #include
этого файла и всех последующих хедеров. Заголовочные файлы обычно имеют защиту от рекурсии include guards
, чтобы предотвратить повторную загрузку, поэтому каждый заголовок обычно загружается только один раз (обычно, потому что все равно можно словить скрытую рекурсию).
В отличие от кода без шаблонов, каждое инстанцирование, обращение и даже указатель на шаблонный класс требует компиляции всех его использованных и разыменованных членов. VS позволяет более вольно обращаться с этим правилом, но clang требует инстанцирования всего шаблона, а не только его частей.
В случае с большой иерархией включений например мегазаголовки, это может означать сотни (СОТНИ!) уникальных типов вектора в одном юните компиляции. Спасения здесь нет, даже то, что компилируются только те вещи, которые действительно используются, позволяют порезать время на проценты, но не в разы, так что если, например, метод std::vector::push_back
никогда не вызывается и не компилируется, он все равно будет распаршен компилятором каждый раз и подготовлен для компиляции. А почему? А вот! Сделано это было для ускорения сборок, если такой метод уже есть в кеше компилятора, то его подготовка занимает меньшее время. И ребята из МС и кланга дружно подумали, а давайте мы будет заранее класть все встречающиеся методы в кеш, авось понадобятся.
Если используются 50 различных типов шаблонов вектора, то стоимость компиляции этих шаблонов оплачивается 50 раз. И это только для одной единицы трансляции, следующая единица трансляции снова платит за все это. !Профит. Давайте попробуем это исправить.
Forward Declaration
Если это возможно, надо использовать forward declarations. Это устраняет включение заголовочного файла, стоимость компиляции шаблона для конкретного типа. Не делать ничего — это лучшая оптимизация из тех, что я могу посоветовать. Шаблоны можно объявлять заранее, но тогда шаблон должен быть объявлен в хедере только через указатель или ссылку, и никогда не должен быть разыменован. Типы параметров шаблона также должны быть либо предварительно объявлены, либо полностью определены.
Иногда надо понять, что именно занимает время. Тут поможет флаг /d1reportTime
компилятора для VS, на тестах мне иногда приходилось компилировать по одной строке за раз с минимальными изменениями и фиксировать время, которое было нужно компилятору, а потом думать почему то или иное изменение приводит к росту времени компиляции. Se la vi, как говорится ¯_(ツ)_/¯. Это конечно смешно было ловить блох, но вот вам пример:
```
void vector<_TYPE_>::preallocate(const size_t count) {
if (count> m_capacity) { // 0.000023s
_TYPE_ * const data = static_cast<_TYPE_*>
(malloc(sizeof(_TYPE_) * count)); // 0.000076s
const size_t end = m_size; // 0.000028s
m_size = std::min(m_size, count); // 0.000402s
for (size_t i = 0; i < count; i++) { // 0.000042s
new (&data[i]) _TYPE_(std::move(m_data[i])); // 0.000148s
}
```
Заметили что-нибудь необычное? А если так?
```
void vector<_TYPE_>::preallocate(const size_t count) {
if (count> m_capacity) { // 0.000023s
_TYPE_ * const data = static_cast<_TYPE_*>
(malloc(sizeof(_TYPE_) * count)); // 0.000076s
const size_t end = m_size; // 0.000028s
if ( count < m_size ) { m_size = count; } // 0.000012s
for (size_t i = 0; i < count; i++) { // 0.000042s
new (&data[i]) _TYPE_(std::move(m_data[i])); // 0.000148s
}
```
Избыточность шаблонов
Дублирования в шаблонах достаточно много, но именно поэтому это и называется шаблоном, добавляя избыточность в одном файле мы убираем её во всех остальных местах
и платим за это временем компиляции. Каждый раз, когда шаблон инстанцируется с новым типом, все использованные члены компилируются заново, с единственное отличием — с другим типом. Ктор, дтор, копирование, операции перемещение, но остальная часть кода остаётся идентичной. И чем больше методов класса, тем дороже инстанцирование шаблона. А если еще появляются шаблонные функции шаблонного класса, время начинает лететь в космос! Каждая пустая шаблонная функция стоит около 0.000030
секунд на компиляцию, и это ещё до того, как в неё будет добавлен какой-либо код. Помещая вызов одной шаблонной функции в другую, мы сильно увеличиваем время компиляции и оно очень нелинейно меняется.
Здесь важно понимать, что хотя оптимизация шаблонных функций может привести к небольшим улучшениям, она не всегда будет лучшим решением, поскольку сама природа шаблонов приводит к значительным затратам на компиляцию из-за множества уникальных инстанцирований. В данном случае, нужно искать способы минимизировать количество инстанцирований или сократить количество шаблонных функций, которые компилятор должен обработать.
Анализ зависимостей и растаскивание иерархий хедеров позволило сэкономить три минуты, время сборки проекта стало 11:25с. На всю эту работу, протаскивание этих задач через таски ушло пару месяцев рабочего времени, проект большой, за раз все не починишь, плюс приходится согласовывать свои изменения с другими командами. Но результаты были видны и поэтому решили эту работу продолжить.
11 минут 25 секунд (Готовим precompiled headers)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:11:25.210
Про include guards
я думаю вы все знаете, иначе бы не пришли читать эту статью :)
Дальнейшее растаскивание хедеров не давало уже существенных результатов, +-5 секунд к времени билда не считаются. Если вы заметили, то на предыдущем скрине functional
был во многих вызовах файлов. Это значит что проекту пришло время начать использовать PCH. Эта оптимизация была включена для большинства проектов, но для некоторых его частей выключена специально. Так как в тот код мне лезть не разрешили, поэтому особо там никакого прироста получить ну удалось, тем не менее минуту на существующем конфиге тоже сэкономили.
Как работают PCH
Если говорить в общих чертах, PCH
создается путем компиляции исходного файла (*.cpp) с использованием специфичных для компилятора флагов. Когда заголовочные файлы из иерархии включений обрабатываются, они проходят через препроцессор как обычно, но их бинарный результат сохраняется на диск. Когда этот PCH
используется в другом файле, его представление загружается, что позволяет пропустить многие шаги обработки и сэкономить время.
Преимущества PCH
заключаются в том, что они позволяют значительно ускорить время компиляции, особенно при многократном включении одних и тех же заголовочных файлов
в разных единицах трансляции. Например, если вы часто используете стандартные заголовочные файлы или системные библиотеки, предварительная компиляция этих
файлов в PCH
может существенно сэкономить время.
Недостаток PCH
— это довольно дорогая операция, которая часто требует гораздо больше времени, чем преимущества, которые оно приносит. Каждое изменение в любом из заголовочных файлов, входящих в PCH, приводит к его пересборке. Второй неочевидный недостаток если вы включите слишком много хедеров начнут влиять уже чисто физические особенности работы с файлами, на скрине ниже наш общий PCH файл перевалил за 4Гб. Я не знаю, что там студия такого делает, чтобы получить такие объемы, но загрузка такого объема стала просто долгой по времени и сьела все преимущества, в этоге пришлось разделить проекты и каждому сделать свой отдельный PCH
файл. Заодно получилось почистить сами PCH
конфиги и снизить их объем до 1ГБ каждый, это позволило сэкономить еще где-то минуту c хвостиком.
10 минут 13 секунд (Упрощаем зависимости)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:10:13.410
Чем больше заголовочных файлов включается, тем дольше происходит компиляция. Заголовок, который включает всё подряд, начитает тормозить всё вокруг вас. Системные заголовки закончились, и мы стали смотреть уже на время компиляции файлов движка и игры, одним из решений стало использовать глобальные хедеры с объявлением основных типов, которые используются. Это позволило не только упростить внутреннию иеррархию заголовоков, но и существенно порефакторить зависимости между модулями, которые и приводили к беспорядочным включениям. А заодно найти проблему с включением windows.h
, в некоторых файлах, откуда он пролезал по всему проекту и увеличивал время компиляции. Избавление от сверх связаности в иерархии объектов и включения windows.h
позволило сэкономить еще секунд 40.
9 минут 31 секунда (Оживляем PIMPL)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:09:31.350
В C++ доступ к приватным членам класса получить несложно (https://habr.com/ru/articles/762250/) , хотя они и скрыты от внешнего кода. Компилятор лишь немного усложняет доступ к ним, однако для внешнего кода иногда необходимо знать о приватных переменных, например чтобы определить размер и выравнивание объекта. Часто классы включают приватные функции и данные, хотя на самом деле они в хедере не нужны или вообще вредны и тянут за собой другие хедеры. Если данные или функции используются только в реализации, то можно сделать их видимыми только в этом модуле, что приводит к нескольким преимуществам:
Меньше работы для препроцессора — минимизация заголовков.
Снижение времени линковки — меньше символов в глобальных таблицах символов.
Метод PIMPL (Pointer to Implementation — указатель на реализацию) помогает скрыть данные и функциональность, уменьшая изменения в публичных заголовочных файлах, что в свою очередь уменьшает время компиляции. Этот подход помогает «закрыть» детали реализации от внешнего мира, но имеет свои недостатки в виде дополнительных расходов на выделение памяти и обращение по указтелю. Но в определенных реализациях, где перф не стоит на первом месте мы можем позволить себе развязать зависимости там, где это не получается через forward declaration
. Получилось не супер красиво, но позволило сэкономить еще 20 секунд. Решение получилось спорным, поэтому решили дальше его не развивать.
9 минут 12 секунд (Отключаем анализаторы)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:09:12.130
Это ужасное предложение, не делайте так! Но если вас действительно беспокоит время компиляции, отключите анализ кода. Анализ значительно замедляет сборку... ооочень сильно. В нашем случае это занимало почти полторы минуты. Не делайте этого на своих проектах без весомых причин. Правильный код — это проверенный код, всем чем только можно, матрицей компиляции, десятком анализаторов и парочкой мудрых лидов. Неправильный код - весь остальной, быстро, но бесполезно и с ошибками. В итоге мы решили это на организационном уровне, PR запускались без анализатора кода, если PR компилился нормально и проходил минимальные тесты, то он уходил в дев, а билдферма запускала второй такой же, но уже с включенным анализом.
7 минут 34 секунды (Отключаем юниттесты)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:07:34.440
И вот мы пришли почти к 7 минутам, это хорошее время комиляции для большого проекта, из почти трех десятков тысяч файлов. Анализируя время, которое было потрачено на компиляцию, стало понятно, что наш разросшийся с прошлого апдейта игры блок тестов, который на тот момент достиг почти 3к различных проверок, занимает почти минуту времени на сборку, тесты эти не нужны в повседневной сборке, и обычный разработчик их никогда не запускал, а время они тратили. Поэтому с ними поступили также как с анализом кода - вынесли в отдельный шаг на билдферме после прогона PR.
6 минут 22 секунды (Отключаем LTO)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:06:22.240
Link Time Optimization (LTO) — это технология оптимизации, которая выполняется на этапе линковки приложения. Компилятор может оптимизировать код на уровне отдельного файла, но при использовании LTO компилятор анализирует сразу всю программу целиком. Это позволяет устранить неиспользуемые функции, более эффективно выстроить порядок вызовов и минимизировать накладные расходы. LTO разделяются на полное LTO (Full LTO), и Thin LTO. Thin LTO — это вариант, специально разработанный для ускорения линковки больших проектов, весь проект бьется на несколько частей и оптимизация проводится параллельно в каждой. LTO значительно увеличивает время компиляции, в нашем случае это занимало минуту с хвостиком для ThinLto, почти две минуты для Full. FullLTO по бенчмаркам давало прирост около 4%, ThinLTO - около 3%, полное оставили только для QA билдов, а всем остальным включили побыстрее. Итого еще минус минута на сборке.
5 минут 16 секунд (Финиш)
>Build succeeded.
> 0 Error(s)
>Time Elapsed 00:05:16.740
Итак финальное время сборки пять минут с хвостиком, считаю неплохо получилось. Не все конечно удалось решить только изменением в проекте, но пять минут, это пять минут - чашка кофе с круассаном, а не банка пива с бутером. Вообщем как обычно, потихоньку придумывали себе проблемы, а потом мужественно их чинили.
З.Ы.
Compiler Diagnostic
Для Microsoft Visual Studio существуют флаги, которые предоставляют информацию разной степени полезности:
/Bt+
— сообщает время компиляции передней и задней части компилятора для каждого файла. C1XX.dll — это передняя часть компилятора, которая отвечает за компиляцию исходного кода в промежуточный язык (IL). Время компиляции на этом этапе обычно зависит от времени работы препроцессора (включения, шаблоны и т.д.). C2.dll — это задняя часть компилятора, которая генерирует объектные файлы (преобразует IL в машинный код)./d1reportTime
— сообщает время работы передней части компилятора, доступно только в Visual Studio 2017 Community или более новых версиях. (Спасибо @phyronnaz и @aras_p)/d2cgsummary
— сообщает о функциях, которые имеют «аномальные» времена компиляции. Это полезно, попробуйте использовать.
Комбинирование этих флагов в Visual Studio предоставляет много информации о том, куда уходит время компиляции. Для clang есть флаг -ftime-report, советую посмотреть этот пост (https://aras-p.info/blog/2019/01/16/time-trace-timeline-flame-chart-profiler-for-Clang/), он довольно старый но принципиально ничего не поменялось.
Если у вас есть дополнения и предложения - пишите в коментах. ccache/ram disk не предлагать, дорого, много возни и мало профита.
Спасибо, что дочитали!
Комментарии (17)
Etlay
10.11.2024 19:04Анализ зависимостей и растаскивание иерархий хедеров позволило сэкономить три минуты, время сборки проекта стало 11:25с. На всю эту работу, протаскивание этих задач через таски ушло пару месяцев рабочего времени, проект большой, за раз все не починишь, плюс приходится согласовывать свои изменения с другими командами.
А оно того стоило, с точки зрения бизнеса? Обычно при таком соотношении трудозатрат/оптимизации просто ставят железку помощнее для билд агента. Получается дешевле чем стоимость работы программиста.
Я конечно понимаю что билдов собирается довольно много, особое если проект ААА. У нас на небольшом мобильном игровом проекте может до нескольких десятков сборок собираться в течении для. Но все таки - пару месяцев работы ради 3х минут сборки, кажется чересчур.
dalerank Автор
10.11.2024 19:04Это суммарно, я объяснил почему так получилось. Залить комит просто так не получится, особенно если он затрагивал какое-то легаси или чужой код на несколько тысяч строк, которое надо было разделить, согласовать с лидами других команд изменения, иногда убедить и показать снижение, 10-15 итераций на ревью это было ещё термином "проскочил неглядя" ;) и потом я не помню когда в час было меньше десятка комитов, собственно время компиляции и было основным мотиватором
alohaeee
10.11.2024 19:04В какой-то момент проблема перестаёт затыкаться железом, если мы говорим про локальные сборки. Может помочь распределённая компиляция и кеши, например Incredibuild. Но тут нужно смотреть на стоимость лицензий. Да и весь отдел должен находиться локально в одном офисе.
AtariSMN82
10.11.2024 19:0425K строк c++23 с пИмплами и урезаниями хедеров в многопотоке компилятся 2 мин (Ryzen 1700). Столько будет компилится хедер-онли код в 2К, если все файлы будут в .h
Cheater
10.11.2024 19:04А у вас сборка проекта на индивидуальных машинах всегда полная? Каждый пересобирает себе все 27 тыс файлов? Не думали зашарить например на команду дневные бинарные билды и пусть разработчики качают их себе и пересобирают локально только свои компоненты?
dalerank Автор
10.11.2024 19:04Вот тут еще (статье почти 7 лет) https://habr.com/ru/companies/pvs-studio/articles/344534/, пробовали incredibuild, icecream. Но похоже проект все же не настолько большой, чтобы почувстовать профит от инкредибилд, который к тому же требует от компаний больше 100 человек помашинную оплату на разработчика, тут уже дешевле еще пару лишних агентов поднять. Icecream подтупливал по сетке и профита не получалось. Плюс с нашими pch когда даже гиговый pch начинает перекидываться между агентами, то тоже ничего хорошего не выходит. Первая сборка после синка репы полная, стоило наверное рассказать об этом, часть хедеров генерится из конфигов, которые могут менять дизайнеры, это осознное решение, лучше один раз перекомпилить, чем потом ловить странные баги в игре. Потом уже докомпиливается конечно, на агента сборка всегда полная.
alohaeee
10.11.2024 19:04Нужно ли каждый раз собирать с нуля проект, после подтягивания репы? Даже если вы генерируете файлы, обычно правильная настройка add_custom_command в CMake позволяет отдать триггеры ребилда на откуп билд системе. В DEPENDS или в DEPFILE прописываются конфиги и перегенерация хедеров будет происходить только при изменении конфигов. https://blog.kangz.net/posts/2016/05/26/integrating-a-code-generator-with-cmake/
По моему опыту использования проблем с таким подходом нет, но может у вас что-то сильно экзотическое.
alohaeee
10.11.2024 19:04Статья классная, поставил плюс) Сам хотел поделиться опытом использования тулинга по профайлингу компиляции, но Вы опередили.
По pch - если архитектура проекта позволяет, можно использовать SHARED_PCH, экономя место на диске и переиспользуя pch между таргетами.
У -ftime-trace clang есть проблема с агрегацией данных по всем файлам. Удобно использовать такую утилиту:
https://github.com/aras-p/ClangBuildAnalyzer
Для сборки сninja скрипт построит flamegraph со всеми файлами:
https://github.com/nico/ninjatracingПосле прочтения появились вопросы:
Вы часто собираете релизные билды локально? Как так вышло, что lto попал в локальные билды разработчикам?
Нет ли проблем в процессе с выключенными аналзаторами? Вроде работа сделана, ПР залит в дев ветку, после сборки на агенте ещё ПР делать на исправление)
На билд агентах не используете unity батчинг? Локально, как я понял, не используете.
Пробовали ли extern template?
Sazonov
10.11.2024 19:04А вы пробовали такие вещи как “unity build” (там правда свои нюансы появляются)? Или тот же sccache, который вроде как бесплатный, но не факт что умеет с msvc фронтендом (с clang-cl вроде должен работать)?
voldemar_d
10.11.2024 19:04Se la vi, как говорится
Если это про французский, то правильно c'est la vie :-)
С одной стороны, вроде как, серьезно ускорили компиляцию. Хотя, если честно, меня удивило, что программисты, собирающие в MS Visual Studio, изначально не использовали precompiled headers. Если в ней создаешь даже простейший проект на MFC, в нем изначально pch уже есть.
C другой стороны, Вы пишете:
А вот оставшиеся 15% или около 15 минут мы занимались "настоящей работой", собирали сердце проекта - бинарь.
Даже если бы вы сократили время компиляции до нуля, полная сборка с двух часов ускорилась до часа 40 минут? И это при том, что:
На всю эту работу, протаскивание этих задач через таски ушло пару месяцев рабочего времени, проект большой, за раз все не починишь, плюс приходится согласовывать свои изменения с другими командами.
Оно точно того стоило? При переделке большого проекта можно и ошибок кучу внести, да и вообще серьезно поломать что-нибудь, а проявиться оно может когда-нибудь потом. Может, и правда, дешевле было бы машину для сборки помощнее собрать?
dalerank Автор
10.11.2024 19:04c'est la vie - я знаю, местная шутка просто, поначалу так и было в комитах, которые не хотят ревьювать. Что-то вроде lgtm, but! Потом с подачи пары немцев сократили до selavi :) Оно того стоило, время сборки на локальных машинах разработчиков сократилось, плюс пришлось порефакторить сам солюшен.
uxgen
10.11.2024 19:04Unity build не пробовали?
Я разделил код по частоте изменений: сторонние либы, движок, тулзы и тд. Перекомпилируется отдельно, а дальше либы цепляются.
vadimr
Я слышал, что когда-нибудь в C++ изобретут модули.
dalerank Автор
Я знаю :(. Вот когда их поддержку завезут вендоры, наверное можно будет попробовать. С тем же успехом можно выносить код в либы.
voldemar_d
А почему бы не вынести часть кода в либы? Особенно те, которые пересобираются не часто, и разрабатываются только определенной частью людей?
AtariSMN82
Их с тех пор как добавили, никаких новых комитов в компилятор по этой теме не было, всем пофиг