为什么添加本地变量会使.NET代码变慢

44

如果注释掉这个for循环的前两行并取消注释第三行,为什么会导致速度提升42%?

int count = 0;
for (uint i = 0; i < 1000000000; ++i) {
    var isMultipleOf16 = i % 16 == 0;
    count += isMultipleOf16 ? 1 : 0;
    //count += i % 16 == 0 ? 1 : 0;
}

在这个不同的时间背后,是完全不同的汇编代码:在循环中有13条指令和7条指令。该平台是运行.NET 4.0 x64的Windows 7操作系统。开启了代码优化,并且测试应用程序在VS2010之外运行。[更新:重现项目,可用于验证项目设置。]

消除中间的布尔型变量是一种基本优化,是我上世纪80年代的龙书中最简单的优化之一。为什么在生成CIL或JIT x64机器代码时没有应用此优化呢?

是否有一个类似“真正的编译器,请优化这段代码”的开关?虽然我赞同过早优化就像贪爱钱财一样,但我可以理解对于那些算法复杂且散布在例程中出现此类问题的人们的沮丧。你会处理热点,但却没有暗示可以通过手动调整通常从编译器那里惯常获得的优点来大幅改进更广泛的“温暖区域”。我真希望我在这里错过了什么。

更新:x86的速度差异也会发生,但取决于方法刚刚进行即时编译的顺序。请参见为什么JIT顺序影响性能?

汇编代码(按要求):

    var isMultipleOf16 = i % 16 == 0;
00000037  mov         eax,edx 
00000039  and         eax,0Fh 
0000003c  xor         ecx,ecx 
0000003e  test        eax,eax 
00000040  sete        cl 
    count += isMultipleOf16 ? 1 : 0;
00000043  movzx       eax,cl 
00000046  test        eax,eax 
00000048  jne         0000000000000050 
0000004a  xor         eax,eax 
0000004c  jmp         0000000000000055 
0000004e  xchg        ax,ax 
00000050  mov         eax,1 
00000055  lea         r8d,[rbx+rax] 
    count += i % 16 == 0 ? 1 : 0;
00000037  mov         eax,ecx 
00000039  and         eax,0Fh 
0000003c  je          0000000000000042 
0000003e  xor         eax,eax 
00000040  jmp         0000000000000047 
00000042  mov         eax,1 
00000047  lea         edx,[rbx+rax] 

4
我很想看到不同的汇编代码。你能发一下吗? - phoog
1
你测试过 bool isMultipleOf16 = ... 吗? - David.Chu.ca
3
@David.Chu.ca - 那也没什么区别 - var 的意思是“编译器,请推断出这个变量的类型,并假装我写了它”。在这种情况下,它会自行推断出 bool 类型。 - Damien_The_Unbeliever
9
由于你是在Debug模式下执行的,因此所有打赌都无效。 - BrokenGlass
3
@EdwardBrey:我暂时找不到来源,但我认为如果你全程连接了调试器(即使你在“Release”模式下编译),jitter和/或其他优化设置会有所不同。尝试从命令行运行代码(而不是从VS),看看会发生什么。 - Daniel Pryden
显示剩余14条评论
5个回答

9

问题应该是“为什么在我的机器上会有如此大的差异?”我无法复制这样的巨大速度差异,怀疑您的环境中有一些特定的问题。尽管很难确定它可能是什么。可能是您之前设置的某些(编译器)选项,而您已经忘记了。

我创建了一个控制台应用程序,在Release模式(x86)下重新构建并在VS外运行。结果几乎相同,两种方法都需要1.77秒。以下是确切的代码:

static void Main(string[] args)
{
    Stopwatch sw = new Stopwatch();
    sw.Start();
    int count = 0;

    for (uint i = 0; i < 1000000000; ++i)
    {
        // 1st method
        var isMultipleOf16 = i % 16 == 0;
        count += isMultipleOf16 ? 1 : 0;

        // 2nd method
        //count += i % 16 == 0 ? 1 : 0;
    }

    sw.Stop();
    Console.WriteLine(string.Format("Ellapsed {0}, count {1}", sw.Elapsed, count));
    Console.ReadKey();
}

请大家花5分钟时间将代码复制,重新构建并在VS外运行,然后在评论中发布结果。我不想说“在我的机器上可以运行”。
编辑:
为了确保,我创建了一个64位的Winforms应用程序,结果与问题中类似-第一种方法比第二种方法慢(1.57秒比1.05秒)。我观察到的差异是33%——还是很多的。看来.NET4 64位JIT编译器存在bug。

1
你需要对 count 进行一些操作(例如将它包括在 WriteLine 语句中)。否则优化器会进行一些选择性的优化,这个过程随时间变化。 - Edward Brey
2
@EdwardBrey,我只能在 64 位应用程序中复现它。 - Maciej
3
在两个平台上运行您的测试,多行版本的执行速度变慢。但如果我改变测试,让它运行4次多行版本,然后运行4次单行版本,在x86上就没有速度差异(x64不受影响)。 - Maciej
1
@Maciej 很好的观察。看起来调用哪个方法首先被调用很重要。这似乎几乎肯定是由于JIT顺序造成的。但为什么JIT顺序很重要却令人困惑。我已经更新了问题正文,并附上了你的观察引发的新问题的链接。 - Edward Brey
@Maciej 抱歉,但我认为你的回答并没有真正回答问题。它帮助我们找到了一个混淆的问题,即对于x86,对齐惩罚可能会导致经过良好优化的代码运行得像不完全优化的代码一样慢。但问题仍然存在,为什么会有不完全优化的x64(和x86)代码生成的情况呢? - Edward Brey
显示剩余5条评论

4

