Trochę o mierzeniu kodu z jmh

jmh jest leciwym narzędziem. Jakoś w marcu stuknie mu już 4 lata. Ostatnio zwróciłem na nie uwagę przy okazji rozwiązywania problemu „czyj kod powinniśmy użyć, nasz czy hindusów”. Wiadomo, że do rozwiązywania tego typu problemów najlepiej sprawdzają się jakieś w miarę niezależne od nas narzędzia. Jako że pisanie mikro benchmarków jest sztuką, narzędzie powinno ładnie zamykać skomplikowane elementy w przyzwoicie wyglądające API. Powinno też umieć wyliczyć jakieś statystyki w rozsądny sposób.

No właśnie statystyki. W przypadku większość benchmarków samo wyznaczenie wartości mierzonej jest niewystarczające. Co prawda na poziomie newsa prasowego możemy przeboleć brak informacji o dokładności pomiaru, ale już na poziomie naszego produktu powinniśmy mieć pełnię wiedzy o pomiarach. Dlatego właśnie jmh zdaje się być dobrym narzędziem.

Krótka powtórka z Pracowni Fizycznej I

Na studiach miałem kilka przedmiotów laboratoryjnych. Zaczynało się to jakoś na trzecim semestrze od przedmiotu o nazwie Pracownia Wstępna, gdzie następował odsiew na podstawie umiejętności zrozumienia tekstu polecenia. Następnie były wa semestry zajęć Pracownia Fizyczna, a na koniec była jeszcze Pracownia Elektroniczna. Te ostatnie zajęcia nie były obowiązkowe. Elementem łączącym te zajęcia, poza osobami prowadzących, był wymóg zrozumienia jak należy przeprowadzać badania.

Co mierzymy?

Jest to pierwsze pytanie, na które musimy sobie odpowiedzieć. Jeżeli nie jesteśmy, w stanie określić, co chcemy zmierzyć, to przystępowanie do pomiarów nie ma sensu. Co więcej, należy określić bardziej abstrakcyjny cel pomiaru. Przykładowo, jeżeli mierzę wydajność mojego kodu, to muszę powiedzieć, wobec czego ta wydajność jest mierzona. Wobec innej wersji kodu? W kontekście specyficznych danych? Czy też jest to pomiar wstępny mający za zadanie określić z wielkościami, jakiego rzędu mamy do czynienia?

Jak mierzymy?

Drugie pytanie dotyczy wybranej metody pomiaru. Zazwyczaj daną wielkość można zmierzyć na wiele sposobów. Zapewne spotkaliście się z historią jak, to Ernest Rutherford egzaminował młodego Nilsa Bohra. Podobnie ma się sprawa z każdym pomiarem. Jeżeli chcę zmierzyć wydajność jakiegoś kodu, to muszę określić, w jaki sposób będę ją mierzyć. Czy mierzę czas pojedynczej operacji? Czy też zliczam ilość operacji w przedziale czasu? Czy pomiar prowadzę w środowisku kontrolowanym, czy swobodnym? Jak duża jest próba? W końcu, co ważne w środowisku JVM (i każdym innym z JIT), jak wygląda rozgrzewka maszyny?

Jakie są wady i zalety wybranej metody pomiarowej?

Jak już wybiorę odpowiednią metodę, to należy uzasadnić ten wybór. Przykładowo, mam fragment kodu, który będzie często wykorzystywany na produkcji. Wybrałem metodę pomiaru liczby operacji w ciągu sekundy z rozgrzewką. Pomiar prowadzę w środowisku izolowanym (fizyczna maszyna przeznaczona do tego typu testów). Zaletą tej metody duża precyzja wynikająca z izolacji. Jako że wykonam najpierw rozgrzewkę, to JIT dokona odpowiednich kompilacji. W efekcie otrzymam pomiar precyzyjny oraz powtarzalny (obarczony małym błędem). Jednak metoda ta nie pozwala nam na określenie, czy w środowisku produkcyjnym wydajność będzie podobna. Wynika to z izolacji testów i tym samym braku możliwości odtworzenia warunków produkcyjnych. Czy jest to poważna wada? Zapewne tak, jeżeli przyjmiemy, że od wyników testów zależy wdrożenie naszego kodu. Czy kod spełnia założone parametry?

