CS APP Cache Memories

363 阅读8分钟

1. CPU高速缓存

由于CPU和内存之间的巨大速度差异, 系统设计者被迫在CPU寄存器和内存之间引入了L1高速缓存, 随着性能差距越来越大又逐渐引入了L2, L3高速缓存. 结构如下图所示:

image.png

高速缓存的架构

高速缓存的结构可以用元组(S, E, B, m)表示,S表示有多少组cache, E表示每组cache有多少行,B表示缓存的数据块的字节大小, m是地址的位数, 如下图所示:

image.png S和B将地址m分成了3部分, 其中S=2^s, B=2^b,如上图右侧所示, s被用来确定该地址应该在哪一组缓存, t被用来确定该组缓存的哪一行(剩下的位数就是t), b被用来确定实际缓存数据的offset.

当然每行开头还有一个bit标志为用来标记当前缓存行是否有效.

直接映射高速缓存

这里对上面的概念给出一个具体的例子, 如果E=1(每组只有一行), 那么该缓存就叫直接映射高速缓存.

CPU执行读内存字w的指令, 那么CPU会向L1缓存请求这个字, 如果缓存有就直接返回, 否则CPU将会等待L1缓存将这个字从内存load到高速缓存里, 再返回给CPU. 那么缓存是如何判断是否命中的呢? 分为三步:

  1. 组选择. 从地址w的中间抽取s个组索引位, 这样就知道具体在哪一组.
  2. 行匹配. 从地址w的开头抽取t个标记位, 如果该组中的某行是有效的(有效位为1)且标记位跟刚刚抽出来的相同, 则该行有我们想要的缓存.
  3. 字抽取. 从地址w末尾抽取b位, 用来判断缓存数据从哪里开始读(就是offset).
  4. 如果没有命中的缓存, 那么高速缓存就需要从内存中取出数据块, 放到对应的高速缓存行里进行替换.

上述过程就是高速缓存的工作原理, 具体代码可以看cache lab的PART A, 我在底下也放有源码.

这里就很容易发现多个缓存命中同一个缓存行的情况, 所以现实世界里的E不是1, 而是多行, 这样能减少缓存冲突的情况, E>1的步骤跟上述过程基本相同, 可以看我代码里的实现, 当然我代码里是简单的for循环, 现实世界里会有一些专门的硬件电路做这些事情.

2. 编写高速缓存友好的代码

  1. 让最常见的情况运行得快. 程序通常把大部分时间都花在少量的核心函数上, 而这些函数通常把大部分时间都花在了少量循环上. 所以要把注意力集中在核心函数里的循环上, 而忽略其他部分.
  2. 尽量减小每个循环内部的缓存不命中数量. 对于下面这个最常见的循环:
int sumVec(int v[N]){
    int i, sum =0;
    for (i=0;i<N;i++){
        sum+=v[i];
    }
    return sum;
}

对于局部变量i和sum, 有着良好的时间局部性, 在每个循环内都被使用, 而且由于他是局部变量, 编译器会将他们缓存在寄存器中. 对于数组v[N], 由于对其的引用步长为1, 每次都能良好的使用完缓存到寄存器中的数据. 因此这是我们能做到最好的情况.我们可以通过以下两点编写出对缓存友好的代码:

  • 对局部变量的反复引用.
  • 步长为1的引用.

下面举一个反例:

int sumArr(int a[M][N]){
    int i, j, sum =0;
    for (j=0;j<N;j++){
        for (i=0;i<M;i++){
            sum+=a[i][j];
        }
    }
}

我们不是按行扫描, 而是按列扫描的, 如果我们的数组比较大缓存装不下, 那么我们每次对数组a的访问都是缓存miss的, 相对于按行扫描性能就会差很多.

3. cache lab 作业

part A

这个lab不难, 主要是考察对书本知识中cache的理解, 以及训练c语言编程(我也是用到啥就查啥), 几个关键点:

  • 参数解析
  • 初始化地址的位与操作数
  • 初始化缓存结构
  • 解析文件里的操作
  • 处理内存操作: 这个是核心部分, 但是也比较取巧, 只要实现load操作就行, 其他两个操作基本就是再调用一次这个操作, 这里也要简单的实现下LRU

总体不难, 主要是C语言不熟, 写起来磕磕绊绊的, 分享下代码, 注意有两个文件: gist.github.com/ahlixinjie/…

part B

Part B 是转置矩阵, 并要求缓存的miss小于lab的要求,

  • 32×32: 8 points if m<300, 0 points if m>600
  • 64×64: 8 points if m<1300, 0 points if m>2000
  • 61×67: 10 points if m<2000, 0points if m>3000

这题比我预想中的难处理, 首先对于矩阵转置肯定是要用到分块的, 这里可以看lab的分块技术指引:csapp.cs.cmu.edu/public/wasi…. 然后我们分别来看这三个矩阵的要求.

本题给的缓存的规格是(s=5, E=1, b=5), 也就是有2^5(s)=32个set, 每个set有1(E)个line(Direct Mapped Cache), 每个line可以缓存2^5(b)=32个字节. 题目给的函数签名是void transpose_submit(int M, int N, int A[N][M], int B[M][N]), 每个int占用4个字节, 也就是说每行line可以缓存8个int.

32 X 32 矩阵

