3.9高速缓冲存储器Cache

一、前言

从这一个小节开始,我们要进入这一章的重中之重。大题和小题的高频考点就是Cache。

之前我们学习了存储系统的一些优化策略,对于主存,我们可以用双端口RAM,还有多模块存储器这样的方式来提高主存的工作速度。但是无论主存的速度再怎么提高,相比于 CPU 的读写运算速度,依然速度差距是很大的。所以如何解决这个问题,一个比较容易想到的方法是,我们可以设计更高速的存储单元,比如把 DRAM 芯片改成 SRAM 芯片。但是这又意味着我们的存储器价格会更高。或者从另一个角度来讲,当你成本相同的情况下,容量肯定是需要下降的。
在这里插入图片描述

这个问题怎么解决?基于程序的局部性原理,我们可以再增加一个 Cache 层,用这样的方式来缓和CPU和主存之间的速度矛盾。

二、Cache工作原理

1.Cache 的工作原理。

先来看没有 Cache 的情况。假设这是大家的一个手机,你的手机可能是 128GB 或者 64GB 等等比较大的一个辅存。
在这里插入图片描述

你在你的储存空间上可以安装微信、王者荣耀各种各样的你喜欢用的软件或者游戏。
在这里插入图片描述

现在假设你的微信要启动,你启动微信的过程其实就是把微信相关的程序代码还有数据给它调入内存的一个过程。
在这里插入图片描述

比如微信里边可能会有一个模块是用来处理文字聊天的,还有一个模块用来处理视频聊天,还有一个模块的代码指令,专门用于实现你的朋友圈相关的功能。当然了,除了你应用软件的这些代码指令之外,也会有一些相关的数据被调入内存。比如你的微信的聊天数据,还有朋友圈里一些图片之类的缓存数据,也需要放到内存里。把这些数据调入内存之后,你的微信就可以正常的开始运行了。

在这里插入图片描述

微信运行的过程其实就是 CPU 从内存里一条一条地来取这些指令,并执行指令的过程。

不过之前我们说过, CPU 速度是很快的,而内存(主存)它的速度很慢,所以快速的 CPU 每一次从主存(内存)里边读取数据时, CPU 的执行效率都会被内存的读写速度所拖累。

在这里插入图片描述

现在考虑这样的一个场景,假设现在你使用微信,正在和你的朋友或者和你的家人视频聊天,在你视频聊天的这一段时间内,是不是微信的这些数据当中,只有视频聊天相关的指令代码在这段时间内会被频繁地访问。所以如果我们能够把视频聊天相关的代码把它复制一份,把它复制到一个更高速、读写速度更快的 Cache 当中,那么CPU就可以直接从 Cache 当中读取视频聊天相关的指令和数据。

而 Cache 的读写速度比内存快多了。这样CPU和内存之间的速度矛盾就会缓和。
在这里插入图片描述

之前我们给过一个截图,三星的内存条,它的读写速度大概是 37 GB 每秒,而 Cache 可以达到接近 1000 GB 每秒这样的一个读速度。写速度虽然比读速度要慢一些,但是也要比内存要快得多。

所以 Cache 要比内存快得多,可以更好地配合高速的 CPU 工作。

这就是 Cache 的工作原理。

上面的图中,我们把 Cache 和 CPU 画成了两个框。但事实上,现在的计算机通常将 Cache 高速缓冲存储器集成在CPU 内部,并且是用 SRAM 这种芯片来实现。之前我们说过, SRAM 要比 DRAM 速度要快得多,但是成本也会更高。

另外 SRAM 的集成度会更低。这就意味着如果我们想要把 Cache 高速缓冲存储器塞进一个很小的手机里边,由于它的集成度很低,所以在芯片的大小不能做的特别大的情况下,就注定了 Cache 的存储空间,就是存储容量不可能做得特别大。这并不是有钱就能解决的事情,你得考虑到它的集成度,它的体积。所以通常现在电脑上的CPU,比如英特尔 I5 系列的CPU,它的 Cache 的大小可能也就是 12 兆字节。

2.局部性原理

刚才我们说的视频聊天的场景,可能大家觉得会比较特殊,其他的程序运行也会存在我们刚才所描述的这么理想的场景吗?

就是说在某一段时间内,CPU是否只会访问到某一部分的数据。

这个问题我们可以用程序的局部性原理来解释。

程序的局部性原理是指程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分,相应地,执行所访问的存储空间也局限于某个内存区域,局部性原理又表现为时间局部性和空间局部性。

(1)空间局部性

来看这样的一段程序。程序A,我们传入了一个二维的数组, M 行 N 列。

在这里插入图片描述
这个程序里边,用了两层嵌套for 循环,一行一行地访问二维数组a里边的各个数据。对于这个程序,它在运行的时候,我们需要把这个程序的代码翻译成二进制的机器指令。另外像数组还有各种各样的变量,这些数据我们也需要把它放到内存里边。

大家都应该学过 c 语言和数据结构,我们知道对于 c 语言里的二维数组,有 m 行、 n 列这样的二维数组。当我们把这些数据放入内存的时候,其实是一行一行来存储的。所以我们看一下,内存里边0x400地址存储了 a[0][0] 数据, 0x404 这个位置存储了a[0][1]数据。为什么这两个地址相差是4?因为我们每一个数组元素是一个 Int 类型,而一个 Int 类型刚好是 4 个字节。总之,从逻辑上看,这个数组它好像是一个二维的数组,但是我们放到内存里边的时候,这个数组会被展开成这种一维的形式来顺序的存储,一行一行的存。
现在结合我们程序的逻辑,要一行一行地访问二维数组。

不难得出这样的一个结论,假如现在我们访问了 a[0][0]元素,在接下来一段时间内,和a[0][0]数据相邻的其他元素是不是很有可能会紧接着被访问?

所以这就是所谓的空间局部性。就是最近的未来有可能要用到的信息,很有可能是在现在我们正在使用的这一个信息的存储空间周围的那些数据。

刚才我们看的是数组的数据,其实除了数据之外,指令的访问也存在空间局部性。

因为这些程序最终肯定是被翻译成一条一条的机器指令。这些机器指令在内存里边也是顺序存放的。因此,当我们访问某一条机器指令的时候,在不久的将来,与这条机器指令相邻的其他这些机器指令也有可能会紧接着被使用到。

这就是所谓的空间局部性。

(2)时间局部性

时间局部性指的是在最近的未来要使用到的信息,很有可能是现在我们正在使用的信息。

要理解时间局部性,最典型的例子就是程序里的循环结构。

比如这个加法sum+=a[i][j],它所对应的指令可能是存放在内存里的某一个部分。如下:
在这里插入图片描述
当我们访问了这一条加法指令之后,由于有这种循环结构的存在,就意味着在未来很短的时间内,有可能会再次使用到加法所对应的指令。

(3)总结

这就是空间局部性和时间局部性。

空间局部性是因为我们的指令和数据在内存里边通常是顺序存储的,而我们访问这些指令和数据的时候,很多时候都是需要顺序的访问,所以这就导致了空间局部性。而时间局部性主要是因为我们程序里边会存在大量的循环结构,所以导致了时间局部性。

除了对指令的访问具有时间局部性之外,其实对某一些数据的访问也具有这种时间局部性。

比如对于变量i,变量j,变量sum,这些变量也因为循环结构的存在,很有可能在短时间内被重复地访问。所以基于程序执行的局部性原理,我们不难想到这样的策略。

由于 CPU目前访问的这些主存地址,它的周围那些数据很有可能在不久的未来就被我们使用。另外,CPU当前访问的地址也有可能在未来被重复访问。

所以我们可以把目前访问的地址周围的一部分数据,先把它放到 Cache 当中,也就复制一份到 Cache 里边。接下来 CPU 就可以直接从 Cache 里读取相应的数据。

比如当我们在访问数组的时候,如果此时 CPU 访问了a[0][0] 这个元素,我们完全可以制定一定的策略,把 a[0][0]之后的多个数组元素,把这些数据复制一份到 Cache 当中,这样接下来 CPU 想要再访问这些数据,是不是只需要去 Cache 里边找就可以了?这样就可以大幅地提升 CPU 的运行速度。

所以局部性原理就是 Cache 能够有效工作的一个理论依据。

现在我们来把程序 A进行一个改造。程序A是对二维数组进行一行一行地访问,我们把 for 循环稍微改一改,把它改成一列一列地访问。就是程序B这种:(就是将两个for颠倒了一下)
在这里插入图片描述

对于程序 B来说,意味着它访问二维数组的顺序应该是这样的。首先访问a[0][0],接下来要访问的是a[1][0],再往后应该要访问的是a[2][0]。因为是一列一列地访问,也就意味着程序B这种执行方式,它的空间局部性要比程序A更差一些。

当程序A在访问某一个数组元素的时候,接下来它访问的肯定是与之相邻的其他元素,而程序B它是跳着来访问的,所以程序B的空间局部性会更差。