Jak określić niedokładność metody i błąd pomiaru?

Z wadami wiąże się jeszcze jeden temat. O ile sama identyfikacja wad może być prosta, to już określenie, w jakim stopniu wpływają one na wyniki, jest trudniejsze. Po pierwsze musimy określić, jak dalece niedokładną metodę stosujemy. Zazwyczaj oznacza, to określenie granulacji mierzonej wartości. Liczba operacji na sekundę jest świetnym przykładem. Wiemy jakiej metryki używamy i jaka jest minimalna mierzalna wartość. Potrafimy też wskazać jakie czynniki wpływają na sam pomiar np. dokładność System.nanoTime. Uzbrojeni w taką wiedzę możemy wyliczyć błąd pojedynczego pomiaru albo odchylenie standardoweW.
Na koniec czeka nas jeszcze jedno drobne zadanie. Musimy zweryfikować, czy sposób pomiaru, nie wpłynął na wartość mierzoną. Następnie musimy uwzględnić, to w naszym rachunku błędów.

Jak już przebrniemy przez powyższe zagadnienia, to możemy przystąpić do pomiarów.

Rola jmh

Gdzie można umieścić jmh? Narzędzie to ułatwia nam tworzenie benchmarków, ponieważ pozwala w łatwy sposób zdefiniować sam pomiar. Co więcej, na zakończenie wyliczy za nas odpowiednie wartości związane z błędami pomiarów. Tym samym nie musimy samodzielnie rzeźbić kodu do matematyki. Dostarcza nam też odpowiednich narzędzi pomiarowych. Zatem nasze zadanie ogranicza się jedynie do określenia co i w jaki sposób chcemy zmierzyć. Oczywiście po naszej stronie jest też odpowiedź na pytanie, czy wybrana metoda ma sens.

Praktyka

Po tym przydługim wstępie teoretycznym przejdźmy do praktyki. W jej ramach porównamy sobie cztery różne implementacje operacji sumowania elementów kolekcji/streamu.

  • Sumowanie z wykorzystaniem pętli for
  • Sumowanie z wykorzystaniem stream
  • Sumowanie z wykorzystaniem parallel stream
  • Sumowanie z wykorzystaniem ForkJoin Framework

Żeby nie było prosto, w naszym kodzie wykorzystamy BigInteger, a następnie porównamy wyniki z kodem zaimplementowanym z użyciem long.

Sam kod wygląda następująco.

Listing 1. Implementacja w oparciu o BigInteger

public class BigIntBenchmark {

    @Benchmark
    public void loopWithSum(Blackhole blackhole) {
        BigInteger sum = new BigInteger("0");

        for (int i = 0; i < MAX; i++) {
            sum = sum.add(new BigInteger(i + ""));
        }
        blackhole.consume(sum);
    }

    @Benchmark
    public void streamWithSum(Blackhole blackhole) {
        blackhole.consume(IntStream.range(0, MAX)
                .mapToObj(i -> new BigInteger(i + ""))
                .reduce(new BigInteger("0"), BigInteger::add));
    }

    @Benchmark
    public void pStreamWithSum(Blackhole blackhole) {
        blackhole.consume(IntStream.range(0, MAX).parallel()
                .mapToObj(i -> new BigInteger(i + ""))
                .reduce(new BigInteger("0"), BigInteger::add));
    }

    @Benchmark
    public void fjWithSum(Blackhole blackhole) {
        ForkJoinPool pool = new ForkJoinPool();
        blackhole.consume(pool.invoke(new BigIntFJT(0, MAX)));
    }

}


class BigIntFJT extends RecursiveTask<BigInteger> {

    private final int start;
    private final int end;
    private final int MAX = 1_000;


    public BigIntFJT(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected BigInteger compute() {
        if (end - start <= MAX) {
            BigInteger sum = new BigInteger("0");

            for (int i = start; i < end; i++) {
                sum = sum.add(new BigInteger(i + ""));
            }

            return sum;
        }
        BigIntFJT fjt1 = new BigIntFJT(start, start + ((end - start) / 2));
        BigIntFJT fjt2 = new BigIntFJT(start + ((end - start) / 2), end);

        fjt1.fork();
        fjt2.fork();
        return fjt1.join().add(fjt2.join());
    }
}

To samo w przypadku użycia long wygląda następująco:

Listing 2. Implementacja w oparciu o long

public class LongBenchmark {

