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

Sim*_*itt 20 c# optimization performance 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; }
Run Code Online (Sandbox Code Playgroud)

(2)单独,后缀:

    for (var j = 0; j != jmax;) { total += intArray[j]; j++; }
Run Code Online (Sandbox Code Playgroud)

(3)Indexer,Postfix:

    for (var j = 0; j != jmax;) { total += intArray[j++]; }
Run Code Online (Sandbox Code Playgroud)

(4)索引器,前缀:

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

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

速度测试表明:

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

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

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

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

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

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

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

  • (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
Run Code Online (Sandbox Code Playgroud)

(3)看起来像这样:

ldloc.0
dup // j on the stack for the *Result of the Operation*
ldc.i4.1
add
stloc.0
Run Code Online (Sandbox Code Playgroud)

(4)看起来像这样:

ldloc.0
ldc.i4.1
add
dup // j + 1 on the stack for the *Result of the Operation*
stloc.0
Run Code Online (Sandbox Code Playgroud)

现在(终于!)问题:

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

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

Sim*_*itt 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 
Run Code Online (Sandbox Code Playgroud)

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

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

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


Eri*_*ert 8

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

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

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

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


Ric*_*key 7

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

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

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

  • jmax = 1000
  • jmax = 1000000

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

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

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

所以这个故事的寓意是:

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

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

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