最近、奇妙な最適化解除に遭遇しました(または最適化の機会を逃しました)。
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 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;
}
}
このコードは、追加のストアなしで「良い」アセンブラーも生成します。だから私の推測は:コンパイラーは構造体のメンバーポインターの負荷を引き上げることができないので、そのような「ホットポインター」は常にローカル変数に格納する必要があります。
- では、コンパイラがこれらのロードを最適化できないのはなぜですか?
- これを禁じているのはC ++メモリモデルですか?それとも単にコンパイラの欠点ですか?
- 私の推測は正しいですか、または最適化を実行できない正確な理由は何ですか?
使用中のコンパイラは、だったg++ 4.8.2-19ubuntu1
と-O3
最適化。私もclang++ 3.4-1ubuntu3
同様の結果を試しました:Clangはローカルtarget
ポインターでメソッドをベクトル化することもできます。ただし、this->target
ポインタを使用しても同じ結果が得られます。各ストアの前にポインタが余分にロードされます。
私はいくつかの類似したメソッドのアセンブラーをチェックしましたが、結果は同じです。this
たとえそのようなロードがループの外で単純に引き上げられたとしても、のメンバーは常にストアの前にリロードする必要があるようです。これらの追加のストアを削除するには、主にホットコードの上で宣言されているローカル変数にポインターをキャッシュすることにより、多くのコードを書き直す必要があります。しかし、ローカル変数にポインターをキャッシュするなどの詳細をいじると、コンパイラーが非常に巧妙になった今日では、時期尚早の最適化の資格があると私はいつも思っていました。しかし、私はここで間違っているようです。ホットループでメンバーポインターをキャッシュすることは、必要な手動最適化手法のようです。
this->
は単に構文上の砂糖です。この問題は、変数の性質(ローカルvsメンバー)と、コンパイラがこの事実から導き出すことに関連しています。