对于以下代码,我遇到了与不同编译器不一致的优化行为:
class tester
{
public:
tester(int* arr_, int sz_)
: arr(arr_), sz(sz_)
{}
int doadd()
{
sm = 0;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < sz; ++i)
{
sm += arr[i];
}
}
return sm;
}
protected:
int* arr;
int sz;
int sm;
};
doadd
函数模拟对成员的一些密集访问(忽略此问题的溢出)。与作为函数实现的类似代码相比:
int arradd(int* arr, int sz)
{
int sm = 0;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < sz; ++i)
{
sm += arr[i];
}
}
return sm;
}
在使用Visual C ++ 2008在发布模式下编译时, doadd
方法比arradd
函数运行慢约1.5倍。当我修改doadd
方法时如下(别名所有成员与当地人):
int doadd()
{
int mysm = 0;
int* myarr = arr;
int mysz = sz;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < mysz; ++i)
{
mysm += myarr[i];
}
}
sm = mysm;
return sm;
}
运行时变得大致相同。我是否正确地认为这是Visual C ++编译器缺少的优化? g++
似乎做得更好,在使用-O2
或-O3
进行编译时,以相同的速度运行成员函数和普通函数。
基准测试是通过在一些足够大的数组(几百万个整数)上调用doadd
成员和arradd
函数来完成的。
编辑:一些细粒度的测试表明,罪魁祸首是sm
成员。用本地版本替换所有其他版本仍会使运行时变长,但是一旦我将sm
替换为mysm
,运行时将等于函数版本。
对答案感到失望(抱歉的家伙),我摆脱了懒惰,潜入了这段代码的反汇编列表中。我的answer below总结了调查结果。简而言之:它与别名无关,它与循环展开有关,并且在决定要展开哪个循环时会应用一些奇怪的启发式MSVC。
答案 0 :(得分:5)
这可能是一个别名问题 - 编译器无法知道实例变量sm
永远不会被arr
指向,所以它必须将sm
视为好像有效地挥发,并在每次迭代时保存。您可以使sm
成为另一种类型来测试此假设。或者只使用一个临时的本地总和(它将被缓存在一个寄存器中)并在最后将它分配给sm
。
答案 1 :(得分:3)
MSVC是正确的,因为它是唯一一个,鉴于我们看到的代码,保证正常工作。 GCC在这个特定实例中采用了可能安全的优化,但只能通过查看更多程序来验证。
由于sm
不是局部变量,因此MSVC显然假设它可能是别名arr
。
这是一个相当合理的假设:因为arr
受到保护,派生类可能会将其设置为指向sm
,因此arr
可以别名sm
GCC发现它实际上没有别名arr
,因此它不会在循环之后将sm
写回内存,这要快得多。
当然可以实例化类,以便arr
指向sm
,MSVC会处理,但GCC不会。
假设sz > 1
,GCC优化通常是允许的。
因为函数循环遍历arr
,将其视为sz
元素的数组,使用sz > 1
调用函数将产生未定义的行为,无论arr
是否别名{ {1}},因此GCC可以安全地假设他们不别名。但是如果sm
,或者编译器无法确定sz == 1
的值是什么,那么它会冒sz
可能为1的风险,所以sz
和arr
可以完全合法地进行别名,GCC的代码会破坏。
所以很有可能,GCC只是通过内联整个内容来逃避它,并且在这种情况下看到 ,它们不会别名。
答案 2 :(得分:2)
我用MSVC反汇编代码,以便更好地了解正在发生的事情。结果是混叠根本不是问题,也不是某种偏执的线程安全。
以下是arradd
函数的有趣部分:
for (int n = 0; n < 10; ++n)
{
for (int i = 0; i < sz; ++i)
013C101C mov ecx,ebp
013C101E mov ebx,29B9270h
{
sm += arr[i];
013C1023 add eax,dword ptr [ecx-8]
013C1026 add edx,dword ptr [ecx-4]
013C1029 add esi,dword ptr [ecx]
013C102B add edi,dword ptr [ecx+4]
013C102E add ecx,10h
013C1031 sub ebx,1
013C1034 jne arradd+23h (13C1023h)
013C1036 add edi,esi
013C1038 add edi,edx
013C103A add eax,edi
013C103C sub dword ptr [esp+10h],1
013C1041 jne arradd+16h (13C1016h)
013C1043 pop edi
013C1044 pop esi
013C1045 pop ebp
013C1046 pop ebx
ecx
指向数组,我们可以看到内部循环展开x4此处 - 请注意来自以下地址的四个连续add
指令,并{{ 1}}在循环内一次提前16个字节(4个字)。
对于未优化的成员函数版本ecx
:
doadd
反汇编是(由于编译器将其内联到int tester::doadd()
{
sm = 0;
for (int n = 0; n < 10; ++n)
{
for (int i = 0; i < sz; ++i)
{
sm += arr[i];
}
}
return sm;
}
中,因此很难找到):
main
注意事项:
int tr_result = tr.doadd();
013C114A xor edi,edi
013C114C lea ecx,[edi+0Ah]
013C114F nop
013C1150 xor eax,eax
013C1152 add edi,dword ptr [esi+eax*4]
013C1155 inc eax
013C1156 cmp eax,0A6E49C0h
013C115B jl main+102h (13C1152h)
013C115D sub ecx,1
013C1160 jne main+100h (13C1150h)
。因此,这里没有别名“关注”。 edi
的值不会一直重读。 sm
只被初始化一次,然后用作临时的。您没有看到它的返回,因为编译器对其进行了优化,并直接使用edi
作为内联代码的返回值。最后,这是成员函数的“优化”版本,edi
手动保持本地总和:
mysm
(再次,内联)反汇编是:
int tester::doadd_opt()
{
sm = 0;
int mysm = 0;
for (int n = 0; n < 10; ++n)
{
for (int i = 0; i < sz; ++i)
{
mysm += arr[i];
}
}
sm = mysm;
return sm;
}
这里的循环展开,但只是x2。
这很好地解释了我的速度差异观察结果。对于175e6阵列,该功能运行约1.2秒,未优化的成员约1.5秒,优化的成员约1.3秒。 (请注意,这可能与您不同,在另一台机器上,我为所有3个版本运行时间更接近)。
gcc怎么样?使用它编译时,所有3个版本的运行时间约为1.5秒。怀疑没有展开我看了 int tr_result_opt = tr_opt.doadd_opt();
013C11F6 xor edi,edi
013C11F8 lea ebp,[edi+0Ah]
013C11FB jmp main+1B0h (13C1200h)
013C11FD lea ecx,[ecx]
013C1200 xor ecx,ecx
013C1202 xor edx,edx
013C1204 xor eax,eax
013C1206 add ecx,dword ptr [esi+eax*4]
013C1209 add edx,dword ptr [esi+eax*4+4]
013C120D add eax,2
013C1210 cmp eax,0A6E49BFh
013C1215 jl main+1B6h (13C1206h)
013C1217 cmp eax,0A6E49C0h
013C121C jge main+1D1h (13C1221h)
013C121E add edi,dword ptr [esi+eax*4]
013C1221 add ecx,edx
013C1223 add edi,ecx
013C1225 sub ebp,1
013C1228 jne main+1B0h (13C1200h)
的反汇编,确实: gcc没有展开任何版本。
答案 3 :(得分:1)
正如Paul所写,这可能是因为sm成员每次都在“真实”内存中更新,同时函数中的局部摘要可以在寄存器变量中累积(在编译器优化之后)。
答案 4 :(得分:0)
传入指针参数时可能会遇到类似的问题。如果您喜欢亲自动手,可能会发现restrict
关键字在将来有用。
答案 5 :(得分:0)
这根本不是完全相同的代码。如果你把sm,arr和sz变量放在类中而不是使本地主题,编译器不能(轻易)猜测其他类不会从test
类继承并希望访问这些成员,做一些像'arr =&amp; sm;的doAdd();.从此以后,无法优化对这些变量的访问,因为它们可以在本地运行时进行优化。
最终原因基本上是Paul指出的一个,当使用类成员时sm在实内存中更新,在函数中可以存储在寄存器中。来自add的内存读取不应该导致很多时间,因为无论如何都必须读取memomry来获取值。
在这种情况下,如果测试未导出到另一个模块,并且没有间接别名导出到导出的内容,并且没有像上面那样的别名。编译器可以优化对sm的中间写入...像gcc这样的一些编译器似乎足够积极地进行优化以检测上述情况(如果导出测试类,它也是如此)。但这些都是非常难以猜测的。编译器尚未执行更简单的优化(例如通过不同的编译单元进行内联)。
答案 6 :(得分:0)
如果使用doadd
使成员访问显式,那么关键可能是this
是这样编写的:
int doadd()
{
this->sm = 0;
for (int n = 0; n < 1000; ++n)
{
for (int i = 0; i < this->sz; ++i)
{
this->sm += this->arr[i];
}
}
return this->sm;
}
存在的问题是:所有类成员都通过this
指针访问,而arradd
包含堆栈上的所有变量。为了加快速度,您发现通过将所有成员作为局部变量移动到堆栈,速度将匹配arradd
。因此,这表明this
间接负责性能损失。
为什么会这样?据我所知,this
通常存储在一个寄存器中,所以我认为它最终不会只是访问堆栈(这也是堆栈指针的偏移量)。正如其他答案所指出的那样,可能是混叠问题会产生不太理想的代码:编译器无法判断是否有任何内存地址重叠。更新sm
理论上也可以更改arr
的内容,因此它决定每次都将sm
的值写入主内存,而不是在寄存器中跟踪它。当变量在堆栈上时,编译器可以假设它们全部位于不同的内存地址。编译器没有像你那样清楚地看到程序:它可以告诉堆栈上的内容(因为你声明它是这样的),但其他一切都只是任意的内存地址,可以是任何地方,任何地方,重叠任何其他指针。
我对你的问题(使用局部变量)的优化没有感到惊讶 - 不仅编译器必须证明arr
的内存不会重叠{{1}指向的任何内容而且,直到函数结束时才更新成员变量,等同于整个函数中未优化的版本更新。这可能比你想象的要复杂得多,特别是如果你考虑到并发性。