3. 内存系统优化:缓存层次结构、预取策略、内存屏障

内存子系统,说白了就是 CPU 和外部 DRAM 之间的「高速公路」。我见过太多性能问题,最后都卡在内存访问上。ARMv9 的缓存设计比以往更复杂,但也给了我们更多调优空间。这一章,咱们就把它掰开揉碎了讲清楚。

3.1 缓存层次结构:从 L1 到系统缓存

ARMv9 延续了经典的缓存层级,但引入了几个关键变化。先看这张整体结构图:

ARMv9 缓存层次结构 Core 0 L1I L1D L2 (私有) Core 1 L1I L1D L2 (私有) Core 2 L1I L1D L2 (私有) L3 (共享 / 集群级) 系统缓存 (System Cache / SLC) DDR DRAM 监听过滤器 (Snoop Filter) — 跨核一致性维护

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 字节)
  • 合理使用预取(不要过度)
  • 只在必要时加内存屏障

嗯,这一章的内容就到这儿。缓存优化是个细活,需要反复测量和调整。但只要你掌握了这些原则,大部分性能问题都能找到方向。