C++成员访问优化

15

我在以下代码中遇到了不同编译器的优化行为不一致问题:

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

doadd 方法在使用 Visual C++ 2008 的 Release 模式编译时运行速度比 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

解决方案

对于这段代码,我对答案感到失望(抱歉各位),因此我摆脱了懒惰并深入研究了反汇编列表。我的下面的答案总结了发现。简而言之:这与别名无关,而与循环展开有关,以及MSVC在决定展开哪个循环时应用的一些奇怪启发式。


你的 tester 实例是在堆上分配的吗? - ereOn
可能是因为缓存的原因吗?你是否以不同的顺序调用它们了? - ruslik
@eeOn:不,在main函数的堆栈中。 - Eli Bendersky
@ruslik:是的,顺序不同,而且我还从不同的.exe实例(彼此独立)中调用它们。此外,这是一个100兆数组,很难缓存... - Eli Bendersky
6
你是否已经查看了生成的汇编语言,而不是仅仅计时和猜测?我假设你并非如此。 - Mike Dunlavey
同意Mike的观点。除非你查看汇编代码,否则任何猜测都是毫无意义的。(而且汇编比写作容易得多。) - BCS
7个回答

5
可能是别名问题 - 编译器无法知道实例变量sm永远不会被arr指向,因此必须将sm视为有效的易失性变量,并在每次迭代中保存它。您可以使用不同类型的sm来测试这个假设。或者只需使用临时本地总和(将在寄存器中缓存),并在最后将其分配给sm

我认为问题可能不仅仅是 sm 的访问,也可能与 arr 的访问有关。此外,为什么 g++ 对其进行了优化呢?它是否冒着这样的优化带来的风险? - Eli Bendersky
@Eli:你可能是对的,这可能只是MSVC太蠢了——我只是想指出代码存在潜在的别名问题。如果你有时间和意愿,可以分别测试每个因素,即仅缓存“sm”,仅缓存“arr”,仅缓存“sz”,然后再测试各种组合。了解哪些因素会产生影响将是有益的。 - Paul R
“sm”确实是主要问题。但我不明白哪个编译器出了问题——是“g++”还是“msvc”。 - Eli Bendersky
@Eli:这可能取决于每个编译器对别名的默认态度 - 尝试使用 gcc -no-fstrict-aliasing,看看是否会将 gcc 的性能拖到 MSVC 的水平。当然,也可能只是因为 gcc 更聪明。;-) - Paul R
2
@Eli: 尽管它可能会有所帮助,但你并没有发布汇编代码。我猜你甚至都没看过它。“为什么g++会对其进行优化”谁说了这句话?也许g++没有优化两个函数,因此性能是相同的。 - Yakov Galka

3

MSVC是正确的,因为它是唯一可以保证给定我们看到的代码能够正确工作的编译器。GCC使用了优化,这些优化在这个特定的实例中可能是安全的,但只有通过查看程序的更多内容才能验证。

因为sm不是一个本地变量,所以MSVC显然认为它可能与arr重名。这是一个相当合理的假设:因为arr受到保护,派生类可能会将其设置为指向sm,因此arr可能与sm重名。

GCC发现它实际上没有与arr重名,因此它直到循环之后才将sm写回内存,这样速度就快得多。

当然,可以实例化这个类,使arr指向sm,这样MSVC会处理,但GCC不会。

假设sz > 1,则通常情况下,GCC的优化是允许的。因为函数循环遍历arr,将其视为sz个元素的数组,无论arr是否与sm重名,调用函数时都会产生未定义的行为,因此GCC可以安全地假设它们不会重名。但是,如果sz == 1,或者编译器无法确定sz的值可能是什么,则存在sz可能为1的风险,因此arrsm可能合法地重名,而GCC的代码将会出错。

因此,最有可能的情况是,GCC通过内联整个过程,并看到在这种情况下,它们不会重名。


