【问题标题】:Using bools in calculations to avoid branches在计算中使用布尔值来避免分支
【发布时间】:2013-12-08 10:56:51
【问题描述】:

这是我想出的一点微优化好奇心:

struct Timer {
    bool running{false};
    int ticks{0};

    void step_versionOne(int mStepSize) {
        if(running) ticks += mStepSize;
    }

    void step_versionTwo(int mStepSize) {
        ticks += mStepSize * static_cast<int>(running);
    }
};

这两种方法似乎实际上做同样的事情。第二个版本是否避免了分支(因此比第一个版本更快),或者任何编译器都能够使用-O3 进行这种优化?

【问题讨论】:

  • 是否指定true必须为1或者可以是任何非零值?
  • @JonasWielicki:根据标准(第 4.5 节整数提升),true 提升为 1,false 提升为 0。 bool 不允许使用其他值。
  • 令人惊讶的是,GCC 不执行此优化。它分别生成比较和分支指令或乘法指令。 (请注意,您不需要在算术表达式中将 static_castbool 提升为 int。)
  • 请注意,使用乘法不一定比分支更快,特别是如果分支是可预测的。 2 补码上还有 ticks += mStepSize &amp; -static_cast&lt;int&gt;(running)可能比那些要快。
  • @VittorioRomeo GCC 4.7,-O3

标签: c++ optimization c++11 boolean micro-optimization


【解决方案1】:

是的,你的技巧可以避免分支,而且它可以让它更快......有时。

我编写了基准来比较各种情况下的这些解决方案,以及我自己的:

ticks += mStepSize & -static_cast<int>(running)

我的结果如下:

Off:
 branch: 399949150
 mul:    399940271
 andneg: 277546678
On:
 branch: 204035423
 mul:    399937142
 andneg: 277581853
Pattern:
 branch: 327724860
 mul:    400010363
 andneg: 277551446
Random:
 branch: 915235440
 mul:    399916440
 andneg: 277537411

Off 是关闭计时器的时间。在这种情况下,解决方案大约需要相同的时间。

On 是它们打开的时候。分支解决方案快两倍。

Pattern 是当它们处于 100110 模式时。性能相似,但分支要快一些。

Random 是分支不可预测的时候。在这种情况下,乘法的速度要快 2 倍以上。

在所有情况下,我的 bit-hacking 技巧都是最快的,除了 On 分支获胜。

请注意,此基准不一定代表所有编译器版本的处理器等。即使是基准的微小变化也会使结果颠倒(例如,如果编译器可以内联知道 mStepSize1,那么实际上乘法可能是最快的) .

基准代码:

#include<array>
#include<iostream>
#include<chrono>

struct Timer {
    bool running{false};
    int ticks{0};

    void branch(int mStepSize) {
        if(running) ticks += mStepSize;
    }

    void mul(int mStepSize) {
        ticks += mStepSize * static_cast<int>(running);
    }

    void andneg(int mStepSize) {
        ticks += mStepSize & -static_cast<int>(running);
    }
};

void run(std::array<Timer, 256>& timers, int step) {
    auto start = std::chrono::steady_clock::now();
    for(int i = 0; i < 1000000; i++)
        for(auto& t : timers)
            t.branch(step);
    auto end = std::chrono::steady_clock::now();
    std::cout << "branch: " << (end - start).count() << std::endl;
    start = std::chrono::steady_clock::now();
    for(int i = 0; i < 1000000; i++)
        for(auto& t : timers)
            t.mul(step);
    end = std::chrono::steady_clock::now();
    std::cout << "mul:    " << (end - start).count() << std::endl;
    start = std::chrono::steady_clock::now();
    for(int i = 0; i < 1000000; i++)
        for(auto& t : timers)
            t.andneg(step);
    end = std::chrono::steady_clock::now();
    std::cout << "andneg: " << (end - start).count() << std::endl;
}

int main() {
    std::array<Timer, 256> timers;
    int step = rand() % 256;

    run(timers, step); // warm up
    std::cout << "Off:\n";
    run(timers, step);
    for(auto& t : timers)
        t.running = true;
    std::cout << "On:\n";
    run(timers, step);
    std::array<bool, 6> pattern = {1, 0, 0, 1, 1, 0};
    for(int i = 0; i < 256; i++)
        timers[i].running = pattern[i % 6];
    std::cout << "Pattern:\n";
    run(timers, step);
    for(auto& t : timers)
        t.running = rand()&1;
    std::cout << "Random:\n";
    run(timers, step);
    for(auto& t : timers)
        std::cout << t.ticks << ' ';
    return 0;
}

【讨论】:

  • 感谢您的测试!结果非常有趣,但仍然令人惊讶。似乎 best 方式是andneg - 我很惊讶编译器没有应用该优化。我也对分支比乘法更快的事实感到惊讶。猜猜实际上没有可遵循的最佳通用指南?
  • @VittorioRomeo,我认为最好的通用指导方针是等待微优化,直到你有工作程序,测试最适合你的应用程序,而不是过度依赖在微基准上。
  • andneg 实际上可以进一步优化为仅and,如果不是保持runningfalse/true 你会保持ints 0/ -1.
  • 确实需要提到“位黑客技巧”取决于实现定义的有符号整数的 2 的补码表示,这并不是它们在所有平台上的表示方式,所以在理论上会导致不可移植的代码,如果移植到不太传统的平台(如果这样的东西不再存在,并且他们永远不会在标准中要求 2c 表示),它可能会以奇怪的方式破坏。
【解决方案2】:

Does the second version avoid a branch

如果你编译你的代码以获得汇编输出,g++ -o test.s test.cpp -S,你会发现在第二个函数中确实避免了一个分支。

and consequently, is faster than the first version

我运行您的每个函数2147483647INT_MAX 的次数,在每次迭代中我随机分配一个布尔值给Timer 结构的running 成员,使用以下代码:

int main() {
    const int max = std::numeric_limits<int>::max();
    timestamp_t start, end, one, two;
    Timer t_one, t_two;
    double percent;

    srand(time(NULL));

    start = get_timestamp();
    for(int i = 0; i < max; ++i) {
        t_one.running = rand() % 2;
        t_one.step_versionOne(1);
    }
    end = get_timestamp();
    one = end - start;

    std::cout << "step_versionOne      = " << one << std::endl;

    start = get_timestamp();
    for(int i = 0; i < max; ++i) {
        t_two.running = rand() % 2;
        t_two.step_versionTwo(1);
    }
    end = get_timestamp();
    two = end - start;

    percent = (one - two) / static_cast<double>(one) * 100.0;

    std::cout << "step_versionTwo      = " << two << std::endl;
    std::cout << "step_one - step_two  = " << one - two << std::endl;
    std::cout << "one fast than two by = " << percent << std::endl;
 }

这些是我得到的结果:

step_versionOne      = 39738380
step_versionTwo      = 26047337
step_one - step_two  = 13691043
one fast than two by = 34.4529%

所以是的,第二个函数显然更快,大约 35%。请注意,对于较少的迭代次数,定时性能的百分比增加在 30% 到 55% 之间变化,而运行时间越长,它似乎稳定在 35% 左右。这可能是由于在模拟运行时系统任务的零星执行,这变得不那么零星了,即运行 sim 的时间越长就越一致(虽然这只是我的假设,我不知道它是否真的是真的)

总之,好问题,我今天学到了一些东西!


更多:


当然,通过随机生成running,我们实质上是在第一个函数中使分支预测无用,所以上面的结果并不令人惊讶。但是,如果我们决定在循环迭代期间不更改 running 而是将其保留为默认值,在本例中为 false,分支预测将在第一个函数中发挥其魔力,实际上会快近 20%正如这些结果所示:

step_versionOne      = 6273942
step_versionTwo      = 7809508
step_two - step_one  = 1535566
two fast than one by = 19.6628

因为running 在整个执行过程中保持不变,请注意模拟时间比随机变化的running 短得多 - 可能是编译器优化的结果。

为什么在这种情况下第二个函数变慢了?好吧,分支预测将很快意识到第一个函数中的条件从未满足,因此将首先停止检查(好像if(running) ticks += mStepSize; 甚至不存在)。另一方面,第二个函数仍然必须在每次迭代中执行这条指令ticks += mStepSize * static_cast&lt;int&gt;(running);,从而使第一个函数更高效。

但是如果我们将running 设置为true 会怎样?好吧,分支预测将再次启动,但是,这一次,第一个函数必须在每次迭代中评估 ticks += mStepSize;;这里是running{true}时的结果:

step_versionOne      = 7522095
step_versionTwo      = 7891948
step_two - step_one  = 369853
two fast than one by = 4.68646

请注意,无论running 始终是true 还是falsestep_versionTwo 所花费的时间都是一致的。但它仍然比step_versionTwo 花费更长的时间,但时间很短。好吧,这可能是因为我懒得运行它很多次来确定它是否始终更快或者它是否是一次侥幸(每次运行它时结果都会略有不同,因为操作系统必须在后台运行它并不总是会做同样的事情)。但如果它始终更快,可能是因为函数二 (ticks += mStepSize * static_cast&lt;int&gt;(running);) 的算术运算比函数一 (ticks += mStepSize;) 多。

最后,让我们进行优化 - g++ -o test test.cpp -std=c++11 -O1 进行编译,然后将 running 还原为 false,然后检查结果:

step_versionOne      = 704973
step_versionTwo      = 695052

大致相同。编译器将执行其优化传递,并意识到running 始终是false,因此,出于所有意图和目的,将删除step_versionOne 的主体,因此当您从main 的循环中调用它时,它'只会调用函数并返回。

另一方面,在优化第二个函数时,它会意识到ticks += mStepSize * static_cast&lt;int&gt;(running);总是会产生相同的结果,即0,所以它也不会费心去执行。

总而言之,如果我是正确的(如果不正确,请纠正我,我对此很陌生),从main 循环调用这两个函数时,您将得到的只是它们的开销。

附言这是第一种情况的结果(running 在每次迭代中随机生成)在使用优化编译时。

step_versionOne      = 18868782
step_versionTwo      = 18812315
step_two - step_one  = 56467
one fast than two by = 0.299261

【讨论】:

  • 感谢您的测试!但是,这假设running 在执行期间不断变化。我想知道当running 90% 的时间都相同时,结果是否不同?
  • 我在这个基准测试中遇到了一些麻烦。 1. 你编译时没有优化。 2.您没有对结果做任何事情,因此可以优化计算。 3. 我无法编译它-我不知道get_timestamp 是什么。 4. 编译器可以内联mStepSize=1 并有效地去除乘法留下ticks += static_cast&lt;int&gt;(running);。 5. 您在基准 sn-p 中使用rand(),这可能相对昂贵。 (我没有看到更新就写了这个评论,我不知道它还有多少适用)
  • @zch get_timestamp 是使用 gettimeofday 的 UNIX 时间戳,我在最近使用的头文件中拥有该时间戳。我意识到我没有对结果做任何事情,你的观点是有效的。就rand() 而言,不知道随机更改running 的更好方法。我后来用-O1 编译(-O2-O3 到处都给了我0s?),无论running 是恒定的还是随机变化的,结果都几乎相同。
  • 这就是重点。你得到了零,因为你没有使用结果。您需要打印它或写信给volatile 或类似的东西以获得有意义的结果。
猜你喜欢
  • 1970-01-01
  • 2012-08-15
  • 2017-07-30
  • 1970-01-01
  • 1970-01-01
  • 2018-09-13
  • 2022-10-05
  • 2014-08-11
  • 1970-01-01
相关资源
最近更新 更多