为啥在分配大量内存时我的线程化 .Net 应用程序不能线性扩展?

Posted

技术标签:

【中文标题】为啥在分配大量内存时我的线程化 .Net 应用程序不能线性扩展?【英文标题】:Why doesn't my threaded .Net app scale linearly when allocating large amounts of memory?为什么在分配大量内存时我的线程化 .Net 应用程序不能线性扩展? 【发布时间】:2011-01-05 13:50:43 【问题描述】:

关于大内存分配对 .Net 运行时可伸缩性的影响,我遇到了一些奇怪的事情。在我的测试应用程序中,我在固定循环数的紧密循环中创建了许多字符串,并输出每秒循环迭代的速率。当我在多个线程中运行这个循环时,奇怪的事情就出现了——看起来速率并没有线性增加。当您创建大字符串时,问题会变得更糟。

让我告诉你结果。我的机器是一个 8gb、8 核的机器,运行 Windows Server 2008 R1,32 位。它有两个 4 核 Intel Xeon 1.83ghz (E5320) 处理器。执行的“工作”是对字符串上的ToUpper()ToLower() 的一组交替调用。我对一个线程、两个线程等运行测试——最多。下表中的列是:

速率:所有线程的循环数除以持续时间。 线性比率:理想的比率,如果性能是线性扩展的。它的计算方法是一个线程实现的速率乘以该测试的线程数。 方差:计算为比率低于线性比率的百分比。

示例 1:10,000 个循环,8 个线程,每个字符串 1024 个字符

第一个示例从一个线程开始,然后是两个线程,最终使用八个线程运行测试。每个线程创建 10,000 个字符串,每个字符串包含 1024 个字符:

每个线程创建 10000 个字符串,每个 1024 个字符,最多使用 8 个线程 GCMode = 服务器 比率线性比率 % 方差线程 -------------------------------------------------- ------ 322.58 322.58 0.00 % 1 689.66 645.16 -6.90 % 2 882.35 967.74 8.82 % 3 1081.08 1290.32 16.22 % 4 1388.89 1612.90 13.89 % 5 1666.67 1935.48 13.89 % 6 2000.00 2258.07 11.43 % 7 2051.28 2580.65 20.51 % 8 完毕。

示例 2:10,000 个循环,8 个线程,每个字符串 32,000 个字符

在第二个示例中,我将每个字符串的字符数增加到 32,000。

每个线程创建 10000 个字符串,每个 32000 个字符,最多使用 8 个线程 GCMode = 服务器 比率线性比率 % 方差线程 -------------------------------------------------- ------ 14.10 14.10 0.00 % 1 24.36 28.21 13.64 % 2 33.15 42.31 21.66 % 3 40.98 56.42 27.36 % 4 48.08 70.52 31.83 % 5 61.35 84.63 27.51 % 6 72.61 98.73 26.45 % 7 67.85 112.84 39.86 % 8 完毕。

注意方差与线性速率的差异;在第二张表中,实际比率比线性比率低 39%。

我的问题是:为什么这个应用不能线性扩展?

我的观察

虚假分享

我最初认为这可能是由于错误共享造成的,但正如您在源代码中看到的那样,我没有共享任何集合,而且字符串非常大。唯一可能存在的重叠是在一个字符串的开头和另一个字符串的结尾。

服务器模式垃圾收集器

我使用 gcServer enabled=true 以便每个内核都有自己的堆和垃圾收集器线程。

大对象堆

我认为我分配的对象不会被发送到大对象堆,因为它们的大小不到 85000 字节。

字符串实习

我认为由于 interningMSDN 可能会在后台共享字符串值,因此我尝试编译禁用实习。这产生的结果比上面显示的更差

其他数据类型

我使用小型和大型整数数组尝试了相同的示例,其中我循环遍历每个元素并更改值。它产生了类似的结果,遵循更大分配时性能更差的趋势。

源代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Diagnostics;
using System.Runtime;
using System.Runtime.CompilerServices;

