zoukankan      html  css  js  c++  java
  • 原子操作与内存屏障之一——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

  • 相关阅读:
    《SQL 基础教程》第五章:复杂查询
    Ruby on Rails Tutorial 第一章笔记
    《Practical Vim》第十章:复制和粘贴
    《Practical Vim》第五章:命令行模式
    《SQL 基础教程》第四章:数据更新
    用户的三次登录验证及进度条
    socket模块开启一个永久性服务
    TCP协议实现切换目录
    爬取好友微信信息
    TCP协议中传输系统命令及上传下载文件
  • 原文地址:https://www.cnblogs.com/chenny7/p/13079865.html
Copyright © 2011-2022 走看看