在实际当中,可能程序B它的实际运行时间要比程序A慢得多。因为程序 A可以很大概率直接从 Cache 当中找到它想要的数据。这是局部性原理。

3.性能分析

(1)方案一:先访问cache,cache未命中再访问主存

接下来我们来看一下,增加了 Cache 之后,整个系统的运行效率可以提升多少。

还是以刚才微信的运行为例,整个微信它本来是有 1GB 这么大,但是现在我们把微信的其中某一小部分的代码和数据,放到 Cache 当中,把它复制了一份到 Cache 当中。

我们可以假设 CPU 对 Cache 的访问,也就是对cache进行一次读写只需要tc这么长的时间(脚标 c 指的就是Cache)。 CPU对内存的读或者写每一次访问需要tm 时间(m 指的是memory)。

在这里插入图片描述

现在 CPU 如果想要执行的是视频聊天相关的代码,这些代码就可以在 Cache 里边找到;或者 CPU想要读取朋友圈相关的数据,这些数据也可以在 Cache 当中找到。

所以对于CPU想要的数据,它如果能够直接在 Cache 里边找到,我们就称这种现象为 Cache 命中,与之对应的一个指标,就是所谓的命中率,就是CPU想要访问的信息,它已经在 Cache 当中的比例
与命中率相对的是缺失率,就是没有命中的概率。
在这里插入图片描述

所以,如果我们能够知道访问Cache,访问主存分别需要花多长时间, Cache 命中的概率是多少,我们就可以计算出系统它的平均访问时间应该是多少。

首先,既然 Cache 更快,CPU 肯定优先会去 Cache 里边找数据。能够找到的概率是H这么多,从 Cache 当中读出数据,总共只需要花tc 这么长的时间。

另一种情况也有可能会发生,就是在 Cache 里边找不到数据的情况。比如此时 CPU要运行的是文字聊天相关的代码, CPU 先花了 tc 这么长的时间在 Cache 里边找,没找到。接下来 CPU 就会花 tm 这么长的时间去内存里边再去读数据。而这种情况发生的概率应该是 1-H 。所以增加了 Cache 之后, CPU进行一次读写所需要的平均时间 t 就应该是这么长。
在这里插入图片描述

显然平均读写时间应该是大于 tc,但是又小于 tm 的。

大家需要注意理解刚才所说的过程。

CPU是先去 Cache 当中找,无论找得到还是找不到,都是要花 tc这么长的时间,如果没有命中没找到,CPU才会紧接着去主存里边找数据。

(2)方案二:同时访问cache和主存,若cache命中则立即停止访问主存

有同学可能会说, CPU可不可以同时去 Cache 和内存里边找数据?其实这种方案也是可以的。

<1> 比如此时 CPU想要找的是视频聊天相关的代码,它会同时去 Cache 和内存里边找。
在这里插入图片描述

但是当过了 tc 这么长的时间之后,它在 Cache 里边找到了, Cache 命中了。这个时候它就可以立即停止在内存里的查找。

所以在 Cache 命中的情况下, CPU的访问时间同样只需要 tc 这么长的时间。这种情况发生的概率应该是H。

<2> 另一种情况,假设CPU此时要找的是文字聊天相关的代码,CPU 同时会去 Cache 和主存里边找,但是过了tc 这么长的时间之后,在 Cache 里边找不到。

既然Cache里边找不到,我们继续在内存找不就行了?但是由于左边和右边的查找是同时开始的,因此总共过了tm 这么长的时间之后,CPU 就可以直接从内存里找到相关的数据,而这种情况发生的概率是 1 - H 这么多。
在这里插入图片描述
所以和上面这种方式相比,下面这种方式,它的平均访问时间肯定要更短,效率会更高。因为在 Cache 没有命中的情况下,我们所需要花费的时间更少,就是少了一个 tc 。

(3)考题

对于这种 Cache 存储系统也经常作为考题进行考察。
在这里插入图片描述
这个题目告诉我们,它采用的是 Cache 和主存同时被访问,也就是我们刚才说的第二种方案。

①来简单算一下 Cache 和主存同时被访问的情况。
在这里插入图片描述

对于系统来说,在没有引入 Cache 之前,我们每一次存或者取一个数据,肯定都得对主存进行访问,主存的访问一定是需要 5t 这么长的时间的。而引入了 Cache 之后,我们的平均访问时间降到了 1. 2T。所以引入了 Cache 之后,整个读写的性能变成了原来的 4. 17 倍,这个提升还是很明显的。

②先访问Cache再访问主存

再来看另一种情况,如果我们之前提到的第一种策略,就是先访问Cache,当 Cache 未命中的时候,再开始访问主存。
在这里插入图片描述

这种情况下, Cache 命中的时候,访问时间就应该是t,没有命中的时候,访问时间就应该是t再加上5t,也就是 6t 的时间。所以平均访问时间我们可以相乘相加就可以得到,应该是 1. 25t,再和 5t 进行一个相除就可以得到,性能是提升为了以前的 4 倍,要比上面这种方式要更慢一些。

所以这种访问的策略不一样,平均访问时间的计算也会有一点点的区别,大家在做题的时候需要注意审题。

5.待解决问题

目前为止,我们已经大致了解了 Cache 的工作原理,并且通过题目,相信大家能够感受到引入 Cache 之后整个系统的性能提升。但是对于Cache,我们依然还有很多需要解决的问题。

(1)如何界定局部性

我们之前说过,基于局部性原理,我们其实可以把CPU目前访问的地址的周围部分数据先把它放到 Cache 当中。那么我们如何界定所谓的周围的数据?

这个问题可以这么解决。我们可以把整个主存的存储空间和cache空间都进行一个分块,每一个块的大小是相同的。以块为单位进行数据交换。
(1)将主存"分块"

比如一块是 1 k B,主存和 Cache 之间会以块为单位进行数据的交换。比如此时我们访问了a[0][0]数组元素,由于整个主存被我们分为了这样一个一个的块,每个块大小是1KB。所以当我们访问数组元素的时候,我们可以通过地址信息来判断数组元素它从属于哪一个分块。比如它的整个分块是这样的一个范围。我们就可以把这一整块的数据全部放入到 Cache 当中,给它复制一份。
在这里插入图片描述
这就回答了刚才提出的问题。我们是以块为单位进行数据的交换的。整个主存会被分为大小相等的一个一个的块。为了方便管理, Cache 里的这些存储空间,也会被分为大小相等的一个一个的块。
在这里插入图片描述

来看我们这儿给出的例子。假设整个主存总共是 4 兆字节, 4 兆字节应该是 2 的22 次方,这么多个字节。它被分为了 1 k B 1 k B 的大小。而 1K 应该是等于 2 的 10 次方。所以用 2 的 22次方除以 2 的 10 次方,我们就可以算出整个主存被分为了多少块,应该是 2 的 12 次方,也就是 4096 这么多个块。所以我们可以给主存的各个块进行一个编号,从 0 开始一直到4095,每一块的大小是 1 kB。

如果主存它是按字节编址,是不是意味着主存它的地址空间应该包含 22 个比特位,因为总共有 4 兆个字节。我们对主存分块之后,整个主存总共有 22 位的地址,我们可以把它拆分成两个部分。
在这里插入图片描述
前边的 12 位用来表示块号,因为 12 位刚好可以表示 0 到 4095 这个范围。末尾的 10 位地址,我们可以用它来表示块内的地址,因为每一个块总共有 1K 个存储单元,刚好可以用 10 个比特位来表示。对主存分块之后,主存的 22 位地址,可以把它看作是由块号和块内地址这样两个部分来组成的。

(2)将Cache分块
现在再来看Cache,假设我们的Cache,它的存储空间只有 8KB这么多,我们把 Cache 分块之后,它总共只能被分为 8 个块,块号分别是 0- 7,每个块的大小同样是 1 kB。
所以对 Cache 和主存分块之后,接下来对这两个部分的数据进行交换,是不是就很方便了,可以一块一块地来交换。

(3)补充
1.术语
再聊几个术语。

在操作系统当中,主存的这样的一个块也会被称为一个页,或者叫一个页面,或者一个页框。大家在做题的时候,如果看到这样的描述,需要知道他说的其实就是主存的一个块。

另外,在很多教材当中,对于 Cache 的一个块,也会把它称为一个Cache行, Cache 的块号也会被称为行号。我个人比较喜欢把 Cache 和主存的这两个术语进行一个统一,都把它们称作块还有块号,因为它们之间本来就有这种对应关系。所以接下来的讲解当中,我们会用 Cache 块还有主存块或者内存块这样的方式来描述。

再次强调,主存和 Cache 之间是以块为单位进行数据交换的

另一点需要补充的是, CPU优先会从 Cache 里边找数据,但是如果 Cache 当中找不到, CPU又会到主存里边找数据。当 CPU 访问了主存的某一个存储单元之后,接下来一定会把存储单元所从属的这一整块立即调入到 Cache 当中。注意这个过程只是把数据复制一份,并不会把主存里的这些数据给删除。

现在问题产生了,我们访问的这些主存块,它有可能被放到 Cache 的每一个位置。

(2) 主存块和cache块如何对应

主存块和 Cache 块它们之间的这种对应关系,我们应该如何记录? CPU 又如何区分 Cache 里的数据和主存里的数据的一个对应关系?

这个问题就是我们下一小节会学习的 Cache 和主存的映射方式。

(3) cache满后怎么办

我们之前说过, Cache 的容量很小,而主存的容量一般要比 Cache 要大得多。这就意味着我们只可能把主存中的一小部分数据放到 Cache 当中。当 Cache 满了之后应该怎么办?

这个问题我们会在下下小节当中进行探讨。就是替换算法要解决的问题。

(4)怎么保持主存块cache块数据一致性

我们主存里保存的数据,它是被复制了一份到 Cache 当中。比如你用毁图秀秀 p 图的时候,你的图片数据有可能会被放到 Cache 当中。图片的数据是被复制了一份到 Cache 里边。当我们进行 p 图的时候,其实 CPU 会优先更改的是 Cache 里边保存的数据。而 Cache 里的数据,它只是一个数据副本,真正的数据母本是被保存在主存当中的。我们应该如何保证数据副本和数据母本它们之间的一致性?这也是后面的小结会解决的问题,也就是 Cache 的写策略所要探讨的问题。

所以通过小结,希望给大家建立起一个关于 Cache 的全局观。接下来的几个小节当中,我们带着这些问题来寻找答案。

6.小结

在这里插入图片描述

这个小节当中我们介绍了高速缓冲存储器 Cache 的一个基本工作原理,还有局部性原理,分为空间局部性和时间局部性。
正是因为程序局部性原理的存在,我们在引入了小容量的,但是高速的 Cache 之后,依然可以使整个系统的性能得到一个很明显的提升。

对于我们的性能提升了多少,这个问题大家一定需要注意审题,有这样的两种方式,第一种方式是先访问Cache, Cache 没有命中的情况下再去访问主存。第二种方式是同时对 Cache 和主存进行访问,如果 Cache 命中,会立即停止主存的访问,而如果 Cache 没有命中, CPU 会继续完成对主存的访问,所以第二种方式它的性能会更好一些。

最后我们还引入了几个很重要的概念,就是主存和Cache。它们都会被分块,每一个块的大小都是相同的。主存和 Cache 之间会以块为单位进行数据交换。

另外也需要注意主存块还有内存块它们的一个别名。

另一个方面,主存的存储空间被我们分块之后,主存的地址,我们可以在逻辑上把它看作是由主存块号和块内地址这样的两个部分来组成的。

这些内容如果大家之前学过操作系统第三章,理解起来应该是很容易的。

这个小节的最后我们留下了一些关于 Cache 的问题,接下来的几个小节我们会逐一地解决下面那些问题。

三、cache和主存的映射方式

上面我们留下了这样的几个问题。
在这里插入图片描述
由于Cache,它保存的是主存里边的某一些数据块的副本,我们必须考虑到如何区分 Cache 和主存它们之间的这种数据块的映射关系。下面我们来探讨这个问题。

1.介绍

(1)三种映射方式

我们总共会学习三种映射方式。

<1> 第一种叫做全相联映射。如果采用这种映射策略,就意味着对于任何一个主存块,它可以存放到 Cache 里的任何一个 Cache 块当中。
在这里插入图片描述
采用这种方式,就意味着 Cache 里的某一行或者说某一个 Cache 块,它里边的数据有可能来自于主存的任何一个位置。

<2> 第二种叫直接映射。直接映射方式就是主存里的某一个主存块,它只能存放在 Cache 当中某一个规定的位置。

怎么规定位置呢?我们可以用主存块号对 Cache 的总块数进行一个取余的操作。余数是多少,我们就把相应的主存块放到哪个位置。
在这里插入图片描述

比如对于这个例子来说, Cache 块的总数应该是有 8 块,对于 1 号主存块来说,它的存放位置就是 1(1%8=1)。 再来看,对于 9 号主存块, 9 对 8 取余也等于1。所以如果要把主存块的内容调入Cache,同样的,它只能放到 Cache 块号为 1 的位置。
a所以如果采用直接映射方式,就意味着每一个主存块它只有可能会存放到 Cache 里的某一个特定的位置。

<3> 第三种叫组相联映射。看这个图不难理解,我们会把 Cache 的各个块进行一个分组,每一组的总块数是相同的。

比如在这个例子当中,我们把 8 个 Cache 块分成了 4 个分组,每一个分组当中会有 2 个 Cache 块。对于任何一个主存块,我们可以用主存块号对分组的总数进行一个取余,来确定某一个主存块它应该放在哪一个分组。
在这里插入图片描述

这个例子当中,总共分了 4 个分组。对于1 号主存块来说,它可以存放的位置应该是1(1%4=1),也就是它应该放到第一个分组里边,第一个分组里边有两个空位,哪个地方有空,我们就可以放到哪。

再来看 9 号主存块, 9 对 4 取余余数等于1,所以 9 主存块它也应该放到第一个分组。同样的,第一个分组内哪有空就可以把它放到哪儿。

(2)标记及有效位

回到刚开始提出的问题,我们应该如何区分 Cache 当中存放的是哪一个主存块?

一个比较容易想到的办法是,我们可以给每一个 Cache 块或者每一个 Cache 行建立一个所谓的标记,可以记录下来 Cache 块它所对应的主存块号是多少。以主存块号作为标记。

所以对于全相联映射来说,如果我们给前三个 Cache 块给它们记录的标记是9、8、5。意思就是 0 号 Cache 块,它存放的数据其实是 9 号内存块的一个副本。 1 号 Cache 块,它所存放的数据应该是 8 号内存块的一个副本。 2 号 Cache 块也是类似,存放的是 5 号主存块的一个副本。所以我们可以用主存块的块号来作为标记,记录下来每一个 Cache 块它所对应的数据应该是什么。

现在看起来好像问题已经解决了,是不是只有标记就够了?

大家要知道,这儿所谓的标记在计算机内部,在计算机硬件的层面,其实记录的就是一些二进制的 0101 ,就是用二进制来记录主存的块号。

像刚才这个例子当中,我们并没有记录后续这些 Cache 块它的标记,我们让它保持为空就可以。然而对于计算机硬件来说,所有的二进制位只有可能是 0 或者 1 这样的两种状态,不可能出现所谓空的状态。
在这里插入图片描述

所以站在计算机硬件的角度来看,其实刚开始所有的这些 Cache 块的一个标记信息,肯定都会被初始化为全 0 这样的一个状态。后边这些标记都是全0,是不是意味着 3、4、5、6、7 这几个块保存的都是主存块号为 0 的一个副本?显然不对。所以我们光有标记是不够的,我们还需要增加一个有效位。有效位可以为 0 或者为1,如果为1,表示标记是有效的;如果为0,表示标记无效。
在这里插入图片描述
像这个例子当中,只有最后这一行 (7 这一行),它的有效位是1,同时标记为0,就说明 7 号 Cache 块它所存放的数据是 0 号主存块的一个副本。所以对于每一个 Cache 块来说,除了给它配一个标记之外,还需要给它配一个有效位。

而这些东西都是直接用硬件电路来实现的。

2.具体细节

接下来我们来展开看一下这三种映射方式的一些具体的细节。

(1)全相联映射

先来看全相联映射,也就是可以随意映射这种策略。
在这里插入图片描述
上个小节我们说过, Cache 行就是 Cache 块,每个的 Cache 行的大小为64 个字节,应该是和主存块的大小是保持一致的。

1.Cache
我们先给出 Cache 的图。如下:
在这里插入图片描述
总共有 8 个 Cache 行,就是 8 个 Cache 块,每一块的大小是 64 个字节,所以总共是 512 个字节。

2.主存
再来看主存,256MB的主存也是被分为了一个一个的块。

首先, 256 兆个字节应该是对应 2 的 28 次方这么多个字节,所以主存它总共的地址位数应该是28 位。256MB(2 ^28)
除以64B( 2^ 6),得到的结果是 2 ^22。这就意味着整个主存会被划分为 2的22 次方这么多个主存块。

我们可以用前边的 22 位作为主存块号,然后末尾的 6 位作为块内地址。
在这里插入图片描述
那么主存块号应该是从0一直到 2^22-1,这是主存块号的范围。每一个主存块的大小是 64 个字节,也就是每一个主存块会对应 64 个地址。我们可以把这些地址的范围进行一个标注。 如下:
在这里插入图片描述
第一个主存块,它的 22 位的块号都是0,块内地址是000000~111111(64个字节,64个地址)。后续的都是一样的。

3.映射
在这里插入图片描述
再次强调,每一个 Cache 块的大小和主存块的大小是相同的。另外,这两个部件之间传送数据是以块为单位。

接下来看一下全相联映射如何实现

首先我们一定需要有一个有效位,刚开始整个 Cache 都是空的,所以所有的有效位都把它记为0。接下来我们假设要把主存的第0块放到 Cache 当中。
在这里插入图片描述

由于采用的是全相联映射,这就意味着主存块可以放到 Cache 里的任何一个位置。假设我们挑选 3 这个位置,把它放进去。

为了区分 3 这个Cache 块它存放的是哪个主存块,因此我们需要在前边记录一个标记,其实就是主存块的块号。之前我们说过,主存块的块号总共需要占 22 位,所以这里我们记录了 22 个0。 (上图3号Cache位的蓝色数字)
在这里插入图片描述
再来看下一个。假设我们要把紫色的主存块放到 Cache 当中,同样的,我们可以随意挑一个位置把它放进去。
在这里插入图片描述
假设我们挑中的是 1 这个位置,把它放进去之后,同样的,我们需要修改有效位为1。同时把标记设置为当前的块号。后续的就不再展开。 总之,采用全相联映射,任意一个主存块可以放到 Cache 里的任意一个位置。

4.访存
接下来我们再来考虑这样的一个问题,采用这种映射方式, CPU 如何访问一个主存的地址?

命中情况:
假设现在 CPU 要访问的主存地址是这样的一个地址,它的主存块号刚好和我们紫色的这一块的主存块号一致。引入了Cache之后是这么做的。
在这里插入图片描述
首先会取主存地址的前 22 位,也就是取出主存块号。用主存块号来和 Cache 当中的每一行的标记进行对比。

如果某一个 Cache 行的标记和我们主存地址的块号是相同的,我们再检查有效位,当有效位为1,同时 22 位的标记匹配之后,就说明 Cache 命中,也就是我们此时要访问的地址所对应的数据,其实在 Cache 当中是存在副本的。

之前我们说过,每一个 Cache 块,每一个主存块都是 64 个字节的大小。所以接下来我们只需要根据后半部分这 6 位的块内地址(001110)来 Cache 当中找到相应的字节或者相应的字就可以。
上述是命中的情况。

未命中情况:
如果没有命中,就是所有的标记和我们给出的主存的主存块号都不能匹配。或者即便标记匹配了,但是有效位为 0 的情况下, CPU 就不能访问Cache,它必须进行一次访问主存的操作。

对于主存的访问如何实现,我们之前已经讲过具体的硬件细节了,这就不再赘述。

这就是基于全相联映射的一个缓存的过程。

(2)直接映射(主存块只能放固定位置)

1.映射
接下来我们再看第二种映射方式直接映射。

直接映射中,每一个主存块它只能放到固定的位置,具体可以放到什么位置可以用主存块号对 Cache 总块数进行取余得到。

在这里插入图片描述

比如,此时要把 0 号主存块放到 Cache 当中, 0 对 Cache 的总块数 8 取余等于0,这就意味着 0 号主存块它只能放到 Cache 的第0行第0块这个位置。我们把它放过去,同时把有效位改为 1 。标记和刚才一样,我们同样需要记录下主存块的块号。

现在,假设我们要把 8 号主存块调入Cache。

由于 8 对 8 取余同样等于0,这就意味着 8 号主存块它也只能被放到 0 号Cache块这个位置。
在这里插入图片描述
所以这个时候我们只能把之前存放的数据给覆盖掉,同时要把标记改为 8 号主存块的块号。大家可以看一下,0…01000翻译成二进制刚好是8 。

所以,如果采用直接映射方式,有个显而易见的缺点:虽然Cache里边还存在很多个空闲的 Cache 块,但是这些块我们都用不了,我们只能把主存块放到固定的位置。

因此相比于全相联映射,直接映射的灵活性要差一些空间利用率也不充分

接下来思考这样一个问题,我们这采用了 22 比特的标记,也就是把整个主存块号都给保存下来作为标记,这个方式可不可以进行优化?

要回答这个问题,我们得回到主存块它存放位置的确定方式。

主存块在Cache中的位置等于主存块号对 Cache 的总块数进行一个取余。这个例子当中,我们会发现, Cache 的总块数刚好是8,可以把它写成 2 的 n 次方这种形式, 8 等于 2^ 3。从二进制的角度来看,就是主存块号对 2 的三次方进行取余。这个运算的结果其实相当于我们保留了整个主存块号的末尾三位
在这里插入图片描述
这也就意味着,对于计算机硬件来说,硬件不需要去做这种取余的操作。计算机硬件只需要把主存块号的末尾 3 位,也就是把上图橙色的地址部分给截取下来。这三个二进制位直接指明了一个主存块它应该存放在 Cache 中的什么位置。

比如刚才的 0 号块(绿色),它的块号的末尾三位刚好是三个0,三个 0 对应十进制的0,而 8 号块,它的末尾三位也刚好是三个0,同样也是对应十进制的0。这也就意味着这两个主存块肯定是存放在 Cache 的 0 号位置。所以这就说明,如果某一个主存块,它能够存放在 0 号 Cache 位置,那么这个主存块的块号末尾的三位一定都是0。既然如此,我们是不是就没必要另外保存主存块号的末尾 3 位了?所以当我们的 Cache 总块数可以写为 2 的 n 次方这种形式的时候,主存块号的末尾 n 位就直接反映了它在 Cache 当中的位置。因此我们这儿存储的标记就没必要存储最末尾的 n 位的信息。对于这个例子来说,我们的标记位实际只需要存储 19 位就可以,末尾的 3 位是可以把它砍掉的。

我们把 Cache 的这些 Cache 块号翻译成二进制的形式,这样大家就可以更方便地和右边给出的这些主存的地址进行一个对比。
在这里插入图片描述
总之,主存块号的末尾三位如果是010,那么这个主存块一定是存放在 Cache 块号同样为 010 这个位置。经过分析之后,我们可以知道,在这个例子当中,只需要记录内存块号前边 19 位来作为标记,末尾的 3 位可以直接把它舍弃。

因此,如果采用直接映射,原本的主存块号,可以再把它细分为两个逻辑部分,前边的 19 位可以作为 Cache 行的标记,后边的 3 位反映了每一个主存块号应该存储在哪个 Cache 行。

2.访存
接下来看一下基于直接映射方式,如何进行访存。

命中情况:

假设此时 CPU 要访问的主存地址是这样的一个地址0…01000 001110,这个地址被包含在 8 号主存块当中(橙色)。

由于采用的是直接映射,所以第一步 CPU 应该确定主存块它会放在 Cache 的哪一个位置。

具体的做法就是
在这里插入图片描述

①取出块号的后三位的信息000(橙色部分)来确定主存块它在 Cache 中的存放位置。现在我们已经知道了,它应该是存放在 0 这个位置。

②所以接下来 CPU 会对比主存块号的前边 19 位0…01(蓝色部分),用这 19 位和 0 号 Cache 块的标记进行一个对比,如果标记相同,并且有效位也为1,就说明 Cache 命中。接下来再根据块内地址,在 Cache 块当中找到想要的数据就可以,这是命中的情况。

未命中
而如果没有命中,或者有效位为0, CPU 同样还是需要进行访存。这就是直接映射。

(3)组相联映射(每个主存块可放到特定分组)

1.映射
接下来再来看最后一种组相联映射。每一个主存块可以被放到特定分组当中的任意一个位置。
在这里插入图片描述
具体应该放到哪个分组?可以用主存块号对总的分组数进行一个取余操作。

假设我们采用了 2 路的组相联映射,所谓的 2 路指的是每两个 Cache 块为一组,总共有 8 个 Cache 块,所以我们会分为 4 组。所以 1 号主存块它应该是被放到了第一组。

第一组里任何一个空闲的位置,我们都可以把它放进去,比如把它放到 3 这个位置。

在这里插入图片描述
再看下面橙色的主存块,大家可以算一下。 2^22-3对 4 进行取余,刚好也是等于1,所以我们可以把它放到第一组的任何一个位置。
在这里插入图片描述
由于 2 号是空闲了,所以可以把它放到 2 号这个位置。

和之前类似,由于我们的分组数刚好可以写成 2 的 n 次方这种形式。

这个例子当中总共是有 2^2这么多个分组。所以主存块号对分组数进行取余,相当于我们只保留了主存块号的末尾 2 位(上图橙色的地址部分),而末尾的两位主存块号刚好又反映了它所属的分组的组号是多少。
在这里插入图片描述
所以大家并不需要去算主存块号这个数对 4 取余到底是多少,我们只需要直接看它的主存块号末尾的两位是01,就可以确定主存块号对 4 取余运算的结果一定是01,也就是对应十进制的1。所以应该把它放到第1个分组。

和之前类似,既然有可能出现在同一组的这些主存块,它们块号的末尾两位一定都是相同的。那我们是不是就没有必要记录后面的这两位了?所以在这个例子当中,我们的标记位只需要取主存块号的前 20 位进行记录就可以。

