我最近遇到了一种奇怪的去优化(或者错过了优化机会)。
考虑使用此函数可以有效地将3位整数数组解包为8位整数。它在每次循环迭代中解包16个int:
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
,-O3
优化。我也尝试clang++ 3.4-1ubuntu3
得到了类似的结果:Clang甚至能够使用本地target
指针对该方法进行矢量化。但是,使用this->target
指针会产生相同的结果:在每个商店之前额外加载指针。
我检查了一些类似方法的汇编程序,结果是一样的:看起来this
的成员总是必须在商店之前重新加载,即使这样的负载可以简单地在循环之外被提升。我将不得不重写大量代码来摆脱这些额外的存储,主要是通过将指针自身缓存到在热代码之上声明的局部变量。 但是我总是想摆弄这些细节,因为在当地变量中缓存指针肯定有资格在编译器变得如此聪明的这些日子里过早优化。但这似乎我错了。在热循环中缓存成员指针似乎是必要的手动优化技术。
答案 0 :(得分:97)
指针别名似乎是问题,具有讽刺意味的是this
和this->target
之间。编译器正在考虑您初始化的相当可怜的可能性:
this->target = &this
在这种情况下,写入this->target[0]
会改变this
的内容(因此,此&gt;目标)。
内存别名问题不限于上述问题。原则上,this->target[XX]
给定({in)适当值XX
的任何使用都可能指向this
。
我更擅长C,可以通过使用__restrict__关键字声明指针变量来解决这个问题。
答案 1 :(得分:30)
严格的别名规则允许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
缓存到局部变量后,使用局部变量就不再可以使用别名。
答案 2 :(得分:24)
这里的问题是strict aliasing ,它说我们可以通过 char * 进行别名,这样就可以防止编译器优化。我们不允许通过不同类型的指针进行别名,这将是未定义的行为,通常在SO上我们看到这个问题是用户尝试alias through incompatible pointer types。
将 uint8_t 实现为 unsigned char 似乎是合理的,如果我们查看cstdint on Coliru它包含stdint.h哪个typedefs uint8_t 如下:
typedef unsigned char uint8_t;
如果你使用了另一个非char类型,那么编译器应该能够进行优化。
草案C ++标准部分3.10
Lvalues和rvalues 中包含了这一点,其中包含:
如果程序试图通过除了其中一个之外的glvalue访问对象的存储值 以下类型行为未定义
并包含以下项目符号:
- char或unsigned char类型。
注意,我在一个问题中发布了一个comment on possible work arounds 当uint8_t≠unsigned char?时,建议是:
然而,简单的解决方法是使用restrict关键字,或者 将指针复制到一个从不采用地址的局部变量 那编译器不需要担心是否uint8_t 对象可以别名。
由于C ++不支持 restrict 关键字,因此您必须依赖编译器扩展,例如gcc uses __restrict__,因此这不是完全可移植的,但另一个建议应该是。