简介:这份郑州大学计算机与人工智能学院并行计算实验报告,面向高校并行计算课程学习者及准备复现实验的编程训练者,对应人工智能与课程资源场景。报告获得90分以上成绩,围绕任务分解、负载均衡、通信与同步展开,覆盖共享内存、分布式内存模型,以及矩阵乘法、FFT、归并/快速排序等并行算法设计。资源以zip压缩包分发,包体约37.69MB;文件总数与文件类型明细上游暂未提供,从内容主题看可服务于实验报告阅读、绘制图像对照和代码分析复盘。已有702人学习下载。读者可据此梳理OpenMP、MPI与CUDA/OpenCL实验环境,理解图像并行渲染、像素操作加速、性能瓶颈识别和缓存效率分析,并参考Gprof、Intel VTune等工具的剖析思路,复盘并行程序优化与实验总结,形成从理论概念到实践验证的学习路径。
1. 知识地图:并行计算这门课的三条主线
并行计算实验报告这类课程资源真正好用的部分,从来不是最后那张成绩截图,而是它把一学期散落的知识点压成了可复现的代码和可对照的曲线。这门课的主线其实只有三条:硬件层次与并行模型、编程接口三件套、性能度量与结果解释。报告里的每一张图背后都对应其中一条——线程数翻倍曲线却不涨,多半撞上了存储墙;进程数一加反而更慢,通常死在通信开销上。它适合正在做实验、准备答辩,或者复习时发现自己只会跑 demo、讲不清“为什么快”的人。
2. 模型底盘:从共享内存到消息传递的分层
并行程序的性能上限在写第一行代码之前就已经被硬件定死了大半。这一章把“模型”和“层次”两件事拆开看:模型决定你怎么描述并行,层次决定你能并行到什么程度。很多实验做不出加速效果,问题不在代码写错,而在于一开始就把任务映射到了不合适的那一层。
2.1 Flynn 分类与实际编程模型的错位
Flynn 四分类按指令流和数据流的单复数组合,考试必背,但工程上真正落地的只有两类 MIMD:共享地址空间的线程模型(OpenMP、pthread)和各自独立地址空间的进程模型(MPI)。MISD 基本没有工程实例,写“流水线有时被归入”就够了;SIMD 则在 GPU 上以 warp 内 32 个线程执行同一条指令的形式重新出现,CPU 侧的向量指令也是同一思路。
所以别把 Flynn 当分类学来背,把它当成一个判断工具:拿到一段循环,先问“这层循环能不能映射成向量”“能不能拆成互不通信的独立任务”。能向量化说明数据连续、无分支;能拆任务说明迭代之间没有写依赖。这两问的答案,直接决定了后面该用 OpenMP 的simd制导、还是干脆上 GPU。
| 模型 | 地址空间 | 同步手段 | 典型接口 | 常见失效场景 |
|---|---|---|---|---|
| 共享内存 MIMD | 同一份 | 锁、原子、barrier | OpenMP、pthread | 数据竞争、伪共享 |
| 分布式内存 MIMD | 各自独立 | 消息、集合通信 | MPI | 通信开销吃掉收益 |
| SIMD(CPU 向量) | 同一份 | 无(隐式) | 编译器自动向量化 | 访存不连续、分支 |
| SIMD(GPU warp) | 全局+共享 | __syncthreads | CUDA 类接口 | 访存不合并、分歧 |
2.2 存储层次与算术强度:真正的天花板
教材上“内存墙”三个字很容易被跳过,但它才是实验报告里曲线弯折的根本原因。处理器的浮点峰值增长快于内存带宽增长,导致大量并行程序不是算不动,而是“喂不饱”。用一组数量级算一下就很直观:某处理器 3.0 GHz、8 核、每周期能发 16 次双精度乘加,峰值约为 3.0×10⁹ × 8 × 16 × 2 ≈ 768 GFLOP/s;而双通道 DDR4-3200 的带宽约为 3200×10⁶ × 2 × 8 B ≈ 51.2 GB/s。矩阵乘每算一个输出元素要读两个 double(16 字节),算术强度约 1 flop/byte,带宽能支撑的上限只有 51 GFLOP/s 左右,峰值利用率不到 7%。
这就是分块(blocking)存在的意义:把大矩阵切成能塞进 L2 的小块,让同一份数据在寄存器或缓存里被复用多次,算术强度从 1 提到几十,带宽才不再是瓶颈。实验报告里那些“分块后性能提升数倍”的图,本质画的就是算术强度的变化。
| 层级 | 典型延迟(数量级) | 容量量级 | 对并行的含义 |
|---|---|---|---|
| 寄存器 | 1 周期 | 百字节级 | 循环展开、私有变量 |
| L1 | 约 4 周期 | 几十 KB | 分块尺寸的参考 |
| L2 | 约 12 周期 | 几百 KB~1 MB | 单核分块上限 |
| L3 / LLC | 约 40 周期 | 几 MB~几十 MB | 多核共享,争用起点 |
| DRAM | 约 200 周期 | GB 级 | 带宽瓶颈来源 |
注意:上表是数量级参考,不同微架构差异很大,写报告时如果要写具体数字,应注明来源是你的实测或实验指导书,别把参考值当成实测结果。
2.3 进程、线程与通信开销的量级感
共享内存下创建一个线程的开销在微秒级,一次 barrier 在核数不多时也是微秒级;而一次 MPI 点对点消息的延迟,在常见集群上通常是几微秒到几十微秒。看起来差不多,但要注意单位累积:如果每个并行任务的有效计算只有几微秒,通信开销就会直接把它抹平,这时候“并行”只是在给通信打工。
判断标准可以记住一条经验规则:任务粒度至少要覆盖通信开销一个数量级以上。做实验时最稳妥的做法是固定总规模、只改进程数,观察加速比曲线的拐点,拐点出现的位置就是你这套硬件和这个算法组合下的“最小有效粒度”。这条曲线的形状,比任何一个孤立的加速比数字都更能说明问题。
强扩展(strong scaling)指总问题规模不变、增加资源;弱扩展(weak scaling)指每份资源上的问题规模不变、总规模随资源增长。前者考的是并行度够不够,后者考的是通信和同步能不能随规模摊薄。实验报告里如果只画了一张强扩展曲线,结论往往会被质疑——加上弱扩展那一张,说服力立刻不一样。
3. 三件套:OpenMP、MPI 与 GPU 的边界与手感
并行计算课程的实验通常围绕三种接口展开,它们不是互相替代的关系,而是覆盖不同的地址空间和粒度。把三者的“能表达什么、不能表达什么”分清楚,比记住一堆制导子句更有用。
3.1 OpenMP:编译制导能表达什么、不能表达什么
OpenMP 的骨架就三块:parallel开线程组,工作共享子句分任务,同步构件收口。它擅长的是循环级并行,尤其是迭代次数已知、迭代之间无写依赖的 for 循环。调度策略的选择直接决定负载是否均衡:static适合每轮耗时均匀的循环,dynamic适合耗时差异大的循环但会带来调度开销,guided介于两者之间,块大小从大到小递减。
#include <omp.h> #include <stdio.h> #define N 1024 int main(void) { static double a[N][N], b[N][N], c[N][N]; // 初始化略:这里只关心并行结构 double t0 = omp_get_wtime(); // collapse(2) 把 i、j 两层循环合并成一个任务空间,提高并行粒度 // schedule(static) 适合每行计算量一致的稠密矩阵乘 #pragma omp parallel for collapse(2) schedule(static) for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { double sum = 0.0; for (int k = 0; k < N; k++) { sum += a[i][k] * b[k][j]; // b 按列访问,跨步大 } c[i][j] = sum; } } double t1 = omp_get_wtime(); printf("time = %.6f s\n", t1 - t0); return 0; }这段代码的并行对象是输出元素,每个(i, j)写自己的c[i][j],不存在写冲突,所以并行是安全的。collapse(2)把两层循环合起来划分,是为了在N较大、线程数较大时避免只在外层划分导致的部分线程闲置。但要注意b[k][j]是按列访问,缓存行利用率低,真正做性能实验时应该改成i-k-j的循环顺序或先做转置,这一步对结果的影响往往比调线程数更大。
反过来,有循环携带依赖的代码不能直接并行:
// 前缀和:每一步依赖上一步的结果,直接加 parallel for 会算错 for (int i = 1; i < n; i++) { a[i] = a[i - 1] + b[i]; }编译器不会拦你,运行结果却会随线程数变化而飘。这类循环要么改成两趟扫描(见 4.2),要么老老实实串行。实验报告里如果出现“结果偶尔对偶尔错”,八成就是这类隐式依赖没处理干净。
3.2 MPI:点对点与集合通信的选择
MPI 的核心心智模型是 SPMD:所有进程跑同一份程序,靠rank分支做不同的事。点对点通信(MPI_Send/MPI_Recv)表达的是“我明确知道数据从谁到谁”,集合通信(MPI_Bcast、MPI_Reduce、MPI_Allreduce、MPI_Scan)表达的是“整个通信域一起完成一次规约或分发”。
#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); // 每个进程负责一段连续区间的局部和 double local_sum = 0.0; for (int i = rank; i < 1000000; i += size) { local_sum += 1.0 / ((double)i * i + 1.0); } double global_sum = 0.0; // 树形归约:内部不是简单的线性累加,而是 log(p) 轮配对 MPI_Reduce(&local_sum, &global_sum, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD); if (rank == 0) { printf("sum = %.12f\n", global_sum); } MPI_Finalize(); return 0; }说明几个关键点。MPI_Reduce的最后一个参数是根进程编号,只有根进程的接收缓冲区有意义,其他进程传进去的地址会被忽略,这一点在初学阶段经常被误解成“每个进程都能拿到结果”,要拿结果得用MPI_Allreduce。计数参数是元素个数而不是字节数,类型必须和数据严格匹配,MPI_DOUBLE配double,配错不会报编译错误,只会在运行时给出莫名其妙的数值。
点对点通信最容易踩的坑是阻塞语义。MPI_Send在小消息时可能直接缓冲返回,大消息时会阻塞等待对方接收;如果两个进程都先Send再Recv且互相等待,就会死锁。稳妥写法是把发送换成MPI_Isend配MPI_Wait,或者调整顺序让接收先发。这个坑在单机多进程测试时可能因为缓冲策略而“恰好能跑”,换到多机就翻车,属于典型的血泪经验。
3.3 GPU:线程层次与访存合并
GPU 的编程模型是三层线程索引:grid、block、thread。一个 block 内的线程可以通过共享内存协作,跨 block 只能走全局内存。硬件以 warp(通常是 32 个线程)为单位调度,同一个 warp 内的线程执行同一条指令,所以分支发散会让部分线程空转,访存不连续会让一次访存拆成多次事务。
矩阵乘的共享内存分块是最经典的例子:
#define TILE 16 __global__ void matmul_tiled(const float* A, const float* B, float* C, int n) { __shared__ float As[TILE][TILE]; __shared__ float Bs[TILE][TILE]; int tx = threadIdx.x, ty = threadIdx.y; int row = blockIdx.y * TILE + ty; int col = blockIdx.x * TILE + tx; float acc = 0.0f; for (int t = 0; t < n / TILE; t++) { // 每个线程搬一个元素进共享内存,全局访存被 block 内所有线程复用 As[ty][tx] = A[row * n + t * TILE + tx]; Bs[ty][tx] = B[(t * TILE + ty) * n + col]; __syncthreads(); // 等共享内存写满 for (int k = 0; k < TILE; k++) { acc += As[ty][k] * Bs[k][tx]; } __syncthreads(); // 等所有线程读完再进入下一轮 } C[row * n + col] = acc; }这段核函数里,每个 block 负责输出矩阵的一个TILE × TILE小块,As和Bs是块内共享的临时缓存。收益来自复用:不做分块时,每个输出元素要读2n个全局元素,总访存量是O(n³);分块后每轮只搬2 × TILE²个元素,总量降到O(n³ / TILE)。两次__syncthreads()缺一不可,少第一个会有线程读到未写入的共享内存,少第二个会有线程在下一轮覆盖时读到旧值,两种错误都表现为“结果偶尔不对”。
写 GPU 实验报告时,除了时间还要记录占用率、共享内存用量、每线程寄存器数这几项。只报一个加速比,读者无法判断瓶颈在算力、带宽还是占用率,这份数据就失去了复用价值。
4. 算法与曲线:矩阵乘、归约和加速比的读法
实验报告里最有信息量的部分不是“跑通了”,而是“为什么是这个数”。这一章把三个高频实验对象拆开:矩阵乘看划分方式如何决定通信量,归约看树形结构如何把步数压到对数级,加速比曲线看实测点偏离线性的几种典型形态。
4.1 矩阵乘的三种划分与通信量对比
分布式内存下做矩阵乘,划分方式直接决定每个进程要存多少、每轮要通信多少。按行块划分是把 A 的若干行分给各进程,B 需要全量广播;按列块划分对称;二维分块把进程排成方阵,每个进程只持有n/√p见方的 A 块和 B 块,通信量最优但实现复杂度最高。
| 划分方式 | 单进程内存 | 通信量量级 | 负载均衡 | 实现难度 |
|---|---|---|---|---|
| 行块(1D) | A 的 n/p 行 + 完整 B | O(n²) 广播 + O(n²/p) | 好 | 低 |
| 列块(1D) | 完整 A + B 的 n/p 列 | 同上 | 好 | 低 |
| 二维分块 | 两块 n/√p 见方 | O(n²/√p) | 好 | 高 |
| Cannon 移位 | 同二维分块 | O(n²/√p),无广播 | 好 | 很高 |
从表里能看出来,一维划分的问题不在内存,而在广播:B 是完整副本,进程数越多,广播的绝对数据量不变,摊到每个进程的收益被通信稀释。二维分块把通信量降了√p倍,代价是要处理进程网格映射和块间对齐。实验报告如果只做了一维划分,结论里应当明确写出这个前提,别把“加速比上不去”笼统归因于“并行度不够”。
共享内存下则是另一套逻辑:没有进程间通信,瓶颈变成缓存。分块尺寸要卡在 L2 容量附近,太小复用不足,太大放不下会溢出到 L3 甚至内存。实测做法是固定矩阵规模,扫一遍块大小(比如 16、32、64、128),把时间画成折线,最低点就是这台机器上这块矩阵的合适分块。这张图放进报告,比任何文字解释都直观。
4.2 归约与前缀和:树形结构的复用
朴素归约是线性的:p 个值依次累加,需要 p−1 步。树形归约把每一步的配对数量减半,总步数降到 log₂p。这个结构不只用于求和,最大值、最小值、逻辑与、向量累加都是同一套骨架。
// 树形归约骨架:n 为 2 的幂,a[] 初始为各线程的局部值 // 每轮相邻两个元素配对,步长倍增,轮数 log2(n) for (int step = 1; step < n; step *= 2) { for (int i = 0; i < n; i += 2 * step) { a[i] += a[i + step]; } // 每轮之间必须同步,否则下一轮会读到未完成的值 }这段骨架的执行轮数是⌈log₂n⌉,每轮活跃线程数减半,总工作量O(n),但关键路径只有O(log n)。MPI 的MPI_Reduce内部用的就是这种树形或二分结构,所以你不需要自己写,但必须知道它不是线性累加——这解释了为什么归约的耗时随进程数增长得很慢,也解释了为什么“换成手写循环再Send给根进程”会明显变慢。
前缀和(扫描)是归约的进阶版:不仅要总和,还要每个位置之前的累积值。串行版本一遍循环搞定,并行版本需要两趟——上扫(up-sweep)把局部和聚到根,下扫(down-sweep)把前缀值分发回去,总步数同样是O(log n)。MPI 提供了MPI_Scan直接给到包含自身的累积结果,MPI_Exscan给到不包含自身的版本,两者差一个元素,考试和实验里都容易写反。
4.3 加速比、效率与 Amdahl 的读图方法
加速比定义是S(p) = T₁ / Tp,并行效率是E(p) = S(p) / p。Amdahl 定律针对固定问题规模:若串行部分占比为1−f,则S(p) = 1 / ((1−f) + f/p),p 趋于无穷时上限是1/(1−f)。Gustafson 定律针对固定时间、规模随资源增长:S(p) = (1−f) + f·p,它给出的是“规模扩大后的收益”,两条定律不矛盾,只是前提不同。
import matplotlib.pyplot as plt # 示意数据:替换成你自己实测的线程数与耗时 threads = [1, 2, 4, 8, 16, 32] speedup = [1.00, 1.89, 3.42, 5.76, 8.31, 9.04] ideal = threads fig, ax = plt.subplots(1, 2, figsize=(10, 4)) # 左图:线性坐标看绝对差距 ax[0].plot(threads, ideal, '--', label='linear') ax[0].plot(threads, speedup, 'o-', label='measured') ax[0].set_xlabel('threads') ax[0].set_ylabel('speedup') ax[0].legend() ax[0].grid(True, alpha=0.3) # 右图:双对数坐标看效率衰减的斜率 ax[1].plot(threads, [s / t for s, t in zip(speedup, threads)], 's-') ax[1].set_xlabel('threads') ax[1].set_ylabel('efficiency') ax[1].set_xscale('log', base=2) ax[1].grid(True, alpha=0.3, which='both') plt.tight_layout() plt.savefig('speedup.png', dpi=150)左图用线性坐标,回答“离理想线差多少”;右图用双对数坐标,回答“效率衰减的斜率是否稳定”。实测点常见的三种形态值得单独说:一是超线性(S > p),通常不是算法变强了,而是串行基准包含了大量缓存未命中的冷启动,并行后数据分散到各核私有缓存,属于测量口径问题,报告里要写清楚基准怎么取的;二是平台期,曲线在某个线程数后基本水平,多半是带宽饱和或 Amdahl 上限到了;三是下降,线程数继续加反而变慢,基本是同步开销或资源争用(包括超订)主导。
提示:画图前先统一计时口径。用
clock()测多线程会把各线程 CPU 时间累加,算出来的加速比虚高甚至出现“效率大于 1”,应改用omp_get_wtime()或MPI_Wtime()这类墙钟计时。
5. 易漏点复盘:五个让加速比失真的细节
这一章单独拎出来,是因为它们不涉及算法 correctness,代码能跑、结果也对,但结论是错的。做实验时逐条对一遍,能省掉答辩现场的大部分追问。
易漏点一:串行基准的选取口径不一致。为什么漏:多数人直接拿“不加#pragma的同一份代码”当T₁,但这版代码可能开着-O2且带着初始化、文件读写、结果校验,这些部分在并行版里被计到了并行时间内,两边分母不一致。 怎么补:把基准拆成三段——初始化、核心计算、结果处理——只对核心计算段计时,两版用同一个计时函数、同一份输入、同样的编译选项,并在报告里写明基准是“单线程同代码”还是“理论串行最优”。
易漏点二:伪共享把并行变成互相拖累。为什么漏:多个线程写同一个缓存行里的不同变量,逻辑上没有共享,硬件层面却在反复失效同一条 cache line。代码看起来完全正确,性能却比单线程还差,很容易被误判成“这台机器并行不行”。 怎么补:让每个线程的私有累加变量按缓存行对齐填充(常见做法是填充到 64 字节),或者改用块内归约再汇总,避免多个线程写相邻地址。用性能采样工具看 L1 缺失率和总线事务数,伪共享的特征是缓存失效次数异常高而指令数正常。
易漏点三:计时函数选错,把 CPU 时间当墙钟。为什么漏:clock()返回的是进程消耗的处理器时间,多线程下会累加,线程越多“耗时”越大,加速比自然失真;而time()精度只有秒级,短实验根本测不出来。 怎么补:OpenMP 用omp_get_wtime(),MPI 用MPI_Wtime(),CUDA 用事件计时,单机通用场景用单调时钟。所有计时点在报告里统一标注函数名,重复测量取多次中位数并写出波动范围。
易漏点四:线程数或进程数超过物理核数。为什么漏:逻辑核数看起来是物理核的两倍,设成“最大”似乎天经地义,但超线程共享执行单元,计算密集型负载下多出来的线程只会互相抢资源,还叠加了上下文切换。 怎么补:先查物理核数而不是逻辑核数,扫描时把线程数设成 1、2、4、8……直到物理核数,再往后各加一档观察是否下降。下降点就是这台机器的实际上限,把它写进结论比写“线程越多越快”有价值得多。
易漏点五:集合通信与 barrier 的隐式同步被忽略。为什么漏:MPI_Barrier和集合通信都带同步语义,放在循环里会强制所有进程对齐,慢进程拖住全体;而MPI_Reduce本身已经同步,再套一层 barrier 是纯浪费。 怎么补:把 barrier 数量当作一个可调参数,做一组“有 barrier / 无 barrier”的对照实验,用时间差说明同步成本。同时在代码注释里标明每个集合通信的调用频率,循环体内的集合通信要重点审视能否合并或改成点对点。
6. 查漏补缺:把实验报告转成复习清单的方法
复习阶段最怕的是“代码能跑但讲不清”。一个可操作的做法是把实验报告拆成三层清单:概念层(能不能用自己的话解释)、参数层(每个参数改了会怎样)、故障层(出错时先看哪里)。三层都过一遍,才算真的掌握。
| 模块 | 必会内容 | 自检方式 |
|---|---|---|
| 模型与层次 | 共享内存/分布式内存区别、SPMD、强/弱扩展 | 给一个场景,判断该用线程还是进程 |
| OpenMP | 工作共享、调度策略、数据作用域、竞争条件 | 手写一个带reduction的循环并说明为何安全 |
| MPI | 阻塞/非阻塞、集合通信语义、死锁条件 | 画出四进程Reduce的树形通信路径 |
| GPU | 线程层次、共享内存、访存合并、同步点 | 说明去掉某一次__syncthreads会发生什么 |
| 性能 | 加速比、效率、Amdahl/Gustafson、算术强度 | 给一组实测点,判断瓶颈在带宽还是同步 |
一个具体技巧:做一张“参数矩阵表”,行是线程数/进程数,列是问题规模,单元格填实测时间,然后把它画成热力图。热力图的好处是把“某个组合特别慢”这种局部异常直接暴露出来,比一维折线更容易发现超订、分块不合适、负载不均这几类问题。再配一张算术强度与实测性能的散点图,就能把“为什么这个规模快”从感觉变成证据。
课程资源里那些绘制好的图像和代码分析,价值不在于图好看,而在于每一张图都能回答一个“为什么”。我自己的教训是第一次做实验时只记了最终加速比,答辩被问到“拐点为什么出现在 8 线程”时完全答不上来,回头重跑才发现是物理核数到了、后面全是超订。从那以后我每次跑并行实验都强制走一遍“基准口径核对 → 计时函数确认 → barrier 计数 → 参数矩阵全扫”,四步走完再动笔写结论。希望帮到你。
本文还有配套的精品资源,点击获取