PORADY

Porady > Programowanie > Delphi > Optymalny, wysokowydajny kod w Delphi

Optymalny, wysokowydajny kod w Delphi

Bazując na własnych wieloletnich doświadczeniach pragnę przedstawić przykładów, jak w prosty sposób dokonać optymalizacji wydajnościowej aplikacji w Delphi. Skupię się tutaj na tym, w jaki sposób kompilator Delphi tłumaczy kod źródłowy na język maszynowy (przy wykorzystaniu zapisu Assemblera), a także przedstawię kilka szczegółów dotyczących wykonywania poleceń przez procesor. Artykuł adresowany jest w głównej mierze do bardziej zaawansowanych programistów oraz mających jakieś pojęcie o języku Assembler. Jednakże pewne przedstawione schematy mogą być wykorzystane także przez początkujących, którym także zależy na tym, aby aplikacja działała możliwie jak najszybciej. Wszystkie przedstawione porady będą bazować na prostych modyfikacjach pewnych podstawowych czynności. Nie chcę przedstawiać tutaj żadnych algorytmów, których właściwy dobór często może dać znacznie lepsze rezultaty, o czym nie należy zapominać. Przykłady opierać się będą na kompilatorze Delphi 10. Jest to o tyle istotne, że inne wersje kompilatora mogą dokonywać innych tłumaczeń na kod procesora, przez co wyniki w pewnych przypadkach mogą się różnić w zależności od kompilatora.

Spis treści

Z czego będziemy korzystać

W przeważającej mierze skupię się na tym, jak kod wygląda na procesorze. Głównym celem będzie zmniejszenie ilości instrukcji wykonywanych przez procesor; w mniejszym stopniu skupię się na czasie wykonywania poszczególnych instrukcji (niektóre potrzebują większej liczby taktów procesora, inne mniej). Z tych to powodów do wykonywania testów potrzebne są dwie rzeczy w środowisku Delphi. Pierwszą jest włączenie opcji w oknie właściwości projektu, które pozwolą dołączyć do projektu informacje potrzebne do debuggowania programu:

Włączenie informacji debuggera w Delphi

Oczywiście, jeśli naszą aplikację chcemy udostępnić innym, to przed publikacją należy wszystkie zaznaczone opcje wyłączyć (tzw. kompilacja release).

Drugą rzeczą jest okno debuggera na poziomie procesora. Wywołać je można poprzez opcję wskazaną na rysunku poniżej i tylko wówczas, gdy ustawimy aplikacja jest uruchomiona, a więc najlepiej, gdy ustawiony jest breakpoint na początku optymalizowanego fragmentu kodu.

Debugger na poziomie CPU w Delphi

Opisywane okno składa się z kilku części; dla nas najważniejsza będzie główna część – kodu procesora. Na prawo od niej widnieją jeszcze podgląd zawartości rejestrów pamięciowych oraz rejestrów flagowych procesora.
Okno debuggera poziomu CPU

Przechodzenie do kolejnych linii kodu odbywa się identycznie, jak w oknie kodu źródłowego – klawiszem F8. Różnica jest taka, że następuje przejście do kolejnych poleceń procesora, a nie linii kodu; każda linia kodu (zaznaczona w tym oknie czcionką pogrubioną; gdyby tak nie było wystarczy posłużyć się skrótem Ctrl+X) może składać się nawet z kilkudziesięciu poleceń procesora.

Bardzo wygodnym rozwiązaniem w badaniu wydajności kodu jest instrukcja GetTickCount pochodząca z WinAPI, która zwraca liczbę milisekund od momentu uruchomienia systemu Windows. Wystarczy więc przed badanym fragmentem kodu zapamiętać zwracaną wartość, a następnie sprawdzić różnicę. var c: LongWord; begin c := GetTickCount; //Badany fragment, opcjonalnie puszczony w pętli powtarzającej wywołanie funkcji Test Writeln(Format(‘Badany fragment kodu wykonywał się %d ms‘, [GetTickCount - c])); end;

Należy pamiętać, iż badając wydajność często nie wystarczy pojedyncze wykonanie danego algorytmu, lecz koniecznie jest jego wielokrotne powtórzenie. Zapewni to nie tylko, że wyniki będą bardziej mierzalne, ale także uśredni błędy związane z faktem, iż pracujemy w środowisku wielozadaniowym, stąd za każdym razem czas wywołania tego samego kodu będzie różny.

Co daje optymalizacja?

Optymalizacja szczególnie w starszych kompilatorach była często zapominana, a jest to niezwykle istotna opcja, jeśli zależy nam, aby kompilator generował szybszy kod. Można włączyć ją albo poprzez dyrektywę kompilatora {$O+}, albo poprzez zaznaczenie odpowiedniej opcji w oknie właściwości projektu (p. rysunek 1, lewy górny róg). Aby zobaczyć, co potrafi optymalizacja, posłużmy się prostą pętlą: var i: Integer; b: Byte; begin i := 0; for b := 1 to 9 do i := i + 3; Result:=i; end; Powyższy kod nie robi nic innego, jak dodaje do zmiennej i 9 razy wartość 3. Jak wygląda kod pętli bez optymalizacji na procesorze? 6: for b := 1 to 9 do mov byte ptr [ebp-$09],$01 7: i := i + 3; add dword ptr [ebp-$08],$03 inc byte ptr [ebp-$09] 6: for b := 1 to 9 do cmp byte ptr [ebp-$09],$0a jnz -$0d Jak widać na początku pod adres [ebp-$09] w pamięci wprowadzana jest wartość początkowa pętli (1). Następnie dodawana jest w adresie pamięci wartość 3 oraz inkrementowana wartość licznika pętli. Na koniec wykonywane jest porównanie wartości licznika znajdującego się we wskazanym miejscu pamięci z wartością 10 ($0a) i instrukcja skoku warunkowego wstecz, jeśli wynik porównania był fałszywy. Cały kod składa się z 5 instrukcji i odwołań do dwóch miejsc w pamięci odpowiadających dwóm użytym zmiennym.

Włączmy teraz optymalizację kodu i przebudujmy projekt. Oto wyniki działania, czyli ta sama pętla po optymalizacji: 6: for b:=1 to 9 do mov al,$09 7: i := i + 3; add edx,$03 6: for b := 1 to 9 do dec al jnz -$07 Już na pierwszy rzut oka widać, że wszystkiego jest jakby mniej. Co się stało? Po pierwsze, zamiast pracować na wolnej pamięci operacyjnej, kod został zapisany z użyciem szybkich rejestrów procesora. Drugą rzeczą, która została zoptymalizowana, jest to, iż licznik pętli (tutaj rejestr AL) wcale nie zmienia się od 1 do 9, lecz od 9 do 1! A w zasadzie do zera. Dzięki temu możliwe było pozbycie się jednego rozkazu porównania. Dzieje się tak dlatego, gdyż wszelkie instrukcje arytmetyczne także dokonują ustawienia flagi ZF (Zero Flag), a właśnie jej ustawienie jest głównym zadaniem instrukcji porównania CMP. Optymalizacja taka była możliwa z uwagi na fakt, że licznik pętli w żaden sposób nie jest wykorzystany w jej środku, stąd jego wartość nie ma znaczenia dla przebiegu pętli.

Ponadto optymalizacja potrafi eliminować z kodu "bezużyteczne" polecenia (np. przypisania wartości do zmiennych, które nigdy nie zostaną odczytane).

Pozostałe opcje kompilatora

Poza optymalizacją kompilator posiada jeszcze wiele innych parametrów, które będą miały wpływ na wydajność wygenerowanego kodu. Opiszę niektóre z nich. Można je też znaleźć w oknie ustawień projektu pokazanym na rys. 1. Po myślnikach podano wartość, która pozytywnie wpłynie na wydajność kody (często jest to wartość domyślna).

  • Podstawianie treści funkcji w miejscu wywołania (Code inlining control) – {$INLINE AUTO} – pozwala na wstawianie zawartości funkcji w miejscu jej użycia. Temat bardziej szczegółowo zostanie omówiony w dalszej części.
  • Minimalny rozmiar listy wyliczeniowych (Minimum enum size) – {$Z4} – określa, na jaki typ mają być zamienione wszystkie elementy wyliczeniowe. Stanem domyślnym jest {$Z1}, który oznacza, że elementy w listach do 256 elementów zostaną zapisane jako zmienne typu byte, do 64k, jako zmienne typu word. Jak będzie można dowiedzieć się z dalszej części artykułu, szczególnie ten drugi przypadek jest bardzo niepożądany. Dlatego warto zastosować przełącznik {$Z4}, który sprawi, że wszystkie elementy zostaną zamienione na najwydajniejszy typ, jakim jest Integer.
  • Optymalizacja (Optimization) – {$O+} – ta flaga została opisana dokładnie w poprzedniej części artykułu.
  • Wyrównanie pól rekordów (Record field alignment) – {$A+} – dzięki temu ustawieniu kompilator dokona wyrównania wszystkich pól do wskazanej wielkości. Domyślną wartością jest 8 bajtów ({$A8}). Oznacza to, że wszystkie zmienne w rekordach i klasach będą miały wielkość 8 bajtów lub jej wielokrotność (o ile nie użyto słówka packed), co z jednej strony spowoduje zwiększenie pliku wynikowego oraz zapotrzebowania na pamięć, ale z drugiej przyspieszy dostęp do tych danych. Takie jest też ustawienie domyślne.
  • Ramki stosu (Stack frames) – {$W-} – opcja ta określa sposób obsługi zmiennych lokalnych we funkcjach; włącznie powoduje, że zawsze dane zostaną odłożone na stos. Takiego działania wymagają niektóre debuggery, lecz takie działanie zwiększa ilość poleceń procesora dla generowanego kodu i to o powolne operacje na stosie.
  • Asercje (Assertions) – {$C-} – specjalnie na potrzeby debugowania wymyślony został mechanizm asercji. Jest to funkcja Assert(...), w której parametrach podajemy warunek logiczny, jaki musi być spełniony. Jeśli tak nie jest, wywołany zostanie wyjątek EAssertionFailed. Z asercjami często można spotkać się w kodzie komponentów. Wyłączenie wspomnianej opcji sprawi, że taki kod zostanie zupełnie pominięty przez kompilator (tak, jakby go w ogóle nie było).
  • Kontrola operacji wejścia/wyjścia (I/O checking) – {$I-} – Włączenie tego przełącznika spowoduje, że program po każdej procedurze wejścia/wyjścia (np. otwarcie pliku) będzie sprawdzał wartość zwracaną przez system Windows i w przypadku wystąpienia wartości niezerowej zostanie wygenerowany wyjątek. Wyłączenie opcji z pewnością wpłynie pozytywnie na wydajność oraz umożliwi rezygnację z czasochłonnych bloków try..except, lecz stwarza zagrożenie dla stabilności aplikacji i systemu w przypadku nie obsłużenia błędu. Dlatego gdy ta opcja jest wyłączona, należy samemu zadbać o badanie stanu zwrotnego operacji I/O poprzez funkcję IOResult.
  • Sprawdzanie przepełnień (Overflow checking) – {$Q-} – włączenie tej opcji sprawi, że wszystkie operacje arytmetyczne stałoprzecinkowe będą badane pod kątem wystąpienia przekroczenia zakresu. W takiej sytuacji wygenerowany zostanie wyjątek. Choć sprawia to, że kod staje się znacznie bardziej bezpieczny, to jednak w zdecydowanej większości przypadków programista sam potrafi przewidzieć możliwe wyniki i sam dobierze właściwie rozmiar zmiennych lub zabezpieczy kod w inny sposób. Dlatego sprawdzanie przekroczenia zakresu jest tylko marnowaniem czasu. Jednak jeśli opcja ta jest wyłączona, a dojdzie do przekroczenia zakresu, to wynik zostanie „zawinięty” (stracony zostanie najstarszy bajt, który nie zmieścił się w zakresie). Warto dodać, że czasem jest to wykorzystywane umyślnie (np. kodowanie znaków Szyfrem Cezara).
  • Sprawdzanie zakresów (Range checking) – {$R-} – podobną funkcję do poprzedniej pełni to ustawienie. Jeśli jest włączone, wówczas program będzie sprawdzał, czy nie przekroczymy zakresu indeksu tablic lub ciągów znakowych, a w przypadku wystąpienia takiej sytuacji zostanie wygenerowany wyjątek. Nie trzeba dodawać, że dość mocno może to spowolnić kod, mimo iż czyni go bezpieczniejszym.
  • Skracanie warunków logicznych (Complete boolean evaluation) – {$B+} – ta flaga sprawia, że warunki logiczne połączone operatorem and będą sprawdzane tak długo, aż wystąpi pierwszy fałszywy, zaś w przypadku operatora or - do momentu, aż wystąpi pierwszy prawdziwy. Dzięki temu procesor nie będzie marnował czasu na sprawdzanie pozostałych, które i tak nie zmienią wyniku operacji logicznej. Takie ustawienie jest domyślne. Kiedy więc wyłącza się tą opcję? A mianowicie, gdy np. jeden z argumentów w całym ciągu jest np. funkcją, która zawsze musi być wywołana, a nie jest umieszczona na początku. Wyłączenie bowiem spowoduje, że wszystkie funkcje w ciągu warunków logicznych zostaną wywołane.

Właściwe zapisywanie warunków

Bardzo często w kodzie programu można znaleźć tego typu zapisy: if i >= 1 then b := True else b := False; Powyższy kod przekłada się na następujący: 1: if i >= 1 then dec eax jnz +$04 2: b := True; mov bl,$01 jmp +$02 4: b := False xor ebx,ebx Jest to często popełniany błąd optymalizacyjny. Warto skorzystać z możliwości przypisywania wyniku warunku wprost do zmiennej. Jest jeszcze jedno miejsce do optymalizacji. Gdzie tylko to możliwe, powinniśmy dążyć do porównań wszelkich wartości z liczbą zero. Jest to spowodowane dokładnie tym samym, o czym zostało wspomniane w poprzednim punkcie. Tak więc nasz kod po optymalizacji będzie miał następującą postać: b := i > 0; A kompilator wygeneruje następujący kod: 1: b := i > 0 test eax,eax setnle bl Jak widać z pięciu instrukcji zrobiły się dwie. Co więcej, gdyby przed warunkiem było jakieś obliczenie liczby i, wówczas polecenie test mogło by okazać się zbyteczne!

Z tego samego tytułu wszelkie pętle for, o ile te możliwe, powinny iść w dół (for..downto) i kończyć się na wartości 1 (gdyż kolejny przebieg powoduje zmniejszenie do wartości 0 i wówczas można wprost zastosować instrukcję skoku warunkowego).

Warto w tym punkcie wspomnieć jeszcze o wielu warunkach w funkcji if. Zasada jest taka, że przy złączeniu alternatywą or na początku powinien występować najczęściej spełniany warunek (gdyż spełnienie pierwszego powoduje, że nie ma potrzeby sprawdzania pozostałych), zaś w przypadku iloczynu logicznego and na początku powinien być ten warunek, które spełniany jest w najmniejszej liczbie przypadków (z tego samego tytułu, co wcześniej).

Często popełnianym błędem z punktu widzenia wydajności jest nieużywanie słowa kluczowego else pomiędzy warunkami wzajemnie się wykluczającymi. Jeśli więc mamy kod: if i > 50 then Instrukcja1; if i < 10 then Instrukcja2; Procesor komputera najpierw sprawdzi pierwszy warunek i w przypadku jego spełnienia wywoła funkcję. Następnie zostanie sprawdzony drugi warunek mimo, iż nie może on zaistnieć, jeśli pierwszy był już spełniony. Dlatego należy dodać słówko else: if i > 50 then Instrukcja1; else if i < 10 then Instrukcja2;

Kolejnym często popełnianym błędem ze względu na optymalizację kodu, jest używanie słowa kluczowego not w intrukcjach warunkowych posiadających klauzulę else. Choć nowe wersje kompilatorów potrafią sobie z tym poradzić, to jednak warto wyrobić sobie nawyk i wszystkie takie wystąpienia z powodzeniem zastąpić eliminując intrukcję negacji i zamieniając miejscami kod podwarunkowy: if not Math.IsZero(a) then writeln('a NIE jest zerem!') else writeln('a jest zerem!'); Optymalna postać kodu powinna wyglądać tak: if Math.IsZero(a) then writeln('a jest zerem!') else writeln('a NIE jest zerem!'); Jak widać w prosty sposób pozbyliśmy się jednej, zupełnie niepotrzebnej instrukcji.

Instrukcja case

Bardzo istotnym zagadnieniem dotyczącym warunkowego sterowania przepływem programu jest korzystanie z instrukcji case. To, jak kompilator Delphi przekształca ją na kod procesora jest zagadnieniem bardzo złożonym. Najpierw kompilator sortuje wszystkie wartości/zakresy. Płynie stąd wniosek, że kolejność zapisanych wartości nie ma żadnego znaczenia. Następnie budowana jest struktura drzewa binarnego, czyli stosowany będzie algorytm wyszukiwania binarnego. Jest to bardzo istotne z punktu widzenia optymalizacji wydajnościowej. Ale uwaga – takie drzewo zostanie zbudowane wyłącznie wtedy, gdy współczynnik gęstości na to pozwala. Jeśli rozrzut jest duży – lista dzielona jest na połowy i każda z nich przeszukiwana niezależnie. Dlatego dobrym sposobem na pomoc kompilatorowi jest pogrupowanie warunków wg zakresów. Kod: case c of 'a': proc1; 'b': proc2; 'c': proc3; 'd': proc4; 'e': proc5; 'A': proc101; 'B': proc102; 'C': proc103; 'D': proc104; 'E': proc105; end; Należy zamienić na: case c of 'a'..'e': case c of 'a': proc1; 'b': proc2; 'c': proc3; 'd': proc4; 'e': proc5; end; 'A'..'E': case c of 'A': proc101; 'B': proc102; 'C': proc103; 'D': proc104; 'E': proc105; end; end; Jeśli jakiś warunek dodatkowo spełniany jest dużo częściej, niż pozostałe, warto wysunąć go przed instrukcję case: if c = 'E' then proc105 else case c of …

Praca ze zbiorami

Język Pascal udostępnia nam niezwykle wygodne narzędzie w postaci zbiorów elementów typu wyliczeniowego. Dzięki nim zamiast wykonywać sprawdzenie, czy znak jest jednym z przewidzianych znaków, poprzez zastosowanie rozbudowanej konstrukcji or if (c = 'a') or (c = 'A') or (c = #27) or (c = #13) then … Można zastosować znacznie czytelniejszą instrukcję: if c in ['a', 'A', #27, #13] then …

Wydajność obu rozwiązań może być zróżnicowana w zależności od prawdopodobieństwa spełnienia lub nie poszczególnych warunków. Jednak przy równomiernym rozkładzie oba rozwiązania będą porównywalne. Należy zwrócić przy tym uwagę, że dla konstrukcji z or kolejność zapisów ma znaczenie; w przypadku zbioru i operatora in nie ma znaczenia kolejność podania elementów zbioru.

Praca ze zbiorami jest na pewno wygodniejsza, a kod zyskuje na czytelności. Czasem ich użycie może być też konieczne. Dokonując operacji na zbiorach należy pamiętać, że dodając lub usuwając elementy ze zbioru zamiast używania operatorów plus (+) i minus (-), powinno się korzystać z funkcji Include i Exclude, które działają znacznie szybciej. Nawet, jeśli chcemy dodać lub odjąć zbiór pokrywający prawie cały zakres, wielokrotne użycie podanych funkcji będzie bardziej wydajne, niż dodawanie lub odejmowanie zbioru.

Podczas deklarowania danych wyliczeniowych kompilator w tle sam przydziela odpowiednim zapisom poszczególne wartości numeryczne. Stosuje przy tym możliwie najmniejszy typ danych. Niestety w przypadku typów wyliczeniowych o rozmiarze ponad 255 elementów, a mniej niż 65535 stosowany jest powolny typ Word. Należy więc bezwzględnie posłużyć się dyrektywą {$Z+} aby wymusić typ 32-bitowy. O tym, dlaczego tak się dzieje, napisane będzie w dalszej części poradnika.

Zmienne

W punkcie tym pragnę poruszyć dwa zagadnienia. Pierwszym z nich jest używanie zmiennych globalnych i lokalnych. Zmienne lokalne to wszystkie te, które definiowane są w argumentach lub wewnątrz procedur i funkcji. Należy kategorycznie rezygnować ze stosowania zmiennych globalnych, chyba że jest to absolutnie niezbędne. Wymóg ten podyktowany jest faktem, iż wyłącznie zmienne lokalne mogą być przekształcone w zmienne rejestrowe. Dlatego często dobrym rozwiązaniem jest przepisanie zmiennej globalnej do zmiennej lokalnej na czas wywoływania funckji, szczególnie jeśli taka zmienna użyta jest w pętlach. Pola klasy także należy traktować jak zmienne globalne. Od tej reguły jest jednak wyjątek. Jeśli mamy tablicę o stałym rozmiarze i stałych elementach prostych lub strukturalnych, to tutaj wydajniejsze okaże się zdefiniowanie jej jako zmiennej globalnej, niż przekazywanie do funkcji.

Druga sprawa dotyczy właściwego doboru typu zmiennych. Skupmy się na zmiennych całkowitych. Delphi oferuje kilkanaście typów: zmienne 8-, 16-, 32- i 64-bitowe. Są dwa podejścia programistów – jedni zupełnie nie zwracają uwagi na stosowany typ i wszędzie wrzucają standardowy Integer; drudzy stosują w każdej sytuacji najmniejszy z możliwych typów. Wbrew pozorom, pierwsze podejście jest lepsze! Tym, czego powinniśmy się wystrzegać, to stosowanie typów 16-bitowych. Operacje wykonywane na tych typach wiążą się koniecznością przełączenia procesora w tryb 16-bitowy, co negatywnie wpływa na wydajność. Takich problemów nie obserwuje się w przypadku korzystania z typów 8-bitowych. Ich stosowanie jest wskazane, gdyż operacje przeprowadzane są szybko, a jednocześnie zajmują mniej w pamięci. Unikać należy także typu 64-bitowego w kompilacjach 32-bitowych z prostej przyczyny – jest to tylko emulacja. Nie ma to zastosowania do kodu skompilowanego jako 64-bitowego (od wersji kompilatora XE2 lub nowszej). Jeśli możemy sobie pozwolić na różny rozmiar, a jednocześnie chcemy utrzymać kod wydajnym na każdej z architektur, możemy posłużć się typem NativeInt i pokrewnymi, której długość jest różna w zależności od platformy docelowej.

To, czego należy się wystrzegać ponadto, to mieszanie typów. Operacje wykonywane na obu typach równocześnie będą wymagały dokonania rzutowania na typ większy. Dla przykładu kod: var b: byte; i: Integer; begin … for b := 255 downto 1 do Inc(i, b); Zostanie zamodelowany na procesorze w następujący sposób: 5: for b := 255 downto 1 do mov al,$ff 6: Inc(i, b); movzx ecx,al add edx,ecx dec eax 5: for b := 255 downto 1 do test al,al jnz -$0b Jak widać operacja inkrementacji złożyła się z kilku etapów – przygotowania rejestru, przeniesienia wartości i dodania właściwego. Prosta zamiany typu zmiennej b z byte na Integer (a więc ten sam, co zmiennej i) daje takie oto rezultaty: 5: for b:=255 downto 1 do mov eax,$000000ff 6: Inc(i,b); add edx,eax dec eax 5: for b:=255 downto 1 do test eax,eax jnz -$07 W ten sposób pozbyliśmy się jednego polecenia odpowiedzialnego za rzutowanie i zwolniliśmy jeden rejestr procesora z użytku. Nadmienię, że w strszych wersjach komilatorów (np. Delphi 7) takie rzutowanie mogło odbyć się z wykorzystaniem aż dwóch poleceń procesora.

Warto zaznaczyć, że jeśli z jakiegoś względu nie możemy z góry zastosować identycznych typów zmiennych, to warto przed wykonywaniem wielokrotnych obliczeń dokonać samemu zrzutowania poprzez przepisanie zmiennej typu 8- lub 16-bitowego na typ Integer.

Nieco inaczej sprawa ma się ze zmiennymi zmiennoprzecinkowymi. Tutaj rozmiar zmiennej ma znaczenie głównie przy przesyłaniu danych z i do FPU. Jednakże jednostka FPU dokonuje obliczeń zawsze w pełnym możliwym zakresie dokładności. Mało kto wie, ale możemy ograniczyć tą dokładność, zyskując przy tym sporo na wydajności operacji dzielenia. Służy do tego funkcja SetPrecisionMode z unitu Math. Przyjmuje ona jeden z trzech parametrów decydujący o precyzji obliczeń. Zamiast niej, znacznie szybciej można przestawić tryb korzystając z procedury Set8087CW lub dostępnej od Delphi 6 SetPrecisionMode z prostszą składnią. Zainteresowanych odsyłam do pomocy Delphi oraz danych w Internecie, które opisują przykład użycia.

Także przy obliczeniach na jednostce FPU należy dbać o to, aby nie mieszać różnych typów zmiennych, gdyż spowoduje to tak samo konieczność ich konwersji (znacznie bardziej czasochłonną, niż w przypadku konwersji liczb stałoprzecinkowych).

Na koniec warto wspomnieć o znów mało znanej możliwości wymuszania typów stałych. Jeśli nie określimy typu stałej, Delphi sam określi jej typ na podstawie jej wartości. Może okazać się to bardzo niewskazane. Dlatego każdorazowo deklarując stałe powinno określać się ich typ taki, jaki będzie wykonywany przy obliczeniach: const i: integer = 2; pi: single = 3.14;

Przechodząc do kolejnego zagadnienia, pragnę zwrócić uwagę na pewną często ignorowaną rzecz: mianowicie używanie własności obietków (property) jak najzwyklejszych zmiennych. Tymczasem pobranie lub ustawienie wartości to najczęściej wywołanie metody (lub przynajmniej odczyt z pamięci). O tym, dlaczego należy tego unikać, napiszę w kolejnym punkcie, a tymczasem przeanalizujmy najczęściej występujące przypadki i popełniane błędy.

Załóżmy kod, gdzie w zależności od wyniku funkcji ustawimy widoczność kontrolki oraz wywołamy funkcję. Najczęściej kod taki będzie wyglądał w następujący sposób: obj.visible := fn(); if obj.visible then p(); To, na co należy zwrócić uwagę, to fakt, że w warunku na nowo pobieramy wartość właściwości obiektu. Tym czasem my ja znamy! Ale kompilator niepotrzebnie wygeneruje wywołanie funkcji w celu dostarczenia wartości. Jak zatem powinien wyglądać poprawny i bardziej wydajny kod? Wystarczy wprowadzić zmienną! var b: Boolean; ... b := fn(); obj.visible := b; if b then p(); Widać tu, że najpierw w zmiennej zapamiętamy wynik i to własnie ona posłuży zarówno do ustawienia własności kontrolki jak i sprawdzenia warunku. W ten sposób pozbywamy się konieczności pobrania wiadomiej wartości. pbProgressBar1.Progress := pbProgressBar1.Progress + 1; gGauge1.Progress := gGauge1.Progress + 1; Tymczasem obie użyte kontrolki posiadają medoty, dzięki którym nie musimy pobierać aktualnej wartości pola Progress: pdProgressBar1.StepIt; gGauge1.AddProgress(1); Warto więc zainteresować się tym, co dostarcza obiekt, a tworząc własne zapewnić metody, które zminimalizują potrzebę pobierania wartości.

Bardzo podobnym a często występującym przykładem nieoptymalnego użycia własności obiektu jest zwiększanie postępu dla pasków postępu:

Wywoływanie funkcji i ich parametry

Każde wywołanie funkcji wiąże się z dość sporym kosztem. Choć funkcje pozwalają na lepsze gospodarowanie pamięcią, zmiennymi i rejestrami, a także upraszczają kod, to jednak samo ich wywołanie trwa dość długo. Dlatego należy unikać stosowania wszelkich funkcji przede wszystkim w newralgicznych pętlach i zastępować je treścią funkcji. W nowszysch wersjach Delphi możliwe jest zachowanie czytelności funkcji przy jednocześnie utrzymanej wysokiej wydajności kodu - służy do tego modyfikator inline umieszczany tuż za nagłówkiem funkcji. Pamietać jednak musimy także o włączeniu właściwej opcji projektu:

Kontrola zachowania modyfikatora inline w Delphi

Wartość On pozwoli na przekształcenie funckji z dyrektywą inline na "czysty" kod. Ciekawą jest wartość Auto, która sprawia, że wszystkie małe funkcje (o rozmiarze poniżej 32 bajtów) zostaną automatycznie potraktowane tak, jakby posiadały modyfikator inline.

Niestety, stosowanie tej sztuczki jest ograniczone i nie każda procedura czy funkcja może być przekształcona w ten sposób. O ewentualnym braku możliwości poinformuje kompilator. Nadmienić tylko warto, że limity przedstawiają się różnie dla róznych platform, zatem warto być może używać dyrektywy inline warunkowo (a więc np. w dyrektywnie kompilatora {$IFDEF WIN64}inline;{$ENDIF}).

Delphi pozwala na stosowanie dość specyficznego tworu, jakim jest funkcja zagnieżdżona (lokalna). Może wydawać się to interesującym rozwiązaniem: Funkcje zagnieżdżone nie są widoczne dla innych funkcji, a jednocześnie wszelkie zmienne, stałe i typy funkcji zewnętrznych (zadeklarowane przed funkcją wewnętrzną) stają się widoczne dla funkcji wewnętrznych. Przykładem niech będzie taka oto konstrukcja: function Test(i, w: Integer): Integer; procedure Wew; var x: Integer; begin for x := 1 to w do i := i div 3; end; var b: integer; begin for b := i to 255 do begin Wew; Inc(i, w); end; Result := i; end;

Stosowanie tego typu dziwolągów powoduje duży bałagan dla procesora. Zarządzanie stosem i przekazywanie parametrów jest w tym momencie niezwykle skomplikowanym przedsięwzięciem. Tak oto wygląda kod funkcji testowej wraz z funkcją wewnętrzną i jej wywołaniem: 2: begin //procedure Wew push ebp mov ebp,esp push ebx 3: for x := 1 to w do mov eax,[ebp+$08] mov ebx,[eax-$04] test ebx,ebx jle +$50 4: i := i div 3 mov eax,[ebp+$08] mov ecx,$00000003 add eax,-$08 push eax mov eax,[eax] cdq idiv ecx pop edx mov [edx],eax 3: for x := 1 to w do dec ebx jnz -$15 5: end //procedure Wew pop ebx pop ebp ret 8: begin //function Test push ebp mov ebp,esp add esp,-$08 push ebx mov [ebp-$04],edx mov [ebp-$08],eax 9: for b := i to 255 do begin mov ebx,[ebp-$08] cmp ebx,$000000ff jnle +$16 10: Wew push ebp call Wew pop ecx 11: inc(i,w) mov eax,[ebp-$04] add [ebp-$08],eax 9: for b := i to 255 do begin inc ebx cmp ebx,$00000100 jnz -$14 14: Result := i mov eax,[ebp-$08] Na cały kod złożyło się 39 instrukcji.

Należy bezwzględnie unikać takiego zapisu. Wszystkie funkcje lokalne można wynieść na zewnątrz wraz z potrzebnymi typami. Wszelkie stałe i zmienne można przekazać przez parametr. Tak oto wygląda program po wyniesieniu funkcji wewnętrznej na zewnątrz: procedure Wew(var i, w: Integer); var x: Integer; begin for x := 1 to w do i := i div 3; end; function Test(i, w: Integer): Integer; var b: integer; begin for b := i to 255 do begin Wew(i, w); Inc(i, w); end; Result := i; end; A tak przedstawia się na procesorze: 4: begin //Wew push ebx push esi mov esi,eax 5: for x := 1 to w do mov ebx,[edx] test ebx,ebx jle +$0f 6: i := i div 3 mov ecx,$00000003 mov eax,[esi] cdq idiv ecx mov [esi],eax 5: for x := 1 to w do dec ebx jnz -$0b 7: end //procedure Wew pop esi pop ebx ret 12: begin //function Test push ebx add esp,-$08 mov [esp+$04],edx mov [esp],eax 13: for b := i to 255 do begin mov ebx,[esp] cmp ebx,$000000ff jnle +$1a 14: Wew lea edx,[esp+$04] mov eax,esp call Wew 15: inc(i, w) mov eax,[esp+$04] add [esp],eax 13: for b := i to 255 do begin inc ebx cmp ebx,$00000100 jnz -$1b 18: Result := i mov eax,[ebp-$08]

Jak widać liczba instrukcji spadła z 39 do 32, przy czym tych związanych z samym wywołaniem funkcji lokalnej ubyło z 13 do 9, w tym liczba czasochłonnych operacji na stosie związanych z wywołaniem funkcji spadła z 8 do 4, czyli aż o połowę! Mało tego, jak widać kod został znacznie lepiej zoptymalizowany i mniej jest odwołań do pamięci z przesunięciem. Tak więc zyski z zamiany funkcji lokalnej na funkcję zewnętrzną są nie do przecenienia. A przy wykorzystaniu większej liczby zmiennych rezultaty będą jeszcze bardziej zachęcające. Korzyści te gołym okiem dają się zauważyć także po czasie wywoływania procedury (tej przedstawionej już przy około 1000 wywołań procedury Test).

Skoro jesteśmy już przy wywoływaniu funkcji, to kolejnym aspektem, który należy poruszyć mówiąc o projektowaniu wysokowydajnego kodu, to sposób przekazywania parametrów. Magiczną granicą są tutaj 3 parametry, gdyż taka ich ilość może być przekazana przez rejestry. Każde kolejne parametry będą przekazywane z użyciem powolnego stosu. Nie należy zapominać przy tym, że wszystkie metody klas otrzymują niewidoczny parametr Self.

Projektując argumenty funkcji należy unikać, gdzie tylko to możliwe, przekazywania tychże parametrów przez wartość. Początkujący programiści Delphi często znają tylko dwa rodzaje przekazywania parametrów – właśnie przez wartość oraz przez referencję (var). Tymczasem Delphi pozwala także na przekazywanie parametrów przez stałą (const) oraz definiuje parametr jako wyjściowych (out). Dzięki właściwemu wykorzystaniu tych modyfikatorów kod będzie mógł być znacznie lepiej zoptymalizowany przez kompilator. W rzeczywistości w większości przypadków modyfikatory const i out zachowają się identycznie, jak przy przekazywaniu przez typową referencję. Różnica polega głównie na tym, że zmiennej poprzedzonej modyfikatorem const nie będziemy mogli zmodyfikować w ciele funkcji.

Aby zobaczyć, jak duże oszczędności może przynieść pozbycie się przekazywania przez wartość, przeanalizujmy funkcję, która przyjmie dwa parametry: pierwszy z nich będzie ciągiem tekstowym, w drugim będzie zwracana długość tego ciągu. Początkujący programista zapisze funkcję zapewne w taki sposób: Procedure MyLen(S: String; var Len: Integer); begin Len := Length(S); end; Tak więc mamy przekazany parametr pierwszy przez wartość, a drugi przez referencję. Tak oto zostanie odwzorowany ten kod: 2: begin push ebp mov ebp,esp push ecx push ebx mov ebx,edx mov [ebp-$04],eax mov eax,[ebp-$04] call @LStrAddRef xor eax,eax push ebp push $00408188 push dword ptr fs:[eax] mov fs:[eax],esp 3: len:=Length(S) mov eax,[ebp-$04] test eax,eax jz +$05 sub eax,$04 mov eax,[eax] mov [ebx],eax 4: end xor eax,eax pop edx pop ecx pop ecx mov fs:[eax],edx push $0040818f lea eax,[ebp-$04] call $LStrClr ret

Analizując naszą funkcję można zauważyć, że zmienna S nie jest modyfikowana, dlatego warto określić ją jako stałą (const). Drugi parametr służy wyłącznie do zwracania wartości, stąd zasadniczo lepiej określić go jako wyjściowy (out). Choć w przypadku typów prostych ta zmiana nie będzie miała znaczenia, to jednak sprawi, że kod będzie bardziej czytelny. Jednakże są przypadki, gdy użycie tego modyfikatora jest zasadne. Po szczegóły odsyłam do pomocy Delphi. Procedura po modyfikacjach będzie miała następujący nagłówek: Procedure MyLen(const S: String; out Len: Integer); A tak zaprezentuje się kod wynikowy po jej zmodyfikowaniu: 3: Len := Length(S) test eax,eax jz +$05 sub eax,$04 mov eax,[eax] mov [edx],eax 4: end ret Jak widać różnica jest diametralna! Choć parametry typu string są bardzo wrażliwe na takie modyfikacje i dają zauważalne efekty, to jednak warto używać ich także wobec typów prostych. Zyska na tym nie tylko wydajność, ale i czytelność kodu.

Przy okazji omawiania procedur, a ściślej – metod, nie sposób nie wspomnieć o metodach dynamicznych lub wirtualnych. Metody (czyli procedury i funkcje klasy), po których występuje słowo dynamic lub virtual mogą zostać nadpisywane w klasach potomnych, w przeciwieństwie do metod statycznych. Zarówno metody dynamiczne i wirtualne są semantycznie identyczne. Różnicą jest to, że metody oznaczone słowem virtual zoptymalizowane są pod kątem wydajności kodu, zaś te z dopiskiem dynamic będą zajmować mniejszą ilość kodu, a więc oszczędzą pamięć.

Arytmetyka w Delphi

Bardzo często w kodach można spotkać zapisy: i := i + 1 Choć w większości przypadków optymalizator poradzi sobie z tym zapisem, to jednak powinniśmy dążyć do operowania na instrukcjach inc i dec (oczywiście zakładając, że liczba i jest liczbą całkowitoliczbową). Dzięki nim mamy pewność, że zwiększenie lub zmniejszenie wartości zajmie zawsze tylko jedną instrukcję w języku procesora.

Warto także zwrócić uwagę na operacje mnożenia i dzielenia przez 2 lub jej potęgę (4, 8, 16 itd.) liczb całkowitoliczbowych dodatnich(!). Okazuje się, że operacje te są bardzo wolne w porównaniu do efektu ich działania. Mnożenie przez 2 to nic innego, jak przesunięcie bitowe o 1 bit w lewo. Zaś dzielenie całkowitoliczbowe to to samo, co przesunięcie bitowe o jeden bit w prawo. Dlatego kod: i := a * 2; j := j div 2; Należy zastąpić kodem: i := a shl 1; j := j shr 1; Także dzielenie modulo (czyli optrzymywanie reszty z dzielenia) przez wielokrotność liczby 2 można z powodzeniem zastąpić operacją mnożenia binarnego przez maskę o wartości równej wartości dzielnika pomniejszoną o 1. Dlatego kod: a := b mod 4 Zastępuje się kodem: a := b and 3; //4 - 1 = 3; binarnie: 00000011b Generalnie warto pamiętać, że wszystkie operacje bitowe (przesunięcia, mnożenia, sumy itp.) są najszybszymi z możliwych operacji.

Skoro już jesteśmy przy wydajności operacji, warto wspomnieć, że operacja dzielenia jest najbardziej obciążającą procesor. Wszędzie, gdzie to możliwe, powinniśmy dążyć do zastąpienia jej operacją mnożenia przez odwrotność. Przykładowo kod: i := a / 5; Powinniśmy zastąpić kodem: i := a * 0.2;

Warto w tym miejscu przypomnieć, że jeżeli w pewnym kodzie programu wykorzystujemy identyczną operację arytmetyczną w kilku miejscach, która daje ten sam wynik, to lepiej tenże zapamiętać w dodatkowej zmiennej, a następnie wykorzystywać właśnie tą zmienną, zamiast powtarzać operację. Nie należy jednak przesadzić. Optymalną liczbą zmiennych jest nie więcej niż 8, gdyż taka ich liczba może być używana z wykorzystaniem rejestrów procesora. Warto więc zbadać, czy zapisanie w pamięci wyniku prostego dodawania i wykorzystanie go w dwóch miejscach nie będzie przypadkiem wolniejsze, niż pozostawienie tych obliczeń bez modyfikacji.

Wykorzystajmy więc tą wiedzę do rozwiązania typowego problemu...

Kolorowanie co drugiego wiersza komponentu TListView

Zapewne często zdarza się, że chcemy urozmaicić zestawienia i stosujemy komponent TListView w stylu raportu (lub jakikolwiek inny tabelaryczny), w których chcemy wyszarzyć lub pokolorować co drugi wiersz. Najczęściej kod wygląda w ten sposób: procedure TForm1.ListView1CustomDrawItem(Sender: TCustomListView; Item: TListItem; State: TCustomDrawState; var DefaultDraw: Boolean); begin with Sender.Canvas.Brush do begin if Item.Index mod 2 = 1 then Color := clSilver else Color := clWhite; end; end; Jako, że przerysowania i wykorzystanie tej procedury obsługi zdarzenia jest bardzo częste, wysoka wydajność jej działania jest niezbędna! W kodzie tym widzimy aż kilka niedoskonałości. Po pierwsze - zupełnie niepotrzebne zastosowanie klauzuli with. Dlaczego? O tym napiszę nieco dalej. Po drugie, jak wspomniałem wcześniej dzielenie modulo należy zastąpić operacją bitową. Po trzecie, nie należy przyrównywać do wartości innej niż 0, jeśli nie jest to konieczne. Jak więc będzie wyglądał nasz optymalny i szybki kod po tych trzech zmianach? procedure TForm1.ListView1CustomDrawItem(Sender: TCustomListView; Item: TListItem; State: TCustomDrawState; var DefaultDraw: Boolean); begin // Rezygnacja z niepotrzebnego with if Item.Index and 1 = 0 then //zastąpienie operacji MOD operacją AND oraz przyrównanie do 0, jako szybsza operacja Sender.Canvas.Brush.Color := clWhite // Z powodu zmiany przyrównania w warunku, zamienione zostały miejscami kolory, aby wciaż dać ten sam efekt else Sender.Canvas.BrushColor := clSilver; end; Badanie wydajności pozostawiam czytelnikowi.

Operacje na łańcuchach (String)

Uwaga! Wszystkie wskazówki przedstawione w tym dziale dotyczą wyłącznie aplikacji środowiska Win32. Operacje na typie String w środowiskach .NET wykonuje się poprzez użycie klasy StringBuilder, która nie będzie omawiana w tym poradniku.

Dość często programiści dokonują łączenia stringów wykorzystując operator dodawania. Jest to faktycznie najbardziej optymalne rozwiązanie, a jednocześnie bardzo przejrzyste. Jednak sprawa zaczyna się komplikować, kiedy w ciąg wynikowy włączamy wartości zmiennych liczbowych, które są poddawane konwersji na łańcuch znaków. Często wówczas korzysta się z funkcji IntToStr. W Delphi w wersjach do 6.0 włącznie funkcja ta przykrywała standardową funckję Format. W nowszych środowiskach funkcje konwersji wartości zostały napisane od nowa z wykorzystaniem kodu niskopoziomowego, a w kolejnych przyspieszono. Dlatego w starych wersjach kompilatora, zamiast kodu: S := 'Wykonano ' + IntToStr(i) + ' z ' + IntToStr(n) + ' zadań'; Znacznie szybsze w wykonaniu jest zapisanie tego kodu w postaci: S := Format('Wykonano %d z %d zadań', [i, n]); I o ile w starszych wersjach znacznie szybszą wersją będzie druga, to w najnowszych jest ręcz przeciwnie! Warto zatem przygotowując wydajny kod zadbać o każdą z wersji oddzielnie. Oczywiście funkcja Format może przyjmować nie tylko liczby całkowite, ale także zmiennoprzecinkowe o określonym formacie, czy ciągi tekstowe. Osoby, które zetknęły się z językiem C, z pewnością poczują się tu swobodnie. Reszcie polecam pomoc Delphi na temat tej funkcji.

Drugim istotnym zagadnieniem jest dołączanie do ciągu innego ciągu, bądź znaków. Szczególnie, jeśli taka operacja przeprowadzana jest w pętli, to zajmuje ona sporo czasu. Przeanalizujmy prostą funkcję kodującą: function Code(const ToCode: String): String; var i: Integer; begin Result := ''; for i := 1 to Length(ToCode) do Result := Result + Char(Ord(ToCode[i]) xor $99); end; Funkcja ta zamienia wszystkie znaki poddając je operacji logicznej na bitach – alternatywy rozłącznej (taka operacja jest odwracalna poprzez ponowne jej wywołanie). Jak widać, za każdym razem wynik jest doklejany do rezultatu, a więc potrzeba za każdym razem na nowo relokować pamięć dla rezultatu funkcji. Jako, iż z góry znamy rozmiar wyniku, znacznie szybciej jest przygotować od razu ciąg tekstowy odpowiedniej długości, a następnie podmieniać w nim wskazane znaki: function Code2(const ToCode: String): String; var i: Integer; begin SetLength(Result, Length(ToCode)); for i := Length(ToCode) downto 1 do Result[i] := Char(Ord(ToCode[i]) xor $99); end; Kod nie tylko zyskał na czytelności, ale także na szybkości działania. Dla ciągu o długości 20 znaków zanotowano wzrost wydajności aż 26-krotny! Ciekawych, skąd aż taki zysk, odsyłam do podglądu procesora i zwróceniu uwagi na realokację ciągu – w drugim przypadku wykonywana jest tylko jednorazowo alokacja pamięci. Dlatego czasem nawet bardziej opłacalne będzie początkowe przewymiarowanie wyniku, jeśli nie jesteśmy do końca pewni co do jego długości, a następnie obcięcie niepotrzebnej części funkcją Delete.

A skoro już przy tej funkcji, to warto zwrócić uwagę, że znacznie bardziej optymalne jest użycie właśnie jej, zamiast funkcji Copy jeżeli zależy nam na wyciągnięciu pewnego fragmentu tekstu.

Także do porównywania części ciągów pomiędzy sobą, nie warto używać funkcji Copy. Zamiast kodu: if Copy(S1, 2, 7) = Copy(S2, 2, 7) then Znacznie lepiej użyć takiego oto zapisu: i := 2; repeat rowne := S1[i] = S2[i]; inc(i); until (not rowne) or (i>8); if rowne then … Zastosowanie tego drugiego kodu dla porównania 20 elementów ciągu daje ponad 8-krotny zysk wydajności. Jeżeli pracujemy ze starszą wersją kompilatora, to jeszcze większy zysk możemy odnieść, jeśli semantycznego znaczenia zmiennej rowne użyjemy jej przeciwieństwa – zmiennej rozne oraz zamienimy porownanie na znak roznosci. Te zmiany pozwolą na wyrzucenie negacji z warunku pętli. Jednak uwaga – jeśli większość ciągów będzie różnych od siebie – wówczas lepszym podejściem okaże się pierwotne założenie. Na nowszych wersjach nie ma to znaczenia, gdyż wygenerowany kod będzie tak samo złożony.

Jak widać na tym przykładzie, nie tylko dobre zaprojektowanie algorytmu, ale także wiedza o częstości prawdopodobieństwa któregoś z wyników determinują wybór algorytmu, który pozwoli zbudować bardziej wydajny kod.

Jednak to nie koniec optymalizacji. Delphi dostarcza bowiem zupełnie inne rozwiązanie polegające nie na porównywaniu zmiennych, lecz porównywaniu dwóch obszarów pamięci. Wszystko sprowadza się do wskazania adresów, gdzie znajdują się ciągi i użycia super-szybkiej instrukcji CompareMem: if CompareMem(@S1[2], @S2[2], 7 * SizeOf(Char)) then W tym miejscu dwa słowa wyjaśnienia. Jak wspomniałem, funkcja bazuje na danych w pamięci. Dzięki temu może porównywać absolutnie dowolne, jednolite struktury. Z tego tytułu należy wskazać w pierwszych parametrach adres zmiennej. Służy do tego operator @ (jest to alias funkcji Addr), który właśnie zwraca adres. Jako, że typ string zapisany jest w pamięci jako ciąg następujących po sobie bajtów, przy czym pierwszym elementem tego typu jest wartość określająca długość, to należy wskazać, gdzie znajduje się początkowy znak, od którego chcemy dokonać porównania. Jeśli chcemy od pierwszego znaku, to należy użyć kodu @S[1]. W przykładzie badamy od 2 znaku do 8 (długość = 7), zatem potrzeba sprawdzenia od znaku 2 przez kolejnych 14 bajtów (jeśli String jest typu Unicode; najbezpieczniej zapisać jak w przykładzie: mnożąć ilosć znaków przez wielkość znaku w bajtach).

Jaki jest efekt zastosowanej optymalizacji? Kolejne blisko 6-krotne przyspieszenie działania! Jak widać, można bardzo dużo stracić na nieprzemyślanym porównywaniu części ciągów tekstowych.

Pętle

Analizując wcześniejsze kody pętli odwzorowywane na procesorze widać, że każda iteracja wiąże się ze sprawdzeniem wartości licznika. Tymczasem, jeśli znamy ilość iteracji pętli, możemy pokusić się o technikę tzw. skracania przebiegu pętli. Polega to na tym, że to, co oryginalnie było wykonywane w kilku iteracjach pętli, można wykonać w jednej iteracji. Załóżmy więc prosty przykład czyszczenia 256 elementowej tablicy: var tablica: array[0..255] of Integer; i: integer; begin for i := 255 downto 0 do tablica[i] := 0; end; Sama pętla (bez części inicjującej) wygląda następująco: 6: tablica[i] := 0 xor ebx,ebx mov [ecx],ebx sub eax,$04 5: for i := 255 downto 0 do inc edx jnz -$0a Tak więc meritum pętli to 5 operacji powtarzanych w tym przypadku 256 razy czyli łącznie 1280 operacji. Po zastosowaniu operacji skracania przebiegu kod Delphi wyglądać będzie następująco: var tablica: array[0..255] of Integer; i: integer; begin i := 254; while i > 0 do begin tablica[i] := 0; tablica[i + 1] := 0; dec(i, 2); end; end; Na procesorze kod pętli przedstawia się następująco: 7: tablica[i] := 0; xor ecx,ecx mov [esp+eax*4],ecx 9: tablica[i+1]:=0; xor ecx,ecx mov [esp+eax*4+$04],ecx 10: dec(i, 2) sub eax,$02 6: while i > 0 do test eax,eax jnle -$12; Z pozoru większy kod, to 7 operacji na pętlę, ale zostanie ona wykonana wyłącznie 128 razy, a więc łącznie zostanie wykonanych 896 operacji. Choć odwołania są nieco bardziej skomplikowane, to także testy wydajnościowe potwierdzają ok. 30% zysk wydajności.

Warto zaznaczyć jednak, że technika ta ma zastosowanie dla pętli, w której liczba przebiegów jest wielokrotnością liczby naturalnej.

Wstawki asemblerowe

Panuje opinia, że zastosowanie kodu asemblera w programie sprawi, że algorytm stanie się szybszy. Jest to tylko połowiczna racja... Po pierwsze często kompilator jest w stanie stworzyć na tyle optymalny kod, że jego poprawienie staje się niemożliwe. Po drugie należy pamiętać, że kod maszynowy bardzo silnie związany jest z danym procesorem; to, co na jednym może okazać się wydajne, na innym będzie stanowić wąskie gardło aplikacji. Po trzecie projektując kod w asemblerze należy umieścić go z głową w całym kodzie. Często niewłaściwe umieszczenie prowadzi do spadku wydajności, gdyż optymalizator nie może wykorzystywać rejestrów użytych w kodzie asemblera i musi ich wartość na nowo inicjować przed wykorzystaniem. Oto prosty przykład (nie mający większego sensu logicznego): procedure Test1(i: Integer; const j1, j2: Integer); var tablica: array[1..3] of Integer; a, t: Integer; begin t := min(j1, j2); for a := 3 downto 1 do tablica[a] := 0; inc(i, t); inc(tablica[t], i); end; A tak oto odwzorowuje ten kod kompilator: 5: begin push ebx push esi add esp,-$0c 6: t := min(j1, j2) cmp ecx, edx jnle +$04 mov edx,ecx mov ebx,edx 8: for a := 3 downto 1 do mov ecx,$fffffffd lea edx,[esp+$08] 9: tablica[a] := 0; xor esi,esi mov [edx],esi sub edx,$04 8: for a := 3 downto 1 do inc ecx jnz -$0a 11: inc(i, t); add eax,ebx 12: inc(Tablica[t], i) add [esp+ebx*4-$04],esi 13: end add esp,$0c pop esi pop ebx ret Widać tutaj pewną możliwość zmiany – a mianowicie w pętli niepotrzebnie za każdym razem zerowany jest rejestr esi (nie ma żadnego polecenia, które mogło by zmienić wartość tego rejestru). Zapiszmy więc pętlę nieco inaczej z wykorzystaniem własnej wstawki asemblerowej: procedure Test(i: Integer; const j1, j2: Integer); var tablica: array[1..3] of Integer; a, t: Integer; begin t := min(j1, j2); asm mov ecx,3 xor eax,eax lea edx,tablica @Petla: mov [edx],eax add edx,4 dec ecx jnz @Petla end; inc(i, t); inc(tablica[t], i); end; Tak oto teraz zaprezentuje się kod procedury: 5: begin push ebp mov ebp,esp add esp,-$0c push ebx push esi mov esi,eax 6: t := min(j1, j2) cmp edx,[ebp-$10] jnl +$04 mov eax,edx jmp +$03 mov eax,[ebp-$10] mov ebx,eax 9: mov ecx,3 mov ecx,$00000003 10: xor eax,eax xor eax,eax 11: lea edx,tablica lea edx,[esp-$0c] 13: mov [edx],eax mov [edx],eax 14: add edx,4 add edx,$04 15: dec ecx dec ecx 16: jnz @Petla jnz -$08 18: inc(i,t); add esi,ebx 19: inc(Tablica[t], i) add [ebp+ebx*4-$10],esi 20: end pop esi pop ebx mov esp,ebp pop ebp ret Jak widać na tym przykładzie zastosowanie kodu asemblera, choć z pozoru pozwoliło zaoszczędzić dwie instrukcje, to w efekcie spowodowało rozrost bloku początkowego i końcowego. Kompilator ewidentnie zaczyna „szukać” wolnych rejestrów. Efekt taki spowodowany jest tym, że po wyjściu z bloku asemblera użyte rejestry nie mają określonej wartości. Dlatego stają się one bezużyteczne do przekazywania danych z kodów wcześniejszych do kodów dalszych. Kompilator radzi sobie poprzez wykorzystanie powolnego stosu.

Z tych powodów użycie asemblera w kodzie powinno być bardzo rozważne i najlepiej poparte analizami wydajnościowymi. Jeśli chcemy w jakiejś prostej procedurze użyć kodu niskopoziomowego, to często lepiej jest zapisać całą procedurę z użyciem tego kodu, niż tylko jej fragmentów (nie stosujemy wówczas słówka begin). Należy stosować taki zapis tylko wówczas, gdy mamy pewność, że będzie on znacznie bardziej optymalny od zapisu przez kompilator, a jednocześnie nie spowoduje zakłócenia efektów działania optymalizatora. Warto pamiętać, że wczesne wersje Delphi przed wejściem w kod asemblera odkładały wszystkie rejestry procesora na stos, a następnie przywracały je po opuszczeniu wstawki. Choć taka operacja pozwalała na niezakłócony przebieg optymalizacyjny reszty programu, to często była zbyteczne i znacząco wydłużała czas potrzebny na rozpoczęcie wykonywania właściwego kodu maszynowego.

Jest jednak jeszcze kilka powodów, dla których warto użyć kodu asemblera. Są to sytuacje, w których pewne zadania są nieosiągalne wprost z poziomu kodu wysokiego poziomu i musiałyby zostać zapisane znacznym nakładem pracy, podczas gdy procesor może zrealizować dane zadanie przy użyciu jednego polecenia (za przykład mogą służyć takie operacje, jak ROL i jej podobne).

Słowo kluczowe with

Tutaj sprawa nie jest taka prosta. Generalnie w większości przypadków zastosowanie tego słowa kluczowego poprawia czytelność kodu, a w pewnych sytuacjach potrafi wyeliminować konieczność deklarowania zmiennych. Niestety wpływ na wydajność bywa różny od sytuacji – może zarówno podnieść jak i obniżyć wydajność aplikacji. Z reguły wzrost wydajności odczujemy, jeśli posłużymy się tym słowem w tych wszystkich przypadkach, w których nie chcemy stosować pomocniczych zmiennych, a część obiektu, do którego się odwołujemy, musi być pobrana przez funkcję (nie jest rekordem, choć i tu bywają odstępstwa, głównie przy rekordach o elementach o rozmiarach nie będących wielkością 1, 2, 4 lub 8).

Jeśli dany fragment kodu używa małej ilości zmiennych i nie są to zmienne pomocnicze, które można zastąpić konstrukcją with, to z pewnością zastosowanie tego słowa kluczowego wpłynie poprawnie na czytelność kodu nie zmieniając jego wydajności. Jednak większość przypadków wymaga indywidualnej analizy, podczas której warto zwrócić uwagę nie tylko na ilość instrukcji, ale także relatywnych odwołań do adresów pamięci.

Jako, że nie potrafię wynaleźć prostego przypadku, gdy zastosowanie with potrafi dać wzrost wydajności, nie zamieszczam przykładu, lecz czekam na propozycje czytelnika.

Właściwy dobór właściwości komponentów

Jak wiadomo, wyświetlenie zawartości jakiegoś komponentu na ekran jest bardzo czasochłonnym zadaniem. Z tego powodu warto przyjrzeć się właściwościom wstawianych komponentów. Pod analizę poddam jeden z najczęściej wykorzystywanych, a mianowicie TLabel.

Komponent ten ma trzy własności, które bezpośrednio wpływają na to, jak komponent będzie rysowany, lecz nie mają wpływu na ostateczny wygląd w większości sytuacji:

  • AutoSize – ustawienie tej właściwości powoduje, że rozmiar komponentu dopasowuje się do zawartości tekstu. Jeśli rozmiar jest odpowiednio duży, a kontrolka nie ma reagować na położenie kursora myszy i nie ma innego tła, to różnic nigdy nie zobaczymy. Warto jednak nadmienić, że przewidzieć należy także fakt, że wpływ na rozmiar tekstu w głównej mierze – poza jego treścią – mają ustawienia czcionek u użytkownika.
  • ShowAccelChar – włączenie spowoduje, że label stanie się aktywny na skróty klawiaturowe, a litera poprzedzona znakiem ‘&’ zostanie podkreślona i stanie się klawiszem skrótu (w połączeniu z Alt). W większości przypadków jest to niepotrzebne, a czasem wręcz przeszkadza, gdy chcemy wyświetlać znaki ampersand (&) na etykiecie.
  • Transparent – to ustawienie powoduje, że tło pod tekstem staje się przeźroczyste. Jeśli tło jest w tym samym kolorze, co element, na którym umieszczona została etykieta, to ustawienie to nie zmienia niczego wizualnie na formie.

Jak zatem wpływają poszczególne opcje na wydajność wyświetlania? Do zbadania użyłem prostego kodu: for x := 1 to 70000 do begin Label1.Caption := Format('%d = %x', [x,x]); Label1.Repaint; end;Kod ten wyświetla 70 000 razy różny tekst w postaci liczby od 1 do 70 000 w postaci dziesiętnej oraz szesnastkowej, za każdym razem wymuszając przerysowanie. Wyniki zawarte są w poniższej tabeli. Przedstawia ona wszystkie możliwe kombinacje opisywanych ustawień oraz średni czas wykonywania jednego przebiegu pętli. Dodatkowo w prawej części przedstawione są różnice pomiędzy włączeniem a wyłączeniem danej opcji.

TransparentAutoSizeShowAccelCharCzas [μs]Różnice [μs]
ShowAccelCharAutoSizeTransparent
NIENIENIE157
NIENIETAK1592
NIETAKNIE258101
NIETAKTAK2602101
TAKNIENIE18629
TAKNIETAK188229
TAKTAKNIE30011442
TAKTAKTAK303311543

Jak widać z powyższej tabeli różnica pomiędzy skrajnymi przypadkami jest prawie dwukrotna! Najmniejszy wpływ na wydajność ma wyłączenie własności ShowAccelChar – możemy zaoszczędzić ok. 2 μs, czyli maksymalnie 1,3%. Włączenie przeźroczystości to strata już znacznie większa sięgająca 18%. Największy spadek wydajności zaobserwujemy włączając parametr AutoSize – możemy spowolnić w ten sposób wyświetlanie nawet o ponad 60%!

Warto jeszcze zwrócić uwagę na jeden fakt – domyślne ustawienia nie są najwydajniejszymi. Wyłączając automatyczne dopasowanie rozmiaru oraz przypisywanie skrótów zyskamy ok. 40% szybsze przerysowywanie komponentu TLabel. Wystarczy tylko pamiętać, by wcześniej nadać właściwy rozmiar uwzględniając przy tym możliwość wystąpienia większej czcionki u użytkownika.

Blokowanie przerysowania interfejsu komponentów bazujących na typie TStrings

Z poprzednim tematem łączy się kolejny. Najczęstszym niedopatrzeniem programistów jest zadbanie o to, czy komponent ma dokonać aktualizacji swej zawartości, czy też nie. Problem dotyczy wszystkich komponentów wizualnych, których zawartość bezpośrednio bazuje na typie TStrings (a więc i dziedziczący TStringList). Wszystkie obiekty tej klasy posiadają własność BeginUpdate, która określa, czy komponent ma dokonać przerysowania bądź innych działań związanych ze zmianą zawartości string-listy. Polecenia tego używa się przed blokiem programu, który dokonuje modyfikacji listy (dodanie, zmiana treści, usunięcie). To, czy lista jest w trakcie aktualizacji można odczytać poprzez zmienną UpdateCount. Po zakończeniu aktualizacji należy koniecznie wywołać metodę EndUpdate, która wywoła działania związane ze zmianą zawartości string-listy. Oczywiście tego typu zabiegów nie ma sensu wykonywać, jeśli dokonywana jest pojedyncza zmiana (te instrukcje są także wywoływane wewnętrznie przez metody Add, Delete itp.).

Rysowanie w oknach

Kolejny, bardzo często popełniany błąd wiążący się z problemem przerysowywania interfejsu, związany jest bezpośrednio z grafiką. Początkujący programiści próbują ułatwić sobie pracę poprzez rysowanie bezpośrednio na Canvie komponentu TImage. Nie trzeba się wówczas martwić przerysowaniem czy innymi aspektami. Niestety takie rozwiązanie jest bardzo powolne, szczególnie jeśli rysowana jest większa ilość obiektów, lub następuje bardzo częste przerysowanie (np. wirtualny efekt przeciągania jakiejś› figury). Dużo szybszym jest rysowanie po obszarze wirtualnej bitmapy (czyli zmiennej typu TBitmap), a następnie, po dokonaniu wszystkich operacji rysowania, których wynik można przedstawić użytkownikowi, przerysowanie jej w całości na komponent wizualny (poleceniem Draw). Warto także pamiętać, że rysowanie dużo szybciej odbywać się będzie po komponencie TPaintBox, gdyż nie wywołuje on automatycznego przerysowania zawartości. Wadą jest to, że należy samemu obsłużyć zdarzenie OnPaint.

Informacje o poradzie
Poradę czytano:9 143 razy
Ocena porady:
5,82 (34 oceniających)
(Kliknij właściwą gwiazdkę, by oddać głos)

Wróć

Komentarze (4)


Ładowanie komentarzy... Trwa ładowanie komentarzy...

Uwaga! Wszystkie porady na tej stronie są mojego autorstwa i objęte są prawami autorskimi! Kopiowanie, publikowanie lub cytowanie całego tekstu bez wiedzy autora - ZABRONIONE! Dowiedz się więcej o prawach autorskich

Strona istnieje od 25.01.2001
Ta strona używa plików Cookie.
Korzystając z niej wyrażasz zgodę na przetwarzanie danych a zakresie podanym w Polityce Prywatności.
 
archive To tylko kopia strony wykonana przez robota internetowego! Aby wyświetlić aktualną zawartość przejdź do strony.

Optymalizowane dla przeglądarki Firefox
© Copyright 2001-2024 Dawid Najgiebauer. Wszelkie prawa zastrzeżone.
Ostatnia aktualizacja podstrony: 11.07.2023 21:16
Wszystkie czasy dla strefy czasowej: Europe/Warsaw