paint-brush
Оптимизация компилятора: повышение производительности кода с минимальными изменениями!к@durganshu
1,418 чтения
1,418 чтения

Оптимизация компилятора: повышение производительности кода с минимальными изменениями!

к Durganshu Mishra13m2023/11/30
Read on Terminal Reader

Слишком долго; Читать

Разработчикам, стремящимся оптимизировать производительность своего кода C++, следует изучить возможности оптимизации компилятора: набор очень эффективных флагов C++, которые без особых усилий повышают производительность кода. Единственное условие — вы знаете, что делаете. Изучите флаги, подходящие для компиляторов Intel C++, такие как -fno-alias, -xHost, -xCORE-AVX512, IPO и т. д., с практическим разбором итерационного кода Jacobi C++.
featured image - Оптимизация компилятора: повышение производительности кода с минимальными изменениями!
Durganshu Mishra HackerNoon profile picture
0-item
1-item


Достижение максимальной производительности вашего кода C++ может оказаться непростой задачей, требующей тщательного профилирования, сложной настройки доступа к памяти и оптимизации кэша. Есть ли способ немного упростить это?? К счастью, есть кратчайший путь к значительному увеличению производительности с минимальными усилиями — при условии, что у вас есть правильное понимание и вы знаете, что делаете. Введите оптимизации компилятора, которые могут значительно повысить производительность вашего кода.


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


Целью этой статьи является освещение возможностей оптимизации компиляторов с упором на компиляторы Intel C++ , известные своей популярностью и широким распространением.


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


Основные моменты: Что такое оптимизация компилятора? | -Вкл | Целевая архитектура | Межпроцедурная оптимизация | -fno-алиасинг | Отчеты об оптимизации компилятора

Что такое оптимизация компилятора?

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


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


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


Я не собираюсь обсуждать все доступные варианты, а расскажу о том, как мы можем поручить компилятору выполнить конкретную оптимизацию, которая может улучшить производительность кода. Итак, решение???? Флаги компилятора.

Разработчики могут указать набор флагов компилятора во время процесса компиляции — практика, знакомая тем, кто использует такие параметры, как « -g» или «-pg» с GCC для отладки и профилирования информации. По ходу дела мы обсудим аналогичные флаги компилятора, которые можно использовать при компиляции нашего приложения с помощью компилятора Intel C++. Это может помочь вам повысить эффективность и производительность вашего кода.


GIF-изображение «Иди, начни» от CAF



Итак, с чем мы работаем?

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


Как нам этого добиться???


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


Ускорение (или время выполнения) измерялось на компьютере с процессором Intel® Xeon® Platinum 8174 . Здесь метод Якоби решает двумерное уравнение в частных производных (уравнение Пуассона) для моделирования распределения тепла на прямоугольной сетке.


Метод Якоби


u(x,y,t) — температура в точке (x,y) в момент времени t.


Мы решаем стабильное состояние, когда распределение больше не меняется:

Решение стабильного состояния


На границе применен набор граничных условий Дирихле.


По сути, у нас есть код C++, выполняющий итерации Якоби на сетках переменных размеров (которые мы называем разрешениями). По сути, размер сетки 500 означает решение матрицы размером 500x500 и так далее.


