并行化for循环不会带来性能提升

时间:2013-04-11 14:11:49

标签: c++ winapi tbb ppl parallel-for

我有一个将拜耳图像通道转换为RGB的算法。在我的实现中,我有一个嵌套的for循环,它遍历拜耳通道,从拜耳索引计算rgb索引,然后从拜耳通道设置该像素的值。 这里要注意的主要事实是每个像素可以独立于其他像素计算(不依赖于先前的计算),因此该算法是并行化的自然候选者。然而,计算依赖于一些预设数组,所有线程将在同一时间访问但不会更改。

然而,当我尝试将主for与MS的cuncurrency::parallel_for并行化时,我的性能没有提升。实际上,对于在4核CPU上运行的大小为3264X2540的输入,非并行化版本运行~34ms,并行版本运行~69ms(平均10次运行)。我确认操作确实是并行化的(为该任务创建了3个新线程)。

将英特尔的编译器与tbb::parallel_for一起使用可获得接近完全的结果。 为了进行比较,我开始使用C#中实现的算法,其中我也使用了parallel_for循环,在那里我遇到了接近X4的性能提升(我选择C++因为这个特定的任务{ {1}}即使使用单核也更快。

有什么想法阻止我的代码很好地并行化?

我的代码:

C++

8 个答案:

答案 0 :(得分:22)

首先,您的算法受限于内存带宽。那就是内存加载/存储将超过你所做的任何索引计算。

SSE / AVX之类的矢量操作也无济于事 - 您没有进行任何密集计算。

每次迭代增加工作量也没用 - PPLTBB都足够聪明,不能每次迭代创建线程,他们会使用一些好的分区,这将另外尝试保留局部性。例如,这里引用TBB::parallel_for

  

当工作线程可用时,parallel_for执行迭代是非确定性的顺序。不要依赖任何特定的执行顺序来保证正确性。但是,为了提高效率,确实期望parallel_for倾向于在连续的值运行中运行

真正重要的是减少内存操作。对输入或输出缓冲区进行任何多余的遍历都会导致性能下降,因此您应该尝试删除memset或同时执行此操作。

您正在完全遍历输入和输出数据。即使你跳过输出中的东西 - 这也不重要,因为内存操作是在现代硬件上由64字节块发生的。因此,计算输入和输出的size,衡量算法的time,除以size / time并将结果与​​系统的最大特征进行比较(例如,衡量benchmark)。

我已经对Microsoft PPLOpenMPNative for进行了测试,结果是(我使用了你身高的8倍):

Native_For       0.21 s
OpenMP_For       0.15 s
Intel_TBB_For    0.15 s
MS_PPL_For       0.15 s

如果删除memset,则:

Native_For       0.15 s
OpenMP_For       0.09 s
Intel_TBB_For    0.09 s
MS_PPL_For       0.09 s

正如您所看到的,memset(经过高度优化)可以响应大量的执行时间,这可以显示您的算法是如何受内存限制的。

FULL SOURCE CODE

#include <boost/exception/detail/type_info.hpp>
#include <boost/mpl/for_each.hpp>
#include <boost/mpl/vector.hpp>
#include <boost/progress.hpp>
#include <tbb/tbb.h>
#include <iostream>
#include <ostream>
#include <vector>
#include <string>
#include <omp.h>
#include <ppl.h>

using namespace boost;
using namespace std;

const auto Width = 3264;
const auto Height = 2540*8;

struct MS_PPL_For
{
    template<typename F,typename Index>
    void operator()(Index first,Index last,F f) const
    {
        concurrency::parallel_for(first,last,f);
    }
};

struct Intel_TBB_For
{
    template<typename F,typename Index>
    void operator()(Index first,Index last,F f) const
    {
        tbb::parallel_for(first,last,f);
    }
};

struct Native_For
{
    template<typename F,typename Index>
    void operator()(Index first,Index last,F f) const
    {
        for(; first!=last; ++first) f(first);
    }
};

struct OpenMP_For
{
    template<typename F,typename Index>
    void operator()(Index first,Index last,F f) const
    {
        #pragma omp parallel for
        for(auto i=first; i<last; ++i) f(i);
    }
};

template<typename T>
struct ConvertBayerToRgbImageAsIs
{
    const T* BayerChannel;
    T* RgbChannel;
    template<typename For>
    void operator()(For for_)
    {
        cout << type_name<For>() << "\t";
        progress_timer t;
        int offsets[] = {2,1,1,0};
        //memset(RgbChannel, 0, Width * Height * 3 * sizeof(T));
        for_(0, Height, [&] (int row)
        {
            for (auto col = 0, bayerIndex = row * Width; col < Width; col++, bayerIndex++)
            {
                auto offset = (row % 2)*2 + (col % 2); //0...3
                auto rgbIndex = bayerIndex * 3 + offsets[offset];
                RgbChannel[rgbIndex] = BayerChannel[bayerIndex];
            }
        });
    }
};

