Taka refleksja, ale szybsza… JDK18 update 0 (0)

Implementacja wywoływania metod z użyciem refleksji ma całkiem bogatą historię. Dostępna od początku w Javie (początkowo bardzo niewydajna), gruntownie przepisana w Javie 1.4, następnie zyskała konkurencję w postaci MethodHandle. Ostatecznie konkurenci będą musieli się pogodzić, gdyż w Jdk 18 w ramach JEP-416 wywołanie metod z użyciem refleksji tj. Method::invoke zostanie przepisane na używające pod spodem MethodHandle.

Co aktualnie nie domaga w refleksji?

Zacznijmy od tego, jak aktualnie działa wywołanie Method::invoke.

  1. Sprawdzany jest dostęp do tej metody – czy w ogóle możemy ją wywołać.
  2. Jeśli ta metoda była wywoływana często, to ostatecznie treść wywoływanej metody jest kompilowana JITem. Trzeba zatem sprawdzić, czy taki kod istnieje i go wywołać.
  3. Jeśli metoda nie jest skompilowana JITem, to wywoływana jest metoda natywna uruchamiająca kod metody.

Warto zauważyć, że dostęp jest sprawdzany przy każdym wywołaniu. Jest to nadmiarowe, gdyż wystarczyłoby sprawdzić kontekst na poziomie klasy tylko raz i taki kontekst wywoływania zcache’ować w klasie.

Drugą niedogodnością jest brak możliwości wniknięcia JITa do treści wywoływanej metody. JIT po prostu nie wie, jakie operacje są wywoływane w tej metodzie – traktuje wywołaną metodę jako „black box”.

Method Handle

Te dwie wady Method::invoke adresuje mechanizm MethodHandle wprowadzony w Jdk 1.7. Po pierwsze kontekst (Lookup) jest wymagany do znalezienia uchwytu na metodę, zatem dostęp sprawdzany jest jednokrotnie.

Po drugie MethodHandle rozumie kod wykonywany, dzięki czemu JIT ma możliwość zinline’owania wykonywanego kodu.

O ile ten mechanizm pozwala na wykonanie optymalnego kodu, o tyle problemem jest poziom trudności. Znacznie trudniej stworzyć kod wywołujący MethodHandle i łatwiej w nim o pomyłkę. Zrozumienie takiego kodu również jest trudniejsze.

Dodatkowo, o ile JIT ma możliwość rozumienia treści MethodHandle, o tyle ta informacja jest wykorzystywana praktycznie tylko w przypadku MethodHandle przetrzymywanych w polach statycznych finalnych (constant). W innych sytuacjach JIT nie wykorzystuje informacji o wykonywanym kodzie (a przynajmniej OpenJdk tak nie robi).

JEP-416

Próbę pożenienia Method::invoke z MethodHandle podejmuje wspomniany JEP-416. Dostarczony zostanie wraz z Jdk 18 w najbliższych dniach.

Zgodnie z oczekiwaniami otrzymujemy usprawnienia działania z MethodHandle wraz z prostotą wykonania Method::invoke. Dodatkowo autorzy JEPa wspominają jeszcze o zalecie „mniejszej ilości StackFrame’ów” oraz ułatwieniu dalszego rozwoju platformy poprzez usunięciu specjalnego traktowania refleksji.

Wydajność

Normalnie w tym akapicie przeszedłbym do benchmarkowania rozwiązania, gdyby nie to, że wyniki benchmarków są już zawarte w JEPie. O ile znacząca poprawa wydajności w przypadku Method, Constructor i Field trzymanych w polach static final (43–57% poprawa) zadowala, o tyle zawieść mogą benchmarki innych wywołań (51–77% pogorszenie).

Autorzy zapewniają, że w rzeczywistych aplikacjach zmiany nie mają znaczenia (a sprawdzili w Jacksonie, XStream i Kryo). Obiecują również poprawę na polach na których wystąpiło pogorszenie.

Podsumowanie

Wprowadzone zmiany należy mimo wszystko uznać za pozytywne. O ile zmniejszenie wydajności ma negatywny wydźwięk, o tyle zwiększenie utrzymywalności, potencjał na zwiększenie wydajności oraz uzasadnienie przygotowaniem pod Valhallę i Loom kompensują ten negatywny efekt.

Żebyśmy jeszcze dożyli tychże…

