Май 2015 г.

Том 30, выпуск 5


Оптимизации компилятором - Что каждый программист должен знать об оптимизациях кода компилятором. Часть 2

Hadi Brais | Май 2015

Продукты и технологии:

Visual Studio 2013, компилятор Visual C++, Microsoft .NET Framework

В статье рассматриваются:

  • распределение регистров;
  • планирование инструкций;
  • ключевые слова volatile и __restrict и ключ /favor.

Исходный код можно скачать по ссылке

Добро пожаловать во вторую часть моей серии статей по оптимизациям компилятором. В первой статье (msdn.microsoft.com/magazine/dn904673) я рассмотрел подстановку функций (замену вызовов телами функций) (function inlining), развертывание циклов (loop unrolling), выделение из цикла кода неизменяемых выражений (инвариантов) (loop-invariant code motion), автоматическую векторизацию (automatic vectorization) и оптимизации COMDAT. Во второй статье я намерен обсудить две другие оптимизации: распределение регистров (register allocation) и планирование инструкций (instruction scheduling). Как всегда, я сосредоточусь на компиляторе Visual C++ с кратким описанием того, как эти вещи работают в Microsoft .NET Framework. Для компиляции кода я буду использовать Visual Studio 2013. Приступим.

Распределение регистров

Распределение регистров — это процесс создания набора переменных в доступных регистрах, чтобы эти переменные не требовали выделения под них места в основной памяти. Этот процесс обычно выполняется на уровне функции в целом. Однако, особенно если включена поддержка Link-Time Code Generation (/LTCG), этот процесс может выполняться между функциями, что зачастую дает более эффективное распределение. (В этом разделе все переменные являются автоматическими, т. е. сроки их существования определяются синтаксически, если не упомянуто иное.)

Распределение регистров — особенно важная оптимизация. Для ее понимания посмотрим, сколько ресурсов требуется для доступа к памяти разных уровней. Доступ к регистру занимает менее одного такта процессора. Доступ к кешу чуть медленнее и требует от нескольких до десятков тактов. Доступ к (удаленной) DRAM-памяти еще медленнее. Наконец, доступ к жесткому диску осуществляется ужасно медленно и может занимать миллионы тактов! Кроме того, доступ к памяти увеличивает трафик в общих кешах и основной памяти. Распределение регистров уменьшает количество обращений к памяти за счет максимально возможного использования доступных регистров.

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

Современные x86-процессоры допускают распределение компилятором следующих регистров: восьми 32-битных регистров общего назначения, восьми 80-битных регистров для чисел с плавающей точкой и восьми 128-битных векторных регистров. Все x64-процессоры предлагают шестнадцать 64-битных регистров общего назначения, восемь 80-битных регистров для чисел с плавающей точкой и минимум шестнадцать векторных регистров, каждый из которых имеет ширину не менее 128 бит. Современные 32-разрядные ARM-процессоры обеспечивают пятнадцать 32-битных регистров общего назначения и тридцать два 64-битных регистров для чисел с плавающей точкой. Все 64-разрядные ARM-процессоры предлагают тридцать один 64-битный регистр общего назначения, тридцать два 128-битных регистров для чисел с плавающей точкой и шестнадцать 128-битных векторных регистров (NEON). Все они доступны для распределения (кроме того, вы можете добавить к этому списку регистры, предоставляемые видеокартой). Когда локальную переменную нельзя создать ни в одном из доступных регистров, память под нее придется выделять в стеке. Это происходит почти в каждой функции по различным причинам, которые мы обсудим позже. Возьмем, к примеру, программу на рис. 1. Она не делает ничего осмысленного, но служит хорошим примером, демонстрирующим распределение регистров.

Рис. 1. Программа-пример, иллюстрирующая распределение регистров

#include <stdio.h>

int main() {
  int n = 0, m;
  scanf_s("%d", &m);
  for (int i = 0; i < m; ++i){
    n += i;
  }
  for (int j = 0; j < m; ++j){
    n += j;
  }
  printf("%d", n);
  return 0;
}

