CPU Cache

CPU Cache

为了弥补CPU寄存器和内存之间的速度差距,加入了CPU缓存,综合硬件布局、性能等因素,CPU 缓存通常分为大小不等的三级缓存。
不同于内存动辄以 GB 计算,在我的windows电脑上,离 CPU 最近的一级缓存是256KB,二级缓存是 1MB,最大的三级缓存则是 6MB

缓存要比内存快很多。CPU 访问一次内存通常需要 100 个时钟周期以上,而访问一级缓存只需要 4~5 个时钟周期,二级缓存大约 12 个时钟周期,三级缓存大约 30 个时钟周期

程序执行时,会先将内存中的数据载入到共享的三级缓存中,再进入每颗核心独有的二级缓存,最后进入最快的一级缓存,之后才会被 CPU 使用,就像下面这张图。
在这里插入图片描述
可以发现有 2 个一级缓存,这是因为CPU 会区别对待指令与数据。比如,“1+1=2”这个运算,“+”就是指令,会放在一级指令缓存中,而“1”这个输入数字,则放在一级数据缓存中。

Cache Line缓存行

缓存行 Cache Line 是缓存中的最小单位,
为什么最小单位设置为缓存行而不是单个数据?
因为每次都只读取一个数据的话成本太高,又有局部性原理,我们就在读取内存数据时多读一些相邻的数据到 Cache Line。提高读取效率

下面是一个 Cache Line 的结构示意图

在这里插入图片描述
主要有三部分组成:有效位,标记位和实际数据

  • 有效位指明这个行是否包含有意义的信息

  • 标记位唯一标识存储在这个高速缓存行中的块在内存中的地址

  • 实际数据

Cache Set缓存组

高速缓存组,由一个或更多 Cache Line 组成,一个 CPU Cache 会被分为多组高速缓存组,通常是 2^s 个,

下图表示一个 CPU Cache
在这里插入图片描述

内存地址的组成部分

主要分为三部分:

  • 标记:总共占 t 位,与 Cache Line 中的标记位一致
  • 组索引:总共占 s 位,存储Cache Set 的索引值
  • 块偏移:总共占 b 位,通过标记和组索引确定到一个 Cache Line 后,通过块偏移就可以确定该字节在高速缓存中的位置

CPU Cache 的映射方式

主存中的地址映射到 CPU 高速缓存有三种映射方式:

  • 直接映射 :
    一个 CPU Cache 有两个或以上 Cache Set,每个 Cache Set 只有一个 Cache Line 的映射方式就叫做直接映射
    评价:由于只有一个缓存行,所以容易发生缓存冲突,命中率低。但是映射速度快。

  • 组相联 :
    一个 CPU Cache 有两个或以上 Cache Set,一个 Cache Set 包含两个或以上的 Cache Line的映射方式就是组相联
    评价:比直接映射发生缓存冲突的概率低,所以命中率更高,但是由于每个块在缓存中的映射位置不确定,在查找和读入数据时速度慢

  • 全相联 :
    一个 CPU Cache 只有一个 Cache Set,该 Cache Set 包含全部Cache Line
    评价: 命中率高,但是速度比组相联慢
    因为高速缓存电路必须并行的搜索许多相匹配的标记,构造一个又大又快的相联高速缓存很困难且昂贵,因此全相联只适合做小的高速缓存,例如虚拟内存中的TLB快表

一个主存的地址映射到高速缓存中有三个步骤:

  • 组选择(查找 Cache Set)
  • 行匹配(查找 Cache Line)
  • 字抽取(查找高速缓存块中一个字的起始字节)

直接映射

每个 Cache Set 只有一个 Cache Line 的映射方式就叫做直接映射

1、组选择
直接看图,内存地址中的组索引部分就对应 Cache Set

在这里插入图片描述
2、行匹配
直接映射中的行匹配很简单,因为一个 Cache Set 只有一个 Cache Line
在这里插入图片描述
3、字抽取
将高速缓存块看成一个数组,块偏移的值(二进制数)就是数组的索引,这样我们就定位到了该地址在 CPU Cache 中的映射位置了,就是该字的起始字节,然后根据字长向后取字节即可
在这里插入图片描述