namespace ***Example

  public class Program
  
    private static int columnWidth = 14;

    static void Main(string[] args)
    
      int loopCount, maxThreads, stringLength;
      loopCount = maxThreads = stringLength = 0;
      try
      
        loopCount = args.Length != 0 ? Int32.Parse(args[0]) : 1000;
        maxThreads = args.Length != 0 ? Int32.Parse(args[1]) : 4;
        stringLength = args.Length != 0 ? Int32.Parse(args[2]) : 1024;
      
      catch
      
        Console.WriteLine("Usage: ***Example.exe [loopCount] [maxThreads] [stringLength]");
        System.Environment.Exit(2);
      

      float rate;
      float linearRate = 0;
      Stopwatch stopwatch;
      Console.WriteLine("Creating 0 strings per thread, 1 chars each, using up to 2 threads", loopCount, stringLength, maxThreads);
      Console.WriteLine("GCMode = 0", GCSettings.IsServerGC ? "Server" : "Workstation");
      Console.WriteLine();
      PrintRow("Rate", "Linear Rate", "% Variance", "Threads"); ;
      PrintRow(4, "".PadRight(columnWidth, '-'));

      for (int runCount = 1; runCount <= maxThreads; runCount++)
      
        // Create the workers
        Worker[] workers = new Worker[runCount];
        workers.Length.Range().ForEach(index => workers[index] = new Worker());

        // Start timing and kick off the threads
        stopwatch = Stopwatch.StartNew();
        workers.ForEach(w => new Thread(
          new ThreadStart(
            () => w.DoWork(loopCount, stringLength)
          )
        ).Start());

        // Wait until all threads are complete
        WaitHandle.WaitAll(
          workers.Select(p => p.Complete).ToArray());
        stopwatch.Stop();

        // Print the results
        rate = (float)loopCount * runCount / stopwatch.ElapsedMilliseconds;
        if (runCount == 1)  linearRate = rate; 

        PrintRow(String.Format("0:#0.00", rate),
          String.Format("0:#0.00", linearRate * runCount),
          String.Format("0:#0.00 %", (1 - rate / (linearRate * runCount)) * 100),
          runCount.ToString()); 
      
      Console.WriteLine("Done.");
    

    private static void PrintRow(params string[] columns)
    
      columns.ForEach(c => Console.Write(c.PadRight(columnWidth)));
      Console.WriteLine();
    

    private static void PrintRow(int repeatCount, string column)
    
      for (int counter = 0; counter < repeatCount; counter++)
      
        Console.Write(column.PadRight(columnWidth));
      
      Console.WriteLine();
    
  

  public class Worker
  
    public ManualResetEvent Complete  get; private set; 

    public Worker()
    
      Complete = new ManualResetEvent(false);
    

    public void DoWork(int loopCount, int stringLength)
    
      // Build the string
      string theString = "".PadRight(stringLength, 'a');
      for (int counter = 0; counter < loopCount; counter++)
      
        if (counter % 2 == 0)  theString.ToUpper(); 
        else  theString.ToLower(); 
      
      Complete.Set();
    
  

  public static class HandyExtensions
  
    public static IEnumerable<int> Range(this int max)
    
      for (int counter = 0; counter < max; counter++)
      
        yield return counter;
      
    

    public static void ForEach<T>(this IEnumerable<T> items, Action<T> action)
    
      foreach(T item in items)
      
        action(item);
      
    
  

App.Config

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <runtime>
    <gcServer enabled="true"/>
  </runtime>
</configuration>

运行示例

要在您的机器上运行 ***Example.exe,请使用以下命令行参数调用它:

***Example.exe [loopCount] [maxThreads] [stringLength]

loopCount:每个线程操作字符串的次数。 maxThreads:要前进到的线程数。 stringLength: 填充字符串的字符数。

【问题讨论】:

我认为我们应该束缚 Jon Skeet 并鞭打他,直到他想出一个合适的答案来回答这个问题。 【参考方案1】:

你可能想看看this question of mine。

我遇到了类似的问题,这是由于 CLR 在分配内存时执行线程间同步以避免重叠分配。现在,使用服务器 GC,锁定算法可能会有所不同 - 但这些相同的东西可能会影响您的代码。

【讨论】:

卢克,我同意 LBushkin 的观点。您的代码似乎落入了 Igor Ostrovsky 详述的陷阱。您似乎在 DoWorth 方法中为每个线程的每次调用的 theString 变量分配新内存。【参考方案2】:

您运行它的硬件无法线性扩展多个进程或线程。

