最近,我遇到了一个奇怪的取消优化(或者说错过了优化机会)。
考虑使用此函数可以有效地将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;
}
}
此代码还可以产生“良好的”汇编程序,而无需其他存储。所以我的猜测是:不允许编译器提升结构的成员指针的负载,因此,此类“热指针”应始终存储在局部变量中。
- 那么,为什么编译器无法优化这些负载?
- 是C ++内存模型禁止这样做吗?还是仅仅是编译器的缺点?
- 我的猜测是正确的还是无法执行优化的确切原因是什么?
使用的编译器g++ 4.8.2-19ubuntu1
具有-O3
优化功能。我也尝试clang++ 3.4-1ubuntu3
了类似的结果:Clang甚至可以使用本地指针对方法进行向量化target
。但是,使用this->target
指针会产生相同的结果:在每次存储之前都要额外增加指针的负载。
我检查了一些类似方法的汇编程序,结果是相同的:似乎this
总是必须在存储之前重新加载的成员,即使可以简单地在循环外部进行加载。我将不得不重写很多代码来摆脱这些额外的存储,主要是通过将指针自己缓存到在热代码上方声明的局部变量中。但是我一直认为,摆弄这样的细节,例如在本地变量中缓存指针,肯定会在当今编译器变得如此聪明的今天有资格进行过早的优化。但是看来我错了。在热循环中缓存成员指针似乎是必要的手动优化技术。
this->
只是语法糖。问题与变量的性质(本地vs成员)以及编译器从该事实推论得出的事情有关。