lab4 cachelab

概述

  1. 修改csim.c实现一个cache,make然后./test-csim测试是否正确
  2. 修改trans.c实现一个转置操作,并优化性能,测试方法如下
 make && ./test-trans -M 32 -N 32   
 make && ./test-trans -M 64 -N 64  
 make && ./test-trans -M 61 -N 67 

模拟cache

首先,这个实验就是要求我们能够得出在一系列操作之下,命中次数,不命中次数,淘汰页面的次数。

cacheline的定义

这个模拟cache的功能非常简单,因为不需要我们真正的去读写数据,只需要模拟进出cache的情况就可以了。因此,我们每个cache行,只需要像下面这样定义即可。

typedef struct {
  int valid;
  int tag;
  int time_stamp;
} cache_line;

cache的操作

然后我们需要支持三种操作,分别是load,store和modify

  1. load的意思很明显,就是先去cache中检查, 如果有这一行,那么就命中,如果不成功,那么就需要找出一个空行,或者根据lru找出一行来替换
  2. store呢,其实在不考虑实际的写入写出的情况下,和load的操作一模一样,如果cache有这一行,那么就命中了,直接store进去,如果没有这一行,需要读入cache。那其实这个时候就没必要store回内存了,因为刚刚才从内存里读出来。我是感觉这里有点奇怪的。
  3. modiy在实验文档里也说了,等于load+store

综上所述,load和store的实现完全一样,modify相当于再操作一次。对应在代码里就是这样。
这个函数的第一行和第二行就是使用位运算取出了这个地址的set和tag

void Func(char command, int address, int size) {
  int set_num = (address >> b) & ((1 << s) - 1);
  int tag = (address >> (b + s)) & ((1 << (32 - b - s)) - 1);
  FindCache(set_num, tag);
  if (command == 'M') {
    FindCache(set_num, tag);
  }
}

关键的FindCache函数

通过这个函数可以发现,其实真正关键的函数是FindCache函数。
这个函数其实就是遍历这个对应的set的所有行

  1. 如果命中了,则更新命中的次数,更新时间戳,然后直接返回
  2. 如果没命中,我们在遍历所有行的过程中需要记录是否存在空行,并且记录时间戳最小的行(LRU)。
    1. 首先,更新miss的次数
    2. 如果存在空行,则将当前这一行写入空行,并更新valid,tag,和时间戳
    3. 如果不存在空间,则将当前这一行写入时间戳最小的行,并和2一样更新各种参数。这种情况还要额外更新一个淘汰页的数量
      具体实现如下
void FindCache(int set_num, int tag) {
  cache_line *cur_set = cache[set_num];
  int empty_index = -1;
  int min_ts_line_index = 0;
  for (int i = 0; i < E; i++) {
    // 当前行存在于cache中,即valid=1,并且tag相同
    if (cur_set[i].valid == 1 && cur_set[i].tag == tag) {
      hit_count++;
      // 记得更新时间戳
      cur_set[i].time_stamp = time_stamp++;
      return;
    }
    // 如果存在空行,即valid=0;
    if (cur_set[i].valid == 0) {
      empty_index = i;
    }
    // 记录时间戳最小的,实在不行就要去替换了
    if (cur_set[i].time_stamp < cur_set[min_ts_line_index].time_stamp) {
      min_ts_line_index = i;
    }
  }
  // 如果没有命中,那么就发生了miss
  miss_count++;
  // 载入某一行,如果有空行,就载入空行
  // 如果没有空行,则载入时间戳最小的,这就发生了evict
  if (empty_index != -1) {
    LoadOrEvict(cur_set, empty_index, tag);
  } else {
    LoadOrEvict(cur_set, min_ts_line_index, tag);
    eviction_count++;
  }
}

整体代码结构

这个实验的关键部分就这么多。剩下的有点类似于脏活累活,但是也很有意义。先看一下整体的代码结构
可以分为以下几个部分

  1. 解析命令行参数
  2. 根据解析出来的参数初始化我们的cache
  3. 读取文件里的操作,并进行操作
  4. 输出结果
  5. 释放申请的变量
