Préambule
Avec Java 8 a été introduit l'API Stream qui permet de manipuler les collections, filtrer, sommer, faire une moyenne, mais bien sûr simplement boucler le tout sous forme de programmation fonctionnelle. Ce qui simplifie l'écriture du code et permet de se concentrer sur l'algorithme plutôt que sur la gestion des itérateurs et autres.
La question qui peut se poser alors est la suivante, quelle est l'impacte sur les performances de l'utilisation de l'API Stream à la place d'une simple boucle for. Intuitivement on a tendance à penser qu'une simple boucle for sera certainement plus performante, et bien c'est ce que nous allons essayer de voir.
Technos
Pour cet article nous allons utiliser Java 14.
Le test
Pour notre test nous allons utiliser une formule permettant de calculer une approximation de Pi. Cette formule demande un grand nombre d'itérations pour augmenter la précision de la décimal de Pi.
Il s'agit de la formule de Leibniz (voir Wikipedia) :
Comme vous pouvez le voir dans cette formule il faut faire tendre k vers l'infini pour avoir la meilleur précision de Pi. Bien évidemment on ne peut pas faire un calcul avec l'infini, on va donc faire varier k pour avoir plus ou moins de décimal juste de Pi.
Une simple boucle
Nous commençons avec une boucle for pour laquelle le nombre d'itérations déterminera la précision du calcul.
private static void testPiSimpleLoop(int precision) { double pi = 0.0; double some = 0.0; for (int index = 0; index <= precision; index++) { some += Math.pow(-1, index) / (2*index + 1); } pi = 4 * some; System.out.println("Pi value (pecision : "+ precision +") : " + pi); }
Nous allons maintenant faire plusieurs appel à notre méthode afin de voir le résultat du calcul et le temps de traitement.
public static void main(String[] args) { System.out.println("Simple loop"); long start = System.currentTimeMillis(); int prec = 100; while (prec <= 10000000) { testPiSimpleLoop(prec); prec = prec*10; } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); }
Exécutons maintenant notre application.
Simple loop Pi value (pecision : 100) : 3.1315929035585537 Pi value (pecision : 1000) : 3.140592653839794 Pi value (pecision : 10000) : 3.1414926535900345 Pi value (pecision : 100000) : 3.1415826535897198 Pi value (pecision : 1000000) : 3.1415916535897743 Pi value (pecision : 10000000) : 3.1415925535897915 time : 288s
Nous constatons qu'avec 100 itérations nous avons une décimale de Pi qui est juste, puis en multipliant par 10 (donc 1000 itérations) nous avons deux décimales de Pi de juste et ainsi de suite.
Le temps de calcul pour 6 augmentations de précisions est de 288 seconds et il augmente de manière exponentiel à chaque augmentation de précision, nous allons donc nous contenter de 6 décimales exacte de Pi.
Une boucle avec l'API Stream
Nous allons maintenant faire le même calcul en utilisant l'API Stream, on va aussi remplacer la valeur de précision par le nombre de décimales juste en calculant la précision.
private static void testStreamPiDecimal(int decimal) { double pi = 0.0; double some = 0.0; long precision = (long) Math.pow(10, decimal+1); some = LongStream.rangeClosed(0, precision).mapToDouble(k -> {return Math.pow(-1, k) / (2*k + 1);}).sum(); pi = 4 * some; System.out.println("Pi value (decimal : "+ decimal +", pecision : "+ precision +") : " + pi); }
On ajoute ensuite l'appel à notre méthode dans notre main.
private static void testStreamPiDecimal(int decimal) { double pi = 0.0; double some = 0.0; long precision = (long) Math.pow(10, decimal+1); some = LongStream.rangeClosed(0, precision).mapToDouble(k -> {return Math.pow(-1, k) / (2*k + 1);}).sum(); pi = 4 * some; System.out.println("Pi value (decimal : "+ decimal +", pecision : "+ precision +") : " + pi); }
Simple loop Pi value (pecision : 100) : 3.1514934010709914 Pi value (pecision : 1000) : 3.1425916543395442 Pi value (pecision : 10000) : 3.1416926435905346 Pi value (pecision : 100000) : 3.1416026534897203 Pi value (pecision : 1000000) : 3.1415936535887745 Pi value (pecision : 10000000) : 3.1415927535897814 time : 281s Stream loop Pi value (decimal : 1, pecision : 100) : 3.151493401070991 Pi value (decimal : 2, pecision : 1000) : 3.1425916543395434 Pi value (decimal : 3, pecision : 10000) : 3.1416926435905435 Pi value (decimal : 4, pecision : 100000) : 3.141602653489794 Pi value (decimal : 5, pecision : 1000000) : 3.1415936535887936 Pi value (decimal : 6, pecision : 10000000) : 3.141592753589783 time : 332s
On constate que l'utilisation de l'API Stream prend un peu plus de temps, l'écart se creuse encore si on augmente la précision.
Une solution pourrait être de lancer les calculs en parallèle et d'agréger les résultats à la fin. Ça tombe bien car l'API Stream propose une solution pour cela. Créons donc une méthode de calcul en parallèle.
Une boucle parallèle avec l'API Stream
private static void testStreamPiParallel(int decimal) { double pi = 0.0; double some = 0.0; long precision = (long) Math.pow(10, decimal+1); some = LongStream.rangeClosed(0, precision).parallel().mapToDouble(k -> {return Math.pow(-1, k) / (2*k + 1);}).sum(); pi = 4 * some; System.out.println("Pi value (decimal : "+ decimal +", pecision : "+ precision +") : " + pi); }
On ajoute ensuite l'appel à notre méthode dans notre main.
public static void main(String[] args) { System.out.println("Simple loop"); long start = System.currentTimeMillis(); int prec = 100; while (prec <= 10000000) { testPiSimpleLoop(prec); prec = prec*10; } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); System.out.println("\nStream loop"); start = System.currentTimeMillis(); for (int decimal = 1; decimal <= 6; decimal++) { testStreamPiDecimal(decimal); } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); System.out.println("\nStream parallel loop"); start = System.currentTimeMillis(); for (int decimal = 1; decimal <= 6; decimal++) { testStreamPiParallel(decimal); } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); }
Exécutons maintenant notre application.
Simple loop Pi value (pecision : 100) : 3.1514934010709914 Pi value (pecision : 1000) : 3.1425916543395442 Pi value (pecision : 10000) : 3.1416926435905346 Pi value (pecision : 100000) : 3.1416026534897203 Pi value (pecision : 1000000) : 3.1415936535887745 Pi value (pecision : 10000000) : 3.1415927535897814 time : 282s Stream loop Pi value (decimal : 1, pecision : 100) : 3.151493401070991 Pi value (decimal : 2, pecision : 1000) : 3.1425916543395434 Pi value (decimal : 3, pecision : 10000) : 3.1416926435905435 Pi value (decimal : 4, pecision : 100000) : 3.141602653489794 Pi value (decimal : 5, pecision : 1000000) : 3.1415936535887936 Pi value (decimal : 6, pecision : 10000000) : 3.141592753589783 time : 319s Stream parallel loop Pi value (decimal : 1, pecision : 100) : 3.151493401070991 Pi value (decimal : 2, pecision : 1000) : 3.1425916543395434 Pi value (decimal : 3, pecision : 10000) : 3.1416926435905435 Pi value (decimal : 4, pecision : 100000) : 3.141602653489794 Pi value (decimal : 5, pecision : 1000000) : 3.1415936535887936 Pi value (decimal : 6, pecision : 10000000) : 3.141592753589783 time : 99s
Comme on peut le constater la parallélisation das calculs permet de réduire grandement le temps de calcul et donne même un temps de calcul inférieur à la simple boucle. En revanche ce que vous ne pouvez pas voir sur le résultat c'est la parallélisation de l'API Stream lance autant de threads que possible et consomme de se faite toutes les ressources disponible de mon processeur (tous les cœurs non occupés sont sollicités). Cela peut avoir un gros impact sur un calcul assez long, il existe un moyen de contourner ce problème, faisons une dernière méthode.
Une boucle parallèle avec l'API Stream et un pool de threads
private static void testStreamPiParallelPool4(int decimal) { double pi = 0.0; double some = 0.0; long precision = (long) Math.pow(10, decimal+1); ForkJoinPool customThreadPool = new ForkJoinPool(4); try { some = customThreadPool.submit(() -> LongStream.rangeClosed(0, precision).parallel().mapToDouble(k -> {return Math.pow(-1, k) / (2*k + 1);}).sum()).get(); } catch (InterruptedException | ExecutionException e) { some = 0.0; } pi = 4 * some; System.out.println("Parallel pool Pi value (decimal : "+ decimal +", pecision : "+ precision +") : " + pi); }
On ajoute ensuite l'appel à notre méthode dans notre main.
Retrouvez les sources sur github.com
public static void main(String[] args) { System.out.println("Simple loop"); long start = System.currentTimeMillis(); int prec = 100; while (prec <= 10000000) { testPiSimpleLoop(prec); prec = prec*10; } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); System.out.println("\nStream loop"); start = System.currentTimeMillis(); for (int decimal = 1; decimal <= 6; decimal++) { testStreamPiDecimal(decimal); } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); System.out.println("\nStream parallel loop"); start = System.currentTimeMillis(); for (int decimal = 1; decimal <= 6; decimal++) { testStreamPiParallel(decimal); } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); System.out.println("\nStream parallel loop pool 4"); start = System.currentTimeMillis(); for (int decimal = 1; decimal <= 6; decimal++) { testStreamPiParallelPool4(decimal); } System.out.println("time : " + String.valueOf(System.currentTimeMillis() - start) + "s"); }
Exécutons maintenant notre application.
Simple loop Pi value (pecision : 100) : 3.1514934010709914 Pi value (pecision : 1000) : 3.1425916543395442 Pi value (pecision : 10000) : 3.1416926435905346 Pi value (pecision : 100000) : 3.1416026534897203 Pi value (pecision : 1000000) : 3.1415936535887745 Pi value (pecision : 10000000) : 3.1415927535897814 time : 279s Stream loop Pi value (decimal : 1, pecision : 100) : 3.151493401070991 Pi value (decimal : 2, pecision : 1000) : 3.1425916543395434 Pi value (decimal : 3, pecision : 10000) : 3.1416926435905435 Pi value (decimal : 4, pecision : 100000) : 3.141602653489794 Pi value (decimal : 5, pecision : 1000000) : 3.1415936535887936 Pi value (decimal : 6, pecision : 10000000) : 3.141592753589783 time : 320s Stream parallel loop Pi value (decimal : 1, pecision : 100) : 3.151493401070991 Pi value (decimal : 2, pecision : 1000) : 3.1425916543395434 Pi value (decimal : 3, pecision : 10000) : 3.1416926435905435 Pi value (decimal : 4, pecision : 100000) : 3.141602653489794 Pi value (decimal : 5, pecision : 1000000) : 3.1415936535887936 Pi value (decimal : 6, pecision : 10000000) : 3.141592753589783 time : 61s Stream parallel loop pool 4 Parallel pool Pi value (decimal : 1, pecision : 100) : 3.151493401070991 Parallel pool Pi value (decimal : 2, pecision : 1000) : 3.1425916543395434 Parallel pool Pi value (decimal : 3, pecision : 10000) : 3.1416926435905435 Parallel pool Pi value (decimal : 4, pecision : 100000) : 3.141602653489794 Parallel pool Pi value (decimal : 5, pecision : 1000000) : 3.1415936535887936 Parallel pool Pi value (decimal : 6, pecision : 10000000) : 3.141592753589783 time : 112s
On constate quand limitant le pool de threads à 4 on augmente le temps de calcul, ce qui est logique vu que mon processeur contient plus de 4 cœurs, mais on sollicite moins les ressources de la machine. Il faut toujours garder à l'esprit que lorsque l'on parallélise un traitement il faut dimensionner le pool de threads utilisés selon le nombre de cœurs disponible sur la machine physique ou virtuel. En général on va cibler un pool de threads égale au nombre de processeurs de la machine ou un peu moins si on veux laisser des ressources pour d'autres applications.
Conclusion
Nous avons pu constater dans cet article que l'utilisation de l'API Stream sur un calcul un peu complexe était plus lent que l'utilisation d'une simple boucle for. En revanche il est relativement simple avec l'API Stream de paralléliser les calcul (il faut bien sûr que les calcul s'y prêtent), et que même si elle ne propose pas nativement une limitation du nombre de threads utilisés on pouvait l'inclure dans un pool de threads pour limiter les impactes sur les ressources.
J'espère que cet article vous aura intéressé et n’hésitez pas à laisser un commentaire si vous avez des questions ou des remarques constructives.
Merci d'avoir pris le temps de lire cet article et à bientôt.
Merci d'avoir pris le temps de lire cet article et à bientôt.
Retrouvez les sources sur github.com