入门阶段写 MPI,几乎只用 MPI_Send/MPI_Recv 和几个集合通信原语。但当网格分块不再连续、当子域需要独立通信域、当算法希望把数据搬运交给硬件引擎时,标准原语就不够用了。本文聚焦三类进阶能力:派生数据类型解决「不连续数据一次搬完」,通信子与进程组解决「谁和谁说话」,单边通信解决「谁发起、谁等待」。理解它们的内存语义,是写出既正确又高效并行程序的关键。若对基础原语尚不熟悉,建议先回顾 MPI 并行编程入门
。
派生数据类型
派生数据类型(Derived Datatype)把内存中不连续的多个片段描述成单个类型,让一次 MPI_Send 就能搬运整块逻辑数据,避免手工打包(pack)带来的额外拷贝。
为什么需要它
考虑一个 C 结构体数组或矩阵的一列:它们在内存中跨步分布,元素之间隔着其他数据。若逐元素发送,消息数爆炸;若先 memcpy 到连续缓冲区,又白白多一次内存搬运。派生类型让 MPI 直接理解这种布局。
#include <mpi.h>
#include <stdlib.h>
/* 从 8x8 矩阵中取出第 0 列(步长 8,取 8 个元素) */
int rows = 8, cols = 8;
double *A = malloc(sizeof(double) * rows * cols);
MPI_Datatype coltype;
MPI_Type_vector(rows, /* count: 8 个块 */
1, /* blocklength: 每块 1 个元素 */
cols, /* stride: 块间步长 8 个元素 */
MPI_DOUBLE,
&coltype);
MPI_Type_commit(&coltype);
MPI_Send(A, 1, coltype, 1, 0, MPI_COMM_WORLD);
MPI_Type_free(&coltype);
注意 MPI_Send 的 count 是 1,因为一个 coltype 已经代表整列。
常用构造函数
| 函数 | 用途 | 关键参数 |
|---|---|---|
MPI_Type_contiguous | 连续 n 个元素 | count |
MPI_Type_vector | 规则跨步块 | count, blocklength, stride |
MPI_Type_create_hvector | 以字节为步长 | stride 单位是字节 |
MPI_Type_indexed | 每块偏移与长度可不同 | blocklengths[], displacements[] |
MPI_Type_create_indexed_block | 各块等长、偏移任意 | blocklength, displacements[] |
MPI_Type_create_subarray | N 维数组的子块 | sizes[], subsizes[], starts[] |
MPI_Type_create_struct | 异构字段(结构体) | blocklengths[], displacements[], types[] |
多维数组分块首选 MPI_Type_create_subarray,它直接描述「大数组里挖出一个小方块」,语义比手工拼 vector 清晰得多:
int sizes[2] = {N, N}; /* 全局数组维度 */
int subsizes[2] = {n, n}; /* 子块维度 */
int starts[2] = {i0, j0}; /* 子块起点 */
MPI_Datatype sub;
MPI_Type_create_subarray(2, sizes, subsizes, starts,
MPI_ORDER_C, MPI_DOUBLE, &sub);
MPI_Type_commit(&sub);
extent 与 resized
派生类型的「大小」有两层含义:内容字节数(size)与跨距(extent,即一个该类型元素占用的地址跨度)。默认 extent 从首字节到末字节,可能大于内容大小,导致数组步进错位。用 MPI_Type_create_resized 显式固定 extent,或对结构体类型用 MPI_Type_get_extent 核对:
MPI_Aint lb, extent;
MPI_Type_get_extent(structtype, &lb, &extent);
/* 若 extent != sizeof(struct), 用 create_resized 重设 */
MPI_Type_create_resized(oldtype, 0, sizeof(MyStruct), &newtype);
MPI_Type_commit(&newtype);
结构体类型还有一个隐蔽陷阱:C 编译器的内存对齐填充。必须用 offsetof 计算真实偏移,绝不能用字段大小累加。
手工打包与派生类型的取舍
当数据布局实在无法用派生类型描述(例如运行时才确定的不规则模式),退路是手工打包:
int position = 0;
MPI_Pack(src, 10, MPI_DOUBLE, buffer, bufsize, &position, MPI_COMM_WORLD);
MPI_Pack(other, 5, MPI_INT, buffer, bufsize, &position, MPI_COMM_WORLD);
MPI_Send(buffer, position, MPI_PACKED, dest, 0, MPI_COMM_WORLD);
接收端用 MPI_Unpack 按相同顺序还原。打包把数据复制进连续缓冲区,代价是一次额外的内存拷贝,但换来布局的完全自由。选择原则:
| 场景 | 推荐 |
|---|---|
| 规则跨步 / 子块 | 派生类型(零拷贝) |
| 结构体数组 | MPI_Type_create_struct |
| 运行时确定的不规则模式 | MPI_Pack / MPI_Unpack |
| 只需发送少量元素 | 直接多次点对点或合并为数组 |
派生类型的本质是「延迟打包」:把描述交给 MPI,由它在发送时按需从原内存抓取,省掉了用户侧的中间缓冲区。
通信子与进程组
通信子(Communicator)定义「哪些进程互相通信」。把世界拆成多个子域,能减少不必要的同步、隔离算法模块、实现主从分工。
按颜色切分
MPI_Comm_split 是使用频率最高的切分原语:按 color 分组,同色进程进入同一新通信子,key 决定新子域内的 rank 顺序:
int color = rank % 2; /* 奇偶分成两组 */
int key = rank;
MPI_Comm newcomm;
MPI_Comm_split(MPI_COMM_WORLD, color, key, &newcomm);
int newrank, newsize;
MPI_Comm_rank(newcomm, &newrank);
MPI_Comm_size(newcomm, &newsize);
/* color 传 MPI_UNDEFINED 的进程不进任何新子域 */
典型用法:三维域分解中把同一 z 层的进程聚成「铅笔」子域,只在层内做二维通信。
按硬件拓扑切分
MPI_Comm_split_type 依据硬件特征自动分组,最常用于把同节点进程聚成共享内存通信子:
MPI_Comm nodecomm;
MPI_Comm_split_type(MPI_COMM_WORLD, MPI_COMM_TYPE_SHARED,
0, MPI_INFO_NULL, &nodecomm);
得到 nodecomm 后,同节点进程可用 MPI-3 的共享内存窗口直接读写彼此的缓冲区,绕过网络栈做零拷贝交换——这是混合编程中节点内通信优化的常用手段。
进程组操作
通信子绑定一个进程组(Group),组支持集合运算,再据此构造新通信子:
MPI_Group world_group, io_group, compute_group;
MPI_Comm_group(MPI_COMM_WORLD, &world_group);
int io_ranks[4] = {0, 1, 2, 3};
MPI_Group_incl(world_group, 4, io_ranks, &io_group);
MPI_Group_difference(world_group, io_group, &compute_group);
MPI_Comm io_comm, compute_comm;
MPI_Comm_create(MPI_COMM_WORLD, io_group, &io_comm);
MPI_Comm_create(MPI_COMM_WORLD, compute_group, &compute_comm);
| 原语 | 语义 |
|---|---|
MPI_Comm_dup | 完整复制,含独立上下文(避免消息串扰) |
MPI_Comm_split | 按 color/key 切分,最常用 |
MPI_Comm_create_group | 只让组内进程参与,扩展性好于 MPI_Comm_create |
MPI_Comm_split_type | 按硬件(共享内存等)自动分组 |
MPI_Comm_create 是集合操作,必须由 MPI_COMM_WORLD 全体调用;而 MPI_Comm_create_group 只要求组内进程调用,在大规模下能显著减少启动开销,是 MPI-3 推荐做法。
共享内存窗口(MPI-3)
同节点进程之间走网络栈是浪费。MPI-3 的共享内存窗口允许进程直接读写彼此的本地内存,实现零拷贝的节点内交换。
MPI_Comm nodecomm;
MPI_Comm_split_type(MPI_COMM_WORLD, MPI_COMM_TYPE_SHARED,
0, MPI_INFO_NULL, &nodecomm);
int noderank, nodesize;
MPI_Comm_rank(nodecomm, &noderank);
MPI_Comm_size(nodecomm, &nodesize);
/* 每个进程在共享段中申请一块可被同节点其他进程直接访问的内存 */
MPI_Win shmwin;
double *shm;
MPI_Win_allocate_shared(shm_local_bytes, sizeof(double),
MPI_INFO_NULL, nodecomm, &shm, &shmwin);
/* 查询邻居 rank 的共享段基址,直接指针访问,无需 MPI 调用 */
double *peer_base;
MPI_Aint peer_size;
MPI_Win_shared_query(shmwin, peer_rank, &peer_size, &peer_base, &peer_base);
拿到 peer_base 后,读写邻居数据就是普通指针操作,不产生任何网络或 MPI 消息开销。MPI_Win_allocate_shared 与普通 MPI_Win_allocate 的区别在于:前者保证同一共享通信子内的进程能互相看到物理内存。这套机制是把「节点内用共享内存、节点间用消息」的混合模型显式写进 MPI 程序的基础,也是 CUDA 与 MPI 混合编程
中减少主机侧通信的常用手段。
单边通信与 RMA
单边通信(Remote Memory Access, RMA)把通信拆成「发起方」和「目标方」:目标方只需在窗口上暴露内存,发起方即可远程读写,无需目标方调用匹配的接收。适合生产者-消费者、随机访问、任务窃取等不规则模式。
窗口创建
MPI-3 推荐用 MPI_Win_allocate,内存与窗口一次分配:
double *base;
MPI_Win win;
MPI_Win_allocate(bufsize * sizeof(double), sizeof(double),
MPI_INFO_NULL, MPI_COMM_WORLD, &base, &win);
/* 每个进程暴露自己的 base,其他进程可远程访问 */
同步模型
RMA 需要「epoch」界定访问区间,两种模式:
/* 模式一:主动目标(Passive Target),目标进程不参与 */
MPI_Win_lock(MPI_LOCK_EXCLUSIVE, target_rank, 0, win);
MPI_Put(local, n, MPI_DOUBLE, target_rank, offset, n, MPI_DOUBLE, win);
MPI_Get(remote_result, n, MPI_DOUBLE, target_rank, offset, n, MPI_DOUBLE, win);
MPI_Win_unlock(target_rank, win);
/* 模式二:集体同步,所有进程进入同一 epoch */
MPI_Win_fence(0, win);
MPI_Put(local, n, MPI_DOUBLE, target, 0, n, MPI_DOUBLE, win);
MPI_Win_fence(0, win); /* 此处保证 Put 已完成并可见 */
| 原语 | 作用 |
|---|---|
MPI_Put | 本地数据写到远端窗口 |
MPI_Get | 远端窗口数据读到本地 |
MPI_Accumulate | 远端原子累加(求和、最大值等) |
MPI_Fetch_and_op | 原子读改写,返回旧值 |
MPI_Compare_and_swap | CAS 原子操作 |
MPI_Accumulate 与 MPI_Fetch_and_op 让远端做原子归约,是分布式计数器、直方图、全局索引分配的高效实现方式。
完成与可见性
MPI_Put 返回不代表数据已到达。要保证本地完成用 MPI_Win_flush(对某目标)或 MPI_Win_flush_all(对所有目标);要保证远端可见,还需 MPI_Win_sync 配合内存栅栏:
MPI_Put(buf, n, MPI_DOUBLE, tgt, 0, n, MPI_DOUBLE, win);
MPI_Win_flush(tgt, win); /* 该目标的 Put 已排空 */
/* 若远端要读本地暴露内存,还需 MPI_Win_sync + MPI_Barrier */
混淆「本地完成」与「远端可见」是 RMA 最常见的正确性 bug。
持久化请求与邻居集合通信
持久化点对点
迭代 stencil 每步重复相同的 Isend/Irecv 模式。持久化请求预先创建请求对象,每步只调用 MPI_Start 启动,省去反复初始化请求的开销:
MPI_Request req[4];
MPI_Send_init(send_n, n, MPI_DOUBLE, north, 0, comm, &req[0]);
MPI_Recv_init(recv_n, n, MPI_DOUBLE, south, 0, comm, &req[1]);
MPI_Send_init(send_s, n, MPI_DOUBLE, south, 1, comm, &req[2]);
MPI_Recv_init(recv_s, n, MPI_DOUBLE, north, 1, comm, &req[3]);
for (int step = 0; step < nsteps; step++) {
MPI_Startall(4, req);
compute_interior(domain);
MPI_Waitall(4, req, MPI_STATUSES_IGNORE);
compute_boundary(domain);
}
注意:持久化请求每次 Start 后必须 Wait 才能再次 Start,缓冲区在 Wait 前不可修改。
邻居集合通信
拓扑通信子上的 MPI_Neighbor_alltoallw 一步完成所有邻居交换,比手工循环更快且更简洁:
/* 每个邻居发送/接收的数据量可不同(用数组描述) */
MPI_Neighbor_alltoallw(sendbuf, sendcounts, sdispls, sendtypes,
recvbuf, recvcounts, rdispls, recvtypes,
cart_comm);
配合非阻塞版本 MPI_Ineighbor_alltoallw 可与计算完全重叠,是七点/二十七点 stencil 的最优通信模式。
动态进程管理与互操作
MPI_Comm_spawn 允许运行中的程序按需启动新进程组,是实现「主控-工作池」或「参数扫描」的机制:
MPI_Comm workercomm;
int errcodes[NWORKERS];
MPI_Comm_spawn("./worker", MPI_ARGV_NULL, NWORKERS,
MPI_INFO_NULL, 0, MPI_COMM_SELF,
&workercomm, errcodes);
/* 父进程与子进程通过 workercomm 通信(其中 rank 0 为父端) */
MPI_Send(task, n, MPI_INT, 0, 0, workercomm);
子进程侧用 MPI_Comm_get_parent 拿到父通信子:
MPI_Comm parent;
MPI_Comm_get_parent(&parent);
/* 此后 parent 上的 rank 0 即父进程 */
| 原语 | 用途 |
|---|---|
MPI_Comm_spawn | 启动新进程组并建立互连通信子 |
MPI_Comm_get_parent | 子进程获取父通信子 |
MPI_Comm_disconnect | 断开互连通信子并释放资源 |
MPI_Intercomm_merge | 合并两个组为单一通信子 |
跨语言互操作(如 C 主程序调用 Fortran 内核)需注意 MPI_Comm 句柄在两种语言绑定下表示不同,必须用 MPI_Comm_c2f / MPI_Comm_f2c 转换,否则通信子会指向错误的内存对象。
调优与调试清单
| 问题 | 现象 | 处置 |
|---|---|---|
| extent 不匹配 | 数组元素错位、结果乱码 | MPI_Type_create_resized 固定 extent |
| 结构体填充 | 收发字节数对不上 | 用 offsetof + create_struct |
| 通信子串扰 | 不同模块消息互相误收 | MPI_Comm_dup 隔离上下文 |
| RMA 读到旧值 | 数据竞争、结果不稳定 | 补 MPI_Win_flush / MPI_Win_sync |
MPI_Comm_create 卡死 | 部分进程未调用 | 改用 MPI_Comm_create_group |
| 持久化请求重复 Start | 运行时报错或挂起 | 先 Wait 再 Start |
性能上,把跨步数据用派生类型一次发送,相比手工 pack 通常能减少 10%~30% 的通信时间;RMA 在随机访问模式下可接近单向带宽上限,但在延迟敏感的小消息场景未必优于点对点。选型要看访问模式:规则、连续、集体 → 集合通信;不规则、随机、单侧发起 → RMA。网络层的带宽与延迟特性会放大这些差异,可参考 InfiniBand 与 RDMA 一文了解底层机制。
小结
派生数据类型让「内存布局」与「通信语义」解耦,通信子让「谁和谁通信」可编程,单边通信让「发起与等待」分离。三者的共同点是:把 MPI 从「逐条消息」提升到「按数据结构与拓扑组织通信」。当你的程序开始混合 CPU 与 GPU 通信、或需要在节点内零拷贝交换时,这些能力会成为性能天花板的关键变量,并行算法设计 中的案例正是它们的延伸。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。