时隔半年,终于填坑了hh...
1. 层次化存储
理想中,我们都希望有无限大的存储容量,且数据都能够立即获得。但考虑到成本等因素,显然很难实现。
在当今的层次化存储中有四种主要技术:
- DRAM,动态随机访问存储 (Dynamic Random Access Memory )
- SRAM,静态随机访问存储 (Static Random Access Memory )
- Flash,闪存(flash memory)
- 磁盘(magnetic disk)
在这些技术中,每比特的价格和访问时间差别很大:
为了平衡成本与性能,计算机系统中存储体系采用的是层次化存储结构:离CPU越远,存储的容量越大,访问时间也随之增加。
靠近处理器的存储层次对访问数据的速度要求比较高,所有通常采用的是速度最快的SRAM;离CPU更远一些的主存通常采用的是DRAM;Flash这种非易失性存储一般作为个人移动设备的二级存储;磁盘用来实现服务器 上最大也是最慢的存储层次。(更多关于存储的介绍可以参考[[半导体存储器]])
如果有合适的数据访问机制,可以尽量使得处理器拥有高层次的访问速度,同时还拥有低层次的存储容量,cache就是这样的结构。
2. 局部性原理
提高cache的原理,必须得先知道局部性原理的概念。执行计算机程序,都存在局部性原理 (principle of locality)。局部性原理表明,在任意一段时间内程序都只会访问地址空间中相对较小的一部分内容。 局部性有两种:
- 时间局部性 (temporal locality) :如果某个数据项被访问,那 么在不久的将来它可能再次被访问。
- 空间局部性 (spatial locality) :如果某个数据项被访问, 与它地址相邻的数据项可能很快也将被访问。
举个例子,如下两段代码:int sumarrayrows (int a[M][N]) { int i,j,sum=0; for (i=0,i<M;i++) for (j=0;j<N;j++) sum += a[i][j]; return sum; }
int sumarraycols (int a[M][N])
{
int i,j,sum=0;
for (j=0,j<M;j++)
for (i=0;i<N;i++)
sum += a[i][j];
return sum;
}
第一段代码访问数组的顺序是:
a[0][0]→a[0][1]→a[0][2]→...
第二段代码访问数组的顺序是:
a[0][0]→a[1][0]→a[2][0]→...
第一段代码访问是按顺序的,而第二段代码访问中间跳过了某些存储单元,所以第一段代码的空间局部性比第二段代码的空间局部性差。对于这两段代码,时间局部性都差,因为每次访问都是新的存储单元,没有重复访问同一存储单元。下面例子的代码中,重复访问了变量sum,所以它的时间局部性好。
int main() {
int sum = 0;
int i;
for (i = 1; i <= 10; i++) {
sum += i;
}
}
一般来说,含有循环指令的代码段时间局部性好,含有数组的代码段空间局部性更好。
如果我们把这最常访问的数据放在最顶层的存储层次,就能减少存取数据的时间。假如把CPU访问的数据在某一存储层次中找到称为一次“命中”(hit),如果命中最上层存储,访问速度会很快。如果“失效”(miss),将会访问下一级存储。虽然容量变大,但访问速度变慢。如果命中率足够高,该层次化存储有效的访问时间将非常接近最上层存储 (也是速度最快的存储), 容量将相当于最下层存储(也是容量最大的存储)。
3.cache的工作原理
3.1 cache基础
通过前面的描述,你大概知道了cache用来存放主存中常用的数据,并且如果CPU要访问的数据在cache中hit,就不必访问主存。如果访问miss,就需要访问主存,并且根据时间局部性原理(如果某个数据项被访问,那么在不久的将来它可能再次被访问),将该数据也放入cache中。cache 在响应数据访问请求前后的情况示意图如下:
从cache访问miss的示意图中可以看出,被访问的数据$X_n$初始时并不在cache中,这个请求会产生一次miss, 数据$X_n$被从内存取到cache中。现在来详细分析,有两个问题需要回答:
- 如何知道数据项是否存在于 cache 中?
- 进一步来说,如果知道数据项存在于cache 中,又该如何找到这个数据项呢?
如果每个数据是按照一定位置分配规则从主存中放到cache中的,那么找到cache中的数据是很简单的。这个位置分配规则就是下面要讲的映射方式。
3.2 映射方式
基于数据项在主存中的地址来分配其在cache中的位置叫cache的映射方式。一共有三种,分别是:
- 直接映射
- 全相连映射
- 组相连映射
3.2.1 直接映射
直接映射(direct mapped) 是cache中为每个主存中的数据项进行位置分配的最简单方式,基于它在主存中的地址来分配 cache 中的位置,每个主存地址都被直接映射到cache中的确定位置。
几乎所有的直接映射cache使用这种映射方法来找到对应的数据块:(块地址) mod (cache 中的 数据块数量)。
如果 cache 的块数是2的幂,则取模运算很简单,只需要取地址的低 N 位即可。其中 N= log2(cache 的块数 )。假设一个cache能够存8个数据项,每一个cache行存一个,一个8个数据项的 cache 使用地址的最低 3 位来查找。那么,主存中$1_{10}(000012)$至 $29{10} (11101_2)$ 之间的地址映射到容量为8的直接映射cache中的位置如下图所示:
可以看到,低3位为001的主存地址映射到同一个cache块中;低3位为101的主存地址映射到同一个cache块中。
至此,解决了前面提出的一个问题:如果知道数据项存在于cache 中,又该如何找到这个数据项。那么,另一个问题:如何知道数据项是否存在于cache中?多个主存地址能映射到同一cache块中,那对于一个cache块怎么知道是否的希望访问的那一个数据项呢?
- 标签 (tag)
所以,需要在cache 中添加一组标签 (tag)。这些标签保存了所需的地址信息,这些信息用来确定请求数据是否在cache 中。按照定义,对于任何可以放入相同 cache 块中的数据字,其地址的索引域必定是对应的cache 块号,因此标签中无须记录这些冗余的索引,标签中只需要保存主存地址的高位部分,这部分地址不会用来作为cache 的索引。例如,主存地址00001映射到cache的001块中,那么设置cache001块的数据中tag为00;主存地址01001映射到cache的001块中,那么设置cache的001块数据中tag为01。当访问到cahce的001块时,将其中的tag与主存地址中的对应高位相比,如果相同,则该cache块中存放的数据就是当前主存地址想要访问的数据。 - 有效位(valid bit)
此外,当处理器启动时,cache是空的,即使在执行了许多指令后,cache中的一些表项内容仍可能为空,所以还需要一种方法能够判断cache中的数据块中是否保存有效信息。即,有效位(valid bit), 用来表示该表项中是否保存有效的数据。如果该位未被置位,则对应的数据块不能使用。 - 直接映射方式的cache地址字段
接下来分析访问cache的地址,首先需要知道主存中的编址方式通常是按字节编址的,CPU发送的主存地址被划分为主存块号索引地址和块内偏移量索引地址,主存块号索引地址用来确定访问数据所在的主存块,块内偏移量索引地址用来确定访问数据在主存块的哪一字节,如下图所示:
假设主存中有$2^m$个主存块,每个主存块中有$2^n$个数据,即主存块号为m bit,块内偏移量为n bit。
从主存中写入cache时,最小单元是主存块,所以cache中存放的是一个个主存块。同样的,访问cache的地址被划分为cache行号索引地址和块内偏移量索引地址。
因为cache中放的是主存中常用的数据,所以cache块(行)少于主存块数,即c<m
。又因为从主存中写入cache时,最小单元是主存块,所以cache地址的块内偏移量和主存地址的块内偏移量是一致的。
根据之前的分析,为确保cache中的数据是有效的,还需要额外添加一为有效位(valid bit):
分析cache的索引地址与存放数据,这其实是相当于对主存地址的解析!主存地址低位用作访问cache的地址,其余高位用作cache中的标签(tag),存放在cache行中。此外,还额外添加一位有效位在cache存放的数据中,所以cache行中存放的完整数据为:
通过cache地址索引到对应的cache行,再根据cache行中存放的标签(tag)判断cache行中的数据是否是当前主存地址希望访问到的数据。如果cache块中的标签(tag)与主存中的对应位相同,且cache块中的有效位为有效,那么该索引到的cache块中的数据即是希望访问的数据,即称为一次“hit”。
举个例子:
主存地址64位,直接映射cache,共1024个cache行
对于上述 cache, 地址低位用来选择 cache 的数据块,该数据块包括数据和标签。cache 中的标签需要与地址的高位进行比较,以判断请求所需的数据是否在 cache 中。由于 cache 容量为 $2^{10}$个数据块,而每个数据块大小为word,因此使用10位地址来索引cache, 剩余的64-10-2 = 52 位与标签进行比较。如果某数据块的标签与地址的高52 位相等,同时对应的有效位有效,则该访存请求在 cache 中命中,处理器所需数据将被读出。否则,出现 cache 失效。
现在来区分一下cache两个容量概念:
- cache总容量:cache行数×(有效位大小+标签字段大小+单个数据块容量)
- cache数据区容量:cache行数×单个数据块容量
所以上述 cache 容量为 1024 个word(4KB)
3.2.2 全相联映射
全相联映射:数据块可以存放在 cache 的任意位置,即主存中的某个数据块和 cache 中的任意表项都可能有关联。所以全相联映射的cache没有cache行号或者cache组号,所有的主存块号都作为cache的标签(tag)。
同样的,cache行中存放的完整数据为:
3.2.3 组相联映射
组相联映射:介于直接映射和全相联之间的组织结构,在一个组相联 cache 中,每一个数据块可以存放的位置数量是固定的。简单来说就是,组之间是直接映射,组内的行是全相联映射的。某个数据块直接映射到某一组,之后组中的所有数据块都需要与之进行命中比对。
在组相联cache中,包含主存块的组号为:(数据块号)mod(cache 中的组数)
每个数据块有n个位置可放的组相联cache称为n路组相联cache。在一个n路组相联cache中,包含有若干组(set), 每一组包含有n个数据块。主存中的每个数据块通过索引位映射到cache中 对应的组,数据块可以存放在该组中的任意位置。
同样的,cache行中存放的完整数据为:
下面给出 8 个数据块 cache 可能的相联结构例子:
8个数据块的 cache 被配置成直接映射、两路组相联、四路组相联和全相联。cache的容量(以块为单位)等于组数乘以相联度。因此,对于固定大小的cache来说, 提高相联度相当于降低组数,即增加每一组中的数据块数。对于8个数据块来说,一个八路组相联的cache就相当于全相联 cache。
3.2.4 各映射方式的优缺点
下图为三种映射模式下地址为 12 的主存数据块在 cache 中的位置示意图,cache 具有 8 个数据块。
- 在直接映射cache中,主存块12只对应一个数据块位置,数据块号为(12 mod 8)=4。
- 在两路组相联cache中,共有4组,主存块12对应的组为(12 mod 4)= 0 ;主存块可以在这个组中的任意位置。
- 在全相联 cache 中,主存块 12 可以放置在任意块中。
接着,引入两个概念来衡量映射方式的优劣:
-
关联度:一个主存块映射到cache中时可能存放的位置个数 直接相联 全相联 组相联 关联度 1 cache行数 组路数 关联度越低,命中率越低,因此直接映射命中率最低,全相联映射命中率最高。
关联度越低,判断是否命中的开销越小,命中时间越短,因此直接映射的命中时间最短,全相联映射命中时间最长。 -
比较器:比较器位数等于tag位数,用于对比tag 直接相联 全相联 组相联 比较器个数 1 cache行数 组路数 比较器位数 tag tag tag 直接映射方式的cache,可以根据主存地址找到唯一的cache行,所有cache行共用同一个比较器即可;
全相联映射方式的cache,主存块号作为所有的tag位,无法精确到唯一的cache行,所以每一cache行都要进行tag比较;
组相联映射方式的cache,可以精确到某一组,但无法精确到组内某一行,所以组内所有行都要进行tag比较,所以比较器个数为组路数。
所以,比较各映射方式的优缺点如下:
- 直接映射
命中率低,容易造成cache容量的浪费,比如主存中多个块可能映射到同一cache块,导致cache块冲突率高,空间利用率低。但是比较器面积开销小。 - 全相联映射
命中率高,但比较器面积开销大。 - 组相联映射
上面两种映射方式的一个平衡。
4. cache访问
4.1 cache read
- read hit
cache read hit的时候,CPU直接从cache中取得数据。 - read miss
cache miss的时候,先将主存中的数据放入cache行(如果cache行已经有数据则替换),CPU再从cache中取得数据。
以下是对一个大小为8个数据块的空cache进行9次存储访问的操作序列,包括每次存储访问的具体操作:
cache 初始化为空,所有有效位(V)为无效(N)。处理器按照下列地址发出请求:$10110_2(失效),11010_2(失效), 10110_2(命中), 11010_2(命中), 10000_2(失效),00011_2(失效), 10000_2(命中), 10010_2(失效 ), 10000_2(命中)$。
地址$10110_2$访问cache 110行,访问失效,将主存中地址为$10110_2$的数据放入cache110行;
地址$11010_2$访问cache 010行,访问失效,将主存中地址为$11010_2$的数据放入cache010行;
地址$10110_2$访问cache 110行,访问命中,CPU读取cache 110行数据;
地址$11010_2$访问cache 010行,访问命中,CPU读取cache 010行数据;
地址$10000_2$访问cache 000行,访问失效,将主存中地址为$10000_2$的数据放入cache000行;
地址$00011_2$访问cache 011行,访问失效,将主存中地址为$00011_2$的数据放入cache011行;
地址$10000_2$访问cache 000行,访问命中,CPU读取cache 000行数据;
地址$10010_2$访问cache 010行,访问失效,将主存中地址为$10010_2$的数据放入cache010行,替换原来的cache010行内容;
地址$10000_2$访问cache 000行,访问命中,CPU读取cache 000行数据;
4.2 cache write
由于cache读操作无须改变 cache 内容,因此处理读操作比处理写操作要简单些。写操作有一些不同,写操作的策略主要用来解决cache的一致性问题。例如,对于存储指令,只把数据写入数据 cache(不需要改变主存)。 完成写入 cache 的操作后,主存中的数据将和 cache 中的数据不同。在这种情况下,cache 和主存称为不一致( inconsistent)。
4.2.1 wrtie hit
写命中时的策略主要有write-through和write-back两种策略。其中write-through又可进一步优化为write buffer。
- write-through(写穿透、写直达、全写法)
保持cache和主存一致的最简单方法是,总是将数据写回内存和cache。
但这方法有个缺点,基于写穿透策略,每次的写操作都会引起写主存的操作。这些写操作延时很长,会大大降低处理器的性能。解决这个问题的方法之一是使用write buffer(写缓冲),即使用一个保存等待写入主存的数据的队列。
写缓冲中保存着等待写回主存的数据,数据写入cache 的同时也写入写缓冲中,之后处理器继续执行。当写入 主存的操作完成后,写缓冲中的表项将被释放。如果写缓冲满了,处理器必须停顿流水线直到写缓冲中出现空闲表项。当然,如果主存写操作的速率小于处理器产生写操作的速率,多大容量的缓冲都无济于事,因为写操作的产生速度远远快于主存系统的处理速度。即使写操作的产生速度小于主存的处理速度,还是会产生停顿。通常,当写操作成簇 (burst) 发生时,会发生上述现象。为减少这样的停顿发生,处理器通常会增多写缓冲的表项数。
- write-back(回写法)
另一种写入策略是write-back(回写法),基于写返回策略,当发生写操作时,新值只被写入 cache 中,被改写的数据块在替换出cache时才被写到下一级存储。
由于回写法暂时不会写入主存,在被替换的cache块被回写至主存前,cache块中和主存中的同一地址对应的内容是不一致的。被替换的cache块被回写至主存时,主存是如何判断这个cache块是否被修改过呢?所以回写法要在cache块中设置一个“脏位(dirty bit)”:对每个cache行设置一个修改位(脏位),若修改位为1,说明对应cache行中的主存块被修改过,替换时需要写回主存。
采用回写法的cache块内容,还需要再添加一个脏位,cache块内容如下:
4.2.2 write miss
写未命中时的策略主要有write-allocate和non-write-allocate两种策略。
-
write-allocate(写分配)
先从主存中取来对应数据块中的数据,在 cache 中为其分配一个数据块,称为write allocate (写分配),将其写入cache中,再改写该数据块中相应部分的数据。同时,也会使用完整地址将数据写回主存。 - non-write-allocate(非写分配)
另一种策略则是,在主存中更新相应部分的数据,并不将其取入cache。
该策略的动机是,有时候程序需要写整个数据块,例如操作系统对某一页内存进行初始操作 (全部写0)。在这 样的情况下,初始写失效就将对应数据块取至cache 里,这样的操作是不必要的。
4.2.3 cache write 策略搭配
通常来说,回写法搭配写分配法;全写法可以搭配写分配法和非写分配法。
假如回写法搭配非写分配法,如果某个地址一开始写未命中,因为非写分配仅更新主存单元而不写入cache,那么后面对这个地址的写一直不会命中,每次写操作都是写主存,大大降低了处理器的性能。
对于全写法来说,无论搭配写分配法和非写分配法,都会写主存,所以效率都不高。
5. cache中主存块的替换算法
cache行数比主存块数少得多,往往会出现多个主存块映射到同一个cache行中。当一个新的主存块复制到cache时,cache中的对应行可能已经被全部占满,必须淘汰一个cache行中的主存块,才能继续装入。
这里就涉及到cache中主存块的替换算法,主要有以下几种:
- 随机算法RAND:从候选行中随机选取一个淘汰。
- 先进先出算法FIFO:总是选择最早装入cache的主存块被替换。
- 近期最少使用算法LRU:最长时间未被使用的数据块将被替换。
- 最不经常使用算法:替换cache中引用次数最少的块。
最常用的近期最少使用算法LRU,这里举个例子:
假设内存中的5块{1,2,3,4,5}映射到cache的同一组,cache采用3路组相连,主存块地址访问流是{1,2,3,4,1,2,5,1,2,3,4,5},下面是cache组内的cache块替换过程: