The Memory Hierarchy
一个矛盾:CPU 很快,内存跟不上。Cache 就夹在 CPU 和内存之间,保存最近可能用到的数据。
1. Load、Store 与主存
1 | movq 8(%rsp), %rax # load:内存 -> 寄存器 |
CPU 真正计算时用的是寄存器,内存负责放数据。大致过程:
1 | 内存 -> 寄存器 -> ALU -> 寄存器 -> 内存 |
1 | SRAM:快、贵、不用刷新,通常拿来做 Cache |
2. Locality:局部性
Cache 能起作用,是因为程序访问地址通常不是乱跳的。
- 时间局部性:刚用过的数据很可能马上再用,例如循环中的
sum。 - 空间局部性:用了某个地址,很可能接着用旁边的地址,例如
a[0]、a[1]、a[2]。
C 的二维数组按行连续存放:
1 | 按行:a[0][0] -> a[0][1] -> a[0][2] |
所以按行走通常更快。按列走每次跨过一整行,stride 比较大。
3. Memory Hierarchy
1 | 寄存器 -> L1 -> L2 -> L3 -> DRAM -> SSD |
上面一层保存下面一层的一小部分。程序希望多数访问停在上面几层,不要每次都跑到 DRAM。
4. Cache 的基本概念
1 | cache hit:数据已经在 Cache 中 |
Cache 搬数据不是一个字节一个字节搬,而是一次搬一个 block。第一次读 a[0] 时,旁边几个元素也可能一起进来,后面读 a[1] 就有机会 hit。
三种 miss:
1 | cold miss:第一次访问,Cache 里本来就没有 |
Working set 就是程序这一小段时间正在频繁使用的数据。它能塞进 Cache,运行一般就比较顺。
Cache Memories
1. Cache 组织方式:S、E、B

