Linux カーネルで可能性が高いマクロと可能性が低いマクロはどのように機能し、その利点は何ですか?
-
02-07-2019 - |
質問
Linux カーネルの一部を調べてみたところ、次のような呼び出しを見つけました。
if (unlikely(fd < 0))
{
/* Do something */
}
または
if (likely(!err))
{
/* Do something */
}
それらの定義を見つけました。
#define likely(x) __builtin_expect((x),1)
#define unlikely(x) __builtin_expect((x),0)
最適化のためのものであることはわかりますが、どのように機能するのでしょうか?また、それらを使用すると、どの程度のパフォーマンス/サイズの削減が期待できますか?また、少なくともボトルネック コード (もちろんユーザー空間内) において、手間をかける (そしておそらく移植性を失う) 価値があるでしょうか。
解決
これらは、分岐予測がジャンプ命令の「可能性の高い」側を優先するようにする命令を発行するコンパイラーへのヒントです。これは大きな勝利となる可能性があります。予測が正しければ、ジャンプ命令は基本的に無料であり、サイクルがゼロになることを意味します。一方、予測が間違っている場合は、プロセッサ パイプラインをフラッシュする必要があり、数サイクルのコストがかかる可能性があることを意味します。ほとんどの場合、予測が正しい限り、これはパフォーマンスに良い影響を与える傾向があります。
このようなパフォーマンスの最適化と同様に、コードが実際にボトルネックになっていることを確認するための広範なプロファイリングの後にのみ実行する必要があります。おそらく、コードが緊密なループで実行されているという微細な性質を考慮してです。一般に、Linux 開発者はかなり経験豊富なので、彼らがそうしただろうと想像します。彼らは gcc のみを対象としているため、移植性についてはあまり気にしておらず、生成したいアセンブリについて非常に近いアイデアを持っています。
他のヒント
これらは、分岐がどの方向に進むかについてコンパイラにヒントを与えるマクロです。マクロは、GCC 固有の拡張機能が利用可能な場合には拡張されます。
GCC はこれらを使用して分岐予測を最適化します。たとえば、次のようなものがある場合
if (unlikely(x)) {
dosomething();
}
return x;
その後、このコードを次のように再構築できます。
if (!x) {
return x;
}
dosomething();
return x;
この利点は、プロセッサが初めて分岐するときに、さらに先のコードを投機的にロードして実行している可能性があるため、大幅なオーバーヘッドが発生することです。分岐を行うと判断した場合は、それを無効にして分岐ターゲットから開始する必要があります。
現在、ほとんどの最新のプロセッサには何らかの分岐予測機能が備わっていますが、それは以前にその分岐を通過したことがあり、その分岐がまだ分岐予測キャッシュにある場合にのみ役立ちます。
これらのシナリオでコンパイラーとプロセッサーが使用できる戦略は他にも多数あります。分岐予測器がどのように機能するかについて詳しくは、Wikipedia を参照してください。 http://en.wikipedia.org/wiki/Branch_predictor
逆コンパイルして、GCC 4.8 が何を行うかを見てみましょう。
それなし __builtin_expect
#include "stdio.h"
#include "time.h"
int main() {
/* Use time to prevent it from being optimized away. */
int i = !time(NULL);
if (i)
printf("%d\n", i);
puts("a");
return 0;
}
GCC 4.8.2 x86_64 Linux でコンパイルおよび逆コンパイルします。
gcc -c -O3 -std=gnu11 main.c
objdump -dr main.o
出力:
0000000000000000 <main>:
0: 48 83 ec 08 sub $0x8,%rsp
4: 31 ff xor %edi,%edi
6: e8 00 00 00 00 callq b <main+0xb>
7: R_X86_64_PC32 time-0x4
b: 48 85 c0 test %rax,%rax
e: 75 14 jne 24 <main+0x24>
10: ba 01 00 00 00 mov $0x1,%edx
15: be 00 00 00 00 mov $0x0,%esi
16: R_X86_64_32 .rodata.str1.1
1a: bf 01 00 00 00 mov $0x1,%edi
1f: e8 00 00 00 00 callq 24 <main+0x24>
20: R_X86_64_PC32 __printf_chk-0x4
24: bf 00 00 00 00 mov $0x0,%edi
25: R_X86_64_32 .rodata.str1.1+0x4
29: e8 00 00 00 00 callq 2e <main+0x2e>
2a: R_X86_64_PC32 puts-0x4
2e: 31 c0 xor %eax,%eax
30: 48 83 c4 08 add $0x8,%rsp
34: c3 retq
メモリ内の命令順序は変更されませんでした。まず最初に printf
その後 puts
そしてその retq
戻る。
と __builtin_expect
今すぐ交換してください if (i)
と:
if (__builtin_expect(i, 0))
そして次のようになります:
0000000000000000 <main>:
0: 48 83 ec 08 sub $0x8,%rsp
4: 31 ff xor %edi,%edi
6: e8 00 00 00 00 callq b <main+0xb>
7: R_X86_64_PC32 time-0x4
b: 48 85 c0 test %rax,%rax
e: 74 11 je 21 <main+0x21>
10: bf 00 00 00 00 mov $0x0,%edi
11: R_X86_64_32 .rodata.str1.1+0x4
15: e8 00 00 00 00 callq 1a <main+0x1a>
16: R_X86_64_PC32 puts-0x4
1a: 31 c0 xor %eax,%eax
1c: 48 83 c4 08 add $0x8,%rsp
20: c3 retq
21: ba 01 00 00 00 mov $0x1,%edx
26: be 00 00 00 00 mov $0x0,%esi
27: R_X86_64_32 .rodata.str1.1
2b: bf 01 00 00 00 mov $0x1,%edi
30: e8 00 00 00 00 callq 35 <main+0x35>
31: R_X86_64_PC32 __printf_chk-0x4
35: eb d9 jmp 10 <main+0x10>
の printf
(コンパイルして __printf_chk
) は関数の最後に移動されました。 puts
他の回答で述べたように、分岐予測を改善するための復帰。
したがって、基本的には次と同じです。
int i = !time(NULL);
if (i)
goto printf;
puts:
puts("a");
return 0;
printf:
printf("%d\n", i);
goto puts;
この最適化は次のものでは行われませんでした -O0
.
ただし、より高速に実行されるサンプルを作成できるよう頑張ってください。 __builtin_expect
ないよりも、 今のCPUは本当に賢いですね. 。私の素朴な試み あります.
これらにより、ハードウェアがサポートする適切な分岐ヒントがコンパイラーに発行されます。これは通常、命令オペコードの数ビットをいじるだけであるため、コード サイズは変わりません。CPU は予測された位置から命令のフェッチを開始し、分岐に到達したときにパイプラインが間違っていることが判明した場合はパイプラインをフラッシュして最初からやり直します。ヒントが正しい場合、これにより分岐が大幅に高速化されます。正確にどれくらい高速になるかはハードウェアによって異なります。これがコードのパフォーマンスにどの程度影響するかは、ヒントが正しい時間の割合によって決まります。
たとえば、PowerPC CPU では、ヒントなしの分岐には 16 サイクル、正しくヒントが与えられた分岐には 8 サイクル、間違ってヒントが与えられた分岐には 24 サイクルかかる可能性があります。最も内側のループでは、適切なヒンティングが大きな違いを生む可能性があります。
移植性は実際には問題ではありません。おそらく定義はプラットフォームごとのヘッダーにあると思われます。静的分岐ヒントをサポートしていないプラットフォームでは、「可能性が高い」と「可能性が低い」を何も定義しないだけで済みます。
long __builtin_expect(long EXP, long C);
このコンストラクトは、式Expが値Cを持つ可能性が高いことをコンパイラに伝えます。戻り値はEXPです。__builtin_expect 条件付き式で使用することを意図しています。ほとんどすべての場合、ブール式のコンテキストで使用されます。この場合、2つのヘルパーマクロを定義する方がはるかに便利です。
#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)
これらのマクロは次のように使用できます。
if (likely(a > 1))
(一般的なコメント - 他の回答で詳細がカバーされます)
これらを使用することで移植性が失われる必要はありません。
他のコンパイラを使用して他のプラットフォームでコンパイルできるようにする、単純な nil 効果の「インライン」またはマクロを作成するオプションが常にあります。
他のプラットフォームを使用している場合は、最適化の恩恵を受けることができません。
さんのコメントによると、 コーディ, 、これは Linux とは関係ありませんが、コンパイラへのヒントです。何が起こるかは、アーキテクチャとコンパイラのバージョンによって異なります。
Linux のこの特定の機能は、ドライバーで多少誤って使用されます。として osgx で指摘する ホット属性のセマンティクス, 、 どれでも hot
または cold
ブロック内で呼び出される関数は、条件の可能性が高いかどうかを自動的に示唆できます。例えば、 dump_stack()
マークされています cold
これは冗長ですので、
if(unlikely(err)) {
printk("Driver error found. %d\n", err);
dump_stack();
}
の将来のバージョン gcc
これらのヒントに基づいて関数を選択的にインライン化できます。そうではないという指摘もある boolean
, 、しかしスコアは次のようになります 最も可能性が高い, 、など。一般に、次のような代替メカニズムを使用することをお勧めします。 cold
. 。ホットパス以外の場所で使用する理由はありません。あるアーキテクチャーでコンパイラーが行うことは、別のアーキテクチャーではまったく異なる可能性があります。
多くの Linux リリースでは、 /usr/linux/ に complier.h があり、これを含めるだけで使用できます。また、別の意見としては、likely() よりも possible() の方が便利です。
if ( likely( ... ) ) {
doSomething();
}
多くのコンパイラでも同様に最適化できます。
ちなみに、コードの詳細な動作を観察したい場合は、次のように簡単に実行できます。
gcc -c test.c objdump -d test.o> obj.s
次に、obj.s を開くと、答えが見つかります。
これらは、コンパイラが分岐にヒント接頭辞を生成するためのヒントです。x86/x64 では、これらは 1 バイトを占めるため、ブランチごとに最大 1 バイトの増加しか得られません。パフォーマンスに関しては、完全にアプリケーションに依存します。最近では、ほとんどの場合、プロセッサ上の分岐予測機能はそれらを無視します。
編集:彼らが本当に助けてくれる場所を一つ忘れていました。これにより、コンパイラが制御フロー グラフの順序を変更して、「可能性の高い」パスに対して選択される分岐の数を減らすことができます。これにより、複数の終了ケースをチェックするループで顕著な改善が得られる可能性があります。
これらは、プログラマが、特定の式で最も可能性の高い分岐条件が何であるかについてコンパイラにヒントを与えるための GCC 関数です。これにより、コンパイラは、最も一般的なケースで実行する命令の数が最小限になるように分岐命令を構築できるようになります。
分岐命令がどのように構築されるかは、プロセッサのアーキテクチャによって異なります。