Parallel Amplifier — это четвертый инструмент, завершающий цикл разработки многопоточных приложений, который поддерживается Intel Parallel Studio и служит для оптимизации производительности параллельных программ. Amplifier может быть установлен и проинтегрирован в Microsoft Visual Studio и как часть набора, и отдельно. Профилировщик производительности предназначен для того, чтобы выяснить, насколько эффективно приложение использует мультиядерную платформу и где те узкие места в программе, которые мешают ей масштабироваться, т. е. ускорять исполнение с ростом количества вычислительных ядер в системе. Сегодня, с выходом новых мультиядерных микропроцессоров, хорошая масштабируемость многопоточного приложения практически является достаточным условием автоматического роста его производительности (при прочих равных условиях). Поэтому Amplifier – это необходимый инструмент для разработчика ПО, требовательного к производительности микропроцессора (решатели, фильтры, кодеки, рендеры и т.д.).

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

Методология оптимизации и пользовательский интерфейс

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

Hotspot-анализ. «На что программа тратит вычислительное время процессора?». Нам необходимо знать те места в программе, Hotspot-функции, где при исполнении тратится больше всего вычислительных ресурсов, а также тот путь, по которому в эти места можно попасть, т.е. стэк вызовов.

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

Lock & Wait-анализ. «Где программа простаивает в ожидании синхронизации или операции ввода-вывода?» Поняв, что наша программа плохо масштабируется, мы хотим найти, где именно и какие именно объекты синхронизации мешают хорошей параллельности. Возможно, необходимо пересмотреть реализацию алгоритмов, а может быть, и всю параллельную инфраструктуру приложения.

Каждый из этих видов анализа запускается по отдельности и имеет собственное окно представления результатов. При этом встроенный Source View расширяет возможности обзора результатов относительно исходного кода программы, а Statistical Call Tree, или статистическое дерево вызовов, поможет получить «объемное» представление о путях вызовов Hotspot-функций. Результаты Concurrency-анализа позволят сделать вывод об общем уровне параллельности программы и конкретных проблемах благодаря групировке информации по функциям, модулям или потокам, а Lock&Wait-анализ позволит судить о причинах этих проблем. Наличие встроенного функционала сравнения результатов позволяет отслеживать влияние изменения кода программы на ее производительность.

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

Результаты каждого запуска профилировщика сохраняются в файловой системе в виде директории, шаблон имени которой может задавать сам пользователь. Место хранения директорий с результатами также может задаваться пользователем в окне опций Amplifier; это может быть директория хранения проекта Visual Studio либо произвольное место. По умолчанию результаты также отображаются в окне Visual Studio Soution Explorer, где ими можно управлять так же, как и другими файлами проекта Visual Studio.

Чтобы было проще отслеживать изменения в производительности с изменением кода приложения, имеется функционал сравнения результатов анализа. Понятно, что простейший способ сравнения – просто замерить время выполнения приложения до и после модификации. Однако в случае громоздких приложений этот метод может не сработать. Поэтому важно иметь возможность сравнивать результаты с более высокой степенью гранулярности, например на уровне функций. Хотя и в этом случае ничто не мешает замерить время выполнения функции с помощью встроенных вызовов измерения времени. Удобство встроенного функционала сравнения результатов заключается в его гибкости: он позволяет работать как на уровне процессов/потоков, так и на уровне функций и строк исходного кода, и при этом нет необходимости каждый раз писать дополнительный код для замера и отображения результатов измерений.

Раз уж мы упомянули громоздкие приложения, то необходимо отметить, что измерение производительности всего приложения часто не имеет смысла, так как часто эти приложения имеют некий графический интерфейс, требующий пользовательского ввода данных (например, открытия файлов проекта), выполняется некая фаза заполнения структур данных, открываются различные окна и панели и т. д. Часто интерес представляет только определенная фаза выполнения приложения, которая начинается лишь после каких-либо действий с графическим интерфейсом пользователя или по прошествии некоторого периода времени. Чтобы сократить время анализа и размер данных трассировки, можно использовать режим запуска приложения с задержкой старта анализа. Для этого достаточно установить опцию Start data collection paused в свойствах проекта Parallel Amplifier (не путать со свойствами проекта Visual Studio) и нажать кнопку Profile тогда, когда это будет необходимо по логике работы приложения (рис. 1). Кстати, кнопка Profile превращается в Continue после нажатия паузы или запуска в режиме Start Paused. Можно также установить временной интервал, когда Amplifier сам запустит коллекцию через заданное пользователем количество секунд после старта приложения.

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

Итак, все предустановки сделаны, и можно начинать поэтапный анализ приложения.

Hotspot-анализ

Выбрав в панели инструментов Hotspots и нажав кнопку Profile, мы запускаем Hotspot-анализ приложения, предназначенный для идентификации функций анализируемой программы, которые отнимают значительное время микропроцессора и скорее всего влияют на общую производительность программы, – Hotspot-функции.

Необходимо сказать, что поиск Hotspot-функций не влечет за собой существенных накладных расходов, т. е. анализ практически не влияет на время исполнения анализируемого приложения. Это достигается за счет использования технологии временного сэмплирования стеков (Stack Sampling), в основе которой лежит прерывание работы приложения по таймеру с определенным (статистически обоснованным) интервалом и фиксации адреса (IP) и контекста исполнения программы. При этом дополнительно происходит «раскручивание» стэка (stack unwinding) с целью определения пути вызова функции, в которой оказался каждый сэмпл. В результате формируется трасса с данными, содержащими статистически значимые временные показатели функций, их стеки, а также контекст исполнения. По окончании выполнения программы или по прерыванию коллекции наступает фаза, называемая финализацией, в которой трасса раскрывается в имена функций, потоков, модулей и процессов, и выстраиваются список Hotspot-функций и статистическое дерево вызовов.

Процесс финализации разделен с фазой коллекции сэмплов с тем, чтобы добиться гибкости обработки собранных данных и уменьшить влияние процесса сбора данных на саму программу. Представьте себе такой сценарий: необходимо собрать коллекцию данных на машине заказчика, чтобы определить проблемы профиля производительности поставляемого приложения на его платформе. Обычно продуктовые версии программ не содержат файлов с отладочной информацией, а это значит, что собранная трасса хотя и будет финализирована, в ней не окажется имен Hotspot-функций, а только адреса вызовов. Эти результаты профилировки можно перенести на машину с отладочной версией приложения и рефинализировать коллекцию, выбрав в контекстном меню результата Re-finalize. Собранным адресам вызовов сопоставятся символы из .pdb-файлов, и в списке можно будет увидеть имена функций. Есть и более «прозаическое» применение этому функционалу – запуская профилировку на своей рабочей машине, пользователь может просто забыть скопировать .pdb-файлы для анализа или указать путь к ним, и если коллекция занимает достаточно длительное время, проще ее рефинализировать, нежели собирать данные снова.

Итак, результатом запуска приложения на Hotspot-анализ будет интегрированное в главное окно Visual Studio окно Hotspots со списком «горячих» функций, напротив каждой из которых отображена ее временная характеристика как в числовом, так и в графическом представлении. По умолчанию результаты сортируются так, что самая «горячая» функция оказывается в списке первой (самое большое значение CPU Self-Time). Если функций слишком много, то на помощь придут фильтры, позволяющие отфильтровать список по имени модуля, потока и процесса. Кроме того, функции представлены в режиме Bottom-Up, т. е. имя может быть «раскрыто» для представления стека вызова этой функции вниз по стэку (рис. 2).

В качестве альтернативы в режиме Top-Down Tree (статистического дерева вызовов) мы можем получить представление данной функции от самого верхнего вызова вниз, которое помогает определить все пути ее вызовов и критический путь. Важными временными характеристиками функций здесь являются Self-Time, т. е. время, затраченное на выполнение самой функцией, и Total-Time – время, затраченное самой функцией и всеми функциями, вызванными из нее (Child Functions). Без такого представления, например, очень трудно определить, каким образом часто исполняемые функции (типа memcpy) влияют на производительность, если они попали в список самых «горячих» в Hotspot-листе.

