Sztuka kompromisu, czyli jak efektywnie debugować kod po optymalizacji
| TechnikaKażdy programista systemów wbudowanych, który zetknął się z zagadnieniem debugowania kodu poddanego procesowi optymalizacji, ma świadomość związanych z tym problemów. Być może zastanawiał się również, dlaczego wykorzystywany przez niego debugger, wcześniej będący tak niezawodnym narzędziem, stracił nagle swoją użyteczność - pokazuje błędne wartości zmiennych, omija zastawione pułapki i nie potrafi prześledzić wszystkich instrukcji programu. W artykule postaramy się odpowiedzieć na pytanie, jak uniknąć tego typu problemów i czy w każdej sytuacji jest to w ogóle możliwe.
Optymalizacja kodu wynikowego dokonywana przez kompilator jest procesem bardzo użytecznym - pozwala zarówno zmniejszyć rozmiar programu, jak i przyspieszyć jego wykonywanie. Niestety, nieuniknioną konsekwencją optymalizacji jest utrata pełnej odpowiedniości pomiędzy kodem źródłowym a zestawem poleceń wykonywanych przez procesor.
Kompilator potrafi poprzestawiać kolejność instrukcji, połączyć je ze sobą, zastąpić zmienne wartościami stałymi czy ingerować w przebieg pętli. Zabiegi te negatywnie wpływają na efektywność debugowania programu - debugger nie jest już w stanie krok po kroku prześledzić wszystkich wykonywanych przez procesor instrukcji.
Jest to niewątpliwie rezultat niekorzystny, gdyż łatwość debugowania również uważa się za jedną z kluczowych zalet oprogramowania, pozwalającą znacząco skrócić czas do wypuszczenia gotowego produktu na rynek. Czy konieczne jest zatem poszukiwanie kompromisu pomiędzy optymalizacją programu (zarówno pod względem rozmiaru, jak i szybkości) a możliwością jego sprawnego debugowania? W dalszej części artykułu spróbujemy znaleźć na to odpowiedź, jak również pokazać wpływ optymalizacji na skuteczność procesu debugowania.
Ilustracja problemu
By pokazać istotę problemu, rozważmy sytuację, w której przykładowy kod źródłowy przedstawiony na rysunku 1 poddany został optymalizacji ze względu na rozmiar kodu wynikowego. Następnie rozpoczęto debugowanie otrzymanego programu. Wykonywanie instrukcji zatrzymało się na jednej z ustawionych pułapek - debugger pokazuje, że nastąpiło to przed realizacją 14. linijki funkcji compute_nonzero(), przedstawia również aktualne wartości zmiennych.
Nawet pobieżna analiza kodu programu prowadzi do wniosku, że przedstawiane przez debugger informacje nie mogą być prawdziwe. Jeśli program zatrzymał się wewnątrz tego bloku warunkowego, wartość zmiennej a w momencie sprawdzania warunku (wyrażenie else if) powinna być dodatnia. Co więcej, wartość tej zmiennej nie podlega modyfikacji wewnątrz bloku, więc w chwili zatrzymania programu musi być taka sama jak w momencie, gdy sprawdzany był warunek wyrażenia else if.
Mimo to debugger w oknie podglądu danych pokazuje, że a jest ujemne - program nie mógł zatem zatrzymać się na tej pułapce, gdyż ta część kodu w ogóle nie powinna być wykonana. Jak to możliwe, że debugger dopuszcza do tak absurdalnej sytuacji? Czy przyczyna błędu leży tylko po jego stronie?
Dziwne zachowanie debuggera tłumaczy dopiero analiza kodu wynikowego wypracowanego przez kompilator. Na listingu 1 przedstawiono fragment otrzymany po kompilacji funkcji compute nonzero(). Pogrubioną czcionką zaznaczono instrukcje źródłowe odpowiadające poleceniom asemblerowym. Jak pamiętamy, celem kompilatora była minimalizacja objętości kodu wynikowego (włączono optymalizację ze względu na rozmiar).
Kompilator zauważył, że w programie dwukrotnie występuje przypisanie cached=result (w 8. i 14. linijce funkcji), które może zostać zastąpione jednym wspólnym blokiem instrukcji oraz poleceniem skoku - tego typu operację określa się jako tail merge lub cross jump. Dzięki temu, zamiast 6 rozkazów asemblerowych (zdublowana operacja przypisania), otrzymano tylko 4 (polecenie skoku oraz operacja przypisania).
Takie działanie wpływa negatywnie na pracę debuggera. Wykonując instrukcje z sekcji LABEL_tail, nie jest w stanie rozróżnić, czy pochodzi ona z pierwszego, czy drugiego bloku warunkowego. To właśnie jest przyczyną nieprawidłowego działania pokazanego na rysunku 1. By naprawić ten problem, należałoby nakazać kompilatorowi zachowanie pełnego odwzorowania kodu źródłowego, co skutkowałoby ponownym zwiększeniem rozmiaru kodu wynikowego.
Przykłady optymalizacyjnych działań kompilatora
By lepiej poznać źródła potencjalnych problemów związanych z debugowaniem, poniżej przedstawiono typowe działania optymalizacyjne wykonywane przez kompilator.
Zamiana kolejności instrukcji to operacja, która w wielu sytuacjach pozwala przyspieszyć wykonywanie programu, np. dzięki skróceniu czasu oczekiwania przez procesor na dostęp do pamięci. Na listingu 2 pokazano przykład takiego działania. Kod wynikowy bezpośrednio odwzorowujący kod źródłowy zawiera następujące po sobie instrukcje load i store (zaznaczone na czerwono).
Dostęp do pamięci odbywa się z reguły wolniej niż wynosi czas trwania jednej instrukcji procesora. Procesor musi więc czekać na zakończenie jednej operacji, by móc rozpocząć następną. Zamiana kolejności instrukcji (inkrementacja licznika pętli przed operacją store) pozwala skrócić czas wykonywania jednej iteracji pętli, nie zmieniając przy tym wyników końcowych.
Jednak podczas debugowania, gdy ustawi się pułapkę przy instrukcji arr[i] = tmp, debugger pokaże nieprawidłową wartość zmiennej i, ponieważ została ona zwiększona przed wykonaniem tej instrukcji.
Eliminacja wyrażeń wspólnych to kolejny często wykonywany zabieg optymalizacyjny, który pozwala zmniejszyć zarówno objętość kodu, jak i czas wykonywania programu. Przykład takiej operacji pokazano na listingu 3. Po optymalizacji wyrażenie "a+b" obliczane jest tylko jeden raz (w oryginalnym kodzie źródłowym dwukrotnie), nie ma również potrzeby korzystania z lokalnej zmiennej j.
Przykładem bardziej agresywnej techniki optymalizacyjnej pozwalającej przyspieszyć wykonywanie programu jest tzw. rozwijanie pętli. Technika ta polega na redukcji liczby iteracji pętli poprzez powielenie kodu tworzącego jej ciało. Uzyskana w ten sposób bardziej liniowa struktura kodu zmniejsza liczbę operacji sprawdzenia warunku pętli oraz modyfikowania jej indeksu. Tak przekształcona pętla nie może być jednak skutecznie debugowana.
"Złoty środek", czyli poszukiwania optymalnego rozwiązania
Od programu oczekuje się zazwyczaj następujących zalet: dużej szybkości działania, małego rozmiaru kodu oraz jak najmniejszej liczby błędów (co łatwiej osiągnąć, gdy program można skutecznie debugować). Zależność między tymi trzema cechami pokazana została na rysunku 2. Niestety niemożliwa jest zazwyczaj jednoczesna poprawa wszystkich trzech wymienionych parametrów.
Konieczne jest znalezienie równowagi pomiędzy poziomem optymalizacji kodu a możliwościami jego skutecznego debugowania. W przypadku stosowania optymalizacji trzeba również podjąć decyzję, czy zwiększać szybkość działania programu, czy raczej zmniejszać objętość jego kodu wynikowego. Dodatkowo należy pamiętać, że im wyższy poziom optymalizacji, tym dłuższy całkowity czas kompilacji programu, co może mieć znaczenie szczególnie w przypadku dużych projektów.
Odmienne podejścia do tworzenia oprogramowania
Można wyróżnić dwa rodzaje podejścia do tworzenia projektu programistycznego, będące odmiennymi sposobami rozwiązywania opisanego wyżej problemu - model Single Build oraz model Dual Build.
W modelu Single Build (rys. 3) tworzy się tylko jedną konfigurację programu, która służy do testowania i debugowania, a następnie traktowana jest jako wersja finalna. Oznacza to, że dokładnie ten sam kod wynikowy, nad którym pracował programista podczas testów i debugowania, wypuszczany jest na rynek jako produkt końcowy.
Zaletą tego typu podejścia jest łatwość konfiguracji oraz zarządzania oprogramowaniem. Korzystanie z tylko jednej wersji projektu wiąże się również z koniecznością szukania kompromisu - ustawienia kompilacji muszą zostać dobrane w taki sposób, by spełniały wymagania produktu końcowego (rozmiar kodu i szybkość działania programu), umożliwiając przy tym skuteczne debugowanie programu. Korzystając z rysunku 2, oprogramowanie wytworzone wg modelu Single Build należałoby umieścić w okolicach środka osi debugowanie-optymalizacja.
Całkiem inne podejście oferuje model Dual Build, w którym z tych samych plików źródłowych tworzy się dwie konfiguracje projektu - jedną (konfiguracja "Debug") przeznaczoną do debugowania oraz drugą (konfiguracja "Release") będącą wersją finalną.
Do testów programista wykorzystuje kod wynikowy uzyskany z konfiguracji "Debug". Ta wersja projektu w zasadzie nie jest poddawana optymalizacji (lub jest w minimalnym stopniu), zamiast tego oferuje jednak pełne wsparcie procesu debugowania. W kodzie można umieścić dodatkowe, kompilowane tylko w tej konfiguracji wyrażenia, takie jak asercje (patrz ramka) czy sprawdzenia, które pozwolą szybciej wykrywać i naprawiać ewentualne błędy. Odnosząc się do rysunku 2, konfiguracja "Debug" umieszczona jest na osi debugowanie-optymalizacja po stronie debugowania.
Konfigurację "Release" traktuje się jako wersję finalną programu, przeznaczoną do wypuszczenia na rynek. Wykorzystuje ona najlepsze możliwe ustawienia optymalizacji, lecz uzyskany w procesie kompilacji kod wynikowy nie jest szczegółowo badany i testowany, ponieważ wysoki stopień optymalizacji znacząco utrudnia pracę debuggera.
Dzięki takiemu podejściu nie trzeba szukać kompromisu między jakością kodu wynikowego a łatwością jego debugowania. Projektant może maksymalnie wykorzystać optymalizacyjne możliwości kompilatora w zakresie poprawy szybkości działania programu lub zmniejszania jego objętości.
Wadą modelu Dual Build jest przede wszystkim konieczność jednoczesnego opracowania i utrzymywania dwóch konfiguracji programu, co jest bardziej pracochłonne w porównaniu do modelu Single Build oraz zajmuje dwukrotnie większy obszar pamięci dysku twardego.
Dodatkowo, choć obie konfiguracje korzystają z tego samego kodu źródłowego, istnieje niebezpieczeństwo pojawienia się błędów występujących jedynie w finalnym kodzie wynikowym (wersji "Release"), przez co bardzo trudnych do wykrycia i usunięcia.
Rysunek 5 przedstawia wzajemne położenie omówionych konfiguracji na osi debugowanie-optymalizacja. Każda z konfiguracji charakteryzuje się odmiennymi ustawieniami optymalizacji wykorzystywanymi przez kompilator. Wybór między szybkością działania a rozmiarem programu, mający szczególne znaczenie w przypadku wersji "Release", symbolizowany jest na rysunku przez oś pionową (szybkość-rozmiar).
Globalne zmniejszanie objętości a lokalna poprawa szybkości
Zastanawiając się na wyborem odpowiednich ustawień kompilatora, warto pamiętać o następującej zasadzie: zmniejszanie objętości kodu uzyskuje się globalnie, a poprawę szybkości lokalnie. Oznacza to, że jeśli chcemy ograniczyć rozmiar programu, musimy zastosować optymalizację ze względu na rozmiar w odniesieniu do całego kodu źródłowego.
Można w przybliżeniu przyjąć, że spadek objętości kodu programu jest wprost proporcjonalny do wielkości obszaru kodu źródłowego, dla którego zastosowano optymalizację. Przykładowo, jeśli optymalizacja całego kodu źródłowego pod względem rozmiaru pozwoliła zmniejszyć objętość kodu wynikowego o 20%, to optymalizując w ten sposób tylko połowę kodu źródłowego, uzyskamy program mniejszy o ok. 10%.
By opisać możliwości poprawy szybkości programu, stosuje się często zasadę 80-20 (zwaną też zasadą Pareto), która zakłada, że wykonanie 20% instrukcji kodu źródłowego zajmuje 80% czasu działania całego programu. Oznacza to, że w programie można zidentyfikować bloki instrukcji stanowiące tzw. wąskie gardła, mające decydujący wpływ na całkowity czas wykonywania programu.
Optymalizacja pod względem szybkości jedynie tych krytycznych obszarów pozwoli uzyskać rezultat zbliżony do tego, gdy zastosuje się tego typu optymalizację w odniesieniu do całego kodu programu. Dla pozostałych 80% kodu źródłowego można zaś zastosować ustawienia kompilatora zmniejszające rozmiar programu lub ułatwiające debugowanie.
Dobre praktyki
Biorąc pod uwagę przedstawione dotychczas spostrzeżenia, można sformułować kilka uniwersalnych zasad, które powinny ułatwić znalezienie zadowalającego kompromisu pomiędzy jakością kodu wynikowego a możliwościami jego skutecznego debugowania:
- należy pamiętać o tym, że zmniejszenie objętości kodu wynikowego uzyskuje się globalnie. Warto rozpocząć od domyślnych ustawień kompilatora dotyczących optymalizacji pod względem rozmiaru i zwiększać je tak długo, aż otrzyma się satysfakcjonujące rezultaty.
- aby uzyskać poprawę szybkości, należy zidentyfikować obszary krytyczne, czyli czasochłonne wąskie gardła programu. Aby ułatwić to zadanie, można posłużyć się profilerem. Po znalezieniu tych miejsc w kodzie należy poddać je optymalizacji pod względem szybkości.
Warto zastanowić się nad wyborem odpowiedniego modelu tworzenia oprogramowania. Jeśli opracowany projekt spełnia zakładane wymagania przy zastosowaniu ograniczonych ustawień optymalizacyjnych, zapewniając jednocześnie wymaganą efektywność debugowania, rozsądniej jest skorzystać z modelu Single Build.
W prawidłowej ocenie pracy debuggera mogą pomóc poniższe pytania. Jeśli odpowiedź na przynajmniej jedno z nich jest negatywna, być może należałoby rozważyć wykorzystanie modelu Dual Build:
- czy dostępny jest podgląd wartości dla wszystkich zmiennych?
- czy wskazywane wartości zmiennych są poprawne?
- czy wykonywanie programu odbywa się prawidłowo (uruchamianie pułapek, praca krokowa, wchodzenie i wychodzenie z funkcji)?
- czy dla każdej instrukcji kodu źródłowego można ustawić pułapkę?
Jeśli nie uda się dobrać ustawień kompilatora w taki sposób, by jednocześnie zaspokoić wymagania co do rozmiaru i szybkości działania programu, należy rozważyć wprowadzenie zmian w kodzie źródłowym.
Podsumowanie
Dokonywana przez kompilator optymalizacja kodu źródłowego pozwala znacząco poprawić parametry programu - zmniejszyć jego rozmiar oraz zwiększyć szybkość działania. Negatywnym jej skutkiem jest niestety pogorszenie jakości pracy debuggera. Nie oznacza to jednak, że chcąc uzyskać dobrej jakości kod wynikowy, musimy pogodzić się z utratą zdolności jego debugowania.
Możliwe jest jednoczesne korzystanie z kilku różnych konfiguracji kompilatora, zaś umiejętne zarządzanie jego ustawieniami pozwala znacząco ułatwić pracę programiście oraz umożliwić uzyskanie dostosowanego do aktualnych potrzeb kodu wynikowego.
Damian Tomaszewski
*Asercja - predykat (wyrażenie zwracające prawdę lub fałsz), umieszczony w określonym miejscu w kodzie programu, który powinien być zawsze prawdziwy. W przypadku gdy jest on fałszywy, powoduje przerwanie wykonywania programu. Tego typu wyrażenia stosuje się przede wszystkim podczas testowania oprogramowania, np. dla sprawdzenia jego luk lub odporności na błędy.