Lecture 10: 高速缓存存储器 (Cache Memories)
Lecture 10: 高速缓存存储器 (Cache Memories)
讲义对应:CMU 15-213 Lecture 10 — Cache Memories(素材:
F25-10-cache-memories.txt) 教材对应:CS:APP3e 第 6 章 6.4–6.7(高速缓存的组织与操作、性能影响、存储器山、循环重排与分块) 关联 Lab:L4 Cache Lab(Part A 写缓存模拟器csim.c,Part B 优化矩阵转置trans.c)
10.1 概述
第 9 讲建立了”存储层次 + 局部性(locality)”的定性图景:程序倾向于访问最近用过的、地址相近的数据。本讲把这张定性图景变成一个可以精确计算的硬件模型:真实的缓存(cache)由 $S$ 个组(set)、每组 $E$ 行(line)、每行 $B$ 字节块(block)构成,容量 $C = S \times E \times B$;CPU 发出的地址被硬件切成标记(tag)、组索引(set index)、块偏移(block offset)三段,每组一次查找就能判定命中(hit)还是未命中(miss)。有了这套模型,我们才能解释为什么”按列遍历矩阵比按行慢 25 倍”、为什么循环交换一下顺序就能提速 10 倍、为什么 $64\times64$ 矩阵转置需要一套看起来极其古怪的分块写法。
本讲承接第 9 讲的局部性与存储层次,向下为第 15 讲”代码优化”提供量化的性能语言,并直接支撑 L4 Cache Lab 的两个部分。
10.2 核心概念与底层机制图解
10.2.1 缓存的组织结构 $C = S \times E \times B$(Cache Organization)
- 定义与目的:缓存是”小、快、贵”的 SRAM,缓存住”大、慢、便宜”的 DRAM 中一块子集。硬件不做任何程序分析,只靠地址位做机械查表,因此必须有一个规整的阵列结构。
- 直观解释(”它是什么?”):把缓存想成一栋图书馆:全馆分成 $S$ 个书架(组),每个书架上恰好有 $E$ 层隔板(行),每层隔板上放一个书盒(块),盒里装 $B$ 字节数据。要知道某本书在不在馆里,先由”索书号中段”决定去哪个书架(组索引),再逐层比对”书脊上的编号”(标记),全都对不上就说明没这本书。
- 底层机制图解:
高速缓存 Cache:C = S x E x B 字节(数据)
组 0 组 S-1
+--------------------------------+ +--------------------------------+
| 行0 : [v][d][ tag ][ data ] | | 行0 : [v][d][ tag ][ data ] |
| 行1 : [v][d][ tag ][ data ] | | 行1 : [v][d][ tag ][ data ] |
| ... | ... | ... |
| 行E-1 : [v][d][ tag ][ data ] | | 行E-1 : [v][d][ tag ][ data ] |
+--------------------------------+ +--------------------------------+
|<------- E = 2^e 行/组 -------->|
^ ^ ^
| | +--- B = 2^b 字节的数据块(data block)
| +-------------- d:脏位(dirty bit),写回策略下标记"已被改过"
+----------------------- v:有效位(valid bit),1 表示这一行真的存了数据
地址划分(总位宽 m = t + s + b 位)
高位 低位
+---------------------------+--------------------+-------------------+
| tag (t 位) | set index (s 位) | block offset(b 位)|
+---------------------------+--------------------+-------------------+
| | |
与组内 E 行并行比较 tag 选中第 set 组 块内第几个字节
-
与机器码/硬件的对应:全部由地址译码逻辑完成——
set index位直接接到一个多路选择器选中 SRAM 组;tag位送进 $E$ 个并行的比较器;block offset位用于从块内取出字节。因此”查找”是组合逻辑,不需要指令、不需要软件参与。唯一会出现在汇编里的是访存指令本身(如movslq (%rax), %rcx)。
10.2.2 直接映射缓存(Direct-Mapped Cache,$E=1$)
- 定义与目的:每组只有一行,每个内存块在全缓存中只有唯一一个位置可放:块地址 $\bmod\ S$。极端简单、快,但容易冲突。
- 直观解释:每个书架只有一层隔板,一本书只能放固定书架。于是两本”必须放在同一书架”的书只能轮流上架——这正是冲突未命中(conflict miss)的来源。
- 底层机制图解:讲义用 4 位地址、$S=4,E=1,B=2$($s=2,b=1,t=1$)演示,访存序列 $0,1,7,8,0$:
地址 4 位: t s s b
0 = 0000 -> tag=0 set=0 off=0 Set0 空 -> miss 装入 M[0-1]
1 = 0001 -> tag=0 set=0 off=1 tag 匹配,v=1 -> hit
7 = 0111 -> tag=0 set=3 off=1 Set3 空 -> miss 装入 M[6-7]
8 = 1000 -> tag=1 set=0 off=0 组0 现有 tag=0 -> miss 替换 -> eviction
0 = 0000 -> tag=0 set=0 off=0 组0 现为 tag=1 -> miss 替换 -> eviction
结果: hits:1 misses:4 evictions:2
10.2.3 组相联缓存(Set-Associative,$E>1$)与全相联(Fully Associative,$S=1$)
-
定义与目的:一组放 $E$ 行,块可以放到组内任意一行。$E$ 越大冲突越少,但并行比较器越多、命中时间越长。$S=1$(即 $s=0$,无组索引位)就是全相联,此时地址只剩
tag\|offset两段。 - 直观解释:$E$ 路组相联就是”每个书架有 $E$ 层隔板”,能容忍 $E$ 本同书架的书共存;全相联则是”整馆一个大书架”,随便放——永远不会发生冲突未命中,但查找最贵。
-
组相联的替换策略:组满时必须选一个牺牲行(victim)踢出去,称为替换(replacement):
- LRU(Least Recently Used,最近最少使用):踢最久未被访问的行。命中率好,但需要为每行维护时间戳/计数器,硬件成本高。Cache Lab 用的就是 LRU。
- LFU(Least Frequently Used):踢访问次数最少的行。实现简单,但对”曾经很热、现在已冷”的块不友好。
- 随机(Random):硬件最省事,平均效果不差(现代 L3 常用伪随机)。
- $E$ 路组相联(讲义中 $E=2$)的另一条访存序列(4 位地址,$S=2,E=2,B=2$,$s=1,b=1,t=2$),仍是 $0,1,7,8,0$:
0 = 0000 -> set=0 tag=00 组0 空 -> miss 装入
1 = 0001 -> set=0 tag=00 命中 -> hit
7 = 0111 -> set=1 tag=01 组1 空 -> miss 装入
8 = 1000 -> set=0 tag=10 组0 还有空行 -> miss 装入(不替换!)
0 = 0000 -> set=0 tag=00 组0 里 tag=00 仍在 -> hit
结果: hits:2 misses:3 evictions:0
同一个访问序列,直接映射是 1 命中/2 替换,2 路组相联是 2 命中/0 替换——多出来的这次命中,正是”冲突未命中”被相联度消化掉的证据。
10.2.4 查找流程图与读写策略(Lookup & Write Policy)
CPU 发出地址 addr
|
v
+---------------------------------------+
| b = log2(B) |
| set = (addr >> b) & (S - 1) |
| tag = addr >> (s + b) |
| off = addr & (B - 1) |
+---------------------------------------+
|
v
+---------------------------------------+ 组内所有 E 行 tag 都不匹配
| 选中"组 set",对 E 行的 tag 并行比较 |------- 或 v = 0 ---------------+
| 同时检查 v = 1 | |
+---------------------------------------+ |
| 有匹配且 v=1 |
v v
+--------------------+ +----------------------------------+
| HIT 命中 | | MISS 未命中 |
| 从块内 off 处取/ | | 1. 向下一级存储请求整个块 |
| 写入该字 | | 2. 组满? -> 按替换策略选牺牲行 |
+--------------------+ | 若牺牲行 d=1 -> 整块写回内存 |
| | (write-back) |
| 读命中: 直接返回 | 3. 装入新块, v=1, 清 d |
| 写命中: write-through -> 同时写下一级 | 4. 若为写未命中且 write-allocate,|
| write-back -> 只写本行, d=1 | 再按"写命中"处理本次写 |
v +----------------------------------+
(完成一次访存)
写策略的两组正交选择(讲义明确给出):
| 时机 | 策略 | 含义 | 代价 |
|---|---|---|---|
| 写命中 | 写直达(write-through) | 立刻写到下一级 | 每次写都产生总线/下一级流量 |
| 写命中 | 写回(write-back) | 只改缓存行并置脏位(dirty bit),直到该行被替换才整块写回 | 节省流量,但需要脏位与更复杂的替换逻辑 |
| 写未命中 | 写分配(write-allocate) | 先把块取进缓存,再在缓存里改 | 若后续还会写同一块则很赚 |
| 写未命中 | 非写分配(no-write-allocate) | 直接写进下一级,不取块 | 避免”只写一次却要取一整块”的浪费 |
工程上成对出现:写直达 + 非写分配(简单,早期机器)、写回 + 写分配(现代通用处理器的 L1/L2 选择,也是 Cache Lab 模拟器必须实现的行为)。
10.2.5 Intel Core i7 的真实缓存层次
+---------------------------+
| Core 0 Core 3 | ...每个核内私有:
| +-----+ +-----+ | L1 i-cache 32 KB, 8-way
| | Regs| | Regs| | L1 d-cache 32 KB, 8-way
| +--+--+ +--+--+ | L2 unified 256 KB, 8-way
| +--+--+ +--+--+ | 访问延迟 ~4 / ~10 周期
| |L1i | |L1i | |
| |L1d | |L1d | |
| +--+--+ +--+--+ |
| | L2 | | L2 | |
| +--+--+ +--+--+ |
+-----+--------+------------+
| |
+-----v--------v------------+
| L3 unified 8 MB, 16-way |
| 所有核共享, ~40-75 周期 |
+-------------+-------------+
|
+------v------+
| Main Memory | ~50-200 周期
+-------------+
所有层次 block size 均为 64 B
| 层次 | 容量 | 相联度 | 块大小 | 组数 $S$ | $s$ | $b$ | $t$(47 位地址) |
|---|---|---|---|---|---|---|---|
| L1 d-cache | 32 KB | 8 路 | 64 B | $32768/(8\times64)=64$ | 6 | 6 | $47-6-6=35$ |
| L1 i-cache | 32 KB | 8 路 | 64 B | 64 | 6 | 6 | 35 |
| L2 unified | 256 KB | 8 路 | 64 B | $262144/(8\times64)=512$ | 9 | 6 | 32 |
| L3 unified | 8 MB | 16 路 | 64 B | $8388608/(16\times64)=8192$ | 13 | 6 | 28 |
讲义给出的 Core i7 L1 数据缓存例题:地址 0x00007f7262a1e010,$s=6,b=6$,则
块偏移 = addr & 0x3F = 0x10 (块内第 16 字节)
组索引 = (addr >> 6) & 0x3F = 0x0 (第 0 组)
标记 = addr >> 12 = 0x7f7262a1e
10.2.6 性能度量与 3C 未命中
- 未命中率(miss rate) = misses / accesses = 1 − 命中率。L1 典型 3–10%,L2 常 <1%。
- 命中时间(hit time):把一行数据交给 CPU 的时间,含”判定是否命中”的时间。L1 约 4 周期,L2 约 10 周期。
- 未命中惩罚(miss penalty):主存约 50–200 周期,且趋势是变大(CPU 越快,相对差距越大)。
- 平均访存时间:
讲义用它解释了”为什么用未命中率而不是命中率”:命中时间 1 周期、未命中惩罚 100 周期时,命中率 97% ⟹ $1+0.03\times100=4$ 周期,命中率 99% ⟹ $1+0.01\times100=2$ 周期。命中率只提高 2 个百分点,平均访存时间却快了一倍。 命中率高的区间里,它看起来”几乎一样”(97% vs 99%),而对性能的影响却是翻倍的——所以要盯住未命中率。
- 3C 未命中(”C”指 Cache miss 的分类):
| 类型 | 讲义定义 | 消除手段 |
|---|---|---|
| 强制/冷未命中(compulsory / cold) | 对某块的第一次引用,缓存不可能有它 | 无法消除;可增大块以摊薄(一次取更多有用字节) |
| 容量未命中(capacity) | 活跃块的集合大于缓存本身(即使全相联也会发生) | 增大缓存;分块(blocking)把工作集切小 |
| 冲突未命中(conflict) | 缓存够大,但太多对象按放置策略映射到同一小组行上 | 提高相联度;调整数据布局/访问顺序 |
recitation 给了一个可操作的判别法:找到该块上一次被引用,数中间出现过的唯一块数——若 ≥ 缓存总行数则是容量未命中,否则是冲突未命中。
10.3 代码示例与底层机制分析
10.3.1 示例一:按行、按列、分块三种访存顺序(/tmp/cachech10/rowcol.c)
代码 (C):
/* rowcol.c - 按行 / 按列 / 分块 三种访存顺序的性能对比
* 编译: gcc -O2 -g -Wall -std=c11 rowcol.c -o rowcol
*/
#define _POSIX_C_SOURCE 199309L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define N 2048 /* 矩阵阶数, 2048*2048*8 = 32 MB */
#define BLK 64 /* 分块大小 */
static double *A, *B, *C;
static int *M; /* N x N 的 int 矩阵, 16 MB */
static double now(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec * 1e-9;
}
static double sum_row(int n, int *a) { /* stride-1 */
long s = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) s += a[i * n + j];
return (double)s;
}
static double sum_col(int n, int *a) { /* stride-n */
long s = 0;
for (int j = 0; j < n; j++)
for (int i = 0; i < n; i++) s += a[i * n + j];
return (double)s;
}
static double sum_block(int n, int *a) { /* 两个方向都 stride-1 */
long s = 0;
for (int ii = 0; ii < n; ii += BLK)
for (int jj = 0; jj < n; jj += BLK)
for (int i = ii; i < ii + BLK; i++)
for (int j = jj; j < jj + BLK; j++) s += a[i * n + j];
return (double)s;
}
static void mm_ijk(int n, double *a, double *b, double *c) {
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) {
double sum = 0.0;
for (int k = 0; k < n; k++) sum += a[i * n + k] * b[k * n + j];
c[i * n + j] = sum;
}
}
static void mm_kij(int n, double *a, double *b, double *c) {
for (int k = 0; k < n; k++)
for (int i = 0; i < n; i++) {
double r = a[i * n + k];
for (int j = 0; j < n; j++) c[i * n + j] += r * b[k * n + j];
}
}
static void mm_block(int n, double *a, double *b, double *c) {
for (int kk = 0; kk < n; kk += BLK)
for (int ii = 0; ii < n; ii += BLK)
for (int jj = 0; jj < n; jj += BLK)
for (int i = ii; i < ii + BLK; i++)
for (int k = kk; k < kk + BLK; k++) {
double r = a[i * n + k];
for (int j = jj; j < jj + BLK; j++)
c[i * n + j] += r * b[k * n + j];
}
}
int main(void) {
M = malloc(sizeof(int) * (size_t)N * N);
A = malloc(sizeof(double) * (size_t)N * N);
B = malloc(sizeof(double) * (size_t)N * N);
C = malloc(sizeof(double) * (size_t)N * N);
if (!M || !A || !B || !C) { fprintf(stderr, "malloc failed\n"); return 1; }
for (size_t i = 0; i < (size_t)N * N; i++) {
M[i] = (int)(i % 7);
A[i] = (double)(i % 13) * 0.5;
B[i] = (double)(i % 11) * 0.25;
}
double t, r;
printf("矩阵阶数 N = %d, 分块 BLK = %d\n", N, BLK);
t = now(); r = sum_row(N, M); printf("sum_row : %8.4f s (sum=%g)\n", now()-t, r);
t = now(); r = sum_col(N, M); printf("sum_col : %8.4f s (sum=%g)\n", now()-t, r);
t = now(); r = sum_block(N, M); printf("sum_block : %8.4f s (sum=%g)\n", now()-t, r);
memset(C, 0, sizeof(double) * (size_t)N * N);
t = now(); mm_ijk(N, A, B, C); printf("mm_ijk : %8.4f s\n", now()-t);
memset(C, 0, sizeof(double) * (size_t)N * N);
t = now(); mm_kij(N, A, B, C); printf("mm_kij : %8.4f s\n", now()-t);
memset(C, 0, sizeof(double) * (size_t)N * N);
t = now(); mm_block(N, A, B, C);printf("mm_block : %8.4f s\n", now()-t);
free(M); free(A); free(B); free(C);
return 0;
}
【代码做什么?】
-
sum_row双层循环按 $i$ 外层、$j$ 内层扫描:内层a[i*n+j]地址连续递增,stride = 1。 -
sum_col交换两层循环:内层a[i*n+j]每次跨一整行($2048\times4$ B $= 8$ KB),stride = n。 -
sum_block用 $64\times64$ 的块把整个扫描切成许多小方阵,块内两维都是 stride-1。 -
mm_ijk/mm_kij是讲义经典的矩阵乘法循环顺序对比;mm_block是分块版本。
【底层机制透视】
-
sum_row:每个 32 B 缓存块装 8 个int,一次未命中换来 8 次命中,未命中率 $= 4/32 = 1/8$(讲义把这一情形写作miss rate = sizeof(aij)/B)。整个矩阵 16 MB,但访问是流式(streaming)的,L1 只需少量行即可,硬件预取器(prefetcher)还能提前把后面的块拉进 L2。 -
sum_col:每一步都跨 8 KB,不同行的同一列元素属于不同块,每个元素都要一次未命中,未命中率 $\approx 1$(100%)。而且 16 MB 的工作集会被预取器”越界取一整行”,反而放大了流量。 -
sum_block:块内两维都 stride-1,未命中率被压回 $1/8$,同时又保证块的工作集($64\times64\times4=16$ KB)留在 L1(32 KB)里,把时间局部性也吃满。 -
mm_ijk:内层对b[k*n+j]是按列访问,按讲义的分析 $B$ 数组贡献 1.0 miss/iter;mm_kij把内层换成对c和b的按行访问,平均 0.5 miss/iter。
【内存布局 / 数据结构图解】
C 语言二维数组是行主序(row-major):a[i][j] 在 a + 4*(i*N + j)
sum_row 的访问轨迹(内存地址递增方向 --->)
a[0][0] a[0][1] ... a[0][7] | a[0][8] ... <- 一个块 8 个 int
^^^^^^^ 一次 miss,后面 7 次 hit
sum_col 的访问轨迹(每步 +8KB)
a[0][j]
a[1][j] <- +8192 B
a[2][j] <- +8192 B
...
每一步都落在不同的块 -> 每一步都 miss
【与汇编 / 硬件的对应】(gcc -O1 -S -fno-tree-vectorize s.c 真实输出,AT&T 语法)
# ---- sum_row 的内层循环(stride = 1) ----
.L4:
movslq (%rax), %rcx # 取一个 int (4 B)
addq %rcx, %rdx
addq $4, %rax # 地址 +4 B <-- stride-1
cmpq %rsi, %rax
jne .L4
# ---- sum_col 的内层循环(stride = n) ----
# %r9 在外层由 salq $2, %r9 算得 = n*4
.L10:
movslq (%rdx), %r8 # 取一个 int (4 B)
addq %r8, %rcx
addq %r9, %rdx # 地址 +n*4 B <-- stride-n !!
cmpl %eax, %edi
jne .L10
两段代码的指令条数几乎一样——唯一区别是内层地址增量:addq $4, %rax 对应 stride-1 的复用(硬件缓存友好),addq %r9, %rdx 对应 stride-$n$ 的跳跃。这 8 KB 的步长,就是 25 倍性能差的全部来源。
【实测验证】(本机 AMD EPYC 7V13,gcc -O2)
矩阵阶数 N = 2048, 分块 BLK = 64
--------------------------------------------------
sum_row : 0.0010 s (sum=1.25829e+07)
sum_col : 0.0259 s (sum=1.25829e+07) <- 慢约 26 倍
sum_block : 0.0008 s (sum=1.25829e+07)
--------------------------------------------------
mm_ijk : 61.9224 s <- 讲义说 1.25 miss/iter
mm_kij : 5.9067 s <- 讲义说 0.50 miss/iter, 快 10.5 倍
mm_block : 5.8397 s
同一程序多次重跑结果稳定(如 mm_ijk 61.33 s–61.92 s、mm_kij 5.85 s–5.93 s),循环顺序带来的 10 倍级差距不是测量噪声,而是本讲缓存模型预测的真实后果。
10.3.2 示例二:简化缓存模拟器(/tmp/cachech10/csim_demo.c)
代码 (C):
/* csim_demo.c - 简化缓存模拟器(LRU,写回 + 写分配)
* 用法: ./csim_demo -s <s> -E <E> -b <b> [-v] [-t tracefile]
* 编译: gcc -O2 -g -Wall -std=c11 csim_demo.c -o csim_demo
*/
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <getopt.h>
typedef struct { /* 一条缓存行 */
unsigned long tag;
int valid;
int dirty;
unsigned long stamp; /* LRU 时间戳, 越大越近 */
} line_t;
static line_t *cache;
static int S, E, B, s, b;
static unsigned long lru_counter, hits, misses, evictions;
/* 返回 1 = hit, 0 = miss; *evicted 置 1 表示本次发生了替换 */
static int access_cache(unsigned long addr, int is_write, int *evicted)
{
unsigned long set = (addr >> b) & ((1UL << s) - 1);
unsigned long tag = addr >> (s + b);
line_t *base = cache + set * (long)E;
int i, lru_way = 0;
*evicted = 0;
lru_counter++;
for (i = 0; i < E; i++) {
if (base[i].valid && base[i].tag == tag) { /* 命中 */
base[i].stamp = lru_counter;
if (is_write) base[i].dirty = 1;
hits++;
return 1;
}
if (base[i].stamp < base[lru_way].stamp) lru_way = i;
}
misses++; /* 未命中 */
for (i = 0; i < E; i++)
if (!base[i].valid) { lru_way = i; break; }
if (base[lru_way].valid) { /* 需要替换 */
evictions++;
*evicted = 1;
if (base[lru_way].dirty)
printf(" [dirty eviction: 把整块写回内存]\n");
}
base[lru_way].valid = 1;
base[lru_way].tag = tag;
base[lru_way].dirty = is_write; /* 写分配: 装入即置脏 */
base[lru_way].stamp = lru_counter;
return 0;
}
/* 处理一条 trace: " L 10,1" / "M 20,1" / "S 18,1" */
static void do_trace(const char *op, unsigned long addr, int size, int verbose)
{
int is_write = (op[0] == 'S' || op[0] == 'M');
int r, evicted;
if (op[0] == 'M') { /* 修改 = 读 + 写 */
r = access_cache(addr, 0, &evicted);
if (verbose) printf(" %c %lx,%d %s%s", op[0], addr, size,
r ? "hit" : "miss", evicted ? " eviction" : "");
r = access_cache(addr, 1, &evicted);
if (verbose) printf(" %s\n", r ? "hit" : "miss");
} else {
r = access_cache(addr, is_write, &evicted);
if (verbose) printf(" %c %lx,%d %s%s\n", op[0], addr, size,
r ? "hit" : "miss", evicted ? " eviction" : "");
}
}
int main(int argc, char *argv[])
{
int opt, verbose = 0;
const char *tracefile = NULL;
s = E = b = -1;
while ((opt = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
switch (opt) {
case 'v': verbose = 1; break;
case 's': s = atoi(optarg); break;
case 'E': E = atoi(optarg); break;
case 'b': b = atoi(optarg); break;
case 't': tracefile = optarg; break;
default: return 1;
}
}
if (s < 0 || E <= 0 || b < 0) return 1;
S = 1 << s; B = 1 << b;
cache = calloc((size_t)S * E, sizeof(line_t));
if (!cache) return 1;
printf("cache: S=%d sets, E=%d ways, B=%d bytes (C=%d bytes)\n\n",
S, E, B, S * E * B);
if (tracefile) {
FILE *fp = fopen(tracefile, "r");
char op[8]; unsigned long addr; int size;
if (!fp) { perror("fopen"); free(cache); return 1; }
while (fscanf(fp, "%7s %lx,%d", op, &addr, &size) == 3) {
if (op[0] == 'I') continue; /* 忽略取指 */
do_trace(op, addr, size, verbose);
}
fclose(fp);
} else { /* 内置讲义 trace */
unsigned long addrs[] = {0, 1, 7, 8, 0};
for (unsigned i = 0; i < sizeof(addrs)/sizeof(addrs[0]); i++) {
if (verbose) printf(" L %lx,1 ", addrs[i]);
do_trace("L", addrs[i], 1, 0);
if (verbose) printf("\n");
}
}
printf("hits:%lu misses:%lu evictions:%lu\n", hits, misses, evictions);
free(cache);
return 0;
}
【代码做什么?】
-
getopt解析-s/-E/-b/-t/-v,与csim-ref的接口完全一致(这正是 Part A 的接口要求)。 - 用
calloc分配 $S\times E$ 个line_t,下标为set*E + way,组内 $E$ 行在一块连续内存里(便于”并行比较”的抽象)。 -
access_cache:先由(addr>>b) & (S-1)求组号、addr>>(s+b)求标记,然后线性扫描组内 $E$ 行找匹配;命中就刷新 LRU 时间戳。 - 未命中时先看有没有无效行(
!valid)可直接占用,否则选stamp最小的行作为牺牲行;若该行dirty,打印一条”脏替换”提示,模拟把整块写回内存。 -
do_trace把M(modify)展开成”读一次 + 写一次”,这正是 Lab 文档强调的语义:“An M operation can result in two cache hits, or a miss and a hit plus a possible eviction.” -
I(instruction load)被直接continue跳过——Lab 只关心数据缓存。
【底层机制透视】
-
LRU 用时间戳数组而不是链表:全局
lru_counter单调递增,每次访问把命中行/新装入行的stamp置为当前计数值;选牺牲行时取stamp最小者。这样每次访存是 $O(E)$,且不需要维护指针——这正是 Part A 推荐的实现方式(讲义 recitation 里称为lru_counter方案)。 -
注意
lru_way的初值:初始lru_way = 0,如果组内有多个无效行,第一次扫描结束时会选中”stamp 最小”的那一行,随后被”找无效行”的循环覆盖。只有当组满时才真正按 LRU 替换——这就是”未命中但无替换”与”未命中且替换”的区别,也是misses与evictions会分开统计的原因。 -
写分配 + 写回的体现:写未命中会把它当读未命中一样装块(miss++),并把新行的
dirty置 1;写命中只改dirty,不产生任何额外计数。
【内存布局 / 数据结构图解】
cache 数组(S=4, E=2 时的实际内存布局,每个 line_t 24 B 打包前)
下标 0 set0 way0 : tag=..., v=1, d=0, stamp=7
下标 1 set0 way1 : tag=..., v=1, d=0, stamp=3 <- stamp 最小 => 牺牲行
下标 2 set1 way0 : tag=..., v=0, d=0, stamp=0 <- v=0 优先占用
下标 3 set1 way1 : tag=..., v=1, d=1, stamp=9 <- d=1, 被替换时要写回
...
组 set 的首地址 = cache + set * E
4 位地址 s=2,b=1 时:
addr = 0 1 0 1
t t s s b
^^^ -> set = (addr>>1)&0b11
tag = addr>>3
【与汇编 / 硬件的对应】:真实硬件里,组选择由 addr[s+b-1:b] 直接驱动 SRAM 的字线(word line),$E$ 个 tag 比较器并行工作,命中信号经 OR 门合成。软件里无法直接看到这个过程,但 perf stat -e cache-misses 读到的正是同一套硬件计数器。
【实测验证】(真实运行输出)
$ ./csim_demo -s 2 -E 1 -b 1 -v # 直接映射: S=4, E=1, B=2
cache: S=4 sets, E=1 ways, B=2 bytes (C=8 bytes), s=2 E=1 b=1
L 0,1
L 1,1
L 7,1
L 8,1
L 0,1
hits:1 misses:4 evictions:2
$ ./csim_demo -s 1 -E 2 -b 1 -v # 2 路组相联: S=2, E=2, B=2
cache: S=2 sets, E=2 ways, B=2 bytes (C=8 bytes), s=1 E=2 b=1
hits:2 misses:3 evictions:0
$ ./csim-ref -s 4 -E 1 -b 4 -t yi.txt # 与官方参考模拟器对照
hits:4 misses:5 evictions:3
$ ./csim_demo -s 4 -E 1 -b 4 -t yi.txt
[dirty eviction: 把整块写回内存]
hits:4 misses:5 evictions:3 # 计数完全一致
10.3.3 示例三:$64\times64$ 转置的特殊分块法(trans.c)
Part B 要求 transpose_submit 在 $(s=5,E=1,b=5)$ 的直接映射缓存(32 组、1 行/组、32 B 块 = 8 个 int)上把未命中数压到阈值以下。关键在于:64 个 int 一行的字节数是 $64\times4=256$ B $= 8$ 个块 $= 8$ 个组,而整个缓存只有 32 组。
64x64 int 矩阵, 一行 = 256 B = 8 个 32 B 块
块号: 8i+0 8i+1 ... 8i+7 组号 = 块号 mod 32 = (8i + t) mod 32
所以第 i 行占用的组集合随 i 每 4 行重复一次!
8x8 tile 的行 i0..i0+7 映射到的组: 8*i0+m, 8*(i0+1)+m, ..., 8*(i0+7)+m (mod 32)
u=0 和 u=4 -> 同一组!
u=1 和 u=5 -> 同一组!
u=2 和 u=6 -> 同一组!
u=3 和 u=7 -> 同一组!
=> 一个 8x8 tile 的 8 行只落在 4 个组上, 每组挤 2 行。
而 A 的 tile 与 B 的 tile (若基址对齐相同) 落在同样的 4 个组 => 直接映射下灾难。
这就是朴素 8×8 分块在 64×64 上完全失效的原因(实测未命中 4724,与不做分块的朴素转置完全相同)。解法是:把每个 8×8 tile 再拆成四个 4×4 子块,用 8 个局部变量(寄存器)做中转。
A 的 8x8 tile (行 i0..i0+7, 列 j0..j0+7) B 的 8x8 tile (行 j0..j0+7, 列 i0..i0+7)
列 j0 列 j0+4 列 i0 列 i0+4
+--------+--------+ +--------+--------+
行 i0 | A_TL | A_TR | 行 j0 | B_TL | B_TR |
| 4x4 | 4x4 | | =A_TL^T| =A_BL^T|
+--------+--------+ +--------+--------+
行 i0+4 | A_BL | A_BR | 行 j0+4 | B_BL | B_BR |
| 4x4 | 4x4 | | =A_TR^T| =A_BR^T|
+--------+--------+ +--------+--------+
步骤 1: 读 A 第 i 行 (j0..j0+7) -> 8 个寄存器 v0..v7 [i = i0..i0+3]
B[j0+0..3][i] = v0..v3 (B_TL 象限, 正式的转置值)
B[j0+0..3][i+4] = v4..v7 (B_TR 象限的"暂存区", 尚未转置)
<<< 8 个 int 正好凑满一条 32 B 缓存行: 1 次 miss 换 8 个寄存器 >>>
步骤 2: 对 k = i0..i0+3:
a0..a3 = B[k][i0+4..i0+7] (读回暂存区 = A_TR 的原始数据)
a4..a7 = A[i0+4..i0+7][k] (A_BL 的一列, 正是 B_TR 需要的)
B[k][i0+4..i0+7] = a4..a7 (B_TR 象限 = A_BL^T)
B[k+4][i0+0..i0+3] = a0..a3 (B_BL 象限 = A_TR^T)
步骤 3: A_BR -> B_BR: for k = i0+4..i0+7:
B[j0+4..j0+7][k] = A[k][j0+4..j0+7]
核心思想:A 的每个 4×4 子块和 B 的对应 4×4 子块会争同一批组,所以绝不能”读一个、写一个”交替进行;必须先把整行 8 个 int 一次性读进寄存器(8 个变量刚好覆盖一条缓存行),再集中写 B,并且借用 B 自己的一块区域当临时仓库做 A_TR 与 A_BL 的对调。
【实测验证】(本机真实跑通官方 tracegen + csim-ref,s=5,E=1,b=5)
$ ./test-trans -M 32 -N 32
func 1 (naive row-wise): hits:869, misses:1184, evictions:1152
func 2 (8x8 blocking, diagonal-aware): hits:1765, misses:288, evictions:256
$ ./test-trans -M 64 -N 64
func 1 (naive row-wise): hits:3473, misses:4724, evictions:4692
func 2 (8x8 blocking, diagonal-aware): hits:3585, misses:4612, evictions:4580
func 3 (64x64 8x8 with four 4x4 sub-blocks):hits:9017, misses:1228, evictions:1196
Summary for official submission: correctness=1 misses=1228 # 阈值 < 1300 ✓
$ ./test-trans -M 61 -N 67
func 3 (61x67 blocked with margins): hits:6186, misses:1993, evictions:1961
Summary for official submission: correctness=1 misses:1993 # 阈值 < 2000 ✓
$64\times64$ 的理论下界是 $2\times(64\times64/8)=1024$ 次强制未命中(A、B 各 512 块),实测 1228 只多出 204 次冲突未命中。$32\times32$ 用 8×8 分块并对对角块额外处理(先读满 8 个再写,避免 A、B 同行同列争夺同一组)得到 288(阈值 <300);$61\times67$ 因为不是 8 的倍数、组数多,用 $16\times16$ 分块并把边界用 && 判据逐行裁剪即可到 1993。
10.3.4 存储器山(The Memory Mountain)
mountain 程序测的是读吞吐率(read throughput / read bandwidth,MB/s)随工作集大小 size 与步长 stride 的二维变化;对每个 (size, stride) 先跑一次预热缓存,再跑一次计时。讲义的核心函数用 4×4 循环展开:
long data[MAXELEMS];
int test(int elems, int stride) {
long i, sx2 = stride*2, sx3 = stride*3, sx4 = stride*4;
long acc0 = 0, acc1 = 0, acc2 = 0, acc3 = 0;
long length = elems, limit = length - sx4;
for (i = 0; i < limit; i += sx4) { /* 一次合并 4 个元素 */
acc0 = acc0 + data[i];
acc1 = acc1 + data[i+stride];
acc2 = acc2 + data[i+sx2];
acc3 = acc3 + data[i+sx3];
}
for (; i < length; i++) acc0 = acc0 + data[i];
return ((acc0 + acc1) + (acc2 + acc3));
}
读吞吐率
(MB/s)
^
14000|#####
|#####
12000|#####
|#####
10000|#####*****
|##### *****
8000|##### *****
|##### *****
6000|##### *****=====
|##### =====
4000|##### =====
|##### =====
2000|##### =====##########
|##### ##########
0+-----+-----+-----+-----+-----+-----+-----+-----+--------------> Size
32k 64k 128k 256k 512k 1m 2m 4m 8m 16m 32m 128m
| | | |
|<-- L1 -->|<---- L2 ---->|<------ L3 ------->|<-- 主存 -->|
32 KB 256 KB 8 MB
图例: "山脊"(ridges of temporal locality) 沿 size 方向 ——
每跨过一级缓存容量就掉一个台阶:
< 32 KB : L1 平台, 吞吐最高
32K - 256K: L2 平台
256K - 8M : L3 平台
> 8 MB : 主存平台, 吞吐最低
"山坡"(slopes of spatial locality) 沿 stride 方向 ——
stride 越小吞吐越高; 若 stride 以 8 B 为单位,
未命中率 = stride/8, stride >= 8 时未命中率 = 1.0,
吞吐出现"悬崖"。
讲义还指出:现代 CPU 的激进预取(aggressive prefetching)会把”悬崖”变形,2018 年前的 Core 2 Duo(无预取)的”山”更规整,而 Haswell 的山在 stride=8 附近仍有较高的吞吐——预取器替你把下一块提前拉进来了。
【实测验证】 本机把同样的 test() 遍历缩小规模跑了一遍(AMD EPYC 7V13,每核 L1d 32 KB、L2 512 KB,16 核共享 L3 32 MB),读吞吐率(MB/s)如下表——台阶位置与本机缓存容量一一对应,正是讲义那座”山”的局部切片:
size\stride 1 2 4 8 16
16K 80638 68292 52230 39847 26223 <- L1 内, 全速; 差距 3.1 倍
64K 93254 88640 80628 68289 52234
256K 97054 95783 93270 88615 80663
1024K 98100 97660 97118 95810 93293 <- L2 之外
4096K 98370 98259 98072 97801 97105
16384K 98417 98441 98326 98306 98144 <- 远超所有缓存, 几乎不随 stride 变化
两个现象与讲义完全一致:(1) 沿 size 增大方向吞吐单调下降(山脊,16 KB 时的 8 万 MB/s 看似低于 1 MB 时的 9.8 万,是因为 16 KB 工作集全部落在 L1 但循环太短、启动开销不可忽略);(2) 沿 stride 增大方向吞吐下降(山坡),在 16 KB 工作集上 stride=1 与 stride=16 相差 3.1 倍。而 16 MB 那一行几乎不随 stride 变化(98417 → 98144),因为数据总量已远超缓存、且预取器把大 stride 的访问也提前拉了进来——讲义标注的 “aggressive prefetching” 说的就是这种失真:它让”山坡”在末端变平了。
10.4 实验关联
L4 Cache Lab,两部分都直接来自本讲:
Part A — 写缓存模拟器 csim.c(27 分)
- 接口:
./csim [-hv] -s <s> -E <E> -b <b> -t <tracefile>,输出格式必须是hits:H misses:M evictions:E;必须调用printSummary(hit_count, miss_count, eviction_count)。 - 输入是 valgrind(lackey 工具)产生的 trace:
I(取指,必须忽略)、L(数据读)、S(数据写)、M(数据修改,等价于一次读加一次写)。格式为[空格]op 地址,长度,注意I前没有空格、L/M/S前有空格——文件里第 0 列是不是空格,恰好可以拿来区分。 - 因为假设访存不跨块边界,
size字段可以直接忽略。 - 编译必须
-Wall -Werror零警告;数据结构要用malloc动态分配(缓存参数是运行时给的)。 - 讲义给出的 8 个测试用例(前 7 个 3 分、最后一个 6 分),实测样例:
./csim -s 1 -E 1 -b 1 -t traces/yi2.trace -> hits:9 misses:8 evictions:6
./csim -s 4 -E 2 -b 4 -t traces/yi.trace -> hits:4 misses:5 evictions:2
./csim -s 2 -E 1 -b 4 -t traces/dave.trace -> hits:2 misses:3 evictions:1
./csim -s 5 -E 1 -b 5 -t traces/trans.trace -> hits:231 misses:7 evictions:0
./csim -s 5 -E 1 -b 5 -t traces/long.trace -> hits:265189 misses:21775 evictions:21743
Part B — 优化 transpose_submit(26 分)
- 评测缓存固定为 $(s=5,E=1,b=5)$,用 valgrind 抽取 trace 后交给参考模拟器统计。
- 三条硬性约束:每个转置函数最多 12 个
int局部变量(跨辅助函数累计)、不得定义数组 / 不得用malloc/ 不得递归 / 不得改A、编译零警告。局部数组被禁的原因是 valgrind 产生的栈访问会被过滤掉,课程希望你把注意力放在 A、B 两个数组的访问模式上。 - 阈值:$32\times32$ 未命中 <300 得 8 分(>600 得 0);$64\times64$ <1300 得 8 分(>2000 得 0);$61\times67$ <2000 得 10 分(>3000 得 0)。
- 调参建议:
./test-trans -M 64 -N 64会写出trace.f0,再用./csim-ref -v -s 5 -E 1 -b 5 -t trace.f0逐条查看命中/未命中/替换,对照矩阵状态图就能定位是哪个 tile 在互相驱逐。 -
本讲最容易踩的坑:$32\times32$ 用 8×8 分块时对角块($i=j$ 的那些 tile)会让 A 与 B 同时争同一批组,必须把一整行 8 个
int先读进寄存器再写,才能把未命中从 344 降到 288。$64\times64$ 若只用朴素 8×8 分块,结果与不做分块一模一样(都是 4724),必须有 4×4 子块 + 8 个局部变量这一层。
10.5 常见错误与调试技巧
-
忽略
M的双重访存语义:把M当成一次访问,导致 hits/misses 各少算一次。调试:用./csim-ref -v对照你-v的输出,注意M那一行会打印两次结果(如M 12,1 miss eviction hit);一个M最多带来”1 miss + 1 hit”外加一次 eviction。 -
误把
I行计入数据缓存:trace 里I行没有前导空格,sscanf若用宽松格式会把它们吃进来。调试:在解析循环里加if (op[0]=='I') continue;,并统计跳过的行数与 trace 中I行数是否一致(grep -c '^I ' traces/*.trace)。 -
lru_way初值导致”必然替换”:若lru_way初值指向一个无效行却仍走替换分支,evictions会虚高。调试:先用-s 5 -E 1 -b 5 -t traces/trans.trace验证evictions:0(此时只有 7 次未命中、远未填满),若你的输出出现 eviction 说明替换逻辑有 bug。 -
位运算溢出 / 移位数不当:
(1UL << s) - 1写成(1 << s) - 1在 $s\ge31$ 时有 UB。调试:-fsanitize=undefined编译一次,或gcc -Wall -Wextra看 shift 警告。 -
64×64 转置的”行内冲突”:只用朴素 8×8 分块时未命中恒为 4724(与朴素转置相同)。诊断命令:
./test-trans -M 64 -N 64看func行;再用./csim-ref -v -s 5 -E 1 -b 5 -t trace.f0 \| head -40观察是否每读一次 A 就紧跟一次 B 的 eviction。 -
数组越界却”看似正确”:$61\times67$ 或 $32\times32$ 的边界 tile 若忘记
k < N && l < M,会越界写坏相邻数据。调试:valgrind --tool=memcheck ./test-trans -M 61 -N 67,或gcc -fsanitize=address重编译tracegen。 -
只看命中率不看访存延迟:优化后命中率从 97% 升到 99%,有人以为”只提升 2%”。调试:改用
AMAT = 1 + 0.01×100 = 2vs1 + 0.03×100 = 4来沟通收益;或在真机上用perf stat -e L1-dcache-loads,L1-dcache-load-misses,cache-misses ./a.out直接读硬件计数器。 -
忘记
-Werror:Makefile 用-Werror -std=c99,一个未使用变量就编不过。调试:本地先gcc -Wall -Werror -std=c99 -m64 -c csim.c单独编译。
10.6 关键要点
- 缓存的全部行为都由地址位决定:$C=S\times E\times B$、$s=\log_2 S$、$b=\log_2 B$、$t=m-s-b$,”组索引用地址中段”是为了让相邻块落到不同组、从而吃满空间局部性;若改用高位索引,stride-1 的程序会疯狂冲突。
- 未命中率比命中率更能说明性能:$AMAT=\text{hit time}+\text{miss rate}\times\text{miss penalty}$,而 miss penalty 高达 50–200 周期,命中率从 97% 到 99% 会让 AMAT 直接减半。
- 3C 各有各的药:强制未命中靠”增大块”摊薄、容量未命中靠”分块”切小工作集、冲突未命中靠”提高相联度或改访问顺序”。
- 写策略是成对选择的:写直达 + 非写分配简单但费流量;写回 + 写分配省流量但需要脏位。现代处理器的 L1/L2 走后者,Cache Lab 也要求实现后者。
-
循环顺序就是性能:
ijk(1.25 miss/iter)平均比kij(0.5 miss/iter)慢 10 倍以上(本机实测 61.9 s vs 5.9 s);把内层循环改成 stride-1 往往是零成本的十倍提速。 - 分块把 $\Theta(n)$ 的未命中降到 $\Theta(1/n)$:无分块时 $n^3$ 次乘法伴随 $(9/8)n^3$ 次未命中,分块($3B^2<C$,两块输入 + 一块输出同时驻留)后降到 $n^3/(4B)$;$B$ 取满足 $3B^2<C$ 的最大值。
- $64\times64$ 转置的教训:当矩阵行字节数恰好是缓存容量的整数倍关系时,任何”规整”的分块都会出现 A、B 组冲突,必须用寄存器当暂存区、借 B 自身当临时仓库做象限对调——这是把缓存参数($s=5,b=5$,一行 8 块=8 组)算清楚之后才能想到的技巧。
10.7 思考题(带答案)
题 1(计算题:地址位域划分 + 手算未命中率)
某 32 位地址机器上的数据缓存:容量 32 KB、8 路组相联、块大小 64 B。现有访问序列(地址为十六进制,每次访问 1 字节,初始缓存全空,LRU 替换):
L 0x0000
L 0x0040
L 0x0080
L 0x00C0
L 0x0100
L 0x0000
(1) 求 $S,s,b,t,E,e$ 以及 $t$ 的位数。 (2) 逐步写出每次访问的 set / tag / 命中情况,统计 hits、misses、evictions。 (3) 若把相联度改为 $E=1$(保持容量与块大小不变),结果会怎样变化?说明了什么?
答案:
(1) $B=64 \Rightarrow b=6$;$S = C/(E\times B) = 32768/(8\times64) = 64 \Rightarrow s=6$;$E=8 \Rightarrow e=3$;$t = 32-6-6 = 20$。
(2) 块偏移取低 6 位(地址低 6 位都是 0),组索引取第 6–11 位,标记取第 12–31 位:
地址 二进制(低位示意) set = (a>>6)&0x3F tag = a>>12 结果
0x0000 ...0000 0000 0000 0 0x0 miss (set0 空)
0x0040 ...0000 0001 0000 1 0x0 miss (set1 空)
0x0080 ...0000 0010 0000 2 0x0 miss (set2 空)
0x00C0 ...0000 0011 0000 3 0x0 miss (set3 空)
0x0100 ...0000 0100 0000 4 0x0 miss (set4 空)
0x0000 ...0000 0000 0000 0 0x0 HIT (set0 里 tag0 仍在)
合计: hits:1 misses:5 evictions:0
6 次访问用了 5 个不同的组(0,1,2,3,4),组内都只有 1 行被占用,远未填满 8 路,没有任何替换。
(3) 改为 $E=1$ 时 $S = 32768/64 = 512 \Rightarrow s=9$,$t = 32-6-9 = 17$。此时组索引变成第 6–14 位:
0x0000 -> set 0, tag 0
0x0040 -> set 1, tag 0
0x0080 -> set 2, tag 0
0x00C0 -> set 3, tag 0
0x0100 -> set 4, tag 0
0x0000 -> set 0, tag 0 -> HIT (仍然命中!)
合计: hits:1 misses:5 evictions:0 (与 E=8 相同)
结论:本例中 5 个地址分散在 5 个不同的组,不构成冲突,所以相联度高低对结果没有影响——这正说明”提高相联度只对冲突未命中有效”。真正的对照组是 10.2.3 节那组 4 位地址实验:$S=4,E=1$ 时 0,1,7,8,0 得 1 命中/2 替换,$S=2,E=2$ 时同一序列得 2 命中/0 替换,多出来的那次命中就是被相联度吸收掉的冲突未命中。
题 2(计算题:AMAT)
三级存储层次:L1 命中时间 1 周期;L1 未命中率 10%;L2 命中时间 10 周期;L2 的局部未命中率 20%(即 L1 未命中中有 20% 在 L2 也失败);主存访问 200 周期。
(1) 求 AMAT。 (2) 若通过分块把 L1 未命中率降到 5%(L2 局部未命中率不变),AMAT 变成多少?相对提升多少? (3) 若只把 L2 局部未命中率从 20% 降到 5%(L1 未命中率仍为 10%),AMAT 变成多少?哪一项更值得优化?
答案:
(1) L1 未命中的代价是”访问 L2 的代价”,即
\[AMAT = 1 + 0.10\times\bigl(10 + 0.20\times200\bigr) = 1 + 0.10\times50 = 6\ \text{周期}\](2) $AMAT = 1 + 0.05\times50 = 3.5$ 周期,相对提升 $6/3.5 \approx 1.71$ 倍(时间下降 41.7%)。
(3) $AMAT = 1 + 0.10\times(10 + 0.05\times200) = 1 + 0.10\times20 = 3$ 周期,相对提升 $6/3 = 2$ 倍(时间下降 50%)。
倾向性结论:本例中优化 L2 的局部未命中率收益更大(3 周期 vs 3.5 周期),因为它削减的是被 200 周期惩罚放大的那一项。但工程上更常见的做法是先优化 L1(分块、循环重排都是零成本改动),因为 $0.10\times50$ 里的 0.10 通常是程序员唯一能直接控制的因子。
题 3(”直观但错误的想法”)
同学 A 说:”既然缓存有 32 KB,我的 $64\times64$ 矩阵一共才 16 KB,完全可以全放进缓存,所以转置时怎么写都不会有冲突未命中。”同学 B 说:”那我只要把块大小从 64 B 调到 8 KB,一次取一整行,stride-$n$ 的遍历也不会未命中了。”
请分别指出两人的错误。
答案:
- 同学 A 错在忽略了相联度。这 16 KB 是能装下,但 $(s=5,E=1,b=5)$ 的缓存只有 32 组、每组 1 行:组号 $=\lfloor addr/32\rfloor \bmod 32$,所以地址相差 $32\times32=1024$ 字节的两个块必然落在同一组。$64\times64$ 矩阵一行 256 B = 8 个块占 8 个连续组,第 $i$ 行与第 $i+4$ 行落同一批组(本讲 10.3.3 节有完整推导)。容量够 ≠ 不冲突,这正是 conflict miss 的定义:”缓存够大,但太多对象映射到同一小组行上”。实测:朴素的 8×8 分块在 $64\times64$ 上仍是 4724 次未命中,和完全不分块一样。
- 同学 B 错在混淆了”块大小”与”缓存容量”。把 $B$ 从 64 B 提高到 8 KB 会把 $S = C/(E\times B)$ 从 64 降到 0.5——缓存根本装不下几行,块变大是以”行数变少”为代价的。而且块太大时,stride-$n$ 的遍历每次只用一个字节、其余全浪费,空间局部性并没有真正利用起来;同时块传输时间变长(”一次取一整行”要占满总线)。正确做法是分块(blocking):不改缓存参数,而是改程序的访问顺序,让工作集在现有缓存容量内被重复使用。
题 4(”直观但错误的想法”)
“写直达(write-through)每次写都要访问内存,肯定比写回(write-back)差,所以写回永远是更好的选择。”这个说法错在哪?
答案:错在把”流量”当成唯一成本。
-
写回需要脏位与更复杂的替换逻辑:每行多一个
d位,替换时必须先判断”是否是脏行”、必要时整块写回,控制器状态机比写直达复杂;写直达行则永远可以随意丢弃。 -
写回的收益依赖”写局部性”:只有当同一行的多个字被反复改写时,写回才省流量(多次写合并成一次块写回)。若程序是”写遍全表、每个字只写一次”的流式写(如大规模
memset、流式拷贝),写回反而要先把整块读进来(写分配),再整块写回去,流量翻倍——这类场景写直达 + 非写分配更合适。 - 多级一致性:写回意味着下层可能持有过期副本,多核/多级缓存之间需要额外的失效与监听(snooping)协议;写直达则天然让下层保持最新(代价是流量)。
- 讲义给出的正是这两种成对的典型组合——写直达 + 非写分配(简单、适合流式写)与写回 + 写分配(现代通用处理器 L1/L2 的选择)。把”写回总是更好”绝对化,会漏掉”写回必须搭配写分配、且依赖写局部性”这个前提。Cache Lab 的模拟器选择实现后者,只是因为它更复杂、更有教学价值,不是因为它在所有场景下都更快。