Kompilator-optymalizator

0
353

Psotna optymalizacja

Wstęp

Jak wiadomo, projekty w Visual Studio mają predefiniowane dwie opcje kompilacji – Debug i Release. Najczęściej korzysta się z tej pierwszej, ponieważ umożliwia ona swobodne „odpluskwianie” z całym dobrodziejstwem oferowanego przez VS przybornika narzędziowego. Z kolei opcji „Release” używa się do skompilowania projektów do formy gotowej do użycia w środowisku produkcyjnym. Czym to się tak naprawdę różni?

Polecam świetnie napisany post Erica Lipperta:
https://blogs.msdn.microsoft.com/ericlippert/2009/06/11/what-does-the-optimize-switch-do/

a także artykuł Scotta Hanselmana:
http://www.hanselman.com/blog/ReleaseISNOTDebug64bitOptimizationsAndCMethodInliningInReleaseBuildCallStacks.aspx

w którym bardzo szczegółowo opisał on to, co chciałem opisać ja.

Skoro wszystko mamy w tych dwóch linkach, to tylko szybkie, dwupunktowe, podsumowanie.

1. Generowanie symboli

  1. Opcja kompilacji /debug włącza/wyłącza tworzenie danych do debugowania. Opcja ta jest automatycznie włączana przez VS w trybie „Debug”.
  2. Opcja kompilacji /optimize określa, czy kompilator ma dokonać optymalizacji kodu.

Poniżej przedstawiam fragment pliku *.csproj ukazujący domyślne ustawienia obu trybów:

Jak widać, w tym przypadku interesują nas głównie elementy DebugSymbols oraz DebugType. DebugType można ustawić przechodząc do ustawień projektu, a dalej Build -> Advanced…

Okno dialogowe VS Project settings->Build->Advanced…

 

Są tam trzy opcje:

  • none – pliki pdb nie będą tworzone(podstawowe wiadomości dot. pdb świetnie opisał Piotr Zieliński na swoim blogu: http://www.pzielinski.com/?p=1041)
  • pdbonly – wygeneruje pliki pdb oraz zoptymalizowany kod bez atrybutu DebuggableAttribute
  • full – wygeneruje pliki pdb oraz atrybut tak, aby możliwe było pełne debugowanie z podglądaniem wartości itp.
Z doświadczenia widzimy, że jest to bardzo rozsądne tłumaczenie – rzeczywiście w trybie Debug możemy debugować kod i podglądać wartości, w trybie Release możemy tylko przejść krokowo po kodzie (F10/F11), a nie mamy możliwości podglądania wartości zmiennych.
Wygląda też więc na to, że element DebugSymbols (true/false) jest raczej nadmiarowy.

2. Optymalizacja

Optymalizacja wykonywana przez kompilator jest niskopoziomowa i polega głównie na:
  1. Dodaniu atrybutu Debuggable z wyłączoną opcją IsJITOptimizerDisabled. Pozwala to JIT na optymalizację kodu, zmiany kolejności linii kodu oraz tworzenie tzw. inliningu czyli zwijania kodu. Przykładowo, zamiast wywołania metody Add, która dodaje dwie liczby, kompilator zmieni to wywołanie na dodawanie dwóch liczb. Optymalizuje to działanie kodu, ale bardzo utrudnia debugowanie (jak widzimy w artykule Scotta Hanselmana oraz na moim przykładzie)
  2. Analizie kodu i eliminowaniu kodu martwego (niedostępnego). Np. if(false)Execute(), będzie całkowicie usunięte.
  3. Usuwa z kodu IL instrukcje NOP (czyli „puste”), które umożliwiają ustawienie „pułapek” w kodzie (breakpoints).
Jak widać, wszystkie powyższe czynności bardzo utrudniają debugowanie, a żadna z nich, jednostkowo, nie daje dużego przyrostu wydajności. Jednak taki przyrost może wystąpić i dlatego na produkcję raczej daje się biblioteki skompilowane w ten właśnie sposób.

Znaleziona ciekawostka

Do napisania tego artykułu zainspirowała mnie moja przygoda z testami. Cały czas uczę się ich pisania i mimo tego, cały czas mam z nimi problemy. W przeważającej części to opowiadanie na inny artykuł, ale tym razem napisałem coś takiego:
Chciałem sprawdzić, czy metoda GetCallerName zwraca nazwę metody wołającej. Umieściłem więc ją w zagnieżdżonych metodach i okazało się, że działa prawidłowo. Puściłem kod do repozytorium a TeamCity rzuciło błąd – mój nowy test nie działał prawidłowo.
Po krótkim śledztwie dowiedziałem się, że przyczyną jest to, że TeamCity całą kompilację przeprowadza w trybie Release, a ja sprawdzałem kod w trybie Debug. Po przeczytaniu poprzedniego podrozdziału tego artykułu już wiadomo o co chodzi – optymalizacja… W trybie Debug wartość zmiennej actual wynosiła DebugUtilTests.Deep3, a w trybie Release: DebugUtilTests.GetCallerName_ReturnsProperName. Wiemy dobrze, z czego to wynika, otóż metody zagnieżdżone Deep2, Deep3 oraz Deep4 niczemu nie służą i dlatego kompilator zdecydował się na ich usunięcie (inlining). 

Rozwiązaniem tego problemu było dodanie atrybutu

[MethodImpl(MethodImplOptions.NoOptimization)]

Wg Scotta Hanselmana możliwe jest też użycie w atrybucie flagi MethodImplOptions.NoInlining, co brzmi bardzo sensownie.

I to w sumie tyle – ot, taka przypominajka, że wszystko może pójść nie do końca tak jak zakładamy – czy to przez niewiedzę, czy niedopatrzenie.
Pisanie tego artykułu zaś przypomina, że wszystko o czym chcę napisać już zostało opisane i to dużo lepiej przez kogoś innego.