>
>
>
Ускорение сборки C и C++ проектов

Филипп Хандельянц
Статей: 30

Ускорение сборки C и C++ проектов

Многие программисты не понаслышке знают о том, что программа на языке C и C++ собирается очень долго. Кто-то решает эту проблему, сражаясь на мечах во время сборки, кто-то - походом на кухню "выпить кофе". Это статья для тех, кому это надоело, и он решил, что пора что-то предпринять. В этой статье разобраны различные способы ускорения сборки проекта, а также лечение болезни "поправил один заголовочный файл - пересобралась половина проекта".

Общие принципы

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

Согласно п.5.1.1.2 драфта N1548 "Programming languages — C" и п.5.2 драфта N4659 "Working Draft, Standard for Programming Language C++" (опубликованные версии стандартов можно приобрести здесь и здесь) определены 8 и 9 фаз трансляции соответственно. Давайте опустим детали и рассмотрим абстрактно процесс трансляции:

  • Фаза I - исходный файл поступает на вход препроцессора. Препроцессор делает подстановку содержимого указанных в #include файлов и раскрывает макросы. Соответствует фазам 1 - 4 драфтов C11 и C++17.
  • Фаза II - препроцессированный файл поступает на вход компилятора и преобразуется в объектный. Соответствует фазам 5 - 7 драфта C11 и 5 - 8 драфта C++17.
  • Фаза III - компоновщик связывает объектные файлы и предоставленные статические библиотеки, формируя исполняемую программу. Соответствует фазе 8 и 9 драфтов C11 и C++17 соответственно.

Программа составляется из единиц трансляции (*.c, *.cc, *.cpp, *.cxx), каждая является самодостаточной и может препроцессироваться/компилироваться независимо от другой. Из этого также следует, что каждая единица трансляции не имеет никакой информации о других единицах. Если двум единицам трансляции надо обменяться какой-либо информацией (например, функцией), то это решается путем связывания по имени: внешняя сущность объявляется с ключевым словом extern, и на фазе III компоновщик их связывает. Простой пример.

Файл TU1.cpp:

// TU1.cpp

#include <cstdint>

int64_t abs(int64_t num) 
{
  return num >= 0 ? num : -num;
}

Файл TU2.cpp:

// TU2.cpp

#include <cstdint>

extern int64_t abs(int64_t num);

int main()
{
  return abs(0);
}

Для упрощения согласования разных единиц трансляции был придуман механизм заголовочных файлов, заключающийся в объявлении четкого интерфейса. Впоследствии каждая единица трансляции в случае надобности включает заголовочный файл через директиву препроцессора #include.

Далее рассмотрим, как можно ускорить сборку на разных фазах. Кроме самого принципа также будет полезно описать, как внедрить тот или иной способ в сборочную систему. Примеры будут приводиться для следующих сборочных систем: MSBuild, Make, CMake.

Зависимости при компиляции

Зависимости при компиляции – это то, что в наибольшей степени влияет на скорость сборки C/C++ проектов. Они возникают всякий раз, когда вы включаете заголовочный файл через препроцессорную директиву #include. При этом создается впечатление, что существует лишь один источник объявления для какой-то сущности. Реальность же далека от идеала - компилятору приходится многократно обрабатывать одни и те же объявления в разных единицах трансляции. Еще сильнее картину портят макросы: стоит перед включением заголовка добавить объявление макроса, как его содержимое может в корне измениться.

Рассмотрим пару способов, как можно уменьшить число зависимостей.

Способ N1: убирайте неиспользуемые включения. Не надо платить за то, что вы не используете. Так вы сокращаете работу как препроцессору, так и компилятору. Можно как вручную "перелопатить" заголовки/исходные файлы, так и воспользоваться утилитами: include-what-you-use, ReSharper C++, CppClean, Doxygen + Graphviz (для визуализации диаграммы включений) и т.д.

Способ N2: используйте зависимость от объявления, а не от определения. Выделим 2 главных аспекта:

1) В заголовочных файлах не используйте объекты там, где можно воспользоваться ссылками или указателями. Для ссылок и указателей достаточно опережающего объявления, поскольку компилятор знает размер ссылки/указателя (4 или 8 байт в зависимости от платформы), а размер передаваемых объектов не имеет значения. Простой пример:

// Foo.h
#pragma once
class Foo
{
  ....
};

// Bar.h
#pragma once
#include "Foo.h"
class Bar
{
  void foo(Foo obj);  // <= Передача по значению
  ....
};

Теперь, при изменении первого заголовка компилятору придется перекомпилировать единицы трансляции, зависимые как от Foo.h, так и Bar.h.

Чтобы разорвать подобную связь, достаточно отказаться от передачи объекта obj по значению в пользу передачи по указателю или ссылке в заголовке Bar.h:

// Bar.h
#pragma once
class Foo;     // <= Опережающее объявление класса Foo
class Bar
{
  void foo(const Foo &obj);  // <= Передача по константной ссылке
  ....
};

Существует также еще один факт, о котором мало кто помнит или знает: вы даже можете объявлять функции, возвращающие и принимающие объекты по значению (!!!), достаточно лишь добавить перед этим опережающее объявление. Важно лишь помнить – перед вызовом такой функции вы обязаны предоставить определение класса, которое использует функция. Такой пример скомпилируется:

// Bar.h
#pragma once
class Foo;     // <= Опережающее объявление класса Foo
class Bar
{
  Foo foo(const Foo obj);  // <= Передача константного объекта
                           //    по значению
  ....
};

Что касается стандартных заголовков, то здесь можно волноваться поменьше и просто включать их в заголовочный файл при необходимости. Исключением является разве что iostream. Этот заголовочный файл настолько вырос в размерах, что к нему дополнительно поставляется заголовок iosfwd, содержащий только опережающие объявления нужных сущностей. Именно его и стоит включать в ваши заголовочные файлы.

2) Используйте идиомы Pimpl или интерфейсного класса. Pimpl убирает детали реализации, помещая их в отдельный класс, объект которого доступен через указатель. Второй подход основан на создании абстрактного базового класса, детали реализации которого переносятся в производный класс, переопределяющем чистые виртуальные функции. Оба варианта устраняют зависимости на этапе компиляции, но также вносят свои накладные расходы во время работы программы, а именно: создание и удаление динамического объекта, добавление уровня косвенной адресации (из-за указателя); и отдельно в случае интерфейсного класса - расходы на вызов виртуальных функций.

Способ N3 (опционально): дополнительно можно создавать заголовки, содержащие только опережающие объявления (аналог iosfwd). Эти "опережающие" заголовки затем можно включать в другие обычные заголовки.

Параллельная компиляция

При стандартном подходе компилятору раз за разом будет поступать новый файл для препроцессирования и компиляции. Поскольку каждая единица трансляции самодостаточна, то хороший способ ускорения - распараллелить фазы I-II трансляции, обрабатывая одновременно N файлов за раз.

В Visual Studio режим включается флагом /MP[processMax] на уровне проекта, где processMax - опциональный аргумент, отвечающий за максимальное количество процессов компиляции.

В make режим включается флагом -jN, где N - число процессов компиляции.

Если вы используете CMake (к тому же и в кросс-платформенной разработке), то им можно сгенерировать файлы для обширного списка сборочных систем через флаг -G. Например, CMake генерирует для C++ анализатора PVS-Studio решение для Visual Studio под Windows, так и Unix Makefiles под Linux. Чтобы CMake генерировал проекты в решении Visual Studio с флагом /MP, добавьте следующие строки в ваш CMakeLists.txt:

if (MSVC)
    target_compile_options(target_name /MP ...)
endif()

Также через CMake (с версии 2.8.0) можно позвать сборочную систему с флагами параллелизации. Для MSVC (/MP указан в CMakeLists.txt) и Ninja (параллелизм уже включен):

cmake --build /path/to/build-dir

Для Makefiles:

cmake --build /path/to/build-dir -- -jN

Распределенная компиляция

Воспользовавшись предыдущим советом, можно в разы снизить время сборки. Однако, когда проект огромен, и этого может быть недостаточно. Увеличивая число процессов компиляции, вы натыкаетесь на барьер в виде максимального числа одновременно компилируемых файлов из-за процессора/оперативной памяти/дисковых операций. Здесь и приходит на помощь распределенная компиляция, использующая свободные ресурсы товарища за спиной. Идея проста:

1) препроцессируем исходные файлы на одной локальной машине или на всех доступных машинах;

2) компилируем препроцессированные файлы на локальной и на удаленных машинах;

3) ожидаем результата от других машин в виде объектных файлов;

4) компонуем объектные файлы;

5) ????

6) PROFIT!

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

  • Масштабируемость - подцепляем машину, и теперь она может помогать в сборке.
  • Эффективность распределенной компиляции зависит от производительности сети и каждой машины. Крайне рекомендуется схожая производительность каждой машины.
  • Необходимость в идентичности окружения на всех машинах (версии компиляторов, библиотек и т.д.). Это особенно необходимо, если препроцессирование происходит на всех машинах.

Наиболее известными представителями являются:

В Linux можно достаточно легко интегрировать distcc и Icecream несколькими способами:

1) Универсальный, через символическую ссылку (symlink)

mkdir -p /opt/distcc/bin # или /opt/icecc/bin
ln -s /usr/bin/distcc /opt/distcc/bin/gcc
ln -s /usr/bin/distcc /opt/distcc/bin/g++
export PATH=/opt/distcc/bin:$PATH

2) Для CMake, начиная с версии 3.4

cmake -DCMAKE_CXX_COMPILER_LAUNCHER=/usr/bin/distcc /path/to/CMakeDir

Кэш компилятора

Другим способом уменьшить время сборки является применение кэша компилятора. Немного изменим фазу II трансляции кода:

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

Что можно использовать:

Регистрацию ccache для его последующего использования можно произвести несколькими способами:

1) Универсальный, через символическую ссылку

mkdir -p /opt/ccache/bin
ln -s /usr/bin/ccache /opt/ccache/bin/gcc
ln -s /usr/bin/ccache /opt/ccache/bin/g++
export PATH=/opt/ccache/bin:$PATH

2) Для CMake, начиная с версии 3.4

cmake -DCMAKE_CXX_COMPILER_LAUNCHER=/usr/bin/ccache /path/to/CMakeDir

Кэш компилятора также можно интегрировать в распределенную компиляцию. Например, для использования ccache с distcc/Icecream, выполните следующие действия:

1) Установите переменную CCACHE_PREFIX:

export CCACHE_PREFIX=distcc # или icecc

2) Воспользуйтесь одним из пунктов 1 - 2 регистрации ccache.

Предварительно откомпилированные заголовочные файлы

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

В MSVC для создания предкомпилированного заголовка по умолчанию генерируются 2 файла: stdafx.h и stdafx.cpp (можно использовать и другие имена). Первым шагом необходимо скомпилировать stdafx.cpp с флагом /Yc"path-to-stdafx.h". По умолчанию создается файл с расширением .pch. Чтобы использовать предкомпилированный заголовок при компиляции исходного файла используем флаг /Yu"path-to-stdafx.h". Совместно с флагами /Yc и /Yu также можно использовать /Fp"path-to-pch" для указания пути к .pch файлу. Теперь необходимо подключить в каждой единице трансляции префиксный заголовок самым первым: либо непосредственно через #include "path-to-stdafx.h", либо принудительно через флаг /FI"path-to-stdafx.h".

Подход в GCC/Clang отличается немногим: необходимо передать компилятору на вход непосредственно сам префиксный заголовок вместо обычного компилируемого файла. Компилятор автоматически выполнит генерацию предкомпилированного заголовка с расширением .gch по умолчанию. При помощи ключа -x можно дополнительно указать, рассматривать ли его как c-header или c++-header. Теперь включите префиксный заголовок вручную через #include, либо через флаг -include.

Подробно о предварительно откомпилированных заголовках вы можете прочитать здесь.

Если вы используете CMake, то рекомендуем попробовать модуль cotire: он может в автоматическом режиме проанализировать исходные файлы, сгенерировать префиксный и предкомпилированный заголовки и подключить их к единицам трансляции. Есть также возможность указать свой префиксный заголовок (например, stdafx.h).

Single Compilation Unit

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

// SCU.cpp
#include "translation_unit1.cpp"
....
#include "translation_unitN.cpp"

Если в единый компилируемый файл включаются все единицы трансляции, то такой способ иначе называют Unity build. Выделим основные особенности Single Compilation Unit:

  • Число компилируемых файлов заметно уменьшается, а значит, и число дисковых операций. Компилятор гораздо меньше обрабатывает одни и те же файлы и инстанцирует шаблоны. Это заметно отражается на времени сборки.
  • Компилятор теперь может выполнять оптимизации, доступные компоновщику (Link time optimization/Whole program optimization).
  • Несколько ухудшается инкрементальная сборка, поскольку изменение одного файла в составе Single Compilation Unit приводит к его перекомпиляции.
  • При применении Unity Build становится невозможным использовать распределенную сборку.

Отметим возможные проблемы при применении подхода:

  • Нарушение ODR (совпадение имен макросов, локальных статических функций, глобальных статических переменных, переменных в анонимных пространствах имен).
  • Коллизии имен вследствие применения using namespace.

Максимальную выгоду на многоядерных системах будут давать схемы:

  • параллельной компиляции нескольких Single Compilation Unit с применением предкомпилированного заголовка;
  • распределенной компиляции нескольких Single Compilation Unit с применением кэша компилятора.

Замена компонентов трансляции

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

В качестве более быстрого компилятора можно воспользоваться Zapcc. Авторы обещают многократное ускорение перекомпиляции проектов. Это можно проследить на примере перекомпиляции Boost.Math (нажмите на анимацию для увеличения):

Zapcc не жертвует производительностью программ, основан на Clang и полностью с ним совместим. Здесь можно ознакомиться с принципом работы Zapcc. Если ваш проект основан на CMake, то заменить компилятор очень легко:

export CC=/path/to/zapcc
export CXX=/path/to/zapcc++
cmake /path/to/CMakeDir

или так:

cmake -DCMAKE_C_COMPILER=/path/to/zapcc \
      -DCMAKE_CXX_COMPILER=/path/to/zapcc++ \
      /path/to/CMakeDir

Если ваша ОС использует ELF-формат объектных файлов (Unix-подобные системы), то можно заменить компоновщик GNU ld на GNU gold. GNU gold идет в составе binutils, начиная с версии 2.19, и активируется флагом -fuse-ld=gold. В CMake его можно активировать, например, следующим кодом:

if (UNIX AND NOT APPLE)
  execute_process(COMMAND ${CMAKE_CXX_COMPILER} 
                  -fuse-ld=gold -Wl,--version 
                  ERROR_QUIET OUTPUT_VARIABLE ld_version)
  if ("${ld_version}" MATCHES "GNU gold")
    message(STATUS "Found Gold linker, use faster linker")
    set(CMAKE_EXE_LINKER_FLAGS 
        "${CMAKE_EXE_LINKER_FLAGS} -fuse-ld=gold")
    set(CMAKE_SHARED_LINKER_FLAGS
        "${CMAKE_SHARED_LINKER_FLAGS} -fuse-ld=gold ")
  endif()
endif()

Использование SSD/RAMDisk

Очевидным "бутылочным горлышком" в сборке является скорость дисковых операций (в особенности случайного доступа). Перенос временных файлов проекта или его самого на более быструю память (HDD с повышенной скоростью случайного доступа, SSD, RAID из HDD/SSD, RAMDisk) в некоторых ситуациях может сильно помочь.

Модульная система в C++

Большинство вышеперечисленных способов исторически возникли из-за выбора принципа трансляции C/C++ языков. Механизм заголовочных файлов, несмотря на кажущуюся простоту, доставляет много хлопот для C/C++ программистов.

Уже достаточно продолжительное время идет обсуждение о включении модулей в стандарт C++ (и, возможно, появится в C++20). Модулем будет считаться связанный набор единиц трансляции (модульная единица) с определенным набором внешних (экспортируемых) имен, называемых интерфейсом модуля. Модуль будет доступен для всех импортирующих его единиц трансляции только через его интерфейс. Неэкспортируемые имена помещаются в имплементацию модуля.

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

В данной статье не будет детально рассматриваться устройство будущих модулей. Если вы хотите узнать о них больше, то рекомендуем к просмотру выступление Бориса Колпакова на CppCon 2017 о модулях C++:

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

На сегодняшний момент компиляторы MSVC, GCC, Clang предлагают экспериментальную поддержку модулей.

А что-нибудь про сборку PVS-Studio будет?

В этом разделе давайте рассмотрим, насколько бывают эффективными и полезными описанные подходы.

За основу возьмем ядро анализатора PVS-Studio для анализа C и C++ кода. Оно, конечно же, написано на C++ и представляет собой консольное приложение. Ядро является небольшим проектом по сравнению с такими гигантами, как LLVM/Clang, GCC, Chromium и т.д. Вот, например, что выдает CLOC на нашей кодовой базе:

----------------------------------------------------------------
Language          files        blank        comment         code
----------------------------------------------------------------
C++                380         28556         17574        150222
C/C++ Header       221          9049          9847         46360
Assembly             1            13            22           298
----------------------------------------------------------------
SUM:               602         37618         27443        196880
----------------------------------------------------------------

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

  • Процессор Intel Core i7-4770 3.4 GHz (8 CPU).
  • ОЗУ 16 Gb RAM DDR3-1333 MHz.
  • Samsung SSD 840 EVO 250 Gb в качестве системного диска.
  • WDC WD20EZRX-00D8PB0 2 Tb под рабочие нужды.

Примем в качестве стартового показателя сборку проекта на HDD, выключив все оптимизации времени сборки. Далее обозначим первый этап замеров:

  • сборка на HDD, компиляция в 1 поток, без оптимизаций;
  • сборка на SSD, компиляция в 1 поток, без оптимизаций;
  • сборка на RAMDisk, компиляция в 1 поток, без оптимизаций.

Рисунок 1. Сборка анализатора PVS-Studio, 1 поток, без оптимизаций. Сверху - сборка Debug версии, снизу - Release.

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

Второй этап замеров - дорабатываем напильником исходный код: удаляем ненужные включения заголовков, устраняем зависимости от определения, улучшаем предкомпилированный заголовок (убираем из него часто изменяемые заголовки) - и постепенно прикручиваем оптимизации:

  • компиляция в 1 поток, проект на HDD, SSD и RAMDisk:
    • single compilation units (SCU);
    • предкомпилированный заголовок (PCH);
    • single compilation units + предкомпилированный заголовок (SCU + PCH).

Рисунок 2. Компиляция в 1 поток после оптимизаций.

  • компиляция в 4 потока, проект на HDD, SSD, RAMDisk:
    • SCU;
    • PCH;
    • SCU + PCH.

Рисунок 3. Компиляция в 4 потока после оптимизаций.

  • компиляция в 8 потоков, проект на HDD, SSD, RAMDisk:
    • SCU;
    • PCH;
    • SCU + PCH.

Рисунок 4. Компиляция в 8 потоков после оптимизаций.

Сделаем краткие выводы:

  • Польза от применения SSD/RAMDisk может колебаться в зависимости от их модели, скорости произвольного доступа, условий запуска, фаз луны и т.д. Хоть они и являются более быстрыми аналогами HDD, конкретно в нашем случае они не дают значительный выигрыш.
  • Предкомпилированные заголовки - очень эффективное средство. Этот способ и ранее использовался в нашем анализаторе, и его использование даже при компиляции в 1 поток давало 7-8 кратное ускорение.
  • При малом числе единых блоков трансляции (SCU) целесообразно не создавать предкомпилированные заголовки. Используйте предкомпилированные заголовки, когда число единых блоков трансляции достаточно велико (> 10).

Заключение

Для многих программистов языки C/C++ ассоциируются как нечто "долго компилирующееся". И на это есть свои причины: выбранный в свое время способ трансляции, метапрограммирование (для C++), тысячи их. Благодаря описанным методам оптимизации можно лишить себя подобных предрассудков о чрезмерно долгой компиляции. В частности, время сборки нашего ядра анализатора PVS-Studio для анализа C и C++ кода удалось снизить с 1 минуты 30 секунд до 40 секунд путем интеграции Single Compilation Units и переработки заголовочных и исходных файлов. Более того, если бы до начала оптимизаций не были использованы параллельная компиляция и предкомпилированные заголовки, нами было бы получено семикратное уменьшение времени сборки!

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