Systemy operacyjne

Już od jakiegoś czasu zanoszę się z wrzuceniem programów, które musiałem przygotowywać na zajęcia laboratoryjne z Systemów operacyjnych. Oczywiście wiadomo że “chęci największe, gdy możliwości najmniejsze”, więc wrzucam je dopiero teraz, gdy mam dostęp do komputera. Wszystko przez firmę ASUS, która zapewniła mi odwyk od komputera dzięki długoterminowej naprawie notebooka (niech będą przeklęci! :P).

Wracając do tematu, programy które należało przygotowywać miały symulować działania algorytmów takich jak:
– planowanie dostępu do procesora
– planowanie dostępu do dysku twardego
– pamięć wirtualna

Programy te nie są zbyt wyszukane, ale spełniają założenia. Oczywiście na przekór temu, czego używamy na wydziale (Java), swoje programy pisałem w C++. Programy są “prawie” multiplatformowe. Aby ruszyły pod linuksem wystarczy zamienić kilka funkcji i nagłówków na ich odpowiedniki.

Zadanie 1
Zadanie 2
Zadanie 3
Zadanie 4

AutomaticHandle ciąg dalszy

Jak się okazuje nic nie jest na początku idealne. Dzięki uwagom Revo udało mi się usprawnić szablon automatycznych uchwytów. Revo poradził mi dodanie dodatkowego wskaźnika na licznik odwołań do danego zasobu. Dzięki temu możliwe było usunięcie jednej funkcji wirtualnej, a druga została sprowadzona do roli sprzątaczki, która zostaje wywołana tylko w momencie, gdy licznik osiąga wartość 0. Dodatkowo dodałem sprawdzenie czy uchwyt nie jest zerowy przed odwołaniem się do wskaźników.

Poniżej znajduję się kod poprawionej klasy szablonowej AutomaticHandle:

typedef unsigned int Dword;
 
template< typename TAG>
class AutomaticHandle
{
public:
	// Podstawowy konstruktor
	AutomaticHandle(NLib::Dword handle, MHandleMgr* manager, NLib::Dword* resCounter) : m_handle(handle), m_manager(manager), m_resCounter(resCounter)
	{ if(m_handle) ++(*m_resCounter); }
	// Konstruktor kopiujący
	AutomaticHandle(const AutomaticHandle& src) : m_manager(src.m_manager), m_handle(src.m_handle), m_resCounter(src.m_resCounter)
	{ if(m_handle) ++(*m_resCounter); }
	// Destruktor
	~AutomaticHandle()
	{
		if(m_handle)
		{ if(!(--(*m_resCounter))) m_manager->ReleaseResource(m_handle); }
	}
	// Operator przypisania
	const AutomaticHandle& operator =(const AutomaticHandle& src)
	{
		if(m_handle)
		{ if(!(--(*m_resCounter))) m_manager->ReleaseResource(m_handle); }
		m_handle = src.m_handle;
		if(m_handle) ++(*(m_resCounter = src.m_resCounter));
		return src;
	}
	// Operator równości
	bool operator ==(const AutomaticHandle& src)
	{ return m_handle == src.m_handle; }
	// Operator różności
	bool operator !=(const AutomaticHandle& src)
	{ return m_handle != src.m_handle; }
	// Operator Dword
	operator NLib::Dword() const
	{ return m_handle; }
	// Operator bool
	operator bool() const
	{ return !!m_handle; }
 
private:
	// Uchwyt
	NLib::Dword m_handle;
	// Wskaźnik do managera
	MHandleMgr* m_manager;
	// Wskaźnik do licznika zasobu
	NLib::Dword* m_resCounter;
};

Kolejną klasą jest AutoHandleMgr, czyli klasa implementująca obsługę automatycznych uchwytów. Aktualnie tylko trzy funkcje są dostępne klasie pochodnej, w tym jedna dla uchwytów. Klasa ta posiada jedną funkcję abstrakcyjną, w której po zdefiniowaniu w klasie pochodnej należy usuwać odpowiednie zasoby zgodnie z przekazanym indeksem.

Oto jej definicja:

typedef unsigned int Dword;
 
class AutoHandleMgr
{
	// Unia do wyciągania informacji z uchwytu
	enum
	{
		// Rozmiary pól
		MAX_BITS_INDEX = sizeof(NLib::Dword) * 4,
		MAX_BITS_MAGIC = sizeof(NLib::Dword) * 4,
		// Maksymalne wartości pól
		MAX_INDEX = (1 << MAX_BITS_INDEX) - 1,
		MAX_MAGIC = (1 << MAX_BITS_MAGIC) - 1
	};
 
public:
	// Funkcja do zwracania uchwytu
	void ReleaseResource(NLib::Dword handle)
	{
		NAssert(GetIndex(handle) < m_magicValues.size(), "Wrong Handle");
		NAssert(CheckMagic(handle), "Wrong handle");
		DeleteHandleAt(GetIndex(handle));
		ReleaseResourceByIndex(GetIndex(handle));
	}
 
protected:
	// Funkcja tworzy nowy uchwyt i go zwraca
	// Index zawarty w uchwycie odpowiada indeksowi w tablicy z danymi
	// w odziedziczonej klasie
	NLib::Dword CreateNewHandle()
	{
		NLib::Dword handle;
		static NLib::Dword s_autoMagic = 0;
		if(++s_autoMagic > MAX_MAGIC) s_autoMagic = 1;	// 0 oznacza pusty uchwyt
		// Jeżeli nie ma wolnych miejsc to tworze nowy index
		if(m_freeSlots.empty())
		{
			handle = m_magicValues.size() << (MAX_BITS_INDEX - 1);
			m_magicValues.push_back(s_autoMagic);
		}
		else
		{
			handle = m_freeSlots.back() << (MAX_BITS_INDEX - 1);
			m_magicValues[m_freeSlots.back()] = s_autoMagic;
			m_freeSlots.pop_back();
		}
		handle |= s_autoMagic;
		return handle;
	}
	// Funkcja zwraca index
	NLib::Dword GetIndex(NLib::Dword handle)
	{ return (handle >> (MAX_BITS_INDEX - 1)) & MAX_INDEX; }
 
private:
	// Funkcja kasuje wskazany uchwyt
	void DeleteHandleAt(NLib::Dword index)
	{	m_freeSlots.push_back(index);
		m_magicValues[index] = 0; }
	// Funkcja zwraca część magiczną
	NLib::Dword GetMagic(NLib::Dword handle)
	{ return handle & MAX_MAGIC; }
	// Funkcja sprawdzająca wartośc magic
	bool CheckMagic(NLib::Dword handle)
	{	NAssert(GetIndex(handle) < m_magicValues.size(), "Wrong Handle");
		return GetMagic(handle) == m_magicValues[GetIndex(handle)]; }
 
private:
	// Funkcja dekrementująca licznik w zasobach
	virtual void ReleaseResourceByIndex(NLib::Dword index) = 0;
 
private:
	// Vector zawierający magiczne wartości
	std::vector m_magicValues;
	// Vector zawierający indeksy z wolnymi miejscami
	std::vector m_freeSlots;
};

W przypadku tego menadżera uchwyty muszą być usunięte przed nim, inaczej pojawią się błędy z dostępem do pamięci.

Refactor silnika + automatyczne uchwyty

Właśnie mija tydzień od zakończenia dwutygodniowego pobytu w domu w czasie końcówki sesji i przerwy egzaminacyjnej. W tym czasie rozpocząłem przepisywania mojego “silnika” od nowa, tym razem w oparciu o interfejsy. Są one bardzo poręczne, gdy chce się ukryć implementację klas wewnątrz statycznych, bądź dynamicznych bibliotek. Głównym założeniem przy projektowaniu drugiej wersji NIne było właśnie ukrycie implementacji wewnątrz statycznej biblioteki .lib. Dzięki takiemu postępowaniu biblioteka wygląda ładnie i pozwala ładnie rozszerzać własną funkcjonalność.

Drugim zagadnieniem poruszanym w tej notce są uchwyty, które same zarządzają licznikiem danego zasobu.
Z początku implementację uchwytów opierałem na artykule znajdującym się w książce “Game Programming Gems”, jednak problemem okazało się zmuszenie tychże uchwytów do sprzątania po sobie zasobów. Na szczęście rozwiązanie okazało się proste, ponieważ wystarczyło umieścić w klasie uchwytu wskaźnik do menadżera zasobów.

Poniżej znajduje się moja implementacja takiego uchwytu:

template< typename TAG>
class AutomaticHandle
{
public:
	// Podstawowy konstruktor
	AutomaticHandle(Dword handle, MHandleMgr* manager) : m_handle(handle), m_manager(manager)
	{}
	// Konstruktor kopiujący
	AutomaticHandle(const AutomaticHandle& src) : m_manager(src.m_manager), m_handle(src.m_handle)
	{ m_manager->Copy(src.m_handle); }
	// Destruktor
	~AutomaticHandle()
	{ m_manager->Release(m_handle); }
	// Operator przypisania
	const AutomaticHandle& operator =(const ManagedHandle& src)
	{ m_manager->Release(m_handle);
	  m_handle = src.m_handle;
	  m_manager->Copy(src.m_handle);
	  return src; }
	// Operator Dword
	operator Dword() const
	{ return m_handle; }
	/*
	...
	*/
 
private:
	// Uchwyt
	Dword m_handle;
	// Wskaźnik do managera
	MHandleMgr* m_manager;
};

Jak widać klasa zawiera dodatkową zmienną – wskaźnik na klasę menadżera ustawiany przy konstrukcji. Klasa menadżera musi zawierać używane metody, które wewnątrz zarządzają licznikiem odwołań na dany zasób, a w momencie jego spadku do 0, likwidować dany zasób.

Interfejs IMesh, klasa Mesh i loadery.

Nawiązując do poprzedniej notki chcę tylko zamieścić jak wyglądają klasy, które napisałem i czemu są takie fajne:

// Interfejs
class IMesh
{
public:
	/*
	...
	*/
	// Funkcja aktualizująca mesha
	virtual void SetMesh(LPD3DXMESH mesh) = 0;
	// Funkcja dodająca materiał do wektora
	virtual void AddMaterial(const Material&amp; mat) = 0;
	// Funkcja zwraca materiał o podanym indeksie
	virtual Material GetMaterial(Dword index) = 0;
	/*
	...
	*/
};
 
// Główna klasa Mesh
class Mesh : public IMesh
{
public:
	// Konstruktor
	Mesh();
	// Destruktor
	~Mesh();
 
public:
	/* Funkcje używane do rysowania modelu
	...
	*/
 
protected:
	/*
	...
	*/
	// Funkcja aktualizująca mesha
	virtual void SetMesh(LPD3DXMESH mesh) = 0;
	// Funkcja dodająca materiał do wektora
	virtual void AddMaterial(const Material&amp; mat) = 0;
	// Funkcja zwraca materiał o podanym indeksie
	virtual Material GetMaterial(Dword index) = 0;
	/*
	...
	*/
};
 
// Przykład loadera
class X_file
{
public:
	// Konstruktor
	X_file(IMesh* mesh) { m_mesh = mesh; }
	// Funkcja ładująca
	RESULT LoadMesh(String filename)
	{
		LPD3DXMESH mesh;
		/*
		... cuda-niewidy...
		*/
		m_mesh->AddMesh(mesh); // Wywołanie funkcji interfejsu
	}
 
private:
	// Obiekt na którym działam
	IMesh* m_mesh;
};

Nie lubię się rozpisywać. Jak można zauważyć dzięki interfejsowi IMesh można załadować dowolny model do klasy Mesh, ponieważ są publiczne wszystkie funkcje umożliwiające to zadanie, natomiast klasa Mesh posiada publiczne funkcje służące tylko i wyłącznie do rysowania modelu. Dzięki takiemu rozwiązaniu kod jest przejrzysty.

Programowania ciąg dalszy

Minęło trochę czasu od mojej ostatniej notki, ale co poradzić, studia są trochę wymagające, więc trzeba się czasem pouczyć, zwłaszcza, że jest to styczeń czyli ostatni miesiąc przed sesją. W momencie, gdy piszę tę notkę, upływa właśnie pierwszy tydzień tej najbardziej nielubianej przez studentów pory roku :). Wracając jednak do tematyki tego devBloga należałoby powiedzieć czym ciekawym zajmowałem się przez ten czas, przecież nie samą nauką człowiek żyje.

Postanowiłem dodać do mojego silnika możliwość wczytywania różnego rodzaju plików z meshami. Na początek napisałem prostą klasę opakowującą interfejs ID3DXMesh, w której umieściłem strukturę przechowującą materiały danego mesha. Następnie napisałem funkcje automatyzujące odczyt i zapis plików w formacie .x. Schody zaczęły się, gdy chciałem dodać obsługę dodatkowego formatu – zdecydowałem, że będą to pliki .obj (Object files). OBJ są to pliki tekstowe, przechowujące informacje o całym meshu (fajna specyfikacja tego formatu znajduje się tutaj), z którymi w parze są jeszcze pliki .mtl, gdzie trzymane są informacje o materiałach dla danego subsetu (więcej info tutaj). Najważniejszą sprawą w całym tym przedsięwzięciu było napisanie parsera dla tego formatu. Na szczęście z pomocą przychodzi Microsoft DirectX SDK, w którym znajduje się przykładowy program (“MeshFromOBJ”) wczytujący pliki OBJ.

Pisząc klasy obsługujące różne modele chciałem, aby istniała możliwość konwersji między tymi formatami. Powstała więc koncepcja dwóch rodzajów klas – klasa Mesh oraz klasy obsługujące formaty. Klasy te mają być od siebie w jak największym stopniu niezależne, czyli ma istnieć możliwość dodawania funkcjonalności do klasy Mesh bez konfliktu z klasami wczytującym oraz musi istnieć możliwość pisania dodatkowych klas obsługujących różne formaty. Szczerze mówiąc, pierwszy raz zetknąłem się z takim problemem, ale na szczęście udało mi się znaleźć proste i eleganckie rozwiązanie. Mianowicie zastosowałem prosty interfejs, po którym dziedziczy klasa Mesh, z tym wyjątkiem, że klasa interfejsu posiada funkcje abstrakcyjne public, a klasa Mesh definiuje te funkcje jako protected, bądź private. Dzięki takiemu podejściu klasy korzystające z interfejsu, mogą operować na tych funkcjach, a klasa Mesh ma te funkcje ukryte :).

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.

Fenomen SSE

Na warsztacie pojawił się ostatnio bardzo ciekawy temat dotyczący wykorzystania instrukcji procesora przetwarzających dane potokowo. Jak wiadomo każdemu programiście, każdy procesor wspiera różny zestaw funkcji (ponieważ są one dołączane do poprzednich). O to lista tych rozszerzeń:

  • MMX (operacje na liczbach całkowitych)
  • SSE (operacje zmiennoprzecinkowe pojedynczej precyzji)
  • SSE2 (operacje zmiennoprzecinkowe podwójnej precyzji)
  • SSE3 (konwersje i dodawanie w poziomie)
  • SSSE3 (dodatkowe działania na liczbach całkowitych)
  • SSE4 (dodatkowe instrukcje wektorowe, w tym upragniony iloczyn skalarny dwóch wektorów)

O ile rozszerzenie MMX korzysta z rejestrów koprocesora (co czyni go niewydajnym, ponieważ przełączanie między MMX a koprocesorem trwa trochę czasu), o tyle pozostałe rozszerzenia wprowadzają dodatkowe rejestry xmm (8 dla procesorów 32-bitowych oraz 16 dla procesorów 64-bitowych) po 128 bitów każdy. Rejestry te są traktowane tak jak jest to wymagane do danej funkcji, zatem dla liczb całkowitych jest całe 128-bitowe słowo lub można dzielić na pół, aż do uzyskania 16 osobnych bajtów. W przypadku liczb zmiennoprzecinkowych są to 4 liczby typu float lub 2 liczby typu double.

Dostępne funkcje pozwalają wykonywać obliczenia zarówno skalarnie jak i wektorowo. Oczywiście najlepiej jest gdy przeprowadzane operacje są głównie wektorowe, ponieważ pozwala to zaoszczędzić mnóstwo cennych cykli procesora. Największy problem to oczywiście pisanie operacji przy użyciu tych funkcji, ponieważ wymagana jest podstawowa znajomość Assemblera, chociaż dzięki tzw. funkcjom intrinsics wcale nie trzeba robić w kodzie wstawek asemblerowych, gdyż funkcje robią to zamiast programisty.

Przyznam szczerze, mnie również  bardzo interesują te funkcje, dlatego postanowiłem z ich pomocą napisać własną implementacje wektorów i macierzy (również dlatego, że te w D3DX, nie mają tego wsparcia). Jest to dość żmudna robota, ponieważ trzeba jakoś zagwarantować to, żeby moje wektory ruszyły na starszych komputerach (a nuż trzeba będzie), ale mimo wszystko uważam, że warta zachodu. Mogę już powiedzieć, że funkcja obliczająca długość wektora, dzięki instrukcją z rozszerzenia SSE, jest szybsza od wersji z D3DX.

Paczka

Właśnie opublikowałem paczkę dotychczasowej pracy. Nie jest tego dużo, ale zawsze coś. Muszę przyznać, że ostatnio pogrywam sobie w L2 więc czasu trochę mniej. Paczka zawiera prostą aplikację wykorzystującą mój menadżer scen, dzięki któremu można przełączać dowolnie między scenami. Więcej w pliku ReadMe.
NineTester

Pobierz

Optymalizacje

Kod który piszemy nigdy nie jest idealny, zawsze można coś poprawić, przyspieszyć. Dowiedziałem się również, że czasami jednak warto zostawiać kod taki jak jest, np. pętle for:
for(int i = 0; i < cos; i++)
ponieważ kompilator potrafi to zoptymalizować sam, a wszelkie próby działań na własną rękę mogą tylko zaciemnić mu kod. Postanowiłem poszukać co podoba się kompilatorowi msvc i natrafiłem na dość ciekawą stronę: link. Nie jest to może dokładnie to czego szukałem, ale jest to coś, co może się bardzo przydać. Postaram się zagłębić w tę lekturę w chwili wolnego czasu.