MPI编程及性能优化

1节 MPI简介

1.1 MPI及其历史

与OpenMP相似,消息传递接口(Message Passing Interface,简称MPI)是一种编程接口标准,而不是一种具体的编程语言。该标准是由消息传递接口论坛(Message Passing Interface Forum,简称MPIF)发起讨论并进行规范化的。

MPI标准从1992年开始起草,1994年发布第一个版本MPI-1(MPI v1.0,进而发展出1.1和1.2版),到1997年发布第二个版本MPI-2(MPI v2.0)。MPI标准如今已经成为事实意义上的消息传递并行编程标准,也是最为流行的并行编程接口。

由于MPI提供了统一的接口,该标准受到各种并行平台上的广泛支持,这也使得MPI程序具有良好的可移植性。目前,MPI支持多种编程语言,包括Fortran77,Fortran90以及C/C++;同时,MPI支持多种操作系统,包括大多数的类UNIX系统以及Windows系统(Windows 2000、Windows XP等);MPI还支持多核(Multicore)、对称多处理机(SMP)、集群(Cluster)等各种硬件平台。。

1.2 典型MPI实现简介

1.MPICH

MPICH是影响最大、用户最多的MPI实现。

MPICH的特点在于:

        开放源码;

        与MPI标准同步发展;

        支持多程序多数据(Multiple Program Multiple Data,MPMD)编程和异构集群系统;

        支持C/C++、Fortran 77 和Fortran 90的绑定;对Fortran的支持提供了头文件mpif.h和模块两种方式;

        支持类Unix和Windows NT平台;

        支持环境非常广泛,包括多核、SMP、集群和大规模并行计算系统;

除此之外,MPICH软件包中还集成了并行程序设计环境组件,包括并行性能可视化分析工具和性能测试工具等。

2.Intel MPI

Intel MPI是由Intel公司推出的符合MPI-2标准的MPI实现。其最新版本是3.0版,突出的特色在于提供了灵活的多架构支持。

Intel MPI提供了名为Direct Access Programming Library (DAPL)的中间层来支持多架构,兼容多种网络硬件及协议,优化网络互联。Intel MPI库及DAPL互联结构可用图7.1.1清晰地表示出来。

图7.1 Intel MPI库及其基于 DAPL 的互联结构

从图7.1可以看出,Intel MPI透明地支持TCP/IP、共享内存,并基于DAPL有效支持多种高性能互联系统, Intel MPI提供更好的线程安全机制,多线程的MPI程序并不限制MPI的使用。

1.3 MPI程序特点

MPI程序是基于消息传递的并行程序。消息传递指的是并行执行的各个进程具有自己独立的堆栈和代码段,作为互不相关的多个程序独立执行,进程之间的信息交互完全通过显式地调用通信函数来完成。

基于消息传递的并行程序可以划分为单程序多数据(Single Program Multiple Data,简称SPMD)和多程序多数据MPMD两种形式。SPMD使用一个程序来处理多个不同的数据集以达到并行的目的。并行执行的不同程序实例处于完全对等的位置。相应的,MPMD程序使用不同的程序处理多个数据集,合作求解同一个问题。

SPMD是MPI程序中最常用的并行模型。图7.2为SPMD执行模型的示意图,其中表示了一个典型的SPMD程序,同样的程序prog_a运行在不同的处理核上,处理了不同的数据集。

图 7.2 SPMD执行模型

 
 

图7.5则给出了三种典型MPMD程序的执行模型。

(a)是一个管理者(Master)/工人(Worker)类型的MPMD程序

(b)为另外一种类型的MPMD程序:联合数据分析程序。在大部分的时间内,不同的程序各自独立的完成自己的任务,并在特定的时候交换数据。

(c)是流式的MPMD程序,程序运行由prog_a、prog_b和prog_c组成,这三个程序的执行过程就好像工厂里的流水线一样。

 
 


图 7.5 MPMD执行模型

通过学习本小节介绍的MPI程序的特点,以及SPMD和MPMD的多种执行模型,读者就可以在以后的开发过程中灵活地设计不同的MPI并行程序。

PI-2提出的高级功能将在第7.6节中将做简要说明。





2节 MPI编程基础

2.1 简单的MPI程序示例

首先,我们来看一个简单的MPI程序实例。如同我们学习各种语言的第一个程序一样,对于MPI的第一个程序同样是"Hello Word"。

/* Case 1 hellow.c */

#include

#include "mpi.h"

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

int rank;

int size;

    MPI_Init( argc, argv );

    MPI_Comm_rank(MPI_COMM_WORLD, &rank);

    MPI_Comm_size(MPI_COMM_WORLD, &size);

    printf( "Hello world from process %d of %d\n", rank, size );

    MPI_Finalize();

    return 0;

}

根据上一节的介绍,我们使用如下命令编译和链接这个程序:

mpicc –o hellow hellow.c

运行这个例子可以在可执行文件的目录中执行mpiexec –np 4 ./hellow。运行结果如下:

Hello world from process 0 of 4

Hello world from process 1 of 4

Hello world from process 2 of 4

Hello world from process 3 of 4

这个程序在MPI程序运行的每个进程中分别打印各自的MPI进程号(0~3)和总进程数(4)。

       值得注意的是,由于四个进程是并行执行,所以即使输出的顺序有变化也是正常的,程序中并没有限制哪个进程在前,哪个进程在后。

2.2 MPI程序的四个基本函数

1.MPI_Init和MPI_Finalize

MPI_Init用来初始化MPI执行环境,建立多个MPI进程之间的联系,为后续通信做准备。而MPI_Finalize则是结束MPI执行环境。

如同OpenMP定义并行区一样,这两个函数就是用来定义MPI程序的并行区的。也就是说,除了检测是否初始化的函数之外,不应该在这两个函数定义的区域之外调用其他MPI函数。

2.MPI_Comm_rank

第7.1节介绍过SPMD的程序形式,给出的例子中需要通过进程标识和总数来分配数据。MPI_Comm_rank就是来标识各个MPI进程的,告诉调用该函数的进程“我是谁?”。MPI_Comm_rank返回整型的错误值,需要提供两个函数参数:

         MPI_Comm类型的通信域,标识参与计算的MPI进程组。

         整型指针,返回进程在相应进程组中的进程号。进程号从0开始编号。

3.MPI_Comm_size

本函数则用来标识相应进程组中有多少个进程。

2.3 MPI的点对点通信

点对点通信是MPI编程的基础。本节我们将重点介绍其中两个最重要的MPI函数MPI_Send和MPI_Recv。

int MPI_SEND(buf, count, datatype, dest, tag, comm);

输入参数包括:

         buf,发送缓冲区的起始地址,可以是各种数组或结构的指针。

         count,整型,发送的数据个数,应为非负整数。

         datatype,发送数据的数据类型,这个参数将在后续节中详细介绍。

         dest,整型,目的进程号。

         tag,整型,消息标志,后续节中会做进一步介绍。

         comm,MPI进程组所在的通信域,留待后续节进一步介绍。

该函数没有输出参数,返回错误码。

这个函数的含义是向通信域comm中的dest进程发送数据。消息数据存放在buf中,类型是datatype,个数是count个。这个消息的标志是tag,用以和本进程向同一目的进程发送的其它消息区别开来。

int MPI_RECV(buf,count,datatype,source,tag,comm,status);

n         status,MPI_Status结构指针,返回状态信息。

MPI_Status的结构定义在mpi.h当中可以找到。

/* The order of these elements must match that in mpif.h */

typedef struct MPI_Status {

    int count;

    int cancelled;

    int MPI_SOURCE;

    int MPI_TAG;

    int MPI_ERROR;

} MPI_Status;

 

int MPI_Get_count( MPI_Status *status, MPI_Datatype datatype, int *count);

        count,是实际接收到的给定数据类型的数据项数

2.4 消息管理7要素

MPI最重要的功能莫过于消息传递。正如我们先前看到一样,MPI_Send和MPI_Recv负责在两个进程间发送和接收消息。总结起来,点对点消息通信的参数主要是由以下7个参数组成:

(1)    发送或者接收缓冲区buf;

(2)    数据数量count;

(3)    数据类型datatype;

(4)    目标进程或者源进程destination/source;

(5)    消息标签tag;

(6)    通信域comm;.

(7)    消息状态status,只在接收的函数中出现。

1.消息数据类型

在消息缓冲的三个变量中,最值得注意的是datatype,消息数据类型。

为什么需要定义消息数据类型?主要的理由有两个:一是支持异构平台计算的互操作性,二是允许方便地将非连续内存区中的数据,具有不同数据类型的内容组成消息。MPI程序有严格的数据类型匹配要求。类型匹配包涵了两个层面的内容:一是宿主语言的类型(C或者Fortran数据类型)和通信操作所指定的类型相匹配;二是发送方和接收方的类型匹配。MPI用预定义的基本数据类型和导出数据类型来满足上述要求。

(1)    基本数据类型

如前所述,我们需要发送和接收连续的数据,MPI提供了预定义的数据类型供程序员使用。

表7.3.1 MPI预定义数据类型与C数据类型的对应关系

MPI预定义数据类型

相应的C数据类型

MPI_CHAR

signed char

MPI_SHORT

signed short int

MPI_INT

signed int

MPI_LONG

signed long int

MPI_UNSIGNED_CHAR

unsigned char

MPI_UNSIGNED_SHORT

unsigned short int

MPI_UNSIGNED

unsigned int

MPI_UNSIGNED_LONG

unsigned long int

MPI_FLOAT

float

MPI_DOUBLE

double

MPI_LONG_DOUBLE

long double

MPI_BYTE

无对应类型

MPI_PACKED

无对应类型

对于初学者来说,应尽可能保证发送和接收的数据类型完全一致。

(2)    导出数据类型

除了这些基本数据类型之外,MPI还允许通过导出数据类型,将不连续的,甚至是不同类型的数据元素组合在一起形成新的数据类型。我们称这种由用户定义的数据类型为导出数据类型。

归纳起来类型匹配规则可以概括为:

        有类型数据的通信,发送方和接收方均使用相同的数据类型;

        无类型数据的通信,发送方和接收方均以MPI_BYTE作为数据类型;

        打包数据的通信,发送方和接收方均使用MPI_PACKED。

2. 通信域

一个通信域(comm)包含一个进程组(process group)及其上下文(context)。进程组是进程的有限有序集。通信域限定了消息传递的进程范围。

MPI实现已经预先定义了两个进程组:MPI_COMM_SELF,只包含各个进程自己的进程组;MPI_COMM_WORLD,包含本次启动的所有MPI进程的进程组。同时,MPI还为通信子提供了各种管理函数,其中包括:

(1)    通信域比较int MPI_Comm_compare(comm1, comm2, result):如comm1,comm2为相同句柄,则result为MPI_Ident;如果仅仅是各进程组的成员和序列号都相同,则result为MPI_Congruent;如果二者的组成员相同但序号不同则结果为MPI_Similar;否则,结果为MPI_Unequal。

(2)    通信域拷贝int MPI_Comm_dup(comm, newcom):对comm进行复制得到新的通信域newcomm。

(3)    通信域分裂int MPI_Comm_split(comm, color, key, newcomm):本函数要求comm进程组中的每个进程都要执行,每个进程指定一个color(整型),此调用首先将具有相同color值的进程形成一个新的进程组,新产生的通信域与这些进程组一一对应。新通信域中各个进程的顺序编号根据key(整型)的大小决定,即key越小,则相应进程在新通信域中的顺序编号也越小,若一个进程中的key相同,则根据这两个进程在原来通信域中顺序号决定新的进程号。一个进程可能提供color值为MPI_Undefined,此种情况下,其newcomm返回MPI_COMM_NULL。

(4)    通信域销毁int MPI_Comm_free(comm):释放给定通信域。

上述函数都返回错误码。

 

2.5 统计时间

MPI提供了两个时间函数MPI_Wtime和MPI_Wtick。其中,MPI_Wtime函数返回一个双精度数,标识从过去的某点的时间到当前时间所消耗的时间秒数。而函数MPI_Wtick则返回MPI_Wtime结果的精度。

2.6 错误管理

MPI在错误管理方面提供了丰富的接口函数,这里我们介绍其中最简单的部分接口。

n         用status.MPI_ERROR来获取错误码。

n         MPI终止MPI程序执行的函数MPI_Abort。

int MPI_Abort(MPI_Comm comm, int errorcode)

它使comm通信域的所有进程退出,返回errorcode给调用的环境。通信域comm中的任一进程调用此函数都能够使该通信域内所有的进程结束运行。





3节 MPI群集通信

除了前面介绍的点到点通信之外,MPI还提供了群集通信。所谓群集通信,包含了一对多,多对一和多对多的进程通信模式。它的最大的特点就是多个进程参与通信,下面我们将要介绍在MPI中常用的几个群集通信函数。

3.1 同步

本函数接口是:int MPI_Barrier(MPI_Comm comm)。

这个函数像一道路障。在操作中,通信子comm中的所有进程相互同步,即它们相互等待,直到所有进程都执行了他们各自的MPI_Barrier函数,然后再各自接着开始执行后续的代码。同步函数是并行程序中控制执行顺序的有效手段。

3.2 广播

广播顾名思义,就是一对多的传送消息。它的作用是从一个root进程向组内所有其他的进程发送一条消息。它的接口形式是:

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

图7.13给出了广播操作的示意。


7.13 广播操作示意图

3.3 聚集

聚集函数MPI_Gather是一个多对一的通信函数。其接口为:

int MPI_Gather(void *sendbuf, int sendcnt, MPI_Datatype sendtype,

             void *recvbuf, int recvcnt, MPI_Datatype recvtype,

             int root, MPI_Comm comm)

root进程接收该通信组每一个成员进程(包括root自已)发送的消息。这n个消息的连接按进程号排列存放在root进程的接收缓冲中。每个发送缓冲由三元组(sendbuf, sendcnt, sendtype)标识。所有非root进程忽略接收缓冲,对root进程发送缓冲由三元组(recvbuf, recvcnt, recvtype)标识。图7.14给出聚集操作的示意。

 


7.14 聚集操作示意图

3.4 播撒

int MPI_Scatter(void *sendbuf, int sendcnt, MPI_Datatype sendtype,

             void *recvbuf, int recvcnt, MPI_Datatype recvtype, int root,

             MPI_Comm comm)

播撒函数MPI_Scatter是一对多的传递消息。但是它和广播不同,root进程向各个进程传递的消息是可以不同的。Scatter实际上执行的是与Gather相反的操作。

3.5扩展的聚集和播撒操作

MPI_Allgather的作用是每一个进程都收集到其他所有进程的消息,它相当于每一个进程都执行了MPI_Gather执行完了MPI_Gather之后,所有的进程的接收缓冲区的内容都是相同的,也就是说每个进程给所有进程都发送了一个相同的消息,所以名为allgather。本函数的接口是:

int MPI_Allgather(void *sendbuf, int sendcount, MPI_Datatype sendtype,

               void *recvbuf, int recvcount, MPI_Datatype recvtype,

               MPI_Comm comm)

图7.15给出了扩展的聚集和播撒操作的示意。


7.15 扩展的聚集和播撒操作示意图

3.6全局交换

MPI_Allgather每个进程发一个相同的消息给所有的进程,而MPI_Alltoall散发给不同进程的消息是不同的。因此,它的发送缓冲区也是一个数组。MPI_Alltoall的每个进程可以向每个接收者发送数目不同的数据,第i个进程发送的第j块数据将被第j个进程接收并存放在其接收消息缓冲区recvbuf的第i块,每个进程的sendcount和sendtype的类型必须和所有其他进程的recvcount和recvtype相同,这也意谓着在每个进程和根进程之间发送的数据量必须和接收的数据量相等。函数接口为:

int MPI_Alltoall(void *sendbuf, int sendcount, MPI_Datatype sendtype,

              void *recvbuf, int recvcount, MPI_Datatype recvtype,

              MPI_Comm comm)

全局交换的操作示意图为图7.4.4。


7.4.4 全局交换操作示意图

3.7规约与扫描

MPI提供了两种类型的聚合操作:归约(reduction)和扫描(scan)。

1.归约

int MPI_Reduce(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype,

              MPI_Op op, int root, MPI_Comm comm)

这里每个进程的待处理数据存放在sendbuf中,可以是标量也可以是向量。所有进程将这些值通过输入的操作子op计算为最终结果并将它存入root进程的recvbuf。数据项的数据类型在Datatype域中定义。具体的归约操作包括:

         MPI_MAX 求最大值

         MPI_MIN 求最小值

         MPI_SUM 求和

         MPI_PROD 求积

         MPI_LAND 逻辑与

         MPI_BAND 按位与

         MPI_LOR 逻辑或

         MPI_BOR 按位或

         MPI_LXOR 逻辑异或

         MPI_BXOR 按位异或

         MPI_MAXLOC 最大值且相应位置

         MPI_MINLOC 最小值且相应位置

规约操作的数据类型组合如表7.4.1所示。

表7.4.1 规约操作与相应类型的对应关系

操作

允许的数据类型

MPI_MAX,MPI_MIN

C整数,Fortran整数,浮点数

MPI_SUM,MPI_PROD

C整数,Fortran整数,浮点数,复数

MPI_LAND,MPI_LOR,MPI_XLOR

C整数,逻辑型

MPI_BAND,MPI_BOR,MPI_BXOR

C整数,Fortran整数,字节型

 

在MPI中,针对规约操作,所有的MPI预定义的操作都是可结合的,也是可交换的。同时,用户可以指定自定义的函数操作,这些操作是也要可结合的,但可以不是可交换的。

2.扫描

int MPI_Scan(void *sendbuf, void *recvbuf, int count, MPI_Datatype datatype,

            MPI_Op op, MPI_Comm comm)

MPI_Scan常用于对分布于组中的数据作前置归约操作。此操作将序列号为0,···,i(包括i)的进程发送缓冲区的归约结果存入序列号为的进程接收消息缓冲区中。这种操作支持的数据类型、操作以及对发送及接收缓冲区的限制和规约相同。与规约相比,扫描Scan操作省去了Root域,因为扫描是将部分值组合成n个最终值,并存放在n个进程的recvbuf中。具体的扫描操作由Op域定义。

MPI的归约和扫描操作允许每个进程贡献向量值,而不只是标量值。向量的长度由Count定义。MPI也支持用户自定义的归约操作。



4节 MPI性能分析与优化举例

4.1 选取计算粒度

当通信的成为并行程序性能瓶颈的时候,一般来说选取较高的计算粒度可以降低进程间的通信开销。例如,用7个进程完成A、B、C 3个不相关的任务,如果B的计算量为A的2倍,而C的计算量为A的4倍。

一种并行执行的策略是采用任务内并行的方式,如图7.16(a)所示,这种方案中对于每一个任务都在7个进程上并行执行,所以每执行一个任务需要进行一次数据分配,和一次数据收集。而采用任务间并行的模式,即更大粒度的并行分配方式,只需要一次数据分配和一次数据收集,节约了两次集合通信(如图7.16(b)所示)。

 

                 

                             (a) 任务内并行                     (b) 任务间并行

图7.16 不同粒度并行模式示意图

 

4.2聚合消息

一种减少通信次数的方法就是将小的消息聚合起来一次发送,这种优化称为消息聚合。如果零碎的消息很多,则通过消息聚合可以得到很大的性能提高。

4.3解决负载均衡问题

在并行计算中,如果各个处理器(核)上的工作需要的完成时间不同,则会使先完成的处理器等待未完成的处理器(核),浪费了计算资源。若这种情况如果比较严重,就应该采用策略来使各处理器负载尽量平衡。一般采用的策略有两种,一种为静态负载平衡,一种为动态负载平衡。前者适用于计算前可以准确知道总的负载,而且这些负载容易平均划分给各个进程的情况。而对于事先不知道负载总数,或者总负载不易平均划分的情况,则可能需要采用动态负载划分来解决。

稠密的矩阵与向量乘法运算是一个静态负载平衡的例子,假设矩阵为N×M阶,而有p个相同处理器可以用于计算,按行分解每个处理器分得或行,若干按列分解每个处理器分得或列,如图7.19所示。当然还可以按矩形块分解矩阵,这时要根据具体矩形块的大小进行。

动态负载平衡我们采用三角矩阵与向量的乘法为例。存在一个管理节点,将矩阵未完成的行发送给工作节点,当工作节点完成任务后主动向管理节点索要任务,当管理节点上没有未完成任务时,向所有进程发送终止信号,如图7.20所示。这是通过主从模式,有效维护任务池,实现动态负载平衡的例子。

图7.19 矩阵向量乘静态负载平衡示意图

图7.20  动态负载平衡示意图





from: http://jpck.zju.edu.cn/eln/200805131515180671/page.jsp?cosid=1423&JSPFILE=page&LISTFILE=list&CHAPFILE=listchapter&PATH=200805131515180671&ROOTID=6380&NODEID=6403&DOCID=8717

你可能感兴趣的:(MPI)