使用此指针会导致热循环中出现奇怪的反优化
Posted
技术标签:
【中文标题】使用此指针会导致热循环中出现奇怪的反优化【英文标题】:Using this pointer causes strange deoptimization in hot loop 【发布时间】:2014-12-05 09:00:09 【问题描述】:我最近遇到了一个奇怪的反优化(或者说错过了优化机会)。
考虑使用此函数将 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
的成员总是必须在存储之前重新加载,即使这样的加载可以简单地提升到循环之外。我将不得不重写大量代码来摆脱这些额外的存储,主要是通过自己将指针缓存到在热代码上方声明的局部变量中。 但我一直认为,在编译器变得如此聪明的今天,摆弄诸如在局部变量中缓存指针之类的细节肯定有资格过早优化。但这里似乎我错了。在热循环中缓存成员指针似乎是一种必要的手动优化技术。
【问题讨论】:
不知道为什么这会被否决 - 这是一个有趣的问题。 FWIW 我已经看到与非指针成员变量类似的优化问题,其中解决方案相似,即在方法的生命周期内将成员变量缓存在局部变量中。我猜这与别名规则有关? 看起来编译器没有优化,因为他无法确保不通过某些“外部”代码访问该成员。所以如果成员可以在外面修改,那么每次访问都应该重新加载。似乎被认为是一种不稳定的... 不使用this->
只是语法糖。问题与变量(本地与成员)的性质以及编译器从这一事实中推断出的东西有关。
与指针别名有关吗?
从语义上讲,“过早优化”仅适用于过早的优化,即在分析发现它成为问题之前。在这种情况下,您努力分析和反编译并找到问题的根源,并制定和分析解决方案。应用该解决方案绝对不会“为时过早”。
【参考方案1】:
指针别名似乎是问题所在,讽刺的是在this
和this->target
之间。编译器正在考虑您初始化的相当淫秽的可能性:
this->target = &this
在这种情况下,写入this->target[0]
会改变this
的内容(因此,this->target
)。
内存混叠问题不限于上述。原则上,在给定(不)适当的XX
值的情况下,任何this->target[XX]
的使用都可能指向this
。
我更精通 C,这可以通过使用 __restrict__
关键字声明指针变量来解决。
【讨论】:
我可以确认!将target
从uint8_t
更改为uint16_t
(以便严格的别名规则生效)改变了它。使用uint16_t
,负载总是被优化出来。
相关:***.com/questions/16138237/…
改this
的内容不是你的意思(不是变量);你的意思是改变*this
的内容。
@gexicide 介意详细说明如何使用严格的别名来解决问题?【参考方案2】:
严格的别名规则允许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*
或void*
时,一定要在写入之前将其缓存在局部变量中?
实际上是当你使用char*
时,不需要作为会员。【参考方案3】:
这里的问题是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;
如果您使用了其他非字符类型,那么编译器应该能够进行优化。
C++ 标准草案3.10
Lvalues and rvalues 对此进行了介绍:
如果一个程序试图通过 Glvalue 访问一个对象的存储值,而不是其中一个 以下类型的行为未定义
并包括以下项目符号:
char 或 unsigned char 类型。
注意,我在一个问题中发布了 comment on possible work arounds,该问题询问 uint8_t ≠ unsigned char 何时? 建议是:
不过,简单的解决方法是使用 restrict 关键字,或者 将指针复制到一个从未使用过地址的局部变量中 那编译器不需要担心uint8_t是否 对象可以给它起别名。
由于 C++ 不支持 restrict 关键字,您必须依赖编译器扩展,例如 gcc uses __restrict__,所以这不是完全可移植的,但其他建议应该是。
【讨论】:
这是一个例子,标准对于优化器来说比一个规则更糟糕的地方允许编译器假设在两次访问 T 类型的对象之间,或者这样的访问和在它发生的循环/函数的开始或结束时,对存储的所有访问都将使用相同的对象对象。这样的规则将消除对“字符类型异常”的需要,该异常会破坏使用字节序列的代码的性能。以上是关于使用此指针会导致热循环中出现奇怪的反优化的主要内容,如果未能解决你的问题,请参考以下文章