尝试捕获加快我的代码?


1503

我编写了一些代码来测试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 ...-实际上运行得更快!但为什么?

注意:我使用Release配置对它进行了编译,并直接运行EXE文件(在Visual Studio外部)。

编辑:乔恩·斯凯特(Jon Skeet)的出色分析表明,在这种特定情况下,try-catch导致x86 CLR以更有利的方式使用CPU寄存器(而且我认为我们尚不了解原因)。我证实了乔恩(Jon)的发现,即x64 CLR没有这种区别,并且它比x86 CLR更快。我还测试int了在Fibo方法中使用类型而不是long类型,然后x86 CLR与x64 CLR一样快。


更新:看起来这个问题已由罗斯林(Roslyn)解决。同一台机器,相同的CLR版本-使用VS 2013进行编译时,问题仍然如上,但使用VS 2015进行编译时,问题就消失了。


111
@Lloyd他试图回答他的问题“它实际上运行得更快!但是为什么呢?”
Andreas Niedermair 2012年

137
因此,现在“吞咽异常”已从一种不好的做法转变为一种良好的性能优化方法:P
Luciano

2
这是在未经检查的还是经过检查的算术上下文中?
Random832

7
@ taras.roshko:虽然我不希望对Eric造成伤害,但这并不是一个真正的C#问题-它是一个JIT编译器问题。最终的困难是弄清楚为什么x86 JIT在没有try / catch的情况下不使用与try / catch块相同的寄存器的原因。
乔恩·斯基特

63
太好了,因此,如果我们将这些尝试的渔获物嵌套起来,我们可以走得更快吗?
13年

Answers:


1053

一位专门研究堆栈使用优化的Roslyn工程师对此进行了研究,并向我报告C#编译器生成本地变量存储的方式与JIT编译器进行注册的方式之间的交互似乎存在问题。在相应的x86代码中进行调度。结果是在本地的加载和存储上生成次优代码。

由于我们所有人都不清楚的某些原因,当JITter知道该块位于受尝试保护的区域时,可以避免有问题的代码生成路径。

这很奇怪。我们将与JITter团队进行跟进,看看我们是否可以输入错误,以便他们可以解决此问题。

另外,我们正在努力改进Roslyn的C#和VB编译器算法,以确定何时可以使本地人成为“临时”人-即只是将其压入并弹出堆栈,而不是在堆栈上分配特定位置以用于激活的持续时间。我们相信JITter将能够更好地进行寄存器分配,如果我们能更好地提示何时可以使本地人“死”的话,那我们将做得更好。

感谢您引起我们的注意,并为您的怪异行为致歉。


8
我一直想知道为什么C#编译器会生成这么多无关的本地语言。例如,新的数组初始化表达式始终生成一个局部变量,但从不需要生成局部变量。如果它允许JITter产生性能更高的代码,那么C#编译器应该在生成不必要的本地代码时更加谨慎……
Timwi 2012年

33
@Timwi:当然。在未优化的代码中,编译器会大量放弃不必要的局部变量,因为它们会使调试更加容易。在优化的代码中,如有可能,应删除不必要的临时对象。不幸的是,这些年来,我们遇到了许多错误,这些错误是我们意外地取消了临时消除优化器的优化。前面提到的工程师完全从头开始为Roslyn重新编写所有这些代码,因此,我们应该在Roslyn代码生成器中大大改善优化行为。
埃里克·利珀特

24
在这个问题上有动静吗?
罗伯特·哈维

10
看起来罗斯林(Roslyn)确实做了修复。
ErenErsönmez15年

56
您错过了将其称为“ JITter错误”的机会。
mbomb007 '17

734

好吧,我对事情的计时方式看起来很讨厌。仅对整个循环进行计时会更明智:

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

这样一来,您就不会受制于微小的时序,浮点运算和累积误差。

进行更改后,请查看“非捕获”版本是否仍比“捕获”版本慢。

编辑:好的,我自己尝试过-我看到了相同的结果。很奇怪。我想知道try / catch是否禁用了一些错误的内联,但是使用[MethodImpl(MethodImplOptions.NoInlining)]替代方法却无济于事...

基本上,您需要在cordbg下查看优化的JITted代码,我怀疑...

编辑:更多信息:

  • 仅将try / catch放在n++;一行上仍然可以提高性能,但不能像将其放在整个块上那样多
  • 如果您ArgumentException在我的测试中发现了一个特定的异常,那么它仍然很快
  • 如果在catch块中打印异常,它仍然会很快
  • 如果将异常重新抛出到catch块中,它又会变慢
  • 如果您使用finally块而不是catch块,它又会变慢
  • 如果您同时使用finally块 catch块,则速度很快

奇怪的...

编辑:好的,我们有反汇编...

这是使用C#2编译器和.NET 2(32位)CLR,与mdbg进行反汇编(因为我的机器上没有cordbg)。即使在调试器下,我仍然看到相同的性能效果。快速版本使用一个try块围绕变量声明和return语句之间的所有内容,仅使用一个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机器上尝试过。在64位CLR是快(约3-4倍的速度)比该代码在x86 CLR,并在x64的try / catch块不会使一个显着的差异。


4
@GordonSimpson,但是在仅捕获特定异常的情况下,将不会捕获所有其他异常,因此仍然需要不尝试的假设中涉及的任何开销。
乔恩·汉纳

45
看起来寄存器分配有所不同。快速版本设法使用esi,edi多头之一而不是堆栈。它ebx用作计数器,慢速版本使用esi
杰弗里·萨克斯

13
@JeffreySax:不仅使用了哪些寄存器,还使用了多少寄存器。慢速版本占用更多的堆栈空间,占用更少的寄存器。我不知道为什么……
乔恩·斯基特

2
如何在寄存器和堆栈方面处理CLR异常帧?设置一个可以释放注册以某种方式使用吗?
Random832

4
IIRC x64比x86具有更多的可用寄存器。您看到的加速将与在x86下强制使用其他寄存器的try / catch一致。
Dan在火光旁摆弄2012年

116

乔恩(Jon)的反汇编表明,两个版本之间的区别在于,快速版本使用一对寄存器(esi,edi)存储慢速版本没有的局部变量之一。

对于包含try-catch块的代码与不包含try-catch块的代码,JIT编译器对寄存器的使用做出不同的假设。这导致它做出不同的寄存器分配选择。在这种情况下,这有利于使用try-catch块的代码。不同的代码可能会导致相反的效果,因此我不认为这是通用的加速技术。

最后,很难说出哪些代码将以最快的速度运行。诸如寄存器分配之类的因素以及影响寄存器分配的因素如此低层的实现细节,我看不到任何特定技术如何可靠地产生更快的代码。

例如,考虑以下两种方法。他们改编自一个真实的例子:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

一个是另一个的通用版本。用替换通用类型StructArray将使方法相同。因为StructArray是值类型,所以它获得了通用方法的自己的编译版本。然而,实际的运行时间比专门方法的要长得多,但仅适用于x86。对于x64,时序几乎相同。在其他情况下,我也观察到x64的差异。


6
话虽如此...您可以在不使用“尝试/捕获”的情况下强制选择不同的寄存器分配吗?是作为对该假设的检验,还是作为调整速度的一般尝试?
WernerCD

1
有许多原因导致此特定情况可能有所不同。也许是尝试。也许事实是变量在内部作用域中被重用。无论是什么具体原因,它都是实现细节,即使在不同的程序中调用了完全相同的代码,也不能指望保留该实现细节。
杰弗里·萨克斯

4
@WernerCD我想说一个事实,即C和C ++具有一个关键字,它暗示着哪个(A)被许多现代编译器忽略,而(B)决定不放入C#,表明这不是我们要做的事情。可以直接看到。
乔恩·汉娜

2
@WernerCD-仅当您自己编写程序集时
OrangeDog 2012年

72

这看起来像是内联变坏的情况。在x86内核上,抖动具有ebx,edx,esi和edi寄存器,可用于通用目的存储局部变量。ecx寄存器可以通过静态方法使用,而不必存储this。计算经常需要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来存储long变量。您之所以选择慢速版本,是因为抖动不知道内联是行不通的。它仅为方法主体生成代码后才发现。

缺点是它没有返回并重新生成该方法的代码。考虑到运行的时间限制,这是可以理解的。

在x64上不会发生这种减速,因为其中一个有8个以上的寄存器。另一个原因是因为它可以在一个寄存器中存储一个long(例如rax)。当使用int而不是long时,不会发生减慢,因为抖动在选择寄存器时具有更大的灵活性。


21

我本来将其作为评论,因为我确实不确定是否可能是这种情况,但是正如我记得的那样,try / except语句并不涉及对垃圾处理机制的修改。编译器的工作方式是,它以递归方式从堆栈中清除对象内存分配。在这种情况下可能没有要清除的对象,或者for循环可能构成垃圾回收机制认识到足以实施另一种回收方法的闭包。可能不是,但是我认为值得一提,因为我没有看到它在其他地方讨论过。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.