原子操作与内存屏障之一——CPU缓存
CPU缓存
缓存原理
首先,我们都知道现在的CPU多核技术,都会有几级缓存,老的CPU会有两级内存(L1和L2),新的CPU会有三级内存(L1,L2,L3 ),如下图所示:
其中:
- L1缓分成两种,一种是指令缓存,一种是数据缓存;L2缓存和L3缓存不分指令和数据。
- L1和L2缓存在每一个CPU核中,L3则是所有CPU核心共享的内存。
- 访问速度 L1 > L2 > L3;存储大小 L3 > L2 > L1;
例如:Intel Core i7-8700K ,是一个6核的CPU,每核上的L1是64KB(数据和指令各32KB),L2 是 256K,L3有2MB。
CPU Cache再往后面就是内存(RAM),内存的后面就是硬盘(HD)。我们来看一些他们的速度:
- L1 的存取速度:4 个CPU时钟周期
- L2 的存取速度: 11 个CPU时钟周期
- L3 的存取速度:39 个CPU时钟周期
- RAM内存的存取速度:107 个CPU时钟周期
我们可以看到,L1的速度是RAM的27倍,但是L1/L2的大小基本上也就是KB级别的,L3会是MB级别的。
CPU访问数据就从内存向上,先到L3,再到L2,再到L1,最后到寄存器进行CPU计算。
对于CPU来说,它是不会一个字节一个字节的加载的,因为这非常没有效率,一般来说都是要一块一块的加载的,对于这样的一块一块的数据单位,术语叫“Cache Line”。
一般来说,一个主流的CPU的Cache Line 是 64 Bytes,64Bytes也就是16个int值,这就是CPU从内存中存取数据的最小单位。
N-Way关联
因为Cache的大小远远小于内存,所以,需要有一种地址关联的算法,能够让内存中的数据可以被映射到Cache中来。而且这种关联算法还需要能够高效的查找cache是否存在某个对象。
N-Way 关联,就是把连续的N个Cache Line绑成一组,例如L1 Cache有32KB,那就包含32KB/64B = 512条Cache Line,如果N=8,表示有8路,那么每路包含512/8=64 条Line。
如下图,列表示Way(共8列),行表示Cache Line(共64行),为了方便索引内存地址,
- Tag:每条 Cache Line 前都会有一个独立分配的 24 bits来存的 tag,其就是内存地址的前24bits(定位列)
- Index:内存地址后续的6个bits则是在这一Way的是Cache Line 索引,2^6 = 64 刚好可以索引64条Cache Line(定位行)
- Offset:再往后的6bits用于表示在Cache Line 里的偏移量,2^6=64 刚好索引Line的64字节(定位Cache line内的起始位置)
当拿到一个内存地址的时候,先拿出中间的 6bits 来,找到对应的index,然后,在这一个8组的cache line中,再进行O(n) n=8 的遍历,主是要匹配前24bits的tag。如果匹配中了,就算命中,如果没有匹配到,那就是cache miss,如果是读操作,就需要进向后面的缓存进行访问了。L2/L3同样是这样的算法。而淘汰算法有两种,一种是随机一种是LRU。
也就是说,当CPU要访问一个内存的时候,先通过这个内存地址中间的6bits 定位是哪个index(行),通过前 24bits 定位相应的Way(列),这样就匹配一条Cache Line。
与HashTable类似,可以把index看做hash地址,Way看做hash冲突的挂链表方式,也就是这是一个长度为64的Hash表,同一个hash地址的单链表最长为8。
此外,当有数据没有命中缓存的时候,CPU就会以最小为Cache Line的单元向内存更新数据。当然,CPU并不一定只是更新64Bytes,因为访问主存实在是太慢了,所以,一般都会多更新一些。好的CPU会有一些预测的技术,如果找到一种pattern的话,就会预先加载更多的内存,包括指令也可以预加载,这叫 Prefetching 技术,参考例子。
缓存更新
read/write through
- Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),则由缓存服务自己来加载,对应用方是透明的;
- Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新backend(如数据库),然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)。
PS:这里的Cache如果指 L1,那么lower memory就是指L2,如果Cache是L2,那么lower memory就是L3或者RAM。
write back
Write Back在更新数据的时候,只更新缓存,不更新backend,而我们的缓存会异步地批量更新backend。因为异步,write back还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。但是,其带来的问题是,数据不是强一致性的,而且可能会丢失。
另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上(lazy write)。
一般来说,主流的CPU(如:Intel Core i7/i9)采用的是Write Back的策略,因为直接写内存实在是太慢了。
好了,现在问题来了,如果有一个数据 x 在 CPU 第0核的缓存上被更新了,那么其它CPU核上对于这个数据 x 的值也要被更新,这就是缓存一致性的问题。(当然,对于我们上层的程序我们不用关心CPU多个核的缓存是怎么同步的,这对上层的代码来说都是透明的)。
缓存一致性
一般来说,在CPU硬件上,会有两种方法来解决这个问题
- Directory 协议。这种方法的典型实现是要设计一个集中式控制器,它是主存储器控制器的一部分。其中有一个目录存储在主存储器中,其中包含有关各种本地缓存内容的全局状态信息。当单个CPU Cache 发出读写请求时,这个集中式控制器会检查并发出必要的命令,以在主存和CPU Cache之间或在CPU Cache自身之间进行数据同步和传输。
- Snoopy 协议。这种协议更像是一种数据通知的总线型的技术。CPU Cache通过这个协议可以识别其它Cache上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它CPU Cache。这个协议要求每个CPU Cache 都可以“窥探”数据事件的通知并做出相应的反应。如下图所示,有一个Snoopy Bus的总线。
因为Directory协议是一个中心式的,会有性能瓶颈,而且会增加整体设计的复杂度。而Snoopy协议更像是微服务+消息通讯,所以,现在基本都是使用Snoopy的总线的设计。
在分布式系统中我们一般用Paxos/Raft这样的分布式一致性的算法。而在CPU的微观世界里,则不必使用这样的算法,原因是因为CPU的多个核的硬件不必考虑网络会断会延迟的问题。所以,CPU的多核心缓存间的同步的核心就是要管理好数据的状态就好了。
MESI
先从最简单的Cache一致性协议MESI开始,其主要表示缓存数据(Cache Line)有四个状态:
- Modified ,被所属的处理器修改了,cache line变为dirty。如果一个缓存行处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成invaliid状态,此外,已修改缓存行如果被丢弃或标记为失效,那么先要把它的内容回写到内存中。
- Exclusive ,
- Shared,
- Invalid,
下图展示了不同状态的转化机制,看起来也比较复杂
举个例子,CPU0从RAM读一个变量x到其cache中,此时该变量对其他cpu不可见,如果其他cpu也需要读该变量呢?大概流程如下:
当前操作 | CPU0 | CPU1 | Memory | 说明 |
---|---|---|---|---|
1) CPU0 read(x) | x=1 (E) | x=1 | 只有一个CPU有 x 变量, 所以,状态是 Exclusive |
|
2) CPU1 read(x) | x=1 (S) | x=1(S) | x=1 | 有两个CPU都读取 x 变量, 所以状态变成 Shared |
3) CPU0 write(x,9) | x=9 (M) | x=1(I) | x=1 | 变量改变,在CPU0中状态 变成 Modified,在CPU1中 状态变成 Invalid |
4) 变量 x 写回内存 | x=9 (M) | X=1(I) | x=9 | 目前的状态不变 |
5) CPU1 read(x) | x=9 (S) | x=9(S) | x=9 | 变量同步到所有的Cache中, 状态回到Shared |
在第3步,CPU0修改了变量x,由于采用write back方式,此时RAM里面的x可能还是旧值,但会标记x是dirty,而通过MESI协议需要将其他CPU对该变量的状态置为invalid,当其他CPU需要读变量x时,发现该变量为invalid,那就要重新从RAM里加载到Cache。
MOESI
如上例,MESI 协议在数据更新后,会标记其它共享的CPU缓存的数据拷贝为Invalid状态,然后当其它CPU再次read的时候,就会出现 cache miss 的问题,此时再从内存中更新数据。从内存中更新数据意味着20倍速度的降低。我们能不能直接从我隔壁的CPU缓存中更新?是的,这就可以增加很多速度了,但是状态控制也就变麻烦了。还需要多来一个状态:Owner(宿主),用于标记,我是更新数据的源。于是,现了 MOESI 协议,MOESI协议允许 CPU Cache 间同步数据,于是也降低了对内存的操作,性能是非常大的提升,但是控制逻辑也非常复杂。
顺便说一下,与 MOESI 协议类似的一个协议是 MESIF,其中的 F 是 Forward,同样是把更新过的数据转发给别的 CPU Cache 但是,MOESI 中的 Owner 状态 和MESIF 中的 Forward 状态有一个非常大的不一样—— Owner状态下的数据是dirty的,还没有写回内存,Forward状态下的数据是clean的,可以丢弃而不用另行通知。
需要说明的是,AMD用MOESI,Intel用MESIF。所以,F 状态主要是针对 CPU L3 Cache 设计的(前面我们说过,L3是所有CPU核心共享的)。
最后看个例子,对于一个二维数组,分别按行优先、列优先顺序去对数组赋值,
#include <stdlib.h> #include <stdio.h> #include <sys/time.h> #define row 128 #define column 4096 typedef int(*parr)[column]; void test1() { parr arr = (parr)malloc(row * column * sizeof(int)); for (int i = 0; i < row; i++) // row priority for (int j = 0; j < column; j++) arr[i][j] = 1; free(arr); } void test2() { parr arr = (parr)malloc(row * column * sizeof(int)); for (int i = 0; i < column; i++) // column priority for (int j = 0; j < row; j++) arr[j][i] = 1; free(arr); } typedef void (*fn)(); void tooktime(fn f, char* desc) { struct timeval t = {0, 0}; gettimeofday(&t, 0); long begin = t.tv_sec * 1000 + t.tv_usec / 1000; f(); gettimeofday(&t, 0); long end = t.tv_sec * 1000 + t.tv_usec / 1000; printf("[%s]=%d\n", desc, (int)(end - begin)); } int main() { tooktime(test1, "row priority"); tooktime(test2, "column priority"); }
结果就是行优先的效率更高,这就是因为数组本身是以行优先顺序存储的,首次存取arr[0][0],下次存取arr[0][1],它们在同一个cache line里面,加载一次即可;
而按列优先的方式,这次存取arr[0][0],下次存取arr[1][0],中间隔了4096*4 Bytes, 可能会导致cache line的重新加载。
参考:
https://coolshell.cn/articles/20793.html
https://zhuanlan.zhihu.com/p/102293437