最近、奇妙な最適化解除(または最適化の機会を見逃した)に遭遇しました。
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
が続き、store
がtarget
バッファーに続きます。しかし、今、関数を構造体のメソッドに変更するとどうなるか見てみましょう。
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;
}
}
このコードは、追加のストアなしで「良い」アセンブラーを生成します。私の推測では、コンパイラは構造体のメンバーポインターの負荷を引き上げることができないため、このような「ホットポインター」は常にローカル変数に格納する必要があります。
使用中のコンパイラはg++ 4.8.2-19ubuntu1
with -O3
最適化。私も試してみましたclang++ 3.4-1ubuntu3
同様の結果:Clangはローカルtarget
ポインターを使用してメソッドをベクトル化することもできます。ただし、this->target
ポインターの結果は同じです。各ストアの前にポインターが余分にロードされます。
いくつかの同様のメソッドのアセンブラをチェックしましたが、結果は同じです:this
のメンバーは、そのようなロードが単にループの外側に引き上げられる場合でも、ストアの前に常にリロードする必要があるようです。主にホットコードの上で宣言されたローカル変数にポインターを自分でキャッシュすることにより、これらの追加のストアを削除するために多くのコードを書き直す必要があります。 ただし、ローカル変数にポインターをキャッシュするなどの詳細をいじるのは、コンパイラーが非常に巧妙になった最近の時期尚早な最適化に確実に適格だと思っていました。しかし、ここでは間違っているようです。ホットループでのメンバーポインターのキャッシュは、必要な手動最適化手法のようです。
皮肉なことにthis
とthis->target
の間のポインターエイリアシングが問題のようです。コンパイラは、ユーザーが初期化したというかなりわいせつな可能性を考慮しています。
this->target = &this
その場合、this->target[0]
への書き込みはthis
(したがってthis-> target)の内容を変更します。
メモリのエイリアシングの問題は上記に限定されません。原則として、XX
の(適切な)値が与えられたthis->target[XX]
の使用は、this
を指す場合があります。
__restrict__キーワードを使用してポインター変数を宣言することでこれを改善できるCに精通しています。
厳密なエイリアスルールにより、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->target
コンテンツを変更すると、this
も変更される可能性があるため。
this->target
がローカル変数にキャッシュされると、エイリアスはローカル変数では使用できなくなります。
ここでの問題は strict aliasing です。これは、char *を介したエイリアスが許可されているため、コンパイラの最適化ができないことを示しています。未定義の動作となる別のタイプのポインター。通常はSOにあります。この問題は、ユーザーが 互換性のないポインタータイプ 。
uint8_tをunsigned charとして実装し、 colidのcstdint に stdint.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__ を使用するため、これは完全に移植可能ではありませんが、他の提案が必要です。