如果采用组相联映射,在这个例子当中,我们可以把这 22 位的主存块号进一步地细分为两个逻辑部分。
在这里插入图片描述
第一个前边的 20 位可以作为 Cache 行的标记,后面的这2位我们可以用来判断主存块应该放到哪个分组,也反映了组号的信息。

2.访存
来看一下如何进行访存。

命中
这地方我们把每一个 Cache 块它所从属的分组的组号给它标记上去了,可以2个比特来表示。
在这里插入图片描述
如果此时 CPU 要访问的主存地址是这样的1…1101 001110,此时 CPU 应该先判断这个地址它所从属的主存块,如果存放在 Cache 当中,应该放在第几个分组里边。

具体的判断方法就是:
在这里插入图片描述
①取出主存块号的后面两位01(橙色部分)。就可以知道,如果它在 Cache 中有副本,肯定是存放在编号为 01 的分组当中。

②接下来CPU就可以在01分组当中一个一个的去对比标记和我们给出的地址的标记是否能够匹配。如果能匹配并且有效位为1,就说明 Cache 命中。

③接下来再结合块内地址,再从 Cache 块里边读取出相应的存储单元就可以。

这是命中的情况。

未命中:
如果没有命中CPU,同样需要对主存进行访问。

这就是最后一种组相联映射方式。

3.对比总结

这一小节当中,我们学习了 Cache 和主存的映射方式,分为全相联映射、直接映射和组相连联映射。
在这里插入图片描述

不同的映射方式对于主存块可以存放在 Cache 的哪个位置,它的限制是不一样的。

由于这种限制不一样,因此当我们要记录 Cache 块和主存块的对应关系的时候,所需要记录的标记位也会出现一些区别。

再来简单分析一下各种地址映射方式的一个优缺点。

①对于全相联映射,由于它的自由度很高,我们可以把任何一个内存块放到 Cache 里的任何一个位置,因此 Cache 的存储空间会利用得很充分。

这就意味着,只要 Cache 没满,我们就可以继续往 Cache 里边存入更多的主存块,这就会导致 Cache 的命中率更高。

另一个方面,使用全相联映射,我们在查找标记的时候,有可能需要把所有的 Cache 行它的标记都进行一个对比。所以查找标记的速度在三种方法当中是最慢的。

②再来看直接映射

直接映射和全相联映射,刚好是两个极端。

它的优点就是对于任何一个地址,我们可以直接根据块号来确定它唯一有可能在Cache 当中出现的位置。

因此,我们只需要进行一次的标记对比,就可以确定 Cache 是否命中。所以这种方法,它标记的对比速度是最快的

另一个方面,由于每一个主存块只能放到 Cache 的某一个固定位置,所以 Cache 的存储空间利用得会很不充分,同时也会导致 Cache 的命中率降低。

③最后是组相联映射

其实是上面这两种方法的一个中和,综合效果会更好。

组相联映射方式具备了全相联映射的自由度,同时又具备了直接映射的标记对比速度。

大家需要注意,在做题的时候,有可能会出现 n 路组相连映射这样的一个描述。所谓的 n 路就是指每 n 个 Cache 行作为一组。

最后,同学们还需要能够根据每一种地址映射方式的地址结构来思考,如果给定一个主存地址,那么 CPU 是如何拆分地址的?如何查找Cache,如何对比标记,这些也是大家需要掌握的东西。

四、Cache替换算法

这个小结我们要学习的是 cache 的替换算法。

1.前言

(1)有待解决的问题

在之前的小结中,我们留下了这样的 3 个问题。
在这里插入图片描述
上一个小结我们解决了第一个问题,而第二个问题就是,我们的 cache 是很小的,主存是很大的。但是每一次被访问的主存块一定会被立即掉入 cache 当中,这就意味着 cache 很容易被装满。当 cache 装满之后,我们应该怎么办?这就是替换算法要解决的问题。

(2)地址映射方式

我们要结合上一小节学习的这些地址映射方式来进行考虑。

①第一种全相联映射。

这种映射方式意味着每一个主存块有可能会被放到 cache 的任何一个位置,所以如果采用这种映射方式,就意味着只有整个 cache 全部被装满之后,我们才需要在整个 cache 当中选择一个 cache 块,把它进行替换。

②第二种直接映射方式。

对于任何一个主存块,我们只能把它放到一个指定的特定的位置。这就意味着,如果这个位置原来已经有数据了,我们直接把原来的这份数据给替换掉、淘汰掉就可以了。所以,如果采用直接映射方式,其实我们不需要考虑替换算法到底要替换哪一块这样的问题,我们毫无选择,只能放到固定的位置。

③第三种组相联映射。

每一个主存块会被放到指定分组当中的任何一个位置。所以如果采用这种方式,就意味着只有内存块它所属的 cache 分组整个组都被装满之后,我们才需要选择到底要替换哪一块。所以替换算法只会被用到全相联映射,还有组相联映射这两种方式。直接映射的时候不需要考虑替换算法。我们以全相联映射为例,学习 4 种替换算法。
在这里插入图片描述
第一种是随机算法,第二种先进先出,第三种近期最少使用,第四种叫最近不经常使用。同学们也需要注意各种算法的英文缩写。

2.随机算法

(1)过程

首先来看第一种随机算法。顾名思义就是如果 cache 满了,我们随机选择一块进行替换,非常的free。

来看这样一个例子,假设有一个系统当中,它的 cache 块总共有 4 个,初始的时候所有的 cache 块都是空的,采用全相邻联映射方式。接下来我们访问的主存块编号分别是这样的一个序列,{1,2,3,4,1,2,5,1,2,3,4,5}也就是先访问 1 号主存块,再访问 2 号、 3 号、 4 号,再访问 1 号、 2 号,以此类推。

总共 4 个Cache块,并且刚开始所有的块都是空的。所以我们刚开始访问 1、2、3、4 这几个主存块的时候,需要把这些主存块依次调入到 cache 当中。把1 号主存块放到 0 号 cache 这个位置, 把2 号主存块放到 1 号 cache 这个位置。接下来 3 号和 4 号主存块也是类似,把它放到 cache 的相应位置。
在这里插入图片描述
刚开始访问的这几个主存块,都是没有命中的。每访问一个主存块,就需要把这一块的数据从主存调入到cache。由于前边的这 4 次访问, cache 一直都没被装满,所以我们不需要进行 cache 替换算法。继续往后。接下我们要访问的是 1 号主存块,由于此时 1 号主存块已经存放在 cache 当中,所以这一次的访问是可以命中的。接下来 2 号也是一样,也可以命中。
在这里插入图片描述

再接下来 5 号,由于此时 4 个 cache 块当中存放的主存块分别是 1234 号, 5 号主存块此时没有被调入cache。而我们之前说过,**每访问到一个主存块,就一定需要把主存块立即调入 cache **。所以此时我们就需要根据 cache 块的替换算法来决定要替换哪一块。根据随机算法的规则,可以随便选择一个块进行替换。

比如我们把 3 号主存块给换出去,换入 5 号主存块,没有任何规律,我们随便选的一个位置。
在这里插入图片描述
接下来 1 号和 2 号的访问都可以命中。
在这里插入图片描述
再接下来访问到 3 号块,此时 3 号主存块没有放到 cache 当中。所以同样的,我们随机选择一个块进行替换。假如我们把 4 号主存块换出去,把 3 号主存块换到它原先存放的位置,也就是 3 号cache行这个地方。
在这里插入图片描述
接下来又需要访问 4 号主存块,我们随机挑一个,把 1 号主存块给换出去,把 4 号换进来。
在这里插入图片描述

接下来访问 5 号储存块, 5 号此时是在 cache 当中的,所以可以直接命中,不需要发生替换。
在这里插入图片描述
这就是随机算法。

(2)分析

这种算法毫无规律,我们并没有考虑到什么程序的局部性之类的这些因素,所以这种算法它实际的运行效果会很不稳定, cache 的命中率可能会很低。

3.先进先出算法

(1)过程

接下来看先进先出算法。顾名思义,就是我们会优先把最先被调入 cache 的块给它替换出去

还是用刚才那个例子。刚开始访问 1、2、3、4 这几个主存块的时候,由于 cache 初始是空的,所以我们只需要把这些主存块分别调入到 cache 的相应位置就可以。
在这里插入图片描述
接下来访问 1 和2,这两个显然是可以命中了。
在这里插入图片描述
再接下来访问 5 号主存块。 5 号主存块此时并没有在 cache 当中。

