Sygnatura metody, jej parametry i kwestia wydajności

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!

 

Serverlesslessless…

Czy da się napisać taką apkę webową, która będzie działała w internecie bez kodu w backendzie? Oczywiście! Odpowiedź to serverless!

A czy da się za ten serverless nie płacić? Oczywiście – do jakiejś skali są darmowe plany, a w nich darmowe usługi.

Ale nie trzeba serverlessa – jest inna droga. W tym wpisie pokrótce opiszę prywatny projekcik realizujący tę nieserverlessową drogę.

Inspiracja

Prywatne projekty zwykle cierpią na nadmiar inspiracji i niedobór zasobów. Brakuje przede wszystkim zasobów ludzkich (1/16 etatu 1 osoby to raczej niewiele…). Budżet również jest ograniczony – najlepiej by wydatki ograniczyły się do liczby nieujemnej i niedodatniej jednocześnie (waluta obojętna)… Potencjał skalowania projektu również marny.

A jednak człowiek czasem coś by zakodził… Na przykład kolejny w internecie projekcik apki webowej do własnych przepisów kulinarnych. Albo do trzymania tekstów piosenek (lub akordów). Albo do zdjęć kapsli od piwa, ewentualnie znaczków pocztowych.

Niby dane niewrażliwe, a jednak nie chcemy, żeby ktoś oglądał nasze kapsle od piwa. Zatem jakieś uwierzytelnienie by się przydało. Jeszcze lepiej by było, gdybyśmy mogli również komuś udostępniać pewne dane. I aby się dało otworzyć wszędzie na świecie. I aby dało się łatwo administrować tymi danymi. A gdyby to wszystko było rozszerzalne, skalowalne i aby to jakoś wyglądało… I najważniejsze – żeby było za darmo!

Jak wiadomo – nie da się. Jednak podjąłem próbę i jestem z niej całkiem zadowolony 😉

Front end

W przypadku tego projektu założyłem, że aplikacja ma wyglądać nie najgorzej oraz ma być przyjazna urządzeniom mobilnym. Do tego celu wykorzystałem Ionic. Domyślnie pisze się w nim jak aplikacje webowe na Angulara, ale wsparcie obejmuje również Reacta i Vue.js. Co więcej, style są dopasowane do systemu operacyjnego – na Androidzie styl androidowy, a na iPhone’ie styl makowy.

W tym frameworku jest dostępnych wiele typowych komponentów i funkcjonalności typowych dla aplikacji mobilnych. Można z takiej aplikacji zrobić aplikację typowo mobilną oraz używać mnóstwa natywnych funkcjonalności poprzez używanie pluginów.

Skorzystałem zatem z predefiniowanych komponentów (List, Infinite Scroll, Floating Action Button, Modal, Refresher, Loader, Slides, Toasts) i z ich pomocą stworzyłem kolejną przeglądarkę, ogólnie mówiąc, systemu plików.

Podgląd plików oraz wyświetlanie ich treści ukryłem za pewną abstrakcją, przez co dodanie nowych wspieranych typów plików jest stosunkowo proste. A same wsparte typy danych ograniczyłem do plików Markdown oraz plików tekstowych. Jak będzie trzeba, to dodanie podglądacza plików przez dodanie 3 klas nie powinno być trudne.

Data source

Projekt miał być w miarę niezależny od użytego źródła danych, dlatego musiałem wydzielić pewną abstrakcję plików i folderów.

Pierwszym i aktualnie jedynym wpieranym typem systemów plików jest „repozytorium” na BitBuckecie.

Ma to pewne plusy – dostęp poprzez Restowe Api Bitbucketowe wymaga uwierzytelnienia w Bitbuckecie. Daje to możliwości ograniczenia dostępu do danych, ale również możliwość wpółdziałania z innymi osobami posiadającymi dostęp do projektu. Darmowe prywatne projekty są aktualnie do 5 osób. Jeśli takie repozytorium będzie przykładowo postawione na gitcie, to dostajemy w prezencie również historię repozytorium oraz jego audyt oraz możliwości PullRequestów. A jeśli bardzo się postaramy, to możemy przy commicie odpalać jakieś pipeline’y (do 50 minut na miesiąc free).

Oczywiście te same feature’y były by dostępne dla GitHuba, gdyż oferuje podobny zakres funkcjonalności. Były by dostępne, gdybym je zaimplementował 😉

A gdzie ten projekt?

Rzeczy jasna najtańszy hosting, to darmowy hosting 😉 Skorzystałem z dobrodziejstwa Github Pages, gdzie zdecydowałem się wrzucić demo aplikacji. Bierzcie i klikajcie.

Jeśli chcecie – zajrzyjcie na źródła. Jeśli chcecie się tym pobawić – forkujcie. W Readme powinno być w miarę ładnie opisane, co i jak.

Backlog

Pomysłów na rozwój jest mnóstwo, a zasobów żadnych 😉

Można by w przyszłości:

  • dodać wsparcie dla Api Githuba,
  • zrobić z tego apkę natywną,
  • dodać wsparcie nie tylko dla odczytów, ale także dla zapisów (nie tylko podgląd pliku, ale także edycja)
  • wpierać przeglądanie wielu plików jednocześnie (coś jak karty przeglądarki)
  • zaimplementować, testy jednostkowe 😉

Ale kto by miał na to czas…

Podsumowanie

Tak, wiem, znów strollowałem nagłówkiem, bo coś o serwerlessie, a tu w sumie wychodzi SaaS – serwis Bitbucketowy to chyba Software as a Service… a może Platform, bo w sumie Bitbucket wystawia API… Nie wiem, nie znam się, ale co po kodziłem, to moje 🙂

