Jak przyspieszyć nasz kod w Javie? Czyli kilka słów o JMH.

Witam Cię bardzo serdecznie!

Dziś zapraszam na tematy typowo Javowy, wchodzący trochę głębiej z zagadnienia JVMa. Opiszę narzędzie, przy pomocy którego będziemy mogli zmierzyć szybkość wykonywania naszego kodu i dzięki wyciągniętym wnioskom przyspieszyć nasze programy w Javie. 🙂

JMH

Java Microbenchmark Harness, o tym będzie dziś mowa, jest narzędziem do tworzenia benchmarków w Javie, czyli do badania wydajności fragmentów naszej aplikacji. Tak na marginesie, „harness” to po angielsku uprząż. Innymi słowy, zakładamy uprząż na konia (w tym przypadku na Javę) i sprawdzamy jak szybko możemy na nim pojechać. 🙂

JMH zostało napisane przez Aleksey Shipilёv’a, który pracował w Oracle i jest współtwórcą JITa. (Tak, tego JITa, który optymalizuje nasz kod w JVMie). Nie jest to narzędzie pierwszej świeżości, ma już kilka lat, ale wciąż nie jest dobrze znane i raczej rzadko używane przez programistów. Postaram się zatem przekonać Cię, że warto JMH przynajmniej poznać. 🙂

Po co w ogóle badać wydajność?

Zanim odpowiemy na pytanie JAK? zadajmy sobie pytanie PO CO? W zasadzie powodów jest kilka:

  1. Porównanie dwóch różnych implementacji tej samej funkcjonalności. Jeżeli mamy kilka bibliotek, służących do tego samego, możemy zmierzyć, która z nich działa najszybciej. Weźmy na przykład problem parsowania stringa do JSONa. Bibliotek, które to robią w Javie jest wiele. Ale która z nich jest lepsza do małych plików, a która do dużych? Która szybciej będzie parsować bardziej płaskie struktury, a które te wielokrotnie zagnieżdżone?
    Warto zmierzyć wydajność dostępnych bibliotek i wybrać taką, która najlepiej się sprawdzi w naszym konkretnym przypadku.
  2. Sprawdzenie czy nasz algorytm nie trwa zbyt długo. Warto zmierzyć ile czasu trwa wykonywanie kluczowych fragmentów naszej aplikacji. Może się okazać, że operacja pozornie szybka, zajmuje dużo więcej czasu niż się spodziewamy i spowalnia nasz program. JHM pozwala nam namierzyć takie miejsca, a następnie wyeliminować.
  3. Kiedy nie starcza nam zasobów. Tu mogą pojawić się głosy sprzeciwu – przecież dzisiaj sprzęt jest tani. Jeśli nasz program działa zbyt wolno to bardziej opłaca jest dorzucić trochę sprzętu, kilkadziesiąt serwerów i problem rozwiązany.
    Pozornie tak jest, jednak mimo wszystko nie zawsze jest możliwość powiększenia naszej infrastruktury. Niekoniecznie też jest to tanie. Jeżeli rozważymy środowisko chmur obliczeniowych okaże się, że maszyny wirtualne są relatywnie jedną z droższych usług.
    Co więcej, wchodzimy teraz intensywnie w świat Serverless. Przykładowo, usługa AWS Lambda, która uruchamia naszą funkcję w nieznanym nam środowisku, może wykonywać się maksymalnie 5 minut. Szkoda by było, gdyby nasz kod nie zdążył w tym czasie wykonać się w całości…
  4. Jako broń w „świętej wojnie”. 🙂 Jeżeli kiedykolwiek prowadziliście dyskusje nad wyższością Javy nad innymi językami programowania, to JMH jest w stanie dostarczyć Wam twardych dowodów na poparcie Waszej tezy. Możecie zmierzyć czas trwania jakiejś operacji i wykazać jaka to Java jest szybka. 😉
  5. Takie liczby świetnie też działają marketingowo. Bo przecież przed refactorem nasz kod uruchamiał się 5x czasu, a teraz kończy się w mniej niż x! Dzięki temu nasza aplikacja działa szybciej i możemy pozyskać większą ilość klientów.

Instalacja JMH

Najprościej zainstalować przy pomocy Mavena:

org.openjdk.jmh
jmh-core
1.19


org.openjdk.jmh
jmh-generator-annprocess
1.19

Nasz pierwszy benchmark

Zasadniczo pisanie banchmarków w JMH jest proste i przypomina trochę pisanie testów. Musimy jedynie oznaczyć naszą metodę adnotacją @Benchmark. Przykładowo:

@Benchmark
public int benchmark() throws InterruptedException {
Thread.sleep(1000);
return 0;
}

i to wszystko!

Jak zatem uruchomić nasz benchamark?

Jest kilka sposobów, ja polecam zainstalować odpowiednią wtyczkę do naszego IDE, albo napisać krótką metodę main:

public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}

Gdy JMH zakończy pomiary zobaczymy komunikat z podsumowaniem i wynikami naszych badań.

Benchmark krok po kroku

W pierwszej fazie, JMH przeprowadza rozgrzewkę i wykonuje nasz test kilka razy, ale nie mierzy jeszcze wyników. Dzieje się tak dlatego, gdyż JVM musi nabrać troszeczkę rozpędu. W drugiej kolejności, następuje faza pomiaru, podczas której nasz kod jest uruchamiany wielokrotnie i mierzony jest czas każdego wykonania. Na końcu tworzony jest nowy proces, w którym cały schemat jest powtarzany od początku.

Dlaczego tworzymy nowy proces? Wyobraźmy sobie, że testujemy dwie różne implementacje tego samego interfejsu. Dopóki tylko jedna z nich jest używana, JIT zastąpi wołanie interfejsu poprzez bezpośrednie wywoływanie metody go implementującej. Kiedy jednak natrafi na drugą implementację nie będzie mógł tak robić. W rezultacie pomiar drugiej implementacji będzie wolniejszy. Żeby pozbyć się tego „efektu pamięci” tworzymy właśnie nowy proces dla każdego nowego pomiaru.

Ilość procesów oraz fazy „warm-up” i „measurement” możemy kontrolować przy pomocy odpowiednich adnotacji:

@Benchmark
@Fork(value = 1) // test powtarzamy tylko dla jednego procesu
@Warmup(iterations = 1, time = 2) // jedna iteracja rozgrzewkowa trwająca 2 sekundy
@Measurement(iterations = 2, time = 2) // dwie iteracje pomiarowe, każda trwające 2 sekundy
public int benchmark() throws InterruptedException {
Math.log(x);
return 0;
}

Test modes

Ustawiając tryb testowania możemy mierzyć:

  • Throughput – ilość wykonanych operacji w jednostce czasu
  • Average time – średni czas wykonania operacji
  • Sample Time – czas wykonania operacji; w tym miejscu uwzględniamy statystyki, percentyle itp
  • SingleShot Time – nasza operacja jest uruchamiana raz i mierzony jest czas tego wykonania

Jednostki czasowe

Nasz wynik możemy wyrazić w następujących jednostkach czasowych:

  • NANOSECONDS
  • MICROSECONDS
  • MILLISECONDS
  • SECONDS
  • MINUTES
  • HOURS
  • DAYS

State

Stan jest dość ważnym konceptem w JMH. Zazwyczaj nasza funkcja, którą mierzymy operuje na jakichś danych. Często też te dane w jakiś sposób trzeba przygotować. Nie chcemy jednak mierzyć czasu przygotowywania tych danych. W takim właśnie przypadku używamy klasy State.

Przykład poniżej:

public class MyBenchmark {

@State(Scope.Thread)
public static class MyState {
public int a = 1, b = 2;
public int sum;

@Setup
public void init() {
sum = 0;
}
}

@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MINUTES)
public void testMethod(MyState state) {
state.sum = state.a + state.b;
}
}

Klasa MyState zawiera zmienne „a”, „b” oraz „sum”. Instancja tej klasy jest podawana jako argument do testowanej funkcji.

