CPU、内存、Cache 与 NUMA:把热点解释到硬件行为 / CPU, Memory, Cache and NUMA
当时间线告诉你线程确实在 CPU 上运行,下一步才是 CPU Profiling。
CPU Profiling 的第一层答案是“哪段代码占时间”。但真正的工程问题通常还要再问一句:它为什么占时间?
同样一个热点函数,可能慢在算法复杂度、分支预测、指令依赖、缓存未命中、内存带宽、锁原子、分配器、NUMA 远端访问,或者只是调用次数太多。
1. Sampling:先找 CPU 时间
采样 Profiler 会定期打断程序,记录当前指令位置和调用栈。
样本聚合后,可以看到 CPU 时间主要落在哪些函数和调用路径。
运行中的程序
|
+-- sample 1: main -> parse -> read_token
+-- sample 2: main -> parse -> read_token
+-- sample 3: main -> build -> hash_lookup
+-- sample 4: main -> build -> allocate_node
|
v
热点聚合常用工具:
- Windows:Visual Studio Profiler、WPA CPU Usage、VTune;
- Linux:perf、VTune、gprof-ng;
- macOS:Instruments;
- AMD 平台:AMD uProf;
- 通用展示:FlameGraph、Speedscope。
最小 Linux 流程:
perf record -g -- ./app
perf reportVTune 流程:
vtune -collect hotspots -- ./app
vtune -report hotspots -r r000hs2. Inclusive Time 和 Self Time
Inclusive time 包含一个函数及其所有子调用。
Self time 只包含函数自身。
load_project()
|
+-- read_file()
|
+-- parse()
| |
| +-- read_token()
|
+-- build_index()如果 load_project() inclusive time 很高,它可能只是上层入口。
如果 read_token() self time 很高,说明函数自身很值得看。
如果一个函数单次很便宜但调用十亿次,它也可能成为总热点。
看 Profile 时至少同时看:
- 绝对时间;
- 百分比;
- 调用路径;
- 调用次数;
- 每次调用成本;
- 输入规模关系;
- 线程分布。
百分比很容易误导。优化后某个热点占比下降,可能是它变快了,也可能是别的阶段变慢了。
3. 符号和调用栈质量
没有符号的 Profile 没法有效分析。
你需要保留:
- 可执行文件;
- 动态库;
- 调试符号;
- Build ID 或 PDB;
- 源码提交;
- 编译选项;
- JIT 符号映射,如果有。
调用栈展开也要可靠。
缺失栈帧常见原因包括:
- 省略 frame pointer;
- unwind 信息不完整;
- 手写汇编;
- 尾调用;
- JIT;
- 栈损坏;
- 工具权限不足;
- 内核态符号缺失。
如果 Profile 里大量 [unknown],先修符号和栈,再谈优化。
4. 硬件计数器:从函数到机制
硬件计数器是 CPU 记录事件的寄存器。
它可以计数:
- cycles:周期;
- instructions:退休指令;
- branches:分支;
- branch-misses:分支预测失败;
- cache-references:缓存访问;
- cache-misses:缓存未命中;
- LLC-load-misses:最后一级缓存读未命中;
- dTLB-load-misses:数据 TLB 未命中;
- stalled-cycles:流水线停顿。
Linux 起步:
perf stat ./app
perf stat -e cycles,instructions,branches,branch-misses,cache-misses ./app常用派生指标:
IPC = instructions / cycles
branch miss rate = branch-misses / branches
cache miss rate = cache-misses / cache-references但这些指标不能机械解释。
低 IPC 可能来自内存等待,也可能来自分支错误、指令依赖或前端供给不足。
高 Cache miss rate 不一定严重。如果访问次数很少,总时间可能仍然不大。
低 miss rate 也不代表访存没问题。如果总访问量巨大,内存带宽仍可能被打满。
5. Top-Down 微架构分析
现代 CPU 的 Top-Down 方法会把流水线槽位粗分为几类。
Pipeline Slots
|
+-- Retiring
+-- Bad Speculation
+-- Frontend Bound
+-- Backend Bound
|
+-- Core Bound
+-- Memory BoundRetiring 高,说明执行的指令大多有效退休。
Bad Speculation 高,常见于分支预测失败或错误路径执行。
Frontend Bound 高,说明取指、解码、指令缓存或微码供给可能不足。
Backend Bound 高,说明执行端口、数据依赖、缓存或内存供给可能限制吞吐。
VTune Microarchitecture Exploration 很适合沿着这条路往下看。
但 Top-Down 是模型,不是最终答案。它告诉你往哪个方向查,而不是自动给出修复方案。
6. Cache:不是越小越快,而是越近越快
CPU 访问寄存器最快,访问内存慢得多。
Cache 是放在 CPU 和内存之间的高速缓存。
CPU Core
|
v
L1 Cache
|
v
L2 Cache
|
v
L3 Cache
|
v
DRAM程序的访问顺序决定 Cache 是否帮得上忙。
连续数组通常友好,因为硬件预取器能提前加载后续数据。
链表和随机哈希访问通常不友好,因为下一个地址要等当前节点读出来才知道。
常见优化方向:
- 用连续结构替代指针追逐;
- 把热字段和冷字段分开;
- 减少对象大小;
- 批量处理;
- 改善循环顺序;
- 避免不必要的临时对象;
- 减少跨线程写同一 Cache Line。
7. False Sharing
False Sharing 是多线程程序里很隐蔽的性能问题。
两个线程修改不同变量,但这些变量落在同一条 Cache Line 上,CPU 仍然需要在核心之间反复转移这条 Cache Line。
Cache Line 64B
+----------------+----------------+
| counter_thread0 | counter_thread1 |
+----------------+----------------+
^ ^
| |
Core 0 Core 1变量逻辑上不共享,硬件上却共享了缓存行。
表现可能是线程数增加后吞吐不升反降,硬件计数器出现大量缓存一致性流量。
解决方向是填充、对齐、分片计数、每线程局部累加,最后合并。
8. 分配器和内存生命周期
频繁小对象分配会带来:
- 分配器锁或线程缓存开销;
- 元数据开销;
- Cache locality 变差;
- 内存碎片;
- Page Fault;
- 峰值内存升高;
- 释放延迟;
- NUMA 放置不可控。
分析工具:
- heaptrack:Linux C/C++ 分配热点;
- Massif:Valgrind 的堆快照工具;
- jemalloc profiling:适合使用 jemalloc 的服务;
- WPA Memory:Windows 场景;
- ASan/LSan:正确性和泄漏,不是低扰动性能工具。
优化方向包括对象池、批量分配、Arena、预分配、复用缓冲区、减少临时对象和改变数据结构所有权。
但分配优化要谨慎。对象池可能提升速度,也可能增加峰值内存、复杂化生命周期,并隐藏 use-after-free。
9. NUMA:内存也有远近
NUMA 是 Non-Uniform Memory Access。
它表示 CPU 访问不同内存区域的成本不一样。多路服务器上,每个 CPU Socket 附近有自己的内存通道。线程访问本地内存较快,访问另一个 Socket 的内存较慢。
Socket 0 ---- local ---- Memory 0
|
remote
|
Socket 1 ---- local ---- Memory 1NUMA 问题常见于:
- 大内存数据结构;
- 多线程初始化和多线程计算分离;
- 线程迁移;
- 全局队列;
- 跨 Socket 共享状态;
- 内存池集中分配;
- 并行求解器和 HPC 程序。
Linux 可用:
numactl --hardware
numastat -p <pid>
perf stat -e node-load-misses ./appVTune Memory Access 也能帮助观察远端访问和带宽问题。
基本原则是 First Touch:谁第一次写入页面,页面通常就放到谁所在 NUMA 节点附近。
因此并行初始化和线程亲和性很重要。
10. 内存带宽瓶颈
如果热点每个元素只做少量计算,却要读写大量内存,它可能受内存带宽限制。
例如:
for (std::size_t i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}每次迭代读取 a[i]、b[i],写入 c[i],计算只有一次加法。
这种代码通常不是缺少更聪明的指令,而是数据搬运成本主导。
优化方向可能是:
- 减少数组遍历次数;
- 融合循环;
- 减少写回;
- 使用更紧凑的数据类型;
- 分块提高 Cache 复用;
- 利用 SIMD;
- 多线程时避免带宽过早打满。
11. 把证据写成假设
不要写:
Cache miss 很高,所以重写数据结构。要写:
现象:
build_index 阶段占端到端 42%。
证据:
perf record 显示 hash_lookup self time 高;
perf stat 显示 LLC-load-misses 随输入规模线性增长;
源码中每个元素会访问 3 个分散节点。
假设:
指针追逐导致最后一级缓存未命中,限制 build_index。
实验:
将节点改为连续数组索引,保持算法语义不变。
预期:
LLC miss 和 build_index 时间下降,峰值内存不显著上升。这样写,别人可以验证,也可以推翻。
12. 本篇总结
CPU Profiling 先告诉你时间落在哪条调用路径。
硬件计数器和微架构分析再解释这条路径为什么慢。
内存、Cache、分配器和 NUMA 是 C++ 项目非常常见的性能边界,尤其在大数据结构、工程导入、求解器、渲染、并行计算和低延迟服务中。
真正的重点不是背指标,而是把指标和代码机制连起来。