跳转至

缓存与一致性

私有缓存让核心以低延迟访问热点数据,却产生一个矛盾:多个缓存可以持有同一物理地址的副本,写入后谁拥有最新值?缓存一致性协议解决单个地址副本的传播与串行化问题;它不自动提供程序所需的跨地址顺序。

先区分三个概念

  • cache consistency/coherence:对同一位置的写入形成所有观察者认可的顺序,写入最终传播。
  • memory consistency model:不同位置的 load/store 允许怎样排序。
  • language memory model:哪些程序无数据竞争,哪些同步建立 happens-before。

因此“机器有 MESI,所以普通变量跨线程可见”是错误推论。C++ 中并发非原子读写仍可能构成 data race 和未定义行为。

cache line 是协议粒度

处理器通常按 line 填充、驱逐和追踪一致性。即使线程 A 写 x、线程 B 写旁边的 y,只要二者在同一 line,协议仍会来回转移整条 line 的写权限,这就是 false sharing。

line 大小和层次包含关系依处理器而变;软件应通过平台接口或测量确认,不把常见的 64 B 当成跨架构保证。

目录与监听

snooping

共享总线或广播互连上,各缓存监听一致性事务。设计直观,但广播流量随核心数增长。

directory

目录记录哪些缓存可能持有 line,把请求定向到相关节点。它更可扩展,但需要目录存储、路由和并发事务状态。

现代系统可混合使用分层 snoop filter、目录与片上网络。MESI 是理解状态转换的模型,不应据此断言某款芯片的完整协议。

MESI 状态机

常见抽象状态:

  • M, Modified:本缓存持有唯一且已修改副本;
  • E, Exclusive:唯一、干净副本;
  • S, Shared:可能有多个干净副本;
  • I, Invalid:不可使用。

核心读取 I 状态 line 时发起读请求,得到 S 或 E;写 S line 时必须先获得独占权限,使其他副本失效;M line 被其他核心读取时,拥有者需提供或回写新数据。

一个简化转换:

CPU load miss: I --GetS--> S/E
CPU store hit: E --------> M
CPU store:     S --GetM--> M, invalidate sharers
remote GetS:  M ---------> S, supply data
remote GetM:  S/E/M -----> I, transfer ownership

真实协议还要处理瞬态状态、竞争请求、重试、写回缓冲和非一致性设备。

store buffer 与 invalidate queue

若每次 store 都等独占权限到达,核心会频繁停顿。store buffer 允许本核心先继续执行,协议在后台完成;invalidate queue 也可延迟处理失效。这些优化会让其他核心观察到与程序顺序不同的跨地址次序,因此 ISA 需要定义 fence 与原子语义。

本核心通常通过 store-to-load forwarding 读到自己尚未传播的 store。于是“我刚写后马上读到了”不能证明其他核心已看到。

false sharing 实验

下面的 C++20 程序比较相邻原子计数器和 cache-line 隔离计数器。它只测目标实现;std::hardware_destructive_interference_size 是实现建议值。

#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <new>
#include <thread>
struct Packed { std::atomic<std::uint64_t> x{0}, y{0}; };
struct alignas(std::hardware_destructive_interference_size) Cell {
    std::atomic<std::uint64_t> v{0};
};
struct Split { Cell x, y; };
template<class X, class Y>
double run(X x, Y y) {
    constexpr std::uint64_t n = 20'000'000;
    auto t0 = std::chrono::steady_clock::now();
    std::jthread a([&] { for (std::uint64_t i = 0; i < n; ++i) x().fetch_add(1, std::memory_order_relaxed); });
    std::jthread b([&] { for (std::uint64_t i = 0; i < n; ++i) y().fetch_add(1, std::memory_order_relaxed); });
    a.join(); b.join();
    return std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
}
int main() {
    Packed p;
    Split s;
    std::cout << run([&]() -> auto& { return p.x; }, [&]() -> auto& { return p.y; }) << '\n';
    std::cout << run([&]() -> auto& { return s.x.v; }, [&]() -> auto& { return s.y.v; }) << '\n';
}

测量时绑定两个线程到不同物理核心,并分别测试同 socket、跨 socket、SMT sibling。否则调度迁移可能淹没一致性成本。relaxed 仍保证每个计数器修改的原子性,只是不额外建立跨对象顺序。

一致性流量的性能形状

真共享

多个核心确实读写同一状态,例如全局队列 head。原子 RMW 会串行争夺 line,吞吐最终由所有权转移延迟限制。将计数分片、批量合并或改变所有权模型常比更换原子内存序更有效。

false sharing

逻辑独立数据共享物理 line。对齐/填充、按线程分区或改变布局可缓解;但填充会增加容量与 TLB 成本。

只读共享

初始化后只读的数据可在多个缓存中保持 Shared,扩展性通常良好。发布过程仍需正确同步,不能依赖“之后不再写”弥补初始化 data race。

NUMA 与一致性

NUMA 系统既要进行 cache coherence,也有不同内存节点延迟。一次远端访问可能因:

  • 数据物理页在远端;
  • line 当前由远端核心持有 Modified;
  • 线程迁移后工作集仍留在旧节点;
  • 目录或互连拥塞。

numactl --hardware、线程/内存绑定和 uncore 计数器可帮助区分。仅看 LLC miss 无法判断数据来自本地 DRAM、远端 cache 还是远端 DRAM。

DMA 与非 CPU 代理

设备 DMA 是否与 CPU cache 完全硬件一致,取决于平台互连和 DMA API。Linux 驱动必须使用平台 DMA mapping 接口表达所有权和同步;把普通虚拟地址直接交给设备或手工假设 cache flush 规则都不可移植。

一致性也不意味着设备操作与 CPU 普通 load/store 自动按程序所需排序。MMIO 和 DMA 还涉及设备内存属性、I/O barrier 与完成协议,参见 I/O、中断与 DMA

如何诊断

  1. 用源代码和 profile 找高频共享写;
  2. 记录对象地址与 cache line 映射;
  3. 固定线程到物理核心,改变核心距离;
  4. 比较单线程、同核、同 socket、跨 socket;
  5. 使用目标 CPU 的 HITM、snoop 或 cache-to-cache 事件;
  6. 尝试分片或 padding 作为因果对照;
  7. 检查容量、TLB 和内存占用是否回归。

工具事件名称和可解释性依平台。Linux perf c2c 在部分硬件上能帮助定位 cache-to-cache 共享,但不保证所有处理器都提供足够信息。

失败模式

  • 把 coherence 当 sequential consistency;
  • volatile 修复线程可见性;
  • 对齐对象却让动态数组元素仍共享 line;
  • 只 padding 字段,忽略分配器把多个对象放在同一 line;
  • 原子 RMW 使用更弱内存序后期待消除所有权争夺;
  • 看到 LLC miss 就断言访问 DRAM;
  • 忽略 SMT、NUMA、设备和 I/O coherence;
  • 使用未文档化 cache flush 指令处理持久化,却没有持久化域模型。

跨层连接

Reference