使用此指针会在热循环中引起奇怪的反优化


122

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

考虑使用此函数可以有效地将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,然后storetarget缓冲液中。但是现在,看看将函数更改为结构中的方法时会发生什么:

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总是必须在存储之前重新加载的成员,即使可以简单地在循环外部进行加载。我将不得不重写很多代码来摆脱这些额外的存储,主要是通过将指针自己缓存到在热代码上方声明的局部变量中。但是我一直认为,摆弄这样的细节,例如在本地变量中缓存指针,肯定会在当今编译器变得如此聪明的今天有资格进行过早的优化。但是看来我错了。在热循环中缓存成员指针似乎是必要的手动优化技术。


5
不知道为什么会投票失败-这是一个有趣的问题。FWIW我看到非指针成员变量在解决方案相似的情况下也存在类似的优化问题,即在方法生命期内将成员变量缓存在局部变量中。我猜这与别名规则有关吗?
Paul R

1
看起来编译器没有优化,因为他不能确保不通过某些“外部”代码访问该成员。因此,如果可以在外部修改成员,则每次访问时都应重新加载该成员。似乎被认为是一种易变的...
Jean-BaptisteYunès2014年

没有不this->只是语法糖。问题与变量的性质(本地vs成员)以及编译器从该事实推论得出的事情有关。
Jean-BaptisteYunès2014年

与指针别名有关系吗?
伊夫·达乌斯特2014年

3
作为更语义的问题,“过早优化”仅适用于过早的优化,即在概要分析发现它是一个问题之前。在这种情况下,您要认真地进行概要分析和反编译,找到问题的根源,然后制定并概要分析解决方案。应用该解决方案绝对不是“不成熟”。
raptortech97 2014年

Answers:


106

指针别名似乎是问题,讽刺之间thisthis->target。编译器考虑到了您初始化的相当淫秽的可能性:

this->target = &this

在这种情况下,写入this->target[0]会更改this(因此,this->target)的内容。

内存别名问题不限于上述问题。原则上,任何this->target[XX]给定(适当)值的使用都XX可能指向this

我更精通C语言,可以通过使用__restrict__关键字声明指针变量来解决此问题。


18
我可以确认!target从更改uint8_tuint16_t(以便使用严格的别名规则)将其更改。使用uint16_t,总是可以优化负载。
杀gexicide,2014年


3
更改内容this不是您的意思(它不是变量);您的意思是更改的内容*this
Marc van Leeuwen 2014年

@gexicide介意阐述如何严格使用别名并解决该问题?
HCSF

33

严格的别名规则允许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被缓存到本地变量中,该别名将不再可用。


1
因此,我们可以说一般规则:每当在结构体中有char*or void*时,确保在写入之前将其缓存在局部变量中?
杀人剂

5
实际上,当您使用时char*,不必将其用作成员。
Jarod42

24

这里的问题是严格的别名,它表示允许我们通过char *进行别名,从而在您的情况下阻止编译器优化。我们不允许通过其他类型的指针来别名,这将是未定义的行为,通常在SO上,我们会看到此问题,即用户试图通过不兼容的指针类型别名

uint8_t实现为无符号字符似乎是合理的,并且如果我们在Coliru上查看cstdint的话,它包含stdint.h,其类型定义为uint8_t,如下所示:

typedef unsigned char       uint8_t;

如果您使用其他非字符类型,则编译器应该能够进行优化。

在C ++标准草案的3.10 Lvalues和rvalues部分中对此进行了介绍,其中说:

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

并包含以下项目符号:

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

注意,我在一个问题中发表了关于可能的解决方法评论,询问何时uint8_t≠unsigned char?推荐是:

但是,最简单的解决方法是使用strict关键字,或者将指针复制到永远不会使用其地址的局部变量,这样编译器就不必担心uint8_t对象是否可以为其别名。

由于C ++不支持limit关键字,因此您必须依赖编译器扩展,例如gcc使用__restrict__,因此这不是完全可移植的,但另一个建议应该是。


这是一个示例,在该示例中,标准对优化器的影响要比规则差,这将使编译器假设两次访问类型T的对象之间(或此类访问与循环/函数的开始或结束之间)在发生这种情况的情况下,所有对存储的访问都将使用同一对象,除非介入操作使用该对象(或对该对象的指针/引用)来派生指向某个其他对象的指针或引用。这样的规则将消除对“字符类型异常”的需求,因为“字符类型异常”会破坏与字节序列一起工作的代码的性能。
超级猫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.