这篇主要讲一下高速缓存,涉及到高速缓存的几种形式,缓存友好代码注意事项,多处理器下缓存的同步机制。
文章目录
- 存储器层次结构
- 高速缓存存储器
- 通用的高速缓存存储器组织结构
- 直接映射高速缓存
- 组选择
- 行匹配
- 字选择
- 不命中时的行替换
- 冲突不命中
- 组相联高速缓存
- 组选择
- 行匹配
- 字选择
- 行替换
- 全相联高速缓存
- 组选择
- 行匹配
- 字选择
- 行替换
- 高速缓存写操作
- 高速缓存层次结构
- 高速缓存参数的性能影响
- 高速缓存大小的影响
- 块大小的影响
- 相联度的影响
- 写策略的影响
- 编写高速缓存友好的代码
- 多处理器下的缓存一致性协议
- 总线嗅探
- MESI 协议
- 协议状态定义
- 协议状态迁移
- 参考资料
存储器层次结构
广义上的缓存的中心思想是,以更快更小的存储设备作为更大更慢的存储设备的缓存。比如L1 Cache可以说是L2 Cache的缓存,L3 Cache可以说是主存的缓存,主存可以说是磁盘的缓存,那么所有存储设备构成金字塔形的存储器层次结构。
为什么不直接用更快的内存呢?
主要原因是较慢的存储设备比较快的存储设备更便宜,而且因为程序倾向于展示局部性,使得基于缓存的存储器层次结构效果很好:
- 时间局部性:同一数据对象可能在一定时间内被多次使用。一旦一个数据对象在第一次不命中时被复制到缓存中,我们就会期望后面对该目标有一系列的访问命中。因为缓存比低一层的存储设备更快,对后面的命中的服务会比最开始的不命中快很多。
- 空间局部性:空间局部性就是访问了当前数据后,相邻的其它数据也很快被访问到。那么我们期望后面对该块中其他对象的访问能够补偿不命中后复制该块的花费。
下表更详细地展示了各种类型缓存的信息,可以看到从 高速缓存->虚拟内存->磁盘缓存 有很大数量级的延迟的增加。
高速缓存存储器
从上表中可以看到,高速缓存延迟还是很低的,L1 Cache 4个时钟周期就能访问到,下面主要讲解几种不同高速缓存组织结构。
通用的高速缓存存储器组织结构
考虑一个计算机系统,其中每个存储器地址有 m m m 位,形成 M = 2 m M=2^m M=2m 个不同的地址。如下图所示,这样一个机器的高速缓存被组织成一个有 S = 2 s S=2^s S=2s 个高速缓存组(cache set)的数组。每个组包含 E E E 个高速缓存行(cache line)。每个行是由一个 B = 2 b B=2^b B=2b 字节的数据块(block)组成的,一个有效位(valid bit)指明这个行是否包含有意义的信息, t = m − ( b + s ) t = m - (b + s) t=m−(b+s) 个标记位(tag bit)(是当前块的内存地址的位的一个子集),它们唯一地标识存储在这个高速缓存行中的块。
一般而言,高速缓存的结构可以用元组
(
S
,
E
,
B
,
m
)
(S, E, B, m)
(S,E,B,m) 来描述。高速缓存的大小(或容量)
C
C
C指的是所有块的大小的和。标记位和有效位不包括在内。因此,
C
=
S
×
E
×
B
C=S\times E \times B
C=S×E×B。
当一条加载指令指示 CPU 从主存地址 A A A 中读一个字时,它将地址 A A A 发送到高速缓存。如果高速缓存正保存着地址 A A A 处那个字的副本,它将立即将那个字发送回 CPU。现在的问题就是怎么知道高速缓存是否包含地址 A A A 处那个字的副本呢?
参数 S S S 和 B B B 将 m m m 个地址位分为了三个字段, A A A 中 s s s 个组索引位是 组0~组S-1 的索引。组索引位被解释为一个无符号整数,它告诉我们这个字必须存储在哪个组中。
一旦我们知道了这个字必须放在哪个组中, A A A 中的 t t t 个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址 A A A 中的标记位相匹配时,组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么 b b b 个块偏移位给出了在 B B B 个字节的数据块中的字偏移。
根据每个组的高速缓存行数 E E E,高速缓存被分为不同的类。
直接映射高速缓存
每个组只有一行(
E
=
1
E=1
E=1)的高速缓存称为直接映射高速缓存(direct-mapped cache)。
当 CPU 执行一条读内存字
w
w
w 的指令,它向 L1 高速缓存请求这个字。如果 L1 高速缓存有
w
w
w 的一个缓存的副本,那么就得到 L1 高速缓存命中,高速缓存会很快抽取出
w
w
w,并将它返回给 CPU。否则就是缓存不命中,当 L1 高速缓存向主存请求包含
w
w
w 的块的一个副本时,CPU 必须等待。当被请求的块最终从内存到达时,L1 高速缓存将这个块存放在它的一个高速缓存行里,从被存储的块中抽取出字
w
w
w,然后将它返回给 CPU。高速缓存确定一个请求是否命中,然后抽取出被请求的字的过程,分为三步:1)组选择;2)行匹配;3)字抽取。
组选择
高速缓存从
w
w
w 的地址中间抽取出
s
s
s 个组索引位。这些位对应这高速缓存中的组。
行匹配
一个是看有效位是否为 1,再看标记位是否匹配。
字选择
行匹配上就是缓存命中了,最后一步就是确定所需要的字在块中是从哪里开始的。如上图所示,块偏移位提供了所需要的字的第一个字节的偏移。比如上面的块偏移位是 10 0 2 100_2 1002,它表明 w w w 的副本是从块中的字节 4 开始的(这里假设字长位 4 字节)。
不命中时的行替换
如果缓存不命中,那么它需要从存储器层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位指示的组中的一个高速缓存行中。一般而言,如果组中都是有效高速缓存行了,那么必须要驱逐出一个现存的行。对于直接映射高速缓存来说,每个组只包含有一行,替换策略非常简单:用新取出的行替换当前的行。
冲突不命中
考虑下面这个计算两个向量点积的函数:
float dotprod(float x[8], float y[8])
{
float sum = 0.0;
int i;
for (i = 0; i < 8; i++)
sum += x[i] * y[i];
return sum;
}
对于 x x x 和 y y y 来说,这个函数有良好的空间局部性,因此我们期望它的命中率会比较高。不幸的是,并不总是如此。
假设浮点数是 4 个字节,
x
x
x 被加载到从地址 0 开始的 32 字节连续内存中,而
y
y
y 紧跟在
x
x
x 之后,从地址 32 开始。为了简便,假设一个块是16 个字节(足够容纳 4 个浮点数),高速缓存由两个组组成,高速缓存的整个大小为 32 字节。
这里假设变量 sum
实际存放在一个 CPU 寄存器中,因此不需要内存引用。根据这些假设每个 x[i]
和 y[i]
会映射到相同的高速缓存组:
在运行时,循环的第一次迭代引用 x[0]
,缓存不命中会导致包含 x[0]
~ x[3]
的块被加载到组 0。接下来是对 y[0]
的引用,又一次缓存不命中,导致包含 y[0]
~ y[3]
的块被复制到组 0,覆盖前一次引用复制进来的 x
的值。在下一次迭代中,对 x[1]
的引用不命中,导致 x[0]
~ x[3]
的块被加载回组 0,覆盖掉 y[0]
~ y[3]
的块。因而现在我们就有了一个冲突不命中,而且实际上后面每次对 x
和 y
的引用都会导致冲突不命中,因为我们在 x
和 y
的块之间抖动(thrash)。即高速缓存反复地加载和驱逐相同的高速缓存块的组。
简要来说,即使程序有良好的空间局部性,而且我们的高速缓存中也有足够的空间来存放 x[i]
和 y[i]
的块,每次引用还是会导致冲突不命中,这是因为这些块被映射到了同一个高速缓存组。这种抖动导致速度下降 2 或 3 倍并不稀奇。
我们可以手动修正抖动问题。比如对于上面的例子,一个简单的方法是在每个数组的结尾放
B
B
B 字节的填充。例如,不是将 x
定义为 float x[8]
,而是定义成 float x[12]
。假设在内存中 y
紧跟在 x
后面,我们有下面这样的从数组元素到组的映射:
在 x
结尾加了填充,x[i]
和 y[i]
现在就映射到了不同的组,消除了抖动冲突不命中。
组相联高速缓存
直接映射高速缓存中冲突不命中造成的问题源于每个组只有一行(或者,按照我们的术语描述就是 E = 1 E=1 E=1)这个限制。
组相联高速缓存(set associative cache)放松了这条限制,所以每个组都保存有多于一个的高速缓存行。一个
1
<
E
<
C
/
B
1 \lt E \lt C/B
1<E<C/B 的高速缓存通常称为
E
E
E 路组相联高速缓存。下面为一个 2 路组相联高速缓存的结构。
组选择
组选择与直接映射高速缓存的组选择一致,组索引位标识组。
行匹配
组相联高速缓存中的行匹配比直接映射高速缓存中的更复杂,因为它必须检查多个行的标记位和有效位,以确定所请求的字是否在集合中。
其实就是匹配的组,任意一行都可以包含该请求的字,所以高速缓存必须搜索组中的每一行,寻找一个有效的行,其标记与地址中的标记相匹配。如果高速缓存找到了这样一行,那么我们就命中。
字选择
命中后的字选择,和直接映射的高速缓存中的字选择是一样的。具体也可以看上图。
行替换
组相联的高速缓存在未命中后的行替换比直接映射的要复杂,主要在于如何需要选择一个合适的行替换掉:
- 如果该组中有空行,那么替换掉空行
- 如果没有空行,且需要采取一些策略进行策略,这块感觉和物理内存页的淘汰页选择差不多,也是有随机选择、LFU、LRU等替换策略,当然这些策略需要额外的时间和硬件(比如LRU中需要记录使用标记)。
全相联高速缓存
全相联高速缓存(fully associative cache)是由一个包含所有高速缓存行的组(即
E
=
C
/
B
E=C/B
E=C/B )组成的。下图为其基础结构。
组选择
全相联高速缓存中的组选择非常简单,因为只有一个组,所以地址中不需要组索引位,地址只被划分成了一个标记和一个块偏移。
行匹配
全相联高速缓存中的行匹配与组相联中的是一致的,唯一的不同是全相联的组包含的行数多,那么匹配需要花的时间也更多。
因为高速缓存电路必须并行地搜索许多相匹配的标记,构造一个又大又快的相联高速缓存很困难,而且很昂贵。因此,全相联高速缓存只适合做小的高速缓存,例如虚拟内存系统中的翻译备用缓冲器(TLB),它缓存页表项。
字选择
命中后的字选择,和组相联和直接映射的方法是一致的。
行替换
未命中后的行替换,与组相联的行替换方法是一致的。
高速缓存写操作
上面讨论都是基于高速缓存的读操作的。写操作相对更复杂一些。
假如我们要写一个已经缓存了的字 w w w(写命中,write hit)。在高速缓存更新了它的 w w w 的副本之后,怎么更新 w w w在层次结构中紧接着低一层中的副本呢?
-
直写(write-through)
立即将 w w w 的高速缓存块写回到紧接着的低一层中。
直写很简单,但是缺点是每次写都会引起总线流量。 -
写回(write-back)
与直写不同,写回策略,尽可能地推迟更新(有点懒加载的意思了),只有当替换算法要驱逐这个更新过的块时,才把它写到紧接着的低一层中。由于局限性,写回能显著地减少总线流量,但是它的缺点是增加了复杂性。高速缓存必须为每个高速缓存行维护一个额外的修改位(dirty bit),表明这个高速缓存块是否被修改过。
另一个问题是如何处理写不命中。
-
写分配(write-allocate)
加载相应的低一层中的块到高速缓存中,然后更新这个高速缓存块。写分配试图利用写的空间局部性,但是缺点是每次不命中都会导致一个块从第一层传送到高速缓存。 -
非写分配(not-write-allocate)
避开高速缓存,直接把这个字写到低一层中。
直写高速缓存通常是非写分配的,写回高速缓存通常是写分配的。
高速缓存层次结构
一个需要说明的点是,高速缓存不仅保存数据,也保存指令:
- 只保存指令的高速缓存称为 i-cache
- 只保存程序数据的高速缓存称为 d-cache
- 既保存指令又包括数据的高速缓存称为统一的高速缓存(unified cache)。
现代的处理包括独立的 i-cache 和 d-cache。这样做有很多原因,有两个独立的高速缓存,处理器能够同时读一个指令字和一个数据字。i-cache 通常是只读的,因此比较简单。通常会针对不同的访问模式来优化这两个高速缓存,它们可以有不同的块大小,相联度和容量。使用不同的高速缓存也确保了数据访问不会与指令访问形成冲突不命中,反过来也是一样,代价就是可能会引起容量不命中增加。
下图给出了 Intel Core i7 处理器的高速缓存层次结构。每个 CPU 芯片有四个核。每个核有自己私有的 L1 i-cache、L1 d-cache 和 L2 统一的高速缓存。所有的核共享片上 L3 统一的高速缓存。这个层次结构的一个有趣的特性是所有的 SRAM 高速缓存存储器都在 CPU 芯片上。
下面为对应高速缓存类型的相关信息
高速缓存参数的性能影响
有许多指标来衡量高速缓存的性能:
- 不命中率(miss rate)。在一个程序执行或程序的一部分执行期间,内存引用不命中的比率。它是这样计算的:不命中数量/引用数量。
- 命中率(hit rate)。命中的内存引用比率。它等于 1 - 不命中率
- 命中时间(hit time)。从高速缓存传送一个字到 CPU 所需的时间,包括组选择、行确认和字选择的时间。对于 L1 高速缓存来说,命中时间的数量级是几个时钟周期。
- 不命中处罚(miss penalty)。由于不命中所需要的额外的时间,L1 不命中需要从 L2 得到服务的处罚,通常是数 10 个周期;从 L3 得到服务的处罚,50个周期;从主存得到的服务的处罚,200 个周期。
优化高速缓存的成本和性能的折中是一项很精细的工作,它需要在现实的基准程序代码上进行大量的模拟,下面主要一些大的方向上的影响。
高速缓存大小的影响
一方面,较大的高速缓存可能会提高命中率。
另一方面,使大存储器运行得更快总是要难一些的。结果,较大的高速缓存可能会增加命中时间。这解释了为什么 L1 高速缓存比 L2 高速缓存小,以及为什么 L2 高速缓存比 L3 高速缓存小。
块大小的影响
大的块有利有弊。
一方面,较大的块能利用程序中可能存在的空间局部性,帮助提高命中率。
另一方面,对于给定的高速缓存大小,块越大就意味着高速缓存行数越少,这会损害时间局部性比空间局部性更好的程序中的命中率。
此外,较大的块对不命中处罚也有负面影响,因为块越大,传送时间就越长。
现代系统(如 Core i7)会折中使高速缓存块包含 64 个字节。
相联度的影响
参数 E E E 选择的影响, E E E 是每个组中高速缓存行数。较高的相联度(也就是 E E E 的值较大)的优点是降低了高速缓存由于冲突不命中出现抖动的可能性。不过,较高的相联度会造成较高的成本。较高的相联度实现起来很昂贵,而且很难使之速度变快。每一行需要额外的 LRU 状态位和额外的控制逻辑。
较高的相联度会增加命中时间,因为复杂性增加了,另外,还会增加不命中处罚,因为选择牺牲行的复杂性也增加了。
相联度的选择最终变成了命中时间和不命中处罚之间的折中。传统上,努力争取时钟频率的高性能系统会为 L1 高速缓存选择较低的相联度(这里的不命中处罚只是几个周期),而在不命中处罚比较高的较低层上使用比较小的相联度。例如,Intel Core i7 系统中,L1 和 L2 高速缓存是 8 路组相联的,而 L3 高速缓存是 16 路组相联的。
写策略的影响
直写高速缓存比较容易实现,且能使用独立于高速缓存的写缓冲区(write buffer),用来更新内存。
此外,读不命中开销没那么大,因为它们不会触发内存写。另一方面,写回高速缓存引起的传送比较少,它允许更多的到内存的带宽用于执行DMA的I/O设备。此外,越往层次结构下面走,传送时间增加,减少传送的数量就变得更加重要。
一般而言,高速缓存越往下层,越可能使用写回而不是直写。
编写高速缓存友好的代码
从上面的内容也能看出,缓存不命中的开销随着层级的下降而数量级的上升,且局部性比较好的程序更容易有较低的不命中率,而不命中率较低的程序往往比不命中率较高的程序运行的更快。
下面是几条确保代码高速缓存友好的基本方法:
- 让最常见的情况运行得快。程序通常把大部分时间都花在少量的核心函数上,而这些函数通常把大部分时间都花在了少量循环上。所以要把注意力集中在核心函数里的循环上,而忽略其他部分。
- 尽量减小每个循环内部的缓存不命中数量。在其他条件(例如加载和存储的总次数)相同的情况下,不命中率较低的循环运行得更快。
下面看一个实例,我们有这样一个顺序求和的函数:
int sumvec(int v[N])
{
int i, sum = 0;
for (i = 0; i < N; i++)
sum += v[i];
return sum;
}
这个函数高速缓存友好吗?首先,注意对于局部变量 i
和 sum
,循环体有良好的时间局部性。实际上,因为它们都是局部变量,任何合理的优化编译器都会把它们缓存在寄存器文件中,也就是存储器层次结构的最高层中。然后考虑向量 v
的步长为 1 的引用。一般而言,如果一个高速缓存的块大小为
B
B
B 字节,那么一个步长为
k
k
k 的引用模式(这里
k
k
k 是以字为单位的)平均每次循环迭代会有
m
i
n
(
1
,
(
w
o
r
d
s
i
z
e
×
k
)
/
B
min(1, (wordsize \times k)/B
min(1,(wordsize×k)/B次缓存不命中。当
k
=
1
k=1
k=1 时,它取最小值,所以对
v
v
v 的步长为 1 的引用是高速缓存友好的。
假如,
v
v
v 是块对齐的,字为 4 个字节,高速缓存块为 4 个字,而高速缓存初始为空(冷高速缓存)。然后,无论是什么样的高速缓存结构,对
v
v
v 的引用都会得到下面的命中和不命中模式:
对 v[0]
的引用会不命中,而相应的包含 v[0]
~ v[3]
的块会被从内存加载到高速缓存中。因此,接下来三个引用都会命中。对 v[4]
的引用会导致不命中,而一个新的块被加载到高速缓存中,接下来的三个引用都命中,依次类推。总的来说,四个引用中,三个会命中,在这种冷缓存的情况下,已经是最好的情况了。
上面sumvec
函数的例子体现了两个关于编写高速缓存友好的代码的重要问题:
- 对局部变量的反复引用是好的,因为编译器能够将它们缓存在寄存器文件中(时间局部性)。
- 步长为 1 的引用模式是好的,因为存储器层次结构中所有层次上的缓存都是将数据存储为连续的块。
在对多维数组进行操作的程序中,空间局部性尤其重要。例如,下面的 sumarrayrows
函数,它按照行优先顺序对一个二维数组的元素求和:
int sumarrayrows(int a[M][N])
{
int i, j, sum = 0;
for (i = 0; i < M; i++)
for (j = 0; j < N; j++)
sum += a[i][j];
return sum;
}
由于 C 语言以行优先顺序存储数组,所以这个函数中的内循环有与上一个例子 sumvec
一样好的步长为1 的访问模式。那么,假如对这个高速缓存做与对 sumvec
一样的假设。那么对数组 a
的引用会得到下面的命中和不命中模式:
但是如果我们做一个看似无伤大雅的改变 —— 交换循环的次序,看看会发生什么:
int sumarraycols(int a[M][N])
{
int i, j, sum = 0;
for (j = 0; j < N; j++)
for (i = 0; i < M; i++)
sum += a[i][j];
return sum;
}
现在是一列一列的扫描数组了,如果高速缓存够大,足够放入整个数组,那么没什么问题,还是与按行扫描一样
1
4
\frac{1}{4}
41 的不命中率。但是,如果数组比高速缓存要大(更可能出现这种情况,毕竟L1 Cache也就32KB的大小),那么每次对 a[i][j]
的访问都会不命中!而较高的不命中率对运行时间可以有显著的影响。例如,在桌面机器上,sumarrayrows
运行速度比 sumarraycols
快 25 倍。
最后,我们再通过一个案例来体现同一功能不同代码实现的高速缓存影响。
给定二维空间中 256 个点,计算它们的平均位置。在一台具有块大小为 16 字节( B = 16 B=16 B=16)、整个大小为 1024 字节的直接映射数据缓存的机器上计算不同代码实现的高速缓存性能。定义如下:
struct point_position {
int x;
int y;
};
struct point_position grid[16][16];
int total_x = 0, total_y = 0;
int i, j;
额外的一些设定:
sizeof(int)==4
也就是int
是 4 字节大小。grid
从内存地址 0 开始。- 这个高速缓存开始时是空的。
- 唯一的内存访问是对数组
grid
的元素的访问。变量i
、j
、total_x
和total_y
存放在寄存器中。
首先分析一下这个缓存,块大小为 16 字节(
B
=
16
B = 16
B=16),整个缓存大小为 1024 字节,那么一共有 64 块,一块能保存两个点的位置信息,比如,读到 a[i][0]
会把 a[i][1]
也读入缓存中。
先看第一种实现:
for (i = 0; i < 16; i++) {
for (j = 0; j < 16; j++) {
total_x += grid[i][j].x;
}
}
for (i = 0; i < 16; i++) {
for (j = 0; j < 16; j++) {
total_y += grid[i][j].y;
}
}
分析一下,读总数是 256 + 256 = 512;缓存不命中的读总数为 512 / 2 = 256;那么不命中率就是 50%。
再看第二种实现:
for (i = 0; i < 16; i++)
for (j = 0; j < 16; j++) {
total_x += grid[j][i].x;
total_y += grid[j][i].y;
}
这就是典型的缓存不友好代码,读总数仍然是 512,缓存不命中的读总数为 256,不命中率是 50%,如果缓存容量是两倍,也就是 2048 B,那么高速缓存就能把数组全装下,那么就能有 25% 的不命中率,扩大高速缓存大小一定程度能缓解缓存不友好代码产生的问题,但是总的来说,不能指望缓存把数组能全部装下。
最后看下第三种实现:
for (i = 0; i < 16; i++) {
for (j = 0; j < 16; j++) {
total_x += grid[i][j].x;
total_y += grid[i][j].y;
}
}
这就是典型的缓存友好型代码了,读总数 512;不命中的读总数 128;不命中率 25 %;两倍大的高速缓存,不命中率还是 25%,当然这是因为冷缓存的原因,具体也有硬件预取等手段缓解这个问题。
多处理器下的缓存一致性协议
之前讨论的场景都是在单处理器下,实际上现代计算机基本都是多核心多处理器架构,那么直接使用高速缓存有什么问题?
比如我们现在有两个核心,分别有一个线程,他们对同一个内存有引用,然后线程 1 修改了这块内存的数据,但是由于高速缓存的存在,且由于采用写回的机制,那么实际的修改还没有应用到内存上。此时线程 2 要读取这块内存的数据,那么实际上读取到的是内存里保存的旧数据了(或者叫脏数据),要是线程 2 也要写这块内存,那么也会产生相应的问题。
所以,我们需要一个方案,来解决多处理器下缓存的不一致问题。
简单地思考下,我们想要保证不同核心缓存的数据一致,那么需要保证做到以下 2 点:
- 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation);
- 第二点,某个 CPU 核心里对数据的操作顺寻,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(Transaction Serialization)。
对于事务的串行,可以通过一个例子体现,比如有四个核心,这四个核心引用同一块内存,或者说操作同一个变量 k,0 核心吧 k 变为 10,同时 1 核心把 k 变为 20,那么这两个修改都会 写传播 到 2 和 3 号核心。
那么,如果 2 号核心如果先收到 0 号核心更新数据的事件,再收到 1 号核心更新数据的事件,因此 2 号核心看到的 k 是先变成 10 再变为 20,而如果 3 号核心接收更新事件顺序正好相反,那么 各个核心对应 Cache 里保存的数据还是不一致的,我们想要保证 2 号核心和 3 号核心看到相同的数据变化,这样的过程就是事务的串行化。
总线嗅探
写传播最常见的实现方式是总线嗅探(Bus Snooping)。
继续以前面修改 k 变量值的例子,当 0 号核心修改了L1 Cache中 k 的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据再自己的 L1 Cache的里面,参考组相联模型,就是看对应组里面的所有行是否有对应的数据(看标记)。如果 1 号核心的 L1 Cache里有该数据,那么也需要把数据更新到自己的 L1 Cache。
总线嗅探方法的逻辑本身很简单,但需要CPU每时每刻监听总线上的一切活动,且不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
另外,总线嗅探也不能保证事务串行化。
MESI 协议
于是,有一个协议基于总线嗅探机制实现了事务串行化,也用状态机降低了总线带宽压力,这个就是1980 年提出的 MESI 协议。
协议状态定义
MESI 协议名字本身就是 Cache 缓存条目的 4 种状态:
- Modified(更改过的,记为 M):这就是代表该缓存块上的数据已经被更新过,但是还没有写到内存里。
- Exclusive(独占的,记为 E):独占和共享都代表这个内存块里的数据是干净的,与内存里的数据是一致的。独占和共享的差别在于,独占状态下,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 里没有该数据,这个时候,如果要向独占的 Cache 里写数据,可以直接自由地写入,而不需要通知其他 CPU 核心。
- Shared(共享的,记为 S):在独占状态下的数据,如果有其他核心从内存读取到了相同的数据到各自的 Cache,那么独占状态下的数据就会变成共享状态。
- Invalid(无效的,记为 I):表示这个内存块里的数据已经失效了,不可以读取该状态的数据。共享状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有其他的 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为 无效状态,然后再更新 Cache 里面的数据。
协议状态迁移
MESI 的状态可以用一个有限状态机来表示它的状态流转。
下面以一个具体的例子来看看 4 个状态之间的转换:
- 0 号核心从内存读取变量 k 的值,数据缓存在 0 号核心的 Cache 里,此时其他核心 Cache 里没有缓存该数据,于是标记 Cache Line 状态为 独占,此时其 Cache 和 内存里的数据是一致的。
- 1 号核心也从内存里读取变量 k 的值,此时会发消息个其他核心,由于 0 号核心已经缓存了该数据,所以会把数据返回给 1 号核心,这时 0 和 1 号核心都缓存了相同的数据,对应的 Cache Line 的状态会变成 共享,并且 Cache 中的数据和内存里的也是一致的。
- 0 号核心要修改 Cache 里 k 变量的值,发现数据对应的 Cache Line 的状态是共享的,那么要向其他所有核心广播一个请求,要求先把所有其他核心的 Cache 中对应的 Cache Line 标记为 无效,然后 0 号核心才更新 Cache 里的数据,同时标记 Cache Line 为 已修改的状态,此时 Cache 中的数据和内存里的就不一致了。
- 0 号核心继续修改 Cache 里 k 变量的值,由于此时已经是 已修改 的状态,因此也不需要给其他核心发送消息,直接更新数据即可。
- 0 号核心里 k 变量的 Cache Line 要被替换,发现 Cache Line 是已修改的状态,就会在替换前先把数据同步到内存。
所以,可以发现当 Cache Line 状态是 已修改 或 独占 状态时,修改更新数据不需要发送广播给其他核心,这在一定程度上减少了总线带宽压力,当然需要在 Cache 中有额外的位来保存这些状态信息。
参考资料
《深入理解计算机系统》
10 张图打开 CPU 缓存一致性的大门