ソートされた配列を処理する方が、ソートされていない配列を処理するよりも速いのはなぜですか?


24450

以下は、非常に奇妙な動作を示すC ++コードの一部です。奇妙な理由で、データを奇妙にソートすると、コードがほぼ6倍速くなります。

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • なしstd::sort(data, data + arraySize);では、コードは11.54秒で実行されます。
  • ソートされたデータにより、コードは1.93秒で実行されます。

最初は、これは単なる言語またはコンパイラの異常かもしれないと思ったので、Javaを試しました。

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

似たような極端な結果ではありません。


私が最初に思ったのは、並べ替えによってデータがキャッシュに入れられるということでしたが、配列が生成されたばかりなので、それがどれほど愚かであるかを考えました。

  • 何が起こっている?
  • ソートされた配列を処理する方が、ソートされていない配列を処理するよりも速いのはなぜですか?

コードはいくつかの独立した用語を合計しているので、順序は重要ではありません。



16
@SachinVerma私の頭の上の部分:1)条件付きの移動を使用するのに十分なほど、JVMは最終的に十分スマートになる可能性があります。2)コードはメモリに拘束されます。200Mは大きすぎてCPUキャッシュに収まりません。したがって、パフォーマンスは分岐ではなくメモリ帯域幅によってボトルネックになります。
Mysticial 2018年

12
@Mysticial、約2)。予測テーブルはパターンを追跡し(そのパターンについてチェックされた実際の変数に関係なく)、履歴に基づいて予測出力を変更すると考えました。超大規模配列が分岐予測の恩恵を受けない理由を教えてください。
Sachin Verma

15
@SachinVermaありますが、配列がそのように大きい場合は、さらに大きな要因であるメモリ帯域幅が関係してくる可能性があります。メモリが平坦ではありません。メモリへのアクセスは非常に遅く、帯域幅には制限があります。物事を単純化しすぎると、一定の時間内にCPUとメモリの間で転送できるバイト数は非常に多くなります。この質問のような単純なコードは、予測ミスによって速度が低下したとしても、おそらくその限界に達します。これは、CPUのL2キャッシュに収まるため、32768(128KB)の配列では発生しません。
Mysticial 2018年

13
:BranchScopeと呼ばれる新しいセキュリティ上の欠陥があるcs.ucr.edu/~nael/pubs/asplos18.pdf
Veve

回答:


31799

あなたはブランチ予測の失敗の犠牲者です。


分岐予測とは何ですか?

鉄道のジャンクションを考えてみましょう:

鉄道のジャンクションを示す画像 Mecanismoによる画像( Wikimedia Commons経由)。CC-By-SA 3.0ライセンスの下で使用されます。

議論のために、これが長距離通信や無線通信の前の1800年代に戻ったとします。

あなたはジャンクションのオペレーターであり、列車が来るのが聞こえます。どちらの方向に進むべきかわからない。列車を止めて、運転手にどちらの方向に行きたいか尋ねます。次に、スイッチを適切に設定します。

列車は重く、慣性がたくさんあります。そのため、起動とスローダウンに永遠にかかります。

もっと良い方法はありますか?あなたは列車がどちらの方向に行くのかを推測します!

  • あなたが正しく推測した場合、それは続きます。
  • 間違えた場合は、キャプテンが停止し、後退して、スイッチを入れるように叫びます。その後、他のパスで再起動できます。

毎回正解すると、電車が止まることはありません。
間違いが多すぎると、列車は停止、バックアップ、再起動に多くの時間を費やします。


ifステートメントについて考えてみましょう。プロセッサレベルでは、これは分岐命令です。

ifステートメントを含むコンパイル済みコードのスクリーンショット

あなたはプロセッサーであり、あなたは枝を見ます。あなたはそれがどの方向に行くのか分かりません。職業はなんですか?実行を停止し、前の指示が完了するまで待ちます。次に、正しいパスをたどります。

最近のプロセッサは複雑で、パイプラインが長いです。したがって、彼らは「ウォームアップ」と「スローダウン」に永遠にかかります。

もっと良い方法はありますか?あなたはブランチがどの方向に行くかを推測します!

  • 正解した場合は、実行を続けます。
  • 推測が間違っていた場合は、パイプラインをフラッシュしてブランチにロールバックする必要があります。次に、他のパスを再起動できます。

毎回正しく推測すれば、実行を停止する必要はありません。
誤解が多すぎると、ストール、ロールバック、再起動に多くの時間が費やされます。


これは分岐予測です。電車は旗で方向を知らせるだけなので、これは最高の類推ではないことを認めます。しかし、コンピュータでは、プロセッサはブランチが最後の瞬間までどの方向に進むかわかりません。

では、列車が他の経路に戻って下りる回数を最小限に抑えるために、戦略的にどのように推測しますか?あなたは過去の歴史を見ます!列車が99%の時間で発車した場合、あなたは発車したと思います。それが交替する場合、あなたはあなたの推測を交代させます。それが3回に1つの方向に進んだ場合、あなたは同じことを推測します...

言い換えれば、あなたはパターンを特定し、それに従うことを試みます。これは多かれ少なかれ分岐予測子がどのように機能するかです。

ほとんどのアプリケーションには、適切に動作するブランチがあります。したがって、最新の分岐予測子は通常、90%を超えるヒット率を達成します。しかし、認識できないパターンのある予測不可能な分岐に直面すると、分岐予測子は事実上役に立たなくなります。

参考文献ウィキペディアの「分岐予測」の記事


上で示唆したように、犯人はこのif文です。

if (data[c] >= 128)
    sum += data[c];

データが0から255の間で均等に分布していることに注意してください。データを並べ替えると、反復のおよそ前半はifステートメントに入りません。その後、それらはすべてifステートメントに入ります。

分岐は連続して同じ方向に何度も進むので、これは分岐予測子にとって非常に友好的です。単純な飽和カウンターでも、方向を切り替えた後の数回の反復を除いて、分岐を正しく予測します。

迅速な視覚化:

T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)

ただし、データが完全にランダムである場合、ランダムデータを予測できないため、分岐予測子は役に立たなくなります。したがって、おそらく約50%の予測ミスがあります(ランダムな推測に勝るものはありません)。

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)

では、何ができるでしょうか?

コンパイラーが分岐を条件付きの移動に最適化できない場合、パフォーマンスのために読みやすさを犠牲にしてもかまわない場合は、いくつかのハックを試すことができます。

交換:

if (data[c] >= 128)
    sum += data[c];

と:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

これにより、ブランチが削除され、いくつかのビット単位の演算に置き換えられます。

(このハックは元のifステートメントと厳密に同等ではないことに注意してください。ただし、この場合、のすべての入力値に対して有効ですdata[]。)

ベンチマーク:Core i7 920 @ 3.5 GHz

C ++-Visual Studio 2010-x64リリース

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java-NetBeans 7.1.1 JDK 7-x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

観察:

  • ブランチで:並べ替えられたデータと並べ替えられていないデータには大きな違いがあります。
  • ハックの場合:ソートされたデータとソートされていないデータに違いはありません。
  • C ++の場合、データがソートされるとき、実際のハックはブランチの場合よりも少し遅くなります。

一般的な経験則は、重要なループ(この例のような)でデータ依存の分岐を回避することです。


更新:

  • GCC 4.6.1 -O3または-ftree-vectorizex64の上では、条件付き移動を生成することができます。したがって、並べ替えられたデータと並べ替えられていないデータに違いはありません。どちらも高速です。

    (または多少高速:すでに並べ替えられている場合、cmov特にGCCがクリティカルパスに配置する場合add、特にcmov2サイクルのレイテンシがあるBroadwellの前のIntelの場合は特に遅くなる可能性があります:gcc最適化フラグ-O3は-O2よりコードを遅くします

  • VC ++ 2010では、このブランチの条件付き移動を生成できません/Ox

  • インテルC ++コンパイラー(ICC)11は奇跡を起こします。これは二つのループを入れ替え、それによって外側のループに予測不可能な分岐を巻き上げ、。そのため、予測ミスの影響を受けないだけでなく、VC ++とGCCが生成できるものの2倍の速さになります。言い換えれば、ICCはテストループを利用してベンチマークを打ち破りました...

  • インテルコンパイラーにブランチなしのコードを与えると、それは完全にそれを完全にベクトル化します...ブランチと同じくらい高速です(ループ交換を使用)。

これは、成熟した現代のコンパイラでさえ、コードを最適化する能力が大きく異なる可能性があることを示しています...


256
次のフォローアップの質問を見てください:stackoverflow.com/questions/11276291/…インテルコンパイラーは、外側のループを完全に取り除くことにかなり近づきました。
ミスティシャル2012

24
@Mysticialトレイン/コンパイラは、間違ったパスに入ったことをどのようにして知るのですか?
onmyway133 2013

26
@obe:階層的なメモリ構造を考えると、キャッシュミスの費用がどの程度になるかを言うことは不可能です。L1で欠落して低速のL2で解決されるか、L3で欠落してシステムメモリで解決される可能性があります。ただし、奇妙な理由でこのキャッシュミスが原因で非常駐ページのメモリがディスクから読み込まれない限り、問題はありません...メモリのアクセス時間は約25〜30年でミリ秒の範囲にありません;)
Andon M. Coleman

21
最新のプロセッサーで効率的なコードを作成するための経験則:プログラムの実行をより規則的に(不均一でなく)するすべてのものは、より効率的になる傾向があります。この例の並べ替えは、分岐予測のためにこの効果があります。(局所的なランダムアクセスではなく)アクセスの局所性は、キャッシュのためにこの効果があります。
Lutz Prechelt、2015

22
@Sandeepはい。プロセッサにはまだ分岐予測があります。何かが変更された場合、それはコンパイラです。今日では、ICCとGCC(-O3のもとで)がここで行ったことを実行する可能性が高いと思います。つまり、ブランチを削除します。この質問がどれだけ注目されているかを考えると、この質問のケースを特に処理するようにコンパイラが更新されている可能性が非常に高いです。確かにSOに注意を払います。そして、それはGCCが3週間以内に更新されたこの質問で起こりました。なぜここでも起こらないのかわかりません。
Mysticial

4087

分岐予測。

ソートされた配列では、条件data[c] >= 128は最初に一連falseの値であり、それtrue以降のすべての値になります。それは簡単に予測できます。並べ替えられていない配列では、分岐コストを支払います。


105
分岐予測は、並べ替えられた配列と異なるパターンの配列のどちらでうまく機能しますか?たとえば、配列-> {10、5、20、10、40、20、...}の場合、パターンの配列の次の要素は80です。この種類の配列は、次の分岐予測によって高速化されますか?パターンが続く場合、次の要素は80です。それとも、通常はソートされた配列でのみ役立ちますか?
Adam Freeman

133
基本的に、私が従来Big-Oについて学んだことはすべて窓の外にあるのでしょうか?分岐コストよりもソートコストが発生する方が良いですか?
Agrim Pathak 2014

133
@AgrimPathak状況によります。入力が大きすぎない場合、複雑度が高いアルゴリズムの定数が小さいほど、複雑度が高いアルゴリズムは、複雑度が低いアルゴリズムよりも速くなります。損益分岐点がどこにあるかを予測するのは難しい場合があります。また、これを比較すると、場所が重要です。Big-Oは重要ですが、パフォーマンスの唯一の基準ではありません。
Daniel Fischer

65
分岐予測はいつ行われますか?言語は、配列がソートされていることをいつ知っていますか?次のような配列の状況を考えています。[1,2,3,4,5、... 998,999,1000、3、10001、10002]?このあいまいな3は実行時間を増やしますか?並べ替えられていない配列と同じですか?
Filip Bartuzi 14年

63
@FilipBartuzi分岐予測は、言語レベルの下のプロセッサで行われます(ただし、言語はコンパイラに可能性のあることを伝える方法を提供する場合があるため、コンパイラはそれに適したコードを発行できます)。あなたの例では、順不同の3は分岐予測を導き(適切な条件では、3は1000とは異なる結果をもたらす)、そのため、その配列の処理にはおそらく数十または数百ナノ秒かかるソートされた配列は、ほとんど目立ちません。時間がかかるのは予測ミスの割合が高いためです。1000あたり1回の予測ミスはそれほど多くありません。
Daniel Fischer

3312

データがソートされたときにパフォーマンスが大幅に向上する理由は、Mysticialの回答で美しく説明されているように、分岐予測ペナルティが削除されているためです。

コードを見てみると

if (data[c] >= 128)
    sum += data[c];

この特定のif... else...ブランチの意味は、条件が満たされたときに何かを追加することです。このタイプの分岐は、条件付き移動ステートメントに簡単に変換できcmovlx86システム内で条件付き移動命令にコンパイルされます。分岐、したがって潜在的な分岐予測ペナルティが削除されます。

CこのようにC++、の条件移動命令に(任意の最適化なしで)直接コンパイルなる文はx86、三項演算子です... ? ... : ...。したがって、上記のステートメントを同等のステートメントに書き換えます。

sum += data[c] >=128 ? data[c] : 0;

読みやすさを維持しながら、スピードアップ要因を確認できます。

Intel Core i7 -2600K @ 3.4 GHzおよびVisual Studio 2010リリースモードでは、ベンチマークは(Mysticialからコピーされた形式)です。

x86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

結果は、複数のテストで堅牢です。分岐の結果が予測できない場合、速度が大幅に向上しますが、予測可能な場合は少し問題があります。実際、条件付き移動を使用する場合、パフォーマンスはデータパターンに関係なく同じです。

次に、x86それらが生成するアセンブリを調査して、さらに詳しく見てみましょう。簡単にするために、2つの関数max1max2

max1条件付きブランチを使用しますif... else ...

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2三項演算子を使用します... ? ... : ...

int max2(int a, int b) {
    return a > b ? a : b;
}

x86-64マシンでGCC -Sは、以下のアセンブリを生成します。

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

max2命令の使用により、使用するコードははるかに少なくなりますcmovge。しかし、本当の利点はmax2、分岐ジャンプを含まjmpないことです。これは、予測された結果が正しくない場合、パフォーマンスが大幅に低下します。

では、なぜ条件付き移動の方が優れているのでしょうか?

典型的なx86プロセッサでは、命令の実行はいくつかの段階に分けられます。大まかに言って、さまざまな段階に対応するためにさまざまなハードウェアがあります。したがって、新しい命令を開始するために、1つの命令が完了するのを待つ必要はありません。これはパイプライン処理と呼ばれますます。

分岐の場合、次の命令は先行する命令によって決定されるため、パイプライン化を行うことはできません。私たちは待つか予測する必要があります。

条件付きムーブ場合に、実行条件付き移動命令は、いくつかの段階に分けたが、初期段階のようなものであるFetchDecode前の命令の結果に依存しません。後のステージだけが結果を必要とします。したがって、1つの命令の実行時間の一部を待機します。これが、予測が容易な場合に条件付き移動バージョンがブランチよりも遅い理由です。

これについては、コンピュータシステム:プログラマの視点の第2版で詳しく説明されています。条件付き移動命令についてはセクション3.6.6、プロセッサアーキテクチャについては第4章全体、分岐予測と予測ミスペナルティの特別な扱いについてはセクション5.11.2を確認できます。

場合によっては、一部の最新のコンパイラーはコードを最適化して、より優れたパフォーマンスでアセンブリできるようにすることも、できない場合もあります(問題のコードはVisual Studioのネイティブコンパイラーを使用しています)。予測不能な場合の分岐と条件付き移動のパフォーマンスの違いを知ることは、シナリオが非常に複雑になり、コンパイラーが自動的に最適化できない場合に、より優れたパフォーマンスでコードを作成するのに役立ちます。


7
@ BlueRaja-DannyPflughoeftこれは最適化されていないバージョンです。コンパイラーは三項演算子を最適化しませんでした。それを変換するだけです。GCCはif-thenを最適化できますが、十分な最適化レベルが与えられている場合でも、これは条件付き移動の威力を示し、手動の最適化は違いをもたらします。
WiSaGaN 2012年

100
@WiSaGaN 2つのコードが同じマシンコードにコンパイルされるため、コードは何も示しません。例のifステートメントが例のTerenaryとはどういうわけか異なるという考えを人々が理解しないことが非常に重要です。あなたが最後の段落の類似点まで所有していることは事実ですが、それでも残りの例が有害であるという事実が消えることはありません。
Justin L.

55
@WiSaGaN誤解を招く-O0例を削除し、2つのテストケースで最適化された asm の違いを示すために回答を変更した場合、私の反対票は間違いなく賛成票になります。
ジャスティンL.

56
@UpAndAdamテストの時点では、VS2010は高い最適化レベルを指定している場合でも、条件付き移動への元のブランチを最適化できませんが、gccは最適化できます。
WiSaGaN 2013

9
この3項演算子のトリックは、Javaで美しく機能します。Mysticalの回答を読んだ後、Javaには-O3に相当するものが何もないため、誤った分岐予測を回避するためにJavaに何ができるのか疑問に思いました。三項演算子:2.1943sおよびオリジナル:6.0303s。
Kin Cheung

2272

このコードに実行できるさらに多くの最適化について知りたい場合は、以下を検討してください。

元のループから始めます。

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

ループ交換を使用すると、このループを安全に次のように変更できます。

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

次に、ループifの実行全体を通して条件が一定であるiことを確認できるので、次のように引き上げることができますif

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

次に、浮動小数点モデルで許可されている(/fp:fastたとえばスローされている)と仮定して、内部ループを1つの式に折りたたむことができることがわかります。

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

これは、以前よりも10万倍高速です。


276
チートしたい場合は、ループの外で乗算を行い、ループの後にsum * = 100000を実行することもできます。
Jyaif