Wraz z tym projekcikiem kończą mi się zaległe tematy do opisania na blogu. Zatem w kolejnych wpisach mam nadzieję wróci Nasza Ukochana Java.

A jeśli ten wpis Ci się podobał, to daj komentarz, odpowiednią ilość gwiazdek, cobym wiedział, jaki jest odbiór tego wpisu.

A jeśli interesuje Cię bieda-programowanie, to popełniłem już wcześniej jeden wpis o Lambdach dla ubogich.

Pax

 

Spring Boot Native, GraalVM i JHipster

Spring Boot na GraalVMie? Brzmi ciekawie, ale czy to jest możliwe?

Według wpisu sprzed 2 miesięcy prosty projekt można postawić na GraalVM Native Image. Jednak prosty CRUD, to nie system produkcyjny, który wymaga różnych bibliotek. Pytanie, jak to wygląda z nieco bardziej skomplikowanym stackiem technologicznym?

JHipster

Nie będę oczywiście pisał wszystkiego od zera. W zeszłym roku napisałem szybką aplikację (i wpis o niej). W niej wykorzystałem generator kodu JHipster. Taki generator to jest dobry dla szybkich, krótkich projektów, gdyż oprócz wygenerowanego modelu dostarcza wielu standardowych wymagań takich jak uwierzytelnienie, autoryzacja, metryki, obsługa logowania, a nawet stworzenie obrazu Dockerowego. Warto spojrzeć na ten projekt oraz co nim się da wygenerować również po to, żeby poznać technologie, zaczerpnąć ewentualnie patternów zaimplementowanych w takim projekcie.

Posiadając już apkę z Spring Bootem w wersji 2.2.7 postanowiłem zastosować w nim Spring Boot Native. Pierwszym wymogiem jest podbicie wersji Spring Boota do 2.4.5. Oczywiście podbicie wersji Spring Boota pociąga za sobą podbicie innych wersji bibliotek używanych przez Springa. Wchodząc do tego piekła ciężko z niego wyjść. Zatem od razu sobie odpuściłem manualne podbijanie, gdy w kolejnej wersji JHipstera (w wersji 7.0) podbicie Spring Boota do wersji 2.4.x jest zrobione out-of-the-box.

Zatem przeszedłem do ponownego wygenerowania na podstawie metadanych JHipstera. Rzecz jasna po tym należało zmerge’ować wygenerowany kod z moimi zmianami. Rzecz jasna to też nie było przyjemnie, ale wiązało się z mniejszym ryzykiem, niż manualne podbicie wersji.

Spring Boot + Spring Boot Native

Po podbiciu Spring Boota można było wprowadzić zmiany wymagane przez Spring Boot Native. Po uaktualnieniu pom.xml można było spróbować pierwszy raz uruchomić proces budowania. Pierwszy raz budowanie zakończyło się porażką na etapie budowania Mavena z komunikatem podobnym do Nie udało się, bo się nie wysypało. Pierwszym rozwiązaniem okazało się dodanie do parametrów kompliacji Mavena paramteru <forceJavacCompilerUse>true</forceJavacCompilerUse> . Po drugiej próbie kompilacji komunikat już był precyzyjny. Tym razem problem był po mojej stronie – źle zmerge’owałem źródła.

Trzecia kompilacja zakończyła się wielkim sukcesem – po 15 minutach mielenia na moim starym lapku projekt skompilował się! Uruchomienie przyniosło jednak szybkie rozczarowanie – logback nie może dostać się do pliku z konfiguracją. Po kilku próbach (czyt. kilkukrotnym czekaniu po 15 minut) i przeczesaniu internetu na temat tego błędu, zmigrowałem konfigurację na taką, która bazuje na przekazanych propertiesach.

Czwarta (15 minut) kompilacja zakończyła się porażką – uruchomienie aplikacji natywnej zakończyła się problemem z Audytowaniem uwierzytelniania. Nie jest to kluczowa funkcjonalność tego projektu, zatem szybkie zakomentowanie @EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware") rozwiązało problem.

Piąta (15 minut) kompilacja zakończyła się kolejną porażkę. Tym razem okazało się, że Swagger to zbyt duże wyzwanie dla Spring Native. Rozwiązanie – zakomentowanie uruchamiania profilu słagerowego.

Szósta (15 minut) kompilacja zakończyła się znów porażką. Tym razem kontekst Springa nie wstał. Po prostu. Bez informacji, co nie działa, gdzie i o co chodzi. Tutaj się poddałem.

Teoretycznie jest jakiś ticket na dostosowanie JHipstera do Spring Native, a nawet można dostać 500$ za dostarczenie tego feature’u. Można podejrzewać, że dostosowanie do Spring Native ostatecznie zostanie wykonane. Ale to póki co, to pieśń przyszłości.

Inne rozwiązania

JHipster jest już dość dojrzałym projektem – na frontendzie oferuje nie tylko Angulara, ale również Reacta i Vue.js, za to na backednzie wsparcie obejmuje Spring Boota, a także Micronauta i Quarkusa. Co ciekawe język programowania wymienić na Kotlina (ze Spring Bootem), C# (.Net), czy Javascript (NestJS).

W przypadku Micronauta niestety wsparcie nie obejmuje kompliacji do Native Image. Szkoda, bo Micronaut ma API bardzo podobne do Spring Boota.

Został JHipster z Quarkusem. Tutorial tłumaczący, jak stworzyć wygenerować taką aplikację jest dosadnie prosty. Wsparcie obejmuje również Native Image. Zatem do dzieła.

JHipster + Quarkus

Ponownie musiałem przegenerować na podstawie metadanych JHipstera. Niestety API Quarkusa różni się zdecydowanie od Spring Boota.

