Partager via


Écriture High-Performance applications managées : introduction

 

Gregor Noriskin
Équipe de performances Microsoft CLR

Juin 2003

S’applique à :
   Microsoft® .NET Framework

Résumé: Découvrez le Common Language Runtime du .NET Framework du point de vue des performances. Découvrez comment identifier les meilleures pratiques en matière de performances de code managé et comment mesurer les performances de votre application managée. (19 pages imprimées)

Téléchargez clr Profiler. (330 Ko)

Contenu

Jongler comme métaphore du développement logiciel
The .NET Common Language Runtime
Données managées et garbage collector
Profils d’allocation
API de profilage et CLR Profiler
Hébergement du gc de serveur
Finalisation
Modèle de suppression
Note sur les références faibles
Code managé et JIT CLR
Types valeur
Gestion des exceptions
Threading et synchronisation
Réflexion
Liaison tardive
Sécurité
COM Interop et Appel de plateforme
Compteurs de performances
Autres outils
Conclusion
Ressources

Jongler comme métaphore du développement logiciel

Le jonglage est une excellente métaphore pour décrire le processus de développement logiciel. Le jonglage nécessite généralement au moins trois éléments, bien qu’il n’existe aucune limite supérieure au nombre d’éléments que vous pouvez tenter de jongler. Lorsque vous commencez à apprendre à jongler, vous constatez que vous watch chaque balle individuellement lorsque vous les attrapez et les lancer. Au fur et à mesure que vous progressez, vous commencez à vous concentrer sur le flux des balles, par opposition à chaque balle individuelle. Quand vous maîtrisez la jonglerie, vous pouvez à nouveau vous concentrer sur une seule balle, en équilibrant cette balle sur votre nez, tout en continuant à jongler avec les autres. Vous savez intuitivement où les balles vont être et vous pouvez mettre votre main au bon endroit pour les attraper et les lancer. Comment est-ce comme le développement logiciel ?

Différents rôles dans le processus de développement logiciel jonglent entre différentes « trinités » ; Les responsables de projet et de programme jonglent entre les fonctionnalités, les ressources et le temps, et les développeurs de logiciels jonglent avec l’exactitude, les performances et la sécurité. On peut toujours essayer de jongler avec plus d’éléments, mais comme tout étudiant en jonglerie peut l’attester, l’ajout d’une seule balle rend exponentiellement plus difficile de garder les boules en l’air. Techniquement, si vous jonglez avec moins de trois balles, vous ne jonglez pas du tout. Si, en tant que développeur de logiciels, vous n’envisagez pas l’exactitude, les performances et la sécurité du code que vous écrivez, vous pouvez faire valoir que vous ne faites pas votre travail. Lorsque vous commencez à prendre en compte l’exactitude, les performances et la sécurité, vous devez vous concentrer sur un aspect à la fois. Comme ils font partie de votre pratique quotidienne, vous constaterez que vous n’avez pas besoin de vous concentrer sur un aspect spécifique, ils feront simplement partie de votre façon de travailler. Une fois que vous les maîtrisez, vous serez en mesure de faire des compromis intuitivement et de concentrer vos efforts de manière appropriée. Et comme pour la jonglerie, la pratique est la clé.

L’écriture de code hautes performances a une trinité qui lui est propre ; Définition des objectifs, mesure et compréhension de la plateforme cible. Si vous ne savez pas à quelle vitesse votre code doit être rapide, comment saurez-vous quand vous avez terminé ? Si vous ne mesurez pas et ne profilez pas votre code, comment saurez-vous quand vous avez atteint vos objectifs ou pourquoi vous n’avez pas atteint vos objectifs ? Si vous ne comprenez pas la plateforme que vous ciblez, comment allez-vous savoir ce qu’il faut optimiser dans le cas où vous ne répondez pas à vos objectifs. Ces principes s’appliquent au développement de code hautes performances en général, quelle que soit la plateforme que vous ciblez. Aucun article sur l’écriture de code hautes performances ne serait complet sans mentionner cette trinité. Bien que les trois soient tout aussi significatifs, cet article va se concentrer sur les deux derniers aspects, car ils s’appliquent à l’écriture d’applications hautes performances qui ciblent Microsoft® .NET Framework.

Les principes fondamentaux de l’écriture de code hautes performances sur n’importe quelle plateforme sont les suivants :

  1. Définir des objectifs de performances
  2. Mesurer, mesurer, puis mesurer un peu plus
  3. Comprendre les plateformes matérielles et logicielles que votre application cible

The .NET Common Language Runtime

Le cœur du .NET Framework est le Common Language Runtime (CLR). Le CLR fournit tous les services d’exécution pour votre code ; Compilation juste-à-temps, gestion de la mémoire, sécurité et un certain nombre d’autres services. Le CLR a été conçu pour être hautement performant. Cela dit, il existe des façons de tirer parti de ces performances et des façons de les entraver.

L’objectif de cet article est de donner une vue d’ensemble du Common Language Runtime du point de vue des performances, d’identifier les meilleures pratiques en matière de performances du code managé et de montrer comment vous pouvez mesurer les performances de votre application managée. Cet article n’est pas une discussion exhaustive sur les caractéristiques de performances du .NET Framework. Pour les besoins de cet article, je vais définir les performances pour inclure le débit, la scalabilité, le temps de démarrage et l’utilisation de la mémoire.

Données managées et garbage collector

L’une des principales préoccupations des développeurs concernant l’utilisation du code managé dans les applications critiques en matière de performances est le coût de la gestion de la mémoire du CLR, qui est effectuée par le Garbage Collector (GC). Le coût de la gestion de la mémoire est fonction du coût d’allocation de la mémoire associé à un instance d’un type, du coût de gestion de cette mémoire pendant la durée de vie de l’instance et du coût de la libération de cette mémoire quand elle n’est plus nécessaire.

Une allocation managée est généralement très bon marché; dans la plupart des cas prenant moins de temps qu’un C/C++ malloc ou new. Cela est dû au fait que le CLR n’a pas besoin d’analyser une liste libre pour trouver le prochain bloc contigu de mémoire disponible suffisamment grand pour contenir le nouvel objet ; il conserve un pointeur vers la position libre suivante dans la mémoire. On peut considérer les allocations de tas managées comme étant de type pile. Une allocation peut provoquer une collection si le GC doit libérer de la mémoire pour allouer le nouvel objet, auquel cas l’allocation est plus coûteuse qu’un malloc ou new. Les objets épinglés peuvent également affecter le coût d’allocation. Les objets épinglés sont des objets que le GC a été invité à ne pas déplacer pendant une collection, généralement parce que l’adresse de l’objet a été passée à une API native.

Contrairement à ou mallocnew, il existe un coût associé à la gestion de la mémoire pendant la durée de vie d’un objet. Le CLR GC est générationnel, ce qui signifie que le tas entier n’est pas toujours collecté. Toutefois, le GC doit toujours savoir si des objets actifs dans le reste des objets racine du tas dans la partie du tas qui est collectée. La mémoire qui contient des objets qui contiennent des références à des objets dans les jeunes générations est coûteuse à gérer pendant la durée de vie des objets.

Le GC est un récupérateur de marque et de balayage générationnel. Le tas managé contient trois générations ; La génération 0 contient tous les nouveaux objets, la génération 1 contient des objets à durée de vie légèrement plus longue et la génération 2 contient des objets à longue durée de vie. Le GC collecte la plus petite section du tas possible pour libérer suffisamment de mémoire pour que l’application continue. La collection d’une génération comprend la collection de toutes les jeunes générations, dans ce cas une collection de génération 1 collecte également la génération 0. La génération 0 est dimensionnée dynamiquement en fonction de la taille du cache du processeur et du taux d’allocation de l’application, et prend généralement moins de 10 millisecondes pour collecter. La génération 1 est dimensionnée dynamiquement en fonction du taux d’allocation de l’application et prend généralement entre 10 et 30 millisecondes pour collecter. La taille de génération 2 dépend du profil d’allocation de votre application, ainsi que du temps nécessaire à la collecte. Ce sont ces collections de génération 2 qui affectent le plus considérablement le coût de performances de la gestion de la mémoire de vos applications.

INDICE Le GC est auto-paramétré et s’ajuste en fonction des besoins en mémoire des applications. Dans la plupart des cas, l’appel d’un GC par programmation entrave ce paramétrage. « Aider » le GC en appelant GC. Collecter n’améliorera probablement pas les performances de vos applications.

Le GC peut déplacer des objets en direct pendant une collection. Si ces objets sont volumineux, le coût de déplacement est élevé, de sorte que ces objets sont alloués dans une zone spéciale du tas appelée tas d’objets volumineux. Le tas d’objets volumineux est collecté, mais n’est pas compacté. Par exemple, les objets volumineux ne sont pas déplacés. Les objets volumineux sont les objets dont la taille est supérieure à 80 Ko. Notez que cela peut changer dans les versions futures du CLR. Lorsque le tas d’objets volumineux doit être collecté, il force une collection complète, et le tas d’objets volumineux est collecté pendant les collections Gen 2. L’allocation et le taux de mort des objets dans le tas d’objets volumineux peuvent avoir un effet significatif sur le coût de performances de la gestion de la mémoire de vos applications.

Profils d’allocation

Le profil d’allocation global d’une application managée définit la difficulté du Garbage Collector à gérer la mémoire associée à l’application. Plus le GC doit travailler dur pour gérer la mémoire, plus le nombre de cycles d’UC que le GC prend, et moins le processeur passe de temps à exécuter le code de l’application. Le profil d’allocation est une fonction du nombre d’objets alloués, de la taille de ces objets et de leur durée de vie. La façon la plus évidente de soulager la pression du GC consiste simplement à allouer moins d’objets. Les applications conçues pour l’extensibilité, la modularité et la réutilisation à l’aide de techniques de conception orientée objet entraînent presque toujours une augmentation du nombre d’allocations. Il y a une pénalité de performance pour l’abstraction et « l’élégance ».

Un profil d’allocation compatible GC aura des objets alloués au début de l’application, puis survivront pendant la durée de vie de l’application, puis tous les autres objets étant de courte durée. Les objets à longue durée de vie contiennent peu ou pas de références à des objets à courte durée de vie. Comme le profil d’allocation s’en écarte, le GC devra travailler plus dur pour gérer la mémoire des applications.

Un profil d’allocation non amicale gc aura de nombreux objets survivant dans la génération 2, puis mourant, ou aura de nombreux objets de courte durée alloués dans le tas d’objets volumineux. Les objets qui survivent suffisamment longtemps pour entrer dans la génération 2, puis meurent, sont les plus coûteux à gérer. Comme je l’ai mentionné précédemment, les objets dans les générations plus anciennes qui contiennent des références à des objets dans les jeunes générations pendant un GC, augmentent également le coût de la collection.

