x86 MESI使缓存行延迟问题无效

时间:2019-01-16 01:02:45

标签: performance x86 shared-memory cpu-cache 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
在这些简单的伪代码流程中,

修改bDOIT为1,它将使缓存行无效 核心2,然后在ProcessB得到bDOIT = 1之后,再到ProcessB 将执行memcpy(localxyz,glbXYZ)。

由于每个evry 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 $。

1 个答案:

答案 0 :(得分:1)

有趣的主意,是的,应该使高速缓存行将您的结构保持在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的线程可以增加该信号量,触发操作系统唤醒被阻塞的ProcessD。 sem_down。如果由于某种原因ProcessD错过了唤醒的机会,它将在再次阻塞之前执行2次迭代,但这很好。 (嗯,所以实际上我们不需要计数信号量,但是我认为我们确实想要OS辅助的睡眠/唤醒,这是获得它的简便方法,除非我们需要避免在之后执行processC中的系统调用的开销编写结构。)或者在ProcessC中进行raise()系统调用可能会发送信号来触发ProcessD的唤醒。

借助Spectre + Meltdown缓解措施,任何系统调用,即使是像Linux futex这样的高效调用,对其进行线程创建也都相当昂贵。不过,这并不是您要缩短的关键路径的一部分,它仍然比您考虑的两次获取之间的10 usc睡眠少得多。

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_size 1 :通常,由于相邻行预取器,您想要避免错误共享的是128,但如果ProcessB触发L2相邻L2,实际上不是一件坏事。内核#2上的行预取器在glbXYZ上旋转时以推测方式将bDOIT拉入其L2高速缓存。因此,如果您使用的是Intel CPU,则可能需要将它们分组为128字节对齐的结构。

和/或,如果bDOIT为false,您甚至可以在processB中使用软件预取。预取不会阻止等待数据,但是如果读取请求到达在ProcessC编写glbXYZ的过程中,它将花费更长的时间。因此,也许每16或64次bDOIT的SW预取都是假的吗?


也不要忘记在旋转循环中使用_mm_pause(),以避免在旋转分支时,内存顺序错误的推测管道核对。 (通常这是自旋等待循环中的退出循环分支,但这是无关紧要的。您的分支逻辑等效于包含自旋等待循环然后进行一些工作的外部无限循环,即使这不是您编写的方式)

或者可以使用lock cmpxchg代替纯负载读取旧值。完整的障碍物已经阻挡了障碍物之后的投机负荷,因此请防止错误推测。 (您可以在C11中使用atomic_compare_exchange_weak并以期望=期望的方式执行此操作。它需要引用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保持为true,则这是一个紧密循环,调用周围没有开销。 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"冒犯者。

或者在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行大小进行更改。