web-dev-qa-db-ja.com

32ビットのループカウンタを64ビットに置き換えると、パフォーマンスがおかしくなります。

私は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のランダムデータ)。

  • 符号なし41959360000 0.401554秒 26.113 GB /秒
  • uint64_t 41959360000 0.759822秒 13.8003 GB /秒

ご覧のとおり、uint64_tバージョンのスループットは、 半分だけ unsignedバージョンのものです。問題は、異なる議会が生成されることですが、なぜでしょうか。まず、私はコンパイラのバグについて考えたので、私はclang++を試しました(Ubuntu Clang version 3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

結果:test 1

  • 符号なし41959360000 0.398293秒 26.3267 GB /秒
  • 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 /秒
  • uint64_t 41959360000 0.508673秒 20.6139 GB /秒

今、両方のバージョンは同じくらい速いです。しかし、unsigned はさらに遅くなりました !それは26から20 GB/sに落としました、したがって、定数でない定数を置き換えることは 非最適化 につながります。真剣に、私はここで何が起こっているのかわかりません!しかし、新しいバージョンでclang++へ。

  • 符号なし41959360000 0.677009秒 15.4884 GB /秒
  • uint64_t 41959360000 0.676909秒 15.4906 GB /秒

ちょっと待って、どうしたんださて、両方のバージョンが slow の15GB /秒に落ちた。したがって、定数でない定数を置き換えると、 both の場合、Clang!のコードが遅くなります。

私はベンチマークをまとめるために Ivy Bridge CPUを持つ同僚に依頼しました。彼は同様の結果を得たので、それはハズウェルではないようです。 2つのコンパイラがここで奇妙な結果を生み出すので、それもコンパイラのバグではないようです。ここにはAMDのCPUがないので、Intelでしかテストできませんでした。

もっと狂気してください!

最初の例(atol(argv[1])を持つもの)を取り、変数の前にstaticを置きます。

static uint64_t size=atol(argv[1])<<20;

これがg ++での結果です。

  • 符号なし41959360000 0.396728秒 26.4306 GB /秒
  • uint64_t 41959360000 0.509484秒 20.5811 GB /秒

Yay、さらに別の選択肢u32の高速26GB/sはまだありますが、少なくとも13GB/sから20GB/sのバージョンまでu64を取得することができました。 私の同僚のPCでは、u64バージョンはu32バージョンよりもさらに速くなり、すべての中で最も速い結果が得られました。 悲しいことに、これはg++に対してのみ機能します、clang++staticを気にしないようです。

私の質問

これらの結果を説明できますか?特に:

  • u32u64の間にそのような違いがあるのはどうしてですか?
  • どのようにして、定数でないバッファサイズを定数のバッファサイズに置き換えると、最適ではないコードを発生させることができますか?
  • 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回ループを展開したにもかかわらず、パフォーマンスの違いがまだ非常に大きいことです。ですから、あなたがアンロールしても、あなたはまだ大きなパフォーマンスの逸脱に見舞われる可能性があります。とてもおもしろい。

1293
gexicide

犯人:False Data Dependency (そしてコンパイラはそれを意識していません)

Sandy/Ivy BridgeおよびHaswellプロセッサでは、次のようになります。

popcnt  src, dest

デスティネーションレジスタdestに誤った依存関係があるようです。命令がそれに書き込むだけであっても、命令は実行の前にdestの準備ができるまで待ちます。

この依存関係は、単一のループ反復からの4つのpopcntを遅らせるだけではありません。それはループ反復をまたいで実行することができ、プロセッサが異なるループ反復を並列化することを不可能にする。

unsigneduint64_tやその他の調整が直接問題に影響することはありません。しかし、それらはレジスタを変数に割り当てるレジスタアロケータに影響を与えます。

あなたの場合、速度は、レジスタアロケータがやろうと決めたことに応じて、(誤った)依存関係チェーンに固執することの直接的な結果です。

  • 13 GB/sのチェーンがあります。popcnt-add-popcnt-popcnt→次の繰り返し
  • 15 GB /秒のチェーンがあります。popcnt-add-popcnt-add→次の繰り返し
  • 20 GB /秒のチェーンがあります。popcnt-popcnt→次の繰り返し
  • 26 GB /秒のチェーンがあります。popcnt-popcnt→次の繰り返し

20 GB /秒と26 GB /秒の違いは、間接アドレス指定のマイナーアーティファクトのようです。どちらにしても、この速度に達すると、プロセッサは他のボトルネックにぶつかり始めます。


これをテストするために、インラインアセンブリを使用してコンパイラを迂回し、正確に必要なアセンブリを取得しました。私はまたベンチマークを台無しにするかもしれない他のすべての依存関係を打破するためにcount変数を分割しました。

結果は次のとおりです。

Sandy Bridge Xeon @ 3.5 GHz: (下部に完全なテストコードがあります)

  • GCC 4.6.3:g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

異なるレジスタ: 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が同じ処理をしている可能性があります。 addsubのような一般的な命令は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
1438
Mysticial

実験用に同等のCプログラムをコーディングしたところ、この奇妙な振る舞いを確認できます。さらに、gccは、64ビット整数(とにかくおそらくsize_tであるべきです...)が優れていると考えています。uint_fast32_tを使用すると、gccが64ビットuintを使用するようになるからです。

私は、議会についてちょっと雑用しました:
単純に32ビットバージョンを使用し、プログラムの内側のポップカウントループですべての32ビット命令/レジスタを64ビットバージョンに置き換えます。所見:コードは 32ビット版と同じくらい速いです!

プログラムの他の部分ではまだ32ビットバージョンが使用されているが、内側のポップカウントループがパフォーマンスを支配している限り、変数のサイズは実際には64ビットではないので、これは明らかにハックです。これは良いスタートです。

次に、32ビットバージョンのプログラムから内部ループコードをコピーし、64ビットになるようにハッキングし、64ビットバージョンの内部ループの代わりになるようにレジスタをいじっています。 このコードも32ビットバージョンと同じくらい高速に実行されます。

私の結論は、これはコンパイラによる悪い命令スケジューリングであり、32ビット命令の実際の速度/レイテンシの利点ではないということです。

(警告:私は議会をハックしました。気づかずに何かを壊すことができたのです。私はそうは思いません。)

50
EOF

これは答えではありませんが、結果にコメントを入れると読みにくくなります。

これらの結果は Mac ProWestmere 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

私も試してみました:

  1. テストの順序を逆にしても結果は同じなので、キャッシュ係数は除外されます。
  2. 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
10
rcgldr

正式な回答はできませんが、考えられる原因の概要を説明してください。 このリファレンス は、ループ本体の命令のレイテンシとスループットの比率が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ビットコードが競合の転機を迎えているという別の手がかりになります。

これは厳密な分析ではないことを私は知っていますが、それはもっともらしい説明です。

10
Gene

-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
9
Dangelov

縮小ステップをループの外側に移動してみましたか?今、あなたは本当に必要とされていないデータ依存関係を持っています。

試してください:

  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];

私は厳密なエイリアシング規則に準拠しているかどうかわからない、あなたはまた、いくつかの奇妙なエイリアシングが起こっている。

7
Ben Voigt

TL; DR:代わりに__builtin組み込み関数を使用してください。

私はgcc 4.8.4(そしてgcc.godbolt.orgでは4.7.3)でも同じアセンブリ命令を使用する__builtin_popcountllを使用することでこれに最適なコードを生成することができましたが、その誤った依存関係のバグはありません。

私はベンチマークコードを100%確信できませんが、objdumpの出力は私の見解を共有しているようです。私は他のトリック(++ii++)を使用して、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:
5
assp1r1n3