Оптимизации компилятором
Что каждый программист должен знать об оптимизациях кода компилятором
Продукты и технологии:
Visual Studio 2013, компилятор Visual C++, Microsoft .NET Framework
В статье рассматриваются:
- важные оптимизации кода компилятором Visual C++;
- как компилятор Visual C++ использует блок обработки векторов (vector unit) в процессоре;
- как управлять оптимизациями кода компилятором;
- оптимизации компилятором в Microsoft .NET Framework.
Исходный код можно скачать по ссылке
Высокоуровневые языки программирования предлагают множество абстрактных программных конструкций, таких как функции, условные выражения и циклы, которые удивительно повышают производительность нашего труда. Однако один из недостатков написания кода на высокоуровневом языке программирования — потенциально возможное значительное снижение скорости работы программы. В идеале, вы должны писать понятный, простой в сопровождении код, не жертвуя производительностью. По этой причине компиляторы пытаются автоматически оптимизировать код для повышения его быстродействия, и в наши дни они стали весьма изощренными в оптимизациях. Компиляторы могут преобразовывать циклы, условные выражения и рекурсивные функции, исключать целые блоки кода и использовать преимущества архитектуры набора инструкций (instruction set architecture, ISA) целевого процессора, чтобы сделать код быстрым и компактным. Гораздо лучше сосредоточиться на написании понятного кода, чем пытаться делать оптимизации вручную, которые приводят к криптографическому коду, трудному в сопровождении. По сути, ручная оптимизация кода может не дать компилятору выполнить дополнительные или более эффективные оптимизации.
Вместо оптимизации кода вручную вам следует подумать о таких аспектах вашего проекта, как применение более быстрых алгоритмов, включение параллелизма на уровне потоков и использование специфичных для инфраструктуры средств (вроде конструкторов перемещения).
Эта статья посвящена оптимизациям, выполняемым компилятором Visual C++. Я намерен обсудить самые важные методы оптимизации и решения, которые должен принимать компилятор, чтобы применить их. Моя цель не в том, чтобы рассказать вам об оптимизации кода вручную, а в том, чтобы показать, почему вы можете довериться компилятору в оптимизации кода в ваших интересах. Эта статья ни в коей мере не претендует на полный анализ оптимизаций, выполняемых компилятором Visual C++. Однако она демонстрирует оптимизации, о которых вы должны знать, и то, как взаимодействовать с компилятором для их применения.
Существуют и другие важные оптимизации, которые в настоящее время выходят за рамки возможностей любого компилятора, например замена неэффективного алгоритма эффективным или изменение разметки структуры данных для повышения степени ее локальности. Но такие оптимизации в этой статье не рассматриваются.
Определение оптимизаций кода компилятором
Оптимизация — это процесс преобразования части кода в другую функционально эквивалентную часть для улучшения одной или более характеристик кода. Две самые важные характеристики — это скорость работы и размер кода. К другим характеристикам относятся энергопотребление, необходимое для выполнения кода, время компиляции кода и — в случае, если конечный код требует JIT-компиляции, — длительность JIT-компиляции.
Компиляторы постоянно совершенствуются в отношении методов, применяемых ими для оптимизации кода. Но они не идеальны. Тем не менее, вместо траты на ручную оптимизацию программы гораздо продуктивнее использовать специфические средства компилятора и дать ему возможность оптимизировать код.
Существует четыре способа, помогающих компилятору оптимизировать ваш код более эффективно.
- Пишите понятный, простой в сопровождении код. Не рассматривайте объектно-ориентированные средства Visual C++ как врагов производительности. Новейшая версия Visual C++ умеет сводить к минимуму соответствующие издержки, а иногда и полностью исключать их.
- Используйте директивы компилятора. Например, сообщите компилятору задействовать то соглашение по вызову функций, которое работает быстрее, чем предлагаемое по умолчанию.
- Применяйте встроенные в компилятор функции (intrinsic function). Встроенной называется особая функция, реализация которой автоматически предоставляется компилятором. Компилятор знает о такой функции все и заменяет ее вызов чрезвычайно эффективной последовательностью инструкций, использующих преимущества целевой ISA. В настоящее время Microsoft .NET Framework не поддерживает встроенные функции, поэтому они не поддерживаются ни одним из управляемых языков. Однако Visual C++ имеет обширную поддержку этого функционала. Заметьте: хотя применение встроенных функций может повысить производительность кода, оно ухудшает его читаемость и портируемость.
- Используйте оптимизацию на основе профиля (profile-guided optimization, PGO). С помощью этого метода компилятор больше узнает о том, как будет вести себя код в период выполнения, и сможет соответственно оптимизировать его.
Цель этой статьи — показать, почему вы можете доверять компилятору, продемонстрировав оптимизации, которые выполняются над неэффективным, но читаемым кодом (с применением первого метода). Кроме того, я дам краткое введение в оптимизацию на основе профиля и упомяну некоторые из директив компилятора, позволяющих вам более тонко оптимизировать некоторые части своего кода.
Методов оптимизации компилятором много: от простых преобразований, таких как свертывание констант (constant folding), до экстремальных преобразований вроде планирования выполнения команд (instruction scheduling). Однако в этой статье я ограничусь обсуждением некоторых из важнейших оптимизаций — тех, которые могут значительно повысить производительность (на десятки процентов) и уменьшить размер кода: замены вызовов телами функций (function inlining), оптимизаций COMDAT и оптимизаций циклов (loop optimizations). Первые два метода я рассмотрю в следующем разделе, а затем покажу, как контролировать оптимизации, выполняемые Visual C++. Наконец, я кратко поясню оптимизации в .NET Framework. На протяжении всей статьи я буду использовать Visual Studio 2013 для компиляции кода.
Генерация кода на этапе связывания
Генерация кода на этапе связывания (Link-Time Code Generation, LTCG) — это метод выполнения полных оптимизаций программы (whole program optimizations, WPO), написанной на C/C++. Компилятор C/C++ транслирует каждый файл исходного кода раздельно и создает соответствующий объектный файл. То есть компилятор может применять оптимизации только к индивидуальному файлу исходного кода, а не ко всей программе. Однако некоторые важные оптимизации могут быть выполнены лишь при анализе полной программы. Вы можете применять эти оптимизации в период связывания (link time), а не при компиляции, так как компоновщик (linker) имеет полное представление о программе.
Один из недостатков написания кода на высокоуровневом языке программирования — потенциально возможное значительное снижение скорости работы программы.
Когда LTCG включена (указанием ключа /GL), драйвер компилятора (cl.exe) запустит только блок предварительной обработки (front end) компилятора (c1.dll или c1xx.dll) и отложит до этапа связывания запуск блока окончательной обработки (back end) (c2.dll). Конечные объектные файлы будут содержать код на C Intermediate Language (CIL), а не аппаратно-зависимый ассемблерный код. Потом, когда будет запущен компоновщик (link.exe), он увидит, что объектные файлы содержат CIL-код, и запустит блок окончательной обработки компилятора, который в свою очередь выполнит WPO, сгенерирует двоичные объектные файлы и вернет управление компоновщику, чтобы тот «сшил» все объектные файлы воедино и создал исполняемый файл.
Блок предварительной обработки на самом деле выполняется некоторые оптимизации, например свертывание констант, независимо от того, включены или отключены оптимизации. Однако все важные оптимизации выполняются блоком окончательной обработки компилятора, и их можно контролировать с помощью ключей компилятора.
LTCG позволяет блоку окончательной обработки агрессивно выполнять многие оптимизации (указанием /GL вместе с ключами компилятора /O1 или /O2 и /Gw, а также ключей компоновщика /OPT:REF и /OPT:ICF). В этой статье я рассмотрю только подстановку функций (замену вызовов функций телами функций) (function inlining) и оптимизации COMDAT. Полный список оптимизаций LTCG см. в документации. Заметьте, что компоновщик может выполнять LTCG применительно к неуправляемым объектным файлам, смешанным объектным файлам (управляемым и неуправляемым), чисто управляемым объектным файлам, безопасно управляемым объектным файлам (safe managed object files) и безопасным файлам .netmodules.
Я создам программу, состоящую из двух файлов исходного кода (source1.c и source2.c) и заголовочного файла (source2.h). Файлы source1.c и source2.c показаны на рис. 1 и 2 соответственно. Заголовочный файл, который содержит прототипы всех функций в source2.c, довольно прост, поэтому я не стану приводить его здесь.
Рис. 1. Файл source1.c
#include <stdio.h> // scanf_s и printf
#include "Source2.h"
int square(int x) { return x*x; }
main() {
int n = 5, m;
scanf_s("%d", &m);
printf("The square of %d is %d.", n, square(n));
printf("The square of %d is %d.", m, square(m));
printf("The cube of %d is %d.", n, cube(n));
printf("The sum of %d is %d.", n, sum(n));
printf("The sum of cubes of %d is %d.", n, sumOfCubes(n));
printf("The %dth prime number is %d.", n, getPrime(n));
}
Рис. 2. Файл source2.c
#include <math.h> // sqrt.
#include <stdbool.h> // bool (true и false)
#include "Source2.h"
int cube(int x) { return x*x*x; }
int sum(int x) {
int result = 0;
for (int i = 1; i <= x; ++i) result += i;
return result;
}
int sumOfCubes(int x) {
int result = 0;
for (int i = 1; i <= x; ++i) result += cube(i);
return result;
}
static
bool isPrime(int x) {
for (int i = 2; i <= (int)sqrt(x); ++i) {
if (x % i == 0) return false;
}
return true;
}
int getPrime(int x) {
int count = 0;
int candidate = 2;
while (count != x) {
if (isPrime(candidate))
++count;
}
return candidate;
}
Файл source1.c содержит две функции: square (принимает целое значение и возвращает результат его возведения в квадрат) и main (главная функция программы). Функция main вызывает функцию square и все функции из source2.c, кроме isPrime. Файл source2.c содержит пять функций: cube (возвращает результат возведения в куб переданного целого значения), sum (возвращает сумму всех целых значений от 1 до переданного значения), sumOfcubes (возвращает сумму кубов всех целых значений от 1 до переданного значения), isPrime (определяет, является ли данное целое значение простым) и getPrime (возвращает x-ое простое число). Я опустил проверку ошибок, потому что она не представляет интереса в этой статье.
Код прост, но полезен. В нем есть ряд функций, выполняющих простые вычисления, а некоторые нужны лишь для создания циклов. Функция getPrime самая сложная, потому что она содержит цикл while, из которого вызывается функция isPrime, тоже содержащая цикл. Я буду использовать этот код для демонстрации одной из важнейших оптимизаций (подстановки функций) и некоторых других оптимизаций.
Я буду компилировать этот код в трех разных конфигурациях и изучать результаты, чтобы определить, как он был преобразован компилятором. Если вы будете следовать за мной, вам понадобится ассемблерный выходной файл (создается ключом /FA[s] компилятора) для анализа полученного ассемблерного кода и файл сопоставлений (map file) (создается ключом /MAP компоновщика) для определения выполненных оптимизаций COMDAT (компоновщик тоже может сообщать об этом, если вы используете ключи /verbose:icf и /verbose:ref). Поэтому убедитесь в том, что эти ключи указываются во всех рассматриваемых далее конфигурациях. Кроме того, я буду использовать компилятор C (/TC), чтобы облегчить анализ сгенерированного кода. Однако все, что обсуждается здесь, применимо и к коду на C++.
Конфигурация Debug
Конфигурация Debug используется в основном потому, что все оптимизации стадии окончательной обработки отключаются при задании ключа /Od компилятора без ключа /GL. При компиляции кода в такой конфигурации получаемые объектные файлы будут содержать двоичный код, который точно соответствует исходному коду. Чтобы убедиться в этом, вы можете проанализировать полученные ассемблерные выходные файлы и файл сопоставлений. Эта конфигурация эквивалентна отладочной конфигурации в Visual Studio.
Конфигурация Compile-Time Code Generation Release
Эта конфигурация похожа на конфигурацию Release, при которой оптимизации включены (задан ключ /O1, /O2 или /Ox компилятора), но ключ /GL компилятора не указан. При такой конфигурации полученные объектные файлы будут содержать оптимизированный двоичный код. Однако оптимизации на уровне всей программы не выполняются.
Изучая сгенерированный из source1.c файл листинга ассемблерного кода, вы заметите, что выполнены две оптимизации. Прежде всего первый вызов функции square — square(n) на рис. 1 — полностью исключен за счет оценки вычисления при компиляции. Как это произошло? Компилятор определил, что функция square компактна, поэтому нужно подставить ее тело в код вместо вызова. После этого компилятор определил, что значение локальной переменной n известно и не изменяется между оператором присваивания и вызовом функции. Поэтому он заключил, что можно безопасно выполнять умножение и подставить результат (25). При второй оптимизации второй вызов функции square — square(m) — тоже был заменен телом функции. Однако, поскольку значение m не известно при компиляции, компилятор не мог оценить вычисление и сгенерировал реальный код.
Теперь я посмотрю файл ассемблерного листинга, созданный из source2.c, который гораздо интереснее. Вызов функции cube в sumOfCubes был заменен телом функции. В свою очередь это позволило компилятору выполнить значительные оптимизации в цикле (как вы увидите в разделе «Оптимизации циклов»). Кроме того, в функции isPrime задействован набор инструкций SSE2 для преобразования из int в double при вызове функции sqrt, а также для преобразования double в int при возврате из sqrt. А sqrt вызывается лишь раз, когда начинается цикл. Заметьте: если компилятору не указывается ключ /arch, то x86-компилятор использует SSE2 по умолчанию. Большинство x86-процессоров, а также все процессоры x86-64 поддерживают SSE2.
Конфигурация Link-Time Code Generation Release
Конфигурация LTCG Release идентична конфигурации Release в Visual Studio. В этой конфигурации оптимизации включены и указывается ключ /GL компилятора. Этот ключ неявно задается при использовании /O1 или /O2. Он сообщает компилятору генерировать объектные файлы CIL, а не ассемблерные объектные файлы. Благодаря этому компоновщик вызывает блок окончательной обработки компилятора для выполнения WPO, как было описано ранее. Теперь я рассмотрю несколько оптимизаций WPO, чтобы показать колоссальные преимущества LTCG. Листинги ассемблерного кода, генерируемые при этой конфигурации, можно скачать как пакет, сопутствующий этой статье.
Пока подстановка функций включена (/Ob, который включается всякий раз, когда вы запрашиваете оптимизации), ключ /GL позволяет компилятору подставлять функции, определенные в других блоках трансляции, независимо от того, указан ли ключ /Gy компилятора (о нем чуть позже). Ключ /LTCG компоновщика не обязателен и обеспечивает управление только компоновщиком.
В идеале, разработчики предпочитают писать понятный, простой в сопровождении код, не жертвуя производительностью.
Изучая ассемблерный листинг для файла source1.c, вы заметите, что все вызовы функций, кроме scanf_s, были заменены телами этих функций. В итоге компилятор смог выполнить вычисления cube, sum и sumOfCubes. Не была подставлена только функция isPrime. Однако, если бы она была подставлена вручную в getPrime, компилятор все равно сумел бы подставить getPrime в main.
Как видите, подстановка функций важна не только потому, что она оптимизирует код, избавляясь от вызовов функций, но и по той причине, что позволяет компилятору выполнять многие другие оптимизации. Подстановка функции обычно повышает производительность за счет увеличения размера кода. Излишнее применение этой оптимизации ведет к феномену, известному как «раздувание кода» (code bloat). В каждой точке вызова компилятор проводит анализ издержек и преимуществ, а затем решает, стоит ли подставлять данную функцию.
Ввиду важности подстановки компилятор Visual C++ обеспечивает гораздо больше поддержки для этой оптимизации, чем того требует стандарт в отношении контроля за подстановкой. Вы можете сообщить компилятору никогда не подставлять некий диапазон функций с помощью директивы auto_inline. Или указать компилятору никогда не подставлять конкретную функцию (метод), пометив ее как __declspec(noinline). Вы можете пометить функцию ключевым словом inline, чтобы подсказать компилятору, что эту функцию следует подставлять (хотя компилятор может проигнорировать эту подсказку, если подстановка окажется чистой потерей). Ключевое слово inline доступно еще с первой версии C++ — оно было введено в C99. В коде на C и C++ можно использовать специфичное ключевое слово __inline от Microsoft; оно полезно, когда вы применяете старую версию C, которая не поддерживает ключевое слово inline. Более того, вы можете указать ключевое слово __forceinline (в C и C++), чтобы заставить компилятор по возможности всегда подставлять функцию. Наконец, можно сообщить компилятору раскрыть рекурсивную функцию либо до конкретной, либо до неопределенной глубины ее подстановкой с помощью директивы inline_recursion. Заметьте, что компилятор в настоящее время не предлагает никаких средств, которые позволяли бы вам управлять подстановкой в точке вызова, а не в определении функции.
Ключ /Ob0 полностью отключает подстановку. Вы должны использовать этот ключ при отладке (он автоматически указывается в конфигурации Debug в Visual Studio). Ключ /Ob1 сообщает компилятору рассматривать для подстановки только те функции, которые помечены ключевым словом inline, __inline или __forceinline. Ключ /Ob2, который вступает в действие при задании /O[1|2|x], информирует компилятор рассматривать для подстановки любую функцию. На мой взгляд, единственная причина использовать ключевое слово inline или __inline — управление подстановкой с помощью ключа /Ob1.
Компилятор не сможет подставить функцию в определенных условиях. Один из примеров — виртуальный вызов виртуальной функции; такую функцию нельзя подставить, потому что компилятору может быть не известно, какая именно функция будет вызываться. Другой пример — вызов функции по указателю на функцию, а не по имени. Если вы хотите в максимальной мере использовать подстановку, избегайте таких условий. Полный список этих условий изложен в документации MSDN.
Подстановка функций — не единственная оптимизация, которая более эффективна при применении на уровне всей программы. По сути, на этом уровне становится эффективнее большинство оптимизаций. В остальной части этого раздела я расскажу о специфическом классе таких оптимизаций — об оптимизациях COMDAT.
По умолчанию при компиляции единицы трансляции весь код будет храниться в одном разделе полученного объектного файла. Компоновщик оперирует на уровне разделов. То есть он может удалять разделы, объединять их и переупорядочивать. Вариант по умолчанию не позволяет компоновщику выполнить три оптимизации, которые могут значительно сократить размер исполняемого файла и повысить его производительность (на десятки процентов). Первая — это исключение функций и глобальных переменных, на которые нет ссылок. Вторая — свертывание идентичных функций и глобальных переменных, являющихся константами. Третья — такое переупорядочение функций и глобальных переменных, чтобы те функции, которые проходят по одному и тому же пути выполнения, и те переменные, к которым обращаются вместе, располагались физически ближе в памяти для улучшения локальности.
Чтобы включить эти оптимизации от компоновщика, можно сообщить компилятору упаковать функции и переменные в разные разделы, указав ключи /Gy (связывание на уровне функций) и /Gw (оптимизация глобальных данных). Такие разделы называются COMDAT. Вы также можете пометить конкретную глобальную переменную ключевым словом __declspec(selectany), чтобы сообщить компилятору упаковать переменную в COMDAT. Затем вы указываете ключ /OPT:REF компоновщику, и он будет исключать функции и глобальные переменные, не имеющие ссылки (unreferenced). Кроме того, при задании ключа /OPT:ICF компоновщик будет свертывать идентичные функции и глобальные переменные — константы. (ICF расшифровывается как Identical COMDAT Folding.) Ключ /ORDER указывает компоновщику помещать COMDAT'ы в конечный образ в определенном порядке. Заметьте, что все эти оптимизации выполняются компоновщиком и не требуют ключа /GL компилятора. Ключи /OPT:REF и /OPT:ICF следует отключать при отладке по очевидным причинам.
По возможности всегда используйте LTCG. Единственная причина не использовать LTCG — вы хотите распространять созданные объектные и библиотечные файлы. Вспомним, что эти файлы содержат CIL-код, а не ассемблерный код. CIL-код может использоваться компилятором и/или компоновщиком только той версии, с помощью которой он был сгенерирован, что может существенно ограничить область применения объектных файлов: у разработчиков должна быть та же версия компилятора, чтобы они смогли задействовать эти файлы. В этом случае, если только вы не собираетесь распространять объектные файлы для каждой версии компилятора, следует использовать генерацию кода этапа компиляции (compile-time code generation, CTCG). Помимо ограниченного применения, эти объектные файлы во много раз объемнее, чем соответствующие ассемблерные объектные файлы. Однако не забывайте о колоссальном преимуществе объектных CIL-файлов, которые поддерживают оптимизацию WPO.
Оптимизации циклов
Компилятор Visual C++ поддерживает несколько оптимизаций циклов, но я рассмотрю только три из них: развертывание циклов (loop unrolling), автоматическая векторизация (automatic vectorization) и выделение из цикла кода неизменяемых выражений (инвариантов) (loop-invariant code motion). Если вы модифицируете код на рис. 1 так, чтобы в sumOfCubes передавалась m вместо n, то компилятор не сможет определить значение параметра, поэтому он должен компилировать функцию для обработки любого аргумента. Полученная функция высоко оптимизирована и ее размер довольно велик, а значит, компилятор не станет подставлять ее.
Компиляция кода с ключом /O1 приводит к созданию ассемблерного кода, оптимизированного по размеру. В этом случае к функции sumOfCubes никакие оптимизации не применяются. Компиляция с ключом /O2 дает код, оптимизированный по скорости работы. Размер кода будет значительно больше, но сам код будет работать существенно быстрее, так как цикл в sumOfCubes развернут и векторизован. Важно понимать, что векторизация была бы невозможна без подстановки функции cube. Более того, без подстановки функций развертывание циклов было бы не столь эффективным. Упрощенное графическое представление создаваемого ассемблерного кода показано на рис. 3. Схема потока управления одинакова для архитектур x86 и x86-64.
Рис. 3. Схема потока управления в sumOfCubes
SSE4.1 supported and x >= 8 | SSE4.1 поддерживается и x >= 8 |
Use SSE4.1 to perform 8 multiplications | Используем SSE4.1 для выполнения восьми умножений |
SSE4.1 not supported or x < 8 | SSE4.1 не поддерживается или x < 8 |
Store result in eax and return | Сохраняем результат в eax и возвращаем управление |
Iterate once more, store result in eax and return | Выполняем еще одну итерацию, сохраняем результат в eax и возвращаем управление |
Use traditional instructions to perform 2 multiplications | Используем традиционные инструкции для выполнения двух умножений |
Store result in eax and return | Сохраняем результат в eax и возвращаем управление |
На рис. 3 черный ромб обозначает точку входа, а красные прямоугольные блоки указывают точки выхода. Светло-серые ромбы представляют условия, которые выполняются как часть функции sumOfCubes в период выполнения. Если SSE4 поддерживается процессором и x больше или равен восьми, тогда SSE4-инструкции будут задействованы для выполнения четырех умножений одновременно. Процесс выполнения одной и той же операции над несколькими значениями одновременно называют векторизацией (vectorization). Кроме того, компилятор будет дважды развертывать цикл, т. е. тело цикла будет дважды повторяться на каждой итерации. В итоге на каждой итерации будут выполняться восемь умножений. Когда x станет меньше восьми, для выполнения остальных вычислений будут использоваться традиционные инструкции. Заметьте, что компилятор сгенерировал три точки выхода, содержащие отдельные эпилоги в функции вместо одного. Это уменьшает количество переходов.
Развертывание цикла — это процесс повторения его тела в пределах цикла, так чтобы более одной итерации цикла выполнялось в рамках одной итерации развернутого цикла. Причина, по которой это увеличивает производительность, заключается в том, что инструкции, управляющие циклом, выполняются реже. Еще важнее, что это, возможно, позволит компилятору выполнить много других оптимизаций, например векторизацию. Недостаток развертывания в том, что он увеличивает размер кода и нагрузку на регистры. Однако в зависимости от тела цикла развертывание может повысить скорость работы кода на десятки процентов.
В отличие от процессоров x86 все процессоры x86-64 поддерживают SSE2. Более того, вы можете использовать преимущества наборов инструкций AVX/AVX2 в новейших архитектурах x86-64 от Intel и AMD, указав ключ /arch. Задание /arch:AVX2 также разрешает компилятору использовать наборы инструкций FMA и BMI.
В настоящее время компилятор Visual C++ не позволяет вам контролировать развертывание циклов. Однако вы можете эмулировать этот метод, используя шаблоны совместно с ключевым словом __ forceinline. Автоматическую векторизацию конкретного цикла можно отключить с помощью директивы loop с параметром no_vector.
Глядя на сгенерированный ассемблерный код, проницательный читатель заметит, что этот код можно еще немного оптимизировать. Однако компилятор уже проделал большую работу и не станет тратить гораздо больше времени на анализ кода и применение малозначимых оптимизаций.
Функция someOfCubes — не единственная, чей цикл можно развернуть. Если вы модифицируете код таким образом, что в функцию sum будет передаваться m вместо n, компилятор не сможет оценить эту функцию и поэтому сгенерирует ее код. В этом случае цикл будет развернут дважды.
Последняя оптимизация, которую я хотел обсудить, — выделение из цикла кода инвариантов (loop-invariant code motion). Рассмотрим следующий фрагмент кода:
int sum(int x) {
int result = 0;
int count = 0;
for (int i = 1; i <= x; ++i) {
++count;
result += i;
}
printf("%d", count);
return result;
}
Здесь единственное изменение — дополнительная переменная, которая увеличивается на 1 при каждой итерации, а затем ее значение выводится. Нетрудно увидеть, что этот код может быть оптимизирован переносом приращения переменной-счетчика за пределы цикла. То есть я могу просто присвоить x переменной-счетчику. Эту оптимизацию называют выделением из цикла кода инвариантов. Инвариантная к циклу часть ясно показывает, что этот метод работает, только когда код не зависит от какого-либо выражения в заголовке цикла.
А теперь небольшой фокус: если применить эту оптимизацию вручную, конечный код может работать медленнее в некоторых условиях. Понимаете ли вы почему? Рассмотрим, что будет, если значение x не является положительным. Цикл никогда не выполнится, а это значит, что в неоптимизированной версии переменная-счетчик не будет затрагиваться. Но в версии, оптимизированной вручную, ненужное присваивание счетчику значения x выполняется вне цикла! Более того, если бы значение x было отрицательным, счетчик содержал бы неправильное значение. И люди, и компиляторы подвержены таким ошибкам. К счастью, компилятор Visual C++ достаточно интеллектуален, чтобы понять это, генерируя условие цикла до присваивания, что в итоге обеспечивает более высокую производительность при всех значениях x.
Некоторые важные оптимизации могут быть выполнены лишь при анализе полной программы.
Вывод: если вы не являетесь экспертом по компиляторам или по оптимизациям, выполняемым компилятором, то должны избегать попыток преобразования кода вручную только для того, чтобы он выглядел работающим быстрее. Доверяйте компилятору в оптимизации своего кода.
Управление оптимизациями
В дополнение к ключам /O1, /O2 и /Ox компилятора вы можете контролировать оптимизации для конкретных функций с помощью директивы optimize, которая выглядит так:
#pragma optimize( "[optimization-list]", {on | off} )
Список оптимизации может быть либо пустым, либо содержать одно или более из следующих значений: g, s, t и y. Они соответствуют ключам компилятора /Og, /Os, /Ot и /Oy.
Пустой список с параметром off приводит к отключению всех оптимизаций независимо от указанных ключей компилятора. Пустой список с параметром on вводит в действие заданные ключи компилятора.
Ключ /Og разрешает глобальные оптимизации. Если LTCG включен, ключ /Og разрешает WPO.
Директива optimize полезна, когда вы хотите, чтобы разные функции оптимизировались по-разному — некоторые для большей компактности, другие для увеличения скорости работы. Однако, если вам действительно нужен контроль такого уровня, вы должны подумать об оптимизации на основе профиля (profile-guided optimization, PGO). Это процесс оптимизации кода с применением профиля, содержащего информацию о поведении, которая была записана при выполнении оснащенной (средствами мониторинга и протоколирования) версии кода. Компилятор использует профиль для принятия более эффективных решений о том, как оптимизировать код. Visual Studio предоставляет необходимые инструменты для применения этого метода к управляемому и неуправляемому коду.
Оптимизации в .NET
В .NET-модели компиляции компоновщик не участвует. Но есть компилятор исходного кода (компилятор C#) и JIT-компилятор. Компилятор исходного кода выполняет только небольшие оптимизации. Например, он не занимается подстановкой функций и оптимизациями циклов. Эти оптимизации берет на себя JIT-компилятор. JIT-компилятор, входящий во все версии .NET Framework вплоть до 4.5 включительно, не поддерживает SIMD-инструкции. Но JIT-компилятор в .NET Framework 4.5.1 и более поздних версиях, называемый RyuJIT, такие инструкции поддерживает.
В чем разница между RyuJIT и Visual C++ в плане возможностей оптимизации? Поскольку он делает свою работу в период выполнения, RyuJIT может осуществлять оптимизации, на которые не способен Visual C++. Например, в период выполнения RyuJIT может определить, что условие в выражении if никогда не будет true в данном конкретном сеансе работы приложения, и поэтому соответственно оптимизировать его. Кроме того, RyuJIT использует преимущества возможностей процессора, на котором он выполняется. Скажем, если процессор поддерживает SSE4.1, JIT-компилятор сгенерирует только инструкции SSE4.1 для функции sumOfcubes, благодаря чему сгенерированный код получится гораздо более компактным. Однако он не имеет возможности тратить много времени на оптимизацию кода, потому что время JIT-компиляции сказывается на производительности приложения. С другой стороны, компилятор Visual C++ может потратить гораздо больше времени для обнаружения других возможностей оптимизации и задействовать их преимущества. Отличная новая технология от Microsoft, называемая .NET Native, позволяет компилировать управляемый код в самодостаточные исполняемые файлы, оптимизируемые с помощью блока окончательной обработки компилятора Visual C++. В настоящее время эта технология поддерживается только для приложений Windows Store.
Возможность контролировать оптимизации управляемого кода пока что весьма ограниченная. Компиляторы C# и Visual Basic позволяют лишь включать или выключать оптимизации ключом /optimize. Для контроля JIT-оптимизаций можно применять атрибут System.Runtime.CompilerServices.MethodImpl к какому-либо методу с указанием варианта из MethodImplOptions. Вариант NoOptimization отключает оптимизации, NoInlining запрещает подстановку метода, а AggressiveInlining (.NET 4.5) выдает рекомендацию (а не просто подсказку) JIT-компилятору подставить метод.
Заключение
Методы оптимизации, рассмотренные в этой статье, могут значительно ускорить работу вашего кода на десятки процентов, и все они поддерживаются компилятором Visual C++. Эти методы важны потому, что при их применении компилятор получает возможность выполнять и другие оптимизации. Моя статья ни в коей мере не претендует на исчерпывающее обсуждение оптимизаций, осуществляемых компилятором Visual C++. Но я надеюсь, что она дала вам представление о возможностях компилятора. Visual C++ способен на большее, гораздо большее, так что ждите очередную статью на эту тему.
Хейди Брейс (Hadi Brais) — аспирант в Индийском технологическом институте Дели (Indian Institute of Technology Delhi, IITD), исследует оптимизации компилятора для технологий памяти следующего поколения. Большую часть времени проводит в написании кода на C/C++/C# и глубоко копает в CLR и CRT. Ведет блог hadibrais.wordpress.com. С ним можно связаться по адресу hadi.b@live.com.
Выражаю благодарность за рецензирование статьи эксперту Microsoft Джиму Хоггу (Jim Hogg).