目录
开始
这个lab有两个部分,第一个部分要求我们写一个缓存模拟器。第二部分要求我们优化矩阵转置的函数,使它的缓存脱靶数降到最低。
Part A
其实这个模拟器不必从头开始写。反正作者已经给我们写好了 csim-ref 对不对?我们只要把它反汇编了,之后把汇编翻译成C语言代码就行了。好了,我们看 Part B.
嘿嘿,开个玩笑而已。不过如果你有足够耐心的话,这种办法真的可以一试……
1. 要用到的几个结构
要写这个模拟程序,我们先从缓存的结构看起。我们知道,缓存的结构是下面这个样子:
所以,我们应该先定义一个结构,用来描述缓存的各个属性。比如下面这个结构:
typedef struct per_block
{
bool isVaild;
int tag;
// 从测试用的指令以及缓存尺寸来看,
// tag 为 32 位是可行的。
int timestamp;
} per_block;
这个结构描述的是缓存中一个 set 里的一行,外加一个“时间戳”,以记录上一次访问这一条缓存是什么时候。为什么我们不存储这一行的内容呢?这是因为,我们的缓存模拟器只需要计算有多少脱靶数、命中数、驱逐数,并不需要读取具体的字节。
刚才我们定义了存储一个 set 中一行的结构。接下来我们看一看怎么把一个 set 中的若干个 per_block 联系在一起。办法很简单,用指向 per_block 的指针就可以了。不过 per_block* 看起来似乎有些表意不明,于是我们给它起一个别名,叫 set:
typedef per_block* set;
那么若干个 set 合起来是什么呢?没错,就相当于一个完整的缓存。我们当然可以直接把缓存定义为指向 set 的指针,之后再给 set* 起别名;不过呢,表示缓存的结构,除了可以有指向 set 的指针,更可以有 set 数、line 数对不对。所以我们把表示缓存的结构定义成这个样子:
typedef struct cache
{
int nlines; // line 数
int nsets; // set 数
int bblocks;
// 在某个具体的地址中,用来表示 block 偏移量的比特数
int bsets;
// 在某个具体的地址中,用来表示 set 索引的比特数
set* head;
} cache;
我们还需要用一个结构,表示测试文件中的每一个指令。如下:
typedef struct operation
{
char op; // 什么操作?'I', 'M', 'S', 还是 'L'?
int size; // 访问了几个字节?
// 事实上,因为内存中的数据都是对齐的
// (也就是说,不会出现某一行能放 8 个字节,
// 某个指令却从该行第 5 个字节开始连续读 6 个字节的情况),
// 不包含这个字段不会对后面命中数之类的计算产生任何影响。
// 这里包含它是为了保证,给出参数 "-v" 时,csim 的输出
// 和参考用的模拟器输出一致。
unsigned long address; // 访问的字节从哪个地址开始?
} operation;
2. 基本的操作!
对于我们的缓存模拟器来说,最最基本的操作就是从一个特定的地址中获得 set 的索引和 tag 值(block 的偏移量倒还在其次),也就是下面两个函数所做的:
static inline unsigned gets_s(unsigned long address, int s, int b)
{
return (address >> b) % (1 << s);
}
static inline unsigned gets_t(unsigned long address, int s, int b)
{
return address >> (s + b);
}
我们知道,对于十进制数 n n n 来说,把它除上 1 0 r 10^r 10r 取余数,可以得到它的末 r r r 位数。比如,1234 除 1 0 2 10^2 102 的余数是 34,正好是它的末两位。对于二进制数来说也同理——把一个二进制数除上 2 s 2^s 2s 位取余数,就可以得到它的末 s s s 位。gets_s()就利用了这个性质来取得地址中包含的 set 索引。
而 gets_t() 的原理就简单啦~ 要获得 tag 值,只要把地址中的 set 索引和 block 偏移量移走就行了。
之后我们为模拟缓存分配空间。参数 s、b 和 e 的含义和书中的相同。
cache allocate_set(int s, int b, int e)
{
int nsets = 1 << s;
set* head = calloc(nsets, sizeof(set));
for (int i = 0; i < nsets; ++i)
head[i] = calloc(e, sizeof(per_block));
cache c = {
e, nsets, b, s, head };
return c;
}
与 allocate_set() 配套的是 free_set():
void free_set(cache* c)
{
for (int i = 0; i < c->nsets; ++i)
{
free(c->head[i]);
c->head[i] = NULL;
}
free(c->head);
c->head = NULL;
}
参数用指向 cache 的指针是为了降低参数传递过程中的开销。
接下来是从文件中提取操作的函数。我们不会试图一下子就把文件中的所有操作载入内存(否则,我们必须考虑在文件过大的情况下,内存不够用的情况);相反地,我们一次只读入一个操作:
operation fetch_operation(FILE* pfile)
// pfile 指向一个已经打开的测试文件。下面我们还会提到它。
{
char temp[OP_LENGTH]; // OP_LENGTH 被定义为 128
operation oper = {
0, 0, 0 };
if(!fgets(temp, OP_LENGTH, pfile))
return oper;
sscanf(temp, "\n%c %lx,%d", &oper.op, &oper.address, &oper.size);
// 这里,'\n' 的含义是“忽略此处可能出现的空白”
return oper;
}
好啦,现在我们已经写好了创建缓存、释放缓存和抓取指令的三个函数。接下来我们要写的是处理抓取来的指令的函数。不过在真正开始之前,我们首先来回顾一下这些操作的一般流程——
首先要先根据地址找到对应的 set;之后呢,要在这个 set 的各行里搜索对应的 tag 值,确定之前是不是已经把这一行载入了;如果找到,那么恭喜,接下来就可以执行相应的操作了;如果没有找到呢,就要在这个 set 里找目前还未加载任何内容的行(也就是 valid bit 为零的行;放在我们的模拟器中,就是 isValid == false)。如果这个 set 全满了,那就用 LRU 算法,找到这个 set 中使用的最不频繁的行,用新的一行把它覆盖掉,再执行相关的操作。
对于 Store 和 Load,它们前期向缓存加载地址的流程都是上面那个样子,不同的只是后期对具体内容的操作——然而我们并不关心它。至于 Modify 操作嘛,只是 Store 和 Load 的简单组合。
因此,只要把上面通用流程中的子过程都写好,就可以完成一大半工作。我们先来看搜索对应 tag 值的函数:
per_block* find_match_block(set s, int e, int tag)
{
for (int i = 0; i < e; ++i)
if (s[i].tag == tag && s[i].isVaild)
return s + i;
return NULL;
}
for 循环遍历 set 中的每一行,以搜索对应的 tag 值。如果找到,就返回指向对应行的指针;如果没有找到,就返回 NULL。
接下来是搜索“新行”的函数:
per_block* find_new_block(set s, int e)
{
for (int i = 0; i < e; ++i)
if (!(s[i].isVaild))
return s + i;
return NULL;
}
这个函数不难理解——就是返回遍历过程中遇到的第一个“新行”。
最后一个要写的函数,要根据 LRU 算法搜索访问时间最靠前的行。要做到这一点,就需要用到前面 per_block 结构里定义的 timestamp——找到值最小的行返回就可以了。
per_block* find_evict_block(set s, int e)
{
int index = 0;
for (int i = 1; i < e; ++i)
if (s[i].timestamp < s[index].timestamp)
index = i;
return s + index;
}
接下来就要实现 Load、Store 操作啦。为了实现 csim-ref 中 “-v” 开关对应的功能,我们另外加一个 isDisplay 参数,用来指示是否输出相关信息。为什么用指针传递 c 和 op 呢?同样是出于降低参数调用过程中开销的考虑。
void load_or_store(cache* c, operation* op, bool isDisplay)
{
int s = gets_s(op->address, c->bsets, c->bblocks);
int t = gets_t(op->address, c->bsets, c->bblocks);
if (isDisplay)
printf("%c %lx,%d ", op->op, op->address, op->size);
per_block* appropriate_tag = find_match_block(c->head[s], c->nlines, t);
if (appropriate_tag)
{
++hit_count;
// hit_count 和下面的 eviction_count 、miss_count 一样,
// 都是全局变量。
if (isDisplay)
printf("hit ");
}
else
{
++miss_count;
if (isDisplay)
printf("miss ");
appropriate_tag = find_new_block(c->head[s], c->nlines);
if (appropriate_tag)
appropriate_tag->isVaild = true;
else
{
++eviction_count;
appropriate_tag = find_evict_block(c->head[s], c->nlines);
if (isDisplay)
printf("eviction ");
}
appropriate_tag->tag = t;
}
appropriate_tag->timestamp = timestamp;
timestamp++;
// 最后更新时间戳。其实不必非要用真实的时间~ 能反映出访问的先后顺序就好。
if (isDisplay)
printf("\n");
}
接下来是 Modify 操作:
void modify(cache* c, operation* op, bool isDisplay)
{
if (isDisplay)
printf("%c %lx,%d ", op->op, op->address, op->size);
op->op = 'L';
load_or_store(c, op, isDisplay);
op->op = 'S';
load_or_store(c, op, isDisplay);
if (isDisplay)
printf("\n");
}
很好理解对不对?
当然这里用一个很不好的问题——调用 modify 时,如果 isDisplay 为 true,那么 op 结构中的内容将会被显示 3 次。解决的方法很简单——只要给 load_or_store 再添加一个参数 isCalledByMain 就可以了。就像下面这样: