简介:这份天津大学并行计算课程资料面向计算机相关专业学生及希望系统入门高性能计算的开发者,围绕多核与分布式环境下的任务分解、通信与性能优化展开,帮助读者建立从理论到编程实践的完整认知。内容覆盖共享内存与分布式内存模型、SIMD/MIMD体系结构、OpenMP与MPI编程模型、负载均衡、同步互斥、分治与归约扫描等并行算法设计,以及Amdahl定律和Gustafson定律的性能分析,并延伸至并行排序、矩阵运算、图形渲染等实验场景与考试复习要点。资源以zip压缩包形式提供,整体约44.18MB,文件类型明细暂未提供,适合按知识点逐章查阅与动手复现。目前已有1586人学习,可作为课程同步学习、实验报告参考与考前梳理的辅助材料,帮助读者理解并行程序的编写思路与性能瓶颈定位方法。
1. 并行计算入门:从一次串行程序的性能瓶颈说起
很多开发者第一次接触并行计算,是因为手里那个串行程序实在跑不动了。一个矩阵乘 4096×4096,单线程跑了将近两分钟;一个蒙特卡洛模拟要迭代上亿次,风扇转得像要起飞,进度条却纹丝不动。这时候你自然会想:机器明明有 8 核 16 线程,为什么只用了 1 个?并行计算要解决的就是这个问题——把一个大任务拆成若干可同时执行的子任务,分配到多个计算单元上,让它们真正同时干活,从而把墙钟时间压下来。
这个方向适合谁?适合已经会写 C/C++、Python 或 Fortran,但程序性能卡在单核上的工程师和研究生;也适合需要跑数值模拟、图像处理、参数扫描、训练小模型这类计算密集型任务的人。它不要求你先成为计算机体系结构专家,但要求你理解一件事:并行不是免费的午餐,拆分、通信、同步、负载均衡,每一步都可能吃掉你辛苦换来的加速比。下面从最朴素的串行瓶颈讲起,一路走到能复现的并行程序、参数调优和踩坑排查。
2. 并行计算的理论基础与选型逻辑
2.1 加速比从哪来:Amdahl 定律与 Gustafson 定律
在动手写第一行并行代码之前,必须先搞清楚一个残酷的事实:并行程序的加速比有上限。Amdahl 定律说,如果一个任务中可并行部分占比为 p,串行部分占比为 1-p,那么用 N 个处理器能达到的最大加速比为:
S(N) = 1 / ((1-p) + p/N)
举个例子,如果你的程序有 20% 的代码必须串行执行,那么哪怕你堆 1000 个核,加速比也不会超过 5。这就是为什么很多人兴冲冲地把循环并行化之后发现只快了 1.5 倍——瓶颈根本不在那个循环里,而在串行的 I/O 或初始化阶段。
Gustafson 定律则从另一个角度看待问题:当问题规模可以随处理器数量一起扩大时,加速比可以近似线性增长。这告诉我们,并行计算更适合「大问题」——问题规模越大,可并行部分占比越高,加速效果越明显。选型时的第一个判断就是:你的问题是固定规模求更快,还是规模可扩展求更大?
2.2 共享内存还是消息传递:OpenMP 与 MPI 的适用边界
并行编程模型主要分两大类。共享内存模型以 OpenMP 为代表,多个线程访问同一块地址空间,通过共享变量通信,适合单机多核场景。消息传递模型以 MPI 为代表,每个进程有独立地址空间,通过显式发送/接收消息通信,适合多机集群。
选型逻辑很直接:如果你的机器就是一台工作站或服务器,所有核心共享内存,优先用 OpenMP,因为它改动小、上手快,通常只需在串行循环前加一行编译指令。如果你要跨多台机器,或者单机内存装不下问题规模,那就必须上 MPI。实际工程中常见的做法是混合模式:节点间用 MPI 通信,节点内用 OpenMP 并行,这样能同时利用分布式内存和共享内存的优势。
2.3 从串行到并行的最小改造路径
假设你有一个计算数组元素平方和的串行程序,想改成 OpenMP 并行版本。最小改造只需要三步:引入头文件、在循环前加编译指令、编译时开启 OpenMP 支持。
#include <stdio.h> #include <omp.h> #define N 100000000 int main() { double sum = 0.0; double *arr = (double *)malloc(N * sizeof(double)); for (int i = 0; i < N; i++) arr[i] = 1.0 / (i + 1); // 关键:reduction 子句让每个线程有私有 sum,最后自动合并 #pragma omp parallel for reduction(+:sum) for (int i = 0; i < N; i++) { sum += arr[i]; } printf("sum = %.10f\n", sum); free(arr); return 0; }编译命令:gcc -fopenmp -O2 -o sum_omp sum_omp.c。这里reduction(+:sum)是核心,它告诉编译器每个线程维护自己的局部 sum,循环结束后再安全地累加到一起。如果不加 reduction,多个线程同时写 sum 会产生数据竞争,结果随机错误。参数上,-O2是必须的,不开优化的话并行开销可能比串行还慢;-fopenmp让编译器识别#pragma omp指令。
2.4 线程数、调度策略与绑核:三个必调参数
OpenMP 默认使用所有可用核心,但默认值往往不是最优。第一个参数是线程数,通过omp_set_num_threads(n)或环境变量OMP_NUM_THREADS=n设置。线程数不是越多越好,超过物理核心数后超线程带来的收益递减,甚至因为缓存争用而变慢。
第二个参数是调度策略。#pragma omp parallel for schedule(static)把迭代平均分给各线程,适合每次迭代计算量均匀的场景;schedule(dynamic, chunk)让线程做完一块再领下一块,适合计算量不均匀的场景,但动态调度有额外开销。chunk 大小一般取总迭代数除以线程数的 1/10 到 1/4。
第三个参数是绑核。在 NUMA 架构上,线程在不同核心间迁移会导致缓存失效。设置OMP_PROC_BIND=true和OMP_PLACES=cores可以把线程固定在核心上,减少迁移开销。这三个参数调完,同一个程序的性能可能相差 30% 以上。
3. 用 MPI 把任务拆到多台机器上
3.1 MPI 程序的骨架:初始化、通信、收尾
MPI 程序有固定的骨架结构:MPI_Init初始化环境,MPI_Comm_rank获取当前进程编号,MPI_Comm_size获取总进程数,中间做计算和通信,最后MPI_Finalize收尾。所有进程运行同一份代码,通过 rank 区分角色。
#include <mpi.h> #include <stdio.h> #include <stdlib.h> int main(int argc, char **argv) { MPI_Init(&argc, &argv); int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); // 当前进程编号,从 0 开始 MPI_Comm_size(MPI_COMM_WORLD, &size); // 总进程数 // 每个进程计算自己负责的那部分 long local_n = 1000000; double local_sum = 0.0; for (long i = 0; i < local_n; i++) { double x = (rank * local_n + i + 0.5) / (size * local_n); local_sum += 4.0 / (1.0 + x * x); } // 用 MPI_Reduce 把所有局部和汇总到 rank 0 double global_sum = 0.0; MPI_Reduce(&local_sum, &global_sum, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD); if (rank == 0) { printf("pi ≈ %.15f\n", global_sum / (size * local_n)); } MPI_Finalize(); return 0; }编译命令:mpicc -O2 -o pi_mpi pi_mpi.c,运行命令:mpirun -np 4 ./pi_mpi。这段代码用数值积分法估算 π 值,每个进程负责一段区间,最后用MPI_Reduce把局部和汇总。MPI_Reduce的六个参数分别是:发送缓冲区、接收缓冲区、数据个数、数据类型、归约操作、目标进程 rank、通信域。只有 rank 0 的接收缓冲区有效,其他进程的接收缓冲区被忽略。
3.2 点对点通信与集合通信怎么选
MPI 通信分两类。点对点通信用MPI_Send和MPI_Recv,一个进程发、一个进程收,适合有明确数据依赖的场景,比如流水线并行中上游算完传给下游。集合通信用MPI_Bcast、MPI_Reduce、MPI_Allgather等,所有进程参与,适合全局汇总或分发。
选型原则:能用集合通信就不要手写点对点循环。集合通信的实现经过深度优化,能根据网络拓扑选择最优的通信树或环形算法,手写的循环发送往往性能更差且容易死锁。比如要把 rank 0 的数据发给所有进程,用MPI_Bcast一行搞定,不要写 for 循环逐个MPI_Send。
3.3 进程数与问题规模的匹配:一个实测对比
进程数不是越多越好。每个 MPI 进程有独立的内存开销和通信开销,进程数增加时,通信占比上升,加速比会逐渐饱和甚至下降。下面是一个矩阵向量乘法的实测数据(矩阵规模 8192×8192,单机 8 核):
| 进程数 | 计算时间(s) | 通信时间(s) | 总时间(s) | 加速比 |
|---|---|---|---|---|
| 1 | 12.4 | 0.0 | 12.4 | 1.00 |
| 2 | 6.3 | 0.4 | 6.7 | 1.85 |
| 4 | 3.2 | 0.9 | 4.1 | 3.02 |
| 8 | 1.7 | 2.1 | 3.8 | 3.26 |
可以看到,从 4 进程到 8 进程,计算时间减半但通信时间翻倍多,总加速比只从 3.02 提升到 3.26。这就是典型的通信瓶颈。实际调优时,如果发现通信时间占比超过 20%,就应该考虑减少进程数、增大每个进程的计算量,或者改用非阻塞通信来重叠计算和通信。
4. 并行程序调优与避坑实战
4.1 数据竞争:最隐蔽的翻车现场
现象:程序串行运行结果正确,并行运行结果每次都不一样,或者偶尔正确偶尔错误。原因:多个线程同时读写同一个变量,且没有同步保护。比如多个线程同时执行sum += arr[i],实际执行的是「读 sum、加 arr[i]、写回 sum」三步,两个线程可能同时读到旧值,导致某次加法丢失。
解决:用 reduction 子句、原子操作或临界区。OpenMP 中#pragma omp atomic保护单条语句,#pragma omp critical保护代码块,但 critical 开销大,能不用就不用。MPI 中数据竞争通常表现为消息匹配错误,用MPI_Recv时务必检查返回的 status 和消息 tag。
4.2 伪共享:缓存行上的隐形战争
现象:两个线程分别操作不同的变量,逻辑上毫无冲突,但并行后性能反而比串行还差。原因:这两个变量恰好落在同一个缓存行(通常 64 字节)上,一个线程修改变量 A 会导致另一个线程的缓存行失效,即使它只关心变量 B。这就是伪共享。
解决:给变量加 padding 对齐到缓存行边界。C 语言中可以用alignas(64)修饰,或者手动填充字节。OpenMP 的 reduction 子句内部已经处理了这个问题,但手写线程私有变量时容易踩坑。一个实用技巧是:如果发现并行版本比串行慢很多,先检查是否有多个线程频繁写相邻内存地址。
4.3 负载不均:木桶效应在并行中的体现
现象:并行程序运行时间远大于「总计算量除以线程数」的理论值,且各线程结束时间差异很大。原因:任务划分不均匀,某些线程分到了计算量大的块,其他线程早早做完在等。比如循环中每次迭代的计算量随索引增大而增大,用 static 调度就会导致后面的线程负担过重。
解决:改用 dynamic 调度,或者手动按计算量而非迭代次数来划分任务。MPI 中可以用主从模式,主进程动态分配任务块给空闲的工作进程。判断负载是否均衡的方法很简单:在每个进程/线程结束时打印时间戳,看最大和最小时间差多少。
4.4 通信死锁:MPI 新手的必修课
现象:程序卡住不动,CPU 占用率为零,等多久都不结束。原因:两个进程互相等待对方先发送,或者MPI_Send的数据量太大触发了同步协议,发送方等接收方取走数据,接收方却在等发送方先发别的消息。
解决:成对使用MPI_Send和MPI_Recv时,确保发送和接收的顺序在所有进程中一致。更稳妥的做法是用MPI_Sendrecv替代分开的 Send/Recv,它内部处理了死锁避免。或者用非阻塞的MPI_Isend和MPI_Irecv,先发起所有通信再统一等待。记住一个原则:如果两个进程都要先发后收,必然死锁。
4.5 加速比不升反降:并行开销从哪来
现象:用 2 个线程比 1 个线程快,用 4 个线程反而更慢。原因:并行开销超过了并行收益。开销来源包括线程创建和销毁、任务调度、同步等待、缓存一致性维护、通信延迟。当每个线程的计算量太小时,这些开销占比就很高。
解决:增大每个线程的计算粒度,减少同步点数量,用线程池避免反复创建销毁。一个经验法则是:如果单个任务块的执行时间小于 1 微秒,并行化基本没有意义。另外检查是否开了编译器优化,-O0下的并行程序可能比-O2下的串行程序还慢。
5. 进阶技巧:从能跑到跑得快的几个关键习惯
第一个习惯是先用性能分析工具定位瓶颈,再决定并行化哪里。Linux 下用perf stat看 CPI 和缓存命中率,用gprof或valgrind --tool=callgrind找热点函数。不要凭直觉猜,我见过太多人花一周并行化了一个只占 5% 时间的函数,加速比自然上不去。
第二个习惯是永远保留一个串行版本作为正确性基准。并行程序的 bug 往往表现为数值微小差异或偶发错误,没有基准你根本不知道结果对不对。每次修改并行策略后,用相同输入跑串行和并行版本,对比输出差异是否在浮点误差允许范围内。
第三个习惯是关注内存带宽而非只关注核心数。很多计算密集型问题在并行后瓶颈从 CPU 转移到了内存带宽上,这时候加核心没用,需要优化数据局部性——比如分块计算让数据留在缓存里,或者用 SIMD 指令提高单位数据的计算量。
第四个习惯是 MPI 程序先在本机用 2 个进程跑通再上集群。本机调试方便,可以用 gdb 附加到单个进程,也可以用mpirun -np 2快速验证逻辑。上集群后遇到问题,先检查各节点 MPI 版本是否一致、网络是否通、共享文件系统是否挂载正确。
最后一个技巧关于混合编程:MPI+OpenMP 的程序,通常设 MPI 进程数等于节点数,OpenMP 线程数等于每节点核心数。但这不是绝对的,如果通信量很大,可以少开 MPI 进程、多开 OpenMP 线程来减少跨节点通信。我一般会做一组小规模测试,固定总核心数,调整 MPI 进程数和 OpenMP 线程数的比例,找到通信和计算的最佳平衡点。这个比例因问题而异,没有万能公式,只能实测。
希望帮到你。
本文还有配套的精品资源,点击获取