理解计算机Cache
如果我们仔细的查看芯片架构,就会发现里面都有缓存( MCU 可能没有缓存),可见缓存的重要性。本文将详细的介绍缓存的基础知识。
1. 什么是缓存
首先考虑一种标量访问的情况。就像下面这段代码,它重复地访问同一个标量。在这种情况下,存储访问会出现需要访问的地址不随时间变化的特点。
a = 10
for i in range(5):print(a)
如果我们考虑数组访问,比如顺序访问数组中的每个元素。那么存储访问会出现需要访问的地址随时间线性变化的特点。
观察这两种不同的访问情况,我们可以发现,内存访问并非是完全随机的,这背后体现的是局部性原理。局部性原理可以分为两类。一个是时间局部性,一个元素一旦被访问到,很可能在短时间内再次被访问到。另一个是空间局部性,一个元素周围的元素很有可能会接下来被访问到。局部性原理至关重要,确保了缓存能够有效运作。
接下来我们关注一个最基础的处理器内存模型。当处理器需要读取数据时,它通过一个特定的地址访问内存,内存响应这一请求并将数据返回。然而,存储器的一个有趣特性是它离处理器越远,容量通常越大,但速度却越慢。这种速度的差异在处理器和内存之间依然很明显。
为了解决速度差异带来的挑战,我们在处理器和内存之间引入了一个强大的中间者——缓存。当处理器寻找的数据恰好在缓存中时,就称为缓存命中。这时处理器可以迅速通过地址访问缓存中的数据。而如果所需数据不在缓存中,此时,缓存必须先从内存中获取相应的数据,然后再将它返回。我们称这种情况为缓存丢失。
2. 块
0 | 0 | 1 | 2 | 3 |
1 | 4 | 5 | 6 | 7 |
2 | 8 | 9 | 10 | 11 |
3 | 12 | 13 | 14 | 15 |
4 | 16 | 17 | 18 | 19 |
5 | 20 | 21 | 22 | 23 |
6 | 24 | 25 | 26 | 27 |
7 | 28 | 29 | 30 | 31 |
8 | 32 | 33 | 34 | 35 |
9 | 36 | 37 | 38 | 39 |
10 | 40 | 41 | 42 | 43 |
11 | 44 | 45 | 46 | 47 |
12 | 48 | 49 | 50 | 51 |
13 | 52 | 53 | 54 | 55 |
14 | 56 | 57 | 58 | 59 |
15 | 60 | 61 | 62 | 63 |
首先,我们来观察这个64字节的内存模型。在按字节编址的情况下,每个小格子代表一个字节,因此我们需要6个位来访问这64个格子。如果我们按字来编址,假设一个字等同于四个字节,那么每行就代表一个字,此时只需四位便可访问这16行。实际上块的概念与字的概念在本质上是相似的,比如我们可以设定一个块等同于1个字。这样内存的一行就相当于一个块。
0 | ||||
1 | ||||
2 | ||||
3 |
让我们再来看一个四行的缓存模型。在这个模型中,缓存的每一行都对应一个块,因此一个内存块就可以直接放入缓存的任意一行。
内存 | ||||
---|---|---|---|---|
字0 | 0 块0 | 1 块0 | 2 块0 | 3 块0 |
字1 | 4 块0 | 5 块0 | 6 块0 | 7 块0 |
字2 | 8 块0 | 9 块0 | 10 块0 | 11 块0 |
字3 | 12 块0 | 13 块0 | 14 块0 | 15 块0 |
字4 | 16 块1 | 17 块1 | 18 块1 | 19 块1 |
字5 | 20 块1 | 21 块1 | 22 块1 | 23 块1 |
字6 | 24 块1 | 25 块1 | 26 块1 | 27 块1 |
字7 | 28 块1 | 29 块1 | 30 块1 | 31 块1 |
字8 | 32 块2 | 33 块2 | 34 块2 | 35 块2 |
字9 | 36 块2 | 37 块2 | 38 块2 | 39 块2 |
字10 | 40 块2 | 41 块2 | 42 块2 | 43 块2 |
字11 | 44 块2 | 45 块2 | 46 块2 | 47 块2 |
字12 | 48 块3 | 49 块3 | 50 块3 | 51 块3 |
字13 | 52 块3 | 53 块3 | 54 块3 | 55 块3 |
字14 | 56 块3 | 57 块3 | 58 块3 | 59 块3 |
字15 | 60 块3 | 61 块3 | 62 块3 | 63 块3 |
缓存 | |
---|---|
0 | |
1 |
那么如果我们让一个块等于四个字呢?在这种情况下,四个内存行才能组成一个完整的块。我们给它们标上序号方便查看。此时展示一个两行的缓存模型,缓存行始终等于一个块的大小。在这种情况下,由四行内存组成的块能够被整体放进缓存的一行中。此时我们对内存地址可以进行重新的理解。目前有四个快,可以使用两位来进行索引。对于按字编址,一个块内有四个字,那么就是用两位作为偏移。对于按字节编址,对块的索引是一致的。而一个块内有16个字节,那么就使用四位作为偏移。
3. 缓存映射
内存 | ||||
---|---|---|---|---|
字0 | 0 块0 | 1 块0 | 2 块0 | 3 块0 |
字1 | 4 块0 | 5 块0 | 6 块0 | 7 块0 |
字2 | 8 块1 | 9 块1 | 10 块1 | 11 块1 |
字3 | 12 块1 | 13 块1 | 14 块1 | 15 块1 |
字4 | 16 块2 | 17 块2 | 18 块2 | 19 块2 |
字5 | 20 块2 | 21 块2 | 22 块2 | 23 块2 |
字6 | 24 块3 | 25 块3 | 26 块3 | 27 块3 |
字7 | 28 块3 | 29 块3 | 30 块3 | 31 块3 |
字8 | 32 块4 | 33 块4 | 34 块4 | 35 块4 |
字9 | 36 块4 | 37 块4 | 38 块4 | 39 块4 |
字10 | 40 块5 | 41 块5 | 42 块5 | 43 块5 |
字11 | 44 块5 | 45 块5 | 46 块5 | 47 块5 |
字12 | 48 块6 | 49 块6 | 50 块6 | 51 块6 |
字13 | 52 块6 | 53 块6 | 54 块6 | 55 块6 |
字14 | 56 块7 | 57 块7 | 58 块7 | 59 块7 |
字15 | 60 块7 | 61 块7 | 62 块7 | 63 块7 |
接着,就让我们尝试自己设计一个缓存。我们令一个块等于两个字,因此两个内存行就是一个块。后面出现的内存模型将会以块为基础单位。
缓存 | |
---|---|
0 | 块 |
1 | 块 |
2 | 块 |
3 | 块 |
让我们使用一个四行的缓存模型,我们都知道缓存行等于一个块,那么其中就包括了两个字,也就是八个字节。缓存的目的是通过内存地址返回给处理器数据,而处理器是不知道块的存在的。因此我们需要从块中找到对应的数据。如果此时是按字节编址,那么访问一个块中的八个字节就需要三个位,我们称之为偏移量。而如果是按字编址,那么访问一个块中的两个字就只需要一个位。
按字编址 | 4位<=>16个字 | ||
---|---|---|---|
标记Tag | 索引Index | 索引Index | 偏移量Offset |
接下来观察这个四行的缓存。为了定位到其中一行的数据,我们需要通过两位来访问这四行,可以将其称之为索引。
内存 | |
---|---|
块0 | |
块1 | |
块2 | |
块3 | |
块4 | |
块5 | |
块6 | |
块7 |
再拿出以块为一行的内存模型。当八个内存块对应到四个缓存行时,必然会有两个内存块映射到同一缓存行。为了解决这个问题,我们在编址时加入了一个标记位。但如果又有新的内存块要映射到这个已满的缓存行呢?单靠一位无法区分三个不同的内存块。为了解决这一问题,我们需要引入不同的映射策略。既然一个标记为只能区分两个内存块,那么我们就指定内存块存储的位置,直接映射(Direct-mapped)的思想就是这样。每两个内存块指向同一个缓存行,比如内存块0和内存块4指向缓存行0、内存块1和内存块5指向缓存行1,以此类推。这样一来,一个缓存行就只会有两个内存块存入,符合我们之前的设计。通过索引就能找到对应的缓存行,再根据标记就能找到对应的内存块。直接映射的优点是硬件设计简单,索引的存在使得查询速度快。缺点是每个缓存行的冲突概率较高,有很大可能会影响效率。
按字编址 | 4位<=>16个字 | ||
---|---|---|---|
标记Tag | 标记Tag | 标记Tag | 偏移量Offset |
那我们就让每个缓存行区分更多的内存块,从而减少冲突的发生,直接区分全部内存块会怎么样?这正是全相联映射(Fully associative)的思想。一个内存块可以放入任意的缓存行,每个内存块都是如此。此时我们需要重新理解内存地址,只需要通过标记就能访问到对应的缓存行。因此不再需要索引,将索引位都改为标记,而三位的标记也对应着一个缓存行能够区分八个内存块。其优点很明显,冲突会特别少。但由于没有了索引,每次访问都需要和所有的标记进行比较,查询速度就会特别慢并且硬件设计也会很复杂。
二路组相联 | 缓存 |
---|---|
0 | 组0 |
1 | 组0 |
2 | 组1 |
3 | 组1 |
按字编址 | 4位<=>16个字 | ||
---|---|---|---|
标记Tag | 标记Tag | 索引Index | 偏移量Offset |
因此,我们需要一个折中的策略,这就是组相联映射(Set-associative)。组的概念本质上与字相似。如果一个组中有一个块,即一个缓存行,我们就称之为一路组相联,就是现在没有区分的状态。而一个组里面有两个块,即两个缓存行,就称为二路组相联。这样我们可以通过一位的组索引来访问这两个组,此时的标记是两位,那么能区分四个内存块。一个内存块可以放到固定的一个组中,而在一个组中可以放置在任意一行。在组中就与全相联一样,因此我们只需要通过标记就可以在组里面访问到对应的内存块。而每个内存块都指定放入对应的组里面,这是直接相联的思路。组相联映射巧妙的平衡了冲突频率、查询速度和硬件设计的复杂度,是一种高效的映射策略。
4. 缓存结构
接下来让我们看一下内存与缓存在结构上的差异。
首先,内存的结构相对比较直观,它包含存储空间及其对应的地址,通过这些地址我们可以直接访问存储空间中的特定数据。而缓存同样具有存储空间,但与内存不同,缓存还需要控制信息,如有效位用于标示该行数据是否有效。此外还可以有其他的标志位,比如一个标示脏数据的位。一行的控制信息与存储数据一起被称为缓存槽。
我们来看一下内存的地址结构。首先能够以块为单位理解地址,分为块索引和块内偏移。而缓存能够对内存地址重新进行理解。以组相联映射为例,分为标记、组索引和偏移。其中的标记正是缓存中的控制信息,此时我们能够计算出缓存的总容量,就是缓存槽的大小乘以缓存总行数。一个槽中包含了有效位、标志位、标记和块容量。缓存的存储容量是另一个关键的概念,它指的是单个缓存槽的块容量与缓存总行数的乘积。在这个情况下,引入缓存地址的概念,由行索引和行内偏移组成。其本质和以块为单位对内存地址的理解是一致的。因此我们可以通过存储容量计算出缓存的地址位数。