Począwszy od bazy danych i JPA różni się podejście do „owrapowania JPA„. W Spring Boot mamy bardzo przyjazne Spring Data Quarkus oferuje nam za to Panache, który jest frameworkiem dedykowanym dla Quarkusem. W nim dominującym podejściem do pobierania i zapisywania krotek jest ActiveRecord, choć da się również stworzyć klasy Repository. Jest wsparcie dla paginacji, customowych projekcji, zapytań w JPQL. Jedyne, czego brakuje, to odpowiednik uproszczenia JPA Criteria Query.

Jak mówiłem, podejściem dominującym jest Active Record, to w modelowaniu klas – zamiast klas POJO z getterami i setterami – dominuje klasa z publicznymi polami bez getterów i setterów dziedzicząca po PanacheEntity. Tutaj również musiałem dostosować swoje customowe zmiany.

Wsparcie dla cache’ów jest, ale w moim przypadku nie było potrzebne (i nie działało ;)), zatem je wyłączyłem.

Wsparcie dla security contextu jest, jednak dostęp nie jest przez ThreadLocala zwanego SecurityContextHolderem, a przez dodatkowy parametr wywołania endpointa.

Dodatkowo Quarkus wymusza poznawanie innych frameworków (czyt. RedHatowych, Eclipse’owych). I tak zamiast Jacksona mamy JSONB, zamiast Springa MVC mamy Jax-Rs. Wstrzykiwanie zależności wykonywane jest przez @Inject, choć Spring również wspiera tę adnotację.

Ostatnie, o czym trzeba pamiętać, to żeby do DTOsów i innych obiektów obrabianych refleksją (encje bazodanowe, projekcje) dodać adnotację @RegisterForReflection.

Wynik

Quarkus jest stworzony dla GraalVM Native Image, więc w tym przypadku nie było żadnego problemu z kompilacją do kodu natywnego. Sam czas kompilacji na mojej maszynie, to około 10 minut, czyli o 1/3 szybciej, niż kompilacja Spring Boot Native.

Normalnie na OpenJDK aplikacja wstaje lokalnie w około 5 sekund. Po skompilowaniu do kodu natywnego, uruchomienie aplikacji trwa do 100 milisekund.

Drugą kwestią jest, że taką aplikację można postawić niewielkim Heapie – dałem -Dxmx=64M, co łącznie z OffHeapem łącznie daje 200 MB (tyle przynajmniej pokazuje htop). Obstawiam, że gdyby zrezygnować z wielu JHipsterowych feature’ów, to dało by się zejść z zużyciem pamięci jeszcze niżej.
Spring Boota to z mniejszym Heapem niż 128 to bym nawet nie próbował startować, a jak wiadomo OffHeap też swoje waży. Jakkolwiek istnieją ludzie, którzy ze Spring Bootem potrafią zejść poniżej 80MB mierząc wszystko…

Podsumowanie

Należy pamiętać, że Spring Native jest jeszcze w fazie beta, zatem nie wszystko może tam działać. Możliwe, że tworzenie serwera od początku do końca było by lepszą strategią pozwalającą na znalezienie wszystkich przyczyn błędów.

Jednak każdorazowa rekompilacja trwająca 15 minut skutecznie odrzuca. Jako programista Java jestem przyzwyczajony do kompilacji klasy do bytecodu mierzonej w maksymalnie sekundach. Nawet kompilacja z użyciem Mavena takiego projektu trwa maksymalnie minutę.

Trzeba poczekać na pierwszy release, wtedy będzie można cokolwiek powiedzieć. Póki co jest zbyt wcześnie…

 

Konkatenacja stringów – benchmark

Dostałem reklamacje od kumpla, który spodziewał się jakichś benchmarków porównujących wydajność konkatenacji w kolejnych wersjach JVMów. Oczywiście chodzi o dopełnienie poprzedniego wpisu.

Co sprawdzamy i jak?

Założyłem, że porównam działanie kodu skompilowane różnymi wersjami kompilatorów javac z JDK 1.4, 1.8 i 15. Następnie te skompiowane kody wrzucę do plików Jar, które będę uruchamiał na kolejnych wersjach JVMa.

Nie wiem, ilu z Was kompilowało kod z poziomu terminala. Osobiście preferuję skorzystanie z Mavena, ewentualnie z poziomu IDE. Jednak w ramach ćwiczenia stwierdziłem, że skompiluję i spakuję do Jar z użyciem linii komend. Dla jednego pliku nie okazało się to zbyt trudne 😉

javac src/main/java/dev/jgardo/jvm/miscellaneous/string/StringConcatenation.java

jar cf ./string.jar dev/jgardo/jvm/miscellaneous/string/StringConcatenation.class

Następnie uruchamiałem następujące benchmarki w JVMek różnych wersji (1.7,1.8,11,15 – wszystkie z rodziny OpenJDK), które korzystają z bibliotek skompilowanych różnymi wersjami javac.

private static final StringConcatenation CONCATENATION = new StringConcatenation();

@Benchmark
public String concatenation() {
    return CONCATENATION.helloWorldFromOldMen(12);
}

Gdzie StringConcatenation to:

public class StringConcatenation {
    public String helloWorldFromOldMen(long age) {
        return "Hello " + " world from " + age + " years old men";
    }
}

Wyniki

JVM \ jar 1.4 [ns/op] 1.7/1.8 [ns/op] >9 [ns/op]
OpenJDK 1.7 101,435 ± 2,746 66,310 ± 1,106
OpenJDK 1.8 98,539 ± 1,757 68,302 ± 1,228
OpenJDK 11 96,123 ± 1,137 54,094 ± 2,117 23,195 ± 0,172
OpenJDK 15 83,235 ± 1,837 55,243 ± 2,067 23,516 ± 0,301

