C#编译器优化

17

我想知道是否有人能够向我解释编译器到底在做什么,以致于我观察到一个简单方法的性能有如此巨大的差异。

 public static uint CalculateCheckSum(string str) { 
    char[] charArray = str.ToCharArray();
    uint checkSum = 0;
    foreach (char c in charArray) {
        checkSum += c;
    }
    return checkSum % 256;
 }

我正在与一位同事合作对消息处理应用程序进行基准测试/优化。在 Visual Studio 2012 中,对于相同的输入字符串,使用相同的函数执行 1000 万次迭代大约需要 25 秒,但是当打开“优化代码”选项构建项目时,相同代码执行相同的 1000 万次迭代仅需 7 秒。

我非常想了解编译器在幕后做了些什么,以使我们能够看到这样一个看似无辜的代码块的性能提高超过 3 倍。

按要求,这里是一个完整的控制台应用程序,演示了我所看到的情况。

class Program
{
    public static uint CalculateCheckSum(string str)
    {
        char[] charArray = str.ToCharArray();
        uint checkSum = 0;
        foreach (char c in charArray)
        {
            checkSum += c;
        }
        return checkSum % 256;
    }

    static void Main(string[] args)
    {
        string stringToCount = "8=FIX.4.29=15135=D49=SFS56=TOMW34=11752=20101201-03:03:03.2321=DEMO=DG00121=155=IBM54=138=10040=160=20101201-03:03:03.23244=10.059=0100=ARCA10=246";
        Stopwatch stopwatch = Stopwatch.StartNew();
        for (int i = 0; i < 10000000; i++)
        {
            CalculateCheckSum(stringToCount);
        }
        stopwatch.Stop();
        Console.WriteLine(stopwatch.Elapsed);
    }
}

在关闭优化的调试模式下运行,我看到需要13秒,启用优化后只需2秒。

在关闭优化的发布模式下运行需要3.1秒,启用优化后只需2.3秒。


6
几个问题:您是否在运行“Release”模式?您是否使用“Stopwatch”进行计时? - Mike Perrenoud
1
一个字符串已经是字符数组了。ToCharArray 是多余的... 因此,编译器可能正在执行类似于 for(int i = 0; i < str.length; i ++) ... 的操作。 - Alex
3
我会坐下来等待Eric Lippert提供权威的、没有猜测的回答。 - Yuck
3
我建议你强烈地将它作为独立运行的程序(例如作为控制台应用程序)而不是从单元测试运行器中运行。尽可能删除多余的内容。 - Jon Skeet
2
你能同时发布你的输入字符串吗?或者更理想的是,提供一个简短但完整的程序来演示差异。 - Jon Skeet
显示剩余15条评论
3个回答

