Хотите дальше читать devby? 📝
Support us

Заклинание GPU на GLSL

Оставить комментарий
Заклинание GPU на GLSL

Устав от монотонности ежедневных задач, я решил бросить взгляд на обыденную рутину серьёзных парней, и, возможно, приложить свою руку. На ум сразу пришел профильный тред с местного форума, 10 страниц которого посвящены войне идеологий и публичной демонстрации широты собственных познаний. Но было в нем и кое-что стоящее, а именно — задача по наложению медианного фильтра. Именно она и была выбрана в качестве challenge`а. Почему-то хотелось экзотики, а упоминание «GLSL» окончательно сформировало возможное решение. Фрагментный шейдер обращается к исходному изображению в 2d текстуре, и реализует логику медианного фильтра. Результирующий фрагмент сохраняется как пиксел в буфер кадра. 100%-я обработка каждого пиксела исходного изображения достигается за счет ортографической проекции прямоугольника с исходной текстурой в буфер кадра. Размеры буфера кадра равны размерам исходного изображения. Таким образом все тексели исходного изображения будут обработаны фильтром. Задача и идея решения были просты и понятны. Оставалось лишь столкнуться и превозмочь. Говорить о эффективных вычислениях с помощью GLSL можно только обладая графическим ускорителем. И он есть: GeForce 8400M GS (NV86). Свежий драйвер от nVidia для Vista 32bit порадовал поддержкой OpenGL 3.2 и GLSL 1.50 на моём, не самом новом графическом процессоре. Тут же решил писать для forward compatibility device context и не оглядываться на все deprecated вызовы API, существовавшие до версии 3.0. FreeGLUT и GLEW сэкономили мне несколько часов реального времени, которое должно было быть потрачено на проверку расширений, загрузку entry-points функций API, создание окна и OGL context. Будете проходить мимо их сайтов на SourceForge.net — обязательно сделайте donation ;) GLSL Современные GPU, это уже не какая-то статическая считалка с отлитыми в кремнии алгоритмами Transformation & Lighting, это вполне себе независимые вычислительные устройства со своими АЛУ, инструкциями, кешами и программами. И вот «эти ваши шейдеры» на GLSL, суть — просто куски С-подобного кода, компилируемого драйвером на этапе выполнения в байткод и передаваемые на GPU для выполнения. Важный момент: GLSL допускает использование процедур и понимает циклы (особено в развернутом виде), но отрицает рекурсию (видимо сказывается отсутствие стека). Итак, будем рисовать текстурированный прямоугольник на весь экран. Массивы вершин, текстурных координат и индексов для сборки геометрии создаются в буферах и отправляются в память GPU навсегда. Проецирование прямоугольника производится вершинным шейдером, выполняющим ортографическую проекцию. Текстурные координаты проходят дальше в фрагментный шейдер.

// vertex shader source
const GLchar* ortho_vs="#version 140\n"
"const mat4 projection = mat4(    2.0, 0.0, 0.0, 0.0,"
"                                 0.0, 2.0, 0.0, 0.0,"
"                                 0.0, 0.0,-2.0, 0.0,"
"                                -1.0,-1.0,-1.0, 1.0);"
"in vec3 position;"
"in vec2 texture0;"
"centroid out vec2 coord0;"
"void main()"
"{"
"    coord0 = texture0;"
"    gl_Position = projection * vec4(position, 1.0);"
"}";
_Winnie C++ Colorizer

А вот собственно и сам фрагментный шейдер, реализующий median3×3. Был добыт на просторах сети и немного поправлен:

// fragment shader source
const GLchar* median3x3_fs=
"#version 140\n"
"#define s2(a, b)           temp = a; a = min(a, b); b = max(temp, b);\n"
"#define mn3(a, b, c)        s2(a, b); s2(a, c);\n"
"#define mx3(a, b, c)        s2(b, c); s2(a, c);\n"
"#define mnmx3(a, b, c)      mx3(a, b, c); s2(a, b);\n"
"#define mnmx4(a, b, c, d)   s2(a, b); s2(c, d); s2(a, c); s2(b, d);\n"
"#define mnmx5(a, b, c, d, e)    s2(a, b); s2(c, d); mn3(a, c, e); mx3(b, d, e);\n"
"#define mnmx6(a, b, c, d, e, f) s2(a,d); s2(b,e); s2(c,f); mn3(a,b,c); mx3(d,e,f);\n"
""
"uniform sampler2DRect image;"
"out vec4 fragment;"
"centroid in vec2 coord0;"
""
"void main()"
"{"
"    vec4 v[6];"
"    v[0] = textureOffset(image, coord0, ivec2(-1, -1));"
"    v[1] = textureOffset(image, coord0, ivec2( 0, -1));"
"    v[2] = textureOffset(image, coord0, ivec2( 1, -1));"
"    v[3] = textureOffset(image, coord0, ivec2(-1,  0));"
"    v[4] = texture(image, coord0);"    // centroid
"    v[5] = textureOffset(image, coord0, ivec2( 1,  0));"
"    vec4 temp;"
"    mnmx6(v[0], v[1], v[2], v[3], v[4], v[5]);"
"    v[5] = textureOffset(image, coord0, ivec2(-1, 1));"
"    mnmx5(v[1], v[2], v[3], v[4], v[5]);"
"    v[5] = textureOffset(image, coord0, ivec2(0, 1));"
"    mnmx4(v[2], v[3], v[4], v[5]);"
"    v[5] = textureOffset(image, coord0, ivec2(1, 1));"
"    mnmx3(v[3], v[4], v[5]);"
"    fragment = v[4];"
"}";
_Winnie C++ Colorizer

Пара слов о характерных моментах:

  • доступ к исходному изображению через переменную sampler2DRect image (почему не sampler2D? — об этом ниже);
  • результурующее значение будет записано в переменную fragment (модификатор доступа out говорит сам за себя);
  • in vec2 coord0 содержит интерполированные текстурные координаты для данного фрагмента;
  • использование векторных переменных (четыре float в каждом из vec4);
  • отсутствие циклов.

Собственно алгоритм прост — поиск 5-й порядковой статистики в множестве из 9 значений. Метод поиска которых подробно описывается например здесь. В нашем случае, это конечное число перестановок макросом s2. Просто, развернуто, молодежно. Data streaming Дело осталось за малым — организавать in/out данных и мерять производительность. Эталоном нагрузки был взят 1 мегапиксел (1MP) 32 битного изображения. Поскольку размеры изображения не обязаны быть равны степени двойки (FullHD video), текстура имеет тип texture_rectangle и обращение к ней происходит через sampler2DRect. Это потенциально позволяет экономить память. Почему-то, сразу же не захотелось рисовать фильтруемое изображение в окно и был организован off-screen rendering в RenderBuffer, равный по размерам исходному изображению. Все готово для первого замера. Итак, BGRA текстура 1024×1024 и медианный фильтр 3×3: ~11.5 мс. Та же текстура, но с модифицированным фрагментным шейдером, оперирующим только red компонентами текселей: ~3.7мс. Неплохо. Но эти цифры не включают главного — передачи данных на GPU и чтения результата в системную память. ОК. Добавляем glTexSubImage2D и glReadPixels в функцию рисования кадра и… огорчаемся. Обновление BGRA 1024×1024 изображения в текстуре, отображение шейдером в RenderBuffer и считывание в системную память — 38мс. Печаль. Конечно, подобные результаты никого не устраивают и для таких ситуаций есть официальный workaround — data streaming для GPU. OpenGL API предоставлет для этих целей разнообразные Pixel Buffer Object’ы — управляемые драйвером области памяти. Можно указать предполагаемый способ использования pbo и драйвер самостоятельно определит тип памяти для размещения данных. Поддерживается резервирование памяти и memory-map-unmap. Вызвав glTexSubImage2D или glReadPixels в контексте PBO, можно указать драйверу на необходимость асинхронной операции ввода/вывода. При этом операции копирования будут отложены до более удобного времени, а вызовы glTexSubImage2D и glReadPixels завершаются немедленно. Создав очереди из PBO для ввода и вывода можно надеяться на довольно честный streaming. Попробовал реализовать изображенную на картинке схему: BGR vs BGRA В начале планировалось использовать не 32 битный (BGRA), а 24 битный (BGR) файл изображения. И шокирующие результаты не заставили себя ждать. Многократные замеры последовательности data streaming в конвеере операций: BGR 1024×1024 -> write-only PBO0 write-onlyPBOn (пишем в первый свободный, текстуру обновляем из первого записанного) -> 2D Rect Texture -> BGR RenderBuffer -> read-onlyPBO0… read-onlyPBOn -> median3×3 BGR 1024×1024 показали ~45мс т. е. хуже чем без использования PBO. Те же синхронные операции копирования + бесполезные переключения буферов. Тушите свет. Странные результаты. Более точечные замеры показали, что порядка 30мс теряется исключительно в glReadPixels. Чтение форумов и эксперименты дали вполне логичный ответ. проблема в переупорядочивании байт для записи в выходной буфер. И с этим драйвер ничего поделать не мог. Действительно, все вычисления на карточке выполняются над четырьмя компонентами. даже если не сохранять один из них — будут подставлены значения по умолчанию. И тут требуется упаковать весь RenderBuffer в массив не с четырьмя, а с тремя компонентами. И без «дырок». Естественно выполняются перемещения и переупорядочивания компонент цвета. Надо сказать что и порядок компонентов цвета (RGBA или BGRA), совершенно безразличный для алгоритма, то же сказывается на скорости ввода/вывода, пусть и не так катастрофически. Результат Осознав и исправив столь важную деталь как, количество байт в формате упаковки данных для считывания, получил те самые преимущества асинхронного ввода/вывода c использованием PBO: ~21мс на один кадр BGRA 1024×1024. Увеличение длинны очередей с двух до, скажем, пяти буферов значительного повышения скорости ввода/вывода не принесло. А вот память под них выделялась. Остановился на трех. Думаю что в моём случае «горлышком» являются заниженные частоты и меньшее количество АЛУ на самом GPU. Потратив немного времени на перестановку команд OpenGL по вводу-отображению-выводу, добился некоторой одновременности их выполнения элементами GPU — стабильные ~17мс на 1024×1024 BGRA кадр. думаю что не так уж и много для мобильного ускорителя. Уверен, что и это не предел, для восьмибитного серого изображения можно выплонить 1 кадр менее чем за 4мс. (c учетом расходов на ввод/вывод). Причем, только переписыванием шейдера и изменением порядка передачи данных. Резюмируя, скажу, что GLSL вполне себе справился с поставленной задачей. Не уверен, что подобные медоты вычислений можно вписать в уже существующие решения и системы, не потеряв буквально все на операциях ввода/вывода. И его применимость в задачах данного класса допустима, но не более. Пробовать использовать API спроектированные для аппаратного ускорения 3d графики в качестве платформы для вычислений, то же, что объяснять людоеду племени «ням-ням» (на его же языке) как прекрасна летняя тундра и как восхитительны переливы северного сияния в середине полярной ночи. Можно, конечно, но получается крайне топорно и не понятно. Проблема в том, что его язык предназначен что бы говорить про другое. Что бы эффективно доносить суть, нужно использовать понятный язык и APIs, не замутненные абстракцией иной предметной области. И такие инструменты, конечно же, существуют. Например, nVidia CUDA. UPDATE: Source code

Помогаете devby = помогаете ИТ-комьюнити.

Засапортить сейчас.

Читайте также
10 курсов по C++ (июнь 2023)
10 курсов по C++ (июнь 2023)
10 курсов по C++ (июнь 2023)
С++, несмотря на свой солидный возраст, остается одним из основных языков программирования, который применется очень широко: от разработки ПО до создания игр. В сети много ресурсов, которые помогут освоить этот язык. Советуем обратить внимаение на подборку команды Digitaldefynd, котрую мы дополнили. В ней как платные, так и бесплатные ресурсы для людей с разным уровнем подготовки и знаний С++.
1 комментарий
Как оплачиваются самые популярные языки GitHub и какой прогноз
Как оплачиваются самые популярные языки GitHub и какой прогноз
Как оплачиваются самые популярные языки GitHub и какой прогноз
Google создала «убийцу» С++
Google создала «убийцу» С++
Google создала «убийцу» С++
4 комментария
С++ по последним рейтингам растет больше всех языков программирования. Кажется, время пройти курсы
С++ по последним рейтингам растет больше всех языков программирования. Кажется, время пройти курсы
С++ по последним рейтингам растет больше всех языков программирования. Кажется, время пройти курсы

Хотите сообщить важную новость? Пишите в Telegram-бот

Главные события и полезные ссылки в нашем Telegram-канале

Обсуждение
Комментируйте без ограничений

Релоцировались? Теперь вы можете комментировать без верификации аккаунта.

Комментариев пока нет.