Arthals' ink

Back

CAUTION

致各位同学:本笔记的撰写目的是用作参考,请勿直接抄袭,否则后果自负。

我的推荐做法是,你可以阅读我的博文后了解都有哪些坑,但自己实现的时候千万不要看我的代码,避免抄袭风险。

PartA#

需要编写一个 csim.c 程序,来模拟缓存机制。

测试指令#

make && ./test-csim
bash

出现 TEST_CSIM_RESULTS=27 字样即代表成功。

编写细节#

感觉没什么好说的,主要是从汇编回到 C 有点陌生了,注意参数读、文件读和内存分配管理的方法就行。

具体的文件读写,系统 I/O 的知识在第十章才会讲到,这里涉及到的 FILE* 流的知识则是在 10.10 节,所以如果你现在不会的话你可以选择提前预习第十章(挺短的),或者只是先简单了解一下所需函数的使用方法。

文件读写#

#include <stdlib.h>

FILE* trace_file; // 定义文件指针
trace_file = fopen(optarg, "r"); // 打开文件
fscanf(trace_file, "%s %lx,%d\n", &operation, &address, &size) == 3; // 读取文件,返回值为成功读取的参数个数
c

FILE *:文件指针,指向文件的指针,用于读写文件。

fopen(const char *path, const char *mode):打开文件,返回文件指针。用 r 模式打开文件,表示只读。

fscanf(FILE *stream, const char *format, ...):从文件流中读取格式化输入。

  • %s 表示字符串
  • %lx 表示 16 进制数
  • %d 表示十进制数。返回值为成功读取的参数个数,所以这里指定为 3 个。

参数读取#

getopt(int argc, char * const argv[], const char *optstring):解析命令行参数。

  • argc 表示参数个数
  • argv 表示参数列表
  • optstring 表示选项字符串,选项字符串中的字母表示选项,冒号表示选项后面需要参数(必填)。返回值为当前选项字母,如果没有选项了则返回 -1。

在本例中,选项字符串为 hvs:E:b:t:,表示有 5 个选项,其中 sEbt 后面需要参数。hv 后面不需要参数。

关于 optarg,可以理解为是用来保存选项的参数的,而且虽然你没有定义它,但是因为你引入了 getopt.h 头文件,所以它是一个外部变量,你可以直接使用它。

本次测评不要求 hv 选项,所以你也可以用 s:E:b:t: 作为选项字符串(当然后面的逻辑也要对应修改)。

atoi(const char *nptr):将字符串转换为整数。

内存分配管理#

CSAPP_3rd_P661_Figure_6.32

注:附图来自英文原版 CSAPP 3rd P661 Figure 6.32

注意结构体使用 sizeof 时,要加上 struct 关键字。

cache 是一个 set 数组,每个 set 有 E 个 line,每个 line 有 3 个参数,分别是 valid、tag 和 last_used_time。

因而 cache 的类型是 set*,即指向 set 的指针,而 set 的类型是 line*,即指向 line 的指针。

malloccalloc 都是动态分配内存的函数,malloc 只分配内存,calloc 分配内存并初始化为 0。malloc 的参数为分配的字节数,calloc 的参数为分配的个数和每个元素的字节数。

别忘了最后释放内存:

free(cache);
c

踩坑#

Modify 跳转技巧#

Modify 修改操作 = Load 加载操作 + Store 存储操作,所以在 M 操作时,需要访问两次缓存。

你编写的程序不用支持 -v 参数,所以你可以使用如下的跳转表:

switch (operation) {
case 'I':
    continue;
case 'M': // Modify = Load + Store
    useCache(address);
case 'L': // Load
case 'S': // Store
    useCache(address);
}
c

这种写法可以让 M 操作直接多执行一次 useCache 函数,而不用再写一遍。但是,如果你想追求效率(尽管这个 Part 并不要求)或者想要支持 -v 参数,那么你可以直接多给 useCache 传递一个 is_modify 参数,来判断是否为 M 操作。若是,则可以直接令第二次写为 HIT,而不用再次访问缓存。具体实现可以参考我的代码。

值得一提的是,在所有的测试样例中,只有 mem.trace 中存在 M 操作,而 handout 中给出的测试命令行均没有测试它,也就没有测试 M 操作的正确性。你必须使用 test-csim 来测试 M 操作的正确性。

地址是 64 位,而不是 32 位#

csim.c 中,地址是 64 位的,而不是 32 位的。所以你需要使用 %lx 来读取地址。同时你不能使用 int 类型来存储地址,而应该使用 unsigned long 或者 __uint64_t 类型或者 size_t 类型(执行的机器是 64 位的)。

