cache高速缓存

132 阅读25分钟

cache高速缓存

为什么需要高速缓存?

当运行一个程序时,会先从磁盘设备中将可执行程序load到内存中,然后由CPU开始执行。如果需要执行 i = i + 1,一般分为以下3个步骤:

  1. CPU先从主存中读取 i 的数据到CPU内部的寄存器中。
  1. 然后在寄存器中 计算 i+1。
  1. 最后CPU将 值写入主存。

然而,CPU寄存器的速度和主存之间存在着太大的差距,如果我们可以提升主存的速度,那么系统将会获得很大的性能提升。但是存储设备的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。所以,如果我们采用更快材料制作更快速度的主存,并且拥有几乎差不多的容量,其成本将会大幅度上升。

因此,有了一种折中的方法,那就是制作一块速度极快但是容量极小的存储设备。那么其成本也不会太高。这块存储设备我们称之为cache memory高速缓存。在硬件上,我们将cache放置在CPU和内存之间,作为内存数据的缓存。 当CPU试图从内存中load/store数据的时候, CPU会首先从cache中查找对应地址的数据是否缓存在cache 中。如果其数据缓存在cache中,直接从cache中拿到数据并返回给CPU

imgimg

CPU Cache 用的是一种叫 SRAM(Static Random-Access Memory,静态随机存储器) 的芯片。

SRAM 之所以叫「静态」存储器,是因为只要有电,数据就可以保持存在,而一旦断电,数据就会丢失了。

在 SRAM 里面,一个 bit 的数据,通常需要 6 个晶体管,所以 SRAM 的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度非常快。

cache的多级存储结构

为了进一步提升性能,又将高速缓存拆分出多级,越接近寄存器的高速缓存容量越小,但访问速度越快。

imgimg

L1 高速缓存

L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要 2~4 个时钟周期,而大小在几十 KB 到几百 KB 不等。

每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成指令缓存和数据缓存。

L2 高速缓存

L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期。

L3 高速缓存

L3 高速缓存通常是多个 CPU 共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。

访问速度相对也比较慢一些,访问速度在 20~60个时钟周期。

img

cache与主存之间的缓存映射

cache的数据结构

img

cacheLinecache和主存之间数据传输的最小单位。即当CPU试图load一个字节数据的时候,如果cache缺失,那么cache控制器会从主存中一次性的加载cacheLine大小的数据到cache中。

比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU 就会顺序加载数组元素到 array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。

cache和主存间的映射?

将所有索引一样的CacheLine组合在一起称之为组

主存中的数据可能存放在一组中任意的cacheLine上。

颠簸:以直接映射为例,访问0x40地址时,就会把0x00地址缓存的数据替换。

直接映射:

img

  • 1路组相连,即只有8组,每个组里有1行cacheLine。也就是说,index相同的情况下,只有定位到一行cacheLine。定位到之后,只需要比较这一行cacheLinetag
  • 用于比较的计算复杂度低,降低硬件设计的复杂度;但颠簸率高

为什么比起其他方式,颠簸率高?

S路组相连缓存:

img

  • 以2路组相连为例,8行cacheLine被平均分成了两路,在每路中,cacheLine的索引都是从0-3,那么一共就有4组。通过index定位cacheLine时,就会返回一组cacheLine,即两行cacheLine,接着就需要用循环比较这两行cacheLinetag
  • 用于比较的计算复杂度高,增加了硬件设计的复杂度;但颠簸率低
  • 直接映射就是特殊的1路组相连

全相连:

  • 即有N路,每一路有1行cacheLine,即有1组,这个组中有N个cacheLine,即通过index会定位到N行cacheLine。
  • 那么,就不需要用index来定位是哪行cacheLine了,每次直接取出N行cacheLine,然后用循环比较tag
  • 计算成本最高;颠簸率最低

如何在cache中查找数据?

地址结构:

img

cache结构:

img

  • index:首先,我们通过地址中的index用于在cache中索引cacheLine。
  • tag:找到cacheline,只代表我们访问的地址对应的数据可能存在这个cache line中,但是也有可能是其他地址对应的数据。因此,引入了tag。即找到cacheLine后还需要比较地址tag与cacheLine的tag来判断是否命中。
  • offset:如果要寻找的数据只占用1byte,那么一行8byte容量的cacheLine中可能存在多个数据段,这就需要用offset来判断要定位的数据在cacheLine的哪个偏移位上。

cache的分配和更新策略

分配策略

指我们什么情况下应该为数据分配CacheLine。

  • 读分配:当CPU读数据时,发生cache缺失,这种情况下都会分配一个CacheLine缓存从主存读取的数据。默认情况下,cache都支持读分配。
  • 写分配:当CPU写数据发生cache缺失时,才会考虑写分配策略。1.当我们不支持写分配的情况下,写指令只会更新主存数据,然后就结束了;2.当支持写分配的时候,我们首先从主存中加载数据到CacheLine中(相当于先做个读分配动作),然后会更新CacheLine中的数据。

更新策略

指当发生cache命中时,写操作应该如何更新数据。

dirty bit:每个CacheLine中会有一位用于记录数据是否被修改过,称之为dirty bit。如果cacheLine上存在多条数据,那么其中一个数据更新,相当于cacheLine上的所有数据都被标记为dirty

  • 写直通:当CPU执行store指令并在cache命中时,我们更新cache中的数据并且更新主存中的数据。cache和主存的数据始终保持一致。
  • 写回:当CPU执行store指令并在cache命中时,我们只更新cache中的数据,并且会将dirty bit置位。主存中的数据只会在 CacheLine 被替换时更新。因此,主存中的数据可能是未修改的数据,而修改的数据躺在cache中。cache和主存的数据可能不一致。

cache 硬件设计方式

CPU中使用的是虚拟地址,内存中是物理地址。那么夹在这中间的cache从硬件设计上既可以采用虚拟地址也可以采用物理地址甚至是取两者地址部分组合作为查找cache的依据。

歧义

  • 歧义指不同的数据在cache中具有相同的tagindex。上面介绍cacheLine时提到是否命中cache的判断依据就是tagindex,因此这种情况下,没办法区分不同的数据。
  • 不同的物理地址存储不同的数据,只要相同的虚拟地址映射不同的物理地址就会出现歧义。例如,进程A将虚拟地址0x4000映射物理地址0x2000,而进程B将虚拟地址0x4000映射物理地址0x3000。假设,缓存设计为直接映射。当A进程运行时,访问0x4000地址会将物理地址0x2000的数据加载到cacheline中。将A进程切换到B进程时,访问虚拟地址0x4000会命中缓存,但是拿到的不是物理地址0x3000,而是物理地址0x2000的数据。
  • 如何避免歧义的发生呢? 当切换进程的时候,可以选择flush所有的cache,即将所有cache标记为dirty,CPU读数据时,会未命中cache,而从主存中读取。

现在的硬件设计一般都采用物理地址的tag作为tag

别名

  • 别名指不同的虚拟地址映射相同的物理地址,而这些虚拟地址的index不同,即同一个物理地址的数据被加载到不同的cacheline中。
  • 例如,假设系统使用的是直接映射虚拟高速缓存,cache更新策略采用写回机制。假设物理地址0x8000存储的数据是0x1234,虚拟地址0x200和虚拟地址0x400都映射物理地址0x8000,即存储的数据是0x1234。首先访问并修改虚拟地址0x200的数据为0x5678。由于使用的是写回机制,因此修改的数据并不会同步到主存中,只是将虚拟地址0x200标记为Dirty。此时通过虚拟地址0x400访问物理地址0x8000的数据,拿到的还是旧数据0x1234
  • 如何避免别名的产生呢? 对于不同进程访问共享数据,可以在进程切换时强制 flush cache;对于同一个进程访问共享数据,可以在虚拟地址映射物理地址时,采用 no cache 的方式(这相当于没有使用 cache )。

虚拟高速缓存(VIVT)

  • 高速缓存地址的 tagindex 取自虚拟地址。
  • CPU发出的虚拟地址直接送到cache控制器,如果cache hit。直接从cache中返回数据给CPU。如果cache miss,则把地址发往**MMU**,经过MMU转换成物理地址,根据物理地址从主存读取数据。
  • 优点:不需要每次读取或者写入操作的时候把虚拟地址经过 MMU 转换为物理地址,这在一定的程度上提升了访问*cache*的速度,毕竟 MMU 转换虚拟地址需要时间。同时硬件设计也更加简单。
  • 缺点:使用了虚拟地址作为tag,会产生歧义问题,那么就会经常需要*flush cache避免歧义产生。那么在切换进程时,就会有大量的cache miss*,导致性能损失。

物理高速缓存 (PIPT)

  • 高速缓存地址的 tagindex 取自物理地址。物理的地址tag部分是独一无二的,因此肯定不会导致歧义。而针对同一个物理地址,index也是唯一的,因此加载到cache中也是唯一的cacheline,所以也不会存在别名。
  • CPU发出的虚拟地址经过MMU转换成物理地址,物理地址发往cache控制器查找确认是否命中cache
  • 优点:在软件层面基本不需要维护
  • 缺点:硬件设计上比虚拟高速缓存复杂很多,因此硬件成本也更高;并且,由于虚拟地址每次都要翻译成物理地址,因此在查找性能上没有VIVT方式简洁高效。

物理标记的虚拟高速缓存 (VIPT)

  • 高速缓存地址的 tag 取自物理地址(不会产生歧义),index 取自虚拟地址(存在别名问题)。
  • CPU发出虚拟地址对应的index位查找cache,与此同时(硬件上同时进行)将虚拟地址发到MMU转换成物理地址。当MMU转换完成,同时cache控制器也查找完成,此时比较cacheline对应的tag和物理地址tag域,以此判断是否命中cache

MMU(内存管理单元)

上面提到虚拟地址通过MMU转换为物理地址,那么虚拟地址和物理地址分别是什么?如何转换的呢?

物理地址和虚拟地址

如果程序运行是直接修改绝对的物理地址,那么,第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所这两个程序会立刻崩溃。

因此,我们需要把不同进程所使用的地址隔离开,即让操作系统为每个进程分配独立的一套「虚拟地址」,各进程互不干涉。由操作系统决定虚拟地址如何映射到物理地址,这个过程对各进程透明。

  • 我们程序所使用的内存地址叫做虚拟内存地址;
  • 实际存在硬件里面的空间地址叫物理内存地址。

MMU(内存管理单元)

操作系统管理虚拟地址到物理地址映射方式有两种:内存分段、内存分页。

内存分段

映射结构

imgimg

分段机制会把程序的虚拟地址按照程序逻辑分为不同段,如可分为代码分段、数据分段、栈段、堆段组成,同样也会将物理地址划分出相对应的段,由于物理内存中是顺序存储的,因此,每一段有一个起始地址。

段表中的每一项存储段基地址(即在物理内存中的起始地址)、段界限(即偏移范围):段基地址+段偏移=物理地址。

虚拟地址由段选择因子和段内偏移量组成。段选择因子中最重要的是段号,通过段号到段表中检索表项,就找到了段基地址;然后判断虚拟地址中的段内偏移量是否超过了段界限,如果没超过,就用段基地址+段偏移量计算出物理地址。

存在的问题

1、内存碎片

img

如图,如果游戏、浏览器、音乐程序都开着,并且都分配了不同的段。现在关闭浏览器,即释放出128MB的空闲内存。此时,想启动一个占200MB内存的程序,虽然物理内存中的空闲内存加起来是满足的,但是无法分配连续的内存。

为了解决这个内存碎片的问题,可以交换内存,即把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。(在 Linux 系统里,有一个 Swap 空间,是从硬盘划分出来的,用于内存与硬盘的空间交换。)

2、内存交换的效率低

对于多进程的系统来说,用分段的方式,很容易产生内存碎片,就不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。

所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。

内存分段的应用场景

Intel 处理器

逻辑地址: 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;

线性地址: 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;

早期 Intel 的处理器从 80286 开始使用的是段式内存管理,设了置四个段寄存器:CS/DS/SS/ES,分别表示代码段、数据段、堆栈段和其他段。每个段都是16位的,用作地址总线的高16位。

但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了页式内存管理。但是,80386并没有摒弃80286的段式内存管理,而是选择了向前兼容,并在在段式内存管理的基础实现保护模式,即对内存空间的保护:

  • 由段寄存器“确定”的基址不要透漏给用户,即用户无从读取段基址;
  • 修改段基址的指令必须是特权指令;
  • 每个段上必须加权限控制,权限不够,不许对内存进行访问;

为了实现保护模式,Intel引入了一个中间结构体,段描述符:

img

  • S、E、ED/C、R/W这几位描述了段的类型,是代码段还是数据段,可读还是可写,从而对访问进行控制。
  • DPL描述了特权级别,从而对内存进行保护。

由于加入了页式内存管理,段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。

img

Linux

因为 Intel X86 CPU 采用【一律对程序中使用的地址先进行段式映射,然后才能进行页式映射】的硬件结构设计,因此Linux 内核也得服从 Intel 的选择。

但是,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用,Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。

内存分页

映射结构(以二级页表为例)

img

内存分页会预先将虚拟内存和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)(在 Linux 下,每一页的大小为 4KB)。

页表中的每一项存储页号(作为页表索引)、物理页号(物理页每页所在物理内存的基地址):物理页号+页内偏移=物理地址。对于多级页表,页表又分为一级页号与二级页表的物理地址的映射表、二级页表与三级页表的物理地址的映射表、.....、n级页号与物理页号的映射表。

虚拟地址由一级页号、二级页号、页内偏移组成。通过虚拟地址的一级页号到一级页表中检索二级页表的物理地址从而找到二级页表。再通过虚拟地址的二级页号到二级页表中检索物理页号,将物理页号+虚拟地址中的页内偏移计算出物理地址。

为什么需要多级页表,而不是只有一级页表?

因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?

我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

对内存分段的优化

1、优化内存碎片问题

内存分页由于内存空间都是预先划分成连续等宽的内存区间,就不会像内存分段一样,在段与段之间会产生间隙非常小的内存。

2、优化内存交换效率

当内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

MMU工作机制

MMU介于处理器和片外存储器之间的中间层。提供对虚拟地址(VA)向物理地址(PA)的转换。一般封装于CPU芯片内部。

处理器根据虚拟地址访问物理内存的分为页表项命中和页表项未命中两种情况:

  • 页表项命中意味着页表项的有效位为1,页表项存储的是物理页号,即虚拟页已经缓冲在物理页中;
  • 页表项未命中意味着页表项有效位为0,即虚拟页还未分配磁盘空间,或已分配磁盘空间,但还没有缓冲到内存中。

1、页表项命中(以一级页表为例)

img

  • CPU将虚拟地址送入MMU,MMU根据页表基址寄存器中页表的起始地址加上虚拟页号,找到了页表项的物理地址PTEA
  • MMUPTEA送入到高速缓冲或者内存。
  • 从高速缓冲或者内存中找到页表项(PTE),返回页表项(PTE)给MMU
  • MMU根据PTE找出物理页号,然后加上虚拟页偏移量形成物理地址(PA),送入到高速缓冲或者内存。
  • 高速缓冲或者内存获取数据,返回数据给处理器。

2、页表项未命中

img

  • CPU将虚拟地址(VA)送入MMU,MMU根据页表基址寄存器中页表的起始地址加上虚拟页号,找到了页表项的物理地址PTEA
  • MMUPTEA送入到高速缓冲或者内存。
  • 从高速缓冲或者内存中找到页表项(PTE),返回页表项(PTE)给MMU
  • MMU根据PTE,发现页不在内存中,未命中,因此MMU发送一个缺页中断,交由缺页异常处理程序处理。
  • 缺页异常处理程序根据页置换算法,选择出一个牺牲页,如果这个页面已经被修改了,则写出到磁盘上,最后将这个牺牲页的页表项有效位设置为0,存入磁盘地址。
  • 缺页异常程序处理程序调入新的页面,如果该虚拟页尚未分配磁盘空间,则分配磁盘空间,然后磁盘空间的页数据拷贝到空闲的物理页上,并更新PTE的有效位为1,更新物理页号,缺页异常处理程序返回后,再回到发生缺页中断的指令处,重新按照页表项命中的步骤执行。

TLB(页表缓存)

从上文可以看出,虚拟地址翻译成物理地址的过程还是比较复杂的。为了加快翻译速度,就在 CPU 中,加入了一个专门存放程序最常访问的页表项的 Cache,即TLB页表缓存。

MMU根据虚拟地址获取页表项时,先查询TLB,在TLB找到了物理页表项后,就不需要从高速缓冲/内存中获取了,找不到了才会计算物理页表项地址PTEA,然后再从高速缓冲或者内存中获取页表项(PTE)。

img

结构组成

imgimg

上次分享说到,为了加快翻译速度,就在 CPU 中,加入了一个专门存放程序最常访问的页表项的 Cache,即 TLB页表缓存。

再回顾一下,虚拟地址由 Tag(校验使用)、Index(检索cacheLine)、Offset(定位数据在CacheLine上的偏移)三部分组成。

对于TLB这种特殊缓存,由于每一行CacheLine上存储的就是一个物理地址,因此,只需要根据虚拟地址中的Index检索到CacheLine,再比较Tag就返回物理地址了,没Offset啥事了。

工作机制

截屏2022-10-18 16.38.53

  • CPU 产生一个虚拟地址,并将虚拟地址发送到MMU;
  • MMU用虚拟地址的页号到TLB中检索CacheLine,然后取出页表项返回到MMU;
  • MMU将返回的页表项翻译成物理地址,并将其发送到高速缓存/内存;
  • 高速缓存/内存将数据字返回给CPU。

别名、歧义

  • TLB存储的是虚拟地址和物理地址的映射关系,因此是一一对应的,就不存在别名的问题。
  • 由于不同的进程之间看到的虚拟地址范围是一样的,所以多个进程下,不同进程的相同的虚拟地址可以映射不同的物理地址。就会存在歧义的问题。

flush TLB

上文提过,避免歧义问题,需要不断flush cache,即每次进程切换时,将整个TLB置为无效,这样切换后的进程都不会命中TLB,但是会导致性能损失。

那么,如何尽可能的避免flush TLB?:为TLB添加一项ASID(Address Space ID)。每创建一个进程,都为它分配一个ASID。每次去TLB查找时,除了比较tag再比较一下ASID,来区分一下进程。

参考

图解系统介绍

高速缓存与一致性专栏