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
組み込み関数を使用してポップカウントを実行します。より正確な結果を得るために、popcountを10,000回実行しています。ポップカウントの時間を測定します。大文字の場合、内部ループ変数はunsigned
であり、小文字の場合、内部ループ変数はuint64_t
です。これで違いはないと思いましたが、その逆です。
(絶対にクレイジーな)結果
私はそれを次のようにコンパイルします(g ++バージョン:Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
これは、実行中のHaswell Core i7-4770K CPU @ 3.50 GHzでの結果ですtest 1
(1 MBのランダムデータ)。
- 署名なし41959360000 0.401554秒 26.113 GB / s
- uint64_t 41959360000 0.759822秒 13.8003 GB /秒
ご覧のとおり、uint64_t
バージョンのスループットはバージョンのスループットの半分にすぎませunsigned
ん。問題は異なるアセンブリが生成されることのようですが、なぜですか?まず、コンパイラのバグを考えたので、試しましたclang++
(Ubuntu Clangバージョン3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
結果: test 1
- 署名なし41959360000 0.398293秒 26.3267 GB / s
- uint64_t 41959360000 0.680954秒 15.3986 GB /秒
したがって、それはほとんど同じ結果であり、まだ奇妙です。しかし、今では超奇妙になっています。入力から読み取られたバッファサイズを定数に置き換える1
ため、次のように変更します。
uint64_t size = atol(argv[1]) << 20;
に
uint64_t size = 1 << 20;
したがって、コンパイラーはコンパイル時にバッファー・サイズを認識します。多分それはいくつかの最適化を追加することができます!の番号はg++
次のとおりです。
- 署名なし41959360000 0.509156秒 20.5944 GB / s
- uint64_t 41959360000 0.508673秒 20.6139 GB / s
現在、両方のバージョンが同等に高速です。しかし、unsigned
さらに遅くなりました!それはからに低下した26
ため20 GB/s
、非定数を定数値に置き換えると、最適化が失われます。真剣に、私はここで何が起こっているのか手がかりがありません!しかし、今clang++
は新しいバージョンです:
- 署名なし41959360000 0.677009秒 15.4884 GB / s
- uint64_t 41959360000 0.676909秒 15.4906 GB / s
待って、何?現在、どちらのバージョンも15 GB /秒という遅い数値に落ちています。したがって、非定数を定数値で置き換えると、Clangのどちらの場合でもコードが遅くなります!
Ivy Bridge CPUを使用している同僚にベンチマークをコンパイルするよう依頼しました。彼は同様の結果を得たので、Haswellではないようです。ここでは2つのコンパイラが奇妙な結果を生成するため、コンパイラのバグでもないようです。ここにはAMD CPUがないため、Intelでのみテストできます。
もっと狂ってください!
最初の例(を含む例)を取り、変数の前atol(argv[1])
にaを配置static
します。
static uint64_t size=atol(argv[1])<<20;
これがg ++での私の結果です:
- 署名なし41959360000 0.396728秒 26.4306 GB / s
- uint64_t 41959360000 0.509484秒 20.5811 GB /秒
いや、さらに別の選択肢。まだ26 GB /秒の高速ですu32
がu64
、少なくとも13 GB /秒から20 GB /秒のバージョンに到達できました。同僚のPCでは、u64
バージョンがバージョンよりもさらに高速になりu32
、すべての中で最も速い結果が得られました。悲しいことに、これはに対してのみ機能しg++
、clang++
気にしないようですstatic
。
私の質問
これらの結果を説明できますか?特に:
- どのようにとの間のこのような違いがあることができ
u32
、およびu64
? - 非定数を一定のバッファサイズで置き換えると、最適でないコードがトリガーされます。
static
キーワードを挿入すると、u64
ループが速くなりますか?同僚のコンピューターの元のコードよりもさらに高速です!
私は最適化が難しい領域であることを知っていますが、そのような小さな変更が実行時間の100%の違いにつながることや、一定のバッファーサイズなどの小さな要素が結果を完全に混ぜることができるとは思いもしませんでした。もちろん、私は常に26 GB /秒をポップカウントできるバージョンが欲しいです。私が考えることができる唯一の信頼できる方法は、このケースのアセンブリをコピーして貼り付け、インラインアセンブリを使用することです。これは、小さな変更で狂ったように見えるコンパイラを取り除くことができる唯一の方法です。どう思いますか?ほとんどのパフォーマンスでコードを確実に取得する別の方法はありますか?
分解
以下は、さまざまな結果の分解です。
g ++ / u32 / non-const bufsizeからの26 GB / sバージョン:
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
15 GB / sバージョンのclang ++ / u64 / non-const bufsize:
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
20 GB /秒バージョンのg ++ / u32&u64 / const bufsize:
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
15 GB /秒バージョンのclang ++ / u32&u64 / const bufsize:
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 /秒)バージョンも最長です。を使用する唯一のソリューションのようlea
です。jb
ジャンプに使用するバージョンもあれば、を使用するバージョンもありますjne
。しかし、それを除いて、すべてのバージョンは同等であるようです。100%のパフォーマンスギャップがどこから発生するかはわかりませんが、アセンブリを解読することに長けていません。最も遅いバージョン(13 GB /秒)でも、非常に短くて見栄えがします。誰かがこれを説明できますか?
学んだ教訓
この質問の答えがどうなるかは関係ありません。本当にホットなループでは、すべての詳細が重要になる可能性があることを知りました。ホットコードに関連付けられていないように見える詳細もです。ループ変数にどのタイプを使用するか考えたことはありませんが、このような小さな変更で100%の違いが生じる可能性があります。static
サイズ変数の前にキーワードを挿入することで見たように、バッファのストレージタイプでも大きな違いを生む可能性があります。将来的には、システムパフォーマンスに不可欠な非常にタイトでホットなループを作成するときに、さまざまなコンパイラーでさまざまな代替手段を常にテストします。
興味深いのは、ループを4回展開したにもかかわらず、パフォーマンスの差が依然として非常に大きいことです。したがって、展開しても、パフォーマンスの大幅な逸脱に見舞われる可能性があります。かなり興味深い。