简介这份资源面向学习并行计算、高性能计算课程的学生与开发者聚焦基于MPI的矩阵乘法并行实现帮助理解分布式内存架构下如何把矩阵运算拆分到多进程协作完成。压缩包共1个文件为docx文档大小约15KB内含可直接参考的C/C源码示例覆盖MPI_Init环境初始化、MPI_Comm_rank与MPI_Comm_size获取进程信息、MPI_Bcast广播矩阵B、MPI_Scatterv分发矩阵A、MPI_Gatherv聚合结果以及MPI_Finalize收尾等完整流程并涉及BLOCK_SIZE分块优化思路。已有445人学习下载适合作为课程实验、并行算法入门与代码调试的对照材料读者可据此理解通信函数的分工、数据切分与结果归约的对应关系并在此基础上改写规模、进程数与分块策略观察并行加速效果与边界处理细节。1. 矩阵乘法 MPI一份能直接跑起来的 C 语言并行计算源码第一次拿到这份mpi_matmul.c的时候我盯着那个#define N 3看了半天——矩阵元素全是 0 到 2 的随机整数打印出来密密麻麻一片肉眼根本没法验算。但恰恰是这种“看起来简陋”的源码把 MPI 并行矩阵乘法最核心的三件事讲透了矩阵 B 广播、矩阵 A 按行分块散射、结果按列块收集。它用MPI_Scatterv和MPI_Gatherv这两个变长分发/收集函数处理了进程数不能整除矩阵行数时的边界问题这一点比很多教材里“假设 N 能被进程数整除”的示例实在得多。适合谁正在学并行计算课程、需要交一份能跑通的 MPI 作业或者想搞明白分布式内存下矩阵乘法到底怎么切数据的开发者。下面我按“它怎么切数据 → 怎么跑起来 → 怎么改”的顺序拆一遍。2. 数据切分逻辑Scatterv 与 Gatherv 到底在干什么2.1 先看整体分工B 全量广播A 按行切块这份代码的并行策略是行块划分。矩阵 A 是 m×n按行切成size块每个进程拿一块block个元素一维数组存储行优先矩阵 B 是 n×m不做切分直接MPI_Bcast广播给所有进程。每个进程用自己那块 A 的行去乘完整的 B算出结果的一部分列最后MPI_Gatherv收回 rank 0 拼成完整的 m×m 结果矩阵。为什么 B 要全量广播而不是也切因为矩阵乘法 C[i][j] Σ A[i][k]·B[k][j]每个进程算自己那几行 C 的时候需要 B 的所有列。如果切 B就得在计算过程中反复通信代码复杂度陡增。这份源码选择了“用内存换通信简单”B 在每个进程都存一份完整副本适合 B 规模不大的场景。常见做法是当 B 大到单机内存放不下时才考虑二维分块Cannon 算法那类但那是另一个量级的工程了。2.2 块大小计算与最后一块的修正int block (m size - 1) / size * n; // 向上取整再乘 n 得到元素个数 if(rank size - 1 block m*n - (size-1)*block) block m*n - (size-1)*block; // 最后一个进程只拿剩下的这两行是整个源码里最容易翻车的地方。(m size - 1) / size是向上取整的行数乘n换算成元素个数。但 m 不一定能被 size 整除比如 m10、size3前两个进程各拿 4 行12 个元素第三个进程只剩 2 行6 个元素。所以 rank 为size-1的进程要单独修正block否则MPI_Scatterv会越界读 A。注意这里有个隐含假设block是按“行”对齐的即每个进程拿到的元素数必须是 n 的整数倍。代码里col block / n直接整除说明作者默认了这一点。如果你的 m 小于 size前几个进程的 block 会算出 0 行MPI_Scatterv传 0 个元素是合法的但后续col0时循环不执行结果收集时 counts 为 0也能跑通——只是浪费了进程。2.3 counts 和 ofss 数组的构造if(rank 0){ counts (int*)malloc(size*sizeof(int)); ofss (int*)malloc(size*sizeof(int)); for(i0; isize-1; i){ counts[i] block; ofss[i] i * block; } counts[i] block m*n - i*block ? block : m*n - i*block; ofss[i] i * block; }counts[i]是发给第 i 个进程的元素个数ofss[i]是它在 A 里的起始偏移。前size-1个进程统一给block最后一个进程用三元表达式兜底防止超出m*n。这段逻辑和 2.2 的修正其实是重复保险——两处都做了边界处理说明作者被越界坑过。我一般会把这两处合并成一个函数算避免改了一处忘了另一处。3. 跑起来编译、启动与结果验证3.1 环境准备与编译命令这份源码只依赖 MPI 实现和标准 C 库没有第三方数学库。Linux 下装 OpenMPI 或 MPICH 都行编译用mpicc# Ubuntu/Debian 装 OpenMPI sudo apt install openmpi-bin libopenmpi-dev # 编译-O2 打开优化-Wall 把警告都放出来 mpicc -O2 -Wall -o mpi_matmul mpi_matmul.c # 4 个进程跑 6x6 的矩阵m6, n6 mpirun -np 4 ./mpi_matmul 6 6参数argv[1]是 margv[2]是 n。注意源码里结果矩阵是 m×m所以它实际算的是A(m×n) × B(n×m)输出一个 m×m 的方阵。如果你传6 3就是 6×3 乘 3×6结果 6×6。这个维度约定在源码里没有注释第一次跑很容易传错参数导致结果对不上。3.2 输出长什么样怎么手工验算rank 0 会打印三个矩阵A 原样、B 转置后打印方便对照、以及结果。因为元素是rand()%3值只有 0/1/2手工验算一行就能确认对不对A: [ 2 1 0 ... ] B: [ 1 2 0 ... ] ← 注意这是 B 的转置形式打印 : [ 5 4 1 ... ]验算方法结果第 0 行第 0 列 A 第 0 行点乘 B 第 0 列。但源码打印 B 时用了B[j][i]的索引方式看起来像转置实际存储没变。这里有个小坑B是int**二维指针每行单独 malloc而MPI_Bcast(B[i], n, ...)是按行广播的所以 B 的每一行在内存里连续跨行不连续——广播时必须逐行调用不能一次性传整个 B。源码里那个for(i0;im;i) MPI_Bcast(B[i],...)循环就是干这个的。3.3 进程数与矩阵规模的关系进程数 sizem6, n6 时每进程行数说明16退化为串行验证正确性用23整除最理想4前 3 个各 2 行最后 1 个 0 行有进程空转8前 6 个各 1 行后 2 个 0 行大量空转不建议进程数超过 m 时多出来的进程block修正后可能为 0MPI_Scatterv收 0 个元素计算循环不执行MPI_Gatherv贡献 0 个结果——逻辑上不崩但纯属浪费。我一般会加一句if(size m) 提示并退出这份源码没做属于可以改进的点。4. 核心计算段拆解三重循环与结果重排4.1 局部计算为什么 RETA 要按列存int col block / n; int *RETA (int*)malloc(col * m * sizeof(int)); for(k0; km; k){ for(i0; icol; i){ int tmp 0; for(j0; jn; j) tmp TA[i*nj] * B[k][j]; RETA[i*m k] tmp; } }这段是每个进程的核心计算。TA是自己那块 A 的行col 行 × n 列B[k][j]是 B 的第 k 行第 j 列。算出来的是 C 的第 i 行、第 k 列但存进RETA时用的是RETA[i*m k]——按行优先存i 是行、k 是列和标准 C 矩阵布局一致。那为什么说“按列存”因为MPI_Gatherv收集时每个进程贡献的是col*m个元素rank 0 按进程顺序拼接。进程 0 贡献 C 的第 0~col-1 行进程 1 贡献接下来的 col 行……拼接后正好是完整的 m×m 行优先矩阵。所以RETA的布局必须和最终结果的行优先布局对齐i*mk这个索引就是保证这一点。如果写成RETA[k*coli]收集回来的矩阵就是乱的——这是并行矩阵乘法里最常见的翻车点之一。4.2 收集前的 counts/ofss 重算if(rank 0){ ofss[0] 0; counts[0] col * m; for(i1; isize; i){ counts[i] counts[i] / n * m; // 元素数从 A 的块换算成结果的块 ofss[i] ofss[i-1] counts[i-1]; } }散射时 counts 是按 A 的元素数算的行数×n收集时结果矩阵每行是 m 个元素所以要把counts[i]/n*m换算过来。这里counts[i]/n就是该进程的行数乘 m 得到结果块大小。注意counts[0]被直接赋成col*m因为 rank 0 的col是本地变量其他进程的 col 可能不同最后一块行数少所以循环里用counts[i]/n反推行数而不是直接用本地 col——这个细节处理得对但可读性差改代码时容易看漏。4.3 内存释放的完整性源码在 rank 0 分支里 free 了 A、counts、ofss、RES在分支外 free 了 TA、RETA 和 B 的每一行。但B的int**本身在free(B[i])循环后还有一句free(B)这个是对的。唯一漏掉的是非 rank 0 进程的counts和ofss是 NULL没 free 也没事但 rank 0 的RES在if(rank0)块内 free而RETA在块外 free顺序上没问题。跑 valgrind 的话应该干净除非 MPI 库自身有残留。5. 避坑与排查五个真实会踩的坑5.1 现象结果矩阵全是 0 或乱码 → 原因Gatherv 的 counts 没换算 → 解决检查/n*m第一次改这份代码时我把收集阶段的 counts 直接沿用了散射阶段的 counts结果 rank 0 收到的数据长度不对打印出来一半是 0 一半是垃圾值。原因是散射时 counts 单位是“A 的元素”收集时单位是“结果的元素”两者差一个m/n的比例。排查方法在MPI_Gatherv前后各加一句printf(rank %d counts %d\n, rank, counts[rank])对比散射和收集两次的 counts 是否满足sum(counts) m*m。5.2 现象进程数大于 m 时程序挂起 → 原因某进程 block0 但 Bcast 仍在等 → 解决加 sizem 检查MPI_Bcast是集合通信所有进程必须参与。如果某个进程因为 block 修正后变成 0它仍然会执行到 Bcast 那行所以不会挂——真正会挂的是MPI_Scatterv的 recvcount 传了负数。源码里block m*n - (size-1)*block在极端情况下可能算出负数当 size 远大于 m 时malloc(负数)直接崩。我一般会在MPI_Init后加if(size m){ if(rank 0) printf(进程数 %d 超过矩阵行数 %d请减少 -np\n, size, m); MPI_Finalize(); return 0; }5.3 现象B 广播后部分进程的 B 是空的 → 原因B 是 int** 跨行不连续 → 解决逐行 Bcast源码里for(i0;im;i) MPI_Bcast(B[i], n, MPI_INT, 0, MPI_COMM_WORLD);这个循环不能省。有人图省事写成MPI_Bcast(B, m*n, MPI_INT, 0, ...)但B是int**B本身指向的是指针数组不是连续的 m*n 个 int。这样广播过去的是指针地址接收端解引用直接段错误。正确做法要么逐行广播源码这样要么把 B 改成int*一维数组再一次性广播。5.4 现象mpirun 报 “not enough slots” → 原因本机核数不够 → 解决加 --oversubscribe在 2 核笔记本上跑mpirun -np 4OpenMPI 默认会拒绝提示 slot 不够。加--oversubscribe允许超订mpirun --oversubscribe -np 4 ./mpi_matmul 6 6注意超订后性能会下降只适合功能验证别拿这个数据去写性能报告。5.5 现象结果和串行版对不上 → 原因随机种子每个进程不同 → 解决只在 rank 0 播种源码里srand((int)time(0))写在if(rank0)块内只有 rank 0 生成 A 和 B然后广播/散射出去所以种子一致没问题。但如果你改成每个进程各自生成局部数据time(0)在同一秒内可能相同也可能不同导致各进程的 B 不一致结果自然错。改代码时记住共享数据只能由一个进程生成再分发这是 MPI 编程的铁律。6. 二次开发从能跑到有用我改过的三个方向6.1 把随机矩阵换成文件读入方便对拍这份源码的随机元素只适合看流程不适合验证正确性。我一般会加一个-f参数读矩阵文件格式就是纯文本的 m n 然后跟 m*n 个整数。rank 0 读入后照常散射其他进程不碰文件 I/O——MPI 里多个进程同时读同一文件容易踩文件锁的坑集中到 rank 0 最稳。改完之后拿 NumPy 算一遍A B对拍比肉眼看 0/1/2 靠谱得多。6.2 加计时但别只计计算段double t1 MPI_Wtime(); // ... Scatterv / Bcast / 计算 / Gatherv ... double t2 MPI_Wtime(); if(rank 0) printf(elapsed %.6f s\n, t2 - t1);MPI_Wtime是 MPI 自带的墙钟计时比clock()准。但要注意如果只包住三重循环测出来的是纯计算时间通信开销被漏掉了。我习惯把 Bcast 之前到 Gatherv 之后整段包住再单独打一个“通信计算”的总时间两个数一对比就知道瓶颈在通信还是计算。矩阵小的时候通信占大头加速比可能小于 1这是正常的别以为是代码写错了。6.3 块大小对齐与缓存友好源码的三重循环是k-i-j顺序外层 k中层 i内层 jB[k][j]在内层连续访问TA[i*nj]也是连续访问缓存友好度还行。但如果把循环顺序改成i-k-jRETA[i*mk]的写入就变成跨步访问性能会掉。我试过在 m1024 时把内层 j 循环手动展开 4 次配合-O3 -marchnative单进程能快 15% 左右——不过这是编译器优化的功劳居多手写展开的收益在 MPI 场景下容易被通信开销淹没不建议一上来就搞。6.4 一个我踩过的坑别在集合通信里做条件分支有次我想“优化”——让 rank 0 在 Bcast 之前先判断 B 是否为空为空就跳过广播。结果 rank 0 跳过了其他进程还在MPI_Bcast里等整个程序挂死。MPI 的集合通信要求所有进程以相同顺序调用相同的集合函数任何基于 rank 的条件分支只要导致调用次数不一致就是死锁。从那以后我每次改集合通信附近的代码都强制在纸上画一遍每个 rank 的执行路径确认 Bcast/Scatterv/Gatherv 的调用次数完全一致才敢跑。希望这份拆解帮到你少走我走过的弯路。本文还有配套的精品资源点击获取