私はpopcount
の大きなデータ配列への最速の方法を探していました。私はとても奇妙な効果に遭遇しました:ループ変数をunsigned
からuint64_t
に変更すると私のPCのパフォーマンスは50%低下しました。
#include <iostream>
#include <chrono>
#include <x86intrin.h>
int main(int argc, char* argv[]) {
using namespace std;
if (argc != 2) {
cerr << "usage: array_size in MB" << endl;
return -1;
}
uint64_t size = atol(argv[1])<<20;
uint64_t* buffer = new uint64_t[size/8];
char* charbuffer = reinterpret_cast<char*>(buffer);
for (unsigned i=0; i<size; ++i)
charbuffer[i] = Rand()%256;
uint64_t count,duration;
chrono::time_point<chrono::system_clock> startP,endP;
{
startP = chrono::system_clock::now();
count = 0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with unsigned
for (unsigned i=0; i<size/8; i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
{
startP = chrono::system_clock::now();
count=0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with uint64_t
for (uint64_t i=0;i<size/8;i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "uint64_t\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
free(charbuffer);
}
ご覧のとおり、サイズがx
メガバイトのランダムデータのバッファを作成します。ここで、x
はコマンドラインから読み込まれます。その後、バッファを繰り返し処理し、ポップカウントを実行するために展開されたバージョンのx86 popcount
組み込み関数を使用します。より正確な結果を得るために、ポップカウントを10,000回行います。ポップカウントの時間を測定します。大文字の場合、内部ループ変数はunsigned
、小文字の場合、内部ループ変数はuint64_t
です。私はこれで違いはないと思いましたが、その逆が当てはまります。
私はこれを次のようにコンパイルします(g ++バージョン:Ubuntu 4.8.2-19ubuntu1)。
g++ -O3 -march=native -std=c++11 test.cpp -o test
これは私の HaswellCore i7-4770K CPU @ 3.50 GHzでtest 1
を実行した結果です(1 MBのランダムデータ)。
ご覧のとおり、uint64_t
バージョンのスループットは、 半分だけ unsigned
バージョンのものです。問題は、異なる議会が生成されることですが、なぜでしょうか。まず、私はコンパイラのバグについて考えたので、私はclang++
を試しました(Ubuntu Clang version 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
結果:test 1
だから、それはほとんど同じ結果であり、まだ奇妙です。 しかし、今ではそれは非常に奇妙になります。私は入力から読み込まれたバッファサイズを定数1
に置き換えます、そこで私は変更します:
uint64_t size = atol(argv[1]) << 20;
に
uint64_t size = 1 << 20;
したがって、コンパイラはコンパイル時にバッファサイズを認識します。多分それはいくつかの最適化を追加することができます!これがg++
の番号です。
今、両方のバージョンは同じくらい速いです。しかし、unsigned
はさらに遅くなりました !それは26
から20 GB/s
に落としました、したがって、定数でない定数を置き換えることは 非最適化 につながります。真剣に、私はここで何が起こっているのかわかりません!しかし、新しいバージョンでclang++
へ。
ちょっと待って、どうしたんださて、両方のバージョンが slow の15GB /秒に落ちた。したがって、定数でない定数を置き換えると、 both の場合、Clang!のコードが遅くなります。
私はベンチマークをまとめるために Ivy Bridge CPUを持つ同僚に依頼しました。彼は同様の結果を得たので、それはハズウェルではないようです。 2つのコンパイラがここで奇妙な結果を生み出すので、それもコンパイラのバグではないようです。ここにはAMDのCPUがないので、Intelでしかテストできませんでした。
最初の例(atol(argv[1])
を持つもの)を取り、変数の前にstatic
を置きます。
static uint64_t size=atol(argv[1])<<20;
これがg ++での結果です。
Yay、さらに別の選択肢。 u32
の高速26GB/sはまだありますが、少なくとも13GB/sから20GB/sのバージョンまでu64
を取得することができました。 私の同僚のPCでは、u64
バージョンはu32
バージョンよりもさらに速くなり、すべての中で最も速い結果が得られました。 悲しいことに、これはg++
に対してのみ機能します、clang++
はstatic
を気にしないようです。
これらの結果を説明できますか?特に:
u32
とu64
の間にそのような違いがあるのはどうしてですか?static
キーワードを挿入すると、どのようにしてu64
ループが速くなるのでしょうか。私の同僚のコンピュータの元のコードよりさらに速いです!最適化は扱いにくい領域であることはわかっていますが、このような小さな変更で実行時間が 100%の差 になることや、一定のバッファサイズのような小さな要素で結果が完全に混同されることはありません。もちろん、私はいつも26 GB /秒でポップカウントできるバージョンを持っていたいです。私が考えることができる唯一の信頼できる方法はこの場合のためにアセンブリをコピーペーストしてインラインアセンブリを使うことです。これは私が小さな変更で気が狂うように見えるコンパイラを取り除くことができる唯一の方法です。どう思いますか?ほとんどのパフォーマンスで確実にコードを取得する方法はありますか?
これがさまざまな結果の逆アセンブリです。
26 GB /秒のバージョンから g ++/u32/non-const bufsize :
0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8
g ++/u64/non-const bufsizeからの13 GB/sバージョン :
0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00
clang ++/u64/non-const bufsizeからの15 GB/sバージョン :
0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50
g ++/u32およびu64/const bufsizeから20 GB/sのバージョン :
0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68
clang ++/u32&u64/const bufsizeからの15 GB /秒バージョン :
0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0
興味深いことに、最速(26 GB/s)のバージョンも最長です。 lea
を使用する唯一の解決策のようです。ジャンプするのにjb
を使うバージョンもあれば、jne
を使うバージョンもあります。しかし、それとは別に、すべてのバージョンは同等のようです。 100%のパフォーマンス格差がどこから生じるのか私にはわかりませんが、私は議会を解読することに熟練しすぎていません。最も遅い(13 GB /秒)バージョンでさえ、とても短くて良いように見えます。誰もがこれを説明できますか?
この質問に対する答えがどうであろうと関係ありません。本当にホットなループでは、every detailが重要になる可能性があることを学びました、hot codeに関連がないような詳細でさえ。ループ変数にどのような型を使用するかについては一度も考えたことがありませんが、このような小さな変更によって100%の違いが生じる可能性があります。 size変数の前にstatic
キーワードを挿入したときに見たように、バッファの格納タイプでさえも、大きな違いがあります。将来的には、システムパフォーマンスにとって非常に重要な、厳密でホットなループを作成するときに、さまざまなコンパイラでさまざまな代替手段を常にテストする予定です。
興味深いことに、私はすでに4回ループを展開したにもかかわらず、パフォーマンスの違いがまだ非常に大きいことです。ですから、あなたがアンロールしても、あなたはまだ大きなパフォーマンスの逸脱に見舞われる可能性があります。とてもおもしろい。
犯人:False Data Dependency (そしてコンパイラはそれを意識していません)
Sandy/Ivy BridgeおよびHaswellプロセッサでは、次のようになります。
popcnt src, dest
デスティネーションレジスタdest
に誤った依存関係があるようです。命令がそれに書き込むだけであっても、命令は実行の前にdest
の準備ができるまで待ちます。
この依存関係は、単一のループ反復からの4つのpopcnt
を遅らせるだけではありません。それはループ反復をまたいで実行することができ、プロセッサが異なるループ反復を並列化することを不可能にする。
unsigned
とuint64_t
やその他の調整が直接問題に影響することはありません。しかし、それらはレジスタを変数に割り当てるレジスタアロケータに影響を与えます。
あなたの場合、速度は、レジスタアロケータがやろうと決めたことに応じて、(誤った)依存関係チェーンに固執することの直接的な結果です。
popcnt
-add
-popcnt
-popcnt
→次の繰り返しpopcnt
-add
-popcnt
-add
→次の繰り返しpopcnt
-popcnt
→次の繰り返しpopcnt
-popcnt
→次の繰り返し20 GB /秒と26 GB /秒の違いは、間接アドレス指定のマイナーアーティファクトのようです。どちらにしても、この速度に達すると、プロセッサは他のボトルネックにぶつかり始めます。
これをテストするために、インラインアセンブリを使用してコンパイラを迂回し、正確に必要なアセンブリを取得しました。私はまたベンチマークを台無しにするかもしれない他のすべての依存関係を打破するためにcount
変数を分割しました。
結果は次のとおりです。
Sandy Bridge Xeon @ 3.5 GHz: (下部に完全なテストコードがあります)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
異なるレジスタ: 18.6195 GB /秒
.L4:
movq (%rbx,%rax,8), %r8
movq 8(%rbx,%rax,8), %r9
movq 16(%rbx,%rax,8), %r10
movq 24(%rbx,%rax,8), %r11
addq $4, %rax
popcnt %r8, %r8
add %r8, %rdx
popcnt %r9, %r9
add %r9, %rcx
popcnt %r10, %r10
add %r10, %rdi
popcnt %r11, %r11
add %r11, %rsi
cmpq $131072, %rax
jne .L4
同じレジスタ: 8.49272 GB/s
.L9:
movq (%rbx,%rdx,8), %r9
movq 8(%rbx,%rdx,8), %r10
movq 16(%rbx,%rdx,8), %r11
movq 24(%rbx,%rdx,8), %rbp
addq $4, %rdx
# This time reuse "rax" for all the popcnts.
popcnt %r9, %rax
add %rax, %rcx
popcnt %r10, %rax
add %rax, %rsi
popcnt %r11, %rax
add %rax, %r8
popcnt %rbp, %rax
add %rax, %rdi
cmpq $131072, %rdx
jne .L9
壊れたチェーンを持つ同じレジスタ: 17.8869 GB/s
.L14:
movq (%rbx,%rdx,8), %r9
movq 8(%rbx,%rdx,8), %r10
movq 16(%rbx,%rdx,8), %r11
movq 24(%rbx,%rdx,8), %rbp
addq $4, %rdx
# Reuse "rax" for all the popcnts.
xor %rax, %rax # Break the cross-iteration dependency by zeroing "rax".
popcnt %r9, %rax
add %rax, %rcx
popcnt %r10, %rax
add %rax, %rsi
popcnt %r11, %rax
add %rax, %r8
popcnt %rbp, %rax
add %rax, %rdi
cmpq $131072, %rdx
jne .L14
それでは、コンパイラの何が問題になったのでしょうか。
GCCもVisual Studioも、popcnt
がそのような誤った依存関係を持っていることを認識していないようです。それにもかかわらず、これらの誤った依存関係は珍しくありません。それはコンパイラがそれを知っているかどうかの問題です。
popcnt
は、まさに最もよく使われる命令ではありません。ですから、大手コンパイラがこのようなことを見逃す可能性があることは、それほど驚くことではありません。この問題について言及している文書はどこにもないようです。 Intelがそれを明らかにしていなければ、誰かが偶然にそれに遭遇するまで、外部に誰も知らないでしょう。
( 更新: バージョン4.9.2 の時点で、GCCはこの誤った依存関係を認識し、最適化が有効になったときにそれを補償するコードを生成します。Clang、MSVC、そしてIntel自身のICCでさえ、このマイクロアーキテクチャーの誤りをまだ認識しておらず、それを補償するコードを発行しないでしょう。)
なぜCPUはそのような誤った依存関係を持っているのですか?
推測することしかできませんが、多くの2オペランド命令でIntelが同じ処理をしている可能性があります。 add
、sub
のような一般的な命令は2つのオペランドを取り、両方とも入力です。そのため、Intelはおそらくプロセッサ設計を単純にするためにpopcnt
を同じカテゴリに入れました。
AMDプロセッサは、この誤った依存関係を持っているようには見えません。
完全なテストコードは参考のために以下に示します。
#include <iostream>
#include <chrono>
#include <x86intrin.h>
int main(int argc, char* argv[]) {
using namespace std;
uint64_t size=1<<20;
uint64_t* buffer = new uint64_t[size/8];
char* charbuffer=reinterpret_cast<char*>(buffer);
for (unsigned i=0;i<size;++i) charbuffer[i]=Rand()%256;
uint64_t count,duration;
chrono::time_point<chrono::system_clock> startP,endP;
{
uint64_t c0 = 0;
uint64_t c1 = 0;
uint64_t c2 = 0;
uint64_t c3 = 0;
startP = chrono::system_clock::now();
for( unsigned k = 0; k < 10000; k++){
for (uint64_t i=0;i<size/8;i+=4) {
uint64_t r0 = buffer[i + 0];
uint64_t r1 = buffer[i + 1];
uint64_t r2 = buffer[i + 2];
uint64_t r3 = buffer[i + 3];
__asm__(
"popcnt %4, %4 \n\t"
"add %4, %0 \n\t"
"popcnt %5, %5 \n\t"
"add %5, %1 \n\t"
"popcnt %6, %6 \n\t"
"add %6, %2 \n\t"
"popcnt %7, %7 \n\t"
"add %7, %3 \n\t"
: "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
: "r" (r0), "r" (r1), "r" (r2), "r" (r3)
);
}
}
count = c0 + c1 + c2 + c3;
endP = chrono::system_clock::now();
duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
{
uint64_t c0 = 0;
uint64_t c1 = 0;
uint64_t c2 = 0;
uint64_t c3 = 0;
startP = chrono::system_clock::now();
for( unsigned k = 0; k < 10000; k++){
for (uint64_t i=0;i<size/8;i+=4) {
uint64_t r0 = buffer[i + 0];
uint64_t r1 = buffer[i + 1];
uint64_t r2 = buffer[i + 2];
uint64_t r3 = buffer[i + 3];
__asm__(
"popcnt %4, %%rax \n\t"
"add %%rax, %0 \n\t"
"popcnt %5, %%rax \n\t"
"add %%rax, %1 \n\t"
"popcnt %6, %%rax \n\t"
"add %%rax, %2 \n\t"
"popcnt %7, %%rax \n\t"
"add %%rax, %3 \n\t"
: "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
: "r" (r0), "r" (r1), "r" (r2), "r" (r3)
: "rax"
);
}
}
count = c0 + c1 + c2 + c3;
endP = chrono::system_clock::now();
duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "Chain 4 \t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
{
uint64_t c0 = 0;
uint64_t c1 = 0;
uint64_t c2 = 0;
uint64_t c3 = 0;
startP = chrono::system_clock::now();
for( unsigned k = 0; k < 10000; k++){
for (uint64_t i=0;i<size/8;i+=4) {
uint64_t r0 = buffer[i + 0];
uint64_t r1 = buffer[i + 1];
uint64_t r2 = buffer[i + 2];
uint64_t r3 = buffer[i + 3];
__asm__(
"xor %%rax, %%rax \n\t" // <--- Break the chain.
"popcnt %4, %%rax \n\t"
"add %%rax, %0 \n\t"
"popcnt %5, %%rax \n\t"
"add %%rax, %1 \n\t"
"popcnt %6, %%rax \n\t"
"add %%rax, %2 \n\t"
"popcnt %7, %%rax \n\t"
"add %%rax, %3 \n\t"
: "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
: "r" (r0), "r" (r1), "r" (r2), "r" (r3)
: "rax"
);
}
}
count = c0 + c1 + c2 + c3;
endP = chrono::system_clock::now();
duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "Broken Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
free(charbuffer);
}
同様に興味深いベンチマークはここで見つけることができます: http://Pastebin.com/kbzgL8si
このベンチマークは、(false)依存関係チェーンに含まれるpopcnt
の数を変えます。
False Chain 0: 41959360000 0.57748 sec 18.1578 GB/s
False Chain 1: 41959360000 0.585398 sec 17.9122 GB/s
False Chain 2: 41959360000 0.645483 sec 16.2448 GB/s
False Chain 3: 41959360000 0.929718 sec 11.2784 GB/s
False Chain 4: 41959360000 1.23572 sec 8.48557 GB/s
実験用に同等のCプログラムをコーディングしたところ、この奇妙な振る舞いを確認できます。さらに、gcc
は、64ビット整数(とにかくおそらくsize_t
であるべきです...)が優れていると考えています。uint_fast32_t
を使用すると、gccが64ビットuintを使用するようになるからです。
私は、議会についてちょっと雑用しました:
単純に32ビットバージョンを使用し、プログラムの内側のポップカウントループですべての32ビット命令/レジスタを64ビットバージョンに置き換えます。所見:コードは 32ビット版と同じくらい速いです!
プログラムの他の部分ではまだ32ビットバージョンが使用されているが、内側のポップカウントループがパフォーマンスを支配している限り、変数のサイズは実際には64ビットではないので、これは明らかにハックです。これは良いスタートです。
次に、32ビットバージョンのプログラムから内部ループコードをコピーし、64ビットになるようにハッキングし、64ビットバージョンの内部ループの代わりになるようにレジスタをいじっています。 このコードも32ビットバージョンと同じくらい高速に実行されます。
私の結論は、これはコンパイラによる悪い命令スケジューリングであり、32ビット命令の実際の速度/レイテンシの利点ではないということです。
(警告:私は議会をハックしました。気づかずに何かを壊すことができたのです。私はそうは思いません。)
これは答えではありませんが、結果にコメントを入れると読みにくくなります。
これらの結果は Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz)で得られます。私はそれをclang -O3 -msse4 -lstdc++ a.cpp -o a
でコンパイルしました(-O2は同じ結果を得ます)。
uint64_t size=atol(argv[1])<<20;
で張るunsigned 41950110000 0.811198 sec 12.9263 GB/s
uint64_t 41950110000 0.622884 sec 16.8342 GB/s
uint64_t size=1<<20;
でくつろぐunsigned 41950110000 0.623406 sec 16.8201 GB/s
uint64_t 41950110000 0.623685 sec 16.8126 GB/s
私も試してみました:
for
ステートメントを逆にします:for (uint64_t i=size/8;i>0;i-=4)
。これにより同じ結果が得られ、コンパイルが(予想どおり)反復ごとにサイズを8で除算しないほど十分に賢いことが証明されます。これは私の野生の推測です:
速度係数は3つの部分に分けられます。
コードキャッシュ:uint64_t
バージョンはコードサイズが大きくなりますが、これは私のXeon CPUには影響しません。これは64ビット版を遅くします。
使用した指示2つのバージョンでは、ループ数だけでなく、バッファに32ビットと64ビットのインデックスでアクセスします。 64ビットオフセットでポインタにアクセスすると、専用の64ビットレジスタとアドレス指定が要求されますが、32ビットオフセットにはイミディエートを使用できます。これは32ビット版をより速くするかもしれません。
命令は64ビットコンパイル(つまり、プリフェッチ)に対してのみ発行されます。これは64ビット高速になります。
3つの要因は共に、観察された一見矛盾する結果と一致する。
私はこれを Visual Studio 2013 Express で試しましたが、インデックスの代わりにポインタを使っていました。これは、アドレッシングがoffset + register +(register << 3)ではなく、offset + registerであるためと思われます。 C++コード.
uint64_t* bfrend = buffer+(size/8);
uint64_t* bfrptr;
// ...
{
startP = chrono::system_clock::now();
count = 0;
for (unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with uint64_t
for (bfrptr = buffer; bfrptr < bfrend;){
count += __popcnt64(*bfrptr++);
count += __popcnt64(*bfrptr++);
count += __popcnt64(*bfrptr++);
count += __popcnt64(*bfrptr++);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "uint64_t\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
アセンブリコード:r10 = bfrptr、r15 = bfrend、rsi = count、rdi =バッファ、r13 = k。
$LL5@main:
mov r10, rdi
cmp rdi, r15
jae SHORT $LN4@main
npad 4
$LL2@main:
mov rax, QWORD PTR [r10+24]
mov rcx, QWORD PTR [r10+16]
mov r8, QWORD PTR [r10+8]
mov r9, QWORD PTR [r10]
popcnt rdx, rax
popcnt rax, rcx
add rdx, rax
popcnt rax, r8
add r10, 32
add rdx, rax
popcnt rax, r9
add rsi, rax
add rsi, rdx
cmp r10, r15
jb SHORT $LL2@main
$LN4@main:
dec r13
jne SHORT $LL5@main
正式な回答はできませんが、考えられる原因の概要を説明してください。 このリファレンス は、ループ本体の命令のレイテンシとスループットの比率が3:1であることを明確に示しています。複数のディスパッチの効果も示しています。最近のx86プロセッサには3つの整数ユニット(give-or-take)があるので、一般に1サイクルに3つの命令をディスパッチすることが可能です。
そのため、ピークパイプラインと複数のディスパッチパフォーマンスとこれらのメカニズムの失敗との間に、パフォーマンスには6倍の要因があります。 x86命令セットの複雑さが風変わりな破損を起こすのを非常に簡単にすることはかなりよく知られています。上記の文書は素晴らしい例です。
64ビット右シフトに対するPentium 4のパフォーマンスは、本当に悪いです。すべての32ビットシフトと同様に64ビット左シフトも許容できる性能を持っています。 ALUの上位32ビットから下位32ビットへのデータパスは適切に設計されていないようです。
私は個人的には、ホットループが4コアチップの特定のコアでかなり遅くなるという奇妙なケースに出くわしました(私が思い出すならAMD)。そのコアをオフにすることで、実際にmap-reduce計算のパフォーマンスが向上しました。
ここで私の推測は整数単位のための競合です:popcnt
、ループカウンタ、およびアドレス計算はすべて32ビット幅のカウンタでフルスピードで実行することはほとんどできませんが、64ビットカウンタは競合とパイプラインストールを引き起こします。ループ本体の実行あたり、合計で約12サイクル、潜在的には複数のディスパッチで4サイクルしかないため、1回のストールが実行時間に2倍の影響を与える可能性があります。
静的変数を使用することによって引き起こされた変更は、命令のマイナーな並べ替えを引き起こしているに過ぎないと推測されますが、32ビットコードが競合の転機を迎えているという別の手がかりになります。
これは厳密な分析ではないことを私は知っていますが、それは は もっともらしい説明です。
-funroll-loops -fprefetch-loop-arrays
をGCCに渡してみましたか?
これらの追加の最適化により、次のような結果が得られます。
[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11 test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays
[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned 41959360000 0.595 sec 17.6231 GB/s
uint64_t 41959360000 0.898626 sec 11.6687 GB/s
[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned 41959360000 0.618222 sec 16.9612 GB/s
uint64_t 41959360000 0.407304 sec 25.7443 GB/s
縮小ステップをループの外側に移動してみましたか?今、あなたは本当に必要とされていないデータ依存関係を持っています。
試してください:
uint64_t subset_counts[4] = {};
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with unsigned
unsigned i=0;
while (i < size/8) {
subset_counts[0] += _mm_popcnt_u64(buffer[i]);
subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
i += 4;
}
}
count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];
私は厳密なエイリアシング規則に準拠しているかどうかわからない、あなたはまた、いくつかの奇妙なエイリアシングが起こっている。
TL; DR:代わりに__builtin
組み込み関数を使用してください。
私はgcc
4.8.4(そしてgcc.godbolt.orgでは4.7.3)でも同じアセンブリ命令を使用する__builtin_popcountll
を使用することでこれに最適なコードを生成することができましたが、その誤った依存関係のバグはありません。
私はベンチマークコードを100%確信できませんが、objdump
の出力は私の見解を共有しているようです。私は他のトリック(++i
とi++
)を使用して、movl
命令なしでコンパイラーにループをアンロールさせます(奇妙な振る舞い、私が言わなければなりません)。
結果:
Count: 20318230000 Elapsed: 0.411156 seconds Speed: 25.503118 GB/s
ベンチマークコード:
#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
uint64_t cnt = 0;
for(size_t i = 0; i < len; ++i){
cnt += __builtin_popcountll(buf[i]);
}
return cnt;
}
int main(int argc, char** argv){
if(argc != 2){
printf("Usage: %s <buffer size in MB>\n", argv[0]);
return -1;
}
uint64_t size = atol(argv[1]) << 20;
uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));
// Spoil copy-on-write memory allocation on *nix
for (size_t i = 0; i < (size / 8); i++) {
buffer[i] = random();
}
uint64_t count = 0;
clock_t tic = clock();
for(size_t i = 0; i < 10000; ++i){
count += builtin_popcnt(buffer, size/8);
}
clock_t toc = clock();
printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
return 0;
}
コンパイルオプション:
gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench
GCCのバージョン:
gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4
Linuxカーネルのバージョン
3.19.0-58-generic
CPU情報
processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 70
model name : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping : 1
microcode : 0xf
cpu MHz : 2494.226
cache size : 6144 KB
physical id : 0
siblings : 1
core id : 0
cpu cores : 1
apicid : 0
initial apicid : 0
fpu : yes
fpu_exception : yes
cpuid level : 13
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs :
bogomips : 4988.45
clflush size : 64
cache_alignment : 64
address sizes : 36 bits physical, 48 bits virtual
power management: