【问题标题】:Why does using an 'if' statement give me better performance?为什么使用“if”语句会给我带来更好的性能?
【发布时间】:2016-01-16 14:25:43
【问题描述】:

在发布模式下在 VC++2015 中编译以下程序时,优化设置为 Ox(完全优化),即使有额外的条件检查,我也不知何故获得了更好的性能。

演示:http://coliru.stacked-crooked.com/a/a33b42a28548d3e4(不演示性能差异,因为 g++ 为两个版本生成几乎相同的代码)。

运行该程序可以缩短 2. 的执行时间。这违反了我的常识,因为 2. 每次都有 if 声明要检查。

在我的机器上,1. 平均为 105 毫秒,2. 平均为 86 毫秒。该演示也显示了差异,但只有 5ms 的差异仍然有利于 2.;这是什么原因造成的?


这里是完整的代码,它实际上给了我执行时间的巨大差异。请注意,它实际上并不使用两个函数。我只是将operator++()中的相关部分注释掉。

#include <iostream>
#include <thread>
#include <chrono>
#include <cstddef>
#include <atomic>
#include <limits>
#include <stdexcept>
#include <assert.h>

template <typename T = std::size_t>
class atomic_counter
{
public:
    using size_type = T;

    atomic_counter() : count_{ 0 }
    {
        assert( count_.is_lock_free() );
    }

    atomic_counter& operator++()
    {
        ++count_;                             // 1.
        //auto prev_count = ++count_;         // 2.
        //if ( prev_count == std::numeric_limits<size_type>::min() )
        //  throw std::overflow_error( "atomic_counter::operator++(): counter overflow" );

        return *this;
    }
    atomic_counter& operator--()
    {
        auto prev_count = --count_;
        if ( prev_count == std::numeric_limits<size_type>::max() )
            throw std::underflow_error( "atomic_counter::operator--() : counter underflow" );

        return *this;
    }

    size_type count() const
    {
        return count_.load();
    }

public:
    std::atomic<size_type> count_;
};

template
<
    typename Clock = std::chrono::high_resolution_clock,
    typename Unit = std::chrono::milliseconds,
    typename F,
    typename... FArgs
>
long long measure_execution_time( F&& f, FArgs&&... fargs )
{
    auto time_begin = Clock::now();
    f( std::forward<FArgs>( fargs )... );
    auto time_end = Clock::now();
    return std::chrono::duration_cast<Unit>( time_end - time_begin ).count();
}

int main()
{
    auto hardware_concurrency = std::thread::hardware_concurrency();
    std::size_t constexpr loop_count = 15'000'000;
    atomic_counter<> ac;

    auto lambda = [&] ( auto&& n )
    {
        for ( atomic_counter<>::size_type i{ 0 }; i < n; ++i )
            ++ac;
    };

    long long avg = 0;
    for ( std::size_t i{ 0 }; i < 20; ++i )
    {
        auto time = measure_execution_time<>( lambda, loop_count );
        std::cout << i + 1 << ".\t" << time << " ms\n";
        avg += time;
    }

    std::cout << "Avg:\t" << avg / 20 << " ms\n";
}

1. 的结果一致:

2. 的结果一致:

1.生成的程序集:

000000013FA110F0  lea         rcx,[rsp+38h]  
000000013FA110F5  call        std::chrono::steady_clock::now (013FA11000h)+100000000h  
000000013FA110FA  mov         eax,2160EC0h  
000000013FA110FF  nop  
000000013FA11100 loopv1: mov    ecx,1  
000000013FA11105  lock xadd   qword ptr [ac],rcx  
000000013FA1110C  sub         rax,1  
000000013FA11110  jne    loopv1 ;   main+70h (013FA11100h)  
000000013FA11112  lea         rcx,[rsp+40h]  
000000013FA11117  call        std::chrono::steady_clock::now (013FA11000h)+100000000h

2.生成的程序集:

long long measure_execution_time( F&& f, FArgs&&... fargs )
{
000000013F871230  mov         qword ptr [rsp+8],rbx  
000000013F871235  push        rdi  
000000013F871236  sub         rsp,50h  
000000013F87123A  mov         rdi,rcx  
000000013F87123D  mov         rbx,rdx  
    auto time_begin = Clock::now();
000000013F871240  lea         rcx,[rsp+70h]  
000000013F871245  call        std::chrono::steady_clock::now (013F871110h)  
    f( std::forward<FArgs>( fargs )... );
000000013F87124A  xor         r9d,r9d  
000000013F87124D  cmp         qword ptr [rbx],r9  
000000013F871250  jbe         measure_execution_time<std::chrono::steady_clock,std::chrono::duration<__int64,std::ratio<1,1000> >,<lambda_fb2a7610a6d36531125f2c739fce673b> & __ptr64,unsigned __int64 const & __ptr64>+41h (013F871271h)  
000000013F871252 loopv2: mov    rax,qword ptr [rdi]   ; top of the inner loop
000000013F871255  mov         r8d,1  
000000013F87125B  lock xadd   qword ptr [rax],r8  
000000013F871260  lea         rax,[r8+1]  
000000013F871264  test        rax,rax  
000000013F871267  je          measure_execution_time<std::chrono::steady_clock,std::chrono::duration<__int64,std::ratio<1,1000> >,<lambda_fb2a7610a6d36531125f2c739fce673b> & __ptr64,unsigned __int64 const & __ptr64>+7Bh (013F8712ABh)  
000000013F871269  inc         r9  
000000013F87126C  cmp         r9,qword ptr [rbx]   ; loop upper-bound in memory
000000013F87126F  jb  loopv2  ;         measure_execution_time<std::chrono::steady_clock,std::chrono::duration<__int64,std::ratio<1,1000> >,<lambda_fb2a7610a6d36531125f2c739fce673b> & __ptr64,unsigned __int64 const & __ptr64>+22h (013F871252h)  
    auto time_end = Clock::now();
000000013F871271  lea         rcx,[time_end]  
000000013F871276  call        std::chrono::steady_clock::now (013F871110h)

【问题讨论】:

  • 这可能是一些激进的 UB 优化,尽管我看不到 UB,除非您使用签名类型进行实例化。
  • 大会怎么说?
  • 高分辨率计时是一门黑色艺术。我注意到您的方法 2. 在方法 1 之后调用。优势很可能是由于在第二种方法运行时指令缓存处于更好的位置,从而使其具有优势。如果先运行方法 2 然后再运行方法 1,您是否看到相同的优势?
  • @P.Hinker 我已经添加了确切的代码,可以提供我谈到的时间。一开始我不想包含它,因为我想保持帖子简短。
  • 无法重现。您最好链接到可靠的测试,并在每种情况下提供为测试循环生成的程序集。

标签: c++ performance visual-c++ x86 atomic


【解决方案1】:

gcc 生成的代码与 MSVC 不同。 v1 和 v2 代码非常相似。请参阅涉及lock addin the asm on godbolt 的循环。它们以相同的速度运行(在我的 Sandybridge CPU 上),仅在 lock add 吞吐量上遇到瓶颈(每 19 个周期一个,剩余大量执行资源来处理其他指令。有关 insn 延迟/吞吐量表,请参阅 http://agner.org/optimize,以及一些不错的指南。)根据ocperf.py,gcc 的 v2 每周期运行 0.17 条指令,每周期 0.61 微指令(融合域)。

MSVC 为 v2 做了一些奇怪的事情,但我仍然无法解释为什么会出现除 lock xadd 吞吐量之外的瓶颈,因为锁定的指令非常慢。我认为可能重要的唯一区别是 v2 使用lock xadd 的寄存器地址,而不是绝对地址。

lock xadd   qword ptr [ac],rcx    ; v1: ac is a 32bit absolute address

lock xadd   qword ptr [rax],r8    ; v2: rax is a pointer (reloaded from memory every time through the loop)

Agner Fog 的 microarch 文档没有详细说明 locked 指令如何影响周围指令的执行,例如根据解码到的微指令数量,它们占用执行端口的时间是否比您预期的要长。在 Sandybridge 之前,他的指令表甚至没有 uop 计数(可能是因为 SnB 引入了 uop 缓存,这使得 uops 的数量更重要。)

MSVC 的 v2 将指向原子变量的指针和循环上限 (n) 留在内存中,并在每次迭代时加载它们。不过,这应该不是问题。它们将在 L1 缓存中保持热,并且每次迭代额外的两个加载微指令不应该是一个因素。我没有要测试的 Nehalem,因此可能不值得在该循环周围包装一些 register-init 代码来在我的 SandyBridge 上尝试它(我没有 MSVC,甚至没有 Windows 计算机来测试)。我见过额外的内存操作实际上稍微加快了一些代码的情况,但这对于这种情况来说似乎很奇怪。

并不是说 uop 吞吐量应该接近一个因素,但根据 IACA 的说法,循环的底部仍然是微观和宏观在 Nehalem 上融合到一个带有内存操作数的比较和分支。

cmp   r9,qword [rbx]   ; loop upper-bound in memory
jb    loopv2

IACA 表示,在 Nehalem 上 v2 循环总共有 12 条微指令。不过,我认为它不能正确解释锁定指令的有限吞吐量,因为它认为循环将以每 3.2 个时钟一次迭代运行。它说 v1 是 8 uops,应该每 3.0 时钟运行一次迭代。因此,IACA 没有对 CPU 行为进行足够详细的建模,无法说明对这种情况有用的任何内容。两个循环都应该从 Nehalem 上的 28uop 循环缓冲区运行,并不是说前端几乎是每个周期一个指令以下的瓶颈!


我之前对具有额外 MFENCE 的代码的猜测是基于误读源代码,认为循环计数器是原子的,而不仅仅是原子的 size_type 的整数。这没有意义,顺便说一句:

循环计数器的大小应基于迭代计数,而不是您正在测试atomic_counter 的任何随机类型。如果它是慢速浮点或扩展精度类型或其他东西,您也不想在循环开销上付出代价。测试一个 8 位原子计数器会造成无限循环。无论如何,不​​是这里的问题。编写的代码适用于简单的整数类型。

【讨论】:

  • 循环计数器不是原子的,它是atomic_counter&lt;&gt;::size_type,当没有指定模板参数时是std::size_t。我会尽快得到组装的。无论如何,我用int 对其进行了测试,它给了我相同的结果。
  • @user2296177:哦,对,当然。我现在看到了>.lock xadd 在循环中,还有一些其他的内存操作。晚饭后我会再看一眼并更新我的答案。
  • 是的,问题中的代码仍然可以重现它。唯一引起我注意的是第二版完全基于寄存器,但我可能错了,因为我对 ASM 不太熟悉。
  • @user2296177:asm 中某些内容的方括号取消引用它。所以[rax]作为源或目标操作数是rax寄存器的值所指向的内存位置。 (LEA 是规则的一个例外:它加载地址,而不是值。在 XADD 之后,lea rax, [r8+1]` 执行 rax = r8+1,而不是从内存加载。这样做是因为 xadd将 old 值留在源寄存器中,但您的代码会与新值进行比较。(无论如何,它都是糟糕的 asm,它可能只是对 XADD 设置的进位标志(或零标志)进行条件分支). 而不是 lea/test)
  • 是的,我很确定,因为我可以按任何顺序执行测试,而且我总是能在 v2 中获得更好的结果;这一定是一个非常奇怪的边缘案例。
【解决方案2】:

看起来这两个成员函数被内联了,但生成的代码却大不相同:

.L5:
        movq    $-1, %rdx
        lock xaddq      %rdx, (%rsp)
        testq   %rdx, %rdx
        je      .L14
        subq    $1, %rax
        jne     .L5

.L3:
        lock addq       $1, (%rsp)
        subq    $1, %rax
        jne     .L3

我在 fedora 上使用 g++ 5.1.1,您需要生成程序集(带有 -S 编译器标志)并查看您的编译器在做什么。很难想象第一个版本运行得更快。

【讨论】:

  • 这看起来像您将 operator-- 的程序集与 operator++ 的程序集进行比较,但测试和问题仅涉及 operator++,即有和没有 if 和投掷。
  • 在我的 Sandybridge 桌面上使用 gcc 4.9.2、g++ -std=gnu++1y -O3 -march=native:它们都编译为以相同速度运行的几乎相同的循环。在带有检查的版本中lock add $1, (%rsp) 之后有一个je exception_handler,但循环仍然受到lock add 吞吐量的瓶颈。 IDK MSVC 在做什么,但显然它在做一些与 gcc 不同的事情,因为你是对的,gcc 的 v2 不能更快。
【解决方案3】:

编译器应该可以毫无问题地检测到您的测试程序中永远不会满足 if 条件。因此,您正在计时的是优化中的随机性和其他奇怪效果的某种组合;例如也许第一次循环迭代会触发页面错误,因为您还没有访问内存。

【讨论】:

  • gcc 4.9.2 仍然在每次迭代时检查它,即使在 main 的内联版本中也是如此。我猜编译器不擅长决定没有其他人可以引用原子变量,并优化检查。虽然,gcc 确实根据寄存器检查循环条件,而不是添加的结果。因此,它利用了原子是该优化的局部变量这一事实。此外,当您查看所有迭代的毫秒数如何相同时,您的页面错误理论没有任何意义,并且两者在启动时都有一些异常值。
  • @PeterCordes:您没有注意到发布答案的时间和问题的编辑时间。自此答案以来,问题已经改变。现在它对我来说是不可复制的。
【解决方案4】:

ordering of tests 的乐趣。

g++ -std=c++17 -O3 -Wall -pedantic -pthread main.cpp && ./a.out 平均 1:159 毫秒 平均 2:165 毫秒

这里首先运行“Average 2”测试。

显然,它为以下“平均 1”测试准备好泵(准备工作)。

【讨论】:

  • 我明白这一点,而且我的问题代码有点误导,因为实际结果(给我带来很大差异的结果)没有可能导致更好的时间发生的启动 # 2.
  • @user2296177:使用您的最新测试,我无法重现。相反,在 MinGW g++ 5.1.0 和 Visual C++ 2015 中,简单的operator++ 运行时间约为 170 毫秒,而检查的operator++ 运行时间为 335 毫秒。对于 g++,我使用了libatomic implementation from gcc pages。这是一台运行 Windows 7 的旧笔记本电脑。如果您对较长的执行时间感到好奇。 :)
  • 这很奇怪。我正在使用默认的 VC++ 实现。无论我做什么,版本 2 在完全优化发布模式下总是更快,但在调试模式下更慢。感谢您对您的系统进行测试!当我得到这样的结果时,很难正确决定使用哪一个。我想界面可以提供一个选中和未选中的版本。
  • 嗯,我刚刚在 MSVC 中使用了 /O2,但我现在尝试使用 /Ox。结果相同。 pastie.org/10489923
  • @PeterCordes:“对于几毫秒的差异,这还不够充分的解释”,这与事实争论不休。事实是它解释了几毫秒的差异,大约 10。关于 2x 差异的评论,是的,我确实犯了那个错误,抱歉。谢谢。当我更正并重新测试时,没有显着差异,因此无法重现。投票结束该问题,因为它不可重现。
猜你喜欢
  • 2013-09-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-06-18
  • 2012-11-14
  • 1970-01-01
  • 2022-07-24
相关资源
最近更新 更多