Skip to content

Latest commit

 

History

History
743 lines (487 loc) · 36.5 KB

File metadata and controls

743 lines (487 loc) · 36.5 KB

九、分布式计算中的多线程

分布式计算是多线程编程的最初应用之一。当每台个人电脑只有一个单核处理器时,政府和研究机构以及一些公司会有多处理器系统,通常是集群形式。这些能够进行多线程处理;通过在处理器之间拆分任务,他们可以加速各种任务,包括模拟、渲染 CGI 电影等。

如今,几乎每个桌面级或更好的系统都有不止一个处理器内核,使用廉价的以太网布线将多个系统组装成一个集群非常容易。结合 OpenMP 和 Open MPI 等框架,扩展基于 C++ 的(多线程)应用以在分布式系统上运行是非常容易的。

本章的主题包括:

  • 在多线程 C++ 应用中集成 OpenMP 和 MPI
  • 实现分布式多线程应用
  • 分布式多线程编程的常见应用和问题

简而言之,分布式计算

当涉及到并行处理大型数据集时,理想的情况是可以将数据分割成许多小部分,并将其推送到许多线程,从而显著缩短处理所述数据所花费的总时间。

分布式计算背后的思想是这样的:在分布式系统的每个节点上,运行我们的应用的一个或多个实例,由此这个应用可以是单个的,也可以是多线程的。由于进程间通信的开销,使用多线程应用通常更有效,也由于其他可能的优化——资源共享。

如果已经有了一个可以使用的多线程应用,那么可以直接使用 MPI 来使它在分布式系统上工作。否则,OpenMP 是一个编译器扩展(用于 C/C++ 和 Fortran),它可以使一个应用在没有重构的情况下实现多线程相对无痛。

为此,OpenMP 允许标记一个公共代码段,在所有从线程上执行。一个主线程创建多个从线程,这些从线程将同时处理同一个代码段。一个基本的 Hello World OpenMP 应用如下所示:

/******************************************************************************
 * FILE: omp_hello.c
 * DESCRIPTION:
 *   OpenMP Example - Hello World - C/C++ Version
 *   In this simple example, the master thread forks a parallel region.
 *   All threads in the team obtain their unique thread number and print it.
 *   The master thread only prints the total number of threads.  Two OpenMP
 *   library routines are used to obtain the number of threads and each
 *   thread's number.
 * AUTHOR: Blaise Barney  5/99
 * LAST REVISED: 04/06/05
 ******************************************************************************/
 #include <omp.h>
 #include <stdio.h>
 #include <stdlib.h>

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

    /* Fork a team of threads giving them their own copies of variables */
 #pragma omp parallel private(nthreads, tid) {
          /* Obtain thread number */
          tid = omp_get_thread_num();
          printf("Hello World from thread = %d\n", tid);

          /* Only master thread does this */
          if (tid == 0) {
                nthreads = omp_get_num_threads();
                printf("Number of threads = %d\n", nthreads);
                }

    }  /* All threads join master thread and disband */ 
} 

从这个基本示例中可以很容易地看出,OpenMP 通过<omp.h>头提供了一个基于 C 的 API。我们还可以看到将由每个线程执行的部分,由#pragma omp预处理器宏标记。

与我们在前面章节中看到的多线程代码示例相比,OpenMP 的优势在于,可以轻松地将一段代码标记为多线程,而无需进行任何实际的代码更改。随之而来的明显限制是,每个线程实例将执行完全相同的代码,并且进一步的优化选项是有限的。

平均弹着点

为了调度特定节点上代码的执行,常用 MPI ( 消息传递接口)。Open MPI 是这方面的一个免费库实现,被很多高级超级计算机使用。MPICH 是另一个流行的实现。

MPI 本身被定义为并行计算机编程的通信协议。目前正在进行第三次修订(MPI-3)。

总之,MPI 提供了以下基本概念:

  • 通信器:通信器对象连接 MPI 会话中的一组进程。它为进程分配唯一的标识符,并在有序的拓扑中排列进程。
  • 点对点操作:这种类型的操作允许特定进程之间的直接通信。
  • 集合功能:这些功能涉及在流程组内广播通信。它们也可以以相反的方式使用,这将从一个组中的所有进程获取结果,例如,在单个节点上对它们求和。更具选择性的版本将确保特定的数据项被发送到特定的节点。
  • 派生数据类型:由于 MPI 集群中并不是每个节点都保证有相同的数据类型定义、字节顺序和解释,所以 MPI 要求指定每个数据段是什么类型,这样 MPI 才能进行数据转换。
  • 单侧通信:这些操作允许一个人向或从远程存储器写入或读取,或者跨多个任务执行缩减操作,而不必在任务之间同步。这对于某些类型的算法非常有用,例如那些涉及分布式矩阵乘法的算法。
  • 动态进程管理:这是一个允许 MPI 进程创建新的 MPI 进程,或者与新创建的 MPI 进程建立通信的功能。
  • 并行 I/O :也叫 MPI-IO,这是分布式系统上 I/O 管理的一个抽象,包括文件访问,便于 MPI 使用。