Современные компиляторы способны обеспечить хорошее, но не оптимальное распределение памяти под переменные.

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

С учетом этого я скомпилирую программу на рис. 1 с включенными оптимизациями и посмотрю, как компилятор будет выделять место под локальные переменные в регистрах. Таких переменных четыре: n, m, i и j. Предположим, что целевой платформой является x86. Изучая сгенерированный ассемблерный код (/FA), я вижу, что переменная n получила пространство в регистре ESI, переменная m — в регистре ECX, а i и j — в EAX. Заметьте, что компилятор с умом повторно использовал EAX для двух переменных, поскольку время их жизни не пересекается. Кроме того, обратите внимание на то, что компилятор зарезервировал пространство в стеке для m, так как ее адрес передается. На платформе x64 переменная n получит пространство в регистре EDI, переменная m — в регистре EDX, i — в EAX и j — в EBX. По какой-то причине компилятор на этот раз не стал выделять место под i и j в одном и том же регистре.

Оптимально ли такое распределение? Нет. Проблема в использовании ESI и EDI. Эти регистры сохраняются вызываемым кодом (callee-saved registers), т. е. вызванная функция должна быть уверена, что значения этих регистров, сохраненные на выходе, будут идентичны таковым на входе. Вот почему компилятору пришлось генерировать инструкцию заталкивания содержимого ESI/EDI в стек на входе в функцию и инструкцию его выталкивания из стека на выходе. Компилятор не сумел бы избежать этого на обеих платформах, используя регистр, сохраняемый вызывающим кодом, например регистр EDX. Такие недостатки в алгоритме распределения регистров иногда могут быть смягчены подстановкой функции. Многие другие оптимизации могут сделать код поддающимся более эффективному распределению регистров, такие как устранение мертвого кода (dead code elimination), устранение общих подвыражений (common subexpression elimination) и планирование инструкций.

На практике весьма распространено, что переменные не пересекаются по времени существования, поэтому выделение одного и того же регистра для них всех очень экономично. А как быть, если вы исчерпаете регистры для принятия любой из этих переменных? Вы должны переместить их в память. Однако это можно делать весьма интеллектуальным способом. Вы переносите их все в один и тот же участок стека. Эта оптимизация называется упаковкой стека (stack packing) и поддерживается Visual C++. Упаковка стека уменьшает размер фрейма стека и может увеличить коэффициент попаданий данных в кеш, что повысит производительность.

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

  • Доступные регистры на платформах x86 и x64 (упомянутые ранее) и на любой другой современной платформе (например, ARM) нельзя использовать произвольно. Существуют сложные ограничения. Каждая инструкция налагает ограничения на то, какие регистры могут быть задействованы в качестве операндов. Следовательно, если вы хотите использовать некую инструкцию, вы должны задействовать разрешенные регистры для передачи в них нужных операндов. Кроме того, результаты некоторых инструкцию сохраняются в предопределенных регистрах, значения которых предполагаются инструкциями как изменяемые. Возможна другая последовательность инструкций, которая выполняет то же самое вычисление, но позволяет добиться более эффективного распределения регистров. Задачи выбора инструкций, планирования инструкций и распределения регистров очень сложны.
  • Не все переменные имеют элементарные типы. Отнюдь не редкость наличие автоматических структур и массивов. Такие переменные напрямую не рассматриваются на роль создаваемых в регистрах. Однако их можно создавать в регистрах дискретно. Текущие компиляторы пока не вышли на такой уровень.
  • Соглашение по вызову функции требует выделения фиксированного места для некоторых аргументов, а для других размещение в регистрах не годится независимо от доступности регистров. Подробнее на эту тему позже. Кроме того, все усложняют понятия регистров, сохраняемых вызывающим и вызванным кодом.
  • Если передается адрес какой-то переменной, то ее лучше хранить в том месте, где есть адрес. Регистр не имеет адреса, поэтому такую переменную нужно хранить в памяти независимо от доступности регистров.

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

