Программирование с использованием 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