4、缓存行与预取:缓存行大小(64字节)、硬件预取机制、软件预取指令(prefetch)
聊到内存带宽,有个东西你绕不开——缓存行。
说白了,CPU 和内存之间不是按字节传数据的。它一次最少搬 64 字节。这 64 字节,就是一条缓存行(Cache Line)。
我刚开始做性能优化时,总觉得这玩意儿就是个硬件细节,跟我写代码没关系。直到有一次,我在一个数据链遍历的场景里,死活跑不满内存带宽。CPU 占用率不高,内存控制器也没到极限,但吞吐量就是上不去。后来一查,发现是缓存行在作祟。
嗯,从那以后,我再也不敢小看这 64 字节了。
4.1 缓存行大小:为什么是 64 字节?
先说说为什么是 64 字节,不是 32,也不是 128。
这其实是个权衡。缓存行太小,预取效率低,CPU 得频繁去内存搬数据。缓存行太大,又容易把不需要的数据也搬进来,浪费带宽,还挤占缓存空间。
64 字节,是业界多年实践下来的一个平衡点。x86 和 ARM 的主流处理器,基本都采用这个大小。
核心要点: 你访问一个 4 字节的 int,CPU 实际上把包含这个 int 的 64 字节都搬进了缓存。这意味着,如果你能利用好这 64 字节里的其他数据,你就赚了。否则,你就浪费了宝贵的带宽。
举个例子:
// 场景 A:按顺序访问数组
int arr[1024];
for (int i = 0; i < 1024; i++) {
process(arr[i]); // 每次访问,CPU 都搬来 64 字节,后续 15 个 int 直接命中缓存
}
// 场景 B:跳跃访问数组
for (int i = 0; i < 1024; i += 16) {
process(arr[i]); // 每次访问,只用了 4 字节,剩下 60 字节全浪费了
}
场景 A 的带宽利用率,可能比场景 B 高十几倍。我在项目中遇到过不少类似场景,很多人写代码时根本没意识到这个差异。
4.2 硬件预取机制:CPU 在帮你干活
光靠缓存行还不够。CPU 还有个更聪明的机制——硬件预取(Hardware Prefetch)。
你想想看,CPU 执行指令的速度比内存快两个数量级。如果每次等数据到了才干活,那大部分时间都在空转。所以硬件预取器会偷偷观察你的内存访问模式,提前把数据拉到缓存里。
常见的硬件预取策略有几种:
| 预取类型 | 工作原理 | 适用场景 |
|---|---|---|
| 顺序预取 | 检测到连续地址访问,提前加载后续缓存行 | 数组遍历、流式处理 |
| 步长预取 | 检测到固定间隔的访问模式,预取对应地址 | 矩阵按列访问、结构体数组 |
| 空间预取 | 访问一个缓存行时,同时加载相邻行 | 链表遍历(部分场景) |
我个人习惯,在写高性能代码前,先想想硬件预取器能不能识别我的访问模式。如果能,那就让它干活;如果不能,我就得手动干预了。
注意: 硬件预取不是万能的。它最怕两种模式:一是随机访问,二是间接跳转(比如通过指针链表)。这两种场景下,预取器基本失效,性能会断崖式下跌。
4.3 软件预取指令:手动控制
当硬件预取搞不定时,就该我们上场了。
x86 提供了 PREFETCH 指令,ARM 有 PRFM。它们的作用是告诉 CPU:「嘿,我马上要用这块数据了,你先帮我搬过来。」
在 C/C++ 里,通常通过编译器内置函数来调用:
// x86 平台
#include <xmmintrin.h>
_mm_prefetch((const char*)addr, _MM_HINT_T0); // 预取到 L1 缓存
// ARM 平台
__builtin_prefetch(addr, 0, 3); // 0 表示读,3 表示高局部性
我建议,使用预取指令时,要把握好提前量。预取太早,数据可能被踢出缓存;预取太晚,CPU 还得等。
一个经验值:提前 7-10 个缓存行。比如你正在处理第 i 个元素,可以预取第 i+8 个元素。
void process_with_prefetch(int* data, int n) {
for (int i = 0; i < n; i++) {
// 预取 8 个元素之后的数据
_mm_prefetch((const char*)&data[i + 8], _MM_HINT_T0);
// 处理当前元素
process(data[i]);
}
}
小技巧: 我曾经在一个图像处理管线里,用预取指令把带宽利用率从 40% 提到了 85%。关键就是找到那个「提前量」——太早没用,太晚也没用。我一般通过性能计数器(比如 Intel 的 MEM_LOAD_RETIRED 事件)来调优这个值。
4.4 知识体系:缓存行与预取的核心逻辑
下面这张图,帮你理清本章的核心脉络:
4.5 避坑指南
最后,分享几个我踩过的坑:
- 伪共享(False Sharing): 多线程场景下,两个线程各自修改不同变量,但这两个变量恰好在同一条缓存行里。结果就是,每次修改都会导致缓存行在核间来回同步,性能暴跌。我曾经在一个多线程日志系统里遇到过,加了 padding 对齐到 64 字节后,性能提升了 3 倍。
- 预取过度: 软件预取不是越多越好。预取指令本身也有开销,而且会占用内存带宽。我建议只在热点路径上使用,并且用性能计数器验证效果。
- 忽略 NUMA 影响: 在多路服务器上,预取的数据可能来自远端内存。这时候预取反而可能加剧延迟。我一般会结合
numactl绑定内存节点。
一句话总结: 理解缓存行,用好预取机制,你的内存带宽利用率能翻倍。别让 CPU 闲着等数据,也别让内存总线空跑无用数据。
想要更多资料,联系可可