Wyniki w zależności od wersji maszyny wirtualnej oraz wersji kompilatora javac. Wyniki wyrażone w ns/op.

Z tych wyników można wyciągnąć kilka wniosków:

  1. StringBuffer jest wolniejszy od StringBuildera
    Mało odkrywcze – dodatkowa synchronizacja zawsze coś będzie kosztować. Jakkolwiek wydaje się, że w tych nowszych JVMkach StringBuffer przyspieszył zawsze jest przynajmniej 1/3 wolniejszy niż StringBuilder
  2. Uruchomienie konkatenacji skompilowanej javac w wersji 1.8 jest szybsze na OpenJDK 11 o około 20% niż na OpenJDK 1.8.
    To w prawdopodobnie wynika z tego, że w Java 9 zaczęto używać 1 bajtowego byte zamiast 2 bajtowego char. Więcej o tym choćby tutaj – JEP-254.
  3. Uruchomienie konkatenacji skompilowanej javac w wersji 9 wzwyż powoduje skrócenie czasu o ok. 55%.
    O tym efekcie wspominałem już w poprzednim wpisie. Notatka eksperymentalna zawierała prawdę 😉

Pamięć

Zmierzyłem również ilość potrzebnej pamięci do wykonania konkatenacji. Również nie było to trudne – wystarczyło do benchmarku dodać jedną linijkę podpinającą GCProfiler. Wyniki w poniższej tabelce.

JVM \ jar 1.4 [B/op] 1.7/1.8[B/op] >9[B/op]
OpenJDK 1.7 272,000 ± 0,001 272,000 ± 0,001
OpenJDK 1.8 272,000 ± 0,001 272,000 ± 0,001
OpenJDK 11 200,000 ± 0,001 168,000 ± 0,001 80,000 ± 0,001
OpenJDK 15 200,028 ± 0,002 168,019 ± 0,001 80,009 ± 0,001

Wyniki w zależności od wersji maszyny wirtualnej oraz wersji kompilatora javac. Wyniki wyrażone w B/op.

Również i tutaj jestem winien kilku słów komentarza:

  1. StringBuilder i StringBuffer uruchomione na OpenJDK w wersji 9 wzwyż korzystają z wspomnianego wcześniej ulepszenia – JEP-254. Stąd redukcja o 25% zużycia pamięci względem uruchomienia na wersji 1.7 lub 1.8.
  2. Użycie konkatenacji skompilowanej javac w wersji 9 wzwyż pozwala na redukcję zużycia pamięci o 50% w porównaniu do konkatenacji skompilowanej javac w wersji 1.8 i o 67% w porównaniu do wersji 1.4.

Podsumowanie

Warto używać nowszej wersji Javy niż owiana sławą 1.8. Wbrew pozorom w nowych wersjach Javy wchodzą nie tylko nowe feature’y, lecz i usprawnienia wydajności.

O ile konkatenacja w Javach 9+ jest znacznie bardziej wydajna, to rzadko kiedy jest to na tyle kluczowe, by zastąpić czytelne String.format, Logger.info itd. Czytelność jest ważna, a wydajność konkatenacji stringów może mieć marginalne znaczenie, jeśli macie znacznie cięższe operacje do wykonania – operacje na bazie danych lub uderzenie HTTP na zewnętrzny serwis.

Warto też spojrzeć na minimalną wersję Javy wymaganą przez biblioteki jako na potencjalną możliwość przyspieszenia działania, zamiast wyłącznie na ograniczenie.

Pax

 

„Hello ” + name + „.”, czyli konkatenacja Stringów

Stringi są na ciekawym tworem – zbyt rozbudowane, aby dało się je zamknąć w typ prymitywny, a jednocześnie operacje na nich muszą być bardzo wydajne.

Jedną z podstawowych operacji na nich jest tworzenie nowych łańcuchów znaków poprzez łączenie różnych zmiennych. Jednym słowem – konkatenacja. Można ją uzyskać na wiele różnych sposobów m. in. operator +, StringBuilder, StringBuffer lub String.format().

W tym artykule opowiem nieco o operatorze +.

Implementacja

Załóżmy, że interesuje nas następująca operacja.

    public String helloWorldFromOldMen(long age) {
        return "Hello  world from " + age + " years old men";
    }

Warto na wstępie zajrzeć, co mówi oficjalna dokumentacja Java Language Specification o konkatenacji.

An implementation may choose to perform conversion and concatenation in one step to avoid creating and then discarding an intermediate String object. To increase the performance of repeated string concatenation, a Java compiler may use the StringBuffer class or a similar technique to reduce the number of intermediate String objects that are created by evaluation of an expression.

The Java® Language Specification, Java SE 15 Edition

Co ciekawe, ta część jest niezmienna od specyfikacji dla Javy 1.0.

I rzeczywiście, jeśli skompilujemy kod kompilatorem do Javy 1.4, to rezultat (po skompilowaniu i dekompilacji) będzie następujący:

    Code:
      stack=3, locals=3, args_size=2
         0: new           #2   // class java/lang/StringBuffer
         3: dup
         4: invokespecial #3   // Method java/lang/StringBuffer."":()V
         7: ldc           #4    // String Hello  world from
         9: invokevirtual #5   // Method java/lang/StringBuffer.append:(Ljava/lang/String;)Ljava/lang/StringBuffer;
        12: lload_1
        13: invokevirtual #6  // Method java/lang/StringBuffer.append:(J)Ljava/lang/StringBuffer;
        16: ldc           #7   // String  years old men
        18: invokevirtual #5  // Method java/lang/StringBuffer.append:(Ljava/lang/String;)Ljava/lang/StringBuffer;
        21: invokevirtual #8  // Method java/lang/StringBuffer.toString:()Ljava/lang/String;
        24: areturn

