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.
Dlaczego nie być leniwym i pozwolić kompilatorowi na optymalizacje, jeśli jest to możliwe? – Vlad
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
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). –