Home / Brudnopis / Optymalizacja kodu aplikacji

Optymalizacja kodu aplikacji 26.10.2011

tagi: php wzorce projektowe wydajność

Często jest tak, że wydajność nie jest naszym absolutnym priorytetem, ale nie raz staje się ważna gdy faktycznie pod tym względem popełniliśmy zbyt wiele zaniedbań. Równie często zdarza się sytuacja, w której jednym z kluczowych wymagań niefunkcjonalnych jest właśnie odpowiednia wydajność. Optymalizacje można dokonywać po stronie serwerów (architektura, konfiguracja serwera, bazy danych itp.) oraz po stronie samej aplikacji (keszowanie dodatkowych elementów, trzymanie cache w pamięci operacyjnej, refaktoryzacja i optymalizacja kodu). W tym krótkim wpisie skoncentruję się na optymalizacji na poziomie kodu aplikacji.

W aplikacji zawsze jest jakieś wąskie gardło, kod który daje największy narzut pod względem czasu wykonania lub zużywania pamięci. Taki kod należy znaleźć i zoptymalizować. Ale w jaki sposób szukać? Należy użyć narzędzia profilującego, takie narzędzie dostarcza nam np. biblioteka xdebug lub Zend Server. Aby zmusić xdebug do zbierania danych na temat czasów wykonywania poszczególnych funkcji w odpalonym skrypcie, należy w php.ini ustawić dyrektywę "xdebug.profiler_enable" na 1. Dyrektywa "xdebug.profiler_output_dir" wskazuje na katalog, w którym te dane będą zbierane. Ok, mamy dane, ale jak je przeglądać? Do przeglądania danych zebranych przez xdebug można wykorzystać np. Webgrind.

Zend Server dostarcza nam narzędzie profilujące, które integruje się z Zend Studio (podobna funkcjonalność do xdebug ale ładniejszy interfejs ;)) oraz Code Tracer, który ma bardzo potężne możliwości i dobrze się sprawdza w analizie kodu pod kątem zużywania pamięci.

Oczywistymi kandydatami na wąskie gardło są funkcje, których wykonanie w sumie zajmuje relatywnie dużo czasu. Gdy feralny kod jest wywoływany sporo razy powinniśmy w miarę możliwości ograniczyć liczbę wywołań oraz ewentualnie dokonać optymalizacji w tym kodzie. Gdy liczba wywołań jest niewielka to pozostaje nam jedynie optymalizacja kodu. Na optymalizację mogą składać się niewielkie kroki, takie jak: zmiana rodzaju pętli (np. z foreach na for), jednokrotne wykonywanie obliczeń i przypisywanie wyniku do zmiennej lokalnej zamiast wielokrotnego wyznaczania tej wartości, zmiana struktury danych, wykorzystanie wydajniejszych wbudowanych mechanizmów języka. Dzięki takim kosmetycznym poprawkom możemy zyskać stosunkowo niedużo. Niekiedy nieoptymalny kod nie poprawimy tymi drobnymi krokami i należy go przepisać na nowo, używając innego, wydajniejszego algorytmu.

Profilując projekt nad którym pracuję dowiedziałem się, że najczęściej wywoływaną funkcją (a zarazem będącą w absolutnej czołówce pod względem czasu wykonywania) jest metoda Doctrine_Configurable::getAttribute() (Doctrine w wersji 1.1, która już nie jest dostępna).

Kod metody:

[PHP]
  1. public function getAttribute($attribute)
  2. {
  3. if (is_string($attribute)) {
  4. $upper = strtoupper($attribute);
  5.  
  6. $const = 'Doctrine::ATTR_' . $upper;
  7.  
  8. if (defined($const)) {
  9. $attribute = constant($const);
  10. $this->_state = $attribute;
  11. } else {
  12. throw new Doctrine_Exception('Unknown attribute: "' . $attribute . '"');
  13. }
  14. }
  15.  
  16. $attribute = (int) $attribute;
  17.  
  18. if ($attribute < 0) {
  19. throw new Doctrine_Exception('Unknown attribute.');
  20. }
  21.  
  22. if (isset($this->attributes[$attribute])) {
  23. return $this->attributes[$attribute];
  24. }
  25.  
  26. if ($this->parent) {
  27. return $this->parent->getAttribute($attribute);
  28. }
  29. return null;
  30. }


Jest ona niepozorna, bo cóż może robić? Zwraca wartość atrybutu, ale po drodze konwertując nazwę atrybutu z ciągu znaków do liczby całkowitej (wykorzystując stałe klasowe) i wywołując rekurencyjnie tą samą metodę rodzica (Chain of responsibility), jeśli atrybut nie jest ustawiony. Metoda ta jest wywoływana strasznie wiele razy (m. in. z powodu rekurencyjnych wywołań), dlatego też ma znaczący wpływ na wydajność. Nasuwają mi się dwie mikro optymalizacje (oprócz makro optymalizacji mającej na celu wyrzucenia tego typu hierarchicznej konfiguracji, co zresztą zostało poczynione w Doctrine 2 ;)) mające na celu optymalizację kodu oraz ograniczenie liczby wywołań:

  • przyjmowanie jako argument tej metody tylko wartości stałych klasowych (brak wsparcia dla nazw atrybutów jako ciąg znaków, który później jest konwertowany)

  • ustawianie wartości atrybutu wartościom zwróconą przez rodzica, jeśli atrybut nie jest ustawiony (w celu ograniczenia liczby wywołań rekurencyjnych)


Pierwsza wspomniana modyfikacja została wprowadzona w wersji Doctrine 1.2 (link do źródła klasy), ale druga nie (być może ze względu na to, że mogła uszkodzić kod).

Podczas profilowania biblioteki PHPPdf wykryłem kilka funkcji, których pojedyncze wywołanie nie zajmowało dużo czasu, ale liczba wywołań tych funkcji (do kilkudziesięciu tysięcy razy podczas jednego żądania - generowanie niezbyt złożonego dokumentu) sprawiała, że funkcje te były stosunkowo kosztowne. O dziwo to były najzwyczajniejsze gettery, które nic nie robiły, poza zwracaniem wartości. Uproszczony kod klasy, o której mowa, przed optymalizacją:

[PHP]
  1. //reprezentacja punktu w przestrzeni dwuwymiarowej
  2. class Point
  3. {
  4. private $x;
  5. private $y;
  6.  
  7. public function __construct($x, $y) { $this->x = $x; $this->y = $y; }
  8.  
  9. public function getX() { return $this->x; }
  10. public function getY() { return $this->y; }
  11.  
  12. public function translate($x, $y)
  13. {
  14. return new self($this->getX() + $x, $this->getY() + $y);
  15. }
  16.  
  17. public function compareYCoord(Point $point)
  18. {
  19. return $this->compare($this->getY(), $point->getY());
  20. }
  21.  
  22. private function compare($value1, $value2) { /* zwraca 1, 0, -1 w zależności od porównania */ }
  23. }


W każdej metodzie klasy Point do składowych odwołuję się pośrednio poprzez metody get*. Profiler wskazał, że przy prostym dokumencie, metody te są wywoływane po ok. 25 tys. razy. Po zastosowaniu refaktoryzacji rozwinięcia metod (odwoływanie się wewnątrz klasy Point do składowych poprzez $this->x zamiast $this->getX()), liczba ich wywołań spadła ponad dwukrotnie, dzięki czemu przeciętny dokument generował się o ok. 1% szybciej. Jeden procent... Nie tak dużo, ale zważając na to, że takich metod w różnych klasach było kilka, zyskałem wielokrotność tego 1%, a dokładniej mówiąc ok. 5% na samych getterach, co już jest przyzwoitym wynikiem. Oczywiście należy mieć na uwadze, że mówimy tutaj o dosyć dużej (wręcz ekstremalnej) liczbie wywołań (kilkadziesiąt / kilkaset tysięcy razy) na jedno żądanie. W projekcie w którym wywołań danej metody na jedno żądanie jest rzędu do kilkuset / kilkutysięcy, taki zabieg jest bezcelowy. Warunkiem wprowadzenia tej mikro optymalizacji jest oczywiście to, że interesująca nas metoda get* nie robi nic, poza zwracaniem wartości, nie ma w niej zaimplementowanego leniwego ładowania wartości, czy też innej logiki. Inną sprawą jest to, że w php 5.4 wywoływanie funkcji zostało wyraźnie zoptymalizowane, przez co zabieg który wyżej opisałem, nie będzie dawał tak widocznych efektów.

W pewnej mądrej książce znalazłem opinię, że nie należy optymalizować zbyt wcześnie, wtedy gdy nie ma rzeczywiście takiej potrzeby. W tej samej książce było stwierdzenie, że dobrze napisany kod ułatwia optymalizację. Z obydwoma zdaniami się zgadzam. Często wąskim gardłem programu jest pamięć, a w szczególności jej brak ;) W dużej części problemy tej natury są związane z tworzeniem zbyt dużej liczby obiektów, nie możemy zapanować nad procesem kreowania obiektów. Z pomocą przychodzą nam wzorce, a w szczególności Factory, Factory Method oraz Flyweight. Nie powinniśmy zbyt wcześnie optymalizować, ale powinniśmy pisać tak, aby ten kod w przyszłości był podatny na ewentualne optymalizacje. Wiedząc, że będzie tworzonych dużo obiektów jakieś klasy lub rodziny klas, powinniśmy się zastanowić na wprowadzeniem wzorców kreacyjnych (Factory lub Factory Method), aby zostawić sobie możliwość zmiany sposobu tworzenia obiektów, np. poprzez wprowadzenie lub (w dalszej perspektywie) zrezygnowanie ze wzorca Flyweight. Przypominam, że wzorca Flyweight należy korzystać tylko dla obiektów niemodyfikowalnych.

Każdy kij ma dwa końce, zastosowanie wzorców Factory Method + Flyweight w złym miejscu może mieć spory, negatywny wpływ na wydajność (zużycie pamięci). Gdy procent ponownego wykorzystania obiektów przy zastosowanym wzorcu Flyweight jest niewielki (tylko kilka/kilkanaście procent obiektów jest wielokrotnie używanych), wiele obiektów jest przetrzymywanych w pamięci przez długi czas bezcelowo, przez co jesteśmy świadkami wycieku pamięci. Początkowo wcześniej przytoczona klasa Point implementowała wzorzec Flyweight poprzez metodę wytwórczą getInstance. Jednakże ponowne wykorzystanie obiektów było niskie, więc przedefiniowałem metodę getInstance aby zawsze zwracała nowy obiekt. Efektem tego było zmniejszenie zużycia pamięci o ok. 5-30% w zależności od wielkości dokumentu (im większy, tym zysk większy) oraz minimalny zysk pod względem czasu wykonywania (<0.5%). Należy jednak pamiętać, że dobrze i w dobrym miejscu zaimplementowana para wzorców Factory Method oraz Flyweight może dać bardzo duży zysk, ja nie raz z niego korzystałem ;)

PHP jest uzbrojony w garbage collector (zbieracz nieużytków), ale czy każdy zdaje sobie sprawę jak to ustrojstwo działa? Teoria mówi, że obiekt zostanie usunięty z pamięci i wtedy zostanie wywołany jego destruktor, gdy zostanie usunięta ostatnia referencja do obiektu, czyli wszystkie referencje do obiektu wypadną poza zakres widoczności lub też ręcznie usuniemy te referencje. Pętle w php nie mają swojego zakresu zmiennych, ale funkcje już tak. Gdy obiekt zostanie utworzony w funkcji i nie zostanie utworzona żadna referencja do niego z poza zakresu tej funkcji (np. poprzez przypisanie do składowej obiektu z szerszego zakresu niż ta funkcja) to obiekt zostanie zwolniony zaraz po wywołaniu tej funkcji.

Na początek definicja dwóch klas, które będę używał w kilku kolejnych przykładach:

[PHP]
  1. class A
  2. {
  3. public $b;
  4.  
  5. public function __destruct()
  6. {
  7. echo 'obiekt A unicestwiony | ';
  8. }
  9. }
  10. class B
  11. {
  12. public $a;
  13.  
  14. public function __destruct()
  15. {
  16. echo 'obiekt B unicestwiony | ';
  17. }
  18. }


Rezultatem poniższego kodu będzie: "obiekt A unicestwiony | obiekt B unicestwiony | kod za funkcją someFunction | ".

[PHP]
  1. function someFunction()
  2. {
  3. $a = new A();
  4. $b = new B();
  5.  
  6. //jakaś logika
  7. }
  8.  
  9. someFunction();
  10. echo 'Kod za funkcją someFunction | ';


W funkcji utworzyłem dwa obiekty, które istnieją tylko w zakresie tej funkcji, więc od razu po wyjściu z tej funkcji, obiekty te zostały zwolnione - logiczne.

Rezultatem poniższego kodu będzie: "obiekt A unicestwiony | obiekt B unicestwiony | kod za funkcją someFunction | "

[PHP]
  1. function someFunction()
  2. {
  3. $a = new A();
  4. $b = new B();
  5. $a->b = $b;
  6. $b->a = $a;
  7.  
  8. //jakaś tajemna logika
  9. }
  10.  
  11. someFunction();
  12. echo 'kod za funkcją someFunction | ';


Hmm, wróć, coś jest nie tak... Dostaję "kod za funkcją someFunction | obiekt A unicestwiony | obiekt B unicestwiony | ". Oj tam, oj tam, pewnie gc był przez moment na urlopie, spróbuję jeszcze raz... Hmm, a jednak nie był, albo ten urlop się wydłuża...

Dzieje się tak dlatego, gdyż istnieje cykliczna zależność między obiektami. Po wyjściu z funkcji mimo, że nie istnieją już żadne referencje do obiektów $a oraz $b, gc nie zwalnia ich. Gc ocenia, czy można usunąć obiekt $a, widzi że istnieje referencja do tego obiektu w innym obiekcie. Sprawdza więc ten obiekt (obiekt $b) pod tym samym kątem. Referencja do niego zawarta jest w obiekcie $a, stwierdza więc, że obiektów nie można zwolnić, gdyż istnieją inne obiekty przechowujące do nich referencje, których nie można usunąć - następuje wyciek pamięci (testowane na PHP 5.3.5). Gc nie jest na tyle mądry, aby wykryć taki cykl.

Jak rozwiązać ten problem? Trzeba przerwać cykl zależności. Ten problem występuje również przy większym cyklu, np. a -> b -> c -> a. Aby go przerwać, należy usunąć referencję $c->a. W naszym wcześniejszym przykładzie wystarczy przypisać null do $a->b lub $b->a.

[PHP]
  1. function someFunction()
  2. {
  3. $a = new A();
  4. $b = new B();
  5. $a->b = $b;
  6. $b->a = $a;
  7.  
  8. //jakaś logika
  9.  
  10. $b->a = null;
  11. }
  12.  
  13. someFunction();
  14. echo 'kod za funkcją someFunction | ';


Rezultat powyższego kodu jest zgodny z oczekiwaniami: "obiekt A unicestwiony | obiekt B unicestwiony | kod za funkcją someFunction | "

Ten problem może być szczególnie dotkliwy np. przy implementacji wzorca Composite. Gdy tworzymy lokalną, dużą strukturę złożoną (dzieci mają referencje do rodzica) stosunkowo głęboko w stosie wywołań, obiekty będą żyły swoim własnym życiem do końca wykonywania skryptu. Aby temu zapobiec można zaimplementować metodę, która zwalnia referencje, przerywając tym samym cykl zależności.

[PHP]
  1. class Composite
  2. {
  3. public $parent;
  4. public $children = array();
  5.  
  6. //metoda przerywająca cykl zależności
  7. public function free()
  8. {
  9. $this->parent = null;
  10.  
  11. foreach($this->children as $child)
  12. {
  13. $child->free();
  14. }
  15. }
  16. }


To co w tym wpisie opisałem, to wierzchołek góry lodowej. Skupiłem się tylko na kilku aspektach optymalizacji samego kodu aplikacji, nie poruszając optymalizacji związanych z serwerami, konfiguracją środowiska, bazą danych, akceleratorami, cache itp. Nawet najbardziej optymalny kod uruchomiony na źle skonfigurowanym serwerze będzie sprawiał problemy podobnie jak mało wydajny kod uruchomiony na dobrze skonfigurowanym środowisku.

Dzięki za lekturę.

Komentarze (6)

#1 filip, 27.10.2011 08:47

Odnośnie pętli, IMO użycie foreach jest najbardziej wydajne.

#2 Anonim, 27.10.2011 10:50

A ja słyszałem (czytałem), że foreach jest mniej wydajne pod względem pamięci bo operuję na dodatkowej kopi tablicy

#3 Anonim, 27.10.2011 11:15

Chyba że użyjesz referencję. Fakt że wtedy trzeba pamiętać że po przejściu foreach zmienna nadal będzie referencją.

#4 EternalSH, 28.10.2011 18:09

To może przesiądźcie się wszyscy na bytecode, albo chociaż na assemblera, bo szybszy? Optymalizacja też ma swoje granice.

#5 Peter (www) , 28.10.2011 21:38

Nie chcę nic mówić, ale kompilacja do kodu bajtowego (np. dzięki wykorzystaniu apc, czy e-acceleratora) to absolutna podstawa. Niektóre przykłady mogą się wydawać absurdalne (np. ten z unikaniem wywoływania metod get* w niektórych przypadkach), ale przy odpowiednich warunkach które w moim przypadku były spełnione (liczba wywołań metod), ten zabieg wyraźnie pomógł, a jakość i czytelność kodu się nie pogorszyła. To nie znaczy, że zawsze należy tak robić, co zresztą zostało wyraźnie napisane we wpisie. Ciekawszą rzeczą niż pętle (o których było 7 słów których funkcją było podanie najprostszego przykładu + Wasze wpisy), są wycieki pamięci w php i sposoby radzenia sobie z nimi.

#6 Anonim, 29.10.2011 19:39

Tak jak napisane jest w artykule, optymalizujemy wtedy, kiedy na prawdę przychodzi na to pora. Typów aplikacji jest mnóstwo a tracić niepotrzebnie czas a co z tym idzie pieniądze - jest niepotrzbne. Trzeba się cieszyć, czerpać satysfakcję z tego co się robi a nie pocić się nad kodem, którego jakość (czyt. wydajność) nie jest i nie musi być adekwatna do korzyści jakie ma przynieść korzystanie z tak napisanej aplikacji. Oczywiście zgadzam się, że wydajne aplikacje są potrzbne ale w granicach rozsądaku i zapotrzebowania.

Dodaj komentarz