Оптимизация шейдеров Apple Metal на примере работы с конвертером цветовых пространств: RGB/HSV.

Баталии в блоге Degradr по поводу эстетики и математики в эстетике и эстетической математики, и еще какого-то другого творчества, которое все несли не стесняясь, разгорелись не шуточные. Хочется немного уйти от гуманитарного и погрузиться в наше любимое: в технодр@черство. Прошу дам меня извинить: сегодня попытаемся выжать из GPU последние капли крови производительности. A по простому — мы будем пытаться оптимизировать код, который, казалось бы и оптимизировать уже невозможно. Но…

Каким бы мощным устройством для параллельных вычислений вы бы не обладали, а сегодня это не редкость, всегда найдутся задачи легко исчерпывающие лимит его производительности. И когда вы сталкиваетесь с этим, приходится решать массу вспомогательных задач связанных с попытками улучшить производительность алгоритма в целом и в частностях. А оптимизировать всегда есть ради чего — это и скорость и, что особенно важно в случае с мобильными устройствами, энергопотребление. Обычно под частными улучшениями мы понимаем оптимизацию кода исполняемого с целью снижения времени исполнения, например, каждой его части. Как правило, такая оптимизация основана не только на алгоритмических улучшениях реализации, но и на трюках позволяющих ускориться за счет более быстрого исполнения блока инструкций кода на конкретном железе.

Вот сегодня о таких штуках, в Metal-лическом смысле, и поговорим. Т.е. поговорим как помочь ускорить исполняемый код на GPU процессоров Apple, подготовленный в шейдерах MSL на C++, или в рамках kernel-функций для наших будущих фильтров.

Об очевидном

Предыдущий пост блога описывал, наверное, самый радикальный способ оптимизации работы шейдеров  для задач конструирования фотографических фильтров. Не открою вселенную если скажу, что работа с цветом фотографического изображения наверное все, что обычно нужно с ним делать (ну или почти все…). Обычно фотографы, когда подразумевают что-то под коррекцией цвета, имеют в виду один из способов: разбаланса кривых в каналах RGB, соответствующего цветового пространства, коррекцию каналов ab в L*a*b , или работу с одним из представлений HSV(HSB)/HSL. С точки зрения применения результирующей коррекции все действия можно «записать»  в LUT и дальше об оптимизации не разговаривать.

Но вот тут, к счастью для нас, любителей покопаться в кишках железа, всплывает вот этот «почти»… Т.е. когда фильтр это не только мапинг цвета, но кое-какой анализ. И возможно коке-какая фильтрация на основе этого анализа. А для практического эксперимента выберем цветовое пространство HSV. Выберем его не только за красивое название, удобство и популярность среди творческих пользователей фото-редакторов, но и за красивую легенду из истории возникновения с практическим смыслом. История HSV начинается с истории Computer Graphics и способов синтеза реалистичных цветов для использования в графических редакторах первого поколения и затем для синтеза реалистичных цветов используемых в анимационных программах киностудии Pixar. Впервые работа с HSV (иногда называют до сих пор HSB, отдавая дань традиции), был реализована в редакторе SuperPaint, легендарным Элви Рейем Смитом. Романтические, драматические и прочие художественные истории вокруг этих событий вы можете без труда разыскать в сети. Мы же сосредоточимся на практических аспектах.

Про сорта пива

А практических аспектов ровно два:

  1. наглядная модификация цветового представления — представление тона цвета отделено от представления насыщенности и яркости
  2. тональный анализ изображения

И их хотелось бы как то использовать, например, найти кожу человека и откорректировать её тон. Но вот тут мы натыкаемся на один все портящий нюанс: исходное изображение, которое прилетает к нам в GPU в виде текстуры, всегда RGB, а конвертация RGB в HSV требует вычислений с использованием операций преобразования через операции control-flow, в частности определение тона цвета требует исполнения If-then-(else) инструкций. Возмем исходную формулу преобразований:

{RGB \rightarrow HSV:}


{H' = \begin{cases}0, & if\ M = m\\\frac{G-B}{M-m}\bmod6, & if\ M=R\\\frac{B-R}{M-m}+2, & if\ M=G\\\frac{R-G}{M-m}+4, & if\ M=B\\\end{cases}}

{H=60^{\circ} \times H',}

где {M = max(R,G,B), m=min(R,G,B),}

{H\in[0,360),\ R,G,B\in[0,1]}

{S = \begin{cases}0 & M = 0\\1-\frac{m}{M} \end{cases}}

{V = M}

Обратное преобразование цветового пространства можно вспомнить там же в википедии.

Запишем все это в лоб:

//
// RGB -> HSV
//
inline float3 original_rgb_2_HSV(float3 rgb)
{

float M = max_component(rgb);
float m = min_component(rgb);
float C = M - m;

float Hi;
float S;
float V = M;

if (C==0){
Hi = 0;
S = 0;
}
else{

S = C/V;

if (M==rgb.r){
Hi = fmod((rgb.g-rgb.b)/C,6);
}
else if (M==rgb.g){
Hi = (rgb.b-rgb.r)/C+2;
}
else if (M==rgb.b){
Hi = (rgb.r-rgb.g)/C+4;
}
}

float H = Hi / 6;

return float3(H,S,V);
}

//
// HSV -> RGB
//
inline float3 original_HSV_2_rgb(float3 hsv){

float3 rgb;

if ( hsv.y == 0 )
{
rgb.r = hsv.z;
rgb.g = hsv.z;
rgb.b = hsv.z;
}
else
{
float C = hsv.z * hsv.y;
float Hi = hsv.x * 6;
float X = C*(1-abs(fmod(Hi,2)-1));

if ( Hi >= 0 && Hi<1 ) { rgb = float3(C,X,0); } else if ( Hi >= 1 && Hi<2 ) { rgb = float3(X,C,0); } else if ( Hi >= 2 && Hi<3 ) { rgb = float3(0,C,X); } else if ( Hi >= 3 && Hi<4 ) { rgb = float3(0,X,C); } else if ( Hi >= 4 && Hi<5 ) { rgb = float3(X,0,C); }
else { rgb = float3(C,0,X); }

float m = hsv.z-C;

rgb = rgb + m;

}

return rgb;
}

Анализ вкусовых характеристик решения

Т.е. как минимум преобразование из RGB в одну сторону потребуется, а это как известно, для GPU плохо. Плохо в том смысле, что операция условного перехода крайне неудобна, поскольку требует прерывания ветвления для всех узлов мультипроцессора. Очевидно, что производители GPU пытаются обойти проблему, использовать предикативные механизмы ускорения, шаманить с регистрами состояний и т.п.

Мы не будем ждать милостей от Apple, а возьмем судьбу своих решений в свои руки — научимся трюкам полезным при реализации наших алгоритмов, в том числе и в Metal-е.

Что же делать и едят ли курицу руками?

В целом такие трюки известны: если есть возможность вычислить вместо проверить — вычисли. Если можно использовать матричные операции — используем. Если можно пренебречь точностью — перенебрегай. Если можно не проверять деление на ноль — не проверяй. Этими правилами мы в дальнейшем и будем пользоваться.

И так, вместо того, чтобы записывать операторы условного ветвление кода будем всегда считать все необходимые нам значения, а конечный результат будем находить через комбинацию встроенных в MSL функций. Таких функций в MSL ровно две: mix(x,y,a) и step(x,y). Mix(x,y,a) = x + (y – x ) * a. Step(x,y) возвращает 0 если y<x и 1 если x>=y. (Отвлекаться на другие варианты пока не будем). Таким образом запись:

...
float z = mix(x,y,step(x,y));
...

Присвоит z значение x если x>y, и y если y>=x. Т.е. будет эквивалентно записи:

...
float z;
if (y&lt;x) z = x; else if (y&gt;=x) z = y;
...

Чего нам и хотелось избежать избежать.

А что если у нас больше чем одно ветвление? Все очень просто: делаем последовательно несколько таких вычислений с учетом уже выполненного:

...
float z = mix(x,y,step(x,y));
float w = mix(q,z,step(q,z));
...

Будет эквивалентно записи:

...
float w;

if (y&lt;x) {
if (q&lt;x) w = x; else if (q&gt;=x) w = q;
}
else if (y&gt;=x){
if (q&lt;y) w = y; else if (q&gt;=y) w = q;
}
...

И так далее… Т.е. практически мы можем переписать почти любое ветвление в более эффективной и компактной с точки зрения GPU форме.

Как вы заметили, еще в нашем коде, нужно проверить деление на ноль. Это еще одно ветвление, но с ним все еще проще, если мы допустим, что нам достаточно какой-то точности, скажем 1e-10, и что наши числа всегда положительные (а это так в конкретном примере), то и проверка будет не нужна — просто будем добавлять в делитель какое-то сверх мелкое число не вносящее в наши вычисления существенную погрешность:

...
constexpr float e = 1.0e-10;
float w = x/(y+e);
...

Вот теперь проделаем все эти трюки с нашим преобразованием. Для этого перепишем его в нормализованном виде, с учетом найденных максимальных и минимальных значений компонент из пространства RGB, M=max(R,G,B), m=min(R,G,B), x — компонента посередине:

{H=\begin{cases}0+\frac{x-m}{M-m},& R\geq G\geq B\\1+\frac{m-x}{M-m},& R\geq B\geq G\\\frac{1}{3}+\frac{x-m}{M-m},& G\geq B\geq R\\\frac{1}{3}+\frac{m-x}{M-m},& G\geq R\geq B\\\frac{2}{3}+\frac{x-m}{M-m},& B\geq R\geq G\\\frac{2}{3}+\frac{m-x}{M-m},& B\geq G\geq R\\\end{cases}}

Преобразуем к форме:

{H= \mid K+\frac{x-m}{M-m}\times6 \mid },

где {K=\begin{cases}0,& R\geq G\geq B\\-1,& R\geq B\geq G\\\frac{1}{3},& G\geq B\geq R\\-\frac{1}{3},& G\geq R\geq B\\\frac{2}{3},& B\geq R\geq G\\-\frac{2}{3},& B\geq G\geq R\\\end{cases}}

Чтобы долго не сочинять правила сам код украдем творчески переработаем с Lol Engineering. Теперь все это безобразие запишем в виде MSL:


//
// RGB -&gt; HSV
//
inline float3 rgb_2_HSV(float3 c)
{
//
// Запишем вектором наше условие
//
constexpr float4 K = float4(0, -1/3, 2/3, -1);

//
// Последовательно выполним вместо условного ветвления все вычисления
// и найдем нужный K
//
float4 p = mix(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g));
float4 q = mix(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);

//
// Что бы не делать проверки деления на 0
//
constexpr float e = 1.0e-10;

//
// Конструируем вектор HSV
//
return float3(abs(q.z + (q.w - q.y) / (6 * d + e)), d / (q.x + e), q.x);
}

inline float3 HSV_2_rgb(float3 c)
{
//
// Все почти тоже самое, но наоборот
//
constexpr float4 K = float4(1, 2/3, 1/3, 3);
float3 p = abs(fract(c.xxx + K.xyz) * 6 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

Варка кода и дегустатция блюда

Что-бы проверить нашу самонадеянную попытку все более улучшить, сконструируем кернел-функцию для фильтра с безумным и не нужным циклом для усложнения жизни GPU:

kernel void kernel_original_adjustHSV(
texture2d&lt;float, access::read&gt; inTexture [[texture(0)]],
texture2d&lt;float, access::write&gt; outTexture [[texture(1)]],
uint2 gid [[thread_position_in_grid]])
{
float4 inColor = inTexture.read(gid);

for(int i=0; i&lt;50; i++){
float3 hsv = original_rgb_2_HSV(inColor.rgb); hsv.x = 0.5;
inColor.rgb = original_HSV_2_rgb(hsv);

//
// тут может быть какой-то другой код
//

hsv = original_rgb_2_HSV(inColor.rgb); hsv.y =clamp(hsv.y+0.5,0.0,1.0);
inColor.rgb = original_HSV_2_rgb(hsv);

//
// тут может быть еще какой-то код
//

hsv = original_rgb_2_HSV(inColor.rgb); hsv.z =clamp(hsv.z+0.2,0.0,1.0);
inColor.rgb = original_HSV_2_rgb(hsv);
}
outTexture.write(inColor, gid);

}

Воспользуемся готовым фреймворком DPCore3 для конструирования прототипа приложения фильтрации:

//
// Просто конструируем фильтр на основе kernel-функции
//
class IMPHSVFilter:DPFilter {

init(context aContext: DPContext!, function:String) {
super.init(context: aContext)
self.addFunction(DPFunction(functionName: function, context: aContext))
}

required init!(context aContext: DPContext!) {
fatalError("init(context:) has not been implemented")
}
}

class ViewController: UIViewController {

let context = DPContext.newContext()

//
// Замеряем время исполнения функции фильтра по одной и тойже картинке много раз
//
func filterTest(filter: DPFilter, provider:DPImageProvider) -&gt; Float {

//
// Источник
//
filter.source = provider

//
// Первая метка
//
let t1 = NSDate.timeIntervalSinceReferenceDate()

//
// Пусть будет 10 раз
//
let times = 10

for _ in 0...times{
//
// Заставляем фильтр не филонить
//
filter.dirty = true
//
// А запускать функцию каждый раз
//
filter.apply()
}

//
// Метка в конце
//
let t2 = NSDate.timeIntervalSinceReferenceDate()

return Float(t2-t1)/Float(times)

}

override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)

//
// Загружаем картинку
//
let provider = DPUIImageProvider.newWithImage(UIImage(named: "test.jpg"), context: context)

//
// Сначала прогоняем тест с реализацией в лоб
//
var filter = IMPHSVFilter(context: context, function: "kernel_original_adjustHSV")

let t1 = self.filterTest(filter, provider: provider)

//
// Картинка была загружена большая, что бы iOS не отстрелил нам приложение
// чистим результаты деятельности фильра. Тоже самое можно было сделать через
// autoreleasepool, но мы любим контролировать память самостоятельно
//
filter.flush()

//
// Затем c оптимиpированной версией
//
filter = IMPHSVFilter(context: context, function: "kernel_fast_adjustHSV")

let t2 = self.filterTest(filter, provider: provider)

filter.flush()

//
// Результат
//
NSLog(" *** Время(%@:%@) оригинальной функции = %.2f, оптимизированной = %.2f, выигрыш = %.2f%%",
UIDevice .currentDevice().model, filter.context.device.name!, t1, t2, (t1-t2)/(t1+t2) * 100.0)
}

override func viewDidLoad() {
super.viewDidLoad()
}
}

Полностью код можно можно посмотреть на ImageMetalling-04, скачать весь репозитарий всех примеров: ImageMetalling.

Прогоним тесты на реальных устройствах. У меня в распоряжении ровно два: iPhone5S, и iPhone6, соответственно с A7 и A8 камнями. Тест должен ответить на вопрос имеют ли смысл все эти танцы с бубнами или и так можно кушать.

Запускаем и получаем:
iphone-5s-MTL-perf-HSV_comparsion2

iphone-6-MTL-perf-HSV_comparsion2

Как видно наши надежды оправдались: без крови, но небольшим потом мы вырвали из цепких объятий железа немного лишнего времени вычислений. Причем хорошо видно, что шаманы из Apple весьма сильно продвинулись в оптимизации обработки ветвления кода на кристалле.

Однако не будем забывать: шейдеры могут быть весьма сложными и каждые 7-15% в каждом слое вычислений, вполне себе могут испортить нам статистику. Вот к примеру только на одной таком трюке, мы бы сэкономили 15% батарейки iPhone5s.

Да, еще на забывайте, что деление в GPU дороже умножения:

...
float x = y/2;
...

будет медленнее чем:

...
float x = y*0.5;
...

Пис ол!


Авторы блога не преследуют цели быть предельно корректными, но если заметили явную ашипку, если написали глупость, если что-то не понятно: комментируйте или пишите на: imagemetalling [*] gmail.com .

Реклама

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s