Coarrays: параллелизм, встроенный в язык
Coarrays делают параллелизм частью самого языка: программа выполняется как набор образов, а данные между ними передаются обычным синтаксисом массивов с квадратными скобками.
Coarray — массив с дополнительной «коразмерностью», объявляемой в квадратных скобках; образ (image) — одна из параллельно выполняющихся копий программы, имеющих собственную память, но способных обращаться к данным друг друга.
Модель SPMD и встроенный параллелизм
До Fortran 2008 параллелизм был внешним: его добавляли библиотеками (MPI) или директивами (OpenMP). Fortran 2008 встроил параллелизм в сам язык через coarrays, и это уникальная черта Fortran среди мейнстримных языков. Модель называется SPMD (Single Program, Multiple Data): запускается несколько идентичных копий программы — образов — каждая со своей памятью, и они работают над общей задачей, обмениваясь данными. Число образов задаётся при запуске (как число процессов), а внутри программы образ узнаёт свой номер функцией this_image() и общее число — num_images(). Это та же идея, что в MPI, но выраженная не вызовами библиотеки, а естественным синтаксисом языка — что делает параллельный код заметно чище.
program hello_coarray
implicit none
integer :: me, total
me = this_image() ! номер этого образа (с 1)
total = num_images() ! всего образов
print *, "Образ", me, "из", total
end program hello_coarray
Запущенная на 4 образах, эта программа напечатает четыре строки — по одной от каждого образа, в произвольном порядке (образы независимы). Каждый образ — отдельный поток исполнения с собственной копией всех обычных переменных; me у них разный.
Объявление coarray и квадратные скобки
Обычная переменная приватна каждому образу. Чтобы данные стали доступны между образами, переменную объявляют как coarray — добавляя коразмерность в квадратных скобках [*]. Звёздочка означает «развернуть по всем доступным образам».
program coarray_demo
implicit none
integer :: x[*] ! скаляр-coarray: своя копия на каждом образе
real :: field(100)[*] ! массив-coarray
integer :: me
me = this_image()
x = me * 10 ! каждый образ пишет в СВОЮ копию x
sync all ! барьер: дождаться всех образов
if (me == 1) then
! образ 1 читает x с образа 2 — синтаксис [2]
print *, "x на образе 2 =", x[2]
print *, "x на образе 1 =", x[1]
end if
end program coarray_demo
Ключевая идея — в различии скобок. Круглые скобки field(i) индексируют элемент массива внутри образа, как обычно. Квадратные скобки x[k] выбирают образ номер k: x[2] — это переменная x, принадлежащая образу 2. Запись x = me*10 без квадратных скобок обращается к локальной копии текущего образа. А x[2] — это удалённый доступ: образ 1 читает память образа 2. Так coarrays выражают межпроцессное взаимодействие обычным синтаксисом: «переменная на таком-то образе». Это и есть PGAS-модель (Partitioned Global Address Space) — глобально адресуемая, но физически распределённая память.
Синхронизация: зачем нужен sync
Образы выполняются асинхронно и независимо, поэтому без явной синхронизации возникает гонка: образ 1 может прочитать x[2] прежде, чем образ 2 успел записать своё значение. Оператор sync all — это барьер: каждый образ, дойдя до него, ждёт, пока его достигнут все остальные. После sync all гарантировано, что все записи до барьера завершены и видны. В примере выше sync all между записью x = me*10 и чтением x[2] обязателен — иначе результат не определён. Существует и более тонкая синхронизация: sync images([2,3]) синхронизирует только с перечисленными образами, что эффективнее глобального барьера, когда взаимодействуют лишь некоторые образы.
| Конструкция | Назначение |
this_image() | номер текущего образа (с 1) |
num_images() | общее число образов |
x[k] | доступ к coarray x на образе k |
sync all | барьер для всех образов |
sync images([...]) | синхронизация с указанными образами |
co_sum, co_broadcast | коллективные операции (Fortran 2018) |
Коллективные операции
Fortran 2018 добавил коллективные подпрограммы, выполняющие типовые параллельные операции одной командой над всеми образами. co_sum(x) суммирует значения x со всех образов; co_max/co_min находят экстремум; co_broadcast рассылает значение с одного образа всем. Это аналоги коллективных операций MPI, но встроенные в язык.
program collective_demo
implicit none
integer :: me, partial, total
me = this_image()
partial = me * me ! у каждого образа свой вклад
total = partial
call co_sum(total) ! total := сумма partial по всем образам
if (me == 1) print *, "Сумма квадратов 1..N номеров образов =", total
end program collective_demo
На 4 образах co_sum даст 1+4+9+16 = 30, и результат окажется у всех образов (или у указанного). Коллективные операции и проще, и зачастую эффективнее ручной попарной пересылки данных, потому что библиотека реализует их оптимальными алгоритмами (например, древовидной редукцией).
Как работает под капотом
Coarrays реализуются поверх низкоуровневого транспортного слоя. На одной машине образы — это обычно процессы ОС (или потоки), и доступ x[k] к чужому образу превращается в копирование из памяти другого процесса. На кластере образы живут на разных узлах, и x[k] компилируется в сетевую передачу — компилятор/рантайм генерирует обмен поверх MPI или специализированного интерконнекта (GASNet, SHMEM). Ключевой момент: синтаксис доступа к удалённым данным одинаков (квадратные скобки), а механизм скрыт — будь то локальная память или сеть InfiniBand. Поэтому coarray-программа масштабируется от ноутбука до суперкомпьютера без изменения исходника, меняется лишь число образов при запуске. Барьер sync all под капотом — это согласование всех образов через транспорт: каждый сообщает «я здесь» и ждёт подтверждения от остальных, что и гарантирует завершённость и видимость предшествующих записей в распределённой памяти.
Распределение данных между образами
Типичный параллельный расчёт делит большую задачу на части между образами — это называется декомпозицией области. Каждый образ владеет своим куском данных (полосой сетки, блоком матрицы) и обменивается с соседями лишь приграничными значениями. Coarrays делают это естественным. Рассмотрим распределение одномерного поля длины n между образами: каждый берёт свою порцию, а на границах обменивается «гало»-ячейками с соседями.
program domain_decomp
implicit none
integer, parameter :: nlocal = 100
! локальная полоса + по одной гало-ячейке с каждого края:
real :: u(0:nlocal+1)[*]
integer :: me, np, left, right
me = this_image()
np = num_images()
left = me - 1 ! сосед слева
right = me + 1 ! сосед справа
! ... инициализация u(1:nlocal) ...
sync all
! обмен границами с соседями через coarray-доступ:
if (right <= np) u(nlocal+1) = u(1)[right] ! взять левый край соседа справа
if (left >= 1) u(0) = u(nlocal)[left]
sync all
! теперь гало-ячейки заполнены, можно считать шаг схемы
end program domain_decomp
Конструкция u(1)[right] читает первую «настоящую» ячейку соседнего образа справа и кладёт её в свою правую гало-ячейку. Это и есть суть параллельных расчётов на сетках: вычисление локально, но требует узкого обмена на границах подобластей. Coarrays выражают такой обмен прозрачно — без явных send/recv, обычным синтаксисом «элемент массива на таком-то образе». Барьеры sync all до и после обмена гарантируют, что данные согласованы: первый — что все образы закончили инициализацию, второй — что обмен завершён прежде, чем кто-то начнёт считать. Этот шаблон (вычисление → синхронизация → обмен границами → синхронизация → следующий шаг) — основа подавляющего большинства параллельных численных кодов.
Coarrays против MPI: язык против библиотеки
Coarrays и MPI решают одну задачу — параллелизм с распределённой памятью, — но философски различны, и понимание этого различия помогает выбирать инструмент. MPI — это библиотека: набор функций (MPI_Send, MPI_Recv, MPI_Reduce), которые вы вызываете явно. Coarrays — это часть языка: параллелизм выражен синтаксисом (квадратные скобки, sync), а компилятор и рантайм скрывают механику передачи. У каждого подхода свои достоинства.
| Аспект | Coarrays | MPI |
| Природа | часть языка | внешняя библиотека |
| Синтаксис обмена | x[k] — как массив | явные вызовы send/recv |
| Проверка типов | компилятором | ограниченная |
| Зрелость и охват | моложе, базовый набор | десятилетия, всё что нужно |
| Контроль над обменом | высокоуровневый | полный, низкоуровневый |
Преимущество coarrays — читаемость и интеграция с языком: параллельный код выглядит почти как последовательный, компилятор проверяет типы при удалённом доступе, нет громоздких вызовов с дескрипторами буферов. Это снижает порог входа и число ошибок. Преимущество MPI — зрелость и полнота: за десятилетия он оброс всеми мыслимыми операциями (асинхронные обмены, сложные коллективы, топологии процессов, односторонние коммуникации), отлично оптимизирован под все интерконнекты и поддерживается повсеместно. На практике крупные «боевые» коды чаще пока написаны на MPI — просто в силу истории и того, что coarrays моложе и их реализации в компиляторах созревали постепенно. Но coarrays набирают силу, особенно в новом коде и там, где ценят чистоту. Нередко их и сочетают: рантайм coarrays у многих компиляторов сам работает поверх MPI, так что это не столько конкуренты, сколько разные уровни абстракции над одним механизмом. Уникальность Fortran в том, что он — едва ли не единственный мейнстримный язык со встроенным в стандарт параллелизмом распределённой памяти, и это отражает его специализацию: язык создан для высокопроизводительных вычислений, поэтому параллелизм в нём — гражданин первого класса, а не пристройка.
PGAS-модель и её концептуальная элегантность
Coarrays реализуют модель, известную как PGAS — Partitioned Global Address Space, разделённое глобальное адресное пространство, — и стоит осмыслить, почему эта концепция так привлекательна. Идея PGAS занимает золотую середину между двумя крайностями параллельного программирования. На одном полюсе — модель полностью общей памяти (как потоки или OpenMP): любой процесс видит любые данные напрямую, программировать просто, но масштабируется лишь в пределах одного узла, потому что физически общую память на тысячи машин не сделать. На другом полюсе — модель полностью распределённой памяти с сообщениями (как чистый MPI): масштабируется неограниченно, но программист должен явно упаковывать и пересылать каждый байт между процессами, что многословно и чревато ошибками. PGAS объединяет достоинства обоих: память физически распределена (значит, масштабируется на кластер), но логически доступна глобально — к данным другого образа обращаются обычным синтаксисом, как к своим, лишь с указанием номера образа в скобках. Программист пишет x[k] и думает «переменная x на образе k», а не «упакуй буфер, вызови send, на той стороне вызови recv, распакуй». Удалённый доступ выглядит почти как локальный, но при этом ясно обозначен (квадратными скобками), так что видно, где происходит потенциально дорогая коммуникация. Эта концептуальная элегантность — главная причина, по которой coarrays включили прямо в стандарт языка: они дают модель распределённого параллелизма, которая и масштабируется, и остаётся читаемой. Fortran в этом отношении уникален среди мейнстримных языков — мало где параллелизм распределённой памяти встроен в синтаксис так органично. Конечно, удобство не отменяет физику: обращение x[k] к данным на другом узле всё равно идёт по сети и стоит на порядки дороже локального доступа, поэтому эффективный код минимизирует удалённые обращения и группирует их — но выражается эта оптимизация куда яснее, чем в россыпи MPI-вызовов. Понимание PGAS как «логически общей, физически распределённой» памяти — ключ к продуктивному использованию coarrays: вы пользуетесь простотой глобального адресного пространства, помня о реальной стоимости пересечения границ между образами.
Частые ошибки
- Читать чужой coarray без синхронизации. Без
syncмежду записью на одном образе и чтением на другом возникает гонка и неопределённый результат. Барьер обязателен. - Путать круглые и квадратные скобки.
x(i)— элемент массива внутри образа;x[k]— переменная на образеk. Это принципиально разные индексации. - Излишний
sync allв горячем цикле. Глобальный барьер дорог; где взаимодействуют лишь пары образов, используйтеsync images. - Считать, что обычные переменные разделяются. Только coarrays (с
[*]) доступны между образами; обычные переменные приватны каждому образу. - Забыть, что номера образов начинаются с 1.
this_image()возвращает значения от 1 доnum_images(), а не от 0 как ранги MPI.
Итоги
- Coarrays встраивают параллелизм в язык по модели SPMD: программа исполняется как набор образов со своей памятью.
- Коразмерность
[*]делает переменную доступной между образами;x[k]читает/пишет данные образаk. - Круглые скобки индексируют элементы массива, квадратные — выбирают образ; это разные вещи.
sync all— барьер, обязательный между записью на одном образе и чтением на другом;sync images— точечная синхронизация.- Коллективы (
co_sum,co_broadcast) выражают типовые параллельные операции; код масштабируется от ноутбука до кластера без изменений.