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 组织整个执行流程。
8. 线程池生命周期
线程池在应用生命周期内复用 Worker。
定义启动、队列、并发、取消、Drain、Shutdown 和异常。
关闭时停止新任务,处理现有任务,再 Join Worker。
9. 队列
中央队列简单,但可能成为锁与 Cache 热点。
每线程队列加 Work Stealing 适合不规则任务。
有界队列提供背压,防止内存无限增长。
10. 静态调度
规则、成本相近的循环适合静态分块。
优势是调度少、局部性好、行为可预测。
输入不均时会产生长尾。
11. 动态调度
动态队列适合任务成本不规则。
Chunk 太小增加调度,太大降低均衡。
Guided 调度从大块逐步缩小。
12. Reduction
每线程局部累加,最后树形合并。
local0 local1 local2 local3
\ / \ /
sum01 sum23
\ /
total浮点顺序变化需要容差。
13. 锁粒度
粗锁容易正确但并行度低。 细锁增加顺序、死锁和维护复杂度。
优先删除共享、分片、缩小临界区和批处理。
14. 条件变量
条件变量等待状态谓词。
cv.wait(lock, [&] { return stop || !queue.empty(); });谓词处理虚假唤醒。
15. 原子顺序
seq_cst 最易推理。
Acquire/Release 适合发布与获取。
Relaxed 不发布周围普通数据。
只有证明热点且协议正确后才弱化。
16. False Sharing 优化
- 每线程数据;
- 分片;
- 对齐;
- 批量合并;
- 减少写频率。
使用计数器和扩展曲线验证。
17. NUMA 策略
并行初始化实现 First Touch。
每节点队列和内存池减少远端访问。
最终在粗粒度合并。
18. SIMD 与线程
线程处理外层块,SIMD 处理块内连续元素。
布局、对齐、别名和尾部决定向量化。
19. 异常
Worker 异常不能悄悄终止线程。
任务系统捕获并写入 Future 或任务结果。
20. 取消
使用 Stop Token 或共享取消状态。
任务在安全点检查并清理资源。
21. 调试
- Thread Sanitizer;
- 死锁检测;
- 日志关联;
- 压力循环;
- 故障注入;
- 单线程对照;
- 确定性调度测试。
22. Profile
观察:
- 每线程 CPU 时间;
- Running/Ready/Wait;
- 锁等待;
- 队列;
- 任务数;
- False Sharing;
- NUMA;
- 串行阶段。
WPA 和 VTune Threading 可用于分析。
23. 扩展实验
测量线程数 1、2、4、8…
记录时间、加速比、效率、锁、带宽、远端内存与正确性。
24. 完成标准
应能:
- 设计线程池;
- 选择调度;
- 正确归约;
- 管理锁与条件变量;
- 使用原子顺序;
- 识别 False Sharing;
- 处理 NUMA;
- 组合 SIMD;
- 取消与关闭;
- Profile 扩展性。
25. 实验记录
CPU 并行实验还应保存:
- CPU 与拓扑;
- 编译器;
- OpenMP/运行库;
- 线程数;
- 调度和 Chunk;
- 亲和性;
- NUMA 放置;
- SIMD 报告;
- 输入;
- 预热;
- 原始样本;
- 锁等待;
- 内存带宽;
- 正确性。 这些信息使扩展曲线能够在其他机器复核。 缺失环境时不应外推线程结论。
章节检查清单
- 循环迭代是否相互独立?
- 任务粒度是否显著大于调度成本?
- 是否存在共享写入和伪共享?
- 编译器是否成功向量化?
- 线程和数据是否跨越 NUMA 节点?
- 数学库是否已经在内部并行?
下一篇:[[04-memory-hierarchy]]