Tak samo jak np w JUnit, klasa State może zawierać metody @Setup i @TearDown.

Nic nie stoi na przeszkodzie, żeby cała klasa, w której znajdują się testowane metody była oznaczona @State:

@State(Scope.Thread)
public class MyBenchmark {
public int a = 1;
public int b = 2;

@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MINUTES)
public void testMethod() {
int sum = a + b;
}
}

Jak pisać dobre benchmarki i na jakie pułapki uważać?

Zasadniczo są 3 pułapki  na które należy uważać podczas pisania benchmarków. Wynikają one ze specyfiki pracy JITa, który poprzez rozmaite optymalizacje stara się, żeby nasz kod działał jak najszybciej. Nie zawsze jest to zgodne z naszym celem, czyli faktycznym mierzeniem wydajności. Już tłumaczę o co chodzi.

1. Dead Code Elimination

Prosta sprawa. Jeżli JIT zauważy, że nasza metoda nie ma żadnego wpływu na otoczenie, może uznać, że nie ma sensu jej wykonywać i po prostu tego nie robić. Przykładowy, źle napisany benchmark mógłby wyglądać tak:

@Benchmark
public void benchmarkBad() {
double a = Math.log(x);
double b = Math.log(y);
double c = a + b;
}

Wystarczy jednak, że nasza funkcja zwróci pewną wartość i JIT już nie będzie miał argumentów, żeby ją zoptymalizować:

@Benchmark
public double benchmarkOK() {
double a = Math.log(x);
double b = Math.log(y);
double c = a + b;
return c;
}

Co w przypadku, kiedy chcielibyśmy wykonać kilka operacji w jednej funkcji? Nie możemy zwrócić przecież kilku wartości… W takim przypadku JMH udostępnia obiekt czarnej dziury, który zapobiega optymalizacjom za strony JITa:

@Benchmark
public void benchmarkOK(Blackhole bh) {
double a = Math.log(x);
double b = Math.log(y);
double c = a + b;
bh.consume(c);
bh.consume(a - b);
}


Dlatego pamiętajmy, że nasza funkcja musi zawsze zwracać wartość, albo wrzucać ją do czarnej dziury. 🙂

2. Optymalizacja pętli

JIT jest świetny w optymalizacji pętli. Zarówno FOR jak i WHILE. Wykonanie obu poniższych fragmentów kodu zajmie tyle samo czasu:

@Benchmark
public int benchmark() {
int sum = 0;
for (int i = 0; i < 100000; i++) {
sum++;
}
return sum;
}
@Benchmark
public int benchmark2() {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum++;
}
return sum;
}

Dlatego pamiętajmy o drugiej zasadzie, żeby unikać pętli w benchmarkach.

3. Constant folding

Co się kryje pod tym tajemniczym hasłem? Zobaczmy to na przykładzie:

@State(Scope.Thread)
public class Benchmark {

private double x = Math.PI;

@Benchmark
public double benchmarkBad() {
return Math.sin(Math.PI);
}

@Benchmark
public double benchmarkOK() {
return Math.sin(x);
}
}

W pierwszym przypadku JIT zobaczy, że funcja operuje na stałych. Nie ma więc sensu liczyć ciągle tego samego, kod zostanie więc skrócony. W drugim przypadku zmienna x pochodzi spoza funkcji, z klasy State. Dlatego JIT nie będzie optymalizować tej funkcji.

Podsumowując, zmienne, na których działa benchmark powinny zawsze pochodzić z klasy State.

Większy przykład z życia

Rozpatrzmy zatem jakiś konkretny przykład. Załóżmy, że chcemy parsować stringi na typy numeryczne (long i double). Porównamy więc metodę natywną z Javy, z klasy Long – Long.valueOf() oraz metodą Longs.tryParse() z biblioteki Google Guava. Która z nich okaże się szybsza?

Żeby test był kompletny zakładamy, że string nie musi być poprawną liczbą. Dlatego używamy metody Longs.tryParse(), która w przypadku błędu zwróci nulla. Z kolei Long.valueOf() otoczymy blokiem try-catch. Poniżej kod całego testu:

@State(Scope.Thread)
public class StringParsing {

private String longOK = "123456";
private String longBad = "123#45";
private String doubleOK = "123.4567";
private String doubleBad = "123#456";

@Benchmark
public Long JDKlongOK() {
return Long.valueOf(longOK);
}

@Benchmark
public Long JDKlongBad() {
try {
return Long.valueOf(longBad);
} catch (NumberFormatException e) {
return null;
}
}

@Benchmark
public Long GuavalongOK() {
return Longs.tryParse(longOK);
}

@Benchmark
public Long GuavaLongBad() {
return Longs.tryParse(longBad);
}

@Benchmark
public Double JDKdoubleOK() {
return Double.valueOf(doubleOK);
}

@Benchmark
public Double JDKdoubleBad() {
try {
return Double.valueOf(doubleBad);
} catch (NumberFormatException e) {
return null;
}
}

@Benchmark
public Double GuavaDoubleOK() {
return Doubles.tryParse(doubleOK);
}

@Benchmark
public Double GuavaDoubleBad() {
return Doubles.tryParse(doubleBad);
}
}

Żeby być super poprawnym powinienem w metodach „JDKlongOK” oraz „JDKdoubleOK” również użyć blocku try-catch, jednak w tym przypadku jego użycie nie wpływa na wydajność kodu.

Wyniki

Wykananie powyższych benchmarków zajęło mojemu średniej klasy laptopowi niecałą godzinę. Poniżej tabelka z wynikami parsowania 4 stringów zarówno przy pomocy funkcji z JDK jak i z Guavy:

JDK Guava
long OK („123456”) 16 971 583 ops/s 14 266 048 ops/s
long Bad („123#45”) 343 430 ops/s 24 741 318 ops/s
double OK („123.4567”) 11 405 944 ops/s 1 498 003 ops/s
double Bad („123#456”) 276 102 ops/s 1 747 459 ops/s

Wyniki są ciekawe i wcale nie są jednoznaczne.

Dla poprawnego longa obie implementacje działają podobnie. JDK jest minimalnie szybsze, ale nie jest to duża różnica. Dla niepoprawnego longa zdecydowanym zwycięzcą jest Guava. W sumie trudno się dziwić – rzucanie wyjątków jest kosztowne, stąd tak słaby wynik dla JDK.

Możemy też zauważyć, jak szybko Guava poradziła sobie z niepoprawnym stringiem. Dlaczego? Jeśli zajrzymy w kod źródłowy zobaczymy, że funkcja analizuje kolejne znaki w stringu. Przerywa działanie, gdy napotka na niepoprawny symbol. Dlatego jeślibym miał parsować jedynie longi, zdecydowałbym się na Google Guavę.

Źródło: wykop.pl

Inaczej sytuacja wygląda w przypadku liczb zmiennoprzecinkowych. W JDK jest duża różnica między czasem parsowania poprawnego stringa i niepoprawnego. Z kolei Google Guava rozczarowuje. W obu przypadkach funkcja jest raczej wolna. Dlaczego? Okazuje się, że Google Guava używa wyrażenia regularnego do sprawdzenia poprawności parsowanego stringa. Używanie RegExpów jest kosztowne i stąd słaby wynik benchmarku.

W przypadku doubli, na korzyść Guavy przemawia taki sam czas parsowania niezależnie od poprawności stringa. Dlatego wybór między JDK a Guavą dla parsowania liczb zmiennoprzecinkowych uzależniłbym od ilości stringów niepoprawnych w stosunku do ilości poprawnych, które mamy do przeparsowania.

I to by było na tyle 🙂

Mam nadzieję, że przynajmniej zaintrygowałem Cię tematem wydajności w Javie. Zachęcam, żeby trochę się tym pobawić i nabrać intuicji. Warto być świadomym programistą, który ma choćby podstawowe pojęcie o wydajności swojego kodu.

Tymczasem, do zobaczenia i…

NIECH KOD BĘDZIE Z TOBĄ!

 

3 komentarze

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *