【问题标题】:Why is it that, in C#, referencing a variable from a function argument is faster than from a private property?为什么在 C# 中,从函数参数引用变量比从私有属性引用变量要快?
【发布时间】:2013-12-03 02:35:36
【问题描述】:

可能是我的硬件是罪魁祸首,但在测试过程中,我发现:

void SomeFunction(AType ofThing) {
    DoSomething(ofThing);
}

...比:

private AType _ofThing;
void SomeFunction() {
    DoSomething(_ofThing);
}

我认为这与编译器如何将其转换为 CIL 有关。谁能具体解释一下为什么会这样?

这里有一些代码:

public void TestMethod1()
{

    var stopwatch = new Stopwatch();

    var r = new int[] { 1, 2, 3, 4, 5 };
    var i = 0;
    stopwatch.Start();
    while (i < 1000000)
    {
        DoSomething(r);
        i++;
    }

    stopwatch.Stop();

    Console.WriteLine(stopwatch.ElapsedMilliseconds);

    i = 0;
    stopwatch.Restart();
    while (i < 1000000)
    {
        DoSomething();
        i++;
    }

    stopwatch.Stop();

    Console.WriteLine(stopwatch.ElapsedMilliseconds);
}

private void DoSomething(int[] arg1)
{
    var r = arg1[0] * arg1[1] * arg1[2] * arg1[3] * arg1[4];
}

private int[] _arg1 = new [] { 1, 2, 3, 4, 5 };
private void DoSomething()
{
    var r = _arg1[0] * _arg1[1] * _arg1[2] * _arg1[3] * _arg1[4];
}

在我的情况下,使用私有财产要慢 2.5 倍。

【问题讨论】:

  • 你能证明它更快吗?你是怎么测试的?
  • 可能它已经在 CPU 缓存中,因为它已经从内存中加载了。而提到一个成员,你必须从记忆中引入它。
  • @Charleh 我使用了秒表,运行了几百万次,从我当前的项目中获得了两个对象的纳秒时间。将进行另一次测试以共享代码。
  • @MoslemBenDhaou 不一定要实用,他可能只是对 C# 在幕后的工作方式感兴趣。
  • @MoslemBenDhaou 我认为他应该关心,直到他不必关心。对系统如何工作的任何检查都会增加人们对它的理解。

标签: c# performance cil


【解决方案1】:

我认为这与编译器如何将其转换为 CIL 有关。

不是真的。性能并不直接取决于 CIL 代码,因为这不是实际执行的内容。执行的是 JITed 本机代码,因此当您对性能感兴趣时应该查看它。

那么,让我们看看为DoSomething(int[]) 循环生成的代码:

mov         eax,dword ptr [ebx+4] ; get the length of the array
cmp         eax,0       ; if it's 0
jbe         0000018C    ; jump to code that throws IndexOutOfRangeException
cmp         eax,1       ; if it's 1, etc.
jbe         0000018C 
cmp         eax,2 
jbe         0000018C 
cmp         eax,3 
jbe         0000018C 
cmp         eax,4 
jbe         0000018C 
inc         esi         ; i++
cmp         esi,0F4240h ; if i < 1000000
jl          000000B7    ; loop again

这段代码的有趣之处在于根本没有做任何有用的工作,大部分代码都是数组边界检查(为什么代码没有优化为在循环之前只执行一次这种检查,我不知道)。

另外请注意,代码是内联的,您无需支付函数调用的费用。

这段代码在我的电脑上大约需要 1.7 毫秒。

那么,DoSomething() 的循环是什么样子的?

mov         ecx,dword ptr [ebp-10h]  ; access this
call        dword ptr ds:[001637F4h] ; call DoSomething()
inc         esi                      ; i++
cmp         esi,0F4240h              ; if i < 1000000
jl          00000120                 ; loop again

好的,所以这实际上调用了方法,这次没有内联。方法本身是什么样的?

mov         eax,dword ptr [ecx+4] ; access this._arg1
cmp         dword ptr [eax+4],0   ; if its length is 0
jbe         00000022 ; jump to code that throws IndexOutOfRangeException
cmp         dword ptr [eax+4],1   ; etc.
jbe         00000022 
cmp         dword ptr [eax+4],2 
jbe         00000022 
cmp         dword ptr [eax+4],3 
jbe         00000022 
cmp         dword ptr [eax+4],4 
jbe         00000022 
ret                               ; bounds checks successful, return

与以前的版本相比(暂时忽略函数调用的开销),它执行了三种不同的内存访问,而不仅仅是一次,这可以解释一些性能差异。 (我认为eax+4 的五次访问应该只算作一次,否则编译器会优化它们。)

这段代码对我来说运行大约 3.0 毫秒。

方法调用需要多少开销?我们可以通过将[MethodImpl(MethodImplOptions.NoInlining)] 添加到先前内联的DoSomething(int[]) 来检查。程序集现在看起来像这样:

mov         ecx,dword ptr [ebp-10h]  ; access this
mov         edx,dword ptr [ebp-14h]  ; access r
call        dword ptr ds:[002937E8h] ; call DoSomething(int[])
inc         esi                      ; i++
cmp         esi,0F4240h              ; if i < 1000000
jl          000000A0                 ; loop again

请注意,r 现在不再保存在寄存器中,而是在堆栈中,这将进一步降低速度。

现在DoSomething(int[]):

push        ebp                   ; save ebp from caller to stack
mov         ebp,esp               ; write our own ebp
mov         eax,dword ptr [edx+4] ; read the length of the array
cmp         eax,0    ; if it's 0
jbe         00000021 ; jump to code that throws IndexOutOfRangeException
cmp         eax,1    ; etc.
jbe         00000021 
cmp         eax,2 
jbe         00000021 
cmp         eax,3 
jbe         00000021 
cmp         eax,4 
jbe         00000021 
pop         ebp      ; restore ebp
ret                  ; return

