Pull to refresh

Ускорение сборки проекта на CMake+GCC: предварительная компиляция заголовочных файлов

Reading time 8 min
Views 7.9K

Есть несколько причин, почему проект на С++ в среднем собирается дольше сравнимых по величине проектов на других языках, например на Java или C#. Соответственно, есть и несколько способов уменьшить время сборки. Одним из самых известных является использование предварительной компиляции заголовочных файлов (precompiled headers). Сегодня я расскажу, как использование этого способа позволило мне существенно уменьшить время сборки моего проекта.


Немного истории и теории


Уже несколько лет я участвую в разработке проекта на C++. Проект кроссплатформенный, на CMake, в качестве основного компилятора под Linux используется GCC. На текущий момент проект разросся до более чем сотни тысяч строк кода, интенсивно используется библиотека Boost и некоторые другие. Со временем сборка проекта стала занимать все больше и больше времени, и в итоге полная сборка всего проекта с нуля на интеграционном сервере занимала почти 45 минут.


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


Я не буду подробно описывать, как реализована поддержка предварительной компиляции, поскольку детали этой реализации различаются в разных компиляторах. Но в общих чертах предварительная компиляция работает следующим образом. Создаётся заголовочный файл (назовём его precompiled.h), который подключает заголовочные файлы для предварительной компиляции. На основании этого заголовочного файла генерируется специальный pch-файл (.pch, .gch, .pchi — в зависимости от компилятора), в котором содержится результат предварительной компиляции заголовочных файлов, подключённых в precompiled.h. Далее, если компилятор при сборке очередного юнита видит включение precompiled.h, то он не считывает и не анализирует заново этот файл и все включённые в него заголовочные файлы, а использует вместо этого результат предварительной компиляции из pch-файла.


Критериев, по которому тот или иной заголовочный файл включается в кандидаты на предварительную компиляцию (попадает в precompiled.h), несколько. Прежде всего такие файлы должны сравнительно редко меняться. В противном случае pch-файл будет слишком часто пересоздаваться, что может свести на нет весь выигрыш от использования предварительной компиляции. Во-первых, создание pch-файла — это сама по себе сравнительно длительная операция. А во-вторых, после изменения pch-файла нужно будет пересобрать все файлы, которые от него зависят. Ещё один критерий — частота использования заголовочного файла в исходных файлах. Чем большее число юнитов зависит от заголовочного файла, тем больший смысл имеет предварительная компиляция такого заголовочного файла. Аналогично, чем больше сам заголовочный файл, или чем больше он включает в себя других заголовочных файлов — тем обычно больше выигрыш от предварительной компиляции.


Есть несколько подходов к предварительной компиляции заголовочных файлов. Кроме того, они могут немного отличаться от компилятора к компилятору. Часто используется интрузивный подход, когда заголовочный файл с наиболее используемыми заголовочными файлами явно включается в исходники проекта. Например, на Visual C++ это выглядит так:


// Начало заголовочного файла
#include "stdafx.h"
#include "internal-header.h"
...

При этом подходе в файл (в данном случае stdafx.h — историческое название для precompiled.h) включаются наиболее часто используемые и редко изменяемые заголовочные файлы, а сам этот файл включается во все необходимые исходники проекта. Основной минус такого подхода в создании неявных зависимостей. Глядя на исходный файл невозможно сказать, какие именно заголовочные файлы из stdafx.h в нем используются. И нет простого автоматического способа это проверить. Такой подход оптимизирован только для предварительной компиляции заголовочных файлов.


Поэтому на Visual C++ можно использовать слегка модифицированный способ:


// Начало заголовочного файла
#include <vector>
#include <map>
#include "stdafx.h" // Внимание: строки выше игнорируются 
                    // при сборке с предварительной компиляцией заголовков
#include "internal-header.h"
...

При таком подходе с одной стороны, по-прежнему, можно использовать предварительную компиляцию заголовков для ускорения сборки, а с другой — видны явные зависимости каждого исходника. Такие зависимости можно автоматически проверять, создав вариант сборки, в котором предварительная компиляция заголовков не используется, и периодически запуская такую сборку на интеграционном сервере. Необходимо только обернуть содержимое stdafx.h необходимым #ifdef'ом, который будет игнорировать включение заголовков внутри него при сборке без предварительной компиляции.


К сожалению, насколько я знаю, у GCC нет аналогичного поведения, при котором строки до включения stdafx.h игнорировались бы при использовании предварительной компиляции заголовков. Эту проблему можно решить, добавив необходимые #ifdef'ы перед подключением stdafx.h, либо переместив подключение стандартных заголовков вниз:


