Pisanie od zera w asm-ie (na dodatek w inline assembly ze składnią AT&T) jest delikatnie mówiąc hardkorowe. O wiele łatwiej wektoryzuje się
w C z użyciem odpowiednich intrinsics-ów. Dlatego najlepiej wyjść od działającej wersji w C (nawet w przypadku tak prostego kodu jak tutaj)
a dopiero później po obejrzeniu kodu wynikowego przygotować wstawkę w asemblerze. W dalszej części celowo użyję składni Intela bo IMO jest ona czytelniejsza.
- Kod w C z użyciem intrinsics-ów.
#define EQUAL(a, b) (fabs((a) - (b)) < 0.0001f)
struct __attribute__ ((aligned (16))) Vector
{
float x1, x2, x3, x4;
};
static void intrinsics_sum()
{
volatile unsigned iterations = 1;
struct Vector v1 = { 1.0f, 4.0f, 9.0f, 16.0f };
struct Vector v2 = { 2.0f, 8.0f, 18.0f, 32.0f };
struct Vector out = { 0.0f, 0.0f, 0.0f, 0.0f };
__m128 *ptr1 = (__m128 *)&v1;
__m128 *ptr2 = (__m128 *)&v2;
float *outptr = (float *)&out;
for (unsigned i = 0; i < iterations; i++)
{
_mm_store_ps(outptr, _mm_add_ps(*ptr1, *ptr2));
outptr += 4;
ptr1++;
ptr2++;
}
assert( EQUAL(out.x1, 3.0f) && EQUAL(out.x2, 12.0f)
&& EQUAL(out.x3, 27.0f) && EQUAL(out.x4, 48.0f));
}
Uwagi do funkcji intrinsics_sum:
- _mm_add_ps generuje instrukcję addps z SSE która dodaje dwa wektory float-ów i umieszcza w trzecim wektorze
- _mm_store_ps generuje instrukcję movaps z SSE przenoszącą wynik dodawania z rejestru do pamięci
- struktura Vector musi mieć 16B alignment, inaczej nie ma szans na wygenerowanie optymalnego kodu
- zmienna iterations musi być oznaczona jako volatile, w innym wypadku kompilator wykona sumowanie w czasie kompilacji
- porównywanie float-ów jest wykonane przez EQUAL z niewielkim episilonem
- Teraz skompilujemy kod z optymalizacjami oraz wygenerujemy dump-a ze składnią Intela.
gcc main.c -Ofast -o main
objdump --no-show-raw-insn -d -M intel -S main > dump.asm
Listing jest krótki. Można dość łatwo zlokalizować ciało pętli z interesującymi nas instrukcjami SSE.
U mnie wygląda to tak:
mov ecx,DWORD PTR [rsp+0xc] ;
add edx,0x1 ; i++
movaps xmm0,XMMWORD PTR [rsp+rax*1+0x20] ; movaps xmm0, *ptr2
addps xmm0,XMMWORD PTR [rsp+rax*1+0x10] ; addps xmm0, *ptr1
movaps XMMWORD PTR [rsp+rax*1+0x30],xmm0 ; movaps *outptr, xmm0
add rax,0x10 ; ptr1++
cmp ecx,edx
ja 4005c0 <intrinsics_sum+0x80>
- Dopiero teraz możemy zabrać się do właściwego zadania czyli napisania wstawki asemblerowej.
Najważniejsza część to oczywiści sekwencja movaps, addps, movaps.
Niestety nie da się po prostu skopiować powyższego asm-owego listingu i owrappować
w keyword "asm volatile ". Gcc de facto nie rozumie tego co się dzieje wewnątrz wstawki,
więc nie wie jakich rejestrów/adresów używamy, co modyfikujemy etc. Trzeba wyrazić to explicite.
Dodatkowo wstawka musi jakoś komunikować się z kodem w C. Stąd potrzeba użycia extended inline assembly.
static void assemblyx64_sum()
{
volatile unsigned iterations = 1;
struct Vector v1 = { 1.0f, 4.0f, 9.0f, 16.0f };
struct Vector v2 = { 2.0f, 8.0f, 18.0f, 32.0f };
struct Vector out = { 0.0f, 0.0f, 0.0f, 0.0f };
__m128 *ptr1 = (__m128 *)&v1;
__m128 *ptr2 = (__m128 *)&v2;
float *outptr = (float *)&out;
__m128 tmp;
for (unsigned i = 0; i < iterations; i++)
{
__asm__ __volatile__(
".intel_syntax noprefix\n"
"movaps %[tmp], XMMWORD PTR [%[ptr1]+%q[idx]*4]\n"
"addps %[tmp], XMMWORD PTR [%[ptr2]+%q[idx]*4]\n"
"movaps XMMWORD PTR [%[outptr]+%q[idx]*4], %[tmp]\n"
".att_syntax prefix\n"
: [tmp] "=x" (tmp)
: [outptr] "r" (outptr), [ptr1] "r" (ptr1), [ptr2] "r" (ptr2),
[idx] "r" (i)
: "memory"
);
outptr += 4;
ptr1++;
ptr2++;
}
assert( EQUAL(out.x1, 3.0f) && EQUAL(out.x2, 12.0f)
&& EQUAL(out.x3, 27.0f) && EQUAL(out.x4, 48.0f));
}
Uwagi do assemblyx64_sum:
- mamy 3 grupy operandów: wyjściowe (constraint "=x"), wejściowe (constraint "r" - rejestry wejściowe) oraz clobbered (constraint "memory").
- Operandem wyjściowym jest zmienna tmp, która będzie trzymana w rejestrze lub pamięci [tmp]. Informujemy w ten sposób gcc, żeby wziął pod uwagę iż wartości, które będą tam ładowane przed wykonaniem naszej wstawki mogą być zmienione po jej wykonaniu.
- Operandami wejściowymi są outptr, ptr1, ptr2 oraz i.
- "memory" oznacza, że w inline assembly robimy write-y i read-y na pamięci. Zatem oprócz zmiany operandów wyjściowych wstawka wykona pewne skutki uboczne na pewnych adresach. Gcc musi wziąć to pod uwagę w czasie optymalizacji.
- dodatkowo z niestandardowych rzeczy przed [idx] użyty został tzw. operand modifier ("q"). Bez tego modyfikatora pod [idx] podstawiony zostanie defaultowo rejestr 32-bitowy (double word) a nie 64-bitowy (q - quad word) czego byśmy nie chcieli.
Polecam zajrzeć tutaj:
Ref1: https://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html
Ref2: https://software.intel.com/sites/landingpage/IntrinsicsGuide/