mardi 30 juin 2020

Java - Loop, parallel loop, stream - approximation de Pi

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);
}

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 : 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.

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.

Retrouvez les sources sur github.com

Aucun commentaire :

Enregistrer un commentaire