78
@Michael-この例は、実際にはループ不変巻上げ(LIH)最適化の例であり、ループスワップではないと考えています。この場合、内側のループ全体が外側のループから独立しているため、外側のループからi引き上げることができ、結果は1単位の合計= 1e5で単純に乗算されます。それは最終結果に違いはありませんが、これは頻繁に使用されるページであるため、レコードを正確に設定したかっただけです。
Yair Altman 2013

54
スワップループの単純な精神ではないが、インナーif:この時点では、に変換することができ sum += (data[j] >= 128) ? data[j] * 100000 : 0;、コンパイラはに低減することができる場合があるcmovgeまたは同等。
Alex North-Keys

43
外側のループは、内側のループにかかる時間をプロファイルに十分な大きさにすることです。では、なぜループスワップを行うのでしょうか。最後に、そのループはとにかく削除されます。
saurabheights 2016年

34
@saurabheights:間違った質問:コンパイラがループスワップしないのはなぜですか。マイクロベンチマークは難しい;)
Matthieu M.16年

1885

私たちの中には、CPUの分岐予測子にとって問題のあるコードを特定する方法に興味を持つ人もいるでしょう。Valgrindツールにcachegrindは、--branch-sim=yesフラグを使用して有効にする分岐予測シミュレータがあります。この質問の例で実行すると、外側のループの数が10000に減り、でコンパイルするとg++、次の結果が得られます。

並べ替え:

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

並べ替えなし:

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

cg_annotate問題のループについて確認した、行ごとの出力にドリルダウンします。

並べ替え:

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

並べ替えなし:

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

これにより、問題のある行を簡単に特定できます- if (data[c] >= 128)並べ替えられていないバージョンでは、行はBcmcachegrindのブランチ予測モデルの下で164,050,007の誤って予測された条件付きブランチ()を引き起こしていますが、並べ替えられたバージョンでは10,006しか引き起こしていません。


または、Linuxでは、パフォーマンスカウンターサブシステムを使用して同じタスクを実行できますが、CPUカウンターを使用してネイティブパフォーマンスを実現できます。

perf stat ./sumtest_sorted

並べ替え:

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

並べ替えなし:

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

また、逆アセンブリでソースコードの注釈を付けることもできます。

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

詳細については、パフォーマンスのチュートリアルを参照してください。


74
これは恐ろしいことであり、ソートされていないリストでは、50%の確率で追加がヒットするはずです。どういうわけか、分岐予測は25%のミス率しかないのですが、50%ミスよりもどうすればよいでしょうか
TallBrian、2013

128
@ tall.b.lo: -がある25%がすべてのブランチである2本のための1つの枝がループ内で、data[c] >= 128(あなたが提案として、50%のミス率を有する)と、ループ条件のために1 c < arraySize〜0%のミス率を持っています。
ca

1341

私はこの質問とその答えを読んだだけで、答えが欠けているように感じます。

マネージ言語で特に効果的であることがわかった分岐予測を排除する一般的な方法は、分岐を使用する代わりにテーブルルックアップを使用することです(この場合はテストしていません)。

このアプローチは、次の場合に一般的に機能します。

  1. これは小さなテーブルであり、プロセッサにキャッシュされる可能性が高く、
  2. 非常にタイトなループで物事を実行している、および/またはプロセッサがデータをプリロードできます。

背景と理由

プロセッサの観点からは、メモリが遅いです。速度の違いを補うために、いくつかのキャッシュがプロセッサーに組み込まれています(L1 / L2キャッシュ)。だからあなたがあなたの素敵な計算をしていると想像してください、そしてあなたはメモリの一部が必要であることを理解してください。プロセッサはその「ロード」操作を取得し、メモリの一部をキャッシュにロードします。その後、キャッシュを使用して残りの計算を実行します。メモリは比較的遅いため、この「ロード」はプログラムの速度を低下させます。

分岐予測と同様に、これはPentiumプロセッサで最適化されました。プロセッサは、データの一部をロードする必要があると予測し、操作が実際にキャッシュにヒットする前にデータをキャッシュにロードしようとします。すでに見てきたように、分岐予測はひどく間違っていることがあります-最悪のシナリオでは、戻って実際にメモリ負荷を待つ必要があります。分岐予測が失敗した後のロードは恐ろしいです!

幸いにも、メモリアクセスパターンが予測可能な場合、プロセッサはそれを高速キャッシュにロードし、すべてが順調です。

最初に知っておくべきことは、何が小さいのかということです。一般的には小さいほど良いですが、経験則では、サイズが4096バイト以下のルックアップテーブルを使用します。上限として:ルックアップテーブルが64Kより大きい場合、おそらく再検討する価値があります。

テーブルの作成

これで、小さなテーブルを作成できることがわかりました。次に、ルックアップ関数を設定します。ルックアップ関数は通常、いくつかの基本的な整数演算(および、または、xor、シフト、追加、削除、および乗算)を使用する小さな関数です。入力をルックアップ関数によってテーブル内のある種の「一意のキー」に変換したいとします。これにより、実行したいすべての作業の答えが得られます。

この場合:> = 128は値を保持できることを意味し、<128は値を削除することを意味します。これを行う最も簡単な方法は、「AND」を使用することです。それを維持する場合は、7FFFFFFFとANDします。それを取り除きたい場合は、0とANDします。128は2の累乗であることにも注意してください。つまり、先に進んで32768/128整数のテーブルを作成し、1つのゼロと多くの7FFFFFFFFの。

管理された言語

なぜこれがマネージ言語でうまく機能するのか疑問に思うかもしれません。結局のところ、マネージ言語はブランチで配列の境界をチェックして、混乱しないようにします...

まあ、正確ではない... :-)

管理言語のこのブランチを排除するためのかなりの作業がありました。例えば:

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

この場合、境界条件にヒットしないことはコンパイラにとって明らかです。少なくともMicrosoft JITコンパイラー(ただし、Javaは同様のことを行うと思います)はこれに気づき、チェックを完全に削除します。WOW、それはブランチがないことを意味します。同様に、それは他の明白なケースを扱います。

管理された言語でのルックアップで問題が発生した場合、重要なのは& 0x[something]FFF、ルックアップ関数にを追加して、境界チェックを予測可能にすることです。

この事件の結果

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();

57
分岐予測子をバイパスしたいのはなぜですか?それは最適化です。
ダスティンオプレア2013

108
ブランチよりも優れているブランチはありません:-)多くの状況では、これは非常に高速です...最適化している場合は、試してみる価値があります。彼らはまた、f.exでそれをかなり使用しています。graphics.stanford.edu/~seander/bithacks.html
atlaste 2013

36
一般的にルックアップテーブルは高速ですが、この特定の条件のテストを実行しましたか?コードにはまだ分岐条件がありますが、それだけがルックアップテーブル生成部に移動されます。それでもパフォーマンスは向上しません
Zain Rizvi

38
@Zainあなたが本当に知りたいなら...はい:ブランチで15秒、私のバージョンで10秒。いずれにせよ、どちらの方法を知っているかは有用なテクニックです。
atlaste 2013

42
なぜsum += lookup[data[j]]ここでlookup、256個のエントリを持つ配列最初のものはゼロであり、かつ最後のものは、インデックスに等しいですか?
クリスヴァンダー

1200

配列のソート時にデータは0から255の間で分布するため、反復の前半あたりifが- ifステートメントに入らない(ステートメントは以下で共有されます)。

if (data[c] >= 128)
    sum += data[c];

問題は、ソートされたデータの場合のように、上記のステートメントが特定のケースで実行されない原因は何ですか?これが「分岐予測子」です。分岐予測子は、分岐if-then-elseが確実にわかる前に、分岐(構造体など)がどの方向に進むかを推測するデジタル回路です。分岐予測子の目的は、命令パイプラインのフローを改善することです。分岐予測子は、高い効果的なパフォーマンスを実現する上で重要な役割を果たします。

それをよりよく理解するためにいくつかのベンチマーキングをしましょう

ifステートメントのパフォーマンスは、その状態に予測可能なパターンがあるかどうかによって異なります。条件が常にtrueまたは常にfalseの場合、プロセッサの分岐予測ロジックがパターンを取得します。一方、パターンが予測できない場合、- ifステートメントははるかに高価になります。

さまざまな条件でこのループのパフォーマンスを測定してみましょう。

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

さまざまな真偽パターンを使用したループのタイミングを次に示します。

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF           513

(i & 2) == 0             TTFFTTFF           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF   1275

(i & 8) == 0             8T 8F 8T 8F        752

(i & 16) == 0            16T 16F 16T 16F    490

悪い」true-falseパターンはif、「良い」パターンよりも最大6倍遅くステートメントを作成できます!もちろん、どちらのパターンが良いか、どちらが悪いかは、コンパイラーによって生成される正確な命令と特定のプロセッサーによって異なります。

したがって、分岐予測がパフォーマンスに与える影響については間違いありません。


23
@MooingDuck 'それは違いを生まないので-その値は何でもかまいませんが、それでもこれらのしきい値の範囲内にあります。では、すでに制限を知っているのに、なぜランダムな値を表示するのでしょうか?完全を期すために、そして「それだけで」表示することもできると私は同意します。
cst1992

24
@ cst1992:現在、彼の最も遅いタイミングはTTFFTTFFTTFFです。これは、私の目にはかなり予測可能です。ランダムは本質的に予測不可能であるため、さらに遅くなる可能性があり、したがって、ここで示す制限の範囲外になる可能性があります。OTOH、それはTTFFTTFFが完全に病理学的ケースを打つことかもしれません。ランダムのタイミングを示さなかったため、わかりません。
Mooing Duck 2016

21
@MooingDuck人間の目には、「TTFFTTFFTTFF」は予測可能なシーケンスですが、ここで話しているのは、CPUに組み込まれた分岐予測子の動作です。分岐予測子はAIレベルのパターン認識ではありません。とても簡単です。ブランチを交互に並べ替えるだけでは、うまく予測できません。ほとんどのコードでは、ブランチはほとんど常に同じ方法で進みます。1000回実行するループを考えてみましょう。ループの終わりにある分岐は、ループの最初に999回戻り、1000回目は別のことを行います。通常、非常に単純な分岐予測子がうまく機能します。
steveha

18
@steveha:CPUブランチプレディクターがどのように機能するかについてはあなたが仮定していると思いますが、その方法論には同意しません。ブランチプレディクタがどれだけ進んでいるかはわかりませんが、あなたよりもはるかに進んでいるようです。あなたはおそらく正しいですが、測定は間違いなく良いでしょう。
Mooing Duck

5
@steveha:2レベルの適応予測子は、まったく問題なくTTFFTTFFパターンにロックできます。「この予測方法の変種は、ほとんどの最新のマイクロプロセッサーで使用されています。」ローカル分岐予測とグローバル分岐予測は、2レベルの適応予測子に基づいていますが、それらも同様です。「グローバルブランチ予測は、AMDプロセッサ、およびIntel Pentium M、Core、Core 2、SilvermontベースのAtomプロセッサで使用されます」また、そのリストにAgree予測子、ハイブリッド予測子、間接ジャンプの予測を追加します。ループ予測はロックオンしませんが、75%に達します。ロックできないのは2つだけです
Mooing Duck

1126

分岐予測エラーを回避する1つの方法は、ルックアップテーブルを作成し、データを使用してインデックスを作成することです。Stefan de Bruijnさんは彼の答えでそれについて話しました。

ただし、この場合、値が[0、255]の範囲にあることがわかっており、値が128以上の場合にのみ注意が必要です。つまり、値が必要かどうかを示す単一のビットを簡単に抽出できます。データは右側の7ビットにあり、0ビットまたは1ビットが残っています。1ビットがある場合にのみ値を追加します。このビットを「決定ビット」と呼びましょう。

決定ビットの0/1値を配列のインデックスとして使用することにより、データがソートされているかどうかに関係なく、同等に高速なコードを作成できます。私たちのコードは常に値を追加しますが、決定ビットが0の場合、気にしない場所に値を追加します。コードは次のとおりです。

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

このコードは追加の半分を浪費しますが、分岐予測の失敗はありません。ランダムデータの方が、実際のifステートメントを使用したバージョンよりも非常に高速です。

しかし、私のテストでは、おそらくルックアップテーブルへのインデックス付けがビットシフトよりもわずかに速いため、明示的なルックアップテーブルはこれよりもわずかに高速でした。これは、私のコードがどのようにルックアップテーブルをセットアップして使用するかを示しています(コードではlut"LookUp Table" と呼ばれています)。これがC ++コードです。

// Declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

この場合、ルックアップテーブルは256バイトしかないため、キャッシュにうまく収まり、すべてが高速でした。この手法は、データが24ビット値で、半分しか必要でない場合はうまく機能しません。ルックアップテーブルが大きすぎて実用的ではありません。一方、上記の2つの手法を組み合わせることができます。最初にビットをシフトし、次にルックアップテーブルにインデックスを付けます。上半分の値のみが必要な24ビット値の場合、データを12ビット右にシフトし、テーブルインデックスの12ビット値のままにすることができます。12ビットテーブルインデックスは、4096の値のテーブルを意味します。

ifステートメントを使用する代わりに、配列にインデックスを付ける手法を使用して、使用するポインターを決定できます。バイナリツリーを実装し、2つの名前付きポインター(pLeftおよびpRightか、何でも)ポインタの長さ-2の配列を持っていたとフォローするかを決定するために「判定ビット」技術を使用していました。たとえば、次の代わりに:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

このライブラリは次のようなことをします:

i = (x < node->value);
node = node->link[i];

これがこのコードへのリンクです:Red Black TreesEternally Confuzzled


29
そうです、ビットを直接使用して乗算することもできます(data[c]>>7これについても、ここのどこかで説明しています)。私は意図的にこのソリューションを省略しましたが、もちろんあなたは正しいです。ほんの少しの注意:ルックアップテーブルの経験則は、それが4KBに収まる場合(キャッシュのため)、それは機能します-できればテーブルをできるだけ小さくします。マネージ言語の場合は64KBにプッシュします。C++やCなどの低レベル言語の場合は、おそらく再考します(これは私の経験です)。以来typeof(int) = 4、私は最大10ビットに固執しようとしています。
atlaste 2013

17
0/1の値を使用したインデックス付けは、おそらく整数の乗算よりも高速になると思いますが、パフォーマンスが本当に重要である場合は、プロファイルする必要があります。キャッシュプレッシャーを回避するためには小さなルックアップテーブルが不可欠であることに同意しますが、明らかに大きなキャッシュがあれば、より大きなルックアップテーブルで済むので、4KBはハードルールよりも経験則です。あなたが意味したと思いますsizeof(int) == 4か?32ビットの場合も同様です。私の2歳の携帯電話には32KBのL1キャッシュがあるので、特にルックアップ値がintではなくバイトの場合、4Kルックアップテーブルでも機能する可能性があります。
steveha 2013

12
多分私は何かが足りないのですが、あなたのjequals 0または1メソッドでj、配列のインデックスを使用するのではなく、追加する前に値を乗算します(多分、1-jではなく乗算する必要がありますj
Richard Tingle

6
@steveha乗算はより高速になるはずです。Intelの本で調べてみましたが、見つかりませんでした...いずれにせよ、ベンチマークでも結果が得られます。
atlaste 2014年

10
@steveha PS:もう1つの考えられる答えint c = data[j]; sum += c & -(c >> 7);は、乗算をまったく必要としないことです。
atlaste 2014年

1022

ソートされたケースでは、成功した分岐予測や分岐のない比較トリックに頼るよりも、分岐を完全に削除するほうが効果的です。

実際、配列はとで隣接するゾーンに分割されdata < 128ていdata >= 128ます。したがって、(比較を使用した)二分検索で分割ポイントを見つけ、Lg(arraySize) = 15そのポイントから直接累積する必要があります。

(チェックされていない)のようなもの

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

または、少し難読化された

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

ソート済みまたは未ソートの両方の近似解を提供するさらに高速なアプローチは次のとおりですsum= 3137536;(真に均一な分布、期待値191.5の16384サンプルを想定):-)


23
sum= 3137536-賢い。それは明らかに問題のポイントではありません。問題は明らかに、驚くべきパフォーマンス特性を説明することです。std::partition代わりに行うことの追加std::sortは価値があると私は言う傾向があります。実際の問題は、与えられた合成ベンチマークだけにとどまりません。
sehe 2013

12
@DeadMG:これは確かに、指定されたキーの標準的な二分法の検索ではなく、パーティションインデックスの検索です。反復ごとに1つの比較が必要です。しかし、このコードに依存しないでください。私はそれをチェックしていません。保証された正しい実装に興味がある場合は、お知らせください。
Yves Daoust 2013

832

上記の動作は、分岐予測のために発生しています。

分岐予測を理解するには、まず命令パイプラインを理解する必要があります。

命令は一連のステップに分割されるため、異なるステップを並行して同時に実行できます。この手法は命令パイプラインと呼ばれ、最新のプロセッサでスループットを向上させるために使用されます。これをよりよく理解するには、Wikipediaのこのを参照してください。

一般に、最近のプロセッサには非常に長いパイプラインがありますが、簡単にするために、これらの4つのステップのみを考えてみましょう。

  1. IF-メモリから命令をフェッチする
  2. ID-命令をデコードする
  3. EX-命令を実行する
  4. WB-CPUレジスタに書き戻す

一般に2命令の4ステージパイプライン。 一般的に4段パイプライン

上記の質問に戻り、次の手順を検討してみましょう。

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

分岐予測がないと、次のことが起こります。

