尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++多核并行计算实验:std::thread与OpenMP矩阵乘法实战

C++多核并行计算实验:std::thread与OpenMP矩阵乘法实战 简介一套面向高校计算机专业本科生及并行计算初学者的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从 C11 开始进入标准库它把操作系统线程 API 包装成可移植对象线程的创建、join、detach都由标准库统一管理底下实际走的是 pthread 或 Win32 线程。用它能完整看到线程从出生到回收的全过程代价是你必须亲自处理线程数量、任务切分和临界区。OpenMP 是另一套思路。它在代码里加编译指导指令编译器看到#pragma omp parallel for后会自动生成线程创建、任务分配和同步代码你在源码层不需要手写std::thread。gcc、Clang、MSVC 都内置支持属于编译器能力而不是 C 标准的一部分。对于课程实验来说OpenMP 的优势是“三行改完”但你失去对线程细节的把控出问题时不直观。第三类方案是 C17 标准库里的并行算法例如std::sort(std::execution::par, begin, end)。它让标准库算法自动并行抽象层级最高一台老旧教学机的编译器未必支持完整实现而且很难在实验报告里展示任务划分逻辑。课程实验通常希望你既能看到并行结构又能对加速效果做出解释所以主流选型集中在std::thread和 OpenMP 这两种上。方案线程管理方式编译期依赖实验考核适配度std::thread显式创建、join、手动切分任务C11 以上标准库高能讲清线程生命周期与临界区OpenMP编译指导指令代管线程-fopenmp / /openmp高方便对比静态/动态调度C17 并行算法标准库自动调度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::vectorstd::vectordouble; Matrix make_matrix(int n) { Matrix m(n, std::vectordouble(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::vectordouble(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::vectorstd::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 -stdc17 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 运行观测加速比的典型记录与波动来源编译完跑一次以 N1024、8 核 16 线程机器为例一次典型记录可能是这样的线程数耗时(ms)加速比142001.00221401.96410903.8586206.77165008.40数字会因 CPU 型号和内存频率明显浮动。注意 8 线程时加速比已经明显低于 816 线程时增长近乎停滞这既可能是超线程收益有限也可能是内存带宽在 N1024 时已经吃紧。实验里必须重复跑至少五次取中位数因为现代 CPU 有动态频率和散热控制单次时间会受到隔壁进程干扰。记录时把机器型号、编译器版本、优化选项一起写下报告里这些信息缺一不可。4. 数据竞争、死锁与缓存伪共享把并行计算实验里的坑过一遍4.1 数据竞争多线程同时写共享变量的随机错误并行实验里最常见的失败现象是“结果有时候对有时候不对”。比如多个线程各自算完局部累加值最后让它们同时执行total partial;这行语句在机器层面是读、加、写三步两个线程交错执行时会把对方刚写回的值覆盖掉。要定位这类问题最直接的方式是让编译器帮你找// race.cpp —— 故意制造数据竞争的示例 #include atomic #include thread #include vector int main() { long long total 0; std::vectorstd::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 -fsanitizethread race.cpp -o race编译运行后 ThreadSanitizer 会直接报告 data race 的读写栈位置。修复方式是给total换成std::atomiclong long并把修改改为total.fetch_add(1, std::memory_order_relaxed)或者更彻底让每个线程维护独立累加变量最后按索引归约。矩阵乘法实验里确保每个线程写不同的c[i]行区间就是不引入竞争的设计。4.2 死锁锁顺序不一致导致的永久等待课程实验如果只是并行矩阵乘法通常不会出现死锁但实验文档若扩展到“并行归并排序”或“生产者消费者模型”就会碰到多把锁。经典死锁场景是线程 A 持有锁 1 等待锁 2线程 B 持有锁 2 等待锁 1。解决方法是让所有线程按相同顺序加锁或者用一把锁一次拿到全部资源。从 C17 起可以直接写std::scoped_lock lk(mtx_a, mtx_b);它内部保证不会因为同时锁定多个互斥量而产生死锁。调试时看 gdb 线程堆栈卡在__lll_lock_wait附近的基本可以判断是锁等待。4.3 缓存伪共享线程没竞争却被拖慢的隐蔽问题如果你的实验里每个线程维护一个局部结果数组最好留意一下伪共享。假设结构体定义成下面这样struct Slot { long long value; }; std::vectorSlot 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)是 C11 引入的对齐控制语法64 字节对应常见的 x86-64 缓存行大小。课程实验里如果发现线程数翻倍性能反而下降先查是不是踩了伪共享再去怀疑锁和调度。4.4 用 perf 和时间测量排除非并行干扰拿到了“加速比不理想”的结果先别急着改代码跑一遍perf stat ./mt注意task-clock、context-switches、cache-misses三个输出。上下文切换高说明线程数超出物理核心缓存缺失高说明数据排布要调整。时间测量本身用std::chrono::steady_clock不要用system_clock后者可能被用户调整系统时间干扰。矩阵规模要保证单线程运行时间在几百毫秒以上否则线程创建开销会淹没计算时间测出来的加速比没有意义。5. 验收把过关加速比曲线、核绑定与实验报告呈现5.1 画加速比曲线时至少测三组规模验收时老师常问“你的并行效率在什么条件下变差”。只测一组规模很难回答。正确做法是固定机器取 N256、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.8516 线程加速 8.40 时效率掉到 0.53这两个数放一起一句话就能说明效率下降来自超线程争抢资源。图用折线图避免柱状图折线更能直观显示斜率变化和拐点。编译命令、环境参数、原始运行日志放到附录正文只放典型值。本文还有配套的精品资源点击获取
返回列表