卢本伟牛逼,写得很好
https://wudaijun.com/2019/04/cpu-cache-and-memory-model/
本文主要谈谈CPU Cache的设计,内存屏障的原理和用法,最后简单聊聊内存一致性。
我们都知道存储器是分层级的,从CPU寄存器到硬盘,越靠近CPU的存储器越小越快,离CPU越远的存储器越大但越慢,即所谓存储器层级(Memory Hierarchy)。以下是计算机内各种存储器的容量和访问速度的典型值。
存储器类型 | 容量 | 特性 | 速度 |
---|---|---|---|
CPU寄存器 | 几十到几百Bytes | 数据电路触发器,断电丢失数据 | 一纳秒甚至更低 |
Cache | 分不同层级,几十KB到几MB | SRAM,断电丢失数据 | 几纳秒到几十纳秒 |
内存 | 几百M到几十G | DRAM,断电丢失数据 | 几百纳秒 |
固态硬盘(SDD) | 几十到几百G | SSD,断电不丢失数据 | 几十微秒 |
机械硬盘(HDD) | 上百G | 磁性介质和磁头,断电不丢失数据 | 几毫秒 |
从广义的概念上来说,所有的存储器都是其下一级存储器的Cache,CPU Cache缓存的是内存数据,内存缓存的是硬盘数据,而硬盘缓存的则是网络中的数据。本文只谈CPU Cache,一个简单的CPU Cache示意图如下:
图中忽略了一些细节,现代的CPU Cache通常分为三层,分别叫L1,L2,L3 Cache, 其中L1,L2 Cache为每个CPU核特有,L3为所有CPU核共有,L1还分为缓存指令的i-cache(只读)和缓存程序数据的d-cache,L2 L3 Cache则不区分指令和程序数据,称为统一缓存(unified cache)。本文主要讨论缓存命中和缓存一致性的问题,因此我们只关注L1 Cache,不区分指令缓存和程序数据缓存。
Cache Geometry
当CPU加载某个地址上的数据时,会从Cache中查找,Cache由多个Cache Line构成(通常L1 Cache的Cache Line大小为64字节),因此目标地址必须通过某种转换来映射对应的Cache Line,我们可以很容易想到两种方案:
- 指定地址映射到指定Cache Line,读Cache时对地址哈希(通常是按照Cache Line数量取模,即在二进制地址中取中间位)来定位Cache Line,写Cache时如果有冲突则丢掉老的数据。这种策略叫直接映射
- 任何地址都可以映射到任何Cache Line,读Cache时遍历所有Cache Line查找地址,写Cache时,可以按照LFU(最不常使用)或LRU(最近最少使用)等策略来替换。这种策略叫全相联
直接映射的缺点在于在特定的代码容易发生冲突不命中,假设某CPU Cache的Cache Line大小为16字节,一共2个Cache Line,有以下求向量点乘的代码:
1 |
// 代码片段1 |
由于x和y在函数栈中是连续存放的,x[0..3]
和y[0..3]
将映射到同一个Cache Line, x[4..7]
和y[4..7]
被映射到同一个Cache Line,那么在for循环一次读取x[i]
,y[i]
的过程中,Cache Line将不断被冲突替换,导致Cache “抖动”(thrashing)。也就是说,在直接映射中,即使程序看起来局部性良好,也不一定能充分利用Cache。
那么同样的例子,换成全相联,则不会有这个问题,因为LRU算法会使得y[0..3]
不会替换x[0..3]
所在的Cache Line,也就不会造成Cache抖动。全相连的缺点是由于每一次读Cache都需要遍历所有的Cache Line进行地址匹配,出于效率考虑,它不适用于太大的Cache。
So,现代OS的操作系统是取两者折中,即组相连结构: 将若干Cache Line分为S个组,组间直接映射,组内全相连,如下图:
通用的Cache映射策略,将目标地址分为t(标记位),s(组索引),b(块偏移)三个部分。我在Linux Perf 简单试用中也有例子说明程序局部性对效率的影响。
Cache Coherency
前面我们谈的主要是Cache的映射策略,Cache设计的最大难点其实在于Cache一致性: 即所有CPU看到的指定地址的值是一致的。比如在CPU尝试修改某个地址值时,其它CPU可能已有该地址的缓存,甚至可能也在执行修改操作。因此该CPU需要先征求其它CPU的”同意”,才能执行操作。这需要给各个CPU的Cache Line加一些标记(状态),辅以CPU之间的通信机制(事件)来完成, 这可以通过MESI协议来完成。MESI是以下四个状态的简称:
M(modified): 该行刚被 CPU 改过,并且保证不会出现在其它CPU的Cache Line中。即CPU是该行的所有者。CPU持有该行的唯一正确参照。
E(exclusive): 和M类似,但是未被修改,即和内存是一致的,CPU可直接对该行执行修改(修改之后为modified状态)。
S(shared): 该行内容至少被一个其它CPU共享,因此该CPU不能直接修改该行。而需要先与其它CPU协商。
I(invalid): 该行为无效行,即为空行,前面提到Cache策略会优先填充Invalid行。
除了状态之外,CPU还需要一些消息机制:
Read: CPU发起读取数据请求,请求中包含需要读取的数据地址。
Read Response: 作为Read消息的响应,该消息可能是内存响应的,也可能是某CPU响应的(比如该地址在某CPU Cache Line中为Modified状态,该CPU必须返回该地址的最新数据)。
Invalidate: 该消息包含需要失效的地址,所有的其它CPU需要将对应Cache置为Invalid状态
Invalidate Ack: 收到Invalidate消息的CPU在将对应Cache置为Invalid后,返回Invalid Ack
Read Invalidate: 相当于Read消息+Invalidate消息,即取得数据并且独占它,将收到一个Read Response和所有其它CPU的Invalid Ack
Writeback: 写回消息,即将状态为Modified的行写回到内存,通常在该行将被替换时使用。现代CPU Cache基本都采用”写回(Write Back)”而非”直写(Write Through)”的方式。
1 |
思考: 为什么要有专门的Read Invalidate消息,而不直接用Read + Inv |