
1. 为什么 Cache 会成为性能的墙很多人学计算机体系结构时第一个感觉是Cache 不就是个中间缓存吗CPU 要数据先看 Cache没有就往下层拿——听起来简单但真到优化性能的时候代码跑不快、延迟压不下去、带宽跑不满最后排查下来问题往往就藏在这个简单的部件里。先聊一个最基本的数字现代高性能处理器的 L1 Cache 命中延迟通常在 4 到 5 个时钟周期左右L2 在 12 到 15 个周期L3 则要 35 到 50 个周期。如果是走内存那就是上百个周期。这个差距意味着什么如果你的程序频繁访问内存而不是 Cache运行时间不是翻倍而是可能直接拉出两个数量级的差距。Cache 从来不是加分项而是性能的底线。我从数字逻辑设计的角度切入来说。课程讲部件设计Cache 这个部件和其他逻辑部件不太一样它的核心不是算得快而是猜得准。加法器、乘法器的性能靠逻辑深度和电路实现Cache 的性能靠的是命中率而命中率本质上是行为预测问题。一个设计得好的 Cache需要在面积、功耗、命中率、缺失代价之间反复拉扯。这也是为什么很多做数字 IC 的人说Cache 是处理器里设计空间最大、坑最多的模块之一。这篇文章我会把 Cache 优化拆成几条主线来讲第一是命中率怎么优化第二是缺失代价怎么降低第三是写入策略怎么选第四是真实处理器里的组合拳最后再聊聊我在实际编程和设计过程中常遇到的几个看起来是 Cache 问题但其实是理解偏差的场景。因为要在这个领域里做优化先得把Cache 到底在优化什么这件事想清楚。1.1 从一条指令的执行路径说起一条指令从取指到执行完毕大概长这样CPU 先根据程序计数器 PC 去取指令指令 Cache 负责给出指令内容拿到指令之后再根据操作数地址去取数据数据 Cache 负责给出数据。如果这两级 Cache 都命中了指令的执行就是流水线操作每一步都在一个周期内完成。但一旦 miss流水线就要停滞等数据从下层存储取回来。从这个路径能看出两个重要事实第一取指令和取数据是两条独立的路径所以现代处理器几乎统一采用指令 Cache 与数据 Cache 分离的哈佛结构哈佛结构其实是相对于指令数据共用的冯·诺依曼结构来的。第二每一次内存访问都要经过 Cache 的标签比较这个比较发生在关键路径上所以 Cache 的访问延迟直接决定了处理器的最高主频能做多高。我再给大家一个参考数字在 45nm 工艺时代一个 32KB 的 L1 Cache 的访问时间大约是 0.5ns 到 1ns 之间而那个时候处理器的目标周期大约是 0.3ns 到 0.5ns。也就是说光是访问一次 L1 Cache就要占掉 1 到 2 个周期而且访问的动作本身还要和流水线的取指/访存阶段对齐。这也就是为什么后来有了多级 Cache的层次化设计——你不可能靠一个超大容量、超低延迟的 Cache 解决所有问题因为物理规律不允许。1.2 局部性原理Cache 能工作的合法性来源Cache 不可能装下所有数据但它依然有效靠的是程序行为的两个规律时间局部性和空间局部性。时间局部性说的是一个数据被访问过后短时间内很可能再被访问。典型的例子是循环变量和累加器它们在循环体内被反复读写。空间局部性说的是一个数据旁边的数据短时间内也很可能被访问。典型的例子是数组遍历你访问了 arr[i]下一步大概率要访问 arr[i1]。Cache 的设计完全是围绕这两个规律展开的为了利用时间局部性Cache 把最近访问过的数据留在身边不轻易淘汰。为了利用空间局部性Cache 以块为单位搬运数据一次不搬一个字节而是搬一个 64 字节的块这样相邻数据也被一并请进来。一旦程序行为的局部性被破坏比如链式遍历一个很大的随机链表结构那么 Cache 的命中率会直线下降所有数据都要从内存重新搬性能立刻崩盘。所以我说 Cache 优化首先不是一个电路问题而是一个结构适配问题。在做 Verilog 或体系结构设计时第一步就是要弄清楚我的访存序列是什么样的是顺序流、跳跃流还是随机访问流这决定了 Cache 参数怎么定。前面铺垫了这么多现在可以正式进入优化层面了。我按三个优化维度来讲命中率、缺失代价、写入策略。这三者并不是独立变量现实中改一个往往会牵连另外两个但逐个攻破更容易建立全貌。2. 命中率优化容量、块大小、相联度背后的权衡命中率是大多数人提到 Cache 优化时第一个想到的东西。没错提高命中率最直接的做法就是减少 miss。但 miss 不是一个单一概念它至少分为三类强制性缺失第一次访问一个数据块它根本不在 Cache 里无论如何都要从下层取。这类缺失没法靠 Cache 自身参数消除只能靠预取来缓解。容量缺失Cache 装不下了数据被挤出去等再需要时又要重新取。这类缺失由 Cache 容量不足引起加大容量能减少。冲突缺失数据明明没有被挤出去的需要但因为映射规则多个地址被映射到同一个 Cache 行互相打架导致反复替换。这类缺失靠提高相联度来改善。理解这三类缺失是选择 Cache 参数的起点。下面逐个看容量、块大小、相联度这三个参数它们各自影响哪类 miss。2.1 容量不是越大越好够用才最好容量是 Cache 最直观的参数。容量越大容量缺失越少命中率越高。但容量不是免费的午餐它牵扯三个代价访问延迟变大。大容量的 Cache 意味着更大的标签存储和更长的译码路径信号传输时间变长命中延迟必然上升。在 L1 这一级延迟是命根子超过 4 个周期就是灾难性的。功耗变大。每次访问都要对标签存储器的部分内容进行读操作容量越大激活的电路越多动态功耗越大。在移动端和嵌入式场景这部分功耗占比非常可观。面积成本。Cache 的面积在处理器芯片上通常占到 30% 到 50%增大容量等于压缩其他逻辑的空间。这就是为什么现代处理器里 L1 Cache 通常只有 32KB 到 64KBL2 是 256KB 到 1MBL3 是几 MB 到几十 MB。每一级 Cache 是在命中率提升和延迟、功耗、面积代价之间取一个最佳平衡点。从设计角度看容量的选择其实就是在估算工作集的规模。如果你的程序工作集是 48KB而你给 L1 配 32KB那容量缺失会非常显著。给出的建议是在划定 L1 大小时先对目标负载做访存 trace 的分析看看工作集分布曲线找到曲线拐点所在的位置那里的容量一般是性价比最高的。实际工程中这一步常被跳过很多人拍脑袋定大小结果流片后性能不达标只能靠下一版本迭代修正代价非常大。2.2 块大小局部性的放大器也是带宽的消耗者块大小block size是一个容易被忽视的参数但它对命中率的影响比很多人想象中大。假设 Cache 总容量不变块大小从 16 字节变成 64 字节行的数量会减少到原来的四分之一。这带来两方面的影响好处单次搬运的数据变多空间局部性利用得更好。如果一个程序倾向于顺序访问一个大数组64 字节的块比 16 字节的块能覆盖更长的连续访问区间命中率提升明显。坏处一个大块意味着内存总线要在一次缺失时传输更多的数据缺失代价直线上升。而且如果程序只访问块里的一小部分数据其余部分都是白搬的浪费了带宽同时也占用了有用的 Cache 空间。医学影像、稀疏矩阵这类低空间局部性的应用就是典型受害者。块大小的选择还和内存访问粒度有关。现代 DRAM 一次突发传输通常是 64 字节所以 CPU Cache 的块大小普遍是 64 字节这个数字并非偶然——你从内存取 32 字节和取 64 字节在总线上花费的时间几乎相同因为突发传输是按 64 字节整块对齐的。这就是为什么很多高端处理器的 L1 虽然挺小但块大小仍然 64 字节甚至更大。一个工程经验是做嵌入式场景的 Cache 设计时块大小要按内存总线的宽度和突发能力来定。如果总线只有 32 位宽突发 8 次就是 32 字节设置 64 字节块就要发起两次突发复杂度上升收益却不一定明显。反过来高性能场景下块大小不足会让内存带宽被频繁的标签访问和预取请求浪费掉。2.3 相联度每一个路都是并行比较器相联度决定了一个地址可以映射到 Cache 中的多少个位置。全相联fully associative允许任意地址映射到任意行冲突缺失最小但硬件代价最高——需要把所有行的标签都拿来并行比较直接映射direct mapped只有一个位置可选硬件简单但冲突缺失严重。中间的折中就是组相联set-associative把 Cache 分成很多组每个地址固定映射到某个组组内可以选择不同路。数字逻辑设计上一个 N 路组相联 Cache 的核心开销在于并行的标签比较器。每一路都有一个比较器N 路就是 N 个比较器同时工作。这意味着2 路组相联需要 2 个比较器面积和功耗大约翻一倍。4 路组相联常被人认为是性价比甜点——冲突缺失明显下降硬件代价还在可控范围。8 路以上比较器和多路选择器的延迟开始成为关键路径的一部分L1 阶段很少用这么高的相联度。从实测数据看从直接映射升级到 2 路冲突缺失能减少大约 30%从 2 路升到 4 路还能再减少 15% 左右再往上边际收益就非常有限了。所以在 L1 设计里4 路几乎成了行业默认L2/L3 因为访问次数相对少、延迟不那么敏感可以采用 8 路、12 路甚至 16 路换取更高的命中率。有一点要特别提醒初学者相联度不能单独看还要看它和块大小的组合。同样是 32KB 的 L116 字节块 2 路组相联的配置和 64 字节块 8 路组相联的配置面积价格完全不同对程序行为的适应能力也完全不同。前者擅长小步长、随机型访问后者擅长顺序型、大跨步访问。没有绝对最优只有针对负载最优。2.4 三种参数放在一起怎么看我做一个简单的表格来汇总三者各自的主要影响方便对照参数主要改善的缺失类型主要代价典型取值L1容量容量缺失延迟、功耗、面积32KB - 64KB块大小强制性缺失、容量缺失空间局部性好的场景缺失传输时间、带宽浪费32B - 64B相联度冲突缺失比较器面积、功耗、访问延迟2路 - 4路L18路L2/L3这里补充一个评价 Cache 配置的常用公式——平均访存时间AMATAMAT 命中时间 缺失率 × 缺失代价这个公式是 Cache 优化的根。所有的参数调整最终都可以归结到三个分量上命中时间hit time、缺失率miss rate、缺失代价miss penalty。前面讲的容量、块大小、相联度主要影响的是缺失率但块大小同时还直接影响缺失代价块越大缺失代价越高相联度又会影响命中时间。所以你看任何一个参数都不干净它们全都交织在一起。这也是为什么做 Cache 优化一定要有量化意识不要凭感觉说4 路比 2 路好要在给定历史和功耗预算下算 AMAT谁的 AMAT 小谁才是真的好。3. 缺失代价优化从等待到流水命中率再高也架不住 miss 的出现。一个 miss 发生时CPU 要等多久这取决于存储层次中下一级的延迟和带宽。这里要先介绍一个概念——缺失代价miss penalty它指的是从发出访问请求到数据返回并填好 Cache 行之间的总时间。在现代处理器里一个 L1 miss 的缺失代价通常是 10 到 100 个周期取决于下一层是 L2、L3 还是主存。3.1 缺失时的停顿是怎么算的假设一个 L1 miss 需要访问 L2L2 的命中时间是 12 个周期。如果这个请求在 L1 miss 后完全停顿CPU 要白白等待 12 个周期。按照前面提到的 AMAT 公式缺失率哪怕只有 5%平均访存时间也会被拖到AMAT 4L1命中时间 0.05 × 12L2命中时间 4.6 个周期如果缺失率是 10%AMAT 就变成 5.2 个周期。相比 4 个周期性能下降 30%。如果下一级是主存缺失代价是 200 个周期那 5% 的缺失率算出来的 AMAT 是 14 个周期——这已经是不容忽视的性能危机了。降低缺失代价的核心思路只有一个在 miss 发生时让 CPU 继续干别的活或者在 miss 还没发生时把数据提前准备好。3.2 写缓冲与写合并把回写从关键路径上拿掉我们先说说写操作。很多人设计 Cache 时首先关注读 miss但写操作的代价往往更隐蔽。在一个写回write-back型 Cache 里写操作命中时只需修改本行的数据不需要立刻写回内存看着很高效。但当一个脏块被修改过的块被替换出去时它必须被写回内存这个写回动作会占用内存总线的带宽。如果写回的时机非常密集就会和读缺失争抢总线变相增加了读缺失的代价。解决思路是加一个写缓冲write buffer。脏块被替换时先把数据放到写缓冲里由写缓冲异步地把数据刷到内存CPU 不必等待写回完成。写缓冲还能做一件更聪明的事情合并写write coalescing。如果连续两次写回恰好落在内存的同一块区域写缓冲可以把它们合并成一次写操作节省带宽。写缓冲的深度设计也有讲究。太浅缓存不了几个写回系统频繁进入写缓冲满 - 暂停替换的状态太深面积和功耗上升而且写数据在缓冲里停留太久一致性风险增加。工程上一般取 4 到 16 项。我在做嵌入式 Cache 的时候基于门级仿真发现 8 项是一个比较稳妥的配置——既能吸收突发写回又不会让一致性模块变得过于复杂。3.3 非阻塞 Cache 与 MSHR让 miss 不挡路传统 Cache 在发生 miss 后会把整个流水线冻结直到数据返回。这是最简单也最高效面积上的实现方式但现代处理器显然不会接受这种一 miss 就全停的设计。于是有了非阻塞 Cachenon-blocking cache它允许在 miss 处理期间继续服务后面的命中请求。要实现非阻塞关键在于一个叫做MSHRMiss Status Holding Register的结构。MSHR 记录每一个未完成的 miss 请求的状态包括目标地址、目标 Cache 行、请求来源等。当新的 miss 到达时先在 MSHR 里查找是否已经有相同地址的请求在等待。如果有就把新的请求合并到已有条目上如果没有就分配一个新的 MSHR 条目。MSHR 条目数是非阻塞能力的主要瓶颈。如果条目太少并发 miss 一多MSHR 满了后面照样要停。如果条目太多比如 16 个以上面积开销大而且多 miss 并发时返回数据连接的仲裁逻辑会变得非常复杂。一个典型的现代处理器会配置 4 到 16 个 MSHR。从这里也能理解为什么store miss比load miss更难处理——写 miss 之后还要考虑写缓冲和内存一致性的交互MSHR 里记录的不仅仅是一个读取完成的事件。从性能角度看非阻塞 Cache 的效果非常可观测在一个乱序执行的处理器里如果 L1 miss 了但后面还有一堆不依赖该数据的指令可执行流水线就不会停。非阻塞 Cache 加乱序执行是现代 CPU 能维持高 IPC 的基石之一。3.4 预取不等待缺失发生直接提前拿预取prefetch的思路和前面的被动等待完全不同它试图在 CPU 真正需要数据之前就把数据搬到 Cache。预取分为两大类硬件预取处理器内置的预取器观察访存流的规律比如连续地址递增预测下一个要访问的地址提前发起取块请求。软件预取编译器分析循环结构在代码里插入预取指令比如 ARM 的pld、x86 的prefetcht0显式告诉硬件把哪个地址的数据搬进 Cache。硬件预取器的设计是数字逻辑领域一个很有意思的话题。最简单的预取器是顺序预取检测到连续两次都访问相邻块就认为这是一个顺序流下一块也提前取。更复杂一点的是步长预取记录每个 PC程序计数器的访存步长按固定步长进行预取。再高级的还有基于区域的预取依赖大量的历史表。预取是一把双刃剑。预取对了缺失代价被隐藏在程序执行过程中CPU 几乎感觉不到 miss预取错了白白占用了内存带宽和 Cache 空间反而拖累了其他请求。所以硬件预取器的设计要非常小心激进度过高的问题通常不会把所有预测到的块都装进 Cache而是只装一部分留下一部分给真实请求。软件预取则要求编译器对循环有足够准确的理解如果预测不准多出来的预取指令本身也会消耗流水线发射宽度。工程上我见过不少团队做预取优化时走弯路——一上来就想做一个超复杂的自适应预取器结果在验证和时序收敛上花费大量时间。我的建议是先把顺序预取和基于 PC 的简单步长预取做好用真实负载的 trace 评估覆盖率。如果覆盖率已经超过 60% 到 70%再考虑更复杂的方案。4. 写入策略优化写直达与写回的账本读路径上的优化做完了我们再来看写路径。Cache 的写入策略可以分为两个维度一是写命中时怎么处理写直达 vs 写回二是写缺失时怎么处理写分配 vs 写不分配。这两组选择会显著影响 Cache 的带宽消耗、一致性和功耗特性。4.1 两种写入的基本账写直达write-through的规则很简单写命中时数据同时写入 Cache 和下一级存储通常是内存。优点是实现简单Cache 行永远是干净的换出时不需要写回一致性模型也简单。缺点是每一次写操作都要访问下一级存储写带宽消耗巨大。在一个写密集型的程序中写直达 Cache 会轻易把内存带宽打满。所以现代处理器很少在 L1 使用纯写直达除非是某些特殊场景比如需要精确记录每一次写操作的调试环境。写回write-back的规则是写命中时只修改 Cache 中的数据并把这个块标记为脏。这个脏块只会在被替换出去时才写回内存。优点是大幅减少了对内存的写操作——如果同一个 Cache 行被反复写了 100 次写回策略可能只在最后换出时写一次内存而写直达策略要写 100 次。缺点是每个 Cache 行需要额外的脏位dirty bit而且一致性协议和错误恢复逻辑都要复杂一些。对比一下项目写直达写回写内存次数每次写命中都写仅替换脏块时写实现复杂度低高脏位、写回仲裁一致性简单复杂要考虑多核场景写带宽需求高低典型应用各级 Cache 之间少见、调试场景绝大多数 L1/L24.2 写分配与写不分配怎么搭配写缺失时有两种选择。写分配write-allocate会把要写的块先加载进 Cache再执行写入写不分配write-no-allocate则直接绕过 Cache把数据写入下一级存储不占用 Cache 行。工程上的经典搭配是写回 写分配写缺失时先取块再在 Cache 里修改之后靠脏位延迟回写。这种组合对读写混合程序的命中率提升帮助很大。写直达 写不分配写操作永远不填 Cache直接穿透到内存。这种组合适合写流比较散且不会再访问的场景避免大量的写缺失反复污染 Cache。为什么很多处理器在 L1 数据 Cache 用写回 写分配而在 L2 或某些特殊硬件上用写直达 写不分配因为写直达能简化多核一致性实现数据永远是最新的不需要任何复杂的失效协议来同步脏数据代价是带宽和延迟。当 Cache 离处理器比较近时带宽是稀缺资源所以要写回当 Cache 离内存比较近、数据共享频率高时一致性变得更重要写直达的吸引力就上来了。数字逻辑设计里还有一点容易踩坑写分配策略直接影响 MSHR 的分配逻辑。写不分配模式下写缺失要查 MSHR 吗不需要。它直接被送往下一级。而写分配模式下写缺失要申请一个 MSHR 条目先取块。如果 MSHR 满了写操作会被挂起。这就意味着写分配的比例不能无脑拉高否则 MSHR 压力会非常大。我测过一个处理器原型写分配从 60% 提到 90% 以后L1 MSHR 平均占用率直接翻了一倍多有些测试用例甚至出现明显的流水线停顿。4.3 脏数据与一致性问题写回策略带来了脏块的存在也就引入了一致性问题。单核场景下脏块只要在替换时正确写回就行多核场景下问题就复杂多了——一个核写脏了某个块另一个核如果读了旧数据就会出错。解决这个问题的经典协议是 MESIModified-Exclusive-Shared-Invalid协议。它把 Cache 行的状态分为四类修改、独占、共享、失效。MESI 的实现细节非常复杂每一行除了有效位、脏位还要增加额外的状态位状态转移逻辑本身就是数字设计里的一块硬骨头。每个总线事务读请求、写请求、失效广播等都要触发状态的查询和跳转如果状态机设计不对很容易出现死锁或数据不一致的严重 bug。我在设计验证阶段碰到的真实案例是一条 store 指令命中了一个处于 Shared 状态的 Cache 行处理器本应向总线广播失效其他核副本的信号由于状态机漏了一个分支导致另一个核在稍后读到了旧值。这类问题在仿真中很难 100% 暴露等到跑一致性测试集比如 x86 的 TSO 测试时才会现出原形。所以我的经验是写回 多核一致性这套组合必须在 RTL 阶段就引入形式化验证工具只靠定向测试远远不够。5. 工业界实例从 ARM Cortex-A77 到 Intel 的 L1/L2 设计前面讲了原理和取舍这一节我们来看几个真实处理器的 Cache 配置。看完你会意识到所谓最优配置从来都是针对特定市场目标和功耗预算的产物。5.1 一个典型的移动端高性能核配置以 ARM Cortex-A77 为例这是 2019 年发布的面向旗舰手机和笔记本的处理器核。它的 Cache 配置大致如下L1 指令 Cache32KB4 路组相联块大小 64BL1 数据 Cache32KB也有配置 64KB4 路组相联块大小 64BL2 Cache256KB 到 512KB8 路组相联L3 Cache聚合在簇内通常 512KB 到 4MB16 路组相联你看移动端核的 L1 是 32KB / 4 路这和我前面说的性价比甜点完全一致。L2 容量不大因为移动端面积和功耗都要省L3 容量做到了几 MB用来容纳更大的工作集。Cortex-A77 的 L1 数据 Cache 访问延迟是 4 周期在一次 4 路组相联的标签比较 数据选择下做到 4 周期已经非常了不起L2 命中延迟大约 11 到 14 周期。对比一下本文开头的基本数据L1 命中 4-5 周期、L2 命中 12-15 周期你会发现工业界确实在这个范围内反复试探。5.2 高性能桌面/服务器核的取向差异再看 Intel 的 Sunny Cove 微架构用于 Ice Lake 及后续桌面处理器它的配置思路就不太一样L1 数据 Cache48KB12 路组相联L1 指令 Cache32KB8 路L2 Cache512KB8 路L3 Cache每核共享通常 1MB 到数 MB 不等12 到 16 路L1 数据 Cache 的 48KB / 12 路搭配是 Intel 的特色。48KB 这个数字很奇怪不是 32KB 也不是 64KB它是在访问延迟仍然保持 4 到 5 个周期和命中率之间折中的产物。12 路的相联度比 ARM 激进得多目的就是进一步压低冲突缺失——桌面端和服务器端的工作负载很多是数据库、编译、浏览器这种大规模、多线程的程序对冲突缺失非常敏感。这里的对照很有启发同样是最优的 L1 设计移动端选了 32KB/4 路桌面端选了 48KB/12 路。原因不在于谁的技术更强而在于面积和功耗预算完全不同——移动端的片子塞了基带、GPU、NPU留给 CPU Cache 的开销很小桌面端的散热和供电条件好得多多花一些面积在 L1 上换来单线程性能提升非常划算。5.3 从这些配置里可以读出什么共同趋势不管 ARM 还是 Intel不管移动端还是桌面端以下几个趋势是一致的块大小普遍 64B和 DRAM 突发粒度对齐性价比最高。L1 相联度在 4 到 12 路之间既要控制比较器面积又要降低冲突缺失。L2 相联度普遍 8 路L2 容量已经比较大冲突概率相对低8 路足够。L3 用大容量、高相联度因为 L3 命中延迟已经很高几十个周期多一个比较器周期根本无所谓但高相联度对多核共享负载的命中率帮助明显。写回 写分配是绝对主流能省带宽就省带宽省下来的资源可以留给读请求。如果你是做数字逻辑课程设计或者 FPGA 上面的小处理器我建议你一开始不要模仿这种复杂配置。先做一个 16KB 直接映射 写回 写分配的简单 Cache跑通验证流程后再逐步改成 4 路组相联、加入 MSHR 和预取。每一步都要有性能数据支撑不要一上来就堆高级特性。6. 常见误区与工程选型最后这部分我想认真聊几个容易被误解的坑。这些都是我在面试、审代码、做技术支持时反复见到的问题本质上都是因为把不同层面的缓存概念混为一谈。6.1 二分查找 Cache 命中率很高为什么性能差这是一个非常经典的面试题但实际工程里也经常踩。很多人以为二分查找的时间复杂度是 O(log n)数据量一大就能碾压线性扫描。但在现代 CPU 上对于某些规模的数组线性扫描可能反而更快。原因在于 Cache。线性扫描是顺序访问空间局部性极好CPU 的硬件预取器能完美预测下一步访问的地址Cache 缺失率极低而且缺失代价被预取完全隐藏了。二分查找呢每次跳到一个新的地址这个地址和前一次访问几乎毫无关系——时间局部性和空间局部性全部失效每一次访问几乎都是一次 Cache miss。数据量越大、Cache 越小二分查找的访存劣势越明显。这个例子告诉大家优化不是机械地选复杂度低的算法而是要结合存储层次的行为特征。Cache 友好的程序有时候算法复杂度更高实测却更快。这也是我在体系结构课上反复强调的分析程序性能时不能只看指令条数还要看访存行为。6.2 页缓存与处理器 Cache 是同一层吗页缓存也好Linux 的 page cache 也好它管的是操作系统虚拟内存和文件系统的页面缓存位于 DRAM 之上、磁盘之下。它缓解的是磁盘太慢的问题和 CPU 与内存之间的延迟鸿沟完全是两码事。这两者当然会协同工作程序先读到 page cache如果有然后通过 CPU Cache 访问其中的数据。但它们的优化手段完全不同。page cache 的优化核心是减少磁盘 I/O 次数、提高缓存命中率靠的是操作系统的页面替换算法LRU 等CPU Cache 的优化核心是容量、关联度、块大小和预取策略靠的是硬件设计。做系统调优时第一件事是分清瓶颈在哪一层是程序访问不到 page cacheI/O 瓶颈还是 page cache 命中了但 CPU Cache 一直 miss延迟瓶颈这两者的解法完全不同。前者考虑换用更大的内存做文件缓存、调整预读参数后者可能需要改数据结构、做循环分块loop blocking来提升时间/空间局部性。6.3 KV Cache 和 CPU Cache 是一回事吗近几年大模型火了KV Cache这个词频繁出现在推理优化的语境里。很多做应用层的人一听Cache就以为是 CPU 里那个 Cache其实是两回事。KV Cache 是大语言模型推理时用于缓存注意力机制的 Key 和 Value 矩阵的存储结构。它的目的是避免在生成每一个 token 的时候重复计算前面所有 token 的 Key 和 Value属于计算结果复用层面的缓存。它运行在 GPU 的显存或系统内存里优化手段主要是显存管理、分页类似操作系统虚拟内存和缓存淘汰策略和 CPU 芯片内部的 Cache 存储层次完全是两个世界的概念。但它们可以出现在同一条链路里GPU 里有自己的 Cache 层次当 KV Cache 从显存中被反复读取时GPU 的存储访问模式同样会影响性能。所以做大模型推理优化时既要考虑 KV Cache 本身的命中率也要考虑它对底层 GPU 内存带宽和 Cache 的冲击。别把这两个Cache混为一谈。6.4 自己在做 Cache 相关优化时的思考顺序基于这些年的经验我把 Cache 优化的思考顺序整理成一张清单每次拿到性能问题都会先过一遍定量分析访存特征用 perf 或者仿真器统计程序的 miss rate、LLC miss 率、平均访存延迟。没有数据一切优化都是空谈。判断瓶颈类型是容量缺失主导工作集 Cache 容量还是冲突缺失主导映射冲突严重还是强制性缺失主导首次访问过多不同类型的解决手段完全不一样。在算法层面调整访存模式改数据结构做空间局部性比如把结构体改为 SoA 布局做循环分块减少跳跃式访问。这一步是成本最低、收益最明显的优化。再考虑硬件参数如果算法已经没有优化空间再调整 Cache 容量、相联度、块大小、预取策略。这一步在芯片设计阶段做在纯软件层面只能通过系统配置或平台特性去影响。关注写入路径写命中/写缺失的处理方式是否合理写缓冲有没有打满写回带宽有没有和读缺失抢资源这套顺序帮我解决过不少看起来很玄的性能问题。说白了Cache 优化的本质就是让数据尽量靠近使用它的地方并且以合适的粒度搬运。无论是芯片里的电路还是系统层面的缓存底层逻辑都是同一个靠近、复用、提前准备好。把这个原则想清楚Cache 优化就不再是玄学而是一套可以量化和复盘的工程方法。