缓存未命中时,CPU 需要等待 100+ 周期才能从 DRAM 获取数据。但如果 CPU 能在你需要数据之前就把它加载到缓存,等你访问时数据已经在缓存中了——这就是预取(Prefetching)。
预取是隐藏内存延迟的最后一道防线。硬件预取器自动检测访问模式并提前加载,软件预取则由程序员显式指定。两者各有适用场景,也各有陷阱。
一、为什么需要预取?
1.1 缓存未命中的延迟
即使有乱序执行,缓存未命中仍然会阻塞依赖链上的后续指令。预取的目标是:在数据被需要之前就把它加载到缓存,消除缓存未命中。
1.2 预取的挑战
预取不是万能的——它面临三个挑战:
- 准确性:预取的数据是否真的会被使用?预取无用数据浪费带宽和缓存空间
- 时效性:预取是否足够提前?太晚等于没预取,太早可能被驱逐
- 带宽压力:预取增加了内存带宽消耗,可能挤占正常访问的带宽
二、硬件预取器
2.1 Stream 预取器
Stream 预取器检测顺序访问模式:当 CPU 连续访问同一缓存行的后续行时,预取器自动预取后续的缓存行。
Stream 预取器的参数:
| 参数 | 典型值 | 含义 |
|---|---|---|
| 预取距离 | 4-8 行 | 预取多少行之后的缓存行 |
| 预取宽度 | 1-2 行 | 每次预取多少行 |
| 触发阈值 | 2-3 次连续访问 | 检测到顺序模式需要多少次访问 |
2.2 Stride 预取器
Stride 预取器检测固定步长访问模式:当 CPU 以固定步长访问内存时(如结构体字段遍历),预取器自动预取后续地址。
struct Particle { float x, y, z; // 12 字节 int id; // 4 字节 char padding[48]; // 填充到 64 字节};
// 遍历所有 x 值for (int i = 0; i < N; i++) { sum += particles[i].x; // 步长 = 64 字节}// Stride 预取器检测到步长 64,自动预取后续行2.3 Spatial 预取器
Spatial 预取器在访问一个缓存行时,自动预取相邻的缓存行(通常是同一 4KB 页内的相邻行)。
2.4 复杂预取器
现代 CPU 的预取器越来越复杂:
| CPU | 预取器类型 | 特点 |
|---|---|---|
| Intel Skylake | Stream + Stride + Spatial | 多种预取器并行 |
| AMD Zen 4 | Stream + Stride | 类似 Intel |
| Apple M1 | Stream + Stride + 专用 | 更激进的预取 |
预取器是 CPU 微架构中最”神秘”的部分之一——厂商很少公开预取器的具体实现细节。预取器的行为可以通过性能计数器间接观察。
2.5 硬件预取器的局限性
硬件预取器对以下访问模式无效:
| 访问模式 | 预取器行为 | 原因 |
|---|---|---|
| 顺序访问 | 有效 | Stream 预取器 |
| 固定步长 | 有效 | Stride 预取器 |
| 随机访问 | 无效 | 无规律可循 |
| 链表遍历 | 无效 | 地址不连续 |
| 间接索引 | 无效 | 地址不确定 |
| 复杂模式 | 无效 | 超出预取器检测能力 |
三、软件预取
3.1 GCC 内建预取指令
// __builtin_prefetch(addr, rw, locality)// rw: 0 = 读预取, 1 = 写预取// locality: 0 = 低(可被驱逐), 1 = 中, 2 = 高, 3 = 保留
// 读预取,低局部性(预取到 L2)__builtin_prefetch(&arr[i + 16], 0, 0);
// 读预取,高局部性(预取到 L1)__builtin_prefetch(&arr[i + 16], 0, 3);
// 写预取__builtin_prefetch(&dst[i], 1, 0);3.2 x86 预取指令
| 指令 | 预取目标 | 说明 |
|---|---|---|
PREFETCHT0 | L1 + L2 + L3 | 所有缓存层级 |
PREFETCHT1 | L2 + L3 | 不加载到 L1 |
PREFETCHT2 | L3 | 只加载到 L3 |
PREFETCHNTA | L2(非临时) | 不污染缓存 |
3.3 ARM 预取指令
| 指令 | 预取目标 | 说明 |
|---|---|---|
PRFM PLDL1STRM | L1 | 流式预取到 L1 |
PRFM PLDL2STRM | L2 | 流式预取到 L2 |
PRFM PLDL3STRM | L3 | 流式预取到 L3 |
3.4 软件预取的适用场景
场景 1:链表遍历
// 链表遍历:硬件预取无效,软件预取有效struct Node { int data; struct Node *next;};
void traverse_list(struct Node *head) { struct Node *curr = head; while (curr) { __builtin_prefetch(curr->next, 0, 1); // 预取下一个节点 process(curr->data); curr = curr->next; }}场景 2:间接索引
// 间接索引:硬件预取无效int *indices = ...; // 索引数组float *data = ...; // 数据数组
for (int i = 0; i < N; i++) { __builtin_prefetch(&data[indices[i + 8]], 0, 0); // 预取 8 步之后的数据 sum += data[indices[i]];}场景 3:矩阵分块
// 矩阵乘法分块中的预取void matmul_prefetch(double *C, double *A, double *B, int N) { for (int i = 0; i < N; i++) { for (int k = 0; k < N; k++) { __builtin_prefetch(&B[(k+4) * N], 0, 1); // 预取 B 的后续行 for (int j = 0; j < N; j++) { C[i*N+j] += A[i*N+k] * B[k*N+j]; } } }}3.5 软件预取的陷阱
软件预取的常见陷阱:
预取太早:数据到达后被驱逐,等于没预取
预取太晚:数据还没到达就访问了,等于没预取
预取无用数据:浪费带宽和缓存空间
预取污染缓存:把有用的数据驱逐了
预取增加代码复杂度:维护成本高
3.6 预取距离的计算
预取距离 = 内存延迟 / 每次迭代的计算时间
// 假设:// - 内存延迟 = 200 周期// - 每次迭代 = 5 周期(计算密集)// - 预取距离 = 200 / 5 = 40 次迭代
for (int i = 0; i < N; i++) { __builtin_prefetch(&arr[i + 40], 0, 0); // 预取 40 步之后的数据 sum += arr[i] * factor; // 5 周期计算}四、预取与缓存的交互
4.1 预取对缓存的压力
预取数据占用缓存空间,可能驱逐有用的数据:
4.2 非临时预取(Non-Temporal)
对于只使用一次的数据(如流式处理),使用 PREFETCHNTA 或非临时存储指令(_mm_stream_si128)避免污染缓存:
// 非临时存储:直接写入内存,不经过缓存void stream_store(float *dst, float *src, int n) { for (int i = 0; i < n; i += 4) { __m128 data = _mm_load_ps(src + i); _mm_stream_ps(dst + i, data); // 非临时存储 } _mm_sfence(); // 确保所有非临时存储完成}4.3 预取与 L3 缓存
预取通常将数据加载到 L2 或 L3,而非 L1。原因:
- L1 容量小(32KB),预取数据容易驱逐有用数据
- L2 容量适中(256KB-1MB),预取数据影响较小
- 预取到 L2 后,后续访问只需 L2→L1 的延迟(~4 周期),远小于 DRAM→L1(~200 周期)
五、预取的性能实测
5.1 顺序访问:硬件预取足够
// 顺序访问:硬件预取器自动工作,无需软件预取for (int i = 0; i < N; i++) { sum += arr[i]; // Stream 预取器自动预取后续行}// 添加软件预取反而可能降低性能(增加指令开销)5.2 链表遍历:软件预取有效
#include <stdio.h>#include <stdlib.h>#include <time.h>
struct Node { int data; struct Node *next; };
void traverse_no_prefetch(struct Node *head) { struct Node *curr = head; while (curr) { curr = curr->next; // 每次都是随机访问 }}
void traverse_with_prefetch(struct Node *head) { struct Node *curr = head; while (curr) { __builtin_prefetch(curr->next, 0, 1); curr = curr->next; }}
int main() { // 构建链表 struct Node *head = NULL, *prev = NULL; for (int i = 0; i < 10000000; i++) { struct Node *node = malloc(sizeof(struct Node)); node->data = i; node->next = NULL; if (prev) prev->next = node; else head = node; prev = node; }
clock_t start = clock(); traverse_no_prefetch(head); clock_t end = clock(); printf("无预取: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
start = clock(); traverse_with_prefetch(head); end = clock(); printf("有预取: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
return 0;}六、控制硬件预取器
6.1 禁用/调整硬件预取器
某些场景下,硬件预取器可能有害(如随机访问模式导致预取无用数据):
# Intel CPU:通过 MSR 寄存器控制预取器# 需要安装 msr-toolssudo modprobe msr
# 禁用 L2 硬件预取器(bit 0 = prefetch enable)sudo wrmsr 0x1a4 0x0 # 禁用所有预取器
# 查看当前预取器状态sudo rdmsr 0x1a4
# 重新启用sudo wrmsr 0x1a4 0xf6.2 禁用预取器的场景
| 场景 | 预取器行为 | 建议 |
|---|---|---|
| 顺序遍历 | 有效 | 保持启用 |
| 随机访问 | 无效但无害 | 保持启用 |
| 部分顺序 | 预取无用数据 | 可能禁用 |
| 带宽受限 | 预取挤占带宽 | 可能禁用 |
七、动手实验
7.1 实验 1:观察硬件预取的效果
# 对比有无硬件预取的性能# 正常模式(预取器启用)perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads ./sequential_test
# 禁用预取器后sudo wrmsr 0x1a4 0x0perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads ./sequential_testsudo wrmsr 0x1a4 0xf # 重新启用7.2 实验 2:软件预取的效果
# 编译链表遍历测试gcc -O2 -o prefetch_test prefetch_test.c
# 对比有无软件预取perf stat -e L1-dcache-loads,L1-dcache-load-misses ./prefetch_test7.3 实验 3:预取距离的优化
// 测试不同预取距离的效果for (int dist = 4; dist <= 64; dist *= 2) { start = clock(); for (int i = 0; i < N; i++) { __builtin_prefetch(&arr[i + dist], 0, 0); sum += arr[i]; } end = clock(); printf("预取距离 %d: %.3f 秒\n", dist, (double)(end - start) / CLOCKS_PER_SEC);}八、硬件预取器类型详解
8.1 Intel CPU 的预取器架构
Intel CPU 通常有 4 个硬件预取器,可以通过 MSR 寄存器独立控制:
| 预取器 | 位置 | 检测模式 | MSR 控制位 |
|---|---|---|---|
| L2 Stream Prefetcher | L2 | 顺序访问(正/反方向) | bit 0 of MSR 0x1A4 |
| L2 Spatial Prefetcher | L2 | 相邻缓存行对 | bit 1 of MSR 0x1A4 |
| L1 Data Prefetcher | L1 | 顺序访问 | bit 2 of MSR 0x1A4 |
| L1 IP Prefetcher | L1 | 基于指令地址的步长 | bit 3 of MSR 0x1A4 |
8.2 AMD Zen 的预取器
AMD Zen 架构的预取器与 Intel 类似,但实现细节不同:
| 预取器 | 检测模式 | 特点 |
|---|---|---|
| L1 Stream | 顺序访问 | 更激进的预取距离 |
| L1 Stride | 固定步长 | 基于指令地址追踪 |
| L2 Stream | 顺序访问 | 预取到 L2 |
| L2 Up/Down | 正/反方向 | 双向流式预取 |
8.3 预取器的自适应行为
现代预取器不是静态的——它们会根据运行时行为自适应调整:
- 预取置信度:预取器为每个流维护一个置信度计数器,只有置信度足够高时才预取
- 预取节流:当缓存未命中率升高时,预取器自动降低预取强度
- 训练窗口:预取器需要 2-3 次连续访问才能”学习”到模式
置信度计数器示例:访问 1: 置信度 0 → 不预取访问 2: 置信度 1 → 不预取(可能只是巧合)访问 3: 置信度 2 → 开始预取 1 行访问 4: 置信度 3 → 预取 2 行访问 5+: 置信度 4+ → 预取 4 行(最大预取距离)九、软件预取 Intrinsics 详解
9.1 x86 预取指令的完整列表
| 指令 | Intrinsics | 预取目标 | 缓存污染 | 适用场景 |
|---|---|---|---|---|
PREFETCHT0 | _mm_prefetch(p, _MM_HINT_T0) | L1+L2+L3 | 高 | 即将使用 |
PREFETCHT1 | _mm_prefetch(p, _MM_HINT_T1) | L2+L3 | 中 | 短期使用 |
PREFETCHT2 | _mm_prefetch(p, _MM_HINT_T2) | L3 | 低 | 中期使用 |
PREFETCHNTA | _mm_prefetch(p, _MM_HINT_NTA) | L2(非临时) | 最低 | 只用一次 |
9.2 ARM 预取指令
// ARM 预取 intrinsics// PRFM (Prefetch Memory) 指令的编码格式// PRFM <prfop>, [<Xn|SP>{, #<pimm>}]
// 读预取到 L1__builtin_prefetch(addr, 0, 3); // GCC 内建
// ARM NEON intrinsics 中的预取// v64int8 *ptr;// __asm__ __volatile__("prfm pldl1strm, [%0]" : : "r"(ptr));9.3 缓存行对齐与预取
预取以缓存行(64 字节)为单位。如果数据没有缓存行对齐,一次预取可能跨越两个缓存行:
// 未对齐:预取可能需要加载 2 个缓存行struct BadAlign { int data; // 偏移 60,跨越缓存行边界!};// 如果结构体起始地址 % 64 = 60,则 data 跨越两个缓存行
// 缓存行对齐:一次预取正好一个缓存行struct GoodAlign { int data;} __attribute__((aligned(64)));
// 检查对齐assert(((uintptr_t)&arr[0] & 63) == 0); // 确保缓存行对齐9.4 预取优化实战案例
案例:B+ 树查找的软件预取
// B+ 树节点查找:预取下一个节点struct btree_node { int keys[ORDER-1]; struct btree_node *children[ORDER]; int num_keys; int is_leaf;};
int btree_search(struct btree_node *root, int key) { struct btree_node *node = root; while (node) { int i = 0; while (i < node->num_keys && key > node->keys[i]) i++;
if (i < node->num_keys && key == node->keys[i]) return 1;
if (node->children[i]) { // 预取下一层节点——B+ 树遍历是链式依赖 // 不预取的话,每次都要等缓存未命中 __builtin_prefetch(node->children[i], 0, 1); } node = node->children[i]; } return 0;}案例:图遍历的软件预取
// BFS 图遍历:预取邻居节点void bfs_prefetch(int *graph, int *visited, int start, int n) { int queue[n], head = 0, tail = 0; queue[tail++] = start; visited[start] = 1;
while (head < tail) { int node = queue[head++]; int neighbor = graph[node * 2]; // 邻居列表偏移 int degree = graph[node * 2 + 1]; // 度数
// 预取邻居节点的数据 for (int i = 0; i < degree && i < 8; i++) { __builtin_prefetch(&graph[(neighbor + i) * 2], 0, 0); __builtin_prefetch(&visited[neighbor + i], 0, 0); }
for (int i = 0; i < degree; i++) { int nb = graph[neighbor + i]; if (!visited[nb]) { visited[nb] = 1; queue[tail++] = nb; } } }}软件预取的收益高度依赖硬件和编译器版本。在 x86 上有效的预取距离,在 ARM 上可能完全不同。建议在目标硬件上实测,而不是凭直觉设置预取距离。使用 perf stat -e L1-dcache-load-misses 对比有无预取的缓存未命中率。
十、小结
上一章建立了NUMA 架构与内存局部性的认知框架。
| 概念 | 要点 | 对软件的影响 |
|---|---|---|
| Stream 预取器 | 检测顺序访问 | 数组遍历自动加速 |
| Stride 预取器 | 检测固定步长 | 结构体字段遍历 |
| 软件预取 | 显式指定预取 | 链表/间接索引 |
| 预取距离 | 内存延迟/迭代时间 | 太早或太晚都无效 |
| 预取污染 | 预取驱逐有用数据 | PREFETCHNTA 避免污染 |
| 预取带宽 | 预取挤占正常带宽 | 带宽受限时需谨慎 |
下一步:性能计数器与 Top-Down 分析——如何用 perf 精确定位 CPU 瓶颈?Top-Down 方法论如何系统化地分析性能问题?
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






