概述
- 修改
csim.c
实现一个cache,make
然后./test-csim
测试是否正确 - 修改
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
- load的意思很明显,就是先去cache中检查, 如果有这一行,那么就命中,如果不成功,那么就需要找出一个空行,或者根据lru找出一行来替换
- store呢,其实在不考虑实际的写入写出的情况下,和load的操作一模一样,如果cache有这一行,那么就命中了,直接store进去,如果没有这一行,需要读入cache。那其实这个时候就没必要store回内存了,因为刚刚才从内存里读出来。我是感觉这里有点奇怪的。
- 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的所有行
- 如果命中了,则更新命中的次数,更新时间戳,然后直接返回
- 如果没命中,我们在遍历所有行的过程中需要记录是否存在空行,并且记录时间戳最小的行(LRU)。
- 首先,更新miss的次数
- 如果存在空行,则将当前这一行写入空行,并更新valid,tag,和时间戳
- 如果不存在空间,则将当前这一行写入时间戳最小的行,并和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++;
}
}
整体代码结构
这个实验的关键部分就这么多。剩下的有点类似于脏活累活,但是也很有意义。先看一下整体的代码结构
可以分为以下几个部分
- 解析命令行参数
- 根据解析出来的参数初始化我们的cache
- 读取文件里的操作,并进行操作
- 输出结果
- 释放申请的变量
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
函数。
这个函数的三个参数
- 程序参数的数量
- 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"
- 第三个参数是我们参数的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尽可能的去命中。
那么如果直接按照一行一行的操作,会有什么问题呢?
- 对于A,问题倒还好,只要B不会干扰到它的行,那么A的命中率就是7/8
- 但是对于B呢?B是按列来访问的,极有可能cache命中的次数为0
那么分块又是为什么可以优化呢?
因为分块的情况下,加入是按4×4的大小分块,那么我们最多使用B的某4列,那么B的命中率很有可能达到3/4,为什么说是很可能呢,因为A和B之间可能也会有点干扰,但是这总比之前很可能命中率为0好。
综上所述,通过分块是有可能有效降低cache miss的次数的,现在就要研究到底怎么分块了。
32×32代表的是每行每列都是32个整数,可以去看这位大佬的图
可以发现,如果通过8×8的分块方式,这个8×8的小块内部是不会冲突的 - 这个不冲突对于A来说其实没啥用,因为A本来就是按行来访问的
- 但是对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。
- 其实这也没太大关系,因为我们在A和B中取的8×8的块并不是相同位置,而是根据对角线对称之后的位置,这是没关系的,因为根据上面那位大佬博客里的图可以发现,对称之后其实A和B就不会撞车。
- 但是,在对角线的时候,就会出现问题,这时候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;
}
}
}
}
总结来说
- 避免自己跟自己冲突,因此选择8×8,而不是9×9或以上
- 避免别人和自己冲突,这里采用了让寄存器来帮忙的方法
64×64
64×64就不能直接用8×8了,同样可以先看上面那位大佬的博客画的,使用8乘8的矩阵,自己就和自己冲突了。
而如果使用4×4的,小矩阵内部倒不会冲突,但是因为每次取8个,结果只用了4个,后面还得再取一次,又要碰撞一次。
所以,8×8地访问还是最适合的,因为不会出现二次访问的需求。但是如果直接使用8×8的方式,又会导致自己不断地撞自己。
那综合一下上面的问题,
- 主要就是我们每次取一个cacheline,这个cacheline都包括了8个数字
- 但是我们如果用4×4的小矩阵去处理,就会导致同一行需要取两次
- 而如果直接使用8×8去取,会导致列操作的那个矩阵不断地撞自己。
有一些大佬就提出了一种方法,我们以8×8的小矩阵去取数据,然后将它分成4个4×4的小矩阵
- 当我们处理A的左上小矩阵时,需要将它放到B的左上小矩阵,这时候我们的cache里有A的4个cache行,B的4个cache行(不在对角线的情况下)
- 正常情况下,我们会去操作A的右上小矩阵,将它放到B的左下小矩阵。注意,在这个时候,我们操作A的右上小矩阵是不会发生cache miss的,因为A的前4个cache行就在cache中。但是如果我们访问B的左下小矩阵,那就出问题了。因为B的下4个cache行和B的上4个cache行冲突了。
- 但是如果我们不正常,我们把A的右上小矩阵,存到B的右上小矩阵中,那就不会发生额外的碰撞了。因为只涉及了A和B的前4个cache行。
那么如果操作到这里,A的前4个cache行已经全部用完了,后面不会再访问了,并且除去对角线情况,命中率应该是7/8
接下来,我们操作A的左下矩阵,将其放到B的右上矩阵。而B的右上矩阵暂存了A的右上矩阵,这个矩阵应该是放到B的左下矩阵的。所以接下来的这一波操作是关键。
- 首先,我们按列取出A的左下矩阵,然后按行取出B的右上矩阵
- 接下来,将B的右上矩阵置为A的左下矩阵,然后将B的左下矩阵置为取出的B的右上矩阵
这里有个细节,就在于我们是按行取出B的右上矩阵的。为什么说它细节呢? - 首先,我们进行到这个阶段的时候,cache中有B的前4行cacheline。
- 而我们这个阶段需要修改B的左下矩阵,这就涉及到了B的下4行。
- 如果我们在这个阶段按行操作B
- 那么取出B的右上矩阵的某一行(这一行存的其实是B的左下矩阵的值,如果不清楚,回去看看第一波操作)
- 然后我们再紧接着将这一行修改为正确的值,
- 至此,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
这玩意就很不规则了,之前之所以又是循环展开的,又是小心翼翼控制矩阵大小的,无非是因为
- 循环展开:避免A和B之间碰撞。因为A和B的地址间距刚好是cache大小的倍数。
- 矩阵大小:避免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];
}
}
}
}
}
感谢这位大佬的博客提供的帮助