代码之家  ›  专栏  ›  技术社区  ›  Eli Bendersky

C++中成员访问的优化

  •  15
  • Eli Bendersky  · 技术社区  · 14 年前

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

    等级测试仪
    {
    公众:
    测试仪(int*arr_uuu,int sz_u)
    :arr(arr_uu),sz(sz_u)
    {}
    
    In doDad()
    {
    Sm=0;
    对于(int n=0;n<1000;+n)
    {
    对于(int i=0;i<sz;++i)
    {
    SM+=ARR〔I〕;
    }
    }
    返回SM;
    }
    受保护的:
    It*ARR;
    国际标准化组织;
    国际标准化组织;
    };
    < /代码> 
    
    

    doadd函数模拟对成员的一些密集访问(对于这个问题,另外忽略溢出)。与作为函数实现的类似代码相比:

    int arrad(int*arr,int sz)
    {
    int SM=0;
    对于(int n=0;n<1000;+n)
    {
    对于(int i=0;i<sz;++i)
    {
    SM+=ARR〔I〕;
    }
    }
    返回SM;
    }
    < /代码> 
    
    <> >在VisualC++ 2008发布模式下编译时,<强> <代码> doAd/<代码>方法的运行速度比<代码> ARADAD< /COD>函数>慢约1.5倍。当我修改doadd方法时,方法如下(用局部变量给所有成员别名):。

    int doadd()。
    {
    int MySM=0;
    int*myarr=arr;
    In Mysz=SZ;
    对于(int n=0;n<1000;+n)
    {
    对于(int i=0;i<mysz;++i)
    {
    mysm+=myarr[i];
    }
    }
    SM=MYSM;
    返回SM;
    }
    < /代码> 
    
    

    运行时间大致相同。我是正确的结论,这是一个缺少优化的Visual C++编译器?g++似乎做得更好,当使用-o2进行编译时,以相同的速度运行成员函数和正常函数。


    基准测试是通过调用一些足够大的数组(大小为数百万个整数)上的doaddmember andarraddfunction来完成的。


    edit:some fine-grained testing shows that the main criminator is thesmmember.用本地版本替换所有其他版本仍然会使运行时很长,但一旦我替换了smbymysmruntime就等于函数版本。


    分辨率

    对于答案(抱歉,伙计们),我不再关注我的懒惰,转而关注这段代码的反汇编清单。myanswer belowsummarized the findings.简而言之:它与混叠无关,它与循环展开有关,并且与一些奇怪的启发式方法MSVC一起在决定要展开哪个循环时应用。

    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;
    }
    

    这个杜德方法的运行速度比arradd功能用Visual C++ 2008在发布模式下编译。当我修改杜德方法如下(将所有成员别名为局部变量):

    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.


    基准测试是通过调用杜德成员和阿拉德对一些足够大的数组(大小为数百万个整数)执行函数。


    编辑:一些细粒度测试表明,主要的罪魁祸首是sm成员。用本地版本替换所有其他版本仍然会使运行时很长,但一旦我替换通过mysm运行时将等于函数版本。


    alt text

    分辨率

    对于答案(抱歉,伙计们),我不再关注我的懒惰,转而关注这段代码的反汇编清单。我的answer below总结研究结果。简而言之:它与混叠无关,它与循环展开有关,并且在决定展开哪个循环时,MSVC应用了一些奇怪的启发式方法。

    7 回复  |  直到 14 年前
        1
  •  5
  •   Paul R    14 年前

    这可能是一个别名问题-编译器不知道实例变量 sm 永远不会被指向 arr 所以必须治疗 好像它是有效的易失性的,并在每次迭代中保存它。你可以做 一个不同的类型来检验这个假设。或者只使用一个临时的本地和(将缓存在寄存器中)并将其分配给 最后。

        2
  •  3
  •   Stack Overflow is garbage    14 年前

    MSVC是正确的,因为它是唯一一个,考虑到我们所看到的代码,能够保证正确工作的代码。GCC使用的优化可能是安全的 在这个特定的实例中 ,但这只能通过查看更多程序来验证。

    因为 sm 不是局部变量,msvc显然假定它可能 arr . 这是一个相当合理的假设:因为 ARR 是受保护的,派生类可能会将其设置为指向 如此 ARR 能够 别名 .

    GCC发现它实际上没有别名 ARR 所以它不写 回到内存,直到循环结束后,这就快多了。

    当然可以实例化类,以便 ARR 指向 ,MSVC会处理,但GCC不会。

    假设 sz > 1 一般情况下,GCCS优化是允许的。

    因为函数循环 ARR ,将其视为 sz 元素,使用 深圳1 是否会产生未定义的行为 ARR 别名 因此GCC可以安全地假设 不要 别名。但是如果 sz == 1 或者如果编译器不能确定 深圳 其价值可能是,那么它就冒着 深圳 可以 1岁,等等 ARR 可以完全合法地使用别名,而gcc的代码会被破坏。

    所以,最有可能的是,海湾合作委员会只是简单地通过将整个事情纳入其中,并看到了这一点而逃脱了惩罚。 在这种情况下 他们没有化名。

        3
  •  2
  •   Eli Bendersky    14 年前

    我用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 以下地址的说明,以及 ECX 在循环中一次前进16个字节(4个字)。

    对于未优化的成员函数版本, 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) 
    

    注意2件事:

    • 总和存储在寄存器中- edi . 因此, 这里没有“小心”的别名 . 价值 sm 不是一直在重读。 电子数据交换 仅初始化一次,然后用作临时。你看不到它的返回,因为编译器优化了它并使用了 电子数据交换 直接作为内联代码的返回值。
    • 这个 循环未展开。 为什么?没有好的理由。

    最后,这里是成员函数的“优化”版本, 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;
    }
    

    (同样,内联)拆卸是:

        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) 
    

    这里的循环是展开的,但只有x2个。

    这很好地解释了我的速度差观测。对于175e6数组,函数运行~1.2秒,未优化成员运行~1.5秒,优化成员运行~1.3秒。(请注意,这可能对您有所不同,在另一台机器上,所有3个版本的运行时间都比较接近)。

    海湾合作委员会呢?当用它编译时,所有3个版本都以大约1.5秒的速度运行。我怀疑没有展开 gcc 的拆卸,事实上: GCC没有展开任何版本 .

        4
  •  1
  •   Gregory    14 年前

    正如Paul所写,这可能是因为sm成员每次在“真实”内存中都会被更新,同时函数中的局部摘要可以在寄存器变量中累积(在编译器优化之后)。

        5
  •  0
  •   spraff    14 年前

    当传入指针参数时,可能会遇到类似的问题。如果你喜欢把手弄脏,你会发现 restrict 关键字在将来很有用。

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

        6
  •  0
  •   kriss    14 年前

    这根本不是同一个代码。如果您将sm、arr和sz变量放在类中,而不是将主题设置为本地的,编译器就不能(很容易)猜测其他类不会继承自 test 类并希望访问这些成员,执行类似“arr=&sm;doadd();”的操作。从此以后,对这些变量的访问就不能像在局部运行时那样优化了。

    最后,原因基本上是保罗指出的,SM在使用类成员时在实际内存中更新,在函数中可以存储在寄存器中。从add读取的内存不应该改变结果时间太多,因为必须读取memomry才能获得值。

    在这种情况下,如果测试没有导出到另一个模块,并且没有别名,即使是间接地导出到某个模块,并且没有类似上面的别名。编译器可以优化对sm的中间写入…一些编译器(如gcc)似乎已经足够积极地进行了优化,以检测上述情况(如果导出测试类,是否也会这样做)。但这些都是很难猜测的。编译器还没有执行更简单的优化(比如通过不同的编译单元进行内联)。

        7
  •  0
  •   AshleysBrain    14 年前

    关键可能是 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;
    }
    

    问题就在这里:所有的类成员都可以通过 指针,而 arradd 在堆栈中包含所有变量。为了加快速度,您发现通过将所有成员作为局部变量移动到堆栈中,速度会匹配 阿拉德 . 所以这表明 间接性是造成性能损失的原因。

    为什么会这样?据我所知 通常存储在一个寄存器中,所以我认为它最终不会比访问堆栈慢(这也是堆栈指针的偏移量)。正如其他答案所指出的,可能是别名问题产生了不太理想的代码:编译器无法判断是否有内存地址重叠。更新 sm 理论上也可以改变 arr 因此,它决定写出 每次主存储器而不是在寄存器中跟踪它。当变量在堆栈上时,编译器可以 假定 它们都在不同的内存地址。编译器并不像您那样清楚地看到程序:它可以分辨堆栈上的内容(因为您是这样声明的),但其他所有内容都只是任意的内存地址,可以是任何内容、任何位置、重叠任何其他指针。

    我并不惊讶你问题中的优化(使用局部变量)没有实现——不仅编译器必须证明 ARR 不与指向的任何内容重叠 ,但在函数结束之前不更新成员变量,这等同于在整个函数中更新未优化的版本。这比您想象的要复杂得多,尤其是考虑到并发性时。