Lab5 Cache Lab

Lab5 Cache Lab

写在前言:这个实验的来源是CSAPP官网:CSAPP Labs ,如果感兴趣的话,可以点击这个链接去下载,这个实验分为两个部分,第一个部分是仿照给出的缓存模拟器,编写一个与参考版本具有相同行为高速缓存模拟器,其替换策略为LRU(Least Recently Use)替换策略;第二部分是使用优化一个矩阵转置函数,使得Cache miss尽可能少。

Part A

实验第一部分要求编写一个高速缓存模拟器,修改 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;

其中 Nodevalid 代表的是该行是否有效,在实际实现当中,没有用到这个,因为初始状态,所有的缓存都是 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函数,将这个缓存行放到链表的最后一个节点上,表示它最近被使用过了。
  • 思路就是:越靠近链表头部,就是比较久没有访问过的缓存行,因此当缓存Miss的时候,把它替换即可。

接下来是主函数的实现:

  • 创建一个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

    • 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缓存模拟器。

Part B

第二部分,目的是编写矩阵转置代码,并且使得缓存的缺失最小,测试使用的缓存是直接映射(dirrect-mapped)高速缓存,这里测试的矩阵大小为:

  • 32×32(M=32,N=32)
  • 64×64(M=64,N=64)
  • 61×67(M=61,N=67)

一些限制条件:

  • 使用的缓存参数为:-s 5 -E 1 -b 5
  • 不能使用数组类型
  • 只能使用不超过12个的局部变量
  • 局部变量只能int类型,不能为long型等。
  • 不能使用递归函数

直接映射高速缓存需要减少冲突不命中(Confilit Miss)的情况,才能使缓存性能最大化。

这一个部分将会使用Blocking(分块)的技术进行优化,有关Blocking的相关介绍请点击这个链接:MEM: BLOCKING

32×32矩阵转置

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矩阵转置

对于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,说明还有需要改进的地方。

61×67矩阵转置

对于大小不是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。

总之,这个实验还是有一点难度的,但是还是能学到比较多的东西,对缓存认识更加深刻,学到了如何利用分块的技术优化矩阵转置函数。

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