web-dev-qa-db-ja.com

ブランチ対応プログラミング

私は、ブランチの予測ミスがアプリケーションのパフォーマンスのホットボトルネックになる可能性があることについて読んでいます。私が見ることができるように、人々はしばしば問題を明らかにするAssemblyコードを示し、プログラマーは通常、ブランチが最も頻繁に行く場所を予測でき、ブランチの予測ミスを避けます。

私の質問は:

1-いくつかの高レベルプログラミングを使用して分岐予測ミスを回避することは可能ですか?テクニック(ie no Assembly)?

2-branch-friendlyコードを高水準プログラミング言語で生成するために何を覚えておくべきですか(主にCとC++に興味があります)?

コード例とベンチマークは大歓迎です!

39
Paolo M

人々はしばしば...そして、プログラマは通常ブランチがどこに行くことができるか予測できると述べています

(*)経験豊富なプログラマーは、人間のプログラマーがそれを予測するのが非常に悪いことを思い出させます。

1-いくつかの高レベルのプログラミング手法(つまり、アセンブリなし)を使用して分岐の予測ミスを回避することは可能ですか?

標準のc ++またはcではありません。少なくとも単一のブランチではそうではありません。実行できることは、依存チェーンの深さを最小限に抑え、ブランチの誤予測が影響しないようにすることです。最新のcpusはブランチの両方のコードパスを実行し、選択されなかったものをドロップします。ただし、これには制限があり、ブランチの予測が重要なのは依存関係の深いチェーンでのみ重要です。

一部のコンパイラは、gccの __ builtin_expect など、手動で予測を提案するための拡張機能を提供します。ここに stackoverflowの質問 があります。さらに良いことに、一部のコンパイラ(gccなど)はコードのプロファイリングをサポートし、最適な予測を自動的に検出します。 (*)があるため、手動作業ではなくプロファイリングを使用する方が賢明です。

2-ブランチフレンドリーなコードを高水準プログラミング言語で作成するには、何に注意すべきですか(主にCおよびC++に興味があります)?

主に、ブランチの誤予測はプログラムの最もパフォーマンスが重要な部分でのみ影響を及ぼし、問題を測定して発見するまで心配する必要がないことを覚えておく必要があります。

しかし、一部のプロファイラー(valgrind、VTuneなど)がfoo.cppの行nで分岐予測ペナルティを取得したと通知した場合、どうすればよいですか?

ランディンは非常に賢明なアドバイスをしました

  1. それが重要かどうかを調べるためにfoを測定します。
  2. それが重要な場合は、
    • 計算の依存チェーンの深さを最小限に抑えます。それを行う方法は非常に複雑で、私の専門知識を超えている可能性があり、アセンブリに飛び込むことなくできることは多くありません。高水準言語でできることは、条件チェック(**)の数を最小限に抑えることです。そうでなければ、コンパイラの最適化に翻弄されます。深い依存関係チェーンを回避することで、アウトオブオーダーのスーパースカラープロセッサをより効率的に使用することもできます。
    • ブランチを一貫して予測可能にします。その影響は、この stackoverflowの質問 で確認できます。問題は、配列のループです。ループには分岐が含まれています。ブランチは、現在の要素のサイズによって異なります。データがソートされた場合、特定のコンパイラーでコンパイルして特定のCPUで実行すると、ループがはるかに高速になることが示されました。もちろん、すべてのデータをソートしておくと、おそらくブランチの誤予測よりもCPU時間もかかりますmeasure
  3. それでも問題がある場合は、 プロファイルに基づく最適化 を使用します(可能な場合)。

2.と3.の順番は入れ替わることがあります。手作業でコードを最適化するのは大変な作業です。一方、一部のプログラムでも、プロファイリングデータの収集が困難な場合があります。

(**)これを行う1つの方法は、ループを展開するなどしてループを変換することです。オプティマイザに自動的に実行させることもできます。ただし、アンロールはキャッシュとのやり取りに影響を及ぼし、結果的に悲観的になる可能性があるため、測定する必要があります。

29
eerorika

