У меня есть алгоритм, который преобразует канал изображения Байера в RGB. В моей реализации у меня есть одно вложенное for
цикл, который перебирает байеровский канал, вычисляет индекс rgb по байеровскому индексу и затем устанавливает значение этого пикселя из байеровского канала.
Главное, на что следует обратить внимание, это то, что каждый пиксель может быть рассчитан независимо от других пикселей (не зависит от предыдущих вычислений), и поэтому алгоритм является естественным кандидатом для распараллеливания. Однако для расчета используются некоторые предустановленные массивы, к которым все потоки будут обращаться одновременно, но не изменятся.
Тем не менее, когда я попытался распараллелить основной 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];
}
});
}
Прежде всего, ваш алгоритм ограничен по пропускной способности памяти. То есть загрузка / хранение в памяти перевесит любые вычисления индекса, которые вы делаете.
Векторные операции, такие как 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;
}
}
Я бы догадался, что объем работы, выполненной за итерацию цикла, слишком мал. Если бы вы разбили изображение на четыре части и выполнили вычисления параллельно, вы бы заметили большой выигрыш. Попытайтесь спроектировать цикл таким образом, чтобы в нем было меньше итераций и больше работы на одну итерацию. Причиной этого является то, что слишком много синхронизации сделано.
Важным фактором может быть то, как данные разделяются (разбиваются) для обработки. Если обработанные строки разделены как в плохом случае ниже, то больше строк приведет к отсутствию кэша. Этот эффект станет более важным с каждым дополнительным потоком, потому что расстояние между рядами будет больше. Если вы уверены, что функция распараллеливания выполняет разумное разбиение, то ручное разделение работы не даст никаких результатов.
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];
}
});
Вот мое предложение:
развернуть внутренний цикл для вычисления одного полного пикселя (упрощает код)
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% быстрее, чем оригинальная версия, но все еще вдвое быстрее, чем непараллельная версия на моем ноутбуке. Похоже, это связано с ограниченностью памяти алгоритма, как уже указывали другие.
Редактировать: Но я не был доволен этим. Я мог бы значительно улучшить параллельную производительность при переходе от 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 влечет за собой большие затраты на ожидание и синхронизацию.
Я не использовал 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 Pro 2012 года с двумя гиперпоточными ядрами = 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();
}
}
Вещи, чтобы проверить или сделать
memset
, как объяснили другие.Дополнительные тайминги
Я объединил предложения Евгения Панасюка и ogni42 в простой реализации C23 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-канальном гиперпоточном процессоре 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];
}
});