第4章 ARMv9缓存架构:从L1到硬件一致性

缓存这东西,说白了就是CPU和内存之间的“加速器”。我刚开始做ARMv8的时候,总觉得缓存配置照着参考设计抄就行。直到有一次,一个客户的产品在跑视频编解码时频繁卡顿,我花了整整两周才定位到是L2缓存的替换策略没配对。从那以后,我对缓存架构再也不敢掉以轻心了。

ARMv9的缓存体系,比以往任何一代都复杂。它不仅要管数据快不快,还要管多个核之间数据一不一致。咱们这一章,就把L1/L2/L3、MESI/MOESI协议、缓存维护指令,还有硬件一致性这几个硬骨头,一个一个啃下来。

4.1 缓存层级:L1、L2、L3的职责分工

ARMv9的缓存层级,你可以想象成一个“三级响应体系”。L1离核心最近,速度最快,但容量最小。L2作为中坚力量,容量和延迟都居中。L3则是多个核心共享的“大仓库”。

我个人习惯把这三级的参数记成一张表,调试时翻出来对照特别方便:

层级 典型容量 典型延迟 关联方式 归属
L1 I-Cache 32KB - 64KB 1-2 cycles 2/4路组相联 每核私有
L1 D-Cache 32KB - 64KB 1-2 cycles 2/4路组相联 每核私有
L2 Cache 128KB - 512KB 8-12 cycles 8/16路组相联 每核私有或簇共享
L3 Cache 1MB - 8MB 20-40 cycles 16/32路组相联 所有核共享

这里有个关键点:L1是分指令缓存和数据缓存的,L2和L3则是统一的。为什么这么设计?因为指令流和数据流的访问模式完全不同。指令是顺序执行的,数据是随机跳转的。混在一起,反而容易互相干扰。

我的经验: 在调试性能问题时,先看L1 miss率。如果L1 miss率超过10%,大概率是代码局部性不好。我曾经在一个图像处理算法里,因为循环顺序写反了,L1 miss率飙到30%,性能直接腰斩。

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

多核系统最头疼的问题是什么?数据打架。核A改了变量x,核B不知道,还在用旧值。这就乱套了。缓存一致性协议,就是用来解决这个问题的。

ARMv9支持两种协议:MESI和MOESI。它们都基于“监听”或“目录”机制,但状态机略有不同。

4.2.1 MESI协议的四态

MESI这个名字,就是四个状态的缩写:

  • M (Modified):数据被修改,且只在本缓存中。内存中的副本已过时。
  • E (Exclusive):数据未被修改,且只在本缓存中。内存中的副本是最新的。
  • S (Shared):数据未被修改,且可能存在于多个缓存中。内存中的副本是最新的。
  • I (Invalid):数据无效,不能使用。

状态转换的规则,我画了一张图,你一看就明白:

MESI协议状态转换图 M Modified E Exclusive S Shared I Invalid 本地写回/失效 本地写 其他核读 其他核写 本地读(未共享) 本地读(已共享) 说明:箭头上的文字表示触发状态转换的事件。 “本地写”指当前核写入,“其他核读/写”指其他核的操作。

4.2.2 MOESI协议的增强

MOESI在MESI基础上多了一个O(Owned)状态。这个O状态有什么用?我举个例子你就懂了。

假设核A把数据改成了M状态,核B想读这个数据。在MESI里,核A必须先把数据写回内存,然后两个核都变成S状态。但在MOESI里,核A可以直接把数据“分享”给核B,自己变成O状态,核B变成S状态。内存不需要更新。

这样做的好处是:减少了写回内存的次数。在ARMv9的DSU-110中,MOESI是默认启用的。我个人建议,除非你有非常特殊的理由,否则不要关闭它。

关键区别: MESI要求Modified状态的数据在被其他核读取前必须写回内存。MOESI允许Modified数据直接转发,减少内存访问延迟。在缓存行频繁共享的场景下,MOESI性能优势明显。

4.3 缓存维护指令:DC CVAC/ISW等

协议是硬件自动维护的,但有些场景下,软件必须主动干预。比如DMA传输、多核通信、电源管理。这时候,就需要缓存维护指令出场了。

ARMv9提供了一套完整的缓存维护指令,我挑几个最常用的给你讲:

指令 全称 作用 典型场景
DC CVAC Clean Virtual Address to Point of Coherency 将指定虚拟地址的缓存行写回,直到一致性点 DMA发送前,确保数据已写回内存
DC CIVAC Clean and Invalidate Virtual Address to Point of Coherency 写回并失效指定缓存行 DMA接收后,使缓存失效以读取新数据
DC ISW Invalidate Set/Way 按Set/Way失效整个缓存 电源管理、热插拔
IC IVAU Instruction Cache Invalidate by Virtual Address to Point of Unification 失效指令缓存中的指定行 自修改代码(SMC)

写代码的时候,我习惯这样用:

// DMA发送前:确保数据从缓存写回内存
__asm volatile("DC CVAC, %0" : : "r" (buf_addr) : "memory");

// DMA接收后:使缓存失效,从内存重新读取
__asm volatile("DC CIVAC, %0" : : "r" (buf_addr) : "memory");

// 自修改代码:先失效指令缓存,再执行屏障
__asm volatile("IC IVAU, %0" : : "r" (code_addr) : "memory");
__asm volatile("DSB ISH" : : : "memory");
__asm volatile("ISB" : : : "memory");
我曾经踩过的坑: 有一次做DMA驱动,我忘了在DMA接收后执行DC CIVAC。结果CPU读到的全是缓存里的旧数据,调试了整整一天。记住:DMA是绕过缓存的,CPU和DMA共享数据时,缓存维护指令一个都不能少。

4.4 硬件一致性:ACE与CHI

前面讲的MESI/MOESI,都是针对CPU核之间的缓存一致性。但现代SoC里,除了CPU,还有GPU、NPU、DSP、DMA控制器。这些设备怎么和CPU保持一致?

ARMv9的答案是:硬件一致性接口。目前主流的有两个:ACE和CHI。

4.4.1 ACE接口

ACE是ARMv8时代的标准。它通过监听总线实现一致性。每个ACE设备都能“看到”其他设备对缓存行的操作。优点是实现简单,缺点是总线带宽压力大。

4.4.2 CHI接口

CHI是ARMv9主推的新一代一致性接口。它采用“目录式”管理,不再依赖广播监听。每个缓存行都有一个“归属节点”(Home Node),所有一致性请求都通过这个节点协调。

我画个对比图,你感受一下差异:

ACE vs CHI 一致性架构对比 ACE(监听式) CPU核0 CPU核1 GPU 监听总线(Snoop Bus) 内存控制器 CHI(目录式) CPU0 CPU1 GPU 目录节点(HN-F) 内存控制器 ACE:所有设备通过共享监听总线通信,每个请求都会广播给所有设备。 CHI:每个缓存行有指定的归属节点(HN-F),请求只发给归属节点,由它协调。 CHI的扩展性更好,适合大规模多核和异构计算。

CHI的优势在大规模系统中特别明显。我记得有一次调试一个16核的服务器芯片,ACE的监听总线带宽成了瓶颈,换成CHI后,一致性延迟降低了40%。

选型建议: 如果你的SoC核数少于8个,且没有高性能GPU/NPU,ACE完全够用。如果核数超过8个,或者有高带宽的异构加速器,建议上CHI。ARMv9的DSU-110已经原生支持CHI,迁移成本并不高。

4.5 实战:缓存一致性问题的调试方法

理论讲完了,咱们来点实际的。怎么判断缓存一致性问题?我一般用三步法:

  1. 看性能计数器:ARMv9的PMU提供了缓存一致性相关的事件,比如BUS_ACCESS、SNOOP_ACCESS、SNOOP_RETRY。如果SNOOP_RETRY占比高,说明一致性协议在频繁重试。
  2. 加调试打印:在关键数据结构的访问前后,打印时间戳和值。如果两个核打印的值不一致,那就是一致性问题。
  3. 用硬件断点:在数据地址上设置硬件断点,观察是哪个核在什么时候修改了它。ARM DS-5和DS-1000都支持这个功能。

我曾经用这个方法,帮一个客户定位到了GPU和CPU之间的数据竞争问题。问题出在GPU驱动里漏了缓存维护指令,加上一条DC CIVAC就解决了。

注意: 缓存一致性问题往往是“偶发”的。可能跑100次才出现1次。不要因为一次测试通过就认为没问题。我建议做压力测试时,把缓存维护指令的缺失当作一个必查项。