【问题标题】:C# Performance on Small Functions小函数的 C# 性能
【发布时间】:2017-07-18 17:54:47
【问题描述】:

我的一位同事一直在阅读 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;
}

我使用秒表类对函数计时,当我在调试中运行它时,我得到了以下结果。

s_OuterLoopCount = 10000;
s_InnerLoopCount = 10000;
NormalFunction Time = 377 ms;
TinyFunctions Time = 1322 ms;

这些结果对我来说很有意义,尤其是在调试中,因为函数调用会产生额外的开销。当我在 release 中运行它时,我得到了以下结果。

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;

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

【问题讨论】:

  • 可能是因为在发布模式下,小函数可以通过内联进行优化。此外,tail call optimization 可能是差异的一部分。优化“NormalFunction”更加困难,因为编译器更难识别可能的优化。
  • 这些可以解释为什么 TinyFunctions 可以像 NormalFunction 一样快地执行,但它并没有向我解释它是如何执行得这么快的。因为 NormalFunctions 几乎是 TinyFunctions 的预内嵌版本
  • 我猜这可能是双重加法得到不同的处理尝试使用正常 a = a + (b + 1);
  • 了解发生了什么的一种方法是查看使用 ILSPY 生成的 IL 代码
  • 寻呼 eric lippert 或 jon skeet

标签: c# performance compilation roslyn jit


【解决方案1】:

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

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  

除了浮点运算的顺序之外,它与 opcode-for-opcode 相同。这会产生巨大的性能差异,但我对 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  

iedx 中,beax 中,aecx 中。

我想对于我们的 CPU 架构,这个 LEA“技巧”(解释为 here)最终会比仅使用 ALU 更慢。还是可以改代码让两者的性能对齐:

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

这最终迫使NormalFunction 方法最终变成mov, add, inc, add,就像它出现在TinyFunctions 方法中一样。

【讨论】:

  • 我已经确认是这种情况,谢谢指出。这可能同样令人困惑,这么小的变化怎么会有这么大的性能差异,为什么编译器不优化呢?
  • @Pumices 我猜它没有优化它有几个原因:首先,如果这是一个“已知”的优化,我会感到惊讶。我对 FPU 了解不多,但我无法想象像这样切换 2 个操作码的顺序会导致如此巨大的性能差异。其次,我认为它不应该改变评估顺序,因为在其他情况下可能会导致行为改变(例如灾难性取消或其他不寻常的舍入问题)。
  • 好吧,只是为了给这个问题增加另一个皱纹,我尝试将所有内容移至整数,发现操作开关对函数的运行时间没有影响,更糟糕的是,现在 TinyFunctions 了~无论操作顺序如何,速度再次提高 56%。
  • 有趣,你又是对的。我对编译器优化的信心正在慢慢减弱。这确实使两者恢复一致,但我仍然看到 TinyFunctions 的速度提高了 5-10%。这比约 56% 好得多,但我仍然对此感到困惑。
  • 如果存在var b = f(x); a += b;a += f(x); 含义不同的情况,那么我认为是语言问题,而不是编译器问题。
猜你喜欢
  • 1970-01-01
  • 2011-06-19
  • 2010-10-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-10-03
  • 2011-03-15
  • 1970-01-01
相关资源
最近更新 更多