CPU 并行:多线程、SIMD、Cache 一致性与 NUMA / CPU Parallelism with Threads, SIMD, Cache Coherence, and NUMA
📅 创建时间:2026-07-20 🏷️ 标签:#CPU #多线程 #SIMD #NUMA #OpenMP 📚 前置知识:[[02-processor-architecture]] 📚 相关知识:[[/02-systems-and-performance/02-computer-architecture-and-hardware/07-openmp-simd]] [[/01-cpp/06-concurrency/04-concurrency]]
1. CPU 并行的三个层次
线程级并行:多个核心执行不同线程
数据级并行:一条 SIMD 指令处理多个元素
任务级并行:不同任务组成流水线或任务图例如矩阵运算可以把行分给多个线程,每个线程内部再使用 AVX 指令一次计算多个浮点数。
2. 线程并行
线程创建不是免费的
创建和销毁线程涉及系统调用、栈空间和调度,因此工程中通常使用线程池。
任务提交 → 工作队列 → 固定数量工作线程 → 执行结果线程数量并非越多越好:
- 计算密集型任务通常接近物理核心数
- I/O 密集型任务可以更多
- 使用 SMT 时需要实际测量
- 第三方数学库可能已经创建线程,避免嵌套过度并行
OpenMP 示例
#pragma omp parallel for
for (int i = 0; i < n; ++i) {
output[i] = compute(input[i]);
}OpenMP 适合逐步并行化规则循环,但仍需检查数据竞争和任务粒度。
3. SIMD 向量化
标量加法一次处理一个元素:
a0+b0 → c0256 位 AVX 指令可以一次处理 8 个 FP32:
[a0..a7] + [b0..b7] → [c0..c7]编译器自动向量化偏爱:
- 连续内存访问
- 简单、固定次数的循环
- 迭代之间没有依赖
- 容易证明指针不重叠
- 分支较少
不利示例
for (int i = 1; i < n; ++i) {
a[i] = a[i - 1] * 0.5f; // 当前迭代依赖前一次结果
}这是循环携带依赖,不能直接并行执行各次迭代。
4. 数据竞争与同步
counter++; // 读取、加一、写回,并非不可分割操作多个线程同时修改会导致结果丢失。常用保护方式:
- Mutex:保护复杂临界区
- Atomic:保护简单原子状态
- Reduction:每个线程局部计算,最后合并
- 消息传递:避免共享可变状态
并行归约通常优于每次循环都锁住全局变量。
5. 伪共享
即使线程修改不同变量,只要变量位于同一个 Cache Line,也可能互相使缓存失效。
struct Counters {
long a; // 线程 A 修改
long b; // 线程 B 修改,但可能与 a 在同一 Cache Line
};常见处理方法:
- 对高频写入的线程局部变量进行 Cache Line 对齐
- 每线程维护局部统计值
- 降低共享写入频率
伪共享不会造成结果错误,但会造成严重性能下降。
6. NUMA:内存也有远近
多路服务器中,每颗 CPU 插槽通常连接自己的本地内存。
CPU 0 ─ 本地内存 0
│
互联总线
│
CPU 1 ─ 本地内存 1CPU 0 访问内存 1 的延迟更高、带宽可能更低,这就是非统一内存访问 NUMA。
NUMA 优化原则
- First Touch:由实际使用数据的线程首次初始化数据
- 线程绑定:避免线程在不同 NUMA 节点之间迁移
- 数据分区:让线程主要访问本地内存
- 测量远程访问比例,而不是凭感觉绑定
7. CPU 并行适用场景
CPU 更适合:
- 分支复杂、任务差异大的计算
- 数据规模较小、要求低延迟的计算
- 图遍历、搜索、编译、数据库执行
- 操作系统和 I/O 协调
- GPU kernel 前后的数据准备与控制
GPU 更适合并不意味着 CPU 不重要。多数异构应用都依赖 CPU 组织整个执行流程。
历史 CPU 并行补充
本页保留合并前的 CPU 并行说明。
当前版本增加线程池、调度、取消、异常和实验记录。
线程池
线程池复用 Worker,减少创建成本。
需要定义队列、并发、取消和关闭。
中央队列
实现简单,可能形成锁和 Cache 热点。
Work Stealing
每线程本地队列改善不规则负载。
窃取增加调度与局部性成本。
有界队列
有界队列提供背压。
无界任务会消耗内存并提高尾延迟。
静态调度
适合规则且成本相近的循环。
调度少,局部性更可预测。
动态调度
适合成本不均任务。
Chunk 需要在调度与均衡之间选择。
Reduction
使用线程局部结果和树形合并。
减少锁与原子争用。
锁
粗锁容易正确但并行度低。
细锁增加死锁和维护复杂度。
条件变量
等待状态谓词,并处理虚假唤醒。
通知与状态修改构成完整协议。
原子
原子只保护对应对象。
Relaxed 不发布周围普通数据。
False Sharing
不同字段位于同一 Cache Line 也会争用。
线程局部、分片和对齐是候选方案。
NUMA
首次触碰、亲和性和数据分片影响远近。
每节点队列和内存池可减少远端访问。
SIMD
线程与 SIMD 可以分层组合。
布局、别名和循环依赖影响向量化。
异常
Worker 捕获异常并传回任务结果。
不能悄悄终止线程或丢失任务状态。
取消
协作式取消在安全点检查。
取消后释放锁、Buffer、文件和第三方资源。
关闭
停止新任务,取消或 Drain,最后 Join。
不允许在 Worker 仍运行时销毁依赖对象。
调试
- Thread Sanitizer;
- 死锁检测;
- 压力测试;
- 日志关联;
- 单线程对照;
- 故障注入。
Profile
WPA CPU Precise 查看 Running/Ready/Wait。
VTune Threading 查看并行度、同步和负载不均。
扩展曲线
测量线程数变化。
记录时间、加速比、效率、锁、带宽和 NUMA。
历史版本边界
OpenMP Runtime、调度器和处理器拓扑随环境变化。
旧线程数建议不能直接套用。
当前课程映射
当前文章新增:
- 线程池生命周期;
- 队列;
- 静态/动态调度;
- Reduction;
- 原子顺序;
- 取消与异常;
- Profile;
- 实验记录。
阅读完成标准
应能:
- 设计线程池;
- 选择调度;
- 管理同步;
- 识别 False Sharing;
- 处理 NUMA;
- 组合 SIMD;
- 取消与关闭;
- 验证扩展。
归档复现记录
- 来源提交;
- CPU;
- 核心与 SMT;
- NUMA;
- 编译器;
- OpenMP/线程库;
- 优化选项;
- 输入;
- 线程数;
- 调度;
- Chunk;
- 亲和性;
- First Touch;
- SIMD 报告;
- 锁等待;
- 带宽;
- 原始样本;
- 正确性;
- 当前差异。
复现实验应从单线程基线开始。 线程数结论只适用于记录的硬件、输入和运行库。 性能提升不能以数据竞争、泄漏或取消失效为代价。 所有结论都应保留可复现命令。
章节检查清单
- 循环迭代是否相互独立?
- 任务粒度是否显著大于调度成本?
- 是否存在共享写入和伪共享?
- 编译器是否成功向量化?
- 线程和数据是否跨越 NUMA 节点?
- 数学库是否已经在内部并行?
下一篇:[[04-memory-hierarchy]]