根据先进先出的规则,此时我们放在 cache 里的这些主存块,最先被调入的应该是 1 号主存块,所以我们会优先淘汰 1 号主存块,然后把 5 号主存块放到 cache 0 位置。
在这里插入图片描述
接下来我们又要访问 1 号。 1 号刚才被我们淘汰了,现在又需要把 1 号调入cache。此时我们放在 cache 里的这几个块当中,最先被调入的应该是 2 号 cache 块。所以我们会淘汰 2 号,放入 1 号。
在这里插入图片描述
淘汰 2 号之后,紧接着又访问 2 号。类似的原理,在剩下的这几个主存块当中,最先被调入的应该是 3 号块,所以淘汰 3 号,放入 2 号。接下来访问 3 号。应该淘汰 4 号,掉入 3 号。再接下来访问 4 号储存块。剩下的这些块当中,最先被调入的应该是 5 号。所以淘汰5,放入4。接下来又访问5,此时剩余的最先被调入的应该是 1 号,所以换出 1 调入5。
在这里插入图片描述
这就是先进先出算法,很好理解,并且用硬件实现也很简单。

(2)分析

刚开始 cache 的所有行都是空的,我们可以按照行号递增的次序 0123 这样的次序来依次放入最初始的几个内存块。接下来我们只需要按照 0123、0123 这样的次序来替换各个块就可以了。

因为按照刚才这种策略,我们最先放入的就是 0 位置,所以我们最先应该淘汰的也是 0 位置。 0 这个位置被替换之后,接下来最先调入的应该就是处于 1 位置对吧?所以接下来还需要替换的话,我们替换1,1替换之后再往后想要替换,就替换 2 就可以了。

所以先进先出算法,用硬件实现起来也会很简单,只需要轮流的替换各个 cache 行就可以。

但是先进先出也没有考虑到局部性原理,因此 cache 的命中率也会很低

最先被调入 cache 的块,也有可能在之后会被频繁地访问到。

这个应该不难理解,比如大家写的 c 语言程序,很有可能在刚开始的时候就调用到了 print f 这个函数,这个函数你是最开始就使用到的,这并不意味着再往后这个函数你就用不到了,对吧?所以我们把最先调入 cache 的给淘汰,这种处理策略是不科学的。因此先进先出这种替换算法实际的运行效果也不太理想

另外,刚才我们分析到后面这个部分的时候,大家会发现我们出现了这样的一个有意思的现象,就是在这个地方,我们换出了1,换入了5, 1 刚被换出,紧接着又访问了1,所以我们不得不换出2,然后再换入1。而 2 刚被换出,接下来又访问到了2,所以就出现了这种频繁的换入换出的现象。

这种频繁的换入换出的现象被称为抖动现象:刚被换出的块,很快又被访问,所以紧接着又会被调入。

4.近期最少使用算法

(1)过程

第三个算法叫近期最少使用,英文缩写叫LRU。

这个算法的思想是这样的,我们会为每一个 cache 块( cache 行)设置一个计数器,用于记录 cache 块里面存放的主存块已经多久没有被访问过了。当我们需要替换的时候,我们会选择计数器最大的一个 cache 块把它替换出去。计数器最大就意味着这个块是最久没有被访问过的。

还是用刚才那个例子体会一下。

1.快速计算方法

首先跟大家介绍一个手算做题的方法。我们先不考虑机器是如何执行的。总共有 4 个 cache 块,刚开始都是空的。

第一个被访问的主存块,块号是1,我们可以把它放到 0 位置。第二个是2,我们可以把 2 号主存块放到 1 这个位置。3、4也是类似的。访问了前 4 个主存块之后, cache 里的情况就是上图这个样子 。接下来 1 和2,它们已经在 cache 当中,所以可以命中。

再接下来要访问5,此时就必须把其中的某一个主存块给替换出去。

根据算法的规则,我们要替换的是最久没有被访问过的一个主存块。

我们可以从 5 位置,从这个位置开始往前看。 2 最近被访问过,1 最近被访问过。 4 最近被访问过。所以此时 cache 里边这几个主存块最久没有被访问过的应该是3号块。
在这里插入图片描述
所以在这个时候我们可以淘汰3,调入5,其他的几个保持不变。

再接下来 1 和 2 又可以命中,然后又要访问到 3 号主存块, 3 号没有在 cache 当中。和之前的处理方法类似,我们从2号这个位置往前看, 2、1、5 是最近被访问过的,所以最久没有被访问过的应该是4号块。
在这里插入图片描述
所以我们淘汰4号,换入3号,其他的几个保持不变。这就是我们做题的时候可以采取的一个方法。

当需要替换一个 cache 块的时候,从当前访问的主存块号位置开始,往前看一下哪些主存块是最近被访问过的。从后往前看,**最后一个出现的主存块号,应该是需要替换的主存块,**后续的两个就不再展开。这是我们做题的时候可以采取的一个比较快的方法。

2.硬件角度分析

接下来我们再来看一下,用硬件,站在机器的角度,它是如何实现近期最少使用算法的。

上一小节中我们说过,每一个 cache 行需要有一个标记,用来记录 cache 行里边所存储的主存块号,另外还需要增加一个有效位。除了这些信息之外,如果要使用LRU 这种算法,我们还必须给每一个 cache 行添加这样的一个属性,就是计数器。刚开始所有的 cache 行都是空的,计数器也会被全部置为0。
在这里插入图片描述
计数器的变化规则是这样的:
在这里插入图片描述
如果我们访问某一个主存块号在 cache 里边没有命中,此时如果还有空闲的 cache 行,我们就会把主存块装入到一个空闲的行里边,同时把这个 cache 行所对应的计数器置为0,其余的非空闲的 cache 行计数器会全部加1。

来看一下什么意思。

<1> 刚开始我们访问的是 1 号主存块,此时没有命中,并且还依然有空闲的 cache 行,所以我们会把 1 号储存块放到 cache 0 这个位置,把 cache 行所对应的计数器设置为0,其余的非空闲行需要把计数器全部加1。在这个时刻,其他这些 cache 行都是空闲的,所以它们的计数器我们不需要管,不需要加1。
在这里插入图片描述

<2> 接下来访问 2 号主存块,我们可以把它调入到 cache 1 这个位置,并且把它的计数器设为0,同时把其它的非空闲 cache 行所对应的计数器加1。此时 cache 0 的计数器变为了1。
在这里插入图片描述

注意理解计数器的逻辑,它想要反映的是 cache 块已经多久没有被访问了

<3> 接下来是 3 号主存块,我们把它放到 cache 2 这个位置,计数器设为0,同时其他的这些非空闲Cache行计数器加1。
在这里插入图片描述

<4>再接下来访问 4 号也是类似的,其他的这些计数器都会加1。
在这里插入图片描述
<5> 接下来要访问的是 1 号主存块。

显然,1号主存块此时已经在 cache 当中,是可以命中的。当我们命中之后,我们会把所命中的 cache 行它的计数器清零。同时,计数器的值比它更低的其它的那些计数器,我们会把它加1,其余不变。
在这里插入图片描述
总之,我们此时访问了 0 号 cache 块,它的计数器应该从 3 变为0,计数器的值比 3 更小的这些cache行它的计数器的值都要加 1。2 变 3、1 变 2、0 变1。所以这种方式可以保证我们最近访问过的主存块,它的计数器的值肯定是最小的,可以恢复成0。

越久没有被访问过的cache行它的计数器的值会不断加1,就会变得越来越大。

<6> 接下来要访问的是 2 号主存块,显然也是可以命中的。

和之前的处理方式一样,我们会把当前访问的 cache 行把它计数器的值清零,另外比其计数器值小的其他计数器的值都加1。

在这里插入图片描述

<7> 接下来要访问的是 5 号主存块,此时 cache 没有命中。我们必须选择替换某一个 cache 块。

按照这个算法的规则,我们会替换掉计数器的值最大的一个 cache 块,也就是 2 号,它的计数器是3,这个cache 块当中存放的是 3 号主存块。

所以最后我们把 3 号主存块替换掉, 将5 号主存块放到这个位置。同时把计数器的值清零,其余的计数器的值全部加1。
在这里插入图片描述
<8>接下来要访问的是1,此时 1 是可以命中的。

因此我们会把 1 号主存块所存放的 cache 行它的计数器给清零。 另外,比这个计数器的值更小的那些计数器的值,我们需要加1。
在这里插入图片描述
也就是我们只需要把比 2 更小的 1 和 0 这两个计数器的值加1,而比 2 更大的计数器3的值,我们不需要加1。

思考一下,我们把 2 变为了 0,1 和0分别加了1。

我们当然也可以把 3 同样也进行一个加1,变成4。但是把 3 变为 4 其实毫无意义。让它的值保留原本的 3 也可以达到同样的效果。

因为我们设置计数器的目的其实是想通过计数器的值的大小来判断我们应该替换哪一个主存块。我们想要选择的是计数器的值最大的一个 cache 块,会把它给替换掉。 3这个值本来就已经是最大的,所以我们再让 3 继续加1,其实是没有意义的。

这种处理策略的好处就是,当我们只有 4 个 cache 行的时候,我们的 4 个计数器的值肯定是0、1、2、3。只有可能出现这几种情况,不可能出现4这种情况。所以注意体会这个细节。

