Pattern matching w Javie – niskopoziomowo

Pierwsza propozycja Pattern Matchingu dla instrukcji switch w Javie pojawiła się w JDK 17. Po kilku iteracjach usprawnień w najświeższym LTS’ie – JDK 21 (19 września 2023)- wyszła w wersji ostatecznej. Warto zatem spojrzeć pod maskę jak to wygląda pod maską i zweryfikować pod kątem wydajności.

Czym jest pattern matching?

Zasadniczo ten blog nie dostarcza informacji o definicjach, a o niskopoziomowych działaniach, zatem po opis Pattern Matchingu zapraszam choćby tu. Jedno co mogę napisać, to że to znany koncept w wielu językach programowania od wielu lat.

W Javie pierwsza wersja preview Pattern Matchingu dla instrukcji pojawiła się w JDK 17. O ile w międzyczasie została zmieniona składnia tego feature, ostatecznie w JDK 21 przykład jego wykorzystania może wyglądać tak:

private Object iterator;

public void patternMatching(Blackhole bh) {
    switch (iterator) {
        case Boolean b when b -> {
            bh.consume(3);
            iterator = false;
        }
        case String s -> {
            bh.consume(s);
        }
        case Boolean b -> {
            bh.consume(5);
            iterator = true;
        }
        default -> throw new IllegalStateException("Unexpected value: " + iterator);
    }
}

W przeciwieństwie do standardowego switcha na enumie lub liczbach, w Pattern Matchingu ważna jest kolejność. W skrajnym przypadku gdy będzie wiele pasujących wyrażeń, zostanie dopasowane to prawdziwe wyrażenie, które występuje jako pierwsze. Wszystkie te dylematy są opisane w JEP-441.

Pattern matching – wgląd w bytecode

Jeślibyśmy skompilowali powyższy fragment, a następnie go zdekompilowali do czytelnej formy z użyciem dekompilatora udostępnionego przez Jetbrains w IntelliJ rezultat (nieco okrojony) wyglądałby tak:

public void patternMatching(Blackhole bh) {
    Object var10000 = this.iterator;
    Objects.requireNonNull(var10000);
    Object var2 = var10000;
    byte var3 = 0;

    while(true) {
        switch (((Class)var2).typeSwitch<invokedynamic>(var2, var3)) {
            case 0:
                Boolean b = (Boolean)var2;
                if (!b) {
                    var3 = 1;
                    continue;
                }

                bh.consume(3);
                this.iterator = false;
                break;
            case 1:
                String s = (String)var2;
                bh.consume(s);
                break;
            case 2:
                Boolean b = (Boolean)var2;
                bh.consume(5);
                this.iterator = true;
                break;
            default:
                throw new IllegalStateException("Unexpected value: " + String.valueOf(this.iterator));
        }

        return;
    }
}

Powyższy listing obrazuje działanie dopasowywania wzorca. Dopasowanie bazuje na mechanizmie invokedynamic, który twórcy JDK używają również w lambdach bądź w konkatenacji Stringów. Pokrótce daje to możliwość wprowadzenia usprawnień bez wymogu rekompilacji źródeł, gdyż wykonywany kod jest generowany w runtime przy pierwszym odwołaniu do klasy zawierającej go.

Jak widać dopasowanie odbywa się na podstawie dwóch argumentów – dopasowywanego obiektu oraz pomocniczej zmiennej pozwalającej określić, jakie było poprzednie dopasowanie, które się nie udało. Dopasowywanie odbywa się w pętli – jeśli warunek (guard) nie zostanie spełniony, wartość zmiennej tymczasowej jest zmieniona, a następnie cała pętla jest powtarzana. Dzięki zmianie tej zmiennej trafimy do innego case’a niż poprzednio.

Przy okazji widać również, że w powyższym przypadku tzw. Switch Expression jest tłumaczone na standardowego switcha.

Jednak podglądając ten bytecode można mieć wątpliwości, czy lepiej pod kątem wydajności będzie użyć Pattern Matchingu, czy zwykłego ciągu If-Else’ów. Warto to sprawdzić benchmarkiem JMH. Wszak jeśli mamy do czynienia z pętlą while (true) (czyt. skok bezwarunkowy), to mogą istnieć różnice.

Benchmark

