C#生成IL for ++运算符 - 何时以及为什么前缀/后缀表示法更快

时间:2011-05-20 17:17:47

标签: c# performance optimization il postfix-notation

由于这个问题是关于增量运算符和前缀/后缀表示法的速度差异,我将非常仔细地描述这个问题,以免Eric Lippert发现它并激怒我!

(有关我可能询问的原因的详细信息和详细信息,请访问http://www.codeproject.com/KB/cs/FastLessCSharpIteration.aspx?msg=3899456#xx3899456xx/

我有四个代码片段如下: -

(1)单独,前缀:

    for (var j = 0; j != jmax;) { total += intArray[j]; ++j; }

(2)单独,后缀:

    for (var j = 0; j != jmax;) { total += intArray[j]; j++; }

(3)Indexer,Postfix:

    for (var j = 0; j != jmax;) { total += intArray[j++]; }

(4)Indexer,Prefix:

    for (var j = -1; j != last;) { total += intArray[++j]; } // last = jmax - 1

我试图做的是证明/反驳在这个上下文中前缀和后缀表示法之间是否存在性能差异(即局部变量因此不易变,不能从另一个线程等变化)并且如果有,为什么那将是。

速度测试表明:

  • (1)和(2)以相同的速度运行。

  • (3)和(4)以相同的速度运行。

  • (3)/(4)比(1)/(2)慢〜27%。

因此,我得出的结论是,在postfix表示法本身上选择前缀表示法没有性能优势。但是,当实际使用操作的结果时,这会导致代码比丢弃的代码慢。

然后,我使用Reflector查看生成的IL并找到以下内容:

  • 在所有情况下,IL字节数都相同。

  • .maxstack在4到6之间变化但我相信它仅用于验证目的,因此与性能无关。

  • (1)和(2)产生完全相同的IL,因此时间相同并不奇怪。所以我们可以忽略(1)。

  • (3)和(4)生成了非常相似的代码 - 唯一相关的差异是将操作码的位置设置为操作结果。同样,时间相同也不足为奇。

所以我然后比较(2)和(3)找出可以解释速度差异的因素:

  • (2)使用ldloc.0 op两次(一次作为索引器的一部分,然后作为增量的一部分)。

  • (3)使用ldloc.0,然后立即使用dup op。

因此(1)(和(2))的递增j的相关IL是:

// ldloc.0 already used once for the indexer operation higher up
ldloc.0
ldc.i4.1
add
stloc.0

(3)看起来像这样:

ldloc.0
dup // j on the stack for the *Result of the Operation*
ldc.i4.1
add
stloc.0

(4)看起来像这样:

ldloc.0
ldc.i4.1
add
dup // j + 1 on the stack for the *Result of the Operation*
stloc.0

现在(终于!)问题:

是否(2)更快,因为JIT编译器将ldloc.0/ldc.i4.1/add/stloc.0模式识别为简单地将局部变量递增1并对其进行优化? (并且(3)和(4)中存在dup会破坏该模式,因此错过了优化)

补充说明: 如果这是真的那么,对于(3)至少,不会用另一个dup替换ldloc.0重新引入该模式吗?

3 个答案:

答案 0 :(得分:10)

经过多次研究后确定(我很难过!),我想已经回答了我自己的问题:

答案是可能的。 显然,JIT编译器会查找模式(请参阅http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx)以确定何时以及如何优化数组边界检查,但是它是否与我猜测的模式相同,我不知道。

在这种情况下,这是一个有争议的问题,因为(2)的相对速度增加是由于更多的东西。事实证明,x64 JIT编译器足够聪明,可以确定数组长度是否为常量(并且看似也是循环中展开次数的倍数):所以代码只是在每次迭代结束时进行边界检查每次展开都只是: -

        total += intArray[j]; j++;
00000081 8B 44 0B 10          mov         eax,dword ptr [rbx+rcx+10h] 
00000085 03 F0                add         esi,eax 

我通过更改应用程序以在命令行中指定数组大小并查看不同的汇编程序输出来证明这一点。

在此练习中发现的其他事情: -

  • 对于独立的增量操作(即未使用结果),前缀/后缀之间的速度没有差异。
  • 当在索引器中使用递增操作时,汇编程序显示前缀表示法稍微更有效(并且在原始情况下如此接近,我认为它只是一个时间差异并称它们相等 - 我的错误)。编译为x86时,差异更明显。
  • 循环展开确实有效。与具有阵列边界优化的标准循环相比,4个汇总总是提高10%-20%(并且x64 /常数情况下为34%)。增加汇总次数会产生不同的时间,而在索引器中使用后缀的情况下会有一些非常慢的情况,所以如果展开,我会坚持使用4,并且只有在特定情况的广泛时间后才更改。

答案 1 :(得分:8)

有趣的结果。我会做的是:

  • 重写应用程序以进行两次整个测试。
  • 在两次测试运行之间放置一个消息框。
  • 编译发布,不进行优化等。
  • 在调试器外部启动可执行文件
  • 当消息框出现时,附上调试器
  • 现在通过抖动检查为两种不同情况生成的代码。

然后你会知道抖动是否比其他人更好。例如,抖动可能意识到在一种情况下它可以删除数组边界检查,但在另一种情况下没有意识到。我不知道;我不是抖动方面的专家。

所有rigamarole的原因是因为连接调试器时抖动可能会生成不同的代码。如果你想知道它在正常情况下的作用,那么你必须确保代码在正常的非调试器环境下进行搜索。

答案 2 :(得分:7)

我喜欢性能测试,我喜欢快速程序,所以我很佩服你的问题。

我试图重现你的发现并失败了。在我的Intel i7 x64系统上运行x86 | Release配置中.NET4框架上的代码示例时,所有四个测试用例产生的计时大致相同。

为了进行测试,我创建了一个全新的控制台应用程序项目,并使用QueryPerformanceCounter API调用来获得基于CPU的高分辨率计时器。我尝试了jmax的两个设置:

  • jmax = 1000
  • jmax = 1000000

因为数组的位置通常会对性能的行为和循环的大小增加产生很大的影响。但是,在我的测试中,两个数组大小的行为都相同。

我已经做了很多性能优化,我学到的一件事就是你可以非常轻松地优化应用程序,使其在一台特定计算机上运行得更快,同时无意中导致它在另一台计算机上运行速度较慢。

我不是假设在这里谈论。我已经调整了内部循环并花费了数小时和数天的工作来使程序运行得更快,只是为了让我的希望破灭,因为我在工作站上优化它并且目标计算机是英特尔处理器的不同型号。

所以这个故事的寓意是:

  • 代码段(2)的运行速度比计算机上的代码段(3)快,但不能在我的计算机上运行

这就是为什么有些编译器为不同的处理器配备了特殊的优化开关,或者某些应用程序有不同的版本,即使一个版本可以在所有支持的硬件上轻松运行。

因此,如果您要进行这样的测试,则必须按照JIT编译器编写者的方式进行:您必须在各种硬件上执行测试,然后选择混合,一种在最无处不在的硬件上提供最佳性能的快乐媒体。