前記事「C++20の[[likely]][[unlikely]]属性を使ってみた」にて、同一プログラムに対して[[likely]]および[[unlikely]]属性を正しく指定した場合および故意に逆に指定した場合、指定しなかった場合について処理時間の比較を行った。その後、記事に対して、そのプログラムの処理内容では比較の意味がない旨のコメントをいただいた。
今回、プログラムを変えて前回と同様の比較を行った。とはいえ、今回のプログラムも[[likely]]や[[unlikely]]の効果を充分に評価するには不十分だとは思う。不備があることを承知での実験結果は、[[likely]]や[[unlikely]]を誤って指定すると性能低下の可能性があり得るように思えた。
実験方法
同一のプログラムに対して、以下のようにした場合の実行時の処理時間を比較した。
-
[[likely]]および[[unlikely]]を正しく指定した場合 -
[[likely]]および[[unlikely]]を故意に逆に指定した場合 -
[[likely]]および[[unlikely]]を全く指定しなかった場合
処理時間の比較の仕方
- timeコマンドで得られるユーザCPU時間とシステムCPU時間の合計を処理時間とみなした
- 一つの測定パターンにつき100回測定し、それぞれのパターンについて、結果の上位10個と下位10個を除いた80回の平均を求めた (結果の上位と下位10個ずつを除く理由は、偶発的に良い結果や悪い結果が出た場合の影響を避けるためである)
- Ubuntu24.04(x86_64版)のGCC(バージョン13.3.0)を用いた
- GCCの最適化オプション指定として、
-O0,-O1,-O2,-O3,-Osを比較した
比較に用いたプログラム
以下のプログラムにて、コンパイル時にマクロ「CORRECT」が定義されていれば、[[likely]]および[[unlikely]]が正しく指定される。マクロ「INCORRECT」が定義されていれば、[[likely]]および[[unlikely]]が故意に逆に指定される。マクロ「CORRECT」「INCORRECT」のどちらも定義されていなければ、[[likely]]および[[unlikely]]の指定はされない。
#include <stdio.h>
#include <stdlib.h>
extern int dummy(int i);
#ifdef CORRECT
#define LIKELY [[likely]]
#define UNLIKELY [[unlikely]]
#elifdef INCORRECT
#define LIKELY [[unlikely]]
#define UNLIKELY [[likely]]
#else
#define LIKELY
#define UNLIKELY
#endif
#define DUMMY1(I) if (dummy(I)) { retfunc = R1; }
#define DUMMY2(I) if (dummy(I)) { retfunc = R2; }
enum RetFunc : int {R_ILLEGAL = -1, R0 = 0, R1, R2};
// [[likely]]および[[unlikely]]を指定した分岐を含む関数
// 引数i1とi2は、低確率の分岐を生じさせるように指定する
int func(int i, int i1, int i2) {
enum RetFunc retfunc = R_ILLEGAL;
if (i == i1) UNLIKELY {
DUMMY1(i1);
DUMMY1(i1 * 2);
DUMMY1(i1 * 3);
DUMMY1(i1 * 4);
DUMMY1(i1 * 5);
}
else if (i == i2) UNLIKELY {
DUMMY2(i2);
DUMMY2(i2 * 2);
DUMMY2(i2 * 3);
DUMMY2(i2 * 4);
DUMMY2(i2 * 5);
} else LIKELY {
retfunc = R0;
}
return retfunc;
}
// コンパイラの最適化によってfunc関数内の処理が省かれてしまっていないことを
// 確認するための関数
void check(int i, int i1, int i2, int r) {
if ((i == i1 && r == RetFunc::R1) || (i == i2 && r == RetFunc::R2)) {
return;
} else if (r == RetFunc::R0) {
return;
}
printf("Error\n");
exit(-1);
}
上記func関数およびcheck関数を呼び出すmain関数、上記func関数から呼び出されるdummy関数(上記とはコンパイル単位を分けている)
#define LOOP 500000000
int main() {
for (int i = 0; i < LOOP; ++i) {
int i1 = -1;
int i2 = -1;
if ((i % 11) == 0) {
i1 = 1;
}
if ((i % 17) == 0) {
i2 = i;
}
int r = func(i, i1, i2);
check(i, i1, i2, r);
}
return 0;
}
// ダミーの処理
static const int MAX_I = 10;
int dummy(int i) {
if (i < 1 || i > MAX_I) {
i = MAX_I;
}
int r = 1;
for (int j = 1; j <= 1; ++j) {
r *= j;
}
return r;
}
測定結果
それぞれのパターン([[likely]]および[[unlikely]]を正しく指定/故意に逆に指定/指定せず)について、コンパイル時の最適化オプションを変えての実行時間である。(単位は秒)
| -O0 | -O1 | -O2 | -O3 | -Os | |
|---|---|---|---|---|---|
| 正しく指定 | 4.233 | 3.480 | 3.519 | 3.524 | 3.514 |
| 逆に指定 | 4.194 | 3.716 | 3.911 | 3.925 | 3.561 |
| 指定せず | 4.098 | 3.193 | 3.445 | 3.458 | 3.450 |
測定結果から感じること
- 正直、あまり意味のない比較かもしれない
- 短いコードなので、分岐の結果によらずCPUキャッシュがヒットするだろう
- 全体の処理時間の中で、分岐予測ミスやコード配置による違いの比重は小さい
-
-O0や-Osで三者がだいたい同じになる以外は、大雑把には、「指定せず」≧「正しく指定」>「逆に指定」 である-
-O0で三者の処理時間がだいたい同じなのは、妥当だろう- コンパイル結果をアセンブラレベルで比較したわけではないが、最適化をしないので
[[likely]]や[[unlikely]]の指定は考慮されないと思われるので
- コンパイル結果をアセンブラレベルで比較したわけではないが、最適化をしないので
- (-O1で「指定せず」が「正しく指定」よりも明らかに良いのは気になる…)
-
上記より、[[likely]]や[[unlikely]]を不適切に指定した場合、処理性能を損なう可能性が考えられる。正しく指定した場合の効果は今回のコードで判断するのは厳しいが、誤って指定してしまうリスクがあるなら無理に指定しなくても良いとは思う。正しく指定して効果が得られるような分岐処理がどれくらいあるのか分からないし。
まとめ
今回試した範囲では、[[likely]]および[[unlikely]]を不適切に指定してしまった場合に処理性能を損なう可能性が感じられた。処理内容によっては[[likely]]や[[unlikely]]を適切に指定した場合に効果があるのかもしれないが、誤って不適切な指定をしてしまうリスクがあるのなら、特に指定しないのが無難だとは思う。
