Archive for December 2008

Wyrównanie pamięci dla operatorów new i new[]

Gdy programuje się coś z użyciem funkcji wewnętrznych kompilatora (Intrinsics) dla SSE, najlepiej jest gdy to, co do nich przekazujemy jest wyrównanie do 16 bajtów, w przeciwnym wypadku powstaje problem z odczytaniem z pamięci i program się wysypuje. Oczywiście można korzystać z funkcji ładujących, które przyjmują dane niewyrównane, ale funkcje te są z oczywistych względów wolniejsze. Do ustawienia wyrównania danych pól stosuję się specjalną deklaracje __declspec(align(16)), którą należy umieścić przed definicją zmiennej (oczywiście dotyczy to Microsoft Visual Studio).

__declspec(align(16)) __m128 m_vector;

Niestety taka deklaracja dotyczy tylko obiektów na stosie. Te, które są tworzone na stercie, czyli przez operator new oraz operator new[], nie zawsze są wyrównane, dlatego trzeba zadbać o to samemu odpowiednio przeciążając te funkcje. Przeciążanie tych operatorów nie jest trudne. Oba przyjmują jako argument ilość potrzebnego miejsca wyrażoną w bajtach, a zwracają wskaźnik typu void, który zawiera adres odpowiedniego obszaru pamięci. Nagłówek jednego z tych operatorów wygląda następująco:

void* operator new(const size_t nBytes)

W ciele takiej funkcji należy zaalokować odpowiednią ilość miejsca, a następnie wyrównać wskaźnik tak, aby dzielił się przez 16. Dopiero ten wskaźnik można zwrócić. Należy oczywiście pamiętać o zapisaniu gdzieś poprzedniego wskaźnika oraz o alokacji pamięci tak, żeby po wyrównaniu nie wychodzić poza przydzielony zakres.
W przypadku mojej funkcji adres jest zapisany przed wyrównanym adresem (dzięki poradom kolegów z forum ), a ilość alokowanego miejsca jest większa o rozmiar alokowanej struktury + 3 bajty.

void* operator new(const size_t nBytes)
{
 char* ptr = new char[nBytes + sizeof(NVector) + 3];
 unsigned int temp = reinterpret_cast<unsigned int>(ptr);  // Konwersja na postać odpowiednią do obliczeń
 unsigned int t = temp % 16;   // Obliczam o ile jest przesunięty od wyrównania
 temp += (t &lt; 13) ? (16 - t) : (32 - t);  // Dodaje odpowiednie przesunięcie do wskaźnika
 void* aPtr = reinterpret_cast<void*>(temp); // Zapis nowego wskaźnika
 temp -= sizeof(void*);
 *reinterpret_cast<unsigned int*>(temp) = reinterpret_cast<unsigned int>(ptr); // Zapis poprzedniego wskaźnika
 return aPtr;
}

NVector jest klasą, w której umieściłem ten operator. Liczba 13 występująca przy porównywaniu w powyższym kodzie reprezentuje przesunięcie, dla którego nie jest możliwe zapisanie wskaźnika przed najbliższym wyrównaniem, dlatego trzeba dodać więcej. Operator new[]wygląda identycznie, dlatego można w nim wywołać powyższą funkcję. Odpowiednik delete dla tego operatora wygląda następująco:

void operator delete(void* p)
{
	unsigned int temp = reinterpret_cast<unsigned int>(p) - sizeof(void*);
	char* temp2 = reinterpret_cast<char*>(*reinterpret_cast<int*>(temp));
	delete[] temp2;
}

Powyższa funkcja oblicza poprzedni wskaźnik i korzystając z niego dealokuje obszar. Operator delete[] jest identyczny.

Niskopoziomowa zabawa

Zabawa w kodzie na niskim poziomie może być bardzo fajna, ja porównuję ją do rozwiązywania łamigłówek (myślę, że są osoby, które podzielają moje zdanie :)). Czasem ta zabawa sprowadza się zbadania, czy zmiana jednej funkcji assemblerowej powoduje przyspieszenie kodu o ten jeden cykl, chociaż jest to już skrajność. Przykładem tego może być funkcja obliczająca długość wektora 4D wykorzystująca funkcje wewnętrzne kompilatora – Intrinsics:

float Length()
{
   float f;
   __m128 temp = _mm_mul_ps(m_vector, m_vector);
   __m128 temp2 = _mm_add_ps(_mm_movehl_ps(temp, temp), temp);
   temp = _mm_shuffle_ps(temp2, temp2, _MM_SHUFFLE(0,0,0,1));
   _mm_store_ss(&f, _mm_sqrt_ss(_mm_add_ss(temp2, temp)));
   return f;
}

Powyższy kod jest najszybszą wersją jaką udało mi się uzyskać. Polega on na obliczeniu kwadratów wszystkich składowych, dodaniu ich do siebie oraz zpierwiastkowanie. Najbardziej pracochłonną częścią jest tutaj wbrew pozorom dodawanie, gdyż kwadrat składowych można załatwić jednym mnożeniem wektorowym. Dodawanie jednak rozbija się na dwie operacje dodawania oraz dwie operacje przemieszania, ponieważ SSE nie oferuje funkcji, która dodawałaby wszystkie elementy pojedynczego rejestru. Do wykonywania operacji przemieszania najczęściej stosuje się funkcje _mm_shuffle(), ponieważ pozwala ona dowolnie rozmieścić elementy w rejestrze. Rozszerzenie SSE umożliwia jednak wykonanie przemieszań za pomocą innych funkcji (_mm_unpackhi_ps(), _mm_unpacklo_ps(), _mm_movehl_ps(), _mm_movelh_ps()), które mieszają elementy tylko w określony sposób. W powyższym kodzie zastosowałem właśnie _mm_unpackhi(), ponieważ funkcja zapisuje elementy x i y na pozycji elementow z i w.

Inną sprawą jest, w jaki sposób kompilator radzi sobie z takim kodem. Otóż posiada on specjalnie zaprogramowane optymalizacje dla tego typu funkcji oraz typu __m128. Kompilator widząc te funkcje zamienia je na wywołania odpowiednich funkcji w kodzie assemblera, przy czym sam zarządza wykorzystaniem rejestrów xmm. Nie trzeba się zatem martwić o ilość zmiennych typu __m128, ponieważ nie wpływa to bezpośrednio na rejestry.