Программирование с использованием MPI — начальный уровень
В настоящем документе содержася базовые сведения по использованию стандарта MPI в программах на языке C в объёме, достаточном для решения первой задачи Практикума на ЭВМ в шестом семестре. Для написания программ, предназначенных для использования в реальных программах рекомендуется изучить документацию и использовать развёрнутые учебные пособия — в особенности, с упором на асинхронную передачу сообщений (MPI_Isend, MPI_Irecv
) и передачу сложных типов данных.
С вопросами, исправлениями и дополнениями по документу обращайтесь по адресу maxim.krivchikov@gmail.com
Компиляция и запуск
Стандарт MPI предназначен для написания параллельных программ, выполняющихся в виде набора независимых процессов, которые могут обмениваться сообщениями. Существует несколько реализаций MPI, наиболее популярные — OpenMPI, MPICH, Intel MPI (последний работает, в частности, на ускорителях Xeon Phi).
Для работы с MPI во все файлы с исходным кодом, в которых используются функции или типы данных MPI, должен быть включён заголовочный файл mpi.h
:
c
#include <mpi.h>
Для компиляции используются обёртки над gcc
и g++
— mpicc
и mpicxx
соответственно. Вместо ld
можно также использовать mpicc
. Запуск осуществляется с использованием команды mpirun
:
bash
mpirun -np количество_процессов имя_программы аргументы_программы
например:
bash
mpirun -np 4 ./a.out --formula 2 -n 2000
Аргументы --formula 2
и -n 2000
передаются в вашу программу (./a.out
), аргумент -np 4
не передаётся.
Под «всеми процессами» далее подразумеваются процессы, запущенные с помощью выполнения одной команды mpirun
.
Команда mpirun
запускает заданное количество абсолютно идентичных процессов, выполняющих заданную программу. Эти процессы различаются только по порядковому номеру, который можно получить с помощью функции MPI_Comm_rank
. Каждый процесс начинает выполнение с функции main
. Таким образом, если запустить с использованием mpirun
на 6 процессов программу:
```c
#include
int main(int argc, char **argv) {
printf(“A”);
return 0;
}
```
, то на экран будут выведены 6 букв A
.
Общая информация по API
Все функции MPI имеют название с префиксом MPI
. Все функции MPI возвращают целое число типа int
— код результата выполнения функции. В случае успешного завершения возвращаемое значение равняется константе MPI_SUCCESS
. Полный список ошибок см. в стандарте.
В случае ошибки, аварийное завершение всех процессов выполняется с использованием функции MPI_Abort
:
int MPI_Abort(MPI_Comm comm, int errorcode);
где comm
— коммуникатор (см. далее), errorcode
— код, возвращаемый в командную оболочку (аналогично return
в main
). Функцию MPI_Abort
достаточно вызвать в одном (любом) из процессов. Стандартом гарантируется, что функция MPI_Abort
завершает как минимум все процессы, входящие в коммуникатор comm
, но на практике во всех существующих реализациях
Коммуникатор (элемент типа MPI_Comm
) — это обозначение какой-то выделенной группы процессов. Стандарт определяет коммуникатор MPI_COMM_WORLD
, который обозначает все запущенные процессы текущей программы. Использование других коммуникаторов в рамках практикума не предполагается, поэтому можно считать, что в качестве аргумента типа MPI_Comm
всегда подставляется MPI_COMM_WORLD
.
Инициализация и завершение
В начале программы, работающей с MPI, необходимо инициализировать библиотеку MPI с помощью функции MPI_Init
; перед завершением — вызвать функцию завершения MPI_Finalize
.
int MPI_Init(int *argc, char*** argv);
int MPI_Finalize();
Аргументы argc
и argv
функции MPI_Init
должны быть указателями на соответствующие аргументы функции main
, см. пример далее. Функция MPI_Finalize
не содержит аргументов.
Количество запущенных процессов не должно передаваться в программу в качестве отдельного аргумента командной строки, но определяется с использованием функций MPI_Comm_size
. Номер, который автоматически присваивается процессу, может быть получен с помощью функции MPI_Comm_rank
. Пример: при запуске p
процессов, в каждом из результат выполнения MPI_Comm_size
равен p
, а результат выполнения MPI_Comm_rank
— числам от 0
до p-1
. Нулевой процесс обычно для простоты считают «главным» и выполняют из него весь ввод-вывод (при разработке допускается использовать отладочный printf
-вывод во всех процессах).
int MPI_Comm_rank(MPI_Comm comm, int *rank);
int MPI_Comm_size(MPI_Comm comm, int *total_procs);
Далее под «номером процесса» будем понимать число, которое возвращает в переменной rank
вызов функции MPI_Comm_rank
.
Пример минимальной программы
Программа в каждом процессе получает и выводит общее количество и номер текущего процесса.
#include <mpi.h>
#include <stdio.h>
int main(int argc, char **argv) {
int rank, size;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
printf("Process #%d of %d\n", rank, size);
MPI_Finalize();
return 0;
}
По-хорошему, нужно ещё проверять значения, возвращаемые каждой функцией.
Типы данных
Для передачи данных между процессами, которые в общем случае могут выполняться на вычислительных устройствах различной архитектуры, библиотеке MPI необходима информация о типах данных, которые должны быть переданы. Язык C не поддерживает средства т.н. рефлексии или параметрического полиморфизма, которые могли бы упростить получение такой информации, поэтому при каждой пересылке тип должен быть указан явно в качестве одного из аргументов функции. Для передачи такой информации используется тип MPI_Datatype
. Функции, выполняющие приём или передачу данных, как правило, принимают аргументы: void *buf, int count, MPI_Datatype datatype
. Семантику этих аргументов можно неформально сформулировать следующим образом: «передаётся (принимается) область памяти, на начало которой указывает buf
и которая содержит массив из count
элементов типа datatype
».
Следует отметить, что в качестве аргумента типа void *
может быть использован любой указатель, при этом приведение типов будет неявным, т.е. писать явно (void*)myArray
не нужно.
В следующей таблице приведены определённые в заголовочном файле mpi.h
константы типа MPI_Datatype
, соответствующие используемым в рамках практикума примитивным типам языка C.
Тип C | Константа MPI_Datatype |
---|---|
char |
MPI_CHAR |
int |
MPI_INT |
unsigned int |
MPI_UNSIGNED |
double |
MPI_DOUBLE |
Для полного списка см., например, http://linux.die.net/man/3/mpi_double, или man mpi_double
.
Кроме того, MPI предоставляет возможность определения своих типов данных для передачи сложных структур. В рамках практикума это, скорее всего, не понадобится, но при необходимости можно начать смотреть, например, с презентации https://www.rc.colorado.edu/sites/default/files/Datatypes.pdf
Передача данных «точка-точка»
В рамках стандарта MPI определяется два вида передачи данных «точка-точка» — блокирующий и неблокирующий. Блокирующие операции не возвращают управление в программу до конца передачи, неблокирующие возвращают управление сразу после вызова. Поэтому в случае с блокирующими операциями буферы приёма/передачи могут быть повторно использованы, а в случае с неблокирующими — только после завершения передачи. Для выполнения практикума используются блокирующие операции.
int MPI_Send(const void *buf, int count, MPI_Datatype datatype,
int dest, int tag, MPI_Comm comm);
int MPI_Recv(void *buf, int count, MPI_Datatype datatype, int source, int tag,
MPI_Comm comm, MPI_Status *status);
int MPI_Sendrecv(const void *sendbuf, int sendcount, MPI_Datatype sendtype,
int dest, int sendtag,
void *recvbuf, int recvcount, MPI_Datatype recvtype,
int source, int recvtag,
MPI_Comm comm, MPI_Status *status);
int MPI_Sendrecv_replace(void *buf, int count, MPI_Datatype datatype,
int dest, int sendtag, int source, int recvtag,
MPI_Comm comm, MPI_Status *status);
Функция MPI_Send
выполняет передачу буфера buf
, содержащего массив элементов типа datatype
размером count
в процесс с номером dest
.
Функция MPI_Recv
выполняет приём сообщения, содержащего массив элементов типа datatype
размером count
и записывает результат в буфер buf
.
Функция MPI_Sendrecv
выполняет одновременно приём и передачу сообщения в рамках одного коммуникатора и содержит наборы аргументов, соответствующие объединению наборов MPI_Send
и MPI_Recv
.
Функция MPI_Sendrecv_replace
выполняет одновременный приём и передачу сообщений, содержащих одинаковое количество count
одного типа datatype
. Приём осуществляется в тот же буфер buf
, из которого выполнялась передача.
Все рассматриваемые функции позволяют отправлять сообщения с метками — целыми числами tag
. Например, функция MPI_Recv
примет только сообщения от процесса source
, для которых метка равняется tag
. Если метки не используются, обычно ставится или одно и то же число (0), или, в случае Recv
— константа MPI_ANY_TAG
.
Функции приёма позволяют получить дополнительную информацию с помощью аргумента status
. Обычно в функции, использующей MPI, определяется одна переменная типа MPI_Status
, указатель на которую передаётся далее во всех вызовах.
Пример программы, выполняющей передачу данных «точка-точка»
Сначала один из массивов пересылается от первого процесса второму, затем — в цикле “каждый следующему” и обратно “каждый предыдущему”.
#include <stdio.h>
#include <mpi.h>
#define PRINT_BUF print_buf(rank, size, buf1, buf2)
void print_buf(int rank, int size, double *buf1, double *buf2) {
printf("[%d/%d]\tbuf1\t%lf\t%lf\tbuf2\t%lf\t%lf\n", rank, size,
buf1[0], buf1[1], buf2[0], buf2[1]);
}
int main(int argc, char **argv) {
int rank, size, dest;
double *buf1, *buf2;
MPI_Status status;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
printf("Process #%d of %d\n", rank, size);
if (size < 2) {
printf("Too few processes\n");
MPI_Abort(MPI_Comm_world, 1);
}
buf1 = calloc(2, sizeof(double));
buf2 = calloc(2, sizeof(double));
buf1[0] = (double)rank;
buf1[1] = -2.;
buf2[0] = -1.;
buf2[0] = -3.;
PRINT_BUF;
if (rank == 0) {
MPI_Send(buf1, /* count */ 1, /* datatype */ MPI_DOUBLE,
/* dest - process N. 1 */ 1, /* tag */ 0, MPI_COMM_WORLD);
} else if (rank == 1) {
MPI_Recv(buf2, 1, MPI_DOUBLE, /* source - process N. 0 */ 0, MPI_ANY_TAG,
MPI_COMM_WORLD, &status);
}
if (rank < 2) {
PRINT_BUF;
}
// Send in cycle: 1 -> 2 -> 3 -> ... -> (p-1) -> 1
// (size + rank - 1) % size - to be non-negative
MPI_Sendrecv(buf1, 2, MPI_DOUBLE, (rank + 1) % size, 0,
buf2, 2, MPI_DOUBLE, (size + rank - 1)%size, 0,
MPI_COMM_WORLD, &status);
PRINT_BUF;
MPI_Sendrecv_replace(buf2, 2, MPI_DOUBLE,
(size + rank - 1)%size, 0,
(rank + 1) % size, 0,
MPI_COMM_WORLD, &status);
PRINT_BUF;
MPI_Finalize();
return 0;
}
.
Групповые операции
В рамках используемого стандарта MPI версии 2 определяются только блокирующие групповые операции. Стандарт MPI версии 3 вводит неблокирующие групповые операции, но, поскольку принят он был совсем недавно (в 2012 году), такие функции могут быть доступны не везде. Как и в случае с операциями «точка-точка», в рамках практикума рассматриваются только блокирующие операции.
Картинки-иллюстрации взяты с сайта http://mpitutorial.com/tutorials/mpi-scatter-gather-and-allgather/
int MPI_Bcast( void *buffer, int count, MPI_Datatype datatype, int root, MPI_Comm comm );
MPI_Bcast
— переслать данные из процесса root
всем процессам в коммуникаторе comm
. Функцию необходимо вызывать во всех процессах коммуникатора.
int MPI_Scatter(const void *sendbuf, int sendcount, MPI_Datatype sendtype,
void *recvbuf, int recvcount, MPI_Datatype recvtype, int root,
MPI_Comm comm);
MPI_Scatter
— разбить данные из sendbuf
на последовательные части размером send_count
и разослать соответствующие части про процессам. Аргументы send...
могут иметь любое значение при вызове в процессах-получателях (всех процессах, кроме root
). Буфер sendbuf
должен иметь размер sendcount * <MPI_Comm_size(comm)>
. На практике лучше передавать sendtype = recvtype
, sendcount = recvcount
.
int MPI_Gather(void *send_data, int send_count, MPI_Datatype send_datatype,
void *recv_data, int recv_count, MPI_Datatype recv_datatype,
int root, MPI_Comm communicator);
MPI_Gather
— обратная к MPI_Scatter
операция — собрать в процессе root
массив из частей, распределённых по процессам. Для процессов кроме root
аргументы recv_data
, recv_count
, recv_datatype
могут иметь любое значение. Как и в случае MPI_Scatter
, безопасными значениями аргументов в root
являются send_datatype = recv_datatype
и send_count = recv_count
.
int MPI_Allgather(const void *sendbuf, int sendcount, MPI_Datatype sendtype,
void *recvbuf, int recvcount, MPI_Datatype recvtype,
MPI_Comm comm);
Аналогично MPI_Gather
, но массив рассылается по всем процессам коммуникатора comm
. При этом, в отличие от MPI_Gather
, аргументы recv...
должны иметь корректные значения во всех процессах коммуникатора.
int MPI_Reduce(void* sendbuf, void* recvbuf, int count, MPI_Datatype datatype,
MPI_Op op, int root, MPI_Comm comm);
Выполняет операцию op
над данными из sendbuf
— операция применяется к элементам с одинаковым индексам в каждом из процессов. Массив результатов применения операции размера count
сохраняется в буфере recvbuf
в процессе root
. В других процессах допустимо любое значение recvbuf
.
Список основных операций:
Операция | Значение |
---|---|
MPI_MAX |
Максимум |
MPI_MIN |
Минимум |
MPI_SUM |
Сумма |
MPI_PROD |
Произведение |
MPI_LAND |
Логическое «и» |
Полный список предопределённых операций: https://www.open-mpi.org/doc/v1.8/man3/MPI_Reduce.3.php
MPI опускает определение пользовательских операций для Reduce, см. документацию по MPI_Op_create
и MPI_Op_free