• 原子操作与内存屏障之一——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 的存取速度:
    • L2 的存取速度: 
    • L3 的存取速度:
    • RAM内存的存取速度

    我们可以看到,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)有四个状态:

    1. Modified ,被所属的处理器修改了,cache line变为dirty。如果一个缓存行处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成invaliid状态,此外,已修改缓存行如果被丢弃或标记为失效,那么先要把它的内容回写到内存中。
    2. Exclusive ,
    3. Shared,
    4. Invalid,

    下图展示了不同状态的转化机制,看起来也比较复杂

    举个例子,CPU0从RAM读一个变量x到其cache中,此时该变量对其他cpu不可见,如果其他cpu也需要读该变量呢?大概流程如下:

    当前操作CPU0CPU1Memory说明
    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
    ", 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

  • 相关阅读:
    对MVC模型的自悟,详尽解释,为了更多非计算机人员可以理解
    openSUSE leap 42.3 实现有线 无线同时用
    Fedora27 源配置
    Ubuntu16.04添加HP Laserjet Pro M128fn打印机和驱动
    openSUSE leap 42.3 添加HP Laserjet Pro M128fn打印机和驱动
    OpenSUSE Leap 42.3下通过Firefox Opera Chromium浏览器直接执行java应用程序(打开java jnlp文件)实现在服务器远程虚拟控制台完成远程管理的方法
    OpenSUSE Leap 42.3 安装java(Oracle jre)
    linux下支持托盘的邮件客户端Sylpheed
    Ubuntu下通过Firefox Opera Chromium浏览器直接执行java应用程序(打开java jnlp文件)实现在服务器远程虚拟控制台完成远程管理的方法
    Firefox 浏览器添加Linux jre插件
  • 原文地址:https://www.cnblogs.com/chenny7/p/13079865.html
Copyright © 2020-2023  润新知