65.9K
CodeProject 正在变化。 阅读更多。
Home

消息传递接口 (MPI) 入门

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2015年4月19日

CPOL

12分钟阅读

viewsIcon

19780

downloadIcon

292

本文为 C 语言程序员提供 MPI 开发概述。

对于某些应用,特别是涉及大规模数学计算的应用,单台计算机无法提供足够的性能。在这种情况下,您需要一个能够跨多系统运行应用程序的框架。您可以考虑基于云的解决方案,如亚马逊弹性计算云(EC2)或Google Compute Engine,但如果您想要一个免费的、可以自行安装和配置的解决方案,我推荐消息传递接口 (Message Passing Interface),简称 MPI。

MPI 是一个标准,它定义了用于在连接的计算机上运行应用程序的 C 例程和工具。该标准的第一版发布于 1994 年,如今,主要有两个实现:MPICHOpenMPI。MPICH 历史更悠久,使用更广泛,但我读到的一些说法声称 OpenMPI 速度更快。许多公司和大学发布了 MPICH 的衍生版本,包括以下几种:

  • MVAPICH - 根据 BSD 许可证发布的 MPICH 免费实现
  • Microsoft MPI - 为 Windows 操作系统设计的 MPICH 实现
  • Intel MPI - 针对 Intel 处理器、Intel 特有的 MPICH 实现 

本文不讨论 MPI 实现之间的差异。相反,我的目标是解释 MPICH 编程的基础知识,并展示如何编译和执行应用程序。特别是,本文将重点介绍数据传输的不同方法。但首先,我想用一个比喻来比较 MPI 应用程序和足球比赛。

1. 比喻:MPI 和足球

尽管完全缺乏协调性,但我踢了多年足球。在比赛前,教练会召集球队并讨论他的计划。大部分计划是不具体的:每个球员都必须守卫一部分场地并盯防对手的球员。有些指示是针对位置的:前锋应该射门,守门员应该阻止对方球员得分。

如果教练用伪代码写下他的计划,可能会是这样的:

if(position == COACH) {
  present_game_plan();
  keep_score();
}
else if(position == FORWARD) {
  take_shots_on_goal();
}
else if(position == GOALIE) {
  block_shots_on_goal();
}
else {
  play_section_of_field(position);
  cover_opposing_players();
}

全队都收到相同的比赛计划,但他们根据自己的位置扮演不同的角色。对于普通球员来说,他们所守卫的场地部分取决于他们的位置。

MPI 的目的是将一组联网的计算机转换成一个统一、功能性的整体,类似于足球队。但 MPI 使用一套不同的术语:

  • 这套指令被称为 *可执行程序* 而不是比赛计划。
  • 这组联网的计算机被称为 *通信器* 而不是球队。
  • 每台计算机被称为 *处理器* 而不是球员(或教练)。
  • 处理器执行的每个角色被称为 *进程* 而不是位置。
  • 每个进程都有一个 *秩* (rank) 而不是球衣号码。
  • 进程之间的通信被称为 *消息传递*。

这个比喻在高层面上是可以接受的,但它至少有四个缺陷:

  1. 在足球比赛中,每个球员只能有一个位置。在 MPI 应用程序中,一个处理器可以分配多个进程。
  2. 足球比赛不允许有子球队,但 MPI 应用程序可以在整个通信器内创建通信器。
  3. 在足球比赛中,教练从不参赛。在 MPI 应用程序中,每个进程都运行可执行程序。
  4. 足球球衣上的号码基本上是随机的。MPI 为进程分配秩时,从 0 开始,然后依次递增 1。

如果您对 MPI 的操作或术语仍不清楚,请不要担心。有很多有用的资源可供参考,包括劳伦斯·利弗莫尔国家实验室的教程和阿贡国家实验室的演示文稿

2. 一个简单的应用程序

MPI 标准定义了许多函数和数据结构,但有四个函数尤其重要:

  1. MPI_Init(int *argc, char ***argv) - 接收命令行参数并初始化执行环境(必需)
  2. MPI_Comm_size(MPI_Comm comm, int *size) - 在 `size` 指向的内存位置提供通信器中的进程数。如果第一个参数设置为 `MPI_COMM_WORLD`,则提供总进程数。
  3. MPI_Comm_rank(MPI_Comm comm, int *rank) - 在 `rank` 指向的内存位置提供执行函数的进程的秩。如果第一个参数设置为 `MPI_COMM_WORLD`,则提供该进程在所有进程中的秩。
  4. MPI_Finalize() - 终止 MPI 执行(必需)

这些函数与所有 MPI 函数一样,都返回一个整数,表示完成状态。如果函数成功完成,则返回 `MPI_SUCCESS` (0)。如果返回大于 0 的值,则表示失败。

hello_mpi.c 中的代码展示了这四个函数在实践中的用法:

#include <stdio.h>
#include <mpi.h>

int main (int argc, char *argv[]) {

  int rank, size;

  // Initialize the MPI environment
  MPI_Init(&argc, &argv);      

  // Determine the size of the communicator
  MPI_Comm_size(MPI_COMM_WORLD, &size);   

  // Determine the rank of the current process
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);

  // Print a message from the process
  printf("This is process %d of %d\n", rank, size);

  MPI_Finalize();
}

要编译一个普通的 C 应用程序,您需要识别头文件和库的位置。但是,MPI 的实现提供了一个名为 `mpicc`(MPI C Compiler)的可执行文件。这个编译器知道在哪里找到 MPI 依赖项,因此可以使用以下命令编译 hello_mpi.c

mpicc -o hello_mpi hello_mpi.c

除了 `mpicc` 之外,MPI 标准还定义了一个名为 `mpiexec` 的工具来启动 MPI 可执行文件。`mpiexec` 有两个重要的标志:

  • -n - 要生成的进程数
  • -host - 要在其中运行可执行文件的主机列表(用逗号分隔)

例如,以下命令将使用两个进程执行 `hello_mpi`。此执行将在名为 `castor` 和 `pollux` 的系统上进行。

mpiexec -n 2 -host castor,pollux hello_mpi

对于此示例,将打印以下输出:

This is process 0 of 2
This is process 1 of 2

当可执行文件运行时,很可能一个进程将在 `castor` 上运行,另一个将在 `pollux` 上运行。但是,不同的 MPI 实现有不同的将进程分配给主机的​​方式。例如,OpenMPI 使用秩文件将进程分配给处理器。

许多实现提供了另一个用于启动可执行文件的工具,称为 `mpirun`。`mpirun` 不由 MPI 标准定义,因此其用法取决于实现。例如,OpenMPI 的 `mpirun` 有一个 `loadbalance` 标志,可以将进程均匀地分配给处理器。Intel 的 `mpirun` 有一个 `iface` 标志,用于定义要使用的网络接口。

注意:在可执行文件可以在多个系统上运行时,它必须被复制到每个系统上的相同位置。如果可执行文件依赖于数据/配置文件,则这些文件也必须复制到每个系统上的相同位置。

3. 点对点通信

在许多 MPI 应用程序中,一个进程将数据分发给其他进程,然后收集处理后的数据。例如,秩为 0 的进程可能会从文件中读取一个 `float` 数组,然后将该数组发送给其他进程(秩 > 0)进行计算。计算完成后,进程 0 收集输出的 `float` 并将其保存到文件中。

在 MPI 中,数据从一个进程传输到另一个进程的过程称为点对点通信。这需要两个步骤:

  1. 发送进程调用 `MPI_Send` 来表达其向接收进程传输数据的意图。
  2. 接收进程调用 `MPI_Recv` 来表达其从发送进程接收数据的意图。

数据传输在两个函数都完成其操作之前不会完成。这两个函数的参数有很多共同点,从它们的签名可以看出:

int MPI_Send(const void* buff, int count, MPI_Datatype type, int dest, int tag, MPI_COMM comm)

int MPI_Recv(void* buff, int count, MPI_Datatype type, int source, int tag,
MPI_COMM comm, MPI_Status *status)

在这种情况下,`buff` 指向要发送的数据或接收数据的内存位置。`count` 和 `type` 指定了传输数据的性质。也就是说,`type` 标识了传输数据的原始类型(`MPI_FLOAT`、`MPI_DOUBLE`、`MPI_INT`、`MPI_UNSIGNED_SHORT`、`MPI_BYTE` 等),而 `count` 标识了应传输多少个原始数据。例如,以下函数发送 `float_array` 中存储的 100 个 `float`。

MPI_Send(float_array, 100, MPI_FLOAT, 3, 2, MPI_COMM_WORLD);

在这种情况下,`dest` 设置为 `3`,因为数据旨在发送到秩为 `3` 的进程。`tag` 值用作数据传输的唯一标识符。如果进程 3 想要接收此数据,它将在 `MPI_Recv` 中将 `tag` 设置为 `3`,如下所示:

MPI_Recv(myfloats, 100, MPI_FLOAT, 3, 2, MPI_COMM_WORLD, &status);

最后一个参数指向一个 `MPI_Status` 结构,其内容由函数初始化。该结构包含发送者的进程号、消息的 ID(`tag`)以及数据传输的错误代码。如果设置为 `MPI_STATUS_IGNORE`,则函数不会提供 `MPI_Status` 结构。

p2p_example.c 中的代码演示了如何使用 `MPI_Send` 和 `MPI_Recv`。此应用程序首先由进程 0 向其他所有进程发送一个 `float`。其他进程将它们的秩加到 `float` 上,然后将其发送回进程 0,进程 0 将结果收集到一个数组中并打印其元素。

#include <stdio.h>
#include <stdlib.h>
#include "mpi.h"

#define INPUT_TAG 0
#define OUTPUT_TAG 1

