воскресенье, 27 июля 2008 г.

Про MMX, SSE и оптимизацию

Вводная


Продолжаю делать H264 декодер. Надо соответственно сделать обратное преобразование блока 8х8 коэффициентов в 8х8 кусок плоскости (можно назвать это IDCT, но это будет не совсем правда, потому что там от косинусов осталось мало, а получилось что-то вроде Адамара, но не в этом суть). В спецификации H264 вся эта хрень осуществляется за 6 шагов (ну или за 7, как считать, может даже и за 8, для нашего текущего разговора это опять же не важно). Решил, что это хороший повод освежить свои навыки работы с ММХ и SSE.

Я уже как-то пытался попользовать эти мега расширения, но получилось достаточно смешно.

Дома у меня тогда стояла MSVS 2003 Standard. а Standard он очень хороший, только вот опции оптимизации компилятора в свойствах проекта он включить не дает (ну или я как обычно где-то, чего-то не нашел). Но я чего-то про это не особо задумывался. Соответственно, набросал кусок кода на С++, потом сделал код с той же функциональностью под SSE, сравнил скорость, и возрадовался. Разница была где-то раз в десять (естественно в пользу SSE). Правда радовался я до момента пока не скомпилил тот же тест на работе, где стояла MSVS 2003 Prof. Оказалось, что компилятор оптимизирует так же хорошо, как и я. Только у меня процесс оптимизации занимает два часа, а у него пару секунд. Но там задачка изначально, плохо ложилась на SSE, так что эксперимент был не слишком чистым.

Возвращаясь к H264. Здесь все очень красиво, преобразования с матрицей 8x8 (в матрице элементы типа short) плюс первые три шага используют только сложение вычитание строк этих матриц, как векторов. В общем, если не распробовать эти MMX с SSE здесь, то уж не понятно, где их и пробовать.

Формулировка задачи


Разбираться со всеми шагами сразу есть смысл только, если пишешь декодер, а для поиграться с MMX и SSE достаточно разобрать один шаг.

Итак, на входе матрица
short arBufferSrc[8][8];

Мы эту матрицу преобразуем, результат помещаем в новую память (можно собственно и на месте крутить, но оно только усложняет код, а мне этого на данном этапе не хотелось бы). На С++ это выглядит так:
показать код
short arBufferSrc[64];
short arBufferCPPDst[64];
short *pBufferSrc = arBufferSrc;
short *pBufferCPPDst = arBufferCPPDst; memset(pBufferCPPDst, 0, 64);
for (int i=0;i<8;i++)
{
pBufferCPPDst[0 * 8 + i] = pBufferSrc[0 * 8 + i] + pBufferSrc[4 * 8 + i];
pBufferCPPDst[1 * 8 + i] = -pBufferSrc[3 * 8 + i] + pBufferSrc[5 * 8 + i]
-pBufferSrc[7 * 8 + i] - (pBufferSrc[7 * 8 + i]>>1);
pBufferCPPDst[2 * 8 + i] = pBufferSrc[0 * 8 + i] - pBufferSrc[4 * 8 + i];
pBufferCPPDst[3 * 8 + i] = pBufferSrc[1 * 8 + i] + pBufferSrc[7 * 8 + i]
-pBufferSrc[3 * 8 + i] - (pBufferSrc[3 * 8 + i]>>1);
pBufferCPPDst[4 * 8 + i] = (pBufferSrc[2 * 8 + i]>>1) - pBufferSrc[6 * 8 + i];
pBufferCPPDst[5 * 8 + i] = -pBufferSrc[1 * 8 + i] + pBufferSrc[7 * 8 + i] +
pBufferSrc[5 * 8 + i] + (pBufferSrc[5 * 8 + i]>>1);
pBufferCPPDst[6 * 8 + i] = pBufferSrc[2 * 8 + i] + (pBufferSrc[6 * 8 + i]>>1);
pBufferCPPDst[7 * 8 + i] = pBufferSrc[3 * 8 + i] + pBufferSrc[5 * 8 + i] +
pBufferSrc[1 * 8 + i] + (pBufferSrc[1 * 8 + i]>>1);
}

