Astuces DotNet (Sébastien Courtois)

26/04/2010

[Nouveautés .NET 4] Task Parallel Library : Gestion des architectures multicores/multiprocesseurs

Filed under: .NET, C# 4, Débutant, Hors Catégorie, Optimisation — Étiquettes : , , , , , , , , — sebastiencourtois @ 09:55

De nos jours, on ne trouve plus dans le marché de la vente d’ordinateurs que des architectures avec des processeurs double,quad,octo…. cores (et parfois même multi processeurs). Malgré cette débauche de puissance de calculs, il est triste à noter que nos applications ne sont, pour la plupart, pas tellement plus rapide qu’avant. La cause :

  • Les développeurs ne savent généralement pas coder des applications gérant plusieurs threads

PFX1 Ca fait mal d’avoir 4 coeurs et d’en utiliser qu’un seul… à moitié !!!

C’est un fait. Lorsque l’on apprend la programmation, on nous enseigne généralement beaucoup de choses mais rarement comment faire des applications gérant correctement plusieurs threads. L’une des raison est simple : Les API de développement sont généralement complexes et demande des efforts d’architecture/programmation/débug afin de rendre le tout utilisable. WaitHandle,Process,Mutex,lock,Interlocked … autant de mot clé et concepts barbare que le développeur doit maitriser afin de pouvoir décupler la puissance de ses applications. “On a déja du mal à faire une application correspondant aux spécifications dans le temps imparti, on va pas en plus passer des heures à gérer les multi coeurs”.

Partant de ce constat, Microsoft a créé Parallel FX afin d’aider le développeur à gérer facilement les threads au sein de ses applications.

  • Organisation de TPL

TPL se compose de 3 namespaces NET 4 :

  1. System.Threading.Tasks : Ce namespace contient la base de Task Parallel Library (TPL), il contient la classe principale de la technologie : la classe Tasks (sorte de “super Thread”)
  2. System.Collections.Concurrent : Ce namespace contient les collections pouvant être utilisé dans des contextes multithreads (thread safe).
  3. System.Linq : LINQ a été amélioré pour intégrer des aides au multithreading. Connu sous le nom de PLINQ (Parallel LINQ), il permet de paralléliser certaines étapes des requêtes LINQ afin de les rendre plus rapide.
  • Le namespace System.Threading.Task

Ce namespace contient une classe Task. Cette classe représente une opération asynchrone. Ainsi on défini notre tache et, lorsqu’on la démarre, celle-ci est assigné par le TaskScheduler à un des coeurs disponibles de la machine. Ce système marche sous la forme d’une  file (first in / first out) de priorité (il y a des VIP :)). Ce Taskscheduler est aussi capable de distribuer un grand nombre de tâches aux différentes cœurs et de balancer la charge si un cœur est plus occupé qu’un autre (un peu à l’instar des load balancing dans les serveurs web).

La création et l’utilisation des Task est simple :

Task t1 = Task.Factory.StartNew(() =>
    {
        for (int i = 0; i < 200; i++)
        {
            if(i % 2 == 0)
                Console.WriteLine(i+" ");
        }
    });

Task t2 =new Task(() =>
{
    for (int i = 0; i < 200; i++)
    {
        if (i % 2 == 1)
            Console.WriteLine(i + " ");
    }
});
t2.Start();