8
要了解C#编译器为您做了什么,您需要查看IL。如果您想了解它如何影响JIT代码,则需要查看由Scott Chamberlain描述的本地代码。请注意,基于处理器架构、CLR版本、进程启动方式和可能的其他因素,JIT代码将有所不同。
通常我会从IL开始,然后可能查看JIT代码。
使用ildasm比较IL可能有些棘手,因为它包括每个指令的标签。这里有两个版本的方法,一个是经过优化的,一个是没有经过优化的(使用C# 5编译器),已删除多余的标签(和nop指令)以使它们易于比较。
  .method public hidebysig static uint32 
          CalculateCheckSum(string str) cil managed
  {
    // Code size       46 (0x2e)
    .maxstack  2
    .locals init (char[] V_0,
             uint32 V_1,
             char V_2,
             char[] V_3,
             int32 V_4)
    ldarg.0
    callvirt   instance char[] [mscorlib]System.String::ToCharArray()
    stloc.0
    ldc.i4.0
    stloc.1
    ldloc.0
    stloc.3
    ldc.i4.0
    stloc.s    V_4
    br.s       loopcheck
  loopstart:
    ldloc.3
    ldloc.s    V_4
    ldelem.u2
    stloc.2
    ldloc.1
    ldloc.2
    add
    stloc.1
    ldloc.s    V_4
    ldc.i4.1
    add
    stloc.s    V_4
  loopcheck:
    ldloc.s    V_4
    ldloc.3
    ldlen
    conv.i4
    blt.s      loopstart
    ldloc.1
    ldc.i4     0x100
    rem.un
    ret
  } // end of method Program::CalculateCheckSum

未优化的

  .method public hidebysig static uint32 
          CalculateCheckSum(string str) cil managed
  {
    // Code size       63 (0x3f)
    .maxstack  2
    .locals init (char[] V_0,
             uint32 V_1,
             char V_2,
             uint32 V_3,
             char[] V_4,
             int32 V_5,
             bool V_6)
    ldarg.0
    callvirt   instance char[] [mscorlib]System.String::ToCharArray()
    stloc.0
    ldc.i4.0
    stloc.1
    ldloc.0
    stloc.s    V_4
    ldc.i4.0
    stloc.s    V_5
    br.s       loopcheck

  loopstart:
    ldloc.s    V_4
    ldloc.s    V_5
    ldelem.u2
    stloc.2
    ldloc.1
    ldloc.2
    add
    stloc.1
    ldloc.s    V_5
    ldc.i4.1
    add
    stloc.s    V_5
  loopcheck:
    ldloc.s    V_5
    ldloc.s    V_4
    ldlen
    conv.i4
    clt
    stloc.s    V_6
    ldloc.s    V_6
    brtrue.s   loopstart

    ldloc.1
    ldc.i4     0x100
    rem.un
    stloc.3
    br.s       methodend

  methodend:
    ldloc.3
    ret
  }

注意事项:

  • 优化版使用更少的本地变量。这可能允许JIT更有效地使用寄存器。
  • 当检查是否再次循环时,优化版本使用blt.s而不是clt后跟brtrue.s(这是其中一个额外本地变量的原因)。
  • 未优化版使用额外的本地变量来存储返回值,可能是为了更容易进行调试。
  • 未优化版在返回之前有一个无条件分支。
  • 优化版更短,但我怀疑它是否足够短,以便内联,所以我认为这是无关紧要的。

6

1
仅仅查看IL是不够的,你需要查看JIT输出以查看应用的大部分优化。 - Scott Chamberlain
2
@ScottChamberlain: 不是通过编译器的优化标志。编译器的输出是IL。任何由编译器标志引起的更改都必须在IL中存在。JIT如何优化事物是另一回事。 - Jon Skeet
3
请注意,您无需使用Reflector(免费试用之外的付费产品)- ildasm就可以很好地完成工作。 - Jon Skeet
3
ILSpy是Reflector的免费替代品,同样可以反编译为C#或IL。 - Scott Chamberlain

5
我不知道它正在做什么优化,但我可以向您展示如何自行查找。
首先,构建您的代码进行优化,并在未附加调试器的情况下启动它(如果附加了调试器,则JIT编译器将生成不同的代码)。运行您的代码,以便您知道该部分至少被输入一次,以便JIT编译器有机会处理它,并在Visual Studio中转到调试->附加到进程...。从新菜单中选择您正在运行的应用程序。
在您想知道的位置设置断点,让程序停止,一旦停止,转到调试->窗口->反汇编。这将显示JIT创建的已编译代码,您将能够检查它正在做什么。

2
查看JIT编译后的代码并不能显示编译器输出的差异。在这种情况下,你评论但被删除的答案是正确的,我认为。该标志是针对编译器的,编译器的输出是IL。 - Jon Skeet
@JonSkeet:OP确实问到了编译器,但在我看来他似乎认为所有的优化都是在那里发生的。如果编译器输出的IL更容易被JIT优化,那么编译器的差异只是方程式的一半。 - Ed S.
哦,如果你正在调试 - 那将大大改变JIT编译的代码。在我的经验中,看到完全优化的JIT编译代码更加棘手。 - Jon Skeet
@JonSkeet 我描述的过程难道不是展示非调试JIT代码的方法吗(启动时不使用调试,允许JIT运行该方法,然后附加调试器)? - Scott Chamberlain
@ScottChamberlain:啊,我错过了那一点,抱歉。我的错误。但是,如果您想查看编译器优化的内容,我仍然认为IL是首要的地方。 - Jon Skeet
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接