MPI 并行编程入门:从点到点通信到非阻塞

系统讲解 MPI 并行编程核心概念,涵盖初始化、点对点通信、集合通信与非阻塞通信,配合完整 C 代码示例。

MPI(Message Passing Interface)是分布式内存系统中最主流的并行编程标准。无论节点间通过 InfiniBand 还是以太网互联,MPI 提供了统一的通信原语。理解 MPI 通信模型是编写可扩展并行程序的基础。

MPI 程序基本结构

每个 MPI 程序遵循固定的生命周期模式:

  1. 初始化 MPI_Init:建立进程通信域
  2. 通信 MPI_Send / MPI_Recv:进程间交换数据
  3. 结束 MPI_Finalize:释放资源并退出
#include <mpi.h>
#include <stdio.h>

int main(int argc, char** argv) {
    int rank, size;
    MPI_Init(&argc, &argv);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    MPI_Comm_size(MPI_COMM_WORLD, &size);
    printf("Hello from rank %d of %d\n", rank, size);
    MPI_Finalize();
    return 0;
}

编译运行:

mpicc hello.c -o hello
mpirun -np 4 ./hello

点对点通信模型

点对点通信涉及明确的发送方和接收方,对应 MPI_SendMPI_Recv

// 进程 0 发送消息到进程 1
if (rank == 0) {
    int data[100];
    for (int i = 0; i < 100; i++) data[i] = i;
    MPI_Send(data, 100, MPI_INT, 1, 0, MPI_COMM_WORLD);
}

// 进程 1 接收消息
if (rank == 1) {
    int buf[100];
    MPI_Status status;
    MPI_Recv(buf, 100, MPI_INT, 0, 0, MPI_COMM_WORLD, &status);
    int count;
    MPI_Get_count(&status, MPI_INT, &count);
    printf("Received %d integers\n", count);
}
参数说明
buf数据缓冲区指针
count元素数量
datatypeMPI 数据类型(MPI_INTMPI_DOUBLE 等)
dest / source目标 / 源进程 rank
tag消息标签,用于区分同一进程的不同消息
comm通信子(通常为 MPI_COMM_WORLD

一个常见的死锁场景:两个进程互相发送但未按序接收。解决方案是交错发送接收顺序,或使用非阻塞通信。

// 安全的交替配对通信:进程 0 先发后收,进程 1 先收后发
if (rank == 0) {
    MPI_Send(sendbuf, n, MPI_DOUBLE, 1, 0, MPI_COMM_WORLD);
    MPI_Recv(recvbuf, n, MPI_DOUBLE, 1, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
} else if (rank == 1) {
    MPI_Recv(recvbuf, n, MPI_DOUBLE, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
    MPI_Send(sendbuf, n, MPI_DOUBLE, 0, 0, MPI_COMM_WORLD);
}

集合通信原语

集合通信涉及通信子内所有进程的同步参与,比逐对发送更高效。

Broadcast 广播

根进程将数据分发给所有进程:

int root = 0;
int data = 0;
if (rank == root) data = 42;
MPI_Bcast(&data, 1, MPI_INT, root, MPI_COMM_WORLD);
// 所有进程此时 data == 42

Reduce 归约

进程计算结果汇总为标量值,支持 MPI_SUMMPI_MAXMPI_MINMPI_PROD 等操作:

int local_sum = rank + 1;  // 进程 0:1, 1:2, ..., 3:4
int global_sum;
MPI_Reduce(&local_sum, &global_sum, 1, MPI_INT,
           MPI_SUM, 0, MPI_COMM_WORLD);
// 进程 0 的 global_sum = 1+2+3+4 = 10
进程 0:1 ──┐
进程 1:2 ──┼─ Bottom-Up 二叉树归约 ──→ 根进程: 10
进程 2:3 ──┘
进程 3:4 ──┘

Scatter / Gather 分发与收集

// Scatter: 根进程的数组分段分发给每个进程
int sendbuf[16], recvbuf[4];
for (int i = 0; i < 16; i++) sendbuf[i] = i;
MPI_Scatter(sendbuf, 4, MPI_INT, recvbuf, 4, MPI_INT, 0, MPI_COMM_WORLD);
// rank 0 得到 [0,1,2,3],rank 1 得到 [4,5,6,7]...

// Gather: 收集各进程的 recbuf 到根进程的 recvbuf
int gathbuf[16];
MPI_Gather(recvbuf, 4, MPI_INT, gathbuf, 4, MPI_INT, 0, MPI_COMM_WORLD);

MPI_Allreduce:全局同步结果

MPI_Reduce 不同,MPI_Allreduce 将归约结果广播到所有进程,无需显式调用 MPI_Bcast

double local_norm = ...;
double global_norm;
MPI_Allreduce(&local_norm, &global_norm, 1, MPI_DOUBLE,
              MPI_SUM, MPI_COMM_WORLD);
// 所有进程都能访问 global_norm

这在迭代计算中极为常用——每个进程计算局部残差后需要判断是否全局收敛。

进程拓扑与 Cart 坐标映射

二维网格上的 Jacobi 迭代需要邻居进程通信,MPI_Cart_create 将一维 rank 映射到逻辑网格:

int dims[2] = {0, 0};
int periods[2] = {0, 0};
MPI_Dims_create(size, 2, dims);  // 自动分解,如 4x4
MPI_Comm cart_comm;
MPI_Cart_create(MPI_COMM_WORLD, 2, dims, periods, 1, &cart_comm);

int coords[2];
MPI_Cart_coords(cart_comm, rank, 2, coords);

// 获取上下左右邻居
int north, south, east, west;
MPI_Cart_shift(cart_comm, 0, 1, &west, &east);
MPI_Cart_shift(cart_comm, 1, 1, &north, &south);

Cart 拓扑的优势在于:进程 rank 与非结构化邻居查找解耦,使得同一逻辑程序能自适应不同节点规模。

非阻塞通信:Isend / Irecv

阻塞通信在 MPI_Send 完成前不能继续执行后续代码。非阻塞版本立即返回一个 MPI_Request 句柄,允许在通信"在途"的同时执行本地计算:

MPI_Request req;
MPI_Isend(sendbuf, n, MPI_DOUBLE, 1, 0, MPI_COMM_WORLD, &req);

// 重叠:在数据传输期间执行本地计算
compute_local(update_stencil, domain);

// 确保通信完成
MPI_Wait(&req, MPI_STATUS_IGNORE);

非阻塞同步的两种典型模式:

// 模式一:多个请求批量等待
MPI_Request reqs[4];
MPI_Isend(..., &reqs[0]);
MPI_Irecv(..., &reqs[1]);
MPI_Isend(..., &reqs[2]);
MPI_Irecv(..., &reqs[3]);
MPI_Waitall(4, reqs, MPI_STATUSES_IGNORE);

// 模式二:非阻塞只等边界邻居,内部计算完全重叠
for (step = 0; step < max_iter; step++) {
    // Phase 1: 启动边界交换
    MPI_Isend(halo_north, ..., north, ..., &reqs[0]);
    MPI_Irecv(halo_south, ..., south, ..., &reqs[1]);
    MPI_Isend(halo_west, ..., west, ..., &reqs[2]);
    MPI_Irecv(halo_east, ..., east, ..., &reqs[3]);

    // Phase 2: 更新内部区域(不依赖边界)
    jacobi_update(domain, FROM(1,1), TO(nx-1, ny-1));

    // Phase 3: 等待边界到达
    MPI_Waitall(4, reqs, MPI_STATUSES_IGNORE);

    // Phase 4: 更新边界行/列
    jacobi_update(domain, edges);
}

非阻塞通信的难点在于缓冲区生命周期——在 MPI_Wait 之前,sendbuf 不能被修改或释放。

通信优化要点

优化方向具体措施预期收益
减少消息数聚合小消息为大数据块减少延迟开销
重叠计算通信MPI_Isend/Irecv + Wait隐藏通信延迟
使用集体通信Allreduce 替代手写的 reduce+broadcast利用优化算法
避免密集 MPI_Send 到同一目标检查缓冲策略(标准模式 vs 同步模式)避免内部额外拷贝
通信子拆分MPI_Comm_split 创建子域通信子减少不必要同步

将阻塞通信改写为非阻塞循环允许 stencil 程序在典型 CPU 集群上获得 15%-30% 的性能提升。MPI 通信优化的核心口诀是:批量发送、计算重叠、集体优先。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「hpc」更多文章

  1. Slurm 集群调度系统深度解析与实战
  2. Roofline 性能模型:判定性能瓶颈与优化方向
  3. ROCm HIP GPU 编程实战