do concurrent, OpenMP и MPI

У Fortran есть три уровня параллелизма: do concurrent для компилятора, OpenMP для многоядерного узла с общей памятью и MPI для кластера с распределённой памятью.

OpenMP — стандарт параллелизма с общей памятью на основе директив-комментариев, распараллеливающий код по потокам внутри одного узла. MPI — библиотека передачи сообщений для параллелизма с распределённой памятью между узлами кластера.

Три модели для трёх масштабов

Параллелизм в Fortran выбирают под масштаб задачи. Для одного цикла, итерации которого независимы, достаточно языковой конструкции do concurrent — компилятор сам решит, как его исполнить. Для загрузки всех ядер одного компьютера (общая память) применяют OpenMP — директивы, превращающие циклы в многопоточные. Для расчёта на кластере из многих машин (каждая со своей памятью) нужен MPI — явная передача сообщений между процессами. Эти подходы не конкурируют, а дополняют друг друга: крупные коды часто используют MPI между узлами и OpenMP внутри узла (гибридная модель). Понимание, какой инструмент для какого масштаба, — ключ к эффективному параллельному коду.

do concurrent: декларация независимости

Мы встречали do concurrent в контексте векторизации. Это стандартная конструкция Fortran (не директива и не библиотека), которой программист сообщает: итерации независимы, выполняйте их в любом порядке. Компилятор волен векторизовать цикл, раскинуть по потокам или (с поддержкой) выгрузить на GPU. Привлекательность do concurrent — переносимость: это часть языка, она не требует внешних библиотек и работает везде, где есть компилятор Fortran.

real :: a(n), b(n), c(n)
integer :: i

do concurrent (i = 1:n)
  c(i) = a(i) + b(i) * 2.0
end do

! с условием-маской: только нужные итерации
do concurrent (i = 1:n, a(i) > 0.0)
  c(i) = sqrt(a(i))
end do

Ограничение do concurrent в том, что степень его параллелизма отдана на откуп компилятору: вы не управляете числом потоков, распределением итераций, синхронизацией. Для простого data-parallel кода этого хватает, но для тонкого контроля нужны OpenMP или MPI. Тем не менее современный Fortran продвигает do concurrent как предпочтительный первый шаг к параллелизму — чистый, переносимый, без зависимостей.

OpenMP: многопоточность директивами

OpenMP добавляет параллелизм специальными комментариями-директивами вида !$omp. Гениальность подхода: для обычного компилятора это комментарии (программа компилируется последовательно), а при включённой поддержке OpenMP они становятся командами распараллеливания. Базовая директива — параллельный цикл !$omp parallel do: его итерации раздаются потокам, работающим над общей памятью.

program openmp_demo
  implicit none
  integer, parameter :: n = 1000000
  real :: a(n), b(n), c(n)
  integer :: i
  a = 1.0; b = 2.0

  !$omp parallel do
  do i = 1, n
    c(i) = a(i) * b(i) + sqrt(real(i))
  end do
  !$omp end parallel do

  print *, "c(1)=", c(1), " c(n)=", c(n)
end program openmp_demo

Директива !$omp parallel do перед циклом распределяет его итерации между потоками (число потоков задаётся переменной окружения OMP_NUM_THREADS или вызовом). Поскольку память общая, все потоки видят массивы a, b, c напрямую — не нужно ничего пересылать. Это и сила, и опасность OpenMP: общая память удобна, но если потоки одновременно пишут в одну ячейку, возникает гонка. Для управления доступом OpenMP даёт оговорки (clauses): private(переменная) делает переменную приватной каждому потоку, reduction(+:s) корректно суммирует частичные результаты потоков в общую переменную.

real :: s
s = 0.0
!$omp parallel do reduction(+:s)
do i = 1, n
  s = s + a(i) * b(i)      ! каждый поток копит свою сумму, потом всё складывается
end do
!$omp end parallel do

Без reduction одновременная запись в s из многих потоков испортила бы результат. OpenMP компилируют флагом (-fopenmp у gfortran); без него директивы игнорируются, и код остаётся корректным последовательным — это удобно для отладки.

MPI: передача сообщений между узлами

Когда задача не помещается в один компьютер, используют кластер — множество машин, соединённых сетью, каждая со своей памятью. Здесь общей памяти нет, и данные передаются явными сообщениями через библиотеку MPI. Программа запускается как набор процессов (часто по одному на ядро), каждый со своим рангом (номером с 0). Процессы обмениваются данными вызовами MPI_Send/MPI_Recv и коллективами вроде MPI_Reduce.

program mpi_demo
  use mpi_f08          ! современный интерфейс MPI для Fortran
  implicit none
  integer :: rank, nprocs
  integer :: local, total

  call MPI_Init()
  call MPI_Comm_rank(MPI_COMM_WORLD, rank)
  call MPI_Comm_size(MPI_COMM_WORLD, nprocs)

  local = (rank + 1) ** 2                 ! вклад этого процесса
  call MPI_Reduce(local, total, 1, MPI_INTEGER, MPI_SUM, 0, MPI_COMM_WORLD)

  if (rank == 0) print *, "Сумма по", nprocs, "процессам =", total
  call MPI_Finalize()
end program mpi_demo

Здесь MPI_Reduce собирает значения local со всех процессов, суммирует и кладёт результат в total на процессе ранга 0. Модуль mpi_f08 — современный объектный интерфейс MPI для Fortran (с проверкой типов), предпочтительный перед старым include 'mpif.h'. MPI требует запуска через mpirun -np N и подходит для масштабирования на тысячи узлов — на нём работают крупнейшие научные расчёты мира.

МодельПамятьМасштабМеханизм
do concurrentобщаяодин узел, на усмотрение компиляторачасть языка
OpenMPобщаяодин узел, много ядердирективы !$omp
MPIраспределённаякластер, много узловбиблиотека сообщений
Coarraysраспределённая (PGAS)узел или кластерчасть языка

Как работает под капотом

OpenMP реализует параллелизм через пул потоков ОС, разделяющих адресное пространство процесса. Встретив !$omp parallel do, рантайм OpenMP распределяет диапазон итераций между потоками (статически или динамически) и запускает их; в конце региона — неявный барьер. Поскольку потоки делят память, обмен данными бесплатен, но синхронизация (защита от гонок) — забота программиста через clauses. MPI устроен иначе: процессы не делят память, и каждое сообщение — это реальная передача байтов через сеть или разделяемую память узла. MPI_Send копирует данные в буфер и отправляет; MPI_Recv принимает; коллективы вроде MPI_Reduce реализованы оптимальными схемами (древовидные редукции за log(N) шагов). Отсюда фундаментальный компромисс: OpenMP проще (общая память, нет явных пересылок), но ограничен одним узлом; MPI сложнее (всё передаётся явно), зато масштабируется неограниченно. Гибридные коды берут лучшее: MPI делит работу между узлами, OpenMP насыщает ядра внутри узла, минимизируя дорогие сетевые обмены.

Гибридный параллелизм и иерархия современного железа

Современный суперкомпьютер устроен иерархически, и эффективный параллельный код отражает эту иерархию. На верхнем уровне — множество узлов, соединённых сетью; у каждого узла своя память, и связь между узлами идёт только через сеть. Внутри узла — несколько процессоров и много ядер, разделяющих общую память узла. А каждое ядро ещё и способно к векторным операциям (SIMD). Три уровня параллелизма — между узлами, между ядрами, внутри ядра — и под каждый есть свой инструмент: MPI между узлами, OpenMP между ядрами, векторизация внутри ядра. Код, использующий все три согласованно, называется гибридным и выжимает из железа максимум.

! Эскиз гибридной структуры: MPI делит работу между узлами,
! OpenMP — между ядрами узла, do concurrent/массивы — векторизация.
call MPI_Comm_rank(MPI_COMM_WORLD, rank)   ! какой узел/процесс
! ... каждый процесс владеет своей подобластью ...

!$omp parallel do                          ! раздать ядрам узла
do j = 1, local_ny
  do i = 1, local_nx                       ! внутренний цикл векторизуется
    u_new(i,j) = 0.25 * (u(i-1,j) + u(i+1,j) + u(i,j-1) + u(i,j+1))
  end do
end do
!$omp end parallel do

call exchange_halos_mpi()                  ! обмен границами между процессами

Зачем такая сложность, почему не запустить просто по одному MPI-процессу на каждое ядро? Потому что гибридный подход экономит самое дорогое — межузловую коммуникацию и память. При чистом MPI каждый процесс держит свои гало-ячейки и шлёт сообщения, и число сообщений растёт с числом процессов. Гибрид же запускает один MPI-процесс на узел (а не на ядро), внутри распараллеливаясь OpenMP по общей памяти без всяких сообщений; межузловых обменов становится во много раз меньше, и они крупнее (эффективнее). На машинах с тысячами узлов и десятками ядер на узел эта экономия решающая. Понимание иерархии железа и сопоставления ей уровней параллелизма — ключевая компетенция в высокопроизводительных вычислениях, и Fortran даёт все нужные инструменты: MPI и coarrays для распределённого уровня, OpenMP и do concurrent для разделяемого, массивный стиль и векторизацию — для уровня ядра.