Вы можете помочь компилятору, включив /LTCG при ориентации на архитектуры x86. Если вы указываете ключ компилятора /GL, сгенерированные OBJ-файлы будут содержать не ассемблерный код, а код на C Intermediate Language (CIL). Соглашения по вызову функций не включаются в CIL-код. Если конкретная функция не определена как экспортируемая из полученного исполняемого файла, компилятор может нарушить ее соглашение по вызову, чтобы повысить ее производительность. Это возможно потому, что компилятор способен идентифицировать все точки вызова функции. Visual C++ использует преимущества этого, делая все аргументы функции подходящими для помещения в регистры независимо от соглашения по вызову. Даже если распределение регистров нельзя улучшить, компилятор попытается переупорядочить параметры для более экономного выравнивания и удалит неиспользуемые параметры. Без ключа /GL получаемые OBJ-файлы содержат двоичный код, в котором соглашения по вызовам уже учтены. Если в ассемблерном OBJ-файле есть точка вызова (call site) функции в OBJ-файле на CIL или если где-то передается адрес функции либо она является виртуальной, тогда компилятор не сможет оптимизировать ее соглашение по вызову. Без /LTCG все функции и методы по умолчанию связываются извне (external linkage), поэтому компилятор не в состоянии применить данный способ. Однако, если функция в OBJ-файле явно определена с внешним связыванием, компилятор может применить этот способ к ней, но только внутри OBJ-файла. Этот метод, называемый в документации пользовательским соглашением по вызову (custom calling convention), важен при ориентации на архитектуры x86, так как соглашение по вызовам по умолчанию, а именно __cdecl, не эффективно. С другой стороны, соглашение по вызову __fastcall в архитектуре x64 очень эффективно, потому что первые четыре аргумента передаются через регистры. По этой причине пользовательское соглашение по вызову выполняется только при ориентации на x86.

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

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

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

Когда ключ /LTCG указан и целевой платформой является x64, компилятор выполняет межпроцедурное распределение регистров. Это означает, что он будет принимать во внимание переменные, объявленные в цепочке функций и пытаться найти более эффективное распределение в зависимости от ограничений, налагаемых кодом в каждой функции. В ином случае компилятор выполняет глобальное распределение регистров, при котором каждая функция обрабатывается по отдельности (здесь слово «глобальный» подразумевает функцию в целом).

