Опубликован: 28.10.2009 | Уровень: специалист | Доступ: платный
Лекция 5:

Параллельное программирование с использованием OpenMP

7.3.1. Управление распределением итераций цикла между потоками

При разном объеме вычислений в разных итерациях цикла желательно иметь возможность управлять распределением итераций цикла между потоками - в OpenMP это обеспечивается при помощи параметра schedule директивы for. Поле type параметра schedule может принимать следующие значения:

  • static - статический способ распределения итераций до начала выполнения цикла. Если поле chunk не указано, то итерации делятся поровну между потоками. При заданном значении chunk итерации цикла делятся на блоки размера chunk и эти блоки распределяются между потоками до начала выполнения цикла.
  • dynamic - динамический способ распределения итераций. До начала выполнения цикла потокам выделяются блоки итераций размера chunk (если поле chunk не указано, то полагается значение chunk=1 ). Дальнейшее выделение итераций (также блоками размера chunk ) осуществляется в момент завершения потоками своих ранее назначенных итераций.
  • guided - управляемый способ распределения итераций. Данный способ близок к предшествующему варианту, отличие состоит только в том, что начальный размер блоков итераций определяется в соответствии с некоторым параметром среды реализации OpenMP, а затем уменьшается экспоненциально (следующее значение chunk есть некоторая доля предшествующего значения) при каждом новом выделении блока итераций. При этом получаемый размер блока итераций не должен быть меньше значения chunk (если поле chunk не указано, то полагается значение chunk=1 ).
  • runtime - способ распределения итераций3Поле chunk для способа runtime не применимо , при котором выбор конкретной схемы (из ранее перечисленных) осуществляется в момент начала выполнения программы в соответствии со значением переменной окружения OMP_SCHEDULE. Так, например, для задания динамического способа при размере блока итераций 3, следует определить:
    setenv OMP_SCHEDULE "dynamic,3"

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

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