Linuxカーネルは、__builtin_expect gccビルトインに基づいてlikelyおよびunlikelyマクロを定義します。

    #define likely(x)   __builtin_expect(!!(x), 1)
    #define unlikely(x) __builtin_expect(!!(x), 0)

include/linux/compiler.hのマクロ定義については こちら を参照)

次のように使用できます。

if (likely(a > 42)) {
    /* ... */
} 

または

if (unlikely(ret_value < 0)) {
    /* ... */
}
17
ouah

警告として、私はマイクロ最適化ウィザードではありません。ハードウェアブランチプレディクタがどのように機能するのか正確にはわかりません。私にとってそれは私がはさみ紙の石を演じる魔法の獣であり、私の心を読んでいつも私を打ち負かすことができるようです。私はデザインと建築のタイプです。

それでも、この質問は高水準の考え方に関するものだったので、いくつかのヒントを提供できるかもしれません。

プロファイリング

先ほど述べたように、私はコンピューターアーキテクチャウィザードではありませんが、VTuneを使用してコードをプロファイリングし、ブランチの予測ミスやキャッシュミスなどを測定し、常にパフォーマンスが重要な分野でそれを実行する方法を知っています。これは、これを行う方法(プロファイリング)がわからない場合に検討すべき最初のことです。これらのマイクロレベルのホットスポットのほとんどは、プロファイラーを手元に置いて後から発見するのが最適です。

ブランチエリミネーション

多くの人が、ブランチの予測可能性を向上させる方法について、いくつかの優れた低レベルのアドバイスを提供しています。場合によっては、手動でブランチプレディクタを支援し、静的ブランチ予測を最適化することもできます(たとえば、最初に一般的なケースをチェックするためにifステートメントを記述します)。 Intelからの重要な詳細に関する包括的な記事があります: https://software.intel.com/en-us/articles/branch-and-loop-reorganization-to-prevent-mispredicts

ただし、これを基本的な一般的なケース/まれなケースの予測を超えて実行することは非常に困難であり、ほとんどの場合、後で後で測定するために保存するのが最善です。人間がブランチプレディクタの性質を正確に予測するのは非常に困難です。ページフォールトやキャッシュミスなどの予測よりもはるかに予測が難しく、複雑なコードベースで完全に人間が予測することもほとんど不可能です。

ただし、分岐の予測ミスを軽減する簡単で高レベルの方法があり、それは完全に分岐を回避することです。

小さい/まれな作業のスキップ

私がキャリアの早い段階でよく犯した間違いの1つは、プロファイリングを学び、まだ直感的に進む前に、多くの仲間が開始時にしようとしているのを見ると、小規模またはまれな作業をスキップしようとすることです。 。

この例は、メガバイトにまたがるルックアップテーブルを使用してcossinを繰り返し呼び出すことを回避するなど、比較的安価な計算を繰り返し実行しないように大きなルックアップテーブルにメモ化することです。 。人間の脳にとっては、この巨大なLUTからメモリ階層を介してレジスタにメモリをロードすることを除いて、一度計算して格納する作業が節約されるように思えますが、意図した計算よりも高価になることがよくあります。保存する。

別のケースは、最適化の単純な試みとしてコード全体で不必要に実行しても無害である(正確さに影響を与えない)小さな計算を回避するために小さな分岐の束を追加することです。

最適化としてのこの分岐の素朴な試みは、わずかに高価ですがまれな作業にも適用できます。次のC++の例を見てください。

struct Foo
{
    ...
    Foo& operator=(const Foo& other)
    {
        // Avoid unnecessary self-assignment.
        if (this != &other)
        {
            ...
        }
        return *this;
    }
    ...
};

これは、ほとんどの人が値で渡されたパラメーターに対してコピーアンドスワップを使用してコピー割り当てを実装し、何があっても分岐を回避するため、多少単純化した例です。

この場合、自己割り当てを回避するために分岐しています。しかし、自己割り当てが冗長な作業のみを行っており、結果の正確性を妨げない場合は、単純に自己コピーを許可するだけで、実際のパフォーマンスを向上させることができます。

struct Foo
{
    ...
    Foo& operator=(const Foo& other)
    {
        // Don't check for self-assignment.
        ...
        return *this;
    }
    ...
};

...自己割り当ては非常にまれである傾向があるため、これは役立ちます。冗長な自己割り当てによってまれなケースを遅くしていますが、他のすべてのケースをチェックする必要を回避することで、一般的なケースを高速化しています。もちろん、分岐に関して一般的/まれなケースのスキューがあるため、分岐の予測ミスを大幅に減らすことはできませんが、存在しない分岐は予測ミスすることはできません。

小さなベクトルでの単純な試み

個人的な話として、私は以前は次のようなコードがたくさんある大規模なCコードベースで働いていました。

char str[256];
// do stuff with 'str'

...そして当然のことながら、かなり広範なユーザーベースが存在するため、一部のまれなユーザーは、最終的にソフトウェアに255文字を超える素材の名前を入力してバッファをオーバーフローさせ、segfaultにつながる可能性があります。私たちのチームはC++に取り掛かり、これらのソースファイルの多くをC++に移植し、そのようなコードを次のコードに置き換え始めました。

std::string str = ...;
// do stuff with 'str'

...これらのバッファオーバーランは、それほど手間をかけることなく解消されました。ただし、少なくとも当時は、std::stringstd::vectorのようなコンテナはヒープ(フリーストア)で割り当てられた構造であり、効率と正確性/安全性を犠牲にしていました。これらの置き換えられた領域の一部はパフォーマンスが重要であり(タイトループで呼び出されます)、これらの大量の置き換えにより多くのバグレポートを排除しましたが、ユーザーはスローダウンに気づき始めました。

そこで、これら2つの手法のハイブリッドのようなものが必要でした。私たちはそこに何かを平手打ちして、Cスタイルの固定バッファーバリアント(一般的なケースのシナリオでは完全に問題なく非常に効率的でした)を安全に実行できるようにしたかったのですが、バッファーが使用されなかったまれなケースのシナリオでも機能しますユーザー入力には十分な大きさではありません。私はチームのパフォーマンスオタクの1人であり、プロファイラーを使用している数少ない人の1人でした(残念ながら、あまりにも賢くてプロファイラーを使用していないと思っていた多くの人と一緒に作業しました)。

私の最初の素朴な試みはこのようなものでした(かなり単純化されています:実際の試みは新しい配置などを使用し、完全に標準に準拠したシーケンスでした)。一般的なケースでは固定サイズのバッファー(コンパイル時に指定されたサイズ)を使用し、サイズがその容量を超えた場合は動的に割り当てられるバッファーを使用します。

template <class T, int N>
class SmallVector
{
public:
    ...
    T& operator[](int n)
    {
        return num < N ? buf[n]: ptr[n];
    }
    ...
private:
    T buf[N];
    T* ptr;
};

この試みは完全に失敗した。構築するためのヒープ/フリーストアの価格は支払われませんでしたが、operator[]での分岐により、std::stringおよびstd::vector<char>よりもさらに悪化し、代わりにプロファイリングホットスポットとして表示されていましたmallocstd::allocatorおよびoperator newのベンダーによる実装では、内部でmallocを使用しました)。それで、コンストラクターでptrbufに単純に割り当てるというアイデアをすぐに思いつきました。 ptrbufを指すようになり、一般的なケースのシナリオでも、operator[]は次のように実装できます。

T& operator[](int n)
{
    return ptr[n];
}

...そして単純なブランチの削除により、ホットスポットはなくなりました。これで、以前のCスタイルの固定バッファソリューションとほぼ同じ速さで使用できる汎用の標準に準拠したコンテナーができました(違いは、1つの追加のポインターとコンストラクターのいくつかの命令のみです)。サイズをNより大きくする必要があるというまれなケースのシナリオを処理できます。現在では、これをstd::vectorよりも多く使用しています(ただし、この使用例では、多数の一時的で連続したランダムアクセスコンテナーが優先されるためです)。そして、それを速くすることは、operator[]のブランチを削除することに帰着しました。

一般的なケース/まれなケースのスキュー

長年プロファイリングと最適化を行って学んだことの1つは、 "absolutely-fast-everywhere"コードなどは存在しないことです。最適化という行為の多くは、非効率性を犠牲にして効率を高めています。ユーザーはあなたのコードをabsolutely-fast-everywhereとして認識しますが、これは、最適化が一般的なケース(一般的なケースは現実的なユーザーエンドと一致している場合)と一致しているスマートトレードオフに由来しますシナリオおよびホットスポットからのアクセスは、これらの一般的なシナリオを測定するプロファイラーから指摘されました)。

パフォーマンスを一般的なケースに偏らせ、まれなケースから遠ざけると、良いことが起こりがちです。一般的なケースが速くなるには、まれなケースが遅くなることがよくありますが、それは良いことです。

ゼロコストの例外処理

一般的なケース/まれなケースのスキューの例は、最新のコンパイラの多くで使用されている例外処理手法です。それらはゼロコストEHを適用しますが、これは全体的に実際には「ゼロコスト」ではありません。例外がスローされた場合、今までよりも遅くなります。ただし、例外がスローされない場合は、これまでにないほど高速になり、成功したシナリオでは次のようなコードより高速になることがよくあります。

if (!try_something())
    return error;
if (!try_something_else())
    return error;
...

代わりにここでゼロコストEHを使用し、手動でエラーのチェックと伝播を回避すると、例外的なケースでは、上記のこのコードスタイルよりも処理が速くなる傾向があります。ひどく言えば、それは分岐の減少によるものです。それと引き換えに、例外がスローされると、はるかに高価なものが発生します。それにもかかわらず、一般的なケースとまれなケースの間のスキューは、現実のシナリオを支援する傾向があります。ファイルの読み込みに失敗する(まれなケース)速度が、正常に読み込まれる(一般的なケース)ほど重要ではありません。そのため、最新のC++コンパイラの多くは "ゼロコスト" EHを実装しています。これもまた、一般的なケースとまれなケースを歪め、パフォーマンスの点で両者を遠ざけるためのものです。

Virtual Dispatch and Homogeneity

依存性が抽象化(安定した抽象化の原則など)に向かって流れるオブジェクト指向コードの多くの分岐は、動的な形式で分岐の大部分(もちろん、分岐予測子にうまく機能するループの他に)を持つことができますディスパッチ(仮想関数呼び出しまたは関数ポインター呼び出し)。

これらの場合、一般的な誘惑は、すべての種類のサブタイプを集約して、ベースポインターを格納するポリモーフィックコンテナーに入れ、それをループし、そのコンテナー内の各要素で仮想メソッドを呼び出すことです。これにより、特にこのコンテナーが常に更新されている場合は、ブランチの予測ミスが多数発生する可能性があります。疑似コードは次のようになります。

for each entity in world:
    entity.do_something() // virtual call

このシナリオを回避する戦略は、サブタイプに基づいてこのポリモーフィックコンテナーの並べ替えを開始することです。これは、ゲーム業界で人気のかなり古いスタイルの最適化です。今日はどれほど役に立ったかわかりませんが、高度な最適化です。

同様の効果を達成する最近の場合でも、まだ有用であることがわかったもう1つの方法は、ポリモーフィックコンテナーをサブタイプごとに複数のコンテナーに分割し、次のようなコードを作成することです。

for each human in world.humans():
    human.do_something()
for each orc in world.orcs():
    orc.do_something()
for each creature in world.creatures():
    creature.do_something()

...これは当然、コードの保守性を妨げ、拡張性を低下させます。ただし、この世界のすべてのサブタイプに対してこれを行う必要はありません。最も一般的な場合にのみ、それを行う必要があります。たとえば、この架空のビデオゲームは、はるかに人間とオークで構成される可能性があります。妖精、ゴブリン、トロル、エルフ、ノームなども含まれますが、人間やオークほど一般的ではない可能性があります。したがって、人間とオークを他の人から分離するだけで済みます。余裕があれば、これらのサブタイプをすべて格納するポリモーフィックコンテナーも使用できます。これらのコンテナーを使用して、パフォーマンスが重要ではないループに使用できます。これは、参照の局所性を最適化するためのホット/コールド分割に多少似ています。

データ指向の最適化

分岐予測のための最適化とメモリレイアウトの最適化は、ある程度あいまいになる傾向があります。ブランチプレディクタの最適化具体的にを試みたのはめったにありませんが、それは私が他のすべてを使い果たした後でのみでした。しかし、メモリと参照の局所性に重点を置くと、測定の結果、ブランチの予測ミスが少なくなることがわかりました(多くの場合、正確な理由がわからないままです)。

ここでは、データ指向の設計を研究するのに役立ちます。最適化に関する最も有用な知識のいくつかは、データ指向設計のコンテキストでメモリ最適化を研究することから得られることがわかりました。データ指向の設計では、抽象化(存在する場合)が少なく、大きなデータのチャンクを処理するかさばる高レベルのインターフェースが強調される傾向があります。本質的に、このような設計は、均質なデータの大きなチャンクを処理するよりルーピーなコードを使用して、コード内の異種の分岐とジャンプの量を減らす傾向があります。

多くの場合、目的がブランチの予測ミスを減らすことであっても、より迅速にデータを消費することに集中することが役立ちます。たとえば、ブランチレスSIMDから以前にいくつかの大きな利益を見つけましたが、データをより迅速に消費するという考え方がまだありました(それを実行しました、そしてこれからのいくつかの助けのおかげでSO =ハロルドのように)。

TL; DR

とにかく、これらは高レベルの観点からコード全体のブランチの誤予測を潜在的に減らすためのいくつかの戦略です。彼らはコンピュータアーキテクチャにおける最高レベルの専門知識を欠いていますが、これが、質問のレベルを考えると、適切な種類の役立つ応答であることを願っています。このアドバイスの多くは、一般的には最適化によってぼやけていますが、分岐予測の最適化は、それを超えた最適化(メモリ、並列化、ベクトル化、アルゴリズム)によってぼやけなければならないことがよくあります。いずれにせよ、最も安全な賭けは、深く冒険する前にプロファイラーを手に持っていることを確認することです。

17
Dragon Energy

おそらく最も一般的な手法は、通常の戻りとエラーの戻りに別々のメソッドを使用することです。 Cには選択の余地はありませんが、C++には例外があります。コンパイラーは、例外ブランチが例外的であり、したがって予期しないものであることを認識しています。

つまり、例外ブランチは予測できないため実際には低速ですが、エラー以外のブランチはより高速になります。平均して、これは正味の勝利です。

7
MSalters

一般に、最も一般的に発生するキャッシュサイズに比例して、ホットな内部ループを維持することをお勧めします。つまり、プログラムが一度に32kバイト未満の塊でデータを処理し、適切な量の処理を行う場合、L1キャッシュを有効に利用していることになります。

対照的に、ホットインナーループが100MByteのデータをかみ砕き、各データ項目で1つの操作のみを実行する場合、CPUはほとんどの時間をDRAMからのデータのフェッチに費やします。

CPUが最初に分岐予測を行う理由の一部は、次の命令のオペランドをプリフェッチできるようにするためです。分岐の予測ミスによるパフォーマンスへの影響は、次のデータがどの分岐に関係なくL1キャッシュから取得される可能性が高くなるようにコードを配置することで軽減できます。完璧な戦略ではありませんが、L1キャッシュサイズは32または64Kで一般的にスタックしているようです。それは業界全体でほぼ一定です。確かに、この方法でのコーディングは簡単ではない場合が多く、他の人が推奨するプロファイル主導の最適化などに依存することは、おそらく最も簡単な方法です。

何であれ、ブランチの誤予測の問題が発生するかどうかは、CPUのキャッシュサイズ、マシンで他に実行されているもの、メインメモリの帯域幅/レイテンシなどによって異なります。

6
bazza

あなたの質問に答えるために、分岐予測がどのように機能するかを説明しましょう。

まず最初に、プロセッサが分岐を行うを正しく予測すると、分岐ペナルティが発生します。プロセッサが分岐が発生すると予測した場合、実行フローはそのアドレスから続行されるため、予測された分岐のターゲットを知る必要があります。分岐ターゲットアドレスがすでに分岐ターゲットバッファー(BTB)に格納されていると仮定すると、BTBにあるアドレスから新しい命令をフェッチする必要があります。したがって、分岐が正しく予測されていても、数クロックサイクルを無駄にしています。
BTBには連想キャッシュ構造があるため、ターゲットアドレスが存在しない可能性があり、したがってより多くのクロックサイクルが浪費される可能性があります。

