首先,我们都知道现在的CPU多核技术,都会有几级缓存,老的CPU会有两级内存(L1和L2),新的CPU会有三级内存(L1,L2,L3 ),如下图所示:
其中:
例如:Intel Core i7-8700K ,是一个6核的CPU,每核上的L1是64KB(数据和指令各32KB),L2 是 256K,L3有2MB。
CPU Cache再往后面就是内存(RAM),内存的后面就是硬盘(HD)。我们来看一些他们的速度:
我们可以看到,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从内存中存取数据的最小单位。
因为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行),为了方便索引内存地址,
当拿到一个内存地址的时候,先拿出中间的 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 技术,参考例子。
PS:这里的Cache如果指 L1,那么lower memory就是指L2,如果Cache是L2,那么lower memory就是L3或者RAM。
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协议是一个中心式的,会有性能瓶颈,而且会增加整体设计的复杂度。而Snoopy协议更像是微服务+消息通讯,所以,现在基本都是使用Snoopy的总线的设计。
在分布式系统中我们一般用Paxos/Raft这样的分布式一致性的算法。而在CPU的微观世界里,则不必使用这样的算法,原因是因为CPU的多个核的硬件不必考虑网络会断会延迟的问题。所以,CPU的多核心缓存间的同步的核心就是要管理好数据的状态就好了。
先从最简单的Cache一致性协议MESI开始,其主要表示缓存数据(Cache Line)有四个状态:
下图展示了不同状态的转化机制,看起来也比较复杂
举个例子,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。
如上例,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
原文:https://www.cnblogs.com/chenny7/p/13079865.html