MPI 并行编程学习

从 Hello World 到集合通信,兼谈与 HCCL 的对应

Posted by ZBX on September 13, 2026

最近系统过了一遍 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 不同。

MPI SPMD 模型

三个贯穿始终的名词:

  • 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 Send Recv 消息信封

一次 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 实现——本机跑通 ≠ 正确,换集群、换数据量就可能死。

三条出路:

  1. 重排顺序:让收发错开(如 ping-pong:rank 0 先发后收,rank 1 先收后发)
  2. 非阻塞通信MPI_Isend 把消息挂出去立刻返回,MPI_Irecv 挂单收货,MPI_Wait 确认完成;依赖环直接消失
    • 纪律:Isend/Irecv 之后、Wait 之前不能碰缓冲区——它是”薛定谔的缓冲区”
    • 配套:MPI_Test(非阻塞轮询)、MPI_Waitall / MPI_Waitany / MPI_Waitsome(批量等待)
  3. 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

四、集合通信

MPI 集合通信四种数据流

集合通信(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 矩阵转置。

Alltoall 矩阵转置

操作 每人发出去什么
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 建群聊。

三条纪律:

  1. 每个进程有两套身份证:全局 rank 与队内 rank —— rank 翻译是组队编程最常见的 bug 源。跨队通信要用全局 rank
  2. 不在名单的进程拿到 MPI_COMM_NULL使用前必须检查
  3. 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 时还需满足交换律。

八、常见坑清单

  1. count 是”每人的份量”,不是总数(Scatter/Gather/Alltoall 都这样)
  2. Allgather 的接收缓冲区按 world_size × recv_count
  3. 集合通信必须全员调用,参数一致,少一个就死锁
  4. Allreduce 与 Reduce 的取舍是正确性问题
  5. root 自己也参与收发,别漏了它那份
  6. 非阻塞通信期间不能碰缓冲区
  7. MPI_Send 返回不代表对方已收到;大消息可能直接阻塞,死锁与否可能被缓冲掩盖
  8. MPI_Scatter 要求每份等长,总数除不尽时尾部数据要自己兜底(或用 Scatterv);集合通信不会替你检查分块对不对
  9. 跨队通信要用全局 rank,队内编号只在自己队里有效;MPI_COMM_NULL 必须先检查
  10. Alltoall 消息数 N²,大规模下往往是性能瓶颈
  11. 自己写的归约算子必须满足结合律
  12. 计时前后要加 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_gatherall_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/sendops/recvops/batch_send_recv

算法层也能对上:树形对折 → RHD(Recursive Halving-Doubling);环 → Ring全交换 → PairWise

MPI 扁平通信与 HCCL 分层通信

最本质的差别是: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 就能跑。