Un profil d’allocation du monde réel classique se trouve quelque part entre les deux profils d’allocation mentionnés ci-dessus. Une métrique importante de votre profil d’allocation est le pourcentage du temps processeur total passé dans GC. Vous pouvez obtenir ce nombre à partir du compteur de performances .NET CLR Memory: % Time in GC . Si la valeur moyenne de ce compteur est supérieure à 30 %, vous devriez probablement envisager d’examiner de plus près votre profil d’allocation. Cela ne signifie pas nécessairement que votre profil d’allocation est « mauvais » ; il existe certaines applications nécessitant beaucoup de mémoire pour lesquelles ce niveau de GC est nécessaire et approprié. Ce compteur doit être la première chose que vous examinez si vous rencontrez des problèmes de performances ; il doit immédiatement indiquer si votre profil d’allocation fait partie du problème.

INDICE Si le compteur de performances Mémoire CLR .NET : % temps dans GC indique que votre application passe en moyenne plus de 30 % de son temps dans GC, vous devez examiner de plus près votre profil d’allocation.

INDICE Une application compatible GC aura beaucoup plus de collections de génération 0 que de collections de génération 2. Ce ratio peut être établi en comparant les compteurs de performances Mémoire NET CLR : # Gen 0 Collections et Mémoire NET CLR : # Gen 2 Collections.

API de profilage et CLR Profiler

Le CLR inclut une API de profilage puissante qui permet aux tiers d’écrire des profils personnalisés pour les applications managées. ClR Profiler est un exemple d’outil de profilage d’allocation non pris en charge, écrit par l’équipe produit CLR, qui utilise cette API de profilage. Le CLR Profiler permet aux développeurs de voir le profil d’allocation de leurs applications de gestion.

Figure 1 FENÊTRE PRINCIPALE DU PROFILEUR CLR

Le profileur CLR comprend un certain nombre d’affichages très utiles du profil d’allocation, notamment un histogramme des types alloués, des graphiques d’allocation et d’appel, une ligne de temps montrant les GCs de différentes générations et l’état résultant du tas géré après ces collections, ainsi qu’une arborescence d’appels montrant les allocations par méthode et les charges d’assembly.

Figure 2 CLR Profiler Allocation Graph

INDICE Pour plus d’informations sur l’utilisation du CLR Profiler, consultez le fichier readme inclus dans le fichier zip.

Notez que clr Profiler a une surcharge de performances élevée et modifie considérablement les caractéristiques de performances de votre application. Les bogues de stress émergents disparaîtront probablement lorsque vous exécutez votre application avec clr Profiler.

Hébergement du gc de serveur

Deux garbage collectors différents sont disponibles pour le CLR : un GC de station de travail et un GC de serveur. Les applications console et Windows Forms hébergent le GC de station de travail, et ASP.NET hébergent le gc de serveur. Le gc de serveur est optimisé pour le débit et la scalabilité multiprocesseur. Le gc du serveur interrompt tous les threads exécutant du code managé pendant toute la durée d’une collection, y compris les phases de marquage et de balayage, et gc se produit en parallèle sur tous les processeurs disponibles pour le processus sur des threads dédiés à affinité avec le processeur de haute priorité. Si les threads exécutent du code natif pendant un GC, ces threads sont suspendus uniquement lorsque l’appel natif est retourné. Si vous créez une application serveur qui va s’exécuter sur des ordinateurs multiprocesseurs, il est vivement recommandé d’utiliser le gc de serveur. Si votre application dans n’est pas hébergée par ASP.NET, vous devrez écrire une application native qui héberge explicitement le CLR.

INDICE Si vous créez des applications serveur évolutives, hébergez le serveur GC. Consultez Implémenter un hôte Common Language Runtime personnalisé pour votre application managée.

Le GC de station de travail est optimisé pour une faible latence, ce qui est généralement requis pour les applications clientes. On ne souhaite pas une pause notable dans une application cliente pendant un GC, car en général, les performances du client ne sont pas mesurées par le débit brut, mais plutôt par les performances perçues. Le GC de station de travail effectue le GC simultané, ce qui signifie qu’il effectue la phase de marquage pendant que le code managé est toujours en cours d’exécution. Le GC interrompt uniquement les threads qui exécutent du code managé lorsqu’il doit effectuer la phase de balayage. Dans le GC de station de travail, gc est effectué sur un seul thread et donc sur un seul processeur.

Finalisation

Le CLR fournit un mécanisme par lequel propre-up est effectué automatiquement avant que la mémoire associée à un instance d’un type soit libérée. Ce mécanisme est appelé Finalisation. En règle générale, la finalisation est utilisée pour libérer des ressources natives, dans ce cas connexions aux bases de données ou des handles de système d’exploitation qui sont utilisés par un objet.

La finalisation est une fonctionnalité coûteuse qui augmente la pression exercée sur le GC. Le GC suit les objets qui nécessitent la finalisation dans une file d’attente finalisable. Si, au cours d’une collection, le GC trouve un objet qui n’est plus actif, mais nécessite la finalisation, l’entrée de cet objet dans la file d’attente finalisable est déplacée vers la file d’attente FReachable. La finalisation se produit sur un thread distinct appelé Thread Finaliser. Étant donné que l’état entier de l’objet peut être requis pendant l’exécution du Finaliser, l’objet et tous les objets vers 2000 sont promus vers la génération suivante. La mémoire associée à l’objet, ou graphique d’objets, n’est libérée que pendant le GC suivant.