另外注意对取地址时,一定要注意是否设置了对于高位(tag 位)的掩码,否则可能会出现段错误(数组越界了)。

int set_pos = address >> b & ((1 << s) - 1);
c

LRU(Least Recently Used)算法#

初始化一个 line 的时候,也许将 last_used_time 初始化为 -1 会更好(区别于初始的 timestamp = 0)。因为这样可以判断这个 line 是否被使用过(即判断是否为冷不命中,决定是否要给 eviction 加一)。

在每次执行 useCache 的时候,让 timestamp 加一,即可维护一个时间戳(而不用使用什么标准库的时间戳,那样会导致两个问题,一个是可能精度不够(每次执行 useCache 的时间间隔可能太短),另一个是可能还要处理浮点数问题)。

同时,在遍历一个组的时候,你可以合并遍历和查找最小时间戳的操作,这样可以减少一次遍历。

其他#

printSummary() 函数定义在 cachelab.h 中,所以你需要在 csim.c 中引入 cachelab.h 头文件。

putsprintf 函数都可以用来输出字符串,但是 puts 函数会自动在字符串后面加上换行符,而 printf 函数不会。

#include "cachelab.h"

printSummary(hit, miss, eviction);
c

如何理解地址的缓存#

我的理解是,对于原本标记 “在内存中的地址(一串 64 位十六进制数)”,通过拆分出 Tag/Set/Byte 这三段,映射到了缓存中的唯一地方(类似于一个哈希函数,大范围到小范围,但是保证了一定的局部性)。当然,映射时就会出现类似冲突 / 驱逐(类似哈希冲突,本质是小范围映射会损失,无法真的不冲突地放下所有的大范围地址)的问题。

成品代码#

运行:

make && ./test-csim
bash

得到:

                        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)     694     453     449     694     453     449  traces/mem.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   21777   21745  265189   21777   21745  traces/long.trace
    27

TEST_CSIM_RESULTS=27
text

大功告成!

PartB#

缓存参数:s = 5, E = 1, b = 5

所以这是一个有 32 个组(S=2s=32S = 2^s = 32)的直接映射高速缓存(E=1E = 1),每个组只有 1 个块,每个块有 32 个字节(B=2b=32B = 2^b = 32)。

也就是说,一共可以放得下 1024 个字节,即 256 个 int。

32x32#

测试指令:

make && ./test-trans -M 32 -N 32
bash

满分线:misses <= 300。

观察到示例转置函数的结果:hits:869, misses:1184, evictions:1152

显然这是极差的,因为 A 和 B 的大小一致,而且存储的地址偏差正好使得其对应位置的数据都在同一个组(行 / 块)中(初始地址映射的块相同)。

以下讨论何时会出现冲突。考虑默认程序,我们总是需要将 A[i][j] 读出后写到 B[j][i]

因而,我们需要考虑他们各自在上级缓存中的位置。你可以认为行优先读时,每 8 个连续的 int 总是映射到同一个 Set/Block(即 A[i][8k+0] ~ A[i][8k+7])。对 B 同理。

所以,我们发现,直接按元素写的时候,会出现大量的不命中。但是实际是不命中只有 1184 次,如果每个读写都造成两次不命中显然不止这么点,那么什么时候会命中?

考虑读 A[i][j] 时,我们总要写 B[j][i]。因而存储着两个数据的块的起始偏移量地址(按照 int 数计算)分别为:

  • A[i][j]:i × 32 + j
  • B[j][i]:j × 32 + i

由于每 8 个 int 偏移一个缓存 Set,所以在缓存中,两个块分别存储如下:

  • A[i][j]:存储在第 ⌊(i × 4 + ⌊j/8⌋)/32⌋ 个块,存储着 A[i][ ⌊j/8⌋ ] ~ A[i][ ⌊j/8⌋+7 ]
  • B[j][i]:存储在第 ⌊(j × 4 + ⌊i/8⌋)/32⌋ 个块,存储着 B[j][ ⌊i/8⌋ ] ~ B[j][ ⌊i/8⌋+7 ]

