一个本硕双非的小菜鸡,备战24年秋招。刚刚看完CSAPP,真是一本神书啊!遂尝试将它的Lab实现,并记录期间心酸历程。
代码下载
官方网站:CSAPP官方网站
以下是官方文档翻译:
这个实验室将帮助您了解缓存存储器对C程序性能的影响。
这个实验室由两部分组成。在第一部分中,您将编写一个小的C程序(大约200-300行)来模拟缓存内存的行为。在第二部分中,您将优化一个小的矩阵变换函数,目的是最小化缓存丢失的数量。
您将修改两个文件: csim.c和trans.c若要编译这些文件,请键入:
linux> make clean
linux> make
警告:不要让Windows WinZip程序打开你的程序。tar文件(许多Web浏览器被设置为自动执行此操作)。相反,请将文件保存到Linux目录中,并使用Linux tar程序来提取文件。一般来说,对于这个类,您不应该使用除Linux以外的任何平台来修改您的文件。这样做可能会导致数据丢失(以及重要的工作!)。
这个实验室有两个部分。在第A部分中,您将实现一个高速缓存模拟器。在B部分中,您将编写一个针对缓存性能进行优化的矩阵转置函数。
虚拟内存跟踪具有以下形式:
I 0400d7d4,8
M 0421c7f0,4
L 04f6b868,8
S 7ff0005c8,8
每一行表示一次或两个内存访问。每一行的格式为
操作字段类型,操作地址,字节大小
操作字段表示内存访问的类型:“I”表示指令加载,“L”表示数据加载,“S”表示数据存储,“M”表示数据修改(i。e., 数据加载后是数据存储)。在每个“I”之前从来没有一个空格。在M、L和S前面总是有一个空格。地址字段指定一个64位的十六进制内存地址。size字段指定该操作所访问的字节数。
官方给了一个命令可以尝试一下:
linux> valgrind --log-fd=1 --tool=lackey -v --trace-mem=yes ls -l
会输出一些类似的
可以解释一下,按照文档中介绍的那样:字母代表着不同的操作,后面是待操作地址,再后面是操作的字节数。
在A部分中,您将在csim.c中编写一个缓存模拟器,它以valgrind内存跟踪作为输入,模拟缓存内存的命中/未命中行为,并输出命中、未命中和删除的总数。
我们为您提供了一个引用缓存模拟器的二进制可执行文件,称为csim-ref,它模拟了在valgrind跟踪文件上具有任意大小和关联性的缓存的行为。在选择要驱逐的缓存行时,它使用LRU(最近最少的)替换策略。
参考模拟器采用以下命令行参数:
Usage: ./csim-ref [-hv] -s <s> -E <E> -b <b> -t <tracefile>
•-h:可选的帮助标志,打印使用信息
-v:可选的详细标志显示跟踪信息
-s<s>:设置索引位的数量(S = 2^s是集合的数量)
-E<E>:关联性(每组行数)
-b<b>:块位数(B = 2^b块大小)
-t<跟踪文件>:回溯的参数valgrind的名称
你在A部分的工作是填写csim.c文件,以便它接受相同的命令行参数,并生成与参考模拟器相同的输出。请注意,这个文件几乎完全为空。
你需要从头开始写它。
编程规则
自我分析:麻烦参考426页(中文版)高速缓存的写法.
高速缓存结果可以用元组(S,E,B,m)来描述,其中 S=2^ s为组数,E为每个组的行数,B=2^b为块大小(字节),m=log2(M)为(主存)物理地址位数。
我们为您提供了一个自动分级程序,称为测试-csim,它可以在引用跟踪上测试缓存模拟器的正确性。在运行测试之前,一定要编译你的模拟器:
linux> make
linux> ./test-csim
对于每个测试,它都显示您获得的点数、缓存参数、输入跟踪文件以及来自模拟器和参考模拟器的结果的比较。
以下是关于在A部分工作的一些提示和建议:
#include
#include
#include
因为此处要用到getopt函数,getopt函数可以用来分析命令行参数,形式为:
int getopt(int argc,char * const argv[ ],const char * optstring);
具体可详见这位大佬的详解:
Linux下getopt()函数的简单使用
题中提到了LRU算法,这是一种缓存淘汰策略,具体详见LRU算法之我见。
主要流程是:
首先仿照上面那张图来构造一个高速缓冲行结构,为结构体,其中有三个参数:有效位、标记位和高速缓冲块,其中高速缓冲块是以LRU替换策略使用的。
typedef struct {
int vaild;
int tag;
int time; //此处应该是高速缓冲块,但是该题没有要求存储,且要求加入LRU替换算法,所以包含一个time表示访问时间间隔
} CacheLine, *CacheSet, **Cache;
定义getopt()函数和输出所需要的参数,根据题目提示,只需要s、E、b和t就可以:
int s, E, b, t, S; //参考模拟器参数
int hit_count, miss_count, eviction_count; //命中、漏掉和驱逐
然后根据getopt()函数的参考仿写:
int main(int argc, char* argv[]) //由于使用了getopt函数,需要传入
{
/*仿写getopt函数的使用*/
int ch;
/*getopt()函数还有搜索值,不返回-1*/
while ((ch = getopt(argc, argv, "s:E:b:t:")) != -1) {
switch (ch) {
case 's':
s = atoi(optarg);
S = (int)pow(2, s); //根据定义,S = 2^s
break;
case 'E':
E = atoi(optarg);
break;
case 'b':
b = atoi(optarg);
break;
case 't':
t = atoi(optarg);
strcpy(filePath, optarg);
break;
default:
printf("ERROR!!!");
break;
}
}
mallocCache();
readTraceFile();
freeCache();
printSummary(hit_count, miss_count, eviction_count);
return 0;
}
其次根据题目中的要求(提示),我们需要使用malloc函数为模拟器的数据结构分配存储空间。也就是根据上一步中读取到的s, E, b使用malloc;函数来在堆上分配空间。
先定义:
Cache cache; //开辟空间
再写开辟缓存空间
/*动态分配缓存空间*/
void mallocCache() {
if (s < 0) {
printf("Not s!!!");
exit(0);
}
cache = (Cache)malloc(S * sizeof(CacheSet));
assert(cache);
for (int i = 0; i < S; i++) {
cache[i] = (CacheSet)malloc(E * sizeof(CacheLine));
assert(cache[i]);
memset(cache[i], 0, sizeof(CacheLine) * E); //初始化值,全置零
}
}
最后是释放空间
/*释放空间*/
void freeCache() {
for (int i = 0; i < S; ++i) {
free(cache[i]);
}
free(cache);
}
首先定义文件指针
char filePath[100]; //文件指针
再写读取文件的函数
/*读取给的trace文件*/
void readTraceFile() {
FILE* file = fopen(filePath, "r");
assert(file);
/*检测文件是否存在*/
if (file == NULL) {
printf("NO File!!!");
exit(0);
}
char type;//虚拟内存的类型
uint64_t address;//虚拟内存访问地址
int size; //虚拟操作访问的字节数
/*只要还有值,就统统放入缓存中*/
/*注意%c前面有个空格,因为只有I没有空格,但是I表示指令加载没啥用*/
while (fscanf(file, " %c %lx,%d", &type, &address, &size) > 0) {
/*按官方的解释,分为M、L、S分别讨论*/
switch (type) {
case 'M' :
cacheOperation(address); //M是数据修改
case 'L' : //L是数据加载,没啥用
case 'S' :
cacheOperation(address); //S是数据存储
break;
}
lruUpdate();
}
fclose(file);
}
因为我们在其中使用了LRU算法,所以得更新下时间。
/*更新访问时间*/
void lruUpdate() {
for (int i = 0; i < S; ++i) {
for (int j = 0; j < E; ++j) {
if (cache[i][j].vaild) {
cache[i][j].time++;
}
}
}
}
终于到了代码的最后部分,我们之前所写的代码都是为了这部分服务的,获取命中、漏掉和驱逐的值
代码:
/*模拟cache行为*/
void cacheOperation(uint64_t address) {
uint64_t setIndex = ((1ULL << 63) - 1) >> (63 - s); //组索引位(这句我也解释的不好,就知道是跟计算机位数和最高位数有关)
int tagIndex = address >> (b + s); //标记位(计算方法:物理地址address-(b+s))
CacheSet cacheSet = cache[(address >> b) & setIndex];
for (int i = 0; i < E; ++i) {
/*看看匹没匹配,有效位是否存在和标记位是否相同*/
if (cacheSet[i].vaild && cacheSet[i].tag == tagIndex) {
hit_count++; //命中
cacheSet[i].time = 0;
return;
}
}
miss_count++; //否则就是没命中
/*取出块,并把这个块存储到组中,并返回*/
/*存在空位,写入*/
for (int i = 0; i < E; ++i) {
if (!cacheSet[i].vaild) {
cacheSet[i].vaild = 1; //有效位设置为1
cacheSet[i].tag = tagIndex;
cacheSet[i].time = 0;
return;
}
}
/*没有空位,使用LRU算法进行替换*/
eviction_count++;
int evictIndex = 0;
int maxTime = 0;
for (int i = 0; i < E; ++i) {
if (cacheSet[i].time > maxTime) {
maxTime = cacheSet[i].time;
evictIndex = i;
}
}
cacheSet[evictIndex].tag = tagIndex;
cacheSet[evictIndex].time = 0;
}
注意,这些函数应重新排列顺序,我的顺序是:mallocCache、freeCache、lruUpdate、cacheOperation、readTraceFile和最后的主函数main,不然会报下面的错误。
先执行make,再./test-csim,成功实现!
到这里我们的A部分才算告一段落。ps:我的代码第一次编译通过了但是执行的答案不对,回头一查发现少了个冒号,当误我一个小时(终端下断点还是不太熟。。。)
在B部分中,您将在trans.c中编写一个转置函数,从而导致尽可能少的缓存丢失。
设A表示一个矩阵,Aij表示第i行和第j列的分量。A的转置,表示AT,是一个矩阵,Aij=Aji。
为了帮助你开始,我们给了你一个trans.c中的转置函数的例子,它计算N×M矩阵a的转置,并将结果存储在M×N矩阵B中:
char trans_desc[] = "Simple row-wise scan transpose";
void trans(int M, int N, int A[N][M], int B[M][N])
这个例子的转置函数是正确的,但效率低,因为访问模式导致相对较多的缓存丢失。
您在B部分中的工作是编写一个类似的函数,称为transpose_submit,这将最小化在不同大小的矩阵中的缓存丢失的数量:
char transpose_submit_desc[] = "Transpose submission";
void transpose_submit(int M, int N, int A[N][M], int B[M][N]);
不要更改您的transpose_submit的描述字符串(“Transpose submission”)。自动评分器搜索这个字符串,以确定使用哪个转置函数来评估信用。
编程规则
自我分析:判分中分为了三种情况3232,6464,61*67三个不同大小的输出矩阵上的正确性和性能.
我们为您提供了一个自动分级程序,称为test-trans。c,它可以测试您在自动分级器上注册的每个转置函数的正确性和性能。
您可以在trans.c文件中注册多达100个版本的转置函数。每个转置版本都有以下形式:
/* Header comment */
char trans_simple_desc[] = "A simple transpose";
void trans_simple(int M, int N, int A[N][M], int B[M][N])
{
/* your transpose code here */
}
通过调用该表单,向自动分级器注册一个特定的转置函数:
registerTransFunction(trans_simple, trans_simple_desc);
在registerFunctions中,功能程序在 trans.c。在运行时,自动评分器将评估每个已注册的转置函数并打印结果。当然,其中一个注册函数必须是transpose_submit函数:
registerTransFunction(transpose_submit, transpose_submit_desc);
请参见默认的trans.c函数,以了解它是如何工作的示例。
自动分级器以矩阵大小作为输入。它使用valgrind来生成每个注册的转置函数的跟踪。然后,它通过在具有参数(s = 5,E = 1,b = 5)的缓存上运行参考模拟器来计算每个跟踪。
例如,要在32×32矩阵上测试您注册的转置函数,重新构建测试-转换,然后使用M和N的适当值运行它:
linux> make
linux> ./test-trans -M 32 -N 32
Step 1: Evaluating registered transpose funcs for correctness:
func 0 (Transpose submission): correctness: 1
func 1 (Simple row-wise scan transpose): correctness: 1
func 2 (column-wise scan transpose): correctness: 1
func 3 (using a zig-zag access pattern): correctness: 1
Step 2: Generating memory traces for registered transpose funcs.
Step 3: Evaluating performance of registered transpose funcs (s=5, E=1, b=5)
func 0 (Transpose submission): hits:1766, misses:287, evictions:255
func 1 (Simple row-wise scan transpose): hits:870, misses:1183, evictions:1151
func 2 (column-wise scan transpose): hits:870, misses:1183, evictions:1151
func 3 (using a zig-zag access pattern): hits:1076, misses:977, evictions:945
Summary for official submission (func 0): correctness=1 misses=287
在这个例子中,我们在trans.c中注册了四种不同的转置函数。test-trans测试每个注册的功能,显示每个功能的结果,并提取结果以供正式提交。以下是一些关于在B部分工作的提示和建议。
linux> ./csim-ref -v -s 5 -E 1 -b 5 -t trace.f0
S 68312c,1 miss
L 683140,8 miss
L 683124,4 hit
L 683120,4 hit
L 603124,4 miss eviction
S 6431a0,4 miss
...
http://csapp.cs.cmu.edu/public/waside/waside-blocking.pdf
跟A部分一样,首先分析题目,提示给出了三点建议:自动分级器进行优化、对角线冲突问题和阻塞技术。
我们先来分析一下为什么会这么慢,首先看下初始转置操作函数的代码(也就是它起始代码)
void trans(int M, int N, int A[N][M], int B[M][N])
{
int i, j, tmp;
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
tmp = A[i][j];
B[j][i] = tmp;
}
}
}
我们可以大致分析出来为什么会这么高:其原因就是数组A与数组B的访问方式正好相反,当先按行优先顺序访问,因为题中给出的参数是(s = 5,E = 1,b = 5),则S=2^s=32、E=1、B=2 ^b=32,又因为int为4字节,所以一个高速缓冲块最多存8个int型,一共有32组。
继续分析:理论上A运行的时候并不会发生较多的miss,但是当你执行第二句的时候为列遍历B的一个元素,前8次还好说不中就不中可以存,但是当你访问到第9列的时候,此时cache已经存储了8(列)*4(int型大小)*32(每一列的行元素),意味着已经存满了,然后你为了存第9列的元素,就只能发生cache冲突,映射到同一缓冲组中,将第1列的行元素顶掉,等你访问1行2列的元素时又要重新加载。。。循环浪费。
毛用没有。
我们可以采用刚才介绍的分块思想,将每一个小块设为8的倍数,然后将这8个元素都进行操作再替换,这样可以极大的节省。
根据提示还有一个问题:对角线
因为对角线其实是根本不动的,属于原地tp,但是仍然会引发冲突(因为重复),所以也要进行单独处理。
我们可以引入局部变量,因为局部变量存储在寄存器中,不涉及内存访问。
transpose_submit函数整体代码如下:
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
int a, b, c, d, e, f, g, h;
for (int i = 0; i < N; i += 8) {
for (int j = 0; j < M; j += 8) {
for (int ii = i; ii < i + 8; ++ii) {
a = A[ii][j];
b = A[ii][j + 1];
c = A[ii][j + 2];
d = A[ii][j + 3];
e = A[ii][j + 4];
f = A[ii][j + 5];
g = A[ii][j + 6];
h = A[ii][j + 7];
B[j][ii] = a;
B[j + 1][ii] = b;
B[j + 2][ii] = c;
B[j + 3][ii] = d;
B[j + 4][ii] = e;
B[j + 5][ii] = f;
B[j + 6][ii] = g;
B[j + 7][ii] = h;
}
}
}
}
终端输入
linux> make
linux> ./test-trans -M 32 -N 32
得出答案,misses287,小于300,成功!
在网上看到了一位大佬的继续优化,我们都知道是因为B矩阵在列优先访问才导致了这个最大的问题,且题目中虽然规定不可以修改A矩阵的二维数组,但可以修改B矩阵的。那么我们可以在使用局部变量的方法的同时对B也进行行优先访问,之后再在B内部转置。(我其实也想到了这个招,就是懒了)
贴下大佬的题解,大佬写的巨详细,我这里看不懂的地方可以参考大佬的解释
代码:
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
const int len = 8;
int a, b, c, d, e, f, g, h, k, s;
for (int i = 0; i < N; i += len) {
for (int j = 0; j < N; j += len) {
// copy
for (k = i, s = j; k < i + len; k++, s++) {
a = A[k][j];
b = A[k][j + 1];
c = A[k][j + 2];
d = A[k][j + 3];
e = A[k][j + 4];
f = A[k][j + 5];
g = A[k][j + 6];
h = A[k][j + 7];
B[s][i] = a;
B[s][i + 1] = b;
B[s][i + 2] = c;
B[s][i + 3] = d;
B[s][i + 4] = e;
B[s][i + 5] = f;
B[s][i + 6] = g;
B[s][i + 7] = h;
}
// transpose
for (k = 0; k < len; k++) {
for (s = k + 1; s < len; s++) {
a = B[k + j][s + i];
B[k + j][s + i] = B[s + j][k + i];
B[s + j][k + i] = a;
}
}
}
}
}
一样的想法,但是由于是现在为64 * 64,一行元素是64个,以至于现在i行与i+4行就会发生冲突。如果还使用8 * 8矩阵分块,就会在内部发生冲突。理论上可以使用4 * 4矩阵分块,即
int a, b, c, d;
for (int i = 0; i < N; i += 4) {
for (int j = 0; j < M; j += 4) {
for (int ii = i; ii < i + 4; ++ii) {
a = A[ii][j];
b = A[ii][j + 1];
c = A[ii][j + 2];
d = A[ii][j + 3];
B[j][ii] = a;
B[j + 1][ii] = b;
B[j + 2][ii] = c;
B[j + 3][ii] = d;
}
}
}
我们发现效果其实并不理想
分析可知,一个cache块一组能存8个int,这么搞只能存一半。
我们可以想到,可以先进行8 * 8分块,然后再在这里进行4 * 4分块
逻辑图解:
比如下图这个8 * 8矩阵
我们可以进行如下变换,首先进行上四行变换,即
然后再对A进行逐列的进行后4行前四列的转置,即:
重复这个操作,最后变成这个样子,即:
最后进行后面的处理,就不贴了直接转就行。
代码:
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
int a, b, c, d, e, f, g, h, i, j, k, l;
for (i = 0; i < N; i += 8)
{
for (j = 0; j < M; j += 8) {
for (k = i; k < i + 4; k++) {
a = A[k][j];
b = A[k][j + 1];
c = A[k][j + 2];
d = A[k][j + 3];
e = A[k][j + 4];
f = A[k][j + 5];
g = A[k][j + 6];
h = A[k][j + 7];
B[j][k] = a;
B[j + 1][k] = b;
B[j + 2][k] = c;
B[j + 3][k] = d;
B[j][k + 4] = e;
B[j + 1][k + 4] = f;
B[j + 2][k + 4] = g;
B[j + 3][k + 4] = h;
}
for (l = j; l < j + 4; l++) {
a = A[i + 4][l];
b = A[i + 5][l];
c = A[i + 6][l];
d = A[i + 7][l];
e = B[l][i + 4];
f = B[l][i + 5];
g = B[l][i + 6];
h = B[l][i + 7];
B[l][i + 4] = a;
B[l][i + 5] = b;
B[l][i + 6] = c;
B[l][i + 7] = d;
B[l + 4][i] = e;
B[l + 4][i + 1] = f;
B[l + 4][i + 2] = g;
B[l + 4][i + 3] = h;
}
for (k = i + 4; k < i + 8; k++) {
a = A[k][j + 4];
b = A[k][j + 5];
c = A[k][j + 6];
d = A[k][j + 7];
B[j + 4][k] = a;
B[j + 5][k] = b;
B[j + 6][k] = c;
B[j + 7][k] = d;
}
}
}
}
这题说难也难,说简单也是真的简单(因为可以试出来),比较玄学。。。
因为没办法对齐处理,基本只能靠猜和一个一个试。
最后发现17 * 17的时候数据最小,为1950。
此处引用一位博主的尝试过程,这位博主我也不知道是谁,但是菜鸡在此感谢这位大佬!
ps:你们是逃学威龙,是正义使者,是这帮实验室的终极克星,照亮了像我这样的萌新前进的道路!
代码:
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
int i, j, h, k;
for (i = 0; i < N; i += 17)
{
for (j = 0; j < M; j += 17)
{
for (h = i; h < i + 17 && h < N; h++)
{
for (k = j; k < M && k < j + 17; k++)
{
B[k][h] = A[h][k];
}
}
}
}
}
这个Lab真的做的爽!(虽然被虐的过程很惨)。让我对cache的过程更加清楚与明白,之前看书的时候还不屑一顾就这,一看就懂,结果一做就废,翻来覆去的去扣每一个知识点的定义。B部分的优化更加具体且贴近实际情况,有的时候可能只是小小的改变就能让整个代码运行的的更加流畅。真的是很爽的一个Lab。