FAQ 2 : Attention à l’utilisation de Parallel.For avec une charge de travail trop faible !!!

clip_image00248

   Qui, n’a jamais été tenté de remplacer systématiquement ses boucles « for » par des « Parallel.For » ? Il est certain que la simplicité de remplacement peut tenter le développeur frustré par les lenteurs de son application. Et pourtant, on ne devrait JAMAIS appliquer cette pratique sans qualifier l’intérêt de parallélisé vis-à-vis de chaque boucle. Généralement, une boucle contient des charges faibles. Prenons un exemple :

 var arr = new int[1024*1024];

 

for (int i = 0; i < arr.Length; i++)

{

arr[i] = (int)Math.Sqrt(arr[i]);

}

 

Temps exécution: 00:00:00.0084701

Supposons que nous utilisons une machine avec 4 coeurs et l’on remplace la boucle for naïvement, on obtient :

 Parallel.For(0, arr.Length, i => 
{ 
    arr[i] = (int)Math.Sqrt(arr[i]); 

});
  

Temps exécution: 00:00:00.0070126

Le résultat n’est pas très concluant, car les performances sont quasiment identiques entre la version série et la version parallèle. Le problème provient du grand nombre d’itérations vis-à-vis du faible coût de la charge de travail unitaire. A chaque appel, nous appelons l’expression lambda qui sera soumise au Scheduler de tâches TPL (ThreadPool) qui peut être jugée dans notre cas, couteux. Pour éviter ce problème, nous pouvons regrouper les petites charges, de manière à rendre négligeable les frais de fonctionnement et de gagner en performances. Il existe une classe statique Partitioner dans l’espace de nom System.Collections.Concurrent , permettant de partitionner en quelques itérations une grande collection d’items. L’utilisation de la classe Partitioner est simple, vous donnez la plage souhaitée pour découper le travail en lot, via la méthode Create, ici de 0 à 1Mo (vous pouvez aussi définir la taille du découpage en précisant l’argument RangeSize).

 Parallel.ForEach(Partitioner.Create(0, arr.Length), range => 
{ 
    for (int i = range.Item1; i < range.Item2; i++) 
    {          
         arr[i] = (int)Math.Sqrt(arr[i]); 
    } 
});
  

Temps exécution: 00:00:00.0034994

Cette fois, nous avons vraiment gagné du temps (accélération de 2,4), mais pour y arriver, nous avons mesuré le temps d’exécution de notre boucle.

En conclusion, vous ne devriez pas paralléliser vos boucles sans considérer le coût d’exécution et leur charge d’itérations respectives. Dans notre exemple, la charge est souvent régulière, mais il arrive parfois qu’elle évolue de manière significative entre chaque itération. Je vous invite à étudier vos charges de travail au regard de leurs coûts. Si vous le souhaitez, vous pouvez étudier les classes de partition, disponible dans le Framework 4.0 et dans les exemples complémentaires aux extensions parallèles avec le SingleItemPartitioner. Enfin, vous trouverez encore plus d’information avec un excellent article de Stephen Toub sur le sujet : Custom Parallel Partitioning With .NET 4.

A bientôt

Bruno

boucard.bruno@free.fr