所以,当 i = j 时,两个块的 Set 会发生冲突,导致大量的不命中。而当 i != j 时,两个块的 Set 不会发生冲突。

  • 对于读 A 操作来说,我们往往具有很好的局部性,因为是行优先读,所以每 8 个数,除了第一次加载,后面的 7 次都会命中。
  • 对于写 B 操作来说,我们完全没有利用局部性。考虑我们写 B 的第 j 行,我们第一次写(B[j][0])和第二次写(B[j][1])之间至少包括了对 B 的一整列(列优先,31 行,B[j+1][0] ~ B[j-1][1])的全部写,而我们的缓存大小只有 256 个数的大小(即 8 行),当前行早就被刷掉了,所以我们写 B 完全没机会出现命中。而且在对角线处时,我们还会因为要写 B 而导致对于已经读入的 A 的行发生冲突不命中(即要发生一次驱逐)。

cache32x32

所以整体计算下来,应当发生 1024(写 B 全部不命中)+ 4(写 A 每行每 8 个数不命中一次)* 32(写 A 的行)+ 32(对角线处写 B 造成读 A 驱逐,需要重新读一次),即 1184 次不命中。

此处写这么细,是因为后续处理别的尺寸的时候也可以进行类似的推导。

调用 csim-ref 来查看 trace:

./csim-ref -v -s 5 -E 1 -b 5 -t trace.f1 > trace.f1.v
bash

果不其然,有大量的 miss:

于是,我们很自然的想到,通过限制写 B 的范围,来利用 B 的局部性。而这也就是书上讲过的分块技巧:将 32x32 的矩阵分成 8x8 的小块,这样就可以充分利用局部性,读 A 一次连续读入 8 个元素,然后转置,再连续写入 B。即避免了对于写 B 时,因为列优先顺序写造成的缓存驱逐。

同时我们使用多个局部变量来存储从 A 读出来的数据,这样可以最大化地利用局部性。

运行:

make && ./test-trans -M 32 -N 32
bash

得到:

func 0 (Transpose submission): hits:1765, misses:288, evictions:256
md

可以发现我们已经将 miss 降低到了 288,小于 300 的满分限,收获满分!

但是,这就是完美无缺的吗?显然不是,我们可以继续优化。

注意到理论最优 MISS 数应当是 16(块数) * [8(读A)+8(写B)] = 256 次,为什么会多出来 32 次呢?

这是因为当 i=j 时,A[i][i]B[i][i] 的组数是一样的,所以每次处理对角线上的块时,都会额外出现 8 次 MISS:

  1. 写 B 的第 i 列,导致 A 的第 i+1 行被驱逐
  2. 读 A 的第 i+1 行,导致 B 的第 i+1 行被驱逐
  3. 写 B 的第 i+1 列,冲突不命中

diag

有没有什么优化方法呢?当然是有的,我们可以先将 A 的一个块完整、不转置地复制到 B,然后再转置 B,这样对 B 的转置与写时,因为 B 已经完全加载到了缓存中,所以不会出现任何的不命中。

运行:

make && ./test-trans -M 32 -N 32
bash

得到:

Summary for official submission (func 0): correctness=1 misses=260
md

可以发现我们已经将 miss 降低到了 260,非常接近理论值 256 了,而经过测试,这 4 个多出来的 MISS 其实函数调用所致。

64x64#

测试指令:

make && ./test-trans -M 64 -N 64
bash

满分线:misses <= 1300。

首先,我们直接使用 32x32 中的代码:

if (M == 32 || M==64){...}
c

得到输出:

Summary for official submission (func 0): correctness=1 misses=3332
md

发现 miss 过多,这是为什么呢?

回忆一下我们的 cache 参数:s = 5, E = 1, b = 5,即 block 有 32 个字节(存 8 个 int) ,32 个组,每个组只有 1 个行。所以我们的缓存总容量是 256 个 int,而 64x64 的矩阵 4 行即可占用 256 个 int,所以我们 8x8 分块后,上半块和下半块的数据会发生冲突不命中,换句话说,就是读 A[i][0]A[i+4][0] 时会造成冲突不命中,写 B 同理。

如果直接修改代码改成 4x4 分块,我们的确就可以解决这个问题:

运行:

make && ./test-trans -M 64 -N 64
bash

得到:

Summary for official submission (func 0): correctness=1 misses=1604
md

收获了 4.5 分,但是仍然不够理想。

注意这里不能采用 4x8 分块,因为这样虽然可以避免 A 矩阵的读的冲突不命中,但是 B 矩阵的写的冲突不命中仍然存在。

不过这也启发了我们,既然可以通过 4x8 分块首先避免对于 A 矩阵的读的冲突不命中,那么 B 矩阵的写的冲突不命中是不是可以通过 “暂时性” 地将后 4 个元素先放到同一行的块里来避免呢?

