Partager via


Pools de threads

Programmation multithread évolutive avec les pools de threads

Ron Fosner

La programmation est un défi de plus en plus grand, en particulier si vous travaillez dans un domaine qui nécessite que vous régliez votre application pour obtenir le débit le plus rapide possible. L'un des facteurs contributeurs réside dans le changement observé dans la manière dont les PC évoluent. Plutôt que de s'appuyer sur la vitesse croissante d'un seul processeur, la puissance de calcul des PC est à présent répartie sur plusieurs cœurs. Et c'est une bonne chose. Des augmentations importantes de la puissance de traitement latente sont à présent disponibles à un coût relativement faible, souvent dotées d'une consommation électrique et de besoins de refroidissement bien moindres.

Mais cette prédominance croissante des systèmes multicœurs présente un inconvénient. Pour utiliser plusieurs processeurs, vous devez plonger dans le monde du traitement parallèle. Cela signifie que les programmeurs doivent travailler davantage, avec parfois une marge d'apprentissage importante, avant de pouvoir profiter de la puissance de traitement latente. Vous pouvez ajouter quelques astuces de compilation à votre projet et demander au compilateur de rédiger un peu de code multithread pour vous. Cela dit, pour profiter totalement de la puissance des PC multicœurs, vous allez devoir effectuer quelques changements dans votre manière de programmer de grands projets.

Il existe de nombreuses façons différentes de répartir votre travail sur plusieurs cœurs. L'une des plus faciles et des plus robustes d'entre elles s'appelle la programmation basée sur les tâches. Les tâches vous permettent de répartir le travail de votre application sur certains ou tous les cœurs de processeurs disponibles. Avec un peu de programmation bien pensée, vous pouvez minimiser, voire éliminer, toute contrainte de dépendance aux données ou de synchronisation. Pour atteindre cet état de félicité multicœurs, vous devrez revoir certaines de vos préconceptions sur la manière d'attaquer un problème de programmation et les repenser en termes de programmation basée sur les tâches.

Pour démontrer comment cela fonctionne, je vais vous accompagner tout au long des étapes, y compris les erreurs, que j'ai suivies pour convertir une application à thread unique en application évolutive permettant d'utiliser tous les processeurs disponibles d'un ordinateur. Dans cet article, je présente certains des concepts de la programmation multithread et démontre des manières simples d'introduire l'exécution de threads dans votre code grâce à OpenMP et aux pools de threads. Vous verrez aussi comment Visual Studio 2010 peut aider à mesurer l'amélioration des performances grâce à ces techniques. Dans un prochain article, je me servirai des fondations posées aujourd'hui pour vous montrer des exécutions multithread plus sophistiquées grâce aux tâches.

Des threads aux tâches

Adapter un programme au nombre de cœurs de processeur est un défi majeur qui réside dans l'impossibilité de lancer des travaux sur leur propre thread et de les laisser s'exécuter. En fait, c'est ce que font de nombreuses personnes, mais cette méthode adapte bien l'application au nombre de cœurs pour lesquels elle a été conçue uniquement. Elle n'adapte pas bien l'application à un nombre inférieur ou supérieur de cœurs que celui visé et l'obsolescence engendrée est totalement ignorée dans cette approche.

Pour s'assurer que votre application s'adapte bien à un nombre varié de cœurs, il suffit de diviser des travaux de grande taille en sous-travaux plus petits, compatibles pour les threads et appelés tâches. La partie la plus difficile de la conversion d'un programme monolithique à thread unique ou d'un programme présentant quelques threads dédiés en système de travaux basés sur les tâches consiste en réalité à diviser vos travaux en tâches.

Vous devez tenir compte de quelques indications lorsque vous convertissez un travail de grande taille à thread unique en tâches multithread.

  • Le travail de départ peut être divisé de façon arbitraire entre 1 et n tâches.
  • Les tâches doivent pouvoir s'exécuter dans n'importe quel ordre.
  • Les tâches doivent êtres indépendantes les unes des autres.
  • Les tâches doivent avoir associé des données contextuelles.

Si ces exigences étaient faciles, vous n'auriez aucun problème pour exécuter vos applications sur n'importe quel nombre de cœurs. Malheureusement, il n'est pas si simple de diviser si nettement les problèmes rencontrés en tâches qui suivent ces instructions.

