繁体   English   中英

当 memory 排序放松时 C++ 延迟增加

[英]C++ latency increases when memory ordering is relaxed

我在 Windows 7 64 位、VS2013(x64 发布版本)上试验 memory 订购。 我想使用最快的同步共享对容器的访问。 我选择了原子比较和交换。

我的程序产生两个线程。 写入器推送到向量,读取器检测到这一点。

最初我没有指定任何 memory 排序,所以我假设它使用memory_order_seq_cst

使用memory_order_seq_cst ,每个操作的延迟为 340-380 个周期。

为了尝试提高性能,我让商店使用memory_order_release并加载使用memory_order_acquire

但是,延迟增加到每个操作大约 1,940 个周期。

我误解了什么吗? 完整代码如下。

使用默认memory_order_seq_cst

#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<bool> _lock{ false };
std::vector<uint64_t> _vec;
std::atomic<uint64_t> _total{ 0 };
std::atomic<uint64_t> _counter{ 0 };
static const uint64_t LIMIT = 1000000;

void writer()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val))
        {
            _vec.push_back(__rdtsc());
            _lock = false;
        }
    }
}

void reader()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val))
        {
            if (_vec.empty() == false)
            {
                const uint64_t latency = __rdtsc() - _vec[0];
                _total += (latency);
                ++_counter;
                _vec.clear();
            }

            _lock = false;
        }
    }
}

int main()
{
    std::thread t1(writer);
    std::thread t2(reader);

    t2.detach();
    t1.join();

    std::cout << _total / _counter << " cycles per op" << std::endl;
}

使用memory_order_acquirememory_order_release

void writer()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val, std::memory_order_acquire))
        {
            _vec.push_back(__rdtsc());
            _lock.store(false, std::memory_order_release);
        }
    }
}

void reader()
{
    while (_counter < LIMIT)
    {
        bool expected{ false };
        bool val = true;

        if (_lock.compare_exchange_weak(expected, val, std::memory_order_acquire))
        {
            if (_vec.empty() == false)
            {
                const uint64_t latency = __rdtsc() - _vec[0];
                _total += (latency);
                ++_counter;
                _vec.clear();
            }

            _lock.store(false, std::memory_order_release);
        }
    }
}

您没有任何保护措施可以防止线程在释放锁后立即再次获取锁,只是发现_vec.empty()不是假的,或者存储另一个 TSC 值,覆盖了读者从未见过的值。 我怀疑您的更改让读者浪费更多时间阻止作者(反之亦然),导致实际吞吐量减少。

TL:DR: 真正的问题是您的锁定缺乏公平性(对于刚刚解锁的线程来说太容易赢得再次锁定它的竞赛),以及您使用该锁的方式。 (您必须先接受它,然后才能确定是否有任何有用的事情要做,强制其他线程重试,并导致内核之间缓存线的额外传输。)

让一个线程重新获得锁而另一个线程没有轮到总是无用和浪费的工作,这与许多需要更多重复才能填满或清空队列的实际情况不同。 这是一个糟糕的生产者-消费者算法(队列太小(大小为 1),和/或读取器在读取vec[0]后丢弃所有向量元素),并且是最糟糕的锁定方案。


_lock.store(false, seq_cst); 编译为xchg而不是普通的mov存储。 它必须等待存储缓冲区耗尽,并且速度很慢1 (例如,在 Skylake 上,微编码为 8 uop,对于许多重复的背靠背操作,每 23 个周期的吞吐量为 1,在无争用情况下它在 L1d 缓存中已经很热了。您没有指定任何有关您拥有的硬件的信息)。

_lock.store(false, std::memory_order_release); 只是编译成一个普通的mov存储,没有额外的屏障指令。 因此_counter的重新加载可以与它并行发生(尽管分支预测 + 推测执行使得这不是问题)。 更重要的是,下一次 CAS 尝试获取锁实际上可以更快地尝试。

当多个内核正在敲击缓存行时,有硬件仲裁来访问缓存行,大概有一些公平启发式,但我不知道细节是否已知。

脚注 1: xchg在最近的一些 CPU 上不如mov + mfence慢,尤其是 Skylake 派生的 CPU。 这是在 x86 上实现 seq_cst 纯存储的最佳方式。 但它比普通的mov慢。


您可以通过让您的锁定力交替写入器/读取器来完全解决

Writer 等待false ,然后在完成后存储true 读者做相反的事情。 所以作者永远不能在没有其他线程转弯的情况下重新进入临界区。 (当您“等待一个值”时,请在加载时以只读方式执行该操作,而不是 CAS。x86 上的 CAS 需要缓存行的独占所有权,防止其他线程读取。只有一个读取器和一个写入器,您不需要任何原子 RMW 即可工作。)

如果您有多个读取器和多个写入器,您可以有一个 4 状态同步变量,写入器尝试将其从 0 CAS 转换为 1,然后在完成后存储 2。 读者尝试从 2 到 3 的 CAS,然后在完成后存储 0。

SPSC(单生产者单消费者)案例很简单:

enum lockstates { LK_WRITER=0, LK_READER=1, LK_EXIT=2 };
std::atomic<lockstates> shared_lock;
uint64_t shared_queue;  // single entry

uint64_t global_total{ 0 }, global_counter{ 0 };
static const uint64_t LIMIT = 1000000;

void writer()
{
    while(1) {
        enum lockstates lk;
        while ((lk = shared_lock.load(std::memory_order_acquire)) != LK_WRITER) {
                if (lk == LK_EXIT) 
                        return;
                else
                        SPIN;     // _mm_pause() or empty
        }

        //_vec.push_back(__rdtsc());
        shared_queue = __rdtsc();
        shared_lock.store(LK_READER, ORDER);   // seq_cst or release
    }
}

void reader()
{
    uint64_t total=0, counter=0;
    while(1) {
        enum lockstates lk;
        while ((lk = shared_lock.load(std::memory_order_acquire)) != LK_READER) {
                SPIN;       // _mm_pause() or empty
        }

        const uint64_t latency = __rdtsc() - shared_queue;  // _vec[0];
        //_vec.clear();
        total += latency;
        ++counter;
        if (counter < LIMIT) {
                shared_lock.store(LK_WRITER, ORDER);
        }else{
                break;  // must avoid storing a LK_WRITER right before LK_EXIT, otherwise writer races and can overwrite with LK_READER
        }
    }
    global_total = total;
    global_counter = counter;
    shared_lock.store(LK_EXIT, ORDER);
}

Godbolt 上的完整版 在我的 i7-6700k Skylake 桌面(2-core turbo = 4200MHz,TSC = 4008MHz)上,使用 clang++ 9.0.1 -O3编译。 正如预期的那样,数据非常嘈杂; 我做了一堆运行并手动选择了一个低点和高点,忽略了一些可能是由于热身效应引起的真正异常高点。

在单独的物理内核上:

  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_release : ~180 到 ~210 个周期/操作,基本上是零machine_clears.memory_ordering (比如19总共超过 1000000 个操作,这要归功于旋转等待循环中的pause 。)
  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_seq_cst : ~195 到 ~215 参考周期/操作,相同的接近零的机器清除。
  • -DSPIN='' -DORDER=std::memory_order_release : ~195 to ~225 ref c/op, 9 to 10 M/sec machine clears without pause
  • -DSPIN='' -DORDER=std::memory_order_seq_cst : 更多变量和更慢,~250 到 ~315 c/op,8 到 10 M/sec 机器清除没有pause

这些时间比我系统上的seq_cst "fast" original 快大约 3 倍 使用std::vector<>而不是标量可能会占大约 4 个周期; 我认为我更换它时有轻微的影响。 不过,也许只是随机噪音。 200 / 4.008GHz 是大约 50ns 的内核间延迟,这听起来很适合四核“客户端”芯片。

从最好的版本(mo_release,在pause时旋转以避免机器清除):

$ clang++ -Wall -g -DSPIN='_mm_pause()' -DORDER=std::memory_order_release -O3 inter-thread.cpp -pthread && 
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r4 ./a.out
195 ref cycles per op. total ticks: 195973463 / 1000000 ops
189 ref cycles per op. total ticks: 189439761 / 1000000 ops
193 ref cycles per op. total ticks: 193271479 / 1000000 ops
198 ref cycles per op. total ticks: 198413469 / 1000000 ops

 Performance counter stats for './a.out' (4 runs):

            199.83 msec task-clock:u              #    1.985 CPUs utilized            ( +-  1.23% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               128      page-faults               #    0.643 K/sec                    ( +-  0.39% )
       825,876,682      cycles:u                  #    4.133 GHz                      ( +-  1.26% )
        10,680,088      branches:u                #   53.445 M/sec                    ( +-  0.66% )
        44,754,875      instructions:u            #    0.05  insn per cycle           ( +-  0.54% )
       106,208,704      uops_issued.any:u         #  531.491 M/sec                    ( +-  1.07% )
        78,593,440      uops_executed.thread:u    #  393.298 M/sec                    ( +-  0.60% )
                19      machine_clears.memory_ordering #    0.094 K/sec                    ( +-  3.36% )

           0.10067 +- 0.00123 seconds time elapsed  ( +-  1.22% )

从最坏的版本开始(mo_seq_cst, no pause ):旋转等待循环旋转得更快,因此发出/执行的分支和微指令要高得多,但实际有用的吞吐量要差一些。

$ clang++ -Wall -g -DSPIN='' -DORDER=std::memory_order_seq_cst -O3 inter-thread.cpp -pthread && 
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r4 ./a.out
280 ref cycles per op. total ticks: 280529403 / 1000000 ops
215 ref cycles per op. total ticks: 215763699 / 1000000 ops
282 ref cycles per op. total ticks: 282170615 / 1000000 ops
174 ref cycles per op. total ticks: 174261685 / 1000000 ops

 Performance counter stats for './a.out' (4 runs):

            207.82 msec task-clock:u              #    1.985 CPUs utilized            ( +-  4.42% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               130      page-faults               #    0.623 K/sec                    ( +-  0.67% )
       857,989,286      cycles:u                  #    4.129 GHz                      ( +-  4.57% )
       236,364,970      branches:u                # 1137.362 M/sec                    ( +-  2.50% )
       630,960,629      instructions:u            #    0.74  insn per cycle           ( +-  2.75% )
       812,986,840      uops_issued.any:u         # 3912.003 M/sec                    ( +-  5.98% )
       637,070,771      uops_executed.thread:u    # 3065.514 M/sec                    ( +-  4.51% )
         1,565,106      machine_clears.memory_ordering #    7.531 M/sec                    ( +- 20.07% )

           0.10468 +- 0.00459 seconds time elapsed  ( +-  4.38% )

将读写器固定在一个物理内核的逻辑内核上可以大大加快速度在我的系统上,内核 3 和 7 是同级的,因此 Linux taskset -c 3,7./a.out阻止 kernel 在其他任何地方调度它们:每个操作 33 到 39 个参考周期,或 80 到 82 个没有pause

在一个带有 HT 的 Core 上执行的线程之间的数据交换将用于什么? ,)

$ clang++ -Wall -g -DSPIN='_mm_pause()' -DORDER=std::memory_order_release -O3 inter-thread.cpp -pthread && 
 taskset -c 3,7 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r4 ./a.out
39 ref cycles per op. total ticks: 39085983 / 1000000 ops
37 ref cycles per op. total ticks: 37279590 / 1000000 ops
36 ref cycles per op. total ticks: 36663809 / 1000000 ops
33 ref cycles per op. total ticks: 33546524 / 1000000 ops

 Performance counter stats for './a.out' (4 runs):

             89.10 msec task-clock:u              #    1.942 CPUs utilized            ( +-  1.77% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               128      page-faults               #    0.001 M/sec                    ( +-  0.45% )
       365,711,339      cycles:u                  #    4.104 GHz                      ( +-  1.66% )
         7,658,957      branches:u                #   85.958 M/sec                    ( +-  0.67% )
        34,693,352      instructions:u            #    0.09  insn per cycle           ( +-  0.53% )
        84,261,390      uops_issued.any:u         #  945.680 M/sec                    ( +-  0.45% )
        71,114,444      uops_executed.thread:u    #  798.130 M/sec                    ( +-  0.16% )
                16      machine_clears.memory_ordering #    0.182 K/sec                    ( +-  1.54% )

           0.04589 +- 0.00138 seconds time elapsed  ( +-  3.01% )

在共享相同物理内核的逻辑内核上。 最佳情况下,延迟比内核之间的延迟低约 5 倍,再次用于暂停 + mo_release。 但是实际的基准测试只在 40% 的时间内完成,而不是 20%

  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_release : ~33 到 ~39 参考周期 / op ,接近零machine_clears.memory_ordering
  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_seq_cst : ~111 到 ~113 参考周期/操作,总共 19 次机器清除。 出乎意料的最差!
  • -DSPIN='' -DORDER=std::memory_order_release : ~81 到 ~84 参考周期/操作,~12.5 M 机器清除/秒。
  • -DSPIN='' -DORDER=std::memory_order_seq_cst : ~94 to ~96 c/op, 5 M/sec 机器无pause清除。

所有这些测试都使用clang++ ,它使用xchg进行 seq_cst 存储。 g++使用mov + mfencepause情况下速度较慢,在没有pause的情况下速度更快,并且机器清除次数更少。 (对于超线程情况。)对于带有pause的单独核心情况,通常非常相似,但在没有pause情况下的单独核心 seq_cst 中速度更快。 (再次,在 Skylake 上,专门针对这一测试。)


对原版的更多调查:

还值得检查machine_clears.memory_ordering的 perf 计数器为什么要刷新其他逻辑处理器导致的 Memory Order Violation 的管道? )。

我确实检查了我的 Skylake i7-6700k,在 4.2GHz 时,每秒machine_clears.memory_ordering的速率没有显着差异(快速 seq_cst 和慢速版本大约为 5M/秒)。 对于 seq_cst 版本(400 到 422),“每个操作的周期数”结果出人意料地一致。 我的 CPU 的 TSC 参考频率是 4008MHz,实际核心频率是 4200MHz 在最大睿频。 如果你有 340-380 个周期,我假设你的 CPU 的最大涡轮增压相对于它的参考频率比我的要高。 和/或不同的微架构。

但我发现mo_release版本的结果mo_release :在 Arch GNU/Linux 上使用 GCC9.3.0 -O3 :一次运行 5790,另一次运行 2269。 使用 clang9.0.1 -O3 73346 和 7333 进行两次运行,是的,确实是 10 倍)。 这是一个惊喜。 在清空/推送向量时,这两个版本都没有进行系统调用来释放/分配 memory,而且我没有看到 clang 版本中有很多内存排序机器清除。 使用您原来的 LIMIT,两次运行 clang 显示每个操作 1394 和 22101 个周期。

使用 clang++,甚至 seq_cst 时间的变化也比使用 GCC 的要大一些,并且更高,比如 630 到 700。(g++ 使用mov + mfence用于 seq_cst 纯存储,clang++ 使用xchg就像 MSVC 一样)。

其他带有mo_release的性能计数器显示每秒相似的指令、分支和 uops 速率,所以我认为这表明代码只是花费更多时间在关键部分使用错误的线程旋转它的轮子,而另一个卡住重试。

两个 perf 运行,第一个是 mo_release,第二个是 mo_seq_cst。

$ clang++ -DORDER=std::memory_order_release -O3 inter-thread.cpp -pthread &&
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r1 ./a.out
27989 cycles per op

 Performance counter stats for './a.out':

         16,350.66 msec task-clock:u              #    2.000 CPUs utilized          
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               231      page-faults               #    0.014 K/sec                  
    67,412,606,699      cycles:u                  #    4.123 GHz                    
       697,024,141      branches:u                #   42.630 M/sec                  
     3,090,238,185      instructions:u            #    0.05  insn per cycle         
    35,317,247,745      uops_issued.any:u         # 2159.989 M/sec                  
    17,580,390,316      uops_executed.thread:u    # 1075.210 M/sec                  
       125,365,500      machine_clears.memory_ordering #    7.667 M/sec                  

       8.176141807 seconds time elapsed

      16.342571000 seconds user
       0.000000000 seconds sys


$ clang++ -DORDER=std::memory_order_seq_cst -O3 inter-thread.cpp -pthread &&
 perf stat --all-user -etask-clock:u,context-switches,cpu-migrations,page-faults,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,machine_clears.memory_ordering -r1 ./a.out
779 cycles per op

 Performance counter stats for './a.out':

            875.59 msec task-clock:u              #    1.996 CPUs utilized          
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               137      page-faults               #    0.156 K/sec                  
     3,619,660,607      cycles:u                  #    4.134 GHz                    
        28,100,896      branches:u                #   32.094 M/sec                  
       114,893,965      instructions:u            #    0.03  insn per cycle         
     1,956,774,777      uops_issued.any:u         # 2234.806 M/sec                  
     1,030,510,882      uops_executed.thread:u    # 1176.932 M/sec                  
         8,869,793      machine_clears.memory_ordering #   10.130 M/sec                  

       0.438589812 seconds time elapsed

       0.875432000 seconds user
       0.000000000 seconds sys

我使用 memory 订单作为 CPP 宏修改了您的代码,因此您可以使用-DORDER=std::memory_order_release进行编译以获得慢速版本。
acquireseq_cst在这里无关紧要; 对于负载和原子 RMW,它在 x86 上编译为相同的 asm。 只有纯存储需要 seq_cst 的特殊 asm。

您还遗漏了stdint.hintrin.h (MSVC) / x86intrin.h (其他所有内容)。 固定版本在带有 clang 和 MSVC 的 Godbolt 上 早些时候,我将 LIMIT 提高了 10 倍,以确保 CPU 频率有时间在大部分时间区域内提升到最大 turbo,但恢复了该更改,因此测试mo_release只需几秒钟,而不是几分钟。

设置 LIMIT 以检查某个总 TSC 周期可能有助于它以更一致的时间退出 这仍然不包括作家被锁定的时间,但总的来说应该运行花费极长时间的可能性较小。


如果您只是想测量线程间延迟,您还会遇到很多非常复杂的事情。

CPU之间的通信是如何发生的?

您有两个线程读取作者每次更新的_total ,而不是在全部完成后仅存储一个标志。 所以作者有潜在的内存排序机器从读取另一个线程写入的变量中清除。

您在阅读器中还有一个_counter的原子 RMW 增量,即使该变量对阅读器是私有的。 它可以是您在reader.join()之后读取的普通非原子全局变量,或者更好的是它可以是仅在循环之后存储到全局变量的局部变量。 (由于发布存储,一个普通的非原子全局变量可能最终仍会在每次迭代时存储到 memory 而不是保存在寄存器中。而且由于这是一个小程序,所有全局变量可能彼此相邻,并且可能在同一缓存行中。)

std::vector也是不必要的。 __rdtsc()不会为零,除非它环绕 64 位计数器1 ,因此您可以在标量uint64_t中使用0作为标记值来表示空。 或者,如果您修复了锁定,这样读者就无法在没有作者轮到的情况下重新进入关键部分,您可以删除该检查。

脚注 2:对于 ~4GHz TSC 参考频率,即 2^64 / 10^9 秒,足够接近 2^32 秒 ~= 136 年以环绕 TSC。 注意,TSC 参考频率不是当前内核时钟频率; 对于给定的 CPU,它固定为某个值。 通常接近额定的“贴纸”频率,而不是最大涡轮。


此外,带有前导_的名称在 ISO C++ 中的全局 scope 中保留。 不要将它们用于您自己的变量。 (通常不会出现在任何地方。如果您真的需要,可以使用尾随下划线。)

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM