【问题标题】:Is the Managed heap not scalable to multi-core systems托管堆是否不能扩展到多核系统
【发布时间】:2009-05-27 09:22:37
【问题描述】:

我在自己编写的多线程应用程序中看到了一些奇怪的行为,该应用程序不能很好地跨多个内核扩展。

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

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 倍的时间。这意味着在这种情况下只使用一个线程会快两倍??????

使用堆栈的效果比预期的要好得多。

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

【问题讨论】:

  • 它的一切都在那里再看一遍
  • 抱歉,滚动条把我扔了:)
  • 我只是注意到堆栈和堆循环运行的次数不同,现在正在修复...
  • @Daniel:你在 Visual Studio 中运行过吗?看看我的答案...
  • @Reed:在 Visual Studio 之外运行时差异消失了。但是,请参阅我对您关于启动线程开销的回答的评论。

标签: c# .net


【解决方案1】:

答案很简单——在 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运行。

【讨论】:

  • 我跑出视觉工作室,可以确认差异消失了。但是我不同意你的理由。堆栈测试也启动了 4 个线程,但从 Visual Studio 运行时不会出现同样的减速。
  • 该问题是 Visual Studio 调试问题,而不是堆栈/堆托管代码问题。 Visual Studio 的调试器完全消除了属性访问器的内联,更改了它附加到线程的方式等。编译时,您的“堆栈”版本更简单 IL,因为您在循环中执行单个操作,所以它没有在 VS 中运行时出现相同的问题。在 VS 中运行时的属性访问比在 VS 之外运行时要慢得多,尤其是。如果您不使用 TLS,则线程属性访问。
  • Visual Studio 正在添加跟踪代码,这将严重影响数据对象的访问速度。即使在发布版本中也会发生这种情况。
  • 看起来在 VS or 下运行这个调试版本会导致速度变慢。
  • 两者都禁用了在此处的结果中获得合理比较所需的优化。这实际上是调试的副作用,而不是平台错误或问题。
【解决方案2】:

除了 debug-vs-release 效果之外,您还应该注意一些其他事项。

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

线程的意义有两个:在代码中有效地模拟并行工作,并有效地利用并行资源(cpu、内核)。

您正在尝试评估后者。鉴于与您计时的时间间隔相比,线程启动开销并非微不足道,因此您的测量结果立即令人怀疑。在大多数性能测试试验中,适当的预热间隔是合适的。这对你来说可能听起来很傻——它毕竟是一个计算机程序,而不是割草机。但是如果你真的要评估多线程性能,预热是绝对必要的。缓存被填满,管道被填满,池被填满,GC 代被填满。稳态、连续性能是您想要评估的。就本练习而言,该程序的行为类似于割草机。

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

如果您确实需要仅在单个 0.3 秒间隔内表现良好,那么您已经找到了答案。但请注意不要将结果一概而论。

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

【讨论】:

  • 关于较长间隔的好点。此外,对于那些多线程的事情,我更喜欢启动所需线程的东西,在监视器上设置标志和阻塞,然后一旦它们都准备好,就脉冲监视器并开始计时。然后,让每个线程运行指定的时间量并记录每个线程的时间/计数,然后总结。这样一来,您就不会遇到公平问题等问题。
  • 是的,完全正确。将它们运行指定的时间量,理想情况下,间隔内执行的事务数很大。此外,使用高分辨率计时器而不是 DateTime 可能会有所帮助 - 查看 p/invoking QueryPerformanceCounter。
【解决方案3】:

[编辑]原来,这是一个发布与调试构建问题——不知道为什么会这样,但确实如此。请参阅 cmets 和其他答案。[/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 缓存是如何工作的。对于小版本的“数据”,如果单个缓存行包含多个数据实例,则内核必须不断地使彼此的缓存无效(最坏的情况是它们都在同一缓存行上)。添加了“间隔”后,它们的内存地址相距足够远,以至于一个 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 线程

  • 无间隔 00:00:06.3480000 00:00:42.6260000
  • 空分隔符 00:00:06.2300000 00:00:36.4030000
  • 0B 间隔 00:00:06.1920000 00:00:19.8460000
  • 4B 间隔 00:00:06.1870000 00:00:07.4150000
  • 8B 间隔 00:00:06.3750000 00:00:07.1260000
  • 12B 间隔 00:00:06.3420000 00:00:07.6930000
  • 16B 间隔 00:00:06.2250000 00:00:07.5530000
  • 20B 间隔 00:00:06.2170000 00:00:07.3670000

无垫片 = 1/6 速度,空垫片 = 1/5 速度,0B 垫片 = 1/3 速度,4B 垫片 = 全速。

我不知道 CLR 如何分配或对齐对象的全部细节,所以我无法说出这些分配模式在实际内存中的样子,但这些肯定是一些有趣的结果。

【讨论】:

  • @Jonathan:他的类很小——它是一个包裹在类中的单个双精度类,每个核心都有自己的类副本。这应该完全消除任何内存一致性/缓存问题,因为每个核心的工作集都应该完全适合该核心的缓存。这更多是测试方法的问题 - 而不是线程。当您在 VS 主机中运行时,VS 会降低线程速度,这很可怕。
  • 当然,我看到了其他 cmets 并仔细检查了 -- 果然,我忘了设置发布模式.... 注意:我在 debug 在 VS 之外构建
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2014-05-27
  • 1970-01-01
  • 2011-12-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-12-26
相关资源
最近更新 更多