Все вроде понятно (кому не понятно, тот соответственно бросает читать, и идет пробовать себя в менеджменте, там, по слухам, еще и платят больше). Отметим один важный момент, на каждом шаге цикла мы используем данные только из i-го столбца, и результат так же будет помещаться в i-ом столбце.

Все работает, код нам потом пригодится проверять результаты работы MMX-шного и SSE-шного вариантов

Оптимизация с использованием MMX


Принцип оптимизации простой. Если в исходном коде мы работали с одним столбцом за раз, то, используя MMX регистры, мы можем обработать сразу 4 столбца. Выглядит это примерно так:
показать код
short arBufferSrc[64];
short arBufferMMXDst[64];

short *pBufferSrc = arBufferSrc;
short *pBufferMMXDst = arBufferMMXDst; memset(pBufferMMXDst, 0, 64);

__asm
{
mov eax, pBufferSrc
mov ecx, pBufferMMXDst
mov edx, 2
$loop:
movq mm0, qword ptr [eax]
movq mm1, qword ptr [eax + 4*16]
paddsw mm0, mm1
movq qword ptr [ecx], mm0

movq mm0, qword ptr [eax]
movq mm1, qword ptr [eax + 4*16]
psubsw mm0, mm1
movq qword ptr [ecx + 2*16], mm0

movq mm0, qword ptr [eax + 3*16]
movq mm1, qword ptr [eax + 5*16]
movq mm2, qword ptr [eax + 7*16]
psubsw mm1, mm0
psubsw mm1, mm2
psraw mm2, 1
psubsw mm1, mm2
movq qword ptr [ecx + 1*16], mm1

movq mm0, qword ptr [eax + 1*16]
movq mm1, qword ptr [eax + 3*16]
movq mm2, qword ptr [eax + 7*16]
paddsw mm0, mm2
psubsw mm0, mm1
psraw mm1, 1
psubsw mm0, mm1
movq qword ptr [ecx + 3*16], mm0

movq mm0, qword ptr [eax + 2*16]
movq mm1, qword ptr [eax + 6*16]
psraw mm0, 1
psubsw mm0, mm1
movq qword ptr [ecx + 4*16], mm0

movq mm0, qword ptr [eax + 1*16]
movq mm1, qword ptr [eax + 5*16]
movq mm2, qword ptr [eax + 7*16]
psubsw mm2, mm0
paddsw mm2, mm1
psraw mm1, 1
paddsw mm2, mm1
movq qword ptr [ecx + 5*16], mm2

movq mm0, qword ptr [eax + 2*16]
movq mm1, qword ptr [eax + 6*16]
psraw mm1, 1
paddsw mm0, mm1
movq qword ptr [ecx + 6*16], mm0

movq mm0, qword ptr [eax + 1*16]
movq mm1, qword ptr [eax + 3*16]
movq mm2, qword ptr [eax + 5*16]
paddsw mm1, mm2
paddsw mm1, mm0
psraw mm0, 1
paddsw mm1, mm0
movq qword ptr [ecx + 7*16], mm1

add eax, 8
add ecx, 8
dec edx
jne $loop
emms
}

Все чудесно, чтобы было еще чудеснее надо кое-чего подоптимизировать, но вначале промеряем время и порадуемся нашим способностям в использовании технологий десятилетний давности.

Замеры скорости


Понятно, что отмерить время выполнения одного преобразования не представляется возможным, по причине и его крайней малости, поэтому, мы будем выполнять наши куски по 1 000 000 (одному миллиону) раз. Так же, чтобы никто нам не помешал, выставим побольше приоритет текущему процессу (SetThreadPriority(GetCurrentThread(),THREAD_PRIORITY_TIME_CRITICAL);) и не забудем вернуть его в нормальный по окончанию работы.

А как Вы будете непосредственно время измерять - вариантов, масса сами придумаете.

А я пока расскажу про свои итоги. Вариант номер раз, C++ код - время выполнения миллиона преобразований составляет 111 мс. Вариант номер два, оптимизация под MMX - 24 мс. Я считаю, что это очень достойный результат оптимизации.

