Optymalizacje, jakie implementuję w OPT, dotyczą przede wszystkim głównej klasy parsera oraz kompilatora. Pierwsza z nich, jako część kodu ładowana każdorazowo, ma narzucone bardzo wyśrubowane wymagania. Nie ma tutaj wyszukanych algorytmów, dlatego przyspieszanie działania dotyczy:
- Zmniejszania objętości kodu.
- Minimalizacji odwołań do dysku twardego.
- Wykorzystaniem najwydajniejszych elementów języka.
Chodzi o to, aby załadowanie biblioteki było jak najmniej obciążające dla interpretera, a w konsekwencji używającego ją skryptu.
Nieco inaczej sprawa wygląda z kompilatorem, który ładowany jest tylko okazjonalnie, zatem może być obszerny i rozbity na więcej składowych (aczkolwiek bez zbędnej przesady). Tutaj kluczową rolę zaczynają odgrywać limity ustawione w konfiguracji PHP, gdyż przy szczególnie złych wiatrach może się zdarzyć, że kompilator zużyje wszystkie dostępne zasoby. Niestety, powiększona funkcjonalność kosztuje, a skoro użytkownicy pragnęli mieć parsowanie także XML-owych znaczników, muszą liczyć się z tym, że dla każdego z nich trzeba teraz tworzyć obiekt węzła i dla każdego z nich trzeba wykonać trochę prac przygotowawczych. Moją rolą jest tutaj uczynienie tego zużycia tak małego, jak to tylko jest możliwe.
Elementy, które mogą się wyczerpać podczas używania kompilatora to:
- Stos
- Pamięć operacyjna
Wykorzystywane algorytmy, z racji operowania na strukturze zwanej drzewem, korzystają dość mocno z rekurencji, przez co liczba aktualnie wykonywanych funkcji i metod szybko rośnie. W PHP domyślnym limitem narzuconym na głębokość rekurencji jest 64 i do pracy z OPT najlepiej zostawić go na takim poziomie, a już na pewno nie bawić się w jego zmniejszanie do jakichś śmiesznych wartości pokroju "16", co zresztą zaszkodziłoby nie tylko OPT, ale i wielu innym skryptom :). Każdą rekurencję można jednak zastąpić iteracyjną wersją, która może i nie zużywa mniej pamięci, ale przynajmniej jest niezależna od wspomnianego limitu. Odpowiednie przeróbki wprowadzone są już teraz do wielu metod, które zamiast wywoływać się rekurencyjnie, korzystają np. z kolejek. Najgorzej sprawa wygląda z dwoma algorytmami:
- Przetwarzanie drzewa przez procesory instrukcji
- Kompilacja wyrażeń
W pierwszym z nich mamy następującą sytuację: odnajdujemy węzeł jakiejś instrukcji i musimy go przetworzyć. Kierujemy go więc do odpowiedniego procesora (klasa pochodna od optInstruction), on generuje do niej kod PHP i decyduje, co zrobić z dziećmi danego węzła. Zazwyczaj decyduje się na ich obróbkę. Tu wkracza wywołanie rekurencyjne i cały proces powtarza się od początku. Trudności to rozrzucenie całego algorytmu po mnóstwie klas, które mogą być na dokładkę oficjalnie rozbudowywane przez zainteresowanych programistów. Co więcej, niektóre instrukcje wymagają, aby przetwarzanie rozpoczęło się natychmiast wraz z wywołaniem metody przetwarzającej, ponieważ od tego, jaki kod PHP wygenerują sobie potomkowie, zależy dalsza obróbka ich rodzica. Eliminuje to w oczywisty sposób kolejkę. Stworzenie eleganckiego zamiennika byłoby proste, gdybyśmy mieli dostęp do programowania niskopoziomowego, lecz tak nie jest.
Popatrzmy, jak to wpłynie na kompilację. Mamy jakiś rozbudowany szablon, w którym znaczniki zagnieżdżone są aż do głębokości 30. Sam OPT z kompilatorem może w porywach zużyć dodatkowe 10 zagnieżdżeń, zaś w najgłębszym miejscu jest na dokładkę jeszcze skomplikowane wyrażenie z nawiasami. Zatem całość zużyje ok. 45 wywołań, z dostępnych 64, czyli ponad 70% limitu! I choć przypadek jest dość pesymistyczny, bo prawdę mówiąc mi się nigdy nie zdarzyło, żebym musiał robić jakiś plik HTML z 30-ma zagnieżdżonymi znacznikami, to jednak pokazuje on, że kompilacja to proces wymagający.
Skoro bez brzydkich sztuczek, które nie spodobałyby się na pewno twórcom instrukcji, nie da się usunąć rekurencji, to jak sobie z tym poradzić? Wpadłem na pomysł, gdzie rekurencji nie usuwamy, lecz za to drastycznie ją ograniczamy. Korzystam tutaj z faktu, że natychmiastowy dostęp do tego, co wykompilowały dzieci, jest potrzebny tylko kilku instrukcjom, natomiast np. zwyczajnym znacznikom XML/HTML jest to po grzyba, gdyż one i tak nie wiedzą, co z tym zrobić. Mamy więc sporą szansę, że gdy wybierzemy losowy węzeł z drzewa, trafimy na taki, któremu rekurencja jest niepotrzebna. Dlaczego więc nie zaproponować dwóch wersji algorytmu: imperatywnej i rekurencyjnej, w zależności od potrzeb? Tam, gdzie instrukcje robią czary, mogą sobie zażyczyć prawdziwej rekurencji, natomiast normalnie wszystko idzie poprzez kolejki. Wtedy przy dobrych wiatrach, takie drzewo o głębokości 30, może być przetworzone na jeden raz, bez żadnej rekurencji! W normalnych warunkach natomiast zejdzie ona na głębokość 3, 4, co jest już akceptowalne.
Druga sprawa dotyczy zużycia pamięci. Wprawdzie nie jest tu aż tak tragicznie, ale da się zauważyć wyraźne zwiększenie zapotrzebowania w porównaniu z kompilatorem do OPT 1.x, nawet pomimo wykorzystania patentów z tamtego projektu. Większość tego zajmuje oczywiście samo drzewo. Mierzyłem ostatnio zużycie pamięci generowane przez pojedyncze obiekty węzłów i wyniki wahały się w granicach 2 kilobajtów na pusty obiekt bez udziwnień, natomiast przy dodaniu atrybutu, rosło średnio o kilobajt. Gdy instrukcje zaczną dopisywać do buforów kod PHP, sprawa się pogarsza, ale dotyczy to tylko wybranych węzłów w całym szablonie. Okazuje się, że możemy tu trochę zaoszczędzić. Obiekty wykorzystują kilka tablic, które dotąd były automatycznie inicjowane na samym początku, na przykład:
abstract class optScannable extends optNode implements Iterator
{
protected $subnodes = array();
W większości węzłów część tych tablic stoi pusta, więc nie ma sensu, aby były one cały czas stworzone. Poprawiłem kod tak, aby odpowiednie metody tworzyły pustą tablicę dopiero, gdy będzie ona potrzebna, zaś z deklaracji wywaliłem dopiski = array(). Dało to ciekawy efekt: na jednej tak wywalonej tablicy zacząłem oszczędzać 400 bajtów. Nowe rezultaty pomiarów są następujące:
- Węzeł bez atrybutów: 1400 b.
- Węzeł z jednym atrybutem: 2350 b.
Dodatkowa oszczędność może zostać osiągnięta dzięki pamiętaniu o kasacji niepotrzebnych już danych o dużej objętości, jak np. treść szablonu.
O czym należy pamiętać, korzystając z nowego OPT, jeśli chodzi o kwestie wydajnościowe:
- Z wyśrubowanymi limitami kompilator na pewno będzie się wysypywać. Myślę, że 16 MB pamięci dla skryptu (aktualny domyślny limit) to wartość na tyle rozsądna, by kompilator mógł odwalić dobrą robotę nawet z całkiem rozbudowanym szablonem oraz jednocześnie aby zostało coś dla skryptu.
- Parsowanie szablonu najlepiej uruchamiać na końcu, kasując przedtem w skrypcie niepotrzebne już dane o dużej objętości.
- Kompilacja szablonów z dziedziczeniem może wymagać więcej pamięci, szczególnie przy nadpisywaniu już istniejących snippetów (parser musi trzymać w pamięci również nadpisane wersje).
- Niezaimplementowany jeszcze tryb tekstowy (quirks mode) wymagać będzie znacznie mniej pamięci dzięki uproszczonemu drzewu (brak znaczników XML).
- Kompilacja uruchamiana jest raz na długi czas; podczas normalnej pracy można spokojnie zapomnieć o istnieniu tych wszystkich wymagań.
Myślę, że ten wpis dość dobrze pokazuje, o ilu aspektach trzeba pamiętać, pisząc porządną bibliotekę. Klucz to wiedzieć, na ile można sobie w danym miejscu pozwolić i że oprócz biblioteki, gdzieś tam ma jeszcze w tej samej pamięci, w tej samej płaszczyźnie pracować wykorzystujący ją skrypt, który także ma swoje wymagania.