使用Try-catch会加速我的代码吗?

1629

我编写了一些代码来测试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的值。

当我将for循环放在Fibo()函数内部并加上try-catch块时:

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更快。我还测试了在Fibo方法中使用int类型而不是long类型,然后x86 CLR与x64 CLR一样快。
更新:看起来Roslyn已经修复了这个问题。同一台机器,同一版本的CLR——当使用VS 2013编译时,问题仍旧存在,但是当使用VS 2015编译时,问题消失了。

118
@Lloyd 他试图得到对他的问题“它真的跑得更快!但是为什么?”的答案。 - user57508
153
现在,“吞掉异常”已从不良实践转变为良好性能优化的做法。 :P - Luciano
2
这是在未经检查的算术上下文中还是已经检查的算术上下文中? - Random832
11
虽然我不想冒犯 Eric,但这并不是一个关于 C# 的问题 - 它是一个关于 JIT 编译器的问题。最终的难点在于弄清楚为什么 x86 JIT 在没有 try/catch 时不使用同样多的寄存器,而在有 try/catch 块时却使用了更多的寄存器。 - Jon Skeet
75
好的,如果我们嵌套这些try-catch块,速度会更快,对吗? - Chuck Pinkert
显示剩余12条评论
6个回答

1136

一位专注于理解堆栈使用优化的Roslyn工程师查看了这个问题并向我报告说,在C#编译器生成本地变量存储和JIT编译器在相应的x86代码中进行寄存器调度之间存在问题。结果是局部变量的加载和存储的代码生成不够优化。

由于某些原因,我们都不清楚,当JITter知道该块处于受保护的try区域时,就会避免出现问题的代码生成路径。

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

同时,我们正在改进Roslyn对于C#和VB编译器的算法,以确定何时可以将本地变量作为“临时”变量处理--即仅在堆栈上推送和弹出,而不是在整个激活期间分配特定位置。我们相信,如果我们能更好地提示JITter何时可以更早地使本地变量成为“死代码”,那么它将能够更好地执行寄存器分配等任务。
感谢您让我们注意到这一点,并为奇怪的行为道歉。

12
我一直想知道为什么 C# 编译器会生成这么多不必要的本地变量。例如,新数组初始化表达式总是会生成一个本地变量,但没有必要生成。如果它可以让JITter产生更高效的代码,也许C#编译器应该更加谨慎地生成不必要的本地变量... - Timwi
37
@Timwi:完全正确。在未经优化的代码中,编译器会随意生成不必要的局部变量,因为这样可以更容易地进行调试。在经过优化的代码中,应该尽可能删除不必要的临时变量。不幸的是,多年来我们已经遇到了许多错误,其中一些是由于我们意外地降低了临时变量消除优化器的性能。上述工程师正在从头开始重新设计 Roslyn 的所有代码,因此我们应该在 Roslyn 代码生成器中获得更好的优化行为。 - Eric Lippert
34
这个问题有没有任何进展? - Robert Harvey
20
看起来 Roslyn 已经修复了它。 - Eren Ersönmez

762

嗯,你目前的计时方式看起来很糟糕。更明智的做法是计算整个循环所需的时间:


Well, the way you're timing things looks pretty nasty to me. It would be much more sensible to just time the whole loop:
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块,只有一个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块没有明显的差异。

4
但是,如果只捕获了特定的异常,那么其他所有异常都不会被捕获,因此您所假设的无需尝试的开销仍然是必要的。 - Jon Hanna
48
似乎是寄存器分配的差异。快速版本设法使用esi、edi其中一个长整型而不是栈。它将ebx用作计数器,而慢速版本使用esi - Jeffrey Sax
16
不仅是使用哪些寄存器,还有使用多少个寄存器。慢速版本使用更多的堆栈空间,接触较少的寄存器。我不知道为什么... - Jon Skeet
2
CLR异常帧在寄存器和堆栈方面是如何处理的?设置一个异常帧是否可以释放某个寄存器以便进行使用? - Random832
4
据我所知,相较于 x86,x64 拥有更多可用的寄存器。你所看到的加速效果与在 x86 下强制使用额外寄存器的 try/catch 相一致。 - Dan Is Fiddling By Firelight
显示剩余12条评论

