试试赶上加快我的代码?

我写了一些代码来testingtry-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 … – 它实际上跑得更快! 但为什么?

注:我使用发布configuration编译这个,并直接运行EXE文件(Visual Studio之外)。

编辑: Jon Skeet的优秀分析表明,try-catch在某种程度上导致x86 CLR以更有利的方式使用CPU寄存器(我认为我们还不明白为什么)。 我确认了Jon的发现,x64 CLR没有这个差别,而且比x86 CLR更快。 我还testing了在Fibo方法中使用inttypes而不是longtypes,然后x86 CLR和x64 CLR一样快。


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

一位专门理解堆栈使用优化的Roslyn工程师看了一眼,并向我报告,C#编译器生成局部variables存储的方式与JIT编译器注册方式之间的交互似乎存在问题调度在相应的x86代码中。 结果是在当地人的加载和存储上代码生成不理想。

由于某些原因我们都不清楚,当JITter知道块处于try-protected区域时,会避免有问题的代码生成path。

这很奇怪。 我们将跟进JITter团队,看看我们是否可以得到一个错误input,以便他们可以解决这个问题。

此外,我们正在为Roslyn改进C#和VB编译器的algorithm,以确定本地人何时可以“短暂” – 也就是在栈上推送和popup,而不是在堆栈上分配一个特定的位置激活的持续时间。 我们相信JITter能够更好地完成寄存器分配,而且如果我们能更好地提供一些关于什么时候可以让本地人“死”的更好的提示。

感谢您将此引起我们的注意,并为这种奇怪的行为道歉。

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

 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++; 线路仍然能够提高性能,但不会像将其放在整个区块中一样
  • 如果你捕捉到一个特定的exception(在我的testing中是ArgumentException ),它仍然很快
  • 如果你在catch块中打印exception,它仍然很快
  • 如果在catch块中重新抛出exception,它会再次变慢
  • 如果使用finally块而不是catch块,则会再次变慢
  • 如果你使用一个finally块一个catch块,那就很快了

奇怪的…

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

这是使用C#2编译器和.NET 2(32位)CLR,与mdbg反汇编(因为我没有cordbg在我的机器上)。 即使在debugging器下,我仍然可以看到相同的性能效果。 快速版本在variables声明和返回语句之间使用了一个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 

在每种情况下*显示debugging器在简单的“步入”中input的位置。

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

可能try / catch块会强制更多的寄存器被保存和恢复,所以JIT使用这些循环以及…这正好改善整体性能。 目前还不清楚JIT是否在“正常”代码中使用了多less个寄存器是合理的决定。

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

Jon的反汇编显示,这两个版本之间的区别在于,快速版本使用一对寄存器( esi,edi )来存储缓慢版本不存在的一个局部variables。

JIT编译器对包含try-catch块的代码和不包含代码的代码进行了不同的假设。 这导致它做出不同的寄存器分配select。 在这种情况下,这有利于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; } 

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

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

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

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

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

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

抖动中的寄存器分配algorithm的一种行为可以通过使用该代码来推断。 它似乎知道抖动何时试图内联一个方法。 一个规则似乎使用,只有edx:eax寄存器对可用于具有longtypes局部variables的内联代码。 但不是edi:ebx。 毫无疑问,因为这对调用方法的代码生成太不利了,edi和ebx都是重要的存储寄存器。

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

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

这种减速在x64上不会发生,因为一个它有8个寄存器。 另一个因为它可以在一个寄存器中存储很长时间(比如rax)。 而且,当你使用int而不是long时,slow-down不会发生,因为抖动在select寄存器方面有更多的灵活性。

我已经把这个作为一个评论,因为我真的不知道这是可能的情况下,但我记得这不是一个尝试/除了声明涉及修改垃圾处理机制的方式编译器可以工作,因为它以recursion的方式从堆栈中清除了对象内存的分配。 在这种情况下可能没有要清理的对象,或者for循环可能构成垃圾收集机制认可足以执行不同收集方法的闭包。 可能不会,但我认为值得一提,因为我没有看到任何其他地方讨论过。