    @Benchmark
    public void loopWithSum(Blackhole blackhole) {
        long sum = 0L;

        for (int i = 0; i < MAX; i++) {
            sum += i;
        }
        blackhole.consume(sum);
    }

    @Benchmark
    public void streamWithSum(Blackhole blackhole) {
        blackhole.consume(LongStream.range(0, MAX).sum());
    }

    @Benchmark
    public void pStreamWithSum(Blackhole blackhole) {
        blackhole.consume(LongStream.range(0, MAX).parallel().sum());
    }

    @Benchmark
    public void fjWithSum(Blackhole blackhole) {
        ForkJoinPool pool = new ForkJoinPool();
        blackhole.consume(pool.invoke(new BigIntFJT(0, MAX)));
    }
}


class LongFJT extends RecursiveTask<Long> {

    private final int start;
    private final int end;
    private final int MAX = 1_000;


    public LongFJT(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= MAX) {
            long sum = 0L;

            for (int i = 0; i < MAX; i++) {
                sum += i;
            }

            return sum;
        }
        LongFJT fjt1 = new LongFJT(start, start + ((end - start) / 2));
        LongFJT fjt2 = new LongFJT(start + ((end - start) / 2), end);

        fjt1.fork();
        fjt2.fork();
        return fjt1.join() + fjt2.join();
    }
}

Jednym odstępstwem od normy jest wykorzystanie Long w przypadku ForkJoin, bo to typ generyczny.

Nasz benchmark jest już gotowy. Możemy go skompilować za pomocą mavena i uruchomić. Będzie działać w domyślnych ustawieniach. Warto jednak rzucić okiem w bebechy naszego kodu.

@Benchmark i Blackhole

Adnotacja @Benchmark wskazuje na metody, które są mierzone. Dla nich zostanie wygenerowane odpowiednie środowisko i wykonane pomiary. Warto zwrócić uwagę, na fakt, że w trakcie kompilacji jmh generuje dodatkowe klasy, które są właściwym pomiarem. W nich właśnie dzieje się cała pomiarowa magia.

Czym jest Blackhole? Żeby zrozumieć zadanie tej klasy, należy najpierw sięgnąć w głąb JVM. Jak uruchamiamy maszynę wirtualną, to jednym z elementów, które wstają praktycznie na samym początku, jest JIT. Narzędzie to śledzi kod pod kątem "gorących metod". Po angielsku hot spots stąd też nazwa maszyny Suna. Jeżeli zidentyfikuje on takie miejsce, to następuje proces kompilacji bytecodu do kodu maszynowego. Dzięki temu kod ten będzie wykonywany bezpośrednio, z pominięciem interpretera. Jednakże JIT potrafi też kilka innych rzeczy. Potrafi optymalizować kod. Przyjrzyjmy się naszej metodzie sumującej kolejne liczby w pętli, ale z lekką modyfikacją:

Listing 3. Metoda loopWithSum

public void loopWithSum(Blackhole blackhole) {
    BigInteger sum = new BigInteger("0");
    
    for (int i = 0; i < MAX; i++) {
        sum = sum.add(new BigInteger(i + ""));
    }
    // blackhole.consume(sum);
}

Co dzieje się ze zmienną sum na koniec działania kodu? Nic się nie dzieje. Znika ona ze stosu i nie jest nigdzie zapisywana. Zatem można pominąć wszystkie jej modyfikacje w kodzie, bo są bezproduktywne. Oczywiście w tym przypadku nie jest tak do końca, bo jednak JIT nie wie, co dzieje się w BigInteger i nie może na pałę dokonać optymalizacji. Jednak w przypadku kodu z long nic nie stoi na przeszkodzie, by po prostu usunąć ciało metody.
Jak temu można zapobiec? Na dwa sposoby. Pierwszy to zwracanie wartości z naszej metody. Osobiście jestem na nie, ponieważ chciałbym tworzyć kod, który jest spójny z testami. Drugi sposób to przekazać rezultat gdzieś w świat. W tym przypadku wrzucić go do czarnej dziury. Takie coś wystarczy, by JIT nie dokonał eksterminacji naszego kodu.

