在单核系统里,缓存一致性(cache coherence)是一个"免费"的属性:同一时刻只有一份执行流,L1 cache 就是内存的忠实镜像,软件几乎不需要为一致性付出任何心智成本。而一旦进入多核 SoC,每个 hart 都拥有私有的 L1 cache(乃至私有 L2),同一份物理数据在任何时刻都可能存在多份副本------不一致不再是否会发生的问题,而是何时发生、以什么代价被纠正的问题。
缓存一致性之所以值得 RISC-V Linux 开发者系统性梳理,原因有二:
其一,它是正确性的地基。锁的实现(lr/sc、AMO)、无锁数据结构、内核的 per-CPU 变量,全都建立在"一个核的写操作对另一个核可见"这一承诺之上;这个承诺由谁兑现、以什么代价兑现,决定了并发代码的正确性边界。
其二,它是性能的暗渠。一致性协议在幕后搬运缓存行,搬运的频率与路径直接决定多核扩展性------一个共享计数器的自增循环,可以让 8 核系统的有效吞吐低于单核;一次不经意的 false sharing,可以让精心设计的并行算法损失数成性能。本文围绕"协议如何工作、RISC-V 规范如何界定软件可见行为、Linux 上如何落地与排查"三个层面展开。
一、一致性问题从哪里来
1.1 三个软件可见的场景
多核系统中,需要维护一致性的场景可以归纳为三类:
共享读写:核 A 与核 B 先后写同一地址。若各自 L1 中都缓存了旧副本而互不知晓,后写的值可能被先写的缓存副本"遮蔽",程序语义就此崩坏。这是教科书意义上的缓存一致性问题。
数据迁移:任务在核间迁移(调度器负载均衡、实时任务的亲和性切换)时,其工作集需要随执行流"搬家"。若没有硬件一致性,迁移意味着手动的 cache flush 与 invalidate;有硬件一致性,迁移的代价则体现为一致性流量------前者是正确性负担,后者是性能税。
设备 I/O:DMA 设备直接读写内存,它不经过任何 hart 的 cache。若设备写入的内存区域恰好驻留在某个核的 cache 里,该核将读到陈旧数据。这引出了 RISC-V SoC 上一个高频的现实问题:设备是否处于一致性域内(coherent vs non-coherent DMA),直接决定软件是否需要显式的缓存维护操作。
1.2 一个关键的认知框架:协议是实现细节
进入具体协议之前,先确立一个对后文所有讨论都成立的框架:
缓存一致性协议(MESI、MOESI 等)是微架构实现细节,而非 ISA 承诺。
RISC-V 非特权 ISA 规范并不规定任何缓存一致性协议;它通过**内存模型(RVWMO,RISC-V Weak Memory Ordering)**定义软件可见的内存操作次序约束,具体以何种协议满足这些约束,是各家 CPU 核与互连的实现自由。这一区分贯穿全文:讨论规范时只谈软件可见行为(fence 的语义、amo 的 acquire/release 位),讨论实现时才谈协议状态与嗅探/目录机制。混淆这两个层面,是从厂商文档或内核 mailing list 中得出错误结论的最常见根源。
二、协议层:从 MESI 到 MOESI
尽管协议属于实现细节,理解其工作原理仍是读懂一致性性能的前提。工业界主流协议均以 MESI 四状态为基座演化。
2.1 MESI 四状态
| 状态 | 含义 | 本核可读写 | 全系统副本数 |
|---|---|---|---|
| M (Modified) | 已修改,与内存不一致 | 可读写 | 唯一 |
| E (Exclusive) | 干净的独占副本 | 可读写 | 唯一 |
| S (Shared) | 干净的共享副本 | 只读 | 可能多份 |
| I (Invalid) | 无有效副本 | --- | --- |
MESI 的精妙之处在于 E 状态:写一个只被自己缓存的缓存行(E→M)不需要任何总线事务,也不需要失效其他核------这解释了为什么"避免共享"是多核性能优化的第一原则:不共享,一致性协议就形同虚设,零开销。
2.2 MOESI 与 MESIF:两条演化路径
MESI 的短板在于 S 状态的写升级代价:一个核要写共享行,必须先发起失效(invalidate)广播,等所有持有者确认回 ACK 后才能进入 M 状态------这中间的往返延迟在高核数系统上急剧放大。业界演化出两条路径:
MOESI 增加 O (Owned) 状态:一个核可以持有"脏的共享"行(O),其他核持有 S。关键收益是:S 副本的读未命中可以直接从 O 持有者的 cache 供给,无需先写回内存;O 持有者对行的再次写入(O→M)也不必写回内存。这把"脏数据转发"与"延迟写回"变成协议内建能力,显著降低了共享写场景的总线流量。
MESIF 增加 F (Forward) 状态:多个 S 持有者中恰好有一个标记为 F,由它负责应答后续的读请求(类似 Intel 的实现路径)。与 O 的区别在于 F 行是干净的。
两条路径的目标一致:避免多核读未命中全部落到内存控制器,让 cache-to-cache 供给成为常态。对软件的启示也一致:多核读共享、单核写 的数据布局,在两类协议下都接近最优;多核写同一行则无论何种协议都是灾难------这直接引出第四节的 false sharing 排查。
2.3 嗅探与目录:协议的两种落地形态
协议状态机之外,还有"谁来仲裁"的问题:
总线嗅探(snooping):每个 cache 控制器监听共享总线上的事务,自行决定失效或供给。结构简单,但广播流量随核数平方级增长,适合小规模一致域(典型如一个簇内的 2--8 核)。
目录(directory):一致性控制器维护一张表,记录每个缓存行当前被哪些核持有(通常以有限指针或粗粒度向量压缩),失效操作点对点下发。流量可控,是大核数系统的主流选择,代价是目录查询延迟与目录本身的面积功耗。
实际 SoC 往往两者混合:簇内嗅探 + 簇间目录的层次化结构在业界相当常见。软件层面的可执行结论是:一致性流量的代价随"跨簇共享"陡增,同簇内的核间共享代价远低于跨簇------这与调度域(sched domain)的拓扑感知调度天然契合,也是亲和性调优时"把通信密集的线程放进同一个簇"的协议层依据。
三、RISC-V 的立场:内存模型、CMO 与一致性域
2 节讨论的是通用微架构知识;本节回到 RISC-V,回答三个问题:规范承诺了什么、扩展提供了什么、现实 SoC 的坑在哪里。
3.1 RVWMO 与 Ztso:软件可见行为的定义者
RISC-V 非特权规范定义的内存模型是 RVWMO(RISC-V Weak Memory Ordering)------一个弱序模型:不同地址的普通 load/store 可以被实现重排,软件需要显式同步才能获得跨核的顺序保证。规范提供三层次的同步原语:
fence 指令 :fence rw, rw 建立全屏障;fence r, w 是 release 语义的轻量形式。内核源码中 smp_mb()/smp_rmb() 在 RISC-V 上的实现即映射到不同强度的 fence 序列。
load/store 领先位(aq/rl) :lr.w.aq、sc.w.rl 等带 acquire/release 位的访问,比全量 fence 更精确,只约束需要的次序。规范层面这是推荐的高效同步方式(相比无条件全屏障)。
Ztso 扩展:对需要 x86 级 TSO 语义的场合(典型如移植强内存序假设的遗留软件),Ztso 扩展提供 Total Store Order。注意这是可选扩展而非基础承诺------若目标核未实现 Ztso,软件仍须按 RVWMO 写同步代码。
一个值得强调的工程事实:弱序模型下"忘记 fence"的 bug 在单核测试中几乎不可复现,只在特定微架构与特定访存交织下显形。规范层面的建议是明确的:同步逻辑以 aq/rl 位表达意图,而不是依赖实现巧合。
3.2 CMO 扩展:标准化的缓存管理指令集
Zicbom (cache block management)提供 cbo.clean、cbo.inval、cbo.flush 三条管理指令;Zicboz 提供按缓存块清零(cbo.zero);Zicbop 提供预取(cbo.prefetch.i/r/w)。三者共同构成缓存管理的标准化指令级接口。
CMO 的价值场景主要在非一致性 I/O 路径与精确的缓存卫生控制:DMA 前后的 clean/invalidate、大缓冲区复用前的 zero(绕过 cache 的读未命中)、数据预取的软件化表达。在 CMO 之前,这些操作只能依赖 SBI 调用或平台私有 CSR,跨平台移植性差------这也是内核社区推进 CMO 支持的核心动因之一(详见内核文档 Documentation/arch/riscv/cmo 中对相应 errata 与 SBI sbi_set_pcm 类接口的演进说明,具体接口以当前内核版本为准)。
3.3 一致性域的边界:coherent 与 non-coherent DMA
这是 RISC-V SoC 差异化最大、也最容易踩坑的一环。设备树中的 dma-coherent 属性决定外设是否处于一致性域:
coherent 设备:互连(如实现 ACE/CHI 一致性接口的 NoC)保证设备的 DMA 访问与各 hart cache 自动维护一致,软件零负担;
non-coherent 设备 :设备直连内存,DMA 写入的数据可能被某核的陈旧缓存行遮蔽;软件必须在设备读内存前执行 cbo.clean(把脏行写回)、在设备写内存后执行 cbo.inval(作废本地副本)。
Linux 内核已经把这条路径封装进 DMA API:dma_map_single(..., DMA_TO_DEVICE) 在 non-coherent 平台上会触发架构侧的 sync 回调(走 CMO 指令或 SBI),驱动开发者只需正确使用 streaming DMA API 而无需手写缓存维护。反过来说,驱动里绕过 DMA API 直接玩 virt_to_phys 的代码,在 non-coherent 平台上就是数据损坏的定时炸弹------这是 RISC-V BSP 维护中反复出现的 bug 类别。
3.4 玄铁平台的一贯路径
以玄铁处理器为例,高性能核(如 C920 及后续路线)在多核一致域与标准互连协议的适配上持续演进,CMO 类扩展也在产品线中逐步落地;具体的核级一致性实现细节(簇内协议形态、跨簇互连选型)以玄铁官网及其开放技术文档为准。对 BSP 与驱动开发者,务实的检查清单是:确认目标 SoC 的设备树中哪些外设标记了 dma-coherent;确认内核 config 中 CMO 扩展的启用状态;再用第五节的方法实测 DMA 路径上的缓存维护开销。
四、Linux 上的落地:数据布局决定一致性代价
协议在硬件里跑,但决定协议跑多忙的是软件的数据布局。以下手段按投入产出比排序。
4.1 认清拓扑:cache 到底怎么共享
bash
# 各级 cache 的大小、共享范围与一致性域边界
lscpu -C
# 或逐级查看
cat /sys/devices/system/cpu/cpu2/cache/index*/shared_cpu_list
shared_cpu_list 直接暴露了哪些核共享同一份 LLC(或簇内 L2)------这是"同簇亲和性"调优的原始输入。
4.2 false sharing:最常见也最隐蔽的性能税
经典反例:一个数组每个元素放一个核的计数器,元素小于缓存行宽度,多核各自写各自元素,硬件看到的却是"多核写同一缓存行":
c
/* 反例:64B 缓存行内塞 16 个 int,16 个核 = 缓存行弹跳 */
struct counters {
int hits[16];
};
/* 修正一:按缓存行对齐每个计数器 */
struct counters {
struct {
int hits;
} __attribute__((aligned(64))) per_core[16];
};
/* 修正二(内核态惯用):per-CPU 变量,结构上根除共享 */
DEFINE_PER_CPU(int, hits);
修正一的代价是内存膨胀(本例 16 倍),换来的是协议层面的零交互;修正二在内核里是标准做法,用户态可用 GCC 的 per-CPU 段模拟或线程局部存储近似。需要强调:aligned(64) 中的 64 只是示例值,正确的填充宽度应取 lscpu -C 读到的实际缓存行大小与一致性管理粒度(部分实现的缓存管理粒度大于缓存行本身,以厂商文档为准)。
4.3 一致性流量调优三原则
写者独立:任何会被多核并发写的字段,要么 per-CPU 化,要么填充隔离;只读共享(代码、配置表、查找表)是免费的,放心共享。
同簇亲和:确需核间共享的数据结构(生产者-消费者队列、共享锁),把通信双方钉在同一个 LLC/簇内,把跨簇一致性流量降级为簇内流量。
批量化写入:合并细粒度写为批量写(per-CPU 聚合后周期性汇总量),把 N 次缓存行所有权迁移压缩为 1 次------内核 per-CPU 计数器聚合、无锁环形缓冲的批量提交,都是这一原则的实例。
4.4 设备路径:两套 DMA API 的选择
c
/* 一致性内存:适合小而频繁的控制结构(描述符环) */
desc = dma_alloc_coherent(dev, SIZE, &dma_handle, GFP_KERNEL);
/* 在 coherent 平台上走普通页 + 无 sync;non-coherent 平台上
内核负责映射为非缓存或执行 CMO 维护------对驱动透明 */
/* 流式映射:适合大块一次性传输(包缓冲) */
dma_handle = dma_map_single(dev, buf, len, DMA_TO_DEVICE);
/* non-coherent 平台上此处触发 cache clean */
选型逻辑:一致性内存有映射属性代价(非缓存访问延迟高),不适合大吞吐数据面;流式映射按方向精确执行缓存维护,是吞吐路径的正解。两条路径的开销差异在 coherent 平台上趋近于零,在 non-coherent 平台上可能相差数倍------这解释了同一份驱动在两类 RISC-V SoC 上性能表现迥异的现象。
五、验证:一致性代价要靠测出来
5.1 perf c2c:false sharing 的照妖镜
bash
perf c2c record -a -- sleep 10
perf c2c report --stdio
重点看 HITM (Hit Modified,命中他核的脏行)维度:LD L1 HitM 与 ST L1 HitM 高的缓存行地址,加上报告给出的源代码偏移,基本可以定位到具体结构体的具体字段。内核社区的 false sharing 修复案例大多以此为起点。
5.2 核间乒乓延迟微基准
测量"缓存行所有权迁移"的裸代价,方法是两个核交替对同一行执行 AMO:
c
/* 核 A 与核 B 轮流对同一 cacheline 执行 amoadd,
每次所有权迁移 = 一次 M 状态独占权往返 */
for (i = 0; i < N; i++) {
while (flag != MY_TURN) ; /* 自旋等待,读他核的写 */
amoadd(flag_addr, 1); /* 抢占该行的 M 状态 */
}
同簇核对与跨簇核对的测量结果之差,就是"跨簇亲和性惩罚"的定量答案。典型量级上,跨簇乒乓是同簇的数倍(具体倍数取决于互连与协议实现,属实现数据而非规范值,应以目标平台实测为准)。
5.3 一份示例对照(量级说明用)
| 数据布局 | 8 核写计数器吞吐(相对值) |
|---|---|
| 反例:int 数组逐元素写 | 1× |
| aligned(64) 填充隔离 | 6--8× |
| per-CPU 聚合 + 周期汇总 | 8×(近线性) |
(示例数据仅用于说明量级与趋势:false sharing 的代价随核数放大,隔离后接近线性扩展;具体数字因平台、核数、协议实现而异,不可外推为普遍规律。)
5.4 排查残留问题
若多核扩展性仍不及预期,按顺序检查:perf c2c 的 HITM 热点是否清零;perf stat -e 的一致性相关事件(各实现的 PMU 事件名不同,以厂商文档为准)是否仍集中于隔离后的数据结构;调度器是否把通信密集线程放进了跨簇调度域(cat /sys/kernel/debug/sched/domains/);DMA 路径是否误用了 dma_alloc_coherent 承载大吞吐数据面。
六、结语:规范定义语义,实现决定代价
多核缓存一致性的工程全貌,可以压缩为一句话:规范(RVWMO、fence、CMO)定义软件可见的语义契约,协议与互连(MESI/MOESI、嗅探/目录)决定履行契约的性能代价,而数据布局决定这份契约被调用的频率。