简介:这份面向并行计算初学者与科学计算开发者的文档,围绕基于 MPI 的矩阵乘法并行实现展开,重点解决多处理器环境下矩阵乘法的任务划分、通信与结果聚合问题。压缩包共1个 docx 文件,大小约15KB,内容以 C/C++ 代码与算法说明为主,便于直接阅读和对照实验。文档涉及 MPI_Init、MPI_Comm_rank、MPI_Bcast、MPI_Scatterv、MPI_Allgather、MPI_Gatherv 等通信函数的使用,并讨论分布式内存架构下的分块计算、结果归并与 BLOCK_SIZE 优化思路,读者可据此理解矩阵 A 分发、矩阵 B 广播及局部乘法结果的聚合流程。已有444人学习,适合需要掌握 MPI 并行矩阵乘法基础实现与调试思路的读者参考。
1. 矩阵乘法 mpi 并行计算:几十行 C 代码里藏着一个完整的分布式闭环
单机跑一遍 1024×1024 的整型矩阵乘法,三重循环几秒钟就过去了;把同样的循环塞进 MPI,用 4 个进程各算四分之一的行,时间掉到三分之一左右——但前提是block、col、counts/ofss这三个数算对了,错一个就是段错误或者一屏随机数。这份用 C 写的矩阵乘法 mpi 并行计算示例,把 MPI 并行计算的四个动作拉成一条直线:根进程广播矩阵 B、MPI_Scatterv把 A 按行切开分发、各进程在本地做内积、MPI_Gatherv把结果拼回根进程。它跑在分布式内存架构上,不依赖任何第三方数学库,一个 mpicc 就能编译。适合刚接触 MPI 通信函数、想拿一份能改能跑的骨架回去做算法验证的开发者。下面从数据怎么切开始,一路拆到编译命令和翻车点。
2. 骨架与数据切分:block、col、counts/ofss 三个数决定程序对不对
这份代码不到一百行,却把 MPI 集合通信里最常用的几个函数都用了一遍:MPI_Bcast、MPI_Scatterv、MPI_Gatherv,外加MPI_Comm_rank和MPI_Comm_size这对拿进程信息的入口。它算的东西得先说清楚,否则后面看代码会拧巴:A 是 m×n,B 也是 m 行 n 列,但循环里写的是B[k][j]并且 k 从 0 走到 m,所以参与运算的其实是 B 的转置,最终结果是 m×m 的 C = A × Bᵀ。打印的时候它把 B 按 n 行 m 列输出,视觉上正好是那个转置后的形状,对得上。
为什么要把 B 设计成这个方向?因为每个进程手里只有 A 的一段行,要算 C 的一行,就得跟 B 的每一行做一次内积。B 按行广播、按行取用,最后RETA也能按列主序写回去,省掉一次转置搬移。这个布局选择贯穿全文,理解了它,后面RETA[i*m+k]这种看着别扭的下标就顺了。
2.1 入口:MPI_Init 与两个命令行参数
程序的所有参数都从命令行来,源码里没有配置文件也没有读文件,这一点对复现很友好,也给后面调参留了口子。
#include <stdio.h> #include <stdlib.h> #include <time.h> #include "mpi.h" #define N 3 /* 随机元素取模上限,元素落在 0..2,方便肉眼核对结果 */ int main(int argc, char **argv) { MPI_Init(&argc, &argv); /* 起 MPI 运行时,后面所有调用都依赖它 */ int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); /* 当前进程编号,0..size-1 */ MPI_Comm_size(MPI_COMM_WORLD, &size); /* 本次启动的进程总数 */ int m = atoi(argv[1]); /* A 的行数,也是 C 的行列数 */ int n = atoi(argv[2]); /* A 的列数,也是 B 的列数 */MPI_Init必须在任何 MPI 调用之前执行,argc/argv要原样传进去,MPI 实现会从里面摘走自己的参数(比如某些实现认--mca之类的选项)。MPI_COMM_WORLD是默认通信域,本次mpirun拉起来的进程全在里面,编号从 0 到 size-1 连续分配。
两个参数m和n直接atoi,源码没做任何校验。少传一个参数就是拿 NULL 去解引用,程序直接段错误退出,看不到任何提示。我在本地第一次跑就是这样翻车的:忘了写第二个参数,屏幕上只有一个 Segmentation fault,排查了十分钟才发现是命令行长度的问题。改的时候第一件事应该是补上if (argc < 3) { ... }的检查,这个后面第 6 章还会提。
2.2 block 的向上取整与最后一个 rank 的修正
这是整个程序里最容易看走眼的两行。block的单位不是行数,是元素个数,因为 Scatterv 是按元素计数的。
/* 每个进程分到的元素个数,向上取整到整行 */ int block = (m + size - 1) / size * n; /* 最后一个进程只拿剩下的部分,防止越界读 */ if (rank == size - 1 && block > m * n - (size - 1) * block) block = m * n - (size - 1) * block;(m + size - 1) / size是整数除法下的向上取整,算出每个进程分到几行;再乘n换算成元素个数。乘 n 这一步不能省,否则一行会被劈成两半,本地做内积时还得处理跨行的边界情况,代码复杂度立刻上一个台阶。
第二个if只在最后一个 rank 上生效:如果按统一 block 分下去会超出 A 的总长度m*n,就把最后一份裁成实际剩余量。举例,m=8、n=4、size=4 时 block = 2×4 = 8 个元素,正好两行,最后一份也是 8,不触发修正;m=9 时 block = 3×4 = 12,前三份吃掉 36 个元素,最后只剩 36 个元素里的 0 个……准确说是9*4 - 3*12 = 0,最后一个进程一行都分不到。这个边界就是后面第 5 章要展开的坑,代码在这里只做了"不越界"的保底,没做"负载均衡"的保证。
注意一个细节:这个修正只在最后一个进程上改变了自己的block局部变量,其他进程的block还是原值。根进程构造counts时用的是自己那份原值,再加上一次min判断来处理尾巴,两头是对齐的。这种"各算各的、靠约定对齐"的写法在 MPI 里很常见,但改代码时特别容易改漏一边。
2.3 counts 与 ofss:Scatterv 需要的两张表
MPI_Scatterv是变长分发,跟MPI_Scatter的区别就在于每个进程收到的数量可以不同,代价是要额外传两张表:每份多长、每份从哪开始。
if (rank == 0) { counts = (int *)malloc(size * sizeof(int)); ofss = (int *)malloc(size * sizeof(int)); for (i = 0; i < size - 1; ++i) { counts[i] = block; /* 前 size-1 份等长 */ ofss[i] = i * block; /* 偏移 = 前面所有份的长度之和 */ } /* 最后一份取剩余量,防止 counts 总和超过 m*n */ counts[i] = (block <= m * n - i * block) ? block : (m * n - i * block); ofss[i] = i * block; }counts[i]是第 i 个进程从 A 里拿走多少个 int,ofss[i]是它在 A 这个一维数组里的起始下标。因为前 size-1 份等长,偏移就是简单的i * block,不需要累加;只有最后一份需要单独判一下剩余量。A 是用malloc(m*n*sizeof(int))分配的一维连续内存,行主序,A[i*n+j]取第 i 行第 j 列——正因为是连续的,才能整块丢给 Scatterv 切。
这段代码在源码里还留着几行被注释掉的printf,用来打印每份的 counts 和 ofss。调试分发逻辑的时候把注释放开非常有用,能直接看到根进程是怎么切蛋糕的;正式跑的时候记得再注释回去,否则输出会被进程间的打印顺序搅乱。
2.4 随机初始化:为什么取模 N=3
矩阵元素不是从文件读的,是rand()现场生成的,取模 3 之后元素只可能是 0、1、2。
if (rank == 0) { A = (int *)malloc(m * n * sizeof(int)); /* 只有根进程持有完整的 A */ srand((int)time(0)); for (i = 0; i < m; ++i) { for (j = 0; j < n; ++j) { A[i * n + j] = (int)rand() % N; /* 0..2,便于手算验证 */ B[i][j] = (int)rand() % N; /* B 由根进程统一生成后广播 */ } } }取模 3 是个很实用的选择:元素小,乘加之后不会溢出 int,结果矩阵里每个数都能在脑子里过一遍,验证正确性的时候省事。真正的科学计算当然不会这么干,但这套流程——根进程生成数据、切分、分发、计算、回收——跟读文件进来是一模一样的,把rand()换成fscanf就能接真实数据。
B 是个二级指针,B[i]每一行单独malloc(n*sizeof(int))。这个结构决定了后面广播 B 的时候没法一次性发完,只能按行循环。如果自己重写,我更倾向把 B 也压成一维连续数组,一次MPI_Bcast(Bflat, m*n, MPI_INT, 0, ...)就搞定,通信次数从 m 次降到 1 次。
3. 通信三段式:Bcast、Scatterv、Gatherv 各自在干什么
数据备齐之后,剩下的就是三段通信。这段代码的顺序值得细看:先广播 B 再分发 A,而不是反过来。原因是 Scatterv 之后每个进程手里的TA只是 A 的一段,但每个进程都需要完整的 B 才能算出自己那几行结果;B 先到,A 后到,中间不需要任何同步点,逻辑上最直白。
3.1 广播 B:为什么按行循环 m 次
for (i = 0; i < m; ++i) MPI_Bcast(B[i], n, MPI_INT, 0, MPI_COMM_WORLD);MPI_Bcast的签名是(buffer, count, datatype, root, comm),根进程把 buffer 里的 count 个元素发给通信域里所有进程,包括自己。这里每行发 n 个 int,一共发 m 次。
之所以要循环,是因为 B 是int**,B[0]、B[1]这些行在堆上不是连续分配的,直接MPI_Bcast(B, m*n, ...)会把一堆指针当数据发出去,接收端拿到的全是垃圾地址。这是新手最容易踩的坑之一,指针的连续性跟数组的连续性完全是两回事。
循环 m 次的代价是 m 次通信启动开销。对 m=100 的小矩阵,这点开销可以忽略;m 上千之后,每次 Bcast 的延迟叠加起来就会明显拖慢整段通信。而且这 m 次调用是串行阻塞的,所有进程都得在原地等这 m 次集合通信走完才能进下一步。
提示:这个循环里的
count是 n 而不是 mn,因为每次只发一行。改成一维连续数组的话,一次调用发 mn 个 int,通信次数直接降到 1。
3.2 Scatterv:把 A 的元素数当成行数用
广播完 B,接着把 A 切开分发。这里用的是变长版本,因为最后一份可能短一截。
MPI_Scatterv(A, /* 根进程的发送缓冲,非根进程传 NULL 即可 */ counts, /* 每份多少个元素 */ ofss, /* 每份从哪个下标开始 */ MPI_INT, TA, /* 本地接收缓冲,大小 = 自己那份的 block */ block, /* 本地能接多少个元素 */ MPI_INT, 0, MPI_COMM_WORLD);九个参数,看着多,其实分三组:发送侧(缓冲、每份长度表、每份偏移表、类型)、接收侧(缓冲、本地长度、类型)、控制侧(根进程编号、通信域)。counts和ofss只在根进程上有意义,非根进程传 NULL 不会报错,因为 MPI 在那些进程上根本不读这两个指针。
TA是在 Scatterv 之前就malloc(block * sizeof(int))好的,大小跟自己的block一致。这里有个隐含约定:接收侧的recvcount必须小于等于实际会收到的元素数。源码里最后一个进程的block已经在前面的修正里改小了,所以TA和被裁的counts[size-1]是匹配的;如果前面的修正逻辑漏了,这里就会读到缓冲末尾之外的地址。
Scatterv 完成后,每个进程手里有block个 int,也就是col = block / n行 A。所有进程的col加起来应该等于 m,验证这一点最快的方法就是在每个进程里打印rank和col,看总和对不对。
3.3 局部累加:RETA 为什么按列主序写
计算部分没有用MPI_Reduce,而是每个进程自己算完、最后统一收集,这是矩阵乘法常见的一种分工:按行切 A,结果也按行产出。
int col = block / n; /* 本进程负责几行 A */ int *RETA = (int *)malloc(col * m * sizeof(int)); /* 本地结果,col 行 m 列 */ for (k = 0; k < m; ++k) { /* 遍历 B 的每一行 = 结果的每一列 */ for (i = 0; i < col; ++i) { /* 本进程负责的 A 的每一行 */ int tmp = 0; for (j = 0; j < n; ++j) /* 内积 */ tmp += TA[i * n + j] * B[k][j]; RETA[i * m + k] = tmp; /* 注意下标顺序:行内偏移 i,列号 k */ } }循环顺序是 k-i-j,不是教科书上的 i-j-k。这个顺序下,内层 j 循环同时扫过TA的一行和B[k]的一行,两个都是连续内存,缓存命中率高;如果换成 i-j-k,RETA[i][k]在每次内层迭代都要跳到不同的位置,缓存会反复失效。这类循环顺序的调整在这个规模上看不出差别,矩阵一大就是两三倍的差距,属于血泪经验级别的细节。
RETA的下标写成i * m + k而不是i * col + k,是因为它按列主序存:第 i 行第 k 列的结果放在第 i 段的第 k 个位置。这样最后 Gatherv 把各段首尾拼起来,得到的正好是行主序的 C 矩阵,中间不需要再做一次转置。这个设计很省事,但也容易在改动时写错下标——一旦写成i * col + k,结果矩阵的每一行都会错位,而且错得很有规律,排查时容易被"看起来差不多"的表象骗过去。
3.4 Gatherv:counts 的单位要换一次
收集之前,根进程要重新算一遍counts和ofss,因为这两张表在上一个阶段描述的是"A 切几份",现在要描述的是"结果切几份",单位从元素数变成了结果元素数。
if (rank == 0) { ofss[0] = 0; counts[0] = col * m; /* 第一个进程贡献 col 行 × m 列 */ for (i = 1; i < size; ++i) { counts[i] = counts[i] / n * m; /* 元素数 → 结果元素数:除以 n 再乘 m */ ofss[i] = ofss[i - 1] + counts[i - 1]; } } MPI_Gatherv(RETA, col * m, MPI_INT, /* 每个进程交出 col*m 个结果 */ RES, counts, ofss, MPI_INT, /* 根进程按表拼接 */ 0, MPI_COMM_WORLD);counts[i] / n * m这一句是整段代码里最需要盯住的地方。原始counts[i]是"A 的元素个数",除以 n 得到行数,再乘 m 就是"该进程产出的结果元素个数"。之所以能这么整除,前提是所有counts[i]都是 n 的整数倍,而这个前提由第 2.2 节的划分方式保证——每份都是整行切的。
RES只有根进程分配,大小m * m,非根进程传 NULL。Gatherv 结束后,根进程手里的RES就是完整的 C,行主序,RES[i*m+k]对应第 i 行第 k 列。后面的打印就是三组双重循环,把 A、B 的转置视图和 C 依次打到屏幕上,顺手把各块内存free掉。
4. 编译、运行与结果核对:从 mpicc 到 mpirun -np 4
代码看明白之后,跑起来这一步反而是最省事的,坑主要集中在对 mpirun 参数和进程数的理解上。
4.1 编译:mpicc 与 -O2
# 确认工具链在 PATH 里 which mpicc mpirun --version # 编译,-O2 打开优化,-Wall 把警告全放出来 mpicc -O2 -Wall -o mpi_matmul mpi_matmul.cmpicc本质上是个包装脚本,它会自动带上 MPI 头文件路径和链接库,不需要自己写-I和-L。-Wall建议一定要加,这份代码里有几处"变量声明了但只在 root 分支里用"的情况,编译器会给出警告,正好帮你确认哪些变量只有根进程需要。
如果编译时报mpi.h: No such file or directory,说明环境变量没配好或者装的是运行时包而不是开发包;报链接错误找不到MPI_Init,一般是编译时用了普通的gcc而不是mpicc。这两类问题占了新手编译失败的大半。
4.2 运行:mpirun -np 与参数顺序
# 4 个进程,矩阵 3×3 mpirun -np 4 ./mpi_matmul 3 3 # 单进程跑一遍做基线 mpirun -np 1 ./mpi_matmul 3 3-np后面跟进程数,必须小于等于可用核数(超了会排队或者直接报错,取决于实现)。./mpi_matmul后面跟的两个数依次是 m 和 n,顺序不能反——第一个是 A 的行数兼结果的行列数,第二个是 A 的列数兼 B 的列数。
本机测试时如果遇到mpirun因为权限或者环境限制起不来,可以试试mpiexec,很多实现里这两个是同一个命令的两个名字。跑之前先mpirun -np 1验证单进程结果,再往上加进程数,能快速区分是算法写错还是并行部分写错。
4.3 结果核对:用 N=3 手算一遍
m=3、n=3、np=4 时,每个进程分到block = (3+3)/4*3 = 3个元素,也就是 1 行。最后一个进程的修正判断是3 > 9 - 3*3 = 0,成立,所以block被改成 0——这个进程一行都分不到。最终只有前 3 个进程参与计算,第 4 个进程的col是 0,交出 0 个结果。
输出长这样(数字每次运行都不同,因为srand(time(0))):
A: [ 2 0 1 ] [ 1 2 0 ] [ 0 1 2 ] B: [ 1 2 0 ] [ 0 1 2 ] [ 2 0 1 ] =: [ 4 ... ]核对方法很直接:拿 A 的第一行和打印出来的 B 的第一行对应元素相乘再相加,第一项应该是2*1 + 0*2 + 1*0 = 2。注意打印的 B 已经是转置后的视图,屏幕上 B 的第 i 行其实是原始 B 的第 i 列,别拿原始生成顺序去对,容易对不上然后怀疑代码。
4.4 观察:进程数变化时 block 怎么变
改-np多跑几组,观察每个进程分到多少行,比读代码直观得多。m=8 时,np=1 分 8 行、np=2 分 4 行、np=3 分 3/3/2 行、np=4 分 2/2/2/2 行。m=9 时情况开始难看:np=4 分成 3/3/3/0,最后一个进程空转;np=5 分到 2/2/2/2/1;np=10 直接变成 1/1/1/.../0,一大半进程没事干。
把col打印出来(临时加一行printf("rank %d col %d\n", rank, col);)是调整进程数时最快的观测手段。进程数与行数的整除关系直接决定了负载均衡,这一点在下一章的避坑里会展开讲。
5. 避坑与排查:整除、缓冲区、空指针三处高频翻车
这段代码逻辑不长,但边界条件一个都不少。下面几条都是我自己复现时真撞上的,按"现象 → 原因 → 解决"写。
5.1 进程数除不尽行数:最后一个进程分到负数或零
现象:mpirun -np 4 ./mpi_matmul 5 3跑出来的结果矩阵有一块全是 0,或者进程直接异常退出。换成 m=6 就正常。
原因:block = (5+3)/4*3 = 2*3 = 6,前三个进程各吃 6 个元素共 18 个,而 A 总共只有 15 个元素,最后一个进程的修正算出来是15 - 18 = -3,malloc(-3)之后后面全乱。这个划分公式只在m能被size整除、或者余数恰好让最后一个进程非负的时候成立。
解决:换成按行区间划分,每个进程负责[row_start, row_end)这么一个不会为负的区间,具体写法在第 6 章。临时验证的话,让 m 是 size 的整数倍就行,比如-np 4配 m=8。
5.2 非 root 进程的 A 是 NULL
现象:把源码里注释掉的 A 打印打开,非根进程序号打印出一串陌生数字,或者直接段错误。
原因:A只在if (rank == 0)分支里malloc,其他进程上它始终是 NULL。Scatterv 的发送缓冲参数在非根进程上本来就不被读取,传 NULL 是合法的;但一旦你在非根进程上访问A[i],就是在读空指针。
解决:所有涉及完整A的操作都加rank == 0判断,或者干脆在调试时只让根进程打印。同样的规则适用于counts、ofss、RES,它们都只在根进程上有意义。
5.3 recvcount 与实际接收量不一致
现象:Gatherv 之后结果矩阵末尾几行是随机数,而且每次运行值都不一样。
原因:Gatherv 的recvcount是"这个进程收多少个结果",如果根进程还沿用 Scatterv 阶段那张以"A 的元素数"为单位的counts表,每个进程就会被判定为要多收 n 倍的数据,RES缓冲区直接被写越界。
解决:进入 Gatherv 之前把counts的单位整体换算一次(除以 n 再乘 m),并把ofss[0]重设为 0。上一个阶段的偏移表不能直接复用,这一点代码里处理了,但自己改划分方式时很容易忘。
5.4 进程多了反而更慢
现象:m=2000 时,-np 2比-np 1快接近一倍,但-np 16反而比-np 8慢。
原因:一是 B 的广播是 m 次串行 Bcast,通信次数跟进程数无关但跟 m 线性相关,进程一多,每次广播的同步等待成本上升;二是每个进程的col小到一定程度之后,通信开销超过计算开销,加速比曲线就开始往下拐。还有一个隐形成本:所有进程都持有一份完整的 B,内存带宽被重复占用。
解决:先把 B 压成一维连续数组,m 次 Bcast 合成一次;再看矩阵规模决定进程数,经验值是每个进程分到至少几百行才有并行收益。真要压到很大,得上二维分块(Cannon 或 Fox 算法),那已经是另一个量级的改造。
6. 二次开发:把 demo 改成能扛大矩阵的乘法内核
先说最该改的一处:行划分。现在的(m+size-1)/size只保证不越界,不保证每个进程都有活干。我一般换成下面这种按区间算的写法,进程数超过行数也不会算出负数。
/* 每个进程负责的行区间 [row_start, row_end) */ int base = m / size; int rem = m % size; int rows = base + (rank < rem ? 1 : 0); /* 本进程负责几行 */ int start = rank * base + (rank < rem ? rank : rem); /* 起始行号 */ int *TA = (int *)malloc(rows * n * sizeof(int)); /* 可能为 0,malloc(0) 合法 */ int *cnt = NULL, *dsp = NULL; if (rank == 0) { cnt = (int *)malloc(size * sizeof(int)); dsp = (int *)malloc(size * sizeof(int)); for (int r = 0; r < size; ++r) { cnt[r] = (base + (r < rem ? 1 : 0)) * n; /* 换成元素个数 */ dsp[r] = (r == 0) ? 0 : dsp[r - 1] + cnt[r - 1]; } } MPI_Scatterv(A, cnt, dsp, MPI_INT, TA, rows * n, MPI_INT, 0, MPI_COMM_WORLD);rows和start都是非负数,进程数大于行数时后面一批进程拿到rows = 0,malloc(0)返回的指针不参与任何读写,Scatterv 也不会给它们发数据,逻辑上干净。换成这套划分之后,第 5.1 节那个负数问题自然消失。
第二处是 B 的广播。把int**换成一块m*n的连续内存,m 次集合通信压成一次:
int *Bflat = (int *)malloc((size_t)m * n * sizeof(int)); /* 所有进程都分配 */ if (rank == 0) { for (int i = 0; i < m; ++i) memcpy(Bflat + (size_t)i * n, B[i], n * sizeof(int)); /* 二维拍平 */ } MPI_Bcast(Bflat, m * n, MPI_INT, 0, MPI_COMM_WORLD); /* 一次广播搞定 */注意所有进程都要malloc,不能只在根进程上分配——Bcast 的接收侧需要一块真实可写的内存。改完之后计算内核里的B[k][j]要相应换成Bflat[k*n+j],编译器不会替你检查这种下标迁移,改漏一处就是错结果,最好拿-np 1跟改之前的结果逐元素对一遍。
第三处是计时。原代码只#include <time.h>但没用上,真正测并行性能得用MPI_Wtime(),它返回墙钟时间,跨进程一致,在MPI_Barrier之后取两次差值就是本段耗时:
MPI_Barrier(MPI_COMM_WORLD); double t0 = MPI_Wtime(); /* ... 计算与通信 ... */ MPI_Barrier(MPI_COMM_WORLD); double t1 = MPI_Wtime(); if (rank == 0) printf("m=%d n=%d np=%d time=%.4fs\n", m, n, size, t1 - t0);MPI_Barrier不能省,不然根进程可能在别人还没算完的时候就取了t0,测出来的数字毫无意义。这也是性能测试里最常见的自欺欺人。
验证改动是否正确的动作我固定三步:-np 1跑一遍存下输出当基准;-np 2、-np 4、-np 8各跑一遍,跟基准逐元素比对;再把 m 换成不能被进程数整除的值(比如-np 4配 m=9、m=11)跑一轮。三步都过,才敢往大矩阵上推。从那以后我每次动 MPI 的切分代码,都先把 size 从 1 到 m+1 全跑一遍再谈性能。希望帮到你。
本文还有配套的精品资源,点击获取