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

Практическое применение 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 нельзя выравнивать классы и отдельные члены структур.
Страницы: 1 2 3 4 5 Следующая »

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

26 августа 2003

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