1. 内存层次结构:从寄存器到硬盘的存储金字塔
做性能优化这么多年,我越来越觉得——搞懂内存层次结构,是写出高性能代码的第一道门槛。你想想看,CPU 每秒能执行几十亿条指令,可内存却慢得像蜗牛。这中间的差距,全靠一层层的缓存来填。
1.1 存储金字塔长什么样?
我习惯把存储系统想象成一个金字塔。越往上,速度越快,容量越小,价格越贵。越往下,速度越慢,容量越大,价格越便宜。
核心观点:程序运行的速度,往往不取决于 CPU 有多快,而取决于数据在金字塔里「爬」了多远。
下面这张图,是我自己画的一个简化版存储金字塔。你看一眼就能明白。
1.2 每一层的真实速度差
光说数字你可能没感觉。我举个例子你就明白了。
假设 CPU 执行一条指令需要 1 秒。那么:
- 从寄存器拿数据:就像你从口袋里掏钥匙——0.3 秒
- 从 L1 缓存拿数据:就像起身走到办公桌前——1 秒
- 从 L2 缓存拿数据:就像走到隔壁办公室——3-5 秒
- 从 L3 缓存拿数据:就像走到楼下大厅——10-20 秒
- 从主存拿数据:就像开车去 5 公里外的超市——100 秒
- 从硬盘拿数据:就像坐飞机去另一个城市——几百万秒
我的经验:很多性能问题,说白了就是数据在「坐飞机」的次数太多了。如果能让它一直待在「口袋里」,性能自然就上去了。
1.3 为什么缓存如此重要?
你可能会问:「既然寄存器最快,那把整个程序都塞进寄存器不就行了?」
嗯,我也想啊。但现实是,寄存器总共才几十个字节。L1 缓存也就几十 KB。你一个程序光代码段就好几 MB,数据更是动不动几百 MB。
所以,缓存存在的意义,就是利用「局部性原理」来欺骗硬件——让它以为你只需要一小块数据,实际上你确实只需要这一小块。
时间局部性
如果你现在访问了一个变量,那么过一会儿你很可能还会访问它。比如循环里的计数器:
for (int i = 0; i < 1000000; i++) {
sum += arr[i]; // sum 被反复访问
}
这里的 sum 变量,每次循环都要读写。如果它不在缓存里,每次都要去主存取,那性能直接崩盘。
空间局部性
如果你访问了地址 A,那么地址 A+1、A+2 很可能也会被访问。比如遍历数组:
for (int i = 0; i < N; i++) {
printf("%d\n", arr[i]); // 连续访问 arr[0], arr[1], arr[2]...
}
CPU 在加载 arr[0] 时,会把相邻的一整块数据(缓存行,通常 64 字节)都拉进缓存。这样你访问 arr[1] 到 arr[15] 时,就直接命中缓存了。
我曾经踩过的坑:有一次优化一个矩阵乘法,我按列遍历内层循环。结果性能惨不忍睹。后来一查,原来是按列访问破坏了空间局部性——每次都要从主存重新加载一整行数据。改成按行遍历后,速度提升了 10 倍。
1.4 缓存行与伪共享
说到缓存,就不得不提「缓存行」。这是 CPU 和缓存之间数据传输的最小单位,通常是 64 字节。
这就引出了一个经典问题——伪共享。
想象一下,两个线程各自操作不同的变量,但这两个变量恰好在同一个缓存行里。线程 A 修改了变量 X,导致整个缓存行失效。线程 B 想访问变量 Y,发现缓存行失效了,只好重新加载。实际上 X 和 Y 毫无关系,但缓存行把它们「绑」在了一起。
// 伪共享示例
struct Data {
int x; // 线程 A 只操作 x
int y; // 线程 B 只操作 y
}; // x 和 y 很可能在同一个缓存行里
我的建议:在多线程编程中,如果多个线程频繁修改不同的数据,尽量让它们之间间隔至少 64 字节。或者用 __attribute__((aligned(64))) 强制对齐。
1.5 缓存未命中的代价
缓存未命中分三种,代价天差地别:
| 类型 | 说明 | 代价(CPU 周期) |
|---|---|---|
| L1 命中 | 数据就在 L1 缓存里 | 3-4 周期 |
| L2 命中 | 数据在 L2 缓存里 | 10-12 周期 |
| L3 命中 | 数据在 L3 缓存里 | 30-40 周期 |
| 主存命中 | 数据在主存里 | 100-200 周期 |
| 缺页中断 | 数据在硬盘上 | 几百万周期 |
你看,一次主存访问的代价,够 CPU 执行上百条指令了。所以,优化内存访问模式,本质上就是减少「主存命中」的次数。
1.6 怎么判断缓存是否在正常工作?
我个人习惯用 perf 工具来查看缓存命中率:
# 查看 L1 缓存命中率
perf stat -e L1-dcache-loads,L1-dcache-load-misses ./my_program
# 查看 LLC (Last Level Cache) 命中率
perf stat -e LLC-loads,LLC-load-misses ./my_program
如果 L1 未命中率超过 10%,或者 LLC 未命中率超过 5%,那你的内存访问模式大概率有问题。
记住一句话:写代码时,脑子里要时刻想着「数据现在在哪一层?」。养成这个习惯,你的代码性能不会差。
好了,这一章我们聊了存储金字塔的结构,也讲了缓存为什么重要。下一章,我会带你看看「缓存行对齐」和「数据布局优化」的具体技巧——这些可都是实战中能直接拿来用的硬功夫。
想要更多资料,联系可可