【问题标题】:C# performance curiosityC# 性能好奇心
【发布时间】:2013-08-10 18:23:57
【问题描述】:

对下面的程序非常好奇(是的,在没有附加调试器的情况下以发布模式运行),第一个循环为数组的每个元素分配一个新对象,运行大约需要一秒钟。

所以我想知道哪个部分花费的时间最多——对象创建或分配。所以我创建了第二个循环来测试创建对象所需的时间,并创建了第三个循环来测试分配时间,两者都只运行了几毫秒。怎么回事?

static class Program
{
    const int Count = 10000000;

    static void Main()
    {
        var objects = new object[Count];
        var sw = new Stopwatch();
        sw.Restart();
        for (var i = 0; i < Count; i++)
        {
            objects[i] = new object();
        }
        sw.Stop();
        Console.WriteLine(sw.ElapsedMilliseconds); // ~800 ms
        sw.Restart();
        object o = null;
        for (var i = 0; i < Count; i++)
        {
            o = new object();
        }
        sw.Stop();
        Console.WriteLine(sw.ElapsedMilliseconds); // ~ 40 ms
        sw.Restart();
        for (var i = 0; i < Count; i++)
        {
            objects[i] = o;
        }
        sw.Stop();
        Console.WriteLine(sw.ElapsedMilliseconds); // ~ 50 ms
    }
}

【问题讨论】:

  • 在某些情况下,当循环对程序的其余部分没有明显影响时,抖动可以决定完全忽略循环。这可能是其中一种情况。您必须查看反汇编代码。
  • 是的 jit 足够聪明,可以分析你的代码在第二个循环中什么都不做。
  • @SriramSakthivel 那么它不应该花费 40 毫秒。 JIT现在也很愚蠢。它不知道新对象什么都不做。
  • @mike z 垃圾收集不会立即发生。
  • 这两种情况的不同之处在于,只有在情况 1 中,您才保留对不同对象的引用;在案例 2 中,您一次只引用一个对象,在案例 3 中,只有一个对象。我不认为这两个案例加起来与案例 1 相同。

标签: c# performance


【解决方案1】:

当创建一个占用小于 85,000 字节 RAM 且不是 double 数组的对象时,它会被放置在称为零代堆的内存区域中。每当 Gen0 堆增长到一定大小时,系统可以找到实时引用的 Gen0 堆中的每个对象都被复制到 Gen1 堆; Gen0 堆然后被批量擦除,因此它有空间容纳更多新对象。如果 Gen1 堆达到一定大小,则所有存在引用的内容都将被复制到 Gen2 堆,因此可以批量擦除 Gen0 堆。

如果创建了许多对象并立即放弃,Gen0 堆将反复填满,但 Gen0 堆中的很少对象必须复制到 Gen1 堆。因此,Gen1 堆将被非常缓慢地填充(如果有的话)。相反,如果 Gen0 堆中的大多数对象在 Gen0 堆满时仍被引用,则系统将不得不将这些对象复制到 Gen1 堆。这将迫使系统花时间复制这些对象,并且 Gen1 堆也可能会填满,以至于必须扫描活动对象,并且必须将那里的所有活动对象再次复制到 Gen2 堆.所有这些都需要更多时间。

在您的第一次测试中减慢速度的另一个问题是,当尝试识别所有活动的 Gen0 对象时,系统可以忽略任何 Gen1 或 Gen2 对象,前提是它们自上次 Gen0 收集以来未被触及。在第一个循环中,objects 数组将被不断地触摸;因此,每个 Gen0 集合都必须花时间处理它。在第二个循环中,它根本没有被触及,所以即使有同样多的 Gen0 集合,它们也不会花费很长时间来执行。在第三个循环中,数组会不断地被触及,但不会创建新的堆对象,因此不需要垃圾回收周期,也不管它们需要多长时间。

如果您要添加第四个循环,该循环在每次传递时创建和放弃一个对象,但它还将对预先存在的对象的引用存储到一个数组槽中,我预计它会花费比合并时间更长的时间第二个和第三个循环,即使它会执行相同的操作。也许没有第一个循环那么多的时间,因为很少有新创建的对象需要从 Gen0 堆中复制出来,但比第二个循环要长,因为需要额外的工作来确定哪些对象仍然存在。如果您想更深入地探究,使用嵌套循环进行第五次测试可能会很有趣:

for (int ii=0; ii<1024; ii++)
  for (int i=ii; i<Count; i+=1024)
     ..

我不知道确切的细节,但 .NET 试图通过将它们细分为块来避免扫描整个大型数组,其中只有一小部分被触及。如果一个大数组的一个块被触及,那么该块中的所有引用都必须被扫描,但存储在块中自上次 Gen0 集合以来未被触及的引用可能会被忽略。如上所示打破循环可能会导致 .NET 最终接触到 Gen0 集合之间数组中的大部分块,很可能会产生比第一个循环更慢的时间。

【讨论】:

  • +1 使用 perfmon 进行的分析显示,% Time in GC 的第一个循环为 80%,但第二个循环为 0%,尽管两者的收集次数大致相同。
  • 第二个循环中的一个小因素也最终成为循环展开。 stackoverflow.com/questions/18166501/… 它展开到例如o = new object(); o = new object(); o = new object(); o = new object() 并递增 4。然后大概优化每次迭代中的前三个分配。如果您将Count 设为奇数,则不会发生优化,并且循环大约需要 80 毫秒。到目前为止,GC 仍然是更大的原因。
【解决方案2】:
  1. 您创建 1000 万 个对象并将它们存储在不同的位置 在记忆中。这里的内存消耗最高。
  2. 您创建了 1000 万 个对象,但它们没有存储在任何地方, 刚刚丢弃
  3. 您创建 1 个对象 并对其进行 1000 万次引用,最少 内存消耗。

是的,下面的性能分析仅适用于 10000 个对象(1000 万个对象需要太长时间)。

更新:此图显示了在第一种情况下内存分配的 CPU 工作。注意JIT_New@@... 函数占用了 80.5% 的 CPU 时间。

UPDATE2: 以及为了完成 CaseTwo 的 CPU 时间。

UPDATE3:为了完整起见,第三种情况

【讨论】:

  • 但这和速度有什么关系?
  • 对我来说似乎合乎逻辑。 +1
  • 对象也在循环 2 的堆上创建。一旦它们在堆上,它们就不需要任何额外的“工作”来留在那里。这与执行速度无关。
  • @Nenad 您应该显示与案例 2 相同的图像以进行比较。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-09-02
  • 2012-09-10
  • 2012-04-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多