Zasadniczo porównania dotyczą powyższej sytuacji, jednak najpierw w wersji okrojonej do samego Booleana z guardem, Booleana oraz domyślnej ścieżki. Zatem kod testujący wyglądał następująco:

public class PatternMatchingBenchmark {
(...)
    private Object iterator;
    @Benchmark
//    @CompilerControl(CompilerControl.Mode.EXCLUDE)
    public void ifElse(Blackhole bh) {
        Objects.requireNonNull(iterator);
        if (iterator instanceof Boolean b) {
            if  (b) {
                bh.consume(3);
                iterator = false;
            } else {
                bh.consume(5);
                iterator = true;
            }
        }
    }

    @Benchmark
//    @CompilerControl(CompilerControl.Mode.EXCLUDE)
    public void patternMatching(Blackhole bh) {
        switch (iterator) {
            case Boolean b when b -> {
                bh.consume(3);
                iterator = false;
            }
            case Boolean b -> {
                bh.consume(5);
                iterator = true;
            }
            default -> throw new IllegalStateException("Unexpected value: " + iterator);
        }
    }
}

W pierwszej wersji, gdzie sprawdzałem tylko tryb interpretowany wyniki były następujące (na MacBooki Pro z 2018 roku z procesorem 2,2 GHz 6-Core Intel Core i7). Dystrybucja to OpenJDK (build Oracle’owski – build 21+35-2513):

Benchmark                                  Mode  Cnt         Score       Error  Units
PatternMatchingBenchmark.ifElse           thrpt   10  15506516.529 ± 54518.117  ops/s
PatternMatchingBenchmark.patternMatching  thrpt   10   4125353.810 ± 24199.732  ops/s

Od razu widać, że PatternMatching ma pewien dodatkowy narzut sprawiający, że w trybie interpretowanym działa prawie 4 razy wolniej niż stary if. Jaka jednak jest wydajność dopasowywania wzorca z użyciem JITa (C2)?

Benchmark                                  Mode  Cnt          Score         Error  Units
PatternMatchingBenchmark.ifElse           thrpt   10  555514980.934 ± 2758608.077  ops/s
PatternMatchingBenchmark.patternMatching  thrpt   10  554135386.372 ± 2915232.272  ops/s

Jak widać tym razem ani dodatkowa pętla, ani dodatkowe zmienne nie stanowiły problemu dla kompilatora C2 – rozwijanie pętli i inline’ing to jedne z najprostszych optymalizacji.

Gdzie PatternMatching może być bardziej wydajny?

Wydaje się, że analogicznie do switcha na Enumach pewien zysk mógłby się pojawić, gdybyśmy chcieli wykonać skok bezpośrednio do case odpowiadającego dopasowanej klasie. Względnie łatwiejsze mogłoby być uszeregowanie bloków case według prawdopodobieństwa wystąpienia, żeby skoki do odpowiedniego bloku były najmniejsze, dzięki czemu możliwe, że blok kodu będzie w cache’u. Chociaż, możliwe, że bloki if-else’ow również mogą taka optymalizację uczynić po dokładniejszej analizie kodu. Aczkolwiek to optymalizacje w skali tak małej, że nie są warte pracy programistów.

Jest jedna różnica, gdzie ciąg if-else’ow mógłby być mniej wydajny – jeśli robilibyśmy switcha na polu klasy. W przypadku if-else’ow za każdym razem zczytywalibysmy wartość ze sterty, a w switchu wartość byłaby zczytana ze sterty jednokrotnie. Oczywiście przypisanie wartości pola do zmiennej lokalnej wystarczyłoby, aby tę przewagę switcha zniwelować…

Podsumowanie

Patttern Matching jest mechanizmem, który ma za zadanie zwiększyć czytelność, zmniejszyć szum informacyjny wokół kodu, a także zwiększyć ekspresywność języka.

Potencjalne zyski z korzystania z tego mechanizmu o ile byłyby możliwe, to są raczej wątpliwe – nie taki był cel wprowadzenia tego mechanizmu.

Jeśli szukacie dobrego dokładnego niskopoziomowego opisu pattern matchingu zapraszam na bloga Natalii Dziubenko.

Źródła wykorzystane przy pisaniu tego wpisu znajdują się na moim Githubie – dokładnie w podprojekcie pattern-matching.

Pax & Bonum

Średnia ocen. 0 / 5. Liczba głosów. 0

Brak ocen.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *