11 sierpnia 2009

SSE w klasie - #2

Po omówieniu podstaw związanych z programowaniem przy użyciu instrukcji SIMD, czas przedstawić nieco bardziej użyteczne informacje. Dzisiejszym daniem głównym więc, będzie niewielka (rozwijana w kolejnych częściach mini kursu) klasa czterowymiarowego wektora. Życzę smacznego i zapraszam do lektury :).

Na początku zaczniemy od przygotowania składników. Dziś zamiast wyrównanej do szesnastu bajtów tablicy typu float, skorzystamy ze struktury:

__declspec(align(16))
struct VectorData{
    float x,y,z,w;
};

Dla instrukcji SSE liczy się tak naprawdę odpowiednio przygotowany skrawek pamięci – w jaki sposób zostanie on zaalokowany (czy to będzie tablica czy struktura) zależy tylko od preferencji programisty.

Mając wszystko przygotowane, możemy przejść do przyrządzania dania głównego. Na pierwszy ogień pójdzie więc konstruktor domyślny, w którym to będziemy zerować współrzędne wektora.
Samo zerowanie jest oczywiście sprawą trywialną, choć początkowo może przysporzyć nieco problemów.
Tradycyjnie, można to zrobić na dwa sposoby:
  • “manualne” przypisanie zer
  • skorzystanie z instrukcji memset (nagłówek cstring lub string.h)
Skorzystanie z memset powinno nieco przyśpieszyć tą prostą operację, ponieważ nie odwołujemy się do koprocesora, tylko operujemy danymi bezpośrednio w pamięci. Mimo iż w konstruktorze nie potrzebujemy wyjątkowej wydajności (jest on wykonywany zazwyczaj tylko raz), to jednak wykorzystamy trochę mocy :).
Otóż i niezwykle wyrafinowana treść naszej funkcji: 
_asm{
    mov esi,this
    movaps xmm0,[esi]
    xorps xmm0,xmm0
    movaps [esi],xmm0
}
Jak widzimy, są tu dwie nowe rzeczy. Pojawił się wskaźnik this oraz instrukcja xorps.
Wskaźnik this używany jest by dostać się to struktury przechowującej wektor. Zadziała to jednak tylko wtedy gdy zmienna ta będzie pierwszym polem klasy. Wówczas adres na który wskazuje this jest równoznaczny z adresem owego pola.
W przeciwnym wypadku, pobranie adresu trzeba będzie wykonać “ręcznie”:
void *p = &m_vecData

_asm{
    mov esi,p
    //...
}
Cóż takiego robi instrukcja xorps? Jest to nic innego jak zwykły xor, w wersji dla SSE, działający na poszczególnych elementach naszego wektora. Wykonując tą operację na dwóch takich samych wektorach, zerujemy je. Jest to znacznie szybsze, niż “statyczne” przypisanie wartości zero do każdej ze współrzędnych. Mając konstruktor domyślny, możemy stworzyć konstruktor kopiujący oraz operator przypisania. Praktycznie rzecz ujmując będą to dwie identyczne funkcje. Ich działanie będzie się tylko ograniczać do kopiowania wartości pomiędzy rejestrami xmm. Spójrzmy więc jak to wygląda:
_asm{
    mov edi,vec
    mov esi,this

    movaps xmm0,[esi]
    movaps xmm1,[edi]

    movaps xmm0,xmm1

    movaps [esi],xmm0
}
Zakładam, że vec, to stała referencja na obiekt naszej klasy, przekazana do konstruktora kopiującego bądź operatora przypisania.
Działanie, jak już wspominałem, jest bardzo proste. Rejestry edi i esi przechowują wskaźniki na nasze obiekty, które następnie (za pomocą movaps) kopiowane są do odpowiednich rejestrów SSE – xmm0 dla naszego wektora oraz xmm1 dla wektora przekazanego w parametrze. Przypisanie wektorów to po prostu skopiowanie zawartości xmm1 do xmm0. Ostatnia instrukcja zapisuje wynik w this.

Na koniec omówimy sobie operator new i delete, ponieważ jak się okazuje, domyślne operatory nie będą kompatybilne z naszą klasą wektora.
W podstawowym ujęciu, instrukcje SSE dzielimy na dwie kategorie: operujące na wyrównanych i niewyrównanych danych. Gdy operujemy na danych niewyrównanych, działanie SSE jest nieco wolniejsze, ponieważ potrzeba więcej cykli by dostać się do odpowiedniej komórki pamięci. Przykładem tego typu instrukcji może być movups – jest to odpowiednik movaps (u – unaligned, a - aligned), tylko, że tablica lub struktura, do której chcemy uzyskać dostęp, nie musi być wyrównana do granicy szesnastu bajtów.

Cóż więc wspólnego mają z tym operatory new i delete? To, że przydzielają one niewyrównane do odpowiadającej nam wartości, kawałki pamięci. Należy zatem operatory te przeciążyć. Na szczęście jest to operacja równie trywialna, jak te, które zaprezentowałem powyżej. Jeden ze sposobów zrobienia tego, opisał Netrix, mi jednak udało się, znaleźć znacznie prostsze rozwiązanie. Okazuje się bowiem, że istnieją funkcje które robią to za nas: _aligned_malloc i _aligned_free. Zewnętrznie działają tak samo jak standardowe wersje malloc i free, więc ich użytkowanie nie powinno sprawiać większego problemu.

Implementację dodawania i odejmowania zostawiam jako zadanie domowe :). Instrukcje, które je wykonują omówiłem w poprzedniej części kursiku więc nie powinno to stanowić większego problemu.

W załączniku znajduje się kod do dzisiejszego kursiku. Dodałem także metodę zerującą wektor.

8 komentarzy:

  1. Mniam, ciekawy wpis. A czy wykorzystujesz to u siebie w silniczku w jakiś sposób?

    OdpowiedzUsuń
  2. Na razie nie. Przyklad z wektorem jest czysto edukacyjny. Pisanie wlasnych implementacji mija sie z celem. D3DX jest napisany bardzo wydajnie (z tego co wiem, uzywa SSE2 lub nawet SSE3). Sa tez inne implementacje - chocby Sony.
    Jednak okazje do samodzielnego uzycia SSE w silniku z pewnoscia sie znajda :)

    OdpowiedzUsuń
  3. Czy użycie align(16) nie sprawi, że alokowane dynamicznie wektory będą wyrównane?

    OdpowiedzUsuń
  4. Wektor sam w sobie tak. Jednak wskaźnik na niego już nie, a to też jest wymagane.

    OdpowiedzUsuń
  5. this jest przekazywany w ecx

    OdpowiedzUsuń
  6. Ja mam klasę Vector z 4 floatami dla raytracera. G++ pod Linuksem generuje instrukcje SSE przy każdej z operacji wektora. Są to instrukcje ss a nie ps, ale i tak jest to szybsze od x87 a instrinsicy dają już nie taki duży przyrost wydajności.

    Realne przyśpieszenie z SSE(2) można dostać, gdy piszę się pod przepustowość a nie opóźnienie np. śledzi się 4 promienie na raz.

    OdpowiedzUsuń
  7. Ej, czy xorps xmm0,xmm0 nie zeruje czasem xmm0 obojętne co tam było wczśniej? Jeśli tak, to po co przed tym kopiujesz movaps xmm0,[esi]?

    OdpowiedzUsuń
  8. Reg: oj stary - nie pamiętam :D

    OdpowiedzUsuń