Распараллеливание цикла for не дает прироста производительности

у меня есть алгоритм, который преобразует канал изображения bayer в RGB. В моей реализации у меня есть один вложенный for цикл, который выполняет итерацию по каналу Байера, вычисляет индекс rgb из индекса Байера, а затем устанавливает значение этого пикселя из канала Байера. Здесь главное отметить, что каждый пиксель может быть вычислен независимо от других пикселей (не полагается на предыдущие вычисления), поэтому алгоритм является естественным кандидатом на распараллеливание. Расчет однако полагается на некоторые предустановленные массивы, к которым все потоки будут обращаться одновременно, но не будут меняться.

однако, когда я попытался распараллелить main forС Г И Л!--3--> Я не получил никакого повышения производительности. Фактически, для ввода размера 3264X2540, работающего на 4-ядерном процессоре, непараллелизированная версия выполнялась в ~34 мс, а распараллеленная версия - в ~69 МС (в среднем более 10 запусков). Я подтвердил, что операция действительно была распараллелена (было создано 3 новых потока для задачи.)

использование компилятора Intel с tbb::parallel_for дал почти точные результаты. Для сравнения, я начал с этого алгоритма, реализованного в C# в котором я также использовал parallel_for петли, и там я столкнулся с увеличением производительности X4 (я выбрал C++ потому что для этой конкретной задачи C++ было быстрее даже с одним ядром).

есть идеи, что мешает моему коду хорошо распараллеливаться?

мой код:

template<typename T>
void static ConvertBayerToRgbImageAsIs(T* BayerChannel, T* RgbChannel, int Width, int Height, ColorSpace ColorSpace)
{
        //Translates index offset in Bayer image to channel offset in RGB image
        int offsets[4];
        //calculate offsets according to color space
        switch (ColorSpace)
        {
        case ColorSpace::BGGR:
            offsets[0] = 2;
            offsets[1] = 1;
            offsets[2] = 1;
            offsets[3] = 0;
            break;
        ...other color spaces
        }
        memset(RgbChannel, 0, Width * Height * 3 * sizeof(T));
        parallel_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];
            }
        });
}

8 ответов


прежде всего, ваш алгоритм-ограниченная пропускная способность памяти. То есть загрузка/хранение памяти будет перевешивать любые вычисления индекса, которые вы делаете.

векторные операции, такие как SSE/AVX тоже не помогло бы - вы не делаете никаких интенсивных вычислений.

увеличение количества работы за итерацию также бесполезно-оба PPL и TBB достаточно умны, чтобы не создавать поток на итерацию, они будут использовать хороший раздел, который будет дополнительно старайтесь сохранить населенный пункт. Например, вот цитата из TBB::parallel_for:

когда рабочие потоки доступны, parallel_for выполняет итерации недетерминированного порядка. Не полагайтесь на какой-либо конкретный порядок исполнения для правильности. Однако, для эффективности, ожидайте, что parallel_for будет стремиться к работе на последовательных запусках значений.

что действительно имеет значение, так это сокращение операций с памятью. Любой лишний обход входного или выходного буфера является ядом для производительности, поэтому вы должны попытаться удалить свой memset или сделать это параллельно тоже.

вы полностью проходите входные и выходные данные. Даже если вы пропустите что - то на выходе-это не имеет значения, потому что операции с памятью происходят 64-байтовыми кусками на современном оборудовании. Итак, вычислить size ввода и вывода, мерой time алгоритма, разделить size/time и сравните результат с максимальным характеристики вашей системы (например, измерение с помощью benchmark).

Я сделал тест для Microsoft PPL, OpenMP и Native for, результаты (я использовал 8x вашего роста):

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 (который высоко оптимизирован) отвечает за значительное количество времени выполнения, что показывает, как ваш алгоритм ограничен памятью.

ПОЛНЫЙ ИСТОЧНИК Код:

#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;
    }
}

накладные расходы синхронизации

Я бы предположил, что объем работы, выполненной за итерацию цикла, слишком мал. Если бы вы разделили изображение на четыре части и провели вычисления параллельно, вы бы заметили большой выигрыш. попробуйте спроектировать цикл таким образом, чтобы было меньше итераций и больше работы за итерацию. Причина этого заключается в том, что слишком много синхронизации сделано.

использование кэша

An важным фактором может быть разделение (секционирование) данных для обработки. если proceessed строки разделяются как в плохом случае ниже, то больше строк вызовет пропуск кэша. Этот эффект станет более важным с каждым дополнительным потоком, потому что расстояние между строками будет больше. Если вы уверены, что функция распараллеливания выполняет разумное разбиение, то ручное разбиение работы не даст результаты

 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];
        }
    });

вот мое предложение:

  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];
                }
            }
        });
    }
    

этот код на 60% быстрее, чем оригинальная версия, но все же только наполовину быстрее, чем непараллелизированная версия на моем ноутбуке. Это, по-видимому, связано с ограниченностью памяти алгоритма, как указывали другие уже.

edit: но я не был доволен этим. Я мог бы значительно улучшить параллельную производительность при переходе от parallel_for to std::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 раза быстрее, чем не распараллеленную версию. Из того, что я видел в профилировщике до сих пор, я предполагаю, что подход к работе parallel_for вызывает много ожиданий и синхронизации.


Я не использовал 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. Последнее выполняется путем отправки четырех заданий четырем уже запущенным (но спящим) фоновым потокам.

выигрыши невелики, но, по крайней мере, это выигрыши. Я сделала тест на MacBook 2012 Pro, с двойной многопоточные ядра = 4 логических ядер.

для справки, вот моя параллель std:: async для:

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();
    }
}

что нужно проверить или сделать

  • вы используете процессор Core 2 или более старый? У них очень узкая шина памяти это легко насытить таким кодом. Напротив, 4-канальные процессоры Sandy Bridge-E требуются несколько потоков для насыщения шины памяти (невозможно, чтобы один поток с привязкой к памяти полностью насыщал его).
  • вы заполнили все ваши каналов памяти? Е. Г. если у вас есть двухканальный процессор, но есть только одна карта RAM или две, которые находятся на одном канале, вы получаете половину доступной полосы пропускания.
  • как времени ваш код?
    • сроки должны быть сделаны внутри приложения, как предлагает Евгений Панасюк.
    • вы должны сделать несколько запусков в одном приложении. В противном случае вы можете синхронизировать одноразовый код запуска для запуска пулов потоков, так далее.
  • удалить лишнее memset, как объясняли другие.
  • как предположили ogni42 и другие,развернуть ваш внутренний цикл (я не потрудился проверить правильность этого решения, но если это неправильно, вы должны быть в состоянии исправить это). Это ортогонально основному вопросу распараллеливания, но в любом случае это хорошая идея.
  • убедитесь, что ваша машина в противном случае ожидания когда делаю тестирование производительности.

дополнительные тайминги

Я объединил предложения Евгения Панасюка и ogni42 в голой реализации C++03 Win23:

#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-полосной коробке hyperthreaded 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

"статические" версии говорят компилятору равномерно разделить работу между потоками при входе в цикл. Это позволяет избежать накладных расходов при попытке выполнить работу воровство или другая динамическая балансировка нагрузки. Для этого фрагмента кода это, похоже, не имеет значения, хотя рабочая нагрузка очень однородна по потокам.


снижение производительности может происходить потому, что вы пытаетесь распределить цикл for по количеству ядер "строки", которые не будут доступны, и, следовательно, снова станут похожи на последовательное выполнение с накладными расходами параллелизма.


не очень знаком с параллельными циклами, но мне кажется, что конфликт заключается в доступе к памяти. Похоже, ваши потоки перекрывают доступ к одним и тем же страницам.

можете ли вы разбить свой доступ к массиву на 4K куски, которые несколько совпадают с границей страницы?


нет смысла говорить о параллельной производительности, прежде чем не оптимизировать цикл 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];
        }
    });