组相联

一个 CPU Cache 有两个或以上 Cache Set,一个 Cache Set 包含两个或以上的 Cache Line的映射方式就是组相联

1、组选择
和直接映射是一样的,这里简单贴一下图

在这里插入图片描述

2、行匹配
一个内存块可以映射到一个固定的 Cache Set 的任意一个 Cache Line ,就是说组是固定的,行是可变的,所以在行匹配中涉及到要寻找到这一行,所以必须搜索组中每一行,找到有效的行并且标记位和地址相同的

3、字抽取
字抽取和直接映射是一样的,这里就不再重复了

全相联

一个 CPU Cache 只有一个 Cache Set,该 Cache Set 包含 CPU Cache 的所有 Cache Line

1、组选择
由于只存在一个 Cache Set ,也就不存在组选择了,内存地址也就没有组索引了
在这里插入图片描述
2、行匹配
和组相联类似的,全相联中行也可以映射到组中的任意一个内存块,只不过是只有一个组了

3、字抽取
字选择和直接映射和组相联都是一致的

CPU缓存一致性

缓存与内存之间的不一致

如果数据写入 Cache 之后,Cache 和内存数据都不一致了,我们肯定是要把 Cache 中的数据同步到内存里的。那在什么时机才把 Cache 中的数据写回到内存呢?主要有两种针对写入数据的方法:

写直达(Write Through)
写回(Write Back)

写直达

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Through)

在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:

  • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
  • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。

写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

写回

写直达由于每次写操作都会把数据写到内存,而导致影响性能。
为了减少数据写回内存的频率,就出现了写回的方法

在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

在这里插入图片描述
以上流程文字描述:

  • 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;
  • 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的
    • 如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,写入到这个 Cache Block 里,同时也把它标记为脏的;
    • 如果 Cache Block 里面的数据没有被标记为脏,则就直接将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。

总结一下:
在把数据写入到 Cache 的时候

  • 只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中
  • 而在缓存命中的情况下,则在写入Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。

这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。

多核心缓存不一致

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心缓存不一致的问题

要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(Transaction Serialization)

写传播很容易理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。
而对于第二点事务事的串形化,我们举个例子来理解它。
在这里插入图片描述
可以看到,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。否则数据就不一致了。

总线嗅探

总线嗅探(Bus Snooping)是写传播的最常见的实现方式,总线嗅探原理很简单, 就是CPU 每时每刻监听总线上的一切活动

我还是以前面的 i 变量例子来说明总线嗅探的工作机制
当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。

可以发现,总线嗅探方法虽然很简单,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。

另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化

于是,有一个协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,MESI 协议做到了 CPU多核缓存一致性

MESI协议

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

用这四个状态来标记 Cache Line 四个不同的状态。

「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。

「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于:

「独占」状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,「独占」状态下的数据就会变成「共享」状态。

「共享」状态代表在多个 CPU 核心的 Cache 里有相同的数据,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

我们举个具体的例子来看看这四个状态的转换:

1、当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;

2、然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;

3、当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。

4、如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。

5、如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。

事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图:

在这里插入图片描述
MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,你可以更详细的看到每个状态转换的原因:
在这里插入图片描述

总结

CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。

当Cache 里没有缓存想要的数据时,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。

而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:

  • 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度;
  • 写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好;

当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。

要想实现缓存一致性,关键是要满足 2 点:

  • 第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;
  • 第二点是事物的串行化,这个很重要,只有保证了这个,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的;

基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。

提升缓存命中率

如果 CPU 所要操作的数据在缓存中,则直接读取,这称为缓存命中。命中缓存会带来很大的性能提升,因此,我们的代码优化目标是提升 CPU 缓存的命中率。

提升数据缓存的命中率

我们要知道,数据的访问顺序对缓存命中率有很大的影响

举个例子:array[j][i]执行的时间是 array[i][j]的 数倍。

为什么会有这么大的差距呢?这是因为二维数组 array 所占用的内存是连续的,比如若长度 N 的值为 2,那么内存中从前至后各元素的顺序是:


array[0][0],array[0][1],array[1][0],array[1][1]

如果用 array[i][j]访问数组元素,则完全与上述内存中元素顺序一致,因此访问 array[0][0]时,缓存已经把紧随其后的 3 个元素也载入了,CPU 通过快速的缓存来读取后续 3 个元素就可以。如果用 array[j][i]来访问,访问的顺序就是:


array[0][0],array[1][0],array[0][1],array[1][1]

此时内存是跳跃访问的,如果 N 的数值很大,那么操作 array[j][i]时,是没有办法把 array[j+1][i]也读入缓存的。

因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。

提升指令缓存的命中率

我们还是用一个例子来看一下。比如,有一个元素为 0 到 255 之间随机数字组成的数组

接下来要对它做两个操作:
一是循环遍历数组,判断每个数字是否小于 128,如果小于则把元素的值置为 0;
二是将数组排序。那么,先排序再遍历速度快,还是先遍历再排序速度快呢?


for(i = 0; i < N; i++) {
       if (array [i] < 128) array[i] = 0;
}
sort(array, array +N);

答案:先排序后判断更快

解释:当代码中出现 if、switch 等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如 if 还是 else 中的指令),就可以提前把这些指令放在缓存中,CPU 执行时就会很快。当数组中的元素完全随机时,分支预测器无法有效工作,而当 array 数组有序时,分支预测器会动态地根据历史命中数据对未来进行预测,命中率就会非常高。

提升多核 CPU 下的缓存命中率

前面我们都是面向一个 CPU 核心谈数据及指令缓存的,然而现代 CPU 几乎都是多核的。虽然三级缓存面向所有核心,但一、二级缓存是每颗核心独享的。我们知道,即使只有一个 CPU 核心,现代分时操作系统都支持许多进程同时运行。这是因为操作系统把时间切成了许多片,微观上各进程按时间片交替地占用 CPU,这造成宏观上看起来各程序同时在执行。

因此,若进程 A 在时间片 1 里使用 CPU 核心 1,自然也填满了核心 1 的一、二级缓存,当时间片 1 结束后,操作系统会让进程 A 让出 CPU,基于效率并兼顾公平的策略重新调度 其他进程占用CPU 核心 1,以防止某些进程饿死。
如果此时 CPU 核心 1 繁忙,而 CPU 核心 2 空闲,则进程 A 很可能会被调度到 CPU 核心 2 上运行,这样,即使我们对代码优化得再好,也只能在一个时间片内高效地使用 CPU 一、二级缓存了,下一个时间片便面临着缓存效率的问题。

因此,操作系统提供了将进程或者线程绑定到某一颗 CPU 上运行的能力。 防止以上情况的发生提高多核CPU的缓存命中率

总结

CPU 缓存分为数据缓存与指令缓存,对于数据缓存,我们应在循环体中尽量操作同一块内存上的数据,由于缓存是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时也有性能提升。

对于指令缓存,有规律的条件分支能够让 CPU 的分支预测发挥作用,进一步提升执行效率。

对于多核系统,如果进程的缓存命中率非常高,则可以考虑绑定 CPU 来提升缓存命中率。

缓存未命中的替换策略

主要有三种未命中:强制性未命中、冲突性未命中、 容量性未命中

1、强制性未命中
即在程序刚刚启动的时候,数据都是不在缓存中的,所以第一次访问数据必定发生 Miss ,这是不可避免的

2、冲突性未命中
该 Miss 是由于不同的内存块映射到同一个 Cache Set 导致的
该 Miss 是可以避免并且也应该避免的,因为仍然有空闲的空间可以存放数据

3、容量不够未命中
该 Miss 是由于程序运行所需的 Set 的数量要大于缓存的 Set 的数量,导致不能把所有数据都装入缓存中

常用的替换策略有:

  • 最近最久未使用LRU
    策略会替换最后一次访问时间最久远的那一行。有较高命中率。

  • 最不常用LFU
    该策略会替换在过去某个时间窗口内引用次数最少的那一行。
    容易把新加入的块替换掉,且容易让前期频繁访问,后期较少访问的块长期驻留。

  • 随机替换
    随机选择一个 Cache Line 替换出去。随机替换算法在硬件上容易实现,且速度也比前两种算法快。缺点则是降低了命中率和Cache工作效率。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值