int main()
{
    vector<float> bayer(Width*Height);
    vector<float> rgb(Width*Height*3);
    ConvertBayerToRgbImageAsIs<float> work = {&bayer[0],&rgb[0]};
    for(auto i=0;i!=4;++i)
    {
        mpl::for_each<mpl::vector<Native_For, OpenMP_For,Intel_TBB_For,MS_PPL_For>>(work);
        cout << string(16,'_') << endl;
    }
}

答案 1 :(得分:5)

同步开销

我猜想每次循环完成的工作量太小。如果您将图像分成四个部分并且并行运行计算,您会注意到一个很大的增益。 尝试以减少迭代次数和每次迭代更多工作的方式设计循环。这背后的原因是同步太多了。

缓存使用情况

一个重要因素可能是如何对数据进行拆分(分区)以进行处理。 如果处理的行分开,如下面的错误情况,则更多行将导致缓存未命中。对于每个附加线程,此效果将变得更加重要,因为行之间的距离将更大。如果您确定并行化功能执行合理的分区,则手动分工不会产生任何结果

 bad       good
****** t1 ****** t1
****** t2 ****** t1
****** t1 ****** t1
****** t2 ****** t1
****** t1 ****** t2
****** t2 ****** t2
****** t1 ****** t2
****** t2 ****** t2

另外,请确保以与对齐方式相同的方式访问您的数据;每次调用offset[]BayerChannel[]都可能是缓存未命中。您的算法非常耗费内存。几乎所有操作都是访问内存段或写入内存段。防止缓存未命中和最小化内存访问至关重要。

代码优化

下面显示的优化可能由编译器完成,可能无法提供更好的结果。值得知道的是,他们可以完成。

    // is the memset really necessary?
    //memset(RgbChannel, 0, Width * Height * 3 * sizeof(T));
    parallel_for(0, Height, [&] (int row)
    {
        int rowMod = (row & 1) << 1;
        for (auto col = 0, bayerIndex = row * Width, tripleBayerIndex=row*Width*3; col < Width; col+=2, bayerIndex+=2, tripleBayerIndex+=6)
        {
            auto rgbIndex = tripleBayerIndex + offsets[rowMod];
            RgbChannel[rgbIndex] = BayerChannel[bayerIndex];

            //unrolled the loop to save col & 1 operation
            rgbIndex = tripleBayerIndex + 3 + offsets[rowMod+1];
            RgbChannel[rgbIndex] = BayerChannel[bayerIndex+1];
        }
    });

答案 2 :(得分:3)

我的建议来了:

  1. 并行计算机较大的块
  2. 摆脱模数/乘法
  3. 展开内循环以计算一个完整像素(简化代码)

    template<typename T> void static ConvertBayerToRgbImageAsIsNew(T* BayerChannel, T* RgbChannel, int Width, int Height)
    {
        // convert BGGR->RGB
        // have as many threads as the hardware concurrency is
        parallel_for(0, Height, static_cast<int>(Height/(thread::hardware_concurrency())), [&] (int stride)
        {
            for (auto row = stride; row<2*stride; row++)
            {
                for (auto col = row*Width, rgbCol =row*Width; col < row*Width+Width; rgbCol +=3, col+=4)
                {
                    RgbChannel[rgbCol+0]  = BayerChannel[col+3];
                    RgbChannel[rgbCol+1]  = BayerChannel[col+1];
                    // RgbChannel[rgbCol+1] += BayerChannel[col+2]; // this line might be left out if g is used unadjusted
                    RgbChannel[rgbCol+2]  = BayerChannel[col+0];
                }
            }
        });
    }
    
  4. 此代码比原始版本快60%,但仍然只是笔记本电脑上非并行版本的一半。这似乎是由于算法的记忆有限性,正如其他人已经指出的那样。

    编辑:但我对此并不满意。从parallel_forstd::async时,我可以大大提高并行性能:

    int hc = thread::hardware_concurrency();
    future<void>* res = new future<void>[hc];
    for (int i = 0; i<hc; ++i)
    {
        res[i] = async(Converter<char>(bayerChannel, rgbChannel, rows, cols, rows/hc*i, rows/hc*(i+1)));
    }
    for (int i = 0; i<hc; ++i)
    {
        res[i].wait();
    }
    delete [] res;
    

    转换器是一个简单的类:

    template <class T> class Converter
    {
    public:
    Converter(T* BayerChannel, T* RgbChannel, int Width, int Height, int startRow, int endRow) : 
        BayerChannel(BayerChannel), RgbChannel(RgbChannel), Width(Width), Height(Height), startRow(startRow), endRow(endRow)
    {
    }
    void operator()()
    {
        // convert BGGR->RGB
        for(int row = startRow; row < endRow; row++)
        {
            for (auto col = row*Width, rgbCol =row*Width; col < row*Width+Width; rgbCol +=3, col+=4)
            {
                RgbChannel[rgbCol+0]  = BayerChannel[col+3];
                RgbChannel[rgbCol+1]  = BayerChannel[col+1];
                // RgbChannel[rgbCol+1] += BayerChannel[col+2]; // this line might be left out if g is used unadjusted
                RgbChannel[rgbCol+2]  = BayerChannel[col+0];
            }
        };
    }
    private:
    T* BayerChannel;
    T* RgbChannel;
    int Width;
    int Height;
    int startRow;
    int endRow;
    };
    

    现在比非并行版本快3.5倍。从我到目前为止在Profiler中看到的情况来看,我认为parallel_for的工作窃取方法会导致大量的等待和同步开销。

答案 3 :(得分:2)

我没有使用过tbb :: parallel_for cuncurrency :: parallel_for,但是如果你的数字是正确的,它们似乎带来了太多的开销。但是,我强烈建议您在测试时运行10次迭代,并确保在计时之前进行尽可能多的预热迭代。

我使用三种不同的方法测试了您的代码,平均超过1000次尝试。

Serial:      14.6 += 1.0  ms
std::async:  13.6 += 1.6  ms
workers:     11.8 += 1.2  ms

首先是连续计算。第二个是使用对std :: async的四次调用完成的。最后一步是通过向四个已经启动(但正在休眠)的后台线程发送四个作业来完成的。

收益并不大,但至少它们是收益。我在2012年的MacBook Pro上进行了测试,双超线程内核= 4个逻辑内核。

供参考,这是我的std :: async parallel for:

template<typename Int=int, class Fun>
void std_par_for(Int beg, Int end, const Fun& fun)
{
    auto N = std::thread::hardware_concurrency();
    std::vector<std::future<void>> futures;

    for (Int ti=0; ti<N; ++ti) {
        Int b = ti * (end - beg) / N;
        Int e = (ti+1) * (end - beg) / N;
        if (ti == N-1) { e = end; }

        futures.emplace_back( std::async([&,b,e]() {
            for (Int ix=b; ix<e; ++ix) {
                fun( ix );
            }
        }));
    }

    for (auto&& f : futures) {
        f.wait();
    }
}

答案 4 :(得分:2)

要检查或做的事

  • 您使用的是Core 2还是旧版处理器?它们有一个非常窄内存总线,很容易用这样的代码饱和。相比之下,4通道Sandy Bridge-E处理器需要多个线程来使内存总线饱和(单个内存绑定线程无法完全饱和)。
  • 您是否填充了所有内存频道?例如。如果您有一个双通道CPU,但只安装了一个RAM卡,或者两个位于同一个通道上,那么您可以获得一半的可用带宽。
  • 您如何计时您的代码?
    • 时间应该在应用程序内完成,如Evgeny Panasyuk建议的那样。
    • 您应该在同一个应用程序中进行多次运行。否则,您可能会计划一次性启动代码以启动线程池等。
  • 删除多余的 memset ,正如其他人所解释的那样。
  • 正如ogni42和其他人所建议的那样,展开你的内部循环(我没有费心去检查该解决方案的正确性,但是如果它错了,你应该能够解决它)。这与并行化的主要问题是正交的,但无论如何它都是一个好主意。
  • 进行性能测试时,请确保您的机器空闲

其他时间

我在一个简单的C ++ 03 Win23实现中合并了Evgeny Panasyuk和ogni42的建议:

#include "stdafx.h"

#include <omp.h>
#include <vector>
#include <iostream>
#include <stdio.h>

using namespace std;

const int Width = 3264;
const int Height = 2540*8;

class Timer {
private:
    string name;
    LARGE_INTEGER start;
    LARGE_INTEGER stop;
    LARGE_INTEGER frequency;
public:
    Timer(const char *name) : name(name) {
        QueryPerformanceFrequency(&frequency);
        QueryPerformanceCounter(&start);
    }

    ~Timer() {
        QueryPerformanceCounter(&stop);
        LARGE_INTEGER time;
        time.QuadPart = stop.QuadPart - start.QuadPart;
        double elapsed = ((double)time.QuadPart /(double)frequency.QuadPart);
        printf("%-20s : %5.2f\n", name.c_str(), elapsed);
    }
};

static const int offsets[] = {2,1,1,0};

template <typename T>
void Inner_Orig(const T* BayerChannel, T* RgbChannel, int row)
{
    for (int col = 0, bayerIndex = row * Width;
         col < Width; col++, bayerIndex++)
    {
        int offset = (row % 2)*2 + (col % 2); //0...3
        int rgbIndex = bayerIndex * 3 + offsets[offset];
        RgbChannel[rgbIndex] = BayerChannel[bayerIndex];
    }
}

// adapted from ogni42's answer
template <typename T>
void Inner_Unrolled(const T* BayerChannel, T* RgbChannel, int row)
{
    for (int col = row*Width, rgbCol =row*Width;
         col < row*Width+Width; rgbCol +=3, col+=4)
    {
        RgbChannel[rgbCol+0]  = BayerChannel[col+3];
        RgbChannel[rgbCol+1]  = BayerChannel[col+1];
        // RgbChannel[rgbCol+1] += BayerChannel[col+2]; // this line might be left out if g is used unadjusted
        RgbChannel[rgbCol+2]  = BayerChannel[col+0];
    }
}

int _tmain(int argc, _TCHAR* argv[])
{
    vector<float> bayer(Width*Height);
    vector<float> rgb(Width*Height*3);
    for(int i = 0; i < 4; ++i)
    {
        {
            Timer t("serial_orig");
            for(int row = 0; row < Height; ++row) {
                Inner_Orig<float>(&bayer[0], &rgb[0], row);
            }
        }
        {
            Timer t("omp_dynamic_orig");
            #pragma omp parallel for
            for(int row = 0; row < Height; ++row) {
                Inner_Orig<float>(&bayer[0], &rgb[0], row);
            }
        }
        {
            Timer t("omp_static_orig");
            #pragma omp parallel for schedule(static)
            for(int row = 0; row < Height; ++row) {
                Inner_Orig<float>(&bayer[0], &rgb[0], row);
            }
        }

        {
            Timer t("serial_unrolled");
            for(int row = 0; row < Height; ++row) {
                Inner_Unrolled<float>(&bayer[0], &rgb[0], row);
            }
        }
        {
            Timer t("omp_dynamic_unrolled");
            #pragma omp parallel for
            for(int row = 0; row < Height; ++row) {
                Inner_Unrolled<float>(&bayer[0], &rgb[0], row);
            }
        }
        {
            Timer t("omp_static_unrolled");
            #pragma omp parallel for schedule(static)
            for(int row = 0; row < Height; ++row) {
                Inner_Unrolled<float>(&bayer[0], &rgb[0], row);
            }
        }
        printf("-----------------------------\n");
    }
    return 0;
}

以下是我在三通道8路超线程Core i7-950盒子上看到的时间:

serial_orig          :  0.13
omp_dynamic_orig     :  0.10
omp_static_orig      :  0.10
serial_unrolled      :  0.06
omp_dynamic_unrolled :  0.04
omp_static_unrolled  :  0.04

“静态”版本告诉编译器在循环入口处平均分配线程之间的工作。这避免了尝试进行工作窃取或其他动态负载平衡的开销。对于此代码段,即使跨线程的工作负载非常一致,它似乎没有什么区别。

答案 5 :(得分:0)

性能降低可能正在发生,因为您正在尝试在“行”数量的核心上分配循环,这些核心不可用,因此它再次成为具有并行开销的顺序执行。

答案 6 :(得分:0)

对并行for循环不是很熟悉,但在我看来,争用是在内存访问中。您的线程看起来是对相同页面的重叠访问。

你可以将数组访问分解为4k块,与页面边界对齐吗?

答案 7 :(得分:0)

在没有优化串行代码的for循环之前,没有必要讨论并行性能。这是我的尝试(一些好的编译器可能能够获得类似的优化版本,但我宁愿不依赖它)

    parallel_for(0, Height, [=] (int row) noexcept
    {
        for (auto col=0, bayerindex=row*Width,
                  rgb0=3*bayerindex+offset[(row%2)*2],
                  rgb1=3*bayerindex+offset[(row%2)*2+1];
             col < Width; col+=2, bayerindex+=2, rgb0+=6, rgb1+=6 )
        {
            RgbChannel[rgb0] = BayerChannel[bayerindex  ];
            RgbChannel[rgb1] = BayerChannel[bayerindex+1];
        }
    });