我无法对.NET编译器及其优化以及何时进行优化进行说明。

但在这种情况下,如果编译器将布尔变量折叠到实际语句中,并且您尝试调试此代码,则优化后的代码将与编写的代码不匹配。您将无法单步执行isMulitpleOf16赋值并检查其值。

这只是其中一个优化可能被关闭的示例。还可能有其他情况。优化可能发生在代码的加载阶段,而不是CLR的代码生成阶段。

现代运行时非常复杂,特别是如果您还加入了JIT和动态优化的运行时。有时我感到很庆幸代码能够按照预期工作。


当我看到汇编代码时,我想知道是否禁用了优化。我通过在VS2010调试器中停止断点并使用反汇编窗口获取了汇编代码(而我得到的时间是在没有调试器运行的情况下)。作为一个测试,我打开了工具>选项>调试>常规>"抑制模块上的JIT优化"设置。果然,汇编代码变得更大了。 - Edward Brey
在本地的C++世界中,启用优化时断点和代码顺序很奇怪是完全正常的。同样,像isMultipleOf16这样的变量在调试器中并不总是可用的。这就是为什么有调试模式的原因。归根结底,我们仍然运行着相同的机器码,所以我不明白CLR为什么会有任何不同的地方。事实上,在C#中发生异常时,即使在调试模式下,有时也会收到关于变量值被“优化掉”的消息。 - Edward Brey
1
+1 对于调试设置可能会影响代码生成的评论。 - Marco van de Voort

3

这是.NET框架中的一个错误。

我只是推测,但我在Microsoft Connect上提交了一个错误报告,希望看看他们的回应。Microsoft Connect。在Microsoft删除该报告后,我在GitHub上的roslyn项目上重新提交了它。

更新: Microsoft已将此问题移至coreclr项目。从对该问题的评论来看,称其为错误似乎有点过分;它更像是一个缺失的优化。


9
如果我拥有一美元,每当一位程序员告诉我:“我的代码不起作用了,这一定是框架(或编译器或运行库等)的问题”,而后发现这其实是他自己代码中的一个bug,那么我可能就可以退休了。 - Jim Mischel
1
@Jim:我自己也见过很多次了。我知道的最好的解决方法是尽可能将行为隔离,并向供应商提供可重现的情况。并保持观望态度。这就是我们目前所处的状态。 - Edward Brey
1
@TankorSmash 或许微软在将代码移至GitHub时删除了它。它不再出现在我的Connect仪表板上了。我报告的许多问题似乎都消失了。收到某种通知会很好。我已经将该问题重新提交到GitHub项目,并相应地更新了答案。 - Edward Brey

2

我认为这与你之前的问题有关。当我将你的代码更改如下时,多行版本胜出。

哎呀,只在x86上有效。在x64上,多行是最慢的,条件语句则轻松击败它们两个。

class Program
{
    static void Main()
    {
        ConditionalTest();
        SingleLineTest();
        MultiLineTest();
        ConditionalTest();
        SingleLineTest();
        MultiLineTest();
        ConditionalTest();
        SingleLineTest();
        MultiLineTest();
    }

    public static void ConditionalTest()
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        int count = 0;
        for (uint i = 0; i < 1000000000; ++i) {
            if (i % 16 == 0) ++count;
        }
        stopwatch.Stop();
        Console.WriteLine("Conditional test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
    }

    public static void SingleLineTest()
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        int count = 0;
        for (uint i = 0; i < 1000000000; ++i) {
            count += i % 16 == 0 ? 1 : 0;
        }
        stopwatch.Stop();
        Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
    }

    public static void MultiLineTest()
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        int count = 0;
        for (uint i = 0; i < 1000000000; ++i) {
            var isMultipleOf16 = i % 16 == 0;
            count += isMultipleOf16 ? 1 : 0;
        }
        stopwatch.Stop();
        Console.WriteLine("Multi-line test  --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
    }
}

我更新了重现项目,加入了一个“if”测试。我制作了单行和多行变体。在x64和x86上,当没有对齐惩罚时,单行版本更快。我还制作了一些在循环中没有任何条件代码的变体(只有位运算)。它们在x86上表现相当,但我还没有检查汇编。在x64上,具有本地变量的版本运行得更快!仍然很惊人,本地变量竟然有所作用。 - Edward Brey

1
我倾向于这样想:编译器的开发人员每年只能完成有限的工作量。如果在这段时间内他们能够实现lambda或大量经典优化,我会投票支持lambda。C#是一种在代码阅读和编写方面高效的语言,而不是在执行时间方面高效。

因此,团队集中精力开发最大化阅读/编写效率的功能,而不是在某些特定情况下(可能有成千上万种)追求执行效率,这是合理的。

最初,我相信,想法是JITter将完成所有优化。不幸的是,JITting需要可观的时间,任何高级优化都会使其变得更糟。所以这并没有像人们希望的那样成功。

关于在C#中编写快速代码的一件事情是,很多时候你会在进行任何优化之前就遇到严重的GC瓶颈。比如说,如果你分配了数百万个对象,C#在避免这种成本方面给你留下了非常少的余地:你可以使用结构体数组代替,但是相比之下,生成的代码真的很丑陋。我的观点是,C#和.NET的许多其他决策使得这些特定的优化不如在像C++编译器这样的东西中那么值得。他们甚至放弃了NGEN中的CPU特定优化,以换取程序员(调试器)的效率而牺牲了性能。
话虽如此,我仍然希望C#能够像C++自上世纪90年代以来所做的那样利用优化。只是不能以牺牲像异步/等待这样的功能为代价。

4
我会非常谨慎地阅读2005年和.net 1.1的文章,因为在过去的7年里发生了很多变化。 - Jason Williams

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