写在前言:这个实验的来源是CSAPP官网:CSAPP Labs ,如果感兴趣的话,可以点击这个链接去下载,这个实验分为两个部分,第一个部分是仿照给出的缓存模拟器,编写一个与参考版本具有相同行为高速缓存模拟器,其替换策略为LRU(Least Recently Use)替换策略;第二部分是使用优化一个矩阵转置函数,使得Cache miss尽可能少。
实验第一部分要求编写一个高速缓存模拟器,修改 csim.c
,实验里面有个实验模拟器的参考版本 csim-ref
,要求实现和这个参考版本具有一样行为的Cache模拟器,并且使用LRU缓存替换策略。
csim-ref
的命令用法:
$ ./csim-ref -h
Usage: ./csim-ref [-hv] -s <s> -E <E> -b <b> -t <tracefile>
Options:
-h Print this help message.
-v Optional verbose flag.
-s <num> Number of set index bits.
-E <num> Number of lines per set.
-b <num> Number of block offset bits.
-t <file> Trace file.
Examples:
linux> ./csim -s 4 -E 1 -b 4 -t traces/yi.trace
linux> ./csim -v -s 8 -E 2 -b 4 -t traces/yi.trace
缓存结构使用的是一个链表数组,链表的节点就是一个高速缓存行。
typedef struct Node {
char valid;
long tag;
/* Cache line index. */
int val;
struct Node* prev;
struct Node* next;
} Node;
typedef struct LinkedList {
struct Node* head;
struct Node* tail;
int size;
} LinkedList;
/* Cache using LRU replacement policy */
typedef struct Cache {
int s; /* Number of set index bits. */
int E; /* Number of lines per set. */
int b; /* Number of block offset bits. */
int t; /* Number of tag bits. */
int m; /* Address bits, 64-bit. */
LinkedList* cache;
} Cache;
其中 Node
的 valid
代表的是该行是否有效,在实际实现当中,没有用到这个,因为初始状态,所有的缓存都是 invalid
的,tag
是该缓存行的标记,而 val
是该缓存行所在组的行数。
每一个组都是一个双向链表,至于为什么使用双向链表,是因为它删除节点比较快,方便用来实现LRU缓存策略。
关于一些基本的链表操作,初始化空链表、添加节点到尾部、删除一个节点、查找一个tag
值为指定值val
的节点:
/* Initialize a empty Linked List. */
void initializeList(LinkedList* list)
{
list->size = 0;
list->head = (struct Node* )malloc(sizeof(Node));
list->tail = (struct Node* )malloc(sizeof(Node));
list->head->next = list->tail;
list->tail->prev = list->head;
}
/* Insert node into list in tail. */
void addLast(LinkedList* list, struct Node* node)
{
node->prev = list->tail->prev;
node->next = list->tail;
list->tail->prev->next = node;
list->tail->prev = node;
list->size++;
}
/* Remove a node from list. */
void removeNode(LinkedList* list, struct Node* node)
{
//printf("in removeNode\n");
node->prev->next = node->next;
node->next->prev = node->prev;
list->size--;
//printf("out removeNode\n");
}
/* Find a node with a specific value. */
Node* findNode(LinkedList* list, long val)
{
//printf("in findNode\n");
struct Node* p;
/* list is empty, return nullptr. */
if (list->head->next == list->tail)
return NULL;
p = list->head->next;
while (p != list->tail)
{
if (p->tag == val)
return p;
p = p->next;
}
//printf("out findNode\n");
/* Cannot find the node with val. */
return NULL;
}
在上面双向链表操作的函数基础上,可以实现LRU缓存的操作,实际实现的时候,
adjust
调整链表。addLast
函数即可。justify
函数,将这个缓存行放到链表的最后一个节点上,表示它最近被使用过了。接下来是主函数的实现:
Cache
Cache
,或者输出帮助信息Cache
Cache
运行过程int main(int argc, char *argv[])
{
int i;
FILE* file;
Cache* c = (Cache* )malloc(sizeof(Cache));
for (i = 1; i < argc; i++) {
if (strcmp("-h", argv[i]) == 0) {
printHelp();
exit(0);
} else if (strcmp("-v", argv[i]) == 0) {
verbose = 1;
} else if (strcmp("-s", argv[i]) == 0) {
assert(i < argc + 1);
c->s = atoi(argv[i + 1]);
i++;
} else if (strcmp("-E", argv[i]) == 0) {
assert(i < argc + 1);
c->E = atoi(argv[i + 1]);
i++;
} else if (strcmp("-b", argv[i]) == 0) {
assert(i < argc + 1);
c->b = atoi(argv[i + 1]);
i++;
} else if (strcmp("-t", argv[i]) == 0) {
assert(i < argc + 1);
file = fopen(argv[i + 1], "r");
assert(file);
i++;
}
}
/* Initialize Cache */
initializeCache(c);
simulate(c, file);
return 0;
}
初始化Cache
,主要是分配内存空间:
/* Initialize cache */
void initializeCache(Cache* c)
{
int i;
int S = pow(2, c->s);
c->cache = (LinkedList* )malloc(S * sizeof(LinkedList));
for (i = 0; i < S; i++)
{
/* Initialize */
initializeList(&c->cache[i]);
}
}
最重要的是simulate
函数:
void simulate(Cache* c, FILE* file)
{
//int i;
char op;
long addr;
int size;
int type;
int misses = 0;
int hits = 0;
int evictions = 0;
char* buf = (char *)malloc(50 * sizeof(char));
/* Read a single line. */
fscanf(file, "%[^\n]%*c", buf);
while (!feof(file))
{
/* Make sure we don't handle I cache access. */
if (buf[0] == ' ')
{
sscanf(buf, " %c %lx,%d", &op, &addr, &size);
//printf("operation: %c, address: %lx, size: %d\n", op, addr, size);
if (op == 'L') {
/* Load value */
type = load(c, addr);
} else if (op == 'M') {
/* Modify value */
type = modify(c, addr);
} else if (op == 'S') {
/* Store value */
type = store(c, addr);
}
//printf("Simulating~~\n");
if (verbose) printDetail(op, addr, size, type);
// Update hit, miss, eviction state.
if (type == 1) {
hits++;
} else if (type == -1) {
misses++;
} else if (type == -2) {
misses++;
evictions++;
}
// Modify will always hit
if (op == 'M') {
hits++;
}
}
/* Read a single line. */
fscanf(file, "%[^\n]%*c", buf);
}
printSummary(hits, misses, evictions);
}
循环按行读取文件,然后用sscanf
函数提取数据,操作类型、地址、访问块的大小等等,然后根据操作类型调用相应的函数即可。
load:
/* Access address, if hit in cache return 1, if miss return -1, if miss and eviction return -2. */
int load(Cache* c, long addr)
{
/* Translate address. */
int index = (addr >> c->b) & ((1 << c->s) - 1);
/* Extract high t bit */
long tag = (addr >> (c->b + c->s)) & ((1 << (c->b + c->s)) - 1);
struct Node* node;
//printf("tag=%lx, set index=%d\n", tag, index);
node = findNode(&c->cache[index], tag);
if (node != NULL) {
/* Cache Hit */
justify(&c->cache[index], node);
return 1;
}
/* Cache Miss */
if (c->cache[index].size < c->E) {
/* Cache is not full. */
node = (struct Node *)malloc(sizeof(Node));
node->valid = 'y';
node->tag = tag;
node->val = c->cache[index].size;
addLast(&c->cache[index], node);
return -1;
} else {
/* Cache is full, use LRU replacement policy. */
node = c->cache[index].head->next;
node->tag = tag;
adjust(&c->cache[index], node);
return -2;
}
}
第一步提取地址的信息,组号,还有标记tag
;
第二步在相应的Cache
组寻找标记为tag
的缓存行;
第三步判断缓存命中还是缺失:
如果缓存命中,那么调用adjust
函数调整链表,以适应LRU缓存策略。
如果缓存缺失,那么就要分两种情况
tag
值替换然后调用adjust
调整缓存根据命中和缺失的类型,返回值设置为3种,1,-1,-2
store
:
int store(Cache* c, long addr)
{
int index = (addr >> c->b) & ((1 << c->s) - 1);
/* Extract high t bit */
long tag = (addr >> (c->b + c->s)) & ((1 << (c->b + c->s)) - 1);
struct Node* node;
node = findNode(&c->cache[index], tag);
/* Cache Hit */
if (node != NULL) {
adjust(&c->cache[index], node);
return 1;
}
/* Cache Miss */
return load(c, addr);
}
store
的操作和load
类似,如果缓存命中返回1;
否则需要进行加载操作,返回加载该地址的情况。
并且注意需要调整缓存。
modify
:
int modify(Cache* c, long addr)
{
/* Translate address. */
int index = (addr >> c->b) & ((1 << c->s) - 1);
long tag = (addr >> (c->b + c->s)) & ((1 << (c->b + c->s)) - 1);
struct Node* node;
//printf("tag=%lx, set index=%d\n", tag, index);
node = findNode(&c->cache[index], tag);
/* Cache hit */
if (node != NULL) {
ajust(&c->cache[index], node);
return 1;
}
if (load(c, addr) == -1) {
/* Cache miss*/
store(c, addr);
return -1;
} else {
store(c, addr);
return -2;
}
}
modify修改内存的值,如果缓存命中调整缓存后返回1;
否则需要进行load操作,再存储。
adjust
:
void adjust(LinkedList* list, Node* node)
{
/* Remove node and Add to list in tail. */
removeNode(list, node);
addLast(list, node);
}
实际得分:
$ ./test-csim
Your simulator Reference simulator
Points (s,E,b) Hits Misses Evicts Hits Misses Evicts
3 (1,1,1) 9 8 6 9 8 6 traces/yi2.trace
3 (4,2,4) 4 5 2 4 5 2 traces/yi.trace
3 (2,1,4) 2 3 1 2 3 1 traces/dave.trace
3 (2,1,3) 167 71 67 167 71 67 traces/trans.trace
3 (2,2,3) 201 37 29 201 37 29 traces/trans.trace
3 (2,4,3) 212 26 10 212 26 10 traces/trans.trace
3 (5,1,5) 231 7 0 231 7 0 traces/trans.trace
6 (5,1,5) 265189 21775 21743 265189 21775 21743 traces/long.trace
27
TEST_CSIM_RESULTS=27
可以看到,这一部分是满分,即实现了一个LRU缓存模拟器。
第二部分,目的是编写矩阵转置代码,并且使得缓存的缺失最小,测试使用的缓存是直接映射(dirrect-mapped)高速缓存,这里测试的矩阵大小为:
一些限制条件:
-s 5 -E 1 -b 5
。int
类型,不能为long
型等。直接映射高速缓存需要减少冲突不命中(Confilit Miss)的情况,才能使缓存性能最大化。
这一个部分将会使用Blocking(分块)的技术进行优化,有关Blocking的相关介绍请点击这个链接:MEM: BLOCKING
Part B限制的缓存有一些特性,每一个缓存块为32个字节,等同于8个int型变量,整个缓存有32组,因此我们可以考虑将矩阵分块,块的大小为8×8
void trans_helper32(int M, int N, int A[N][M], int B[M][N])
{
int i, j;
int tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
for (j = 0; j < 32; j += 8) {
for (i = 0; i < 32; i++) {
tmp0 = A[i][j];
tmp1 = A[i][j+1];
tmp2 = A[i][j+2];
tmp3 = A[i][j+3];
tmp4 = A[i][j+4];
tmp5 = A[i][j+5];
tmp6 = A[i][j+6];
tmp7 = A[i][j+7];
B[j][i] = tmp0;
B[j+1][i] = tmp1;
B[j+2][i] = tmp2;
B[j+3][i] = tmp3;
B[j+4][i] = tmp4;
B[j+5][i] = tmp5;
B[j+6][i] = tmp6;
B[j+7][i] = tmp7;
}
}
}
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
if (M == 32) trans_helper32(M, N, A, B);
}
循环内部,对数组A的访问具有很好的时间和空间局部性,第一次访问 A[i][j]
,都会miss
一次,之后访问 A[i][j+1]
、A[i][j+1]
、…… 、A[i][J+7]
都会缓存命中Cache Hit,而对于第一次访问的B[j+1][i]
、…… 、B[j+7][i]
,都会 miss
一次,此后的访问7次访问每一次最多会出现一次Cache Miss,所以这样的访问模式(Access Pattern)对数组B来说具有比较好的空间局部性。
事实上,选择块长度为8还有一个原因,是因为这种情况32×32矩阵和缓存之间的特殊性,对于矩阵B而言,访问B[j][i]
、……B[j+7][i]
这八个位置的元素,它们是以步长为32个int长度访问的并且这些元素放在缓存上的位置是不可能出现冲突的,它们间隔为3个缓存块。
虽然对数组B的访问以8个int为步长,不具有空间局部性,但是只要我们重复使用对数组B的一个块(Chunk),那么之后对这个块的访问,出现Cache Miss的概率就会大大降低,这样的以步长为8个int的访问模式所造成的空间局部性差的情况就会得到解决,这得益于对矩阵的分块访问。
测试的结果为:
$ ./test-trans -M -N 32
Function 0 (6 total)
Step 1: Validating and generating memory traces
Step 2: Evaluating performance (s=5, E=1, b=5)
func 0 (Transpose submission): hits:1766, misses:287, evictions:255
Summary for official submission (func 0): correctness=1 misses=287
TEST_TRANS_RESULTS=1:287
实际测试结果缓存缺失287次,小于要求的300次,所以这个情况是满分。
对于64×64的矩阵转置,块的长度就不能设置为8了,如果块长度为8,那么访问数组B就会发生很多冲突不命中(Conflit Miss),这是不可避免的,因为对于64×64矩阵,数组B的访问B[j][i]
、…… 、B[j+7][i]
是以步长为64个int,相邻两个元素在缓存中的位置间隔为7,也就是说每8个Cache块,就放置了1个以地址B[k][i]
开始的8个int数据,其中k=j, j+1, ...j+7
,也就是说,这个配置的缓存最多同时放4行数组B的元素。如果每次访问8行数据,那么每一次都会冲突不命中,造成的现象称为抖动(thrashing),缓存出现抖动现象,缺失率会很高,缓存效率变得很低。
因此,我们就不可以使用8为块长度,可以考虑以4为块长度。
void trans_helper4(int M, int N, int A[N][M], int B[M][N], int size)
{
int i, j;
int tmp0, tmp1, tmp2, tmp3;
for (j = 0; j < size; j += 4) {
for (i = 0; i < size; i++) {
tmp0 = A[i][j];
tmp1 = A[i][j+1];
tmp2 = A[i][j+2];
tmp3 = A[i][j+3];
B[j][i] = tmp0;
B[j+1][i] = tmp1;
B[j+2][i] = tmp2;
B[j+3][i] = tmp3;
}
}
}
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
if (M == 32) trans_helper32(M, N, A, B);
if (M == 64) trans_helper4(M, N, A, B, 64);
}
测试结果为:
$ ./test-trans -M -N 32
Function 0 (6 total)
Step 1: Validating and generating memory traces
Step 2: Evaluating performance (s=5, E=1, b=5)
func 0 (Transpose submission): hits:6546, misses:1651, evictions:1619
Summary for official submission (func 0): correctness=1 misses=1651
TEST_TRANS_RESULTS=1:1651
这一种情况满分需要在缺失值1300以下,但是超出了351,说明还有需要改进的地方。
对于大小不是2的幂次的矩阵,可以先对它的子矩阵60×60的矩阵使用以块长度为4进行操作,剩下的部分简单做一下替换就可以。
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
int i, j;
if (M == 32) trans_helper32(M, N, A, B);
if (M == 64) trans_helper4(M, N, A, B, 64);
if (M == 61) {
trans_helper4(M, N, A, B, 60);
for (j = 0; j < 60; j++) {
for (i = 60; i < 67; i++) {
tmp = A[i][j];
B[j][i] = tmp;
}
}
for (j = 0; j < 67; j++) {
tmp = A[j][60];
B[60][j] = tmp;
}
}
}
测试结果如下:
$ ./test-trans -M 61 -N 67
Function 0 (6 total)
Step 1: Validating and generating memory traces
Step 2: Evaluating performance (s=5, E=1, b=5)
func 0 (Transpose submission): hits:6025, misses:2154, evictions:2122
Summary for official submission (func 0): correctness=1 misses=2154
TEST_TRANS_RESULTS=1:2154
这一关的满分需要缓存缺失数量少于2000,超出了154,结果还可以,相对于64×64矩阵转置,这个效果其实要更好,缺失率会更低,实际上,对于64×64矩阵转置,还可以使用块长度为8的矩阵转置操作,剩下的再使用块长为5、8的矩阵转置操作,这样的效果会更好:
void trans_helper(int M, int N, int A[N][M], int B[M][N])
{
int i, j;
int tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
/* Blocking length: 8*/
for (j = 0; j < 8 * (M / 8); j+=8) {
for (i = 0; i < 8 * (N / 8); i++) {
tmp0 = A[i][j];
tmp1 = A[i][j+1];
tmp2 = A[i][j+2];
tmp3 = A[i][j+3];
tmp4 = A[i][j+4];
tmp5 = A[i][j+5];
tmp6 = A[i][j+6];
tmp7 = A[i][j+7];
B[j][i] = tmp0;
B[j+1][i] = tmp1;
B[j+2][i] = tmp2;
B[j+3][i] = tmp3;
B[j+4][i] = tmp4;
B[j+5][i] = tmp5;
B[j+6][i] = tmp6;
B[j+7][i] = tmp7;
}
}
if (M == 61) {
j = 56;
/* Blocking length: 5*/
for (i = 0; i < 67; i++) {
tmp0 = A[i][j];
tmp1 = A[i][j+1];
tmp2 = A[i][j+2];
tmp3 = A[i][j+3];
tmp4 = A[i][j+4];
B[j][i] = tmp0;
B[j+1][i] = tmp1;
B[j+2][i] = tmp2;
B[j+3][i] = tmp3;
B[j+4][i] = tmp4;
}
/* Blocking length: 8*/
for (j = 0; j < 56; j += 8) {
for (i = 64; i < 67; i++) {
tmp0 = A[i][j];
tmp1 = A[i][j+1];
tmp2 = A[i][j+2];
tmp3 = A[i][j+3];
tmp4 = A[i][j+4];
tmp5 = A[i][j+5];
tmp6 = A[i][j+6];
tmp7 = A[i][j+7];
B[j][i] = tmp0;
B[j+1][i] = tmp1;
B[j+2][i] = tmp2;
B[j+3][i] = tmp3;
B[j+4][i] = tmp4;
B[j+5][i] = tmp5;
B[j+6][i] = tmp6;
B[j+7][i] = tmp7;
}
}
}
}
测试结果为:
$ ./test-trans -M 61 -N 67
Function 0 (6 total)
Step 1: Validating and generating memory traces
Step 2: Evaluating performance (s=5, E=1, b=5)
func 0 (Transpose submission): hits:6391, misses:1788, evictions:1756
Summary for official submission (func 0): correctness=1 misses=1788
TEST_TRANS_RESULTS=1:1788
Cache Miss数量为:1788,是满分。
61×67矩阵转置可以使用块长为8进行转置操作,而64×64矩阵转置则不好,因为前面提到的抖动现象,但是在这里,矩阵B的访问模式是以步长为61个int而不是64个int,它跟缓存块的大小不成正比,因此,地址B[j][i]
、…… 、B[j+7][i]
对应的8个int,都能同时出现在Cache中,而不会造成冲突不命中(Confilt Miss),这正是我们想要的情况,避免冲突不命中。
有了上面的理论,实际操作就是先对子矩阵56×64以块长为8进行转置,再将另一子矩阵5×67以块长为5进行转置,最后再将子矩阵3×56以块长为8进行转置。
最终没有拿到满分,问题出现在64×64矩阵转置上面,使用了块长为4的分块矩阵转置方法,主要的目的是为了减少数组B访问的冲突不命中,但是以访问模式访问有一定的局限性,可能没有充分利用Cache,这里我也没有想明白,我尝试做一些优化,过程复杂,最后结果却差不多。
$ ./driver.py
Part A: Testing cache simulator
Running ./test-csim
Your simulator Reference simulator
Points (s,E,b) Hits Misses Evicts Hits Misses Evicts
3 (1,1,1) 9 8 6 9 8 6 traces/yi2.trace
3 (4,2,4) 4 5 2 4 5 2 traces/yi.trace
3 (2,1,4) 2 3 1 2 3 1 traces/dave.trace
3 (2,1,3) 167 71 67 167 71 67 traces/trans.trace
3 (2,2,3) 201 37 29 201 37 29 traces/trans.trace
3 (2,4,3) 212 26 10 212 26 10 traces/trans.trace
3 (5,1,5) 231 7 0 231 7 0 traces/trans.trace
6 (5,1,5) 265189 21775 21743 265189 21775 21743 traces/long.trace
27
Part B: Testing transpose function
Running ./test-trans -M 32 -N 32
Running ./test-trans -M 64 -N 64
Running ./test-trans -M 61 -N 67
Cache Lab summary:
Points Max pts Misses
Csim correctness 27.0 27
Trans perf 32x32 8.0 8 287
Trans perf 64x64 4.0 8 1651
Trans perf 61x67 10.0 10 1788
Total points 49.0 53
这个实验可以让我们对Cache有更加深刻的认识,它的访问方式、缓存缺失的行为、缓存冲突缺失的行为等等。
实际上,对于Part A,LRUCache的实现,不应该只用双向链表来实现,还需要添加一个辅助的数据结构,哈希表,用哈希表将链表的节点(缓存块)链接起来,这样在就不需要findNode
寻找节点了,这样就将时间复杂度降低为O(1),性能提升很多,但这里不能使用C++的库,所以就只能这样了。
对于Part B,我们利用缓存,编写局部性好的程序,并且根据特定的Cache进行不同程度的优化,这里需要考虑直接映射冲突不命中情况,如果不考虑,就可能会出现抖动(thrashing)现象,Cache会不断地Confilt Miss。
总之,这个实验还是有一点难度的,但是还是能学到比较多的东西,对缓存认识更加深刻,学到了如何利用分块的技术优化矩阵转置函数。