当内存排序放宽时,C++ 延迟会增加

use*_*112 4 c++ performance x86 memory-barriers c++11

我在 Windows 7 64 位、VS2013(x64 发行版本)上尝试内存排序。我想使用最快的同步来共享对容器的访问。我选择了原子比较和交换。

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

最初我没有指定任何内存排序,所以我假设它使用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;
}
Run Code Online (Sandbox Code Playgroud)

使用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);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 5

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

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

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


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

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

当多个核心对高速缓存行进行访问时,可能会进行硬件仲裁,大概有一些公平启发法,但我不知道细节是否已知。

脚注 1: 在一些最新的 CPU 上,尤其是 Skylake 衍生的 CPU 上,速度xchg并不像mov+慢。mfence这是在 x86 上实现 seq_cst 纯存储的最佳方式。但它比 plain 慢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);
}
Run Code Online (Sandbox Code Playgroud)

Godbolt 上的完整版本。在我的 i7-6700k Skylake 桌面上(2 核 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 至~225 ref c/op,9 至 10 M/sec 机器清除,无需pause.
  • -DSPIN='' -DORDER=std::memory_order_seq_cst:变量更多,速度更慢,约 250 至约 315 c/op,8 至 10 M/秒,机器无需清除pause

seq_cst这些计时比我系统上的“快速”原始计时快大约 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% )
Run Code Online (Sandbox Code Playgroud)

从最差的版本(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% )
Run Code Online (Sandbox Code Playgroud)

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

在一个具有 HT 的核心上执行的线程之间将使用什么来进行数据交换?,)

$ 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% )
Run Code Online (Sandbox Code Playgroud)

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

  • -DSPIN='_mm_pause()' -DORDER=std::memory_order_release~33 至~39 个参考周期/操作,接近于零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 至〜96 c/op,5 M/sec 机器清除,无需pause.

所有这些测试都clang++用于xchgseq_cst 存储。 g++使用mov+ mfence,在这种pause情况下速度较慢,在没有pause或较少机器清除的情况下速度更快。(对于超线程的情况。)通常对于使用 的单独核心情况非常相似pause,但在不使用情况的单独核心 seq_cst 中速度更快pause。(同样,针对这一测试,专门针对 Skylake。)


对原始版本的更多调查:

还值得检查性能计数器machine_clears.memory_ordering为什么要刷新由其他逻辑处理器引起的内存顺序冲突的管道?)。

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

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

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

其他性能计数器mo_release显示每秒指令、分支和微指令的类似速率,因此我认为这表明代码只是花费了更多时间在关键部分中使用错误的线程旋转轮子,而其他线程则卡住了重试。

两次 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
Run Code Online (Sandbox Code Playgroud)

我用 CPP 宏的内存顺序修改了你的代码,这样你就可以编译-DORDER=std::memory_order_release以获得慢速版本。
acquirevs.seq_cst在这里并不重要;它在 x86 上编译为相同的 asm 以用于加载和原子 RMW。只有纯存储需要 seq_cst 的特殊 asm。

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

设置 LIMIT 来检查特定的总 TSC 周期可能有助于它在更一致的时间内退出。这仍然不计算写入者被