您只有一个内存库。这是一个瓶颈(多通道内存可能会改善访问,但不会比您拥有的内存组更多的进程(似乎 e5320 处理器支持 1 - 4 个内存通道)。

每个物理 cpu 包只有一个内存控制器(在你的情况下是两个),这是一个瓶颈。

每个 cpu 包有 2 个 l2 缓存。这是一个瓶颈。如果缓存耗尽,就会发生缓存一致性问题。

这甚至没有涉及管理进程调度和内存管理中的 OS/RTL/VM 问题,这也会导致非线性扩展。

我认为你得到了相当合理的结果。多线程显着加速,每次增加到 8...

确实,您是否曾经读过任何内容表明商品多 CPU 硬件能够线性扩展多个进程/线程?我没有。

【讨论】:

【参考方案3】:

您最初的帖子存在根本缺陷 - 您假设通过并行执行可以实现线性加速。它不是,而且从来都不是。请参阅Amdahl's Law(是的,我知道,***,但它比其他任何东西都容易)。

从 CLR 提供的抽象来看,您的代码似乎没有依赖关系 - 但是,正如 LBushkin 指出的那样,事实并非如此。正如 SuperMagic 所指出的,硬件本身意味着执行线程之间的依赖关系。几乎任何可以并行化的问题都是如此——即使使用独立的机器,使用独立的硬件,问题的某些部分通常需要一些同步元素,而同步会阻止线性加速。

【讨论】:

@Matt Jordan,谢谢,我知道真正的线性加速是不可能的(并且应该指定这一点),但我的目标是尽可能接近!【参考方案4】:

内存分配器对应用程序speedup 的影响与分配次数的关系比分配的数量更密切。它还受分配延迟(在单个线程上完成单个分配的时间量)的影响更大,在 CLR 的情况下,由于使用了bump-pointer allocator (see section 3.4.3),延迟非常快。

您的问题是问为什么实际的加速是次线性的,并回答您当然应该查看Amdahl's Law。

回到Notes on the CLR Garbage Collector,您可以看到分配上下文属于特定线程(第 3.4.1 节),这减少(但不消除)多线程分配期间所需的同步量。如果您发现分配确实是弱点,我建议尝试使用对象池(可能是每个线程)来减少收集器的负载。通过减少分配的绝对数量,您将减少收集器必须运行的次数。但是,这也会导致更多的对象进入第 2 代,这是在需要时收集起来最慢的。

最后,Microsoft 继续在新版本的 CLR 中改进垃圾收集器,因此您应该针对您能够使用的最新版本(至少 .NET 2)。

【讨论】:

@280z28 谢谢!尽管在我的示例中,我在每个执行线程的上下文中分配内存。当您说:“您可以看到分配上下文属于特定线程”时,这就是您的建议吗? 这意味着当您分配一些内存时,分配器会将 8kB 的内存块移动到请求线程,因此当同一线程上的未来分配适合该 8kB 窗口时,不需要跨线程同步在分配过程中。【参考方案5】:

卢克的好问题!我对答案很感兴趣。

我怀疑您并没有期望线性缩放,而是比 39% 的方差更好。

NoBugz - 基于 280Z28 的链接,每个核心实际上会有一个 GC 堆,GCMode=Server。每个堆还应该有一个 GC 线程。这应该不会导致您提到的并发问题?

遇到了类似的问题 由于 CLR 执行 线程间同步时 分配内存以避免重叠 分配。现在,使用服务器 GC, 锁定算法可能不同 - 但类似的事情可能会影响您的代码

LBushkin - 我认为这是关键问题,在分配内存时 GCMode=Server 是否仍会导致线程间锁定?任何人都知道 - 或者它可以简单地通过 SuperMagic 提到的硬件限制来解释?

【讨论】:

@Carls 是的,这完全正确,我知道获得真正的线性缩放是不可能的(我们不希望如此!)但我很难说服我项目的技术负责人.他坚持认为,我们应该能够简单地添加沙鼠来获得优于线性的性能。坦率地说,我怀疑他可能只是去邮局把这个地方炸了。 是的,每个核心一个 GC 堆。这样就可以对所有可用的内核进行垃圾收集。它不是每个用户线程一个 GC 堆,无法扩展。

以上是关于为啥在分配大量内存时我的线程化 .Net 应用程序不能线性扩展?的主要内容,如果未能解决你的问题,请参考以下文章

内存池进程池线程池

创建大量线程时.Net内存泄漏

为啥当我使用 QImage::scaled() 时我的内存消耗很大?

为啥存档时我的 NSURLResponse 会泄漏内存?

C/C++ 的多线程内存分配器

明明还有大量内存,为啥报错“无法分配内存”?