// Начало заголовочного файла
#include "stdafx.h"
#include <vector>
#include <map>
#include "internal-header.h"
...

При таком подходе добавляются накладные расходы на повторное подключение некоторых заголовков. Однако, поскольку такие заголовки внутри себя содержат необходимую защиту от повторного подключения (#ifdef guard'ы), этими дополнительными расходами обычно можно пренебречь.


Есть и третий, неинтрузивный подход. При этом подходе файл со списком нужных заголовков, аналогичный precompiled.h или stdafx.h, не включается явно в исходники проекта, а вместо этого используется ключ компилятора для принудительного включения (force include) заголовочного файла (-include на GCC и /FI на Visual C++). Это подход по накладным расходам аналогичен предыдущему, но при этом обладает тем преимуществом, что предварительная компиляция заголовочных файлов никак не проявляется в исходниках проекта. А значит при таком подходе предварительная компиляция может быть легко добавлена в существующий проект.


Именно этот подход и реализован в CMake. В версии CMake 3.16 была добавлена команда target_precompile_headers(). Она позволяет перечислить заголовочные файлы, которые должны предварительно компилироваться для цели (target'а) CMake-проекта. В таком случае, при сборке цели сначала будет создан файл, подобный stdafx.h или precompiled.h, включающий все необходимые заголовки, который будет предварительно компилироваться в pch-файл. Он также будет автоматически подключаться к исходникам цели при помощи ключей -include или /FI в зависимости от компилятора.


Кроме того, существует модификация target_precompile_headers(<target1> REUSE_FROM <target2>), которая позволяет не собирать новый pch-файл для target1, а использовать аналогичный файл из target2. Это позволяет сэкономить время сборки и место на диске, однако накладывает ограничения на то, что target1 и target2 должны использовать совместимые флаги компилятора при сборке, в том числе и одинаковые определения препроцессора (preprocessor defines).


К делу


При оптимизации чего либо важно иметь метрики для оценки эффективности оптимизации. В качестве очевидного кандидата, например, можно использовать время сборки всего проекта. Кроме того, можно замерять время компиляции каждого юнита. В CMake это удобно сделать, задав "обёртки" для запуска команд компиляции или линковки:


set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CMAKE_COMMAND} -E time")
set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK "${CMAKE_COMMAND} -E time")

Это позволит при сборке увидеть время на компиляцию или линковку в виде:


[ 60%] Building CXX object source1.cpp.o
Elapsed time: 3 s. (time), 0.002645 s. (clock)
[ 64%] Building CXX object source2.cpp.o
Elapsed time: 4 s. (time), 0.001367 s. (clock)
[ 67%] Linking C executable my_target
Elapsed time: 0 s. (time), 0.000672 s. (clock)

Кроме того, при добавлении или отладки предварительной компиляции заголовков могут быть полезны следующие ключи GCC:


-Winvalid-pch - выводит предупреждение при попытке использования неподходящего gch-файла
-H - отображает путь и имя заголовочных файлов по мере их подключения

В CMake эти ключи можно задать так:


add_compile_options(-Winvalid-pch)
add_compile_options(-H)

Можно пойти ещё дальше, и использовать ключ GCC -ftime-report:


add_compile_options(-ftime-report)

Этот ключ позволяет вывести для каждого юнита время каждой фазы компиляции, в виде:


Execution times (seconds)
 phase setup             :   0.01 ( 4%) usr   0.00 ( 0%) sys   0.01 ( 3%) wall    1223 kB ( 8%) ggc
 phase parsing           :   0.21 (81%) usr   0.10 (100%) sys   0.33 (87%) wall   13896 kB (88%) ggc
 phase opt and generate  :   0.03 (12%) usr   0.00 ( 0%) sys   0.03 ( 8%) wall     398 kB ( 3%) ggc
 phase last asm          :   0.01 ( 4%) usr   0.00 ( 0%) sys   0.01 ( 3%) wall     237 kB ( 2%) ggc
 |name lookup            :   0.05 (19%) usr   0.02 (20%) sys   0.03 ( 8%) wall     806 kB ( 5%) ggc
 |overload resolution    :   0.00 ( 0%) usr   0.01 (10%) sys   0.02 ( 5%) wall      68 kB ( 0%) ggc
 dump files              :   0.00 ( 0%) usr   0.00 ( 0%) sys   0.01 ( 3%) wall       0 kB ( 0%) ggc
 preprocessing           :   0.06 (23%) usr   0.04 (40%) sys   0.12 (32%) wall    1326 kB ( 8%) ggc
 parser (global)         :   0.06 (23%) usr   0.02 (20%) sys   0.11 (29%) wall    6783 kB (43%) ggc
 ...
 TOTAL                 :   0.26             0.10             0.38              15783 kB

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


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


