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

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

Однако, когда я попытался распараллелить основной for с MS cuncurrency::parallel_for, я не получил повышения производительности. Фактически, для ввода размера 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];
            }
        });
}

Ответы

Ответ 1

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

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

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

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

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

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

Я сделал тест для 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;
    }
}

Ответ 2

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

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

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

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

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

Ответ 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 до 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 несет много ожидающих и синхронизирующих издержек.

Ответ 4

Я не использовал tbb:: parallel_for not 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 Pro 2012 года, с двумя гипервинтовыми ядрами = 4 логических ядра.

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

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

Ответ 5

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

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

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

Я объединил предложения Евгения Панасюка и ogni42 в реализации С++ 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 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-way гиперпоточном ядре 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

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

Ответ 6

Снижение производительности может происходить из-за того, что вы пытаетесь распределить цикл по числу строк, которые не будут доступны, и, следовательно, снова станут похожими на последовательное выполнение с накладными расходами parallelism.

Ответ 7

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

Можете ли вы разбить доступ к вашему массиву в 4k-фрагменты, несколько совпадающие с границей страницы?

Ответ 8

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