二、Cache_上

本篇参考 arm 官网公开材料,和知乎学习笔记
https://zhuanlan.zhihu.com/p/102293437

宋宝华:深入理解cache对写好代码至关重要(上)

深入学习Cache系列 1: 带着几个疑问,从Cache的应用场景学起
深入学习Cache系列 2: Cache是如何工作的?概念以及工作过程
深入学起Cache系列 3 : 多核多Cluster多系统之间的缓存一致性

在这里插入图片描述

0. 为什么需要cache

在思考为什么需要cache之前,我们首先先来思考另一个问题:我们的程序是如何运行起来的?

我们应该知道程序是运行在 RAM之中,RAM 就是我们常说的DDR(例如: DDR3、DDR4等)。我们称之为main memory(主存)。当我们需要运行一个进程的时候,首先会从磁盘设备(例如,eMMC、UFS、SSD等)中将可执行程序load到主存中,然后开始执行。在CPU内部存在一堆的通用寄存器(register)。如果CPU需要将一个变量(假设地址是A)加1,一般分为以下3个步骤:

  1. CPU 从主存中读取地址A的数据到内部通用寄存器 x0(ARM64架构的通用寄存器之一)。
  2. 通用寄存器 x0 加1。
  3. CPU 将通用寄存器 x0 的值写入主存。

其实现实中,CPU通用寄存器的速度和主存之间存在着太大的差异。

CPU register的速度一般小于1ns,主存的速度一般是65ns左右。速度差异近百倍。因此,上面举例的3个步骤中,步骤1和步骤3实际上速度很慢。当CPU试图从主存中load/store 操作时,由于主存的速度限制,CPU不得不等待这漫长的65ns时间。如果我们可以提升主存的速度,那么系统将会获得很大的性能提升。如今的DDR存储设备,动不动就是几个GB,容量很大。如果我们采用更快材料制作更快速度的主存,并且拥有几乎差不多的容量。其成本将会大幅度上升。我们试图提升主存的速度和容量,又期望其成本很低,这就有点难为人了。因此,我们有一种折中的方法,那就是制作一块速度极快但是容量极小的存储设备。那么其成本也不会太高。这块存储设备我们称之为cache memory。在硬件上,我们将cache放置在CPU和主存之间,作为主存数据的缓存。 当CPU试图从主存中load/store数据的时候, CPU会首先从cache中查找对应地址的数据是否缓存在cache 中。如果其数据缓存在cache中,直接从cache中拿到数据并返回给CPU。当存在cache的时候,以上程序如何运行的例子的流程将会变成如下:
在这里插入图片描述
CPU和主存之间直接数据传输的方式转变成CPU和cache之间直接数据传输。cache负责和主存之间数据传输。

1. 什么是cache

Cache存储器也被称为高速缓冲存储器,位于CPU和主存储器之间。之所以在CPU和主存之间要加cache是因为现代的CPU频率大大提高,内存的发展已经跟不上CPU访存的速度。在2001 – 2005年间,处理器时钟频率以每年55%的速度增长,而主存的增长速度只是7%。在现在的系统中,处理器需要上百个时钟周期才能从主存中取到数据。如果没有cache,处理器在等待数据的大部分时间内将会停滞不动

2. Cache的基本原理

Cache的容量跟主存比起来要小得多,尤其是离CPU最近的 L1,通常是几十KB大小。一般L3也就是几十MB大小,跟现在以GB为单位的内存比起来差了好几个数量级。那为什么加入cache还能提高性能呢?

设想一下,如果提前把CPU接下来最有可能用到的数据存放在 cache 中,那么 CPU 就可以在很短的时间内得到数据了,一般如果L1命中的话,CPU在2-3个时钟周期内就会得到想要的数据。那么CPU是如何预测到接下来将要用到的数据的呢?其实这种预测是基于程序代码和数据在时间和空间上的局部性原理(locality)。

时间局部性(temporal locality):如果一个数据现在被访问了,那么以后很有可能也会被访问
空间局部性(spatial locally):如果一个数据现在被访问了,那么它周围的数据在以后可能也会被访问
这里要提到一些概念。当CPU在cache中找到需要的数据,我们称之为命中(hit)。反之没有找到数据,我们称之为缺失(miss),这时候就要去外层存储中寻找所需数据。如果是多级cache设计,那么对于L1来讲L2就是它的外层存储。

缓存缺失的类型有很多,常见的有以下三种,可以用3C表示

强制缺失(Compulsory miss),第一次将数据块读入cache所产生的缺失,也成为冷缺失(cold miss)。
冲突缺失(Conflict miss), 由于cache相联度有限导致的缺失。
容量缺失(Capacity miss), 由于cache大小有限导致的缺失。
高速缓存的管理需要考虑多个方面。首先是数据放置策略;其次是数据替换策略;最后是数据写策略,后面会逐一介绍。