PHASES SUMMARY
   phase opt and generate                   : 1309.1 s. = 21.8 m. ( 50 %)  --->  1577.5 s. = 26.3 m. ( 74 %)
   deferred                                 :  135.0 s. =  2.3 m. (  5 %)  --->   221.4 s. =  3.7 m. ( 10 %)
   integration                              :   62.2 s. =  1.0 m. (  2 %)  --->    85.1 s. =  1.4 m. (  4 %)
   template instantiation                   :  224.3 s. =  3.7 m. (  9 %)  --->   246.5 s. =  4.1 m. ( 12 %)
   callgraph optimization                   :   32.9 s. =  0.5 m. (  1 %)  --->    48.5 s. =  0.8 m. (  2 %)
   unaccounted todo                         :   36.5 s. =  0.6 m. (  1 %)  --->    49.7 s. =  0.8 m. (  2 %)
   |overload resolution                     :   82.1 s. =  1.4 m. (  3 %)  --->    95.2 s. =  1.6 m. (  4 %)
                                                        ...
   parser enumerator list                   :    2.1 s. =  0.0 m. (  0 %)  --->     0.5 s. =  0.0 m. (  0 %)
   parser function body                     :   32.0 s. =  0.5 m. (  1 %)  --->     9.3 s. =  0.2 m. (  0 %)
   garbage collection                       :   55.3 s. =  0.9 m. (  2 %)  --->    16.7 s. =  0.3 m. (  1 %)
   |name lookup                             :  132.8 s. =  2.2 m. (  5 %)  --->    63.5 s. =  1.1 m. (  3 %)
   body                                     :   87.5 s. =  1.5 m. (  3 %)  --->    18.2 s. =  0.3 m. (  1 %)
   parser struct body                       :  113.4 s. =  1.9 m. (  4 %)  --->    21.1 s. =  0.4 m. (  1 %)
   parser (global)                          :  158.0 s. =  2.6 m. (  6 %)  --->    25.8 s. =  0.4 m. (  1 %)
   preprocessing                            :  548.1 s. =  9.1 m. ( 21 %)  --->    88.0 s. =  1.5 m. (  4 %)
   phase parsing                            : 1119.7 s. = 18.7 m. ( 43 %)  --->   228.3 s. =  3.8 m. ( 11 %)
  TOTAL : 2619.2 s. = 43.7 m.  --->  2118.4 s. = 35.3 m.

Из этого вывода можно увидеть, что предварительная компиляция заголовков позволила кардинально уменьшить время на первичную обработку и анализ текста заголовков (parsing, preprocessing). Однако при этом несколько увеличилось время на другие фазы, например на оптимизацию и генерацию машинного кода. Это объясняется тем, что теперь многие заголовочные файлы анализируются только один раз, однако информация из них включается при сборке каждого исходного файла. В результате компилятор с одной стороны экономит время на чтении и анализе заголовочных файлов, но с другой стороны ему приходится обрабатывать больший объем данных, что оказывает влияние на время других фаз.


На основании анализа данных о потраченном на компиляцию времени я мог оптимизировать использование предварительно откомпилированных заголовков. Поначалу я использовал один и тот же набор предварительно компилируемых заголовков для всех целей проекта. Этот набор включал в себя часто включаемые в проекте заголовки из Boost и стандартной библиотеки. Однако статистика времени компиляции показала как уменьшение времени сборки одних целей, так и увеличение времени сборки других. Цели, время сборки которых увеличилось, не использвали Boost. Поэтому следующим логичным решением было разделение набора предварительно компилируемых заголовочных файлов на два. Один из них включал заголовки только из стандартной библиотеки, а второй дополнительно включал заголовки из Boost. Использование двух различных наборов позволило уменьшить время сборки всех целей — и тех, что использовали Boost, и тех, что использовали только стандартную библиотеку.


Вторым улучшением было переиспользование pch-файлов между разными целями, при помощи команды target_precompile_headers(<target1> REUSE_FROM <target2>). Это позволило сэкономить еще несколько минут и несколько сотен мегабайт на диске.


Итог


Как можно увидеть, в моем случае в результате предварительной компиляции заголовков, время сборки в итоге уменьшилось с 43 до 35 минут.


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


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

Tags:
Hubs:
+11
Comments 11
Comments Comments 11

Articles