Чтобы улучшить этот и без того прекрасный результат надо немного подумать, и, во-первых, не загружать данные в регистры из памяти по два раза, а, во-вторых, развернуть цикл. Регистров для этого вполне хватает, я пробовал. Если все это проделать вместо 24 мс мы получим 20 мс и это хорошая плата, за легкое напряжение мозга.

Основное что нам надо запомнить на данном этапе, это то, что при использовании MMX скорость увеличилась в 4-5 раз. Перейдем теперь к SSE.

Оптимизация с использованием SSE


Принцип оптимизации тот же что и с MMX, только теперь мы будем обрабатывать все 8 столбцов за раз. В данном случае представленный код уже прооптимизирован (в меру моих скромных способностей), на предмет исключения повторной загрузки из памяти в регистры.
показать код
short arBufferSrc[64];
short arBufferSSEDst[64];

short *pBufferSrc = arBufferSrc;
short *pBufferSSEDst = arBufferMMXDst; memset(pBufferSSEDst, 0, 64);

__asm
{
mov eax, pBufferSrc
mov ecx, pBufferSSEDst

movdqa xmm0, [eax]
movdqa xmm2, xmm0
movdqa xmm1, [eax + 4*16]
paddsw xmm0, xmm1
movdqa [ecx], xmm0

psubsw xmm2, xmm
movdqa [ecx + 2*16], xmm2

movdqa xmm0, [eax + 2*16]
movdqa xmm2, xmm0
movdqa xmm1, [eax + 6*16]
psraw xmm0, 1
psubsw xmm0, xmm1
movdqa [ecx + 4*16], xmm0

psraw xmm1, 1
paddsw xmm1, xmm2
movdqa [ecx + 6*16], xmm1

movdqa xmm0, [eax + 1*16]
movdqa xmm1, [eax + 3*16]
movdqa xmm2, [eax + 5*16]
movdqa xmm3, [eax + 7*16]
movdqa xmm4, xmm0
movdqa xmm5, xmm1
movdqa xmm6, xmm2
movdqa xmm7, xmm3
psubsw xmm6, xmm1
psubsw xmm6, xmm3
psraw xmm7, 1
psubsw xmm6, xmm7
movdqa [ecx + 16], xmm6

paddsw xmm4, xmm3
psubsw xmm4, xmm5
psraw xmm5, 1
psubsw xmm4, xmm5
movdqa [ecx + 3*16], xmm4

movdqa xmm6, xmm2
psubsw xmm3, xmm0
paddsw xmm3, xmm6
psraw xmm6, 1
paddsw xmm3, xmm6
movdqa [ecx + 5*16], xmm3

paddsw xmm1, xmm2
paddsw xmm1, xmm0
psraw xmm0, 1
paddsw xmm1, xmm0
movdqa [ecx + 7*16], xmm1

emms
}

Меряем для этого кода время аналогично тому, как делали для MMX - получаем 19 мс. Ага. Прирост относительно MMX варианта, прооптимизированого для работы с памятью, 5%. Упс. Но, как выясняется, столь скромный результат связан с тем, что у меня AMD Athlon, на Intel-овском процессоре получилось ускорение на 50% (соответственно, для MMX получили 17 мс, для SSE - 9 мс). Выводы делать не буду, собственно, для меня понятно, что надо пользовать SSE, если возможно, потому что и 5% тоже хлеб, а 50% это уже и масло.

Надо сделать одно замечание по поводу SSE.

Для загрузки данных из памяти в регистры мы используем оператор movdqa, для него есть аналог movdqu, если использовать его, то вместо прироста скорости относительно MMX мы получим спад. Однако, чтобы использовать movdqa и не получить Run time error, исходные данные должны быть выровнены в памяти к 16 байтам. Это можно сделать либо автоматически:
__declspec(align(16) ) short arBufferSrc[64];

либо выделяя памяти побольше и подравнивая руками.

Заключение


Итак, MMX и SSE оптимизация не представляет сложностей, все работает на задаче, которая к этой оптимизации хорошо подходит. Относительно исходного C++ варианта использование SSE дает нам ускорение от 5 раз в худшем случае, до 10 раз в лучшем.

Комментариев нет: