1636

我写了一些代码来测试try-catch的影响,但看到了一些令人惊讶的结果。

static void Main(字符串[]参数){线程。当前线程。优先级=线程优先级。最高;过程。GetCurrentProcess()。优先级类别=处理优先级类别。实时;长启动=0,停止=0,已用时间=0;双平均值=0.0;长温度=斐波(1);对于(int i=1;i<100000000;i++){start=秒表。获取时间戳();温度=斐波(100);stop=秒表。获取时间戳();已用时间=停止-启动;平均值=平均值+(两倍)已用时间-平均值)/i;}慰问。WriteLine(“已用时间:”+平均值);慰问。ReadKey();}静态长Fibo(int n){长n1=0,n2=1,fibo=0;n++;for(int i=1;i<n;i++){n1=n2;n2=fibo;fibo=n1+n2;}返回fibo;}

在我的电脑上,这始终会打印出大约0.96的值。。

当我在Fibo()中用try-catch块包装for循环时,如下所示:

静态长Fibo(int n){长n1=0,n2=1,fibo=0;n++;尝试{for(int i=1;i<n;i++){n1=n2;n2=fibo;fibo=n1+n2;}}捕获{}返回fibo;}

现在它一直打印出0.69……--实际上它跑得更快了!但为什么?

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

编辑:乔恩·斯凯特的杰出的分析显示try-catch在某种程度上导致x86 CLR在这种特定情况下以更有利的方式使用CPU寄存器(我认为我们还不明白为什么)。我证实了Jon的发现,x64 CLR没有这个区别,而且它比x86 CLR更快。我还使用整数在Fibo方法中键入,而不是长的类型,然后x86 CLR的速度与x64 CLR一样快。


更新:看起来这个问题已经被Roslyn解决了。相同的机器,相同的CLR版本——在使用VS 2013编译时,问题仍然如上所述,但在使用VS2015编译时,该问题消失了。

14
  • 1
    你是在64位机器上运行这个吗?Try-catch或Try-except(据我所知)不会带来32位平台上的性能损失。 评论 2012年1月19日17:58
  • 156
    因此,现在“吞噬异常”从一种糟糕的做法变成了一种良好的性能优化:P 评论 2012年1月19日18:08
  • 1
    @taras.roshko不,这两种情况都是一样的。关键是,如果选中它,那么try块中的代码会抛出异常,我希望JIT足够聪明,可以在所有内容都是本地的情况下,将其更改为简单的跳转到catch块中。
    – 随机832
    评论 2012年1月19日18:25
  • 11
    @taras.roshko:虽然我不想伤害Eric,但这并不是一个真正的C#问题,而是一个JIT编译器问题。最终的困难是弄清楚为什么x86 JIT在没有try/catch的情况下使用的寄存器没有它使用的那么多具有try/catch块。
    – 斯基特
    评论 2012年1月19日19:39
  • 76
    太好了,如果我们把这些试捕物嵌套起来,我们可以跑得更快,对吗? 评论 2013年8月6日22:37

6个答案6

重置为默认值
1141

其中一个罗斯林专门了解堆栈使用优化的工程师对此进行了研究,并向我报告说,C#编译器生成局部变量存储的方式与准时制编译器在相应的x86代码中注册调度。结果是局部变量的加载和存储的代码生成不太理想。

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

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

此外,我们正在为Roslyn改进C#和VB编译器的算法,以确定何时可以将局部变量设置为“临时”,即只在堆栈上推送和弹出,而不是在激活期间在堆栈上分配特定位置。我们相信,如果我们给JITter提供更好的提示,说明什么时候可以让本地人更早“死亡”,那么JITter将能够更好地完成寄存器分配等工作。

感谢您提醒我们注意这一点,并为这种奇怪的行为道歉。

