Пару месяцев назад я прикрутил профилирование к нашей билд-системе (форке JamPlus). Оно было реализовано на уже описанном мной ранее Chrome Tracing View, так что добавить его поддержку в Jam было просто. Jam написан на С, так что я просто нашел подходящую библиотеку для профилирования на С (это была minitrace) и буквально несколькими строками обернул интересующие меня места (собственно, сборку).
Здесь нет ничего выдающегося. Однако… как только у вас появляются первые результаты профилирования, они чаще всего заставляют задуматься и начать кое-что замечать.
Как-то раз я занимался чем-то не связанным с темой данной статьи и для чего-то взглянул на вывод профилировщика для свежесобранного билда моего продукта. Опыт подсказывает, что в сборке С++ кода львиную долю времени занимает линковка. В этот раз, однако, дело было не в этом:
На диаграмме легко заметна большая задержка перед этапом линковки. Большая часть кода уже скомпилирована и лишь один файл с С++ кодом продолжает собираться. Тогда я был занят другой задачей, так что всего-лишь добавил задачу разобраться в этом на нашу доску с задачами. В другой раз я собирал билд другого компонента нашего продукта и снова взглянул на вывод профилировщика сборки:
И вот здесь дела выглядели уже откровенно плохо. Общее время сборки было около 10 минут и почти 7 из них занимала компиляция всего одного файла (5 из которых больше ничего не компилировалось). В этот момент стало ясно, что проблема в системе сборке такого масштаба, который не позволяет больше её игнорировать или откладывать.
Среднее время компиляции С++ файлов в данном проекте и в данной конфигурации составляло около 2 секунд. Была парочка файлов, которые собирались по 30 секунд, но 400+ секунд для сборки выходило за все разумные рамки. Что же происходит?
Я сделал несколько экспериментов и выяснил, что:
Был ли наш подход идеальным — отдельный вопрос, но тогда он давал нам достаточно преимуществ, чтобы не отказываться от него на ровном месте. Но всё-же что-то нужно было делать со скоростью компиляции.
Одним простым изменением, которое вполне было во власти системы сборки, могло бы стать исключение медленно компилируемых файлов из Unity-билдов. Весь их смысл в том, чтобы слегка сэкономить на запусках процесса компилятора и препроцессинге общих заголовочных файлов. Однако для нескольких файлов, компиляция которых занимает по 30+ секунд, этот выигрыш будет минимален, а вот необходимость ожидание нескольких минут на каждой сборке из-за «застрявшего» в конце сборки файлов — существенная проблема.
Хорошо было бы ещё как-то заставить систему сборки начать компиляцию «медленных» файлов как можно раньше. Раньше начнём — раньше закончим. Идеальным вариантом было бы прикрутить к системе сборки анализ исторических данных по предыдущим сборкам и автоматическое построение очереди компиляции на их основе. Но это не было нужно в данном конкретном случае — просто исключение файлов из unity-билдов в нашей системе сборки передвигало их в начало очереди. Ок, нам этого пока достаточно.
Этот трюк на самом деле ни на секунду не ускорил нашу 7-минутную сборку того «плохого» файла, но его было легко сделать и он сразу дал около минуты общего выиграша на всей сборке (которая до этого занимала 10 минут).
А после этого я сделал то, на что у меня вообще-то вообще не было надежд — я разбил в том «медленном» файле самую большую шаблонную функцию на несколько более мелких (некоторые из которых уже не были шаблонными). Тривиальный рефакторинг. Некоторые IDE умеют делать подобные вещи в режиме «выделил мышкой часть кода, правый клик, Extract Function». Ну вот только это С++ и код, как я уже говорил, содержал много макросов и шаблонов, так что пришлось всё делать вручную.
После выделения около 5 функций время компиляции проблемного файла упало с 420 секунд до 70. Стало в 6 раз быстрее!
Конечно, выделение функций означает, что они больше не являются инлайновым кодом и у нас появляются затраты на их вызов (передача аргументов, jump, возврат). В то же время такой подход всё-же позволяет вызывающей функции использовать регистры (лучше или хуже), уменьшить общий объём кода и т.д. Мы замерили скорость работы изменённого кода на разных платформах и пришли к выводу, что изменения производительности незначительны. Так что, в этот раз всё сработало!
Конечно, минута на компиляцию одного файла, это всё ещё много. Но дальнейшие попытки ускорения компиляции повлекли бы за собой существенные изменения в дизайне нашей математической библиотеки. Это требовало уже более продуманного планирования.
Сборка после сделанных изменений выглядит уже лучше. Больше нет кучи процессорных ядер, ожидающих завершения процесса компиляции на одном из них. Линковка всё ещё последовательна, но это не новость. Общее время сборки упало с 10 минут до 5 минут 10 секунд, т.е. стало почти в 2 раза быстрее.
Здесь нет ничего выдающегося. Однако… как только у вас появляются первые результаты профилирования, они чаще всего заставляют задуматься и начать кое-что замечать.
Замеченные вещи
Как-то раз я занимался чем-то не связанным с темой данной статьи и для чего-то взглянул на вывод профилировщика для свежесобранного билда моего продукта. Опыт подсказывает, что в сборке С++ кода львиную долю времени занимает линковка. В этот раз, однако, дело было не в этом:
На диаграмме легко заметна большая задержка перед этапом линковки. Большая часть кода уже скомпилирована и лишь один файл с С++ кодом продолжает собираться. Тогда я был занят другой задачей, так что всего-лишь добавил задачу разобраться в этом на нашу доску с задачами. В другой раз я собирал билд другого компонента нашего продукта и снова взглянул на вывод профилировщика сборки:
И вот здесь дела выглядели уже откровенно плохо. Общее время сборки было около 10 минут и почти 7 из них занимала компиляция всего одного файла (5 из которых больше ничего не компилировалось). В этот момент стало ясно, что проблема в системе сборке такого масштаба, который не позволяет больше её игнорировать или откладывать.
Среднее время компиляции С++ файлов в данном проекте и в данной конфигурации составляло около 2 секунд. Была парочка файлов, которые собирались по 30 секунд, но 400+ секунд для сборки выходило за все разумные рамки. Что же происходит?
Я сделал несколько экспериментов и выяснил, что:
- Наша сборочная система, построенная на принципе Unity-билдов, не была виновником происходящего. Всё дело было в одном конкретном cpp-файле.
- Такое поведение показывал толко компилятор MSVC (clang работал в 10 раз быстрее), но нам тогда был нужен именно MSVC
- Проблема касалась только Release-сборок (а вернее тех сборок, где был включен инлайнинг)
- Проблема касалась не только относительно старого компилятора VS2010. Компиляция с помощью VS2015 работала даже ещё медленнее
- Общим моментом для всех файлов, компиляция которых занимала более 30 секунд, было использование нашей «математической SIMD-библиотеки», которая давала возможность писать код в стиле HLSL. Реализация была основана на весьма заковыристых макросах и шаблонах
- Тот самый файл, компиляция которого занимала 7 минут, включал в себя очень большую и сложную SIMD-функцию, которая к тому же за счет использования шаблонов требовала создание нескольких типизированных реализаций на этапе компиляции (так мы избавлялись от накладных расходов на рантайме, так что этот подход имел смысл)
Был ли наш подход идеальным — отдельный вопрос, но тогда он давал нам достаточно преимуществ, чтобы не отказываться от него на ровном месте. Но всё-же что-то нужно было делать со скоростью компиляции.
Ускорение компиляции
Одним простым изменением, которое вполне было во власти системы сборки, могло бы стать исключение медленно компилируемых файлов из Unity-билдов. Весь их смысл в том, чтобы слегка сэкономить на запусках процесса компилятора и препроцессинге общих заголовочных файлов. Однако для нескольких файлов, компиляция которых занимает по 30+ секунд, этот выигрыш будет минимален, а вот необходимость ожидание нескольких минут на каждой сборке из-за «застрявшего» в конце сборки файлов — существенная проблема.
Хорошо было бы ещё как-то заставить систему сборки начать компиляцию «медленных» файлов как можно раньше. Раньше начнём — раньше закончим. Идеальным вариантом было бы прикрутить к системе сборки анализ исторических данных по предыдущим сборкам и автоматическое построение очереди компиляции на их основе. Но это не было нужно в данном конкретном случае — просто исключение файлов из unity-билдов в нашей системе сборки передвигало их в начало очереди. Ок, нам этого пока достаточно.
Этот трюк на самом деле ни на секунду не ускорил нашу 7-минутную сборку того «плохого» файла, но его было легко сделать и он сразу дал около минуты общего выиграша на всей сборке (которая до этого занимала 10 минут).
А после этого я сделал то, на что у меня вообще-то вообще не было надежд — я разбил в том «медленном» файле самую большую шаблонную функцию на несколько более мелких (некоторые из которых уже не были шаблонными). Тривиальный рефакторинг. Некоторые IDE умеют делать подобные вещи в режиме «выделил мышкой часть кода, правый клик, Extract Function». Ну вот только это С++ и код, как я уже говорил, содержал много макросов и шаблонов, так что пришлось всё делать вручную.
После выделения около 5 функций время компиляции проблемного файла упало с 420 секунд до 70. Стало в 6 раз быстрее!
Конечно, выделение функций означает, что они больше не являются инлайновым кодом и у нас появляются затраты на их вызов (передача аргументов, jump, возврат). В то же время такой подход всё-же позволяет вызывающей функции использовать регистры (лучше или хуже), уменьшить общий объём кода и т.д. Мы замерили скорость работы изменённого кода на разных платформах и пришли к выводу, что изменения производительности незначительны. Так что, в этот раз всё сработало!
Конечно, минута на компиляцию одного файла, это всё ещё много. Но дальнейшие попытки ускорения компиляции повлекли бы за собой существенные изменения в дизайне нашей математической библиотеки. Это требовало уже более продуманного планирования.
Сборка после сделанных изменений выглядит уже лучше. Больше нет кучи процессорных ядер, ожидающих завершения процесса компиляции на одном из них. Линковка всё ещё последовательна, но это не новость. Общее время сборки упало с 10 минут до 5 минут 10 секунд, т.е. стало почти в 2 раза быстрее.
Мораль
- Иметь хотя бы какую-нибудь систему профилирования сборки проекта, это лучше, чем не иметь вообще никакой. Если бы у нас подобной системы не было — мы бы так и теряли по 5 минут на каждой сборке проекта (не только на билд-сервере, но и на машинах разработчиков).
- Когда ваша шаблонная функция компилируется, то создаётся N её типизированных представлений. И каждое представление — это отдельный код, который сначала автоматически генерируется, а потом компилируется. При этом для разных версий такого кода, в зависимости от используемых типов, компилятор может ещё и применять различные оптимизации. Разделение большой шаблонной функции на более мелкие (и, возможно, не шаблонные) может реально ускорить компиляцию.
- Сложные шаблонные функции могут компилироваться долго из-за слишком долгой работы оптимизатора. Например, компилятор MSVC тратит основное время именно на это.
- Ускорение сборки — хорошая штука. Ну, помните, тот комикс — "-Эй, вы куда? — Код компилируется!". Чем меньше такого случается в жизни, тем лучше.