Podstawy .NET: dwuetapowa kompilacja i WinDbg do analizy JIT

Tak naprawdę, moje posty powinienem rozpocząć od wyjaśnienia czym jest JIT. To jedno z podstawowych pojęć, które pojawia się w przypadku omawiania .NET. Wolałem jednak najpierw pokazać kilka programów napisanych w IL Assembly.  Kilka postów powinno dać już jakiś obraz czym jest IL.  Oczywiście kod piszemy w C# lub w innym języku wysokiego poziomu, więc wystarczy abyśmy ogólnie mieli pojęcie o IL.

Co to jest więc kompilacja JIT? Czym różni się od klasycznej? W językach niezarządzanych takich jak CPP, kompilator wygeneruje kod maszynowy. W świecie .NET kompilacja jest dwuetapowa. Używając Visual Studio nie generujemy kodu maszynowego, a właśnie IL – język pośredni, który na początku jest kompletnie bezużyteczny. Pomimo, że przypomina on kod maszynowy, to nie można go uruchomić na żadnej maszynie bez wcześniejszego przetworzenia.

Wspomniane “przetworzenie” to kolejna kompilacja, a mianowicie JIT (Just in Time).  W większości przypadków odbywa się w czasie rzeczywistym, w momencie pierwszego wywołania danej metody. Nie JIT’ujemy całej aplikacji od razu bo byłoby to zbyt wolne. Jeśli  w ogóle nie wywołamy jakieś metody, po prostu nie zostanie wygenerowany kod maszynowy dla niej. JIT zatem, to drugi etap kompilacji, który wygeneruje już konkretny kod maszynowy, bardzo często specyficzny dla konkretnej architektury. Ma to wiele zalet, takich jak niezależność od CPU i możliwość optymalizacji kodu dla konkretnej konfiguracji.

Naturalne jest, że JIT dodaje pewien overhead do aplikacji. Wywołanie każdej metody pierwszy raz jest wolniejsze niż kolejne. Z tego względu, jeśli piszemy jakieś benchmarki, musimy mieć to na uwadze, że wyniki mogą być sfałszowane przez JIT. Istnieje możliwość z JIT’owania całej aplikacji przed uruchomieniem programu, ale o tym kiedy indziej. Polecam również mój stary wpis o tzw. MultiCore JIT, który został dodany w .NET 4.5 i ma na celu przyśpieszenie JIT poprzez zrównoleglenie kompilacji.

Podkreślę jeszcze raz, że drugi etap komplikacji (JIT) w przeciwieństwie do pierwszego (Visual Studio, kompilator C#), nie jest wykonywany dla całej aplikacji, a wyłącznie dla konkretnej metody. Po prostu dzieje się to w czasie rzeczywistym i nie ma tyle czasu, aby analizować cały kod. JIT zatem nie dysponuje informacjami o całym kodzie i może wykonać optymalizacje dotyczące wyłącznie kawałka kodu ( w przeciwieństwie do komplikacji c#). Prawdziwa optymalizacja wynika jednak z faktu, że JIT bierze pod uwagę architekturę komputera (CPU) i może wygenerować instrukcje maszynowe , które teoretycznie powinny być optymalne.

Większość książek zawiera powyższe teoretyczne informacje i z tego względu, lepiej popraktykować tutaj. Posłużę się tutaj narzędziem windbg.exe. Jeśli nie pracowaliście z tym debuggerem to polecam MSDN. Windbg.exe jest bardzo przydatny w środowisku produkcyjnym, gdzie nie mamy kodu źródłowego i tym bardziej Visual Studio. Narzędzie jest dość trudne w obsłudze, ale przydatne w eksperymentowaniu oraz analizowaniu błędów, które zdarzyły się na produkcji (np. poprzez memory dump).

image

Nasz eksperyment przeprowadzimy na:

class Program
{
   static void Main(string[] args)
   {
       Display("1");
       Console.ReadLine();
       Display("2");
   }

   private static void Display(string text)
   {
       Console.WriteLine(text);
   }
}

Wywołujemy tam dwukrotnie Display. Za pierwszym razem powinna zostać dokonana kompilacja JIT, a przy drugim wywołaniu, będziemy już dysponować kodem maszynowym.

Pierwszy etap kompilacji, który wygeneruje IL jest prosty i wykonujemy go np. bezpośrednio z Visual Studio. Poskutkuje to wygenerowanym plikiem EXE:

image

Windbg, który stanowi część Windows SDK, jest ogólnym narzędziem do debuggowania. Domyślnie nie wie nic o CLR i zarządzanym kodzie. Z tego względu, musimy zainstalować dodatkowe rozszerzenie SOS.DLL. Aby załadować SOS wystarczy wykonać polecenie:

.load C:\Windows\Microsoft.NET\Framework\v4.0.30319\sos.dll

Kolejna komenda to:

sxe ld:clrjit

Potrzebujemy jej, aby debugger został powiadomiony, gdy CLRJIT jest załadowany.

Następnie wpisujemy g, aby kontynuować:

g

Dzięki poprzedniemu poleceniu zobaczymy:

0:000> g
(1134.17d8): Unknown exception - code 04242420 (first chance)
ModLoad: 730f0000 7316d000   C:\Windows\Microsoft.NET\Framework\v4.0.30319\clrjit.dll
eax=00000000 ebx=00800000 ecx=00000000 edx=00000000 esi=00000000 edi=7f06e000
eip=7721cfbc esp=00bce5dc ebp=00bce638 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
ntdll!NtMapViewOfSection+0xc:
7721cfbc c22800          ret     28h

Ustawmy breakpoint na metodzie Display za pomocą:

!bpmd ConsoleApplication12.exe ConsoleApplication12.Program.Main

Mamy dokładnie jedną metodę o takiej nazwie więc debugger wyświetli:

Found 1 methods in module 00cc2ed4...
MethodDesc = 00cc37a8
Adding pending breakpoints...

00cc37a8 to identyfikator metody. Dzięki niemu możemy wyświetlić IL wpisując polecenie:

!dumpil 00cc37a8

Wynik:

IL_0000: nop 
IL_0001: ldstr "1"
IL_0006: call ConsoleApplication12.Program::Display
IL_000b: nop 
IL_000c: call System.Console::ReadLine 
IL_0011: pop 
IL_0012: ldstr "2"
IL_0017: call ConsoleApplication12.Program::Display
IL_001c: nop 
IL_001d: ret 

Taki sam kod IL zobaczymy oczywiście w Reflector. Nie powinno być dla nas zaskoczeniem, że pierwszy etap kompilacji został wykonany – mamy w końcu EXE. Naszym celem jest udowodnienie, że JIT nie został jeszcze wykonany, ponieważ metoda nie została wywołana.

Kolejna komenda, wyświetli kod maszynowy:

!u 00cc37a8

Wynik:

00a30050 55              push    ebp
00a30051 8bec            mov     ebp,esp
00a30053 83ec08          sub     esp,8
00a30056 33c0            xor     eax,eax
00a30058 8945f8          mov     dword ptr [ebp-8],eax
00a3005b 894dfc          mov     dword ptr [ebp-4],ecx
00a3005e 833d78318e0000  cmp     dword ptr ds:[8E3178h],0
00a30065 7405            je      00a3006c
00a30067 e8a4c2c673      call    clr!JIT_DbgIsJustMyCode (7469c310)
00a3006c 90              nop

c:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication12\ConsoleApplication12\Program.cs @ 13:
00a3006d 8b0d88214603    mov     ecx,dword ptr ds:[3462188h] ("1")
00a30073 ff15b0378e00    call    dword ptr ds:[8E37B0h] (ConsoleApplication12.Program.Display(System.String), mdToken: 06000002)
00a30079 90              nop

c:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication12\ConsoleApplication12\Program.cs @ 14:
00a3007a e8b1a71b73      call    mscorlib_ni+0xa0a830 (73bea830) (System.Console.ReadLine(), mdToken: 06000995)
00a3007f 8945f8          mov     dword ptr [ebp-8],eax
00a30082 90              nop

c:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication12\ConsoleApplication12\Program.cs @ 15:
00a30083 8b0d8c214603    mov     ecx,dword ptr ds:[346218Ch] ("2")
00a30089 ff15b0378e00    call    dword ptr ds:[8E37B0h] (ConsoleApplication12.Program.Display(System.String), mdToken: 06000002)
00a3008f 90              nop

c:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication12\ConsoleApplication12\Program.cs @ 16:
00a30090 90              nop
00a30091 8be5            mov     esp,ebp
00a30093 5d              pop     ebp
00a30094 c3              ret

Proszę zwrócić uwagę na wywołanie metody Display:

00a30073 ff15b0378e00    call    dword ptr ds:[8E37B0h] (ConsoleApplication12.Program.Display(System.String), mdToken: 06000002)

Ustawiając teraz breakpoint na Display dostaniemy:

0:000> !bpmd ConsoleApplication12.exe ConsoleApplication12.Program.Display
Found 1 methods in module 004f2ed4...
MethodDesc = 004f37b4
Adding pending breakpoints...

Oznacza to, że 004f37b4 jest identyfikatorem Display. Spróbujmy wywołać !u 004f37b4, aby otrzymać kod:

0:000> !u 004f37b4
Not jitted yet

Widzimy wyraźnie, że jest to niemożliwe – drugi etap kompilacji Display nie nastąpił jeszcze. Jeśli pozwolimy na pierwsze wykonanie tej metody (polecenie “g”) to będziemy mogli zobaczyć kod za pomocą tego samego polecenia (!u 004f37b4):

005800d8 55              push    ebp
005800d9 8bec            mov     ebp,esp
005800db 50              push    eax
005800dc 894dfc          mov     dword ptr [ebp-4],ecx
005800df 833d78314f0000  cmp     dword ptr ds:[4F3178h],0
005800e6 7405            je      005800ed
005800e8 e823c21174      call    clr!JIT_DbgIsJustMyCode (7469c310)
005800ed 90              nop

c:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication12\ConsoleApplication12\Program.cs @ 24:
005800ee 8b4dfc          mov     ecx,dword ptr [ebp-4]
005800f1 e84601fa72      call    mscorlib_ni+0x34023c (7352023c) (System.Console.WriteLine(System.String), mdToken: 060009a3)
005800f6 90              nop

c:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication12\ConsoleApplication12\Program.cs @ 25:
005800f7 90              nop
005800f8 8be5            mov     esp,ebp
005800fa 5d              pop     ebp
005800fb c3              ret

Możemy również napisać mały benchmark, aby pokazać, że faktycznie zachodzi JIT:

class Program
{
   static void Main(string[] args)
   {
       Stopwatch stopWatch = Stopwatch.StartNew();
       Display("1");
       Console.WriteLine(stopWatch.ElapsedTicks);
       
       stopWatch.Restart();
       Display("2");
       Console.WriteLine(stopWatch.ElapsedTicks);
   }

   private static void Display(string text)
   {
       Console.WriteLine(text);
   }
}

Na ekranie pojawi się:

image

Widać, że drugie wywołanie jest wyraźnie szybsze ponieważ kod jest już po JIT.

Polecam zapoznanie się z narzędziem WinDbg, szczególnie dla osób, które muszą naprawiać błędy występujące wyłącznie na produkcji lub w innym specyficznym środowisku.

2 thoughts on “Podstawy .NET: dwuetapowa kompilacja i WinDbg do analizy JIT”

Leave a Reply

Your email address will not be published.