<9> 接下来访问 2 号主存块, 2 号也可以命中。

我们会把 2 号块所对应的计数器把它变为0,同时把计数器更小的这些值让它加1,计数器更大的3让它保持不变。
在这里插入图片描述

<10> 接下来要访问的是3,此时 cache 没有命中,并且已经没有空闲的 cache 行了。

所以我们会选择计数器的值最大的 cache 块进行淘汰,把 4 号主存块淘汰出去,把 3 号主存块换进来。

在这里插入图片描述

另外还需要修改计数器的值为0,其余的这些计数器都需要加1。

<11> 再往后访问 4 ,没有命中。

我们同样需要把计数器最大的给它替换掉,就变成这个样子。
在这里插入图片描述

<12> 接下来访问 5 也是类似的,没有命中。

我们把计数器最大的这一个给它替换掉,其余的这些计数器都需要加1。
在这里插入图片描述
这就是LRU算法。

(2)分析

我们模拟机器的执行比我们手算要麻烦一些,但是机器对计数器进行这样的处理是有好处的。刚才我们说过,采用这样的策略,可以保证所有的这些计数器的值只有可能出现0、1、2、3,这也就意味着,当我们的 cache 块的总数只有 2 的 n 次方这么多块的时候,我们就只需要 n 个比特位来作为计数器就可以。

比如这个例子当中,我们只有 4 个 cache 块,我们就只需要 2 比特的信息来表示计数器就可以, 2 比特刚好可以表示 0123 这几种数字。所以这样的策略就保证了当我们用硬件实现替换算法的时候,硬件电路的设计可以变得更简单,我们只需要增加两个比特的冗余信息就可以。很显然,这个算法它其实是考虑到了局部性原理的。

因为基于时间局部性,可以知道,我们近期访问过的主存块,在不久的将来也很有可能会被再次访问。所以这种算法的思想就是每一次会淘汰最久没有访问过的主存块。这种策略是合理的,遵循的局部性原理。

所以这个算法在实际当中运行效果会很优秀, cache 的命中率很高。

这就是LRU算法,虽然这个算法很优秀,但是如果我们频繁访问到的主存块的数量要比 cache 行的数量要更大,我们也有可能会发生抖动现象。

比如我们按 12345、12345 这样的顺序来访问各个主存块,此时被我们频繁访问到的主存块总共有 5 块,而 cache 行的数量只有4。

大家可以自己模拟一下。如果是这样的访问序列,即便是采用LRU算法,依然会发生抖动现象,这就不再展开。

5.最不经常使用算法

(1)过程

接下来我们看小结的最后一种算法,最不经常使用算法,叫** LFU**。
在这里插入图片描述
f 指的是频率frequently,和 LRU算法类似,我们也需要给每个 cache 块 (cache 行)设置一个计数器,只不过它这儿的计数器是用于记录每一个 cache 块到目前为止被访问过了多少次。

当我们需要替换某一个块的时候,我们会选择计数器最小的,也就是被访问次数最少的一个进行替换。还是用刚才的例子来看一下。

刚开始我们需要把计数器都设为0。
在这里插入图片描述

计数器变化规则:当我们调入一个新的块时候,这个块所对应的计数器会置为0,之后这个块每被访问一次,计数器的值就会加1。

<1> 所以刚开始我们访问1234,只需要把这几个主存块分别的调入到 cache 的相应位置。由于第一次被调入的时候,计数器的值都是置为0,所以计数器此时是 4 个0。
在这里插入图片描述

<2> 接下来要访问 1,1 号主存块命中,我们就可以让它计数器的值加1。再接下来访问 2,2 号主存块命中,与它所对应的 cache 行,计数器也会加1。
在这里插入图片描述
<3> 接下来访问5,没有命中。此时就需要替换一个块。按照算法的规则,我们会替换掉计数器最小的一行。

不过现在大家会发现,我们 2 和 3 这两行的计数器的值都是最小的,都是0,到底替换哪一块?

通常来说可以有这样的两种处理策略:

①第一种策略就是按照行号,也就是按照cache行号递增的次序来判断它们的优先级。

比如我可以选择优先淘汰 cache 行号更小的,这是第一种策略。

②第二种策略,也可以按照先进先出的规则来给他们进行排序。

对于这个例子来说, 2 和 3 这两个 cache 行,它里边存入的主存块,应该是 2 号 cache 行的最先被调入的。

所以按照先进先出的规则,我们会优先淘汰 2 号 cache 块里边存放的 3 号主存块给淘汰掉,相应的位置换入 5 号。

在这里插入图片描述

<4> 再接下来, 1 和 2 又再次会被访问,这两次访问都可以命中,所以我们需要把 1 和 2 所对应的这两个计数器都加 1 。
在这里插入图片描述
<5> 再往后访问到3,此时又需要运行一次替换算法。

同样的,Cache2和Cache3它们的计数器都是最小的,我们可以选择按照行号递增的次序来给它们进行一个优先级的排序。

我们会选择淘汰行号更小的 cache 块,所以我们把 5 换出 ,把 3 换入。
在这里插入图片描述
<6> 接下来 4 号,可以命中。

与 4 号相对应的 cache 行,它的计数器加1。
在这里插入图片描述

<7> 最后要访问 5 号。

这个时候计数器最小的 cache 行只有一个。所以毫无疑问,我们需要淘汰的是 3 这个主存块,然后换入 5 号。
在这里插入图片描述

这就是 LFU算法。

注意,只有命中时才加1,换入不加

(2)分析

显然,采用这种算法,这个计数器的值有可能是 0到1一个很大的数。

所以如果要用硬件实现每个 cache 行所对应的计数器,就需要用比较长的几个二进制比特位来表示。

另一个方面,LFU这种算法看起来好像很科学,这个计数器相当于我们对每一个 cache 块被访问的次数进行了一个总体的统计,对吧?看起来好像很科学。

总体来看,被访问次数越多的我们就越有可能把它留住。

但事实上,考虑到时间局部性原理,我们不难想到,曾经经常会被访问的主存块,在未来其实不一定会被用到。

比如大家使用微信视频聊天的时候,和视频聊天相关的那些块的数据,显然在一段时间内经常会被访问。这就意味着,与这些块相对应的计数器,在你使用视频聊天的这段时间内会一直增加,加到一个很大的值。之后你不再需要视频聊天了。

但是,由于这些块的计数器已经变得很大了,所以接下来这些块在 cache 当中的数据副本很有可能在长时间内不会被淘汰。

因此这种算法实际运行效果其实也不好。它并没有遵循局部性原理,因此不如LRU。

所谓局部性,其实只需要考虑到最近的一小个局部,而不需要像这个算法这样有可能会记录下整个全局的一个访问的频率。

所以这个算法看起来科学,但实际上它的 cache 命中率可能也不高。

6.总结

这个小节当中,我们学习了 4 种 cache 替换算法。
在这里插入图片描述
因为 cache 的存储容量很小,而主存的存储容量很大,所以我们不得不考虑到当 cache 存满了之后,我们需要替换哪些数据这样的问题。

随机算法和先进先出很好理解。稍微复杂一点的是近期最少使用这个算法,但是这个算法的实际运行效果,就是 cache 的命中率在这几个算法当中是最优秀的。所以我们考试最经常考察的也还是这个算法。

不过我们手动做题的时候,大多数情况下我们也不需要写出计数器的值怎么变化,大家可以用我们刚才提到的更快的那种方法来快速地做题。大家也需要理解这种算法计数器它的一个工作原理。

以上就是小节的全部内容。

五、Cache写策略

1.前言

在这一小节中,我们要解决 cache 部分的最后一个内容,就是 cache 的写策略。

之前我们提出了这样 3 个待解决的问题。
在这里插入图片描述
前两个问题我们已经解决了,还剩最后一个问题。 就是cache 当中保存的只是主存里的数据的一个副本, CPU 对 cache 里的数据进行写操作,修改了里边的数据之后,如何保持主存和 cache 的数据一致性?这就是 cache 的写策略要探讨的问题。

我们会分为两种情况来探讨写的策略。
在这里插入图片描述

①第一种情况:

如果此时 CPU 要写的存储单元被命中,也就是已经存在 cache 里边的话,这种情况如何处理?

我们可以有两种方法,一种叫全写法,一种叫写回法。

②第二种情况:

如果此时 CPU 它想要写的地址没有命中

我们又会有两种处理的策略,分别是写分配法和非写分配法。

为什么我们在这儿只探讨写操作呢?

这是因为我们要解决的是 cache 和主存数据一致性的问题。如果CPU进行的是读操作,而不是写操作,无论是读命中还是读不命中,无论发生哪种情况,只要是读操作,就一定不会导致数据不一致的问题。
所以在这个小节中,我们只会探讨 CPU进行写操作的时候有可能发生的各种情况。

2.写命中