3. 为什么cache要分级

我们经常会看到cache分为L1,L2,L3甚至L4等多级。为什么不能把L1的容量做大,不要其它的cache了?
原因在于性能/功耗/面积(PPA)权衡考虑。L1 cache一般工作在CPU的时钟频率,要求的就是够快,可以在2-4时钟周期内取到数据

L2 cache相对来说是为提供更大的容量而优化的。虽然L1和L2往往都是SRAM,但构成存储单元的晶体管并不一样。

L1是为了更快的速度访问而优化过的,它用了更多/更复杂/更大的晶体管,从而更加昂贵和更加耗电;L2相对来说是为提供更大的容量优化的,用了更少/更简单的晶体管,从而相对便宜和省电。

在有一些CPU设计中,会用DRAM实现大容量的 L3 cache(一个DRAM的存储单元要比SRAM小)。现在也有一些设计会带L4 cache,有时放在片外或者和CPU封装在一起。

因此出于PPA的权衡,我们先看到的cache系统一般是这样的:32-64KB的指令Cache和数据Cache(一般L1的指令和数据cache是分开的),2-4个时钟周期访问时间;256KB-2MB的 L2 cache(一般从L2开始指令和数据就不分开了),10-20个时钟周期的访问时间;8-80MB的 L3 cache,20-50个时钟周期的访问时间。注意,这里所说的时钟周期都是指的CPU的时钟周期。一般 L2 和 L3 的工作时钟频率要比 CPU 的低,这个时钟周期是折算后的数值。
在这里插入图片描述

4. Cache的数据放置策略

在讲 Cache的构成前,先要讲几个概念。首先,缓存的大小称之为 Cache size,其中每一个缓存行称之为cache line。Cache主要由两部分组成,Tag部分和Data部分。因为 Cache 是利用了程序中的相关性,一个被访问的数据,它本身和它周围的数据在最近都有可能被访问,因此Data部分就是用来保存一片连续地址的数据,而Tag部分则是存储着这片连续地址的公共地址,一个Tag和它对应的所有数据Data组成一行称为Cache line,而Cache line中的数据部分成为数据块(Cache data block,也称做Cache block或data block)。如果一个数据可以存储在Cache的多个地方,这些被同一个地址找到的多个Cache line称为Cache set。当CPU在读取缓存数据时,一个Cache line的多字节会被同时读出。

假设我们现在的 Cache size是32KB,一个 Cache line是64Bytes,4路组相连。
通过简单的除法我们就知道在Cache中有512条Cache line。

假设我们的系统中地址宽度是44bit,当一个地址发下来,会用最低的6bits作为块内的偏移地址(offset),用较高的9bits作为Cache索引地址(set==index),将其余的29bits地址作为标志位(tag)作为比对。

32KB 4-way set associative data cache
使用Index来从 Cache 中找到一个对应的 Cache line,但是所有 Index 相同的地址都会寻址到这个 Cache line,因此在cache line中还有Tag部分,用来和地址中的Tag进行比较,只有它们相等才表明这个 Cache line就是想要的那个。在一个Cache line中有很多数据,通过存储器地址中的Offset部分可以找到真正想要的数据,它可以定位到每个字节。在Cache line中还有一个有效位(Valid),用来标记这个Cache line是否保存着有效的数据,只有在之前被访问过的存储器地址,它的数据才会存在于对应的 Cache line中,相应的有效位也会被置为1。每个 Cache line 中会有一个bit位记录数据是否被修改过,称之为 Dirty bit。

组相联的方式是为了解决直接映射结构Cache的不足而提出的,存储器中的一个数据不单单只能放在一个cache line中,而是可以放在多个cache line中,对于一个组相联结构的cache来说,如果一个数据可以放在n个位置,则称这个cache是n路组相联的cache(n way set-associative Cache)。下图为一个两路组相联Cache的原理图。

这种结构仍旧使用存储器地址的Index部分对cache进行寻址,此时可以得到两个cache line,这两个cache line称为一个cache set,究竟哪个cache line才是最终需要的,是根据Tag比较的结果来确定的,如果两个cache line的Tag比较结果都不相等,那么就说明这个存储器地址对应的数据不在cache中,也就是发生了cache缺失。上图所示为并行访问,如果先访问Tag SRAM部分,根据Tag比较的结果再去访问Data SRAM部分,就称为串行访问。

两路组相联缓存的硬件成本相对于直接映射缓存更高。因为其每次比较tag的时候需要比较多个cache line对应的tag(某些硬件可能还会做并行比较,增加比较速度,这就增加了硬件设计复杂度)。为什么我们还需要两路组相联缓存呢?因为其可以有助于降低cache颠簸可能性。

