分岐を増やすと速くなる不思議な現象

マイクロベンチマークを書いていたのですが、表題のようなことが起こったのでメモです。

環境

  • g++ (GCC) 12.1.0

問題のソースコード

a.cpp↓

#include <cmath>
#include <bit>
#include <tuple>

std::tuple<unsigned long, double, double> f(double w) {
    unsigned long n = std::bit_cast<unsigned long>(w) & 3;
    double x = std::fma(1.0, 1.0, w);
    double y = std::fma(1.0, 1.0, x);
    double z = std::fma(1.0, 1.0, y);
    return {n, y, z};
}

double g(double y, unsigned long n) {
    double ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
    return (n & 2) ? -ret : ret;
}

double bench(double w) {
    auto [n, y, z] = f(w);
    return g(y, n);
}

int dummy;

int main(int argc, char* argv[]) {
    for(size_t i=0; i < 100000000; ++i) {
        double w = std::bit_cast<double>((unsigned long)rand() << 24) * 1e300 * 1e10;
        double v = bench(w);
        if (dummy & 1) { dummy += v; }
    }
}

b.cpp↓

#include <cmath>
#include <bit>
#include <tuple>

std::tuple<unsigned long, double, double> f(double w) {
    unsigned long n = std::bit_cast<unsigned long>(w) & 3;
    double x = std::fma(1.0, 1.0, w);
    double y = std::fma(1.0, 1.0, x);
    double z = std::fma(1.0, 1.0, y);
    return {n, y, z};
}

double g(double y, unsigned long n) {
    double ret;
    if (n == 0)      ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
    else if (n == 1) ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
    else             ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
    return (n & 2) ? -ret : ret;
}

double bench(double w) {
    auto [n, y, z] = f(w);
    return g(y, n);
}

int dummy;

int main(int argc, char* argv[]) {
    for(size_t i=0; i < 100000000; ++i) {
        double w = std::bit_cast<double>((unsigned long)rand() << 24) * 1e300 * 1e10;
        double v = bench(w);
        if (dummy & 1) { dummy += v; }
    }
}

違いは、g関数の中身だけです。

$ diff a.cpp b.cpp
15c15,18
<     double ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
---
>     double ret;
>     if (n == 0)      ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
>     else if (n == 1) ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));
>     else             ret = 1 + y * (2 + y * (3 + y * (4 + y * (5 + y))));

さて、a.cppとb.cppはどちらが速いでしょうか。

普通に考えると、分岐の少ないa.cppの方が速いように思われます*1。 少なくとも、a.cppとb.cppの速度が同じことはあっても、b.cppの方が速いということはありえなさそうに思われます。 しかし、実際に測ってみると、b.cppの方が圧倒的に速いです(rand()を呼ぶだけのコードとほとんど同じ速さです)。 これはなぜでしょうか。

両者の違い

両者の違いは、インライン展開されるかどうかにあります。 a.cppはbench関数が小さいため、main関数の中にインライン展開されます。 一方、b.cppはbench関数がやや大きいため、main関数の中にはインライン展開されません。

速くなる原因

速くなる原因は、bench関数の結果であるvを使っていないことにあります。

a.cppでは、main関数にインライン展開されたbench関数の中身が、dummy & 1の検査の前に実行されるようなコードにコンパイルされるようです。 一方、b.cppでは、dummy & 1の検査がbench関数の呼び出しより前に行われるコード、つまりbench関数の中身が実行されないようなコードにコンパイルされるようです。

つまり、a.cppはvを使っていないにもかかわらず、運よくbench関数の中身が実行されているだけです。 b.cppはvを使っていないので順当にbench関数を実行しない最適化が行えています。

まとめ

ベンチマーク関数の結果を真には使わないようなマイクロベンチマークを書いた場合、最適化でベンチマークコードが消されないかはそのベンチマークコードの大きさによることがあります。 今回の例では、インライン展開されないような大きなコードの場合のみ働く最適化がコードを消したことで、見かけ上速度が向上していました。

*1:b.cppはthen部とelse部で仕事が同じなので分岐がなくなりそうにも見えますが、g++の場合、n==0やn==1が成立する場合はn&2が成立しないことを利用した最適化が優先され、then部とelse部のマージは行われません。