简介:一套面向高校并行计算课程的系统学习资料包,内容覆盖从并行体系结构、编程模型到性能分析的全链路,适合零基础到进阶学习者系统掌握。资料整理了并行计算常见知识点,包括共享内存/分布式内存模型、SIMD/MIMD体系结构、OpenMP和MPI编程、负载均衡与同步互斥、数据竞争和死锁防范,以及分治法、归约、扫描等并行算法策略和阿姆达尔、古斯塔夫森定律等性能评估方法。压缩包采用zip格式,容量约44.18MB,目前已有1584人学习浏览。资料源自天津大学并行计算课程,除系统知识梳理外,还配有实验报告、示例代码和考试复习总结,方便学生对照实践、巩固考点和考前冲刺。借助实验案例,可深入体会并行排序、矩阵运算、图形渲染等场景,在实际环境中掌握并行程序编写、性能分析和排错技巧。 我第一次在并行计算课程的实验终端里敲下mpirun -np 8 ./hello_mpi时,屏幕上瞬间跳出 8 行来自不同进程号的 Hello, World。那一刻我才意识到,这门课真正让人头疼的不是“把程序写快”,而是让一堆原本各干各的进程按照同一套节拍合作起来。后来我在天津大学并行计算相关的课程项目里,完整经历了从单机串行程序到教学集群多节点并行程序的全过程,也踩了不少只有真正上过集群才会遇到的坑。
这篇文章就把这段经历整理出来。它适合刚接触并行计算、第一次登上学校教学集群跑 MPI/OpenMP 作业的同学,也适合想系统梳理并行计算从环境配置到性能优化整个链路的人。我会尽量少讲空泛的理论,多说能直接落地的操作、命令、参数和排错思路。
1. 并行计算课程真正让人懵的点:一台机器远远不够
1.1 先分清“并行”和“并发”是两种完全不同的东西
很多同学刚上课时都会混淆这两个词。并发是同一时刻在逻辑上同时处理多个任务,比如一台 4 核机器上跑 8 个线程,操作系统靠时间片轮转制造“同时”的假象;而并行是同一时刻在物理上同时处理多个任务,这需要真的有多颗核、多台机器参与计算。
在并行计算课程里,绝大多数作业要求的是真并行。以天津大学常见的课程设计为例,你往往要同时面对两种并行维度:共享内存并行和分布式内存并行。共享内存并行用 OpenMP 或 pthread,所有线程能直接访问同一块内存,问题往往出在“谁先改、谁后读”;分布式内存并行用 MPI,每个进程只拥有自己那块内存,进程之间靠消息传递交换数据,问题往往出在“消息什么时候到、有没有收到”。
理解了这个本质区别,再去选工具就不会混乱。
1.2 三种主流编程模型怎么选
这里先给一张很实用的对照表,是我在并行计算作业中反复比较后整理出来的:
| 编程模型 | 内存模型 | 适合场景 | 典型接口/库 | 上手难度 |
|---|---|---|---|---|
| OpenMP | 共享内存 | 单节点内多核循环并行化 | #pragma omp parallel for | 较低 |
| MPI | 分布式内存 | 跨节点大规模科学计算 | MPI_Send、MPI_Recv、MPI_Reduce | 较高 |
| CUDA | GPU 显存 | 大规模数据密集型计算 | <<<grid, block>>>、__global__ | 高 |
选型逻辑其实很简单:如果你只需要把一段热点循环加速到 8 核、16 核,OpenMP 是性价比最高的;如果作业明确要求用多节点计算,比如用 4 台机器各起 4 个进程,那 MPI 绕不开;如果涉及图像处理、矩阵运算这类超大规模并行任务,而且课程安排了 GPU 节点,那就值得进 CUDA 的坑。
我之前做的一个矩阵乘法的课程作业就很有意思:OpenMP 版本只改了三行代码就获得了 12 倍加速,而 MPI 版本需要做数据分块和进程间通信,代码量多了上百行,加速比却受限于通信开销。这里要先说一句,并行单元选得越大,通信代价就越高,但通信次数却更少。这个矛盾会贯穿你整个并行计算学习过程。
2. 从零跑通一个并行程序的完整工具链:真正卡住你的往往是环境
2.1 在集群上提交第一个作业:SLURM 的打开方式
学校教学集群普遍使用 SLURM 作为作业调度系统。很多第一次接触集群的同学会习惯性地在登录节点直接执行./my_program,这不是不行,但对并行作业来说几乎必挂,因为登录节点的核数和内存都受限,而且你直接在登录节点跑大规模并行作业会干扰别人。
正确的姿势是写一个作业提交脚本,丢给 SLURM 调度。下面是我经常用的模板:
#!/bin/bash #SBATCH --job-name=mpi_demo #SBATCH --nodes=2 #SBATCH --ntasks-per-node=8 #SBATCH --cpus-per-task=1 #SBATCH --time=00:30:00 #SBATCH --partition=course #SBATCH --output=result_%j.out #SBATCH --error=result_%j.err module load intel-mpi mpirun ./mpi_pi每一行都不能乱写,参数含义是这样的:
--nodes=2:申请 2 台计算节点;--ntasks-per-node=8:每台节点跑 8 个 MPI 进程,总共 16 个进程;--cpus-per-task=1:每个进程分配 1 个 CPU 核心;--partition=course:选择课程专用的分区,不选这个可能被调度到配置不对的节点上;--output=result_%j.out:%j是作业 ID 占位符,用于区分不同次运行结果。
提交命令只有一行:sbatch run.slurm。查看作业排队和运行情况用squeue -u 你的学号,作业结束后再查看输出文件。这个流程我建议至少手动走三遍,直到你不需要想就知道下一步干什么。
2.2 编译环节容易翻车的几个细节
跑并行程序前,编译就是第一个大坑。我这里说的不是语法错误,而是一些很隐蔽的链接问题。
MPI 的编译器命令是封装过的。一般 C 语言用mpicc,C++ 用mpicxx,Fortran 用mpif90,它们本质上是在系统原生的 gcc/g++ 后面自动附加了 MPI 头文件路径和链接库。所以不要手动写gcc -lmpi,版本和路径很容易与集群默认的 MPI 实现不一致。
OpenMP 的编译选项是-fopenmp,但要注意它必须出现在编译和链接两个阶段。有人只在编译时加了-fopenmp,链接时忘了写,结果程序编译通过,运行时多线程完全不生效,或者直接报undefined reference to GOMP_parallel这类链接错误。
CUDA 程序用nvcc编译时,要特别注意 GPU 架构参数。同一份代码在 A100 和 V100 上的编译选项并不完全相同,用-arch=sm_80编译的二进制在 sm_70 的卡上可能运行不了。课程集群一般会配置好默认的-arch,如果发现程序运行时出现no kernel image is available这类报错,大概率就是架构不匹配。
提示:在集群上不要随便把自己机器上的二进制文件直接拿过去跑,不同节点的 CPU 指令集、MPI 库版本、CUDA 驱动都可能不一样。老老实实在登录节点重新编译一次,成本远比排查诡异段错误低得多。
3. 把作业从“能跑”改成“跑得值”:一条经典优化路径复盘
3.1 先算理论加速比,而不是直接优化代码
第一步,我强烈建议先估算一下什么问题值得并行。Amdahl 定律给出过一个很扎心的结论:一个程序的加速比上限由串行部分比例决定。
公式是:加速比 = 1 / (s + (1-s)/N),其中 s 是串行时间占比,N 是处理器数量。
我之前做过一个流体模拟的作业,程序整体里初始化、写文件、部分后处理占 2% 的串行时间,如果理想情况下用 64 核并行,理论加速比大概是:1 / (0.02 + 0.98/64) ≈ 26.7 倍。但实际测出来只有 18 倍,卡点就在进程间通信和负载不均。所以 Amdahl 定律的意义不在于给出精确预测,而是帮你判断:如果串行部分占比超过 10%,你优化并行部分的收益会迅速衰减,这时候应该先优化串行部分本身。
3.2 通信优化:把“聊天”次数减下来
对 MPI 程序来说,性能最大的敌人往往是通信而不是计算。我常跟同学说,MPI 进程之间的消息传递就像异地团队开电话会:每次通话都有时延和带宽开销,你与其打一百次短暂电话说一个数字,不如攒够一百个数字打一次长电话说完。
以圆周率计算的蒙特卡洛积分版为例,常见的低效写法是每个进程算完一部分后立刻把局部结果发回 0 号进程,0 号进程再分发给其他进程做下一步判断,这会造成大量点对点通信。正确的做法是让每个进程独立完成足够多的采样,最后只调用一次MPI_Reduce做全局归约:
#include <mpi.h> #include <stdio.h> #include <stdlib.h> #include <math.h> int main(int argc, char **argv) { int rank, size, i; long long total = 100000000; long long local_count = 0, global_count = 0; double x, y, pi; MPI_Init(&argc, &argv); MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); unsigned int seed = (unsigned int)time(NULL) + rank * 7919; long long local_n = total / size; for (i = 0; i < local_n; i++) { x = (double)rand_r(&seed) / RAND_MAX; y = (double)rand_r(&seed) / RAND_MAX; if (x * x + y * y <= 1.0) local_count++; } MPI_Reduce(&local_count, &global_count, 1, MPI_LONG_LONG, MPI_SUM, 0, MPI_COMM_WORLD); if (rank == 0) { pi = 4.0 * (double)global_count / total; printf("Pi = %f\n", pi); } MPI_Finalize(); return 0; }聚合通信代替点对点通信之后,同样的采样点数,16 进程的加速比从 9.2 提升到了 14.5 倍。唯一要注意的是,实际改写时先确认聚合操作的语义是否符合业务逻辑,像、MPI_Allreduce是把结果广播回所有进程,而MPI_Reduce只把结果送到根进程,别用混。
3.3 OpenMP 与 CUDA 的优化各自瞄着不同的地方
OpenMP 优化中最常见的改善点是循环级并行与调度策略。对计算量不均匀的循环,默认的静态调度很可能让某些核早早干完,闲在那里等别人。我之前在一个稀疏矩阵迭代里把调度方式改成schedule(dynamic, 100),让每个线程每次动态抓取 100 次迭代的任务,整体运行时间减少约 25%。
CUDA 的优化核心则是存储访问。GPU 的访存效率有个“合并访问”原则:同一 warp 里的 32 个线程最好访问连续的地址。如果你写出一个 stride 很大的循环,比如for (i = tid; i < n; i += 32) a[i] += 1.0f,虽然功能没错,但每个线程访问的内存地址彼此之间离得很远,显存带宽会被严重浪费。把这个循环改成相邻线程访问相邻地址后,内存吞吐能翻好几倍。
另外提一个典型坑:shared memory的 bank conflict。共享内存在硬件上被分成 32 个 bank,如果同一 warp 的多个线程同时访问同一个 bank 的不同地址,就会引发访问冲突,导致串行化惩罚。解决办法通常是给数组填充一定数量的哑元数据(padding),改变地址映射。很多并行计算的作业不会直接考 bank conflict,但性能优化报告里写出这一点会非常加分。
4. 集群实战中踩过的坑,和完整的排查链路
4.1 第一个坑:任务“卡死”了,CPU 占用却为 0
有一段时间我的 MPI 程序在小规模(4 进程)下运行正常,一上 16 进程就卡住不动。用squeue查看作业还在运行,但登录节点用top看所有进程的 CPU 占用率几乎为 0,典型的死锁现象。
复盘后的定位链路是这样的:
- 先复现。直接在计算节点上手动跑
mpirun -np 16 ./program,加上--bind-to core和--report-bindings参数,结果仍然卡住,排除了 SLURM 资源绑定问题。 - 插桩定位。在代码里每个进程的主要通信点加上
fprintf(stderr, "rank %d reach point %d\n", rank, point_id),重编译后跑一次,发现所有进程都停在了某个MPI_Send调用上。 - 分析代码。该处是相邻进程交换边界数据,我原来写的是先
MPI_Send给右邻居,再MPI_Recv从左邻居收。当 16 个进程同时执行MPI_Send时,缓冲区满后发送函数阻塞,而接收动作还没执行,于是互相等待。 - 修复。把发送接收顺序调整,或改用
MPI_Sendrecv同时收发。 - 验证。重跑后观察输出和耗时,再在大规模下重复测试。
这个教训很典型:很多新手以为MPI_Send只是发送,实际上它是可能阻塞的。正确理解消息缓冲机制是 MPI 排错的基础。
4.2 第二个坑:结果每次跑都不一样,怀疑人生
另一个常见问题是计算结果不稳定,同样输入、同样进程数,每次跑出来的数值都有细微差别。这种情况在并行计算里几乎可以锁定为竞态条件。
我当时遇到的是一个 OpenMP 程序:多个线程同时往里写同一个全局数组,理论上不同的写顺序会导致不同的浮点累加顺序,最后一位小数不稳定。排查方法是用 ThreadSanitizer 重新编译:
gcc -fsanitize=thread -fopenmp -g -o program program.c ./program它很快就定位到了发生数据竞争的具体行号。修复手段也简单,把累加操作改成#pragma omp parallel for reduction(+:sum),让每个线程保持私有变量,最后统一归约。
这里注意的是:浮点累加顺序本来就是不确定的,即便如此,用 reduction 至少能保证每次累加顺序一致,结果可复现。做并行计算实验时,结果可复现性和正确性同样重要。
4.3 第三个坑:作业莫名其妙被“挤掉”,其实是被 OOM Kill 了
还有一次我给作业加了较大的矩阵数据,运行几分钟后作业状态直接变成FAILED。用scontrol show job <job_id>查看后发现,进程由于内存超过节点限制被OOM Killer终止了。
排查思路是先确认程序的内存占用峰值,再回到 SLURM 脚本中显式申请内存。如果你的程序确实需要很多内存,就在脚本里加:
#SBATCH --mem=32G或者是按 CPU 核数计算:
#SBATCH --mem-per-cpu=4G如果作业本身不申请那么多内存,就别顺手写上很大的数值,这样不仅浪费公共资源,排队时间也可能变长。另外,MPI 作业申请内存时还要注意:--mem是每节点的内存总量,而不是所有节点的总和,这个细节看两遍文档不亏。
5. 用课程里的性能分析工具说话,而不是靠猜
5.1 三个效率极高的“埋点”方法
性能优化的最基本原则是测量先行。我见过太多同学写代码半小时,调参两小时,最后也不知道瓶颈在哪。这里分享三个我实际用下来性价比最高的手段。
第一个是 MPI 自带的计时接口MPI_Wtime()。在程序的关键区段前后各取一次时间,算出通信时间和计算时间分别占比多少,比任何外部 perf 工具都直观。一个简单的记录方法:
double t_start, t_comm, t_total; MPI_Barrier(MPI_COMM_WORLD); t_start = MPI_Wtime(); // 计算区段 t_total = MPI_Wtime() - t_start;第二个是 gprof。编译时加-pg,运行结束后会生成gmon.out,再执行gprof ./program gmon.out | head -50就能看到每个函数消耗的 CPU 时间占比。要注意,MPI 程序用 gprof 统计的是每个进程各自的信息,建议配合重定向,只查看 0 号进程的结果文件,或者用专门的 MPI 性能工具如 mpiP、TAU 来分析。
第三个是 CUDA 环境下的 Nsight Compute(新版)或nvprof(老版本)。一行命令nvprof --print-gpu-trace ./program能清晰展示 kernel 执行时长、内存传输量、占用率等信息。对你写性能优化报告非常有用。
5.2 用数据绘制加速比曲线,优化方向立刻清晰
不要只会贴出一句“程序运行时间变短了”。更专业的做法是测一组不同进程数下的运行时间,比如 1、2、4、8、16、32 进程各跑三次取平均,然后计算加速比和并行效率。
并行效率 = 加速比 / 进程数。如果 16 进程时的并行效率只有 45%,说明增加进程带来的收益已经非常有限。再配合通信时间占比,就能精确回答“这一步到底该继续加核,还是优化通信”这个核心问题。
我自己的习惯是做完实验后画一张表格:
| 进程数 | 平均运行时间(s) | 加速比 | 并行效率 |
|---|---|---|---|
| 1 | 512.3 | 1.0 | 100% |
| 2 | 261.8 | 1.96 | 98% |
| 4 | 132.5 | 3.87 | 97% |
| 8 | 70.1 | 7.31 | 91% |
| 16 | 42.8 | 11.97 | 75% |
从这张表能非常明显地看到,当进程数超过 8 以后,效率开始下滑。接下来再进一步定位是负载不均还是通信开销导致的,优化方向就非常清晰了。
6 最后想分享的几点个人经验
这一路并行计算项目做下来,我最大的体会就是:并行计算不是“把一个程序拆开跑就完了”,它是一个从算法拆解、资源规划、编码实现到性能评估的完整系统工程。
如果让我给刚开始接触并行计算课程的同学两个具体建议,我会说:
第一,每次调试 MPI 程序时,从小规模开始。先在单机 2 个进程上验证逻辑正确,再逐渐扩大到多节点、多进程。不要一开始就挑战 64 进程,否则出了问题你连日志都不一定来得及存下来。
第二,善用集群上的测试队列或小分区。课程集群通常有专门用于短时间测试的队列,可以让你快速试错而不占用宝贵的课程作业额度。跑大规模正式作业前,先跑一次极小规模确认参数没问题,再提交完整作业,这样能极大减少无效排队时间。
这些经验如果重新学一遍,我会更早去画加速比曲线、更早去搭一套可复现的性能测量流程,而不是凭感觉调参。希望这篇分享能帮你少走一点我走过的弯路。
本文还有配套的精品资源,点击获取