简介:这份天津大学并行计算课程资料面向计算机相关专业学生与并行编程入门者,系统梳理了从理论到实践的核心知识体系,适合需要掌握多核与分布式计算技能、准备课程考试或开展科研项目的人群。压缩包为zip格式,整体约44.18MB,内容涵盖并行计算基础概念、OpenMP与MPI编程模型、负载均衡、同步互斥机制、并行算法设计及性能分析等模块,并配有实验报告与考试复习总结,帮助读者将分治法、归约扫描等策略落地到并行排序、矩阵运算等实际项目中。目前已有1586人学习下载,说明该资料在同类课程资源中具有一定参考价值。通过实验报告与代码实践,读者可深入理解Amdahl定律与Gustafson定律的评估方法,掌握共享内存与分布式内存环境下的编程技巧,并了解并行计算在气候模拟、生物信息学、机器学习等领域的应用场景,为后续研究或工程开发打下扎实基础。
1. 并行计算到底在算什么:从一次跑崩的矩阵乘法说起
很多人第一次接触并行计算,是因为一段串行代码跑得太慢。我见过一个典型的场景:某实验室用 Python 做矩阵乘法,单线程跑 4096×4096 的浮点矩阵,等了将近两分钟才出结果。后来把同样的计算拆到多核上,时间直接压到十几秒。这不是玄学,是并行计算最朴素的收益——把一个大任务切成若干小块,让多个计算单元同时干活。
并行计算的核心问题从来不是“能不能并行”,而是“怎么拆、怎么同步、怎么衡量收益”。拆得不好,通信开销比计算本身还大;同步没做对,结果随机出错;衡量方式不对,加速比看起来很美但实际不可复现。这套课程资源围绕的就是这些真问题:从并行架构的基本模型讲起,落到 MPI、OpenMP 这类常用工具的具体用法,再到性能分析和调优。适合已经会写串行程序、但一碰到多线程或多进程就翻车的开发者,也适合想系统补齐并行思维的学生和工程师。
2. 并行拆解的三种基本盘:数据并行、任务并行与流水线
2.1 数据并行:把大数组切成块,每块独立算
数据并行是最容易上手的模式。典型例子是向量加法或矩阵逐元素运算:把长度为 N 的数组分成 P 份,每个进程或线程处理一份,彼此之间不需要通信,算完直接汇总。
用 OpenMP 写一个数据并行的向量加法,核心代码就几行:
#include <omp.h> #include <stdio.h> #define N 100000000 int main() { double *a = (double*)malloc(N * sizeof(double)); double *b = (double*)malloc(N * sizeof(double)); double *c = (double*)malloc(N * sizeof(double)); // 初始化略 #pragma omp parallel for for (int i = 0; i < N; i++) { c[i] = a[i] + b[i]; // 每个 i 独立,无数据依赖 } return 0; }#pragma omp parallel for告诉编译器把这个 for 循环的迭代空间自动分给多个线程。默认调度是静态的,每个线程拿差不多数量的连续迭代块。如果各次迭代计算量差异大,可以改成schedule(dynamic, chunk_size)让线程动态领任务。参数chunk_size控制每次领取的迭代数,太小会增加调度开销,太大又会导致负载不均,一般从 16 或 32 开始试。
数据并行的关键是确认循环迭代之间没有依赖。如果写成c[i] = c[i-1] + a[i],那就不能直接并行,因为后一次迭代依赖前一次的结果。这种依赖需要换算法或做前缀和专门处理。
2.2 任务并行:不同功能模块各跑各的
任务并行是把程序里不同的功能拆开,让它们同时执行。比如一个图像处理流程里,滤波和直方图统计互不依赖,就可以各开一个线程。OpenMP 的sections指令适合这种场景:
#pragma omp parallel sections { #pragma omp section { filter_image(img); } #pragma omp section { compute_histogram(img); } }两个 section 会由不同线程并行执行。任务并行的难点在于任务粒度的划分:任务太小,创建和调度开销占比高;任务太大,又起不到并行效果。经验法则是单个任务执行时间至少在毫秒级,否则不值得并行。
2.3 流水线并行:把计算拆成阶段,数据像流水一样过
流水线并行适合有先后依赖但可以重叠执行的场景。比如“读取数据 → 预处理 → 计算 → 写回”这条链,如果串行做,每一时刻只有一个阶段在工作。流水线让不同数据块处于不同阶段,理想情况下每个阶段都满负荷。
用 MPI 实现流水线时,通常把不同阶段分配给不同进程,进程之间用MPI_Send和MPI_Recv传递中间结果。这里最容易踩的坑是缓冲区管理:如果发送方不等接收方就连续发,可能阻塞或覆盖。常见做法是采用双缓冲或非阻塞通信MPI_Isend/MPI_Irecv,让计算和通信重叠。
注意:流水线的加速比受限于最慢的那个阶段。如果某个阶段耗时是其他的三倍,整体速度最多只能提升到该阶段串行时间的倒数。优化时要先找瓶颈阶段。
3. MPI 上手实操:从进程启动到点对点通信
3.1 编译与运行:mpicc 和 mpirun 的基本用法
MPI 程序用mpicc编译,用mpirun或mpiexec启动。一个最小的 MPI 程序长这样:
#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 rank %d of %d\n", rank, size); MPI_Finalize(); return 0; }编译命令:mpicc -o hello hello.c。运行命令:mpirun -np 4 ./hello。-np 4指定启动 4 个进程。如果本机核数不够,MPI 会分时调度,但性能会下降。实际跑的时候建议-np不超过物理核心数,除非做了绑核。
MPI_Comm_rank返回的 rank 从 0 开始,通常 rank 0 负责输入输出和结果汇总。MPI_Comm_size返回总进程数,用来计算每个进程负责的数据范围。
3.2 点对点通信:Send 和 Recv 的参数怎么填
点对点通信是 MPI 最基础的操作。一个进程发,另一个进程收:
// rank 0 发送,rank 1 接收 if (rank == 0) { int data = 42; MPI_Send(&data, 1, MPI_INT, 1, 0, MPI_COMM_WORLD); } else if (rank == 1) { int recv_data; MPI_Recv(&recv_data, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("Received %d\n", recv_data); }MPI_Send的参数依次是:数据指针、元素个数、数据类型、目标 rank、消息标签、通信域。MPI_Recv类似,但最后一个参数是状态对象,不关心时填MPI_STATUS_IGNORE。消息标签用来区分同一对进程之间的不同消息,避免收错。
这里有个血泪经验:MPI_Send可能是阻塞的,也可能是缓冲的,取决于数据大小和实现。如果发送的数据量很大,而接收方还没调用MPI_Recv,发送方会一直等。解决办法是调整调用顺序,让接收方先 post recv,或者改用非阻塞的MPI_Isend。
3.3 集合通信:Bcast、Reduce、Allgather 的适用场景
集合通信让一组进程协同完成数据分发或汇总。常用的三个:
MPI_Bcast:一个进程广播数据给所有其他进程。比如 rank 0 读入参数后广播给所有人。MPI_Reduce:把所有进程的数据按指定操作(求和、求最大值等)汇总到一个进程。MPI_Allgather:每个进程贡献一份数据,最后所有进程都拿到完整的汇总结果。
以MPI_Reduce为例:
double local_sum = compute_local_sum(); double global_sum; MPI_Reduce(&local_sum, &global_sum, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD); // 只有 rank 0 的 global_sum 是有效结果参数里的MPI_SUM是归约操作,还可以用MPI_MAX、MPI_MIN、MPI_PROD。倒数第二个参数指定结果放在哪个 rank。如果所有进程都需要结果,用MPI_Allreduce。
集合通信的底层实现会针对不同数据量和进程数选择算法,比如二叉树、递归倍增等。使用者一般不用管,但要知道集合通信通常比手写点对点循环更高效,能优先用集合通信就别自己拼。
4. 性能分析与调优:加速比、效率与常见瓶颈
4.1 加速比和效率:怎么算、怎么解读
加速比S = T_serial / T_parallel,效率E = S / P,其中 P 是进程数或线程数。理想情况下 S 等于 P,E 等于 1。实际中通信、同步、负载不均都会让效率下降。
举个例子:串行跑 100 秒,用 4 个进程跑 30 秒,加速比是 3.33,效率是 0.83。效率低于 0.7 通常说明并行化有问题,需要查通信开销或负载均衡。
Amdahl 定律给出了加速比的上限:如果程序中不可并行的部分占比例 f,那么无论用多少进程,加速比最多是1 / f。比如 10% 的代码必须串行,最大加速比就是 10。这个定律提醒我们,优化时要先找串行瓶颈,而不是盲目增加进程数。
4.2 用 perf 和 MPI 自带工具定位热点
Linux 下可以用perf做性能采样:
perf record -g ./my_parallel_program perf report-g保留调用栈,perf report里能看到每个函数的耗时占比。如果发现大量时间花在MPI_Wait或pthread_join上,说明同步开销大,需要调整任务划分。
MPI 实现通常自带 profiling 接口,比如 MPICH 的mpiP或 OpenMPI 的ompi_info配合mpirun --tag-output。更简单的办法是在代码里手动加计时:
double t_start = MPI_Wtime(); // 计算部分 double t_end = MPI_Wtime(); if (rank == 0) printf("Elapsed: %f\n", t_end - t_start);MPI_Wtime返回墙钟时间,精度足够做粗粒度分析。注意要在所有进程同步后再计时,否则测到的是某个进程的局部时间。
4.3 负载均衡:静态调度和动态调度的选择
静态调度在编译或启动时就把任务分好,每个进程拿固定份额。优点是零调度开销,缺点是无法应对计算量不均。动态调度让进程在运行时领任务,适合每次迭代耗时差异大的场景。
OpenMP 里用schedule(dynamic, chunk)或schedule(guided)。MPI 里没有内置的动态调度,需要自己实现任务队列:rank 0 维护任务列表,其他进程算完一个就向 rank 0 要下一个。这种模式叫主从模式,适合任务数远大于进程数的场景。
提示:动态调度的通信开销可能抵消负载均衡带来的收益。如果每个任务执行时间很短(微秒级),动态调度反而更慢。先用静态调度测一遍,确认负载不均再换。
5. 避坑与排查:并行程序最常见的五类翻车
5.1 现象:结果每次运行都不一样
原因:存在数据竞争。多个线程或进程同时读写同一块内存,且没有同步保护。比如两个线程同时执行sum += local,实际执行顺序不确定,结果就随机。
解决:用原子操作、临界区或归约代替共享写。OpenMP 里加#pragma omp atomic或#pragma omp critical。MPI 里每个进程写自己的局部变量,最后用MPI_Reduce汇总。不要依赖“看起来正确”的时序。
5.2 现象:程序卡死,不报错也不退出
原因:死锁。常见于 MPI 点对点通信中,两个进程都在等对方先发。比如 rank 0 先MPI_Recv等 rank 1 的数据,rank 1 也先MPI_Recv等 rank 0 的数据,双方都不发,就卡住了。
解决:调整发送和接收的顺序,让至少一方先发。或者用非阻塞通信MPI_Isend/MPI_Irecv配合MPI_Waitall。调试时可以用mpirun -np 2小规模复现,再逐步加进程。
5.3 现象:进程数增加后反而更慢
原因:通信开销超过计算收益。当每个进程负责的数据量很小时,MPI 消息传递的固定开销(延迟)占主导。比如用 64 个进程算一个 128×128 的矩阵,每个进程只算 256 个元素,通信时间比计算时间还长。
解决:减少进程数,或者增大问题规模。也可以用共享内存的 OpenMP 替代 MPI,避免跨节点通信。判断依据是计算通信比:如果单次通信时间超过单次计算时间的 10%,就要警惕。
5.4 现象:编译通过但运行时报段错误
原因:数组越界或指针错误。并行程序里常见的是每个进程分配了N/P大小的数组,但循环边界算错,访问了不属于自己的区域。比如for (i = rank * chunk; i < (rank+1) * chunk; i++)里chunk没考虑最后一个进程可能多出的余数。
解决:用valgrind或 AddressSanitizer 检查内存错误。编译时加-fsanitize=address -g,运行后能定位到具体行。分配数组时多留一个余数处理分支,确保所有数据都被覆盖且不越界。
5.5 现象:OpenMP 并行区域里结果对但速度没提升
原因:伪共享或线程绑定问题。伪共享是多个线程修改同一缓存行里的不同变量,导致缓存频繁失效。比如两个线程分别写arr[0]和arr[1],它们在同一缓存行,硬件会反复同步。
解决:给每个线程的变量加 padding,让它们落在不同缓存行。或者用#pragma omp parallel for时确保每个线程写连续的大块数据。线程绑定可以用OMP_PROC_BIND=true和OMP_PLACES=cores让线程固定到物理核,减少迁移开销。
6. 进阶技巧:用非阻塞通信重叠计算与传输
非阻塞通信是提升 MPI 程序性能的关键手段。它的思路是:发起通信后不等它完成,先去做计算,等计算做完再检查通信是否结束。这样通信时间和计算时间可以重叠,总耗时接近两者中的较大值,而不是相加。
一个典型的用法是在迭代算法里,每个进程先MPI_Irecv接收邻居数据,再MPI_Isend发送自己的边界数据,然后计算内部区域,最后MPI_Waitall确保通信完成后再用边界数据更新。
MPI_Request reqs[4]; MPI_Status stats[4]; // 先发起接收 MPI_Irecv(recv_left, N, MPI_DOUBLE, left_rank, 0, MPI_COMM_WORLD, &reqs[0]); MPI_Irecv(recv_right, N, MPI_DOUBLE, right_rank, 1, MPI_COMM_WORLD, &reqs[1]); // 再发起发送 MPI_Isend(send_left, N, MPI_DOUBLE, left_rank, 2, MPI_COMM_WORLD, &reqs[2]); MPI_Isend(send_right, N, MPI_DOUBLE, right_rank, 3, MPI_COMM_WORLD, &reqs[3]); // 计算内部区域,不依赖边界数据 compute_interior(); // 等待通信完成 MPI_Waitall(4, reqs, stats); // 用接收到的边界数据更新 update_boundary(recv_left, recv_right);这里的关键是compute_interior()不依赖边界数据,所以可以和通信并行。如果计算必须等边界数据,那就没法重叠,只能老老实实等。
参数上要注意MPI_Request数组的大小要匹配发起的非阻塞操作数量。MPI_Waitall会阻塞直到所有请求完成。如果只想等其中一部分,用MPI_Waitany或MPI_Waitsome。
另一个进阶点是派生数据类型。比如发送一个结构体数组时,不用手动打包成连续缓冲区,而是用MPI_Type_create_struct定义一个和结构体布局一致的数据类型,直接发送。这样代码更清晰,也减少内存拷贝。定义完记得MPI_Type_commit,用完MPI_Type_free。
验证非阻塞通信是否正确,我一般会做两件事:一是用 2 个进程和 4 个进程分别跑,结果必须一致;二是把MPI_Waitall临时换成MPI_Barrier加打印,确认每个进程都到达了等待点。从那以后我每次写非阻塞通信,都强制走一遍小规模一致性检查,避免那种“跑一次对、跑十次错一次”的随机翻车。希望帮到你。
本文还有配套的精品资源,点击获取