看着我的答案,现在感觉一团糟。我做了太多的修改,这就是我得到的结果。不过没关系...至少我现在相信它是正确的。 :) - jalf
为什么“使用sz > 1调用函数会产生未定义行为”? - Eli Bendersky
我的意思是,如果 sz > 1 并且 arr == &sm,那么调用该函数会产生未定义的行为(因为循环将把 sm 视为数组,读取其末尾之后的内容),但是如果保证 sz 大于 1,则无论是否存在别名,都可以使用 GCC 的优化,因为如果它们存在别名,则是未定义的行为,因此优化不会破坏任何东西,如果它们没有别名,则优化是安全的。 - jalf
@jalf 嗯,我觉得你又错了。即使对于 sz > 1,如果你从 tester 派生并添加其他字段,也可能是实现定义的行为。不太确定。 - Yakov Galka
1
@jalf:读取超出sm并不一定是未定义的行为。如果它超出了最派生对象,则是未定义的:struct A : tester { int arr[1000]; }; 使其实现定义。虽然我不知道POD/nonPOD类型的确切标准制定。 - Yakov Galka
显示剩余4条评论

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指令地址和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) 

注意两点:
  • 总和存储在寄存器edi中。因此,这里没有别名"care"。不是一直重新读取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;
}

(再次内联)反汇编如下:
    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) 

这里的循环展开了,但只展开了两次。
这很好地解释了我的速度差异观察结果。对于一个175e6数组,该函数运行约1.2秒,未优化成员运行约1.5秒,优化后的成员运行约1.3秒。(请注意,这可能因计算机不同而有所不同,在另一台计算机上,我得到了更接近的运行时间)。
那么gcc呢?当使用gcc编译时,所有三个版本的运行时间都约为1.5秒。怀疑缺乏展开,我查看了gcc的反汇编代码,确实如此:gcc没有展开任何版本

下次不要急于责怪MSVC。“这里没有采取别名”是因为在内联之后,它看到没有别名。“循环没有展开。为什么?没有充分的理由。”再次强调,不要那么快下结论。也许你是对的,但是MSVC比你和GCC更了解平台。(例如,我花了一些时间才明白为什么它在上面的代码中使用add ebp,1sub而不是inc ebp)。在你的情况下,这可能是因为它只是在内联后不展开循环以防止代码膨胀。 - Yakov Galka

1
正如Paul所写的那样,这可能是因为sm成员在“真实”内存中每次都会被更新,而函数中的本地摘要可以在寄存器变量中累积(经过编译器优化后)。

0
关键可能在于,如果您使用this显式访问成员,则doadd的编写方式如下:
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的内存不会与this指向的任何内容重叠,而且还要证明在函数结束之前不更新成员变量等同于未经优化的版本在整个函数中进行更新。这可能比你想象的更加棘手,特别是如果考虑并发性。

0

或者在微软领域中使用:__declspec(restrict) - spraff
1
@spraff:C++是标准定义的,而不是您特定的编译器扩展。所以DeadMG的评论是正确的。 - Yakov Galka
1
@spraff:那些是C++标准的一部分自从什么时候开始的? - jalf
1
仅仅因为某个东西是编译器扩展并不意味着你不能使用它,同样,原始问题是用C++提出的,并不意味着答案不能用C来回答。 - spraff
放松,伙计们,这个评论本来就只是附带的!OP甚至没有谈论传递的参数,我只是想扩大一下答案。 - spraff

0

这并不是完全相同的代码。如果将sm、arr和sz变量放在类内而不是作为局部变量,编译器就无法(轻易地)猜测是否有其他类会继承test类并希望访问这些成员,执行类似于`arr=&sm doadd();`的操作。因此,当它们是函数的局部变量时,对这些变量的访问不能被优化掉。

最终的原因基本上就是Paul指出的那个,当使用类成员时,sm在实际内存中更新,而在函数中可以存储在寄存器中。从add中读取内存不应该改变结果时间太多,因为必须读取内存才能获取值。

在这种情况下,如果test没有导出到另一个模块,并且没有间接别名到导出的内容,如果没有像上面那样的别名。编译器可以优化对sm的中间写入...一些编译器(如gcc)似乎足够积极地优化检测到上述情况(如果test类也被导出,它也会这样吗)。但这些都是非常难猜测的。仍然有很多更简单的优化尚未被编译器执行(例如通过不同的编译单元进行内联)。


你对“其他线程”的评论听起来有些可疑,抱歉。这岂不意味着所有的优化都必须关闭,以便考虑到不安全的代码访问来自不同线程的“正在进行中”的成员? - Eli Bendersky
你可能是对的。从其他线程的角度来看,我们无法知道doadd在开始、中间还是结束时是否被中断。因此,在编译doadd时考虑这一点肯定是无关紧要的。我将在我的答案中删除有关其他线程的部分。我们现在所剩下的就是没有简单的方法可以确保某个派生自test的类不会执行“arr=&sm; doadd()”。 - kriss

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接