既然组相联缓存那么好,如果所有的cache line都在一个组内。岂不是性能更好?由于所有的cache line都在一个组内,因此地址中不需要set index部分。因为,只有一个组让你选择,间接来说就是你没得选。我们根据地址中的tag部分和所有的cache line对应的tag进行比较(硬件上可能并行比较也可能串行比较)。哪个tag比较相等,就意味着命中某个cache line。因此,在全相连缓存中,任意地址的数据可以缓存在任意的cache line中。所以,这可以最大程度的降低cache颠簸的频率。但是硬件成本上也是更高。

5.再谈直接相连与组相联

我们继续引入一些cache相关的名词。cache的大小称之为cahe size,代表cache可以缓存最大数据的大小。我们将cache平均分成相等的很多块,每一个块大小称之为cache line,其大小是cache line size。例如一个64 Bytes大小的cache。如果我们将64 Bytes平均分成64块,那么cache line就是1字节,总共64行cache line。如果我们将64 Bytes平均分成8块,那么cache line就是8字节,总共8行cache line。现在的硬件设计中,一般cache line的大小是4-128 Byts。为什么没有1 byte呢?原因我们后面讨论。

这里有一点需要注意,cache line是cache和主存之间数据传输的最小单位。什么意思呢?当CPU试图load一个字节数据的时候,如果cache缺失,那么cache控制器会从主存中一次性的load cache line大小的数据到cache中。例如,cache line大小是8字节。CPU即使读取一个byte,在cache缺失后,cache会从主存中load 8字节填充整个cache line。又是因为什么呢?后面说完就懂了。

我们假设下面的讲解都是针对64 Bytes大小的cache,并且cache line大小是8字节。
我们可以类似把这块cache想想成一个数组,数组总共8个元素,每个元素大小是8字节。就像下图这样。
在这里插入图片描述

5.1 直接相连:

现在我们考虑一个问题,CPU从0x0654地址读取一个字节,cache控制器是如何判断数据是否在cache中命中呢?cache大小相对于主存来说,可谓是小巫见大巫。所以cache肯定是只能缓存主存中极小一部分数据。我们如何根据地址在有限大小的cache中查找数据呢?现在硬件采取的做法是对地址进行散列(可以理解成地址取模操作)。我们接下来看看是如何做到的?
在这里插入图片描述

  1. 我们一共有8行cache line,cache line大小是8 Bytes。所以我们可以利用地址低3 bits(如上图地址蓝色部分)用来寻址8 bytes中某一字节,我们称这部分bit组合为offset。
  1. 同理,8行cache line,为了覆盖所有行。我们需要3 bits(如上图地址黄色部分)查找某一行,这部分地址部分称之为index。
  1. 现在我们知道,如果两个不同的地址,其地址的bit3-bit5如果完全一样的话,那么这两个地址经过硬件散列之后都会找到同一个cache line。所以,当我们找到cache line之后,只代表我们访问的地址对应的数据可能存在这个cache line中,但是也有可能是其他地址对应的数据。
  1. 我们又引入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的值是0x19,和地址中的tag部分相等,因此在本次访问会命中。由于tag的引入,因此解答了我们之前的一个疑问“为什么硬件cache line不做成一个字节?”。这样会导致硬件成本的上升,因为原本8个字节对应一个tag,现在需要8个tag,占用了很多内存。

我们可以从图中看到tag旁边还有一个valid bit,这个bit用来表示cache line中数据是否有效(例如:1代表有效;0代表无效)。当系统刚启动时,cache中的数据都应该是无效的,因为还没有缓存任何数据。cache控制器可以根据valid bit确认当前cache line数据是否有效。所以,上述比较tag确认cache line是否命中之前还会检查valid bit是否有效。只有在有效的情况下,比较tag才有意义。如果无效,直接判定cache缺失。

5.2 组相连

在这里插入图片描述
两路组相连缓存就是将cache平均分成2份,每份32 Bytes。
cache被分成2路,每路包含4行cache line。我们将所有索引一样的cache line组合在一起称之为组。

例如,上图中一个组有两个cache line,总共4个组。我们依然假设从地址0x0654地址读取一个字节数据。由于cache line size是8 Bytes,因此offset需要3 bits,这和之前直接映射缓存一样。

不一样的地方是index,在两路组相连缓存中,index只需要2 bits,因为一路只有4行cache line。上面的例子根据index找到第2行cache line(从0开始计算),第2行对应2个cache line,分别对应way 0和way 1。因此index也可以称作set index(组索引)。

先根据index找到set,然后将组内的所有cache line对应的tag取出来和地址中的tag部分对比,如果其中一个相等就意味着命中。

两路组相连缓存优缺点
两路组相连缓存的硬件成本相对于直接映射缓存更高。因为其每次比较tag的时候需要比较多个cache line对应的tag(某些硬件可能还会做并行比较,增加比较速度,这就增加了硬件设计复杂度)。为什么我们还需要两路组相连缓存呢?因为其可以有助于降低cache颠簸可能性。那么是如何降低的呢?根据两路组相连缓存的工作方式,我们可以画出主存地址0x00-0x4f地址对应的cache分布图。

我们依然考虑直接映射缓存一节的问题“如果一个程序试图依次访问地址0x00、0x40、0x80,cache中的数据会发生什么呢?”。现在0x00地址的数据可以被加载到way 1,0x40可以被加载到way 0。这样是不是就在一定程度上避免了直接映射缓存的尴尬境地呢?在两路组相连缓存的情况下,0x00和0x40地址的数据都缓存在cache中。试想一下,如果我们是4路组相连缓存,后面继续访问0x80,也可能被被缓存。
因此,当cache size一定的情况下,组相连缓存对性能的提升最差情况下也和直接映射缓存一样,在大部分情况下组相连缓存效果比直接映射缓存好。同时,其降低了cache颠簸的频率。从某种程度上来说,直接映射缓存是组相连缓存的一种特殊情况,每个组只有一个cache line而已。因此,直接映射缓存也可以称作单路组相连缓存

5.3 全相连

在这里插入图片描述
由于所有的cache line都在一个组内,因此地址中不需要set index部分。因为,只有一个组让你选择,间接来说就是你没得选。我们根据地址中的tag部分和所有的cache line对应的tag进行比较(硬件上可能并行比较也可能串行比较)。哪个tag比较相等,就意味着命中某个cache line。因此,在全相连缓存中,任意地址的数据可以缓存在任意的cache line中。所以,这可以最大程度的降低cache颠簸的频率。但是硬件成本上也是更高。

5.4 一个四路组相连缓存实例问题

考虑这么一个问题,32 KB大小4路组相连cache,cache line大小是32 Bytes。请思考以下2个问题:

1)多少个组?
2)假设地址宽度是48 bits,index、offset以及tag分别占用几个bit?

总共4路,因此每路大小是8 KB。cache line size是32 Bytes,因此一共有256组(8 KB / 32 Bytes)。由于cache line size是32 Bytes,所以offset需要5位。一共256组,所以index需要8位,剩下的就是tag部分,占用35位。这个cache可以绘制下图表示。

先由 Cacheline 计算出来 Offset的位宽,然后set\index的位宽就是组别的大小,最后算 Tag 的位宽

index 和 set 是一个概念。
在这里插入图片描述

6 Cache分配策略(Cache allocation policy)

cache的分配策略是指我们什么情况下应该为数据分配cache line。cache分配策略分为读和写两种情况。

读分配(read allocation)
当CPU读数据时,发生cache缺失,这种情况下都会分配一个cache line缓存从主存读取的数据。默认情况下,cache都支持读分配。

写分配(write allocation)
当CPU写数据发生cache缺失时,才会考虑写分配策略。
当我们不支持写分配的情况下,写指令只会更新主存数据,然后就结束了。
当支持写分配的时候,我们首先从主存中加载数据到cache line中(相当于先做个读分配动作),然后会更新cache line中的数据。

7 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和主存的数据可能不一致。

同时思考个问题,为什么cache line大小是cache控制器和主存之间数据传输的最小单位呢?这也是因为每个cache line只有一个dirty bit。这一个dirty bit代表着整个cache line是否被修改的状态。

8 实例

假设我们有一个 64 Bytes 大小直接映射缓存,cache line大小是 8 Bytes,采用写分配和写回机制。当CPU从地址0x2a读取一个字节,cache中的数据将会如何变化呢?假设当前cache状态如下图所示(tag旁边valid一栏的数字1代表合法,0代表非法。后面Dirty的1代表dirty,0代表没有写过数据,即非dirty)。

在这里插入图片描述
根据index找到对应的cache line,对应的tag部分valid bit是合法的,但是tag的值不相等,因此发生缺失。此时我们需要从地址0x28地址加载8字节数据到该cache line中。但是,我们发现当前cache line的dirty bit置位。因此,cache line里面的数据不能被简单的丢弃,由于采用写回机制,所以我们需要将cache中的数据0x11223344写到地址0x0128地址(这个地址根据tag中的值及所处的cache line行计算得到)。这个过程如下图所示。

在这里插入图片描述
当写回操作完成,我们将主存中0x28地址开始的8个字节(0x65524130是最新ddr的数据)加载到该cache line中,并清除dirty bit。然后根据offset找到0x52(第2Byte数据)返回给CPU。

  • 10
    点赞
  • 54
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值