W poprzednim wpisie podzieliłem się ogólnymi uwagami dot Quarkusa. W tym chciałbym opowiedzieć o technikaliach współpracy Quarkusa z GraalVMem i Javascriptem.
Javascript
Teoretycznie w dokumentacji nie znajdziemy informacji, iż Quarkus wspiera Javascript. GraalVM wspiera JSa, to tak, ale Quarkus o tym nie wspomina.
Jeszcze w zeszłym roku instalując GraalVMa otrzymaliśmy wsparcie dla Javascriptu out-of-the-box. Aktualnie, aby skorzystać z dobrodziejstwa JSa należy go doinstalować – podobnie jak wcześniej było ze wsparciem dla Pythona, R czy Ruby’ego. Wydzielenie Javascriptu z bazowej paczki (stało się to w wersji 22.2) było spowodowane chęcią jej odchudzenia.
To odłączenie powoduje dwa problemy – jeden z Dockerfilem dla wersji wykorzystującej JVMa, drugi – w wersji skompilowanej do kodu natywnego.
Aby to wyjaśnić najpierw muszę cofnąć się i dopowiedzieć nieco o budowaniu projektów z Quarkusem.
Proces budowania obrazu z JVM
Budowanie projektu do postaci JVMowej to nic innego jak zbudowanie go Mavenem/Gradlem. jeżeli chcemy skorzystać z Dockera, to musimy uruchomić budowanie obrazu z Dockerfile’a.
Jednak bazowy obraz, z którego korzystamy dockeryzujac nasza apke, to nie standardowy GraalVM CE dostarczona przez Oracle’a, a maksymalnie odchudzona wersja OpenJDK -dostarczane przez RedHata. Zatem jeżeli chcemy korzystać z GraalVMa- musimy zmienić obraz bazowy na GraalVMowy (a istnieją obrazy dostarczane przez Oracle’a). Musimy się jednak pogodzić, iż nasz obraz będzie cięższy i może nieco wolniejszy.
Jeśli chcemy skorzystać z Javascriptu, to musimy go doinstalować do naszego obrazu bazowego, gdyż preinstalowanych obrazów z Jsem nie ma. Wystarczy jednak dodać kilka linijek w Dockerfile’u, zatem bez tragedii…
Proces budowania obrazu z programem natywnym
Budowanie projektu do postaci natywnej jest już bardziej skompikowane. Zamiast standardowego kompilatora potrzebujemy GraalVM z dodatkiem „Native Image”. Instalacja dodatku wygląda analogicznie do dodania wsparcia dla Javascriptu.
Budowanie kodu natywnego ma pewien mankament – jest on natywny, czyli specyficzny dla danej platformy. Innymi słowy – na Windowsie nie zbuduje się źródeł dla Linuxa. Problem ten da się rozwiązać poprzez wymuszenie kompilacji do kodu natywnego z użyciem obrazu dockerowego, co też Quarkus udostępnia. Standardowo wykorzystywany do tego celu jest obraz z Mandrillem – forkiem GraalVMa stworzonym na potrzeby Quarkusa.
Jak można się spodziewać Mandrill nie wspiera Javascriptu. Można jednak podmienić obraz budujący wykorzystywany na GraalVMowy, uprzednio wzbogacając go o wsparcie dla JSa. Jednak tak zmodyfikowany obraz trzeba trzymać w jakimś docker registry.
Podsumowując – wszystko się da, jednak musimy się pożegnać z różnego rodzaju optymalizacjami, ułatwieniami i poświęcić nieco czasu w znajdywanie różnych rozwiązań niestandardowych problemów. Wymaga to też pewnej kreatywności – jak robić, by zrobić, ale się nie narobić
Użycie niewspieranej biblioteki w trybie Native
Jak już wspominałem w poprzednim wpisie, jest bardzo dużo bibliotek posiadających własne Pluginy do Quarkusa. Wówczas użycie takiej biblioteki nie sprawia żadnych problemów.
Użycie niewspieranej biblioteki w trybie JVMowym również nie jest zbytnim wyzwaniem. Prawdopodobnie wystarczy zarejestrować odpowiednie klasy jako beany z użyciem CDI ewentualnie inicjalizując.
Problemy zaczynają się, gdy taka niewspieraną bibliotekę próbujemy użyć w trybie natywnym. Problemów dostarcza zarówno Quarkus, jaki i sam GraalVM.
GraalVM umożliwia inicjalizację pól statycznych oraz uruchomienie bloków statycznych na etapie budowania zamiast w runtime (więcej o tym tu). Pozwala to ograniczyć czas inicjalizacji aplikacji. Quarkus w ramach optymalizacji deklaruje, iż wszystkie możliwe pola statyczne finalne mają być zainicjalizowane. Jednak nie wszystkie pola możemy tak zainicjalizować. Chociażby generator licz losowych nie powinien być zainicjalizowany na etapie budowania, gdyż spowodowałby to generowanie tych samych liczb w tej samej kolejności przy kolejnych uruchomieniach aplikacji. jeżeli zatem chcemy zainicjalizować klasę w runtime’ie, musimy to jawnie zadeklarować, co wymaga dodatkowej pracy.
Za to powszechnym wymaganiem wynikacjących z użycia GraalVM Native Image jest wymienienie wszystkich klas, które muszą posiadać wsparcie dla refleksji, proxowania, serializacji, JNI lub które są resource’ami i które trzeba zawrzeć w aplikacji natywnej. Można próbować samemu stworzyć wymagane pliki json, jednak jest to karkołomne zadanie.
Twórcy GraalVMa stworzyli agenta, który sam spisuje za nas dla jakich klas używamy refleksji, jakie proxy tworzymy itd. Należy jednak zadbać o to, aby po uruchomieniu „wywołać” te miejsca (użycia refleksji itd.), aby agent mógł je zarejestrować. Innymi słowy trzeba przetestować całą aplikacje wzdłuż i wszerz… Takie testy regresyjne należałoby przeprowadzać przynajmniej przy dodawaniu nowej biblioteki lub zmiany konfiguracji. Zatem to rozwiązanie jest mocno uwierające.
Oprócz tego dość niedawno pojawiło się ogólnodostępne repozytorium z metadanymi dla najbardziej popularnych bibliotek. Nie miałem jednak okazji skorzystać z tego dobrodziejstwa, jednak wydaje mi się, iż nie rozwiąże to problemów dla niszowych bibliotek…
Czas to pieniądz…
W kontekście kompilacji do kodu natywnego to powiedzenie można rozumieć wielorako.
Kod natywny praktycznie się nie musi rozgrzewać, inicjalizacja aplikacji to dosłownie milisekundy. W zasadzie jest od razu gotowy do użycia. jeżeli przewaga naszego produktu polega na szybszym uruchamianiu, skalowaniu, to kompilacja do kodu natywnego ma sens. Zatem widziałbym sens takiego rozwiązania w serverless, gdzie czas inicjalizacji ma znaczenie, lub narzędziach uruchamianych na desktopach (mvnd, czyli maven na sterydach – polecam). Sensowne byłoby też wykorzystanie programu natywnego na sprzęcie o małych dostępnych zasobach – aplikacja natywna minimalizuje zużycie pamięci oraz wielkość aplikacji. Można by również rozważyć w krótkich Jobach uruchamianych raz dziennie, aby minimalizować ilość używanej pamięci, lub aplikacjach o małym natężeniu ruchu, które JVM nie zdąży rozgrzać w czasie między kolejnymi deployami.
W przypadku długożyjących aplikacji wybór Native Image jest nieuzasadniony, (o ile nie błedny). Kompilacja AOT co prawda działa szybciej na początku, to ostatecznie jest wolniejsza, gdyż nie ma dostępu do informacji o działaniu aplikacji w runtime (chociaż w GraalVM Enterprise Edition wydaje się, iż można dostarczyć jakieś dane z działania aplikacji, jednak wciąż nie może dorównać to JITowi).
Należy jednak brać również pod uwagę dodatkowy czas kompilacji do kodu natywnego – zawsze jest to przynajmniej kilka minut. Można optymistycznie założyć, iż kompilacja będzie wykonywana w ramach CI/CD (czyli nie przez dewelopera), ale jeżeli coś się wysypie, to i tak deweloper musi to poprawić. Dodatkowo jeszcze wspomniany czas na testy regresyjne w celu poinformowania agenta GraalVMa o klasach rejestrowanych do użycia refleksji.
Liczby
Z własnego doświadczenia mogę podzielić się pewnymi danymi. Nie są to jednak dokładne benchmarki wykonywane na czystym środowisku. Jednak dają pogląd na rząd wielkości i przyspieszenie/spowolnienie względem trybu native/jvm. Ich udostępnienie ma dać jedynie ogólny pogląd, a wszelka zbieżność imion i nazwisk – przypadkowa
Jeszcze jeden szczegół, który może mieć wpływ na wyniki – operacje te wykorzystują Javascript. Zarówno w trybie natywnym, jak i JVMowym GraalVMowy Javascript posiada swój JIT, gdy kod Javy podlega JITowi wyłącznie w trybie JVM. Może to spowodować, iż różnice czasów mogą być mniejsze (amplituda), aniżeli gdybyśmy wykorzystywali tylko kod napisany w Javie.
Operacja nr 1 | 1 | 2 | 3 | 4 |
JVM | 2m 08s | 1m 20s | 56s | 51s |
native | 1m 40s | 1m 32s | 1m 22s | 1m 11s |
Operacja nr 2 | 1 | 2 | 3 | 4 | (…) | min |
JVM | 15s | 3.5s | 2.3s | 2s | 0,7s | |
native | 7s | 3s | 2s | 1.6s | 1,1s |
Min oznacza minimalny otrzymany czas wykonania operacji.
Widać zatem pewną logiczną zależność – pierwsze operacje są wykonywane szybciej w trybie natywnym, ale już od 2 lub 3 czasy są porównywalne, a na dłuższą metę tryb JVM jest kilkadziesiąt procent szybszy.
Mogę się jeszcze podzielić jeszcze jednym wynikiem. Otóż operacja nr 1 była wykonywana z różnymi danymi kilka tysięcy razy. W trybie JVM całościowy czas to było 16 minut, gdy w trybie natywnym – 43 minuty.
Podsumowanie
GraalVM Native Image jest bezsprzecznie ciekawą technologią, która w pewnych przypadkach może mieć pozytywny wpływ na produkt – chodzi zarówno o czas inicjalizacji, jak ilość pamięci potrzebnej do działania aplikacji. Trzeba być jednak świadomym dodatkowych kosztów – rosnącego skomplikowania technicznego projektu oraz spowolnienia dewelopowania spowodowanego dłuższym czasem kompilacji. Warto zatem rozważyć, czy tryb natywny jest koniecznie niezbędny w naszym przypadku biznesowym.
Pax et bonum.