最近系统过了一遍 MPI,教材用的是 mpitutorial(mpitutorial.com 的源码仓库,有官方中文翻译)。这篇把学到的东西整理成一份可运行、可速查的笔记:先建立 SPMD 心智模型,过一遍点对点通信、死锁与动态接收,再逐个拆解集合通信,最后用一个完整的并行应用串起来,并和昇腾的 HCCL 做了对照。
一、教材与环境
mpitutorial 仓库由两部分组成:Jekyll 教程站点,以及 tutorials/<教程名>/code/ 下的可运行 C 代码(14 个教程目录中约 10 篇配了代码,其余 3 篇是安装与集群搭建文档)。tutorials/ 目录下的 run.py 可以一键构建并运行全部 16 个示例:python3 run.py mpi_hello_world。
环境(Windows → WSL2):
sudo apt update && sudo apt install -y mpich build-essential
mpicc --version && mpirun --version
apt install mpich 装的是阿贡国家实验室的 MPICH 项目(源码 pmodels/mpich),MPI 标准的经典参考实现。家族里还有 OpenMPI、微软的 MS-MPI(MPICH 的 Windows 移植)、Intel MPI(基于 MPICH 的商业分支)、MVAPICH(专攻 InfiniBand)——实现可以随便换,代码不用改,因为 MPI 是一份标准。
mpirun 常用参数:
mpirun -n 4 ./a.out # 起 4 个进程(-n 与 -np 等价)
mpirun -n 4 -timeout 10 ./a.out # 10 秒超时,防死锁卡死终端
mpirun --oversubscribe -n 16 ./a.out # 进程数超过 CPU 核数(OpenMPI 写法)
mpirun -f hostfile -n 8 ./a.out # 跨机:指定主机列表
第一个 MPI 程序
每个 MPI 程序都是这个骨架,五个调用缺一不可:
#include <mpi.h>
#include <stdio.h>
int main(int argc, char **argv)
{
MPI_Init(NULL, NULL); // ① 报到,必须是第一个 MPI 调用
int world_rank, world_size;
MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); // ② 领身份证:我是几号(0 ~ size-1)
MPI_Comm_size(MPI_COMM_WORLD, &world_size); // ③ 查总人数
printf("Hello from rank %d of %d\n", world_rank, world_size);
MPI_Finalize(); // ④ 退场,之后不能再调任何 MPI 函数
return 0;
}
编译运行:
mpicc mpi_hello_world.c -o mpi_hello_world
mpirun -n 4 ./mpi_hello_world
# Hello from rank 0 of 4
# Hello from rank 2 of 4 ← 顺序不确定,多跑几次会变
# Hello from rank 1 of 4
# Hello from rank 3 of 4
输出顺序不确定是并行程序的典型特征:没有任何机制规定谁先执行 printf。
二、核心心智模型:SPMD
一份代码,N 份拷贝同时运行(SPMD = Single Program, Multiple Data)。你只写一份 main(),mpirun -n 4 把它复制成 4 个进程同时跑,唯一的区别是每个进程拿到的 rank 不同。
三个贯穿始终的名词:
- rank:进程在通信域里的编号,
0 ~ size-1 - communicator(通信域):进程的”群聊”,
MPI_COMM_WORLD是系统建好的最大群聊,包含所有进程;通信域自带一套独立的 rank 编号 - root:集合通信里的”主事人”参数,决定数据从哪里发出、结果落到谁手里
用 if (world_rank == 0) 就能让某个进程做与众不同的事——后面所有模式都是围绕这一点展开的。
三、点对点通信
3.1 信封模型
MPI_Send(&number, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
// 数据地址 个数 类型 目的地 标签 通信域
MPI_Recv(&number, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
// 数据地址 个数 类型 来源 标签 通信域 状态
一次 MPI 通信 = 数据 + 信封。信封上的字段必须”发送方写什么、接收方声明什么”严格对应才能匹配:
| 字段 | 发送方 | 接收方 | 匹配规则 |
|---|---|---|---|
| 通信域 | comm | comm | 必须同一个 |
| 类型/个数 | count × datatype | count × datatype | 接收缓冲区 ≥ 实际消息长度 |
| 来源/目的地 | dest | source | 相等,或接收方填 MPI_ANY_SOURCE |
| 标签 | tag | tag | 相等,或接收方填 MPI_ANY_TAG |
标签的价值在于:两个进程之间可以同时跑多条不同用途的消息流(比如”数据”用 tag 0、”控制信号”用 tag 1),靠 tag 区分互不串线。
一个容易被忽略的细节:同一个 (source, tag) 下的消息是按发送顺序接收的(MPI 保证非超车),不同 (source, tag) 之间则没有顺序保证。
3.2 阻塞语义与四种发送模式
关于 MPI_Send 最常见的误解是”它立刻返回”。准确说法是:
MPI_Send返回时只保证发送缓冲区可以被复用,不保证对方已收到;而且大消息时它可能直接阻塞(内部走 rendezvous 协议,要等接收方就绪)。
MPI 提供了四种发送模式,区别就在于”返回条件”:
| 模式 | 返回条件 |
|---|---|
MPI_Send(标准) |
由实现决定:小消息通常拷进内部缓冲立刻返回(eager),大消息可能阻塞 |
MPI_Ssend(同步) |
确认对方开始接收后才返回,最安全也最慢 |
MPI_Bsend(缓冲) |
拷进用户提供的缓冲区立刻返回,需先 MPI_Buffer_attach |
MPI_Rsend(就绪) |
假设对方已在等,省一次握手;用错会出错 |
而 MPI_Recv 是真阻塞的:匹配的消息不到,进程就停在那里。
3.3 死锁
所有进程都先阻塞 Recv → 谁都发不出去 → 环状等待,永远卡住。
阴险之处:小消息可能被 MPI 内部缓冲”顺手”送达,坏程序有时也能跑通。死锁与否可能取决于消息大小和 MPI 实现——本机跑通 ≠ 正确,换集群、换数据量就可能死。
三条出路:
- 重排顺序:让收发错开(如 ping-pong:rank 0 先发后收,rank 1 先收后发)
- 非阻塞通信:
MPI_Isend把消息挂出去立刻返回,MPI_Irecv挂单收货,MPI_Wait确认完成;依赖环直接消失- 纪律:Isend/Irecv 之后、Wait 之前不能碰缓冲区——它是”薛定谔的缓冲区”
- 配套:
MPI_Test(非阻塞轮询)、MPI_Waitall/MPI_Waitany/MPI_Waitsome(批量等待)
MPI_Sendrecv:一个调用同时收发,互不阻塞;MPI_Sendrecv_replace复用同一缓冲区,是环形位移、邻居交换的标准工具
3.4 动态接收:Status 与 Probe
前面都假设”接收方事先知道消息多大、从哪来”,真实程序常常不知道。
MPI_Status(回执)——MPI_Recv 的最后一个参数(之前一直填 MPI_STATUS_IGNORE):
MPI_Status status;
MPI_Recv(buf, MAX, MPI_INT, 0, 0, comm, &status);
MPI_Get_count(&status, MPI_INT, &count); // 实际收到几个元素
status.MPI_SOURCE // 谁发来的
status.MPI_TAG // 标签是什么
MPI_Probe(先探后收)——不搬数据,只问”匹配的消息有多大”,然后按需分配精确大小的缓冲区:
MPI_Probe(MPI_ANY_SOURCE, MPI_ANY_TAG, comm, &status);
MPI_Get_count(&status, MPI_INT, &count);
buf = malloc(sizeof(int) * count);
MPI_Recv(buf, count, MPI_INT, status.MPI_SOURCE, status.MPI_TAG, comm,
MPI_STATUS_IGNORE);
对比:前者是”按最大可能开缓冲区 + 事后问收到几个”,后者是”先探后收”,内存更省,是不定长消息的标准姿势。想只探测而不阻塞,用 MPI_Iprobe。
四、集合通信
集合通信(collective)与点对点的最大区别:它必须由通信域内所有进程一起调用,参数一致,少一个就死锁。换来的是极高的效率——所有进程共享同一套调度,内部用树/环等结构并行推进。
4.1 Bcast(广播)
MPI_Bcast(&data, 1, MPI_INT, 0, MPI_COMM_WORLD);
// 缓冲区 个数 类型 root 通信域
Bcast 是一对多——数据从 root 流向所有人,方向由 root 参数决定。root 上的缓冲区被读,其他进程上被写覆盖。
为什么比手写 for 循环快?
- 笨办法:root 挨个 Send,O(N) 轮,root 的网卡是瓶颈
- 树形传播:每一轮”已拿到数据”的进程同时往外转发,16 个进程只需 4 轮,O(log N)
4.2 Scatter / Gather(切片与归堆)
MPI_Scatter(send_data, send_count, MPI_FLOAT, recv_data, recv_count, MPI_FLOAT, root, comm);
MPI_Gather (send_data, send_count, MPI_FLOAT, recv_data, recv_count, MPI_FLOAT, root, comm);
最大的坑在 count 语义:它是”每个进程的份量”,不是总数。root 有 100 个数、4 个进程,send_count 填 25。Gather 时 root 的接收缓冲区要按 world_size × recv_count 开。另外,root 自己也参与——它分到的片 0 自己算。
并行求平均四部曲,也就是 MapReduce 的雏形:
root 生成 P×N 个数 → Scatter 切 P 份 → 各算局部平均 → Gather 归堆 → root 汇总
几个变体:
MPI_Allgather= Gather + Bcast:当每个进程都需要看到全景时使用(例如迭代算法里所有人都要检查全局收敛条件)MPI_Scatterv/Gatherv:每份长度可以不同(对应Alltoallv那套 counts + displs 参数)MPI_Gather的 recv_count 是”从每个进程收多少”,不是总数
4.3 Alltoall(全交换)
黄金模型:N×N 矩阵转置。
| 操作 | 每人发出去什么 |
|---|---|
| Bcast | root 一人发 N 份相同数据 |
| Allgather | 大家都发,发给每个人的同一份 |
| Alltoall | 大家都发,发给每个人各不相同(个性化) |
不等长的 MPI_Alltoallv 有个标准两步套路——接收方事先不知道会收到多少,必须先交换份量:
MPI_Alltoall(send_counts, 1, MPI_INT, recv_counts, 1, MPI_INT, comm); // 先换"发几个"
// 用前缀和算出 sdispls / rdispls
MPI_Alltoallv(sendbuf, send_counts, sdispls, MPI_INT, recvbuf, recv_counts, rdispls, MPI_INT, comm);
这正是 MoE 专家并行里”先通信 counts、再通信 token”两轮 all-to-all 的由来,不是多此一举。
代价:每个进程要发 N 条、收 N 条,消息数 N² 级,是集合通信里最贵的一个,大规模下往往是性能瓶颈。
4.4 Reduce / Allreduce(归约)
MPI_Reduce(&sub_avg, &total_avg, 1, MPI_FLOAT, MPI_SUM, 0, MPI_COMM_WORLD);
// 局部值 结果 个数 类型 算子 root
树形对折:在搬运的半路上就把数加掉,N 个数只需 log₂N 轮(见上图下半部分)。广播树倒过来就是归约树。
| 结果在谁手里 | 何时用 | |
|---|---|---|
MPI_Reduce |
只有 root | 只有 root 需要结论(打印、写盘) |
MPI_Allreduce |
人人都有 | 聚合结果是后续计算的输入 |
选 Reduce 还是 Allreduce 不是性能问题而是正确性问题:比如算标准差时,非 root 进程拿不到全局均值 μ,结果会悄悄算错而不报错。
算子族(换算子即换聚合含义):
| 算子 | 含义 | 典型用途 |
|---|---|---|
MPI_SUM |
求和 | 全局平均、梯度累加 |
MPI_MAX / MIN |
极值 | 求最大误差、找最优 |
MPI_PROD |
连乘 | — |
MPI_LAND / LOR |
逻辑与/或 | 全局收敛判断(每人交 0/1,全收敛才算收敛) |
MPI_MAXLOC / MINLOC |
极值 + 位置 | 找最大值在哪 |
还有个组合技 MPI_Reduce_scatter:先归约再切片分发,结果是每人拿到全局结果的一段——ZeRO 类优化里梯度分片同步用的就是它。
现实锚点:数据并行训练里的梯度同步,本质就是一次 AllReduce(求和)。PyTorch DDP 把梯度分桶(bucket),在反向传播过程中逐桶发起 AllReduce,让通信与计算重叠——这正好是本文 6.1 节那个思路的工业版。
4.5 怎么量化”快多少”
MPI 自带高精度计时器,写性能对比实验就靠它:
MPI_Barrier(MPI_COMM_WORLD); // 先对齐,避免有的进程先跑
double t = MPI_Wtime();
MPI_Bcast(data, N, MPI_INT, 0, MPI_COMM_WORLD);
MPI_Barrier(MPI_COMM_WORLD); // 再对齐,确保都做完
double elapsed = MPI_Wtime() - t;
理论上常用 α-β 模型(Hockney 模型)估算一次通信的耗时:
T = α + n·β (α:启动延迟,n:字节数,β:每字节传输时间)
归约再加一项 n·γ (γ:每字节的计算时间)
它解释了集合通信的核心权衡:小消息时被 α(步数/延迟)主导,所以用步数少的树形算法;大消息时被 nβ(带宽)主导,所以用每条链路只传一次的环形算法。后面谈 HCCL 的算法选择,依据也是这个模型。
4.6 全景速查
| 操作 | 数据流向 | 结果归属 |
|---|---|---|
MPI_Bcast |
root → 全体(同一份) | 人人 |
MPI_Scatter |
root → 全体(切片) | 每人一片 |
MPI_Gather |
全体 → root | 只有 root |
MPI_Allgather |
全体 → 全体 | 人人 |
MPI_Reduce |
全体 → root(边走边算) | 只有 root |
MPI_Allreduce |
全体 → 全体(边走边算) | 人人 |
MPI_Reduce_scatter |
归约 + 切片 | 每人一片 |
MPI_Alltoall(V) |
全体 ↔ 全体(个性化) | 人人 |
MPI_Barrier |
无数据 | — |
记忆口诀:All 前缀 = 结果人人都有一份;V 后缀 = 每个方向长度可以不同。
五、综合应用:并行随机游走
一维空间 [0, domain_size) 切成 N 段,每个进程管一段;游走者每步随机 ±1,越界就移交给邻居进程,掉出全局边界就消失。这个例子把前面所有内容串成一条线:
| 用到的东西 | 在例子里的角色 |
|---|---|
| 域分解 + rank | 算出自己负责 [subdomain_start, start + size) |
| tag 区分方向 | 发给右邻居用 TAG_RIGHT、左邻居用 TAG_LEFT |
MPI_Isend |
非阻塞发出越界的游走者,避免收发顺序死锁 |
MPI_Probe |
邻居送来几个游走者是不定的,先探后收 |
MPI_Allreduce |
汇总”还有多少活着的游走者”,全局为 0 才结束 |
最值得记住的工程细节:每轮每个进程都必定给存在的邻居发一条消息(哪怕 0 个游走者),接收方据此只从存在的邻居 Probe。收发条件必须严格对称,否则就会出现”等一条永远不会来的消息”——这是这类邻居交换程序最常见的隐蔽死锁来源。我在写这份代码时第一版就踩了这个坑。
六、进程组与通信器
- MPI_Comm:能通信的”群聊”,自带独立 rank 编号
- MPI_Group:只是成员名单,不能通信
按规则切分(color 相同的一队,key 决定队内排序):
int color = world_rank / 4;
MPI_Comm_split(MPI_COMM_WORLD, color, world_rank, &row_comm);
按名单点将:MPI_Comm_group 取出名单 → MPI_Comm_incl 挑人 → MPI_Comm_create_group 建群聊。
三条纪律:
- 每个进程有两套身份证:全局 rank 与队内 rank —— rank 翻译是组队编程最常见的 bug 源。跨队通信要用全局 rank
- 不在名单的进程拿到
MPI_COMM_NULL,使用前必须检查 - Group 和 Comm 都要手动释放(
MPI_Group_free/MPI_Comm_free);组队有成本,初始化时建一次反复用
顺带一提,如果进程排布是规则的网格(比如二维块划分),可以用虚拟拓扑 MPI_Cart_create 建立笛卡尔通信域,之后用坐标而非 rank 算邻居,MPI_Cart_shift 直接给出上下左右的 rank——写 stencil、矩阵分块时非常省事。
七、进阶
7.1 非阻塞集合通信
命名规则:集合通信函数名前加 I。
MPI_Request req;
MPI_Ibcast(vec, N, MPI_INT, 0, MPI_COMM_WORLD, &req);
/* 趁通信在后台推进,做不依赖 vec 的本地计算 */
MPI_Wait(&req, MPI_STATUS_IGNORE);
价值在于通信与计算重叠——把干等通信的时间拿去干活。前提是本地计算不能用到正在通信的缓冲区。同类还有 MPI_Iallreduce / MPI_Igather / MPI_Iscatter / MPI_Ibarrier。
7.2 派生数据类型
内置类型不够用时(结构体、非连续内存),可以描述内存布局然后当成一种类型来收发:
MPI_Datatype my_type;
int block_lengths[2] = {1, 1};
MPI_Aint displacements[2], base;
MPI_Datatype types[2] = {MPI_FLOAT, MPI_INT};
MPI_Get_address(&dummy, &base);
MPI_Get_address(&dummy.value, &displacements[0]); displacements[0] -= base;
MPI_Get_address(&dummy.rank, &displacements[1]); displacements[1] -= base;
MPI_Type_create_struct(2, block_lengths, displacements, types, &my_type);
MPI_Type_commit(&my_type); // 必须 commit 才能用
/* ... 使用 ... */
MPI_Type_free(&my_type);
好处是一次调用传完整个结构,不必手动序列化成字节流。
7.3 自定义归约算子
内置算子只认单个基础类型,想归约”一条记录”(例如求最大值并记住它来自哪个进程)就得自己写:
void my_op(void *in, void *inout, int *len, MPI_Datatype *dtype);
// in 是输入,inout 既是输入又是输出(结果写回这里)
MPI_Op_create(&my_op, 1 /* commute */, &op);
MPI_Allreduce(&local, &global, 1, my_type, op, MPI_COMM_WORLD);
MPI_Op_free(&op);
硬性要求:必须满足结合律(否则归约树的结果不确定);commute=1 时还需满足交换律。
八、常见坑清单
- count 是”每人的份量”,不是总数(Scatter/Gather/Alltoall 都这样)
- Allgather 的接收缓冲区按
world_size × recv_count开 - 集合通信必须全员调用,参数一致,少一个就死锁
- Allreduce 与 Reduce 的取舍是正确性问题
- root 自己也参与收发,别漏了它那份
- 非阻塞通信期间不能碰缓冲区
MPI_Send返回不代表对方已收到;大消息可能直接阻塞,死锁与否可能被缓冲掩盖MPI_Scatter要求每份等长,总数除不尽时尾部数据要自己兜底(或用Scatterv);集合通信不会替你检查分块对不对- 跨队通信要用全局 rank,队内编号只在自己队里有效;
MPI_COMM_NULL必须先检查 - Alltoall 消息数 N²,大规模下往往是性能瓶颈
- 自己写的归约算子必须满足结合律
- 计时前后要加
MPI_Barrier,否则测出来的是”最快那个进程”的时间
九、与 HCCL 的对照
HCCL(gitcode.com/cann/hccl)是昇腾 AI 处理器的高性能集合通信库。它的 src/ops/ 目录和 MPI 几乎是一比一对应的:
| MPI | HCCL 目录 |
|---|---|
MPI_Bcast |
ops/broadcast |
MPI_Scatter |
ops/scatter |
MPI_Gather/Allgather |
ops/all_gather、all_gather_v |
MPI_Reduce |
ops/reduce |
MPI_Allreduce |
ops/all_reduce |
MPI_Reduce_scatter |
ops/reduce_scatter(_v) |
MPI_Alltoall(V) |
ops/all_to_all_v |
MPI_Barrier |
ops/barrier |
MPI_Send/Recv |
ops/send、ops/recv、ops/batch_send_recv |
算法层也能对上:树形对折 → RHD(Recursive Halving-Doubling);环 → Ring;全交换 → PairWise。
最本质的差别是:HCCL 显式分层。 MPI 把 N 个 rank 当平铺世界,统一用树或环;HCCL 则先做 Server 内归约(Mesh/Star/Double-Ring,走 HCCS),再做 Server 间通信(Ring/RHD/NHR/NB,走 RoCE),最后 Server 内广播——Pipeline 算法还能把三步流水线化,让机内外链路并发。
算法选择依然遵循 α-β 模型的权衡(文档里给了每种算法的代价公式):Ring 步数多但抗拥塞,适合小数据量、节点少;RHD 步数少但要求节点数是 2 的幂;NHR/NB 专门解决非 2 的幂;Pipeline 在大数据量多机多卡时最优。
几样 MPI 里没有的东西:
- Mesh:FullMesh 硬件下 O(1) 步,传统以太网集群没这硬件
- NHR / NB:解决节点数非 2 的整数次幂时的非均衡切分
- AHC:非对称层次拼接,超节点/带宽收敛场景
- 代码组织上
op_common/selector(CostModel 决策)与algorithm/executor(执行)分离,可用环境变量HCCL_ALGO干预算法选择
API 观感上:HCCL 跑在 NPU stream 上异步入队、没有 tag 匹配机制、数据类型只有有限几种、缓冲区在 Device 显存、通信域与设备绑定。一句话总结——MPI 是”标准 + 可换实现”,HCCL 把 MPI 藏在实现内部的算法选择显式化、可配置、可观测了。
至此 mpitutorial 主线的 10 课加 Alltoall 补讲就都过了一遍。这些概念并不绑定某个具体库——换个马甲(NCCL、HCCL)语义依然成立,变的只是”怎么走”那部分。配套代码在 D:\code\mpi-course\ 下按课分目录,均带 Makefile,在 WSL 里进目录 make 就能跑。