题 尝试抓住加速我的代码?


我写了一些代码来测试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的 优秀 分析 表明try-catch在某种程度上导致x86 CLR在这种特定情况下以更有利的方式使用CPU寄存器(我认为我们还没理解为什么)。我确认Jon发现x64 CLR没有这个区别,并且它比x86 CLR更快。我也测试过使用过 int Fibo方法里面的类型代替 long 类型,然后x86 CLR与x64 CLR一样快。


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


1342
2018-01-19 15:10


起源


@Lloyd他试图回答他的问题“它实际上跑得更快!但为什么?” - Andreas Niedermair
所以,现在“吞咽异常”从糟糕的做法转变为良好的绩效优化:P - Luciano
这是在未经检查或检查的算术上下文中吗? - Random832
@taras.roshko:虽然我不想让Eric伤害,但这不是一个C#问题 - 这是一个JIT编译器问题。最终的困难在于解决为什么x86 JIT没有像try / catch那样使用尽可能多的寄存器 同 try / catch块。 - Jon Skeet
很好,所以如果我们试试这些尝试捕获量,我们的速度会更快吗? - Chuck Pinkert


答案:


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

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

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

此外,我们正在努力改进Roslyn到C#和VB编译器的算法,以确定何时可以使本地变为“短暂” - 也就是说,只是在堆栈上推送和弹出,而不是在堆栈上分配特定位置激活的持续时间。我们相信JITter能够更好地完成寄存器分配,如果我们给出更好的提示,可以让当地人更早地“死”。

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


928
2018-01-20 20:14



我一直想知道为什么C#编译器会产生这么多无关的本地化。例如,新的数组初始化表达式总是生成本地,但永远不需要生成本地。如果它允许JITter生成性能更高的代码,那么C#编译器可能会对生成不必要的本地代码更加谨慎...... - Timwi
@Timwi:绝对的。在未经优化的代码中,编译器会产生不必要的本地因素,因为它们使调试更容易。在优化代码中,如果可能,应删除不必要的临时代码。不幸的是,多年来我们遇到了许多错误,我们意外地对临时消除优化器进行了优化。前面提到的工程师完全从头开始重新编写Roslyn的所有代码,因此我们应该在Roslyn代码生成器中有很多改进的优化行为。 - Eric Lippert
在这个问题上有没有任何动静? - Robert Harvey♦
@RobertHarvey:Eric Lippert现在不在MS工作......我认为他不能给出最新进展.. - Danny Chen
你错过了把它称为“JITter bug”的机会。 - mbomb007


好吧,你对事情进行计时的方式对我来说非常讨厌。对整个循环进行计时会更加明智:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

这样你就不会受到微小时序,浮点运算和累积误差的影响。

做了这个改变之后,看看“非捕获”版本是否仍然比“catch”版本慢。

编辑:好的,我自己尝试过 - 而且我看到的结果相同。很奇怪。我想知道try / catch是否禁用了一些错误的内联,但是使用了 [MethodImpl(MethodImplOptions.NoInlining)]相反没有帮助......

基本上你需要查看cordbg下优化的JITted代码,我怀疑......

编辑:一些信息:

  • 将try / catch放在一起 n++; line仍然提高了性能,但并没有将它放在整个块上
  • 如果你发现一个特定的例外(ArgumentException 在我的测试中)它仍然很快
  • 如果在catch块中打印异常,它仍然很快
  • 如果你在catch块中重新抛出异常,它会再次变慢
  • 如果你使用finally块而不是catch块,它会再次变慢
  • 如果你使用finally块 以及 一个捕获块,它很快

奇怪的...

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

这是使用C#2编译器和.NET 2(32位)CLR,与mdbg分解(因为我的机器上没有cordbg)。即使在调试器下,我仍然会看到相同的性能影响。快速版使用了 try 阻止变量声明和return语句之间的所有内容,只需一个 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 / catch块没有明显的区别。


702
2018-01-19 15:15



@GordonSimpson但是在只捕获特定异常的情况下,所有其他异常都不会被捕获,因此仍然需要在您的假设中进行无尝试的任何开销。 - Jon Hanna
它看起来像寄存器分配的差异。快速版本设法使用 esi,edi 其中一个长而不是堆栈。它用 ebx 作为计数器,慢版本使用的地方 esi。 - Jeffrey Sax
@JeffreySax:这不仅仅是 哪一个 使用寄存器但有多少。慢版本使用更多的堆栈空间,触及更少的寄存器。我不知道为什么...... - Jon Skeet
IIRC x64有比x86更多的寄存器。您看到的加速与try / catch强制一致,强制在x86下使用额外的寄存器。 - Dan Neely
@JonSkeet我downvoted 当天早些时候 当它看起来没有回答这个问题时(它只是提出了一个建议,并确认了OP的经验,这似乎更像是评论)。 Downvote现在被移除(实际上转换为upvote),因为这有一些非常合理的解释=) - jadarnel27


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

JIT编译器对包含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的差异。


110
2018-01-19 18:27



有了这样说......你可以强制使用不同的寄存器分配选择而不使用Try / Catch吗?要么作为这个假设的测试,要么作为调整速度的一般尝试? - WernerCD
这种具体情况可能有所不同,有很多原因。也许这是尝试捕获。也许这是变量在内部范围中重复使用的事实。无论具体原因是什么,它都是一个实现细节,即使在不同的程序中调用完全相同的代码,也无法保留。 - Jeffrey Sax
@WernerCD我要说的是C和C ++有一个关键字,用于表明许多现代编译器忽略了哪个(A)和(B)决定不放入C#,这表明这不是我们的事情。我会以更直接的方式看到。 - Jon Hanna
@WernerCD - 仅在您自己编写程序集时 - OrangeDog


这看起来像一个内联变坏的案例。在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来存储长变量。你得到了慢速版本,因为抖动事先并不知道内联不起作用。它才发现  生成方法体的代码。

那个缺点是它没有回去 再生 方法的代码。考虑到它必须运行的时间限制,这是可以理解的。

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


65
2017-08-03 10:42





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


18
2018-01-20 13:15