并行计算

MPI编程

MPI是一种标准或规范的代表,同时也是一种消息传递编程模型。

MPI的实现是一个库,而不是一门语言。

可以把C + MPI看作是一种在原来串行语言基础之上扩展后得到的并行语言。

MPI概述

程序示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <studio.h>
#include <mpi.h>

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

    err = MPI_Init(&argc, &argv);
    printf("Hello World\n");
    err = MPI_Finalize();

    return 0;
}

MPI程序的结构

MPI程序结构

开始与结束

MPI代码开始之前必须进行如下的调用:

1
MPI_Init(&argc, &argv);

MPI系统将通过argcargv得到命令行参数

MPI的最后一行必须是

1
MPI_Finalize();

进程的标识

通信域默认为MPI_COMM_WORLD

MPI_Comm_size(MPI_COMM_WORLD, &size):获得通信域内所有进程的数目,赋值给size

MPI_Comm_rank(MPI_COMM_WORLD, &myrank):获得进程在通信域内的编号,赋值给myrank

发送与接收信息

发送消息示例:

1
MPI_Send(&N, 1, MPI_INT, j, num, MPI_COMM_WORLD);

接收消息示例:

1
MPI_Recv(&M, 1, MPI_INT, i, num, MPI_COMM_WORLD, &status);

据此可以得出MPI程序运行的完整过程:

MPI程序运行的完整过程

点到点通信

Send和Recv

1
MPI_Send(buffer, count, datatype, destination, tag, communicator);

具体解释如下:

参数 含义
buffer 指明消息缓存的起始地址,即存放要发送的数据信息
count 指明消息中给定的数据类型有多少项,数据类型由datatype给定
datatype 数据类型要么是基本数据类型,要么是导出数据类型(由用户指定的混合数据类型)
destination 目的进程的标识符(进程编号)
tag 消息标签
communicator 标识进程组和上下文,即通信域。通常消息只能在同组的进程间传递,但MPI允许通过intercommunicator在组间传递
1
MPI_Recv(address, count, datatype, source, tag, communicator, status);

具体解释如下:

参数 含义
address 指明接收消息缓冲的起始地址,即存放接收消息的内存地址
count 指明给定数据类型datatype可以被接收的最大项数
datatype 指定接收的数据类型
destination 源进程的标识符(进程编号)
tag 消息标签
communicator 标识通信域
status 指向一个结构MPI_Status,存放有关接收消息的各种信息

标签的使用

考虑下列程序:

1
2
3
4
5
6
7
// 进程P
MPI_Send(A, 32, Q);
MPI_Send(B, 16, Q);

// 进程Q
MPI_Recv(X, 32, P);
MPI_Recv(Y, 16, P);

进程P需要传递A进程的前32个字节进入X,传递B的前16个字节进入Y。但是,如果消息B后发送但是先到达进程Q,就会被Q的第一个MPI_Recv()接收到X中。

使用标签可以避免这个错误:

1
2
3
4
5
6
7
// 进程P
MPI_Send(A, 32, Q, tag1);
MPI_Send(B, 16, Q, tag2);

// 进程Q
MPI_Recv(X, 32, P, tag1);
MPI_Recv(Y, 16, P, tag2);

标签还可以应用在不同客户端请求服务端数据时,服务端根据标签的不同而分别采用相应的函数。

组通信

组通信的函数分类如下:

功能 函数
一对多 BroadcastScatter
多到一 ReduceGather
多到多 AllreduceAllgather
同步 Barrier

Broadcast

1
MPI_Bcast(address, count, datatype, root, comm);

标号为root的进程发送相同的消息给标记为comm的通信子中的所有进程。

消息的内容如同点对点通信一样由三元组(address, count, datatype)标识。对root进程来说,这个三元组既定义了发送缓冲也定义了接收缓冲;对其他进程来说,这个三元组只定义了接收缓冲。

MPI_Bast

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int main(int argc, char** argv) {
    int rank, value;

    MPI_Init(&argc, &argv);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    do {
        if (rank == 0) { 
            // 进程0读入需要广播的数据
            scanf("%d", &value);
        }
        // 将该数据广播出去
        MPI_Bcast(&value, 1, MPI_INT, 0, MPI_COMM_WORLD);
        // 各进程打印收到的数据
        printf("Process %d got %d \n", rank, value);
    } while (value >= 0);
    MPI_Finalize();

    return 0;
}

Scatter

1
2
MPI_Scatter(sendAddress, sendCount, sendDatatype, 
            recvAddress, recvCount, recvDatatype, root, comm);

root进程发送给所有n个进程各发送一个不同的消息,包括自己。

这n个消息在root进程的发送缓冲区中按标号的顺序有序地存放。每个接收缓冲区由三元组(recvAddress, recvCount, recvDatatype)标识。非root进程忽略发送缓冲。

对于root进程,发送缓冲由三元组(sendAddress, sendCount, sendDatatype)标识。

MPI_Scatter

示例:

1
2
3
4
5
6
7
8
9
// 根进程向组内每个进程散播100个整型数据
MPI_Comm comm;
int gsize, *sendbuf;
int root, rbuf[100];
...
MPI_Comm_size(comm, &gsize);
sendbuf = (int*) malloc (gsize * 100 * sizeof(int));
...
MPI_Scatter(sendbuf, 100, MPI_INT, rbuf, 100, MPI_INT, root, comm);

根进程向各个进程发送个数不等的数据

1
2
MPI_Scatterv(sendbuf, sendcounts, displs, sendtype, 
            recvbuf, recvcount, recvtype, root, comm);

Gather

1
2
MPI_Gather(sendAddress, sendCount, sendDatatype, 
            recvAddress, recvCount, recvDatatype, root, comm);

root进程接收各个进程(包括它自己)的消息。这n个进程的连接按序号rank进行,存放在root进程的接收缓冲中。

每个发送缓冲由三元组(sendAddress, sendCount, sendDatatype)标识。

root进程忽略接收缓冲。对于root进程,发送缓冲由三元组(recvAddress, recvCount, recvDatatype)标识。recvCount标识root进程从每个进程接收数据的个数。

MPI_Gather

示例:

1
2
3
4
5
6
7
8
// 根进程从进程组中每个进程收集100个整型数据
MPI_Comm comm;
int gsize, sendarray[100];
int root, rbuf[100];
...
MPI_Comm_size(comm, &gsize);
rbuf = (int*) malloc (gsize * 100 * sizeof(int));
MPI_Gather(sendarray, 100, MPI_INT, rbuf, 100, MPI_INT, root, comm);

从不同进程接收不同数量的数据

1
2
MPI_Gatherv(sendbuf, sendcount, sendtype, 
            recvbuf, recvcounts, displs, recvtype, root, comm);

Allgather

1
2
MPI_Allgather(sendAddress, sendCount, sendDatatype, 
                recvAddress, recvCount, recvDatatype, comm);

MPI_Allgather

示例:

1
2
3
4
5
6
7
8
// 每个进程都从其他进程收集100个数据,存入自己的缓冲区
MPI_Comm comm;
int gsize, sendarray[100];
int *rbuf;
...
MPI_Comm_size(comm, &gsize);
rbuf = (int*) malloc (gsize * 100 * sizeof(int));
MPI_Allgather(sendarray, 100, MPI_INT, rbuf, 100, MPI_INT, comm);

Reduce

所有进程向同一进程发送消息,与Broadcast的消息发送方向相反。

接收进程对所有收到的消息进行规约处理。

1
MPI_Reduce(inbuf, result, count, datatype, op, root, comm);

MPI_Reduce

将规约结果散播到所有进程中:

1
MPI_Reduce_scatter(sendbuf, recvbuf, recvcounts, datatype, op, comm);

Scan

1
MPI_Scan(sendbuf, recvbuf, count, datatype, op, comm);

MPI_Scan令每一个进程都对排在它前面的进程进行规约操作。

调用的结果:对于每一个进程 $i$,它对进程 $0, 1, \cdots, i$ 的发送缓冲区的数据进行指定的规约操作,结果存入进程 $i$ 的接收缓冲区。

不同类型的规约操作对比

不同类型的规约操作对比

Alltoall

1
MPI_Alltoall(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm);

每个进程依次将它的发送缓冲区的第 $i$ 块数据发送给第 $i$ 个进程,同时每个进程又都依次从第 $j$ 个进程接收数据放到各自的接收缓冲区中第 $j$ 块数据区的位置。

Barrier

令所有进程在某一个位置阻塞,直到所有进程都到达该位置。

阻塞通信

标准通信模式

使用函数MPI_Send

在MPI采用标准通信模式时,是否对发送的数据进行缓存是由MPI自身决定的,无法由程序员控制。

如果MPI决定缓存将要发出的数据,发送操作不管接收操作是否执行都可以进行,而且发送操作可以正确返回而不要求接收操作收到发送的数据。

标准通信模式

缓存通信模式

使用函数MPI_Bsend

