c++ 演示LoadStore重新排序,其中一个加载获得一个往返到另一个线程的值,在实践中使用宽松的加载/存储?

3htmauhk  于 2023-03-09  发布在  其他
关注(0)|答案(2)|浏览(155)
#include <atomic>
#include <thread>

void test_relaxed()
{
    using namespace std;
    atomic<int> x{0};
    atomic<int> y{0};

    std::thread t1([&] {
        auto r1 = y.load(memory_order_relaxed); //a
        x.store(r1, memory_order_relaxed); //b
        });

    std::thread t2([&] {
        auto r2 = x.load(memory_order_relaxed); //c
        y.store(42, memory_order_relaxed); //d
    });
    
    t1.join();
    t2.join();
}

根据cppreference(in a relaxed ordering示例),允许上述代码生成r1 == r2 == 42
但是我已经在x86-64和arm 64平台上测试过了,我不能得到这个结果,有没有办法在真实的编译器和CPU上得到它呢?
(上帝 lightning )

ecr0jaav

ecr0jaav1#

根据ARM内存工具(the articlethe online tool),arm64允许此行为(这意味着它可能发生在某些arm64 CPU上)。
下面是对您的示例的试金石:

AArch64 SO-q-2023-03-06
{
0:X1=x; 0:X3=y;
1:X1=y; 1:X3=x;
}
 P0          | P1          ;
 LDR W0,[X1] | LDR W0,[X1] ;
 STR W0,[X3] | MOV W2,#42  ;
             | STR W2,[X3] ;
exists
(0:X0=42 /\ 1:X0=42)

您可以在the online tool中自己尝试。
但是在查找显示此类行为的arm64硬件时可能会出现问题。
我没有arm64的数据,但有ARMv7的数据(这可能会给予您带来启示,或者您可能想尝试在ARMv7上重现您的示例)。
您的测试用例与LB+data+po石蕊测试非常相似。
不同硬件上的石蕊测试结果如下所示:如你所见,它只能在某些硬件上重现。
表中所用硬件缩写的含义为here

bfrts1fy

bfrts1fy2#

在x86-64上,只有当编译器选择在编译时重新排序时,才会发生这种情况。x86内存模型是程序顺序加上存储缓冲区(具有存储转发功能),它只允许StoreLoad加上线程在全局可见之前看到自己的存储的怪癖。(这里没有线程这样做。)https://preshing.com/20121019/this-is-why-they-call-it-a-weakly-ordered-cpu/在ARM上有一些实际的重新排序测试,但没有这个。
我认为这在ARM /AArch 64上理论上是可能的,但只是对x的MESI共享请求的响应非常慢。就像其他线程中的x.fetch_add(0)一样?(注意不要将无操作RMW优化为只使用内存屏障,而不触及该高速缓存线)。或者不完整的LDXR / STXR事务实际上并不修改它?也许用ldxr/clrex手写asm,如果这是64位模式的话。
或者更简单地说,第三和第四个线程垃圾邮件存储(并且可能加载)在保存x的同一高速缓存行的不同部分上。
变量需要单独的缓存行(因此使用alignas(64) std::atomic<int> x {0};等)我们需要线程2的x.load(C)在缓存中严重缺失,以至于它直到往返于另一个线程之后才取值。(到其它核心)读取该高速缓存线的请求,直到其存储已引退并提交到L1 d高速缓存之后很久。否则,在线程1污染缓存线之前,请求将发出到其他内核,因此数据将仅从DRAM或主线程的存储区(因为它是堆栈上的本地存储区)进入。
不幸的是,像Nate尝试过的加载地址的长依赖链并没有什么帮助,如果你可以用这种方式延迟加载,而不延迟后面的存储的可见性,那就可以了,但是由于无序执行的工作方式,情况并非如此(当前面的指令很慢时,有序执行当然会自动延迟后面的所有指令)。
在从乱序执行中退出之前,存储对其他内核不可见(从存储缓冲区提交到L1 d缓存1),因为在此之前,它们仍然是推测性的。推测需要在一个内核中保持私有,以便只有在检测到错误推测时才需要回滚。请参阅 * Can a speculatively executed CPU branch contain opcodes that access RAM? *(推测所有可能出错的指令,而不仅仅是分支)。
因此,在C的加载地址准备就绪并且加载本身发送到执行单元之前,存储D不能引退,除非编译器静态地(在asm中)在加载之前对存储进行重新排序。允许这样做,但不会无缘无故这样做。编译器也不会这样做(当前)优化原子,因此早期存储y以使编译器进行死存储消除将不起作用,x上的其他操作也不起作用。

**如果运行线程2的核心由于x.load()而在其发出RFO的大约同时发出MESI共享请求(读取所有权),原因是y.store(),它将在一个非常窄的窗口内获得对这两者的回复。**另一个内核读取存储值和修改保存x该高速缓存线的时间太少,无法在接收和响应共享请求。

至少在正常条件下;由于对保存X1 M14 N1 X该高速缓存行的激烈竞争,由于一些其它线程获得了一轮(给予X1 M15 N1 X被线程1中的X1 M16 N1 X看到的时间),回复可能被延迟很多。
但是线程1(B)中的x.store直到线程2(D)中的y.store之后的线程间延迟的一个跳跃才会发生,并且线程1(C)中的x.load将有所有的时间来轮到它读取该缓存行。
也许在线程2中有很多先前的缓存未命中(到分散的地址)会有所帮助,希望加载仍然可以被发送到执行单元,并在有空闲缓冲区跟踪对它的非核心MESI请求之前退出。如果那些加载都是ldapr获取加载,它将确保它们必须在宽松的ldr之前完成。
非x86伊萨可以在数据到达之前从乱序后端退出加载,只要地址已知并且验证了它没有出错(即TLB命中和有效转换)。这就是LoadStore重新排序的根本原因。

关于测试方法的其他说明:

编译时需要启用优化,否则GCC不会内联x.store等等。运行时变量memory_order会被视为seq_cst,而不是进行分支以跳过围栏。https://godbolt.org/z/6rz43a716
线程启动可能比重新排序窗口花费更长的时间;正如Nate在注解中所描述的,您希望在启动线程后运行多个测试。

也许让线程1旋转,直到它看到一个非0值,然后存储它。这样就不需要在正确的时间运行的时间巧合。Preshing的https://preshing.com/20120515/memory-reordering-caught-in-the-act x86 StoreLoad测试在执行一个测试之前有一个同步和随机延迟,以最大限度地增加两个线程同时运行的机会。没有一个完成之前,操作系统可以开始在不同的核心上的第二个。

脚注1:核还可以在退役之前使退役(“分级”)存储对一些其它核可见:一些POWER CPU在同一物理核心的本地核心之间具有存储转发。参见 Will two atomic writes to different locations in different threads always be seen in the same order by other threads?。ARMv8不允许这样做,早期的ARM CPU也不允许这样做。x86还要求所有线程可以就所有存储(而不是加载)的总顺序达成一致。

相关问题