4
  • 12
    我一直想知道为什么C#编译器会生成这么多无关的局部变量。例如,新的数组初始化表达式总是生成局部表达式,但不必生成局部表达式。如果它允许JITter生成性能更高的代码,也许C#编译器应该更加小心地生成不必要的局部变量。。。
    – 蒂姆维
    评论 2012年1月25日20:37
  • 37
    @蒂姆维:当然。在未优化的代码中,编译器会大量生成不必要的局部变量,因为它们可以简化调试。在优化的代码中,如果可能的话,应该删除不必要的临时代码。不幸的是,这些年来我们遇到了很多错误,我们意外地取消了临时消除优化器的优化。上述工程师正在为Roslyn从头开始重新编写所有代码,因此我们应该在Roslyn代码生成器中大大改进优化行为。
    – 利珀特
    评论 2012年1月25日20:42
  • 34
    在这个问题上有什么进展吗? 评论 2013年6月14日4:49
  • 20
    看起来罗斯琳确实修好了。 评论 2015年8月18日9:24
766

嗯,我觉得你安排时间的方式很糟糕。只安排整个循环的时间会更明智:

var秒表=秒表。开始新建();对于(int i=1;i<100000000;i++){斐博(100);}秒表。停止();慰问。WriteLine(“已用时间:{0}”,秒表。已用);

这样,您就不会受微小计时、浮点运算和累积错误的支配。

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

编辑:好的,我自己也试过了,我也看到了同样的结果。非常奇怪。我想知道try/catch是否禁用了一些糟糕的内联,但使用了[方法实现(MethodImplOptions.NoInlining)]反而无济于事。。。

我想,基本上你需要在cordbg下查看优化的JITted代码。。。

编辑:更多信息:

  • 尝试/捕捉n++;行仍然可以提高性能,但不如将其放在整个块上
  • 如果捕捉到特定异常(参数异常在我的测试中)它仍然很快
  • 如果在catch块中打印异常,它仍然很快
  • 如果在catch块中重新抛出异常,那么速度又会变慢
  • 如果使用finally块而不是catch块,那么速度又会变慢
  • 如果使用finally块以及拦网,很快

奇怪的。。。

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

这是使用C#2编译器和。NET2(32位)CLR,使用mdbg反汇编(因为我的机器上没有cordbg)。即使在调试器下,我仍然可以看到相同的性能效果。快速版本使用尝试用一个捕获{}处理程序。显然,除了没有try/catch之外,慢速版本是相同的。调用代码(即Main)在这两种情况下都是相同的,并且具有相同的程序集表示形式(因此这不是内联问题)。

快速版本的反汇编代码:

[0000]按ebp[0001]移动ebp,尤指[0003]推动edi[0004]推动esi[0005]推送ebx[0006]子esp,1Ch[0009]x或eax,eax[000b]mov双字ptr[ebp-20h],eax[000e]mov双字ptr[ebp-1Ch],eax[0011]mov双字ptr[ebp-18h],eax[0014]mov双字ptr[ebp-14h],eax[0017]x或eax,eax[0019]mov双字ptr[ebp-18h],eax*[001c]移动esi,1[0021]异或edi,edi[0023]mov双字ptr[ebp-28h],1[002a]mov双字ptr[ebp-24h],0[0031]包括ecx[0032]移动ebx,2[0037]cmp-ecx,2号[003a]吉利00000024[003c]移动eax,esi[003e]移动编辑,编辑[0040]移动esi,双字ptr[ebp-28h][0043]mov edi,双字ptr[ebp-24h][0046]添加eax,双字ptr[ebp-28h][0049]adc edx,双字ptr[ebp-24h][004c]mov双字ptr[ebp-28h],eax[004f]mov双字ptr[ebp-24h],edx[0052]包括ebx[0053]cmp-ebx、ecx[0055]jl法国队E7[0057]jmp 0000000 7[0059]致电64571ACB[005e]mov eax,双字ptr[ebp-28h][0061]mov edx,双字ptr[ebp-24h][0064]lea-esp,[ebp-0Ch][0067]流行ebx[0068]流行esi[0069]流行编辑[006a]弹出ebp[006b]转

慢速版本的反汇编代码:

[0000]按ebp[0001]移动ebp,尤指[0003]推动esi[0004]子esp,18小时*[0007]mov双字ptr[ebp-14h],1[000e]mov双字ptr[ebp-10h],0[0015]mov双字ptr[ebp-1Ch],1[001c]mov双字ptr[ebp-18h],0[0023]包括ecx[0024]移动esi,2[0029]cmp-ecx,2号[002c]吉利00000031[002e]mov eax,双字ptr[ebp-14h][0031]mov edx,双字ptr[ebp-10h][0034]mov双字ptr[ebp-0Ch],eax[0037]mov双字ptr[ebp-8],edx[003a]mov eax,双字ptr[ebp-1Ch][003d]移动edx,双字ptr[ebp-18h][0040]mov双字ptr[ebp-14h],eax[0043]mov双字ptr[ebp-10h],edx[0046]mov eax,双字ptr[ebp-0Ch][0049]mov edx,双字ptr[ebp-8][004c]添加eax,dword ptr[ebp-1Ch][004f]adc edx,双字ptr[ebp-18h][0052]mov双字ptr[ebp-1Ch],eax[0055]mov双字ptr[ebp-18h],edx[0058]包括[0059]cmp-esi,ecx公司[005b]jl FFFF D3型[005d]mov eax,双字ptr[ebp-1Ch][0060]mov edx,双字ptr[ebp-18h][0063]学习esp,[ebp-4][0066]流行esi[0067]弹出ebp[0068]转

在每种情况下*显示调试器在简单的“单步执行”中输入的位置。

编辑:好的,我现在已经看过代码了,我想我可以看到每个版本的工作原理。。。我认为较慢的版本较慢,因为它使用更少的寄存器和更多的堆栈空间。对于较小的值n个这可能更快,但当循环占用大部分时间时,速度会变慢。

可能是try/catch块军队需要保存和恢复更多寄存器,因此JIT也将这些寄存器用于循环。。。这正好提高了整体性能。目前尚不清楚JIT是否合理决定在“正常”代码中使用尽可能多的寄存器。

编辑:刚在我的x64机器上试过。x64 CLR是许多的在这段代码中,它比x86 CLR快(大约快3-4倍),在x64下,try/catch块没有明显的区别。

17
  • 4
    @Gordon-Simpson,但在只捕获特定异常的情况下,所有其他异常都不会被捕获,因此无论您的无尝试假设中涉及到什么开销,都仍然需要。 评论 2012年1月19日16:58
  • 48
    它看起来像是寄存器分配中的一个差异。快速版本设法使用esi、edi用于其中一个long,而不是堆栈。它使用电子束x作为计数器,慢速版本使用电子感应强度. 评论 2012年1月19日17:59
  • 16
    @杰弗里·萨克斯:这不仅仅是哪一个寄存器的使用数量。慢速版本使用更多堆栈空间,接触的寄存器更少。我不知道为什么。。。
    – 斯基特
    评论 2012年1月19日18:25
  • 2
    如何根据寄存器和堆栈处理CLR异常帧?设置一个寄存器会以某种方式释放寄存器以供使用吗?
    – 随机832
    评论 2012年1月19日18:34
  • 4
    IIRC x64的可用寄存器比x86多。您看到的速度提升与在x86下强制额外使用寄存器的try/catch一致。 评论 2012年1月19日18:43
121

Jon的反汇编显示,这两个版本的区别在于快速版本使用了一对寄存器(esi、edi)存储慢速版本没有的一个局部变量。

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

最后,很难判断哪些代码最终会运行得最快。像寄存器分配这样的东西以及影响它的因素都是如此低级的实现细节,以至于我看不出任何特定的技术如何可靠地生成更快的代码。

例如,考虑以下两种方法。它们改编自现实生活中的一个例子:

接口IIindexed{intthis[intindex]{get;set;}}struct StructArray:已索引{public int[]数组;public int this[int索引]{get{return数组[index];}集合{Array[index]=value;}}}static int Generic<T>(int length,T a,T b),其中T:I为索引{int sum=0;for(int i=0;i<长度;i++)总和+=a[i]*b[i];收益总额;}static int Specialized(int length,StructArray a,StructArray b){int sum=0;for(int i=0;i<长度;i++)总和+=a[i]*b[i];收益总额;}

一个是另一个的通用版本。将泛型类型替换为结构数组将使方法相同。因为结构阵列是一个值类型,它获取自己的泛型方法编译版本。然而,实际的运行时间比专用方法要长得多,但只适用于x86。对于x64,时间几乎完全相同。在其他情况下,我也观察到了x64的差异。

