Издавна графические ускорители (ГПУ) были созданы для обработки изображения и видео. В какой то момент ГПУ стали использоваться для вычислений общего назначения. Но развитие центральных процессоров тоже не стояло на месте: компания Intel ведет активные разработки в сторону развития векторных расширений (AVX256, AVX512, AVX1024). В итоге, появляются разные процессоры — Core, Xeon, Xeon Phi. Обработку изображений можно отнести к такому классу алгоритмов, которые легко векторизуются.
Но как показывает практика, несмотря на довольно высокий уровень компиляторов и технологичность центральных процессоров и сопроцессоров Xeon Phi, сделать обработку изображения с использованием векторных инструкций не так просто, так как современные компиляторы плохо справляются с автоматической векторизацией, а использовать векторные intrinsic функции достаточно трудоемко. Также возникает вопрос о совмещении векторизованного вручную кода и скалярных участков.  В данной статье  было разобрано, как можно использовать векторные расширения для оптимизации медианной фильтрации для центральных процессоров. Для чистоты эксперимента я взял все те же самые условия, что у автора: использовал алгоритм медианной фильтрации квадратом 3х3, данные картинки занимают 8 бит на пиксель, обработка рамки (в зависимости от размера квадрата фильтрации) не делается; и попробовал оптимизировать данное ядро на графическом процессоре. Также был рассмотрен алгоритм медианной фильтрации квадратов 5х5. Суть алгоритма в следующем: Для того, чтобы не заниматься "лишней" оптимизацией, воспользуемся некоторыми выкладками автора упомянутой статьи. И в итоге получится простой код вычислительного ядра: Для каждой точки окрестность грузится согласно данной картинке (желтым выделен пиксель изображения, для которого требуется посчитать результат): В данной реализации есть один минус — картинка хранится в 8 битном формате, а вычисления происходят в 32х битном. Если заглянуть в боевой код векторной реализации на ЦПУ, можно увидеть, что там применено много оптимизаций, в числе которых исключение лишних операций во время сортировки, так как в зависимости от компилятора, оптимизации могут не сработать. Компилятор Nvidia таких недостатков лишен — он с легкостью развернет два цикла по z2 и z1 даже если не будет указано #pragma unroll ( в данном случае компилятор будет принимать решение по разворачиванию цикла сам, и если регистров не хватает, то циклы не будут развернуты), а также будут исключены лишние операции функций Sort после их подстановки (указание inline носит рекомендательный характер, если необходимо подставить функцию вне зависимости от того, как решит компилятор, необходимо использовать forceinline).
Тестировать я буду на ГПУ Nvidia GTX 780 Ti примерно такого же года выпуска, что и центральный процессор в приведенной выше статье (Intel Core-i7 4770 3.4 GHz). Размер изображения — черно-белое 1920 х 1080 пикселей. Если запустить скомпилированную первую версию на ГПУ, то получим следующие времена: Как видно из приведенных времен, просто "нативно" переписав код и запустив его на ГПУ, получаем неплохое ускорение, причем нет проблем с выравниванием по размеру картинки — код на ГПУ будет одинаково хорошо работать на любых размерах изображения (соответственно начиная с какого-то размера, когда ГПУ будет полностью загружен). Также не приходится думать о выравнивании памяти — все за нас сделает компилятор, достаточно выделить память с помощью специальных функций CUDA RunTime.
Многие, кто сталкивался с графическими ускорителями, сразу скажут, что необходимо загрузить данные и выгрузить их обратно. При скорости PCIe 3.0 в 15 ГБ/с для 2 МБ изображения получается порядка 130 микросекунд для передачи в одну сторону. Тем самым, если происходит потоковая обработка изображений, то загрузка + выгрузка будут занимать около 260 микросекунд, в то время как счет занимает около 372 микросекунды, что означает, что даже в самой "нативной" реализации передачи в ГПУ покрываются. Также можно сделать вывод, что быстрее, чем 260 микросекунд на одно изображение, обрабатывать поток картинок не получится. Но если этот фильтр применяется вместе с другим десятком фильтров во время обработки одной картинки, то дальнейшая оптимизация данного ядра становится полезной.  Для того, чтобы повысить эффективность операций сортировки, можно воспользоваться недавно добавленными SIMD инструкциями. Данные инструкции представляют собой intrinsics функции, которые позволяют за одну операцию вычислить некоторую математическую функцию для двух unsigned / signed short или для 4х unsigned / signed char. Список доступных функций можно посмотреть в документации  Cuda ToolKit. Нам нужны две функции: vminu4(unsigned a, usnigned b) и vmaxu4(unsigned a, unsigned b); вычисляющие минимум и максимум для 4х без знаковых 8ми битных значений. Для загрузки четырех значений, разделим все изображение на 4 части по горизонтали и каждый блок будет загружать данные из 4х полос, объединяя 4 загруженных точки с помощью побитового сдвига в unsigned int: Активная зона обработки — самый верхний квадрат размером 1920 х 270. Соответственно, оранжевым показан блок из 128 х 1 нитей, которые загрузят 128 * 4 элемента. Как известно, размер варпа на ГПУ состоит из 32х нитей. Все нити одного варпа выполняют одну инструкцию за такт одновременно. Так как каждая нить загрузит к себе 4 элемента типа unsigned char, то один варп в общей сложности выполнит обработку 128 элементов за одну инструкцию, то есть получается некая 1024 битная обработка. Для ЦПУ на данный момент доступны 256 битные AVX2 регистры (которые в будущем превратятся в 512 и 1024 битные). С данными оптимизациями код вычислительного ядра преобразуется к следующему виду: В результате небольших изменений, мы увеличили количество обрабатываемых элементов до 4х (или 128 элементов за инструкцию). Сложность кода возросла и компилятор генерирует слишком много регистров, что не позволяет загрузить ГПУ на 100%. Так как у нас все-таки простые вычисления, мы скажем компилятору, что хотим запустить максимально возможное количество нитей на каждом потоковом процессоре (максимум для данного ГПУ — 2048 нитей) с помощью __launch_bounds (128, 16), что говорит о том, что будет запущена конфигурация из 16 блоков по 128 нитей в каждой. И еще одно нововведение — добавим ключевое слово restrict для использования текстурного кэша для загрузки повторяющихся данных (если провести анализ, то их достаточно много, большинство точек считывается по 3 раза). Чтобы текстурный кэш был задействован, необходимо также указание const. Все вместе указание будет выглядеть так:  Соответствующие времена второй версии: Уже получили не плохое ускорение. Но можно заметить, что загружая 4 элемента с таким расстоянием, происходит слишком много повторных загрузок. Это значительно снижает быстродействие. Можно ли каким то образом использовать то, что мы уже загрузили непосредственно на регистры и улучшить локальность данных? Да, можно! Пусть эти 4 линии теперь располагаются близко друг к другу, а начала блоков будут "размазаны" по всей высоте с расстоянием 4. Таким образом, учитывая размер блока нитей по Y, приходим к следующему коду: Оптимизированный код таким образом позволяет компилятору использовать уже загруженный данные. Единственная проблема — проверка условия (idy < H — 1). Для того, чтобы убрать данную проверку, необходимо добавить необходимое количество строк, то есть выровнить картинку по вертикали на 4, и заполнить все дополнительные элементы максимальным числом (255). После преобразования кода к третьему варианту, времена будут следующие: Далее оптимизировать уже довольно сложно, так как все таки вычислений здесь не так много. Здесь не был затронут вопрос использования разделяемой памяти. Были некоторые попытки использовать разделяемую память, но лучшее время я получить не смог. Если связать скорость обработки изображения с его размером, то в не зависимости от того, выровнена ли картинка по размеру или нет, можно получить порядка 15 GigaPixels / second. Применив все оптимизации, можно с небольшими изменениями реализовать фильтр квадратом 5х5. Сложность данного кода состоит в правильной реализации частичной битонической сортировки для 25 элементов. По аналогии можно реализовывать фильтры любого размера (начиная с какого-то размера фильтра, наверняка, будет быстрым общий алгоритм сортировки). Скорость обработки фильтра данного размера будет примерно в 5.7 раз больше при использовании AVX2, в то время как ГПУ версия замедлится всего в 3.8 раза (порядка 0.5 ms). Итоговое ускорение на ГПУ при медианной фильтрации квадратом 5х5 может достигнуть 5 раз по сравнению с AVX2 версией.  В заключении можно подвести некоторые итоги. Оптимизируя последовательную скалярную программу можно получить большие ускорения с помощью графического процессора. Для медианных фильтров в зависимости от радиуса фильтрации значения ускорений могут достигать 1000 раз! Конечно, очень оптимальная версия с использованием AVX2 инструкций резко сокращает разрыв между ГПУ и ЦПУ. Но лично по своему опыту могу сделать заключение, что векторизовывать код вручную не так просто, как на самом деле кажется. Напротив, даже самая "нативная" реализация на ГПУ дает достаточно высокие ускорения. И в большинстве случаев это бывает достаточно. И если учесть, что количество графических плат в одной материнской плате может быть больше одной, общее ускорение обработки потока изображений или видео может линейно возрасти.
Вопрос о том, стоит ли использовать ГПУ или нет, является риторическим. Безусловно, в задачах обработки изображений и видео ГПУ будет показывать лучшую производительность и с увеличением вычислительной нагрузки, разница между скоростью обработки на ЦПУ и ГПУ будет увеличиваться в пользу последнего. Боевые оптимизации доступны во второй статье: Быстрее быстрого или глубокая оптимизация Медианной фильтрации для GPU Nvidia Программист высокопроизводительных вычислений