外观
MPI 并行程序入门:从“多进程一起干活”开始
MPI(Message Passing Interface)几乎是高性能计算里绕不开的一站。
如果只用一句话概括 MPI,可以这样理解:
启动多个进程,让它们各自处理自己那一部分数据,再通过消息通信把整体任务拼起来。
这篇文章不打算一上来就堆 API,而是先把最常见、最有用的那几个概念讲清楚:MPI 程序是怎么跑起来的,rank 是什么,进程之间怎么发消息,怎么做广播、求和,以及为什么 PETSc 这类库天然离不开 MPI。
第一个 MPI 程序
先看最经典的 hello world:
hello.cpp
#include <mpi.h>
#include <iostream>
int main(int argc, char** argv)
{
MPI_Init(&argc, &argv);
int rank, size;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
std::cout << "Hello from rank "
<< rank
<< " of "
<< size
<< std::endl;
MPI_Finalize();
return 0;
}这个程序做的事情非常简单:
MPI_Init启动 MPI 环境MPI_Comm_rank获取当前进程编号MPI_Comm_size获取总进程数- 每个进程打印一句自己的身份
MPI_Finalize结束 MPI 环境
虽然代码只有一份,但运行时它会被启动成多个并行进程。每个进程执行的还是同一段程序,只是因为 rank 不同,后续就可以分工做不同的事。
怎么编译运行
编译
mpicxx hello.cpp -o hello这里的 mpicxx 可以理解成 MPI 提供的 C++ 编译器包装器。它会替你把 MPI 头文件和库都接好,所以平时写 MPI C++ 程序时,直接用它编译就行。
运行
mpirun -np 4 ./hello这条命令的意思是:启动 4 个 MPI 进程,一起运行 ./hello。
输出大概会像这样:
Hello from rank 0 of 4
Hello from rank 2 of 4
Hello from rank 1 of 4
Hello from rank 3 of 4顺序不固定是正常的。因为这 4 个进程本来就是并行执行,谁先把输出刷到终端,取决于系统调度。
先把三个关键词记住
刚接触 MPI 时,最值得先记住的是这三个词:rank、size 和 communicator。
rank
每个 MPI 进程都有一个唯一编号:
0, 1, 2, ..., size - 1这就是 rank。很多 MPI 程序的第一层分支逻辑,基本都围绕它展开。
MPI_Comm_rank(MPI_COMM_WORLD, &rank);拿到 rank 之后,你就可以写出“0 号进程读输入,其他进程负责计算”这类逻辑。
size
size 表示当前通信域里一共有多少个进程:
MPI_Comm_size(MPI_COMM_WORLD, &size);这个值通常决定了数据怎么切分、工作怎么分配。
communicator
communicator 可以理解成“哪些进程属于同一个通信组”。
最常见的是:
MPI_COMM_WORLD它表示“这次启动出来的所有 MPI 进程”。入门阶段绝大多数程序都直接在 MPI_COMM_WORLD 上工作。
MPI 最基本的能力:一个发,一个收
MPI 最朴素、也最重要的能力,就是进程之间显式传消息。
换句话说,数据不会自己“共享”过去。谁要发、发给谁、发什么类型、由谁来收,这些都要明确写出来。
示例:rank 0 给 rank 1 发一个整数
#include <mpi.h>
#include <iostream>
int main(int argc, char** argv)
{
MPI_Init(&argc, &argv);
int rank;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
if (rank == 0)
{
int x = 123;
MPI_Send(
&x,
1,
MPI_INT,
1,
0,
MPI_COMM_WORLD
);
}
if (rank == 1)
{
int y;
MPI_Recv(
&y,
1,
MPI_INT,
0,
0,
MPI_COMM_WORLD,
MPI_STATUS_IGNORE
);
std::cout << "rank 1 received "
<< y
<< std::endl;
}
MPI_Finalize();
return 0;
}这个例子里只发生了两件事:
rank 0把x = 123发给rank 1rank 1收到后把它打印出来
MPI 的点对点通信,本质上就是这么直接。
MPI_Send 这些参数在说什么
MPI_Send(
&x,
1,
MPI_INT,
1,
0,
MPI_COMM_WORLD
);可以把它读成一句完整的话:
把从
&x开始的 1 个MPI_INT,发送给MPI_COMM_WORLD里的rank 1,消息标签是0。
对应关系如下:
| 参数 | 含义 |
|---|---|
&x | 数据起始地址 |
1 | 数据个数 |
MPI_INT | 数据类型 |
1 | 目标进程的 rank |
0 | tag,消息标签 |
MPI_COMM_WORLD | 通信域 |
入门阶段先把它理解成“发消息时要把目标和格式都说清楚”,就够用了。
MPI_Recv 这些参数在说什么
MPI_Recv(
&y,
1,
MPI_INT,
0,
0,
MPI_COMM_WORLD,
MPI_STATUS_IGNORE
);它可以读成:
从
MPI_COMM_WORLD里的rank 0接收 1 个MPI_INT,标签是0,把结果写到&y这块内存里。
对应关系如下:
| 参数 | 含义 |
|---|---|
&y | 接收数据写入的位置 |
1 | 接收数据个数 |
MPI_INT | 数据类型 |
0 | 源进程的 rank |
0 | tag,消息标签 |
MPI_COMM_WORLD | 通信域 |
MPI_STATUS_IGNORE | 忽略返回状态信息 |
这里最关键的是:MPI_Recv 里写的源进程、数据类型、标签和通信域,要和发送端的 MPI_Send 对得上,否则这条消息就匹配不上。
一次发给所有人:MPI_Bcast
很多时候,并不是两个进程私下交换数据,而是某个进程拿到一个公共值之后,要同步给所有进程。比如:
- 读入一个参数
- 广播时间步长
- 广播材料参数
- 广播网格规模
这时候就会用到广播 MPI_Bcast。
int x;
if (rank == 0)
{
x = 100;
}
MPI_Bcast(
&x,
1,
MPI_INT,
0,
MPI_COMM_WORLD
);
std::cout << "rank "
<< rank
<< " has x = "
<< x
<< std::endl;这段代码的意思很直观:
rank 0先把x设成 100- 然后把这个值广播给所有进程
- 广播结束后,每个进程上的
x都会变成 100
如果你写的是数值程序,这种“一个人拿到公共数据,再发给所有人”的模式会非常常见。
从局部结果拼成全局结果:MPI_Reduce
并行计算里最典型的场景之一是:每个进程先算自己的局部量,最后再汇总成一个全局量。
比如:
- 全局质量
- 总能量
- 残差范数
- 误差估计
- 求和、最大值、最小值
这时最常用的就是 MPI_Reduce。
double local_value = rank + 1;
double global_sum = 0.0;
MPI_Reduce(
&local_value,
&global_sum,
1,
MPI_DOUBLE,
MPI_SUM,
0,
MPI_COMM_WORLD
);
if (rank == 0)
{
std::cout << "sum = "
<< global_sum
<< std::endl;
}如果一共 4 个进程,那么它们各自的 local_value 分别是:
1, 2, 3, 4做完 MPI_Reduce(..., MPI_SUM, 0, ...) 之后,rank 0 会拿到:
global_sum = 10这里最关键的一点是:结果只会交给 root,也就是这里的 rank 0。其他进程不会自动拿到这个总和。
如果大家都要结果,就用 MPI_Allreduce
MPI_Reduce 和 MPI_Allreduce 的区别很简单:
| 函数 | 谁能拿到结果 |
|---|---|
MPI_Reduce | 只有 root |
MPI_Allreduce | 所有进程 |
比如:
double local_norm2 = 1.0;
double global_norm2;
MPI_Allreduce(
&local_norm2,
&global_norm2,
1,
MPI_DOUBLE,
MPI_SUM,
MPI_COMM_WORLD
);执行完以后,每个进程上的 global_norm2 都会是同一个全局值。
这在迭代法里特别常见。比如每轮都要判断全局残差是否收敛,那所有进程通常都需要知道这个结果。
MPI 程序里最常见的工作方式:分块
MPI 之所以能扩展到很多进程,本质上是因为它让每个进程只处理自己那一块数据。
最常见的形式就是:
一个大数组,按区间切开,每个进程负责其中一段。
最简单的等分写法
int N = 100;
int local_n = N / size;
int start = rank * local_n;
int end = start + local_n;
if (rank == size - 1)
{
end = N;
}
for (int i = start; i < end; i++)
{
// 当前进程处理自己的数据
}这个版本思路很直接:前面的进程先平均分,最后一个进程把剩下的尾巴接过去。
它够简单,也够常用,但在很多场景下还不算最稳健。
更稳健的切分:处理不能整除的情况
如果 N 不能被 size 整除,一个更平衡的做法是:把多出来的那几项,平均分给前面的若干个进程。
int base = N / size;
int rem = N % size;
int local_n;
int start;
if (rank < rem)
{
local_n = base + 1;
start = rank * local_n;
}
else
{
local_n = base;
start =
rem * (base + 1)
+ (rank - rem) * base;
}
int end = start + local_n;这个写法的好处是负载更均衡。前 rem 个进程每人多拿一个元素,其他进程拿标准长度,整体差距最多只会有 1。
在并行程序里,这种“尽量让所有进程都差不多忙”的意识非常重要,否则很容易出现一部分进程早早算完,另一部分还在拖尾。
大多数 MPI 程序,其实都长这个样子
写多了以后你会发现,很多 MPI 程序虽然业务不同,但骨架非常像:
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
// 1. 数据划分
// 2. 每个进程做自己的局部计算
// 3. 需要时进行通信
// 4. 汇总全局量
// 5. 由 rank 0 输出结果
MPI_Finalize();你完全可以把这当成 MPI 入门时的一张思维模板。以后不管是写并行积分、并行稀疏矩阵操作,还是做有限元装配,大多都绕不开这个结构。
为什么 PETSc / MFEM 这些库离不开 MPI
如果你接下来会学 PETSc 或 MFEM,那 MPI 基本是必修课。
比如在 PETSc 里,很多对象一创建就带着通信域:
MatCreate(PETSC_COMM_WORLD, &A);
VecCreate(PETSC_COMM_WORLD, &b);
KSPCreate(PETSC_COMM_WORLD, &ksp);这里的 PETSC_COMM_WORLD,本质上就是 MPI 通信域那套思路的延续。很多时候你可以近似把它理解成建立在 MPI_COMM_WORLD 之上的封装接口。
也就是说,PETSc 能自动帮你做矩阵分块、向量分布、并行 Krylov 迭代,不是因为它“跳过了 MPI”,而是因为它把 MPI 这一层封装得足够成熟。
运行 PETSc 程序
mpirun -np 4 ./solver一旦你这样启动 PETSc 程序,后面的事情通常就是:
- 矩阵自动分布到各个进程
- 向量自动分块
- 线性求解器在并行环境里协同工作
理解 MPI 之后,再看 PETSc 的接口,很多东西会自然很多。
常用 MPI 函数,先记住这一组
刚上手时,不需要一次背太多。先把最常见的这些函数认熟就够了。
初始化
MPI_Init
MPI_Finalize获取进程信息
MPI_Comm_rank
MPI_Comm_size点对点通信
MPI_Send
MPI_Recv集体通信
MPI_Bcast
MPI_Reduce
MPI_Allreduce同步
MPI_Barrier其中最值得先练熟的,就是 rank/size、Send/Recv、Bcast 和 Reduce。它们几乎构成了所有 MPI 入门练习的核心。
一个完整例子:并行计算积分
前面的概念如果只停留在“会背函数名”,其实还不算真的懂。更好的办法,是看一个完整但不复杂的小例子。
下面这个程序用并行方式近似计算:
∫01x2dx=31
思路非常标准:
- 把区间采样点分给不同进程
- 每个进程计算自己的局部积分和
- 最后用
MPI_Reduce汇总成全局结果
完整代码
#include <mpi.h>
#include <iostream>
#include <cmath>
double f(double x)
{
return x * x;
}
int main(int argc, char** argv)
{
MPI_Init(&argc, &argv);
int rank, size;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
int N = 1000000;
double h = 1.0 / N;
int base = N / size;
int rem = N % size;
int local_n;
int start;
if (rank < rem)
{
local_n = base + 1;
start = rank * local_n;
}
else
{
local_n = base;
start =
rem * (base + 1)
+ (rank - rem) * base;
}
int end = start + local_n;
double local_sum = 0.0;
for (int i = start; i < end; i++)
{
double x = (i + 0.5) * h;
local_sum += f(x) * h;
}
double global_sum = 0.0;
MPI_Reduce(
&local_sum,
&global_sum,
1,
MPI_DOUBLE,
MPI_SUM,
0,
MPI_COMM_WORLD
);
if (rank == 0)
{
std::cout << "Integral = "
<< global_sum
<< std::endl;
std::cout << "Exact = "
<< 1.0 / 3.0
<< std::endl;
}
MPI_Finalize();
return 0;
}编译
mpicxx integral.cpp -o integral运行
mpirun -np 4 ./integralMPI 和 OpenMP,到底差在哪
很多人刚学并行时,都会把 MPI 和 OpenMP 放在一起比较。这个对比是有意义的,因为两者解决的问题有重叠,但思路其实很不一样。
| MPI | OpenMP |
|---|---|
| 多进程 | 多线程 |
| 分布式内存 | 共享内存 |
| 适合超算/HPC | 常见于单机多核 |
| 通信显式 | 通过共享内存协作 |
如果再压缩成一句话:
MPI 强调“大家各管一块内存,通过消息配合”;OpenMP 更像“大家共享一块内存,用线程分工”。
这也是为什么大规模分布式计算更常见 MPI,而单机多核加速里 OpenMP 很常见。
总结
MPI 最核心的想法并不复杂:
把大问题拆成很多局部问题,让多个进程分别处理自己的那一块,再通过消息通信把结果组织成一个整体。
MPI 其实不适合一口气把所有 API 全啃完。更有效的方式,是按“能写出程序”的顺序往前走。
建议路线可以是:
- Hello MPI
Send / RecvBcast / Reduce- 并行数组划分
- Halo exchange(邻居通信)
- 并行矩阵与稀疏存储
- PETSc
- MFEM 并行有限元
前四步扎实了,后面很多内容都会顺下来。
版权所有
版权归属:Guisong Wu