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!