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.