ПрограммированиеСтатьиОбщее

Практическое применение 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]

Страницы: 1 2 3 4 5

#ASM, #SSE, #оптимизация

26 августа 2003

Комментарии [9]