
简介面向计算机、电子信息工程及数学专业学生的并行计算课程设计或期末大作业提供了一套基于C的MPI并行编程实现完整覆盖普通高斯消去法与特殊高斯消去法。资源包共30个文件包含13个C源程序、16张结果截图及1份说明文档压缩后仅222KB便于下载与查阅。目前已有323人学习浏览适合需要对照参考并行算法实现与调试验证的读者。源码涵盖按块划分、按列划分、均匀划分静态/动态、非阻塞通信、广播方式等多种MPI策略并扩展了OpenMP、Pthread及AVX/SSE版本能够帮助读者理解不同并行方式对高斯消去法性能的影响说明文档与截图则展示了程序结构、运行流程及效果可为毕业设计或课程报告提供直接参考。1. 普通高斯消去法与特殊高斯消去法MPI 并行的第一个分水岭在回代拿 n4096 的稠密矩阵做高斯消去法串行版本单核要跑 40 秒以上4 进程的 MPI 行分块版本能压到 12 秒左右更反直觉的是普通高斯消去法的回代阶段几乎无法并行而特殊高斯消去法高斯-若尔当消去法总浮点量多出约 50%却因免掉串行回代进程数一多反而更稳。标题里的普通高斯消去法指化成上三角再回代的顺序消元特殊高斯消去法指化成单位阵、无回代的高斯-若尔当消元两者同在一个源码包里是课程设计与毕业设计的经典组合。写 MPI 版本时真正难的不是消元循环本身而是数据怎么分、主元行怎么广播、选主元时怎么跨进程找全局最大值。这篇文章按“任务划分 - MPI 实现 - 算例验证”的顺序把两种消去法在 C 与 MPI 下的写法、参数与排错讲清楚拿到 rar 包想改代码、补图表的读者可以直接对照第五章的命令复现残差曲线和加速比图片。2. 高斯消去法的并行任务划分行分块、循环分块与主元行广播开销2.1 串行流程与计算量为什么特殊消去法要多算 50%普通高斯消去法串行版本就两段消元把系数矩阵化成上三角 U回代从最后一个未知数往前解。消元阶段对 k0,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³无除主元行外全部行每步均匀无空闲这解释了一个经验现象p2 时普通消去法通常更快p8 以上时高斯-若尔当的实测时间往往反超。课程设计里把这组数字画成折线图就是 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。n1024、p4 时通信约占百分之几n2048、p8 时也还能接受一旦 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; // 增广矩阵列数 vectordouble local(local_n * cols, 0.0); // rank 0 生成对角占优矩阵并分发给所有进程 if (rank 0) { vectordouble 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 而不是 nMPI_DOUBLE 表示数据单元是双精度最后一个 0 是根进程号。根进程之外调用时第一个参数传 nullptr、count 传 0 是标准写法MPI 严格要求非根进程的 sendbuf 不参与。生成矩阵用的对角占优构造ij 时取 n2其余取小数是为了保证不选主元也能消下去避免新手一上来就踩主元为 0 的崩点。MPI 原语关键参数本代码中的取值作用MPI_Scattersendbuf / count / datatype / rootfull / local_n*cols / MPI_DOUBLE / 0行分块分发MPI_Bcastbuffer / count / datatype / rootpivot_row / cols / MPI_DOUBLE / owner主元行全员广播MPI_Gathersendbuf / recvbuf / count / rootlocal / 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; vectordouble 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] 是广播来的主元不需要额外同步其他数据。更新范围从 jk 开始因为该行第 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串行回代 vectordouble 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) { vectordouble 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; }回代从 in-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 在物理核不足时会提示是否使用 --oversubscribeMPICH 系则直接跑。测速时先跑 -np 1 拿 T1再跑 -np 2/4/8 拿 Tp加速比 ST1/Tp效率 ES/p。命令前的 time 只统计墙钟时间够课程报告用了要更精细的阶段拆分用第五章末尾的 MPI_Wtime 方案。mpicxx -O2 -stdc17 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 的参数里count1 表示每个进程贡献一个结构体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; vectordouble 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*colsk]不需要再除以主元省掉一次除法也少一个浮点误差来源。更新范围 j 从 k 开始是因为第 k 列左边的列在前面 k 步已经被消成只有主元行有非零值。与普通版本“只消下方”不同这里对 g k 的上方行也做消元这正是 Gauss-Jordan 免回代的来源。4.4 求逆模式与通信次数对比求逆模式只需要改两个地方cols 从 n1 改成 2n初始化时右半块放单位阵。消元结束后右半块就是 A⁻¹验证方式是 rank 0 上做一次串行矩阵乘 A*A⁻¹ 减去单位阵取无穷范数。通信次数对比要心里有数普通版本每步 1 次 Bcast共 n 次高斯-若尔当每步 1 次 Allreduce、1 次 Bcast、至多 1 次行交换共约 3n 次集合操作。p 增大时 Allreduce 的 log p 通信开销开始显现这也是为什么小规模下普通版本仍占优。算法每步集合操作总次数额外行交换普通无选主元MPI_Bcast ×1n无普通列主元Allreduce Bcast约 2n最多 nGauss-Jordan列主元Allreduce Bcast约 2n最多 n英文资料里搜 mpi tutorial 或 gauss jordan elimination mpi source绝大多数示例也是这种一维行分块加广播的结构只是有的用 Fortran、有的用 C。对照着看时注意它的行号计算是 0 基还是 1 基这比算法本身的差异更容易让人看晕。5. 验证两种 MPI 高斯消去法的最小算例以及死锁、精度偏差的定位方法5.1 用 n2048 和 p1,2,4,8 复现加速比图片正确性验证不要上来就跑大矩阵。先用 n8、p2 跑通肉眼对比两个程序打印的残差然后固定 n2048把 p 从 1 到 8 扫一遍。mpicxx -O2 -stdc17 gauss_mpi.cpp -o gauss_mpi mpicxx -O2 -stdc17 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 折线再画一条 yp 的理想直线这就是 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)跑 p2 的小矩阵看哪个 rank 停在哪一步几乎立刻能定位到漏调用的分支。这里有个新手最容易踩的坑认为 MPI_Bcast 是“发送方等接收方”于是在 owner 分支里加 if 判断决定要不要调用——这是错的集合操作要求的是全员调用根进程只是数据来源不同。注意排查死锁时不要用 MPI_Send 的返回值做依据MPI 的 eager 协议会让 Send 在接收未就绪时也可能返回现象掩盖本质。5.3 残差验证与精度偏差判断精度问题比死锁隐蔽。双精度下 n2048 的消元残差在 1e-10 到 1e-12 都算正常如果残差是 1e-3 甚至更大先检查矩阵是否对角占优、选主元代码是否真的生效而不是怀疑网络传输。另一个常见现象是并行结果与串行结果最后一位不同这是 MPI_Allreduce 的求和顺序和串行循环不同导致的舍入差异属于正常浮动对比时用相对误差或残差不要用 。最后补一个实际有用的计时技巧课程报告里的“通信开销占比”图靠它出数在消元循环前后各取一次 MPI_Wtime()再用一次 MPI_Reduce 把各进程的最大耗时归约到 root单进程跑一遍得到 T1多进程跑一遍得到 Tp通信占比近似用 1 - Tp/(T1/p) 估。把 2.2 节连续行分块和循环行分块的代码各跑一组两张图放在一起就是源码图片作业里最有含金量的对比材料。本文还有配套的精品资源点击获取