これは非常に独特であるように思われる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秒で実行されます。最初は、これは単なる言語またはコンパイラの異常なのかと思いました。だから私は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);
}
}
多少似ていますが極端な結果は出ません。
私が最初に考えたのは、ソートによってデータがキャッシュに入れられるということでしたが、配列が生成されたばかりなので、それがどれほどばかげているのかと思いました。
あなたはの犠牲者です 分岐予測 失敗。
鉄道の交差点を考えます。
画像 Mecanismo著、ウィキメディア・コモンズ経由。 CC-By-SA 3.0 ライセンスの下で使用されます。
今議論のために、これが1800年代に戻ったと仮定しなさい - 長距離または無線通信の前に。
あなたは交差点の運営者であり、電車が来るのを聞いています。あなたはそれがどの道を行くことになっているのかわかりません。あなたは電車を止めて、運転手にどちらの方向を向くのか尋ねます。それからスイッチを適切に設定します。
電車は重く、慣性がたくさんあります。そのため、立ち上げと減速には時間がかかります。
もっと良い方法はありますか?あなたは電車がどちらの方向に行くのか推測します!
毎回正しいと思うなら なら、電車は停車する必要は決してないでしょう。
あまりにも間違って推測した場合 、電車は停止、後退、再開に多くの時間を費やします。
if文を考えます。 プロセッサレベルでは、分岐命令です。
あなたはプロセッサです、そしてあなたは枝を見ます。どの方向に進むのかわかりません。職業はなんですか?実行を停止して、前の命令が完了するまで待ちます。それから正しい道を進みます。
現代のプロセッサは複雑で長いパイプラインを持っています。したがって、「ウォームアップ」と「スローダウン」には時間がかかります。
もっと良い方法はありますか?あなたは枝がどちらの方向に行くのか推測します!
毎回正しいと推測した場合 の場合、実行は停止する必要はありません。
あなたがあまりにも頻繁に間違っていると思うなら 、あなたは失速し、ロールバックし、そして再起動するのに多くの時間を費やします。
これは分岐予測です。電車はただ旗で方向を知らせることができるので、私はそれが最善の類推ではないと認めます。しかし、コンピュータでは、プロセッサはブランチが最後の方向に進む方向を知りません。
では、列車が後退して別の道をたどらなければならない回数を最小限に抑えるために、どのようにして戦略的に推測するのでしょうか。あなたは過去の歴史を見ます!電車がその時間の99%出発した場合、あなたは出発したと思います。それが交替するならば、あなたはあなたの推測を交替させます。それが3回ごとに片道になるならば、あなたは同じことを推測します...
言い換えれば、パターンを識別してそれに従うことを試みます。これは、分岐予測子がどのように機能するかを表したものです。
ほとんどのアプリケーションには行儀の良いブランチがあります。そのため、最新のブランチ予測では通常90%を超えるヒット率が達成されます。しかし、認識可能なパターンのない予測不可能な分岐に直面した場合、分岐予測子は実質的に役に立ちません。
さらに読むこと: ウィキペディアの "Branch predictor"記事 。
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[]
のすべての入力値に対して有効です。)
ベンチマーク:3.5 GHzのコアi7 920
C++ - Visual Studio 2010 - x 64リリース
// 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 - x 64
// Branch - Random
seconds = 10.93293813
// Branch - Sorted
seconds = 5.643797077
// Branchless - Random
seconds = 3.113581453
// Branchless - Sorted
seconds = 3.186068823
所見:
一般的な経験則は、クリティカルループにおけるデータ依存の分岐を避けることです。 (この例のように)
更新:
X64で-O3
または-ftree-vectorize
を使用するGCC 4.6.1は条件付き移動を生成できます。そのため、ソートされたデータとソートされていないデータに違いはありません。どちらも高速です。
VC++ 2010は/Ox
の下でもこのブランチの条件付き移動を生成できません。
インテル®コンパイラー11は、奇跡的なことをします。それ 2つのループを交換する それによって、予測不可能な分岐を外側のループに巻き上げます。そのため、予測ミスの影響を受けないだけでなく、VC++およびGCCが生成できる値の2倍の速さです。言い換えれば、ICCはテストループを利用してベンチマークを破りました。
あなたがインテル®コンパイラーに分岐のないコードを与えると、それはそれを右アウトベクトル化します…そして分岐と同じくらい速くなります(ループ交換を使って)。
これは、成熟した現代のコンパイラでさえ、コードを最適化する能力が大きく異なる可能性があることを示しています...
分岐予測
ソートされた配列では、条件data[c] >= 128
は最初に縞の値に対してfalse
となり、それ以降のすべての値に対してtrue
になります。予測は簡単です。並べ替えられていない配列では、分岐コストがかかります。
データがソートされたときにパフォーマンスが劇的に向上するのは、 Mysticial の答えで詳しく説明されているように、分岐予測のペナルティが取り除かれるためです。
さて、コードを見ると
if (data[c] >= 128)
sum += data[c];
この特定のif... else...
ブランチの意味は、条件が満たされたときに何かを追加することであることがわかります。このタイプの分岐は、 条件付き移動 ステートメントに簡単に変換できます。これは、x86
システム内で、条件付き移動命令cmovl
にコンパイルされます。分岐ひいては潜在的な分岐予測ペナルティは除去される。
C
、つまりC++
では、x86
の条件付き移動命令に直接(最適化なしで)コンパイルされるステートメントは、3項演算子... ? ... : ...
です。したがって、上記のステートメントを同等のものに書き換えます。
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つの関数max1
とmax2
を使います。
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つの命令が終了するのを待つ必要はありません。これはパイプライン化と呼ばれます。
分岐の場合、後続の命令は先行する命令によって決定されるため、パイプライン処理を実行できません。待つか予測する必要があります。
条件付き移動の場合、実行条件付き移動命令はいくつかのステージに分割されますが、Fetch
やDecode
のような初期のステージは前の命令の結果に依存しません。後者のステージだけが結果を必要とします。したがって、1命令の実行時間のごく一部を待ちます。これが、予測が容易な場合に条件付き移動バージョンが分岐より遅い理由です。
本コンピュータシステム:プログラマーの視点、第2版はこれを詳細に説明しています。 条件付き移動命令についてはセクション3.6.6、Processor Architectureについては第4章全体、そしてBranch Predictionとmispredictionペナルティについての特別な取り扱いについてはセクション5.11.2を確認することができます。
最新のコンパイラの中には、パフォーマンスを向上させてコードをAssemblyに最適化できるものもあれば、できないものもあります(問題のコードはVisual Studioのネイティブコンパイラを使用しています)。予測できないときに分岐と条件付き移動のパフォーマンスの違いを知っておくと、シナリオが複雑になりすぎてコンパイラが自動的に最適化できないときに、パフォーマンスが向上したコードを書くのに役立ちます。
このコードに対して実行できるさらに多くの最適化に興味がある場合は、次の点を考慮してください。
元のループから始めます。
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
outを巻き上げることができます。
for (unsigned j = 0; j < arraySize; ++j)
{
if (data[j] >= 128)
{
for (unsigned i = 0; i < 100000; ++i)
{
sum += data[j];
}
}
}
これで、浮動小数点モデルで許可されていると仮定して、内側のループを1つの式にまとめることができます(たとえば、/ fp:fastがスローされます)。
for (unsigned j = 0; j < arraySize; ++j)
{
if (data[j] >= 128)
{
sum += data[j] * 100000;
}
}
それは以前よりも10万倍速い
私たちの何人かは、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];
. . . . }
. . . . }
これにより、問題のある行を簡単に識別できます。ソートされていないバージョンでは、cachegrindの分岐予測モデルの下でif (data[c] >= 128)
ラインが164,050,007の予測ミス条件分岐(Bcm
)を引き起こしています。
あるいは、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)
...
詳しくは パフォーマンスチュートリアル をご覧ください。
私はこの質問とその答えを読んだばかりで、答えが欠けていると感じます。
管理言語で特に有効に機能することがわかったブランチ予測を排除する一般的な方法は、ブランチを使用する代わりにテーブルルックアップを使用することです(この場合はテストしていません)。
このアプローチは、次の場合に一般的に機能します。
背景とその理由
プロセッサの観点からは、あなたの記憶は遅いです。速度の違いを補うために、2つのキャッシュがプロセッサに組み込まれています(L1/L2キャッシュ)。それで、あなたがあなたのニース計算をしていることを想像して、そしてあなたが1個の記憶を必要とすることを考え出しなさい。プロセッサは、その「ロード」操作を取得してメモリの一部をキャッシュにロードします。その後、キャッシュを使用して残りの計算を行います。メモリは比較的遅いので、この「ロード」はプログラムを遅くします。
分岐予測と同様に、これはPentiumプロセッサで最適化されています。プロセッサは、データをロードする必要があると予測し、オペレーションが実際にキャッシュにヒットする前にそれをキャッシュにロードしようとします。すでに見たように、分岐予測は時々ひどく間違っています - 最悪の場合、戻って実際にメモリのロードを待つ必要があります。これは永遠にかかります( 言い換えれば、分岐予測の失敗は悪いです、分岐予測が失敗した後のメモリロードは恐ろしいものです! )。
幸いなことに、メモリアクセスパターンが予測可能であれば、プロセッサはそれを高速キャッシュにロードします。
最初に知っておくべきことは、 small とは何ですか?一般的には小さいほうが優れていますが、経験則では4096バイト以下のサイズのルックアップテーブルを使用します。上限として:あなたのルックアップテーブルが64Kより大きいならば、それはおそらく再検討する価値があります。
テーブルを作成する
だから私たちは小さなテーブルを作成できることを考え出しました。次にやるべきことは、適切な場所にルックアップ関数を取得することです。ルックアップ関数は通常、2つの基本的な整数演算(および、xor、shift、add、remove、そしておそらく乗算)を使用する小さな関数です。あなたは自分の入力をルックアップ関数によってあなたのテーブルの中のある種の「ユニークキー」に変換させたいと思うでしょう。
この場合、> = 128は値を保持できることを意味し、<128は値を削除できることを意味します。これを行う最も簡単な方法は、 'AND'を使用することです。それを保持する場合は、7FFFFFFFとANDします。 128を2のべき乗にすることにも注意してください - したがって、先に進んで32768/128の整数のテーブルを作成し、それに1個のゼロと多数の整数で満たすことができます。 7FFFFFFFF.
管理言語
なぜこれがマネージ言語でうまく機能するのか疑問に思うかもしれません。結局、マネージ言語はあなたがめちゃくちゃにならないようにするためにブランチで配列の境界をチェックします...
まあ、正確ではありません... :-)
管理言語のためにこのブランチをなくすことに関してかなりの研究がありました。例えば:
for (int i = 0; i < array.Length; ++i)
{
// Use array[i]
}
この場合、境界条件が決して満たされないことはコンパイラにとって明らかです。少なくともMicrosoft JITコンパイラ(ただし、Javaも同様のことをしていると思います)がこれに気付き、チェックをすべて削除します。うわー、それは分岐がないことを意味します。同様に、それは他の明白なケースを扱います。
管理言語でのルックアップで問題が発生した場合 - キーチェックを予測可能にするためにルックアップ関数に& 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();
配列のソート時にデータが0から255の間に分散されるため、反復の前半あたりでif
-ステートメントは使用されません(if
ステートメントは以下で共有されます)。
if (data[c] >= 128)
sum += data[c];
問題は、ソートされたデータの場合のように、特定の場合に上記のステートメントが実行されない理由です。これが「分岐予測子」です。分岐予測子は、確実に認識される前に分岐(例:if-then-else
構造体)がどの方向に進むかを推測しようとするデジタル回路です。分岐予測器の目的は、命令パイプラインの流れを改善することです。分岐予測子は、高い有効パフォーマンスを達成する上で重要な役割を果たします。
理解を深めるためにベンチマーキングを行いましょう
if
-文のパフォーマンスは、その状態に予測可能なパターンがあるかどうかによって異なります。条件が常に真または常に偽である場合、プロセッサ内の分岐予測ロジックはパターンを選びます。一方、パターンが予測不能な場合は、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
“ bad ”の真偽パターンは、“ good ”パターンよりもif
-文を最大6倍遅くすることができます。もちろん、どのパターンが良く、どれが悪いのかは、コンパイラによって生成された正確な命令と特定のプロセッサに依存します。
したがって、分岐予測がパフォーマンスに与える影響については間違いありません。
分岐予測エラーを回避する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文を使用したバージョンよりもランダムデータのほうが非常に高速です。
しかし、私のテストでは、ルックアップテーブルへのインデックス付けがビットシフトよりわずかに速いため、明示的なルックアップテーブルはこれよりわずかに高速でした。これは私のコードがどのようにルックアップテーブルを設定し使用するかを示しています(コード内では "LookUp Table"に対してlut
が想像力を込めて呼ばれています)。これが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];
これはこのコードへのリンクです: 赤黒の木 、 永遠に混乱しています
ソートされたケースでは、分岐予測の成功や分岐のない比較トリックに頼るよりも、分岐を完全に削除する方が適切です。
実際、配列は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];
並べ替え済みまたは未並べ替えの両方にapproximateソリューションを提供する、さらに高速なアプローチは次のとおりです。sum= 3137536;
(真の均一分布、想定値191.5の16384サンプルを想定) :-)
上記の動作は、分岐予測のために発生しています。
分岐予測を理解するには、最初に命令パイプラインを理解する必要があります。
どの命令も一連のステップに分割されるため、異なるステップを並行して同時に実行できます。この手法は命令パイプラインとして知られており、最新のプロセッサのスループットを向上させるために使用されます。これをよりよく理解するには、こちらをご覧ください Wikipediaの例 。
一般に、最新のプロセッサには非常に長いパイプラインがありますが、簡単にするためにこれらの4つのステップのみを考えてみましょう。
2命令の一般的な4ステージパイプライン
上記の質問に戻って、次の指示を考えてみましょう。
A) if (data[c] >= 128)
/\
/ \
/ \
true / \ false
/ \
/ \
/ \
/ \
B) sum += data[c]; C) for loop or print().
分岐予測がなければ、次のことが起こります。
命令Bまたは命令Cに進むかどうかの決定は命令Aの結果に依存するため、命令Bまたは命令Cを実行するには、パイプラインのEXステージまで命令Aが到達しないまでプロセッサが待機する必要があります。このようになります。
条件がtrueを返す場合:
条件が偽を返す場合:
命令Aの結果を待機した結果、上記の場合(分岐予測なし。trueとfalseの両方)で費やされた合計CPUサイクルは7です。
では、分岐予測とは何ですか?
分岐予測は、分岐(if-then-else構造)がどの方向に進むかを推測します。命令AがパイプラインのEXステージに到達するまで待機しませんが、決定を推測してその命令(この例ではBまたはC)に進みます。
正しい推測の場合、パイプラインは次のようになります:
推測が間違っていたことが後で検出された場合、部分的に実行された命令は破棄され、パイプラインは正しいブランチから開始され、遅延が発生します。分岐予測ミスの場合に浪費される時間は、フェッチステージから実行ステージまでのパイプラインのステージ数に等しくなります。最新のマイクロプロセッサは非常に長いパイプラインを持つ傾向があるため、予測ミスの遅延は10〜20クロックサイクルです。パイプラインが長いほど、良い 分岐予測子 が必要になります。
OPのコードでは、条件付きの最初の場合、分岐予測子には予測のベースとなる情報がないため、初めて次の命令をランダムに選択します。 forループの後半では、履歴に基づいて予測を行うことができます。昇順にソートされた配列の場合、3つの可能性があります。
予測子は、最初の実行時に常に真の分岐を想定すると仮定します。
したがって、最初のケースでは、歴史的にすべての予測が正しいため、常に真のブランチを使用します。 2番目のケースでは、最初は間違って予測しますが、数回の反復の後、正しく予測します。 3番目のケースでは、要素が128未満になるまで最初に正しく予測します。その後、しばらくの間失敗し、履歴で分岐予測の失敗を確認すると、それ自体を修正します。
これらのすべてのケースで、障害の数が少なすぎるため、部分的に実行された命令を破棄して正しいブランチからやり直す必要があるのは数回であり、CPUサイクルが少なくなります。
しかし、ランダムな並べ替えられていない配列の場合、予測は部分的に実行された命令を破棄し、ほとんどの場合正しい分岐からやり直す必要があり、並べ替えられた配列に比べてCPUサイクルが多くなります。
公式の回答は
分岐予測子が混乱するのは、この素敵な diagram からもわかります。
元のコードの各要素はランダムな値です
data[c] = std::Rand() % 256;
だから予測者はstd::Rand()
打撃として側面を変えるでしょう。
一方、いったんソートされると、プレディクタは最初に強くとられていない状態に移行し、値が高い値に変わると3回でプレディクタは強くとられていない状態から強くとられている状態へと変化します。
同じ行に(私はこれは答えで強調されていないと思います)時々(特にLinuxカーネルのようにパフォーマンスが重要なソフトウェアで)、次のようなif文を見つけることができることを言及するのは良いことです:
if (likely( everything_is_ok ))
{
/* Do something */
}
または同様に:
if (unlikely(very_improbable_condition))
{
/* Do something */
}
likely()
とunlikely()
は、実際には、GCCの__builtin_expect
のようなものを使用して定義されるマクロで、コンパイラがユーザーから提供される情報を考慮して条件を優先するための予測コードを挿入するのを助けます。 GCCは、実行中のプログラムの動作を変更したり、キャッシュのクリアなどの低レベルの命令を発行したりする可能性のある他の組み込み関数をサポートします。 このドキュメントを参照 利用可能なGCCの組み込み関数を調べてください。
通常、この種の最適化は、実行時間が重要で、それが重要なハードリアルタイムアプリケーションや組み込みシステムで主に見られます。たとえば、1/100000000回しか発生しないエラー状態をチェックしている場合は、コンパイラにそのことを知らせないでください。このように、デフォルトでは、分岐予測は条件が偽であると見なします。
C++で頻繁に使用されるブール演算は、コンパイル済みプログラムに多数の分岐を生成します。これらの分岐がループの内側にあり予測が難しい場合は、実行が大幅に遅くなる可能性があります。ブール変数は、false
には0
、true
には1
の値を持つ8ビット整数として格納されます。
入力としてブール変数を持つすべての演算子は、入力が0
または1
以外の値を持つかどうかをチェックしますが、Booleansを出力として持つ演算子は、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;
}
このコードは最適とは言えません。予測が誤っていると、分岐に時間がかかることがあります。オペランドに0
と1
以外の値がないことが確実にわかっていれば、ブール演算をはるかに効率的に行うことができます。コンパイラがそのような仮定をしていないのは、変数が初期化されていないか未知のソースから来たものであれば、変数が他の値を持つ可能性があるためです。 a
とb
が有効な値に初期化されている場合、またはそれらがブール出力を生成する演算子から来る場合は、上記のコードを最適化できます。最適化されたコードは次のようになります。
char a = 0, b = 1, c, d;
c = a & b;
d = a | b;
ブール演算子(&
および|
)の代わりにビット演算子(&&
および||
)を使用できるようにするために、char
の代わりにbool
が使用されています。ビット演算子は、1クロックサイクルしかかからない単一の命令です。 a
とb
が|
または0
以外の値を持っていても、OR演算子(1
)は機能します。オペランドの値が&
および^
以外の場合、AND演算子(0
)とEXCLUSIVE OR演算子(1
)の結果が矛盾する可能性があります。
~
はNOTには使用できません。代わりに、0
とのXORをとることで、1
または1
であることがわかっている変数にブールNOTを設定できます。
bool a, b;
b = !a;
に最適化することができます。
char a = 0, b;
b = a ^ 1;
b
がa
である場合にfalse
が評価されない式である場合、a && b
をa & b
に置き換えることはできません(&&
はb
を評価しない、&
は評価します)。同様に、b
がa
である場合にtrue
が評価すべきでない式である場合、a || b
をa | b
に置き換えることはできません。
オペランドが変数の場合は、オペランドが比較の場合よりもビット演算子を使用するほうが有利です。
bool a; double x, y, z;
a = x > y && z < 5.0;
ほとんどの場合に最適です(&&
式が分岐の誤予測を多く発生させると予想しない限り)。
それは確かだ!...
分岐予測 あなたのコードで起こる切り替えのために、ロジックの実行を遅くします!まっすぐな道や曲がりくねった道がたくさんあるようです。まっすぐな道はより早く完成するでしょう!...
配列がソートされている場合、最初のステップdata[c] >= 128
では条件は偽になり、その後、通りの終わりまでずっと真の値になります。それが、ロジックの最後まで早く到達する方法です。一方、ソートされていない配列を使用すると、コードの実行速度を確実に遅くするために、大量のターニングと処理が必要になります。
下記の私があなたのために作成した画像を見てください。どっちの道が早く終わるのでしょうか。
プログラム的には、 分岐予測 の方が処理が遅くなります。
また最後に、2種類の分岐予測があり、それぞれがコードに異なる影響を与えることを知っておくと便利です。
1.静的
2.動的
静的分岐予測は、条件付き分岐に初めて遭遇したときにマイクロプロセッサによって使用され、動的分岐予測は、条件付き分岐コードの後続の実行のために使用される。
if-else または switch ステートメントを書くとき、これらの規則を利用するようにコードを効果的に書くために、最初に最も一般的なケースをチェックし、最も一般的でないケースに段階的に取り組みます。ループイテレータの条件のみが通常使用されるため、ループは必ずしも静的分岐予測のための特別なコードの順序付けを必要としません。
この質問はすでに何度もよく答えられています。それでも、私はそのグループの注目をさらに別の興味深い分析に向けたいと思います。
最近、この例(ごくわずかに変更されたもの)も、Windows上のプログラム内でコードの一部をどのようにプロファイルできるかを示すための方法として使用されました。その過程で、著者は、ソートされた場合とソートされていない場合の両方において、コードがその時間の大部分を費やしている場所を判断するために結果を使用する方法も示します。最後に、この記事では、HAL(Hardware Abstraction Layer)のちょっとした既知の機能を使用して、ソートされていない場合に発生するブランチの予測ミスの量を判断する方法も示しています。
リンクはこちら: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm
他の人がすでに述べたように、この謎の背後にあるのは Branch Predictor です。
私は何かを追加しようとしているのではなく、概念を別の方法で説明しています。テキストと図を含むwikiについての簡潔な紹介があります。私は直観的に分岐予測子を詳しく説明するために図を使用する以下の説明が好きです。
コンピュータアーキテクチャにおいて、分岐予測子は、確実に知られる前に分岐(例えば、if-then-else構造)がどの方向に進むかを推測しようと試みるデジタル回路である。分岐予測器の目的は、命令パイプラインの流れを改善することです。分岐予測子は、x86などの最新のパイプライン式マイクロプロセッサアーキテクチャの多くで高い実効性能を達成する上で重要な役割を果たします。
双方向分岐は通常、条件付きジャンプ命令を使用して実装されます。条件付きジャンプは、条件付きジャンプの直後に続くコードの最初の分岐で「実行されず」、実行を継続することも、コードメモリの2番目の分岐があるプログラムメモリの別の場所にジャンプすることもできます保存しました。条件が計算され、条件付きジャンプが命令パイプラインの実行ステージを通過するまで、条件付きジャンプが実行されるかどうかは、確かにわかっていません(図1を参照)。
説明したシナリオに基づいて、さまざまな状況でパイプラインで命令がどのように実行されるかを示すアニメーションデモを作成しました。
分岐予測がなければ、プロセッサは、次の命令がパイプライン内のフェッチステージに入ることができる前に、条件付きジャンプ命令が実行ステージを通過するまで待たなければならないであろう。
例には3つの命令が含まれており、最初の命令は条件付きジャンプ命令です。後者の2つの命令は、条件付きジャンプ命令が実行されるまでパイプラインに入ることができます。
3命令が完了するのに9クロックサイクルかかります。
3命令が完了するのに7クロックサイクルかかります。
3命令が完了するのに9クロックサイクルかかります。
分岐予測ミスの場合に浪費される時間は、フェッチステージから実行ステージまでのパイプライン内のステージ数に等しい。現代のマイクロプロセッサは、予測ミスの遅延が10から20クロックサイクルの間になるようにかなり長いパイプラインを持つ傾向があります。その結果、パイプラインを長くすると、より高度な分岐予測子の必要性が高まります。
お分かりのように、Branch Predictorを使用しない理由はありません。
Branch Predictorの非常に基本的な部分を明確にした非常に単純なデモです。これらのGIFが迷惑な場合は、回答から自由に削除してください。訪問者は git からデモを入手することもできます。
分岐予測ゲイン!
ブランチの誤予測はプログラムを遅くしないことを理解することは重要です。予測を見逃した場合のコストは、分岐予測が存在しない場合と同じで、実行するコードを決定するための式の評価を待っていました(次の段落で詳しく説明)。
if (expression)
{
// Run 1
} else {
// Run 2
}
if-else
\switch
ステートメントがあるときはいつでも、どのブロックを実行するべきかを決定するために式を評価しなければなりません。コンパイラによって生成されたアセンブリコードには、条件付き 分岐 命令が挿入されます。
分岐命令は、コンピュータに異なる命令シーケンスの実行を開始させることができます。したがって、条件によっては、命令を順番に実行するというデフォルトの動作(式が偽の場合、プログラムはif
ブロックのコードをスキップします)から逸脱します。我々の場合の式評価です。
とはいえ、コンパイラは実際に評価される前に結果を予測しようとします。それはif
ブロックから命令をフェッチします、そしてもし式が真であることが判明すれば、そして素晴らしい!私たちはそれを評価するのにかかる時間を増やし、コードを進歩させました。そうでなければ、間違ったコードを実行していることになり、パイプラインはフラッシュされ、正しいブロックが実行されます。
あなたがルート1かルート2を選ぶ必要があるとしましょう。あなたのパートナーが地図をチェックするのを待っている、あなたは##で停止して待った、またはあなたが運びました。パートナーが地図をチェックするのを待つ必要はありませんでしたが(それ以外の場合は元に戻すだけです)。
パイプラインをフラッシュするのは超高速ですが、今日このギャンブルを取ることは価値があります。ソートされたデータやゆっくりと変化するデータを予測することは、速い変化を予測するよりも常に簡単で優れています。
O Route 1 /-------------------------------
/|\ /
| ---------##/
/ \ \
\
Route 2 \--------------------------------
それは分岐予測についてです。それは何ですか?
分岐予測子は、現代の建築への関連性を依然として見いだしている古代のパフォーマンス改善技法の1つです。単純な予測技術は速いルックアップおよび電力効率を提供するが、それらは高い誤予測率に悩まされている。
一方、ニューラルベースまたは2レベルの分岐予測の変形のいずれかである複雑な分岐予測は、予測精度を向上させますが、消費電力が増え、複雑さが指数関数的に増加します。
これに加えて、複雑な予測手法では、分岐を予測するのにかかる時間はそれ自体非常に長く、2〜5サイクルの範囲です。これは実際の分岐の実行時間に匹敵します。
分岐予測は本質的に最適化(最小化)問題であり、最小のリソースで可能な限り低いミス率、低消費電力、および低複雑性を達成することが強調されています。
実際には3種類の分岐があります。
前方条件付き分岐 - 実行時条件に基づいて、PC(プログラムカウンタ)は命令ストリーム内の前方アドレスを指すように変更されます。
後方条件付き分岐 - 命令ストリーム内で後方に向くようにPCが変更されます。分岐の条件は、ループの最後のテストでループを再度実行する必要があると判断された場合に、プログラムループの先頭に向かって逆方向に分岐するなど、何らかの条件に基づきます。
無条件分岐 - これには、ジャンプ、プロシージャコール、および特定の条件を持たないリターンが含まれます。例えば、無条件ジャンプ命令はアセンブリ言語では単に "jmp"としてコーディングされ、命令ストリームはジャンプ命令によって指されるターゲット位置に直ちに向けられなければならないのに対して、 "jmpne"としてコーディングされることがある。前の "compare"命令の2つの値を比較した結果、値が等しくないことが示された場合にのみ、命令ストリームをリダイレクトします。 (x86アーキテクチャで使用されているセグメント化されたアドレッシングスキームは、ジャンプが "near"(セグメント内)または "far"(セグメント外)のどちらかになる可能性があるため、さらに複雑になります。
静的/動的分岐予測 :静的分岐予測は、条件付き分岐に初めて遭遇したときにマイクロプロセッサによって使用され、動的分岐予測は、条件付き分岐コードの後続の実行に使用されます。
参考文献:
分岐予測が遅くなるという事実に加えて、ソートされた配列には別の利点があります。
値をチェックするだけでなく、停止条件を設定することもできます。この方法では、関連データのみをループし、残りを無視します。
分岐予測は一度だけ失敗する可能性があります。
// 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];
}
ARMでは、すべての命令に4ビットの条件フィールドがあり、ゼロコストでテストされるため、分岐は不要です。これにより、短い分岐が不要になり、分岐予測のヒットがなくなります。したがって、ソートのオーバーヘッドが大きいため、ソートされたバージョンは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
ソートされた配列は、分岐予測と呼ばれる現象により、ソートされていない配列よりも高速に処理されます。
分岐予測は、分岐がどの方向に進むかを予測しようとするデジタル回路(コンピュータアーキテクチャ)で、命令パイプラインの流れを改善します。回路/コンピュータは次のステップを予測しそれを実行する。
間違った予測をすると、前のステップに戻って別の予測を実行することになります。予測が正しいと仮定すると、コードは次のステップに進みます。間違った予測は、正しい予測が行われるまで、同じステップを繰り返すことになります。
あなたの質問に対する答えはとても簡単です。
並べ替えられていない配列では、コンピュータは複数の予測を行い、エラーの可能性が高まります。一方、ソートでは、コンピューターは予測を少なくしてエラーの可能性を減らします。より多くの予測をすることはより多くの時間を必要とします。
ソート済み配列:直線道路
____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT
未分類の配列:曲線道路
______ ________
| |__|
分岐予測:どの道路が直線であるかを推測/予測し、確認せずに進む
___________________________________________ Straight road
|_________________________________________|Longer road
両方の道路が同じ目的地に到達しているにもかかわらず、まっすぐな道はより短く、他方はより長いです。それからあなたが間違って他を選ぶならば、引き返すことがないので、あなたがより長い道を選ぶならあなたはいくらかの余分な時間を無駄にするでしょう。これはコンピュータで起こることに似ています、そして、私はこれがあなたがよりよく理解するのを助けたことを望みます。
また、コメントから @Simon_Weaver を引用したいと思います。
予測が少なくなるわけではありません。誤った予測が少なくなります。それはまだループを通して毎回予測する必要があります。
データをソートする必要があるという他の回答による仮定は正しくありません。
次のコードは、配列全体をソートするのではなく、その200要素のセグメントのみをソートするので、最も速く実行されます。
K要素セクションのみをソートすると、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;
}
これは、ソート順などのアルゴリズムの問題とは無関係であることも「証明」しており、実際には分岐予測です。
ソートされているから!
順序付けされていないデータよりも順序付けされたデータを取得して操作するのは簡単です。
私が店から(注文された)そして私のワードローブ(めちゃめちゃ)からアパレルを選ぶのと同じように。