Оптимизация параллельной программы
10.6. Проблемы производительности, определяемые при помощи профилирования
Мы уже говорили ранее, что профилирование многопоточных приложений имеет свою специфику. ITP ориентирован на выявление проблемных ситуаций, характерных именно для многопоточных приложений, таких как: неэффективное управлением потоками, ошибки при выборе и использовании примитивов синхронизации, неправильное распределение вычислительной нагрузки и другие недостатки.
Рассмотрим далее наиболее распространенные ошибки и то, как ITP может помочь разработчикам в их обнаружении и исправлении.
10.6.1. Распределение вычислительной нагрузки
Весьма распространенной ошибкой является неравномерное распределение нагрузки между потоками. Общее время работы приложения в значительной степени зависит от времени работы самого медленного из его потоков. Очевидно, приложение будет работать быстрее всего, если нагрузка поделена между потоками поровну - тогда они завершают работу одновременно и за минимальное время.
Типичный пример: стоит задача обработки множества заявок, трудоемкость каждой из которых заранее неизвестна. В такой ситуации не всегда правильно разделять множество заявок поровну между потоками. Дело в том, что один поток может, например, получить все сложные заявки, в результате чего он станет узким местом в приложении.
Проблема распределения вычислительной нагрузки в ряде ситуаций решается достаточно просто. Первый признак ее появления - большая доля последовательных вычислений в приложении, что легко обнаруживается при анализе критического пути. Кроме того, ITP позволяет определить время работы каждого из потоков. При этом, если наблюдается существенное различие между временами работы нескольких потоков (при том, что они выполняют одинаковые действия), это свидетельствует о неравномерном распределении нагрузки.
Для рассмотренного выше примера в качестве решения можно предложить не делать априорного разделения множества заявок между потоками. Вместо этого стоит разрешить потокам выбирать из общей очереди новую заявку каждый раз после того, как была обработана предыдущая. Тогда пока один поток обрабатывает одну сложную заявку, второй поток успеет обработать несколько простых, и в результате можно ожидать более сбалансированного времени работы потоков.
10.6.2. Синхронизация и производительность
Аспекты производительности, связанные с синхронизацией между потоками, требуют особого внимания. Неправильно выбранная стратегия может привести к тому, что параллельная версия приложения будет выполняться даже медленнее, чем последовательная. Поэтому разработчик должен тщательно продумать используемую в его приложении модель синхронизации, и ITP способен оказать ему в этом существенную помощь.
Рассмотрим основные вопросы, связанные с синхронизацией.
10.6.2.1. Выбор примитивов синхронизации
Существует достаточно большое число примитивов синхронизации: мьютексы, семафоры, мониторы, критические секции. Все они имеют одинаковое назначение (обрамление критических областей программы), но эффективность (дополнительные накладные расходы) их работы может существенно различаться. При этом разработчику важно выбрать наиболее подходящий тип примитива синхронизации для каждой конкретной ситуации.
Рассмотрим, например, ситуацию, когда в приложении имеется некоторый целочисленный счетчик, являющийся глобальной переменной. При этом, если мы в приложении используем каждый раз увеличение этого счетчика на единицу, то выбор такого объекта как мьютекс является нерациональным. Гораздо эффективнее использовать атомарную функцию ОС Windows InterlockedIncrement. Кроме того, при разработке модели синхронизации следует делать выбор в пользу примитивов пользовательского уровня ( CriticalSection, например), поскольку они работают быстрее, так как не генерируют системных вызовов операционной системы.
ITP способен помочь в выборе наиболее подходящего примитива, позволяя определить время, затраченное на работу с каждым из объектов синхронизации. Таким образом, разработчик может реализовать несколько моделей синхронизации и сравнить их производительность между собой.
10.6.2.2. Синхронизация между потоками
Разработчикам следует придерживаться следующей рекомендации: производить синхронизацию между потоками как можно реже. То есть необходимо сделать потоки максимально независимыми, чтобы избежать ситуаций, когда они ожидают друг друга. Слишком частое обращение нескольких потоков к разделяемому ресурсу приводит к тому, что большое количество времени потоки простаивают, находясь в состоянии ожидания.
Часто встречающаяся ситуация - это совместное использование одного и того же объекта несколькими потоками. Чтобы избежать блокировки потоков при обращении к объекту, часто используется подход, при котором каждый из потоков получает копию объекта в свое личное пользование. Так, в частности, поступают при работе нескольких пользователей с одной таблицей базы данных.
В данной ситуации ITP используется следующим образом. С его помощью определяются объекты, обращение к которым происходит наиболее часто. Затем анализируется насколько затратно столь частое обращение к объекту. Если обнаруживается, что производительность существенно страдает, разработчику следует попытаться изменить архитектуру приложения. Хороший пример - замена глобальных переменных локальными.
10.6.3. Непроизводительные издержки при работе с потоками
Важный момент при работе с потоками - появление дополнительных накладных расходов. Конечно, эти затраты гораздо меньше чем при управлении процессами, но все равно они могут оказаться весьма существенными. Основная рекомендация здесь состоит в следующем: потоки за время своей жизни должны совершать работу гораздо большей сложности, чем трудоемкость их собственного создания, уничтожения и управления ими.
Понятно, что если накладные расходы на управление потоками будут преобладать над их полезной деятельностью, то производительность приложения только пострадает.
ITP позволяет определить долю непроизводительных издержек от общего времени работы приложения. Если она оказывается слишком велика, скорее всего, приложение требует перепроектирования. Так, например, если создается система, обслуживающая поступающие в режиме реального времени запросы, то для обработки лучше содержать пул потоков, вместо того, чтобы создавать новый поток для каждого очередного запроса.
10.7. Общий порядок работы с инструментом
Порядок работы с Intel® Thread Profiler включает в себя следующие шаги:
На первом шаге происходит подготовка приложения к профилированию - так называемая инструментация. Затем осуществляется запуск, и в процессе выполнения происходит накопление статистической информации о работе приложения. Собранная информация представляет собой трассу приложения, которая затем обрабатывается ITP и представляется в графическим виде, удобном для понимания. После этого разработчик может начинать непосредственно анализ производительности приложения.
Далее мы приведем рассмотрение процессов инструментации и профилирования. О том, как эти процессы осуществляются в ITP, мы расскажем в "Cинхронизация и накладные расходы на поддержку многопоточности" .
10.7.1. Инструментация приложения
Инструментация представляет собой встраивание в приложение дополнительных вызовов, при помощи которых профилировщик получает информацию о работе приложения. Эти вызовы могут быть добавлены как на уровне исходного кода приложения, так и в уже скомпилированное приложение. В связи с этим различают инструментацию исходных кодов ( source instrumentation ) и бинарную инструментацию ( binary instrumentation ). ITP поддерживает оба этих способа.
Бинарная инструментация выполняется автоматически при запуске приложения из ITP. То есть вы имеете возможность взять любое уже скомпилированное приложение и немедленно начать профилировку. Однако полезность информации, которую вы получите, существенно зависит от опций, с которыми было скомпилировано приложение. Так, если в него была включена отладочная информация, то вы сможете обращаться к исходному коду из ITP. В противном случае вам будет доступен лишь ассемблерный код, что может быть весьма неудобно.
В связи с этим, обычная процедура состоит в следующем: разработчик компилирует свое приложение со всеми необходимыми опциями, а затем использует бинарную инструментацию. Мы рассмотрим этот процесс подробнее в разделе 6.2.
Второй тип инструментации используется крайне редко. Создатели ITP рекомендуют инструментацию исходных кодов лишь в двух ситуациях:
- Бинарная инструментация недоступна. Это справедливо для систем, созданных для работы на архитектурах Intel® Itanium и Intel® EM64T.
- Необходимо запустить инструментированное приложение вне Intel® Thread Profiler.
Далее под инструментацией мы будем понимать именно бинарную инструментацию, поскольку именно с ней нам придется работать.
10.7.2. Профилирование приложения
После того как приложение было инструментировано, можно начинать профилирование. ITP осуществляет запуск приложения, в процессе которого собирает его трассу. В нее включается следующая статистическая информация:
- идентификаторы созданных потоков;
- время создания и уничтожения каждого потока;
- количество времени, которое каждый поток провел в состоянии ожидания;
- эффективность использования приложением ядер процессора на каждом участке критического пути.
ITP также регистрирует большое число других событий и вызовов API, информация о которых может быть полезна при оптимизации производительности многопоточного приложения.
При профилировании старайтесь следовать следующим советам:
- Избегайте запускать другие приложения во время профилирования. Деятельность посторонних приложений (особенно потребляющих много ресурсов) может существенно исказить интересующую вас информацию.
- Производите профилирование несколько раз и для анализа выбирайте тот запуск, когда приложение отработало быстрее всего. Этот запуск соответствует ситуации, когда на ваше приложение было меньше всего воздействий, поэтому эта информация ближе всего к "идеальному" профилю вашего приложения.
Далее мы на конкретном примере познакомимся с графическим интерфейсом ITP и основными приемами работы с ним.
10.8. Пример использования Intel Thread Profiler
Целью настоящего раздела является начальное ознакомление с инструментом ITP и общими принципами работы с ним. Изучается процесс подготовки приложения к профилированию, графический интерфейс ITP и основные возможности по анализу производительности многопоточных приложений.
Настоящий раздел представлен в виде лабораторной работы, которую читатели могут выполнять одновременно с чтением данного документа.
10.8.1. Изучение профилируемого приложения
Лабораторная работа проводится на учебном приложении, которое осуществляет факторизацию (разложение на простые множители) чисел из диапазона от 1 до N. Используется алгоритм, который основан на попытке деления факторизуемого числа на каждое из меньших его чисел. Если остаток от деления равен нулю, то очередной множитель запоминается, после чего производится повторная попытка деления на это же число. При нахождении каждого множителя, факторизуемое число делится на него, и алгоритм завершает работу, когда частное от очередного деления становится равным единице. Заметим, что это малоэффективный алгоритм факторизации, поэтому мы не рекомендуем использовать его при решении практических задач. Использование такого алгоритма предпринято только в учебных целях - на его примере мы сможем изучить некоторые особенности оптимизации многопоточных приложений.
Откройте проект Factorization, последовательно выполняя следующие шаги:
- запустите приложение Microsoft Visual Studio 2005,
- в меню File выполните команду Open Project/Solution…,
- в диалоговом окне Open Project выберите папку C:\ITPLab\Factorization,
- дважды щелкните на файле Factorization.sln или, выбрав файл, выполните команду Open.
В окне Solution Explorer дважды щелкните на файле исходного кода Factorization.cpp (рис. 10.5). После этого в рабочей области Microsoft Visual Studio появится программный код, с которым нам предстоит работать.
Приступим к изучению приложения. В начале файла Factorization.cpp объявлены две константы:
#define NUM_NUMBERS 100000 #define NUM_THREADS 2
Первая из них указывает количество чисел, которые будут факторизованы. В данном случае будет построено разложение для чисел от 1 до 100000. Вторая константа показывает, сколько потоков будет создано для решения этой задачи.
Также объявлен глобальный массив векторов divisors.
vector<int> divisors[NUM_NUMBERS+1];
Он предназначен для хранения простых множителей каждого из чисел. Так, например, вектор divisors для NUM_NUMBERS=6 будет содержать два числа: 2 и 3.
В данной лабораторной работе нас будут интересовать только функции main и factorization1.
Ознакомьтесь с кодом функции main. Он содержит объявления переменных, операции создания потоков и ожидания их завершения, а также вывод на экран, предназначенный для контроля правильности результатов.
После этого ознакомьтесь с функцией factorization1, которая представляет собой рабочую функцию потока. В ней реализуется простейшая стратегия распределения нагрузки между потоками. А именно, первый поток строит разложение для первых NUM_NUMBERS/NUM_THREADS чисел, второй - для второго массива чисел такой же длины, и так далее. Пример распределения нагрузки между двумя потоками представлен на рис. 10.6
Скомпилируйте и запустите приложение:
- В меню Build выберите команду Build Solution ;
- В меню Debug выберите команду Start Without Debugging.
Убедитесь в правильности работы приложения по выводу на консоли.