其中,MPI-IO、动态进程管理和单边通信是 MPI-2 的特色。从基于 MPI-1 的代码的迁移和动态过程管理与某些设置的不兼容性,以及许多不需要 MPI-2 特性的应用,意味着 MPI-2 的采用相对较慢。

履行

MPI 的最初实施是由阿贡国家实验室 ( ANL )和密西西比州立大学实施的 MPICH 。它是目前最流行的实现之一,被用作 MPI 实现的基础,包括由 IBM(蓝色基因)、英特尔、QLogic、Cray、杨梅、微软、俄亥俄州立大学(MVAPICH)和其他公司开发的实现。

另一个非常常见的实现是开放 MPI,它是由三个 MPI 实现合并而成的:

  • 田纳西大学
  • 洛斯阿拉莫斯国家实验室
  • 印第安纳大学

这些,连同斯图加特大学的 PACX-MPI 团队,是开放 MPI 团队的创始成员。开放 MPI 的主要目标之一是创建一个高质量的开源 MPI-3 实现。

MPI 实现被授权支持 C 和 Fortran。C/C++ 和 Fortran 以及汇编支持非常普遍,其他语言的绑定也是如此。

使用 MPI

无论选择哪种实现,最终的应用编程接口都将始终与官方的 MPI 标准相匹配,不同之处仅在于库所支持的 MPI 版本。然而,所有 MPI-1(修订版 1.3)特性都应该被任何 MPI 实现所支持。

