Cache的基本原理
Cache主要用于提供高速存储访问,是利用数据的时间局部性和空间局部性而设计的小存储单元。即用一块更小更快的存储设备作为更大更慢的存储设备的缓冲区,从而提高数据访问速度。
我们应该知道程序是运行在 RAM之中,RAM 就是我们常说的DDR(例如: DDR3、DDR4等)。我们称之为main memory(主存)。当我们需要运行一个进程的时候,首先会从磁盘设备(例如,eMMC、UFS、SSD等)中将可执行程序load到主存中,然后开始执行。在CPU内部存在一堆的通用寄存器(register)。如果CPU需要将一个变量(假设地址是A)加1,一般分为以下3个步骤:
- CPU 从主存中读取地址A的数据到内部通用寄存器 x0(ARM64架构的通用寄存器之一)。
- 通用寄存器 x0 加1。
- CPU 将通用寄存器 x0 的值写入主存
这个过程可以理解为:
graph TD
Cpu-register <--> main-memory <--> flash-memory
在实际应用中,CPU通用寄存器和主存之间存在显著的速度差异。通常情况下,CPU register的速度远远小于1ns,而主存速度则在65ns左右,两者的速度差异高达近百倍。由于主存速度的限制,CPU在执行load/store操作时必须等待长达65ns。提高主存速度将会显著提升系统性能。尽管如今的DDR存储设备容量巨大,达到几个GB,但要提升速度并保持相近的容量,成本却会大幅上升。为了在速度、容量和成本之间找到平衡,我们采用了一种折中的方法,即制作一块速度极快但容量极小的存储设备,即cache memory。硬件上,我们将cache放置在CPU和主存之间,作为主存数据的缓存。当CPU需要load/store数据时,首先会查找cache中是否缓存了相应地址的数据。如果数据已缓存在cache中,CPU可直接从cache中获取数据,以提高效率。
CPU和主存之间直接数据传输的方式转变成CPU和cache之间直接数据传输。cache负责和主存之间数据传输。
直接映射缓存
说直白一些,cache就是一个存储空间,整个cache的大小称为cache size,为了便于使用,把cache均分为若干个小的存储单位,这个基本单位我们可以称之为“块”,即称之为 cache-line。假设有一个8192 Bytes大小的cache,我们将其均分成128块,那么每块大小为64bytes,现在的硬件设计中,一般cache line的大小是4-128 Bytes。这里有一点需要注意,cache line是cache和主存之间数据传输的最小单位。什么意思呢?当CPU试图load一个字节数据的时候,如果cache缺失,那么cache控制器会从主存中一次性的load cache line大小的数据到cache中。例如,cache line大小是8字节。CPU即使读取一个byte,在cache缺失后,cache会从主存中load 8字节填充整个cache line。
假如CPU从0xf654地址读取一个字节,cache控制器是如何判断数据是否在cache中命中呢?cache大小相对于主存来说,可谓是小巫见大巫。所以cache肯定是只能缓存主存中极小一部分数据。我们如何根据地址在有限大小的cache中查找数据呢?现在硬件采取的做法是对地址进行散列(可以理解成地址取模操作)。我们接下来看看是如何做到的?
如上图所示,共有128行cache-line,每个cache-line的大小是64bytes,因此我们可以用32位地址中的低3位来寻址每个cache-line的64bytes中的8bytes,即512bits中的64bit数据,我们称这部分bit的组合为offset,那如何确定这128个cache-line中的哪一个呢,我们需要7bit数据来确定,即bit3到bit9,我们称这一部分为index。现在我们知道,如果两个不同的地址,其地址的bit3-bit9如果完全一样的话,那么这两个地址经过硬件散列之后都会找到同一个cache line。所以,当我们找到cache line之后,只代表我们访问的地址对应的数据可能存在这个cache line中,但是也有可能是其他地址对应的数据。所以,我们又引入tag array区域,tag array和data array一一对应。每一个cache line都对应唯一一个tag,tag中保存的是整个地址位宽去除index和offset使用的bit剩余部分(如上图地址绿色部分)。tag、index和offset三者组合就可以唯一确定一个地址了。因此,当我们根据地址中index位找到cache line后,取出当前cache line对应的tag,然后和地址中的tag进行比较,如果相等,这说明cache命中。如果不相等,说明当前cache line存储的是其他地址的数据,这就是cache缺失。
我们可以从图中看到tag旁边还有一个valid bit,这个bit用来表示cache line中数据是否有效(例如:1代表有效;0代表无效)。当系统刚启动时,cache中的数据都应该是无效的,因为还没有缓存任何数据。cache控制器可以根据valid bit确认当前cache line数据是否有效。所以,上述比较tag确认cache line是否命中之前还会检查valid bit是否有效。只有在有效的情况下,比较tag才有意义。如果无效,直接判定cache缺失。
两路组相联映射
两路组相连缓存的硬件成本相对于直接映射缓存更高。因为其每次比较tag的时候需要比较多个cache line对应的tag(某些硬件可能还会做并行比较,增加比较速度,这就增加了硬件设计复杂度)。为什么我们还需要两路组相连缓存呢?因为其可以有助于降低cache颠簸可能性。
cache被分成2路,每路包含128行cache line。我们将所有索引一样的cache line组合在一起称之为组。例如,上图中一个组有两个cache line,总共128个组。index也可以称作set index(组索引),先根据index找到set,然后将组内的所有cache line对应的tag取出来和地址中的tag部分对比,如果其中一个相等就意味着命中。
Cache分配策略(Cache allocation policy)
读分配(read allocation)
当CPU读数据时,发生cache缺失,这种情况下都会分配一个cache line缓存从主存读取的数据。默认情况下,cache都支持读分配。
写分配(write allocation)
当CPU写数据发生cache缺失时,才会考虑写分配策略。当我们不支持写分配的情况下,写指令只会更新主存数据,然后就结束了。当支持写分配的时候,我们首先从主存中加载数据到cache line中(相当于先做个读分配动作),然后会更新cache line中的数据。
Cache更新策略(Cache update policy)
cache更新策略是指当发生cache命中时,写操作应该如何更新数据。cache更新策略分成两种:写直通和回写。
写直通(write through)
当CPU执行store指令并在cache命中时,我们更新cache中的数据并且更新主存中的数据。cache和主存的数据始终保持一致。
写回(write back)
当CPU执行store指令并在cache命中时,我们只更新cache中的数据。并且每个cache line中会有一个bit位记录数据是否被修改过,称之为dirty bit(翻翻前面的图片,cache line旁边有一个D就是dirty bit)。我们会将dirty bit置位。主存中的数据只会在cache line被替换或者显示的clean操作时更新。因此,主存中的数据可能是未修改的数据,而修改的数据躺在cache中。cache和主存的数据可能不一致。