2012-05-23 17 views
14

Obecnie pracuję nad aplikacją open source 3D framework w (z ). Moja własna biblioteka matematyczna jest zaprojektowana tak, jak XNA math library, również z myślą o SIMD. Ale obecnie nie jest naprawdę szybki i ma problemy z alignmentami pamięci, ale więcej o tym w innym pytaniu.Czy należy używać rozszerzeń SIMD lub wektorowych lub czegoś innego?

Kilka dni temu zadałem sobie pytanie, dlaczego powinienem napisać swój własny kod SSE. Kompilator jest również w stanie wygenerować kod zoptymalizowany przy włączonej optymalizacji. Mogę również użyć "vector extension" z GCC. Ale to wszystko nie jest naprawdę przenośne.

Wiem, że mam większą kontrolę, gdy używam własnego kodu SSE, ale często ta kontrola jest nieważna.

Jednym dużym problemem SSE jest wykorzystanie pamięci dynamicznej, która przy pomocy pul pamięci i projektowania zorientowanego na dane jest ograniczona do minimum.

Teraz moje pytanie:

  • powinienem używać nagi SSE? Być może zamknięte w kapsule.

    __m128 v1 = _mm_set_ps(0.5f, 2, 4, 0.25f); 
    __m128 v2 = _mm_set_ps(2, 0.5f, 0.25f, 4); 
    
    __m128 res = _mm_mul_ps(v1, v2); 
    
  • A może kompilator powinien wykonać brudną robotę?

    float v1 = {0.5f, 2, 4, 0.25f}; 
    float v2 = {2, 0.5f, 0.25f, 4}; 
    
    float res[4]; 
    res[0] = v1[0]*v2[0]; 
    res[1] = v1[1]*v2[1]; 
    res[2] = v1[2]*v2[2]; 
    res[3] = v1[3]*v2[3]; 
    
  • Czy powinienem użyć SIMD z dodatkowym kodem? Podobnie jak w przypadku dynamicznej klasy kontenerów z operacjami SIMD, która wymaga dodatkowych instrukcji: load i store.

    Pear3D::Vector4f* v1 = new Pear3D::Vector4f(0.5f, 2, 4, 0.25f); 
    Pear3D::Vector4f* v2 = new Pear3D::Vector4f(2, 0.5f, 0.25f, 4); 
    
    Pear3D::Vector4f res = Pear3D::Vector::multiplyElements(*v1, *v2); 
    

    Powyższy przykład wykorzystywać urojonej klasy z zastosowań float[4] wewnętrznych i wykorzystuje store i load w każdej metody, takie jak multiplyElements(...). Metody używają SSE wewnętrznego.

Nie chcę używać innej biblioteki, ponieważ chcę dowiedzieć się więcej o SIMD i projektowaniu oprogramowania na dużą skalę. Ale przykłady bibliotek są mile widziane.

PS: To nie jest prawdziwy problem, więcej pytań projektowych.

+0

Dlaczego nie być leniwym i pozwolić kompilatorowi na optymalizacje, jeśli jest to możliwe? – Vlad

+0

To jest pytanie, dlaczego nie powinienem pozwolić kompilatorowi na brudną robotę? Czytam wiele książek w językach C++ i projektowych, a większość preferuje implementację SSE. – pearcoding

+11

Cóż, zdecydowanie nie trzecia, przynajmniej nie z dynamicznie przydzieloną pamięcią dla czegoś tak małego jak vec4 (to jest C++, a nie Java). Możesz enkapsulować '__m128' do klasy, aby propagować jego ograniczenia (oczywiście musisz zająć się alokacją dynamiczną przez przeciążenie' operatora new' i specjalizację 'std :: allocator'), ale nigdy nie używaj dynamicznej pamięci alokacja na coś tak prostego, jak pojedyncze vec4. To przeważy każdy możliwy zysk z SSE o czynnik 2 miliardy (zamierzona przesada). –

Odpowiedz

12

Cóż, jeśli chcesz korzystać z rozszerzeń SIMD, dobrym podejściem jest użycie wewnętrznej samoistności SSE (oczywiście, że pozostaniesz na wszelki wypadek z montażem w linii, ale na szczęście nie podałeś jej jako alternatywy).Ale dla czystości należy ująć je w ładnym klasy Vector z przeciążonych operatorów:

struct aligned_storage 
{ 
    //overload new and delete for 16-byte alignment 
}; 

class vec4 : public aligned_storage 
{ 
public: 
    vec4(float x, float y, float z, float w) 
    { 
     data_[0] = x; ... data_[3] = w; //don't use _mm_set_ps, it will do the same, followed by a _mm_load_ps, which is unneccessary 
    } 
    vec4(float *data) 
    { 
     data_[0] = data[0]; ... data_[3] = data[3]; //don't use _mm_loadu_ps, unaligned just doesn't pay 
    } 
    vec4(const vec4 &rhs) 
     : xmm_(rhs.xmm_) 
    { 
    } 
    ... 
    vec4& operator*=(const vec4 v) 
    { 
     xmm_ = _mm_mul_ps(xmm_, v.xmm_); 
     return *this; 
    } 
    ... 

private: 
    union 
    { 
     __m128 xmm_; 
     float data_[4]; 
    }; 
}; 

Teraz Dobrą rzeczą jest to, ze względu na anonimowego Union (UB, wiem, ale pokaż mi platformę z SSE, gdzie tego nie robi Jeśli chcesz, możesz użyć standardowej tablicy float (np. operator[] lub inicjalizacji (nie używać _mm_set_ps)) i używać tylko SSE, gdy jest to odpowiednie. Z nowoczesnym wbudowanym kompilatorem enkapsulacja odbywa się prawdopodobnie bez kosztów (byłem raczej zaskoczony, jak dobrze VC10 zoptymalizował instrukcje SSE dla szeregu obliczeń z tą klasą wektorów, bez obawy przed niepotrzebnymi ruchami do zmiennych pamięci tymczasowej, ponieważ wydawało się, że VC8 lubi nawet bez enkapsulacji).

Jedyną wadą jest to, że trzeba zadbać o właściwe ustawienie, ponieważ nieusunięte wektory niczego nie kupują, a nawet mogą być wolniejsze od nie-SSE. Ale na szczęście wymóg wyrównania z __m128 będzie propagował do vec4 (i każdej otaczającej klasy) i wystarczy zająć się alokacją dynamiczną, do której C++ ma dobre środki. Trzeba tylko utworzyć klasę podstawową, której funkcje (oczywiście we wszystkich smakach) są przeciążone poprawnie iz której wywodzi się klasa wektorowa. Aby użyć swojego typu w standardowych pojemnikach, musisz oczywiście również wyspecjalizować std::allocator (i może std::get_temporary_buffer i std::return_temporary_buffer ze względu na kompletność), ponieważ w przeciwnym razie użyje globalnego operator new.

Jednak prawdziwą wadą jest to, że trzeba również zadbać o dynamiczny przydział każdej klasy, która ma wektor SSE jako członek, co może być uciążliwe, ale może być ponownie zautomatyzowane, również wywodząc te klasy z aligned_storage i umieszczenie całego bałaganu specjalizacyjnego std::allocator w poręcznym makrze.

JamesWynn ma rację, że operacje te często spotykają się w niektórych specjalnych blokach obliczeniowych (takich jak filtrowanie tekstur lub transformacja wierzchołków), ale z drugiej strony używanie tych wektorów wektorowych SSE nie wprowadza żadnych kosztów ogólnych w stosunku do standardowego float[4] - implementacja klasy wektorowej. Musisz mimo to pobrać te wartości z pamięci do rejestrów (czy to stosu x87 czy skalarnego rejestru SSE), aby wykonać dowolne obliczenia, więc dlaczego nie wziąć wszystkich na raz (co powinno być IMHO wolniejsze od przesuwania pojedynczego wartość, jeśli jest prawidłowo wyrównany) i obliczaj równolegle. W ten sposób można dowolnie zaimplementować implementację SSE dla wersji innej niż SSE bez wywoływania jakichkolwiek narzutów (popraw mnie, jeśli moje rozumowanie jest błędne).

Ale jeśli zapewnienie wyrównania dla wszystkich klas posiadających vec4 jako członka jest zbyt uciążliwe dla ciebie (co jest IMHO jedyną wadą tego podejścia), możesz również zdefiniować wyspecjalizowany typ wektora SSE, którego używasz do obliczeń i użyj standardowego wektora innego niż SSE do przechowywania.


EDIT: Ok, aby spojrzeć na napowietrznej argumentu, że idzie tu (i wygląda całkiem rozsądne w pierwszym), weźmy kilka obliczeń, które wyglądają bardzo czyste, z powodu przeciążonych operatorów:

#include "vec.h" 
#include <iostream> 

int main(int argc, char *argv[]) 
{ 
    math::vec<float,4> u, v, w = u + v; 
    u = v + dot(v, w) * w; 
    v = abs(u-w); 
    u = 3.0f * w + v; 
    w = -w * (u+v); 
    v = min(u, w) + length(u) * w; 
    std::cout << v << std::endl; 
    return 0; 
} 

i zobaczyć, co myśli o nim VC10:

... 
; 6 :  math::vec<float,4> u, v, w = u + v; 

movaps xmm4, XMMWORD PTR _v$[esp+32] 

; 7 :  u = v + dot(v, w) * w; 
; 8 :  v = abs(u-w); 

movaps xmm3, XMMWORD PTR [email protected] 
movaps xmm1, xmm4 
addps xmm1, XMMWORD PTR _u$[esp+32] 
movaps xmm0, xmm4 
mulps xmm0, xmm1 
haddps xmm0, xmm0 
haddps xmm0, xmm0 
shufps xmm0, xmm0, 0 
mulps xmm0, xmm1 
addps xmm0, xmm4 
subps xmm0, xmm1 
movaps xmm2, xmm3 

; 9 :  u = 3.0f * w + v; 
; 10 : w = -w * (u+v); 

xorps xmm3, xmm1 
andnps xmm2, xmm0 
movaps xmm0, XMMWORD PTR [email protected] 
mulps xmm0, xmm1 
addps xmm0, xmm2 

; 11 : v = min(u, w) + length(u) * w; 

movaps xmm1, xmm0 
mulps xmm1, xmm0 
haddps xmm1, xmm1 
haddps xmm1, xmm1 
sqrtss xmm1, xmm1 
addps xmm2, xmm0 
mulps xmm3, xmm2 
shufps xmm1, xmm1, 0 

; 12 : std::cout << v << std::endl; 

mov edi, DWORD PTR [email protected]@@[email protected]?$[email protected]@[email protected]@@[email protected] 
mulps xmm1, xmm3 
minps xmm0, xmm3 
addps xmm1, xmm0 
movaps XMMWORD PTR _v$[esp+32], xmm1 
... 

Nawet bez dokładnie analizuje każdą dyspozycję i ja ts use, jestem całkiem pewny, że nie ma żadnych zbędnych ładunków lub zapasów, z wyjątkiem tych na początku (Ok, zostawiłem je niezainicjowane), które i tak są potrzebne, aby przenieść je z pamięci do rejestrów komputerowych, i na końcu, który jest niezbędny, jak w poniższym wyrażeniu v zostanie wydany. Nie zapisał nawet niczego z powrotem w u i w, ponieważ są to tylko zmienne tymczasowe, których nie używam dalej. Wszystko jest doskonale zorientowane i zoptymalizowane. Udało mu się nawet bezproblemowo przetasować wynik produktu kropki dla następnego mnożenia, bez opuszczania rejestru XMM, chociaż funkcja dot zwraca float przy użyciu rzeczywistego _mm_store_ss po s.

Tak więc nawet ja, będąc zwykle nieco nadpobudliwymi zdolnościami kompilatora, muszę powiedzieć, że ręczne wykonywanie własnych funkcji specjalnych nie opłaca się w porównaniu z czystym i ekspresyjnym kodem, który uzyskuje się przez enkapsulację. Choć możesz być w stanie stworzyć mordercze przykłady, w których przygotowanie intriników może naprawdę zaoszczędzić ci kilku instrukcji, ale potem znowu musisz przechytrzyć optymalizator.


EDIT: Ok, Ben Voigt podkreślił inny problem unii oprócz (najprawdopodobniej nie problematycznej) niezgodności układu pamięci, który jest to, że naruszono surowe zasady aliasingu i kompilator może zoptymalizować instrukcje dostępu różnych członków związku w sposób, który sprawia, że ​​kod jest nieważny. Jeszcze o tym nie myślałem. Nie wiem, czy sprawia to jakieś problemy w praktyce, to na pewno wymaga dochodzenia.

Jeśli to naprawdę problem, niestety musimy usunąć członka data_[4] i użyć samego __m128. W celu inicjalizacji musimy ponownie odwołać się do _mm_set_ps i _mm_loadu_ps. Model operator[] jest nieco bardziej skomplikowany i może wymagać kombinacji _mm_shuffle_ps i _mm_store_ss. Ale w przypadku wersji niestałej należy użyć jakiegoś obiektu proxy delegującego przypisanie do odpowiednich instrukcji SSE. Należy zbadać, w jaki sposób kompilator może zoptymalizować dodatkowe obciążenie w określonych sytuacjach.

Lub używasz tylko wektora SSE do obliczeń i po prostu tworzy się interfejs do konwersji zi do wektorów innych niż SSE w całości, który jest następnie używany w urządzeniach peryferyjnych obliczeń (ponieważ często nie trzeba dostęp do poszczególnych komponentów w długich obliczeniach). Wydaje się, że ten sposób obsługuje ten problem. Ale nie jestem pewien, w jaki sposób obsługuje go Eigen.

Ale jak sobie z tym poradzić, nadal nie ma potrzeby używania rzemieślników SSE bez korzystania z zalet przeciążania operatora.

+0

+1 dziękuję za odpowiedź. Już używam intrinsics, ale nie używam prawdziwej klasy enkapsulacji z przeciążonymi operatorami. W jednej książce (tak naprawdę nie pamiętam, może [Real Time Rendering] (http://www.realtimerendering.com/) autorstwa Tomasa Akenine-Möllera, Eric Hainesa i Naty Hoffman) nie było to sugerowane z powodu dużo nad głową.Myślę, że powinienem przeprojektować aplikację i wyśrodkować wiele algorytmów matematycznych w jednym punkcie :) – pearcoding

+3

@ omercan1993 Sprawdź to. Zrób kilka z tych wektorów i wykonaj z nimi kilka obliczeń. Byłbym zaskoczony, gdyby nie spowodowało to tylko garści instrukcji SSE bez zbędnych obciążeń lub zapasów (odłóż na bok wywołania funkcji). Nie sądzę, żeby gcc w ostatniej chwili był w jakiś sposób gorszy niż VC10 pod tym względem. Oczywiście na początku tych bloków obliczeniowych prawdopodobnie masz trochę ładunków, ale są one dostępne dla wektorów innych niż SSE lub ręcznie pisanych samoistnie. –

+0

Spróbuję i opublikuję tutaj wynik :) – pearcoding

2

Proponuję użyć nagiego kodu simd w ściśle kontrolowanej funkcji. Ponieważ nie będziesz go używał do podstawowego mnożenia wektorów z powodu narzutu, ta funkcja powinna prawdopodobnie wziąć listę obiektów Vector3, które muszą być zmanipulowane, jak na DOD. Gdzie jest jeden, jest ich wiele.

+0

+1 Więc mogę użyć funkcji takiej jak pear3d_multiply3fv (float * vs, int count) :) Ale bardzo trudno jest wyśrodkować wiele multiplikacji w jednym punkcie ... dobrze powinienem przemyśleć projekt, ale z DOD nie powinno być bardzo trudny. – pearcoding

+0

JamesWynn Naprawdę nie ma żadnego wzlotu podczas wykonywania wszystkich obliczeń za pomocą wektora zaimplementowanego w SSE (zobacz moją odpowiedź), więc nie sądzę, że przenoszenie wszystkiego do oddzielnej funkcji z ręcznie wykonaną intrinsą naprawdę się opłaca. Ale masz rację, że główne użycie kodu SSE jest rzeczywiście w niektórych specjalnych blokach kompuacji. –

4

Proponuję zapoznać się z szablonami ekspresji (niestandardowe implementacje operatorów używające obiektów proxy). W ten sposób można uniknąć ładowania/przechowywania danych o wydajności w odniesieniu do każdej pojedynczej operacji i wykonywać je tylko raz dla całego obliczenia.

+0

+1 Coś jak [this] (http://stackoverflow.com/questions/4527394/template-trick-to-optimize-out-allocations)? Dzięki, popatrzę na nie. – pearcoding

+1

@ omercan1993: Tak, właśnie o tym mówię. –

+1

Cóż, kompilator (przynajmniej mój VC10) jest całkiem niezły w inwentaryzacji i optymalizowaniu tych obliczeń do samych instrukcji SSE bez ponoszenia niepotrzebnych obciążeń i zapisów (zobacz moją odpowiedź na mały (i może prosty, ale raczej powszechny IMHO) przykład). Choć nadal możliwe jest stworzenie przykładowych obliczeń zabijania (dla których potrzebna jest również bardzo dobra implementacja ET). –

Powiązane problemy