Les ressources qui doivent être libérées doivent être encapsulées dans un objet Finalizable aussi petit que possible ; par instance, si votre classe nécessite des références à des ressources managées et non managées, vous devez encapsuler les ressources non managées dans une nouvelle classe Finalizable et faire de cette classe un membre de votre classe. La classe parente ne doit pas être finalisable. Cela signifie que seule la classe qui contient les ressources non managées sera promue (en supposant que vous ne détenez pas de référence à la classe parente dans la classe contenant les ressources non managées). Une autre chose à garder à l’esprit est qu’il n’y a qu’un seul thread de finalisation. Si un Finaliser bloque ce thread, les finaliseurs suivants ne sont pas appelés, les ressources ne sont pas libérées et votre application fuit.

INDICE Les finaliseurs doivent être conservés aussi simples que possible et ne doivent jamais bloquer.

INDICE Rendre finalisable uniquement la classe wrapper autour des objets non managés qui doivent être nettoyés.

La finalisation peut être considérée comme une alternative au comptage des références. Un objet qui implémente le comptage des références effectue le suivi du nombre d’autres objets qui ont des références à celui-ci (ce qui peut entraîner des problèmes bien connus), de sorte qu’il peut libérer ses ressources lorsque son nombre de références est égal à zéro. Le CLR n’implémente pas le comptage des références. Il doit donc fournir un mécanisme permettant de libérer automatiquement des ressources lorsqu’aucune référence à l’objet n’est conservée. La finalisation est ce mécanisme. La finalisation n’est généralement nécessaire que dans le cas où la durée de vie d’un objet qui nécessite propre-up n’est pas explicitement connue.

Modèle de suppression

Dans le cas où la durée de vie de l’objet est explicitement connue, les ressources non managées associées à un objet doivent être libérées avec impatience. C’est ce qu’on appelle « Suppression » de l’objet. Le modèle de disposition est implémenté via l’interface IDisposable (même si son implémentation vous-même serait triviale). Si vous souhaitez que la finalisation soit disponible pour votre classe, par exemple, rendre les instances de votre classe jetables, vous devez faire en sorte que votre objet implémente l’interface IDisposable et fournisse une implémentation pour la méthode Dispose . Dans la méthode Dispose , vous allez appeler le même code de nettoyage que celui du Finaliser et informer le GC qu’il n’a plus besoin de finaliser l’objet en appelant le GC. Méthode SuppressFinalization . Il est recommandé de faire en sorte que la méthode Dispose et le finaliseur appellent une fonction de finalisation commune afin qu’une seule version du code propre soit conservée. En outre, si la sémantique de l’objet est telle qu’une méthode Close est plus logique qu’une méthode Dispose , une méthode Close doit également être implémentée ; dans ce cas, une connexion de base de données ou un socket sont logiquement « fermés ». Close peut simplement appeler la méthode Dispose.

Il est toujours recommandé de fournir une méthode Dispose pour les classes avec un finaliseur ; on ne peut jamais être sûr de la façon dont cette classe sera utilisée, pour instance, si sa durée de vie sera explicitement connue ou non. Si une classe que vous utilisez implémente le modèle Dispose et que vous savez explicitement quand vous avez terminé l’objet, appelez certainement Dispose.

INDICE Fournissez une méthode Dispose pour toutes les classes finalisables.

INDICE Supprimez la finalisation dans votre méthode Dispose .

INDICE Appelez une fonction de nettoyage commune.

INDICE Si un objet que vous utilisez implémente IDisposable et que vous savez que l’objet n’est plus nécessaire, appelez Dispose.

C# fournit un moyen très pratique de supprimer automatiquement des objets. Le using mot clé vous permet d’identifier un bloc de code après lequel Dispose sera appelé sur un certain nombre d’objets jetables.

C# utilise mot clé

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

Note sur les références faibles

Toute référence à un objet qui se trouve sur la pile, dans un registre, dans un autre objet ou dans l’un des autres GC Roots maintient un objet actif pendant un GC. C’est généralement une très bonne chose, étant donné que cela signifie généralement que votre application n’est pas terminée avec cet objet. Toutefois, il existe des cas où vous souhaitez avoir une référence à un objet, mais ne souhaitez pas affecter sa durée de vie. Dans ce cas, le CLR fournit un mécanisme appelé Références faibles pour ce faire. Toute référence forte (pour instance, référence qui racine un objet) peut être transformée en référence faible. Par exemple, lorsque vous souhaitez utiliser des références faibles, vous souhaitez créer un objet de curseur externe qui peut parcourir une structure de données, mais qui ne doit pas affecter la durée de vie de l’objet. Un autre exemple est si vous souhaitez créer un cache qui est vidé en cas de pression de la mémoire ; pour instance, lorsqu’un GC se produit.

Création d’une référence faible en C#

MyRefType mrt = new MyRefType();
//...

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

Code managé et JIT CLR

Les assemblys managés, qui sont l’unité de distribution du code managé, contiennent un langage indépendant du processeur appelé Microsoft Intermediate Language (MSIL ou IL). Le clr juste-à-temps (JIT) compile l’il dans des instructions X86 natives optimisées. Le JIT est un compilateur qui optimise, mais étant donné que la compilation se produit au moment de l’exécution et que seule la première fois qu’une méthode est appelée, le nombre d’optimisations qu’elle fait doit être équilibré par rapport au temps nécessaire pour effectuer la compilation. En règle générale, cela n’est pas critique pour les applications serveur, car le temps de démarrage et la réactivité ne sont généralement pas un problème, mais il est essentiel pour les applications clientes. Notez que le temps de démarrage peut être amélioré en effectuant la compilation au moment de l’installation à l’aide de NGEN.exe.

La plupart des optimisations effectuées par le JIT n’ont pas de modèles programmatiques associés. Par instance, vous ne pouvez pas les coder explicitement, mais il y en a un certain nombre. La section suivante décrit certaines de ces optimisations.

INDICE Améliorez le temps de démarrage des applications clientes en compilant votre application au moment de l’installation, à l’aide de l’utilitaire NGEN.exe.

Inlining de méthode

Il existe un coût associé aux appels de méthode ; les arguments doivent être envoyés (push) sur la pile ou stockés dans des registres, le prologue de méthode et l’épilogue doivent être exécutés, etc. Le coût de ces appels peut être évité pour certaines méthodes en déplaçant simplement le corps de la méthode appelée dans le corps de l’appelant. C’est ce qu’on appelle la méthode in-lining. Le JIT utilise un certain nombre d’heuristiques pour décider si une méthode doit être alignée. Voici une liste des plus significatives (notez que ce n’est pas exhaustif) :

  • Les méthodes dont la taille est supérieure à 32 octets d’IL ne sont pas incluses.
  • Les fonctions virtuelles ne sont pas incluses.
  • Les méthodes qui ont un contrôle de flux complexe ne sont pas alignées. Le contrôle de flux complexe est tout contrôle de flux autre que if/then/else; dans ce cas, switch ou while.
  • Les méthodes qui contiennent des blocs de gestion des exceptions ne sont pas incluses, bien que les méthodes qui lèvent des exceptions soient toujours candidates à l’incorporation.
  • Si l’un des arguments formels de la méthode est des structs, la méthode n’est pas incluse.

J’envisagerais soigneusement de coder explicitement pour ces heuristiques, car elles pourraient changer dans les versions futures du JIT. Ne compromettez pas l’exactitude de la méthode pour tenter de garantir qu’elle sera insérée. Il est intéressant de noter que les inline mots clés et __inline en C++ ne garantissent pas que le compilateur inlinera une méthode (si __forceinline ).

Les méthodes get et set de propriété sont généralement de bons candidats pour l’incorporation, car elles ne font généralement que initialiser des membres de données privés.

**HINT **Ne pas compromettre l’exactitude d’une méthode pour tenter de garantir l’incorporation.

Élimination de la vérification de la plage

L’un des nombreux avantages du code managé est la vérification automatique de la plage ; chaque fois que vous accédez à un tableau à l’aide de la sémantique array[index], le JIT émet un case activée pour s’assurer que l’index se trouve dans les limites du tableau. Dans le contexte de boucles avec un grand nombre d’itérations et un petit nombre d’instructions exécutées par itération, ces vérifications de plage peuvent être coûteuses. Dans certains cas, le JIT détecte que ces vérifications de plage ne sont pas nécessaires et élimine le case activée du corps de la boucle, en ne le vérifiant qu’une seule fois avant le début de l’exécution de la boucle. En C#, il existe un modèle programmatique pour s’assurer que ces vérifications de plage seront éliminées : testez explicitement la longueur du tableau dans l’instruction « for ». Notez que les écarts subtils par rapport à ce modèle entraînent l’élimination de l’case activée et, dans ce cas, l’ajout d’une valeur à l’index.

Élimination de la vérification de la plage en C#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

L’optimisation est particulièrement perceptible lors de la recherche de grands tableaux de instance, car les case activée de plage de la boucle interne et externe sont éliminés.

Optimisations qui nécessitent le suivi de l’utilisation des variables

Un certain nombre d’optimisations du compilateur JIT nécessitent que le JIT effectue le suivi de l’utilisation des arguments formels et des variables locales ; par exemple, quand sont-ils utilisés pour la première fois et la dernière fois qu’ils sont utilisés dans le corps de la méthode . Dans les versions 1.0 et 1.1 du CLR, il existe une limitation de 64 sur le nombre total de variables pour lesquelles le JIT effectuera le suivi de l’utilisation. L’enregistrement est un exemple d’optimisation qui nécessite un suivi de l’utilisation. L’enregistrement est lorsque les variables sont stockées dans des registres de processeur plutôt que sur le frame de pile, par exemple, dans la RAM. L’accès aux variables inscrites est beaucoup plus rapide que si elles se trouvent sur la trame de pile, même si la variable sur l’image se trouve dans le cache du processeur. Seules 64 variables seront prises en compte pour l’enregistrement ; toutes les autres variables seront envoyées (push) sur la pile. D’autres optimisations que l’enregistrement dépendent du suivi de l’utilisation. Le nombre d’arguments formels et de locaux pour une méthode doit être maintenu en dessous de 64 pour garantir le nombre maximal d’optimisations JIT. Gardez à l’esprit que ce nombre peut changer pour les versions ultérieures du CLR.

INDICE Gardez les méthodes courtes. Il existe un certain nombre de raisons à cela, notamment l’inlining de la méthode, l’enregistrement et la durée JIT.

Autres optimisations JIT

Le compilateur JIT effectue un certain nombre d’autres optimisations : propagation constante et copie, hissage invariant de boucle, etc. Il n’existe aucun modèle de programmation explicite que vous devez utiliser pour obtenir ces optimisations ; ils sont libres.

Pourquoi ne vois-je pas ces optimisations dans Visual Studio ?

Lorsque vous utilisez Démarrer dans le menu Déboguer ou appuyez sur F5 pour démarrer une application dans Visual Studio, que vous ayez créé une version Release ou Debug, toutes les optimisations JIT sont désactivées. Lorsqu’une application managée est démarrée par un débogueur, même s’il ne s’agit pas d’une build Debug de l’application, le JIT émet des instructions x86 non optimisées. Si vous souhaitez que le JIT émette du code optimisé, démarrez l’application à partir de l’Explorer Windows ou utilisez CTRL+F5 à partir de Visual Studio. Si vous souhaitez afficher le désassemblement optimisé et le comparer au code non optimisé, vous pouvez utiliser cordbg.exe.

