一、Cache 的由来
1. 为什么需要Cache
解决计算机系统中一个核心矛盾:处理器的高速运算能力与相对低速的主存储器(如内存)访问速度之间的巨大差距。简单来说,CPU 太快了,而内存太慢了,如果每次 CPU 需要数据或指令都直接访问内存(DRAM),CPU 大部分时间都会在等待,造成巨大的性能浪费。 缓存作为 CPU 和主存之间的高速缓冲区,存储最近或频繁使用的数据,当 CPU 需要数据时,如果能从比内存快得多的缓存中找到(命中),就能极大地减少等待时间,显著提升系统整体性能。
2. 设计基础:局部性原理
【时间局部性】如果一个数据项被访问,那么在不久的将来它很可能再次被访问,因为程序中存在循环。
【空间局部性】如果一个数据项被访问,那么与它地址相近的数据项很可能很快也会被访问,因为指令通常是顺序存放,顺序执行的。
看下面一个程序示例:
如果数据在内存中的存放顺序如下:
a[0][0] -> a[0][1] -> a[0][2] -> a[0][3]
对于程序A
sum = a[0][0] + a[0][1] + a[0][2] + a[0][3]
对于程序B]
sum = a[0][0] + a[1][0] + a[2][0] + a[3][0]
可以看出,程序A访问数据顺序和存储顺序一致,空间局部性更好。 Cache(SRAM)就是通过利用程序访问的局部性原理,在高速但容量小的存储介质中保留低速但容量大的主存(如 DRAM)数据副本,从而大幅减少 CPU 访问主存的延迟。
3. 实际系统中的 Cache
整个系统的存储架构包括了 CPU 的寄存器,L1/L2/L3 CACHE,DRAM 和硬盘。数据访问时先找寄存器,寄存器里没有找 L1 Cache, L1 Cache 里没有找 L2 Cache,依次类推,最后找到主存中。可以看到,速度与存储容量的折衷关系。容量越小,访问速度越快,同时价格也越贵。
二、Cache的工作原理
1. Cache和主存的映射方式
主存和 Cache 之间以固定大小的块(Cache Line)为单位传输数据(常见 64 字节)。主存被划分为若干块,每个块通过地址映射到 Cache 的特定位置。
Cache 的本质:映射关系 + 数据副本
Cache 既存储主存数据的副本,也维护这些副本与主存地址的映射关系。Cache和主存的映射方式有三种:
- 直接映射(Direct mapped):主存中的每一个数据块只能被放置在缓存中唯一一个特定的位置。这种映射关系是通过一个固定的数学计算(通常是取模运算)来确定的。
- 全相连(Fully associative):主存中的任何一个数据块可以被放置到缓存中的任意一个空闲行(Cache Line)。
- 组相连(set associative):是直接映射缓存和全相联缓存的折中方案,可以放在Cache的某几行。
映射方式对性能的影响
2. 数据查找过程
2.1 直接映射(Direct mapped)
主存地址划分:为了找到主存地址 Addr 对应的数据在缓存中的位置(如果存在),需要将 Addr 分解为三个部分:
标记 (Tag): 地址的最高有效位部分。用于唯一标识一个可能映射到该缓存行的主存块范围。
索引 (Index):地址的中间部分。直接决定了这个主存块应该被放置在缓存中的哪个行(行号)。索引字段的位数决定了缓存有多少行(S = 2^索引位数)。
块内偏移 (Block Offset): 地址的最低有效位部分。用于定位数据块内具体的字节。偏移字段的位数决定了数据块的大小(B = 2^偏移位数)。
直接映射缓存结构划分:缓存被划分为 S 个缓存行。每个缓存行包含:
有效位 (Valid bit): 标记该行中的数据是否有效(1=有效,0=无效)。
标记位 (Tag):用于标识当前存储在该行中的数据块来自主存的哪个区域。标记位的长度取决于主存地址空间的大小和缓存行数。
数据块 (Data Block):实际存储从主存复制过来的数据。块大小通常是 B 字节(例如 32B, 64B)。
数据查找过程
a. 提取索引: CPU 给出要访问的内存地址 Addr。硬件首先提取出 Index 字段。
b. 定位缓存行: 使用 Index 的值作为行号,直接定位到缓存中唯一对应的那一行。
c. 检查有效位和标记:
检查该行的 Valid bit 是否为 1(有效)。
比较该行的 Tag 字段与地址 Addr 中的 Tag 字段是否完全一致。
d. 判断命中/缺失:
命中 (Cache Hit): 如果 Valid bit = 1 且 Tag 匹配,则表示所需数据就在这个缓存行中。然后根据 Block Offset 从数据块中取出目标字节/字,返回给 CPU。
缺失 (Cache Miss): 如果 Valid bit = 0 或 Tag 不匹配,则表示所需数据不在缓存中(缓存缺失)。
2.2 全相连(Fully associative)
缓存结构,每个缓存行包含:
有效位 (Valid bit): 标记该行数据是否有效。
标记位 (Tag): 完整的主存块地址(因为无索引字段)。用于唯一标识该行存储的是哪个主存块。
数据块 (Data Block): 存储实际数据。
脏位 (Dirty bit): (用于写回策略)。
替换信息位 (Replacement Information): (如LRU位)记录行的使用情况。
主存地址划分:
主存地址 Addr 被划分为仅两部分:
标记 (Tag): 地址的绝大部分(甚至全部)。因为不再需要索引(Index)字段,Tag 直接对应完整的主存块起始地址(除块内偏移外的部分)。
块内偏移 (Block Offset): 地址的最低有效位,定位数据块内的字节。
数据查找过程
a. CPU给出地址 Addr。
b. 并行查找与比较:
同时读出缓存中所有 M 个行的 Valid bit 和 Tag 字段。
将 Addr 中的完整 Tag 与所有 M 个行的 Tag 进行并行比较。
同时检查每个行的 Valid bit。
c. 判断命中/缺失:
命中 (Cache Hit): 如果在任何一行找到 Valid=1 且 Tag 完全匹配,则命中。根据 Block Offset 取出数据。
缺失 (Cache Miss): 如果没有任何一行同时满足 Valid=1 和 Tag 匹配,则缺失。
2.3 组相连(set associative)
主存地址划分和直接映射主存地址划分一致。 组相连缓存结构:缓存被组织成一个二维结构:
行 (Rows): 物理上的缓存行。
组 (Sets): 逻辑上的分组,每组包含 N 个行。
每个缓存行包含:
有效位 (Valid bit): 标记该行数据是否有效。
标记位 (Tag): 标识主存块的来源。
数据块 (Data Block): 存储实际数据。
脏位 (Dirty bit): (可选,用于写回策略)标记该行数据是否被修改过。
替换信息位 (Replacement Information): (如LRU位)记录组内各行的使用情况,用于决定替换哪一行。
数据查找过程
a. 提取组索引: CPU给出地址 Addr,硬件提取 Set Index。
b. 定位组: 使用 Set Index 的值找到唯一对应的那个组(Set)。
c. 组内并行查找与比较:
同时读出该组内 N 个缓存行的 Valid bit 和 Tag 字段。
将 Addr 中的 Tag 与组内 N 个行的 Tag 进行并行比较。
同时检查每个行的 Valid bit。
d. 判断命中/缺失:
命中 (Cache Hit): 如果在组内找到任何一行满足 Valid=1 且 Tag 匹配,则表示命中。然后根据 Block Offset 从该命中行的数据块中取出数据。
缺失 (Cache Miss): 如果在组内没有任何一行同时满足 Valid=1 和 Tag 匹配,则表示缺失。
3. Cache数据的替换
3.1 替换时机
- 缓存未命中(Cache Miss):CPU 读写的地址不在缓存中,需要从主存加载。
- 缓存已满:目标数据映射到的缓存组(Set)所有行(Way)均被有效数据占用(无空闲行)。
3.2 替换策略
- 最近最少使用(LRU - Least Recently Used):优先替换最久未被访问的缓存行。
- 伪LRU(Pseudo-LRU):用二叉树等简化结构近似 LRU,牺牲精度换低成本。
- 先进先出(FIFO - First-In First-Out):替换最早进入缓存的行(按加载时间排序)。
- 随机替换(Random):随机选择组内一行替换。
- 最不经常使用(LFU - Least Frequently Used):替换访问次数最少的行(统计历史频率)。
不同层级缓存的策略选择
补充:现代 CPU(如 Intel/AMD)L3 Cache 普遍使用 类随机策略(如 DRRIP)或 动态策略(根据程序行为调整)。
4. Cache和主存数据的一致性
Cache 是临时缓存。如果发生了写操作,就会造成Cache和主存中的数据不一致,那如何保证写数据操作正确?
4.1 写直达(Write-Through)
CPU 写 Cache 时同步写主存,保持两者实时一致。
缺点:
- 每次写操作都访问慢速主存 → 性能瓶颈(如写入频繁时带宽饱和)。
- 不适用现代高性能 CPU(仅用于特殊场景如 GPU 显存管理)。
4.1 写回(Write-Back) (主流方案)
写操作只更新 Cache,并标记为 脏(Dirty)。脏行仅在替换时或强制冲刷时写回主存。
减少主存访问次数(合并多次写操作)可以让CPU性能显著提升。
5. 命中率
Cache 命中率(Cache Hit Rate) 是衡量缓存系统效率的核心指标,定义为缓存命中次数占总访问次数的百分比,它直接反映了缓存减少低速存储访问的有效性,计算公式为:
示例
测量命中率
测试程序
#include <stdio.h>
#include <stdlib.h>
#define SIZE 10000
#define ITERS 100
int main() {
int *arr = malloc(SIZE * SIZE * sizeof(int));
volatile long long sum = 0;
// 初始化数组
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
arr[i*SIZE + j] = i + j;
}
}
// 场景一:行优先访问测试
#if 1
for (int iter = 0; iter < ITERS; iter++) {
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
sum += arr[i*SIZE + j];
}
}
}
#endif
#if 0
// 场景二:列优先访问测试
for (int iter = 0; iter < ITERS; iter++) {
for (int j = 0; j < SIZE; j++) {
for (int i = 0; i < SIZE; i++) {
sum += arr[i*SIZE + j];
}
}
}
#endif
free(arr);
return 0;
}
测试方式
// Ubuntu 上需要调整文件控制性能监控的访问权限
sudo sh -c "echo -1 > /proc/sys/kernel/perf_event_paranoid"
perf stat -e cache-misses,cache-references ./program
测试结果
场景一
场景二
ubuntu 上测试没有体现出来,但是可以看到行优先访问所花费的时钟周期要远小于列访问的。
典型命中率参考值
关键洞察:L1 命中率 < 90% 或 L3 命中率 < 70% 通常表明程序有优化空间!
三、Cache一致性
1. Cache一致性问题根源
2. 硬件解决方案:缓存一致性协议
通过状态机+总线消息维护一致性,定义4种缓存行状态:
3. 总线通信机制
3.1 总线嗅探(Bus Snooping)
原理:所有核心监听总线消息,发现地址冲突时响应请求。
消息类型:
3.2 点对点互联(现代替代方案)
问题:总线嗅探在核心数>32时带宽瓶颈严重。 目录协议(Directory Protocol):中央目录记录缓存行状态(如 Intel Mesh, AMD Infinity Fabric)。 优势:仅通知涉及的核心,减少广播流量。
4. 软件协同机制
4.1 内存屏障(Memory Barrier)
解决指令重排导致的一致性问题:
// 核心A:
store X = 1;
store_barrier(); // 确保X写入先于flag
store flag = 1;
// 核心B:
while (load flag != 1); // 等待flag
load_barrier(); // 确保flag读取后获取最新X
load value = X; // 此时X必定为1
4.2 缓存维护指令
5. 总结
硬件基石:MESI/MOESI 协议 + 总线/目录通信 → 自动化解决多数一致性问题。 软件关键:内存屏障 + 缓存维护指令 → 处理极端场景和持久化。 性能核心:避免伪共享 + 减少共享写入 → 降低协议开销。
四、调试方式
1. 查看系统缓存信息
# CPU 缓存拓扑
lscpu | grep cache
# 详细缓存信息
cat /sys/devices/system/cpu/cpu0/cache/index*/size
cat /sys/devices/system/cpu/cpu0/cache/index*/ways_of_associativity
2. perf
// 列出所有可监控的缓存相关事件
perf list | grep -E 'cache|TLB'
// 基本用法
perf stat -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-loads,LLC-load-misses,LLC-loads,dTLB-load-misses,dTLB-loads ./your_program
// 实时监控动态查看缓存事件变化
perf top -e cache-misses,cache-references
// 检测伪共享(False Sharing)
perf c2c record ./your_program
perf c2c report --stdio
// 分析内存访问模式
perf mem record ./your_program
perf mem report --stdio
3. ftrace - 内核函数跟踪
// 启用缓存相关跟踪
echo 1 > /sys/kernel/debug/tracing/events/kmem/mm_page_alloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/mm_page_free/enable
// 捕获数据
cat /sys/kernel/debug/tracing/trace_pipe
4. valgrind + cachegrind
valgrind 的 cachegrind 工具是一个强大的缓存和分支预测模拟器,它通过动态二进制插桩技术模拟程序执行期间的缓存行为。
# 模拟缓存层次结构
valgrind --tool=cachegrind ./your_program
# 生成详细报告
cg_annotate cachegrind.out.<pid>
关键指标:
Ir:指令读取
I1mr:L1指令缓存未命中
D1mr:L1数据缓存未命中
LLmr:末级缓存未命中
基于第二节中命中率测试结果如下:
列访问
行访问
可以看到 D1 miss rate 及 L1 Data Cache 在行访问中为2.2%,列访问为32.5%。