MPI 进阶通信:派生数据类型、通信子与单边通信

从派生数据类型的内存布局与 extent 语义讲起,系统梳理 MPI 通信子与进程组的切分方法、单边通信(RMA)的窗口与同步模型、持久化请求与邻居集合通信、共享内存窗口与动态进程管理,并给出可落地的性能调优与调试清单,帮助读者写出高扩展的并行程序。

入门阶段写 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_subarrayN 维数组的子块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_swapCAS 原子操作

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 通信、或需要在节点内零拷贝交换时,这些能力会成为性能天花板的关键变量,并行算法设计 中的案例正是它们的延伸。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「hpc」更多文章

  1. FPGA 可重构加速
  2. 互连拓扑设计:Fat-tree、Torus 与 Dragonfly
  3. 检查点与重启策略