问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

存储器层次结构与高速缓存机制详解

创作时间:
作者:
@小白创作中心

存储器层次结构与高速缓存机制详解

引用
1
来源
1.
https://rinevard.github.io/wiki/learning/open-course/CMU-15-213/Notes/Chapter6-memory-and-cache/

计算机存储器层次结构是计算机系统中一个重要的概念,它描述了不同存储器设备在访问速度和容量上的差异。从高速缓存到主存,再到磁盘存储,每一层都扮演着不同的角色,共同构成了现代计算机的存储体系。本文将深入探讨存储器层次结构、局部性原理、高速缓存的工作机制以及性能优化等内容,帮助读者更好地理解计算机存储系统的运作原理。

存储器层次结构

存储器有多种类型,我们可以根据访问速度把它们排成金字塔结构。靠近上面的访问速度快,靠近下面的访问速度慢。每一层都作为下面一层的缓存(cache),即从下面一层读取数据来加快访问速度:

下图比较了不同存储器的访问速度:

局部性

局部性分为空间局部性和时间局部性。前者指如果一个内存位置被引用,程序可能在将来引用附近的一个内存位置;后者指如果一个内存位置被引用,程序可能在将来再次引用它。

为什么我们希望程序有较好的局部性呢?这是因为访问 cache 比访问 cache 下面一层要快,而 cache 的大小有限,因此我们希望尽可能访问 cache 里的数据。而 cache 存储的数据在空间上和时间上都有一定连续性(参考下面的“高速缓存”一节),所以我们希望程序的局部性好。

如果我们引用了不在 cache 中的数据,这就被称作“缓存不命中”(命中为 “hit”,不命中为“miss”),从而要把 cache 下面一层的数据复制到 cache 中,这是很慢的。

举个例子,一般来说,数组的局部性比链表好,因为链表的地址往往是碎片化的。

高速缓存

高速缓存的结构如图所示:

让我们推测一下 line 和 set 是怎么设计出来的。

我们的核心思路是尽量减少高速缓存和主存间交换数据的次数。如果我们能预知未来,我们只要根据未来尽可能把高速缓存填满未来会读的值,然后在需要新值时再读一次就好了。

显然我们不能预知未来,所以我们希望每次的数据交换都尽可能多带来一些“未来可能用到的数据”。所以我们把高速缓存划分成若干个 line,每个 line 都存储一组连续的地址的数据。毕竟如果读到了一个地址,有理由认为它附近的地址在将来会被读到。

上图给出了 line 的可视化,我们把数据存储在 data 段中,共存储 B 个 bytes.

但只是读取连续的一段是不够的。在下图中,我们把主存中需要用到的地址标红。这两种情况中,我们认为右边那种情况更可能在运行时发生,所以我们希望实现多个 set. 如果每个 set 只有一个 line,就实现了直接映射高速缓存。如果每个 set 有多个 line,就实现了组相联高速缓存

接下来我们讲讲如何读取数据,我们以组相联高速缓存为例,毕竟直接映射是组相联的特殊情况。

这里的思路是根据地址找到高速缓冲中对应的 set,然后检查 set 的 line 中是否有当前地址。

我们把每个地址划分为三个部分,tag 部分、组索引部分、偏移部分。组索引部分在中间,耗费 $\log_2 S$ 个位,用于定位对应的组tag 部分在最前,占据地址剩下的位置,用于和每个 line 的 tag 作比较从而确定是否有与地址对应的 line偏移部分在最后,耗费 $\log_2 B$ 个位,用于在找到对应的 line 以后找到对应的数据位置

所以,对给定的地址,我们先找到它的组索引部分,从而定位对应的组,然后把组里的所有 line 的 tag 和地址的 tag 比较,找到相同的 tag 以后就算是成功命中,然后根据偏移获取对应的值。

如果没找到相同的 tag 呢?这就是不命中了,这时就要让主存和 cache 间交换数据,选一个 line(一般是一组间最久没有被使用的那个)把它替换掉。

写比读复杂很多,书上没详细讲,所以我们只是简要提一下。

一个比较靠谱的思路是检查要写的地址是否在 cache 中,如果在就直接写 cache,并标记那个位置为“已修改”。如果不在 cache 中,就像读一样,把那个地址拿到 cache 里来,然后覆写。在 cache 的任何一个地址被覆盖掉时,检查那个地址的值是否已修改,如果修改则覆写主存上的值。

这种做法叫做写回(write-back),尽可能地推迟更新。

要注意的是,由于写是可以推迟的,所以写一般比读要快。

性能例子

Memory Mountain——数组大小和访问步长影响性能

这张图是对一个大小为 size 的数组以 stride 为步长求和的性能比较。注意到随着 size 增大,我们愈发往存储器层次结构下面走(比如说,L1 不足以装下所有数据了,我们就不得不走到 L2),同时每走一层时间都有一个猛增。

随着 stride 增大,缓存命中率越来越低,耗时越来越大,直到某个临界点,每次都不命中。

缓存命中影响性能——以矩阵乘法为例

假设我们在做矩阵乘法,我们的高速缓存的每个 line 的 block 能装下 $K$ 个值。

循环顺序对性能的影响

版本 1 是 ijk 循环,a 是行遍历,b 是列遍历,c 不在最内存循环内所以不计性能损耗。对 a 来说,每经过 K 个值,就有一次不命中;对 b 来说,每次都不命中。我们可以认为有这样的缓存不命中率

for(i=0; i<n; i++) {
    for(j=0; j<n; j++) {
        sum =0.0;
        for(k=0; k<n; k++)
            sum += a[i][k] * b[k][j];
        c[i][j] = sum;
    }
}

如果把循环顺序改成 ikj,就能得到更好的性能。

这时 a 不在最内层循环,不计性能损耗,b 是行遍历,c 也是行遍历,我们可以认为有这样的缓存不命中率:

for(i=0; i<n; i++) {
    for(k=0; k<n; k++) {
        r = a[i][k];
        for(j=0; j<n; j++)
            c[i][j] += r * b[k][j];
    }
}

下图展示了六种循环顺序带来的不同效率:

最好的是 ikj 循环,我们可以简单地算出它的大概的总不命中次数。共有 $n^2$ 个值要算,没算一个值大概会不命中 $\frac{n}{K}

  • \frac{n}{K}$
    次,因此总不命中次数大约为:

$$
n^2 \times (\frac{n}{K} + \frac{n}{K})=\frac{2n^3}{K}
$$

blocking 对性能的影响

其实我们还有更好的优化手段——矩阵分块乘法:

结果里的每个 block 的计算需要访问 $\frac{2n}{B}$ 个小 blck,每个小 block 大概会不命中 $\frac{B^2
}{K}$ 次,因此总不命中次数大约为:

$$
(\frac{n}{B}
)^2 \times \frac{B^2}{K}\times \frac{2n}{B}=\frac{2n^3}{BK}
$$

当然,我们不能无限地增大 $B$. 对于矩阵乘法 C=A×B,我们要同时在缓存中保存 A 的一个块、B 的一个块和 C 的一个块,从而希望有类似 $3B^2 < C$ 的限制(这里的 $C$ 是 cache 的 capacity)。所以尽可能选大的 block,但保证不过大就好。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号