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