Cache的基本原理
在学习Cache的基本原理之前,我们首先先介绍什么是时间局部性以及空间局部性,我们会用一个例子说明存储顺序是如何对我们编写的程序性能产生影响的。
时间局部性和空间局部性
我们仍然回到我们一开始讲的那个图书馆的例子,如果你已经忘了,我们重新来回顾一下那个例子:
想象你正坐在图书馆中完成一份关于计算机硬件重要历史性发展的论文,你可以从图书馆的书架上精心挑选一些经典的计算机书籍,并将它们放在书桌上。你从这些书中找到了需要写的几种重要的计算机,但是没有找到关于EDSAC的,因此,你返回书架去寻找其他书,并在早期的英国计算机书籍中找到了一本有关EDSAC的书。一旦在你的书桌上有了选好的一些书,你就有可能从这些书中找到你需要的内容。这样一来,你的大部分时间只需花在阅读这些书上,而无需返回书架。
试比较这两种情况:一种是在你的书桌上有好几本书;另一种是书桌上只有一本书,你不得不频繁的返回书架,进行还书后取另一本书。很明显,在书桌前放一些常用的书籍会更加节省时间。
同样,在计算机底层实现中,我们可以构建一个大容量的虚拟存储器,它能像小容量的存储器那样被快速访问。就像你不会同时以相同的概率查阅图书馆中的每一本书那样,一个程序自然也不会同时以同样的概率访问全部的代码和数据。否则,不可能让存储器在保持大容量的同时又能快速访问,就像你不能既要求你把图书馆中所有的图书都放在书桌上,还要求你能保持快速查找一样。
这就是局部性原理的理解,局部性原理表明了在任何时间内,程序访问的只是地址空间内较小的一段内容。
- 时间局部性:如果某个数据被访问,那么在不久的将来它可能再次被访问。
- 空间局部性:如果某个数据项被访问,那么在不久的将来,与它地址相邻的数据项也可能被访问。
程序的局部性起源于简单自然的程序结构。例如,大多数程序都包含了循环结构,因此这部分指令和数据被重复的访问,呈现出来很高的时间局部性。由于指令通常是顺序执行的,因此也体现出很高的空间局部性。
在我们用C语言编写访问二维数组程序的时候,你有没有考虑过如果我们先访问数组的列再访问数组的行,会对程序的性能产生何种影响呢?
在这个程序中,我们首先访问数组的行接着访问数组的列。
我们将这个程序改为先访问列,再访问行。
虽然得到的结果是完全一致的,但是程序的空间局部性变差,程序的性能受到了较大影响。
一般而言,有良好局部性的程序比局部性差的程序运行的更快
先访问列导致空间局部性变差的原因是在内存中的存储也是按照行优先的顺序进行存储的。(具体可看上节内容)
Cache的基本原理
在前面介绍的图书馆例子中,书桌就好比Cache(高速缓存),缓存在如今的计算机中几乎无处不在,用途十分广泛。
例如:图中第 \(K+1\)层的存储器被划分成了16个大小固定的块,每个块都有唯一的地址 这里我们用编号0-15来表示。第\(K\)层的存储器有4个块的空间,每个块的大小与\(K+1\)层的块一样。
数据总是以块为单元 在第\(K\)层和第\(K\)加一层之间来回复制。例如 当前第\(K\)层的存储器包含了四个块的副本,对于相邻层之间的块大小是固定的,然而 不相邻的层次之间块大小是不一样的。一般来说 层次结构中离CPU越远的设备,访问时间就越长。
接下来,我们将重新介绍在之前已经提到的几个缓存相关概念。
当程序需要读取第\(K+1\)层的某个数据对象\(d\)时,它首先从第\(K\)层的数据块中检索是否包含目标数据d的副本,如果目标数据d刚好缓存在第\(K\)层中 我们将这种情况成为缓存命中。另一方面,如果第\(k\)层没有缓存目标数据d 我们将这种情况成为缓存不命中。
当发生不命中时,第\(k\)层的缓存要从第\(k+1\)层取出包含目标数据的块。如果第\(K\)层的缓存已经满了,这时包含目标数据的块就会覆盖现存的一个块。我们把这个过程称为替换。被替换的块也称为牺牲块
决定替换哪个块是由缓存的替换策略来具体决定的
接下来 我们重点来看一下基于SRAM的高速缓存。
早期计算机系统的存储层次结构只有三层 分别是计算器 文件 内存以及磁盘。
由于CPU与内存之间的性能差距逐渐增大,于是系统设计者在寄存器文件和内存之间插入了SRAM的高速缓存(L1 cache L2cache)。
Cache的内部结构
整个cache被划分成一个或者多个set,这里我们用变量S来表示set的个数,每个set包含一个或者多个cache line(高速缓冲行)。这里我们用变量e来表示一个set中cache line的行数。
每个cache line由三部分组成 分别是有效位、标记、数据块。
- 其中有效位的长度是一个bit ,表示当前开始按存储的信息是否有效。当valid为1时表示数据有效 当valid为零时表示数据无效。
- 标记是用来确定目标数据是否存在于当前的cache line中。
- 数据块就是一小部分内存数据的副本,大小用B来表示
通常来说,Cache的结构可以用元组\({S,E,B,m}\)来描述
Cache的大小是指所有数据块的和,其中有效位和标记位不包括在内。
因此 开始的容量可以通过:
得到。
Cache的直接映射
我们如何确定数据在cache中的位置呢?我们需要引入一种叫做直接映射的方法。
在Cache中为主存的每个字分配一个位置的最简单方法就是根据这个字的主存地址进行分配,这种Cache结构称为直接映射。每个存储器地址对应到Cache中一个确定的地址。我们可以使用以下的映射方法确定:
如果cache中的块数是2的幂,那么我们只主要取地址的低\(log_2\)位。因此,一个8块的cache可以使用块地址中的低三位。
对标记的深入理解
标记中包含了地址信息,这些地址信息可以用来判断cache中的字是否就是所请求的字。标记只需包含地址的高位,也就是没有用来检索cache的那些位。
Cache的寻找过程
搞清楚了cache的内部结构以及cache的索引原则,我们就可以开始了解cache的工作流程了,也就是cache是如何寻找对应的数据的。
首先 我们可以通过长度为s的组索引位来确定目标数据存储在哪个set中,一旦我们知道了目标数据属于哪个set。接下来 我们需要确定目标数据放在哪一行,确定具体的行是通过长度为t的标记来实现的。不过还需要注意一点 此时有效位必须为1。也就是说需要有效位和标记共同来确定目标数据属于哪一行。最后 我们需要根据长度为b的块偏移量来确定目标数据在数据块中的确切地址。通过以上三步开始就能确定是否命中。
直接映射cache的工作原理
当每个sit只有一个cache line,也就是e等于1时,我们将这种结构的开始成为直接映射。
首先 我们先来看一下直接映射的开始是如何工作的。
判断是否命中,获取目标数据的过程一共分为三步:
- 组选择
- 行匹配
- 字抽取
组选择
这一步是根据组索引值来确定目标数据属于哪个set
行匹配
而且当前cache line的有效位等于1,此时cache line中的数据是有效的。
然后我们需要对比cache line中的标记与地址中的标记位是否一致。如果一致,表示目标数据一定在当前的cache line中。另一方面,如果不一致或者有效位等于零,表示目标数据不在当前的cache line中。因此 行匹配最终的结果无非就是命中或者不命中。
字抽取
一旦命中,就可以继续执行第三步--字抽取。这一步需要根据偏移量来确定目标数据的确切位置。通俗来讲就是从数据块的什么位置开始抽取数据。
当块偏移等于100时,他表明目标数据的起始地址位于字节4处。
经过以上三步开始就可以将目标数据返回给CPU,上述过程就是开始命中的情况。
Cache缺失
如果发生了不命中,那么cache需要从存储器层次结构的下一层取出被请求的块。由于直接映射的每个sit只包含一行,因此替换策略十分简单。直接用新取出的行来代替当前的行就可以了。
cache缺失:由于数据不在cache中而导致被请求的数据不能满足
cache缺失处理由两部分共同完成:处理器控制单元以及一个进行初始化主存访问和重新填充cache的独立控制器。当cache缺失,我们等待主存操作完成时(可以理解为替换),整个处理器阻塞,临时寄存器和可见的寄存器内容被冻结。
读操作中的冲突不命中
冲突不命中指的是A与B交替占用了同一个缓存空间,随着时间的进行,A与B反复对缓存空间中的同一个区域进行替换操作。
我们看一个例子:
每个元素的长度为四个字节,因此可以得到数组x各个元素的起始地址。数组y紧跟其后,\(y[0]\)的地址从32开始。
当程序开始运行时 循环在第一次迭代时引用了元素\(x[0]\)此时发生不命中,cache把包含\(x[0] - x[3]\)的块加载到\(set_0\),接下来又立刻引用了数组元素 \(y[0]\)又一次不命中,这时开始把包含\(y[0]-y[3]\)的块加载到\(set 0\)。
这里需要注意的是,之前\(set0\)中存储的内容是数据块\(x[0] - x[3]\)的数据。那么 这些数据会被\(y[0]-y[3]\)覆盖。
实际上 后面每次对x和y的引用都会导致cache line的替换。
我们把这种现象称为”抖动“
冲突不命中的原因是这些块被映射到了同一个set中,我们可以将数组的长度由8变为12即可解决问题。
此时 数组y的起始地址发生了改变。这样一来通过这种数据填充的方式就可以消除抖动,从而解决冲突不命中的问题。
组相联 全相联高速缓存
之前我们讲述的内容才用的是最简单的定位机制:一个块只能放到cache中一个明确的位置。实际上,有一整套放置块的方法。直接映射是一种极端的情况,此时一个块被精确地放到一个位置。
另一种极端方式是,一个块可以被放置在cache中的任何位置,这种机制被称为全相联,因为存储器的快可以与cache中任何一项相关。全相联只适合块数较少的cache。
介于直接映射和全相联之间的设计是组相联。在组相联cache中,每个块可被放置的位置数是固定的。每个块有n个位置可放的cache被称为n路组相联cache。
组相联包含存储块的组是这样给出的:
由于块可能被放在组中的任何位置,因此组中所有块的标记都要被检索。而在全相联cache中,块可能被放在任何位置,所以也都要被检索。
提高相联度的好处在于它通常能够降低缺失率,缺点则是增加了命中时间。
组相联全相联查找
组相联
组相联的查找同样需要执行三步
如果找不到符合条件的,cache line表示不命中。此时开始必须从内存中取出包含目标数据的块,不过一旦开始取出 这个块应该替换哪一行呢。
如果存在空行 也就是valid等于零的cache line,那么这个空行就是不错的选择。
但是 如果这个set中没有空行,这时我们需要从中选择一个非空行作为被替换的对象
下面介绍了几种常用的替换策略:
最近最少使用法也就是LRU算法,是最常用的方法。
全相联
这样一来 地址只需要划分成标记和块偏移即可。
关于全相联cache的行匹配和字选择与组相联cache是一样的
写操作处理
当CPU需要往内存中写入数据时,需要考虑写命中和不命中两种情况。当发生写命中时,有两种策略,分别是写穿透和写回。
写命中
写直达:也译为写通过或写穿。写操作总是同时更新cache和下一存储器层次,以保持二者一致性。
写穿透是指CPU在写cache的同时写内存(更低一级cache),这种策略的好处是内存的数据永远都是新的,cache替换时,直接扔掉旧的数据就可以。
写回策略是指CPU只写开始不写内存,写回的好处是写开始时比较省事,不用关注是否与内存一致,只有当替换算法要驱逐这个更新的块时,再写回到内存里。不过,这种策略会增加cache的复杂性
为了表明每个数据块是否被修改过,每一个cache line需要增加一个额外的修改位。
当发写不命中时 也有两种策略 分别是写分配和写不分配。
写不命中
写分配就是把目标数据所在的块从内存加载到cache中,然后再往cache中写。
写不分配就是绕开cache,直接把要写的内容写到内存里
通常情况下,写分配与写回搭配使用。写不分配与写穿透搭配使用。
第五章的基本内容到此就暂时结束,还有一个重要概念虚拟内存,我会在之后与深入理解计算机系统这一本书一起做总结。