Jednak StringBuffer ma tę właściwość, że jego metody są synchronizowane. W przypadku konkatenacji stringów, wykonywanych w tym samym wątku, jest to zbędne. Z tego powodu w Javie 1.5 zdecydowano zamienić StringBuffer na StringBuilder, który tej synchronizacji jest pozbawiony.

Kolejnym krok nastąpił w Javie 1.6 – wprowadzono usprawnienie, pozwalające JITowi C2 zamienić użycia StringBuilder na tworzenie Stringa bez konieczności tworzenia obiektu StringBuilder. W Javie 1.7 włączono ten mechanizm domyślnie. Okazało się, jednak, że ta opcja (OptimizeStringConcat) jest „krucha” (ang. fragile) i sprawia problemy przy dalszej optymalizacji Stringa.

JEP-280

Postanowiono zastosować ten sam mechanizm, co przy implementacji Lambd. Zamiast na etapie kompilacji ustalać, jak jest wykonywana konkatenacja Stringów, pozwólmy na wygenerowanie tego kodu przez JVMa przed pierwszym uruchomieniem.

Takie podejście pozwala na eliminowanie wstecznej kompatybilności zastosowanych optymalizacji, gdzie zmiany w starszej Javie musiały również działać w nowszej. Jednocześnie kod skompilowany w starszej Javie po uruchomieniu na nowszym JVMie automatycznie działał szybciej, gdyż optymalizacje robione są przy pierwszym uruchomieniu.

Wspomniany JEP-280 został wdrożony w Javie 9.

A jak to będzie w wydajności?

Generalnie – szybciej.

Przy generowaniu kodu konkatenacji aktualnie jest dostępnych 6 strategii, przy czym domyślnie włączona jest najefektywniejsza. Pozwala ona na konkatenowanie 3-4 krotnie szybciej, jednocześnie wykorzystując 3-4 razy mniej pamięci (w ekstremalnych przypadkach 6.36x szybciej i 6.36x mniej pamięci). Tworzenie Stringów w tej strategii odbywa się praktycznie bez tworzenia dodatkowych obiektów, po których GC musiałby sprzątać.

Jednokrotny narzut wynikający z konieczności wygenerowania kodu w Runtime’ie jest stosunkowo mały – do 30ms.

Podsumowanie

Szczerze mówiąc, tkwiło we mnie przekonanie, że jak konkatenacja Stringów to tylko i wyłącznie StringBuilder, bo inaczej jest „nieefektywnie”. Okazuje się jednak, że operator + może być bardziej efektywny w prostych przypadkach.

Kolejny raz można powiedzieć, że jeśli chcesz pomóc JVMowi w optymalizacji, to pisz porządny, czytelny, rzemieślniczy kod.

Jeśli chodzi o linki do poczytania, to:

Pax et bonum 🙂

 

Kompilacja kodu w Javie – JIT teoretycznie

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

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

 

Prywatny projekt w tydzień na produkcji

Czy da się w tydzień wypuścić produkcję MVP własnego prostego projektu robionego po godzinach pracy? Challenge accepted!

Btw. nie martwcie się, że to drugi wpis z rzędu o projektach, zamiast o low level Javie. Nie mam zamiaru zmieniać profilu bloga. Po prostu poświęciłem dużo czasu na ten projekt i szkoda byłoby tego też nie opisać. A w moim mniemaniu wyszło całkiem spoko.

Prolog

Do indeksowania wybranego fragmentu internetu już się wcześniej zabierałem. Moim targetem były audycje radiowe – linki do nich oraz metadane, żeby mieć po czym szukać audycji.

Podobny potencjał dostrzegłem i w zupełnie innej sferze. Pojawiało się na bieżąco mnóstwo contentu, który warto by zaindeksować, aby łatwiej sobie tego pożądanego fragmentu poszukać. Toteż spytałem na jednej grupie Facebookowej, czy taka koncepcja jest sensowna i potencjalnie przydatna. Pomysł spotkał się z dużym entuzjazmem, zatem nic tylko wziąć się do roboty.

Charakterystyka projektu jest mocno nacelowana na czas – im szybciej tym szybciej. Zatem nie ma czasu na rzeźbienie, trzeba korzystać z gotowców, dowozić największy efekt najmniejszym kosztem. Bugi? Na później. Pracochłonny feature? Na koniec backlogu. Słowem – czyste naparzanie kodu 🙂

Technologie

Czas nagli, więc nie będę tworzył CRUDów od zera, stawiał serwera, zbędnie konfigurował itd. Zdarzyło mi się wcześniej korzystać z JHipstera, zatem stwierdziłem, że i w tym przypadku będzie to najlepsze narzędzie.

JHipster to w skrócie generator aplikacji opartych na Spring Boot. Tworzy on CRUDy razem z widokami. Tworzy kontrolery, opcjonalne serwisy, mappery oraz repozytoria Spring Data. Utrwalanie danych możliwe przez wykorzystanie relacyjnej bazy danych (u mnie Postgres, na dev jest H2), choć dostępne są również różne cache oraz NoSQLe, jednak z nich nie korzystałem.

Dostępne technologie front-endowe to Angular, Vue oraz React. Wygląd oparty na Bootstrap 4.

Czas zacząć robić tę robotę…

Na początku trzeba zdefiniować model – encje oraz relacje między nimi. Istnieje do tego ładny edytor webowy JDL-Studio. Można też skorzystać z innych narzędzi do definicji modelu, jednak wówczas nie miałem dostępu do kompa, a na tablecie ładnie dało radę wszystko wyklikać. W godzinę model był gotowy.

I tutaj pierwsze zaskoczenie – po zapisaniu projektu gdzieś w chmurze w tym JDL-Studio można go ładnie poprosić, żeby zaaplikował wygenerowany projekt do wybranego repozytorium na githubie poprzez stworzenie Pull Requesta. Zatem jak uzyskałem dostęp do kompa, można było zrobić git pull i zacząć naparzać kod!

Budowanie projektu to wykonanie ./mvnw. Rzecz jasna trzeba odczekać, aż Maven zaciągnie się sam, a potem zaciągnie pół internetu. Zależności front-endowe również są zaciągane automagicznie. Po jakimś czasie widzimy uruchomiony projekt w wersji developerskiej.

Development

Wygenerowane CRUDy wyglądają dość topornie i trzeba je znacznie podrasować (szczególnie formularz new/edit). Jakkolwiek style, layouty już istnieją predefiniowane i wyglądają nie najgorzej, zatem przynajmniej coś.

Fajne jest to, że do developerki JHipster generuje wstępnie dane użytkowników oraz losowe dane dla naszych encji – nie trzeba tyle klikać. Za to mvn clean czyści również całą bazę H2.

Gdyby ten projekt nie był „na szybkości”, to pewnie spodobało by mi się, że w ramach generowania kodu generowane są testy. I to nie tylko jednostkowe, ale też integracyjne, end to end, a nawet (co mnie zdziwiło) testy architektury aplikacji.

JHipster jednak nie tylko generuje model i widoki. Generuje również całą przydatną otoczkę aplikacyjną. Można wymienić rejestrację, autoryzację, uwierzytelnianie, metryki, zarządzanie loggerami, użytkownikami. Audyt – kto kiedy się logował, healthchecki, podgląd konfiguracji to również przyjemna sprawa. Wszystko to dostępne od ręki dla admina. Zwykły użytkownik może domyślnie tylko zarządzać encjami biznesowymi. Niezalogowany może się co najwyżej zarejestrować lub zalogować (zabezpieczone bodajże przez Spring Security).

Na produkcję z tym!

Wymagana konfiguracja produkcyjna projektu jest minimalna – u mnie ograniczyło się to do konfiguracji skrzynki pocztowej, żeby projekt mógł wysyłać maile do rejestracji. Zatem projekt jest prawie od razu gotowy na produkcje.

Na produkcję, czyli gdzie? Otóż JHipster również i tutaj ma wsparcie dla różnych rozwiązań: AWS, Azure, GCP, Kubernetes, Heroku oraz Openshift.

Osobiście wybrałem Heroku, bo tanio – domyślnie za free możesz testowo postawić Proof of Concept i postawić Postgresa do 10 000 wierszy.

Jednak z darmowym planem jest o tyle problem, że gdy aplikacja nie jest używana to ją ubijają. Za to za 7 $ miesięcznie będzie raz dziennie restartowana, lecz będzie działać cały czas. Limit pamięci – 512MB; metryki (nieco inne niż w JHipster) też ograniczone – do ostatniej doby. Deployment oczywiście z downtime (w przypadku jednej instancji serwisu).

Wrzucenie tego na produkcję z JHipsterem to bajka! Wystarczyło zainstalować integrację do heroku, wpisać jhipster heroku, wybrać wersję Javy i po kilku minutach nowa wersja jest na prodzie! A byłoby jeszcze szybciej, ale testy się „muszą” odpalić 😉

Ostatnie szlify

Nową apkę warto postawić pod jakąś bardziej ambitną domeną niż domyślna: https://.herokuapp.com/. Osobiście kupiłem na OVH domenę za 7 zł na rok, więc to też nie jest jakiś super koszt. Domena gotowa do użycia w jeden dzień – tylko skonfigurować.

Warto wspomnieć, że Heroku samo podpina certyfikat od Let’s Encrypt za free i konfiguruje, więc HTTPSa mamy również zerowym kosztem.

Przyda się również pamiętać, żeby zmienić hasło z admin:admin i user:user na jakieś trudniejsze 🙂

Na szybkości zrobiłem też testy wydajnościowe i aplikacja klęka przy mniej więcej 20 req/s.

Epilog

Warto by powiedzieć co tak naprawdę było przedmiotem projektu 😉

Otóż jest taki kandydat na prezydenta, który bardzo mocno poszedł w internety. Codziennie przynajmniej jeden live z odpowiedziami na pytania ludzi. Jednak trudno się odnajduje potem te fragmenty, kiedy jaki temat był na tapecie.

Zatem poindeksowałem, co usłyszałem na 30 filmikach na Youtube, przeczytałem na 42 stronach pdfu z programem/wizją oraz na 8 z 13 stron www z programem w internetach.

Wyszło jakieś 200-300 przeróżnych tematów.
Co by nie mówić, to contentu jest naprawdę sporo… U innych kandydatów roboty byłoby mniej (a na jeszcze innych na program trzeba czekać do 4 dnia przed wyborami, więc bym nie zdążył).

Cały projekt, jeśli chcecie zobaczyć, dostępny na tej oto podlinkowanej stronie.

To by było na tyle. Mam nadzieję, że apka podziała jeszcze 3 tygodnie, a potem będzie można zaorać 😉

A potem wreszcie wrócić do pisania o JVMie!

 

Lambda dla ubogich