由用户直接对通信缓冲区进行申请、使用和释放。缓存模式下对通信缓冲区的合理与正确使用由程序员自己保证。

MPI_Bsend参数的含义和MPI_Send的完全相同,不同之处仅表现在通信时是使用标准的系统提供的缓冲区还是用户自己提供的缓冲区。

缓存通信模式

MPI_Buffer_attach:将大小为size的缓冲区递交给MPI,该缓冲区就可以作为缓存发送时的缓存使用。

MPI_Buffer_detach:将提交的大小为size的缓冲区buffer收回。该调用将一直等到该缓存的消息发送完成后才返回,返回后用户可以重新使用该缓冲区或者将这一个缓冲区释放。

同步通信模式

使用函数MPI_Ssend

同步通信模式的开始不依赖于接收进程相应的接收操作是否已经启动,但是同步发送必须等到相应的接收进程开始后才可以正确返回。

同步发送返回后,意味着发送缓冲区中的数据已经全部被系统缓冲区缓存并已开始发送。

当同步发送返回后,发送缓冲区可以被重新释放或重新使用。

同步通信模式

就绪通信模式

使用函数MPI_Rsend

只有当接收进程的接收操作已经启动时,才可以在发送进程启动发送操作,否则当发送操作启动而相应的接收还没有启动时,发送操作将出错。

对于非阻塞发送操作的正确返回,并不意味着发送已经完成;但对于阻塞发送的正确返回,则发送缓冲区可以重复使用。

就绪通信模式

非阻塞通信

MPI非阻塞通信的核心是让计算和通信重叠进行,从而进一步提升并行效率。

发送与接收

非阻塞标准的发送与接收

1
2
MPI_Isend(void* buf, int count, MPI_Datatype datatype, 
            int dest, int tag, MPI_Comm comm, MPI_Request* request);

其中,MPI_Request是一个非阻塞通信对象,是MPI内部的对象,可识别非阻塞通信操作的各种特性,包括发送模式、和它关联的通信缓冲区、通信上下文、用于发送的标识和目的参数、用于接收的标识和源参数。

与阻塞通信的4种通信模式相对应,非阻塞通信也有相应的4种通信模式。

MPI使用与阻塞通信一样的命名约定,即前缀B、S、R分别表示缓存通信模式、同步通信模式和就绪通信模式。

前缀I(immediate)表示这个调用是非阻塞的。

通信完成

对于非阻塞通信,通信调用的返回并不意味着通信的完成,因此需要专门的通信语句来完成或检查该非阻塞通信。

不管非阻塞通信是什么形式,对于完成调用是不加区分的。当非阻塞调用完成后,就可以保证该非阻塞通信已经正确完成了。

单个非阻塞通信的完成

1
int MPI_Wait(MPI_Request* request, MPI_Status* status)

阻塞调用,通信完成后才能返回并释放对象。

1
int MPI_Test(MPI_Request* request, int* flag, MPI_Status* status)

非阻塞调用,直接返回状态结果。若返回FALSE(即0)则不释放对象。

多个非阻塞通信的完成

1
2
int MPI_Waitany(int count, MPI_Request* array_of_requests, 
                int* index, MPI_Status* status)

返回后index = i,即完成的是非阻塞通信对象表中第i个对象对应的非阻塞通信,则其效果等价于调用了MPI_Wait(array_of_requests[i], status)

对应地,我们还有以下函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
int MPI_Testany(int count, MPI_Request* array_of_requests, 
                int* index, int* flag, MPI_Status* status)

int MPI_Waitall(int count, MPI_Request* array_of_requests, 
                MPI_Status* array_of_statuses)

int MPI_Testall(int count, MPI_Request* array_of_requests, 
                int* flag, MPI_Status* array_of_statuses)

int MPI_Waitsome(int incount,                           // 对象个数
                    MPI_Request* array_of_requests,     // 对象数组
                    int* outcount,                      // 已完成的对象个数
                    int* array_of_indices,              // 已完成的对象下标数组
                    MPI_Status* array_of_statuses)

int MPI_Testsome(int incount,                           // 对象个数
                    MPI_Request* array_of_requests,     // 对象数组
                    int* outcount,                      // 已完成的对象个数
                    int* array_of_indices,              // 已完成的对象下标数组
                    MPI_Status* array_of_statuses)                    

通信取消

1
int MPI_Cancel(MPI_Request* request)

如果一个非阻塞通信已经被执行了取消操作,则该通信的MPI_WaitMPI_Test将释放取消通信的非阻塞通信对象,并且在返回结果的status中指明该通信已经被取消。

