Ускорение компиляции КОМПАС-3D в 4 раза при помощи PCH

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Рассказывает программист Александр Тулуп:

Время сборки проекта имеет немалое значение в процессе разработки. Начиная с "комфорта" разработчика при внесении изменений, заканчивая стоимостью оборудования, необходимого для организации (Continuous Integration) — в дословном переводе «непрерывная интеграция» </p>" data-abbr="CI">CI.

Проблема

Проект активно разрабатывается, добавляется новый функционал, увеличивается объем кодовой базы и время сборки...

На момент начала исследования, время сборки Debug перевалило за 20 минут и продолжало расти (а впереди ещё linux-версия, что потребовало бы удвоения вычислительных мощностей), несмотря на использование средств ускорения компиляции, таких как Incredibuild. "Средний" компьютер разработчика в основном состоял из топового железа для своего времени, да и сеть уже была расширена до 1 Гбит.

А это значит, что "легких" способов ускорения практически не осталось.

C++ Build Insights

Придется подходить к делу с умом.
Как обычно ищут узкое место в производительности ? Правильно, используют профилировщик.

К этому моменту Microsoft выпустила дополнение к Windows Performance Analyzer (WPA), C++ Build Insights.

Суть ее в том, что в компилятор встраиваются метки, которые фиксируют время выполнения каждого этапа компиляции: препроцессор, генерация кода, оптимизация, линковка...

Для получения результатов используется системный профилировщик Windows Performance Analyzer (WPA).

Перед тем как начать замер, нужно подружить WPA с компилятором студии. Иначе результаты не будут видны.

Для этого нужно скопировать  perf_msvcbuildinsights.dll из студии и прописать в настройки perfcore.ini, подробнее тут.

После чего сам процесс выглядит так:

В результате мы получим .etl файл, в котором видны результаты профилирования

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

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

Для каждой единицы трансляции (обычно *.cpp) выполняется ряд однотипных действий.

Сперва считывается все содержимое в виде текста, попутно раскрывая все инструкции препроцессора, к которым относится и #include.
Если #include содержит в себе вложенные инструкции, то аналогично и для них. И так рекурсивно до полного завершения. Т.е. простая строчка в виде #include <boost/property_tree/ptree.hpp> может превратиться в сотни тысяч строк.

Полученный текст отдается генератору кода, который еще пытается что-то оптимизировать.
На выходе получаем *.obj файл. После обработки всех *.cpp, их следует слинковать, чтобы получить исполняемый *.dll, *.exe, *.so ...

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

И с этим нужно что-то делать.

Варианты решения

Самый очевидный способ, писать код "правильно", используя по максимум forward declaration и идиому pimpl. Найти все "тяжелые" заголовки и переписать как надо. Но у нас проект с богатой историей, т.е. legacy.И просто так все переделать, ничего не поломав... Да к тому же нужно добавлять новый функционал и править баги. Поэтому отложим эту идею на светлое будущее или дождемся полной поддержки (С++20 modules).

А сейчас нужно работать с тем что имеем.

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

Unity build - а что, если все *.cpp собрать в один и скомпилировать. Сразу отпадает тяжелая часть препроцессора. Были даже эксперименты, и результаты впечатляющие.

Но тогда возникает другая проблема. Такой код сложно поддерживать. Все "приватные" части сразу становятся видимыми. Изменения в одном тянет за собой пересборку большей части кода. Да и багов при переходе можно добавить немало, которые не сразу проявятся. Поэтому пока отложим эту идею в сторону.

Incredibuild, ccache, ... - а что если использовать "ускорители" сборки? Т.к. большинство программистов работают примерно с одним и тем же проектом, и часто на свежих ревизиях, то можно было бы закэшировать результат на сервере, и вместо компиляции просто брать уже "готовый" *.obj. Идея выглядит интересно, но это сильно усложняет процесс, требует дополнительный настроек окружения и т.д. К тому же еще впереди портирование на linux, а значит требует дублирования инфраструктуры. Посмотрим что еще есть?

Precompiled header (PCH) - а что, если взять часто используемые заголовочные файлы в проекте, предварительно их обработать в "оптимальный" для компилятора вид, и в местах использования, вместо перечитывания #include, просто выдавать готовый результат. Это похоже на объединение двух предыдущих подходов, т.е. один раз обработать заголовочные файлы. При этом не требуется значительного изменения проекта.  Осталось только понять, что туда нужно класть и как поддерживать в актуальном состоянии.

PCH

Для получения списка "горячих" мест, используем уже знакомый C++ Build Insights. К счастью, у него уже есть интерфейс для программного доступа к результатам профилирования.
Даже есть репозиторий с готовыми примерами, нас интересует TopHeaders.

