目录
实验题目介绍
本次实验共包括两个部分,在Part1中我们将首先完成一个Cache模拟器,从而模拟每条访存指令的命中/缺失情况,在Part2中我们将基于Part1实现的Cache模拟器,针对特定大小的矩阵,给出矩阵转置的优化策略,使得miss(即缺失数)尽可能低。我们只需要根据下发的实验代码,补全文件csim.c和trans.c即可。
Part1 Cache模拟器实现(5分)
在本节中,我们将完成一个Cache模拟器,它通过读取memory trace(内存访问记录文件),模拟访问内存的过程,并且给出每条访问记录在Cache中的命中/缺失情况。本实验只需要补全csim.c文件中的内容,提交评测也只需提交该文件。
题目描述
我们要求实现的Cache模拟器需要满足的功能详情描述如下。
读入文件(memory trace)格式
我们实现的Cache模拟器将从文本文件中逐行读取内存访问记录,每一条记录代表了一次内存相关操作。为了评测方便,内存访问记录的格式与valgrind(一个强大的程序内存检测工具,后文会有介绍)的输出格式相同,具体格式如下:
[space]<operation> <address>,<size>
<operation>表示操作类型,共分为4类I表示取指L表示取数(Load)S表示存数(Store)M表示修改(可以视为先Load后Store)
[space]为空格,当<operation>为除I类型之外的类型时,会在行首保留一个空格(这一安排是为了与valgrind的格式保持一致)。<address>为16进制数,表示一个64位地址(注意地址大小可能会超过32位)<size>为10进制数,表示存/取的内存大小(单位:字节)
memory trace示例:
I 0400d7d4,8
M 0421c7f0,4
L 04f6b868,8
S 7ff0005c8,8
Cache使用方法
./csim [-hv] -s <s> -E <E> -b <b> -t <tracefile>
-h: 输出帮助信息(帮助信息选做,不在评测范围内)-v: 输出详细trace信息(可以选择是否输出详细信息,输出格式见后文)-s <s>: set bits ($S=2^s$ 代表组的数量,评测保证s不超过10)-E <E>: lines bits ($E$ 代表每组的block个数,即行数/路数,评测保证E不超过 8)-b <b>: block bits ($B=2^b$ 代表block的大小,单位为字节,评测保证b不超过10)-t <tracefile>: 内存访问记录文件(memory trace)
我们的Cache程序需要完成以下操作:
敲门篇
敲门篇
MIPS 体系结构中的寄存器
MIPS 结构体系当中定义了 32 个寄存器
通用寄存器标志由 $ 符号开头,通常有两种使用的方式:
直接使用对应的寄存器的名称,例如 $t0、$t1、$sp 等。
直接使用对应的寄存器的编号,例如 $0、$1、$31 等。
每个寄存器都有其特定的作用,有相应的规范,如下表所示。
寄存器编号 助记符 用途 0 zero 值总是为 0 1 at (汇编暂存寄存器)一般由汇编器作为临时寄存器使用。我们编写 MIPS 汇编不使用。 2 - 3 v0 - v1 用于存放表达式的值,或函数的整型、指针型的返回值 4 - 7 a0 - a3 用于函数传参。其值在函数调用的过程中不会被保存。若函数参数较多,多出来的部分通常会使用栈进行传递。 8 - 15 t0 - t7 用于存放表达式的值的临时寄存器。其值在函数调用的过程中不会被保存。 16 - 23 s0 - s7 保存寄存器。这些寄存器中的值在经过函数调用之后不会改变。 24 - 25 t8 - t9 用于存放表达式的值的临时寄存器。其值在函数调用的过程中不会被保存。当调用位置无关函数时,25 号寄存器必须存放被调用函数的地址。 26 - 27 k0 - k1 仅被操作系统使用。 28 gp 全局指针和内容指针。 29 sp 栈指针。 30 fp 或 s8 保存寄存器(同 s0 - s7)。也可用作帧指针。 31 ra 函数返回地址。
替换策略与分块技术
Cache运行与冲突替换
Cache运行流程
如下图所示,当我们对一个地址进行访存操作时,我们按照前文所述的地址结构进行匹配。

首先我们根据地址中的组号(set),查找到Cache中的对应组(图中绿色箭头所指的部分)。之后我们将标记(tag)与Cache中该组所有行的tag进行比较(即图中左部比较的部分)。
如果Cache中某行的tag能够匹配,则说明Cache中有对应的数据块,即命中(hit)。当命中时,处理器直接对Cache进行对应的访存操作,可以通过地址中的offset,查找Cache存储的block中的offset,从而找到该地址对应的数据信息。
如果Cache中没有对应的数据块,那么就是不命中(miss)的情况。当不命中时,缓存从主存中取出包含该地址的块,并需要放入缓存中。假如Cache中该地址所属组的所有行均已被占用,那么就需要覆盖一个现有的块,这一过程通常称为替换/驱逐(eviction)。在本实验中,我们假设当Cache的块被换出时,被换出块中如果发生数据更改则此时写回主存。
这里我们就会遇到一个问题,当需要替换时,如何在Cache中选择被替换的块?我们的目的是使得发生替换的情况尽可能少,从理论上来说,我们应当选择现有块中将来不再使用的块,或者选择现有块中经过最长时间才会使用到的块,因为这最大程度上推迟了下一次发生替换的时刻,我们可以证明,如果我们能够预知程序未来将要访问的地址的话,上述的替换策略是最优的,这一策略被称为最佳置换算法(OPT,OPTimal selection)。
事实上,我们很难达到预知未来的要求,我们的替换选择往往通过已有的命中情况来推算,因此我们介绍以下三种常见的替换算法。
常见替换算法
先进先出法(FIFO,First-In-First-Out),顾名思义,即选择最早装入的块进行替换。它的实现比较简单,不需要记录各个块的访问情况,比较容易实现,开销小。但是这种算法没有依据访存的局部性原理(最早调入的块有可能是经常访问的块),因此不能提高Cache的命中率。
FIFO策略的实现:
- 缓存的每一块都设定一个计数器,初始时均为0。
- 当某块被装入或被替换时该块的计数器清为0,而同组的其它各块的计数器均加1,
- 当需要替换时就选择计数值最大的块被替换掉。
最低使用频率法(LFU,Least-Frequently Used),即替换出使用频率最低的块,又称最不经常使用算法。
LFU策略的实现:
- 缓存的每一块都设定一个计数器,初始时均为0。
- 当某块被装入或被访问时该块的计数器加1。
- 当需要替换时就选择计数值最小的块被替换掉,如果计数值相同则替换出装入时间更早的块。
最近最少使用法(LRU,Least-Recently Used),含义为替换出近期用的最少的块。它与LFU的区别在于它更加关注各个块在近期的访问情况,即近期的权重会更高。LRU需要随时记录Cache中各块的访问情况,以便确定近期最少使用的块。LRU比较复杂,一般来说我们采用简化的方法,只记录每个块最近一次使用的时间,替换时选择距上一次使用经过时间最长的块。
LRU策略的实现:
- 缓存的每一块都设置一个计数器,初始时均为0。
- 访问命中时,所有块的计数值与命中块的计数值进行比较,如果某块计数值小于命中块的计数值, 则该块的计数值加 1;如果该块的计数值大于命中块的计数值,则数值不变;最后将命中块的计数器清为0。
- 访问未命中,需要替换/装入时,则选择计数值最大的块被替换/装入,其计数器清为0,而其它的计数器则加1(除了初始装入之外,计数值是不会出现相等情况的,可以思考一下为什么)。
分块技术与实验提示
分块技术
分块技术是一项很有趣的技术,它可以提高循环中的时间局部性。分块的大致思想是将一个程序中的数据结构组织成大的块(在这里,“块”指的是一个应用级的数据组块,而不是我们之前介绍的高速缓存块)。这样构造程序,使得能够将一个块加载到高速缓存中,并在这个块内进行所需的所有读和写,然后丢掉这个块再加载下一个块。
分块会使得代码更难阅读和理解,由于这个原因,它最适合优化编译器或者频繁执行的库函数。这项技术学习和理解起来还是很有趣的,因为它是一个通用的概念,可以在一些系统上获得极大的性能收益。
实验提示
我们将以 $s=5,E=1,b=5$ 结构的Cache为例,结合大小为 $32\times 32$ 的矩阵,来初步分析在矩阵转置中应用分块等技术可以优化的方面,希望能够启发同学们的思路。我们假设矩阵A和B的地址都是block大小对齐的。
最常规的转置思路即,遍历A的所有行,将其存放到B的对应列中。
void trans(int M, int N, int A[N][M], int B[M][N])
{
int i, j;
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
B[j][i] = A[i][j];
}
}
}
我们来结合Cache的结构分析一下转置过程中产生的缺失(miss)情况。首先block大小 $B=2^b=32$ 字节,即可以存放 $8$ 个int型值;$E = 1$ 为直接映射,即Cache中每组只有一行;$S = 2^s=32$ ,即分为 $32$ 个组,整个Cache可以存放 $32\times 8=256$ 个int型值。对应到矩阵中,我们可以发现,矩阵的前8行就正好能够填满我们的Cache。结合下方矩阵B的示意图,我们可以进行进一步的分析。
进阶篇
进阶篇
函数
虽然前面有宏定义,但是 MIPS 本身还是有函数的使用的。
宏定义和函数的关系就像是 C 语言当中 #define 关键字 和 函数 的关系一样。
首先,我们来学习 jal(jump and link)指令。
jal 指令的意思是跳转到对应标签的位置,并将当前执行的指令位置存储在 $ra 寄存器中。
前面说过 $a0 - $a3 寄存器的作用是函数传参,意味着如果在 jal 指令调用之前,往 $a0 - $a3 寄存器当中存放了数据,那么 jal 指令调用之后,这几个寄存器的值是可以直接拿出来用的。
既然 $ra 寄存器当中存放了调用之前的执行的位置,当函数调用结束后可使用 jr(jump register)指令:jr $ra ,返回(return)这个函数,函数的返回值存储在 $v0 和 $v1 寄存器中。(MIPS 甚至可以有两个返回值)
高级篇
高级篇
前面我们已经学习了 MIPS 汇编的基本使用。
下面我们在此基础上回顾一下 MIPS 汇编,同时讲一些高级一点的用法。
宏定义
有的同学可能会说,这个输入输出每次都要写好几条指令才能做到,但是每次写起来都是一样的,有没有更方便的方法去完成这件事呢?
当然有,我们隆重向大家介绍宏定义。
宏定义需要使用 .macro 和 .end_macro 进行包裹。
看下面的例子:
.macro RI(%i)
li $v0, 5
syscall
move %i, $v0
.end_macro
首先阅读一下中间三行 MIPS 代码:
$v0 寄存器的值设置成 5,然后 syscall 系统调用,这个时候程序会进行读入整数的操作。