Дополнительном источником данных по стэкам является окно Stack View, в котором с помощью полосового индикатора можно определить, какой именно путь внес наибольший вклад (в смысле влияния на производительность).

Сам факт определения имени «горячей» функции может нам ничего и не говорить. Поэтому полезно посмотреть, какой именно код в этой функции внес наибольший вклад в потребление процессорного времени. Функции бывают довольно громоздкие, содержащие достаточно большое количество строк кода, много циклов и ветвлений. Понятно, что основные «потребители» процессорного времени – это циклы, но вот какой из них, и какие именно инструкции внутри цикла нагружают процессор? С этим поможет разобраться режим Sources, т. е. отображение исходного кода программы. Достаточно двойного клика мышкой на имени функции, чтобы открылось окно исходного кода, в котором строкам программы сопоставлены временные характеристики его исполнения (Рис. 3). В колонке CPU Time значение времени исполнения инструкций, составляющих данную строку, представлено в числовом и графическом виде. Кстати, для удобства это значение можно выводить и в процентах от общего времени анализируемого объекта.

Тут важно понимать, что данные значения – это не результат точного измерения времени исполнения строк кода, а только их статистические оценки, хотя и достаточно адекватные. Поэтому напротив некоторых строк вообще не будет никаких значений. Это не значит, что они исполнялись 0 секунд, просто в процессе сэмплирования на эти инструкции не пришлось ни одного сэмпла. А раз так, то время их исполнения не является сколько-нибудь статистически значимым, и выводить значения не имеет смысла. И наоборот, те строки, инструкции которых выполнялись достаточно большое количество раз, чтобы набрать весомую статистику, будут иметь более точное значение оценок времени исполнения, и чем больше это время по сравнению с другими участками кода, тем сильнее будет выделена строка полосовым индикатором. А при открытии окна Sources в центр экрана будет помещена строка, занявшая самое большую часть времени исполнения всей функции.

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

Concurrency-анализ

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

Скорее всего, вы заметите, что приложение в режиме Concurrency работает медленнее, т. е. существуют определенные накладные расходы на исполнение в данном режиме. Это связано с тем, что Amplifier инструментирует все вызовы потоковых API и примитивы синхронизации приложения, очевидно, для отслеживания поведения потоков в системе при выполнении пользовательской программы. На сегодняшний момент Amplifier поддерживает Windows API создания и управления потоками и синхронизации, а также потоковую модель OpenMP, поддерживаемую компилятором как Intel, так и Microsoft.

По окончании анализа будет выведено окно с результатами Concurrency, а также, пожалуй, главная метрика в результатах Concurrency-анализа – уровень утизицации, интегральная характеристика параллельности всего приложения, представленная в окне Summary (рис. 4). Эта характеристика дает нам представление о том, насколько хорошо распараллелено приложение в целом. Она представлена гистограммой распределения времени исполнения приложения по уровням параллельности (Concurrency Level). Уровень параллельности – это количество потоков, находящихся в прогрессе исполнения (runnable threads), а под уровнем утилизации понимают долю времени, в течение которого программа выполнялась одновременно в N ядрах процессора. Уровень N=1 означает, что программа выполнялась последовательно, N=2 – в двух потоках, и т. д. В идеальном случае график должен иметь пик на уровне N, равном числу вычислительных ядер в системе, и незначительные показатели на всех остальных уровнях. Кстати, цвет и квалификаторы (Poor, Ok, Ideal, Over) на графике показывают, насколько близка к идеальной утилизация процессора. Другое важное свойство такого представления – то, что по графику можно оценить потенциал роста производительности программы (целью является достижение Target Concurrency) в случае разрешения проблем с масштабируемостью.

Дополнительные данные, содержащиеся в окне Summary – собственно, количество логических ядер процессора в данной системе (Core Count) и число потоков, запускавшихся приложением (Threads Created). Время исполнения приложения (Elapsed Time) указывает время на «настенных часах», затраченное на исполнение программы, а процессорное время (CPU Time) отражает суммарное время, потраченное ядрами процессора на активное исполнение программы. Иными словами, если двухпоточная программа выполнялась одним потоком 5 с, а другим – 10 с, то CPU Time = 15 с. В идеале CPU Time = Elapsed time х число логических ядер. Кроме того, в Summary будет указано время ожидания процессора (Wait Time), когда потоки ждут событий синхронизации или заблокированы на вводе-выводе, и время, не использованное процессором для вычислений (Unused CPU Time), – общее время, потерянное процессором на блокировках или неисполнении потоками программы.

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

