托管堆是否不可扩展到多核系统

时间:2009-05-26 23:00:48

标签: c# .net

我在多线程应用程序中看到了一些奇怪的行为,我写的这个行为在多个内核中都没有很好的扩展性。

以下代码说明了我看到的行为。堆密集型操作似乎不会跨多个核心扩展,而是看起来速度变慢。即使用单个线程会更快。

class Program
{
   public static Data _threadOneData = new Data();
   public static Data _threadTwoData = new Data();
   public static Data _threadThreeData = new Data();
   public static Data _threadFourData = new Data();

   static void Main(string[] args)
   {
      // Do heap intensive tests
      var start = DateTime.Now;
      RunOneThread(WorkerUsingHeap);
      var finish = DateTime.Now;
      var timeLapse = finish - start;
      Console.WriteLine("One thread using heap: " + timeLapse);

      start = DateTime.Now;
      RunFourThreads(WorkerUsingHeap);
      finish = DateTime.Now;
      timeLapse = finish - start;
      Console.WriteLine("Four threads using heap: " + timeLapse);

      // Do stack intensive tests
      start = DateTime.Now;
      RunOneThread(WorkerUsingStack);
      finish = DateTime.Now;
      timeLapse = finish - start;
      Console.WriteLine("One thread using stack: " + timeLapse);

      start = DateTime.Now;
      RunFourThreads(WorkerUsingStack);
      finish = DateTime.Now;
      timeLapse = finish - start;
      Console.WriteLine("Four threads using stack: " + timeLapse);

      Console.ReadLine();
   }

   public static void RunOneThread(ParameterizedThreadStart worker)
   {
      var threadOne = new Thread(worker);
      threadOne.Start(_threadOneData);

      threadOne.Join();
   }

   public static void RunFourThreads(ParameterizedThreadStart worker)
   {
      var threadOne = new Thread(worker);
      threadOne.Start(_threadOneData);

      var threadTwo = new Thread(worker);
      threadTwo.Start(_threadTwoData);

      var threadThree = new Thread(worker);
      threadThree.Start(_threadThreeData);

      var threadFour = new Thread(worker);
      threadFour.Start(_threadFourData);

      threadOne.Join();
      threadTwo.Join();
      threadThree.Join();
      threadFour.Join();
   }

   static void WorkerUsingHeap(object state)
   {
      var data = state as Data;
      for (int count = 0; count < 100000000; count++)
      {
         var property = data.Property;
         data.Property = property + 1;
      }
   }

   static void WorkerUsingStack(object state)
   {
      var data = state as Data;
      double dataOnStack = data.Property;
      for (int count = 0; count < 100000000; count++)
      {
         dataOnStack++;
      }
      data.Property = dataOnStack;
   }

   public class Data
   {
      public double Property
      {
         get;
         set;
      }
   }
}

此代码在Core 2 Quad(4核心系统)上运行,结果如下:

使用堆的一个线程:00:00:01.8125000

使用堆的四个线程:00:00:17.7500000

使用堆栈的一个线程:00:00:00.3437500

使用堆栈的四个线程:00:00:00.3750000

因此,使用具有四个线程的堆执行了4倍的工作但是花费了近10倍的时间。这意味着在这种情况下,只使用一个线程的速度会快两倍??????

使用堆栈远远超出预期。

我想知道这里发生了什么。堆只能一次从一个线程写入吗?

3 个答案:

答案 0 :(得分:13)

答案很简单 - 在Visual Studio之外运行......

我刚刚复制了整个程序,并在我的四核系统上运行它。

内部VS(发布版本):

One thread using heap: 00:00:03.2206779
Four threads using heap: 00:00:23.1476850
One thread using stack: 00:00:00.3779622
Four threads using stack: 00:00:00.5219478

外部VS(发布版本):

One thread using heap: 00:00:00.3899610
Four threads using heap: 00:00:00.4689531
One thread using stack: 00:00:00.1359864
Four threads using stack: 00:00:00.1409859

注意区别。 VS外部构建的额外时间几乎都是由于启动线程的开销。在这种情况下你的工作太小而无法真正测试,而且你没有使用高性能计数器,因此它不是一个完美的测试。

主要经验法则 - 总是做到性能。在VS外测试,即:使用Ctrl + F5而不是F5来运行。

答案 1 :(得分:3)

除了调试与释放效果之外,还有一些你应该注意的事情。

您无法在0.3秒内有效评估多线程代码的性能。

线程的重点是双重的:有效地模拟代码中的并行工作,并有效地利用并行资源(cpus,cores)。

你正试图评估后者。鉴于线程启动开销与您计时的时间间隔相比并不是很小,您的测量结果立即被怀疑。在大多数性能测试试验中,显着的预热间隔是合适的。这对你来说可能听起来很愚蠢 - 这是一个计算机程序,而不是割草机。但是,如果您真的要评估多线程性能,那么热身是绝对必要的。缓存填满,管道填满,池填满,GC代填满。您希望评估稳态持续性能。出于本练习的目的,该程序的行为类似于割草机。

你可以说 - 嗯,不,我不想评估稳态性能。如果是这样,那么我会说你的场景非常专业。大多数应用场景,无论他们的设计者是否明确意识到,都需要持续,稳定的性能。

如果你真的只需要在0.3秒的时间间隔内获得好的表现,你就找到了自己的答案。但要小心不要概括结果。

如果您想要一般结果,则需要有相当长的预热间隔和更长的采集间隔。对于这些阶段,您可能从20秒/ 60秒开始,但这是关键的事情:您需要改变这些间隔,直到您发现结果收敛为止。因人而异。显然,有效时间取决于应用程序工作负载和专用资源。你可能会发现收敛需要120s的测量间隔,或者你可能会发现40s就好了。但是(a)在你测量它之前你不会知道,并且(b)你可以下注0.3s不够长。

答案 2 :(得分:2)

[edit]事实证明,这是一个发布与调试构建问题 - 不确定它为什么,但确实如此。见评论和其他答案。[/ edit]

这非常有趣 - 我不会猜到会有那么大的差异。 (类似的测试机器 - Core 2 Quad Q9300)

这是一个有趣的比较 - 在​​'数据'类中添加一个体面大小的附加元素 - 我将其更改为:

public class Data
{
    public double Property { get; set; }
    public byte[] Spacer = new byte[8096];
}

它仍然不是完全相同的时间,但它非常接近(运行10倍,结果为13.1秒,而我的机器上则为17.6秒)。

如果我不得不猜测,我推测它与跨核心缓存一致性有关,至少如果我记得CPU缓存是如何工作的话。对于'Data'的小版本,如果单个缓存行包含多个Data实例,则核心必须不断地使彼此的缓存无效(最坏的情况是它们都在同一缓存行上)。添加'spacer'后,它们的内存地址足够远,以至于一个CPU写入给定地址不会使其他CPU的高速缓存无效。

另外需要注意的是 - 4个线程几乎同时启动,但它们没有同时完成 - 另一个迹象表明存在交叉核心问题。另外,我猜想在不同架构的多CPU机器上运行会带来更多有趣的问题。

我想从这里得到的教训是,在一个高度并发的场景中,如果你正在做一些使用一些小数据结构的工作,你应该尽量确保它们不是全部打包在每个其他在记忆中。当然,实际上没有办法确保这一点,但我猜测有些技术(比如添加垫片)可以用来试图让它成为现实。

[编辑] 这太有趣了 - 我无法爱不释手。为了进一步测试这个,我想我会尝试不同大小的垫片,并使用整数而不是双精度来保持对象,而不增加任何间隔的间隔。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("name\t1 thread\t4 threads");
        RunTest("no spacer", WorkerUsingHeap, () => new Data());

        var values = new int[] { -1, 0, 4, 8, 12, 16, 20 };
        foreach (var sv in values)
        {
            var v = sv;
            RunTest(string.Format(v == -1 ? "null spacer" : "{0}B spacer", v), WorkerUsingHeap, () => new DataWithSpacer(v));
        }

        Console.ReadLine();
    }

    public static void RunTest(string name, ParameterizedThreadStart worker, Func<object> fo)
    {
        var start = DateTime.UtcNow;
        RunOneThread(worker, fo);
        var middle = DateTime.UtcNow;
        RunFourThreads(worker, fo);
        var end = DateTime.UtcNow;

        Console.WriteLine("{0}\t{1}\t{2}", name, middle-start, end-middle);
    }

    public static void RunOneThread(ParameterizedThreadStart worker, Func<object> fo)
    {
        var data = fo();
        var threadOne = new Thread(worker);
        threadOne.Start(data);

        threadOne.Join();
    }

    public static void RunFourThreads(ParameterizedThreadStart worker, Func<object> fo)
    {
        var data1 = fo();
        var data2 = fo();
        var data3 = fo();
        var data4 = fo();

        var threadOne = new Thread(worker);
        threadOne.Start(data1);

        var threadTwo = new Thread(worker);
        threadTwo.Start(data2);

        var threadThree = new Thread(worker);
        threadThree.Start(data3);

        var threadFour = new Thread(worker);
        threadFour.Start(data4);

        threadOne.Join();
        threadTwo.Join();
        threadThree.Join();
        threadFour.Join();
    }

    static void WorkerUsingHeap(object state)
    {
        var data = state as Data;
        for (int count = 0; count < 500000000; count++)
        {
            var property = data.Property;
            data.Property = property + 1;
        }
    }

    public class Data
    {
        public int Property { get; set; }
    }
    public class DataWithSpacer : Data
    {
        public DataWithSpacer(int size) { Spacer = size == 0 ? null : new byte[size]; }
        public byte[] Spacer;
    }
}

结果:

1个线程与4个线程

  • no spacer 00:00:06.3480000 00:00:42.6260000
  • null spacer 00:00:06.2300000 00:00:36.4030000
  • 0B spacer 00:00:06.1920000 00:00:19.8460000
  • 4B spacer 00:00:06.1870000 00:00:07.4150000
  • 8B spacer 00:00:06.3750000 00:00:07.1260000
  • 12B spacer 00:00:06.3420000 00:00:07.6930000
  • 16B spacer 00:00:06.2250000 00:00:07.5530000
  • 20B spacer 00:00:06.2170000 00:00:07.3670000

无间隔=速度的1/6,零间隔=速度的1/5,0B间隔=速度的1/3,4B间隔=全速。

我不知道CLR如何分配或对齐对象的完整细节,所以我不能谈论这些分配模式在实际内存中的样子,但这些肯定是一些有趣的结果。