【问题标题】:Parallelizing a very tight loop并行化一个非常紧密的循环
【发布时间】:2014-07-18 09:17:01
【问题描述】:

我在这个问题上已经花了好几个小时了,但我总是以线程争用而结束并行化循环的任何性能改进。

我正在尝试计算 8 位灰度千兆像素图像的直方图。读过《CUDA by Example》一书的人可能知道这是从哪里来的(第 9 章)。

该方法非常非常简单(导致非常紧密的循环)。基本上只是

    private static void CalculateHistogram(uint[] histo, byte[] buffer) 
    {
        foreach (byte thisByte in buffer) 
        {
            // increment the histogram at the position
            // of the current array value
            histo[thisByte]++;
        }
    }

其中 buffer 是一个包含 1024^3 个元素的数组。

在最近的 Sandy Bridge-EX CPU 上,构建 10 亿个元素的直方图需要 1 秒在一个内核上运行。

无论如何,我尝试通过在所有内核之间分配循环来加快计算速度,最终得到一个慢 50 倍的解决方案。

    private static void CalculateHistrogramParallel(byte[] buffer, ref int[] histo) 
    {
        // create a variable holding a reference to the histogram array
        int[] histocopy = histo;

        var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };

        // loop through the buffer array in parallel
        Parallel.ForEach(
            buffer,
            parallelOptions,
            thisByte => Interlocked.Increment(ref histocopy[thisByte]));
    }

很明显,因为原子增量对性能的影响。

