mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2183 字
6 分钟
硬件预取与软件预取
2026-02-10

缓存未命中时,CPU 需要等待 100+ 周期才能从 DRAM 获取数据。但如果 CPU 能在你需要数据之前就把它加载到缓存,等你访问时数据已经在缓存中了——这就是预取(Prefetching)

预取是隐藏内存延迟的最后一道防线。硬件预取器自动检测访问模式并提前加载,软件预取则由程序员显式指定。两者各有适用场景,也各有陷阱。

一、为什么需要预取?#

1.1 缓存未命中的延迟#

即使有乱序执行,缓存未命中仍然会阻塞依赖链上的后续指令。预取的目标是:在数据被需要之前就把它加载到缓存,消除缓存未命中

flowchart LR subgraph 无预取["无预取"] A1["访问数据"] --> A2["缓存未命中!"] --> A3["等待 200 周期"] --> A4["数据到达"] end subgraph 有预取["有预取"] B1["预取指令<br/>提前 200 周期"] --> B2["数据到达缓存"] --> B3["访问数据"] --> B4["缓存命中!"] end style 无预取 fill:#ffcdd2,stroke:#c62828 style 有预取 fill:#e8f5e9,stroke:#2e7d32

1.2 预取的挑战#

预取不是万能的——它面临三个挑战:

  1. 准确性:预取的数据是否真的会被使用?预取无用数据浪费带宽和缓存空间
  2. 时效性:预取是否足够提前?太晚等于没预取,太早可能被驱逐
  3. 带宽压力:预取增加了内存带宽消耗,可能挤占正常访问的带宽

二、硬件预取器#

2.1 Stream 预取器#

Stream 预取器检测顺序访问模式:当 CPU 连续访问同一缓存行的后续行时,预取器自动预取后续的缓存行。

sequenceDiagram participant CPU participant 预取器 participant L2缓存 participant DRAM CPU->>L2缓存: 访问地址 A Note over 预取器: 检测到顺序访问 CPU->>L2缓存: 访问地址 A+64 预取器->>DRAM: 预取 A+128, A+192 DRAM-->>L2缓存: 数据到达 CPU->>L2缓存: 访问地址 A+128 命中! CPU->>L2缓存: 访问地址 A+192 命中!

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 SkylakeStream + Stride + Spatial多种预取器并行
AMD Zen 4Stream + Stride类似 Intel
Apple M1Stream + Stride + 专用更激进的预取
Note

预取器是 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 预取指令#

指令预取目标说明
PREFETCHT0L1 + L2 + L3所有缓存层级
PREFETCHT1L2 + L3不加载到 L1
PREFETCHT2L3只加载到 L3
PREFETCHNTAL2(非临时)不污染缓存

3.3 ARM 预取指令#

指令预取目标说明
PRFM PLDL1STRML1流式预取到 L1
PRFM PLDL2STRML2流式预取到 L2
PRFM PLDL3STRML3流式预取到 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 软件预取的陷阱#

Warning

软件预取的常见陷阱:

  1. 预取太早:数据到达后被驱逐,等于没预取

  2. 预取太晚:数据还没到达就访问了,等于没预取

  3. 预取无用数据:浪费带宽和缓存空间

  4. 预取污染缓存:把有用的数据驱逐了

  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 预取对缓存的压力#

预取数据占用缓存空间,可能驱逐有用的数据:

graph TB subgraph 预取污染["预取污染缓存"] P1["预取数据 A"] --> P2["驱逐有用数据 B"] P2 --> P3["后续访问 B → 缓存未命中"] end subgraph 预取优化["预取不污染"] O1["PREFETCHNTA<br/>非临时预取"] --> O2["只加载到 L2<br/>不驱逐 L1 的有用数据"] end style 预取污染 fill:#ffcdd2,stroke:#c62828 style 预取优化 fill:#e8f5e9,stroke:#2e7d32

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-tools
sudo modprobe msr
# 禁用 L2 硬件预取器(bit 0 = prefetch enable)
sudo wrmsr 0x1a4 0x0 # 禁用所有预取器
# 查看当前预取器状态
sudo rdmsr 0x1a4
# 重新启用
sudo wrmsr 0x1a4 0xf

6.2 禁用预取器的场景#

场景预取器行为建议
顺序遍历有效保持启用
随机访问无效但无害保持启用
部分顺序预取无用数据可能禁用
带宽受限预取挤占带宽可能禁用

七、动手实验#

7.1 实验 1:观察硬件预取的效果#

# 对比有无硬件预取的性能
# 正常模式(预取器启用)
perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads ./sequential_test
# 禁用预取器后
sudo wrmsr 0x1a4 0x0
perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads ./sequential_test
sudo 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_test

7.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 PrefetcherL2顺序访问(正/反方向)bit 0 of MSR 0x1A4
L2 Spatial PrefetcherL2相邻缓存行对bit 1 of MSR 0x1A4
L1 Data PrefetcherL1顺序访问bit 2 of MSR 0x1A4
L1 IP PrefetcherL1基于指令地址的步长bit 3 of MSR 0x1A4
flowchart TB subgraph L1["L1 缓存层"] L1DP["L1 Data Prefetcher<br/>检测顺序访问模式"] L1IP["L1 IP-based Prefetcher<br/>基于 PC 的步长检测"] end subgraph L2["L2 缓存层"] L2SP["L2 Stream Prefetcher<br/>检测流式访问"] L2SPT["L2 Spatial Prefetcher<br/>预取相邻缓存行"] end L1DP --> L1_CACHE["L1 D-Cache"] L1IP --> L1_CACHE L2SP --> L2_CACHE["L2 Cache"] L2SPT --> L2_CACHE style L1 fill:#e3f2fd,stroke:#1565c0 style L2 fill:#fff9c4,stroke:#f9a825

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;
}
}
}
}
Warning

软件预取的收益高度依赖硬件和编译器版本。在 x86 上有效的预取距离,在 ARM 上可能完全不同。建议在目标硬件上实测,而不是凭直觉设置预取距离。使用 perf stat -e L1-dcache-load-misses 对比有无预取的缓存未命中率。

十、小结#

上一章建立了NUMA 架构与内存局部性的认知框架。

概念要点对软件的影响
Stream 预取器检测顺序访问数组遍历自动加速
Stride 预取器检测固定步长结构体字段遍历
软件预取显式指定预取链表/间接索引
预取距离内存延迟/迭代时间太早或太晚都无效
预取污染预取驱逐有用数据PREFETCHNTA 避免污染
预取带宽预取挤占正常带宽带宽受限时需谨慎

下一步性能计数器与 Top-Down 分析——如何用 perf 精确定位 CPU 瓶颈?Top-Down 方法论如何系统化地分析性能问题?

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

硬件预取与软件预取
https://blog.souloss.com/posts/cpu-architecture/prefetching/
作者
Souloss
发布于
2026-02-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时