Как в C, так и в C++ есть ключевое слово register, позволяющее программисту давать подсказку компилятору в отношении того, какие переменные следует помещать в регистры. По сути, это ключевое слово появилось в первой версии C (примерно в 1972 году) и в то время было весьма полезным, поскольку никто не знал, как эффективно выполнять распределение регистров. (Однако компилятор FORTRAN IV, разработанный IBM Corp. в конце 60-х для серии S/360, умел выполнять простое распределение регистров. Большинство моделей S/360 предлагало шестнадцать 32-битных регистров общего назначения и четыре 64-битных регистра для операций над числами с плавающей точкой!) Кроме того, как и многие другие средства C, ключевое слово register упрощало написание компилятор C. Почти десятилетие спустя был создан C++, и в нем тоже было ключевое слово register, так как C считался подмножеством C++. (К сожалению, между ними было много тонких различий.) С начала 80-х были реализованы многие эффективные алгоритмы распределения регистров, поэтому наличие ключевого слова keyword до сих пор создает путаницу. Большинство языков, созданных с тех пор, не имеет такого ключевого слова (включая C# и Visual Basic). Это ключевое слово убрано в C++11, но не в последней версии C — C11. Оно должно использоваться только при написании эталонных тестов. Компилятор Visual C++ по возможности учитывает это ключевое слово. C не позволяет передавать адрес переменной в регистре. Однако C++ это позволяет, но тогда компилятор должен хранить эту переменную в адресуемом месте, а не в регистре, нарушая вручную указанный класс хранилища.

При ориентации на CLR компилятор должен генерировать код Common Intermediate Language (CIL), который моделирует стековую машину (stack machine). В этом случае компилятор не выполняет распределение регистров (однако, если часть сгенерированного кода является неуправляемой, к ней, конечно, будет применена оптимизация — распределение регистров) и отложит ее до тех пор, пока исполняющей средой не будет запущен JIT-компилятор (или внутренняя часть Visual C++ в случае компиляции .NET Native). RyuJIT, JIT-компилятор, поставляемый с .NET Framework 4.5.1 и выше, реализует довольно приличный алгоритм распределения регистров.

Планирование инструкций

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

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

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

  • С помощью компилятора Компилятор анализирует инструкции в функции, чтобы определить, какие из них могут вызвать простаивание конвейера. Затем он пытается найти другой порядок инструкций, чтобы свести к минимуму издержки ожидаемых простоев, в том же время сохранив корректность программы. Это называют переупорядочением инструкций (instruction reordering).
  • Аппаратное Большинство современных процессоров x86, x64 и ARM способно предсказывать поток инструкций (точнее, микроопераций) и выдавать эти инструкции, чьи операнды и требуемый функциональный блок доступны для выполнения. Это называют выполнением с изменением очередности (out-of-order execution, OoOE или 3OE), или динамическим выполнением. В итоге программа выполняется в порядке, отличном от исходного.

Есть и другие причины, которые могут заставить компилятор переупорядочить определенные инструкции. Например, компилятор может переупорядочить вложенные циклы так, чтобы в коде была более высокая степень локальности ссылок (эту оптимизацию называют перестановкой циклов [loop interchange]). Другой пример — сокращение издержек переноса переменных из регистров в память (register spilling) за счет последовательного размещения инструкций, использующих одинаковое значение, загружаемое из памяти, чтобы это значение загружалось лишь раз. Еще один пример — уменьшение промахов кешей данных и инструкций.

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

Хотя планирование инструкций сохраняет корректность большинства программ, оно может давать некоторые неочевидные и удивительные результаты. На рис. 2 показан пример, где планирование инструкций заставляет компилятор генерировать некорректный код. Чтобы увидеть это, скомпилируйте эту программу как код на C (/TC) в режиме Release. Целевой платформой можно выбрать либо x86, либо x64. Так как вы будете изучать полученный ассемблерный код, укажите /FA, чтобы компилятор создал листинг ассемблерного кода.

Рис. 2. Пример программы, где планирование инструкций дает некорректный код

#include <stdio.h>
#include <time.h>

__declspec(noinline) int compute(){
  /* Здесь какой-то код */
  return 0;
}
int main() {
  time_t t0 = clock();
  /* Целевой адрес */
  int result = compute();
  time_t t1 = clock(); /* перемещаемый вызов функции */
  printf("Result (%d) computed in %lld ticks.",
    result, t1 - t0);
  return 0;
}

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

Поскольку это код на C и поскольку эта программа очень проста, генерируемый ассемблерный код легко понять. Изучая этот ассемблерный код и уделяя особое внимание инструкциям вызовов, вы заметите, что второй вызов функции clock предшествует вызову функции compute (она была перемешена в «целевое место»), что дает совершенно неправильное измерение.

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

Но почему же компилятор делает именно так? Компилятор решил, что второй вызов clock не зависит от вызова compute (и вправду, с точки зрения компилятора эти функции никак не влияют друг на друга). Кроме того, после первого вызова clock скорее всего кеш инструкций содержит некоторые инструкции той функции, а кеш данных — некоторые данные, нужные этим инструкциям. Вызов compute мог бы привести к перезаписи этих инструкций и данных, поэтому компилятор выполнил соответствующее переупорядочение кода.

У компилятора Visual C++ нет ключа, который отключал бы планирование инструкций, сохраняя все остальные оптимизации. Более того, эта проблема может возникнуть из-за динамического выполнения, если функция compute была подставлена в код. В зависимости от того, как идет выполнение функции compute и насколько далеко процессор может заглядывать вперед, 3OE-процессор мог бы решить начать выполнение второго вызова clock до завершения функции compute. Как и в случае компилятора, большинство процессоров не позволяет вам отключать динамическое выполнение. Но, если честно, крайне маловероятно, что эта проблема возникнет из-за динамического выполнения. А как предсказать, что такая может появиться?

Компилятор Visual C++ на самом деле очень осторожен при выполнении этой оптимизации. Причем настолько осторожен, что множество вещей предотвращает переупорядочение им инструкций (например, инструкция call). Я заметил следующие ситуации, в которых компилятор не перемещает вызов функции clock в конкретное (целевое) место.

  • Вызов импортируемой функции из любой функции, вызываемой между местом вызова функции и целевым местом. Как показывает этот код, вызов любой импортируемой функции из функции compute заставляет компилятор не перемещать второй вызов clock:
__declspec(noinline) int compute(){
  int x;
  scanf_s("%d", &x); /* вызов импортируемой функции */
  return x;
}
  • Вызов импортируемой функции между вызовом compute и вторым вызовом clock:
int main() {
  time_t t0 = clock();
  int result = compute();
  printf("%d", result); /* вызов импортируемой функции */
  time_t t1 = clock();
  printf("Result (%d) computed in %lld.", result, t1 - t0);
  return 0;
}
  • Обращение к любой глобальной или статической переменной из любой функции, вызываемой между местом вызова функции и целевым местом. При этом не имеет значения, считывается значение переменной или записывается. Ниже показано, что обращение к глобальной переменной из функции compute заставляет компилятор отказаться от перемещения второго вызова clock:
int x = 0;
__declspec(noinline) int compute(){
  return x;
}
  • Указание t1 как volatile.

Есть и другие ситуации, заставляющие компилятор от переупорядочения инструкций. Все они связаны с правилом «как если бы» (as-if rule) в C++; оно гласит, что компилятор может преобразовать программу, не включающую неопределенные операции любым образом, который устраивает компилятор, если только наблюдаемое поведение кода гарантированно остается прежним. Visual C++ не только отвечает этому правилу, но и ведет себя гораздо консервативнее, чтобы сократить время компиляции кода. Импортируемая функция может вызвать побочные эффекты. Библиотечные функции ввода-вывода и обращение к volatile-переменным вызывают побочные эффекты.

Volatile, restrict и /favor

Указание переменной с ключевым словом volatile влияет как на выделение регистров, так и на переупорядочение инструкций. Во-первых, такая переменная не будет создана в каком-либо регистре. (Большинство инструкций требует хранения некоторых операндов в регистрах, а значит, переменная будет загружена в регистр, но только для выполнения некоторых инструкций, которые используют эту переменную.) То есть чтение или запись этой переменной всегда вызывает обращение к памяти. Во-вторых, запись в volatile-переменную имеет семантику Release, т. е. все обращения к памяти, которые синтаксически происходят до записи в эту переменную, будут выполняться до этого. В-третьих, чтение из volatile-переменной имеет семантику Acquire, т. е. все обращения к памяти, которые синтаксически происходят после чтения из этой переменной, будут выполняться после этого. Но здесь есть подвох: эти гарантии переупорядочения предлагаются только при указании ключа /volatile:ms. В противоположность этому ключ /volatile:iso сообщает компилятору придерживаться языкового стандарта, в котором не предусмотрены никакие гарантии через это ключевое слово. В случае ARM ключ /volatile:iso действует по умолчанию. В других архитектурах по умолчанию действует ключ /volatile:ms. До C++11 ключ /volatile:ms был полезен, так как ничего не предлагал для многопоточных программ. Однако, начиная с C11/C++11, использование /volatile:ms делает ваш код не портируемым, пользоваться им настоятельно не рекомендуется; вместо него применяйте atomics. Стоит отметить: если ваша программа корректно работает при /volatile:iso, она будет корректно работать и при /volatile:ms. Но еще важнее, что, если он корректно работает при /volatile:ms, то может работать некорректно при /volatile:iso, так как первый ключ дает более жесткие гарантии, чем второй.

Ключ /volatile:ms реализует семантику Acquire (захвата во владение) и Release (освобождения). Недостаточно поддерживать ее во время компиляции; компилятор может (в зависимости от целевой платформы) сгенерировать дополнительные инструкции (например, mfence и xchg), чтобы сообщить 3OE-процессору поддерживать эту семантику при выполнении данного кода. Поэтому volatile-переменные приводят к деградации производительности не только потому, что эти переменные нельзя кешировать в регистрах, но и потому, что генерируются дополнительные инструкции.

Семантика ключевого слова volatile согласно языковой спецификации C# аналогична той, которая предлагается компилятором Visual C++ с помощью ключа /volatile:ms. Однако отличия все же есть. Ключевое слово volatile в C# реализует семантику Sequentially Consistent (SC) Acq/Rel, тогда как в C/C++ volatile при ключе /volatile:ms реализует чистую семантику Acq/Rel. Помните, что в C/C++ volatile при /volatile:iso не имеет семантики Acq/Rel. Детали всего этого выходят за рамки данной статьи. В целом, барьеры памяти (memory fences) могут не дать компилятору выполнить многие оптимизации.

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

Ключевое слово __restrict (или restrict) также влияет на эффективность как выделения регистров, так и на планирование инструкций. Но в противоположность volatile ключевое слово restrict может значительно улучшить эти оптимизации. Переменная-указатель, помеченная этим ключевым словом в какой-то области видимости, сообщает, что нет другой переменной, указывающей на тот же объект и созданной вне данной области, а также используемой для его модификации. Кроме того, это ключевое слово может разрешить компилятору выполнить множество оптимизаций над указателями, гарантированно включающих автоматическую векторизацию и оптимизации циклов; это приводит к уменьшению объема сгенерированного кода. Вы можете рассматривать ключевое слово restrict как совершенно секретное, высокотехнологичное, «противооптимизационное» оружие. Оно само по себе заслуживает отдельной статьи, так что здесь оно больше не будет обсуждаться.

Если переменная помечена как volatile и __restrict, то ключевое слово volatile будет приоритетным при принятии решений, касающихся того, как оптимизировать код. По сути, компилятор может полностью игнорировать restrict, но должен уважать volatile.

Ключ /favor может разрешить компилятору выполнить планирование инструкций, оптимизированных порд конкретную архитектуру. Он также может позволить уменьшить объем генерируемого кода, поскольку компилятору скорее всего не понадобится генерировать инструкции, проверяющие поддержку процессором конкретной функциональности. В свою очередь это ведет к повышению коэффициента попаданий кеша инструкций и увеличению производительности. По умолчанию действует /favor:blend, который дает код с хорошей производительностью на процессорах x86 и x64 как от Intel Corp., так и от AMD.

Заключение

Я рассмотрел две важные оптимизации, выполняемые компилятором Visual C++: выделение регистров ит планирование инструкций.

Выделение регистров — самая важная оптимизация, выполняемая компилятором, так как обращение к регистру происходит гораздо быстрее, чем даже к кешу. Планирование инструкций также важно. Однако новейшие процессоры обладают выдающимися средствами динамического выполнения, делая планирование инструкций не столь значимым, как это было раньше. Тем не менее, компилятор может видеть все инструкции функции независимо от того, насколько она велика, тогда как процессор способен увидеть лишь ограниченное количество инструкций. Кроме того, аппаратное обеспечение с измененным порядком выполнения потребляет довольно много электроэнергии, поскольку всегда работает, пока работает ядро. Более того, в процессорах x86 и x64 реализована модель памяти, более строгая, чем модель памяти в C11/C++11, и она предотвращает переупорядочение определенных инструкций, которые могли бы улучшить производительность. Поэтому планирование инструкций с помощью компилятора все еще крайне важно для устройств с ограниченным электропитанием.

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


Hadi Braisаспирант в Индийском технологическом институте Дели (Indian Institute of Technology Delhi, IITD), исследует оптимизации компилятора для технологий памяти следующего поколения. Большую часть времени проводит в написании кода на C/C++/C# и глубоко копает в CLR и CRT. Ведет блог hadibrais.wordpress.com. С ним можно связаться по адресу hadi.b@live.com.

Выражаю благодарность за рецензирование статьи эксперту Microsoft (из группы Microsoft Visual C++) Джиму Хоггу (Jim Hogg).