在计算中使用布尔值以避免分支

Using bools in calculations to avoid branches

本文关键字:分支 布尔值 计算      更新时间:2023-10-16

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

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 进行这种优化?

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

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

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 倍以上。

在所有情况下,我的位黑客技巧都是最快的,除了分支获胜的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;
}

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% 左右。 这可能是由于在模拟运行时零星执行系统任务, 它变得不那么零星,即运行模拟的时间越长,一致性(虽然这只是我的假设,但我不知道这是否真的是真的(

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

<小时 />

更多:

<小时 />

当然,通过随机生成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<int>(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<int>(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<int>(running);将始终生成相同的结果,即 0,所以它也不会打扰执行。

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

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

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