我写了一些测试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()中用try-catch块包装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更快。我还测试了在Fibo方法中使用int类型而不是长类型,然后x86CLR与x64 CLR一样快。


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


当前回答

这看起来像是一个内联失效的例子。在x86内核上,抖动具有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(结构异常处理)的内置支持。

寄存器分配算法在抖动中的一种行为可以从播放该代码中推断出来。它似乎知道抖动何时试图内联方法。它似乎使用的一条规则是,只有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

这看起来像是一个内联失效的例子。在x86内核上,抖动具有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(结构异常处理)的内置支持。

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

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

缺陷在于它没有返回并重新生成该方法的代码。考虑到运营所需的时间限制,这是可以理解的。

x64上不会出现这种速度减慢的情况,因为对于一个来说,它还有8个寄存器。另一个原因是它只能在一个寄存器中存储long(如rax)。当您使用int而不是long时,速度不会减慢,因为抖动在选择寄存器时具有更大的灵活性。

一位专门了解堆栈使用优化的Roslyn工程师对此进行了研究,并向我报告,C#编译器生成本地变量存储的方式与JIT编译器在相应的x86代码中注册调度的方式之间的交互似乎存在问题。结果是在本地程序的加载和存储上生成次优代码。

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

这很奇怪。我们将与JITter团队一起跟进,看看是否可以输入一个bug,以便他们能够解决这个问题。

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

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

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

Jon的反汇编显示,这两个版本之间的区别在于,快速版本使用一对寄存器(esi、edi)来存储其中一个本地变量,而慢速版本没有。

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