Ces instructions sont importantes car, bien suivies, elles permettent d'exécuter chaque tâche sur son thread, sans aucune dépendance entre les tâches. Dans l'idéal, les tâches doivent pouvoir être exécutées dans n'importe quel ordre. L'élimination ou la réduction des interdépendances est de la plus haute importance pour que le système basé sur les tâches fonctionne.

La plupart des programmes du monde réel vont passer par diverses étapes de traitement, chacune d'entre elles devant être terminée avant de commencer la suivante. Ces points de synchronisation sont souvent inévitables, mais avec un système basé sur les tâches, l'objectif consiste à s'assurer de bien profiter de la puissance de processeur immédiatement disponible, quelle qu'elle soit. En divisant de manière judicieuse des travaux de grande taille en travaux plus petits, il est souvent possible d'entremêler certains résultats des tâches terminées avec leur étape suivante de traitement alors que certaines tâches initiales sont encore en cours d'exécution.

Multithreads simples avec OpenMP

Avant de convertir votre application entière à l'utilisation de tâches, sachez qu'il existe d'autres manières de profiter des avantages du multithread sans passer par l'exercice rigoureux de tout transformer en tâche. Il existe de nombreuses manières d'incorporer le multithread dans votre application. Celles-ci nécessitent peu d'efforts mais permettent de bénéficier de l'ajout du multithread à votre code.

OpenMP est l'une des manières les plus simples d'ajouter du traitement parallèle à vos programmes et est pris en charge par le compilateur Visual Studio C++ depuis 2005. Activez OpenMP en ajoutant à votre code des pragmas qui indiquent l'endroit et le type de parallélisme que vous souhaitez ajouter. Par exemple, vous pouvez ajouter un parallélisme à un simple programme Hello World :

#include <omp.h> // You need this or it won't work
#include <stdio.h>
int main (int argc, char *argv[]) {
  #pragma omp parallel
  printf("Hello World from thread %d on processor %d\n",
    ::GetCurrentThreadfID(), 
    ::GetCurrentProcessorNumber());
  return 0;
}

Le pragma d'OpenMP parallélise le bloc de code suivant (dans ce cas, il s'agit simplement du printf) et l'exécute simultanément sur tous les threads matériels. Le nombre de threads dépend du nombre de threads matériels disponibles sur l'ordinateur. La sortie est une instruction printf qui s'exécute sur chaque thread matériel.

Pour qu'un programme OpenMP parallélise (et n'ignore pas vos pragmas OpenMP en silence), vous devez activer OpenMP pour votre programme. Vous devez tout d'abord inclure l'option de compilateur /openmp (Propriétés | C/C++ | Langage | Support Open MP). Ensuite, incluez le fichier d'en-têtes omp.h.

OpenMP brille réellement lorsque votre application passe le plus clair de son temps à faire des boucles de fonctions ou de données et que vous souhaitez ajouter une prise en charge multiprocesseur. Par exemple, si une boucle « for » met du temps à s'exécuter, vous pouvez paralléliser facilement cette boucle à l'aide d'OpenMP. Voici un exemple de division automatique des calculs de tableaux et de distribution sur le nombre de cœurs disponible sur le moment :

#pragma omp parallel for
for (int i = 0; i < 50000; i++)
  array[i] = i * i;

OpenMP dispose d'autres constructions qui donnent plus de contrôle sur le nombre de threads créé, qui décident si le travail distribué doit être terminé avant l'exécution du bloc de code suivant, qui créent des données locales de thread, des points de synchronisation, des sections critiques, etc.

Comme vous pouvez le voir, OpenMP est une manière simple d'introduire doucement le parallélisme dans une base de code existante. Cependant, même si la simplicité d'OpenMP est attrayante, vous avez parfois besoin de plus de contrôle sur ce que fait votre application, comme par exemple lorsque vous souhaitez que votre programme règle de manière dynamique ce qu'il fait ou si vous devez vous assurer qu'un thread demeure sur un cœur particulier. OpenMP est conçu pour intégrer facilement certains aspects de la programmation multithread dans votre programme, mais il lui manque certaines des fonctions avancées dont vous pensez avoir besoin pour avoir une utilisation optimale de plusieurs cœurs. C'est là qu'interviennent les tâches et les pools de thread.

Utilisation du pool de thread

