O programování 02 - Efektivita funkcionálního přístupu v Java 8
V prvním díle jsem vysvětlil záměr této série článků a nyní je na řadě první část a to (naivní) porovnání výkonnosti běžného cyklu foreach a funkcionálního přístupu v Javě 8. Toto porovnání jsem vyzkoušel na následující jednoduché úloze: Mám seznam objektů a chci ho zkopírovat do druhého seznamu, přičemž jednotlivé prvky modifikuji. Jinými slovy, provedu iteraci přes prvky vstupního seznamu a do výstupního seznamu vložím změněné hodnoty. Abych celý problém zjednodušil co nejvíce, budu pracovat s ArrayListem Integerů a změna bude spočívat v přičtení jedničky.
Nejdříve deklaruji vstupní a výstupní seznamy:
List<Integer> inputList = new ArrayList<>();
List<Integer> outputList = new ArrayList<>();
Poté do vstupního seznamu inputList vložím hodnoty 1 až 10 000, tuto část kódu neuvádím, pro vlastní účely článku není podstatná.
Přímočará implementace uvedené úlohy s využitím běžného cyklu foreach vypadá takto:
for (Integer value : inputList) {
outputList.add(++value);
}
Pro seznam 10 000 Integerů trvá takto zapsaný cyklus cca 5 milisekund a pro milion prvků zhruba 100 milisekund.
NetBeansy mi radí, abych cyklus přepsal s využitím streamu následujícím způsobem:
inputList.stream().forEach((value) -> {
outputList.add(++value);
});
Takto přepsaný cyklus trvá pro 10 000 Integerů cca 120 milisekund a pro milion 150 milisekund. Což vede k závěru, že časová výkonnost streamu je sice "skoro" konstantní (logaritmická složitost je asi přesnější), ale pro běžné případy je o dva řády horší než běžný foreach.
Takový výsledek jsem opravdu nečekal a tak jsem hledal na internetu, zda jsem něco nepřehlédl. Našel jsem však více lidí s podobným výsledkem a zajímavé byly reakce na toto pozorování. Jako první argument se uvádělo, že takhle (zjistit Date před a po a odečíst) se mikrobenchmarky nedělají. Druhý argument byl, že JVM potřebuje nějaký prostor na warm up a teprve jako třetí v pořadí následovalo vysvětlení, proč se tak děje. Vysvětlení celkem chápu, jde třeba i o to, že obyčejný (a jednoúčelový) for cyklus se optimalizuje nějakých 15 let, kdežto stream ani ne dva roky a že vše je o přístupu k objektům a jejich konstrukci. A teprve po těchto třech argumentech následuje konstatování, že to tak prostě je.
Pod vlivem diskusí jsem se rozhodl implementovat warm up před vlastním měřením, cyklus spustit opakovaně a počítat průměrnou hodnotu, minima a maxima. V takovém případě je výsledek mnohem lepší, po warm upu, a to stačí jedno až dvě spuštění, je implementace využívající streamy postupně čím dál rychlejší, takže celý výkonnostní problém je pouze u prvního spuštění.
Pokud mám popsat implementaci detailněji, tak se cyklus pro N prvkové pole spustí WARMUP krát a teprve poté probíhá vlastní měření výkonnosti. A to tak, že se cyklus spustí 1000x a z tohoto tisíce průběhů se vypíše minimum, maximum a průměr na jeden cyklus. Výsledky v milisekundách uvádí následující tabulky.
WARMUP = 10
N | 100 | 1000 | 10000 | 100000 |
---|---|---|---|---|
standard forEach | avg: 0,017 min: 0 max: 3 | avg 0,086 min: 0 max: 9 | avg: 0,223 min: 0 max: 4 | avg 2,276 min: 1 max: 16 |
stream().forEach() | avg: 0,018 min: 0 max: 4 | avg: 0,049 min: 0 max: 2 | avg: 0,254 min: 0 max: 10 | avg: 1,819 min: 1 max: 23 |
WARMUP = 100
N | 100 | 1000 | 10000 | 100000 |
---|---|---|---|---|
standard forEach | avg: 0,009 min: 0 max: 1 | avg: 0,064 min: 0 max: 10 | avg: 0,199 min: 0 max: 4 | avg: 2,023 min: 1 max: 16 |
stream().forEach() | avg: 0,006 min: 0 max: 1 | avg: 0,049 min: 0 max: 5 | avg: 0,190 min: 0 max: 4 | avg: 1,700 min: 0 max: 11 |
Dále mě zajímalo, jaký je časový průběh jednotlivých iterací v rámci warm upu, tedy kolik času zabere prvních n-průběhů cyklem. Výsledky jsou opět uvedeny v tabulce:
N = 10 000 | foreach | stream | N = 100 000 | foreach | stream | |
---|---|---|---|---|---|---|
0 | 7 | 109 | 0 | 38 | 131 | |
1 | 3 | 1 | 1 | 11 | 8 | |
2 | 4 | 1 | 2 | 6 | 3 | |
3 | 3 | 1 | 3 | 4 | 3 | |
4 | 3 | 1 | 4 | 5 | 2 | |
5 | 3 | 1 | 5 | 4 | 3 | |
6 | 2 | 1 | 6 | 4 | 2 | |
7 | 2 | 1 | 7 | 4 | 3 | |
8 | 1 | 0 | 8 | 4 | 3 | |
9 | 1 | 0 | 9 | 16 | 2 | |
10 | 5 | 1 | 10 | 2 | 5 | |
11 | 2 | 0 | 11 | 1 | 3 | |
12 | 1 | 0 | 12 | 2 | 2 | |
13 | 1 | 0 | 13 | 2 | 2 | |
14 | 1 | 0 | 14 | 2 | 2 | |
15 | 1 | 0 | 15 | 2 | 2 | |
16 | 1 | 0 | 16 | 2 | 2 | |
17 | 1 | 1 | 17 | 2 | 2 | |
18 | 1 | 0 | 18 | 2 | 2 | |
19 | 2 | 1 | 19 | 2 | 2 | |
20 | 1 | 0 | 20 | 10 | 8 | |
21 | 1 | 1 | 21 | 1 | 2 | |
22 | 1 | 1 | 22 | 2 | 2 | |
23 | 1 | 0 | 23 | 2 | 2 | |
24 | 1 | 0 | 24 | 2 | 2 | |
25 | 0 | 0 | 25 | 2 | 2 |
Takže, znovu opakuji a vyvracím prvotní dojem. Po warm upu je stream postupně čím dál rychlejší a jeho časový průběh je stabilnější, jen to první spuštění je řádově horší.
Během výše uvedených pokusů jsem si ověřil, že takhle se mikro benchmarky opravdu dělat nedají, časy se při opětovném spuštění lišily od desítek procent až ke dvojnásobku.
V dalších dílech budu hledat způsob, jak udělat mikrobenchmarky spolehlivější, ale ještě předtím se podívám na hlavní argument pro funkcionální zápis a tím je přehlednost kódu.
- O programování 01 - Úvod
- O programování 03 - Přehlednost funkcionálního zápisu v Java 8
- O programování 04 - Java Microbenchmark Harness
- O programování 05 - Další varianty Javovské implementace foreach
- O programování 06 - Návrhové vzory - síla i slabina Javy
- O programování 07 - Funkce jako argument aneb od factory k lambdě v Javě
- O programování 08 - Jak v Javě předat metodu, která bude mít různé parametry
- O programování 09 - Pokročilé využití streamů a funkcionálního přístupu v Java 8
- O programování 10 - Java a inspirace v Ruby
- O programování 11 - Minimalistické programy a Java
- O programování 12 - Paralelní implementace násobení matic v Java - úvod