对于32 X 32规格的矩阵, 想使用分块技术的话, 每加一行对应的cache中的set index增加了多少呢? 这里可以计算一下: 假设matrix[0][0]的地址为0, 那么matrix[1][0]的地址为0+4*32=128, 而缓存的配置为(s=5, E=1, b=5), 也就是说地址的中间5个bit为set index, 这里计算(128 & 0b1111100000)>>5 = 4, 也就是说每加一行对应的set index会加4.

那么这里就可以得到结论, 对于32 X 32规格的矩阵, 分块的size最好设置为8, 因为32(总set数)/4(每行set index的差值)=8, 如果分块的size超过8, 那么访问第9行回驱逐之前的缓存, 就会造成miss了. 这里按照分块的思想给出第一版的代码:

void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
    int i, j, i1, j1;
    int bsize = 8;
    int tmp;

    for (i = 0; i < N; i += bsize) {
        for (j = 0; j < M; j += bsize) {
            for (i1 = i; i1 < i + bsize; i1++) {
                for (j1 = j; j1 < j + bsize; j1++) {
                    tmp = A[i1][j1];
                    B[j1][i1] = tmp;
                }
            }
        }
    }
}

然后用cache lab给的工具跑分, ./test-trans -M 32 -N 32, 发现有344次miss, 已经能通过测试了, 但是拿不到满分(<300次miss). 这里需要分析一下内存的访问情况, 使用./csim-ref -v -s 5 -E 1 -b 5 -t trace.f0跑一下, 发现他这个lab出的有点刁钻, 矩阵A和矩阵B地址对应的cache line是相同的, 这里画图示意一下:

image.png 这就是两个矩阵每一个元素加载到缓存中对应set index的情况, 按上面第一版的代码进行简单的分析:

  1. 加载A[0][0]到缓存的set 0.
  2. 写B[0][0], 同样对应到的set 0, 因此会驱逐刚刚加载的缓存.
  3. 加载A[0][1]到缓存的set 0, 因此又会驱逐刚刚加载的缓存.
  4. 写B[1][0], 加载到缓存的set 4, 从这里开始后面都是正常的.
  5. 每次到对角线的元素转置, 都会造成额外的miss开销

因此这里需要对对角线的元素进行优化, 有了第二版代码:

void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
    int i, j, i1, j1;
    int bsize = 8;
    int tmp;

    for (i = 0; i < N; i += bsize) {
        for (j = 0; j < M; j += bsize) {
            for (i1 = i; i1 < i + bsize; i1++) {
                for (j1 = j; j1 < j + bsize; j1++) {
                    if (i1 != j1) {
                        tmp = A[i1][j1];
                        B[j1][i1] = tmp;
                    }
                }
                if (i == j) {
                    tmp = A[i1][i1];
                    B[i1][i1] = tmp;
                }
            }
        }
    }
}

对于非对角线的元素就正常转置, 不会造成额外的开销, 对于对角线的元素则在该元素对应A的那一行转置完后再进行转置, 这里分析一下开销:

对角线的块: 第一行 A:1 + B:7 + B:1(驱逐A) = 9, 剩下的行: A:1 + B:0(刚好上次驱逐的行能用上) + B:1(驱逐A) = 2. 因此对角线是23次; 非对角线的块: 第一行 A:1 + B:8 = 9 剩下的行: A: 1+B:0 = 1 因此非对角线是16次 总共是: 23*4 +16*12=284, 这里跑下分就只有288次miss了(多的几次是函数开销), 能拿到满分了, 说实话有点应试了, 能写出第一版的代码就可以了.

64 X 64 矩阵

按照上面对32 X 32矩阵的分析, 只要把分块的大小进行改动即可,每加一行set index会加8, 因此block size只能设置为4, 把上面的第二版代码的bsize设置为4后跑分, 会有1796次miss, 达到要求(2000次miss)但是没有满分(1300次miss)🥲, 是真滴不想再优化了. 这里给出矩阵元素对应的缓存中的set index图(部分):

image.png 由于工作时间原因这里没继续优化了, 给出另外一个分析这个的例子zhuanlan.zhihu.com/p/28585726, 有时间再研究一下吧.

61 X 67 矩阵

这个不太规则, 多试几次block size就行, 我这里用的是18, 满足要求了.

这里给出我最后的代码

void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
    int i, j, i1, j1;
    int bsize = 8;
    int tmp;
    if (M == 64) {
        bsize = 4;
    } else if (M == 61) {
        bsize = 18;
    }

    for (i = 0; i < N; i += bsize) {
        for (j = 0; j < M; j += bsize) {
            for (i1 = i; i1 < i + bsize && i1 < N; i1++) {
                for (j1 = j; j1 < j + bsize && j1 < M; j1++) {
                    if (M == 61 || i1 != j1) {
                        tmp = A[i1][j1];
                        B[j1][i1] = tmp;
                    }
                }
                if (M != 61 && i == j) {
                    tmp = A[i1][i1];
                    B[i1][i1] = tmp;
                }
            }
        }
    }
}

由于PART B的第二题比较难, 没有拿到满分, 但是我的目的主要是学习思想, 更多的分以后有时间再看吧.

Cache Lab summary:
                        Points   Max pts      Misses
Csim correctness          27.0        27
Trans perf 32x32           8.0         8         288
Trans perf 64x64           2.3         8        1796
Trans perf 61x67          10.0        10        1962
Total points              47.3        53