int main(int argc, char **argv) {

  int rank, size, i;
  float* data;

  MPI_Init(&argc, &argv);
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Comm_size(MPI_COMM_WORLD, &size);

  // Allocate, initialize, and send data
  if(rank == 0) {
    data = (float*)malloc((size-1) * sizeof(float));
    for(i=1; i<size; i++) {
      data[i-1] = 0.1 * i;
      MPI_Send(&data[i-1], 1, MPI_FLOAT, i, INPUT_TAG, MPI_COMM_WORLD);
    }
  }

  // Receive, process, and resend data
  if(rank > 0) {
    data = (float*)malloc(sizeof(float));
    MPI_Recv(data, 1, MPI_FLOAT, 0, INPUT_TAG, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
    data[0] += rank;
    MPI_Send(data, 1, MPI_FLOAT, 0, OUTPUT_TAG, MPI_COMM_WORLD);
  }

  // Receive processed data and print results
  if(rank == 0) {
    for(i=1; i<size; i++) {
      MPI_Recv(&data[i-1], 1, MPI_FLOAT, i, OUTPUT_TAG, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
    }
    printf("Output: ");
    for(i=1; i<size; i++) {
      printf("%.1f ", data[i-1]);
    }
    printf("\n");
  }

  free(data);
  MPI_Finalize();
  return 0;
}

此应用程序使用标签来区分数据传输。当进程 0 分发输入数据时,传输由 `INPUT_TAG` 标识。当其他进程发送处理后的数据时,传输由 `OUTPUT_TAG` 标识。

使用 `MPI_Send` 和 `MPI_Recv` 的一个缺点是它们会强制进程等待其他进程。为了弥补这一点,MPI 提供了点对点通信的替代函数。例如,`MPI_Isend` 和 `MPI_Irecv` 执行发送/接收操作,而不会阻止任何一个进程。这些函数超出了本文的范围,但您可以在此处了解更多信息。

4. 集合通信

在前面的示例中,`MPI_Send` 和 `MPI_Recv` 被放在循环中来在多个进程之间传输数据。涉及多个进程的通信称为集合通信,MPI 提供了专门用于此目的的函数。表 1 列出了其中的五个并提供了它们的描述。

函数签名 描述

MPI_Bcast(void *buff, int count,
  MPI_Datatype type, int root,
  MPI_Comm comm)

将一块数据从一个进程传输到
通信器中的每个进程

MPI_Scatter(const void* src_buff,
  int src_count, MPI_Datatype src_type,
  void* dst_buff, int dst_count,
  MPI_Datatype dst_type, int root,
  MPI_Comm comm)

将一块数据的连续块从一个
进程分发到通信器中的每个进程 

MPI_Gather(const void* src_buff,
  int src_count, MPI_Datatype src_type,
  void* dst_buff, int dst_count,
  MPI_Datatype dst_type, int root,
  MPI_Comm comm)

将来自多个
进程的数据块收集到一个进程中的数组

MPI_Allgather(const void src_buff,
  int src_count, MPI_Datatype src_type,
  void* dst_buff, int dst_count,
  MPI_Datatype dst_type, MPI_Comm comm)

将来自多个
进程的数据块收集到一个相同的数组,并分发到
每个进程

MPI_Alltoall(const void* src_buff,
  int src_count, MPI_Datatype src_type,
  void* dst_buff, int dst_count,
  MPI_Datatype dst_type, MPI_Comm comm)

以类似矩阵转置的方式在多个
进程之间传输数据

这些函数不仅简化了源代码,而且通常比点对点传输的循环执行得更好。本节将详细讨论这五个函数。

4.1 广播 (Broadcast)

考虑以下代码:

if(rank == root) {
  for(i=0; i<size; i++) {
    MPI_Send(&data, 1, MPI_FLOAT, i, tag, MPI_COMM_WORLD);
  }
}
MPI_Recv(&data, 1, MPI_FLOAT, root, tag, MPI_COMM_WORLD, MPI_STATUS_IGNORE);

for 循环将一个 `float` 从一个进程发送到通信器中的每个进程。调用 `MPI_Recv` 从根进程读取每个进程(包括根进程)的 `float`。

当一个进程将数据传输到通信器中的每个进程时,该操作称为广播。这由 `MPI_Bcast` 执行,它是 MPI 集合通信函数中最简单的。例如,以下函数调用执行了与前面示例代码相同的广播。

MPI_Bcast(&data, 1, MPI_FLOAT, root, MPI_COMM_WORLD);

使用 `MPI_Bcast` 时,不需要调用 `MPI_Recv`。`MPI_Bcast` 在所有数据已从根进程传输到通信器中的每个进程之前不会完成。这就解释了为什么该函数不使用 `tag` 值来标识传输。

4.2 分散 (Scatter) 和收集 (Gather)

分散操作类似于广播,但有两个重要区别:

  1. 广播将相同的数据从根节点传输到每个进程。分散操作将根节点的数**据分割成块,并根据它们的秩将不同的块传输到不同的进程。第一个块发送到进程 0,第二个发送到进程 1,依此类推。
  2. 在广播中,接收到的数据始终与发送的数据具有相同的类型、数量和内存引用。对于分散操作,这些特性是可以改变的。

例如,假设您想将根节点数组的前两个 `int` 传输到进程 0,接下来的两个 `int` 传输到进程 1,依此类推。这可以通过调用 `MPI_Scatter` 来实现:

MPI_Scatter(input_array, 2, MPI_INT, output_array, 2, MPI_INT, root, MPI_COMM_WORLD);

图 1 的左侧通过说明上述数据传输,阐明了 `MPI_Scatter` 的工作原理:

图 1:MPI_Scatter 和 MPI_Gather 函数

如图所示,收集操作执行的操作与分散操作相反。也就是说,它不是将数据块从一个进程分发到多个进程,而是将多个进程的数据块组合到一个进程的数组中。例如,以下函数调用将每个进程的三个 `float` 收集到根进程的 `output_array` 中:

MPI_Gather(input_array, 3, MPI_FLOAT, output_array, 3, MPI_FLOAT, root, MPI_COMM_WORLD);
与 `MPI_Bcast` 一样,接收进程或进程不需要调用 `MPI_Recv` 来完成传输。当 `MPI_Scatter` 或 `MPI_Gather` 完成时,数据传输就已完成。

4.3 AllGather

如前所述,`MPI_Gather` 将来自多个进程的数据块收集起来,并将它们作为数组传输到一个进程。`MPI_Allgather` 函数执行类似的操作,但现在收集到的数组被分发到每个进程。实际上,`MPI_Allgather` 执行了多次 `MPI_Gather` 调用,每次都使用不同的进程作为根节点。

例如,以下代码从每个进程收集两个 `int` 并将它们合并到一个数组中。然后,它将数组分发到通信器中的每个进程。

MPI_Allgather(input_array, 2, MPI_INT, output_array, 2, MPI_INT, MPI_COMM_WORLD);

重要的是要注意,每个进程都会收到一个包含收集到的元素的相同数组。

4.4 Alltoall

MPI_Alltoall 接受与 `MPI_Allgather` 相同的参数,并执行类似的操作——它从每个进程收集数据并将数据传输到每个进程。但是 `MPI_Alltoall` 不会将相同的数组传输到每个进程。相反,进程 k 接收每个进程的第 k 个元素。也就是说,进程 0 接收包含每个进程第一个元素的数组,进程 1 接收包含每个进程第二个元素的数组,依此类推。

如果您将进程 k 的数据视为矩阵的第 k 行,那么 `MPI_Alltoall` 执行的操作类似于矩阵转置,也称为角点转置。图 2 显示了它的样子:

a2a_example.c 源文件展示了 `MPI_Alltoall` 的实际用法。每个进程都有一个输入数组,其长度等于进程数。`MPI_Alltoall` 函数重新排列这些值,以便进程 k 接收来自每个进程的第 k 个元素。

#include <stdio.h>
#include <stdlib.h>
#include "mpi.h"

int main(int argc, char **argv) {

  int rank, size, i;

  MPI_Init(&argc, &argv);
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Comm_size(MPI_COMM_WORLD, &size);

  // Allocate and initialize data
  int* input_array = (int*)malloc(size * sizeof(int));
  int* output_array  = (int*)malloc(size * sizeof(int));
  for(i=0; i<size; i++) {
    input_array[i] = rank * size + i;
  }

  // Rearrange the data
  MPI_Alltoall(input_array, 1, MPI_INT, output_array, 1, MPI_INT, MPI_COMM_WORLD);

  printf("Process %d: ", rank);
  for(i=0; i<size; i++) {
    printf("%d ", output_array[i]);
  }
  printf("\n");

  // Deallocate and finalize MPI
  free(input_array);
  free(output_array);
  MPI_Finalize();
  return 0;
}

在此代码中,进程数始终等于每个数组的元素数。但这并非必须如此。如果进程数大于数组长度,则额外进程的数据将被设置为零。

5. 结论

本文讨论了 MPI 编码和进程间通信的基础知识。但是 MPI 提供了比本文讨论的更多的功能。如果您查看函数列表,您会发现 MPI 可以处理文件、线程、窗口、屏障等等。

Using the Code

代码存档包含三个文件:hello_mpi.cp2p_example.ca2a_example.c。安装 MPI 实现后,您可以使用 `mpicc` 编译它们,它的工作方式与流行的 GNU C Compiler (gcc) 非常相似。MPI 可执行文件编译并链接后,您可以使用 `mpiexec` 或 `mpirun` 来启动它。

历史

  • 2015/4/18:提交
© . All rights reserved.