отсутствует оптимизация кода компиляция

Оптимизация кода

Оптимизируя исполняемый файл, можно добиться баланса между быстрым выполнением и небольшим размером кода. В этом разделе обсуждаются механизмы, предоставляемые Visual Studio для оптимизации кода.

Возможности языка

В следующих разделах описываются некоторые функции оптимизации в C/C++.

Прагмы и ключевые слова оптимизации
Список ключевых слов и прагм, которые можно использовать в коде для повышения производительности.

Параметры компилятора, упорядоченные по категориям
Список параметров компилятора /O, которые влияют на скорость выполнения или размер кода.

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

Прагма optimize

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

Заключите код между двумя прагмами, как показано ниже:

Рекомендации по программированию

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

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

В следующих разделах рассматриваются оптимальные методы программирования.

Рекомендации по оптимизации критичного по времени кода
Улучшенные методы программирования могут повысить производительность. В этом разделе предлагаются приемы программирования, которые помогут обеспечить удовлетворительную производительность критичного по времени кода.

Рекомендации по оптимизации
Общие рекомендации по эффективной оптимизации приложения.

Отладка оптимизированного кода

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

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

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

Источник

Компиляция. 8: оптимизация

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

Далее в посте:

Починка бага

Проблема возникала тогда, когда в правой части присваивания использовалось не промежуточное значение, а нечто долгоживущее: например, другая переменная.
Во второй свёртке присваивания не генерируется новый код — только запоминаем vars[«b»]=R1
Обе переменные оказались в одном регистре, и при изменении одной из них — изменится и вторая.

Иначе говоря, теперь в генерируемом коде будет уйма лишних копирований из регистра в регистр, а назначение регистров будет работать ещё медленнее — из-за того, что число использованных регистров растёт.
Постараемся найти те случаи, где копирование излишне (например, если переменная из правой части присваивания в дальнейшем не изменяется), и исправим команды, использующие копию, так, чтоб использовали оригинал.

Чистка копирований

Copy elimination — одна из простых оптимизаций, которую я обещал с самого первого поста серии. Как и для оптимизаций, выполняемых во время назначения регистров, для чистки удобно применить data-flow analysis. Важным отличием от двух предыдущих применений DFA (проход назад для обнаружения живых регистров, проход вперёд для обнаружения сохранённых регистров) будет являться то, что в каждой точке храним не одно множество регистров, а разбиение всех регистров на множества одинаковых. Можно смотреть на это как на более общий случай DFA, чем два рассмотренных прежде. (Прежде, регистры всегда разбивались на два множества — «включённые» и «исключённые».)

И ещё одна тонкость: поскольку при переходах от команды к команде мы не наращиваем множества, а усекаем (пересекаем все входящие множества), то перед запуском DFA нужно инициализировать множества не в пустые, а во всеобъемлющие — и по мере работы алгоритма множества усекутся, как надо. Чтобы не тратиться и не держать взаправду в каждой команде множество всех существующих регистров, договоримся считать «отсутствующий итератор» указывающим именно на такое всеобъемлющее множество.

Для удобства, три нужных нам операции над разбиениями оформляем в класс. В разбиении храним список множеств, на которые разбиты регистры (кроме «глобального» множества, в котором регистры все вместе находятся изначально), и для каждого регистра (кроме тех, что в «глобальном» множестве) — итератор того множества, в котором он находится.

typedef bit::set 255 > regset;

class regPartition <
typedef std::list regsets;
regsets sets;
std::map byreg;
// изначально: все регистры в «глобальном» разбиении

public :
// возвращает: изменилось ли разбиение
bool add(regnum copy, regnum orig) <
if (byreg.count(copy)) <
if (byreg==byreg[orig]) // уже вместе
return false ;
byreg->erase(copy);
// был последним?
if (!byreg->size())
sets.erase(byreg);
>
assert(byreg.count(orig));
byreg = byreg[orig];
byreg->insert(copy);
return true ;
>

void remove(regnum r) <
if (byreg.count(‌r)) <
if (byreg[r]->size()== 1 ) return ; // уже один
byreg[r]->erase(‌r);
>
byreg[r] = sets.insert(sets.end(), regset());
byreg[r]->insert(‌r);
>

Что получилось?

По сравнению с прошлым разом, код сократился ещё на пару команд:

Может показаться, что про исчезнувшие команды ( add r01, r01, 0 и add r02, r02, 0 ) сразу было видно, что они бессмысленные. На самом деле, эти команды принимали бессмысленную форму только после назначения физических регистров, т.е. на самом последнем этапе перед выводом готового п-кода. До тех пор, номера п-регистров у операндов различались; лишь выполненный нами только что анализ позволил их объединить, и удалить ставшее бессмысленным копирование — всё это задолго до назначения физических регистров.

Сворачивание констант

Ещё одна стандартная оптимизация, которая, как и предыдущие, реализуется при помощи DFA, — constant folding. Принцип донельзя прост: если известны значения операндов, то операцию можно выполнить сразу при компиляции. Например, вместо кода можем сгенерировать сразу же
Операции над константами не обязательно свидетельствуют о небрежности программиста, поленившегося вычислить заранее известный результат: например, pixels=1024*768; легче читать и поддерживать, чем pixels=786432;

Те же самые данные позволят нам выполнить ещё одну оптимизацию — constant propagation (подстановка известного значения вместо ссылки на регистр). Эта оптимизация невозможна при выбранном нами формате п-кода, потому что в нём отсутствуют операции над регистром и константой; такие операции, однако, присутствуют во многих реальных процессорах, так что выполнить полноценную «подстановку констант» можно будет при генерации выполнимого кода. Сейчас же ограничимся заменой нулевого значения на R0.

Реализация

struct commandn дополняется ещё парой полей:
std::map int > known; regset unknown;

В следующем посте — собираем из п-кода с расставленными физическими регистрами настоящий исполнимый код для x86/x64.

Источник

Что каждый программист должен знать про оптимизации компилятора

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

Данная статья посвящена оптимизациям компилятора Visual C++. Я собираюсь обсудить наиболее важные техники оптимизаций и решения, которые приходится применить компилятору, чтобы правильно их применить. Моя цель не в том, чтобы рассказать вам как вручную оптимизировать код, а в том, чтобы показать, почему стоит доверять компилятору оптимизировать ваш код самостоятельно. Эта статья ни в коем случае не является описанием полного набора оптимизаций, которые совершает компилятор Visual C++, в ней будут показаны только действительно важные из них, о которых полезно знать. Есть другие важные оптимизации, которые компилятор выполнить не в состоянии. Например, замена неэффективного алгоритма на эффективный или изменение выравнивания структуры данных. Такие оптимизации в этой статье мы обсуждать не будем.

Определение оптимизаций компилятора

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

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

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

Link-Time Code Generation

Генерация кода на этапе линковки (Link-Time Code Generation, LTCG) — это техника для выполнения оптимизаций над всей программой (whole program optimizations, WPO) для С/С++ кода. Компилятор С/С++ обрабатывает каждый файл исходного кода по отдельности и выдаёт соответствующий файл объектов (object file). Другими словами, компилятор может оптимизировать только одиночный файл, вместо того, чтобы оптимизировать всю программу. Однако некоторые важные оптимизации могут быть применимы только к программе целиком. Вы можете использовать эти оптимизации только во время линковки, а не во время компиляции, т. к. линковщик имеет полное представление о программе.

Если LTCG включён (флаг /GL ), то драйвер компилятора ( cl.exe ) будет вызывать только front end ( c1.dll или c1xx.dll ) и отложит работу back end ( c2.dll ) до момента линковки. Полученные объектные файлы содержат C Inter­mediate Language (CIL), а не машинный код. Затем вызывается линковщик ( link.exe ). Он видит, что объектные файлы содержат CIL-код, и вызывает back end, который, в свою очередь, выполняет WPO и генерирует бинарные объектные файлы, чтобы линковщик мог соединить их вместе и сформировать исполняемый файл.

