我写了一些代码来测试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的值 .
当我使用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 ...... - 它实际上跑得更快!但为什么?
注意:我使用Release配置编译它并直接运行EXE文件(在Visual Studio外部) .
编辑:Jon Skeet's excellent analysis显示try-catch在某种程度上导致x86 CLR在这种特定情况下以更有利的方式使用CPU寄存器(我认为我们发现x64 CLR没有这种差异,并且它是比x86 CLR更快 . 我还在Fibo方法中使用 int
类型而不是 long
类型进行了测试,然后x86 CLR与x64 CLR一样快 .
UPDATE: Roslyn已经解决了这个问题 . 相同的机器,相同的CLR版本 - 在使用VS 2013编译时问题仍然如上所述,但在使用VS 2015编译时问题就消失了 .
5 回答
这看起来像一个内联变坏的案例 . 在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 / catch语句时,不会内联方法 . 这一背后的背景是异常的实现细节,它们捎带到Windows的内置支持SEH(结构异常处理),它是基于堆栈帧的 .
抖动中的寄存器分配算法的一种行为可以通过使用该代码来推断 . 它似乎知道抖动何时试图内联一个方法 . 似乎使用的一条规则是只有edx:eax寄存器对可用于具有long类型的局部变量的内联代码 . 但不是edi:ebx . 毫无疑问,因为这对调用方法的代码生成太有害,edi和ebx都是重要的存储寄存器 .
所以你得到了快速版本,因为抖动事先知道方法体包含try / catch语句 . 它知道它永远不会被内联,所以很容易使用edi:ebx来存储long变量 . 你有慢速版本,因为抖动没有工作 . 它只在生成方法体的代码后才发现 .
然后,缺陷是它没有返回并重新生成该方法的代码 . 考虑到它必须运行的时间限制,这是可以理解的 .
在x64上不会发生这种减速,因为对于一个它有8个寄存器 . 另一个是因为它可以在一个寄存器(如rax)中存储一个long . 当你使用int而不是long时,不会发生减速,因为抖动在选择寄存器时有更大的灵活性 .
我已经把它作为一个评论,因为我真的不确定这可能是这种情况,但我记得它不是一个尝试/除了声明涉及修改垃圾处理机制的方式编译器工作,因为它以递归方式从堆栈中清除对象内存分配 . 在这种情况下可能没有要清除的对象,或者for循环可能构成一个垃圾收集机制认识到的封闭足以强制执行不同的收集方法 . 可能不是,但我认为值得一提,因为我没有看到它在其他地方讨论过 .
专门了解堆栈使用优化的Roslyn工程师之一看了一下这个并告诉我,C#编译器生成局部变量存储的方式与JIT编译器注册的方式之间的交互似乎存在问题在相应的x86代码中进行调度 . 结果是在本地的加载和存储上生成次优代码 .
由于某些原因我们所有人都不清楚,当JITter知道该块在try-protected区域时,可以避免有问题的代码生成路径 .
这很奇怪 . 我们将跟进JITter团队,看看我们是否可以输入错误,以便他们可以解决这个问题 .
此外,我们正在努力改进Roslyn到C#和VB编译器的算法,以确定何时可以使本地变为“短暂” - 也就是说,只是在堆栈上推送和弹出,而不是在堆栈上分配特定位置激活的持续时间 . 我们相信JITter能够更好地完成寄存器分配,如果我们给出更好的提示,可以更早地了解本地人何时“死” .
感谢您引起我们的注意,并为奇怪的行为道歉 .
好吧,你对事情进行计时的方式对我来说非常讨厌 . 对整个循环进行计时会更加明智:
这样你就不会受到微小时序,浮点运算和累积误差的影响 .
做了这个改变之后,看看“非捕获”版本是否仍然比“catch”版本慢 .
编辑:好的,我've tried it myself - and I'看到相同的结果 . 很奇怪 . 我想知道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
块围绕变量声明和return语句之间的所有内容,只使用catch{}
处理程序 . 显然慢速版本是相同的,除了没有try / catch . 调用代码(即Main)在两种情况下都是相同的,并且具有相同的程序集表示(因此它不是内联问题) .快速版本的反汇编代码:
慢速版的反汇编代码:
在每种情况下,
*
显示调试器在简单的"step-into"中输入的位置 .编辑:好的,我现在已经查看了代码,我想我可以看到每个版本的工作原理......我相信较慢的版本较慢,因为它使用较少的寄存器和更多的堆栈空间 . 对于
n
的小值,'s possibly faster - but when the loop takes up the bulk of the time, it'慢 .可能try / catch块会强制保存和恢复更多的寄存器,因此JIT也会将这些寄存器用于循环......这恰好会提高整体性能 . 对于JIT来说,'s not clear whether it'是一个合理的决定,不能在"normal"代码中使用尽可能多的寄存器 .
编辑:刚刚在我的x64机器上试过这个 . x64 CLR比此代码上的x86 CLR快得多(大约快3-4倍),而在x64下,try / catch块没有明显的区别 .
Jon的反汇编显示,两个版本之间的区别在于快速版本使用一对寄存器(
esi,edi
)来存储慢速版本不存在的局部变量之一 .JIT编译器对包含try-catch块的代码与不具有try-catch块的代码的寄存器使用做出了不同的假设 . 这导致它做出不同的寄存器分配选择 . 在这种情况下,这有利于try-catch块的代码 . 不同的代码可能导致相反的效果,所以我不认为这是一种通用的加速技术 .
最后,很难分辨哪些代码最终会以最快的速度运行 . 寄存器分配和影响它的因素之类的是低级实现细节,我不知道任何特定技术如何能够可靠地生成更快的代码 .
例如,请考虑以下两种方法 . 他们是改编自现实生活中的例子:
一个是另一个的通用版本 . 用
StructArray
替换泛型类型会使方法相同 . 因为StructArray
是一个值类型,所以它获得了自己的泛型方法的编译版本 . 然而,实际运行时间明显长于专门的方法_259452已经观察到x64的差异 .