简介这份天津大学并行计算课程资料面向计算机相关专业学生与并行编程入门者系统梳理了从理论到实践的核心知识体系适合需要掌握多核与分布式计算技能、准备课程考试或开展科研项目的人群。压缩包为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 等于 PE 等于 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 (rank1) * chunk; i)里chunk没考虑最后一个进程可能多出的余数。解决用valgrind或 AddressSanitizer 检查内存错误。编译时加-fsanitizeaddress -g运行后能定位到具体行。分配数组时多留一个余数处理分支确保所有数据都被覆盖且不越界。5.5 现象OpenMP 并行区域里结果对但速度没提升原因伪共享或线程绑定问题。伪共享是多个线程修改同一缓存行里的不同变量导致缓存频繁失效。比如两个线程分别写arr[0]和arr[1]它们在同一缓存行硬件会反复同步。解决给每个线程的变量加 padding让它们落在不同缓存行。或者用#pragma omp parallel for时确保每个线程写连续的大块数据。线程绑定可以用OMP_PROC_BINDtrue和OMP_PLACEScores让线程固定到物理核减少迁移开销。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加打印确认每个进程都到达了等待点。从那以后我每次写非阻塞通信都强制走一遍小规模一致性检查避免那种“跑一次对、跑十次错一次”的随机翻车。希望帮到你。本文还有配套的精品资源点击获取