一、MPI简介
- 消息传递接口(MPI, Massage Passing Interface),是一种编程接口标准,而不是一种具体的编程语言。简而言之,MPI标准定义了一组具有可移植性的编程接口,提供与C和Fortran语言的绑定。
- MPI主要用于实现分布式计算中多进程之间通信,与共享内存的并行方式不同,MPI通过消息通信机制实现并行化,并行计算粒度更大,可扩展性更好,更加适合大规模的并行计算,是并行计算中非常重要的程序设计方式。
- 通过MPI程序设计,可以实现SPMD编程、MPMD编程、主/从编程等多种分布式编程模式。
二、MPI程序的基本结构和运行
1. MPI程序的基本结构
#include <mpi.h>
int main(int argc, char* argv[]){
int my_rank, comm_sz;
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &comm_sz);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
/*
每个进程执行的任务
MPI_Send(...);
MPI_Recv(...);
*/
MPI_Finalize();
}
2. MPI程序的运行(Linux环境 Shell命令行)
(1)首先安装MPI:
sudo apt-get install libopenmpi-dev
sudo apt-get install openmpi-bin
(2)写好c++文件后编译:
mpicc -g -Wall -o mpi_hello mpi_hello.c
# -g: 生成调试信息
# -Wall: 显示所有warning
# -o: 命名可执行文件
(3)运行可执行文件:
mpiexec -n 4 ./mpi_hello
# -n: 使用n个进程运行并行程序
三、MPI基本函数语法
1. MPI程序的开始和结束
// 启动MPI环境,标志并行代码的开始
MPI_Init(&argc, &argv);
// 标志并行代码的结束,结束除主进程外的其他进程
MPI_Finalize(void);
2. 获取当前进程信息
// 获取通信子中的进程数量
MPI_Comm_size(comm, &comm_sz);
// 获取本进程在通信子进程组中的逻辑编号(从0开始)
MPI_Comm_rank(comm, &my_rank);
3. 点对点通信
(1)阻塞通信:缓冲区仅在发送/接收指令完成返回后,才可再次使用,否则其他消息只能排队等候。
MPI_Send(msg, msg_size, msg_type, destination, tag, comm);
MPI_Recv(msg, msg_size, msg_type, source, tag, comm, status);
-
参数分别表示(消息内容,消息大小,消息类型,消息来源/目的地,消息标签,通信子),可以看作(消息数据+消息信封)结构。
-
消息类型必须是MPI预定义数据类型:
-
status参数可以获取被接受消息的来源、标签,通过MPI_Get_count函数可以获取消息长度:
typedef struct {
...,
int MPI_SOURCE,
int MPI_TAG,
int MPI_ERROR,
...
} MPI_Status
int MPI_Get_count(
MPI_Status* status /* in */
MPI_Datatype datatype /* in */
int* count /* out */)
- source = MPI_ANY_SOURCE 表示接收任意来源
- tag = MPI_ANY_TAG 表示接收任意标签
- 若 source == destination 会导致死锁
- 接收缓冲区的大小一定要大于等于发送数据的长度
(2)非阻塞通信:可将通讯交由后台处理,通信与计算可重叠
MPI_Isend(msg, msg_size, msg_type, destination, tag, comm, request);
MPI_Irecv(msg, msg_size, msg_type, source, tag, comm, request);
- 可以通过MPI_Wait()和MPI_Test()来判断通信是否已经完成:
int MPI_Wait(
MPI_Request* request /* in/out */,
MPI_Status* status /* out */)
int MPI_Test(
MPI_Request* request /* in/out */,
int* flag /* out */,
MPI_Status* status /* out */)
当request标识的通信结束后,MPI_Wait才返回。
MPI_Test不论通信是否完成,立即返回,若通信完成则flag=true,否则flag=false。
- 可以通过MPI_Probe()和MPI_Iprobe()探测接收消息的内容:
int MPI_Probe(
int source /* in */,
int tag /* in */,
MPI_Comm comm /* in */,
MPI_Status* status /* out */)
int MPI_Iprobe(
int source /* in */,
int tag /* in */,
MPI_Comm comm /* in */,
int* flag /* out */,
MPI_Status* status /* out */)
MPI_Probe()为阻塞型探测,直到有一个符合条件的消息到达才返回。
MPI_Iprobe()为非阻塞型探测,无论是否有一个符合条件的消息到达都返回,若符合条件flag=true,否则flag=false。
在不知道消息的来源/标签/大小时,可通过提前探测,决定用什么语句接收及接收后的操作。
- 可以使用MPI_Request_free()函数在通信结束前强行释放request对象:
MPI_Request_free(
MPI_Request* request /* in/out */)
- 可以通过MPI_Cancel()和MPI_Test_cancelled()取消或测试一个尚未完成的通信请求:
int MPI_Cancel(
MPI_Request* request /* in */)
int MPI_Test_cancelled(
MPI_Status* status /* in */,
int* flag /* out */)
MPI_Cancel函数用于取消一个被挂起的通信函数,代价通常昂贵,尽量少用。
MPI_Test_cancelled用于测试一个通信请求是否已经取消,若已经取消则flag=true,否则flag=false。
- rank = MPI_PROC_NULL 的进程称为空进程。
在send或recv函数中使用MPI_PEOC_NULL会立即成功返回,但没有通信操作,缓冲区不发生任何改变。空进程一般可以用来简化边界处理的代码。
4. 组通信(Collective Communication)函数
-
与点对点通信不同,参加组通信的进程都必须调用同一条通信函数。组通信函数大致可分为三类:
(1)同步函数
(2)数据移动函数:广播、收集、alltoall等
(3)组计算函数:执行规约操作 -
常用的组通信函数:
(1)MPI_Reduce函数: 规约函数,将当前进程input_data_p数据发送给dest_process,若当前进程为dest_process,则依次接收所有消息,并将所有收到的数据执行规约操作后存到output_data_p内存块中。实际执行时以树状结构优化计算。其中output_data_p仅在dest_process进程中有效。
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_process /* in */,
MPI_Comm comm /* in */);
MPI_Allreduce函数: MPI_Reduce后将结果发送到每个进程。
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 comm /* in */);
MPI中的规约操作:
MPI_Reduce树状计算过程:
MPI_Allreduce树状计算过程:
MPI_Allreduce蝶形计算过程:
(2)MPI_Bcast函数: 广播函数,将进程source_proc中的data_p数据广播到通信子comm的每个进程中。每个进程都需要提前声明一个名为data_p、类型为datatype的变量,用来接收数据。广播过程可以采用树状通信等结构。
MPI_Bcast(
void* data_p /* in/out */,
int count /* in */,
MPI_Datatype datatype /* in */,
int source_proc /* in */,
MPI_Comm comm /* in */);
实例:一种基于MPI_Bcast的MPI程序输入函数:
void Get_input(
int my_rank /* in */,
int comm_sz /* in */,
double* a_p /* out */,
double* b_p /* out */,
int* n_p /* out */) {
if (my_rank == 0){
printf("Enter a, b, and n: \n");
scanf("%lf %lf %d", a_p, b_p, n_p);
}
MPI_Bcast(a_p, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
MPI_Bcast(b_p, 1, MPI_DOUBLE, 0, MPI_COMM_WORLD);
MPI_Bcast(n_p, 1, MPI_INT, 0, MPI_COMM_WORLD);
}
(3)MPI_Scatter函数: 分发(散射)函数,分割+发送,将数据分成多个部分,分别发送给其他进程。主要用于对向量进行数据分割。
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_rtpe /* in */,
int src_proc /* in */,
MPI_Comm comm /* in */);
三种向量划分方式:
块划分 vs. 循环划分 vs. 块 - 循环划分
实例:一种基于MPI_Scatter的MPI程序输入函数(输入向量):
void Read_vector(
double local_a[] /* out */,
int local_n /* in */,
int n /* in */,
char vec_name[] /* in */,
int my_rank /* in */,
MPI_Comm comm /* in */)
{
double* a = NULL;
int i;
if (my_rank == 0) {
a = malloc(n * sizeof(double));
printf("Enter the vector %s: \n", vec_name);
for (i = 0; i < n; i++)
scanf("%lf", &a[i]);
MPI_Scatter(a, local_n, MPI_DOUBLE, local_a, local_n, MPI_DOUBLE, 0, comm);
free(a);
} else {
MPI_Scatter(a, local_n, MPI_DOUBLE, local_a, local_n, MPI_DOUBLE, 0, comm);
}
}
(4)MPI_Gather: 聚集函数,从通信子comm的其他进程中分别获取数据的一部分,在dest_proc进程中整合为完整的数据。其中recv_buf_p参数仅在dest_proc中有效。
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_proc /* in */,
MPI_Comm comm /* in */);
MPI_Allgather: 全局聚集函数,MPI_Gather + 广播,先执行聚集函数,然后将完整的数据广播给每个进程。
MPI_Allgather(
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 comm /* in */);
实例一:一种基于MPI_Gather的打印分布式向量函数:
void Print_vector(
double local_b[] /* in */,
int local_n /* in */,
int n /* in */,
char title[] /* in */,
int my_rank /* in */,
MPI_Comm comm /* in */)
{
double* b = NULL;
int i;
if (my_rank == 0) {
b = malloc(n * sizeof(double));
MPI_Gather(local_b, local_n, MPI_DOUBLE, b, local_n, MPI_DOUBLE, 0, comm);
printf("%s\n", title);
for (i=0; i < n; i++)
printf("%f ", b[i]);
printf("\n");
free(b);
} else {
MPI_Gather(local_b, local_n, MPI_DOUBLE, b, local_n, MPI_DOUBLE, 0, comm);
}
}
实例二:一种基于MPI_Allgather的矩阵(向量)乘法函数:
void Mat_vect_mult(
double local_A[] /* in */,
double local_x[] /* in */,
double local_y[] /* out */,
int local_m /* in */,
int n /* in */,
int local_n /* in */,
MPI_Comm comm /* in */)
{
double* x;
int local_i, j;
int local_ok = 1;
x = malloc(n * sizeof(double));
MPI_Allgather(local_x, local_n, MPI_DOUBLE, x, local_n, MPI_DOUBLE, comm);
for (local_i=0; local_i < local_m; local_i++) {
local_y[local_i] = 0.0;
for (j=0; j < n, j++)
local_y[local_i] += local_A[local_i * n + j] * x[j];
}
free(x);
}
(5)MPI_Alltoall: 全交换函数,每个进程都会向每个接收方发送不同的数据。将发送缓冲区和接收缓冲区的数据分别分割成多个数据块,将进程i的发送缓冲区中的第j块数据发送给进程j,进程j将接收到的来自进程i的数据块放在自身接收缓冲区的第i块位置。可以看作是MPI_Allgather函数的扩展。
MPI_Alltoall(
void* sendbuf /* in */,
int sendcount /* in */,
MPI_Datatype sendtype /* in */,
void* recvbuf /* out */,
int recvcount /* out */,
MPI_Datatype recvtype /* out */,
MPI_Comm comm /* out */
);
(6)MPI_Barrier: 同步函数,MPI唯一的一个同步函数,如果有一个进程没有执行该函数,其余进程将处于等待状态,所有进程执行完这个函数后,同时执行其后的任务。
int MPI_Brrier(MPI_Comm comm /* in */)
5. MPI派生数据类型(MPI Derived Datatypes)
- MPI派生数据类型用于表示一组数据项,其中数据项的基本数据类型可相同也可不同,类似于python中的数组(list)和c中的结构体(struct)。
- 使用派生数据类型的目的是将其他进程使用的多个数据项一次性发送(或广播),而不是分别发送多次,因为多条消息的通信成本远远大于整合不同数据类型所需要的内存成本,所以一次性发送能够提升程序的性能。
- MPI提供的整合多条消息的手段主要有三个:使用count参数(同一数据类型)、MPI_Pack/MPI_Unpack函数、派生数据类型MPI_Type_create_struct函数。这里只讨论第三种。
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_Datatype* new_type_p /* out */);
/*
参数说明:
count:派生数据类型中的元素个数
array_of_blocklengths:派生数据类型中每个元素的长度(允许嵌套数组)
array_of_displacements:派生数据类型中每个元素的起始地址相对于第一个元素起始地址的偏移量
array_of_types:派生数据类型中每个元素的基本数据类型
new_type_p:输出的新派生数据类型的指针
*/
在使用一个派生数据类型之前,需要一些准备工作:
- 通过MPI_Get_address函数获得每个元素的起始地址,然后依次将每个元素地址减去第一个元素地址,得到每个元素的地址偏移量(第一个元素为0)
MPI_Get_address(
void* location_p /* in */,
MPI_Aint* address_p /* out */);
- 调用MPI_Type_create_struct()创建派生数据类型。
- 在使用通信函数中的派生数据类型之前,必须先用一个函数调用去指定它:
MPI_Type_commit(MPI_Datatype* mpi_t_p);
- 使用派生数据类型(MPI_Bcast, MPI_Send等通信操作)
- 新的派生数据类型初始化完成可以使用之后,可以将原先的数据项变量和初始化过程中产生的额外存储空间释放:
MPI_Type_free(MPI_Datatype* old_mpi_t_p /* in/out */);
实例:使用派生数据类型的Get_input()函数:
void Build_mpi_type(
double* a_p /* in */,
double* b_p /* in */,
int* n_p /* in */,
MPI_Datatype* input_mpi_t_p /* out */) {
int array_of_blocklengths[3] = {1, 1, 1};
MPI_Datatype array_of_types[3] = {MPI_DOUBLE, MPI_DOUBLE, MPI_INT};
MPI_Aint a_addr, b_addr, n_addr;
MPI_Aint array_of_displacements[3] = {0};
MPI_Get_address(a_p, &a_addr);
MPI_Get_address(b_p, &b_addr);
MPI_Get_address(n_p, &n_addr);
array_of_displacements[1] = b_addr - a_addr;
array_of_displacements[2] = n_addr - a_addr;
MPI_Type_create_struct(3, array_of_blocklengths, \
array_of_displacements, array_of_types, input_mpi_t_p);
MPI_Type_commit(input_mpi_t_p);
}
void Get_input(
int my_rank /* in */,
int comm_sz /* in */,
double* a_p /* in */,
double* b_p /* in */,
int* n_p /* in */) {
MPI_Datatype input_mpi_t;
Build_mpi_type(a_p, b_p, n_p, &input_mpi_t);
if (my_rank == 0) {
printf("Enter a, b and n:\n");
scanf("%lf %lf %d", a_p, b_p, n_p);
}
MPI_Bcast(a_p, 1, input_mpi_t, 0, MPI_COMM_WORLD);
MPI_Type_free(&input_mpi_t);
}
6. 程序性能评估
(1)MPI_Wtime() 函数的使用
double start, finish;
...
start = MPI_Wtime();
/* code to be timed */
finish = MPI_Wtime();
printf("Proc %d > Elapsed time = %e seconds\n", my_rank, finish - start);
与使用timer中GET_TIME()函数等价,可任选择其一使用:
#include <timer.h>
...
double start, finish;
...
GET_TIME(start);
/* code to be timed */
GET_TIME(finish);
printf("Elapsed time = %e seconds\n", finish - start);
(2)并行程序性能评估标准
- 加速比(Speedup):S(n) = T(串行) / T(并行),数值越接近进程数p越好。
- 效率(Efficiency):E(n) = S(n) / p = T(串行) / (T(并行) * p),数值越接近1越好。
- 可拓展性(Scalability):随着当问题规模扩大时,当增加进程数量时,程序运行的效率/加速比是否能够保持不下降或保持不变。如果在增加进程/线程的个数时,不需要增加问题的规模就可以维持固定的效率,则程序是强可扩展的;如果在增加进程/线程个数时,只有以相同倍率增加问题的规模才能使效率保持不变,则程序是弱可扩展的。
7. MPI程序的安全性
- 定义:当部分或全部进程之间的阻塞通信(MPI_Send/MPI_Recv)形成一个等待环时,会发生死锁,这种依赖于MPI自身提供的缓冲机制是不安全的。需要注意的是,非阻塞通信只是能够将通信在缓冲区的等待时间充分利用起来进行计算,而并不能解决死锁的问题,因此非阻塞通信依旧存在程序不安全的问题。
- 如何判断程序是否安全:将MPI_Send替换为MPI_Ssend函数,若程序能够正常运行,则原来的程序是安全的。MPI_Ssend()函数的S代表同步(Synchronization),保证了直到对应接收开始前,发送端一直阻塞。
- 如何确保程序安全:
(1)重构通信函数,避免死锁,例如在环状通信中,让奇数的进程先接收后发送,偶数的进程先发送后接收,而不是所有进程统一先发送后接收,或统一先接收后发送。
(2)使用MPI自己的通信调度方法:MPI_Sendrecv()会分别执行一次阻塞式消息发送和消息接收,使程序不会挂起或崩溃。
void MPI_Sendrecv(
void* send_buf_p /* in */,
int send_buf_size /* in */,
MPI_Datatype send_buf_type /* in */,
int dest /* in */,
int send_tag /* in */,
void* recv_buf_p /* out */,
int recv_buf_size /* in */,
MPI_Datatype recv_buf_type /* in */,
int source /* in */,
int recv_tag /* in */,
MPI_Comm comm /* in */,
MPI_Status* status_p /* in */);
四、完整示例程序
1. hello world.c
第一个基于mpi的hello world程序
#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_Status status;
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, &status);
printf("%s\n", greeting);
}
}
MPI_Finalize();
return 0;
}
2. trapezoid.c
利用mpi任务分配,微分求解梯形面积
#include <stdio.h>
#include <mpi.h>
double f(double x){
double y;
y = x;
return y;
}
double Trap(double left_endpt, double right_endpt, int trap_count, double base_len){
double estimate, x;
int i;
estimate = (f(left_endpt) + f(right_endpt)) / 1.0;
for (i=0; i <= trap_count-1; i++) {
x = left_endpt + i * base_len;
estimate += f(x);
}
estimate = estimate * base_len;
return estimate;
}
int main(void){
int my_rank, comm_sz, n = 10240000, local_n;
double a = 0.0, b = 100000000.0, h, local_a, local_b;
double local_int, total_int;
int source;
MPI_Init(NULL, NULL);
MPI_Comm_rank(MPI_COMM_WORLD, &my_rank);
MPI_Comm_size(MPI_COMM_WORLD, &comm_sz);
h = (b-a) / n;
local_n = n / comm_sz;
local_a = a + my_rank * local_n * h;
local_b = local_a + local_n * h;
local_int = Trap(local_a, local_b, local_n, h);
if(my_rank != 0){
MPI_Send(&local_int, 1, MPI_DOUBLE, 0, 0, MPI_COMM_WORLD);
} else {
total_int = local_int;
for(source = 1; source < comm_sz; source++){
MPI_Recv(&local_int, 1, MPI_DOUBLE, source, 0,
MPI_COMM_WORLD, MPI_STATUS_IGNORE);
total_int += local_int;
}
}
if (my_rank == 0) {
printf("With n = %d trapezoids, our estimate\n", n);
printf("of the integral from %f to %f = %.15e\n", a, b, total_int);
}
MPI_Finalize();
return 0;
}
参考资料
教材《并行程序设计导论》 机械工业出版社
超算习堂课程《超级计算机原理与操作》中山大学杜云飞老师主讲
更多函数用法可以查看:Microsoft MPI 官方文档