命令Bまたは命令Cを実行するには、命令Bまたは命令Cに進むかどうかの決定が命令Aの結果に依存するため、プロセッサは命令AがパイプラインのEXステージに到達するまで待機する必要があります。したがって、パイプラインこのようになります。

if条件がtrueを返す場合: ここに画像の説明を入力してください

if条件がfalseを返す場合: ここに画像の説明を入力してください

命令Aの結果を待機した結果、上記のケース(分岐予測なし、trueとfalseの両方)で費やされた合計CPUサイクルは7です。

では、分岐予測とは何ですか?

分岐予測子は、これが確実にわかる前に、分岐(if-then-else構造)がどの方向に進むかを推測しようとします。命令AがパイプラインのEXステージに到達するまで待機しませんが、決定を推測してその命令(この例ではBまたはC)に進みます。

正しい推測の場合、パイプラインは次のようになります。 ここに画像の説明を入力してください

推測が間違っていたことが後で検出された場合、部分的に実行された命令は破棄され、パイプラインは正しい分岐で最初からやり直し、遅延が発生します。分岐予測ミスの場合に浪費される時間は、フェッチステージから実行ステージまでのパイプラインのステージ数と同じです。最近のマイクロプロセッサはパイプラインが非常に長くなる傾向があるため、予測ミスの遅延は10〜20クロックサイクルです。パイプラインが長いほど、優れた分岐予測子の必要性が高くなります。

OPのコードでは、最初に条件付きで分岐予測子に予測の基礎となる情報がないため、初めてランダムに次の命令を選択します。forループの後半では、履歴に基づいて予測を行うことができます。昇順でソートされた配列の場合、3つの可能性があります。

  1. すべての要素が128未満です
  2. すべての要素が128より大きい
  3. いくつかの開始新しい要素は128未満であり、後で128を超える

予測子が最初の実行で常に真の分岐を仮定すると仮定します。

したがって、最初のケースでは、歴史的にすべての予測が正しいため、常に真の分岐が行われます。2番目のケースでは、最初は間違って予測しますが、数回の反復の後、正しく予測します。3番目のケースでは、最初は要素が128未満になるまで正しく予測します。その後、しばらく失敗し、履歴で分岐予測の失敗を検出すると、それ自体が正しくなります。

これらのすべてのケースで失敗の数は少なすぎます。その結果、部分的に実行された命令を破棄して正しいブランチでやり直す必要が数回あるだけで、CPUサイクルが少なくなります。

しかし、ランダムな並べ替えられていない配列の場合、予測は部分的に実行された命令を破棄し、正しい分岐でほとんどの場合最初からやり直す必要があり、並べ替えられた配列と比較してより多くのCPUサイクルが発生します。


1
2つの命令を一緒に実行するにはどうすればよいですか?これは別個のCPUコアで行われますか、それともパイプライン命令が単一のCPUコアに統合されていますか?
M.kazem Akhgary 2017年

1
@ M.kazemAkhgaryすべて1つの論理コアの中にあります。興味があれば、これは、例えばIntel Software Developer Manual
Sergey.quixoticaxis.Ivanov

728

公式の回答は

  1. Intel-ブランチの予測ミスによるコストの回避
  2. Intel-予測ミスを防ぐためのブランチとループの再編成
  3. 科学論文-分岐予測コンピュータアーキテクチャ
  4. 書籍:JL Hennessy、DA Patterson:コンピュータアーキテクチャ:定量的アプローチ
  5. 科学出版物の記事:TY Yeh、YN Pattは分岐予測についてこれらの多くを作成しました。

この素敵なから、分岐予測子が混乱する理由もわかります。

2ビットの状態図

元のコードの各要素はランダムな値です

data[c] = std::rand() % 256;

したがって、予測子はstd::rand()打撃として側面を変更します。

一方、ソートされると、予測子は最初に強く取られない状態に移行し、値が高い値に変化すると、予測子は3回で強く取られないから強く取られるまでの変化を経ます。



697

同じ行(これはどの回答でも強調されていなかったと思います)では、(特にLinuxカーネルのようにパフォーマンスが重要なソフトウェアでは)次のようなifステートメントが見つかることがあることに言及しておくのは良いことです。

if (likely( everything_is_ok ))
{
    /* Do something */
}

または同様に:

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

どちらlikely()unlikely()GCCのようなものを使用して定義されている実際のマクロである__builtin_expectアカウントにユーザーから提供された情報を取る条件を優先するように予測コードを挿入し、コンパイラを支援します。GCCは、実行中のプログラムの動作を変更したり、キャッシュのクリアなどの低レベルの命令を発行したりできる他のビルトインをサポートします。利用可能なGCCのビルトインを通過するこのドキュメントを参照してください。

通常、この種の最適化は主に、実行時間が重要で重要なハードリアルタイムアプリケーションまたは組み込みシステムで見られます。たとえば、1/10000000回しか発生しないエラー条件をチェックしている場合は、コンパイラにこれを通知しないのはなぜですか?このように、デフォルトでは、分岐予測は条件が偽であると想定します。


679

C ++で頻繁に使用されるブール演算は、コンパイルされたプログラムで多くの分岐を生成します。これらのブランチがループ内にあり、予測が難しい場合、実行が大幅に遅くなる可能性があります。ブール変数は値を8ビット整数として格納されている0ためfalse1のためにtrue

ブール変数は、ブール変数を入力として持つすべての演算子は、入力に0or 以外の値があるかどうかをチェック1しますが、ブールを出力として持つ演算子は、0または以外の値を生成できないという意味で過決定1です。これにより、ブール変数を入力として使用した操作は、必要以上に効率が低下します。例を考えてみましょう:

bool a, b, c, d;
c = a && b;
d = a || b;

これは通常、コンパイラーによって次のように実装されます。

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

このコードは最適とはほど遠いものです。予測ミスの場合、ブランチに長い時間がかかることがあります。オペランドに0and 以外の値がないことが確実にわかっている場合は、ブール演算をはるかに効率的に行うことができます1。コンパイラーがそのような仮定を行わない理由は、変数が初期化されていないか、未知のソースからのものである場合、変数は他の値を持つ可能性があるためです。上記のコードは、有効な値に初期化されている場合abまたはブール値の出力を生成する演算子に由来する場合に最適化できます。最適化されたコードは次のようになります。

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

charブール演算子(and )の代わりにboolビット演算子(&and |)を使用できるようにするために、の代わりにが使用されています。ビット演算子は、1クロックサイクルしかかからない単一の命令です。OR演算子は()しても動作しますし、以外の値を持っていますか。AND演算子()とEXCLUSIVE OR演算子()は、オペランドにand 以外の値がある場合、一貫性のない結果になることがあります。&&|||ab01&^01

~NOTには使用できません。代わりに、既知であることがわかっている変数にブールNOTを作成する01、次のようにXORすることでブール値を作成できます1

bool a, b;
b = !a;

次のように最適化できます。

char a = 0, b;
b = a ^ 1;

a && b置き換えることができないa & b場合b場合に評価すべきではない表現でaあるfalse&&評価しないだろうb&だろうが)。同様に、a || bと置き換えることができないa | b場合b場合に評価すべきではない表現です。atrue

オペランドが比較である場合よりも、オペランドが変数である場合の方が、ビット演算子を使用する方が有利です。

bool a; double x, y, z;
a = x > y && z < 5.0;

ほとんどの場合に最適です(&&式が多くの分岐予測ミスを生成することを期待している場合を除く)。


342

それは確かだ!...

分岐予測コード内で発生する切り替えのため、によりロジックの実行が遅くなります。まっすぐな通りや曲がり角の多い通りに行っているようなものです。まっすぐな方が早く完了します。...

配列がソートされている場合、最初のステップで条件はfalseです。 data[c] >= 128なり、次に、通りの終わりまでの全体の真の値になります。これにより、ロジックをより早く終了させることができます。一方、ソートされていない配列を使用すると、コードの実行を確実に遅くする多くの調整と処理が必要になります...

以下に作成した画像をご覧ください。どちらの道路が早く終了するのですか?

分岐予測

したがって、プログラム的には、分岐予測によりプロセスが遅くなります...

また、最後に、2種類の分岐予測があり、それぞれがコードに異なる影響を与えることを知っておくとよいでしょう。

1.静的

2.ダイナミック

分岐予測

静的分岐予測は、条件付き分岐に初めて遭遇したときにマイクロプロセッサによって使用され、動的分岐予測は、条件付き分岐コードの後続の実行に使用されます。

これらのルールを利用するようにコードを効率的に作成するには、if-elseステートメントまたはswitchステートメントを作成するときに、最も一般的なケースを最初に確認し、最も一般的でないものまで段階的に作業します。通常、ループ反復子の条件のみが使用されるため、ループは静的分岐予測のためにコードの特別な順序を必ずしも必要としません。


304

この質問はすでに何度も素晴らしい回答を得ています。それでも、グループの注目を別の興味深い分析に向けたいと思います。

最近、この例(わずかに変更)は、Windows上のプログラム自体の中でコードの一部をプロファイルする方法を示す方法としても使用されました。途中で、作成者は結果を使用して、ソートされた場合とソートされていない場合の両方で、コードがほとんどの時間を費やしている場所を判別する方法も示します。最後に、この記事では、HAL(ハードウェアアブストラクションレイヤー)のあまり知られていない機能を使用して、並べ替えられていない場合に発生する分岐予測ミスの量を判断する方法も示します。

リンクはここにあります:http : //www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm


3
これは非常に興味深い記事です(実際、すべて読んだばかりです)が、質問にはどのように答えますか?
Peter Mortensen

2
@PeterMortensen私はあなたの質問に少し混乱しています。たとえば、ここにその部分の1つの関連する行があります。 When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping. 作成者は、ここに投稿されたコードのコンテキストでプロファイリングについて話し合い、その過程で、ソートされたケースがはるかに高速である理由を説明しようとしています。
ForeverLearning 2018年

261

他の人がすでに言及しているように、謎の背後にあるのは分岐予測子です。

私は何かを追加しようとしているのではなく、別の方法でコンセプトを説明しています。テキストと図を含む簡潔な紹介がウィキにあります。図を使用して分岐予測子を直感的に詳しく説明する以下の説明が好きです。

コンピュータアーキテクチャでは、分岐予測子は、分岐が確実にわかる前に分岐(たとえば、if-then-else構造)がどの方向に進むかを推測するデジタル回路です。分岐予測子の目的は、命令パイプラインのフローを改善することです。ブランチプレディクタは、x86などの多くの最新のパイプラインマイクロプロセッサアーキテクチャで高い効果的なパフォーマンスを実現する上で重要な役割を果たします。

双方向分岐は通常、条件付きジャンプ命令で実装されます。条件付きジャンプは、「実行されない」で条件付きジャンプの直後に続くコードの最初の分岐から実行を継続するか、「取得」してコードの2番目の分岐が存在するプログラムメモリの別の場所にジャンプすることができます。保管。条件が計算され、条件付きジャンプが命令パイプラインの実行ステージを通過するまで、条件付きジャンプが行われるかどうかは確実にはわかりません(図1を参照)。

図1

説明したシナリオに基づいて、さまざまな状況でパイプラインで命令が実行される方法を示すアニメーションデモを作成しました。

  1. 分岐予測子なし。

分岐予測がない場合、次の命令がパイプラインのフェッチステージに入る前に、条件付きジャンプ命令が実行ステージを通過するまでプロセッサは待機する必要があります。

この例には3つの命令が含まれており、最初の命令は条件付きジャンプ命令です。後の2つの命令は、条件付きジャンプ命令が実行されるまでパイプラインに入ることができます。

分岐予測子なし

3つの命令が完了するまでに9クロックサイクルかかります。

  1. 分岐予測子を使用し、条件付きジャンプを行わないでください。予測が条件付きジャンプをとっていないと仮定しましょう。

ここに画像の説明を入力してください

3つの命令が完了するまでに7クロックサイクルかかります。

  1. 分岐予測子を使用して、条件付きジャンプを実行します。予測が条件付きジャンプをとっていないと仮定しましょう。

ここに画像の説明を入力してください

3つの命令が完了するまでに9クロックサイクルかかります。

分岐予測ミスの場合に無駄になる時間は、フェッチステージから実行ステージまでのパイプラインのステージ数と同じです。最近のマイクロプロセッサはパイプラインが非常に長くなる傾向があるため、予測ミスの遅延は10〜20クロックサイクルです。その結果、パイプラインを長くすると、より高度な分岐予測子の必要性が高まります。

ご覧のとおり、ブランチプレディクターを使用しない理由はないようです。

これは、Branch Predictorの非常に基本的な部分を明らかにする非常に単純なデモです。それらのgifが迷惑な場合は、自由に回答から削除してください。訪問者は、BranchPredictorDemoからライブデモのソースコードを取得することもできます


1
インテルのマーケティングアニメーションとほぼ同じで、ブランチの予測だけでなく、順序どおりに実行されず、どちらの戦略も「投機的」でした。メモリとストレージの先読み(バッファへの順次プリフェッチ)も投機的です。それはすべて追加されます。
mckenzm

@mckenzm:順不同の投機的execは分岐予測をさらに価値のあるものにします。フェッチ/デコードバブルを非表示にするだけでなく、分岐予測+投機的execは、クリティカルパスレイテンシから制御依存関係を削除します。if()ブロックの内部または後のコードは、分岐条件がわかるに実行できます。または、またはのような検索ループのstrlen場合memchr、反復が重複する可能性があります。次の反復のいずれかを実行する前に、一致するかどうかの結果がわかるまで待つ必要がある場合は、スループットではなく、キャッシュの負荷+ ALUの待ち時間でボトルネックが発生します。
Peter Cordes

210

分岐予測ゲイン!

ブランチの予測ミスがプログラムの速度を低下させないことを理解することが重要です。予測に失敗した場合のコストは、分岐予測が存在しない場合と同じで、実行するコードを決定するための式の評価を待ちます(次の段落でさらに説明します)。

if (expression)
{
    // Run 1
} else {
    // Run 2
}

if-else\ switchステートメントがある場合は常に、実行するブロックを決定するために式を評価する必要があります。コンパイラが生成するアセンブリコードには、条件分岐命令が挿入されています。

分岐命令は、コンピュータに別の命令シーケンスの実行を開始させ、命令を実行するというデフォルトの動作から逸脱する可能性があります(つまり、式がfalseの場合、プログラムは if、ある条件に応じてブロック)。私たちの場合の表現の評価。

そうは言っても、コンパイラーは実際に評価される前に結果を予測しようとします。ifブロックから命令をフェッチし、式が真であることが判明した場合は、すばらしいです!評価にかかる時間を取得し、コードを進歩させました。そうでない場合、間違ったコードを実行しており、パイプラインがフラッシュされ、正しいブロックが実行されます。

視覚化:

ルート1またはルート2を選択する必要があるとしましょう。パートナーがマップを確認するのを待っています。##で停止して待機しています。または、ルート1を選択して、運が良ければ(ルート1が正しいルートです)、そうすれば、パートナーがマップをチェックするのを待つ必要はありませんでした(彼がマップをチェックするのにかかる時間を節約できました)。それ以外の場合は、元に戻すだけです。

パイプラインのフラッシュは非常に高速ですが、現在、このギャンブルを利用する価値はあります。ソートされたデータやゆっくり変化するデータを予測することは、速い変化を予測するよりも常に簡単で優れています。

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------

パイプラインのフラッシュは非常に高速です が、実際にはそうでありません。DRAMまでのキャッシュミスと比較すると高速ですが、最新の高性能x86(Intel Sandybridgeファミリなど)では約12サイクルです。高速リカバリを使用すると、リカバリを開始する前に、古い独立した命令がすべてリタイアに到達するのを待たなくても、予測ミスにより多くのフロントエンドサイクルが失われます。 skylake CPUがブランチを誤って予測すると、正確にはどうなりますか?。(そして、各サイクルは約4命令の作業になる可能性があります。)高スループットコードには適していません。
Peter Cordes

153

ARMでは、すべての命令に4ビットの条件フィールドがあり、プロセッサステータスレジスタで発生する可能性のある16の異なる条件のいずれかを(ゼロコストで)テストするため、分岐の必要はありません。false、命令はスキップされます。これにより、短い分岐の必要がなくなり、このアルゴリズムでは分岐予測のヒットはありません。したがって、ソートのオーバーヘッドが増えるため、このアルゴリズムのソートされたバージョンは、ARMのソートされていないバージョンよりも実行速度が遅くなります。

このアルゴリズムの内部ループは、ARMアセンブリ言語では次のようになります。

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize

しかし、これは実際には全体像の一部です。

CMPオペコードは常にプロセッサステータスレジスタ(PSR)のステータスビットを更新します。これは、その目的であるためです。ただし、オプションにSサフィックスを追加しない限り、他のほとんどの命令はPSRに影響を与えません。PSRは、命令の結果。4ビット条件サフィックスと同様に、PSRに影響を与えずに命令を実行できることは、ARMでの分岐の必要性を減らし、ハードウェアレベルでの順序外のディスパッチを容易にするメカニズムです。ため、更新することを、いくつかの操作Xを実行した後、ステータスビット、その後(または並行して)明示的にステータスビットに影響を与えない他の一連の作業を実行できます。その後、Xによって以前に設定されたステータスビットの状態をテストできます。

条件テストフィールドとオプションの「ステータスビットの設定」フィールドは、たとえば次のように組み合わせることができます。

  • ADD R1, R2, R3R1 = R2 + R3ステータスビットを更新せずに実行します。
  • ADDGE R1, R2, R3 ステータスビットに影響を与える前の命令が「より大きい」または「等しい」条件になった場合にのみ、同じ操作を実行します。
  • ADDS R1, R2, R3実行添加した後、更新NZC及びV結果は陰性であったかどうかに基づいて、プロセッサステータスレジスタのフラグをゼロ(符号なし添加のため)を搭載し、またはオーバーフロー(符号付き加算のために)。
  • ADDSGE R1, R2, R3GEテストが真の場合にのみ加算を実行し、その後、加算の結果に基づいてステータスビットを更新します。

ほとんどのプロセッサアーキテクチャには、特定の操作でステータスビットを更新する必要があるかどうかを指定するこの機能がありません。ステータスビットを保存して後で復元するために追加のコードを書く必要がある場合や、追加の分岐が必要な場合や、プロセッサのアウトを制限する場合があります。ほとんどの命令の後にステータスビットを強制的に更新するほとんどのCPU命令セットアーキテクチャの副作用の1つは、互いに干渉することなく並列に実行できる命令を切り離すことがはるかに難しいことです。ステータスビットの更新には副作用があるため、コードに線形化効果があります。ARMは、命令の後に分岐のない条件テストを組み合わせて、任意の命令の後にステータスビットを更新するかどうかを選択するオプションを組み合わせて、アセンブリ言語のプログラマーとコンパイラーの両方にとって非常に強力であり、非常に効率的なコードを生成します。

ARMがなぜこれほどまでに成功したのか疑問に思ったことがあるなら、これら2つのメカニズムの素晴らしい効果と相互作用は、ARMアーキテクチャの効率の最大の源の1つであるため、ストーリーの大きな部分を占めています。1983年のARM ISAのオリジナルデザイナーであるスティーブファーバーとロジャー(現在はソフィー)ウィルソンの輝きは、誇張することはできません。


1
ARMのもう1つの革新は、S命令サフィックスの追加です。これは、(ほとんど)すべての命令でオプションであり、指定しない場合、命令がステータスビットを変更することを防ぎます(ステータスビットを設定するCMP命令を除き、したがって、Sサフィックスは不要です)。これにより、比較がゼロまたは類似のものである限り、多くの場合CMP命令を回避できます(たとえば、SUBS R0、R0、#1は、R0がゼロに達するとZ(ゼロ)ビットを設定します)。条件文とSサフィックスはオーバーヘッドを発生しません。それはかなり美しいISAです。
ルークハッチソン

2
Sサフィックスを追加しないことで、1つがステータスビットを変更することを心配せずに、複数の条件付き命令を続けて使用できるようになります。
ルークハッチソン

OPには測定値を並べ替える時間が含まれていないことに注意してください。ソートされていない場合、ループの実行が大幅に遅くなりますが、ブランチx86ループを実行する前に最初にソートすることも、おそらく全体的な損失です。しかし、大きな配列をソートするには、多くの作業が必要です。
Peter Cordes

ところで、配列の最後を基準にしてインデックスを付けることで、ループ内の命令を保存できます。ループの前に、を設定してからR2 = data + arraySize、から始めR1 = -arraySizeます。ループの最下部はadds r1, r1, #1/になりbnz inner_loopます。コンパイラーは何らかの理由でこの最適化を使用しません。//とにかく、この場合の述部の追加の実行は、x86などの他のISAのブランチレスコードで実行できるものと基本的に違いはありませんcmov。それほど良くはありませんが、gcc最適化フラグ-O3を使用すると、コードが-O2よりも遅くなります
Peter Cordes

1
(ARM述部実行は命令の真のNOPを実行するためcmov、メモリソースオペランドを持つx86 とは異なり、ロードまたはストアでそれを使用することもできます。AArch64を含むほとんどのISAはALU選択演算のみを備えています。したがって、ARM述部は強力です。ほとんどのISAでブランチレスコードよりも効率的に使用できます。)
Peter Cordes

147

分岐予測についてです。それは何ですか?

  • ブランチプレディクタは、現代のアーキテクチャとの関連性が見出される古代のパフォーマンス向上手法の1つです。単純な予測手法は高速なルックアップと電力効率を提供しますが、予測ミス率が高くなります。

  • 一方、複雑な分岐予測(ニューラルベースまたは2レベル分岐予測のバリアント)は予測精度が向上しますが、消費電力が増え、複雑さが指数関数的に増加します。

  • これに加えて、複雑な予測手法では、分岐の予測にかかる時間自体が非常に長く(2〜5サイクル)、実際の分岐の実行時間に匹敵します。

  • 分岐予測は、本質的に最適化(最小化)の問題であり、最小のリソースで可能な限り低いミス率、低消費電力、および低複雑度を達成することに重点が置かれます。

実際には、3種類のブランチがあります。

条件分岐の転送 -ランタイム条件に基づいて、PC(プログラムカウンター)は、命令ストリーム内の転送アドレスを指すように変更されます。

後方条件付き分岐-PCは、命令ストリームの後方を指すように変更されます。分岐は、ループの最後のテストでループを再度実行する必要があると示されているときにプログラムループの先頭に逆方向に分岐するなど、いくつかの条件に基づいています。

無条件分岐 -これには、特定の条件のないジャンプ、プロシージャコール、リターンが含まれます。たとえば、無条件ジャンプ命令は単に「jmp」としてアセンブリ言語でコーディングされ、命令ストリームはジャンプ命令によってポイントされたターゲットの場所にすぐに送られる必要がありますが、「jmpne」としてコーディングされる条件付きジャンプ以前の「比較」命令で2つの値を比較した結果、値が等しくないことが示された場合にのみ、命令ストリームをリダイレクトします。(x86アーキテクチャーで使用されるセグメント化されたアドレス指定スキームは、ジャンプが「セグメント内」または「セグメント外」のいずれかになるため、さらに複雑になります。各タイプは、分岐予測アルゴリズムに異なる影響を与えます。)

静的/動的分岐予測:静的分岐予測は、条件付き分岐に初めて遭遇したときにマイクロプロセッサによって使用され、動的分岐予測は、条件付き分岐コードの後続の実行に使用されます。

参照:


146

分岐予測が遅くなる可能性があるという事実に加えて、ソートされた配列には別の利点があります。

値をチェックするだけでなく、停止条件を設定することができます。これにより、関連するデータのみをループし、残りを無視します。
分岐予測は一度だけ失敗します。

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }

1
そうですが、配列をソートするための設定コストはO(N log N)であるため、配列をソートする唯一の理由が早期にブレークできるようにする必要がある場合は、早期にブレークしても役に立ちません。ただし、配列を事前にソートする他の理由がある場合は、そうです、これは価値があります。
ルークハッチソン

データをループする回数と比較して、データをソートする回数によって異なります。この例では、ソートはそれだけでループの前にある必要はありません、単なる一例である
Yochai Timmer

2
はい、それはまさに私の最初のコメントで私が述べた点です:-)あなたは「分岐予測は一度だけ失敗するでしょう」と言います。ただし、並べ替えアルゴリズム内のO(N log N)分岐予測ミスはカウントされません。これは、実際には、並べ替えられていない場合のO(N)分岐予測ミスよりも大きくなります。したがって、ソートアルゴリズムに応じて、ソートされたデータ全体をO(log N)回使用してブレークする必要があります(おそらく実際にはO(10 log N)に近く、キャッシュミスによるクイックソートの場合-mergesortよりキャッシュコヒーレントなので、均等に分割するにはO(2 log N)の使用量に近づける必要があります。)
Luke Hutchison

ただし、重要な最適化の1つは、「半分のクイックソート」のみを実行して、ターゲットピボット値127未満のアイテムのみをソートすることです(ピボット以下のすべてがピボットの後にソートされる想定)。ピボットに到達したら、ピボットの前の要素を合計します。これは、O(N log N)ではなくO(N)起動時間で実行されますが、分岐予測ミスは依然として多く、おそらく前に与えた数に基づいたO(5 N)のオーダーです。クイックソートの半分です。
ルークハッチソン

132

分岐予測と呼ばれる現象により、ソートされた配列は、ソートされていない配列よりも高速に処理されます。

分岐予測子は、分岐がどの方向に進むかを予測し、命令パイプラインのフローを改善しようとする(コンピュータアーキテクチャの)デジタル回路です。回路/コンピューターは次のステップを予測して実行します。

間違った予測を行うと、前のステップに戻り、別の予測で実行されます。予測が正しいと仮定すると、コードは次のステップに進みます。間違った予測は、正しい予測が行われるまで同じステップを繰り返すことになります。

あなたの質問への答えは非常に簡単です。

並べ替えられていない配列では、コンピューターが複数の予測を行うため、エラーの可能性が高くなります。一方、並べ替えられた配列では、コンピューターが行う予測が少なく、エラーの可能性が低くなります。より多くの予測を行うには、より多くの時間が必要です。

ソートされた配列:直線道路____________________________________________________________________________________----------------------------------------------TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

並べ替えられていない配列:曲線道路

______   ________
|     |__|

分岐予測:どの道路がまっすぐであるかを推測/予測し、チェックせずにそれに従います

___________________________________________ Straight road
 |_________________________________________|Longer road

どちらの道路も同じ目的地に到達しますが、直線道路は短く、もう一方は長くなります。誤ってもう一方を選択した場合、後戻りする必要がないため、長い道路を選択した場合、余分な時間を浪費することになります。これはコンピュータで起こることと似ていますが、これがあなたの理解を深めるのに役立つことを願っています。


また、コメントから@Simon_Weaverを引用したいと思います。

予測が少なくなることはありません。誤った予測が少なくなります。それでも、毎回ループを予測する必要があります...


124

次のMATLABコードに対して、MacBook Pro(Intel i7、64ビット、2.4 GHz)でMATLAB 2011bを使用して同じコードを試しました。

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

上記のMATLABコードの結果は次のとおりです。

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

@GManNickGのようなCコードの結果:

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

これに基づいて、MATLABはソートなしのC実装よりもほぼ175倍遅く、ソートありの場合は350倍遅いように見えます。つまり、(分岐予測の)効果は、MATLAB実装の場合は1.46x、C実装の場合は2.7xです。


7
完全を期すために、これはおそらくMatlabに実装する方法ではありません。問題をベクトル化した後で実行すると、はるかに高速になると思います。
ysap 2013年

1
Matlabは多くの状況で自動並列化/ベクトル化を行いますが、ここでの問題は分岐予測の効果を確認することです。とにかくMatlabは免除されていません!
シャン2013年

1
matlabはネイティブ番号または
ThorbjørnRavn Andersen

55

データをソートする必要があるという他の回答による仮定は正しくありません。

次のコードは配列全体を並べ替えるのではなく、配列の200要素のセグメントのみを並べ替えるため、最も高速に実行されます。

k要素セクションのみを並べ替えると、配列全体を並べ替えるのに必要な時間でO(n)はなく、線形時間で前処理が完了O(n.log(n))します。

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

これはまた、ソート順などのアルゴリズムの問​​題とは何の関係もないことを「証明」し、実際には分岐予測です。


4
これが何を証明するのか本当にわかりませんか?あなたが示した唯一のことは、「配列全体をソートするすべての作業を行う必要がないことは、配列全体をソートするよりも時間がかからない」ということです。これも「最も速く実行される」というあなたの主張は、非常にアーキテクチャに依存しています。これがARMでどのように機能するかについての私の回答を参照してください。PSでは、合計を200要素のブロックループ内に入れ、逆に並べ替え、範囲外の値が得られたらブレークするというYochai Timmerの提案を使用することで、ARM以外のアーキテクチャでコードを高速化できます。このようにして、各200要素のブロックの合計を早期に終了できます。
ルークハッチソン

ソートされていないデータに対してアルゴリズムを効率的に実装したい場合は、その操作をブランチレスで実行します(SIMDを使用して、たとえばx86 pcmpgtbを使用して上位ビットが設定された要素を検索し、ANDして小さな要素をゼロにします)。実際にチャンクを並べ替えるのに時間がかかると遅くなります。ブランチレスバージョンは、データに依存しないパフォーマンスを備え、コストがブランチの予測ミスによるものであることも証明します。または、パフォーマンスカウンターを使用して、Skylakeのように直接それを観察するint_misc.clear_resteer_cyclesint_misc.recovery_cycles、予測ミスからフロントエンドのアイドルサイクルをカウントします
Peter Cordes

上記のコメントはどちらも、一般的なアルゴリズムの問​​題と複雑さを無視しているようで、特別な機械命令を備えた専用ハードウェアを推奨しています。私は最初の1つが特にささいなことだと思います。それは、特殊な機械語命令を支持して、この回答の重要な一般的洞察を軽快に却下するからです。
user2297550

36

この質問に対するBjarne Stroustrupの回答

それはインタビューの質問のように聞こえます。本当ですか?どうやって知る?最初にいくつかの測定を行わずに効率に関する質問に答えることは悪い考えなので、測定方法を知ることが重要です。

だから、私は百万の整数のベクトルで試してみました:

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

確かに数回実行しました。はい、現象は本物です。私のキーコードは:

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label 
         << duration_cast<microseconds>(t1  t0).count() 
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

少なくとも、この現象は、このコンパイラ、標準ライブラリ、およびオプティマイザの設定で実際に発生しています。実装が異なれば、答えも異なります。実際、誰かがより体系的な調査を行っており(簡単なWeb検索でそれを見つけることができます)、ほとんどの実装はその効果を示しています。

1つの理由は分岐予測です“if(v[i] < pivot]) …”。並べ替えアルゴリズムの主要な操作は同等です。ソートされたシーケンスの場合、そのテストは常に真ですが、ランダムシーケンスの場合、選択されるブランチはランダムに変化します。

別の理由は、ベクトルがすでにソートされている場合、要素を正しい位置に移動する必要がないためです。これらの細部の影響は、私たちが見た5倍または6倍の要因です。

クイックソート(および一般的なソート)は、コンピュータサイエンスの最高の心を集めた複雑な研究​​です。優れたソート関数は、優れたアルゴリズムを選択し、その実装でハードウェアのパフォーマンスに注意を払った結果です。

効率的なコードを記述したい場合は、マシンのアーキテクチャについて少し知っておく必要があります。


28

この質問は、CPUのブランチ予測モデルに根ざしています。このペーパーを読むことをお勧めします。

複数の分岐予測と分岐アドレスキャッシュによる命令フェッチレートの向上

要素をソートすると、IRはすべてのCPU命令を何度もフェッチする必要がなくなり、キャッシュからそれらをフェッチします。


命令は、予測ミスに関係なく、CPUのL1命令キャッシュでホットなままです。問題は、直前の命令がデコードされて実行が完了する前に、それらを正しい順序でパイプラインにフェッチすることです。
Peter Cordes

15

分岐予測エラーを回避する1つの方法は、ルックアップテーブルを作成し、データを使用してインデックスを作成することです。Stefan de Bruijnさんは彼の答えでそれについて話しました。

ただし、この場合、値が[0、255]の範囲にあることがわかっており、値が128以上の場合にのみ注意が必要です。つまり、値が必要かどうかを示す単一のビットを簡単に抽出できます。データは右側の7ビットにあり、0ビットまたは1ビットが残っています。1ビットがある場合にのみ値を追加します。このビットを「決定ビット」と呼びましょう。

決定ビットの0/1値を配列のインデックスとして使用することにより、データがソートされているかどうかに関係なく、同等に高速なコードを作成できます。私たちのコードは常に値を追加しますが、決定ビットが0の場合、気にしない場所に値を追加します。コードは次のとおりです。

//テスト

clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

このコードは追加の半分を浪費しますが、分岐予測の失敗はありません。ランダムデータの方が、実際のifステートメントを使用したバージョンよりも非常に高速です。

しかし、私のテストでは、おそらくルックアップテーブルへのインデックス付けがビットシフトよりもわずかに速いため、明示的なルックアップテーブルはこれよりもわずかに高速でした。これは、私のコードがどのようにルックアップテーブルをセットアップして使用するかを示しています(コードでは "LookUp Table"のlutと呼ばれています)。これがC ++コードです。

//宣言してからルックアップテーブルに入力します

int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

この場合、ルックアップテーブルは256バイトしかないため、キャッシュにうまく収まり、すべてが高速でした。この手法は、データが24ビット値で、半分しか必要でない場合はうまく機能しません。ルックアップテーブルが大きすぎて実用的ではありません。一方、上記の2つの手法を組み合わせることができます。最初にビットをシフトし、次にルックアップテーブルにインデックスを付けます。上半分の値のみが必要な24ビット値の場合、データを12ビット右にシフトし、テーブルインデックスの12ビット値のままにすることができます。12ビットテーブルインデックスは、4096の値のテーブルを意味します。

ifステートメントを使用する代わりに、配列にインデックスを付ける手法を使用して、使用するポインターを決定できます。バイナリツリーを実装したライブラリを見て、2つの名前付きポインタ(pLeftとpRightなど)の代わりに、長さ2のポインタ配列を持ち、「決定ビット」手法を使用して、どちらを使用するかを決定しました。たとえば、次の代わりに:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;
this library would do something like:

i = (x < node->value);
node = node->link[i];

多分それはうまくいくでしょう


これをテストしたC ++コンパイラ/ハードウェア、およびコンパイラオプションは何ですか?元のバージョンが素晴らしいブランチレスSIMDコードに自動ベクトル化しなかったのには驚いています。完全な最適化を有効にしましたか?
Peter Cordes、

4096のエントリルックアップテーブルは異常に聞こえます。ビットをシフトアウトする場合、元の数値を追加する場合は、LUTの結果だけを使用する必要はありません。これらはすべて、ブランチレステクニックを使用して簡単にコンパイラを回避する愚かなトリックのように聞こえます。より簡単なのはmask = tmp < 128 : 0 : -1UL;/total += tmp & mask;
Peter Cordes
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.