简介:这份 docx 文档聚焦基于 MPI 的并行矩阵乘法算法实现,面向已具备 C/C++ 基础、希望入门分布式内存并行计算的学生与开发者,用于解决大规模矩阵乘法中单机算力不足、任务拆分与进程通信难以落地的问题。内容围绕矩阵分块划分、广播与散射、结果聚合三条主线展开:先由 MPI_Init、MPI_Comm_rank、MPI_Comm_size 初始化并行环境并确定各进程分工,再用 MPI_Bcast 广播矩阵 B、MPI_Scatterv 按块分发矩阵 A,各进程独立完成计算后通过 MPI_Gatherv、MPI_Allgather 汇总结果,末尾以 MPI_Finalize 释放环境。文档还讨论了按 BLOCK_SIZE 分块以提升缓存命中率与计算效率的优化思路,并附上可直接编译运行的 C 语言代码,便于对照理解通信函数与调试。压缩包内仅 1 个 docx 文件,约 15KB,篇幅轻量、结构紧凑,适合作为课程实验或自学并行编程的参考模板。目前已有 444 人学习。
1. MPI 矩阵乘法源码拆解:先确认它到底算的是哪个形状的乘积
把矩阵乘法 mpi 并行计算压缩成一条 mpirun 命令,看着简单,真正翻车往往发生在分块除不尽的那一行。这份源码是一个 C 语言写的 MPI 并行矩阵乘法小工程:根进程随机生成 A、B,按行把 A 用 MPI_Scatterv 分散到各进程,用 MPI_Bcast 把 B 的每一行广播出去,各进程算局部点积,最后用 MPI_Gatherv 把结果收回根进程打印。它适合用来拆 MPI 的通信原语、理解分布式内存架构下的数据切分,也适合课程设计和并行计算入门实验;但它不是通用矩阵库,矩阵形状、进程数、counts/ofss 三个数对不齐就会错位。先别急优化,先看懂它在什么条件下才算对。
2. 源码骨架与矩阵形状:A、B、RETA 三条数据流怎么走
2.1 进程角色与四个 MPI 调用
这份代码的进程角色很清晰:rank 0 是根进程,负责生成 A、B,分配全局结果矩阵 RES,最后打印;其余进程只保留 B 的副本和自己分到的 A 行块。所有进程都执行同一份 main,从MPI_Init开始,到MPI_Finalize结束,中间靠rank和size分流。新手看 MPI 代码最容易把“根进程做的事”和“所有进程做的事”混在一起,实际上if(rank==0)外面的代码每个进程都会跑,里面的代码只有 rank 0 跑。
先看骨架里的四个关键调用。MPI_Comm_rank(MPI_COMM_WORLD,&rank)取当前进程编号,MPI_Comm_size(MPI_COMM_WORLD,&size)取总进程数;MPI_Bcast(B[i],n,MPI_INT,0,MPI_COMM_WORLD)把 B 的第 i 行从根进程广播给所有进程,循环 m 次;MPI_Scatterv(A,counts,ofss,MPI_INT,TA,block,MPI_INT,0,MPI_COMM_WORLD)把 A 按不等长块散给各进程;MPI_Gatherv(RETA,col*m,MPI_INT,RES,counts,ofss,MPI_INT,0,MPI_COMM_WORLD)把各进程的局部结果收回根进程。四个调用串起来,就是“根进程造数据、广播共享矩阵、散开分块矩阵、收集局部结果”的完整链路。
MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); /* 根进程生成 A 和 B;所有进程都为 B 分配行指针 */ if (rank == 0) { A = (int*)malloc(m * n * sizeof(int)); srand((int)time(0)); for (i = 0; i < m; ++i) { for (j = 0; j < n; ++j) { A[i * n + j] = (int)rand() % N; B[i][j] = (int)rand() % N; } } } /* B 的每一行都广播,循环 m 次 */ for (i = 0; i < m; ++i) { MPI_Bcast(B[i], n, MPI_INT, 0, MPI_COMM_WORLD); } /* A 按行块散开,TA 是本进程收到的元素 */ MPI_Scatterv(A, counts, ofss, MPI_INT, TA, block, MPI_INT, 0, MPI_COMM_WORLD);这段代码的逻辑说明:MPI_Bcast的第三个参数n是每行元素个数,MPI_INT是数据类型,0是根进程 rank。MPI_Scatterv的counts和ofss都以“元素个数”为单位,不是字节数;TA的接收长度block必须等于发送端counts[rank],否则运行时会报消息截断或结果错位。参数上最容易忽略的是block:它不是行数,而是本进程分到的 A 元素总数,等于行数乘以 n。
2.2 A 的行分块、B 的广播布局与 RETA 的转置式写入
原始代码用int block=(m+size-1)/size*n;做上取整分块,意思是每个进程最多分到ceil(m/size)行,每行 n 个元素。后面又对最后一个 rank 做了修正:if(rank==size-1 && block>m*n-(size-1)*block) block=m*n-(size-1)*block;,防止最后一块超出 A 的总元素数。这个修正本身是对的,但它只改了本进程的block,根进程构造counts时用的还是自己的block,于是根进程认为最后一块是block,最后那个进程实际只接收更少的元素,Scatterv 的发送计数和接收计数对不上。这是源码里最隐蔽的错位来源。
col=block/n是把元素数换算成行数。每个进程拿到col行 A,然后对 B 的每一行 k 做点积:外层k从 0 到 m-1,内层i从 0 到 col-1,最内层j从 0 到 n-1,累加TA[i*n+j]*B[k][j]。结果写进RETA[i*m+k],而不是常见的RETA[i*m+k]行主序结果矩阵的i行k列。这个写法不是随手写的:各进程的 RETA 按“局部行优先、k 为列”排,Gatherv 拼接时,进程 p 的偏移量按“前面所有进程的行数乘以 m”计算,拼起来正好是全局结果矩阵的行主序。换句话说,RETA 在进程内是“转置式写入”,但拼接后是正常结果。
int col = block / n; int *RETA = (int*)malloc(col * m * sizeof(int)); for (k = 0; k < m; ++k) { for (i = 0; i < col; ++i) { 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是 B 的行下标,也是结果矩阵的列下标;i是本进程局部行号;j是点积维度。RETA的长度是col*m,每个元素对应“本进程第 i 行、全局第 k 列”的结果。Gatherv 阶段根进程的counts要按“每个进程贡献的结果元素数”填,从 A 的rows*n换算成rows*m,不能简单用/n*m整除,因为最后一块的行数可能不是 n 的倍数,整除会截断。
2.3 为什么它默认只适合方阵:m、n、size 的约束推导
从数学上看,A 是 m 行 n 列,B 应该是 n 行 m 列,乘积 C 是 m 行 m 列。代码里 B 的分配是int **B=(int**)malloc(m*sizeof(int*));,每个B[i]分配n个 int,也就是按 m 行 n 列分配;但广播和乘法时又按B[k][j]访问,k 从 0 到 m-1,j 从 0 到 n-1;打印 B 时用for(i=0;i<n;++i) for(j=0;j<m;++j) printf("%d\t",B[j][i]);,说明逻辑上把 B 当成 n 行 m 列。分配形状和逻辑形状只有在m==n时才一致。所以这份源码最稳妥的运行条件是方阵,即 m=n。
第二个约束是进程数。block=(m+size-1)/size*n在size>m时会让某些进程分到 0 行,col=0,malloc(0)的行为依赖实现,后面的 Gatherv 也可能出现空贡献。一般建议1<=size<=m,并且 m 能被 size 整除时最稳;除不尽时要用修正后的 counts/ofss,而不是原始代码里“最后一个 rank 单独改 block”的写法。第三个约束是n必须能整除每个进程的 A 元素数,否则col=block/n会丢掉余数。原始代码没有处理这个余数,所以它适合教学演示,不适合直接拿去做任意形状的矩阵乘法。把这些约束先写进运行说明,比后面调半天参数更省时间。
3. 编译运行与参数:从 mpicc 到 mpirun 的最小复现路径
3.1 环境准备与编译命令
这份源码依赖 MPI 环境,常见做法是装 Open MPI 或 MPICH,然后用mpicc编译、mpirun运行。先确认编译器能用:mpicc --version能输出版本信息,说明 MPI 头文件和链接库路径已经配好。如果mpicc找不到,优先检查当前 shell 的 PATH 和 MPI 环境是否一致,不要一会儿用系统 gcc、一会儿用另一个 MPI 的 mpirun,混用最容易出现“编译过了但运行时报找不到符号”。
编译命令建议带警告:
mpicc -O2 -Wall -Wextra mpi_matmul.c -o mpi_matmul参数说明:-O2打开常用优化,矩阵乘法的内层循环对优化敏感;-Wall -Wextra把未使用变量、类型不匹配、隐式声明都暴露出来。原始代码把多个#include挤在一行,编译不受影响,但建议拆成标准写法:#include <stdio.h>、#include <stdlib.h>、#include <time.h>、#include "mpi.h"。如果编译报mpi.h: No such file or directory,说明mpicc不是当前环境里的 MPI 编译器,换成交付环境里配套的mpicc即可。
在某些容器环境里,以 root 身份运行 Open MPI 会被拒绝,这是常见现象,按提示加--allow-run-as-root就能继续;这不是代码问题,也不是 MPI 安装失败。先跑通单进程,再跑多进程,能少踩很多环境坑。
3.2 运行参数 m、n、进程数的实际含义
程序从argv[1]取 m,从argv[2]取 n,也就是矩阵 A 的行数和列数;进程数由mpirun -np指定。最小运行命令:
mpirun -np 4 ./mpi_matmul 4 4这里的-np 4是启动 4 个 MPI 进程,4 4是 m=4、n=4。因为源码默认只适合方阵,先用 4×4 跑通最稳:每个进程分到 1 行 A,block=4,col=1,Scatterv 和 Gatherv 的计数都整齐。跑通后再试 8×8、-np 2、-np 4,观察结果是否一致。
参数缺失是新手最容易翻车的地方。atoi(argv[1])在argv[1]为空时返回 0,程序会继续往下跑,然后出现malloc(0)、除零或空块。建议在MPI_Init之后加一段校验:
if (argc < 3) { if (rank == 0) { fprintf(stderr, "usage: %s m n\n", argv[0]); } MPI_Finalize(); return 1; } int m = atoi(argv[1]); int n = atoi(argv[2]); if (m <= 0 || n <= 0) { if (rank == 0) fprintf(stderr, "m and n must be positive\n"); MPI_Finalize(); return 1; }这段校验的逻辑说明:所有进程都检查参数,但只有 rank 0 打印用法,避免多进程重复输出;MPI_Finalize之前不能直接return,否则 MPI 环境没有正常关闭。参数校验不改变算法,但能把“玄学崩溃”变成明确报错。
3.3 用 1、2、4 进程做一致性对照
验证并行结果最直接的办法是做进程数对照。先跑单进程:
mpirun -np 1 ./mpi_matmul 4 4再跑 2 进程和 4 进程:
mpirun -np 2 ./mpi_matmul 4 4 mpirun -np 4 ./mpi_matmul 4 4如果三次输出的 A、B、结果完全一致,说明分块和收集逻辑在 4×4 这个规模下对上了。但原始代码用srand((int)time(0)),每次运行种子不同,A 和 B 会变,没法直接 diff。常见做法是把种子固定成常量,或者从环境变量读种子,例如srand(1);,这样同一组参数多次运行结果可复现。固定种子后再做进程数对照,才有意义。
对照时重点看三个地方:结果矩阵的行列数是不是 m×m;最后一行最后几列有没有变成 0 或旧值;不同进程数下同一位置的结果是否相同。4×4、-np 4是最小闭环,先把这个跑对,再上 7×7、10×10 这种除不尽的规模。除不尽的规模会立刻暴露 counts/ofss 的问题,是排查分块逻辑的试金石。
4. 通信拆解:Scatterv 与 Gatherv 的 counts、ofss 怎么填才不越界
4.1 为什么不用 Scatter 而用 Scatterv
MPI_Scatter要求每个进程收到等长数据块,MPI_Scatterv允许每个进程收到不同长度。矩阵行数 m 不一定能被进程数 size 整除,比如 m=10、size=4,行块是 3、3、3、1,长度不等,所以必须用 Scatterv。Scatterv 的发送端需要两个数组:counts[p]表示发给进程 p 的元素个数,ofss[p]表示进程 p 的数据在发送缓冲区里的起始元素偏移。两个数组的长度都等于 size,单位都是“元素个数”,不是字节数。
接收端只看三个参数:接收缓冲区TA、接收长度recvcount、数据类型。recvcount必须等于发送端counts[rank],这是 MPI 的匹配规则,不是“大概相等”。原始代码里TA按本地block分配,而最后一块的block被修正过,根进程的counts却可能还是旧值,于是接收端长度和发送端计数不一致,运行时就会报 truncated message,或者收到一半数据后结果错位。
4.2 发送端 counts/ofss 与接收端 recvcount 的匹配规则
更稳的写法是先把每个进程分到多少行算清楚,再换算成元素数。下面这个分块函数所有进程都能调用,根进程用它填 counts/ofss,从进程用它算自己的接收长度,保证两边一致:
/* rows[p] 是进程 p 分到的 A 行数,offset_rows[p] 是起始行 */ int *rows = (int*)malloc(size * sizeof(int)); int *offset_rows = (int*)malloc(size * sizeof(int)); int base = m / size; int rem = m % size; int cursor = 0; for (int p = 0; p < size; ++p) { rows[p] = base + (p < rem ? 1 : 0); offset_rows[p] = cursor; cursor += rows[p]; } int *counts = (int*)malloc(size * sizeof(int)); int *ofss = (int*)malloc(size * sizeof(int)); for (int p = 0; p < size; ++p) { counts[p] = rows[p] * n; /* A 块元素数 */ ofss[p] = offset_rows[p] * n; /* A 块起始元素偏移 */ } int recvcount = counts[rank]; int *TA = (int*)malloc(recvcount * sizeof(int)); MPI_Scatterv(A, counts, ofss, MPI_INT, TA, recvcount, MPI_INT, 0, MPI_COMM_WORLD);这段代码的参数说明:base是平均行数,rem是余数,前rem个进程多分一行;rows[p]是行数,offset_rows[p]是行偏移;counts[p]是 A 的元素数,ofss[p]是 A 的元素偏移。接收端recvcount=counts[rank],不再用本地block去猜。这样即使 m 不能被 size 整除,最后一块也能正确匹配。
4.3 Gatherv 阶段 counts 为什么要除以 n 再乘 m
原始代码在 Gatherv 前做了counts[i]=counts[i]/n*m;,意图是把 A 的块元素数换算成结果块元素数。因为 A 块有rows行、每行 n 个元素,结果块有rows行、每行 m 个元素,所以结果元素数是rows*m。问题在于counts[i]/n依赖counts[i]能被 n 整除,如果前面分块有截断,这里就会丢行。正确做法是直接用rows[p]算:
int *gcounts = (int*)malloc(size * sizeof(int)); int *gofss = (int*)malloc(size * sizeof(int)); int gcursor = 0; for (int p = 0; p < size; ++p) { gcounts[p] = rows[p] * m; /* 每个进程贡献的结果元素数 */ gofss[p] = gcursor; gcursor += gcounts[p]; } int local_count = rows[rank] * m; int *RETA = (int*)malloc(local_count * sizeof(int)); /* ... 局部乘法写入 RETA[i*m+k] ... */ MPI_Gatherv(RETA, local_count, MPI_INT, RES, gcounts, gofss, MPI_INT, 0, MPI_COMM_WORLD);这段代码的逻辑说明:gcounts[p]是进程 p 贡献的结果元素数,gofss[p]是它在 RES 里的起始元素偏移;RETA的局部下标i*m+k对应“局部第 i 行、全局第 k 列”,拼接后正好是行主序。参数上不要把gcounts和 A 的counts混用,它们是两个不同阶段的数组,单位相同但含义不同。
用一张表推演 size=4、m=10、n=8 的情况:
| 进程 p | rows[p] | A 的 counts[p] | A 的 ofss[p] | 结果 gcounts[p] | 结果 gofss[p] |
|---|---|---|---|---|---|
| 0 | 3 | 24 | 0 | 30 | 0 |
| 1 | 3 | 24 | 24 | 30 | 30 |
| 2 | 3 | 24 | 48 | 30 | 60 |
| 3 | 1 | 8 | 72 | 10 | 90 |
这张表能直接看出两个阶段不能共用同一组 counts:A 阶段按 n 乘,结果阶段按 m 乘。原始代码用counts[i]/n*m在 n 能整除时等价,但在最后一块行数不整齐时容易出问题。把rows保留下来,两个阶段都用它换算,是最省后悔药的做法。
4.4 根进程与从进程必须用同一套分块元数据
MPI 的发送端和接收端不会自动协商“你实际想发多少”,它只按你填的 counts 匹配。所以分块元数据必须只有一套来源。常见做法是让根进程算好rows、counts、ofss,然后用MPI_Bcast把rows广播给所有进程,从进程据此算自己的recvcount;或者所有进程都调用同一个确定性函数,根据m、n、size、rank算出相同结果。两种做法都比“根进程用一套、最后一块用另一套”安全。
原始代码里if(rank==size-1)修正block的写法,问题不在修正公式,而在修正只发生在最后一个进程。根进程的counts构造在if(rank==0)里,用的还是未修正的block。这就是典型的“每个进程都觉得自己对,拼起来就错”。把分块逻辑抽成函数,所有进程调用同一份,再在根进程填数组,才能从根上避免错位。
5. 避坑排查:六个会让结果错位或直接崩的细节
5.1 现象:结果最后一块变成 0 或旧值
现象很具体:用 4×4、-np 2跑,结果前两行正常,最后两行有一半是 0;换成-np 4又好像正常。原因通常是最后一个 rank 的block被修正了,但根进程构造counts时仍用旧block,Scatterv 发给最后一块的计数和接收计数不一致。接收缓冲区可能只写进去一部分,剩下的内存没被覆盖,打印出来就是 0 或旧值。
解决方式是把分块元数据统一到根进程算:先算rows[p],再算counts[p]=rows[p]*n、ofss[p]=offset_rows[p]*n,然后用MPI_Bcast把rows或counts广播给所有进程。接收端recvcount=counts[rank],不要用本地临时变量猜。改完后用 m=10、size=4 这种除不尽的规模跑一遍,重点看最后一行最后几列是否完整。
5.2 现象:mpirun 报 truncated message 或接收缓冲区太小
运行时报MPI_ERR_TRUNCATE,或者程序直接 abort,提示消息被截断。原因多半是MPI_Scatterv的recvcount小于发送端counts[rank],或者MPI_Gatherv的recvcount和根进程的recvcounts[rank]不一致。原始代码里TA按block分配,最后一块实际更小,但根进程counts可能更大,接收端就会等不到足够的数据。
解决方式是先打印每个进程的rank、counts[rank]、recvcount,在MPI_Scatterv之前对照一遍。接收缓冲区按counts[rank]分配,发送端和接收端用同一套数组。Gatherv 阶段同样打印gcounts[rank]和local_count,确认两边相等再调用。MPI 的错误信息通常不会告诉你具体哪个 rank 的计数错了,自己把元数据打出来最快。
5.3 现象:m 不等于 n 时结果形状不对或越界
把 m 设成 4、n 设成 3,程序可能还能跑,但结果矩阵形状不对,甚至越界。原因是 B 按m行n列分配,逻辑上却按n行m列访问:B[k][j]的 k 最大到 m-1,j 最大到 n-1;打印时又用B[j][i]取 n 行 m 列。只有 m==n 时这两种形状才重合,非方阵下要么读越界,要么结果错位。
解决方式有两种:如果只想教学演示,就在参数校验里强制m==n,并在 usage 里写清楚;如果要支持非方阵,就把 B 改成n个行指针、每行m个元素,广播循环改成for(i=0;i<n;++i) MPI_Bcast(B[i],m,...),乘法里B[k][j]的维度也要跟着改。改形状时不要只改一处,分配、广播、乘法、打印四个地方要一起对齐。
5.4 现象:Gatherv 后结果行列错位
结果矩阵能打印出来,但行列像被转置了一半,或者每个进程的结果块之间插入了几列空白。原因通常是RETA的写入顺序和gofss的计算不匹配。原始代码用RETA[i*m+k],如果gofss[p]按rows[p]*m累加,拼接后是行主序;如果gofss按 A 的counts累加,就会错位。另一个常见原因是counts[i]/n*m整除截断,导致某些进程贡献的元素数少了一行。
解决方式是保留rows[p],用gcounts[p]=rows[p]*m、gofss[p]=gofss[p-1]+gcounts[p-1],不要从 A 的 counts 反推。局部写入坚持RETA[i*m+k],i 是局部行,k 是全局列。验证时先跑 m=n=4、size=2,结果应该和单进程完全一致;再跑 m=n=7、size=3,看最后一块是否对得上。
5.5 现象:参数缺失或 size 大于 m 时直接崩
不带参数运行./mpi_matmul,atoi(argv[1])访问空指针,程序可能直接段错误;size大于m时,base=0,部分进程rows=0,malloc(0)后继续做乘法和 Gatherv,行为不确定。原始代码没有参数校验,也没有限制进程数,拿去做课堂演示时很容易被学生的一串错误参数打崩。
解决方式是在MPI_Init后检查argc、m、n,并限制size<=m。如果确实要让进程数大于行数,需要显式处理空块:rows[p]=0的进程跳过 Scatterv 接收和乘法,Gatherv 贡献 0 个元素;根进程的gcounts对应位置填 0,gofss可以重复。对入门源码来说,直接限制1<=size<=m更省事,报错信息写清楚,比让程序在空块上跑飞强。
5.6 现象:根进程释放了,其他进程还在等
程序最后在if(rank==0)里free(A)、free(counts)、free(ofss)、free(RES),这些变量在非根进程上本来就是 NULL 或未分配,释放逻辑不对称。更危险的是,如果某个进程提前return而没有调用MPI_Finalize,其他进程可能卡在集合通信里。表现是命令不退出,或者要等超时才报错。
解决方式是所有进程走同一条退出路径:参数错误时也先MPI_Finalize再返回;集合通信调用不要放在只有部分进程执行的分支里。释放内存时按“谁分配谁释放”的原则,根进程释放全局数组,每个进程释放自己的TA、RETA、B 行指针。把退出路径理顺,比调通信参数更能减少“跑一半挂住”的血泪经验。
6. 性能与验证:先让 m=n 的小矩阵稳定对拍,再谈分块缓存
这份源码的教学价值大于性能价值,但验证方法可以先立起来。最稳的对拍方式是固定随机种子,跑-np 1和-np 4,把结果矩阵逐元素比较;如果不想改 C 代码,就用单位矩阵做输入,结果应该等于另一个矩阵本身。单位矩阵能同时验证行分块、广播和 Gatherv 拼接,出问题时很容易定位到哪一块没对上。
性能上先别急着上非阻塞通信。矩阵乘法的内层循环顺序对缓存影响很大,原始写法是k-i-j,常见做法是把j放最内层做连续访问,或者把 B 转置成列主序再点积。更实际的第一步是加编译优化-O2,然后用time mpirun -np 4 ./mpi_matmul 256 256看趋势;小矩阵下进程启动和通信开销占主导,进程数越多不一定越快。分块大小BLOCK_SIZE的意义在于让每个进程多算几行,减少通信次数,但这份源码按行切 A,块太大又会让负载不均,先保证 counts/ofss 正确再调块大小。
验证时我习惯用三个规模:4×4 看逻辑,10×10 看除不尽,64×64 看趋势。每次改完 counts/ofss,先跑 4×4 单进程和多进程对拍,再跑 10×10 看最后一块,最后才看耗时。从那以后我每次改 MPI 分块代码,都强制先用 size=3、m=5、n=5 跑一遍对照,确认最后一个 rank 的接收长度和根进程的发送计数完全一致,再谈优化。希望帮到你。
本文还有配套的精品资源,点击获取