3. ARMv8内存管理

缓存,说白了就是 CPU 和内存之间的「加速带」。ARMv8 的缓存架构,我做了这么多年调优,觉得它最核心的设计思路就是:用面积换速度,用层次换容量。你想想看,CPU 主频动辄 2-3GHz,而 DDR 内存的延迟还在 100ns 级别,这个差距不靠缓存填,根本跑不起来。

3.1 L1/L2/L3 缓存层次

ARMv8 的缓存层次,我习惯把它理解成一个「漏斗」:越靠近 CPU 的缓存越小、越快、越贵;越远离的越大、越慢、越便宜。

层级 典型大小 延迟(周期) 关联度 私有/共享
L1 I-Cache 32KB - 64KB 2-4 2-4 路 每核私有
L1 D-Cache 32KB - 64KB 2-4 2-4 路 每核私有
L2 Cache 128KB - 512KB 8-12 8-16 路 每核私有
L3 Cache 1MB - 8MB 30-50 16-32 路 多核共享

嗯,这里要注意:L1 是分指令和数据缓存的。为什么?因为指令流和数据流的访问模式完全不同。指令是顺序的、只读的;数据是随机的、读写都有。混在一起反而会互相干扰。我在项目中遇到过,有人把 L1 当成统一缓存用,结果指令 miss 和数据 miss 互相踢,性能直接掉了 15%。

3.2 缓存行与组相联

ARMv8 的缓存行大小固定是 64 字节。这个值不是随便定的——64 字节刚好能在一个 burst 传输里从 DDR 搬完。我个人习惯在优化时,把热点数据结构对齐到 64 字节边界,避免一个结构体跨两个缓存行。

关键概念:组相联

缓存不是全相联的(那太贵了),也不是直接映射的(冲突太高)。ARMv8 用的是 N 路组相联。比如 4 路组相联,意味着一个内存地址只能映射到缓存中的 4 个位置。这 4 个位置叫一个「组」。

为什么这个重要?因为如果你不小心让多个热点变量映射到同一个组,就会发生「缓存抖动」——明明缓存还有空间,但就是互相踢。我曾经调试过一个网络包处理程序,就是因为两个频繁访问的数组恰好映射到同一组,性能从 10Gbps 掉到了 6Gbps。

3.3 缓存一致性协议:MESI 与 MOESI

多核环境下,每个核都有自己的 L1/L2 缓存。如果核 A 改了某个变量,核 B 还在用旧值,那就出大事了。缓存一致性协议就是解决这个问题的。

ARMv8 主流用的是 MESI 协议,部分实现用 MOESI。我直接说人话:

  • M (Modified):这行数据被我改了,跟内存不一样,别的核不能碰。
  • E (Exclusive):这行数据只有我有,跟内存一样,但别人没有。
  • S (Shared):这行数据我跟别人共享,都是干净的。
  • I (Invalid):这行数据是废的,别用。
  • O (Owned):MOESI 特有的。我改了这行数据,但别人可以读我的版本,不用去内存拿。

避坑指南

我曾经在调试一个数据库引擎时,发现多核写入同一个缓存行(伪共享问题)。两个核各自改不同的字段,但因为它们在同一个缓存行里,MESI 协议让这两个核疯狂发 invalidate 消息。性能从 100 万 QPS 掉到 30 万。解决方案很简单:把热点字段用 64 字节对齐隔开。

3.4 缓存一致性协议状态转换

我画了一张图,帮你理解 MESI 的状态机。核心逻辑就是:读之前先看别人有没有,写之前先通知别人失效。

MESI 协议状态转换图 M (Modified) E (Exclusive) S (Shared) I (Invalid) 远程读/写 本地写 远程读 远程写 本地读(无共享) 本地读(有共享) 状态说明 M: 已修改,独占 E: 独占,未修改 S: 共享,未修改 I: 无效 箭头: 触发事件

为什么会这样设计?因为 MESI 的核心思想是「写时失效」。你写一个变量,必须让所有其他核的副本失效。这样下次别人读的时候,要么从你的缓存拿(MOESI 的 O 状态),要么从内存拿。这个设计在大多数场景下效率很高,但伪共享就是它的阿克琉斯之踵。

3.5 预取策略

ARMv8 提供了硬件预取和软件预取两种方式。硬件预取是自动的,你基本不用管。但软件预取——也就是 PRFM 指令——才是调优的利器。

// 软件预取示例:提前加载下一个缓存行
// 假设我们要遍历一个大型数组
void process_array(uint64_t *data, size_t len) {
    for (size_t i = 0; i < len; i += 8) {
        // 预取 64 字节后的数据
        __asm__ volatile("PRFM PLDL1KEEP, [%0, #64]" : : "r"(&data[i]));
        
        // 处理当前 8 个元素
        data[i]   = compute(data[i]);
        data[i+1] = compute(data[i+1]);
        // ... 处理到 data[i+7]
    }
}

预取陷阱

预取不是越多越好。我曾经在一个视频编解码项目里,对每个循环都加了预取,结果性能反而下降了 8%。原因很简单:预取指令本身有开销,而且预取太远会把有用的缓存行踢出去。我建议预取距离控制在 2-4 个缓存行,且只在内存访问模式是顺序的时候用。

3.6 缓存优化实战策略

我总结了几个在 ARMv8 上屡试不爽的缓存优化策略:

  1. 数据对齐:热点结构体按 64 字节对齐,避免跨行访问。
  2. 避免伪共享:多核写入的变量,用 __attribute__((aligned(64))) 隔开。
  3. 循环分块:把大循环拆成小块,让每个块的数据能塞进 L1 缓存。
  4. 使用非临时存储:对于只写一次的数据,用 STNP 指令绕过缓存。
  5. 合理使用预取:只在顺序访问且延迟敏感的场景用 PRFM。

我的调试习惯

我一般先用 perf stat -e cache-misses 看整体 miss 率。如果超过 5%,就上 perf record -e cache-misses 抓热点。然后看是 L1 miss 还是 L2 miss——L1 miss 高说明数据局部性差,L2 miss 高说明工作集太大。对症下药,别盲目优化。

3.7 缓存一致性对虚拟化的影响

在虚拟化场景下,缓存一致性变得更复杂。因为虚拟机迁移时,VMM 需要保证所有 vCPU 的缓存状态一致。ARMv8 的 GIC (Generic Interrupt Controller) 和 SMMU (System MMU) 都参与了缓存一致性的维护。

我记得有一次调试 KVM 上的 ARMv8 虚拟机,发现虚拟机内部频繁出现数据不一致。查了两天才发现,是 VMM 没有在 vCPU 迁移时正确执行 DC CISW 指令来 clean 和 invalidate 缓存。这个坑,我建议做虚拟化开发的朋友一定要记住:vCPU 迁移后,必须做缓存维护操作。

嗯,缓存这块内容就讲到这里。核心就三件事:理解层次结构、掌握 MESI 协议、用好预取和对齐。把这些吃透了,ARMv8 的性能调优你就入门了。