17% скидка на размещение рекламы на площадках devby — до 20 ноября. Клац!
Support us

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

5 комментариев
Заклинание 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

Читайте также
Python установил сразу два рекорда на рынке языков программирования
Python установил сразу два рекорда на рынке языков программирования
Python установил сразу два рекорда на рынке языков программирования
2 комментария
«Спасите C++»: отец языка программирования просит помощи у сообщества
«Спасите C++»: отец языка программирования просит помощи у сообщества
«Спасите C++»: отец языка программирования просит помощи у сообщества
9 комментариев
TIOBE: «быстрые» языки программирования растут лучше всех
TIOBE: «быстрые» языки программирования растут лучше всех
TIOBE: «быстрые» языки программирования растут лучше всех
2 комментария
TIOBE назвал «язык года»-2024
TIOBE назвал «язык года»-2024
TIOBE назвал «язык года»-2024
1 комментарий

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

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

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

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

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