简介:这份技术文档聚焦基于消息传递接口的并行矩阵乘法实现,面向正在学习并行计算、科学计算或高性能计算课程的高校学生与开发者,帮助理解分布式内存架构下如何把矩阵乘法拆分为多进程协作任务。压缩包内仅含 1 个 docx 文件,约 15KB,篇幅紧凑,核心是一套可运行的 C 语言示例及配套说明,覆盖矩阵分块分发、矩阵广播、各进程局部乘加、结果聚合的完整流程。文档结合环境初始化、进程编号与规模获取、广播、分散、聚集等通信函数,展示进程数、分块大小与剩余块处理之间的关系,并给出分块尺寸等优化思路,便于读者对照代码理解负载均衡、通信开销与结果汇总方式。已有 444 人学习,适合用作课程实验参考、并行算法入门案例或面试前的知识点梳理。
1. 4 进程跑 4×4 正常,改成 6×6 就翻车:这份 MPI 矩阵乘法卡在哪
我第一次拿这份矩阵乘法 MPI 并行计算代码压测时,4×4 矩阵跑 4 进程一切正常,参数改成 6×6 直接段错误,连结果矩阵都没打出来。后来定位到问题不在 MPI 本身,而是分块边界、Scatterv/Gatherv 的 counts 数组和二维指针内存布局这三件事没有对齐。这段 C 代码走的是最经典的分块并行路线:A 按行切块用 MPI_Scatterv 发到各进程,B 用 MPI_Bcast 广播到所有进程,每个进程只算自己那部分 A 与 B 的转置乘,最后用 MPI_Gatherv 在根进程按行拼回结果。适合已经跑过 MPI hello world、想把这套模板直接用到小规模稠密矩阵乘法的读者,也适合正在排查“进程数一多结果就错位”的从业者。
2. 环境准备与矩阵布局:从 mpi.h 到 Scatterv 的块对齐
2.1 编译环境与最小运行骨架
这份代码只依赖 MPI 和一个 C 编译器,没有 BLAS、没有 OpenMP,全部计算手写在循环里。编译用 MPI 包装器,不要直接拿 gcc 去链,路径和宏都会对不上。
mpicc -O2 -Wall -std=c99 -o mpi_matmul mpi_matmul.cmpicc是 MPI 编译器包装,负责把mpi.h的 include 路径和 libmpi 的链接参数带进去;-O2打开优化,后面做加速比时这个选项要保持一致;-std=c99是因为代码里变量声明和 for 循环混在一起,老编译器默认的 gnu89 可能报错。编译完先别急着上多进程,用mpirun -np 1跑通再往上加。
程序入口的参数解析在每个进程上都会执行一遍,不是只有 rank 0 解析。argv[1] 是 m,argv[2] 是 n,两个参数缺一不可,代码里没有默认值。
#include <mpi.h> #include <stdio.h> #include <stdlib.h> #include <time.h> #define N 3 int main(int argc, char **argv) { MPI_Init(&argc, &argv); int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); /* 当前进程编号,0 为根 */ MPI_Comm_size(MPI_COMM_WORLD, &size); /* 进程总数 */ if (argc < 3) { if (rank == 0) fprintf(stderr, "usage: %s m n\n", argv[0]); MPI_Finalize(); return 1; } int m = atoi(argv[1]); /* A 的行数,也是结果矩阵的行数 */ int n = atoi(argv[2]); /* A 的列数,也是 B 的列数 */ ...这里有一个容易被忽略的细节:MPI_Init之后才能调MPI_Comm_rank,顺序反了会直接 abort。参数检查放在 rank 0 打印,其他进程静默退出,避免多进程同时往 stderr 写同一行字造成输出交错。
2.2 行块划分:block 的计算与向上取整
整段代码的核心分块逻辑只有两行,但这两行决定了后面 Scatterv 和 Gatherv 的 counts 数组怎么填。
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是整数除法下的向上取整,等价于ceil(m / size),得到每个进程大致分几行;再乘 n 把“行数”换算成“元素个数”,因为 Scatterv 的 counts 单位是元素而不是行。block这个变量从头到尾都是元素数,不是行数,后面col = block / n才是当前进程真正持有的行数。
第二个 if 只对rank == size - 1生效,作用是把最后一个进程的块截到剩余元素数。之所以只需要修最后一个,是因为前面的进程都拿同样的块长block,只有尾部会多出来。这个修正逻辑在 m 能被 size 整除时完全正确,在 m 不能被整除但ceil(m/size) * (size-1) <= m时也正确;一旦不满足这个不等式,前面某个进程就会越界,第 5 章会展开。
下表把几个关键量的关系固定下来,后面调参数时对照看:
| 变量 | 含义 | 计算方式 | 单位 |
|---|---|---|---|
| m | A 的行数、结果矩阵行数 | argv[1] | 行 |
| n | A 的列数、B 的列数 | argv[2] | 列 |
| block | 单个进程分到的 A 元素数 | ceil(m/size) * n | 元素 |
| col | 单个进程分到的行数 | block / n | 行 |
| counts[i] | 第 i 个进程收/发的元素数 | Scatterv 前为 block,Gatherv 前为col * m | 元素 |
2.3 Scatterv 散射 A 与 Bcast 广播 B:为什么不用 Allgather
A 和 B 走的是两条完全不同的通信路径,这不是随手写的,是数据访问模式决定的。每个进程只负责结果矩阵的一部分行,所以它只需要 A 的对应行块,用MPI_Scatterv把 A 按 counts 撒下去最省内存;但 B 的每一行在计算每个结果元素时都要被访问,每个进程都需要完整的 B,所以用MPI_Bcast广播。
/* B 是二维指针数组,行与行不连续,只能逐行广播 */ for (i = 0; i < m; ++i) MPI_Bcast(B[i], n, MPI_INT, 0, MPI_COMM_WORLD); /* A 是一维连续数组,按 counts/ofss 散射 */ MPI_Scatterv(A, counts, ofss, MPI_INT, TA, block, MPI_INT, 0, MPI_COMM_WORLD);MPI_Scatterv的 sendbuf、counts、displs 只在根进程有意义,其他进程传 NULL 不会报错;recvbuf 是TA,每个进程接收block个元素。MPI_Bcast的 root 是 0,所有进程包括根进程自己都参与,根进程的 B 就是发送源。
摘要里提到的MPI_Allgather在这份实现里没有出现,实际用的是MPI_Gatherv。两者差别很大:Allgather 会让每个进程都拿到完整的拼接结果,内存翻 size 倍;Gatherv 只把结果收到根进程,其他进程算完就结束。对于只需要根进程输出结果的场景,Gatherv 更省内存,代价是后续没法在其他进程上继续对结果做并行处理。选哪个取决于结果要不要继续往下算,这份代码选 Gatherv 是合理的。
3. 转置写入与收集:让每个进程的块能按行拼回去
3.1 RETA 的列转置写入:i*m+k 的索引推导
计算部分的循环顺序和写入位置,是这份代码里最容易看错的地方。它没有直接算C = A × B,而是算了C = A × B^T,再把结果按列写入RETA。
int col = block / n; /* 当前进程持有的行数 */ 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 列 */ } }TA[i*n+j]是 A 块第 i 行第 j 列,B[k][j]是 B 第 k 行第 j 列,两者点积得到A_block × B^T的第 i 行第 k 列。写入RETA[i*m+k]说明 RETA 是按行主序存的 col 行 m 列矩阵,行内偏移是i*m,列偏移是k。这样每个进程贡献的col*m个元素在内存里是连续的整块,Gatherv 拼回去时直接按块首尾相接,不需要再做一次转置。
为什么不在计算时就写成RETA[k*col+i]?因为那样每个进程的输出块在逻辑上是“转置”的,拼到根进程后还要再转一次,多一次 m×m 的遍历。当前写法把转置摊到了每个进程的写循环里,写一次就位,根进程只负责按偏移摆放。
3.2 Gatherv 的 counts/ofss 二次改写
Scatterv 阶段填的 counts 是 A 的元素数,Gatherv 阶段要换成结果矩阵的元素数,单位必须跟着变。
if (rank == 0) { ofss[0] = 0; counts[0] = col * m; /* 第 0 块贡献 col*m 个结果元素 */ for (i = 1; i < size; ++i) { counts[i] = counts[i] / n * m; /* 元素数 → 行数 → 结果元素数 */ ofss[i] = ofss[i - 1] + counts[i - 1]; } } MPI_Gatherv(RETA, col * m, MPI_INT, RES, counts, ofss, MPI_INT, 0, MPI_COMM_WORLD);counts[i] / n把 A 块元素数换算成行数,再乘 m 得到该进程在结果矩阵里占的元素数。因为每个进程的 col 行在结果里还是 col 行,每行 m 个元素,所以块大小就是col*m。ofss按元素累加,不是按字节,MPI 的 displs 单位跟随 datatype,这里 datatype 是MPI_INT,单位就是 int 个数。
最后一个进程的counts[size-1]在 Scatterv 前已经被修正过,它是 n 的倍数,所以/n*m不会丢余数。如果这里直接用原始 block 换算,最后一个进程的 counts 会大于实际写入量,Gatherv 会读到 RETA 边界外的内存,表现是结果最后几行出现随机值。
3.3 B 的二维指针布局与逐行 Bcast 的代价
B 用int **分配,每行单独 malloc,行与行之间不保证连续。这种布局在单机小矩阵上没问题,但广播时要一行一行发,m 次MPI_Bcast,每次只有 n 个 int。
int **B = (int**)malloc(m * sizeof(int*)); for (i = 0; i < m; ++i) B[i] = (int*)malloc(n * sizeof(int));每次 Bcast 都有固定延迟,m 次小消息在进程数增加后延迟占比会明显上升。这是后面第 6 章要改的点,先把正确性跑通,再谈把 B 压成一维连续数组、一次 Bcast 发完。当前布局的好处是B[k][j]写法直观,和数学公式一一对应,调试时打印也方便;坏处是通信次数多、内存碎片多,规模上去后性能吃亏。
4. 编译、运行与结果验证:从 -np 1 到 -np 4 的对照实验
4.1 编译命令与参数入口
完整编译和运行命令如下,先单进程,再多进程,顺序不要反。
mpicc -O2 -Wall -std=c99 -o mpi_matmul mpi_matmul.c # 单进程基线 mpirun -np 1 ./mpi_matmul 4 4 # 双进程 mpirun -np 2 ./mpi_matmul 4 4 # 四进程 mpirun -np 4 ./mpi_matmul 4 4-np后面的数字是进程数,不是线程数;./mpi_matmul后面的两个参数分别是 m 和 n。如果本机没有配置 MPI 的 hostfile,mpirun默认在本机起多个进程,不需要额外参数。第一次跑建议加--oversubscribe如果进程数超过物理核数,否则部分 MPI 实现会拒绝启动。
运行前确认当前目录有可执行权限,mpirun找的是相对路径或 PATH 里的可执行文件,不是源文件。
4.2 单进程与多进程结果对照
验证并行结果是否正确,最直接的办法是固定随机种子,让-np 1和-np 4生成同一组 A、B,然后逐元素比对结果矩阵。
/* 临时把 time(0) 换成固定值,仅用于验证 */ srand(42);改完重新编译,分别跑-np 1和-np 4,把两次输出的=:下面的矩阵贴到两个文件里 diff。如果完全一致,说明分块、散射、收集三条链路都对上了;如果不一致,先看第一处不同的行列位置,再回去查对应进程的 counts 和 ofss。
注意代码里srand只在 rank 0 执行,A 和 B 也只在 rank 0 生成,其他进程通过 Bcast 和 Scatterv 拿到数据,所以同一组进程内 A、B 一定一致。跨次运行结果不同是正常的,因为种子来自time(0);要跨次对比就必须固定种子。
4.3 随机种子与边界尺寸的测试矩阵
只测 4×4 不够,至少要覆盖三类尺寸:m 能被 size 整除、m 不能被 size 整除、m 小于 size。每类跑-np 1到-np 4,记录是否崩溃、结果是否一致。
| 测试组 | m | n | 进程数 | 预期 |
|---|---|---|---|---|
| 整除 | 4 | 4 | 1/2/4 | 结果一致,无崩溃 |
| 非整除 | 5 | 4 | 1/2/4 | 结果一致,最后一块小 |
| 非整除 | 6 | 6 | 1/4 | 结果一致 |
| 行数小于进程数 | 2 | 2 | 4 | 当前实现会崩,属已知边界 |
这张表不是让你一次全跑完,而是按顺序加难度。先跑整除组确认基本链路,再跑非整除组看尾部修正,最后跑行数小于进程数那组,复现第 5 章的负 block 问题,知道边界在哪,后面改代码才有方向。
5. 避坑与排查:五个让结果错位或直接崩的细节
5.1 进程数大于矩阵行数:负 block 与 Scatterv 越界
现象:mpirun -np 8 ./mpi_matmul 4 4直接段错误,或者结果矩阵全是 0,rank 靠后的进程在 Scatterv 处挂掉。
原因:block = (m + size - 1) / size * n,当 size 大于 m 时,ceil(m/size)等于 1,block 等于 n。前 size-1 个进程的ofss = i * n会一直加到(size-1)*n,远超 A 的总元素数m*n。最后一个进程的修正block = m*n - (size-1)*block会算出负数,malloc(负数)在部分平台返回 NULL,后续写入直接崩。
解决:在参数解析后加一行约束,进程数超过行数时直接拒绝,或者把分块粒度从“按行”改成“按元素”,让 block 可以小于 n。我一般会在 rank 0 打印一句提示再MPI_Finalize,避免所有进程各崩各的。
5.2 最后一个 rank 的修正不够:中间进程已经越界
现象:m=5、size=4 时,-np 1和-np 4结果不一致,最后一行或最后几行是随机值;换 m=6、size=4 又正常。
原因:ceil(5/4) = 2,block 等于2n,前三个进程各拿2n,加起来6n,已经超过总元素5n。代码只对rank == size-1做截断,rank 1 和 rank 2 的 ofss 分别是2n和4n,rank 2 的块尾部越过了 A 的边界,Scatterv 读到了堆上的脏数据。
解决:不要用统一的 block 加尾部修正,改成每个进程单独算start = rank * m / size和end = (rank+1) * m / size,按行分配,再乘 n 换算成元素偏移。这样任意 m 和 size 的组合都不会越界,代价是 counts 和 ofss 要在每个进程上各算一遍。
5.3 Gatherv 的 counts 单位错误:结果只有第一段对
现象:多进程跑出来的结果,第一段行正确,后面全错位,或者根进程打印时越界读到乱码。
原因:Scatterv 阶段 counts 是 A 的元素数,Gatherv 阶段直接复用没有换算,或者换算时用了block/n却忘了乘 m。counts 的单位是 datatype 个数,这里 datatype 是MPI_INT,一个元素就是一个 int,不是字节。
解决:在 Gatherv 前单独重置 counts 和 ofss,按counts[i] = counts[i] / n * m换算,ofss 按元素累加。换算完打印一遍 counts 和 ofss 的累加和,确认等于m*m,这步花不了几秒,能省掉大量调试时间。
5.4 逐行 Bcast 的隐藏成本:进程数上去加速比反而降
现象:-np 1跑 200×200 用 0.8 秒,-np 4用 1.2 秒,进程越多越慢。
原因:B 是二维指针数组,行与行不连续,只能循环 m 次MPI_Bcast,每次 n 个 int。m=200 时就是 200 次小消息,每次都有固定延迟,进程数增加后同步开销放大,计算省下来的时间被通信吃掉了。
解决:把 B 重排成一维连续数组Bflat[m*n],一次MPI_Bcast(Bflat, m*n, MPI_INT, 0, MPI_COMM_WORLD)发完,计算时用Bflat[k*n+j]取值。这个改动不影响结果,只改内存布局和通信次数,第 6 章给完整写法。
5.5 释放顺序与非 root 指针:rank 0 正常,其他进程偶尔崩
现象:程序跑完能打印结果,但退出码非 0,或者某些 rank 在free处崩溃。
原因:counts和ofss只在 rank 0 分配,其他进程是 NULL,如果释放前不判空,free(NULL)本身是安全的,但如果代码里对 NULL 做了解引用或重复释放就会出问题;另外 B 是二级指针,每行单独 malloc,释放时只free(B)不逐行free(B[i])会漏内存,反过来先free(B)再free(B[i])会访问已释放内存。
解决:释放顺序按分配的逆序来,先释放每行B[i],再释放B;counts和ofss在if (rank == 0)块里释放,和分配保持同一作用域。释放前把指针置 NULL 的习惯能挡住重复释放,尤其是调试阶段反复改代码的时候。
6. 进阶:把 B 压成一维,再用 MPI_Wtime 量加速比
6.1 一次 Bcast 替代 m 次小消息
把 B 从int **改成一维连续数组,是这份代码里性价比最高的一处优化。分配、填充、广播、计算四个环节都要跟着改,但逻辑不变。
/* 用一维连续内存替代二维指针 */ int *Bflat = (int*)malloc(m * n * sizeof(int)); if (rank == 0) { for (i = 0; i < m; ++i) for (j = 0; j < n; ++j) Bflat[i * n + j] = rand() % N; } /* 一次广播替代 m 次逐行广播 */ MPI_Bcast(Bflat, m * n, MPI_INT, 0, MPI_COMM_WORLD); /* 计算时用 Bflat[k*n+j] 替代 B[k][j] */ 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] * Bflat[k * n + j]; RETA[i * m + k] = tmp; } }Bflat的长度是m*n,索引k*n+j对应原来的B[k][j]。广播次数从 m 次降到 1 次,消息大小从 n 个 int 变成 m*n 个 int,在小 n 大 m 的场景下延迟改善很明显。释放时只需要free(Bflat)一次,也顺带消掉了 5.5 里逐行释放的坑。
6.2 用 MPI_Wtime 测通信与计算占比
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 size=%d time=%.6f s\n", m, n, size, t1 - t0);先用-np 1测基线,再依次跑-np 2、-np 4,把时间填进下表,加速比用T1 / Tp算。如果-np 4的时间反而比-np 1长,先回去看 5.4 的逐行 Bcast,再看是不是矩阵太小、通信占比压过了计算。
| 进程数 | 时间(秒) | 加速比 | 说明 |
|---|---|---|---|
| 1 | T1 | 1.00 | 基线 |
| 2 | T2 | T1/T2 | 检查是否接近 2 |
| 4 | T4 | T1/T4 | 明显低于 4 说明通信或分块有问题 |
矩阵规模建议从 64×64 起步,太小的矩阵单进程几毫秒就跑完,计时噪声比计算时间还大,加速比没有参考价值。64 到 256 之间逐档加,观察加速比曲线在哪一档开始走平,那一档就是当前实现和当前机器网络下的实际上限。改完分块参数后,我习惯先用-np 1和-np 4跑同一个固定种子,逐元素 diff 结果矩阵,确认正确性再谈性能。希望帮到你。
本文还有配套的精品资源,点击获取