В нашем случае нужно просто достать топ N "тяжелых" заголовков и прописать их в PCH.

Достаточно собрать исполняемый файл и отдать ему полученный ранее .etl.
Тут есть одно но. Для работы через SDK, при завершении профилирования нужно выполнить команду с /stopnoanalyze, вместо /stop

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

После многочисленных экспериментов с критериями добавления, получилось что-то такое.
Оказалось что чем больше добавляем в pch, тем лучше, но до определенного предела. Например, растет время генерации самого PCH и его объем.
На системах со слабым жестким диском можно даже замедлить.

В крупных проектах доходило до 200 заголовков.

Так же было замечено, что некоторые заголовки из списка уже включены в другие. В формируемом etl уже есть полное дерево include.
Строим граф, и выбираем "верхушки".  В итоге 200 превращаются в 10.

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

На таблице результаты представлены следующим образом. Первый замер, добавили N заголовков, без дополнительной обработки.
Начальное время было около 45 минут. И далее пытаемся немного улучшить. В итоге ускорение получилось около 4 раз.

IWYU

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

Т.к. в PCH добавляются заголовки из самого проекта, то это начинает влиять на компиляцию.
Например, использовали std::vector, а заголовок не включили. И все компилируется, т.к. есть в PCH.

Теперь пытаемся перегенерировать, а для этого нужно удалить все из PCH и получаем тысячи ошибок по всему проекту, что где-то чего то не хватает.
Можно конечно врукопашную "починить" сборку, ради такого раз в несколько месяцев можно и потерпеть. Но как показала практика,
с каждым разом это становится все труднее. Проект не только активно развивается, но и переделывается для портирования на Linux.
А это несет за собой масштабные переносы файлов между проектами.

Вот бы этот процесс как-то автоматизировать. Т.е. для каждого *.cpp и *.h включить только те заголовки, что реально используются, и удалить все лишнее.
Оказывается, есть и такое.

Программа так и называется, include-what-you-use (IWYU). Работает это следующим образом. Используя фронтэнд от clang (llvm), строим AST файла.
Анализируем каждый символ, из какого заголовка он пришел. Сравниваем с тем что включено и добавляем недостающее, удаляем лишнее.

Но и тут не все так просто. Один и тот же символ может быть определён в разных *.h файлах. В некоторых проектах используется "проброс" деталей реализации в публичные заголовки.
Все это алгоритмически не может быть вычислено. Нужно явно настраивать. Для этого есть специальные *.imp файлы. По структуре напоминающей json, в котором прописываем что откуда брать.

Подробнее тут.

Так же сам алгоритм поиска символов работает с небольшими огрехами. Например, путается в сложных шаблонах и удаляет то что на самом деле нужно.
Для таких случаев остается нужно "настроить" проект на месте, прописав в необходимых местах специальные прагмы. В данном случае // IWYU pragma: keep

После длительного (1,5 года) процесса работы в таком режиме, удалось достичь достаточно стабильного результата работы.
Иногда все же приходится подправлять руками, но значительно меньше чем до этого.

C++20 Modules

Что дальше ? На подходе с++20 modules. Точнее они уже есть, но поддержка со стороны cmake и основных компиляторов только на подходе.
В чем-то модули похожи с pch. Т.е. так же формируют в оптимальном для компилятора формате файл, избавляя от избыточной работы препроцессора.
Но так же добавляют еще множество полюшек. Подробнее можно почитать тут.

Вывод

И так, что можно сказать за 1,5 года данного подхода?

Источник: https://habr.com/ru/company/ascon/blog/723242/


Интересные статьи

Интересные статьи

Всем привет! На связи ИТ-команда подразделения ДОМ.РФ Земли. Мы занимаемся автоматизацией вовлечения в оборот неиспользуемых или используемых не по назначению федеральных земельных участков и объектов...
Эта статья, переводом которой мы делимся к старту курса о машинном и глубоком обучении, представляет собой обзор недавней работы "Генерация моделируемых данных посредство...
Анализ безопасности хранения и хеширования паролей при помощи алгоритма MD5 С появлением компьютерных технологий стало более продуктивным хранить информацию в базах данны...
Как-то мне позвонили из Ростелекома и предложили подключить IP TV. Ну что же, решил я, пусть жена с сыном смотрят в спальне мультики и согласился. И вот принесли мне заветную коробочку. Т...
Почему мне захотелось рисовать муравьями Я хотела создать произведение искусства, исследующее сложность проектирования программного обеспечения. Когда я представляю огромную кодовую базу, то д...