В предыдущей публикации Фортран: пишем параллельные программы для суперкомпьютера мы рассмотрели общий подход к программированию в массивно-паралллельной архитектуре (MPP) с использованием языка Фортран-2018 и дали пример запуска массивно-параллельной программы на одной машине с многоядерным процессором. В настоящей статье мы рассмотрим запуск массивно-параллельных программ на кластере высокой производительности (HPC) или кластере высокой готовности (HA). Код в данной статье пишется на языке Фортран-2018 с использованием комассивов (coarrays) и преобразуется компилятором Фортрана в вызовы фреймворка MPI. С тем же успехом можно писать код на C/C++ с использованием непосредственно вызовов MPI, только работы руками в таком случае будет гораздо больше, так как уровень абстракций параллельного программирования возрастает по пути ассемблер – С/С++ – Фортран.
Кластер высокой производительности и кластер высокой готовности с архитектурной точки зрения обычно отличаются между собой. В кластере высокой производительности, как правило, выделяется один или несколько управляющих узлов, содержащих операционную систему и её окружение в полной комплектации, и большое количество вычислительных узлов, в которых операционная система сконфигурирована по минимальному варианту. В кластере высокой готовности обычно все узлы сконфигурированы идентично, чтобы иметь возможность подменять друг друга. Для наших целей, однако, это различие не очень существенно.
Узлы кластера для запуска массивно-параллельной программы должны быть соединены между собой сетью с минимальной латентностью (это наиболее важно) и максимальной пропускной способностью, а также обеспечивать запускаемой программе одинаковое окружение. Говоря об одинаковом окружении, мы имеем в виду, прежде всего, возможность запуска одного и того же исполняемого модуля одной и той же командой вызова. Это может достигаться либо размещением исполняемого модуля на разделяемом диске кластера, либо записью идентичных исполняемых модулей по одинаковым путям на локальные диски каждого узла.
Массивно-параллельные программы для расчётов обычно пишутся таким образом, что вводом-выводом занимается только один узел. Но если ваша программа включает ввод-вывод на нескольких узлах, то разделяемый диск становится практически обязательным. Он может быть организован на базе системы хранения данных с параллельными подключениями к узлам либо в виде сетевого ресурса.
Так как у автора есть под рукой тестовый кластер высокой готовности на базе SLES 15SP5 HA, то иллюстрировать изложение будем на нём. Операционная система SLES 15SP5 вместе с дополнительными компонентами, в том числе и HA, без приобретения поддержки в настоящий момент доступна для бесплатного скачивания в виде оффлайнового дистрибутива (бесплатная лицензия на HA юридически ограничена 4 процессорами).
Наш тестовый кластер организован на базе четырёх серверов IBM BladeServer HS23 с одним 4-ядерным процессором Intel Xeon E5-1600/E5-2600 v2 на каждом сервере. Серверы объединены общим коммутатором Ethernet и имеют параллельное подключение SAS к системе хранения даных с общим дисковым разделом, на котором средствами кластера HA организована файловая система OCFS2.
Использование комассивов поверх MPI поддерживается компилятором Intel Fortran из коробки, а компилятором gfortran – с помощью сторонних скриптов и библиотек компиляции, например, OpenCoarrays. Поскольку сборка OpenCoarrays под SLES – само по себе довольно запутанное дело, мы в нашем примере будем использовать последнюю на данный момент (2023 год) версию Intel Fortran.
Intel Fortran под Linux представлен двумя компиляторами – старым ifort и новым ifx, которые устанавливаются в одном свободно распространяемом пакете. Для наших целей подойдут оба, но ifort пока обеспечивает кодогенерацию получше, и, если нет цели выгружать код на GPU Intel (а у нас такой цели нет), то лучше использовать его.
Реализация фреймворка MPI в Linux обеспечивается одной из двух альтенативных библиотек – openmpi и mpich. Обе могут быть установлены из дистрибутива SLES (и переключаться и активироваться с помощью скрипта mpi-selector-menu), но в составе пакета Intel Fortran устанавливается собственая версия Intel mpich, которой мы и будем пользоваться.
Приступим к настройке нашей параллельной среды.
По умолчанию выполнение распределённых приложений обеспечивается путём доступа к удалённым узлам при помощи ssh, мы это умолчание менять не будем. Поэтому для начала нам понадобится настроить беспарольный доступ ssh на все узлы кластера (включая и свой собственный) для того пользователя, от имени которого мы будем выполнять программы. Не пытайтесь делать это от рута.
Однообразные команды копирования
node1: ssh‑copy‑id node1
node1: ssh‑copy‑id node2
node1: ssh‑copy‑id node3
node1: ssh‑copy‑id node4
node2: ssh‑copy‑id node1
node2: ssh‑copy‑id node2
node2: ssh‑copy‑id node3
node2: ssh‑copy‑id node4
node3: ssh‑copy‑id node1
node3: ssh‑copy‑id node2
node3: ssh‑copy‑id node3
node3: ssh‑copy‑id node4
node4: ssh‑copy‑id node1
node4: ssh‑copy‑id node2
node4: ssh‑copy‑id node3
node4: ssh‑copy‑id node4
Если у вас используется одинаковый rsa key на всех хостах (что будет логично), то само по себе копирование публичного ключа не надо выполнять столько раз, но коннектиться по всем возможным маршрутам всё равно нужно, чтобы сформировать known_hosts. Поэтому пройтись по этому списку всё равно потребуется.
Дальше устанавливаем на каждом разделе (или на общем диске) Intel Fortran Compiler:
node1: ./l_fortran_compiler_p_..._offline.sh
и т.д. В результате установки создаются специальные скрипты для формирования программного окружения Intel OneAPI, из которых наиболее верхний уровень занимает intel/oneapi/setvars.sh. Если вызвать этот скрипт, то он автоматически проверяет все зависимости и устанавливает необходимую среду.
Однако, крайне плохим решением будет вписать скрипт setvars.sh в профиль пользователя. Это связано с тем, что проверки из него выполняются долго, и мы фактически будем на ровном месте получать задержку на несколько секунд при каждом логине, а значит – при каждом запуске нашей распределённой программы.
Вместо этого впишем в наш ~/.profile на всех узлах два простых скрипта более низкого уровня:
source intel/oneapi/compiler/latest/env/vars.sh
source intel/oneapi/mpi/latest/env/vars.sh
Дальше нам надо сделать очень важную вещь, которая вскользь однократно описана на странице 2505 руководства по компилятору Intel и гуглится на данный момент на 6 страницах в интернете, одна из которых на японском языке. А между тем, без этой фишки ничего нормально работать не будет. Добавим в файл /etc/environment на всех узлах строку:
FOR_ICAF_STATUS=launched
Относительно значения переменной разные документы рекомендуют разное, и, видимо, важен только сам факт присвоения значения.
Если переменная FOR_ICAF_STATUS не будет задана при выполнении программы, то mpich не сможет правильно вести нумерацию экземпляров программы, на узлах будут появляться лишние экземпляры, а номера экземпляров (ранги) будут дублироваться, что совсем уж убийственно для логики работы программы.
Перелогинимся на узлы, чтобы подхватить наши новые переменные окружения.
Отлично, теперь всё готово для компиляции и выполнения. Для проверки используем нашу программу из предыдущей статьи, чуточку модифицированную для измерения более высокой производительности:
life_mpp.f90
program life_mpp
implicit none
integer, parameter :: matrix_kind = 4
integer, parameter :: generations = 2
integer, parameter :: rows = 6000, cols = 6000
integer, parameter :: steps = 1000
integer (kind=matrix_kind) :: field (0:rows+1, 0:cols+1, generations) [*]
integer :: thisstep = 1, nextstep =2
integer :: i
integer (kind=8) :: clock_cnt1, clock_cnt2, clock_rate
integer, allocatable :: cols_lo (:), cols_hi (:) ! диапазоны столбцов для узлов
integer :: me ! номер текущего узла, чтобы обращаться покороче
me = this_image()
print *, "it's me:", me
! заполним таблицы верхних и нижних границ полос для узлов
allocate (cols_lo (num_images()), cols_hi (0:num_images()))
cols_hi (0) = 0
do i = 1, num_images()
cols_lo (i) = cols_hi (i-1) + 1
cols_hi (i) = cols * i / num_images()
end do
! проинициализируем матрицу (инициализация тоже изменилась)
call init_matrix (field (:, :, thisstep))
sync all
! первый узел займётся хронометрированием
if (me == 1) call system_clock (count=clock_cnt1)
! цикл выглядит как и раньше, но после каждого шага синхронизируемся,
! чтобы не возникло разнобоя с сечениями источника и приёмника
do i = 1, steps
call process_step (field (:, :, thisstep), field (:, :, nextstep))
thisstep = nextstep
nextstep = 3 - thisstep
sync all
end do
! первый узел заканчивает хронометрирование, печатает результат,
! собирает матрицу и пишет в файл
if (me == 1) then
call system_clock (count=clock_cnt2, count_rate=clock_rate)
print *, (clock_cnt2-clock_cnt1)/clock_rate, 'сек, ', &
int(rows,8)*cols*steps*clock_rate/(clock_cnt2-clock_cnt1), 'ячеек/с'
! мы хотим вывести матрицу в файл, а разные её столбцы хранятся
! на разных узлах. надо собрать всю матрицу на пишущем узле
do i = 2, num_images()
field (:, cols_lo(i):cols_hi(i), thisstep) = &
field (:, cols_lo(i):cols_hi(i), thisstep) [i]
end do
call output_matrix (field (:, :, thisstep))
end if
contains
impure subroutine init_matrix (m)
integer (kind=matrix_kind), intent (out) :: m (0:,0:)
integer j
! обнулим поле в своей полосе и гало
! не лезем далеко в чужие полосы, чтобы не распределять
! ненужную узлу память
do j = cols_lo(me)-1, cols_hi(me)+1
m (:, j) = 0
end do
! первый и последний узлы обнуляют кромки поля
if (me == 1) m (:, 0) = 0
if (me == num_images()) m (:, cols+1) = 0
! нарисуем "мигалку" на имеющих отношение к ней узлах
if (cols_lo(me) <= 51 .and. cols_hi(me) >= 49) m (50, 50) = 1
if (cols_lo(me) <= 52 .and. cols_hi(me) >= 50) m (50, 51) = 1
if (cols_lo(me) <= 53 .and. cols_hi(me) >= 51) m (50, 52) = 1
end subroutine init_matrix
! подпрограмма снова 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
! типичная техника программирования в coarrays - цикл нарублен лапшой по узлам
do j = cols_lo (me), cols_hi (me)
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
! обмениваемся гало с соседями снизу и сверху
if (me > 1) then
m2 (:, cols_hi (me-1)) [me-1] = m2 (:, cols_hi (me-1))
end if
if (me < num_images()) then
m2 (:, cols_lo (me+1)) [me+1] = m2 (:, cols_lo (me+1))
end if
! заворачивание краёв здесь будет посложнее, так как выполняется разными узлами
! в пределах своей компетенции
if (me == num_images()) m2 (:, 0) [1] = m2 (:, cols)
if (me == 1) m2 (:, cols+1) [num_images()] = m2 (:, 1)
m2 (0,cols_lo(me):cols_hi(me)) = m2 (rows, cols_lo(me):cols_hi(me))
m2 (rows+1, cols_lo(me):cols_hi(me)) = m2 (1, cols_lo(me):cols_hi(me))
end subroutine process_step
subroutine output_matrix (m)
integer (kind=matrix_kind), intent (in) :: m (0:,0:)
integer :: rows, cols
integer :: i, j, n
integer :: outfile
rows = size (m, dim=1) - 2
cols = size (m, dim=2) - 2
open (file = 'life.txt', newunit=outfile)
do i = 1, rows
write (outfile, '(*(A1))') (char (ichar (' ') + &
m(i, j)*(ichar ('*') - ichar (' '))), j=1, cols)
end do
close (outfile)
end subroutine output_matrix
end program life_mpp
Скомпилируем нашу программу, используя команду:
ifort life_mpp.f90 -o life_mpp -O3 -march=native -coarray=distributed
Здесь принципиален ключ -coarray=distributed, который создаёт исполняемый код для работы на нескольких распределённых узлах.
Теперь наш исполняемый модуль life_mpp необходимо расположить на разделяемом диске (или по одинаковому пути на локальных дисках узлов).
Для выполнения массивно-параллельных программ служит утилита mpiexec, которая в данном случае входит в комплект компилятора Intel.
Выполним программу, сначала ограничив одним ядром на локальном узле:
node4: mpiexec -n 1 ./life_mpp
it's me: 1
148 сек, 243134259 ячеек/с
Это даёт нам некую начальную базу для сравнения.
Теперь выполним на всех ядрах текущего узла (их 4):
node4: mpiexec ./life_mpp
it's me: 1
it's me: 2
it's me: 3
it's me: 4
38 сек, 928890419 ячеек/с
Мы получили ускорение в 3.89 раз, что близко к теоретически возможным 4.
Теперь попробуем выполнить программу также в 4 экземплярах, но по одному на каждом узле кластера:
node4: mpiexec -ppn 1 -host node1,node2,node3,node4 ./life_mpp
it's me: 4
it's me: 3
it's me: 2
it's me: 1
38 сек, 926037179 ячеек/с
Сетевое распределение экземпляров программы в данном случае оказалось почти настолько же эффективным, как и запуск на локальном узле.
Ну и выполним программу, используя все доступные ресурсы наших узлов кластера:
node4: mpiexec -host node1,node2,node3,node4 ./life_mpp
it's me: 13
it's me: 9
it's me: 1
it's me: 14
it's me: 10
it's me: 2
it's me: 5
it's me: 15
it's me: 11
it's me: 3
it's me: 6
it's me: 16
it's me: 12
it's me: 4
it's me: 7
it's me: 8
11 сек, 3184791417 ячеек/с
Коэффициент ускорения составил 13.45 от единичной программы и 3.45 от одного узла. Заметим, что 13.45 хоть и меньше теоретического максимума 16, но всё же больше 12, то есть оверхед в кластере меньше одного узла. По мере увеличения степени автономности узлов (то, есть, в нашем случае, увеличения размеров массива) эффективность распараллеливания будет расти, а по мере уменьшения - снижаться. Очень частые синхронизации через Ethernet способны полностью убить эффект от распараллеливания.
Список хостов для mpiexec, разумеется, можно поместить в конфигурационный файл, синтаксис можно посмотреть в описании mpiexec.
В принципе, описанный подход позволяет выполнять распределённые программы и просто на произвольном наборе узлов сети, не объединённых кластерным программным обеспечением. Однако, при использовании разнородных и по-разному нагруженных узлов может возникнуть значительный оверхед на синхронизацию процессов, выполняемых с различной скоростью.