Практическое применение SSE расширения. (5 стр)
Автор: Антон В. Звягинцев
Мощь инструкции SHUFPS
Если ты не умеешь использовать эту инструкцию, то скорее всего напоминаешь велосипедиста на скоростной автостраде. Команда имеет следующий вид:
SHUFPS операнд-приемник (он же источник 1), операнд-источник 2, маска селектор
Вот диаграмма ее функционирования из презентации intel:
Инструкция позволяет посредством маски комбинировать и переставлять данные в 32 битных компонентах XMM регистра. Битовая маска селектор фактически разбита на 4 части по два бита в каждой. Соответственно, каждая из позиций маски может принимать значение от 0 до 3. Две младшие позиции, как видно из диаграммы, выполняют выборку из операнда a. В зависимости от значения битовых масок будут выбираться следующие регистры.
00 - a0
01 - a1
10 - a2
11 - a3
После выборки значения, находившиеся до этого в соответствующих позициях регистра a будут перенесены в регистр а (так как он же является приемником в регистры XMMA0 и XMMA1 соответственно, где A номер регистра в команде). Аналогичная операция выполняется над вторым операндом команды - b, но уже по двум старшим битовым маскам. Сложно? Рассмотрим несколько примеров:
shufps xmm0,xmm0,10101010bисходя из маски селектора, которая для всех выборок равна 10b комманда поместит содержимое XMM02 (одна из 32 битных частей регистра XMM0) по всем XMM0X частям. Т.н. бродкаст.
shufps xmm0,xmm0,00011011выполнит перестановку всех четырех 32 битных частей регистра XMM0 в обратном порядке.
А теперь последовательно исследуем текст скалярного произведения векторов в SSE варианте:
[tr=code]На заметку:
В SSE варианте используется 4 компонентное скалярное произведение. Это не значит, что при попытке заменить его на 3 компонентное производительность бы возросла. Скорее наоборот при попытке отсеять четвертую компоненту как результат результат мы получили дополнительные потери в производительности.
[trtd]movaps xmm0,xmmword ptr [ecx]
movaps xmm1,xmmword ptr [edx]
В функцию скалярного произведения векторов передаются два параметра - адреса наших векторов. Каждый вектор представлен 4 float значениями. Поскольку мы используем соглашения __fastcall, то оба вектора передаются исключительно через регистры ecx и edx. Значения лежащие по адресам, которые находятся в этих регистрах мы загружаем соответственно в регистры xmm0 и xmm1. Обращаю внимание на то, что мы используем выровненные данные - обрати внимание на определения v1 и v2 в test.cpp. Именно поэтому для загрузки используется инструкция movaps а не movups.
mulps xmm0,xmm1
Последовательно перемножим четыре пары float значений и результат поместим в xmm0.
movhlps xmm1,xmm0
Поместим xmm03 в xmm11, а xmm02 в xmm10 фактически перебросив два старших float числа из xmm0 в два младших в xmm1
addps xmm0,xmm1
Сложим уже две пары, которые находятся в двух младших частях xmm регистров. Старшие части нас уже не интересуют.
movaps xmm1,xmm0
Поместим содержимое xmm0 регистра с двумя суммами в xmm1 регистр.
shufps xmm0,xmm0,00000001b
Выполним "смешивание" только по xmm0 регистру. В первых двух битах у нас стоит индекс 01. Таким образом в самой младшей float части xmm0 (это xmm00) будет находится число, которое до этого было расположено в xmm01.
addps xmm0,xmm1
Сложим xmm0 и xmm1. Как ты уже понял, нас интересует только сложение xmm00 c xmm10
movss dword ptr [ tr],xmm0
Поместим младшее float значение из регистра xmm0 (xmm00) в определенное место в памяти. tr не обязательно должно быть выровнено, но в данном случае я просто взял выравнивание данных за правило.
fld dword ptr [ tr]
Сымитируем оператор return, поместив переменную типа float на вершину FPU стека.
ret
Выход без корректировки стека.
[tr=code]На заметку:
Пользователей VC 6.0 с установленным SP5 и процессор паком ожидает неприятный сюрприз. При компиляции примера без ключика /O2 программа "вылетает" на вызове SSE варианта скалярного произведения векторов. Ошибка связанна с неверной генерацией кода компилятором.
Генерируемый код с ключем /O2:
movaps xmm0, oword ptr [ecx]
movaps xmm1, oword ptr [edx]
mulps xmm0, xmm1
movhlps xmm1, xmm0
addps xmm0, xmm1
movaps xmm1, xmm0
shufps xmm0, xmm0, 1
addps xmm0, xmm1
movss dword_409800, xmm0
fld dword_409800
retn
Генерируемый код без ключа оптимизации:
mov [ebp-8], edx ; ????
mov [ebp-4], ecx ; ????
movaps xmm0, oword ptr [ecx]
movaps xmm1, oword ptr [edx]
mulps xmm0, xmm1
movhlps xmm1, xmm0
addps xmm0, xmm1
movaps xmm1, xmm0
shufps xmm0, xmm0, 1
addps xmm0, xmm1
movss dword_40A800, xmm0
fld dword_40A800
retn
Откуда компилятор взял первые две инструкции остается загадкой. При компиляции в MS VC 7 подобной ошибки нет.
[trtd]Уфф! Но ничего сложного ведь не было? Замечательно - это была наша первая совместно написанная процедура под SSE. Попробуем усложнить задачу? Конечно - следующее на очереди векторное произведение векторов:
mov eax,[esp+4]
В векторное произведение у нас передается уже 3 параметра последний из которых передан через стек. Здесь мы помещаем адрес последнего параметра в регистр eax. Просто по адресу esp сейчас находится адрес возврата из процедуры.
movaps xmm0,qword ptr [ecx]
Первый параметр в xmm0 регистр.
movaps xmm2,xmm0
Продублируем xmm0, положив его и в xmm2
movaps xmm1,qword ptr [edx]
Второй параметр в xmm1 регистр.
movaps xmm3,xmm1
Продублируем xmm1 в xmm3
shufps xmm0,xmm0,11001001b
Выполним перестановку данных в xmm0 регистре следующим образом:
xmm00 = по маске селектору 01 = xmm01
xmm01 = по маске селектору 10 = xmm02
xmm02 = по маске селектору 00 = xmm00
xmm03 = по маске селектору 11 = xmm03
shufps xmm1,xmm1,11010010b
Выполним перестановку данных в xmm1 регистре следующим образом:
xmm10 = по маске селектору 10 = xmm12
xmm11 = по маске селектору 00 = xmm10
xmm12 = по маске селектору 01 = xmm11
xmm13 = по маске селектору 11 = xmm13
Что же мы получили после этих двух вызовов ? Содержимое регистров до "смешивания" было следующим:
XMM0 = [ v1.w v1.z v1.y v1.x] или же [ v1[3] v1[2] v1[1] v1[0] ]
XMM1 = [ v2.w v2.z v2.y v2.x] или же [ v2[3] v2[2] v2[1] v2[0] ]
После серии из двух "смешиваний" мы имем:
XMM0 = [ v1.w v1.x v1.z v1.y] или же [ v1[3] v1[0] v1[2] v1[1] ]
XMM1 = [ v2.w v2.y v2.x v2.z] или же [ v2[3] v2[1] v2[0] v2[2] ]
Для чего это было нужно ? А для того чтобы построить данные для последуюшего умножения по формулам:
r[0] = v1[1]*v2[2] - v2[1]*v1[2];
r[1] = v1[2]*v2[0] - v2[2]*v1[0];
r[2] = v1[0]*v2[1] - v2[0]*v1[1];
Обрати внимание, как мы скомбинировали данные для умножений:
v1[1] с v2[2] из первой части первой формулы
v1[2] с v2[0] из первой части второй формулы
v1[0] с v2[1] из первой части третьей формулы
shufps xmm2,xmm2,11010010b
shufps xmm3,xmm3,11001001b
Следующие инструкции несут полностью аналогичную функцию, и я оставляю возможность читателю самому проиллюстрировать для себя процесс исполнения shufps.
mulps xmm0,xmm1
После этого умножения в регистре xmm0 как ты уже догадался, будет следующее содержимое:
XMM0 = [ (не важно) (v1[0] с v2[1]) (v1[2] с v2[0]) (v1[1] с v2[2]) ]
mulps xmm2,xmm3
Комментарий этого умножения я думаю излишен.
subps xmm0,xmm2
Выполнить итоговое вычитание по уже упомянутой формуле:
r[0] = v1[1]*v2[2] - v2[1]*v1[2];
r[1] = v1[2]*v2[0] - v2[2]*v1[0];
r[2] = v1[0]*v2[1] - v2[0]*v1[1];
Теперь в XMM0 у нас результирующий вектор r
movaps qword ptr [eax],xmm0
Поместим результат в область памяти по адресу третьего параметра. Обрати внимание на использование movaps инструкции оперирующей с выравненными данными.
ret 4
Исправляем стек.
Неправда ли достаточно элегантно ?
[tr=code]На заметку:
Ненадолго вернемся к скалярному произведению векторов. Когда я только только начал заниматься примером, вместо movhlps xmm1,xmm0 в скалярном произведении мною была допущена ошибка: я написал movhps xmm1,xmm0. VC 6 как ни странно "проглатывает" исходный текст и генерирует следующий код для этой инструкции:
movlhps xmm1, xmm0
VC 7 при генерации кода в этой ситуации дает желаемое сообщение об ошибке: improper operand type...
[trtd]Полное исследование умножения матриц я оставлю в качестве очередного задания самому читателю. Мы обсудим лишь основные моменты. Как ты заметил, умножение матриц в примере реализовано 4 вариантами, 3 из которых это те или иные варианты использования SSE расширения.
Главное достоинство интринсик варианта - отсутствие прямого использования ассемблерного кода. По итогам сравнения интринсик вариант находится где то между SSE вариантом и классическим Си. Читатель может конечно "развернуть" цикл в mmm_sse_int(), но думаю на уровень ассемблерного SSE варианта интринсик вариант это не выведет.
Особое внимание стоит уделить функции mmm_sse_bad(). Типичный пример неправильного построения кода для SSE и как следствие провал в производительности. Так что же в ней такого плохого? Рассмотрим на Си код функции умножения матриц:
r[0] = m1[0]*m2[0] + m1[1]*m2[4] + m1[2]*m2[8] + m1[3]*m2[12];
r[1] = m1[0]*m2[1] + m1[1]*m2[5] + m1[2]*m2[9] + m1[3]*m2[13];
r[2] = m1[0]*m2[2] + m1[1]*m2[6] + m1[2]*m2[10] + m1[3]*m2[14];
r[3] = m1[0]*m2[3] + m1[1]*m2[7] + m1[2]*m2[11] + m1[3]*m2[15];
r[4] = m1[4]*m2[0] + m1[5]*m2[4] + m1[6]*m2[8] + m1[7]*m2[12];
r[5] = m1[4]*m2[1] + m1[5]*m2[5] + m1[6]*m2[9] + m1[7]*m2[13];
r[6] = m1[4]*m2[2] + m1[5]*m2[6] + m1[6]*m2[10] + m1[7]*m2[14];
r[7] = m1[4]*m2[3] + m1[5]*m2[7] + m1[6]*m2[11] + m1[7]*m2[15];
r[8] = m1[8]*m2[0] + m1[9]*m2[4] + m1[10]*m2[8] + m1[11]*m2[12];
r[9] = m1[8]*m2[1] + m1[9]*m2[5] + m1[10]*m2[9] + m1[11]*m2[13];
r[10] = m1[8]*m2[2] + m1[9]*m2[6] + m1[10]*m2[10] + m1[11]*m2[14];
r[11] = m1[8]*m2[3] + m1[9]*m2[7] + m1[10]*m2[11] + m1[11]*m2[15];
r[12] = m1[12]*m2[0] + m1[13]*m2[4] + m1[14]*m2[8] + m1[15]*m2[12];
r[13] = m1[12]*m2[1] + m1[13]*m2[5] + m1[14]*m2[9] + m1[15]*m2[13];
r[14] = m1[12]*m2[2] + m1[13]*m2[6] + m1[14]*m2[10] + m1[15]*m2[14];
r[15] = m1[12]*m2[3] + m1[13]*m2[7] + m1[14]*m2[11] + m1[15]*m2[15];
Рассмотрим, как умножение и сложение организованно в "плохом" SSE варианте:
movaps xmm0,qword ptr [ecx]
Поместить в xmm0 содержимое в 128 бит расположенное по адресу ecx - наш первый аргумент. В xmm0 окажутся значения m1[3], m1[2], m1[1], m1[0].
movaps xmm1,qword ptr [ecx+16]
Зададим смещение в 4 элемента массива и именно 4*sizeof(float) = 16. По аналогии с первой загрузкой в xmm1 будут помещены элементы m1[7], m1[6], m1[5], m1[4].
movaps xmm2,qword ptr [ecx+32]
movaps xmm3,qword ptr [ecx+48]
Продолжаем в том же духе...
movaps xmm4,qword ptr [edx]
В xmm4 - m2[3], m2[2], m2[1], m2[0]
movaps xmm5,qword ptr [edx+16]
В xmm5 - m2[7], m2[6], m2[5], m2[4] и т.д.
movaps xmm6,qword ptr [edx+32]
movaps xmm7,qword ptr [edx+48]
movlhps xmm4,xmm5
shufps xmm4,xmm4,00001000b
movlhps xmm6,xmm7
shufps xmm6,xmm6,00001000b
movlhps xmm4,xmm6
Для первых четырех результатов в регистре XMM4, как видно из вышеуказанного текста формируется строчка m2[0], m2[4], m2[8], m2[5], которая затем используется в умножении. Для комбинирования данных активно используется movlhps - перенос двух младших 32 битных частей регистра приемника в две старшие части регистра получателя.
В итоге сначала находится r[0] r[4] r[8] r[12], затем r[1] r[5] r[9] r[14] и т.д. по тому же принципу. В случае "правильного" SSE умножения расчет выполняет последовательно r[0] r[1] r[2] r[3] и т.д. Как ты уже наверное обратил внимание результат "плохого" примера записывается покомпонентно, в то время как результат "правильного" умножения записывает сразу четыре компоненты результата.
[tr=code]На заметку:
У SSE раширения есть еще одно достоинство - программный контроль над записью в кеши процессора реализуемый инструкциями группы prefetch*. Достоинство ли это ? Смотря в какой ситуации. При написании примера я пытался использовать префетчинг в умножении матриц. Результатом было падение производительности. Немалое падение. Это вызвало недоумение и заставило обратиться с вопросом на форуме поддержки intel. Вот ответы, которые я получил:
If you are running on a P4, prefetch instructions are said to contain 6 UOPS (micro-ops) and come out of MSROM. They can easily cut the performance of matrix multiplication by half or more, as P4 also has hardware prefetch. Hardware prefetch is triggered by a sequence of cache misses, and only for sequential access, so you may have gone through several cache lines before it kicks in fully. Among the differences between the /G6 and /G7 options on the Intel compilers is that /G7 prevents compiler generation of prefetch instructions. Even on a P-III, with matrix multiplication, prefetch instructions sometimes slow things down, but on a P4 it's much more so.
It's difficult to guess when software prefetch might be useful on P4, but the compiler's guess is that it won't. Anyway, it's not likely to be useful for sequential access, where there is hardware prefetch. As I tried to hint, it's possible that you get low performance for several cache lines, before hardware prefetch kicks in, and, when you are waiting for cache fills, it doesn't make a noticeable difference whether you use parallel instructions. You may take 2 cache misses before hardware prefetch is triggered; then, if hardware prefetch works 2 cache lines ahead, you will surely have one more cache miss stall. This would be related to the tactic of doing all possible adjustments in preparation for unrolled loops ahead of the loop. You try to do the slow operations where you expect more cache misses, and save the code with more parallel operations to run after the cache starts to fill. If you were experimenting with prefetch, you might try just a prefetch ahead of the loop, hoping to get it started quicker.
timothy.c.prince(at)intel.com
Для особо ленивых, а также тех, у кого проблемы с переводом вкратце передам отдельные мысли Тимоти: Использование программного префетча достаточно дорогая команда - 6 микроопераций и легко может снизить производительность вполовину или более на Pentium 4 т.к. на вооружении Pentium 4 есть и аппаратный префетч. Он активируется в случае поеследовательных промахов в кэш и только для последовательного доступа. Даже на Pentium 3 при умножении матриц программный префетчинг может замедлять вычисления, но на Pentium 4 это более заметно.
[trtd]То, что осталось за бортом...
Как обычно - практически всё :) Это и собственно оптимизация самих SSE программ, работа с интринсиками и выделение выровненных областей памяти, более подробный разговор о контроле кэширования, обсуждение Intel C и многое другое...
"- Документация? -Да, конечно..."
Выводы.
Что напрашивается как вывод? Использование SSE для разовых (вспомним наши 128 итераций для замеров производительности и реального выигрыша) вычислений при написании в дополнении к си процедурам их аналогов вряд ли оправданно в соотношении выигрыш/затраченное время разработки. Хотя если программисту просто необходимо выжать все возможное из приложения, это может стать той необходимой крупицей, порой не малой. SSE расширение мощный механизм обработки больших потоков float данных. К примеру SSE будет полезно разработчикам видео драйверов. Кроме того, при разработке целых ветвей кода (code paths) оптимизированных под SSE можно будет также достичь солидного прироста в конечном приложении. При использовании SSE расширения не следует забывать о правильном построении алгоритма выборки и работы с данными, как и о правильном представлении самих данных.
Жду ваших отзывов и коррекцию.
Благодарности:
Лане за то, что ты есть...
voxatu и wat'у - настоящим человекам-пароходам за то, что они терпят мое молчание :)
Роману Газину за живой интерес к статье и написание интринсик варианта умножения матриц.
Klear Games за отличный саундтрек в игре Reiner Knizia's Samurai v 1.1 который был неоднократно прослушан в процессе написания статьи.
Тебе читатель что читал весь этот поток порой бессвязных мыслей :)
Небольшая благодарность самому себе за то что наконец выкроил время и закончил уже очень давно начатую статью :)
Исходный код к статье: [gurl=http://www.gamedev.ru/articles/engine/20030825.zip]20030825.zip[/url]
26 августа 2003
Комментарии [9]