C#小函数的性能

C#小函数的性能,c#,performance,compilation,roslyn,jit,C#,Performance,Compilation,Roslyn,Jit,我的一位同事一直在阅读Robert C Martin编写的干净代码,并进入了关于使用许多小函数而不是较少使用大函数的部分。这导致了关于这种方法的性能后果的争论。因此,我们编写了一个快速程序来测试性能,结果让我们感到困惑 首先,这里是函数的正常版本 static double NormalFunction() { double a = 0; for (int j = 0; j < s_OuterLoopCount; ++j) { for (int i

我的一位同事一直在阅读Robert C Martin编写的干净代码,并进入了关于使用许多小函数而不是较少使用大函数的部分。这导致了关于这种方法的性能后果的争论。因此,我们编写了一个快速程序来测试性能,结果让我们感到困惑

首先,这里是函数的正常版本

static double NormalFunction()
{
    double a = 0;
    for (int j = 0; j < s_OuterLoopCount; ++j)
    {
        for (int i = 0; i < s_InnerLoopCount; ++i)
        {
            double b = i * 2;
            a = a + b + 1;
        }
    }
    return a;
}
这些结果对我来说很有意义,尤其是在调试中,因为函数调用会增加额外的开销。当我在发行版中运行它时,我会得到以下结果

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 377 ms;
TinyFunctions Time = 1322 ms;
s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 173 ms;
TinyFunctions Time = 98 ms;
这些结果让我很困惑,即使编译器通过排列所有函数调用来优化TinyFunction,这怎么能使它快57%呢

我们已经尝试在NormalFunction中移动变量声明,但它基本上不会影响运行时

我希望有人知道发生了什么,如果编译器能够很好地优化TinyFunction,为什么它不能对NormalFunction应用类似的优化呢

static double NormalFunction()
{
    double a = 0;
    for (int j = 0; j < s_OuterLoopCount; ++j)
    {
        for (int i = 0; i < s_InnerLoopCount; ++i)
        {
            double b = i * 2;
            a = b + 1 + a;
        }
    }
    return a;
}
环顾四周,我们发现有人提到,分解函数可以让JIT更好地优化寄存器中的内容,但普通函数只有4个变量,因此我很难相信这可以解释巨大的性能差异

如果有人能提供任何见解,我将不胜感激

更新1 正如Kyle在下文中指出的,改变操作顺序对NormalFunction的性能产生了巨大的影响

static double NormalFunction()
{
    double a = 0;
    for (int j = 0; j < s_OuterLoopCount; ++j)
    {
        for (int i = 0; i < s_InnerLoopCount; ++i)
        {
            double b = i * 2;
            a = b + 1 + a;
        }
    }
    return a;
}
这比我预期的要多,但仍然留下了一个问题,即为什么操作顺序会对性能造成约56%的影响

此外,我还尝试了整数运算,我们又回到了毫无意义的状态

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 87 ms;
TinyFunctions Time = 52 ms;

无论操作顺序如何,这一点都不会改变。

我可以通过更改一行代码来更好地匹配性能:

a = a + b + 1;
将其更改为:

a = b + 1 + a;
或:

现在您会发现
NormalFunction
实际上可能稍微快一点,您可以通过将
Double
方法的签名更改为:

int Double( int a ) { return a * 2; }
我想到这些变化是因为这是两个实现之间的不同之处。在这之后,它们的性能非常相似,
TinyFunctions
慢了几个百分点(如预期的那样)

第二个变化很容易解释:
NormalFunction
实现实际上将
int
加倍,然后将其转换为
double
(在机器代码级别使用
fild
操作码)。最初的
Double
方法首先加载
Double
,然后将其加倍,我希望这会稍微慢一点

但这并不能解释运行时差异的大部分。这几乎完全是因为我首先改变了订单。为什么?我真的不知道。机器代码的差异如下所示:

Original                                                    Changed
01070620  push        ebp                                   01390620  push        ebp  
01070621  mov         ebp,esp                               01390621  mov         ebp,esp  
01070623  push        edi                                   01390623  push        edi  
01070624  push        esi                                   01390624  push        esi  
01070625  push        eax                                   01390625  push        eax  
01070626  fldz                                              01390626  fldz  
01070628  xor         esi,esi                               01390628  xor         esi,esi  
0107062A  mov         edi,dword ptr ds:[0FE43ACh]           0139062A  mov         edi,dword ptr ds:[12243ACh]  
01070630  test        edi,edi                               01390630  test        edi,edi  
01070632  jle         0107065A                              01390632  jle         0139065A  
01070634  xor         edx,edx                               01390634  xor         edx,edx  
01070636  mov         ecx,dword ptr ds:[0FE43B0h]           01390636  mov         ecx,dword ptr ds:[12243B0h]  
0107063C  test        ecx,ecx                               0139063C  test        ecx,ecx  
0107063E  jle         01070655                              0139063E  jle         01390655  
01070640  mov         eax,edx                               01390640  mov         eax,edx  
01070642  add         eax,eax                               01390642  add         eax,eax  
01070644  mov         dword ptr [ebp-0Ch],eax               01390644  mov         dword ptr [ebp-0Ch],eax  
01070647  fild        dword ptr [ebp-0Ch]                   01390647  fild        dword ptr [ebp-0Ch]  
0107064A  faddp       st(1),st                              0139064A  fld1  
0107064C  fld1                                              0139064C  faddp       st(1),st  
0107064E  faddp       st(1),st                              0139064E  faddp       st(1),st  
01070650  inc         edx                                   01390650  inc         edx  
01070651  cmp         edx,ecx                               01390651  cmp         edx,ecx  
01070653  jl          01070640                              01390653  jl          01390640  
01070655  inc         esi                                   01390655  inc         esi  
01070656  cmp         esi,edi                               01390656  cmp         esi,edi  
01070658  jl          01070634                              01390658  jl          01390634  
0107065A  pop         ecx                                   0139065A  pop         ecx  
0107065B  pop         esi                                   0139065B  pop         esi  
0107065C  pop         edi                                   0139065C  pop         edi  
0107065D  pop         ebp                                   0139065D  pop         ebp  
0107065E  ret                                               0139065E  ret  
除了浮点运算的顺序外,操作码的操作码是相同的。这会造成巨大的性能差异,但我对x86浮点运算了解不够,无法确切了解原因

更新: 在新的整数版本中,我们看到了一些奇怪的东西。在这种情况下,JIT似乎试图变得更聪明,并应用优化,因为它改变了这一点:

int b = 2 * i;
a = a + b + 1;
变成类似于:

mov esi, eax              ; b = i
add esi, esi              ; b += b
lea ecx, [ecx + esi + 1]  ; a = a + b + 1
mov         eax, edx  
add         eax, eax  
inc         eax  
add         ecx, eax  
其中
a
存储在
ecx
寄存器中,
eax
中的
i
,以及
esi中的
b

TinyFunctions
版本则变成了:

mov esi, eax              ; b = i
add esi, esi              ; b += b
lea ecx, [ecx + esi + 1]  ; a = a + b + 1
mov         eax, edx  
add         eax, eax  
inc         eax  
add         ecx, eax  
其中
i
edx
中,
b
eax
中,
a
ecx

我认为对于我们的CPU架构来说,这种LEA“技巧”(解释)最终比仅仅使用ALU本身要慢。仍然可以更改代码以获得两者之间的性能:

int b = 2 * i + 1;
a += b;

这最终迫使
NormalFunction
方法变成
mov,add,inc,add
,正如
TinyFunctions
方法中所显示的那样。

可能是因为在发布模式下,可以通过内联优化小功能。此外,这也可能是差异的一部分。优化“NormalFunction”更难,因为编译器更难识别可能的优化。这可以解释为什么TinyFunction可以和NormalFunction一样快,但它没有向我解释它是如何执行得更快的。因为NormalFunctions几乎是TinyFunctions的一个预内联版本,我猜这可能是两个加法得到了不同的处理,尝试使用a=a+(b+1)的Normal;了解情况的一种方法是查看使用ILSpypage生成的IL代码eric lippert或jon skeetI已经证实了这一点,感谢您指出。这可能同样令人困惑,为什么这么小的更改会导致如此大的性能差异?为什么编译器不对此进行优化?@Pumices我猜它没有优化它有两个原因:首先,如果这是一个“已知”的优化,我会感到惊讶。我对FPU了解不多,但我无法想象这样切换2个操作码的顺序会导致如此巨大的性能差异。第二,我认为它不应该改变评估的顺序,因为在其他情况下,可能会导致行为的改变(例如灾难性取消或其他不寻常的取整问题)。好的,只是在这个问题上再添一条皱纹,我尝试将所有内容移到整数上,发现操作开关对函数的运行时间没有影响,更糟糕的是,TinyFunction现在再次提高了约56%,而不管操作的顺序如何。有趣的是,您再次证明了这一点。我对编译器优化的信心正在慢慢减弱。这确实让两人重新站到了一起,但我