Frédéric Mélantois
Manipulation d'images en C#, vers la performance
Nous chercherons à augmenter la performance de nos traitements via quelques petites « recettes »
Par Frédéric Mélantois publié le 01/02/2007 à 10:11, lu 8087 fois, 5 pages
 3 | Tableaux et action du garbage collector, stackalloc
Nous n'avons pas achevé l'optimisation de notre code. Lorsque nous créons des tableaux de valeurs, ils sont soumis aux actions du garbage collector (ramasse-miette) car ils se trouvent en mémoire sur ce qu'on appelle le tas managé. Concrêtement, les performances de notre code vont donc dépendre du fonctionnement du garbage collector. N'y a t-il pas des possibilités pour atténuer les effets de ce comportement ? Si vous avez un peu étudié le fonctionnement du garbage collector de la plate-forme .NET, vous aurez noté que celui-ci intégre la notion de génération d'objets, afin de limiter le temps de traitement. C'est ainsi que les objects de génération 0 (en théorie, les plus jeunes) sont collectés plus souvent que les objets (plus anciens) de génération 1 ou 2. Il serait donc intéressant de pouvoir contrôler la mise sous une génération plus élevée certains de nos objets afin de réduire la nécessité d'intervention du garbage collector sur les basses générations. Malheureusement, il n'est pas possible d'avoir un tel contrôle. Après tout, on en attend pas moins d'un code dit managé. Faisons le postulat suivant : tous les objets nouvellement créés sont de génération 0. Alors, la fréquence d'intervention du garbage collector se trouve lié à l'espace occupé dans la zone alouée pour cette generation sur le tas managé. L'espace occupé est donc fonction du nombre d'objets et de la taille des objets. En clair, plus on occupe l'espace réservé à la génération par de gros objets, plus le garbage collector est sollicité, avec la nécessité de déplacer ces gros objets vers une génération plus élevée. C'est ce constat qu'ont fait les concepteurs du garbage collector. Il devient alors évident qu'il est souhaitable de créer les objets volumineux directement dans une génération plus élevée. Ainsi les tableaux de grande occupation mémoire sont créés directement dans la zone mémoire dédiée aux objets de génération 2. Il est facile de déterminer le nombre d'octets nécessaires pour qu'un tableau puisse se trouver directement dans cette zone, plutôt que dans la zone dédiée aux objets de génération 0.

byte[] tableau1 = new byte[84987];

byte[] tableau2 = new byte[84988];

 

MessageBox.Show(GC.GetGeneration(tableau1).ToString());

MessageBox.Show(GC.GetGeneration(tableau2).ToString());

