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

由于这个问题是关于增量运算符和带有前缀/后缀表示法的速度差异,我将非常谨慎地描述这个问题,以免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)索引器,前缀:

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

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

速度测试表明:

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

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

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

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

然后,我使用Reflector查看生成的IL,发现以下内容:

  • 在所有情况下,IL字节的数量是相同的。

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

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

  • (3)和(4)生成了非常相似的代码 – 唯一相关的区别是dup操作码的定位以考虑操作的结果 。 同样,时间相同也就不足为奇了。

所以我然后比较(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)至少,不会用另一个ldloc.0替换dup重新引入那个模式吗?

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

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

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

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

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

在此练习中发现的其他事项: –

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

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

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

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

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

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

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

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

  • jmax = 1000
  • jmax = 1000000

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

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

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

所以这个故事的寓意是:

  • 代码段(2)比计算机上的代码段(3)运行得更快,但在我的计算机上却没有

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

因此,如果您要进行这样的测试,您必须以与JIT编译器编写者相同的方式执行此操作:您必须在各种硬件上执行测试,然后选择混合 ,这是一种可以提供最佳效果的快乐介质最无处不在的硬件上的性能。