czwartek, 22 lipca 2010

5 trików dla wydajności w C++

Dzisiaj 2 posty, promocja z okazji święta ;) Najpierw pięć prostych trików, aby programy w C++ były bardziej wydajne - może nie wszyscy znają wszystkie z nich ;)


1. Przekazywanie argumentów przez referencję

Chyba najstarszy trik świata ;) Zamiast pisać:

void f(A a);

piszemy

void f(const A& a);

i tyle ;) Dlaczego jest to takie przyspieszenie? Ponieważ teraz argument jest przekazywany przez referencję - przekazywany jest jego adres, a nie on sam w całości. Dzięki temu mamy jedno wywołanie konstruktora kopiującego mniej ;)

Modyfikator const gwarantuje nam, że nie zmodyfikujemy przez przypadek argumentu. Można go umieścić również po typie argumentu, znaczenie jest to samo - przekazywana jest referencja do stałego obiektu:

void f(A const& a);

Jednak lepszy jest chyba const na samym początku - raz, że od razu widać, co jest const. Dwa, pisząc go po typie można łatwo popełnić błąd i napisać go po ampersandzie (np. dopisując):

void f(A& const a);

Dobry kompilator zgłosi nam ostrzeżenie, ponieważ powyższa konstrukcja nie ma sensu i modyfikator const jest pomijany. O co chodzi? Porównajmy dokładnie oba typy argumentów:

void f(A const& a); - OK
void f(A& const a); - ŹLE

Różnica to referencja do typu A const - czyli referencja do stałego obiektu w pierwszym przypadku i zaznaczenie, że referencja A& jest stała w drugim. Druga konstrukcja nie ma sensu, ponieważ referencje z definicji są stałe. Takie proste, a jednak jakaś mała pułapka się znalazła.

2. Odpowiednie słowa kluczowe

Mowa o słowach kluczowych register i inline. Oba stanowią tylko sugestię dla kompilatora, który zazwyczaj i tak wie lepiej ;)

register - poprzedza deklaracje zmiennej. Oznacza, że zmienna ta powinna być przechowywana w rejestrze, jeżeli jest to możliwe. Przechowywanie w rejestrze daje oczywiście znaczące przyspieszenie w porównaniu do zmiennych w pamięci operacyjnej ;) Tyle teoria. Jak czytamy na stronach MSDN to np. Visual C++ pominie nasze prośby o umieszczanie zmiennych w rejestrach jeżeli dostanie przełącznik /Oe. Więcej: http://msdn.microsoft.com/en-us/library/482s4fy9%28VS.90%29.aspx

inline - ten modyfikator może poprzedzić deklarację funkcji. Wywołanie takiej funkcji nie będzie wtedy standardowym wywołaniem, a kod instrukcji zostanie przepisany w miejscu wywołania. Zwiększy to objętość kodu wynikowego, ale oszczędzi jedno wywołanie funkcji ;) Jak widać kandydatami są małe, jedno-linijkowe funkcje, np.

inline double average(double a, double b)
{
  return (a + b) * 0.5;
}


Oczywiście funkcja zostanie tak rozwinięta jeżeli kompilator pozytywnie rozpatrzy naszą prośbę :P Co ciekawe, czasami sam wpada na to, że takie "jednolinijkowce" można rozwinąć ;) Jeżeli chcemy w ogóle pominąć analizę kompilatora i zdać się na siebie, to w Visual C++ służy do tego słowo kluczowe __forceinline.
Więcej: http://msdn.microsoft.com/en-us/library/z8y1yy88%28VS.90%29.aspx


3. Mnożenie zamiast dzielenia

Zaraz, dlaczego funkcja powyżej to

inline double average(double a, double b)
{
  return (a + b) * 0.5;
}


a nie

inline double average(double a, double b)
{
  return (a + b) / 2;
}


? To jest dopiero ciekawy trik - tam gdzie mamy dzielić - mnożymy (oczywiście chodzi o liczby zmiennoprzecinkowe)! Operacja mnożenia jest 10 razy szybsza od operacji dzielenia, np. poniższy kod

double s = 0;
for (int i = 0; i < 1000000000; ++i) {
  s += i / 100.0;
}


po jednej prostej zmianie:

double s = 0;
for (int i = 0; i < 1000000000; ++i) {
  s += i * (1 / 100.0);
}


Zacznie działać 10 razy szybciej w Visual C++. Kompilator jak widać nie jest taki sprytny, żeby to zoptymalizować ;) Wynika to oczywiście z tego, iż operacja dzielenia już na poziomie rozkazów procesora jest bardziej skomplikowana. W podstawówce też najpierw się mnożyło, dopiero potem dzieliło.


4. ++i nad i++

Dalej analizujemy powyższy kod. Dlaczego

for (int i = 0; i < 1000000000; ++i)

a nie

for (int i = 0; i < 1000000000; i++)

OK, racja, dla typu int nie ma żadnej różnicy w kodzie wynikowym (sprawdzałem w g++ i Visual C++). Ale pisanie w ten sposób wyrobi nam dobry nawyk, bo co dla typów podstawowych nie gra różnicy jest jednak kluczowe w przypadku iteratorów. Dlaczego? i++ przesuwa iterator do przodu, ale jednocześnie zwraca wartość. A żeby zwrócić wartość musi zostać wywołany konstruktor kopiujący - skopiuje on wartość, która nie zostanie potem wykorzystana. Konstruktor kopiujący to nasz wróg i trzeba go eliminować ;)


5. throw()

Do deklaracji funkcji możemy dodawać specyfikację wyjątków - jest to lista typów wyjątków, jakie może rzucić dana funkcja. I tak funkcja o deklaracji:

void f() throw(...);

może rzucić dowolny wyjątek, natomiast z taką deklaracją:

void g() throw();

funkcja żadnego wyjątku nie może (a właściwie nie powinna) rzucić. Na czym polega przyspieszenie? Ponieważ dajemy znak, że dana funkcja nie rzuca wyjątków, to kompilator może ja zoptymalizować pod tym kątem. Przede wszystkim nie są dodawane dodatkowe instrukcje i informacje potrzebne przy łapaniu i przekazywaniu wyjątków, a także odwijaniu stosu po rzuconym wyjątku.

Optymalizacje te komplikują jedną sytuację - kiedy nasza funkcja void g() jednak rzuci wyjątek. Wtedy program skompilowany np. w Visual C++ zachowa się w nieokreślony sposób.
Kompilator ten ostrzeże nas o sytuacji, kiedy definicja funkcji ze specyfikacją throw() wykorzystuje instrukcję throw, ale np. takie coś:

void g() throw(int)
{
  throw 42;
}

void f() throw()
{
  std::cout << "Hi!" << std::endl;
  g();
}


przez Visual C++ przejdzie bez problemów. Może przydałby się warning, że wykorzystujemy funkcję, która może rzucać wyjątki. Chociaż wtedy najlepiej wszystkie deklaracje funkcji powinniśmy poszerzyć o specyfikację wyjątków - co samo w sobie jest dobrą praktyką.

Więcej: http://msdn.microsoft.com/en-us/library/wfa0edys%28VS.90%29.aspx

2 komentarze:

  1. Super dzięki ,mój Snake w konsolce działa teraz około 8% wydajniej!

    OdpowiedzUsuń
    Odpowiedzi
    1. A w jaki sposób kolega porusza snake-iem? API czy zwykłe getch()?

      Usuń