这段代码对我来说运行时间约为 3.2 毫秒。这甚至比DoSomething() 还要慢。怎么回事?

事实证明,[MethodImpl(MethodImplOptions.NoInlining)] 似乎会导致那些不必要的ebp 指令。如果我将该属性添加到DoSomething(),它会在 3.3 毫秒内运行。

这意味着堆栈访问和堆访问之间的差异非常小(但仍然可以测量)。内联方法时可以将数组指针保存在寄存器中这一事实可能更重要。


因此,结论是您看到的最大差异是因为内联。 JIT 编译器决定内联DoSomething(int[]) 的代码,而不是DoSomething() 的代码,这使得DoSomething(int[]) 的代码非常高效。最可能的原因是DoSomething() 的 IL 更长(21 字节对 46 字节)。

此外,您并没有真正衡量您编写的内容(数组访问和乘法),因为可以优化。所以在设计你的微基准时要小心,这样编译器就不会忽略你真正想要测量的代码。

【讨论】:

  • 出于好奇,您使用的是哪个 JIT 编译器?
  • @kvb 我有.Net 4.5,我没有更改项目中的任何设置(除了切换到发布模式)。
  • x86,我猜?你在 x64 上看到类似的性能差异吗?
  • @kvb 就像我说的,我没有更改任何设置,这意味着首选任何 CPU 32 位。所以,是的,这是 x86。我没有检查 x64。
【解决方案2】:

有几个人做了栈/堆的区分,但这是错误的二分法;当 IL 被编译为机器代码时,还有其他可能性,例如在寄存器中传递参数,这可能比将它们从堆栈中取出更快。有关这些方面的更多想法,请参阅 Eric Lippert 的精彩博文 The Truth About Value Types。无论如何,对性能差异的正确分析几乎肯定需要查看生成的机器代码,而不是 IL,并且可能取决于 JIT 编译器的版本等。

【讨论】:

    【解决方案3】:

    如果这是你的例子,我不会对 SomeFunction内联感到惊讶。 See Here

    JIT 也完全有可能无法内联第二个示例。

    您需要查看编译后的代码来证明这一点。我不知道有一种确定性的方法可以知道查看编译后的代码是否是内联条。

    您至少可以通过让另一个线程写入_ofThing 来反驳缓存,如果您得到类似的结果,而它正在更改读取值,那么它就不会缓存。

    【讨论】:

      【解决方案4】:

      即使函数没有内联,由于缓存局部性,引用 arg 也会更快:arg 已经在 CPU 缓存中。

      值得注意的是,你通过调用这个函数将它放入缓存中,所以已经付出了这个代价。

      【讨论】:

      • 你已经付出了代价是一个非常好的观点(这是一个零和游戏)。 OP 应该可以对此进行测试。
      • 这应该不会导致差异,至少 999999 次迭代,数组将在两个版本中缓存。
      【解决方案5】:

      这与变量的存储位置完全相关。如果它在堆栈或堆上。以下代码要快得多,因为它使用了 static 变量,例如:

      private static AType _ofThing;
      void SomeFunction() {
          DoSomething(_ofThing);
      }
      

      有关变量存储位置的更多信息,请查看this excellent answer from Hans Passant

      【讨论】:

      • 感谢您的评论,但这并不快。
      • 为什么访问堆栈上的变量比访问堆上的变量要快?
      • 另外,你是说static 变量存储在哪里? (它肯定不在堆栈上,也不属于正常的托管堆。)
      • 静态变量存储在堆上。这个堆与普通的垃圾收集堆是分开的——它被称为“高频堆”,每个应用程序域都有一个。看看这里codeproject.com/Articles/15269/Static-Keyword-Demystified
      【解决方案6】:

      当您使用参数调用方法时,您使用的是堆栈内存,而当您使用全局变量时,您使用的是堆内存。

      堆栈

      • 访问速度非常快
      • 不必显式取消分配变量
      • 空间由 CPU 高效管理,内存不会碎片化
      • 仅限局部变量
      • 堆栈大小限制(取决于操作系统)
      • 变量无法调整大小

      • 变量可以全局访问
      • 内存大小没有限制
      • (相对)较慢的访问
      • 无法保证有效使用空间,随着内存块的分配和释放,内存可能会随着时间的推移而碎片化
      • 变量可以调整大小

      http://tutorials.csharp-online.net/Stack_vs._Heap

      【讨论】:

      • 小心使用“全局变量”这个词......它在技术上是不正确的。你有类的静态成员变量。
      • 好的,您提到了本机代码中堆栈和堆之间的一些差异(其中一些在 .Net 中无效)。这如何回答这个问题?为什么访问堆比访问堆栈慢?
      • @svick 同意。我觉得这个答案有被误导的危险。与堆相比,堆栈唯一可能使其更快的是引用的局部性。即,因为堆栈中充满了您现在正在查看的变量,所以这些变量更有可能在缓存中(如果它们没有像另一个答案所暗示的那样在寄存器中传递)。
      • @TooTone 这可能与这里无关,因为堆的那部分将在第一次迭代时被加载到缓存中并且它将保留在那里。但还有另一个原因:访问堆通常意味着需要再取消一次指针引用。我计划回家后在自己的答案中详细说明这一点。
      • @svick 抱歉,我指的是没有循环的原始问题。无论如何,我期待稍后看到您的回答!
      猜你喜欢
      • 2020-08-20
      • 1970-01-01
      • 1970-01-01
      • 2016-06-15
      • 1970-01-01
      • 2013-03-23
      • 2023-01-29
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多