int main(int argc, char *argv[]) {
  // 解析命令行参数
  int par_res = parser(argc, argv);
  if (par_res == -1) {
    return 1;
  }
  // 创造cache,初始化cache
  Init();
  // 读取文件,获得操作,进行操作
  int get_res = GetOperation();
  if (get_res == -1) {
    return 1;
  }
  // 输出结果
  printSummary(hit_count, miss_count, eviction_count);
  // 释放malloc申请的变量
  Destory();
  return 0;
}

命令行参数解析部分

首先是我们通过命令行启动程序,那么我们的参数都是在命令行中给出的,如何解析命令行的参数呢?需要使用getopt函数。
这个函数的三个参数

  1. 程序参数的数量
  2. argv可以理解为一个二维数组,如果我们的输入是这样的./my_program -f input.txt -o output.txt,那么argv的实际参数是这样的
argv[0] -> "./my_program"
argv[1] -> "-f"
argv[2] -> "input.txt"
argv[3] -> "-o"
argv[4] -> "output.txt"
  1. 第三个参数是我们参数的format,如果是这样的"hvs:E️t:",那就说明我们有hvsEbt这六种参数,并且后面跟着冒号的说明这些参数还有值
    这个函数的返回值,这里用opt记录,就是读取到的参数。
    最后还有一个变量叫optrag,这个需要我们声明,只要我们正确声明了getopt函数的头文件,这个变量就存在了。代表了当前参数对应的值。
int parser(int argc, char *argv[]) {
  // 解析参数
  int opt;
  while ((opt = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
    switch (opt) {
    case 'h':
      h_flag = 1;
      break;
    case 'v':
      v_flag = 1;
      break;
    case 's':
      s = atoi(optarg);
      break;
    case 'E':
      E = atoi(optarg);
      break;
    case 'b':
      b = atoi(optarg);
      break;
    case 't':
      tracefile = optarg;
      break;
    case '?':
      printf("未知选项或缺少参数\n");
      return -1;
    }
  }
  return 0;
}

初始化cache部分

首先,我们的cache的定义是这样的cacheline ** cache,因此要用二维数组的方式对这个cache进行初始化。
先分配出S个行,然后给每行分配出E列

void Init() {
  S = 1 << s;
  cache = (cache_line **)malloc(S * sizeof(cache_line *));
  for (int i = 0; i < S; i++) {
    cache[i] = (cache_line *)malloc(E * sizeof(cache_line));
    // 记得初始化每一行的tag为0
    for (int j = 0; j < E; j++) {
      cache[i][j].tag = 0;
    }
  }
}

读取文件的操作

首先,我们读取进来的tracefile其实只是一个文件名,还需要根据这个文件名去真正的取到这个文件
这就要使用这个了FILE *file_ptr;

int GetOperation() {
  // 读取文件,获得访问记录
  file_ptr = fopen(tracefile, "r");
  if (file_ptr == NULL) {
    printf("无法打开文件 %s\n", tracefile);
    return -1;
  }
  char buffer[100];
  char command;
  int address;
  int size;
  while (fgets(buffer, sizeof(buffer), file_ptr)) {
    if (buffer[0] == 'I') {
      continue;
    }
    if (sscanf(buffer, " %c %x,%d", &command, &address, &size) == 3) {
      // 成功获取操作
      Func(command, address, size);
      // printf("%c %x,%d\n", command, address, size);
    } else {
      // 输入不合法
      return -1;
    }
  }
  fclose(file_ptr);
  return 0;
}

释放内存的操作

申请了多少就释放多少,先释放列,再释放行

void Destory() {
  for (int i = 0; i < S; i++) {
    free(cache[i]);
  }
  free(cache);
}

整体来说,这个实验并不难。因为根本没用到什么难的算法,暴力遍历就完事了。
但是因为前前后后所有内容都需要自己实验,还是有不少dirtywork的,这可能比较像真正工作上写的代码,而不是算法题的代码

cache转置

给我们的cache配置是s = 5, E = 1, b = 5
即有32个set,每个set有一行,即直接映射,每一行可以存储32字节,即每个set可以存储8个整数

32×32

首先,我们要做的是尽量减少miss,即让我们的cache尽可能的去命中。
那么如果直接按照一行一行的操作,会有什么问题呢?

  1. 对于A,问题倒还好,只要B不会干扰到它的行,那么A的命中率就是7/8
  2. 但是对于B呢?B是按列来访问的,极有可能cache命中的次数为0
    那么分块又是为什么可以优化呢?
    因为分块的情况下,加入是按4×4的大小分块,那么我们最多使用B的某4列,那么B的命中率很有可能达到3/4,为什么说是很可能呢,因为A和B之间可能也会有点干扰,但是这总比之前很可能命中率为0好。
    综上所述,通过分块是有可能有效降低cache miss的次数的,现在就要研究到底怎么分块了。
    32×32代表的是每行每列都是32个整数,可以去看这位大佬的图
    可以发现,如果通过8×8的分块方式,这个8×8的小块内部是不会冲突的
  3. 这个不冲突对于A来说其实没啥用,因为A本来就是按行来访问的
  4. 但是对B来说,这就很重要了,因为B是按列来访问的,如果这个小块内部冲突,那B就会不断的替换cache
    由此可以写出下面这个代码。但是可以发现这个代码拿不到满分,因为满分要求300次miss以内,而这个代码是343
void transpose_submit(int M, int N, int A[N][M], int B[M][N]) {
  // i和j枚举出了每个8×8的矩阵的左顶点
  for (int i = 0; i < 32; i += 8) {
    for (int j = 0; j < 32; j += 8) {
      // cnti和cntj则分别枚举这个小矩阵的行和列
      for (int cnti = 0; cnti < 8; cnti++) {
        for(int cntj=0;cntj<8;cntj++){
            B[j+cntj][i+cnti]=A[i+cnti][j+cntj];
        }
      }
    }
  }
}

为什么呢?因为,A和B之间存在打架的情况。通过打印地址是可以发现A和B的距离很尴尬,使得A和B的任意一个cacheline都是映射到同一个cache。

  1. 其实这也没太大关系,因为我们在A和B中取的8×8的块并不是相同位置,而是根据对角线对称之后的位置,这是没关系的,因为根据上面那位大佬博客里的图可以发现,对称之后其实A和B就不会撞车。
  2. 但是,在对角线的时候,就会出现问题,这时候A和B会撞车。那么该如何优化呢?

可以通过csapp第五章介绍的循环展开的方式来优化,我们可以提前把A的这一行的八个int给取出来,这会将它放到寄存器里,然后我们再去修改B,这时候就不会撞车了。具体实现如下。

void transpose_submit(int M, int N, int A[N][M], int B[M][N]) {
  // i和j枚举出了每个8×8的矩阵的左顶点
  for (int i = 0; i < 32; i += 8) {
    for (int j = 0; j < 32; j += 8) {
      // cnt枚举的是小矩阵的行数
      for(int cnt=0;cnt<8;cnt++){
        int temp1 = A[i + cnt][j];
        int temp2 = A[i + cnt][j + 1];
        int temp3 = A[i + cnt][j + 2];
        int temp4 = A[i + cnt][j + 3];
        int temp5 = A[i + cnt][j + 4];
        int temp6 = A[i + cnt][j + 5];
        int temp7 = A[i + cnt][j + 6];
        int temp8 = A[i + cnt][j + 7];

        B[j][i + cnt] = temp1;
        B[j + 1][i + cnt] = temp2;
        B[j + 2][i + cnt] = temp3;
        B[j + 3][i + cnt] = temp4;
        B[j + 4][i + cnt] = temp5;
        B[j + 5][i + cnt] = temp6;
        B[j + 6][i + cnt] = temp7;
        B[j + 7][i + cnt] = temp8;
      }
    }
  }
}

总结来说

  1. 避免自己跟自己冲突,因此选择8×8,而不是9×9或以上
  2. 避免别人和自己冲突,这里采用了让寄存器来帮忙的方法

64×64

64×64就不能直接用8×8了,同样可以先看上面那位大佬的博客画的,使用8乘8的矩阵,自己就和自己冲突了。
而如果使用4×4的,小矩阵内部倒不会冲突,但是因为每次取8个,结果只用了4个,后面还得再取一次,又要碰撞一次。
所以,8×8地访问还是最适合的,因为不会出现二次访问的需求。但是如果直接使用8×8的方式,又会导致自己不断地撞自己。
那综合一下上面的问题,

  1. 主要就是我们每次取一个cacheline,这个cacheline都包括了8个数字
  2. 但是我们如果用4×4的小矩阵去处理,就会导致同一行需要取两次
  3. 而如果直接使用8×8去取,会导致列操作的那个矩阵不断地撞自己。

有一些大佬就提出了一种方法,我们以8×8的小矩阵去取数据,然后将它分成4个4×4的小矩阵

  1. 当我们处理A的左上小矩阵时,需要将它放到B的左上小矩阵,这时候我们的cache里有A的4个cache行,B的4个cache行(不在对角线的情况下)
  2. 正常情况下,我们会去操作A的右上小矩阵,将它放到B的左下小矩阵。注意,在这个时候,我们操作A的右上小矩阵是不会发生cache miss的,因为A的前4个cache行就在cache中。但是如果我们访问B的左下小矩阵,那就出问题了。因为B的下4个cache行和B的上4个cache行冲突了。
  3. 但是如果我们不正常,我们把A的右上小矩阵,存到B的右上小矩阵中,那就不会发生额外的碰撞了。因为只涉及了A和B的前4个cache行。

那么如果操作到这里,A的前4个cache行已经全部用完了,后面不会再访问了,并且除去对角线情况,命中率应该是7/8

接下来,我们操作A的左下矩阵,将其放到B的右上矩阵。而B的右上矩阵暂存了A的右上矩阵,这个矩阵应该是放到B的左下矩阵的。所以接下来的这一波操作是关键。

  1. 首先,我们按列取出A的左下矩阵,然后按行取出B的右上矩阵
  2. 接下来,将B的右上矩阵置为A的左下矩阵,然后将B的左下矩阵置为取出的B的右上矩阵
    这里有个细节,就在于我们是按行取出B的右上矩阵的。为什么说它细节呢?
  3. 首先,我们进行到这个阶段的时候,cache中有B的前4行cacheline。
  4. 而我们这个阶段需要修改B的左下矩阵,这就涉及到了B的下4行。
  5. 如果我们在这个阶段按行操作B
    1. 那么取出B的右上矩阵的某一行(这一行存的其实是B的左下矩阵的值,如果不清楚,回去看看第一波操作)
    2. 然后我们再紧接着将这一行修改为正确的值,
  6. 至此,B的这一行将永远不再需要被访问。然后它就会被下4行中对应的那一个给替换掉。
    而如果是按列取出B的右上矩阵呢?那就不断地碰撞,miss。

最后一波操作,就是把A的右下矩阵给放到B的右下矩阵中去,这就很轻松了,基本不会miss。
代码实现如下,这个操作说起来还是很抽象的,对着代码看,更加清楚。

void deal_64_64(int M, int N, int A[N][M], int B[M][N]) {
  // i和j枚举出了每个8×8的矩阵的左顶点
  for (int i = 0; i < 64; i += 8) {
    for (int j = 0; j < 64; j += 8) {
      int temp1, temp2, temp3, temp4, temp5, temp6, temp7, temp8;
      int cnti, cntj;
      // 现在处理A的左上4×4,顺便操作一下A的右上4×4
      for (cnti = 0; cnti < 4; cnti++) {
        // 取出A的左上,以行的方式
        temp1 = A[i + cnti][j];
        temp2 = A[i + cnti][j + 1];
        temp3 = A[i + cnti][j + 2];
        temp4 = A[i + cnti][j + 3];
        // 取出A的右上,以行的方式
        temp5 = A[i + cnti][j + 4];
        temp6 = A[i + cnti][j + 5];
        temp7 = A[i + cnti][j + 6];
        temp8 = A[i + cnti][j + 7];
        // 将A的左上放到正确的地方,以列的方式
        B[j][i + cnti] = temp1;
        B[j + 1][i + cnti] = temp2;
        B[j + 2][i + cnti] = temp3;
        B[j + 3][i + cnti] = temp4;
        // 将A的右上放到现在B的右上,提前处理一下,省的后面还要访问A的这个cacheline
        B[j][i + cnti + 4] = temp5;
        B[j + 1][i + cnti + 4] = temp6;
        B[j + 2][i + cnti + 4] = temp7;
        B[j + 3][i + cnti + 4] = temp8;
      }
      // 至此,A的前4行已经全部处理完了,命中率约为7/8
      // 上述操作处理完之后,B的前4行都在cache中
      // 现在处理A的左下,将其移到B的右上,同时我们已经把A的右上存在了B的右上
      // 所以要记得取下来,放到B的左下去
      for (cntj = 0; cntj < 4; cntj++) {
        // 取出A的左下,一列一列地取
        temp1 = A[i + 4][j + cntj];
        temp2 = A[i + 5][j + cntj];
        temp3 = A[i + 6][j + cntj];
        temp4 = A[i + 7][j + cntj];
        // 取出当前B的右上,一行一行地取,之后还要一行一行地放到B的左下
        temp5 = B[j + cntj][i + 4];
        temp6 = B[j + cntj][i + 5];
        temp7 = B[j + cntj][i + 6];
        temp8 = B[j + cntj][i + 7];
        // 修改B的右上为真正的值,即A的左下
        B[j + cntj][i + 4] = temp1;
        B[j + cntj][i + 5] = temp2;
        B[j + cntj][i + 6] = temp3;
        B[j + cntj][i + 7] = temp4;
        // 至此B的这一行完全OK了,虽然马上就要被自己干掉,但是也没关系,反正也用不上了
        // 接下来别忘了修改B的左下,本来应该是A的右上,但是我们提前存在了B的右上
        // 现在就是temp5-8
        B[j + cntj + 4][i] = temp5;
        B[j + cntj + 4][i + 1] = temp6;
        B[j + cntj + 4][i + 2] = temp7;
        B[j + cntj + 4][i + 3] = temp8;
      }
      // 最后修改B的右下
      for (cnti = 4; cnti < 8; cnti++) {
        temp1 = A[i + cnti][j + 4];
        temp2 = A[i + cnti][j + 5];
        temp3 = A[i + cnti][j + 6];
        temp4 = A[i + cnti][j + 7];
        B[j + 4][i + cnti] = temp1;
        B[j + 5][i + cnti] = temp2;
        B[j + 6][i + cnti] = temp3;
        B[j + 7][i + cnti] = temp4;
      }
    }
  }
}

61×67

这玩意就很不规则了,之前之所以又是循环展开的,又是小心翼翼控制矩阵大小的,无非是因为

  1. 循环展开:避免A和B之间碰撞。因为A和B的地址间距刚好是cache大小的倍数。
  2. 矩阵大小:避免A或者B内部不断碰撞。行操作的那一个没事,但是列操作的那一个就遭殃了。

而这一切在61×64的矩阵下都不是问题,因为A或者B内部基本不会碰撞,那矩阵的大小就无所谓了,选一个最小的就行。

#define edge 17
void deal_61_67(int M, int N, int A[N][M], int B[M][N]) {
  for (int i = 0; i < N; i += edge) {
    for (int j = 0; j < M; j += edge) {
      for (int x = i; x < i + edge && x < N; x++) {
        for (int y = j; y < j + edge && y < M; y++) {
          B[y][x] = A[x][y];
        }
      }
    }
  }
}

感谢这位大佬的博客提供的帮助

你可能感兴趣的:(CSAPP,linux)