Les threads donnent beaucoup de travail de comptabilité pour le système d'exploitation. Souhaiter uniquement les créer et les détruire est donc une mauvaise idée. La création et la destruction d'un thread engendre des coûts qui ne sont pas sans importance. Si vous faites constamment cela, il est facile de perdre tout l'avantage gagné à faire du multithread.

Au lieu de cela, le mieux consiste à utiliser un ensemble existant de threads, recyclés si nécessaire pour toute votre activité en threads. Cette conception s'appelle un pool de thread, et Windows vous en fournit un. Ce pool de thread vous retire la création, la destruction et la gestion des threads, car tous ces éléments sont réalisés à votre place par le pool de thread. OpenMP utilise un pool de thread pour répartir le travail sur les threads, et à la fois Windows Vista et Windows 7 offrent des versions optimisées du pool de thread pour que vous puissiez les utiliser directement.

Même s'il est tout à fait possible de créer votre propre pool de thread (ce qui peut arriver en cas d'exigences de planification inhabituelles), il est plus simple pour vous d'utiliser un de ceux fournis par le système d'exploitation ou par Microsoft .NET Framework.

C'est maintenant que je dois éclaircir des points de terminologie. Lorsque la plupart des gens parlent de thread, ils font référence au flux d'exécution via un seul cœur de processeur. En d'autres termes, il s'agit d'un thread logiciel. Sur un processeur, le flux d'exécution (l'exécution réelle des instructions) a lieu sur des threads matériels. Leur nombre est limité par le matériel sur lequel votre application s'exécute. Autrefois, ce matériel n'était autre qu'un processeur à thread unique, mais il est à présent courant de trouver des systèmes équipés de processeurs double-cœurs. Un processeur à quadruple-cœurs aura la capacité d'exécuter quatre threads matériels, voire huit s'il est en hyperthreading. Les systèmes de bureau haut de gamme peuvent présenter jusqu'à 16 threads matériels et certaines configurations de serveurs en ont plus de 100 !

 Tandis que c'est bien un thread matériel qui exécute les instructions, un thread logiciel se réfère au contexte entier (valeurs de registre, descripteurs, attributs de sécurité, etc.) requis pour réellement exécuter le travail sur un thread matériel. Il est important de noter que vous pouvez avoir bien plus de threads logiciels que de threads matériels, ce qui compose la base sous-jacente d'un pool de thread. Cela vous permet de mettre en attente les tâches à l'aide des threads logiciels, puis de planifier leur exécution sur les threads matériels réels.

L'avantage d'un pool de thread par rapport à ceux que vous créez réside dans le fait que le système d'exploitation gère les tâches de planification à votre place. Votre travail se résume à alimenter le pool de thread en tâches afin que le système d'exploitation puisse occuper tous les threads matériels. Ceci est illustré à la figure 1. Tout ce qui se trouve dans le carré constitue le pool de thread et sort du champ d'action du programmeur. C'est à l'application d'alimenter le pool de thread en tâches, de choisir l'endroit où les placer dans la file d'attente du thread et enfin de planifier leur exécution sur un thread matériel.

image: A Thread Pool

Figure 1  Un pool de thread

Voici à présent la parti difficile : Quelle est la meilleure manière de structurer des travaux afin d'occuper les cœurs et de garder une utilisation des processeurs à son maximum ? Cela dépend de ce que votre application doit faire.

Je travaille souvent avec des entreprises de jeux vidéo et ces derniers comptent parmi les types d'applications les plus difficiles car il y a beaucoup à faire, habituellement dans un ordre de série particulier, et sensibles aux retards. Le programme se met généralement à jour à une certaine fréquence d'images. Et si les fréquences d'images commencent à prendre du retard, l'expérience du jeu s'en ressent. Il est par conséquent vraiment conseillé de maximiser l'utilisation du matériel.

D'un autre côté, si votre application effectue une action de grande taille à la fois, ce que vous devez faire vous apparait plus clairement, mais cela reste un défi que d'essayer de répartir un seul travail sur plusieurs cœurs.

Tri multithread

Prenons tout d'abord un travail monolithique que l'on trouve fréquemment dans des applications et voyons comment vous pouvez le transformer en une forme plus compatible pour le multithread. Je pense, bien entendu, au tri. Le tri est un exemple particulièrement bon car il présente un obstacle majeur : Comment trier quelque chose et répartir ce tri sur plusieurs cœurs de manière que le tri d'un cœur soit indépendant de ce qui a été trié sur un autre cœur ?

On retrouve souvent une approche naïve qui consiste à verrouiller l'accès à toute donnée accessible par plusieurs cœurs à l'aide de quelque chose comme un mutex, un sémaphore ou une section critique. Cela fonctionnera. Cependant, employée comme la panacée pour éviter de planifier correctement l'accès aux données partagées, vous allez au final tuer tous les gains que vous auriez pu obtenir en bloquant l'exécution d'autres threads. Au pire, vous pouvez introduire une subtile compétition qu'il sera immensément difficile de retrouver.

Heureusement, vous pouvez concevoir l'application afin d'éliminer le plus gros de l'accès partagé aux données sur les threads en choisissant l'algorithme de tri approprié.

L'approche qui consiste à donner à chaque cœur une sous-section du tableau à trier est meilleure. Cette méthode du « diviser pour mieux régner » est une manière aisée de répartir le travail sur le même jeu de données sur plusieurs cœurs. Des algorithmes tels que le tri fusion et le tri rapide fonctionnent à partir d'une stratégie « diviser pour mieux régner » et sont simples à mettre en œuvre d'une manière qui tire parti d'un système multicœurs.

Voyons comment le tri fusion fonctionne sur la chaîne d'entiers aléatoires illustrée dans la figure 2. La première étape consiste à choisir un point médian dans le tableau et à diviser ce dernier en deux sous-listes. Continuez les divisions jusqu'à disposer de listes de zéro ou d'un élément de long.

image: Sorting a String of Random Integers

Figure 2 Tri d'une chaîne d'entiers aléatoires

Dans la plupart des implémentations, il existe une limite de taille de liste sous laquelle fonctionne un algorithme efficace, conçu pour les petites listes mais qui fonctionne également si vous continuez à diviser les travaux jusqu'à ce que vous ne pouviez plus rien diviser du tout. Il est important de noter qu'une fois une liste divisée en deux sous-listes, celles-ci sont indépendantes. Ceci est illustré par les pointillés rouges de la figure 2. Une fois les listes divisées en sous-listes, chacune de ces sous-listes est indépendante et vous pouvez attribuer chacune à un processeur pour qu'il la manipule comme il l'entend sans avoir à verrouiller quoique ce soit.

Pour que le tri soit aussi efficace que possible, choisissez un algorithme qui saura trier convenablement chaque sous-liste. Ceci est important non seulement pour empêcher toute copie non nécessaire des données, mais également pour que les données soient à l'abri dans le cache L2 du processeur. Pour optimiser vos efforts dans la rédaction de code parallèle de plus en plus efficace, il vous faut avoir conscience de la manière dont les données remplacent ou sont remplacées dans le cache L2, qui fait généralement dans les 256 Ko sur les processeurs les plus modernes.

Nombreux sont les algorithmes de tri qui se prêtent à la parallélisation. Tri rapide, tri par sélection, tri fusion et tri par base sont tous des algorithmes qui subdivisent les données et les font travailler indépendamment. Étudions donc l'implémentation de série d'une routine de tri et convertissons-la en implémentation parallèle.

En théorie, si vous continuez à sous-diviser un tableau de manière récursive, vous finirez par un seul élément. À ce stade, il n'y a rien à trier, l'algorithme passe alors à l'étape suivante, qui consiste à fusionner les sous-listes triées. Chaque élément est fusionné dans des listes plus grandes et triées. Ces sous-listes triées sont ensuite fusionnées dans des listes triées de plus grandes tailles jusqu'à ce que vous retrouviez le tableau d'origine dans un ordre trié. Comme mentionné précédemment, il est généralement plus rapide de passer à un algorithme optimisé pour trier les petites listes lorsque la taille des listes atteint un certain seuil.

Il existe de nombreuses manières de rédiger un algorithme de tri, et j'ai choisi de rédiger une simple routine de tri rapide dans C#, comme indiqué à la figure 3. Ce programme insère une même séquence de nombres aléatoires dans un grand tableau puis les trie à l'aide d'une routine de tri rapide qui signale le temps passé à effectuer la tâche.

Figure 3 Tri rapide

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;

namespace ParallelSort {
  class Program {
    // For small arrays, use Insertion Sort
    private static void InsertionSort(
      int[] list, int left, int right) {

      for (int i = left; i < right; i++) {
        int temp = list[i];
        int j = i;

        while ((j > 0) && (list[j - 1] > temp)) {
          list[j] = list[j - 1];
          j = j - 1;
        }
        list[j] = temp;
      }
    }

    private static int Partition(
      int[] array, int i, int j) {

      int pivot = array[i];

      while (i < j) {
        while (array[j] >= pivot && i < j) {
          j--;
        }

        if (i < j) {
          array[i++] = array[j];
        }

        while (array[i] <= pivot && i < j) {
          i++;
        }

        if (i < j) {
          array[j--] = array[i];
        }
      }

      array[i] = pivot;
      return i;
    }

    static void QuickSort(
      int[] array, int left, int right) {


      // Single or 0 elements are already sorted
      if (left >= right)
        return;

      // For small arrays, use a faster serial routine
      if ( right-left <= 32) {
        InsertionSort(array, left, right);
        return;
      }

      // Select a pivot, then quicksort each sub-array
      int pivot = Partition(array, left, right);

      QuickSort(array, left, pivot - 1);
      QuickSort(array, pivot + 1, right);
    }

    static void Main(string[] args) {

      const int ArraySize = 50000000;

      for (int iters = 0; iters < 1; iters++) {
        int[] array;
        Stopwatch stopwatch;

        array = new int[ArraySize];
        Random random1 = new Random(5);

        for (int i = 0; i < array.Length; ++i) {
          array[i] = random1.Next();
        }

        stopwatch = Stopwatch.StartNew();
        QuickSort(array, 0, array.Length - 1);
        stopwatch.Stop();

        // Verify it is sorted
        for (int i = 1; i < array.Length; ++i) 
          if (array[i - 1] > array[i - 1]) 
            throw new ApplicationException("Sort Failed");

        Console.WriteLine("Serialt: {0} ms",  
           stopwatch.ElapsedMilliseconds);
      }
    }
  }
}

Si vous jetez un œil à la fonction QuicSort, vous verrez qu'il divise le tableau en deux de manière récursive jusqu'à un certain seuil. Là, il trie la liste sans la sous-diviser davantage. Si vous voulez passer cela en version parallèle, il vous suffit de modifier ces deux lignes :

QuickSort( array, lo, pivot - 1);
QuickSort( array, pivot + 1, hi);

La version rendue parallèle est la suivante :

Parallel.Invoke(
  delegate { QuickSort(array, left, pivot - 1); },
  delegate { QuickSort(array, pivot + 1, right); }
);

L'interface Parallel.Invoke fait partie de l'espace de nom Systems.Threading.Tasks de la bibliothèque parallèle de tâches .NET. Celle-ci vous permet de spécifier une fonction à exécuter de manière asynchrone. Dans notre cas, je lui dis que je souhaite exécuter chaque fonction de tri sur un thread séparé.

Même s'il était plus efficace de générer uniquement un nouveau thread et d'utiliser le thread d'exécution actuel pour trier l'autre sous-liste, je souhaitais maintenir la symétrie et illustrer la facilité avec laquelle on convertit un programme en série en programme parallèle.

Utilisation des cœurs

La question suivante est évidente : Cette parallélisation a-t-elle amélioré les performances ?

Visual Studio 2010 comprend plusieurs outils permettant de comprendre où votre programme passe du temps et comment il se comporte en tant qu'application multithread. L'utilisation de ces outils de mesure des performances de votre application multithread avec Visual Studio 2010 est excellemment présentée dans le Magazine MSDN de septembre 2009, « Débogage des applications parallèles basées sur les tâches dans Visual Studio 2010 » de Stephen Toub et Daniel Moth (msdn.microsoft.com/magazine/ee410778). Il existe également une bonne vidéo d'introduction de Daniel Moth sur Channel 9 (channel9.msdn.com/posts/DanielMoth/Parallel-Tasks--new-Visual-Studio-2010-debugger-window/).

La programmation parallèle nécessite que vous fassiez effectivement des mesures de vérification de l'amélioration réelle des performances et de l'utilisation de tout le matériel. Pour en savoir plus sur la manière d'utiliser la parallélisation dans mon exemple d'application, utilisons ces outils pour mesurer les routines de tri en action. J'ai lancé l'assistant de performances de Visual Studio 2010 pour faire des mesures de concurrence de mon application de tri au cours de son exécution.

La première chose à regarder est l'utilisation des cœurs qui démontre l'utilisation que l'application a fait des cycles de processeurs disponibles. Mon programme test exécute le tri en série, se met en veille une seconde, puis exécute la version parallèle du tri. Sur mon ordinateur quadruple-cœurs, j'obtiens un graphique de l'utilisation des cœurs, illustré sur la figure 4. Le vert correspond à mon application, le jaune au système d'exploitation et à d'autres programmes et le gris est inactif. La ligne plate au niveau 1 cœur indique que j'ai complètement saturé le traitement sur un seul cœur en exécutant la version en série et que j'obtiens environ 2,25 cœurs sur 4 en exécutant la version parallèle. Il n'est pas surprenant que le temps d'exécution du tri parallèle représente 45 % du temps requis pour le tri en série. Résultat plutôt impressionnant pour deux lignes de code modifiées.

image: Core Utilization

Figure 4 Utilisation des cœurs

Passons maintenant du graphique d'utilisation des processeurs à l'affichage des threads illustré à la figure 5 qui indique comment l'application a employé les threads disponibles. Remarquez que, pour la majeure partie du temps d'exécution, il y a un seul thread. Ce n'est qu'en commençant à générer des tâches que d'autres threads se créent. Sur cet affichage, la couleur saumon indique un thread bloqué par un autre thread.

image: Thread Work

Figure 5 Travail de thread

En fait, l'affichage des threads indique que, bien que la vitesse d'exécution ait augmenté de manière significative, je n'ai pas été très efficace. Il est parfaitement normal d'avoir un blocage de thread, en attendant d'autres threads comme lorsque le thread principal attend la fin des tâches. Cela dit, ce que vous voulez vraiment voir, c'est du vert sur autant de tâches que vous avez de cœurs de processeurs. Ainsi, même si le graphique d'utilisation des processeurs indique une amélioration au niveau des cœurs de processeurs, en regardant de plus près à la manière dont les tâches ont été réparties sur le pool de thread, vous distinguez de la place pour optimiser cela.

En fait, vous devriez toujours mesurer les performances de votre code après le travail de mise en multithread, même pour du travail aussi simple que ce que j'ai fait ici. Pour les petits travaux, inutile de faire du multithread car les frais dépasseraient toute performance des threads. Pour les travaux de plus grande taille, vous pouvez les diviser en autant de sections qu'il y a de cœurs de processeurs disponibles afin de ne pas donner trop d'attributions au pool de thread.

Et maintenant ?

Il existe un certain nombre de manières d'améliorer encore les performances à partir du code, mais ce n'est pas l'objectif de ce premier article. Mais vous avez pu voir comment obtenir une utilisation des processeurs de 80 % simplement en faisant quelques modifications au code qui le rendent compatible aux threads. Plutôt que d'optimiser davantage ce code, cependant, nous allons nous concentrer sur l'obtention des performances maximum des processeurs d'un système en organisant un peu différemment l'architecture des travaux.

Trier comme je viens de le démontrer est particulièrement adaptable au multithread. Vous pouvez calculer le degré de division du travail, puis attribuer chaque sous-travail à un thread. Cela dit, même si j'ai obtenu une accélération de mes performances, j'ai effectivement oublié des performances.

Mais dans les applications réelles, vous pouvez vous trouver dans une situation où vous avez de nombreux travaux qui vous donnent des groupes de tâches uniques ou qui éventuellement ne connaissent pas le temps d'exécution d'une tâche donnée et qui vont devoir planifier les tâches en fonction de cette incertitude. C'est un problème particulièrement difficile. Dans mon prochain article, je vais étudier une architecture qui suit une approche holistique du thread et va vous permettre de répartir plusieurs travaux, éventuellement très différents. Je vous montrerai comment créer l'architecture d'une application afin de la rendre compatible au multicoeurs dès le départ via l'utilisation de tâches et de pools de thread.

Ron Fosner optimise des applications et des jeux hautes performances sous Windows depuis 20 ans, et il commence à maîtriser le sujet. C'est un expert des images et de l'optimisation chez Intel et son grand bonheur consiste à voir tous les cœurs de processeurs tourner à fond. Vous pouvez le contacter à l'adresse Ron@directx.com.

Je remercie l'expert technique suivant d'avoir relu cet article : Stephen Toub