Konfigurowanie pomiarów

Jak już wspomniałem, uruchomienie naszego kodu odbędzie się z wartościami domyślnymi. Każda metoda zostanie uruchomiona dwadzieścia razy w ramach rozgrzewki, następnie dwadzieścia razy w ramach właściwych pomiarów, a cały cykl zostanie powtórzony dziesięć razy. Sam pomiar będzie domyślnie zajmować jedną sekundę. Jak łatwo obliczyć cały test zajmie nam około 53 minut (2 klasy * 10 cykli * 4 metody per klasa w każdym cyklu * 40 pomiarów i rozgrzewek). Trochę dużo...

Konfiguracji możemy dokonać na trzy sposoby. Po pierwsze z linii poleceń możemy ustawić odpowiednie parametry. Uruchomienie naszego pomiaru z przełącznikiem -h wyświetli listę i RTFMW. Jest to niezła metoda, jeżeli chcemy coś na szybko odpalić z jakimiś nietypowymi ustawieniami. Parametry te nadpisują konfigurację w kodzie. Drugą metodą jest wspomniana przed chwilą konfiguracja w kodzie:

Listing 4. Przykładowa konfiguracja w kodzie

@Benchmark
@Warmup(iterations = LOOP, time = TIME, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = LOOP, time = TIME, timeUnit = TimeUnit.MILLISECONDS)
@Fork(LOOP)
public void loopWithSum(Blackhole blackhole) {
    BigInteger sum = new BigInteger("0");

    for (int i = 0; i < MAX; i++) {
        sum = sum.add(new BigInteger(i + ""));
    }
    blackhole.consume(sum);
}

Ten rodzaj konfiguracji sprawdzi się najlepiej, jeżeli chcemy śledzić zmiany. W takim przypadku dodanie konfiguracji do kodu pozwoli nam na zarządzanie zmianami za pomocą na przykład gita. Trzecią metodą jest użycie klasy OptionBuilder. W tym przypadku z poziomu naszego kodu możemy uruchomić pomiar, konstruując odpowiedni zestaw parametrów. Jest to najlepsza opcja, jeżeli chcemy zintegrować jmh z serwerem CI.

Rodzaje pomiarów

jmh domyślnie wykonuje pomiar wydajności wyrażonej w liczbie operacji na sekundę. Tego typu pomiary są najpopularniejsze i sprawdzą się w większości przypadków. Jeżeli jednak chcemy zmierzyć inne wartości, to musimy wykorzystać adnotację @BenchmarkMode z odpowiednim parametrem:

  • Throughput – wydajność ops/s domyślny pomiar.
  • AverageTime – średni czas wykonania operacji. Jest to odwrotność poprzedniego.
  • SampleTime – sampling. Uruchamia test i losowo co pewien czas mierzy czas wykonania metody.
  • SingleShotTime – pomiar pojedynczego wywołania. Bez rozgrzewki.
  • All – wszystkie pomiary, ale w ramach osobnych cykli.

Wybierając odpowiedni tryb, należy zwrócić uwagę na to, co mierzymy. Średni czas wykonania metody raczej jest słabą metryką w przypadku prostych metod jak nasze. Z drugiej strony może być świetnym rozwiązaniem w przypadku metod, które wykonują złożone operacje na dużych zbiorach danych.

Podsumowanie

Czas na podsumowanie. jmh daje nam całkiem duże możliwości, jeśli chodzi o pomiar wydajności. Jest to narzędzie, które odciąża nas w zakresie przygotowania maszyny wirtualnej (rozgrzewka), poprawności pomiaru (mierzy różne rzeczy) oraz statystyki (dostajemy odchylenie jako element wyniku). Warto wdrożyć to narzędzie do naszego arsenału.

Na koniec jeszcze jedna uwaga. Przemyślcie dokładnie co i jak chcecie mierzyć, ponieważ źle zaprojektowany pomiar może prowadzić do bardzo dziwnych wniosków i tym samym problemów na produkcji. Co gorsza, źle wykonany pomiar podważy zaufanie do narzędzia, bo przecież mierniczy się nie myli 😉

Kod dostępny tu

Jedna myśl na temat “Trochę o mierzeniu kodu z jmh

Napisz odpowiedź

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

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax