Рассмотрим несколько простых примеров.
Пример 1
Пусть DVM-эксперт распараллелил следующий цикл:
CDVM$ PARALLEL (i,j) ON a(i,j)
do i=1,N
do j=1,M
A(I, J) = B(J, I)
enddo
enddo
Директива CDVM$ PARALLEL (i,j) ON a(i,j) говорит о том, что виток цикла со значением итераторов (i, j) будет выполняться на то узле, на котором распределен элемента массива a(i, j). Существует два способа распараллелить на OpenMP этот многомерный цикл: распараллелить внутренний или внешний цикл.
Вариант 1.1
CDVM$ PARALLEL (i,j) ON a(i,j)
!$OMP PARALLEL PRIVATE(i, j)
!$OMP DO SCHEDULE (STATIC)
do i=1,N
do j=1,M
A(I, J) = B(J, I)
enddo
enddo
!$OMP END DO
!$OMP END PARALLEL
Директива !$OMP PARALLEL говорит о том, что на каждом узле начинается параллельная область. То есть на узле создаются нити, между которыми будет распределяться работа. Все порождённые нити исполняют один и тот же код, соответствующий параллельной области. Предполагается, что в SMP-системе нити будут распределены по различным ядрам процессора. Клауза PRIVATE(i, j) означает, что для каждой нити выделяется локальная память под две переменные: i и j. Действительно, у каждой нити должен быть свой итератор цикла. По достижении директивы !$OMP END PARALLEL нити останавливают свою работу, и на узле остается работать только одна мастер-нить.
Директива !$OMP DO сообщает нам, что далее в программе следует цикл, витки которого будут распределяться между нитями. Клауза SCHEDULE (STATIC) гласит, что все множество итераций внешнего цикла делится на непрерывные куски примерно одинакового размера, и полученные порции итераций распределяются между нитями.
Директива !$OMP END DO сообщает об окончании параллельного цикла. Происходит неявная барьерная синхронизация нитей и неявный вызов !$OMP FLUSH. Выполнение FLUSH предполагает, что значения всех переменных (или переменных из списка, если он задан), временно хранящиеся в регистрах и кэш-памяти текущей нити, будут занесены в основную память; все изменения переменных, сделанные нитью во время работы, станут видимы остальным нитям. [9]
Вариант 1.2
CDVM$ PARALLEL (i,j) ON a(i,j)
do i=1,N
!$OMP PARALLEL PRIVATE(i, j)
!$OMP DO SCHEDULE (STATIC)
do j=1,M
A(I, J) = B(J, I)
enddo
!$OMP END DO
!$OMP END PARALLEL
enddo
Отличие этого варианта от предыдущего заключается в том, на каждой итерации внешнего цикла будет создаваться новая параллельная область, то есть будут создаваться нити, и выделяться локальная память для них. Затем нити будут синхронизоваться, выполнять FLUSH и останавливаться. Всё это накладные расходы, которые будут возникать N раз. Еще одно отличие заключается в том, что в этом варианте между нитями распределяются витки внутреннего цикла, а не внешнего.
Другим важным критерием, помимо накладных расходов на вызовы библиотеки OpenMP, является загруженность нитей. Если рассматривать первый вариант, то N витков внешнего цикла сначала распределяются между узлами, а потом витки, соответствующие одному узлу, распределяются между ядрами этого узла. Если количество витков цикла меньше суммарного количества ядер нашего SMP-кластера, то некоторые ядра будут простаивать. Если некоторый цикл способен загрузить не более чем по одному ядру на каждом узле, то распараллеливать на OpenMP такой цикл смысла не имеет.
Пример 2
Пусть DVM-эксперт распараллелил то же самый цикл следующим образом:
CDVM$ PARALLEL (i,j) ON a(*,j)
do i=1,N
do j=1,M
A(I, J) = B(J, I)
enddo
enddo
Отличие этого примера от предыдущего заключается в распределении данных. Здесь, между узлами одномерной процессорной решетки распределено только второе измерение массива A. Соответственно, между узлами будут распределяться только итерации внутреннего цикла. Таким образом, если количество витков внешнего цикла меньше суммарного количества ядер нашего SMP-кластера, но больше количества ядер на одном узле, то распараллеливание на OpenMP внешнего цикла позволит загрузить все ядра (заметим, что в варианте 1.1 на каждом узле работало бы не более одного ядра).
Для этого примера также существует два варианта распараллеливания, аналогичных вариантам 1.1 и 1.2.
Пример 3
Пусть DVM-эксперт распараллелил следующий цикл:
CDVM$ PARALLEL (i,j) ON a(i,j),
*DVM$* REDUCTION (SUM(s))
do i=1,N
do j=1,M
S = S + A(I, J)
enddo
enddo
Клауза REDUCTION (SUM(s)) означает, что каждый узел создаст в своей локальной памяти редукционную переменную, и будет накапливать в ней суммы элементов массива. По окончанию цикла эти локальные переменные будут просуммированы и записаны в s. В таком случае мы по-прежнему имеем два варианта распараллеливания:
Вариант 3.1 | Вариант 3.2 |
CDVM$ PARALLEL (i,j) ON a(i,j),*DVM$* REDUCTION (SUM(s))!$OMP PARALLEL PRIVATE(i, j)!$OMP*REDUCTION(+: s)!$OMP DO SCHEDULE (STATIC) do i=1,N do j=1,M S = S + A(I, J) enddo enddo!$OMP END DO!$OMP END PARALLEL | CDVM$ PARALLEL (i,j) ON a(i,j),*DVM$* REDUCTION (SUM(s)) do i=1,N!$OMP PARALLEL PRIVATE(i, j)!$OMP*REDUCTION(+: s)!$OMP DO SCHEDULE (STATIC) do j=1,M S = S + A(I, J) enddo!$OMP END DO!$OMP END PARALLEL enddo |
Клауза REDUCTION(+: s) означает, что каждая нить создаст в своей локальной памяти редукционную переменную, и будет накапливать в ней суммы элементов массива. По окончанию цикла эти локальные редукционные переменные нитей будут просуммированы и записаны в локальную редукционную переменную узла.
В варианте 3.1 локальные редукционные переменные нитей будут суммироваться по окончании внешнего цикла. В варианте 3.2 локальные редукционные переменные нитей суммируются на каждой итерации внешнего цикла, и это снова дополнительные накладные расходы.
Пример 4
Рассмотрим пример с регулярной зависимостью:
CDVM$ PARALLEL (i,j) ON a(i,j),
*DVM$* ACROSS (a(1:1,1:1))
do i=2,N-1
do j=2,M-1
A(I, J) = A(I-1, J) + A(I+1, J)
* + A(I, J-1) + A(I, J+1)
enddo
enddo
Тело данного цикла примечательно тем, что невозможно независимое выполнение витков цикла, т.к. прежде чем вычислить A(I, J), необходимо вычислить A(I-1, J) и A(I, J-1).
Прежде всего, клауза ACROSS (a(1:1,1:1)) определяет точное местоположение удаленных данных (теневые грани). Также ACROSS обеспечивает сохранение порядка вычислений витков цикла.
Для распараллеливания на OpenMP циклов с регулярной зависимостью используется алгоритм конвейерного выполнения при помощи синхронизующего массива. Этот алгоритм применялся в тестах NAS [12]. Цикл с регулярной зависимостью должен содержать тесно-вложенный цикл. Вариант распараллеливания здесь всего один:
Вариант 4.1
CDVM$ PARALLEL (i,j) ONa(i,j),
*DVM$* ACROSS (a(1:1,1:1))
!$OMP PARALLEL PRIVATE(IAM, NUMT, ILIMIT, i, j)
!$ IAM = omp_get_thread_num ()
!$ NUMT = omp_get_num_threads ()
!$ ISYNC (IAM) = 0
!$ ILIMIT=MIN(NUMT-1, N-3)
!$OMP BARRIER
do i=2,N-1
!$ IF (IAM .GT. 0 .AND. IAM .LE. ILIMIT) THEN
!$ DO WHILE (ISYNC(IAM-1) .EQ. 0)
!$OMP FLUSH (ISYNC)
!$ ENDDO
!$ ISYNC(IAM-1)=0
!$OMP FLUSH (ISYNC)
!$ ENDIF
!$OMP DO SCHEDULE (STATIC)
do j=2,M-1
A( I, J ) = A( I-1, J ) + A( I+1, J ) +
* A( I, J-1 ) + A( I, J+1 )
enddo
!$OMP END DO NOWAIT
!$ IF (IAM .LT. ILIMIT) THEN
!$ DO WHILE (ISYNC (IAM) .EQ. 1)
!$OMP FLUSH (ISYNC)
!$ ENDDO
!$ ISYNC (IAM)=1
!$OMP FLUSH (ISYNC)
!$ ENDIF
enddo
!$OMP END PARALLEL
Предположим, что N = 7, M = 14, а количество нитей – 4. Принцип конвейерного выполнения отображен на рисунке.
Нить 1 | Нить 2 | Нить 3 | Нить 4 | |||||||||
Массив A | J = 2…4 | J = 5…7 | J = 8…10 | J = 11…13 | ||||||||
I = 2 | Такт 1 | Такт 2 | Такт 3 | Такт 4 | ||||||||
I = 3 | Такт 2 | Такт 3 | Такт 4 | Такт 5 | ||||||||
I = 4 | Такт 3 | Такт 4 | Такт 5 | Такт 6 | ||||||||
I = 5 | Такт 4 | Такт 5 | Такт 6 | Такт 7 | ||||||||
I = 6 | Такт 5 | Такт 6 | Такт 7 | Такт 8 |
Рисунок 4. Иллюстрация принципа конвейерной работы
Такт 1. Нить 1 выполняет три витка цикла: (I = 2, J = 2), (I = 2, J = 3), (I = 2, J = 4). Все остальные нити ждут. Таким образом, элементы массива A(2,2), A(2,3), A(2,4) получают новые значения.
Такт 2. Работают 1-я и 2-я нить. Нить 1 выполняет три витка цикла: (I = 3, J = 2), (I = 3, J = 3), (I = 3, J = 4). Отметим, что A(2, 2), A(2, 3) и A(2, 4), требуемые для вычисления A(I, J) для 1-й нити в текущем такте, уже содержат новое значение. Аналогично, нить 2 выполняет три витка: (I = 2, J = 5), (I = 2, J = 6), (I = 2, J = 7). Элемент A(2, 4), требуемый для вычисления A(2, 5), был уже посчитан 1-й нитью в 1-м такте.
Такт 3. Работают 1-я, 2-я и 3-я нить. Каждая нить выполняет по три витка. Для каждого элемента A(I, J), обрабатываемого в текущем такте, элементы A(I-1, J) и A(I, J-1) уже содержат новое значение.
Такт 4, 5, 6, 7, 8 – аналогично.
Таким образом, порядок вычисления витков при параллельной работе нитей сохраняется.
В работе конвейера можно отметить три этапа:
· Разгон конвейера (Такты 1,2,3).
· Работа с полной загрузкой (Такты 4,5).
· Остановка конвейера (Такты 6,7,8).
Пример 5
CDVM$ PARALLEL (i,j,k) ON a(i,j,k),
*DVM$* ACROSS (a(0:0,1:1,0:0))
do i=1,N-1
do j=1,M
do k=1,P
A( I, J, K ) = (A( I, J, K ) + A( I, J, K ) +
* A( I, J-1, K ) + A( I, J+1, K ))
enddo
enddo
enddo
Здесь регулярная зависимость присутствует только по второму измерению массива A. Соответственно, внешний или внутренний цикл можно распараллелить с помощью OMP PARALLEL DO, а для среднего можно организовать конвейер.
Вариант 5.1
CDVM$ PARALLEL (i,j,k) ON a(i,j,k),
*DVM$* ACROSS (a(0:0,1:1,0:0))
!$OMP PARALLEL PRIVATE(j, i, k)
!$OMP DO SCHEDULE (STATIC)
do i=1,N-1
do j=1,M
do k=1,P
A( I, J, K ) = (A( I, J, K ) + A( I, J, K ) +
* A( I, J-1, K ) + A( I, J+1, K ))
enddo
enddo
enddo
!$OMP END DO
!$OMP END PARALLEL
Вариант 5.2
CDVM$ PARALLEL (i,j,k) ON a(i,j,k),
*DVM$* ACROSS (a(0:0,1:1,0:1))
do i=1,N-1
!$OMP PARALLEL PRIVATE(IAM, NUMT, ILIMIT, j, k)
!$ IAM = omp_get_thread_num ()
!$ NUMT = omp_get_num_threads ()
!$ ISYNC (IAM) = 0
!$ ILIMIT=MIN(NUMT-1, 11)
!$OMP BARRIER
do j=1,M
!$ IF (IAM .GT. 0 .AND. IAM .LE. ILIMIT) THEN
!$ DO WHILE (ISYNC(IAM-1) .EQ. 0)
!$OMP FLUSH (ISYNC)
!$ ENDDO
!$ ISYNC(IAM-1)=0
!$OMP FLUSH (ISYNC)
!$ ENDIF
!$OMP DO SCHEDULE (STATIC)
do k=1,P
A( I, J, K ) = (A( I, J, K ) + A( I, J, K ) +
* A( I, J-1, K) + A( I, J+1, K ))
enddo
!$OMP END DO NOWAIT
!$ IF (IAM .LT. ILIMIT) THEN
!$ DO WHILE (ISYNC (IAM) .EQ. 1)
!$OMP FLUSH (ISYNC)
!$ ENDDO
!$ ISYNC (IAM)=1
!$OMP FLUSH (ISYNC)
!$ ENDIF
enddo
!$OMP END PARALLEL
Enddo
Вариант 5.3
CDVM$ PARALLEL (i,j,k) ON a(i,j,k),