紧密循环并行化

6
我已经在这个问题上头痛了几个小时,但最终线程争用一直吞噬着并行循环带来的性能提升。
我正在尝试计算一个8位灰度千兆像素图像的直方图。读过《CUDA示例》一书的人可能知道这是什么(第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]++;
        }
    }

其中缓冲区是一个包含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]等),最终都归结为一个事实,即我将十亿个元素缩减为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和网络上搜索了很多,但这似乎是并行性的边缘案例。


1
你试过为每个并行的东西使用单独的histo,然后在最后把它们全部加起来吗? - Andrew Morton
我曾经用霍夫变换做过类似的事情。我使用了单独的累加器并在最后合并它们,这给了我巨大的提升。在最后合并4/8个小数组不应该成为瓶颈。我个人从未使用过“Parallel”,所以对此不太了解,但如果您从中没有获得提升,那么似乎它可能正在做一些奇怪的事情。 - Chris
1
考虑每个并行循环的启动成本,创建一个任务,分配一些L1 / L2缓存,分配它认为需要的内容,引用内存等。这可能会变得非常繁重,并导致紧密循环的减速。您可以尝试使用动态分区 http://msdn.microsoft.com/en-us/library/dd997416.aspx ,通常情况下 http://www.albahari.com/threading/part5.aspx#_PLINQ 可以加快速度。 - Paul Zahra
考虑首先加速糟糕的代码。 histo [thisByte] ++; 是慢的 - 在这里使用指针和不安全的代码。应该会有显著的提升。 - TomTom
我其实不会把避免不安全代码称作“糟糕的代码”。无论如何,即使我们设法加速单核版本,这也不是问题的关键。 - lightxx
为什么你使用Parallel.ForEach而不是Parallel.For,因为你知道缓冲区的实际长度呢?根据我的经验,它们的并行化方式有很大的不同。 - Cesar
3个回答

4
您应该使用其中一个具有本地状态的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];
            }
        }
    }

建议从默认选项开始设置分区大小和并行选项,然后再进行优化。


1
我进行了一些测试,结果这个版本比单核的要慢。在“朴素”实现上花费了3秒,在这个版本上花费了15秒,使用的是i5-2540M(即具有2个物理核心和4个逻辑单元的笔记本处理器)。 - flindeberg
@flindeberg 如果你强制只使用两个线程,会发生什么情况呢?因为(如果我没记错的话)超线程核心共享缓存。 - Chris
这些是我得到的结果:00:00:02.7638552(naive)对比 00:00:04.9138028 (Dirk 2 线程)对比 00:00:02.7535994(2 线程,硬编码)。 - flindeberg
所以,限制为两个线程确实有很大的差异,但至少对我来说并没有比单核更快。 - flindeberg
@flindeberg 我运行了一个类似的测试用例,并且同意你的观察结果。因此,虽然这种方法比不使用本地状态要好,但仍然很糟糕。我的猜测是问题是内存受限而不是CPU受限。 - Dirk

2

我没有使用过Parallel,但我用手动线程编写了一个测试,它完美地运行。

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移动版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个线程(约120毫秒)和128个线程(约150毫秒)。 - lightxx
1
你的装备真是太棒了! - Chris
我会暂时保留这个问题,以防有人想到使用并行框架来加速事情的想法。 - lightxx
请注意,我稍微更改了工作线程的构造函数,第一次修改时有些危险。 - Chris
你有没有想法为什么在运行你的代码时,整体CPU利用率(根据Windows任务管理器)只有10%? - lightxx
1
该算法几乎全部是内存读取,而数据远远不小到可以放入缓存中,因此我认为大部分时间处理器只是闲置等待另一行内存被读入。 - Chris

1
我不知道这样做是否会更快,但是有一个小观察;如果你将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/ 附注:还有其他几个技巧可以带来非常实质性的性能提升:
1)记住虚假缓存共享的概念 - http://msdn.microsoft.com/en-us/magazine/cc872851.aspx 2)尝试使用stackalloc关键字,并确保通过堆栈进行任何内存分配。相信我,在直接从堆栈中分配之外进行的任何内存分配都会非常慢。我们谈论的是5倍的差异。

3) 您可以使用C# MONO SIMD尝试并对不同的数组进行求和(这是C版本,但概念适用于C# C++快速将2个数组相加)。


感谢您的回复。然而,我正是想避免使用GPU。我有点受够了人们告诉我他们的算法在GPU上实现比CPU实现“快几个数量级”,只是因为CPU实现很糟糕,而他们已经优化了GPU版本。Lee等人有一篇有趣的论文涵盖了这个主题。它的名字叫做“揭穿100倍GPU与CPU之间的神话:对CPU和GPU的吞吐量计算进行评估”。 - lightxx

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接