Последний раз про математику или ещё раз про abstraction penalty

Мы дружим с дизассемблером не только и не столько потому, что мы не любим компилятор. Работа компилятора это работа сантехника – продираться через clutter. Clutter – это не только синтаксический сахар, но и неявно выраженные невнятные неочевидные идеи; пишем Партия подразумеваем Ленин.

Один из наиболее очевидных и показательных тестов это madd.

__declspec(noinline) Vec3Res MulAdd ( Vec3Arg a, Vec3Arg b, Vec3Arg c )
{
return a * b + c;
}

В наивной реализации всё очевидно

struct Vec3
{
float x, y, z;
__forceinline Vec3 ( ) {};
__forceinline Vec3 ( float X, float Y, float Z ) : x(X), y(Y), z(Z) {};
__forceinline Vec3 operator * ( const Vec3 & t ) const { return Vec3(x*t.x,y*t.y,z*t.z); };
__forceinline Vec3 operator + ( const Vec3 & t ) const { return Vec3(x+t.x,y+t.y,z+t.z); };
};
typedef const Vec3 & Vec3Arg;
typedef Vec3 Vec3Res;


глядим в дизассемблер (VS2005). Если отбросить clutter, то

$T5907 = -12 ; size = 12
; ___$ReturnUdt$ = eax
; _a$ = esi
; _b$ = edx
; _c$ = ecx

sub esp, 12 ; 0000000cH
fld DWORD PTR [esi]
fmul DWORD PTR [edx]
fstp DWORD PTR $T5907[esp+12]
fld DWORD PTR [esi+4]
fmul DWORD PTR [edx+4]
fstp DWORD PTR $T5907[esp+16]
fld DWORD PTR [esi+8]
fmul DWORD PTR [edx+8]
fstp DWORD PTR $T5907[esp+20]
fld DWORD PTR [ecx]
fadd DWORD PTR $T5907[esp+12]
fstp DWORD PTR [eax]
fld DWORD PTR [ecx+4]
fadd DWORD PTR $T5907[esp+16]
fstp DWORD PTR [eax+4]
fld DWORD PTR [ecx+8]
fadd DWORD PTR $T5907[esp+20]
fstp DWORD PTR [eax+8]
add esp, 12 ; 0000000cH
ret 0

здесь всё тоже привычно, и совсем не быстро. Параметры передаются и возвращаются на стеку. Используется FPU.

Intel представил SSE в 1999 году; сегодня 2007. Компилятор от Intel, очевидно, умеет делать какую-то векторизацию, но о его граблях мы поговорим в другой раз.

Попробуем несколько менее (!!!) наивный код

struct Vec3
{
union
{
__m128 vMM;
struct { float x, y, z; };
};
__forceinline Vec3 ( ) {};
__forceinline Vec3 ( float X, float Y, float Z ) : x(X), y(Y), z(Z) {};
__forceinline Vec3 ( __m128 t ) : vMM(t) {};
__forceinline Vec3 operator * ( const Vec3 & t ) const { return Vec3(_mm_mul_ps(vMM,t.vMM)); };
__forceinline Vec3 operator + ( const Vec3 & t ) const { return Vec3(_mm_add_ps(vMM,t.vMM)); };
};
typedef const Vec3 & Vec3Arg;
typedef Vec3 Vec3Res;

Такой код я видел много раз в разных конторах, в той или иной его реинкарнации. Дизассемблер показывает не менее очевидные вещи

___$ReturnUdt$ = 8 ; size = 4
_c$ = 12 ; size = 4
; _a$ = ecx
; _b$ = edx

push ebp
mov ebp, esp
and esp, -16 ; fffffff0H
movaps xmm0, XMMWORD PTR [ecx]
movaps xmm1, XMMWORD PTR [edx]
mov ecx, DWORD PTR _c$[ebp]
mov eax, DWORD PTR ___$ReturnUdt$[ebp]
mulps xmm0, xmm1
movaps xmm1, XMMWORD PTR [ecx]
addps xmm0, xmm1
movaps XMMWORD PTR [eax], xmm0
mov esp, ebp
pop ebp
ret 0

Обычно на этом всё и заканчивается. Оптимизировать особенно нечего. В более сложных функциях компилятор частенько осиливает не складывать промежуточные результаты на стек. Пролог и эпилог не особенно отличаются от аналогичных пролога и эпилога в других функциях. Можно передавать указатель на результат.

Замечу, что clutter здесь не менее очевидный. Результаты разумеется надо передавать в регистрах. Параметры разумеется надо передавать в регистрах. Пролог и эпилог просто не нужны.

Победить такой clutter значительно тяжелее. Замечу, что чем ближе к простому результату, тем тяжелее компилятору справляться – эта тенденация сохраняется в любых clutter-related задачах. Такая тенденция настолько яркая, что в отдельных местах до сих пор предпочитают ассемблер – потому как «простой результат» на нём получить значительно легче.

Ну и, наконец, про простой результат

typedef __m128 Vec3;
typedef __m128 Vec3Arg;
typedef __m128 Vec3Res;
__forceinline Vec3Res operator + ( Vec3Arg a, Vec3Arg b ) { return _mm_add_ps(a,b); };
__forceinline Vec3Res operator * ( Vec3Arg a, Vec3Arg b ) { return _mm_mul_ps(a,b); };

который представляет из себя значительно меньше букв, и требует реализации в явном виде. Дизассемблер не отстаёт

mulps xmm0, xmm1
addps xmm0, xmm2
ret 0

как и следовало ожидать.

Проблемы синтаксического сахара в разумной степени тоже можно решить. Начиная от макросов для векторного и скалярного произведений


#define Cross %

реализации Set и Get макросов, для кода вида


SetX(v0) = GetY(v1) + 0.5f

или макросов VEC_ для кода вида


V0.VEC_X = V1.VEC_Y + 0.5f

и заканчивая модификацией xmmintrin.h, после которой в структуре __m128 появляются поля x, y, z, w.

А у вас скалярное произведение всё ещё возвращает float?

  • http://www.codygain.com neteraser

    это вопрос к разработчикам SDK для платформ! :)

    юзать что-либо кроме страшного SDK в последнее время вижу все меньше и меньше смысла. зачем подменять такое же на тоже самое, если формат заранее не тот?

    формат зависит от платформы, конечно…

    пусть на платформе есть крутой, плоский vfpu. он умеет и vdot, и vcross и кучу регистров – все, что надо для сказочного развлечения. зачем на такой платформе мат-либа?

    пусть на платформе нет fpu совсем. зачем на такой мат-либа?

    пусть на платформе есть SPU …
    “отвечает Петр Попов!” (С) shodan

    :)

    P. S.
    я не знаю, может и нужна, конечно…

  • CEMEH

    А придумал как compile-time корректность проверять? Типа там, float3 vs float4?

  • look4awhile

    там разницы крайне мало – между float3 и float4
    т-е можно промежуточный тип – там где надо явно форсировать – и явно в него кастить – со словом explicit
    но оно обычно не нужно совсем
    а нужно dot3 и dot4

  • CEMEH

    float3 и float4 – ладно. Я скорее за float3 и float1. И функции приведения туда-сюда.

  • look4awhile

    а в чём проблема float1?
    ну т-е дописать __m128 operator * ( float t ) можно всегда
    и наоборот
    сделать два разных (по результату) dot-а
    итд итп

  • CEMEH

    Надо различать mul от вектора на mul от отдельного флоата. Или уметь отдельный флоат превращать в вектор.
    Ну тупо, случился у тебя dot. Ты записал его только в первую компоненту регистра.
    Теперь надо вектор на него умножить, надо не забыть сделать шаффл, который разможит первую компоненту по остальным.
    Очень хочется такое как-то ловить в compile-time.

  • look4awhile

    либо делать лишний shuffle всегда
    я пока не придумал как лечить лишний shuffle
    но результаты замера показывают, что лучше лишний shuffle чем float в результате dot-а – в среднем

  • CEMEH

    У меня это было причиной, по которой я писал таки враппер-классы. И очень-очень следил, чтобы они инлайнились. Разницу между этим и “шаффлом всегда” видел глазами.
    Но я пускал не на сборной солянке, а на отдельном маленьком куске, где контролировать было проще.

  • look4awhile

    а как ты инлайнил передачу параметров? чтобы через регистр

  • CEMEH

    Следил, чтобы всегда функция инлайнилась, как еще….

  • look4awhile

    ну за юзверьскими не уследишь
    вообщем-то думаю можно 3 разных dot ф-ции
    обычную. ту какая вертает флоат. и ту, какая не шафлит
    последнюю называть unsafe
    там где глядеть оптимизации – их всё едино глядеть

    впрочем не уверен что вижу проблему
    потому как если надо dot в SSE – то обычно и shuffle нужен

  • YE

    What is the reasoning behind having separate typedefs for float4 arguments and float4 results?

  • Pingback: The Daily DIP Count » Blog Archive » The Four Screens()

  • look4awhile

    For naive implementation float4 argument would be const & float4, where float4 result would be just float4.
    For __m128 implementation folat4 argument would also be __m128, which makes it possible to pass up to 3 arguments in the registers on PC.
    On a platforms with more sane ABI this makes huge difference.
    It also gives a lot of flexibility in being able to swap implementations with just compilation settings. const & is just not always the best option.