121
Jon的反汇编显示,两个版本之间的区别在于快速版本使用一对寄存器(esi、edi)来存储其中一个本地变量,而慢速版本则没有。
JIT编译器对包含try-catch块和不包含try-catch块的代码使用不同的寄存器使用假设。这导致它做出不同的寄存器分配选择。在这种情况下,这有利于带有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的差异。


7
话虽如此,你能在不使用 Try/Catch 的情况下强制进行不同的寄存器分配选择吗?这是为了测试这个假设或是作为试图调整性能的一般尝试? - WernerCD
1
这个特定情况可能有很多不同的原因。也许是try-catch的问题,也许是变量在内部作用域中被重复使用的事实。无论具体原因是什么,它都是一个实现细节,即使在不同的程序中调用完全相同的代码,也不能保证其保留。 - Jeffrey Sax
5
我会尽力为您进行翻译:我认为C和C ++有一个关键字,用于建议忽略某些内容,但现代编译器忽略了它,而且决定不将其放入C#中,这表明我们不太可能以更直接的方式看到这个关键字。 - Jon Hanna
2
@WernerCD - 只有你自己编写汇编代码时才需要这样做。 - OrangeDog

77

看起来像是内联出现了问题。 在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(结构异常处理)支持捆绑,而这种处理方式是基于堆栈帧的。

注册分配算法在JIT编译器中的一个行为可以从这段代码中推断出来。它似乎知道JIT正在尝试内联方法时的情况。其中一条规则是只有edx:eax寄存器对可以用于具有本地变量类型为long的内联代码,而不是edi:ebx寄存器对。毫无疑问,那对调用方法的代码生成太有害了,因为edi和ebx都是重要的存储寄存器。
因此,您会得到快速版本,因为JIT已经预先知道方法体包含try/catch语句。它知道它永远不能被内联,因此会立即使用edi:ebx作为长变量的存储。您会得到慢速版本,因为JIT并不知道内联将不起作用。它只在生成方法体代码后才发现这一点。
然后缺陷在于它没有回去重新生成方法的代码。这是可以理解的,考虑到它必须操作的时间限制。
这种减速在x64上不会发生,因为它有8个以上的寄存器。另外,因为它可以将一个长整型存储在一个寄存器中(如rax),所以也不会发生减速。如果使用int而不是long,则不会发生减速,因为JIT在选择寄存器方面有更大的灵活性。

22

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


4

九年过去了,这个漏洞仍然存在!你可以通过以下方式轻松查看:

   static void Main( string[] args )
    {
      int hundredMillion = 1000000;
      DateTime start = DateTime.Now;
      double sqrt;
      for (int i=0; i < hundredMillion; i++)
      {
        sqrt = Math.Sqrt( DateTime.Now.ToOADate() );
      }
      DateTime end = DateTime.Now;

      double sqrtMs = (end - start).TotalMilliseconds;

      Console.WriteLine( "Elapsed milliseconds: " + sqrtMs );

      DateTime start2 = DateTime.Now;

      double sqrt2;
      for (int i = 0; i < hundredMillion; i++)
      {
        try
        {
          sqrt2 = Math.Sqrt( DateTime.Now.ToOADate() );
        }
        catch (Exception e)
        {
          int br = 0;
        }
      }
      DateTime end2 = DateTime.Now;

      double sqrtMsTryCatch = (end2 - start2).TotalMilliseconds;

      Console.WriteLine( "Elapsed milliseconds: " + sqrtMsTryCatch );

      Console.WriteLine( "ratio is " + sqrtMsTryCatch / sqrtMs );

      Console.ReadLine();
    }

在我的机器上,运行最新版本的MSVS 2019和.NET 4.6.1,这个比率小于1。


1
我在.NET 5.0中运行了这段代码。在x86上,如果没有try-catch,它需要530-535毫秒,如果有try-catch,则需要少1-3%。在构建x64时,如果没有try-catch,它需要218-222毫秒,如果有try-catch,则需要少11-13%。真是奇怪。 - foxite
FYI:最好使用“Stopwatch”来测量性能。 - ofthelit
我曾经使用过秒表,但是我没有注意到任何优点。 - Markus
2
比 Stopwatch 更好的是 BenchmarkDotNet - Torben Koch Pløen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接