Компания
Блог компании Intel, Высокая производительность, Обработка изображений, Параллельное программирование, Программирование
В результате длительного использования даже самых хороших программных продуктов постепенно выявляются те или иные их недостатки. Не
стала исключением, и библиотека Intel Performance Primitives (IPP). К моменту выхода версии 8.0 выяснились некоторые проблемы, часть из
которых относится к функциям обработки двумерных изображений.
Для их решения в IPP 8.0 многие функции обработки изображений приведены к общему шаблону, позволяющему обрабатывать изображения по
блокам ( tiles), и, следовательно, эффективно распараллеливать на уровне приложения код, содержащий вызовы IPP функций. Новый API
соответствующих IPP функций поддерживает бордюры нескольких типов, не использует внутреннее выделение динамической памяти, позволяет
делить изображения на фрагменты произвольного размера и обрабатывать эти фрагменты независимо; упрощает использование и повышает
производительность ряда функций. В данной статье подробно рассмотрен новый API и приведены примеры использования.
Как было сказано выше, командой разработчиков IPP была проведена большая работа по изменению интерфейса двумерных функций и
приведению его к единому стандартному “API с поддержкой бордюров” (“новому АPI”). Зачем? Для ответа на этот вопрос давайте опишем
проблемы, которые присутствовали в предыдущих версиях.
Итак, первая проблема связана с тем, как использовать IPP в многопоточных приложениях. Иногда задача параллельной обработки изображения
эффективнее решается на верхнем уровне, т.е. на уровне приложения. В этом случае, приложение само разбивает изображение на блоки и
создает потоки для их параллельной обработки. Многопоточная версия IPP использует OpenMP с отключенным вложенным параллелизмом (по
англ. nested threading). Если приложение использует другой инструмент для многопоточности или даже OpenMP, но другой версии, то при вызове
из потока такого приложения IPP функции, в свою очередь будет создано несколько потоков, как это показано на рис. 1.
Такое двухуровневое распараллеливания вместо желаемого ускорения может привести к снижению производительности (по англ.
oversubscription), которое можно объяснить тем, что различные средства распараллеливания работают одновременно и ведут борьбу за ресурсы-
очереди, семафоры, физические потоки, выталкивают из кэша данные конкурирующих потоков и т.п.
Приложение пользователя, в отличие от IPP, располагает информацией о том, как полученное изображение будет обрабатываться дальше, и
может сформировать параллельные блоки таким образом, чтобы данные оставшиеся в кэше, использовались после предыдущего этапа
обработки, а не зачитывались из памяти (по англ. data cache locality). Поясним подробнее, что же имеется в виду. IPP большей частью
Стр. 1 из 12
используется в клиентских машинах – десктопах, ноутбуках, топология которых в общем случае выглядит следующим образом рис.2
IPP используется и в HPC сегменте, при этом предполагается, что отличие HPC систем от клиентских лишь в количестве X процессоров.
Нумерация потоков на рисунке условна и узнать физический номер потока можно через идентификатор принадлежащего ему APIC (по англ.
Advanced Programmable Interrupt Controller), который является уникальным для каждого потока. Соответствие между логическим и физическим
номером потока назначается операционной системой или используемым инструментом для параллелизации. Если запустить интенсивную
вычислительную задачу в системе Windows и понаблюдать за ней в диспетчере задач, то можно заметить, что система иногда переключает ее с
одного ядра на другое. Поэтому нет гарантии, что поток будет выполнен на одном и том же ядре в двух последовательных параллельных
регионах. Для указания соответствия между потоком с определенным логическим номером и физическим (по англ. HW thread от слова hardware)
потоком существует специальный термин — affinity. При установке affinity операционная система будет запускать логический поток в
последовательных параллельных регионах на одном и том же HW потоке. В идеальном случае, полностью “чистой” системы указание affinity
могло бы помочь решить проблемы производительности, связанные с тем, что логические потоки переключаются с одного HW потока на другой.
Однако, в реальной современной системе выполняются сотни и возможно тысячи процессов. В любое время каждый такой процесс может быть
выведен из спящего состояния и запущен на некотором HW потоке.
Предположим, что в некотором приложении был тщательно рассчитан объем вычислений для каждого потока, и при этом, в соответствии с
установленной affinity каждый поток приложения был запущен на соответствующем ему HW потоке. На рисунке — зеленым цветом.
Через некоторое время операционная система может запустить другой процесс, к примеру, на HW потоке #3, который изображен красным цветом.
Поскольку все потоки первого приложения жестко привязаны к HW потокам, то поток #3 будет ждать освобождения ресурсов другим процессом, а
операционная система не сможет переключить его с HW потока #3, хотя потоки #0-2 могут уже завершиться и будут простаивать. Мы
рассматриваем идеальную ситуацию, и видим, что в первом случае фиксированной affinity будет затрачено 9 условных единиц времени, а в
случае, когда affinity не указана будет 7 единиц. Итак, в конечном итоге такая фиксированная установка affinity приводит к тому, что в отдельных
случаях производительность будет высокой, а в других очень плохой.
Теперь рассмотрим какие проблемы могут возникнуть, когда наоборот affinity не установлена, и операционная система может перераспределять
потоки по своему усмотрению. Возьмем простой пример, реализующий фильтр Sobel. Он состоит из нескольких последовательных этапов,
каждый из которых может быть распараллелен:
Применяем вертикальный фильтр Sobel к исходному изображению и получаем временное изображение A (ippiFilterSobelVertBorder_8u16s_C1R)
Применяем горизонтальный фильтр Sobel к исходному изображению и получаем временное изображение B
(ippiFilterSobelHorizBorder_8u16s_C1R)
Вычисляем квадрат каждого элемента A и сохраняем результат снова в A (ippiSqr_16s_C1IRSfs)
Вычисляем квадрат каждого элемента B и сохраняем результат снова в B (ippiSqr_16s_C1IRSfs)
Получаем сумму изображений A и B, можем сохранить результат снова в A (ippiAdd_16s_C1IRSfs)
Последний этап – корень квадратный из A (ippiSqrt_16u_C1IRSfs) и преобразование к 8u (ippiConvert_16s8u_C1R)
Стр. 2 из 12
Рис. 3 Этапы реализация фильтра Sobel, которые могут иметь внутреннюю параллелизацию.
Каждая из внутренних функций может быть распараллелена. Однако такой подход представляется неэффективным, поскольку имеется 7
последовательных вызовов функций, у каждого из которых есть точки синхронизации для создания/завершения параллельных регионов.
Перечисленные IPP функции используют простой шаблон разбиения изображения на блоки в параллельном регионе
Рассмотрим, например, последовательный вызов ippiFilterSobelVertBorder_8u16s_C1R (src->A) и ippiSqr_16s_C1IRSfs (A->A2)). Сначала создается
первый параллельный регион (region #1) для функции ippiFilterSobelVertBorder_8u16s_C1R, затем второй (region #2).
Логический поток #0 будет выполнятся на физическом потоке #4 и поэтому порция данных функции SobelVert будет сохранена в L2 и L3 кэшах,
относящихся к физическому потоку #4. В следующем параллельном регионе #2, создаваемом функцией Sqr, разбиение изображения на блоки
будет осуществляться по такому же шаблону, как и в первой функции, поэтому логический поток #0 будет ожидать данные доступными в кэше.
Однако ОС запустила этот логический поток #0 на физическом #3. Данных в кэше не окажется и начнется интенсивный обмен данными между
кэшами этих потоков, что приведет к замедлению времени выполнения приложения.
Еще одна проблема была связана с тем, что до выхода IPP версии 8.0 gold у пользователей возникала ситуация, что приложение и библиотека
используют одинаковую версию OpenMP, но различные модели линковки. К примеру, статическая версия IPP использовала также статическую
библиотеку OpenMP, а пользователь в своем приложении динамическую версию В этом случае OpenMP обнаруживает конфликт и выдает
предупреждение о возможном снижении производительности. В IPP 8.0 теперь используется только динамическая версия OpenMP, поэтому после
перехода на эту версию проблемы такого рода у пользователей библиотеки уже не должны возникать.
Результатом рассмотренных выше проблем стало то, что, несмотря на повсеместную многоядерность, функции с новым API имеют только
обычную однопоточную реализацию. Однако в нем учитывается возможность обработки изображения в параллельном режиме. Однако для IPP
функций, которые уже присутствовали в IPP многопоточная OpenMP реализация сохранилась.
Вторая проблема предыдущей версии АPI возникает при последовательной обработке изображения несколькими фильтрами. Он подразумевает,
что все необходимые пиксели по краям региона интереса (сокращенно ROI от англ. region of interest) доступны в памяти. Поэтому, для получения
выходного изображения шириной dstWidth и высотой dstHeight и фильтра размером kernelWidth X kernelHeight необходимо подать входное
изображение шириной dstWidth+kernelWidth-1 и высотой dstHeight+kernelHeight-1, рис. 6b) и 6c).
Стр. 3 из 12
Рис.6 Необходимость в дополнительных рядах пикселей вокруг ROI для последовательного применения нескольких фильтров в старом API.
А если применяется последовательно 2 ,.., N фильтров, с размерами ядра kWidth1 X kHeight1,…, kWidthN X kHeightN то размер входного
изображения соответственно должен быть
(dstWidth+kWidth1+…+kWidthN – N) X (dstHeight+kHeight1+…+kHeightN-N), рис. 6a). В данном примере для того чтобы получить результирующее
изображение размером 6x6 пикселей применяются два фильтра размером 5x5 и 3x3. Первое изображение имеет размер 6+5-1+3-1 X 6+5-1+3-1,
т.е. 12 X 12 пикселей, а изображение, полученное после первого фильтра размер 8x8. Применяя к нему следующий фильтр размером 3x3
получаем итоговое изображение 6x6. Для того чтобы сформировать бордюры изображения можно воспользоваться IPP функциями
ippiCopyReplicate(Mirror/Const)Border, однако это очень накладно с точки зрения производительности и дополнительно требуемой памяти под
новое изображение с бордюром.
Новый API позволяет избежать вызова таких функций и предоставляет выбор из трех вариантов обработки пикселей, находящихся вне ROI — по-
прежнему считать их доступными в памяти, подставлять вместо них постоянное значение или использовать копию пикселей, лежащих на границе
изображения. Более подробно будет рассказано далее.
В предыдущей версии API двумерных фильтров использовался обратный порядок коэффициентов в соответствии с формулой
Стр. 4 из 12
Рис. 7. Фиксированное положение якоря x=(kw-1)/2, y=(kh-1)/2
В принципе якорь определяет смещение в памяти, от подаваемого указателя на ROI, поэтому в новом API в случае “нестандартного” значения
якоря, достаточно просто сместить ROI в необходимом направлении, см. рис.8 на котором для различных значений якоря в старом API показан
требуемый сдвиг ROI в новом API. Серым цветом показана одна и та же область памяти, а синим – указатель на ROI, подаваемый в функцию. В
старом он неизменен, а в новом сдвигается.
Для масок с четной шириной (высотой) при указании типа бордюра находящегося в памяти справа(внизу) от изображения должно быть доступно
пикселей на один ряд больше.
Рис. 9. Для маски с четной шириной(высотой) требуется на ряд пикселей больше справа(снизу).
Помимо удобства использования, прямой порядок позволяет снизить накладные расходы внутри функции, связанные с перестановкой в обратном
порядке коэффициентов. Часто коэффициенты ядра бывают симметричными и такое их транспонирование было излишним.
В функциях, имеющих новый API, не используется внутреннее выделение динамической памяти с помощью операции malloc. Поскольку при
выделении такой памяти в каком либо из потоков все остальные потоки останавливаются. Память теперь должна быть выделена вне
обрабатывающих функций, а необходимый размер требуемых буферов может быть получен с помощью вспомогательных функций вида
ippiGetBufferSize.
Использование функций с новым API рассмотрим на примере операции морфологии. Подробное описание свойств данной операции можно найти
в литературе и в сети интернет. В общих чертах результатом данной операции является минимальный или максимальный элемент внутри
некоторой окрестности пикселя. В первом случае операция называется Erode рис.11, во втором Dilate, рис. 12
Стр. 5 из 12
Рис. 11 Операция Erode
На первый взгляд, кажется, что название не соответствует полученному результату, тем не менее, все корректно. Поскольку операция Erode
выбирает минимальное значение, а черный цвет формируется меньшими значениями, чем белый, то соседние с буквами пиксели заменяются
черным цветом и возникает эффект утолщения букв. В случае же операции Dilate, наоборот края букв заменяются белыми пикселями и буквы
становятся тоньше.
Для того чтобы получить результат на рисунке 12 необходимо использовать следующие функции
IppStatus ippiMorphologyBorderGetSize_32f_C1R(int roiWidth, IppiSize maskSize, int* pSpecSize, int* pBufferSize)
IppStatus ippiMorphologyBorderInit_32f_C1R( int roiWidth, const Ipp8u* pMask, IppiSize maskSize, IppiMorphState* pMorphSpec, Ipp8u* pBuffer )
IppStatus ippiDilateBorder_32f_C1R (const Ipp32f* pSrc, int srcStep, Ipp32f* pDst, int dstStep, IppiSize roiSize, IppiBorderType borderType, Ipp32f
borderValue, const IppiMorphState* pMorphSpec, Ipp8u* pBuffer)
Первая функция вычисляет необходимый размер буферов, следующая инициализирует эти буферы, а третья функция осуществляет обработку
изображения. Фрагмент кода, использующий эти функции выглядит следующим образом.
ippiMorphologyBorderGetSize_32f_C1R(roiSize.width, maskSize,
&specSize, &bufferSize);
pMorphSpec = (IppiMorphState*)ippsMalloc_8u(specSize);
pBuffer = (Ipp8u*)ippsMalloc_8u(bufferSize);
ippiMorphologyBorderInit_32f_C1R(roiSize.width, pMask, maskSize, pMorphSpec, pBuffer);
ippiDilateBorder_32f_C1R(pSrc, srcStep, pDst, dstStep, roiSize, border, borderValue, pMorphSpec, pBuffer);
Стр. 6 из 12
}
}
status = ippiMorphologyBorderGetSize_32f_C1R(roiSize.width, maskSize, &specSize, &bufferSize);
if (status != ippStsOk){
printf("ippiMorphologyBorderGetSize_32f_C1R / %s\n", ippGetStatusString(status));
return -1;
}
pMorphSpec = (IppiMorphState*)ippsMalloc_8u(specSize);
pBuffer = (Ipp8u*)ippsMalloc_8u(bufferSize);
status = ippiMorphologyBorderInit_32f_C1R(roiSize.width, pMask, maskSize, pMorphSpec, pBuffer);
if (status != ippStsOk){
printf("ippiMorphologyBorderInit_32f_C1R / %s\n", ippGetStatusString(status));
ippsFree(pMorphSpec);
ippsFree(pBuffer);
return -1;
}
status = ippiDilateBorder_32f_C1R(pSrc, srcStep, pDst, dstStep, roiSize, border, borderValue, pMorphSpec, pBuffer);
if (status != ippStsOk){
printf("ippiMorphologyBorderGetSize_32f_C1R / %s\n", ippGetStatusString(status));
ippsFree(pMorphSpec);
ippsFree(pBuffer);
return -1;
}
ippsFree(pMorphSpec);
ippsFree(pBuffer);
ippiFree(pSrc);
ippiFree(pDst);
return 0;
}
В данном примере, и в общем случае, вызов всех функций с новым API следует следующему шаблону
Стр. 7 из 12
Рис. 14 Использование буфера максимального из необходимых размера позволяет уменьшить фрагментацию памяти и “подогреть” буфер
В первом случае ippBorderInMem функции доступны необходимые пиксели как внутри изображения, так и лежащие в памяти. Во втором —
ippBorderConst используется фиксированное значение borderValue. Для многоканальных изображений используется массив значений.
Формирование недостающих пикселей в случае ippBorderRepl показано на рис. 15с). Угловые пиксели изображения дублируются в две внешние
стороны угла, а остальные граничные пиксели только в соответствующую им сторону от границы.
К указанным типам бордюров могут применяться модификаторы, имеющие следующие значения
ippBorderInMemTop = 0x0010,
ippBorderInMemBottom = 0x0020,
ippBorderInMemLeft = 0x0040,
ippBorderInMemRight = 0x0080
Эти модификаторы предназначены для обработки изображения по блокам. Они объединяются с параметром borderType с помощью операции “|”
Стр. 8 из 12
Новый API позволяет производить обработку изображения в нескольких потоках по блокам. Для правильного соединения полученных отдельных
блоков изображения в единое выходное изображение, требуется применить к основному типу бордюра модификаторы соответствующие
положению блока. К примеру, если имеется 16 потоков и используется тип бордюра ippBorderRepl, то значение borderType нужно формировать
следующим образом рис. 16
Блок номер 0 расположен в левой верхней части изображения. Поэтому необходимые для обработки, но недостающие пиксели слева и вверху
блока будут дублироваться граничными, а необходимые пиксели справа и снизу блока будут загружаться из соседних блоков 1, 4 и 5, в
соответствии с модификаторами ippBorderInMemRight|ippBorderInMemBottom. Блоки 1,2 не имеют пикселей лишь сверху, а с трех других сторон и
углов окружены соседними блоками, в которых лежат нужные пиксели, поэтому указывается три модификатора
ippBorderInMemBottom|ippBorderInMemRight|ippBorderInMemLeft. Для блоков 3,4,8,7,B,C,D,E,F модификаторы задаются аналогичным образом.
Блоки 5,6,9, а находятся внутри изображения и поэтому для них можно указать все 4 модификатора, но проще использовать отдельное значение
типа бордюра ippBorderInMem. Возвращаясь к блоку номер 0, комбинация модификаторов ippBorderInMemBottom|ippBorderInMemRight позволяет
функции сделать вывод о том, что нужно использовать пиксели из блока номер 5. А для блока номер C в данную область будут дублироваться
пиксели из блока D, но зато справа и сверху будут использованы пиксели из блока 9. Вся логика такого чтения пикселей из памяти или
дублирования пикселей построена таким образом, чтобы результат обработки изображения как целого так и по блокам полностью совпадал.
Также данный подход предполагает, что при указании модификатора ippBorderInMem* память действительно доступна. В некоторых частных
случаях возможна следующая ситуация как на рис 17
Если разбить изображение размером 1x2 на два блока по одному пикселю, и при этом использовать маску 5x5, то при обработке левого блока и
указании для него как это и положено модификатора ippBorderInMemRight может произойти выход за границу выделенной памяти с
непредсказуемыми последствиями. Поэтому при разбиении изображения на блоки и использовании модификатора ippBorderInMem следует
обеспечить наличие kernelWidth/2 пикселей в памяти в нужную сторону.
На рис.16 изображен общий случай разбивки изображения на блоки для параллельной обработки и вообще говоря, реализация разбиения по
блокам может быть и не такой простой задачей и возможно эффективнее разбивать изображение не на блоки, а на полосы, так как это показано
на рис.18. API с бордюрами позволяет осуществить и такое разбиение на блоки.
Стр. 9 из 12
Приведем пример обработки изображения в несколько потоков. Для этого будем использовать OpenMP.
Лист. 3 Код обработки изображения по блокам
Стр. 10 из 12
k */
t_dst = (Ipp8u*)pDst + t_y * t_h * dstStep + t_x * t_w*sizeof(Ipp32f); /*using x and y of block*/
t_buf = pBuffer + t_id * bufferSize; /*every thread uses own buffer*/
ippiDilateBorder_32f_C1R(t_src, srcStep, t_dst, dstStep, t_roi,t_bor, borderValue, pMorphSpec, t_buf);
}
}
}
}
Этот пример разбивает изображение на 16 блоков в соответствии с рис. 16. Используется общая структура данных pMorphSpec, в соответствии с
идентификатором потока определяется его положение в изображении, после чего формируются модификаторы бордюра. Также каждому потоку
выделяется свой буфер t_buf.
Таким образом, преимущества нового API функций с бордюром, реализованного в IPP 8.0 позволяют решить пользователям IPP библиотеки сразу
несколько важных проблем связанных с обработкой изображений — можно использовать произвольные инструменты параллелизации на уровне
приложения, размер результирующих изображений не уменьшается, а также возможно обрабатывать изображения большого размера, разбивая
их на блоки.
Компания
@a_bakshaev
Пользователь
Поделиться публикацией
+4 3k 19 3
+10 10,9k 20 10
+13 955 1 2
Стр. 11 из 12