简介:基于C++实现普通高斯消去法与特殊高斯消去法的MPI并行编程资源,适合计算机、电子信息工程、数学等专业学生用于并行计算课程设计、期末大作业或毕业设计参考。压缩包共30个文件,包含13个cpp源码、16张过程截图及1份说明文档,内容从串行算法出发,覆盖按块划分、按列划分、均匀划分静态与动态、非阻塞通信、广播方式,以及结合AVX、SSE、OpenMP、Pthread的多版本实现,便于对比不同并行策略的代码结构与性能表现。资源整体仅222KB,轻量易下载,已有323人学习。源码可帮助理解MPI并行化改造的关键环节,说明文档梳理实验设计与实现思路,截图直观展示运行结果,适合具有一定C++和MPI基础、希望高效搭建实验并验证多版本加速效果的读者。
1. 从高斯消去法到MPI并行:为什么需要换一种消元策略
普通高斯消去法和特殊高斯消去法的MPI编程,听起来像是数值分析课的两道习题,但这套基于C++的源码把每一步都变成了可运行的进程。资源包里既有串行算法.cpp做基线,也有按块划分、按列划分、均匀划分静态/动态、广播方式、非阻塞通信、流水线算法等多个MPI版本,甚至还有Pthread、OpenMP、AVX、SSE的对照实现和运行截图。对计算机、电子信息工程或数学专业的学生来说,它的价值在于把同一个算法沿着“单机CPU→共享内存→分布式内存→指令集”四条路径重新实现了一遍。
我读下来的直观感受是:普通高斯消去法如果不处理主元,只要矩阵不是严格对角占优,误差就会在消元过程中被放大;而特殊高斯消去法恰恰在列主元选择上做了手脚,这一改动放到MPI里就变成了跨进程的归约与广播。下文按串行基线、数据划分、通信优化、验证调优的顺序拆开讲,每个阶段都有可以照抄的C++代码和参数说明。
2. 先把并行基线打准:普通高斯消去法与列主元的串行C++代码
2.1 消元过程为什么必须是三重循环,主元又为什么重要
高斯消去法的核心是把增广矩阵变换成上三角矩阵,再做回代。消元阶段对每一列k,用第k行第k列元素作为主元,把下方所有行的第k列系数消成0;回代阶段从最后一行开始,依次解出每个未知数。整个过程是一个k、i、j三重循环,复杂度约为n^3/3次乘加。当n=2048、double占8字节时,增广矩阵本身就占32MB内存,单节点还能存下,但每增加一个维度,计算量呈立方增长,这就是MPI并行化的直接动机。
主元选择决定了这个方法是否稳定。普通实现直接拿a[k][k]做除数,一旦它是0,程序直接除零;即使不是0,如果绝对值很小,factor会变得非常大,导致后面减去factor乘主元行时把有效数字吃掉。列主元消去法在每一步先扫描第k列下方所有元素,找出绝对值最大的行交换到第k行,再执行消元。这个交换动作在串行程序里只是换行指针,在MPI程序里却意味着行数据要从一个进程迁移到另一个进程,所以“特殊”版本的并行化成本高是有原因的。全选主元的情况更少见,因为行交换和列交换会破坏未知数顺序,除非矩阵病态到极点,我不会在MPI版本里优先引入列交换。
下面这份完整串行实现是我在写MPI版本之前必跑的基线,它的结果用来判断后续所有并行版本是否正确。
2.2 串行列主元高斯消去法实现(可编译运行)
#include <bits/stdc++.h> using namespace std; // 串行列主元高斯消去法 // a: n 行 n+1 列的增广矩阵,按行优先存放在 vector 中 // x: 长度为 n 的解向量 void gauss_elimination(vector<vector<double>>& a, vector<double>& x) { int n = a.size(); for (int k = 0; k < n - 1; ++k) { // 1. 列主元搜索:在第 k 列 [k, n-1] 行范围内找绝对值最大 int piv = k; for (int i = k + 1; i < n; ++i) { if (fabs(a[i][k]) > fabs(a[piv][k])) { piv = i; } } if (fabs(a[piv][k]) < 1e-12) return; // 矩阵接近奇异,退出 if (piv != k) { swap(a[piv], a[k]); // 整行交换,注意只交换行指针 } // 2. 消元:用主元行把第 k 列下方的所有行对应列消成 0 double pivot = a[k][k]; for (int i = k + 1; i < n; ++i) { double factor = a[i][k] / pivot; for (int j = k; j <= n; ++j) { a[i][j] -= factor * a[k][j]; } } } // 3. 回代:从最后一行往上解 for (int i = n - 1; i >= 0; --i) { x[i] = a[i][n]; for (int j = i + 1; j < n; ++j) { x[i] -= a[i][j] * x[j]; } x[i] /= a[i][i]; } }代码逻辑并不复杂,有三个地方值得说明。第一,内层j循环从k开始而不是从0开始,因为第k列之前的列已经被消成0,再减一遍没有意义;增广矩阵的最后一列下标是n,所以循环条件写成j <= n。第二,factor只在第k列下方计算,消元时用当前a[i][k]除以主元pivot,这样factor本身保存了消元所需的比例,后面所有列都减去主元行的factor倍。第三,回代时j从i+1到n-1,sum中不包含i自己,最后再除以主元a[i][i]。
2.3 特殊高斯消去法的两种形态:列主元与追赶法
资源标题里的“特殊高斯消去法”在不同教材里可能指两种东西,做MPI版之前必须分清楚。第一种就是上面代码里的列主元消去法,它的特殊之处在于每轮消元前多一步“全局选主元”,在分布式内存中对应一次跨进程归约;第二种是求解三对角矩阵的Thomas算法,它把高斯消去法的复杂度从O(n^3)降到了O(n),但每一步消元结果立刻依赖前一步,天然形成长依赖链,在MPI里反而比稠密矩阵更难并行。
| 方法 | 适用矩阵 | 主元来源 | 串行复杂度 | MPI并行难度 |
|---|---|---|---|---|
| 普通顺序消去 | 主元非0的小规模稠密矩阵 | 直接用a[k][k] | O(n^3) | 低,但数值不稳定 |
| 列主元消去(特殊) | 一般稠密矩阵 | 第k列下方最大值 | O(n^3) | 中,需跨进程归约 |
| Thomas追赶法 | 三对角矩阵 | 固定系数,无需搜索 | O(n) | 高,依赖链超长 |
这个资源包里的按块划分、按列划分、流水线算法,全部默认处理的是稠密矩阵,本质上都在为列主元消去法服务。如果你手里拿到的是三对角矩阵,我不会推荐直接用里面的MPI划分代码,而是建议先做分块追赶,把三对角系统拆成多个小区间交给不同进程,边界点用Sherman-Morrison修正。不过那是另一个话题,下面先看主流路线:稠密矩阵的MPI划分。
3. MPI进程模型与三类数据划分:按块、按列、均匀分配
3.1 为什么不是每个进程复制一份矩阵
MPI的模型是分布式内存,每个进程有独立地址空间,不能像OpenMP那样直接读共享数组。如果每个进程都复制完整的增广矩阵,消元时它们各自算一遍,不仅没有加速,反而浪费内存和缓存。所以第一步必须是数据划分。划分方式会同时影响两个指标:一是每个进程要存多少行、算多少次浮点运算;二是每轮消元需要多少次进程间通信。普通高斯消去法每一轮只需要把主元行分发给所有进程,因此最直观的划分是行块划分。
行块划分把n行连续分成size个小区间,每个进程持有约n/size行。它的优点是内存局部性好,因为同一行内n+1个double在内存里连续,每次更新都能顺序访问;缺点是负载不均衡,第k轮以后,行号小于k的行不再参与更新,拥有前面这些行的进程会提前空闲。如果要让每个进程的工作量尽量一致,就轮到均匀划分出场。
3.2 按块划分的实现:主元行必须广播
下面这段代码是从串行版本改造成MPI版本最基础的一步。假设每个进程已经持有local_a数组,它是一维double数组,长度为local_rows*(n+1),按行连续存储。进程p持有全局行号从p*local_rows开始的若干行,pivot_owner是当前持有第k个主元行的进程。为了先讲清数据划分,这里假设主元行已经确定;列主元版本只需在广播前把搜索到的全局主元行交换到某个进程,再把它当owner。
// k: 当前消元列;pivot_owner: 持有主元行的进程 double* pivot_row = new double[n + 1]; if (rank == pivot_owner) { int row_in_local = k - my_start_row; // 主元行在本地数组中的下标 memcpy(pivot_row, &local_a[row_in_local * (n + 1)], (n + 1) * sizeof(double)); } MPI_Bcast(pivot_row, n + 1, MPI_DOUBLE, pivot_owner, MPI_COMM_WORLD); // 每个进程只更新自己持有的、全局行号大于 k 的行 for (int i = 0; i < local_rows; ++i) { int gi = my_start_row + i; // 当前行的全局行号 if (gi > k) { double factor = local_a[i * (n + 1) + k] / pivot_row[k]; for (int j = k; j <= n; ++j) { local_a[i * (n + 1) + j] -= factor * pivot_row[j]; } } } delete[] pivot_row;MPI_Bcast的参数现在可以对照说明。第一个参数是缓冲区首地址pivot_row,第二个参数是长度n+1,为什么不是n?因为增广矩阵每行多一个右端项,主元行必须把整个n+1列传出去,后面进程更新最后一列时也要用到这个值。第三个参数是数据类型MPI_DOUBLE,对应C++的double;第四个参数是根进程编号pivot_owner;第五个参数是通信子MPI_COMM_WORLD。pivot_row是有长度n+1的动态数组,MPI要求缓冲区地址必须有足够空间,不能用vector直接传内存地址。
真正的完整实现里,pivot_owner不会提前知道。正确的做法是先用MPI_Allreduce找出第k列绝对值最大的全局主元行,再把那个进程编号传给所有进程。后文会单独讲这个归约技巧。
3.3 按列划分:消元在列上,广播就变成了行归约
按列划分把n+1列切成多个连续块,每个进程持有若干列。这样做的好处是,每次消元时第k列下方元素的更新发生在同一个进程内部,主元搜索不需要跨进程遍历,只需要在持有第k列的进程本地扫描。但更新其他行的第j列时,如果j所在的列不在本进程,就需要远程读取,这比按行划分更绕。
实际课程设计里,按列划分多用于验证“数据布局改变通信模式”这个认知。按行划分时,广播的主元行是连续内存,一次MPI_Bcast就能搞定;按列划分时,主元行被拆散在各个进程中,要先做一次MPI_Gather把整行收集到根进程,再从根进程广播下去,通信量多了一倍。除非矩阵本身按列生成(比如有限差分得到的稀疏矩阵)或配合SSE/AVX做列方向向量化,否则我不建议在稠密高斯消去里优先用按列划分。
3.4 均匀划分静态与动态:解决负载均衡的两种思路
均匀划分-静态是指不是连续分块,而是把行按循环方式分给进程:第0行给进程0,第1行给进程1,第i行给进程i%size。这种循环划分让每轮消元时,要更新的行均匀分布在所有进程上,避免前面进程提前空闲。代价是进程p持有的行号不再是连续区间,更新前要维护一个global_to_local的映射表,代码复杂一点。
均匀划分-动态更进一步:用一个全局计数器task_counter,进程每处理完一行,就去counter领取下一个未处理的行号。动态划分最灵活,但计数器更新需要进程间同步,一般用MPI_Send/MPI_Recv实现一个小型请求服务,频繁通信会成为瓶颈。在我的经验里,高斯消去每轮更新行的计算量差别不大,静态循环划分已经足够,动态划分只有在矩阵某些行因为条件判断跳过部分更新时才有优势。
| 划分方式 | 内存局部性 | 每轮通信次数 | 负载均衡 | 实现复杂度 |
|---|---|---|---|---|
| 连续行块 | 最好 | 1次MPI_Bcast | 差,前方进程早空 | 低 |
| 连续列块 | 中 | 1次Gather+1次Bcast | 中 | 高 |
| 静态循环行 | 好 | 1次MPI_Bcast | 好 | 中 |
| 动态行分配 | 差 | 多次小控制消息 | 最好 | 最高 |
4. 通信模式重新设计:广播方式、非阻塞通信与流水线算法
4.1 MPI_Bcast的隐式同步代价
按行划分的MPI版本里,每一轮消元都要把当前主元行广播给所有进程。使用MPI_Bcast写起来最简单,但它是一个同步的集合操作,调用时进程要等通信完成才能继续往下走。在集群上,进程0广播时,其他进程可能还停在上一轮计算里,这就会产生等待;当n/size不是整数时,有的进程手里行数少,早早空闲在MPI_Bcast上,其他人还要继续算,整体时间被拖长。
一个容易忽略的问题是MPI_Bcast内部实现并非对所有消息大小都走同一条路径。小消息使用直连或树形广播,大消息在MPICH和OpenMPI里可能使用二项式树或递归倍增,通信量不完全一致。如果你要测试不同进程数的加速比,建议把主元行长度n控制在较大规模,比如n>=1024,这样才不至于让广播通信时间淹没在进程启动开销里。
4.2 用MPI_Isend和MPI_Irecv把计算压进通信间隙
非阻塞通信是改进广播等待最直接的手段。持有主元行的进程在MPI_Isend返回后,不等待发送完成就去更新本地的其他行;等本地计算做完再调用MPI_Wait把发送收尾。接收进程则提前用MPI_Irecv注册缓冲区,然后做不依赖主元行的本地计算,等需要主元行时再MPI_Wait。下面是一个典型的点对点替换片段。
MPI_Request req; double* pivot_row = new double[n + 1]; if (rank == pivot_owner) { memcpy(pivot_row, &local_a[row_in_local * (n + 1)], (n + 1) * sizeof(double)); MPI_Isend(pivot_row, n + 1, MPI_DOUBLE, next_rank, TAG_PIVOT, MPI_COMM_WORLD, &req); // 发送已提交,CPU可以继续做本进程的消元 update_local_rows(pivot_row, k); MPI_Wait(&req, MPI_STATUS_IGNORE); } else if (rank == next_rank) { MPI_Irecv(pivot_row, n + 1, MPI_DOUBLE, pivot_owner, TAG_PIVOT, MPI_COMM_WORLD, &req); // 这里不能立刻使用pivot_row,先做不依赖它的工作 do_partial_work_without_pivot(); MPI_Wait(&req, MPI_STATUS_IGNORE); update_local_rows(pivot_row, k); } delete[] pivot_row;这里MPI_Isend的req必须保留到MPI_Wait,不要在每个循环里new局部变量;tag值用于区分不同类型的消息,发送和接收要保持一致。MPI_Irecv的缓冲区在MPI_Wait之前不能被写入或读取,因为数据可能还没到达。这种模式在MPI标准里是合法的,但要注意,MPI_Isend可能出于实现原因直接拷贝到系统缓冲区并立即返回,也可能必须等接收方启动才能完成,所以性能上的“重叠”不一定每次都能看到。
4.3 流水线算法:把“每轮全局广播”降级为“相邻传递”
流水线算法改变了前面“所有进程同时得到主元行”的前提。它的思路是把进程看成一维链路,拥有主元行的进程不向所有人广播,而是只传给下一个进程;每个进程收到后更新自己的本地行,再把主元行继续传给后继。这样第k轮的主元行还在链路中间传输时,前面的进程已经开始第k+1轮的消元,形成流水。
// 流水线阶段,进程链路 0 -> 1 -> ... -> size-1 for (int k = 0; k < n - 1; ++k) { int owner = k / local_rows; // 当前主元行的初始位置 if (rank == 0 || rank == owner) { MPI_Send(pivot_row, n + 1, MPI_DOUBLE, rank + 1, k, MPI_COMM_WORLD); } if (rank > 0) { MPI_Recv(pivot_row, n + 1, MPI_DOUBLE, rank - 1, k, MPI_COMM_WORLD, MPI_STATUS_IGNORE); // 收到后先更新本进程所有未消完的行 for (int i = 0; i < local_rows; ++i) { if (global_i > k) { /* 用pivot_row消元 */ } } if (rank < size - 1) { MPI_Send(pivot_row, n + 1, MPI_DOUBLE, rank + 1, k, MPI_COMM_WORLD); } } }这里把tag直接设成k,是为了避免多条消息在链路上互相串扰。MPI_Send是阻塞发送,如果下一个进程还没执行MPI_Recv,发送进程会等待;在均匀数据规模下,这个等待不会变成死锁,因为消息流是单向的。流水线的死锁隐患出现在同时双向传递时,比如进程i同时向i-1和i+1发送并分别接收,四步顺序写错就会卡死,推荐用MPI_Sendrecv替代两个分离的Send/Recv。
流水线在“特殊高斯消去法”里要注意一个陷阱:列主元每轮都会改变主元行的来源,如果选出的主元行跨越多个进程,流水线链路上的消息就不再是从固定owner发出,而是要从新的owner开始向两边传播。为了保持流水线形状,通常先做一次全局MPI_Allreduce确定主元行,再由该行的owner作为链路起点,每轮都重新计算。
5. 集群上验证与混合编程调优:从MPI_Wtime到AVX/SSE
5.1 用MPI_Wtime计时,用残差验正确
串行程序里常用的clock()在MPI下不能跨节点使用,因为每个进程的CPU时间不同步,只能测本进程占用时间,测不出真实墙钟时间。正确的做法是MPI_Wtime,所有进程返回统一时钟,可以直接做差。
mpic++ -O2 -std=c++17 gauss_mpi.cpp -o gauss_mpi mpirun -np 4 ./gauss_mpi 2048验证正确性别直接用解向量对比。串行和MPI版本的运算顺序不一样,浮点结果天然有微小差异,阈值设在1e-8比较合理。更稳的做法是计算残差范数||Ax-b||∞,除以n×||A||∞×||x||∞做归一化,只要小于1e-10就认为并行实现没有破坏算法。
5.2 混用OpenMP、Pthread与AVX/SSE时注意绑定和指令检测
资源包里的按块划分Pthread.cpp、OpenMP.cpp、按块划分AVX.cpp,SSE.cpp说明作者做了节点内混合并行。MPI管节点间,OpenMP或Pthread管多核,AVX/SSE管单核向量化,这条路线是对的,但细节容易翻车。MPI和OpenMP混跑时,每个MPI进程默认会看到所有CPU核,如果不设置OMP_NUM_THREADS,每个进程都会启动大量线程,造成超订。通常让MPI进程数等于节点数,OMP_NUM_THREADS等于单节点物理核数,再设置OMP_PROC_BIND=true,把线程绑定到固定核。AVX512代码要在运行前检测CPU是否支持,否则一条非法指令直接SIGILL;最内层改用SSE后,连续一维数组比vector 更友好,否则每次load之前都要多做一次聚合。
5.3 列主元归约的一个高频技巧:MPI_MAXLOC
前面反复说列主元搜索需要全局归约,这里给出最简洁的写法。MPI标准提供MPI_MAXLOC,可以在一次MPI_Allreduce里同时得到最大值和对应的进程编号,避免了“先发最大值再找行号”的两轮通信。
struct { double val; int rank; } local, global; local.val = local_max; local.rank = rank; MPI_Allreduce(&local, &global, 1, MPI_DOUBLE_INT, MPI_MAXLOC, MPI_COMM_WORLD); // global.rank 就是持有全局主元行的进程注意MPI_DOUBLE_INT这个复合类型在MPI里是按特定内存布局定义的,不能用自定义struct替换,必须用它作为类型参数,确保MPI实现知道每个元素的偏移量。最后把验证步骤串起来:先n=128跑残差,再n=2048跑MPI_Wtime对比串行时间,最后编译时加-O3 -march=native看AVX/SSE版本是否达到线性加速。这样筛出来的版本,才是真正能在集群上稳定跑的版本。
本文还有配套的精品资源,点击获取