performance - x86 MESI 无效缓存线延迟问题
问题描述
我有以下进程,我尝试使 ProcessB 的延迟非常低,所以我一直使用紧密循环并隔离 cpu core 2。
共享内存中的全局变量:
int bDOIT ;
typedef struct XYZ_ {
int field1 ;
int field2 ;
.....
int field20;
} XYZ;
XYZ glbXYZ ;
static void escape(void* p) {
asm volatile("" : : "g"(p) : "memory");
}
ProcessA(在核心 1 中)
while(1){
nonblocking_recv(fd,&iret);
if( errno == EAGAIN)
continue ;
if( iret == 1 )
bDOIT = 1 ;
else
bDOIT = 0 ;
} // while
ProcessB(在核心 2 中)
while(1){
escape(&bDOIT) ;
if( bDOIT ){
memcpy(localxyz,glbXYZ) ; // ignore lock issue
doSomething(localxyz) ;
}
} //while
ProcessC(在核心 3 中)
while(1){
usleep(1000) ;
glbXYZ.field1 = xx ;
glbXYZ.field2 = xxx ;
....
glbXYZ.field20 = xxxx ;
} //while
在这些简单的伪代码进程中,当 ProcessesA 将 bDOIT 修改为 1 时,它将使 Core 2 中的缓存行无效,然后在 ProcessB 获得 bDOIT=1 之后,ProcessB 将执行 memcpy(localxyz,glbXYZ) 。
由于 evry 1000 usec ProcessC 将使 Core2 中的 glbXYZ 无效,我想这会影响 ProcessB 尝试执行 memcpy(localxyz,glbXYZ) 时的延迟,因为当 ProcessB 将 bDOIT 扫描到 1 时,glbXYZ 已经被 ProcessC 无效,
glbXYZ 的新值仍在核心 3 L1$ 或 L2$ 中,在 ProcessB 实际得到 bDOIT=1 后,此时 core2 知道它的 glbXYZ 无效,所以此时它询问 glbXYZ 的新值,ProcessB 延迟受等待glbXYZ 的新值。
我的问题 :
如果我有一个 processD(在核心 4 中),它会:
while(1){
usleep(10);
memcpy(nouseXYZ,glbXYZ);
} //while
这个 ProcessD 是否会让 glbXYZ 更早地刷新到 L3$,这样当核心 2 中的 ProcessB 知道它的 glbXYZ 无效时,它会询问 glbXYZ 的新值,这个 ProcessD 会帮助 PrcoessB 更早地获得 glbXYZ 吗?!由于 ProcessD 一直帮助将 glbXYZ 变为 L3$。
解决方案
有趣的想法,是的,这可能会使缓存线保持您的结构进入 L3 缓存中的状态,其中 core#2 可以直接获得 L3 命中,而不是在该行仍处于 M 状态时等待 MESI 读取请求核心#2 的 L1d。
或者,如果 ProcessD 在与 ProcessB 相同的物理内核的另一个逻辑内核上运行,则数据将被提取到正确的 L1d中。如果它大部分时间都处于休眠状态(并且很少醒来),ProcessB 通常仍将拥有整个 CPU,以单线程模式运行,而不会对 ROB 和存储缓冲区进行分区。
您可以让它等待一个条件变量或 ProcessC 在编写 glbXYZ 后戳的信号量,usleep(10)
而不是让虚拟访问线程在 上旋转。
使用计数信号量(如 POSIX C 信号量sem_wait
/ sem_post
),写入的线程glbXYZ
可以增加信号量,触发操作系统唤醒被阻塞的 ProcessD sem_down
。如果由于某种原因 ProcessD 错过了唤醒,它将在再次阻塞之前执行 2 次迭代,但这很好。(嗯,所以实际上我们不需要计数信号量,但我认为我们确实需要操作系统辅助的睡眠/唤醒,这是一种简单的方法,除非我们需要避免在 processC 之后的系统调用开销编写结构。)或者raise()
ProcessC 中的系统调用可以发送信号来触发 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 的优化手册第 7.3.2 节,当前 CPU 上的 PREFETCHT2 与 PREFETCHT1 相同,并且会进入 L2 缓存(以及沿途的 L3。我没有检查 AMD。PREFETCHT2 会进入什么级别的缓存?) .
我还没有测试过 PREFETCHT2 在 Intel 或 AMD CPU 上是否真的有用。您可能想使用类似或的虚拟volatile
访问。特别是如果您有 ProcessD 在与 ProcessB 相同的物理内核上运行。*(volatile char*)&glbXYZ;
*(volatile int*)&glbXYZ.field1
如果prefetchT2
可行,您可以在写入bDOIT
(ProcessA) 的线程中执行此操作,因此它可以在 ProcessB 需要它之前触发该行到 L3 的迁移。
如果您发现该行在使用前被驱逐,也许您确实希望线程在获取该缓存行时旋转。
在未来的 Intel CPU 上,有一条cldemote
指令 ( _cldemote(const void*)
),您可以在写入后使用该指令来触发脏缓存行到 L3 的迁移。它在不支持它的 CPU 上作为 NOP 运行,但到目前为止它仅适用于Tremont (Atom) 。(当另一个内核在用户空间的受监视范围内写入时唤醒umonitor
/ umwait
,这对于低延迟内核间的东西可能也非常有用。)
由于 ProcessA 不写入结构,您可能应该确保bDOIT
它位于与结构不同的缓存行中。您可以放置alignas(64)
第一个成员,XYZ
因此该结构从缓存行的开头开始。 alignas(64) atomic<int> bDOIT;
会确保它也在一行的开头,所以他们不能共享一个缓存行。或将其设为alignas(64) atomic<bool>
or atomic_flag
。
另请参阅了解 std::hardware_破坏性_interference_size 和 std::hardware_constructive_interference_size 1:通常 128 是您想要避免由于相邻行预取器而导致错误共享的值,但如果 ProcessB 在核心上触发 L2 相邻行预取器,这实际上并不是一件坏事# 2 在它启动时推测性地拉glbXYZ
入其 L2 缓存bDOIT
。因此,如果您使用的是 Intel CPU,您可能希望将它们组合成一个 128 字节对齐的结构。
和/或您甚至可以bDOIT
在 processB 中使用软件预取(如果为假)。 预取不会阻塞等待数据,但如果读取请求在 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.
}
}
}
这在 Godbolt 上可以很好地编译为非常好的 asm。如果bDOIT
保持不变,这是一个紧密的循环,在调用周围没有开销。clang7.0 甚至使用 SSE 加载/存储将结构作为函数 arg 一次复制 16 个字节到堆栈。
显然,问题是一堆未定义的行为,您应该使用_Atomic
(C11) 或std::atomic
(C++11) 与memory_order_relaxed
. 或mo_release
/ mo_acquire
。 您在 write 的函数中没有任何内存屏障bDOIT
,因此它可以将其排除在循环之外。放松atomic
记忆顺序对 asm 质量的负面影响几乎为零。
大概您正在使用 SeqLock 或其他东西来防止glbXYZ
撕裂。是的,asm("":::"memory")
应该通过强制编译器假设它已被异步修改来完成这项工作。 但是,asm 语句的"g"(glbXYZ)
输入是无用的。它是全局的,因此"memory"
障碍已经适用于它(因为该asm
语句已经可以引用它)。如果您想告诉编译器它可能已经改变,请asm volatile("" : "+g"(glbXYZ));
不要使用"memory"
clobber。
或者在 C(不是 C++)中,只需制作它volatile
并进行结构赋值,让编译器选择如何复制它,而不使用障碍。在 C++ 中,对于where是一个聚合类型(如结构 )foo x = y;
失败。volatile struct = struct 不可能,为什么?. 当您想告诉编译器数据可能会作为在 C++ 中实现 SeqLock 的一部分异步更改时,这很烦人,但是您仍然希望让编译器以任意顺序尽可能有效地复制它,而不是一个狭窄的成员时间。volatile foo y;
foo
volatile
脚注 1:C++17 指定std::hardware_destructive_interference_size
作为硬编码 64 或使您自己的 CLSIZE 常量的替代方案,但 gcc 和 clang 尚未实现它,因为如果在alignas()
结构中使用它会成为 ABI 的一部分,因此实际上不能根据实际的 L1d 线大小而改变。
推荐阅读
- sql - 使用 NOT IN 的 SQL 连接显示不正确的数据
- java - 当我获得 RecyclerView 时,Android 片段会抛出 NPE
- swift - 在 UITableView 中使用 insertRow 的 Swift 无效更新
- javascript - 如何在输入中更改多个元素的位置
- c# - 如何从序列号列中删除水晶报表中的小数点
- python - 将动态表单中的数据存储在数组字段中(postgrsql)
- php - $t=“ 2022/8/6 15:44:54”;
- javascript - socket.io 和 Nodejs 控制器的问题
- c - 为什么我在数组分配上遇到段错误
- reactjs - 如何将反应组件作为变量传递给子组件?