06 stycznia 2009

Kompilator vs. inline assembler

Optymalizacje niskopoziomowe, mimo że stosowane coraz rzadziej, nadal w wielu przypadkach, są jedynym wyjściem by przyśpieszyć działanie aplikacji. Z roku na rok jednak, kompilatory czynią dość znaczące postępy w automatycznym optymalizowaniu kodu i nie zawsze musimy uciekać się do drastycznych metod wstawek asemblerowych, które wbrew powszechnej opinii, nie są już zalecane. Przyjrzyjmy się jednak jednemu, specyficznemu przypadkowi: użycie SSE.
Mamy trzy możliwości do wyboru:

  1. Optymalizacje stosowane przez kompilator (w opisywanym przypadku Visual Studio 2008).
  2. Użycie wewnętrznych poleceń, tzw. intrinsic.
  3. Bezpośrednie zastosowanie instrukcji SSE procesora.
Będziemy wykonywać operacje na wektorach, które będą reprezentowane przez zwykłą, wyrównaną do 16 bajtów (128 bitów) tablicą 4 wartości float:

__declspec(align(16)) float vector[4];

Pewnie wiele osób zastanawia się, skąd ta dziwna składnia tworzenia tablicy. Wyrównanie danych narzuca restrykcje dotyczące rozmiaru. Wyrównanie do 16 bajtów (czyli polecenie __declspec(align(16) ) informuje nas, że określona zmienna będzie zajmować dokładnie tyle. Przypomnijmy, że rejestry SSE są 128 bitowe i aby optymalnie wykonywać operacje musimy wysyłać dane do nich właśnie takimi, 128 bitowymi, paczkami. Aby porównać ze sobą wymienione wyżej trzy sposoby optymalizacji, zajrzymy do kodu wynikowego generowanego przez kompilator. Można to zrobić wchodząc we właściwości projektu (Project->Properties), następnie w zakładkę C++, Output Files i tam ustawić Aseembler Output na Assembly With Source Code. Będziemy porównywać podstawowe operacje typu przy zastosowaniu pierwszej wersji SSE, wektorem zaś będzie struktura:

struct Vector{
__declspec(align(16)) float data[4];
};

1. Optymalizacje kompilatora.

Jest to najprostszy sposób by użyć dodatkowej mocy procesora. Zaznaczamy kilka checkbox’ów, rekompilujemy projekt i dostajemy szybszy (przynajmniej w teorii) program. Zobaczmy jak sytuacja przedstawia się, gdy chcemy użyć SSE. Na początek przejdźmy do opcji projektu i tam C++ -> Code Generation -> Enable Enhanced Instruction Set ustawiamy na Streaming SIMD Extensions. Mając wszystko przygotowane, możemy przystąpić do pracy.

Posłużmy się prostą funkcją dodającą wektory:

void addVectors(Vector &v1,Vector &v2, Vector *pOut){
pOut->data[0] = v1.data[0] + v2.data[0];
pOut->data[1] = v1.data[1] + v2.data[1];
pOut->data[2] = v1.data[2] + v2.data[2];
pOut->data[3] = 1.0f;
}

Warto zauważyć, że element w, czyli ostatni, powinien być ustawiany na wartość 1.0f. Nie zastosowałem tutaj pętli, choć kompilator przy tak niewielkiej ilości iteracji i tak zastosowałby rozwinięcie.

Spójrzmy teraz na kod, który został wyprodukowany:

//; 8    :     pOut->data[0] = v1.data[0] + v2.data[0];
mov eax, DWORD PTR _v1$[ebp]
fld DWORD PTR [eax]
mov ecx, DWORD PTR _v2$[ebp]
fadd DWORD PTR [ecx]
mov edx, DWORD PTR _pOut$[ebp]
fstp DWORD PTR [edx]
//; 9 : pOut->data[1] = v1.data[1] + v2.data[1];
//dodawanie wartosci y //; 10 : pOut->data[2] = v1.data[2] + v2.data[2];
//dodawanie wartosci z //; 12 : pOut->data[3] = 1.0f;
mov eax, DWORD PTR _pOut$[ebp]
movss xmm0, DWORD PTR __real@3f800000
movss DWORD PTR [eax+12], xmm0

Pominąłem tu prolog i epilog funkcji, bo nie są w tym momencie istotne. Nie ma także dodawania elementów y i z, ponieważ jest to realizowane w taki sam sposób jak dla elementu x. Zmieniane są tylko wartości rejestrów ecx dla instrukcji fadd (która odpowiada za dodawanie przez koprocesor FPU), oraz rejestru edx dla funkcji fstp odpowiednio o 4 (współrzędna x) i 8 (współrzędna y). Jak widać, samo dodawanie jest realizowane standardowo. Pierwszy raz instrukcja SSE jest użyta podczas przypisania elementu w (data[3]) – movss odpowiedzialna za ustawienie najniższej wartości rejestru na odpowiednią wartość.

Wnioski są bardzo proste: korzystanie z optymalizacji dostarczanych przez kompilator (w kontekście korzystania z SSE) nie dają zbyt wiele korzyści, a czasem nawet mogą zaszkodzić – powyższy kod nie uruchomi się na procesorach niższej klasy niż pentuim III – wszystko przez jedną instrukcję movss, która de facto, nie przyśpieszyła zauważalnie naszego kodu.

2. Wewnętrzne polecenia kompilatora.

Są to instrukcje, dzięki którym kompilator wie jak poprawnie użyć poleceń asemblera. By móc z nich skorzystać musimy dołączyć do projektu nagłówek xmmintrin.h, gdzie znajdują się wszystkie deklaracje funkcji i typów. Także i w tym przypadku wykorzystamy prostą funkcję dodającą dwa wektory:

void addVectors(Vector &v1,Vector &v2, Vector *pOut){
_mm_store_ps(pOut->data,_mm_add_ps(*(__m128*)&v1.data,*(__m128*)&v2.data));
pOut->data[3] = 1.0f;
}

Tak. Oto cała funkcja. Dodaje ona dwa wektory, więc funkcjonalnością jest ona identyczna z tą zaprezentowaną wcześniej. Na tym jednak kończą się podobieństwa. Jak widać sposób dodawania jest tutaj inny. Jest on wykonywany za pomocą funkcji _mm_add_ps, przyjmującej zmienne typu __m128 – by przekazać wyrównaną tablicę trzeba użyć rzutowania. Za pomocą _mm_store_ps zapisujemy wartość typu __m128, zwracaną przez _mm_add_ps, do tablicy. Zostaje jeszcze przypisanie wartości dla w. Tworząc własną klasę do zarządzania wektorami, można pominąć tę operację, dodając metodę dostępową zwracającą dla w zawsze 1.0f – wartość zapisana w ostatniej komórce będzie więc bez znaczenia.

Zobaczmy więc, co zostało ostatecznie wygenerowane:

    mov    eax, DWORD PTR _v2$[ebx]
movaps xmm0, XMMWORD PTR [eax]
mov ecx, DWORD PTR _v1$[ebx]
movaps xmm1, XMMWORD PTR [ecx]
addps xmm1, xmm0
movaps XMMWORD PTR $T20497[ebp], xmm1
movaps xmm0, XMMWORD PTR $T20497[ebp]
mov edx, DWORD PTR _pOut$[ebx]
movaps XMMWORD PTR [edx], xmm0 mov eax, DWORD PTR _pOut$[ebx]
fld1
fstp DWORD PTR [eax+12]

Jak widać, kompilator utworzył znacznie mniej kodu. Pierwsze cztery linijki zapisują przekazane wektory do odpowiednich rejestrów SSE (xmm0, xmm1). Następnie addps dodaje te wektory (zwróćmy uwagę – jedno polecenie dodaje po cztery wartości dla każdego rejestru). Kolejne operacje dotyczą zapisania wyniku w przekazanej zmiennej, zaś trzy ostatnie przypisania wartości 1.0 dla ostatniej komórki tablicy. Tak utworzony kod jest zdecydowanie bardziej wydajny od poprzedniego. Widzimy tu zgodne z oczekiwaniami wykorzystanie instrukcji SSE (movaps, addps). Można zapisać to jeszcze lepiej?

3. Inline asembler

Została nam ostatnia opcja: bezpośrednia wstawka asemblerowa. Wbrew pozorom nie będzie to trudne zadanie – jak już wyżej było pokazane, dodawanie to zaledwie jedna instrukcja.

void addVectors(Vector &v1,Vector &v2, Vector *pOut){
_asm{
mov eax,v1;
mov ecx,v2;
movaps xmm0, [eax];
addps xmm0,[ecx];
mov eax, pOut;
movaps [eax],xmm0;
}
pOut->data[3] = 1.0f;
}

Pierwsze dwie instrukcje przenoszą adresy (warto zauważyć, że wejściowe wektory przekazywane są przez referencje) danych na do odpowiednich rejestrów. Następnie movaps przenosi do rejestru xmm0, to co znajduje się pod adresem wskazywanym przez rejestr eax. Instrukcja addps dodaje zawartość rejestru xmm0 oraz miejsca w pamięci wskazywanego przez ecx. Ostatnie instrukcje zapisują wynik w wyjściowej zmiennej pOut.

Kod, który da nam kompilator, będzie praktycznie identyczny z tym znajdującym się  wewnątrz bloku _asm{}. Dodatkowo dojdzie jeszcze ostatnie przypisanie dla data[3].

Jak widać okazaliśmy się lepsi od kompilatora. ;)

Co więc wybrać?

Pytanie nie jest taki proste. Nie zawsze bowiem, mamy chęć i czas by pisać kod w asemblerze – funkcje operujące na macierzach są zdecydowanie bardziej skomplikowane. Wyniki jakie dają metody z rodziny _mm* są wystarczająco zadawalające, jednak czasem można zaoszczędzić kilka instrukcji, pisząc niskopoziomowo. Dodatkowo funkcję lub metodę można oznaczyć jako inline lub __forceinline, dzięki temu ciało funkcji będzie wstawiane bezpośrednio w miejsce wywołania. Dzięki temu można uniknąć dodatkowego narzutu. Korzystanie z asemblera ma jednak jedną zasadniczą wadę. Funkcje napisane w taki sposób nie są w żadne sposób optymalizowane. Kompilator grzecznie wklei kod, który wcale nie będzie musiał być wykonywany. W takich przypadkach zdecydowanie bezpieczniej jest użyć intrinsic.

Ostatnim dylematem, może okazać sie potrzeba pisania własnych metod obsługi wektorów i macierzy. Jeśli korzystamy z DirectX, mamy tam do dyspozycji funkcje z rodziny D3DX, które (choć nie wszystkie) zostały odpowiednio już przygotowane – optymalizacja normalizacji wektorów lub mnożenia macierzy jest na prawdę dobrze wykonana.

Brak komentarzy:

Prześlij komentarz