有趣的想法,是的,这可能应该让保存你的结构的缓存线进入 L3 缓存中的状态,其中 core#2 可以直接获得 L3 命中,而不必等待 MESI 读取在核心#2 的 L1d 中线路仍处于 M 状态时请求。
或者,如果 ProcessD 运行在与 ProcessB 相同物理内核的另一个逻辑内核上,则数据将被提取到正确的 L1d 中。如果它大部分时间都处于休眠状态(并且很少醒来),ProcessB 通常仍将拥有整个 CPU,以单线程模式运行,而不会对 ROB 和存储缓冲区进行分区。
不是让虚拟访问线程在usleep(10) 上旋转,您可以让它等待一个条件变量或 ProcessC 在写入 glbXYZ 后触发的信号量。
使用计数信号量(如 POSIX C 信号量sem_wait/sem_post),写入glbXYZ 的线程可以增加信号量,触发操作系统唤醒在sem_down 中阻塞的 ProcessD。如果由于某种原因 ProcessD 错过了唤醒,它将在再次阻塞之前执行 2 次迭代,但这很好。 (嗯,所以实际上我们不需要计数信号量,但我认为我们确实需要操作系统辅助的睡眠/唤醒,这是一种简单的方法,除非我们需要避免在 processC 之后的系统调用开销编写结构体。)或者 ProcessC 中的 raise() 系统调用可以发送信号来触发 ProcessD 的唤醒。
借助 Spectre+Meltdown 缓解措施,任何系统调用,即使是像 Linux futex 这样的高效系统调用,对于创建它的线程来说都是相当昂贵的。不过,这个成本并不是您试图缩短的关键路径的一部分,而且它仍然比您在两次提取之间考虑的 10 微秒睡眠时间要少得多。
void ProcessD(void) {
while(1){
sem_wait(something); // allows one iteration to run per sem_post
__builtin_prefetch (&glbXYZ, 0, 1); // PREFETCHT2 into L2 and L3 cache
}
}
(根据Intel's optimization manual section 7.3.2,当前 CPU 上的 PREFETCHT2 与 PREFETCHT1 相同,并获取 L2 缓存(以及沿途的 L3。我没有检查 AMD。
What level of the cache does PREFETCHT2 fetch into?)。
我还没有测试过 PREFETCHT2 在 Intel 或 AMD CPU 上是否真的有用。您可能想要使用虚拟的volatile 访问,例如*(volatile char*)&glbXYZ; 或*(volatile int*)&glbXYZ.field1。特别是如果您的 ProcessD 与 ProcessB 在同一物理内核上运行。
如果prefetchT2 有效,您可以在写入bDOIT (ProcessA) 的线程中执行此操作,因此它可以在 ProcessB 需要它之前触发行迁移到 L3。
如果您发现该行在使用前被驱逐,也许您确实想要一个线程在获取该缓存行时旋转。
在未来的 Intel CPU 上,您可以在写入后使用 cldemote instruction (_cldemote(const void*)) 来触发脏缓存行迁移到 L3。它在不支持它的 CPU 上作为 NOP 运行,但到目前为止它仅适用于 Tremont (Atom)。 (与umonitor/umwait 一起,当另一个内核在用户空间的受监控范围内写入时唤醒,这对于低延迟的内核间内容可能也非常有用。)
由于 ProcessA 不写入结构,您可能应该确保bDOIT 与结构位于不同的缓存行中。您可以将alignas(64) 放在XYZ 的第一个成员上,这样结构就从缓存行的开头开始。 alignas(64) atomic<int> bDOIT; 会确保它也在一行的开头,所以他们不能共享一个缓存行。或者将其设为alignas(64) atomic<bool> 或atomic_flag。
另见Understanding std::hardware_destructive_interference_size and std::hardware_constructive_interference_size1:通常 128 是您希望避免由于邻线预取器而导致错误共享的值,但如果 ProcessB 触发 L2 邻线预取器,这实际上并不是一件坏事在核心#2 上,当它在 bDOIT 上旋转时,推测性地将 glbXYZ 拉入其 L2 缓存。因此,如果您使用的是 Intel CPU,您可能希望将它们组合成一个 128 字节对齐的结构。
并且/或者如果bDOIT 为假,您甚至可以在进程B 中使用软件预取。预取不会阻塞等待数据,但如果读取请求在中间到达ProcessC 的写作glbXYZ 那么它会花费更长的时间。所以也许只有每 16 或 64 次的 SW 预取 bDOIT 是假的?
并且不要忘记在你的旋转循环中使用_mm_pause(),以避免当你旋转的分支转到另一个方向时内存顺序错误推测管道核弹。 (通常这是自旋等待循环中的循环退出分支,但这无关紧要。您的分支逻辑等效于包含自旋等待循环的外部无限循环,然后进行一些工作,即使这不是您编写的方式.)
或者可能使用lock cmpxchg 而不是纯负载来读取旧值。完全障碍已经阻止了障碍之后的投机负载,因此请防止错误推测。 (您可以在 C11 中使用 atomic_compare_exchange_weak 和 expected = desired 执行此操作。它通过引用获取 expected,并在比较失败时更新它。)但是使用 lock cmpxchg 敲击缓存行可能对 ProcessA 没有帮助能够快速将其存储提交到 L1d。
检查machine_clears.memory_ordering 性能计数器,看看在没有_mm_pause 的情况下是否会发生这种情况。 如果是,请先尝试_mm_pause,然后再尝试使用atomic_compare_exchange_weak 作为负载.或者atomic_fetch_add(&bDOIT, 0),因为lock xadd 是等价的。
// GNU C11. The typedef in your question looks like C, redundant in C++, so I assumed C.
#include <immintrin.h>
#include <stdatomic.h>
#include <stdalign.h>
alignas(64) atomic_bool bDOIT;
typedef struct { int a,b,c,d; // 16 bytes
int e,f,g,h; // another 16
} XYZ;
alignas(64) XYZ glbXYZ;
extern void doSomething(XYZ);
// just one object (of arbitrary type) that might be modified
// maybe cheaper than a "memory" clobber (compile-time memory barrier)
#define MAYBE_MODIFIED(x) asm volatile("": "+g"(x))
// suggested ProcessB
void ProcessB(void) {
int prefetch_counter = 32; // local that doesn't escape
while(1){
if (atomic_load_explicit(&bDOIT, memory_order_acquire)){
MAYBE_MODIFIED(glbXYZ);
XYZ localxyz = glbXYZ; // or maybe a seqlock_read
// MAYBE_MODIFIED(glbXYZ); // worse code from clang, but still good with gcc, unlike a "memory" clobber which can make gcc store localxyz separately from writing it to the stack as a function arg
// asm("":::"memory"); // make sure it finishes reading glbXYZ instead of optimizing away the copy and doing it during doSomething
// localxyz hasn't escaped the function, so it shouldn't be spilled because of the memory barrier
// but if it's too big to be passed in RDI+RSI, code-gen is in practice worse
doSomething(localxyz);
} else {
if (0 == --prefetch_counter) {
// not too often: don't want to slow down writes
__builtin_prefetch(&glbXYZ, 0, 3); // PREFETCHT0 into L1d cache
prefetch_counter = 32;
}
_mm_pause(); // avoids memory order mis-speculation on bDOIT
// probably worth it for latency and throughput
// even though it pauses for ~100 cycles on Skylake and newer, up from ~5 on earlier Intel.
}
}
}
This compiles nicely on Godbolt 非常漂亮的 asm。如果bDOIT 保持为真,那么这是一个紧密的循环,调用周围没有开销。 clang7.0 甚至使用 SSE 加载/存储将结构作为函数 arg 一次复制 16 个字节到堆栈。
很明显,这个问题是一堆未定义的行为,你应该用_Atomic (C11) 或std::atomic (C++11) 和memory_order_relaxed 来解决。或mo_release / mo_acquire。 在写入bDOIT 的函数中没有任何内存屏障,因此它可以将其从循环中删除。将其设为 atomic 并放松内存顺序对 asm 的质量几乎为零。
大概您正在使用 SeqLock 或其他东西来保护 glbXYZ 不被撕裂。是的,asm("":::"memory") 应该通过强制编译器假设它已被异步修改来完成这项工作。 "g"(glbXYZ) 输入的 asm 语句虽然没用。它是全局的,所以 "memory" 屏障已经适用于它(因为 asm 语句已经可以引用它)。如果您想告诉编译器只是它可能已经改变,请使用 asm volatile("" : "+g"(glbXYZ)); 而不使用 "memory" clobber。
或者在 C(不是 C++)中,只需将其设为 volatile 并进行结构赋值,让编译器选择如何复制它,而不使用障碍。在 C++ 中,foo x = y; 对 volatile foo y; 失败,其中 foo 是一个类似结构的聚合类型。 volatile struct = struct not possible, why?。当您想使用volatile 告诉编译器数据可能作为在 C++ 中实现 SeqLock 的一部分异步更改时,这很烦人,但是您仍然希望让编译器以任意顺序尽可能有效地复制它,而不是一个狭窄的一个成员。
脚注 1:C++17 指定 std::hardware_destructive_interference_size 作为硬编码 64 或使您自己的 CLSIZE 常量的替代方案,但 gcc 和 clang 尚未实现它,因为它已成为一部分如果在结构中的 alignas() 中使用 ABI,则实际上不能根据实际 L1d 行大小进行更改。