Функция для выполнения одной итерации Якоби выглядит следующим образом:


 /* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }


Мы продолжаем выполнять итерацию Якоби до тех пор, пока остаток не достигнет порогового значения (внутри цикла). Вычисление остатка и оценка порога выполняются вне этой функции и здесь не имеют значения. Итак, давайте теперь поговорим о слоне в комнате!

Как работает базовый код?

Без оптимизации (-O0) мы получаем следующие результаты:


Время выполнения в секундах и MFLOP/с для базового варианта («-O0»)


Здесь мы измеряем производительность в MFLOP/s. Это будет основой нашего сравнения.


MFLOP/s означает «миллион операций с плавающей запятой в секунду». Это единица измерения, используемая для количественной оценки производительности компьютера или процессора с точки зрения операций с плавающей запятой. Операции с плавающей запятой включают математические вычисления с десятичными или действительными числами, представленными в формате с плавающей запятой.


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


Примечание 1. Чтобы обеспечить стабильный результат, я запускаю исполняемый файл 5 раз для каждого разрешения и беру среднее значение значений MFLOP/s.

Примечание 2. Важно отметить, что оптимизацией по умолчанию для компилятора Intel C++ является -O2. Поэтому важно указать -O0 при компиляции исходного кода.


Давайте продолжим и посмотрим, как будет меняться время выполнения при использовании разных флагов компилятора!

Наиболее распространенные: -O1, -O2, -O3 и -Ofast.

Это некоторые из наиболее часто используемых флагов компилятора, когда кто-то начинает оптимизировать компилятор. В идеальном случае производительность Ofast > O3 > O2 > O1 > O0 . Однако это не обязательно происходит. Критические моменты этих вариантов заключаются в следующем:


-O1:

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

-О2:

  • Улучшения по сравнению с -O1:
    • Включает векторизацию.
    • Позволяет встраивать встроенные функции и внутрифайловую межпроцедурную оптимизацию.

-O3:

  • Улучшения по сравнению с -O2:
    • Позволяет более агрессивные преобразования циклов (Fusion, Block-Unroll-and-Jam).
    • Оптимизации могут постоянно превосходить -O2 только в том случае, если происходят преобразования циклов и доступа к памяти. Это может даже замедлить работу кода.
  • Рекомендуется для:
    • Приложения с тяжелыми циклами вычислений с плавающей запятой и большими наборами данных.

-Офаст:

  • Устанавливает следующие флаги:
    • «-О3»
    • «- no-prec-div» : позволяет выполнять оптимизацию, которая дает быстрые и немного менее точные результаты, чем полное деление IEEE. Например, A/B вычисляется как A * (1/B) для повышения скорости вычислений.
    • « -fp-model fast=2» : включает более агрессивную оптимизацию с плавающей запятой.


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


При использовании этих параметров в нашем коде Jacobi мы получаем следующее время выполнения:

Сравнение флагов -On

Совершенно очевидно, что все эти оптимизации выполняются намного быстрее, чем наш базовый код (с «-O0»). Время выполнения в 2–3 раза меньше, чем в базовом варианте. А как насчет MFLOP/s??


Сравнение флагов -On


Ну это что-то!!!


Существует большая разница между MFLOP/s в базовом варианте и в случае оптимизации.


В целом, хотя и незначительно, «-O3» работает лучше всего.


Дополнительные флаги, используемые « -Ofast » (« -no-prec-div -fp-model fast=2 »), не дают никакого дополнительного ускорения.

Целевая архитектура (-xHost,-xCORE-AVX512)

Архитектура машины является ключевым фактором, влияющим на оптимизацию компилятора. Это может значительно повысить производительность, если компилятор знает доступные наборы команд и оптимизации, поддерживаемые оборудованием (например, векторизацию и SIMD).


Например, моя машина Skylake имеет 3 блока SIMD: 1 AVX 512 и 2 блока AVX-2.


Могу ли я реально что-то сделать с этими знаниями???


Ответ кроется в стратегических флагах компилятора. Экспериментирование с такими опциями, как « -xHost » и, точнее, « -xCORE-AVX512 », может позволить нам использовать весь потенциал возможностей машины и адаптировать оптимизацию для оптимальной производительности.


Вот краткое описание того, что означают эти флаги:


-xХост:

  • Цель: указывает, что компилятор должен генерировать код, оптимизированный для самого высокого набора команд хост-машины.
  • Ключевые особенности: Использует новейшие функции и возможности, доступные на оборудовании. Это может дать потрясающее ускорение целевой системы.
  • Соображения: Хотя этот флаг оптимизирует архитектуру хоста, это может привести к тому, что двоичные файлы не будут переноситься на разные машины с разными архитектурами набора команд.

-xCORE-AVX512:

  • Цель: явно указать компилятору сгенерировать код, использующий набор инструкций Intel Advanced Vector Extensions 512 (AVX-512).

  • Ключевые особенности: AVX-512 — это расширенный набор инструкций SIMD (одна инструкция, несколько данных), который предлагает более широкие векторные регистры и дополнительные операции по сравнению с предыдущими версиями, такими как AVX2. Включение этого флага позволяет компилятору использовать эти расширенные функции для оптимизации производительности.

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


В инструкциях установки AVX-512 используются регистры Zmm, которые представляют собой набор регистров шириной 512 бит. Эти регистры служат основой для векторной обработки.


По умолчанию « -xCORE-AVX512 » предполагает, что программа вряд ли выиграет от использования регистров zmm. Компилятор избегает использования регистров zmm, если не гарантирован прирост производительности.


Если вы планируете использовать регистры zmm без ограничений, для « -qopt-zmm-usage » можно установить высокое значение. Это то, что мы тоже будем делать.


Не забудьте проверить официальное руководство для получения подробных инструкций.


Давайте посмотрим, как эти флаги работают в нашем коде:

Эффекты -xHost и -xCORE-AVX512

Уууу!


Теперь мы пересекаем отметку 1200 MFLOP/s для наименьшего разрешения. Значения MFLOP/s для других разрешений также увеличились.


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


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


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


Примечание. Не волнуйтесь, если ваше оборудование не поддерживает AVX-512. Компилятор Intel C++ поддерживает оптимизацию для AVX, AVX-2 и даже SSE. В документации есть все, что вам нужно знать!

Межпроцедурная оптимизация (IPO)

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


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


Intel Compiler поддерживает два распространенных типа IPO: компиляцию одного файла и компиляцию нескольких файлов (оптимизация всей программы) [ 3 ]. Существует два общих флага компилятора, выполняющих каждый из них:


-ипо:

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

  • Ключевые особенности: - Оптимизация всей программы: « -ipo » выполняет анализ и оптимизацию всех исходных файлов, учитывая взаимодействие между функциями и процедурами во всей программе. - Межфункциональная и межмодульная оптимизация: флаг облегчает встраивание функций, синхронизацию. оптимизаций и анализа потоков данных в различных частях программы.

  • Соображения: Требуется отдельный шаг связывания. После компиляции с использованием « -ipo » необходим определенный шаг компоновки для создания окончательного исполняемого файла. Во время компоновки компилятор выполняет дополнительную оптимизацию на основе всего представления программы.


-IP:

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

  • Ключевые особенности: - Анализ и распространение: « -ip » позволяет компилятору выполнять исследование и распространение данных между различными функциями и модулями во время компиляции. Однако он не выполняет все оптимизации, требующие полного просмотра программы. Ускоренная компиляция: в отличие от « -ipo », « -ip » не требует отдельного этапа компоновки, что приводит к ускорению компиляции. Это может быть полезно во время разработки, когда важна быстрая обратная связь.

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


-ipo обычно предоставляет более широкие возможности межпроцедурной оптимизации, поскольку включает отдельный этап компоновки, но за это приходится платить более длительным временем компиляции. [ 4 ]

-ip — более быстрая альтернатива, которая выполняет некоторые межпроцедурные оптимизации, не требуя отдельного этапа связывания, что делает ее подходящей для этапов разработки и тестирования.[ 5 ]


Поскольку мы говорим только о производительности и различных оптимизациях, времени компиляции или размере исполняемого файла, нас это не касается, мы сосредоточимся на « -ipo ».

Эффект -ipo

-fno-псевдоним

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


Давайте еще раз посмотрим на наш код:


 /* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }


Функция jacobi() принимает пару указателей в качестве параметров, а затем выполняет что-то внутри вложенных циклов for. Когда любой компилятор видит эту функцию в исходном файле, ему следует быть очень осторожным.


Почему??


Выражение для вычисления new с использованием u включает в себя среднее значение 4 соседних значений u . Что, если и u , и unew указывают на одно и то же место? Это станет классической проблемой псевдонимов указателей [ 7 ].


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


В нашем случае мы знаем, что u и unew — это разные области памяти и предназначены для хранения разных значений. Итак, мы можем легко сообщить компилятору, что здесь не будет никаких псевдонимов.


Как мы это делаем?


Есть два метода. Во-первых, это ключевое слово C « limit » . Но это требует изменения кода. Мы пока этого не хотим.


Что-нибудь простое? Давайте попробуем « -fno-alias ».


-fno-псевдоним:

  • Цель: дать указание компилятору не допускать псевдонимов в программе.

  • Ключевые особенности: При отсутствии псевдонимов компилятор может более свободно оптимизировать код, потенциально повышая производительность.

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


Более подробную информацию можно найти в официальной документации .


Как это отразится на нашем коде?

Эффект -fno-alias

Ну, теперь у нас есть что-то!!!


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


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


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

Итоговые графики

Вот как все оптимизации работают друг против друга:


Сравнение всех флагов оптимизации

Отчет об оптимизации компилятора (-qopt-report)

Компилятор Intel C++ предоставляет ценную функцию, которая позволяет пользователям создавать отчет об оптимизации, суммирующий все корректировки, внесенные в целях оптимизации [ 8 ]. Этот подробный отчет сохраняется в формате файла YAML и представляет подробный список оптимизаций, примененных компилятором в коде. Подробное описание смотрите в официальной документации по « -qopt-report ».

Что дальше?

Мы обсудили несколько флагов компилятора, которые могут значительно улучшить производительность нашего кода без особых усилий с нашей стороны. Единственное условие: ничего не делайте вслепую; убедитесь, что вы знаете, что делаете!


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


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


Точно так же компиляторы Intel C++ (и все популярные) также поддерживают директивы pragma, что является очень полезной функцией. Стоит проверить некоторые прагмы, такие как ivdep, Parallel, Simd, Vector и т. д., в Intel-Specific Pragma Reference .


Это все ребята на Giphy

Рекомендуемое чтение

[1] Оптимизация и программирование (intel.com)

[2] Высокопроизводительные вычисления с «Эльветричем» в Университете Кайзерслаутерн-Ландау (rptu.de)

[3] Межпроцедурная оптимизация (intel.com)

[4] IPO, Qipo (intel.com)

[5] ip, Qip (intel.com)

[6] Флаги компилятора Intel, оптимизации и другие флаги для использования SPEChpc.

[7] Псевдонимы — Документация IBM

[8] Отчеты об оптимизации компилятора Intel®


Рекомендуемое фото Игоря Омилаева на Unsplash .


Также опубликовано здесь .