1 | S:一共有多少个 set |
例如 S = 4, E = 2, B = 8:4 个 set,每个 set 2 条 line,每条 line 装 8 字节数据。
1.1 Cache line
1 | valid bit | tag | block data |
1 | valid bit:这条 line 现在有没有有效数据 |
这里我之前容易把 line 和 block 混在一起:
1 | line 是 Cache 中的一个槽位 |
判断 hit 要同时满足:
1 | valid = 1 && tag 匹配 |
Cache 数据容量:
1 | C = S x E x B |
这里只算 block data,不算 valid、tag 和 dirty bit。
1.2 地址拆分
一个地址直接拆成三段:
1 | tag | set index | block offset |
我的记法:
1 | set index:先去第几个 set,不用比较 |
例如 B = 64 = 2^6 bytes,就需要 6 个 offset bit,表示 block 中的第 0~63 个字节。
2. Cache Read
CPU 读一个地址时:
1 | 地址拆成 tag | set index | block offset |
所以 set index 只是定位;真正判断“是不是这块内存”的是 valid bit 和 tag。
3. Direct-Mapped Cache
Direct-Mapped Cache 就是 E = 1:
1 | Set 0: [line] |
每个 block 只能去唯一的 set,而且这个 set 里只有一个位置。两个 block 如果 set index 相同,只能互相顶掉,这就是 conflict miss。
4. E-way Set Associative Cache
E > 1,一个 set 里可以有多个位置:
1 | Set 0: [line 0] [line 1] ... |
set index 还是只负责找到 set。找到后,要把 tag 和里面 E 条 line 都比较一次,只要一条满足 valid && tag_match 就 hit。
miss 后:
1 | 有 invalid line:直接放进去 |
E 越大越不容易互相顶掉,但硬件要同时比较更多 tag。
5. Cache Simulation
例子:
1 | 4-bit address |
先算地址各部分占几位:
1 | s = log2(S) = 2 |
访问 0, 1, 7, 8, 0:
| 地址 | 二进制 | tag | set | offset | 结果 |
|---|---|---|---|---|---|
| 0 | 0000 | 0 | 00 | 0 | miss,加载 M[0-1] |
| 1 | 0001 | 0 | 00 | 1 | hit |
| 7 | 0111 | 0 | 11 | 1 | miss,加载 M[6-7] |
| 8 | 1000 | 1 | 00 | 0 | miss,顶掉 M[0-1] |
| 0 | 0000 | 0 | 00 | 0 | miss,再顶回来 |
B = 2 bytes,所以一个 block 里确实有两个字节。只需要 1 个 offset bit,就能用 0/1 区分这两个位置。因此地址 0 和 1 属于同一个 block,先访问 0 后再访问 1 会 hit。
地址 0 和 8 的 set index 都是 00,但 tag 不同。因为这里 E = 1,它们会在 Set 0 里互相替换。
6. 写操作与 dirty bit
读操作只要把数据拿到 CPU。写操作多一个问题:Cache 改了,内存什么时候改?
6.1 Write hit
目标 block 已经在 Cache 中,有两种做法:
1 | write-through:改 Cache,同时立刻改下一级存储 |
write-through 简单,但是每次 store 都会占用总线。write-back 可以把同一个 block 的多次修改合成一次写回,但此时 Cache 和内存可能不一样,所以要加 dirty bit:
1 | valid bit | dirty bit | tag | block data |
这个trade-off在 RoCC 卷积加速器时类似:每算出一个结果就 DMA 写回,逻辑简单,但小块写太密会占总线;先存在 BRAM 或寄存器堆里,攒一批再 burst 出去,事务会少很多。只是思路相似,并不等于 CPU Cache 的 write-back。
6.2 Write miss
目标 block 不在 Cache 中:
1 | write-allocate:先把整个 block 搬进 Cache,再修改 |
常见搭配:
1 | write-back + write-allocate |
为什么 write-allocate 要搬整个 block?例如 B = 4 bytes,CPU 只改地址 10,它所属的 block 是地址 8~11。Cache line 要保存整个 block,其他三个没改的字节也不能丢,所以要先把 8~11 全部拿进来,再改地址 10。
6.3 和 DMA 的关系
CPU 和 DMA 都访问内存,但 DMA 不一定经过 CPU Cache。
1 | CPU 写,DMA 读:新数据可能还在 Cache,DMA 从内存读到旧数据 |
从 Cache 角度看:
1 | DMA 读取前:CPU 的 dirty 数据要 clean/write back |
Linux 驱动里通常通过 DMA API 做映射和同步,不应该自己随便操作 Cache。
7. Cache 性能:为什么 miss 很贵
CPU 如果在 L1 Cache 中 hit,通常只需要很少的 cycles;一旦 miss,就要继续访问 L2、L3,甚至 DRAM。CPU 后面的指令如果正好依赖这个数据,就只能等。
常见指标:
1 | hit rate = 命中次数 / 总访问次数 |
平均内存访问时间:
1 | AMAT = hit time + miss rate x miss penalty |
虽然只有 5% 的访问 miss,平均时间却从 4 cycles 增加到了 9 cycles。miss rate 看起来不高,也可能把实际访问时间拖慢很多。
实际处理器还有多级 Cache:
1 | CPU |
所以 L1 miss 不代表一定访问 DRAM,但每往下一层走一次,代价都会增加。如果被替换的 Cache line 还是 dirty,还要先写回旧 block,再加载新 block。
8. 访问模式:working set、stride 与 Memory Mountain
Working set 不是程序申请的全部内存,而是程序在一段时间内反复使用的那部分数据。
1 | working set < L1:大部分访问停在 L1 |
stride 表示连续两次访问之间隔了多少个元素。例如:
1 | for (int i = 0; i < n; i += stride) { |
假设一条 Cache line 是 64 bytes,一个 int 是 4 bytes,那么一条 line 可以放 16 个 int:
1 | stride = 1:一条 line 中的 16 个 int 基本都能用到 |
所以 stride 越大,空间局部性通常越差。某些固定 stride 还会让地址反复映射到相同的几个 set,额外产生 conflict miss。
Memory Mountain 就是把 working set、stride 和读取速度画到一起:
1 | 一个方向:working set size |
小 working set、小 stride 的区域最高;working set 超过 L1、L2、L3,或者 stride 逐渐变大后,吞吐量就会下降。图上的几个性能平台,大致对应数据主要停留在 L1、L2、L3 和 DRAM 时的速度。
9. 写出 Cache-friendly 的代码:矩阵乘法与 Blocking
C 语言二维数组按行连续存放。对于矩阵乘法:
1 | C[i][j] += A[i][k] * B[k][j]; |
六种循环顺序不用逐个背,先看最内层循环是谁。
ijk / jik 的最内层是 k:
1 | A[i][k]:沿 A 的一行连续访问 |
A 友好,B 不友好,整体不算好。
ikj / kij 的最内层是 j:
1 | A[i][k]:同一个值反复使用 |
这一组通常最好。可以理解成:先取出一个 A[i][k],让它依次乘 B 的一整行,并累加到 C 的一整行。
jki / kji 的最内层是 i:
1 | A[i][k]:沿 A 的一列跳跃访问 |
两个矩阵都在跨行访问,通常最差。暂时可以这样记:
1 | 内层 j:通常最好 |
但矩阵很大时,光改循环顺序还不够,因为一整行或整个矩阵仍然放不进 Cache。这时使用 Blocking:把大矩阵切成小块。
1 | 大矩阵 |
1 | for (ii = 0; ii < n; ii += BS) |
每次只处理能放进 Cache 的几个小块,让刚加载的数据在被替换前多用几次。Blocking 没有减少乘法次数,它减少的是同一批数据被反复从内存加载的次数。
Cache Lab 的矩阵转置部分做的就是这件事:计算结果不能变,但可以通过调整访问顺序和 block size,把 miss 数量降下来。