第六章——存储器层次结构

文章目录
  1. 1. 存储器层次结构
  2. 2. 局部性
  3. 3. 高速缓存
    1. 3.1.
    2. 3.2.
  4. 4. 性能例子
    1. 4.1. Memory Mountain——数组大小和访问步长影响性能
    2. 4.2. 缓存命中影响性能——以矩阵乘法为例
      1. 4.2.1. 循环顺序对性能的影响
      2. 4.2.2. blocking 对性能的影响

存储器层次结构

存储器有多种类型,我们可以根据访问速度把他们排成金字塔结构。靠近上面的访问速度快,靠近下面的访问速度慢。每一层都作为下面一层的缓存(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 来说,每次都不命中。我们可以认为有这样的缓存不命中率

a b c
1 / K 1 0
1
2
3
4
5
6
7
8
9
// 版本1
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 也是行遍历,我们可以认为有这样的缓存不命中率:

a b c
0 1 / K 1 / K
1
2
3
4
5
6
7
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,但保证不过大就好。