C# 在小函数上的性能
C# Performance on Small Functions
我的一位同事一直在阅读 Robert C Martin 的 Clean Code,并读到关于使用许多小函数而不是更少的大函数的部分。这引发了关于这种方法的性能结果的争论。所以我们写了一个快速程序来测试性能,并对结果感到困惑。
对于初学者来说,这里是函数的普通版本。
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;
}
这是我制作的将功能分解成小功能的版本。
static double TinyFunctions()
{
double a = 0;
for (int i = 0; i < s_OuterLoopCount; i++)
{
a = Loop(a);
}
return a;
}
static double Loop(double a)
{
for (int i = 0; i < s_InnerLoopCount; i++)
{
double b = Double(i);
a = Add(a, Add(b, 1));
}
return a;
}
static double Double(double a)
{
return a * 2;
}
static double Add(double a, double b)
{
return a + b;
}
我使用秒表 class 为函数计时,当我 运行 在调试时得到以下结果。
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;
这些结果让我感到困惑,即使编译器正在通过内联所有函数调用来优化 TinyFunctions,这怎么能让它快 57%?
我们已经尝试在 NormalFunctions 中移动变量声明,它对 运行 时间基本上没有影响。
我希望有人知道发生了什么,如果编译器可以很好地优化 TinyFunctions,为什么它不能对 NormalFunction 应用类似的优化。
环顾四周,我们发现有人提到,分解函数可以让 JIT 更好地优化放入寄存器的内容,但 NormalFunctions 只有 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;
}
以下是此配置的结果。
s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 91 ms;
TinyFunctions Time = 102 ms;
这超出了我的预期,但仍然存在一个问题,即为什么操作顺序会对性能造成约 56% 的影响。
此外,我随后用整数运算尝试了它,我们又回到没有任何意义。
s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 87 ms;
TinyFunctions Time = 52 ms;
无论操作顺序如何,这都不会改变。
我可以通过更改一行代码使性能匹配得更好:
a = a + b + 1;
改为:
a = b + 1 + a;
或者:
a += b + 1;
现在您会发现 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
其中a
存放在ecx
寄存器,i
存放在eax
,b
存放在esi
.
而 TinyFunctions
版本变成了这样的东西:
mov eax, edx
add eax, eax
inc eax
add ecx, eax
这次 i
在 edx
,b
在 eax
,a
在 ecx
。
我想对于我们的 CPU 架构来说,这个 LEA“技巧”(已解释 here)最终比仅使用 ALU 本身要慢。还是可以改代码让两者的性能对齐:
int b = 2 * i + 1;
a += b;
这最终会迫使 NormalFunction
方法最终变成 mov, add, inc, add
,因为它出现在 TinyFunctions
方法中。
我的一位同事一直在阅读 Robert C Martin 的 Clean Code,并读到关于使用许多小函数而不是更少的大函数的部分。这引发了关于这种方法的性能结果的争论。所以我们写了一个快速程序来测试性能,并对结果感到困惑。
对于初学者来说,这里是函数的普通版本。
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;
}
这是我制作的将功能分解成小功能的版本。
static double TinyFunctions()
{
double a = 0;
for (int i = 0; i < s_OuterLoopCount; i++)
{
a = Loop(a);
}
return a;
}
static double Loop(double a)
{
for (int i = 0; i < s_InnerLoopCount; i++)
{
double b = Double(i);
a = Add(a, Add(b, 1));
}
return a;
}
static double Double(double a)
{
return a * 2;
}
static double Add(double a, double b)
{
return a + b;
}
我使用秒表 class 为函数计时,当我 运行 在调试时得到以下结果。
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;
这些结果让我感到困惑,即使编译器正在通过内联所有函数调用来优化 TinyFunctions,这怎么能让它快 57%?
我们已经尝试在 NormalFunctions 中移动变量声明,它对 运行 时间基本上没有影响。
我希望有人知道发生了什么,如果编译器可以很好地优化 TinyFunctions,为什么它不能对 NormalFunction 应用类似的优化。
环顾四周,我们发现有人提到,分解函数可以让 JIT 更好地优化放入寄存器的内容,但 NormalFunctions 只有 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;
}
以下是此配置的结果。
s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 91 ms;
TinyFunctions Time = 102 ms;
这超出了我的预期,但仍然存在一个问题,即为什么操作顺序会对性能造成约 56% 的影响。
此外,我随后用整数运算尝试了它,我们又回到没有任何意义。
s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 87 ms;
TinyFunctions Time = 52 ms;
无论操作顺序如何,这都不会改变。
我可以通过更改一行代码使性能匹配得更好:
a = a + b + 1;
改为:
a = b + 1 + a;
或者:
a += b + 1;
现在您会发现 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
其中a
存放在ecx
寄存器,i
存放在eax
,b
存放在esi
.
而 TinyFunctions
版本变成了这样的东西:
mov eax, edx
add eax, eax
inc eax
add ecx, eax
这次 i
在 edx
,b
在 eax
,a
在 ecx
。
我想对于我们的 CPU 架构来说,这个 LEA“技巧”(已解释 here)最终比仅使用 ALU 本身要慢。还是可以改代码让两者的性能对齐:
int b = 2 * i + 1;
a += b;
这最终会迫使 NormalFunction
方法最终变成 mov, add, inc, add
,因为它出现在 TinyFunctions
方法中。