简介:面向计算机、电子信息工程及数学专业学生的并行计算课程设计或期末大作业,提供了一套基于C++的MPI并行编程实现,完整覆盖普通高斯消去法与特殊高斯消去法。资源包共30个文件,包含13个C++源程序、16张结果截图及1份说明文档,压缩后仅222KB,便于下载与查阅。目前已有323人学习浏览,适合需要对照参考并行算法实现与调试验证的读者。源码涵盖按块划分、按列划分、均匀划分静态/动态、非阻塞通信、广播方式等多种MPI策略,并扩展了OpenMP、Pthread及AVX/SSE版本,能够帮助读者理解不同并行方式对高斯消去法性能的影响;说明文档与截图则展示了程序结构、运行流程及效果,可为毕业设计或课程报告提供直接参考。
1. 普通高斯消去法与特殊高斯消去法,MPI 并行的第一个分水岭在回代
拿 n=4096 的稠密矩阵做高斯消去法,串行版本单核要跑 40 秒以上,4 进程的 MPI 行分块版本能压到 12 秒左右;更反直觉的是,普通高斯消去法的回代阶段几乎无法并行,而特殊高斯消去法(高斯-若尔当消去法)总浮点量多出约 50%,却因免掉串行回代,进程数一多反而更稳。
标题里的普通高斯消去法指化成上三角再回代的顺序消元,特殊高斯消去法指化成单位阵、无回代的高斯-若尔当消元,两者同在一个源码包里,是课程设计与毕业设计的经典组合。写 MPI 版本时,真正难的不是消元循环本身,而是数据怎么分、主元行怎么广播、选主元时怎么跨进程找全局最大值。
这篇文章按“任务划分 -> MPI 实现 -> 算例验证”的顺序,把两种消去法在 C++ 与 MPI 下的写法、参数与排错讲清楚;拿到 rar 包想改代码、补图表的读者,可以直接对照第五章的命令复现残差曲线和加速比图片。
2. 高斯消去法的并行任务划分:行分块、循环分块与主元行广播开销
2.1 串行流程与计算量:为什么特殊消去法要多算 50%
普通高斯消去法串行版本就两段:消元把系数矩阵化成上三角 U,回代从最后一个未知数往前解。消元阶段对 k=0,1,…,n-2 依次处理,把第 k 列主元下方的元素全部消成 0,总的乘除运算量约 n³/3;回代阶段对每个 i 做一次求和减法和一次除法,总量只有约 n²/2。加起来是 2n³/3 这个数值分析里最常见的浮点量估计。
高斯-若尔当消去法每一步先把主元行归一化,再对该列所有非主元行做消元,最终增广矩阵 [A|b] 直接变成 [I|x]。它的总运算量是 n³,比普通版本多出整整 50%,所以单纯从串行浮点量看,它更“贵”。但在并行视角下,这 50% 换来的东西很关键:普通版本的回代是从 x[n-1] 到 x[0] 的强依赖链,p 个进程同时在场也只能一个进程算;高斯-若尔当没有回代环节,每一步处理的都是全部 n 行,工作天然摊到所有进程上。
| 算法 | 总浮点量 | 回代依赖 | 每步更新范围 | 并行负载 |
|---|---|---|---|---|
| 普通高斯消去法 | 约 2n³/3 | 强依赖,串行 | 仅主元下方行 | 前重后轻,尾部空闲 |
| 特殊高斯消去法(Gauss-Jordan) | 约 n³ | 无 | 除主元行外全部行 | 每步均匀,无空闲 |
这解释了一个经验现象:p=2 时普通消去法通常更快,p=8 以上时高斯-若尔当的实测时间往往反超。课程设计里把这组数字画成折线图,就是 rar 包里最常见的第一张图片;画图用的数据点可以用第五章的命令直接复现。
2.2 三种行分块方式与负载均衡
并行化的第一步是决定矩阵怎么分。一维行分块是最常见的做法,三种布局:连续行分块(进程 i 拿第 i 块连续的 n/p 行)、循环行分块(全局行号对 p 取模分配)、块循环分块(连续 nb 行一块,块号轮流分)。连续行分块胜在通信原语最直观:一次 MPI_Scatter 下去数据就位,最后 MPI_Gather 收回,行顺序天然正确。它的缺点是负载不均衡——消元越靠后,高编号进程的本地行更新量越少,p 大时后期有大半进程在空转。
循环行分块把负载抹平,但 MPI_Gather 收回来后行序是乱的,要先做一次置换,而且每一步广播的接收方不变、发送内容却跨进程,缓存局部性差。块循环分块是一维场景下的折中,也是二维分块、ScaLAPACK 风格的简化版。课程设计里我一般建议先用连续行分块跑通正确性,再花半小时改造成循环分块对比负载曲线。
| 布局 | 负载均衡 | 通信次数 | 数据收回是否重排 | 最适合规模 |
|---|---|---|---|---|
| 连续行分块 | 差(尾部空闲) | 最少 | 否 | n 大、p 小 |
| 循环行分块 | 好 | 中等 | 是(需置换) | n 大、p 大 |
| 块循环行分块 | 较好 | 中等 | 部分需要 | 中大规模,接近 2D 分块 |
2.3 集合通信模型:MPI_Bcast 与 MPI_Allreduce 的开销位置
不管哪种布局,消元第 k 步都需要主元行的第 k 到 n-1 列被所有进程看见,因为每个进程都要用 factor = a[i][k]/a[k][k] 更新自己的本地行。把主元行推给全员,用的就是 MPI_Bcast;如果还要选主元,则需要 MPI_Allreduce 把各进程的局部最大值归约成全局最大值。把进程和消息画成一张架构图,主元行广播就是那条最粗的横线,每步一条,共 n 条。
通信量估算:第 k 步广播约 n-k 个 double,累加约 n²/2 个 double,也就是约 4n² 字节;而每进程计算量是 n³/3p。n=1024、p=4 时通信约占百分之几,n=2048、p=8 时也还能接受;一旦 n 掉到 256 以下,通信时间直接超过计算时间,这就是“小矩阵上 MPI 跑不过串行”的原因。
// 串行普通高斯消去法核心:把 A 化为上三角 for (int k = 0; k < n; ++k) { for (int i = k + 1; i < n; ++i) { double factor = A[i][k] / A[k][k]; // 乘子只依赖主元行 for (int j = k; j < n; ++j) A[i][j] -= factor * A[k][j]; // 第 k 列左边已经是 0 } }这段代码的循环顺序是教科书里的“按行消元”,i 是行、k 是当前消元步、j 是列。并行版本只需要回答一个问题:第 k 行 A[k][j] 不在我这个进程时怎么办。答案是 MPI_Bcast 把它广播过来,于是内层 j 循环完全不用动,改动集中在外层 k 循环和 i 循环的号段。这就是行分块版本改起来最顺手的原因——消元内核保持不变,变的只是数据从哪里来。
3. 普通高斯消去法的 MPI 编程:MPI_Scatter 分块消元与 MPI_Bcast 主元广播
3.1 增广矩阵按行分发:MPI_Scatter 的 count 与根进程参数
代码层面第一件事是确定数据布局。我用一维 vector 按行优先存增广矩阵,这样传给 MPI_Scatter 时缓冲区天然连续,不用自己拼二维指针数组;很多 MPI 新手在二维数组上翻车,都是因为 new int**[n] 出的行指针不连续,MPI 只认连续内存。
// gauss_mpi.cpp:普通高斯消去法,增广矩阵 [A|b] 按连续行分块 #include <mpi.h> #include <cstdio> #include <cmath> #include <cstdlib> #include <vector> #include <algorithm> using namespace std; int main(int argc, char** argv) { MPI_Init(&argc, &argv); int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); int n = 8; if (argc > 1) n = atoi(argv[1]); if (n % size != 0) { if (rank == 0) fprintf(stderr, "要求 n 能被进程数整除\n"); MPI_Finalize(); return 1; } int local_n = n / size; // 每进程本地行数 int cols = n + 1; // 增广矩阵列数 vector<double> local(local_n * cols, 0.0); // rank 0 生成对角占优矩阵,并分发给所有进程 if (rank == 0) { vector<double> full(n * cols); for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) full[i * cols + j] = (i == j) ? (n + 2) : ((i + j) % 3 + 1) * 0.5; full[i * cols + n] = 1.0 + i; // 右端项 b } MPI_Scatter(full.data(), local_n * cols, MPI_DOUBLE, local.data(), local_n * cols, MPI_DOUBLE, 0, MPI_COMM_WORLD); } else { MPI_Scatter(nullptr, 0, MPI_DOUBLE, local.data(), local_n * cols, MPI_DOUBLE, 0, MPI_COMM_WORLD); }MPI_Scatter 的参数要说明几点:第五个参数是每个进程接收的元素个数,这里是 local_n*cols 而不是 n;MPI_DOUBLE 表示数据单元是双精度;最后一个 0 是根进程号。根进程之外调用时第一个参数传 nullptr、count 传 0 是标准写法,MPI 严格要求非根进程的 sendbuf 不参与。生成矩阵用的对角占优构造(i==j 时取 n+2,其余取小数)是为了保证不选主元也能消下去,避免新手一上来就踩主元为 0 的崩点。
| MPI 原语 | 关键参数 | 本代码中的取值 | 作用 |
|---|---|---|---|
| MPI_Scatter | sendbuf / count / datatype / root | full / local_n*cols / MPI_DOUBLE / 0 | 行分块分发 |
| MPI_Bcast | buffer / count / datatype / root | pivot_row / cols / MPI_DOUBLE / owner | 主元行全员广播 |
| MPI_Gather | sendbuf / recvbuf / count / root | local / U / local_n*cols / 0 | 回收上三角 |
3.2 消元主循环:owner 计算与 MPI_Bcast 主元行
消元主循环里每个 rank 都要执行相同的 k 循环,区别只在:当 k 属于本进程时,把第 k 行拷出来广播;不属于时就只接收。owner = k / local_n 这个整除关系成立的前提是连续行分块,换成循环分块后要改成 k % size,这是最容易写错的一行。
// 消元:第 k 步,行 k 的 owner 广播主元行,全员做局部行更新 for (int k = 0; k < n; ++k) { int owner = k / local_n; vector<double> pivot_row(cols, 0.0); if (rank == owner) { // 只有 owner 持有第 k 行 int r = k % local_n; for (int j = k; j < cols; ++j) // 第 k 列左边全为 0,只发右侧 pivot_row[j] = local[r * cols + j]; } MPI_Bcast(pivot_row.data(), cols, MPI_DOUBLE, owner, MPI_COMM_WORLD); for (int i = 0; i < local_n; ++i) { int g = rank * local_n + i; // 本地行 i 对应的全局行号 if (g <= k) continue; // 主元行及以上不更新 double factor = local[i * cols + k] / pivot_row[k]; for (int j = k; j < cols; ++j) local[i * cols + j] -= factor * pivot_row[j]; } }关键点:MPI_Bcast 是集合通信,owner 进程和非 owner 进程都必须调用且调用顺序一致。本代码里每条消息的根是 owner,它随 k 变化,但所有进程的 owner 计算结果相同,所以不会错配。factor 的计算完全本地化,pivot_row[k] 是广播来的主元,不需要额外同步其他数据。更新范围从 j=k 开始,因为该行第 k 列左边经过前 k 步消元后已经是数值 0。
提示:MPI_Bcast 只是把内存缓冲区复制到全员,并不隐式做“锁”。如果某个进程在 Bcast 前提前 return 或走了不同分支,整个 job 会挂死在下一个集合操作上。
3.3 汇总与回代:MPI_Gather 回收上三角,root 串行回代
消元结束后每个进程手里是上三角的部分行。把回代放在 root 进程串行做,是课程设计里最常见也最合理的收尾:回代只有 O(n²) 次运算,而消元是 O(n³/p),p 不极端时串行回代占比可以忽略。MPI_Gather 把 local 按进程号顺序拼回 U,行序与原始矩阵一致,回代代码和串行版完全相同。
// 汇总上三角矩阵到 rank 0,串行回代 vector<double> U; if (rank == 0) U.resize(n * cols); MPI_Gather(local.data(), local_n * cols, MPI_DOUBLE, U.data(), local_n * cols, MPI_DOUBLE, 0, MPI_COMM_WORLD); if (rank == 0) { vector<double> x(n, 0.0); for (int i = n - 1; i >= 0; --i) { double s = U[i * cols + n]; // 右端项 for (int j = i + 1; j < n; ++j) s -= U[i * cols + j] * x[j]; x[i] = s / U[i * cols + i]; } double err = 0.0; // 残差 ||Ax-b||_inf for (int i = 0; i < n; ++i) { double s = 0.0; for (int j = 0; j < n; ++j) s += U[i * cols + j] * x[j]; err = max(err, fabs(s - U[i * cols + n])); } printf("p=%d n=%d residual=%.3e\n", size, n, err); } MPI_Finalize(); return 0; }回代从 i=n-1 倒着走,每一步先算 b[i] 减去已解出的右侧未知数贡献,再除以对角元;这里直接用残差而不是打印全部 x,是为了让正确性检查自动化。err 在 1e-10 量级说明消元和通信都正确,如果出现 1e-3 甚至更大,优先怀疑主元过小而不是 MPI 调用错误。注意 MPI_Gather 的接收缓冲区 U 只在 rank 0 分配,其他进程传 nullptr 即可,这一点和 MPI_Scatter 的 sendbuf 规则是对称的。
3.4 编译与运行:mpicxx、mpirun 与核数参数
编译用 mpicxx,它本质是 g++ 加上了 MPI 的头文件和库路径;运行时 mpirun -np 指定进程数,OpenMPI 在物理核不足时会提示是否使用 --oversubscribe,MPICH 系则直接跑。测速时先跑 -np 1 拿 T1,再跑 -np 2/4/8 拿 Tp,加速比 S=T1/Tp,效率 E=S/p。命令前的 time 只统计墙钟时间,够课程报告用了;要更精细的阶段拆分,用第五章末尾的 MPI_Wtime 方案。
mpicxx -O2 -std=c++17 gauss_mpi.cpp -o gauss_mpi mpirun -np 4 ./gauss_mpi 2048 time mpirun -np 1 ./gauss_mpi 2048 # 串行基线 T1 time mpirun -np 4 ./gauss_mpi 2048 # 并行时间 T44. 特殊高斯消去法(Gauss-Jordan)的 MPI 编程:MPI_Allreduce 选主元与无回代消元
4.1 Gauss-Jordan 的并行优势与适用边界
特殊高斯消去法在并行语境下的常见所指是高斯-若尔当消去法:每一步先把主元行归一化成主元为 1,然后对除主元行以外的所有行做消元。跑完 n 步后 [A|b] 直接变成 [I|x],x 就是解,不需要回代。与普通版本对比,它的浮点量多 50%,但结构性优势在 2.1 已经讲过:无回代、每步全行参与、负载均匀。
适用边界要讲清楚。高斯-若尔当适合两类场景:一是进程数较多、普通版本回代串行段开始拖后腿时;二是要顺便求逆矩阵时,把右端从 b 换成单位阵 I,最后右半块就是 A⁻¹,一次消元同时拿到 n 个右端项的解。如果只是单机单核解一个中等规模方程组,串行上普通高斯消去法更快,不要为了“特殊”而特殊。
| 对比项 | 普通高斯消去法 | 特殊高斯消去法(Gauss-Jordan) |
|---|---|---|
| 目标形态 | 上三角 U | 单位阵 I |
| 浮点量 | 约 2n³/3 | 约 n³ |
| 回代 | 需要,串行依赖链 | 不需要 |
| 每步更新行 | 主元下方 | 除主元行外所有行 |
| 求逆 | 需 n 次回代 | [A |
| 并行负载 | 尾部进程空闲 | 每步均匀 |
4.2 分布式列主元:MPI_Allreduce + MPI_MAXLOC 找全局最大值
普通版本可以靠对角占优矩阵绕开选主元,高斯-若尔当同样可以;但只要矩阵不是严格对角占优,主元一旦接近 0,消元结果直接报废。分布式环境下做列主元的标准做法是:每步先让各进程在本地行里找第 k 列绝对值最大的元素和它的全局行号,再用一次 MPI_Allreduce 归约出全局最大值,后面跟着一次跨进程行交换。
// 每步 k 的分布式列主元搜索 struct { double val; int row; } local_max = {0.0, -1}; for (int i = 0; i < local_n; ++i) { int g = rank * local_n + i; // 全局行号 if (g < k) continue; // 只考虑主元下方 double a = fabs(local[i * cols + k]); if (a > local_max.val) { local_max.val = a; local_max.row = g; } } struct { double val; int row; } global_max; MPI_Allreduce(&local_max, &global_max, 1, MPI_DOUBLE_INT, MPI_MAXLOC, MPI_COMM_WORLD);结构体 local_max 用 MPI_DOUBLE_INT 这个预定义类型描述,比较规则是:先比 val 取最大,val 相同时取 row 小者,这是 MPI_MAXLOC 的标准语义。没有候选行的进程(g < k)把 val 置 0、row 置 -1,保证它不会干扰归约结果。MPI_Allreduce 的参数里,count=1 表示每个进程贡献一个结构体,MPI_MAXLOC 是操作符,通信域 MPI_COMM_WORLD。所有进程拿到的 global_max 完全相同,这是它与 MPI_Bcast 的一个区别:广播是从一个根读数据,归约是全员参与再全员拿结果。
4.3 行交换与无回代消元:MPI_Sendrecv_replace 与归一化主元行
拿到全局最大主元行号 global_max.row 后,如果它不等于 k,就要把全局第 k 行和主元行互换。连续行分块下,行 k 属于进程 k/local_n,主元行属于进程 global_max.row/local_n;跨进程交换用 MPI_Sendrecv_replace,它在同一缓冲区上完成“发送旧行、接收对方行”,避免做 MPI_Send + MPI_Recv 配对。同一进程内的交换直接用 std::swap 逐元素换即可。
// 行交换:跨进程用 Sendrecv_replace,同进程用 swap int ok = k / local_n; // 行 k 所在进程 int op = global_max.row / local_n; // 主元行所在进程 if (ok != op) { if (rank == ok) MPI_Sendrecv_replace(&local[(k % local_n) * cols], cols, MPI_DOUBLE, op, 0, op, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); else if (rank == op) MPI_Sendrecv_replace(&local[(global_max.row % local_n) * cols], cols, MPI_DOUBLE, ok, 0, ok, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); } else if (k != global_max.row && rank == ok) { int r1 = k % local_n, r2 = global_max.row % local_n; for (int j = 0; j < cols; ++j) swap(local[r1 * cols + j], local[r2 * cols + j]); } // 归一化主元行后广播;之后消元不再需要除法 int owner = k / local_n; vector<double> pv(cols, 0.0); if (rank == owner) { int r = k % local_n; double piv = local[r * cols + k]; for (int j = k; j < cols; ++j) local[r * cols + j] /= piv; for (int j = k; j < cols; ++j) pv[j] = local[r * cols + j]; } MPI_Bcast(pv.data(), cols, MPI_DOUBLE, owner, MPI_COMM_WORLD); // 无回代消元:除主元行外,上方、下方所有行一起消 for (int i = 0; i < local_n; ++i) { int g = rank * local_n + i; if (g == k) continue; double factor = local[i * cols + k]; // 主元已归一化,不用除 for (int j = k; j < cols; ++j) local[i * cols + j] -= factor * pv[j]; }这段代码的一个细节是归一化放在广播之前、由 owner 做掉,所以其他进程消元时 factor 直接等于 local[i*cols+k],不需要再除以主元,省掉一次除法也少一个浮点误差来源。更新范围 j 从 k 开始,是因为第 k 列左边的列在前面 k 步已经被消成只有主元行有非零值。与普通版本“只消下方”不同,这里对 g < k 的上方行也做消元,这正是 Gauss-Jordan 免回代的来源。
4.4 求逆模式与通信次数对比
求逆模式只需要改两个地方:cols 从 n+1 改成 2n,初始化时右半块放单位阵。消元结束后右半块就是 A⁻¹,验证方式是 rank 0 上做一次串行矩阵乘 A*A⁻¹ 减去单位阵取无穷范数。通信次数对比要心里有数:普通版本每步 1 次 Bcast,共 n 次;高斯-若尔当每步 1 次 Allreduce、1 次 Bcast、至多 1 次行交换,共约 3n 次集合操作。p 增大时 Allreduce 的 log p 通信开销开始显现,这也是为什么小规模下普通版本仍占优。
| 算法 | 每步集合操作 | 总次数 | 额外行交换 |
|---|---|---|---|
| 普通(无选主元) | MPI_Bcast ×1 | n | 无 |
| 普通(列主元) | Allreduce + Bcast | 约 2n | 最多 n |
| Gauss-Jordan(列主元) | Allreduce + Bcast | 约 2n | 最多 n |
英文资料里搜 mpi tutorial 或 gauss jordan elimination mpi source,绝大多数示例也是这种一维行分块加广播的结构,只是有的用 Fortran、有的用 C。对照着看时注意它的行号计算是 0 基还是 1 基,这比算法本身的差异更容易让人看晕。
5. 验证两种 MPI 高斯消去法的最小算例,以及死锁、精度偏差的定位方法
5.1 用 n=2048 和 p=1,2,4,8 复现加速比图片
正确性验证不要上来就跑大矩阵。先用 n=8、p=2 跑通,肉眼对比两个程序打印的残差;然后固定 n=2048,把 p 从 1 到 8 扫一遍。
mpicxx -O2 -std=c++17 gauss_mpi.cpp -o gauss_mpi mpicxx -O2 -std=c++17 gauss_jordan_mpi.cpp -o gauss_jordan_mpi for p in 1 2 4 8; do echo "== p=$p ==" mpirun -np $p ./gauss_mpi 2048 mpirun -np $p ./gauss_jordan_mpi 2048 done输出里的 residual 全部应该在 1e-10 以下。然后把 time 命令换成程序内计时,用 MPI_Wtime 包住消元主循环,MPI_Reduce 取全员最大耗时作为该进程数下的 Tp,一处打点就够画加速比曲线;python 侧用 matplotlib 画 S-p 折线,再画一条 y=p 的理想直线,这就是 rar 包“图片”里最常见的那张图。
5.2 死锁定位:集合操作必须全员到场
跑最小算例时最常遇到的现象是 mpirun 挂住不退出。99% 的原因是集合操作没凑齐人:某个进程因为 owner 分支里写了 return、或者 printf 后忘了继续调用 MPI_Bcast,其他进程全部卡死在等待里。定位手段是先加超时再谈调试。
timeout 30 mpirun -np 4 ./gauss_mpi 2048 # 超时退出码 124,说明有进程没走完集合通信超时退出后,用最笨也最有效的办法:在每个 MPI 集合调用前后加 fprintf(stderr, "rank %d step %d\n", rank, k),跑 p=2 的小矩阵,看哪个 rank 停在哪一步,几乎立刻能定位到漏调用的分支。这里有个新手最容易踩的坑:认为 MPI_Bcast 是“发送方等接收方”,于是在 owner 分支里加 if 判断决定要不要调用——这是错的,集合操作要求的是全员调用,根进程只是数据来源不同。
注意:排查死锁时不要用 MPI_Send 的返回值做依据,MPI 的 eager 协议会让 Send 在接收未就绪时也可能返回,现象掩盖本质。
5.3 残差验证与精度偏差判断
精度问题比死锁隐蔽。双精度下 n=2048 的消元残差在 1e-10 到 1e-12 都算正常;如果残差是 1e-3 甚至更大,先检查矩阵是否对角占优、选主元代码是否真的生效,而不是怀疑网络传输。另一个常见现象是并行结果与串行结果最后一位不同,这是 MPI_Allreduce 的求和顺序和串行循环不同导致的舍入差异,属于正常浮动,对比时用相对误差或残差,不要用 ==。
最后补一个实际有用的计时技巧,课程报告里的“通信开销占比”图靠它出数:在消元循环前后各取一次 MPI_Wtime(),再用一次 MPI_Reduce 把各进程的最大耗时归约到 root;单进程跑一遍得到 T1,多进程跑一遍得到 Tp,通信占比近似用 1 - Tp/(T1/p) 估。把 2.2 节连续行分块和循环行分块的代码各跑一组,两张图放在一起,就是源码+图片作业里最有含金量的对比材料。
本文还有配套的精品资源,点击获取