[c#] Try-catch加速我的代码?



Answers

那么,你对时间进行计时的方式对我来说看起来非常讨厌。 整个循环的时间会更加明智:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

这样你就不会受到微小时间,浮点运算和累积误差的影响。

做了这些改变后,看看“非catch”版本是否仍然比“catch”版本慢。

编辑:好吧,我自己尝试过 - 我看到了相同的结果。 很奇怪。 我想知道try / catch是否禁用了一些不良内联,但是使用[MethodImpl(MethodImplOptions.NoInlining)]并没有帮助...

基本上你需要查看cordbg下的优化JIT代码,我怀疑...

编辑:多一点的信息:

  • 把try / catch放在n++; 线仍然可以提高性能,但不会像将其放在整个区块中一样
  • 如果你捕捉到一个特定的异常(在我的测试中是ArgumentException ),它仍然很快
  • 如果你在catch块中打印异常,它仍然很快
  • 如果在catch块中重新抛出异常,它会再次变慢
  • 如果你使用finally块而不是catch块,它会再次变慢
  • 如果你使用finally块 catch块,它很快

奇怪的...

编辑:好的,我们已经拆卸...

这是使用C#2编译器和.NET 2(32位)CLR,用mdbg进行反汇编(因为我的机器上没有cordbg)。 即使在调试器下,我仍然可以看到相同的性能效果。 快速版本在变量声明和返回语句之间的所有内容上都使用try块,只需一个catch{}处理程序。 显然,慢版本是相同的,除非没有try / catch。 调用代码(即Main)在这两种情况下都是相同的,并且具有相同的程序集表示形式(所以它不是内联问题)。

快速版本的反汇编代码:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

反汇编缓慢版本的代码:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

在每种情况下, *显示调试器在简单的“步入”中输入的位置。

编辑:好的,我现在已经查看了代码,我想我可以看到每个版本是如何工作的......我相信较慢的版本会更慢,因为它使用更少的寄存器和更多的堆栈空间。 对于n的小数值可能更快 - 但是当循环占用大部分时间时,速度会变慢。

可能try / catch块会强制更多的寄存器被保存和恢复,所以JIT也会将这些寄存器用于循环......这恰好可以提高整体性能。 目前尚不清楚JIT是否在“正常”代码中使用了很多寄存器是否合理。

编辑:刚在我的x64机器上试过这个。 x64 CLR比这个代码上的x86 CLR快得多(速度快3-4倍),而在x64下,try / catch块并没有明显的区别。

Question

我写了一些代码来测试try-catch的影响,但看到了一些令人惊讶的结果。

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

在我的电脑上,这一直打印出大约0.96的值。

当我用这样的try-catch块封装Fibo()中的for循环时:

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

现在它一直打印出0.69 ... - 它实际上运行得更快! 但为什么?

注意:我使用发布配置对其进行编译,并直接运行EXE文件(在Visual Studio之外)。

编辑: Jon Skeet的优秀分析表明,try-catch在某种程度上导致x86 CLR以更有利的方式使用CPU寄存器(并且我认为我们还不明白为什么)。 我证实了Jon的发现,即x64 CLR没有这种差异,并且它比x86 CLR更快。 我还测试了在Fibo方法中使用int类型而不是long类型,然后x86 CLR与x64 CLR一样快。

更新:看起来这个问题已经被Roslyn修复。 同一台机器,相同的CLR版本 - 使用VS 2013进行编译时问题依然存在,但使用VS 2015编译时问题会消失。




这看起来像内联变糟的情况。 在x86内核上,抖动具有ebx,edx,esi和edi寄存器,可用于通用存储本地变量。 ecx寄存器在静态方法中变得可用,它不需要存储 。 eax寄存器通常用于计算。 但是这些是32位寄存器,对于long类型的变量,它必须使用一对寄存器。 edx:用于计算的eax和用于存储的edi:ebx。

在缓慢版本的反汇编中,哪个是突出的,既不使用edi也不使用ebx。

当抖动找不到足够的寄存器来存储局部变量时,它必须生成代码来从堆栈帧中加载和存储它们。 这会降低代码速度,它会阻止名为“寄存器重命名”的处理器优化,这是一种使用多个寄存器副本并允许超标量执行的内部处理器核心优化技巧。 它允许几条指令同时运行,即使它们使用相同的寄存器。 没有足够的寄存器是x86内核的常见问题,在x64中有8个额外的寄存器(r9到r15)。

抖动将尽其所能应用另一代码生成优化,它会尝试内联您的Fibo()方法。 换句话说,不要调用该方法,而是在Main()方法中为内联方法生成代码。 非常重要的优化,例如,免费提供C#类的属性,为它们提供字段的性能。 它避免了调用方法和设置堆栈帧的开销,节省了几纳秒。

有几个规则可以确定何时可以内联一个方法。 他们没有完全记录,但已在博客文章中提到。 一个原则是当方法体太大时不会发生。 这从内联失败中获益,它会产生太多的代码,并不适合在L1指令缓存中使用。 这里适用的另一个硬性规则是,当一个方法包含try / catch语句时,它不会被内联。 背后的背景是异常的实现细节,它们捎带回到Windows对内置支持基于堆栈帧的SEH(结构异常处理)。

寄存器分配算法在抖动中的一种行为可以通过使用此代码来推断。 它似乎意识到抖动何时尝试内联一种方法。 一个规则似乎使用只有edx:eax寄存器对可以用于具有long类型局部变量的内联代码。 但不是edi:ebx。 毫无疑问,因为这对调用方法的代码生成太不利,edi和ebx都是重要的存储寄存器。

所以你得到快速版本是因为抖动知道方法体包含try / catch语句。 它知道它永远不能内联,所以很容易使用edi:ebx来存储长变量。 你得到了缓慢的版本,因为抖动并不知道内联不起作用。 它只是为方法体生成代码后才发现的。

然后,这个缺陷是它没有返回并重新生成该方法的代码。 鉴于时间的限制,这是可以理解的。

这种减速在x64上不会发生,因为其中一个有8个寄存器。 另一个是因为它可以在一个寄存器中存储一个长(如rax)。 当你使用int来代替long时,减速并不会发生,因为抖动在选择寄存器方面有更多的灵活性。




Related