Z mojego doświadczenia wynika, że większość programistów posiada jakieś swoje własne projekciki na boku. A to jakiś analizator giełdy, a to jakaś apka do ogarniania smogu, a to parsowanie rozkładów jazdy pociągów, względnie sterowanie żarówkami we własnym smart domu.

Takie projekciki charakteryzują się myślą przewodnią: byle szybko zakodzić i byle działało. Z założenia mogą działać tylko w optymistycznych flow i nie posiadać żadnych testów. Mają być radosnym programowanie i sprawdzeniem się, czy jeszcze potrafimy coś zaprogramować w innych „niepracowych” technologiach.

Takimi też przesłankami kierowałem się tworząc swój mini projekcik o roboczej nazwie:

Potrzeba była następująca: posiadam wirtualkę w cloudzie i chciałbym posiadać na niej dużo swoich aplikacji, jednak mam tylko 1GB pamięci i 8GB miejsca na dysku… Bardziej problematyczne jest pierwsze ograniczenie… A Java – jak to Java – zjada tyle pamięci, ile się jej przydzieli.

Można oczywiście skorzystać z Graala, skorzystać z Micronauta/Quarkusa i skompilować projekt do kodu natywnego. Problem pojawia się, gdy dziergamy nasze tajne projekty w Spring Boot od kilku lat i jest za dużo kodu do przepisania.

Zatem gdyby tylko istniało jakieś rozwiązanie, które pozwala na uruchamianie naszych serwisów, gdy są potrzebne, a gdy nie są potrzebne, to je ubija. I gdyby jeszcze było w miarę lekkie, nieobciążające zbytnio pamięci ani procesora. Takie Heroku tylko na VPSie.

Trochę poszukałem w internecie takie rozwiązania i oczywiście nie znalazłem 😛

No dobra, gdybym szukał trochę dłużej, to bym znalazł. Jednak taki własny projekt zawsze czegoś uczy, domena jest inna niż ta w pracy, więc może warto spróbować samemu coś takiego napisać…

Development

Z założenia rozwiązanie miało być przenaszalne, crossplatformowe oraz w miarę proste, zatem wybór padł na Dockera, jako sposób zarządzania aplikacjami. Serwerem proxy jest Nginx (wcześniej już chciałem go nieco poznać). Jednak jak połączyć te dwa światy, żeby za proxy była warstwa zarządzająca kontenerami Dockera?

Okazuje się, że istnieje takie rozwiązanie jak OpenResty, który rozszerza Nginx o możliwość wykonywania skryptów (w języku Pascalo-podobnym o nazwie Lua). Zawsze nowy język do CV 😀 Warto mieć jakieś Api dockerowe i panel administracyjny zatem pożeniłem to wszystko z Portainer.

Zatem poświęciłem kilka wieczorów i oto jest: lambda-for-vps. Jeżli ktoś chciałby spróbować, jak działa, wystarczy zklonować repo i uruchomić:

docker-compose -f docker-compose.yml up

Domyślnie uruchamia się projekt z przykładowym hello-world, który jest dostępny na http://localhost/hello-world.

Działanie

Co nieco o działaniu jest opisane na githubie, jednak po angielsku. Po polsku to w skrócie:

  1. Po uderzeniu na endpoint /hello-world za pomocą Portainer sprawdzany jest stan aplikacji (określonej za pomocą docker-compose.yml).
  2. Jeśli kompozycja nie istnieje, to wszystkie kontenery są automagicznie zaciągane.
  3. Jeśli kompozycja jest nieżywa, wówczas uruchamia całą kompozycję. Po uruchomieniu kontenerów istnieje opcja czekania określonego czasu od wstania kompozycji.
  4. Skoro wszystko działa, to request jest obsługiwany.
  5. Następnie zlecamy ubicie kompozycji o ile nie będzie w między czasie nowych żądań. Innymi słowy kompozycja jest ubijana po podanej liczbie sekund od ostatniego requesta.

Prawdopodobnie projekt nie jest doskonały. Jednak posiada kilka fajnych cech:

  • wg docker images całość waży 181 MB,
  • wg docker stats obydwa kontenery zużywają 12 MB pamięci i szczątkową część procesora,
  • jest w nowym nieznanym mi języku Lua,
  • konfiguracja w popularnym Nginx,
  • zarządza apkami trzymanymi w Dockerze.

zatem jak na mały projekt jest przynajmniej Good enough.

 

Wpływ Garbage Collectorów na działanie JVMa – odczyty z pamięci

Poprzedni wpis był o tworzeniu obiektów w JVMie w zależności od wybranego garbage collectora. W tym wpisie skupię się na przedstawieniu wpływie wybranego gc na odczyt istniejących obiektów.

Benchmark

Przed uruchomieniem Benchmarku tworzona jest statyczna tablica. Następnie w bloku statycznym jest ona wypełniana przykładowymi danymi. Tworzone obiekty to wrappery na int.
Benchmark uruchamiany jest dla młodej oraz starej generacji (o ile istnieje taki podział). Aby wymusić przynależność do starej generacji JVM jest proszony o wykonanie GC kilkakrotnie.

    private static final IntWrapper[] WRAPPERS = new IntWrapper[100000];

    static {
        for (int i = 0; i < WRAPPERS.length; i++) {
            WRAPPERS[i] = new IntWrapper(i);
        }
        for (int i = 0; i < 20; i++) {
//            Runtime.getRuntime().gc();
        }
    }

Następnie w benchmarku zliczana jest suma pierwszych 2000 wartości we wspomnianych obiektach.

    @Benchmark
    public long method() {
        long sum = 0;
        for (int i = 0; i < 2000; i++){
            sum += WRAPPERS[i].getI();
        }
        return sum;
    }

UWAGA! Jak widać w powyższym benchmarku bazujemy jedynie na zmiennych lokalnych (czyt. trzymanych na stosie wątku). Jednocześnie w trakcie benchmarku nie tworzymy ani jednego nowego obiektu. Badamy jedynie wpływ gc na odczyty, a nie wydajność samego gc.

UWAGA 2! We wpisie zakładam, że czytelnik przeczytał poprzedni wpis, a także, że jego treść była w miarę zrozumiała. 😉

Epsilon GC

Na początek EpsilonGC, czyli brak działań. Zarówno wynik dla wariantu bez gc oraz z wymuszonym gc jest taki sam – około 1708 ns/op (wymuszanie gc nie ma w tym przypadku sensu i jest ignorowane, stąd te same wyniki).

W tym wpisie uznaję ten wynik uznać za „wzorcowy” i do niego będę porównywał inne rezultatu.

ParallelGC

W przypadku ParallelGC program działa nieprzerwanie aż do momentu braku miejsca na nowe obiekty (MinorGC) lub braku miejsca na promocję obiektów do starej generacji (FullGC). Wszystkie metadane niezbędne do gc wyliczane są w czasie STW bez dodatkowego narzutu na „runtime”. Zatem można spodziewać się wyniku analogicznego do EpsilonGC.

Tak też się dzieje w przypadku obiektów w młodej generacji – w tym przypadku wynik to 1703 ns/op .

W przypadku starej generacji mamy niespodziankę – wynik 1579 ns/op (szybciej o 8%). Z czego wynika szybsze wykonanie benchmarku?

Otóż przy kopiowaniu danych z młodej do starej generacji wykonywana jest analiza, która pozwala na uporządkowanie danych w bardziej logiczny sposób niż przy tworzeniu obiektów. O ile wcześniej elementy tablicy mogły być rozdzielone innymi obiektami (szczególnie, że statyczna inicjalizacja działa się przy ładowaniu klas), o tyle po wykonaniu gc elementy tablicy prawdopodobnie trzymane są razem.

ConcMarkSweepGC

W tym przypadku również młoda generacja wypada podobnie do EpsilonGc – 1694 ns/op. W przypadku starej generacji wynik to 1837 ns/op, czyli 7% wolniej. Jednak podobnie jak w poprzednim wpisie zostawię tę analizę historykom, skoro w OpenJDK 14 CMSa już nie uświadczymy 😉

G1GC

W przypadku G1Gc nie widać różnicy w porównaniu do EpsilonGc – 1701 ns/op oraz 1698 op/s. Prawdopodobnie wynika to z tego, że zarówno młoda generacja, jak i stara znajdują się w takiej samej liczbie segmentów, które niekoniecznie muszą być zlokalizowane blisko siebie. Aczkolwiek, to tylko domysł, który trudno zweryfikować…

O ile G1Gc korzysta z barier zapisu, o tyle z barier odczytu nie korzysta, stąd też brak dodatkowego narzutu na odczyt.

ZGC

Jak już wspominałem w poprzednim wpisie ZGC korzysta z barier odczytu. Wspomniałem również, że aktualnie nie ma podziału na młodą i starą generację.

Oba te fakty widoczne są w wynikach benchmarku – 1996 ns/op w wersji z gc oraz 2025 ns/op w wersji bez gc. Zatem w tym benchmarku narzut spowodowany korzystaniem z ZGC to 16%. Dość dużo, biorąc pod uwagę, że ten gc nie został ani razu uruchomiony w czasie benchmarkowania…

Shenandoah

O ile Shenandoah w OpenJDK 13 również nie posiada podziału o tyle wyniki dwóch benchmarków się znacząco różnią.

W przypadku braku wcześniejszych gc, wynik jest porównywalny do EpsilonGC (~1700 ns/op).

W drugim przypadku – gdy został wcześniej wymuszony gc – wynik był 4041 ns/op. To jest bardzo duży narzut – 136% więcej w porównaniu do EpsilonGC.

Taka różnica może wynikać z głównego założenia działania Shenandoah. Przed każdym obiektem zawsze znajduje się dodatkowy wskaźnik na ten obiekt, lub na nowe miejsce w pamięci, gdzie został ten obiekt przeniesiony. W pesymistycznym przypadku najpierw odczytujemy wartość wskaźnika (jednocześnie ładując do Cache’u procesora obiekt), po czym okazuje się, że załadowany obiekt nie jest tym, którego szukamy. Wówczas musimy pobrać obiekt z innej lokalizacji. Jednak wydaje się dość nieprawdopodobne, że taka sytuacja by była cały czas.

Trzeba pamiętać, że ten gc jest w wersji 13 eksperymentalny. Plany są takie, by ten gc mógł działać produkcyjnie w wersji 15. Zatem jest szansa, że ten przypadek będzie usprawniony.

Podsumowanie

Wybór Garbage Collectora może mieć wpływ nie tylko na samo czyszczenie pamięci, lecz również na działanie aplikacji. Garbage Collector może przyspieszyć wykonywanie kodu (przypadek ParallelGC) lub spowolnić (Shenandoah, ZGC).

Warto wspomnieć, że Shenandoah i ZGC wejdą produkcyjnie dopiero w OpenJDK 15 zatem można oczekiwać pewnych poprawek.

Standardowo ludziom z nadmiarem czasu polecam prezentacje na Youtube. Bardzo dobre podsumowanie wydajności wykorzystania procesora oraz pamięci – Sergey Kuksenko z Oracle. Ponadto, dla ludzi chcących zaznajomić się z Shenandoah polecam prezentację Alexey’a Shipilev’a.

Przy okazji życzę wszystkiego dobrego na święta Wielkanocne 😉