Оптимизируем шаг за шагом с компилятором Intel C++
Каждый разработчик рано или поздно сталкивается с проблемой оптимизации своего приложения, причём сделать это хочется с минимальным вложением усилий и максимальной выгодой в плане производительности. В этом вопросе на помощь приходит компилятор, который на сегодняшний день многое умеет делать автоматически, нужно только сказать ему об этом с помощью ключей. Опций компиляции, как и видов оптимизации, развелось достаточно много, поэтому я решил написать блог о пошаговой оптимизации приложения с помощью компилятора Intel.
Итак, весь тернистый путь компиляции и оптимизации нашего приложения можно разбить на 7 шагов. Пошагали! Шаг 1. Соберём ли мы код вообще без оптимизации? Правильно, на первом шаге нам бы хотелось ответить на этот вопрос. Я часто начинаю процесс оптимизации именно с отключения всего и вся в компиляторе. Почему? Ну, во-первых, хочу убедиться, что мой код работает корректно без какого-либо вмешательства со стороны компилятора и его хитроумных преобразований. Отключаю оптимизации с помощью ключа -O0 (на Windows /Od), собираю код и запускаю приложение. Да и отлаживать неоптимизированный код проще.
Шаг 2. Что там можно «попроще» подключить? Начинаем с «базовых» опций.
-O1/-OsПервый, базовый уровень оптимизации, при котором компилятор не делает автовекторизации — то есть даже не пытается. При этом выполняется анализ потока данных, перемещение кода, снижение стоимости операций, анализ времени жизни переменных, планирование выполнения команд. Часто используется для того, чтобы ограничить размер нашего приложения, несколько урезав оптимизации. Если включена опция О1, то Os тоже неявно включается.
-O2Уровень оптимизации, который включен по умолчанию, ориентированный на скорость выполнения приложения. Начиная с этого уровня включается векторизация циклов. Кроме того, выполняется ряд базовых оптимизаций с циклами, инлайнинг, IP (Intra-file interprocedural) оптимизация и многое другое.
-O3На этом максимальном уровне оптимизаций, в дополнение к тому, что делалось на O2, включается ряд более агрессивных трансформаций с циклами, например, развертка (unrolling) внешнего цикла и объединение (fusion) внутренних, разделение циклов на блоки (blocking), объединение условий IF. Весьма неплохой обзор самих оптимизаций представлен здесь. Если для вашего приложения критично сохранение численных результатов (например, научные вычисления), то нужно быть аккуратным в использовании этой опции. Зачастую, цифры «плывут» и нужно ограничивать оптимизацию, возвращаясь к -O2 и используя опции -fp-model. В целом, после компиляции с -O2 никто нас не ограничивает попробовать -O3 и просто посмотреть, что будет. По идее, приложение должно работать быстрее.
-no-prec-divОперация деления, соответствующая стандарту IEEE, весьма трудозатратна. Можно несколько поступиться точностью в вычислениях, но ускорить вычисления с помощью данной опции, при которой компилятор, например, будет заменять выражения вида A/B на A*(1/B).
-ansi-aliasДанная опция говорит компилятору, что мы придерживаемся строгих правил алиасинга (strict aliasing) при написании нашего кода в соответствии со стандартом ISO C. При соблюдении этих правил, при разыменовывании указателей на объекты разных типов мы никогда не обратимся к одному и тому же участку памяти, что даёт больший простор компилятору для выполнения оптимизаций. Для подробного описания алиасинга можно почитать эту статью.Важно заметить, что начиная с компилятора Intel версии 15.0 (Intel Parallel Studio XE 2015 Composer Edition) и выше, эта опция включена по умолчанию, а вот если мы пишем на более ранних версиях — не забываем про неё.
Шаг 3. Используем специфику «железа«Для подключения оптимизаций, специфичных для процессоров Intel, можно использовать опцию -x’code'. Она говорит компилятору, какие процессорные возможности можно использовать, включая набор инструкций, которые могут быть сгенерированы. В качестве 'code' можно задавать SSE2, SSE3, SSSE3, SSE3_ATOM и SSSE3_ATOM, ATOM_SSSE3, ATOM_SSE4.2, SSE4.1, SSE4.2, AVX, CORE-AVX-I, CORE-AVX2, CORE-AVX512, MIC-AVX512, COMMON-AVX512.Понятно, что полученное приложение можно будет запускать только на системах с процессорами Intel, поддерживающих сгенерированные инструкции.По умолчанию, используется ключ -xSSE2, который, например, при векторизации будет говорить компилятору, что нужно использовать инструкции SSE2. В большинстве случаев (Pentium 4 и выше), это гарантирует выполнение приложения.Если же мы пишем под Atom и точно знаем, что приложение будет запускаться только на нём, то для лучшей производительности можем использовать -xSSSE3_ATOM. Для архитектуры Silvermont нужно задать -xATOM_SSE4.2.Особо ленивые могут использовать опцию -xHost, и в этом случае оптимизации будут делаться под железо, на котором мы собираем код.
Кстати, есть возможность указать не только один конкретный набор инструкций, а сразу несколько — с помощью ключа -ax’code'.При этом в код будет добавляться автовыборщик (dispatcher), который во время запуска приложения будет определять CPU (по CPUID), и в зависимости от того, какой набор инструкций он поддерживает, выполнение пойдёт по нужному пути. Конечно, это ведёт к увеличению размера нашего приложения, но даёт большую гибкость. Кроме явно заданного набора инструкций через 'code', всегда создаётся ещё и версия по умолчанию SSE2. Например, задав -axAVX, получим одну дефолтную версию с SSE2, а так же отдельную для AVX.Кроме того, мы можем задавать сразу несколько наборов инструкций в опции -ax через запятую. Например, -axSSE4.2, AVX скажет компилятору генерить версии SSE4.2, AVX, и не забываем про дефолтную (SSE2) ветку, которая будет всегда. Её можно так же задавать явно, используя ключ -x в дополнение к -ax. Например, указав ключи -axSSE4.2, AVX -xSSE4.1 дефолтной версией будет уже SSE4.1.
Для оптимизаций, не специфичных для процессоров Intel используется ключ -m.Например, для Quark SoC X1000 можно задать опции -mia32 (генерируем код для IA-32 архитектуры) и -falign-stack=assume-4-byte, позволяющая сказать компилятору, что наш стек выровнен по 4 байта. Если понадобится, компилятор сможет выровнять его по 16 байт. Это может позволить уменьшить размер данных необходимых для вызова функций.
Шаг 4. IPOНет, мы пока не собираемся продавать акции на бирже. IPO (Interprocedural Optimization) — межпроцедурный анализ и оптимизации, которые делает компилятор над нашим кодом. Подключается он опцией -ipo и позволяет проводить оптимизации не для одного отдельного файла с исходным кодом, а для всех исходников одновременно. В этом случае компилятор знает намного больше и может сделать значительно больше выводов и, соответственно, преобразований/оптимизаций. Во всех тонкостях IPO поможет разобраться этот блог. Специфика работы заключается в том, при компиляции с -ipo меняется привычный нам порядок компиляции и линковки, а объектный файл содержит упакованное внутреннее представление, поэтому стандартный (на Linux) линковщик ld и утилита ar должны быть заменены на Интеловские xiar и xild. Не стоит забывать, что сам процесс компиляции с IPO может занимать существенно больше времени, особенно для «больших» приложений.
Шаг 5. А может «профильнём»? Ничто не может дать компилятору больше информации, чем запуск самого приложения. Благодаря ему, мы можем точно узнать, по каким веткам мы ходили, где тратили больше времени, как часто не попадали в кэш и прочее. Естественно, я подвожу к тому, что профилировка нашего приложения может существенно помочь его оптимизации.У компилятора есть такая опция, которая позволяет профилировать наше приложение и проводить оптимизации на основе собранных данных — PGO (Profile-guided Optimization).Процесс работы состоит из нескольких шагов, и, соответственно, ключей компилятора.
Для начала, нам нужно выполнить инструментацию нашего приложения, собрав его с ключом -prof-gen. Далее, необходимо выполнить наше приложение, при этом собрать различные статистические данные (профиль) в отдельный информационный файл с расширением .dyn. Ну и в конце концов использовать эти данные при финальной компиляции с ключом -prof-use, при которой компилятор будет пытаться оптимизировать наиболее затратные в вычислительном плане ветки кода.
В некоторых случаях может понадобиться указать место, куда положить те самые файлы с результатами выполнения приложения. Это можно сделать с опцией -prof-dir='val', указав путь к папке. Таким образом мы можем собирать наш код на одной машине, затем профилировать на другой, и выполнять финальную компиляцию опять на первой. Просто берём файлы dyn и кладём их в папочку на системе, где собираем код, и указываем путь через -prof-dir.
Для того, чтобы профиль был собран, приложение должно нормально завершиться и выйти.Если наше приложение выполняется бесконечно (например, частый случай для встроенных систем), придётся сделать ещё пару телодвижений:1. Добавляем точку выхода из приложения2. Добавляем вызов PGO API _PGOPTI_Prof_Dump_All ()3. Можно контролировать интервал дампа в микросекундах через переменные окружения: export INTEL_PROF_DUMP_INTERVAL 5000export INTEL_PROF_DUMP_CUMULATIVE 1
Шаг 6. Игры с векторамиСпециально решил акцентировать внимание на векторизации, хотя включена она по умолчанию (с опцией -O2), а набор инструкций контролируется уже описанными -x, -ax и т.д. Но при разговоре о производительности компилятора Intel именно на векторизацию нужно обращать повышенное внимание, потому как она даёт максимальный прирост в скорости выполнения приложения. Читаем соответствующий пост о том, как помочь компилятору в его нелёгкой работе. Ну, а набор обновленных опций -opr-report будет в помощь.
Шаг 7. Параллелим автоматически! Есть у компилятора Intel преинтереснейшая опция -parallel, которая позволяет распараллелить циклы с помощью OpenMP средствами компилятора в автоматическом режиме. Очевидно, что не все циклы одинаково хорошо параллелятся, и компилятор далеко не всегда может это сделать. Но попробовать эту опцию стоит — от этого мы вряд ли что-то потеряем.
В итоге, вот такой набор опций стоит опробовать при компиляции вашего кода для увеличения производительности:
-O2/O3 -no-prec-div -x’code' -ipo -prof-gen/-prof-use -prof-dir='val' -parallel
Кстати, для ленивых придумали опцию -fast, которая включает в себя большинство этих ключей: ipo, -O3, -no-prec-div, -static, -fp-model fast=2, and -xHost.Ну, а кроме опций нам всегда поможет хороший профилировщик Intel® Vtune Amplifier XE, но это уже совсем другая история.
ПрактикаКроме теоретических размышлений, я хочу поиграться с перечисленными опциями на примерчике вычисления числа Пи и показать, как они влияют на скорость выполнения приложения, пусть и простенького. В «теории» ключи я приводил для Linux’а, в случае же Windows они практически идентичны, просто добавляется буква Q в начале (в большинстве случаев). Буду собирать пример на Windows, чтобы показать соответствующие опции. Я использовал компилятор Intel C++ версии 15.0 (15.0.2.179 Build 20150121).
Итак, код я сознательно разбил на два файла (чтобы был эффект от IPO).pi.c:
#define N 1000000000
double f (double x);
main () { double sum, pi, x, h; clock_t start, stop; int i;
h = (double)1.0/(double)N; sum = 0.0;
start = clock ();
for (i=0; i // print value of pi to be sure multiplication is correct
pi = h*sum;
printf (» pi is approximately: %f \n», pi);
// print elapsed time
printf («Elapsed time = %lf seconds\n»,((double)(stop — start)) / CLOCKS_PER_SEC);
}
В отдельном файле fx.c определяется функция f:
double f (double x){
double ret;
ret = 4.0 / (x*x + 1.0);
return ret;
}
Инклуд библиотек stdio и time я не стал приводить.Итак, будем собирать этот код с разными опциями и смотреть на получаемое ускорение.Для начала, компилируем без оптимизаций:
icl /Od pi.c fx.c /o Od_pi.exe
И запускаем Od_pi.exe:
pi is approximately: 3.141593
Elapsed time = 22.828000 seconds
Что-то долго, давайте посмотрим, что даст следующий уровень O1:
icl /O1 pi.c fx.c /o O1_pi.exe
pi is approximately: 3.141593
Elapsed time = 4.963000 seconds
Интересно, что увеличив уровень оптимизации до O2 и O3, никакого выигрыша в скорости мы больше не получаем.Это весьма логично, потому что код достаточно простой, да к тому же не был векторизован из-за вызова функции, определенной в другом файле, внутри цикла. Значит IPO должна помочь:
icl /O2 pi.c fx.c /Qipo ipo_pi.exe
pi is approximately: 3.141593
Elapsed time = 2.562000 seconds
При этом наш цикл был векторизован. Если мы будем собирать код без IPO, но с ключами QxAVX, QxSSE2 и другими из этой же серии, то никакой разницы в скорости так же не заметим. Опять весьма логично, так как векторизация работать не будет:
icl /O2 /QxAVX pi.c fx.c /o xAVX_pi.exe
Elapsed time = 5.065000 seconds icl /O2 /QxSSE2 pi.c fx.c /o xSSE2_pi.exe
Elapsed time = 5.093000 seconds
Я компилирую код и запускаю приложение на Haswell’е, поэтому использую опцию /QxHost и IPO:
icl /O2 /QxHost /Qipo pi.c fx.c /o xHost_ipo_pi.exe
Elapsed time = 2.718000 seconds
Опция /fast даёт такой же результат:
icl /fast /Qvec-report2 pi.c fx.c /o fast_pi.exe
Elapsed time = 2.718000 seconds
Профилировка позволяет ещё немножко «выжать» в плане оптимизации:
icl /Qprof-gen pi.c fx.c /o pgen_pi.exe
Запускаем приложение, и потом компилируем опять:
icl /Qprof-use /O2 /Qipo pi.c fx.c /o puse_pi.exe
Elapsed time = 2.578000 seconds
Ну, а самый максимум мы получаем с автораспараллеливанием:
icl /Qparallel /Qpar-report2 /Qvec-report2 /Qipo pi.c fx.c /o par_ipo_pi.exe
Elapsed time = 1.447000 seconds
Вот таким образом мы существенно ускорились, не прикладывая больших усилий для этого. Простая игры опций, так сказать.