Jeśli ten wpis Cię zainteresował, polecam moje dwa poprzednie wpisy o refleksji – Taka refleksja tylko szybsza… i Przepisujemy Jacksona z refleksji na LambdaMetafactory [ZOBACZ JAK].

Pax et bonum!

Sygnatura metody, jej parametry i kwestia wydajności 0 (0)

Ile parametrów powinna mieć metoda?

Wujek Bob w „Czystym kodzie” twierdzi, że najlepiej zero. Dopuszcza też metody jedno i maksymalnie dwuparametrowe. Trzy i więcej parametry to już tłok, który przy przekazywaniu należy zgrupować w obiekt.

Pod kątem czystości kodu mamy zatem pewne wytyczne. Jak to wygląda od strony wydajności kodu? Czy opłaca się grupować parametry w obiekty, które można przekazać, czy wręcz przeciwnie?

Benchmark przekazywania parametrów

Załóżmy, że mamy dwie nieskomplikowane metody – w jednej przekazujemy parametry, w drugiej wrapper z tymi parametrami:

private int func(int i1, int i2, int counter) {
    counter--;
    if (counter == 0) {
        return counter;
    } else {
        return func(i1, i2, counter);
    }
}

private int func(Wrapper2 i1, int counter) {
    counter--;
    if (counter == 0) {
        return counter;
    } else {
        return func(i1, counter);
    }
}
private static class Wrapper2 {
    private final int i1;
    private final int i2;

    public Wrapper2(int i1, int i2) {
        this.i1 = i1;
        this.i2 = i2;
    }
}

Metody te pozwalają na wykonanie nieskomplikowanej czynności dekrementacji oraz rekurencyjnie wywołują te metody, aż licznik się wyzeruje. Badać będziemy przypadki z 1,2,4,8 i 16 argumentami/polami wrapperów.

Uruchamiając benchmark z licznikiem o wartości 1 zauważymy te same wyniki (u mnie ok 3,327 ± 0,013 ns/op). Jest to efekt inline’owania, dzięki któremu można zaoszczędzić wywołań metod. Kod maszynowy skompilowany przez c2 dla wszystkich metod jest taki sam.

Zatem, jeśli chcielibyśmy zbadać, czy wywołania metod z różnymi sygnaturami się różnią, to… trzeba wywołać te metody 😉 Można to zrobić na dwa sposoby – albo zwiększyć counter do wartości uniemożliwiającej inline’owanie albo zakazać inline’owania.

Po zmianie wartości counter na 10 wyniki już się znacząco różnią:

Benchmark                                Mode  Cnt   Score   Error  Units
WriteParamsBenchmark.primitiveParams_01  avgt   10  12,498 ± 0,123  ns/op
WriteParamsBenchmark.primitiveParams_02  avgt   10  12,817 ± 0,059  ns/op
WriteParamsBenchmark.primitiveParams_04  avgt   10  12,960 ± 0,146  ns/op
WriteParamsBenchmark.primitiveParams_08  avgt   10  23,064 ± 0,145  ns/op
WriteParamsBenchmark.primitiveParams_16  avgt   10  55,409 ± 0,750  ns/op
WriteParamsBenchmark.wrappedParams_01    avgt   10  12,529 ± 0,088  ns/op
WriteParamsBenchmark.wrappedParams_02    avgt   10  12,519 ± 0,285  ns/op
WriteParamsBenchmark.wrappedParams_04    avgt   10  12,481 ± 0,164  ns/op
WriteParamsBenchmark.wrappedParams_08    avgt   10  12,521 ± 0,130  ns/op
WriteParamsBenchmark.wrappedParams_16    avgt   10  12,455 ± 0,144  ns/op

Jeśli natomiast zakażemy inline’owania, to wyniki różnią się, choć mniej znacząco:

Benchmark                                Mode  Cnt  Score   Error  Units
WriteParamsBenchmark.primitiveParams_01  avgt   10  5,657 ± 0,100  ns/op
WriteParamsBenchmark.primitiveParams_02  avgt   10  5,630 ± 0,044  ns/op
WriteParamsBenchmark.primitiveParams_04  avgt   10  5,964 ± 0,059  ns/op
WriteParamsBenchmark.primitiveParams_08  avgt   10  6,395 ± 0,061  ns/op
WriteParamsBenchmark.primitiveParams_16  avgt   10  8,766 ± 0,143  ns/op
WriteParamsBenchmark.wrappedParams_01    avgt   10  5,624 ± 0,036  ns/op
WriteParamsBenchmark.wrappedParams_02    avgt   10  5,650 ± 0,052  ns/op
WriteParamsBenchmark.wrappedParams_04    avgt   10  5,630 ± 0,097  ns/op
WriteParamsBenchmark.wrappedParams_08    avgt   10  5,673 ± 0,077  ns/op
WriteParamsBenchmark.wrappedParams_16    avgt   10  5,624 ± 0,056  ns/op

Z obu benchmarków wynika jednoznacznie, że przekazywanie większej ilości parametrów kosztuje (porównując 1 parametr z 16 parametrami widać różnicę od 50% do 440%). Jednocześnie zgrupowanie parametrów w obiekt pozwala ten koszt zredukować – przekazujemy wówczas tak naprawdę tylko jeden parametr – wskaźnik na wrapper.

Korzystanie z parametrów

W powyższym benchmarku sprawdziliśmy jak tylko jak przekazywanie parametru wpływa na wydajność, ale nie sprawdziliśmy wpływu na korzystanie przekazanych danych. Potencjalnie te dane mogą trafić do jakichś cache’ów, więc może większy koszt przekazania danych przez parametr zostanie jakoś zrekompensowany.

Sprawdza to drugi benchmark będący modyfikacją pierwszego. W tym przypadku zamiast zwracać counter zwracamy sumę parametrów metody.

    private int func(int i1, int i2, int counter) {
        counter--;
        if (counter == 0) {
            return i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2;
        } else {
            return func(i1, i2, counter);
        }
    }

    private int func(Wrapper2 i1, int counter) {
        counter--;
        if (counter == 0) {
            return i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2
                    + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2;
        } else {
            return func(i1, counter);
        }
    }

Gdy inline’owanie pozostało włączone, a licznik wynosił 1 wyniki były następujące:

Benchmark                               Mode  Cnt  Score   Error  Units
ReadParamsBenchmark.primitiveParams_01  avgt   10  3,356 ± 0,023  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10  3,343 ± 0,013  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10  3,429 ± 0,019  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10  3,340 ± 0,014  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  3,358 ± 0,017  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10  5,639 ± 0,096  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10  5,652 ± 0,051  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10  5,995 ± 0,049  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10  6,240 ± 0,268  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10  6,621 ± 0,026  ns/op

Widać zatem pewien zysk na przekazywaniu parametrów zamiast obiektu. Wynika on poniekąd z faktu, że przekazywany obiekt już był utworzony jeden raz i udostępniony na Heapie, zatem odczyty w wrappedParams dotyczą obiektu z Heapa. Sytuacja wygląda inaczej, jeśli będziemy tworzyć nowe obiekty, które po escape analysis zostaną zamienione na zmienne lokalne – wówczas nie ma różnicy w czasie wykonywania metod zarówno dla parametrów, jak i wrapperów (jest to około 3,358 ± 0,017 ns/op).

Jeśli jednak zakażemy inline’owania, to rezultaty są następujące:

Benchmark                               Mode  Cnt   Score   Error  Units
ReadParamsBenchmark.primitiveParams_01  avgt   10   7,561 ± 0,126  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10   7,651 ± 0,038  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10   7,847 ± 0,031  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10   8,800 ± 0,075  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  10,405 ± 0,060  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10   8,044 ± 0,104  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10   8,084 ± 0,068  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10   8,356 ± 0,076  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10   8,573 ± 0,065  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10   9,073 ± 0,042  ns/op

W tym wypadku korzystanie z przekazywania parametrów, bez umieszczania ich w obiekcie jest korzystniejsze. Wynika to (podobnie jak wcześniej) z tego, że przekazujemy wartości, a nie wskaźnik na obiekt z wartościami. Zatem oszczędzamy na dostępie do pamięci.

Jednak często się zdarza, że wykorzystujemy wartości dopiero po kilkukrotnym przekazaniu (przepchaniu) ich przez inne metody – przekazujemy parametry tylko po to, aby wykorzystać je w kolejnych wywołaniach metody. Warto zatem się przyjrzeć sytuacji, gdzie zanim skorzystamy z parametrów, musimy je przekazać w kolejnych wywołaniach metod.

Taką sytuację symuluje wywołanie benchmarku z licznikiem równym 10. Wyniki są następujące:

