Современный Фортран представляет собой специализированный язык программирования, предназначенный в основном для написания вычислительных программ для векторно-конвейерных и параллельных архитектур. Эволюция стандартов языка Фортран была рассмотрена в предыдущих статьях – здесь и здесь.
На данный момент действующим стандартом языка Фортран является стандарт ISO 2018 года "Fortran 2018", готовится к принятию стандарт 2023 года. К сожалению, различные компиляторы Фортрана поддерживают требования стандартов в различной степени.
В этой статье мы попробуем написать простейшую параллелизуемую программу на языке Фортран, используя для этого методы конвейеризации и симметричной параллелизации и сравним их между собой, применив наиболее популярные компиляторы GNU Fortran и Intel Fortran.
В целом, компилятор Intel Fortran гораздо более полно реализует стандарт Fortran 2018. В частности, он поддерживает все имеющиеся в стандарте средства параллельных вычислений, в то время как GNU Fortran реализует только самые базовые из них (чего, впрочем, в ряде случаев более чем достаточно). С другой стороны, Intel Fortran, в отличие от GNU Fortran, не обеспечивает реализацию символьного типа CHARACTER (KIND=4)
с поддержкой кодировки UCS-4, что может затруднить обработку не-ASCII текстов. Бытует мнение, что Intel Fortran обладает более мощным оптимизатором.
Постановка задачи
Напишем простейшую программу для реализации классического клеточного автомата игры "Жизнь". Не будем сейчас париться с вводом и выводом, исходную конфигурацию зададим в самой программе, а результирующую конфигурацию после заданного числа шагов выведем в файл. Нас будут интересовать сами вычислительные шаги клеточного автомата. Эта задача хороша для нас тем, что она позволяет небольшими усилиями достичь любого наперёд заданного объёма чистых (pure) вычислений с массивами произвольных размеров, не вырождаясь в заведомо излишний код, который оптимизатор мог бы выкинуть, обманув наши метрики производительности.
Для тестов, чтобы далеко не ходить, используется компьютер Mac mini с процессором Intel Core i3 @ 3.6 GHz с 4 физическими ядрами. Компиляторы GNU Fortran 12.2.0 и Intel Classic Fortran 2021.8.0 20221120.
0. Последовательная программа
Для начала напишем программу в чисто последовательном стиле. Напишем всё в одном файле, чтобы оптимизатору было легче работать.
program life
! чисто последовательный вариант программы
implicit none
! здесь мы задаём количество байтов
! для каждой ячейки - вдруг операции над
! целыми словами окажутся эффективными? (нет)
integer, parameter :: matrix_kind = 1
integer, parameter :: generations = 2 ! автомат рассматривает 2 поколения
integer, parameter :: rows = 1000, cols = 1000 ! размеры поля
integer, parameter :: steps = 10000 ! количество шагов
! описываем игровое поле. значения элементов могут быть целыми 0 или 1
integer (kind=matrix_kind) :: field (0:rows+1, 0:cols+1, generations)
integer :: thisstep = 1, nextstep =2 ! индексы массива для шагов
! при желании это можно легко обобщить на автомат с памятью больше 1 шага
integer :: i ! счётчик шагов
integer :: clock_cnt1, clock_cnt2, clock_rate ! для работы с таймером
! инициализируем поле на шаге thisstep начальной конфигурацией
call init_matrix (field (:, :, thisstep))
! засечём время
call system_clock (count=clock_cnt1)
! вызовем процедуру выполнения шага в цикле для заданного числа шагов
do i = 1, steps
! тут мы берём сечение массива по thisstep и преобразовываем в nextstep
call process_step (field (:, :, thisstep), field (:, :, nextstep))
! следующий шаг становится текущим
thisstep = nextstep
! а для следующего шага снова возвращаемся к другому сечению
nextstep = 3 - thisstep
end do
! узнаем новое значение таймера и его частоту
call system_clock (count=clock_cnt2, count_rate=clock_rate)
! напечатаем затраченное время и оценку производительности
print *, (clock_cnt2-clock_cnt1)/clock_rate, 'сек, ', &
int(rows*cols,8)*steps/(clock_cnt2-clock_cnt1)*clock_rate, 'ячеек/с'
! выведем результирующую конфигурацию в файл для контроля
call output_matrix (field (:, :, thisstep))
! разместим подпрограммы тут же, чтобы оптимизатору было проще
contains
! проинициализируем, просто воткнув одну "мигалку" в чистое поле
pure subroutine init_matrix (m)
integer (kind=matrix_kind), intent (out) :: m (0:,0:)
m = 0
m (50, 50) = 1
m (50, 51) = 1
m (50, 52) = 1
end subroutine init_matrix
! выведем матрицу в файл при помощи пробелов, звёздочек и грязного хака
subroutine output_matrix (m)
integer (kind=matrix_kind), intent (in) :: m (0:,0:)
integer :: rows, cols
integer :: i, j
integer :: outfile
rows = size (m, dim=1) - 2
cols = size (m, dim=2) - 2
open (file = 'life.txt', newunit=outfile)
do i = 1, rows
! выводим в каждой позиции строки символ, код которого является
! суммой кода пробела и значения ячейки (0 или 1), умноженного
! на разность между звёздочкой и пробелом
write (outfile, '(*(A1))') (char (ichar (' ') + &
m(i, j)*(ichar ('*') - ichar (' '))), j=1, cols)
end do
close (outfile)
end subroutine output_matrix
! здесь самое интересное – обработка шага
! для начала простой последовательный алгоритм
pure subroutine process_step (m1, m2)
integer (kind=matrix_kind), intent (in) :: m1 (0:,0:)
integer (kind=matrix_kind), intent (out) :: m2 (0:,0:)
integer :: rows, cols
integer :: i, j, s
! восстанавливаем значения rows и cols
! конечно, мы могли бы из просто передать в параметрах, но так культурнее
rows = size (m1, dim=1) - 2
cols = size (m1, dim=2) - 2
! обычные последовательные вложенные циклы
! поскольку в Фортране массивы хранятся по столбцам, то j раньше i
do j = 1, cols
do i = 1, rows
! считаем количество живых соседей
s = m1 (i-1, j) + m1 (i+1, j) + m1 (i-1, j-1) + m1 (i+1, j-1) + &
m1 (i, j-1) + m1 (i-1, j+1) + m1 (i, j+1) + m1 (i+1, j+1)
! присваиваем значение выходной клетке
select case (s)
case (3)
m2 (i, j) = 1
case (2)
m2 (i, j) = m1 (i, j)
case default
m2 (i, j) = 0
end select
end do
end do
! закольцуем игровое поле, используя гало в массиве,
! дублирующее крайние элементы с другой стороны массива
m2 (0,:) = m2 (rows, :)
m2 (rows+1, :) = m2 (1, :)
m2 (:, 0) = m2 (:, cols)
m2 (:, cols+1) = m2 (:, 1)
end subroutine process_step
end program life
Откомпилируем нашу программу при помощи GNU Fortran и Intel Fortran:
$ gfortran life_seq.f90 -o life_seq_g -O3 -ftree-vectorize -fopt-info-vec -flto
$ ifort life_seq.f90 -o life_seq -Ofast
Запустим:
$ ./life_seq_g
11 сек, 125172000 ячеек/с
$ ./life_seq
14 сек, 94120000 ячеек/с
125 лямов в секунду у GNU Fortran против 94 лямов у Intel Fortran.
Попробуем запустить автоматический параллелизатор (спасибо@AlexTmp8за замечание в комментариях):
$ gfortran life_seq.f90 -o life_seq_g -O3 -ftree-vectorize -fopt-info-vec -flto -floop-parallelize-all -fopenmp
$ ifort life_seq.f90 ‑o life_seq ‑Ofast ‑parallel
$ ./life_seq_g
11 сек, 124773000 ячеек/с
$ ./life_seq
4 сек, 340690000 ячеек/с
Intel Fortran очень серьёзно прибавил в производительности, в три с половиной раза. GNU Fortran добавил самую малость. Это единственный из наших тестов, где ifort показал преимущество перед gfortran, причём весьма заметное.
Давайте, может, попробуем 32-разрядные целые вместо байтов (с автопараллелизатором)?
integer, parameter :: matrix_kind = 4
$ ./life_seq_g
10 сек, 131818000 ячеек/с
$ ./life_seq
6 сек, 212080000 ячеек/с
Как видим, ничего хорошего нам это не дало.
1. Матричная программа
Некоторые люди думают, что, если заменить циклы неявными вычислениями с матрицами, то это невероятно оптимизирует код. Посмотрим, так ли это. Поменяем нашу любимую подпрограмму process_step:
! обработка шага операциями с матрицами
pure subroutine process_step (m1, m2)
integer (kind=matrix_kind), intent (in) :: m1 (0:,0:)
integer (kind=matrix_kind), intent (out) :: m2 (0:,0:)
integer :: rows, cols
integer s (0:size(m1,dim=1)-1, 0:size (m1,dim=2))
rows = size (m1, dim=1) - 2
cols = size (m1, dim=2) - 2
! вычислим матрицу s, которая повторяет по форме и размерам матрицу m1
! и содержит в каждом элементе количество живых соседей клетки
s = m1(0:rows-1,:) + m1(2:rows+1,:) + m1(0:rows-1,0:cols-1) + &
m1(2:rows+1,0:cols-1) + m1(:,0:cols-1) + m1(0:rows-1,2:cols+1) + &
m1(:,2:cols+1) + m1(2:rows+1,2:cols+1)
! завернём края ещё до вычислений
s (0,:) = s (rows, :)
s (rows+1, :) = s (1, :)
s (:, 0) = s (:, cols)
s (:, cols+1) = s (:, 1)
! и применим оператор матричной обработки where
where (s==3 .or. s==2 .and. m1 == 1)
m2 = 1
elsewhere
m2 = 0
end where
end subroutine process_step
Вернёмся к matrix_kind = 1
и проверим мощь матричных операторов (с автопараллелизатором):
$ ./life_mat_g
12 сек, 115730000 ячеек/с
7 сек, 184630000 ячеек/с
$ ./life_mat
Как видим, результат чуть-чуть хуже чисто последовательного алгоритма. Причём если выключить автопараллелизатор, то Intel Fortran почему-то сильно расстраивается:
$ ./life_mat
25 сек, 55580000 ячеек/с
При этом надо ещё отметить, что Intel Fortran по умолчанию размещает очень мало памяти для стека, и увеличение размеров игрового поля (а вместе с ним и размещаемой на стеке переменной s в матричном варианте) приводит к выпадению программы в кору. GNU Fortran свободно работает при настройках по умолчанию с огромным размером поля.
С другой стороны, складывается впечатление, что здесь можно серьёзно соптимизировать матричный алгоритм, чтобы не перебирать одни и те же элементы массива трижды при движении по матрице. Возможно, кто-то из читателей предложит своё решение.
2. SMP параллелизм через OpenMP
Обе предыдущие программы были чисто последовательными, хотя компиляторы немножко векторизовали операции. Это неинтересно. Давайте извлечём пользу из наличия нескольких ядер в процессоре, причём сделаем это самым простым и грубым способом – через OpenMP:
! обратите внимание, что подпрограмма, управляющая внутри себя
! параллелизмом с помощью директив omp, не может быть объявлена чистой,
! так как это очевидный побочный эффект. декларация pure привела бы
! к ошибке компиляции
impure subroutine process_step (m1, m2)
integer (kind=matrix_kind), intent (in) :: m1 (0:,0:)
integer (kind=matrix_kind), intent (out) :: m2 (0:,0:)
integer :: rows, cols
integer :: i, j, s
rows = size (m1, dim=1) - 2
cols = size (m1, dim=2) - 2
! внешний цикл исполняется параллельно на ядрах SMP.
! переменные i и s свои в каждой параллельной ветке кода
!$omp parallel do private (i, s)
do j = 1, cols
do i = 1, rows
s = m1 (i-1, j) + m1 (i+1, j) + m1 (i-1, j-1) + m1 (i+1, j-1) + &
m1 (i, j-1) + m1 (i-1, j+1) + m1 (i, j+1) + m1 (i+1, j+1)
select case (s)
case (3)
m2 (i, j) = 1
case (2)
m2 (i, j) = m1 (i, j)
case default
m2 (i, j) = 0
end select
end do
end do
!$end parallel do
m2 (0,:) = m2 (rows, :)
m2 (rows+1, :) = m2 (1, :)
m2 (:, 0) = m2 (:, cols)
m2 (:, cols+1) = m2 (:, 1)
end subroutine process_step
Не забудем подключить OpenMP при компиляции:
$ gfortran life_omp.f90 -o life_omp_g -O3 -ftree-vectorize -fopt-info-vec -flto -fopenmp
$ ifort life_omp.f90 -o life_omp -Ofast -qopenmp
И запустим:
$ ./life_omp_g
3 сек, 377022000 ячеек/с
$ ./life_omp
3 сек, 356690000 ячеек/с
Теперь наш цикл выполняется одновременно на 4 ядрах процессора, за счёт чего выполнение ускорилось в 3 с лишним раза. По-прежнему, однако, GNU Fortran чуть впереди Intel Fortran'а.
3. SMP параллелизм через DO CONCURRENT
Попробуем переписать нашу программу стандартными средствами параллельного SMP программирования языка Фортран, без использования внешнего API OpenMP:
! подпрограмма снова может быть чистой, так как она не управляет нитками
pure subroutine process_step (m1, m2)
integer (kind=matrix_kind), intent (in) :: m1 (0:,0:)
integer (kind=matrix_kind), intent (out) :: m2 (0:,0:)
integer :: rows, cols
integer :: i, j, s
rows = size (m1, dim=1) - 2
cols = size (m1, dim=2) - 2
! так выглядит параллельный цикл в стандарте Фортрана
! как и в OpenMP,здесь распараллелен только внешний цикл
do concurrent (j = 1:cols) local (i, s)
do i = 1, rows
s = m1 (i-1, j) + m1 (i+1, j) + m1 (i-1, j-1) + m1 (i+1, j-1) + &
m1 (i, j-1) + m1 (i-1, j+1) + m1 (i, j+1)+ m1 (i+1, j+1)
select case (s)
case (3)
m2 (i, j) = 1
case (2)
m2 (i, j) = m1 (i, j)
case default
m2 (i, j) = 0
end select
end do
end do
m2 (0,:) = m2 (rows, :)
m2 (rows+1, :) = m2 (1, :)
m2 (:, 0) = m2 (:, cols)
m2 (:, cols+1) = m2 (:, 1)
end subroutine process_step
Здесь нас ждёт некоторое разочарование, потому что конструкция DO CONCURRENT
в GNU Fortran реализована мало и плохо. Предложение LOCAL
не может быть оттранслировано этим компилятором. И даже если бы мы как-то вывернулись из этого положения, то GNU Fortran всё равно преобразует DO CONCURRENT
в обычный последовательный цикл DO
(в интернете встречаются утверждения, что иногда GNU Fortran способен распараллелить DO CONCURRENT
, но автору не удалось достичь такого эффекта).
Поэтому трансляцию этого примера мы можем выполнить только в Intel Fortran (обратите внимание, что компилятору всё равно нужна многонитевая библиотека OpenMP для параллелизации, без неё цикл будет откомпилирован в последовательный код):
$ ifort life_con2.f90 -o life_con -Ofast -qopenmp
Запустим:
$ ./life_con
3 сек, 355890000 ячеек/с
Этот результат лучше всего, что мы видели в Intel Fortran, хотя немного не дотягивает до результата GNU Fortran с OpenMP.
4. Больше SMP параллелизма
Синтаксис оператора DO CONCURRENT
как бы намекает, что мы можем объединить внутренний и внешний циклы в один параллельный цикл по двум параметрам. Посмотрим, что это даст:
! подпрограмма снова может быть чистой, так как она не управляет нитками
! объединяем циклы do в общий do concurrent
pure subroutine process_step (m1, m2)
integer (kind=matrix_kind), intent (in) :: m1 (0:,0:)
integer (kind=matrix_kind), intent (out) :: m2 (0:,0:)
integer :: rows, cols
integer :: i, j, s
rows = size (m1, dim=1) - 2
cols = size (m1, dim=2) - 2
! так выглядит параллельный цикл в стандарте Фортрана
! здесь распараллелен как внешний, так и внутренний цикл
! в единую параллельную конструкцию, параметризованную по j и i
do concurrent (j = 1:cols, i = 1:rows) local (s)
s = m1 (i-1, j) + m1 (i+1, j) + m1 (i-1, j-1) + m1 (i+1, j-1) + &
m1 (i, j-1) + m1 (i-1, j+1) + m1 (i, j+1) + m1 (i+1, j+1)
select case (s)
case (3)
m2 (i, j) = 1
case (2)
m2 (i, j) = m1 (i, j)
case default
m2 (i, j) = 0
end select
end do
m2 (0,:) = m2 (rows, :)
m2 (rows+1, :) = m2 (1, :)
m2 (:, 0) = m2 (:, cols)
m2 (:, cols+1) = m2 (:, 1)
end subroutine process_step
Что же это нам даёт?
$ ./life_con2
4 сек, 308920000 ячеек/с
Компилятор увлёкся обилием возможностей и ухудшил результат. Так что параллелить всё же надо с умом.
Вывод
Мы рассмотрели компиляцию простейшей программы на современном Фортране с использованием средств векторизации и симметричных параллельных вычислений. В результате тестов Intel Fortran показал преимущество в поддержке возможностей языка и в автопараллелизации последовательного кода, а GNU Fortran – в скорости работы кода с ручным управлением параллелизацией. При этом, однако, не надо забывать, что Intel Fortran поддерживает мощные методы совместной оптимизации раздельно расположенных в исходных файлах единиц компиляции, поэтому для большой программы сравнительный результат мог бы быть другим.
Получается, что компилятор Intel Classic Fortran (ifort) более эффективен тогда, когда нам нужно оттранслировать на SMP много унаследованного последовательного кода, автоматически его распараллелив. GNU Fortran же позволяет генерировать более эффективный код в абсолютном зачёте, но требует для этого некоторой ручной работы по явному указанию параллелизации.
В следующей статье мы рассматриваем средства поддержки массивно-параллельных архитектур, имеющиеся в современном Фортране, и ещё ускоряем нашу программу.