Ostatnio w pracy miałem za zadanie dokonać optymalizacji pewnego fragmentu kodu. Postanowiłem, że to dobra okazja, aby zebrać trochę porad dotyczących optymalizacji i się nimi podzielić. Część porad odnosi się konkretnie do C, ale większość można uogólnić na każdy język. Entuzjaści wyciskania procesorów do granic możliwości mogą jednak poczuć się zawiedzeni, ponieważ nie będę się skupiał na trickach urywających pojedyncze takty. Bliżej mi do podejścia przedstawionego w dwóch zasadach optymalizacji:

  1. Nie rób tego.
  2. (Tylko dla ekspertów) Jeszcze tego nie rób.

Przedwczesna optymalizacja to zło

Stosowanie optymalizacji jest dla programisty sposobem pokazania swojej wiedzy. Możemy w ten sposób pokazać, że znamy kompilator i architekturę procesora. Wiemy, że skok do funkcji to dodatkowa instrukcja, dlatego kod będzie szybciej działał jeśli napiszemy wszystko w jednej funkcji. Tak samo z pętlami – sprawdzenie warunku po każdej iteracji to dodatkowa instrukcja, możemy więc wykonać kilka iteracji na raz. Poza tym w warunkach lepiej robić porównanie z zerem niż z innymi liczbami, bo w asemblerze jest to robione jedną instrukcją. Jednak postępując w ten sposób prawdopodobnie nie napiszemy programu, który szybciej się wykonuje. Mamy za to bardzo duże szanse, żeby strzelić sobie w stopę.

Zbyt wczesna optymalizacja może być źródłem wielu problemów. Kod po takiej optymalizacji jest mniej zrozumiały i trudniej do niego wprowadzać modyfikacje. Trudniejsze również jest debugowanie. Może się okazać, że taka optymalizacja wcale nie da pożądanego skoku wydajności. Natomiast jest pewne, że wpłynie negatywnie na wydajność programistów.

Optymalizacja wysokopoziomowa

Okazuje się, że największe zyski możemy osiągnąć nie urywając pojedyncze instrukcje, tylko ulepszając rozwiązanie na wyższych poziomach abstrakcji. Dlatego najlepszą optymalizacją jest tworzenie dobrze przemyślanych modułów realizujących w jasny i prosty sposób postawione zadania. Jeżeli przykładamy wagę do prostoty kodu, w naturalny sposób redukujemy ilość pętli i instrukcji warunkowych, które odpowiadają za szybkość wykonywania.

Poza analizą przepływu sterowania, kolejnym ważnym aspektem jest dobranie do realizacji zadania odpowiednich struktur danych. Jeżeli na przykład zależy nam na szybkim dostępie do danych możemy użyć hash table, jednak musimy się wtedy liczyć ze znaczącym wzrostem zużycia pamięci. Często optymalizacja jest szukaniem balansu pomiędzy szybkością wykonania, a zużyciem pamięci. Z tym problemem można się spotkać szczególnie w mikrokontrolerach, gdzie pamięć bywa bardzo ograniczona.

Identyfikacja krytycznych elementów

Dopiero kiedy zaprojektujemy odpowiednie rozwiązanie i zaimplementujemy je, żeby potwierdzić, że działa zgodnie z założeniami, jesteśmy w stanie sprawdzić, czy optymalizacja jest potrzebna i które elementy są krytyczne dla wydajności. Tylko dysponując takimi danymi wiemy, że nie stracimy czasu poprawiając czas wykonania jednego elementu z 5ms do 3ms, kiedy inny wykonuje się 2 sekundy. Poza tym może się okazać, że system spełnia założenia i optymalizacja nie jest potrzebna.

Mając takie newralgiczne punkty, możemy wziąć się za ich optymalizację. W tym celu najpierw analizujemy ich złożoność i szukamy uproszczeń. Analizując pętle szukamy fragmentów, które można wyciągnąć na zewnątrz. Instrukcje warunkowe w pętlach ustawiamy w takiej kolejności, aby pierwszy warunek powodował przejście do kolejnej iteracji w jak największej ilości przypadków. Poza pętlami i warunkami warto jeszcze zwrócić uwagę na operacje kopiowania dużych ilości danych i problemy wynikające ze współbieżności i przerwań. Analizujemy więc priorytety, aby dowiedzieć się co, kiedy i na jak długo może wywłaszczyć analizowany proces. Sprawdzamy sekcje krytyczne, aby nie trwały zbyt długo.

Czasem wąskim gardłem nie jest szybkość wykonywania programu, a jakaś interakcja ze światem zewnętrznym. Do popularnych przykładów należą interfejsy komunikacyjne, bazy danych, czy zewnętrzne pamięci. Aby zoptymalizować obsługę tych elementów warto przeanalizować ilość wykonywanych zapytań i ilość wysyłanych/otrzymywanych danych. Często warto przygotować w pamięci większe paczki danych do wysłania/zapisu, albo odpytać o więcej rekordów na raz. Takie małe zmiany mogą przekładać się na spore oszczędności czasu.

Optymalizacje nie zaburzające czytelności

Czytelny kod działający szybko jest lepszy niż tak samo czytelny kod działający wolno. Dlatego szczegółowa wiedza o architekturze na którą piszemy i jakieś specyficzne optymalizacje warto znać i stosować, jeśli nie pogarszają one czytelności. Moim ulubionym przykładem takiej optymalizacji jest rozmiar zmiennych domyślny dla architektury. Jeżeli na przykład architektura jest 32-bitowa, zmienne, argumenty przekazywane do funkcji i wartości zwracane przez funkcje najlepiej jeśli również są 32-bitowe. Zadeklarowanie zmiennych w ten sposób w żaden sposób nie zmniejsza czytelności, a kompilator wygeneruje mniej instrukcji.

Optymalizacje kompilatora

Wielu małych optymalizacji oszczędzających po kilka instrukcji nie musimy robić sami w kodzie, ponieważ wykona je za nas kompilator. Optymalizacje na niskim poziomie kompilator zwykle zrobi lepiej od nas i prosto napisany kod łatwiej mu się optymalizuje. Oznacza to, że podczas developmentu nie musimy tak się przejmować optymalizacją, bo zrobi to za nas maszyna. Uruchomienie kompilacji z włączoną optymalizacją ma jednak pewną wadę  – ciężko jest zdebugować kod krok po kroku, ponieważ komendy nie są wykonywane po kolei. Poza tym kompilując z optymalizacją możemy spowodować błędy, które bez optymalizacji nie są widoczne np. błędy związane z volatile. Możemy również włączyć optymalizację tylko dla konkretnych funkcji (w gcc atrybut optimize – link).

Podsumowanie

W początkowej fazie rozwoju projektu nie należy się specjalnie przejmować optymalizacją. Ważniejsze jest zapewnienie odpowiedniego designu i realizacja wymaganych funkcjonalności. Dobry design w wielu przypadkach jest wystarczająco optymalny. Dopiero mając gotową funkcjonalność i dane z testów potwierdzające problemy z wydajnością powinniśmy przystąpić do optymalizacji. Skupiamy się wtedy tylko na kluczowych elementach mających największy wpływ na wydajność. Próba optymalizacji całego kodu jest bardzo czasochłonna i może spowodować problemy z czytelnością kodu.