10

我在 C# 中编写了一个简单的 Parallel.For() 循环,如下所示。我也使用常规的 for() 循环来比较单线程与多线程。每次运行单线程版本大约需要五秒钟。并行版本一开始大约需要三秒钟,但如果我运行它大约四次,它会显着减慢。大多数情况下,它需要大约三十秒。有一次需要八十秒。如果我重新启动程序,并行版本会再次快速启动,但在三到四次并行运行后会变慢。有时并行运行会再次加速到原来的三秒,然后减速。

我编写了另一个 Parallel.For() 循环来计算 Mandelbrot 集合成员(丢弃结果),因为我认为问题可能与分配和操作大型数组的内存问题有关。第二个问题的 Parallel.For() 实现确实每次都比单线程版本执行得快,而且时间也是一致的。

我应该查看哪些数据来理解为什么我的第一个天真的程序在多次运行后变慢了?Perfmon 中有什么我应该看的吗?我仍然怀疑它与内存有关,但我在计时器之外分配了数组。我还在每次运行结束时尝试了 GC.Collect() ,但这似乎没有帮助,无论如何都不是一致的。这可能是处理器某处缓存的对齐问题吗?我怎么知道呢?还有什么可能是原因吗?

JR

    const int _meg = 1024 * 1024;
    const int _len = 1024 * _meg;

    private void ParallelArray() {
        int[] stuff = new int[_meg];
        System.Diagnostics.Stopwatch s = new System.Diagnostics.Stopwatch();
        lblStart.Content = DateTime.Now.ToString();
        s.Start();

        Parallel.For(0,
            _len,
            i => {
                stuff[i % _meg] = i;
            }
            );
        s.Stop();

        lblResult.Content = DateTime.Now.ToString();

        lblDiff.Content = s.ElapsedMilliseconds.ToString();

    }
4

2 回答 2

8

我已经分析了您的代码,它确实看起来很奇怪。应该没有偏差。这不是分配问题(GC 很好,每次运行只分配一个数组)。

这个问题可以在我的 Haswell CPU 上重现,其中并行版本突然需要更长的时间来执行。我有 CLR 版本 4.0.30319.34209 FX452RTMGDR。

在 x64 上它工作正常并且没有问题。只有 x86 构建似乎受到它的影响。我使用 Windows Performance Toolkit 对其进行了分析,发现它看起来像是一个 CLR 问题,TPL 试图找到下一个工作项。有时会发生调用

System.Threading.Tasks.RangeWorker.FindNewWork(Int64 ByRef, Int64 ByRef)
System.Threading.Tasks.Parallel+<>c__DisplayClassf`1[[System.__Canon, mscorlib]].<ForWorker>b__c()
System.Threading.Tasks.Task.InnerInvoke()
System.Threading.Tasks.Task.InnerInvokeWithArg(System.Threading.Tasks.Task)
System.Threading.Tasks.Task+<>c__DisplayClass11.<ExecuteSelfReplicating>b__10(System.Object)
System.Threading.Tasks.Task.InnerInvoke()

似乎“挂”在 clr 本身。clr!COMInterlocked::ExchangeAdd64+0x4d

当我将采样堆栈与慢速和快速运行进行比较时,我发现:

ntdll.dll!__RtlUserThreadStart  -52%
kernel32.dll!BaseThreadInitThunk  -52%
ntdll.dll!_RtlUserThreadStart  -52% 
clr.dll!Thread::intermediateThreadProc  -48%
clr.dll!ThreadpoolMgr::ExecuteWorkRequest  -48%
clr.dll!ManagedPerAppDomainTPCount::DispatchWorkItem  -48%
clr.dll!ManagedThreadBase_FullTransitionWithAD  -48%
clr.dll!ManagedThreadBase_DispatchOuter  -48%
clr.dll!ManagedThreadBase_DispatchMiddle  -48%
clr.dll!ManagedThreadBase_DispatchInner  -48%
clr.dll!QueueUserWorkItemManagedCallback  -48% 
clr.dll!MethodDescCallSite::CallTargetWorker  -48%
clr.dll!CallDescrWorkerWithHandler  -48%
mscorlib.ni.dll!System.Threading._ThreadPoolWaitCallback.PerformWaitCallback()  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.ExecuteEntry(Boolean)  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.ExecuteWithThreadLocal(System.Threading.Tasks.TaskByRef)  -48%
mscorlib.ni.dll!System.Threading.ExecutionContext.Run(System.Threading.ExecutionContext System.Threading.ContextCallback System.Object Boolean)  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.ExecutionContextCallback(System.Object)  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.Execute()  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.InnerInvoke()  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task+<>c__DisplayClass11.<ExecuteSelfReplicating>b__10(System.Object)  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.InnerInvokeWithArg(System.Threading.Tasks.Task)  -48%
mscorlib.ni.dll!System.Threading.Tasks.Task.InnerInvoke()  -48%
ParllelForSlowDown.exe!ParllelForSlowDown.Program+<>c__DisplayClass1::<ParallelArray>b__0  -24%
ParllelForSlowDown.exe!ParllelForSlowDown.Program+<>c__DisplayClass1::<ParallelArray>b__0<itself>  -24%
...
clr.dll!COMInterlocked::ExchangeAdd64  +50%

在功能失调的情况下,大部分时间 (50%) 都花在 clr.dll!COMInterlocked::ExchangeAdd64 中。此方法是使用 FPO 编译的,因为堆栈在中间被破坏以获得更高的性能。我认为 Windows 代码库中不允许使用此类代码,因为它使分析变得更加困难。看起来优化已经走得太远了。当我使用调试器单步执行实际的交换操作时

eax=01c761bf ebx=01c761cf ecx=00000000 edx=00000000 esi=00000000 edi=0274047c
eip=747ca4bd esp=050bf6fc ebp=01c761bf iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
clr!COMInterlocked::ExchangeAdd64+0x49:
747ca4bd f00fc70f        lock cmpxchg8b qword ptr [edi] ds:002b:0274047c=0000000001c761bf

cmpxchg8b 将 EDX:EAX=1c761bf 与内存位置进行比较,如果值相等,则将 ECX:EBX=1c761cf 的新值复制到内存位置。当您查看寄存器时,您会发现索引 0x1c761bf = 29.843.903 所有值都不相等。看起来在增加全局循环计数器时存在竞争条件(或过度争用),只有当您的方法主体所做的工作太少以至于它弹出时才会出现。

恭喜您在 .NET Framework 中发现了一个真正的错误!您应该在连接网站上报告它,让他们意识到这个问题。

为了绝对确定这不是另一个问题,您可以尝试使用空委托的并行循环:

    System.Diagnostics.Stopwatch s = new System.Diagnostics.Stopwatch();
    s.Start();
    Parallel.For(0,_len, i => {});
    s.Stop();
    System.Console.WriteLine(s.ElapsedMilliseconds.ToString());

这也重现了这个问题。因此,这绝对是一个 CLR 问题。通常我们在 SO 告诉人们不要尝试编写无锁代码,因为很难做到正确。但即使是 MS 最聪明的人,有时似乎也会弄错......

更新: 我在这里打开了一个错误报告:https ://connect.microsoft.com/VisualStudio/feedbackdetail/view/969699/parallel-for-causes-random-slowdowns-in-x86-processes

于 2014-09-11T21:47:07.257 回答
2

根据您的程序,我编写了一个程序来重现该问题。我认为这与 .NET 大对象堆以及 Parallel.For 如何实现有关。

class Program
    {
        static void Main(string[] args)
        {
            for (int i = 0; i < 10; i++)
                //ParallelArray();
                SingleFor();
        }

        const int _meg = 1024 * 1024;
        const int _len = 1024 * _meg;

         static void ParallelArray()
        {
            int[] stuff = new int[_meg];
            System.Diagnostics.Stopwatch s = new System.Diagnostics.Stopwatch();           
            s.Start();
            Parallel.For(0,
                _len,
                i =>
                {
                    stuff[i % _meg] = i;
                }
                );
            s.Stop();          

         System.Console.WriteLine( s.ElapsedMilliseconds.ToString());

        }

         static void SingleFor()
         {
             int[] stuff = new int[_meg];
             System.Diagnostics.Stopwatch s = new System.Diagnostics.Stopwatch();

             s.Start();

             for (int i = 0; i < _len; i++){
                     stuff[i % _meg] = i;
                 }

             s.Stop();            

             System.Console.WriteLine(s.ElapsedMilliseconds.ToString());
         }
    }

我用VS2013编译,发布版本,不用调试器运行。如果在主循环中调用函数 ParallelArray(),我得到的结果是:

1631
1510
51302
1874
45243
2045
1587
1976
44257
1635

如果调用 SingleFor() 函数,则结果为:

898
901
897
897
897
898
897
897
899
898

我浏览了 MSDN 上有关 Parallel.For 的一些文档,引起了我的注意:写入共享变量。如果循环体写入共享变量,则存在循环体依赖性。这是聚合值时发生的常见情况。与在 Parallel for 循环中一样,我们使用共享变量的东西。

这篇文章Parallel Aggregation解释了 .NET 如何处理这种情况:Parallel Aggregation 模式使用非共享的局部变量,这些变量在计算结束时合并以给出最终结果。将非共享的局部变量用于局部的局部计算结果是循环的步骤如何变得彼此独立的方式。并行聚合展示了这样一个原则:对算法进行更改通常比在现有算法中添加同步原语要好。这意味着它会创建数据的本地副本,而不是使用锁来保护共享变量,并且最后需要将这 10 个分区组合在一起;这会带来性能损失。

当我使用 Parall.For 运行测试程序时,我使用进程 explore 来计算线程数,它有 11 个线程,所以 Parallel.For 为循环创建 10 个分区,这意味着它创建了 10 个大小为 100K 的本地副本,这些对象将被放置在大对象堆上。

.NET 中有两种不同类型的堆。小对象堆 (SOH) 和大对象堆 (LOH)。如果对象大小大于 85,000 字节,则它位于 LOH 中。在进行 GC 时,.NET 以不同的方式处理 2 个堆。

正如本博客中所解释的:.NET 大对象堆上没有更多内存碎片:堆之间的主要区别之一是 SOH 压缩内存,因此显着减少了内存碎片的机会,而 LOH 不使用压缩. 因此,过度使用 LOH 可能会导致内存碎片严重到足以导致应用程序出现问题。

当您连续分配大小 > 85,000 的大数组时,当 LOH 成为内存碎片时,性能会下降。

如果您使用的是 .NET 4.5.1,则可以将 GCSettings.LargeObjectHeapCompactionMode 设置为 CompactOnce,以在 GC.Collect() 之后使 LOH 紧凑。

另一个理解这个问题的好文章是:Large Object Heap Uncovered

需要进一步调查,但我现在没有时间。

于 2014-09-11T18:00:30.550 回答