3、CPU缓存架构:L1/L2/L3缓存结构、缓存行与缓存一致性、TLB对内存访问的影响

聊内存带宽,绕不开CPU缓存。说白了,现代CPU和内存之间隔着好几层“缓冲池”。我见过不少开发者,代码写得挺漂亮,一跑起来性能就是上不去。查到最后,十有八九是缓存没用好。

今天咱们就把缓存这层窗户纸捅破。从L1/L2/L3的结构,到缓存行和一致性,再到TLB这个容易被忽略的“隐形杀手”。

3.1 三级缓存:L1、L2、L3 到底长啥样?

先看一张我画的架构图,帮你建立直观印象:

现代CPU三级缓存架构示意图 Core 0 L1i L1d L2 Cache (私有) Core 1 L1i L1d L2 Cache (私有) Core 2 L1i L1d L2 Cache (私有) ... L3 Cache (共享) 主内存 (DRAM) ~1ns ~4ns ~10ns ~100ns

这张图我画得比较直观。每个CPU核心有自己的L1和L2,L3是所有核心共享的。你想想看,L1访问延迟大概1纳秒,L3大概10纳秒,内存呢?100纳秒起步。这差距,就是优化的空间。

关键数据:

  • L1缓存:32KB ~ 64KB,延迟约1ns
  • L2缓存:256KB ~ 1MB,延迟约4ns
  • L3缓存:8MB ~ 32MB,延迟约10ns
  • 主内存:延迟约100ns

我个人习惯,在分析性能瓶颈时,先看L1 miss率。如果L1 miss率超过10%,基本可以断定缓存没用好。

3.2 缓存行:64字节的秘密

CPU从内存读数据,不是按字节读的。一次读64字节,这就是一个缓存行(Cache Line)。

为什么是64字节?历史原因加上硬件设计权衡。我记得Intel从Pentium 4开始就用64字节,一直沿用到现在。

实战技巧:

如果你在遍历一个结构体数组,结构体大小刚好是64字节的整数倍,性能会好很多。我曾经把一个48字节的结构体改成64字节(加了些padding),遍历速度提升了30%。

看个代码例子,感受一下缓存行的威力:

// 不好的写法:按列访问,每次跳很远
for (int j = 0; j < N; j++) {
    for (int i = 0; i < N; i++) {
        sum += matrix[i][j];  // 每次访问不同行,缓存行浪费
    }
}

// 好的写法:按行访问,连续内存
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        sum += matrix[i][j];  // 连续访问,充分利用缓存行
    }
}

为什么会这样?因为按行访问时,matrix[i][0]到matrix[i][15](假设是int数组)都在同一个缓存行里。按列访问呢?每次都要重新加载缓存行。

3.3 缓存一致性:多核时代的噩梦

多核CPU上,每个核心有自己的L1/L2。如果Core 0改了某个变量,Core 1怎么知道?

这就引出了MESI协议。四个状态:

状态 含义 说明
M Modified 已修改,数据只在本核心缓存中
E Exclusive 独占,数据只在本核心,且与内存一致
S Shared 共享,多个核心都有这份数据
I Invalid 无效,数据已过时

避坑指南:

我曾经在写一个多线程计数器时,每个线程频繁更新同一个全局变量。结果性能比单线程还差。为什么?因为缓存一致性协议让所有核心都在互相通知“我改了,你的缓存失效了”。这就是所谓的“缓存抖动”(Cache Thrashing)。

解决方案?用线程本地存储(Thread Local Storage),或者减少共享变量的写操作频率。

3.4 TLB:被忽视的性能杀手

TLB(Translation Lookaside Buffer)是CPU内部的一个小缓存,用来加速虚拟地址到物理地址的转换。

你想想看,每次内存访问都要做地址转换。如果没有TLB,每次都要查页表,那性能就崩了。

TLB miss的代价有多大?

  • TLB hit:约1个CPU周期
  • TLB miss:约10-100个CPU周期(需要查页表)
  • 如果触发缺页中断:数百万个CPU周期

TLB对内存带宽的影响:

TLB miss会导致流水线停顿。当TLB miss率超过1%时,内存带宽可能下降20%以上。我调优过一个大数据处理程序,通过使用大页(Huge Pages),把TLB miss率从3%降到了0.1%,吞吐量直接翻倍。

怎么优化TLB?

  1. 使用大页(Huge Pages):Linux下2MB或1GB的页,能覆盖更多内存空间
  2. 内存访问局部性:尽量访问连续的内存地址
  3. 避免跨页访问:如果一个数据结构跨越了两个页,TLB miss概率翻倍

看个实际例子:

// 启用大页(Linux)
echo 1024 > /proc/sys/vm/nr_hugepages
mount -t hugetlbfs none /mnt/hugepages

// 在程序中使用大页
#include <sys/mman.h>
void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);

嗯,这里要注意:大页不是银弹。如果内存碎片严重,分配大页可能会失败。我建议在程序启动时提前分配好大页内存。

3.5 总结一下

缓存架构这块,说白了就三件事:

  • 缓存层次:L1快但小,L3慢但大。代码要尽量在L1里跑。
  • 缓存行:64字节对齐,连续访问。别让CPU白干活。
  • TLB:地址转换的加速器。大页是你的朋友。

我见过太多人,优化了半天内存带宽,结果发现瓶颈在缓存一致性协议上。所以,下次写多线程代码时,先问问自己:这个变量真的需要共享吗?

好了,这一章就到这里。记住:理解缓存,就是理解性能。

一句话总结:

CPU缓存不是黑盒,它是你可以利用的工具。用好了,性能翻倍;用不好,代码跑得比蜗牛还慢。

想要更多资料,联系可可