Лекции 2 / Lectures_9_12 / Лекция 10
.docЛекция 10. Системы параллельного программирования OpenMP
Разработчики OpenMP хотели предоставить простой способ создания потоков в приложениях, не требуя от программиста знаний о создании, синхронизации и уничтожении потоков, а также необходимости определения, сколько потоков следует создать. Для этого разработчики OpenMP создали независимый от платформы набор прагм, директив, вызовов функций и переменных среды, которые явным образом указывают компилятору, как и где именно следует вставить потоки в приложение. Большинство циклов можно распараллелить, вставив всего одну прагму непосредственно перед циклом. Более того, оставив исполнение рутинных функций компилятору и OpenMP, вы можете больше времени уделить определению того, какие циклы следует распараллелить, и как наилучшим образом изменить структуру алгоритмов для достижения максимальной производительности. Максимальная производительность OpenMP реализуется при использовании этого инструмента для распараллеливания "горячих точек", - наиболее трудоемких циклов в приложении.
В OpenMP существуют пять ограничений на то, какие циклы можно распараллелить:
-
Переменная цикла должна иметь тип signed integer. Беззнаковые целые числа, такие как DWORD, работать не будут.
-
Операция сравнения должна иметь следующий формат: переменная_цикла <, <=, >, >= инвариант_цикла_целого_типа
-
Третье выражение (или инкрементная часть цикла for) должно являться либо целочисленным сложением, либо целочисленным вычитанием и должно практически совпадать со значением инварианта цикла.
-
Если используется операция сравнения < или <=, переменная цикла должна увеличиваться при каждой итерации, а при использовании операции > или >= переменная цикла должна уменьшаться.
-
Цикл должен являться базовым блоком. Это означает, что не разрешены переходы из цикла, за исключением оператора exit, который завершает работу всего приложения. Если используются операторы goto или break, они должны приводить к переходам внутри цикла, а не вне его. То же самое относится к обработке исключений; исключения должны перехватываться внутри цикла.
Хотя эти ограничения кажутся сдерживающими, нестандартные циклы можно легко изменить соответствующим образом.
Основы компиляции
Для использования прагм OpenMP требуется совместимый с OpenMP компилятор и поточно-ориентированные библиотеки. Хорошим вариантом является компилятор Intel® языка C++ версии 7.0 или выше. (Компилятор Intel® языка Fortran также поддерживает OpenMP.) Добавление в компилятор следующей опции командной строки указывает компилятору обратить внимание на прагмы OpenMP и вставить потоки.
Если опция /Qopenmp в командной строке отсутствует, компилятор будет игнорировать прагмы OpenMP, что позволяет с легкостью создать версию с единственным потоком, не изменяя исходный код. Компилятор Intel языка C++ поддерживает спецификацию OpenMP 2.0. Данные о последних обновлениях приводятся в сведениях о выпуске и информации о совместимости, которые поставляются вместе с компилятором Intel® языка C++. Полная спецификация OpenMP доступна на сайте http://www.openmp.org*.
Open MP Задуман как стандарт для программирования на масштабируемых системах с общей памятью (работает через IPC – передача через общую память). В стандарт OpenMP входят спецификации набора директив компилятора, процедур и переменных среды.
Директивы, классы и переменные OpenMP
Директивы
-
Порождение нитей
-
Разделение работы
-
Директивы синхронизации
Классы переменных
-
SHARED – общие
-
PRIVATE – приватные
Переменные
-
Переменные среды
-
Процедуры для контроля/запроса параметров среды исполнения
-
Процедуры для синхронизации на базе замков
Основы компиляции
Для использования прагм OpenMP требуется совместимый с OpenMP компилятор и поточно-ориентированные библиотеки. Хорошим вариантом является компилятор Intel® языка C++ версии 7.0 или выше. (Компилятор Intel® языка Fortran также поддерживает OpenMP.) Добавление в компилятор следующей опции командной строки указывает компилятору обратить внимание на прагмы OpenMP и вставить потоки.
Если опция /Qopenmp в командной строке отсутствует, компилятор будет игнорировать прагмы OpenMP, что позволяет с легкостью создать версию с единственным потоком, не изменяя исходный код. Компилятор Intel языка C++ поддерживает спецификацию OpenMP 2.0. Данные о последних обновлениях приводятся в сведениях о выпуске и информации о совместимости, которые поставляются вместе с компилятором Intel® языка C++. Полная спецификация OpenMP доступна на сайте http://www.openmp.org*.
Для условной компиляции компилятор определяет _OPENMP. При необходимости это определение можно протестировать, как показано ниже.
- collapse sourceview plaincopy to clipboardprint?
#ifdef _OPENMP
fn();
#endif
Все прагмы OpenMP имеют следующий формат:
Если в начале строки отсутствует pragma omp - это не прагма OpenMP. С полным списком прагм можно ознакомиться в спецификации, размещенной на сайте http://www.openmp.org*.
Поточно-ориентированные CRT-библиотеки выбираются с помощью опций командной строки компилятора /MD или /MDd (для отладки). Эти опции, при использовании Microsoft Visual C++*, выбираются в Code Generation Category для параметров проекта C/C++ путем выбора либо Multithreaded DLL, либо Debug Multithreaded DLL.
Несколько простых примеров
Следующие примеры демонстрируют простоту работы с OpenMP. В обычной практике необходимо рассматривать дополнительные вопросы, однако эти примеры - хорошее начало.
Задача 1: Следующий цикл отсекает массив в диапазоне 0...255. Необходимо распараллелить его, используя прагму OpenMP.
Решение: Просто вставьте следующую прагму непосредственно перед циклом.
Задача 2: В следующем цикле создается таблица квадратных корней для чисел 0...100. Необходимо распараллелить его с помощью OpenMP.
Решение: В качестве переменной цикла необходимо использовать целое число со знаком, а затем добавить прагму.
Как избежать зависимости данных и состояния гонки
Если цикл соответствует всем ограничениям и компилятор распараллелил цикл, это не гарантирует правильной работы, поскольку может существовать зависимость данных. Зависимость данных существует, если различные итерации цикла (точнее говоря, итерация, которая выполняется в другом потоке) выполняют чтение или запись общей памяти. Рассмотрим следующий пример, в котором вычисляются факториалы.
- collapse sourceview plaincopy to clipboardprint?
// Do NOT do this. It will fail due to data dependencies.
// Each loop iteration writes a value that a different
iteration reads.
#pragma omp parallel for
for (i=2; i < 10; i++)
{
factorial[i] = i * factorial[i-1];
}
Компилятор создает из этого цикла поток, который, однако, завершается ошибкой, поскольку по крайней мере одна из итераций цикла зависит от данных другой итерации. Подобная ситуация называется состоянием гонки или гонками данных. Состояние гонки возникает только при использовании общих ресурсов (например, памяти) и при параллельном выполнении. Для решения этой проблемы следует либо изменить цикл, либо выбрать другой алгоритм, который не приведет к состязанию потоков.
Состояние гонки трудно обнаружить, поскольку, в данном экземпляре, переменные могут "выигрывать гонку" в том порядке, который обеспечивает правильную работу программы. Но то, что программу удалось выполнить один раз, не означает, что она будет работать всегда. Хорошей отправной точкой является тестирование программы в различных системах, в одних из которых применяется технология Hyper-Threading, а в других – несколько физических процессоров. Также могут оказаться полезными такие инструменты, как Intel Thread Checker. Традиционные программы отладки не в состоянии обнаружить гонки данных, поскольку вынуждают один поток остановить "гонку", в то время как остальные потоки продолжают вносить значительные изменения в динамическое поведение.
Управление общими и индивидуальными данными
Практически в каждом цикле (по крайней мере, если это полезный цикл) выполняется чтение данных из памяти и запись в память. Программист должен указать компилятору, какие участки памяти являются общими для всех потоков, а какие участки должны быть индивидуальными. Если память определена как общая, все потоки получают доступ к одному и тому же адресу памяти. Однако если память определена как индивидуальная, создается отдельная копия переменной для каждого потока, чтобы обеспечить индивидуальный доступ. После завершения цикла эти индивидуальные копии уничтожаются. По умолчанию общими являются все переменные, за исключением переменной цикла, которая является индивидуальной. Память можно объявить как индивидуальную следующими двумя способами.
Объявите переменную внутри цикла (внутри директивы OpenMP) без ключевого слова static.
Укажите индивидуальное выражение в директиве OpenMP.
Следующий цикл не может работать правильно, поскольку переменная temp является общей. Эта переменная должна быть индивидуальной.
- collapse sourceview plaincopy to clipboardprint?
// WRONG. Fails due to shared memory.
// Variable temp is shared among all threads, so while one thread
// is reading variable temp another thread might be writing to it
#pragma omp parallel for
for (i=0; i < 100; i++)
{
temp = array[i];
array[i] = do_something(temp);
}
В следующих двух примерах переменная temp объявляется как индивидуальная, что позволяет решить проблему.
- collapse sourceview plaincopy to clipboardprint?
// This works. The variable temp is now private
#pragma omp parallel for
for (i=0; i < 100; i++)
{
int temp; // variables declared within a parallel construct are, by definition, private
temp = array[i];
array[i] = do_something(temp);
}
// This also works. The variable temp is declared private
#pragma omp parallel for private(temp)
for (i=0; i < 100; i++)
{
temp = array[i];
array[i] = do_something(temp);
}
Каждый раз, когда вы используете OpenMP для распараллеливания цикла, следует тщательно изучить все обращения к памяти, включая обращения, выполняемые вызываемыми функциями. Переменные, объявленные в директиве parallel, определяются как индивидуальные, за исключением того случая, когда они объявляются с описателем static, поскольку статические переменные не размещаются в стек.
Уменьшения
Циклы, суммирующие значения, широко распространены, и в OpenMP имеется специальное выражение для работы с ними. Рассмотрим следующий цикл, в котором вычисляется сумма массива целых чисел.
- collapse sourceview plaincopy to clipboardprint?
sum = 0;
for (i=0; i < 100; i++)
{
sum += array[i]; // this variable needs to be shared to generate the correct results,
// but private to avoid race conditions from parallel execution
}
Переменная sum в предыдущем цикле должна быть общей для получения правильного результата, однако также должна быть и индивидуальной для обеспечения доступа нескольких потоков. Для разрешения этой ситуации в OpenMP предоставляется выражение reduction, которое применяется для эффективного сочетания математического уменьшения одной или нескольких переменных в цикле. В следующем цикле используется выражение reduction для получения правильных результатов.
- collapse sourceview plaincopy to clipboardprint?
sum = 0;
#pragma omp parallel for reduction(+:sum)
for (i=0; i < 100; i++)
{
sum += array[i];
}
OpenMP предоставляет индивидуальные копии переменной sum для каждого потока, а после завершения потоков складывает значения и помещает результат в одну глобальную копию переменной.
В следующей таблице показаны возможные уменьшения, а также исходные переменные (которые также являются математически определяемым значением) для временно индивидуальных переменных.
В цикле можно выполнить несколько уменьшений, указав разделенные запятыми переменные и уменьшения в данной директиве parallel. Единственными требованиями для этого являются следующие:
переменные уменьшения можно перечислить только в одном reduction
их нельзя объявить постоянными;
их нельзя объявить индивидуальными в директиве parallel.
Планирование цикла
Баланс нагрузки (распределение рабочей нагрузки поровну между потоками) является одним из наиболее важных атрибутов параллельного выполнения приложения. Баланс нагрузки имеет большое значение, поскольку гарантирует работу всех процессоров большую часть времени. Без баланса нагрузки некоторые потоки могут завершить работу значительно раньше остальных, что приводит к простою вычислительных ресурсов и потере производительности.
В циклах отсутствие баланса нагрузки обычно является следствием различия времени вычисления в различных итерациях цикла. Разброс времен вычисления в итерациях цикла обычно легко определить, изучив исходный код. В большинстве случаев вы увидите, что итерации цикла занимают одинаковое время. Если это не так, то можно найти наборы итераций, занимающие одинаковое время. Например, иногда набор всех четных итераций занимает примерно столько же времени, как и набор всех нечетных итераций. Аналогично, первая половина итераций цикла может занимать примерно столько же времени, как и вторая половина. С другой стороны, может оказаться невозможным найти наборы итераций, имеющие одинаковое время выполнения. Независимо от того, какой из этих случаев имеет место в конкретном приложении, необходимо предоставить OpenMP эту дополнительную информацию о планировании цикла, чтобы он мог правильно распределить итерации цикла между потоками (и, следовательно, между процессорами) для оптимизации распределения нагрузки.
По умолчанию, OpenMP предполагает, что все итерации цикла занимают одинаковое время. В результате OpenMP распределяет итерации цикла между потоками примерно поровну и таким образом, чтобы минимизировать вероятность возникновения конфликтов памяти вследствие ее неправильного совместного использования. Это возможно, поскольку итерации цикла обычно обращаются к памяти последовательно. Поэтому при разделении цикла на две большие части (например, на первую и вторую половины) при использовании двух потоков вероятность наложения памяти оказывается наименьшей. Однако, хотя это и может быть наилучшим вариантом для избежания конфликтов памяти, с точки зрения баланса нагрузки это может быть плохим выбором. К сожалению, обратное тоже справедливо. То, что хорошо для баланса нагрузки, может быть плохо для работы с памятью. Поэтому инженерам по производительности необходимо найти баланс между оптимальным использованием памяти и оптимальным распределением нагрузки, измеряя производительность, чтобы определить, какие методы дают наилучшие результаты.
Информация о планировании цикла передается в OpenMP с помощью директивы parallel, имеющей следующий формат.
- collapse sourceview plaincopy to clipboardprint?
#pragma omp parallel for schedule(kind [, chunk size])
Четыре различных типа планирования цикла может быть представлено в OpenMP, как показано в следующей таблице. Интересы параметр (chunk) должен являться постоянным для цикла положительным целым числом.
Примеры
Задача: Распараллелить следующий цикл
- collapse sourceview plaincopy to clipboardprint?
for (i=0; i < NumElements; i++)
{
array[i] = StartVal;
StartVal++;
}
Решение: Рассмотрим зависимости данных
В исходном виде цикл содержит зависимость данных, что делает невозможным его распараллеливание без внесения изменений. Новый цикл, показанный ниже, заполняет массив таким же образом, но в нем отсутствуют зависимости данных. Также можно написать новый цикл с применением команд SIMD.
- collapse sourceview plaincopy to clipboardprint?
#pragma omp parallel for
for (i=0; i < NumElements; i++)
{
array[i] = StartVal + i;
}
Обратите внимание, что данный код не на 100% совпадает с первоначальным, поскольку отсутствует приращение переменной StartVal. В результате после завершения параллельного цикла эта переменная будет иметь значение, отличное от того, которое получается в последовательной версии. Если значение StartVal используется после цикла, требуется дополнительный оператор, показанный ниже.
- collapse sourceview plaincopy to clipboardprint?
// This works and is identical to the serial version.
#pragma omp parallel for
for (i=0; i < NumElements; i++)
{
array[i] = StartVal + i;
}
StartVal += NumElements;
Резюме
Основной целью создания OpenMP было освободить программиста от подробностей многопоточности, позволяя сосредоточиться на более важных вопросах. С помощью прагм OpenMP большинство циклов можно распараллелить с помощью одного простого оператора. В этом заключается сила OpenMP и его основное преимущество.
