
简介这份郑州大学计算机与人工智能学院并行计算实验报告面向高校并行计算课程学习者及准备复现实验的编程训练者对应人工智能与课程资源场景。报告获得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同一份锁、原子、barrierOpenMP、pthread数据竞争、伪共享分布式内存 MIMD各自独立消息、集合通信MPI通信开销吃掉收益SIMDCPU 向量同一份无隐式编译器自动向量化访存不连续、分支SIMDGPU warp全局共享__syncthreadsCUDA 类接口访存不合并、分歧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。矩阵乘每算一个输出元素要读两个 double16 字节算术强度约 1 flop/byte带宽能支撑的上限只有 51 GFLOP/s 左右峰值利用率不到 7%。这就是分块blocking存在的意义把大矩阵切成能塞进 L2 的小块让同一份数据在寄存器或缓存里被复用多次算术强度从 1 提到几十带宽才不再是瓶颈。实验报告里那些“分块后性能提升数倍”的图本质画的就是算术强度的变化。层级典型延迟数量级容量量级对并行的含义寄存器1 周期百字节级循环展开、私有变量L1约 4 周期几十 KB分块尺寸的参考L2约 12 周期几百 KB1 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 块通信量最优但实现复杂度最高。划分方式单进程内存通信量量级负载均衡实现难度行块1DA 的 n/p 行 完整 BO(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, --, labellinear) ax[0].plot(threads, speedup, o-, labelmeasured) ax[0].set_xlabel(threads) ax[0].set_ylabel(speedup) ax[0].legend() ax[0].grid(True, alpha0.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, base2) ax[1].grid(True, alpha0.3, whichboth) plt.tight_layout() plt.savefig(speedup.png, dpi150)左图用线性坐标回答“离理想线差多少”右图用双对数坐标回答“效率衰减的斜率是否稳定”。实测点常见的三种形态值得单独说一是超线性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 计数 → 参数矩阵全扫”四步走完再动笔写结论。希望帮到你。本文还有配套的精品资源点击获取