Globalement, les tableaux de plus de 85000 octets sont créés directement en génération 2. Il n'est évidemment pas certain que ce nombre n'évolue pas dans les prochaines versions. Actuellement, nous pouvons toutefois nous fixer comme règle de définir des tableaux d'au moins 85000 octets lorsqu'on a des tableaux inférieurs en taille mais proche de cette valeur. Par exemple, si on est en présence d'un tableau de 60000 octets, on aura tout intérêt de le créer avec 85000 entrées de bytes. Mais que se passe t-il pour les tableaux bien inférieurs en taille, doit-on subir cette règle pénalisante lorsqu'on a du code très performant à produire ? Il existe une alternative qui demande de produire du code dit unsafe (c'est à dire, non vérifiable) ce qui est notre cas pour traiter les images avec le maximum de performance. On utilise pour cela l'instruction c# « stackalloc ». La documentation nous indique grossièrement que cette instruction permet d'allouer un certain nombre d'octets consécutifs sur une pile. L'instruction « stackalloc » pourrait nous permettre de créer des tableaux d'octets de petites tailles sur une pile dont nous ne savons pratiquement rien. On peut s'attendre par la notion de pile à ce que le garbage collector n'interviennent pas dans la gestion d'un tel tableau, ce qui résoudrait nos problèmes de performance. Une révision de la documentation indique effectivement que cette pile n'est pas soumise aux actions du garbage collector et que sa durée de vie est limitée au temps d'exécution de la méthode courante. Observons la syntaxe et la mise en oeuvre de cette instruction :

unsafe

{

    byte* monTableau = stackalloc byte[50];

    byte* monTableau2 = stackalloc byte[50];

 

    byte cpt = 0;

    do

    {

        monTableau[cpt] = 5;

        monTableau2[cpt] = 3;

    }

    while (cpt++ < 49);

 

    MessageBox.Show(monTableau[0].ToString());

    MessageBox.Show(monTableau[49].ToString());

    MessageBox.Show(monTableau2[0].ToString());

    MessageBox.Show(monTableau2[49].ToString());

}

Nous venons de définir deux tableaux de 50 octets sur la pile que nous venons de remplir par des valeurs différentes. Cette allocation se fait dans un bloc unsafe. Nous manipulons un pointeur. Et à partir de ce pointeur, nous avons la pleine responsabilité de nos allocations d'octets, c'est à dire que si nous débordons de la zone allouée, les conséquences peuvent être très importantes. C'est pourquoi on parle de code non vérifiable. Essayons de déborder pour voir ce qu'il se produit en changeant la condition « while » :

while (cpt++ < 51);

Nous obtenons des résultats en lecture en parfaite cohérence. On ne remarque pas de chevauchement de valeurs dans les tableaux et pourtant nous sommes sur une pile. La raison en est simple, l'allocation de la mémoire dans la pile se fait par tranche de 4 octets. Comme notre réservation ne fait que 50 octets, le premier multiple de 4 est donc 52. Ainsi, si dans notre tableau, nous affectons la 53ème valeur alors que la zone réservée n'était que de 50 octets, nous modifions la zone réservée suivante à l'indice 0. Voici une démonstration, des incohérences que l'on peut produire si on n'y prend pas garde :

unsafe

{

    byte* monTableau = stackalloc byte[50];

    byte* monTableau2 = stackalloc byte[50];

 

    byte cpt = 0;

    do

    {

        monTableau[cpt] = 5;

        monTableau2[cpt] = 3;

    }

    while (cpt++ < 52);

 

    MessageBox.Show(monTableau[0].ToString());

    MessageBox.Show(monTableau[52].ToString());

    MessageBox.Show(monTableau2[0].ToString());

    MessageBox.Show(monTableau2[52].ToString());

}

Il convient donc de porter toute l'attention sur la manipulation de ces octets. Un degré de liberté nous est offert par l'utilisation de l'instruction « stackalloc » et des pointeurs, tout ceci échappant au vérification du compilateur C# et du compilateur Just-In-Time, c'est pourquoi on parle de code invérifiable.
Une question importante se pose : l'utilisation de « stackalloc » permet-elle un gain de performance ? La réponse est effectivement oui car nous échappons à l'action du Garbage Collector. Des outils tels que Dev Partner, ou même la simple utilisation de « StopWatch » de « System.Diagnostics » permettent de mettre en évidence et de quantifier le gain de performance obtenu. A noter, que la pile liée à la méthode courante possède une taille limitée de quelques milliers d'octets. On veillera donc à ne pas provoquer un débordement de pile. Pour cela, il est recommandable de laisser dans le tas managé les tableaux de plus de 85000 octets. Les tableaux de moins de 85000 octets seront astucieusement remplacés, si leur action est limitée à la méthode courante, par une allocation via l'instruction c# « stackalloc ». On veillera à ne pas créer trop de tableaux de cette manière dans la méthode courante afin de ne pas avoir un débordement de pile.

//tables de précalculs des multiplications pour le calcul du niveau de gris

int* multiBleu = stackalloc int[256];

int* multiVert = stackalloc int[256];

int* multiRouge = stackalloc int[256];

Si, nous revenons à notre code initial, la modification des tables de pré-calculs fait gagner un temps précieux dans la boucle de parcours des pixels de l'image. De gros gains sont ainsi obtenus. Une des questions que l'on peut se poser, c'est pourquoi ne pas en faire autant pour le tableau appelé « pattern » ? On peut effectivement le faire mais l'initialisation risque de se faire entrée par entrée, ce qui ne serait pas idéal pour l'écriture et la relecture de notre code. Essayons de conserver notre tableau de 64 entrées dans le tas managé et tentons de copier le contenu dans une zone réservée dans la pile de la méthode courante pour gagner en temps d'accès dans le parcours et le traitement de tous les pixels de l'image.

//le pattern a appliquer sur des niveaux de gris de 64

byte[] pat = new byte[64]{0,32,8,40,34,2,10,42,

                        48,16,56,24,50,18,58,26,

                        12,44,4,36,14,46,6,38,

                        60,28,52,20,62,30,54,22,

                        3,35,11,43,1,33,9,41,

                        51,19,59,27,49,17,57,25,

                        15,47,7,39,13,45,5,37,

                        63,31,55,23,61,29,53,21};

 

byte* pattern = stackalloc byte[64];

 

for (int i = 0; i < 64; i++)

    pattern[i] = pat[i];

Nous parvenons à réaliser un gain de temps. Toutefois, il serait souhaitable de pouvoir copier le contenu d'un tableau vers un autre tableau via une sorte de « memcpy », au lieu de « balayer » les entrées du tableau. Mais cette instruction n'existe pas en c#. Nous devons avoir recours à la méthode « Marshal.Copy » de l'espace de nom « System.Diagnostics ».

Marshal.Copy(pat, 0, new IntPtr(pattern), 64);

Cette façon de copier les données d'un tableau vers un autre permet de gagner toutefois du temps dans notre cas de figure. Dans la boucle de parcours des pixels, nous travaillons sur un pointeur dans la pile réservée aux allocations de type « stackalloc » de la méthode courante. Ceci est beaucoup plus rapide que de travailler à partir d'une référence d'un objet de type tableau situé dans le tas managé.
 
» Démarrer une discussion
 
Discussion démarée par Frédéric Mélantois le 07/05/2007 à 11:28, 1 commentaire(s).
Discussion démarée par Frédéric Mélantois le 07/05/2007 à 11:21, 1 commentaire(s).