x86 MESI 无效缓存行延迟问题

x86 MESI invalidate cache line latency issue

我有以下进程,我尝试使 ProcessB 的延迟非常低,所以我一直使用紧密循环并隔离 cpu 核心 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

进程B(核心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 ,会使缓存行失效 核心 2,然后在 ProcessB 得到 bDOIT=1 之后,然后是 ProcessB 将执行 memcpy(localxyz,glbXYZ) .

因为每个 1000 usec ProcessC 都会使 glbXYZ 在 Core2,我想这会影响延迟 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 命中,而不必在内核#2 的 L1d 中线路仍处于 M 状态时等待 MESI 读取请求。

或者如果ProcessD 运行 在与ProcessB 相同的物理内核的另一个逻辑内核上,数据将被提取到右侧的L1d。如果它大部分时间都在睡觉(并且很少醒来),ProcessB 通常仍然拥有整个 CPU,在单线程模式下 运行,而不对 ROB 和存储缓冲区进行分区。

不是让虚拟访问线程在 usleep(10) 上旋转,您可以让它等待条件变量或 ProcessC 在写入 glbXYZ 后戳出的信号量。

使用计数信号量(如 POSIX C 信号量 sem_wait/sem_post),写入 glbXYZ 的线程可以递增信号量,触发 OS 唤醒阻塞在 sem_down 中的 ProcessD。如果由于某种原因 ProcessD 错过轮到它醒来,它会在再次阻塞之前进行 2 次迭代,但这没关系。 (嗯,所以实际上我们不需要计数信号量,但我认为我们确实需要 OS 辅助的 sleep/wake 并且这是获得它的简单方法,除非我们需要避免开销写入结构后 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,当前 CPUs 上的 PREFETCHT2 与 PREFETCHT1 相同,并提取到 L2 缓存(以及沿途的 L3。我没有检查 AMD。 What level of the cache does PREFETCHT2 fetch into?).

我还没有测试 PREFETCHT2 在 Intel 或 AMD CPUs 上是否真的有用。您可能想要使用虚拟 volatile 访问权限,例如 *(volatile char*)&glbXYZ;*(volatile int*)&glbXYZ.field1。特别是如果您在与 ProcessB.

相同的物理核心上拥有 ProcessD 运行

如果 prefetchT2 有效,您可以在写入 bDOIT (ProcessA) 的线程中执行此操作,因此它可以在 ProcessB 需要它之前触发该行向 L3 的迁移。

如果您发现该行在使用前被逐出,也许您确实想要一个线程在获取该缓存行时旋转。

在未来的英特尔 CPU 上,目前有一个 cldemote instruction (_cldemote(const void*)) which you could use after writing to trigger migration of the dirty cache line to L3. It runs as a NOP on CPUs that don't support it, but it's only slated for Tremont (Atom)。 (与 umonitor/umwait 一起,当另一个内核在用户 space 的监控范围内写入时唤醒,这对于低延迟内核间的东西可能也非常有用。 )


由于 ProcessA 不写入结构,您应该确保 bDOIT 与结构位于不同的缓存行中。您可以将 alignas(64) 放在 XYZ 的第一个成员上,以便该结构从缓存行的开头开始。 alignas(64) atomic<int> bDOIT; 会确保它也在一行的开头,因此它们不能共享缓存行。或者将其设为 alignas(64) atomic<bool>atomic_flag.

另请参阅 1 :通常 128 是您要避免由于相邻行预取器而导致的错误共享,但如果 ProcessB 实际上并不是一件坏事触发 core#2 上的 L2 相邻行预取器,当它在 bDOIT 上旋转时,推测性地将 glbXYZ 拉入其 L2 缓存。因此,如果您使用的是 Intel CPU.

,您可能希望将它们分组到一个 128 字节对齐的结构中

And/or 如果 bDOIT 为假,您甚至可以使用软件预取,在 processB 中。 预取不会阻塞等待数据,但是如果读取请求在 ProcessC 写入 glbXYZ 的中间到达,那么它会花费更长的时间。所以也许只有 SW 每 16 次或第 64 次预取 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 loads/stores 将结构作为一次 arg 16 字节的函数复制到堆栈。


显然问题是一堆未定义的行为,你应该用 _Atomic (C11) 或 std::atomic (C++11) 和 memory_order_relaxed 来修复.或 mo_release / mo_acquire. 您在写入 bDOIT 的函数中没有任何内存屏障,因此它可以将其排除在循环之外。使其 atomic 放宽内存顺序对 asm 的质量几乎为零。

大概您正在使用 SeqLock 或其他东西来保护 glbXYZ 不被撕裂。是的,asm("":::"memory") 应该通过强制编译器假定它已被异步修改来使其工作。 尽管,asm 语句的"g"(glbXYZ) 输入是无用的。它是全局的,因此 "memory" 屏障已经应用于它(因为 asm 语句已经可以引用它)。如果你想告诉编译器 只是 它可能已经改变,使用 asm volatile("" : "+g"(glbXYZ)); 没有 "memory" 破坏。

或者在 C(不是 C++)中,只需将其设为 volatile 并进行结构赋值,让编译器选择如何复制它,而不使用障碍。在 C++ 中,foo x = y;volatile foo y; 失败,其中 foo 是类似于结构的聚合类型。 。当您想使用 volatile 告诉编译器数据可能会异步更改作为在 C++ 中实现 SeqLock 的一部分时,这很烦人,但您仍然希望让编译器以任意顺序尽可能高效地复制它,而不是一次一个狭窄的成员。


脚注 1:C++17 指定 std::hardware_destructive_interference_size 作为硬编码 64 或使您自己的 CLSIZE 常量的替代方法,但 gcc 和 clang 没有尚未实现它,因为如果在结构中的 alignas() 中使用它,它就会成为 ABI 的一部分,因此实际上不能根据实际的 L1d 行大小进行更改。