cache64x64

于是我们得到了优化思路:既然直接 8x8 分块不行,那我们就先 8x8 分大块,再在每个大块内 4x4 地分小块,然后注意,读完 A 的第一行(8 个 int)后,我们将前 4 个正常转置并写入 B,然后将后 4 个先放到一个暂时存储块中,这样就可以避免写下半块时的冲突不命中了。

考虑 α[2][2]β[2][2] 为 A、B 8x8 分块之后的矩阵再进行 4x4 分块得到的子矩阵,其每个元素都是 4x4 个 int 。

以下所有代码说明,建议参照 NFLS-CHINA / CSAPP - Cache Lab 的更(最)优秀的解法 一文的图片对照着看,我相信你会有更好的理解。

  • 对于 i ≠ j:4 + 4 = 8 次 MISS
  • 对于 i = j: 4 + 4 + 3 = 11 次 MISS

此时,已经转置完毕 β[0][0](左上矩阵),而转置好的 β[1][0](左下矩阵)暂时位于 β[0][1](右上矩阵)。

同时,A 的前四行已经完全被读完并用完了。所以我们可以放心的开始读 A 的后四行了。

这里我们已经将 β[1][0](左下矩阵)交换回正确的位置,以及转置了 β[0][1](右上矩阵)。

  • 对于 i ≠ j:4 + 4 = 8 次 MISS
  • 对于 i = j: 7 + 4 + 4 = 15 次 MISS

注意我们先读 A 再读 B,这里也可以先读 B 再读 A ,两者没有区别。我原以为,读 β[0][1](右上矩阵)(实际上存的是 β[1][0](左下矩阵)) 接写 β[0][1](右上矩阵)可以避免 MISS,但实际测试无影响。读者可以思考一下是为什么。

β[1][1](右下矩阵)仍然没做操作,我们使用类似 32x32 中的先复制再转置的方法,将 β[1][1](右下矩阵)转置好。

  • 对于 i ≠ j:0 次 MISS
  • 对于 i = j:5 次 MISS

最终,我们得到了满分的代码:

运行:

make && ./test-trans -M 64 -N 64
bash

得到:

Summary for official submission (func 0): correctness=1 misses=1148
md

距离理论最优值 1024(8x8 分块,64 个块,每个块 16 次 miss) 仍然有一定差距,但是已经很接近了,而且已经收获了满分。

如果你想继续优化,显然是要特别处理对角线上的块的,因为他们每次都会出现冲突不命中,所以可以通过先复制到一个临时块这样的方法来避免,这里就不展开了 (才不是懒得卷了)

60x68#

测试指令:

make && ./test-trans -M 60 -N 68
bash

满分线:misses <= 1600。

首先尝试 4x4 分块:

运行:

make && ./test-trans -M 60 -N 68
bash

得到:

Summary for official submission (func 0): correctness=1 misses=1623
md

发现似乎已经接近满分了啊?那我们只需要贪心地对大部分地方用 8x8 分块,再用 4x4 分块处理余下的部分就行了:

运行:

make && ./test-trans -M 60 -N 68
bash

得到:

Summary for official submission (func 0): correctness=1 misses=1567
md

完美!下播!

Handin#

首先运行:

python driver.py
bash

得到结果:

注意阅读 README 中要求的代码格式:

  • 缺少标题注释或标题注释不具描述性:扣 2 分
  • 缺少函数头注释或函数头注释不具描述性:每处扣 1 分,最多扣 2 分
  • 缩进不一致:扣 2 分
  • 行长度严重超过 80 个字符(仅限极端情况):每处扣 1 分,最多扣 2 分
  • 错误检查不充分:扣 1 分
  • 任何其他严重影响可读性的问题:扣 2 分

本仓库内提供了一个 test-length.py 脚本,可以检查行长度是否超过 80 个字符,使用方法:

python test-length.py
bash

注意,提交前一定要先 make 并生成 handin.tar,然后改个名交了就行啦。

Other#

一些别的我觉得可能有用的教程:

NFLS-CHINA / CSAPP - Cache Lab 的更(最)优秀的解法 :暂存想法的来源,很生动的图示。

孟永康 / 《深入理解计算机系统》配套实验:Cache Lab :很好的解析了为什么尺寸变化会出现冲突。配有测试程序。

更适合北大宝宝体质的 Cache Lab 踩坑记
https://arthals.ink/blog/cache-lab
Author Arthals
Published at November 3, 2023
Comment seems to stuck. Try to refresh?✨