20

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

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

(4) 索引器,前缀:

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

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

速度测试表明:

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

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

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

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

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

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

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

  • (1) 和 (2) 生成完全相同的 IL,因此时序相同也就不足为奇了。所以我们可以忽略(1)。

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

因此,我比较了 (2) 和 (3) 以找出可能导致速度差异的原因:

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

  • (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 并对其进行优化?(并且dup(3)和(4)中的存在打破了这种模式,因此错过了优化)

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

4

3 回答 3

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,并且仅在针对特定情况进行大量时间后更改它。
于 2011-05-22T18:21:35.517 回答
8

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

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

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

一切繁琐的原因是因为在附加调试器时抖动可能会生成不同的代码。如果您想知道它在正常情况下的作用,那么您必须确保代码在正常的非调试器情况下得到 jitted。

于 2011-05-20T22:11:43.910 回答
7

I love performance testing and I love fast programs so I admire your question.

I tried to reproduce your findings and failed. On my Intel i7 x64 system running your code samples on .NET4 framework in the x86|Release configuration, all four test cases produced roughly the same timings.

To do the test I created a brand new console application project and used the QueryPerformanceCounter API call to get a high-resolution CPU-based timer. I tried two settings for jmax:

  • jmax = 1000
  • jmax = 1000000

because locality of the array can often make a big difference in how the performance behaves and the size of the of loop increases. However, both array sizes behaved the same in my tests.

I have done a lot of performance optimization and one of the things that I have learned is that you can very easily optimize an application so that it runs faster on one particular computer while inadvertently causing it to run slower on another computer.

I am not talking hypothetically here. I have tweaked inner loops and poured hours and days of work to make a program run faster, only to have my hopes dashed because I was optimizing it on my workstation and the target computer was a different model of Intel processor.

So the moral of this story is:

  • Code snippet (2) runs faster than code snippet (3) on your computer but not on my computer

This is why some compilers have special optimization switches for different processors or some applications come in different versions even though one version could easily run on all supported hardware.

So if you are going to do testing like this, you have to do it same way that JIT compiler writers do: you have to perform your tests on a wide variety of hardware and then choose a blend, a happy-medium that gives the best performance on the most ubiquitous hardware.

于 2011-05-20T21:38:23.733 回答