【并行程序设计导论】第3章:用MPI 进行分布式内存编程
文章目录
3.1 预备知识
现实理解:在多进程程序中,两个进程可以通过调用函数来进行通信:一个进程发送函数,另一个接收函数。我们将使用消息传递的实现称为消息传递接口(MPI)。MPI并不是一种新的语言,它定义了一个可以被C、C++和Fortran程序调用的函数库。
3.2 编译与执行
命令:
mpicc/mpic++ -o mpi[可执行文件名] mpi.c/mpi.cpp # 编译命令
mpirun/mpiexec -np [线程数] ./mpi[可执行文件] # 执行命令
打印来自进程问候语的MPI程序
#include <stdio.h>
#include <string.h>
#include <mpi.h>
const int MAX_STRING = 100;
int main(void)
{
char greeting[MAX_STRING];
int comm_sz;
int my_rank;
MPI_Init(NULL, NULL);
MPI_Comm_size(MPI_COMM_WORLD, &comm_sz);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
if(my_rank != 0)
{
sprintf(greeting, "Greetings from process %d of %d!", my_rank, comm_sz);
MPI_Send(greeting, strlen(greeting)+1, MPI_CHAR, 0, 0, MPI_COMM_WORLD);
}
else
{
printf("Greetings from process %d of %d!\n", my_rank, comm_sz);
for(int q=1; q<comm_sz; q++)
{
MPI_Recv(greeting, MAX_STRING, MPI_CHAR, q, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
printf("%s\n", greeting);
}
}
MPI_Finalize();
return 0;
}
3.3 MPI实现
我们通过调用一系列的MPI函数来实现多进程间的通信(MPI通信)
3.3.1 开始与结束:
(1) MPI_Init函数:
函数原型:int MPI_Init(int* argc_p,char*** argv_p){...}
参数解释:
①int* argc_p:
argc_p
是指向argc
的指针,是main
函数中参数的继承(一般NULL即可)。
②char*** argv_p:
argv_p
是指向argv
的指针,是main
函数中参数的继承(一般NULL即可)。
③返回值:
返回值为int
类型的错误码,在大部分情况下,我们忽略这些错码。亦可以利用`MPI_Error_string~函数,通过输入错误码返回错信息的方式得到错误信息。
函数作用:
初始化MPI。具体的:为MPI分配缓存,设置MPI中所有进程的号等。
(2) MPI_Finalize函数:
函数原型:int MPI_Finalize(void){...}
参数解释:
返回值:错误码
作用:释放MPI占用的资源
(3) MPI框架:
有了上述①MPI_Init,②MPI_Finalize两个函数,则我们可以初步得一个利用MPI程序设计的框架,即:
int main(int* argc,char** argv){
/*No MPI Calls Before This*/
MPI_Init(NULL,NULL);
....
MPI_Finalize();
/*No MPI Calls After This*/
return 0;
}
3.3.2 通信子Communicator
(1) 通信子
概念:通信子就是一个集合,一个由多个可以相互通信的进程组成的集合。
MPI通信子类型:
MPI_Comm
, 其值为:MPI_COMM_WORLD
(2) MPI_Comm_size函数
函数原型:int MPI_Comm_size(MPI_Comm comm,int* Comm_sz_p){...}
参数解释:
①MPI_Comm
comm
: (输入)
comm
是MPI通信子的名称,其名称为MPI_COMM_WORLD不变。
②int* comm_sz_p: (输出)
comm_sz_p
是一个指针,用来返回通信子大小(进程总数)。
③返回值: (输出)
错误码。
函数作用:
用
comm_sz_p
来返回 名叫comm
的通信子大小
(3) MPI_Comm_rank函数
函数原型:int MPI_Comm_rank(MPI_Comm comm,int* my_rank_p){...}
参数解释:
①MPI_Comm
comm
(输入)
同上,通信子名称:MPI_COMM_WORLD
②int*my_rank_p
(输出)
my_rank_p
是一个指针,用来返回当前调用此函数的进程在通信子中的序号。
③返回值:错误码。 (输出)
函数作用:
用
my_rank_p
来返回调用此函数的进程在名叫comm
的通信子中的序号。
(4) MPI框架:
有了通信子的新知识,我可以在1-(3)MPI框架的基础上扩展为一个更细的框架。由于我们在MPI调用中,不可避免的要大量使用通信子名(MPI_COMM_WORLD
),通信子大小(comm_sz
),当前进程号(my_rank
),则我们将MPI框架构造如下:
#include <stdio.h>//C语言基本头文件
#include <mpi.h>//MPI头文件
/*---设置全球变量----*/
MPI_Comm comm;
int comm_sz;
int my_rank;
/*---SET GLOBAL VARIABLE---*/
int main(int argc,char** argv){
/*No MPI calls before this*/
MPI_Init(NULL,NULL);
comm = MPI_COMM_WORLD;//Set comm
MPI_Comm_size(comm,&comm_sz);//get communicator size
MPI_Comm_rank(comm,&my_rank);//get this processs rank
...//you can coding whatever you want
MPI_Finalize();
/*No MPI calls after this*/
}
3.3.3 点对点通信
(1) MPI_Send函数:
函数原型:
int MPI_Send(
void* msg_buf_p, /* in */
int msg_size, /* in */
MPI_Datatype msg_type, /* in */
int dest, /* in */
int tag, /* in */
MPI_Comm communicator /* in */
){...}
参数解释:
①void*
msg_buf_p
:
消息的地址。
②intmsg_size
:
消息的大小(字节数)。
③MPI_Datatyoemsg_type
:
MPI自己的数据类型。
④intdest
:
发送消息的目的地,也就是接收进程的进程号。
⑤inttag
:
标签,用于区分多个消息。随意设置(如0、1、2…),但MPI_Send与MPI_Recv的tag值相等接收成功。
⑥MPI_Commcommunicator
:
通信子名字。
⑦返回值:错误码。
函数作用:
当前调用此函数的进程,将
msg_buf_p
指向的内存中msg_size
大小的msg_type
类型的消息,并打上tag
标签后,发给名叫communicator
通信子中序号为dest
的进程。
(2) MPI_Recv函数:
函数原型:
int MPI_Recv(
void* msg_buf_p, /* out */
int buf_size, /* in */
MPI_Datatype buf_type, /* in */
int source, /* in */
int tag, /* in */
MPI_Comm communicator, /* in */
MPI_Status* status_p /* out */
){...}
参数解释:
①void*
msg_buf_p
:
接收到的消息要存放的地址。
②intbuf_size
:
消息存放区的大小(字节数)。
③MPI_Datatypebuf_type
:
消息存放区的数据类型。
④intsource
:
接收消息的来源地,也就是发送这个消息的进程的进程号。注意,只有在MPI_Recv函数中才存在一个特殊的MPI常量:MPI_ANY_SOURCE意为可以接收任意来源进程的信息。
⑤inttag
:
标签,用于区分多个消息。随意设置(如0、1、2…),但MPI_Send与MPI_Recv的tag值相等接收成功。注意,只有在MPI_Recv函数中才存在一个特殊的MPI常量:MPI_ANY_TAG意为可以接收任意标签值的信息。
⑥MPI_Commcommunicator
:
通信子名字。
⑦MPI_Status*status_p
:
大部分情况下,我们并不使用这个参数,通常用特殊的MPI常量MPI_STATUS_IGNORE
填写参数。更深一步:该参数实际上存放有来源地相关的信息,可以用MPI_Get_count
函数来得到。
⑧返回值:错误码。
函数作用:
当前调用此函数的进程接收来自名为
communicator
通信子中进程号为source
的进程发来的消息,并将此消息存放在msg_buf_P
指向的buf_type
类型且大小为buf_size
的内存区域中。
(3) MPI_Datatype类型
MPI 数据类型 | C语言数据类型 | MPI 数据类型 | C语言数据类型 |
---|---|---|---|
MPI_CHAR | signed char | MPI_UNSIGNED | unsigned int |
MPI_SHORT | signed short int | MPI_UNSIGNED_LONG | unsigned long int |
MPI_INT | signed int | MPI_FLOAT | float |
MPI_LONG | signed long int | MPI_DOUBLE | double |
MPI_LONG_LONG | signed long long int | MPI_LONG_DOUBLE | long double |
MPI_UNSIGNED_CHAR | unsigned char | MPI_BYTE | |
MPI_UNSIGNED_SHORT | unsigned short int | MPI_FACKED |
(4) MPI_send与MPI_recv的作用机理
- MPI_Recv是阻塞型(挂起型),必须要有与之配对的MPI_Send函数在MPI缓存中,MPI_Recv才会激活工作。
- 更进一步:同一组收发配对进程调用MPI_Send函数与MPI_recv有严格的顺序关系,如:进程1先Send一个A消息到进程2,再Send一个B消息到进程2,那么进程2一定是先接收A再接收B,无论进程2的MPI_Recv的顺序如何。
- 更进一步:MPI_Send函数,是将消息传递到MPI缓存中便返回,而MPI_Recv函数是进程成功接收到消息后才返回。
(5) status_p参数
MPI类型的MPI_Stasus是一个拥有至少三个成员的结构:MPI_SOURCE
、MPI_TAG
和MPI_ERROR
。
(6) MPI_Get_count函数
函数原型:MPI_Get_count(&status, recv_type, &count)
int MPI_Get_count(
MPI_Status* status_p /* in */
MPI_Datatype type /* in */,
int* count_p /* out */
)
函数作用:
根据
status
和datatype
,查询实际接受到了数据个数保存在*count
中。
(7) MPI_Probe函数
函数原型:
MPI_Probe(
int source,
int tag,
MPI_Comm comm,
MPI_Status* status
)
函数作用:
可以作为 MPI_Recv 的预热,通过 status 确定收到的数据大小之后,再分配准确的内存来用 MPI_Recv 接受数据。
每个进程只是打印一条消息
#include <stdio.h>
#include <mpi.h>
int main(void)
{
int my_rank, comm_sz;
MPI_Init(NULL, NULL);
MPI_Comm_size(MPI_COMM_WORLD, &comm_sz);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
printf("Proc %d of %d > Does anyone have a toothpick?\n", my_rank, comm_sz);
MPI_Finalize();
return 0;
}
3.3.4 集合通信
(1) 广播:MPI_Bcast函数
函数原型:
int MPI_Bcast(
void* data_buf_p, /* in/out */
int count, /* in */
MPI_Datatype datatype, /* in */
int source, /* in */
MPI_Comm communicator /* in */
){...}
参数解释:
①void* data_buf_p
要广播的信息的存放地
②int count
要广播的信息的大小
③MPI_Datatype datatype
MPI数据类型
④int source
广播者的进程号
⑤MPI_Comm communicator
通信子名。
⑥返回值:错误码。
函数作用:
名为
communicator
通信子中序号为source
的进程向通信子中所有的进程传递消息,此消息是存放放在source
进程的data_buf_p
所指向的内存空间,且消息大小为count
,消息类型为datatype
;接受广播的其余所有进程便会将此条消息存放在本地的data_buf_p
所指向的内存空间中。
注意事项:
这里发送与接收的消息均为各个进程中自己的data_buf_p所指向的内存空间,所以在调用此函数之前,要确保通信子中的每一个进程都有适配的data_buf_p所指向的内存空间。
(2) 均匀分发(均匀散射): MPI_Scatter函数
函数原型:
int MPI_Scatter(
void* send_buf_p, /* in */
int send_count, /* in */
MPI_Datatype send_type, /* in */
void* recv_buf_p, /* out */
int recv_count, /* in */
MPI_Datatype recv_type, /* in */
int source, /* in */
MPI_Comm communicator /* in */
参数解释:
①void* send_buf_p
要分发的数据的存放地
②int send_count
要分发的数据的总大小
③MPI_Datatype datatype
MPI数据类型
④void* recv_buf_p
指向数据接收的内存空间
⑤inte recv_cound
接收的数据大小。注意,由于此函数是均匀分发,所以其实此参数的值应该为recv_count = send_count/comm_sz(comm_sz是通信子的大小即总进程数)
⑥MPI_Datatype recv_type
接收的数据类型。显然,recvtype应该与send_type一致
⑦int source
分发数据的源进程的序号,即是此进程将会分发send_buf_p指向的数据
⑧MPI_Comm communicator
老朋友了,通信子的名字
函数作用:
将名为
Communicator
通信子中的序号为source
的进程的send_buf_p
所指向的内存空间中的send_type
类型且大小为send_count
的数据,平均分发给communicator
通信子中的每一个进程(包括分发源进程),同时每一个进程将接收到的数据存入recv_buf_p
所指向的内存空间中,这些数据大小为recv_count
,类型为recv_type
。
注意事项:
由于含有接收参数,所以在调用此函数时,必须确保每一个进程都能调用到该函数,所有进程应该调用参数一模一样的该函数以确保不会报错。
(3) 均匀聚集: MPI_Gather函数
函数原型:
int MPI_Gather(
void* send_buf_p, /* in */
int send_count, /* in */
MPI_Datatype send_type, /* in */
void* recv_buf_p, /* out */
int recv_count, /* in */
MPI_Datatype recv_type, /* in */
int dest, /* in */
MPI_Comm communicator /* in */
){...}
参数解释:
①void* send_buf_p
要聚集的数据的存放地
②int send_count
要聚集的数据的总大小
③MPI_Datatype datatype
MPI数据类型
④void* recv_buf_p
指向数据接收的内存空间
⑤inte recv_cound
接收的数据大小。注意,由于此函数是均匀聚集,所以其实此参数的值应该为 recv_count = send_count*comm_sz(comm_sz是通信子的大小即总进程数)
⑥MPI_Datatype recv_type
接收的数据类型。显然,recvtype应该与send_type一致
⑦int dest
聚集数据的目的地进程的序号,即是要将各个进程的数据向此进程的汇聚
⑧MPI_Comm communicator
老朋友了,通信子的名字
函数作用:
将名为
communicator
通信子中每一个进程(包括聚集目的地进程)的send_buf_p
所指向的内存空间中send_type
类型大小为send_count
的数据向dest
号进程汇聚,dest
号进程将收到数据按发送方的进程号作为偏移量,存储在recv_buf_p
指向的recv_type
类型且大小为recv_count
大小的内存空间中。
注意事项:
与MPI_Scatter函数一样,此均匀聚集函数也必须确保每一个进程能调用到一模一样的MPI_Gather函数。
(4) MPI_Allgather函数
函数原型
int MPI_Allather(
void* send_buf_p, /* in */
int send_count, /* in */
MPI_Datatype send_type, /* in */
void* recv_buf_p, /* out */
int recv_count, /* in */
MPI_Datatype recv_type, /* in */
MPI_Comm communicator /* in */
){...}
参数解释与MPI_Gather函数一致。
函数作用:
这个函数将每个进程的send_buf_p 内容串联起来,存储到每个进程的recv_buf_p 参数中。通常,
recv_count
指每个进程接收的数据量。
(5) MPI_Gatherv函数
函数原型:
int MPI_Gatherv(
const void* sendbuf, /* in */
int sendcount, /* in */
MPI_Datatype sendtype, /* in */
void* recvbuf, /* out */
const int recvcounts[], /* in */
const int displs[], /* in */
MPI_Datatype recvtype, /* in */
int root, /* in */
MPI_Comm comm /* in */
)
参数解释:
①const void* sendbuf
发送缓冲区的起始地址
②int sendcount
发送缓冲区中的元素数(非负整数)
③MPI_Datatype sendtype
发送缓冲区元素(句柄)的数据类型
④void* recvbuf
接收缓冲区的地址(可选,仅在根目录有效)
⑤const int recvcounts[]
非负整数数组(长度组大小) ,包含从每个进程接收的元素数(仅在根目录有效)
⑥const int displs[]
整数数组(长度组大小)。条目 i 指定相对于 recvbuf 的位移,在此位置放置来自进程 i 的传入数据(仅在根目录有效)
函数作用:
当每个节点传递的数据长度不一时,采用这个函数。
(6) MPI_Barrier
函数原型:MPI_Barrier(MPI_Comm communicator)
函数作用:
这个方法会构建一个屏障,任何进程都没法跨越屏障,直到所有的进程都到达屏障
3.3.5 集合操作通信
(1) MPI_Reduce函数
函数原型:
MPI_Reduce(
void* input_data_p, /* in */
void* output_data_P, /* out */
int count, /* in */
MPI_Datatype datatype, /* in */
MPI_Op operator, /* in */
int dest, /* in */
MPI_Comm communicator /* in */
)
参数解释:
①void* input_data_p
每一个进程要输入的数据存放地。
②void* output_data_p
输出结果的存放地。
③int count
操作次数,注意:如果count>1,那么MPI_Reduce函数可以应用到数组上(配合input_data_p指向一个数组,output_data_p指向一个数组)。
④MPI_Datatype datatype
MPI数据类型。
⑤MPI_Op operator
要执行的操作名。
⑥int dest
MPI将操作过后的结果传入的目的地进程的序号
⑦MPI_Comm communicator
通信子名。
⑧返回值:错误码。
函数作用:
count=1
时:名为communicator
通信子中所有进程中(包括目的地进程)input_data_p
指向的datatype
类型的数据存放地中的第1
个数据,全部汇集到MPI缓存中,MPI对它们执行operator
操作,得到的结果再传入dest
号进程的output_data_p
指向的区域中。count=k
时:在count=k-1
的基础上,communicator
通信子中所有进程中(包括目的地进程)intput_data_p
指向的datatype
类型的数据存放地中的第k
个数据,全部汇集到MPI缓存中,MPI对它们执行operator
操作,得到的结果再传入dest
号进程的output_data_p
指向的区域中的第k
个位置。
(2) MPI_Allreduce函数
函数原型:
MPI_Allreduce(
void* input_data_p, /* in */
void* output_data_p, /* out */
int count, /* in */
MPI_Datatype datatype, /* in */
MPI_Op operator, /* in */
MPI_Comm communicator /* in */
)
参数解释:
①void* input_data_p
每一个进程要输入的数据存放地。
②void* output_data_p
输出结果的存放地。
③int count
操作次数,注意:如果count>1,那么MPI_Allreduce函数可以应用到数组上(配合input_data_p指向一个数组,output_data_p指向一个数组)。
④MPI_Datatype datatype
MPI数据类型。
⑤MPI_Op operator
要执行的操作名。
⑥MPI_Comm communicator
通信子名。
⑦返回值:错误码。
函数作用:
count=1
时:名为communicator
通信子中所有进程中intput_data_p
指向的datatype
类型的数据存放地中的第1
个数据,全部汇集到MPI缓存中,MPI对它们执行operator
操作,得到的结果再传入每一个进程的output_data_p
指向的区域中。count=k
时:在count=k-1
的基础上,communicator
通信子中所有进程中intpu_data_p
指向的datatype
类型的数据存放地中的第k
个数据,全部汇集到MPI缓存中,MPI对它们执行operator
操作,得到的结果再传入每一个进程的output_data_p
指向的区域中的第k
个位置。
(3) MPI_Op
操作类型:
MPI_MAX
- 最大 - maximumMPI_MIN
- 最小 - minimumMPI_SUM
- 求和 - peaceMPI_PROD
- 乘积 - productMPI_LAND
- 逻辑与 - logic andMPI_LOR
- 逻辑或 - logic orMPI_BAND
- 位运算的“与” And of-bit operationMPI_BOR
- 位运算的“或” Or of-bit operationMPI_MAXLOC
- 最大值和拥有该值的进程的 rankMPI_MINLOC
- 最小值和拥有该值的进程的 rank
(4) 集合通信与点对点通信
区别:
- 在通信子中的所有进程都必须调用相同的集合通信函数。
- 每个进程传递给MPI 集合通信函数的参数必须是“相容的”。
- 参数output_data_p 只用在dest_process 上。然而,所有进程仍需要传递一个与output_data_p 相对应的实际参数,即使它的值只是NULL 。
- 点对点通信函数是通过标签和通信子来匹配的。集合通信函数不使用标签,只通过通信子和调用的顺序来进行匹配。
#include <stdio.h>
#include <string.h>
#include <mpi.h>
const int MAX_STRING = 100;
int main(void)
{
char greeting[MAX_STRING];
int comm_sz;
int my_rank;
int a, b, c, d;
MPI_Init(NULL, NULL);
MPI_Comm_size(MPI_COMM_WORLD, &comm_sz);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
if(my_rank == 0){
a = 1;
c = 2;
MPI_Reduce(&a, &b, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
MPI_Reduce(&c, &d, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
printf("a=%d, b=%d, c=%d, d=%d\n", a, b, c, d);
}
if(my_rank == 1){
a = 1;
c = 2;
MPI_Reduce(&c, &d, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
MPI_Reduce(&a, &b, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
}
if(my_rank == 2){
a = 1;
c = 2;
MPI_Reduce(&a, &b, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
MPI_Reduce(&c, &d, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
}
MPI_Finalize();
return 0;
}
3.3.6 MPI派生数据类型
(1) 背景
用多条消息发送一定量的数据明显比只用一条消息发送等量数据的开销更大。MPI提供了三种手段来整合可能需要多条消息才能交换的数据。
①count参数;
②派生数据类型;
③MPI_Pack与MPI_Unpack函数。
目前我们已经能熟练使用count参数,现在我们来了解MPI派生数据类型,至于MPI打包/解包函数暂时用得不多,可以自己去了解。
(2) 实现:MPI_Type_create_struct函数
函数原型:
int MPI_Type_create_struct(
int count, /* in */
int array_of_blocklengths[], /* in */
MPI_Aint array_of_displacements[], /* in */
MPI_Datatype array_of_types[], /* in */
MPI_LDatatype new_type_p /* out */
)
参数解释:
①int count
数据类型中元素的个数,块的数目(整数)
②int array_of_blocklengths[]
每个块中的元素数(整数数组)
③MPI_Aint array_of_displacements[]
指定了距离消息起始位置的偏移量,单位为字节。
可以使用MPI_Get_address 函数找到其值,它返回的是location_p 所指向的内存单元的地(代码如下):
int MPI_Get_address(
void* location_p /* in */,
MPI_Aint* address_p /* out */
)
④MPI_Datatype array_of_types[]
每个块中元素的类型(数据类型对象的句柄数组)
⑤MPI_LDatatype new_type_p
新数据类型(句柄)
在通信函数中使用新数据类型之前,用一个函数去指定它:
int MPI_Type_commit(MPI_Datatype* new_mpi_t_p)
在构造新的数据类型的过程中,MPI实现可能要在内部分配额外的存储空间,可以用一个函数去释放额外的存储空间:
int MPI_Type_free(MPI_Datatype* old_mpi_t_p)
3.3.7 计时
下面这一段代码可用于对一段MPI程序进行计时并报告运行时间:
double local_start,local_finish,local_elapsed,elapsed;
...
MPI_Barrier(comm);//能够确保同一通信子中的所有进程都完成调用该函数之前,没有进程能够返回
local_start = MPI_Wtime();//MPI_Wtime此函数记录之前某一时刻到此时所经历的时间
...
local_finish = MPI_Wtime();//所有MPI_Wtime函数的时间开始时刻在同一进程中是同一时刻
local_elapsed = local_finish-local_start;
MPI_Reduce(&local_elapsed,&elapsed,1,MPI_DOUBLE,MPI_MAX,0,comm);
if(my_rank==0){
printf("%e\n",elapsed);
}
注:仅用于自己复习,不作他用。