ChatGPT解决这个技术问题 Extra ChatGPT

Try-catch 加速我的代码?

我写了一些代码来测试 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 块将 for 循环包装在 Fibo() 中时:

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 寄存器(我认为我们尚未了解原因)。我证实了 Jon 的发现,即 x64 CLR 没有这种差异,而且它比 x86 CLR 更快。我还在 Fibo 方法中使用 int 类型而不是 long 类型进行了测试,然后 x86 CLR 与 x64 CLR 一样快。

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

@Lloyd 他试图回答他的问题“它实际上运行得更快!但是为什么呢?”
所以,现在“吞咽异常”从一个不好的做法变成了一个好的性能优化:P
这是在未经检查或检查的算术上下文中吗?
@taras.roshko:虽然我不想对 Eric 造成伤害,但这并不是一个真正的 C# 问题——它是一个 JIT 编译器问题。最终的困难是弄清楚为什么 x86 JIT 在没有 try/catch 的情况下使用的寄存器不如使用 try/catch 块的寄存器多。
太好了,所以如果我们嵌套这些尝试捕获,我们可以走得更快,对吧?

L
Lightness Races in Orbit

一位专门了解堆栈使用优化的 Roslyn 工程师查看了这个并向我报告,C# 编译器生成局部变量存储的方式与 {2编译器在相应的 x86 代码中注册调度。结果是在本地人的加载和存储上生成次优代码。

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

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

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

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


我一直想知道为什么 C# 编译器会生成这么多无关的局部变量。例如,新的数组初始化表达式总是生成一个局部变量,但从来不需要生成一个局部变量。如果它允许 JITter 生成性能更高的代码,也许 C# 编译器应该更加小心地生成不必要的局部变量......
@Timwi:当然。在未优化的代码中,编译器会大大放弃生成不必要的局部变量,因为它们使调试更容易。在优化的代码中,如果可能的话,应该删除不必要的临时变量。不幸的是,这些年来我们遇到了很多错误,我们不小心取消了临时消除优化器的优化。前面提到的工程师正在完全从头开始为 Roslyn 重新编写所有这些代码,因此我们应该在 Roslyn 代码生成器中大大改进了优化行为。
在这个问题上是否有任何动静?
看起来罗斯林确实修复了它。
J
Jon Skeet

嗯,你计时的方式对我来说看起来很讨厌。只为整个循环计时会更明智:

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++ 周围; line 仍然可以提高性能,但不如将它放在整个块周围那么多

如果您捕获到特定异常(我的测试中的 ArgumentException),它仍然很快

如果您在 catch 块中打印异常,它仍然很快

如果你在 catch 块中重新抛出异常,它又会变慢

如果你使用 finally 块而不是 catch 块,它又会变慢

如果你使用 finally 块和 catch 块,它会很快

诡异的...

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

这是使用 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/catch 块不会产生明显的差异。


@GordonSimpson,但在仅捕获特定异常的情况下,将不会捕获所有其他异常,因此您的不尝试假设中涉及的任何开销仍然需要。
它看起来像寄存器分配的差异。快速版本设法将 esi,edi 用于其中一个 long 而不是堆栈。它使用 ebx 作为计数器,慢版本使用 esi
@JeffreySax:不仅仅是使用了哪些寄存器,还有使用了多少。慢版本使用更多的堆栈空间,接触更少的寄存器。我不知道为什么...
CLR 异常帧在寄存器和堆栈方面是如何处理的?设置一个可以以某种方式释放一个寄存器以供使用吗?
IIRC x64 比 x86 有更多可用的寄存器。您看到的加速与在 x86 下强制使用额外寄存器的 try/catch 一致。
J
Jeffrey Sax

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

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


话虽如此......你可以在不使用 Try/Catch 的情况下强制选择不同的寄存器分配吗?是作为对这一假设的检验,还是作为调整速度的一般尝试?
这种特定情况可能不同的原因有很多。也许这是尝试捕捉。也许这是变量在内部范围内重复使用的事实。无论具体原因是什么,即使在不同的程序中调用完全相同的代码,您也不能指望保留它的实现细节。
@WernerCD 我想说的是,C 和 C++ 有一个关键字表明许多现代编译器忽略了哪个 (A) 和 (B) 决定不放入 C#,这表明这不是我们的东西会以更直接的方式看到。
@WernerCD - 仅当您自己编写程序集时
H
Hans Passant

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

这是慢版本反汇编中的突出之处,既不使用 edi 也不使用 ebx。

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

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

有几个规则可以准确地确定何时可以内联方法。它们没有完全记录在案,但在博客文章中有所提及。一条规则是,当方法体太大时,它不会发生。这破坏了内联的好处,它生成了太多不适合 L1 指令缓存的代码。另一个适用于此的硬性规则是,当一个方法包含 try/catch 语句时,它不会被内联。其背后的背景是异常的实现细节,它们搭载 Windows 对基于堆栈帧的 SEH(结构异常处理)的内置支持。

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

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

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

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


m
miller the gorilla

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


M
Markus

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();
    }

在我的机器上比例不到1,运行最新版MSVS 2019,.NET 4.6.1


我在 .NET 5.0 中运行了这段代码。在 x86 上,没有 try-catch 需要 530-535 毫秒,而使用 try-catch 会减少 1-3%。在构建 x64 时,不使用 try-catch 需要 218-222 毫秒,使用 try-catch 会减少 11-13%。多么好奇。
仅供参考:最好使用 Stopwatch 来衡量性能。
我曾经使用秒表,但我没有注意到任何优点
比秒表更好的是BenchmarkDotNet