3. 内存系统优化:缓存层次结构、预取策略、内存屏障
内存子系统,说白了就是 CPU 和外部 DRAM 之间的「高速公路」。我见过太多性能问题,最后都卡在内存访问上。ARMv9 的缓存设计比以往更复杂,但也给了我们更多调优空间。这一章,咱们就把它掰开揉碎了讲清楚。
3.1 缓存层次结构:从 L1 到系统缓存
ARMv9 延续了经典的缓存层级,但引入了几个关键变化。先看这张整体结构图:
3.2 各级缓存的角色与延迟
不同层级的缓存,延迟差异巨大。我习惯用下面这张表来跟团队对齐预期:
| 缓存层级 | 典型大小 | 命中延迟 (周期) | 关联性 | 说明 |
|---|---|---|---|---|
| L1 I-Cache | 32KB - 64KB | 2-4 | 4路 | 指令缓存,只读 |
| L1 D-Cache | 32KB - 64KB | 2-4 | 4路/8路 | 数据缓存,读写 |
| L2 Cache | 128KB - 512KB | 8-12 | 8路/16路 | 每核私有 |
| L3 Cache | 1MB - 8MB | 20-40 | 16路 | 集群共享 |
| 系统缓存 (SLC) | 512KB - 16MB | 40-80 | 可变 | 跨集群/外设共享 |
| DRAM | GB级 | 100-300+ | — | 主存,延迟最高 |
关键认知:L1 命中只要 2-4 个周期,而一次 DRAM 访问要 100+ 周期。这意味着,如果 L1 命中率从 90% 提升到 95%,程序性能可能翻倍。我调优时,第一件事永远是看 perf 里的缓存命中率。
3.3 缓存行与伪共享问题
ARMv9 的缓存行大小固定为 64 字节。这个细节很重要——它直接导致了「伪共享」(False Sharing) 问题。
举个例子:
// 两个线程操作相邻的变量
struct data {
int a; // 线程0 只写 a
int b; // 线程1 只写 b
} __attribute__((aligned(64))); // 强制对齐到缓存行
// 如果不做对齐,a 和 b 可能在同一个缓存行里
// 线程0 写 a → 缓存行被标记为脏 → 线程1 的缓存行失效
// 线程1 写 b → 缓存行被标记为脏 → 线程0 的缓存行失效
// 这就是伪共享!性能会断崖式下跌
我曾经在一个多线程网络转发项目里,发现性能始终上不去。perf 显示 L1 命中率很高,但 L2 一致性流量异常大。排查了两天,最后发现是两个线程的统计变量挨在一起,触发了伪共享。加上 padding 对齐到 64 字节后,吞吐量直接提升了 40%。
3.4 预取策略:硬件预取与软件预取
预取是隐藏内存延迟的关键手段。ARMv9 提供了两套预取机制:
3.4.1 硬件预取
CPU 自动识别内存访问模式,提前加载数据。常见的模式有:
- 顺序预取:检测到连续地址访问,预取后续缓存行
- 跨步预取:固定间隔的访问模式,比如遍历数组的步长
- 空间预取:访问一个地址时,预取相邻地址
硬件预取是透明的,但我们可以通过 PREFETCH 指令给它「提示」:
// 软件预取示例
void process_array(int *data, int len) {
for (int i = 0; i < len; i++) {
// 提前预取 3 个缓存行之后的数据
__builtin_prefetch(&data[i + 64], 0, 3);
data[i] = data[i] * 2;
}
}
3.4.2 软件预取指令
ARMv9 提供了 PRFM 指令,比编译器内置函数更灵活:
| 指令 | 含义 | 使用场景 |
|---|---|---|
PRFM PLDL1KEEP |
预取数据到 L1,保留 | 短期频繁访问 |
PRFM PLDL1STRM |
预取数据到 L1,流式 | 一次性遍历 |
PRFM PLDL2KEEP |
预取数据到 L2,保留 | 中等频率访问 |
PRFM PLDL3KEEP |
预取数据到 L3,保留 | 低频但大块数据 |
PRFM PSTL1KEEP |
预取写入权限到 L1 | 即将写入的数据 |
我的经验:软件预取不是万能的。预取太早,数据可能被踢出缓存;预取太晚,来不及隐藏延迟。我一般会在循环里提前 2-3 次迭代做预取,然后通过 perf 测量缓存缺失率来微调。
3.5 内存屏障:为什么需要它们?
ARMv9 是弱内存序架构。什么意思?CPU 和编译器可以重排内存访问指令,只要不影响单线程语义。但在多核环境下,这就出问题了。
看这个经典例子:
// 线程 A
flag = 1; // 写 flag
data = 42; // 写 data
// 线程 B
while (flag == 0); // 等待 flag
print(data); // 读 data
// 问题:在 ARM 上,线程 B 可能看到 flag=1 但 data=0!
// 因为 CPU 可能把 data 的写操作重排到了 flag 之后
解决方案就是内存屏障。ARMv9 提供了三种屏障指令:
| 指令 | 全称 | 作用 |
|---|---|---|
DMB |
Data Memory Barrier | 保证屏障前的所有内存访问在屏障后的访问之前完成 |
DSB |
Data Synchronization Barrier | 比 DMB 更强,还会等待所有缓存/写缓冲排空 |
ISB |
Instruction Synchronization Barrier | 清空流水线,用于指令自修改或上下文切换 |
3.5.1 使用场景对比
// DMB 示例:保证顺序
flag = 1;
DMB(); // 确保 flag 写完后,data 的读才能开始
read_data = data;
// DSB 示例:等待所有写完成
write_data();
DSB(); // 确保数据真正到达内存
enable_interrupts(); // 才能安全开中断
// ISB 示例:修改代码后
patch_instruction();
ISB(); // 清空流水线,确保执行新指令
注意:内存屏障是有性能代价的。DMB 大约消耗 10-20 个周期,DSB 更重。我见过有人无脑在每个共享变量访问前后都加屏障,结果性能掉了 50%。正确的做法是:只在需要保证顺序的地方加,并且尽量用 DMB 而不是 DSB。
3.6 实战:缓存感知的代码优化
最后,分享一个我常用的优化套路。假设我们要遍历一个二维数组:
// 坏例子:按列遍历(缓存不友好)
for (int j = 0; j < COLS; j++) {
for (int i = 0; i < ROWS; i++) {
matrix[i][j] = 0; // 每次访问跨行,缓存行利用率低
}
}
// 好例子:按行遍历(缓存友好)
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
matrix[i][j] = 0; // 连续访问,一个缓存行用满
}
}
按行遍历比按列遍历快 5-10 倍,原因就是缓存行利用率。我调优时,会先看 perf 的 cache-misses 指标,如果 L1 缺失率超过 10%,就优先检查数据访问模式。
总结一下内存优化的核心思路:
- 让数据访问尽量连续(空间局部性)
- 让频繁访问的数据尽量小(时间局部性)
- 避免伪共享(对齐到 64 字节)
- 合理使用预取(不要过度)
- 只在必要时加内存屏障
嗯,这一章的内容就到这儿。缓存优化是个细活,需要反复测量和调整。但只要你掌握了这些原则,大部分性能问题都能找到方向。