首先来看第一种情况写命中: CPU要对某一个地址进行写,并且这个地址所对应的主存块已经被调入 cache 当中,发生了命中的情况。

在这种情况下,我们会有两种处理的策略。

(1)写回法

1.过程
第一种策略叫做写回法。
在这里插入图片描述
我们结合之前的小结给出的具体的例子,假设此时 0 号主存块(绿色)已经被调入到了Cache绿色这个地方,紫色的主存块也被调入 cache 当中。
在这里插入图片描述
假设此时CPU 要进行写操作的地址刚好于 0 号,也就是绿色主存块它所对应的地址范围。(CPU读写时先去cache里找有没有)
在这里插入图片描述
如果采用写回法,就意味着 CPU只会往 cache 当中的数据副本这儿写入相应的数据。这个时候主存的数据母本和 cache 里的数据副本就发生了不一致的情况。
但是这种不一致,我们只有在第三个 cache 行被淘汰的时候,我们才会把整个 cache 行里存储的这一整块的数据,全部统一写回主存。

在这里插入图片描述

2.优化
对于紫色这一块数据,如果整个过程它都没有被修改过的话,当 cache 块被替换的时候,我们不需要把 cache 块写回主存,这样我们就可以节省一些写回的时间。
在这里插入图片描述
所以,为了让硬件能够区分哪个 cache 行的数据被修改过,哪一个没有被修改过,我们还需要给每一个 cache 行增加一个所谓的脏位的信息。
当一个数据块被修改之后,我们需要把这个 cache 行所对应的脏位设为1。
在这里插入图片描述
这样的话,当我们淘汰某一个 cache 行的时候,就可以知道这个 cache 行是否需要写回主存。而到底应该写回主存的什么位置,我们又可以根据标记来判断。

所以采用写回法可以使 CPU 的访存次数减少,从而节省写操作所需要的时间。但是这种方式又存在数据不一致的隐患。

(2)全写法(cache和主存都写)

1.过程
接下来看第二种方法,叫做全写法,又叫写直通法
在这里插入图片描述
如果采用这种方法,就意味着当 CPU 对 cache 写命中的时候,它除了会把数据写往 cache 之外,同时CPU也要往主存对应的存储单元写入相应的数据。
在这里插入图片描述
采用这种方法,就可以保证 cache 和主存的数据基本上都能够保持一致。

另外,当 cache 里的数据被淘汰的时候,我们也不需要像之前那样写回主存。因为这两边的数据随时都是保持一致的。

采用这种方法,就意味着 CPU 每一次进行写操作的时候,除了访问 cache 之外,也需要进行访存。访存次数增加就会导致 CPU 写操作的速度变慢。

2.优化
为了减少 CPU 访存的次数,我们通常可以采用这样的优化策略。

我们可以增加一个所谓的写缓冲。写缓冲也是用 SRAM 来制造的,用 SRAM 制造就意味着对写缓冲的读和写操作会比较快。我们可以把它看作是一个先进先出(FIFO)的队列。来看一下如何使用。
在这里插入图片描述

<1> 当 CPU 对某一个地址进行写操作,并且这个地址命中的时候, CPU 首先会向 cache 当中写入数据。另外 CPU 也会往写缓冲里写入相应的数据。
在这里插入图片描述
<2> 接下来假设CPU又往紫色的写入了数据,同样的也会把这一块的内容写到写缓冲里。
在这里插入图片描述
由于写缓冲是用 SRAM 实现的,所以CPU 对写缓冲的写操作要比直接往主存里边写要快得多。

<3> 接下来 CPU 就可以去干其他的事情,比如会进行连续的好几次读操作。

当 CPU 干其他事情的期间,又会有一个专门的控制电路来负责把写缓冲里面写入的这些数据把它同步到主存里边。
在这里插入图片描述
同样的,如果采用这种方式,当我们淘汰某一个 cache 行的时候,也不需要把 cache 行的数据写回主存。

增加了写缓冲之后,可以使得 CPU 的写操作写的速度会变得很快。
在这里插入图片描述
如果 CPU 的写操作不频繁,采用这种策略效果会很好。而如果 CPU 的写操作很频繁,由于我们写缓冲它的容量是有限的,所以如果写操作很多,就会导致写缓冲饱和。当写缓冲饱和之后,CPU 就必须阻塞等待,等它有空位再继续往里边写。

这就是全写法。

3.写不命中

到目前为止,我们探讨的是当CPU想要写的地址可以命中的情况下,我们可以采取的两种策略。接下来我们再来看当CPU要写的地址不命中的情况下,可以采取的策略。

(1)写分配法

第一种处理的方法叫做写分配法。

当 CPU此时要访问的地址没有命中,如果采用的是写分配法,我们就会先把CPU当前要写的这一块的数据,先把它调到 cache 当中。
在这里插入图片描述
然后CPU再对 cache 进行写操作,也就是说,主存里的这一块数据保持不动, CPU 只是修改了它的数据副本。
在这里插入图片描述
显然,这种方式比较适合和我们之前提到的写回法配合着使用。也就是当 cache 块被淘汰的时候,才把这一整块的内容同步回主存里边。
在这里插入图片描述

这是写分配法,通常和写回法配合着使用。

(2)非写分配法

再看第二种处理方式,叫做非写分配法。

指当 CPU 想要写的地址没有命中的时候,此时 CPU 会直接往主存里边写数据,而不会把这一块的内容调入cache。
在这里插入图片描述

所以非写分配法一般来会搭配着之前提到的全写法来使用。(不命中用非写分配,命中用全写法)

如果采用这种方法,就意味着只有 CPU 对某一个地址进行读操作,读操作未命中的情况下(即Cache中没有要找到信息,再从主存调入),才会把相应的主存块调入cache。

而如果是写操作未命中, CPU 会直接把数据写入主存,而不会把这块调入cache。

这就是写操作不命中的情况下,可以采取的两种策略,一种叫写分配法,一种叫非写分配法。

4.多级Cache

现在我们使用的计算机通常会采用多级 cache 的结构。

(1)介绍

最接近 CPU 的这一级 cache 编号是L1,往下一级是L2,也有一些 CPU 会有L3,三级cache。
在这里插入图片描述

越接近CPU 的速度会越快,但是容量会越小,因为成本会越高。而越远离CPU,它的速度越慢,容量也会越大。

比如我们之前给出的截图,可以看到I5 9300 H ,这个CPU它有三级 cache,L1, L2, L3 。最靠近 CPU 的 L1这一级的cache,它的读写速度几乎可以到 1000 GB 这样的一个量级。而第二级的cache,它的读写速度差不多比 L1 会慢一半。

对了,再补充一下,我们之前说过, cache 里边保存的是主存里的某一些数据,一小部分数据的副本。更高级更快速的cache,它所保存的数据又是更低一级 cache 的一小部分数据的副本。
在这里插入图片描述

因此,在各级 cache 之间同样存在数据一致性的问题。既然是要保证数据的一致性,因此就可以使用我们之前介绍的两种方法。

各级 cache 之间采用的是全写法加上非写分配法。而 cache 和主存之间通常会使用写回法加上写分配法,用这样的方式来保证数据的一致性。这个部分内容做一个简要的了解即可。大家也可以去自己的 windows 电脑的任务管理器去看一下性能页签。在这个地方你可以看到自己的CPU 的一个详细的信息,它有几级的cache,每一级的 cache 容量是多少。
在这里插入图片描述
这些信息大家可以去看一下。

(2)总结

这一小节中我们介绍了 cache 写策略,从而解决了 cache 与主存之间数据一致性的问题。
在这里插入图片描述

当 CPU 要写的地址可以命中的情况下,可以采取全写法和写回法这两种策略,而当写不命中的时候,可以采取写分配法和非写分配法这样的两种处理方式,通常写分配法和写回法会配合着使用,而非写分配法会和全写法配合着使用。

在这个小节的最后,我们也简单地介绍了多级cache。

本质上,各级 cache 的作用和 cache 与主存之间的作用其实都是一样的,都是为了尽可能的降低成本,但是同时又尽可能地提升 CPU 的运行速度

各级 cache 之间的数据同步通常会采用全写法配合非写分配法这样的方式来解决。而 cache 和主存之间通常采用写回法和写分配法来解决数据一致性的问题。

六。总结

好的,到目前为止,我们就解决了在 cache 的第一个小节当中提出的这 3 个问题。

①第一个问题,如何区分 cache 和主存它们之间数据块的对应关系?这就是 cache 和主存的映射方式所要探讨的问题。分为全相联映射、直接映射和组相联映射。

②在上个小节当中,我们又解决了当 cache 满了之后,我们应该怎么办这样的问题。我们介绍了 4 种替换算法,其中最常用的应该是LRU,也就是最近最久没使用算法。

③而这个小结中,我们又解决了第三个问题,就是 cache 和主存当中数据一致性的问题。

以上就是关于 cache 的相关考点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值