通讯: 点对点
MPI的通讯是指程序在不同的处理器之间进行数据交换的一种行为,通讯方式按照目标的不同主要分为两类:点对点通讯和集群通讯。 点对点通讯需要一个处理器进行发送,另外一个处理器进行接收。
Message
要了解MPI的通讯,首先需要了解一下MPI中Message的结构。Message主要包含数据(3个参数),封包(3个参数)以及其他的一些与通讯有关的参数
其中,数据主要包括:
- 数据指针(datapointer): 数据或数组的第一个元素的地址;
- 数据长度(count): 当前类型元素的个数(注意,不一定是数组元素个数,尤其是使用自定义类型时);
- 数据类型(datatype): 数据类型(可以是内置类型(如MPI_INT,MPI_DOUBLE),也可以是自定义类型(Derived Data Type))。
封包:
- 通讯目标(dest): 目标处理器的rank,当目标不确定时可以使用MPI_ANY_SOURCE;
- 封包标志(tag): 用于标识Message,只有具有相同tag的发送和接收函数才能进行通讯;
- 通讯器(communicator): 当前通讯的系统,MPI中默认的通讯系统为MPI_COMM_WORLD。但多数情况下会根据传输方向和内容自定义一些子系统,尤其是在集群通讯中。
Message中通常还会带有关于通讯状态的结构体变量: MPI_Status。 它包含了三个重要成员:
- MPI_SOURCE: Message源在当前通讯系统中的rank;
- MPI_TAG: 通讯标识;
- MPI_ERROR: 通讯中的错误信息。
这些参数都可以用.运算取得,有了这些参数在任何情况下,处理器都能清楚的知道数据发送源的信息。
Send
点对点通讯共有八种发送方式,包括四种阻塞式发送(blocking)MPI_Send,MPI_Ssend,MPI_Rsend,MPI_Bsend和四种非阻塞式(non-blocking)MPI_Isend,MPI_Issend,MPI_Irsend,MPI_Ibsend。阻塞式发送是指在发送指令执行后到数据传输结束之前,当前处理器处于阻塞状态,不会进行任何其他的运算。相对的,非阻塞模式则是在传输过程中,处理器还可以进行其他运算,因此需要额外的函数判断通讯是否结束,例如MPI_Test 和MPI_Wait。 MPI的Send函数调用方式通常为:
MPI_Send(datapointer, count, datatype, dest, tag, comm); MPI_Isend(datapointer, count, datatype, dest, tag, comm, request);
此外,还可以按照发送类型把他们分成如下四类:
- 标准型(Standard):MPI_Send和MPI_Isend,最基本的发送类型,会根据数据大小选择不同的发送方式。当数据长度小于规定阈值时,采用缓冲型;当数据较大时采用同步型。
- 同步型(Synchronous):MPI_Ssend和MPI_Issend,需要先进行握手,建立链接后进行发送。
- 立即型(Ready):MPI_Rsend和MPI_Irsend,不需要预先握手,只要有相关的接收命令,就可以发送。
- 缓冲型(Buffer):MPI_Bsend和MPI_Ibsend,需要用户通过MPI_Buffer_attach(buffer, buflen);手动创建一个缓冲区,而后将数据通过MPI_Bsend(data,count,type,dest,tag,comm);将数据发送到缓冲区,系统会在完成握手后自动将缓冲区中的数据发送出去。
这是一段完整的使用缓冲型发送函数的程序,注意在声明缓冲区长度时需要附加一个MPI的内建变量MPI_BSEND_OVERHEAD,它代表了整个Message中其他参数所需存储空间的最大值。
- int buflen=totlen*sizeof(double)+MPI_BSEND_OVERHEAD;
- double *buffer=malloc(buflen);
- MPI_Buffer_attach(buffer,buflen);
- MPI_Bsend(data,count,type,dest,tag,comm);
Receive
不同与发送函数的各种形式,接收函数只有阻塞式MPI_Recv和非阻塞式MPI_Irecv两种。接收函数的调用方式通常为:
MPI_Recv(data, count, datatype, source, tag, comm, status); MPI_Irecv(data, count, datatype, source, tag, comm, request);
这里面的status和request分别是MPI_Status和MPI_Request类型变量的地址,MPI_Status已经在前文提到过了,这里就只说一下MPI_Request。这个变量是用来表明当前通讯请求状态的,不论是非阻塞的发送还是接收,都需要将这个变量放入MPI_Wait和MPI_Test中判断传输是否结束,从而进行下一步操作或者结束程序。
注意:通常情况下,对于在同一个处理的的发送和接收函数会采用不同的MPI_Request变量,以免相互覆盖影响。
Send-Receive
点对点通讯中还包括了使用一个函数进行发送接收的MPI_Sendrecv和MPI_Sendrecv_replace,他们的调用方式如下:
MPI_Sendrecv(sendbuf, sendcount, sendtype, dest, sendtag, recvbuf, recvcount, recvtype, source, recvtag, comm, status); MPI_Sendrecv_replace(buff, count, datatype, dest, sendtag, source, recvtag, comm, status);
这两个函数主要是用在一连串阻塞传输中,因为在一连串的阻塞发送和阻塞接收过程中,发送和接收的顺序需要格外注意,以免造成锁死的现象。而这两个函数会自动避免锁死状况的出现,会降低编程的难度。 这两个函数的区别是第一个函数需要两个缓冲区,而第二个函数只需要一个。
单边通讯
MPI还提供了一类单边通讯的函数:MPI_Get和MPI_Put。它们采用异步方式对其他处理器的内存空间进行直接读写。
通讯:集群
集群通讯是指将一个定义好的处理器集群作为一个整体,在其中进行消息的传递。这种通讯方式通常是阻塞式的,需要在集群中的所有处理器都参与进来,并在完成操作后才能进行下一步的运算。集群通讯相比点对点更加智能,会最大限度的发挥并行处理的好处。
MPI_Barrier
在集群通讯中,最重要的问题就是同步,尽管多数的集群通讯函数都会自动进行同步,但是MPI还是提供了一个手动同步的函数int MPI_Barrier(MPI_Comm Comm)。这个函数的功能是让Comm集群中的所有处理器都运行到MPI_Barrier后才可以继续进行后续的运算。作为同步整个通讯集群的的函数,MPI_Barrier的主要用途是在手动debug和评估算法。
MPI_Barrier(Comm); time = MPI_Wtime(); /* 运行算法程序 */ MPI_Barrier(Comm); time = MPI_Wtime() - time;
MPI_Bcast
MPI_Bcast(buffer, count, datatype, root, comm);这个函数的主要功能是在整个集群中进行广播,将root中buffer的内容(长度为count)传播给comm集群中其他的处理器。下面的两段程序分别使用了MPI_Bcast和点对点通讯,实现的功能是完全一样的。需要注意的是,所有的处理器都是调用相同的MPI_Bcast函数以及参数,尤其是保持root参数都是同一个处理器的编号。
- /* Broadcast from processor 0 */
- if (rank == 0)
- a=999.999;
- MPI_Bcast(&a, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
- printf("Processor %2d received %f be broadcast from processor 0\n",rank ,a);
- /* Send from processor 0 */
- if (rank == 0) {
- a=999.999;
- MPI_Send(&a, 1, MPI_DOUBLE, (rank + 1), 111, MPI_COMM_WORLD);
- }
- else {
- MPI_Recv(&a, 1, MPI_DOUBLE, (rank - 1), 111, MPI_COMM_WORLD, &status);
- printf("Processor %d got %f from processor %d\n", rank, a, status.MPI_SOURCE);
- if (rank < size - 1) { /* The last processor only receive, not send */
- MPI_Send(&a, 1, MPI_DOUBLE, (rank + 1), 111, MPI_COMM_WORLD);
- i = 1;
- if (rank == 0)
- a = 999.999;
- while(i <= size) {
- if (rank < i) { /*send*/
- if (rank + i < size){
- MPI_Isend(&a, 1, MPI_DOUBLE, (rank + i), 1, MPI_COMM_WORLD, &sendRequest);
- MPI_Wait(&sendRequest, &status);
- }
- }
- else if (rank <= (2 * i - 1)){ /*receive*/
- MPI_Irecv(&a, 1, MPI_DOUBLE, (rank - i), 1, MPI_COMM_WORLD, &recvRequest);
- MPI_Wait(&recvRequest, &status);
- printf("Processor %2d got %f from pocessor %2d \n", rank, a, status.MPI_SOURCE);
- }
- i *= 2;
- }
MPI_Scatter
这个函数是专门用来等长度的均匀分割数组,并按顺序的分配的集群中的每个处理器中。调用方式为:
MPI_Scatter(sendbuff, sendcount, sendtype, recvcount, recvtype, root, comm);
其中,sendbuff只有在root处理器上才有效,这个函数的作用如下所示
P1 | A1 | A2 | A3 | A4 |
P2 | ||||
P3 | ||||
P4 |
MPI_Scatter → |
P1 | A1 | |||
P2 | A2 | |||
P3 | A3 | |||
P4 | A4 |
如果需要不等长度的分割,MPI还提供了一个函数MPI_Scatterv(sendbuff, sendcounts, displs, sendtype, recvbuf, recvcount, recvtype, root, comm)。其中,sendcounts是一个整型数组,表示发送给各个处理器的数据长度;displs也是整型数组,分别表示了每个发送数据在sendbuff中的起始位置;sendtype则存储了每一个发送数据的数据类型。其余的参数与MPI_Scatter基本一致。
MPI_Gather
和MPI_Scatter正好相反,MPI_Gather是将集群中所有处理器的数据整合到root上,调用方式与为MPI_Gather完全一样:
MPI_Gather(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, root, comm);
不同的是recvbuf只有在root处理器上面生效,函数作用为:
P1 | A1 | |||
P2 | A2 | |||
P3 | A3 | |||
P4 | A4 |
MPI_Gather → |
P1 | A1 | A2 | A3 | A4 |
P2 | ||||
P3 | ||||
P4 |
MPI_Allgather
这个函数的目的是将分散在各个处理器上的数据收集起来,并在每个处理器上都留有一个拷贝,如图:
P1 | A1 | |||
P2 | A2 | |||
P3 | A3 | |||
P4 | A4 |
MPI_Allgather → |
P1 | A1 | A2 | A3 | A4 |
P2 | A1 | A2 | A3 | A4 |
P3 | A1 | A2 | A3 | A4 |
P4 | A1 | A2 | A3 | A4 |
与之前类似,这个函数的调用方式:
MPI_Allgather(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm);
通常情况下这个函数相当于MPI_Gather+MPI_Bcast。
MPI_Alltoall
这个函数的功能如下图:
P1 | A1 | B1 | C1 | D1 |
P2 | A2 | B2 | C2 | D2 |
P3 | A3 | B3 | C3 | D3 |
P4 | A4 | B4 | C4 | D4 |
MPI_Alltoall → |
P1 | A1 | A2 | A3 | A4 |
P2 | B1 | B2 | B3 | B4 |
P3 | C1 | C2 | C3 | C4 |
P4 | D1 | D2 | D3 | D4 |
函数调用方法:
MPI_Alltoall(sendbuf, sendcount, sendtype, recvbuf, recvcount, recvtype, comm);
这个函数的主要用途就是在于运算矩阵转置和FFT。
MPI_Reduce
这个函数在并行计算中非常非常非常重要,它提供了一系列将分散数据集中的方法,如求和,求积,求最大最小值等,通常情况下这些运算也会内在的考虑到算法复杂度的问题,会尽量的降低运算时间复杂度。。这个函数的调用方式为:
MPI_Reduce(sendbuf, recvbuf, count, datatype. op, root, comm);
除op以外,其他参数应该都很熟悉了,这里就不多说。op指的是还原操作,例如MPI_SUM,MPI_PROD,MPI_MAX,MPI_MIN等预定义操作,同时,用户也可以通过MPI_Op_create(user_fn, commute, op)自定义操作,这其中user_fn是用户自定义的函数指针;commute是一个整型数,当它为真的时候(非0)表明自定义函数的两个输入可以交换位置,即遵守交换率,当它为假时(等于0),则不遵守。
对于用户自定义函数,需要遵守下面的函数原型规则:
typedef void MPI_User_function(void* invec, void* inoutvec, int *len, MPI_Datatype *datatype);这里 invec 和 inoutvec 都作为函数的输入变量进行二元运算(或者是二元数组运算),同时 inoutvec 又作为函数返回值,被二元运算结果重写。
MPI_Allreduce
不但进行还原运算,还会将结果分发给集群的全部处理器。
MPI_Allreduce(sendbuf, recvbuf, count, datatype, op, comm);