web-dev-qa-db-ja.com

このポインターを使用すると、ホットループで奇妙な最適化が解除されます

最近、奇妙な最適化解除(または最適化の機会を見逃した)に遭遇しました。

3ビット整数の配列を8ビット整数に効率的にアンパックするには、この関数を検討してください。各ループ反復で16個の整数をアンパックします。

void unpack3bit(uint8_t* target, char* source, int size) {
   while(size > 0){
      uint64_t t = *reinterpret_cast<uint64_t*>(source);
      target[0] = t & 0x7;
      target[1] = (t >> 3) & 0x7;
      target[2] = (t >> 6) & 0x7;
      target[3] = (t >> 9) & 0x7;
      target[4] = (t >> 12) & 0x7;
      target[5] = (t >> 15) & 0x7;
      target[6] = (t >> 18) & 0x7;
      target[7] = (t >> 21) & 0x7;
      target[8] = (t >> 24) & 0x7;
      target[9] = (t >> 27) & 0x7;
      target[10] = (t >> 30) & 0x7;
      target[11] = (t >> 33) & 0x7;
      target[12] = (t >> 36) & 0x7;
      target[13] = (t >> 39) & 0x7;
      target[14] = (t >> 42) & 0x7;
      target[15] = (t >> 45) & 0x7;
      source+=6;
      size-=6;
      target+=16;
   }
}

コードの一部に対して生成されたアセンブリは次のとおりです。

 ...
 367:   48 89 c1                mov    rcx,rax
 36a:   48 c1 e9 09             shr    rcx,0x9
 36e:   83 e1 07                and    ecx,0x7
 371:   48 89 4f 18             mov    QWORD PTR [rdi+0x18],rcx
 375:   48 89 c1                mov    rcx,rax
 378:   48 c1 e9 0c             shr    rcx,0xc
 37c:   83 e1 07                and    ecx,0x7
 37f:   48 89 4f 20             mov    QWORD PTR [rdi+0x20],rcx
 383:   48 89 c1                mov    rcx,rax
 386:   48 c1 e9 0f             shr    rcx,0xf
 38a:   83 e1 07                and    ecx,0x7
 38d:   48 89 4f 28             mov    QWORD PTR [rdi+0x28],rcx
 391:   48 89 c1                mov    rcx,rax
 394:   48 c1 e9 12             shr    rcx,0x12
 398:   83 e1 07                and    ecx,0x7
 39b:   48 89 4f 30             mov    QWORD PTR [rdi+0x30],rcx
 ...

それは非常に効率的に見えます。単にshift rightの後にandが続き、storetargetバッファーに続きます。しかし、今、関数を構造体のメソッドに変更するとどうなるか見てみましょう。

struct T{
   uint8_t* target;
   char* source;
   void unpack3bit( int size);
};

void T::unpack3bit(int size) {
        while(size > 0){
           uint64_t t = *reinterpret_cast<uint64_t*>(source);
           target[0] = t & 0x7;
           target[1] = (t >> 3) & 0x7;
           target[2] = (t >> 6) & 0x7;
           target[3] = (t >> 9) & 0x7;
           target[4] = (t >> 12) & 0x7;
           target[5] = (t >> 15) & 0x7;
           target[6] = (t >> 18) & 0x7;
           target[7] = (t >> 21) & 0x7;
           target[8] = (t >> 24) & 0x7;
           target[9] = (t >> 27) & 0x7;
           target[10] = (t >> 30) & 0x7;
           target[11] = (t >> 33) & 0x7;
           target[12] = (t >> 36) & 0x7;
           target[13] = (t >> 39) & 0x7;
           target[14] = (t >> 42) & 0x7;
           target[15] = (t >> 45) & 0x7;
           source+=6;
           size-=6;
           target+=16;
        }
}

生成されたアセンブリはまったく同じであると考えましたが、そうではありません。その一部を次に示します。

...
 2b3:   48 c1 e9 15             shr    rcx,0x15
 2b7:   83 e1 07                and    ecx,0x7
 2ba:   88 4a 07                mov    BYTE PTR [rdx+0x7],cl
 2bd:   48 89 c1                mov    rcx,rax
 2c0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2c3:   48 c1 e9 18             shr    rcx,0x18
 2c7:   83 e1 07                and    ecx,0x7
 2ca:   88 4a 08                mov    BYTE PTR [rdx+0x8],cl
 2cd:   48 89 c1                mov    rcx,rax
 2d0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2d3:   48 c1 e9 1b             shr    rcx,0x1b
 2d7:   83 e1 07                and    ecx,0x7
 2da:   88 4a 09                mov    BYTE PTR [rdx+0x9],cl
 2dd:   48 89 c1                mov    rcx,rax
 2e0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2e3:   48 c1 e9 1e             shr    rcx,0x1e
 2e7:   83 e1 07                and    ecx,0x7
 2ea:   88 4a 0a                mov    BYTE PTR [rdx+0xa],cl
 2ed:   48 89 c1                mov    rcx,rax
 2f0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 ...

ご覧のとおり、各シフトの前に、メモリから追加の冗長loadを導入しました(mov rdx,QWORD PTR [rdi])。 targetポインター(現在はローカル変数ではなくメンバーになっています)は、格納する前に常に再ロードする必要があるようです。 これにより、コードの速度が大幅に低下します(私の測定では約15%)。

