简介:一套面向高校计算机专业本科生及并行计算初学者的C++并行计算课程实验资料包,围绕多核平台下的矩阵分块并行计算展开。资源提供串行与并行两个版本,通过合理切分任务块并利用斜向依赖关系调度计算顺序,便于读者直观对比并行优化的性能收益。压缩包为zip格式,共34个文件,约352KB,主要包含C++源程序、makefile构建脚本、可执行文件、实验题目与指导书PDF、实验报告docx以及串并行时间对比数据,另有README说明、LICENSE许可文件与png结构图等辅助内容,目录按题目1和题目2的串行、并行版本整齐划分。已有131人浏览学习。通过该包可获取完整的实验代码框架、构建与运行方法、性能对比思路,同时readme与实验报告还能辅助理解算法设计细节,很适合课程设计、并行计算入门或复试上机准备。
1. 基于 C++ 的多核并行计算课程实验,到底要解决什么问题
一门并行计算课程在期末出现“基于 C++ 实现多核平台下的并行计算实验”这个题目时,绝大多数人的第一反应是:把一个大循环拆给几个线程,跑起来测测快了多少。真正验收时,老师重点看的往往不是那行运行时间,而是你能否解释清楚“为什么用了 8 个线程却只有 3 倍加速”,以及换一组输入规模后这个数字为什么又变了。这类实验的核心是让你亲手触碰从串行到并行的三个台阶:任务怎么划分、线程怎么同步、性能怎么度量。本文按照课程实验最常见的做法,用std::thread和 OpenMP 各写一版矩阵乘法,把从编写、编译、运行到写报告的全过程摊开讲。适合正在做并行计算实验、或者想系统补一遍 C++ 多线程基础的人。
2. 并行计算课程实验的 C++ 方案选型:std::thread 与 OpenMP 的取舍
2.1 选择 std::thread 还是 OpenMP,先看两者如何管理线程
std::thread从 C++11 开始进入标准库,它把操作系统线程 API 包装成可移植对象,线程的创建、join、detach都由标准库统一管理,底下实际走的是 pthread 或 Win32 线程。用它能完整看到线程从出生到回收的全过程,代价是你必须亲自处理线程数量、任务切分和临界区。
OpenMP 是另一套思路。它在代码里加编译指导指令,编译器看到#pragma omp parallel for后会自动生成线程创建、任务分配和同步代码,你在源码层不需要手写std::thread。gcc、Clang、MSVC 都内置支持,属于编译器能力而不是 C++ 标准的一部分。对于课程实验来说,OpenMP 的优势是“三行改完”,但你失去对线程细节的把控,出问题时不直观。
第三类方案是 C++17 标准库里的并行算法,例如std::sort(std::execution::par, begin, end)。它让标准库算法自动并行,抽象层级最高,一台老旧教学机的编译器未必支持完整实现,而且很难在实验报告里展示任务划分逻辑。课程实验通常希望你既能看到并行结构,又能对加速效果做出解释,所以主流选型集中在std::thread和 OpenMP 这两种上。
| 方案 | 线程管理方式 | 编译期依赖 | 实验考核适配度 |
|---|---|---|---|
| std::thread | 显式创建、join、手动切分任务 | C++11 以上标准库 | 高,能讲清线程生命周期与临界区 |
| OpenMP | 编译指导指令代管线程 | -fopenmp / /openmp | 高,方便对比静态/动态调度 |
| C++17 并行算法 | 标准库自动调度 | g++ 12+ / 需安装 TBB | 中,抽象层级偏高 |
课程实验里我一般这样处理:主版本用std::thread,保证报告里有线程创建、参数传递、结果回收这些可见步骤;再用 OpenMP 写一个对照版,用来对比两种模型下代码量和性能差异。这样两个并行模型都覆盖到了,答辩时不会无话可说。
2.2 实验里最常用的 OpenMP 子句与调度参数
OpenMP 的并行循环最常出现的位置是#pragma omp parallel for。单独用parallel表示开启一个并行区域,for告诉编译器把紧随其后的 for 循环分拆到 team 中的线程上。常见写法:
#pragma omp parallel for num_threads(4) schedule(static, 32) reduction(+:total) for (int i = 0; i < n; ++i) { total += data[i]; }num_threads(4)是显式指定线程数;schedule(static, 32)让编译器把循环按照每 32 个迭代一个块,预先分给 4 个线程;reduction(+:total)为每个线程保留一个total私有副本,最后再做求和归约。这个归约子句极其重要,没有它,多个线程同时写total会造成数据竞争,结果随机出错。
矩阵乘法这种负载均匀的计算,选static调度就够了,块大小为行数除以线程数再向上取整。如果实验换成分治搜索这类负载不均的场景,dynamic调度会让线程频繁取下一个任务块,但能避免部分线程提前空闲。collapse(2)可以把两层循环合并成一个循环空间重新分配,适合内层循环很短的情况,但它不能用在带break、return的循环上,否则编译器要报错。参数怎么设要在实验里做对照,不要只贴一个#pragma omp parallel for就完事,调度策略本身就是一个可以写出两段讨论的观测点。
2.3 逻辑核心、物理核心与超线程对线程数选择的影响
选线程数之前,先搞清楚机器上有几个真正的执行单元。std::thread::hardware_concurrency()返回的是“硬件线程数”,在启用超线程的 CPU 上它等于物理核心数的两倍。Linux 下直接看lscpu输出,Windows 上可以在任务管理器性能页看逻辑处理器数量。
超线程是把一个物理核心分成两个逻辑处理器,共享同一组执行资源。对纯计算型负载,8 核 16 线程的机器开 16 个线程往往不会带来 2 倍提升,反而会因为上下文切换和缓存争用导致性能回退。课程实验选线程数时,先跑 1、2、4、8、16 五组,画出加速比曲线,观察上升变缓的拐点,不要默认线程数越大越好。这个拐点通常落在物理核数附近,也可能因为内存带宽限制提前出现。
3. 多核平台下矩阵乘法实验的可运行实现与编译命令
3.1 先写串行基线并验证正确性
任何并行实验都要先有一个串行版本作为正确性参照。矩阵乘法是并行计算课上的常客,因为它的计算密集、数据访问规律明显,而且并行化不需要依赖复杂的同步原语。先写一个 N x N 方阵乘法:
// serial.cpp —— 串行基线版本 #include <chrono> #include <iostream> #include <vector> using Matrix = std::vector<std::vector<double>>; Matrix make_matrix(int n) { Matrix m(n, std::vector<double>(n)); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) m[i][j] = i + j; // 构造简单但结果非平凡 return m; } Matrix multiply_serial(const Matrix& a, const Matrix& b) { int n = a.size(); Matrix c(n, std::vector<double>(n, 0.0)); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) for (int p = 0; p < n; ++p) c[i][j] += a[i][p] * b[p][j]; return c; }make_matrix里用i + j初始化矩阵,目的是让结果矩阵的每个元素都有确定的非平凡值,便于对比串行和并行输出。真正对比时不要随机初始化,随机数会引入不可复现性。multiply_serial是最简单的 i-j-p 顺序,内层 p 循环访问a[i][p]时按行连续,访问b[p][j]时跳列访问,缓存命中率一般,但这正是实验要的“朴素基线”。
验证正确性时,对同样输入分别跑串行和并行版本,逐元素比较容差,因为浮点加法不满足结合律,不同累加顺序导致末尾几位的误差是正常的。判定标准设为abs(a - b) < 1e-9比较稳妥。
3.2 用 std::thread 把行区间拆给多个线程
有了串行版本,并行化就变成“把行区间分给若干线程,各自算完再汇合”。这里按行划分而不是按元素划分,是避免多个线程写同一个c[i][j]的最简单方式:
// mt.cpp —— std::thread 多线程版本核心函数 #include <thread> void multiply_range(const Matrix& a, const Matrix& b, Matrix& c, int row_begin, int row_end) { int n = a.size(); for (int i = row_begin; i < row_end; ++i) { for (int j = 0; j < n; ++j) { double sum = 0.0; for (int p = 0; p < n; ++p) sum += a[i][p] * b[p][j]; c[i][j] = sum; } } } void multiply_mt(const Matrix& a, const Matrix& b, Matrix& c, int num_threads) { int n = a.size(); std::vector<std::thread> pool; pool.reserve(num_threads); int base = n / num_threads; for (int t = 0; t < num_threads; ++t) { int begin = t * base; int end = (t == num_threads - 1) ? n : begin + base; pool.emplace_back(multiply_range, std::cref(a), std::cref(b), std::ref(c), begin, end); } for (auto& th : pool) th.join(); }base = n / num_threads是每个线程平均分到的行数,最后一个线程的end强制设为n,解决 n 不能被线程数整除时剩下的余数行。std::cref和std::ref用来包装实参,因为std::thread构造函数按值存储参数,矩阵拷贝代价太高,必须用引用包装且用 const 引用保护输入矩阵。c的行区间理论上不会重叠,线程间没有写冲突,所以不需要加锁。
编译命令是:
g++ -O2 -std=c++17 mt.cpp -o mt -pthread-pthread不能省,它让编译器链接 pthread 库并定义相关宏;-O2是必须开的基础优化,不开优化时串行程序本身就慢得没参考价值。join是汇合点,主线程执行到此处必须等待所有子线程结束,否则 main 返回时子线程还在运行,程序会直接崩溃。
3.3 用 OpenMP 三行改写出对照版本
OpenMP 版本只需要在串行循环上方加一行指令:
// omp.cpp —— OpenMP 版本核心循环 #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 p = 0; p < n; ++p) sum += a[i][p] * b[p][j]; c[i][j] = sum; } }collapse(2)让编译器把 i 和 j 两层循环合并成一个连续迭代空间再做静态分块,这样小规模矩阵下负载更均匀。把sum声明在 j 循环内层,保证它是每个任务的私有变量,OpenMP 不会自动私有化循环体内变量,遇到这种情况容易出隐蔽错误。c[i][j] = sum每次写不同的元素,天然满足无竞争条件。
编译命令:
g++ -O2 -fopenmp omp.cpp -o omp-fopenmp让编译器生成 OpenMP 运行时调用,并把默认线程数设为 CPU 逻辑核心数。这个默认值在实验里要显式覆盖,我们可以用omp_set_num_threads(4)或在指令里加num_threads(4),否则换机器跑结果对不上。OpenMP 版本和std::thread版本输出应该逐元素一致,这是验证正确性的硬指标。
3.4 运行观测:加速比的典型记录与波动来源
编译完跑一次,以 N=1024、8 核 16 线程机器为例,一次典型记录可能是这样的:
| 线程数 | 耗时(ms) | 加速比 |
|---|---|---|
| 1 | 4200 | 1.00 |
| 2 | 2140 | 1.96 |
| 4 | 1090 | 3.85 |
| 8 | 620 | 6.77 |
| 16 | 500 | 8.40 |
数字会因 CPU 型号和内存频率明显浮动。注意 8 线程时加速比已经明显低于 8,16 线程时增长近乎停滞,这既可能是超线程收益有限,也可能是内存带宽在 N=1024 时已经吃紧。实验里必须重复跑至少五次取中位数,因为现代 CPU 有动态频率和散热控制,单次时间会受到隔壁进程干扰。记录时把机器型号、编译器版本、优化选项一起写下,报告里这些信息缺一不可。
4. 数据竞争、死锁与缓存伪共享:把并行计算实验里的坑过一遍
4.1 数据竞争:多线程同时写共享变量的随机错误
并行实验里最常见的失败现象是“结果有时候对,有时候不对”。比如多个线程各自算完局部累加值,最后让它们同时执行total += partial;,这行语句在机器层面是读、加、写三步,两个线程交错执行时会把对方刚写回的值覆盖掉。要定位这类问题,最直接的方式是让编译器帮你找:
// race.cpp —— 故意制造数据竞争的示例 #include <atomic> #include <thread> #include <vector> int main() { long long total = 0; std::vector<std::thread> pool; for (int t = 0; t < 8; ++t) pool.emplace_back([&total] { for (int i = 0; i < 100000; ++i) total += 1; }); for (auto& th : pool) th.join(); }用g++ -O1 -g -fsanitize=thread race.cpp -o race编译,运行后 ThreadSanitizer 会直接报告 data race 的读写栈位置。修复方式是给total换成std::atomic<long long>,并把修改改为total.fetch_add(1, std::memory_order_relaxed);或者更彻底,让每个线程维护独立累加变量,最后按索引归约。矩阵乘法实验里确保每个线程写不同的c[i]行区间,就是不引入竞争的设计。
4.2 死锁:锁顺序不一致导致的永久等待
课程实验如果只是并行矩阵乘法,通常不会出现死锁,但实验文档若扩展到“并行归并排序”或“生产者消费者模型”,就会碰到多把锁。经典死锁场景是线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1。解决方法是让所有线程按相同顺序加锁,或者用一把锁一次拿到全部资源。从 C++17 起可以直接写std::scoped_lock lk(mtx_a, mtx_b);,它内部保证不会因为同时锁定多个互斥量而产生死锁。调试时看 gdb 线程堆栈,卡在__lll_lock_wait附近的基本可以判断是锁等待。
4.3 缓存伪共享:线程没竞争却被拖慢的隐蔽问题
如果你的实验里每个线程维护一个局部结果数组,最好留意一下伪共享。假设结构体定义成下面这样:
struct Slot { long long value; }; std::vector<Slot> slots(num_threads);8 个Slot连续排布,很可能两个相邻value落在同一个 64 字节缓存行里。线程 0 写slots[0],线程 1 写slots[1],它们各自修改缓存行的不同字节,却会因为缓存一致性协议反复刷新整个缓存行,性能比不加并行还差。解法是让每个变量独占缓存行:
struct Slot { alignas(64) long long value; // 64 字节对齐,独占 cache line };alignas(64)是 C++11 引入的对齐控制语法,64 字节对应常见的 x86-64 缓存行大小。课程实验里如果发现线程数翻倍性能反而下降,先查是不是踩了伪共享,再去怀疑锁和调度。
4.4 用 perf 和时间测量排除非并行干扰
拿到了“加速比不理想”的结果,先别急着改代码,跑一遍perf stat ./mt,注意task-clock、context-switches、cache-misses三个输出。上下文切换高说明线程数超出物理核心,缓存缺失高说明数据排布要调整。时间测量本身用std::chrono::steady_clock,不要用system_clock,后者可能被用户调整系统时间干扰。矩阵规模要保证单线程运行时间在几百毫秒以上,否则线程创建开销会淹没计算时间,测出来的加速比没有意义。
5. 验收把过关:加速比曲线、核绑定与实验报告呈现
5.1 画加速比曲线时,至少测三组规模
验收时老师常问“你的并行效率在什么条件下变差”。只测一组规模很难回答。正确做法是固定机器,取 N=256、512、1024 三组,每组线程数从 1 到 16 递增,得到三张数据表。画出加速比随线程数变化的三条曲线,观察小规模矩阵的曲线在大线程数时掉头向下,这表示线程创建开销超过并行收益。把数据和图放一起,报告的讨论部分就有素材了。
5.2 用 taskset 固定核运行,排除调度器干扰
同一台机器上重复跑,结果波动大时,可以用 Linux 的taskset把进程绑定到指定核上,减少操作系统的线程迁移。命令示例:
taskset -c 0-7 ./mt --size 1024 --threads 8-c 0-7表示允许进程在物理核 0 到 7 上运行,配合nproc查看可用核数。固定核之后得到的时间序列明显更稳定。Windows 上等价操作是SetProcessAffinityMask,但命令行层面没有 taskset 这么顺手,建议实验统一在 WSL 或 Linux 环境跑,报告里注明环境即可。
5.3 报告里用一张表三句话讲清性能结论
报告不需要长篇大论,一张表配合三句分析就够了。表头固定为“规模、线程数、耗时、加速比、并行效率”。并行效率是加速比除以线程数,8 线程加速 6.77 时效率是 0.85,16 线程加速 8.40 时效率掉到 0.53,这两个数放一起,一句话就能说明效率下降来自超线程争抢资源。图用折线图,避免柱状图,折线更能直观显示斜率变化和拐点。编译命令、环境参数、原始运行日志放到附录,正文只放典型值。
本文还有配套的精品资源,点击获取