Share via


Как сделать ваше приложение быстрым: профильная оптимизация C++

Профильная оптимизация это очень интересный способ оптимизации кода приложения всреде выполнения (в команде разработчиков Visual C этот метод называют POGO или PGO, от английского Profile Guided Optimization). Впервые профильная оптимизация была применена в конце 90-х исследовательскими группами в Visual C и Microsoft. Тогда она была рассчитана для архитектуры Itanium. Затем PGO была включена в состав Visual Studio C/C++ 2005. На сегодня это основной процесс оптимизации, значительно повышающий производительность приложений Microsoft и других разработчиков.
В этом посте будет рассказано, как создавать более быстрые и высокопроизводительные нативные приложения. Для начала, познакомимся ближе с PGO, а затем рассмотрим на примере (симуляция NBody), как с помощью нескольких простых шагов можно применить этот процесс оптимизации в ваших приложениях. Для работы используйте исходный код изпримера. Для сборки проекта вам понадобится DirectX SDK.

Как сделать нативное приложение более быстрым

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

При работе с функцией whichBranchIsTaken компилятор не знает, как часто параметр «a» будет меньше параметра «b», и сколько раз будет применено условие «if» (т. е. компилятор не может предсказывать ветвления). При работе с функциями девиртуализации и switchCaseExpansion компилятор знает недостаточно о значениях *p и i, что делает невозможным оптимизацию девиртуализации и расширений параметров. Эти проблемы проявятся еще ярче, если мы подставим данный фрагмент кода в разные модули (например, разные объектные файлы), поскольку функции традиционной компиляции не могут быть оптимизированы для работы в пределах исходных модулей.
Базовая модель компилятора и компоновщика не так уж и плоха, но в ней недостает двух основных возможностей для оптимизации. Во-первых, в ней не используется информация, которую можно было бы получить на основе анализа всех исходных файлов (традиционные компиляторы оптимизируют только отдельные объектные файлы). Во-вторых, в ней не проводится оптимизация на базе ожидаемой или профильной реакции приложения. Первый недостаток может быть исправлен с помощью переключателя компилятора (/GL) или переключателя компоновщика (/LTCG), выполняющего полную оптимизацию программы и необходимого для профильной оптимизации приложения. После того, как оптимизация полной программы включена, вы можете применять профильную оптимизацию. Остановимся на ней подробнее.
PGO – это процесс оптимизации компилятора в среде выполнения, применяющий данные профиля, собранные в ходе выполнения важных или требующих высокой производительности пользовательских сценариев, с целью оптимизации приложения. Профильная оптимизация обладает рядом преимуществ по сравнению с традиционной статической оптимизацией, поскольку она принимает в расчет, как будет себя вести приложение в рабочей среде. Благодаря этому оптимизатор может осуществлять оптимизацию по скорости (для частых пользовательских сценариев) или оптимизацию по размеру (для редких сценариев). В результате код становится более лаконичным, что, в конечном счете, повышает производительность приложения.

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

  • Первый этап обычно называют фазой инструментирования. В ходе этой фазы идет сборка приложения с заданным набором флагов компиляции. В процессе сборки внутренний компилятор добавляет в созданный код пробные инструкции (зонды), которые используются для записи данных обучения, необходимых на следующем этапе. Всего добавляется три типа зондов (входа в функцию, перехода и значений). Зонд входа в функцию измеряет, как часто запрашивалась та или иная функция. Зонд перехода позволяет узнать, сколько раз была достигнута та или иная ветвь кода. Таким образом в ходе фазы обучения компилятор получает информацию о том, как часто «a > b» в фрагменте кода whichBranchisTaken в заданном сценарии обучения. Зонд значений позволяет получить данные для построения гистограммы значений. Например, зонд значений, добавленный в фрагмент кода switchCaseExpansion, позволит получить данные для построения гистограммы значений для переменной индекса switch case i. Получив в ходе обучения информацию о том, какие значения будет принимать переменная «i», компилятор сможет провести оптимизацию для наиболее частых значений, а также таких функций как switchCaseExpansion. Таким образом, по окончании фазы у нас будет инструментированная версия приложения (с зондами) и пустой файл базы данных (.pgd), в которую будет заноситься информация, полученная в ходе следующей фазы.
  • Фаза обучения . В ходе этой фазы пользователь запускает инструментированную версию приложения и проигрывает стандартные пользовательские сценарии, требующие высокой производительности. На выходе мы имеем файлы .pgc, содержащие информацию, связанную с различными пользовательскими сценариями. В процессе обучения информация проходит через зонды, добавленные в ходе первой фазы. На выходе мы получаем pgc-файлы appname!# (где appname соответствует названию приложения, а # — единице плюс числу pgc-файлов appname!# в выходном каталоге построения).
  • Последняя фаза PGO — оптимизация. В ходе этой фазы создается оптимизированная версия приложения. Помимо этого, информация из pgc-файлов, полученная в ходе фазы обучения, вносится в фоновом режиме в базу данных (файл .pgd), созданную в ходе инструментирования. Внутренний компилятор затем использует эту базу данных для последующей оптимизации кода и построения еще более совершенной версии приложения.

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

Применение профильной оптимизации

Теперь, когда мы знаем немного больше о профильной оптимизации, рассмотрим ее применение на конкретном примере. Профильную оптимизацию приложения можно осуществлять с помощью Visual Studio или командной строки разработчика. Ниже рассмотрен пример работы в Visual Studio с приложением под условным названием «Nbody Simulation». Если вы хотите узнать больше о PGO в командной строке, обратитесь к этим статьям. Для начала работы загрузите решение в Visual Studio и выберите конфигурацию построения для работы (т.е. «Release»).

Как было упомянуто выше, PGO состоит из трех этапов: инструментирования, обучения и оптимизации. Для создания инструментированной версии приложения, нажмите правой кнопкой мыши по названию проекта («NBodyGravityCPU») и выберите раздел «Instrument» в меню «Profile Guided Optimization».

В Visual Studio будет построена инструментированная версия приложения. После этого можно переходить к фазе обучения. Запустите инструментированную версию приложения. Для этого зайдите в меню «Profile Guided Optimization» и выберите «Run Instrumented/Optimized Application». В нашем случае приложение выполняется с максимально большим телом кода (15360), т. к. будет реализован стабильный пользовательский сценарий, требующий высокой производительности. После того как два основных показателя производительности приложения — FPS (кадров в секунду) и GFlop – примут устойчивые значения, вы можете закрыть приложение. На этом фаза обучения будет завершена, а полученные данные будут сохранены в файле .pgc. По умолчанию файл .pgc будет включен в вашу конфигурацию построения, т.е. каталог «Release». Например, в результате этого обучения создается файл NBodyGravityCPU!1.pgc.

NBody Simulation представляет собой очень простое приложение, созданное исключительно для иллюстрации процесса PGO. В действительности может существовать множество вариантов сценариев обучения приложений. В частности, можно производить обучение в несколько этапов, разделенных по времени. Для записи таких сценариев обучения лучше всего использовать команду pgosweep в командной строке разработчика после того, как инструментированная версия уже создана (напр., в Visual Studio).

В ходе последней фазы PGO создается оптимизированная версия приложения. В меню «Profile Guided Optimization» выберите «Optimize». Будет создана оптимизированная версия приложения. В выходном журнале PGO-построения вы увидите обобщенную информацию о проведенной операции.
Как было сказано выше, информация из файлов .pgc, полученная в ходе фазы обучения, включается в базу данных .pgd, которая затем используется матрицей оптимизации внутреннего компилятора. В большинстве случаев (за исключением небольших быстрых приложений) критерий оптимизации скорость/размер определяется соотношением динамических инструкций для определенной функции. Функции с большим числом инструкций (т. н. «горячие») оптимизируются на скорость, а с малым количеством инструкций (т. н. «холодные») – на размер.
Это практически все, что вам необходимо для того чтобы начать профильную оптимизацию в ваших приложениях. Попробуйте применить PGO для своих приложений и оцените результаты! И обязательно загляните в блог Инструменты для разработчика, возможно вы найдете там еще какие либо интересные решения! 
Автор поста — Анкит Астхана (Ankit Asthana) — руководитель программы по внутреннему компилятору Microsoft Visual С++.