最初に、C++メモリモデルでは、メンバーポインターをレジスターに保存せずに再ロードする必要があると考えられますが、多くの実行可能な最適化が不可能になるため、これは厄介な選択のように思えました。そのため、コンパイラがtargetをここのレジスタに格納しなかったことに非常に驚きました。

メンバーポインターをローカル変数に自分でキャッシュしてみました。

void T::unpack3bit(int size) {
    while(size > 0){
       uint64_t t = *reinterpret_cast<uint64_t*>(source);
       uint8_t* target = this->target; // << ptr cached in local variable
       target[0] = t & 0x7;
       target[1] = (t >> 3) & 0x7;
       target[2] = (t >> 6) & 0x7;
       target[3] = (t >> 9) & 0x7;
       target[4] = (t >> 12) & 0x7;
       target[5] = (t >> 15) & 0x7;
       target[6] = (t >> 18) & 0x7;
       target[7] = (t >> 21) & 0x7;
       target[8] = (t >> 24) & 0x7;
       target[9] = (t >> 27) & 0x7;
       target[10] = (t >> 30) & 0x7;
       target[11] = (t >> 33) & 0x7;
       target[12] = (t >> 36) & 0x7;
       target[13] = (t >> 39) & 0x7;
       target[14] = (t >> 42) & 0x7;
       target[15] = (t >> 45) & 0x7;
       source+=6;
       size-=6;
       this->target+=16;
    }
}

このコードは、追加のストアなしで「良い」アセンブラーを生成します。私の推測では、コンパイラは構造体のメンバーポインターの負荷を引き上げることができないため、このような「ホットポインター」は常にローカル変数に格納する必要があります。

  • だから、なぜコンパイラはこれらの負荷を最適化できないのですか?
  • これを禁止しているのはC++メモリモデルですか?それとも単にコンパイラの欠点ですか?
  • 私の推測は正しいですか、最適化を実行できない正確な理由は何ですか?

使用中のコンパイラはg++ 4.8.2-19ubuntu1 with -O3最適化。私も試してみましたclang++ 3.4-1ubuntu3同様の結果:Clangはローカルtargetポインターを使用してメソッドをベクトル化することもできます。ただし、this->targetポインターの結果は同じです。各ストアの前にポインターが余分にロードされます。

いくつかの同様のメソッドのアセンブラをチェックしましたが、結果は同じです:thisのメンバーは、そのようなロードが単にループの外側に引き上げられる場合でも、ストアの前に常にリロードする必要があるようです。主にホットコードの上で宣言されたローカル変数にポインターを自分でキャッシュすることにより、これらの追加のストアを削除するために多くのコードを書き直す必要があります。 ただし、ローカル変数にポインターをキャッシュするなどの詳細をいじるのは、コンパイラーが非常に巧妙になった最近の時期尚早な最適化に確実に適格だと思っていました。しかし、ここでは間違っているようです。ホットループでのメンバーポインターのキャッシュは、必要な手動最適化手法のようです。

114
gexicide

皮肉なことにthisthis->targetの間のポインターエイリアシングが問題のようです。コンパイラは、ユーザーが初期化したというかなりわいせつな可能性を考慮しています。

this->target = &this

その場合、this->target[0]への書き込みはthis(したがってthis-> target)の内容を変更します。

メモリのエイリアシングの問題は上記に限定されません。原則として、XXの(適切な)値が与えられたthis->target[XX]の使用は、thisを指す場合があります。

__restrict__キーワードを使用してポインター変数を宣言することでこれを改善できるCに精通しています。

97
Peter Boncz

厳密なエイリアスルールにより、char*が他のポインターをエイリアスできます。したがって、this->targetthisでエイリアスでき、コードメソッドでは、コードの最初の部分、

target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;

実際に

this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;

this->targetコンテンツを変更すると、thisも変更される可能性があるため。

this->targetがローカル変数にキャッシュされると、エイリアスはローカル変数では使用できなくなります。

30
Jarod42

ここでの問題は strict aliasing です。これは、char *を介したエイリアスが許可されているため、コンパイラの最適化ができないことを示しています。未定義の動作となる別のタイプのポインター。通常はSOにあります。この問題は、ユーザーが 互換性のないポインタータイプ

uint8_tunsigned charとして実装し、 colidのcstdintstdint.hが含まれている場合) which typedefsuint8_t次のように:

typedef unsigned char       uint8_t;

別の非文字型を使用した場合、コンパイラは最適化できるはずです。

これは、ドラフトC++標準セクション3.10左辺値と右辺値は次のとおりです。

プログラムが次のタイプのいずれか以外のglvalueを介してオブジェクトの保存された値にアクセスしようとした場合、動作は未定義です

次の箇条書きが含まれています。

  • charまたはunsigned char型。

注:uint8_t≠unsigned char?と尋ねる質問で、可能な回避策について コメントを投稿しました

ただし、簡単な回避策は、restrictキーワードを使用するか、アドレスが取得されないローカル変数にポインターをコピーして、コンパイラがuint8_tオブジェクトがエイリアスできるかどうかを心配する必要がないようにすることです。

C++はrestrictキーワードをサポートしていないため、コンパイラー拡張機能に依存する必要があります。たとえば、 gccは__restrict__ を使用するため、これは完全に移植可能ではありませんが、他の提案が必要です。

24
Shafik Yaghmour