Kompilacja JIT, czyli Just-In-Time, polega na tworzeniu kodu maszynowego podczas działania programu, a nie wyłącznie przed jego uruchomieniem. Technika ta ma szczególne znaczenie w JavaScript, ponieważ współczesne silniki nie ograniczają się do prostego wykonywania instrukcji jedna po drugiej. Mogą obserwować zachowanie kodu, identyfikować często wykonywane fragmenty i przygotowywać dla nich szybsze wersje. Dzięki temu dynamiczny język może zachować szybki start, a jednocześnie osiągać wysoką wydajność podczas dłuższego działania aplikacji. JIT nie oznacza jednak, że każdy fragment JavaScript jest od razu intensywnie optymalizowany - nowoczesne silniki wykorzystują kilka poziomów wykonywania kodu i dobierają koszt optymalizacji do tego, jak często dana funkcja jest rzeczywiście używana.
Co to jest kompilacja JIT?
JIT - Just-In-Time compilation - to kompilacja wykonywana w czasie działania programu. Zamiast przygotowywać cały kod maszynowy przed uruchomieniem aplikacji, środowisko może rozpocząć wykonywanie programu szybciej, a dopiero później kompilować wybrane fragmenty, dla których przyniesie to realną korzyść.
Takie rozwiązanie jest szczególnie użyteczne w językach dynamicznych. Silnik może obserwować rzeczywiste zachowanie programu: jakie funkcje są wywoływane najczęściej, jakie typy wartości do nich trafiają i które ścieżki wykonywania są wykorzystywane wielokrotnie.
Na podstawie tych informacji może przygotować bardziej wyspecjalizowany kod maszynowy. Zamiast więc próbować przed uruchomieniem przewidzieć wszystkie możliwe zachowania aplikacji, JIT wykorzystuje wiedzę zebraną podczas jej rzeczywistej pracy.
Interpreter, kompilator i JIT - czym się różnią?
Aby zrozumieć JIT, trzeba najpierw rozróżnić klasyczną interpretację i kompilację. Interpreter wykonuje program na podstawie jego instrukcji lub pośredniej reprezentacji podczas działania aplikacji. Kompilator tłumaczy kod do innej postaci - często kodu maszynowego - zanim zostanie on wykonany albo na określonym etapie procesu uruchamiania.
Szerzej różnice między interpreterem a kompilatorem i sposób wykonywania kodu opisano w serwisie WIX. W przypadku JIT granica staje się mniej oczywista, ponieważ środowisko może korzystać zarówno z interpretacji, jak i kompilacji wykonywanej już podczas pracy programu.
| Podejście | Kiedy wykonywane jest tłumaczenie kodu? | Główna cecha |
|---|---|---|
| Interpretacja | Podczas działania programu | Szybkie rozpoczęcie wykonywania bez przygotowywania pełnego kodu maszynowego |
| AOT | Przed uruchomieniem programu | Kod docelowy przygotowany wcześniej |
| JIT | Podczas działania programu | Możliwość kompilowania i optymalizowania kodu na podstawie informacji z runtime |
Współczesne środowiska często nie wybierają więc wyłącznie jednej techniki. Stosują kilka etapów, aby znaleźć kompromis między szybkim uruchomieniem programu, wykorzystaniem pamięci i wydajnością kodu wykonywanego przez dłuższy czas.
Czy JavaScript jest kompilowany czy interpretowany?
Określenie JavaScript po prostu jako "języka interpretowanego" jest dziś dużym uproszczeniem. Specyfikacja języka opisuje jego zachowanie, ale nie wymusza jednej konkretnej architektury silnika wykonawczego.
Nowoczesny silnik może najpierw przeanalizować kod źródłowy, utworzyć jego wewnętrzną reprezentację, wygenerować bytecode, rozpocząć jego wykonywanie, zbierać informacje o zachowaniu programu, a następnie kompilować wybrane fragmenty do zoptymalizowanego kodu maszynowego.
Dlatego bardziej precyzyjne pytanie brzmi nie "czy JavaScript jest interpretowany czy kompilowany?", lecz "w jaki sposób konkretny silnik JavaScript wykonuje kod?".
Odpowiedź może się różnić pomiędzy silnikami i ich kolejnymi wersjami. Sama konstrukcja JavaScript nie oznacza automatycznie, że każda instrukcja musi być przez cały czas interpretowana.
Jak działa JIT w praktyce?
Najłatwiej zrozumieć JIT na przykładzie funkcji wykonywanej wielokrotnie:
function calculatePrice(price, quantity) {
return price * quantity;
}
for (let i = 0; i < 100000; i++) {
calculatePrice(49.99, 2);
}
Silnik widzi, że `calculatePrice()` jest wywoływana ogromną liczbę razy i otrzymuje podobne dane. Zamiast za każdym razem obsługiwać ją najbardziej uniwersalną drogą, może uznać ją za dobrego kandydata do dalszej optymalizacji.
Schemat można uprościć do następującej postaci:
kod JavaScript → analiza → bytecode → wykonanie → profilowanie → optymalizacja → kod maszynowy
Nie jest to jednak sztywny proces obowiązujący identycznie w każdym silniku. Konkretne środowiska stosują własne poziomy kompilacji, własne reprezentacje pośrednie i własne mechanizmy decydowania o tym, które fragmenty programu warto optymalizować.
Hot code - dlaczego nie każdy kod jest optymalizowany tak samo?
Zaawansowana optymalizacja ma koszt. Analiza programu i wygenerowanie wysokiej jakości kodu maszynowego wymagają czasu procesora i pamięci. Nie miałoby więc sensu przeprowadzać kosztownej optymalizacji funkcji, która zostanie wykonana tylko raz.
Silniki koncentrują się przede wszystkim na fragmentach nazywanych potocznie hot code lub hot spots - miejscach wykonywanych wystarczająco często, aby koszt kompilacji miał szansę się zwrócić.
Rozważmy dwa fragmenty:
function showWelcomeMessage() {
console.log("Witaj");
}
showWelcomeMessage();
oraz:
function transform(value) {
return value * 2 + 10;
}
for (let i = 0; i < 1000000; i++) {
transform(i);
}
Pierwsza funkcja wykonuje się raz. Poświęcenie dużej ilości czasu na przygotowanie jej maksymalnie zoptymalizowanej wersji mogłoby kosztować więcej niż zwykłe wykonanie. Druga funkcja pracuje milion razy, więc nawet niewielkie skrócenie czasu pojedynczego wywołania może mieć znaczenie dla całego programu.
Type feedback - czego silnik uczy się podczas wykonywania kodu?
JavaScript jest językiem dynamicznie typowanym. Zmienna nie jest na stałe przypisana do jednego typu wartości:
let value = 10;
value = "SVEGA";
value = {
name: "SVEGA"
};
Ta elastyczność jest wygodna dla programisty, ale komplikuje generowanie bardzo szybkiego kodu maszynowego. Operacja `+` może przecież oznaczać dodawanie liczb albo łączenie tekstów, zależnie od otrzymanych wartości.
Podczas działania programu silnik może więc zbierać type feedback, czyli informacje o typach wartości rzeczywiście występujących w określonych miejscach programu.
Jeżeli funkcja tysiące razy otrzymuje liczby:
function add(a, b) {
return a + b;
}
add(10, 20);
add(5, 15);
add(100, 200);
silnik może wykorzystać tę obserwację podczas przygotowywania zoptymalizowanej wersji kodu. Nie oznacza to jednak zmiany zasad języka - funkcja nadal może później otrzymać inne wartości.
Optymalizacja spekulacyjna
Jednym z ciekawszych elementów JIT jest optymalizacja spekulacyjna. Silnik wykorzystuje dotychczasowe zachowanie programu i przyjmuje założenie, że prawdopodobnie będzie ono podobne również podczas kolejnych wywołań.
Jeśli funkcja przez długi czas otrzymuje dwie liczby, można wygenerować bardzo wydajną wersję wyspecjalizowaną pod taki przypadek. Nie trzeba w niej wykonywać wszystkich operacji potrzebnych do obsługi każdej teoretycznie możliwej kombinacji wartości.
Słowo "spekulacyjna" jest jednak istotne. Silnik nie ma gwarancji, że wcześniejsze zachowanie programu będzie trwało wiecznie.
Przykładowo:
function add(a, b) {
return a + b;
}
for (let i = 0; i < 100000; i++) {
add(i, 10);
}
add("SVEGA", " Web");
Przez ogromną liczbę wywołań `add()` operuje na liczbach. Później otrzymuje jednak tekst. Z punktu widzenia JavaScript jest to całkowicie poprawny kod, dlatego silnik musi potrafić obsłużyć również zmianę wcześniej obserwowanego zachowania.
Deoptymalizacja - kiedy szybki kod trzeba porzucić?
Jeżeli założenia wykorzystane podczas optymalizacji przestają być prawdziwe, silnik może przeprowadzić deoptimization. Zoptymalizowany kod nie może po prostu zwrócić błędnego wyniku dlatego, że program zachował się inaczej niż wcześniej.
Silnik musi wrócić do wersji zdolnej prawidłowo obsłużyć bardziej ogólną sytuację. Jest to cena, jaką płaci się za optymalizacje bazujące na obserwowanym zachowaniu programu.
Nie oznacza to, że każda zmiana typu automatycznie prowadzi do katastrofalnego spadku wydajności. Współczesne silniki są projektowane właśnie do wykonywania dynamicznego JavaScript. Deoptymalizacja jest normalnym elementem ich działania, a nie awarią.
Jak wygląda wykonywanie JavaScript w V8?
Dobrym przykładem wielopoziomowego podejścia jest V8, czyli silnik JavaScript wykorzystywany między innymi przez Chromium i Node.js. Jego architektura zmieniała się wielokrotnie, dlatego opis oparty na starym podziale "interpreter + jeden kompilator JIT" nie pokazuje już całego procesu.
Kod JavaScript jest przetwarzany do bytecode wykonywanego przez interpreter Ignition. V8 może później kierować kod do kolejnych poziomów wykonywania i kompilacji.
| Element V8 | Rola |
|---|---|
| Ignition | Interpreter wykonujący bytecode JavaScript |
| Sparkplug | Szybki kompilator bazowy tworzący kod maszynowy bez kosztownych zaawansowanych optymalizacji |
| Maglev | Warstwa optymalizująca zaprojektowana jako kompromis pomiędzy szybkością kompilacji i jakością wygenerowanego kodu |
| TurboFan | Zaawansowany kompilator optymalizujący ukierunkowany na wysoką wydajność kodu |
Wielopoziomowa architektura pozwala uniknąć dwóch skrajności. Silnik nie musi wykonywać całego programu wyłącznie w interpreterze, ale nie musi też od razu uruchamiać najbardziej kosztownej optymalizacji dla każdej funkcji.
Kod może stopniowo przechodzić do bardziej zaawansowanych poziomów, gdy informacje z runtime wskazują, że dalsza kompilacja jest opłacalna.
Czy JIT zawsze przyspiesza JavaScript?
Nie należy traktować JIT jako mechanizmu gwarantującego, że każda aplikacja automatycznie stanie się szybsza. Kompilacja i optymalizacja również kosztują czas oraz pamięć.
Dla bardzo krótkiego programu, który wykonuje określoną funkcję tylko raz, koszt rozbudowanej optymalizacji mógłby przewyższyć potencjalny zysk. Stąd wielopoziomowe systemy wykonawcze, w których bardziej zaawansowana kompilacja jest stosowana przede wszystkim tam, gdzie ma uzasadnienie.
Istnieje również pojęcie warm-up. Kod wykonywany długo może osiągnąć inną charakterystykę wydajności niż podczas pierwszych kilku uruchomień, ponieważ silnik zdążył zebrać informacje i zastosować kolejne poziomy optymalizacji.
Ma to znaczenie podczas tworzenia benchmarków. Pomiar jednej funkcji wykonanej kilka razy może nie odzwierciedlać zachowania tej samej funkcji w długotrwałej aplikacji.
Czy można pisać kod bardziej przyjazny dla JIT?
Zrozumienie JIT jest przydatne, ale nie powinno prowadzić do pisania kodu podporządkowanego przypadkowym wewnętrznym szczegółom konkretnej wersji silnika. Mechanizmy V8, SpiderMonkey czy JavaScriptCore zmieniają się, a optymalizacja działająca dzisiaj może wyglądać inaczej w następnej wersji przeglądarki.
Najpierw algorytm
Zmiana algorytmu z nieefektywnego na lepszy zazwyczaj daje znacznie większy efekt niż próba mikrozarządzania kompilatorem JIT.
Mierz realny problem
Profilowanie powinno poprzedzać optymalizację. Nie ma sensu komplikować czytelnego kodu tylko dlatego, że hipotetycznie mógłby zostać inaczej skompilowany.
Unikaj niepotrzebnej niestabilności danych
Jeżeli ta sama struktura raz pełni funkcję liczby, chwilę później tekstu, a następnie zupełnie innego obiektu, kod staje się trudniejszy nie tylko dla silnika, lecz również dla programisty utrzymującego aplikację.
Optymalizuj miejsca rzeczywiście kosztowne
W aplikacji większym problemem może być zbyt duża liczba operacji DOM, wielokrotne pobieranie tych samych danych albo nieefektywne renderowanie niż sama szybkość wykonania niewielkiej funkcji JavaScript.
Najlepszą praktyką nie jest więc "programowanie pod JIT", ale pisanie przewidywalnego kodu i korzystanie z profilera wtedy, gdy istnieje rzeczywisty problem wydajnościowy.
JIT a AOT - na czym polega różnica?
Przeciwieństwem kompilacji wykonywanej w czasie działania jest w uproszczeniu AOT - Ahead-Of-Time, czyli przygotowanie kodu przed uruchomieniem programu.
AOT może przenieść koszt kompilacji poza moment działania aplikacji. JIT ma natomiast dostęp do informacji z runtime, dzięki czemu może podejmować decyzje na podstawie rzeczywistych typów danych, częstotliwości wywołań funkcji i zachowania programu.
| Cecha | JIT | AOT |
|---|---|---|
| Moment kompilacji | Podczas działania programu | Przed uruchomieniem |
| Dane z runtime | Można wykorzystać rzeczywiste zachowanie programu | Nie są dostępne podczas wcześniejszej kompilacji |
| Koszt kompilacji | Część kosztu występuje podczas pracy programu | Koszt ponoszony jest wcześniej |
| Optymalizacja dynamiczna | Może reagować na aktualne zachowanie kodu | Opiera się na informacjach dostępnych przed uruchomieniem |
W praktyce granica pomiędzy poszczególnymi modelami może być bardziej złożona, ponieważ współczesne środowiska programistyczne łączą różne etapy kompilacji, bytecode, cache kodu i profilowanie runtime.
Kompilacja JIT jest więc nie tyle prostym zamiennikiem interpretera lub tradycyjnego kompilatora, ile sposobem na wykorzystanie informacji dostępnych dopiero podczas działania programu. W JavaScript pozwala silnikowi rozpocząć wykonywanie kodu bez kosztownego optymalizowania wszystkiego z góry, obserwować rzeczywiste zachowanie aplikacji i inwestować więcej pracy w te fragmenty, które wykonują się wystarczająco często, aby optymalizacja mogła przynieść zauważalną korzyść.

Komentarze