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!

 

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *