使用此指针会导致热循环中奇怪的不优化

我最近遇到了一个奇怪的去优化(或者说错过了优化的机会)。

考虑这个函数,它可以有效地将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,然后是一个 storetarget缓冲区。但是现在,看看当我把函数改成 struct 中的方法时会发生什么:

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 + + 内存模型禁止这样做吗? 还是仅仅是我的编译器的一个缺点?
  • 我的猜测是正确的还是不能执行优化的确切原因是什么?

使用的编译器是带有 -O3优化的 g++ 4.8.2-19ubuntu1。我还尝试了 clang++ 3.4-1ubuntu3,得到了类似的结果: Clang 甚至能够使用本地 target指针向量化该方法。但是,使用 this->target指针会产生相同的结果: 在每个存储之前额外加载指针。

我检查了一些类似方法的汇编程序,结果是相同的: 似乎 this的一个成员总是必须在存储之前重新加载,即使这样的加载可以简单地悬挂在循环之外。我将不得不重写大量的代码来删除这些额外的存储,主要是通过将指针缓存到一个局部变量中,这个局部变量是在热代码之上声明的。但我一直认为,在编译器变得如此聪明的今天,在局部变量中缓存指针这样的细节肯定有资格进行过早的优化。但看来我错了.在热循环中缓存成员指针似乎是一种必要的手动优化技术。

7844 次浏览

指针混淆似乎是问题所在,具有讽刺意味的是在 thisthis->target之间。编译器正在考虑您初始化的相当下流的可能性:

this->target = &this

在这种情况下,写入 this->target[0]将改变 this的内容(也就是 this->target)。

内存混淆问题不仅限于上述情况。原则上,如果使用 this->target[XX]的值为 XX,那么它可能指向 this

我更精通 C 语言,在 C 语言中,这可以通过使用 __restrict__关键字声明指针变量来补救。

严格的别名规则允许 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缓存到局部变量中,别名就不再可能与局部变量一起使用。

这里的问题是 严格的化名,它说我们可以通过 Char * 使用别名,这样就可以防止在你的情况下出现编译器最佳化。我们不允许通过一个不同类型的指针使用别名,这个指针通常是未定义行为的,所以我们看到这个问题就是用户试图使用 通过不兼容的指针类型使用别名

Uint8 _ t作为 无符号字符来实现似乎是合理的,如果我们看看 关于科利鲁的信息,它包括 Stdint.h,它将 Uint8 _ t类型化如下:

typedef unsigned char       uint8_t;

如果您使用了另一种非字符类型,那么编译器应该能够进行优化。

C + + 标准条款草案 3.10 左值和右值涵盖了这一点,其中说:

如果程序试图通过另一个对象的 glvalue 访问对象的存储值,则 以下类型的行为是未定义的

并包括以下子弹:

  • 字符或无符号字符类型。

注意,我在一个问 Uint8 _ t ≠无符号字符是什么时候?的问题中提出了一个 对可能的解决办法发表意见,建议是:

但是,简单的解决方案是使用 limit 关键字,或者 将指针复制到一个局部变量,该局部变量的地址从未被采用 编译器不需要担心 uint8 _ t 对象可以给它起别名。

由于 C + + 不支持 限制关键字,你必须依赖于编译器扩展,例如 Gcc 使用 _ _ limit _ _,所以这不是完全可移植的,但其他建议应该是。