无论我尝试了什么(例如范围分区器 [http://msdn.microsoft.com/en-us/library/ff963547.aspx]、并发集合 [http://msdn.microsoft.com/en-us/library/dd997305(v=vs.110).aspx] 等等),归根结底是我将 10 亿个元素减少到 256 个元素,而且我总是以尝试访问我的直方图数组时处于竞争状态。

我最后一次尝试是使用范围分区器,例如

       var rangePartitioner = Partitioner.Create(0, buffer.Length);

        Parallel.ForEach(rangePartitioner, parallelOptions, range => 
        {
            var temp = new int[256];
            for (long i = range.Item1; i < range.Item2; i++) 
            {
                temp[buffer[i]]++;
            }
        });

计算子直方图。但最后,我仍然遇到问题,我必须合并所有这些子直方图,然后再次发生线程争用。

我拒绝相信没有办法通过并行化来加快速度,即使它是一个如此紧密的循环。如果它在 GPU 上是可能的,那么它在某种程度上也必须在 CPU 上是可能的。

除了放弃,还有什么可以尝试的?

我已经搜索了很多 stackoverflow 和互联网,但这似乎是并行性的边缘案例。

【问题讨论】:

  • 您是否尝试过为每个并行事物使用单独的histo 并在最后将它们全部加起来?
  • 我用霍夫变换做过类似的事情。我使用了单独的累加器并在最后将它们合并,给了我很大的提升。最后合并 4/8 个小数组不应该是一个瓶颈。我从来没有亲自使用过Parallel,所以对此不太了解,但如果你没有从中得到提升,它似乎在做一些奇怪的事情。
  • @lightxx 考虑每个并行循环的启动成本,创建一个任务,分配一些 L1 / L2 缓存,分配它认为需要的内容,引用内存等。这可能会变得非常繁重和如此紧密的循环会导致减速。您可以考虑使用动态分区msdn.microsoft.com/en-us/library/dd997416.aspx,通常albahari.com/threading/part5.aspx#_PLINQ 会加快速度。
  • 首先考虑加速坏代码。 histo[thisByte]++;很慢 - 在此处使用指针和不安全的代码。应该会显着提升。
  • 实际上我不会将避免不安全代码称为“坏代码”。无论如何,即使我们设法加快单核版本,这不是这里的问题。

标签: c# multithreading performance parallel-processing parallel.foreach


【解决方案1】:

您应该使用具有本地状态的Parallel.ForEach 循环之一。

并行循环的每个单独分区都有一个唯一的本地状态,这意味着它不需要同步。作为最终操作,您必须将每个本地状态聚合为最终值。此步骤需要同步,但每个分区只调用一次,而不是每次迭代调用一次。

代替

Parallel.ForEach(
    buffer,
    parallelOptions,
    thisByte => Interlocked.Increment(ref histocopy[thisByte]));

你可以使用

Parallel.ForEach(
    buffer,
    parallelOptions,
    () => new int[histocopy.Length], // initialize local histogram
    (thisByte, state, local) => local[thisByte]++, // increment local histogram
    local =>
    {
        lock(histocopy) // add local histogram to global
        {
            for (int idx = 0; idx < histocopy.Length; idx++)
            {
                histocopy[idx] += local[idx];
            }
        }
    }

从分区大小和并行选项的默认选项开始并从那里优化也是一个好主意。

【讨论】:

  • 我进行了一些测试,这实际上比单核变体慢。使用 i5-2540M(即具有 2 个物理内核和 4 个逻辑单元的笔记本电脑处理器)在“幼稚”实现中获得 3 秒,在此获得 15 秒
  • @flindeberg 如果你强制它只使用两个线程,看看(iirc)超线程内核如何共享缓存会发生什么?
  • 这些是我得到的结果:00:00:02.7638552(天真)vs 00:00:04.9138028(Dirk 2 个线程)vs 00:00:02.7535994(2 个线程,硬编码)
  • 是的,限制为两个线程有​​很大的不同,但至少对我来说并不比单核快。
  • @flindeberg 我运行了一个类似的测试用例并同意您的观察。因此,虽然这种方法比不使用本地状态要好,但它仍然很糟糕。我的猜测是问题出在内存限制而不是 CPU 限制。
【解决方案2】:

我对@9​​87654321@ 没有任何经验,但我使用手动线程进行了测试,并且效果很好。

private class Worker
{
    public Thread Thread;
    public int[] Accumulator = new int[256];
    public int Start, End;
    public byte[] Data;

    public Worker( int start, int end, byte[] buf )
    {
        this.Start = start;
        this.End = end;
        this.Data = buf;

        this.Thread = new Thread( Func );
        this.Thread.Start();
    }
    public void Func()
    {
        for( int i = Start; i < End; i++ )
            this.Accumulator[this.Data[i]]++;
    }
}

int NumThreads = 8;
int len = buf.Length / NumThreads;

var workers = new Worker[NumThreads];
for( int i = 0; i < NumThreads; i++ )
    workers[i] = new Worker( i * len, i * len + len, buf );

foreach( var w in workers )
    w.Thread.Join();

int[] accumulator = new int[256];
for( int i = 0; i < workers.Length; i++ )
    for( int j = 0; j < accumulator.Length; j++ )
        accumulator[j] += workers[i].Accumulator[j];

我的 Q720 mobile i7 上的结果:

Single threaded time = 5.50s
4 threads = 1.90s
8 threads = 1.24s

看起来它对我有用。有趣的是,即使超线程内核共享一个缓存,8 线程实际上也比 4 快一点。

【讨论】:

  • 我可以确认您的发现。在 Xeon E5-2680 上,8 线程大约需要 420 毫秒,16 线程需要 200 毫秒,32 线程需要不到 100 毫秒。只是出于好奇,我尝试了 64 线程(~~ 120ms)和 128 线程(~~ 150ms)
  • 你得到了一些甜蜜的装备!
  • 我会暂时搁置这个问题,以防有人想出如何使用并行框架加快处理速度。
  • 请注意,我稍微改变了worker的构造函数,我第一次这样做的方式有点危险。
  • 该算法几乎是所有内存读取,并且数据远不足以放入缓存中,所以我认为大多数时候处理器只是坐在那里等待另一条线要读入的内存。
【解决方案3】:

我不知道这是否会更快,但请稍作观察;

如果对 buffer[] 中的所有元素进行排序会怎样?这将意味着不同核心之间不再有交叉。如果性能适用,则可以增加核心数,它应该线性上升。请注意,您确实需要更好地处理 firstRange/secondRange 拆分,因为您不希望两个元素在不同的范围内具有相同的值。

private static void CalculateHistogram(uint[] histo, byte[] buffer)
{
    Array.Sort(buffer); // so the indexes into histo play well with cache.   

    // todo; rewrite to handle edge-cases.
    var firstRange = new[] {0, buffer.Length/2}; // [inclusive, exclusive]
    var secondRange = new[] {buffer.Length/2, buffer.Length};

    // create two tasks for now ;o
    var tasks = new Task[2];
    var taskIdentifier = 0;

    foreach (var range in new[] {firstRange, secondRange})
    {
        var rangeFix = range; // lambda capture ;s
        tasks[taskIdentifier++] = Task.Factory.StartNew(() =>
        {
            for (var i = rangeFix[0]; i < rangeFix[1]; i++)
                ++histo[i];
        });

    }

    Task.WaitAll(tasks);
}

快速谷歌搜索告诉我,您可以使用 C# 和 GPU 对数字进行进一步排序,这将带来大约 3 倍的性能提升,值得一试:http://adnanboz.wordpress.com/2011/07/27/faster-sorting-in-c-by-utilizing-gpu-with-nvidia-cuda/

Ps 还有一些技巧可以带来非常可观的性能提升:

1) 记住虚假缓存共享的概念-http://msdn.microsoft.com/en-us/magazine/cc872851.aspx

2) 尝试使用 stackalloc 关键字并确保任何内存分配都是通过堆栈完成的。相信我 - 任何内存分配都非常缓慢,除非直接从堆栈中分配。我们谈论的是 5 倍的差异。

3) 您可以使用 C# MONO SIMD 来尝试和求和不同的数组(这是 C 版本,但该概念适用于 C# C++ Adding 2 arrays together quickly

【讨论】:

  • 感谢您的意见。然而,使用 GPU 正是我想要避免的。我有点受够了人们告诉我他们的算法的 GPU 实现比 CPU 实现快“数量级”,只是因为 CPU 实现很糟糕并且他们已经优化了他们的 GPU 版本。 Lee 等人有一篇有趣的论文。涵盖该主题。它被称为“揭穿 100 倍 GPU 与 CPU 的神话:对 CPU 和 GPU 上的吞吐量计算的评估”
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-10-17
  • 1970-01-01
  • 1970-01-01
  • 2011-01-13
  • 1970-01-01
  • 2022-11-27
  • 1970-01-01
相关资源
最近更新 更多