有一说一,cache lab有点难。本文参考了网络上不少大神的博文,终于是做到了满分。做这个lab之前或者看本文之前建议在浏览器中点开以下材料链接:
WriteUp,本lab的规则与评分标准。
书本内容及实验,这个ppt其实非常好,回顾了书本上的内容,对实验部分也给予了一定的引导作用。
分块技术,CMU早年的一篇文章,配合Lab食用体验更佳。
本文实验环境 Ubuntu 18.04 LTS 非虚拟机。在实验之前还需要装一下valgrind内存泄露检测工具。
sudo apt install valgrind
安装完成之后如果能够检测到valgrind的版本号即安装成功。
valgrind --version
Part A —— Writing A Cache Simulator
在这个part里需要在 csim.c 里编写一个使用LRU策略的 cache 模拟器,要求最终测试结果要和文件中给出的 csim-ref 文件的结果一样。它里面进行验证的命令存在traces文件夹里,打开一个可以看到其中的样子是这样:
...
I 0040052a,7
S 7ff000384,4
I 00400531,2
I 00400581,4
L 7ff000384,4
...
其中注意要求,如果碰到 I 开头的命令,不用理会。代码里直接continue就好了。
LRU在操作系统课程中已经讲过了,即被替换掉的块其最后的访问时间是距离现在最远的,体现在代码里可以设置一个时间戳,完成一次操作然后递增,替换的时候比大小就可以了。
这个模拟器还需要几个函数,getopt 函数和 fscanf 函数在开头链接里的ppt里都有示例用法,拿过来稍微改一下就可以了。
对于这个模拟器来说,需要适应不同的 s E b,所以要用 malloc 函数动态分配。
下面给出代码,注释写在代码旁边。建议从最下 main 函数往上看。
#include "cachelab.h"
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <limits.h>
#include <getopt.h>
#include <string.h>
int h,v,s,E,b,S; // 这个是我们模拟的参数,为了方便在函数里调用,设置成全局
int hit_count ,
miss_count ,
eviction_count; // 三个在 printSummary 函数中的参数,需要不断更新
char t[1000]; // 存 getopt 中选项内容,表示的是验证中需使用的trace文件名
typedef struct{
int valid_bits;
int tag;
int stamp;
}cache_line, *cache_asso, **cache; // cache 模拟器的结构。由合法位、标记位和时间戳组成
cache _cache_ = NULL; // 声明一个空的结构体类型二维数组
// 打印 helper 内容的函数,-h 命令使用,内容可自定义
void printUsage()
{
printf("Usage: ./csim-ref [-hv] -s <num> -E <num> -b <num> -t <file>n"
"Options:n"
" -h Print this help message.n"
" -v Optional verbose flag.n"
" -s <num> Number of set index bits.n"
" -E <num> Number of lines per set.n"
" -b <num> Number of block offset bits.n"
" -t <file> Trace file.nn"
"Examples:n"
" linux> ./csim-ref -s 4 -E 1 -b 4 -t traces/yi.tracen"
" linux> ./csim-ref -v -s 8 -E 2 -b 4 -t traces/yi.tracen");
}
// 初始化cache的函数
void init_cache()
{
//多维数组的开辟要一行行malloc
_cache_ = (cache)malloc(sizeof(cache_asso) * S);
for(int i = 0; i < S; ++i)
{
_cache_[i] = (cache_asso)malloc(sizeof(cache_line) * E);
for(int j = 0; j < E; ++j)
{
_cache_[i][j].valid_bits = 0;
_cache_[i][j].tag = -1;
_cache_[i][j].stamp = -1;
}
}
}
void update(unsigned int address)
{
// 索引地址位可以用位运算,-1U是最大整数,64是因为我电脑是64位
int setindex_add = (address >> b) & ((-1U) >> (64 - s));
int tag_add = address >> (b + s);
int max_stamp = INT_MIN;
int max_stamp_index = -1;
for(int i = 0; i < E; ++i) //如果tag相同,就hit,重置时间戳
{
if(_cache_[setindex_add][i].tag == tag_add)
{
_cache_[setindex_add][i].stamp = 0;
++hit_count;
return ;
}
}
for(int i = 0; i < E; ++i) // 查看有没有空行
{
if(_cache_[setindex_add][i].valid_bits == 0)
{
_cache_[setindex_add][i].valid_bits = 1;
_cache_[setindex_add][i].tag = tag_add;
_cache_[setindex_add][i].stamp = 0;
++miss_count;
return ;
}
}
// 没有空行又没有hit就是要替换了
++eviction_count;
++miss_count;
for(int i = 0; i < E; ++i)
{
if(_cache_[setindex_add][i].stamp > max_stamp)
{
max_stamp = _cache_[setindex_add][i].stamp;
max_stamp_index = i;
}
}
_cache_[setindex_add][max_stamp_index].tag = tag_add;
_cache_[setindex_add][max_stamp_index].stamp = 0;
return ;
}
void update_stamp()
{
for(int i = 0; i < S; ++i)
for(int j = 0; j < E; ++j)
if(_cache_[i][j].valid_bits == 1)
++_cache_[i][j].stamp;
}
void parse_trace()
{
FILE* fp = fopen(t, "r"); // 读取文件名
if(fp == NULL)
{
printf("open error");
exit(-1);
}
char operation; // 命令开头的 I L M S
unsigned int address; // 地址参数
int size; // 大小
while(fscanf(fp, " %c %xu,%dn", &operation, &address, &size) > 0)
{
switch(operation)
{
//case 'I': continue; // 不用写关于 I 的判断也可以
case 'L':
update(address);
break;
case 'M':
update(address); // miss的话还要进行一次storage
case 'S':
update(address);
}
update_stamp(); //更新时间戳
}
fclose(fp);
for(int i = 0; i < S; ++i)
free(_cache_[i]);
free(_cache_); // malloc 完要记得 free 并且关文件
}
//===============================================================
int main(int argc, char* argv[])
{
h = 0;
v = 0;
hit_count = miss_count = eviction_count = 0;
int opt; // 接收getopt的返回值
// getopt 第三个参数中,不可省略的选项字符后要跟冒号,这里h和v可省略
while(-1 != (opt = (getopt(argc, argv, "hvs:E:b:t:"))))
{
switch(opt)
{
case 'h':
h = 1;
printUsage();
break;
case 'v':
v = 1;
printUsage();
break;
case 's':
s = atoi(optarg);
break;
case 'E':
E = atoi(optarg);
break;
case 'b':
b = atoi(optarg);
break;
case 't':
strcpy(t, optarg);
break;
default:
printUsage();
break;
}
}
if(s<=0 || E<=0 || b<=0 || t==NULL) // 如果选项参数不合格就退出
return -1;
S = 1 << s; // S=2^s
FILE* fp = fopen(t, "r");
if(fp == NULL)
{
printf("open error");
exit(-1);
}
init_cache(); // 初始化cache
parse_trace(); // 更新最终的三个参数
printSummary(hit_count, miss_count, eviction_count);
return 0;
}
结果应该如下图
Part B —— Optimizing Matrix Transpose
这个 part 要求我们去完成 trans.c 文件中的 transpose_submit 函数,实现矩阵的转置。有几个要求,一是逻辑上一共只准使用不超过 12 个 int 类型局部变量,二是不能用递归,三是不准改变 A 数组的内容,但能在 B 数组里随便写,四是不能定义新的数组也不能用 malloc 函数开辟空间。最终要使得 cache miss 的次数尽可能少。
上面给的文章和ppt里有关于这个 part 一些提示,建议自己认真看一遍。最终给分由三个测试决定,用 csim-ref 或者 test-trans 测试。
已经给定了 cache 的参数 s = 5,b = 5 ,E = 1。那么 cache 的大小就是 32 组,每组 1 行, 每行可存储 32 字节的数据。ppt 里给出了完成本 part 的提示:分块 (blocking)
32 X 32
第一个测试矩阵大小是 32 x 32 的。我们先来分析一下,一个 int 类型数字是 4 字节,cache 中一行 32 字节,可以放 8 个 int 。先用原来给的示例代码看一下 miss 数量。
if
可以看到 miss 数达到了 1183 个,距离我们要求的 300 个以下满分有着不小的距离。这就要求我们去使用分块技术,因为 cache 一行能放 8 个,所以我们分块最好也用 8 的倍数。在 32 x 32的矩阵中,一行有 32 个 int,即 4 个 cache 行,所以 cache 一共可以存矩阵的 8 行。瞧,正好可以用长宽都为 8 的分块,不会造成冲突。那么再按 8x8 的分块写代码测试一下。
if
这段代码最后 miss 数量有了惊人的下降,降低到了 343 次,看来分块技术很有效果,但是距离满分还有一些差距。
可以发现,A 和 B 中相同位置的元素是映射在同一 cache line 上的,但是因为我们做转置的话,并不会把 A 中某位置的元素放到 B 中相同的地方,除非在对角线上,因为下标是一样的,此时就会发生原地转置。譬如我们已经把 A 的第四行存进去了,但是当要写 B44 时,发生了冲突,第四行被换成 B 的,然后读 A 的时候又换成了 A 的,就多造成了两次 miss。
这个时候可以使用一个简单的办法,因为除了循环需要的 4 个变量外我们还剩余 8 个自由变量可以用,正好可以存一个 cache line。以空间换时间,把一行一次性读完,减少冲突不命中。代码如下
if
结果到了 287,已经满分了。但是我们还可以继续做下去。根据 PPT 或者是文章里的说法,32 x 32的矩阵利用 8x8 分块,理论上的 miss 应该是
现在是287次,哪里出现了问题?现在 A 是在每对 8 个 int 操作时,会不命中一次,即每个块中有且只有一个元素不被命中,这是无法避免的,而 B 现在是不仅在开头有不命中,在对角线上也有不命中,如果能把 B 中的对角线元素命中的话,就可以达到理论上的 256 次 miss。
处理冲突不命中,就是要处理重复的加载。在访问一个块后,这个被加载过的块因为又被访问使得被覆盖,导致第二次访问这个块的时候不命中。这是因为两次访问的 tag 位并不相同。比如在下标为 22 的不命中,因为第一行的数据转置时会访问到 B 的第二行,随后 A 也访问到第二行,接着 B 又重新访问第二行,这个第二次访问第二行的过程即一次冲突不命中。
要避免它,一个是可以在 A 访问第二行之前不让 B 访问第二行,一个是让 A 访问第二行之后不让 B 访问第二行。后者因为要写入所以不可能,就是必须要先读到 A 才能写到 B。那前者的话,我们原本是直接把所有元素转置到 B 中,但是这样就必定访问 B 的第二行。所以我们换一个思路,能否等 A 的下一行的元素访问完了再转置?
这样的话面临一个问题,我们没有地方存放第一行的 8 个元素了,因为自由变量是有限的。但是我们可以在 B 里随便写啊,因为 B 中第一行在第一次冷不命中后,后面对于该行的访问是一定命中的,因为 A 不再访问第一行了,所以我们可以将 A 的元素暂存在 B 的第一行,等到下一行读到变量里再访问 B 的下一行。代码如下:
if
最终达到了 259 次 miss,剩下 3 次是函数的开销所造成的。
64 X 64
对 64 x 64 的矩阵来说,每行有 64 个 int,则 cache 只能存矩阵的 4 行了,所以如果使用 8x8 的分块,一定会在写 B 的时候造成冲突,因为映射到了相同的块。看一下 8x8 的分块能达到多少 miss。
else
8 分块的效果很不理想,惊人的四千六百多次。没有办法只能尝试使用 4 分块,虽然可能对 cache 的利用效率不高。
else
一千七百次,还是达不到满分要求,但是已经缩小了很多。这个地方我自己的想法是对 8 分块再进行 4 分块,但是无奈自己代码功力欠缺,敲着敲着逻辑有点混乱,这里直接贴一下我参考网络上一位大神的代码,他的 4 分块读写的顺序和我想的有些差异,但是大差不差,还有图可以参考。建议各位一定要自己去画图,去看代码里怎么对元素进行读写的,然后自己算 miss 率。
else
网上有大神优化到 1100 次以内,不知道是怎么做到的。太强了只能膜。
61 X 67
这个相对 64 来说好想一点,因为不是正好的边边相等的矩形,所以不一定要用 8 分块,一个个试下来发现单纯分块的话,17 分块达到了最小的是 1950 次。但是这样很粗糙,我们还是用 8 分块去稍微对对角线做下操作,因为 32 x 32 最小的 miss 的方法和这边是一样的,而且写起来太多了,我们就用最简单的存变量的方式去做。
else if(M == 61)
{
int i, j, v1, v2, v3, v4, v5, v6, v7, v8;
int n = N / 8 * 8;
int m = M / 8 * 8;
for (j = 0; j < m; j += 8)
for (i = 0; i < n; ++i)
{
v1 = A[i][j];
v2 = A[i][j+1];
v3 = A[i][j+2];
v4 = A[i][j+3];
v5 = A[i][j+4];
v6 = A[i][j+5];
v7 = A[i][j+6];
v8 = A[i][j+7];
B[j][i] = v1;
B[j+1][i] = v2;
B[j+2][i] = v3;
B[j+3][i] = v4;
B[j+4][i] = v5;
B[j+5][i] = v6;
B[j+6][i] = v7;
B[j+7][i] = v8;
}
for (i = n; i < N; ++i)
for (j = m; j < M; ++j)
{
v1 = A[i][j];
B[j][i] = v1;
}
for (i = 0; i < N; ++i)
for (j = m; j < M; ++j)
{
v1 = A[i][j];
B[j][i] = v1;
}
for (i = n; i < N; ++i)
for (j = 0; j < M; ++j)
{
v1 = A[i][j];
B[j][i] = v1;
}
}
最终是 1905 次 miss,满分了已经。
cache lab 确实比前面的 lab 难度要提升了不少,也着实发现自己的代码功力还不足。
贴几个我觉得写的很好的博文给大家参考。
孟佬的cachelab,猫神的cache lab,pikahan,李秋豪,特别推荐看一下的网络某巨佬
个人代码github下载