Практическое применение SSE расширения. (4 стр)
Автор: Антон В. Звягинцев
Оптимизация вызова функций
Вот мы подошли, собственно, к коду примера. Предварительно я хочу обратить внимание читателя на некоторые интересные моменты, связанные со структурой функций примера. Рассмотрим текст функции dp_fpu в math.h:
__declspec(naked) float __fastcall dp_fpu(float *v1,float *v2)
{
//...
}
Прежде всего, стоит обратить внимание на идентификатор __declspec(naked). Данная директива отключает компилятору генерацию пролога и эпилога для функции, которые выглядят следующим образом:
Типичный пролог программы:
push ebp
mov ebp, esp
Текст программы:
...
Типичный эпилог программы:
mov esp, ebp
pop ebp
Внимание! В случае использования ключа /O2 (оптимизация по скорости) для MS VC (по крайней мере версии 6.0), компилятор, когда это возможно, САМ пытается представить функции как naked! Отключение пролога и эпилога несет свои плюсы и минусы. В качестве плюсов можно отметить то, что мы избавляемся от дополнительно сгенерированных инструкций, тем самым повышая скорость исполнения программы. В качестве минусов - мы не сможем использовать переменные внутри функции (стековые переменные которые объявляются внутри стека текущей программы) и оператор return. В первом случае это связанно с тем, что в прологе так же выполняется настройка стека. Что касается оператора return, то эта проблема легко исправима. Дело в том, что под возвратом значения оператором return по общепринятым соглашениям подразумевается помещение результата в регистр EAX или его младшие части (AX,AL). Именно так передаются такие типы данных как char, short, int, их беззнаковые аналоги и указатели на эти типы данных и void. При возврате float или double значений используется вершина стека регистров сопроцессора. Для лучшего понимания рассмотрим простые функции и их ассемблерные листинги:
int func(void) { return 1; }
будет превращена Visual C++ в:
push ebp ; пролог
mov ebp, esp
mov eax, 1 ; return 1
pop ebp ; эпилог
retn
а функция:
float func(void) { return 1.0; }
будет превращена Visual C++ в:
push ebp ; пролог
mov ebp, esp
fld ds:4060A0 ; поместить значение по адресу 4060A0 (туда компилятор положил 1.0)
; на вершину стека регистров сопроцессора
pop ebp ; эпилог
retn
Что такое __fastcall? Это соглашения по вызову функции и генерации кода для вызова. Рассмотрим сравнительный обзор соглашений:
__cdecl - стандартный вызов в Си. Параметры передаются через стек, справа налево, то есть первый параметр ляжет в стек последним. Вызывающая функция (та, из которой был инициирован вызов) "правит" стек. Вот пример такого вызова:
SomeFunc(0x01,0x02);
Код, сгенерированный компилятором:
push 0x02 ; Параметры через стек
push 0x01
call Some_func_address ; Вызов функции
add esp,08 ; Исправим стек
__stdcall - стандартный вызов Win32 функций - большая часть функций ядра windows используют именно его. Параметры передаются через стек справа налево. Вызываемая функция правит стек.
__pascal - стандартный вызов в языке Pascal. Параметры передаются через стек слева направо. Вызываемая процедура правит стек.
__thiscall - стандартный вызов языка C++. Параметры передаются через стек справа налево. В регистре ECX передается указатель this. Вызывающая функция правит стек.
__fastcall - вызов используемый Borland C Builder и Delphi. Первый и второй параметры передаются в паре регистров ECX и EDX, остальные - через стек справа налево. Вызываемая функция правит стек
Как уже видно из описания, наиболее быстрым в данном случае является вызов __fastcall т.к. первые два параметра передаются через соответствующие регистры, а не через стек.
[tr=code]На заметку:
В MS VC можно установить __fastcall как вызов по умолчанию. Для этого существует ключ /Gr. Естественно что ключ по умолчанию будет справедлив только для функций написанных вами и не сможет изменить соглашения о вызове, к примеру, функций Cи рантайма или ядра.
[trtd]А где же forceinline?
Как мы уже говорили, директивы пометки функции как inline выполняет следующее - вместо вызова самой функции, компилятор подставляет ее код. Тем самым экономится некоторое количество времени, затрачиваемое до этого на организацию вызова функции. Однако при построении модульного проекта, о котором сейчас буду говорить я, это не подойдет и вот почему. Поскольку даже при наличии поддержки определенного расширения компилятору нельзя отметить в пределах одного файла с исходными текстами программы, какие участки кода оптимизировать для расширения, а какие нет, возникает необходимость разносить функции различной реализации математики в различные библиотеки или объектные модули.
[tr=code]На заметку:
Разработчик конечно может пойти и по другому пути, который к примеру использовался в игре ZAR от maddox games - существовало две версии exe модуля игры - MMX и не-MMX версия.
[trtd]В целом, существует два основных варианта использования различных реализаций, и оба при одинаковой оптимизации имеют одинаковую производительность. В случае первого вся математика находится в пределах одного слинкованного исполняемого модуля. В приложении существует объект математики (объект или структура в применении к Си++ и Си соответственно), который содержит в себе набор указателей на базовые функции. Приложение детектирует тип процессора и инициализирует эти указатели функциями, которые наиболее производительны для данного типа процессора. Вот типичный пример реализации:
Частичный листинг файла r_math.h
#ifndef R_MATH_H #define R_MATH_H #include<windows.h> #include<stdio.h> #include<string.h> #include<math.h> //... typedef void (__cdecl *PFN_MATRIXMULVEC)( float*,float*); //... void MATH_PURE_MatMulVec( float *,float *); void MATH_SSE_MatMulVec( float *,float *); typedef struct { PFN_MATRIXMULVEC pfnMatrixMulVec; ... } MATH,*PMATH; #endif R_MATH_H
Частичный листинг файла engine.cpp:
//... MATH g_CM; void SelectAppropriateMath(LPSTR pszCmdLine) { ... g_CM.pfnMatMulMat = MATH_PURE_MatMulMat; if ( IsInString( pszCmdLine,"-puremath")) { DEBUG_Printf( "...принудительное отключение оптимизации функций\r\n"); return; } // Если есть поддержка SSE и она не отключена в коммандной строке if ( IsSSE && !IsInString( pszCmdLine,"-nosse")) { DEBUG_Printf( "...оптимизация с использованием SEE инструкций\r\n"); g_CM.pfnMatrixMulVec = MATH_SSE_MatMulVec; return; } }
Из исходного текста ясно виден процесс настройки математики в приложении - исходя из ранее выполненного детектирования процессора, приложение назначает соответствующие адреса указателям функций, которые затем можно вызвать следующим образом:
g_CM.pfnMatrixMulVec(&v,&mat);
Вторым способом является вынесение различных реализаций математики в разные DLL модули как это сделано в Colin McRae Rally 3. DLL модули в Windows мапируется в адресное пространство текущего приложения. В итоге нет никаких пенальти в вызове функции из DLL модуля или внутри приложения. Вариациями этого способа являются линковка DLL модуля на этапе загрузки (load time linking - вызовы в DLL записываются в секцию импорта исполняемого модуля, по которым операционная система на этапе загрузки настраивает адреса для приложения) или линковка на этапе исполнения (run time linking - в приложении используются системные вызовы LoadLibrary(Ex)/FreeLibrary() для загрузки DLL/освобождения вручную и вызов GetProcAddress() для получения адреса необходимой функции из DLL). Первый вариант более прост в реализации, так как операционная система сама при загрузке выполняет все функции по настройке адресов для приложения. Но в память процесса загружаются все DLL модули и соответственно все функции из каждого из модулей прилинкованные к приложению независимо от их дальнейшего использования или не использования. Второй способ более экономичен, поскольку позволяет выбрать для загрузки только необходимый модуль, но возлагает все процедуры настройки адресов на само приложение.
[tr=code]На заметку:
Обычно размеры DLL модулей не так велики. Стоит ли отказываться от удобства линковки приложения самой ОС ради экономии 100-200 Кб памяти - решать только программисту.
Совсем недавно на irc канале [gurl=http://www.gamedev.ru/chat/]#gamedev[/url] у меня была дискуссия с человеком, который утверждал, что run-time линковка не столь производительна как настройка адресов ОС при load time линковке. Это не так. И в первом и во втором случае для вызова функции будет использован косвенный вызов процедуры. Только в случае run time линковки указатель на функцию декларирует и настраивает сам программист, а в случае load time линкования указатели уже существуют в секции импорта приложения и настраиваются они загрузчиком исполняемых модулей ОС. Генерируемый код зависит от типа компилятора (Borland C++ имел свойство генерировать так называемые переходники для load time вызовов), но в случае MS VC будет одинаков, соответственно затраты на вызов будут идентичны. Занавес.
MS VC предлагает и еще один вариант загрузки функций из DLL находящийся по сути между "ручной" run time линковкой и "автоматической" load time. Соответствующая DLL на этапе компиляции помечается как "загружаемая по требованию" (загрузка с задержкой - delay loaded). Это делается заданием при линковки ключа /delayload<имя dll модуля>. Кроме того, в проект нужно включить Delayimp.lib. При этом компилятор сам создаст код для загрузки модулей в течении исполнения программы и фанки (thunks) - переходники указатели для используемых функций, которые будут настроены при первом вызове какой либо функции из dll модуля и будут использованы для работы в дальнейшем.
[trtd]Архитектура SSE
Перейдем к рассмотрения самих листингов функций. Рассматривая примеры реализации гораздо проще понять и научиться программировать самому, не так ли ? Однако это не освобождает читателя от "ответственности" прочтения технической документации. Начнем с типов данных и регистров. Как уже упоминалось ранее, программисту стали доступны 8-мь 128-битных регистров - от XMM0 до XMM7. Каждый из них принимает в себя до 4 float (IEEE стандартных) чисел:
XMM регистр (128 бит) : [ float (32 бита) ][ float (32 бита) ][ float (32 бита) ][ float (32 бита) ]
В отладчиках часто XMM регистр разбивают на 4 части:
XMMx = (XMMx3 XMMx2 XMMx1 XMMx0) где x - соответствующий номер регистра.
[tr=code]На заметку:
Если в SSE мы можем оперировать с 4 парами чисел с плавающей точкой одинарной точности, то SSE2 позволяет оперировать с 2 парами чисел двойной точности (double) используя те же самые регистры, но разбитые уже на две High и Low части.
[trtd]В качестве нового типа данных компилятор поддерживает __m128. На самом деле это уже выровненная (о выравнивании и его жизненно важной функции ниже) структура из 4 float чисел. Документация настоятельно не рекомендует адресовать какое либо из 4 float чисел по отдельности. В примере же, я использовал обычный выровненный массив из 4 float чисел.
За одну операцию SSE позволяет выполнять эту операцию над 4 парами чисел расположенные в одинаковых позициях XMM регистров Например инструкция:
addps xmm0,xmm1
выполнит следующие действия:
xmm0 = ( [xmm03+xmm13] [xmm02+xmm12] [xmm01+xmm11] [xmm00+xmm10] )
Мы не будем рассматривать все инструкции в этой статье поскольку на это не хватит ни моих, ни сил читателя, но я полагаю что они не вызовут особых проблем у тех, кто уже имел опыт программирования на ассемблере. В качестве справочника вполне подойдет документация с http://developer.intel.com.
Выравнивание данных - ключ к производительности!
Если ты используешь SSE расширение, ты должен использовать выровненные данные. Под выравниванием подразумевается выравнивание адреса расположения переменных в памяти по определенной границе. Это правило, которому нужно следовать. Да - мы можем использовать невыровненные данные, но при этом производительность приложения падает просто катастрофически. Как же мне выравнивать данные спросит пытливый читатель? Неужели нужно писать собственные процедуры выделения памяти с выравниванием ? К счастью - нет. В MS VC существует директива
_declspec( align(X) )поставив которую, перед идентификатором типа переменной, мы дадим компилятору задание выровнить адрес переменной по границе X байт. Допустимые значения для этой директивы - целые числа степени двойки - вплоть от 1 и до 8192. Для SSE расширения необходимо выравнивание данных по границе 16 байт. Загрузка выровненных и невыровненных данных в регистры осуществляется различными ассемблерными инструкциями. Если использовать инструкцию, которая оперирует с выровненными для невыровненных данных, это вызовет ошибку и программист будет лицезреть уже почти родное для него "Программа выполнила недопустимую операцию...". Не следует забывать, что директивой align нельзя выравнивать классы и отдельные члены структур.
26 августа 2003
Комментарии [9]