这意味着,无论选择哪个库,MPI 的规范 Hello World(例如,在 MPI 教程网站:http://mpitutorial.com/tutorials/mpi-hello-world/)都应该工作:

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

int main(int argc, char** argv) { 
         // Initialize the MPI environment 
         MPI_Init(NULL, NULL); 

         // Get the number of processes 
         int world_size; 
         MPI_Comm_size(MPI_COMM_WORLD, &world_size); 

         // Get the rank of the process 
         int world_rank; 
         MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 

         // Get the name of the processor 
         char processor_name[MPI_MAX_PROCESSOR_NAME]; 
         int name_len; 
         MPI_Get_processor_name(processor_name, &name_len); 

         // Print off a hello world message 
         printf("Hello world from processor %s, rank %d" 
                     " out of %d processors\n", 
                     processor_name, world_rank, world_size); 

         // Finalize the MPI environment. 
         MPI_Finalize(); 
} 

通读这个基于 MPI 的应用的基本示例时,熟悉 MPI 使用的术语非常重要,尤其是:

  • 世界:该作业的注册 MPI 进程
  • 通信器:连接一个会话中所有 MPI 进程的对象
  • 等级:通信器中进程的标识符
  • 处理器:物理 CPU,多核 CPU 的单个核心,或者系统的主机名

在这个 Hello World 的例子中,我们可以看到我们包含了<mpi.h>头。不管我们使用什么实现,这个 MPI 头总是相同的。

初始化 MPI 环境需要对MPI_Init()进行一次调用,可以取两个参数,这两个参数在这一点上都是可选的。

下一步是获得世界的大小(即可用进程的数量)。这是使用MPI_Comm_size()完成的,它采用MPI_COMM_WORLD全局变量(由 MPI 定义供我们使用),并使用该世界中的进程数更新第二个参数。

然后我们获得的等级本质上是 MPI 分配给这个进程的唯一标识。通过MPI_Comm_rank()获得该 UID。同样,这将MPI_COMM_WORLD变量作为第一个参数,并返回我们的数值排名作为第二个参数。这个等级对于进程间的自我识别和交流非常有用。

获取正在运行的特定硬件的名称也很有用,特别是对于诊断目的。为此我们可以称之为MPI_Get_processor_name()。返回的字符串将具有全局定义的最大长度,并将以某种方式标识硬件。该字符串的确切格式由实现定义。

最后,我们打印出我们收集的信息,并在终止应用之前清理 MPI 环境。

编译 MPI 应用

为了编译 MPI 应用,使用了mpicc编译器包装器。这个可执行文件应该是已经安装的任何 MPI 实现的一部分。

然而,使用它与使用例如海湾合作委员会是一样的:

    $ mpicc -o mpi_hello_world mpi_hello_world.c

这可以比作:

    $ gcc mpi_hello_world.c -lmsmpi -o mpi_hello_world

这将把我们的 Hello World 示例编译并链接成二进制文件,准备执行。但是,执行这个二进制文件不是通过直接启动它来完成的,而是使用一个启动器,如下所示:

    $ mpiexec.exe -n 4 mpi_hello_world.exe
    Hello world from processor Generic_PC, rank 0 out of 4 processors
    Hello world from processor Generic_PC, rank 2 out of 4 processors
    Hello world from processor Generic_PC, rank 1 out of 4 processors
    Hello world from processor Generic_PC, rank 3 out of 4 processors

前面的输出来自运行在视窗系统的 Bash 外壳中的开放 MPI。如我们所见,我们总共启动了四个流程(4 个级别)。处理器名称被报告为每个进程的主机名(“PC”)。

用来启动 MPI 应用的二进制文件称为 mpiexec 或 mpirun 或 orterun。这些是同一个二进制的同义词,尽管不是所有的实现都有同义词。对于开放 MPI,这三个都存在,并且可以使用其中的任何一个。

集群硬件

基于 MPI 或类似应用运行的系统由多个独立的系统(节点)组成,每个系统都使用某种网络接口与其他系统相连。对于高端应用,这些往往是具有高速、低延迟互连的定制节点。另一端是所谓的贝奥武夫和类似类型的集群,由标准(台式)计算机制成,通常使用普通以太网连接。

在撰写本文时,最快的超级计算机(根据 TOP500 榜单)是位于中国无锡国家超级计算中心的 Sunway TaihuLight 超级计算机。它总共使用了 40,960 个中国设计的 SW26010 多核 RISC 架构 CPU,每个 CPU 有 256 个内核(分为 4 个 64 核组),以及 4 个管理内核。术语多核指的是一种专门的中央处理器设计,它更注重显式并行性,而不是大多数中央处理器核心的单线程和通用重点。这种类型的中央处理器一般类似于图形处理器架构和矢量处理器。

每个节点都包含一个 SW26010 和 32 GB 的 DDR3 内存。它们通过基于 PCIe 3.0 的网络连接,该网络本身由三层结构组成:中央交换网络(用于超级节点)、超级节点网络(连接超级节点中的所有 256 个节点)和资源网络,后者提供对 I/O 和其他资源服务的访问。单个节点之间的网络带宽为 12gb/秒,延迟约为 1 微秒。

下图(来自“太阳之路太湖光超级计算机:系统和应用”,DOI: 10.1007/s11432-016-5588-7)提供了该系统的可视化概述:

对于预算不允许如此复杂和高度定制的系统的情况,或者特定任务不保证采用这种方法的情况,始终存在“贝奥武夫”方法。贝奥武夫集群是一个术语,用来指由普通计算机系统构建的分布式计算系统。这些可以是基于英特尔或 AMD 的 x86 系统,基于 ARM 的处理器现在变得越来越流行。

让集群中的每个节点与其他节点大致相同通常会有所帮助。虽然有可能有一个不对称的集群,但当人们可以对每个节点进行广泛的假设时,管理和作业调度就变得容易得多。

至少,人们会希望匹配处理器架构,具有基本级别的 CPU 扩展,例如 SSE2/3,可能还有 AVX 和同族,在所有节点上都通用。这样做将允许跨节点使用相同的编译二进制,以及相同的算法,极大地简化了作业的部署和代码库的维护。

对于节点之间的网络,以太网是一个非常受欢迎的选择,它提供以几十到几百微秒为单位的通信时间,而成本仅为更快选择的一小部分。通常每个节点都连接到一个以太网,如下图所示:

还可以选择为每个节点或特定节点添加第二条甚至第三条以太网链路,使它们能够访问文件、输入/输出和其他资源,而不必与主网络层的带宽竞争。对于非常大的集群,人们可以考虑一种方法,例如 Sunway TaihuLight 和许多其他超级计算机使用的方法:将节点拆分为超级节点,每个节点都有自己的节点间网络。这将允许人们通过仅将流量限制到相关联的节点来优化网络上的流量。

这种优化的贝奥武夫集群的示例如下所示:

显然,基于 MPI 的集群有多种可能的配置,可以使用定制的、现成的或两种类型硬件的组合。群集的预期目的通常决定了特定群集的最佳布局,例如运行模拟或处理大型数据集。每种类型的工作都有自己的一套限制和要求,这也反映在软件实现中。

安装开放 MPI

在本章的剩余部分,我们将关注开放 MPI。为了获得一个开放 MPI 的工作开发环境,必须安装它的头文件和库文件,以及它的支持工具和二进制文件。

Linux 和 BSDs

在带有包管理系统的 Linux 和 BSD 发行版上,这非常容易:只需安装 Open MPI 包,一切都应该设置和配置好,随时可以使用。请查阅手册了解具体的发行版本,了解如何搜索和安装特定的软件包。

在基于 Debian 的发行版上,可以使用:

    $ sudo apt-get install openmpi-bin openmpi-doc libopenmpi-dev

前面的命令将安装开放 MPI 二进制文件、文档和开发头。计算节点上可以省略最后两个包。

Windows 操作系统

在 Windows 上,事情变得稍微复杂,主要是因为 Visual C++ 和伴随的编译器工具链占据了主导地位。如果您希望使用与 Linux 或 BSD 相同的开发环境,使用 MinGW,您必须采取一些额外的步骤。

This chapter assumes the use of either GCC or MinGW. If one wishes to develop MPI applications using the Visual Studio environment, please consult the relevant documentation for this.

最容易使用和最新的 MinGW 环境是 MSYS2,它提供了一个 Bash 外壳和大多数在 Linux 和 BSD 下熟悉的工具。它还具有从 Linux Arch 发行版中已知的 Pacman 包管理器。使用它,很容易安装开放 MPI 开发所需的包。

https://msys2.github.io/安装 MSYS2 环境后,安装 MinGW 工具链:

    $ pacman -S base-devel mingw-w64-x86_64-toolchain

这假设安装了 64 位版本的 MSYS2。对于 32 位版本,请选择 i686 而不是 x86_64。安装完这些软件包后,我们将安装 MinGW 和基本开发工具。为了使用它们,使用名称中的 MinGW 64 位后缀启动一个新的 shell,或者通过开始菜单中的快捷方式,或者通过使用 MSYS2 install文件夹中的可执行文件。

MinGW 准备好了,是时候安装 MS-MPI 7 . x 版本了,这是微软对 MPI 的实现,也是 Windows 上使用 MPI 最简单的方法。它是 MPI-2 规范的一个实现,主要与 MPICH2 参考实现兼容。由于 MS-MPI 库在不同版本之间不兼容,所以我们使用这个特定的版本。

虽然 MS-MPI 第 7 版已经存档,但仍可通过 https://www.microsoft.com/en-us/download/details.aspx?[微软下载中心下载 id=49926](https://www.microsoft.com/en-us/download/details.aspx?id=49926) 。

MS-MPI 7 版自带msmpisdk.msiMSMpiSetup.exe两个安装程序。两者都需要安装。之后,我们应该能够打开一个新的 MSYS2 外壳,并找到以下环境变量设置:

    $ printenv | grep "WIN\|MSMPI"
    MSMPI_INC=D:\Dev\MicrosoftSDKs\MPI\Include\
    MSMPI_LIB32=D:\Dev\MicrosoftSDKs\MPI\Lib\x86\
    MSMPI_LIB64=D:\Dev\MicrosoftSDKs\MPI\Lib\x64\
    WINDIR=C:\Windows

printenv 命令的输出显示 MS-MPI 软件开发工具包和运行时安装正确。接下来,我们需要将静态库从 Visual C++ LIB 格式转换为 MinGW A 格式:

    $ mkdir ~/msmpi
    $ cd ~/msmpi
    $ cp "$MSMPI_LIB64/msmpi.lib" .
    $ cp "$WINDIR/system32/msmpi.dll" .
    $ gendef msmpi.dll
    $ dlltool -d msmpi.def -D msmpi.dll -l libmsmpi.a
    $ cp libmsmpi.a /mingw64/lib/.

我们首先将原始的 LIB 文件和运行时 DLL 一起复制到主文件夹中的一个新的临时文件夹中。接下来,我们在 DLL 上使用 gendef 工具来创建我们将需要的定义,以便将其转换为新的格式。

最后一步是用 dlltool 完成的,它将定义文件和 DLL 一起使用,并输出一个与 MinGW 兼容的静态库文件。这个文件,然后我们复制到一个位置,MinGW 可以找到它以后当链接。

接下来,我们需要复制 MPI 头:

    $ cp "$MSMPI_INC/mpi.h" .

复制此头文件后,我们必须打开它并找到以下列内容开头的部分:

typedef __int64 MPI_Aint 

在该行的正上方,我们需要添加以下行:

    #include <stdint.h>

这包括增加了__int64的定义,我们需要它来正确编译代码。

最后,将头文件复制到 MinGW include文件夹:

    $ cp mpi.h /mingw64/include

有了这个,我们就有了库和头文件,可以和 MinGW 一起开发 MPI 了。允许我们编译和运行早期的 Hello World 示例,并继续本章的其余部分。

跨节点分发作业

为了在集群中的节点间分配 MPI 作业,必须将这些节点指定为mpirun / mpiexec命令的参数,或者使用主机文件。该主机文件包含网络上可用于运行的节点的名称,以及主机上可用插槽的数量。

在远程节点上运行 MPI 应用的先决条件是 MPI 运行时安装在该节点上,并且已经为该节点配置了无密码访问。这意味着只要主节点安装了 SSH 密钥,它就可以登录到这些节点中的每一个,以便在其上启动 MPI 应用。

设置 MPI 节点

在节点上安装 MPI 后,下一步是为主节点设置无密码 SSH 访问。这需要在节点上安装 SSH 服务器(基于 Debian 的发行版上的 ssh 包的一部分)。之后,我们需要生成并安装 SSH 密钥。

一种简单的方法是在主节点和其他节点上有一个公共用户,并使用 NFS 网络共享或类似方式将用户文件夹装载到计算节点上的主节点上。这样,所有节点都将拥有相同的 SSH 密钥和已知主机文件。这种方法的一个缺点是缺乏安全性。对于一个互联网连接的集群,这不是一个很好的方法。

然而,作为同一个用户在每个节点上运行作业绝对是一个好主意,以防止任何可能的权限问题,尤其是在使用文件和其他资源时。使用在每个节点上创建的公共用户帐户和生成的 SSH 密钥,我们可以使用以下命令将公钥传输到节点:

    $ ssh-copy-id mpiuser@node1

或者,我们可以在设置时将公钥复制到节点系统上的authorized_keys文件中。如果创建和配置大量节点,使用映像复制到每个节点的系统驱动器、使用设置脚本或可能通过 PXE 引导从映像引导是有意义的。

完成此步骤后,主节点现在可以登录到每个计算节点来运行作业。

创建 MPI 主机文件

如前所述,为了在其他节点上运行作业,我们需要指定这些节点。最简单的方法是创建一个包含我们希望使用的计算节点名称以及可选参数的文件。

为了允许我们使用节点名称而不是 IP 地址,我们必须首先修改操作系统的主机文件:例如,Linux 上的/etc/hosts:

    192.168.0.1 master
    192.168.0.2 node0
    192.168.0.3 node1

接下来,我们创建一个新文件,它将成为 MPI 使用的主机文件:

    master
    node0
    node1

使用这种配置,作业将在两个计算节点以及主节点上执行。我们可以从这个文件中取出主节点来防止这种情况。

如果不提供任何可选参数,MPI 运行时将使用节点上所有可用的处理器。如果需要,我们可以限制这个数字:

    node0 slots=2
    node1 slots=4

假设两个节点都是四核 CPU,这将意味着节点 0 上只有一半的核心会被使用,而所有核心都在节点 1 上。

运行作业

跨多个 MPI 节点运行 MPI 作业与仅在本地执行基本相同,如本章前面的示例所示:

    $ mpirun --hostfile my_hostfile hello_mpi_world

该命令将告诉 MPI 启动器使用名为my_hostfile的主机文件,并在该主机文件中找到的每个节点的每个处理器上运行指定 MPI 应用的副本。

使用集群调度程序

除了使用手动命令和主机文件在特定节点上创建和启动作业之外,还有集群调度程序应用。这些通常包括在每个节点和主节点上运行守护进程。使用提供的工具,可以管理资源和作业,安排分配和跟踪作业状态。

最受欢迎的集群管理调度程序之一是 Slurm,它是简单 Linux 资源管理实用程序的缩写(虽然现在更名为 SLURM 工作负载管理器,网站位于https://slurm.schedmd.com/)。它通常被超级计算机和许多计算机集群使用。其主要功能包括:

  • 使用时隙向特定用户分配对资源(节点)的独占或非独占访问
  • 在一组节点上启动和监控作业,如基于 MPI 的应用
  • 管理挂起作业的队列以仲裁共享资源的争用

基本群集操作不需要设置群集调度程序,但是当同时运行多个作业时,或者当群集的多个用户希望运行他们自己的作业时,设置群集调度程序对于较大的群集非常有用。

MPI 通信

此时,我们有了一个功能性的 MPI 集群,它可以用来以并行方式执行基于 MPI 的应用(以及其他应用)。虽然对于某些任务来说,在途中发送几十个或几百个进程并等待它们完成可能没问题,但通常这些并行进程能够相互通信是至关重要的。

这就是 MPI(作为“消息传递接口”)的真正意义发挥作用的地方。在 MPI 作业创建的层次结构中,进程可以通过多种方式进行通信和共享数据。最根本的是,他们可以分享和接收信息。

MPI 消息具有以下属性:

  • 寄件人
  • 接收器
  • 信息标签
  • 消息中元素的计数
  • 一个 MPI 数据类型

发送方和接收方应该相当明显。消息标签是一个数字标识,发送者可以设置,接收者可以使用它来过滤消息,例如,允许对特定消息进行优先排序。数据类型决定了消息中包含的信息类型。

发送和接收功能如下所示:

int MPI_Send( 
         void* data, 
         int count, 
         MPI_Datatype datatype, 
         int destination, 
         int tag, 
         MPI_Comm communicator) 

int MPI_Recv( 
         void* data, 
         int count, 
         MPI_Datatype datatype, 
         int source, 
         int tag, 
         MPI_Comm communicator, 
         MPI_Status* status) 

这里需要注意的一件有趣的事情是,send 函数中的 count 参数指示该函数将发送的元素数量,而 receive 函数中的相同参数指示该线程将接受的最大元素数量。

通信器是指正在使用的 MPI 通信器实例,接收函数包含一个最终参数,可用于检查 MPI 消息的状态。

MPI 数据类型

MPI 定义了许多可以直接使用的基本类型:

MPI datatype 碳当量
MPI_SHORT 短整型
MPI_INT (同 Internationalorganizations)国际组织
MPI_LONG 长整型
MPI_LONG_LONG 长整型
MPI_UNSIGNED_CHAR 无符号字符
MPI_UNSIGNED_SHORT 无符号短整型
MPI_UNSIGNED 无符号整数
MPI_UNSIGNED_LONG 无符号长整型
MPI_UNSIGNED_LONG_LONG 无符号长整型
MPI_FLOAT 漂浮物
MPI_DOUBLE 两倍
MPI_LONG_DOUBLE 长双
MPI_BYTE

MPI 保证当使用这些类型时,接收方将总是以它期望的格式获得消息数据,而不管字符顺序和其他与平台相关的问题。

自定义类型

除了这些基本格式,还可以创建新的 MPI 数据类型。这些使用了许多 MPI 功能,包括MPI_Type_create_struct:

int MPI_Type_create_struct( 
   int count,  
   int array_of_blocklengths[], 
         const MPI_Aint array_of_displacements[],  
   const MPI_Datatype array_of_types[], 
         MPI_Datatype *newtype) 

使用这个函数,可以创建一个包含结构的 MPI 类型,就像基本的 MPI 数据类型一样传递:

#include <cstdio> 
#include <cstdlib> 
#include <mpi.h> 
#include <cstddef> 

struct car { 
        int shifts; 
        int topSpeed; 
}; 

int main(int argc, char **argv) { 
         const int tag = 13; 
         int size, rank; 

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

         if (size < 2) { 
               fprintf(stderr,"Requires at least two processes.\n"); 
               MPI_Abort(MPI_COMM_WORLD, 1); 
         } 

         const int nitems = 2; 
         int blocklengths[2] = {1,1}; 
   MPI_Datatype types[2] = {MPI_INT, MPI_INT}; 
         MPI_Datatype mpi_car_type; 
         MPI_Aint offsets[2]; 

         offsets[0] = offsetof(car, shifts); 
         offsets[1] = offsetof(car, topSpeed); 

         MPI_Type_create_struct(nitems, blocklengths, offsets, types, &mpi_car_type); 
         MPI_Type_commit(&mpi_car_type); 

         MPI_Comm_rank(MPI_COMM_WORLD, &rank); 
         if (rank == 0) { 
               car send; 
               send.shifts = 4; 
               send.topSpeed = 100; 

               const int dest = 1; 

         MPI_Send(&send, 1, mpi_car_type, dest, tag, MPI_COMM_WORLD); 

               printf("Rank %d: sent structure car\n", rank); 
         } 

   if (rank == 1) { 
               MPI_Status status; 
               const int src = 0; 

         car recv; 

         MPI_Recv(&recv, 1, mpi_car_type, src, tag, MPI_COMM_WORLD, &status); 
         printf("Rank %d: Received: shifts = %d topSpeed = %d\n", rank, recv.shifts, recv.topSpeed); 
    } 

    MPI_Type_free(&mpi_car_type); 
    MPI_Finalize(); 

         return 0; 
} 

这里我们看到一个新的叫做mpi_car_type的 MPI 数据类型是如何定义的,并用来在两个进程之间传递消息。要创建这样的结构类型,我们需要定义结构中的项数、每个块中的元素数、它们的字节位移以及它们的基本 MPI 类型。

基本通信

MPI 通信的一个简单例子是将单个值从一个进程发送到另一个进程。为此,需要使用下面列出的代码并运行编译后的二进制文件来启动至少两个进程。这些进程是在本地运行还是在两个计算节点上运行并不重要。

以下代码是从http://mpitutorial.com/tutorials/mpi-hello-world/借来的:

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

int main(int argc, char** argv) { 
   // Initialize the MPI environment. 
   MPI_Init(NULL, NULL); 

   // Find out rank, size. 
   int world_rank; 
   MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 
   int world_size; 
   MPI_Comm_size(MPI_COMM_WORLD, &world_size); 

   // We are assuming at least 2 processes for this task. 
   if (world_size < 2) { 
               fprintf(stderr, "World size must be greater than 1 for %s.\n", argv[0]); 
               MPI_Abort(MPI_COMM_WORLD, 1); 
   } 

   int number; 
   if (world_rank == 0) { 
         // If we are rank 0, set the number to -1 and send it to process 1\. 
               number = -1; 
               MPI_Send(&number, 1, MPI_INT, 1, 0, MPI_COMM_WORLD); 
   }  
   else if (world_rank == 1) { 
               MPI_Recv(&number, 1, MPI_INT, 0, 0,  
                           MPI_COMM_WORLD,  
                           MPI_STATUS_IGNORE); 
               printf("Process 1 received number %d from process 0.\n", number); 
   } 

   MPI_Finalize(); 
} 

这段代码没有太多内容。我们通过通常的 MPI 初始化工作,然后检查以确保我们的世界大小至少有两个进程大。

等级为 0 的进程将发送数据类型为MPI_INT且值为-1的 MPI 消息。有等级的进程1会等待收到这个消息。接收过程指定MPI_Status MPI_STATUS_IGNORE表示该过程将不检查消息的状态。这是一种有用的优化技术。

最后,预期输出如下:

    $ mpirun -n 2 ./send_recv_demo
    Process 1 received number -1 from process 0

在这里,我们从总共两个过程开始编译演示代码。输出显示第二个进程收到了来自第一个进程的 MPI 消息,值正确。

高级通信

对于高级 MPI 通信,可以使用MPI_Status字段获取关于消息的更多信息。您可以使用MPI_Probe在通过MPI_Recv接受消息之前发现消息的大小。这对于事先不知道消息大小的情况很有用。

广播

广播一条消息意味着世界上所有的进程都会收到它。这相对于发送功能简化了广播功能:

int MPI_Bcast( 
   void *buffer,  
   int count,  
   MPI_Datatype datatype, 
         int root,    
   MPI_Comm comm) 

接收过程将简单地使用正常的MPI_Recv功能。广播功能所做的只是使用一种算法来优化许多消息的发送,该算法同时使用多个网络链路,而不是一个。

分散和聚集

分散非常类似于广播消息,有一个非常重要的区别:它不是在每个消息中发送相同的数据,而是向每个接收者发送数组的不同部分。其功能定义如下:

int MPI_Scatter( 
         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) 

每个接收流程将获得相同的数据类型,但我们可以指定将向每个流程发送多少个项目(send_count)。该功能用于发送和接收端,后者只需定义与接收数据相关的最后一组参数,并提供根进程和相关通信器的世界排名。

聚集是散射的逆过程。在这里,多个进程将发送最终到达单个进程的数据,这些数据按照发送它的进程的等级排序。其功能定义如下:

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) 

人们可能会注意到这个函数看起来非常类似于散射函数。这是因为它的工作方式基本相同,只是这一次各地的发送节点都要填写与发送数据相关的参数,而接收过程则要填写与接收数据相关的参数。

这里需要注意的是recv_count参数与从每个发送进程接收的数据量有关,而不是总量。

这两个基本功能还有进一步的专门化,但这里不涉及。

MPI 与线程

人们可能会认为使用 MPI 将 MPI 应用的一个实例分配给每个集群节点上的单个 CPU 内核是最容易的,这是真的。然而,这不是最快的解决办法。

尽管对于跨网络的进程之间的通信,MPI 可能是这种情况下的最佳选择,但是在单个系统(单个或多 CPU 系统)中,使用多线程很有意义。

主要原因很简单,线程之间的通信比进程间通信快得多,尤其是在使用 MPI 这样的通用通信层时。

可以编写一个应用,使用 MPI 在集群的网络上进行通信,从而为每个 MPI 节点分配一个应用实例。应用本身将检测该系统上的 CPU 内核数量,并为每个内核创建一个线程。因此,人们通常称之为混合 MPI,因为它具有以下优点而被广泛使用:

  • 更快的通信–使用快速线程间通信。
  • 更少的 MPI 消息–更少的消息意味着带宽和延迟的减少。
  • 避免数据重复–数据可以在线程之间共享,而不是向一系列进程发送相同的消息。

通过使用 C++ 11 和后续版本中的多线程特性,可以按照我们在前面章节中看到的方式实现这一点。另一个选择是使用 OpenMP,正如我们在本章开头看到的。

使用 OpenMP 的明显优势是它只需要开发人员很少的努力。如果你需要的只是运行更多相同例程的实例,那么只需要做一些小的修改来标记工作线程要使用的代码。

例如:

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

int main(int argc, char *argv[]) {
  int numprocs, rank, len;
  char procname[MPI_MAX_PROCESSOR_NAME];
  int tnum = 0, tc = 1;

  MPI_Init(&argc, &argv);
  MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Get_processor_name(procname, &len);

  #pragma omp parallel default(shared) private(tnum, tc) {
      np = omp_get_num_threads();
      tnum = omp_get_thread_num();
      printf("Thread %d out of %d from process %d out of %d on %s\n", 
      tnum, tc, rank, numprocs, procname);
  }

  MPI_Finalize();
}

上面的代码结合了一个 OpenMP 应用和 MPI。为了编译它,我们将运行例如:

$ mpicc -openmp hellohybrid.c -o hellohybrid

接下来,要运行该应用,我们将使用 mpirun 或等效工具:

$ export OMP_NUM_THREADS=8
$ mpirun -np 2 --hostfile my_hostfile -x OMP_NUM_THREADS ./hellohybrid

mpirun 命令将使用 hellohybrid 二进制文件运行两个 MPI 进程,将我们用-x 标志导出的环境变量传递给每个新进程。OpenMP 运行时将使用该变量中包含的值来创建该数量的线程。

假设我们的 MPI 主机文件中至少有两个 MPI 节点,我们将在两个节点上有两个 MPI 进程,每个进程运行八个线程,这将适合一个具有超线程的四核 CPU 或一个八核 CPU。

潜在问题

当编写基于 MPI 的应用并在多核 CPU 或集群上执行它们时,可能遇到的问题与我们在前面几章中已经遇到的多线程代码的问题非常相似。

然而,MPI 的另一个担心是依赖于网络资源的可用性。由于用于MPI_Send调用的发送缓冲区在网络堆栈能够处理该缓冲区之前不能被回收,并且该调用是阻塞类型的,发送大量小消息会导致一个进程等待另一个进程,而另一个进程又在等待调用完成。

在设计 MPI 应用的消息结构时,应该记住这种类型的死锁。例如,可以确保一侧没有建立发送呼叫,这将导致这样的情况。提供关于队列深度等的反馈信息可以用来缓解压力。

MPI 还包含一个使用所谓屏障的同步机制。这意味着在 MPI 进程之间使用,以允许它们在例如任务上同步。使用 MPI 屏障(MPI_Barrier)调用与互斥体类似,问题在于,如果 MPI 进程无法获得同步,此时一切都会挂起。

摘要

在这一章中,我们详细研究了 MPI 标准及其一些实现,特别是开放 MPI,并研究了如何设置集群。我们还看到了如何使用 OpenMP 轻松地将多线程添加到现有代码中。

此时,读者应该能够设置一个基本的 Beowulf 或类似的集群,将其配置为 MPI,并在其上运行基本的 MPI 应用。应该知道 MPI 进程之间如何通信,以及如何定义自定义数据类型。此外,读者将意识到为 MPI 编程时的潜在陷阱。

在下一章中,我们将学习前几章的所有知识,并在最后一章中看看我们如何将其结合起来,就像我们在视频卡(GPU)上查看通用计算一样。