ReadParamsBenchmark.primitiveParams_01  avgt   10   27,279 ± 0,407  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10   27,115 ± 0,803  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10   26,793 ± 0,100  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10   63,575 ± 0,380  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  203,437 ± 1,001  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10   27,635 ± 0,194  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10   27,311 ± 0,193  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10   27,731 ± 0,219  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10   27,744 ± 0,181  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10   29,646 ± 0,106  ns/op

W tym wypadku wyniki są mniej korzystne dla przekazywania parametrów. Zysk z odczytu parametrów został został praktycznie zniwelowany przez koszt przekazania tych parametrów.

Przekazanie parametrów według C2

Warto zwrócić uwagę na jeden fakt – czasy wykonania benchmarków znacznie się zwiększają w przypadku przekazania 8 lub 16 parametrów. Czasy się zwiększają nieliniowo w stosunku do ilości parametrów.

Warto spojrzeć w kod maszynowy wygenerowany przez C2. Tam przekazywanie parametrów jest wykonywane w następujący sposób:

  0x00007efdb9222d8c: mov     $0x1,%edx
  0x00007efdb9222d91: mov     $0x1,%ecx
  0x00007efdb9222d96: mov     $0x1,%r8d
  0x00007efdb9222d9c: mov     $0x1,%r9d
  0x00007efdb9222da2: mov     $0x1,%edi
  0x00007efdb9222da7: mov     $0x1,%r10d
  0x00007efdb9222dad: mov     %r10d,(%rsp)
  0x00007efdb9222db1: mov     $0x1,%r11d
  0x00007efdb9222db7: mov     %r11d,0x8(%rsp)
  0x00007efdb9222dbc: mov     %r10d,0x10(%rsp)
  0x00007efdb9222dc1: mov     %r11d,0x18(%rsp)
  0x00007efdb9222dc6: mov     %r10d,0x20(%rsp)
  0x00007efdb9222dcb: mov     %r11d,0x28(%rsp)
  0x00007efdb9222dd0: mov     %r10d,0x30(%rsp)
  0x00007efdb9222dd5: mov     %r11d,0x38(%rsp)
  0x00007efdb9222dda: mov     %r10d,0x40(%rsp)
  0x00007efdb9222ddf: mov     %r11d,0x48(%rsp)
  0x00007efdb9222de4: mov     %r10d,0x50(%rsp)
  0x00007efdb9222de9: mov     $0xa,%r11d
  0x00007efdb9222def: mov     %r11d,0x58(%rsp)

Z powyższego listingu wynik jeden wniosek – pierwsze parametry przekazywane są bezpośrednio w rejestrze, gdy kolejne poza rejestrami procesora. To jest prawdopodobnie przyczyna zwiększonych czasów przy większej ilości parametrów. Oczywiście możliwe, że taka sytuacja występuje na moim przedpotopowym laptopie, a dla maszyn serwerowych o lepszych procesorach C2 wygeneruje kod wykorzystujący większą ilość rejestrów.

Podsumowanie

Wydaje się, że mniejsza ilość parametrów metod może wpływać nie tylko na czytelność kodu, ale również na wydajność. Oczywiście różnice są nieznaczne, ale jednak istnieją. Zależy również, jak często korzystamy z tych parametrów. Jeśli parametry przekazujemy częściej, niż z nich korzystamy, to lepiej użyć obiektu opakowującego parametry.

Pax!

Kompilacja kodu w Javie – JIT teoretycznie 0 (0)

W poprzednim wpisie pisałem ogólnie o kompilacji kodu w Javie. W tym głównym bohaterem jest JIT, a dokładniej techniki optymalizacji w nim stosowane.

Uwagi wstępne

Na początku zarysować sytuację niskopoziomową.

Otóż mamy procesor. Dla ułatwienia załóżmy, że jeden. Ten procesor służy do wykonywania różnych prostych operacji. Operacje przeważnie mają dodatkowe argumenty i jakiś rezultat.

Skąd procesor ma wiedzieć jakie instrukcje wykonać i skąd te argumenty operacji?

Otóż wszystkie dane zapisane są w pamięci operacyjnej. Jednak każdorazowe sięgnięcie do pamięci ram byłoby mało optymalne, dlatego dane pobierane są w większej ilości. Pamięć – podobnie do instrukcji – jest ułożona sekwencyjnie. Zatem pobierane są fragmenty pamięci najbliżej pobieranej instrukcji. Potrzebujemy w danym momencie jedną instrukcję (i tylko jedną), zatem reszta pobranych danych trafia do cache‚ów procesora.

