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

本文关键字:表示 后缀 何时 何更快 运算符 IL 前缀 | 更新日期: 2023-09-27 17:57:13

由于这个问题是关于前缀/后缀符号的增量运算符和速度差异,我将非常仔细地描述这个问题,以免埃里克·利珀特发现它并激怒我!

(关于我为什么问的更多信息和更多详细信息可以在 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%。

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

然后,我使用反射器查看了生成的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 并对其进行优化?(以及 (3) 和 (4) 中dup的存在打破了该模式,因此错过了优化)

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

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

经过大量研究(我知道很伤心!),我想已经回答了我自己的问题:

答案是也许。显然,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,并且只有在针对特定情况进行大量计时后才会更改它。

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

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

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

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

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

我试图重现您的发现并失败了。 在我的英特尔 i7 x64 系统上运行您的代码示例。x86|发布配置时,所有四个测试用例产生大致相同的时序。

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

  • jmax = 1000
  • jmax = 1000000

因为数组的局部性通常会对性能的行为方式和 OF 循环的大小增加产生很大的影响。 但是,在我的测试中,两种数组大小的行为相同。

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

我在这里不是假设性的。 我调整了内部循环,投入了数小时和数天的工作来使程序运行得更快,结果我的希望破灭了,因为我正在我的工作站上优化它,而目标计算机是不同型号的英特尔处理器。

所以这个故事的寓意是:

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

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

因此,如果你要做这样的测试,你必须像JIT编译器编写者一样做:你必须在各种各样的硬件上执行测试,然后选择一个混合,一个快乐的媒介,在最普遍的硬件上提供最佳性能。