#include <omp.h> 
#define CHUNK 100 
#define NMAX 1000 
main () { 
 int i, j, sum; 
 float a[NMAX][NMAX]; 
 <инициализация данных>
 #pragma omp parallel for shared(a) private(i,j,sum) \
                                      schedule (dynamic, CHUNK)
 { 
  for (i=0; i < NMAX; i++) {
    sum = 0;
    for (j=i; j < NMAX; j++)
      sum += a[i][j]; 
    printf ("Сумма элементов строки %d равна %f\n",i,sum);
} /* Завершение параллельного фрагмента */ 
}
7.4. Пример динамического распределения итераций между потоками (использование параметра schedule директивы for)

7.3.2. Управление порядком выполнения вычислений

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

#pragma omp parallel for shared(a) private(i,j,sum) \
                     schedule (dynamic, CHUNK) ordered { 
  for (i=0; i < NMAX; i++) {
    sum = 0;
    for (j=i; j < NMAX; j++)
      sum += a[i][j]; 
#pragma omp ordered
    printf ("Сумма элементов строки %d равна %f\n",i,sum);
} /* Завершение параллельного фрагмента */
7.5. Пример использование директивы ordered

Поясним дополнительно, что параметр ordered управляет порядком выполнения только тех действий, которые выделены директивой ordered - выполнение оставшихся действий в итерациях цикла по-прежнему может происходить параллельно. Важно помнить также, что директива ordered может быть применена в теле цикла только один раз.

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

7.3.3. Синхронизация вычислений по окончании выполнения цикла

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

7.3.4. Введение условий при определении параллельных фрагментов (параметр if директивы parallel)

Теперь при наличии учебного примера можно пояснить назначение параметра if директивы parallel.

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

#include <omp.h> 
#define NMAX  1000 
#define LIMIT 100 
main () { 
 int i, j, sum; 
 float a[NMAX][NMAX]; 
 <инициализация данных>
 #pragma omp parallel for shared(a) private(i,j,sum) if (NMAX>LIMIT)
 { 
  for (i=0; i < NMAX; i++) {
    sum = 0;
    for (j=0; j < NMAX; j++)
      sum += a[i][j]; 
    printf ("Сумма элементов строки %d равна %f\n",i,sum);
 } /* Завершение параллельного фрагмента */ 
}
7.6. Пример использования параметра if директивы parallel

В приведенном примере параллельный фрагмент создается только, если порядок матрицы превышает 100 (т.е. когда обеспечивается некоторый минимальный объем выполняемых вычислений).

7.4. Управление данными для параллельно-выполняемых потоков

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

7.4.1. Определение общих и локальных переменных

Параметры shared и private директивы for для управления доступа к переменным уже использовались в примере 7.2. Параметр shared определяет переменные, которые будут общими для всех потоков. Параметр private указывает переменные, для которых в каждом потоке будут созданы локальные копии - они будут доступны только внутри каждого потока в отдельности (значения локальных переменных потока недоступны для других потоков). Параметры shared и private могут повторяться в одной и той же директиве несколько раз, имена переменных должны быть уже ранее определены и не могут повторяться в списках параметров shared и private.

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

default ( shared | none )

Как можно видеть, при помощи этого параметра можно отменить действие правила по умолчанию ( default(none) ) или восстановить правило, что по умолчанию переменные программы являются общими ( default(shared) ).

Следует отметить, что начальные значения локальных переменных не определены, а конечные значения теряются при завершении потоков. Для инициализации можно использовать параметр firstprivate директивы for, по которому начальные значения локальных переменных будут устанавливаться в значения, которые существовали в переменных до момента создания локальных копий. Запоминание конечных значений обеспечивается при помощи параметра lastprivate, в соответствии с которым значения локальных переменных копируются из потока, выполнившего последнюю итерацию. Поясним сказанное на примере рис. 7.5. На рисунке показана переменная sum, которая определена как lastprivate в директиве parallel for. Для этой переменной создаются локальные копии в каждом потоке, при завершении параллельного участка программного кода значение локальной переменной потока, выполнившего последнюю итерацию цикла, переписывается в исходную п еременную sum.

Общая схема работы с локальными переменными потоков

Рис. 7.5. Общая схема работы с локальными переменными потоков

7.4.2. Совместная обработка локальных переменных (операция редукции)

Использование параметра lastprivate позволяет сохранить значений локальной переменной одного из потоков, но во многих случаях для обработки могут понадобиться значения всех локальных переменных. Данная возможность может быть обеспечена, например, сохранением этих значений в общих переменных - более подробно правила работы с общими переменными будет рассмотрена в следующем подразделе. Другой подход состоит в использовании коллективных операций над локальными переменными, предусмотренными в OpenMP. Задание коллективной операции происходит при помощи параметра reduction директивы for:

reduction (operator: list)

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

+, -, *, &, |, ^, &&, ||

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

  • x = x <operator> <expression>
  • x = <expression> <operator> x (за исключением операции вычитания)
  • x <op>= <expression>
  • x++, ++x, x--, --x

где x есть имя скалярной переменной, выражение expression не должно включать переменную x, возможные операции для поля operator совпадают с выше приведенным списком, а допустимыми значениями для поля op являются операции:

+, -, *, &, |, ^

В качестве примера можно добавить в нашу учебную задачу действие по сложению всех сумм элементов строк матрицы - возможный программный код может быть следующим:

total = 0;
#pragma omp parallel for shared(a) private(i,j,sum) reduction (+:total)
{ 
  for (i=0; i < NMAX; i++) {
    sum = 0;
    for (j=i; j < NMAX; j++)
      sum += a[i][j]; 
    printf ("Сумма элементов строки %d равна %f\n",i,sum);
    total = total + sum;
} /* Завершение параллельного фрагмента */ 
printf ("Общая сумма элементов матрицы равна %f\n",total);
7.7. Пример использования операции редукции данных

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

total = total + sum;

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

Алексей Николаев
Алексей Николаев
Россия, г. Саранск
Рамиль Ариков
Рамиль Ариков
Россия, Республика Мордовия