Закон Амдала и пределы масштабирования

Параллелизм даёт ускорение, но не безграничное, и важно понимать его фундаментальный предел — закон Амдала. Он гласит: если доля p программы распараллеливается, а доля (1-p) остаётся последовательной, то максимальное ускорение на бесконечном числе процессоров равно 1/(1-p). Смысл отрезвляющий: даже крошечная последовательная часть ставит жёсткий потолок. Если 95% кода параллельны, а 5% — нет, предел ускорения всего лишь двадцатикратный, сколько бы тысяч ядер вы ни добавили. Последовательные участки — чтение входных данных, запись результатов, неустранимые зависимости в алгоритме — становятся бутылочным горлышком.

Параллельная доля pПредел ускорения 1/(1-p)
50%2x
90%10x
95%20x
99%100x
99.9%1000x

Практические следствия закона Амдала определяют стратегию распараллеливания. Во-первых, максимизируйте параллельную долю: чтобы использовать тысячи ядер, нужно довести p до 99,9% и выше, то есть распараллелить почти всё, включая ввод-вывод (параллельные файловые системы, MPI-IO) и инициализацию. Во-вторых, есть пределы сильного масштабирования: для фиксированной задачи добавление процессоров рано или поздно перестаёт помогать — накладные расходы на коммуникацию и последовательные части берут своё. Поэтому в HPC чаще говорят о слабом масштабировании: увеличивают и число процессоров, и размер задачи вместе, так что работы на процессор остаётся столько же, — и тогда можно эффективно задействовать огромные машины. В-третьих, коммуникация — это тоже накладные расходы: обмены между процессами не делают полезной работы, и их минимизация (укрупнение, перекрытие с вычислениями) критична для масштабируемости. Эти законы и принципы — теоретический фундамент, на котором стоит вся практика параллельных вычислений. Знание их уберегает от наивных ожиданий («добавлю ядер — и станет во столько же раз быстрее») и направляет усилия туда, где они дают результат: на увеличение параллельной доли и сокращение коммуникации. Fortran с его богатым арсеналом параллельных средств — мощный инструмент в руках того, кто понимает эти пределы и проектирует код с их учётом.

Частые ошибки

  • Гонка данных в OpenMP. Одновременная запись потоков в общую переменную без reduction/private портит результат. Аккуратно классифицируйте переменные цикла.
  • Забыть флаг -fopenmp. Без него директивы !$omp — просто комментарии, и код выполняется последовательно (что, впрочем, безопасно).
  • Использовать OpenMP для кластера. OpenMP не выходит за пределы одного узла (общая память). Для многих машин нужен MPI или coarrays.
  • Путать ранги MPI (с 0) и образы coarray (с 1). MPI_Comm_rank даёт 0..N-1, а this_image() — 1..N.
  • Использовать устаревший mpif.h вместо mpi_f08. Современный модуль mpi_f08 даёт проверку типов и чище; предпочитайте его в новом коде.

Итоги

  • do concurrent — переносимый языковой способ объявить независимость итераций; параллелизм на усмотрение компилятора.
  • OpenMP распараллеливает по потокам с общей памятью директивами !$omp; масштаб — один многоядерный узел.
  • В OpenMP избегайте гонок через private/reduction; без -fopenmp код остаётся последовательным.
  • MPI — передача сообщений для распределённой памяти кластера; масштабируется на тысячи узлов; используйте mpi_f08.
  • Крупные коды гибридны: MPI между узлами + OpenMP внутри узла; ранги MPI с 0, образы coarray с 1.
Проверьте себя
1. Чем принципиально различаются модели OpenMP и MPI?
AOpenMP работает с общей памятью на одном узле, MPI передаёт сообщения между процессами с распределённой памятью
BOpenMP для GPU, а MPI для CPU
CЭто два названия одной технологии
DMPI работает только с одним ядром
2. Зачем в OpenMP-цикле, суммирующем значения в переменную s, нужна оговорка reduction(+:s)?
AЧтобы ускорить сложение
BЧтобы каждый поток накапливал свою частичную сумму, а затем они корректно сложились, без гонки данных
CЧтобы сделать s приватной навсегда
DЭто необязательно, s и так суммируется правильно