C#小函数的性能
我的一位同事一直在阅读Robert C Martin编写的干净代码,并进入了关于使用许多小函数而不是较少使用大函数的部分。这导致了关于这种方法的性能后果的争论。因此,我们编写了一个快速程序来测试性能,结果让我们感到困惑 首先,这里是函数的正常版本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
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%,而不管操作的顺序如何。有趣的是,您再次证明了这一点。我对编译器优化的信心正在慢慢减弱。这确实让两人重新站到了一起,但我