在c ++中优化对成员的访问

时间:2010-10-14 07:00:48

标签: c++ visual-c++ optimization g++

对于以下代码,我遇到了与不同编译器不一致的优化行为:

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,运行时将等于函数版本。


alt text

分辨率

对答案感到失望(抱歉的家伙),我摆脱了懒惰,潜入了这段代码的反汇编列表中。我的answer below总结了调查结果。简而言之:它与别名无关,它与循环展开有关,并且在决定要展开哪个循环时会应用一些奇怪的启发式MSVC。

7 个答案:

答案 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的风险,所以szarr可以完全合法地进行别名,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关键字在将来有用。

http://developers.sun.com/solaris/articles/cc_restrict.html

答案 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}指向的任何内容而且,直到函数结束时才更新成员变量,等同于整个函数中未优化的版本更新。这可能比你想象的要复杂得多,特别是如果你考虑到并发性。