如果你正在读这篇笔记,大概率是被 CPU cache 折磨过的人。或者你只是想搞明白:为什么代码里加一个 print 语句性能就崩了?为什么多线程程序在某个核上跑得飞快,换个核就慢得像蜗牛?别急,这些都是 cache 能解释的问题。上一篇我整理了 cache 的基础概念,包括为什么要有多级缓存、cache line 是怎么回事。这一篇继续往深里挖,重点放在映射方式、写策略、多核一致性,以及我们平时写代码时真正用得上的 cache 性能优化方法。
这篇笔记适合三类人:一类是刚接触体系结构的初学者,想系统理解 cache 是怎么运作的;一类是写业务代码但经常被性能问题困扰的人,想搞清楚“明明逻辑很简单,CPU 占用却高得离谱”背后的原因;还有一类是做性能调优的工程师,需要把 cache miss 这种底层指标和上层现象对应起来。我会尽量用大白话,配合真实可跑的命令和例子,把这块硬骨头嚼碎了给你看。
1. 从结构说起:Cache 的三种映射方式凭什么值得反复琢磨
1.1 直接映射、全相联、组相联的取舍
上一篇文章里我们说过,cache 是 CPU 和主存之间的一层高速缓冲,它把主存里近期可能被访问的数据复制一份放到更靠近 CPU 的地方。但这里有一个问题:主存那么大,cache 那么小,怎么知道一个内存地址到底对应 cache 里的哪个位置?这就引出了映射方式。
最常见的三种映射方式:直接映射、全相联映射、组相联映射。教科书上画过不少图,但实际工程里用的几乎都是组相联,所以理解组相联是重点,另外两种更多是用来理解组相联的铺垫。
直接映射的意思非常直白:每个内存块只能放到 cache 里唯一的一个位置。比如 cache 一共有 1024 行,内存块号是 0 的只能放到第 0 行,块号是 1024 的也只能放到第 0 行,因为它们对 1024 取模的余数相同。这种方式硬件实现最简单,只需要按地址低位查找,但是缺点也特别明显:同一时刻映射到同一行的多个内存块会互相挤掉对方,导致频繁的 miss。
全相联映射就是彻底放开:任何一个内存块可以放到 cache 里任意一个空闲位置,地址不跟 cache 行绑定。这样 miss 率理论最低,但硬件需要把 cache 里所有行的 tag(标签)都拿出来和地址比较,容量一大,比较器和功耗就爆炸,所以只适合小容量的缓存,比如 CPU 内部的 TLB 部分场景还会用。
组相联映射是两者的折中:先把 cache 分组,每个组里有若干行(几路),内存块可以放到某个组里的任意一行。比如 4 路组相联,就是每个组有 4 个位置,内存块按地址中位数定位到组,组内自由选择位置。这样既有一定的灵活性,又不需要全量比较,硬件成本可控。现代 CPU 的 L1、L2 基本都是组相联,L3 也差不多。
打个生活化的比方。假设你住酒店,走廊里有很多房门。直接映射像是“房号 = 楼层 × 相同规律”,你只能去固定的房间,住满了你就只能把原来的住客赶走;全相联像是“只要有空房就能住”,但服务员要找遍整栋楼才能找到你的旅客信息;组相联像是“每个楼层可以选固定几个方向的房型,但楼层内部可以换房”,既不太死板,找人也快。
1.2 组相联的具体参数:地址划分与索引计算
光理解概念不够,实际计算才是关键。一个组相联 cache 的地址一般分成三段:
- offset(块内偏移):用来在 cache line 里面找字节,长度取决于 line size。
- index(组索引):用来定位是哪一组。
- tag(标签):用来确认这一组里的某一行是否正好保存了目标内存块。
举个例子:假设有一个 64KB 的 L1 cache,cache line 大小是 64 字节,4 路组相联。那么总行数是 64KB / 64B = 1024 行。组数 = 总行数 / 路数 = 1024 / 4 = 256 组。每组需要 256 个索引值,所以 index 位数为 8(2^8 = 256)。offset 位数是 6(2^6 = 64)。如果机器是 64 位地址,地址从低位往上依次是 6 位 offset、8 位 index,剩下的 50 位都是 tag。
这个计算有什么实际意义?我遇到过不少同学写程序时想当然地以为访问一个连续数组 cache 命中率很高,结果发现某些固定步长的访问模式会疯狂踩踏同一个组,导致 cache miss 率居高不下。这就是组索引在捣鬼。
举个例子:一个 8KB 的 cache,line size 64B,直接映射,那么 index 位数是 log2(8192/64) = 7 位。一个数组首地址刚好是 8192 字节对齐的话,你每隔 8KB 访问一个元素,这两个元素会映射到同一个 cache 行,于是每次访问都会把上次的数据踢出去,形成“冲突 miss”。这就是传说中的 cache thrashing。解决办法很简单,重新调整数组的 padding,或者换用组相联级别更高的 CPU,也能缓解。
做性能调优的时候,我习惯先确认机器 cache 的几何参数,再决定代码里数据结构的排布。这不是玄学,而是因为 cache 的实际表现跟这些参数强相关。后面第四节我会给一套完整的查看命令。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写策略与一致性:Cache 不只是读得快,写对了才稳
2.1 Write Through 与 Write Back 的差异
读 cache 相对简单,只要地址命中就能快速返回。写呢?写操作要麻烦得多,因为缓存里的数据最终要同步回内存。这里有两条路线,一条叫 Write Through(写直达),一条叫 Write Back(写回)。
Write Through 的规则非常直接:每次 CPU 写数据时,一边更新 cache 里的副本,一边立即把整个 cache line 写回主存。优点是实现简单,缓存和内存永远是一致的,不需要复杂的脏标记逻辑。缺点也致命:每一次写操作都要访问慢速内存,写操作的延迟完全暴露给 CPU,写带宽成了瓶颈。早期一些低功耗、低性能的嵌入式 CPU 或者简单外设会用这种方式,服务器 CPU 基本不会在主缓存上采用。
Write Back 的规则是:写数据时只更新 cache 里的副本,并把这一行标记为 dirty(脏行),等这个 cache line 被替换出 cache 时,再一次性写回主存。这样做的好处是写操作只需要打到 cache,速度快,而且如果同一个 cache line 被频繁修改,最终只需要写一次内存,而不是写无数次。坏处是需要额外的 dirty 标志位,并且替换策略要考虑脏行的处理,硬件复杂度提升。
工程上还有两个配合选项:写分配(Write Allocate)和不写分配(No-Write Allocate)。写分配指发生写 miss 时,先把内存块加载到 cache,再执行写操作,适合后续还要反复访问的场景。不写分配则直接往内存里写,不占用 cache 空间,适合一次性写入且以后不会用到的场景。常见的组合是 Write Back + Write Allocate,以及 Write Through + No-Write Allocate,不同处理器会根据场景选择。
这一层的取舍直接影响程序性能。如果你的程序大量写一个稀疏数组,每次写都 miss,那么用 Write Back + Write Allocate 意味着每次 miss 要先读一块内存到 cache,可能反而比不写分配更慢。所以不少编译器在优化循环时会做循环分块或数据重塑,目的就是减少写入时的 cache miss。
2.2 从 MESI 协议看多核 Cache 怎么保持一致
单核的时代,cache 里脏数据只要自己处理好就行。但现代手机 CPU 都有 8 个核,服务器更是几十个核,每个核都有私有的 L1/L2,还共享一个大 L3。这一下就出现了新问题:CPU 核 A 改了某个内存地址的数据,CPU 核 B 的 cache 里还留着旧副本,B 如果继续读,就会读到过期数据。谁负责同步?这就是缓存一致性协议,最经典的是 MESI 协议。
MESI 用四个状态标记每个 cache line:
- M(Modified,已修改):这一行数据已被当前核修改,和主存不一致,数据只在本核 cache 中有效。
- E(Exclusive,独占):这一行数据只存在于当前核 cache,和主存一致。
- S(Shared,共享):这一行数据可能存在于多个核的 cache 中,且所有副本都和主存一致。
- I(Invalid,失效):这一行数据无效,不能使用。
协议的核心操作是监听(snooping)。每个核的 cache 控制器会监视总线上的读写请求,一旦发现别的核在读自己独占或修改的数据,就需要做出响应。比如 A 核将自己的 cache line 状态从 E 改成 S,并把自己的数据副本通过总线传给发起读的核;如果 A 核是 M 状态,那它得先把数据写回主存,然后再共享。
这些状态转换听着复杂,真正影响程序员的是两点:第一,多核写共享数据会产生大量的缓存一致性消息,这比直接访问内存还贵;第二,不同核修改同一个 cache line 会导致互相抢占,也就是下一节要说的伪共享。很多人喜欢用“缓存一致性就是硬件自动保证的,写代码时不用管”,这话对了一部分,但你要是把并发热点数据摆在了同一个 cache line 里,硬件协议再怎么保证都救不了你的性能。
还要提一句:MESI 是教科书级别的协议,实际处理器实现会做很多优化,比如引入 L3 作为后端一致性点,或使用目录(directory)来避免总线广播风暴。但理解 MESI 已经足够解释绝大多数并发性能问题。
3. 从软件视角看 Cache:为什么你的程序总是“意外”变慢
3.1 Cache Line 和伪共享:两个线程打架的现场
很多人写并发代码时,脑海里只有“原子性”和“锁”,很少去考虑 cache line 的粒度问题。但伪共享恰恰是并发场景下最容易踩的坑。
什么叫伪共享?假设有两个线程,一个频繁读写变量 A,另一个频繁读写变量 B,A 和 B 恰好位于同一个 64 字节的 cache line 里。由于缓存一致性是按 cache line 为单位管理的,线程 1 更新 A 时,会把整条 cache line 标记为 Modified,线程 2 读 B 时发现自己的 cache line 失效,必须重新从内存加载;反过来也是同样。于是两个线程表面上操作的是不同变量,底层却在疯狂传递这条 cache line,性能损耗极其严重。
怎么解决?方法是让 A 和 B 分别落在不同的 cache line 上。C++11 提供 alignas 关键字,Java 提供了 @Contended 注解,很多框架里直接用 padding 变量补位。我记得早年做高并发队列时,队头指针和队尾指针如果放在同一个结构体里,性能会掉得非常明显,后来在队头队尾之间塞了 56 字节填充,吞吐量直接翻倍。这就是伪共享的“威力”。
检查伪共享的手段也简单:先用 perf 看 cache-miss 相关事件,如果 miss 率高得离谱,而且并发核数越多性能越差,再反推数据结构,基本都能找到问题。后续我会讲如何用 perf 量化。
3.2 Cache 命中率与 Page Cache、KV Cache 的关系
聊到这里,有必要把概念边界理一理。CPU cache 是硬件上的缓存,但软件世界里还有很多同名或相似的缓存机制,比如操作系统的 Page Cache,比如大模型推理里的 KV Cache。它们的核心思想一致:用更快的介质,缓存更慢的介质上的数据,减少重复访问。
Page Cache 是内核用来缓存磁盘文件页的,当程序读取一个文件时,内核先把数据从磁盘读进内存页,后续再读同一页就直接命中 Page Cache,不再访问磁盘。Page Cache 的管理和 CPU cache 有许多相通之处:都要考虑时间和空间局部性,都会有脏页回写,都会面临缓存替换算法的问题。不过 Page Cache 的粒度是 4KB 页,替代算法也更接近软件。
KV Cache 是这几年做大模型推理绕不开的概念。Transformer 推理时,每个 token 的注意力计算会反复用到历史 token 的 Key 和 Value,如果每个 decode 步骤都重新计算一遍,计算量爆炸。所以现在主流实现是把已经算好的 Key、Value 缓存起来,避免重复前向计算。这里的缓存虽然存在于 GPU 显存或普通内存,不叫 CPU cache,但“用空间换时间”“命中率决定加速比”的思维是完全一样的。
我提这个概念是想强调:cache 不只是一种硬件结构,更是一种通用的性能优化哲学。你理解了 CPU cache 的命中率、替换策略、失效问题,去理解 Page Cache 的 page cache 命中率、KV Cache 的缓存复用,甚至 Redis 的缓存淘汰,都会非常顺。笔记标题虽然是 CPU cache,但从“缓存”的大视角看,很多东西是相通的。
4. 实操:查看 CPU Cache 信息与定位性能瓶颈
4.1 Linux 下怎么看 Cache 的真实配置
很多朋友搜索“linux 查看 cache 版本”,其实是想看 CPU 的 cache 硬件参数。这里整理几个最实用的命令。
第一个是 lscpu,它在大多数 Linux 发行版都自带。执行 lscpu 后能看到 L1d、L1i、L2、L3 的大小、行大小、组相联路数等关键信息,输出片段类似这样:
bash复制L1d cache: 64K (per core)
L1i cache: 64K (per core)
L2 cache: 512K (per core)
L3 cache: 32M (per socket)
要注意“per core”和“per socket”的区别。L1/L2 通常是每个物理核私有的,L3 才是在一个插槽内共享的。如果你在做 NUMA 相关的优化,光看这些还不够,得结合 lscpu -e 看 CPU 和 NUMA 节点分布。
如果想看更底层的 cache 行大小和关联参数,可以读 sysfs 目录。比如 /sys/devices/system/cpu/cpu0/cache/index0/ 里有 size、ways_of_associativity、coherency_line_size 等文件。我常用的命令是:
bash复制cat /sys/devices/system/cpu/cpu0/cache/index*/level /sys/devices/system/cpu/cpu0/cache/index*/type /sys/devices/system/cpu/cpu0/cache/index*/size /sys/devices/system/cpu/cpu0/cache/index*/coherency_line_size
运行后就能把每一级缓存的层级、类型、大小、行大小一次性列出来。很多性能分析工具(比如 valgrind 的 cachegrind)也需要配置这些参数,如果不想手动填,可以直接从 sysfs 抄。
还有一个办法是通过 getconf 命令快速拿到关键值:
bash复制getconf LEVEL1_DCACHE_LINESIZE
getconf LEVEL1_DCACHE_SIZE
getconf LEVEL2_CACHE_SIZE
这些命令本身非常简单,但能把很多“网上看的”参数落到自己机器上,是学习 cache 结构最直观的第一步。我建议你拿到真机后亲手跑一遍,再对照前面说的地址划分公式算一算,印象会深很多。
4.2 用 perf 统计 Cache Miss 并优化代码
看参数只是开始,真正有价值的操作是测量程序的 cache 行为。Linux 下的 perf 工具是首选。它基于内核的 perf_event 和硬件性能计数器,可以统计 cache 访问次数、cache miss 次数、分支预测失败次数等。
先用 perf stat 跑一个简单命令:
bash复制perf stat -e cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses ./your_program
输出里会显示各类事件次数和 miss 率。注意不同平台事件名称可能不同,建议先 perf list | grep cache 看当前硬件支持哪些事件。
如果 miss 率特别高,下一步就该定位代码位置。可以用 perf record 和 perf report 来采样,比如:
bash复制perf record -e cache-misses ./your_program
perf report
perf report 会按函数维度显示 miss 采样落在哪些函数上。不过硬件采样会有一定的“滞后”偏差,因为 miss 可能是在某个指令触发的,但 PC 可能已经执行到了后续指令。定位到热点函数后,再用 perf annotate 看具体指令级别的 miss 分布。
我自己做调优时积累的经验是:先看全局 miss 率,如果 L1 miss 率超过 10%,L2/L3 miss 率超过 5%,就值得投入精力优化;如果 miss 率本来就低于 1%,那再纠结 cache 便是浪费时间,不如去看锁竞争、系统调用和 IO 开销。
优化的直接手段无非几种:改进数据局部性(比如把 AoS(Array of Structs)改成 SoA(Struct of Arrays))、循环分块(Loop Blocking / Tiling)、Cache Line 对齐、减少伪共享、使用预取指令。这些都是老生常谈,但真正做起来需要结合指标反复迭代。我下面给一个铺垫例子:假设你有一个结构体数组,每个结构体只有两个字段被频繁读,其他字段全部闲置,那么把结构体拆分后,热点字段连续排列,cache 利用率会立刻提升。原因就是原来一条 cache line 只能装下少数几个完整结构体,现在能装下更多热点字段。
5. 常见坑与排查记录
5.1 哪些情况会导致 Cache 被“拖垮”
前面讲了这么多理论,这里汇总一下我实际遇到过的、容易误导人的情况。
第一个坑是“编译器优化搞乱你的局部性假设”。比如你在循环里交换两个循环变量的顺序,cache 命中率可能发生剧烈变化。C/C++ 的多维数组默认行优先,但如果你习惯用 a[j][i] 的方式遍历,那么每次访问都是一次 cache miss,因为相邻元素并不位于同一行内存。这个错误新手常犯,老手偶尔也会在生成代码时忽略。
第二个坑是“不要以为加锁之后数据结构竞争就会串行化到无缓存问题”。锁是对临界区的保护,但它并不会阻止多个核同时修改同一个 cache line 上的不同字段。伪共享依旧会发生,甚至锁的实现里多个等待者同时自旋同一个 cache line,会让缓存一致性协议忙得不可开交。所以高并发场景下,除了锁粒度,cache line 的布局也要纳入考虑。
第三个坑是“内存分配器的行为影响缓存对齐”。malloc 返回的地址通常按 16 字节对齐,但如果你想让一个结构体按 64 字节对齐,需要用 aligned_alloc 或 posix_memalign。如果结构体没有对齐,即使你人为加了 padding,也可能跨 cache line 边界,导致一个字段被切到两条 line 上,命中的代价变大。
第四个坑是“CPU 频率变化掩盖了 cache 性能差异”。现代 CPU 有睿频、功耗管理,跑性能测试时如果不固定频率,你会发现有时优化后帧率反而下降了,其实是频率波动。所以我做 cache 对比实验时,都会先把 CPU 频率固定在一个值,比如用 cpupower frequency-set -g performance,确保 CPU 在高频稳定状态下。否则你优化了半天,测量噪音比优化效果还大。
5.2 针对热词里的几个典型问题做说明
我注意到网上有不少人问“为什么浏览器渲染进程 CPU 占用过高”,或者“为什么 Windows 待机后 CPU 降频不恢复”,这些问题的根因不一定在 cache,但排查思路和缓存优化有相通之处。比如浏览器渲染进程大量图形指令和 DOM 操作,会产生巨额的内存访问,如果数据布局不好,cache miss 率会剧增。这时用 perf 或者任务管理器看出来的 CPU 占用高,其实是“工具在忙着等内存”。
还有“array card disk cache”这类硬件层的 disk cache 概念:阵列卡上的缓存主要用来吸收写入 IO,它的写策略也有 WriteBack 和 WriteThrough 模式,操作不当掉电会丢数据。这和 CPU cache 的 WriteBack 概念很接近,但它在存储控制器上,策略选择更多和可靠性有关,而不是单纯性能。理解了 CPU cache 的写策略,再理解阵列卡缓存就很容易。
至于“KV cache 计算”这类问题,计算的核心无非是每层每个头的 Key/Value 张量大小,公式通常是:
text复制KV Cache Size = batch_size * sequence_length * num_layers * num_heads * head_dim * 2 * dtype_size
这里乘 2 是因为 Key 和 Value 各一份。虽然是纯软件缓存,但它一样有“命中率”“内存带宽”“驱逐策略”的问题,和 CPU cache 是同类问题在不同尺度的映射。
我不打算在这篇笔记里深入展开这些扩展概念,因为它们各自的细节都够单独写几篇。但如果你能从 CPU cache 出发,建立起“缓存命中率决定访问效率”的心智模型,再去看这些具体实现,会少很多困惑。
最后再分享一个小技巧:在处理未知性能问题时,不要一开始就怀疑 cache。先排除锁竞争、IO 阻塞、系统调用、内存泄漏这些明显因素,最后再用 perf 看 cache 指标。因为 cache 优化是“锦上添花”,而不是“雪中送炭”。不过一旦确认瓶颈在缓存命中率,它带来的收益往往让人惊喜。我自己踩过多次坑之后,现在写核心数据结构前都会先画一张内存访问布局图,想清楚哪些变量会被一起访问、哪些会被并发修、cache line 怎么划分。这不花太多时间,但效果立竿见影。