1
int MPI_Test_cancelled(MPI_Status status, int* flag)

返回结果flag = true表明该通信已经被成功取消,否则还未被取消。

1
int MPI_Request_free(MPI_Request* request)

表明非阻塞通信完成后将该对象占用的资源释放,此时request变为MPI_Request_Null

执行了释放操作后,非阻塞通信对象就无法再通过其他任何调用访问。但如果与该非阻塞通信对象相联系的通信还没有完成,则该对象的资源不会立即释放,它将等到该非阻塞通信结束后再释放。因此非阻塞通信对象的释放并不影响该非阻塞通信的完成。

消息到达的检查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int MPI_Probe(int source,       // 源进程标识(可任意源)
            int tag,            // 标签值(可任意标签)
            MPI_Comm comm,
            MPI_Status* status)

int MPI_Iprobe(int source,
            int tag,
            MPI_Comm comm,
            int* flag,          // 是否有标签到达
            MPI_Status* status)

MPI_Probe是阻塞调用,检测到消息后才返回。

重复非阻塞通信

通信重复执行,比如循环结构内的通信调用。

通过将通信参数和MPI内部对象建立起固定联系,然后通过该对象完成重复通信的任务,并优化以降低开销。这样的通信方式在MPI中都是非阻塞通信。

阻塞通信和非阻塞通信的比较:

阻塞操作 非阻塞操作
阻塞发送返回意味着发送缓冲区可被再次使用而不会影响接收方,但并不意味着接收方已经完成接收 非阻塞的发送和接收在调用后都可以立即返回,不会等待任何与通信相关的事件
阻塞发送可以同步形式工作,,发送方与接收方需要实施一个握手协议来确保发送动作的安全 非阻塞只对MPI环境提出一个要求:在可能的时候启动通信。用户无法预测通信何时发生
阻塞发送可以异步进行,此时需要系统缓冲区进行缓存 在通过某种手段确定MPI环境确实执行了通信之前,修改发送缓冲区的数据是不安全的
阻塞接收操作仅当消息接收完成后才可以返回 非阻塞通信的主要目的是把计算和通信重叠起来,从而改进并行效率

非阻塞通信的一个重要作用就是避免死锁。

MPI_Sendrecv和虚进程

以Jacobi迭代问题为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
REAL A(N+1, N+1), B(N+1, N+1)
...
DO K=1, STEP
    DO J=1, N
        DO I=1, N
            B(I, J) = 0.25*(A(I-1, J) + A(I+1, J) + A(I, J+1) + A(I, J-1))
        END DO
    DO J=1, N
        DO I=1, N
            A(I, J) = B(I, J)
    END DO
END DO

也就是对一个矩阵 $\bm{H}$ 的所有元素作以下操作:

$$h_{i,j} = \frac{h_{i-1,j} + h_{i+1,j} + h_{i,j-1} + h_{i,j+1}}{4}$$

对Jacobi迭代进行数据按列划分,容易得知,数据域的中间可以直接使用进程内部的数据,而两侧需要接收来自其他进程的数据。

Jacobi迭代的数据划分

因此需要在每个进程数据域的两边各增加一列,用于存放通信得到的数据。

Jacobi迭代的通信过程

捆绑发送和接收

MPI提供了MPI_Sendrecv操作,可以在一条MPI语句中同时实现向其他进程的数据发送和从其他进程接收数据的操作。该操作把发送一个消息到一个目的地和从另一个进程接收一个消息合并到一个调用中,源和目的可以相同。

在语义上等同于一个发送操作和一个接收操作的结合,但可以有效地避免单独调用发送和接收操作时由于次序错误而造成的死锁。

因为该调用由通信系统来实现,系统会优化通信次序从而有效地避免不合理的通信次序,最大限度地避免死锁的产生。

1
2
3
int MPI_Sendrecv(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)

捆绑发送接收操作是不对称的,即一个由捆绑发送接收调用发出的消息可以被一个普通接收操作接收,一个捆绑发送接收调用可以接收一个普通发送操作发送的消息。

该操作执行一个阻塞的发送和接收,接收和发送操作使用同一个通信域。发送缓冲区和接收缓冲区必须分开,可以是不同的数据长度和不同的数据类型。

MPI_Sendrecv

虚拟进程

虚拟进程(MPI_PROC_NULL)是不存在的假想进程,在MPI中的主要作用是充当真实进程通信的目的或源进程。

