尝试捕获加速我的代码?

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

当我在Fibo()中包装for循环时,使用像这样的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…--它实际上运行得更快!但是为什么呢?

注意:我使用发布配置编译了它并直接运行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编译时问题消失了。

117488 次浏览

好吧,你给事情计时的方式在我看来很糟糕。给整个循环计时要明智得多:

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++;行周围仍然可以提高性能,但不如将其放在整个块中那么多
  • 如果您捕获特定异常(在我的测试中为0),它仍然很快
  • 如果你在catch块中打印异常,它仍然很快
  • 如果你在cat块中重新抛出异常,它会再次变慢
  • 如果你使用一个最终块而不是一个捕获块,它会再次变慢
  • 如果你使用以及块来捕获块,它会很快

奇怪…

编辑:好的,我们有拆卸…

这是使用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/cat块没有明显的区别。

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

即时编译器在寄存器使用方面做出了不同的假设,用于包含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的差异。

我会把它作为注释放在这里,因为我真的不确定是否会出现这种情况,但我记得它不涉及修改编译器垃圾处理机制的工作方式,因为它以递归的方式从堆栈中清除对象内存分配。在这种情况下,可能没有要清除的对象,或者for循环可能构成垃圾回收机制识别的闭包,足以强制执行不同的收集方法。也许不是,但我认为值得一提,因为我没有看到它讨论其他地方。

一位专门了解堆栈使用优化的罗斯林工程师看了看这个,并向我报告说,C#编译器生成局部变量存储的方式与JIT编译器在相应的x86代码中注册调度的方式之间的交互似乎存在问题。结果是在本地加载和存储上生成次优代码。

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

这非常奇怪。我们将与JITter团队跟进,看看是否可以输入bug,以便他们可以修复此问题。

此外,我们正在为Roslyn改进C#和VB编译器的算法,以确定何时可以使本地元素“短暂”——也就是说,只是在堆栈上推送和弹出,而不是在激活期间在堆栈上分配特定位置。我们相信,如果我们更好地提示何时可以更早地使本地元素“死亡”,JITter将能够在寄存器分配方面做得更好。

谢谢你让我们注意到这一点,并为奇怪的行为道歉。

这看起来像是内联出错的情况。在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/cat语句时不会被内联。这背后的背景是异常的实现细节,它们搭载到Windows对基于栈帧的SEH(结构异常处理)的内置支持上。

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

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

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

这种慢下来不会发生在x64上,因为它有8个寄存器。对于另一个,因为它可以在一个寄存器中存储long(如rax)。当你使用int而不是long时,不会发生慢下来,因为抖动在选择寄存器方面有更大的灵活性。

9年后,bug仍然存在!你可以很容易地看到它:

   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