我试图了解当 RPQ(读取待处理队列)和 WPQ(写入待处理队列)之间存在显著的队列压力差异时,内存控制器如何在非临时负载和非临时存储之间维持程序顺序。
考虑以下序列:
load A // goes to RPQ
ntstore A // goes to WPQ
如果 RPQ 有许多待处理的条目(比如 20 个)并且 WPQ 相对空(比如 2 个条目),直观地看似乎存储在加载完成之前就可以到达 DRAM:
- 加载 A 在 RPQ 中排在另外 20 个读取之后
- ntstore A 进入几乎为空的 WPQ 并可以快速完成
- 这会破坏程序顺序,因为存储会在之前的加载之前完成
我编写了一个测试程序(见下文)来验证这个假设。其中:
- 造成较大的 RPQ 压力,同时保持 WPQ 相对空闲
- 发出 load 请求,然后发出 ntstore 请求到同一地址
- 检查 load 是否看到 ntstore 写入的值(这表明顺序违规)
- 通过将 RPQ 填充地址与测试地址分开来防止缓存效应
核心测试序列:
uint64_t val = MAGIC_A;
uint64_t addr = (uint64_t)&memory[test_idx].sentinel;
asm volatile(
"mov (%1), %%rax\n\t" // Load into rax
"movnti %%rbx, (%1)\n\t" // NT Store
"mov %%rax, %0\n\t" // Save loaded value
: "=r"(val)
: "r"(addr), "b"(MAGIC_B)
: "rax", "memory"
);
if (val == MAGIC_B) { // Would indicate store completed before load
local_violations++;
}
结果:以 16 个线程和每个线程 10M 缓存行运行,我们发现 0 个违规。性能计数器确认我们达到了所需的队列压力:
$ sudo perf stat -e uncore_imc_0/unc_m_rpq_occupancy/ -e uncore_imc_0/unc_m_rpq_inserts/ -e uncore_imc_0/unc_m_wpq_occupancy/ -e uncore_imc_0/unc_m_wpq_inserts/ -e uncore_imc_3/unc_m_rpq_occupancy/ -e uncore_imc_3/unc_m_rpq_inserts/ -e uncore_imc_3/unc_m_wpq_occupancy/ -e uncore_imc_3/unc_m_wpq_inserts/ sleep 60
[sudo] password for vin:
Performance counter stats for 'system wide':
2,893,410,795,007 uncore_imc_0/unc_m_rpq_occupancy/
9,443,033,953 uncore_imc_0/unc_m_rpq_inserts/
574,954,888,344 uncore_imc_0/unc_m_wpq_occupancy/
32,101,285 uncore_imc_0/unc_m_wpq_inserts/
1,086,622,871 uncore_imc_3/unc_m_rpq_occupancy/
38,269,189 uncore_imc_3/unc_m_rpq_inserts/
76,056,378,805 uncore_imc_3/unc_m_wpq_occupancy/
31,895,245 uncore_imc_3/unc_m_wpq_inserts/
60.002128565 seconds time elapsed
在这种情况下,内存控制器如何维持程序顺序?鉴于队列深度存在显著差异(RPQ ~306 vs WPQ ~18),哪些机制可以防止存储在其先前的加载之前完成?我怀疑除了简单的队列动态之外还存在某种排序机制,但我不明白它是什么。
以下是完整的代码: https: //gist.github.com/VinayBanakar/6841e553d274fa5b8a156c13937405c8
在 x86 上(与 ARM 和其他处理器不同),我非常确定加载不能退出(变为非推测并允许后续 insns 也退出),直到返回值。这让 CPU 能够捕获内存顺序错误推测,因为硬件实际上会提前加载并检查诸如 LoadLoad 顺序违规之类的问题,并在必要时摧毁管道。(
machine_clears.memory_ordering
perf 事件)。弱排序 ISA 无需这样做;一旦确定负载无故障且请求已发送,它们就可以让负载从 ROB(重新排序缓冲区)退出。因此,它仅由负载缓冲区条目跟踪,而不是 ROB 条目。
存储在变为非推测之前不能从存储缓冲区提交到 LFB 或 L1d 缓存,否则可能会导致错误推测的存储值对其他核心可见,而这些核心无法回滚。(例如,在检测到早期的分支预测错误或错误指令之后。)
因此,x86 无序执行硬件从根本上无法进行 LoadStore 重新排序,即使是弱排序的 NT 存储也是如此。 您对内存控制器的核外请求无法立即进行。
SSE4.1
movntdqa
从 WC 内存加载是弱排序的(与movntdqa
从任何其他内存类型加载不同 - 与 NT 存储不同,该指令不会覆盖排序语义)。假设,它们可以在数据到达之前(在响应核外请求之前)退出。这不会违反内存模型,因为它们可以自由地重新排序,以更早或更晚的加载和存储(我认为),并且可能需要一个完整的mfence
来阻止它们的重新排序。我不知道是否有任何真正的 CPU 允许它们在数据仍在传输时退出,或者它们是否都像正常加载一样处理它们,只是不检查内存顺序错误推测。我猜是后者,因为这是一个非常罕见的用例,好处可能很小。mov
但是您在正常分配的 C++ 对象(全局变量或new
通过)上使用普通加载std::vector
,这些对象将位于 WB(写回)内存区域中,因此这些都不适用于您的测试用例。您的货物和商店都发往同一地址,
(%1)
从架构上来说,要求从同一逻辑核心(线程)加载和存储到同一地址的操作不能重新排序。我非常确定这种保证甚至扩展到从 WC 内存进行的弱排序 NT 加载,但我没有仔细检查。
即使是在弱排序 ISA(如 PowerPC)上,对于普通的加载/存储来说,情况也是如此。所有当前主流 CPU 都免费实现了 C++ 的读写一致性保证
shared.load(relaxed); shared.store(newval, relaxed);
,两者之间不需要 asm 屏障,加载不可能看到存储。核心内的内存歧义消除将发现存储较新,因此它不能从存储转发到加载,并且内存控制器中的任何核外重新排序都必须设计为支持足够的排序。也许将序列号附加到请求中?我不确定 CPU 和内存控制器之间的缓存一致性互连以及内存控制器本身如何处理这个问题。我读到过,在核心之间的互连中保持足够的排序不会自动发生,这是 CPU 架构师必须内置的东西。