INDICE Utilisez cordbg.exe pour voir le désassemblement du code optimisé et non optimisé émis par le JIT. Après avoir démarré l’application avec cordbg.exe, vous pouvez définir le mode JIT en tapant ce qui suit :

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

Les JIT produisent du code débogué (non optimisé).

Types valeur

Le CLR expose deux ensembles différents de types, les types référence et les types valeur. Les types de référence sont toujours alloués sur le tas managé et sont passés par référence (comme le nom l’indique). Les types valeur sont alloués sur la pile ou inline dans le cadre d’un objet sur le tas et sont transmis par valeur par défaut, bien que vous puissiez également les transmettre par référence. Les types de valeur sont très bon marché à allouer et, en supposant qu’ils sont réduits et simples, ils sont peu coûteux à passer comme arguments. Un bon exemple d’utilisation appropriée des types valeur est un type valeur point qui contient une coordonnée x et y .

Type de valeur point

struct Point
{
   public int x;
   public int y;
   
   //
}

Les types valeur peuvent également être traités comme des objets ; par instance, les méthodes d’objet peuvent être appelées sur celles-ci, elles peuvent être converties en objet ou passées là où un objet est attendu. Toutefois, dans ce cas, le type valeur est converti en type référence, par le biais d’un processus appelé Boxing. Lorsqu’un type de valeur est Boxed, un nouvel objet est alloué sur le tas managé et la valeur est copiée dans le nouvel objet. Il s’agit d’une opération coûteuse qui peut réduire ou annuler entièrement les performances obtenues à l’aide de types valeur. Lorsque le type Boxed est implicitement ou explicitement converti en type valeur, il est Unboxed.

Type de valeur Box/Unbox

C#:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

MSIL:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // end of method Class1::BoxUnboxValueType

Si vous implémentez des types valeur personnalisés (struct en C#), vous devez envisager de remplacer la méthode ToString . Si vous ne remplacez pas cette méthode, les appels à ToString sur votre type valeur entraînent le type Boxed. Cela est également vrai pour les autres méthodes héritées de System.Object, dans ce cas, Equals, bien que ToString soit probablement la méthode la plus souvent appelée. Si vous souhaitez savoir si et quand votre type de valeur est boxé, vous pouvez rechercher l’instruction dans le box MSIL à l’aide de l’utilitaire ildasm.exe (comme dans l’extrait de code ci-dessus).

Substitution de la méthode ToString() en C# pour empêcher le boxing

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

N’oubliez pas que lors de la création de collections( par exemple, un ArrayList de float), chaque élément est boxé lorsqu’il est ajouté à la collection. Vous devez envisager d’utiliser un tableau ou de créer une classe de collection personnalisée pour votre type valeur.

Boxing implicite lors de l’utilisation de classes de collection en C#

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

Gestion des exceptions

Il est courant d’utiliser des conditions d’erreur comme contrôle de flux normal. Dans ce cas, lorsque vous tentez d’ajouter par programmation un utilisateur à un instance Active Directory, vous pouvez simplement essayer d’ajouter l’utilisateur et, si un E_ADS_OBJECT_EXISTS HRESULT est retourné, vous savez qu’il existe déjà dans l’annuaire. Vous pouvez également rechercher l’utilisateur dans l’annuaire, puis l’ajouter uniquement en cas d’échec de la recherche.

Cette utilisation des erreurs pour le contrôle de flux normal est un anti-modèle de performances dans le contexte du CLR. La gestion des erreurs dans le CLR s’effectue avec la gestion structurée des exceptions. Les exceptions gérées sont très bon marché jusqu’à ce que vous les jetiez. Dans le CLR, lorsqu’une exception est levée, une promenade de la pile est nécessaire pour trouver un gestionnaire d’exceptions approprié pour l’exception levée. La marche sur pile est une opération coûteuse. Les exceptions doivent être utilisées comme leur nom l’indique ; dans des circonstances exceptionnelles ou inattendues.

**HINT **Envisagez de retourner un résultat énuméré pour les résultats attendus, au lieu de lever une exception, pour les méthodes critiques en matière de performances.

**HINT **Il existe un certain nombre de compteurs de performances d’exceptions CLR .NET qui vous indiquent le nombre d’exceptions levées dans votre application.

**HINT **Si vous utilisez VB.NET utilisez des exceptions plutôt que On Error Goto; l’objet d’erreur représente un coût inutile.

Threading et synchronisation

Le CLR expose des fonctionnalités de thread et de synchronisation riches, notamment la possibilité de créer vos propres threads, un pool de threads et diverses primitives de synchronisation. Avant de tirer parti de la prise en charge des threads dans le CLR, vous devez examiner attentivement votre utilisation des threads. N’oubliez pas que l’ajout de threads peut en fait réduire votre débit plutôt que l’augmenter, et vous pouvez être sûr que cela augmentera votre utilisation de la mémoire. Dans les applications serveur qui vont s’exécuter sur des ordinateurs multiprocesseurs, l’ajout de threads peut améliorer considérablement le débit en parallélisant l’exécution (bien que cela dépende de la contention de verrouillage, par exemple, la sérialisation de l’exécution), et dans les applications clientes, l’ajout d’un thread pour afficher l’activité et/ou la progression peut améliorer les performances perçues (à un faible coût de débit).

Si les threads de votre application ne sont pas spécialisés pour une tâche spécifique ou si un état spécial leur est associé, vous devez envisager d’utiliser le pool de threads. Si vous avez utilisé le pool de threads Win32 dans le passé, le pool de threads du CLR vous sera très familier. Il existe une seule instance du pool de threads par processus managé. Le pool de threads est intelligent quant au nombre de threads qu’il crée et s’ajuste en fonction de la charge sur l’ordinateur.

Le threading ne peut pas être abordé sans discuter de la synchronisation ; tous les gains de débit que le multithreading peut donner à votre application peuvent être annulés par une logique de synchronisation mal écrite. La granularité des verrous peut affecter considérablement le débit global de votre application, à la fois en raison du coût de création et de gestion du verrou et du fait que les verrous peuvent potentiellement sérialiser l’exécution. Je vais utiliser l’exemple de tentative d’ajout d’un nœud à une arborescence pour illustrer ce point. Si l’arborescence doit être une structure de données partagée, pour instance, plusieurs threads doivent y accéder pendant l’exécution de l’application, et vous devez synchroniser l’accès à l’arborescence. Vous pouvez choisir de verrouiller l’arborescence entière lors de l’ajout d’un nœud, ce qui signifie que vous n’encourez que le coût de création d’un verrou unique, mais d’autres threads tentant d’accéder à l’arborescence seront probablement bloqués. Il s’agit d’un exemple de verrou grossier. Vous pouvez également verrouiller chaque nœud lorsque vous parcourez l’arborescence, ce qui signifie que vous encourez le coût de création d’un verrou par nœud, mais d’autres threads ne bloquent pas, sauf s’ils ont tenté d’accéder au nœud spécifique que vous avez verrouillé. Il s’agit d’un exemple de verrou à grain d’amende. Une granularité de verrou plus appropriée serait probablement de verrouiller uniquement la sous-arborescence sur laquelle vous travaillez. Notez que dans cet exemple, vous utiliserez probablement un verrou partagé (RWLock), car plusieurs lecteurs doivent être en mesure d’obtenir l’accès en même temps.

Le moyen le plus simple et le plus performant d’effectuer des opérations synchronisées consiste à utiliser la classe System.Threading.Interlocked. La classe Interlocked expose un certain nombre d’opérations atomiques de bas niveau : Incrémenter, Décrémenter, Exchange et ComparerExchange.

Utilisation de la classe System.Threading.Interlocked dans C#

using System.Threading;
//...
public class MyClass
{
   void MyClass() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

Le mécanisme de synchronisation le plus couramment utilisé est probablement la section Surveiller ou critique. Un verrou Monitor peut être utilisé directement ou à l’aide du lock mot clé en C#. Le lock mot clé synchronise l’accès, pour l’objet donné, à un bloc de code spécifique. Un verrou de moniteur qui est assez légèrement contesté est relativement bon marché du point de vue des performances, mais devient plus cher s’il est fortement contesté.

Le verrou C# mot clé

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

RwLock fournit un mécanisme de verrouillage partagé : par exemple, les « lecteurs » peuvent partager le verrou avec d’autres « lecteurs », mais un « enregistreur » ne le peut pas. Dans les cas où cela s’applique, le RWLock peut entraîner un meilleur débit que l’utilisation d’un moniteur, ce qui ne permettrait qu’à un seul lecteur ou enregistreur d’obtenir le verrou à la fois. L’espace de noms System.Threading inclut également la classe Mutex. Un Mutex est une primitive de synchronisation qui permet la synchronisation inter-processus. N’oubliez pas que cela est beaucoup plus coûteux qu’une section critique et ne doit être utilisé que dans le cas où la synchronisation interprocessus est requise.

Réflexion

La réflexion est un mécanisme fourni par le CLR, qui vous permet d’obtenir des informations de type par programmation au moment de l’exécution. La réflexion dépend fortement des métadonnées, qui sont incorporées dans les assemblys managés. De nombreuses API de réflexion nécessitent une recherche et une analyse des métadonnées, qui sont des opérations coûteuses.

Les API de réflexion peuvent être regroupées en trois compartiments de performances ; comparaison de types, énumération de membres et appel de membre. Chacun de ces compartiments devient de plus en plus cher. Les opérations de comparaison de types (dans ce cas, typeof en C#, GetType, is, IsInstanceOfType , etc.) sont les API de réflexion les moins chères, bien qu’elles ne soient pas bon marché. Les énumérations membres vous permettent d’inspecter par programmation les méthodes, les propriétés, les champs, les événements, les constructeurs, etc. d’une classe. Un exemple d’utilisation de ces éléments est dans les scénarios au moment de la conception, en l’occurrence l’énumération des propriétés des contrôles web douaniers pour l’explorateur de propriétés dans Visual Studio. Les API de réflexion les plus coûteuses sont celles qui vous permettent d’appeler dynamiquement les membres d’une classe, ou d’émettre dynamiquement, JIT et d’exécuter une méthode. Il existe certainement des scénarios à limite tardive où le chargement dynamique d’assemblys, les instanciations de types et les appels de méthode sont nécessaires, mais ce couplage souple nécessite un compromis explicite des performances. En général, les API de réflexion doivent être évitées dans les chemins de code respectant les performances. Notez que même si vous n’utilisez pas directement la réflexion, une API que vous utilisez peut l’utiliser. Soyez donc également conscient de l’utilisation transitive des API de réflexion.

Liaison tardive

Les appels liés en retard sont un exemple de fonctionnalité qui utilise la réflexion sous les couvertures. Les Basic.NET visuels et les JScript.NET prennent en charge les appels à liaison tardive. Pour instance, vous n’avez pas besoin de déclarer une variable avant son utilisation. Les objets à liaison tardive sont en fait d’objet de type et Reflection est utilisé pour convertir l’objet en type correct au moment de l’exécution. Un appel à liaison tardive est des ordres de grandeur plus lents qu’un appel direct. Sauf si vous avez spécifiquement besoin d’un comportement à limite tardive, vous devez éviter son utilisation dans les chemins de code critiques pour les performances.

INDICE Si vous utilisez VB.NET et que vous n’avez pas besoin explicitement d’une liaison tardive, vous pouvez indiquer au compilateur de l’interdire en incluant le Option Explicit On et Option Strict On en haut de vos fichiers sources. Ces options vous obligent à déclarer et à taper fortement vos variables et désactivent la diffusion implicite.

Sécurité

La sécurité est une partie nécessaire et intégrale du CLR, et a un coût de performances associé. Si le code est entièrement approuvé et que la stratégie de sécurité est la stratégie par défaut, la sécurité doit avoir un impact mineur sur le débit et le temps de démarrage de votre application. Le code partiellement approuvé (par exemple, le code provenant de la zone Internet ou Intranet) ou le rétrécissement du jeu d’octrois MyComputer augmente le coût de performances de la sécurité.

COM Interop et Appel de plateforme

COM Interop et Platform Invoke exposent les API natives au code managé de manière presque transparente ; l’appel de la plupart des API natives ne nécessite généralement aucun code spécial, même si cela peut nécessiter quelques clics de souris. Comme vous pouvez vous y attendre, il existe un coût associé à l’appel du code natif à partir de code managé et vice versa. Ce coût comporte deux composants : un coût fixe associé à l’exécution des transitions entre le code natif et le code managé, et un coût variable associé à tout marshaling d’arguments et de valeurs de retour qui peuvent être nécessaires. La contribution fixe au coût pour COM Interop et P/Invoke est faible : généralement moins de 50 instructions. Le coût du marshaling vers et depuis les types managés dépend de la différence entre les représentations d’un côté ou de l’autre de la limite. Les types qui nécessitent une quantité importante de transformation seront plus coûteux. Par exemple, toutes les chaînes du CLR sont des chaînes Unicode. Si vous appelez une API Win32 via P/Invoke qui attend un tableau de caractères ANSI, chaque caractère de la chaîne doit être restreint. Toutefois, si un tableau d’entiers managé est transmis à l’endroit où un tableau d’entiers natif est attendu, aucun marshaling n’est requis.

Étant donné qu’il existe un coût de performances associé à l’appel du code natif, vous devez vous assurer que le coût est justifié. Si vous envisagez d’effectuer un appel natif, assurez-vous que le travail effectué par l’appel natif justifie le coût de performances associé à l’appel : gardez les méthodes « segmentées » plutôt que « bavardes ». Une bonne façon de mesurer le coût d’un appel natif consiste à mesurer les performances d’une méthode native qui ne prend aucun argument et n’a aucune valeur de retour, puis à mesurer les performances de la méthode native que vous souhaitez appeler. La différence vous donnera une indication du coût de marshaling.

INDICE Effectuez des appels COM Interop et P/Invoke « segmentés » par opposition aux appels « bavards », et assurez-vous que le coût de l’appel est justifié par la quantité de travail effectuée par l’appel.

Notez qu’aucun modèle de threading n’est associé aux threads managés. Lorsque vous effectuez un appel COM Interop, vous devez vous assurer que le thread sur lequel l’appel va être effectué est initialisé sur le modèle de thread com correct. Cela se fait généralement à l’aide de MTAThreadAttribute et STAThreadAttribute (même si cela peut également être effectué par programmation).

Compteurs de performances

Un certain nombre de compteurs de performances Windows sont exposés pour le CLR .NET. Ces compteurs de performances doivent être l’arme de choix d’un développeur lors du diagnostic d’un problème de performances ou lors de la tentative d’identification des caractéristiques de performances d’une application managée. J’ai déjà mentionné quelques-uns des compteurs qui concernent la gestion de la mémoire et les exceptions. Il existe des compteurs de performances pour presque tous les aspects du CLR et du .NET Framework. Ces compteurs de performances sont toujours disponibles et ne sont pas invasifs; ils ont une faible surcharge et ne modifient pas les caractéristiques de performances de votre application.

Autres outils

Outre les compteurs de performances et le profileur CLR, vous voudrez utiliser un profileur conventionnel pour déterminer quelles méthodes de votre application prennent le plus de temps et sont appelées le plus souvent. Ce sont les méthodes que vous optimisez en premier. Un certain nombre de profileurs commerciaux sont disponibles qui prennent en charge le code managé, notamment DevPartner Studio Professional Edition 7.0 de Compuware et VTune™ Analyseur de performances 7.0 d’Intel®. Compuware produit également un profileur gratuit pour le code managé appelé DevPartner Profiler Community Edition.

Conclusion

Cet article commence simplement l’examen du CLR et du .NET Framework du point de vue des performances. De nombreux autres aspects de l’architecture du CLR et du .NET Framework affectent les performances de votre application. Le meilleur conseil que je peux donner à n’importe quel développeur est de ne pas faire d’hypothèses sur les performances de la plateforme que votre application cible et les API que vous utilisez. Mesurez tout !

Bonne jonglerie.

Ressources