Еще одним полезным свойством инструмента является возможность группировать результаты по потокам (Thread – Function – Caller Function Tree), что позволяет оценить балансировку потоков при исполнении приложения (рис. 5) и определить, какие функции внесли вклад в ту или иную долю времени исполнения в зеленой или красной зоне (нужно раскрыть ветку потока, и ниже будет представлен список функций и соответствующий им «градусник»). Понятно, что оценка балансировки при группировке по потокам будет правильной только тогда, когда параллельный регион в программе только один. Если же их несколько, то целесообразнее группировать по регионам (OpenMP Region – Wait Function – Wait Function Tree).

Таким образом, в параллельной программе важно найти не только Hotspot-функции, но и понять, насколько хорошо они распараллелены и нужно ли над ними работать в плане более эффективной балансировки нагрузки или оптимизации объектов синхронизации. Если же Hotspot-функции распараллелены хорошо, то они не окажутся в списке с красной зоной «градусника», и тогда работа по увеличению производительности разделяется на две задачи: оптимизация Hotspot-функций на микроархитектурном уровне и улучшение параллельности тех функций, которые в списке есть и которые недоиспользуют ресурсы микропроцессора.

Locks & Waits-анализ

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

Например, захват потоком критической секции для выполнения короткой операции в теле функции, такой как обновление глобального флага или увеличение общего счетчика, не ведет к существенным проблемам блокирования потоков в обычном приложении. Однако если вы попробуете создать искусственный тест, где в единственном в программе цикле будет только короткая операция, «обернутая» критической секцией, то все потоки «столпятся» вокруг этой секции и большую часть своего времени будут проводить в ожидании ее освобождения. Чем больше будет потоков, тем существеннее будет проблема. Такой код часто называют highly contended. Мы должны обнаружить такие конструкции, если они есть в реальном приложении, и исключить интенсивное соперничество потоков за ресурс.

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

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

Профилировка приложения с помощью Locks & Waits-анализа поможет нам найти причину, по которой не масштабируется приложение, понять, какие функции ввода-вывода или объекты синхронизации мешают ему выполняться быстрее с увеличением количества потоков. Обратите внимание, что в суммарной характеристике (окно Summary) есть метрика, которая предоставляет оценку количества вызовов, приведших к блокировке потоков (Wait Count).

Главное окно Lock & Waits-анализа отображает список функций, исполнение которых было заблокировано в ожидании каких-либо объектов синхронизации. Чем больше влияние такой функции на время исполнения программы, тем выше она в списке. По умолчанию установлена группировка по объектам синхронизации, функциям ожидания и дереву их вызовов (Sync Object Name – Wait Function – Wait Function Tree). В данном случае функцией ожидания является та функция приложения, в которой потоки были заблокированы при попытке захвата данного объекта синхронизации. Раскрыв древовидный список объека синхронизации, мы увидим, в какой функции потоки были заблокированы и каким образом мы туда попали, т.е. стэк вызовов.

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

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

Заключение

Intel Parallel Amplifier являет собой некое интегральное решение, объединяющее методологию профилировки параллельного приложения и являющееся «наследником» профилировщиков VTune Performance Analizer и Intel Thread Profiler. Пожалуй, главное отличие от предыдущих инструментов заключается в том, что методология значительно упрощена и стала более понятной простым разработчикам, а не только опытным специалистам по оптимизации. Однако поскольку Intel Parallel Amplifier построен на базе новых технологий сбора данных, имеющих низкие накладные расходы, возможно, какой-то функциональности от VTune и Thread Profiler пользователям будет недоставать. Все замечания и пожелания по поводу функционала продукта можно разместить на форумах ISN, как англоязычном, так и русскоязычном. Тем более, что для Intel Parallel Studio это будет основная модель поддержки клиентов.