如果你正在读这篇笔记,大概率是被 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,直接映射,那么 inde