4
  • 7
    有人这么说。。。你能在不使用Try/Catch的情况下强制选择不同的寄存器分配吗?要么作为对这一假设的检验,要么作为对速度进行微调的一般尝试? 评论 2012年1月19日19:13
  • 1
    这一具体案例可能会有所不同,原因有很多。也许这是一次尝试。也许是因为变量在内部范围内被重新使用。无论具体原因是什么,它是一个实现细节,即使在不同的程序中调用完全相同的代码,也不能指望它会被保留。 评论 2012年1月19日20:56
  • 5
    @WernerCD我想说的是,C和C++有一个关键字,表示(a)被许多现代编译器忽略,(B)决定不放在C#中,这表明我们不会直接看到这一点。 评论 2012年1月19日21:02
  • 2
    @WernerCD-仅当您自己编写程序集时 评论 2012年1月20日11:00
77

这看起来像是一个内联失败的案例。在x86内核上,jitter具有ebx、edx、esi和edi寄存器,可用于本地变量的通用存储。ecx寄存器在静态方法中可用,它不必存储计算通常需要eax寄存器。但这些是32位寄存器,对于long类型的变量,它必须使用一对寄存器。其中edx:eax用于计算,edi:ebx用于存储。

这就是慢速版本反汇编中最突出的部分,既没有使用edi也没有使用ebx。

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

抖动将尽力应用另一个代码生成优化,它将尝试内联您的Fibo()方法。换句话说,不要调用该方法,而是在Main()方法中为该方法生成内联代码。这是一个非常重要的优化,其中之一是免费生成C#类的属性,使其具有字段的性能。它避免了进行方法调用和设置堆栈框架的开销,节省了几纳秒。

有几个规则可以准确地确定方法何时可以内联。它们没有确切的文档记录,但在博客帖子中提到过。一个规则是,当方法体太大时,它不会发生。这会破坏内联的收益,它生成的代码太多,不适合一级指令缓存。这里适用的另一个硬规则是,当方法包含try/catch语句时,它不会被内联。它背后的背景是异常的实现细节,它们依赖于Windows对基于堆栈框架的SEH(Structure Exception Handling)的内置支持。

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

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

缺陷是它没有回去再生方法的代码。这是可以理解的,因为它必须在时间限制下运作。

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

0
22

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

4

9年后,错误仍然存在!您可以通过以下方式轻松查看:

static void Main(字符串[]参数){整数亿=1000000;DateTime开始=DateTime。现在;双平方米;对于(int i=0;i<亿;i++){sqrt=数学。Sqrt(DateTime.Now.ToOADate());}DateTime end=日期时间。现在;double sqrtMs=(结束-开始)。总毫秒;慰问。WriteLine(“经过的毫秒数:”+sqrtM);DateTime start2=日期时间。现在;双平方米2;for(int i=0;i<一亿;i++){尝试{sqrt2=数学。Sqrt(DateTime.Now.ToOADate());}catch(异常e){整数br=0;}}DateTime end2=日期时间。现在;double sqrtMsTryCatch=(end2-start2)。总毫秒;慰问。WriteLine(“已用毫秒:”+sqrtMsTryCatch);慰问。WriteLine(“比率为”+sqrtMsTryCatch/sqrtMs);慰问。ReadLine();}

在我运行最新版本的MSVS2019的机器上,这个比率小于一。净额4.6.1

4
  • 1
    我运行了这个代码。NET 5.0。在x86上,不使用try-catch需要530-535毫秒,而使用try-catch则需要1-3%的时间。构建x64时,不使用try-catch需要218-222 ms,使用try-catch需要11-13%。真好奇。
    – 狐狸石
    评论 2021年1月7日12:40
  • 仅供参考:最好使用秒表衡量绩效。
    – 土地的
    评论 2022年1月28日11:10
  • 我过去用秒表,但我没有注意到任何优点
    – 马库斯
    评论 2022年1月28日12:12
  • 2
    甚至比秒表更好基准点网 评论 2022年5月17日10:06

不是你想要的答案吗?浏览标记的其他问题问你自己的问题.