Front end также выполняет некоторые оптимизации (например, свёртка констант) независимо то того, включены или выключены оптимизации. Впрочем, все важные оптимизации выполняет back end, и их можно контролировать с помощью ключей компиляции.

Рассмотрим результат работы компилятора под тремя разными конфигурациями. Если вы будете разбираться с примером самостоятельно, то вам понадобится assembler output file (получается с помощью ключа компилятора /FA[s] ) и map file (получается с помощью ключа линковщика /MAP ) чтобы изучить выполняемые COMDAT-оптимизации (линковщик будет сообщать о них, если вы включите ключи /verbose:icf и /verbose:ref ). Убедитесь, что все ключи указаны правильно, и продолжайте чтение статьи. Я буду использовать компилятор C ( /TC ), чтобы генерируемый код был проще для изучения, но всё излагаемое в статье также применимо к С++ коду.

Конфигурация Debug

Конфигурация Compile-Time Code Generation Release

Конфигурация Link-Time Code Generation Release

Эта конфигурация идентична конфигурации Release в Visiual Studio: оптимизации включены и ключ компилятора /GL указан (вы также можете явно указать /O1 или /O2 ). Тем самым мы говорим компилятору формировать объектные файлы с CIL кодом вместо assembly object files. А значит, линковщик вызовет back end компилятора для выполнения WPO, как было описано выше. Теперь мы обсудим несколько WPO, чтобы показать огромную пользу LTCG. Генерируемые assembly code-листинги для этой конфигурации доступны online.

Как вы можете видеть, инлайнинг функций — это важно не только из-за того, что оптимизируется вызов функций, но также из-за того, что он позволяет компилятору выполнить многие дополнительные оптимизации. Инлайнинг обычно увеличивает производительность за счёт увеличения размера кода. Чрезмерное использование этой оптимизации приводит к такому явлению, которое называется code bloat. Поэтому при каждом вызове функции компилятор проводит вычисление затрат и выгод, после чего принимает решение о том, стоит ли инлайнить функцию.

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

Инлайнинг функций — это не единственная оптимизация, которая может применятся на уровне программы целиком. Большинство оптимизаций наиболее эффективно работают именно на этом уровне. В оставшейся части статьи я обсужу специфический класс оптимизаций, который называется COMDAT-оптимизации.

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

Вы должны использовать LTCG всегда, когда это возможно. Единственная причина отказа от LTCG заключается в том, что вы хотите распространять итоговые объектные файлы и файлы библиотек. Напомним, что они содержат CIL-код вместо машинного. CIL-код может быть использован только компилятором и линковщиком той же версии, с помощью которой они были сгенерированы, что является значительным ограничением, ведь разработчикам придётся использовать ту же версию компилятора, чтобы использовать ваши файлы. В данном случае, если вы не хотите распространять отдельную версию объектных файлов для каждой версии компилятора, то вы должны использовать вместо этого генерацию кода. В дополнение к ограничению по версии, объектные файлы во много раз больше, чем соответствующие assembler object-файлы. Впрочем, не забывайте про огромное преимущество объектных файлов с CIL-кодом, которое заключается в возможности использовать WPO.

Оптимизации циклов

Компилятор Visual C++ поддерживает несколько видов оптимизаций циклов, но мы будем обсуждать только три: размотка циклов (loop unrolling), автоматическая векторизация (automatic vectorization) и вынос инвариантов цикла (loop-invariant code motion). Если вы модифицируете код из source1.c так, что в sumOfCubes будет передаваться m вместо n, то компилятор не сможет высчитать значение параметров, придётся компилировать функцию, чтобы она могла работать для любого аргумента. Итоговая функция будет хорошо оптимизирована, ввиду чего будет иметь большой размер, а значит компилятор не будет её инлайнить.

отсутствует оптимизация кода компиляция

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

На текущий момент компилятор Visual C++ не позволяет контролировать размотку циклов. Но вы можете влиять на неё с помощью __forceinline и директивы loop c опцией no_vector (последняя выключает автовекторизацию заданных циклов).

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

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

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

Контролирование оптимизаций

Пустой список c параметром off выключает все оптимизации вне зависимости от ключей компилятора. Пустой список с параметром on применяет вышеозначенные ключи компилятора.

Ключ /Og позволяет выполнять глобальные оптимизации, которые могут быть выполнены только внутри оптимизируемой функции, а не в функциях, которые её вызывают. Если LTCG включён, то /Og позволяет делать WPO.

Директива optimize очень полезна в случаях, когда вы хотите, чтобы разные функции были оптимизированы разными способами: одни по занимаемому размеру, а другие по скорости. Впрочем, если вы действительно хотите иметь контроль оптимизаций такого уровня, вы должны посмотреть в сторону profile-guided-оптимизаций (PGO), которые представляют собой оптимизацию кода с использованием профиля, хранящего информацию о поведении, записанную во время выполнения инструментальной версии кода. Компилятор использует профиль для того, чтобы обеспечить лучшие решения во время оптимизации кода. Visual Studio представляет специальные инструменты, чтобы применить эту технику как к нативному, так и к управляемому коду.

Источник

отсутствует оптимизация кода компиляцияm_i_kuznetsov

Размышления о разработке программного обеспечения и информационных систем

То, что действительно важно, но чему нигде не учат

отсутствует оптимизация кода компиляция

Компилятор C# имеет два параметра компиляции, влияющих на оптимизацию кода: /optimize и /debug.

Параметр /optimize определяет, будет ли оптимизирован код IL, генерируемый компилятором C#. Если указывается параметр /optimize- (по умолчанию), то оптимизации не будет. Если указать /optimize+, то код будет оптимизирован компилятором C#.

Параметр /debug влияет на оптимизацию машинного кода, формируемого JIT-компилятором из IL-кода (не кода C#). Если указано значение /debug- (по умолчанию), то код будет оптимизирован. Если будет указано /debug+, то оптимизации на этапе JIT-компиляции не будет.

Что происходит при оптимизации? Не оптимизированный код, созданный компилятором C#, содержит много пустых команд. Они нужны для возможности изменения кода в процессе отладки. В VS вы можете остановить выполнение кода в нужном месте, внести изменения и продолжить выполнение. Эти же пустые команды позволяют поставить точки останова на управляющих инструкциях IL (ветвлениях, блоках try и т.п.). При оптимизации эти пустые команды в IL-код не вставляются. Соответственно, не тратится время на их выполнение.

Далее, код некоторых простых методов вставляется прямо в вызывающие методы, чтобы сократить затраты на вызове этого простого метода.

Ещё пример. Есть некоторые недостижимые участки кода или условия, которые всегда (по мнению оптимизатора) выполняются. Такие участки кода при оптимизации не вносятся в результирующий код.

Итак, естественным образом отсутствие оптимизации делает быстрее компиляцию кода. Наличие оптимизации делает код. эээ. другим! Вроде как он производительней в подавляющем большинстве случаев, но не во всех. Иногда программисту приходится писать реализацию алгоритма очень аккуратно, но оптимизатор (как в компиляторе C#, так и в JIT-компиляторе) может решить, что в этом коде нужно покопаться, и такой код может стать не только непроизводительным, но и просто некорректным. Оптимизированный код нужно отдельно тестировать. Но это отдельная тема.

Источник

Ускорение сборки 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 фаз трансляции соответственно. Давайте опустим детали и рассмотрим абстрактно процесс трансляции:

отсутствует оптимизация кода компиляция

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

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

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

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

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

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

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

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

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

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

Что касается стандартных заголовков, то здесь можно волноваться поменьше и просто включать их в заголовочный файл при необходимости. Исключением является разве что 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:

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

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

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

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

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

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

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

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

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

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

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

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

отсутствует оптимизация кода компиляция

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

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

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

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

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

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

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».

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

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

Single Compilation Unit

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

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

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

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

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

отсутствует оптимизация кода компиляция

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

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

Использование 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 на нашей кодовой базе:

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

отсутствует оптимизация кода компиляция

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

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

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

отсутствует оптимизация кода компиляция

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

отсутствует оптимизация кода компиляция

отсутствует оптимизация кода компиляция

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

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

Заключение

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

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

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *