Just-In-Time , czyli kompilacja w .NET

10 lutego 2016 roku był dla mnie szczególnie ważnym dniem. Był to dzień mojej obrony pracy inżynierskiej na WAT. Po wygłoszeniu prezentacji o moim projekcie komisja egzaminacyjna przystąpiła do zadawania pytań, których tematyka może objąć całe 3,5 roku studiów. Wziąłem kartkę i zacząłem spisywać pytania coraz bardziej ciesząc się w duchu (bo znałem na nie odpowiedzi 😉 ), aż tu nagle pata niespodziewane, czwarte pytanie od mojego promotora – „Proszę opisać proces kompilacji w .NET”. AHA ! To łatwe ! No to tego… no… kurcze, jak to szło? Żeby się (jeszcze bardziej) nie kompromitować, pominę ten fragment opowieści. Kiedy emocje opadły doszedłem do wniosku, że nigdy nie zastanowiło mnie jak to się dzieje, że kiedy klikam w visualu w ten mały przycisk „build” , to po chwili mogę cieszyć się działającym (lub też nie) programem. Oczywiście studia dały mi pewien przekrój wiedzy dotyczący kompilacji, no ale cóż… Postanowiłem, że czas uzupełnić braki w wiedzy, a ten wpis ma przybliżyć tematykę osobom, które także nie wiedzą o co kaman. Czas więc na trochę teorii, ale najpierw chciałbym przedstawić rysunek, który powinien pomóc Wam w zrozumieniu tego procesu.

 

JIT

Faza 1. Kompilacja do kodu pośredniego

Pierwszym etapem jest kompilacja kodu (np. C#) przez wybrany kompilator do assembly ( exe lub dll ), które zawiera:

  • Common Intermediate Language (CIL) – kod pośredni zwany także bajtowym. Jest formą pośrednią pomiędzy kodem źródłowym, a natywnym. Jest zrozumiały dla człowieka, a zawarte w nim instrukcje zwykle mają długość 1 bajta, stąd też nazwa. Warto dodać, że kod pośredni jest wykonywany przez maszyny wirtualne, a nie procesor, dzięki czemu nie jest on zależny od architektury sprzętowej.
  • Metadane – zwierają opis struktur oraz danych znajdujących się w danym assembly.

Faza 2. Just-In-Time compilation

W zasadzie od tego momentu możemy mówić już o Common Languge Runtime (CLR), czyli o środowisku uruchomieniowym .NET, które wykonuje CIL poprzez jego kompilację do kodu natywnego. W tym celu CLR używa kompilatora just-in-time. Co ważne JIT domyślnie nie kompiluje całego kodu pośredniego do postaci natywnej. Zamiast tego kompilowane zostaje niezbędne minimum potrzebne do uruchomienia aplikacji, a cała reszta dopiero kiedy zajdzie taka potrzeba. JIT posiada trzy tryby pracy:

  • Normal JIT – jak nazwa sugeruje jest to tryb normalny/domyślny. Na starcie aplikacji kompilowane jest tylko minimum, a reszta w trakcie działania aplikacji. Ważne jest także to, że w tym trybie każdy skompilowany kod natywny zostaje umieszczony w cache-u. Dzięki temu kiedy zachodzi potrzeba wykonania go ponownie JIT nie musi znów kompilować CIL tylko bierze go z cache.
  • Pre JIT – przed uruchomieniem aplikacji cały kod zostaje skompilowany do postaci natywnej. Tryb ten jest dostępny poprzez Ngen (Native Image Generator). Kompilacji można dokonać komendą ngen install <ścieżka_do_assembly>
  • Econo JIT – działa dokładnie na takiej samej zasadzie jak normal JIT, z tą różnicą, że kod natywny nie jest cache-owany. W związku z tym każde wykonanie kodu CIL wiąże się z jego ponowną kompilacją.

Chciałbym jeszcze przedstawić Wam krótki przykład, który sprawdzi czy faktycznie normal JIT działa. W tym celu napisałem taki oto kodzik:

class Program
    {
        public static int Number;
 
        static void Main(string[] args)
        {
            while (true)
            {
                var key = Console.ReadKey();
 
                if (key.KeyChar == 'y')
                {
                    Execute();
                }
            }
        }
 
        public static void Execute()
        {
            Number = (int) Math.Ceiling(434.9m);
        }
    }

Metoda Main wchodzi do nieskończonej pętli while (wiem, że nie zaimplementowałem wyjścia z pętli 😉 ) po czym oczekuje na wpisanie przez użytkownika litery. Jeżeli tą literą jest ‚y’ wtedy wykonana zostaje metoda Execute(). Jak widać do uruchomieniatego kodu nie jest ona potrzebna i może zostać skompilowana już w runtime. Sprawdźmy czy tak faktycznie jest! W tym celu w windowsowym ‚Uruchom’ należy wpisać perfmon, który uruchamia monitor wydajności. Następnie, aby ułatwić analizę zmieńmy widok z histogramu na raport używając do tego menu kontekstowego zaznaczonego na screenie poniżej:

sample1

Kolejnym krokiem jest dodanie licznika, który przedstawi nam interesujące nas dane. W tym celu klikamy PPM w białe tło po czym wybieramy ‚Dodaj liczniki..’. Teraz z listy przechodzimy do Kompilator JIT dla .NET CLR i wybieramy licznik „Czas działania kompilatora JIT[%] ” oraz assembly, które nas interesuje. Klikamy dodaj, a następnie ok.

sample2

Ok, mamy jakiś super, pro licznik i co teraz? Teraz możemy sprawdzić czy normal JIT działa poprzez używanie aplikacji i obserwowanie licznika, a w zasadzie wartości jakie przedstawia. Po uruchomieniu aplikacji wartość procentowego czasu działania powinna wynosić 0, ponieważ jak wcześniej wspomniałem cały kod pośredni, który był niezbędny został już skompilowany i umieszczony w cache. Zobaczmy jak sprawa ma się w przypadku wcześniej przedstawionego kodu. Po uruchomieniu kodu i wpisywaniu liter różnych od ‚y’ licznik ani drgnie. Dopiero po wpisaniu magicznej litery zmienia swoją wartość na krótką chwilę:

 

sample3

Jak wcześniej wspomniałem, kolejne wciskanie ‚y’ spowoduje wykonanie kodu natywnego z cache, bez udziału JIT 🙂

 

Ufff… to tyle. Wiem, że to nie jest szczyt „technicznej nomenklatury” i poziomu opisu, ale serio – to nie jest takie łatwe kiedy przychodzi do pisania :/ Mam nadzieję, że mimo tego przybliżyłem trochę tematykę 🙂

You may also like...