Try-catch speeding up my code?
我编写了一些代码来测试try-catch的影响,但是看到了一些令人惊讶的结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | 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循环时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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外部)。
编辑:乔恩·斯基特出色的分析表明,在这种特定的情况下,Try-Catch会以更有利的方式使用x86 CLR的CPU寄存器(我认为我们还不明白为什么)。我证实了乔恩的发现X64 clr没有这个区别,而且它比X86 clr更快。我还使用fibo方法中的
更新:看来罗斯林已经解决了这个问题。同样的机器,同样的clr版本——当用vs 2013编译时,问题仍然如上所述,但是当用vs 2015编译时问题就消失了。
一位专门了解栈使用优化的Roslyn工程师对此进行了研究,并向我报告,C编译器生成本地变量存储的方式与JIT编译器在相应x86代码中注册调度的方式之间的交互似乎存在问题。结果是在本地的加载和存储上生成了次优的代码。
由于某些我们都不清楚的原因,当抖动器知道块位于尝试保护区域时,可以避免出现问题的代码生成路径。
这很奇怪。我们将与抖动团队一起跟进,看看是否可以输入一个bug,以便他们修复这个问题。
此外,我们正在为Roslyn改进C和VB编译器的算法,以确定何时可以将局部变量设置为"短暂的",即只在堆栈上推送和弹出,而不是在激活期间在堆栈上分配特定位置。我们相信,如果我们能更好地提示局部变量何时可以"死亡",抖动将能够更好地完成寄存器分配以及其他什么。
感谢您引起我们的注意,并为我们的古怪行为道歉。
嗯,你给事情安排时间的方式对我来说很讨厌。更明智的做法是只给整个循环计时:
1 2 3 4 5 6 7 | 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是否禁用了一些坏的内联,但是使用
基本上,您需要查看cordbg下优化的jitted代码,我怀疑…
编辑:更多信息:
- 只在
n++; 行中使用try/catch仍然可以提高性能,但不会像在整个块中使用它那么多。 - 如果你在我的测试中发现了一个特定的异常(
ArgumentException ),它仍然很快 - 如果在catch块中打印异常,它仍然很快
- 如果在catch块中重新引发异常,它将再次变慢。
- 如果使用finally块而不是catch块,它会再次变慢。
- 如果使用finally块和catch块,则速度很快
奇怪的。。。
编辑:好的,我们有拆卸……
这是使用C 2编译器和.NET 2(32位)CLR,使用mdbg进行分解(因为我的计算机上没有cordbg)。即使在调试器下,我仍然可以看到相同的性能效果。Fast版本在变量声明和返回语句之间的所有内容周围使用一个
快速版本的反汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | [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 |
慢版本的反汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | [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 |
在每种情况下,
编辑:好的,我已经看过代码了,我想我可以看到每个版本是如何工作的…我相信较慢的版本速度较慢,因为它使用更少的寄存器和更多的堆栈空间。对于小值的
Try/Catch块可能会强制保存和还原更多的寄存器,因此JIT也将这些寄存器用于循环…这正好提高了整体性能。对于JIT来说,不使用"正常"代码中的那么多寄存器是否是一个合理的决定尚不清楚。
编辑:刚在我的X64机器上尝试过。在这段代码上,x64 clr比x86 clr快得多(大约快3-4倍),在x64下,try/catch块没有明显的区别。
乔恩的反汇编表明,这两个版本之间的区别在于,快速版本使用一对寄存器(
对于包含try-catch块的代码与不包含try-catch块的代码,JIT编译器对寄存器的使用做了不同的假设。这会导致它做出不同的寄存器分配选择。在这种情况下,这有利于使用try catch块的代码。不同的代码可能会产生相反的效果,所以我不认为这是一种通用的加速技术。
最后,很难判断哪种代码的运行速度最快。类似寄存器分配和影响它的因素是如此低级的实现细节,我不知道任何特定的技术如何可靠地生成更快的代码。
例如,考虑以下两种方法。它们改编自现实生活中的一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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; } |
一个是另一个的通用版本。用
这看起来像是一个内衬坏了的案例。在x86核心上,抖动具有EBX、EDX、ESI和EDI寄存器,可用于本地变量的通用存储。ECX寄存器在静态方法中可用,它不需要存储这个。计算通常需要EAX寄存器。但是这些是32位寄存器,对于long类型的变量,它必须使用一对寄存器。其中edx:eax用于计算,edi:ebx用于存储。
这就是为什么在缓慢版本的反汇编中脱颖而出,既不使用EDI,也不使用EBX。
当抖动找不到足够的寄存器来存储局部变量时,它必须生成代码来从堆栈帧加载和存储它们。这会减慢代码的速度,它会阻止名为"寄存器重命名"的处理器优化,这是一种内部处理器核心优化技巧,它使用寄存器的多个副本,并允许超标量执行。它允许多个指令同时运行,即使它们使用相同的寄存器。没有足够的寄存器是X64内核上的一个常见问题,X64有8个额外的寄存器(R9到R15)。
抖动将尽最大努力应用另一种代码生成优化,它将尝试内联fibo()方法。换句话说,不是调用该方法,而是在main()方法中为该方法内联生成代码。非常重要的优化,例如,可以免费生成C类的属性,从而使它们具有字段的性能。它避免了方法调用和设置堆栈帧的开销,节省了几纳秒。
有几个规则可以精确地确定什么时候可以内联一个方法。它们没有确切的记录,但在博客文章中提到过。一个规则是当方法体太大时不会发生这种情况。这就破坏了从内联中获得的收益,它生成了太多的代码,而这些代码不适合一级指令缓存。这里应用的另一个硬规则是,当方法包含try/catch语句时,它不会被内联。其中一个背后的背景是异常的实现细节,它们结合了Windows对SEH(结构异常处理)的内置支持,SEH是基于堆栈框架的。
在抖动中,寄存器分配算法的一个行为可以通过使用此代码推断出来。它似乎知道抖动何时试图内联一个方法。一条规则似乎是,只有edx:eax寄存器对可以用于具有long类型局部变量的内联代码。但不是EDI:EBX。毫无疑问,因为这对调用方法的代码生成太有害了,所以EDI和EBX都是重要的存储寄存器。
所以您得到了快速版本,因为抖动提前知道方法体包含try/catch语句。它知道它永远不能内联,所以很容易使用edi:ebx来存储长变量。你得到的是慢版本,因为抖动不知道前面的内嵌不起作用。它只在为方法体生成代码后才发现。
然后,缺陷是它没有返回并重新生成该方法的代码。这是可以理解的,考虑到时间限制,它必须运行。
在X64上不会发生这种减速,因为对于一个它还有8个寄存器。另一个原因是它可以在一个寄存器中存储一个长的(如rax)。当你使用in t而不是long时,慢下来不会发生,因为抖动在选择寄存器方面有很大的灵活性。
我本可以把它作为注释放进去的,因为我真的不确定是否会发生这种情况,但我记得,它不是一个try/except语句,它涉及对编译器的垃圾处理机制工作方式的修改,因为它以递归的方式从堆栈中清除对象内存分配。在这种情况下,可能没有要清除的对象,或者for循环可能构成一个闭包,垃圾收集机制可以识别该闭包足以强制执行不同的收集方法。可能没有,但我觉得值得一提,因为我没有看到它在其他地方讨论过。