Kod operacji oraz argumenty są ładowane do rejestrów. Jednak nie wszystkie rejestry są wykorzystywane w przetwarzaniu operacji – niektóre służą za „najszybszą pamięć” szybszą od cache‚ów procesora.

Jak działa procesor?

Z wykonywaniem instrukcji procesora jest trochę, jak czytaniem książki. Ale tylko trochę…

Standardowo, ludzie czytają książki od początku do końca, słowo po słowie. Czytają z pewną stałą prędkością (stała ilość słów w czasie). Gdy skończymy czytać stronę (skończą się instrukcje w cache‚ach procesora), to musimy przerzucić kartkę (wczytać z pamięci do cache‚ów nową stronę). To zajmuje jakiś czas i zmniejsza ilość przeczytanych słów w czasie.

Najlepiej się czyta słowa będące obok siebie – w linii. Gdy zaczynamy czytać kolejną linię, również czasem to wybija z rytmu. Podobnie i procesor najchętniej wykonuje wszystkie operacje po kolei, znając już następną instrukcję. To porównanie jest nieco naciągane, jednakże potokowość bazuje właśnie na najbliższych operacjach. Gdy linia się kończy, musimy (nawet w obrębie widzianej strony) spojrzeć na następną linię. Analogiczną sytuacją jest wykonanie instrukcji skoku w obrębie instrukcji w cache‚u – gubimy wówczas kontekst następnych operacji.

Najgorzej (biorąc pod uwagę wydajność czytania) za to czyta się powieści interaktywne, gdzie co chwilę trzeba skakać z jednej strony na inną. Tutaj alegorią może być małe procedurki (rozumiane jako pewne ciągi instrukcji), które są wywoływane co chwila i w ten sposób procesor „skacze” po skompilowanym kodzie.

Generalnie wszelkiego rodzaju instrukcje skoku (wysokopoziomowo mówiąc wywołania metod, if-else‚y, switch-case‚y itp) są trudne.

A co to ma do kompilacji?

Wiedząc o tym, w jak działa procesor możemy w taki sposób skompilować aplikację, aby procesor dział jak najszybciej.

W JVMie JIT kompiluje poszczególne metody. Na tej metodzie są wykonywane optymalizacje, które usprawniają działanie metody. Musimy zatem przeanalizować cały kod metody.

W metodach bardzo często są wywoływane inne metody. Aby dowiedzieć się, co się w nich dzieje, należy do nich zajrzeć i – jeśli to możliwe – przepisać do analizowanej metody treści wywoływanych metod. W ten sposób nie tylko rozszerzamy poznany kontekst metody, ale również ograniczamy ilość skoków do wywoływanych metod.
O inliningu jednak zrobię osobny wpis, zatem nie będę się o tym rozpisywał.

Jeśli już kontekst wywoływanej metody jest wystarczająco szeroki, można pokusić się o poszukanie usunięcie martwego kodu. Przykładowo, gdy w jednej metodzie przekazujemy do drugiej zawsze nienullowy parametr, a w wywoływanej metodzie na początku mamy „nullchecka”, to można spokojnie go usunąć (to tylko przykład, bo nullchecki są inaczej obsługiwane).

Profilowanie

Wspomniane wcześniej optymalizacje można wykonać zarówno AoT jak i JIT. Bardzo dużą wartość daje profilowanie wykonania metody, które jest specyficzne dla JIT.

Dzięki temu uzyskujemy specyficzny kontekst działania naszej aplikacji. Przykłady:

  • Załóżmy, że mamy jakąś flagę, która po uruchomieniu nie zmienia swojej wartości oraz ifka, który od tejże zależy. Jeśli 15 000 razy wartość się nie zmieniła, to możemy założyć, że i 15 001 raz wartość się nie zmieni. O ile samo sprawdzenie musimy wykonać, to domyślną ścieżką powinno być wykonanie treści if, a ewentualne else przerzucone gdzieś możliwie daleko w skompilowanym kodzie. Tak, żeby nie tracić miejsca na „stronie” naszej książki, skoro i tak się to prawie na pewno nie zdarzy.
  • Załóżmy, że zarówno if, jak i else są wykonywane, jednak blok wewnątrz if jest 3 krotnie częściej niż blok else. Zatem, aby zaoszczędzić na skokach, treść częstszego bloku powinna być zaraz po sprawdzeniu warunku. Wówczas ograniczamy rozmiar straty potokowości.
  • Podobnie dla switcha kolejność case można uszeregować wg częstości występowania.

Profilowanie dotyczy jednak również typu. Pozwala ono – analogicznie do profilowania ifów – na umiejscowienie najczęściej wywoływanego kodu najbliżej wywołania metody. W optymistycznym przypadku możemy stwierdzić, że obiekt tylko jednego typu pojawia się w wywołaniu wirtualnym, a w pozostałych przypadkach zostawić pułapkę, która spowoduje deoptymalizację metody do pierwotnej postaci.

Inne optymalizacje

W szczególny sposób optymalizowane są pętle.

Pod koniec każdej iteracji pętli domyślnie wstawiana jes instrukcja skoku do początku pętli. Zakładając, że pętla wykona się wielokrotnie, możemy ograniczyć ilość skoków poprzez wielokrotny „copy-paste” bloku pętli po każdym sprawdzając warunek pętli.

Dodatkowo, jeśli pętla wykonywana jest tysiącami razy, to o ile nie wykonuje żadnej specyficznej metody, którą można by skompilować, to kod wewnątrz pętli nie miał by okazji do kompilacji. Jednak istnieje mechanizm (On Stack Replacement), który pozwala skompilować kod samej pętli oraz przełączyć się na wykonywanie skompilowanej treści pętli bez zatrzymania programu.

Inną ciekawostką jest, że nie wszystkie metody są kompilowane „od zera” w czasie JIT. Istnieją takie metody zwane intrinsics, które są gotowe do inline’owania. Przykłady to Math.max(), Object.hashCode(), System.copyArray() czy bardziej używane StringBuilder.append() lub Integer.toLongValue().

O ile niektóre „zmienne” możemy przechowywać w rejestrach procesora, to rejestrów tych jest niewiele. Na tyle niewiele, aby w czasie kompilacji metody decydować, które zmienne powinny tam trafić. Ten problem nie należy do banalnych. A bardziej szczegółowo jest to problem NP trudny – problem kolorowania grafu. Jednak C2 taką analizę wykonuje.

Podsumowanie

Temat jest na tyle obszerny, że trudno omówić w jednym wpisie wszystkie optymalizacje. Mogę tylko zostawić kilka ciekawych linków:

Kolejny wpis będzie prawdopodobnie o inliningu, gdyż najważniejszy temat, jeśli chodzi kompilację kodu.
Standardowo prośba o ocenę wpisu, żebym wiedział, że ktoś to przeczytał 😉

Pax et bonum
(und gesundheit, bo korona)

Kompilacja kodu w Javie 0 (0)

Ile razy kod Javy trzeba kompilować, aby optymalny…

Wpis ten jest wstępem do kolejnych wpisów, więc może być dosyć ogólny, względnie nudny dla przeciętnego Senior Tech JVM Performance Leada 😉

OpenJDK/OracleJDK

Największą częścią rynku (91%) są dystrybucje JVMa z rodziny OpenJDK (z OracleJDK włącznie). Dystrybucje te bazują na tym samym kodzie źródłowym, zatem można je spokojnie omawiać wspólnie.

Kompilacja plików .java

Chronologicznie należałoby zacząć od kodu źródłowego zapisanego w plikach.java.

Kod kompilowany jest do standardowo bytecode‚u i ta kompilacja jest wykonywana przed uruchomieniem programu. Spokojnie tę kompilację można nazwać pierwszą kompilacją i – bynajmniej – nie jest ona optymalna 😉 Wszelkie metody optymalizacji są tutaj bardzo ograniczone. Można zrobić jakieś Constant Folding (o którym pisałem już chociażby we wpisach o finalach), jednak nie jest to szczyt technik optymalizacyjnych.

Kod kompilowany do bytecode‚u kompiluje się jednak znacznie szybciej, niż kompilowany do kodu natywnego. Jest też bardziej elastyczny. Czyli do developmentu „good enough”.

JIT

Kompilacja just-in-time (swobodne tłumaczenie to „rychło w czas”) polega na kompilowaniu kodu w czasie działania aplikacji.
Jakby się głębiej zastanowić, to widać podobieństwa do samolotu w tejże reklamie.

Założenie jest takie, że większość kodu jest wykonywana na tyle rzadko, że można ją po prostu wykonać w trybie interpretowanym. Szkoda zatem cykli procesora na szalone optymalizacje fragmentów kodu, wykonywanych jednokrotnie. Warto się jednak skupić na metodach wykonywanych tysiące razy.

Co więcej, skoro mamy już pewien narzut na interpretację kodu bajtowego, to do czasu porządnej optymalizacji warto poświęcić procesor i zbierać dodatkowe statystyki. Mogą one być bardzo przydatne – jeśli w ciągu przykładowo 1000 wykonań program ani razu nie wszedł w jakiegoś ifka, to prawdopodobnie przy 1001 wywołaniu również nie wejdzie. Można przykładowo pominąć treść sekcji, którego ten ifek dotyczy (zaoszczędzimy bajtów i nie tylko, acz o tym kiedy indziej). W razie czego, gdyby przez przypadek ifek stał się prawdziwy, można przykładowo odesłać do wykonywania źródeł w bajtkodzie (to tylko przykładowy przykład; trochę inaczej się to robi w realu).

C1, C2

Dawno temi, w zamierzchłych czasach Javy 1.6 istniały dwa osobne kompilatory C1 (szybki, acz niedokładny – kliencki) oraz C2 (wolny, ale zaawansowany – serwerowy/opto). Na starcie wybierało się pożądany kompilator flagą lub JVM sam nam go wybierał bazując na parametrach naszego sprzętu.

W Javie 1.7 została wprowadzona tzn. kompilacja warstwowa. Według niej interpretujemy kod. Po przekroczeniu progu 2 000 wywołań kompilujemy metodę z użyciem C1. Jednak życie toczy się dalej, a metoda dalej jest wywoływana. Po wywoływaniu metody 15 000 razy jest ona kompilowana z użyciem C2.

Jest jednak kilka „ale”.

Wspomniana kompilacja warstwowa ma ponumerowane warstwy. Poziom 0 to nieskompilowana metoda w trybie interpretowanym. Poziom 4 to C2. Za to za warstwy 1-3 odpowiada C1, która ma różne „warianty”. Istnieje wariant z pełnym profilowaniem (3), ale też istnieje lżejsza wersja ze ograniczonym profilowaniem(2) i najlżejsza – bez profilowania (1).

W idealnej sytuacji kompilujemy metodę do warstwy 3 (po 2 000 wykonaniach), a następnie do warstwy 4 (po 15 000 wykonaniach). Jednak nie zawsze tak jest. Trzeba mieć świadomość, że po przekroczeniu tych progów metoda jest wrzucana do kolejki metod do kompilacji. Przykładowo czasem kolejka do kompilatora C2 jest na tyle długa, że może do czasu zwolnienia C2 można ograniczyć profilowanie (przejść z 3 warstwy na 2). Jak głoszą slajdy Oracle’owe, trzeci poziom jest 30% wolniejszy niż drugi.

AoT?

Jak wiemy istnieje też GraalVM i kompilator Graal. Jest to jednak rozwiązanie typowo Oracle’owe, więc w tym wpisie nie będę rozwijał tego tematu.

Są jednak dwie ciekawostki.
Pierwsza jest taka, że jeśli macie ochotę napisać własny kompilator, to nie ma sprawy, OpenJDK (od Javy 9) wesprze Cię w tym wystawiając interfejs,który trzeba zaimplementować pisząc kompilator. Proste 5 metodek 😉

Druga ciekawostka jest mniej znana – również w Javie 9 powstał eksperymentalnie kompilator AoT. Pozwala on kompilować program do kodu natywnego. Istnieje jednak jeszcze drugi tryb kompilacji – kompilowanie do kodu natywnego z profilowaniem. Taka opcja pozwala na rekompilację z użyciem C2 i dodatkowych metadanych zbieranych w czasie działania programu. W założeniu ten tryb miał przyspieszyć włączanie projektu, jednak benchmarki powiadają, że tak się nie dzieje…

Podsumowanie

O ile trochę wiemy co się dzieje, to nie wiemy w jaki sposób. Zatem w kolejnych wpisach napiszę nieco o tym co się kompiluje, kiedy, jak oraz skąd to wszystko mamy wiedzieć…

Z ciekawych linków zostawiam tylko wpis na blogu Microsoftu o AoT i nie tylko.

Gwiazdkujcie, komentujcie i bywajcie zdrów 😉

Pax et Bonum