マイクロベンチマークを書いていたのですが、表題のようなことが起こったのでメモです。
環境
- 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部のマージは行われません。