一方、CPUが分岐しないと予測し、それが正しい場合、CPUは連続した命令の場所をすでに知っているため、ペナルティはありません。

上記で説明したように、予測されない分岐は、予測される分岐よりもスループットが高くなります

高レベルのプログラミング手法(つまり、アセンブリなし)を使用して、分岐の予測ミスを回避することはできますか?

はい、可能です。すべての分岐が常に実行される、または実行されないような反復的な分岐パターンを持つようにコードを編成することで回避できます。
しかし、より高いスループットを得たい場合は、上で説明したように、分岐が発生しない可能性が最も高い方法でブランチを編成する必要があります。

高レベルのプログラミング言語でブランチフレンドリーなコードを作成するには、何に注意すべきですか(主にCおよびC++に興味があります)?

可能な場合は、ブランチをできるだけ削除してください。 if-elseまたはswitchステートメントを作成するときにこれが当てはまらない場合は、最初に最も一般的なケースをチェックして、分岐が行われない可能性が最も高いことを確認してください。 __builtin_expect(condition, 1)関数を使用して、コンパイラーに強制的に条件が満たされないように処理するように強制します。

2
Root G

1-いくつかの高レベルのプログラミング手法(つまり、アセンブリなし)を使用して分岐の予測ミスを回避することは可能ですか?

避ける?おそらくそうではありません。減らす?もちろん...

2-ブランチフレンドリーなコードを高水準プログラミング言語で作成するには、何に注意すべきですか(主にCおよびC++に興味があります)?

あるマシンの最適化が別のマシンの最適化であるとは限りません。それを念頭に置いて、 profile-guided optimisation は、与えるテスト入力に基づいて、ブランチを再配置するのに適度に優れています。これは、この最適化を実行するためにanyプログラミングを実行する必要がないことを意味し、する必要がありますプロファイリングするマシンに合わせて比較的調整されます。明らかに、最良の結果は、テスト入力とプロファイリングするマシンが一般的な期待にほぼ一致するときに達成されます...しかし、これらは他の最適化、分岐予測関連などの考慮事項でもあります。

2
autistic

ブランチの両側が取るに足らないものであったとしても、ブランチレスは常に良いとは限りません。 分岐予測が機能する場合、ループで運ばれるデータ依存関係よりも高速です

_gcc -O3_がif()をブランチレスに変換するケースについては、 gcc最適化フラグ-O3が-O2 よりもコードを遅くするを参照してくださいコードが非常に予測しやすい場合は、コードを遅くします。

(ソートアルゴリズムやバイナリ検索などで)条件が予測不可能であると確信できる場合があります。または、最悪のケースが1.5倍速いというよりも、最悪のケースが10倍遅くないことを重視します。


一部のイディオムは、ブランチなしの形式にコンパイルされる可能性が高くなります(cmov x86条件付き移動命令など)。

_x = x>limit ? limit : x;   // likely to compile branchless

if (x>limit) x=limit;      // less likely to compile branchless, but still can
_

最初の方法は常にxに書き込みますが、2番目の方法はブランチの1つでxを変更しません。これが、一部のコンパイラがcmovバージョンのifではなくブランチを発行する傾向がある理由のようです。これは、xがローカルint変数であり、すでにレジスターに存在している場合でも適用されるため、「書き込み」には、メモリーへのストアは含まれず、レジスターの値を変更するだけです。

コンパイラーは必要に応じて何でも実行できますが、このイディオムの違いが違いを生む可能性があることを発見しました。あなたがテストしているものに依存して、それは 単純な古いcmov を行うよりもコンパイラマスクとANDを助ける方が時々良いです(-===-)I私はコンパイラが単一の命令でマスクを生成するために必要なものを持っていることを知っていたので(そしてclangがそれをどのように実行したかを見て)、それをその答えで行いました。

TODO: http://gcc.godbolt.org/ の例

1
Peter Cordes