引入虚拟进程的目的是为了在某些情况下编写通信语句的方便。

  1. 一个真实进程向一个虚拟进程发送数据或从一个虚拟进程接收数据时,该真实进程会立即正确返回,如同执行了一个空操作。
  2. 一个真实进程从虚拟进程接收消息时,也会立即成功返回,并且对接收缓冲区没有任何改变。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
if (myid > 0) {
    left = myid-1;
} else {
    left = MPI_PROC_NULL;
} 

if (myid < n-1) {
    right = myid + 1;
} else {
    right = NPI_PROC_NULL;
}

// 从左向右平移数据
MPI_Sendrecv(sendData1, sendCount, MPI_FLOAT, right, tag1, 
            recvData1, recvCount, MPI_FLOAT, left, tag1, MPI_COMM_WORLD, status)

// 从右向左平移数据
MPI_Sendrecv(sendData2, sendCount, MPI_FLOAT, left, tag1, 
            recvData2, recvCount, MPI_FLOAT, right, tag1, MPI_COMM_WORLD, status)

自定义数据类型

MPI基本数据类型

MPI_Datatype C datatype
MPI_CHAR signed char
MPI_SHORT signed short
MPI_INT signed int
MPI_LONG signed long int
MPI_UNSIGNED_CHAR unsigned char
MPI_UNSIGNED_SHORT unsigned short int
MPI_UNSIGNED_INT unsigned int
MPI_UNSIGNED_LONG unsigned long int
MPI_FLOAT float
MPI_DOUBLE double
MPI_LONG_DOUBLE long double
MPI_BYTE
MPI_PACKED

自定义数据类型

MPI的自定义数据类型需要固定的创建格式:

1
2
3
4
// 创建数据类型
MPI_Type_struct(..., &buff_datatype);
// 声明数据类型
MPI_Type_commit(&buff_datatype);

创建方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 连续数据
int MPI_Type_contiguous(int count, MPI_Datatype oldtype, MPI_Datatype* newtype)

// 向量
int MPI_Type_vector(int count, int blocklength, int stride, 
                    MPI_Datatype oldtype, MPI_Datatype* newtype)

// 结构体
int MPI_Type_struct(int count, int* array_of_blocklengths, 
                    MPI_Aint* array_of_displacements, 
                    MPI_Datatype* array_of_types,
                    MPI_Datatype* newtype)

虚拟进程拓扑

在许多并行应用程序中,进程的线性排列通常不能充分地反映进程间在逻辑上的通信模型。此时我们经常将进程排列为二维或三维网格形式的拓扑模型,并用一个图来描述逻辑进程排列。

这种逻辑进程的排列称作虚拟进程拓扑

虚拟拓扑的作用

拓扑是组内通信域上的额外、可选的属性,它不能附加在组间通信域上。

作用:

  1. 便于命名:拓扑能够提供一种方便的命名机制,对于有特定拓扑要求的算法使用起来直接、自然且方便。
  2. 简化代码编写。
  3. 辅助运行时系统将进程映射到实际的硬件结构之上。
  4. 便于MPI内部对通信进行优化。

常用拓扑构型

笛卡尔拓扑:

  1. 每个进程处于一个虚拟的网络内,与其邻居通信
  2. 边界可以构成环
  3. 通过笛卡尔坐标(平面直角坐标)来标识进程
  4. 任何两个进程之间都可以通信

图拓扑:适用于更复杂的通信情形。

案例:二维阵列拓扑

二维阵列拓扑

虚拟拓扑的使用

创建虚拟拓扑

1
2
3
int MPI_Cart_create(MPI_Comm comm_old, int ndims, 
                    int* dims, int* periods, int reorder,
                    MPI_Comm* comm_cart)

创建虚拟拓扑

构建坐标映射

实现进程号与其在虚拟进程拓扑中坐标的映射

1
2
3
4
5
// 给定进程号返回其笛卡尔坐标
int MPI_Cart_coords(MPI_Comm comm_cart, int rank, int maxdims, int* coords)

// 给定笛卡尔坐标返回进程号
int MPI_Cart_rank(MPI_Comm comm_cart, int* coords, int* rank)

计算当前进程的坐标

数据平移

1
2
int MPI_Cart_shift(MPI_Comm comm_cart, int direction, 
                    int disp, int* rank_source, int* rank_dest)

计算相邻进程的rank,如果没有邻居则返回MPI_PROC_NULL

数据平移

划分子拓扑

1
MPI_Cart_sub(comm_cart, remain_dims, comm_sub, ierror)

划分子拓扑

使用块划分的Jacobi迭代方法:

Jacobi块划分

Jacobi块通信

网站总访客数:Loading

使用 Hugo 构建
主题 StackJimmy 设计