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


122

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

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、次にa 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-O3最適化。私もclang++ 3.4-1ubuntu3同様の結果を試しました:Clangはローカルtargetポインターでメソッドをベクトル化することもできます。ただし、this->targetポインタを使用しても同じ結果が得られます。各ストアの前にポインタが余分にロードされます。

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


5
なぜこれが反対票を投じたのかわからない-それは興味深い質問です。FWIW解決策が似ている非ポインターメンバー変数で同様の最適化問題を見たことがあります。つまり、メソッドの存続期間中、メンバー変数をローカル変数にキャッシュします。私はそれがエイリアシング規則と関係があると思いますか?
Paul R

1
コンパイラーが最適化されていないように見えます。なぜなら、メンバーが何らかの「外部」コードを介してアクセスされないようにすることができないためです。したがって、メンバーを外部で変更できる場合は、アクセスするたびに再ロードする必要があります。一種の揮発性のようなものと考えられているようです...
Jean-BaptisteYunès2014年

いいえ、使用しないのthis->は単に構文上の砂糖です。この問題は、変数の性質(ローカルvsメンバー)と、コンパイラがこの事実から導き出すことに関連しています。
Jean-BaptisteYunès2014年

ポインタのエイリアスとは何か?
Yves Daoust 2014年

3
より意味論的な問題として、「時期尚早の最適化」は時期尚早の、つま​​りプロファイリングが問題であると判明する前の、最適化にのみ適用されます。この場合、あなたはこまめにプロファイルを作成して逆コンパイルし、問題の原因を見つけ、解決策を策定してプロファイルしました。そのソリューションを適用することは絶対に「時期尚早」ではありません。
raptortech97 2014年

回答:


107

ポインタエイリアシングは皮肉の間で、問題になるようだthisthis->target。コンパイラーは、初期化されたかなり卑劣な可能性を考慮に入れています。

this->target = &this

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

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

私はCに精通していますが、__restrict__キーワードでポインター変数を宣言することでこれを修正できます。


18
確認できました!targetからuint8_tに変更するとuint16_t(厳密なエイリアシングルールが適用されるように)、変更されました。ではuint16_t、負荷が常に出て最適化されています。
殺虫剤2014年


3
の内容を変更するthisことは、あなたが意図していることではありません(変数ではありません)。の内容を変更することを意味します*this
Marc van Leeuwen

@gexicideマインドは、厳密なエイリアスがどのようにして問題を修正するのかを詳しく説明していますか?
HCSF、

33

厳格なエイリアシングルールによりchar*、他のポインタをエイリアスできます。したがって、とthis->target別名を付けることができthis、コードメソッドでは、コードの最初の部分、

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変更時に変更することができるthis->targetコンテンツを。

一度this->targetローカル変数にキャッシュされ、別名は、ローカル変数ではもはや不可能です。


1
それで、一般的なルールとして言うことができます:構造体にchar*またはがある場合void*は、書き込む前に必ずローカル変数にキャッシュしてください。
2014年

5
実際char*、メンバーとして必要ではないを使用する場合です。
Jarod42 2014年

24

ここでの問題は、char *を介したエイリアスが許可されているため、コンパイラの最適化が妨げられるという厳密なエイリアスです。未定義の動作となる別のタイプのポインタを介してエイリアスを作成することはできません。通常、SOでは、互換性のないポインタタイプを使用してエイリアスを作成しようとしているユーザーにこの問題が発生します

実装するのが妥当と思われるuint8_tとしてunsigned char型と我々が見ればColiruにcstdintそれは、stdint.hのtypedef uint8_tを次のように:

typedef unsigned char       uint8_t;

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

これは、C ++標準のドラフトセクションの3.10 Lvaluesおよびrvalues説明されています。

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

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

  • charまたはunsigned char型。

注:uint8_t≠unsigned char はいつですか?と尋ねる質問に、考えられる回避策に関するコメントを投稿しましたそして勧告は:

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

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


これは、標準がオプティマイザにとってルールよりも悪い場所の例です。コンパイラは、タイプTのオブジェクトへの2つのアクセス、またはそのようなアクセスとループ/関数の開始または終了を想定することができます。発生した場合、介在する操作がそのオブジェクト(またはそのオブジェクトへのポインター/参照)を使用して他のオブジェクトへのポインターまたは参照を導出しない限り、ストレージへのすべてのアクセスは同じオブジェクトを使用します。このようなルールにより、バイトシーケンスを処理するコードのパフォーマンスを低下させる可能性のある「文字タイプの例外」が不要になります。
スーパーキャット2018
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.