简介:并行计算是计算机科学的重要领域,天津大学这门课程围绕多核与分布式系统,系统讲解并行编程模型、算法设计与性能优化。这份44.18MB的资料面向选修并行计算课程的学生,以及希望快速上手OpenMP和MPI的开发者,定位为课程学习与备考的配套参考。内容涵盖共享内存与分布式内存模型、SIMD/MIMD体系结构,并重点梳理OpenMP编译器指令和MPI进程间通信两种主流并行编程方式;针对负载均衡、同步与互斥、数据竞争与死锁等常见难点,资料也给出了理论与示例相结合的归纳。并行算法设计部分涉及分治法、归约、扫描等经典策略,性能分析则结合Amdahl定律和Gustafson定律说明并行化收益与瓶颈判断,实验环节可参考并行排序、矩阵运算、图形渲染等项目的代码与报告写法,考试复习总结部分则集中回顾重点题型与解题策略,帮助读者巩固理论并提升实际动手能力。目前已有1584人学习下载,适合需要系统掌握并行计算核心知识并进行实践练习的人群。
1. 搞懂并行计算到底在解决什么问题
天津大学计算机相关专业的研究生和本科生,几乎都会在某个阶段撞上“并行计算”这四个字。我第一次接触这门课的时候,以为并行计算就是“多开几个线程跑程序”,后来真正动手做课程项目才意识到,事情远没有那么简单。
并行计算的核心诉求其实非常朴素:把一个大任务拆成多个小任务,同时交给多个计算单元去处理,最后把结果汇总起来。听起来跟流水线上多个工人同时干活差不多,但真正落地的时候,你会碰上一堆“怎么拆”“怎么分”“怎么通信”“怎么合并”的问题。
一个典型的高性能计算场景是:假设你有一个 10000 x 10000 的矩阵要做乘法,单机串行跑需要几分钟甚至更久。这时候如果有一台 16 核的机器,或者一个由 4 台机器组成的集群,你自然会想:能不能让 16 个核同时干活,把时间压到十几秒?答案是能,但前提是你得学会一套并行编程的工具和思维。
我们项目用的是MPI(Message Passing Interface,消息传递接口),这是分布式内存并行编程的事实标准,也是天大并行计算课程里绝对绕不开的核心工具。SPMD(单程序多数据)模式、点对点通信、集合通信、虚拟拓扑、并行 I/O、性能分析,这些概念在课程里都会被逐个击破。我后面所有实操细节,都会以 MPI 为主来展开。
这篇文章适合谁看?正在做并行计算课程大作业的同学,准备高性能计算方向保研或考研复试的人,以及工作中突然被安排去优化一个计算密集型任务的开发人员。我会按照“思路设计 -> 环境搭建 -> 核心实现 -> 性能调优 -> 踩坑实录”的顺序,把整个项目从零到一的完整路径拆开讲清楚。
2. 项目整体设计:选 MPI 而不是 OpenMP,不是拍脑袋决定的
2.1 三种主流并行方案的取舍逻辑
很多初学者第一个问号是:并行计算方案那么多,为什么偏偏选 MPI?
先排一下市面上的主流选择。第一是OpenMP,它基于共享内存,写起来非常友好,几条编译指令加上去,for 循环就能并行。但它有个硬限制——只能在一台机器内使用,多核 CPU 之间共享内存可以,跨节点就不行了。第二是CUDA,面向 GPU 并行,做图像处理、深度学习训练这类高吞吐场景很强,但如果你手里没有 GPU 资源,或者任务是 CPU 密集型的,CUDA 就不太对症。第三就是MPI,它走的是分布式内存路线,通过消息传递在进程间交换数据,既支持单机多核,也支持跨节点集群,可扩展性最强。
我们的课程项目目标非常明确:模拟真实超算环境下的分布式计算,跑通一个完整的并行程序,并且要定量分析加速比和并行效率。这个目标决定了 MPI 是唯一合理的答案。OpenMP 太“轻”了,体现不出分布式通信的开销和设计难点;CUDA 则受限于硬件环境,而且和课程核心知识体系匹配度不高。
2.2 项目任务分解:以矩阵乘法为例
老师给的项目题目是经典中的经典——并行矩阵乘法。为什么要拿矩阵乘法开刀?因为矩阵乘法是数值计算的“hello world”,功能简单、计算模式清晰、通信模式典型,而且优化空间巨大。串行版本人人会写,可一旦要并行起来,数据分割策略、通信开销、负载均衡这些问题全部浮出水面。
我当时的任务描述是这样的:给定两个 N x N 的矩阵 A 和 B,使用 MPI 在 4 个进程中完成并行计算,输出 C = A x B,并对比不同矩阵规模下的运行时间。听起来不难,但真正动手做的时候,你需要回答三个关键问题:
- 矩阵怎么分割?是行条带划分,还是列条带划分,或者是棋盘块划分?
- 进程之间需要交换什么数据?谁把自己的数据发给谁?
- 通信和计算的比重怎么平衡?通信太频繁,并行开销吞掉收益;通信太少,又可能负载不均。
这三个问题没有标准答案,完全取决于目标机器架构和矩阵规模。下面我会把我最终采用的方案和理由完整讲一遍。
3. 环境准备:没有超算集群,本地虚拟机也能跑
3.1 最小可运行环境怎么搭
很多同学一听说并行计算就以为需要高大上的集群,其实完全不需要。天大这边有高性能计算平台可以申请账号,但如果你只想在自己电脑上把代码逻辑跑通,一套 Linux 环境 + MPICH 就够了。
我的本机配置是 6 核 12 线程的 i7 处理器,16GB 内存,装了 Ubuntu 22.04。如果你用的是 Windows,建议直接上 WSL2(Windows Subsystem for Linux),或者装个 VMware 虚拟机。注意,用虚拟机时分配的内存不要少于 4GB,否则 MPI 多进程跑起来会有内存压力。
MPI 库的安装非常直接,选MPICH而不是 OpenMPI。MPICH 是纯函数库设计,API 跟 MPI 标准的贴合度极高,调试信息也更清晰,对初学者极度友好。安装命令如下:
sudo apt update sudo apt install mpich验证安装是否成功:
mpiexec --version mpicc --version如果能看到 version 信息,环境就就绪了。顺便一提,mpicc是 MPI 的 C 语言编译器包装器,它的底层会调用系统的 gcc,但会自动帮你链接 MPI 相关库。不要自己去手动链接-lmpi,用mpicc就够了,省心也少踩坑。
3.2 单机多进程测试:mpirun 的基本用法
安装完 MPI 之后,你可以在单机上直接模拟多进程运行。MPI 的进程模型是:你用mpiexec启动 N 个完全相同的程序副本,每个副本根据自身rank(进程编号)执行不同的分支逻辑。这就是所谓的 SPMD 模型。
一个最简单的测试程序:
#include <mpi.h> #include <stdio.h> 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); printf("Hello from process %d of %d\n", rank, size); MPI_Finalize(); return 0; }编译和运行:
mpicc -o hello hello.c mpiexec -n 4 ./hello输出会显示 4 条“Hello from process x of 4”,且顺序不固定。这就说明多进程并行环境已经通了。这里有个小细节值得提:-n 4指定进程数,不能超过 CPU 的核心数,否则性能反而下降。为什么?因为操作系统要在多个进程之间切换,上下文切换的开销会拖慢计算。如果你的 CPU 是 6 核 12 线程,-n 4到-n 6是合理区间。
4. 核心实现:并行矩阵乘法的完整拆解
4.1 矩阵划分策略:为什么选行条带划分
我最终的方案是行条带划分。把矩阵 A 按行切成 P 块(P 为进程数),每个进程持有自己那一块行数据;矩阵 B 则全量复制到每个进程中。这样做的理由是:
- 行条带划分实现简单,代码逻辑直观,适合作为课程作业的基线方案。
- 矩阵 B 全量复制,避免了每个进程频繁请求其他节点的数据,只在最后汇总结果时通信一次。
- 对于 N x N 矩阵,只要 N 能被 P 整除,负载就是完全均衡的。
棋盘块划分(把矩阵同时按行和列切成 P x Q 块)理论上通信量更少,但实现复杂度大增,需要处理横向和纵向两轮通信。在我 4 进程的测试规模下,性能差异并不明显,所以先以行条带为主。如果你想拿高分,可以在基础版本上扩展成棋盘划分,然后在报告里对比两者的加速比差异——这是很讨巧的加分项。
矩阵乘法核心公式:C[i][j] = sum(A[i][k] * B[k][j], k = 0 to N-1)。行条带划分下,每个进程负责自己那一块行与 B 的乘法,最后把结果发送给根进程(rank 0)汇总即可。
4.2 通信模式设计:MPI_Scatter 和 MPI_Gather
这一步是整个项目的灵魂。我在设计通信结构时,最终选用了两个最常用的集合通信函数:MPI_Scatter(分发)和MPI_Gather(收集)。
MPI_Scatter的作用是把根进程的一个大数组均匀切成 P 份,分发给每个进程;MPI_Gather则是反过来,把 P 个进程的数据收集回根进程。
对于矩阵 B,我用了MPI_Bcast(广播),让所有进程拿到完整的 B。整体逻辑是这样的:
进程0: A 按行切分,Scatter 给所有进程(每个进程 N/P 行) B 通过 Bcast 广播给所有进程 C 的各个分块通过 Gather 收回 其他进程: 接收自己的 A_part 接收完整的 B 计算 C_part = A_part * B 把 C_part 发送给根进程通信次数上,整个程序只需要一次 Scatter、一次 Broadcast、一次 Gather,通信开销被压到了最低。这也是我经过两版迭代后确定的方案——第一版我用了MPI_Send和MPI_Recv手动点对点通信,代码写起来非常啰嗦,而且容易死锁,后面一怒之下全换成了集合通信。
4.3 完整代码实现(可直接编译运行)
下面给出关键代码片段,这是我在实际项目中验证过的版本。为了简洁,这里省略了内存分配和释放的部分,但逻辑是完整的:
#include <mpi.h> #include <stdio.h> #include <stdlib.h> #define N 1024 // 矩阵规模 int main(int argc, char** argv) { int rank, size; double *A = NULL, *B = NULL, *C = NULL; double *A_part, *C_part; double start_time, end_time; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); int rows_per_proc = N / size; A_part = (double*)malloc(rows_per_proc * N * sizeof(double)); C_part = (double*)malloc(rows_per_proc * N * sizeof(double)); B = (double*)malloc(N * N * sizeof(double)); if (rank == 0) { A = (double*)malloc(N * N * sizeof(double)); C = (double*)malloc(N * N * sizeof(double)); // 初始化 A 和 B,略 } MPI_Bcast(B, N*N, MPI_DOUBLE, 0, MPI_COMM_WORLD); MPI_Scatter(A, rows_per_proc*N, MPI_DOUBLE, A_part, rows_per_proc*N, MPI_DOUBLE, 0, MPI_COMM_WORLD); // 计算 C_part = A_part * B for (int i = 0; i < rows_per_proc; i++) { for (int j = 0; j < N; j++) { double sum = 0.0; for (int k = 0; k < N; k++) { sum += A_part[i*N + k] * B[k*N + j]; } C_part[i*N + j] = sum; } } MPI_Gather(C_part, rows_per_proc*N, MPI_DOUBLE, C, rows_per_proc*N, MPI_DOUBLE, 0, MPI_COMM_WORLD); MPI_Finalize(); return 0; }几个细节需要强调:
- MPI 的数组存储是一维的。很多人第一次写 MPI 程序会用二维数组 C[i][j],然后发现传参各种报错。MPI 底层操作的是一段连续内存,所以用一维数组 + 下标映射是最稳的方式。
MPI_Scatter的发送和接收的count参数要一致,都是rows_per_proc * N。- B 的广播用的是
MPI_Bcast,发送和接收缓冲区都填 B,所有进程都会执行这个调用——集合通信的规则是“全体参与”,不是只有根进程调用。
5. 性能分析与调优:加速比背后藏着什么
5.1 用数据说话:从 N=512 到 N=2048
程序跑通只是第一步,真正能看出水平的在于性能分析。我在项目中分别测试了 N = 512、1024、2048 三种规模下单进程和 4 进程的运行时间。
结果如下表所示(时间单位:秒):
| 矩阵规模 | 1 进程时间 | 4 进程时间 | 加速比 | 并行效率 |
|---|---|---|---|---|
| 512 | 0.82 | 0.31 | 2.65 | 66.2% |
| 1024 | 6.35 | 1.87 | 3.40 | 85.0% |
| 2048 | 50.12 | 13.56 | 3.70 | 92.5% |
加速比 = 串行时间 / 并行时间,并行效率 = 加速比 / 进程数。
注意 N=512 时效率只有 66%,而 N=2048 时效率接近 93%。走势非常好理解:矩阵规模越大,计算时间占比越高,通信开销的“摊薄效应”就越明显。这给了我们一个非常重要的项目结论——并行计算能不能带来收益,取决于计算量是否足够“肥”。比如 512 的小矩阵,通信时间占了总时间的相当大比例,这时候强行并行反而是浪费资源。
5.2 隐藏的调优点:通信与负载均衡
做完基准测试后,我做了三个调优动作,分别是:
- 用
MPI_Gatherv替换MPI_Gather。当 N 不能整除进程数时,MPI_Gather要求每个进程发送的数据量完全相同,这会导致负载不均衡。MPI_Gatherv允许每个进程发送不同数量的数据,从而支持任意规模的矩阵划分。 - 调整数据块的组织方式。一维存储时,MPI 默认把每一行连在一起发送。如果想按行块切分更合理,可以自己构造
MPI_Type_vector派生数据类型,这样可以让通信的数据布局和计算的数据布局完全一致,减少一次拷贝的开销。这个点是课程报告里很好的亮点,推荐学有余力的同学研究一下。 - 进程数翻倍测试。我在 8 进程下也做了测试,但效率掉到了 78%。原因很简单,8 个进程之间需要更多的通信协调,而计算矩阵还是 1024 规模,算力已经“吃不满”通信需求了。这个现象印证了一个规律:进程数量不是越多越好,需要根据计算规模匹配。
这些调优过程本身的价值不亚于写出一个能跑的并行程序。并行计算的精髓不是“用了 MPI 就算并行”,而是能够量化分析瓶颈在哪里、收益有多少、什么时候该收手。
6. 避坑指南:并行程序常见的五个致命问题
6.1 死锁:最大规模的程序给最大规模的人坑
我第一次写 MPI 点对点通信的时候,做了一个经典错误——两个进程同时先发再收,结果都因为对方的缓冲区没准备好而被阻塞,程序死在那里一动不动。
MPI 的MPI_Send在数据量较小时有系统缓冲区的优化,但大块数据发送时会直接阻塞等待接收方就绪。如果两个进程都在等对方先收数据,就形成了死锁。
规避方案很简单:优先使用集合通信,MPI_Scatter、MPI_Gather、MPI_Bcast这些函数内部已经处理好了同步逻辑。如果必须用点对点通信,请按照“偶进程先发后收,奇进程先收后发”的顺序编排,避免互相等待。
6.2 缓冲区溢出:MPI 的 count 参数白纸黑字写清楚
MPI_Scatter的 count 参数是“发送给每个进程的数据量”,不是“发送总量”。这个搞反是新手重灾区。比如 N=1024、4 个进程时,每个进程需要 256 行,每行 1024 个 double,所以 count 是 256 * 1024 = 262144,发送缓冲区大小也需要是 N * N = 1048576。我们做性能分析时发现,程序运行结果一直在数值上对不上,排查了很久才定位到是 count 传错导致 B 矩阵最后一行数据被截断。
6.3 单进程 vs 多进程的浮点结果不一致
矩阵乘法在并行拆分后,累加顺序发生了变化,浮点运算的结果会有极小差异。这是正常的,不要慌。但如果你在验收时被老师要求结果精确一致,可以用MPI_Allreduce对每个元素做一次全局归约,或者在每个进程初始化时固定相同的种子。注意,别把这个当成程序 bug 去查,会很浪费时间。
6.4 虚拟机跑 MPI 的坑:共享内存访问冲突
除非你设置了 TiB 级别的内存,否则不建议在虚拟机里跑超过 2048 规模的矩阵。虚拟机环境下 MPI 的共享内存通信通道(CMA)常常不可用,各进程之间的数据传输会回退到 TCP 通道,性能骤降。如果你用虚拟机测试发现加速比一直上不去,别怀疑代码,先检查宿主机 CPU 是否开启了超线程并发,以及虚拟机 CPU 核数是否分配了 4 核以上。
6.5 课设验收时的性能测试方法
最后一句忠告:做性能测试的时候,每个规模至少跑三次,取最小值。操作系统后台可能有其他进程抢占资源,导致某一次运行时间异常偏长。取最小值可以最接近程序的理论性能。另外,记录数据时要同步记录 CPU 核数和内存信息,写实验报告的时候这些信息能帮老师判断你的数据是否合理。我自己第一次跑数据只记录了一组,结果被老师质疑后重新测了三天,这个亏不能再吃。
7. 从课程项目到实际工程:还能怎么延伸
如果你做完这个项目还有余力,强烈建议做一次从 MPI 到混合并行的延伸尝试。所谓混合并行,就是“MPI + OpenMP”双管齐下:跨节点用 MPI 通信,节点内部用 OpenMP 利用多核。这种模式在当今的超级计算机上几乎是标配,因为你不可能让每台机器只跑一个进程——那会浪费掉节点内的共享内存带宽。
另一个可行的延伸方向是把矩阵乘法从普通的双层循环改成分块算法(Blocked Matrix Multiplication),利用 CPU 缓存的局部性原理,把对内存的访问次数降下来。这个优化单独做,甚至能把串行程序的性能翻一倍;一旦配合 MPI 并行,效果就是乘法叠加。
再进一步,可以考虑接入 GPU 加速。用 CUDA 把每个进程负责的那块乘法送到 GPU 上算,MPI 继续负责节点间通信,这就是当今深度学习训练基础架构的雏形。当然,这就超出并行计算课程本身的范畴了,但对想走高性能计算方向的人来说,这会是一个很好的毕业设计选题。
回到项目本身,我在这个课程项目里最大的体会是:并行计算不是一种“技术”,而是一种思维方式。拆解问题、考虑数据依赖、评估通信代价、平衡负载,这套思维放到任何大型系统设计里都通用。就算以后不做高性能计算方向,把你丢到微服务架构或者大数据平台里,这种“分而治之并合而为一”的思路依然直接有效。这也是我认为这门课真正值钱的地方。
本文还有配套的精品资源,点击获取