Il y a,au moins, deux méthodes pour créer une Task. La première en utilisant une fabrique d’objet fournit par la classe Task. La seconde est de créer soi même une classe Task et de lui fournir la méthode à éxécuter puis de le lancer en appelant la méthode Start (un peu de la même façon qu’un thread. Il est à noter que l’on ne peut fournir de méthode retournant de valeurs. De plus, la méthode doit être sans paramètre ou alors avec un seul paramètres de type Object (Action, Action<object>).

L’autre nouveautés très sympathique est la possibilité de paralléliser facilement ses boucles. Si on part des exemples suivants :

foreach (string filename in Directory.GetFiles(MyDirectory, "*"))
    Console.WriteLine(filename);

for (int i = 0; i < 200; i++)
{
    if (i % 2 == 1)
        Console.WriteLine(i + " ");
}

Il est possible d’exécuter les itérations en parallèle en remplacement le for et le foreach par Parallel.For et Parallel.Foreach.

Parallel.ForEach(Directory.GetFiles(MyDirectory, "*"), (filename) =>
{
    Console.WriteLine(filename);
});

Parallel.For(0, 200, (i) =>
{
    if (i % 2 == 1)
        Console.WriteLine(i + " ");
});

Et c’est tout !!! Le taskscheduler s’occupe de la répartition des opérations sur le processeur. On peut ainsi passer d’un code compliqué de copie de fichier multithreadée :

public void SpeedCopyFolder(DirectoryInfo source, DirectoryInfo target, bool overrideExisting)
{
    using (ManualResetEvent mre = new ManualResetEvent(false))
    {
        int threadCount = 0;
        foreach (FileInfo fi in source.GetFiles())
        {
            Interlocked.Increment(ref threadCount);
            // Create a thread for each file
            FileInfo file = new FileInfo(fi.FullName); // Created for the delegate scope
            ThreadPool.QueueUserWorkItem(delegate
            {
                if (File.Exists(Path.Combine(target.ToString(), file.Name)) && !overrideExisting)
                    return;
                fi.CopyTo(Path.Combine(target.ToString(), file.Name), overrideExisting);
                if (Interlocked.Decrement(ref threadCount) == 0) mre.Set();
            });
        }
        if (Interlocked.Decrement(ref threadCount) == 0) mre.Set();
        mre.WaitOne();
    }
}

à un code beaucoup plus simple :

public static void CopyFiles(string fromFolder, string toFolder)
{
    Parallel.ForEach<string>(Directory.EnumerateFiles(fromFolder, "*"), f =>
    {
        File.Copy(f, toFolder + @"\" + Path.GetFileName(f), true);
    });
}

  • Le namespace System.Collections.Concurrent

Ce namespace contient des collections utilisables dans les cas de multithreading. Ces collections utilisent une interface créé spécialement pour l’occasion : IProducerConsumerCollection<T> qui permet la manipulation de ces collections.

Les collections ainsi ajoutés :

Les collections classiques fonctionnent toujours avec les tasks (sur les exemples que j’ai pu essayer.). Je pense qu’il doit y avoir des différences de performances/d’utilisations possibles qui doivent indiquer quel type utiliser. Je publierais surement un post quand j’en saurais un peu plus sur le sujet.

  • Parallel LINQ

PLINQ est une technologie qui permet de paralléliser vos requêtes LINQ.

Prenons l’exemple suivant : On souhaite récupérer les fichiers dont la première lettre est un ‘a’ puis classer ce résultat selon la dernière lettre du nom du fichier. On aurait là requête LINQ suivante  :

Directory.GetFiles(MyDirectory, "*").Where((name) => name.First() == 'a').OrderBy((name) => name.Last());

Si l’on étudie un peu la façon dont la requête s’éxécute, LINQ va d’abord parcourir la liste des fichiers en recherche de ceux commençant par a puis il va les classer par ordre. Cela est logique pour un traitement mono-core. Toutefois, on pourrait imaginer faire le classement en parralèle du Where. Ainsi lorsque le Where trouve un élément commençant par un ‘a’, il est envoyé au Orderby (se trouvant dans un autre thread) qui réalise un classement en temps réel. Vous devez vous dire que cela doit être complexe à coder. Pourtant voici là façon de faire.

Directory.GetFiles(MyDirectory, "*").AsParallel().Where((name) => name.First() == 'a').OrderBy((name) => name.Last());

Il suffit de rajouter un AsParallel() afin que toutes les expressions suivantes s’exécutent “simultanément”. “C’est pas wonderful ?!?!”

  • C’est bien beau tout ça mais … et les performances ?

Sur un exemple qui est très parlant pour PLINQ (BabyNames disponible sur le training Kit VS 2010), j’obtiens les résultats suivants (sur une machine 4 cœurs avec une sélection du nombre de cœurs utilisés pour l’application).

    Nombre de cœurs LINQ (temps en secondes) PLINQ (Temps en secondes) Amélioration
    1 37,71 38,57 x 0.98
    2 37,46 20,68 x 1.81
    3 37,14 12,82 x 2.90
    4 37,36 10,60 x 3.53

Remarque : La raison pour laquelle on n’arrive pas à 4x plus de performance avant un quad core est tout simplement du au fait que l’OS plus d’autres applications tourne en arrière plan avec des pics imprévisibles. De plus, les mécanismes de synchronisation entre thread prend aussi un peu de la puissance de calculs.

Pour terminer, un petit graph qui fait plaisir :

pfx2

  • Conclusion

Cet technologie va surement réconcilier de nombreux développeurs avec la gestion des applications multithreadés. Toutefois, il est à noter que, dans certains cas, le parallélisme peut nuire au performance de l’application (car cela implique toujours en tâches de fond des techniques de synchronisation). A utiliser avec parcimonie et toujours en testant les performances avec et sans TPL.

Remarque : TPL est disponible en C++ aussi.

Note : Ce post a été fortement inspiré du post de Gal Ratner : http://galratner.com/blogs/net/archive/2010/04/24/a-quick-lap-around-net-4-0-s-parallel-features.aspx

19/04/2010

[Nouveautés .NET 4] Dynamic VS Réflexion VS Code classique : Performances

Filed under: .NET, C# 4, Débutant, Intermediaire, Optimisation — Étiquettes : , , , , , , — sebastiencourtois @ 10:28

Suite à des discussions sur le sujet des performances avec les Dynamics, j’ai décidé de monter le petit test suivant :

class Program
   {
       static void Main(string[] args)
       {
           Test t = new Test();
           dynamic d = t;
           //Premier appel
           Console.WriteLine("Premier appel");
           
           //Appel direct
           Stopwatch sw = Stopwatch.StartNew();
           t.TotoNoParam();
           sw.Stop();
           Console.WriteLine("Appel direct : {0}",sw.Elapsed);
           //Appel reflexion
           sw.Reset();
           sw.Start();
           Test.CallMethod<Test>(t, "TotoNoParam", null);
           sw.Stop();
           Console.WriteLine("Appel Reflection : {0}", sw.Elapsed);
           //Appel dynamique
           sw.Reset();
           sw.Start();
           d.TotoNoParam();
           sw.Stop();
           Console.WriteLine("Appel Dynamique : {0}", sw.Elapsed);

           //Second appel
           Console.WriteLine("Second appel");

           //Appel direct
           sw.Reset();
           sw.Start();
           t.TotoNoParam();
           sw.Stop();
           Console.WriteLine("Appel direct : {0}", sw.Elapsed);
           //Appel reflexion
           sw.Reset();
           sw.Start();
           Test.CallMethod<Test>(t, "TotoNoParam", null);
           sw.Stop();
           Console.WriteLine("Appel Reflection : {0}", sw.Elapsed);
           //Appel dynamique
           sw.Reset();
           sw.Start();
           d.TotoNoParam();
           sw.Stop();
           Console.WriteLine("Appel Dynamique : {0}", sw.Elapsed);

           //Appel chainés
           //Appel direct
           for (int i = 1; i < 10000000; i *= 10)
           {
               sw.Reset();
               sw.Start();
               for (int j = 0; j < i; j++)
                   t.TotoNoParam();
               sw.Stop();
               Console.WriteLine("Appel direct x {0} \t: {1}",i, sw.Elapsed);
           }

           //Appel reflection
           for (int i = 1; i < 10000000; i *= 10)
           {
               sw.Reset();
               sw.Start();
               for (int j = 0; j < i; j++)
                   Test.CallMethod<Test>(t, "TotoNoParam", null);
               sw.Stop();
               Console.WriteLine("Appel reflection x {0} \t: {1}", i, sw.Elapsed);
           }

           //Appel dynamic
           for (int i = 1; i < 10000000; i *= 10)
           {
               sw.Reset();
               sw.Start();
               for (int j = 0; j < i; j++)
                   d.TotoNoParam();
               sw.Stop();
               Console.WriteLine("Appel dynamic x {0} \t: {1}", i, sw.Elapsed);
           }

           Console.WriteLine("Fini");
           Console.ReadLine();
       }

   }

La classe Test est la même que le poste précédent. J’ai d’abord voulu tester les performances pour un appel d’une méthode sans paramètres avec 3 méthodes :

  • Appel direct (classique depuis .NET 1)
  • Appel en utilisant la réflexion (MethodInfo.Invoke)
  • le mot clé dynamic.

Voici les tableaux de résultats ( les graphiques ne rendant pas correctement les valeurs, j’ai choisi de ne pas en mettre).

Sur un appel (en ms) Direct Réflexion Dynamics
Premier appel 0,0622 0,4020 26,0880
Second appel 0,0007 0,0076 0,7007
  88x 53x 37x

Premier constat, un deuxième appel est toujours plus rapide que le premier quelque soit la méthode. Cela est dû à une optimisation de .NET qui compile le code à la volée et le met en cache. Le premier appel subit donc la compilation + mise en cache. Cela ajoute un temps (non négligeable) supplémentaire pour l’exécution de la méthode.

Deuxième constant, on remarque que Direct est 6 à 10 x plus rapide que la réflexion et 400 à 1000x fois plus rapide que Dynamics.

Si l’on regarde les apples multiples, on obtient le tableau suivant (en secondes) :

Appels Multiples Direct Réflexion Dynamics Dynamics/Direct Réflexion / Direct Réflexion/Dynamics
1,00 0,0000003 0,0000069 0,0007376 2 458,67 23,00 0,01
10,00 0,0000003 0,0000238 0,0000015 5,00 79,33 15,87
100,00 0,0000011 0,0002062 0,0000072 6,55 187,45 28,64
1 000,00 0,0000103 0,0020013 0,0000691 6,71 194,30 28,96
10 000,00 0,0000979 0,0211307 0,0006662 6,80 215,84 31,72
100 000,00 0,0009323 0,1582175 0,0067310 7,22 169,71 23,51
1 000 000,00 0,0096497 1,5509957 0,0872035 9,04 160,73 17,79

Remarque : Afin de clarifier les choses, ces tableaux a été généré plusieurs fois afin d’être sur de la validité des résultats.

On peut voir que l’appel classque reste, de loin, la méthode la plus rapide. Direct reste environ 5 à 10 x plus rapide que dynamic et 80 à 200 x plus rapide que la réflexion. On remarque aussi que la reflexion est plus lente (15 à 30x) que Dynamic (ce qui est en contradiction avec le premier tableau). De plus, le premier appel multiple pour Dynamic est étonnamment élevé car il intervient APRES deux appels à cette méthode issus de l’expérience précédente donc il ne devrait plus y avoir de compilation/mise en cache.

Je n’ai pas encore trouvé les raisons de ces “incohérences”. Si vous avez des explications, n’hésitez pas à commenter ce post.

Concernant les appels de méthodes avec paramètres, on se retrouve avec les mêmes plages de valeurs (Direct 5 à 10x plus rapide que reflexion et 500 à 1200 x plus rapide que dynamic). On retrouve les mêmes incohérences du deuxième tableau.

Conclusion :

L’utilisation du mot clé dynamic peut avoir des conséquences dramatiques sur les performances d’un code C# et ne doit donc être utilisé que dans les cas vraiment spécifiques comme l’interopérabilité avec un langages dynamique.

EDIT : 28 / 07 / 2010 : Suite à un commentaire, je publie ici le code ainsi qu’une optimisation proposé pour le test :

class Program
    {
        static void Main(string[] args)
        {
            Test t = new Test();
            dynamic d = t;

            Console.WriteLine("Test Appel Direct");
            Stopwatch sw = new Stopwatch();
            for (int i = 1; i < 10000000; i *= 10)
            {
                sw.Reset();
                sw.Start();
                for (int j = 0; j < i; j++)
                    t.TotoNoParam();
                sw.Stop();
                Console.WriteLine("Appel direct x {0} \t: {1}", i, sw.Elapsed);
            }

            Console.WriteLine("Test Appel Reflection");
            for (int i = 1; i < 10000000; i *= 10)
            {
                sw.Reset();
                sw.Start();
                for (int j = 0; j < i; j++)
                    Program.CallMethod<Test>(t, "TotoNoParam");
                sw.Stop();
                Console.WriteLine("Appel Reflection x {0} \t: {1}", i, sw.Elapsed);
            }

            Console.WriteLine("Test Appel Reflection (optimized)");
            Type type = t.GetType();
            MethodInfo mi = type.GetMethod("TotoNoParam");
            for (int i = 1; i < 10000000; i *= 10)
            {
                sw.Reset();
                sw.Start();
                for (int j = 0; j < i; j++)
                    mi.Invoke(t,null);
                sw.Stop();
                Console.WriteLine("Appel Reflection (optimized) x {0} \t: {1}", i, sw.Elapsed);
            }

            Console.WriteLine("Test Appel Dynamic");
            for (int i = 1; i < 10000000; i *= 10)
            {
                sw.Reset();
                sw.Start();
                for (int j = 0; j < i; j++)
                    d.TotoNoParam();
                sw.Stop();
                Console.WriteLine("Appel Dynamic x {0} \t: {1}", i, sw.Elapsed);
            }
            Console.ReadLine();
        }

        public static void CallMethod<T>(T instance, string methodName) where T : class
        {
            Type t = instance.GetType();
            MethodInfo mi = t.GetMethod("TotoNoParam");
            if (mi == null)
                throw new InvalidOperationException(string.Format("La méthode {0} n'existe pas pour le type {1}", methodName, t.Name));
            mi.Invoke(instance, new object[] { });
        }
    }

    public class Test
    {
        public string ValA { get; set; }
        public int ValB;
        public dynamic ValC;

        public void TotoNoParam() { }
        public void Toto(int a) { }
        public string Tata(dynamic b) { return string.Empty; }
    }

Les résultats :

Test Appel Direct

Appel direct x 1        : 00:00:00.0000614

Appel direct x 10       : 00:00:00.0000003

Appel direct x 100      : 00:00:00.0000011

Appel direct x 1000     : 00:00:00.0000076

Appel direct x 10000    : 00:00:00.0000702

Appel direct x 100000   : 00:00:00.0007034

Appel direct x 1000000  : 00:00:00.0072613

Test Appel Reflection

Appel Reflection x 1    : 00:00:00.0005199

Appel Reflection x 10   : 00:00:00.0000188

Appel Reflection x 100  : 00:00:00.0001620

Appel Reflection x 1000         : 00:00:00.0016719

Appel Reflection x 10000        : 00:00:00.0155864

Appel Reflection x 100000       : 00:00:00.1943837

Appel Reflection x 1000000      : 00:00:01.8779117

Test Appel Reflection (optimized)

Appel Reflection (optimized) x 1        : 00:00:00.0000069

Appel Reflection (optimized) x 10       : 00:00:00.0000184

Appel Reflection (optimized) x 100      : 00:00:00.0001509

Appel Reflection (optimized) x 1000     : 00:00:00.0014998

Appel Reflection (optimized) x 10000    : 00:00:00.0151252

Appel Reflection (optimized) x 100000   : 00:00:00.1431094

Appel Reflection (optimized) x 1000000  : 00:00:01.3252002

Test Appel Dynamic

Appel Dynamic x 1       : 00:00:00.0346741

Appel Dynamic x 10      : 00:00:00.0000053

Appel Dynamic x 100     : 00:00:00.0000107

Appel Dynamic x 1000    : 00:00:00.0000921

Appel Dynamic x 10000   : 00:00:00.0009150

Appel Dynamic x 100000  : 00:00:00.0088054

Appel Dynamic x 1000000         : 00:00:00.0764506

Apparement même en sortant le MethodInfo de l’appel, on obitent encore des résultats plus rapide avec Dynamic qu’avec Reflection.

Créez un site Web ou un blog gratuitement sur WordPress.com.