CPU缓存(Cache)背后的运行逻辑1-缓存必知

1. 缓存

缓存又叫Cache,官方一点的定义:缓存是指可以进行高速数据交换的存储器,它先于内存与CPU交换数据,因此速率很快。说到底,缓存也是一种存储的东东,特点是它的存储速度很快。其实是因为CPU核的执行速度太快了,但是由于CPU核在访问内存的延时过高,而且内存的速度无法跟上CPU核执行的速度,于是Cache登场了。图中显示了CPU核与内存之间有三层Cache的架构. 一般三次Cache称为L1,L2,L3。

img

说到Cache,就不得不提缓存命中概念,所谓缓存命中(hit),表示的CPU核需要读取的数据或者指令在缓存中可以找到,这个过程就叫缓存命中。对于CPU核来说,如果缓存不命中,CPU核不得不去访问内存取出相关数据或者指令,这么看,所以缓存命中的概率应该越大越好。下文会讲到,3级缓存(L1,L2,L3)的访问延时逐渐增加,所以核来讲,最希望即将执行的指令和数据在L1就命中,其次L2,再其次L3。如果L1,L2,L3都没有命中,则CPU就只能去内存中取数据了。而由于分支预测,路预测等技术,Intel与AMD的CPU的缓存命中率通常可以达到95%以上,这可以保证CPU运行过程中不会空转,保证了程序的运行效率。

2. L1,L2,L3三级缓存

2.1 缓存的分级

通常情况下,缓存被分为三级,L1,L2,L3。那么为什么分为三级呢,为什么不是只有一级缓存,为什么不设置四级缓存呢?我们来一个一个搞清楚。

为什么CPU不是只有一级缓存而偏偏要搞出三级缓存呢?

这里就要涉及到CPU三级缓存的区别了。这里我们需要明确私有缓存与共享缓存的概念,对于一个完整的CPU,里面会有若干个核,比如四核八核等等,对于CPU多核心的场景下,每个核心的L1,L2缓存都只能被这个核访问,别的核无法访问,L1,L2是这个核的私有缓存。而相对应的,L3缓存被以某种协议或者方式链接到CPU内的一种总线上,供所有核访问,所以,L3为共享缓存。(从上述的Cache架构图就可以看出这种区别)

那么,既然都是私有缓存,为什么还要分出L1和L2呢,莫非厂家是闲的?这里就要明确另外一个概念,指令缓存与数据缓存。指令缓存是专门用来给CPU提供指令的,而数据缓存是专门给CPU提供数据的。请注意,是提供指令和数据。指令缓存中偶尔也会存入数据,数据缓存中也偶尔存入指令。但是存归存,CPU核不会在指令缓存中读取数据,也不会在数据缓存里读取指令,除非有BUG。而指令缓存与数据缓存的区别只会在L1存在。L2缓存是同时存储指令和数据的。为什要区分指令缓存和数据缓存?因为L1缓存的容量是比较小的。我们知道Cache是不断刷新的,假设对于L1缓存,刚存上来一堆指令,啪,被下一波数据给替换掉了,这样就会导致指令读取不命中,又得去L2取,这样得不偿失。所以需要搞出L1和L2。

为什么Cache不搞出四级缓存呢?

经过上面的了解,我们知道了L1,L2,L3缓存各有各的责任和义务,那么为什么对于大多数商用处理器普遍不搞出四级缓存呢?我们总结一下,对于CPU而言,L1,L2是专门给一个核设计的,L3是给一个CPU内的多核设计的。L1,L2,L3各司其职,以及满足了一个CPU的需求了,如果Cache容量缺的话,我们加大一下各级的大小不就好了嘛,那么为什么还要设计出L4呢?没必要。况且,对于一个CPU而言,缓存实在是太占有CPU的单元面积了,如下图,L3缓存就已经占有了四个核的面积了,再搞出L4,那又得塞出多大的一块呢?当然,有人提到锐炬系列中已经搞出了L4了,但是我感觉,那是当显存用。偶尔作为缓存,从功能上划分,只是扩充了L3的容量,而不是L4。

img

缓存容量频率与延迟

上文中提到,缓存的出现是解决了CPU核处理能力的日益增长而内存的带宽和延迟无法满足这种增长的问题。那么这里我们就详细看一下缓存的有多大本事。

对于L1缓存来说,作为最靠近CPU核的部件,它的频率是与CPU核同频的,以Ghz为单位,同时它的容量是比较小的,一般情况下只有几十kB到几百kB不等。而对于L2,L3,缓存的容量不断加大,一般L2的容量是几百KB到几MB不等,而L3一般是十几MB。访问延时也在不断增高。Core访问需要等待的时钟周期也不断增加。这里列出来某款处理器的CPU核访问三级缓存与内存需要占有的时钟周期与延迟。

img

从表中可以看到,访问内存所占有的时间是访问L1缓存的30~50倍。缓存的存在大大提高了CPU的执行效率。

2.2 三级缓存的协作

既然缓存分为三级,那么要考虑一下三级缓存直接是怎么协作的。对于缓存的协作方法上,Intel与AMD很有默契的分成了两种阵营。Inclusive模式与Exclusive模式。

首先来说一下Inclusive(包含)模式,这种模式下,L3的缓存包含了缓存的所有内容。也就是说L2缓存的内容是L3的子集,L1的内容又是L2的子集。内存中被缓存的内容会首先进入到L3中。这种模式的好处就是,当一个CPU核发现L3缓存没有命中后,完全可以确定别的CPU核的缓存也没有命中。而且上层的Cache与底层Cache的数据交换更快。因为假设L1发现L2有需要的数据,直接从底层Cache复制一份上来就可以了,无需做别的操作。但是缺点就是会空间浪费,也会产生缓存一致性问题,例如一个CPU核修改了某个缓存行的内容,那么别这个Cacheline在别的Cache中的数据就是过时的,需要进行额外的操作。

而AMD选择的Exclusive(排斥)模式下,L1,L2,L3是一个大的整体。也就是所有的缓存中,每一份数据都是唯一的,这个好处就是,首先不会造成空间浪费,所有缓存没有副本,而且也不会存在上文所说的一个核更改缓存行,别的数据副本过时的问题,因为数据只有一份。但是缺点也比较明显,更改一份数据需要更多的操作,同样假设L1发现L2有需要的缓存数据,不仅仅需要复制,还需要把L2的该数据的缓存行设为无效。而且内存中被缓存的内容会首先进入到L1缓存中,当L1的缓存占满后,会把优先级可能没那么高的数据放入L2,如果L2也被占满,就移到L3,如果L3也满了,直接淘汰掉。这样看,AMD的三层缓存更像三个仓库,L1是最近的小仓库,拿起来更方便,L1满了就往L2那个仓库里扔,L2满了,就往L3扔。

3. 缓存中的数据是如何管理的

缓存行

缓存在对数据进行读取,移除,淘汰,置换等操作的时候,每次操作都是在操作一块数据,这一块数据是缓存操作的最小单位,即缓存行。以Inclusive模式的CPU模式为例:L3缓存每次向内存中取数据都是取若干个缓存行。目前主流的CPU的缓存行大小都为64B,也就是512bit。这是因为内存的一次传输的单位也是64字节。以下图中某CPU的缓存大小为20MB来看,每个缓存行大小为64B,再加上其他标志物和tag信息,即大约有十几万个Cache line。

img

当CPU核访问一串内存地址的时候,首先去到缓存里看缓存有没有命中,那么怎么看缓存中存储的内容是CPU所想访问的那个地址的数据呢?问题来了,缓存中需要有地方去存储内存的地址信息。这里假设内存位4G大小,即2^32。如果以缓存行为单位,即需要2^26个缓存行表示。则前置需要26bit来表示当前缓存行存储的是哪一个地址的数据。这个地址标识则称为Tag。假设CPU给出的是物理地址,则可以根据物理地址计算中出这个地址的tag是多少,然后比对所有cacheline是否存在这样一个tag,如果存在,则缓存命中,否则,缓存不命中。实际操作有更快捷有效的方式(VIPT),不需要所有的cacheline都比较,后面详细阐述。如果命中之后,还需要判断当前访问的那个数据位于cacheline的哪里,即64字节中的哪一个字节,这就还需要额外的6bit去具体哪一个字节。额外的6bit则称为offset。

缓存中的其他标志位

缓存行中除了64字节的数据为,Tag位和invalid位,是否还包含其他位呢?

没错,一个缓存行中还需要包含两个特殊的标志位invalid位与dirty位。

首先看,Dirty位。缓存再怎么牛,也只是一个内存和CPU之间的一个缓冲,CPU在把一个数据更改完成,数据最终还是要存在内存里的。所以当CPU把一个Cacheline中的数据更改之后,需要把该数据增加一个bit位表示该数据已经被更改,如果需要切换线程,或者占有该cacheline则需要把该行数据存储到内存中,即Dirty位。当Dirty为1时,表示该行数据是有效数据,需要做若干操作才可以清除该行数据。否则则可以直接清楚。

其次,invalid位。Invalid位与Dirty的功能相同,同样表达该cacheline是否可以被直接覆盖,但是Invalid位有自己的用途。即上问中讲到,Intel的CPU的缓存中会有多个一个数据的多个副本,当这些副本的一个被CPU核修改时,其余副本的invalid则被置1,表示该cacheline无效,可以替换了。

截止到现在,我们可以看到一个缓存行包含标志位,Dirty,invalid位,包含Tag记录在内存中的地址,包含Offset记录缓存行里面的内容,最后包含64字节的数据。一个缓存行的示意图如下:

img

伪共享问题与缓存行对齐

上面我们了解了缓存是以缓存行为单位来缓存内容中的内容的,每个缓存行的大小是64字节。这里我们假设对于一个进程,有两个数据存在两个线程中但被存在了一个缓存行里面,这里我们称为数据A与数据B,恰好数据A与B被不同的CPU核同时用到,所以两个核里都有该CacheLine的一个拷贝。

img

于是问题就出现了,当第一个CPU核去更改数据A的时候, 第二个核并没有更改数据A缺遭受了牵连,因为由于A数据被更改,所以数据B所在的核中的Invalid位被置1了,那核B就不满意了,合着我刚刚白忙活了,即出现了伪共享问题。针对这种问题,编译器会把同一个进程的不同线程使用的变量尽量放在不同的缓存行中,即称为缓存行对齐技术。